From 921ba5eab33e4c13e87e7a097dbeeb3b0453488e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Fri, 4 Sep 2026 11:16:23 +0800 Subject: [PATCH 1/2] change --- .../00-author-contract.md | 1 + .../01-brief-and-assumptions.md | 1 + .../02-parameters-and-derived-dimensions.md | 1 + .../03-coordinate-system-and-datums.md | 1 + .../04-construction-and-feature-order.md | 1 + .../05-profiles-workplanes-and-cuts.md | 1 + ...-hosted-features-selectors-and-topology.md | 1 + .../07-patterns-symmetry-and-repetition.md | 1 + .../08-finishing-and-boolean-risk.md | 1 + ...9-evidence-visual-review-and-validation.md | 1 + .../10-repair-and-best-effort.md | 1 + .../skills/cdsl-author-guidance/README.md | 63 ++ .../skills/cdsl-author-guidance/manifest.json | 56 + .../cdsl-author-guidance/op-extrude-add.md | 1 + .../cdsl-author-guidance/op-extrude-cut.md | 1 + .../skills/cdsl-author-guidance/op-finish.md | 1 + .../skills/cdsl-author-guidance/op-hole.md | 1 + .../skills/cdsl-author-guidance/op-pattern.md | 1 + .../cdsl-author-guidance/op-reference.md | 1 + .../skills/cdsl-author-guidance/op-revolve.md | 1 + .../skills/cdsl-author-guidance/op-sphere.md | 1 + .../app/cad_agent/adapters/author_guidance.py | 191 ++++ .../app/cad_agent/adapters/structured_llm.py | 81 +- backend/app/cad_agent/application/workflow.py | 52 +- backend/app/cad_agent/composition.py | 8 +- backend/app/cad_agent/evals/live.py | 209 +++- backend/app/cad_agent/ports.py | 49 +- backend/app/settings.py | 4 + backend/tests/test_author_guidance.py | 158 +++ .../tests/test_live_guidance_comparison.py | 71 ++ backend/tests/test_structured_llm.py | 123 +++ cadfs_to_cdsl/cli.py | 30 +- cadfs_to_cdsl/describe.py | 976 ++++++++++++++++++ cadfs_to_cdsl/tests/test_describe.py | 147 +++ .../cadfs_0005_模型描述.xlsx | Bin 0 -> 20236 bytes .../cadfs_0005_模型描述.xlsx.inspect.ndjson | 457 ++++++++ 36 files changed, 2665 insertions(+), 29 deletions(-) create mode 100644 backend/agent/skills/cdsl-author-guidance/00-author-contract.md create mode 100644 backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md create mode 100644 backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md create mode 100644 backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md create mode 100644 backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md create mode 100644 backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md create mode 100644 backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md create mode 100644 backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md create mode 100644 backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md create mode 100644 backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md create mode 100644 backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md create mode 100644 backend/agent/skills/cdsl-author-guidance/README.md create mode 100644 backend/agent/skills/cdsl-author-guidance/manifest.json create mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-add.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-finish.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-hole.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-pattern.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-reference.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-revolve.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-sphere.md create mode 100644 backend/app/cad_agent/adapters/author_guidance.py create mode 100644 backend/tests/test_author_guidance.py create mode 100644 backend/tests/test_live_guidance_comparison.py create mode 100644 backend/tests/test_structured_llm.py create mode 100644 cadfs_to_cdsl/describe.py create mode 100644 cadfs_to_cdsl/tests/test_describe.py create mode 100644 outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx create mode 100644 outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx.inspect.ndjson diff --git a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md new file mode 100644 index 00000000..92470134 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md @@ -0,0 +1 @@ +只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。 diff --git a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md new file mode 100644 index 00000000..1c850d90 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md @@ -0,0 +1 @@ +先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md new file mode 100644 index 00000000..035276fe --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md @@ -0,0 +1 @@ +把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。 diff --git a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md new file mode 100644 index 00000000..0e85183c --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md @@ -0,0 +1 @@ +世界坐标默认是右手 mm。根 `XY` 与 `+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X`,`normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件。 diff --git a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md new file mode 100644 index 00000000..28e2331d --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md @@ -0,0 +1 @@ +优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。 diff --git a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md new file mode 100644 index 00000000..289c3ecf --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md @@ -0,0 +1 @@ +轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。 diff --git a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md new file mode 100644 index 00000000..56109b58 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md @@ -0,0 +1 @@ +宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。 diff --git a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md new file mode 100644 index 00000000..6a1fa24b --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md @@ -0,0 +1 @@ +对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。 diff --git a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md new file mode 100644 index 00000000..3ab5a436 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md @@ -0,0 +1 @@ +圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。 diff --git a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md new file mode 100644 index 00000000..8183f587 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md @@ -0,0 +1 @@ +确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。 diff --git a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md new file mode 100644 index 00000000..145d9f5f --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md @@ -0,0 +1 @@ +修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。 diff --git a/backend/agent/skills/cdsl-author-guidance/README.md b/backend/agent/skills/cdsl-author-guidance/README.md new file mode 100644 index 00000000..96da5a74 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/README.md @@ -0,0 +1,63 @@ +# CDSL Author Guidance Corpus + +This corpus is a Chinese-first, non-authoritative author aid. The runtime +operation contract, fragment schema, topology/reference tokens, preflight and +verifier evidence always win over these Markdown files. The manifest maps +only workflow phase, scheduled atomic operation and repair state; it never +classifies the user's part request. + +## Source Migration + +| Source reference | CDSL target sections | Intentionally excluded | +| --- | --- | --- | +| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow | +| `parameters.md` | `02`, `07` | sidecars, animation, viewer control | +| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement | +| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source | +| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set | +| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax | +| `snapshot-review.md` | `09` | renderer commands | +| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax | +| `step-generation.md` | `00`, `09` | Python generator commands | +| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags | + +## Selection Contract + +- Requirements authoring selects `00` to `03`. +- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`. +- A scheduled feature selects `00`, `03` to `06`, `08`, and its current + operation appendix. +- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix. +- Final validation selects `00`, `09`, and `10`. + +At a bounded prompt budget, contract, coordinate/datum, and the scheduled +operation appendix are mandatory. Other sections are included in stable +priority order. A malformed corpus or an unsupported operation registry +falls back to the original short author prompt and records fallback metadata +with the author usage record. + +## Evaluation Commands + +Run the six matched control scenarios three times each, first without and +then with guidance: + +```bash +PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \ + --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ + --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ + --scenario double_hole_linkage_arm --scenario l_bracket + +PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \ + --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ + --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ + --scenario double_hole_linkage_arm --scenario l_bracket + +PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json +``` + +The comparator excludes declared validation gaps and engine-declared +`unsupported_*` capability gaps from prompt quality metrics, checks paired +model/runtime/contract/budget equivalence, and +requires the treatment's checkpoint/completion rates not to regress, median +author calls to stay within 10 percent, and either schema/decision or CDSL +expression failures to improve. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json new file mode 100644 index 00000000..74d07eff --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/manifest.json @@ -0,0 +1,56 @@ +{ + "schema_version": "cdsl.author-guidance.manifest.v1", + "version": "2026-09-03.1", + "sections": [ + {"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true}, + {"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false}, + {"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false}, + {"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true}, + {"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false}, + {"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false}, + {"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false}, + {"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false}, + {"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false}, + {"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false}, + {"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false}, + {"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true}, + {"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true}, + {"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true}, + {"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true}, + {"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true}, + {"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true}, + {"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true}, + {"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true} + ], + "phase_sections": { + "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], + "DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], + "REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], + "FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"], + "AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"] + }, + "repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "operation_sections": { + "extrude_add_blind": ["op-extrude-add"], + "extrude_add_two_sided": ["op-extrude-add"], + "extrude_cut_blind": ["op-extrude-cut"], + "revolve_add": ["op-revolve"], + "revolve_cut": ["op-revolve"], + "hole_blind": ["op-hole"], + "hole_counterbore": ["op-hole"], + "hole_countersink": ["op-hole"], + "hole_wizard": ["op-hole"], + "reference_plane": ["op-reference"], + "reference_axis": ["op-reference"], + "pattern_linear": ["op-pattern"], + "pattern_mirror": ["op-pattern"], + "fillet": ["op-finish"], + "chamfer": ["op-finish"], + "sphere_add": ["op-sphere"] + } +} diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md new file mode 100644 index 00000000..754cacb0 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md @@ -0,0 +1 @@ +`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md new file mode 100644 index 00000000..960bca12 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md @@ -0,0 +1 @@ +`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-finish.md b/backend/agent/skills/cdsl-author-guidance/op-finish.md new file mode 100644 index 00000000..728dc7c1 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-finish.md @@ -0,0 +1 @@ +`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-hole.md b/backend/agent/skills/cdsl-author-guidance/op-hole.md new file mode 100644 index 00000000..455e5be8 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-hole.md @@ -0,0 +1 @@ +孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-pattern.md b/backend/agent/skills/cdsl-author-guidance/op-pattern.md new file mode 100644 index 00000000..8bf48548 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-pattern.md @@ -0,0 +1 @@ +`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-reference.md b/backend/agent/skills/cdsl-author-guidance/op-reference.md new file mode 100644 index 00000000..20e1e31f --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-reference.md @@ -0,0 +1 @@ +`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-revolve.md b/backend/agent/skills/cdsl-author-guidance/op-revolve.md new file mode 100644 index 00000000..161997dc --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-revolve.md @@ -0,0 +1 @@ +`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-sphere.md b/backend/agent/skills/cdsl-author-guidance/op-sphere.md new file mode 100644 index 00000000..99c82b4b --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-sphere.md @@ -0,0 +1 @@ +`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。 diff --git a/backend/app/cad_agent/adapters/author_guidance.py b/backend/app/cad_agent/adapters/author_guidance.py new file mode 100644 index 00000000..4cae1ef5 --- /dev/null +++ b/backend/app/cad_agent/adapters/author_guidance.py @@ -0,0 +1,191 @@ +"""Bounded, file-backed author guidance for the CDSL workflow. + +The corpus is deliberately non-authoritative: contracts, schemas, topology +tokens, and server preflight always remain the executable source of truth. +Loading errors return an empty selection so authoring continues with the +pre-guidance prompt instead of turning documentation into an availability +dependency. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from app.cad_agent.domain.state import TaskPhase +from app.cad_agent.ports import AuthorGuidanceSelection + + +_MIN_CHARS = 1_200 +_MAX_CHARS = 6_000 + + +@dataclass(frozen=True, slots=True) +class _Section: + section_id: str + title: str + priority: int + mandatory: bool + content: str + + @property + def block(self) -> str: + return f"## {self.title}\n{self.content.strip()}" + + +@dataclass(frozen=True, slots=True) +class _Corpus: + version: str + sections: dict[str, _Section] + phase_sections: dict[str, tuple[str, ...]] + repair_sections: tuple[str, ...] + final_sections: tuple[str, ...] + operation_sections: dict[str, tuple[str, ...]] + + +class FileAuthorGuidance: + """Read and select the checked-in corpus deterministically. + + Selection depends exclusively on workflow state and the runtime operation + registry. It intentionally receives neither the user's request nor image + observations, so it cannot become an implicit part-family classifier. + """ + + def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None: + self.root = root + self.enabled = enabled + self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars)) + self._corpus: _Corpus | None = None + self._load_error = "" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: + if not self.enabled: + return AuthorGuidanceSelection(fallback_reason="guidance_disabled") + corpus = self._load() + if corpus is None: + return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable") + supported = set(supported_atomic_ids) + if set(corpus.operation_sections) != supported: + return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch") + if atomic_id and atomic_id not in supported: + return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id") + + if repair_required: + requested = list(corpus.repair_sections) + elif phase == TaskPhase.FINAL_VALIDATION: + requested = list(corpus.final_sections) + else: + requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ()))) + if atomic_id: + requested.extend(corpus.operation_sections[atomic_id]) + requested = list(dict.fromkeys(requested)) + if not requested: + return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections") + + mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory] + optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory] + optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id))) + selected: list[str] = [] + text = "" + for section_id in [*mandatory, *optional]: + block = corpus.sections[section_id].block + candidate = block if not text else f"{text}\n\n{block}" + if len(candidate) <= self.max_chars: + text = candidate + selected.append(section_id) + elif section_id in mandatory: + # Do not silently drop contract, datum, or operation guidance. + return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget") + return AuthorGuidanceSelection( + version=corpus.version, + section_ids=tuple(selected), + content=text, + enabled=True, + ) + + def _load(self) -> _Corpus | None: + if self._corpus is not None: + return self._corpus + if self._load_error: + return None + try: + manifest_path = self.root / "manifest.json" + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("manifest is not an object") + version = raw.get("version") + if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version: + raise ValueError("manifest version is invalid") + raw_sections = raw.get("sections") + if not isinstance(raw_sections, list) or not raw_sections: + raise ValueError("manifest sections are invalid") + sections: dict[str, _Section] = {} + root = self.root.resolve() + for item in raw_sections: + if not isinstance(item, dict): + raise ValueError("section declaration is invalid") + section_id = item.get("id") + filename = item.get("file") + title = item.get("title") + priority = item.get("priority") + mandatory = item.get("mandatory", False) + if ( + not isinstance(section_id, str) or not section_id + or not isinstance(filename, str) or not filename + or not isinstance(title, str) or not title + or not isinstance(priority, int) or isinstance(priority, bool) + or not isinstance(mandatory, bool) + or section_id in sections + ): + raise ValueError("section metadata is invalid") + path = (self.root / filename).resolve() + if root not in path.parents or not path.is_file(): + raise ValueError("section file is unavailable") + content = path.read_text(encoding="utf-8").strip() + if not content: + raise ValueError("section content is empty") + sections[section_id] = _Section(section_id, title, priority, mandatory, content) + + def identifiers(value: Any, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value): + raise ValueError(f"{field} is invalid") + return tuple(dict.fromkeys(value)) + + raw_phases = raw.get("phase_sections") + if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases: + raise ValueError("phase sections are invalid") + phase_sections = { + phase: identifiers(section_ids, f"phase {phase}") + for phase, section_ids in raw_phases.items() + if isinstance(phase, str) + } + if len(phase_sections) != len(raw_phases): + raise ValueError("phase name is invalid") + operation_sections = { + atomic_id: identifiers(section_ids, f"operation {atomic_id}") + for atomic_id, section_ids in (raw.get("operation_sections") or {}).items() + if isinstance(atomic_id, str) + } + if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}): + raise ValueError("operation sections are invalid") + self._corpus = _Corpus( + version=version, + sections=sections, + phase_sections=phase_sections, + repair_sections=identifiers(raw.get("repair_sections"), "repair sections"), + final_sections=identifiers(raw.get("final_sections"), "final sections"), + operation_sections=operation_sections, + ) + return self._corpus + except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: + self._load_error = f"guidance_load_failed:{type(error).__name__}" + return None diff --git a/backend/app/cad_agent/adapters/structured_llm.py b/backend/app/cad_agent/adapters/structured_llm.py index 9d1f1054..53532ef8 100644 --- a/backend/app/cad_agent/adapters/structured_llm.py +++ b/backend/app/cad_agent/adapters/structured_llm.py @@ -46,13 +46,67 @@ class StructuredModelGateway: async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]: provider, model = self._provider_model(provider_id, model_id) - payload = self._payload(provider, model.id, messages, [tool], required_tool_name) - response = await self._request(provider, payload) + response, compatibility_mode = await self._request_tool_call( + provider, model.id, messages, [tool], required_tool_name, + ) try: - return self._normalized_response(provider, response) + normalized = self._normalized_response(provider, response) + normalized["usage"]["structured_compatibility_mode"] = compatibility_mode + return normalized except (KeyError, TypeError, ValueError) as error: raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error + async def _request_tool_call( + self, + provider: ProviderConfig, + model_id: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + required_tool_name: str, + ) -> tuple[dict[str, Any], str]: + """Keep forced tool choice unless a provider explicitly rejects thinking mode. + + Some OpenAI-compatible gateways reject an otherwise valid forced + ``tool_choice`` whenever reasoning is enabled or implicit. This is a + request compatibility issue rather than an author error. First retry + without the client-requested reasoning option; only if that exact + rejection remains do we allow automatic choice among the single tool + already exposed to the model. The workflow's one-call/schema checks + still reject free-form or wrong-tool responses. + """ + try: + return await self._request( + provider, + self._payload(provider, model_id, messages, tools, required_tool_name), + ), "forced" + except StructuredModelError as first_error: + if not self._thinking_tool_choice_rejection(first_error): + raise + try: + return await self._request( + provider, + self._payload( + provider, model_id, messages, tools, required_tool_name, + include_reasoning=False, + ), + ), "reasoning_disabled" + except StructuredModelError as second_error: + if not self._thinking_tool_choice_rejection(second_error): + raise + return await self._request( + provider, + self._payload( + provider, model_id, messages, tools, required_tool_name, + include_reasoning=False, + force_tool_name=False, + ), + ), "single_tool_auto" + + @staticmethod + def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool: + message = str(error).casefold() + return "thinking" in message and ("tool_choice" in message or "tool choice" in message) + async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: failures: list[dict[str, str]] = [] usage = {"prompt_tokens": 0, "completion_tokens": 0} @@ -190,20 +244,31 @@ class StructuredModelGateway: }) return values - def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]: + def _payload( + self, + provider: ProviderConfig, + model_id: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + required_tool_name: str, + *, + include_reasoning: bool = True, + force_tool_name: bool = True, + ) -> dict[str, Any]: if len(tools) != 1 or not required_tool_name: raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.") + request_options = provider.request_options if include_reasoning else {} if provider.api_style == "responses": payload: dict[str, Any] = { "model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools), - "tool_choice": {"type": "function", "name": required_tool_name}, - **provider.request_options, + "tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto", + **request_options, } return payload return { "model": model_id, "messages": messages, "tools": tools, - "tool_choice": {"type": "function", "function": {"name": required_tool_name}}, - "temperature": 0, **provider.request_options, + "tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto", + "temperature": 0, **request_options, } @staticmethod diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index f92e33d0..73e9839b 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -33,7 +33,17 @@ from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash from app.cad_agent.domain.errors import ErrorCode, WorkflowError from app.cad_agent.domain.operation_contract import fragment_schema from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition -from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository +from app.cad_agent.ports import ( + AdapterUnavailable, + ArtifactStore, + AuthorGuidance, + AuthorGuidanceSelection, + CadRuntime, + ModelGateway, + NullAuthorGuidance, + ReviewGateway, + TaskRepository, +) T = TypeVar("T", bound=BaseModel) @@ -116,6 +126,7 @@ class WorkflowCoordinator: review_gateway: ReviewGateway, requirements: RequirementsCommandHandler, actions: ActionCommandHandler, + author_guidance: AuthorGuidance | None = None, ) -> None: self.config = config self.repository = repository @@ -125,6 +136,7 @@ class WorkflowCoordinator: self.review_gateway = review_gateway self.requirements = requirements self.actions = actions + self.author_guidance = author_guidance or NullAuthorGuidance() def create_task( self, @@ -1078,14 +1090,14 @@ class WorkflowCoordinator: "message": message[:1000], } - async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError: + async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError: if len(tools) != 1: raise RuntimeError("Workflow state must expose exactly one author tool.") tool = tools[0] name = str((tool.get("function") or {}).get("name") or "") if not name: raise RuntimeError("Workflow exposed an unnamed author tool.") - messages = self._author_context(task_id, feedback) + messages, guidance = self._author_context(task_id, feedback) try: response = await self.model_gateway.call_tool( messages=messages, tool=tool, provider_id=author.provider_id, @@ -1103,10 +1115,26 @@ class WorkflowCoordinator: "model_id": author.model_id, "retry_reason": "provider_unavailable", "cache_hit": False, + **guidance.usage_metadata(), }) return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True) call = validate_one_tool_call(response["tool_calls"], name) if isinstance(call, WorkflowError): + # A provider response is still a billable author attempt even when + # it violates the one-tool-call protocol. Keep guidance audit + # metadata on that record so A/B reports do not silently omit the + # failures this corpus is intended to reduce. + self.repository.record_usage(task_id, { + **response["usage"], + "context_chars": len(json.dumps(messages, ensure_ascii=False)), + "tool": name, + "provider_id": author.provider_id, + "model_id": author.model_id, + "raw_arguments_hash": "", + "retry_reason": "invalid_tool_call", + "cache_hit": False, + **guidance.usage_metadata(), + }) self._record_rejected_tool_calls( task_id, actor="author", @@ -1132,6 +1160,7 @@ class WorkflowCoordinator: "raw_arguments_hash": raw_arguments_hash(raw), "retry_reason": "", "cache_hit": False, + **guidance.usage_metadata(), } self.repository.record_usage(task_id, usage) return name, raw, usage @@ -1400,10 +1429,10 @@ class WorkflowCoordinator: return [] return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] - def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]: + def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]: state = self.repository.get_state(task_id) if state is None: - return [] + return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable") if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: content = { "protocol": "cad.v3.2.feature-dag", @@ -1557,8 +1586,17 @@ class WorkflowCoordinator: "feature_plan_hash": state.feature_plan_hash, "feature_node_statuses": self._feature_node_statuses(task_id, None), }) - messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] - return [*messages, *feedback[-2:]] + guidance = self.author_guidance.select( + phase=state.phase, + atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "", + repair_required=state.repair_required, + supported_atomic_ids=self.runtime.supported_atomic_ids(), + ) + system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON." + if guidance.content: + system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content + messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] + return [*messages, *feedback[-2:]], guidance def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: clarifications: list[dict[str, str]] = [] diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index d8f99944..13287c9c 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import shutil from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.author_guidance import FileAuthorGuidance from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.runtime import ProfileCadRuntime from app.cad_agent.adapters.review_gateway import RenderedReviewGateway @@ -17,7 +18,7 @@ from app.cad_agent.application.outbox import OutboxDispatcher from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator from app.cad_agent.domain.verifier_registry import default_registry -from app.settings import Settings +from app.settings import BACKEND_ROOT, Settings from app.services.storage import WorkspaceStore @@ -65,5 +66,10 @@ def compose_v3(settings: Settings) -> V3Services: RenderedReviewGateway(models), requirements, actions, + FileAuthorGuidance( + BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance", + enabled=settings.agent_author_guidance_enabled, + max_chars=settings.agent_author_guidance_max_chars, + ), ) return V3Services(repository, artifacts, workflow, models, outbox) diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py index 7b6656ee..593767b1 100644 --- a/backend/app/cad_agent/evals/live.py +++ b/backend/app/cad_agent/evals/live.py @@ -15,6 +15,7 @@ from hashlib import sha256 import json from pathlib import Path import secrets +from statistics import median import subprocess import sys from typing import Any @@ -100,12 +101,15 @@ def _arguments() -> argparse.Namespace: parser.add_argument("--author-model") parser.add_argument("--review-provider") parser.add_argument("--review-model") - parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.") + parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.") + parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.") + parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.") + parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.") parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.") return parser.parse_args() -def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]: +def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]: fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json" value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8")) if fixture_name == "comprehensive.json": @@ -122,11 +126,14 @@ def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]] raise ValueError("Comprehensive fixture is not synchronized with its source document.") values = [item for item in value.get("scenarios") or () if isinstance(item, dict)] values = values[:2] if suite == "smoke" else values - if scenario_id is None: + if not scenario_ids: return values - selected = [item for item in values if str(item.get("id") or "") == scenario_id] - if not selected: - raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}") + requested = list(dict.fromkeys(scenario_ids)) + available = {str(item.get("id") or "") for item in values} + unknown = [scenario_id for scenario_id in requested if scenario_id not in available] + if unknown: + raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}") + selected = [item for item in values if str(item.get("id") or "") in set(requested)] return selected @@ -567,12 +574,180 @@ def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]: return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files} +def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]: + """Summarize author-only guidance audit metadata without retaining prompts.""" + records = [ + item for item in usage.get("records") or () + if isinstance(item, dict) and item.get("role") != "reviewer" + ] + sections = sorted({ + section_id + for item in records + for section_id in item.get("guidance_section_ids") or () + if isinstance(section_id, str) + }) + versions = sorted({ + str(item.get("guidance_version") or "") + for item in records + if str(item.get("guidance_version") or "") + }) + return { + "enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records), + "versions": versions, + "section_ids": sections, + "chars_total": sum(int(item.get("guidance_chars") or 0) for item in records), + "fallback_reasons": sorted({ + str(item.get("guidance_fallback_reason") or "") + for item in records + if str(item.get("guidance_fallback_reason") or "") + }), + } + + +def _has_unsupported_capability(row: dict[str, Any]) -> bool: + """Recognize engine-declared unsupported capability without hiding model errors.""" + for event in row.get("ledger") or (): + if not isinstance(event, dict): + continue + for failure in event.get("operation_failures") or (): + if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""): + return True + if "unsupported_" in str(event.get("message") or ""): + return True + return False + + +def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + results = [item for item in report.get("results") or () if isinstance(item, dict)] + capability_gaps = [ + item for item in results + if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item) + ] + return [item for item in results if item not in capability_gaps], capability_gaps + + +def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]: + if not rows: + return {"eligible_runs": 0} + author_calls = [ + sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + context_chars = [ + sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + prompt_tokens = [ + sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + failure_layers: dict[str, int] = {} + for row in rows: + layer = str((row.get("failure_attribution") or {}).get("layer") or "passed") + failure_layers[layer] = failure_layers.get(layer, 0) + 1 + def rate(predicate: Any) -> float: + return sum(1 for row in rows if predicate(row)) / len(rows) + return { + "eligible_runs": len(rows), + "executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))), + "completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"), + "deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))), + "schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows), + "cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"), + "median_author_calls": median(author_calls), + "median_author_context_chars": median(context_chars), + "total_author_prompt_tokens": sum(prompt_tokens), + "median_author_prompt_tokens": median(prompt_tokens), + "failure_layers": dict(sorted(failure_layers.items())), + } + + +def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]: + """Compare paired guidance-off/on runs without treating engine gaps as prompt results.""" + control_rows, control_gaps = _guidance_metric_rows(control) + treatment_rows, treatment_gaps = _guidance_metric_rows(treatment) + control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows} + treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows} + paired = sorted(set(control_by_key).intersection(treatment_by_key)) + control_only = sorted(set(control_by_key).difference(treatment_by_key)) + treatment_only = sorted(set(treatment_by_key).difference(control_by_key)) + control_pairs = [control_by_key[key] for key in paired] + treatment_pairs = [treatment_by_key[key] for key in paired] + control_metrics = _guidance_metrics(control_pairs) + treatment_metrics = _guidance_metrics(treatment_pairs) + same_runtime = ( + control.get("author") == treatment.get("author") + and control.get("reviewer") == treatment.get("reviewer") + and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256") + and control.get("operation_contracts") == treatment.get("operation_contracts") + ) + control_guidance = bool((control.get("author_guidance") or {}).get("enabled")) + treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled")) + same_budgets = all( + control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget") + for key in paired + ) + calls_control = control_metrics.get("median_author_calls") + calls_treatment = treatment_metrics.get("median_author_calls") + calls_within_limit = ( + isinstance(calls_control, (int, float)) + and isinstance(calls_treatment, (int, float)) + and calls_treatment <= calls_control * 1.10 + ) + improved = ( + treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0) + or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0) + ) + gates = { + "complete_pairing": bool(paired) and not control_only and not treatment_only, + "control_off_treatment_on": not control_guidance and treatment_guidance, + "same_author_reviewer_runtime_and_contracts": same_runtime, + "same_per_scenario_budgets": same_budgets, + "checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0), + "completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0), + "median_author_calls_within_ten_percent": calls_within_limit, + "model_or_cdsl_failure_improved": improved, + } + return { + "schema_version": "cad.author-guidance-comparison.v1", + "status": "passed" if all(gates.values()) else "failed", + "gates": gates, + "paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired], + "unpaired_runs": { + "control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only], + "treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only], + }, + "control": control_metrics, + "treatment": treatment_metrics, + "excluded_capability_gaps": { + "control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps], + "treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps], + }, + } + + +def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]: + try: + control = json.loads(control_path.read_text(encoding="utf-8")) + treatment = json.loads(treatment_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"} + if not isinstance(control, dict) or not isinstance(treatment, dict): + return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"} + return compare_guidance_reports(control, treatment) + + async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: try: - scenarios = _fixture(arguments.suite, arguments.scenario) + scenarios = _fixture(arguments.suite, arguments.scenarios) except ValueError as error: return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} + repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1 + if repetitions < 1: + return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"} settings = get_settings() + if arguments.author_guidance is not None: + settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on") try: author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model) if arguments.review_provider or arguments.review_model: @@ -628,7 +803,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An } results: list[dict[str, Any]] = [] for scenario in scenarios: - for repetition in range(1, 4 if arguments.suite == "release" else 2): + for repetition in range(1, repetitions + 1): services.workflow.config = replace( services.workflow.config, # State transitions include local candidate recovery, so the @@ -716,6 +891,13 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "terminal": terminal, "projection": projection, "usage": usage, "checks": checks, "acceptance_coverage": acceptance, "failure_attribution": failure_attribution, + "guidance": _guidance_metadata(usage), + "scenario_budget": { + "max_author_turns": int(scenario["max_author_turns"]), + "max_reviewer_turns": int(scenario["max_reviewer_turns"]), + "max_total_calls": int(scenario["max_total_calls"]), + "max_total_tokens": int(scenario["max_total_tokens"]), + }, "rejection_codes": _rejection_codes(events), "schema_rejection_count": len(_rejection_codes(events)), "retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"), @@ -745,6 +927,10 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "author": {"provider": author_provider.id, "model": author_model.id}, "author_request_identity": current_author_identity, "reviewer": {"provider": review_provider.id, "model": review_model.id}, + "author_guidance": { + "enabled": settings.agent_author_guidance_enabled, + "max_chars": settings.agent_author_guidance_max_chars, + }, "author_capability": author_capability, "reviewer_capability": reviewer_capability, "structured_output_mode": { @@ -759,6 +945,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "verifier_schema_hash": verifier_schema_hash, "token_comparison": token_comparison, "baseline_report": str(baseline_path) if baseline_path else "", + "repetitions": repetitions, "results": results, } @@ -768,7 +955,11 @@ def main() -> int: report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") report_root.mkdir(parents=True, exist_ok=True) try: - result = asyncio.run(_run(arguments, report_root)) + result = ( + compare_guidance_report_paths(*arguments.compare_guidance_reports) + if arguments.compare_guidance_reports + else asyncio.run(_run(arguments, report_root)) + ) except KeyboardInterrupt: # Let an explicit operator interruption retain its normal CLI # semantics. An external kill cannot be reported reliably either. diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index 5c8809bc..dc88cff5 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -5,13 +5,60 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol -from .domain.state import TaskState +from .domain.state import TaskPhase, TaskState class AdapterUnavailable(RuntimeError): """A bounded external-service outage; handlers must preserve checkpoints.""" +@dataclass(frozen=True, slots=True) +class AuthorGuidanceSelection: + """Non-authoritative author context selected from the local guidance corpus.""" + + version: str = "" + section_ids: tuple[str, ...] = () + content: str = "" + enabled: bool = False + fallback_reason: str = "" + + def usage_metadata(self) -> dict[str, object]: + return { + "guidance_version": self.version, + "guidance_section_ids": list(self.section_ids), + "guidance_chars": len(self.content), + "guidance_enabled": self.enabled, + "guidance_fallback_reason": self.fallback_reason, + } + + +class AuthorGuidance(Protocol): + """Select bounded local author guidance without interpreting user intent.""" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: ... + + +class NullAuthorGuidance: + """Compatibility default that retains the pre-guidance author prompt.""" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: + return AuthorGuidanceSelection(fallback_reason="guidance_not_configured") + + @dataclass(frozen=True, slots=True) class InvocationRecord: invocation_id: str diff --git a/backend/app/settings.py b/backend/app/settings.py index 8eafe639..49a6c07d 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -71,6 +71,8 @@ class Settings: agent_consecutive_no_progress_limit: int = 6 agent_format_error_repeat_limit: int = 3 agent_context_char_limit: int = 14000 + agent_author_guidance_enabled: bool = True + agent_author_guidance_max_chars: int = 3600 agent_render_cache: bool = True autonomous_generation: bool = True resume_running_tasks_on_startup: bool = True @@ -211,6 +213,8 @@ def get_settings() -> Settings: agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))), agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))), agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))), + agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True), + agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))), agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), autonomous_generation=True, # Production instances recover durable runs by default. Test workers diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py new file mode 100644 index 00000000..3ba6d541 --- /dev/null +++ b/backend/tests/test_author_guidance.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.adapters.author_guidance import FileAuthorGuidance # noqa: E402 +from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402 +from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402 +from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402 + + +GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance" +PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json" + + +def atomic_ids() -> tuple[str, ...]: + return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"]) + + +class _Repository: + def __init__(self, state: TaskState) -> None: + self.state = state + self.usage_records: list[dict] = [] + + def get_state(self, _task_id: str) -> TaskState: + return self.state + + def ledger_events(self, _task_id: str) -> list[dict]: + return [] + + def record_usage(self, _task_id: str, payload: dict) -> None: + self.usage_records.append(payload) + + def record_tool_audit(self, _task_id: str, _payload: dict) -> None: + pass + + +class _Artifacts: + def read_source_requirements(self, _task_id: str) -> str: + return "Create a symmetric mounting plate." + + def read_json(self, *_args: object) -> None: + return None + + +class _Runtime: + def supported_atomic_ids(self) -> tuple[str, ...]: + return atomic_ids() + + +class AuthorGuidanceTests(unittest.TestCase): + def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None: + guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200) + covered: set[str] = set() + for atomic_id in atomic_ids(): + selection = guidance.select( + phase=TaskPhase.FEATURE_PENDING, + atomic_id=atomic_id, + repair_required=False, + supported_atomic_ids=atomic_ids(), + ) + self.assertTrue(selection.enabled, selection.fallback_reason) + self.assertIn("00-author-contract", selection.section_ids) + self.assertIn("03-coordinate-system-and-datums", selection.section_ids) + self.assertLessEqual(len(selection.content), 1_200) + self.assertIn("世界坐标", selection.content) + covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) + self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere"}) + + def test_phase_repair_and_budget_selection_are_stable(self) -> None: + guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) + planning = guidance.select( + phase=TaskPhase.COMPILING_FEATURE_PLAN, + atomic_id="", + repair_required=False, + supported_atomic_ids=atomic_ids(), + ) + repair = guidance.select( + phase=TaskPhase.AWAITING_ACTION, + atomic_id="fillet", + repair_required=True, + supported_atomic_ids=atomic_ids(), + ) + self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums")) + self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids) + self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish")) + self.assertIn("10-repair-and-best-effort", repair.section_ids) + + def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None: + common = { + "phase": TaskPhase.FEATURE_PENDING, + "atomic_id": "extrude_add_blind", + "repair_required": False, + "supported_atomic_ids": atomic_ids(), + } + self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError") + (root / "manifest.json").write_text("{}", encoding="utf-8") + self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError") + + def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) + workflow = WorkflowCoordinator( + WorkflowConfig(max_turns=8, format_error_limit=2), + _Repository(state), + _Artifacts(), + _Runtime(), + object(), + object(), + object(), + object(), + FileAuthorGuidance(GUIDANCE_ROOT), + ) + messages, selection = workflow._author_context(state.task_id, []) + system = str(messages[0]["content"]) + self.assertTrue(selection.enabled) + self.assertIn("Use exactly one offered structured tool call", system) + self.assertIn("Coordinate System And Datums", system) + self.assertIn("世界坐标", system) + + def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) + repository = _Repository(state) + class _Models: + async def call_tool(self, **_kwargs: object) -> dict: + return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}} + workflow = WorkflowCoordinator( + WorkflowConfig(max_turns=8, format_error_limit=2), + repository, + _Artifacts(), + _Runtime(), + _Models(), + object(), + object(), + object(), + FileAuthorGuidance(GUIDANCE_ROOT), + ) + tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}} + result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], [])) + self.assertIsInstance(result, WorkflowError) + self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID) + self.assertEqual(repository.usage_records[0]["guidance_enabled"], True) + self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"]) + self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_live_guidance_comparison.py b/backend/tests/test_live_guidance_comparison.py new file mode 100644 index 00000000..eb1224e0 --- /dev/null +++ b/backend/tests/test_live_guidance_comparison.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402 + + +def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict: + return { + "scenario": scenario, + "repetition": repetition, + "outcome": "passed", + "revision_ids": ["revision_001"], + "projection": {"phase": "COMPLETED"}, + "checks": {"deterministic_claims_pass": True}, + "schema_rejection_count": schema_rejections, + "failure_attribution": {"layer": failure_layer} if failure_layer else None, + "scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000}, + "usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]}, + } + + +def _report(results: list[dict]) -> dict: + return { + "author": {"provider": "author", "model": "model"}, + "reviewer": {"provider": "reviewer", "model": "review"}, + "runtime_profile_sha256": "a" * 64, + "operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}], + "author_guidance": {"enabled": False, "max_chars": 3600}, + "results": results, + } + + +class LiveGuidanceComparisonTests(unittest.TestCase): + def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None: + selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"]) + self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"]) + + def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None: + control = _report([ + _result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"), + _result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1), + ]) + treatment = _report([ + _result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0), + _result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0), + ]) + treatment["author_guidance"]["enabled"] = True + comparison = compare_guidance_reports(control, treatment) + self.assertEqual(comparison["status"], "passed") + self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"]) + self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"]) + + def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None: + control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)]) + treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)]) + treatment["author_guidance"]["enabled"] = True + treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}] + comparison = compare_guidance_reports(control, treatment) + self.assertEqual(comparison["treatment"]["eligible_runs"], 0) + self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_structured_llm.py b/backend/tests/test_structured_llm.py new file mode 100644 index 00000000..ccb50777 --- /dev/null +++ b/backend/tests/test_structured_llm.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.adapters.structured_llm import StructuredModelError, StructuredModelGateway # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +def _settings(*, api_style: str = "responses", reasoning_effort: str = "medium") -> Settings: + provider = ProviderConfig( + "provider", "Provider", "https://example.invalid/v1", "key", + (ProviderModel("model"),), reasoning_effort=reasoning_effort, api_style=api_style, + ) + return Settings( + task_root=ROOT / "tmp-tasks", + conversation_root=ROOT / "tmp-conversations", + library_root=ROOT / "backend" / "cdsl_library", + engine_root=ROOT / "backend" / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="model", + llm_timeout_s=1, + default_provider_id="provider", + providers=(provider,), + ) + + +def _tool() -> dict: + return { + "type": "function", + "function": { + "name": "write_document", + "description": "Write one document.", + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + } + + +def _response(api_style: str) -> dict: + if api_style == "responses": + return { + "output": [{"type": "function_call", "name": "write_document", "arguments": "{}"}], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + } + return { + "choices": [{"message": {"tool_calls": [{"function": {"name": "write_document", "arguments": "{}"}}]}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +class StructuredModelGatewayCompatibilityTests(unittest.TestCase): + def test_thinking_tool_choice_retries_without_reasoning_before_relaxing_tool_choice(self) -> None: + gateway = StructuredModelGateway(_settings()) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + if len(payloads) == 1: + raise StructuredModelError("Provider rejected structured request (400): Thinking mode does not support this tool_choice") + return _response("responses") + + gateway._request = request # type: ignore[method-assign] + result = asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + + self.assertEqual(len(payloads), 2) + self.assertEqual(payloads[0]["tool_choice"], {"type": "function", "name": "write_document"}) + self.assertEqual(payloads[0]["reasoning"], {"effort": "medium"}) + self.assertEqual(payloads[1]["tool_choice"], {"type": "function", "name": "write_document"}) + self.assertNotIn("reasoning", payloads[1]) + self.assertEqual(result["usage"]["structured_compatibility_mode"], "reasoning_disabled") + + def test_persistent_thinking_rejection_uses_auto_with_the_same_single_tool(self) -> None: + gateway = StructuredModelGateway(_settings(api_style="chat_completions", reasoning_effort="")) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + if len(payloads) < 3: + raise StructuredModelError("Thinking mode does not support this tool_choice") + return _response("chat_completions") + + gateway._request = request # type: ignore[method-assign] + result = asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + + self.assertEqual(len(payloads), 3) + self.assertEqual(payloads[0]["tool_choice"]["function"]["name"], "write_document") + self.assertEqual(payloads[1]["tool_choice"]["function"]["name"], "write_document") + self.assertEqual(payloads[2]["tool_choice"], "auto") + self.assertEqual(len(payloads[2]["tools"]), 1) + self.assertEqual(result["usage"]["structured_compatibility_mode"], "single_tool_auto") + + def test_unrelated_provider_rejection_is_not_retried(self) -> None: + gateway = StructuredModelGateway(_settings()) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + raise StructuredModelError("Provider rejected structured request (400): invalid model") + + gateway._request = request # type: ignore[method-assign] + with self.assertRaisesRegex(StructuredModelError, "invalid model"): + asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + self.assertEqual(len(payloads), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py index 0374c8c0..9bcc1777 100644 --- a/cadfs_to_cdsl/cli.py +++ b/cadfs_to_cdsl/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse, json from pathlib import Path +from .describe import describe_samples from .pipeline import load_samples, run_stage, scan, select_samples from .reports import generate_markdown_report, generate_reports, read_json @@ -12,11 +13,20 @@ DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output") def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP") commands = parser.add_subparsers(dest="command", required=True) - for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline"): + for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe"): command = commands.add_parser(name) command.add_argument("--input", type=Path, default=DEFAULT_INPUT) command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - if name != "scan": + if name == "describe": + command.add_argument("--shard", help="input shard directory to describe, for example 0005") + command.add_argument("--sample-id", action="append") + command.add_argument("--offset", type=int, default=0) + command.add_argument("--limit", type=int) + command.add_argument("--seed", type=int) + command.add_argument("--workers", type=int, default=1) + command.add_argument("--mode", choices=("local", "hybrid", "vision"), default="hybrid") + command.add_argument("--force", action="store_true") + elif name != "scan": command.add_argument("--sample-id", action="append") command.add_argument("--offset", type=int, default=0) command.add_argument("--limit", type=int) @@ -31,9 +41,23 @@ def _parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv); args.output.mkdir(parents=True, exist_ok=True) + args = _parser().parse_args(argv) + if args.command != "describe": + args.output.mkdir(parents=True, exist_ok=True) if args.command == "scan": records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")} + elif args.command == "describe": + records, result = describe_samples( + args.input, + shard=args.shard, + sample_ids=args.sample_id, + mode=args.mode, + offset=args.offset, + limit=args.limit, + seed=args.seed, + force=args.force, + workers=args.workers, + ) elif args.command == "report": manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()] result = generate_reports(args.output, records) diff --git a/cadfs_to_cdsl/describe.py b/cadfs_to_cdsl/describe.py new file mode 100644 index 00000000..0ded9d08 --- /dev/null +++ b/cadfs_to_cdsl/describe.py @@ -0,0 +1,976 @@ +from __future__ import annotations + +import asyncio +import base64 +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +import json +import math +from pathlib import Path +import random +import re +import struct +import threading +from typing import Any, Protocol + +from .dataset import Sample, scan_dataset +from .featurescript_parser import parse_featurescript +from .lowering import _number, _point, plain + + +DESCRIPTION_SCHEMA_VERSION = "cadfs_to_cdsl.description.v1" +MANIFEST_NAME = "description_manifest.jsonl" + +ENTITY_LABELS = { + "skLineSegment": "线段", + "skCircle": "圆", + "skArc": "圆弧", + "skEllipse": "椭圆", + "skFitSpline": "样条", + "skPoint": "点", +} + +OPERATION_LABELS = { + "extrude": "拉伸", + "revolve": "旋转", + "fillet": "圆角", + "chamfer": "倒角", + "hole": "孔", + "linearPattern": "线性阵列", + "circularPattern": "圆周阵列", + "mirror": "镜像", + "shell": "抽壳", + "loft": "放样", + "sweep": "扫掠", + "booleanBodies": "布尔", + "cPlane": "参考平面", + "referenceAxis": "参考轴", +} + +VISION_DESCRIPTION_TOOL = { + "type": "function", + "function": { + "name": "describe_cad_model", + "description": "Return a conservative semantic and geometric description for one CAD model.", + "parameters": { + "type": "object", + "additionalProperties": False, + "required": [ + "category", + "category_confidence", + "candidate_names", + "summary_zh", + "possible_functions", + "applications", + "structural_features", + "geometric_features", + "keywords_zh", + "keywords_en", + "uncertainties", + ], + "properties": { + "category": {"type": "string", "minLength": 1, "maxLength": 160}, + "category_confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "candidate_names": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 120}, "maxItems": 8}, + "summary_zh": {"type": "string", "minLength": 1, "maxLength": 1200}, + "possible_functions": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8}, + "applications": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8}, + "structural_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16}, + "geometric_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16}, + "keywords_zh": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32}, + "keywords_en": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32}, + "uncertainties": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 12}, + }, + }, + }, +} + + +class VisionDescriptionClient(Protocol): + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + ... + + +class ConfiguredVisionDescriptionClient: + def __init__(self) -> None: + from app.cad_agent.adapters.structured_llm import StructuredModelGateway + from app.settings import get_settings + + self.settings = get_settings() + provider, model = self.settings.resolve_review_model() + if not model.vision: + raise ValueError("configured review model is not vision-capable") + self.provider_id = provider.id + self.model_id = model.id + self.gateway = StructuredModelGateway(self.settings) + + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop and loop.is_running(): + raise RuntimeError("vision description cannot run inside an active event loop") + return asyncio.run(self._describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts)) + + async def _describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + payload = { + "sample_id": sample_id, + "local_category": local_facts.get("category"), + "local_candidate_names": local_facts.get("candidate_names"), + "local_geometric_features": local_facts.get("geometric_features"), + "local_operations": local_facts.get("operations"), + "local_dimensions": local_facts.get("dimensions"), + "annotation_excerpt": str(local_facts.get("annotation_excerpt") or "")[:4000], + "instruction": ( + "Use the image and deterministic CAD facts to identify likely model class and retrieval terms. " + "Keep product identity and use cases as candidates when the evidence is not definitive. " + "Do not invent exact dimensions beyond the supplied facts." + ), + } + content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] + if image_path.is_file(): + content.append(self._image_part(image_path)) + response = await self.gateway.call_tool( + messages=[ + { + "role": "system", + "content": ( + "You describe CAD parts for vector search. Return only the required tool call. " + "Separate visible geometry from inferred semantics, and mark uncertain real-world identity conservatively." + ), + }, + {"role": "user", "content": content}, + ], + tool=VISION_DESCRIPTION_TOOL, + provider_id=self.provider_id, + model_id=self.model_id, + required_tool_name="describe_cad_model", + ) + calls = response.get("tool_calls") or [] + if len(calls) != 1: + raise ValueError("vision provider did not return exactly one tool call") + function = calls[0].get("function") if isinstance(calls[0], dict) else None + if not isinstance(function, dict) or function.get("name") != "describe_cad_model": + raise ValueError("vision provider returned an unexpected tool call") + arguments = json.loads(str(function.get("arguments") or "{}")) + if not isinstance(arguments, dict): + raise ValueError("vision provider arguments are not an object") + arguments["usage"] = response.get("usage") or {} + return arguments + + @staticmethod + def _image_part(path: Path) -> dict[str, Any]: + media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + data = base64.b64encode(path.read_bytes()).decode("ascii") + return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}} + + +class VisionCallState: + def __init__(self, client: VisionDescriptionClient, *, failure_limit: int = 3) -> None: + self.client = client + self.failure_limit = failure_limit + self._lock = threading.Lock() + self._consecutive_failures = 0 + self._disabled_reason = "" + + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + with self._lock: + disabled = self._disabled_reason + if disabled: + return None, {"code": "vision_disabled", "message": disabled} + try: + result = self.client.describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts) + except Exception as exc: + with self._lock: + self._consecutive_failures += 1 + if self._consecutive_failures >= self.failure_limit: + self._disabled_reason = f"vision disabled after {self._consecutive_failures} consecutive failures" + return None, {"code": "vision_failed", "message": str(exc), "type": type(exc).__name__} + with self._lock: + self._consecutive_failures = 0 + return result, None + + +def sample_shard(sample: Sample) -> str: + for modality in ("image", "featurescript", "annotation", "step", "stl"): + path = sample.files.get(modality) + if path: + return Path(path).parent.name + return sample.sample_id[:4] + + +def select_description_samples( + input_root: Path, + *, + shard: str | None = None, + sample_ids: list[str] | None = None, + offset: int = 0, + limit: int | None = None, + seed: int | None = None, +) -> list[Sample]: + samples = scan_dataset(input_root, include_hashes=False) + if shard: + samples = [sample for sample in samples if sample_shard(sample) == shard] + if sample_ids: + wanted = set(sample_ids) + samples = [sample for sample in samples if sample.sample_id in wanted] + missing = wanted - {sample.sample_id for sample in samples} + if missing: + raise ValueError("unknown sample ids: " + ", ".join(sorted(missing))) + if seed is not None and limit is not None: + samples = random.Random(seed).sample(samples, min(limit, len(samples))) + return sorted(samples, key=lambda sample: sample.sample_id) + return samples[offset:None if limit is None else offset + limit] + + +def describe_samples( + input_root: Path, + *, + shard: str | None = None, + sample_ids: list[str] | None = None, + mode: str = "hybrid", + offset: int = 0, + limit: int | None = None, + seed: int | None = None, + force: bool = False, + workers: int = 1, + vision_client: VisionDescriptionClient | None = None, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + if mode not in {"local", "hybrid", "vision"}: + raise ValueError("--mode must be local, hybrid, or vision") + if not shard and not sample_ids and limit is None: + raise ValueError("describe requires --shard, --sample-id, or --limit to avoid accidental full-corpus generation") + if not 1 <= workers <= 8: + raise ValueError("--workers must be between 1 and 8") + + samples = select_description_samples(input_root, shard=shard, sample_ids=sample_ids, offset=offset, limit=limit, seed=seed) + existing_manifest = _read_manifest(input_root / "description_txt" / MANIFEST_NAME) + shared_diagnostics: list[dict[str, Any]] = [] + vision_state = VisionCallState(vision_client) if vision_client is not None else None + if mode != "local" and vision_client is None: + try: + vision_client = ConfiguredVisionDescriptionClient() + vision_state = VisionCallState(vision_client) + except Exception as exc: + shared_diagnostics.append({"code": "vision_unavailable", "message": str(exc), "type": type(exc).__name__}) + + def process(sample: Sample) -> dict[str, Any]: + return describe_one( + sample, + input_root, + mode=mode, + force=force, + vision_state=vision_state, + existing_record=existing_manifest.get(sample.sample_id), + shared_diagnostics=shared_diagnostics, + ) + + if workers == 1: + records = [process(sample) for sample in samples] + else: + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs-describe") as executor: + records = list(executor.map(process, samples)) + manifest_rows = _write_manifest(input_root, records) + return records, _summary(input_root, records, manifest_rows) + + +def describe_one( + sample: Sample, + input_root: Path, + *, + mode: str, + force: bool = False, + vision_state: VisionCallState | None = None, + existing_record: dict[str, Any] | None = None, + shared_diagnostics: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + shard = sample_shard(sample) + txt_path = input_root / "description_txt" / shard / f"{sample.sample_id}.txt" + if txt_path.exists() and not force: + return existing_record or _skipped_record(sample, input_root, txt_path, shard) + + diagnostics = list(shared_diagnostics or []) + try: + local_facts = _local_facts(sample) + diagnostics.extend(local_facts.pop("diagnostics", [])) + vision_result = None + if mode != "local": + if vision_state is None: + diagnostics.append({"code": "vision_unavailable", "message": "no configured vision client"}) + else: + vision_result, vision_diagnostic = vision_state.describe( + sample_id=sample.sample_id, + image_path=Path(sample.files.get("image", "")), + local_facts=local_facts, + ) + if vision_diagnostic is not None: + diagnostics.append(vision_diagnostic) + record = _compose_record(sample, input_root, shard, txt_path, local_facts, vision_result, diagnostics, mode) + _atomic_write_text(txt_path, render_description_text(record)) + return record + except Exception as exc: + diagnostics.append({"code": "description_failed", "message": str(exc), "type": type(exc).__name__}) + record = _failed_record(sample, input_root, txt_path, shard, diagnostics) + _atomic_write_text(txt_path, render_description_text(record)) + return record + + +def render_description_text(record: dict[str, Any]) -> str: + confidence = _format_float(float(record.get("category_confidence") or 0.0)) + keywords = _unique([*(record.get("keywords_zh") or []), *(record.get("keywords_en") or [])], limit=48) + evidence = [ + "确定事实来自 FeatureScript、原始 CAD 操作说明、STL/STEP 文件存在性和可解析的几何范围。", + *_as_text_list(record.get("uncertainties")), + ] + diagnostics = record.get("diagnostics") or [] + if diagnostics: + codes = _unique([str(item.get("code") or "diagnostic") for item in diagnostics if isinstance(item, dict)], limit=8) + if codes: + evidence.append("诊断:" + ",".join(codes)) + lines = [ + f"样本ID:{record.get('sample_id', '')}", + f"分类:{record.get('category', '通用机械 CAD 零件(候选)')}", + "候选名称:" + _join_or_unknown(record.get("candidate_names")), + "模型概述:" + str(record.get("summary_zh") or "该模型为缺少语义上下文的 CAD 几何样本,描述以可见结构和建模特征为主。"), + "可能作用:" + _join_or_unknown(record.get("possible_functions")), + "典型应用:" + _join_or_unknown(record.get("applications")), + "结构特征:" + _join_or_unknown(record.get("structural_features")), + "建模与几何特征:" + _join_or_unknown(record.get("geometric_features")), + "检索关键词:" + _join_or_unknown(keywords), + "证据与不确定性:" + ";".join(evidence), + f"置信度:{confidence}(几何事实置信度较高;真实零件类别、用途和装配位置为候选判断)", + ] + return "\n".join(lines) + "\n" + + +def _local_facts(sample: Sample) -> dict[str, Any]: + feature_path = Path(sample.files["featurescript"]) + annotation_path = Path(sample.files.get("annotation", "")) + stl_path = Path(sample.files.get("stl", "")) + image_path = Path(sample.files.get("image", "")) + source = feature_path.read_text(encoding="utf-8") + annotation = annotation_path.read_text(encoding="utf-8") if annotation_path.is_file() else "" + model = parse_featurescript(source, sample.sample_id) + diagnostics: list[dict[str, Any]] = [] + sketch_info, sketch_features, sketch_dimensions, entity_counts = _describe_sketches(model) + operations, operation_dimensions = _describe_operations(model) + stl_info = _stl_bbox(stl_path) if stl_path.is_file() else None + image_info = _image_metadata(image_path) if image_path.is_file() else None + shape_cues = _shape_cues(annotation, entity_counts, operations) + geometric_features = _unique( + [ + f"包含 {len(model.sketches)} 个草图和 {len(model.features)} 个建模/修饰特征", + *sketch_features, + *shape_cues, + *_stl_features(stl_info), + ], + limit=32, + ) + dimensions = _unique([*sketch_dimensions, *operation_dimensions, *_stl_dimensions(stl_info)], limit=32) + classification = _classify_local(annotation, operations, geometric_features, stl_info) + keywords_zh, keywords_en = _keywords(classification, operations, geometric_features) + uncertainties = [ + "CADFS 样本未提供真实装配上下文,类别、用途和安装位置只能作为候选语义。", + "确定描述优先依据几何、草图和特征操作;视觉判断仅作为补充证据。", + ] + summary = _local_summary(classification, operations, geometric_features, dimensions) + if not source.strip(): + diagnostics.append({"code": "empty_featurescript", "message": "FeatureScript file is empty"}) + return { + "category": classification["category"], + "category_confidence": classification["confidence"], + "candidate_names": classification["candidate_names"], + "summary_zh": summary, + "possible_functions": classification["possible_functions"], + "applications": classification["applications"], + "structural_features": _unique([*classification["structural_features"], *sketch_info], limit=24), + "geometric_features": geometric_features, + "operations": operations, + "dimensions": dimensions, + "keywords_zh": keywords_zh, + "keywords_en": keywords_en, + "uncertainties": uncertainties, + "annotation_excerpt": annotation[:4000], + "image_metadata": image_info, + "diagnostics": diagnostics, + } + + +def _describe_sketches(model: Any) -> tuple[list[str], list[str], list[str], Counter[str]]: + sketch_info: list[str] = [] + features: list[str] = [] + dimensions: list[str] = [] + entity_counts: Counter[str] = Counter() + for sketch in model.sketches: + counts = Counter(entity.operation for entity in sketch.entities) + entity_counts.update(counts) + labels = [f"{ENTITY_LABELS.get(name, name)} {count} 个" for name, count in sorted(counts.items())] + plane = _plane_name(sketch.workplane) + if labels: + sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,包含" + "、".join(labels)) + else: + sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,没有可解析草图实体") + if counts: + features.append("草图包含" + "、".join(labels)) + bbox = _sketch_bbox(sketch) + if bbox: + width = bbox[2] - bbox[0] + height = bbox[3] - bbox[1] + dimensions.append(f"{sketch.feature_id} 二维范围约 {_format_float(width)} × {_format_float(height)} mm") + return sketch_info, _unique(features, limit=12), dimensions, entity_counts + + +def _describe_operations(model: Any) -> tuple[list[str], list[str]]: + operations: list[str] = [] + dimensions: list[str] = [] + for feature in model.features: + params = feature.params + operation = feature.operation + if operation == "extrude": + operation_type = _enum_tail(params.get("operationType") or "NEW").upper() + action = "extrude_cut" if operation_type in {"REMOVE", "CUT"} else "extrude_add" + depth = _safe_number(params.get("depth")) + label = f"{action}" + if depth is not None: + label += f" depth_mm={_format_float(depth)}" + dimensions.append(f"{feature.feature_id} 拉伸深度 {_format_mm(depth)}") + if _truthy(params.get("hasSecondDirection")): + label += " two_sided=true" + operations.append(label) + elif operation == "revolve": + operation_type = _enum_tail(params.get("operationType") or params.get("surfaceOperationType") or "NEW").upper() + action = "revolve_cut" if operation_type in {"REMOVE", "CUT"} else "revolve_add" + angle = 360.0 if "FULL" in _enum_tail(params.get("revolveType") or "FULL").upper() else _safe_number(params.get("angle")) + operations.append(f"{action} angle_deg={_format_float(angle or 360.0)}") + elif operation == "hole": + diameter = _safe_number(params.get("holeDiameter")) + style = _enum_tail(params.get("style") or "simple").lower() + label = f"hole {style}" + if diameter is not None: + label += f" diameter_mm={_format_float(diameter)}" + dimensions.append(f"{feature.feature_id} 孔径 {_format_mm(diameter)}") + operations.append(label) + elif operation == "fillet": + radius = _safe_number(params.get("radius")) + operations.append("fillet" + (f" radius_mm={_format_float(radius)}" if radius is not None else "")) + if radius is not None: + dimensions.append(f"{feature.feature_id} 圆角半径 {_format_mm(radius)}") + elif operation == "chamfer": + width = _safe_number(params.get("width") or params.get("width1")) + operations.append("chamfer" + (f" distance_mm={_format_float(width)}" if width is not None else "")) + if width is not None: + dimensions.append(f"{feature.feature_id} 倒角距离 {_format_mm(width)}") + else: + operations.append(OPERATION_LABELS.get(operation, operation)) + return _unique(operations, limit=32), _unique(dimensions, limit=32) + + +def _shape_cues(annotation: str, entity_counts: Counter[str], operations: list[str]) -> list[str]: + text = annotation.lower() + joined_ops = " ".join(operations).lower() + cues: list[str] = [] + if "triangle" in text or "triangular" in text: + cues.append("具有三角形轮廓或三角截面") + if "rectangle" in text or entity_counts.get("skLineSegment", 0) >= 4: + cues.append("包含矩形/多边形直线轮廓") + if "circle" in text or entity_counts.get("skCircle", 0): + cues.append("包含圆形轮廓或圆孔候选特征") + if "arc" in text or entity_counts.get("skArc", 0): + cues.append("包含圆弧边界") + if "slot" in text or "notch" in text or "cutout" in text or "开口" in text: + cues.append("包含槽、缺口或开口候选结构") + if "hole" in text or "hole" in joined_ops: + cues.append("包含孔加工候选结构") + if "fillet" in joined_ops: + cues.append("包含圆角过渡") + if "chamfer" in joined_ops: + cues.append("包含倒角边") + if "pattern" in joined_ops: + cues.append("包含阵列重复特征") + if "mirror" in joined_ops: + cues.append("包含镜像对称特征") + if any(item.startswith("revolve") for item in operations): + cues.append("绕轴旋转形成回转体") + if any(item.startswith("extrude") for item in operations): + cues.append("由二维草图拉伸形成实体") + return _unique(cues, limit=16) + + +def _classify_local(annotation: str, operations: list[str], geometric_features: list[str], stl_info: dict[str, Any] | None) -> dict[str, Any]: + text = " ".join([annotation.lower(), " ".join(operations).lower(), " ".join(geometric_features)]) + slender = _is_slender(stl_info) + has_extrude = any(item.startswith("extrude") for item in operations) + has_revolve = any(item.startswith("revolve") for item in operations) + has_hole = "hole" in text or "孔" in text + has_triangle = "triangle" in text or "triangular" in text or "三角" in text + has_rectangle = "rectangle" in text or "矩形" in text + has_cutout = any(word in text for word in ("slot", "notch", "cutout", "开口", "缺口")) + + if has_revolve: + return { + "category": "回转轴套/法兰类零件(候选)" if has_hole else "回转体机械零件(候选)", + "confidence": 0.5, + "candidate_names": ["回转体", "轴套", "法兰盘", "轮毂状零件", "revolved part", "flange", "bushing"], + "possible_functions": ["可能用于同轴定位、连接、支承、隔套或旋转类结构的几何占位。"], + "applications": ["机械传动、夹具、管路连接、轴承座周边或需要轴线对称零件的装配场景。"], + "structural_features": ["回转外形", "轴向轮廓", "圆柱/圆盘候选结构"], + } + if has_triangle and has_extrude: + return { + "category": "三角棱柱/楔形梁类零件(候选)", + "confidence": 0.56, + "candidate_names": ["长条三角棱柱", "楔形梁", "三角截面导轨", "triangular prism", "wedge beam"], + "possible_functions": ["可能作为楔块、导向条、支撑肋、定位块或三角截面结构件使用。"], + "applications": ["夹具定位、机械支撑、导向结构、教育/仿真几何库或需要楔形截面的 CAD 检索场景。"], + "structural_features": ["三角截面", "长条拉伸体", "棱柱体", "斜面侧壁"], + } + if has_hole and has_rectangle: + return { + "category": "带孔板/安装板类零件(候选)", + "confidence": 0.52, + "candidate_names": ["安装板", "连接板", "带孔支架板", "mounting plate", "bracket plate"], + "possible_functions": ["可能用于螺钉安装、定位连接、固定支撑或作为装配转接板。"], + "applications": ["设备框架、夹具底板、连接支架、外壳内部固定件。"], + "structural_features": ["板状主体", "孔特征", "平面安装面"], + } + if has_cutout and has_extrude: + return { + "category": "开槽板/叉形支架类零件(候选)", + "confidence": 0.48, + "candidate_names": ["开槽板", "叉形支架", "U 形支架", "slotted plate", "fork bracket"], + "possible_functions": ["可能用于避让、卡接、导向、夹持或作为插槽式连接件。"], + "applications": ["支架、夹具、连接耳、导向槽结构或板件开口检索场景。"], + "structural_features": ["开口/缺口", "板状拉伸体", "平直侧壁"], + } + if has_extrude and slender: + return { + "category": "拉伸型梁/导轨类零件(候选)", + "confidence": 0.46, + "candidate_names": ["拉伸梁", "导轨条", "长条棱柱", "extruded beam", "rail"], + "possible_functions": ["可能用于导向、支撑、隔距、边框或长条结构件。"], + "applications": ["框架、滑轨、夹具、机械结构支撑或型材检索场景。"], + "structural_features": ["长条外形", "恒定截面候选", "拉伸成型"], + } + if has_extrude: + return { + "category": "拉伸棱柱/板块类零件(候选)", + "confidence": 0.42, + "candidate_names": ["拉伸实体", "板块", "棱柱体", "extruded solid", "prismatic part"], + "possible_functions": ["可能作为基础块、板件、支撑件或后续加工毛坯。"], + "applications": ["通用机械零件、夹具、支架、CAD 几何检索和相似形状匹配。"], + "structural_features": ["二维轮廓拉伸", "平面端面", "直壁结构"], + } + return { + "category": "通用机械 CAD 零件(候选)", + "confidence": 0.34, + "candidate_names": ["机械零件", "CAD 几何样本", "mechanical part", "CAD model"], + "possible_functions": ["可能作为机械结构、连接、支撑或几何检索样本,实际用途需结合装配上下文确认。"], + "applications": ["CAD 数据集检索、几何相似度匹配、零件分类训练和工程知识库索引。"], + "structural_features": ["可解析 CAD 特征组合"], + } + + +def _keywords(classification: dict[str, Any], operations: list[str], geometric_features: list[str]) -> tuple[list[str], list[str]]: + text = " ".join([classification["category"], " ".join(classification["candidate_names"]), " ".join(operations), " ".join(geometric_features)]).lower() + zh = ["CAD模型", "机械零件", "几何检索", "相似特征", *classification["candidate_names"][:4]] + en = ["cad model", "mechanical part", "geometry retrieval", "similar features"] + mappings = [ + ("三角", "三角棱柱", "triangular prism"), + ("wedge", "楔形", "wedge"), + ("extrude", "拉伸", "extruded"), + ("revolve", "回转", "revolved"), + ("hole", "孔", "hole"), + ("fillet", "圆角", "fillet"), + ("chamfer", "倒角", "chamfer"), + ("slot", "槽", "slot"), + ("flange", "法兰", "flange"), + ("bushing", "轴套", "bushing"), + ("plate", "板件", "plate"), + ("bracket", "支架", "bracket"), + ("rail", "导轨", "rail"), + ] + for needle, zh_value, en_value in mappings: + if needle in text or zh_value in text: + zh.append(zh_value) + en.append(en_value) + return _unique(zh, limit=32), _unique(en, limit=32) + + +def _compose_record( + sample: Sample, + input_root: Path, + shard: str, + txt_path: Path, + local_facts: dict[str, Any], + vision_result: dict[str, Any] | None, + diagnostics: list[dict[str, Any]], + mode: str, +) -> dict[str, Any]: + vision = _normalize_vision(vision_result) + status = "described_local" + if mode != "local": + status = "described_hybrid" if vision else "local_fallback" + category = vision.get("category") or local_facts["category"] + category_confidence = _clamp_float(vision.get("category_confidence"), local_facts["category_confidence"]) + candidate_names = _unique([*vision.get("candidate_names", []), *local_facts["candidate_names"]], limit=12) + structural_features = _unique([*local_facts["structural_features"], *vision.get("structural_features", [])], limit=32) + geometric_features = _unique([*local_facts["geometric_features"], *vision.get("geometric_features", [])], limit=40) + uncertainties = _unique([*local_facts["uncertainties"], *vision.get("uncertainties", [])], limit=20) + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": status, + "txt_path": str(txt_path), + "category": category, + "category_confidence": category_confidence, + "candidate_names": candidate_names, + "summary_zh": vision.get("summary_zh") or local_facts["summary_zh"], + "possible_functions": _unique([*vision.get("possible_functions", []), *local_facts["possible_functions"]], limit=12), + "applications": _unique([*vision.get("applications", []), *local_facts["applications"]], limit=12), + "structural_features": structural_features, + "geometric_features": geometric_features, + "operations": local_facts["operations"], + "dimensions": local_facts["dimensions"], + "keywords_zh": _unique([*vision.get("keywords_zh", []), *local_facts["keywords_zh"]], limit=40), + "keywords_en": _unique([*vision.get("keywords_en", []), *local_facts["keywords_en"]], limit=40), + "uncertainties": uncertainties, + "source_files": _source_files(sample, input_root), + "diagnostics": diagnostics, + } + + +def _normalize_vision(value: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + result: dict[str, Any] = {} + for key in ( + "candidate_names", + "possible_functions", + "applications", + "structural_features", + "geometric_features", + "keywords_zh", + "keywords_en", + "uncertainties", + ): + result[key] = _as_text_list(value.get(key)) + for key in ("category", "summary_zh"): + if isinstance(value.get(key), str) and value[key].strip(): + result[key] = value[key].strip() + result["category_confidence"] = value.get("category_confidence") + return result + + +def _local_summary(classification: dict[str, Any], operations: list[str], geometric_features: list[str], dimensions: list[str]) -> str: + operation_text = "、".join(operations[:4]) if operations else "可解析 CAD 特征" + feature_text = ";".join(geometric_features[:4]) if geometric_features else "几何结构待进一步识别" + dimension_text = ";".join(dimensions[:3]) if dimensions else "未提取到稳定尺寸摘要" + return ( + f"该模型可保守识别为{classification['category']},主要由 {operation_text} 构成。" + f"确定结构包括:{feature_text}。尺寸线索:{dimension_text}。" + "真实产品身份和装配用途缺少上下文,因此以候选名称和相似几何特征用于检索。" + ) + + +def _failed_record(sample: Sample, input_root: Path, txt_path: Path, shard: str, diagnostics: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": "failed", + "txt_path": str(txt_path), + "category": "通用机械 CAD 零件(描述失败)", + "category_confidence": 0.0, + "candidate_names": ["CAD 模型"], + "summary_zh": "该样本描述生成失败,仅保留源文件索引和诊断信息。", + "possible_functions": ["无法可靠判断。"], + "applications": ["需重新运行描述生成或人工复核后再进入向量库。"], + "structural_features": [], + "geometric_features": [], + "operations": [], + "dimensions": [], + "keywords_zh": ["CAD模型", "描述失败"], + "keywords_en": ["cad model", "description failed"], + "uncertainties": ["描述生成过程失败,不能据此判断模型类别或用途。"], + "source_files": _source_files(sample, input_root), + "diagnostics": diagnostics, + } + + +def _skipped_record(sample: Sample, input_root: Path, txt_path: Path, shard: str) -> dict[str, Any]: + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": "skipped_existing", + "txt_path": str(txt_path), + "category": "", + "category_confidence": 0.0, + "candidate_names": [], + "geometric_features": [], + "operations": [], + "dimensions": [], + "keywords_zh": [], + "keywords_en": [], + "uncertainties": ["已有描述文件,未使用 --force,因此本次未覆盖。"], + "source_files": _source_files(sample, input_root), + "diagnostics": [{"code": "existing_output_skipped"}], + } + + +def _source_files(sample: Sample, input_root: Path) -> dict[str, str]: + return {key: str(Path(value)) for key, value in sorted(sample.files.items()) if Path(value).is_file() or input_root} + + +def _read_manifest(path: Path) -> dict[str, dict[str, Any]]: + if not path.exists(): + return {} + records: dict[str, dict[str, Any]] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + sample_id = str(value.get("sample_id") or "") + if sample_id: + records[sample_id] = value + return records + + +def _write_manifest(input_root: Path, records: list[dict[str, Any]]) -> int: + path = input_root / "description_txt" / MANIFEST_NAME + existing = _read_manifest(path) + for record in records: + existing[str(record["sample_id"])] = record + ordered = [existing[key] for key in sorted(existing)] + text = "".join(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" for record in ordered) + _atomic_write_text(path, text) + return len(ordered) + + +def _summary(input_root: Path, records: list[dict[str, Any]], manifest_rows: int) -> dict[str, Any]: + counts = Counter(str(record.get("status") or "unknown") for record in records) + txt_count = sum(1 for record in records if Path(str(record.get("txt_path") or "")).is_file()) + return { + "sample_count": len(records), + "statuses": dict(sorted(counts.items())), + "txt_count": txt_count, + "manifest": str(input_root / "description_txt" / MANIFEST_NAME), + "manifest_rows": manifest_rows, + } + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + + +def _safe_number(value: Any) -> float | None: + try: + result = _number(value, True) + except Exception: + return None + return result if math.isfinite(result) else None + + +def _safe_point(value: Any) -> list[float] | None: + try: + point = _point(value) + except Exception: + return None + return point if len(point) >= 2 and all(math.isfinite(item) for item in point[:2]) else None + + +def _sketch_bbox(sketch: Any) -> list[float] | None: + xs: list[float] = [] + ys: list[float] = [] + for entity in sketch.entities: + params = entity.params + if entity.operation == "skLineSegment": + for key in ("start", "end"): + point = _safe_point(params.get(key)) + if point: + xs.append(point[0]) + ys.append(point[1]) + elif entity.operation == "skCircle": + center = _safe_point(params.get("center")) + radius = _safe_number(params.get("radius")) + if center and radius is not None: + xs.extend([center[0] - radius, center[0] + radius]) + ys.extend([center[1] - radius, center[1] + radius]) + elif entity.operation == "skArc": + for key in ("start", "mid", "end"): + point = _safe_point(params.get(key)) + if point: + xs.append(point[0]) + ys.append(point[1]) + elif entity.operation == "skPoint": + point = _safe_point(params.get("position")) + if point: + xs.append(point[0]) + ys.append(point[1]) + if not xs or not ys: + return None + return [min(xs), min(ys), max(xs), max(ys)] + + +def _stl_bbox(path: Path) -> dict[str, Any] | None: + try: + data = path.read_bytes() + except OSError: + return None + vertices = _binary_stl_vertices(data) or _ascii_stl_vertices(data) + if not vertices: + return None + mins = [min(vertex[i] for vertex in vertices) for i in range(3)] + maxs = [max(vertex[i] for vertex in vertices) for i in range(3)] + size = [maxs[i] - mins[i] for i in range(3)] + return {"min_mm": mins, "max_mm": maxs, "size_mm": size, "vertex_count": len(vertices)} + + +def _binary_stl_vertices(data: bytes) -> list[tuple[float, float, float]]: + if len(data) < 84: + return [] + triangle_count = struct.unpack(" len(data): + return [] + vertices: list[tuple[float, float, float]] = [] + offset = 84 + for _ in range(triangle_count): + offset += 12 + for _ in range(3): + vertices.append(struct.unpack(" list[tuple[float, float, float]]: + try: + text = data[:5_000_000].decode("utf-8", errors="ignore") + except Exception: + return [] + pattern = re.compile(r"\bvertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)") + vertices = [] + for match in pattern.finditer(text): + try: + vertices.append((float(match.group(1)), float(match.group(2)), float(match.group(3)))) + except ValueError: + continue + return vertices + + +def _image_metadata(path: Path) -> dict[str, Any]: + try: + from PIL import Image + + with Image.open(path) as image: + return {"width": image.width, "height": image.height, "format": str(image.format or "").lower()} + except Exception as exc: + return {"error": f"image metadata unavailable: {type(exc).__name__}"} + + +def _stl_features(stl_info: dict[str, Any] | None) -> list[str]: + if not stl_info: + return [] + size = stl_info.get("size_mm") or [] + if len(size) != 3: + return [] + features = ["STL 网格提供三维包围盒"] + if _is_slender(stl_info): + features.append("整体呈长条比例") + if _is_plate_like(stl_info): + features.append("整体呈薄板比例") + return features + + +def _stl_dimensions(stl_info: dict[str, Any] | None) -> list[str]: + if not stl_info: + return [] + size = stl_info.get("size_mm") or [] + if len(size) != 3: + return [] + return [f"STL 三维包围盒约 {_format_float(size[0])} × {_format_float(size[1])} × {_format_float(size[2])} mm"] + + +def _is_slender(stl_info: dict[str, Any] | None) -> bool: + if not stl_info: + return False + values = [abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9] + return len(values) >= 2 and max(values) / max(min(values), 1e-9) >= 3.0 + + +def _is_plate_like(stl_info: dict[str, Any] | None) -> bool: + if not stl_info: + return False + values = sorted(abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9) + return len(values) == 3 and values[0] * 4 <= values[1] + + +def _plane_name(value: Any) -> str: + text = json.dumps(plain(value), ensure_ascii=False) + for name in ("Top", "Front", "Right"): + if f"{name}.planeOp" in text: + return name + return "未知" + + +def _enum_tail(value: Any) -> str: + return str(value or "").split(".")[-1] + + +def _truthy(value: Any) -> bool: + return value is True or (isinstance(value, str) and value.lower() == "true") + + +def _format_float(value: float) -> str: + rounded = round(float(value), 4) + if rounded == int(rounded): + return str(int(rounded)) + return f"{rounded:.4f}".rstrip("0").rstrip(".") + + +def _format_mm(value: float) -> str: + return f"{_format_float(value)} mm" + + +def _clamp_float(value: Any, fallback: float) -> float: + try: + number = float(value) + except (TypeError, ValueError): + number = float(fallback) + if not math.isfinite(number): + number = float(fallback) + return max(0.0, min(1.0, number)) + + +def _as_text_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _unique(values: list[Any], *, limit: int | None = None) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value).strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + if limit is not None and len(result) >= limit: + break + return result + + +def _join_or_unknown(values: Any) -> str: + items = _as_text_list(values) if isinstance(values, list) else _unique(list(values or [])) if isinstance(values, tuple) else [] + return "、".join(items) if items else "无法可靠判断" diff --git a/cadfs_to_cdsl/tests/test_describe.py b/cadfs_to_cdsl/tests/test_describe.py new file mode 100644 index 00000000..e229d29d --- /dev/null +++ b/cadfs_to_cdsl/tests/test_describe.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from cadfs_to_cdsl.describe import describe_samples, select_description_samples + + +SAMPLE_FS = r''' +FeatureScript 1511; +import(path : "onshape/std/geometry.fs", version : "1511.0"); +const mm = millimeter; +const FACE = EntityType.FACE; +function v(x, y){return vector(x, y);} +function sQuery(a, b, c) {return sketchEntityQuery(a, b, c);} +export const myFeature = defineFeature(function(context is Context, id is Id, definition is map) + precondition{} + { + { + var Q0; + Q0=qCreatedBy(makeId("Top.planeOp"),FACE); + var sketch = newSketch(context, id + "F0", { "sketchPlane" : qUnion([Q0])}); + skLineSegment(sketch, "E0", {"start": v(-269.41, -156.6) * mm, "end": v(-0.92, 311.62) * mm}); + skLineSegment(sketch, "E1", {"start": v(-0.92, 311.62) * mm, "end": v(270.33, -155.02) * mm}); + skLineSegment(sketch, "E2", {"start": v(270.33, -155.02) * mm, "end": v(-269.41, -156.6) * mm}); + skSolve(sketch); + } + { + var Q0; + Q0 = qSketchRegion(id + "F0", true); + extrude(context, id + "F1", {"entities" : qUnion([Q0]), "depth" : 1828.8 * mm}); + } + }); +''' + + +SAMPLE_ANNOTATION = """Step 1 - Sketch +Draw a closed triangle. + +Step 2 - Extrude NEW +Extrude the triangular area upward a distance of 1828.8 mm. +""" + + +class FailingVision: + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, object]) -> dict[str, object]: + raise RuntimeError("vision offline") + + +class StaticVision: + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, object]) -> dict[str, object]: + return { + "category": "视觉增强三角楔块(候选)", + "category_confidence": 0.7, + "candidate_names": ["视觉楔块"], + "summary_zh": "视觉模型认为该模型是三角楔块候选件。", + "possible_functions": ["可能用于定位。"], + "applications": ["夹具。"], + "structural_features": ["斜面"], + "geometric_features": ["三角截面"], + "keywords_zh": ["视觉识别"], + "keywords_en": ["vision wedge"], + "uncertainties": ["视觉语义仍需人工确认。"], + } + + +class DescribeTests(unittest.TestCase): + def test_selects_only_requested_shard(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + self._write_sample(root, "0006", "00060001") + + selected = select_description_samples(root, shard="0005") + + self.assertEqual([sample.sample_id for sample in selected], ["00050089"]) + + def test_hybrid_falls_back_and_writes_txt_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + + records, summary = describe_samples(root, shard="0005", mode="hybrid", vision_client=FailingVision()) + + self.assertEqual(summary["sample_count"], 1) + self.assertEqual(summary["statuses"], {"local_fallback": 1}) + txt_path = root / "description_txt/0005/00050089.txt" + self.assertTrue(txt_path.is_file()) + text = txt_path.read_text(encoding="utf-8") + self.assertIn("样本ID:00050089", text) + self.assertIn("三角棱柱", text) + self.assertIn("证据与不确定性", text) + manifest = root / "description_txt/description_manifest.jsonl" + rows = [json.loads(line) for line in manifest.read_text(encoding="utf-8").splitlines()] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["sample_id"], "00050089") + self.assertEqual(rows[0]["status"], "local_fallback") + self.assertIn("vision_failed", [item["code"] for item in records[0]["diagnostics"]]) + + def test_hybrid_uses_vision_when_available(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + + records, _summary = describe_samples(root, shard="0005", mode="hybrid", vision_client=StaticVision()) + + self.assertEqual(records[0]["status"], "described_hybrid") + self.assertEqual(records[0]["category"], "视觉增强三角楔块(候选)") + self.assertIn("视觉楔块", records[0]["candidate_names"]) + + def test_existing_txt_is_skipped_without_force(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + first, _ = describe_samples(root, shard="0005", mode="local") + txt_path = root / "description_txt/0005/00050089.txt" + before = txt_path.read_text(encoding="utf-8") + + second, _ = describe_samples(root, shard="0005", mode="local") + + self.assertEqual(second[0]["status"], first[0]["status"]) + self.assertEqual(txt_path.read_text(encoding="utf-8"), before) + + @staticmethod + def _write_sample(root: Path, shard: str, sample_id: str) -> None: + for directory, suffix, content in ( + ("featurescript_rp", ".txt", SAMPLE_FS), + ("text_annotations", ".txt", SAMPLE_ANNOTATION), + ("step_abc", ".step", "ISO-10303-21;\nEND-ISO-10303-21;\n"), + ("stl_abc", ".stl", "solid sample\nendsolid sample\n"), + ): + path = root / directory / shard / f"{sample_id}{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + image = root / "multiview_images_abc" / shard / f"{sample_id}.png" + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx b/outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5ccad3bd6119c2d8923b593477b68ce3f0c2f450 GIT binary patch literal 20236 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 Date: Mon, 7 Sep 2026 18:21:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(cadfs):=20=E8=A1=A5=E9=BD=90=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=BB=BA=E6=A8=A1=E8=83=BD=E5=8A=9B=E5=B9=B6=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E4=BB=A3=E8=A1=A8=E6=80=A7=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持 - 支持多种 reference plane、B-spline profile 和 circular pattern replay - 保留 transform 历史,并烘焙安全的单源平移/旋转变换 - 改进 selector 绑定、拓扑快照和 pattern 变换处理 - 建立 17 个代表样本的转换、重建与比较回归工具链 - 补充 schema、author guidance、运行时和几何回归测试 --- backend/agent/skills/cad-engine/SKILL.md | 13 + .../skills/cdsl-author-guidance/manifest.json | 15 +- .../cdsl-author-guidance/op-extrude-cut.md | 2 +- .../skills/cdsl-author-guidance/op-loft.md | 1 + .../cdsl-author-guidance/op-primitives.md | 1 + .../skills/cdsl-author-guidance/op-thread.md | 1 + .../engine/cdsl_engine/build123d_adapter.py | 52 +- backend/engine/cdsl_engine/capabilities.py | 99 +- backend/engine/cdsl_engine/cdsl_schema.json | 24 +- backend/engine/cdsl_engine/phase_pools.py | 8 +- .../engine/cdsl_engine/profile_schema.json | 6 +- backend/engine/cdsl_engine/runtime.py | 89 +- backend/engine/cdsl_engine/sketch_solver.py | 27 +- backend/tests/test_author_guidance.py | 2 +- backend/tests/test_engine_loft_geometry.py | 117 +++ .../test_engine_pattern_transform_contract.py | 49 + .../tests/test_engine_runtime_foundation.py | 35 + .../ENGINE_CAPABILITY_GAPS_BASELINE.md | 103 ++ cadfs_to_cdsl/README.md | 25 + cadfs_to_cdsl/cli.py | 25 +- cadfs_to_cdsl/featurescript_parser.py | 2 +- cadfs_to_cdsl/lowering.py | 434 +++++++- cadfs_to_cdsl/pipeline.py | 7 +- cadfs_to_cdsl/regression.py | 216 ++++ cadfs_to_cdsl/regression/manifest.json | 942 ++++++++++++++++++ cadfs_to_cdsl/reports.py | 3 +- cadfs_to_cdsl/selector_binding.py | 38 +- cadfs_to_cdsl/tests/test_lowering.py | 136 ++- cadfs_to_cdsl/tests/test_parser.py | 14 + cadfs_to_cdsl/tests/test_regression.py | 73 ++ cadfs_to_cdsl/tests/test_selector_binding.py | 24 + 31 files changed, 2498 insertions(+), 85 deletions(-) create mode 100644 backend/agent/skills/cdsl-author-guidance/op-loft.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-primitives.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-thread.md create mode 100644 backend/tests/test_engine_loft_geometry.py create mode 100644 cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md create mode 100644 cadfs_to_cdsl/regression.py create mode 100644 cadfs_to_cdsl/regression/manifest.json create mode 100644 cadfs_to_cdsl/tests/test_regression.py create mode 100644 cadfs_to_cdsl/tests/test_selector_binding.py diff --git a/backend/agent/skills/cad-engine/SKILL.md b/backend/agent/skills/cad-engine/SKILL.md index 9427d021..234460ee 100644 --- a/backend/agent/skills/cad-engine/SKILL.md +++ b/backend/agent/skills/cad-engine/SKILL.md @@ -51,3 +51,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine. value shapes in `cdsl_schema.json`. In particular, hole positions must be objects such as `{"mm": [u_mm, v_mm, w_mm]}`, never bare coordinate arrays. - Preserve the original CDSL and write revisions as separate artifacts. + +## CADFS Engine Maintenance + +When changing CADFS lowering, the CDSL schema, runtime, geometry adapter, +selector binding, or their tests, update +`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md` in the same change. + +- Record the end-to-end capability state, remaining semantic boundary, and + regression evidence. A successful rebuild is not geometric similarity. +- Update a capability to completed only when lowering, contract, execution, + geometry, and regression coverage all exist. +- The progress ledger is local development state and is intentionally ignored + by Git. Do not add it to a commit. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json index 74d07eff..f82f8911 100644 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ b/backend/agent/skills/cdsl-author-guidance/manifest.json @@ -15,12 +15,15 @@ {"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false}, {"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true}, {"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true}, + {"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true}, {"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true}, {"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true}, {"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true}, {"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true}, {"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true}, - {"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true} + {"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true}, + {"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true}, + {"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true} ], "phase_sections": { "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], @@ -39,6 +42,9 @@ "extrude_add_blind": ["op-extrude-add"], "extrude_add_two_sided": ["op-extrude-add"], "extrude_cut_blind": ["op-extrude-cut"], + "extrude_cut_two_sided": ["op-extrude-cut"], + "extrude_cut_through": ["op-extrude-cut"], + "loft_add": ["op-loft"], "revolve_add": ["op-revolve"], "revolve_cut": ["op-revolve"], "hole_blind": ["op-hole"], @@ -49,8 +55,13 @@ "reference_axis": ["op-reference"], "pattern_linear": ["op-pattern"], "pattern_mirror": ["op-pattern"], + "pattern_circular": ["op-pattern"], "fillet": ["op-finish"], "chamfer": ["op-finish"], - "sphere_add": ["op-sphere"] + "sphere_add": ["op-sphere"], + "box_add": ["op-primitives"], + "cylinder_add": ["op-primitives"], + "thread_add": ["op-thread"], + "thread_cut": ["op-thread"] } } diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md index 960bca12..f88a19b2 100644 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md @@ -1 +1 @@ -`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 +`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-loft.md b/backend/agent/skills/cdsl-author-guidance/op-loft.md new file mode 100644 index 00000000..99deb7c9 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-loft.md @@ -0,0 +1 @@ +`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-primitives.md b/backend/agent/skills/cdsl-author-guidance/op-primitives.md new file mode 100644 index 00000000..09e10d85 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-primitives.md @@ -0,0 +1 @@ +`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-thread.md b/backend/agent/skills/cdsl-author-guidance/op-thread.md new file mode 100644 index 00000000..38e932da --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-thread.md @@ -0,0 +1 @@ +`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。 diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 105ba1b3..e555705e 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -65,9 +65,15 @@ class Build123dGeometryAdapter: @staticmethod def _wire(edges: list[dict[str, Any]]) -> Wire: - # 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。 + # 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。 built: list[Edge] = [] for edge in edges: + if edge.get("type") == "bspline": + points = [_vector(point) for point in edge.get("points_mm") or []] + if len(points) < 3: + raise ValueError("bspline contour edge needs at least 3 points") + built.append(Edge.make_spline(points, periodic=False)) + continue start = _vector(edge["start_mm"]) end = _vector(edge["end_mm"]) if edge.get("type") == "arc" and edge.get("center_mm") is not None: @@ -133,20 +139,38 @@ class Build123dGeometryAdapter: result: list[Face] = [] for region in regions: outer = region.get("outer") or [] - if len(outer) < 2: + # 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。 + if len(outer) < 1: continue face = Face(self._wire(outer)) - holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2] + holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1] result.append(face.make_holes(holes) if holes else face) return result # 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。 edges = sketch.get("contour_edges_mm") or [] - if len(edges) >= 2: + if len(edges) >= 1: return [Face(self._wire(edges))] # 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。 plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) return self._faces_from_circles(sketch.get("entities") or [], plane) + def loft(self, sketches: list[dict[str, Any]]) -> Solid: + """由多条简单闭合草图轮廓生成实体放样。""" + wires: list[Wire] = [] + for index, sketch in enumerate(sketches): + # Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、 + # 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系 + # 尚未由 CDSL 表达,必须显式拒绝而非猜测。 + faces = self.faces_for_sketch(sketch) + if len(faces) != 1: + raise ValueError(f"loft profile {index} must resolve to exactly one closed region") + if faces[0].inner_wires(): + raise ValueError(f"loft profile {index} must not contain inner loops") + wires.append(faces[0].outer_wire()) + if len(wires) < 2: + raise ValueError("loft requires at least two profile sketches") + return Solid.make_loft(wires) + @staticmethod def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any: """规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。""" @@ -188,14 +212,22 @@ class Build123dGeometryAdapter: """ # 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中 # 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。 + # through_next 的 target 是当前主体,必须先从其面中选出实际命中的 + # 下一终止面,不能把整个 body 当作待拉伸的 Face。 unit = _vector(direction).normalized() - hits = [ - Build123dGeometryAdapter._forward_intersection_distance(target, point, unit) - for point in Build123dGeometryAdapter.profile_sample_points(face) - ] - distances = [value for value in hits if value is not None] - if not distances: + points = Build123dGeometryAdapter.profile_sample_points(face) + targets = [target] if isinstance(target, Face) else list(target.faces()) + candidates = [] + for candidate in targets: + hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points] + distances = [value for value in hits if value is not None] + if distances: + candidates.append((len(distances), min(distances), candidate, distances)) + if not candidates: raise ValueError("extent target is not reached by the profile") + # 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同 + # 时取最近的正向交点,确保相邻面交界处的选择稳定。 + _, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1])) margin = max(distances) + 2.0 # 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。 # build123d 的布尔交方法名是 intersect(不是 OCC 的 common), diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index ef22863d..150184d1 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -19,21 +19,22 @@ from .operation_contracts import materialized_feature_contracts _SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"}) _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_") _PRIMARY_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", }) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) _ACTIVE_BODY_REQUIRED = frozenset({ - "extrude_cut_blind", "extrude_cut_through", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", + "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", + "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", # thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空 # 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。 "thread_cut", }) _BODY_MUTATING_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "extrude_cut_through", + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", }) @@ -145,27 +146,32 @@ def _schema_contract() -> dict[str, dict[str, Any]]: def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]: - """Return only sketches consumed by a feature with a sketch contract. + """Return only sketches consumed by executable feature contracts. CAD documents commonly preserve construction or abandoned sketches whose contours are incomplete. They are semantic data, but must not make an - otherwise independent feature history ineligible for execution. + otherwise independent feature history ineligible for execution. Most + operations declare ``sketch_id``; loft declares its ordered section set + in ``params.profile_sketch_ids``. """ contracts = _schema_contract() - return frozenset( - str(feature["sketch_id"]) - for feature in cdsl.get("features") or () - if feature.get("sketch_id") is not None - and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch") - ) + required: set[str] = set() + for feature in cdsl.get("features") or (): + atomic_id = str(feature.get("atomic_id") or "") + if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"): + required.add(str(feature["sketch_id"])) + if atomic_id == "loft_add": + for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or (): + required.add(str(sketch_id)) + return frozenset(required) def _has_closed_region(sketch: dict[str, Any]) -> bool: """Mirror the adapter's input contract without importing the geometry kernel.""" regions = sketch.get("contour_regions_mm") or [] - if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)): + if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)): return True - if len(sketch.get("contour_edges_mm") or []) >= 2: + if len(sketch.get("contour_edges_mm") or []) >= 1: return True return any( entity.get("type") == "circle" and not entity.get("construction") @@ -175,6 +181,18 @@ def _has_closed_region(sketch: dict[str, Any]) -> bool: ) +def _has_single_loft_region(sketch: dict[str, Any]) -> bool: + """Whether a resolved profile maps exactly to one solid loft section.""" + regions = sketch.get("contour_regions_mm") or [] + if regions: + return len(regions) == 1 and not (regions[0].get("holes") or []) + circles = [ + entity for entity in sketch.get("entities") or [] + if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction") + ] + return len(circles) == 1 + + @dataclass(frozen=True) class CapabilityAnalysis: plan: tuple[FeaturePlanNode, ...] @@ -281,6 +299,54 @@ class CapabilityAnalyzer: "The resolved sketch contains no closed profile region", sketch_id=node.sketch_id, )) + if node.atomic_id == "loft_add": + profile_ids = params.get("profile_sketch_ids") + if not isinstance(profile_ids, list) or len(profile_ids) < 2: + blockers.append(self._blocker( + node.feature_id, "invalid_loft_profiles", + "Loft requires at least two profile sketch ids", + )) + elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids): + blockers.append(self._blocker( + node.feature_id, "invalid_loft_profiles", + "Loft profile sketch ids must be distinct", + )) + else: + for sketch_id in profile_ids: + sketch_id = str(sketch_id) + profile = sketches.get(sketch_id) + if profile is None: + blockers.append(self._blocker( + node.feature_id, "missing_loft_profile", + "Loft profile sketch does not exist", sketch_id=sketch_id, + )) + continue + profile_type = str((profile.get("profile") or {}).get("type") or "") + required.append(f"loft_profile:{profile_type}") + resolution_error = sketch_errors.get(sketch_id) + if resolution_error: + blockers.append(self._blocker( + node.feature_id, "profile_resolution_failed", + "A loft profile sketch could not be resolved into executable regions", + sketch_id=sketch_id, reason=resolution_error, + )) + elif profile_type not in self.profile_types: + blockers.append(self._blocker( + node.feature_id, "unsupported_profile", + "The current runtime cannot resolve a loft profile", + sketch_id=sketch_id, profile_type=profile_type, + )) + elif not _has_closed_region(profile): + blockers.append(self._blocker( + node.feature_id, "profile_no_closed_region", + "A loft profile contains no closed region", sketch_id=sketch_id, + )) + elif not _has_single_loft_region(profile): + blockers.append(self._blocker( + node.feature_id, "unsupported_loft_profile_regions", + "Loft currently requires exactly one outer profile without holes", + sketch_id=sketch_id, + )) if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): # #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许, # 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形 @@ -318,7 +384,7 @@ class CapabilityAnalyzer: node.feature_id, "missing_offset_distance", "Offset-from-surface requires a non-zero captured offset distance", )) - if node.atomic_id == "extrude_add_two_sided": + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}: reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} reverse_type = reverse_condition.get("type") required.append(f"extent:reverse:{reverse_type}") @@ -449,7 +515,8 @@ class CapabilityAnalyzer: if node.atomic_id in _BODY_MUTATING_ATOMICS: body_available = True body_producers = { - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "thread_add", # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 6f251c8e..c8774d56 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -108,6 +108,20 @@ "required": ["end_condition"], "additionalProperties": false }, + "loftParams": { + "type": "object", + "properties": { + "profile_sketch_ids": { + "type": "array", + "minItems": 2, + "maxItems": 16, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} + } + }, + "required": ["profile_sketch_ids"], + "additionalProperties": false + }, "revolveParams": { "type": "object", "properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}}, @@ -335,18 +349,20 @@ "analyticSegment": { "type": "object", "properties": { - "type": {"enum": ["line", "arc", "circle"]}, + "type": {"enum": ["line", "arc", "circle", "bspline"]}, "start": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, + "points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}, "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": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, + {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}} ], "additionalProperties": false }, @@ -389,7 +405,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_through", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, + "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -410,6 +426,8 @@ {"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}}, diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py index 7e274f06..83a5dd5c 100644 --- a/backend/engine/cdsl_engine/phase_pools.py +++ b/backend/engine/cdsl_engine/phase_pools.py @@ -18,7 +18,7 @@ from .sketch_solver import resolve_required_sketches P3_ATOMIC_IDS = frozenset({ "reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided", - "extrude_cut_blind", "revolve_add", "revolve_cut", + "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut", }) P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"}) P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"}) @@ -27,7 +27,7 @@ P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"}) # 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. _P3_PRIMARY_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut", }) @@ -47,8 +47,8 @@ def _p3_profile_ready(cdsl: dict[str, Any]) -> bool: if profile.get("type") not in P3_PROFILE_TYPES: return False if not ( - any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict)) - or len(sketch.get("contour_edges_mm") or ()) >= 2 + any(len(region.get("outer") or []) >= 1 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict)) + or len(sketch.get("contour_edges_mm") or ()) >= 1 or any( entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0 for entity in sketch.get("entities") or () diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 4a38cba6..6afb7565 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -1,15 +1,17 @@ { "schema": "cdsl.engine.schema.v1", - "schema_version": "1.3.1", + "schema_version": "1.3.2", "cdsl_json_schema_file": "cdsl_schema.json", "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_profiles": ["circle", "polygon", "analytic_contours"], "operation_contracts": { "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, + "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, + "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]}, "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]}, @@ -71,7 +73,7 @@ "constraints": ["vertices contains at least three [u, v] points"] }, "analytic_contours": { - "summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry." + "summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry." } } } diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index d91d6a04..3118a5a6 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -20,8 +20,8 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "extrude_cut_through", + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "reference_plane", "reference_axis", @@ -82,6 +82,7 @@ class GeometryAdapter(Protocol): def body_solids(self, body: Any) -> list[Any]: ... def body_geometry(self, body: Any) -> dict[str, Any]: ... def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def loft(self, sketches: list[dict[str, Any]]) -> Any: ... def extrude(self, face: Any, direction: Vector3) -> Any: ... def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... @@ -236,7 +237,7 @@ def _targeted_extent_vector( except ValueError as error: message = str(error) code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" - if condition == "up_to_surface": + if condition in {"up_to_surface", "through_next"}: # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 @@ -244,6 +245,7 @@ def _targeted_extent_vector( # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 # "not reached",保持显式拒绝。 + # through_next 从当前主体中选取实际命中的下一张面; # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 # 裁剪体层,仍保持显式拒绝。 return ExtentVector(vector_scale(direction, 1.0), trim_to=target) @@ -271,7 +273,7 @@ def _side_extent_vectors( ) -> list[ExtentVector]: """Resolve one directional extent without borrowing the opposite side. - ``extrude_add_two_sided`` calls this once for each independently captured + ``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured termination. The regular one-sided executor also uses it for all simple termination modes, keeping the geometry adapter interface uniform. """ @@ -319,7 +321,7 @@ def _extent_vectors( end_condition = params.get("end_condition") or {"type": "blind"} condition = end_condition.get("type", "blind") distance = abs(float(params.get("distance_mm") or 0.0)) - if node.atomic_id == "extrude_add_two_sided": + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}: reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) if reverse_distance <= 0: @@ -457,6 +459,21 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske return session.result(node) +def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 + # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid = session.adapter.loft(profiles) + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 @@ -763,6 +780,11 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any for point_key in ("start_mm", "end_mm", "center_mm"): if point_key in value: value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)] + if "points_mm" in value: + value["points_mm"] = [ + [float(point[index]) + components[index] for index in range(3)] + for point in value["points_mm"] + ] for child in value.values(): translate(child) elif isinstance(value, list): @@ -772,6 +794,30 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any return output +def _transformed_loft_profiles( + node: FeaturePlanNode, + params: dict[str, Any], + instance_id: str, + session: ExecutionSession, + transform: Callable[[dict[str, Any]], dict[str, Any]], +) -> None: + """为 pattern replay 创建放样截面的变换副本。""" + if node.atomic_id != "loft_add": + return + profile_ids = params.get("profile_sketch_ids") or [] + transformed_ids: list[str] = [] + for index, sketch_id in enumerate(profile_ids): + source = session.sketches.get(str(sketch_id)) + if source is None: + raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition") + transformed_id = f"{instance_id}.profile.{index}" + # 不复用原 profile:pattern 中的每个截面都必须与 source feature + # 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。 + session.sketches[transformed_id] = transform(source) + transformed_ids.append(transformed_id) + params["profile_sketch_ids"] = transformed_ids + + def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None: """解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。 @@ -812,6 +858,10 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, # 否则阵列副本会静默重合在原位置。 params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _translated_sketch(sketch, offset), + ) mirror_plane = params.get("mirror_plane") if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 @@ -919,6 +969,12 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any] point = value.get(point_key) if isinstance(point, list) and len(point) == 2: value[point_key] = [float(point[0]), -float(point[1])] + if isinstance(value.get("points"), list): + value["points"] = [ + [float(point[0]), -float(point[1])] + for point in value["points"] + if isinstance(point, list) and len(point) == 2 + ] for child in value.values(): mirror_local_coordinates(child) elif isinstance(value, list): @@ -935,6 +991,8 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any] for point_key in ("start_mm", "end_mm", "center_mm"): if point_key in value: value[point_key] = _reflect_point(value[point_key], plane) + if "points_mm" in value: + value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]] if value.get("normal"): value["normal"] = _reflect_point(value["normal"], plane, vector=True) for child in value.values(): @@ -994,6 +1052,10 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern # 中已被显式拒绝)。 params["center_mm"] = _reflect_point(center, plane) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _mirrored_sketch(sketch, plane), + ) mirror_plane = params.get("mirror_plane") if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 @@ -1102,7 +1164,7 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center - # 世界坐标点绕轴旋转。 + # 世界坐标点和圆弧法向绕轴旋转。 output = deepcopy(sketch) workplane = output.get("workplane") or {} if workplane.get("origin_mm"): @@ -1117,6 +1179,10 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> for point_key in ("start_mm", "end_mm", "center_mm"): if point_key in value: value[point_key] = _rotated_point(value[point_key], axis, angle_rad) + if "points_mm" in value: + value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]] + if "normal" in value: + value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad)) for child in value.values(): rotate(child) elif isinstance(value, list): @@ -1168,6 +1234,10 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle center = params.get("center_mm") if isinstance(center, list) and len(center) == 3: params["center_mm"] = _rotated_point(center, axis, angle_rad) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _rotated_sketch(sketch, axis, angle_rad), + ) if node.atomic_id in {"pattern_mirror", "pattern_circular"}: # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 @@ -1281,6 +1351,11 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: return _shape_from_primary(node, session, sketch=sketch) +def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add(node, session) + + def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_reference_plane(node, session) @@ -1347,7 +1422,9 @@ EXECUTORS: dict[str, ExecutorFunction] = { "extrude_add_blind": _primary_executor, "extrude_add_two_sided": _primary_executor, "extrude_cut_blind": _primary_executor, + "extrude_cut_two_sided": _primary_executor, "extrude_cut_through": _primary_executor, + "loft_add": _loft_executor, "revolve_add": _primary_executor, "revolve_cut": _primary_executor, "hole_blind": _hole_executor, diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index 29bff622..b211bf49 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -1,9 +1,9 @@ """Core CDSL sketch resolver. 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. +polygons, and closed analytic line/arc/circle/B-spline 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 @@ -81,6 +81,11 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: if edge["type"] == "arc": output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) output["normal"] = list(normal) + elif edge["type"] == "bspline": + output["points_mm"] = [ + _to_3d(workplane, point[0], point[1]) + for point in edge["points_mm"] + ] transformed.append(output) return transformed @@ -118,6 +123,8 @@ def _reverse(edge: _Ctx) -> _Ctx: 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"]) + if output.get("type") == "bspline": + output["points_mm"] = list(reversed(output["points_mm"])) return output @@ -163,7 +170,16 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: if kind == "circle": return _circle_edges(segment) if kind == "bspline": - raise ValueError("analytic_contours: bspline requires an explicit approximation capability") + points = segment.get("points") or [] + if len(points) < 3: + raise ValueError("analytic_contours: bspline needs at least 3 interpolation points") + converted = [_point(point) for point in points] + return [{ + "type": "bspline", + "start_mm": converted[0], + "end_mm": converted[-1], + "points_mm": converted, + }] raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") @@ -172,6 +188,9 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) + if edge.get("type") == "bspline": + points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1]) + continue if edge.get("type") != "arc": continue center, end = edge["center_mm"], edge["end_mm"] diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py index 3ba6d541..54af4727 100644 --- a/backend/tests/test_author_guidance.py +++ b/backend/tests/test_author_guidance.py @@ -73,7 +73,7 @@ class AuthorGuidanceTests(unittest.TestCase): self.assertLessEqual(len(selection.content), 1_200) self.assertIn("世界坐标", selection.content) covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) - self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere"}) + self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread"}) def test_phase_repair_and_budget_selection_are_stable(self) -> None: guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) diff --git a/backend/tests/test_engine_loft_geometry.py b/backend/tests/test_engine_loft_geometry.py new file mode 100644 index 00000000..b40b92e6 --- /dev/null +++ b/backend/tests/test_engine_loft_geometry.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from engine.cdsl_engine.runtime import rebuild_cdsl + + +def _spline_profile(sketch_id: str, z: float) -> dict: + return { + "id": sketch_id, + "workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": { + "type": "analytic_contours", + "contours": [{ + "role": "outer", + "closed": True, + "segments": [{ + "type": "bspline", + "start": [0, 0], + "end": [0, 0], + "points": [[0, 0], [20, 0], [25, 10], [15, 18], [0, 10], [0, 0]], + }], + }], + }, + } + + +class LoftGeometryTests(unittest.TestCase): + def test_open_bspline_segment_can_close_with_other_contour_edges(self): + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "open-bspline-contour", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, + "segments": [ + {"type": "line", "start": [0, 0], "end": [0, 10]}, + { + "type": "bspline", "start": [0, 10], "end": [0, 0], + "points": [[0, 10], [4, 10], [4, 0], [0, 0]], + }, + ], + }]}, + }]}, + "features": [{ + "id": "add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "profile", "params": {"distance_mm": 5}, + }], + } + with tempfile.TemporaryDirectory() as tmp: + rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step") + self.assertGreater(rebuilt["volume_mm3"], 1.0) + + def test_bspline_loft_and_mirror_replay_produce_step(self): + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "bspline-loft-mirror", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [_spline_profile("lower", 0), _spline_profile("upper", 20)]}, + "features": [ + { + "id": "mirror_plane", + "atomic_id": "reference_plane", + "depends_on": [], + "params": {"plane": {"origin_mm": [35, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}}, + "execution_status": "supported", + }, + { + "id": "loft", + "atomic_id": "loft_add", + "depends_on": [], + "params": {"profile_sketch_ids": ["lower", "upper"]}, + "execution_status": "supported", + }, + { + "id": "mirror", + "atomic_id": "pattern_mirror", + "depends_on": ["mirror_plane", "loft"], + "params": { + "source_feature_ids": ["loft"], + "mirror_plane": { + "kind": "plane", + "owner_feature_id": "mirror_plane", + "stable_id": "mirror-plane", + "source": "runtime_snapshot", + "confidence": 1.0, + }, + }, + "selectors": [{ + "kind": "plane", + "owner_feature_id": "mirror_plane", + "stable_id": "mirror-plane", + "source": "runtime_snapshot", + "confidence": 1.0, + }], + "execution_status": "supported", + }, + ], + } + with tempfile.TemporaryDirectory() as tmp: + rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "loft.step") + self.assertGreater(rebuilt["volume_mm3"], 1.0) + self.assertEqual(rebuilt["solid_count"], 2) + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["mirror_plane", "loft", "mirror.m.loft", "mirror"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_engine_pattern_transform_contract.py b/backend/tests/test_engine_pattern_transform_contract.py index 2aba2e95..0c21b28f 100644 --- a/backend/tests/test_engine_pattern_transform_contract.py +++ b/backend/tests/test_engine_pattern_transform_contract.py @@ -316,6 +316,55 @@ class PatternTransformContractTests(unittest.TestCase): self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5) + def test_circular_pattern_rotates_analytic_arc_normals_with_the_sketch(self) -> None: + """倾斜圆草图的环形阵列必须保持每条圆弧处于旋转后的工作平面。 + + ``analytic_contours`` 在运行时会预先展开为世界坐标的圆弧边。环形阵列 + 不能只旋转边端点和圆心:Build123d 依据 edge.normal 计算三点圆弧中点, + 法向遗留在源草图平面会让中点偏离 wire 平面,最终报 + ``Cannot build face(s): wires not planar``。该 fixture 使用绕 Z 轴的 + 垂直圆草图,保证每个实例都实际变换该法向。 + """ + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "circular-arc-normal", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + { + "id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]), + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, + }, + { + "id": "boss", "workplane": _workplane(origin=[0, -10, 0], normal=[0, 1, 0]), + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, + "segments": [{"type": "circle", "center": [0, -5], "radius_mm": 1}], + }]}, + }, + ]}, + "features": [ + { + "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "base", "params": {"distance_mm": 10}, + }, + { + "id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "boss", "params": {"distance_mm": 2}, + }, + { + "id": "repeat", "atomic_id": "pattern_circular", "depends_on": ["boss_add"], + "params": { + "source_feature_ids": ["boss_add"], + "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 4, "sweep_angle_deg": 360, + }, + }, + ], + } + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cdsl, Path(directory) / "circular-arc-normal.step") + self.assertGreater(rebuilt["volume_mm3"], math.pi * 1000) + self.assertEqual(rebuilt["solid_count"], 1) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 43565df2..d31ae60f 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -918,6 +918,25 @@ class EngineRuntimeFoundationTests(unittest.TestCase): analysis = analyze_cdsl(cdsl) self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers]) + def test_two_sided_cut_uses_independent_forward_and_reverse_distances(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"].append({ + "id": "cut", "workplane": {**_workplane(), "origin_mm": [0, 0, 5]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }) + cdsl["features"].append({ + "id": "cut_1", "atomic_id": "extrude_cut_two_sided", "depends_on": ["base_add"], + "params": { + "distance_mm": 3, "reverse_distance_mm": 5, + "end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"}, + }, "sketch_id": "cut", + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "two-sided-cut.step") + self.assertAlmostEqual(result["volume_mm3"], 1000.0 - 8.0 * 3.141592653589793, places=5) + def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -1038,6 +1057,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(cdsl, root / f"{name}.step") self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5) + def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + base = self._base_block() + base["geometry"]["sketches"].append({ + "id": "partial", "workplane": {"origin_mm": [0, 0, 12], "x_dir": [1, 0, 0], "normal": [0, 0, -1]}, + "profile": {"type": "polygon", "vertices": [[3, -3], [9, -3], [9, 3], [3, 3]]}, + }) + base["features"].append({ + "id": "partial_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "partial", "params": {"distance_mm": 0, "end_condition": {"type": "through_next"}}, + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step") + self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5) + def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None: from cdsl_engine.runtime import rebuild_cdsl diff --git a/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md b/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md new file mode 100644 index 00000000..5b651567 --- /dev/null +++ b/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md @@ -0,0 +1,103 @@ +# CADFS 基线 Engine 能力缺口与补齐清单 + +基于原始 `cadfs_to_cdsl/output` 全量报告。受影响样本数可重叠,不可相加。 + +## P0:主体、草图与核心特征 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| B 样条草图 / `skFitSpline` | 未单独统计 | 控制点、闭合 wire、B-spline edge,以及供 extrude、loft、sweep 使用的 profile | +| 复杂草图轮廓 | 未单独统计 | 直线、圆弧、圆、椭圆、样条、多 wire 和孔洞的稳定闭合与排序 | +| `shell` | 696 | 移除面、壁厚、内/外方向、多实体和失败诊断 | +| `sweep` | 326 | 截面、路径、导轨、实体/曲面模式、扭转和过渡策略 | +| `loft` | 309 | 多 profile、闭合/开口 profile、导轨、实体/曲面模式和 profile 对齐 | +| `booleanBodies` | 187 | union、subtract、intersect、目标/工具 body 和保留工具体策略 | +| `circularPattern` | 232 | source feature 重放、旋转轴、数量、角度范围和嵌套 pattern | + +## P0:拉伸语义 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude_cut_through_all` | 303 | 按目标 body 实际交段执行穿透全部切除 | +| `extrude_cut_two_sided` | 262 | 正反独立距离和终止条件的双向切除 | +| `extrude_extent:up_to_surface` | 95 | 到面终止与非平面 profile 裁剪 | +| `extrude_extent:up_to_next` | 49 | 到下一实体终止 | +| `extrude_add_through_all` | 4 | 穿透全部加料拉伸 | +| `extrude_extent:up_to_vertex` | 4 | 到顶点终止 | +| `extrude_surface_or_mixed` | 4 | 曲面拉伸和 surface/solid 混合拓扑 | +| `extrude_extent:up_to_body` | 2 | 到指定 body 终止 | + +## P1:selector 与基准面 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude_profile_topology:intersect` | 382 | 面/边交集 selector 与持久化 | +| `extrude_profile_topology:cap_face` | 200 | 拉伸端盖面 selector | +| `extrude_profile_topology:cap_edge` | 133 | 拉伸端盖边 selector | +| `reference_plane:line_angle` | 123 | 线-角度基准面 | +| `extrude_profile_topology:swept_face` | 99 | 侧壁面 selector | +| `reference_plane:plane_point` | 47 | 平面-点基准面 | +| `reference_plane:three_point` | 35 | 三点基准面 | +| `reference_plane:mid_plane` | 25 | 两平面中面 | +| `reference_plane:line_point` | 23 | 线-点基准面 | +| `extrude_profile_topology:offset_face` | 18 | 偏移面 selector | +| `extrude_profile_topology:swept_edge` | 16 | 侧壁边 selector | +| `reference_plane:curve_point` | 13 | 曲线-点基准面 | +| `extrude_profile_topology:mid_cap_edge` | 2 | 中性面端盖边 selector | + +## P1:已有特征的未覆盖语义 + +| 特征 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude` | 3,863 | 复杂 profile、拓扑引用和曲面/混合实体语义 | +| `fillet` | 1,913 | 稳定边选择、切线传播、半径可行性 | +| `revolve` | 742 | 轴选择、方向/角度、曲面模式和 profile 表达 | +| `hole` | 623 | 孔型、螺纹、沉头/沉孔、终止条件和宿主面引用 | +| `chamfer` | 588 | 距离-角度、双距离等参数及稳定边选择 | +| `revolve_surface` | 367 | 曲面旋转及其与实体布尔的混合策略 | +| `mirror` | 263 | source replay、镜像面引用和嵌套 pattern | +| `cPlane` | 145 | 除 OFFSET 外的基准面构造和可重用 frame | + +## P2:未覆盖的 FeatureScript 操作 + +| 能力缺口 | 需要补足的能力 | +| --- | --- | +| `draft` | 拔模面与中性面定义 | +| `thicken` | 曲面加厚和方向控制 | +| `split` | 面/实体分割与结果 body 管理 | +| `moveFace` | 偏移、旋转、平移现有面 | +| `deleteFace` | 删除面及修补策略 | +| `replaceFace` | 面替换与拓扑更新 | +| `derive` | 受控外部派生模型协议 | +| `import` | 受控外部几何导入协议 | + +## 补齐清单 + +### 第一阶段 + +- [ ] 支持 B 样条草图和闭合 profile。 +- [ ] 新增 `loft_add`。 +- [ ] 新增 `shell`。 +- [ ] 新增 `sweep_add`。 +- [ ] 新增多 body boolean:union、subtract、intersect。 +- [ ] 为以上 FeatureScript 操作完成 lowering、schema、executor、adapter、selector 和端到端回归。 + +### 第二阶段 + +- [ ] 补齐 extrude 的 through-all、two-sided cut、up-to-surface、up-to-next、up-to-body、up-to-vertex。 +- [ ] 支持 `revolve_surface` 和 surface/solid 混合策略。 +- [ ] 补齐 `hole_wizard` 的 CADFS 孔型、螺纹和终止条件。 +- [ ] 补齐 `fillet`/`chamfer` 的参数变体、可行性预检查和稳定失败诊断。 +- [ ] 补齐 `circularPattern` 和 `mirror` 的 source replay、基准引用和嵌套 pattern。 + +### 第三阶段 + +- [ ] 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 等面/边 selector。 +- [ ] 补齐 line-angle、plane-point、three-point、mid-plane、line-point、curve-point 基准面。 +- [ ] 补齐 selector 的 owner、几何签名和歧义解析。 + +### 第四阶段 + +- [ ] 支持 `draft`、`thicken`、`split`。 +- [ ] 支持 `moveFace`、`deleteFace`、`replaceFace`。 +- [ ] 定义并实现 `derive`、`import` 的受控资产协议。 diff --git a/cadfs_to_cdsl/README.md b/cadfs_to_cdsl/README.md index 7cdb1019..28f2a28f 100644 --- a/cadfs_to_cdsl/README.md +++ b/cadfs_to_cdsl/README.md @@ -14,3 +14,28 @@ PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline All stages are resumable. Use `--force` after changing converter behavior. The original CADFS directory is read-only; generated evidence is written under `cadfs_to_cdsl/output/samples//`. + +## Representative regression pool + +Generate the checked-in representative manifest after a full corpus conversion: + +```bash +PYTHONPATH=backend:. python -m cadfs_to_cdsl regression-select +``` + +The selector deterministically covers every observed FeatureScript modeling +operation, sketch entity type, lowered CDSL atomic operation, unsupported +operation, and unsupported engine-capability variant. It records which atomic +operations have a successful engine baseline and which are diagnostic-only; +the latter are never reported as passing builds. A normal engine change has a +small, executable STEP regression command: + +```bash +PYTHONPATH=backend:. python -m cadfs_to_cdsl regression --tier engine --stage rebuild +``` + +The command reruns by default; use `--resume` only to inspect cached results. +Use `--tier conversion --stage convert` to verify feature lowering and +diagnostics, or `--tier all --stage pipeline` for the complete representative +conversion/rebuild/compare pass. Unsupported CADFS features remain diagnostic +coverage, not expected successful engine builds. diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py index 9bcc1777..378449a1 100644 --- a/cadfs_to_cdsl/cli.py +++ b/cadfs_to_cdsl/cli.py @@ -4,6 +4,7 @@ import argparse, json from pathlib import Path from .describe import describe_samples from .pipeline import load_samples, run_stage, scan, select_samples +from .regression import regression_sample_ids, summarize_regression, write_regression_manifest from .reports import generate_markdown_report, generate_reports, read_json DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test") @@ -13,11 +14,21 @@ DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output") def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP") commands = parser.add_subparsers(dest="command", required=True) - for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe"): + for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe", "regression-select", "regression"): command = commands.add_parser(name) command.add_argument("--input", type=Path, default=DEFAULT_INPUT) command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - if name == "describe": + if name == "regression-select": + command.add_argument("--manifest", type=Path, default=Path("cadfs_to_cdsl/regression/manifest.json")) + elif name == "regression": + command.add_argument("--manifest", type=Path, default=Path("cadfs_to_cdsl/regression/manifest.json")) + command.add_argument("--tier", choices=("engine", "conversion", "all"), default="engine") + command.add_argument("--stage", choices=("convert", "rebuild", "compare", "pipeline"), default="rebuild") + command.add_argument("--workers", type=int, default=1) + command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp") + command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)") + command.add_argument("--resume", action="store_true", help="reuse cached per-sample stage results instead of rerunning them") + elif name == "describe": command.add_argument("--shard", help="input shard directory to describe, for example 0005") command.add_argument("--sample-id", action="append") command.add_argument("--offset", type=int, default=0) @@ -46,6 +57,16 @@ def main(argv: list[str] | None = None) -> int: args.output.mkdir(parents=True, exist_ok=True) if args.command == "scan": records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")} + elif args.command == "regression-select": + manifest = write_regression_manifest(args.output, args.manifest) + result = {"manifest": str(args.manifest), "selected_sample_count": manifest["selected_sample_count"], "engine_sample_count": manifest["engine_sample_count"]} + elif args.command == "regression": + if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8") + if args.timeout_seconds <= 0: raise ValueError("--timeout-seconds must be positive") + sample_ids = regression_sample_ids(args.manifest, args.tier) + samples = select_samples(load_samples(args.input, args.output), sample_ids=sample_ids) + records = run_stage(args.stage, samples, args.output, force=not args.resume, compare_mode=args.compare_mode, timeout_seconds=args.timeout_seconds, workers=args.workers) + result = {"tier": args.tier, "stage": args.stage, "manifest": str(args.manifest), **summarize_regression(records)} elif args.command == "describe": records, result = describe_samples( args.input, diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index 3a360bd2..7dde2219 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -119,7 +119,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: if model.sketches: args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}" model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name)) - elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies"}: + elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "transform"}: fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}" feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name) model.features.append(feature_ir); model.steps.append(feature_ir) diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 0d7fd0d8..ce37a358 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -8,7 +8,7 @@ from .ir import Call, FeatureIR, ModelIR, SketchIR from .query_parser import parse_query, walk_calls -UNSUPPORTED = {"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive"} +UNSUPPORTED = {"shell", "sweep", "draft", "thicken", "split", "booleanBodies", "moveFace", "replaceFace", "deleteFace", "import", "derive"} PLANES = { "Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]}, "Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]}, @@ -65,6 +65,43 @@ def _cross(a: list[float], b: list[float]) -> list[float]: return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] +def _dot(a: list[float], b: list[float]) -> float: return sum(left * right for left, right in zip(a, b)) + + +def _unit(value: list[float], message: str) -> list[float]: + length = math.sqrt(_dot(value, value)) + if length <= 1e-9: raise ValueError(message) + return [component / length for component in value] + + +def _sub(a: list[float], b: list[float]) -> list[float]: return [a[index] - b[index] for index in range(3)] + + +def _rotate(value: list[float], axis: list[float], angle_rad: float) -> list[float]: + axis = _unit(axis, "rotation axis is degenerate") + cosine, sine = math.cos(angle_rad), math.sin(angle_rad) + cross = _cross(axis, value); projection = _dot(axis, value) * (1.0 - cosine) + return [value[index] * cosine + cross[index] * sine + axis[index] * projection for index in range(3)] + + +def _translate_frame(frame: dict[str, Any], offset: list[float]) -> dict[str, Any]: + return {**frame, "origin_mm": [frame["origin_mm"][index] + offset[index] for index in range(3)]} + + +def _rotate_point(point: list[float], axis: dict[str, list[float]], angle_rad: float) -> list[float]: + relative = _rotate(_sub(point, axis["origin_mm"]), axis["direction"], angle_rad) + return [axis["origin_mm"][index] + relative[index] for index in range(3)] + + +def _rotate_frame(frame: dict[str, Any], axis: dict[str, list[float]], angle_rad: float) -> dict[str, Any]: + return { + **frame, + "origin_mm": _rotate_point(frame["origin_mm"], axis, angle_rad), + "x_dir": _rotate(frame["x_dir"], axis["direction"], angle_rad), + "normal": _rotate(frame["normal"], axis["direction"], angle_rad), + } + + def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"]) @@ -81,6 +118,12 @@ def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]} +def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict[str, list[float]]: + normal = _unit(normal, "reference plane normal is degenerate") + x_dir = _sub(x_dir, [normal[index] * _dot(x_dir, normal) for index in range(3)]) + return {"origin_mm": origin, "x_dir": _unit(x_dir, "reference plane x direction is degenerate"), "normal": normal} + + def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]: for call in walk_calls(value): if call.name in {"makeId", "qCreatedBy"}: @@ -107,6 +150,7 @@ def _end_condition(value: Any) -> dict[str, Any]: if name == "BLIND": return {"type": "blind", "solidworks_code": 0} if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8} if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1} + if name == "UP_TO_NEXT": return {"type": "through_next", "solidworks_code": 4} raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}") @@ -137,12 +181,13 @@ def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], lis if _endpoint(item, True) == tail: item["start"], item["end"] = item["end"], item["start"] if item["type"] == "arc": item["clockwise"] = not item["clockwise"] + elif item["type"] == "bspline": item["points"] = list(reversed(item["points"])) contour.append(item); tail = _endpoint(item, True) if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour}) return contours + circles, construction -def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: +def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = False) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = [] for entity in sketch.entities: p = entity.params @@ -150,6 +195,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])} elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)} elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"])) + elif entity.operation == "skFitSpline": + points = [_point(point) for point in p.get("points") or []] + if len(points) < 3: raise ValueError("fit spline needs at least 3 points") + item = {"type": "bspline", "start": points[0], "end": points[-1], "points": points} else: unsupported.append(entity.operation); continue (explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) @@ -162,7 +211,9 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An else: contours, open_segments = _contours(segments) if open_segments: - raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") + if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") + profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments} + return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities construction = list(explicit_construction) if not contours: profile = {"type": "analytic_contours", "contours": [], "construction": construction} @@ -193,6 +244,89 @@ def _source_sketch(params: dict[str, Any]) -> str | None: return None +def _pattern_source_features(value: Any, previous: list[str]) -> list[str]: + sources = [] + for call in walk_calls(value): + if call.name != "makeQuery" or not call.args: + continue + owner = symbolic_string(call.args[0]) + if "F" not in owner: + continue + source = "f_" + owner[owner.find("F"):].split(".", 1)[0] + if source in previous and source not in sources: + sources.append(source) + if not sources: + raise ValueError("pattern source features are unresolved") + return sources + + +def _transform_source_features(value: Any, previous: list[str]) -> list[str]: + sources = [] + for call in walk_calls(value): + if call.name not in {"makeQuery", "qCreatedBy"} or not call.args: + continue + owner = symbolic_string(call.args[0]) + if "F" not in owner: + continue + source = "f_" + owner[owner.find("F"):].split(".", 1)[0] + if source in previous and source not in sources: + sources.append(source) + if not sources: + raise ValueError("transform source features are unresolved") + return sources + + +def _circular_pattern_axis( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, list[float]]: + query = parse_query(value) + entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") + if entity is None or query.source_sketch not in sketch_by_source: + raise ValueError("circular pattern axis is unresolved") + plane = sketch_by_source[query.source_sketch]["workplane"] + if entity["type"] == "line": + start = _global(plane, entity["start"]); end = _global(plane, entity["end"]) + direction = [end[index] - start[index] for index in range(3)] + norm = math.sqrt(sum(component * component for component in direction)) + if norm <= 1e-9: + raise ValueError("circular pattern axis line is degenerate") + return {"origin_mm": start, "direction": [component / norm for component in direction]} + if entity["type"] == "circle": + frame = feature_frames.get(query.owner_feature or "") + if frame and query.is_start is not None: + plane = frame["start" if query.is_start else "end"] + return {"origin_mm": _global(plane, entity["center"]), "direction": list(plane["normal"])} + raise ValueError("circular pattern axis must be a sketch line or circular edge") + + +def _loft_profile_sketches(params: dict[str, Any]) -> list[str]: + # CADFS loft 的 profile 是草图 IMPRINT 面;几何仍来自原始闭合草图, + # 保留草图 source,不能把前序实体的选中面近似为新的放样轮廓。 + profiles = params.get("sheetProfilesArray") + if not isinstance(profiles, list): + raise ValueError("loft sheetProfilesArray is unresolved") + sources: list[str] = [] + for profile in profiles: + query_value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else profile + query = parse_query(query_value) + if query.topology_type and query.topology_type != "IMPRINT": + raise UnsupportedCapability( + f"loft_profile_topology:{query.topology_type.lower()}", + f"current CDSL loft only supports sketch-imprint profiles, not {query.topology_type}", + ) + if not query.source_sketch: + raise ValueError("loft profile sketch query is unresolved") + sources.append(query.source_sketch) + if len(sources) < 2: + raise ValueError("loft requires at least two profile sketches") + if len(set(sources)) != len(sources): + raise ValueError("loft profile sketches must be distinct") + return sources + + def _profile_query_kind(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray"): if key in params: @@ -215,18 +349,243 @@ def _default_plane(value: Any) -> dict[str, Any] | None: return None +def _entity_from_query( + query: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[dict[str, Any], dict[str, Any], str]: + info = parse_query(query); source = info.source_sketch or ""; token = info.source_entity or "" + available = entity_by_sketch.get(source) or {} + entity = available.get(token) + if entity is None: + entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="") + entity = available.get(entity_id) + sketch = sketch_by_source.get(source) + if entity is None or sketch is None: + raise ValueError("reference geometry source is unresolved") + return entity, sketch["workplane"], token + + +def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> list[float]: + if entity["type"] == "point": return _global(plane, entity["point"]) + if entity["type"] == "line": + local = entity["end"] if ".end" in token else entity["start"] + return _global(plane, local) + if entity["type"] == "bspline": + index = next((int(part) - 1 for part in token.split(".") if part.isdigit()), 0) + points = entity.get("points") or [] + if not points: raise ValueError("B-spline reference point is unresolved") + return _global(plane, points[max(0, min(index, len(points) - 1))]) + raise ValueError("reference entity does not define a point") + + +def _entity_line(entity: dict[str, Any], plane: dict[str, Any]) -> tuple[list[float], list[float]]: + if entity["type"] != "line": raise ValueError("reference entity is not a line") + return _global(plane, entity["start"]), _global(plane, entity["end"]) + + +def _query_plane( + query: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, Any]: + try: + return _plane_from_query(query, feature_frames, sketch_by_source) + except ValueError: + info = parse_query(query) + if info.topology_type != "SWEPT_FACE" or not info.owner_feature: + raise + entity, source_plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) + start, end = _entity_line(entity, source_plane); frame = feature_frames.get(info.owner_feature) + if frame is None: raise ValueError("swept face owner frame is unresolved") + direction = _unit(_sub(end, start), "swept face source line is degenerate") + normal = _cross(direction, frame["end"]["normal"]) + return _frame(start, direction, normal) + + +def _query_line( + query: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[list[float], list[float]]: + info = parse_query(query) + entity, plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) + if info.topology_type == "CAP_EDGE" and info.owner_feature in feature_frames: + frame = feature_frames[info.owner_feature]["start" if info.is_start else "end"] + plane = frame + return _entity_line(entity, plane) + + +def _transform_axis( + query: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, list[float]]: + start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) + return {"origin_mm": start, "direction": _unit(_sub(end, start), "transform axis is degenerate")} + + +def _bake_transform( + params: dict[str, Any], + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + feature_source_by_id: dict[str, str], + sketches_by_id: dict[str, dict[str, Any]], + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> None: + if _bool(params.get("makeCopy")): + raise UnsupportedCapability("transform", "current CDSL engine cannot exactly copy transformed CADFS source bodies") + sources = _transform_source_features(params.get("entities"), previous) + if len(sources) != 1: + raise UnsupportedCapability("transform", "current CDSL engine cannot exactly transform multiple selected CADFS source bodies") + source_id = sources[0]; source = feature_by_id.get(source_id) + if source is None or source.get("atomic_id") not in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add"}: + raise UnsupportedCapability("transform", "current CDSL engine can only bake a direct additive extrusion or revolve transform") + sketch = sketches_by_id.get(str(source.get("sketch_id") or "")) + source_feature_id = feature_source_by_id.get(source_id) + if sketch is None or source_feature_id is None: + raise UnsupportedCapability("transform", "current CDSL engine cannot resolve the transformed source feature geometry") + transform_type = str(params.get("transformType") or "").split(".")[-1].upper() + if transform_type == "TRANSLATION_3D": + offset = [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")] + transform_frame = lambda frame: _translate_frame(frame, offset) + transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]} + elif transform_type == "ROTATION": + axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch) + angle_rad = math.radians(_number(params.get("angle"), True)) + transform_frame = lambda frame: _rotate_frame(frame, axis, angle_rad) + transform_axis = lambda value: { + **value, + "origin_mm": _rotate_point(value["origin_mm"], axis, angle_rad), + "direction": _rotate(value["direction"], axis["direction"], angle_rad), + } + else: + raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly bake {transform_type or 'unknown'} transform") + sketch["workplane"] = transform_frame(sketch["workplane"]) + frame = feature_frames.get(source_feature_id) + if frame is not None: + feature_frames[source_feature_id] = {key: transform_frame(value) for key, value in frame.items()} + source_axis = source.get("params", {}).get("axis") + if isinstance(source_axis, dict) and source_axis.get("origin_mm") and source_axis.get("direction"): + source["params"]["axis"] = transform_axis(source_axis) + + +def _query_point( + query: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> list[float]: + info = parse_query(query) + if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: + references = _source_refs(query) + if len(references) >= 2: + plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] + lines = [] + for source, token in references: + available = entity_by_sketch.get(source) or {} + entity = available.get(token) + if entity is None: + entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="") + entity = available.get(entity_id) + if entity and entity.get("type") == "line": lines.append(_entity_line(entity, plane)) + if len(lines) >= 2: + pairs = [(math.dist(left, right), left) for left in lines[0] for right in lines[1]] + distance, point = min(pairs, key=lambda item: item[0]) + if distance <= 1e-5: return point + entity, plane, token = _entity_from_query(query, sketch_by_source, entity_by_sketch) + if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: + plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] + return _entity_point(entity, plane, token) + + +def _cplane( + params: dict[str, Any], + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, Any]: + plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper() + entities = _queries(params.get("entities")) + if plane_type == "OFFSET": + return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), _number(params.get("offset", 0), True)) + if plane_type == "LINE_ANGLE": + line_query = next((item for item in entities if parse_query(item).source_entity), None) + if line_query is None: raise ValueError("line-angle reference line is unresolved") + base_query = next((item for item in entities if item is not line_query), line_query) + try: + base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) + except ValueError: + _, base, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) + start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) + axis = _sub(end, start); angle = _number(params.get("angle", 0.0)) + if _bool(params.get("oppositeDirection")): angle = -angle + return _frame(start, _rotate(base["x_dir"], axis, math.radians(angle)), _rotate(base["normal"], axis, math.radians(angle))) + if plane_type == "PLANE_POINT": + base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None) + point_query = next((item for item in entities if item is not base_query), None) + if base_query is None or point_query is None: raise ValueError("plane-point references are unresolved") + base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) + return _frame(_query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch), base["x_dir"], base["normal"]) + if plane_type == "CURVE_POINT": + point_query = next((item for item in entities if parse_query(item).kind and "vertex" in parse_query(item).kind), None) + curve_query = next((item for item in entities if item is not point_query), None) + if point_query is None or curve_query is None: raise ValueError("curve-point references are unresolved") + point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) + start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch) + _, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch) + return _frame(point, source_plane["normal"], _sub(end, start)) + if plane_type == "THREE_POINT": + if len(entities) != 3: raise ValueError("three-point plane requires exactly three points") + first, second, third = [_query_point(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] + normal = _cross(_sub(second, first), _sub(third, first)) + if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] + return _frame(first, _sub(second, first), normal) + if plane_type == "LINE_POINT": + line_query = next((item for item in entities if "edge" in (parse_query(item).kind or "")), None) + point_query = next((item for item in entities if item is not line_query), None) + if line_query is None or point_query is None: raise ValueError("line-point references are unresolved") + start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) + point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) + direction = _sub(end, start); normal = _cross(direction, _sub(point, start)) + if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] + return _frame(start, direction, normal) + if plane_type == "MID_PLANE": + if len(entities) != 2: raise ValueError("mid-plane requires exactly two reference planes") + first, second = [_query_plane(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] + alignment = 1.0 if _dot(first["normal"], second["normal"]) >= 0 else -1.0 + offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first["normal"]) * alignment + return _shift_plane(first, offset / 2.0) + raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane") + + def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: diagnostics: list[dict[str, Any]] = []; history = [] - sketches: list[dict[str, Any]] = []; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {} + sketches: list[dict[str, Any]] = []; sketches_by_id: dict[str, dict[str, Any]] = {}; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {} feature_frames: dict[str, dict[str, Any]] = {} features: list[dict[str, Any]] = []; complete = True; previous: list[str] = [] + feature_by_id: dict[str, dict[str, Any]] = {}; feature_source_by_id: dict[str, str] = {} for step in model.steps: if isinstance(step, SketchIR): history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]}) try: plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source) - lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities except Exception as exc: + # 开放草图不能作为实体 profile,但其几何仍可能是后续基准面、 + # 阵列轴或旋转轴的精确引用。保留为 reference 草图,后续实体 + # 特征仍由 _profile_executable 明确拒绝,不能静默把开放轮廓实体化。 + try: + plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source) + lowered, entities = _lower_sketch(step, plane, allow_open=True) + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + except Exception: + pass diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False continue item = step @@ -235,12 +594,14 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue try: fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any] - if item.operation == "cPlane": - plane_type = str(p.get("cplaneType") or "OFFSET").split(".")[-1].upper() - if plane_type != "OFFSET": - raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter only supports exact OFFSET reference planes, not {plane_type}") - base = _plane_from_query(p.get("entities"), feature_frames, sketch_by_source); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset) - feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"} + if item.operation == "transform": + # 仅将单一、直接的原始实体变换烘焙回其输入几何。不能移动当前 + # 聚合主体:CADFS transform 可能只选择 pattern copy 或多 body。 + _bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch) + continue + elif item.operation == "cPlane": + plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch) + feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"} feature_frames[item.feature_id] = {"start": plane, "end": plane} elif item.operation == "extrude": if p.get("surfaceOperationType") is not None: @@ -255,19 +616,21 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0 operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT")) second = _bool(p.get("hasSecondDirection")) - if cutting and (second or end["type"] != "blind"): - capability = "extrude_cut_two_sided" if second or end["type"] == "mid_plane" else f"extrude_cut_{end['type']}" + if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}: + capability = f"extrude_cut_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") - if not cutting and not second and end["type"] not in {"blind", "mid_plane"}: + if not cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}: capability = f"extrude_add_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") if second: - atomic = "extrude_add_two_sided"; params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} + atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" + params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} reverse_depth = _number(p.get("secondDirectionDepth", depth), True) params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": _end_condition(p.get("secondDirectionBound"))}) - elif end["type"] == "mid_plane" and not cutting: + elif end["type"] == "mid_plane": blind = _end_condition("BLIND") - atomic = "extrude_add_two_sided"; params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)} + atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" + params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)} else: atomic = "extrude_cut_blind" if cutting else "extrude_add_blind" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} @@ -279,6 +642,22 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: elif end["type"] == "mid_plane": direction = -1 if reverse else 1 feature_frames[item.feature_id] = {"start": _shift_plane(plane, -direction * depth / 2), "end": _shift_plane(plane, direction * depth / 2)} + elif item.operation == "loft": + sources = _loft_profile_sketches(p) + missing = [source for source in sources if source not in sketch_by_source] + if missing: + raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) + non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] + if non_closed: + raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "loft_add", + "depends_on": depends, + "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, + "execution_status": "supported", + } elif item.operation == "revolve": if p.get("surfaceOperationType") is not None and p.get("operationType") is None: raise UnsupportedCapability("revolve_surface", "current CDSL engine has no exact surface-revolve operation") @@ -365,6 +744,25 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)} if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True} feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"} + elif item.operation == "circularPattern": + sources = _pattern_source_features(p.get("entities"), previous) + axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch) + count = int(_number(p.get("instanceCount"))) + if count < 1: + raise ValueError("circular pattern instanceCount must be positive") + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "pattern_circular", + "depends_on": list(dict.fromkeys(sources + depends)), + "params": { + "source_feature_ids": sources, + "axis": axis, + "pattern_count": count, + "sweep_angle_deg": _number(p.get("angle", 360.0)), + }, + "execution_status": "supported", + } elif item.operation == "mirror": owners = [] for call in walk_calls(p.get("entities")): @@ -387,7 +785,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"} else: raise ValueError(f"operation mapping not implemented: {item.operation}") - features.append(feature); previous.append(fid) + features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id; previous.append(fid) except UnsupportedCapability as exc: diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False except Exception as exc: diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index 5e7655f6..0cee6328 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -103,7 +103,12 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]: directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json" status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force) - if status.get("conversion_status") != "converted_complete": return status + # A partial conversion may still retain a self-contained, semantically + # executable CDSL prefix. That prefix is valuable engine evidence and + # must be rebuilt instead of being hidden behind the conversion label. + # ``rebuild_candidate`` keeps the two outcomes distinct by reporting a + # runtime-ineligible candidate when no executable body can be produced. + if not (directory / "candidate.cdsl.json").exists(): return status rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step" if not force and rebuild_path.exists() and status.get("rebuild_status"): if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status diff --git a/cadfs_to_cdsl/regression.py b/cadfs_to_cdsl/regression.py new file mode 100644 index 00000000..8ca6e393 --- /dev/null +++ b/cadfs_to_cdsl/regression.py @@ -0,0 +1,216 @@ +"""Deterministic, feature-covering CADFS regression pools. + +The converted CADFS corpus is deliberately kept outside version control. This +module turns the artifacts already produced in ``output/samples`` into a small, +versionable manifest that records why every selected source sample is needed. +""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path +from typing import Any + +from .reports import read_json, write_json + + +REGRESSION_SCHEMA = "cadfs_to_cdsl.regression.v1" + + +def _tag(kind: str, value: str) -> str: + return f"{kind}:{value}" + + +def _split_tag(tag: str) -> tuple[str, str]: + kind, separator, value = tag.partition(":") + if not separator: + raise ValueError(f"Malformed regression coverage tag: {tag!r}") + return kind, value + + +def _sample_features(sample_dir: Path) -> dict[str, Any]: + """Read coverage signals from a converted sample without reparsing CADFS.""" + history = read_json(sample_dir / "history.json") if (sample_dir / "history.json").exists() else [] + candidate = read_json(sample_dir / "candidate.cdsl.json") if (sample_dir / "candidate.cdsl.json").exists() else {} + diagnostics = read_json(sample_dir / "diagnostics.json") if (sample_dir / "diagnostics.json").exists() else [] + status = read_json(sample_dir / "status.json") if (sample_dir / "status.json").exists() else {} + + source_operations: set[str] = set() + sketch_entities: set[str] = set() + for item in history: + if not isinstance(item, dict): + continue + operation = item.get("operation") + if isinstance(operation, str): + source_operations.add(operation) + for entity in item.get("entities") or (): + if isinstance(entity, dict) and isinstance(entity.get("operation"), str): + sketch_entities.add(str(entity["operation"])) + + atomic_ids = { + str(feature["atomic_id"]) + for feature in candidate.get("features") or () + if isinstance(feature, dict) and isinstance(feature.get("atomic_id"), str) + } + unsupported_capabilities = { + str(item["capability"]) + for item in diagnostics + if isinstance(item, dict) + and item.get("code") == "unsupported_engine_capability" + and isinstance(item.get("capability"), str) + } + unsupported_operations = { + str(item["operation"]) + for item in diagnostics + if isinstance(item, dict) + and item.get("code") == "unsupported_operation" + and isinstance(item.get("operation"), str) + } + return { + "sample_id": sample_dir.name, + "source_operations": sorted(source_operations), + "sketch_entities": sorted(sketch_entities), + "engine_atomic_ids": sorted(atomic_ids), + "unsupported_capabilities": sorted(unsupported_capabilities), + "unsupported_operations": sorted(unsupported_operations), + "has_candidate": bool(candidate), + "baseline": { + key: status[key] + for key in ("conversion_status", "rebuild_status", "comparison_decision", "status") + if key in status + }, + } + + +def _tags(record: dict[str, Any]) -> set[str]: + return { + *(_tag("source_operation", item) for item in record["source_operations"]), + *(_tag("sketch_entity", item) for item in record["sketch_entities"]), + *(_tag("engine_atomic", item) for item in record["engine_atomic_ids"]), + *(_tag("unsupported_capability", item) for item in record["unsupported_capabilities"]), + *(_tag("unsupported_operation", item) for item in record["unsupported_operations"]), + } + + +def _greedy_cover(records: list[dict[str, Any]], wanted: set[str], selected: list[dict[str, Any]]) -> dict[str, list[str]]: + """Cover ``wanted`` with stable maximum-coverage selection. + + The sample id breaks ties, so a corpus refresh is reviewable and never + changes pool membership due to directory iteration order. + """ + coverage: dict[str, list[str]] = {} + selected_ids = {record["sample_id"] for record in selected} + remaining = set(wanted) + while remaining: + choices = [record for record in records if record["sample_id"] not in selected_ids] + if not choices: + break + choice = min( + choices, + key=lambda record: (-len(_tags(record) & remaining), record["sample_id"]), + ) + gained = _tags(choice) & remaining + if not gained: + break + selected.append(choice) + selected_ids.add(choice["sample_id"]) + for tag in sorted(gained): + coverage.setdefault(tag, []).append(choice["sample_id"]) + remaining -= gained + return coverage + + +def build_regression_manifest(output: Path) -> dict[str, Any]: + """Build a compact pool covering every observed CADFS modeling signal.""" + records = [_sample_features(directory) for directory in sorted((output / "samples").glob("*")) if directory.is_dir()] + if not records: + raise FileNotFoundError(f"No converted CADFS samples found under {output / 'samples'}") + + all_tags = set().union(*(_tags(record) for record in records)) + selected: list[dict[str, Any]] = [] + + # Give the engine pool a current successful baseline per atomic operation + # whenever the corpus has one. Unsupported/failed atoms remain covered by + # the full conversion pool and are never represented as passing builds. + engine_records = [ + record for record in records + if record["has_candidate"] and record["baseline"].get("rebuild_status") == "rebuilt" + ] + engine_tags = {_tag("engine_atomic", atom) for record in engine_records for atom in record["engine_atomic_ids"]} + _greedy_cover(engine_records, engine_tags, selected) + # Samples chosen for the executable baseline can also cover source and + # converter signals. Count those signals before the general pass so the + # final fixture remains genuinely representative rather than redundant. + coverage = { + tag: sorted(record["sample_id"] for record in selected if tag in _tags(record)) + for tag in sorted(set().union(*(_tags(record) for record in selected))) + } + all_coverage = _greedy_cover(records, all_tags - set(coverage), selected) + for tag, sample_ids in all_coverage.items(): + coverage[tag] = sorted(set(coverage.get(tag, []) + sample_ids)) + + selected_ids = {record["sample_id"] for record in selected} + missing = sorted(all_tags - set(coverage)) + if missing: + raise RuntimeError("Unable to cover CADFS regression features: " + ", ".join(missing)) + + entries = [] + for record in sorted(selected, key=lambda item: item["sample_id"]): + tags = _tags(record) + tiers = ["conversion"] + if record["sample_id"] in {item["sample_id"] for item in engine_records}: + tiers.append("engine") + entries.append({ + **record, + "tiers": tiers, + "selection_reasons": sorted(tag for tag, sample_ids in coverage.items() if record["sample_id"] in sample_ids), + }) + + inventory: dict[str, dict[str, int]] = {} + for kind, _ in map(_split_tag, sorted(all_tags)): + inventory.setdefault(kind, {}) + for tag in all_tags: + kind, value = _split_tag(tag) + inventory[kind][value] = sum(tag in _tags(record) for record in records) + + return { + "schema": REGRESSION_SCHEMA, + "description": "Feature-covering representative CADFS regression pool. Engine samples have a prior successful rebuild; conversion samples retain unsupported-feature diagnostics.", + "source_output": str(output), + "source_sample_count": len(records), + "selected_sample_count": len(entries), + "engine_sample_count": sum("engine" in entry["tiers"] for entry in entries), + "conversion_sample_count": len(entries), + "engine_baseline_atomic_ids": sorted(tag.removeprefix("engine_atomic:") for tag in engine_tags), + "engine_diagnostic_only_atomic_ids": sorted( + tag.removeprefix("engine_atomic:") for tag in all_tags - engine_tags if tag.startswith("engine_atomic:") + ), + "feature_inventory": {kind: dict(sorted(values.items())) for kind, values in sorted(inventory.items())}, + "coverage": coverage, + "entries": entries, + "selected_ids": sorted(selected_ids), + } + + +def write_regression_manifest(output: Path, manifest_path: Path) -> dict[str, Any]: + manifest = build_regression_manifest(output) + write_json(manifest_path, manifest) + return manifest + + +def regression_sample_ids(manifest_path: Path, tier: str) -> list[str]: + manifest = read_json(manifest_path) + if manifest.get("schema") != REGRESSION_SCHEMA: + raise ValueError(f"Unsupported regression manifest schema: {manifest.get('schema')!r}") + if tier not in {"engine", "conversion", "all"}: + raise ValueError(f"Unknown regression tier: {tier!r}") + entries = manifest.get("entries") or [] + selected = [item["sample_id"] for item in entries if tier == "all" or tier in (item.get("tiers") or ())] + if not selected: + raise ValueError(f"Regression manifest has no samples for tier {tier!r}") + return sorted(selected) + + +def summarize_regression(records: list[dict[str, Any]]) -> dict[str, Any]: + statuses = Counter(str(record.get("status") or "unknown") for record in records) + return {"sample_count": len(records), "statuses": dict(sorted(statuses.items()))} diff --git a/cadfs_to_cdsl/regression/manifest.json b/cadfs_to_cdsl/regression/manifest.json new file mode 100644 index 00000000..f866186c --- /dev/null +++ b/cadfs_to_cdsl/regression/manifest.json @@ -0,0 +1,942 @@ +{ + "conversion_sample_count": 17, + "coverage": { + "engine_atomic:chamfer": [ + "00002243", + "00111611" + ], + "engine_atomic:extrude_add_blind": [ + "00111611" + ], + "engine_atomic:extrude_add_two_sided": [ + "00002243", + "00111611" + ], + "engine_atomic:extrude_cut_blind": [ + "00002243", + "00129362" + ], + "engine_atomic:fillet": [ + "00111611" + ], + "engine_atomic:hole_wizard": [ + "00002243" + ], + "engine_atomic:pattern_mirror": [ + "00925274" + ], + "engine_atomic:reference_plane": [ + "00129362" + ], + "engine_atomic:revolve_add": [ + "00111611", + "00129362" + ], + "engine_atomic:revolve_cut": [ + "00129362" + ], + "sketch_entity:skArc": [ + "00111611", + "00129362" + ], + "sketch_entity:skCircle": [ + "00002243", + "00111611", + "00129362" + ], + "sketch_entity:skEllipse": [ + "00287955" + ], + "sketch_entity:skFitSpline": [ + "00542223" + ], + "sketch_entity:skLineSegment": [ + "00002243", + "00111611", + "00129362" + ], + "sketch_entity:skPoint": [ + "00002243", + "00129362" + ], + "source_operation:booleanBodies": [ + "00835610" + ], + "source_operation:cPlane": [ + "00129362" + ], + "source_operation:chamfer": [ + "00002243", + "00111611" + ], + "source_operation:circularPattern": [ + "00542223" + ], + "source_operation:extrude": [ + "00002243", + "00111611", + "00129362" + ], + "source_operation:fillet": [ + "00111611" + ], + "source_operation:hole": [ + "00002243" + ], + "source_operation:loft": [ + "00287955" + ], + "source_operation:mirror": [ + "00925274" + ], + "source_operation:newSketch": [ + "00002243", + "00111611", + "00129362" + ], + "source_operation:revolve": [ + "00111611", + "00129362" + ], + "source_operation:shell": [ + "00542223" + ], + "source_operation:sweep": [ + "00542223" + ], + "unsupported_capability:extrude_add_through_all": [ + "00159804" + ], + "unsupported_capability:extrude_cut_through_all": [ + "00542223" + ], + "unsupported_capability:extrude_cut_two_sided": [ + "00212904" + ], + "unsupported_capability:extrude_extent:up_to_body": [ + "00694309" + ], + "unsupported_capability:extrude_extent:up_to_next": [ + "00192744" + ], + "unsupported_capability:extrude_extent:up_to_surface": [ + "00925274" + ], + "unsupported_capability:extrude_extent:up_to_vertex": [ + "00423838" + ], + "unsupported_capability:extrude_profile_topology:cap_edge": [ + "00287955" + ], + "unsupported_capability:extrude_profile_topology:cap_face": [ + "00835610" + ], + "unsupported_capability:extrude_profile_topology:intersect": [ + "00835610" + ], + "unsupported_capability:extrude_profile_topology:mid_cap_edge": [ + "00612529" + ], + "unsupported_capability:extrude_profile_topology:offset_face": [ + "00789939" + ], + "unsupported_capability:extrude_profile_topology:swept_edge": [ + "00054089" + ], + "unsupported_capability:extrude_profile_topology:swept_face": [ + "00789939" + ], + "unsupported_capability:extrude_surface_or_mixed": [ + "00710855" + ], + "unsupported_capability:reference_plane:curve_point": [ + "00192744" + ], + "unsupported_capability:reference_plane:line_angle": [ + "00925274" + ], + "unsupported_capability:reference_plane:line_point": [ + "00035682" + ], + "unsupported_capability:reference_plane:mid_plane": [ + "00287955" + ], + "unsupported_capability:reference_plane:plane_point": [ + "00542223" + ], + "unsupported_capability:reference_plane:three_point": [ + "00212904" + ], + "unsupported_capability:revolve_surface": [ + "00835610" + ], + "unsupported_operation:booleanBodies": [ + "00835610" + ], + "unsupported_operation:circularPattern": [ + "00542223" + ], + "unsupported_operation:loft": [ + "00287955" + ], + "unsupported_operation:shell": [ + "00542223" + ], + "unsupported_operation:sweep": [ + "00542223" + ] + }, + "description": "Feature-covering representative CADFS regression pool. Engine samples have a prior successful rebuild; conversion samples retain unsupported-feature diagnostics.", + "engine_baseline_atomic_ids": [ + "chamfer", + "extrude_add_blind", + "extrude_add_two_sided", + "extrude_cut_blind", + "fillet", + "hole_wizard", + "reference_plane", + "revolve_add", + "revolve_cut" + ], + "engine_diagnostic_only_atomic_ids": [ + "pattern_mirror" + ], + "engine_sample_count": 3, + "entries": [ + { + "baseline": { + "comparison_decision": "rejected", + "conversion_status": "converted_complete", + "rebuild_status": "rebuilt", + "status": "rebuilt" + }, + "engine_atomic_ids": [ + "chamfer", + "extrude_add_two_sided", + "extrude_cut_blind", + "hole_wizard" + ], + "has_candidate": true, + "sample_id": "00002243", + "selection_reasons": [ + "engine_atomic:chamfer", + "engine_atomic:extrude_add_two_sided", + "engine_atomic:extrude_cut_blind", + "engine_atomic:hole_wizard", + "sketch_entity:skCircle", + "sketch_entity:skLineSegment", + "sketch_entity:skPoint", + "source_operation:chamfer", + "source_operation:extrude", + "source_operation:hole", + "source_operation:newSketch" + ], + "sketch_entities": [ + "skCircle", + "skLineSegment", + "skPoint" + ], + "source_operations": [ + "chamfer", + "extrude", + "hole", + "newSketch" + ], + "tiers": [ + "conversion", + "engine" + ], + "unsupported_capabilities": [], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "deferred_no_executable_feature", + "status": "deferred_no_executable_feature" + }, + "engine_atomic_ids": [ + "extrude_cut_blind", + "revolve_add" + ], + "has_candidate": true, + "sample_id": "00035682", + "selection_reasons": [ + "unsupported_capability:reference_plane:line_point" + ], + "sketch_entities": [ + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "cPlane", + "extrude", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "reference_plane:line_point" + ], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "deferred_no_executable_feature", + "status": "deferred_no_executable_feature" + }, + "engine_atomic_ids": [ + "extrude_add_blind", + "extrude_cut_blind", + "revolve_add" + ], + "has_candidate": true, + "sample_id": "00054089", + "selection_reasons": [ + "unsupported_capability:extrude_profile_topology:swept_edge" + ], + "sketch_entities": [ + "skArc", + "skLineSegment" + ], + "source_operations": [ + "extrude", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_profile_topology:swept_edge" + ], + "unsupported_operations": [] + }, + { + "baseline": { + "comparison_decision": "rejected", + "conversion_status": "converted_complete", + "rebuild_status": "rebuilt", + "status": "rebuilt" + }, + "engine_atomic_ids": [ + "chamfer", + "extrude_add_blind", + "extrude_add_two_sided", + "fillet", + "revolve_add" + ], + "has_candidate": true, + "sample_id": "00111611", + "selection_reasons": [ + "engine_atomic:chamfer", + "engine_atomic:extrude_add_blind", + "engine_atomic:extrude_add_two_sided", + "engine_atomic:fillet", + "engine_atomic:revolve_add", + "sketch_entity:skArc", + "sketch_entity:skCircle", + "sketch_entity:skLineSegment", + "source_operation:chamfer", + "source_operation:extrude", + "source_operation:fillet", + "source_operation:newSketch", + "source_operation:revolve" + ], + "sketch_entities": [ + "skArc", + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "chamfer", + "extrude", + "fillet", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion", + "engine" + ], + "unsupported_capabilities": [], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "converted_complete", + "rebuild_status": "rebuilt", + "status": "rebuilt" + }, + "engine_atomic_ids": [ + "extrude_cut_blind", + "reference_plane", + "revolve_add", + "revolve_cut" + ], + "has_candidate": true, + "sample_id": "00129362", + "selection_reasons": [ + "engine_atomic:extrude_cut_blind", + "engine_atomic:reference_plane", + "engine_atomic:revolve_add", + "engine_atomic:revolve_cut", + "sketch_entity:skArc", + "sketch_entity:skCircle", + "sketch_entity:skLineSegment", + "sketch_entity:skPoint", + "source_operation:cPlane", + "source_operation:extrude", + "source_operation:newSketch", + "source_operation:revolve" + ], + "sketch_entities": [ + "skArc", + "skCircle", + "skLineSegment", + "skPoint" + ], + "source_operations": [ + "cPlane", + "extrude", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion", + "engine" + ], + "unsupported_capabilities": [], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "reference_plane", + "revolve_add" + ], + "has_candidate": true, + "sample_id": "00159804", + "selection_reasons": [ + "unsupported_capability:extrude_add_through_all" + ], + "sketch_entities": [ + "skArc", + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "cPlane", + "circularPattern", + "extrude", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_add_through_all", + "reference_plane:line_angle" + ], + "unsupported_operations": [ + "circularPattern" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind", + "reference_plane" + ], + "has_candidate": true, + "sample_id": "00192744", + "selection_reasons": [ + "unsupported_capability:extrude_extent:up_to_next", + "unsupported_capability:reference_plane:curve_point" + ], + "sketch_entities": [ + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "cPlane", + "circularPattern", + "extrude", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_extent:up_to_next", + "reference_plane:curve_point" + ], + "unsupported_operations": [ + "circularPattern" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00212904", + "selection_reasons": [ + "unsupported_capability:extrude_cut_two_sided", + "unsupported_capability:reference_plane:three_point" + ], + "sketch_entities": [ + "skLineSegment", + "skPoint" + ], + "source_operations": [ + "cPlane", + "extrude", + "newSketch", + "shell" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_cut_two_sided", + "reference_plane:three_point" + ], + "unsupported_operations": [ + "shell" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00287955", + "selection_reasons": [ + "sketch_entity:skEllipse", + "source_operation:loft", + "unsupported_capability:extrude_profile_topology:cap_edge", + "unsupported_capability:reference_plane:mid_plane", + "unsupported_operation:loft" + ], + "sketch_entities": [ + "skEllipse", + "skFitSpline", + "skLineSegment" + ], + "source_operations": [ + "cPlane", + "extrude", + "loft", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_profile_topology:cap_edge", + "reference_plane:mid_plane" + ], + "unsupported_operations": [ + "loft" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00423838", + "selection_reasons": [ + "unsupported_capability:extrude_extent:up_to_vertex" + ], + "sketch_entities": [ + "skArc", + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "circularPattern", + "extrude", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_cut_two_sided", + "extrude_extent:up_to_vertex" + ], + "unsupported_operations": [ + "circularPattern" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00542223", + "selection_reasons": [ + "sketch_entity:skFitSpline", + "source_operation:circularPattern", + "source_operation:shell", + "source_operation:sweep", + "unsupported_capability:extrude_cut_through_all", + "unsupported_capability:reference_plane:plane_point", + "unsupported_operation:circularPattern", + "unsupported_operation:shell", + "unsupported_operation:sweep" + ], + "sketch_entities": [ + "skCircle", + "skFitSpline" + ], + "source_operations": [ + "cPlane", + "circularPattern", + "extrude", + "fillet", + "newSketch", + "shell", + "sweep" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_cut_through_all", + "reference_plane:plane_point" + ], + "unsupported_operations": [ + "circularPattern", + "shell", + "sweep" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "reference_plane" + ], + "has_candidate": true, + "sample_id": "00612529", + "selection_reasons": [ + "unsupported_capability:extrude_profile_topology:mid_cap_edge" + ], + "sketch_entities": [ + "skFitSpline" + ], + "source_operations": [ + "cPlane", + "extrude", + "loft", + "mirror", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_profile_topology:mid_cap_edge" + ], + "unsupported_operations": [ + "loft" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind", + "fillet" + ], + "has_candidate": true, + "sample_id": "00694309", + "selection_reasons": [ + "unsupported_capability:extrude_extent:up_to_body" + ], + "sketch_entities": [ + "skCircle" + ], + "source_operations": [ + "extrude", + "fillet", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_extent:up_to_body" + ], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00710855", + "selection_reasons": [ + "unsupported_capability:extrude_surface_or_mixed" + ], + "sketch_entities": [ + "skCircle" + ], + "source_operations": [ + "chamfer", + "extrude", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_surface_or_mixed" + ], + "unsupported_operations": [] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind", + "fillet" + ], + "has_candidate": true, + "sample_id": "00789939", + "selection_reasons": [ + "unsupported_capability:extrude_profile_topology:offset_face", + "unsupported_capability:extrude_profile_topology:swept_face" + ], + "sketch_entities": [ + "skArc", + "skCircle", + "skLineSegment", + "skPoint" + ], + "source_operations": [ + "extrude", + "fillet", + "newSketch", + "shell" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_profile_topology:offset_face", + "extrude_profile_topology:swept_face" + ], + "unsupported_operations": [ + "shell" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind" + ], + "has_candidate": true, + "sample_id": "00835610", + "selection_reasons": [ + "source_operation:booleanBodies", + "unsupported_capability:extrude_profile_topology:cap_face", + "unsupported_capability:extrude_profile_topology:intersect", + "unsupported_capability:revolve_surface", + "unsupported_operation:booleanBodies" + ], + "sketch_entities": [ + "skCircle", + "skLineSegment", + "skPoint" + ], + "source_operations": [ + "booleanBodies", + "extrude", + "newSketch", + "revolve" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_profile_topology:cap_face", + "extrude_profile_topology:intersect", + "revolve_surface" + ], + "unsupported_operations": [ + "booleanBodies" + ] + }, + { + "baseline": { + "conversion_status": "converted_partial", + "status": "converted_partial" + }, + "engine_atomic_ids": [ + "extrude_add_blind", + "pattern_mirror", + "reference_plane" + ], + "has_candidate": true, + "sample_id": "00925274", + "selection_reasons": [ + "engine_atomic:pattern_mirror", + "source_operation:mirror", + "unsupported_capability:extrude_extent:up_to_surface", + "unsupported_capability:reference_plane:line_angle" + ], + "sketch_entities": [ + "skCircle", + "skLineSegment" + ], + "source_operations": [ + "cPlane", + "extrude", + "mirror", + "newSketch" + ], + "tiers": [ + "conversion" + ], + "unsupported_capabilities": [ + "extrude_cut_through_all", + "extrude_extent:up_to_surface", + "reference_plane:line_angle" + ], + "unsupported_operations": [] + } + ], + "feature_inventory": { + "engine_atomic": { + "chamfer": 1011, + "extrude_add_blind": 6215, + "extrude_add_two_sided": 924, + "extrude_cut_blind": 1302, + "fillet": 2292, + "hole_wizard": 343, + "pattern_mirror": 152, + "reference_plane": 1023, + "revolve_add": 1062, + "revolve_cut": 99 + }, + "sketch_entity": { + "skArc": 2777, + "skCircle": 5556, + "skEllipse": 131, + "skFitSpline": 897, + "skLineSegment": 7962, + "skPoint": 4419 + }, + "source_operation": { + "booleanBodies": 187, + "cPlane": 1188, + "chamfer": 1432, + "circularPattern": 232, + "extrude": 8238, + "fillet": 3639, + "hole": 894, + "loft": 309, + "mirror": 411, + "newSketch": 9347, + "revolve": 1798, + "shell": 696, + "sweep": 326 + }, + "unsupported_capability": { + "extrude_add_through_all": 4, + "extrude_cut_through_all": 303, + "extrude_cut_two_sided": 262, + "extrude_extent:up_to_body": 2, + "extrude_extent:up_to_next": 49, + "extrude_extent:up_to_surface": 95, + "extrude_extent:up_to_vertex": 4, + "extrude_profile_topology:cap_edge": 133, + "extrude_profile_topology:cap_face": 200, + "extrude_profile_topology:intersect": 382, + "extrude_profile_topology:mid_cap_edge": 2, + "extrude_profile_topology:offset_face": 18, + "extrude_profile_topology:swept_edge": 16, + "extrude_profile_topology:swept_face": 99, + "extrude_surface_or_mixed": 4, + "reference_plane:curve_point": 13, + "reference_plane:line_angle": 123, + "reference_plane:line_point": 23, + "reference_plane:mid_plane": 25, + "reference_plane:plane_point": 47, + "reference_plane:three_point": 35, + "revolve_surface": 367 + }, + "unsupported_operation": { + "booleanBodies": 187, + "circularPattern": 232, + "loft": 309, + "shell": 696, + "sweep": 326 + } + }, + "schema": "cadfs_to_cdsl.regression.v1", + "selected_ids": [ + "00002243", + "00035682", + "00054089", + "00111611", + "00129362", + "00159804", + "00192744", + "00212904", + "00287955", + "00423838", + "00542223", + "00612529", + "00694309", + "00710855", + "00789939", + "00835610", + "00925274" + ], + "selected_sample_count": 17, + "source_output": "cadfs_to_cdsl/output", + "source_sample_count": 9347 +} diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py index 53543bb9..c72ad908 100644 --- a/cadfs_to_cdsl/reports.py +++ b/cadfs_to_cdsl/reports.py @@ -184,11 +184,12 @@ def generate_markdown_report( ) unsupported_ops = { - "shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", + "shell", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive", } exact_mappings = { "extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind", + "loft": "loft_add (simple closed sketch profiles only)", "revolve": "revolve_add / revolve_cut", "fillet": "fillet", "chamfer": "chamfer", diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 93ab6ef0..7baafa3c 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -14,6 +14,12 @@ def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None: left, right = expected[key], actual.get(key) if not isinstance(right, (list, tuple)) or len(left) != len(right): return None delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right))) + if key == "normal": + # A source CAP_FACE identifies a geometric plane, not the OCC + # orientation of the resulting face. The two kernels may + # report the same cap with inverse normals, especially for a + # start cap. Keep axis direction orientation-sensitive. + delta = min(delta, math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right)))) scores.append(max(0.0, 1.0 - delta / 0.05)) for key in ("radius_mm", "plane_offset_mm"): if key in expected: @@ -75,7 +81,16 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix") report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True) body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None) - records = [item for item in report.get("topology_records") or [] if not body_id or item.get("body_id") == body_id or str(item.get("body_id") or "").startswith(f"{body_id}:")] + records = [ + item for item in report.get("topology_records") or [] + # Reference planes and axes are session context, not body + # topology. They must remain available while binding a mirror + # or extent selector against a body-bearing prefix. + if item.get("kind") in {"plane", "axis"} + or not body_id + or item.get("body_id") == body_id + or str(item.get("body_id") or "").startswith(f"{body_id}:") + ] resolved = [] for placeholder in placeholders: geometry = placeholder.get("geometry") or {} @@ -85,14 +100,21 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list owner = placeholder.get("owner_feature_id") owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])] pool = owner_matches or same_kind - if not geometry and len(pool) != 1: - raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") - scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8] - scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id")))) - if scored: - if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9: + if not geometry: + # Context selectors (notably a generated mirror plane) + # may have no geometric snapshot. Their owner-qualified + # singleton identity is sufficient and must not be scored + # as a zero-information geometric match. + if len(pool) != 1: raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") - candidates = [scored[0][1]] + candidates = [pool[0]] + else: + scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8] + scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id")))) + if scored: + if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9: + raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") + candidates = [scored[0][1]] if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild") for record in candidates: owners = record.get("owner_feature_ids") or [record.get("feature_id")] diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 6f958a82..6ca5cc11 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -7,7 +7,7 @@ from cadfs_to_cdsl.lowering import lower_model from cadfs_to_cdsl.pipeline import convert_one from cadfs_to_cdsl.dataset import Sample from cadfs_to_cdsl.dataset import scan_dataset -from cadfs_to_cdsl.tests.test_parser import SOURCE +from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE class LoweringTests(unittest.TestCase): @@ -30,12 +30,61 @@ class LoweringTests(unittest.TestCase): self.assertIsNone(result.cdsl) self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation") - def test_symmetric_cut_is_not_disguised_as_blind_cut(self): + def test_direct_translation_transform_is_baked_into_the_source_feature(self): + result = lower_model(parse_featurescript(TRANSFORM_SOURCE, "transform"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertEqual([item["id"] for item in result.cdsl["features"]], ["f_F1"]) + self.assertEqual(result.history[-1]["feature_id"], "F2") + self.assertFalse(result.diagnostics) + self.assertEqual(result.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [10.0, 0.0, 0.0]) + + def test_copy_transform_remains_an_explicit_engine_capability_gap(self): + source = TRANSFORM_SOURCE.replace('"makeCopy":false', '"makeCopy":true') + result = lower_model(parse_featurescript(source, "transform-copy"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertEqual(result.diagnostics[-1]["code"], "unsupported_engine_capability") + self.assertEqual(result.diagnostics[-1]["capability"], "transform") + + def test_symmetric_cut_lowers_to_two_sided_cut(self): source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true') result = lower_model(parse_featurescript(source, "symmetric-cut"), {}) - self.assertIsNone(result.cdsl) - self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability") - self.assertEqual(result.diagnostics[0]["capability"], "extrude_cut_two_sided") + self.assertEqual(result.status, "converted_complete") + feature = result.cdsl["features"][0] + self.assertEqual(feature["atomic_id"], "extrude_cut_two_sided") + self.assertEqual(feature["params"]["distance_mm"], 60.0) + self.assertEqual(feature["params"]["reverse_distance_mm"], 60.0) + + def test_through_all_extrudes_reuse_the_engine_extent_contract(self): + for operation_type, expected_atomic in [ + ("NewBodyOperationType.NEW", "extrude_add_blind"), + ("NewBodyOperationType.REMOVE", "extrude_cut_blind"), + ]: + with self.subTest(operation_type=operation_type): + source = SOURCE.replace( + '"depth":120 * mm', + f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.THROUGH_ALL', + ) + result = lower_model(parse_featurescript(source, f"through-all-{operation_type}"), {}) + self.assertEqual(result.status, "converted_complete") + feature = result.cdsl["features"][0] + self.assertEqual(feature["atomic_id"], expected_atomic) + self.assertEqual(feature["params"]["end_condition"]["type"], "through_all") + + def test_up_to_next_extrudes_reuse_the_engine_extent_contract(self): + for operation_type, expected_atomic in [ + ("NewBodyOperationType.NEW", "extrude_add_blind"), + ("NewBodyOperationType.REMOVE", "extrude_cut_blind"), + ]: + with self.subTest(operation_type=operation_type): + source = SOURCE.replace( + '"depth":120 * mm', + f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.UP_TO_NEXT', + ) + result = lower_model(parse_featurescript(source, f"up-to-next-{operation_type}"), {}) + self.assertEqual(result.status, "converted_complete") + feature = result.cdsl["features"][0] + self.assertEqual(feature["atomic_id"], expected_atomic) + self.assertEqual(feature["params"]["end_condition"]["type"], "through_next") def test_open_nonconstruction_geometry_is_not_silently_dropped(self): source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1) @@ -53,6 +102,83 @@ class LoweringTests(unittest.TestCase): self.assertEqual(gap["capability"], "revolve_surface") self.assertIsNone(result.cdsl) + def test_fit_spline_loft_lowers_to_executable_loft_add(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0061/00612529.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {}) + self.assertIsNotNone(result.cdsl) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F3"]["atomic_id"], "loft_add") + self.assertEqual(features["f_F3"]["params"]["profile_sketch_ids"], ["sketch_F2", "sketch_F0"]) + self.assertEqual(features["f_F4"]["atomic_id"], "pattern_mirror") + self.assertEqual(features["f_F4"]["params"]["source_feature_ids"], ["f_F3"]) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual(sketches["sketch_F0"]["profile"]["contours"][0]["segments"][0]["type"], "bspline") + self.assertNotIn("loft", {item.get("operation") for item in result.diagnostics}) + + def test_fit_spline_loft_candidate_rebuilds(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0061/00612529.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {}) + with tempfile.TemporaryDirectory() as tmp: + from cadfs_to_cdsl.rebuild import rebuild_candidate + rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertGreater(rebuilt["result"]["volume_mm3"], 0) + + def test_direct_translation_transform_updates_the_source_revolve(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0011/00111611.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}) + self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics}) + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0]) + for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]): + self.assertAlmostEqual(actual, expected) + + def test_circular_pattern_lowers_to_existing_engine_contract(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0042/00423838.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {}) + self.assertIsNotNone(result.cdsl) + features = {item["id"]: item for item in result.cdsl["features"]} + pattern = features["f_F4"] + self.assertEqual(pattern["atomic_id"], "pattern_circular") + self.assertEqual(pattern["params"]["source_feature_ids"], ["f_F1"]) + self.assertEqual(pattern["params"]["pattern_count"], 6) + self.assertEqual(pattern["params"]["sweep_angle_deg"], 360.0) + self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0]) + self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics}) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertNotEqual(sketches["sketch_F0"]["workplane"]["normal"], [0.0, 0.0, 1.0]) + self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics}) + + def test_reference_plane_variants_lower_to_explicit_frames(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00159804": {"F3", "F5"}, + "00192744": {"F3"}, + "00212904": {"F3"}, + "00542223": {"F3"}, + } + for sample_id, expected in cases.items(): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + features = {item["name"]: item for item in result.cdsl["features"]} + for feature_id in expected: + plane = features[feature_id] + self.assertEqual(plane["atomic_id"], "reference_plane") + self.assertEqual(set(plane["params"]["plane"]), {"origin_mm", "x_dir", "normal"}) + diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] + self.assertFalse(diagnostics) + def test_feature_face_profile_is_not_reused_as_original_sketch(self): source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})') result = lower_model(parse_featurescript(source, "face-profile"), {}) diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index a420fd82..127197ad 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -19,6 +19,14 @@ export const f = defineFeature(function(context, id, definition) { }); ''' +TRANSFORM_SOURCE = SOURCE.replace( + '\n});\n', + ''' + transform(context, id + "F2", {"entities":qCreatedBy(id + "F1", BODY), "transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false}); +}); +''', +) + class ParserTests(unittest.TestCase): def test_lexer_ignores_comments_and_preserves_lines(self): @@ -32,6 +40,12 @@ class ParserTests(unittest.TestCase): self.assertEqual(model.sketches[0].entities[0].operation, "skCircle") self.assertIsInstance(model.features[0].params["entities"], Call) + def test_transform_is_preserved_as_a_feature(self): + model = parse_featurescript(TRANSFORM_SOURCE, "transform") + self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1", "F2"]) + self.assertEqual(model.features[-1].operation, "transform") + self.assertEqual(model.features[-1].params["transformType"], "TransformType.TRANSLATION_3D") + def test_query_parser(self): query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}]) value = parse_query(query) diff --git a/cadfs_to_cdsl/tests/test_regression.py b/cadfs_to_cdsl/tests/test_regression.py new file mode 100644 index 00000000..6986efa3 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_regression.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from cadfs_to_cdsl.regression import build_regression_manifest, regression_sample_ids + + +class RegressionSelectionTests(unittest.TestCase): + def _sample( + self, + root: Path, + sample_id: str, + *, + operation: str, + entity: str | None = None, + atomic: str | None = None, + capability: str | None = None, + rebuilt: bool = False, + ) -> None: + directory = root / "samples" / sample_id + directory.mkdir(parents=True) + history = [{"operation": operation}] + if entity: + history[0]["entities"] = [{"operation": entity}] + (directory / "history.json").write_text(json.dumps(history), encoding="utf-8") + if atomic: + (directory / "candidate.cdsl.json").write_text(json.dumps({"features": [{"atomic_id": atomic}]}), encoding="utf-8") + diagnostics = [] if not capability else [{"code": "unsupported_engine_capability", "capability": capability}] + (directory / "diagnostics.json").write_text(json.dumps(diagnostics), encoding="utf-8") + status = {"status": "rebuilt_approximate" if rebuilt else "converted_partial"} + if rebuilt: + status["rebuild_status"] = "rebuilt" + (directory / "status.json").write_text(json.dumps(status), encoding="utf-8") + + def test_manifest_covers_every_observed_signal_and_keeps_engine_pool_buildable(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) + self._sample(output, "00000001", operation="extrude", entity="skLineSegment", atomic="extrude_add_blind", rebuilt=True) + self._sample(output, "00000002", operation="loft", entity="skFitSpline", capability="loft") + self._sample(output, "00000003", operation="revolve", entity="skCircle", atomic="revolve_add", rebuilt=True) + + manifest = build_regression_manifest(output) + + self.assertEqual(manifest["selected_ids"], ["00000001", "00000002", "00000003"]) + self.assertEqual(set(manifest["coverage"]), { + "engine_atomic:extrude_add_blind", "engine_atomic:revolve_add", + "sketch_entity:skCircle", "sketch_entity:skFitSpline", "sketch_entity:skLineSegment", + "source_operation:extrude", "source_operation:loft", "source_operation:revolve", + "unsupported_capability:loft", + }) + self.assertEqual(regression_sample_ids_from_manifest(manifest, "engine"), ["00000001", "00000003"]) + self.assertEqual(manifest["engine_baseline_atomic_ids"], ["extrude_add_blind", "revolve_add"]) + self.assertEqual(manifest["engine_diagnostic_only_atomic_ids"], []) + + def test_manifest_reader_rejects_unknown_tier(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self._sample(root, "00000001", operation="extrude", atomic="extrude_add_blind", rebuilt=True) + manifest_path = root / "manifest.json" + manifest_path.write_text(json.dumps(build_regression_manifest(root)), encoding="utf-8") + self.assertEqual(regression_sample_ids(manifest_path, "engine"), ["00000001"]) + with self.assertRaisesRegex(ValueError, "Unknown regression tier"): + regression_sample_ids(manifest_path, "not-a-tier") + + +def regression_sample_ids_from_manifest(manifest: dict, tier: str) -> list[str]: + return sorted( + entry["sample_id"] for entry in manifest["entries"] + if tier == "all" or tier in entry["tiers"] + ) diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py new file mode 100644 index 00000000..33fc7ca2 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest + +from cadfs_to_cdsl.selector_binding import _score + + +class SelectorBindingTests(unittest.TestCase): + def test_face_normal_match_is_orientation_independent(self) -> None: + score = _score( + {"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 12.0}, + {"normal": [0.0, 0.0, -1.0], "plane_offset_mm": 12.0}, + ) + self.assertEqual(score, 1.0) + + def test_axis_direction_remains_orientation_sensitive(self) -> None: + score = _score( + {"axis_direction": [0.0, 0.0, 1.0]}, + {"axis_direction": [0.0, 0.0, -1.0]}, + ) + self.assertEqual(score, 0.0) + + def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None: + self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0)