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
This commit is contained in:
Bruno Postle 2026-06-10 20:50:13 +01:00
commit 0366392da4
10 changed files with 518 additions and 0 deletions

26
.claude/settings.json Normal file
View file

@ -0,0 +1,26 @@
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
],
"SessionStart": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
]
}
}

11
.gitignore vendored Normal file
View file

@ -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

69
CLAUDE.md Normal file
View file

@ -0,0 +1,69 @@
# Project Instructions for AI Agents
This file provides instructions and context for AI coding agents working on this project.
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## 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 <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # 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
<!-- END BEADS INTEGRATION -->
## 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_

36
README.md Normal file
View file

@ -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.

23
experiments/dump_areas.pl Normal file
View file

@ -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";

26
experiments/dump_areas.py Normal file
View file

@ -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])

25
pyproject.toml Normal file
View file

@ -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

View file

@ -0,0 +1,3 @@
"""homemaker — programme-driven building-layout search over slicing trees."""
__version__ = "0.0.1"

201
src/homemaker/dom.py Normal file
View file

@ -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
)

98
src/homemaker/geometry.py Normal file
View file

@ -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)]