homemaker-layout/src/homemaker/geometry.py
Bruno Postle 497d05c343 Add programme/solver/oracle + sizing experiments (negative result)
Adds the bottom-up ratio solver, programme parser, Perl-oracle bridge,
and two experiments. Headline finding: the "isolated size solver on a
frozen topology" hypothesis is NOT validated.

- resolve_ratios.py: re-solving candidate-002 from programme targets
  recovers areas accurately but scores below the original (introduces
  width/perpendicular/crinkliness failures the area objective ignores).
- refine_sweep.py: warm-start refine of all 34 evolved candidates
  regresses 34/34 (fails 124->297 perpendicular-tied; 124->626 area-only
  with free skew). Moving cuts to fix room area breaks the coupled
  adjacency/access/shape constraints those designs balanced.

Conclusion: sizing is not separable from the rest of Urb's fitness;
a geometry inner loop must optimise the full objective, not an area proxy.
Geometry port remains validated byte-identical to Urb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:49:31 +01:00

131 lines
4.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)]