Algorithm Search⌘K

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
bsp_tree.pyRawGitHub
# 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

Read the full page about Binary space partitioning tree