trikona / geometry /assembler.py
disssid's picture
fix: generate non-regular shapes (pyramids, dipyramids, cones, etc.)
b0364db
Raw
History Blame Contribute Delete
6.79 kB
"""Build spec -> frozen frontend contract (SPEC_OPTION_A.md §3.3).
`build(spec, params)` runs the generator through the kernel + repair, then
`assemble(...)` maps the result into the EXACT object the canvas already
consumes (SPEC.md §7.1 / validated by tests/test_schema.py::validate_shape):
geometry must satisfy `validate_shape(...) == []`.
Deviation from §3.3 step 5 (noted deliberately): undeclared face component_ids
are AUTO-RECONCILED into components[] rather than stripped. This produces
stricter-valid output and — crucially — lets a `regenerate` param re-run the
cached generator with a new count (e.g. more vanes) and still yield a valid
contract with no model call.
"""
import re
from collections import Counter
from . import kernel, repair
VALID_AXES = {"x", "y", "z"}
_AXIS_OPS = {"scale_axis", "rotate", "translate"}
def _default_params(spec):
return {p["id"]: p["default"] for p in spec.get("params", []) if "default" in p}
def _pretty(cid):
return cid.replace("_", " ").strip().title() or cid
def _snake(s):
s = re.sub(r"[^a-z0-9]+", "_", str(s).strip().lower()).strip("_")
return s or "shape"
def _num(v, default):
return v if isinstance(v, (int, float)) and not isinstance(v, bool) else default
def _sanitize_params(params, component_ids):
"""Coerce model param schema so it satisfies the frozen contract.
LLMs emit near-valid params (e.g. transform axis "xy", missing valid_min,
out-of-range defaults). The frontend rejects any violation, so the assembler
must make params conform: fix/drop bad transforms, clamp valid ranges, fix
dropdown/toggle defaults, and drop unusable params.
"""
out = []
for raw in params:
p = dict(raw)
t = p.get("type")
if t == "slider":
p["min"] = _num(p.get("min"), 0.0)
p["max"] = _num(p.get("max"), p["min"] + 1.0)
if p["max"] <= p["min"]:
p["max"] = p["min"] + 1.0
p["step"] = _num(p.get("step"), 0.1) or 0.1
p["default"] = min(max(_num(p.get("default"), p["min"]), p["min"]), p["max"])
p["valid_min"] = min(max(_num(p.get("valid_min"), p["min"]), p["min"]), p["max"])
p["valid_max"] = min(max(_num(p.get("valid_max"), p["max"]), p["min"]), p["max"])
if p["valid_max"] < p["valid_min"]:
p["valid_min"], p["valid_max"] = p["min"], p["max"]
tr = p.get("transform")
if isinstance(tr, dict):
tr = dict(tr)
if tr.get("op") in _AXIS_OPS:
axis = next((c for c in str(tr.get("axis", "")).lower() if c in VALID_AXES), None)
if axis:
tr["axis"] = axis
else:
tr = None # un-fixable axis -> drop the transform hint
if tr is not None:
comps = tr.get("components", "all")
if comps != "all":
kept = [c for c in comps if c in component_ids] if isinstance(comps, list) else []
tr["components"] = kept or "all"
if tr:
p["transform"] = tr
else:
p.pop("transform", None)
elif t == "toggle":
p["default"] = bool(p.get("default"))
elif t == "dropdown":
opts = p.get("options")
if not isinstance(opts, list) or not opts:
continue # unusable dropdown -> drop
if p.get("default") not in opts:
p["default"] = opts[0]
else:
continue # unknown param type -> drop
out.append(p)
return out
def run_generator(spec, params=None):
"""Execute the spec's generator, then repair. Returns (verts, faces)."""
gen = spec["generator"]
p = params if params is not None else _default_params(spec)
verts, faces = kernel.run(gen["code"], gen.get("entry", "build"), p)
return repair.repair(verts, faces, spec.get("regularity", "freeform"))
def assemble(spec, verts, faces):
"""Map (verts, faces) + spec metadata into the frontend contract.
Returns (contract_dict, warnings_list).
"""
declared = {c["id"]: c for c in spec.get("components", [])}
counts = Counter(f[3] for f in faces)
# component ordering: declared first, then any auto-discovered cids.
ids, warnings = list(declared), []
for f in faces:
if f[3] not in ids:
ids.append(f[3])
auto = [cid for cid in ids if cid not in declared]
if auto:
warnings.append(f"auto-reconciled undeclared components: {sorted(auto)}")
components = []
for cid in ids:
if counts.get(cid, 0) == 0:
continue # declared but no geometry this build (e.g. fewer vanes) — drop it
base = dict(declared.get(cid, {"id": cid, "label": _pretty(cid)}))
base.setdefault("id", cid)
base.setdefault("label", _pretty(cid))
base.setdefault("param_dependencies", [])
base["triangle_count"] = counts.get(cid, 0)
components.append(base)
live_ids = {c["id"] for c in components}
# Prune construction-step references to components that no longer exist.
steps = []
for s in spec.get("construction_steps", []):
s2 = dict(s)
s2["component_ids"] = [c for c in s.get("component_ids", []) if c in live_ids]
steps.append(s2)
geometry_faces = [{"indices": [int(f[0]), int(f[1]), int(f[2])],
"component_id": f[3]} for f in faces]
# Sanitize the model's param/constraint schema so the contract always
# validates (the frontend rejects any violation; the model is imprecise).
params = _sanitize_params(spec.get("params", []), live_ids)
param_ids = {p["id"] for p in params}
constraints = [c for c in spec.get("constraints", [])
if all(r in param_ids for r in c.get("params", []))]
contract = {
"shape_id": _snake(spec.get("shape_id", "shape")),
"shape_label": spec.get("shape_label", spec.get("shape_id", "Shape")),
"description": spec.get("description", ""),
"complexity": {
"vertex_count": len(verts),
"face_count": len(geometry_faces),
"component_count": len(components),
},
"params": params,
"constraints": constraints,
"components": components,
"construction_steps": steps,
"geometry": {"vertices": verts, "faces": geometry_faces},
}
return contract, warnings
def build(spec, params=None):
"""Full path: generator -> repair -> contract. Returns (contract, warnings)."""
verts, faces = run_generator(spec, params)
return assemble(spec, verts, faces)