Compare commits

...

15 Commits

Author SHA1 Message Date
likang 038d38ed98 feat(cadfs): 补齐核心建模能力并建立代表性回归
- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持
- 支持多种 reference plane、B-spline profile 和 circular pattern replay
- 保留 transform 历史,并烘焙安全的单源平移/旋转变换
- 改进 selector 绑定、拓扑快照和 pattern 变换处理
- 建立 17 个代表样本的转换、重建与比较回归工具链
- 补充 schema、author guidance、运行时和几何回归测试
2026-09-07 18:21:07 +08:00
likang 4ad890b7b4 Merge pull request '几何引擎新增open_slot功能。' (#12) from ganjihong into main
Reviewed-on: #12
2026-09-07 16:59:05 +08:00
ganjihong 9a3e09b9f5 几何引擎新增open_slot功能。 2026-09-07 16:44:06 +08:00
likang c1b84d1f76 Merge pull request '修改了一些之前增加功能的bug' (#11) from ganjihong into main
Reviewed-on: #11
2026-09-07 15:57:53 +08:00
ganjihong 9160c2bace 修改了一些之前增加功能的bug 2026-09-07 15:25:24 +08:00
likang 4f068068f9 Merge pull request 'Ganjihong: thread 功能的主要功能实现' (#10) from ganjihong into main
Reviewed-on: #10
2026-09-04 17:49:53 +08:00
ganjihong ef5d393c20 内螺纹 thread_cut 算子的增加 2026-09-04 17:42:04 +08:00
likang 921ba5eab3 change 2026-09-04 11:17:36 +08:00
ganjihong 9abba1b12d Merge branch 'ganjihong' of https://gitea.robotquan.com/likang/cdsl-cad into ganjihong 2026-09-04 10:36:45 +08:00
ganjihong 9c52022c14 thread 功能的主要功能实现 2026-09-04 10:29:39 +08:00
likang 9e27f11bb7 Merge pull request '增加了thread几何基础操作' (#9) from ganjihong into main
Reviewed-on: #9
2026-09-04 10:18:51 +08:00
likang 1b6958db62 Merge branch 'main' into ganjihong 2026-09-04 10:18:36 +08:00
ganjihong bb9e8a8720 增加了thread几何基础操作 2026-09-03 19:39:06 +08:00
likang 1a43556910 Merge pull request 'feat: add 增加了几个较为简单的几何操作' (#8) from ganjihong into main
Reviewed-on: #8
2026-09-03 17:05:08 +08:00
ganjihong ef9626f936 feat: add primitive & pattern geometry ops to build123d adapter
- add box/cylinder/intersect/sweep/sweep_path/helix_path primitives
- add pattern_linear/pattern_circular direct kernel arraying
- refactor boolean result normalization into _coerce_single_or_compound
2026-09-03 16:14:33 +08:00
67 changed files with 6346 additions and 133 deletions
+13
View File
@@ -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.
@@ -0,0 +1 @@
只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。
@@ -0,0 +1 @@
先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。
@@ -0,0 +1 @@
把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。
@@ -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、偏移或方向,不修改已发布工件。
@@ -0,0 +1 @@
优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。
@@ -0,0 +1 @@
轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。
@@ -0,0 +1 @@
宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。
@@ -0,0 +1 @@
对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。
@@ -0,0 +1 @@
圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。
@@ -0,0 +1 @@
确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。
@@ -0,0 +1 @@
修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。
@@ -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.
@@ -0,0 +1,67 @@
{
"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-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-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"],
"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"],
"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"],
"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"],
"pattern_circular": ["op-pattern"],
"fillet": ["op-finish"],
"chamfer": ["op-finish"],
"sphere_add": ["op-sphere"],
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
"thread_add": ["op-thread"],
"thread_cut": ["op-thread"]
}
}
@@ -0,0 +1 @@
`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。
@@ -0,0 +1 @@
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
@@ -0,0 +1 @@
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。
@@ -0,0 +1 @@
孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。
@@ -0,0 +1 @@
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
@@ -0,0 +1 @@
`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。
@@ -0,0 +1 @@
`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
@@ -0,0 +1 @@
`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。
@@ -0,0 +1 @@
`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。
@@ -0,0 +1 @@
`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。
@@ -0,0 +1 @@
`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
@@ -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
@@ -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
+45 -7
View File
@@ -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]] = []
+7 -1
View File
@@ -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)
+200 -9
View File
@@ -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.
+48 -1
View File
@@ -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
+9 -1
View File
@@ -156,7 +156,15 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
for parameter_name in contract.get("required_params") or []:
if params.get(parameter_name) is None:
raise ValueError(f"Feature {fid} ({atomic_id}) is missing required parameter: {parameter_name}")
if atomic_id.startswith("extrude_") and float(params.get("distance_mm") or 0) <= 0:
# 距离检查只对"以 distance_mm 驱动"的 extrude 契约生效:extrude_cut_through
# 等贯通型契约的作者参数不含 distance_mm(以 end_condition 表达贯通),
# 不能按盲切假设要求距离。用契约声明判定,避免硬编码原子名单漂移。
author_props = (contract.get("author_params_schema") or {}).get("properties") or {}
if (
atomic_id.startswith("extrude_")
and "distance_mm" in author_props
and float(params.get("distance_mm") or 0) <= 0
):
raise ValueError(f"Feature {fid} ({atomic_id}) requires distance_mm > 0 for runtime rebuild")
if atomic_id.startswith("revolve_") and float(params.get("angle_deg") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires angle_deg > 0 for runtime rebuild")
+4
View File
@@ -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
+218 -19
View File
@@ -5,9 +5,10 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
from .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
@@ -64,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:
@@ -132,20 +139,60 @@ 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。"""
# build123d 的布尔方法有时返回 None(无结果)、ShapeList(多/单成员)
# 或直接返回 Solid/Compound,这里统一为单实体或 Compound。
if result is None:
if empty_error is not None:
raise ValueError(empty_error)
return None
members = list(result) if isinstance(result, ShapeList) else [result]
if not members:
if empty_error is not None:
raise ValueError(empty_error)
return None
if len(members) == 1:
return members[0]
# build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域
# 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在,
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明
# make_composite,保留宽泛类型桩噪音抑制。
return Compound.make_composite(members)
@staticmethod
def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
@@ -165,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),
@@ -180,13 +235,9 @@ class Build123dGeometryAdapter:
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
if isinstance(trimmed, ShapeList):
members = list(trimmed)
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。
trimmed = members[0] if len(members) == 1 else Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue]
if trimmed is None or (hasattr(trimmed, "is_empty") and trimmed.is_empty()):
raise ValueError("extent target produced an empty trimmed solid")
return trimmed
return Build123dGeometryAdapter._coerce_single_or_compound(
trimmed, empty_error="extent target produced an empty trimmed solid",
)
@staticmethod
def body_center(body: Any) -> Vector3:
@@ -288,6 +339,32 @@ class Build123dGeometryAdapter:
# 以给定球心与半径生成球体实体。
return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
@staticmethod
def box(length_mm: float, width_mm: float, height_mm: float, plane: PlaneSpec | None = None) -> Solid:
# 原生立方体图元。plane 缺省为世界 XY;plane 的原点是长方体最小角点,
# 长/宽/高分别沿 plane 的 x/y/z 方向生长(build123d Solid.make_box 原生语义)。
build_plane = Build123dGeometryAdapter.plane(plane) if plane is not None else Plane.XY
return Solid.make_box(length_mm, width_mm, height_mm, build_plane)
@staticmethod
def cylinder(radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> Solid:
# 原生圆柱图元。axis 缺省为世界 +Z;axis 的原点是底面圆心,
# 轴向由 axis 的方向决定,沿该方向生长高度。
build_plane = (
Plane(origin=_vector(axis.origin_mm), z_dir=_vector(axis.direction))
if axis is not None
else Plane.XY
)
return Solid.make_cylinder(radius_mm, height_mm, build_plane)
@staticmethod
def intersect(left: Any, right: Any) -> Any:
# 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触),
# 此时规整 helper 会抛出明确的空交集错误。
return Build123dGeometryAdapter._coerce_single_or_compound(
left.intersect(right), empty_error="boolean intersection produced no solid",
)
def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid:
"""Build a neutral ``HoleSpec`` into one OCC cutting tool."""
# 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。
@@ -313,6 +390,26 @@ class Build123dGeometryAdapter:
raise ValueError("hole has no positions")
return result
@staticmethod
def thread_solid(spec: ThreadSpec) -> Any:
"""Build a threaded solid segment anchored on ``spec.axis``.
The parametric generator constructs the thread in a local +Z frame
spanning ``[0, length_mm]``. This gate rotates that frame so the
thread axis lands on ``spec.axis.direction`` with
``spec.axis.origin_mm`` at the leading end face, keeping placement in
the adapter and geometry construction in ``parametric_thread.py``.
"""
solid = build_thread_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.X) <= 1e-9 and abs(direction.Y) <= 1e-9:
# 轴沿 ±Z:X 方向任意即可,螺纹起始相位绕轴无意义。
frame_x = Vector(1.0, 0.0, 0.0)
else:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
# 对指定边以给定半径做圆角。
@@ -375,6 +472,108 @@ class Build123dGeometryAdapter:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
@staticmethod
def sweep(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> Solid:
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
# 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True
# 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需);
# transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。
sweep_options: dict[str, Any] = {
"inner_wires": inner_wires,
"make_solid": make_solid,
"is_frenet": is_frenet,
}
if transition is not None:
sweep_options["transition"] = transition
return Solid.sweep(section, spine, **sweep_options)
@staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire:
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)])
@staticmethod
def helix_path(
radius_mm: float,
pitch_mm: float,
turns: float | None = None,
*,
height_mm: float | None = None,
lefthand: bool = False,
) -> Edge:
# 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。
# 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。
# turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键,
# 按高度适配 parametric_thread(length + 两端余量)。构造与 parametric_thread
# 原 Edge.make_helix 同源,保证生成器复用后逐位一致。
if pitch_mm <= 0:
raise ValueError("helix pitch must be positive")
if (turns is None) == (height_mm is None):
raise ValueError("helix_path needs exactly one of turns or height_mm")
if turns is not None:
if turns <= 0:
raise ValueError("helix turns must be positive")
height = turns * pitch_mm
else:
if height_mm <= 0:
raise ValueError("helix height must be positive")
height = height_mm
return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand)
@staticmethod
def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any:
# 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。
# 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or spacing_mm == 0:
return body
vector = _vector(direction)
if vector.length <= 1e-12:
raise ValueError("pattern direction must be non-zero")
unit = vector.normalized()
result = body
for index in range(1, count):
offset = unit * (index * spacing_mm)
result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z))))
return result
@staticmethod
def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any:
# 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction)
# 旋转 sweep_angle_deg 均布 count 份并合并。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or sweep_angle_deg == 0:
return body
origin = _vector(axis.origin_mm)
direction = _vector(axis.direction)
if direction.length <= 1e-12:
raise ValueError("pattern rotation axis must be non-zero")
unit = direction.normalized()
step_angle = sweep_angle_deg / count
# 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”,
# 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。
to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z)))
back = Location((float(origin.X), float(origin.Y), float(origin.Z)))
result = body
for index in range(1, count):
rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle)
instance = body.moved(to_origin).moved(rotation).moved(back)
result = result.fuse(instance)
return result
@staticmethod
def mirror(body: Any, plane: PlaneSpec) -> Any:
# 沿给定平面镜像主体。
+114 -20
View File
@@ -19,21 +19,33 @@ 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",
"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", "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",
"revolve_add", "revolve_cut", "sphere_add", *_HOLE_ATOMICS, "fillet", "chamfer",
"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",
})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance.
_REPLAYABLE_ATOMICS = _BODY_MUTATING_ATOMICS | frozenset({"pattern_linear", "pattern_mirror"})
# Context-only features have no geometry definition to instance. thread_add
# and thread_cut are excluded: pattern translation does not yet move their
# parametric axis, so a replayed thread would silently re-run at the original
# location.
_REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"})
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body",
@@ -134,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")
@@ -164,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, ...]
@@ -270,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)没有锥形
@@ -307,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}")
@@ -419,6 +496,18 @@ class CapabilityAnalyzer:
))
if node.atomic_id == "pattern_mirror" and not params.get("mirror_plane"):
blockers.append(self._blocker(node.feature_id, "missing_mirror_plane", "Mirror pattern has no mirror plane"))
if node.atomic_id == "pattern_circular":
if not _has_explicit_axis(params.get("axis")):
blockers.append(self._blocker(
node.feature_id, "missing_circular_axis",
"Circular pattern requires an explicit axis with origin_mm and direction",
))
pattern_count = params.get("pattern_count")
if pattern_count is None or int(pattern_count) < 1:
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_count",
"Circular pattern requires pattern_count >= 1",
))
status = "executable" if not blockers else ("unsupported" if any(b.code.startswith("unsupported") or b.code == "unknown_atomic" for b in blockers) else "blocked")
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
if status == "executable":
@@ -426,8 +515,13 @@ 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",
"revolve_add", "revolve_cut", "sphere_add",
"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 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut",
}
document_blockers: list[RuntimeDiagnostic] = []
if not any(node.atomic_id in body_producers for node in plan):
+98 -3
View File
@@ -102,6 +102,26 @@
"required": ["distance_mm"],
"additionalProperties": false
},
"extrudeCutThroughParams": {
"type": "object",
"properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
"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"}},
@@ -117,6 +137,60 @@
"required": ["radius_mm", "center_mm"],
"additionalProperties": false
},
"boxParams": {
"type": "object",
"properties": {
"length_mm": {"$ref": "#/$defs/positive"},
"width_mm": {"$ref": "#/$defs/positive"},
"height_mm": {"$ref": "#/$defs/positive"},
"center_mm": {"$ref": "#/$defs/point3"}
},
"required": ["length_mm", "width_mm", "height_mm", "center_mm"],
"additionalProperties": false
},
"cylinderParams": {
"type": "object",
"properties": {
"radius_mm": {"$ref": "#/$defs/positive"},
"height_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"}
},
"required": ["radius_mm", "height_mm"],
"additionalProperties": false
},
"threadAddParams": {
"type": "object",
"properties": {
"major_diameter_mm": {"$ref": "#/$defs/positive"},
"minor_diameter_mm": {"$ref": "#/$defs/positive"},
"pitch_mm": {"$ref": "#/$defs/positive"},
"length_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"},
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180},
"lefthand": {"type": "boolean"},
"relief_length_mm": {"type": "number", "minimum": 0},
"crest_radius_mm": {"type": "number", "minimum": 0},
"root_radius_mm": {"type": "number", "minimum": 0}
},
"required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"],
"additionalProperties": false
},
"threadCutParams": {
"type": "object",
"properties": {
"major_diameter_mm": {"$ref": "#/$defs/positive"},
"minor_diameter_mm": {"$ref": "#/$defs/positive"},
"pitch_mm": {"$ref": "#/$defs/positive"},
"length_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"},
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180},
"lefthand": {"type": "boolean"},
"crest_radius_mm": {"type": "number", "minimum": 0},
"root_radius_mm": {"type": "number", "minimum": 0}
},
"required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"],
"additionalProperties": false
},
"holeBaseParams": {
"type": "object",
"properties": {
@@ -209,6 +283,17 @@
"required": ["source_feature_ids", "mirror_plane"],
"additionalProperties": false
},
"circularPatternParams": {
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"axis": {"$ref": "#/$defs/axis"},
"pattern_count": {"type": "integer", "minimum": 1},
"sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360}
},
"required": ["source_feature_ids", "axis", "pattern_count"],
"additionalProperties": false
},
"referencePlaneParams": {
"type": "object",
"properties": {
@@ -264,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
},
@@ -318,7 +405,7 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "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": {
@@ -338,9 +425,16 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"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"}}}},
{"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "thread_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadAddParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "thread_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadCutParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_countersink"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCountersinkParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}},
@@ -348,6 +442,7 @@
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
@@ -0,0 +1,295 @@
"""Parametric screw-thread generator built on build123d geometry.
Strategy
--------
A screw thread is a helical prism: one trapezoidal tooth profile swept along a
helix using the OCC Frenet frame. ``is_frenet=True`` keeps the profile
orientation constant along a straight helix (the curvature vector always
points at the axis), which is exactly the configuration a machined thread has:
the flank is a true helical surface with a constant axial pitch.
The generator builds a single seamless "tooth ribbon" (one pitch wide per
loop) plus the core cylinder, fuses them, and trims both ends flush at
``z in [0, length_mm]``. The tooth root is sunk slightly below the core
radius so the boolean union has a clean volume overlap instead of a pair of
coincident faces (which OCC cannot fuse reliably).
``spec.internal=False`` builds an external thread (thread_add): a solid rod
whose crest envelope is ``major_diameter_mm``. ``spec.internal=True`` builds
an internal-thread cutting tool: the identical helical-rod topology but with
the crest radius over-sized by ``INTERNAL_CUT_OVERLAP_MM`` so that
``body.cut(tool)`` removes a clean helical groove from the host wall instead
of collapsing on coincident faces. External threads may add plain root-radius
end shanks (``relief_length_mm``, total length then becomes
``length_mm + 2 * relief_length_mm``) and crest/root fillet radii; the
internal cutting form accepts fillets but not relief.
The module stays independent of the CDSL runtime: it only reads
``runtime_types.ThreadSpec`` (a build123d-free data class) and returns an OCC
``Solid``. Construction happens in a local +Z frame anchored at ``z = 0``;
frame placement/rotation to ``spec.axis`` is the adapter's responsibility.
"""
from __future__ import annotations
import math
from build123d import Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire
from .runtime_types import ThreadSpec
#: How far below the nominal core radius the tooth root extends (mm, clamped).
#: The extra overlap guarantees the root cylinder union is a clean volume
#: boolean rather than a coincident-face attachment.
_ROOT_OVERLAP_MM = 0.15
#: Minimum surviving flat on the tooth crest before the flanks would overlap.
_MIN_CREST_HALF_WIDTH_MM = 0.02
#: Internal-thread cutting tool over-size beyond the nominal major radius (mm).
#: ``body.cut(tool)`` removes a helical groove whose crest envelope has to
#: penetrate the host wall by a thin material layer; an exact-fit tool would
#: place coincident faces inside OCC's boolean and fail unpredictably.
INTERNAL_CUT_OVERLAP_MM = 0.02
def _validate_spec(spec: ThreadSpec) -> tuple[float, float, float, float, float]:
"""Range-check a spec and return geometry parameters.
Returns ``(crest_radius, root_radius, sink, flank_throw, flank_half_tan)``
where ``flank_throw`` is the horizontal flank run per tooth side and
``flank_half_tan`` is ``tan(flank_half_angle)``.
"""
if spec.internal and spec.relief_length_mm > 0:
raise ValueError("relief_length_mm is only supported on external threads (thread_add)")
if spec.major_diameter_mm <= 0 or spec.minor_diameter_mm <= 0:
raise ValueError("thread diameters must be positive")
if spec.minor_diameter_mm >= spec.major_diameter_mm:
raise ValueError("thread minor diameter must be smaller than the major diameter")
if spec.pitch_mm <= 0:
raise ValueError("thread pitch must be positive")
if spec.length_mm <= 0:
raise ValueError("thread length must be positive")
if not 0 < spec.angle_deg < 180:
raise ValueError("thread angle_deg must be between 0 and 180")
if spec.internal:
# 内螺纹刀具:牙顶必须比名义 major 大一个薄材料层,body.cut 才能
# 切入宿主孔壁完成布尔差,而不是在 coincident faces 上退化。
crest_radius = spec.major_diameter_mm / 2.0 + INTERNAL_CUT_OVERLAP_MM
else:
crest_radius = spec.major_diameter_mm / 2.0
root_radius = spec.minor_diameter_mm / 2.0
depth_radius = crest_radius - root_radius
if depth_radius <= 0:
raise ValueError("thread major diameter must exceed the minor diameter")
sink = min(_ROOT_OVERLAP_MM, 0.25 * depth_radius, 0.1 * spec.pitch_mm)
full_depth = depth_radius + sink # from sunk root up to the crest
flank_half_tan = math.tan(math.radians(spec.angle_deg / 2.0))
flank_throw = full_depth * flank_half_tan
return crest_radius, root_radius, sink, flank_throw, flank_half_tan
def _build_z_aligned(spec: ThreadSpec) -> Solid:
"""Construct an external thread along +Z spanning ``z in [0, length_mm]``.
Local frame: z = thread axis, the leading end face sits at ``z = 0`` and
starts inside a tooth valley so the first crest rises cleanly off the end
face. Helix pitch runs right-handed (or left-handed when ``lefthand``).
"""
crest_radius, root_radius, sink, flank_throw, _flank_half_tan = _validate_spec(spec)
pitch = spec.pitch_mm
# Tooth geometry in the axial cross-section (z = axial, r = radial).
# One full tooth occupies a pitch-wide interval centered on the crest flat;
# the flank horizontal throw is `full_depth * tan(half_angle)`.
crest_half_width = pitch / 2.0 - flank_throw
if crest_half_width < _MIN_CREST_HALF_WIDTH_MM:
raise ValueError(f"thread pitch is too small for the given depth and flank angle (flank throw {flank_throw:.4f} mm must stay below pitch/2)")
# Overshoot both ends by one pitch so the trimmed faces land in full
# material; the crest centre sits at the helix start phase (z = 0), which
# also puts the z = 0 end face through full crest material after trimming.
helix_height = spec.length_mm + 2.0 * pitch
# 复用 adapter 门面构造螺旋路径与扫掠。函数级延迟导入:build123d_adapter 顶部
# 已 import build_thread_solid,模块级反向 import adapter 会形成循环依赖。
from .build123d_adapter import Build123dGeometryAdapter
helix = Build123dGeometryAdapter.helix_path(
root_radius, pitch, height_mm=helix_height, lefthand=spec.lefthand,
)
# Profile vertices (z, r) -> world (x = r, y = 0, z). Order is counter
# clockwise in the (z, r) plane: bottom edge first, then crest right,
# crest flat, crest left back down. The bottom edge spans the full pitch
# so consecutive helical loops share an identical seam line.
sunk_root = root_radius - sink
pts = [
Vector(sunk_root, 0.0, -pitch / 2.0),
Vector(sunk_root, 0.0, pitch / 2.0),
Vector(crest_radius, 0.0, crest_half_width),
Vector(crest_radius, 0.0, -crest_half_width),
]
wire = Wire([Edge.make_line(pts[index], pts[(index + 1) % len(pts)]) for index in range(len(pts))])
wire = _apply_profile_fillets(wire, spec, crest_radius, sunk_root, pitch, crest_half_width)
ribbon = Build123dGeometryAdapter.sweep(Face(wire), helix, make_solid=True, is_frenet=True)
# Core cylinder at the nominal minor radius spanning the whole helix.
# (The sunken tooth roots overlap it so the union below is clean.)
# The profile spans z in [-pitch/2, +pitch/2] around the helix start, so
# the core begins at -pitch/2 and covers the shell plus one extra pitch.
core_height = helix_height + pitch
core = Solid.make_cylinder(root_radius, core_height, Plane(origin=(0.0, 0.0, -pitch / 2.0)))
fused = core.fuse(ribbon)
if spec.relief_length_mm > 0:
# 端部收尾(relief):螺纹有效段保持 length_mm 不变,置于总长中部
# z ∈ [relief, relief + length],两端各附一个牙根半径的光杆段,总长 =
# length + 2 * relief。光杆与芯柱做实体重叠后由 trim 裁出干净的纯光杆端面。
return _build_external_with_relief(spec, fused, root_radius, crest_radius, pitch)
# 平移半个牙距,使 z = 0 端面落在牙谷中心:首尾端面无半牙、端面圆盘完整,
# 裁切后 [0, length_mm] 内牙顶平台数稳定为 length/pitch。
fused = fused.moved(Location((0.0, 0.0, -pitch / 2.0)))
return _trim_thread_z(fused, 0.0, spec.length_mm, crest_radius)
def _trim_thread_z(fused: Solid | Compound, z0: float, z1: float, crest_radius: float) -> Solid:
"""Intersect ``fused`` with an oversized box clamped to ``z in [z0, z1]``.
A threaded solid may leave OCC float slivers at the trim planes; the
largest surviving solid is returned as the canonical body.
"""
trim_margin = 2.0
half_span = crest_radius + trim_margin
clamp = Solid.make_box(
2.0 * half_span,
2.0 * half_span,
z1 - z0,
Plane(origin=(-half_span, -half_span, z0)),
)
intersected = fused.intersect(clamp)
if isinstance(intersected, ShapeList):
candidates = list(intersected)
elif intersected is not None:
candidates = [intersected]
else:
candidates = []
members: list[Solid] = []
for candidate in candidates:
if isinstance(candidate, Solid):
members.append(candidate)
else:
members.extend(candidate.solids())
if not members:
raise ValueError("thread end trim produced no solid")
# 端部裁齐应保持单一主体;若 OCC 留下浮点碎屑,取体积最大的实心主体。
trimmed = members[0] if len(members) == 1 else max(members, key=lambda shape: shape.volume)
return trimmed
def _build_external_with_relief(
spec: ThreadSpec,
aligned: Solid | Compound,
root_radius: float,
crest_radius: float,
pitch: float,
) -> Solid:
"""Build an externally threaded rod with plain root-radius end shanks.
The threaded portion keeps its full ``length_mm`` and sits in the middle of
the part: ``z in [relief, relief + length_mm]``. A plain shank of radius
``root_radius`` (the thread root/minor radius) extends over ``z in
[0, relief]`` and ``[relief + length_mm, total]``, so the total part length
is ``length_mm + 2 * relief_length_mm``.
``aligned`` is the fused core+ribbon *before* the valley-centring shift.
The build first trims a clean valley-centred thread over ``[0, length_mm]``
(identical phase and tooth count to the plain build), then shifts it up by
``relief`` so both end planes land inside tooth valleys. Each shank
cylinder overlaps the threaded core by ``_ROOT_OVERLAP_MM`` so both fuses
are volume booleans, and the final trim turns the two end planes into clean
plain discs at ``z = 0`` and ``z = total``.
"""
relief = spec.relief_length_mm
length = spec.length_mm
overlap = _ROOT_OVERLAP_MM
total = length + 2.0 * relief
thread = _trim_thread_z(aligned.moved(Location((0.0, 0.0, -pitch / 2.0))), 0.0, length, crest_radius)
thread = thread.moved(Location((0.0, 0.0, relief)))
bottom = Solid.make_cylinder(root_radius, relief + overlap, Plane(origin=(0.0, 0.0, 0.0)))
top = Solid.make_cylinder(
root_radius,
relief + overlap,
Plane(origin=(0.0, 0.0, relief + length - overlap)),
)
fused = thread.fuse(bottom).fuse(top)
return _trim_thread_z(fused, 0.0, total, crest_radius)
def _apply_profile_fillets(
wire: Wire,
spec: ThreadSpec,
crest_radius: float,
sunk_root: float,
pitch: float,
crest_half_width: float,
) -> Wire:
"""Round the tooth crest/root corners of the axial cross-section.
A crest/root radius that is too large for the flank lengths makes OCC's
``fillet_2d`` fail; the profile then falls back to the sharp-cornered
trapezoid instead of blocking the whole build (AGENTS: never destroy a
buildable model over a cosmetic detail).
"""
if spec.crest_radius_mm <= 0 and spec.root_radius_mm <= 0:
return wire
try:
if spec.crest_radius_mm > 0:
crest_vertices = [
vertex for vertex in wire.vertices()
if vertex.X >= crest_radius - 1e-6 and abs(vertex.Z) <= crest_half_width + 1e-6
]
if crest_vertices:
wire = wire.fillet_2d(spec.crest_radius_mm, crest_vertices)
if spec.root_radius_mm > 0:
root_vertices = [
vertex for vertex in wire.vertices()
if vertex.X <= sunk_root + 1e-6 and abs(vertex.Z) >= pitch / 2.0 - 1e-6
]
if root_vertices:
wire = wire.fillet_2d(spec.root_radius_mm, root_vertices)
except Exception:
# 清根圆角过大导致截面退化:保留尖角梯形,不阻断生成。
pass
return wire
def build_thread_solid(spec: ThreadSpec) -> Solid:
"""Build one external-threaded solid segment for ``spec``.
Returns a solid whose thread axis is +Z and whose leading end face is at
``z = 0``. The caller (geometry adapter) is responsible for placing the
solid at ``spec.axis``.
"""
result = _build_z_aligned(spec)
if not result.solids():
raise ValueError("thread generation produced no solid")
return result
def build_thread_cut_tool(spec: ThreadSpec) -> Solid:
"""Build the internal-thread cutting tool that ``thread_cut`` subtracts.
The tool is exactly the ``internal=True`` thread form: the identical
helical-rod topology as ``thread_add`` but with the crest envelope
over-sized by ``INTERNAL_CUT_OVERLAP_MM`` beyond the nominal major radius.
``host.cut(tool)`` then removes a clean full-depth helical groove from the
host bore wall instead of collapsing on a pair of coincident faces (which
OCC cannot cut reliably). This is the geometry-side entry point of the
``thread_cut`` atomic; the caller (geometry adapter) places the resulting
+Z-aligned tool at ``spec.axis`` exactly like an external thread segment.
"""
if not spec.internal:
raise ValueError("build_thread_cut_tool requires an internal-thread spec (internal=True)")
return build_thread_solid(spec)
+4 -4
View File
@@ -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 ()
+11 -3
View File
@@ -1,20 +1,27 @@
{
"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"]},
"hole_countersink": {"atomic_id":"hole_countersink","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}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"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"]},
"hole_counterbore": {"atomic_id":"hole_counterbore","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}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"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"]},
"sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_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":[],"candidate_verifiers":["single_connected_body"]},
"box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_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":[],"candidate_verifiers":["single_connected_body"]},
"cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"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}},"required":["radius_mm","height_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":[],"candidate_verifiers":["single_connected_body"]},
"thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"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},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","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":[],"candidate_verifiers":["single_connected_body"]},
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"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},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","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"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"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","reference_plane_nonzero_normal"],"candidate_verifiers":[]},
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"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}},"required":["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","reference_axis_nonzero_direction"],"candidate_verifiers":[]},
"hole_wizard": {
@@ -50,7 +57,8 @@
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]},
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]},
"pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"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},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}
},
"profiles": {
"circle": {
@@ -65,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."
}
}
}
+408 -6
View File
@@ -11,7 +11,8 @@ from typing import Any, Callable, Protocol
from .build123d_adapter import Build123dGeometryAdapter
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
from .runtime_types import (
AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, Vector3,
AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
ThreadSpec, Vector3,
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
)
@@ -19,10 +20,14 @@ 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_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", "reference_plane", "reference_axis",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
"pattern_circular",
"thread_add", "thread_cut",
})
@@ -77,12 +82,14 @@ 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: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
@@ -230,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 到目标面之间的材料。
@@ -238,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)
@@ -265,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.
"""
@@ -313,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:
@@ -451,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)执行入口:从参数解析平面并登记为拓扑上下文。
@@ -510,6 +533,90 @@ def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> Feature
return session.result(node)
def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。
# 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。
try:
length = float(node.params.get("length_mm") or 0.0)
width = float(node.params.get("width_mm") or 0.0)
height = float(node.params.get("height_mm") or 0.0)
center = node.params.get("center_mm") or []
except (TypeError, ValueError) as error:
raise ValueError("box dimensions must be numeric") from error
if length <= 0 or width <= 0 or height <= 0 or len(center) != 3:
raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm")
# 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半
# 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。
corner = (
float(center[0]) - length / 2,
float(center[1]) - width / 2,
float(center[2]) - height / 2,
)
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
solid = session.adapter.box(length, width, height, plane)
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向;
# axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。
# 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。
try:
radius = float(node.params.get("radius_mm") or 0.0)
height = float(node.params.get("height_mm") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError("cylinder dimensions must be numeric") from error
if radius <= 0 or height <= 0:
raise ValueError("cylinder_add requires positive radius_mm and height_mm")
raw_axis = node.params.get("axis")
if raw_axis is not None and not (
isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None
):
raise ValueError("cylinder_add axis must define origin_mm and direction")
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
solid = session.adapter.cylinder(radius, height, axis)
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
solid = session.adapter.thread_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
# 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
del sketch
if node.atomic_id == "thread_cut":
return _execute_thread_cut(node, session)
return _execute_thread(node, session)
def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
tool = session.adapter.thread_solid(spec)
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
@@ -673,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):
@@ -682,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。
@@ -717,6 +853,15 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)]
center = params.get("center_mm")
if center:
# 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 引用,随实例平移
@@ -824,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):
@@ -840,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():
@@ -893,6 +1046,16 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se
axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
if axis.get("direction"):
axis["direction"] = _reflect_point(axis["direction"], plane, vector=True)
center = params.get("center_mm")
if isinstance(center, list) and len(center) == 3:
# box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _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 时,其镜像面引用
@@ -934,6 +1097,15 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
def _normal_is_coordinate_axis(normal: Any) -> bool:
# 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。
return (
isinstance(normal, (list, tuple))
and len(normal) == 3
and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3))
)
def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
mirror = node.params.get("mirror_plane") or {}
resolution = session.resolve(mirror)
@@ -946,6 +1118,11 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal):
# box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向,
# 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面
# (法向平行于任一坐标轴)的镜像仍然精确。
raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes")
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
sketch = session.sketches.get(str(source.sketch_id))
_execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
@@ -953,6 +1130,208 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
return session.result(node)
def _coordinate_axis_direction(direction: Any) -> bool:
# 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。
# 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向
# (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。
if not isinstance(direction, (list, tuple)) or len(direction) != 3:
return False
return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1
def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3:
# Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。
cosine = math.cos(angle_rad)
sine = math.sin(angle_rad)
axis_direction = axis.direction
cross = vector_cross(axis_direction, value)
dot = vector_dot(axis_direction, value)
return tuple( # type: ignore[return-value]
value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine)
for index in range(3)
)
def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]:
# 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。
value = tuple(float(component) for component in point)
relative = vector_subtract(value, axis.origin_mm)
rotated = _rotated_vector(relative, axis, angle_rad)
return [axis.origin_mm[index] + rotated[index] for index in range(3)]
def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]:
# 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转;
# 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与
# _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center
# 世界坐标点和圆弧法向绕轴旋转。
output = deepcopy(sketch)
workplane = output.get("workplane") or {}
if workplane.get("origin_mm"):
workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad)
for key in ("x_dir", "y_dir", "normal"):
if workplane.get(key):
workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad))
output["workplane"] = workplane
def rotate(value: Any) -> None:
if isinstance(value, dict):
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):
for child in value:
rotate(child)
for key in ("contour_edges_mm", "contour_regions_mm"):
rotate(output.get(key))
return output
def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: ExecutionSession) -> FeaturePlanNode:
# 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与
# _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转,
# 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/
# 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern
# source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层
# 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。
params = deepcopy(node.params)
plane = params.get("plane")
if isinstance(plane, dict):
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
if plane.get(key):
if key == "origin_mm":
plane[key] = _rotated_point(plane[key], axis, angle_rad)
else:
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad))
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
)
if positions_are_local:
if host_frame.get("origin_mm"):
host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad)
for key in ("x_dir", "normal"):
if host_frame.get(key):
host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad))
else:
for position in params.get("positions") or []:
if position.get("mm"):
position["mm"] = _rotated_point(position["mm"], axis, angle_rad)
feature_axis = params.get("axis")
if isinstance(feature_axis, dict):
if feature_axis.get("origin_mm"):
feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad)
if feature_axis.get("direction"):
feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad))
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)。
if node.atomic_id == "pattern_mirror":
mirror_plane = params.get("mirror_plane")
if not isinstance(mirror_plane, dict):
raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference")
frame = _owner_plane_frame(session, mirror_plane)
if frame is None:
raise ValueError("mirror plane reference cannot be transformed for circular pattern replay")
cloned_selector = deepcopy(mirror_plane)
cloned_selector["frame"] = {
"origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad),
"x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)),
"normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)),
}
params["mirror_plane"] = cloned_selector
transformed_ids: list[str] = []
for source_id in node.params.get("source_feature_ids") or []:
source_node = session.replay_definitions.get(str(source_id))
if source_node is None:
raise ValueError(f"pattern source feature {source_id} has no replay definition")
temp_id = f"{instance_id}.src.{source_id}"
shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session)
if shifted.sketch_id:
source_sketch = session.sketches.get(str(source_node.sketch_id))
if source_sketch is not None:
temp_sketch_id = f"{temp_id}.sk"
session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad)
shifted = FeaturePlanNode(
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
shifted.params, shifted.selectors, temp_sketch_id,
shifted.declared_status, shifted.source_feature,
)
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
session.nodes[temp_id] = shifted
session.replay_definitions[temp_id] = shifted
transformed_ids.append(temp_id)
params["source_feature_ids"] = transformed_ids
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
params = node.params
raw_axis = params.get("axis")
if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None):
raise ValueError("circular pattern requires an explicit axis with origin_mm and direction")
axis = AxisSpec.from_mapping(raw_axis)
count = int(params.get("pattern_count") or 1)
if count < 1:
raise ValueError("circular pattern pattern_count must be >= 1")
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("circular pattern source features have no replay definitions")
for instance in range(1, count):
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
angle_deg = sweep_angle_deg * instance / count
angle_rad = math.radians(angle_deg)
for source in sources:
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"circular pattern source uses an unsupported {dependency}")
if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad):
raise ValueError(
"box_add circular pattern is exact only for coordinate-axis rotation "
"by multiples of 180 degrees"
)
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
# 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。
session.replay_definitions[node.feature_id] = node
return session.result(node)
def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool:
# box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴,
# 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确
# (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的
# box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。
if not _coordinate_axis_direction(axis.direction):
return False
half_turns = abs(math.degrees(angle_rad)) / 180.0
return abs(half_turns - round(half_turns)) < 1e-9
def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_circular_pattern(node, session, _execute_node)
def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult:
executor = EXECUTORS.get(node.atomic_id)
if executor is None:
@@ -972,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)
@@ -987,6 +1371,16 @@ def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: d
return _execute_sphere(node, session)
def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_box(node, session)
def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_cylinder(node, session)
def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_hole(node, session)
@@ -1021,9 +1415,16 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"reference_plane": _reference_plane_executor,
"reference_axis": _reference_axis_executor,
"sphere_add": _sphere_executor,
"box_add": _box_executor,
"cylinder_add": _cylinder_executor,
"thread_add": _thread_executor,
"thread_cut": _thread_executor,
"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,
@@ -1034,6 +1435,7 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"chamfer": _chamfer_executor,
"pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_pattern_executor,
}
@@ -273,6 +273,88 @@ class HoleSpec:
)
@dataclass(frozen=True)
class ThreadSpec:
"""Runtime-neutral definition of a parametric screw thread.
The spec deliberately contains no OCC planes or shapes. The adapter turns
this definition into a threaded solid segment keeping source-contract
parsing separate from B-rep work.
``axis.origin_mm`` anchors the leading end face of the threaded segment and
``axis.direction`` is the outward thread axis; ``angle_deg`` is the full
flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews).
``internal`` selects the cutting form used by later thread_cut support.
"""
major_diameter_mm: float
minor_diameter_mm: float
pitch_mm: float
length_mm: float
axis: AxisSpec
angle_deg: float = 60.0
internal: bool = False
lefthand: bool = False
relief_length_mm: float = 0.0
crest_radius_mm: float = 0.0
root_radius_mm: float = 0.0
@classmethod
def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec":
try:
major = float(params.get("major_diameter_mm") or 0.0)
minor = float(params.get("minor_diameter_mm") or 0.0)
pitch = float(params.get("pitch_mm") or 0.0)
length = float(params.get("length_mm") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError("thread dimensions must be numeric") from error
if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0:
raise ValueError("thread requires positive major/minor/pitch/length in millimetres")
if minor >= major:
raise ValueError("thread minor diameter must be smaller than the major diameter")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("thread requires an axis definition")
angle = 60.0
raw_angle = params.get("angle_deg")
if raw_angle is not None:
try:
angle = float(raw_angle)
except (TypeError, ValueError) as error:
raise ValueError("thread angle_deg must be numeric") from error
if not 0 < angle < 180:
raise ValueError("thread angle_deg must be between 0 and 180")
relief = float(params.get("relief_length_mm") or 0.0)
crest_r = float(params.get("crest_radius_mm") or 0.0)
root_r = float(params.get("root_radius_mm") or 0.0)
if relief < 0:
raise ValueError("thread relief_length_mm must be non-negative")
if crest_r < 0 or root_r < 0:
raise ValueError("thread crest/root radius must be non-negative")
# 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半,
# 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。
depth_radius = (major - minor) / 2.0
if crest_r + root_r > 0.5 * depth_radius:
raise ValueError("thread crest_radius + root_radius exceeds half the thread depth")
# thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传
# internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓
# 刀具做布尔差会在 coincident faces 上退化)。
internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False))
return cls(
major_diameter_mm=major,
minor_diameter_mm=minor,
pitch_mm=pitch,
length_mm=length,
axis=AxisSpec.from_mapping(raw_axis),
angle_deg=angle,
internal=internal,
lefthand=bool(params.get("lefthand", False)),
relief_length_mm=relief,
crest_radius_mm=crest_r,
root_radius_mm=root_r,
)
@dataclass(frozen=True)
class RuntimeDiagnostic:
code: str
+23 -4
View File
@@ -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"]
+158
View File
@@ -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-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)
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()
@@ -0,0 +1,166 @@
"""N2 pattern_circular —— 旋转几何契约层测试。
中文说明
--------
统计报告把 N2(直线/环形阵列)归为 A 类;runtime 的 pattern 采用源特征
重放范式(pattern_linear/_translated_node、pattern_mirror/_mirrored_node),
本次为 pattern_circular 补齐了缺失的"环形重放实现入口":旋转重放节点
(_rotated_node)与 Rodrigues 旋转辅助(_rotated_vector/_rotated_point)。
本文件是三个测试角度中的**几何单元层**:只验证旋转数学与草图/参数变换
函数的正确性,不驱动 executor、不跑布尔运算。旋转是环形阵列几何正确性
的全部基础——若绕轴旋转的坐标变换有误,任何 count/包角组合都会产出错误
实例位置。runtime 端到端层见 test_engine_circular_pattern_runtime.py。
契约要点(与 runtime 层的分工):
1. Rodrigues 旋转是等距变换:轴向分量守恒、长度/到轴距离不变、
非轴向按 cos/sin 旋转 → 用 90°/180° 与随机向量精确断言坐标。
2. 方向约定:绕轴 direction 正角度为右手逆时针(+Z 轴把 +X 转到 +Y)。
3. _rotated_sketch 只旋转世界坐标(workplane frame 与 start/end/center_mm),
2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。
4. _box_circular_is_exact:box_add 是固定世界轴对齐图元,仅坐标轴旋转
且每份转角为 180° 整数倍时才精确(对齐 _execute_mirror_pattern 的
box 坐标平面限制思路)。
sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import
cdsl_engine 包内模块直测 adapter(与既有测试风格一致)。
"""
from __future__ import annotations
import math
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime import ( # noqa: E402
_box_circular_is_exact,
_coordinate_axis_direction,
_rotated_point,
_rotated_sketch,
_rotated_vector,
)
from cdsl_engine.runtime_types import AxisSpec # noqa: E402
X_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(1.0, 0.0, 0.0))
Y_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 1.0, 0.0))
Z_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0))
# Rodrigues 要求单位轴(运行时 AxisSpec.from_mapping 会单位化);测试直接
# 构造 dataclass,必须显式传入单位方向。
_UNIT_DIAGONAL = math.sqrt(1.0 / 3.0)
SKEW_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0),
direction=(_UNIT_DIAGONAL, _UNIT_DIAGONAL, _UNIT_DIAGONAL))
def _assert_vector_close(test: unittest.TestCase, actual, expected, *, places: int = 9) -> None:
for actual_component, expected_component in zip(actual, expected):
test.assertAlmostEqual(float(actual_component), float(expected_component), places=places)
class CircularPatternGeometryTests(unittest.TestCase):
def test_rotated_point_around_z_axis_cardinal_angles(self) -> None:
# +Z 轴右手逆时针:90° 把 (10, 0, 0) 转到 (0, 10, 0),180° 到 (-10, 0, 0),
# 270° 到 (0, -10, 0)。z 坐标不变。
point = (10.0, 0.0, 4.0)
for angle_deg, expected in [
(90.0, (0.0, 10.0, 4.0)),
(180.0, (-10.0, 0.0, 4.0)),
(270.0, (0.0, -10.0, 4.0)),
(360.0, (10.0, 0.0, 4.0)),
]:
with self.subTest(angle=angle_deg):
rotated = _rotated_point(point, Z_AXIS, math.radians(angle_deg))
_assert_vector_close(self, rotated, expected)
def test_rotated_point_negative_angle_rotates_clockwise(self) -> None:
# -90° 顺时针:+X 转到 -Y。
rotated = _rotated_point((10.0, 0.0, 0.0), Z_AXIS, math.radians(-90.0))
_assert_vector_close(self, rotated, (0.0, -10.0, 0.0))
def test_rotated_point_about_arbitrary_axis_keeps_axis_distance(self) -> None:
# 绕空间对角轴:轴向分量守恒、到轴距离不变(等距变换)。
point = (3.0, -2.0, 7.0)
rotated = _rotated_point(point, SKEW_AXIS, math.radians(40.0))
axial = sum(component * value for component, value in zip(point, SKEW_AXIS.direction))
rotated_axial = sum(component * value for component, value in zip(rotated, SKEW_AXIS.direction))
self.assertAlmostEqual(rotated_axial, axial, places=9)
distance_sq = sum(c * c for c in point) - axial * axial
rotated_distance_sq = sum(c * c for c in rotated) - rotated_axial * rotated_axial
self.assertAlmostEqual(rotated_distance_sq, distance_sq, places=9)
def test_rotated_point_around_offset_axis(self) -> None:
# 轴不过原点:先平移到轴、旋转、再平移回。绕 (10, 0, 0) 竖直轴转 90°,
# 点 (10, 5, 0) 的相对矢量 (0, 5, 0) 转到 (-5, 0, 0) → (5, 0, 0)。
offset_axis = AxisSpec(origin_mm=(10.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0))
rotated = _rotated_point((10.0, 5.0, 0.0), offset_axis, math.radians(90.0))
_assert_vector_close(self, rotated, (5.0, 0.0, 0.0))
def test_rotated_vector_preserves_length_and_normalizes_axis(self) -> None:
# 向量旋转不含平移项(原点到向量尾所在轴上的投影分量守恒)。
value = (2.0, 0.0, 0.0)
rotated = _rotated_vector(value, Z_AXIS, math.radians(90.0))
_assert_vector_close(self, rotated, (0.0, 2.0, 0.0))
for axis, expected in [
(X_AXIS, (2.0, 0.0, 0.0)), # 绕自身轴旋转不变
(Y_AXIS, (0.0, 0.0, -2.0)), # 绕 +Y 转 90°(右手)把 +X 转到 -Z
]:
with self.subTest(axis=axis.direction):
_assert_vector_close(self, _rotated_vector(value, axis, math.radians(90.0)), expected)
def test_rotated_vector_arbitrary_angle_is_length_preserving(self) -> None:
value = (1.0, 2.0, 3.0)
rotated = _rotated_vector(value, SKEW_AXIS, math.radians(71.0))
self.assertAlmostEqual(
sum(c * c for c in rotated), sum(c * c for c in value), places=9)
def test_rotated_sketch_rotates_workplane_and_world_contours_only(self) -> None:
# workplane frame(原点 + 三向量)与 contour 世界坐标点绕 +Z 转 90°;
# 2D 局部实体坐标保持原样(frame 旋转负责映射到新世界位置)。
sketch = {
"workplane": {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]},
"entities": [{"type": "circle", "center": [0.0, 1.0], "radius_mm": 2.0}],
"contour_edges_mm": [{"start_mm": [1.0, 2.0, 0.0], "end_mm": [3.0, 4.0, 5.0],
"center_mm": [2.0, 3.0, 0.0]}],
"contour_regions_mm": [{"outer": [{"start_mm": [0.0, 1.0, 0.0],
"end_mm": [1.0, 0.0, 0.0]}]}],
}
rotated = _rotated_sketch(sketch, Z_AXIS, math.radians(90.0))
_assert_vector_close(self, rotated["workplane"]["origin_mm"], (0.0, 0.0, 0.0))
_assert_vector_close(self, rotated["workplane"]["x_dir"], (0.0, 1.0, 0.0))
_assert_vector_close(self, rotated["workplane"]["y_dir"], (-1.0, 0.0, 0.0))
_assert_vector_close(self, rotated["workplane"]["normal"], (0.0, 0.0, 1.0))
self.assertEqual(rotated["entities"], sketch["entities"])
edge = rotated["contour_edges_mm"][0]
_assert_vector_close(self, edge["start_mm"], (-2.0, 1.0, 0.0))
_assert_vector_close(self, edge["end_mm"], (-4.0, 3.0, 5.0))
_assert_vector_close(self, edge["center_mm"], (-3.0, 2.0, 0.0))
outer = rotated["contour_regions_mm"][0]["outer"][0]
_assert_vector_close(self, outer["start_mm"], (-1.0, 0.0, 0.0))
_assert_vector_close(self, outer["end_mm"], (0.0, 1.0, 0.0))
def test_box_circular_exactness_boundary(self) -> None:
# box_add 固定世界轴对齐:仅坐标轴旋转且每份转角为 180° 整数倍时精确。
self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(180.0)))
self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(360.0)))
self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(90.0)))
self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(45.0)))
# 绕 x/y 的非 180° 转角同样不可精确表达;空间对角轴任何转角都不行。
self.assertFalse(_box_circular_is_exact(X_AXIS, math.radians(90.0)))
self.assertFalse(_box_circular_is_exact(SKEW_AXIS, math.radians(180.0)))
def test_coordinate_axis_direction_detection(self) -> None:
self.assertTrue(_coordinate_axis_direction([0.0, 0.0, 1.0]))
self.assertTrue(_coordinate_axis_direction((0.0, -1.0, 0.0)))
self.assertFalse(_coordinate_axis_direction([1.0, 1.0, 1.0]))
self.assertFalse(_coordinate_axis_direction([1.0, 0.0, 0.5]))
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -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()
@@ -191,19 +191,27 @@ class PatternTransformContractTests(unittest.TestCase):
self.assertTrue(pattern.executable)
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
def test_mirror_source_stays_blocked(self) -> None:
"""回归护栏:mirror-as-source(plane selector)仍阻塞。
def test_mirror_source_is_replayed_by_linear_pattern(self) -> None:
"""mirror-as-source 放行 + 平移几何正确(体积 3000 精确)。
pattern_mirror 的镜像面是 reference_plane 引用;线性阵列重放镜像
特征需要把镜像面逐实例平移,当前执行器没有实例变换通道,放行会
产出"所有实例重合"的错误几何。因此 mirror 特征作为 pattern source
必须继续保持 unsupported_pattern_selector_transform。
早期(#6 修复前)mirror 的镜像面是 reference_plane 引用,无实例变换
通道,linear 阵列重放 mirror 会退化成"所有实例重合",因此曾阻塞
mirror-as-source。_translated_node 已把镜像面 frame 随实例平移内联、
源递归平移后重放(镜像副本 = reflect(源@t, 面@t))→ 该护栏已过时。
本测试把新语义固定下来:分析放行(无
unsupported_pattern_selector_transform)+ 几何正确。
baseline 10×10×10(x/y ∈ [-5,5]、z 0..10)。mirror 跨平面 y=5 镜像
base → box x ∈ [-5,5]、y ∈ [5,15](vol 1000,与 base 共面 fuse 为
2000)。linear 沿 +x 间距 12 重放 mirror 源 → 镜像面平移到 (12,5) 且
源 base 平移至 x ∈ [7,17],反射得 box x ∈ [7,17]、y ∈ [5,15]
(vol 1000)。期望总体积 = 1000 + 1000 + 1000 = 3000。
"""
base = _base_block()
base["features"].extend([
{
"id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [],
"params": {"plane": _workplane(origin=[0, 0, 0], normal=[0, 1, 0])},
"params": {"plane": _workplane(origin=[0, 5, 0], normal=[0, 1, 0])},
},
{
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "plane_ctx"],
@@ -219,13 +227,16 @@ class PatternTransformContractTests(unittest.TestCase):
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["mirror"],
"params": {"source_feature_ids": ["mirror"], "direction_1": [1, 0, 0],
"spacing_1_mm": 10, "pattern_count_1": 2},
"spacing_1_mm": 12, "pattern_count_1": 2},
},
])
analysis = analyze_cdsl(base)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertFalse(pattern.executable)
self.assertIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
self.assertTrue(pattern.executable)
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(base, Path(directory) / "mirror-in-linear.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 3000.0, places=6)
def test_pattern_replays_face_selector_hole_geometry(self) -> None:
"""几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱。
@@ -305,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()
@@ -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
@@ -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()
+123
View File
@@ -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()
@@ -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` 的受控资产协议。
+25
View File
@@ -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/<sample_id>/`.
## 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.
+48 -3
View File
@@ -2,7 +2,9 @@ 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 .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")
@@ -12,11 +14,30 @@ 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", "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 != "scan":
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)
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 +52,33 @@ 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 == "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,
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)
+976
View File
@@ -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("<I", data[80:84])[0]
expected = 84 + triangle_count * 50
if triangle_count <= 0 or expected > 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("<fff", data[offset:offset + 12]))
offset += 12
offset += 2
return vertices
def _ascii_stl_vertices(data: bytes) -> 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 "无法可靠判断"
+1 -1
View File
@@ -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)
+416 -18
View File
@@ -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:
+6 -1
View File
@@ -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
+216
View File
@@ -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()))}
+942
View File
@@ -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
}
+2 -1
View File
@@ -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",
+30 -8
View File
@@ -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")]
+147
View File
@@ -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()
+131 -5
View File
@@ -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"), {})
+14
View File
@@ -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)
+73
View File
@@ -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"]
)
@@ -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)
File diff suppressed because one or more lines are too long