diff --git a/.gitignore b/.gitignore index 07113cd..4481db0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ .vite/ .runtime/ +.cache/ +__pycache__/ *.log .env .env.* diff --git a/README.md b/README.md index 76c19e3..016fe51 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,6 @@ Build verification: ./npmw run verify ``` +The locked FreeCAD `1.1.1` source build and desktop-oracle replay workflow is documented in [docs/freecad-baseline-and-golden.zh-CN.md](docs/freecad-baseline-and-golden.zh-CN.md). All project Node commands in that workflow use `./npmw`. + The SQLite OPFS worker requires cross-origin isolation headers. Vite dev and preview apply `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`; deployments must preserve them. diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 815e4b3..fb70d9c 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -21,7 +21,8 @@ "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "undo-redo"] }, "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v6", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] }, "sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] }, - "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] } + "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }, + "verification": { "level": "experimental", "provider": "FreeCADCmd 1.1.1 native headless Part oracle", "operations": ["locked-source-commit", "reproducible-native-build", "declarative-golden-contract", "part-geometry-replay", "numeric-tolerance-comparison"] } }, "rules": [ "UI status does not imply geometry or file compatibility.", diff --git a/config/freecad-baseline.json b/config/freecad-baseline.json index 78598f9..d7e09f3 100644 --- a/config/freecad-baseline.json +++ b/config/freecad-baseline.json @@ -5,6 +5,6 @@ "sourceUrl": "https://github.com/FreeCAD/FreeCAD/releases/tag/1.1.1", "uiContract": "FreeCAD official desktop UI and Developers Handbook", "compatibilityLevels": ["exact", "compatible", "read-only", "proxy", "unsupported"], - "status": "release-tag-and-source-commit-verified", - "notes": "The official 1.1.1 release tag resolves to the recorded commit. Build flags, compiler image, and enabled workbenches remain P0-01 deliverables." + "status": "native-headless-part-oracle-verified", + "notes": "The official 1.1.1 tag and commit are locked. A reproducible local headless Part oracle and five geometry replay fixtures are verified; the digest-locked container, full workbench build, 100 golden files, and 50 error files remain P0-01/P0-03 deliverables." } diff --git a/config/freecad-native-bootstrap.cmake b/config/freecad-native-bootstrap.cmake new file mode 100644 index 0000000..87eba2a --- /dev/null +++ b/config/freecad-native-bootstrap.cmake @@ -0,0 +1,3 @@ +# FreeCAD 1.1.1 generates Base translations in headless builds but only loads +# LinguistTools from its GUI branch. Keep the pinned source tree unmodified. +find_package(Qt6 REQUIRED COMPONENTS LinguistTools) diff --git a/config/freecad-native-build.json b/config/freecad-native-build.json new file mode 100644 index 0000000..ce2ec80 --- /dev/null +++ b/config/freecad-native-build.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "baselineId": "freecad-1.1.1", + "source": { + "repository": "https://github.com/FreeCAD/FreeCAD.git", + "tag": "1.1.1", + "commit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" + }, + "host": { + "distribution": "Debian trixie", + "compiler": "GCC 14.2.0", + "cmake": "3.31.6", + "ninja": "1.12.1", + "python": "3.13.5", + "qt": "6.8.2", + "occt": "7.8.1", + "swig": "4.3.0" + }, + "configuration": { + "buildType": "Release", + "gui": false, + "enabledModules": ["Part", "Material", "Measure"], + "purpose": "FreeCADCmd geometry oracle; not a complete desktop distribution" + }, + "artifact": { + "path": ".cache/freecad/install-native/bin/FreeCADCmd", + "reportedVersion": "FreeCAD 1.1.1 Revision: 14556 (Git)", + "sizeBytes": 137688, + "sha256": "14b8e8c94472249c657426ee312f6f6d05b62e328dfe2794d20c81c95379bfbc" + }, + "verification": { + "goldenManifest": "fixtures/freecad-golden/manifest.json", + "scenarioCount": 5, + "result": "passed" + } +} diff --git a/config/freecad-toolchain.json b/config/freecad-toolchain.json new file mode 100644 index 0000000..832882a --- /dev/null +++ b/config/freecad-toolchain.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "baselineId": "freecad-1.1.1", + "source": { + "repository": "https://github.com/FreeCAD/FreeCAD.git", + "tag": "1.1.1", + "commit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d", + "verification": "git-ls-remote-tag" + }, + "desktopOracle": { + "commandEnvironmentVariable": "FREECAD_CMD", + "candidateCommands": [".cache/freecad/install-native/bin/FreeCADCmd", "FreeCADCmd", "freecadcmd"], + "requiredVersion": "1.1.1", + "localBuildManifest": "config/freecad-native-build.json" + }, + "wasmToolchain": { + "emscripten": "3.1.69", + "cmake": "3.31.6", + "ninja": "1.12.1", + "status": "host-tools-detected-container-lock-pending" + }, + "sourceCheckout": { + "defaultDirectory": ".cache/freecad/FreeCAD", + "mode": "detached-pinned-commit" + }, + "nativeBuild": { + "buildDirectory": ".cache/freecad/build-native", + "installDirectory": ".cache/freecad/install-native", + "configuration": "Release", + "status": "headless-part-oracle-verified" + }, + "goldenContract": { + "manifest": "fixtures/freecad-golden/manifest.json", + "driver": "scripts/freecad-golden-driver.py", + "resultPrefix": "FREECAD_GOLDEN_RESULT=", + "schemaVersion": 1 + } +} diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index bab12c3..2a3c23e 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -1,6 +1,6 @@ # Web FreeCAD 接续执行状态 -更新时间:2026-08-03(MultiTransform whole-shape 里程碑) +更新时间:2026-08-03(FreeCAD 1.1.1 原生黄金 oracle 里程碑) ## 1. 当前目标 @@ -10,9 +10,9 @@ - 分支:`main` - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` -- 最近已推送里程碑:`b09c5aa feat: add structured multi-transform` +- 最近已推送里程碑:`81fcc69 docs: record multi-transform milestone` - 最近已推送里程碑的完整门禁:`./npmw run verify` 通过 -- 当前 MultiTransform 里程碑验证:`./npmw run verify` 通过(Facade 67/67),diff/JSON 校验通过 +- 当前原生 oracle 里程碑验证:FreeCAD 1.1.1 源码构建成功,5/5 Part 黄金回放、`./npmw run verify`、diff/JSON 校验全部通过 - 生产构建:通过 - 本地开发地址:`http://127.0.0.1:5173/` - 兼容真值表:`config/compatibility-matrix.json` @@ -49,7 +49,7 @@ ## 4. 已确认限制 -- 仓库没有 FreeCAD C++ 源码、Emscripten 工程或 planegcs WASM 产物;`SK-03` 不能标记完成。现有 `typescript-basic` solver 仅用于协议与基础模型验证。 +- 锁定的 FreeCAD 1.1.1 C++ 源码可由脚本获取到忽略目录并完成无界面 Part 原生构建;尚无 FreeCAD/planegcs Emscripten 工程或 planegcs WASM 产物,`SK-03` 不能标记完成。现有 `typescript-basic` solver 仅用于协议与基础模型验证。 - Pattern 与 MultiTransform 当前复制整个 Shape,不是 FreeCAD 局部特征历史。 - Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。 - 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。 @@ -61,7 +61,7 @@ 1. 补齐 P2 浏览器实证门禁:真实 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件和崩溃注入;合同层与 fallback 测试已由 `6bfbdf5` 完成。 2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径;Node/fallback 合同已通过。 3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮,并增加真实 OCCT/SQLite 浏览器回放;候选替换 Facade 事务和属性/诊断 UI 已进入当前里程碑。 -4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC;此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 +4. 基于已锁定的 FreeCAD `1.1.1` 源码提取 planegcs 最小依赖图,完成 `SK-03` Emscripten/WASM POC;同时扩充回放夹具、约束分类和 Sketch 编辑事务。 5. 扩展 PartDesign:推进 Loft/Pipe/Groove、Thickness/Draft,以及 Mirrored/MultiTransform/Pattern/Hole 的局部特征历史与完整语义。 6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。 7. 按核心优先级完成 TechDraw、Spreadsheet、Draft、Assembly、BIM、Mesh、Surface、FEM、CAM 等工作台。 @@ -145,3 +145,12 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文 当前实现不是 FreeCAD Transform feature history 的完全等价实现:它组合并融合整个 Base Shape,未实现局部特征列表、Datum/TopoRef 轴面、单实体过滤或 OCCT 原生历史。下一执行点是 Additive/Subtractive Loft 的结构化截面合同与 whole-shape experimental 执行边界。 本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。 + +## 13. 2026-08-03 FreeCAD 原生黄金 oracle 快照 + +- 官方 `1.1.1` 标签已在线核验到提交 `0108fd4b4850cc46e625b60e53cea7a7bbe69f8d`;`fetch:freecad-source` 只接受该提交并使用 detached checkout。 +- `configure:freecad-native` 和 `build:freecad-native` 已在 Debian trixie 上完成 Release、无 GUI、Part/Material/Measure 最小构建;产物报告 `FreeCAD 1.1.1 Revision: 14556 (Git)`,哈希和工具版本记录在 `config/freecad-native-build.json`。 +- 新增声明式黄金契约、FreeCAD Python driver、CLI 回放器和比较器。Box、Cylinder、Sphere、Cone、穿孔 Cut 共 5/5 场景通过有效性、拓扑、体积/面积和包围盒对比。 +- 全部 Node/npm 操作均使用 `./npmw`;依赖、构建、回放和 CI 边界已写入 `docs/freecad-baseline-and-golden.zh-CN.md`。 + +本轮只完成 P0-01/P0-03 的首批可复现基线。依赖容器 digest、完整 FreeCAD 工作台构建、100 个黄金文件、50 个错误文件、GUI 行为和 Web OCCT 双端差分尚未完成,F0/G0 保持进行中。下一执行点是读取 FreeCAD/OCCT 原生 Generated/Modified/Deleted 历史接口并形成可供 Web `TopoRef` 消费的类型化历史合同。 diff --git a/docs/freecad-baseline-and-golden.zh-CN.md b/docs/freecad-baseline-and-golden.zh-CN.md new file mode 100644 index 0000000..b07db3c --- /dev/null +++ b/docs/freecad-baseline-and-golden.zh-CN.md @@ -0,0 +1,84 @@ +# FreeCAD 基线与黄金回放 + +## 1. 已锁定基线 + +- FreeCAD:`1.1.1` +- 官方标签提交:`0108fd4b4850cc46e625b60e53cea7a7bbe69f8d` +- 本地源码:`.cache/freecad/FreeCAD` +- 本地构建:`.cache/freecad/build-native` +- 本地安装:`.cache/freecad/install-native` +- 构建清单:`config/freecad-native-build.json` +- 黄金清单:`fixtures/freecad-golden/manifest.json` + +源码、构建和安装目录均在 `.cache/` 下,不进入 Git。仓库保存精确提交、构建参数、工具版本、产物哈希和回放契约;当前产物是用于几何对照的无界面 Part oracle,不是完整 FreeCAD 桌面发行版。 + +## 2. Debian trixie 依赖 + +本次实测使用 GCC `14.2.0`、CMake `3.31.6`、Ninja `1.12.1`、Python `3.13.5`、Qt `6.8.2`、OCCT `7.8.1` 和 SWIG `4.3.0`。安装命令: + +```bash +sudo apt-get update +sudo apt-get install -y \ + qt6-base-dev qt6-tools-dev qt6-svg-dev \ + libocct-foundation-dev libocct-modeling-data-dev \ + libocct-modeling-algorithms-dev libocct-visualization-dev \ + libocct-ocaf-dev libocct-data-exchange-dev \ + libxerces-c-dev libeigen3-dev libfmt-dev libyaml-cpp-dev \ + libicu-dev zlib1g-dev libtbb-dev swig +``` + +## 3. 可复现构建 + +所有项目脚本统一经 `./npmw` 使用仓库锁定的 Node/npm 运行时: + +```bash +./npmw run check:freecad-source +./npmw run fetch:freecad-source +./npmw run configure:freecad-native +FREECAD_BUILD_JOBS=4 ./npmw run build:freecad-native +``` + +`configure:freecad-native` 会拒绝非锁定提交,并配置 Release、`BUILD_GUI=OFF`、`BUILD_PART=ON`。Sketcher、PartDesign、TechDraw、Assembly、FEM、CAM 等非当前 oracle 所需模块均显式关闭。Qt6 翻译工具通过 `config/freecad-native-bootstrap.cmake` 在无界面配置中补充加载。 + +构建完成后检查版本: + +```bash +.cache/freecad/install-native/bin/FreeCADCmd --version +``` + +预期为 `FreeCAD 1.1.1 Revision: 14556 (Git)`。当前已验证二进制 SHA-256 为 `14b8e8c94472249c657426ee312f6f6d05b62e328dfe2794d20c81c95379bfbc`;不同主机工具链生成的产物哈希变化必须先更新构建清单并重新执行全部黄金回放,不能静默接受。 + +## 4. 黄金回放 + +执行契约单元测试和真实 FreeCAD 对照: + +```bash +./npmw run test:golden +./npmw run test:golden:freecad +``` + +回放器按以下顺序定位 oracle:`FREECAD_CMD`、仓库本地构建、系统 `FreeCADCmd`、系统 `freecadcmd`。也可显式指定: + +```bash +FREECAD_CMD=/absolute/path/to/FreeCADCmd ./npmw run test:golden:freecad +``` + +当前五个场景覆盖 Box、Cylinder、Sphere、Cone 和穿孔 Cut。每个场景比较 FreeCAD 版本、空/有效状态、顶层 Shape 类型、Solid 数、面/边/点数(声明时)、体积、面积(声明时)及包围盒,并分别应用标量与线性容差。穿孔 Cut 的 FreeCAD 1.1.1 实测顶层是含一个 Solid 的 `Compound`,契约保留这一原生行为。 + +输出完整机器可读报告或只执行单个场景: + +```bash +./npmw run test:golden:freecad -- --json +./npmw run test:golden:freecad -- --scenario=part-cut-through-hole +``` + +## 5. 常规门禁与边界 + +```bash +./npmw run check:baseline +./npmw run verify +``` + +`check:baseline` 在本地 oracle 存在时校验其 SHA-256;干净检出没有 `.cache/` 时仍校验所有锁文件、黄金契约和 Web OCCT WASM 哈希。`verify` 不强制编译 FreeCAD,也不把缺失桌面 oracle 误报为回放通过;CI 若承担 FreeCAD 对照职责,必须单独执行 `test:golden:freecad`。 + +当前只完成 P0-01/P0-03 的第一批可复现基础设施和 5 个成功几何场景。依赖镜像摘要、完整工作台构建、100 个黄金文件、50 个错误文件、GUI 行为回放和浏览器 OCCT 差分仍未完成,因此不得将 F0 或完整 FreeCAD 兼容标记为通过。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 8d8bbc4..5242b6f 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -247,9 +247,9 @@ | ID | 任务 | 交付物 | 验收标准 | |---|---|---|---| -| P0-01 | 固定 FreeCAD 源码、编译器和依赖镜像 | lock、容器、构建日志 | 同一输入产生相同版本信息 | +| P0-01 | `IN PROGRESS`:已固定 FreeCAD 源码提交、原生编译器/依赖版本、无界面 Part 构建脚本与产物哈希;依赖镜像 digest 尚未完成 | lock、容器、构建日志 | 同一输入产生相同版本信息 | | P0-02 | 生成 UI/命令/对象/Property/工作台 manifest | JSON manifest | 每条能力有来源和等级 | -| P0-03 | 建立 100 个黄金文件、50 个错误文件、回放脚本 | fixtures、expected | 桌面结果可复现 | +| P0-03 | `IN PROGRESS`:声明式契约、FreeCADCmd driver/比较器和首批 5 个 Part 成功样例已通过;100 个黄金文件与 50 个错误文件尚未达到 | fixtures、expected | 桌面结果可复现 | | P0-04 | 固定浏览器能力矩阵和 COOP/COEP | browser matrix | Chrome/Firefox/Safari 最新两个版本有结果 | | P0-05 | 许可证、供应链、WASM 安全评审 | SBOM、许可证清单 | 无未知二进制和高风险漏洞 | | API-01 | Facade API 版本化和 JSON Schema | `src/facade` 合同 | 破坏性变更有迁移和弃用期 | @@ -293,7 +293,7 @@ ## 14. 下一轮实际执行顺序 1. 使用项目本地 Node 运行时完成 P0-RT、依赖安装和验证,清除引擎警告。 -2. 完成 P0-01/P0-03:FreeCAD 提交、构建容器、manifest 和黄金模型目录。 +2. 继续完成 P0-01/P0-03:在已验证的提交、原生构建和 5 个黄金样例上补齐 digest 锁定构建容器、100 个黄金文件、50 个错误文件和 GUI 回放。 3. 实现 `DAG-01..DAG-06` 与 `EXU-01..EXU-06`,让属性修改从“Touched 标记”升级为可解释的依赖重算。 4. 实现 `TSN-02..TSN-07`,再把附着、Pocket up-to-face、测量和 TechDraw 选择切换到 `TopoRef`。 5. 编译/验证 `SK-03`,然后按基础约束→高级约束→编辑器→PartDesign 轮廓的顺序推进。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 89f161b..0ae0585 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -985,6 +985,8 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 阶段时长是日历周估算,不是简单相加的人周。UI-0 至 UI-3 必须先于真实功能接入;阶段 2、5、6 可并行,阶段 4 和 7 的深度受 POC 结论影响;阶段 9-11 是实现“完整 FreeCAD 官方核心覆盖”不可省略的后续计划。以需求、交互和技术决策已能及时确认作为前提,建议采用以下排期口径: +当前 P0 实证:FreeCAD `1.1.1` 精确提交已由脚本获取并完成无界面 Part 原生构建,Box/Cylinder/Sphere/Cone/Cut 五个声明式黄金场景通过 `FreeCADCmd` 回放。完整命令见 `docs/freecad-baseline-and-golden.zh-CN.md`,Node/npm 操作统一使用 `./npmw`。该证据尚未覆盖 digest 锁定容器、GUI、完整工作台、100 个成功样例和 50 个错误样例,因此只计为 P0-01/P0-03 进行中。 + | 交付层级 | 建议团队 | 预计周期 | 范围 | |---|---:|---:|---| | 全量前端页面设计 | 4-6 人 | 13-22 个日历周 | FreeCAD 页面盘点、设计系统、全部工作台静态页面、状态、原型和 Facade 映射 | diff --git a/fixtures/freecad-golden/manifest.json b/fixtures/freecad-golden/manifest.json new file mode 100644 index 0000000..e681dfb --- /dev/null +++ b/fixtures/freecad-golden/manifest.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "baselineId": "freecad-1.1.1", + "driver": "scripts/freecad-golden-driver.py", + "scenarios": [ + { "id": "part-box", "file": "scenarios/part-box.json" }, + { "id": "part-cylinder", "file": "scenarios/part-cylinder.json" }, + { "id": "part-sphere", "file": "scenarios/part-sphere.json" }, + { "id": "part-cone", "file": "scenarios/part-cone.json" }, + { "id": "part-cut-through-hole", "file": "scenarios/part-cut-through-hole.json" } + ] +} diff --git a/fixtures/freecad-golden/scenarios/part-box.json b/fixtures/freecad-golden/scenarios/part-box.json new file mode 100644 index 0000000..166357c --- /dev/null +++ b/fixtures/freecad-golden/scenarios/part-box.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "part-box", + "category": "Part", + "description": "Axis-aligned 10 x 20 x 30 mm box", + "operation": { "type": "box", "length": 10, "width": 20, "height": 30 }, + "tolerance": { "linear": 1e-7, "scalar": 1e-7 }, + "expected": { + "shapeType": "Solid", + "isNull": false, + "isValid": true, + "solids": 1, + "faces": 6, + "edges": 12, + "vertices": 8, + "volume": 6000, + "area": 2200, + "boundingBox": { "min": [0, 0, 0], "max": [10, 20, 30] } + } +} diff --git a/fixtures/freecad-golden/scenarios/part-cone.json b/fixtures/freecad-golden/scenarios/part-cone.json new file mode 100644 index 0000000..4f549e0 --- /dev/null +++ b/fixtures/freecad-golden/scenarios/part-cone.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "part-cone", + "category": "Part", + "description": "Truncated cone with radii 5 and 2 mm and height 10 mm", + "operation": { "type": "cone", "radius1": 5, "radius2": 2, "height": 10, "angle": 360 }, + "tolerance": { "linear": 1e-7, "scalar": 1e-7 }, + "expected": { + "shapeType": "Solid", + "isNull": false, + "isValid": true, + "solids": 1, + "volume": 408.4070449666731, + "boundingBox": { "min": [-5, -5, 0], "max": [5, 5, 10] } + } +} diff --git a/fixtures/freecad-golden/scenarios/part-cut-through-hole.json b/fixtures/freecad-golden/scenarios/part-cut-through-hole.json new file mode 100644 index 0000000..b43cf95 --- /dev/null +++ b/fixtures/freecad-golden/scenarios/part-cut-through-hole.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "part-cut-through-hole", + "category": "Part", + "description": "20 mm cube cut by a centered radius 5 mm through cylinder", + "operation": { + "type": "cut", + "base": { "type": "box", "length": 20, "width": 20, "height": 20 }, + "tool": { "type": "cylinder", "radius": 5, "height": 20, "angle": 360, "placement": { "translation": [10, 10, 0] } } + }, + "tolerance": { "linear": 1e-7, "scalar": 1e-7 }, + "expected": { + "shapeType": "Compound", + "isNull": false, + "isValid": true, + "solids": 1, + "volume": 6429.203673205104, + "boundingBox": { "min": [0, 0, 0], "max": [20, 20, 20] } + } +} diff --git a/fixtures/freecad-golden/scenarios/part-cylinder.json b/fixtures/freecad-golden/scenarios/part-cylinder.json new file mode 100644 index 0000000..599658e --- /dev/null +++ b/fixtures/freecad-golden/scenarios/part-cylinder.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "part-cylinder", + "category": "Part", + "description": "Radius 5 mm, height 20 mm cylinder on the positive Z axis", + "operation": { "type": "cylinder", "radius": 5, "height": 20, "angle": 360 }, + "tolerance": { "linear": 1e-7, "scalar": 1e-7 }, + "expected": { + "shapeType": "Solid", + "isNull": false, + "isValid": true, + "solids": 1, + "volume": 1570.7963267948965, + "area": 785.3981633974482, + "boundingBox": { "min": [-5, -5, 0], "max": [5, 5, 20] } + } +} diff --git a/fixtures/freecad-golden/scenarios/part-sphere.json b/fixtures/freecad-golden/scenarios/part-sphere.json new file mode 100644 index 0000000..76e2fb9 --- /dev/null +++ b/fixtures/freecad-golden/scenarios/part-sphere.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "part-sphere", + "category": "Part", + "description": "Radius 5 mm sphere centered at the origin", + "operation": { "type": "sphere", "radius": 5 }, + "tolerance": { "linear": 1e-7, "scalar": 1e-7 }, + "expected": { + "shapeType": "Solid", + "isNull": false, + "isValid": true, + "solids": 1, + "volume": 523.5987755982989, + "area": 314.1592653589793, + "boundingBox": { "min": [-5, -5, -5], "max": [5, 5, 5] } + } +} diff --git a/package.json b/package.json index 777e2df..ca17596 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,17 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "check:baseline": "node scripts/check-baseline.mjs", + "check:freecad-source": "node scripts/verify-freecad-source.mjs", "check:facade-boundary": "node scripts/check-facade-boundary.mjs", + "fetch:freecad-source": "node scripts/fetch-freecad-source.mjs", + "configure:freecad-native": "node scripts/configure-freecad-native.mjs", + "build:freecad-native": "node scripts/build-freecad-native.mjs", "test:facade": "tsx --test tests/facade.test.ts", + "test:golden": "node --test tests/goldenReplay.test.mjs", + "test:golden:freecad": "node scripts/run-freecad-golden.mjs", "check:runtime": "node scripts/check-runtime.mjs", - "verify": "npm run check:runtime && npm run check:facade-boundary && npm run test:facade && npm run build" + "verify": "npm run check:runtime && npm run check:baseline && npm run check:facade-boundary && npm run test:golden && npm run test:facade && npm run build" }, "dependencies": { "@bitbybit-dev/occt": "1.1.1", diff --git a/scripts/build-freecad-native.mjs b/scripts/build-freecad-native.mjs new file mode 100644 index 0000000..dc24c5e --- /dev/null +++ b/scripts/build-freecad-native.mjs @@ -0,0 +1,10 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const root = resolve(new URL('..', import.meta.url).pathname) +const build = resolve(root, '.cache/freecad/build-native') +const install = resolve(root, '.cache/freecad/install-native') +const jobs = process.env.FREECAD_BUILD_JOBS || '4' +execFileSync('cmake', ['--build', build, '--parallel', jobs], { cwd: root, stdio: 'inherit' }) +execFileSync('cmake', ['--install', build], { cwd: root, stdio: 'inherit' }) +console.log(`FreeCAD native oracle installed: ${install}`) diff --git a/scripts/check-baseline.mjs b/scripts/check-baseline.mjs new file mode 100644 index 0000000..4cccb37 --- /dev/null +++ b/scripts/check-baseline.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto' +import { access, readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadGoldenManifest } from './freecad-golden-contract.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const readJson = async (relative) => JSON.parse(await readFile(resolve(root, relative), 'utf8')) +const fail = (message) => { throw new Error(message) } + +const baseline = await readJson('config/freecad-baseline.json') +const toolchain = await readJson('config/freecad-toolchain.json') +const nativeBuild = await readJson('config/freecad-native-build.json') +const runtime = await readJson('config/runtime-baseline.json') +const packageJson = await readJson('package.json') +const nodeRuntime = await readFile(resolve(root, 'config/node-runtime.env'), 'utf8') + +if (baseline.baselineId !== toolchain.baselineId || baseline.freecadVersion !== toolchain.source.tag) fail('FreeCAD baseline and toolchain tag disagree.') +if (!baseline.sourceRef.endsWith(`@${toolchain.source.commit}`) || !/^[0-9a-f]{40}$/.test(toolchain.source.commit)) fail('FreeCAD source commit is not an exact 40-character revision.') +if (nativeBuild.baselineId !== baseline.baselineId || nativeBuild.source.commit !== toolchain.source.commit) fail('Native oracle build manifest does not match the locked FreeCAD source.') +if (!nodeRuntime.includes(`NODE_VERSION=${packageJson.engines.node}`) || runtime.projectRuntime.node !== packageJson.engines.node) fail('Project Node runtime declarations disagree.') + +for (const [packageName, expectedVersion] of [['@bitbybit-dev/occt', runtime.geometry.version], ['@bitbybit-dev/occt-worker', runtime.geometry.version], ['three', runtime.viewport.version], ['@sqlite.org/sqlite-wasm', '3.53.0-build1']]) { + const declared = packageJson.dependencies[packageName] + const installed = await readJson(`node_modules/${packageName}/package.json`) + if (declared !== expectedVersion || installed.version !== expectedVersion) fail(`${packageName} must be declared and installed at ${expectedVersion}.`) +} + +const wasmFile = resolve(root, 'node_modules/@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt.a4a6ec2a.wasm') +const wasmHash = createHash('sha256').update(await readFile(wasmFile)).digest('hex') +if (wasmHash !== runtime.geometry.wasmSha256) fail(`OCCT WASM checksum mismatch: ${wasmHash}.`) + +const manifest = await loadGoldenManifest(resolve(root, toolchain.goldenContract.manifest)) +if (manifest.baselineId !== baseline.baselineId || manifest.driver !== toolchain.goldenContract.driver) fail('Golden manifest does not match the locked baseline contract.') +if (nativeBuild.verification.goldenManifest !== toolchain.goldenContract.manifest || nativeBuild.verification.scenarioCount !== manifest.scenarios.length) fail('Native oracle verification does not match the golden manifest.') +const nativeOracle = resolve(root, nativeBuild.artifact.path) +try { + await access(nativeOracle) + const nativeHash = createHash('sha256').update(await readFile(nativeOracle)).digest('hex') + if (nativeHash !== nativeBuild.artifact.sha256) fail(`Native FreeCADCmd checksum mismatch: ${nativeHash}.`) +} catch (error) { + if (error?.code !== 'ENOENT') throw error +} +console.log(`Baseline contract OK: FreeCAD ${baseline.freecadVersion} @ ${toolchain.source.commit.slice(0, 12)}, ${manifest.scenarios.length} golden scenarios, OCCT WASM ${wasmHash.slice(0, 12)}.`) diff --git a/scripts/configure-freecad-native.mjs b/scripts/configure-freecad-native.mjs new file mode 100644 index 0000000..ec8987d --- /dev/null +++ b/scripts/configure-freecad-native.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const root = resolve(new URL('..', import.meta.url).pathname) +const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8')) +const source = resolve(root, toolchain.sourceCheckout.defaultDirectory) +const build = resolve(root, '.cache/freecad/build-native') +const install = resolve(root, '.cache/freecad/install-native') +const bootstrap = resolve(root, 'config/freecad-native-bootstrap.cmake') +const revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() +if (revision !== toolchain.source.commit) throw new Error(`FreeCAD source must be checked out at ${toolchain.source.commit}; received ${revision}.`) + +const disabledModules = ['ADDONMGR', 'ASSEMBLY', 'BIM', 'CAM', 'DRAFT', 'FEM', 'FLAT_MESH', 'HELP', 'IDF', 'IMPORT', 'INSPECTION', 'MATERIAL_EXTERNAL', 'MESH', 'MESH_PART', 'OPENSCAD', 'PART_DESIGN', 'PLOT', 'POINTS', 'REVERSEENGINEERING', 'ROBOT', 'SHOW', 'SKETCHER', 'SPREADSHEET', 'START', 'SURFACE', 'TECHDRAW', 'TEST', 'TUX', 'WEB'] +const definitions = [ + `-DCMAKE_BUILD_TYPE=Release`, + `-DCMAKE_INSTALL_PREFIX=${install}`, + `-DCMAKE_PROJECT_INCLUDE=${bootstrap}`, + '-DBUILD_GUI=OFF', + '-DBUILD_PART=ON', + '-DENABLE_DEVELOPER_TESTS=OFF', + '-DFREECAD_USE_FREETYPE=OFF', + '-DFREECAD_USE_EXTERNAL_FMT=ON', + '-DFREECAD_USE_PCH=OFF', + '-DINSTALL_TO_SITEPACKAGES=OFF', + ...disabledModules.map((module) => `-DBUILD_${module}=OFF`), +] +execFileSync('cmake', ['-S', source, '-B', build, '-G', 'Ninja', '-U', 'CMAKE_PROJECT_TOP_LEVEL_INCLUDES', ...definitions], { cwd: root, stdio: 'inherit' }) +console.log(`FreeCAD native build configured: ${build}`) diff --git a/scripts/fetch-freecad-source.mjs b/scripts/fetch-freecad-source.mjs new file mode 100644 index 0000000..aaa6111 --- /dev/null +++ b/scripts/fetch-freecad-source.mjs @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, stat } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' + +const root = resolve(new URL('..', import.meta.url).pathname) +const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8')) +const directoryArgument = process.argv.slice(2).find((argument) => argument.startsWith('--directory=')) +const target = resolve(root, directoryArgument?.slice('--directory='.length) || toolchain.sourceCheckout.defaultDirectory) +const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 300000 }).trim() +const exists = await stat(target).then(() => true, () => false) + +if (!exists) { + await mkdir(dirname(target), { recursive: true }) + git('clone', '--filter=blob:none', '--no-checkout', toolchain.source.repository, target) +} else { + const remote = git('-C', target, 'remote', 'get-url', 'origin') + if (remote !== toolchain.source.repository) throw new Error(`Existing checkout has unexpected origin: ${remote}`) + const dirty = git('-C', target, 'status', '--porcelain') + if (dirty) throw new Error(`Existing FreeCAD checkout is dirty: ${target}`) +} + +git('-C', target, 'fetch', '--depth=1', 'origin', `refs/tags/${toolchain.source.tag}`) +const fetched = git('-C', target, 'rev-parse', 'FETCH_HEAD') +if (fetched !== toolchain.source.commit) throw new Error(`Fetched FreeCAD tag resolved to ${fetched}, expected ${toolchain.source.commit}.`) +git('-C', target, 'checkout', '--detach', toolchain.source.commit) +console.log(`FreeCAD source ready: ${target} @ ${toolchain.source.commit}`) diff --git a/scripts/freecad-golden-contract.mjs b/scripts/freecad-golden-contract.mjs new file mode 100644 index 0000000..d685c3b --- /dev/null +++ b/scripts/freecad-golden-contract.mjs @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +const readJson = async (file) => JSON.parse(await readFile(file, 'utf8')) +const finite = (value) => typeof value === 'number' && Number.isFinite(value) +const positive = (value) => finite(value) && value > 0 + +function validatePlacement(placement, label) { + if (placement === undefined) return + if (!placement || typeof placement !== 'object' || Array.isArray(placement)) throw new TypeError(`${label}.placement must be an object.`) + if (placement.translation !== undefined && (!Array.isArray(placement.translation) || placement.translation.length !== 3 || !placement.translation.every(finite))) throw new TypeError(`${label}.placement.translation must contain three finite numbers.`) + if (placement.rotation !== undefined) { + if (!placement.rotation || typeof placement.rotation !== 'object' || Array.isArray(placement.rotation)) throw new TypeError(`${label}.placement.rotation must be an object.`) + if (!Array.isArray(placement.rotation.axis) || placement.rotation.axis.length !== 3 || !placement.rotation.axis.every(finite) || Math.hypot(...placement.rotation.axis) === 0) throw new TypeError(`${label}.placement.rotation.axis must be a non-zero vector.`) + if (!finite(placement.rotation.angle)) throw new TypeError(`${label}.placement.rotation.angle must be finite.`) + } +} + +export function validateGoldenOperation(operation, label = 'operation') { + if (!operation || typeof operation !== 'object' || Array.isArray(operation) || typeof operation.type !== 'string') throw new TypeError(`${label} must have a type.`) + validatePlacement(operation.placement, label) + if (operation.type === 'box') { + if (![operation.length, operation.width, operation.height].every(positive)) throw new RangeError(`${label} box dimensions must be positive.`) + } else if (operation.type === 'cylinder') { + if (!positive(operation.radius) || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cylinder parameters are invalid.`) + } else if (operation.type === 'sphere') { + if (!positive(operation.radius)) throw new RangeError(`${label} sphere radius must be positive.`) + } else if (operation.type === 'cone') { + if (!finite(operation.radius1) || operation.radius1 < 0 || !finite(operation.radius2) || operation.radius2 < 0 || operation.radius1 + operation.radius2 <= 0 || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cone parameters are invalid.`) + } else if (operation.type === 'cut' || operation.type === 'fuse' || operation.type === 'common') { + validateGoldenOperation(operation.base, `${label}.base`) + validateGoldenOperation(operation.tool, `${label}.tool`) + } else throw new RangeError(`${label} uses unsupported operation type ${operation.type}.`) +} + +export function validateGoldenScenario(scenario, expectedId) { + if (!scenario || scenario.schemaVersion !== 1) throw new TypeError(`${expectedId || 'Scenario'} must use schemaVersion 1.`) + if (typeof scenario.id !== 'string' || !scenario.id || (expectedId && scenario.id !== expectedId)) throw new RangeError(`Scenario ID does not match ${expectedId}.`) + if (typeof scenario.category !== 'string' || !scenario.category || typeof scenario.description !== 'string' || !scenario.description) throw new TypeError(`${scenario.id} requires category and description.`) + validateGoldenOperation(scenario.operation, scenario.id) + if (!scenario.tolerance || !positive(scenario.tolerance.linear) || !positive(scenario.tolerance.scalar)) throw new RangeError(`${scenario.id} requires positive linear and scalar tolerances.`) + if (!scenario.expected || typeof scenario.expected !== 'object' || Array.isArray(scenario.expected)) throw new TypeError(`${scenario.id} requires expected output.`) + const allowedShapeTypes = new Set(['Solid', 'Compound']) + if (scenario.expected.isNull !== false || scenario.expected.isValid !== true || !allowedShapeTypes.has(scenario.expected.shapeType) || !Number.isInteger(scenario.expected.solids) || scenario.expected.solids < 1) { + throw new RangeError(`${scenario.id} must declare a valid non-null oracle containing at least one Solid.`) + } +} + +export async function loadGoldenManifest(manifestFile) { + const absoluteManifest = resolve(manifestFile) + const manifest = await readJson(absoluteManifest) + if (manifest.schemaVersion !== 1 || typeof manifest.baselineId !== 'string' || !manifest.baselineId) throw new TypeError('Golden manifest must use schemaVersion 1 and declare baselineId.') + if (!Array.isArray(manifest.scenarios) || manifest.scenarios.length === 0) throw new RangeError('Golden manifest requires at least one scenario.') + const ids = new Set() + const scenarios = [] + for (const entry of manifest.scenarios) { + if (!entry || typeof entry.id !== 'string' || !entry.id || ids.has(entry.id) || typeof entry.file !== 'string' || !entry.file) throw new RangeError('Golden manifest scenario IDs must be non-empty and unique.') + ids.add(entry.id) + const file = resolve(dirname(absoluteManifest), entry.file) + const scenario = await readJson(file) + validateGoldenScenario(scenario, entry.id) + scenarios.push({ ...entry, file, scenario }) + } + return { ...manifest, file: absoluteManifest, scenarios } +} + +const toleranceForPath = (scenario, path) => path.includes('boundingBox') ? scenario.tolerance.linear : scenario.tolerance.scalar + +export function compareGoldenResult(scenario, actual) { + const differences = [] + const compare = (expected, received, path) => { + if (typeof expected === 'number') { + if (!finite(received)) differences.push(`${path}: expected finite ${expected}, received ${String(received)}`) + else { + const tolerance = toleranceForPath(scenario, path) + if (Math.abs(expected - received) > tolerance) differences.push(`${path}: expected ${expected} +/- ${tolerance}, received ${received}`) + } + return + } + if (Array.isArray(expected)) { + if (!Array.isArray(received) || received.length !== expected.length) differences.push(`${path}: expected an array of length ${expected.length}`) + else expected.forEach((value, index) => compare(value, received[index], `${path}[${index}]`)) + return + } + if (expected && typeof expected === 'object') { + if (!received || typeof received !== 'object' || Array.isArray(received)) differences.push(`${path}: expected an object`) + else for (const [key, value] of Object.entries(expected)) compare(value, received[key], `${path}.${key}`) + return + } + if (received !== expected) differences.push(`${path}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`) + } + compare(scenario.expected, actual, scenario.id) + return differences +} diff --git a/scripts/freecad-golden-driver.py b/scripts/freecad-golden-driver.py new file mode 100644 index 0000000..3ce2b4b --- /dev/null +++ b/scripts/freecad-golden-driver.py @@ -0,0 +1,69 @@ +import json +import os + +import FreeCAD as App +import Part + + +def make_shape(operation): + kind = operation["type"] + if kind == "box": + shape = Part.makeBox(operation["length"], operation["width"], operation["height"]) + elif kind == "cylinder": + shape = Part.makeCylinder(operation["radius"], operation["height"], App.Vector(0, 0, 0), App.Vector(0, 0, 1), operation["angle"]) + elif kind == "sphere": + shape = Part.makeSphere(operation["radius"]) + elif kind == "cone": + shape = Part.makeCone(operation["radius1"], operation["radius2"], operation["height"], App.Vector(0, 0, 0), App.Vector(0, 0, 1), operation["angle"]) + elif kind in ("cut", "fuse", "common"): + base = make_shape(operation["base"]) + tool = make_shape(operation["tool"]) + shape = base.cut(tool) if kind == "cut" else base.fuse(tool) if kind == "fuse" else base.common(tool) + else: + raise ValueError("Unsupported golden operation: " + kind) + + placement_value = operation.get("placement") + if placement_value: + placement = App.Placement() + placement.Base = App.Vector(*(placement_value.get("translation") or [0, 0, 0])) + rotation = placement_value.get("rotation") + if rotation: + placement.Rotation = App.Rotation(App.Vector(*rotation["axis"]), rotation["angle"]) + shape = shape.copy() + shape.Placement = placement + return shape + + +def vector(x, y, z): + return [float(x), float(y), float(z)] + + +scenario_path = os.environ.get("FREECAD_GOLDEN_SCENARIO") +if not scenario_path: + raise RuntimeError("FREECAD_GOLDEN_SCENARIO is required") + +with open(scenario_path, "r", encoding="utf-8") as scenario_file: + scenario = json.load(scenario_file) + +shape = make_shape(scenario["operation"]) +box = shape.BoundBox +version = App.Version() +result = { + "schemaVersion": 1, + "fixtureId": scenario["id"], + "freecadVersion": ".".join(str(value) for value in version[:3]), + "shapeType": shape.ShapeType, + "isNull": shape.isNull(), + "isValid": shape.isValid(), + "solids": len(shape.Solids), + "faces": len(shape.Faces), + "edges": len(shape.Edges), + "vertices": len(shape.Vertexes), + "volume": float(shape.Volume), + "area": float(shape.Area), + "boundingBox": { + "min": vector(box.XMin, box.YMin, box.ZMin), + "max": vector(box.XMax, box.YMax, box.ZMax), + }, +} +print("FREECAD_GOLDEN_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":"))) diff --git a/scripts/run-freecad-golden.mjs b/scripts/run-freecad-golden.mjs new file mode 100644 index 0000000..8781b50 --- /dev/null +++ b/scripts/run-freecad-golden.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadGoldenManifest, compareGoldenResult } from './freecad-golden-contract.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const args = new Set(process.argv.slice(2)) +const scenarioArgument = process.argv.slice(2).find((argument) => argument.startsWith('--scenario=')) +const requestedScenario = scenarioArgument?.slice('--scenario='.length) +const jsonOutput = args.has('--json') +const allowMissing = args.has('--allow-missing') +const manifest = await loadGoldenManifest(resolve(root, 'fixtures/freecad-golden/manifest.json')) +const driver = resolve(root, manifest.driver) + +const localOracle = resolve(root, '.cache/freecad/install-native/bin/FreeCADCmd') +const candidates = process.env.FREECAD_CMD + ? [process.env.FREECAD_CMD] + : [localOracle, 'FreeCADCmd', 'freecadcmd'] +let command = null +for (const candidate of candidates) { + const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', timeout: 15000 }) + if (!probe.error || probe.error.code !== 'ENOENT') { command = candidate; break } +} +if (!command) { + const message = `FreeCADCmd 1.1.1 is unavailable. Set FREECAD_CMD to the locked desktop oracle executable.` + if (allowMissing) { console.log(message); process.exit(0) } + console.error(message) + process.exit(2) +} + +const scenarios = requestedScenario ? manifest.scenarios.filter((entry) => entry.id === requestedScenario) : manifest.scenarios +if (scenarios.length === 0) { console.error(`Unknown golden scenario: ${requestedScenario}`); process.exit(2) } +const reports = [] +for (const entry of scenarios) { + const execution = spawnSync(command, [driver], { + cwd: root, + encoding: 'utf8', + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, FREECAD_GOLDEN_SCENARIO: entry.file }, + }) + const output = `${execution.stdout || ''}\n${execution.stderr || ''}` + const resultLine = output.split(/\r?\n/).find((line) => line.startsWith('FREECAD_GOLDEN_RESULT=')) + if (execution.error || execution.status !== 0 || !resultLine) { + reports.push({ id: entry.id, passed: false, differences: [`FreeCADCmd execution failed with status ${execution.status}: ${execution.error?.message || output.trim()}`] }) + continue + } + let actual + try { actual = JSON.parse(resultLine.slice('FREECAD_GOLDEN_RESULT='.length)) } + catch (error) { reports.push({ id: entry.id, passed: false, differences: [`Invalid result JSON: ${error.message}`] }); continue } + const differences = [] + if (actual.fixtureId !== entry.id) differences.push(`fixtureId: expected ${entry.id}, received ${actual.fixtureId}`) + if (actual.freecadVersion !== '1.1.1') differences.push(`freecadVersion: expected 1.1.1, received ${actual.freecadVersion}`) + differences.push(...compareGoldenResult(entry.scenario, actual)) + reports.push({ id: entry.id, passed: differences.length === 0, differences, actual }) +} + +if (jsonOutput) console.log(JSON.stringify({ schemaVersion: 1, baselineId: manifest.baselineId, command, reports }, null, 2)) +else for (const report of reports) { + console.log(`${report.passed ? 'PASS' : 'FAIL'} ${report.id}`) + report.differences.forEach((difference) => console.log(` ${difference}`)) +} +if (reports.some((report) => !report.passed)) process.exit(1) diff --git a/scripts/verify-freecad-source.mjs b/scripts/verify-freecad-source.mjs new file mode 100644 index 0000000..31d2a11 --- /dev/null +++ b/scripts/verify-freecad-source.mjs @@ -0,0 +1,12 @@ +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' + +const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8')) +const reference = `refs/tags/${toolchain.source.tag}` +const output = execFileSync('git', ['ls-remote', toolchain.source.repository, reference], { encoding: 'utf8', timeout: 30000 }).trim() +const [resolved, resolvedReference] = output.split(/\s+/) +if (resolvedReference !== reference || resolved !== toolchain.source.commit) { + console.error(`FreeCAD tag mismatch: expected ${reference} @ ${toolchain.source.commit}, received ${output || 'no result'}.`) + process.exit(1) +} +console.log(`FreeCAD source tag verified: ${reference} @ ${resolved}.`) diff --git a/tests/goldenReplay.test.mjs b/tests/goldenReplay.test.mjs new file mode 100644 index 0000000..be88804 --- /dev/null +++ b/tests/goldenReplay.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { compareGoldenResult, loadGoldenManifest, validateGoldenOperation } from '../scripts/freecad-golden-contract.mjs' + +const manifestFile = resolve('fixtures/freecad-golden/manifest.json') + +test('FreeCAD golden manifest loads unique validated scenarios', async () => { + const manifest = await loadGoldenManifest(manifestFile) + assert.equal(manifest.baselineId, 'freecad-1.1.1') + assert.deepEqual(manifest.scenarios.map((entry) => entry.id), ['part-box', 'part-cylinder', 'part-sphere', 'part-cone', 'part-cut-through-hole']) + assert.equal(manifest.scenarios.at(-1).scenario.expected.shapeType, 'Compound') + assert.equal(manifest.scenarios.at(-1).scenario.expected.solids, 1) +}) + +test('golden comparator applies linear and scalar tolerances only to declared oracle fields', async () => { + const manifest = await loadGoldenManifest(manifestFile) + const box = manifest.scenarios[0].scenario + const actual = structuredClone(box.expected) + actual.volume += box.tolerance.scalar / 2 + actual.boundingBox.max[0] += box.tolerance.linear / 2 + actual.untrackedRuntimeField = 'ignored' + assert.deepEqual(compareGoldenResult(box, actual), []) + actual.volume += box.tolerance.scalar * 2 + assert.match(compareGoldenResult(box, actual)[0], /volume/) +}) + +test('golden operation validation rejects malformed dimensions, placements and unknown operations', () => { + assert.throws(() => validateGoldenOperation({ type: 'box', length: 0, width: 2, height: 3 }), /positive/) + assert.throws(() => validateGoldenOperation({ type: 'sphere', radius: 2, placement: { rotation: { axis: [0, 0, 0], angle: 10 } } }), /non-zero/) + assert.throws(() => validateGoldenOperation({ type: 'loft' }), /unsupported/) +})