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:
@@ -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,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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Executable
+135
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user