150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
|
|
"""Bottom-up division-ratio solver.
|
||
|
|
|
||
|
|
Given a *fixed* slicing topology (types, rotations, tree shape), solve every
|
||
|
|
free division ratio so that each programme leaf best meets its target area —
|
||
|
|
with soft, one-sided penalties for being too narrow or too elongated. This is
|
||
|
|
the inversion of Urb's top-down sizing: rooms declare targets, geometry follows.
|
||
|
|
|
||
|
|
Only generic leaves (circulation/outside/storage) and unconstrained types are
|
||
|
|
left to absorb the residual area, exactly as a real plan lets corridors flex.
|
||
|
|
|
||
|
|
A division is *free* only at the lowest storey where its tree path is divided;
|
||
|
|
higher storeys inherit that cut via Below-inheritance (see geometry.coordinate),
|
||
|
|
so their stored ratios are dead variables and must not be optimised.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
from scipy.optimize import least_squares
|
||
|
|
|
||
|
|
from . import geometry
|
||
|
|
from .dom import Node, levels
|
||
|
|
from .programme import SpaceReq
|
||
|
|
|
||
|
|
_EPS = 0.02 # keep cuts off the edges
|
||
|
|
|
||
|
|
|
||
|
|
def _branches(n: Node) -> list[Node]:
|
||
|
|
if not n.divided:
|
||
|
|
return []
|
||
|
|
return [n] + _branches(n.left) + _branches(n.right)
|
||
|
|
|
||
|
|
|
||
|
|
def free_branches(root: Node) -> list[Node]:
|
||
|
|
"""Branches whose division actually drives geometry (not inherited)."""
|
||
|
|
out: list[Node] = []
|
||
|
|
for lvl in levels(root):
|
||
|
|
for b in _branches(lvl):
|
||
|
|
if b.below is None or not b.below.divided:
|
||
|
|
out.append(b)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _width(leaf: Node) -> float:
|
||
|
|
l0 = geometry.edge_length(leaf, 0)
|
||
|
|
l1 = geometry.edge_length(leaf, 1)
|
||
|
|
l2 = geometry.edge_length(leaf, 2)
|
||
|
|
l3 = geometry.edge_length(leaf, 3)
|
||
|
|
return min((l0 + l2) / 2, (l1 + l3) / 2)
|
||
|
|
|
||
|
|
|
||
|
|
def _aspect(leaf: Node) -> float:
|
||
|
|
l0 = geometry.edge_length(leaf, 0)
|
||
|
|
l1 = geometry.edge_length(leaf, 1)
|
||
|
|
l2 = geometry.edge_length(leaf, 2)
|
||
|
|
l3 = geometry.edge_length(leaf, 3)
|
||
|
|
a = (l0 + l2) / 2
|
||
|
|
b = (l1 + l3) / 2
|
||
|
|
if a <= 0 or b <= 0:
|
||
|
|
return 1.0
|
||
|
|
return max(a, b) / min(a, b)
|
||
|
|
|
||
|
|
|
||
|
|
def solve_ratios(
|
||
|
|
root: Node,
|
||
|
|
targets: dict[str, SpaceReq],
|
||
|
|
*,
|
||
|
|
strip: bool = True,
|
||
|
|
perpendicular: bool = True,
|
||
|
|
weight_width: float = 1.0,
|
||
|
|
weight_proportion: float = 0.3,
|
||
|
|
min_width_generic: float = 1.2,
|
||
|
|
max_nfev: int = 4000,
|
||
|
|
):
|
||
|
|
"""Solve free division ratios in place. Returns the scipy result object.
|
||
|
|
|
||
|
|
``strip=True`` discards the existing ratios first (start from 0.5) — the
|
||
|
|
honest test that sizes are recoverable from the programme alone.
|
||
|
|
|
||
|
|
``perpendicular=True`` ties the two ends of each cut (``a == b``), one DOF
|
||
|
|
per branch, so cuts stay perpendicular to their walls (matches Urb's
|
||
|
|
``perpendicular`` quality and the slicing-tree model). ``min_width_generic``
|
||
|
|
keeps unconstrained circulation/outside leaves from collapsing into slivers.
|
||
|
|
"""
|
||
|
|
free = free_branches(root)
|
||
|
|
if not free:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if strip:
|
||
|
|
for b in free:
|
||
|
|
b.division = [0.5, 0.5]
|
||
|
|
|
||
|
|
x0 = np.array(
|
||
|
|
[b.division[0] for b in free] if perpendicular
|
||
|
|
else [v for b in free for v in b.division],
|
||
|
|
dtype=float,
|
||
|
|
)
|
||
|
|
|
||
|
|
all_leaves = [leaf for lvl in levels(root) for leaf in lvl.leaves()]
|
||
|
|
|
||
|
|
def apply(x: np.ndarray) -> None:
|
||
|
|
for j, b in enumerate(free):
|
||
|
|
if perpendicular:
|
||
|
|
b.division = [float(x[j]), float(x[j])]
|
||
|
|
else:
|
||
|
|
b.division = [float(x[2 * j]), float(x[2 * j + 1])]
|
||
|
|
geometry.clear_cache() # divisions changed; invalidate derived coords
|
||
|
|
|
||
|
|
def residuals(x: np.ndarray) -> list[float]:
|
||
|
|
apply(x)
|
||
|
|
r: list[float] = []
|
||
|
|
for leaf in all_leaves:
|
||
|
|
req = targets.get(leaf.type)
|
||
|
|
if req is not None:
|
||
|
|
area = geometry.area(leaf)
|
||
|
|
r.append((area - req.size) / req.size)
|
||
|
|
if weight_width:
|
||
|
|
w = _width(leaf)
|
||
|
|
r.append(weight_width * min(0.0, (w - req.width) / req.width))
|
||
|
|
if weight_proportion:
|
||
|
|
asp = _aspect(leaf)
|
||
|
|
r.append(weight_proportion * max(0.0, (asp - req.proportion) / req.proportion))
|
||
|
|
elif min_width_generic:
|
||
|
|
# keep circulation/outside from collapsing to slivers
|
||
|
|
w = _width(leaf)
|
||
|
|
r.append(min(0.0, (w - min_width_generic) / min_width_generic))
|
||
|
|
return r
|
||
|
|
|
||
|
|
res = least_squares(
|
||
|
|
residuals, x0, bounds=(_EPS, 1 - _EPS), max_nfev=max_nfev, xtol=1e-10, ftol=1e-10
|
||
|
|
)
|
||
|
|
apply(res.x)
|
||
|
|
return res
|
||
|
|
|
||
|
|
|
||
|
|
def area_report(root: Node, targets: dict[str, SpaceReq]) -> str:
|
||
|
|
"""Human-readable per-programme-leaf area vs target (for experiment output)."""
|
||
|
|
rows = []
|
||
|
|
for lvl_idx, lvl in enumerate(levels(root)):
|
||
|
|
for leaf in lvl.leaves():
|
||
|
|
if leaf.type in targets:
|
||
|
|
req = targets[leaf.type]
|
||
|
|
a = geometry.area(leaf)
|
||
|
|
rows.append(
|
||
|
|
f" {lvl_idx}/{leaf.id:6s} {leaf.type:4s} area={a:6.2f} "
|
||
|
|
f"target={req.size:6.2f} err={(a - req.size):+6.2f} "
|
||
|
|
f"w={_width(leaf):.2f}/{req.width:.2f} asp={_aspect(leaf):.2f}/{req.proportion:.2f}"
|
||
|
|
)
|
||
|
|
return "\n".join(rows)
|