Spaces:
Running
Running
| """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) | |