homemaker-layout/src/homemaker/dom.py
Bruno Postle 92cc63348e Topology operators: 7 mutations + area-matched subtree crossover
operators.py (homemaker-py-nyb): divide/undivide/retype/swap/rotate/
level_add/level_delete + Urb-style area-matched base-storey crossover.
Operators edit the decoded Node tree; genome.encode absorbs all repair
(dangling deltas, storey misalignment) so every child is a valid genome
by construction. Geometry moves deliberately absent — the inner loop owns
continuous DOF, and 8cs made Lamarckian re-optimisation mandatory.

Fixes dom._link to CLEAR stale below-links when a path vanishes from the
storey below (undividing a base branch left upper nodes pointing at
orphaned quads; oracle scoring unaffected but in-process geometry crashed).

Acceptance (experiments/operator_locality.py, flag-on): 115/115 children
scored without error; geometry perturbation small for core ops (retype
0.07, divide/undivide 0.14, swap/crossover 0.16-0.17), fitness
perturbation large for all (0.68-0.99 rel) — the 0.5^n cliff flags most
raw moves, confirming warm-started re-optimisation + penalty reshaping
as the load-bearing design choices. 27 tests pass.

Closes homemaker-py-nyb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:07:35 +01:00

202 lines
7.1 KiB
Python

"""Read/write Urb ``.dom`` YAML into a ``Node`` tree.
A ``.dom`` file is a YAML serialisation of a binary slicing tree (see
``Urb::Quad::Serialise``). The top-level mapping is the *lowest* level's root
quad; each level's root carries an ``above`` mapping pointing at the next storey
up. Only the lowest root carries plot geometry (``node``); all other coordinates
are derived top-down (see :mod:`homemaker.geometry`).
This module owns *structure and I/O only* — no geometry. Linkage fields
(``parent``, ``below``, ``position``) are populated after parsing so the geometry
port can mirror ``Urb::Quad`` exactly, including multi-storey wall-stacking where
an upper quad inherits its coordinates from the matching quad below.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import yaml
# Root-level attributes that live only on a level's root quad.
_ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer")
@dataclass
class Node:
rotation: int = 0
division: list[float] | None = None
type: str | None = None
left: "Node | None" = None
right: "Node | None" = None
above: "Node | None" = None
# level-root only
node: list[list[float]] | None = None # working corners (wall-inset)
node_file: list[list[float]] | None = None # raw outer corners as read from disk
perimeter: dict | None = None
height: float | None = None
elevation: float | None = None
wall_inner: float | None = None
wall_outer: float | None = None
# runtime linkage (never serialised)
parent: "Node | None" = field(default=None, repr=False, compare=False)
below: "Node | None" = field(default=None, repr=False, compare=False)
position: str = "" # 'l', 'r', or '' for a level root
@property
def divided(self) -> bool:
return self.division is not None and self.left is not None and self.right is not None
@property
def id(self) -> str:
"""Absolute id path within this level, e.g. '' (root), 'l', 'rlr'."""
parts: list[str] = []
n: Node | None = self
while n is not None and n.parent is not None:
parts.append(n.position)
n = n.parent
return "".join(reversed(parts))
def leaves(self) -> list["Node"]:
if not self.divided:
return [self]
return self.left.leaves() + self.right.leaves()
def by_id(self, path: str) -> "Node | None":
"""Walk an id path from this node; None if the path runs off a leaf."""
n: Node | None = self
for ch in path:
if n is None or not n.divided:
return None
n = n.left if ch == "l" else n.right
return n
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
def _parse(d: dict) -> Node:
n = Node()
n.rotation = int(d.get("rotation") or 0)
if d.get("type") is not None:
n.type = str(d["type"])
if d.get("node") is not None:
n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
if d.get("perimeter") is not None:
n.perimeter = dict(d["perimeter"])
for k in _ROOT_FLOATS:
if d.get(k) is not None:
setattr(n, k, float(d[k]))
if d.get("division") is not None:
n.division = [float(x) for x in d["division"]]
n.left = _parse(d["l"])
n.right = _parse(d["r"])
if d.get("above") is not None:
n.above = _parse(d["above"])
return n
def _link_subtree(n: Node, parent: Node | None, position: str) -> None:
n.parent = parent
n.position = position
if n.divided:
_link_subtree(n.left, n, "l")
_link_subtree(n.right, n, "r")
def levels(root: Node) -> list[Node]:
"""Level roots, lowest first."""
out: list[Node] = []
lvl: Node | None = root
while lvl is not None:
out.append(lvl)
lvl = lvl.above
return out
def _link(root: Node) -> None:
lvls = levels(root)
for lvl in lvls:
_link_subtree(lvl, None, "") # each level root is parent-less
# Below-inheritance: a node links to the same-id node one level down,
# if that path exists there (mirrors Urb::Quad::Below via By_Id).
for i in range(1, len(lvls)):
below_root = lvls[i - 1]
def _set(n: Node, below_root: Node = below_root) -> None:
# always assign: re-linking a structurally mutated tree must CLEAR
# below-links whose path no longer exists on the storey below
n.below = below_root.by_id(n.id)
if n.divided:
_set(n.left)
_set(n.right)
_set(lvls[i])
def load(path: str) -> Node:
"""Load a ``.dom`` file and return the fully-linked lowest level root.
The plot stored on disk is the *outer* boundary; Urb::Dom insets it by
``wall_outer`` on load (and offsets back out on save). We mirror that so
leaf areas match ``urb-fitness.pl``, stashing the raw corners in
``node_file`` for byte-perfect plot round-tripping.
"""
from . import geometry # local import avoids a module-load cycle
with open(path) as fh:
root = _parse(yaml.safe_load(fh))
_link(root)
if root.wall_outer is None:
root.wall_outer = 0.25 # Urb::Dom::Wall_Outer default
if root.wall_inner is None:
root.wall_inner = 0.08 # Urb::Dom::Wall_Inner default
if root.node is not None:
root.node_file = [list(p) for p in root.node]
root.node = geometry.offset_quad(root.node, -root.wall_outer)
geometry.clear_cache() # fresh tree: drop any stale coordinates
return root
# --------------------------------------------------------------------------- #
# Emit (mirrors Urb::Quad::Serialise; field order kept close for readability)
# --------------------------------------------------------------------------- #
def _emit(n: Node, is_level_root: bool) -> dict:
d: dict = {}
if is_level_root and (n.node_file is not None or n.node is not None):
# write the outer plot boundary Urb expects on disk
if n.node_file is not None:
d["node"] = [list(p) for p in n.node_file]
else:
from . import geometry
d["node"] = [list(p) for p in geometry.offset_quad(n.node, n.wall_outer or 0.25)]
if is_level_root and n.perimeter is not None:
d["perimeter"] = dict(n.perimeter)
if n.type and not n.divided:
d["type"] = n.type
d["rotation"] = n.rotation
if n.divided:
d["division"] = list(n.division)
if is_level_root and n.height is not None:
d["height"] = n.height
if is_level_root and n.elevation is not None:
d["elevation"] = n.elevation
for k in ("wall_inner", "wall_outer"):
if is_level_root and getattr(n, k) is not None:
d[k] = getattr(n, k)
if n.divided:
d["l"] = _emit(n.left, False)
d["r"] = _emit(n.right, False)
if n.above is not None:
d["above"] = _emit(n.above, True)
return d
def dump(root: Node, path: str) -> None:
with open(path, "w") as fh:
yaml.safe_dump(
_emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True
)