feat: advance FreeCAD parity verification and runtime boundaries
This commit is contained in:
25
.github/workflows/real-verification.yml
vendored
25
.github/workflows/real-verification.yml
vendored
@@ -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]
|
||||
|
||||
45
config/browser-matrix-verification.json
Normal file
45
config/browser-matrix-verification.json
Normal file
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"wrong-document-root": 77
|
||||
},
|
||||
"timing": {
|
||||
"p95Ms": 0.121,
|
||||
"maxMs": 2.672
|
||||
"p95Ms": 0.137,
|
||||
"maxMs": 2.691
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"zero-revolution-axis": 67
|
||||
},
|
||||
"timing": {
|
||||
"p95Ms": 0.021,
|
||||
"maxMs": 1.033
|
||||
"p95Ms": 0.02,
|
||||
"maxMs": 1.133
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"invalid": 800
|
||||
},
|
||||
"timing": {
|
||||
"p95Ms": 0.03,
|
||||
"maxMs": 1.012
|
||||
"p95Ms": 0.032,
|
||||
"maxMs": 0.733
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,不接受旧报告代替。
|
||||
|
||||
@@ -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/依赖审计。
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
@@ -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",
|
||||
|
||||
13
scripts/check-browser-matrix.mjs
Normal file
13
scripts/check-browser-matrix.mjs
Normal file
@@ -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))
|
||||
@@ -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).`)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
90
scripts/run-browser-matrix.mjs
Normal file
90
scripts/run-browser-matrix.mjs
Normal file
@@ -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.')
|
||||
@@ -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(', ')}.`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<DocumentSnapshot, 'objects'>): 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<string, { label: string; detail: string }> = {
|
||||
'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<str
|
||||
}
|
||||
|
||||
export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitWebCadFacade {
|
||||
const runtime = createFacadeRuntimeProfile(options.runtimeMode ?? 'production')
|
||||
const projectPersistence = createSqliteProjectPersistence()
|
||||
const geometryRuntime = new BitbybitGeometryRuntime()
|
||||
const camJob = createCamJob('Job', 'CAM Job', { min: [0, 0, 0], max: [10, 10, 10], mode: 'from-base-bound-box' })
|
||||
@@ -743,26 +748,40 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
}
|
||||
const appendFeature = (document: DocumentSnapshot, commandId: string, draft: Record<string, unknown> = {}): { 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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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<WorkerRequest>) => {
|
||||
: 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) })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
39
src/facade/runtimeProfile.ts
Normal file
39
src/facade/runtimeProfile.ts
Normal file
@@ -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',
|
||||
})
|
||||
@@ -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
|
||||
|
||||
3
src/facade/webCadFacade.ts
Normal file
3
src/facade/webCadFacade.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Stable production-facing entry for the shared Facade core.
|
||||
export { createWebCadFacade } from './mockFacade'
|
||||
export type { WebCadFacadeOptions } from './mockFacade'
|
||||
@@ -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<never>((_, 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' })
|
||||
|
||||
Reference in New Issue
Block a user