Algorithm Search⌘K

Loose octree in Python

Octree whose cells overlap by a margin so each object fits in one node, which eases dynamic updates.

File
loose_octree.py, 187 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
loose_octree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/loose-octree/. The authors are listed in prov/7DA53Q.jsonl.

from typing import Optional

Vec = tuple[int, int, int]
Box = tuple[Vec, Vec]  # closed box [lo, hi] with integer corners, lo <= hi on each axis


class Node:
    """A cell of side size with corner (i*size, j*size, k*size) and looseness 2.

    The loose cell is the cell grown by size/2 on every side, so it has side
    2*size. children[o] is the child for octant o = bx + 2*by + 4*bz, or None.
    """

    __slots__ = ("index", "size", "boxes", "children")

    def __init__(self, index: Vec, size: int) -> None:
        self.index, self.size = index, size
        self.boxes: list[Box] = []
        self.children: list[Optional[Node]] = [None] * 8

    def loose_meets(self, q: Box) -> bool:
        """Whether the closed loose cell meets the closed box q, in doubled coordinates."""
        s = self.size
        for a in range(3):
            lo2 = 2 * self.index[a] * s - s
            hi2 = 2 * (self.index[a] + 1) * s + s
            if 2 * q[1][a] < lo2 or 2 * q[0][a] > hi2:
                return False
        return True

    def loose_holds(self, b: Box) -> bool:
        s = self.size
        for a in range(3):
            if 2 * b[0][a] < 2 * self.index[a] * s - s or 2 * b[1][a] > 2 * (self.index[a] + 1) * s + s:
                return False
        return True


def meets(a: Box, b: Box) -> bool:
    return all(a[0][i] <= b[1][i] and b[0][i] <= a[1][i] for i in range(3))


