feat(cadfs): 扩展重建引擎能力并固化代表性模型回归

- 扩展 CDSL engine 的 shell、sweep、loft、reference plane、pattern 等运行时能力,
  支持新的实体结果模式、双向拉伸、曲线扫掠、镜像/圆周阵列及相关 selector 解析。
- 完善 Build123d 适配层的拓扑快照、Compound/ShapeList 兼容处理和旋转曲面识别,
  兼容 Python 3.12 / 当前 Build123d 缺少 axis_of_rotation 的合法曲面场景。
- 扩展 CDSL schema、profile schema、capability analysis、semantic validation 和
  sketch solver,使新增建模操作能够被校验、执行并保留可诊断的部分结果。
- 完善 CADFS FeatureScript lowering:
  支持 shell、sweep、surface/实体 loft、圆周阵列副本、镜像副本、删除阵列实例、
  新 body 操作、更多拉伸终止条件和 reference plane 变体。
- 补齐椭圆、B-spline、环形区域、imprint、SWEPT_FACE、CAP_FACE、OFFSET_FACE 等
  草图和拓扑引用的转换逻辑,改善后续特征的工作平面、轴线和 profile 定位精度。
- 改进 selector binding:支持 pattern 前缀复合 B-rep 快照、交集顶点引用、
  多面 match_mode=all、圆柱轴线/半径和面积下限等稳定匹配条件。
- 修复 MID_PLANE 法向统一后交线方向未同步的问题,恢复 00287955 基准面的正确位置;
  修复 00542223 sweep 路径反转后的切线契约和 00423838 的拓扑面数不稳定测试假设。
- 修正 CADFS 比较模块 import 路径,补充重建报告、批量重建脚本、目标文档和 README。
- 新增并扩展 engine、lowering、parser、selector binding、reports、integration 和
  Onshape pipeline 回归测试,覆盖代表性 CADFS 特征链及运行时兼容性。
