homemaker-layout/src/homemaker/geometry.py
Bruno Postle 9e020f4769 Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)
Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
   root-division boundary (empty-string id).  Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
   delegates to Below->Rotation.  boundary_id now walks the below-chain to the
   ground-floor rotation, matching Perl exactly.

After fixes all 35 corpus files produce edge counts matching Perl oracle.

Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
  has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
  find_missing_spaces, check_adjacency, check_level_constraints,
  check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
  is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
  exact widths for 2f45907, merge_divided smoke, two-phase independence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:57:45 +01:00

254 lines
9.9 KiB
Python

"""Faithful port of Urb's top-down quad geometry (``Urb::Quad``).
A leaf has *no* intrinsic dimensions: its corners are derived by walking up to
the level root, and — for upper storeys — across to the matching quad on the
level below (wall-stacking). Every function here mirrors the corresponding Perl
method so areas computed in Python match ``urb-fitness.pl`` to floating point.
Corner ids run anti-clockwise and are offset by the node's ``rotation``. A
division is a line between a point on edge (0->1), parameter ``division[0]``, and
a point on edge (3->2), parameter ``division[1]`` — independent params allow a
skewed (non-perpendicular) cut.
"""
from __future__ import annotations
import math
from .dom import Node
Point = list[float]
# Memoisation of derived coordinates. The pull-based recursion mirrors Urb but,
# uncached, re-derives ancestor/below corners exponentially with depth. Urb
# itself caches in the node and clears via Clean_Cache(); we do the same with a
# module cache keyed by node identity. Callers that mutate divisions (the
# solver) must call clear_cache(); dom.load() clears it for a fresh tree.
_cache: dict = {}
def clear_cache() -> None:
_cache.clear()
def _interp(a: Point, b: Point, t: float) -> Point:
return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t]
def coordinate(n: Node, idx: int) -> Point:
"""Corner ``idx`` (0..3) of ``n``; mirrors ``Urb::Quad::Coordinate``."""
key = (id(n), idx)
hit = _cache.get(key)
if hit is not None:
return hit
if n.below is not None: # upper storey inherits geometry from below
result = coordinate(n.below, idx)
else:
rid = (idx + n.rotation) % 4
if n.parent is None: # level root: stored, rotation-adjusted corner
result = list(n.node[rid])
else:
p = n.parent
if n.position == "l":
result = {0: coordinate(p, 0), 1: coord_a(p), 2: coord_b(p), 3: coordinate(p, 3)}[rid]
else: # 'r'
result = {0: coord_a(p), 1: coordinate(p, 1), 2: coordinate(p, 2), 3: coord_b(p)}[rid]
_cache[key] = result
return result
def coord_a(n: Node) -> Point:
"""End 'a' of the division line; mirrors ``Urb::Quad::Coordinate_a``."""
key = (id(n), "a")
hit = _cache.get(key)
if hit is not None:
return hit
if n.below is not None and n.below.divided:
result = coord_a(n.below)
else:
result = _interp(coordinate(n, 0), coordinate(n, 1), n.division[0])
_cache[key] = result
return result
def coord_b(n: Node) -> Point:
"""End 'b' of the division line; mirrors ``Urb::Quad::Coordinate_b``."""
key = (id(n), "b")
hit = _cache.get(key)
if hit is not None:
return hit
if n.below is not None and n.below.divided:
result = coord_b(n.below)
else:
result = _interp(coordinate(n, 3), coordinate(n, 2), n.division[1])
_cache[key] = result
return result
def _dist(a: Point, b: Point) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _triangle_area(a: Point, b: Point, c: Point) -> float:
# Heron's formula, matching Urb::Math::triangle_area (always >= 0).
da, db, dc = _dist(b, c), _dist(a, c), _dist(a, b)
s = (da + db + dc) / 2
return math.sqrt(max(0.0, s * (s - da) * (s - db) * (s - dc)))
def area(n: Node) -> float:
"""Area of quad ``n``; mirrors ``Urb::Quad::Area`` (two Heron triangles)."""
c = [coordinate(n, i) for i in range(4)]
return _triangle_area(c[0], c[1], c[2]) + _triangle_area(c[0], c[2], c[3])
def edge_length(n: Node, idx: int) -> float:
"""Length of edge from corner ``idx`` to ``idx+1`` (``Urb::Quad::Length``)."""
return _dist(coordinate(n, idx), coordinate(n, (idx + 1) % 4))
# --------------------------------------------------------------------------- #
# Plot wall inset (Urb::Quad::Coordinate_Offset, used on the root in Urb::Dom).
# Positive offset moves a corner outward, negative inward. Computed per corner
# from its two neighbours along the interior-angle bisector; independent of
# rotation, so it operates directly on the stored corner order.
# --------------------------------------------------------------------------- #
def _corner_offset(prev: Point, b: Point, c: Point, offset: float) -> Point:
side_a = _dist(b, c)
side_b = _dist(prev, b)
side_c = _dist(c, prev)
cos_t = (side_a**2 + side_b**2 - side_c**2) / (2 * side_a * side_b)
theta2 = math.acos(max(-1.0, min(1.0, cos_t))) / 2
angle_new = math.atan2(c[1] - b[1], c[0] - b[0]) + theta2
scale = offset / math.sin(theta2)
return [b[0] - math.cos(angle_new) * scale, b[1] - math.sin(angle_new) * scale]
def offset_quad(corners: list[Point], offset: float) -> list[Point]:
"""Offset a 4-corner plot by ``offset`` (negative = inward)."""
n = len(corners)
return [_corner_offset(corners[(k - 1) % n], corners[k], corners[(k + 1) % n], offset)
for k in range(n)]
# --------------------------------------------------------------------------- #
# Leaf-adjacency graph (Urb::Quad::Graph + Urb::Boundary)
# --------------------------------------------------------------------------- #
def boundary_id(n: Node, edge: int) -> str:
"""Boundary id of ``edge`` (0..3) of ``n``; mirrors ``Urb::Quad::Boundary_Id``.
External plot-perimeter boundaries return one of 'a','b','c','d'.
Internal division boundaries return the id-path of the ancestor whose
division created that boundary line. The left child's rid==1 and the right
child's rid==3 both map to ``parent.id``.
Rotation is delegated to the lowest below-link (Urb::Quad::Rotation does the
same: ``return $self->Below->Rotation if defined $self->Below``). Upper-storey
nodes store their own rotation in the YAML but Urb ignores it and uses the
ground-floor counterpart's rotation instead.
"""
nb = n
while nb.below is not None:
nb = nb.below
rid = (edge + nb.rotation) % 4
if n.parent is None:
return "abcd"[rid]
if n.position == "l":
if rid == 0:
return boundary_id(n.parent, 0)
elif rid == 1:
return n.parent.id # division line shared with the r sibling
elif rid == 2:
return boundary_id(n.parent, 2)
else:
return boundary_id(n.parent, 3)
else: # position == 'r'
if rid == 0:
return boundary_id(n.parent, 0)
elif rid == 1:
return boundary_id(n.parent, 1)
elif rid == 2:
return boundary_id(n.parent, 2)
else:
return n.parent.id # division line shared with the l sibling
def centroid(n: Node) -> Point:
"""Average of the four corners; mirrors ``Urb::Quad::Centroid``."""
c = [coordinate(n, i) for i in range(4)]
return [(c[0][0] + c[1][0] + c[2][0] + c[3][0]) / 4,
(c[0][1] + c[1][1] + c[2][1] + c[3][1]) / 4]
def _edge_overlap(a: Node, edge_a: int, b: Node, edge_b: int) -> float:
"""Shared boundary width between two leaves; faithful port of
``Urb::Boundary::Overlap``.
The formula is a 1-D overlap estimator: given the four pairwise distances
between the two edge endpoints, ``len_a + len_b - max_dist`` (with clamped
special cases). This mirrors the Perl exactly, including the behaviour for
below-inherited nodes where the two edges are not physically collinear but
share the same tree-structure boundary — the formula still returns a
positive overlap that NetworkX uses to add an adjacency edge.
Do NOT replace with Shapely's ``intersection().length``: that returns 0 for
non-collinear segments and would miss valid tree-level adjacencies in
multi-storey buildings where below-inheritance shifts physical coordinates.
"""
p_a0 = coordinate(a, edge_a)
p_a1 = coordinate(a, (edge_a + 1) % 4)
p_b0 = coordinate(b, edge_b)
p_b1 = coordinate(b, (edge_b + 1) % 4)
len_a = _dist(p_a0, p_a1)
len_b = _dist(p_b0, p_b1)
max_dist = max(_dist(p_a0, p_b0), _dist(p_a0, p_b1), _dist(p_a1, p_b0), _dist(p_a1, p_b1))
if max_dist <= len_b:
return len_a
if max_dist <= len_a:
return len_b
return max(0.0, len_a + len_b - max_dist)
def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
"""Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``.
Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose
edges carry ``width`` (shared boundary metres) and ``weight`` (centroid
distance metres). Edges with width < ``door_width`` (Urb default 1.2 m)
are excluded. External plot-perimeter boundaries ('a','b','c','d') are
never edges. A single-leaf storey gets one isolated vertex.
"""
import networkx as nx
from collections import defaultdict
_external = frozenset("abcd") # single-char external boundary ids
leaves = level_root.leaves()
# Group (leaf, edge) pairs by shared boundary id.
# Use frozenset membership, NOT 'bid not in "abcd"' — the latter is a
# substring check and silently drops the root-division boundary ('').
groups: dict[str, list[tuple[Node, int]]] = defaultdict(list)
for leaf in leaves:
for edge in range(4):
bid = boundary_id(leaf, edge)
if bid not in _external:
groups[bid].append((leaf, edge))
G: nx.Graph = nx.Graph()
for leaf in leaves:
G.add_node(leaf)
for contributors in groups.values():
for i in range(len(contributors)):
for j in range(i + 1, len(contributors)):
a, edge_a = contributors[i]
b, edge_b = contributors[j]
if a is b:
continue
width = _edge_overlap(a, edge_a, b, edge_b)
if width >= door_width:
if not G.has_edge(a, b) or G[a][b]["width"] < width:
dist = _dist(centroid(a), centroid(b))
G.add_edge(a, b, weight=dist, width=width)
return G