Files
cdsl-cad/backend/app/services/engine_service.py
T
2026-08-24 14:52:08 +08:00

516 lines
24 KiB
Python

from __future__ import annotations
import copy
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.settings import Settings
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
sys.path.insert(0, parent)
import cdsl_engine
return cdsl_engine
def _walk(value: Any) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
if isinstance(value, dict):
for key, child in value.items():
result.append((str(key), child))
result.extend(_walk(child))
elif isinstance(value, list):
for child in value:
result.extend(_walk(child))
return result
def _engine_schema(engine: Any) -> dict[str, Any]:
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
return schema
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
document = _engine_schema(engine)
schema_name = str(document.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
schema_path = Path(str(engine.__file__)).with_name(schema_name)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
return schema
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
validator = Draft202012Validator(load_cdsl_json_schema(engine))
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
if not errors:
return
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "")
if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id):
raise ValueError("part_id must use letters, numbers, underscores, or hyphens")
forbidden = {"compiler_context", "unknown_shape", "complex_arc_shape", "contour_edges_mm", "contour_regions_mm", "entities"}
for key, value in _walk(cdsl):
if key in forbidden or (isinstance(value, str) and value in {"unknown_shape", "complex_arc_shape"}):
raise ValueError(f"Training-unsafe CDSL field: {key}")
features = cdsl.get("features")
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches:
raise ValueError("CDSL requires features and parameterized sketches")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
supported_atomic_ids = sorted(
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
)
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
atomic_id = str(feature.get("atomic_id") or "")
if not atomic_id:
raise ValueError(f"Feature {fid} has no atomic_id")
contract = atomic_contracts.get(atomic_id)
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
if feature.get("execution_status") == "deferred":
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
raise ValueError(
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
)
params = feature.get("params")
if not isinstance(params, dict):
raise ValueError(f"Feature {fid} params must be an object")
for parameter_name in contract.get("required_params") or []:
if params.get(parameter_name) is None:
raise ValueError(f"Feature {fid} ({atomic_id}) is missing required parameter: {parameter_name}")
if atomic_id.startswith("extrude_") and float(params.get("distance_mm") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires distance_mm > 0 for runtime rebuild")
if atomic_id.startswith("revolve_") and float(params.get("angle_deg") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires angle_deg > 0 for runtime rebuild")
if contract.get("requires_sketch") and str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} ({atomic_id}) requires a valid sketch_id")
for dependency in feature.get("depends_on") or []:
if dependency not in feature_ids:
raise ValueError(f"Feature {fid} has a forward or missing dependency")
for sketch in sketches:
profile = sketch.get("profile")
if sketch.get("profile_from"):
continue
if not isinstance(profile, dict):
raise ValueError(f"Sketch {sketch.get('id')} has no self-contained profile")
profile_type = str(profile.get("type") or "")
if profile_type == "polygon":
if not profile.get("vertices"):
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
raise ValueError(f"CDSL engine runtime preflight failed: {error}") from error
if not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(f"CDSL engine runtime preflight failed: {analysis.document_blockers[0].code}")
blockers = ", ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"CDSL engine runtime preflight failed: feature {first.feature_id}: {blockers}")
def _parameter_id(path: list[str]) -> str:
return "param_" + "_".join(re.sub(r"[^a-zA-Z0-9]+", "_", item).strip("_") for item in path)
def _parameter_label(path: list[str]) -> str:
return " / ".join(path[-2:]).replace("_mm", " (mm)").replace("_", " ")
def _derived_parameters(cdsl: dict[str, Any]) -> list[dict[str, Any]]:
parameters: list[dict[str, Any]] = []
def add(path: list[str], value: Any, group: str) -> None:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return
number = float(value)
magnitude = max(abs(number), 1.0)
parameters.append({
"id": _parameter_id(path),
"name": ".".join(path),
"display_name": _parameter_label(path),
"path": path,
"value": number,
"default_value": number,
"minimum": 0.01 if number >= 0 else -magnitude * 10,
"maximum": magnitude * 10,
"step": 0.1 if abs(number) < 100 else 1.0,
"precision": 2,
"unit": "mm" if path[-1].endswith("_mm") else "",
"group": group,
"editable": True,
})
for feature_index, feature in enumerate(cdsl.get("features") or []):
for key, value in (feature.get("params") or {}).items():
add(["features", str(feature_index), "params", str(key)], value, "Features")
for sketch_index, sketch in enumerate(cdsl.get("geometry", {}).get("sketches") or []):
profile = sketch.get("profile") or {}
def walk_profile(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
for key, child in value.items():
walk_profile(child, [*path, str(key)])
elif isinstance(value, list):
# Coordinates are topology anchors, not user-facing dimensions.
return
else:
add(path, value, "Sketches")
walk_profile(profile, ["geometry", "sketches", str(sketch_index), "profile"])
return parameters
def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
declared = cdsl.get("meta", {}).get("editable_parameters")
if isinstance(declared, list) and declared:
values = [item for item in declared if isinstance(item, dict) and isinstance(item.get("path"), list)]
if values:
return {"schema_version": "1.0", "parameters": values, "source": "declared"}
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
topology_faces = (preview or {}).get("topology_faces")
if isinstance(topology_faces, list) and topology_faces:
references = []
for face in topology_faces:
if not isinstance(face, dict):
continue
frame = face.get("frame")
center = face.get("center")
normal = face.get("normal")
if not isinstance(frame, dict) or not isinstance(center, list) or not isinstance(normal, list):
continue
references.append({
"id": str(face.get("id") or f"face_{len(references):03d}"),
"selectorType": "face",
"label": str(face.get("surface_type") or "face"),
"center": center,
"normal": normal,
"frame": frame,
"bbox": face.get("bbox") or {},
"surface_type": str(face.get("surface_type") or "unknown"),
"triangle_start": int(face.get("triangle_start") or 0),
"triangle_count": int(face.get("triangle_count") or 0),
})
if references:
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
bbox = engine_result.get("bbox_mm") or {}
minimum = [float(value) for value in bbox.get("min") or [0, 0, 0]]
maximum = [float(value) for value in bbox.get("max") or [0, 0, 0]]
if len(minimum) != 3 or len(maximum) != 3:
raise ValueError("Engine result is missing a valid bounding box")
center = [(minimum[index] + maximum[index]) / 2 for index in range(3)]
definitions = [
("top", [center[0], center[1], maximum[2]], [0, 0, 1], [1, 0, 0], [0, 1, 0]),
("bottom", [center[0], center[1], minimum[2]], [0, 0, -1], [1, 0, 0], [0, -1, 0]),
("right", [maximum[0], center[1], center[2]], [1, 0, 0], [0, 1, 0], [0, 0, 1]),
("left", [minimum[0], center[1], center[2]], [-1, 0, 0], [0, 1, 0], [0, 0, -1]),
("front", [center[0], maximum[1], center[2]], [0, 1, 0], [1, 0, 0], [0, 0, -1]),
("back", [center[0], minimum[1], center[2]], [0, -1, 0], [1, 0, 0], [0, 0, 1]),
]
references = [
{
"id": f"face_{name}", "selectorType": "face", "label": name,
"center": point, "normal": normal,
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
"bbox": {"min": minimum, "max": maximum},
}
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
target: Any = document
for index, key in enumerate(path):
final = index == len(path) - 1
if isinstance(target, list):
item_index = int(key)
if item_index < 0 or item_index >= len(target):
raise ValueError("Parameter path is no longer valid")
if final:
target[item_index] = value
else:
target = target[item_index]
elif isinstance(target, dict):
if key not in target:
raise ValueError("Parameter path is no longer valid")
if final:
target[key] = value
else:
target = target[key]
else:
raise ValueError("Parameter path is no longer valid")
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
contract = parameter_contract(cdsl)
entries = {str(item.get("id")): item for item in contract["parameters"]}
updated = copy.deepcopy(cdsl)
for parameter_id, raw_value in values.items():
entry = entries.get(parameter_id)
value = float(raw_value)
if entry is None or not entry.get("editable", False):
raise ValueError(f"Unknown editable parameter: {parameter_id}")
if not math.isfinite(value):
raise ValueError("Parameter values must be finite")
minimum, maximum = entry.get("minimum"), entry.get("maximum")
if isinstance(minimum, (int, float)) and value < float(minimum):
raise ValueError(f"{parameter_id} is below its minimum")
if isinstance(maximum, (int, float)) and value > float(maximum):
raise ValueError(f"{parameter_id} is above its maximum")
path = entry.get("path")
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
raise ValueError(f"{parameter_id} has an invalid path")
_set_parameter_value(updated, path, value)
declared = updated.get("meta", {}).get("editable_parameters")
if isinstance(declared, list):
for declared_entry in declared:
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
declared_entry["value"] = value
return updated, parameter_contract(updated)
def _part_skill_audit(
part_skills: dict[str, Any] | None,
request: str,
generation_assumptions: list[str] | None,
) -> dict[str, Any]:
"""Normalize the planning audit persisted beside a product revision."""
audit = copy.deepcopy(part_skills) if isinstance(part_skills, dict) else {}
skills = [item for item in audit.get("skills") or [] if isinstance(item, dict)]
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
if not skill_ids:
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {}
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
"structural_intent": str(audit.get("structural_intent") or request),
"skill_ids": skill_ids,
"skills": skills,
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
"assumptions": [str(item) for item in generation_assumptions or []],
"evidence": evidence,
"capability_translations": [
item for item in audit.get("capability_translations") or [] if isinstance(item, dict)
],
})
return audit
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
intent = design_intent if isinstance(design_intent, dict) else {}
return {
"design_intent_id": str(intent.get("intent_id") or design_intent_id or ""),
"design_intent_path": design_intent_path,
"design_intent_structures": [
str(item.get("id") or "")
for item in intent.get("structures") or []
if isinstance(item, dict) and item.get("id")
],
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
{
"id": str(item.get("id") or ""),
"bridge": str(item.get("bridge") or ""),
"source": str(item.get("source") or ""),
}
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []),
"design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []),
"capability_translations": list(part_skill_audit.get("capability_translations") or []),
}
def build_revision(
*,
settings: Settings,
store: WorkspaceStore,
task_id: str | None,
request: str,
cdsl: dict[str, Any],
reference_ids: list[str],
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
design_intent_status: str = "",
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
revision_id, revision_dir = store.next_revision(task["task_id"])
cdsl_path = revision_dir / "model.cdsl.json"
step_path = revision_dir / "model.step"
glb_path = revision_dir / "model.glb"
report_path = revision_dir / "rebuild-report.json"
request_path = revision_dir / "request.json"
references_path = revision_dir / "references.json"
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
part_skills_path = revision_dir / "part-skills.json"
intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {}
intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)]
merged_assumptions = list(dict.fromkeys([
*intent_assumptions,
*(str(item) for item in generation_assumptions or [] if str(item)),
]))
part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions)
intent_id = str(intent.get("intent_id") or design_intent_id or "")
if design_intent_path:
design_intent_path = str(design_intent_path)
part_skill_audit.update({
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"intent_status": str(intent.get("status") or design_intent_status or ""),
"design_intent_assumptions": intent_assumptions,
"design_intent_capability_gaps": [
item for item in intent.get("capability_gaps") or [] if isinstance(item, dict)
],
})
generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id)
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids})
write_json(part_skills_path, part_skill_audit)
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
record = {
"revision_id": revision_id,
"status": status,
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"reference_ids": reference_ids,
"summary": summary,
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
if status == "success":
record.update({
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"engine": engine_name,
})
else:
record["error"] = error
return record
try:
cdsl_copy = copy.deepcopy(cdsl)
if not isinstance(cdsl_copy, dict):
raise ValueError("CDSL must be a JSON object")
cdsl_copy["part_id"] = task["task_id"]
meta = cdsl_copy.setdefault("meta", {})
if not isinstance(meta, dict):
raise ValueError("CDSL meta must be an object when present")
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
write_json(cdsl_path, cdsl_copy)
write_json(parameters_path, parameter_contract(cdsl_copy))
validate_cdsl(cdsl_copy, engine)
# Product revisions are semantic CDSL artifacts. Do not route them
# through the legacy rebuild entry point, which is allowed to use
# compiler_context/translator compatibility fallbacks.
engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
report = {
"engine_result": engine_result,
"preview": preview,
"generation_context": generation_context,
"validated_at": now_iso(),
}
write_json(report_path, report)
revision = revision_record("success", engine_name=str(engine_result["engine"]))
except Exception as error:
if not cdsl_path.is_file() and isinstance(cdsl, dict):
write_json(cdsl_path, copy.deepcopy(cdsl))
write_json(report_path, {
"error": str(error),
"generation_context": generation_context,
"validated_at": now_iso(),
})
revision = revision_record("failed", error=str(error))
store.update_task(task["task_id"], revision)
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}