789 lines
39 KiB
Python
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", [])],
|
|
}
|