Files
cdsl-cad/backend/app/services/cdsl_fragment.py
T
2026-08-31 14:16:08 +08:00

789 lines
39 KiB
Python

"""Append-only CDSL materialisation for the autonomous authoring loop."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import math
from typing import Any
class CdslFragmentError(ValueError):
"""A fragment cannot safely be applied to the current CDSL state."""
class AutonomousFragmentError(CdslFragmentError):
"""A free-form candidate violates the autonomous append-only boundary."""
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
value = cdsl or {"geometry": {"sketches": []}, "features": []}
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
"""Create the complete CDSL document that will be rebuilt from scratch."""
if base_cdsl is None:
document: dict[str, Any] = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "agent_preflight",
"geometry": {"sketches": []},
"features": [],
}
else:
document = deepcopy(base_cdsl)
geometry = document.setdefault("geometry", {})
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise CdslFragmentError("Base CDSL has invalid geometry collections")
sketches.extend(deepcopy(fragment["add_sketches"]))
features.extend(deepcopy(fragment["add_features"]))
return document
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
"""Persist runtime selector choices as build evidence."""
values = []
for resolution in engine_result.get("selector_resolution") or ():
if not isinstance(resolution, dict):
continue
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
values.append({
"consumer_node_id": node_id,
"consumer_feature_id": str(resolution.get("feature_id") or ""),
"source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id),
"kind": str(selector.get("kind") or ""),
"owner_feature_id": str(selector.get("owner_feature_id") or ""),
"stable_id": str(selector.get("stable_id") or ""),
"geometry": deepcopy(selector.get("geometry") or {}),
"status": str(resolution.get("status") or ""),
"candidates": deepcopy(list(resolution.get("candidates") or [])),
"score": resolution.get("score"),
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
})
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
def _autonomous_id(prefix: str, used: set[str]) -> str:
index = 1
while True:
candidate = f"{prefix}_{index:03d}"
if candidate not in used:
used.add(candidate)
return candidate
index += 1
def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""Make opaque, revision-scoped selector tokens from executable topology."""
if not isinstance(snapshot, dict):
return {}
snapshot_id = str(snapshot.get("snapshot_id") or "")
if not snapshot_id:
return {}
values: dict[str, dict[str, Any]] = {}
for record in snapshot.get("records") or ():
if not isinstance(record, dict) or not record.get("executable"):
continue
record_id = str(record.get("record_id") or "")
kind = str(record.get("kind") or "")
if not record_id or kind not in {"face", "edge", "vertex", "plane", "axis", "body"}:
continue
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
geometry = deepcopy(record.get("geometry") or {})
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
values[token] = {
"token": token,
"kind": kind,
"geometry": geometry,
"selector": {
"kind": kind,
"stable_id": record_id,
"owner_feature_id": str(owners[0] or ""),
"geometry": geometry,
"source": "runtime_snapshot",
"snapshot_id": snapshot_id,
"confidence": 1.0,
},
}
return values
def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
"""Keep only selector-choice facts useful to an author model.
Runtime snapshots also carry adjacency signatures, curve endpoints and
other diagnostic detail. Those fields are required for deterministic
engine work, but repeatedly placing them in an LLM prompt is expensive
and does not help choose an opaque token. Full records remain available
on disk and through targeted measurement tools.
"""
useful = (
"bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm",
"surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2",
"volume_mm3", "solid_count",
)
return {key: deepcopy(geometry[key]) for key in useful if key in geometry}
def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]:
"""Return compact safe token fields, never persistent selector IDs."""
return [
{"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])}
for token, value in sorted(tokens.items())
if not kind or value["kind"] == kind
]
def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if not isinstance(fragment, dict):
raise AutonomousFragmentError("fragment_json must decode to a JSON object")
# Ordinary tool-call providers occasionally lift the unambiguous selector
# array one level out of a single-feature payload. Accept that shorthand
# only when it can be moved to exactly one feature without choosing or
# changing any selector ourselves.
allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"}
unknown = sorted(set(fragment) - allowed)
if unknown:
raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown))
sketches_raw = fragment.get("sketches", fragment.get("add_sketches", []))
features_raw = fragment.get("features", fragment.get("add_features", []))
if "sketch" in fragment:
if sketches_raw:
raise AutonomousFragmentError("Use either sketch or sketches, not both")
sketches_raw = [fragment["sketch"]]
if "feature" in fragment:
if features_raw:
raise AutonomousFragmentError("Use either feature or features, not both")
features_raw = [fragment["feature"]]
if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw):
raise AutonomousFragmentError("fragment sketches must be an array of objects")
if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw):
raise AutonomousFragmentError("fragment features must be a non-empty array of objects")
sketches = deepcopy(sketches_raw)
features = deepcopy(features_raw)
top_level_tokens = fragment.get("selector_tokens")
if top_level_tokens is not None:
if len(features) != 1:
raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature")
if "selector_tokens" in features[0]:
raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both")
features[0]["selector_tokens"] = deepcopy(top_level_tokens)
return sketches, features
def _move_equivalent_field(
value: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move a lossless spelling alias without choosing any CAD geometry."""
if source not in value:
return
if target in value:
if value[source] != value[target]:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values"
)
value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"})
return
value[target] = value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"})
def _move_axis_component(
params: dict[str, Any],
axis: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move an explicit top-level axis alias into the canonical axis object."""
if source not in params:
return
value = params.pop(source)
if target in axis and axis[target] != value:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values"
)
if target in axis:
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"})
return
axis[target] = value
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"})
def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Normalize only lossless aliases used inside an already explicit axis."""
for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"):
_move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes)
for source in ("axis_dir", "axis_direction", "dir"):
_move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes)
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Accept common feature-local sketch spellings without choosing geometry.
The public fragment grammar owns one ordered sketch list and one ordered
feature list. Tool-call models commonly emit either a direct feature with
a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
losslessly transformable when the fragment has no root sketch collection.
Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in
the same batch; the materializer pairs only sketch-requiring features with
the lifted sketches.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
normalized_features: list[dict[str, Any]] = []
for index, item in enumerate(features):
wrapped = item.get("feature")
if wrapped is not None:
if not isinstance(wrapped, dict):
return
feature = deepcopy(wrapped)
if "selector_tokens" in item:
if "selector_tokens" in feature:
raise AutonomousFragmentError(
f"features[{index}] supplies selector_tokens both on the wrapper and feature"
)
feature["selector_tokens"] = deepcopy(item["selector_tokens"])
nested = item.get("sketches", item.get("sketch"))
fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"})
else:
feature = deepcopy(item)
nested = feature.get("sketches", feature.get("sketch"))
if isinstance(nested, dict):
sketches = [nested]
elif isinstance(nested, list):
sketches = nested
else:
normalized_features.append(feature)
continue
if len(sketches) != 1 or not isinstance(sketches[0], dict):
return
feature.pop("sketch", None)
feature.pop("sketches", None)
lifted.append(sketches[0])
normalized_features.append(feature)
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
fragment["features"] = normalized_features
if lifted:
fragment["sketches"] = lifted
def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Lift an exact legacy ``params.workplane/profile`` sketch spelling.
Some authors place an extrusion's complete sketch inside its params object.
The workplane and profile retain their meaning verbatim, so extracting them
is safe. Partial shapes remain invalid instead of being guessed.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
atomic_id = str(feature.get("atomic_id") or "")
params = feature.get("params")
if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict):
continue
workplane = params.get("workplane")
profile = params.get("profile")
if workplane is None and profile is None:
continue
if not isinstance(workplane, dict) or not isinstance(profile, dict):
return
params.pop("workplane")
params.pop("profile")
lifted.append({"workplane": workplane, "profile": profile})
fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"})
if lifted:
fragment["sketches"] = lifted
def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None:
if "reverse" in params and params["reverse"] is not reverse:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse"
)
params["reverse"] = reverse
params.pop("direction", None)
fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"})
def _normalize_extrude_direction(
params: dict[str, Any],
sketch: dict[str, Any] | None,
*,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Accept an extrusion direction only when it exactly restates the sketch."""
direction = params.get("direction")
if direction is None:
return
if isinstance(direction, str):
normalized = direction.strip().lower()
if normalized in {"negative", "reverse", "-normal"}:
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
elif normalized in {"positive", "forward", "+normal"}:
_set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes)
return
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
normal = workplane.get("normal") if isinstance(workplane, dict) else None
if (
not isinstance(direction, list)
or not isinstance(normal, list)
or len(direction) != 3
or len(normal) != 3
or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal])
):
return
direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction))
normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal))
if direction_norm == 0 or normal_norm == 0:
return
cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm)
if math.isclose(cosine, 1.0, abs_tol=1e-9):
params.pop("direction")
fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"})
elif math.isclose(cosine, -1.0, abs_tol=1e-9):
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Convert the explicitly unit-labelled angle_rad alias to angle_deg."""
if "angle_rad" not in params:
return
radians = params["angle_rad"]
if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)):
return
degrees = float(radians) * 180.0 / math.pi
if "angle_deg" in params:
supplied = params["angle_deg"]
if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9):
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg"
)
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"})
return
params["angle_deg"] = degrees
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"})
def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Expand a common two-circle annulus shorthand into analytic contours."""
if profile.get("type") != "analytic_contours":
return
contours = profile.get("contours")
if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours):
return
if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours):
return
if contours[0]["center"] != contours[1]["center"]:
return
try:
ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True)
except (KeyError, TypeError, ValueError):
return
if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]):
return
profile["contours"] = [
{"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
for role, item in zip(("outer", "inner"), ordered)
]
fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"})
def _finite_vector3(value: Any) -> tuple[float, float, float] | None:
"""Return a finite numeric vector when the author supplied one."""
if (
not isinstance(value, list)
or len(value) != 3
or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value)
):
return None
result = tuple(float(component) for component in value)
return result if all(math.isfinite(component) for component in result) else None
def _validate_revolve_axis_in_sketch_plane(
atomic_id: str,
params: dict[str, Any],
sketch: dict[str, Any],
) -> None:
"""Reject a revolve axis that cannot be a construction line of its sketch.
A solid revolve is defined around an axis in the source sketch plane.
Letting an out-of-plane axis reach OCC can produce degenerate BReps that
fail much later during tessellation, so enforce this geometric invariant
before candidate staging. Malformed vectors are left to CDSL schema
validation, which can report their field-level shape.
"""
axis = params.get("axis")
workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None
if not isinstance(axis, dict) or not isinstance(workplane, dict):
return
axis_origin = _finite_vector3(axis.get("origin_mm"))
axis_direction = _finite_vector3(axis.get("direction"))
plane_origin = _finite_vector3(workplane.get("origin_mm"))
plane_normal = _finite_vector3(workplane.get("normal"))
if None in {axis_origin, axis_direction, plane_origin, plane_normal}:
return
assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None
direction_length = math.sqrt(sum(component * component for component in axis_direction))
normal_length = math.sqrt(sum(component * component for component in plane_normal))
if direction_length == 0 or normal_length == 0:
return
direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length))
if direction_normal_dot > 1e-7:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; "
f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length)
if origin_plane_offset > 1e-6:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; "
f"plane_offset_mm={origin_plane_offset:.3g}"
)
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Normalize only explicitly equivalent author spellings.
Normalization is deliberately narrow. It accepts common CAD vocabulary
where the target runtime field has identical units and meaning, while
refusing inputs that would need a guessed profile, direction, selector,
coordinate system, or topology decision. The original fragment and every
applied fix are retained in the candidate audit record.
"""
if not isinstance(fragment, dict):
return fragment, []
normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes)
_lift_param_embedded_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature")
if isinstance(feature, dict):
feature_values.append((feature, "feature"))
features = normalized.get("features", normalized.get("add_features"))
if isinstance(features, list):
feature_values.extend(
(item, f"features[{index}]")
for index, item in enumerate(features)
if isinstance(item, dict)
)
for current_feature, location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
params = current_feature.get("params")
if not isinstance(params, dict):
continue
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}:
# CAD systems commonly call a blind extrusion's travel "depth".
# CDSL calls the exact same signed scalar ``distance_mm``.
_move_equivalent_field(
params,
source="depth_mm",
target="distance_mm",
location=f"{location}.params",
fixes=fixes,
)
if atomic_id in {"revolve_add", "revolve_cut"}:
_move_equivalent_field(
params,
source="angle_degrees",
target="angle_deg",
location=f"{location}.params",
fixes=fixes,
)
_normalize_angle_radians(params, location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if axis is None:
axis = {}
params["axis"] = axis
if isinstance(axis, dict):
_move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if isinstance(axis, dict):
_normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes)
shared_axis = normalized.get("revolve_axis")
if shared_axis is not None:
if not isinstance(shared_axis, dict):
raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object")
_normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes)
revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")]
if not revolved:
raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut")
for feature, location in feature_values:
if not str(feature.get("atomic_id") or "").startswith("revolve_"):
continue
params = feature.get("params")
if not isinstance(params, dict):
continue
axis = params.get("axis")
if not isinstance(axis, dict) or not axis:
params["axis"] = deepcopy(shared_axis)
fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"})
sketch_values: list[tuple[dict[str, Any], str]] = []
sketch = normalized.get("sketch")
if isinstance(sketch, dict):
sketch_values.append((sketch, "sketch"))
sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
sketch_feature_index = 0
for current_feature, feature_location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
if not atomic_id.startswith(("extrude_", "revolve_")):
continue
current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None
sketch_feature_index += 1
if atomic_id.startswith("extrude_"):
params = current_feature.get("params")
if isinstance(params, dict):
_normalize_extrude_direction(
params,
sketch=current_sketch,
location=f"{feature_location}.params",
fixes=fixes,
)
for current_sketch, location in sketch_values:
profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
if isinstance(profile, dict):
_normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes)
# Earlier versions advertised sphere_add as sketch-backed even though its
# executor has always used only radius_mm and center_mm. Preserve that
# single-feature spelling without keeping an unused locator sketch in the
# immutable CDSL document.
if (
len(feature_values) == 1
and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add"
and len(sketch_values) == 1
):
normalized.pop("sketch", None)
normalized.pop("sketches", None)
normalized.pop("add_sketches", None)
fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"})
return normalized, fixes
def materialize_autonomous_fragment(
base_cdsl: dict[str, Any] | None,
fragment: dict[str, Any],
*,
engine: Any,
selector_tokens: dict[str, dict[str, Any]],
max_features: int,
source: str = "legacy_restore",
allow_legacy_aliases: bool = True,
expected_atomic_id: str = "",
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata.
Workplanes, profiles, feature parameters, directions and boolean meaning
pass through exactly as the author supplied them. Local CDSL validation and
full engine rebuild happen after this function, before a checkpoint exists.
"""
# Delayed import avoids engine_service -> selector_bindings -> this module
# becoming an import cycle.
from app.services.engine_service import feature_atomic_contract
from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
if not allow_legacy_aliases:
if compatibility_fixes:
first = compatibility_fixes[0]
location = str(first.get("path") or "fragment")
legacy = str(first.get("from") or "legacy field")
canonical = str(first.get("to") or "canonical field")
raise AutonomousFragmentError(
f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: "
f"{legacy} is a legacy spelling; use {canonical}"
)
try:
validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id)
except CanonicalFragmentError as error:
raise AutonomousFragmentError(str(error)) from error
normalized_fragment = deepcopy(fragment)
compatibility_fixes = []
sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []})
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
existing_features = document.get("features") if isinstance(document.get("features"), list) else []
used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)}
used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)}
last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else ""
materialized_sketches: list[dict[str, Any]] = []
materialized_features: list[dict[str, Any]] = []
sketch_index = 0
for source_feature in features:
# Materialization injects server-owned ids, host faces and pattern
# sources. Work on a private copy so the original tool-call fragment
# remains intact in audit records and diagnostic replacement cards.
source_feature = deepcopy(source_feature)
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
atomic_id = str(source_feature.get("atomic_id") or "")
if not atomic_id:
raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id")
contract = feature_atomic_contract(engine, atomic_id)
params = source_feature.get("params")
if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object")
if atomic_id.startswith("revolve_") and params.get("angle_deg") is None:
# A shared batch axis is deliberately limited to the axis. A
# default revolution angle would silently turn valid partial
# revolves into a different solid, so it remains author-owned.
raise AutonomousFragmentError(
f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
"supplies only params.axis. Declare an explicit angle in degrees."
)
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
tokens = source_feature.pop("selector_tokens", [])
token_list_is_valid = (
isinstance(tokens, list)
and all(isinstance(token, str) for token in tokens)
and len(set(tokens)) == len(tokens)
)
if not token_list_is_valid and not token_backed_param:
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
if not token_list_is_valid:
tokens = []
selected: list[dict[str, Any]] = []
invalid_tokens: list[str] = []
for token in tokens:
candidate = selector_tokens.get(token)
if candidate is None:
if token_backed_param:
invalid_tokens.append(token)
continue
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot")
if token_backed_param:
# Report all author-correctable hole errors at once. A hole is
# topology-sensitive, so its host face remains server-owned and
# must be injected from one current face token.
issues: list[str] = []
author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"}
if "host_face" in params:
issues.append("params.host_face is server-owned; use selector_tokens")
missing = [name for name in contract["required_params"] if name != "host_face" and name not in params]
if missing:
issues.append("missing params: " + ", ".join(missing))
unexpected = sorted(name for name in params if name not in author_params and name != "host_face")
if unexpected:
issues.append("unsupported params: " + ", ".join(unexpected))
if not token_list_is_valid:
issues.append("selector_tokens must be a unique array of opaque tokens")
if invalid_tokens:
issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face":
issues.append(f"{atomic_id} requires exactly one face selector token for its host face")
if issues:
raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues))
if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif token_backed_param:
# Hole token validation above deliberately aggregates every
# actionable error before this materialization boundary.
pass
elif atomic_id.startswith("revolve_"):
axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
raise AutonomousFragmentError(
f"{atomic_id} requires params.axis with explicit origin_mm and direction; "
"the revolve axis is author-defined geometry, not a topology selector token"
)
feature_id = _autonomous_id("feature", used_feature_ids)
output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"}
output["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]:
if sketch_index >= len(sketches):
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
sketch = sketches[sketch_index]
sketch_index += 1
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id
if atomic_id.startswith("revolve_"):
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
params["source_feature_ids"] = [last_feature_id]
if isinstance(slot, dict) and slot.get("path") == "feature.selectors":
output["selectors"] = selected
elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane":
output["params"]["mirror_plane"] = selected[0]
output["selectors"] = []
elif isinstance(slot, dict) and slot.get("path") == "params.host_face":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
else:
output["selectors"] = []
materialized_features.append(output)
last_feature_id = feature_id
if sketch_index != len(sketches):
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, {
"schema_version": "cad.autonomous-fragment.v1",
"source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes,
"compatibility_fix_count": len(compatibility_fixes),
"legacy_input": source != "tool_call" or bool(compatibility_fixes),
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features],
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
}