From beaa5dd9fefd80e75bdbabf1ddd255f8db4b25e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Tue, 25 Aug 2026 17:41:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=9F=E6=88=90=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 1 + .../bridge/atomic-pattern-holes.md | 2 +- .../part-skills/bridge/planning-flange.md | 2 +- .../references/part-skills/catalog.json | 36 +- backend/app/main.py | 57 +- backend/app/models/contracts.py | 7 + backend/app/services/agent_service.py | 524 +++-- backend/app/services/attachments.py | 20 +- backend/app/services/cdsl_patch.py | 122 + backend/app/services/editing.py | 51 +- backend/app/services/engine_service.py | 123 +- .../app/services/flange_sleeve_template.py | 273 --- backend/app/services/library.py | 26 +- backend/app/services/part_skills.py | 78 +- backend/app/services/quality.py | 262 +++ backend/app/services/storage.py | 122 +- backend/app/settings.py | 2 + backend/cdsl_importer/__init__.py | 6 + .../distill_output3.py | 0 .../cdsl_importer/legacy_profile_adapter.py | 117 + backend/cdsl_importer/legacy_profiles.py | 2075 +++++++++++++++++ .../solidworks_to_cdsl.py} | 16 +- backend/engine/cdsl_engine/__init__.py | 12 +- backend/engine/cdsl_engine/batch_rebuild.py | 3 +- .../engine/cdsl_engine/build123d_adapter.py | 16 +- backend/engine/cdsl_engine/cdsl_schema.json | 90 +- backend/engine/cdsl_engine/design_intent.py | 331 --- .../cdsl_engine/design_intent_schema.json | 124 - .../engine/cdsl_engine/generation_compiler.py | 212 -- backend/engine/cdsl_engine/generation_spec.py | 177 -- .../cdsl_engine/generation_spec_schema.json | 100 - backend/engine/cdsl_engine/phase_pools.py | 6 +- .../engine/cdsl_engine/profile_schema.json | 190 +- backend/engine/cdsl_engine/rebuild.py | 15 +- backend/engine/cdsl_engine/runtime.py | 6 +- backend/engine/cdsl_engine/sketch_solver.py | 2036 ++-------------- .../remove_generation_spec_artifacts.py | 159 ++ backend/tests/test_agent_tool_arguments.py | 59 +- backend/tests/test_design_intent.py | 203 -- backend/tests/test_design_intent_flow.py | 94 +- backend/tests/test_direct_cdsl_pipeline.py | 360 +++ .../tests/test_engine_runtime_foundation.py | 15 +- backend/tests/test_part_skills.py | 159 +- backend/tests/test_profile_schema.py | 68 +- .../app/api/tasks/[taskId]/quality/route.ts | 11 + frontend/src/app/globals.css | 12 + frontend/src/components/cad-message-parts.tsx | 54 + .../src/components/cad-viewer-preview.tsx | 7 + frontend/src/lib/cad-artifacts.ts | 7 + frontend/src/lib/cad-types.ts | 17 + 50 files changed, 4204 insertions(+), 4261 deletions(-) create mode 100644 backend/app/services/cdsl_patch.py delete mode 100644 backend/app/services/flange_sleeve_template.py create mode 100644 backend/app/services/quality.py create mode 100644 backend/cdsl_importer/__init__.py rename backend/{engine/cdsl_engine => cdsl_importer}/distill_output3.py (100%) create mode 100644 backend/cdsl_importer/legacy_profile_adapter.py create mode 100644 backend/cdsl_importer/legacy_profiles.py rename backend/{engine/cdsl_engine/convert_to_cdsl.py => cdsl_importer/solidworks_to_cdsl.py} (98%) delete mode 100644 backend/engine/cdsl_engine/design_intent.py delete mode 100644 backend/engine/cdsl_engine/design_intent_schema.json delete mode 100644 backend/engine/cdsl_engine/generation_compiler.py delete mode 100644 backend/engine/cdsl_engine/generation_spec.py delete mode 100644 backend/engine/cdsl_engine/generation_spec_schema.json create mode 100644 backend/scripts/remove_generation_spec_artifacts.py delete mode 100644 backend/tests/test_design_intent.py create mode 100644 backend/tests/test_direct_cdsl_pipeline.py create mode 100644 frontend/src/app/api/tasks/[taskId]/quality/route.ts diff --git a/backend/.env b/backend/.env index b60a1ec1..ff21e115 100644 --- a/backend/.env +++ b/backend/.env @@ -29,6 +29,7 @@ CDSL_KIMI_VISION_MODELS= CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false CDSL_OPENAI_STRICT_TOOL_SCHEMA=false CDSL_KIMI_STRICT_TOOL_SCHEMA=true +CDSL_MAX_REPAIR_ATTEMPTS=4 # To enable individual models instead of every model from a provider, keep the # provider-wide switch false and list exact IDs, for example: # CDSL_OPENAI_STRICT_TOOL_MODELS=gpt-4.1 diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md index 204ad8ed..b3945d63 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md @@ -2,4 +2,4 @@ - Build a valid seed hole first and preserve its host frame and functional subtype. - Use `pattern_linear` for one/two-dimensional rows and `pattern_mirror` for symmetry. -- For circular layouts, use an explicit `circles` profile expansion and record the audit status; do not invent a circular-pattern atomic. +- For circular layouts, use an explicit `circles` profile expansion; do not invent a circular-pattern atomic. diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md index e4e877fa..6b48bc1e 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md @@ -3,5 +3,5 @@ - Use for a circular flange, flanged sleeve, or coupling flange with a shared central axis. - Invariants: coaxial outside diameter, bore/hub, sealing land, and fastener holes on a pitch circle. - Plan: `revolve_add` for stepped axisymmetric bodies or circular `extrude_add_blind` -> coaxial bore -> one bolt-hole definition -> explicit circle set or supported repetition -> late finishing. -- No circular-pattern atomic exists. Expand a bolt circle into explicit circles in one cut profile and record `expanded` in the audit. +- No circular-pattern atomic exists. Express a bolt circle with explicit circles in one cut profile when the engine schema supports that profile. - Keep bolt-circle diameter, count, start angle, bore, and hub dimensions distinct. diff --git a/backend/agent/skills/cad-engine/references/part-skills/catalog.json b/backend/agent/skills/cad-engine/references/part-skills/catalog.json index ae537cb1..5eceb8f8 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/catalog.json +++ b/backend/agent/skills/cad-engine/references/part-skills/catalog.json @@ -12,7 +12,6 @@ "exclude": ["flange", "法兰", "3d bracket", "mounting bracket", "轴承座"], "bridge": "bridge/planning-mounting-plate.md", "source": "source/planning/mounting-plate.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/mounting-plate-hole-layout", "functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -24,7 +23,6 @@ "exclude": ["flat mounting plate only", "仅底板", "sheet metal"], "bridge": "bridge/planning-mounting-bracket.md", "source": "source/planning/mounting-bracket.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -36,7 +34,6 @@ "exclude": ["flange nut", "法兰螺母", "side tab"], "bridge": "bridge/planning-flange.md", "source": "source/planning/flange.planning.md", - "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles"], "related": ["functional/flange-bolt-circle", "functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -48,7 +45,6 @@ "exclude": ["spindle assembly", "bearing assembly", "gearbox shaft assembly", "主轴组件", "轴承组件"], "bridge": "bridge/planning-simple-shaft.md", "source": "source/planning/simple-shaft-or-cylindrical-rod.planning.md", - "capability_translation_rules": ["exact: revolve_or_extrude_supported"], "related": ["functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/through-hole-cut", "atomic/fillet-chamfer-last"] }, { @@ -60,7 +56,6 @@ "exclude": ["complete rolling bearing", "bearing assembly", "完整轴承", "轴承组件"], "bridge": "bridge/planning-bearing-housing.md", "source": "source/planning/bearing-housing-or-seat.planning.md", - "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["functional/bearing-bore-seat", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/extrude-base-profile", "atomic/revolve-profile-around-axis", "atomic/counterbored-hole-creation", "atomic/fillet-chamfer-last"] }, { @@ -72,25 +67,24 @@ "exclude": ["bolt", "screw", "wing nut", "cap nut", "flange nut", "螺栓", "螺钉", "蝶形螺母", "法兰螺母"], "bridge": "bridge/planning-hexagonal-nut.md", "source": "source/planning/hexagonal-nut.planning.md", - "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": ["functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/extrude-base-profile", "atomic/coaxial-bore-rule", "atomic/threaded-hole-creation", "atomic/fillet-chamfer-last"] }, - {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "capability_translation_rules": ["exact: explicit_revolve_axis"], "related": []}, - {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, - {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles", "blocked: explicit_circle_layout_required"], "related": ["atomic/pattern-holes-from-datum"]}, - {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "capability_translation_rules": ["exact: linear_or_mirror_pattern"], "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, - {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "capability_translation_rules": ["exact: obround_extrude_cut"], "related": []}, - {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "capability_translation_rules": ["approximated: thread_geometry_omitted", "exact: supported_hole_subtype"], "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, - {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "capability_translation_rules": ["exact: pattern_mirror_or_pattern_linear"], "related": []}, + {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "related": []}, + {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, + {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "related": ["atomic/pattern-holes-from-datum"]}, + {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, + {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "related": []}, + {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, + {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "related": []}, - {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "capability_translation_rules": ["exact: explicit_axis_or_frame"], "related": []}, - {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "capability_translation_rules": ["exact: hole_counterbore"], "related": []}, - {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "capability_translation_rules": ["exact: extrude_add_blind"], "related": []}, - {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "capability_translation_rules": ["omitted: selector_unavailable", "exact: unique_selector"], "related": []}, - {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "capability_translation_rules": ["expanded: explicit_circle_layout", "exact: pattern_linear_or_pattern_mirror"], "related": []}, - {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "capability_translation_rules": ["exact: revolve_add_or_revolve_cut"], "related": []}, - {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": []}, - {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "capability_translation_rules": ["exact: through_hole_or_explicit_cut"], "related": []} + {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "related": []}, + {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "related": []}, + {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "related": []}, + {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "related": []}, + {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "related": []}, + {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "related": []}, + {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "related": []}, + {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "related": []} ] } diff --git a/backend/app/main.py b/backend/app/main.py index a6858f17..70fff67e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -57,6 +57,7 @@ async def config() -> dict[str, Any]: "model": settings.llm_model, "configured": settings.llm_configured, "library_samples": library.count(), + "max_repair_attempts": settings.max_repair_attempts, } @@ -130,30 +131,6 @@ async def read_task(task_id: str) -> JSONResponse: return JSONResponse(task) -@app.get("/v1/tasks/{task_id}/design-intent") -async def read_current_design_intent(task_id: str) -> JSONResponse: - try: - safe_id = safe_task_id(task_id) - result = store.read_design_intent(safe_id) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - if result is None: - raise HTTPException(status_code=404, detail="No DesignIntent exists for this task") - return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result}) - - -@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}") -async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse: - try: - safe_id = safe_task_id(task_id) - result = store.read_design_intent(safe_id, intent_id) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - if result is None: - raise HTTPException(status_code=404, detail="DesignIntent not found") - return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result}) - - @app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}") async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: from fastapi.responses import FileResponse @@ -186,6 +163,38 @@ async def read_parameters(task_id: str) -> JSONResponse: return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))}) +@app.get("/v1/tasks/{task_id}/quality") +async def read_quality(task_id: str) -> JSONResponse: + try: + safe_id = safe_task_id(task_id) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + task = store.read_task(safe_id) + revision_id = str((task or {}).get("current_revision") or "") + revisions = (task or {}).get("revisions", []) + revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) + if revisions and revisions[-1].get("revision_id") != revision_id: + revision = revisions[-1] + revision_id = str(revision.get("revision_id") or "") + payload: dict[str, Any] = { + "task_id": safe_id, + "revision_id": revision_id, + "quality_status": (revision or {}).get("quality_status", ""), + "snapshot_status": (revision or {}).get("snapshot_status", "unavailable"), + "snapshot_paths": (revision or {}).get("snapshot_paths", []), + "assumptions": (revision or {}).get("generation_assumptions", []), + "verification_summary": (revision or {}).get("verification_summary", {}), + } + relative = str((revision or {}).get("quality_path") or "") + if relative: + path = store.artifact_path(safe_id, relative) + if path.is_file(): + payload["quality"] = json.loads(path.read_text(encoding="utf-8")) + if not revision: + raise HTTPException(status_code=404, detail="Task has no revision") + return JSONResponse(payload) + + @app.post("/v1/tasks/{task_id}/parameters") async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse: try: diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index 668b59ff..d50f9025 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -59,3 +59,10 @@ class CadResult(BaseModel): summary: str reference_ids: list[str] = Field(default_factory=list) engine: str = "cdsl_only" + quality_status: str = "" + quality_path: str | None = None + assumptions: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + repair_attempts: int = 0 + snapshot_paths: list[str] = Field(default_factory=list) + snapshot_status: str = "unavailable" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 611a6735..bdefac56 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -5,7 +5,9 @@ import base64 from copy import deepcopy import json import math +import re import secrets +import sys from collections.abc import AsyncIterator from pathlib import Path from typing import Any @@ -14,13 +16,10 @@ import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl -from app.services.flange_sleeve_template import ( - TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID, - build_flange_sleeve_cdsl, - flange_sleeve_plan_schema, -) +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary +from app.services.quality import QUALITY_RULE_TYPES, validate_verification from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso from app.settings import ProviderConfig, ProviderModel, Settings @@ -42,6 +41,14 @@ class RepeatedToolArgumentsError(RuntimeError): self.diagnostic_paths = diagnostic_paths or [] +class CdslRepairLimitError(RuntimeError): + """The direct CDSL repair budget is exhausted for this request.""" + + def __init__(self, diagnostic_paths: list[str]) -> None: + super().__init__("CDSL repair limit reached") + self.diagnostic_paths = diagnostic_paths + + def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") @@ -61,6 +68,11 @@ def user_visible_error_message(error: Exception, user_text: str) -> str: "请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。" + diagnostics ) + if isinstance(error, CdslRepairLimitError): + diagnostics = "、".join(error.diagnostic_paths) + if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): + return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" + return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "." return str(error) @@ -84,7 +96,7 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return None if candidate[candidate_end:].strip() or not isinstance(value, dict): return None - if not set(value).issubset({"cdsl", "summary", "assumptions"}): + if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}): return None if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str): return None @@ -135,10 +147,38 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[ } -def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: +def _cdsl_error_details(error: Exception) -> dict[str, Any]: + message = str(error) + path_match = re.search(r"(?:at|path) (\$[^: ]*)", message) + quality = getattr(error, "quality_report", None) + if isinstance(quality, dict): + return { + "code": "VERIFICATION_FAILED", + "path": "$.verification.rules", + "kind": "verification", + "repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.", + "quality": quality, + } + if "schema violation" in message: + return { + "code": "CDSL_SCHEMA_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "schema", + "repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.", + } + return { + "code": "CDSL_RUNTIME_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "runtime", + "repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.", + } + + +def invalid_cdsl_result(error: Exception) -> dict[str, Any]: + details = _cdsl_error_details(error) return { "ok": False, - "code": "INVALID_CDSL", + **details, "message": ( f"The submitted CDSL is incomplete or invalid: {error}. " "Read the authoritative local engine schema, then call generate_cdsl_model " @@ -149,7 +189,7 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") - if code == "INVALID_CDSL": + if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}: if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。" return "The CDSL model does not match the engine contract. Asking the model to correct it and retry." @@ -185,14 +225,109 @@ def _cdsl_tool_schema() -> dict[str, Any]: schema_name = str(contract.get("cdsl_json_schema_file") or "") if not schema_name or Path(schema_name).name != schema_name: raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path") - return json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + supported_atomics = { + str(item) + for item in contract.get("runtime_supported_atomic_ids") or () + if str(item) + } + declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys()) + if not supported_atomics or not supported_atomics.issubset(declared_atomics): + raise RuntimeError("Engine capability contract has invalid runtime atomic ids") + engine_parent = str(engine_dir.parent) + if engine_parent not in sys.path: + sys.path.insert(0, engine_parent) + import cdsl_engine + + registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics &= registered_atomics + if not supported_atomics: + raise RuntimeError("Engine has no registered atomic executors in common with its capability contract") + feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids") + if not isinstance(feature_atomic_definition, dict): + raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition") + feature_atomic_definition["enum"] = sorted(supported_atomics) + profile_definition = schema.get("$defs", {}).get("profile_type") + supported_profiles = { + str(item) + for item in contract.get("runtime_supported_profiles") or () + if str(item) + } + supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())} + if isinstance(profile_definition, dict) and supported_profiles: + profile_definition["enum"] = sorted(supported_profiles) + return schema except (OSError, json.JSONDecodeError, AttributeError) as error: raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error CDSL_TOOL_SCHEMA = _cdsl_tool_schema() -FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema() -GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"} +GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"} + +VERIFICATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "rules": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"enum": sorted(QUALITY_RULE_TYPES)}, + "feature": {"type": "string"}, + "expected": { + "description": ( + "For bbox, use [dx, dy, dz], " + "{min: [x, y, z], max: [x, y, z]}, or " + "{x_min, x_max, y_min, y_max, z_min, z_max}." + ), + }, + "tolerance": {"type": "number", "minimum": 0}, + "severity": {"enum": ["blocking", "warning", "informational"]}, + }, + "required": ["id", "type", "expected"], + "additionalProperties": False, + }, + }, + }, + "required": ["rules"], + "additionalProperties": False, +} + +JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]}, + "path": {"type": "string", "pattern": "^/"}, + "from": {"type": "string", "pattern": "^/"}, + "value": {}, + }, + "required": ["op", "path"], + "additionalProperties": False, +} + + +def engine_capability_manifest(settings: Settings) -> dict[str, Any]: + """Build the compact planner-facing capability contract from engine files.""" + load_engine(settings) + try: + profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []} + atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {} + declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts} + registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics = sorted(declared_atomics & registered_atomics) + declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()} + registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())} + return { + "supported_profiles": sorted(declared_profiles & registered_profiles), + "runtime_atomic_ids": supported_atomics, + "required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)}, + "unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []), + "verification_rule_types": sorted(QUALITY_RULE_TYPES), + } TOOL_SCHEMAS: list[dict[str, Any]] = [ @@ -285,29 +420,6 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, - { - "type": "function", - "function": { - "name": "generate_flange_sleeve_model", - "description": ( - "Generate a square or rectangular flange sleeve from a compact semantic plan. " - "Use this preferred tool for a flange plate with a coaxial hollow tube, " - "a stepped or tapered tip, four mounting holes, and counterbores. " - "The backend's verified template creates all CDSL workplanes, sketches, " - "feature dependencies, and feature parameters." - ), - "parameters": { - "type": "object", - "properties": { - "plan": FLANGE_SLEEVE_PLAN_SCHEMA, - "summary": {"type": "string", "minLength": 1}, - "assumptions": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["plan", "summary", "assumptions"], - "additionalProperties": False, - }, - }, - }, { "type": "function", "function": { @@ -319,12 +431,32 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "cdsl": CDSL_TOOL_SCHEMA, "summary": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, }, "required": ["cdsl", "summary", "assumptions"], "additionalProperties": False, }, }, }, + { + "type": "function", + "function": { + "name": "patch_cdsl_model", + "description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.", + "parameters": { + "type": "object", + "properties": { + "base_revision_id": {"type": "string", "minLength": 1}, + "patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA}, + "summary": {"type": "string", "minLength": 1}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, + }, + "required": ["base_revision_id", "patches", "summary", "assumptions"], + "additionalProperties": False, + }, + }, + }, ] @@ -332,7 +464,6 @@ def tools_for_model( model: ProviderModel, *, include_image_analysis: bool = True, - flange_sleeve_only: bool = False, ) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" tools = deepcopy(TOOL_SCHEMAS) @@ -342,17 +473,6 @@ def tools_for_model( for tool in tools if tool.get("function", {}).get("name") != "analyze_image_reference" ] - if flange_sleeve_only: - tools = [ - tool - for tool in tools - if tool.get("function", {}).get("name") not in { - "search_cdsl_library", - "read_cdsl_reference", - "read_current_cdsl", - "generate_cdsl_model", - } - ] if not model.strict_tool_schema: return tools @@ -445,33 +565,6 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N return None -def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool: - """Route only a high-confidence new image reference to the verified template.""" - if task_id or not isinstance(analysis, dict): - return False - visible = analysis.get("visibleFeatures", analysis.get("visible_features", [])) - values = [analysis.get("partType", analysis.get("part_type", ""))] - if isinstance(visible, list): - values.extend(visible) - text = " ".join(str(value or "").casefold() for value in values) - has_flange = "法兰" in text or "flange" in text - has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube")) - return has_flange and has_sleeve - - -def flange_sleeve_template_instruction(enabled: bool) -> str: - if not enabled: - return "" - return """ -Template routing (highest priority for this request): -- This new image reference has been classified as a flange sleeve. -- After describe_design_intent, call generate_flange_sleeve_model exactly once. -- The complete-CDSL generation tool is intentionally unavailable for this request. -- Supply only visible semantic dimensions and assumptions; omitted dimensions use - the verified template defaults. Do not ask for CDSL implementation fields. -""" - - def image_reference_instruction( attachments: list[dict[str, Any]], analysis: dict[str, Any] | None, @@ -635,15 +728,30 @@ def system_prompt( part_skill_context: str = "", image_reference_context: str = "", cad_request_context: str = "", - flange_sleeve_template_mode: bool = False, ) -> str: + capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":")) skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md" skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else "" - readme_path = settings.engine_root / "README.md" - engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else "" profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" - supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) + generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string. +- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement. +- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model. +- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime. +- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. +- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL. +- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm.""" + workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements. +4. Generate one complete generic CDSL and call generate_cdsl_model. +5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement. +6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.""" + authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities. + +Local skill: +{skill} + +Authoritative engine schema: +{profile_schema}""" return f"""You are the CDSL CAD Agent for CDSL CAD Studio. Language policy: @@ -661,31 +769,12 @@ Language policy: Tool call contract: - Every function call arguments field must contain exactly one valid JSON object. - Do not append prose, Markdown code fences, comments, or a second JSON value. -- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, - not as Markdown and not as a concatenated JSON string. -- For a square/rectangular flange with a coaxial hollow sleeve, stepped or - tapered front, and four mounting counterbores, call - generate_flange_sleeve_model. Submit only its compact semantic plan; do not - create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for - that template. The backend creates and validates the complete CDSL. -- The backend can normalize only these unambiguous legacy aliases before - validation: sketch_id -> id on sketches, sketch -> sketch_id on features, - legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. - Do not rely on it for geometry, dimensions, selectors, or feature intent. - Call describe_design_intent first with a concise natural-language plan. - Its returned text is reference context for your CDSL work, not a second CAD - contract and not a source of feature IDs, profile bindings, or selectors. -- Do not call search_cdsl_library, read_cdsl_reference, - generate_flange_sleeve_model, or generate_cdsl_model until - describe_design_intent returns its plan. -- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced - schema. Satisfy its nested object and array types exactly; do not substitute - a shorthand array for an object. For example, every hole position is - `{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`. - If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that tool again. Do not claim that the CAD model was generated. - If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL as applicable and call that same tool again. Do not claim success. +{generation_contract} Workflow limits: - Do not expose internal planning or "let me" commentary to the user while @@ -698,7 +787,7 @@ Workflow limits: the missing dimension or decision. Do not combine it with a tool trace or a generic progress update. - Use at most two CDSL-library searches per user request. If neither finds a - useful reference, stop searching and use the engine guide to either generate + useful reference, stop searching and use the available capability contract to either generate the model or ask one concise clarification question. - Do not repeatedly search for the same unavailable feature or profile. @@ -708,24 +797,12 @@ Workflow limits: {cad_request_context} - {flange_sleeve_template_instruction(flange_sleeve_template_mode)} - -You generate executable CAD through compact semantic plans or parameterized CDSL, -never raw CAD source code. For new CAD requests: +You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests: 1. Use the injected part-skill guidance, when present, only to establish the - structural plan, feature dependency order, and parameter roles. + structural plan, feature dependency order, parameter roles, and reference queries. 2. Call describe_design_intent with a concise textual plan. Ask one concise user-facing question before CAD generation if essential dimensions are missing. -3. If the requested family is a flange sleeve matching the dedicated template, - call generate_flange_sleeve_model immediately after the textual plan. Do not - search the CDSL library and do not use generate_cdsl_model for that case. -4. For other families, search the local official CDSL library. Read at least - one relevant reference when a match exists; samples provide schema-valid - expressions, not higher-priority user requirements. -5. For non-template families, generate one complete CDSL and call - generate_cdsl_model. The backend-generated CDSL is the authoritative CAD - model; the textual plan is guidance only. -6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. +{workflow} Precedence is strict: explicit user request, then CDSL schema/runtime, then part-skill guidance, then CDSL-library examples. Part skills never authorize @@ -738,29 +815,10 @@ whole-part replacement. A primary-family conflict is a hard clarification stop: ask one concise question and do not call a generation tool until the user resolves it. -For a revision, call read_current_cdsl first and preserve unrelated features. -Use the flange sleeve template only when the user wants to replace the whole -part or the current part already follows that family; otherwise submit the -complete replacement CDSL. The backend automatically uses the latest -successful revision as the parent; do not provide a revision ID. -Do not output compiler_context, unknown_shape, complex_arc_shape, entities, -contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles -and feature atomic IDs defined in the engine schema below. Read the engine -schema before selecting an atomic ID, a profile, or their parameter names. Do -not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential -dimensions or intent are missing. Ordinary explanations must not create CAD. +{authoring_context} -Local skill: -{skill} - -Local engine guide: -{engine_readme} - -Authoritative engine schema: -{profile_schema} - -Supported named profile types: -{supported_profiles} +Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative): +{capability_manifest} Injected part-skill context: {part_skill_context or "No part-family skill guidance was selected for this request."} @@ -849,18 +907,17 @@ class AgentService: image_inputs = image_attachments(conversation) recorded_image_analysis = image_reference_analysis(conversation) requires_image_intake = bool(image_inputs) and recorded_image_analysis is None - flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id) yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task) part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids) - planning_state: dict[str, Any] = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False} yield event("progress", { "step": "select_part_skill", - "label": "识别零件族", + "label": "选择建模 Skill", "status": "success", - "message": "已完成零件族与辅助建模规则识别。", + "message": "已完成建模 Skill 与辅助规则选择。", }) model_messages: list[dict[str, Any]] = [{ "role": "system", @@ -872,7 +929,6 @@ class AgentService: self.part_skill_library.render_context(part_skill_selection), image_reference_instruction(image_inputs, recorded_image_analysis), cad_request_instruction(task_id, current_task), - flange_sleeve_template_mode, ), }] model_messages.extend(messages_for_model(messages)) @@ -881,16 +937,21 @@ class AgentService: tools = tools_for_model( model, include_image_analysis=requires_image_intake, - flange_sleeve_only=flange_sleeve_template_mode, ) required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] cdsl_validation_diagnostics: list[str] = [] generation_completed = False + repair_attempts = 0 try: for iteration in range(8): + if ( + planning_state.get("phase") == "CDSL_REPAIR" + and repair_attempts >= self.settings.max_repair_attempts + ): + raise CdslRepairLimitError(cdsl_validation_diagnostics) response = await self._complete(model_messages, tools, provider, model, required_tool_name) response_choice = response["choices"][0] choice = response_choice["message"] @@ -914,6 +975,15 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") + if requires_image_intake and name != "analyze_image_reference": + result = { + "ok": False, + "code": "IMAGE_ANALYSIS_REQUIRED", + "message": "Analyze all current image attachments before using planning, library, or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"}) + continue if name == "analyze_image_reference" and recorded_image_analysis is not None: result = { "ok": False, @@ -996,8 +1066,12 @@ class AgentService: "message": "CAD 工具参数格式无效,正在请求模型修正。", }) continue + if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR": + if repair_attempts >= self.settings.max_repair_attempts: + raise CdslRepairLimitError(cdsl_validation_diagnostics) + repair_attempts += 1 cdsl_attempt_path = "" - if name == "generate_cdsl_model": + if name in GENERATION_TOOL_NAMES: cdsl_attempt_path = self._record_cdsl_attempt( conversation_id=conversation["conversation_id"], arguments=arguments, @@ -1020,6 +1094,8 @@ class AgentService: planning_state=planning_state, image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs], input_attachments=revision_input_attachments(conversation), + conversation_id=conversation["conversation_id"], + repair_attempts=repair_attempts, ) except (ValueError, RuntimeError) as error: if name in GENERATION_TOOL_NAMES: @@ -1038,8 +1114,24 @@ class AgentService: ) cdsl_validation_diagnostics.append(diagnostic_path) result = invalid_cdsl_result(error) + result["diagnostic_path"] = diagnostic_path + repair_task_id = str(getattr(error, "task_id", "") or "") + repair_revision_id = str(getattr(error, "revision_id", "") or "") + if repair_task_id and repair_revision_id: + result.update({ + "task_id": repair_task_id, + "base_revision_id": repair_revision_id, + "repair_instruction": ( + "Patch this explicit base_revision_id for a local correction, or call read_current_cdsl " + "before a complete CDSL replacement." + ), + }) + if name == "patch_cdsl_model": + result["code"] = "INVALID_CDSL_PATCH" + result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL." generated = None required_tool_name = name + planning_state["phase"] = "CDSL_REPAIR" else: raise if result.get("task_id"): @@ -1071,26 +1163,21 @@ class AgentService: "visibleFeatures": result["visible_features"], "uncertainFeatures": result["uncertain_features"], "dimensionCandidates": result["dimension_candidates"], + "artifactPath": result.get("artifact_path"), } assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) yield event("image_analysis", image_payload) recorded_image_analysis = image_payload - flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id) + requires_image_intake = False required_tool_name = None tools = tools_for_model( model, include_image_analysis=False, - flange_sleeve_only=flange_sleeve_template_mode, ) model_messages.append({ "role": "system", "content": image_reference_instruction(image_inputs, image_payload), }) - if flange_sleeve_template_mode: - model_messages.append({ - "role": "system", - "content": flange_sleeve_template_instruction(True), - }) yield event("progress", { "step": "analyze_image_reference", "label": "识别图片参考", @@ -1114,6 +1201,12 @@ class AgentService: "summary": generated["summary"], "referenceIds": generated["reference_ids"], "engine": generated["engine"], + "qualityStatus": generated.get("quality_status", ""), + "qualityPath": generated.get("quality_path") or None, + "assumptions": generated.get("generation_assumptions", []), + "repairAttempts": generated.get("repair_attempts", 0), + "snapshotPaths": generated.get("snapshot_paths", []), + "snapshotStatus": generated.get("snapshot_status", "unavailable"), } assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) @@ -1128,10 +1221,14 @@ class AgentService: if generation_completed: break if iteration == 7: - if cdsl_validation_diagnostics: - diagnostic_paths = "、".join(cdsl_validation_diagnostics) + validation_diagnostics = cdsl_validation_diagnostics + if validation_diagnostics: + diagnostic_paths = "、".join(validation_diagnostics) if any("\u4e00" <= char <= "\u9fff" for char in user_text): - message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}。" + message = ( + "模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:" + f"{diagnostic_paths}。" + ) else: message = ( "Agent tool loop reached its safety limit. " @@ -1250,6 +1347,7 @@ class AgentService: error: Exception, ) -> str: function = call.get("function") if isinstance(call.get("function"), dict) else {} + details = _cdsl_error_details(error) payload = { "schema_version": "1.0", "recorded_at": now_iso(), @@ -1269,6 +1367,8 @@ class AgentService: "cdsl_attempt_path": cdsl_attempt_path, "summary": str(arguments.get("summary") or ""), "assumptions": arguments.get("assumptions") or [], + "verification": arguments.get("verification"), + "diagnostic": details, "validation_error_type": type(error).__name__, "validation_error": str(error), } @@ -1328,24 +1428,30 @@ class AgentService: planning_state: dict[str, Any] | None = None, image_attachment_ids: list[str] | None = None, input_attachments: list[dict[str, str | int]] | None = None, + conversation_id: str | None = None, + repair_attempts: int = 0, ) -> tuple[dict[str, Any], dict[str, Any] | None]: - state = planning_state if planning_state is not None else {"phase": "WAITING_FOR_PLAN", "design_brief": ""} - phase = str(state.get("phase") or "WAITING_FOR_PLAN") + state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + phase = str(state.get("phase") or "INTAKE") if name == "analyze_image_reference": if not image_attachment_ids: raise ValueError("analyze_image_reference requires at least one image attachment") - return { + analysis = { "ok": True, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), **normalize_image_analysis(arguments), - }, None + } + if conversation_id: + analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis}) + state["phase"] = "REFERENCE_ANALYZED" + return analysis, None if name == "describe_design_intent": plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions") state["design_brief"] = plan - state["phase"] = "PLAN_RECORDED" + state["phase"] = "PLANNED" return { "ok": True, "plan": plan, @@ -1353,60 +1459,108 @@ class AgentService: "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, None if name == "search_cdsl_library": - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None - results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5)) - state["phase"] = "LIBRARY_REFERENCE" + query = str(arguments.get("query") or request) + normalized_query = " ".join(query.casefold().split()) + seen_queries = state.setdefault("library_queries", []) + if len(seen_queries) >= 2: + return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None + if normalized_query in seen_queries: + return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None + seen_queries.append(normalized_query) + results = self.library.search(query, min(8, int(arguments.get("limit") or 5))) + state["phase"] = "LIBRARY_SEARCHING" return {"ok": True, "results": results}, None if name == "read_cdsl_reference": - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None part_id = str(arguments.get("part_id") or "") - sample = self.library.read_sample(part_id) + if part_id in references: + return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None + if len(state.get("reference_records") or []) >= 2: + return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None + try: + sample = self.library.read_sample(part_id) + except ValueError as error: + return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None if part_id not in references: references.append(part_id) - state["phase"] = "WAITING_FOR_CDSL" + state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"}) + state["phase"] = "LIBRARY_REFERENCE_READY" return {"ok": True, "part_id": part_id, "cdsl": sample}, None if name == "read_current_cdsl": if not task_id: return {"ok": False, "message": "No current task exists. This is a new model request."}, None path = self.store.current_cdsl_path(task_id) + revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") if path is None: - return {"ok": False, "message": "The current task has no successful CDSL revision."}, None - return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None + repairable = self.store.latest_repairable_cdsl(task_id) + if repairable is None: + return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None + revision_id, path = repairable + payload: dict[str, Any] = { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "cdsl": json.loads(path.read_text(encoding="utf-8")), + } + state["current_model_read"] = True + state["read_revision_id"] = revision_id + state["phase"] = "PLANNED" + return payload, None if name in GENERATION_TOOL_NAMES: - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None - template_plan: dict[str, float | str] | None = None - operation: dict[str, Any] | None = None - if name == "generate_flange_sleeve_model": - cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan")) - operation = { - "type": "parameterized_template", - "template_id": FLANGE_SLEEVE_TEMPLATE_ID, - "plan": template_plan, - } - else: + selection = part_skill_selection or self.part_skill_library.select(request) + if selection.get("conflict"): + state["phase"] = "BLOCKED" + return { + "ok": False, + "code": "PART_SKILL_CONFLICT", + "message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."), + }, None + state["phase"] = "CDSL_AUTHORING" + if name == "generate_cdsl_model": + if task_id and not state.get("current_model_read"): + return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None cdsl = arguments.get("cdsl") if isinstance(cdsl, str): cdsl = json.loads(cdsl) if not isinstance(cdsl, dict): raise ValueError("generate_cdsl_model requires a CDSL JSON object") + parent_revision_id = str(state.get("read_revision_id") or "") if task_id else "" + if task_id and not parent_revision_id: + parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"} + else: + if not task_id: + return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None + base_revision_id = str(arguments.get("base_revision_id") or "") + base_path = self.store.revision_cdsl_path(task_id, base_revision_id) + if base_path is None: + raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision") + try: + cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches")) + except (CdslPatchError, json.JSONDecodeError) as error: + raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error + parent_revision_id = base_revision_id + operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])} cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) # Reject malformed model output before build_revision allocates a task # directory or revision. build_revision will assign the real task ID. preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")} engine = load_engine(self.settings) + state["phase"] = "PREFLIGHT" validate_cdsl(preflight_cdsl, engine) summary = str(arguments.get("summary") or "Parameterized CAD model") raw_assumptions = arguments.get("assumptions") or [] if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions): raise ValueError(f"{name} assumptions must be an array of strings") assumptions = [item.strip() for item in raw_assumptions if item.strip()] - selection = part_skill_selection or self.part_skill_library.select(request) + verification = arguments.get("verification") + validate_verification(verification, cdsl) part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions) - current_task = self.store.read_task(task_id) if task_id else None - parent_revision_id = str((current_task or {}).get("current_revision") or "") state["phase"] = "BUILDING" try: yieldable = await asyncio.to_thread( @@ -1422,10 +1576,13 @@ class AgentService: operation=operation, part_skills=part_skill_audit, generation_assumptions=assumptions, + repair_attempts=repair_attempts, input_attachments=input_attachments, + verification=verification, + reference_records=state.get("reference_records"), ) except Exception: - state["phase"] = "PLAN_RECORDED" + state["phase"] = "CDSL_REPAIR" raise state["phase"] = "COMPLETED" return { @@ -1434,8 +1591,7 @@ class AgentService: "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"], "normalization_repairs": normalization_repairs, - "template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "", - "template_plan": template_plan, + "operation": operation, }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1450,8 +1606,8 @@ class AgentService: "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "describe_design_intent": "整理设计说明", - "generate_flange_sleeve_model": "生成参数化法兰套筒", "generate_cdsl_model": "生成 CDSL", + "patch_cdsl_model": "修复 CDSL", }.get(name, "调用 CAD 工具") def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str: diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 2a8372d9..65ced3ff 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -4,8 +4,8 @@ import hashlib from pathlib import Path -IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} -DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"} +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"} +DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"} MAX_IMAGE_BYTES = 10 * 1024 * 1024 MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 MAX_EXTRACTED_CHARS = 30_000 @@ -13,8 +13,6 @@ MAX_EXTRACTED_CHARS = 30_000 def classify_upload(filename: str, mime: str, size: int) -> str: suffix = Path(filename).suffix.lower() - if suffix in {".step", ".stp"}: - raise ValueError("STEP/STP upload is not supported in this version") if suffix in IMAGE_SUFFIXES or mime.startswith("image/"): if size > MAX_IMAGE_BYTES: raise ValueError("Image upload exceeds the 10 MB limit") @@ -23,14 +21,24 @@ def classify_upload(filename: str, mime: str, size: int) -> str: if size > MAX_DOCUMENT_BYTES: raise ValueError("Document upload exceeds the 2 MB limit") return "document" - raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported") + raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported") def extract_document_text(data: bytes) -> str: + # PDF extraction is optional so the API can still accept a reference when + # the local PDF dependency is unavailable; the binary remains available as + # the primary attachment artifact. + if data.startswith(b"%PDF"): + try: + from pypdf import PdfReader + import io + return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS] + except Exception: + return "[PDF reference uploaded; text extraction unavailable]" try: text = data.decode("utf-8") except UnicodeDecodeError as error: - raise ValueError("Documents must be UTF-8 text") from error + return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]" return text[:MAX_EXTRACTED_CHARS] diff --git a/backend/app/services/cdsl_patch.py b/backend/app/services/cdsl_patch.py new file mode 100644 index 00000000..3290ec2d --- /dev/null +++ b/backend/app/services/cdsl_patch.py @@ -0,0 +1,122 @@ +"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +class CdslPatchError(ValueError): + """A JSON Patch operation cannot be applied to the requested CDSL revision.""" + + +def _tokens(path: Any) -> list[str]: + if not isinstance(path, str) or not path.startswith("/"): + raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'") + return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] + + +def _index(token: str, length: int, *, allow_append: bool = False) -> int: + if allow_append and token == "-": + return length + if not token.isdigit() or (len(token) > 1 and token.startswith("0")): + raise CdslPatchError(f"Invalid JSON Patch array index: {token}") + value = int(token) + if value < 0 or value >= length: + raise CdslPatchError(f"JSON Patch array index is out of range: {token}") + return value + + +def _parent(document: Any, path: str) -> tuple[Any, str]: + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + tokens = _tokens(path) + current = document + for token in tokens[:-1]: + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}") + return current, tokens[-1] + + +def _get(document: Any, path: str) -> Any: + current = document + for token in _tokens(path): + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve: {path}") + return current + + +def _add(document: Any, path: str, value: Any) -> None: + parent, token = _parent(document, path) + if isinstance(parent, dict): + parent[token] = deepcopy(value) + elif isinstance(parent, list): + index = _index(token, len(parent), allow_append=True) + parent.insert(index, deepcopy(value)) + else: + raise CdslPatchError(f"JSON Patch add target is not a container: {path}") + + +def _remove(document: Any, path: str) -> Any: + parent, token = _parent(document, path) + if isinstance(parent, dict): + if token not in parent: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + return parent.pop(token) + if isinstance(parent, list): + return parent.pop(_index(token, len(parent))) + raise CdslPatchError(f"JSON Patch remove target is not a container: {path}") + + +def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]: + if not isinstance(cdsl, dict): + raise CdslPatchError("The base CDSL must be an object") + if not isinstance(patches, list) or not patches: + raise CdslPatchError("patches must be a non-empty JSON Patch array") + if len(patches) > 32: + raise CdslPatchError("At most 32 JSON Patch operations are allowed") + result: Any = deepcopy(cdsl) + for index, operation in enumerate(patches): + if not isinstance(operation, dict): + raise CdslPatchError(f"Patch operation {index} must be an object") + kind = str(operation.get("op") or "") + path = operation.get("path") + if kind not in {"add", "remove", "replace", "move", "copy", "test"}: + raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or ''}") + if kind in {"add", "replace", "test"} and "value" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires value") + if kind in {"move", "copy"} and "from" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires from") + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + if kind == "add": + _add(result, path, operation["value"]) + elif kind == "remove": + _remove(result, path) + elif kind == "replace": + _get(result, path) + _remove(result, path) + _add(result, path, operation["value"]) + elif kind == "move": + source = str(operation["from"]) + if path == source or str(path).startswith(source.rstrip("/") + "/"): + raise CdslPatchError("JSON Patch move cannot move a value into itself") + moved = _remove(result, source) + _add(result, path, moved) + elif kind == "copy": + _add(result, path, _get(result, str(operation["from"]))) + elif kind == "test" and _get(result, path) != operation["value"]: + raise CdslPatchError(f"JSON Patch test failed at {path}") + return result diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 779f7e35..81f710e2 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -5,7 +5,7 @@ import json import math from typing import Any -from app.services.engine_service import build_revision +from app.services.engine_service import build_revision, load_engine from app.services.storage import WorkspaceStore from app.settings import Settings @@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any] if operation == "add_slot": width = _number(values, "slotWidth", values.get("width", 8.0)) length = _number(values, "slotLength", values.get("length", width * 3)) - return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth + length = max(length, width) + radius = width / 2 + left, right = -length / 2 + radius, length / 2 - radius + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius}, + ]}]}, depth if operation == "add_pocket": width = _number(values, "width", 20.0) height = _number(values, "height", 12.0) - return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth + return {"type": "polygon", "vertices": [ + [-width / 2, -height / 2], [width / 2, -height / 2], + [width / 2, height / 2], [-width / 2, height / 2], + ]}, depth if operation == "add_hole_pattern": diameter = _number(values, "holeDiameter", values.get("diameter", 6.0)) rows = max(1, int(_number(values, "rows", 2, 1))) columns = max(1, int(_number(values, "columns", 2, 1))) - return { - "type": "circle_grid", "radius_mm": diameter / 2, - "count_x": columns, "count_y": rows, - "spacing_x_mm": _number(values, "pitchX", 12.0), - "spacing_y_mm": _number(values, "pitchY", 12.0), - "center_mm": [0.0, 0.0], - }, depth + pitch_x = _number(values, "pitchX", 12.0) + pitch_y = _number(values, "pitchY", 12.0) + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [ + (column - (columns - 1) / 2) * pitch_x, + (row - (rows - 1) / 2) * pitch_y, + ], "radius_mm": diameter / 2}]} + for row in range(rows) for column in range(columns) + ]}, depth raise ValueError(f"Unsupported direct CDSL edit: {operation}") @@ -149,6 +162,15 @@ def apply_direct_edit( raise ValueError("Task has no successful CDSL revision") frame = _selection_frame(selection) cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) + # Historical revisions can contain importer-only profile macros. An edit + # creates a new CDSL-only revision, so lower those profiles explicitly + # before adding the new generic feature. + load_engine(settings) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered_cdsl = lower_legacy_profiles(cdsl) + legacy_profiles_lowered = lowered_cdsl != cdsl + cdsl = lowered_cdsl features = cdsl.setdefault("features", []) sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", []) picks = selection.get("picks") if isinstance(selection.get("picks"), list) else [] @@ -183,7 +205,12 @@ def apply_direct_edit( reference_ids=[], summary=f"Applied {operation}", parent_revision_id=revision_id, - operation={"type": operation, "selection": selection, "parameters": parameters}, + operation={ + "type": operation, + "selection": selection, + "parameters": parameters, + "legacy_profiles_lowered": legacy_profiles_lowered, + }, part_skills=None, - generation_assumptions=[], + generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [], ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index eb76d849..27d8a310 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -4,6 +4,7 @@ import copy import json import math import re +import shutil import sys from pathlib import Path from typing import Any @@ -12,10 +13,21 @@ from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError from vendor.cdsl_preview_runtime import step_to_glb +from app.services.quality import evaluate_quality, validate_verification from app.services.storage import WorkspaceStore, now_iso, write_json from app.settings import Settings +class QualityVerificationError(RuntimeError): + """A built CDSL document missed a blocking generic verification rule.""" + + def __init__(self, quality_report: dict[str, Any]) -> None: + super().__init__("Blocking CDSL verification checks failed") + self.quality_report = quality_report + self.task_id = "" + self.revision_id = "" + + def load_engine(settings: Settings) -> Any: parent = str(settings.engine_root.parent) if parent not in sys.path: @@ -165,10 +177,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: 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( + declared_atomic_ids = { str(atomic_id) for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts) - ) + } + registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids) feature_ids: set[str] = set() for feature in features: fid = str(feature.get("id") or "") @@ -400,7 +414,6 @@ def _part_skill_audit( 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), @@ -409,10 +422,6 @@ def _part_skill_audit( "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 @@ -420,20 +429,9 @@ def _part_skill_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": [ @@ -445,9 +443,6 @@ def _generation_context( 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 []), } @@ -465,10 +460,9 @@ def build_revision( input_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 = "", + repair_attempts: int = 0, + verification: dict[str, Any] | None = None, + reference_records: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: engine = load_engine(settings) task = store.ensure_task(task_id, request) @@ -483,30 +477,19 @@ def build_revision( 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) + quality_path = revision_dir / "quality-report.json" + snapshot_manifest_path = revision_dir / "snapshot-manifest.json" + part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions) + generation_context = _generation_context(reference_ids, part_skill_audit) write_json(request_path, {"request": request, "created_at": now_iso()}) - write_json(references_path, {"reference_ids": reference_ids}) + write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]}) write_json(part_skills_path, part_skill_audit) - + write_json(snapshot_manifest_path, { + "schema_version": "1.0", + "status": "unavailable", + "reason": "Snapshot runner is not attached to this backend build", + "snapshots": [], + }) def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]: record = { "revision_id": revision_id, @@ -517,8 +500,10 @@ def build_revision( "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, + "quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_status": "unavailable", + "snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()], "part_skill_ids": list(part_skill_audit.get("skill_ids") or []), "generation_assumptions": list(part_skill_audit.get("assumptions") or []), "reference_ids": reference_ids, @@ -526,6 +511,7 @@ def build_revision( "parent_revision_id": parent_revision_id or "", "operation": operation or {}, "input_attachments": input_attachments or [], + "repair_attempts": max(0, int(repair_attempts)), } if status == "success": record.update({ @@ -539,6 +525,8 @@ def build_revision( record["error"] = error return record + quality_report: dict[str, Any] | None = None + quality_status = "" try: cdsl_copy = copy.deepcopy(cdsl) if not isinstance(cdsl_copy, dict): @@ -562,6 +550,17 @@ def build_revision( selector, edges = topology_sidecars(engine_result, preview) write_json(selector_path, selector) write_json(edges_path, edges) + rules = validate_verification(verification, cdsl_copy) + quality_report = evaluate_quality(rules, cdsl_copy, engine_result) + quality_report["evaluated_at"] = now_iso() + write_json(quality_path, quality_report) + quality_status = ( + "accepted" if rules and quality_report["status"] == "passed" + else "built_with_warnings" if quality_report["status"] == "passed" + else "needs_repair" + ) + if quality_report["status"] != "passed": + raise QualityVerificationError(quality_report) report = { "engine_result": engine_result, "preview": preview, @@ -570,16 +569,40 @@ def build_revision( } write_json(report_path, report) revision = revision_record("success", engine_name=str(engine_result["engine"])) + revision["quality_status"] = quality_status or "accepted" + revision["verification_summary"] = { + "requested": bool(rules), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } except Exception as error: + if not isinstance(error, QualityVerificationError): + # The failed candidate is retained in the conversation diagnostics, + # not as a task revision. Runtime/schema failures must not create + # an editable revision that looks like a model version. + shutil.rmtree(revision_dir, ignore_errors=True) + raise if not cdsl_path.is_file() and isinstance(cdsl, dict): write_json(cdsl_path, copy.deepcopy(cdsl)) + if quality_report is not None and not quality_path.is_file(): + write_json(quality_path, quality_report) write_json(report_path, { "error": str(error), "generation_context": generation_context, + "quality": quality_report, "validated_at": now_iso(), }) - revision = revision_record("failed", error=str(error)) + revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error)) + revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed") + if quality_report is not None: + revision["verification_summary"] = { + "requested": bool(quality_report.get("verification_requested")), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } store.update_task(task["task_id"], revision) + error.task_id = task["task_id"] + error.revision_id = revision_id raise store.update_task(task["task_id"], revision) return {"task_id": task["task_id"], **revision} diff --git a/backend/app/services/flange_sleeve_template.py b/backend/app/services/flange_sleeve_template.py deleted file mode 100644 index f7e1f705..00000000 --- a/backend/app/services/flange_sleeve_template.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import copy -import math -import re -from typing import Any - - -TEMPLATE_ID = "flange_sleeve_v1" - -# These defaults describe the pictured family, but the model may override every -# dimension that affects the visible form. The backend owns all CDSL plumbing. -DEFAULT_PLAN: dict[str, float | str] = { - "name": "Parameterized flange sleeve", - "flange_width_mm": 120.0, - "flange_height_mm": 120.0, - "flange_thickness_mm": 14.0, - "corner_chamfer_mm": 10.0, - "tube_outer_diameter_mm": 70.0, - "tube_straight_length_mm": 95.0, - "tip_outer_diameter_mm": 62.0, - "tip_length_mm": 28.0, - "bore_diameter_mm": 46.0, - "boss_outer_diameter_mm": 82.0, - "boss_height_mm": 5.0, - "mount_hole_diameter_mm": 12.0, - "mount_counterbore_diameter_mm": 24.0, - "mount_counterbore_depth_mm": 5.0, - "mount_hole_u_mm": 42.0, - "mount_hole_v_mm": 42.0, -} - -_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name") -_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$") - - -def flange_sleeve_plan_schema() -> dict[str, Any]: - """Return the compact semantic plan accepted by the flange-sleeve tool.""" - properties: dict[str, Any] = { - "template": {"const": TEMPLATE_ID}, - "name": {"type": "string", "minLength": 1, "maxLength": 120}, - "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, - } - for field in _DIMENSION_FIELDS: - minimum = 0 if field == "corner_chamfer_mm" else 0.001 - properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else { - "type": "number", "minimum": 0, - } - return { - "type": "object", - "properties": properties, - "required": ["template"], - "additionalProperties": False, - } - - -def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]: - """Validate only semantic dimensions; do not accept CDSL implementation data.""" - if not isinstance(plan, dict): - raise ValueError("flange sleeve plan must be a JSON object") - if plan.get("template") != TEMPLATE_ID: - raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}") - allowed = {"template", "part_id", *DEFAULT_PLAN} - unknown = sorted(str(key) for key in plan if key not in allowed) - if unknown: - raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}") - - normalized = copy.deepcopy(DEFAULT_PLAN) - for field in _DIMENSION_FIELDS: - if field not in plan: - continue - value = plan[field] - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): - raise ValueError(f"flange sleeve plan {field} must be a finite number") - number = float(value) - if number < 0 if field == "corner_chamfer_mm" else number <= 0: - comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero" - raise ValueError(f"flange sleeve plan {field} must be {comparator}") - normalized[field] = number - - name = str(plan.get("name") or normalized["name"]).strip() - if not name: - raise ValueError("flange sleeve plan name must not be empty") - normalized["name"] = name[:120] - part_id = str(plan.get("part_id") or "flange_sleeve_template") - if not _PART_ID.fullmatch(part_id): - raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens") - normalized["part_id"] = part_id - _validate_dimensions(normalized) - return normalized - - -def _validate_dimensions(plan: dict[str, float | str]) -> None: - number = lambda field: float(plan[field]) - width, height = number("flange_width_mm"), number("flange_height_mm") - thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") - tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm") - boss_od = number("boss_outer_diameter_mm") - hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm") - counterbore_depth = number("mount_counterbore_depth_mm") - hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") - - if chamfer * 2 >= min(width, height): - raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side") - if not bore < min(tube_od, tip_od): - raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters") - if not tube_od <= boss_od <= min(width, height): - raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side") - if counterbore_od < hole_od: - raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm") - if counterbore_depth > thickness: - raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm") - - radius = counterbore_od / 2 - if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2: - raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary") - if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer: - edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v)) - if edge_clearance < chamfer + radius * math.sqrt(2): - raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers") - - -def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]: - return { - "origin_mm": [offset_mm, 0.0, 0.0], - "x_dir": [0.0, 1.0, 0.0], - "normal": [normal_x, 0.0, 0.0], - } - - -def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]: - if chamfer == 0: - return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height} - half_width, half_height = width / 2, height / 2 - return { - "type": "polygon", - "vertices": [ - [-half_width + chamfer, -half_height], - [half_width - chamfer, -half_height], - [half_width, -half_height + chamfer], - [half_width, half_height - chamfer], - [half_width - chamfer, half_height], - [-half_width + chamfer, half_height], - [-half_width, half_height - chamfer], - [-half_width, -half_height + chamfer], - ], - } - - -def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]: - """Build a schema-valid CDSL flange sleeve from semantic, editable parameters.""" - values = normalize_flange_sleeve_plan(plan) - number = lambda field: float(values[field]) - width, height = number("flange_width_mm"), number("flange_height_mm") - thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") - tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2 - straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm") - boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm") - hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2 - hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") - hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)] - - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.3.0", - "part_id": str(values["part_id"]), - "kind": "part", - "meta": {"name": str(values["name"]), "units": "mm"}, - "geometry": {"sketches": [ - { - "id": "flange_outline", - "workplane": _x_plane(-thickness), - "profile": _flange_profile(width, height, chamfer), - }, - { - "id": "sleeve_profile", - "workplane": { - "origin_mm": [0.0, 0.0, 0.0], - "x_dir": [1.0, 0.0, 0.0], - "normal": [0.0, 0.0, 1.0], - }, - "profile": { - "type": "polygon", - "vertices": [ - [0.0, bore_radius], - [0.0, tube_radius], - [straight, tube_radius], - [straight + tip_length, tip_radius], - [straight + tip_length, bore_radius], - ], - }, - }, - { - "id": "front_boss_ring", - "workplane": _x_plane(0.0), - "profile": { - "type": "annulus", - "inner_radius_mm": bore_radius, - "outer_radius_mm": boss_radius, - "center": [0.0, 0.0], - }, - }, - { - "id": "center_bore", - "workplane": _x_plane(-thickness), - "profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]}, - }, - { - "id": "mount_holes", - "workplane": _x_plane(-thickness), - "profile": { - "type": "circles", - "items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers], - }, - }, - { - "id": "mount_counterbores", - "workplane": _x_plane(0.0, -1.0), - "profile": { - "type": "circles", - "items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers], - }, - }, - ]}, - "features": [ - { - "id": "flange_plate", - "atomic_id": "extrude_add_blind", - "depends_on": [], - "sketch_id": "flange_outline", - "params": {"distance_mm": thickness}, - }, - { - "id": "sleeve_body", - "atomic_id": "revolve_add", - "depends_on": ["flange_plate"], - "sketch_id": "sleeve_profile", - "params": { - "angle_deg": 360.0, - "axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]}, - }, - }, - { - "id": "front_boss", - "atomic_id": "extrude_add_blind", - "depends_on": ["flange_plate", "sleeve_body"], - "sketch_id": "front_boss_ring", - "params": {"distance_mm": boss_height}, - }, - { - "id": "center_bore_cut", - "atomic_id": "extrude_cut_blind", - "depends_on": ["flange_plate", "sleeve_body", "front_boss"], - "sketch_id": "center_bore", - "params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0}, - }, - { - "id": "mount_hole_cuts", - "atomic_id": "extrude_cut_blind", - "depends_on": ["center_bore_cut"], - "sketch_id": "mount_holes", - "params": {"distance_mm": thickness + 1.0}, - }, - { - "id": "mount_counterbore_cuts", - "atomic_id": "extrude_cut_blind", - "depends_on": ["mount_hole_cuts"], - "sketch_id": "mount_counterbores", - "params": {"distance_mm": number("mount_counterbore_depth_mm")}, - }, - ], - } - return cdsl, values diff --git a/backend/app/services/library.py b/backend/app/services/library.py index e681eb72..fa50a53f 100644 --- a/backend/app/services/library.py +++ b/backend/app/services/library.py @@ -9,10 +9,28 @@ from app.settings import Settings TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+") +CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]") + +TERM_ALIASES = { + "法兰": {"flange", "mounting_plate"}, + "套筒": {"sleeve", "tube", "cylinder"}, + "孔": {"hole", "circle", "circles"}, + "沉头": {"countersunk", "counterbore"}, + "沉孔": {"counterbore", "counterbored"}, + "槽": {"slot", "obround"}, + "底板": {"plate", "rectangle", "mounting_plate"}, + "轴": {"shaft", "cylinder"}, +} def tokens(value: str) -> set[str]: - return {token.lower() for token in TOKEN_PATTERN.findall(value)} + text = str(value or "").lower() + result = {token.lower() for token in TOKEN_PATTERN.findall(text)} + result.update(CN_TOKEN_PATTERN.findall(text)) + for term, aliases in TERM_ALIASES.items(): + if term in text: + result.update(aliases) + return result class CdslLibrary: @@ -38,6 +56,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for record in self._records()[:limit] ] @@ -46,6 +66,8 @@ class CdslLibrary: corpus = " ".join([ record.get("part_id", ""), record.get("source_name", ""), + record.get("summary", ""), + record.get("family", ""), " ".join(record.get("profiles", [])), " ".join(record.get("features", [])), " ".join(record.get("parameters", [])), @@ -60,6 +82,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for _, record in scored[:limit] ] diff --git a/backend/app/services/part_skills.py b/backend/app/services/part_skills.py index 653cc1aa..30c13cd3 100644 --- a/backend/app/services/part_skills.py +++ b/backend/app/services/part_skills.py @@ -58,7 +58,6 @@ class PartSkill: bridge: str source: str related: tuple[str, ...] - capability_translation_rules: tuple[str, ...] @classmethod def from_mapping(cls, value: dict[str, Any]) -> "PartSkill": @@ -72,9 +71,6 @@ class PartSkill: bridge=str(value["bridge"]), source=str(value["source"]), related=tuple(str(item) for item in value.get("related") or []), - capability_translation_rules=tuple( - str(item) for item in value.get("capability_translation_rules") or [] - ), ) @@ -84,6 +80,7 @@ class PartSkillLibrary: def __init__(self, root: Path) -> None: self.root = Path(root) payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8")) + self.catalog_version = str(payload.get("schema_version") or "1.0") self.max_planning = int(payload.get("max_planning") or 1) self.max_support = int(payload.get("max_support") or 3) self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or []) @@ -112,9 +109,10 @@ class PartSkillLibrary: "kind": skill.kind, "category": skill.kind, "title": skill.title, + "version": self.catalog_version, + "summary": skill.title, "source": skill.source, "bridge": skill.bridge, - "capability_translation_rules": list(skill.capability_translation_rules), "selection": selection, "matched_triggers": list(matched), } @@ -207,14 +205,7 @@ class PartSkillLibrary: revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id] if revision_ids: return revision_ids - # Legacy tasks can contain a DesignIntent created before their first - # runtime build. Retain this fallback only for historical artifacts. - intent_id = str((task or {}).get("current_design_intent_id") or "") - intent = next( - (item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id), - None, - ) - return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id] + return [] def render_context(self, selection: dict[str, Any]) -> str: records = selection.get("skills") or [] @@ -233,62 +224,9 @@ class PartSkillLibrary: return "\n".join(sections) def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]: - features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)] - feature_ids = [str(item.get("id")) for item in features if item.get("id")] - atomics = [str(item.get("atomic_id")) for item in features] - sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)] - profiles = [str((item.get("profile") or {}).get("type")) for item in sketches] - evidence = { - "feature_ids": feature_ids, - "atomic_ids": sorted(set(atomics)), - "profile_types": sorted(set(profiles)), - "features": [ - { - "id": str(item.get("id") or ""), - "atomic_id": str(item.get("atomic_id") or ""), - "sketch_id": str(item.get("sketch_id") or ""), - "params": item.get("params") if isinstance(item.get("params"), dict) else {}, - } - for item in features - ], - "profiles": [ - { - "id": str(item.get("id") or ""), - "type": str((item.get("profile") or {}).get("type") or ""), - "profile": item.get("profile") if isinstance(item.get("profile"), dict) else {}, - } - for item in sketches - ], - } - translations: list[dict[str, Any]] = [] - for record in selection.get("skills") or []: - skill_id = str(record.get("id")) - status = "exact" - reason = "The selected structural guidance is represented by the submitted CDSL plan." - if skill_id == "functional/flange-bolt-circle": - if "circles" in profiles and "extrude_cut_blind" in atomics: - status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists." - else: - status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL." - elif skill_id == "atomic/threaded-hole-creation": - status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology." - elif skill_id == "atomic/fillet-chamfer-last": - finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}] - if not finishing: - status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted." - elif skill_id == "atomic/pattern-holes-from-datum": - if "circles" in profiles or "circle_grid" in profiles: - status, reason = "expanded", "The layout is represented by explicit profile circles." - elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics): - status, reason = "blocked", "No supported pattern or explicit circle layout was found." - translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence} - if skill_id == "functional/flange-bolt-circle" and status == "expanded": - translation["translation"] = "circular_pattern_to_explicit_circles" - elif skill_id == "atomic/threaded-hole-creation": - translation["translation"] = "thread_geometry_omitted" - elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted": - translation["translation"] = "selector_unavailable" - translations.append(translation) + # Skills are prompt knowledge only. Do not interpret a selected skill + # as geometry, capability translation, or an executable model rule. + del cdsl return { "schema_version": "1.0", "request": selection.get("request", ""), @@ -298,6 +236,4 @@ class PartSkillLibrary: "inherited_skill_ids": list(selection.get("inherited_skill_ids") or []), "conflict": selection.get("conflict"), "assumptions": [str(item) for item in assumptions or []], - "evidence": evidence, - "capability_translations": translations, } diff --git a/backend/app/services/quality.py b/backend/app/services/quality.py new file mode 100644 index 00000000..72c6f90b --- /dev/null +++ b/backend/app/services/quality.py @@ -0,0 +1,262 @@ +"""Generic, model-family-independent verification for direct CDSL revisions.""" + +from __future__ import annotations + +import math +from typing import Any + + +QUALITY_RULE_TYPES = frozenset({ + "bbox", "solid_count", "feature_count", "hole_count", "hole_diameter", + "hole_center", "overall_length", "overall_diameter", "through_condition", +}) +_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"} +_SEVERITIES = {"blocking", "warning", "informational"} + + +def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None: + """Return a whole-model or feature-owned runtime bounding box.""" + candidates: list[Any] = [] + if feature_id: + for record in engine_result.get("topology_records") or []: + if not isinstance(record, dict): + continue + owners = record.get("owner_feature_ids") or [] + if record.get("feature_id") == feature_id or feature_id in owners: + geometry = record.get("geometry") + if isinstance(geometry, dict): + candidates.append(geometry.get("bbox_mm")) + else: + bbox = engine_result.get("bbox_mm") + if isinstance(bbox, dict): + candidates.append([ + *(bbox.get("min") or []), + *(bbox.get("max") or []), + ]) + + boxes = [ + value for value in candidates + if isinstance(value, list) and len(value) == 6 + and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value) + ] + if not boxes: + return None + minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)] + maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)] + return [float(item) for item in minimum], [float(item) for item in maximum] + + +def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None: + bounds = _bbox_bounds(engine_result, feature_id) + if bounds is None: + return None + minimum, maximum = bounds + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + +def _dimensions(engine_result: dict[str, Any]) -> list[float] | None: + value = _bbox_value(engine_result) + return value["dimensions"] if value else None + + +def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None: + return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None) + + +def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None: + return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None) + + +def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]: + if profile.get("type") == "circle": + center, radius = profile.get("center"), profile.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)): + return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}] + if profile.get("type") != "analytic_contours": + return [] + return [ + {"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])} + for contour in profile.get("contours") or [] if isinstance(contour, dict) + for segment in contour.get("segments") or [] if isinstance(segment, dict) + and segment.get("type") == "circle" + and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2 + and isinstance(segment.get("radius_mm"), (int, float)) + ] + + +def _finite_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) + + +def _validate_expected(kind: str, expected: Any, index: int) -> None: + scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"} + if kind in scalar_types and not _finite_number(expected): + raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}") + if kind == "bbox": + valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected) + valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}) + if valid_ranges and set(expected) == {"min", "max"}: + valid_ranges = all( + isinstance(expected[key], list) + and len(expected[key]) == 3 + and all(_finite_number(value) for value in expected[key]) + for key in ("min", "max") + ) + elif valid_ranges: + valid_ranges = all(_finite_number(expected[key]) for key in expected) + if not valid_dimensions and not valid_ranges: + raise ValueError( + f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], " + "{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}" + ) + if kind == "hole_center" and ( + not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected) + ): + raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center") + if kind == "through_condition" and not isinstance(expected, bool): + raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition") + + +def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]: + if verification is None: + return [] + if not isinstance(verification, dict) or set(verification) - {"rules"}: + raise ValueError("verification must be an object containing only rules") + rules = verification.get("rules", []) + if not isinstance(rules, list) or len(rules) > 32: + raise ValueError("verification.rules must be an array with at most 32 rules") + feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")} + normalized: list[dict[str, Any]] = [] + ids: set[str] = set() + for index, raw in enumerate(rules): + if not isinstance(raw, dict): + raise ValueError(f"verification.rules[{index}] must be an object") + allowed = {"id", "type", "feature", "expected", "tolerance", "severity"} + if set(raw) - allowed: + raise ValueError(f"verification.rules[{index}] has unsupported fields") + rule_id = str(raw.get("id") or "").strip() + kind = str(raw.get("type") or "").strip() + if not rule_id or rule_id in ids: + raise ValueError(f"verification.rules[{index}].id must be unique and non-empty") + if kind not in QUALITY_RULE_TYPES: + raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}") + if "expected" not in raw: + raise ValueError(f"verification.rules[{index}].expected is required") + _validate_expected(kind, raw["expected"], index) + severity = str(raw.get("severity") or "blocking") + if severity not in _SEVERITIES: + raise ValueError(f"verification.rules[{index}].severity is unsupported") + try: + tolerance = float(raw.get("tolerance") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error + if not math.isfinite(tolerance) or tolerance < 0: + raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative") + feature = str(raw.get("feature") or "").strip() + if kind in _FEATURE_RULE_TYPES and not feature: + raise ValueError(f"verification.rules[{index}].feature is required for {kind}") + if feature and feature not in feature_ids: + raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID") + ids.add(rule_id) + normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity}) + return normalized + + +def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]: + kind, target = rule["type"], rule.get("feature") or None + feature = _feature(cdsl, target) + params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {} + sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or "")) + profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {} + circles = _circles(profile) if isinstance(profile, dict) else [] + dimensions = _dimensions(engine_result) + if kind == "bbox": + value = _bbox_value(engine_result, target) + source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm" + return value, source + if kind == "solid_count": + return float(engine_result.get("solid_count", 1)), "runtime.solid_count" + if kind == "feature_count": + return float(len(cdsl.get("features") or [])), "cdsl.features" + if kind == "hole_count": + return float(len(circles)), f"cdsl.features.{target}.sketch" + if kind == "hole_diameter": + value = params.get("diameter_mm", params.get("hole_diameter_mm")) + if isinstance(value, (int, float)): + return float(value), f"cdsl.features.{target}.params" + return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch" + if kind == "hole_center": + centers = [circle["center"] for circle in circles] + return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch" + if kind == "overall_length": + return (max(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "overall_diameter": + return (min(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "through_condition": + end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {} + if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}: + return True, f"cdsl.features.{target}.params.end_condition" + distance = params.get("distance_mm") + return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm" + return None, "unsupported verification type" + + +def _matches(expected: Any, actual: Any, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return isinstance(actual, list) and len(expected) == len(actual) and all( + _matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual) + ) + if isinstance(expected, bool): + return bool(actual) is expected + if isinstance(expected, (int, float)) and isinstance(actual, (int, float)): + return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0) + return expected == actual + + +def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return _matches(expected, actual["dimensions"], tolerance) + if not isinstance(expected, dict): + return False + if set(expected) == {"min", "max"}: + return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance) + if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}: + actual_ranges = { + "x_min": actual["min"][0], "x_max": actual["max"][0], + "y_min": actual["min"][1], "y_max": actual["max"][1], + "z_min": actual["min"][2], "z_max": actual["max"][2], + } + return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges) + return False + + +def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]: + results = [] + for rule in rules: + actual, source = _actual(rule, cdsl, engine_result) + passed = ( + _matches_bbox(rule["expected"], actual, rule["tolerance"]) + if rule["type"] == "bbox" + else _matches(rule["expected"], actual, rule["tolerance"]) + ) + results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source}) + blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"] + warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"] + return { + "schema": "cad.quality-report.v1", + "schema_version": "1.0", + "status": "passed" if not blocking else "failed", + "verification_requested": bool(rules), + "results": results, + "blocking_failures": blocking, + "warnings": warnings, + "measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)}, + } diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index bcdac2a4..fa1459f7 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -12,7 +12,6 @@ from app.settings import Settings TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$") CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$") -DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$") def now_iso() -> str: @@ -37,13 +36,6 @@ def safe_conversation_id(conversation_id: str) -> str: return value -def safe_design_intent_id(intent_id: str) -> str: - value = str(intent_id or "").strip() - if not DESIGN_INTENT_ID.fullmatch(value): - raise ValueError("Invalid design intent id") - return value - - def safe_relative_path(value: str) -> str: path = Path(str(value or "")) if not value or path.is_absolute() or ".." in path.parts: @@ -104,6 +96,15 @@ class WorkspaceStore: write_json(path, payload) return (Path(conversation) / relative).as_posix() + def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str: + """Persist structured intake/planning evidence before a task exists.""" + conversation = safe_conversation_id(conversation_id) + safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning" + relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, payload) + return (Path(conversation) / relative).as_posix() + def ensure_conversation( self, conversation_id: str | None, @@ -184,14 +185,12 @@ class WorkspaceStore: task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { - "schema_version": "1.1", + "schema_version": "1.2", "task_id": tid, "request": request, "created_at": now_iso(), "updated_at": now_iso(), "current_revision": "", - "current_design_intent_id": "", - "design_intents": [], "revisions": [], } write_json(path, record) @@ -213,81 +212,6 @@ class WorkspaceStore: write_json(self.task_path(task_id), task) return task - def create_design_intent( - self, - task_id: str | None, - request: str, - intent: dict[str, Any], - part_skill_selection: dict[str, Any], - ) -> dict[str, Any]: - """Persist a validated plan and atomically make it the current plan.""" - task = self.ensure_task(task_id, request) - task.setdefault("design_intents", []) - task.setdefault("current_design_intent_id", "") - task["schema_version"] = "1.1" - previous_id = str(task.get("current_design_intent_id") or "") - if previous_id: - for record in task["design_intents"]: - if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded": - record["status"] = "superseded" - - intent_id = new_id("intent") - created_at = now_iso() - relative = Path("planning") / f"design-intent-{intent_id}.json" - skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)] - persisted = dict(intent) - persisted.update({ - "intent_id": intent_id, - "created_at": created_at, - "part_skill_ids": skill_ids, - "part_skill_selection": part_skill_selection, - }) - write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted) - record = { - "intent_id": intent_id, - "status": "accepted" if persisted.get("status") == "ready" else "pending", - "created_at": created_at, - "path": relative.as_posix(), - "part_skill_ids": skill_ids, - "base_revision_id": str(persisted.get("base_revision_id") or ""), - } - task["design_intents"].append(record) - task["current_design_intent_id"] = intent_id - task["updated_at"] = now_iso() - write_json(self.task_path(task["task_id"]), task) - return {"task_id": task["task_id"], **record, "intent": persisted} - - def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None: - task = self.read_task(task_id) - if not task: - return None - chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "") - if not chosen: - return None - record = next( - (item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen), - None, - ) - if not isinstance(record, dict): - return None - path = self.artifact_path(task_id, str(record.get("path") or "")) - intent = read_json(path) - if not isinstance(intent, dict): - return None - return {"task_id": task_id, "record": record, "intent": intent} - - def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]: - safe_id = safe_design_intent_id(intent_id) - task = self.ensure_task(task_id, "") - records = task.setdefault("design_intents", []) - record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None) - if not isinstance(record, dict): - raise ValueError("Design intent not found") - record["status"] = str(status) - task["updated_at"] = now_iso() - write_json(self.task_path(task_id), task) - return record - def read_task(self, task_id: str) -> dict[str, Any] | None: return read_json(self.task_path(task_id)) @@ -299,6 +223,32 @@ class WorkspaceStore: candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" return candidate if candidate.is_file() else None + def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None: + """Return the latest revision CDSL when a quality failure needs repair.""" + task = self.read_task(task_id) + for revision in reversed((task or {}).get("revisions") or []): + revision_id = str(revision.get("revision_id") or "") + if not revision_id or str(revision.get("quality_status") or "") != "needs_repair": + continue + path = self.revision_cdsl_path(task_id, revision_id) + if path is not None: + return revision_id, path + return None + + def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("cdsl_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + return candidate if candidate.is_file() else None + def artifact_path(self, task_id: str, relative_path: str) -> Path: safe = safe_relative_path(relative_path) root = self.task_dir(task_id).resolve() diff --git a/backend/app/settings.py b/backend/app/settings.py index e9320a0a..ea015be5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -50,6 +50,7 @@ class Settings: llm_timeout_s: float default_provider_id: str providers: tuple[ProviderConfig, ...] + max_repair_attempts: int = 4 @property def llm_configured(self) -> bool: @@ -137,6 +138,7 @@ def get_settings() -> Settings: llm_api_key=default_provider.api_key, llm_model=default_model, llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")), + max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))), default_provider_id=default_provider_id, providers=providers, ) diff --git a/backend/cdsl_importer/__init__.py b/backend/cdsl_importer/__init__.py new file mode 100644 index 00000000..c9ee396e --- /dev/null +++ b/backend/cdsl_importer/__init__.py @@ -0,0 +1,6 @@ +"""Compatibility adapters for imported and historical CDSL documents. + +This package is deliberately outside ``cdsl_engine``. It may lower retired +source syntax into the engine's generic CDSL contract, but is never imported +by the CDSL runtime. +""" diff --git a/backend/engine/cdsl_engine/distill_output3.py b/backend/cdsl_importer/distill_output3.py similarity index 100% rename from backend/engine/cdsl_engine/distill_output3.py rename to backend/cdsl_importer/distill_output3.py diff --git a/backend/cdsl_importer/legacy_profile_adapter.py b/backend/cdsl_importer/legacy_profile_adapter.py new file mode 100644 index 00000000..d90415e9 --- /dev/null +++ b/backend/cdsl_importer/legacy_profile_adapter.py @@ -0,0 +1,117 @@ +"""Explicit import/rebuild compatibility for retired CDSL profile macros. + +The CDSL runtime accepts only direct geometric profiles. This adapter is for +historical artifacts and importers: it expands a legacy shorthand exactly once +into an ``analytic_contours`` profile before generic runtime validation. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from .legacy_profiles import LEGACY_PROFILE_GENERATORS + + +class LegacyProfileError(ValueError): + """A legacy profile could not be lowered to generic CDSL geometry.""" + + +def legacy_profile_types() -> tuple[str, ...]: + return tuple(sorted(LEGACY_PROFILE_GENERATORS)) + + +def _segment_from_edge(edge: dict[str, Any]) -> dict[str, Any]: + kind = str(edge.get("type") or "") + if kind == "line": + return { + "type": "line", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + } + if kind == "arc": + segment = { + "type": "arc", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + "center": list(edge["center_mm"][:2]), + "radius_mm": float(edge["radius_mm"]), + } + if "clockwise" in edge: + segment["clockwise"] = bool(edge["clockwise"]) + return segment + raise LegacyProfileError(f"Legacy profile emitted unsupported edge type: {kind}") + + +def _circle_contour(entity: dict[str, Any]) -> dict[str, Any]: + radius = entity.get("radius_mm") + if not isinstance(radius, (int, float)) or float(radius) <= 0: + raise LegacyProfileError("Legacy circle must have radius_mm > 0") + center = entity.get("center") or [0.0, 0.0] + if not isinstance(center, list) or len(center) < 2: + raise LegacyProfileError("Legacy circle must have a two-dimensional center") + return { + "role": "outer", + "closed": True, + "segments": [{"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}], + } + + +def _edge_contour(edges: list[dict[str, Any]], *, role: str) -> dict[str, Any]: + return { + "role": role, + "closed": True, + "segments": [_segment_from_edge(edge) for edge in edges], + } + + +def lower_legacy_profile(profile: dict[str, Any], *, sketch_id: str = "") -> dict[str, Any]: + """Expand one named legacy profile to a direct analytic contour profile.""" + kind = str(profile.get("type") or "") + generator = LEGACY_PROFILE_GENERATORS.get(kind) + if generator is None: + raise LegacyProfileError(f"Unsupported legacy profile: {kind!r}") + meta: dict[str, Any] = {"id": sketch_id, "_entities": None, "_contour": None} + try: + entities, contour = generator(copy.deepcopy(profile), meta) + except (KeyError, TypeError, ValueError) as error: + raise LegacyProfileError(f"Could not lower legacy profile {kind!r}: {error}") from error + + contours: list[dict[str, Any]] = [] + if contour: + contours.append(_edge_contour(contour, role="outer")) + for region in meta.get("_regions") or []: + if not isinstance(region, dict): + continue + outer = region.get("outer") or [] + if outer: + contours.append(_edge_contour(outer, role="outer")) + for hole in region.get("holes") or []: + if hole: + contours.append(_edge_contour(hole, role="inner")) + for entity in entities or []: + if entity.get("type") == "circle" and not entity.get("construction"): + contours.append(_circle_contour(entity)) + if not contours: + raise LegacyProfileError(f"Legacy profile {kind!r} did not produce a closed contour") + return {"type": "analytic_contours", "contours": contours} + + +def lower_legacy_profiles(cdsl: dict[str, Any]) -> dict[str, Any]: + """Return a copy with every self-contained legacy sketch lowered. + + This is opt-in. Calling the runtime directly with a legacy profile still + returns an unsupported-profile diagnostic. + """ + lowered = copy.deepcopy(cdsl) + sketches = ((lowered.get("geometry") or {}).get("sketches") or []) + for sketch in sketches: + if not isinstance(sketch, dict): + continue + profile = sketch.get("profile") + if not isinstance(profile, dict): + continue + if str(profile.get("type") or "") not in LEGACY_PROFILE_GENERATORS: + continue + sketch["profile"] = lower_legacy_profile(profile, sketch_id=str(sketch.get("id") or "")) + return lowered diff --git a/backend/cdsl_importer/legacy_profiles.py b/backend/cdsl_importer/legacy_profiles.py new file mode 100644 index 00000000..bc6ec928 --- /dev/null +++ b/backend/cdsl_importer/legacy_profiles.py @@ -0,0 +1,2075 @@ +"""Historical profile macro lowering support. + +This module is an importer-only compatibility adapter. It is not imported by +the CDSL runtime and is never a planner-facing CDSL contract. It preserves +the historical macro definitions long enough to lower old artifacts to direct +``analytic_contours`` before calling the engine. + +架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, + 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: + 1. 写 def solver_xxx(profile, meta) -> (entities, contour) + 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx + 3. 在 convert 脚本中输出对应的 profile + 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) + +支持的 profile 类型: + - circle: 单个圆 + - annulus: 同心圆环 + - circles: 多圆(引擎自动判断加/切除) + - circle_grid: 矩形圆孔阵列(行列+间距) + - rectangle: 矩形 + - rectangle_with_circles: 矩形 + 内圆孔/岛 + - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 + - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) + - obround: 槽形 / 键槽(2平行线 + 2半圆) + - polygon: N边多边形(顶点列表) + - ibone: 工字形凸耳(12线+4弧+4孔) + - circle_with_arc_notches: 圆+均匀圆弧凹口 + - circular_sector_slot: 圆弧扇区+中心矩形槽 + - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) + - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 + - d_shape: D形(半圆+弦线) + - partial_ring: 部分圆环(同心弧+径向线) + - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) + - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) + - patterned_cutouts: 母形 + 规则布局的多区域切口 + - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import Any, Iterable + + +# ═══════════════════════════════════════════════════════════════ +# 基础几何原语 +# ═══════════════════════════════════════════════════════════════ + +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: + return { + "type": "circle", + "center": [float(center[0]), float(center[1])], + "radius_mm": float(radius_mm), + "construction": construction, + } + + +def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: + return { + "type": "line", + "start": [float(start[0]), float(start[1])], + "end": [float(end[0]), float(end[1])], + "construction": construction, + } + + +def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: + return { + "type": "line", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + } + + +def _contour_arc( + start_mm: list[float], + end_mm: list[float], + center_mm: list[float], + radius_mm: float | None, + clockwise: bool | None = None, +) -> dict[str, Any]: + result = { + "type": "arc", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + "center_mm": [ + float(center_mm[0]), + float(center_mm[1]), + float(center_mm[2]) if len(center_mm) > 2 else 0.0, + ], + "radius_mm": float(radius_mm) if radius_mm is not None else None, + } + if clockwise is not None: + result["clockwise"] = bool(clockwise) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 3D 坐标转换 +# ═══════════════════════════════════════════════════════════════ + +def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: + """将2D局部坐标 (u,v) 映射到3D世界坐标。""" + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + y_dir = [ + normal[1] * x_dir[2] - normal[2] * x_dir[1], + normal[2] * x_dir[0] - normal[0] * x_dir[2], + normal[0] * x_dir[1] - normal[1] * x_dir[0], + ] + return [ + origin[0] + u * x_dir[0] + v * y_dir[0], + origin[1] + u * x_dir[1] + v * y_dir[1], + origin[2] + u * x_dir[2] + v * y_dir[2], + ] + + +def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: + """将轮廓边的2D坐标映射为3D世界坐标。""" + result: list[dict[str, Any]] = [] + x_dir = wp.get("x_dir") or [1, 0, 0] + normal = wp.get("normal") or [0, 0, 1] + for e in contour: + e2 = deepcopy(e) + if e["type"] == "line": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + elif e["type"] == "arc": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) + e2["normal"] = list(normal) + result.append(e2) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 矩形 / 圆辅助 +# ═══════════════════════════════════════════════════════════════ + +def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: + """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" + center = profile.get("center") + w = float(profile.get("width_mm") or 0) + h = float(profile.get("height_mm") or 0) + if center and w > 0 and h > 0: + cx, cy = float(center[0]), float(center[1]) + return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 + mn = profile.get("min_mm") + mx = profile.get("max_mm") + if mn and mx: + return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) + raise ValueError("rectangle profile needs (center+width+height) or (min+max)") + + +def _rect_lines_and_contour( + x0: float, y0: float, x1: float, y1: float, +) -> tuple[list[dict], list[dict]]: + p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] + entities = [ + _line([x0, y0], [x1, y0]), + _line([x1, y0], [x1, y1]), + _line([x1, y1], [x0, y1]), + _line([x0, y1], [x0, y0]), + ] + contour = [ + _contour_line(p00, p10), + _contour_line(p10, p11), + _contour_line(p11, p01), + _contour_line(p01, p00), + ] + return entities, contour + + +def _build_circle_entities(items: list[dict]) -> list[dict]: + entities: list[dict] = [] + for item in items: + center = item.get("center") or [0.0, 0.0] + r = float(item.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities.append(_circle(center, r, construction=False)) + return entities + + +def _filleted_rect_contour( + x0: float, y0: float, x1: float, y1: float, r: float, +) -> tuple[list[dict], list[dict]]: + """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" + if r <= 0: + return _rect_lines_and_contour(x0, y0, x1, y1) + + cx0, cx1 = x0 + r, x1 - r + cy0, cy1 = y0 + r, y1 - r + + entities = [ + _line([cx0, y0], [cx1, y0]), + _line([x0, cy0], [x0, cy1]), + _line([cx0, y1], [cx1, y1]), + _line([x1, cy0], [x1, cy1]), + ] + + contour = [ + _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), + _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), + _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), + _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), + _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), + _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), + _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), + _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), + ] + + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 形状生成器(每个是一个独立函数,按 type 注册) +# ═══════════════════════════════════════════════════════════════ + +_Ctx = dict[str, Any] + + +def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + r = float(profile.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities = [_circle(center, r)] + cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return entities, contour + + +def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + inner_r = float(profile.get("inner_radius_mm") or 0) + outer_r = float(profile.get("outer_radius_mm") or 0) + if inner_r <= 0 or outer_r <= 0: + raise ValueError("annulus radii must be > 0") + if inner_r >= outer_r: + raise ValueError("inner_radius >= outer_radius") + return [_circle(center, inner_r), _circle(center, outer_r)], [] + + +def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + items = profile.get("items") or [] + if not items: + raise ValueError("circles items must be non-empty") + return _build_circle_entities(items), [] + + +def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 + + 参数: + radius_mm: 孔半径 + count_x / count_y: 列数、行数 + spacing_x_mm / spacing_y_mm: 圆心间距 + origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) + 或 center_mm: 阵列几何中心(与 origin_mm 二选一) + + 覆盖: b006(4×5 通孔阵列) + """ + r = float(profile["radius_mm"]) + nx = int(profile["count_x"]) + ny = int(profile["count_y"]) + sx = float(profile["spacing_x_mm"]) + sy = float(profile["spacing_y_mm"]) + if r <= 0 or nx < 1 or ny < 1: + raise ValueError("circle_grid: invalid radius/counts") + + if profile.get("center_mm") is not None: + cc = profile["center_mm"] + u0 = float(cc[0]) - (nx - 1) * sx / 2.0 + v0 = float(cc[1]) - (ny - 1) * sy / 2.0 + else: + origin = profile.get("origin_mm") or [0.0, 0.0] + u0, v0 = float(origin[0]), float(origin[1]) + + items = [ + {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} + for j in range(ny) + for i in range(nx) + ] + return _build_circle_entities(items), [] + + +def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + x0, y0, x1, y1 = _build_rect_bounds(profile) + return _rect_lines_and_contour(x0, y0, x1, y1) + + +def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + boundary = profile.get("boundary") or {} + circle_items = profile.get("circles") or [] + btype = boundary.get("type") or "rectangle" + if btype in ("rectangle", "rectangle_with_fillets"): + if btype == "rectangle": + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _rect_lines_and_contour(x0, y0, x1, y1) + else: + fr = float(boundary.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(circle_items)) + return ent, con + raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") + + +def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + fr = float(profile.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(profile) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(profile.get("circles") or [])) + return ent, con + + +def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") + length = float(profile.get("length_mm") or 0) + width = float(profile.get("width_mm") or 0) + if length <= 0 or width <= 0: + raise ValueError("obround needs positive length/width") + r = width / 2 + cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) + offset = max(0, (length - width) / 2) + left_cx, right_cx = cx - offset, cx + offset + + if offset < 0.001: + c3d = [cx, cy, 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return [_circle([cx, cy], r)], contour + + top_y, bot_y = cy + r, cy - r + left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] + contour = [ + _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), + _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), + _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), + _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), + _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), + _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), + ] + entities = [ + _line([left_cx, bot_y], [right_cx, bot_y]), + _line([left_cx, top_y], [right_cx, top_y]), + _circle([left_cx, cy], r), + _circle([right_cx, cy], r), + ] + return entities, contour + + +def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + vertices = profile.get("vertices") or [] + if len(vertices) >= 3: + pts_2d = [(float(v[0]), float(v[1])) for v in vertices] + entities, contour = [], [] + for i in range(len(pts_2d)): + s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + # 015133: no vertices in profile -> use entities from compiler_context + ents = meta.get("_entities") or [] + if not ents: + raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") + contour = [] + for e in ents: + t = e.get("type", "") + if t == "line": + s = e.get("start", [0, 0]) + ed = e.get("end", [0, 0]) + contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) + elif t == "arc": + contour.append(_contour_line( + [float(e["start"][0]), float(e["start"][1]), 0.0], + [float(e["end"][0]), float(e["end"][1]), 0.0])) + return list(ents), contour + + +def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """工字形凸耳:12线 + 4弧 + 4孔""" + bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) + fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) + cr = float(profile["corner_radius_mm"]) + hr = float(profile.get("hole_radius_mm") or 0) + hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr + av_bot, av_top = fh - cr, bh - fh + cr + + segs = [ + ("L", hfw, 0, -hfw, 0), + ("L", -hfw, 0, -hfw, fh - cr), + ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), + ("L", -ar, fh, -hw, fh), + ("L", -hw, fh, -hw, bh - fh), + ("L", -hw, bh - fh, -ar, bh - fh), + ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), + ("L", -hfw, bh - fh + cr, -hfw, bh), + ("L", -hfw, bh, hfw, bh), + ("L", hfw, bh, hfw, bh - fh + cr), + ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), + ("L", ar, bh - fh, hw, bh - fh), + ("L", hw, bh - fh, hw, fh), + ("L", hw, fh, ar, fh), + ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), + ("L", hfw, fh - cr, hfw, 0), + ] + entities, contour = [], [] + for s in segs: + if s[0] == "L": + _, u1, v1, u2, v2 = s + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + else: + _, u1, v1, u2, v2, cu, cv, r = s + contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) + + if hr > 0: + for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: + entities.append(_circle([cu, cv], hr)) + return entities, contour + + +def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" + w, h = float(profile["width_mm"]), float(profile["height_mm"]) + n = profile.get("notch") or {} + n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) + n_depth = float(n["depth_mm"]) + n_ir = float(n.get("inner_radius_mm") or 0) + n_cr = float(n.get("corner_radius_mm") or 0) + hw = w / 2 + inner_u = hw - n_depth + + if n_ir > 0 and n_cr > 0: + icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 + entities, contour = [], [] + for u1, v1, u2, v2 in [ + (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), + (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), + (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), + (-hw, 0, hw, 0), (hw, h, -hw, h), + ]: + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + + def _notch(sign_u, y_bot, y_top): + u = sign_u * hw + ec = sign_u * (hw - n_cr) + icu2 = sign_u * icu + contour.append(_contour_arc( + [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) + av = y_bot + n_cr + au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) + contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) + bv = y_top - n_cr + bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) + contour.append(_contour_arc( + [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) + contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) + contour.append(_contour_arc( + [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) + + _notch(+1, n_ys, n_ye) + _notch(+1, h - n_ye, h - n_ys) + _notch(-1, n_ys, n_ye) + _notch(-1, h - n_ye, h - n_ys) + return entities, contour + + # 简化多边形(5 参数) + verts = [ + (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), + (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), + (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), + (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), + (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), + (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), + ] + entities, contour = [], [] + pts = [(float(v[0]), float(v[1])) for v in verts] + for i in range(len(pts)): + s, e = pts[i], pts[(i + 1) % len(pts)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + bw = float(profile["bottom_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + si = float(profile.get("step_inset_mm") or 0) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) + v3 = (sign * bw, -ah); v4 = (sign * tw, -si) + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的斜底梯形截面(5顶点)。 + + 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 + 参数: + axis_height_mm: 轴侧总高度(V1→V2) + top_width_mm: 顶部宽度(轴→外壁) + wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) + wall_height_mm: 壁段高度(V4→V3) + wall_width_mm: 壁距轴的距离 + on_axis_side: "left"(U负) 或 "right"(U正) + """ + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + wh = float(profile["wall_height_mm"]) + ww = float(profile["wall_width_mm"]) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + + v0 = (sign * tw, 0.0) # 顶部外侧 + v1 = (0.0, 0.0) # 轴顶点 + v2 = (0.0, -ah) # 轴底部 + v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) + v4 = (sign * ww, -wi) # 壁顶部(台阶) + + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 弧边复合轮廓生成器(按"015133 手册"方法注册) +# ═══════════════════════════════════════════════════════════════ + + +def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 + + 参数: + outer_radius_mm: 大圆半径 + notch_radius_mm: 每个凹口的圆弧半径 + notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) + 默认 [0, 90, 180, 270](十字槽) + 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 + + 覆盖文件: 48, 49, 50, 82 + """ + import math + R = float(profile["outer_radius_mm"]) + r = float(profile["notch_radius_mm"]) + angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) + + # 每个凹口在大圆上占据的半角宽度 + delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) + angles_rad = [math.radians(a) for a in sorted(angles_deg)] + + entities, contour = [], [] + n = len(angles_rad) + + for i in range(n): + prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 + curr_enter = angles_rad[i] - delta # 当前凹口入口 + + # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) + ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) + pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) + + contour.append(_contour_arc( + [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], + [0.0, 0.0, 0.0], R, + )) + + # 凹口弧:从入口→出口,中心在外圆上 + curr_exit = angles_rad[i] + delta + nc_u = R * math.cos(angles_rad[i]) + nc_v = R * math.sin(angles_rad[i]) + + pn_enter_u = R * math.cos(curr_enter) + pn_enter_v = R * math.sin(curr_enter) + pn_exit_u = R * math.cos(curr_exit) + pn_exit_v = R * math.sin(curr_exit) + + # 凹口弧:从出口回到入口(与大弧方向相反) + contour.append(_contour_arc( + [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], + [nc_u, nc_v, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 + + 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 + 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 + + 参数: + arc_radius_mm: 大弧半径(圆心在原点) + slot_half_width_mm: 槽口半宽(从圆心起的径向距离) + chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) + + 覆盖文件: 87, 88, 89, 90 + """ + import math + R = float(profile["arc_radius_mm"]) + hw = float(profile["slot_half_width_mm"]) + ch = float(profile["chord_half_mm"]) + + # 弧端点:在圆上,到中心轴的垂直距离为 ch + # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) + half_angle = math.asin(max(-1.0, min(1.0, ch / R))) + + # 弧端点坐标(圆上,2 个象限) + arc_x = R * math.cos(half_angle) + arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) + + # 4 个关键点(顺时针) + # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) + # 右上角弧段 + arc_x_neg_angle = R * math.cos(-half_angle) + arc_y_neg = R * math.sin(-half_angle) + + p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) + p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 + + # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) + # xs 由圆 R 和 ch 确定 + xs = math.sqrt(max(0, R * R - ch * ch)) + + contour = [ + # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) + _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 左侧线:(-xs, -ch) → (-xs, +ch) ... + # 不对,夹着槽口 + + ] + + # 重新按 87 的实际边序列构建 + # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 + # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 + # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 + # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) + # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 + # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 + # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 + # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 + + # 参数化: + # slot_half = 27.5 (槽口半宽) + # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) + # arc_radius = 40 + # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 + + sh = hw # slot half + axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint + + contour = [ + # 槽口竖线(从左下到左上) + _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), + # 连接到弧(从槽口左上到弧左下) + _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), + # 上弧(从弧左下到弧右下,经过原点顶) + _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧右下到槽口右上) + _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), + # 槽口竖线(从右上到右下) + _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), + # 连接到弧(从槽口右下到弧右上) + _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), + # 下弧(从弧右上到弧左上,经过原点底) + _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧左上到槽口左下) + _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), + ] + + entities = [ + _line([-sh, -ch], [-sh, ch]), + _line([-sh, ch], [-axe, ch]), + _line([sh, ch], [axe, ch]), + _line([sh, ch], [sh, -ch]), + _line([sh, -ch], [axe, -ch]), + _line([-sh, -ch], [-axe, -ch]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+径向凸耳:大圆弧上有矩形凸起。 + + 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 + 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 + + 参数: + outer_radius_mm: 大圆半径 + tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) + tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) + + 覆盖文件: 91, 92, 93, 94, 95 + """ + import math + R = float(profile["outer_radius_mm"]) + tu = float(profile.get("tab_u_half_mm") or 0) + tv = float(profile.get("tab_v_offset_mm") or 0) + + # 凸耳根部在圆上的角度 + angle = math.asin(min(1.0, max(0.0, tv / R))) + + # 弧上根部点(右侧) + root_u = R * math.cos(angle) + root_v = R * math.sin(angle) + + # 凸耳内缘 + inner_u = root_u - tu + inner_v = root_v * 0.9 # 略浅于弧线 + + # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 + + contour = [] + + # 上弧:从左侧根部到右侧根部(经过顶点) + contour.append(_contour_arc( + [root_u, root_v, 0.0], [-root_u, root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 右侧凸耳(多边形:根部→内顶→内底→根部) + contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) + contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) + contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) + + # 下弧:从右侧底部到左侧底部(经过底点) + contour.append(_contour_arc( + [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 左侧凸耳(镜像) + contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) + contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) + contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) + + entities = [ + _line([root_u, root_v], [inner_u, inner_v]), + _line([inner_u, inner_v], [inner_u, -inner_v]), + _line([inner_u, -inner_v], [root_u, -root_v]), + _line([-root_u, -root_v], [-inner_u, -inner_v]), + _line([-inner_u, -inner_v], [-inner_u, inner_v]), + _line([-inner_u, inner_v], [-root_u, root_v]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆角矩形+两侧中心凹槽。 + + 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 + + 参数: + half_width_mm: 矩形半宽(不含圆角) + half_height_mm: 矩形半高(不含圆角) + corner_radius_mm: 四角圆角半径 + slot_radius_mm: 两侧中心凹槽半径(默认 5.0) + circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] + + 覆盖文件: 58, 63, 64, 65, 66 + """ + hw = float(profile["half_width_mm"]) + hh = float(profile["half_height_mm"]) + cr = float(profile["corner_radius_mm"]) + sr = float(profile.get("slot_radius_mm") or cr * 0.5) + + # 注意: v=-Z, 所以 v 正方向朝 Z 负 + # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] + # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh + + entities, contour = [], [] + top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh + + # 上边(直线,从左上角到右上角) + contour.append(_contour_line( + [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) + entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) + + # 右上圆角(逆时针绕 center: 从顶点到右侧) + contour.append(_contour_arc( + [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], + [(hw - cr), top_v + cr, 0.0], cr)) + + # 右边上半(从圆角到凹槽上方) + contour.append(_contour_line( + [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) + entities.append(_line([hw, top_v + cr], [hw, -sr])) + + # 右侧中心凹槽(半圆向内的 U 形凹口) + contour.append(_contour_arc( + [hw, sr, 0.0], [hw, -sr, 0.0], + [hw, 0.0, 0.0], sr)) + + # 右边下半(从凹槽下方到右下角) + contour.append(_contour_line( + [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) + entities.append(_line([hw, sr], [hw, bot_v - cr])) + + # 右下圆角 + contour.append(_contour_arc( + [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], + [(hw - cr), bot_v - cr, 0.0], cr)) + + # 下边 + contour.append(_contour_line( + [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) + entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) + + # 左下圆角 + contour.append(_contour_arc( + [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], + [-(hw - cr), bot_v - cr, 0.0], cr)) + + # 左边下半 + contour.append(_contour_line( + [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) + entities.append(_line([-hw, bot_v - cr], [-hw, sr])) + + # 左侧中心凹槽 + contour.append(_contour_arc( + [-hw, -sr, 0.0], [-hw, sr, 0.0], + [-hw, 0.0, 0.0], sr)) + + # 左边上半 + contour.append(_contour_line( + [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) + entities.append(_line([-hw, -sr], [-hw, top_v + cr])) + + # 左上圆角 + contour.append(_contour_arc( + [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], + [-(hw - cr), top_v + cr, 0.0], cr)) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 更多弧边复合轮廓生成器 +# ═══════════════════════════════════════════════════════════════ + + +def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 + + 参数: + radius_mm: 大弧半径(圆心在原点) + chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 + 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) + + 覆盖文件: 144358 + """ + import math + R = float(profile["radius_mm"]) + side = profile.get("chord_sign", "left") + sign = 1 if side == "left" else -1 + + # chord at x=cx (cx^2 + y^2 = R^2) + # For side="left": chord at x = sqrt(R^2 - y_len^2) ... + # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) + # So the chord is at x=34, v ranges from -22.9 to 22.9 + # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ + + v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) + # 实际上,chord x 可以根据 radius 推导 + # 使用 chord_x 参数如果存在,否则用近似 + chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 + + v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) + cx = sign * chord_x # 弦线 x 坐标 + + contour = [ + # 弦线(从下到上) + _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), + # 大弧(从右上到左下,即从左到右沿弧线) + _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), + ] + + entities = [ + _line([cx, -v_half], [cx, v_half]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 + + 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) + + 覆盖文件: 177126 + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + h_rad = math.radians(h_deg) + + # 内弧端点 + iu_pos = ir * math.cos(h_rad) + iv_pos = ir * math.sin(h_rad) + iu_neg = ir * math.cos(-h_rad) + iv_neg = ir * math.sin(-h_rad) + + # 外弧端点 + ou_pos = oR * math.cos(h_rad) + ov_pos = oR * math.sin(h_rad) + ou_neg = oR * math.cos(-h_rad) + ov_neg = oR * math.sin(-h_rad) + + contour = [ + # 右侧径向线(从内弧到外弧,+h角度) + _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), + # 外弧(从 +h 到 -h) + _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), + # 左侧径向线(从外弧到内弧,-h角度) + _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), + # 内弧(从 -h 到 +h) + _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), + ] + + entities = [ + _line([iu_pos, iv_pos], [ou_pos, ov_pos]), + _line([ou_neg, ov_neg], [iu_neg, iv_neg]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 + + 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 + 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 + + 参数: + inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 + island_radius_mm: 岛外弧半径 + island_gap_mm: 岛内外弧径向间距(等宽) + center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) + + 覆盖: b005 + """ + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + island_r = float(profile["island_radius_mm"]) + gap = float(profile.get("island_gap_mm") or 1.0) + if ir <= 0 or oR <= ir or island_r <= gap: + raise ValueError("partial_ring_with_arc_island: invalid radii") + + replicas = profile.get("replicas") + if replicas: + angles = [float(r["center_angle_deg"]) for r in replicas] + else: + angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] + + h = math.radians(h_deg) + entities: list[_Ctx] = [] + regions: list[dict[str, Any]] = [] + + for ca_deg in angles: + ca = math.radians(ca_deg) + a0, a1 = ca - h, ca + h + + def polar(r: float, ang: float) -> list[float]: + return [r * math.cos(ang), r * math.sin(ang), 0.0] + + # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) + ou0, ou1 = polar(oR, a0), polar(oR, a1) + iu0, iu1 = polar(ir, a0), polar(ir, a1) + outer = [ + _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), + _contour_line(ou1, iu1), + _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), + _contour_line(iu0, ou0), + ] + entities.extend([ + _line(ou0[:2], ou1[:2]), + _line(ou1[:2], iu1[:2]), + _line(iu1[:2], iu0[:2]), + _line(iu0[:2], ou0[:2]), + ]) + + # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir + ux, uy = math.cos(ca), math.sin(ca) + mx = oR * ux * math.cos(h) + my = oR * uy * math.cos(h) + cdx, cdy = -uy, ux + span = ir * math.cos(h) + e1 = [mx + span * cdx, my + span * cdy, 0.0] + e2 = [mx - span * cdx, my - span * cdy, 0.0] + + # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 + dot = e1[0] * ux + e1[1] * uy + e2n = e1[0] * e1[0] + e1[1] * e1[1] + disc = max(0.0, dot * dot - (e2n - island_r * island_r)) + t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) + t = t1 if abs(t1) <= abs(t2) else t2 + cx, cy = t * ux, t * uy + c3 = [cx, cy, 0.0] + + def inward(pt: list[float]) -> list[float]: + vx, vy = cx - pt[0], cy - pt[1] + L = math.hypot(vx, vy) or 1.0 + return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] + + i1, i2 = inward(e1), inward(e2) + ri = island_r - gap + + # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 + # 外弧走短弧中指向外侧(远离原点)的那条 + hole = [ + _contour_arc(e1, e2, c3, island_r), + _contour_line(e2, i2), + _contour_arc(i2, i1, c3, ri), + _contour_line(i1, e1), + ] + entities.extend([ + _line(e1[:2], e2[:2]), + _line(e2[:2], i2[:2]), + _line(i2[:2], i1[:2]), + _line(i1[:2], e1[:2]), + ]) + regions.append({"outer": outer, "holes": [hole]}) + + meta["_regions"] = regions + return entities, [] + + +def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 + + 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 + + 参数: + arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] + (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) + + 覆盖文件: 020543 + """ + import math + arc_list = profile.get("arcs") or [] + + if not arc_list or len(arc_list) < 2: + raise ValueError("arc_chain needs at least 2 arcs") + + entities, contour = [], [] + + for arc_desc in arc_list: + r = float(arc_desc["radius_mm"]) + center = arc_desc.get("center") or [0.0, 0.0] + cu, cv = float(center[0]), float(center[1]) + sa = math.radians(float(arc_desc["start_angle_deg"])) + ea = math.radians(float(arc_desc["end_angle_deg"])) + + su = cu + r * math.cos(sa) + sv = cv + r * math.sin(sa) + eu = cu + r * math.cos(ea) + ev = cv + r * math.sin(ea) + + contour.append(_contour_arc( + [su, sv, 0.0], [eu, ev, 0.0], + [cu, cv, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 + + 用于 extrude_cut 在圆柱壁面上开弧形槽口。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) + end_angle_deg: 槽终止角度 + + 覆盖文件: 020543 (sk_02, sk_03, sk_04) + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + sa_deg = float(profile["start_angle_deg"]) + ea_deg = float(profile["end_angle_deg"]) + + fr = (oR - ir) / 2.0 # 端盖圆角半径 + sa = math.radians(sa_deg) + ea = math.radians(ea_deg) + + entities, contour = [], [] + + # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) + # 1. 内弧(从 start→end) + isu = ir * math.cos(sa); isv = ir * math.sin(sa) + ieu = ir * math.cos(ea); iev = ir * math.sin(ea) + contour.append(_contour_arc( + [isu, isv, 0.0], [ieu, iev, 0.0], + [0.0, 0.0, 0.0], ir, + )) + + # 2. 终端圆角(半圆,从内弧终点到外弧终点) + fc_u = (ir + oR) / 2.0 + fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) + osu = oR * math.cos(sa); osv = oR * math.sin(sa) + oeu = oR * math.cos(ea); oev = oR * math.sin(ea) + + contour.append(_contour_arc( + [ieu, iev, 0.0], [oeu, oev, 0.0], + [fcu_s, fcv_s, 0.0], fr, + )) + + # 3. 外弧(从 end→start,反向) + contour.append(_contour_arc( + [oeu, oev, 0.0], [osu, osv, 0.0], + [0.0, 0.0, 0.0], oR, + )) + + # 4. 起始端圆角(从外弧起点到内弧起点) + fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) + contour.append(_contour_arc( + [osu, osv, 0.0], [isu, isv, 0.0], + [fcu_e, fcv_e, 0.0], fr, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 程序化重复切口 +# ═══════════════════════════════════════════════════════════════ + +def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: + """把按顺序给出的二维顶点变成闭合直线轮廓。""" + return [ + _contour_line( + [vertices[i][0], vertices[i][1], 0.0], + [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], + ) + for i in range(len(vertices)) + ] + + +def _transform_pattern_contour( + contour: list[_Ctx], + x_mm: float, + y_mm: float, + angle_deg: float, + scale: float = 1.0, +) -> list[_Ctx]: + """旋转、缩放并平移一个二维轮廓。""" + a = math.radians(angle_deg) + ca, sa = math.cos(a), math.sin(a) + + def point(p: list[float]) -> list[float]: + x, y = float(p[0]) * scale, float(p[1]) * scale + return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] + + result: list[_Ctx] = [] + for edge in contour: + item = deepcopy(edge) + item["start_mm"] = point(edge["start_mm"]) + item["end_mm"] = point(edge["end_mm"]) + if edge.get("center_mm") is not None: + item["center_mm"] = point(edge["center_mm"]) + if edge.get("radius_mm") is not None: + item["radius_mm"] = float(edge["radius_mm"]) * scale + result.append(item) + return result + + +def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: + """从少量命名尺寸生成一个切口母形。""" + kind = str(motif.get("type") or "") + + if kind == "circle": + radius = float(motif["radius_mm"]) + return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] + + if kind in ("square", "rectangle"): + width = float(motif["width_mm"]) + height = float(motif.get("height_mm") or width) + return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] + + if kind == "obround": + length = float(motif["length_mm"]) + width = float(motif["width_mm"]) + return _gen_obround( + {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, + {}, + )[1] + + if kind == "cross": + size = float(motif["size_mm"]) + arm = float(motif["arm_width_mm"]) + half, arm_half = size / 2.0, arm / 2.0 + vertices = [ + (-arm_half, -half), (arm_half, -half), + (arm_half, -arm_half), (half, -arm_half), + (half, arm_half), (arm_half, arm_half), + (arm_half, half), (-arm_half, half), + (-arm_half, arm_half), (-half, arm_half), + (-half, -arm_half), (-arm_half, -arm_half), + ] + return _poly_contour(vertices) + + if kind == "d_shape_polygon": + stem = float(motif["stem_length_mm"]) + nose = float(motif["nose_depth_mm"]) + half_height = float(motif["half_height_mm"]) + segments = int(motif.get("arc_segments") or 14) + vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] + # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 + for i in range(1, segments): + angle = math.pi / 2.0 - math.pi * i / segments + vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) + vertices.append((0.0, -half_height)) + return _poly_contour(vertices) + + if kind == "regular_hexagon": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(60.0 * i)), + radius * math.sin(math.radians(60.0 * i)), + ) + for i in range(6) + ]) + + if kind == "skew_hexagon": + # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, + # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 + radius = float(motif["nominal_radius_mm"]) + return _poly_contour([ + (radius, 0.0), + (radius * 0.317014, radius * 0.682, ), + (-radius * 0.5, radius * 0.682), + (-radius * 1.183014, 0.0), + (-radius * 0.408494, -radius * 0.774519), + (radius * 0.317014, -radius * 0.774519), + ]) + + if kind == "triangle": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(120.0 * i)), + radius * math.sin(math.radians(120.0 * i)), + ) + for i in range(3) + ]) + + if kind == "teardrop_polygon": + if motif.get("left_width_mm") is not None: + left = float(motif["left_width_mm"]) + right = float(motif["right_width_mm"]) + tip = float(motif["tip_height_mm"]) + bottom = -float(motif["bottom_depth_mm"]) + shoulder = float(motif["shoulder_height_mm"]) + return _poly_contour([ + (0.0, tip), + (right, shoulder), + (right, bottom), + (-left, bottom), + (-left, shoulder), + ]) + width = float(motif["width_mm"]) + height = float(motif["height_mm"]) + shoulder = float(motif.get("shoulder_fraction") or 0.58) + half = width / 2.0 + top = height / 2.0 + bottom = -height / 2.0 + shoulder_y = bottom + height * shoulder + return _poly_contour([ + (0.0, top), + (half, shoulder_y), + (half, bottom), + (-half, bottom), + (-half, shoulder_y), + ]) + + if kind == "trapezoid": + bottom = float(motif["bottom_width_mm"]) + top = float(motif["top_width_mm"]) + height = float(motif["height_mm"]) + hh = height / 2.0 + return _poly_contour([ + (-bottom / 2.0, -hh), + (bottom / 2.0, -hh), + (top / 2.0, hh), + (-top / 2.0, hh), + ]) + + if kind == "annular_sector_polygon": + inner = float(motif["inner_radius_mm"]) + outer = float(motif["outer_radius_mm"]) + half_angle = float(motif["half_angle_deg"]) + segments = int(motif.get("arc_segments") or 8) + outer_pts = [ + ( + outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + inner_pts = [ + ( + inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), + inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + return _poly_contour(outer_pts + inner_pts) + + raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") + + +def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: + """展开语义布局,返回 (x, y, rotation_deg, scale)。""" + kind = str(layout.get("type") or "") + orientation = str(layout.get("orientation") or "fixed") + orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) + + def orient(angle: float) -> float: + if orientation == "radial": + return angle + orientation_offset + if orientation == "tangential": + return angle + 90.0 + orientation_offset + if orientation == "snapped_radial": + snap = float(layout.get("orientation_snap_deg") or 45.0) + return round(angle / snap) * snap + orientation_offset + return orientation_offset + + if kind in ("ring", "angular"): + radius = float(layout.get("radius_mm") or 0.0) + count = int(layout["count"]) + start = float(layout.get("start_angle_deg") or 0.0) + step = float(layout.get("angle_step_deg") or (360.0 / count)) + angular_only = kind == "angular" + return [ + ( + 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), + 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "concentric_rings": + result: list[tuple[float, float, float, float]] = [] + for ring in layout.get("rings") or []: + merged = dict(layout) + merged.update(ring) + merged["type"] = "ring" + result.extend(_pattern_placements(merged)) + return result + + if kind == "disc_grid": + nx, ny = int(layout["count_x"]), int(layout["count_y"]) + sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) + center = layout.get("center_mm") or [0.0, 0.0] + x0 = float(center[0]) - (nx - 1) * sx / 2.0 + y0 = float(center[1]) - (ny - 1) * sy / 2.0 + limit = layout.get("max_center_radius_mm") + points = [ + (x0 + i * sx, y0 + j * sy) + for j in range(ny) + for i in range(nx) + ] + if limit is not None: + points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] + return [(x, y, orientation_offset, 1.0) for x, y in points] + + if kind == "open_arc": + radius = float(layout["radius_mm"]) + count = int(layout["count"]) + start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) + step = 0.0 if count == 1 else (end - start) / (count - 1) + return [ + ( + radius * math.cos(math.radians(start + i * step)), + radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "spiral": + count = int(layout["count"]) + start_radius = float(layout["start_radius_mm"]) + radius_step = float(layout["radius_step_mm"]) + start_angle = float(layout.get("start_angle_deg") or 0.0) + angle_step = float(layout["angle_step_deg"]) + result = [] + for i in range(count): + radius = start_radius + i * radius_step + angle = start_angle + i * angle_step + result.append(( + radius * math.cos(math.radians(angle)), + radius * math.sin(math.radians(angle)), + orient(angle), + 1.0, + )) + return result + + if kind == "cross_lines": + count = int(layout["count_per_axis"]) + spacing = float(layout["spacing_mm"]) + start = -(count - 1) * spacing / 2.0 + result = [] + for i in range(count): + value = start + i * spacing + result.append((value, 0.0, orientation_offset, 1.0)) + result.append((0.0, value, orientation_offset + 90.0, 1.0)) + return result + + if kind == "x_field": + levels = int(layout["levels"]) + spacing = float(layout["spacing_mm"]) + start = -(levels - 1) * spacing / 2.0 + result = [] + for i in range(levels): + value = start + i * spacing + if abs(value) < 1e-9: + rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset + result.append((0.0, 0.0, rotation, 1.0)) + else: + for y in (value, -value): + if orientation == "diagonal_axes": + rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset + else: + angle = math.degrees(math.atan2(y, value)) + rotation = orient(angle) + result.append((value, y, rotation, 1.0)) + return result + + if kind == "twin_strips": + x_offset = float(layout["x_offset_mm"]) + count = int(layout["count_y"]) + y_start = float(layout["y_start_mm"]) + y_end = float(layout["y_end_mm"]) + step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) + return [ + (x, y_start + j * step, orientation_offset, 1.0) + for j in range(count) + for x in (-x_offset, x_offset) + ] + + if kind == "corner_clusters": + levels = [float(v) for v in (layout.get("levels_mm") or [])] + return [ + (sx * x, sy * y, orientation_offset, 1.0) + for sx in (-1.0, 1.0) + for sy in (-1.0, 1.0) + for y in levels + for x in levels + ] + + if kind == "diamond_field": + radius = int(layout["manhattan_radius"]) + spacing = float(layout["spacing_mm"]) + return [ + (i * spacing, j * spacing, orientation_offset, 1.0) + for distance in range(radius + 1) + for j in range(-radius, radius + 1) + for i in range(-radius, radius + 1) + if abs(i) + abs(j) == distance + ] + + raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") + + +def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" + motif = profile.get("motif") or {} + layout = profile.get("layout") or {} + base = _pattern_motif_contour(motif) + regions = [] + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("patterned_cutouts: layout produced no regions") + meta["_regions"] = regions + return [], [] + + +def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """把少量不同母形/布局的程序化图案合并到同一草图。""" + regions: list[_Ctx] = [] + for pattern in profile.get("patterns") or []: + motif = pattern.get("motif") or {} + layout = pattern.get("layout") or {} + base = _pattern_motif_contour(motif) + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("compound_patterned_cutouts: patterns produced no regions") + meta["_regions"] = regions + return [], [] + + +# ═══════════════════════════════════════════════════════════════ +# Evidence v2 analytic contours +# ═══════════════════════════════════════════════════════════════ + +_ANALYTIC_TOLERANCE_MM = 1e-5 + + +def _distance_2d(left: list[float], right: list[float]) -> float: + return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) + + +def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: + result = deepcopy(edge) + result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] + if result.get("type") == "arc" and "clockwise" in result: + result["clockwise"] = not bool(result["clockwise"]) + return result + + +def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: + """Order/reorient a contour without depending on SolidWorks segment order.""" + if not edges: + return [] + pending = [deepcopy(edge) for edge in edges] + ordered = [pending.pop(0)] + while pending: + tail = ordered[-1]["end_mm"] + match_index = None + reverse = False + for index, edge in enumerate(pending): + if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + break + if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + reverse = True + break + if match_index is None: + raise ValueError("analytic_contours: segments do not form a connected contour") + edge = pending.pop(match_index) + ordered.append(_reverse_analytic_edge(edge) if reverse else edge) + if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + raise ValueError("analytic_contours: closed contour endpoints do not meet") + return ordered + + +def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: + center = segment.get("center") or [0.0, 0.0] + radius = float(segment.get("radius_mm") or 0.0) + if radius <= 0: + raise ValueError("analytic_contours: circle radius_mm must be > 0") + cx, cy = float(center[0]), float(center[1]) + clockwise = bool(segment.get("clockwise", False)) + angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] + points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] + return [ + _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) + for index in range(4) + ] + + +def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: + segment_type = segment.get("type") + if segment_type == "line": + return [_contour_line(segment["start"], segment["end"])] + if segment_type == "arc": + return [ + _contour_arc( + segment["start"], segment["end"], segment["center"], + segment.get("radius_mm"), segment.get("clockwise"), + ) + ] + if segment_type == "circle": + return _analytic_circle_edges(segment) + if segment_type == "bspline": + raise ValueError("analytic_contours: bspline requires an explicit approximation capability") + raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + + +def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: + """Create a deterministic planar sample only for containment classification.""" + points: list[tuple[float, float]] = [] + for edge in edges: + start = edge["start_mm"] + points.append((float(start[0]), float(start[1]))) + if edge.get("type") != "arc": + continue + center = edge["center_mm"] + end = edge["end_mm"] + sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) + ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) + start_angle = math.atan2(sy, sx) + end_angle = math.atan2(ey, ex) + delta = end_angle - start_angle + if edge.get("clockwise"): + if delta >= 0: + delta -= math.tau + elif delta <= 0: + delta += math.tau + for fraction in (0.25, 0.5, 0.75): + angle = start_angle + delta * fraction + radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) + points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) + return points + + +def _loop_area(points: list[tuple[float, float]]) -> float: + if len(points) < 3: + return 0.0 + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) + + +def _endpoint_signed_area(edges: list[_Ctx]) -> float: + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + return sum( + points[index][0] * points[(index + 1) % len(points)][1] + - points[(index + 1) % len(points)][0] * points[index][1] + for index in range(len(points)) + ) / 2.0 + + +def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: + """Repair inconsistent sweep flags on a conventional rounded rectangle. + + Evidence exports occasionally label one or more 90-degree corner arcs + with the opposite direction. Honouring those isolated flags creates + 270-degree loops. This normalizer applies only to the unambiguous shape: + exactly four equal-radius quarter arcs in one closed loop. Other arcs, + including annular sectors and long sweeps, retain their captured flags. + """ + arcs = [edge for edge in edges if edge.get("type") == "arc"] + if len(arcs) != 4: + return + radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] + if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + return + for edge in arcs: + center = edge.get("center_mm") + if not isinstance(center, list): + return + start, end = edge["start_mm"], edge["end_mm"] + first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1])) + angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) + if abs(angle - math.pi / 2) > 1e-4: + return + # A clockwise endpoint loop needs clockwise short corner arcs; a + # counter-clockwise loop needs their reverse. This preserves the actual + # rounded-rectangle boundary, independent of per-segment export noise. + clockwise = _endpoint_signed_area(edges) < 0.0 + for edge in arcs: + edge["clockwise"] = clockwise + + +def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: + if len(loop) < 3: + return False + inside = False + x, y = point + previous = loop[-1] + for current in loop: + x1, y1 = current + x2, y2 = previous + if (y1 > y) != (y2 > y): + intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 + if x < intersect_x: + inside = not inside + previous = current + return inside + + +def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. + + The returned regions preserve holes and islands. The build adapter owns + B-rep creation; this profile generator only reasons about sketch geometry. + """ + loops: list[_Ctx] = [] + entities: list[_Ctx] = [] + for contour_index, contour in enumerate(profile.get("contours") or []): + if not contour.get("closed"): + raise ValueError(f"analytic_contours: contour {contour_index} is open") + segment_edges: list[_Ctx] = [] + for segment in contour.get("segments") or []: + segment_type = segment.get("type") + if segment_type == "line": + entities.append(_line(segment["start"], segment["end"])) + elif segment_type == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) + segment_edges.extend(_analytic_segment_edges(segment)) + if not segment_edges: + continue + edges = _join_analytic_edges(segment_edges, closed=True) + _normalize_quarter_rounding_direction(edges) + points = _sample_analytic_loop(edges) + area = _loop_area(points) + if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") + loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) + + for segment in profile.get("construction") or []: + if segment.get("type") == "line": + entities.append(_line(segment["start"], segment["end"], construction=True)) + elif segment.get("type") == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) + + if not loops: + return entities, [] + for loop in loops: + # Role tags captured from the source sketch are useful provenance but + # not authoritative geometry. A number of exports label separate + # closed contours as ``inner`` although no outer contour contains + # them. The even-odd containment rule is deterministic for the + # supported analytic curves and preserves those independent regions. + contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) + loop["role"] = "inner" if contained_by % 2 else "outer" + outers = [loop for loop in loops if loop["role"] == "outer"] + inners = [loop for loop in loops if loop["role"] == "inner"] + regions = [{"outer": outer["edges"], "holes": []} for outer in outers] + for inner in inners: + containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + if not containing: + raise ValueError("analytic_contours: inner contour has no containing outer contour") + selected = min(containing, key=lambda outer: outer["area"]) + regions[outers.index(selected)]["holes"].append(inner["edges"]) + meta["_regions"] = regions + return entities, [] + + +# ═══════════════════════════════════════════════════════════════ +# Runtime profile registry +# +# The CDSL-only runtime accepts direct geometric profiles only. Business +# shapes and procedural sketch macros are retained below solely for explicit +# importer/rebuild compatibility lowering and are not runtime capabilities. +# ═══════════════════════════════════════════════════════════════ + +CORE_SHAPE_GENERATORS: dict[str, Any] = { + "circle": _gen_circle, + "polygon": _gen_polygon, + "analytic_contours": _gen_analytic_contours, +} + +# Import/rebuild compatibility only. New CDSL must be lowered before runtime +# execution instead of adding any of these names to the core registry. +LEGACY_PROFILE_GENERATORS: dict[str, Any] = { + "annulus": _gen_annulus, + "circles": _gen_circles, + "circle_grid": _gen_circle_grid, + "rectangle": _gen_rectangle, + "rectangle_with_circles": _gen_rect_with_circles, + "rectangle_with_fillets": _gen_rect_with_fillets, + "obround": _gen_obround, + "ibone": _gen_ibone, + "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, + "revolve_chamfer": _gen_revolve_chamfer, + "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, + # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) + "circle_with_arc_notches": _gen_circle_with_arc_notches, + "circular_sector_slot": _gen_circular_sector_slot, + "circle_with_radial_tabs": _gen_circle_with_radial_tabs, + "filleted_rect_side_slots": _gen_filleted_rect_side_slots, + # 弧边形状 + "d_shape": _gen_d_shape, + "partial_ring": _gen_partial_ring, + "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, + "arc_chain": _gen_arc_chain, + "radial_slot": _gen_radial_slot, + "patterned_cutouts": _gen_patterned_cutouts, + "compound_patterned_cutouts": _gen_compound_patterned_cutouts, +} + +# Historical callers query this name to learn runtime capability. Keep the +# name but make it an alias to the core-only registry. +# This registry is intentionally local to the historical importer. It is +# useful for replaying old exports, but must never be treated as engine +# capability. +SHAPE_GENERATORS = {**CORE_SHAPE_GENERATORS, **LEGACY_PROFILE_GENERATORS} + +# ═══════════════════════════════════════════════════════════════ +# 注册表功能:扩展、查询 +# ═══════════════════════════════════════════════════════════════ + +def register_shape(ptype: str, generator: Any) -> None: + """Runtime profiles are a fixed CDSL contract, not plugin hooks.""" + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") + + +def list_registered_shapes() -> list[str]: + """返回所有已注册的形状生成器名称。""" + return sorted(SHAPE_GENERATORS.keys()) + + +# ═══════════════════════════════════════════════════════════════ +# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) +# ═══════════════════════════════════════════════════════════════ + +_ShapeInfo = dict[str, Any] + +LEGACY_SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, + "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, + "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, + "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, + "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, + "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, + "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, + "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, + "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, + "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, + "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, + "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, + "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, + "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, + "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, + "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, + "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, + "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, + "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, + "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, + "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, + "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, +} + +SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "闭合直线轮廓"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "闭合线、圆弧和圆轮廓"}, +} + + +# ═══════════════════════════════════════════════════════════════ +# 主入口 +# ═══════════════════════════════════════════════════════════════ + +def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: + """按 type 查找生成器,生成 entities + contour_edges_mm。 + + 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities + 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 + """ + profile = sketch.get("profile") + if not profile: + return sketch + + ptype = profile.get("type") + generator = SHAPE_GENERATORS.get(ptype) + if generator is None: + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") + + meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + entities, contour = generator(profile, meta) + + out = deepcopy(sketch) + + # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) + orig_ents = sketch.get("entities") or [] + keep_circles = [ + e for e in orig_ents + if e.get("type") == "circle" and not e.get("construction") + ] + if keep_circles and contour: + # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) + entities = list(entities) + keep_circles + + out["entities"] = entities + wp = sketch.get("workplane") + if contour: + out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour + regions = meta.get("_regions") or [] + if regions: + out["contour_regions_mm"] = [ + { + "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], + "holes": [ + _transform_contours(hole, wp) if wp else hole + for hole in (reg.get("holes") or []) + ], + } + for reg in regions + ] + return out + + +def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: + """对 CDSL 中所有带 profile 字段的草图进行解析。 + + 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 + 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 + """ + geom = cdsl.get("geometry") or {} + sketches = geom.get("sketches") or [] + + # 第一遍: 解析所有有自己 profile 的草图 + resolved: dict[str, dict] = {} + for sk in sketches: + sid = sk.get("id") + if sid is None: + continue + if "profile" in sk: + resolved[sid] = resolve_profile(sk) + + # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) + for sk in sketches: + sid = sk.get("id") + pf = sk.get("profile_from") + if pf and sid: + src = resolved.get(pf) + if src is None: + raise ValueError( + f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" + ) + sk2 = deepcopy(sk) + sk2["profile"] = deepcopy(src.get("profile")) + sk2.pop("profile_from", None) + + # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) + shift = sk.get("profile_shift") + if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for v in sk2["profile"]["vertices"]: + v[0] = round(v[0] + du, 6) + v[1] = round(v[1] + dv, 6) + sk2.pop("profile_shift", None) + resolved[sid] = resolve_profile(sk2) + + # 按原顺序输出 + result = [] + for sk in sketches: + sid = sk.get("id") + if sid and sid in resolved: + result.append(resolved[sid]) + else: + result.append(deepcopy(sk)) + + out = deepcopy(cdsl) + out.setdefault("geometry", {})["sketches"] = result + return out + + +def resolve_required_sketches( + cdsl: dict[str, Any], + sketch_ids: Iterable[str], + *, + errors: dict[str, str] | None = None, +) -> dict[str, Any]: + """Resolve only profiles that an executable feature actually consumes. + + ``profile_from`` dependencies are resolved recursively. Callers that + pass ``errors`` get feature-addressable failures without losing unrelated + resolved sketches; callers that omit it retain the strict exception + behavior useful to profile tooling. + """ + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} + resolved: dict[str, dict[str, Any]] = {} + resolving: set[str] = set() + + def resolve_one(sketch_id: str) -> dict[str, Any]: + if sketch_id in resolved: + return resolved[sketch_id] + sketch = by_id.get(sketch_id) + if sketch is None: + raise ValueError(f"sketch {sketch_id!r} was not found") + if sketch_id in resolving: + raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle") + resolving.add(sketch_id) + try: + if "profile" in sketch: + output = resolve_profile(sketch) + elif sketch.get("profile_from"): + source_id = str(sketch["profile_from"]) + source = resolve_one(source_id) + if not source.get("profile"): + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for vertex in output["profile"]["vertices"]: + vertex[0] = round(vertex[0] + du, 6) + vertex[1] = round(vertex[1] + dv, 6) + output.pop("profile_shift", None) + output = resolve_profile(output) + else: + output = deepcopy(sketch) + resolved[sketch_id] = output + return output + finally: + resolving.discard(sketch_id) + + for sketch_id in {str(item) for item in sketch_ids}: + try: + resolve_one(sketch_id) + except ValueError as error: + if errors is None: + raise + errors[sketch_id] = str(error) + + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [ + resolved.get(str(sketch.get("id")), deepcopy(sketch)) + for sketch in sketches + ] + return output diff --git a/backend/engine/cdsl_engine/convert_to_cdsl.py b/backend/cdsl_importer/solidworks_to_cdsl.py similarity index 98% rename from backend/engine/cdsl_engine/convert_to_cdsl.py rename to backend/cdsl_importer/solidworks_to_cdsl.py index b0161fdc..7c45f9ba 100644 --- a/backend/engine/cdsl_engine/convert_to_cdsl.py +++ b/backend/cdsl_importer/solidworks_to_cdsl.py @@ -26,9 +26,9 @@ from pathlib import Path from typing import Any, Callable try: - from .translator import normalize_to_ir -except ImportError: # 允许直接 python engine/convert_to_cdsl.py - from translator import normalize_to_ir + from engine.cdsl_engine.translator import normalize_to_ir +except ModuleNotFoundError: # Engine root added to sys.path by product runtime. + from cdsl_engine.translator import normalize_to_ir # ═══════════════════════════════════════════════════════════════ @@ -882,8 +882,16 @@ def convert_sw_json_to_cdsl( }, } + # The import classifier may use historical shape labels while recognizing + # a SolidWorks sketch. Lower them before exposing the result as CDSL so + # the runtime contract remains line/arc/circle/contour based. + try: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + except ImportError: # pragma: no cover - direct converter invocation + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + cdsl = _clean_cdsl_floats(cdsl) - return cdsl + return lower_legacy_profiles(cdsl) def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None: diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py index 3afc935d..39c490f4 100644 --- a/backend/engine/cdsl_engine/__init__.py +++ b/backend/engine/cdsl_engine/__init__.py @@ -6,22 +6,19 @@ The package exposes the CDSL-only rebuild API used by the backend Agent. from __future__ import annotations -from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild from .semantic_validation import validate_semantic_cdsl -from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl -from .design_intent import DesignIntentError, design_intent_from_cdsl, validate_design_intent, validate_intent_cdsl # The package-level runtime contract is the session executor registry. The # older llm_engine dispatcher remains available only for legacy engine packs. SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS __all__ = [ - "convert_sw_json_to_cdsl", - "write_cdsl_outputs", "compile_cdsl", "compile_cdsl_to_pack", "run_engine_plan", @@ -31,6 +28,7 @@ __all__ = [ "compare_with_gold", "resolve_all_sketches", "resolve_required_sketches", + "CORE_SHAPE_GENERATORS", "SHAPE_GENERATORS", "SUPPORTED_ATOMIC_IDS", "validate_semantic_cdsl", @@ -39,10 +37,6 @@ __all__ = [ "rebuild_cdsl", "analyze_cdsl", "RuntimeExecutionError", - "DesignIntentError", - "validate_design_intent", - "validate_intent_cdsl", - "design_intent_from_cdsl", ] __version__ = "1.0.0" diff --git a/backend/engine/cdsl_engine/batch_rebuild.py b/backend/engine/cdsl_engine/batch_rebuild.py index 7260a356..6b2ea707 100644 --- a/backend/engine/cdsl_engine/batch_rebuild.py +++ b/backend/engine/cdsl_engine/batch_rebuild.py @@ -13,6 +13,7 @@ from collections import Counter from pathlib import Path from typing import Any, Iterable +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl @@ -225,7 +226,7 @@ def analyze_document( "failure_category": "preflight_blocked", } try: - cdsl = json.loads(path.read_text(encoding="utf-8")) + cdsl = lower_legacy_profiles(json.loads(path.read_text(encoding="utf-8"))) report["part_id"] = str(cdsl.get("part_id") or report["part_id"]) semantic = validate_semantic_cdsl(cdsl) report["semantic_valid"] = True diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 0e81abcd..6537e2df 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -201,13 +201,23 @@ class Build123dGeometryAdapter: def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid: return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) + @staticmethod + def _body_shape(value: Any) -> Any: + """Normalize boolean results, including disconnected ShapeList values.""" + if hasattr(value, "bounding_box"): + return value + shapes = list(value) + if not shapes: + raise ValueError("Boolean operation produced no shapes") + return shapes[0] if len(shapes) == 1 else Compound(shapes) + @staticmethod def fuse(body: Any | None, solid: Solid) -> Any: - return solid if body is None else body.fuse(solid) + return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid)) @staticmethod def cut(body: Any, tool: Any) -> Any: - return body.cut(tool) + return Build123dGeometryAdapter._body_shape(body.cut(tool)) @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index a8e05d61..ece7b8be 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -258,24 +258,18 @@ "analyticSegment": { "type": "object", "properties": { - "type": {"enum": ["line", "arc", "circle", "bspline"]}, + "type": {"enum": ["line", "arc", "circle"]}, "start": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, - "clockwise": {"type": "boolean"}, - "degree": {"type": "integer", "minimum": 1}, - "control_points": {"type": "array", "items": {"$ref": "#/$defs/point2"}}, - "knots": {"type": "array", "items": {"$ref": "#/$defs/number"}}, - "weights": {"type": "array", "items": {"$ref": "#/$defs/positive"}}, - "periodic": {"type": "boolean"} + "clockwise": {"type": "boolean"} }, "required": ["type"], "allOf": [ {"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}}, {"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["degree", "control_points", "knots"]}} + {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}} ], "additionalProperties": false }, @@ -334,87 +328,11 @@ {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} ] }, - "rectangleBounds": { - "type": "object", - "properties": {"center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, - "allOf": [ - {"oneOf": [ - {"required": ["center", "width_mm", "height_mm"], "not": {"anyOf": [{"required": ["min_mm"]}, {"required": ["max_mm"]}]}}, - {"required": ["min_mm", "max_mm"], "not": {"anyOf": [{"required": ["center"]}, {"required": ["width_mm"]}, {"required": ["height_mm"]}]}} - ]} - ] - }, - "rectangleBoundary": { - "type": "object", - "properties": {"type": {"enum": ["rectangle", "rectangle_with_fillets"]}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}}, - "allOf": [{"$ref": "#/$defs/rectangleBounds"}], - "additionalProperties": false - }, - "profile_type": {"enum": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "radial_slot", "arc_chain", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours"]}, - "motif_type": {"enum": ["circle", "square", "rectangle", "obround", "cross", "d_shape_polygon", "regular_hexagon", "skew_hexagon", "triangle", "teardrop_polygon", "trapezoid", "annular_sector_polygon"]}, - "layout_type": {"enum": ["ring", "angular", "concentric_rings", "disc_grid", "open_arc", "spiral", "cross_lines", "x_field", "twin_strips", "corner_clusters", "diamond_field"]}, - "motif": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "square"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross"}, "size_mm": {"$ref": "#/$defs/positive"}, "arm_width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "size_mm", "arm_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape_polygon"}, "stem_length_mm": {"$ref": "#/$defs/positive"}, "nose_depth_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "stem_length_mm", "nose_depth_mm", "half_height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "regular_hexagon"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "skew_hexagon"}, "nominal_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "nominal_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "triangle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "teardrop_polygon"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "shoulder_fraction": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1}, "left_width_mm": {"$ref": "#/$defs/positive"}, "right_width_mm": {"$ref": "#/$defs/positive"}, "tip_height_mm": {"$ref": "#/$defs/positive"}, "bottom_depth_mm": {"$ref": "#/$defs/positive"}, "shoulder_height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type"], "oneOf": [{"required": ["width_mm", "height_mm"], "not": {"anyOf": [{"required": ["left_width_mm"]}, {"required": ["right_width_mm"]}, {"required": ["tip_height_mm"]}, {"required": ["bottom_depth_mm"]}, {"required": ["shoulder_height_mm"]}]}}, {"required": ["left_width_mm", "right_width_mm", "tip_height_mm", "bottom_depth_mm", "shoulder_height_mm"], "not": {"anyOf": [{"required": ["width_mm"]}, {"required": ["height_mm"]}, {"required": ["shoulder_fraction"]}]}}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "trapezoid"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "bottom_width_mm", "top_width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annular_sector_polygon"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "half_angle_deg"], "additionalProperties": false} - ] - }, - "layoutCommon": { - "type": "object", - "properties": {"orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}} - }, - "layout": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "ring"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "angular"}, "radius_mm": {"$ref": "#/$defs/number"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "concentric_rings"}, "rings": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "count"], "additionalProperties": false}}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "rings"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "disc_grid"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "center_mm": {"$ref": "#/$defs/point2"}, "max_center_radius_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "open_arc"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "spiral"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_radius_mm": {"$ref": "#/$defs/positive"}, "radius_step_mm": {"$ref": "#/$defs/number"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross_lines"}, "count_per_axis": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_per_axis", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "x_field"}, "levels": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "levels", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "twin_strips"}, "x_offset_mm": {"$ref": "#/$defs/positive"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "y_start_mm": {"$ref": "#/$defs/number"}, "y_end_mm": {"$ref": "#/$defs/number"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "corner_clusters"}, "levels_mm": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/positive"}}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "levels_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "diamond_field"}, "manhattan_radius": {"type": "integer", "minimum": 0}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "manhattan_radius", "spacing_mm"], "additionalProperties": false} - ] - }, - "pattern": {"type": "object", "properties": {"motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["motif", "layout"], "additionalProperties": false}, + "profile_type": {"enum": ["circle", "polygon", "analytic_contours"]}, "profile": { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annulus"}, "center": {"$ref": "#/$defs/point2"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circles"}, "items": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "items"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_grid"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "origin_mm": {"$ref": "#/$defs/point2"}, "center_mm": {"$ref": "#/$defs/point2"}}, "required": ["type", "radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false, "oneOf": [{"required": ["origin_mm"], "not": {"required": ["center_mm"]}}, {"required": ["center_mm"], "not": {"required": ["origin_mm"]}}]}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_circles"}, "boundary": {"$ref": "#/$defs/rectangleBoundary"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "boundary"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_fillets"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "center": {"$ref": "#/$defs/point2"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "ibone"}, "body_width_mm": {"$ref": "#/$defs/positive"}, "body_height_mm": {"$ref": "#/$defs/positive"}, "flange_width_mm": {"$ref": "#/$defs/positive"}, "flange_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "hole_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_symmetric_notches"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "notch": {"type": "object", "properties": {"y_start": {"$ref": "#/$defs/number"}, "y_end": {"$ref": "#/$defs/number"}, "depth_mm": {"$ref": "#/$defs/positive"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["y_start", "y_end", "depth_mm"], "additionalProperties": false}}, "required": ["type", "width_mm", "height_mm", "notch"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "step_inset_mm": {"$ref": "#/$defs/number"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "bottom_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer_slanted"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "wall_height_mm": {"$ref": "#/$defs/positive"}, "wall_width_mm": {"$ref": "#/$defs/positive"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_arc_notches"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "notch_radius_mm": {"$ref": "#/$defs/positive"}, "notch_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm", "notch_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circular_sector_slot"}, "arc_radius_mm": {"$ref": "#/$defs/positive"}, "slot_half_width_mm": {"$ref": "#/$defs/positive"}, "chord_half_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_radial_tabs"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "tab_u_half_mm": {"$ref": "#/$defs/positive"}, "tab_v_offset_mm": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "filleted_rect_side_slots"}, "half_width_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "slot_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "half_width_mm", "half_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape"}, "radius_mm": {"$ref": "#/$defs/positive"}, "chord_sign": {"enum": ["left", "right"]}, "chord_x_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring_with_arc_island"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "island_radius_mm": {"$ref": "#/$defs/positive"}, "island_gap_mm": {"$ref": "#/$defs/positive"}, "center_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "replicas": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"center_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["center_angle_deg"], "additionalProperties": false}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "island_radius_mm"], "additionalProperties": false, "oneOf": [{"required": ["center_angles_deg"], "not": {"required": ["replicas"]}}, {"required": ["replicas"], "not": {"required": ["center_angles_deg"]}}]}, - {"type": "object", "properties": {"type": {"const": "radial_slot"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "arc_chain"}, "arcs": {"type": "array", "minItems": 2, "items": {"type": "object", "properties": {"center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arcs"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "patterned_cutouts"}, "motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["type", "motif", "layout"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "compound_patterned_cutouts"}, "patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/pattern"}}}, "required": ["type", "patterns"], "additionalProperties": false}, {"$ref": "#/$defs/analyticProfile"} ] }, diff --git a/backend/engine/cdsl_engine/design_intent.py b/backend/engine/cdsl_engine/design_intent.py deleted file mode 100644 index 76c18b3f..00000000 --- a/backend/engine/cdsl_engine/design_intent.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Validation for the semantic planning artifact between a request and CDSL.""" - -from __future__ import annotations - -import json -from copy import deepcopy -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator -from jsonschema.exceptions import SchemaError - - -class DesignIntentError(ValueError): - """A plan-contract violation with a stable error code for the Agent.""" - - def __init__(self, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - -def _schema_path(engine: Any) -> Path: - return Path(str(engine.__file__)).with_name("design_intent_schema.json") - - -def load_design_intent_schema(engine: Any) -> dict[str, Any]: - path = _schema_path(engine) - try: - schema = json.loads(path.read_text(encoding="utf-8")) - Draft202012Validator.check_schema(schema) - except (OSError, json.JSONDecodeError, SchemaError) as error: - raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error - return schema - - -def _location(error: Any) -> str: - return "$" + "".join( - f"[{item}]" if isinstance(item, int) else f".{item}" - for item in error.absolute_path - ) - - -def _schema_validate(intent: dict[str, Any], engine: Any) -> None: - errors = sorted( - Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent), - key=lambda error: (list(error.absolute_path), error.message), - ) - if errors: - error = errors[0] - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"DesignIntent schema violation at {_location(error)}: {error.message}", - ) - - -def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]: - try: - profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error - atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) - profiles = { - name - for name, contract in (profile_schema.get("profiles") or {}).items() - if isinstance(contract, dict) and contract.get("agent_allowed") is True - } - atomic_contracts = { - str(atomic_id): contract - for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items() - if isinstance(contract, dict) - } - return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts - - -def _blocking(intent: dict[str, Any]) -> bool: - questions = intent.get("open_questions") or [] - gaps = intent.get("capability_gaps") or [] - return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps]) - - -def validate_design_intent( - intent: dict[str, Any], - engine: Any, - *, - current_revision_id: str = "", -) -> dict[str, Any]: - """Validate DesignIntent syntax and semantic ordering against runtime capability.""" - if not isinstance(intent, dict): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object") - _schema_validate(intent, engine) - - normalized = deepcopy(intent) - mode = str(normalized["mode"]) - base_revision_id = str(normalized["base_revision_id"] or "") - if mode == "create" and base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id") - if mode == "revise": - if not base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id") - if current_revision_id and base_revision_id != current_revision_id: - raise DesignIntentError( - "DESIGN_INTENT_BASE_REVISION_MISMATCH", - f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision", - ) - - structures = normalized["structures"] - ids = [str(item["id"]) for item in structures] - feature_ids = [str(item["cdsl_feature_id"]) for item in structures] - if len(set(ids)) != len(ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique") - if len(set(feature_ids)) != len(feature_ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique") - - feature_order = [str(item) for item in normalized["feature_order"]] - if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "feature_order must list every DesignIntent structure exactly once", - ) - order = {structure_id: index for index, structure_id in enumerate(feature_order)} - atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine) - for structure in structures: - structure_id = str(structure["id"]) - for dependency in structure["depends_on"]: - dependency_id = str(dependency) - if dependency_id not in order: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} depends on an unknown structure {dependency_id}", - ) - if order[dependency_id] >= order[structure_id]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} must depend only on earlier feature_order entries", - ) - strategy = structure["cdsl_strategy"] - atomic_id = str(strategy["atomic_id"]) - if atomic_id not in atomic_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported atomic_id {atomic_id}", - ) - profile_type = str(strategy.get("profile_type") or "") - if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses {atomic_id} and requires a profile_type", - ) - if profile_type and profile_type not in profile_types: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}", - ) - - if normalized["status"] == "ready" and _blocking(normalized): - raise DesignIntentError( - "DESIGN_INTENT_BLOCKED", - "A DesignIntent with blocking questions or capability gaps must need clarification", - ) - if normalized["status"] == "needs_clarification" and not _blocking(normalized): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "needs_clarification requires a blocking question or capability gap", - ) - if normalized["status"] == "ready" and not structures: - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure") - if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures): - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure") - nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]] - if nonblocking_gaps and not normalized["assumptions"]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "Non-blocking capability gaps require an explicit approximation assumption", - ) - for gap in normalized["capability_gaps"]: - if str(gap["structure_id"]) not in set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Capability gap references an unknown structure {gap['structure_id']}", - ) - valid_expectation_ids = set(ids) | set(feature_ids) - for expectation in normalized["verification_expectations"]: - if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}", - ) - return normalized - - -def _has_selector_evidence(value: Any, role: str) -> bool: - if isinstance(value, dict): - for key, child in value.items(): - if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child: - return True - if key == role and child not in (None, "", [], {}): - if not (isinstance(child, dict) and set(child) == {"unresolved"}): - return True - if _has_selector_evidence(child, role): - return True - elif isinstance(value, list): - return any(_has_selector_evidence(item, role) for item in value) - return False - - -def validate_intent_cdsl( - intent: dict[str, Any], - cdsl: dict[str, Any], - engine: Any, - *, - current_cdsl: dict[str, Any] | None = None, -) -> None: - """Ensure an executable CDSL document faithfully realizes one accepted plan.""" - normalized = validate_design_intent(intent, engine) - if normalized["status"] != "ready": - raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready") - if not isinstance(cdsl, dict): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object") - features = cdsl.get("features") - sketches = (cdsl.get("geometry") or {}).get("sketches") - if not isinstance(features, list) or not isinstance(sketches, list): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches") - feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)} - if len(feature_by_id) != len(features): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids") - structure_by_id = {str(item["id"]): item for item in normalized["structures"]} - expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]] - if normalized["mode"] == "revise" and isinstance(current_cdsl, dict): - base_feature_ids = { - str(feature.get("id") or "") - for feature in current_cdsl.get("features") or [] - if isinstance(feature, dict) and feature.get("id") - } - if not base_feature_ids.issubset(set(expected_feature_ids)): - missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids))) - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"revise DesignIntent must explicitly preserve current features: {missing}", - ) - actual_feature_ids = [str(feature.get("id") or "") for feature in features] - if actual_feature_ids != expected_feature_ids: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - "CDSL feature ids and order must exactly match DesignIntent feature_order", - ) - sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)} - feature_for_structure = { - str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])] - for structure in normalized["structures"] - } - for structure in normalized["structures"]: - structure_id = str(structure["id"]) - feature = feature_for_structure[structure_id] - strategy = structure["cdsl_strategy"] - if feature.get("atomic_id") != strategy["atomic_id"]: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}", - ) - expected_dependencies = { - str(structure_by_id[dependency]["cdsl_feature_id"]) - for dependency in structure["depends_on"] - } - actual_dependencies = {str(item) for item in feature.get("depends_on") or []} - if not expected_dependencies.issubset(actual_dependencies): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing planned dependencies", - ) - profile_type = str(strategy.get("profile_type") or "") - if profile_type: - sketch_id = str(feature.get("sketch_id") or "") - sketch = sketch_by_id.get(sketch_id) - actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "") - if actual_profile_type != profile_type: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} must use profile_type {profile_type}", - ) - for selector_role in strategy["selector_roles"]: - if not _has_selector_evidence(feature, str(selector_role)): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}", - ) - - -def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]: - """Create a conservative audit-only plan for legacy revisions when needed.""" - sketches = (cdsl.get("geometry") or {}).get("sketches") or [] - profile_by_sketch_id = { - str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "") - for sketch in sketches - if isinstance(sketch, dict) - } - structures: list[dict[str, Any]] = [] - for index, feature in enumerate(cdsl.get("features") or []): - if not isinstance(feature, dict): - continue - feature_id = str(feature.get("id") or f"feature_{index + 1}") - atomic_id = str(feature.get("atomic_id") or "") - profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "") - strategy: dict[str, Any] = { - "atomic_id": atomic_id, - "parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()), - "selector_roles": [], - } - if profile_type: - strategy["profile_type"] = profile_type - structures.append({ - "id": feature_id, - "cdsl_feature_id": feature_id, - "role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive", - "purpose": str(feature.get("name") or feature_id), - "depends_on": [str(item) for item in feature.get("depends_on") or []], - "cdsl_strategy": strategy, - }) - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": mode, - "request": request, - "base_revision_id": base_revision_id, - "structures": structures, - "feature_order": [item["id"] for item in structures], - "assumptions": ["Synthesized from a legacy CDSL revision."], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [], - "status": "ready", - } diff --git a/backend/engine/cdsl_engine/design_intent_schema.json b/backend/engine/cdsl_engine/design_intent_schema.json deleted file mode 100644 index cc1ab344..00000000 --- a/backend/engine/cdsl_engine/design_intent_schema.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdsl.local/schema/cad.cdsl.design-intent.v1", - "title": "CDSL design intent", - "type": "object", - "properties": { - "schema": {"const": "cad.cdsl.design-intent.v1"}, - "schema_version": {"const": "1.0"}, - "mode": {"enum": ["create", "revise"]}, - "request": {"type": "string", "minLength": 1}, - "base_revision_id": {"type": "string"}, - "structures": {"type": "array", "items": {"$ref": "#/$defs/structure"}}, - "feature_order": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "open_questions": {"type": "array", "items": {"$ref": "#/$defs/openQuestion"}}, - "capability_gaps": {"type": "array", "items": {"$ref": "#/$defs/capabilityGap"}}, - "verification_expectations": {"type": "array", "items": {"$ref": "#/$defs/verificationExpectation"}}, - "status": {"enum": ["ready", "needs_clarification"]}, - "intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"}, - "created_at": {"type": "string", "minLength": 1}, - "part_skill_ids": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "part_skill_selection": {"type": "object"} - }, - "required": [ - "schema", - "schema_version", - "mode", - "request", - "base_revision_id", - "structures", - "feature_order", - "assumptions", - "open_questions", - "capability_gaps", - "verification_expectations", - "status" - ], - "additionalProperties": false, - "$defs": { - "structure": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "cdsl_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "role": {"enum": ["base", "reference", "additive", "subtractive", "dressup", "pattern"]}, - "purpose": {"type": "string", "minLength": 1}, - "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "cdsl_strategy": {"$ref": "#/$defs/cdslStrategy"} - }, - "required": ["id", "cdsl_feature_id", "role", "purpose", "depends_on", "cdsl_strategy"], - "additionalProperties": false - }, - "cdslStrategy": { - "type": "object", - "properties": { - "atomic_id": {"type": "string", "minLength": 1}, - "profile_type": {"type": "string", "minLength": 1}, - "parameter_roles": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "selector_roles": {"type": "array", "items": {"type": "string", "minLength": 1}} - }, - "required": ["atomic_id", "parameter_roles", "selector_roles"], - "additionalProperties": false - }, - "openQuestion": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "question": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["id", "question", "blocking"], - "additionalProperties": false - }, - "capabilityGap": { - "type": "object", - "properties": { - "code": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$"}, - "structure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "message": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["code", "structure_id", "message", "blocking"], - "additionalProperties": false - }, - "verificationExpectation": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": {"const": "feature_count"}, - "feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "expected": {"type": "integer", "minimum": 0} - }, - "required": ["type", "feature_id", "expected"], - "additionalProperties": false - }, - { - "type": "object", - "properties": {"type": {"const": "symmetry"}, "axis": {"enum": ["x", "y", "z"]}}, - "required": ["type", "axis"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": {"const": "bbox"}, - "expected_mm": { - "type": "object", - "properties": { - "x": {"type": "number", "exclusiveMinimum": 0}, - "y": {"type": "number", "exclusiveMinimum": 0}, - "z": {"type": "number", "exclusiveMinimum": 0} - }, - "required": ["x", "y", "z"], - "additionalProperties": false - } - }, - "required": ["type", "expected_mm"], - "additionalProperties": false - } - ] - } - } -} diff --git a/backend/engine/cdsl_engine/generation_compiler.py b/backend/engine/cdsl_engine/generation_compiler.py deleted file mode 100644 index 36bc2511..00000000 --- a/backend/engine/cdsl_engine/generation_compiler.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Deterministic GenerationSpec -> CDSL compilers. - -The LLM supplies semantic parameters and feature intent. This module owns -the CDSL details so tool calls do not need to contain sketch IDs, selectors, or -runtime-specific parameter wrappers. -""" - -from __future__ import annotations - -import copy -from dataclasses import dataclass -from typing import Any, Protocol - -from .generation_spec import GenerationSpecError, validate_generation_spec - - -def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]: - return { - "origin_mm": [0.0, 0.0, float(z)], - "x_dir": [1.0, 0.0, 0.0], - "y_dir": [0.0, 1.0, 0.0], - "normal": list(normal or [0.0, 0.0, 1.0]), - } - - -def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any: - parameter = (spec.get("parameters") or {}).get(name) - if isinstance(parameter, dict) and "value" in parameter: - return parameter["value"] - return default - - -def _number(spec: dict[str, Any], name: str, default: float) -> float: - value = _value(spec, name, default) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value") - number = float(value) - if not number == number or number in {float("inf"), float("-inf")}: - raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value") - return number - - -def _integer(spec: dict[str, Any], name: str, default: int) -> int: - value = _number(spec, name, default) - if int(value) != value: - raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value") - return int(value) - - -def _positive(number: float, name: str) -> float: - if number <= 0: - raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value") - return number - - -def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]: - existing = copy.deepcopy(spec.get("acceptance") or []) - known = {str(item.get("id")) for item in existing} - for item in generated: - if item["id"] not in known: - existing.append(item) - known.add(item["id"]) - return existing - - -@dataclass(frozen=True) -class CompiledGeneration: - cdsl: dict[str, Any] - acceptance: list[dict[str, Any]] - provenance: dict[str, Any] - assumptions: list[str] - approximations: list[dict[str, Any]] - - -class Compiler(Protocol): - family: str - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - ... - - -class MountingPlateCompiler: - family = "mounting_plate" - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - width = _positive(_number(spec, "width", 100.0), "width") - height = _positive(_number(spec, "height", 60.0), "height") - thickness = _positive(_number(spec, "thickness", 6.0), "thickness") - hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter") - hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset") - hole_count = _integer(spec, "hole_count", 4) - counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter") - counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth") - slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length") - slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width") - - if hole_count != 4: - raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value") - if counterbore_diameter <= hole_diameter: - raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value") - if counterbore_depth > thickness: - raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value") - if hole_offset * 2 >= min(width, height): - raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value") - if slot_length < slot_width: - raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value") - - centers = [ - [-width / 2 + hole_offset, -height / 2 + hole_offset], - [width / 2 - hole_offset, -height / 2 + hole_offset], - [width / 2 - hole_offset, height / 2 - hole_offset], - [-width / 2 + hole_offset, height / 2 - hole_offset], - ] - frame = _workplane(thickness) - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "generation-spec-preflight", - "meta": {"unit": "mm", "name": str(spec["part"]["name"])}, - "geometry": {"sketches": [ - {"id": "base_plate", "workplane": _workplane(), "profile": { - "type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height, - }}, - {"id": "mounting_holes", "workplane": frame, "profile": { - "type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers], - }}, - {"id": "center_slot", "workplane": frame, "profile": { - "type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width, - }}, - ]}, - "features": [ - {"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}}, - {"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}}, - {"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": { - "diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter, - "counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame}, - }}, - {"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}}, - ], - } - assumptions = [str(item) for item in spec.get("assumptions") or []] - if "hole_diameter" not in (spec.get("parameters") or {}): - assumptions.append("M4 clearance hole defaults to 4.5 mm") - if "counterbore_depth" not in (spec.get("parameters") or {}): - assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness") - acceptance = _acceptance(spec, [ - {"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"}, - {"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"}, - {"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"}, - {"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"}, - ]) - provenance = { - "compiler": self.family, - "compiler_version": "1.0", - "references": references, - "feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []}, - "parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})}, - } - return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or [])) - - -class FlangeSleeveCompiler: - family = "flange_sleeve" - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID - - aliases = { - "width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm", - "tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm", - "hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm", - "counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm", - } - plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]} - for source, target in aliases.items(): - if source in (spec.get("parameters") or {}): - plan[target] = _value(spec, source, 0) - cdsl, values = build_flange_sleeve_cdsl(plan) - acceptance = _acceptance(spec, [ - {"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"}, - ]) - provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references} - assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."])) - return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or [])) - - -COMPILERS: dict[str, Compiler] = { - "mounting_plate": MountingPlateCompiler(), - "flange_sleeve": FlangeSleeveCompiler(), -} - - -def compiler_for(family: str) -> Compiler: - try: - return COMPILERS[family] - except KeyError as error: - raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error - - -def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]: - normalized = validate_generation_spec(spec) - compiler = compiler_for(str(normalized["part"]["family"])) - compiled = compiler.compile(normalized, references or []) - return { - "spec": normalized, - "cdsl": compiled.cdsl, - "acceptance": compiled.acceptance, - "provenance": compiled.provenance, - "assumptions": compiled.assumptions, - "approximations": compiled.approximations, - } diff --git a/backend/engine/cdsl_engine/generation_spec.py b/backend/engine/cdsl_engine/generation_spec.py deleted file mode 100644 index 870587d4..00000000 --- a/backend/engine/cdsl_engine/generation_spec.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Validation and normalization for the semantic GenerationSpec contract.""" - -from __future__ import annotations - -import copy -import json -from functools import lru_cache -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator - - -KNOWN_PART_FAMILIES = frozenset({ - "mounting_plate", - "flange", - "flange_sleeve", - "simple_shaft", - "bearing_housing", - "mounting_bracket", - "hex_nut", - "slotted_plate", -}) - -KNOWN_FEATURE_KINDS = frozenset({ - "base_extrusion", - "base_revolve", - "boss", - "through_hole", - "blind_hole", - "counterbored_hole", - "countersunk_hole", - "hole_pattern", - "counterbored_hole_pattern", - "obround_cut", - "pocket", - "revolve_profile", - "coaxial_bore", - "fillet", - "chamfer", -}) - - -class GenerationSpecError(ValueError): - """A stable, user-repairable GenerationSpec validation error.""" - - def __init__(self, message: str, path: str = "$") -> None: - super().__init__(message) - self.path = path - - -@lru_cache(maxsize=1) -def generation_spec_schema() -> dict[str, Any]: - path = Path(__file__).with_name("generation_spec_schema.json") - schema = json.loads(path.read_text(encoding="utf-8")) - Draft202012Validator.check_schema(schema) - return schema - - -def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None: - errors = sorted( - Draft202012Validator(generation_spec_schema()).iter_errors(spec), - key=lambda error: (list(error.absolute_path), error.message), - ) - if not errors: - return None - error = errors[0] - location = "$" + "".join( - f"[{item}]" if isinstance(item, int) else f".{item}" - for item in error.absolute_path - ) - return GenerationSpecError(error.message, location) - - -def _parameter_value(spec: dict[str, Any], name: str) -> Any: - value = (spec.get("parameters") or {}).get(name) - if not isinstance(value, dict): - raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}") - return value.get("value") - - -def _numeric(value: Any, path: str) -> float: - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise GenerationSpecError("Expected a finite numeric value", path) - number = float(value) - if number != number or number in {float("inf"), float("-inf")}: - raise GenerationSpecError("Expected a finite numeric value", path) - return number - - -def _check_constraints(spec: dict[str, Any]) -> None: - for index, constraint in enumerate(spec.get("constraints") or []): - path = f"$.constraints[{index}]" - kind = constraint.get("type") - if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}: - left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left") - right_name = constraint.get("right") - right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value") - ok = { - "less_than": left < right, - "less_equal": left <= right, - "greater_than": left > right, - "greater_equal": left >= right, - "equal": abs(left - right) <= 1e-9, - }[kind] - if not ok: - raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path) - - -def _check_graph(spec: dict[str, Any]) -> None: - features = spec.get("features") or [] - ids = [str(item.get("id")) for item in features] - if len(ids) != len(set(ids)): - raise GenerationSpecError("Feature ids must be unique", "$.features") - known: set[str] = set() - for index, feature in enumerate(features): - feature_id = str(feature.get("id")) - kind = str(feature.get("kind")) - if kind not in KNOWN_FEATURE_KINDS: - raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind") - for dependency in feature.get("depends_on") or []: - if dependency not in known: - raise GenerationSpecError( - f"Feature {feature_id} has a forward or missing dependency: {dependency}", - f"$.features[{index}].depends_on", - ) - known.add(feature_id) - - acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []} - if len(acceptance_ids) != len(spec.get("acceptance") or []): - raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance") - for index, item in enumerate(spec.get("acceptance") or []): - feature = item.get("feature") - if feature and feature not in known: - raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature") - - -def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]: - if not isinstance(spec, dict): - raise GenerationSpecError("GenerationSpec must be a JSON object") - normalized = copy.deepcopy(spec) - normalized.setdefault("schema", "cad.generation-spec.v1") - normalized.setdefault("schema_version", "1.0") - normalized.setdefault("mode", "create") - normalized.setdefault("base_revision_id", "") - normalized.setdefault("parameters", {}) - normalized.setdefault("features", []) - normalized.setdefault("constraints", []) - normalized.setdefault("acceptance", []) - normalized.setdefault("assumptions", []) - normalized.setdefault("approximations", []) - normalized.setdefault("references", []) - normalized.setdefault("patch_intent", request) - return normalized - - -def validate_generation_spec( - spec: dict[str, Any], - *, - known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES, -) -> dict[str, Any]: - normalized = normalize_generation_spec(spec) - error = _schema_error(normalized) - if error: - raise error - family = str(normalized["part"]["family"]) - if family not in known_families: - raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family") - for name, parameter in normalized["parameters"].items(): - source = parameter["source"] - if source == "user" and not parameter["locked"]: - raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked") - if source == "image_estimate" and not parameter.get("assumption"): - raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption") - _check_graph(normalized) - _check_constraints(normalized) - return normalized diff --git a/backend/engine/cdsl_engine/generation_spec_schema.json b/backend/engine/cdsl_engine/generation_spec_schema.json deleted file mode 100644 index d50fd219..00000000 --- a/backend/engine/cdsl_engine/generation_spec_schema.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdsl.local/schema/cad.generation-spec.v1", - "title": "CDSL GenerationSpec", - "type": "object", - "additionalProperties": false, - "properties": { - "schema": {"const": "cad.generation-spec.v1"}, - "schema_version": {"const": "1.0"}, - "mode": {"enum": ["create", "revise"]}, - "base_revision_id": {"type": "string"}, - "patch_intent": {"type": "string"}, - "part": {"$ref": "#/$defs/part"}, - "parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}}, - "features": {"type": "array", "items": {"$ref": "#/$defs/feature"}}, - "constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}}, - "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, - "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}}, - "references": {"type": "array", "items": {"type": "object"}} - }, - "required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"], - "$defs": { - "part": { - "type": "object", - "additionalProperties": false, - "properties": { - "family": {"type": "string", "minLength": 1}, - "name": {"type": "string", "minLength": 1}, - "units": {"const": "mm"}, - "coordinate_system": {"type": "string", "minLength": 1} - }, - "required": ["family", "name", "units", "coordinate_system"] - }, - "parameter": { - "type": "object", - "additionalProperties": false, - "properties": { - "value": {}, - "unit": {"enum": ["mm", "deg", "rad", "count", ""]}, - "source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - "locked": {"type": "boolean"}, - "assumption": {"type": "string"} - }, - "required": ["value", "unit", "source", "confidence", "locked", "assumption"] - }, - "feature": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "kind": {"type": "string", "minLength": 1}, - "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "parameters": {"type": "object"}, - "semantic_role": {"type": "string"} - }, - "required": ["id", "kind", "depends_on", "parameters"] - }, - "constraint": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]}, - "left": {"type": "string"}, - "right": {"type": "string"}, - "value": {}, - "feature": {"type": "string"}, - "margin": {"type": "number"}, - "ratio": {"type": "number"}, - "message": {"type": "string"} - }, - "required": ["type"] - }, - "acceptance": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]}, - "feature": {"type": "string"}, - "expected": {}, - "tolerance": {"type": "number", "minimum": 0}, - "severity": {"enum": ["blocking", "warning", "informational"]} - }, - "required": ["id", "type", "expected", "tolerance", "severity"] - }, - "approximation": { - "type": "object", - "additionalProperties": false, - "properties": { - "request": {"type": "string", "minLength": 1}, - "status": {"enum": ["approximated", "expanded", "omitted", "blocked"]}, - "translation": {"type": "string", "minLength": 1}, - "reason": {"type": "string", "minLength": 1} - }, - "required": ["request", "status", "translation", "reason"] - } - } -} diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py index a2118c32..fd228762 100644 --- a/backend/engine/cdsl_engine/phase_pools.py +++ b/backend/engine/cdsl_engine/phase_pools.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any from .capabilities import sketch_ids_required_by_contract +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl from .sketch_solver import resolve_required_sketches @@ -21,7 +22,7 @@ P3_ATOMIC_IDS = frozenset({ }) P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"}) P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"}) -P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"}) +P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"}) # Static pool membership asks whether the exported history is an extrude/ # revolve history. Whether a first cut has a preceding active body remains a # runtime preflight question, not a reason to erase it from the input pool. @@ -66,6 +67,9 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool: allowed = _PHASE_ATOMIC_IDS.get(phase) if allowed is None: raise ValueError(f"Unknown CDSL runtime phase {phase!r}") + # Corpus pools retain historical profiles on disk but evaluate them after + # the importer compatibility lowering used by the batch path. + cdsl = lower_legacy_profiles(cdsl) semantic = validate_semantic_cdsl(cdsl) if semantic["unresolved"]: return False diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 14ba81d4..74a90623 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -2,10 +2,10 @@ "schema": "cdsl.engine.schema.v1", "schema_version": "1.3.0", "cdsl_json_schema_file": "cdsl_schema.json", - "maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.", + "maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"], - "runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"], + "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], "feature_atomic_ids": { "extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true}, "extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true}, @@ -26,202 +26,18 @@ }, "profiles": { "circle": { - "agent_allowed": true, "summary": "A single circular closed profile.", "required": ["radius_mm"], "optional": ["center"], "constraints": ["radius_mm > 0", "center defaults to [0, 0]"] }, - "annulus": { - "agent_allowed": true, - "summary": "A concentric ring.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["center"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "circles": { - "agent_allowed": true, - "summary": "A non-empty list of circles.", - "required": ["items"], - "item_schema": {"required": ["radius_mm"], "optional": ["center"]} - }, - "circle_grid": { - "agent_allowed": true, - "summary": "A rectangular grid of equal circles.", - "required": ["radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "optional": ["origin_mm", "center_mm"], - "constraints": ["radius_mm > 0", "count_x and count_y are integers >= 1", "use origin_mm or center_mm, not both"] - }, - "rectangle": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "constraints": ["width_mm > 0 and height_mm > 0 when using center"] - }, - "rectangle_with_circles": { - "agent_allowed": true, - "summary": "A rectangle or filleted rectangle with optional internal circles.", - "required": ["boundary"], - "optional": ["circles"], - "nested": {"boundary": "rectangle schema plus optional type=rectangle|rectangle_with_fillets and fillet_radius_mm"} - }, - "rectangle_with_fillets": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle with corner fillets and optional internal circles.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "optional": ["fillet_radius_mm", "circles"], - "constraints": ["fillet_radius_mm defaults to 0"] - }, - "obround": { - "agent_allowed": true, - "summary": "A horizontal capsule/slot.", - "required": ["length_mm", "width_mm"], - "optional": ["center"], - "constraints": ["length_mm > 0", "width_mm > 0", "center defaults to [0, 0]"] - }, "polygon": { - "agent_allowed": true, "summary": "A closed straight-edge polygon.", "required": ["vertices"], "constraints": ["vertices contains at least three [u, v] points"] }, - "ibone": { - "agent_allowed": true, - "summary": "I-shaped lug with optional four holes.", - "required": ["body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], - "optional": ["hole_radius_mm"] - }, - "rectangle_with_symmetric_notches": { - "agent_allowed": true, - "summary": "A rectangular plate with four symmetric side notches.", - "required": ["width_mm", "height_mm", "notch"], - "nested": {"notch": {"required": ["y_start", "y_end", "depth_mm"], "optional": ["inner_radius_mm", "corner_radius_mm"]}} - }, - "revolve_chamfer": { - "agent_allowed": true, - "summary": "Five-edge trapezoid profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "bottom_width_mm"], - "optional": ["wall_inset_mm", "step_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "revolve_chamfer_slanted": { - "agent_allowed": true, - "summary": "Five-edge slanted profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], - "optional": ["wall_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "circle_with_arc_notches": { - "agent_allowed": true, - "summary": "A circular boundary with arc-shaped notches.", - "required": ["outer_radius_mm", "notch_radius_mm"], - "optional": ["notch_angles_deg", "circles"], - "constraints": ["notch_angles_deg defaults to [0, 90, 180, 270]"] - }, - "circular_sector_slot": { - "agent_allowed": true, - "summary": "A circular-sector boundary with a central rectangular slot.", - "required": ["arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], - "optional": ["circles"] - }, - "circle_with_radial_tabs": { - "agent_allowed": true, - "summary": "A circle with two mirrored radial tabs.", - "required": ["outer_radius_mm"], - "optional": ["tab_u_half_mm", "tab_v_offset_mm", "circles"] - }, - "filleted_rect_side_slots": { - "agent_allowed": true, - "summary": "A filleted rectangle with one semicircular slot at each side centre.", - "required": ["half_width_mm", "half_height_mm", "corner_radius_mm"], - "optional": ["slot_radius_mm", "circles"] - }, - "d_shape": { - "agent_allowed": true, - "summary": "A D-shaped boundary made from one chord and one arc.", - "required": ["radius_mm"], - "optional": ["chord_sign", "chord_x_mm", "circles"], - "constraints": ["chord_sign is left or right"] - }, - "partial_ring": { - "agent_allowed": true, - "summary": "An annular sector.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["half_angle_deg", "circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "partial_ring_with_arc_island": { - "agent_allowed": true, - "summary": "One or more annular sectors with an arc island.", - "required": ["inner_radius_mm", "outer_radius_mm", "island_radius_mm"], - "optional": ["half_angle_deg", "island_gap_mm", "center_angles_deg", "replicas"], - "nested": {"replicas": {"required": ["center_angle_deg"]}} - }, - "arc_chain": { - "agent_allowed": true, - "summary": "A closed chain of two or more arcs, commonly used for revolve sections.", - "required": ["arcs"], - "optional": ["circles"], - "item_schema": {"required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "optional": ["center"]}, - "constraints": ["arcs contains at least two endpoint-connected arcs"] - }, - "radial_slot": { - "agent_allowed": true, - "summary": "A rounded annular sector slot.", - "required": ["inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], - "optional": ["circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "patterned_cutouts": { - "agent_allowed": true, - "summary": "One procedural cutout motif repeated by one procedural layout.", - "required": ["motif", "layout"], - "nested": {"motif": "See motif_types", "layout": "See layout_types"} - }, - "compound_patterned_cutouts": { - "agent_allowed": true, - "summary": "Multiple procedural motif/layout pairs merged into one cutout sketch.", - "required": ["patterns"], - "nested": {"patterns": "Non-empty list of {motif, layout}; see patterned_cutouts."} - }, - "complex_arc_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." - }, "analytic_contours": { - "agent_allowed": false, - "summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic." - }, - "unknown_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." + "summary": "Closed direct line, arc and circle contours. B-splines are unsupported." } - }, - "motif_types": { - "circle": ["radius_mm"], - "square": ["width_mm"], - "rectangle": ["width_mm", "height_mm"], - "obround": ["length_mm", "width_mm"], - "cross": ["size_mm", "arm_width_mm"], - "d_shape_polygon": ["stem_length_mm", "nose_depth_mm", "half_height_mm"], - "regular_hexagon": ["radius_mm"], - "skew_hexagon": ["nominal_radius_mm"], - "triangle": ["radius_mm"], - "teardrop_polygon": ["width_mm", "height_mm"], - "trapezoid": ["bottom_width_mm", "top_width_mm", "height_mm"], - "annular_sector_polygon": ["inner_radius_mm", "outer_radius_mm", "half_angle_deg"] - }, - "layout_types": { - "ring": ["radius_mm", "count"], - "angular": ["count"], - "concentric_rings": ["rings"], - "disc_grid": ["count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "open_arc": ["radius_mm", "count", "start_angle_deg", "end_angle_deg"], - "spiral": ["count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], - "cross_lines": ["count_per_axis", "spacing_mm"], - "x_field": ["levels", "spacing_mm"], - "twin_strips": ["x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], - "corner_clusters": ["levels_mm"], - "diamond_field": ["manhattan_radius", "spacing_mm"] } } diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 57b9e7ee..7f965e73 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -15,25 +15,30 @@ from pathlib import Path from typing import Any try: - from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .translator import generate_build123d_code, normalize_to_ir except ImportError: # 允许直接 python rebuild.py - from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from llm_compiler import compile_cdsl from llm_engine import run_engine_plan from translator import generate_build123d_code, normalize_to_ir -def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, - force_exact: bool = False) -> dict[str, Any]: +def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, + force_exact: bool = False) -> dict[str, Any]: """主重建入口。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 回退:CDSL + compiler_context 的 translator 路径。 """ - sketches = cdsl.get("geometry", {}).get("sketches", []) + # Compatibility entry point only: new CDSL-only runtime calls do not use + # macro profiles and therefore never invoke this adapter. + cdsl = lower_legacy_profiles(cdsl) + sketches = cdsl.get("geometry", {}).get("sketches", []) all_drawable = bool(sketches) and all( _sketch_is_cdsl_drawable(s) for s in sketches ) diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 060d4208..f3aa4ff8 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -15,7 +15,7 @@ from .runtime_types import ( RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) -from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ @@ -758,7 +758,7 @@ def analyze_cdsl(cdsl: dict[str, Any]): resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS) + analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS) return analyzer.analyze(resolved, sketch_errors=sketch_errors) @@ -768,7 +768,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze( + analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( resolved, sketch_errors=sketch_errors, ) if strict and not analysis.runtime_eligible: diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index 4311d7dc..c9c5e4a6 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -1,37 +1,9 @@ -"""轮廓求解器:参数化草图描述 → entities + contour_edges_mm。 +"""Core CDSL sketch resolver. -LLM 只需输出离散决策(type, radius, width …), -求解器负责生成精确的实体和轮廓边坐标。 - -架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, - 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: - 1. 写 def solver_xxx(profile, meta) -> (entities, contour) - 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx - 3. 在 convert 脚本中输出对应的 profile - 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) - -支持的 profile 类型: - - circle: 单个圆 - - annulus: 同心圆环 - - circles: 多圆(引擎自动判断加/切除) - - circle_grid: 矩形圆孔阵列(行列+间距) - - rectangle: 矩形 - - rectangle_with_circles: 矩形 + 内圆孔/岛 - - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 - - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) - - obround: 槽形 / 键槽(2平行线 + 2半圆) - - polygon: N边多边形(顶点列表) - - ibone: 工字形凸耳(12线+4弧+4孔) - - circle_with_arc_notches: 圆+均匀圆弧凹口 - - circular_sector_slot: 圆弧扇区+中心矩形槽 - - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) - - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 - - d_shape: D形(半圆+弦线) - - partial_ring: 部分圆环(同心弧+径向线) - - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) - - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) - - patterned_cutouts: 母形 + 规则布局的多区域切口 - - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +The runtime accepts only direct geometric descriptions: circles, straight-edge +polygons, and closed analytic line/arc/circle contours. Semantic shapes and +historical profile macros belong to the importer compatibility layer and must +be lowered before this module is invoked. """ from __future__ import annotations @@ -41,81 +13,34 @@ from copy import deepcopy from typing import Any, Iterable -# ═══════════════════════════════════════════════════════════════ -# 基础几何原语 -# ═══════════════════════════════════════════════════════════════ - -def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: - return { - "type": "circle", - "center": [float(center[0]), float(center[1])], - "radius_mm": float(radius_mm), - "construction": construction, - } +_Ctx = dict[str, Any] +_TOLERANCE_MM = 1e-5 -def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: - return { - "type": "line", - "start": [float(start[0]), float(start[1])], - "end": [float(end[0]), float(end[1])], - "construction": construction, - } +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: + return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} -def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: - return { - "type": "line", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - } +def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: + return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} -def _contour_arc( - start_mm: list[float], - end_mm: list[float], - center_mm: list[float], - radius_mm: float | None, - clockwise: bool | None = None, -) -> dict[str, Any]: - result = { - "type": "arc", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - "center_mm": [ - float(center_mm[0]), - float(center_mm[1]), - float(center_mm[2]) if len(center_mm) > 2 else 0.0, - ], - "radius_mm": float(radius_mm) if radius_mm is not None else None, - } +def _point(point: list[float]) -> list[float]: + return [float(point[0]), float(point[1]), float(point[2]) if len(point) > 2 else 0.0] + + +def _contour_line(start: list[float], end: list[float]) -> _Ctx: + return {"type": "line", "start_mm": _point(start), "end_mm": _point(end)} + + +def _contour_arc(start: list[float], end: list[float], center: list[float], radius: float | None, clockwise: bool | None = None) -> _Ctx: + edge: _Ctx = {"type": "arc", "start_mm": _point(start), "end_mm": _point(end), "center_mm": _point(center), "radius_mm": float(radius) if radius is not None else None} if clockwise is not None: - result["clockwise"] = bool(clockwise) - return result + edge["clockwise"] = bool(clockwise) + return edge -# ═══════════════════════════════════════════════════════════════ -# 3D 坐标转换 -# ═══════════════════════════════════════════════════════════════ - -def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: - """将2D局部坐标 (u,v) 映射到3D世界坐标。""" +def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] normal = workplane.get("normal") or [0, 0, 1] @@ -124,1491 +49,81 @@ def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: normal[2] * x_dir[0] - normal[0] * x_dir[2], normal[0] * x_dir[1] - normal[1] * x_dir[0], ] - return [ - origin[0] + u * x_dir[0] + v * y_dir[0], - origin[1] + u * x_dir[1] + v * y_dir[1], - origin[2] + u * x_dir[2] + v * y_dir[2], - ] + return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] -def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: - """将轮廓边的2D坐标映射为3D世界坐标。""" - result: list[dict[str, Any]] = [] - x_dir = wp.get("x_dir") or [1, 0, 0] - normal = wp.get("normal") or [0, 0, 1] - for e in contour: - e2 = deepcopy(e) - if e["type"] == "line": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - elif e["type"] == "arc": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) - e2["normal"] = list(normal) - result.append(e2) - return result +def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: + transformed: list[_Ctx] = [] + normal = workplane.get("normal") or [0, 0, 1] + for edge in contours: + output = deepcopy(edge) + output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1]) + output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1]) + if edge["type"] == "arc": + output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) + output["normal"] = list(normal) + transformed.append(output) + return transformed -# ═══════════════════════════════════════════════════════════════ -# 矩形 / 圆辅助 -# ═══════════════════════════════════════════════════════════════ - -def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: - """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" - center = profile.get("center") - w = float(profile.get("width_mm") or 0) - h = float(profile.get("height_mm") or 0) - if center and w > 0 and h > 0: - cx, cy = float(center[0]), float(center[1]) - return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 - mn = profile.get("min_mm") - mx = profile.get("max_mm") - if mn and mx: - return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) - raise ValueError("rectangle profile needs (center+width+height) or (min+max)") - - -def _rect_lines_and_contour( - x0: float, y0: float, x1: float, y1: float, -) -> tuple[list[dict], list[dict]]: - p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] - entities = [ - _line([x0, y0], [x1, y0]), - _line([x1, y0], [x1, y1]), - _line([x1, y1], [x0, y1]), - _line([x0, y1], [x0, y0]), - ] - contour = [ - _contour_line(p00, p10), - _contour_line(p10, p11), - _contour_line(p11, p01), - _contour_line(p01, p00), - ] - return entities, contour - - -def _build_circle_entities(items: list[dict]) -> list[dict]: - entities: list[dict] = [] - for item in items: - center = item.get("center") or [0.0, 0.0] - r = float(item.get("radius_mm") or 0) - if r <= 0: - raise ValueError("circle radius must be > 0") - entities.append(_circle(center, r, construction=False)) - return entities - - -def _filleted_rect_contour( - x0: float, y0: float, x1: float, y1: float, r: float, -) -> tuple[list[dict], list[dict]]: - """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" - if r <= 0: - return _rect_lines_and_contour(x0, y0, x1, y1) - - cx0, cx1 = x0 + r, x1 - r - cy0, cy1 = y0 + r, y1 - r - - entities = [ - _line([cx0, y0], [cx1, y0]), - _line([x0, cy0], [x0, cy1]), - _line([cx0, y1], [cx1, y1]), - _line([x1, cy0], [x1, cy1]), - ] - - contour = [ - _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), - _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), - _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), - _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), - _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), - _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), - _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), - _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), - ] - - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 形状生成器(每个是一个独立函数,按 type 注册) -# ═══════════════════════════════════════════════════════════════ - -_Ctx = dict[str, Any] - - -def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: +def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: center = profile.get("center") or [0.0, 0.0] - r = float(profile.get("radius_mm") or 0) - if r <= 0: + radius = float(profile.get("radius_mm") or 0.0) + if radius <= 0: raise ValueError("circle radius must be > 0") - entities = [_circle(center, r)] - cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return entities, contour - - -def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") or [0.0, 0.0] - inner_r = float(profile.get("inner_radius_mm") or 0) - outer_r = float(profile.get("outer_radius_mm") or 0) - if inner_r <= 0 or outer_r <= 0: - raise ValueError("annulus radii must be > 0") - if inner_r >= outer_r: - raise ValueError("inner_radius >= outer_radius") - return [_circle(center, inner_r), _circle(center, outer_r)], [] - - -def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - items = profile.get("items") or [] - if not items: - raise ValueError("circles items must be non-empty") - return _build_circle_entities(items), [] - - -def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 - - 参数: - radius_mm: 孔半径 - count_x / count_y: 列数、行数 - spacing_x_mm / spacing_y_mm: 圆心间距 - origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) - 或 center_mm: 阵列几何中心(与 origin_mm 二选一) - - 覆盖: b006(4×5 通孔阵列) - """ - r = float(profile["radius_mm"]) - nx = int(profile["count_x"]) - ny = int(profile["count_y"]) - sx = float(profile["spacing_x_mm"]) - sy = float(profile["spacing_y_mm"]) - if r <= 0 or nx < 1 or ny < 1: - raise ValueError("circle_grid: invalid radius/counts") - - if profile.get("center_mm") is not None: - cc = profile["center_mm"] - u0 = float(cc[0]) - (nx - 1) * sx / 2.0 - v0 = float(cc[1]) - (ny - 1) * sy / 2.0 - else: - origin = profile.get("origin_mm") or [0.0, 0.0] - u0, v0 = float(origin[0]), float(origin[1]) - - items = [ - {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} - for j in range(ny) - for i in range(nx) - ] - return _build_circle_entities(items), [] - - -def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - x0, y0, x1, y1 = _build_rect_bounds(profile) - return _rect_lines_and_contour(x0, y0, x1, y1) - - -def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - boundary = profile.get("boundary") or {} - circle_items = profile.get("circles") or [] - btype = boundary.get("type") or "rectangle" - if btype in ("rectangle", "rectangle_with_fillets"): - if btype == "rectangle": - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _rect_lines_and_contour(x0, y0, x1, y1) - else: - fr = float(boundary.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(circle_items)) - return ent, con - raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") - - -def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - fr = float(profile.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(profile) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(profile.get("circles") or [])) - return ent, con - - -def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") - length = float(profile.get("length_mm") or 0) - width = float(profile.get("width_mm") or 0) - if length <= 0 or width <= 0: - raise ValueError("obround needs positive length/width") - r = width / 2 - cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) - offset = max(0, (length - width) / 2) - left_cx, right_cx = cx - offset, cx + offset - - if offset < 0.001: - c3d = [cx, cy, 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return [_circle([cx, cy], r)], contour - - top_y, bot_y = cy + r, cy - r - left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] - contour = [ - _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), - _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), - _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), - _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), - _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), - _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), - ] - entities = [ - _line([left_cx, bot_y], [right_cx, bot_y]), - _line([left_cx, top_y], [right_cx, top_y]), - _circle([left_cx, cy], r), - _circle([right_cx, cy], r), - ] - return entities, contour + cx, cy = float(center[0]), float(center[1]) + points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]] + return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: vertices = profile.get("vertices") or [] - if len(vertices) >= 3: - pts_2d = [(float(v[0]), float(v[1])) for v in vertices] - entities, contour = [], [] - for i in range(len(pts_2d)): - s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - # 015133: no vertices in profile -> use entities from compiler_context - ents = meta.get("_entities") or [] - if not ents: - raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") - contour = [] - for e in ents: - t = e.get("type", "") - if t == "line": - s = e.get("start", [0, 0]) - ed = e.get("end", [0, 0]) - contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) - elif t == "arc": - contour.append(_contour_line( - [float(e["start"][0]), float(e["start"][1]), 0.0], - [float(e["end"][0]), float(e["end"][1]), 0.0])) - return list(ents), contour - - -def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """工字形凸耳:12线 + 4弧 + 4孔""" - bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) - fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) - cr = float(profile["corner_radius_mm"]) - hr = float(profile.get("hole_radius_mm") or 0) - hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr - av_bot, av_top = fh - cr, bh - fh + cr - - segs = [ - ("L", hfw, 0, -hfw, 0), - ("L", -hfw, 0, -hfw, fh - cr), - ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), - ("L", -ar, fh, -hw, fh), - ("L", -hw, fh, -hw, bh - fh), - ("L", -hw, bh - fh, -ar, bh - fh), - ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), - ("L", -hfw, bh - fh + cr, -hfw, bh), - ("L", -hfw, bh, hfw, bh), - ("L", hfw, bh, hfw, bh - fh + cr), - ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), - ("L", ar, bh - fh, hw, bh - fh), - ("L", hw, bh - fh, hw, fh), - ("L", hw, fh, ar, fh), - ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), - ("L", hfw, fh - cr, hfw, 0), - ] - entities, contour = [], [] - for s in segs: - if s[0] == "L": - _, u1, v1, u2, v2 = s - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - else: - _, u1, v1, u2, v2, cu, cv, r = s - contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) - - if hr > 0: - for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: - entities.append(_circle([cu, cv], hr)) - return entities, contour - - -def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" - w, h = float(profile["width_mm"]), float(profile["height_mm"]) - n = profile.get("notch") or {} - n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) - n_depth = float(n["depth_mm"]) - n_ir = float(n.get("inner_radius_mm") or 0) - n_cr = float(n.get("corner_radius_mm") or 0) - hw = w / 2 - inner_u = hw - n_depth - - if n_ir > 0 and n_cr > 0: - icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 - entities, contour = [], [] - for u1, v1, u2, v2 in [ - (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), - (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), - (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), - (-hw, 0, hw, 0), (hw, h, -hw, h), - ]: - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - - def _notch(sign_u, y_bot, y_top): - u = sign_u * hw - ec = sign_u * (hw - n_cr) - icu2 = sign_u * icu - contour.append(_contour_arc( - [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) - av = y_bot + n_cr - au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) - contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) - bv = y_top - n_cr - bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) - contour.append(_contour_arc( - [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) - contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) - contour.append(_contour_arc( - [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) - - _notch(+1, n_ys, n_ye) - _notch(+1, h - n_ye, h - n_ys) - _notch(-1, n_ys, n_ye) - _notch(-1, h - n_ye, h - n_ys) - return entities, contour - - # 简化多边形(5 参数) - verts = [ - (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), - (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), - (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), - (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), - (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), - (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), - ] - entities, contour = [], [] - pts = [(float(v[0]), float(v[1])) for v in verts] - for i in range(len(pts)): - s, e = pts[i], pts[(i + 1) % len(pts)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - bw = float(profile["bottom_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - si = float(profile.get("step_inset_mm") or 0) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) - v3 = (sign * bw, -ah); v4 = (sign * tw, -si) - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的斜底梯形截面(5顶点)。 - - 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 - 参数: - axis_height_mm: 轴侧总高度(V1→V2) - top_width_mm: 顶部宽度(轴→外壁) - wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) - wall_height_mm: 壁段高度(V4→V3) - wall_width_mm: 壁距轴的距离 - on_axis_side: "left"(U负) 或 "right"(U正) - """ - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - wh = float(profile["wall_height_mm"]) - ww = float(profile["wall_width_mm"]) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - - v0 = (sign * tw, 0.0) # 顶部外侧 - v1 = (0.0, 0.0) # 轴顶点 - v2 = (0.0, -ah) # 轴底部 - v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) - v4 = (sign * ww, -wi) # 壁顶部(台阶) - - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 弧边复合轮廓生成器(按"015133 手册"方法注册) -# ═══════════════════════════════════════════════════════════════ - - -def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 - - 参数: - outer_radius_mm: 大圆半径 - notch_radius_mm: 每个凹口的圆弧半径 - notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) - 默认 [0, 90, 180, 270](十字槽) - 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 - - 覆盖文件: 48, 49, 50, 82 - """ - import math - R = float(profile["outer_radius_mm"]) - r = float(profile["notch_radius_mm"]) - angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) - - # 每个凹口在大圆上占据的半角宽度 - delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) - angles_rad = [math.radians(a) for a in sorted(angles_deg)] - - entities, contour = [], [] - n = len(angles_rad) - - for i in range(n): - prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 - curr_enter = angles_rad[i] - delta # 当前凹口入口 - - # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) - ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) - pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) - - contour.append(_contour_arc( - [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], - [0.0, 0.0, 0.0], R, - )) - - # 凹口弧:从入口→出口,中心在外圆上 - curr_exit = angles_rad[i] + delta - nc_u = R * math.cos(angles_rad[i]) - nc_v = R * math.sin(angles_rad[i]) - - pn_enter_u = R * math.cos(curr_enter) - pn_enter_v = R * math.sin(curr_enter) - pn_exit_u = R * math.cos(curr_exit) - pn_exit_v = R * math.sin(curr_exit) - - # 凹口弧:从出口回到入口(与大弧方向相反) - contour.append(_contour_arc( - [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], - [nc_u, nc_v, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 - - 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 - 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 - - 参数: - arc_radius_mm: 大弧半径(圆心在原点) - slot_half_width_mm: 槽口半宽(从圆心起的径向距离) - chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) - - 覆盖文件: 87, 88, 89, 90 - """ - import math - R = float(profile["arc_radius_mm"]) - hw = float(profile["slot_half_width_mm"]) - ch = float(profile["chord_half_mm"]) - - # 弧端点:在圆上,到中心轴的垂直距离为 ch - # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) - half_angle = math.asin(max(-1.0, min(1.0, ch / R))) - - # 弧端点坐标(圆上,2 个象限) - arc_x = R * math.cos(half_angle) - arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) - - # 4 个关键点(顺时针) - # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) - # 右上角弧段 - arc_x_neg_angle = R * math.cos(-half_angle) - arc_y_neg = R * math.sin(-half_angle) - - p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) - p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 - - # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) - # xs 由圆 R 和 ch 确定 - xs = math.sqrt(max(0, R * R - ch * ch)) - - contour = [ - # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) - _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 左侧线:(-xs, -ch) → (-xs, +ch) ... - # 不对,夹着槽口 - - ] - - # 重新按 87 的实际边序列构建 - # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 - # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 - # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 - # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) - # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 - # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 - # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 - # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 - - # 参数化: - # slot_half = 27.5 (槽口半宽) - # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) - # arc_radius = 40 - # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 - - sh = hw # slot half - axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint - - contour = [ - # 槽口竖线(从左下到左上) - _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), - # 连接到弧(从槽口左上到弧左下) - _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), - # 上弧(从弧左下到弧右下,经过原点顶) - _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧右下到槽口右上) - _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), - # 槽口竖线(从右上到右下) - _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), - # 连接到弧(从槽口右下到弧右上) - _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), - # 下弧(从弧右上到弧左上,经过原点底) - _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧左上到槽口左下) - _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), - ] - - entities = [ - _line([-sh, -ch], [-sh, ch]), - _line([-sh, ch], [-axe, ch]), - _line([sh, ch], [axe, ch]), - _line([sh, ch], [sh, -ch]), - _line([sh, -ch], [axe, -ch]), - _line([-sh, -ch], [-axe, -ch]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+径向凸耳:大圆弧上有矩形凸起。 - - 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 - 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 - - 参数: - outer_radius_mm: 大圆半径 - tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) - tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) - - 覆盖文件: 91, 92, 93, 94, 95 - """ - import math - R = float(profile["outer_radius_mm"]) - tu = float(profile.get("tab_u_half_mm") or 0) - tv = float(profile.get("tab_v_offset_mm") or 0) - - # 凸耳根部在圆上的角度 - angle = math.asin(min(1.0, max(0.0, tv / R))) - - # 弧上根部点(右侧) - root_u = R * math.cos(angle) - root_v = R * math.sin(angle) - - # 凸耳内缘 - inner_u = root_u - tu - inner_v = root_v * 0.9 # 略浅于弧线 - - # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 - - contour = [] - - # 上弧:从左侧根部到右侧根部(经过顶点) - contour.append(_contour_arc( - [root_u, root_v, 0.0], [-root_u, root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 右侧凸耳(多边形:根部→内顶→内底→根部) - contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) - contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) - contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) - - # 下弧:从右侧底部到左侧底部(经过底点) - contour.append(_contour_arc( - [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 左侧凸耳(镜像) - contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) - contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) - contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) - - entities = [ - _line([root_u, root_v], [inner_u, inner_v]), - _line([inner_u, inner_v], [inner_u, -inner_v]), - _line([inner_u, -inner_v], [root_u, -root_v]), - _line([-root_u, -root_v], [-inner_u, -inner_v]), - _line([-inner_u, -inner_v], [-inner_u, inner_v]), - _line([-inner_u, inner_v], [-root_u, root_v]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆角矩形+两侧中心凹槽。 - - 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 - - 参数: - half_width_mm: 矩形半宽(不含圆角) - half_height_mm: 矩形半高(不含圆角) - corner_radius_mm: 四角圆角半径 - slot_radius_mm: 两侧中心凹槽半径(默认 5.0) - circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] - - 覆盖文件: 58, 63, 64, 65, 66 - """ - hw = float(profile["half_width_mm"]) - hh = float(profile["half_height_mm"]) - cr = float(profile["corner_radius_mm"]) - sr = float(profile.get("slot_radius_mm") or cr * 0.5) - - # 注意: v=-Z, 所以 v 正方向朝 Z 负 - # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] - # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh - - entities, contour = [], [] - top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh - - # 上边(直线,从左上角到右上角) - contour.append(_contour_line( - [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) - entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) - - # 右上圆角(逆时针绕 center: 从顶点到右侧) - contour.append(_contour_arc( - [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], - [(hw - cr), top_v + cr, 0.0], cr)) - - # 右边上半(从圆角到凹槽上方) - contour.append(_contour_line( - [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) - entities.append(_line([hw, top_v + cr], [hw, -sr])) - - # 右侧中心凹槽(半圆向内的 U 形凹口) - contour.append(_contour_arc( - [hw, sr, 0.0], [hw, -sr, 0.0], - [hw, 0.0, 0.0], sr)) - - # 右边下半(从凹槽下方到右下角) - contour.append(_contour_line( - [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) - entities.append(_line([hw, sr], [hw, bot_v - cr])) - - # 右下圆角 - contour.append(_contour_arc( - [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], - [(hw - cr), bot_v - cr, 0.0], cr)) - - # 下边 - contour.append(_contour_line( - [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) - entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) - - # 左下圆角 - contour.append(_contour_arc( - [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], - [-(hw - cr), bot_v - cr, 0.0], cr)) - - # 左边下半 - contour.append(_contour_line( - [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) - entities.append(_line([-hw, bot_v - cr], [-hw, sr])) - - # 左侧中心凹槽 - contour.append(_contour_arc( - [-hw, -sr, 0.0], [-hw, sr, 0.0], - [-hw, 0.0, 0.0], sr)) - - # 左边上半 - contour.append(_contour_line( - [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) - entities.append(_line([-hw, -sr], [-hw, top_v + cr])) - - # 左上圆角 - contour.append(_contour_arc( - [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], - [-(hw - cr), top_v + cr, 0.0], cr)) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 更多弧边复合轮廓生成器 -# ═══════════════════════════════════════════════════════════════ - - -def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 - - 参数: - radius_mm: 大弧半径(圆心在原点) - chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 - 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) - - 覆盖文件: 144358 - """ - import math - R = float(profile["radius_mm"]) - side = profile.get("chord_sign", "left") - sign = 1 if side == "left" else -1 - - # chord at x=cx (cx^2 + y^2 = R^2) - # For side="left": chord at x = sqrt(R^2 - y_len^2) ... - # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) - # So the chord is at x=34, v ranges from -22.9 to 22.9 - # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ - - v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) - # 实际上,chord x 可以根据 radius 推导 - # 使用 chord_x 参数如果存在,否则用近似 - chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 - - v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) - cx = sign * chord_x # 弦线 x 坐标 - - contour = [ - # 弦线(从下到上) - _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), - # 大弧(从右上到左下,即从左到右沿弧线) - _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), - ] - - entities = [ - _line([cx, -v_half], [cx, v_half]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 - - 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) - - 覆盖文件: 177126 - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - h_rad = math.radians(h_deg) - - # 内弧端点 - iu_pos = ir * math.cos(h_rad) - iv_pos = ir * math.sin(h_rad) - iu_neg = ir * math.cos(-h_rad) - iv_neg = ir * math.sin(-h_rad) - - # 外弧端点 - ou_pos = oR * math.cos(h_rad) - ov_pos = oR * math.sin(h_rad) - ou_neg = oR * math.cos(-h_rad) - ov_neg = oR * math.sin(-h_rad) - - contour = [ - # 右侧径向线(从内弧到外弧,+h角度) - _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), - # 外弧(从 +h 到 -h) - _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), - # 左侧径向线(从外弧到内弧,-h角度) - _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), - # 内弧(从 -h 到 +h) - _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), - ] - - entities = [ - _line([iu_pos, iv_pos], [ou_pos, ov_pos]), - _line([ou_neg, ov_neg], [iu_neg, iv_neg]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 - - 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 - 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 - - 参数: - inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 - island_radius_mm: 岛外弧半径 - island_gap_mm: 岛内外弧径向间距(等宽) - center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) - - 覆盖: b005 - """ - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - island_r = float(profile["island_radius_mm"]) - gap = float(profile.get("island_gap_mm") or 1.0) - if ir <= 0 or oR <= ir or island_r <= gap: - raise ValueError("partial_ring_with_arc_island: invalid radii") - - replicas = profile.get("replicas") - if replicas: - angles = [float(r["center_angle_deg"]) for r in replicas] - else: - angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] - - h = math.radians(h_deg) - entities: list[_Ctx] = [] - regions: list[dict[str, Any]] = [] - - for ca_deg in angles: - ca = math.radians(ca_deg) - a0, a1 = ca - h, ca + h - - def polar(r: float, ang: float) -> list[float]: - return [r * math.cos(ang), r * math.sin(ang), 0.0] - - # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) - ou0, ou1 = polar(oR, a0), polar(oR, a1) - iu0, iu1 = polar(ir, a0), polar(ir, a1) - outer = [ - _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), - _contour_line(ou1, iu1), - _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), - _contour_line(iu0, ou0), - ] - entities.extend([ - _line(ou0[:2], ou1[:2]), - _line(ou1[:2], iu1[:2]), - _line(iu1[:2], iu0[:2]), - _line(iu0[:2], ou0[:2]), - ]) - - # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir - ux, uy = math.cos(ca), math.sin(ca) - mx = oR * ux * math.cos(h) - my = oR * uy * math.cos(h) - cdx, cdy = -uy, ux - span = ir * math.cos(h) - e1 = [mx + span * cdx, my + span * cdy, 0.0] - e2 = [mx - span * cdx, my - span * cdy, 0.0] - - # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 - dot = e1[0] * ux + e1[1] * uy - e2n = e1[0] * e1[0] + e1[1] * e1[1] - disc = max(0.0, dot * dot - (e2n - island_r * island_r)) - t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) - t = t1 if abs(t1) <= abs(t2) else t2 - cx, cy = t * ux, t * uy - c3 = [cx, cy, 0.0] - - def inward(pt: list[float]) -> list[float]: - vx, vy = cx - pt[0], cy - pt[1] - L = math.hypot(vx, vy) or 1.0 - return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] - - i1, i2 = inward(e1), inward(e2) - ri = island_r - gap - - # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 - # 外弧走短弧中指向外侧(远离原点)的那条 - hole = [ - _contour_arc(e1, e2, c3, island_r), - _contour_line(e2, i2), - _contour_arc(i2, i1, c3, ri), - _contour_line(i1, e1), - ] - entities.extend([ - _line(e1[:2], e2[:2]), - _line(e2[:2], i2[:2]), - _line(i2[:2], i1[:2]), - _line(i1[:2], e1[:2]), - ]) - regions.append({"outer": outer, "holes": [hole]}) - - meta["_regions"] = regions - return entities, [] - - -def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 - - 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 - - 参数: - arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] - (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) - - 覆盖文件: 020543 - """ - import math - arc_list = profile.get("arcs") or [] - - if not arc_list or len(arc_list) < 2: - raise ValueError("arc_chain needs at least 2 arcs") - - entities, contour = [], [] - - for arc_desc in arc_list: - r = float(arc_desc["radius_mm"]) - center = arc_desc.get("center") or [0.0, 0.0] - cu, cv = float(center[0]), float(center[1]) - sa = math.radians(float(arc_desc["start_angle_deg"])) - ea = math.radians(float(arc_desc["end_angle_deg"])) - - su = cu + r * math.cos(sa) - sv = cv + r * math.sin(sa) - eu = cu + r * math.cos(ea) - ev = cv + r * math.sin(ea) - - contour.append(_contour_arc( - [su, sv, 0.0], [eu, ev, 0.0], - [cu, cv, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 - - 用于 extrude_cut 在圆柱壁面上开弧形槽口。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) - end_angle_deg: 槽终止角度 - - 覆盖文件: 020543 (sk_02, sk_03, sk_04) - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - sa_deg = float(profile["start_angle_deg"]) - ea_deg = float(profile["end_angle_deg"]) - - fr = (oR - ir) / 2.0 # 端盖圆角半径 - sa = math.radians(sa_deg) - ea = math.radians(ea_deg) - - entities, contour = [], [] - - # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) - # 1. 内弧(从 start→end) - isu = ir * math.cos(sa); isv = ir * math.sin(sa) - ieu = ir * math.cos(ea); iev = ir * math.sin(ea) - contour.append(_contour_arc( - [isu, isv, 0.0], [ieu, iev, 0.0], - [0.0, 0.0, 0.0], ir, - )) - - # 2. 终端圆角(半圆,从内弧终点到外弧终点) - fc_u = (ir + oR) / 2.0 - fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) - osu = oR * math.cos(sa); osv = oR * math.sin(sa) - oeu = oR * math.cos(ea); oev = oR * math.sin(ea) - - contour.append(_contour_arc( - [ieu, iev, 0.0], [oeu, oev, 0.0], - [fcu_s, fcv_s, 0.0], fr, - )) - - # 3. 外弧(从 end→start,反向) - contour.append(_contour_arc( - [oeu, oev, 0.0], [osu, osv, 0.0], - [0.0, 0.0, 0.0], oR, - )) - - # 4. 起始端圆角(从外弧起点到内弧起点) - fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) - contour.append(_contour_arc( - [osu, osv, 0.0], [isu, isv, 0.0], - [fcu_e, fcv_e, 0.0], fr, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 程序化重复切口 -# ═══════════════════════════════════════════════════════════════ - -def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: - """把按顺序给出的二维顶点变成闭合直线轮廓。""" - return [ - _contour_line( - [vertices[i][0], vertices[i][1], 0.0], - [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], - ) - for i in range(len(vertices)) - ] - - -def _transform_pattern_contour( - contour: list[_Ctx], - x_mm: float, - y_mm: float, - angle_deg: float, - scale: float = 1.0, -) -> list[_Ctx]: - """旋转、缩放并平移一个二维轮廓。""" - a = math.radians(angle_deg) - ca, sa = math.cos(a), math.sin(a) - - def point(p: list[float]) -> list[float]: - x, y = float(p[0]) * scale, float(p[1]) * scale - return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] - - result: list[_Ctx] = [] - for edge in contour: - item = deepcopy(edge) - item["start_mm"] = point(edge["start_mm"]) - item["end_mm"] = point(edge["end_mm"]) - if edge.get("center_mm") is not None: - item["center_mm"] = point(edge["center_mm"]) - if edge.get("radius_mm") is not None: - item["radius_mm"] = float(edge["radius_mm"]) * scale - result.append(item) - return result - - -def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: - """从少量命名尺寸生成一个切口母形。""" - kind = str(motif.get("type") or "") - - if kind == "circle": - radius = float(motif["radius_mm"]) - return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] - - if kind in ("square", "rectangle"): - width = float(motif["width_mm"]) - height = float(motif.get("height_mm") or width) - return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] - - if kind == "obround": - length = float(motif["length_mm"]) - width = float(motif["width_mm"]) - return _gen_obround( - {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, - {}, - )[1] - - if kind == "cross": - size = float(motif["size_mm"]) - arm = float(motif["arm_width_mm"]) - half, arm_half = size / 2.0, arm / 2.0 - vertices = [ - (-arm_half, -half), (arm_half, -half), - (arm_half, -arm_half), (half, -arm_half), - (half, arm_half), (arm_half, arm_half), - (arm_half, half), (-arm_half, half), - (-arm_half, arm_half), (-half, arm_half), - (-half, -arm_half), (-arm_half, -arm_half), - ] - return _poly_contour(vertices) - - if kind == "d_shape_polygon": - stem = float(motif["stem_length_mm"]) - nose = float(motif["nose_depth_mm"]) - half_height = float(motif["half_height_mm"]) - segments = int(motif.get("arc_segments") or 14) - vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] - # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 - for i in range(1, segments): - angle = math.pi / 2.0 - math.pi * i / segments - vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) - vertices.append((0.0, -half_height)) - return _poly_contour(vertices) - - if kind == "regular_hexagon": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(60.0 * i)), - radius * math.sin(math.radians(60.0 * i)), - ) - for i in range(6) - ]) - - if kind == "skew_hexagon": - # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, - # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 - radius = float(motif["nominal_radius_mm"]) - return _poly_contour([ - (radius, 0.0), - (radius * 0.317014, radius * 0.682, ), - (-radius * 0.5, radius * 0.682), - (-radius * 1.183014, 0.0), - (-radius * 0.408494, -radius * 0.774519), - (radius * 0.317014, -radius * 0.774519), - ]) - - if kind == "triangle": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(120.0 * i)), - radius * math.sin(math.radians(120.0 * i)), - ) - for i in range(3) - ]) - - if kind == "teardrop_polygon": - if motif.get("left_width_mm") is not None: - left = float(motif["left_width_mm"]) - right = float(motif["right_width_mm"]) - tip = float(motif["tip_height_mm"]) - bottom = -float(motif["bottom_depth_mm"]) - shoulder = float(motif["shoulder_height_mm"]) - return _poly_contour([ - (0.0, tip), - (right, shoulder), - (right, bottom), - (-left, bottom), - (-left, shoulder), - ]) - width = float(motif["width_mm"]) - height = float(motif["height_mm"]) - shoulder = float(motif.get("shoulder_fraction") or 0.58) - half = width / 2.0 - top = height / 2.0 - bottom = -height / 2.0 - shoulder_y = bottom + height * shoulder - return _poly_contour([ - (0.0, top), - (half, shoulder_y), - (half, bottom), - (-half, bottom), - (-half, shoulder_y), - ]) - - if kind == "trapezoid": - bottom = float(motif["bottom_width_mm"]) - top = float(motif["top_width_mm"]) - height = float(motif["height_mm"]) - hh = height / 2.0 - return _poly_contour([ - (-bottom / 2.0, -hh), - (bottom / 2.0, -hh), - (top / 2.0, hh), - (-top / 2.0, hh), - ]) - - if kind == "annular_sector_polygon": - inner = float(motif["inner_radius_mm"]) - outer = float(motif["outer_radius_mm"]) - half_angle = float(motif["half_angle_deg"]) - segments = int(motif.get("arc_segments") or 8) - outer_pts = [ - ( - outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - inner_pts = [ - ( - inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), - inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - return _poly_contour(outer_pts + inner_pts) - - raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") - - -def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: - """展开语义布局,返回 (x, y, rotation_deg, scale)。""" - kind = str(layout.get("type") or "") - orientation = str(layout.get("orientation") or "fixed") - orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) - - def orient(angle: float) -> float: - if orientation == "radial": - return angle + orientation_offset - if orientation == "tangential": - return angle + 90.0 + orientation_offset - if orientation == "snapped_radial": - snap = float(layout.get("orientation_snap_deg") or 45.0) - return round(angle / snap) * snap + orientation_offset - return orientation_offset - - if kind in ("ring", "angular"): - radius = float(layout.get("radius_mm") or 0.0) - count = int(layout["count"]) - start = float(layout.get("start_angle_deg") or 0.0) - step = float(layout.get("angle_step_deg") or (360.0 / count)) - angular_only = kind == "angular" - return [ - ( - 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), - 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "concentric_rings": - result: list[tuple[float, float, float, float]] = [] - for ring in layout.get("rings") or []: - merged = dict(layout) - merged.update(ring) - merged["type"] = "ring" - result.extend(_pattern_placements(merged)) - return result - - if kind == "disc_grid": - nx, ny = int(layout["count_x"]), int(layout["count_y"]) - sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) - center = layout.get("center_mm") or [0.0, 0.0] - x0 = float(center[0]) - (nx - 1) * sx / 2.0 - y0 = float(center[1]) - (ny - 1) * sy / 2.0 - limit = layout.get("max_center_radius_mm") - points = [ - (x0 + i * sx, y0 + j * sy) - for j in range(ny) - for i in range(nx) - ] - if limit is not None: - points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] - return [(x, y, orientation_offset, 1.0) for x, y in points] - - if kind == "open_arc": - radius = float(layout["radius_mm"]) - count = int(layout["count"]) - start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) - step = 0.0 if count == 1 else (end - start) / (count - 1) - return [ - ( - radius * math.cos(math.radians(start + i * step)), - radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "spiral": - count = int(layout["count"]) - start_radius = float(layout["start_radius_mm"]) - radius_step = float(layout["radius_step_mm"]) - start_angle = float(layout.get("start_angle_deg") or 0.0) - angle_step = float(layout["angle_step_deg"]) - result = [] - for i in range(count): - radius = start_radius + i * radius_step - angle = start_angle + i * angle_step - result.append(( - radius * math.cos(math.radians(angle)), - radius * math.sin(math.radians(angle)), - orient(angle), - 1.0, - )) - return result - - if kind == "cross_lines": - count = int(layout["count_per_axis"]) - spacing = float(layout["spacing_mm"]) - start = -(count - 1) * spacing / 2.0 - result = [] - for i in range(count): - value = start + i * spacing - result.append((value, 0.0, orientation_offset, 1.0)) - result.append((0.0, value, orientation_offset + 90.0, 1.0)) - return result - - if kind == "x_field": - levels = int(layout["levels"]) - spacing = float(layout["spacing_mm"]) - start = -(levels - 1) * spacing / 2.0 - result = [] - for i in range(levels): - value = start + i * spacing - if abs(value) < 1e-9: - rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset - result.append((0.0, 0.0, rotation, 1.0)) - else: - for y in (value, -value): - if orientation == "diagonal_axes": - rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset - else: - angle = math.degrees(math.atan2(y, value)) - rotation = orient(angle) - result.append((value, y, rotation, 1.0)) - return result - - if kind == "twin_strips": - x_offset = float(layout["x_offset_mm"]) - count = int(layout["count_y"]) - y_start = float(layout["y_start_mm"]) - y_end = float(layout["y_end_mm"]) - step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) - return [ - (x, y_start + j * step, orientation_offset, 1.0) - for j in range(count) - for x in (-x_offset, x_offset) - ] - - if kind == "corner_clusters": - levels = [float(v) for v in (layout.get("levels_mm") or [])] - return [ - (sx * x, sy * y, orientation_offset, 1.0) - for sx in (-1.0, 1.0) - for sy in (-1.0, 1.0) - for y in levels - for x in levels - ] - - if kind == "diamond_field": - radius = int(layout["manhattan_radius"]) - spacing = float(layout["spacing_mm"]) - return [ - (i * spacing, j * spacing, orientation_offset, 1.0) - for distance in range(radius + 1) - for j in range(-radius, radius + 1) - for i in range(-radius, radius + 1) - if abs(i) + abs(j) == distance - ] - - raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") - - -def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" - motif = profile.get("motif") or {} - layout = profile.get("layout") or {} - base = _pattern_motif_contour(motif) - regions = [] - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("patterned_cutouts: layout produced no regions") - meta["_regions"] = regions - return [], [] - - -def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """把少量不同母形/布局的程序化图案合并到同一草图。""" - regions: list[_Ctx] = [] - for pattern in profile.get("patterns") or []: - motif = pattern.get("motif") or {} - layout = pattern.get("layout") or {} - base = _pattern_motif_contour(motif) - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("compound_patterned_cutouts: patterns produced no regions") - meta["_regions"] = regions - return [], [] - - -# ═══════════════════════════════════════════════════════════════ -# Evidence v2 analytic contours -# ═══════════════════════════════════════════════════════════════ - -_ANALYTIC_TOLERANCE_MM = 1e-5 - - -def _distance_2d(left: list[float], right: list[float]) -> float: + if len(vertices) < 3: + entities = meta.get("_entities") or [] + if not entities: + raise ValueError("polygon needs at least 3 vertices") + return list(entities), [_contour_line(edge["start"], edge["end"]) for edge in entities if edge.get("type") == "line"] + points = [(float(vertex[0]), float(vertex[1])) for vertex in vertices] + return ( + [_line(list(points[index]), list(points[(index + 1) % len(points)])) for index in range(len(points))], + [_contour_line([*points[index], 0.0], [*points[(index + 1) % len(points)], 0.0]) for index in range(len(points))], + ) + + +def _distance(left: list[float], right: list[float]) -> float: return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) -def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: - result = deepcopy(edge) - result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] - if result.get("type") == "arc" and "clockwise" in result: - result["clockwise"] = not bool(result["clockwise"]) - return result +def _reverse(edge: _Ctx) -> _Ctx: + output = deepcopy(edge) + output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] + if output.get("type") == "arc" and "clockwise" in output: + output["clockwise"] = not bool(output["clockwise"]) + return output -def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: - """Order/reorient a contour without depending on SolidWorks segment order.""" +def _join(edges: list[_Ctx]) -> list[_Ctx]: if not edges: return [] - pending = [deepcopy(edge) for edge in edges] - ordered = [pending.pop(0)] - while pending: + remaining = [deepcopy(edge) for edge in edges] + ordered = [remaining.pop(0)] + while remaining: tail = ordered[-1]["end_mm"] - match_index = None - reverse = False - for index, edge in enumerate(pending): - if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index + for index, candidate in enumerate(remaining): + if _distance(tail, candidate["start_mm"]) <= _TOLERANCE_MM: + ordered.append(remaining.pop(index)) break - if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index - reverse = True + if _distance(tail, candidate["end_mm"]) <= _TOLERANCE_MM: + ordered.append(_reverse(remaining.pop(index))) break - if match_index is None: + else: raise ValueError("analytic_contours: segments do not form a connected contour") - edge = pending.pop(match_index) - ordered.append(_reverse_analytic_edge(edge) if reverse else edge) - if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + if _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM: raise ValueError("analytic_contours: closed contour endpoints do not meet") return ordered -def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: +def _circle_edges(segment: _Ctx) -> list[_Ctx]: center = segment.get("center") or [0.0, 0.0] radius = float(segment.get("radius_mm") or 0.0) if radius <= 0: @@ -1617,86 +132,56 @@ def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: clockwise = bool(segment.get("clockwise", False)) angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] - return [ - _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) - for index in range(4) - ] + return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] -def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: - segment_type = segment.get("type") - if segment_type == "line": +def _segment_edges(segment: _Ctx) -> list[_Ctx]: + kind = segment.get("type") + if kind == "line": return [_contour_line(segment["start"], segment["end"])] - if segment_type == "arc": - return [ - _contour_arc( - segment["start"], segment["end"], segment["center"], - segment.get("radius_mm"), segment.get("clockwise"), - ) - ] - if segment_type == "circle": - return _analytic_circle_edges(segment) - if segment_type == "bspline": + if kind == "arc": + return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] + if kind == "circle": + return _circle_edges(segment) + if kind == "bspline": raise ValueError("analytic_contours: bspline requires an explicit approximation capability") - raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") -def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: - """Create a deterministic planar sample only for containment classification.""" +def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) if edge.get("type") != "arc": continue - center = edge["center_mm"] - end = edge["end_mm"] - sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) - ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) - start_angle = math.atan2(sy, sx) - end_angle = math.atan2(ey, ex) + center, end = edge["center_mm"], edge["end_mm"] + start_angle = math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0])) + end_angle = math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0])) delta = end_angle - start_angle if edge.get("clockwise"): if delta >= 0: delta -= math.tau elif delta <= 0: delta += math.tau + radius = float(edge.get("radius_mm") or _distance(start, center)) for fraction in (0.25, 0.5, 0.75): angle = start_angle + delta * fraction - radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) return points -def _loop_area(points: list[tuple[float, float]]) -> float: - if len(points) < 3: - return 0.0 - return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) - - -def _endpoint_signed_area(edges: list[_Ctx]) -> float: - points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] - return sum( - points[index][0] * points[(index + 1) % len(points)][1] - - points[(index + 1) % len(points)][0] * points[index][1] - for index in range(len(points)) - ) / 2.0 - - def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: - """Repair inconsistent sweep flags on a conventional rounded rectangle. + """Repair inconsistent direction flags on a conventional rounded box. - Evidence exports occasionally label one or more 90-degree corner arcs - with the opposite direction. Honouring those isolated flags creates - 270-degree loops. This normalizer applies only to the unambiguous shape: - exactly four equal-radius quarter arcs in one closed loop. Other arcs, - including annular sectors and long sweeps, retain their captured flags. + The rule only applies to the unambiguous case of four equal 90-degree + corner arcs. It is geometry normalization, not a semantic shape macro. """ arcs = [edge for edge in edges if edge.get("type") == "arc"] if len(arcs) != 4: return radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] - if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + if min(radii) <= _TOLERANCE_MM or max(radii) - min(radii) > _TOLERANCE_MM: return for edge in arcs: center = edge.get("center_mm") @@ -1708,81 +193,62 @@ def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) if abs(angle - math.pi / 2) > 1e-4: return - # A clockwise endpoint loop needs clockwise short corner arcs; a - # counter-clockwise loop needs their reverse. This preserves the actual - # rounded-rectangle boundary, independent of per-segment export noise. - clockwise = _endpoint_signed_area(edges) < 0.0 + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + clockwise = sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) < 0.0 for edge in arcs: edge["clockwise"] = clockwise -def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: - if len(loop) < 3: - return False +def _area(points: list[tuple[float, float]]) -> float: + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) if len(points) >= 3 else 0.0 + + +def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: inside = False x, y = point previous = loop[-1] for current in loop: - x1, y1 = current - x2, y2 = previous - if (y1 > y) != (y2 > y): - intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 - if x < intersect_x: + if (current[1] > y) != (previous[1] > y): + crossing = (previous[0] - current[0]) * (y - current[1]) / (previous[1] - current[1]) + current[0] + if x < crossing: inside = not inside previous = current return inside def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. - - The returned regions preserve holes and islands. The build adapter owns - B-rep creation; this profile generator only reasons about sketch geometry. - """ loops: list[_Ctx] = [] entities: list[_Ctx] = [] - for contour_index, contour in enumerate(profile.get("contours") or []): + for index, contour in enumerate(profile.get("contours") or []): if not contour.get("closed"): - raise ValueError(f"analytic_contours: contour {contour_index} is open") - segment_edges: list[_Ctx] = [] + raise ValueError(f"analytic_contours: contour {index} is open") + raw_edges: list[_Ctx] = [] for segment in contour.get("segments") or []: - segment_type = segment.get("type") - if segment_type == "line": + if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"])) - elif segment_type == "circle": + elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) - segment_edges.extend(_analytic_segment_edges(segment)) - if not segment_edges: - continue - edges = _join_analytic_edges(segment_edges, closed=True) - _normalize_quarter_rounding_direction(edges) - points = _sample_analytic_loop(edges) - area = _loop_area(points) - if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: - raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") - loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) - + raw_edges.extend(_segment_edges(segment)) + if raw_edges: + edges = _join(raw_edges) + _normalize_quarter_rounding_direction(edges) + sample = _sample_loop(edges) + if _area(sample) <= _TOLERANCE_MM * _TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {index} is degenerate") + loops.append({"edges": edges, "points": sample, "area": _area(sample)}) for segment in profile.get("construction") or []: if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"], construction=True)) elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) - if not loops: return entities, [] for loop in loops: - # Role tags captured from the source sketch are useful provenance but - # not authoritative geometry. A number of exports label separate - # closed contours as ``inner`` although no outer contour contains - # them. The even-odd containment rule is deterministic for the - # supported analytic curves and preserves those independent regions. - contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) - loop["role"] = "inner" if contained_by % 2 else "outer" + loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" outers = [loop for loop in loops if loop["role"] == "outer"] - inners = [loop for loop in loops if loop["role"] == "inner"] regions = [{"outer": outer["edges"], "holes": []} for outer in outers] - for inner in inners: - containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + for inner in (loop for loop in loops if loop["role"] == "inner"): + containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] if not containing: raise ValueError("analytic_contours: inner contour has no containing outer contour") selected = min(containing, key=lambda outer: outer["area"]) @@ -1791,218 +257,84 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ return entities, [] -# ═══════════════════════════════════════════════════════════════ -# 生成器注册表 —— 唯一索引点 -# ═══════════════════════════════════════════════════════════════ - -SHAPE_GENERATORS: dict[str, Any] = { - "circle": _gen_circle, - "annulus": _gen_annulus, - "circles": _gen_circles, - "circle_grid": _gen_circle_grid, - "rectangle": _gen_rectangle, - "rectangle_with_circles": _gen_rect_with_circles, - "rectangle_with_fillets": _gen_rect_with_fillets, - "obround": _gen_obround, - "polygon": _gen_polygon, - "ibone": _gen_ibone, - "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, - "revolve_chamfer": _gen_revolve_chamfer, - "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, - # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) - "circle_with_arc_notches": _gen_circle_with_arc_notches, - "circular_sector_slot": _gen_circular_sector_slot, - "circle_with_radial_tabs": _gen_circle_with_radial_tabs, - "filleted_rect_side_slots": _gen_filleted_rect_side_slots, - # 弧边形状 - "d_shape": _gen_d_shape, - "partial_ring": _gen_partial_ring, - "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, - "radial_slot": _gen_radial_slot, - "patterned_cutouts": _gen_patterned_cutouts, - "compound_patterned_cutouts": _gen_compound_patterned_cutouts, - "analytic_contours": _gen_analytic_contours, - "arc_chain": _gen_arc_chain, - "complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建 - "unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退 +CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours} +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS +SHAPE_CAPABILITIES: dict[str, _Ctx] = { + "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"}, } -# ═══════════════════════════════════════════════════════════════ -# 注册表功能:扩展、查询 -# ═══════════════════════════════════════════════════════════════ -def register_shape(ptype: str, generator: Any) -> None: - """注册一个新的轮廓生成器。扩展用途。""" - SHAPE_GENERATORS[ptype] = generator +def register_shape(_: str, __: Any) -> None: + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") def list_registered_shapes() -> list[str]: - """返回所有已注册的形状生成器名称。""" - return sorted(SHAPE_GENERATORS.keys()) + return sorted(SHAPE_GENERATORS) -# ═══════════════════════════════════════════════════════════════ -# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) -# ═══════════════════════════════════════════════════════════════ - -_ShapeInfo = dict[str, Any] - -SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { - "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, - "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, - "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, - "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, - "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, - "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, - "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, - "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, - "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, - "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, - "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, - "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, - "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, - "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, - "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, - "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, - "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, - "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, - "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, - "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, - "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, - "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, - "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, - "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, -} - - -# ═══════════════════════════════════════════════════════════════ -# 主入口 -# ═══════════════════════════════════════════════════════════════ - -def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: - """按 type 查找生成器,生成 entities + contour_edges_mm。 - - 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities - 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 - """ +def resolve_profile(sketch: _Ctx) -> _Ctx: profile = sketch.get("profile") if not profile: return sketch - - ptype = profile.get("type") - generator = SHAPE_GENERATORS.get(ptype) + generator = SHAPE_GENERATORS.get(profile.get("type")) if generator is None: - raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") - - meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") + meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": []} entities, contour = generator(profile, meta) - - out = deepcopy(sketch) - - # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) - orig_ents = sketch.get("entities") or [] - keep_circles = [ - e for e in orig_ents - if e.get("type") == "circle" and not e.get("construction") - ] - if keep_circles and contour: - # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) - entities = list(entities) + keep_circles - - out["entities"] = entities - wp = sketch.get("workplane") + output = deepcopy(sketch) + original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")] + output["entities"] = list(entities) + (original_circles if contour else []) + workplane = sketch.get("workplane") if contour: - out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour - regions = meta.get("_regions") or [] - if regions: - out["contour_regions_mm"] = [ - { - "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], - "holes": [ - _transform_contours(hole, wp) if wp else hole - for hole in (reg.get("holes") or []) - ], - } - for reg in regions + output["contour_edges_mm"] = _transform_contours(contour, workplane) if workplane else contour + if meta["_regions"]: + output["contour_regions_mm"] = [ + {"outer": _transform_contours(region["outer"], workplane) if workplane else region["outer"], "holes": [_transform_contours(hole, workplane) if workplane else hole for hole in region.get("holes") or []]} + for region in meta["_regions"] ] - return out + return output -def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: - """对 CDSL 中所有带 profile 字段的草图进行解析。 - - 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 - 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 - """ - geom = cdsl.get("geometry") or {} - sketches = geom.get("sketches") or [] - - # 第一遍: 解析所有有自己 profile 的草图 - resolved: dict[str, dict] = {} - for sk in sketches: - sid = sk.get("id") - if sid is None: - continue - if "profile" in sk: - resolved[sid] = resolve_profile(sk) - - # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) - for sk in sketches: - sid = sk.get("id") - pf = sk.get("profile_from") - if pf and sid: - src = resolved.get(pf) - if src is None: - raise ValueError( - f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" - ) - sk2 = deepcopy(sk) - sk2["profile"] = deepcopy(src.get("profile")) - sk2.pop("profile_from", None) - - # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) - shift = sk.get("profile_shift") - if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for v in sk2["profile"]["vertices"]: - v[0] = round(v[0] + du, 6) - v[1] = round(v[1] + dv, 6) - sk2.pop("profile_shift", None) - resolved[sid] = resolve_profile(sk2) - - # 按原顺序输出 - result = [] - for sk in sketches: - sid = sk.get("id") - if sid and sid in resolved: - result.append(resolved[sid]) - else: - result.append(deepcopy(sk)) - - out = deepcopy(cdsl) - out.setdefault("geometry", {})["sketches"] = result - return out +def _shift_profile(sketch: _Ctx, source: _Ctx) -> _Ctx: + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + for vertex in output["profile"]["vertices"]: + vertex[0], vertex[1] = round(float(vertex[0]) + float(shift[0]), 6), round(float(vertex[1]) + float(shift[1]), 6) + output.pop("profile_shift", None) + return output -def resolve_required_sketches( - cdsl: dict[str, Any], - sketch_ids: Iterable[str], - *, - errors: dict[str, str] | None = None, -) -> dict[str, Any]: - """Resolve only profiles that an executable feature actually consumes. +def resolve_all_sketches(cdsl: _Ctx) -> _Ctx: + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + resolved: dict[str, _Ctx] = {} + for sketch in sketches: + sketch_id = sketch.get("id") + if sketch_id is not None and "profile" in sketch: + resolved[str(sketch_id)] = resolve_profile(sketch) + for sketch in sketches: + sketch_id, source_id = sketch.get("id"), sketch.get("profile_from") + if sketch_id is not None and source_id: + source = resolved.get(str(source_id)) + if source is None: + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} not found or not yet resolved") + resolved[str(sketch_id)] = resolve_profile(_shift_profile(sketch, source)) + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] + return output - ``profile_from`` dependencies are resolved recursively. Callers that - pass ``errors`` get feature-addressable failures without losing unrelated - resolved sketches; callers that omit it retain the strict exception - behavior useful to profile tooling. - """ + +def resolve_required_sketches(cdsl: _Ctx, sketch_ids: Iterable[str], *, errors: dict[str, str] | None = None) -> _Ctx: sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} - resolved: dict[str, dict[str, Any]] = {} + resolved: dict[str, _Ctx] = {} resolving: set[str] = set() - def resolve_one(sketch_id: str) -> dict[str, Any]: + def resolve_one(sketch_id: str) -> _Ctx: if sketch_id in resolved: return resolved[sketch_id] sketch = by_id.get(sketch_id) @@ -2015,21 +347,7 @@ def resolve_required_sketches( if "profile" in sketch: output = resolve_profile(sketch) elif sketch.get("profile_from"): - source_id = str(sketch["profile_from"]) - source = resolve_one(source_id) - if not source.get("profile"): - raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") - output = deepcopy(sketch) - output["profile"] = deepcopy(source["profile"]) - output.pop("profile_from", None) - shift = sketch.get("profile_shift") - if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for vertex in output["profile"]["vertices"]: - vertex[0] = round(vertex[0] + du, 6) - vertex[1] = round(vertex[1] + dv, 6) - output.pop("profile_shift", None) - output = resolve_profile(output) + output = resolve_profile(_shift_profile(sketch, resolve_one(str(sketch["profile_from"])))) else: output = deepcopy(sketch) resolved[sketch_id] = output @@ -2044,10 +362,6 @@ def resolve_required_sketches( if errors is None: raise errors[sketch_id] = str(error) - output = deepcopy(cdsl) - output.setdefault("geometry", {})["sketches"] = [ - resolved.get(str(sketch.get("id")), deepcopy(sketch)) - for sketch in sketches - ] + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] return output diff --git a/backend/scripts/remove_generation_spec_artifacts.py b/backend/scripts/remove_generation_spec_artifacts.py new file mode 100644 index 00000000..ec3977f4 --- /dev/null +++ b/backend/scripts/remove_generation_spec_artifacts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Remove retired semantic-plan artifacts from the local workspace. + +Run without arguments to list every file and metadata document that would +change. Run again with --apply to make the irreversible cleanup. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +RETIRED_FILENAMES = { + "generation-spec.json", + "generation-provenance.json", + "acceptance-report.json", +} +RETIRED_DIAGNOSTIC_PREFIX = "generation_spec_" +RETIRED_TOOL_NAMES = { + "create_generation_spec", + "author_cdsl_from_generation_spec", + "compile_generation_spec", + "patch_generation_spec", + "generate_flange_sleeve_model", +} +RETIRED_METADATA_KEYS = { + "generation_spec_path", + "generation_provenance_path", + "acceptance_path", + "approximations", + "current_design_intent_id", + "design_intents", + "design_intent_id", + "design_intent_path", + "design_intent_structures", + "design_intent_assumptions", + "design_intent_capability_gaps", + "capability_translations", + "evidence", +} + + +def _strip_metadata(value: Any) -> bool: + changed = False + if isinstance(value, list): + for item in value: + changed = _strip_metadata(item) or changed + return changed + if not isinstance(value, dict): + return False + for key in list(value): + if key in RETIRED_METADATA_KEYS or key.startswith("generation_spec_"): + value.pop(key) + changed = True + operation = value.get("operation") + if isinstance(operation, dict) and "spec" in str(operation.get("type") or "").casefold(): + value["operation"] = {} + changed = True + for key, child in list(value.items()): + if key == "source" and isinstance(child, str) and "generation_spec" in child.casefold(): + value[key] = "legacy_cdsl" + changed = True + elif key in {"message", "text"} and isinstance(child, str) and "generationspec" in child.casefold(): + value[key] = "已清理已弃用的规格流程诊断;当前任务统一使用直接 CDSL 生成。" + changed = True + else: + changed = _strip_metadata(child) or changed + return changed + + +def _retired_diagnostic(path: Path) -> bool: + if path.name.startswith(RETIRED_DIAGNOSTIC_PREFIX): + return True + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + tool_name = str(payload.get("tool_name") or "") + arguments = str(payload.get("arguments") or "") + return tool_name in RETIRED_TOOL_NAMES or "generation-spec" in arguments or "generation_spec" in arguments + + +def cleanup_plan(data_root: Path) -> tuple[list[Path], list[Path]]: + files: list[Path] = [] + metadata: list[Path] = [] + task_root = data_root / "tasks" + conversation_root = data_root / "conversations" + if task_root.is_dir(): + for path in task_root.rglob("*"): + if path.is_file() and ( + path.name in RETIRED_FILENAMES + or (path.parent.name == "planning" and path.name.startswith("design-intent-")) + ): + files.append(path) + for path in task_root.rglob("*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + if conversation_root.is_dir(): + for path in conversation_root.rglob("*.json"): + if _retired_diagnostic(path): + files.append(path) + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + file_set = set(files) + return sorted(file_set), sorted(set(metadata) - file_set) + + +def apply_cleanup(data_root: Path) -> tuple[list[Path], list[Path]]: + files, metadata = cleanup_plan(data_root) + for path in files: + path.unlink() + for path in metadata: + payload = json.loads(path.read_text(encoding="utf-8")) + if _strip_metadata(payload): + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return files, metadata + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove retired GenerationSpec and semantic-plan artifacts") + parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data") + parser.add_argument("--apply", action="store_true", help="Delete the listed artifacts and rewrite metadata") + args = parser.parse_args() + files, metadata = cleanup_plan(args.data_root) + action = "Will remove" if not args.apply else "Removing" + for path in files: + print(f"{action} artifact: {path}") + for path in metadata: + print(f"{action} retired metadata: {path}") + if not files and not metadata: + print("No retired GenerationSpec or semantic-plan artifacts found.") + if not args.apply: + print("Dry run only. Re-run with --apply to make these changes.") + return 0 + apply_cleanup(args.data_root) + print(f"Removed {len(files)} artifacts and rewrote {len(metadata)} metadata documents.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index de89d6cb..4c7a3e61 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -9,10 +9,12 @@ from types import SimpleNamespace from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message +from app.services.engine_service import load_engine +from app.services.quality import QUALITY_RULE_TYPES from app.services.library import CdslLibrary from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings +from app.settings import ProviderConfig, ProviderModel, Settings, get_settings class ToolChoiceCompatibilityTests(unittest.TestCase): @@ -107,16 +109,31 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"]) self.assertEqual(cdsl, CDSL_TOOL_SCHEMA) + def test_generation_schema_exposes_only_runtime_atomic_ids(self) -> None: + settings = get_settings() + engine = load_engine(settings) + self.assertEqual( + set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]), + set(engine.SUPPORTED_ATOMIC_IDS), + ) + + def test_verification_schema_exposes_only_implemented_rule_types(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + verification = generate_tool["function"]["parameters"]["properties"]["verification"] + rule_type = verification["properties"]["rules"]["items"]["properties"]["type"] + self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES)) + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] - self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"]) - template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model") - self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"]) + self.assertEqual(strict_tools, ["generate_cdsl_model", "patch_cdsl_model"]) generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model") self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1}) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA) + self.assertIn("verification", generate_tool["function"]["parameters"]["properties"]) + patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model") + self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"]) self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"]) def test_default_model_does_not_receive_strict_tool_schema(self) -> None: @@ -124,21 +141,14 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertFalse(any(tool["function"].get("strict") for tool in tools)) - def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None: - analysis = { - "partType": "带方形法兰的圆筒管座", - "visibleFeatures": ["同轴中空管", "四个安装孔"], - } - - self.assertTrue(use_flange_sleeve_template(analysis, "")) - self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing")) - tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True) - names = [tool["function"]["name"] for tool in tools] - self.assertIn("generate_flange_sleeve_model", names) - self.assertNotIn("search_cdsl_library", names) - self.assertNotIn("read_cdsl_reference", names) - self.assertNotIn("read_current_cdsl", names) - self.assertNotIn("generate_cdsl_model", names) + def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None: + names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))] + self.assertIn("generate_cdsl_model", names) + self.assertIn("patch_cdsl_model", names) + self.assertNotIn("create_generation_spec", names) + self.assertNotIn("author_cdsl_from_generation_spec", names) + self.assertNotIn("patch_generation_spec", names) + self.assertNotIn("generate_flange_sleeve_model", names) def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None: tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False) @@ -385,15 +395,15 @@ class ToolArgumentsRetryTests(unittest.TestCase): diagnostics = settings.conversation_root / conversation_id / "diagnostics" attempts = sorted(diagnostics.glob("cdsl_attempt_*.json")) failures = sorted(diagnostics.glob("cdsl_validation_*.json")) - self.assertEqual(len(attempts), 7) - self.assertEqual(len(failures), 7) + self.assertEqual(len(attempts), 5) + self.assertEqual(len(failures), 5) self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts)) records = sorted( (json.loads(path.read_text(encoding="utf-8")) for path in failures), key=lambda record: int(record["iteration"]), ) - self.assertEqual([record["iteration"] for record in records], list(range(2, 9))) + self.assertEqual([record["iteration"] for record in records], list(range(2, 7))) self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records)) self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records)) self.assertTrue(all(record["validation_error"] for record in records)) @@ -402,7 +412,7 @@ class ToolArgumentsRetryTests(unittest.TestCase): {path.name for path in attempts}, ) self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events)) - self.assertEqual(agent.responses, []) + self.assertEqual(len(agent.responses), 2) self.assertEqual(list(settings.task_root.glob("cad_*")), []) @@ -822,5 +832,6 @@ class StructuredResultResponseTests(unittest.TestCase): self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {}) self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), []) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_design_intent.py b/backend/tests/test_design_intent.py deleted file mode 100644 index 5bf96509..00000000 --- a/backend/tests/test_design_intent.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import copy -import sys -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - - - -def workplane(z: float = 0.0) -> dict[str, list[float]]: - return { - "origin_mm": [0.0, 0.0, z], - "x_dir": [1.0, 0.0, 0.0], - "y_dir": [0.0, 1.0, 0.0], - "normal": [0.0, 0.0, 1.0], - } - - -def mounting_plate_intent() -> dict: - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": "create", - "request": "Create a mounting plate with four holes and a center slot", - "base_revision_id": "", - "structures": [ - { - "id": "base_plate", - "cdsl_feature_id": "base_add", - "role": "base", - "purpose": "Rectangular mounting plate", - "depends_on": [], - "cdsl_strategy": { - "atomic_id": "extrude_add_blind", - "profile_type": "rectangle", - "parameter_roles": ["width_mm", "height_mm", "thickness_mm"], - "selector_roles": [], - }, - }, - { - "id": "mounting_holes", - "cdsl_feature_id": "hole_cut", - "role": "subtractive", - "purpose": "Four mounting holes", - "depends_on": ["base_plate"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "circle_grid", - "parameter_roles": ["diameter_mm", "count_x", "count_y"], - "selector_roles": [], - }, - }, - { - "id": "adjustment_slot", - "cdsl_feature_id": "slot_cut", - "role": "subtractive", - "purpose": "Center adjustment slot", - "depends_on": ["mounting_holes"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "obround", - "parameter_roles": ["length_mm", "width_mm", "depth_mm"], - "selector_roles": [], - }, - }, - ], - "feature_order": ["base_plate", "mounting_holes", "adjustment_slot"], - "assumptions": [], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [ - {"type": "feature_count", "feature_id": "mounting_holes", "expected": 4}, - {"type": "symmetry", "axis": "x"}, - {"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}}, - ], - "status": "ready", - } - - -def mounting_plate_cdsl() -> dict: - return { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.0", - "kind": "part", - "part_id": "mounting-plate", - "geometry": { - "sketches": [ - {"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, - {"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, - {"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}}, - ], - }, - "features": [ - {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, - {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, - {"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}}, - ], - } - - -class DesignIntentValidationTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - sys.path.insert(0, str(ROOT / "backend" / "engine")) - import cdsl_engine - - cls.engine = cdsl_engine - - def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None: - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine) - self.assertEqual(context.exception.code, code) - - def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None: - intent = mounting_plate_intent() - validated = self.engine.validate_design_intent(intent, self.engine) - - self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"]) - self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine) - - def test_revise_requires_current_base_revision(self) -> None: - intent = mounting_plate_intent() - intent["mode"] = "revise" - intent["base_revision_id"] = "" - self.assert_plan_error(intent) - - intent["base_revision_id"] = "rev_001" - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001") - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002") - self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH") - self.assertIn("rev_002", str(context.exception)) - - def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None: - duplicate = mounting_plate_intent() - duplicate["structures"][1]["id"] = "base_plate" - self.assert_plan_error(duplicate) - - cyclic = mounting_plate_intent() - cyclic["structures"][0]["depends_on"] = ["mounting_holes"] - self.assert_plan_error(cyclic) - - unsupported = mounting_plate_intent() - unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add" - self.assert_plan_error(unsupported) - - incomplete = mounting_plate_intent() - incomplete["feature_order"].pop() - self.assert_plan_error(incomplete) - - no_profile = mounting_plate_intent() - no_profile["structures"][0]["cdsl_strategy"].pop("profile_type") - self.assert_plan_error(no_profile) - - unknown_expectation = mounting_plate_intent() - unknown_expectation["verification_expectations"][0]["feature_id"] = "missing" - self.assert_plan_error(unknown_expectation) - - def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None: - clarification = mounting_plate_intent() - clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}] - clarification["status"] = "needs_clarification" - self.engine.validate_design_intent(clarification, self.engine) - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine) - self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED") - - approximation = mounting_plate_intent() - approximation["capability_gaps"] = [{ - "code": "unsupported_thread_geometry", - "structure_id": "mounting_holes", - "message": "Only a cylindrical bore is available.", - "blocking": False, - }] - self.assert_plan_error(approximation) - approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."] - self.engine.validate_design_intent(approximation, self.engine) - - def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None: - missing_feature = mounting_plate_cdsl() - missing_feature["features"].pop() - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine) - self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH") - - atomic = mounting_plate_cdsl() - atomic["features"][1]["atomic_id"] = "extrude_add_blind" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine) - - profile = mounting_plate_cdsl() - profile["geometry"]["sketches"][1]["profile"]["type"] = "circles" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine) - - selector_plan = copy.deepcopy(mounting_plate_intent()) - selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"] - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine) diff --git a/backend/tests/test_design_intent_flow.py b/backend/tests/test_design_intent_flow.py index 72fb6b48..fae6e704 100644 --- a/backend/tests/test_design_intent_flow.py +++ b/backend/tests/test_design_intent_flow.py @@ -14,20 +14,42 @@ sys.path.insert(0, str(ROOT / "backend")) from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService # noqa: E402 -from app.services.engine_service import load_engine # noqa: E402 -from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 -from tests.test_design_intent import mounting_plate_cdsl # noqa: E402 BACKEND = ROOT / "backend" PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" -class DesignIntentFlowTests(unittest.TestCase): +def _workplane(z: float = 0.0) -> dict[str, list[float]]: + return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]} + + +def mounting_plate_cdsl() -> dict: + holes = [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]} + for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]] + ] + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "mounting-plate", + "geometry": {"sketches": [ + {"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}}, + {"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}}, + ]}, + "features": [ + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, + {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, + ], + } + + +class DirectCdslFlowTests(unittest.TestCase): def settings(self, root: Path) -> Settings: provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) return Settings( @@ -48,7 +70,7 @@ class DesignIntentFlowTests(unittest.TestCase): settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + state = {"phase": "INTAKE", "design_brief": ""} searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state)) generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state)) @@ -77,7 +99,7 @@ class DesignIntentFlowTests(unittest.TestCase): settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."} + state = {"phase": "PLANNED", "design_brief": "Create a flange base."} captured_build: dict[str, object] = {} def fake_build_revision(**kwargs: object) -> dict[str, object]: @@ -99,45 +121,6 @@ class DesignIntentFlowTests(unittest.TestCase): self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0]) self.assertEqual(len(result["normalization_repairs"]), 3) - def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = self.settings(Path(directory)) - store = WorkspaceStore(settings) - agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."} - captured_build: dict[str, object] = {} - - def fake_build_revision(**kwargs: object) -> dict[str, object]: - captured_build.update(kwargs) - return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"} - - with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): - result, _ = asyncio.run(agent._run_tool( - "generate_flange_sleeve_model", - { - "plan": { - "template": TEMPLATE_ID, - "name": "Image flange sleeve", - "flange_width_mm": 140, - "flange_height_mm": 120, - "mount_hole_u_mm": 48, - "mount_hole_v_mm": 38, - }, - "summary": "flange sleeve", - "assumptions": ["Dimensions are approximate."], - }, - "", "Create an image flange sleeve", [], planning_state=state, - )) - - built_cdsl = captured_build["cdsl"] - self.assertTrue(result["ok"]) - self.assertEqual(result["template_id"], TEMPLATE_ID) - self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID) - self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve") - self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0]) - self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"]) - self.assertNotIn("cdsl", result["template_plan"]) - def test_successful_generation_ends_the_agent_tool_loop(self) -> None: class CaptureAgent(AgentService): def __init__(self, *args: object, **kwargs: object) -> None: @@ -148,7 +131,7 @@ class DesignIntentFlowTests(unittest.TestCase): "role": "assistant", "content": "", "tool_calls": [{ "id": "brief", "type": "function", "function": { "name": "describe_design_intent", - "arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}), + "arguments": json.dumps({"plan": "Create a cylindrical part.", "assumptions": []}), }, }], }}], @@ -156,11 +139,11 @@ class DesignIntentFlowTests(unittest.TestCase): { "choices": [{"message": { "role": "assistant", "content": "", "tool_calls": [{ - "id": "template", "type": "function", "function": { - "name": "generate_flange_sleeve_model", + "id": "generate", "type": "function", "function": { + "name": "generate_cdsl_model", "arguments": json.dumps({ - "plan": {"template": TEMPLATE_ID}, - "summary": "flange sleeve", + "cdsl": mounting_plate_cdsl(), + "summary": "mounting plate", "assumptions": [], }), }, @@ -189,7 +172,7 @@ class DesignIntentFlowTests(unittest.TestCase): with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): async def consume() -> None: - message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")]) + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) async for _ in agent.stream([message], None, None): pass @@ -270,8 +253,7 @@ class DesignIntentFlowTests(unittest.TestCase): "engine": "cdsl_only", } - engine = load_engine(settings) - with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): async def consume() -> None: message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) async for _ in agent.stream([message], None, None): @@ -281,18 +263,16 @@ class DesignIntentFlowTests(unittest.TestCase): brief_result = json.loads(str(agent.seen_messages[1][-1]["content"])) self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.") - self.assertNotIn("design_intent_id", brief_result) - self.assertNotIn("design_intent_id", captured_build) + self.assertNotIn("structures", brief_result) self.assertNotIn("design_intent", captured_build) self.assertEqual(captured_build["parent_revision_id"], "") - legacy_validation.assert_not_called() def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None: with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + state = {"phase": "INTAKE", "design_brief": "", "current_model_read": True} asyncio.run(agent._run_tool( "describe_design_intent", {"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []}, diff --git a/backend/tests/test_direct_cdsl_pipeline.py b/backend/tests/test_direct_cdsl_pipeline.py new file mode 100644 index 00000000..4c37b343 --- /dev/null +++ b/backend/tests/test_direct_cdsl_pipeline.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.agent_service import AgentService # noqa: E402 +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402 +from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402 +from app.services.library import CdslLibrary # noqa: E402 +from app.services.part_skills import PartSkillLibrary # noqa: E402 +from app.services.quality import evaluate_quality, validate_verification # noqa: E402 +from app.services.storage import WorkspaceStore, write_json # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +BACKEND = ROOT / "backend" +PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" + + +def workplane(z: float = 0.0) -> dict[str, list[float]]: + return { + "origin_mm": [0.0, 0.0, z], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0], + } + + +def fixture() -> dict: + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "direct-cdsl-fixture", + "geometry": { + "sketches": [{ + "id": "base", + "workplane": workplane(), + "profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]}, + }], + }, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "sketch_id": "base", + "params": {"distance_mm": 4}, + }], + } + + +def settings(root: Path) -> Settings: + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + return Settings( + task_root=root / "tasks", + conversation_root=root / "conversations", + library_root=BACKEND / "cdsl_library", + engine_root=BACKEND / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + + +class PatchTests(unittest.TestCase): + def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None: + document = fixture() + patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}]) + + self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8) + self.assertEqual(document["features"][0]["params"]["distance_mm"], 4) + with self.assertRaisesRegex(CdslPatchError, "complete CDSL"): + apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}]) + + def test_invalid_patch_path_is_rejected(self) -> None: + with self.assertRaisesRegex(CdslPatchError, "out of range"): + apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}]) + + +class VerificationTests(unittest.TestCase): + def test_bbox_accepts_dimension_vector(self) -> None: + rules = validate_verification({"rules": [ + {"id": "size", "type": "bbox", "expected": [20, 10, 4]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0]) + + def test_bbox_accepts_coordinate_ranges(self) -> None: + rules = validate_verification({"rules": [ + {"id": "range", "type": "bbox", "expected": { + "x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4, + }}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + + def test_feature_bbox_uses_owned_topology_records(self) -> None: + rules = validate_verification({"rules": [ + {"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]}, + "topology_records": [{ + "feature_id": "base_add", "owner_feature_ids": ["base_add"], + "geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]}, + }], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm") + + def test_bbox_shape_is_rejected_before_execution(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [ + {"id": "bad", "type": "bbox", "expected": {"width": 10}}, + ]}, fixture()) + + def test_unknown_feature_reference_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"): + validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture()) + + def test_invalid_expected_value_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture()) + + def test_warning_does_not_block_generic_quality(self) -> None: + rules = validate_verification({"rules": [ + {"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"}, + {"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "solid_count": 1, + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(len(report["blocking_failures"]), 0) + self.assertEqual(len(report["warnings"]), 1) + + def test_blocking_failure_requires_repair(self) -> None: + rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture()) + report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}}) + + self.assertEqual(report["status"], "failed") + self.assertEqual(report["blocking_failures"][0]["id"], "solids") + + +class AgentPatchFlowTests(unittest.TestCase): + def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]: + task = store.ensure_task(None, "Create a direct CDSL fixture") + revision_id = "rev_001" + revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id + revision_dir.mkdir(parents=True) + write_json(revision_dir / "model.cdsl.json", fixture()) + store.update_task(task["task_id"], { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + }) + return task["task_id"], revision_id + + def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "patch_cdsl_model", + { + "base_revision_id": parent, + "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], + "summary": "Increase thickness", + "assumptions": [], + }, + task_id, + "Increase thickness", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_patch") + self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8) + + def test_bad_patch_creates_no_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []}, + task_id, + "bad patch", + [], + planning_state={"phase": "PLANNED"}, + )) + + task = store.read_task(task_id) + self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent]) + + def test_complete_replacement_uses_current_revision_as_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Replacement", "assumptions": []}, + task_id, + "replace geometry", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_replacement") + + def test_blocking_quality_failure_is_repairable_and_readable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + with self.assertRaises(QualityVerificationError) as context: + build_revision( + settings=config, + store=store, + task_id=None, + request="Create fixture", + cdsl=fixture(), + reference_ids=[], + summary="fixture", + verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]}, + ) + + error = context.exception + self.assertTrue(error.task_id) + self.assertEqual(error.revision_id, "rev_001") + task = store.read_task(error.task_id) + self.assertEqual(task["revisions"][0]["status"], "needs_repair") + self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair") + + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + read, _ = asyncio.run(agent._run_tool( + "read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"} + )) + self.assertTrue(read["ok"]) + self.assertEqual(read["revision_id"], error.revision_id) + + def test_runtime_build_failure_does_not_leave_a_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + + class BrokenEngine: + def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict: + raise RuntimeError("intentional runtime failure") + + with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch( + "app.services.engine_service.validate_cdsl", return_value=None + ), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"): + build_revision( + settings=config, + store=store, + task_id=None, + request="broken fixture", + cdsl=fixture(), + reference_ids=[], + summary="broken", + ) + + tasks = list(config.task_root.glob("cad_*")) + self.assertEqual(len(tasks), 1) + task = store.read_task(tasks[0].name) + self.assertEqual(task["revisions"], []) + + +class CleanupAndBoundaryTests(unittest.TestCase): + def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + data_root = Path(directory) / "data" + task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001" + conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics" + task_dir.mkdir(parents=True) + conversation_dir.mkdir(parents=True) + write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"}) + write_json(task_dir / "model.cdsl.json", fixture()) + (task_dir / "model.step").write_bytes(b"step") + (task_dir / "model.glb").write_bytes(b"glb") + write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", { + "generation_spec_path": "revisions/rev_001/generation-spec.json", + "current_design_intent_id": "intent_aaaaaaaaaaaa", + "revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}], + }) + write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"}) + + script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py" + dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True) + self.assertIn("Dry run only", dry.stdout) + self.assertTrue((task_dir / "generation-spec.json").is_file()) + + subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True) + self.assertFalse((task_dir / "generation-spec.json").exists()) + self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists()) + self.assertTrue((task_dir / "model.cdsl.json").is_file()) + self.assertTrue((task_dir / "model.step").is_file()) + self.assertTrue((task_dir / "model.glb").is_file()) + cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8")) + self.assertNotIn("generation_spec_path", cleaned) + self.assertNotIn("current_design_intent_id", cleaned) + self.assertNotIn("acceptance_path", cleaned["revisions"][0]) + + def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None: + engine_root = BACKEND / "engine" / "cdsl_engine" + source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py")) + lowered = source.casefold() + + self.assertNotIn("from app", lowered) + self.assertNotIn("import app", lowered) + for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"): + self.assertNotIn(business_family, lowered) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 4d68e36c..fe68573c 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -21,6 +21,13 @@ def _workplane() -> dict: return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + class EngineRuntimeFoundationTests(unittest.TestCase): def _base_block(self) -> dict: return { @@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "base", "workplane": _workplane(), - "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + "profile": _rectangle([-5, -5], [5, 5]), }]}, "features": [{ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], @@ -490,7 +497,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test", "meta": {"unit": "mm"}, "geometry": {"sketches": [ - {"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}}, + {"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])}, {"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}}, ]}, "features": [ @@ -518,7 +525,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "geometry": {"sketches": [{ "id": "circles", "workplane": _workplane(), - "profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]}, + "profile": {"type": "circle", "center": [2, 3], "radius_mm": 1}, }]}, "features": [], } @@ -761,7 +768,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "profile", "workplane": _workplane(), - "profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]}, + "profile": _rectangle([2, 1], [4, 2]), }]}, "features": [ { diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 9c94a14f..054a662c 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import copy import json import sys import tempfile @@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend")) from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402 -from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 @@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict: } +def rectangle(center: list[float], width: float, height: float) -> dict: + half_width, half_height = width / 2, height / 2 + return {"type": "polygon", "vertices": [ + [center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height], + [center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height], + ]} + + +def circles(items: list[dict]) -> dict: + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]} + for item in items + ]} + + +def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict: + return circles([ + {"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius} + for row in range(count_y) for column in range(count_x) + ]) + + +def obround(length: float, width: float) -> dict: + radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2 + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius}, + ]}]} + + +def annulus(inner_radius: float, outer_radius: float) -> dict: + return circles([ + {"center": [0, 0], "radius_mm": outer_radius}, + {"center": [0, 0], "radius_mm": inner_radius}, + ]) + + def mounting_plate_fixture() -> dict: return document("golden-mounting-plate", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}}, - {"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)}, + {"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"}, {"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"}, @@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict: def mounting_bracket_fixture() -> dict: web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]} return document("golden-mounting-bracket", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}}, - {"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}}, - {"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}}, - {"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)}, + {"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)}, + {"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)}, + {"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"}, {"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"}, @@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict: def flange_fixture() -> dict: return document("golden-flange", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}}, - {"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": annulus(10, 40)}, + {"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"}, {"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"}, @@ -94,9 +131,9 @@ def shaft_fixture() -> dict: def bearing_housing_fixture() -> dict: return document("golden-bearing-housing", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)}, {"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}}, - {"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, + {"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"}, {"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"}, @@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict: ]) -GOLDEN_CASES = ( - ("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}), - ("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}), - ("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}), - ("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}), -) - - class PartSkillLibraryTests(unittest.TestCase): def setUp(self) -> None: self.library = PartSkillLibrary(PART_SKILL_ROOT) @@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase): self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file()) self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file()) self.assertTrue(skill["triggers"]) - self.assertTrue(skill["capability_translation_rules"]) + self.assertNotIn("capability_translation_rules", skill) self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8}) self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file()) self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file()) - def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None: + def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None: fixture = mounting_plate_fixture() - exact = self.library.audit(self.library.select("安装底板"), fixture) - finishing = self.library.audit(self.library.select("edge treatment"), fixture) - blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture()) - - exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]} - finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]} - blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]} - self.assertEqual(exact_statuses["planning/mounting-plate"], "exact") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable") - self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked") + audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"]) + self.assertIn("planning/mounting-plate", audit["skill_ids"]) + self.assertEqual(audit["assumptions"], ["孔位置由用户确认"]) + self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"])) + self.assertNotIn("capability_translations", audit) + self.assertNotIn("evidence", audit) class AgentPartSkillTests(unittest.TestCase): @@ -211,7 +233,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"])) self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"])) - def test_prompt_contains_bridge_and_template_tool(self) -> None: + def test_prompt_contains_bridge_and_direct_cdsl_tools(self) -> None: library = PartSkillLibrary(PART_SKILL_ROOT) selection = library.select("Create a flange with a bolt circle") with tempfile.TemporaryDirectory() as directory: @@ -220,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -230,7 +252,7 @@ class AgentPartSkillTests(unittest.TestCase): agent = AgentService(settings, store, CdslLibrary(settings), library) request = "M6 六角螺母" selection = library.select(request) - planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + planning_state = {"phase": "INTAKE", "design_brief": ""} planned, _ = asyncio.run(agent._run_tool( "describe_design_intent", {"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []}, @@ -278,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase): ) -class PartSkillBuildAndGoldenTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp())) - cls.engine = load_engine(cls.settings) - cls.library = PartSkillLibrary(PART_SKILL_ROOT) - - def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = AgentPartSkillTests._settings(Path(directory)) - store = WorkspaceStore(settings) - selection = self.library.select("带螺栓圆的法兰") - audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"]) - success = build_revision( - settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(), - reference_ids=["flange-reference"], summary="flange", part_skills=audit, - generation_assumptions=["Explicit circle expansion"], - ) - success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8")) - success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(success_audit["skill_ids"], audit["skill_ids"]) - self.assertIn("generation_context", success_report) - - invalid = copy.deepcopy(flange_fixture()) - invalid["features"][0]["atomic_id"] = "not_supported" - failed_audit = self.library.audit(selection, invalid, []) - with self.assertRaisesRegex(ValueError, "CDSL schema violation"): - build_revision( - settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid, - reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[], - ) - task = store.read_task(success["task_id"]) - failed = task["revisions"][-1] - self.assertEqual(failed["status"], "failed") - self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file()) - report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"]) - legacy_report = {"engine_result": {"engine": "cdsl_only"}} - self.assertEqual(legacy_report.get("generation_context", {}), {}) - - def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None: - for request, factory, expected_skills, expected_statuses in GOLDEN_CASES: - with self.subTest(request=request): - cdsl = factory() - selection = self.library.select(request) - self.assertTrue(expected_skills.issubset(selection["skill_ids"])) - validate_cdsl(cdsl, self.engine) - with tempfile.TemporaryDirectory() as directory: - step_path = Path(directory) / "model.step" - result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path) - self.assertEqual(result["engine"], "cdsl_only") - self.assertTrue(step_path.is_file()) - self.assertGreater(step_path.stat().st_size, 0) - audit = self.library.audit(selection, cdsl, ["golden fixture"]) - statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]} - for skill_id, status in expected_statuses.items(): - self.assertEqual(statuses[skill_id], status) - self.assertTrue(audit["evidence"]["features"]) - self.assertTrue(audit["evidence"]["profiles"]) - - if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index d4d89116..d0509e24 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl -from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl from app.settings import get_settings @@ -23,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase): def test_schema_and_registered_profiles_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS)) + def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None: + legacy = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + }]}, + "features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}], + } + with self.assertRaisesRegex(ValueError, "CDSL schema violation"): + validate_cdsl(legacy, self.engine) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered = lower_legacy_profiles(legacy) + self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours") + validate_cdsl(lowered, self.engine) + def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS)) - self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"])) + self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"])) self.assertEqual( set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]), - set(self.schema["feature_atomic_ids"]), + set(self.engine.SUPPORTED_ATOMIC_IDS), ) def test_machine_schema_and_human_contract_stay_in_sync(self) -> None: self.assertEqual( set(self.cdsl_schema["$defs"]["profile_type"]["enum"]), - set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"}, + set(self.schema["runtime_supported_profiles"]), ) - self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"])) - self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"])) + self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"])) def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None: cdsl = { @@ -135,39 +151,6 @@ class ProfileSchemaTests(unittest.TestCase): self.assertNotIn("point_mm", axis) self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs) - def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None: - cdsl, plan = build_flange_sleeve_cdsl({ - "template": TEMPLATE_ID, - "name": "Template flange sleeve", - "flange_width_mm": 120, - "flange_height_mm": 120, - "flange_thickness_mm": 14, - "corner_chamfer_mm": 10, - "tube_outer_diameter_mm": 70, - "tube_straight_length_mm": 95, - "tip_outer_diameter_mm": 62, - "tip_length_mm": 28, - "bore_diameter_mm": 46, - "boss_outer_diameter_mm": 82, - "boss_height_mm": 5, - "mount_hole_diameter_mm": 12, - "mount_counterbore_diameter_mm": 24, - "mount_counterbore_depth_mm": 5, - "mount_hole_u_mm": 42, - "mount_hole_v_mm": 42, - }) - - self.assertEqual(plan["name"], "Template flange sleeve") - self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"]) - self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0]) - validate_cdsl(cdsl, self.engine) - with tempfile.TemporaryDirectory() as directory: - step_path = Path(directory) / "flange-sleeve.step" - result = self.engine.run_cdsl_only(cdsl, step_path) - self.assertEqual(result["engine"], "cdsl_only") - self.assertTrue(step_path.is_file()) - self.assertGreater(step_path.stat().st_size, 0) - def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", @@ -192,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase): self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source) def test_all_official_samples_match_the_engine_schema(self) -> None: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json")) self.assertGreater(len(samples), 0) for sample_path in samples: with self.subTest(sample=sample_path.parent.name): - validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine) + legacy_sample = json.loads(sample_path.read_text(encoding="utf-8")) + lowered = lower_legacy_profiles(legacy_sample) + validate_cdsl(lowered, self.engine) + self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible) if __name__ == "__main__": unittest.main() diff --git a/frontend/src/app/api/tasks/[taskId]/quality/route.ts b/frontend/src/app/api/tasks/[taskId]/quality/route.ts new file mode 100644 index 00000000..29a3ddcf --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/quality/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4dcd14b1..f8fac541 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -442,6 +442,18 @@ button:disabled { .cad-result { margin-top: 12px; } .cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } .cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.cad-quality-status { font-weight: 600; } +.quality-accepted { color: #2f8f5b; } +.quality-built_with_warnings, .quality-historical { color: #a66a1f; } +.quality-needs_repair, .quality-blocked, .quality-failed { color: #b44545; } +.cad-result-notes { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-result-notes strong { color: var(--ui-text); } +.cad-quality-results { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-quality-results > strong { color: var(--ui-text); } +.cad-quality-results ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.cad-quality-results li { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; } +.quality-rule-passed { color: #2f8f5b; } +.quality-rule-failed, .quality-rule-unavailable { color: #b44545; } .download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; } .download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; } .download-link:hover { color: var(--ui-link-hover); text-decoration: underline; } diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index 11ad5842..b5a6c7b2 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,6 +1,7 @@ "use client"; import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react"; +import { useEffect, useState } from "react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types"; @@ -34,6 +35,16 @@ export function CadResultPart({ data }: { data: CadResult }) { ["GLB", data.glbPath], ["报告", data.reportPath], ]; + if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]); + if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); + const qualityLabels: Record = { + accepted: "验收通过", + built_with_warnings: "构建完成,有警告", + needs_repair: "需要修复", + blocked: "已阻塞", + failed: "失败", + }; + const quality = String(data.qualityStatus || ""); return (
@@ -45,7 +56,12 @@ export function CadResultPart({ data }: { data: CadResult }) { {data.engine} {data.revisionId} {data.referenceIds.length} 个参考 + {quality ? {qualityLabels[quality] || quality} : null}
+ {data.assumptions?.length ?
假设{data.assumptions.join(";")}
: null} + {data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} + + {data.snapshotStatus && data.snapshotStatus !== "unavailable" ?
快照{data.snapshotStatus}
: null}
{downloads.map(([label, path]) => ( @@ -58,6 +74,44 @@ export function CadResultPart({ data }: { data: CadResult }) { ); } +type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown }; +type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string }; + +function QualitySummary({ data }: { data: CadResult }) { + const [quality, setQuality] = useState(null); + + useEffect(() => { + if (!data.qualityPath) { + setQuality(null); + return; + } + const controller = new AbortController(); + void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise : null) + .then((payload) => { if (!controller.signal.aborted) setQuality(payload); }) + .catch(() => { if (!controller.signal.aborted) setQuality(null); }); + return () => controller.abort(); + }, [data.qualityPath, data.revisionId, data.taskId]); + + const results = quality?.quality?.results || []; + if (!results.length) return null; + const label: Record = { passed: "通过", failed: "未通过", unavailable: "不可用" }; + return ( +
+ 验证 +
    + {results.map((result, index) => ( +
  • + {result.id || "rule"} + {label[result.status || ""] || result.status || "不可用"} + {result.severity && result.severity !== "blocking" ? {result.severity} : null} +
  • + ))} +
+
+ ); +} + export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; return ( diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx index f6b05729..4b0c3fde 100644 --- a/frontend/src/components/cad-viewer-preview.tsx +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -54,6 +54,13 @@ function resultFromBackend(payload: Record): CadResult { summary: String(payload.summary || "Updated CDSL model"), referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], engine: String(payload.engine || "cdsl_only"), + qualityStatus: String(payload.quality_status || ""), + qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined, + assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [], + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [], + repairAttempts: Number(payload.repair_attempts || 0), + snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [], + snapshotStatus: String(payload.snapshot_status || "unavailable"), }; } diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index ccf3aff8..600dca02 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -34,5 +34,12 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul summary: current.summary || "CDSL CAD model", referenceIds: current.reference_ids || [], engine: current.engine || "cdsl_only", + qualityStatus: current.quality_status || "", + qualityPath: current.quality_path, + assumptions: current.generation_assumptions || current.assumptions || [], + warnings: current.warnings || [], + repairAttempts: current.repair_attempts || 0, + snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [], + snapshotStatus: current.snapshot_status || "unavailable", }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index b7c490ff..3b441a6f 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -20,6 +20,13 @@ export type CadResult = { summary: string; referenceIds: string[]; engine: string; + qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string; + qualityPath?: string; + assumptions?: string[]; + warnings?: string[]; + repairAttempts?: number; + snapshotPaths?: string[]; + snapshotStatus?: string; }; export type CadError = { @@ -41,6 +48,7 @@ export type CadImageAnalysis = { dimensionCandidates?: CadImageDimension[]; // Legacy conversations stored this field before dimensions became optional. requiredDimensions?: CadImageDimension[]; + artifactPath?: string; }; export type CadDataParts = { @@ -85,6 +93,14 @@ export type TaskRevision = { reference_ids?: string[]; engine?: string; error?: string; + quality_status?: string; + quality_path?: string; + assumptions?: string[]; + generation_assumptions?: string[]; + warnings?: string[]; + repair_attempts?: number; + snapshot_manifest_path?: string; + snapshot_status?: string; }; export type TaskRecord = { @@ -104,4 +120,5 @@ export type BackendConfig = { model: string; configured: boolean; library_samples: number; + max_repair_attempts?: number; };