220 lines
9.0 KiB
Python
220 lines
9.0 KiB
Python
import math
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
|
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
|
from app.cad_agent.ports import AdapterUnavailable
|
|
from app.cad_agent.application.single_stage import SingleStageExecutor
|
|
from app.services.engine_service import validate_cdsl, validate_cdsl_shape
|
|
from app.settings import get_settings
|
|
|
|
|
|
def _base_feature() -> dict:
|
|
return {
|
|
"name": "base",
|
|
"operation": "extrude_add_blind",
|
|
"params": {"distance_mm": 8, "result_mode": "new_body"},
|
|
"sketch": {
|
|
"workplane": {
|
|
"origin_mm": [0, 0, 0],
|
|
"x_dir": [1, 0, 0],
|
|
"normal": [0, 0, 1],
|
|
},
|
|
"profile": {
|
|
"type": "polygon",
|
|
"vertices": [[-40, -25], [40, -25], [40, 25], [-40, 25]],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _authoring(*features: dict) -> dict:
|
|
return {
|
|
"schema_version": "cad.author.v1",
|
|
"units": "mm",
|
|
"bodies": [{"name": "main", "features": list(features)}],
|
|
}
|
|
|
|
|
|
def test_authoring_compiles_to_runtime_cdsl_and_publishes_one_revision() -> None:
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
authoring = _authoring(_base_feature())
|
|
runtime_cdsl, audit = runtime.compile_authoring(authoring)
|
|
|
|
assert runtime_cdsl["schema"] == "cad.runtime.v1"
|
|
assert runtime_cdsl["bodies"] == [{"id": "body_001", "name": "main"}]
|
|
assert runtime_cdsl["features"][0]["id"] == "feature_001"
|
|
assert audit["feature_ids"] == {"base": "feature_001"}
|
|
validate_cdsl_shape(runtime_cdsl, runtime.engine)
|
|
validate_cdsl(runtime_cdsl, runtime.engine)
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
artifacts = FileArtifactStore(Path(temporary) / "artifacts")
|
|
task_id = "cad_abcdefghijkl"
|
|
artifacts.initialize_task(task_id, "make a rectangular plate")
|
|
result = SingleStageExecutor(None, artifacts, runtime).execute(task_id, authoring)
|
|
|
|
assert result["status"] == "completed"
|
|
revision = str(result["revision_id"])
|
|
assert result["executed_feature_ids"] == ["feature_001"]
|
|
for relative in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json", "renders/render-manifest.json"):
|
|
assert artifacts.artifact_path(task_id, f"revisions/{revision}/{relative}").is_file()
|
|
|
|
|
|
def test_selector_failure_keeps_the_successful_prefix() -> None:
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
invalid_fillet = {
|
|
"name": "bad_fillet",
|
|
"operation": "fillet",
|
|
"depends_on": ["base"],
|
|
"params": {"radius_mm": 1},
|
|
# extrude.end is a face output. Using it for an edge-only operation
|
|
# is a deliberate selector-kind failure, not a geometry fallback.
|
|
"selectors": [{"kind": "edge", "source": "base.top_planar_face"}],
|
|
}
|
|
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature(), invalid_fillet))
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
built, diagnostics = runtime.rebuild_best_effort(
|
|
runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_selector_failure",
|
|
)
|
|
|
|
assert built["executed_feature_ids"] == ["feature_001"]
|
|
assert diagnostics[0]["feature_id"] == "feature_002"
|
|
assert diagnostics[0]["code"] == "SELECTOR_KIND_MISMATCH"
|
|
|
|
|
|
def test_cylinder_cap_selector_is_compiled_into_hole_host_face_and_executes() -> None:
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
base = {
|
|
"name": "base_flange",
|
|
"operation": "cylinder_add",
|
|
"params": {
|
|
"radius_mm": 30,
|
|
"height_mm": 10,
|
|
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
|
|
},
|
|
}
|
|
bore = {
|
|
"name": "center_bore",
|
|
"operation": "hole_wizard",
|
|
"params": {
|
|
"hole_type": "simple",
|
|
"diameter_mm": 12,
|
|
"depth_mm": 10,
|
|
"end_condition": {"type": "through_all", "solidworks_code": 1},
|
|
"positions": [{"mm": [0, 0, 10]}],
|
|
},
|
|
"selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}],
|
|
}
|
|
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(base, bore))
|
|
compiled_bore = runtime_cdsl["features"][1]
|
|
assert compiled_bore["depends_on"] == ["feature_001"]
|
|
assert "selectors" not in compiled_bore
|
|
assert compiled_bore["params"]["host_face"] == {
|
|
"kind": "face",
|
|
"output_role": "cylinder.end",
|
|
"owner_feature_id": "feature_001",
|
|
"source": "runtime_snapshot",
|
|
"confidence": 1.0,
|
|
"match_mode": "unique",
|
|
}
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
built, diagnostics = runtime.rebuild_best_effort(
|
|
runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_cylinder_host",
|
|
)
|
|
|
|
assert diagnostics == []
|
|
assert built["executed_feature_ids"] == ["feature_001", "feature_002"]
|
|
|
|
|
|
def test_authoring_circle_diameter_is_lowered_to_runtime_radius() -> None:
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
authoring = _authoring({
|
|
"name": "round_base",
|
|
"operation": "extrude_add_blind",
|
|
"params": {"distance_mm": 8, "result_mode": "new_body"},
|
|
"sketch": {
|
|
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
|
"profile": {"type": "circle", "diameter_mm": 20, "center_mm": [0, 0]},
|
|
},
|
|
})
|
|
compiled, _audit = runtime.compile_authoring(authoring)
|
|
assert compiled["geometry"]["sketches"][0]["profile"] == {
|
|
"type": "circle", "center": [0.0, 0.0], "radius_mm": 10.0,
|
|
}
|
|
|
|
|
|
def test_flange_bolt_host_is_built_before_source_topology_is_replaced() -> None:
|
|
"""An exposed base cap remains a valid bolt host before later fusions/cuts."""
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
circle_sketch = lambda z, normal, diameter: {
|
|
"workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": normal},
|
|
"profile": {"type": "circle", "diameter_mm": diameter, "center_mm": [0, 0]},
|
|
}
|
|
bolt_positions = [
|
|
{"mm": [43 * math.cos(math.radians(angle)), 43 * math.sin(math.radians(angle)), 12]}
|
|
for angle in range(0, 360, 45)
|
|
]
|
|
authoring = _authoring(
|
|
{
|
|
"name": "base_flange", "operation": "cylinder_add",
|
|
"params": {"radius_mm": 60, "height_mm": 12, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}},
|
|
},
|
|
{
|
|
"name": "rear_shallow_pad", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"],
|
|
"params": {"distance_mm": 4, "result_mode": "fuse", "reverse": False},
|
|
"sketch": circle_sketch(0, [0, 0, -1], 105),
|
|
},
|
|
{
|
|
"name": "rear_guide_boss", "operation": "extrude_add_blind", "depends_on": ["rear_shallow_pad"],
|
|
"params": {"distance_mm": 12, "result_mode": "fuse", "reverse": False},
|
|
"sketch": circle_sketch(0, [0, 0, -1], 38),
|
|
},
|
|
{
|
|
"name": "bolt_holes", "operation": "hole_wizard",
|
|
"params": {
|
|
"hole_type": "counterbore", "diameter_mm": 8, "depth_mm": 12,
|
|
"end_condition": {"type": "through_all_both", "solidworks_code": 7},
|
|
"counterbore": {"diameter_mm": 10, "depth_mm": 4}, "positions": bolt_positions,
|
|
},
|
|
"selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}],
|
|
},
|
|
{
|
|
"name": "front_hub_boss", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"],
|
|
"params": {"distance_mm": 14, "result_mode": "fuse", "reverse": False},
|
|
"sketch": circle_sketch(12, [0, 0, 1], 56),
|
|
},
|
|
{
|
|
"name": "center_through_cut", "operation": "extrude_cut_through", "depends_on": ["front_hub_boss", "rear_guide_boss"],
|
|
"params": {"end_condition": {"type": "through_all_both", "solidworks_code": 7}},
|
|
"sketch": circle_sketch(0, [0, 0, 1], 32),
|
|
},
|
|
)
|
|
compiled, audit = runtime.compile_authoring(authoring)
|
|
assert audit["implicit_selector_dependencies"] == {"bolt_holes": ["base_flange"]}
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
rebuilt = runtime.rebuild(compiled, temporary, "cad_abcdefghijkl", "flange_source_order")
|
|
|
|
assert rebuilt["health"]["feature_count"] == 6
|
|
|
|
|
|
def test_service_failure_during_prefix_export_is_not_reclassified_as_a_model_error(monkeypatch) -> None:
|
|
runtime = ProfileCadRuntime(get_settings())
|
|
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature()))
|
|
|
|
def unavailable(*_args, **_kwargs):
|
|
raise AdapterUnavailable("renderer unavailable")
|
|
|
|
monkeypatch.setattr(runtime, "rebuild", unavailable)
|
|
with TemporaryDirectory() as temporary:
|
|
try:
|
|
runtime.rebuild_best_effort(runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_service_failure")
|
|
except AdapterUnavailable as error:
|
|
assert "renderer unavailable" in str(error)
|
|
else:
|
|
raise AssertionError("service failure was incorrectly converted into a model repair diagnostic")
|