2026-06-10 20:50:13 +01:00
|
|
|
"""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]
|
|
|
|
|
|
2026-06-10 21:49:31 +01:00
|
|
|
# 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()
|
|
|
|
|
|
2026-06-10 20:50:13 +01:00
|
|
|
|
|
|
|
|
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``."""
|
2026-06-10 21:49:31 +01:00
|
|
|
key = (id(n), idx)
|
|
|
|
|
hit = _cache.get(key)
|
|
|
|
|
if hit is not None:
|
|
|
|
|
return hit
|
2026-06-10 20:50:13 +01:00
|
|
|
if n.below is not None: # upper storey inherits geometry from below
|
2026-06-10 21:49:31 +01:00
|
|
|
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
|
2026-06-10 20:50:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def coord_a(n: Node) -> Point:
|
|
|
|
|
"""End 'a' of the division line; mirrors ``Urb::Quad::Coordinate_a``."""
|
2026-06-10 21:49:31 +01:00
|
|
|
key = (id(n), "a")
|
|
|
|
|
hit = _cache.get(key)
|
|
|
|
|
if hit is not None:
|
|
|
|
|
return hit
|
2026-06-10 20:50:13 +01:00
|
|
|
if n.below is not None and n.below.divided:
|
2026-06-10 21:49:31 +01:00
|
|
|
result = coord_a(n.below)
|
|
|
|
|
else:
|
|
|
|
|
result = _interp(coordinate(n, 0), coordinate(n, 1), n.division[0])
|
|
|
|
|
_cache[key] = result
|
|
|
|
|
return result
|
2026-06-10 20:50:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def coord_b(n: Node) -> Point:
|
|
|
|
|
"""End 'b' of the division line; mirrors ``Urb::Quad::Coordinate_b``."""
|
2026-06-10 21:49:31 +01:00
|
|
|
key = (id(n), "b")
|
|
|
|
|
hit = _cache.get(key)
|
|
|
|
|
if hit is not None:
|
|
|
|
|
return hit
|
2026-06-10 20:50:13 +01:00
|
|
|
if n.below is not None and n.below.divided:
|
2026-06-10 21:49:31 +01:00
|
|
|
result = coord_b(n.below)
|
|
|
|
|
else:
|
|
|
|
|
result = _interp(coordinate(n, 3), coordinate(n, 2), n.division[1])
|
|
|
|
|
_cache[key] = result
|
|
|
|
|
return result
|
2026-06-10 20:50:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dist(a: Point, b: Point) -> float:
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
# NOT math.hypot: Urb::Math::distance_2d is sqrt(dx**2 + dy**2) and the
|
|
|
|
|
# two differ in the last ULP. Boundary overlap tests feed the difference
|
|
|
|
|
# of near-equal lengths into a > 0 predicate (Urb::Boundary::Overlap), so
|
|
|
|
|
# a 1-ULP deviation flips adjacency decisions on exactly-touching quads.
|
|
|
|
|
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
|
2026-06-10 20:50:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
def angle(n: Node, idx: int) -> float:
|
|
|
|
|
"""Interior angle at corner ``idx`` in radians (``Urb::Quad::Angle``,
|
|
|
|
|
cosine rule). Clamped acos argument — Perl leaves it unclamped but only a
|
|
|
|
|
degenerate quad would push it out of [-1, 1]."""
|
|
|
|
|
a = edge_length(n, idx)
|
|
|
|
|
b = edge_length(n, (idx + 3) % 4)
|
|
|
|
|
c = _dist(coordinate(n, (idx + 1) % 4), coordinate(n, (idx + 3) % 4))
|
|
|
|
|
return math.acos(max(-1.0, min(1.0, (a * a + b * b - c * c) / (2 * a * b))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def aspect(n: Node) -> float:
|
|
|
|
|
"""Plan aspect ratio, always >= 1 (``Urb::Quad::Aspect``)."""
|
|
|
|
|
asp = (edge_length(n, 0) + edge_length(n, 2)) / (edge_length(n, 1) + edge_length(n, 3))
|
|
|
|
|
if 0 < asp < 1:
|
|
|
|
|
asp = 1 / asp
|
|
|
|
|
return asp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def length_narrowest(n: Node) -> float:
|
|
|
|
|
"""Shortest of the four edge lengths (``Urb::Quad::Length_Narrowest``)."""
|
|
|
|
|
return min(edge_length(n, i) for i in range(4))
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 20:50:13 +01:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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)]
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
_EXTERNAL = frozenset("abcd") # single-char external boundary ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def boundary_groups(level_root: Node) -> dict[str, list[tuple[Node, int]]]:
|
|
|
|
|
"""Group (leaf, edge) pairs by shared internal boundary id; the data half
|
|
|
|
|
of ``Urb::Quad::Calc_Boundaries`` (external 'a'-'d' boundaries excluded —
|
|
|
|
|
Urb's ``Boundary::Overlap`` returns 0 for them anyway).
|
|
|
|
|
|
|
|
|
|
Membership test uses the frozenset, NOT ``bid not in "abcd"`` — the latter
|
|
|
|
|
is a substring check and silently drops the root-division boundary ('').
|
|
|
|
|
"""
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
|
|
|
|
groups: dict[str, list[tuple[Node, int]]] = defaultdict(list)
|
|
|
|
|
for leaf in level_root.leaves():
|
|
|
|
|
for edge in range(4):
|
|
|
|
|
bid = boundary_id(leaf, edge)
|
|
|
|
|
if bid not in _EXTERNAL:
|
|
|
|
|
groups[bid].append((leaf, edge))
|
|
|
|
|
return groups
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def boundary_pair_overlap(contributors: list[tuple[Node, int]], a: Node, b: Node) -> float:
|
|
|
|
|
"""Overlap of quads ``a`` and ``b`` on one boundary's contributor list;
|
|
|
|
|
mirrors ``Urb::Boundary::Overlap`` (last matching edge wins, as in the
|
|
|
|
|
Perl loop). Returns 0.0 if either quad has no edge on this boundary.
|
|
|
|
|
"""
|
|
|
|
|
edge_a = edge_b = None
|
|
|
|
|
for leaf, edge in contributors:
|
|
|
|
|
if leaf is a:
|
|
|
|
|
edge_a = edge
|
|
|
|
|
if leaf is b:
|
|
|
|
|
edge_b = edge
|
|
|
|
|
if edge_a is None or edge_b is None:
|
|
|
|
|
return 0.0
|
|
|
|
|
return _edge_overlap(a, edge_a, b, edge_b)
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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
|
|
|
|
|
|
|
|
|
|
leaves = level_root.leaves()
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
groups = boundary_groups(level_root)
|
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
|
|
|
|
|
|
|
|
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
|