Composer half of the ground-truth bead: examples/harbor-house/drawings/ harbor-house 1.svg turned out to be a Bonsai/Blender render of 3m.dom's own IFC (32 IfcSpace paths == 3m.dom's upper-storey leaf count), not a human trace, so no usable reference exists yet -- this builds and tests the pipeline that will consume one once traced. compose.py parses storey-N Inkscape layers of cut-lines + labels against a boundary-stub .dom (plot/ height/elevation only, no room shapes to keep aligned across storeys) and recursively detects guillotine cuts, mirroring geometry.py's own division-line algebra; non-slicible regions and label mismatches are reported by location rather than guessed at. homemaker-compose CLI added. Renamed dom._link to public dom.link since compose.py needs to re-link from outside dom.py. Full design writeup in DESIGN.md sec 37.3; actual human tracing of harbor-house/programme-house is tracked as follow-up under 2g7.1, still open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
408 lines
16 KiB
Python
408 lines
16 KiB
Python
"""SVG trace + boundary ``.dom`` -> full slicing-tree ``.dom`` (homemaker-py-2g7.1).
|
|
|
|
Ground-truth problem: every non-empty ``.dom`` in this repo is evolution
|
|
output, so nobody has ever measured what a *known-good human design* scores
|
|
under ``fitness.py``. This module builds one from a hand trace instead of
|
|
requiring a hand-authored YAML tree (impractical) or hand-drawn room shapes
|
|
(don't line up between storeys or with each other on a rough sketch).
|
|
|
|
Trace format (see ``DESIGN.md`` sec 37.x for the full write-up):
|
|
|
|
* A "boundary" ``.dom`` file supplies everything geometric that ISN'T a
|
|
guillotine-cut topology: the plot outline (level 0's ``node``), per-storey
|
|
``height``/``elevation``, ``wall_inner``/``wall_outer``, ``perimeter``.
|
|
It has no ``division``/``type`` — ``dom.load()`` parses it as-is. Urb's own
|
|
model requires every storey to share the ground-floor footprint exactly
|
|
(``geometry.coordinate`` always derives an upper level root from the level
|
|
below), so there is exactly one outline, not one per storey.
|
|
* An SVG file supplies the topology: one Inkscape layer per storey, named
|
|
``storey-0``, ``storey-1``, ... Each layer holds only straight open cut
|
|
lines (``<line>`` or a 2-point ``<path d="M.. L..">``) and text labels
|
|
(room type codes). No closed room shapes are ever drawn — a room's outline
|
|
is *derived*, not traced, which is what makes this robust to a rough
|
|
sketch (lines that overlap slightly, undershoot a corner, or don't quite
|
|
align between storeys all fall within a tolerance).
|
|
|
|
Composition works by mirroring ``geometry.py``'s own division-line algebra:
|
|
starting from the plot quad, recursively look for a traced line that spans
|
|
the current quad edge-to-edge (a "guillotine cut" test with a snapping
|
|
tolerance), split into two child quads via the exact corner formulas
|
|
``geometry.coordinate``/``coord_a``/``coord_b`` use, and recurse. A region
|
|
with interior lines but none spanning it is not representable as a guillotine
|
|
partition — reported via ``NonSlicible`` naming the region, per the bead's
|
|
acceptance criteria, rather than silently guessed at.
|
|
|
|
Traced cut *positions* only need to be roughly right: ``refine()`` calls
|
|
``solver.solve_ratios(..., strip=False)`` to slide them to the best fit for
|
|
the programme's target dimensions afterward, keeping the traced topology
|
|
fixed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
|
|
from .dom import Node, levels, link
|
|
|
|
Point = tuple[float, float]
|
|
|
|
_SVG_NS = "http://www.w3.org/2000/svg"
|
|
_INK_NS = "http://www.inkscape.org/namespaces/inkscape"
|
|
_STOREY_RE = re.compile(r"^storey-(\d+)$")
|
|
|
|
|
|
def _qn(ns: str, tag: str) -> str:
|
|
return f"{{{ns}}}{tag}"
|
|
|
|
|
|
@dataclass
|
|
class StoreyTrace:
|
|
lines: list[tuple[Point, Point]] = field(default_factory=list)
|
|
labels: list[tuple[Point, str]] = field(default_factory=list)
|
|
|
|
|
|
class NonSlicible(Exception):
|
|
"""A traced region has interior cut lines but none spans it edge-to-edge
|
|
— not representable as a guillotine slicing tree."""
|
|
|
|
def __init__(self, storey: int, corners: list[Point]):
|
|
self.storey = storey
|
|
self.corners = corners
|
|
cx = sum(p[0] for p in corners) / 4
|
|
cy = sum(p[1] for p in corners) / 4
|
|
super().__init__(
|
|
f"storey {storey}: region around ({cx:.2f}, {cy:.2f}) "
|
|
f"(corners {corners}) has cut lines that don't fully divide it "
|
|
"into two — not a guillotine partition"
|
|
)
|
|
|
|
|
|
class LabelError(Exception):
|
|
"""A leaf region has zero or more than one room label."""
|
|
|
|
def __init__(self, storey: int, corners: list[Point], labels: list[str]):
|
|
self.storey = storey
|
|
self.corners = corners
|
|
self.labels = labels
|
|
cx = sum(p[0] for p in corners) / 4
|
|
cy = sum(p[1] for p in corners) / 4
|
|
super().__init__(
|
|
f"storey {storey}: leaf region around ({cx:.2f}, {cy:.2f}) has "
|
|
f"{len(labels)} label(s) {labels!r}, expected exactly 1"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SVG parsing
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
Matrix = tuple[float, float, float, float, float, float] # a b c d e f
|
|
_IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
|
|
|
|
def _mat_mul(m1: Matrix, m2: Matrix) -> Matrix:
|
|
a1, b1, c1, d1, e1, f1 = m1
|
|
a2, b2, c2, d2, e2, f2 = m2
|
|
return (
|
|
a1 * a2 + c1 * b2,
|
|
b1 * a2 + d1 * b2,
|
|
a1 * c2 + c1 * d2,
|
|
b1 * c2 + d1 * d2,
|
|
a1 * e2 + c1 * f2 + e1,
|
|
b1 * e2 + d1 * f2 + f1,
|
|
)
|
|
|
|
|
|
def _apply(m: Matrix, p: Point) -> Point:
|
|
a, b, c, d, e, f = m
|
|
return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)
|
|
|
|
|
|
_TRANSFORM_RE = re.compile(r"(\w+)\s*\(([^)]*)\)")
|
|
|
|
|
|
def _parse_transform(s: str | None) -> Matrix:
|
|
if not s:
|
|
return _IDENTITY
|
|
m = _IDENTITY
|
|
for name, args in _TRANSFORM_RE.findall(s):
|
|
nums = [float(x) for x in re.split(r"[,\s]+", args.strip()) if x]
|
|
if name == "translate":
|
|
part: Matrix = (1, 0, 0, 1, nums[0], nums[1] if len(nums) > 1 else 0.0)
|
|
elif name == "scale":
|
|
sx = nums[0]
|
|
sy = nums[1] if len(nums) > 1 else sx
|
|
part = (sx, 0, 0, sy, 0, 0)
|
|
elif name == "matrix":
|
|
part = (nums[0], nums[1], nums[2], nums[3], nums[4], nums[5])
|
|
elif name == "rotate":
|
|
theta = math.radians(nums[0])
|
|
cos_t, sin_t = math.cos(theta), math.sin(theta)
|
|
rot: Matrix = (cos_t, sin_t, -sin_t, cos_t, 0, 0)
|
|
if len(nums) == 3:
|
|
cx, cy = nums[1], nums[2]
|
|
part = _mat_mul(_mat_mul((1, 0, 0, 1, cx, cy), rot), (1, 0, 0, 1, -cx, -cy))
|
|
else:
|
|
part = rot
|
|
else:
|
|
continue # skewX/skewY: not needed for straight hand traces
|
|
m = _mat_mul(m, part)
|
|
return m
|
|
|
|
|
|
_PATH_CMD_RE = re.compile(r"([MLZmlz])\s*([^MLZmlz]*)")
|
|
|
|
|
|
def _parse_path_line(d: str) -> tuple[Point, Point] | None:
|
|
"""A straight 2-point path ``M x,y L x,y`` (absolute only); None if this
|
|
isn't a simple straight segment (curves, more than 2 points, ...)."""
|
|
pts: list[Point] = []
|
|
for cmd, args in _PATH_CMD_RE.findall(d):
|
|
if cmd.upper() == "Z":
|
|
continue
|
|
if cmd not in ("M", "L"):
|
|
return None
|
|
nums = [float(x) for x in re.split(r"[,\s]+", args.strip()) if x]
|
|
if len(nums) != 2:
|
|
return None
|
|
pts.append((nums[0], nums[1]))
|
|
if len(pts) != 2:
|
|
return None
|
|
return pts[0], pts[1]
|
|
|
|
|
|
def _walk(el: ET.Element, xf: Matrix, storey: StoreyTrace) -> None:
|
|
xf = _mat_mul(xf, _parse_transform(el.get("transform")))
|
|
tag = el.tag.rsplit("}", 1)[-1]
|
|
if tag == "line":
|
|
p1 = _apply(xf, (float(el.get("x1")), float(el.get("y1"))))
|
|
p2 = _apply(xf, (float(el.get("x2")), float(el.get("y2"))))
|
|
storey.lines.append((p1, p2))
|
|
elif tag == "path":
|
|
d = el.get("d") or ""
|
|
pts = _parse_path_line(d)
|
|
if pts is None:
|
|
raise ValueError(f"unsupported non-straight-2-point cut path: {d!r}")
|
|
storey.lines.append((_apply(xf, pts[0]), _apply(xf, pts[1])))
|
|
elif tag == "text":
|
|
x, y = el.get("x"), el.get("y")
|
|
tspan = el.find(_qn(_SVG_NS, "tspan"))
|
|
if tspan is not None and tspan.get("x") is not None:
|
|
x, y = tspan.get("x"), tspan.get("y")
|
|
text = "".join(el.itertext()).strip()
|
|
if x is not None and y is not None and text:
|
|
storey.labels.append((_apply(xf, (float(x), float(y))), text))
|
|
for child in el:
|
|
_walk(child, xf, storey)
|
|
|
|
|
|
def parse_svg(path: str, scale: float = 1.0) -> list[StoreyTrace]:
|
|
"""Parse ``storey-N`` Inkscape layers into per-storey traces, storey 0
|
|
first. Layers must be flat (not nested inside one another)."""
|
|
root = ET.parse(path).getroot()
|
|
root_xf = _parse_transform(root.get("transform"))
|
|
storeys: dict[int, StoreyTrace] = {}
|
|
for g in root.iter(_qn(_SVG_NS, "g")):
|
|
if g.get(_qn(_INK_NS, "groupmode")) != "layer":
|
|
continue
|
|
m = _STOREY_RE.match((g.get(_qn(_INK_NS, "label")) or "").strip())
|
|
if not m:
|
|
continue
|
|
trace = storeys.setdefault(int(m.group(1)), StoreyTrace())
|
|
_walk(g, root_xf, trace)
|
|
if not storeys:
|
|
raise ValueError(f"{path}: no 'storey-N' Inkscape layers found")
|
|
result = [storeys.get(i, StoreyTrace()) for i in range(max(storeys) + 1)]
|
|
if scale != 1.0:
|
|
result = [
|
|
StoreyTrace(
|
|
lines=[
|
|
((p[0] * scale, p[1] * scale), (q[0] * scale, q[1] * scale))
|
|
for p, q in t.lines
|
|
],
|
|
labels=[((p[0] * scale, p[1] * scale), s) for p, s in t.labels],
|
|
)
|
|
for t in result
|
|
]
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Geometry: mirrors geometry.py's division-line algebra (coordinate/coord_a/
|
|
# coord_b) so composed rotation+division values reproduce these exact corners
|
|
# when the engine re-derives them top-down.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _dist(a: Point, b: Point) -> float:
|
|
return math.hypot(a[0] - b[0], a[1] - b[1])
|
|
|
|
|
|
def _interp(a: Point, b: Point, t: float) -> Point:
|
|
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
|
|
|
|
|
|
def _nearest_on_segment(p: Point, a: Point, b: Point) -> tuple[Point, float]:
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
length_sq = dx * dx + dy * dy
|
|
if length_sq == 0:
|
|
return a, 0.0
|
|
t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length_sq
|
|
t_clamped = max(0.0, min(1.0, t))
|
|
return (a[0] + t_clamped * dx, a[1] + t_clamped * dy), t_clamped
|
|
|
|
|
|
def _near_edge(p: Point, a: Point, b: Point, tol: float) -> float | None:
|
|
"""Clamped projection parameter (0=a, 1=b) if p is within tol of segment
|
|
a-b, else None."""
|
|
nearest, t = _nearest_on_segment(p, a, b)
|
|
return t if _dist(p, nearest) <= tol else None
|
|
|
|
|
|
def _side(p: Point, a: Point, b: Point) -> float:
|
|
return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
|
|
|
|
|
|
def _find_span(
|
|
corners: list[Point], lines: list[tuple[Point, Point]], tol: float
|
|
) -> tuple[int, tuple[float, float], tuple[Point, Point]] | None:
|
|
"""Best line spanning ``corners`` edge-to-edge on either axis: axis 0 =
|
|
edges (0,1)&(3,2), axis 1 = edges (1,2)&(0,3) — mirrors the two edge
|
|
pairs ``geometry.coord_a``/``coord_b`` can address via ``rotation``."""
|
|
axes = (
|
|
(0, corners[0], corners[1], corners[3], corners[2]),
|
|
(1, corners[1], corners[2], corners[0], corners[3]),
|
|
)
|
|
best = None
|
|
best_err = None
|
|
for axis, ea0, ea1, eb0, eb1 in axes:
|
|
for p, q in lines:
|
|
for p1, p2 in ((p, q), (q, p)):
|
|
t0 = _near_edge(p1, ea0, ea1, tol)
|
|
t1 = _near_edge(p2, eb0, eb1, tol)
|
|
if t0 is None or t1 is None:
|
|
continue
|
|
err = _dist(p1, _interp(ea0, ea1, t0)) + _dist(p2, _interp(eb0, eb1, t1))
|
|
if best_err is None or err < best_err:
|
|
best_err = err
|
|
best = (axis, (t0, t1), (p, q))
|
|
return best
|
|
|
|
|
|
def _build(
|
|
storey: int,
|
|
corners: list[Point],
|
|
lines: list[tuple[Point, Point]],
|
|
labels: list[tuple[Point, str]],
|
|
tol: float,
|
|
) -> Node:
|
|
span = _find_span(corners, lines, tol)
|
|
if span is None:
|
|
if not lines:
|
|
texts = [text for _, text in labels]
|
|
if len(texts) != 1:
|
|
raise LabelError(storey, corners, texts)
|
|
leaf = Node()
|
|
leaf.type = texts[0]
|
|
return leaf
|
|
raise NonSlicible(storey, corners)
|
|
|
|
axis, (t0, t1), chosen = span
|
|
idx = lines.index(chosen)
|
|
other_lines = lines[:idx] + lines[idx + 1 :]
|
|
|
|
if axis == 0:
|
|
coord_a = _interp(corners[0], corners[1], t0)
|
|
coord_b = _interp(corners[3], corners[2], t1)
|
|
left_corners = [corners[0], coord_a, coord_b, corners[3]]
|
|
right_corners = [coord_a, corners[1], corners[2], coord_b]
|
|
rotation = 0
|
|
else:
|
|
coord_a = _interp(corners[1], corners[2], t0)
|
|
coord_b = _interp(corners[0], corners[3], t1)
|
|
left_corners = [corners[1], coord_a, coord_b, corners[0]]
|
|
right_corners = [coord_a, corners[2], corners[3], coord_b]
|
|
rotation = 1
|
|
|
|
ref_side = _side(left_corners[0], coord_a, coord_b)
|
|
|
|
def is_left(p: Point) -> bool:
|
|
return (_side(p, coord_a, coord_b) >= 0) == (ref_side >= 0)
|
|
|
|
left_lines, right_lines = [], []
|
|
for p, q in other_lines:
|
|
mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
|
|
(left_lines if is_left(mid) else right_lines).append((p, q))
|
|
|
|
left_labels, right_labels = [], []
|
|
for pt, text in labels:
|
|
(left_labels if is_left(pt) else right_labels).append((pt, text))
|
|
|
|
node = Node()
|
|
node.rotation = rotation
|
|
node.division = [t0, t1]
|
|
node.left = _build(storey, left_corners, left_lines, left_labels, tol)
|
|
node.right = _build(storey, right_corners, right_lines, right_labels, tol)
|
|
return node
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Compose
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def compose(boundary_root: Node, storeys: list[StoreyTrace], tol: float = 0.15) -> Node:
|
|
"""Fill in ``division``/``left``/``right`` on each level of
|
|
``boundary_root`` (as loaded by ``dom.load()``) from the traced storeys,
|
|
and return the fully-linked, re-composed root."""
|
|
level_roots = levels(boundary_root)
|
|
if len(storeys) != len(level_roots):
|
|
raise ValueError(
|
|
f"boundary dom has {len(level_roots)} storey(s), trace has {len(storeys)}"
|
|
)
|
|
|
|
# A human traces against the plot's visible (outer wall face) boundary,
|
|
# not the wall_outer-inset working quad dom.load() computes -- so match
|
|
# against node_file (the raw corners as authored), same frame the file's
|
|
# author drew the boundary quad in.
|
|
plot = level_roots[0].node_file or level_roots[0].node
|
|
if plot is None or len(plot) != 4:
|
|
raise ValueError("boundary dom's level-0 root must have a 4-corner 'node'")
|
|
corners = [(float(p[0]), float(p[1])) for p in plot]
|
|
|
|
for i, (level_root, trace) in enumerate(zip(level_roots, storeys)):
|
|
built = _build(i, corners, trace.lines, trace.labels, tol)
|
|
level_root.division = built.division
|
|
level_root.left = built.left
|
|
level_root.right = built.right
|
|
level_root.rotation = built.rotation
|
|
level_root.type = built.type
|
|
if i == 0:
|
|
# Every storey above sees level 0's own *rotation-adjusted*
|
|
# corners (geometry.coordinate() always derives an upper root
|
|
# from the level below via the below-link, using level 0's
|
|
# final rotation) -- not necessarily plot's raw file order.
|
|
corners = [
|
|
(float(plot[(k + level_root.rotation) % 4][0]),
|
|
float(plot[(k + level_root.rotation) % 4][1]))
|
|
for k in range(4)
|
|
]
|
|
|
|
link(boundary_root)
|
|
from . import geometry
|
|
|
|
geometry.clear_cache()
|
|
return boundary_root
|
|
|
|
|
|
def refine(root: Node, programme_dir: str) -> None:
|
|
"""Slide the traced cuts to the best fit for the programme's target
|
|
dimensions, keeping the traced topology fixed -- trace precision only
|
|
needs to get the structure right, not the exact ratios."""
|
|
from . import solver
|
|
from .programme import load_programme_dir
|
|
|
|
solver.solve_ratios(root, load_programme_dir(programme_dir), strip=False)
|