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
# 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