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