This commit is contained in:
2026-09-08 11:47:10 +08:00
parent e7dc521ece
commit 738934416e
28 changed files with 5532 additions and 339 deletions
+1
View File
@@ -1,3 +1,4 @@
output/
output-history/
__pycache__/
*.pyc
@@ -0,0 +1,195 @@
# CADFS 全量重建目标与验收计划
## 目标
将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与
原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过;
最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。
这里的“重建成功”仅指 `comparison.json` 的 `strict.passed == true`,不是
仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。
本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更
后的证据记录在本地台账
`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;两者必须保持一致。
## 当前基线
- 全量输出快照:9,347 个 CADFS 样本,见 `output/summary.json`。产品目标可
称为“一万条全量数据”,但验收时必须以本次扫描得到的实际样本总数为分母,
不能将 10,000 写成未验证的固定数量。
- 代表性回归池:17 个样本,见 `regression/manifest.json`。它覆盖当前观察到的
FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。
- 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in
manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion
样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类
当作当前通过结果。
- 当前全量报告中仍有 `rebuild_failed`、`rebuild_timeout`、`comparison_timeout` 和
`rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。
## 验收口径
一个样本必须同时具备以下工件和结果,才计入严格重建成功:
1. `candidate.cdsl.json` 通过 schema 和语义验证,且没有以跳过后续几何换取成功。
2. `bound.cdsl.json`(若样本需要 selector 绑定)可复现产生,绑定证据可追溯到
feature、owner 和 topology snapshot。
3. `rebuild.step` 由完整的 CDSL feature history 生成;中间失败不能被最终可执行
前缀掩盖。
4. `comparison.json` 的 `decision` 为 `strict_pass`,并同时满足:
`surface_max <= 0.01 mm`、`surface_p99 <= 0.01 mm`、
`bbox_max_delta <= 0.01 mm`、体积和表面积相对误差均不超过 `1e-5`,以及实体数相等。
5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似
的模型必须保留,而不能在失败时删除。
`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。
缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception,
附原始证据;不得伪装成 engine 或 converter 已完成。
## 第一阶段:17 个代表样本
阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过,
同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力;
一个样本可能由多个能力共同阻塞。
| 样本 | 代表的操作或几何 | 当前需要验证或补齐的门槛 |
| --- | --- | --- |
| `00002243` | extrude、hole、chamfer | 孔宿主、端盖/边 selector、倒角参数与稳定选边。 |
| `00035682` | cPlane、extrude、revolve | `line_point` 基准面及其后续特征引用。 |
| `00054089` | extrude、revolve | `swept_edge` selector。 |
| `00111611` | extrude、revolve、fillet、chamfer | 复杂草图、回转轴、圆角和倒角的拓扑稳定性。 |
| `00129362` | cPlane、extrude、revolve | 可复用基准面 frame、回转 add/cut。 |
| `00159804` | circularPattern、through-all add | 环形阵列 replay、`line_angle` 和 through-all 加料。 |
| `00192744` | circularPattern、extrude | 已执行 `curve_point` 附着 frame、`up_to_next` 和 pattern copy deletion;仍缺多 body transform 与 source/STEP 一致性证据。 |
| `00212904` | shell、two-sided cut | shell、`three_point`、双向切除终止条件。 |
| `00287955` | loft、cPlane、revolve | 多 profile loft、`cap_edge`、`mid_plane`。 |
| `00423838` | circularPattern、extrude | `up_to_vertex` 与 two-sided cut 的非盲终止。 |
| `00542223` | shell、sweep、circularPattern | sweep path/profile、shell、`plane_point`、through-all cut。 |
| `00612529` | loft、mirror、cPlane | loft profile 对齐、镜像 replay、`mid_cap_edge`。 |
| `00694309` | extrude、fillet | `up_to_body` 和 feature 后继 selector。 |
| `00710855` | extrude、chamfer | 曲面拉伸与 surface/solid 混合拓扑。 |
| `00789939` | shell、extrude、fillet | shell、`offset_face`、`swept_face`。 |
| `00835610` | booleanBodies、revolve | 多 body boolean、`cap_face`、`intersect`、revolve surface。 |
| `00925274` | mirror、through-all cut | 镜像 replay、`up_to_surface`、`line_angle`。 |
推荐的完整验收命令:
```bash
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
--tier all --stage pipeline --compare-mode strict --timeout-seconds 60
```
命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。
## 能力完成目标
能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、
selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为
“部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。
### P0:使特征历史成为完整实体
1. 复杂草图与 B-spline:稳定的多 wire、孔洞、闭合排序、退化诊断;开口 wire
可作为 sweep path,闭合 wire 可作为实体 profile。
2. 显式多 body 结果模型:body ID、选择集、复制、保留和生命周期;这是
`booleanBodies`、transform、pattern copy 的前置条件。
3. sweep、完整 loft、shell 和 booleanBodies:包含实体/曲面模式、导轨或 profile
对齐、移除面、结果 body 和可诊断的失败边界。
4. circularPattern 与 mirror:source replay、完整轴/面引用和嵌套 pattern;实例必须
是独立的几何结果,不能依赖隐式全局主体。
### P0:补齐拉伸终止语义
按全量影响数优先完成复杂 `extrude`,以及 through-all、two-sided、up-to-surface、
up-to-next、up-to-body、up-to-vertex 和 surface/mixed 模式。终止距离必须由目标 body
的真实相交结果或明确的源参考决定,不能用固定默认值近似。
### P1:让后续特征引用稳定
1. 记录内核拓扑 delta:`preserved`、`modified`、`generated`、`deleted` 和 feature
输出角色;不唯一的后继必须诊断为歧义。
2. 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 的 face/edge selector,并保留
owner 与几何签名。
3. 完成 line-angle、plane-point、three-point、mid-plane、line-point、curve-point
基准面,覆盖曲面和曲线切线引用及退化输入。
4. 完成 fillet、chamfer、hole、revolve/revolve_surface 的参数变体、可行性预检查
和拓扑后继处理。
### P2:清除全量语料的剩余 FeatureScript 缺口
实现 draft、thicken、split、moveFace、deleteFace、replaceFace、完整 transform,
以及 derive/import 的受控外部资产协议。任何依赖外部模型的操作只有在资产身份、
输入、版本和失败方式均可复现时,才能计入全量成功。
## 实现参考与边界
`/Users/lk/Downloads/SimpleCADAPI-master 4` 可作为 OCP 调用和拓扑建模的实现
参考,而不是可直接替换的 engine:
- sweep、loft、shell 分别可参考 `BRepOffsetAPI_MakePipeShell`、
`BRepOffsetAPI_ThruSections` 和 shell builder 的调用边界、失败处理和 shape
输出方式。
- boolean、fillet、chamfer 应参考其内核 `Modified`、`Generated`、`IsDeleted`
的拓扑 delta 采集方式,落实本计划要求的 feature 输出角色和 selector 后继证据。
- transform 和 pattern 应作用于明确的 shape/body 输入,并保留每个实例的独立结果。
SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS 的多 body、
保留工具体、复制和 body 生命周期语义。实现时只能借鉴 OCP 调用与拓扑证据模型,
必须保留本项目的 CDSL schema、runtime adapter 和显式 body-result contract。
## Source Exception Evidence
严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。
| 分类 | 样本 | 当前证据 | 处理要求 |
| --- | --- | --- | --- |
| 已验证 source exception | 无 | 当前 17 样本没有已经证明可豁免的 source exception。 | 不得从验收分母移除。 |
| engine / converter 缺陷(受限路径已修复) | `00710855` | F5 的 CADFS IMPRINT cut 已由错误圆盘改为 `r=19..25.5 mm` 环。对于 F10,runtime 仅在显式同轴 surface shell 在 selected plane 提供内边界证据时,才以源 `width=10 mm` 构造受限圆锥倒角;完整 history 已可执行。 | 将该受限模式扩展为一般 surface/solid split、topology delta 和后继 selector;不得缩小源倒角宽度。 |
| source / STEP 精度候选 | `00710855` | 完整 history 的 surface 最大差为 `0.0035 mm`,面积、bbox 与实体数满足 strict;但 source F8 E14 是 `r=22 mm`,gold STEP 对应圆柱是 `r=21.9965 mm`,导致体积相对误差 `7.998e-5` 超过 strict。 | 保存 source literal 与 STEP 测量证据;未获得 source export 版本证明前不得回填 gold 半径,也不得从验收分母移除。 |
| source / STEP 差异候选 | `00035682` | FeatureScript 圆柱/孔径 literal 的精度低于原 STEP 的测量值;现有拓扑类型和数量相同,但严格体积/面积不一致。 | 保存原始精度证据;复核 source 导出版本后才能定为 exception。 |
| source / STEP 差异候选 | `00192744` | F3/F5 的 frame 与 `UP_TO_NEXT` 已修复,F7/F12 的 `COPY(BODY)` 删除也已 replay,完整 history 可执行且 bbox 对齐;但 source F15 将 F1 移动约 `-10000 mm`,gold STEP 仍位于约 `y=[-500,500]`。按 source 缺省 `NEW` 保留 8 个实体,gold 只有 1 个,体积相对误差仍为 `1.001e-3`。 | 保持严格失败并完成通用 multi-body transform/body lifecycle;不以 gold STEP 反推、伪造 transform 参数或把 source `NEW` 改成 `ADD`。 |
| source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 |
| source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`;FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 |
## 全量完成门槛
在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足:
1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`、
`unsupported_operation`、`unsupported_engine_capability` 和因 converter 丢失
feature 而导致的 `converted_partial` 均为零。
2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step`;`rebuild_failed` 和
`rebuild_timeout` 均为零。
3. 每个可比较样本严格通过比较;`comparison_timeout`、`rebuilt_rejected` 和仅
`rebuilt_approximate` 均不计入完成。
4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功
分母,也不得吞并为“未分类失败”。
5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到
converter、engine、源数据或比较基础设施。
17 个样本覆盖了已观察到的特征类别,但不是全量正确性的数学证明。只有滚动的
全量比较通过,才能声称完成全量重建。
## 每次修复的执行规则
1. 先重跑发生退化的代表样本,并读取 `history.json`、`candidate.cdsl.json`、
`bound.cdsl.json`、`rebuild.json` 和 `comparison.json`。
2. 将问题归类为:CADFS parser/lowering 丢失或误译、CDSL schema/contract 无表达、
engine/adapter 几何能力不足、selector/body 引用错误、源数据异常,或比较基础设施
问题。证据不足时不得猜测分类。
3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及
时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。
4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与
证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`。
5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过。
## 阶段交付物
| 阶段 | 必须交付 | 通过条件 |
| --- | --- | --- |
| R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 |
| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 |
| R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 |
| R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
+15
View File
@@ -15,6 +15,21 @@ 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>/`.
## Full corpus one-command runner
Run the full conversion, rebuild, comparison, and Markdown report with:
```bash
./cadfs_to_cdsl/run_full_rebuild.sh
```
The default is a forced fresh pass. Before it begins, the previous
`cadfs_to_cdsl/output` directory is moved to
`cadfs_to_cdsl/output-history/<timestamp>/`; it is never deleted or mixed
with the new result. Use `--resume` to reuse the existing output without
archiving it, or `--compare-mode strict` for strict acceptance. See
`./cadfs_to_cdsl/run_full_rebuild.sh --help` for worker and timeout options.
## Representative regression pool
Generate the checked-in representative manifest after a full corpus conversion:
+13 -6
View File
@@ -9,11 +9,13 @@ RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relat
def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
surface = report["surface"]; metrics = report["metrics"]
maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"])
p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"])
maximum = [surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]]
p99 = [surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]]
surface_ready = all(isinstance(value, (int, float)) for value in [*maximum, *p99])
checks = {
"surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"],
"bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
"surface_max": surface_ready and max(maximum) <= limits["surface_mm"],
"surface_p99": surface_ready and max(p99) <= limits["surface_mm"],
"bbox": isinstance(metrics["bbox_max_delta_mm"], (int, float)) and metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
"volume": metrics["volume_relative_error"] <= limits["volume_relative"],
"area": metrics["surface_area_relative_error"] <= limits["area_relative"],
"solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"],
@@ -22,8 +24,13 @@ def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]:
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm)
from onshape_to_cdsl.compare import strict_compare
raw = strict_compare(
gold_step,
rebuilt_step,
surface_tolerance_mm=surface_tessellation_mm,
fast_reject_relative_error=RP["volume_relative"],
)
strict, rp = _assess(raw, STRICT), _assess(raw, RP)
decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected"
return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision}
+30 -7
View File
@@ -63,21 +63,44 @@ class Parser:
return left
def statements(self) -> list[Call]:
found: list[Call] = []; environment: dict[str, Any] = {}
found: list[Call] = []; scopes: list[dict[str, Any]] = [{}]
def environment() -> dict[str, Any]:
resolved: dict[str, Any] = {}
for scope in scopes: resolved.update(scope)
return resolved
def assignment_scope(name: str) -> dict[str, Any]:
return next((scope for scope in reversed(scopes) if name in scope), scopes[-1])
while self.peek().kind != "eof":
if self.accept("{"):
scopes.append({}); continue
if self.accept("}"):
if len(scopes) > 1: scopes.pop()
continue
if self.peek().value == "var":
self.pop()
if self.peek().kind == "ident":
name = self.pop().value
if self.accept("="):
scopes[-1][name] = _resolve(self.expression(), environment())
else: scopes[-1][name] = name
if isinstance(scopes[-1][name], Call) and scopes[-1][name].name == "newSketch":
found.append(scopes[-1][name])
continue
if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=":
name = self.pop().value; self.pop()
try:
environment[name] = self.expression()
if isinstance(environment[name], Call) and environment[name].name == "newSketch":
value = environment[name]
value.args = [_resolve(arg, environment) for arg in value.args]
scope = assignment_scope(name); scope[name] = _resolve(self.expression(), environment())
if isinstance(scope[name], Call) and scope[name].name == "newSketch":
value = scope[name]
found.append(value)
except Exception: pass
elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(":
value = self.expression()
if isinstance(value, Call):
value.args = [_resolve(arg, environment) for arg in value.args]
value.args = [_resolve(arg, environment()) for arg in value.args]
found.append(value)
else: self.i += 1
return found
@@ -119,7 +142,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", "transform"}:
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "deleteBodies", "transform", "draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "derive"}:
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)
+2058 -84
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -102,7 +102,7 @@ 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)
status = convert_one(sample, output, force=True) if force else read_json(status_path) if status_path.exists() else convert_one(sample, output)
# 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.
@@ -146,7 +146,7 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
else: comparison = read_json(comparison_path)
status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"]
status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected"
status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected"
write_json(status_path, status); return status
+2 -1
View File
@@ -45,7 +45,8 @@ def parse_query(value: Any) -> QueryInfo:
elif "isStart" in definition: info.is_start = bool(definition["isStart"])
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0]
info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2])
if info.topology_type is None: info.kind = str(call.args[1]).lower()
info.source_entity = str(call.args[2])
if call.name == "qSketchRegion" and call.args:
info.source_sketch = symbolic_string(call.args[0]); info.kind = "face"
return info
+11 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import csv, json, re
import csv, json, re, tempfile
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
@@ -9,9 +9,16 @@ from typing import Any
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False,
) as handle:
temporary = Path(handle.name)
handle.write(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n")
temporary.replace(path)
finally:
if temporary is not None: temporary.unlink(missing_ok=True)
def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8"))
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Run the complete CADFS corpus while retaining a snapshot of the prior run.
set -Eeuo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
INPUT_DIR="${ROOT_DIR}/data/cadfs-sample/CADFS_test"
OUTPUT_DIR="${ROOT_DIR}/cadfs_to_cdsl/output"
HISTORY_DIR="${ROOT_DIR}/cadfs_to_cdsl/output-history"
WORKERS=4
TIMEOUT_SECONDS=60
COMPARE_MODE=rp
FORCE=true
usage() {
cat <<'EOF'
Usage: ./cadfs_to_cdsl/run_full_rebuild.sh [options]
Runs conversion, CDSL rebuild, STEP comparison, and the Markdown report for
every sample in data/cadfs-sample/CADFS_test.
By default the existing cadfs_to_cdsl/output directory is moved to
cadfs_to_cdsl/output-history/<timestamp>/ before a forced full rerun.
Options:
--resume Reuse cached results in output; do not archive or force rerun.
--workers N Concurrent samples (1-8, default: 4).
--timeout-seconds N Per-model rebuild timeout in seconds (default: 60).
--compare-mode rp|strict Comparison acceptance mode (default: rp).
-h, --help Show this help message.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--resume)
FORCE=false
shift
;;
--workers)
[[ $# -ge 2 ]] || { echo "--workers requires a value" >&2; exit 2; }
WORKERS="$2"
shift 2
;;
--timeout-seconds)
[[ $# -ge 2 ]] || { echo "--timeout-seconds requires a value" >&2; exit 2; }
TIMEOUT_SECONDS="$2"
shift 2
;;
--compare-mode)
[[ $# -ge 2 ]] || { echo "--compare-mode requires a value" >&2; exit 2; }
COMPARE_MODE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ "$WORKERS" =~ ^[1-8]$ ]] || { echo "--workers must be an integer between 1 and 8" >&2; exit 2; }
[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+([.][0-9]+)?$ ]] && (( $(awk "BEGIN { print (${TIMEOUT_SECONDS} > 0) }") )) || {
echo "--timeout-seconds must be positive" >&2
exit 2
}
[[ "$COMPARE_MODE" == "rp" || "$COMPARE_MODE" == "strict" ]] || {
echo "--compare-mode must be rp or strict" >&2
exit 2
}
[[ -d "$INPUT_DIR" ]] || { echo "CADFS input directory not found: $INPUT_DIR" >&2; exit 1; }
[[ -f "$INPUT_DIR/CADFS_text_test.jsonl" ]] || { echo "CADFS text manifest not found: $INPUT_DIR/CADFS_text_test.jsonl" >&2; exit 1; }
if [[ -x "${ROOT_DIR}/backend/.venv/bin/python" ]]; then
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/backend/.venv/bin/python}"
else
PYTHON_BIN="${PYTHON_BIN:-python3}"
fi
command -v "$PYTHON_BIN" >/dev/null 2>&1 || { echo "Python executable not found: $PYTHON_BIN" >&2; exit 1; }
archive_output() {
[[ -d "$OUTPUT_DIR" ]] || return
[[ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]] || return
mkdir -p "$HISTORY_DIR"
local timestamp archive_dir suffix=1
timestamp="$(date +%Y%m%d-%H%M%S)"
archive_dir="${HISTORY_DIR}/${timestamp}"
while [[ -e "$archive_dir" ]]; do
archive_dir="${HISTORY_DIR}/${timestamp}-${suffix}"
((suffix += 1))
done
mv "$OUTPUT_DIR" "$archive_dir"
echo "Previous output archived at: $archive_dir"
}
on_error() {
local code=$?
echo "Run stopped with exit code ${code}. Partial artifacts remain in: $OUTPUT_DIR" >&2
exit "$code"
}
trap on_error ERR
if [[ "$FORCE" == true ]]; then
archive_output
echo "Starting forced full corpus rebuild."
else
echo "Resuming existing full corpus rebuild without archiving output."
fi
mkdir -p "$OUTPUT_DIR"
cd "$ROOT_DIR"
pipeline_args=(
pipeline
--input "$INPUT_DIR"
--output "$OUTPUT_DIR"
--workers "$WORKERS"
--timeout-seconds "$TIMEOUT_SECONDS"
--compare-mode "$COMPARE_MODE"
)
if [[ "$FORCE" == true ]]; then
pipeline_args+=(--force)
fi
PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
"$PYTHON_BIN" -m cadfs_to_cdsl "${pipeline_args[@]}"
PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
"$PYTHON_BIN" -m cadfs_to_cdsl report --input "$INPUT_DIR" --output "$OUTPUT_DIR" --markdown
echo "Current results: $OUTPUT_DIR"
echo "Full report: $OUTPUT_DIR/full_run_report.md"
+88 -30
View File
@@ -9,23 +9,40 @@ from typing import Any
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
scores: list[float] = []
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"):
reversed_plane_normal = False
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
if key in expected:
left, right = expected[key], actual.get(key)
# plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的
# plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。
use_plane_normal = key == "normal" and "plane_offset_mm" in expected and actual.get("plane_normal") is not None
right = actual.get("plane_normal") if use_plane_normal else actual.get(key)
left = expected[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":
if key in {"normal", "axis_direction"}:
# 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))))
# start cap. A rotational-face axis is likewise a geometric
# line, whose direction may be reversed by OCC.
reversed_delta = math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right)))
if key == "normal" and use_plane_normal and reversed_delta < delta: reversed_plane_normal = True
delta = min(delta, reversed_delta)
scores.append(max(0.0, 1.0 - delta / 0.05))
for key in ("radius_mm", "plane_offset_mm"):
if key in expected:
try: delta = abs(float(expected[key]) - float(actual[key]))
try:
value = float(actual[key])
# 平面偏移是 normal · point。面法向反向时,同一几何平面的
# 有符号偏移也必须同步反号,不能只放宽 normal 的比较。
if key == "plane_offset_mm" and reversed_plane_normal: value = -value
delta = abs(float(expected[key]) - value)
except Exception: return None
scores.append(max(0.0, 1.0 - delta / 0.05))
if "minimum_area_mm2" in expected:
try:
if float(actual["area_mm2"]) + 1e-6 < float(expected["minimum_area_mm2"]): return None
except Exception: return None
if "bbox_mm" in expected:
actual_box = actual.get("bbox_mm")
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
@@ -69,30 +86,70 @@ def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) ->
return matches
def _binding_targets(selector: dict[str, Any]):
components = selector.get("intersection_of")
if isinstance(components, list):
for component in components:
if isinstance(component, dict): yield from _binding_targets(component)
return
yield selector
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
bound = []
for record in records:
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
selector = {"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}}
if placeholder.get("binding_feature_id") is not None: selector["binding_feature_id"] = placeholder["binding_feature_id"]
bound.append(selector)
if placeholder.get("match_mode") == "all":
selector = dict(placeholder); selector["matched_selectors"] = bound
return selector, bound
return bound[0], bound
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Rebuild every selector-bearing prefix and bind against its active body."""
from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []):
placeholders = list(feature.get("selectors") or [])
if not placeholders: continue
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
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 []
# 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}:")
]
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict): roots.append(reference)
targets = [target for root in roots for target in _binding_targets(root)]
if not targets: continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
prefix_count = index
if binding_feature_id is not None:
binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
prefix_count = binding_index + 1
if prefix_count not in prefix_cache:
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.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)
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
all_records, body_id = prefix_cache[prefix_count]
return [
item for item in all_records
# 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:
for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
@@ -112,18 +169,19 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
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 placeholder.get("match_mode") != "all" and 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]]
candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [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")]
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}})
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
selector, bound_selectors = _bound_selector(placeholder, candidates)
placeholder.clear(); placeholder.update(selector)
resolved.extend(bound_selectors)
feature_selectors = feature.get("selectors") or []
unique = {selector["stable_id"]: selector for selector in feature_selectors}; feature["selectors"] = list(unique.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]})
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved})
return bound, evidence
+158 -6
View File
@@ -23,7 +23,7 @@ class IntegrationTests(unittest.TestCase):
self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass")
def test_counterbore_abbreviation_00002243(self):
def test_standard_tapped_through_counterbore_00002243(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -32,13 +32,165 @@ class IntegrationTests(unittest.TestCase):
self.assertEqual(first["atomic_id"], "extrude_add_two_sided")
self.assertEqual(first["params"]["distance_mm"], 125.0)
self.assertEqual(first["params"]["reverse_distance_mm"], 125.0)
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3")
self.assertEqual(gap["code"], "unsupported_engine_capability")
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
cut = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(cut["atomic_id"], "extrude_cut_two_sided")
hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard")
self.assertEqual(hole["params"]["hole_type"], "c_bore")
self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25)
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0)
self.assertEqual(hole["params"]["diameter_mm"], 15.0)
self.assertEqual(hole["params"]["end_condition"]["type"], "through_all")
self.assertNotIn("counterbore", hole["params"])
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt")
comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0012/00129362.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00129362"), {})
profile = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F0")["profile"]
arcs = [segment for segment in profile["contours"][0]["segments"] if segment["type"] == "arc"]
self.assertEqual(sorted(arc["center"] for arc in arcs), [[25.4, 12.7], [25.4, 63.5]])
self.assertTrue(all(abs(arc["radius_mm"] - 12.7) <= 1e-12 for arc in arcs))
triangle = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5")["profile"]
self.assertEqual(
sorted({coordinate for contour in triangle["contours"] for segment in contour["segments"] for point in (segment.get("start"), segment.get("end")) if point for coordinate in point}),
[-12.7, -6.35, 0.0, 6.35, 12.7, 25.4, 34.925, 41.275],
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
def test_cylindrical_swept_face_selector_00111611(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"), {})
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["kind"], "face")
self.assertEqual(
fillet["selectors"][0]["geometry"],
{"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66},
)
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
self.assertEqual(
sorted(selector["geometry"]["axis_origin_mm"] for selector in chamfer["selectors"]),
sorted([[0.0, 25.4, 33.37], [33.38, 25.4, 0.0], [0.0, 25.4, -33.38], [-33.38, 25.4, 0.0]]),
)
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
f4 = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertEqual(
sorted(segment["center"] for contour in sketches[f4["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]),
sorted([[0.0, 33.37], [33.38, 0.0], [-33.38, 0.0], [0.0, -33.38]]),
)
f7 = next(item for item in result.cdsl["features"] if item["id"] == "f_F7")
self.assertEqual(
[segment["radius_mm"] for contour in sketches[f7["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]],
[4.48, 2.36],
)
self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"])
self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body")
from build123d import import_step
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
runtime_box = outcome["result"]["bbox_mm"]
imported_box = import_step(str(rebuilt)).bounding_box()
actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z]
expected = runtime_box["min"] + runtime_box["max"]
for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5)
def test_inward_shells_00789939_lower_and_rebuild(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F2"]["atomic_id"], "shell")
self.assertEqual(features["f_F5"]["atomic_id"], "shell")
self.assertEqual(features["f_F2"]["params"], {"thickness_mm": 2.5, "inward": True})
self.assertEqual(features["f_F5"]["params"], {"thickness_mm": 2.5, "inward": True})
self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
def test_sweep_00542223_preserves_its_open_bspline_path(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(sweep["sketch_id"], "sketch_F4")
self.assertEqual(segment["points"], [[-40.0, -50.0], [-20.0, -14.96], [0.0, 5.0]])
self.assertEqual(segment["start_tangent"], [27.94, 92.49])
self.assertEqual(segment["end_tangent"], [52.88, 49.52])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
def test_face_chamfer_ignores_periodic_seams_00111611(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")
from copy import deepcopy
from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract
from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges
from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
bound, _ = bind_candidate_selectors(candidate)
resolved = resolve_required_sketches(
deepcopy(bound), sketch_ids_required_by_contract(bound), errors={},
)
analysis = CapabilityAnalyzer(
atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS,
).analyze(resolved)
session = ExecutionSession(
sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]},
nodes={node.feature_id: node for node in analysis.plan},
)
for node in analysis.plan:
if node.feature_id == "f_F5":
edges = _selector_edges(node, session, tangent_propagation=True)
break
_execute_node(node, session)
else:
self.fail("F5 chamfer was not planned")
self.assertEqual(len(edges), 8)
self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges))
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0015/00159804.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00159804"), {})
source = next(item for item in result.cdsl["features"] if item["id"] == "f_F8")
pattern = next(item for item in result.cdsl["features"] if item["id"] == "f_F9")
self.assertEqual(source["atomic_id"], "extrude_cut_blind")
self.assertNotIn("result_mode", source["params"])
self.assertEqual(pattern["params"]["operation_mode"], "remove")
if __name__ == "__main__": unittest.main()
+530 -13
View File
@@ -1,16 +1,26 @@
from __future__ import annotations
import json, tempfile, unittest
import json, math, tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.pipeline import convert_one
from cadfs_to_cdsl.lowering import _arc, _contours, _global, lower_model
from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_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, TRANSFORM_SOURCE
class LoweringTests(unittest.TestCase):
def test_strict_comparison_status_is_not_labeled_approximate(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True)
sample = Sample("strict-status", {"step": str(directory / "gold.step")}, {}, {}, [])
(directory / "rebuild.step").touch(); (directory / "gold.step").touch()
(directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id, "rebuild_status": "rebuilt"}), encoding="utf-8")
(directory / "comparison.json").write_text(json.dumps({"decision": "strict_pass", "strict": {"passed": True}, "rp": {"passed": True}}), encoding="utf-8")
status = compare_one(sample, output, compare_mode="strict")
self.assertEqual(status["status"], "rebuilt_strict")
def test_missing_dataset_fails_explicitly(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing")
@@ -23,8 +33,20 @@ class LoweringTests(unittest.TestCase):
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
def test_rectilinear_fillet_arc_recovers_its_exact_endpoint_radius(self):
arc = _arc([38.1, 63.5], [34.38, 72.48], [25.4, 76.2])
self.assertEqual(arc["center"], [25.4, 63.5])
self.assertAlmostEqual(arc["radius_mm"], 12.7)
self.assertFalse(arc["clockwise"])
def test_general_three_point_arc_remains_circumcircle_defined(self):
arc = _arc([0, 0], [2, 3], [5, 0])
self.assertAlmostEqual(arc["center"][0], 2.5)
self.assertAlmostEqual(arc["center"][1], 0.5)
self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5))
def test_unsupported_operation_is_audited_not_invented(self):
source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",')
source = SOURCE.replace('extrude(context, id + "F1",', 'draft(context, id + "F1",')
result = lower_model(parse_featurescript(source, "00000173"), {})
self.assertEqual(result.status, "deferred_no_executable_feature")
self.assertIsNone(result.cdsl)
@@ -86,21 +108,225 @@ class LoweringTests(unittest.TestCase):
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):
def test_implicit_extrude_after_a_body_uses_default_new_body_semantics(self):
source = SOURCE.replace(
'\n});\n',
'''
{ var Q0; Q0=qSketchRegion(id + "F0", true);
extrude(context, id + "F2", {"entities":qUnion([Q0]), "depth":20 * mm}); }
});
''',
)
result = lower_model(parse_featurescript(source, "implicit-add"), {})
first, second = result.cdsl["features"]
self.assertEqual(first["params"]["result_mode"], "new_body")
self.assertEqual(second["params"]["result_mode"], "new_body")
def test_up_to_body_extrude_captures_target_and_offset(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0069/00694309.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00694309"), {})
self.assertEqual(result.status, "converted_complete")
extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
end = extrude["params"]["end_condition"]
self.assertEqual(end["type"], "up_to_body")
self.assertEqual(end["offset_mm"], 3.0)
self.assertEqual(end["reference"]["kind"], "body")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
def test_up_to_surface_extrude_captures_the_cap_face(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
end = extrude["params"]["end_condition"]
self.assertEqual(end["type"], "up_to_surface")
self.assertEqual(end["reference"]["kind"], "face")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
self.assertEqual(end["reference"]["geometry"]["plane_offset_mm"], 0.0)
swept = next(item for item in result.cdsl["features"] if item["id"] == "f_F6")
self.assertEqual(swept["params"]["end_condition"]["reference"]["geometry"]["radius_mm"], 24.3)
mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18")
self.assertTrue(mirror["params"]["mirror_current_body"])
def test_mirrored_copy_cap_face_uses_the_mirrored_loft_frame(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"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F6")
plane = sketch["workplane"]
self.assertEqual(plane["origin_mm"], [0.0, 50.0, 0.0])
self.assertEqual(plane["normal"], [0.0, 1.0, 0.0])
self.assertEqual(plane["x_dir"], [-1.0, 0.0, 0.0])
spline = sketch["profile"]["contours"][0]["segments"][0]
first = spline["points"][0]
self.assertEqual([
plane["origin_mm"][index]
+ plane["x_dir"][index] * first[0]
+ (plane["normal"][(index + 1) % 3] * plane["x_dir"][(index + 2) % 3]
- plane["normal"][(index + 2) % 3] * plane["x_dir"][(index + 1) % 3]) * first[1]
for index in range(3)
], [-60.68, 50.0, 66.22])
def test_line_angle_opposite_direction_preserves_the_sketch_side(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F8")
plane = sketch["workplane"]
y_dir = [
plane["normal"][1] * plane["x_dir"][2] - plane["normal"][2] * plane["x_dir"][1],
plane["normal"][2] * plane["x_dir"][0] - plane["normal"][0] * plane["x_dir"][2],
plane["normal"][0] * plane["x_dir"][1] - plane["normal"][1] * plane["x_dir"][0],
]
center = sketch["profile"]["center"]
self.assertAlmostEqual(plane["origin_mm"][2] + y_dir[2] * center[1], 25.0)
def test_line_angle_cylinder_reference_uses_the_surface_generatrix(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5")
plane = sketch["workplane"]
self.assertAlmostEqual(plane["normal"][0], -0.5)
self.assertAlmostEqual(plane["normal"][1], -math.sqrt(3) / 2)
self.assertAlmostEqual(plane["origin_mm"][0], -12.15)
self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2)
def test_open_nonconstruction_geometry_is_preserved_as_reference(self):
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
result = lower_model(parse_featurescript(source, "open-profile"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred")
self.assertIn("open non-construction", result.diagnostics[0]["message"])
self.assertEqual(result.status, "converted_complete")
profile = result.cdsl["geometry"]["sketches"][0]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(profile["construction"][0]["type"], "line")
def test_surface_revolve_is_not_disguised_as_solid_revolve(self):
def test_open_imprint_profiles_and_mixed_sketch_context_lower_for_00789939(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F7__f_F8")
self.assertEqual(features["f_F10"]["sketch_id"], "sketch_F9__f_F10")
self.assertEqual(features["f_F12"]["sketch_id"], "sketch_F11")
self.assertFalse(sketches["sketch_F7__f_F8"]["profile"]["contours"][0]["closed"])
self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["origin_mm"], [45.7, 0.0, 0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["origin_mm"], [-44.3, -0.0, -0.0])
self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["normal"], [1.0, -0.0, -0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["normal"], [-1.0, -0.0, -0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["x_dir"], [-0.0, -1.0, -0.0])
def test_circular_pattern_replays_the_fused_swept_body_history(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F6"]["params"]["source_feature_ids"], ["f_F1", "f_F5"])
def test_shared_sketch_edge_produces_each_bounded_region(self):
segments = [
{"type": "line", "start": [0, 0], "end": [1, 0]},
{"type": "line", "start": [1, 0], "end": [2, 0]},
{"type": "line", "start": [2, 0], "end": [2, 1]},
{"type": "line", "start": [2, 1], "end": [1, 1]},
{"type": "line", "start": [1, 1], "end": [0, 1]},
{"type": "line", "start": [0, 1], "end": [0, 0]},
{"type": "line", "start": [1, 0], "end": [1, 1]},
]
contours, construction = _contours(segments)
self.assertEqual(len(contours), 2)
self.assertFalse(construction)
self.assertEqual(sorted(len(item["segments"]) for item in contours), [4, 4])
def test_vertex_on_long_line_is_split_before_region_extraction(self):
segments = [
{"type": "line", "start": [0, 0], "end": [2, 0]},
{"type": "line", "start": [2, 0], "end": [2, 2]},
{"type": "line", "start": [2, 2], "end": [0, 2]},
{"type": "line", "start": [0, 2], "end": [0, 0]},
{"type": "line", "start": [2, 0.5], "end": [3, 0.5]},
{"type": "line", "start": [3, 0.5], "end": [3, 1.5]},
{"type": "line", "start": [3, 1.5], "end": [2, 1.5]},
]
contours, construction = _contours(segments)
self.assertEqual(len(contours), 2)
self.assertFalse(construction)
def test_surface_operation_on_a_closed_region_lowers_to_a_solid_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0095/00957738.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {})
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2")
self.assertEqual(gap["capability"], "revolve_surface")
self.assertIsNone(result.cdsl)
self.assertEqual(result.status, "converted_complete")
feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertEqual(feature["atomic_id"], "revolve_add")
self.assertNotIn("result_mode", feature["params"])
def test_explicit_surface_body_lowers_to_the_surface_revolve_contract(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00784880.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00784880"), {})
self.assertEqual(result.status, "converted_complete")
feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(feature["atomic_id"], "revolve_surface")
self.assertNotIn("result_mode", feature["params"])
def test_mixed_extrude_lowers_selected_circular_surface_wires(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0071/00710855.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F1_surface"]["atomic_id"], "extrude_surface")
self.assertEqual(features["f_F1_surface"]["params"], {
"distance_mm": 63.5,
"reverse": False,
"reverse_distance_mm": 63.5,
})
self.assertEqual(features["f_F5_surface"]["atomic_id"], "extrude_surface")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
radii = [
contour["segments"][0]["radius_mm"]
for contour in sketches["sketch_F0__f_F1_surface"]["profile"]["contours"]
]
self.assertEqual(radii, [19.0, 16.0])
profile = sketches[features["f_F5"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(
[(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]],
[("outer", 25.5), ("inner", 19.0)],
)
def test_mixed_extrude_rebuilds_surface_limited_chamfer_history(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0071/00710855.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {})
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")
chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10")
self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]])
self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6)
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -128,6 +354,171 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(rebuilt["status"], "rebuilt")
self.assertGreater(rebuilt["result"]["volume_mm3"], 0)
def test_closed_fit_splines_lower_to_periodic_contours_and_join_split_imprints(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.assertEqual(result.status, "converted_complete")
self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
for sketch_id in ("sketch_F0", "sketch_F2", "sketch_F5", "sketch_F6"):
segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0]
self.assertTrue(segment["periodic"])
self.assertEqual(segment["parameterization"], "centripetal")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F6")
def test_open_fit_spline_reversal_preserves_parameter_and_tangent_semantics(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0028/00287955.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
spline = sketches["sketch_F13__f_F14"]["profile"]["contours"][0]["segments"][1]
source_points = [[33.77, 39.06], [38.2, 35.02], [36.58, 30.72], [33.77, 28.66]]
source_parameters = [0.0]
for start, end in zip(source_points, source_points[1:]):
source_parameters.append(source_parameters[-1] + math.sqrt(math.dist(start, end)))
source_parameters = [value / source_parameters[-1] for value in source_parameters]
self.assertEqual(spline["points"], list(reversed(source_points)))
self.assertEqual(spline["start_tangent"], [11.58, -1.2])
self.assertEqual(spline["end_tangent"], [-14.95, 1.46])
self.assertEqual(
spline["parameters"],
[source_parameters[-1] - value for value in reversed(source_parameters)],
)
def test_draft_and_boolean_bodies_preserve_explicit_source_body_selection(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F1"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True})
self.assertEqual(features["f_F3"]["sketch_id"], "sketch_F0__f_F3")
self.assertEqual(features["f_F3"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True})
self.assertEqual(features["f_F4"]["atomic_id"], "boolean_bodies")
self.assertEqual(features["f_F4"]["params"], {
"operation": "subtract",
"target_feature_ids": ["f_F3"],
"tool_feature_ids": ["f_F2"],
"keep_tools": False,
})
self.assertEqual(features["f_F8"]["atomic_id"], "revolve_add")
self.assertNotIn("result_mode", features["f_F8"]["params"])
def test_cap_face_profile_preserves_the_selected_circular_output_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
cap_profile = sketches[features["f_F5"]["sketch_id"]]["profile"]
self.assertEqual(features["f_F5"]["sketch_id"], "sketch_F3__f_F5")
self.assertEqual(cap_profile["type"], "analytic_contours")
self.assertEqual([
contour["segments"][0]["radius_mm"]
for contour in cap_profile["contours"]
], [34.0, 32.0])
def test_transformed_cap_face_uses_its_attachment_plane_origin(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
plane = sketches["sketch_F14"]["workplane"]
self.assertAlmostEqual(plane["origin_mm"][0], 0.0)
self.assertAlmostEqual(plane["origin_mm"][1], 0.0)
self.assertAlmostEqual(plane["origin_mm"][2], -24.08)
self.assertAlmostEqual(plane["normal"][0], 0.0)
self.assertAlmostEqual(plane["normal"][1], 0.0)
self.assertAlmostEqual(plane["normal"][2], 1.0)
def test_chamfer_tangent_propagation_lowers_to_engine_param(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {})
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertTrue(chamfer["params"]["tangent_propagation"])
def test_imprint_profile_query_selects_one_shared_edge_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00035682.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {})
revolve = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
self.assertEqual(revolve["sketch_id"], "sketch_F0__f_F1")
selected = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == revolve["sketch_id"])
self.assertEqual(len(selected["profile"]["contours"]), 1)
x_coordinates = {point[0] for segment in selected["profile"]["contours"][0]["segments"] for point in (segment["start"], segment["end"])}
self.assertEqual(x_coordinates, {11.68, 12.05})
def test_line_point_plane_uses_the_reference_line_as_its_normal(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00035682.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F5"]["atomic_id"], "reference_plane")
self.assertEqual(features["f_F8"]["atomic_id"], "reference_plane")
self.assertEqual(features["f_F7"]["atomic_id"], "extrude_cut_blind")
self.assertEqual(features["f_F10"]["atomic_id"], "extrude_cut_blind")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
length = math.hypot(10.44, 6.03)
for actual, expected in zip(sketches["sketch_F6"]["workplane"]["normal"], [-10.44 / length, 6.03 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F6"]["workplane"]["x_dir"], [-6.03 / length, -10.44 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F9"]["workplane"]["normal"], [10.44 / length, 6.03 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F9"]["workplane"]["x_dir"], [-6.03 / length, 10.44 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
def test_revolved_swept_face_lowers_to_an_annular_profile(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0005/00054089.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00054089"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F5"]["atomic_id"], "extrude_add_blind")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches["sketch_F4"]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
radii = {contour["role"]: contour["segments"][0]["radius_mm"] for contour in profile["contours"]}
self.assertEqual(radii, {"outer": 18.0, "inner": 14.0})
def test_intersect_partition_profile_lowers_to_its_bounded_circular_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
self.assertEqual(result.status, "converted_partial")
self.assertEqual({item["feature_id"] for item in result.diagnostics}, {"F7", "F12"})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
first = sketches[features["f_F15"]["sketch_id"]]["profile"]
second = sketches[features["f_F16"]["sketch_id"]]["profile"]
self.assertEqual(first["type"], "analytic_contours")
self.assertEqual(
{contour["role"]: contour["segments"][0]["radius_mm"] for contour in first["contours"]},
{"outer": 2.75, "inner": 2.25},
)
self.assertEqual(second, {"type": "circle", "center": [-50.8, 0.0], "radius_mm": 2.25})
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"
@@ -136,10 +527,63 @@ class LoweringTests(unittest.TestCase):
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(features["f_F9"]["params"]["result_mode"], "new_body")
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_cap_face_workplane_uses_the_physical_cap_normal(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"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(sketches["sketch_F2"]["workplane"]["origin_mm"], [0.0, 25.4, 0.0])
self.assertEqual(sketches["sketch_F2"]["workplane"]["normal"], [0.0, 1.0, 0.0])
self.assertEqual(sketches["sketch_F6"]["workplane"]["origin_mm"], [0.0, 0.0, 0.0])
self.assertEqual(sketches["sketch_F6"]["workplane"]["normal"], [0.0, -1.0, 0.0])
def test_nested_circle_imprint_selects_its_bounded_annulus(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"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches[features["f_F7"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(
[(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]],
[("outer", 4.48), ("inner", 2.36)],
)
def test_ellipse_profiles_unblock_the_00287955_feature_chain(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0028/00287955.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {})
features = {item["id"] for item in result.cdsl["features"]}
feature_by_id = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertTrue({"f_F3", "f_F5", "f_F6", "f_F11"}.issubset(features))
self.assertEqual(feature_by_id["f_F9"]["atomic_id"], "extrude_add_blind")
self.assertNotIn("selectors", feature_by_id["f_F9"])
mid_plane = feature_by_id["f_F12"]["params"]["plane"]
self.assertAlmostEqual(mid_plane["origin_mm"][0], -18.67, places=5)
self.assertAlmostEqual(mid_plane["normal"][0], 1.0, places=5)
self.assertAlmostEqual(mid_plane["normal"][1], 0.0, places=3)
axis = feature_by_id["f_F14"]["params"]["axis"]
self.assertAlmostEqual(axis["origin_mm"][0], -18.67, delta=0.01)
self.assertAlmostEqual(axis["origin_mm"][1], 33.77, delta=0.01)
for sketch_id in ("sketch_F2", "sketch_F4", "sketch_F7", "sketch_F8"):
segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0]
self.assertEqual(segment["type"], "ellipse")
self.assertNotIn("sketch_deferred", {item["code"] for item in result.diagnostics})
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"
@@ -155,9 +599,20 @@ class LoweringTests(unittest.TestCase):
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.assertNotEqual(sketches[features["f_F1"]["sketch_id"]]["workplane"]["normal"], [0.0, 0.0, 1.0])
self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics})
def test_opposed_circle_regions_lower_to_their_union_disk(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"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches[features["f_F6"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "circle")
self.assertEqual(profile["radius_mm"], 30.0)
def test_reference_plane_variants_lower_to_explicit_frames(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
@@ -179,6 +634,57 @@ class LoweringTests(unittest.TestCase):
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
self.assertFalse(diagnostics)
def test_shell_preserves_pattern_copy_cap_faces_and_offset_edge_fillet(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
shell = features["f_F7"]
fillet = features["f_F8"]
self.assertEqual(sketches["sketch_F4"]["workplane"]["origin_mm"], [0.0, 0.0, -50.0])
self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["points"][0], [-40.0, -50.0])
self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["start_tangent"], [27.94, 92.49])
self.assertEqual(shell["selectors"][1]["geometry"], {"normal": [0.0, 0.0, 1.0], "plane_offset_mm": -50.0})
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual(len(shell["selectors"]), 4)
self.assertEqual(
[selector["owner_feature_id"] for selector in shell["selectors"][:3]],
["f_F6.c1.f_F5", "f_F5", "f_F6.c2.f_F5"],
)
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["geometry"]["source_circle_radius_mm"], 17.5)
self.assertNotIn("F7", {item.get("feature_id") for item in result.diagnostics})
self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics})
def test_curve_point_plane_uses_its_projected_attachment_origin(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0019/00192744.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
plane = sketches["sketch_F4"]["workplane"]
self.assertEqual(plane["origin_mm"], [0.0, -2000.0, -4000.0])
self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[1], -10000.0, places=2)
self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[2], 0.0, places=2)
def test_delete_pattern_copies_lower_to_explicit_exclusions(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0019/00192744.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F6"]["params"]["excluded_instance_indices"], [2])
self.assertEqual(features["f_F11"]["params"]["excluded_instance_indices"], [2])
self.assertEqual(result.status, "converted_partial")
self.assertEqual(
[(item["feature_id"], item["capability"]) for item in result.diagnostics if item.get("code") == "unsupported_engine_capability"],
[("F15", "transform")],
)
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"), {})
@@ -197,5 +703,16 @@ class LoweringTests(unittest.TestCase):
self.assertTrue((directory / name).exists(), name)
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
def test_force_rebuild_refreshes_the_candidate_from_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"})
directory = root / "out/samples/00000173"; directory.mkdir(parents=True)
(directory / "candidate.cdsl.json").write_text(json.dumps({"part_id": "stale"}), encoding="utf-8")
(directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id}), encoding="utf-8")
status = rebuild_one(sample, root / "out", force=True)
self.assertEqual(status["rebuild_status"], "rebuilt")
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
if __name__ == "__main__": unittest.main()
+13
View File
@@ -4,6 +4,7 @@ import unittest
from cadfs_to_cdsl.featurescript_lexer import lex
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.ir import Call
from cadfs_to_cdsl.lowering import _queries, _source_refs
from cadfs_to_cdsl.query_parser import parse_query
from cadfs_to_cdsl.units import length_mm
@@ -52,6 +53,18 @@ class ParserTests(unittest.TestCase):
self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0"))
self.assertFalse(value.is_start)
def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self):
source = r'''
{ var Q0;
{ var subQ0=sQuery(id + "F2.wireOp", EDGE, "E2"); Q0=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); }
var Q1;
{ var subQ0=sQuery(id + "F2.wireOp", EDGE, "E4"); Q1=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); }
chamfer(context, id + "F5", {"entities":qUnion([Q0, Q1]), "width":5 * mm}); }
'''
model = parse_featurescript(source, "scoped-query")
queries = _queries(model.features[0].params["entities"])
self.assertEqual([_source_refs(query)[0] for query in queries], [("F2", "E2"), ("F2", "E4")])
def test_safe_units(self):
self.assertEqual(length_mm("2 * inch"), 50.8)
self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import json, tempfile, unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from cadfs_to_cdsl.reports import write_json
class ReportTests(unittest.TestCase):
def test_parallel_json_writes_use_unique_temporary_paths(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "summary.json"
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(lambda index: write_json(path, {"index": index}), range(64)))
payload = json.loads(path.read_text(encoding="utf-8"))
self.assertIn(payload["index"], range(64))
self.assertEqual(list(path.parent.glob(".summary.json.*.tmp")), [])
if __name__ == "__main__": unittest.main()
+55 -2
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
from pathlib import Path
import unittest
from cadfs_to_cdsl.selector_binding import _score
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors
class SelectorBindingTests(unittest.TestCase):
@@ -13,12 +16,62 @@ class SelectorBindingTests(unittest.TestCase):
)
self.assertEqual(score, 1.0)
def test_axis_direction_remains_orientation_sensitive(self) -> None:
def test_rotational_face_axis_direction_is_orientation_independent(self) -> None:
score = _score(
{"axis_direction": [0.0, 0.0, 1.0]},
{"axis_direction": [0.0, 0.0, -1.0]},
)
self.assertEqual(score, 1.0)
def test_axis_origin_distinguishes_parallel_cylinders(self) -> None:
score = _score(
{"axis_origin_mm": [0.0, 25.4, 33.37]},
{"axis_origin_mm": [33.38, 25.4, 0.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)
def test_swept_face_area_lower_bound_rejects_coplanar_fragment(self) -> None:
expected = {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "minimum_area_mm2": 285.0}
self.assertIsNone(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 0.64}))
self.assertEqual(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 463.7}), 1.0)
def test_reversed_cap_normal_reverses_its_plane_offset(self) -> None:
score = _score(
{"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 10.0},
{
"normal": [0.0, 0.0, 1.0],
"plane_normal": [0.0, 0.0, -1.0],
"plane_offset_mm": -10.0,
},
)
self.assertEqual(score, 1.0)
def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None:
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")
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
bound, evidence = bind_candidate_selectors(candidate.cdsl)
f7 = next(item for item in bound["features"] if item["id"] == "f_F7")
reference = f7["params"]["end_condition"]["reference"]
components = reference["intersection_of"]
self.assertEqual(len(components), 3)
self.assertEqual([item["binding_feature_id"] for item in components], ["f_F4", "f_F4", "f_F6"])
# 绑定发生在 F4 完整 pattern 前缀;owner 保留 instance 4 的语义来源,
# snapshot 则指向该前缀的 active Compound B-rep。
self.assertEqual([item["owner_feature_id"] for item in components[:2]], ["f_F4.c4.f_F1", "f_F4.c4.f_F1"])
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F4:") for item in components[:2]))
self.assertEqual(components[2]["match_mode"], "all")
# 同一圆柱面可能被前缀 boolean 切成不同数量的 B-rep face;关键是
# match_mode=all 保留每个可匹配片段,而不是把它收缩为任意一个面。
matched = components[2]["matched_selectors"]
self.assertTrue(matched)
self.assertTrue(all(item["owner_feature_id"] == "f_F6" for item in matched))
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F6:") for item in matched))
f7_evidence = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertEqual(len(f7_evidence["resolved"]), len(matched) + 2)