From 0366392da4c3179feea7a075f065164b7dbcfcc0 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 10 Jun 2026 20:50:13 +0100 Subject: [PATCH] Scaffold homemaker-py with validated geometry port Clean-room Python successor to Urb for programme-driven layout search. This initial commit establishes the .dom bridge format and a faithful port of Urb's top-down quad geometry, validated byte-identical against Urb across all 35 programme-house example files (including the wall inset and multi-storey wall-stacking inheritance). - dom.py: .dom YAML <-> Node tree, parent/below/position linkage, wall_outer inset on load, raw-corner stash for round-tripping - geometry.py: Coordinate/Coordinate_a/_b/Area/Length + Coordinate_Offset - experiments/dump_areas.{py,pl}: geometry regression harness --- .claude/settings.json | 26 +++++ .gitignore | 11 +++ CLAUDE.md | 69 +++++++++++++ README.md | 36 +++++++ experiments/dump_areas.pl | 23 +++++ experiments/dump_areas.py | 26 +++++ pyproject.toml | 25 +++++ src/homemaker/__init__.py | 3 + src/homemaker/dom.py | 201 ++++++++++++++++++++++++++++++++++++++ src/homemaker/geometry.py | 98 +++++++++++++++++++ 10 files changed, 518 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 experiments/dump_areas.pl create mode 100644 experiments/dump_areas.py create mode 100644 pyproject.toml create mode 100644 src/homemaker/__init__.py create mode 100644 src/homemaker/dom.py create mode 100644 src/homemaker/geometry.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..963a538 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f800a05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +# scratch output from the Perl oracle during experiments +scratch/ +*.dom.score +*.dom.fails diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..50af487 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# Project Instructions for AI Agents + +This file provides instructions and context for AI coding agents working on this project. + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + + +## Build & Test + +_Add your build and test commands here_ + +```bash +# Example: +# npm install +# npm test +``` + +## Architecture Overview + +_Add a brief overview of your project architecture_ + +## Conventions & Patterns + +_Add your project-specific conventions here_ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ff2241 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# homemaker-py + +Programme-driven building-layout search over slicing trees. A clean-room Python +successor to the Perl [Urb](../urb) project, intended to eventually be 100% Python. + +## Why a rewrite + +Urb represents a building as a binary slicing tree where room sizes are derived +**top-down** from division ratios. That makes room area an emergent property of +every cut above it, which: + +- gives the genome low locality (a cut near the root rescales every descendant), +- makes target room sizes nearly impossible to hit, so the gaussian size penalty + dominates fitness, and +- defeats crossover (transplanted subtrees lose their proportions). + +homemaker inverts this: leaves carry **target dimensions** from the programme and +division ratios are **solved bottom-up** for a fixed topology. The evolutionary +search then only explores topology + types + adjacency. + +## Phase plan + +1. **Solver experiment** (current): port Urb's geometry, re-solve ratios from + programme targets, score the result against the original via the Perl oracle. +2. Native Python fitness (retire the Perl oracle). +3. Canonical slicing encoding (normalized Polish expression) + memetic search. + +## Layout + +- `src/homemaker/dom.py` — read/write Urb `.dom` YAML into a `Node` tree. +- `src/homemaker/geometry.py` — faithful port of Urb's top-down geometry. +- `src/homemaker/programme.py` — parse `patterns.config` space requirements. +- `src/homemaker/solver.py` — bottom-up ratio solve (scipy). +- `src/homemaker/oracle.py` — Phase-1 scaffold: score a `.dom` via Urb's `urb-fitness.pl`. + +The Perl oracle is the only throwaway component; everything else is permanent. diff --git a/experiments/dump_areas.pl b/experiments/dump_areas.pl new file mode 100644 index 0000000..69efe6f --- /dev/null +++ b/experiments/dump_areas.pl @@ -0,0 +1,23 @@ +#!/usr/bin/perl +# Dump per-leaf areas from a .dom using Urb itself (ground truth for validating +# the Python geometry port). Run from the urb repo root with -Ilib. +# Output matches experiments/dump_areas.py: "level/idpath type area", sorted. +use strict; +use warnings; +use YAML; +use Urb::Dom; + +my $dom = Urb::Dom->new; +$dom->Deserialise (YAML::LoadFile ($ARGV[0])); + +my @rows; +for my $level ($dom, $dom->Levels_Above) +{ + my $lid = scalar $level->Levels_Below; + for my $leaf ($level->Leafs) + { + my $type = $leaf->Type || '?'; + push @rows, sprintf ("%d/%s %s %.4f", $lid, $leaf->Id, $type, $leaf->Area); + } +} +print join ("\n", sort @rows), "\n"; diff --git a/experiments/dump_areas.py b/experiments/dump_areas.py new file mode 100644 index 0000000..4cbf115 --- /dev/null +++ b/experiments/dump_areas.py @@ -0,0 +1,26 @@ +"""Dump per-leaf areas from a .dom using the Python geometry port. + +Used to validate the port against a Perl dump from Urb (see the sibling +``dump_areas.pl``). Output: one line per leaf, ``level/idpath type area``, +sorted for a stable diff. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from homemaker import dom, geometry # noqa: E402 + + +def main(path: str) -> None: + root = dom.load(path) + rows = [] + for level_idx, lvl in enumerate(dom.levels(root)): + for leaf in lvl.leaves(): + rows.append(f"{level_idx}/{leaf.id} {leaf.type or '?'} {geometry.area(leaf):.4f}") + print("\n".join(sorted(rows))) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..873fb59 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "homemaker" +version = "0.0.1" +description = "Programme-driven building layout search over slicing trees (Python successor to Urb)" +requires-python = ">=3.11" +dependencies = [ + "pyyaml>=6.0", + "numpy>=1.26", + "scipy>=1.11", + "shapely>=2.0", + "networkx>=3.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.5"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 diff --git a/src/homemaker/__init__.py b/src/homemaker/__init__.py new file mode 100644 index 0000000..459e40d --- /dev/null +++ b/src/homemaker/__init__.py @@ -0,0 +1,3 @@ +"""homemaker — programme-driven building-layout search over slicing trees.""" + +__version__ = "0.0.1" diff --git a/src/homemaker/dom.py b/src/homemaker/dom.py new file mode 100644 index 0000000..b0c2a80 --- /dev/null +++ b/src/homemaker/dom.py @@ -0,0 +1,201 @@ +"""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: + b = below_root.by_id(n.id) + if b is not None: + n.below = b + 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) + 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 + ) diff --git a/src/homemaker/geometry.py b/src/homemaker/geometry.py new file mode 100644 index 0000000..99c92f5 --- /dev/null +++ b/src/homemaker/geometry.py @@ -0,0 +1,98 @@ +"""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] + + +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``.""" + if n.below is not None: # upper storey inherits geometry from below + return coordinate(n.below, idx) + rid = (idx + n.rotation) % 4 + if n.parent is None: # level root: stored, rotation-adjusted corner + return list(n.node[rid]) + p = n.parent + if n.position == "l": + return {0: coordinate(p, 0), 1: coord_a(p), 2: coord_b(p), 3: coordinate(p, 3)}[rid] + # position == 'r' + return {0: coord_a(p), 1: coordinate(p, 1), 2: coordinate(p, 2), 3: coord_b(p)}[rid] + + +def coord_a(n: Node) -> Point: + """End 'a' of the division line; mirrors ``Urb::Quad::Coordinate_a``.""" + if n.below is not None and n.below.divided: + return coord_a(n.below) + return _interp(coordinate(n, 0), coordinate(n, 1), n.division[0]) + + +def coord_b(n: Node) -> Point: + """End 'b' of the division line; mirrors ``Urb::Quad::Coordinate_b``.""" + if n.below is not None and n.below.divided: + return coord_b(n.below) + return _interp(coordinate(n, 3), coordinate(n, 2), n.division[1]) + + +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)]