diff --git a/.github/workflows/real-verification.yml b/.github/workflows/real-verification.yml index 0b17049..a2890c2 100644 --- a/.github/workflows/real-verification.yml +++ b/.github/workflows/real-verification.yml @@ -23,9 +23,32 @@ jobs: node-version: 22.23.2 cache: npm - run: ./npmw ci --ignore-scripts - - run: ./npmw run ci:real -- --lane=chrome + - name: Run real Chrome harnesses + run: ./npmw run ci:real:chrome env: CHROME_BIN: ${{ vars.CHROME_BIN }} + - name: Check generated Chrome reports + run: ./npmw run ci:check:chrome + env: + CHROME_BIN: ${{ vars.CHROME_BIN }} + - name: Run Firefox and WebKit matrix + run: ./npmw run test:browser-matrix + env: + FIREFOX_BIN: ${{ vars.FIREFOX_BIN }} + WEBKIT_BIN: ${{ vars.WEBKIT_BIN }} + - name: Check Firefox and WebKit reports + run: ./npmw run check:browser-matrix + - name: Upload Chrome evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: chrome-real-verification-${{ github.run_id }} + if-no-files-found: warn + path: | + .cache/ci/real-verification*.json + config/chrome-*-verification.json + config/browser-matrix-verification.json + fixtures/chrome-app-e2e/*.png freecad-oracle: runs-on: [self-hosted, linux, cad] diff --git a/config/browser-matrix-verification.json b/config/browser-matrix-verification.json new file mode 100644 index 0000000..2916349 --- /dev/null +++ b/config/browser-matrix-verification.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-08-11T04:50:53.969Z", + "status": "pass", + "browsers": [ + { + "engine": "firefox", + "status": "pass", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:153.0) Gecko/20100101 Firefox/153.0", + "crossOriginIsolated": true, + "sharedArrayBuffer": true, + "opfs": true, + "persistence": "opfs", + "shell": true, + "workspace": true, + "workbench": "Part Design", + "canvas": { + "width": 732, + "height": 579, + "geometrySource": "bitbybit-occt" + }, + "screenshotBytes": 131545, + "pageErrors": [] + }, + { + "engine": "webkit", + "status": "pass", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15", + "crossOriginIsolated": true, + "sharedArrayBuffer": true, + "opfs": false, + "persistence": "memory-fallback", + "shell": true, + "workspace": true, + "workbench": "Part Design", + "canvas": { + "width": 732, + "height": 579, + "geometrySource": "bitbybit-occt" + }, + "screenshotBytes": 128023, + "pageErrors": [] + } + ] +} diff --git a/config/chrome-app-e2e-verification.json b/config/chrome-app-e2e-verification.json index d4a9412..d0d5809 100644 --- a/config/chrome-app-e2e-verification.json +++ b/config/chrome-app-e2e-verification.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-08T17:54:57.664Z", + "generatedAt": "2026-08-11T02:37:40.468Z", "browserId": "chrome", "status": "pass", "browser": { @@ -423,18 +423,18 @@ "screenshots": { "desktop": { "path": "fixtures/chrome-app-e2e/desktop-workspace.png", - "byteLength": 108999, - "sha256": "33456e593549e88e561d1168eb80644b362fd941725bf4fff76500a14a6d7949" + "byteLength": 108743, + "sha256": "dd67ab23283ffd55dc0509174f8636eac5344de581d4cacfff13cc1828617399" }, "cam": { "path": "fixtures/chrome-app-e2e/cam-workbench.png", - "byteLength": 118390, - "sha256": "5349262af666380bbc96f2932eed2613547ae60870a1d8b9fe44d8ed4831b4ab" + "byteLength": 118389, + "sha256": "c339e4b998b8ce7b3290c32efeffebed85dbd1d56d8ae9f764196c7b52e80a8f" }, "mobile": { "path": "fixtures/chrome-app-e2e/mobile-start.png", - "byteLength": 130367, - "sha256": "c3873002bf12dde3a7731a511eb651880a6733ce68b149167d1ff84d6e436b69" + "byteLength": 130371, + "sha256": "9d69c9e9ced4b45bbd083a37a94b5df074e8d60540c0857e79b9df08a8648d81" } }, "pageErrors": [] diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 520a823..9842474 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "freecadBaseline": "freecad-1.1.1", "workbenches": { - "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "mirrored", "multi-transform", "linear-pattern", "polar-pattern"] }, + "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "shape-binder", "datum", "pad", "pocket", "revolution", "fillet", "chamfer", "mirrored", "multi-transform", "linear-pattern", "polar-pattern"] }, "Part": { "status": "facade-partial", "level": "experimental", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, "Sketcher": { "status": "facade-model", "level": "experimental", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical", "solve-sketch"] }, "Draft": { "status": "domain-contract", "level": "experimental", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "draft-circle", "move", "rotate", "scale", "offset", "array", "clone", "layer"] }, diff --git a/config/qa04-fcstd-fuzz-verification.json b/config/qa04-fcstd-fuzz-verification.json index a9104fc..47226c4 100644 --- a/config/qa04-fcstd-fuzz-verification.json +++ b/config/qa04-fcstd-fuzz-verification.json @@ -24,7 +24,7 @@ "wrong-document-root": 77 }, "timing": { - "p95Ms": 0.121, - "maxMs": 2.672 + "p95Ms": 0.137, + "maxMs": 2.691 } } diff --git a/config/qa04-geometry-fuzz-verification.json b/config/qa04-geometry-fuzz-verification.json index bc0c566..5274f9b 100644 --- a/config/qa04-geometry-fuzz-verification.json +++ b/config/qa04-geometry-fuzz-verification.json @@ -41,7 +41,7 @@ "zero-revolution-axis": 67 }, "timing": { - "p95Ms": 0.021, - "maxMs": 1.033 + "p95Ms": 0.02, + "maxMs": 1.133 } } diff --git a/config/qa04-sketch-fuzz-verification.json b/config/qa04-sketch-fuzz-verification.json index 80119ec..022a558 100644 --- a/config/qa04-sketch-fuzz-verification.json +++ b/config/qa04-sketch-fuzz-verification.json @@ -22,7 +22,7 @@ "invalid": 800 }, "timing": { - "p95Ms": 0.03, - "maxMs": 1.012 + "p95Ms": 0.032, + "maxMs": 0.733 } } diff --git a/config/release-artifacts.json b/config/release-artifacts.json index 8df28b9..8000c33 100644 --- a/config/release-artifacts.json +++ b/config/release-artifacts.json @@ -22,20 +22,20 @@ "bytes": 556597, "sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105" }, + { + "path": "assets/index-BcH6J3tB.js", + "bytes": 1103011, + "sha256": "870d33fae0ceabaf31a8100e65ad7f1afb3f4f869bfc225bb8c722d7771ca917" + }, { "path": "assets/index-BnRqhr3m.css", "bytes": 65976, "sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec" }, { - "path": "assets/index-CBxL0K57.js", - "bytes": 1099755, - "sha256": "02858b3fb3a849f18e45dc5ccd906d54db30abe46352ab6d2483a405a9185a56" - }, - { - "path": "assets/nativeHistoryWorkerEntry-HtQ0QZhi.js", - "bytes": 4544, - "sha256": "31eec7608dd0a500340319a7f14f3f9ec83ecd9cb4104a00eb6c77329c406e8c" + "path": "assets/nativeHistoryWorkerEntry-D9i46nqJ.js", + "bytes": 4664, + "sha256": "488e45728dd4ccfd65acb82c61266332171129a52e916ecbb7cc2a8ab61d4ed6" }, { "path": "assets/persistenceWorker-CyD5EUVX.js", @@ -330,7 +330,7 @@ { "path": "index.html", "bytes": 678, - "sha256": "56c68b39c24935e08673f86ae825539f4f1f58e1fcc4232f64472c8466b444c9" + "sha256": "07fab5a0e83597c5ca0a0cd2d57625f0134b56c02f2349a35be4e6de23540d33" }, { "path": "manifest.webmanifest", @@ -380,7 +380,7 @@ { "path": "sw.js", "bytes": 1390, - "sha256": "52a07bb516143ea6578fbf346881b2e7140fbc8c85d4492bf72fb88b8a66c95b" + "sha256": "01593ec533053a9ccdf341ef276ee5c0e1752e6dc30951acdc042c33d60f206c" }, { "path": "vendor/camotics/camotics-sweep.wasm", @@ -402,7 +402,7 @@ "status": "signed", "algorithm": "Ed25519", "keyId": "bitbybit-local-release-2026", - "payloadSha256": "87baf721899f6acb4c5ea8436f8d6e53de3dc5937431ea572c3df33f09c3576e", - "signature": "9/KPdUGbb0FmGTMftU1k2rbwJ5rjbq3nDHZLLjY0tFfreKuPFfy9mByuHHsUSQIz5Fx0CVJO+Xai/qXjrCNBBw==" + "payloadSha256": "f1fe903dc9879b0f02d1a4becbd5daf7fb9d7b96ef304f2cc04fc5058d80256e", + "signature": "RY1mpA6T/zWYx1/Z/9Z7A2kpkBbbCbP8TQIIykaFbY2E8qzmlAw0qotNYq3yOP5OfbJqas+BjAXWJeEY7WA4DQ==" } } diff --git a/docs/ci-real-verification.zh-CN.md b/docs/ci-real-verification.zh-CN.md index 61287c9..528f0c6 100644 --- a/docs/ci-real-verification.zh-CN.md +++ b/docs/ci-real-verification.zh-CN.md @@ -8,7 +8,9 @@ ./npmw run ci:real -- --lane=wasm ``` -`--lane=all` 会按 oracle、WASM、Chrome 顺序执行全部 lane。任一命令退出非零都会使整个 lane 失败;运行摘要写入 `.cache/ci/real-verification.json`,该文件不作为仓库基线证据提交。 +Chrome 还提供显式的两阶段入口:`./npmw run ci:real:chrome` 只启动真实 Chrome harness 并生成报告,`./npmw run ci:check:chrome` 只读取并校验这些报告。CI 会按这两个步骤执行并上传 JSON/截图证据;因此历史报告检查不会被误认为重新执行了浏览器。随后 `test:browser-matrix` / `check:browser-matrix` 会用 Playwright 的 Firefox 和 WebKit 对生产构建执行 shell、OCCT canvas、COOP/COEP、SharedArrayBuffer 和持久化能力 smoke gate;Firefox 强制 OPFS,WebKit 缺少 OPFS 时必须明确报告 `memory-fallback`。 + +`--lane=all` 会按 oracle、WASM、Chrome 顺序执行全部 lane。任一命令退出非零都会使整个 lane 失败;完整运行摘要写入 `.cache/ci/real-verification.json`,分阶段运行分别写入 `real-verification-execute.json` 和 `real-verification-check.json`,这些文件不作为仓库基线证据提交。 oracle lane 默认会重新抓取锁定的 FreeCAD commit 并初始化子模块。仅在本机已经具备该 commit、且需要离线复现时,才可显式使用 `FREECAD_SOURCE_OFFLINE=1`;该模式会拒绝未锁定的 checkout,并跳过网络相关子模块更新,不会替代 CI 的真实 fetch。 @@ -17,7 +19,7 @@ oracle lane 默认会重新抓取锁定的 FreeCAD commit 并初始化子模块 工作流使用标签为 `self-hosted, linux, cad` 的 runner,因为 FreeCAD 原生编译和外围 CAM harness 需要较大的本地工具链。runner 必须提供: - Node `22.23.2`、CMake、Ninja、C/C++ 编译器和 Python; -- Chrome/Chromium(通过 `CHROME_BIN` 指定)以及可用的无头模式; +- Chrome/Chromium(通过 `CHROME_BIN` 指定)、Firefox 和 WebKit(缓存版本不匹配时分别通过 `FIREFOX_BIN`、`WEBKIT_BIN` 指定)以及可用的无头模式; - Emscripten(通过 `EMSDK` 指定)和 OCCT 源码目录(通过 `OCCT_SOURCE_DIR` 指定); - `CAMotics/`、`OpenCAMLib/`、`cnc_wams_gpt6/`、`working_ocl/` 外部工作树。它们不进入 Git,版本由 `config/` 中的锁定清单和 drift check 校验; - FreeCAD 构建所需的系统开发包。`ci:real -- --lane=oracle` 会重新抓取锁定的 FreeCAD 源码、配置、编译、安装并执行 oracle,不接受旧报告代替。 diff --git a/docs/freecad-full-parity-execution-wbs.zh-CN.md b/docs/freecad-full-parity-execution-wbs.zh-CN.md index d7cf537..d84663a 100644 --- a/docs/freecad-full-parity-execution-wbs.zh-CN.md +++ b/docs/freecad-full-parity-execution-wbs.zh-CN.md @@ -182,7 +182,7 @@ Owner:IO。Gate:G5。 | FC-10 | IO+ORACLE:FreeCAD→Web→FreeCAD 核心/代理报告 | 无静默丢失;差异全部分类 | | FC-11 | IO+QUALITY:大文件、损坏、取消、中断和恢复 | 能力内不崩溃;超限明确拒绝 | -P06 已由 `check:fcstd-closure` 聚合关闭:FreeCAD 1.1.1 原生双向 22 场景没有未知差异,未知 proxy 归档逐字节保持;Chrome 锁定 Expression/Link/LinkSubList/TopoRef/Sketcher/GuiDocument/BRep/ElementMap 语义。100 模型 FCStd+BRep 以及 STEP/IGES/BREP 三格式质量、毫米单位和容差矩阵全部通过;DXF/SVG/PDF、OBJ/PLY/STL、IFC、CSV 八种次级格式分别具有成功、错误签名拒绝和字节精确回环。FCStd ZIP/XML/stream 限额、损坏、取消与 checkpoint 恢复由 `check:fcstd-hardening` 锁定。 +P06 已由 `check:fcstd-closure` 聚合关闭:FreeCAD 1.1.1 原生双向 23 场景没有未知差异,未知 proxy 归档逐字节保持;Chrome 锁定 Expression/Link/LinkSubList/TopoRef/Sketcher/GuiDocument/BRep/ElementMap 语义。100 模型 FCStd+BRep 以及 STEP/IGES/BREP 三格式质量、毫米单位和容差矩阵全部通过;DXF/SVG/PDF、OBJ/PLY/STL、IFC、CSV 八种次级格式分别具有成功、错误签名拒绝和字节精确回环。FCStd ZIP/XML/stream 限额、损坏、取消与 checkpoint 恢复由 `check:fcstd-hardening` 锁定。 ### P07 TechDraw、Spreadsheet、Draft、Plot @@ -327,7 +327,7 @@ Points、ReverseEngineering、OpenSCAD、IDF、JT、Material、Import 七个专 ## 16. P10 QA 与发布闭包证据 -QA-01/02 由 24 个测试套件、217 个 Node 用例、100 个成功/50 个失败 FreeCAD golden、19 类 Sketcher 成功/失败 oracle、17 个 PartDesign oracle 和 22 个 FCStd 双向 round-trip 场景关闭。QA-03 的 Chrome 150 生产构建回放 8 个核心流程,桌面/移动截图均为有效 PNG;初始几何预览来自 Bitbybit OCCT,任务态 canvas 仍通过像素和 WebGL 检查。 +QA-01/02 由 24 个测试套件、217 个 Node 用例、100 个成功/50 个失败 FreeCAD golden、19 类 Sketcher 成功/失败 oracle、17 个 PartDesign oracle 和 23 个 FCStd 双向 round-trip 场景关闭。QA-03 的 Chrome 150 生产构建回放 8 个核心流程,桌面/移动截图均为有效 PNG;初始几何预览来自 Bitbybit OCCT,任务态 canvas 仍通过像素和 WebGL 检查。 QA-04 三套固定种子 fuzz 覆盖 1000 个 FCStd parser、2000 个 geometry input、2000 个 Sketch solver 模型,均有正/负分类、输入不变性、确定性和 P95。QA-05 覆盖 1000 对象、1000000 三角形、100000 表格单元,Chrome 堆约 35 MB;QA-06 覆盖 Worker/WASM 取消、崩溃、恢复、stale、OPFS 迁移和事务回滚;QA-07 覆盖路径/归档/XML/权限、Addon 和 Script、CSP/依赖审计。 diff --git a/fixtures/chrome-app-e2e/cam-workbench.png b/fixtures/chrome-app-e2e/cam-workbench.png index 1e3c04c..bb519f4 100644 Binary files a/fixtures/chrome-app-e2e/cam-workbench.png and b/fixtures/chrome-app-e2e/cam-workbench.png differ diff --git a/fixtures/chrome-app-e2e/desktop-workspace.png b/fixtures/chrome-app-e2e/desktop-workspace.png index f858689..58f69df 100644 Binary files a/fixtures/chrome-app-e2e/desktop-workspace.png and b/fixtures/chrome-app-e2e/desktop-workspace.png differ diff --git a/fixtures/chrome-app-e2e/mobile-start.png b/fixtures/chrome-app-e2e/mobile-start.png index 64f34c1..e05cfdd 100644 Binary files a/fixtures/chrome-app-e2e/mobile-start.png and b/fixtures/chrome-app-e2e/mobile-start.png differ diff --git a/package.json b/package.json index bcb0462..8d2cf9f 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,10 @@ "test:golden:freecad:families": "node scripts/run-freecad-golden.mjs --manifest=fixtures/freecad-golden/feature-families/manifest.json", "probe:freecad-reference": "node scripts/run-freecad-reference-probe.mjs", "ci:real": "node scripts/run-real-verification.mjs", + "ci:real:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=execute", + "ci:check:chrome": "node scripts/run-real-verification.mjs --lane=chrome --phase=check", + "test:browser-matrix": "node scripts/run-browser-matrix.mjs", + "check:browser-matrix": "node scripts/check-browser-matrix.mjs", "check:runtime": "node scripts/check-runtime.mjs", "verify": "npm run check:runtime && npm run check:baseline && npm run check:bitbybit-history && npm run check:execution-plan && npm run check:freecad-sketcher-constraints && npm run check:freecad-inventory && npm run check:freecad-type-properties && npm run check:freecad-gui-commands && npm run check:freecad-entrypoints && npm run check:freecad-golden-fixtures && npm run check:freecad-semantic-comparator && npm run check:freecad-fcstd-roundtrip && npm run check:freecad-composite-history-elementmap && npm run check:freecad-exact-history-elementmap-gate && npm run check:freecad-native-naming-evidence && npm run check:fcstd-hardening && npm run check:occt-history-artifact && npm run check:planegcs-artifact && npm run check:browser-occt && npm run check:chrome-topology && npm run check:chrome-planegcs && npm run check:chrome-fault-injection && npm run check:chrome-opfs-migration && npm run check:chrome-persistence && npm run check:chrome-app-e2e && npm run check:chrome-partdesign-lifecycle && npm run check:pwa && npm run check:chrome-offline && npm run check:chrome-geometry-features && npm run check:chrome-native-history && npm run check:chrome-native-pad-history && npm run check:chrome-native-pocket-history && npm run check:chrome-native-loft-history && npm run check:chrome-native-pipe-history && npm run check:chrome-native-revolution-history && npm run check:chrome-native-groove-history && npm run check:chrome-native-fillet-history && npm run check:chrome-native-chamfer-history && npm run check:chrome-native-hole-history && npm run check:chrome-native-draft-history && npm run check:chrome-native-thickness-history && npm run check:chrome-native-linear-pattern-history && npm run check:chrome-native-polar-pattern-history && npm run check:chrome-native-mirrored-history && npm run check:chrome-native-multi-transform-history && npm run check:chrome-sketcher-diagnostics && npm run check:chrome-profile-validation && npm run check:chrome-sketcher-editor && npm run check:chrome-sketcher-bspline && npm run check:chrome-sketcher-stress && npm run check:chrome-part-primitives && npm run check:chrome-partdesign-transform && npm run check:chrome-partdesign-loft && npm run check:partdesign-closure && npm run check:chrome-fcstd-semantic && npm run check:chrome-fcstd-roundtrip && npm run check:fcstd-closure && npm run check:chrome-spreadsheet && npm run check:chrome-plot && npm run check:chrome-draft && npm run check:chrome-techdraw && npm run check:chrome-assembly && npm run check:chrome-mesh && npm run check:chrome-inspection && npm run check:chrome-bim && npm run check:chrome-cam && npm run check:cam-parity && npm run check:cam-pipeline && npm run check:opencamlib-wasm && npm run check:camotics-native && npm run check:camotics-gui-tpl && npm run check:camotics-wasm && npm run check:chrome-camotics-wasm && npm run check:chrome-cam-native-simulation && npm run check:chrome-cam-linuxcnc-machine && npm run check:chrome-fem && npm run check:chrome-robot && npm run check:chrome-data && npm run check:chrome-addon && npm run check:chrome-script && npm run check:chrome-surface && npm run check:chrome-qa08 && npm run check:chrome-secondary-formats && npm run check:chrome-performance && npm run check:chrome-production-document && npm run check:document-closure && npm run check:chrome-engineering && npm run check:chrome-security && npm run check:chrome-fcstd-golden && npm run check:sbom && npm run check:project-migration && npm run check:security-baseline && npm run check:dependency-audit && npm run check:freecad:threeway && npm run check:occt-upstream-drift && npm run check:facade-boundary && npm run test:golden && npm run test:spreadsheet && npm run test:plot && npm run test:draft && npm run test:techdraw && npm run test:assembly && npm run test:mesh && npm run test:inspection && npm run test:bim && npm run test:cam && npm run test:camotics-wasm && npm run test:fem && npm run test:robot && npm run test:data && npm run test:addon && npm run test:script && npm run test:surface && npm run test:locale && npm run test:secondary-formats && npm run test:performance && npm run test:production-document && npm run test:engineering && npm run test:security-preflight && npm run test:fcstd-roundtrip && npm run test:facade && npm run test:fcstd-fuzz && npm run test:geometry-input-fuzz && npm run test:sketch-solver-fuzz && npm run check:quality-closure && npm run test:planegcs && npm run test:project-migration && npm run test:sketcher-stress && npm run test:topology-replay && npm run build && npm run check:built-pwa && npm run generate:release-artifacts && npm run check:release-artifacts && npm run check:release-closure && npm run test:release-signature", "test:chrome-native-thickness-history": "node scripts/run-chrome-native-thickness-history.mjs", diff --git a/scripts/check-browser-matrix.mjs b/scripts/check-browser-matrix.mjs new file mode 100644 index 0000000..e2814b4 --- /dev/null +++ b/scripts/check-browser-matrix.mjs @@ -0,0 +1,13 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const report = JSON.parse(await readFile(resolve(root, 'config/browser-matrix-verification.json'), 'utf8')) +const expected = ['firefox', 'webkit'] +if (report.schemaVersion !== 1 || report.status !== 'pass' || report.browsers?.length !== expected.length) throw new Error('Browser matrix report is incomplete.') +for (const engine of expected) { + const entry = report.browsers.find((candidate) => candidate.engine === engine) + const persistencePass = engine === 'firefox' ? entry?.opfs === true && entry?.persistence === 'opfs' : entry && ['opfs', 'memory-fallback'].includes(entry.persistence) + if (!entry || entry.status !== 'pass' || entry.pageErrors?.length !== 0 || entry.shell !== true || entry.workspace !== true || entry.crossOriginIsolated !== true || entry.sharedArrayBuffer !== true || !persistencePass || entry.workbench !== 'Part Design' || entry.canvas?.geometrySource !== 'bitbybit-occt' || entry.canvas.width < 100 || entry.canvas.height < 100 || entry.screenshotBytes < 10_000) throw new Error(`Browser matrix ${engine} evidence is incomplete.`) +} +console.log(JSON.stringify({ status: 'browser-matrix-pass', browsers: report.browsers.map(({ engine, userAgent, persistence, canvas }) => ({ engine, userAgent, persistence, canvas })) }, null, 2)) diff --git a/scripts/check-facade-boundary.mjs b/scripts/check-facade-boundary.mjs index 9fb8f13..a4e4578 100644 --- a/scripts/check-facade-boundary.mjs +++ b/scripts/check-facade-boundary.mjs @@ -21,7 +21,8 @@ async function walk(relative = '') { } const violations = [] -for (const file of await walk()) { +const sourceFiles = await walk() +for (const file of sourceFiles) { if (allowDirectRuntime.has(file)) continue const content = await readFile(new URL(file, sourceRoot), 'utf8') for (const pattern of forbidden) { @@ -30,9 +31,18 @@ for (const file of await walk()) { } } +const productionFacade = await readFile(new URL('facade/productionFacade.ts', sourceRoot), 'utf8') +const application = await readFile(new URL('App.tsx', sourceRoot), 'utf8') +const runtimeProfile = await readFile(new URL('facade/runtimeProfile.ts', sourceRoot), 'utf8') +if (/from\s+["']\.\/mockFacade["']/.test(productionFacade)) violations.push('facade/productionFacade.ts: production entry imports mockFacade directly') +if (/createMockFacade|facade\/mockFacade/.test(application)) violations.push('App.tsx: production application references the mock Facade') +for (const declaration of ['bitbybit-occt', 'optional-worker', 'not-exposed', 'sqlite-opfs-with-memory-fallback', 'three-webgl2']) { + if (!runtimeProfile.includes(declaration)) violations.push(`facade/runtimeProfile.ts: missing explicit runtime boundary '${declaration}'`) +} + if (violations.length) { console.error('Facade-only boundary failed:') violations.forEach((violation) => console.error(`- ${violation}`)) process.exit(1) } -console.log(`Facade-only boundary passed (${(await walk()).length} TypeScript source files checked).`) +console.log(`Facade-only boundary passed (${sourceFiles.length} TypeScript source files checked).`) diff --git a/scripts/check-fcstd-closure.mjs b/scripts/check-fcstd-closure.mjs index 03fe965..e90bf67 100644 --- a/scripts/check-fcstd-closure.mjs +++ b/scripts/check-fcstd-closure.mjs @@ -11,7 +11,8 @@ const golden = await load('config/chrome-fcstd-golden-verification.json') const secondary = await load('config/chrome-secondary-formats-verification.json') const fail = (message) => { throw new Error(`FCStd closure: ${message}`) } -if (native.status !== 'verified' || native.baselineId !== 'freecad-1.1.1' || native.freecadCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || native.scenarioCount !== 22 || native.scenarios?.length !== 22 || native.unknownDifferencesFail !== true || JSON.stringify(native.directions) !== JSON.stringify(['freecad-web-freecad', 'web-freecad-web'])) fail('native round-trip baseline or direction matrix is incomplete.') +const nativeScenarioCount = native.scenarioCount +if (native.status !== 'verified' || native.baselineId !== 'freecad-1.1.1' || native.freecadCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || !Number.isSafeInteger(nativeScenarioCount) || nativeScenarioCount < 23 || native.scenarios?.length !== nativeScenarioCount || native.unknownDifferencesFail !== true || JSON.stringify(native.directions) !== JSON.stringify(['freecad-web-freecad', 'web-freecad-web'])) fail('native round-trip baseline or direction matrix is incomplete.') if (native.scenarios.some((scenario) => scenario.status !== 'pass' || scenario.differences?.length !== 0 || scenario.domains?.length === 0 || scenario.evidence === undefined)) fail('native round-trip contains an unknown difference or missing evidence.') const proxy = native.scenarios.find((scenario) => scenario.id === 'proxy-byte-preservation')?.evidence if (!proxy || proxy.byteIdentical !== true || proxy.sourceArchiveBytes !== proxy.preservedArchiveBytes || proxy.sourceSha256 !== proxy.preservedSha256 || proxy.proxyObjectCount !== 8 || proxy.resavedProxyObjectCount !== 8) fail('unknown/proxy archive bytes were not preserved.') @@ -28,4 +29,4 @@ if (secondary.status !== 'pass' || secondary.formats?.descriptors !== formats.le const hardening = spawnSync('./npmw', ['run', 'check:fcstd-hardening'], { cwd: root, encoding: 'utf8', timeout: 120_000 }) if (hardening.status !== 0) fail(`FC-11 hardening suite failed: ${(hardening.stdout || '') + (hardening.stderr || '')}`) -console.log(JSON.stringify({ status: 'fcstd-closure-pass', nativeScenarios: native.scenarioCount, semanticObjects: semantic.objectNames.length, goldenModels: golden.scenarioCount, formatMatrix: secondary.formats.matrix, chromeDirections: chromeRoundTrip.directions, hardening: 'pass' }, null, 2)) +console.log(JSON.stringify({ status: 'fcstd-closure-pass', nativeScenarios: nativeScenarioCount, semanticObjects: semantic.objectNames.length, goldenModels: golden.scenarioCount, formatMatrix: secondary.formats.matrix, chromeDirections: chromeRoundTrip.directions, hardening: 'pass' }, null, 2)) diff --git a/scripts/check-quality-closure.mjs b/scripts/check-quality-closure.mjs index e6f1ec7..a32f00c 100644 --- a/scripts/check-quality-closure.mjs +++ b/scripts/check-quality-closure.mjs @@ -6,7 +6,7 @@ const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse) const [ fcstdFuzz, geometryFuzz, sketchFuzz, successFixtures, failureFixtures, supplementalSuccessFixtures, supplementalFailureFixtures, sketchOracle, partdesignBase, partdesignLoft, partdesignDressup, partdesignTransform, partdesignFailures, partdesignRevolutionGroove, partBuilders, - fcstdRoundTrip, app, performance, fault, opfsMigration, security, qa08, addon, script, + fcstdRoundTrip, app, browserMatrix, performance, fault, opfsMigration, security, qa08, addon, script, ] = await Promise.all([ load('config/qa04-fcstd-fuzz-verification.json'), load('config/qa04-geometry-fuzz-verification.json'), @@ -25,6 +25,7 @@ const [ load('config/freecad-part-builders-oracle.json'), load('config/freecad-fcstd-roundtrip-verification.json'), load('config/chrome-app-e2e-verification.json'), + load('config/browser-matrix-verification.json'), load('config/chrome-performance-verification.json'), load('config/chrome-fault-injection-verification.json'), load('config/chrome-opfs-migration-verification.json'), @@ -46,10 +47,11 @@ if (partdesignCases !== 23 || [partdesignBase, partdesignLoft, partdesignDressup if (partdesignFailures.status !== 'pass' || partdesignFailures.freecadVersion !== '1.1.1' || partdesignFailures.summary?.cases !== 17 || partdesignFailures.summary.passed !== 17 || partdesignFailures.summary.rejected !== 13 || partdesignFailures.summary.acceptedEmpty !== 4 || partdesignFailures.summary.accepted !== 0) throw new Error('QA-02 PartDesign failure oracle evidence is incomplete.') if (partdesignRevolutionGroove.status !== 'pass' || partdesignRevolutionGroove.freecadVersion !== '1.1.1' || partdesignRevolutionGroove.summary?.cases !== 2 || partdesignRevolutionGroove.summary.passed !== 2 || partdesignRevolutionGroove.cases?.some((fixture) => fixture.shapeNull !== false || fixture.shapeValid !== true || fixture.solids !== 1)) throw new Error('QA-02 PartDesign Revolution/Groove oracle evidence is incomplete.') if (partBuilders.status !== 'pass' || partBuilders.freecadVersion !== '1.1.1' || partBuilders.summary?.successCases !== 6 || partBuilders.summary.successPassed !== 6 || partBuilders.summary.failureCases !== 6 || partBuilders.summary.failurePassed !== 6 || partBuilders.summary.rejected !== 6 || partBuilders.summary.acceptedEmpty !== 0) throw new Error('QA-02 Part builders oracle evidence is incomplete.') -if (fcstdRoundTrip.status !== 'verified' || fcstdRoundTrip.scenarioCount !== 22 || fcstdRoundTrip.scenarios?.some((scenario) => scenario.status !== 'pass' || scenario.differences?.length !== 0)) throw new Error('QA-02 FCStd oracle evidence is incomplete.') +if (fcstdRoundTrip.status !== 'verified' || !Number.isSafeInteger(fcstdRoundTrip.scenarioCount) || fcstdRoundTrip.scenarioCount < 23 || fcstdRoundTrip.scenarios?.length !== fcstdRoundTrip.scenarioCount || fcstdRoundTrip.scenarios?.some((scenario) => scenario.status !== 'pass' || scenario.differences?.length !== 0)) throw new Error('QA-02 FCStd oracle evidence is incomplete.') const requiredAppWorkflows = ['start-to-workspace', 'freecad-menus', 'command-search-dialog', 'preferences-about-dialogs', 'model-tree-context-menu', 'workbench-change', 'create-sketch-command', 'loft-section-task', 'drawer-collapse', 'cam-workbench-job-flow', 'opfs-save-reopen', 'import-format-selection', 'export-format-selection'] if (app.status !== 'pass' || requiredAppWorkflows.some((workflow) => !app.workflows?.includes(workflow)) || app.pageErrors?.length !== 0 || app.desktop?.kernelPreviewSource !== 'bitbybit-occt' || app.desktop.canvas?.uniqueColors < 4 || app.desktop?.commandGroups < 4 || app.desktop?.selectionView !== true || app.desktop?.statusBar !== true || app.uiParity?.commandDialog?.modal !== true || app.uiParity?.preferencesDialog?.modal !== true || app.uiParity?.aboutDialog?.modal !== true || app.uiParity?.contextMenu?.items?.length !== 4 || app.uiParity?.camMenu?.items !== 60 || app.cam?.ui?.generated !== true || app.cam.ui.toolbarCommands !== 60 || app.cam.ui.toolpathPoints < 5 || app.mobile?.viewport?.width !== 390 || Object.keys(app.screenshots ?? {}).length !== 3) throw new Error('QA-03 production Chrome E2E evidence is incomplete.') +if (browserMatrix.status !== 'pass' || browserMatrix.browsers?.length !== 2 || !['firefox', 'webkit'].every((engine) => browserMatrix.browsers.some((entry) => entry.engine === engine && entry.status === 'pass' && entry.pageErrors?.length === 0 && entry.canvas?.geometrySource === 'bitbybit-occt'))) throw new Error('QA-03 non-Chrome browser matrix evidence is incomplete.') if (fcstdFuzz.status !== 'fcstd-parser-fuzz-pass' || fcstdFuzz.cases !== 1000 || fcstdFuzz.counts?.accepted + fcstdFuzz.counts?.rejected !== 1000 || Object.keys(fcstdFuzz.categories ?? {}).length !== 13 || fcstdFuzz.timing?.p95Ms >= 50) throw new Error('QA-04 FCStd fuzz evidence is incomplete.') if (geometryFuzz.status !== 'geometry-input-fuzz-pass' || geometryFuzz.cases !== 2000 || geometryFuzz.counts?.accepted + geometryFuzz.counts?.rejected !== 2000 || Object.keys(geometryFuzz.categories ?? {}).length !== 30 || geometryFuzz.timing?.p95Ms >= 10) throw new Error('QA-04 geometry fuzz evidence is incomplete.') @@ -66,7 +68,8 @@ console.log(JSON.stringify({ tasks: 8, unitSuites: testFiles.length, unitTests: testCases, - freecad: { successFixtures: 105, failureFixtures: 56, primarySuccessFixtures: 100, primaryFailureFixtures: 51, supplementalSuccessFixtures: 5, supplementalFailureFixtures: 5, sketchConstraints: 19, partdesignCases, partdesignFailureCases: partdesignFailures.summary.cases, partdesignRejectedFailures: partdesignFailures.summary.rejected, partdesignAcceptedEmpty: partdesignFailures.summary.acceptedEmpty, partdesignRevolutionGrooveCases: partdesignRevolutionGroove.summary.cases, partBuilderSuccessCases: partBuilders.summary.successCases, partBuilderFailureCases: partBuilders.summary.failureCases, fcstdRoundTrips: 22 }, + freecad: { successFixtures: 105, failureFixtures: 56, primarySuccessFixtures: 100, primaryFailureFixtures: 51, supplementalSuccessFixtures: 5, supplementalFailureFixtures: 5, sketchConstraints: 19, partdesignCases, partdesignFailureCases: partdesignFailures.summary.cases, partdesignRejectedFailures: partdesignFailures.summary.rejected, partdesignAcceptedEmpty: partdesignFailures.summary.acceptedEmpty, partdesignRevolutionGrooveCases: partdesignRevolutionGroove.summary.cases, partBuilderSuccessCases: partBuilders.summary.successCases, partBuilderFailureCases: partBuilders.summary.failureCases, fcstdRoundTrips: fcstdRoundTrip.scenarioCount }, fuzz: { fcstd: fcstdFuzz.cases, geometry: geometryFuzz.cases, solverModels: sketchFuzz.models }, chrome: { workflows: app.workflows.length, screenshots: Object.keys(app.screenshots).length, accessibilityNodes: app.screenReader.nodes }, + browsers: Object.fromEntries(browserMatrix.browsers.map((entry) => [entry.engine, { status: entry.status, persistence: entry.persistence, geometrySource: entry.canvas.geometrySource }])), }, null, 2)) diff --git a/scripts/run-browser-matrix.mjs b/scripts/run-browser-matrix.mjs new file mode 100644 index 0000000..b0f88e5 --- /dev/null +++ b/scripts/run-browser-matrix.mjs @@ -0,0 +1,90 @@ +import { createReadStream, existsSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, normalize, resolve } from 'node:path' +import { firefox, webkit } from '../cnc_wams_gpt6/linuxcnc-master/web/node_modules/playwright/index.mjs' + +const root = resolve(import.meta.dirname, '..') +const dist = resolve(root, 'dist') +const reportPath = resolve(root, 'config/browser-matrix-verification.json') +if (!existsSync(resolve(dist, 'index.html'))) throw new Error('Browser matrix requires a production build in dist/. Run npm run build first.') + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.wasm': 'application/wasm', +} +const server = createServer((request, response) => { + response.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + response.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + const requestPath = decodeURIComponent((request.url || '/').split('?')[0]) + const candidate = requestPath === '/' ? resolve(dist, 'index.html') : normalize(resolve(dist, `.${requestPath}`)) + const file = candidate.startsWith(dist) && existsSync(candidate) ? candidate : resolve(dist, 'index.html') + response.setHeader('Content-Type', contentTypes[extname(file)] || 'application/octet-stream') + createReadStream(file).on('error', (error) => { response.writeHead(500); response.end(String(error)) }).pipe(response) +}) + +await new Promise((resolveServer, rejectServer) => { + server.once('error', rejectServer) + server.listen(0, '127.0.0.1', resolveServer) +}) +const baseUrl = `http://127.0.0.1:${server.address().port}` +const requested = process.argv.find((argument) => argument.startsWith('--browser='))?.slice('--browser='.length) +const engines = requested ? requested.split(',') : ['firefox', 'webkit'] +const browserTypes = { firefox, webkit } +const executablePaths = { firefox: process.env.FIREFOX_BIN, webkit: process.env.WEBKIT_BIN } +const results = [] + +try { + for (const engine of engines) { + const browserType = browserTypes[engine] + if (!browserType) throw new Error(`Unsupported browser matrix engine: ${engine}`) + const browser = await browserType.launch({ headless: true, ...(executablePaths[engine] ? { executablePath: executablePaths[engine] } : {}) }) + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }) + const pageErrors = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + try { + await page.goto(`${baseUrl}/workspace/pump-housing`, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + await page.waitForSelector('.app-shell', { timeout: 30_000 }) + await page.waitForFunction(() => document.querySelector('.three-viewport-host canvas')?.dataset.geometrySource === 'bitbybit-occt', undefined, { timeout: 120_000 }) + const state = await page.evaluate(async () => { + const canvas = document.querySelector('.three-viewport-host canvas') + let opfs = false + try { opfs = Boolean(await navigator.storage?.getDirectory?.()) } catch { opfs = false } + return { + userAgent: navigator.userAgent, + crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer === 'function', + opfs, + persistence: opfs ? 'opfs' : 'memory-fallback', + shell: Boolean(document.querySelector('.app-shell')), + workspace: Boolean(document.querySelector('.workspace-page')), + workbench: document.querySelector('select[aria-label="Workbench"]')?.value || '', + canvas: { + width: canvas?.width || 0, + height: canvas?.height || 0, + geometrySource: canvas?.dataset.geometrySource || '', + }, + } + }) + const screenshot = await page.screenshot({ type: 'png' }) + const persistencePass = engine === 'firefox' ? state.opfs && state.persistence === 'opfs' : ['opfs', 'memory-fallback'].includes(state.persistence) + const pass = pageErrors.length === 0 && state.shell && state.workspace && state.crossOriginIsolated && state.sharedArrayBuffer && persistencePass && state.workbench === 'Part Design' && state.canvas.width >= 100 && state.canvas.height >= 100 && state.canvas.geometrySource === 'bitbybit-occt' && screenshot.byteLength > 10_000 + results.push({ engine, status: pass ? 'pass' : 'fail', ...state, screenshotBytes: screenshot.byteLength, pageErrors }) + } catch (error) { + results.push({ engine, status: 'fail', pageErrors: [...pageErrors, error instanceof Error ? error.message : String(error)] }) + } finally { + await browser.close() + } + } +} finally { + await new Promise((resolveClose) => server.close(resolveClose)) +} + +const report = { schemaVersion: 1, generatedAt: new Date().toISOString(), status: results.every((entry) => entry.status === 'pass') ? 'pass' : 'fail', browsers: results } +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`) +console.log(JSON.stringify(report, null, 2)) +if (report.status !== 'pass') throw new Error('Non-Chrome browser matrix failed.') diff --git a/scripts/run-real-verification.mjs b/scripts/run-real-verification.mjs index 0f332bc..f29aa74 100644 --- a/scripts/run-real-verification.mjs +++ b/scripts/run-real-verification.mjs @@ -13,6 +13,15 @@ const validLanes = new Set(['all', 'chrome', 'oracle', 'wasm']) if (!validLanes.has(requestedLane)) { throw new Error(`Unknown lane "${requestedLane}". Expected one of: ${[...validLanes].join(', ')}`) } +const phaseArg = process.argv.find((arg) => arg.startsWith('--phase=')) +const requestedPhase = phaseArg ? phaseArg.slice('--phase='.length) : 'all' +const validPhases = new Set(['all', 'execute', 'check']) +if (!validPhases.has(requestedPhase)) { + throw new Error(`Unknown phase "${requestedPhase}". Expected one of: ${[...validPhases].join(', ')}`) +} +if (requestedPhase !== 'all' && requestedLane !== 'chrome') { + throw new Error(`Phase "${requestedPhase}" is currently supported only for the chrome lane.`) +} const chromeTests = Object.keys(scripts) .filter((name) => name.startsWith('test:chrome-')) @@ -92,6 +101,17 @@ const lanes = { } const selectedLanes = requestedLane === 'all' ? ['oracle', 'wasm', 'chrome'] : [requestedLane] +const chromeExecution = ['build', ...chromeTests] +const chromeChecks = [ + ...chromeTests + .map((name) => `check:${name.slice('test:'.length)}`) + .filter((name) => scripts[name]), + 'check:freecad-tsn-stage-evidence', +] +const commandsFor = (lane) => { + if (lane !== 'chrome' || requestedPhase === 'all') return lanes[lane] + return requestedPhase === 'execute' ? chromeExecution : chromeChecks +} const results = [] function runScript(name, laneEnvironment = {}) { @@ -118,12 +138,13 @@ try { FREECAD_REFERENCE_CMD: resolve(root, '.cache/freecad/install-desktop/bin/FreeCAD'), } : {} - for (const name of lanes[lane]) runScript(name, laneEnvironment) + for (const name of commandsFor(lane)) runScript(name, laneEnvironment) } } finally { const outputDir = resolve(root, '.cache', 'ci') mkdirSync(outputDir, { recursive: true }) - writeFileSync(resolve(outputDir, 'real-verification.json'), `${JSON.stringify({ generatedAt: new Date().toISOString(), lanes: selectedLanes, results }, null, 2)}\n`) + const summaryName = requestedPhase === 'all' ? 'real-verification.json' : `real-verification-${requestedPhase}.json` + writeFileSync(resolve(outputDir, summaryName), `${JSON.stringify({ generatedAt: new Date().toISOString(), lanes: selectedLanes, phase: requestedPhase, results }, null, 2)}\n`) } -console.log(`\n[real-verification] completed ${results.length} commands across ${selectedLanes.join(', ')}.`) +console.log(`\n[real-verification] completed ${results.length} ${requestedPhase} commands across ${selectedLanes.join(', ')}.`) diff --git a/src/facade/bodyRules.ts b/src/facade/bodyRules.ts index 11d9030..2dd9e67 100644 --- a/src/facade/bodyRules.ts +++ b/src/facade/bodyRules.ts @@ -8,6 +8,16 @@ export type BodyFeatureState = { solid: boolean } +const nonSolidBodyTypeIds = new Set([ + 'Sketcher::SketchObject', + 'PartDesign::Body', + 'PartDesign::Plane', + 'PartDesign::Line', + 'PartDesign::Point', + 'PartDesign::ShapeBinder', + 'PartDesign::SubShapeBinder', +]) + export const resolveBodyTip = (features: readonly BodyFeatureState[]): string | null => { for (let index = features.length - 1; index >= 0; index -= 1) { const feature = features[index] @@ -29,7 +39,7 @@ export const redirectBodyTips = (document: DocumentSnapshot, objectStates: Recor typeId: object?.typeId ?? '', suppressed: objectStates[id] === 'suppressed', upstreamSuppressed: objectStates[id] === 'upstream-suppressed', - solid: Boolean(object && object.typeId !== 'Sketcher::SketchObject' && object.typeId !== 'PartDesign::Body'), + solid: Boolean(object && !nonSolidBodyTypeIds.has(object.typeId)), } }) const nextTip = resolveBodyTip(features) diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts index 5fbc94c..03b02fd 100644 --- a/src/facade/fcstd.ts +++ b/src/facade/fcstd.ts @@ -238,6 +238,11 @@ const recognizedTypeIds = new Set([ 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole', + 'PartDesign::Plane', + 'PartDesign::Line', + 'PartDesign::Point', + 'PartDesign::ShapeBinder', + 'PartDesign::SubShapeBinder', 'Sketcher::SketchObject', 'Path::Feature', ]) diff --git a/src/facade/index.ts b/src/facade/index.ts index 2611676..d24d4d4 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -1,5 +1,7 @@ export { createBlankDocument, createMockFacade, createPumpHousingDemoDocument, createWebCadFacade } from './mockFacade' export type { WebCadFacadeOptions } from './mockFacade' +export { createFacadeRuntimeProfile } from './runtimeProfile' +export type { FacadeRuntimeMode, FacadeRuntimeProfile } from './runtimeProfile' export { createProductionFacade } from './productionFacade' export type { ProductionFacadeOptions } from './productionFacade' export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics' @@ -75,7 +77,8 @@ export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from ' export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol' export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol' -export type { NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol' +export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities } from './nativeHistoryProtocol' +export type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol' export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence' export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence' export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index a7872b6..0f5cd04 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -31,6 +31,7 @@ import type { TopoRefValue, Unsubscribe, } from './types' +import { createFacadeRuntimeProfile, type FacadeRuntimeMode } from './runtimeProfile' import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment' import { redirectBodyTips } from './bodyRules' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' @@ -315,7 +316,7 @@ const collectDependencyEdges = (document: Pick): De if (property.type === 'App::PropertyLinkList' && Array.isArray(property.value)) for (const [index, targetId] of property.value.entries()) { if (typeof targetId === 'string' && objectIds.has(targetId)) edges.push({ sourceId: object.id, targetId, relation: 'link', propertyName: property.name, reference: `${property.name}[${index}]` }) } - if (property.name === 'Support' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'objectId' in property.value && typeof property.value.objectId === 'string' && objectIds.has(property.value.objectId)) { + if (property.type === 'App::PropertyLink' && property.name === 'Support' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'objectId' in property.value && typeof property.value.objectId === 'string' && objectIds.has(property.value.objectId)) { const subElement = 'subElement' in property.value ? property.value.subElement : null if (subElement && typeof subElement === 'object' && !Array.isArray(subElement) && 'schemaVersion' in subElement && typeof subElement.persistentId === 'string') edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: subElement.persistentId }) else edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'link', propertyName: property.name }) @@ -386,15 +387,16 @@ export type WebCadFacadeOptions = { initialDocument?: DocumentSnapshot initialWorkbench?: WorkbenchId initialSelectedObjectIds?: string[] + runtimeMode?: FacadeRuntimeMode } -const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) +const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'shape-binder', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) const systemCommands = new Set(['new-document', 'save', 'select-object']) const camCommandIds = new Set(workbenchDefinitions.CAM.groups.flatMap((group) => group.commands.map((entry) => entry.id))) const camSimulationCommands = new Set(['CAM_SimTools', 'CAM_Simulator', 'CAM_SimulatorGL']) const camCopyCommands = new Set(['CAM_OperationCopy', 'CAM_Copy', 'CAM_SimpleCopy']) -const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'helix', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'fillet-part', 'chamfer-part', 'check-shape', 'solve-sketch', ...camCommandIds]) -const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) +const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'shape-binder', 'datum', 'pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'helix', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'fillet-part', 'chamfer-part', 'check-shape', 'solve-sketch', ...camCommandIds]) +const partDesignCommands = new Set(['create-body', 'create-sketch', 'shape-binder', 'datum', 'pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) const partCommands = new Set(['primitive', 'helix', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'fillet-part', 'chamfer-part', 'check-shape']) const partProfileCommands = new Set(['extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe']) const loftCommands = new Set(['loft-part', 'additive-loft', 'subtractive-loft']) @@ -447,6 +449,8 @@ const shapeTypeIds = new Set([ const featureCommands: Record = { 'create-body': { label: 'Body', detail: 'Part Design body' }, 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, + 'shape-binder': { label: 'Shape Binder', detail: 'Cross-body support reference' }, + datum: { label: 'Datum Plane', detail: 'Attached datum reference' }, pad: { label: 'Pad', detail: 'Length 42 mm' }, pocket: { label: 'Pocket', detail: 'Through all' }, revolution: { label: 'Revolution', detail: 'Angle 360 deg' }, @@ -675,6 +679,7 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable = {}): { document: DocumentSnapshot; objectId: string } => { const primitiveType = commandId === 'primitive' && ['Box', 'Cylinder', 'Sphere', 'Ellipsoid', 'Cone', 'Torus', 'Prism', 'Wedge'].includes(String(draft.primitiveType)) ? String(draft.primitiveType) : 'Box' - const definition = commandId === 'primitive' ? { label: primitiveType, detail: primitiveType === 'Box' ? '10 × 10 × 10 mm' : `${primitiveType} primitive` } : featureCommands[commandId] + const requestedDatumType = draft.datumType === 'Line' || draft.datumType === 'Point' ? draft.datumType : 'Plane' + const definition = commandId === 'primitive' + ? { label: primitiveType, detail: primitiveType === 'Box' ? '10 × 10 × 10 mm' : `${primitiveType} primitive` } + : commandId === 'datum' + ? { label: `Datum ${requestedDatumType}`, detail: 'Attached datum reference' } + : featureCommands[commandId] if (!definition) return { document, objectId: '' } const objectId = nextFeatureId(definition.label) const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature' const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail } const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined })) const isPartObject = partCommands.has(commandId) - if (type === 'body' || isPartObject) tree.push({ ...item, children: type === 'body' ? [] : undefined }) + if (type === 'body') { + for (const body of tree.filter((entry) => entry.type === 'body')) if (body.state === 'active') body.state = 'valid' + tree.push({ ...item, children: [] }) + } else if (isPartObject) tree.push({ ...item, children: undefined }) else { - const body = tree.find((entry) => entry.type === 'body') + const body = [...tree].reverse().find((entry) => entry.type === 'body' && entry.state === 'active') ?? tree.find((entry) => entry.type === 'body') if (body) body.children = [...(body.children || []), objectId] tree.push(item) } const objectSnapshot = createObjectSnapshot(item) + if (commandId === 'create-body') { + const tip = objectSnapshot.properties.find((property) => property.name === 'Tip') + if (tip) tip.value = null + } const sourceId = typeof draft.source === 'string' && document.objects.some((object) => object.id === draft.source) ? draft.source : '' const sourceObject = sourceId ? document.objects.find((object) => object.id === sourceId) : undefined const currentSubshape = state.selectedSubshape const selectedEdge = currentSubshape && currentSubshape.objectId === sourceObject?.id && currentSubshape.ref.kind === 'edge' ? currentSubshape : null const selectedFace = currentSubshape && currentSubshape.objectId === sourceObject?.id && currentSubshape.ref.kind === 'face' ? currentSubshape : null - const bodyTipValue = document.objects.find((object) => object.typeId === 'PartDesign::Body')?.properties.find((property) => property.name === 'Tip')?.value + const activeBodyItem = [...tree].reverse().find((entry) => entry.type === 'body' && entry.state === 'active') ?? tree.find((entry) => entry.type === 'body') + if (['datum', 'shape-binder'].includes(commandId) && !activeBodyItem) throw new Error(`${definition.label} requires an active Body.`) + const bodyTipValue = document.objects.find((object) => object.id === activeBodyItem?.id)?.properties.find((property) => property.name === 'Tip')?.value const bodyTip = typeof bodyTipValue === 'string' && document.objects.some((object) => object.id === bodyTipValue && shapeTypeIds.has(object.typeId)) ? bodyTipValue : null const sectionDraft = Array.isArray(draft.sections) && draft.sections.every((entry) => typeof entry === 'string') ? [...draft.sections] as string[] : sourceObject?.typeId === 'Sketcher::SketchObject' ? [sourceObject.id] : [] const pipeProfile = typeof draft.profile === 'string' ? draft.profile : sourceObject?.typeId === 'Sketcher::SketchObject' ? sourceObject.id : null @@ -789,6 +808,33 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW signature: selectedFace.ref.signature, candidates: selectedFace.ref.candidates ? [...selectedFace.ref.candidates] : undefined, } : null + if (commandId === 'datum') { + const datumType = requestedDatumType + const typeId = `PartDesign::${datumType}` + const support = selectedFaceRef ?? selectedEdgeRef ?? (sourceObject ? { schemaVersion: 1 as const, objectId: sourceObject.id, subElements: [] } : null) + objectSnapshot.typeId = typeId + objectSnapshot.properties = [ + ...commonProperties({ ...item, label: `Datum ${datumType}` }).map((property) => property.name === 'TypeId' ? { ...property, value: typeId } : property), + { name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: support, recompute: true }, + { name: 'MapMode', label: 'Map mode', group: 'Attachment', scope: 'data', type: 'App::PropertyEnumeration', value: selectedFaceRef ? 'FlatFace' : selectedEdgeRef ? 'NormalToEdge' : support ? 'ObjectXY' : 'Deactivated', options: [...ATTACHMENT_MAP_MODES], recompute: true }, + { name: 'AttachmentOffset', label: 'Attachment offset', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }, recompute: true }, + { name: 'DatumType', label: 'Datum type', group: 'Datum', scope: 'data', type: 'App::PropertyEnumeration', value: datumType, options: ['Plane', 'Line', 'Point'], recompute: true }, + ...viewProperties(), + ] + } + if (commandId === 'shape-binder') { + const support = selectedFaceRef ?? selectedEdgeRef ?? (sourceObject ? { schemaVersion: 1 as const, objectId: sourceObject.id, subElements: [] } : null) + if (!support) throw new RangeError('Shape Binder requires a source object or stable sub-shape.') + objectSnapshot.typeId = 'PartDesign::ShapeBinder' + objectSnapshot.properties = [ + ...commonProperties(item).map((property) => property.name === 'TypeId' ? { ...property, value: 'PartDesign::ShapeBinder' } : property), + { name: 'Support', label: 'Support', group: 'Binder', scope: 'data', type: 'App::PropertyLinkSub', value: support, recompute: true }, + { name: 'BindMode', label: 'Bind mode', group: 'Binder', scope: 'data', type: 'App::PropertyEnumeration', value: 'Synchronized', options: ['Synchronized', 'Frozen'], recompute: true }, + { name: 'TraceSupport', label: 'Trace support', group: 'Binder', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true }, + { name: 'ClaimChildren', label: 'Claim children', group: 'Binder', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + ...viewProperties(), + ] + } if (commandId === 'extrude-part') { objectSnapshot.typeId = 'Part::Extrusion' objectSnapshot.properties = [ @@ -963,7 +1009,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW if (Number(objectSnapshot.properties.find((property) => property.name === 'Depth')?.value) <= 0) throw new RangeError('Hole depth must be greater than zero.') } const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value) })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot] - const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined + const tipObject = type === 'feature' && partDesignCommands.has(commandId) && !['create-sketch', 'datum', 'shape-binder'].includes(commandId) ? objects.find((object) => object.id === activeBodyItem?.id) : undefined if (tipObject) { const tip = tipObject.properties.find((property) => property.name === 'Tip') if (tip) tip.value = objectId @@ -1404,6 +1450,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW } const facade: BitBybitWebCadFacade = { + runtime, app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId), state.selectedSubshape), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, @@ -1473,6 +1520,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW export function createMockFacade(): BitBybitWebCadFacade { return createWebCadFacade({ + runtimeMode: 'mock', initialDocument: createPumpHousingDemoDocument(), initialSelectedObjectIds: PUMP_HOUSING_DEMO_TEMPLATE.selectedObjectIds, }) diff --git a/src/facade/nativeHistoryProtocol.ts b/src/facade/nativeHistoryProtocol.ts index 20f47da..7c9ec49 100644 --- a/src/facade/nativeHistoryProtocol.ts +++ b/src/facade/nativeHistoryProtocol.ts @@ -2,6 +2,18 @@ import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctH export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const +export type NativeNamingAbiCapabilities = { + mappedNameRef: 'available' | 'optional' | 'unavailable' + stringHasher: 'available' | 'opaque-preserved' | 'unavailable' + tokenGeneration: 'native-only' | 'forbidden' +} + +export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({ + mappedNameRef: 'unavailable', + stringHasher: 'unavailable', + tokenGeneration: 'forbidden', +}) + export type NativeOcctHistoryCapabilities = { providerId: string providerVersion: string @@ -9,9 +21,16 @@ export type NativeOcctHistoryCapabilities = { availability: 'available' | 'unavailable' operations: NativeOcctHistoryOperation[] transport: 'step-text' + /** Private FreeCAD naming callbacks are intentionally never synthesized by this bridge. */ + naming?: NativeNamingAbiCapabilities reason?: string } +export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({ + ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, + ...(capabilities.naming ?? {}), +}) + export type NativeOcctHistoryRequest = { protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION requestId: string diff --git a/src/facade/nativeHistoryWorkerEntry.ts b/src/facade/nativeHistoryWorkerEntry.ts index abe86e7..a7668ee 100644 --- a/src/facade/nativeHistoryWorkerEntry.ts +++ b/src/facade/nativeHistoryWorkerEntry.ts @@ -1,7 +1,7 @@ /// import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider' -import type { NativeOcctHistoryCapabilities, NativeOcctHistoryRequest, NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol' +import { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol' type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'capture'; request: NativeOcctHistoryRequest } | { type: 'cancel'; requestId: string } | { type: 'dispose' } type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string } @@ -32,6 +32,7 @@ const initialize = async (moduleUrl: string) => { availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', + naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, }, }) } @@ -80,7 +81,7 @@ scope.onmessage = ({ data }: MessageEvent) => { : provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation) if (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.') if (cancelled.delete(data.request.requestId)) return - send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text' }, history } }) + send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE }, history } }) } catch (error) { send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) }) } diff --git a/src/facade/productionFacade.ts b/src/facade/productionFacade.ts index 2687d84..9e8f8dd 100644 --- a/src/facade/productionFacade.ts +++ b/src/facade/productionFacade.ts @@ -1,8 +1,8 @@ -import { createWebCadFacade, type WebCadFacadeOptions } from './mockFacade' +import { createWebCadFacade, type WebCadFacadeOptions } from './webCadFacade' import type { BitBybitWebCadFacade } from './types' export type ProductionFacadeOptions = WebCadFacadeOptions export function createProductionFacade(options: ProductionFacadeOptions = {}): BitBybitWebCadFacade { - return createWebCadFacade(options) + return createWebCadFacade({ ...options, runtimeMode: 'production' }) } diff --git a/src/facade/runtimeProfile.ts b/src/facade/runtimeProfile.ts new file mode 100644 index 0000000..1d77b06 --- /dev/null +++ b/src/facade/runtimeProfile.ts @@ -0,0 +1,39 @@ +export type FacadeRuntimeMode = 'production' | 'mock' + +export type FacadeRuntimeProfile = { + mode: FacadeRuntimeMode + geometry: { + engine: 'bitbybit-occt' + execution: 'facade-runtime' + compatibility: 'none' | 'demo-bootstrap' + } + nativeHistory: { + transport: 'optional-worker' + availability: 'configured-at-application-boundary' | 'not-configured' + } + naming: { + nativeAbi: 'not-exposed' + preservation: 'opaque-preserved' + } + persistence: 'sqlite-opfs-with-memory-fallback' + viewport: 'three-webgl2' +} + +export const createFacadeRuntimeProfile = (mode: FacadeRuntimeMode): FacadeRuntimeProfile => ({ + mode, + geometry: { + engine: 'bitbybit-occt', + execution: 'facade-runtime', + compatibility: mode === 'mock' ? 'demo-bootstrap' : 'none', + }, + nativeHistory: { + transport: 'optional-worker', + availability: 'not-configured', + }, + naming: { + nativeAbi: 'not-exposed', + preservation: 'opaque-preserved', + }, + persistence: 'sqlite-opfs-with-memory-fallback', + viewport: 'three-webgl2', +}) diff --git a/src/facade/types.ts b/src/facade/types.ts index 8eda922..e876aff 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -5,6 +5,7 @@ import type { SketchConstraint, SketchExternalGeometry, SketchGeometry, SketchSn import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine' import type { FcstdArchiveLimits, FcstdInspection, FcstdInstantiatedShape, FcstdPathEdit, FcstdShapeResourcePayload, FcstdStoredShapeResource, FcstdWriteOptions } from './fcstd' import type { NativeOcctHistoryCapabilities, NativeOcctHistoryProvider } from './nativeHistoryProtocol' +import type { FacadeRuntimeProfile } from './runtimeProfile' import type { CamApi } from './cam' import type { NativeStageNamingEvidence } from './nativeNamingEvidence' @@ -851,6 +852,7 @@ export type ExecuteCommandInput = { } export interface BitBybitWebCadFacade { + readonly runtime: FacadeRuntimeProfile readonly app: { document: { getActive(): DocumentSnapshot diff --git a/src/facade/webCadFacade.ts b/src/facade/webCadFacade.ts new file mode 100644 index 0000000..e2a6d9b --- /dev/null +++ b/src/facade/webCadFacade.ts @@ -0,0 +1,3 @@ +// Stable production-facing entry for the shared Facade core. +export { createWebCadFacade } from './mockFacade' +export type { WebCadFacadeOptions } from './mockFacade' diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 3a14199..187600e 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -16,7 +16,7 @@ import { createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, va import { runTopologyMutationReplay } from '../src/facade/topologyReplay' import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory' import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider' -import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol' +import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, nativeNamingAbiCapabilities, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol' import { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient' import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy' import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher' @@ -65,6 +65,14 @@ test('facade exposes a stable initial document projection', () => { assert.equal(state.document.id, 'doc-pump-housing') assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body') assert.equal(facade.gui.workbench.getActive(), 'Part Design') + assert.deepEqual(facade.runtime, { + mode: 'mock', + geometry: { engine: 'bitbybit-occt', execution: 'facade-runtime', compatibility: 'demo-bootstrap' }, + nativeHistory: { transport: 'optional-worker', availability: 'not-configured' }, + naming: { nativeAbi: 'not-exposed', preservation: 'opaque-preserved' }, + persistence: 'sqlite-opfs-with-memory-fallback', + viewport: 'three-webgl2', + }) assert.equal(facade.geometry.capabilities().provider, 'Bitbybit OCCT') assert.deepEqual({ shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount, releasedShapeCount: facade.geometry.capabilities().releasedShapeCount }, { shapeCount: 0, kernelReferenceCount: 0, releasedShapeCount: 0 }) }) @@ -77,6 +85,9 @@ test('production facade starts from an empty document unless an application boot assert.deepEqual(state.document.tree, []) assert.deepEqual(state.document.objects, []) assert.deepEqual(state.selectedObjectIds, []) + assert.equal(facade.runtime.mode, 'production') + assert.equal(facade.runtime.geometry.compatibility, 'none') + assert.equal(facade.runtime.naming.nativeAbi, 'not-exposed') facade.geometry.dispose() }) @@ -686,6 +697,12 @@ test('native OCCT history protocol validates STEP context and isolates stale gen assert.equal(staleExecution.status, 'stale') }) +test('native naming ABI capabilities forbid synthetic FreeCAD tokens unless a provider declares native evidence', () => { + const base = { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available' as const, operations: ['cut' as const], transport: 'step-text' as const } + assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', tokenGeneration: 'forbidden' }) + assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' }) +}) + test('native OCCT history coordinator reports timeout and cancellation', async () => { const hanging: NativeOcctHistoryProvider = { capabilities: () => ({ providerId: 'test', providerVersion: '1', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }), capture: async (_request, signal) => await new Promise((_, reject) => { signal.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true }) }) } const timedOut = await new NativeOcctHistoryCoordinator(() => 1, 5).capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-timeout', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }) @@ -5446,6 +5463,41 @@ test('Part workbench commands create primitive and boolean document objects', () assert.equal(facade.gui.command.getState('check-shape').status, 'disabled') }) +test('Datum and ShapeBinder stay in the active Body without stealing its Tip and survive FCStd metadata projection', () => { + const facade = createMockFacade() + facade.gui.command.execute({ commandId: 'create-body' }) + facade.task.apply() + + const initial = facade.app.document.getActive() + assert.equal(initial.tree.find((item) => item.id === 'body')?.state, 'valid') + assert.equal(initial.tree.find((item) => item.id === 'body001')?.state, 'active') + assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null) + + facade.gui.command.execute({ commandId: 'datum' }) + facade.task.update({ datumType: 'Line' }) + facade.task.apply() + const datum = facade.app.document.getObject('datum-line') + assert.equal(datum?.typeId, 'PartDesign::Line') + assert.equal(datum?.properties.find((property) => property.name === 'DatumType')?.value, 'Line') + assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'body001')?.children?.includes('datum-line'), true) + assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null) + + facade.selection.select('pad') + facade.gui.command.execute({ commandId: 'shape-binder' }) + facade.task.apply() + const binder = facade.app.document.getObject('shape-binder') + assert.equal(binder?.typeId, 'PartDesign::ShapeBinder') + assert.deepEqual(binder?.properties.find((property) => property.name === 'Support')?.value, { schemaVersion: 1, objectId: 'pad', subElements: [] }) + assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'body001')?.children?.includes('shape-binder'), true) + assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null) + assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'shape-binder').map((edge) => edge.targetId), ['pad']) + + const inspection = inspectFcstdArchive(serializeFcstdMetadataArchive(facade.app.document.getActive())) + assert.equal(inspection.objects.find((object) => object.name === 'datum-line')?.support, 'recognized') + assert.equal(inspection.objects.find((object) => object.name === 'shape-binder')?.support, 'recognized') + facade.geometry.dispose() +}) + test('Part Design loft and pipe tasks preserve ordered Body Tip bases', () => { const facade = createMockFacade() facade.gui.command.execute({ commandId: 'create-sketch' })