class LooseOctree:
    """A loose octree with looseness 2 over [0, 2^depth)^3.

    Invariant: a box with largest extent e is stored at the depth whose cell
    side s is the least power of two with s >= max(e, 1), in the cell that holds
    its center. Then the box lies in the loose cell of its node. A node exists
    only if it or a node below it stores a box. The node of a box is found from
    its size and center alone, with no search.
    """

    def __init__(self, depth: int) -> None:
        if depth < 0:
            raise ValueError("need depth >= 0")
        self.depth = depth
        self.root = Node((0, 0, 0), 1 << depth)
        self.size = 0

    def __len__(self) -> int:
        return self.size

    def _norm(self, b: Box) -> Box:
        lo = tuple(int(v) for v in b[0])
        hi = tuple(int(v) for v in b[1])
        side = 1 << self.depth
        if len(lo) != 3 or len(hi) != 3 or not all(0 <= lo[i] <= hi[i] < side for i in range(3)):
            raise ValueError("need 0 <= lo <= hi < 2^depth on each axis")
        return lo, hi  # type: ignore[return-value]

    def place(self, b: Box) -> tuple[int, Vec]:
        """The depth and cell index of the node of b. O(1) word operations plus a bit length."""
        lo, hi = self._norm(b)
        e = max(hi[i] - lo[i] for i in range(3))  # @line L1
        s = 1
        while s < e:  # @line L2
            s *= 2
        t = self.depth - (s.bit_length() - 1)
        index = tuple((lo[i] + hi[i]) // (2 * s) for i in range(3))  # @line L3
        return t, index  # type: ignore[return-value]

    def _path(self, t: int, index: Vec, create: bool) -> tuple[Optional[Node], list[tuple[Node, int]]]:
        u = self.root
        path: list[tuple[Node, int]] = []
        for d in range(t):  # @line L4
            bit = t - d - 1
            o = ((index[0] >> bit) & 1) | (((index[1] >> bit) & 1) << 1) | (((index[2] >> bit) & 1) << 2)
            c = u.children[o]
            if c is None:
                if not create:
                    return None, path
                h = u.size // 2
                c = Node(tuple(2 * u.index[i] + ((o >> i) & 1) for i in range(3)), h)  # type: ignore[arg-type]
                u.children[o] = c
            path.append((u, o))
            u = c
        return u, path

    def insert(self, b: Box) -> bool:
        """Store b. Returns False if it was stored. O(depth) to make the path."""
        b = self._norm(b)
        t, index = self.place(b)
        u, _ = self._path(t, index, True)
        assert u is not None
        if b in u.boxes:
            return False
        u.boxes.append(b)  # @line L5
        self.size += 1
        return True

    def query(self, q: Box) -> list[Box]:
        """All stored boxes that meet the closed box q."""
        q = self._norm(q)
        out: list[Box] = []
        stack = [self.root]
        while stack:  # @line L6
            u = stack.pop()
            if not u.loose_meets(q):  # @line L7
                continue
            for b in u.boxes:  # @line L8
                if meets(b, q):
                    out.append(b)  # @line L9
            for c in u.children:  # @line L10
                if c is not None:
                    stack.append(c)
        return out

    def delete(self, b: Box) -> bool:
        """Remove b. Returns False if it was not stored. Removes the nodes left empty."""
        b = self._norm(b)
        t, index = self.place(b)
        u, path = self._path(t, index, False)  # @line L11
        if u is None or b not in u.boxes:  # @line L12
            return False
        u.boxes.remove(b)
        self.size -= 1
        while path and not u.boxes and all(c is None for c in u.children):  # @line L13
            parent, o = path.pop()
            parent.children[o] = None  # @line L14
            u = parent
        return True

    def move(self, b: Box, nb: Box) -> bool:
        """Replace b by nb, as when an object moves. Returns False if b was not stored."""
        if not self.delete(b):
            return False
        self.insert(nb)
        return True

    def boxes(self) -> list[Box]:
        out: list[Box] = []
        stack = [self.root]
        while stack:
            u = stack.pop()
            out.extend(u.boxes)
            stack.extend(c for c in u.children if c is not None)
        return out

    def node_count(self) -> int:
        n, stack = 0, [self.root]
        while stack:
            u = stack.pop()
            n += 1
            stack.extend(c for c in u.children if c is not None)
        return n

    def check_invariant(self) -> None:
        seen: set[Box] = set()
        stack: list[tuple[Node, int]] = [(self.root, 0)]
        while stack:
            u, d = stack.pop()
            assert u.size == 1 << (self.depth - d), "wrong side for the depth"
            for b in u.boxes:
                assert b not in seen, "box stored twice"
                seen.add(b)
                assert self.place(b) == (d, u.index), "box at the wrong node"
                assert u.loose_holds(b), "box outside the loose cell"
            for o, c in enumerate(u.children):
                if c is None:
                    continue
                assert c.index == tuple(2 * u.index[i] + ((o >> i) & 1) for i in range(3)), "child is not an octant"
                assert c.boxes or any(cc is not None for cc in c.children), "empty node"
                stack.append((c, d + 1))
        assert len(seen) == self.size, "size is wrong"
test_loose_octree.py 95 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/loose-octree/. The authors are listed in prov/7DA53Q.jsonl.

import random

import pytest

from loose_octree import LooseOctree, Node, meets


def shape(u: Node) -> object:
    return (sorted(u.boxes), {o: shape(c) for o, c in enumerate(u.children) if c is not None})


A = ((1, 1, 1), (2, 2, 2))
B = ((3, 3, 0), (5, 5, 0))
C = ((0, 0, 0), (7, 3, 3))
D = ((6, 1, 6), (6, 1, 6))


def test_page_example() -> None:
    t = LooseOctree(3)
    assert t.place(A) == (3, (1, 1, 1))
    assert t.place(B) == (2, (2, 2, 0))
    assert t.place(C) == (0, (0, 0, 0))
    assert t.place(D) == (3, (6, 1, 6))
    for b in [A, B, C, D]:
        assert t.insert(b)
        t.check_invariant()
    assert not t.insert(B)
    assert t.node_count() == 9
    assert t.root.boxes == [C]
    assert t.root.children[3] is not None and t.root.children[3].children[0].boxes == [B]
    assert sorted(t.query(((3, 3, 0), (4, 4, 1)))) == sorted([B, C])
    assert t.delete(A) and not t.delete(A)
    t.check_invariant()
    assert t.node_count() == 6
    assert t.move(D, ((2, 2, 2), (2, 2, 2)))
    t.check_invariant()
    assert t.node_count() == 6 and t.place(((2, 2, 2), (2, 2, 2))) == (3, (2, 2, 2))


def test_edges() -> None:
    t = LooseOctree(0)
    p = ((0, 0, 0), (0, 0, 0))
    assert t.insert(p) and t.root.boxes == [p] and t.query(p) == [p]
    assert t.delete(p) and len(t) == 0 and not t.move(p, p)
    with pytest.raises(ValueError):
        t.insert(((0, 0, 0), (1, 0, 0)))
    with pytest.raises(ValueError):
        LooseOctree(-1)
    u = LooseOctree(10)
    small = ((511, 0, 0), (512, 1, 1))
    assert u.place(small) == (10, (511, 0, 0))
    assert u.insert(small) and u.node_count() == 11


def test_random_against_oracle() -> None:
    rng = random.Random(20261104)
    for case in range(600):
        depth = rng.randint(0, 5)
        side = 1 << depth
        t = LooseOctree(depth)
        live: set = set()

        def rand_box() -> tuple:
            e = rng.randint(0, max(1, side // rng.choice([1, 2, 4, 8])) - 1)
            lo = tuple(rng.randrange(side - e) for _ in range(3))
            hi = tuple(min(side - 1, lo[i] + rng.randint(0, e)) for i in range(3))
            return (lo, hi)

        for _ in range(rng.randint(1, 30)):
            if live and rng.random() < 0.3:
                d = rng.choice(sorted(live))
                if rng.random() < 0.5:
                    assert t.delete(d)
                    live.discard(d)
                else:
                    nb = rand_box()
                    if nb not in live:
                        assert t.move(d, nb)
                        live.discard(d)
                        live.add(nb)
            b = rand_box()
            assert t.insert(b) == (b not in live)
            live.add(b)
        t.check_invariant()
        assert sorted(t.boxes()) == sorted(live)
        u = LooseOctree(depth)
        for b in rng.sample(sorted(live), len(live)):
            u.insert(b)
        assert shape(u.root) == shape(t.root)
        for _ in range(3):
            q = rand_box()
            assert sorted(t.query(q)) == sorted(b for b in live if meets(b, q))

What the next level needs

  • the tests did not run in this build

Read the full page about Loose octree