Binary space partitioning tree in Python
Tree that recursively splits space by hyperplanes, used for visibility ordering and ray tracing.
- File
bsp_tree.py, 198 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bsp-tree/. The authors are listed in prov/MSZB1N.jsonl.
import random
from fractions import Fraction
from typing import Optional, Sequence
Num = Fraction
Point = tuple[Fraction, Fraction]
Segment = tuple[Point, Point]
def _pt(p: Sequence[object]) -> Point:
return (Fraction(p[0]), Fraction(p[1])) # type: ignore[arg-type]
def side(seg: Segment, p: Point) -> Fraction:
"""Twice the signed area of (seg[0], seg[1], p): > 0 left (front), < 0 right (back)."""
(ax, ay), (bx, by) = seg
return (bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax)
class Node:
"""A node of the BSP tree: the line through `line`, the segments on it,
and the subtrees for the open front (left) and back (right) half-planes."""
__slots__ = ("line", "on", "front", "back")
def __init__(self, line: Segment) -> None:
self.line = line
self.on: list[Segment] = [line]
self.front: Optional[Node] = None
self.back: Optional[Node] = None
def split(line: Segment, s: Segment) -> tuple[Optional[Segment], Optional[Segment], bool]:
"""Classify s against the line: (front part, back part, lies on the line). O(1)."""
p, q = s
sp, sq = side(line, p), side(line, q)
if sp == 0 and sq == 0:
return None, None, True
if sp >= 0 and sq >= 0:
return s, None, False
if sp <= 0 and sq <= 0:
return None, s, False
t = sp / (sp - sq) # the parameter where s crosses the line, 0 < t < 1
m = (p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]))
if sp > 0:
return (p, m), (m, q), False
return (m, q), (p, m), False
class BspTree:
"""A binary space partition of a set of segments in the plane (an autopartition).
Each node splits by the line through one of the input segments. Invariant:
every fragment in the front subtree of a node lies in the closed front
half-plane of the node's line, every fragment in the back subtree in the
closed back half-plane, and the fragments of the node itself lie on the line.
Coordinates are exact rationals.
"""
def __init__(self, segments: Sequence[Sequence[Sequence[object]]] = (), seed: Optional[int] = None) -> None:
"""Build the tree, using the segments in the given order, or in a random
order when seed is given (expected O(n log n) fragments). O(n^2) worst time."""
segs = [(_pt(s[0]), _pt(s[1])) for s in segments]
for a, b in segs:
if a == b:
raise ValueError("a segment has length zero")
if seed is not None:
random.Random(seed).shuffle(segs)
self.root = self._build(segs)
@staticmethod
def _build(segs: list[Segment]) -> Optional[Node]:
root: Optional[Node] = None
# Each task: (list of segments, parent node, True if front child).
stack: list[tuple[list[Segment], Optional[Node], bool]] = [(segs, None, True)]
while stack:
group, parent, is_front = stack.pop()
if not group: # @line L1
continue
s = group[0] # @line L2
t = Node(s) # @line L3
front: list[Segment] = [] # @line L4
back: list[Segment] = []
for r in group[1:]: # @line L5
f, b, on = split(s, r)
if on: # @line L6
t.on.append(r)
elif b is None: # @line L7
front.append(r)
elif f is None:
back.append(r)
else: # @line L8: r crosses the line and is cut in two
front.append(f)
back.append(b)
if parent is None:
root = t
elif is_front:
parent.front = t
else:
parent.back = t
stack.append((back, t, False)) # @line L9
stack.append((front, t, True))
return root
def insert(self, seg: Sequence[Sequence[object]]) -> None:
"""Filter a segment down the tree, cutting it at node lines. O(size of the tree) worst."""
s0 = (_pt(seg[0]), _pt(seg[1]))
if s0[0] == s0[1]:
raise ValueError("a segment has length zero")
if self.root is None:
self.root = Node(s0)
return
stack: list[tuple[Segment, Node]] = [(s0, self.root)]
while stack:
s, t = stack.pop()
f, b, on = split(t.line, s)
if on:
t.on.append(s)
continue
for part, is_front in ((f, True), (b, False)):
if part is None:
continue
child = t.front if is_front else t.back
if child is None:
n = Node(part)
if is_front:
t.front = n
else:
t.back = n
else:
stack.append((part, child))
def back_to_front(self, v: Sequence[object]) -> list[Segment]:
"""The fragments in an order in which no fragment hides a later one from v
(the painter's order). O(size of the tree)."""
eye = _pt(v)
out: list[Segment] = []
# A task is a node to expand, or a list of fragments to output.
stack: list[object] = [self.root]
while stack:
x = stack.pop()
if x is None: # @line L10
continue
if isinstance(x, list):
out.extend(x)
continue
assert isinstance(x, Node)
d = side(x.line, eye)
if d > 0: # @line L11: v is in front: back first, then the node, then front
stack.extend([x.front, x.on, x.back])
elif d < 0: # @line L12: v is behind: front first
stack.extend([x.back, x.on, x.front])
else: # @line L13: v is on the line; the node's segments are seen edge on
stack.extend([x.back, x.on, x.front])
return out
def fragments(self) -> list[Segment]:
out: list[Segment] = []
stack = [self.root]
while stack:
t = stack.pop()
if t is not None:
out.extend(t.on)
stack.extend([t.front, t.back])
return out
def size(self) -> int:
"""The number of fragments stored."""
return len(self.fragments())
def height(self) -> int:
best = 0
stack = [(self.root, 1)]
while stack:
t, d = stack.pop()
if t is not None:
best = max(best, d)
stack.extend([(t.front, d + 1), (t.back, d + 1)])
return best
def check_invariant(self) -> None:
"""Check that each fragment lies on the correct side of each ancestor line."""
stack: list[tuple[Optional[Node], list[tuple[Segment, int]]]] = [(self.root, [])]
while stack:
t, cons = stack.pop()
if t is None:
continue
for s in t.on:
assert s[0] != s[1]
assert side(t.line, s[0]) == 0 and side(t.line, s[1]) == 0
for line, sign in cons:
for p in s:
assert side(line, p) * sign >= 0
stack.append((t.front, cons + [(t.line, 1)]))
stack.append((t.back, cons + [(t.line, -1)]))
test_bsp_tree.py 132 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bsp-tree/. The authors are listed in prov/MSZB1N.jsonl.
import random
from fractions import Fraction as F
from typing import Optional
import pytest
from bsp_tree import BspTree, Node, Point, Segment, side
def fmt(s: Segment) -> str:
return "(%s,%s)-(%s,%s)" % (s[0][0], s[0][1], s[1][0], s[1][1])
def shape(t: Optional[Node]) -> object:
if t is None:
return None
return ([fmt(s) for s in t.on], shape(t.front), shape(t.back))
A = ((0, 0), (4, 0))
B = ((2, -2), (2, 2))
C = ((5, -1), (5, 3))
D = ((-1, 3), (3, 4))
def test_page_example() -> None:
t = BspTree([A, B, C, D])
t.check_invariant()
assert shape(t.root) == (
["(0,0)-(4,0)"],
(["(2,0)-(2,2)"], (["(-1,3)-(2,15/4)"], None, None),
(["(5,0)-(5,3)"], (["(2,15/4)-(3,4)"], None, None), None)),
(["(2,-2)-(2,0)"], None, (["(5,-1)-(5,0)"], None, None)),
)
assert t.size() == 7 and t.height() == 4
assert [fmt(s) for s in t.back_to_front((3, -3))] == [
"(-1,3)-(2,15/4)", "(2,0)-(2,2)", "(5,0)-(5,3)", "(2,15/4)-(3,4)",
"(0,0)-(4,0)", "(2,-2)-(2,0)", "(5,-1)-(5,0)",
]
def test_edges() -> None:
t = BspTree([])
assert t.root is None and t.back_to_front((0, 0)) == []
with pytest.raises(ValueError):
BspTree([((1, 1), (1, 1))])
t.insert(((0, 0), (1, 0)))
t.insert(((2, 0), (3, 0))) # on the same line
t.check_invariant()
assert t.size() == 2 and t.height() == 1
assert len(t.back_to_front((5, 0))) == 2
def test_chain_is_deep_but_iterative() -> None:
n = 200
segs = [((0, i), (1, i)) for i in range(n)]
t = BspTree(segs)
assert t.height() == n and t.size() == n
order = t.back_to_front((F(1, 2), -1))
assert [s[0][1] for s in order] == list(range(n - 1, -1, -1))
def _cross(o: Point, a: Point, b: Point) -> F:
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
def _proper(p: Point, q: Point, r: Point, s: Point) -> bool:
"""Whether segments pq and rs meet at a point interior to both."""
d1, d2 = _cross(p, q, r), _cross(p, q, s)
d3, d4 = _cross(r, s, p), _cross(r, s, q)
return d1 * d2 < 0 and d3 * d4 < 0
def _touch(p: Point, q: Point, r: Point, s: Point) -> bool:
"""Whether closed segments pq and rs meet."""
def on(a: Point, b: Point, c: Point) -> bool:
return (_cross(a, b, c) == 0 and min(a[0], b[0]) <= c[0] <= max(a[0], b[0])
and min(a[1], b[1]) <= c[1] <= max(a[1], b[1]))
d1, d2 = _cross(p, q, r), _cross(p, q, s)
d3, d4 = _cross(r, s, p), _cross(r, s, q)
if d1 * d2 < 0 and d3 * d4 < 0:
return True
return on(p, q, r) or on(p, q, s) or on(r, s, p) or on(r, s, q)
def _random_scene(rng: random.Random, n: int) -> list[Segment]:
segs: list[Segment] = []
while len(segs) < n:
p = (F(rng.randrange(21)), F(rng.randrange(21)))
q = (F(rng.randrange(21)), F(rng.randrange(21)))
if p == q or any(_touch(p, q, a, b) for a, b in segs):
continue
segs.append((p, q))
return segs
def _param(s: Segment, p: Point) -> F:
(ax, ay), (bx, by) = s
return (p[0] - ax) / (bx - ax) if bx != ax else (p[1] - ay) / (by - ay)
def test_random_against_oracle() -> None:
rng = random.Random(1980)
for case in range(500):
segs = _random_scene(rng, rng.randrange(1, 9))
t = BspTree(segs, seed=case if case % 2 else None)
t.check_invariant()
frags = t.fragments()
# The fragments of each segment tile it exactly.
for s in segs:
parts = []
for f in frags:
if side(s, f[0]) == 0 and side(s, f[1]) == 0:
a, b = sorted((_param(s, f[0]), _param(s, f[1])))
if a >= 0 and b <= 1:
parts.append((a, b))
parts.sort()
assert parts[0][0] == 0 and parts[-1][1] == 1
assert all(parts[i][1] == parts[i + 1][0] for i in range(len(parts) - 1))
# Painter's order: no earlier fragment hides a point of a later one.
v = (F(rng.randrange(-5, 26)), F(rng.randrange(-5, 26)))
order = t.back_to_front(v)
assert sorted(map(fmt, order)) == sorted(map(fmt, frags))
for i in range(len(order)):
for j in range(i + 1, len(order)):
p, q = order[j]
for w in (F(1, 4), F(1, 2), F(3, 4)):
y = (p[0] + w * (q[0] - p[0]), p[1] + w * (q[1] - p[1]))
assert not _proper(v, y, order[i][0], order[i][1])
What the next level needs
- the tests did not run in this build