diff --git a/.gitignore b/.gitignore index 4481db0..31d8998 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist/ .vite/ .runtime/ .cache/ +native/occt-history/dist/ +public/native/occt-history/ __pycache__/ *.log .env diff --git a/README.md b/README.md index 016fe51..f1ffd9b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary. - FreeCAD-style application menus, workbench navigation, Combo View, Task Dock, viewport and Report view - Machine-readable workbench and command manifest in `src/freecadManifest.ts` - Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md` +- Full 34-module FreeCAD 1.1.1 scope, Bitbybit/OCCT source-build track and executable task ledger in [docs/freecad-complete-parity-backlog.zh-CN.md](docs/freecad-complete-parity-backlog.zh-CN.md) +- Executable reference-oracle and R0-R9 delivery order in [docs/freecad-reference-execution-roadmap.zh-CN.md](docs/freecad-reference-execution-roadmap.zh-CN.md) The current milestone is a facade-first integration slice. Part Design document transactions and history are executable in the mock domain adapter; the internal SQLite WASM + OPFS Persistence Worker, schema and in-memory fallback are bundled behind `facade.project`. FreeCAD/OCCT geometry, solver behavior, FCStd interoperability and full workbench semantics remain staged behind the documented P3-P7 gates. @@ -29,4 +31,6 @@ Build verification: The locked FreeCAD `1.1.1` source build and desktop-oracle replay workflow is documented in [docs/freecad-baseline-and-golden.zh-CN.md](docs/freecad-baseline-and-golden.zh-CN.md). All project Node commands in that workflow use `./npmw`. +Geometry work follows a Bitbybit-first policy. Existing Bitbybit OCCT Worker, modeling, IO, triangulation and cache APIs are reused first; missing capabilities may be built from locked Bitbybit/OCCT sources as an Emscripten WASM provider behind the Facade. Source commits, patches, ABI, SBOM, hashes and FreeCAD differential fixtures are required before a capability can be promoted. + The SQLite OPFS worker requires cross-origin isolation headers. Vite dev and preview apply `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`; deployments must preserve them. diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index fb70d9c..5cc18ce 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -14,14 +14,102 @@ "FEM": { "status": "ui-manifest", "level": "unsupported", "commands": ["new-analysis", "material", "mesh", "solve", "results"] }, "Mesh": { "status": "ui-prototype", "level": "read-only", "commands": ["mesh-import", "analyze-mesh", "repair-mesh", "fill-holes", "mesh-export"] }, "Surface": { "status": "ui-manifest", "level": "unsupported", "commands": ["bspline", "bezier", "fill-surface", "loft-surface", "trim-surface"] }, - "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } + "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] }, + "AddonManager": { "status": "scope-only", "level": "unsupported", "commands": ["addon-list", "addon-install", "addon-update", "addon-remove"] }, + "Cloud": { "status": "scope-only", "level": "unsupported", "commands": ["cloud-login", "cloud-open", "cloud-save", "cloud-share"] }, + "Fem": { "status": "scope-only", "level": "unsupported", "commands": ["new-analysis", "material", "mesh", "solve", "results"] }, + "Help": { "status": "scope-only", "level": "unsupported", "commands": ["help-contents", "help-search", "help-about"] }, + "Idf": { "status": "scope-only", "level": "unsupported", "commands": ["idf-import", "idf-export"] }, + "Import": { "status": "scope-only", "level": "unsupported", "commands": ["import-model", "import-batch", "import-options"] }, + "JtReader": { "status": "scope-only", "level": "unsupported", "commands": ["jt-import", "jt-assembly", "jt-export"] }, + "Material": { "status": "scope-only", "level": "unsupported", "commands": ["material-card", "material-library", "material-assign"] }, + "Measure": { "status": "scope-only", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "measure-volume"] }, + "MeshPart": { "status": "scope-only", "level": "unsupported", "commands": ["mesh-to-shape", "shape-to-mesh", "mesh-section"] }, + "OpenSCAD": { "status": "scope-only", "level": "unsupported", "commands": ["openscad-import", "openscad-export", "openscad-preview"] }, + "Plot": { "status": "scope-only", "level": "unsupported", "commands": ["plot-create", "plot-style", "plot-export"] }, + "Points": { "status": "scope-only", "level": "unsupported", "commands": ["points-import", "points-filter", "points-export"] }, + "ReverseEngineering": { "status": "scope-only", "level": "unsupported", "commands": ["fit-curve", "fit-surface", "reverse-engineer"] }, + "Robot": { "status": "scope-only", "level": "unsupported", "commands": ["robot-model", "robot-trajectory", "robot-simulate", "robot-export"] }, + "Show": { "status": "scope-only", "level": "unsupported", "commands": ["show-object", "show-share", "show-presentation"] }, + "Start": { "status": "scope-only", "level": "unsupported", "commands": ["start-page", "recent-projects", "templates"] }, + "Web": { "status": "scope-only", "level": "unsupported", "commands": ["web-export", "web-view", "web-share"] }, + "Sandbox": { "status": "scope-only", "level": "unsupported", "commands": [] }, + "TemplatePyMod": { "status": "scope-only", "level": "unsupported", "commands": [] }, + "Test": { "status": "scope-only", "level": "unsupported", "commands": [] }, + "Tux": { "status": "scope-only", "level": "unsupported", "commands": [] } + }, + "sourceScope": { + "baseline": "FreeCAD 1.1.1 src/Mod", + "moduleCount": 34, + "modules": ["AddonManager", "Assembly", "BIM", "CAM", "Cloud", "Draft", "Fem", "Help", "Idf", "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh", "MeshPart", "OpenSCAD", "Part", "PartDesign", "Plot", "Points", "ReverseEngineering", "Robot", "Sandbox", "Show", "Sketcher", "Spreadsheet", "Start", "Surface", "TechDraw", "TemplatePyMod", "Test", "Tux", "Web"], + "coreDirectories": ["App", "Base", "Gui", "Main"], + "coverageRule": "Every module, command, TypeId, Property, file format, script/addon entry and browser limitation must have a task ID and a compatibility level before G9. Scope-only means registered but not implemented." + }, + "sourceModuleAliases": { + "Fem": "FEM", + "PartDesign": "Part Design", + "TechDraw": "TechDraw", + "MeshPart": "Mesh" + }, + "geometryBuildStrategy": { + "primary": "reuse-bitbybit", + "fallback": ["extend-bitbybit-binding", "custom-occt-binding"], + "sourceTasks": ["BYB-01", "BYB-02", "BYB-03", "BYB-04", "BYB-05", "BYB-06", "BYB-07", "BYB-08"], + "constraints": ["Facade-only React boundary", "Emscripten reproducible build", "ABI and protocol versioning", "SBOM/license/hash evidence", "Bitbybit-FreeCAD golden differential"] + }, + "bitbybitSource": { + "repository": "https://github.com/bitbybit-dev/bitbybit", + "localSnapshot": ".cache/bitbybit/bitbybit", + "commit": "d3f52bd1fcf78148b60800d69ed6b42e53f4ad06", + "version": "1.1.1", + "occtPackage": "packages/dev/occt", + "workerPackage": "packages/dev/occt-worker", + "sourceForm": "generated-wasm-and-typescript-only", + "missingBuildInputs": ["OCCT C++ source checkout", "Embind generation rules", "Emscripten link/export manifest"], + "evidence": "./npmw run check:bitbybit-history" + }, + "nativeOcctHistory": { + "status": "built-provider-not-default-runtime", + "sourceCheckout": "OCCT_SOURCE_DIR", + "sourceCommit": "bb75017e915f6509a4bfa8b551f853352754c2da", + "occtVersion": "8.0.0", + "toolchain": "Emscripten 3.1.69 + CMake 3.31.6 + Ninja 1.12.1", + "library": ["TKBO", "TKDESTEP", "TKXSBase", "TKDE"], + "bindings": ["BRepAlgoAPI_Fuse", "BRepAlgoAPI_Cut", "BRepAlgoAPI_Common", "Generated", "Modified", "IsDeleted", "TopExp_Explorer", "STEPControl_Reader", "STEPControl_Writer", "shapeToStep", "booleanHistoryFromStep"], + "buildOptimization": "OCCT dependencies -O0 with Emscripten -fexceptions; Embind wrapper -O2 with -fexceptions", + "artifact": { + "javascript": "native/occt-history/dist/bitbybit-occt-history.js", + "wasm": "native/occt-history/dist/bitbybit-occt-history.wasm", + "javascriptSha256": "e2feca5a8bab431e3351e6af3972beef4f9ce47fe02de2d0ac7ba9de91882311", + "wasmSha256": "6fac28102132ddeb8e7f1f254c76643d595cc60a26b2adf80fe3d6407b18b675" + }, + "protocol": "native result and history records stay inside the provider; cross-WASM handles are forbidden; STEP text is the explicit transport boundary", + "historyProtocol": { + "version": 1, + "request": "NativeOcctHistoryRequest", + "response": "NativeOcctHistoryProtocolResponse", + "transport": "step-text", + "cancellation": "AbortSignal", + "timeout": "coordinator-configured", + "staleGuard": "documentId + documentVersion + operationId" + }, + "browserWorker": { + "entry": "src/facade/nativeHistoryWorkerEntry.ts", + "client": "src/facade/nativeHistoryWorkerClient.ts", + "moduleUrl": "/native/occt-history/bitbybit-occt-history.js", + "runtimeConfiguration": "BitbybitGeometryRuntime.configureNativeHistory(provider, timeoutMs)", + "artifactPublishCommand": "./npmw run build:occt-history", + "status": "worker-provider-configurable-not-default-runtime" + }, + "buildCommand": "./npmw run build:occt-history", + "testCommand": "./npmw run test:occt-history" }, "facadeCapabilities": { "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "multi-transform-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] }, - "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "undo-redo"] }, + "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "native-topology-history-adapter", "undo-redo"] }, "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v6", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] }, "sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] }, - "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }, + "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "document-property-projection", "proxy-report", "script-isolation"] }, "verification": { "level": "experimental", "provider": "FreeCADCmd 1.1.1 native headless Part oracle", "operations": ["locked-source-commit", "reproducible-native-build", "declarative-golden-contract", "part-geometry-replay", "numeric-tolerance-comparison"] } }, "rules": [ diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index 2a3c23e..377e7fb 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -54,7 +54,7 @@ - Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。 - 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。 - Placement 已进入文档属性,并接入 Facade 的对象级 Shape 重算;尚未完整映射到 Body/Tip、Support、AttachmentOffset 或所有特征执行器的 FreeCAD 局部坐标链语义。 -- FCStd 仍以安全预检、metadata/proxy 和只读报告为主,尚未双向原生往返。 +- FCStd 仍以安全预检、metadata/proxy 和只读报告为主;本轮新增对象属性名、FreeCAD 类型、XML 元素和值的字段级 `Document.xml` 投影,但尚未双向原生往返。 ## 5. 下一步执行顺序 @@ -154,3 +154,52 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文 - 全部 Node/npm 操作均使用 `./npmw`;依赖、构建、回放和 CI 边界已写入 `docs/freecad-baseline-and-golden.zh-CN.md`。 本轮只完成 P0-01/P0-03 的首批可复现基线。依赖容器 digest、完整 FreeCAD 工作台构建、100 个黄金文件、50 个错误文件、GUI 行为和 Web OCCT 双端差分尚未完成,F0/G0 保持进行中。下一执行点是读取 FreeCAD/OCCT 原生 Generated/Modified/Deleted 历史接口并形成可供 Web `TopoRef` 消费的类型化历史合同。 + +## 14. 2026-08-03 全功能范围账本与 Bitbybit 构建轨道 + +新增 [FreeCAD 1.1.1 全功能对标主任务账本](freecad-complete-parity-backlog.zh-CN.md),以锁定源码 `src/Mod` 的 34 个模块和 `App/Base/Gui/Main` 通用能力为完整范围。账本把任务拆成 `INV`、`BASE`、`BYB`、`CORE`、`TSN`、`SK`、`PART`、`PD`、`FC`、各工作台任务和 `QA/REL` 门禁;源码中的 Sandbox/模板/测试设施以及 Python、宏、插件、偏好、打印和剪贴板均必须有明确能力等级,不得隐藏在“其他”中。 + +几何实现改为 Bitbybit 优先:先复用 Bitbybit 的 OCCT Worker、建模、IO、三角化、缓存和资源生命周期;公开 API 不足时,从锁定的 Bitbybit/OCCT 源码扩展绑定并用 Emscripten 构建自有 WASM provider。对应 `BYB-01..08` 要求来源提交、补丁、ABI/消息协议、SBOM/许可证、产物 hash、句柄回收和 Bitbybit↔自构建 provider↔FreeCAD 三方黄金差分。React 仍只能通过 `BitBybitWebCadFacade` 访问几何。 + +兼容矩阵新增 `sourceScope`(34 个模块)和 `geometryBuildStrategy`,并把 AddonManager、Cloud、Fem、Help、Idf、Import、JtReader、Material、Measure、MeshPart、OpenSCAD、Plot、Points、ReverseEngineering、Robot、Show、Start、Web 及开发/测试目录登记为 `scope-only/unsupported`,直到各自任务与证据完成。现有 Part/PartDesign/Sketcher/持久化/FCStd 等状态不提升,仍按实验或只读边界执行。 + +本机 `FreeCADCmd` reference probe 已接入 `./npmw run probe:freecad-reference`:实测报告为 FreeCAD 1.1.1、commit `0108fd4b4850cc46e625b60e53cea7a7bbe69f8d`、`guiUp=false`;当前构建可加载 `Part`、`Material`、`Measure`,Sketcher/PartDesign/TechDraw/Spreadsheet/Draft/Mesh/Fem/CAM/Assembly 明确 unavailable。探针会采集核心 TypeId 和 Property 元数据,作为 `INV-02` 和后续黄金夹具的输入;完整桌面工作台行为仍需启用对应 FreeCAD 原生模块后再回放。 + +本轮已把 `geometry.topologyHistory()` provider 接入 `createFacadeGeometryRecomputeExecutor`:对有 Base/Tool/Profile 拓扑输入的特征,若 Worker 提供原生记录,则使用 `captureNativeTopologyHistory()` 按 kind 和 source/result index 生成 `occt-native` 历史;provider 缺失、无输入或不支持时继续使用 `signature-fallback`。新增原生关系映射、删除、重复记录和索引越界测试,完整门禁 68/68 通过。该接线不等于 OCCT 原生历史已可用,真实 Bitbybit/OCCT 自构建绑定仍由 `BYB-04..07` 完成。 + +新增 `check:bitbybit-history` 绑定面审计并纳入 `./npmw run verify`。审计结果明确记录当前 npm 绑定的原生历史缺口,防止 `occt-native` 合同在没有 Generated/Modified/IsDeleted 实现时被误报为完成。 + +BYB-01/02 证据已锁定:本地 Bitbybit 源码快照为 `d3f52bd1fcf78148b60800d69ed6b42e53f4ad06`(版本 `1.1.1`),记录于 `config/compatibility-matrix.json`;门禁会同时校验源码提交与安装包版本。 + +本轮已完成 BYB-04 的第一版实际编译:`native/occt-history` 从缓存 OCCT `bb75017e915f6509a4bfa8b551f853352754c2da`(8.0.0)构建 `TKBO`、`TKDESTEP`、`TKXSBase`、`TKDE` 和 Embind WASM,`./npmw run test:occt-history` 通过,`shapeToStep -> booleanHistoryFromStep(cut)` smoke case 返回 42 条原生关系记录。`mapNativeOcctHistoryRecords` 与 `createNativeOcctStepHistoryBridge` 已把 provider 协议接入 Facade 类型边界。该 provider 当前仍是独立 WASM 实例;跨 Bitbybit 主 WASM 只能通过 STEP 文本序列化交换,不能直接传递指针。 + +## 15. 2026-08-03 原生 OCCT 历史 + STEP 跨 WASM 桥接 + +- `native/occt-history` 现从 OCCT `bb75017e...` 编译 `TKBO`、`TKDESTEP`、`TKXSBase` 和 `TKDE`,Embind 暴露 `Generated`、`Modified`、`IsDeleted`、`STEPControl_Reader/Writer`、`shapeToStep` 与 `booleanHistoryFromStep`。 +- 原生 smoke 已验证 `shapeToStep(makeBox()) -> STEP text -> booleanHistoryFromStep()`,`cut` 返回 42 条历史记录;最终 wasm 在 Node 22.23.2 可实例化。 +- 由于当前 Emscripten/OCCT 大型静态链接的 wasm 异常控制流限制,依赖库固定 `-O0 -fexceptions`,Embind wrapper 固定 `-O2 -fexceptions`;产物 hash 记录在兼容矩阵并由 `check:bitbybit-history` 校验。 +- `createNativeOcctStepHistoryBridge()` 已加入 Facade:它只把 Bitbybit `exportStep()` 的文本交给原生 provider,禁止传递跨 WASM ShapeHandle;重算器仅对 `Part::Fuse/Cut/Common` 注入明确 operation,其余特征继续签名回退。 + +该里程碑仍不是默认 GeometryRuntime,也没有把所有 PartDesign 特征切换到原生 provider;本轮已补齐 `BitbybitGeometryRuntime.configureNativeHistory()`,显式配置后 Boolean 重算会通过 STEP 和协调器消费原生记录。浏览器真实 WASM 实例化、STEP 精度/单位策略和 Bitbybit↔原生↔FreeCAD 三方黄金差分仍是 `BYB-05..08`、`TSN-03..10` 后续门禁。 + +## 16. 2026-08-03 原生历史协议与故障隔离 + +- 新增 `NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1`,请求携带 `documentId/documentVersion/operationId` 和两份 ISO-10303-21 STEP 文本,响应携带 provider 能力、原生历史和上下文回显。 +- `DirectNativeOcctHistoryProvider` 绑定已编译 Embind 模块;`NativeOcctHistoryCoordinator` 统一处理 AbortSignal、超时、取消、stale 文档版本和并发 generation,未把任何 ShapeHandle 放进消息协议。 +- Facade 测试新增协议上下文、STEP 格式拒绝、timeout/cancel/stale 覆盖;完整测试数更新为 72/72。 + +该协议已完成 BYB-05 的类型/合同层,并新增 `NativeOcctHistoryWorkerProvider`/Worker 入口;构建脚本会把产物发布到 `/native/occt-history/`,`BitbybitGeometryRuntime` 可显式配置该 provider 并把 STEP 历史接入 Facade 重算。跨浏览器真实 WASM 实例化、Worker 崩溃恢复和三方黄金差分仍需单独验证,不能把 Node 原生 smoke 当作浏览器证据。 + +本轮文档变更验证必须使用项目包装器: + +```bash +./npmw run verify +git diff --check +jq empty config/compatibility-matrix.json +``` + +## 17. 2026-08-03 本机 FreeCAD 参考与后续安排 + +再次执行 `./npmw run probe:freecad-reference` 已确认锁定 oracle 为 FreeCAD `1.1.1`、提交 `0108fd4b4850cc46e625b60e53cea7a7bbe69f8d`。当前缓存构建 `guiUp=false`,可加载 `Part/Material/Measure`;其余工作台是构建缺失证据,不是对应 Web 功能的行为 oracle。 + +新增 [FreeCAD 1.1.1 全功能对标执行路线图](freecad-reference-execution-roadmap.zh-CN.md),按 R0 参考环境、R1 几何/拓扑、R2 Sketcher、R3 参数化核心、R4 FCStd、R5-R8 工作台与平台长尾、R9 发布门禁连续执行。近期先完成完整桌面 O2 oracle、机器清册差分、真实浏览器原生历史三方黄金,再进入 planegcs 与 PartDesign 完整闭环。 diff --git a/docs/freecad-baseline-and-golden.zh-CN.md b/docs/freecad-baseline-and-golden.zh-CN.md index b07db3c..046c3bb 100644 --- a/docs/freecad-baseline-and-golden.zh-CN.md +++ b/docs/freecad-baseline-and-golden.zh-CN.md @@ -12,6 +12,8 @@ 源码、构建和安装目录均在 `.cache/` 下,不进入 Git。仓库保存精确提交、构建参数、工具版本、产物哈希和回放契约;当前产物是用于几何对照的无界面 Part oracle,不是完整 FreeCAD 桌面发行版。 +本机 oracle 可作为锁定版本的行为参考。`./npmw run probe:freecad-reference` 会通过 `FreeCADCmd` 采集版本、Git 提交、GUI 能力、可导入 Python 模块和核心 TypeId/Property 元数据,输出机器可读报告;当前无界面构建只启用 `Part`、`Material`、`Measure`,未加载的 Sketcher、PartDesign、TechDraw 等模块会明确标记为 unavailable。完整桌面/工作台对照仍需单独构建 GUI oracle,不能从该报告推断已支持。 + ## 2. Debian trixie 依赖 本次实测使用 GCC `14.2.0`、CMake `3.31.6`、Ninja `1.12.1`、Python `3.13.5`、Qt `6.8.2`、OCCT `7.8.1` 和 SWIG `4.3.0`。安装命令: diff --git a/docs/freecad-complete-parity-backlog.zh-CN.md b/docs/freecad-complete-parity-backlog.zh-CN.md new file mode 100644 index 0000000..8924cd8 --- /dev/null +++ b/docs/freecad-complete-parity-backlog.zh-CN.md @@ -0,0 +1,267 @@ +# FreeCAD 1.1.1 全功能对标主任务账本 + +> 本账本是全功能范围、依赖、任务 ID 和验收证据的唯一总索引。实现状态仍以 `config/compatibility-matrix.json` 为机器可读真值;详细技术设计见 `freecad-full-parity-plan.zh-CN.md`。任何 UI、类型或 Mock 行为都不能单独计为兼容。 + +## 1. 对标合同 + +- 基线固定为 FreeCAD `1.1.1`,提交 `0108fd4b4850cc46e625b60e53cea7a7bbe69f8d`。 +- “全部功能”指基线源码中的 App/Base/Gui 公共行为、用户可见模块、命令、对象、属性、任务面板、文件格式、脚本/扩展入口和错误行为全部进入清册。 +- 每项能力只能处于 `exact`、`compatible`、`read-only`、`proxy` 或 `unsupported`。`unsupported` 必须是经过评审的浏览器边界,有明确诊断和数据保留策略,不能代表遗漏。 +- `exact`/`compatible` 必须有锁定 FreeCAD 的黄金输入、状态、输出、错误和 round-trip 报告;没有证据时最高只能是 `experimental`。 +- Bitbybit 是 Web 几何、Worker、序列化、IO 和三角化能力的首选实现层。先审计和复用 Bitbybit 已有 API;若接口不足,允许从锁定的 Bitbybit 与 OCCT 源码扩展绑定并编译 WASM,不能因 npm 包暂未暴露接口而降低 FreeCAD 功能目标。 +- 自构建 OCCT/Bitbybit 代码必须经过版本锁定、补丁清单、Emscripten 构建、ABI/消息 schema、许可证/SBOM、产物哈希和与 FreeCAD/现有 Bitbybit 的双端黄金测试。React 仍只能调用 Facade,不能直接导入 OCCT 或 Worker。 +- 所有 Node/npm 命令必须通过项目包装器 `./npmw` 执行,禁止以系统 `npm` 产生验收证据。 + +## 2. 基线模块范围 + +本地锁定源码 `src/Mod` 共 34 个目录,按交付责任分组如下。P0 清册必须进一步枚举各模块的命令、对象、属性、偏好、文件格式和可选依赖,目录存在不等于用户功能存在。 + +| 分组 | FreeCAD 1.1.1 源码模块 | 对标处理 | +|---|---|---| +| 核心建模 | `Part`、`PartDesign`、`Sketcher`、`Surface` | 浏览器内参数化编辑与重算,目标至少 `compatible` | +| 文档与二维 | `Draft`、`TechDraw`、`Spreadsheet`、`Plot` | 创建、编辑、表达式、投影与输出闭环 | +| 工程 | `Assembly`、`BIM`、`CAM`、`Fem`、`Robot`、`Inspection`、`Measure` | 求解器/后处理器可端口则兼容,否则逐能力显式分级 | +| 数据与几何 | `Import`、`Mesh`、`MeshPart`、`Points`、`ReverseEngineering`、`OpenSCAD`、`Idf`、`JtReader`、`Material` | 格式、单位、颜色、拓扑、代理和往返差异可审计 | +| 应用体验 | `Start`、`Help`、`AddonManager`、`Cloud`、`Show`、`Web` | Web 等价工作流、离线帮助、扩展治理和安全边界 | +| 工具/开发 | `Sandbox`、`TemplatePyMod`、`Test`、`Tux` | 不伪装成最终用户工作台;保留开发/回归等价能力或明确 `unsupported` | + +此外必须覆盖源码目录 `App`、`Base`、`Gui`、`Main` 的文档模型、Property、事务、选择、视图、参数、命令、Python 控制台、宏、偏好、快捷键、多文档、剪贴板、打印和帮助行为。 + +## 3. 单项完成定义 + +每个命令或对象的任务卡必须同时具备以下证据,缺任一项不得关闭: + +1. 基线来源:FreeCAD 命令 ID、对象 TypeId、Property/枚举/默认值、选择前置、可选依赖和平台差异。 +2. 合同:版本化 Facade 类型、JSON Schema、能力探测、结构化诊断、取消和进度。 +3. 领域行为:创建、编辑、重算、删除、复制、可见性、事务、Undo/Redo、失败恢复和依赖传播。 +4. 几何/求解:公差、单位、拓扑历史、确定性、资源释放和过期 generation 隔离。 +5. 持久化:SQLite/OPFS 保存加载、schema migration、FCStd 或原始代理数据保留。 +6. 交互:任务面板、Property editor、选择/预选、键盘、只读、窄屏和无障碍。 +7. 互操作:相关导入/导出、FreeCAD→Web→FreeCAD round-trip 和差异报告。 +8. 测试:最小成功、边界、错误、取消、黄金几何/状态、浏览器 E2E、性能和内存。 + +工作台只有在其范围内所有任务完成上述八项,并通过“创建→编辑→重算→撤销→保存→加载→导出”七步回放后,才能通过阶段门。 + +## 4. 执行波次 + +| 波次 | 结果 | 阻断关系 | +|---|---|---| +| W0 范围冻结 | 全模块清册、依赖、许可证、黄金 oracle、能力矩阵 | 阻断所有兼容声明 | +| W1 平台内核 | Facade、App/Gui 模型、表达式/单位、DAG、持久化、Viewport | 阻断所有工作台闭环 | +| W2 拓扑与 Sketcher | OCCT 原生历史、稳定 TopoRef、planegcs WASM、草图编辑器 | 阻断 PartDesign/TechDraw/附着 | +| W3 核心建模 | Part、PartDesign、Sketcher 参数化闭环 | 阻断生产和工程工作台 | +| W4 原生文件 | FCStd 双向往返、核心交换格式 | 阻断 G5 及后续工作台保存门禁 | +| W5 生产文档 | TechDraw、Spreadsheet、Draft、Plot | 阻断 G6 | +| W6 高级建模 | Assembly、BIM、Mesh/MeshPart、Surface | 阻断 G7 | +| W7 工程与长尾 | FEM、CAM、Robot、Inspection、数据/应用模块 | 阻断 G8 | +| W8 发布 | 全量回放、跨浏览器、性能、安全、离线、升级与回滚 | 阻断 G9 | + +## 5. W0:范围、基线和黄金 oracle + +| ID | 任务 | 退出条件 | +|---|---|---| +| INV-01 | 从 `src/Mod`、Workbench 初始化脚本和命令注册表生成模块/工作台/命令清册 | 34 个源码模块均有分类,不存在未登记目录 | +| INV-02 | 提取 App/Gui TypeId、Property、枚举、默认值、只读/隐藏/表达式能力 | 每个核心对象能追溯到源码与最小 FCStd fixture | +| INV-03 | 提取菜单、工具栏、上下文菜单、快捷键、任务面板和选择前置 | GUI 命令均有状态与前置条件记录 | +| INV-04 | 提取偏好、参数树、导航样式、颜色/显示和平台差异 | 每项标记 document/user/session 作用域 | +| INV-05 | 提取导入/导出格式、扩展名、MIME、单位、颜色和可选库 | 格式矩阵无未解释入口 | +| INV-06 | 提取 Python API、宏、控制台、扩展和 Addon 入口 | 每项有 sandbox/兼容/不支持决策 | +| BASE-01 | 固定构建容器 digest、编译器、依赖、补丁和产物哈希 | 干净环境重建结果可追溯 | +| BASE-02 | 构建完整无界面 oracle 与可自动化 GUI oracle | Part 之外的目标模块可被回放 | +| BASE-03 | 扩充到不少于 100 个成功、50 个错误黄金文件 | 覆盖核心对象、拓扑变化、单位和错误分类 | +| BASE-04 | 建立 Web OCCT/FreeCAD 双端比较器 | 树、Property、Shape、拓扑、质量属性和诊断可差分 | +| BASE-05 | 生成 SBOM、许可证、来源和已知漏洞报告 | 所有 WASM/Worker/求解器/字典可追溯 | + +### 5.1 Bitbybit/OCCT 源码构建轨道 + +| ID | 任务 | 退出条件 | +|---|---|---| +| BYB-01 | 盘点 Bitbybit TypeScript API、OCCT Worker 命令、IO、缓存和三角化覆盖 | 每个 GEO/PART/PD/TSN/FC 几何任务标记 `reuse`/`extend`/`custom` | +| BYB-02 | 锁定 Bitbybit 源码提交、子模块、补丁和上游 OCCT 版本 | npm 包、源码提交、生成绑定和 WASM 来源链一致 | +| BYB-03 | 建立 Bitbybit + OCCT Emscripten 可复现构建 | 干净容器产出同版本、同导出符号和可追溯 hash | +| BYB-04 | 扩展 OCCT 绑定生成规则 | 暴露拓扑历史、BRepCheck、几何属性、导入导出及各工作台缺失算法,不泄漏原始指针 | +| BYB-05 | 建立版本化 Geometry Worker 协议和能力探测 | 现有 npm provider 与自构建 provider 可替换、可诊断、可取消 | +| BYB-06 | 建立 Shape 所有权、共享拓扑和细粒度释放策略 | Boolean/历史共享 Shape 长会话无悬空和不可控增长 | +| BYB-07 | 建立 Bitbybit、高层扩展绑定、原生 FreeCAD 三方差分 | 几何、拓扑、错误、公差和资源行为差异可审计 | +| BYB-08 | 上游同步和回归策略 | 更新 Bitbybit/OCCT 时自动生成 API/ABI/黄金差异,不静默漂移 | + +## 6. W1:平台与领域内核 + +| ID 范围 | 任务组 | 必须补齐的能力 | +|---|---|---| +| CORE-01..04 | App 文档内核 | 多文档、DocumentObject/Extension、动态 Property、Group/Link/LinkGroup、UUID/InternalName/Label、生命周期 | +| CORE-05..08 | 事务和状态 | 嵌套事务、Undo/Redo、剪切复制粘贴、删除传播、保存点、恢复、只读、最近有效 Shape 回滚 | +| CORE-09..12 | Body/容器 | Body/Tip、Feature 顺序、Origin、Datum、Part/Group、Support、MapMode、AttachmentOffset、局部坐标链、单实体规则 | +| API-01..05 | Facade/Worker | 版本化 schema、事件、取消、进度、诊断、Bitbybit/自构建 provider 能力探测、Worker 崩溃恢复、并发与句柄所有权 | +| EXU-01..08 | Quantity/Expression | 全单位 schema、完整 parser/函数、维度/舍入、对象与 Spreadsheet 引用、循环、locale、自动完成、迁移黄金 | +| DAG-01..11 | 依赖与重算 | Link/Expression/TopoRef 统一边、SCC、稳定调度、并行层、抑制、错误传播、最小闭包、性能计数和 1000 对象基准 | +| DB-01..08 | SQLite/OPFS | 真实迁移、单写者、配额、资源 GC、检查点、崩溃注入、多标签页、备份恢复和无 OPFS 降级 | +| VIEW-01..10 | Gui/Viewport | 相机/导航、显示模式、对象/面/边/点拾取、预选、稳定 TopoRef、剖切、裁剪、测量、网格、增量缓存、LOD、GPU 回收 | +| UI-01..10 | 应用交互 | 菜单/工具栏、Combo/Task/Property、命令状态、状态栏、报告视图、多选 mixed、搜索、键盘、a11y、窄屏、主题/语言/偏好 | + +## 7. W2:稳定拓扑命名与 Sketcher + +### 7.1 稳定拓扑命名 + +| ID | 任务 | 退出条件 | +|---|---|---| +| TSN-01 | 采集 30 个基础和复杂模型的 FreeCAD 命名/错误行为 | 输入、逐代历史和选择结果齐全 | +| TSN-02 | 冻结版本化 `TopoRef` schema 和迁移 | stable/ambiguous/deleted 可往返 | +| TSN-03 | 自构建 OCCT 绑定暴露 Generated/Modified/IsDeleted | Pad/Pocket/Dress-up/Boolean 返回逐输入历史 | +| TSN-04 | 建立曲面、曲线、邻接、方向和几何签名 | 不依赖三角网格精度或瞬时索引 | +| TSN-05 | 一对一/一对多评分、置信度和迁移 | 参数编辑和平移保持安全引用 | +| TSN-06 | 对称、重复、分裂、合并的歧义策略 | 低置信度不强绑,保留候选 | +| TSN-07 | Boolean、多实体、Pattern 和跨 Body 历史 | 每个源对象均有可解释映射 | +| TSN-08 | LinkSub/外部几何/数据库/FCStd 持久化 | 保存加载和版本升级引用稳定 | +| TSN-09 | 3D 拾取、预选、高亮、候选替换和附着 API | 全链路只通过 Facade | +| TSN-10 | 100 模型、1000 参数变异和随机拓扑回放 | 无静默错绑,差异可审计 | + +当前工作区仅在增加 `occt-native` 类型化历史合同,尚未连接自构建 OCCT Worker,不满足 TSN-03。 + +当前 Bitbybit 声明面的可重复证据由 `./npmw run check:bitbybit-history` 固化:布尔 API 仍只有 `HasGenerated()`,`BOPAlgo_Builder.Modified(shape)` 属于部分能力,且列表没有稳定索引读取;该门禁会在绑定升级后要求重新审计。 + +Bitbybit npm 快照仍锁定为 `.cache/bitbybit/bitbybit` 提交 `d3f52bd1fcf78148b60800d69ed6b42e53f4ad06`、版本 `1.1.1`;它本身是生成 WASM/TypeScript 产物。现在已补充独立原生 provider:使用缓存 OCCT 提交 `bb75017e915f6509a4bfa8b551f853352754c2da`(8.0.0)和 `TKBO` 编译完整历史 Embind。由于两个 WASM 实例的 `TopoDS_Shape*` 地址空间不同,provider 内部句柄不得直接传给 Bitbybit;跨 provider 接入必须走版本化序列化协议。 + +### 7.2 Sketcher + +| ID 范围 | 任务组 | 必须补齐的能力 | +|---|---|---| +| SK-01..04 | 清册与求解器 | 全命令/约束 manifest、planegcs 最小依赖、Emscripten 构建、确定性 Worker、取消/过期隔离 | +| SK-05 | 几何 | 点、线、折线、圆、圆弧、椭圆/椭圆弧、B-spline、周期曲线、构造几何、内部/外部几何 | +| SK-06..07 | 约束 | 几何、尺寸、驱动/参考、Block、表达式;DOF、冗余、冲突、部分冗余与诊断分类 | +| SK-08 | 支持与外部几何 | 基准面/平面/面附着、投影、CarbonCopy、跨对象 TopoRef 迁移 | +| SK-09 | 编辑器 | 创建/修剪/延伸/分割、拖拽、自动约束、约束列表、虚拟空间、键鼠回放 | +| SK-10 | B-spline 专项 | 控制点、节点、权重、次数、周期、约束限制和编辑器 | +| SK-11 | 特征轮廓 | 闭合/开放/多环/自交验证,驱动 Pad/Pocket/Revolution/Pipe/Loft | +| SK-12 | 黄金与压力 | 500 模型,欠约束/全约束/冲突/冗余/拖拽/外部几何覆盖 | + +## 8. W3:Part 与 Part Design 参数化闭环 + +| ID 范围 | 任务组 | 必须补齐的能力 | +|---|---|---| +| PART-01..04 | 基础 Shape | 全基本体、Placement、ShapeBuilder、Line/Plane/Helix/Prism/Wedge、Compound/CompSolid/Solid/Shell/Wire | +| PART-05..08 | Boolean/检查 | Union/Cut/Common/Fragments、Section/Slice、Refine、BRepCheck、容差、自交、修复和错误语义 | +| PART-09..12 | 生成/修改 | Extrude/Revolve/Mirror/Scale/Offset/Thickness/Loft/Sweep/Pipe、Fillet/Chamfer/Draft | +| PART-13..16 | 拓扑/互操作 | ShapeBinder、SubShapeBinder、多实体历史、测量、颜色、STEP/IGES/BREP 与黄金套件 | +| PD-01..03 | Body/基准/附着 | Body/Tip/Origin、DatumPlane/Line/Point、Support/MapMode/AttachmentOffset、跨 Body 规则 | +| PD-04..07 | 基础特征 | Pad/Pocket/Revolution/Groove 的两长度、对称、反向、到面/到首个/到最后、Midplane、Taper | +| PD-08..10 | 放样与管道 | Additive/Subtractive Loft/Pipe、截面排序、路径、过渡、方向和失败恢复 | +| PD-11..13 | Dress-up | Fillet/Chamfer/Draft/Thickness 的稳定选边选面、变半径/模式、局部历史 | +| PD-14..16 | 变换与孔 | Mirrored/Linear/Polar/MultiTransform 的特征列表/轴面/单实体;Hole 轮廓、定位、沉孔、锪孔、钻尖、螺纹和标准 | +| PD-17..18 | 高级链与验收 | Boolean、ShapeBinder、Clone、抑制/Tip 重定向、多配置;完整参数编辑/Undo/FCStd 回放 | + +## 9. W4:文件、项目和数据交换 + +| ID | 任务 | 退出条件 | +|---|---|---| +| FC-01..03 | FCStd 样例与安全容器 | 核心/扩展对象样例;ZIP/XML/资源限制和恶意文件拒绝 | +| FC-04 | `Document.xml`/`GuiDocument.xml` 双向映射 | 树、Property、Extension、视图状态可往返 | +| FC-05 | BRep/Shape/缩略图资源读写 | 精度、拓扑数、颜色和资源引用有差异报告 | +| FC-06 | Expression/Unit/Link/TopoRef/Sketch 序列化 | 保存后可由 FreeCAD 重算或明确 proxy | +| FC-07 | 未知对象/属性/扩展原样保留 | 读写后无静默丢失,脚本永不自动执行 | +| FC-08 | STEP/IGES/BREP 导入导出 | 100 个模型的单位、颜色、几何和拓扑黄金 | +| FC-09 | STL/OBJ/PLY/DXF/SVG/PDF/glTF/IFC/CSV/JT/IDF | 每种格式有能力、精度、失败和 round-trip 报告 | +| FC-10 | FreeCAD↔WebCAD↔FreeCAD 双向回放 | 核心对象可编辑,代理对象字节/语义保留 | +| FC-11 | 大文件/损坏/中断/恢复 | 能力上限内不崩溃,超限明确拒绝或流式降级 | +| PKG-01..04 | `.webcad` 项目包 | manifest/hash/schema、导入导出、备份、迁移回滚、干净浏览器恢复 | + +## 10. W5-W7:工作台任务 + +### 10.1 生产文档与二维 + +| ID 范围 | 模块 | 任务范围 | +|---|---|---| +| TD-01..15 | TechDraw | Page/Template、正投影视图、投影组、剖视/局部/辅助/断开、隐藏线、尺寸/公差/GD&T、中心线、气泡、注释、SVG/PDF/打印比例、TopoRef 更新 | +| SS-01..10 | Spreadsheet | Sheet/Cell、公式、alias、样式/合并、行列操作、对象绑定、DAG 循环、CSV、XLSX 能力决策、大表性能 | +| DRAFT-01..14 | Draft | Working Plane、网格/捕捉、Line/Wire/Arc/Circle/Ellipse/Rectangle/Polygon/BSpline/BezCurve、Text/Dimension、Move/Rotate/Scale/Offset/Trim/Array/Clone、Layer 与参数化 | +| PLOT-01..06 | Plot | 数据序列、轴/图例/样式、Spreadsheet/结果绑定、更新、图片/SVG/CSV 输出 | + +### 10.2 高级建模和工程 + +| ID 范围 | 模块 | 任务范围 | +|---|---|---| +| ASM-01..16 | Assembly | Assembly/Component/Link、LCS/Joint、固定/距离/角度/滑动/转动/球等连接、Ondsel solver WASM、变体、运动、碰撞、爆炸图、BOM、FCStd | +| BIM-01..20 | BIM | Site/Building/Level/Space、Wall/Structure/Slab/Roof/Stairs/Window/Door/Equipment、材料/属性集、Draft 联动、IFC2x3/IFC4、分类、数量/清单、代理 | +| MESH-01..14 | Mesh/MeshPart | STL/OBJ/PLY、生成/转换、分析、法向、非流形、自交、孔修补、平滑、简化、布尔、截面、大网格 Worker/LOD | +| SURF-01..14 | Surface | Bezier/BSpline、Ruled/Loft/Sweep、Fill、Sections、Offset、Extend、Trim、Split、Join/Sew、连续性和容差、TopoRef | +| FEM-01..18 | Fem | Analysis、Material、Geometry/element sets、网格器、边界/载荷、CalculiX/Elmer 等求解器能力、WASM/远端策略、求解、结果场、单位、VTK/导出 | +| CAM-01..20 | CAM | Job/Stock、ToolBit/Controller、SetupSheet、Profile/Pocket/Contour/Drill/Adaptive/Surface、dress-up、仿真、碰撞、post sandbox、G-code 黄金 | +| ROBOT-01..12 | Robot | 机器人模型、轴/姿态、轨迹、工作空间、运动学、碰撞、程序与控制器导出;外部设备能力分级 | +| INSP-01..10 | Inspection/Measure | 距离/角度/面积/体积/曲率、剖切、偏差、几何检查、TopoRef 标注、报告、单位/精度和导出 | + +### 10.3 数据、应用与长尾模块 + +| ID 范围 | 模块 | 任务范围 | +|---|---|---| +| PTS-01..08 | Points | 点云导入、显示、裁剪、变换、下采样、曲面/网格转换、大数据 Worker | +| RE-01..10 | ReverseEngineering | 点/网格采样、曲线/曲面拟合、截面、识别、误差可视化和可编辑结果 | +| OSC-01..08 | OpenSCAD | `.scad` 导入/导出、CSG 映射、外部 OpenSCAD 可用性、沙箱、颜色/单位和代理 | +| IDF-01..06 | IDF | PCB/元件/孔/板框导入导出、单位、层/颜色、往返 | +| JT-01..06 | JtReader | JT 版本/压缩能力、装配树、几何/PMI/颜色、流式读取、代理 | +| MAT-01..08 | Material | 材料卡、物理/外观属性、继承、库、单位、BIM/FEM/CAM 共享和 FCStd | +| IMP-01..08 | Import | 格式注册、探测、单位、颜色、进度、取消、错误和批量导入统一层 | +| APPMOD-01..08 | Start/Help/Show/Web/Cloud | 最近项目、模板、示例、离线帮助、对象展示/分享、URL 安全、云连接器能力和隐私 | +| ADDON-01..10 | AddonManager | 插件目录、签名/来源、依赖、安装/更新/卸载、权限、隔离、回滚、离线包;不可执行桌面 Python 时明确代理 | +| SCRIPT-01..10 | Python/宏/控制台 | FreeCAD API 兼容面、Pyodide/受限解释器决策、文件/网络权限、录制/回放、超时、资源配额、不可端口 API 诊断 | +| GUI-REST-01..10 | 桌面通用功能 | 多窗口/MDI 等价、剪贴板、打印、参数编辑器、自定义工具栏/快捷键、主题、语言、导航设备能力 | +| DEV-01..06 | Sandbox/TemplatePyMod/Test/Tux | 开发模板、测试运行器、GUI 自动化入口和明确非产品能力矩阵 | + +## 11. W8:质量与发布门禁 + +| ID | 任务 | 退出条件 | +|---|---|---| +| QA-01 | 单元、属性、parser、图算法与领域状态覆盖 | 核心错误分支和不变量有自动测试 | +| QA-02 | FreeCAD CLI/GUI 对照器 | 每个兼容命令至少一个成功和一个失败 fixture | +| QA-03 | Chrome/Firefox/Safari E2E 与视觉基线 | 目标版本矩阵可审计,无 P0/P1 回归 | +| QA-04 | 几何/求解确定性与 fuzzing | 公差内稳定,随机输入不崩溃/错绑 | +| QA-05 | 性能和内存 | 1000 对象、百万三角形、大装配、大表、长会话 P95/峰值达预算 | +| QA-06 | Worker/WASM/OPFS 故障注入 | 取消、崩溃、配额、断电点、过期结果和恢复行为通过 | +| QA-07 | 文件与消息安全 | zip bomb、路径穿越、XML、表达式、脚本、post、插件和 Worker 消息 fuzzing 通过 | +| QA-08 | 可访问性/国际化/移动 | 键盘核心流、焦点、读屏、locale、长文本和目标视口通过 | +| REL-01 | PWA/离线/cache/COOP/COEP/CSP | 首装、升级、离线和缓存失效可恢复 | +| REL-02 | 数据迁移与回滚演练 | 最近支持版本可升级,可回滚或无损导出 | +| REL-03 | SBOM/许可证/漏洞/产物签名 | 无未知二进制和未处置高危问题 | +| REL-04 | 能力矩阵和差异报告 | 每项功能有准确等级,无未解释 `unsupported` | +| REL-05 | 发布 runbook/遥测/支持流程 | 可部署、监控、诊断、恢复和撤回 | + +## 12. BYB-04 原生拓扑历史与跨 WASM 传输增量 + +本轮已完成 BYB-04 的第一版实际编译和 BYB-05 的协议合同层:`native/occt-history` 从缓存 OCCT `bb75017e915f6509a4bfa8b551f853352754c2da`(8.0.0)构建 `TKBO`、`TKDESTEP`、`TKXSBase`、`TKDE` 和 Embind WASM,`./npmw run test:occt-history` 通过,`shapeToStep -> booleanHistoryFromStep(cut)` smoke case 返回 42 条原生关系记录。`mapNativeOcctHistoryRecords`、`createNativeOcctStepHistoryBridge` 和版本化 `NativeOcctHistoryCoordinator` 已把 provider 协议接入 Facade 类型边界,覆盖 STEP 校验、取消、超时和 stale 文档版本。该 provider 当前仍是独立 WASM 实例;跨 Bitbybit 主 WASM 只能通过 STEP 文本序列化交换,不能直接传递指针。 + +该增量不提升完整 FreeCAD 兼容等级:原生 provider 现在可通过 Facade `geometry.configureNativeHistory()` 显式配置并进入 Boolean 重算,但仍不是默认 GeometryRuntime,也未覆盖全部 PartDesign/工作台操作;浏览器真实 WASM 实例化、Worker 崩溃恢复、STEP 精度/单位策略、BRep/IGES 和 Bitbybit↔原生↔FreeCAD 三方黄金差分仍由 `BYB-05..08`、`TSN-03..10`、`FC-08` 和发布门禁负责。 + +## 13. 当前真实状态与最近执行队列 + +当前仅 `Part Design`、`Part`、`Sketcher` 是 `experimental`;Draft、TechDraw、Spreadsheet、Assembly、Mesh 只有 UI/只读原型,其余多数模块未实现。FCStd 已有安全预检、元数据、对象属性字段投影和 proxy 报告,但仍不可编辑/双向 round-trip。基础 oracle 只有 5 个 Part 场景。 + +本机参考环境、R0-R9 执行批次、立即队列和单项完成定义见 [FreeCAD 1.1.1 全功能对标执行路线图](freecad-reference-execution-roadmap.zh-CN.md)。当前缓存 oracle 是 headless `Part/Material/Measure` 子集;完整桌面工作台行为必须由 O2 oracle 单独采集。 + +最近执行队列不得跳过依赖: + +1. 完成 `BASE-01..04`、`INV-01..06` 和 `BYB-01..03`,生成全命令/对象/属性清册、完整 oracle 和可复现 Bitbybit/OCCT 源码构建。 +2. 完成 `BYB-04..08` 与 `TSN-03..10`:扩展真正 OCCT 历史、稳定签名、跨 Boolean、3D 选择与三方回放。 +3. 完成 `SK-03..12`:planegcs WASM、全约束、编辑器、外部几何和压力套件。 +4. 完成 `CORE-09..12`、`PART-*`、`PD-*`,通过 Part Design 参数化闭环。 +5. 完成 `FC-04..11` 和 `PKG-*`,通过 FCStd 双向 round-trip。 +6. 依次完成 `TD-*`、`SS-*`、`DRAFT-*`、`PLOT-*`。 +7. 依次完成 `ASM-*`、`BIM-*`、`MESH-*`、`SURF-*`。 +8. 完成 FEM/CAM/Robot/Inspection、数据模块、应用模块、脚本/扩展和桌面通用能力。 +9. 完成 `QA-*`、`REL-*`,F0-F9/G0-G9 全部通过后才允许宣称“完整对标”。 + +## 14. 里程碑维护规则 + +每个独立里程碑必须同步实现、聚焦测试、本账本状态、`config/compatibility-matrix.json`、总体方案和接续状态。统一执行: + +```bash +./npmw run verify +git diff --check +jq empty config/compatibility-matrix.json +``` + +涉及原生 oracle 的里程碑另执行: + +```bash +./npmw run test:golden:freecad +``` + +提交信息、差异报告和兼容等级必须准确描述边界;不得把 facade 合同、类型、UI 或 mock 测试写成 FreeCAD 几何/文件兼容已完成。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 5242b6f..4c2c018 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -1,6 +1,8 @@ # 完整 FreeCAD 对标实施方案 > 本文是“功能完全对标 FreeCAD”的工程基线和任务分解,不把当前已有的页面、MockFacade 或少量 OCCT 特征误报为完成。所有“支持”都必须经过锁定版本 FreeCAD 的行为黄金测试和文件互操作测试。 +> +> 全模块、逐领域任务 ID、执行波次和统一退出条件以 [FreeCAD 1.1.1 全功能对标主任务账本](freecad-complete-parity-backlog.zh-CN.md) 为准;本文保留架构决策、专项设计和阶段门。两者不一致时,能力状态以 `config/compatibility-matrix.json` 为机器可读真值,范围和待办以主任务账本为准。 ## 1. 目标与边界 @@ -12,6 +14,8 @@ 对 React 和页面公开的唯一业务入口是 `BitBybitWebCadFacade`。页面不得直接导入 Three.js、OCCT、OCCT Worker、SQLite 或 OPFS;这些实现只能由 Facade 适配器和 Worker 使用。新增工作台、命令、属性、文件格式和诊断,必须先扩展版本化 Facade 合同,再接入页面。 +Bitbybit 必须被充分用作几何、Worker、IO、三角化和缓存的首选实现层。对 Bitbybit 1.1.1 npm 包未暴露但 FreeCAD 对标必需的能力,允许锁定 Bitbybit 与 OCCT 源码提交后扩展绑定并用 Emscripten 自构建 WASM;不得以现成包 API 不足作为降低功能范围的理由。自构建 provider 与现有 provider 必须共享 Facade/Worker 合同、能力探测、句柄生命周期、取消/诊断和黄金测试。 + ### 1.3 “完整对标”的可验收定义 | 等级 | 含义 | 允许的产品表述 | @@ -24,6 +28,16 @@ 没有黄金测试、差异报告和迁移策略的功能,不得标记为 `exact` 或 `compatible`。浏览器限制必须体现为能力矩阵和用户可见诊断,不能静默删除 FreeCAD 行为。 +### 1.4 全量范围和清册门禁 + +锁定源码 `src/Mod` 的 34 个目录全部纳入范围:`AddonManager`、`Assembly`、`BIM`、`CAM`、`Cloud`、`Draft`、`Fem`、`Help`、`Idf`、`Import`、`Inspection`、`JtReader`、`Material`、`Measure`、`Mesh`、`MeshPart`、`OpenSCAD`、`Part`、`PartDesign`、`Plot`、`Points`、`ReverseEngineering`、`Robot`、`Sandbox`、`Show`、`Sketcher`、`Spreadsheet`、`Start`、`Surface`、`TechDraw`、`TemplatePyMod`、`Test`、`Tux`、`Web`。同时覆盖 `App`、`Base`、`Gui`、`Main` 暴露的文档、Property、事务、选择、视图、参数、命令、Python 控制台、宏、偏好、快捷键、多文档、剪贴板和打印行为。 + +`P0-02/INV-01..06` 未完成以前,“全部功能”范围仍视为未冻结。源码模块中的测试、模板或开发设施可以被分为开发等价能力或明确 `unsupported`,但不得遗漏;Python、宏、插件、外部求解器和后处理器必须有沙箱与权限决策。 + +项目 Node/npm 命令统一经 `./npmw` 执行;系统 `npm` 的结果不得用作阶段门证据。 + +本机 `FreeCADCmd` 可作为第一层行为 oracle:`./npmw run probe:freecad-reference` 采集版本、提交、已加载模块和核心 Property 元数据;当前构建是无界面 `Part/Material/Measure` 子集。完整桌面/工作台回放必须另行启用 FreeCAD 原生模块,不能将 headless probe 的可用性扩展到未构建的工作台。 + ## 2. 当前差距与解决策略 | 领域 | 当前切片 | 达到对标还缺少的核心能力 | 主任务包 | @@ -56,6 +70,7 @@ - 依赖循环、求解冲突、拓扑歧义和未知文件对象必须显式失败并保留诊断。 - Undo/Redo、自动保存和资源引用计数必须以同一事务边界提交。 - Worker 结果不可直接改变 React 状态,必须经 Facade 校验和事件投影。 +- 几何能力按 `reuse-bitbybit`、`extend-bitbybit-binding`、`custom-occt-binding` 三档登记;后两档必须锁定源码、补丁、ABI、Emscripten 工具链、许可证和 WASM hash,并完成 Bitbybit/FreeCAD 三方差分。 ## 4. 专项一:稳定子形状命名(TSN) @@ -196,11 +211,15 @@ | Surface | 曲面创建、偏移、放样、填充、修剪、缝合 | SURF-01..SURF-14:NURBS/BSpline 参数、边界和缝合容差 | OCCT 扩展、TSN | | CAM | Job、刀具、路径、后处理、仿真和 G-code | CAM-01..CAM-20:刀具库、碰撞、后处理器沙箱、单位和输出黄金 | FEM/Mesh 可选,安全门 | | Inspection/Measure | 测量、剖切、偏差、标注和报告 | INSP-01..INSP-10:TopoRef 选择、精度、报告和导出 | TSN、EXU | -| Raytracing/Robot/其他 | 基线版本清单中的其余官方模块 | WB-REST-01..WB-REST-20:逐模块决定 WASM 端口或明确不支持 | P0 manifest | +| 数据/交换模块 | Import、Points、ReverseEngineering、OpenSCAD、IDF、JT、Material、MeshPart | IMP/PTS/RE/OSC/IDF/JT/MAT:格式、单位、颜色、代理、几何转换和往返 | FC、QA | +| 应用/扩展模块 | Start、Help、AddonManager、Cloud、Show、Web | APPMOD/ADDON:最近项目、离线帮助、扩展治理、分享/云和安全边界 | P0 manifest、安全门 | +| Robot/Plot | 机器人、轨迹、运动学、数据绘图和结果输出 | ROBOT-01..12、PLOT-01..06 | DAG、EXU、QA | +| 脚本/宏/桌面通用 | Python 控制台、宏、插件、偏好、自定义、剪贴板、打印、多文档 | SCRIPT/GUI-REST:沙箱、权限、等价 Web 工作流或逐项不支持诊断 | P0 manifest、安全门 | +| 开发/测试模块 | Sandbox、TemplatePyMod、Test、Tux | DEV-01..06:保留开发/自动化等价入口或明确非产品能力 | P0 manifest | ### 8.3 工作台交付顺序 -先完成 `PartDesign + Part + Sketcher + TechDraw + Spreadsheet` 的参数化闭环,再做 Draft/Assembly/BIM/Mesh/Surface,最后处理 FEM/CAM/Raytracing 等需要额外数值库或后处理器的模块。每个工作台在进入下一层前必须通过对象创建、编辑、重算、撤销、保存、加载和导出七项门禁。 +先完成 `PartDesign + Part + Sketcher` 的参数化闭环与 FCStd 双向往返,再做 TechDraw/Spreadsheet/Draft/Plot,随后做 Assembly/BIM/Mesh/Surface,最后处理 FEM/CAM/Robot/Inspection、数据交换模块、应用/扩展模块和脚本/桌面通用能力。每个工作台在进入下一层前必须通过对象创建、编辑、重算、撤销、保存、加载和导出七项门禁。 ## 9. 专项六:文件兼容与迁移(FC) @@ -297,13 +316,17 @@ 3. 实现 `DAG-01..DAG-06` 与 `EXU-01..EXU-06`,让属性修改从“Touched 标记”升级为可解释的依赖重算。 4. 实现 `TSN-02..TSN-07`,再把附着、Pocket up-to-face、测量和 TechDraw 选择切换到 `TopoRef`。 5. 编译/验证 `SK-03`,然后按基础约束→高级约束→编辑器→PartDesign 轮廓的顺序推进。 -6. 以 PartDesign 核心闭环为第一条完整垂直链,随后接入 FCStd 读写和 round-trip。 -7. 按工作台表逐个实现并验收,任何暂未端口的模块保持 `read-only/proxy/unsupported`,不提前宣称完整。 +6. 以 PartDesign 核心闭环为第一条完整垂直链,随后完成 FCStd 对象/属性/Shape/视图双向读写和 round-trip。 +7. 按 `TD→SS→DRAFT/PLOT→ASM/BIM/MESH/SURF→FEM/CAM/ROBOT/INSP` 顺序完成工作台七项门禁。 +8. 完成 Import/Points/ReverseEngineering/OpenSCAD/IDF/JT/Material、Start/Help/AddonManager/Cloud/Show/Web、Python/宏/插件和桌面通用能力的逐项兼容决策与实现。 +9. 完成跨浏览器、OPFS 故障、性能/内存、安全 fuzzing、a11y/i18n、PWA、升级回滚、SBOM 和发布差异报告。 ## 15. 完成定义 “完整 FreeCAD 对标”只有在 F0-F9 全部阶段门通过、核心与扩展工作台均有兼容等级、稳定子形状命名/Sketcher/Expression/单位/DAG/重算/FCStd 均有黄金报告、跨浏览器和安全门禁通过,并且发布文档列出所有差异后才能使用。当前仓库仍处于核心 Facade、OCCT 几何和属性编辑的进行中阶段,下一工作目标按第 14 节执行。 +完整对标还要求 34 个源码模块和 App/Base/Gui 通用能力全部进入机器可读清册;任何尚未登记的命令、对象、属性、格式、Python/宏/插件入口都会阻断 G9。详细关闭条件见主任务账本第 3、11、13 节。 + ## 16. 已实现切片与剩余边界 本轮已完成 `EXU-02..EXU-06` 的基础版本:Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本:Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker,因此当前是可验证的业务调度层,不是完整几何重算。 @@ -348,6 +371,8 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 | TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑;schema v6 保存对象拓扑快照;Facade 候选替换事务验证当前同 kind persistentId,重建 DAG 并支持 Undo/Redo;属性/诊断 UI 可替换;Pocket 暴露 `UpToFace` 槽 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、跨 generation 回退解析、候选替换、Undo/Redo、保存加载和 67 项全套测试通过 | 尚未从 3D 拾取、预选或高亮候选;Pocket Up to face executor 仍保持结构化 unsupported,等待原生面历史/解析接线 | | TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted;`signature-fallback` 已进入真实重算 generation,对每个成功 Shape 保存快照/matches/history,采集失败释放临时 Shape并保留最近有效缓存 | 平移修改、新增/删除/重复面、跨对象竞争、generation 写回、采集失败隔离和持久化测试通过;全套 63 项测试通过 | 此 provider 不是 OCCT history;多输入 Boolean 当前仍缺逐输入原生映射,只有自构建绑定暴露逐子形状历史并通过黄金模型后,才能将 TSN-03/07 标为完成 | +原生历史 provider 适配器现已进入重算执行器:当 Geometry Worker 暴露 `topologyHistory()` 且 Base/Tool/Profile 有拓扑快照时,Facade 将真实输入/输出 Shape 和记录交给 `captureNativeTopologyHistory()`;否则保守回退。该适配层有独立单测,但当前 Bitbybit 1.1.1 provider 仍未提供真实记录,因此 TSN-03/07 仍不能标记完成。 + 后续按以下顺序连续开发: 1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播和根因诊断语义,并支持定位、解除错误后的最小闭包重算与合法特征抑制;继续补齐多配置抑制、Body Tip 重定向、最近有效 Shape 显式回滚和性能计数器。Shape 缓存已进入 Facade,但尚未持久化。 diff --git a/docs/freecad-reference-execution-roadmap.zh-CN.md b/docs/freecad-reference-execution-roadmap.zh-CN.md new file mode 100644 index 0000000..8dc6e0a --- /dev/null +++ b/docs/freecad-reference-execution-roadmap.zh-CN.md @@ -0,0 +1,68 @@ +# FreeCAD 1.1.1 全功能对标执行路线图 + +## 1. 基线与证据线 + +唯一语义基线是 FreeCAD `1.1.1`、提交 `0108fd4b4850cc46e625b60e53cea7a7bbe69f8d`。界面相似、OCCT 算法可调用或命令名称存在都不等于兼容。 + +本机参考分三条证据线: + +| 证据线 | 当前状态 | 用途 | 不得推断 | +|---|---|---|---| +| O1 headless oracle | `FreeCADCmd 1.1.1` 可用;`Part/Material/Measure` 可加载 | 几何、对象属性、错误和 FCStd 的确定性 CLI 回放 | Sketcher、PartDesign、TechDraw 等未构建模块的行为 | +| O2 完整桌面 oracle | 源码已锁定,完整 GUI/工作台产物尚未建立 | 命令状态、Task、选择、视图、工作台对象与 GUI 行为 | 不能用静态源码清册代替运行时行为 | +| O3 Web 对照 | Bitbybit 1.1.1、原生 OCCT 历史 provider 和 Facade 已有基础门禁 | FreeCAD↔Bitbybit↔Web 三方差分、浏览器 Worker/OPFS/交互 | Node smoke 不能代替真实浏览器证据 | + +每项功能必须同时记录:FreeCAD 输入与状态、Web 输入与状态、输出、错误、持久化、资源行为、差异原因和兼容等级。 + +## 2. 连续执行顺序 + +| 批次 | 任务范围 | 主要交付物 | 退出门禁 | +|---|---|---|---| +| R0 参考环境 | `BASE-01..04`、`INV-01..06` | 完整桌面 oracle 构建清单;34 模块、命令、TypeId、Property、格式和错误清册 | 锁定提交可重建;清册无未登记入口 | +| R1 几何内核 | `BYB-03..08`、`TSN-03..10` | 可复现 Bitbybit/OCCT 构建;浏览器原生历史;Shape 生命周期;三方黄金差分 | Boolean/PartDesign 历史无静默错绑;长会话无泄漏 | +| R2 Sketcher | `SK-03..12` | planegcs WASM;完整几何/约束/DOF/冲突;外部几何;编辑回放 | FreeCAD 草图成功/失败/冗余行为黄金通过 | +| R3 参数化核心 | `CORE-01..12`、`PART-*`、`PD-*` | Document/Property/DAG/Body/Tip/Support;Part 与 PartDesign 全特征 | 编辑→重算→Undo→保存→加载闭环通过 | +| R4 文件互操作 | `FC-04..11`、`PKG-*` | Document/GuiDocument、BRep/Shape、Expression/Link/TopoRef、未知字段保留 | FreeCAD→Web→FreeCAD 双向差异可解释且无静默丢失 | +| R5 核心工作台 | `TD-*`、`SS-*`、`DRAFT-*`、`PLOT-*` | TechDraw、Spreadsheet、Draft、Plot 的对象、命令、任务和格式 | 每命令至少成功/失败/round-trip fixture | +| R6 工程工作台 | `ASM-*`、`BIM-*`、`MESH-*`、`SURF-*` | 装配、BIM、网格、曲面全链路 | 求解/几何/IFC/网格黄金和大模型预算通过 | +| R7 分析制造 | `FEM-*`、`CAM-*`、`ROBOT-*`、`INSP-*` | 求解器/后处理/刀路/仿真/检测报告 | 求解或明确远端能力;G-code/结果文件黄金通过 | +| R8 平台长尾 | `PTS-*`、`RE-*`、`OSC-*`、`IDF-*`、`JT-*`、`MAT-*`、`APP-*`、`SCRIPT-*` | 数据模块、插件、宏/API、桌面通用能力的实现或显式代理 | 无未分类入口;安全和权限模型通过 | +| R9 发布 | `QA-*`、`REL-*`、F0-F9/G0-G9 | 跨浏览器、性能、故障注入、安全、SBOM、迁移和发布 runbook | 所有阻断项关闭后才允许声明完整对标 | + +## 3. 当前立即执行队列 + +1. `ORA-01`:建立完整 FreeCAD 1.1.1 桌面/工作台 oracle 构建配置;探针必须区分编译缺失、导入失败和 GUI-only。 +2. `INV-02`:从 O1/O2 生成模块→命令→对象→属性→格式清册,并与 Web manifest 做机器差分。 +3. `BYB-06/07`:在真实浏览器实例化原生历史 Worker,执行 Bitbybit STEP→原生 OCCT→FreeCAD 三方 Boolean 黄金和内存释放测试。 +4. `TSN-04/07/10`:补曲面/曲线/邻接签名,扩展 Boolean、Pattern 和 PartDesign 的逐输入历史,建立参数变异套件。 +5. `SK-03/06/07`:编译 planegcs WASM,按 FreeCAD 草图约束分类逐组替换基础 TypeScript solver。 +6. `CORE-09..12`、`PD-*`:完成 Support/AttachmentOffset、Body 单实体、Tip 重定向、局部特征和编辑历史。 +7. `FC-04..10`:在现有安全 ZIP 和属性投影上增加 GuiDocument、BRep/Shape、Expression/Link/TopoRef 与未知 XML/资源保留。 +8. 按 R5→R8 顺序逐工作台交付,禁止只启用 UI 或把未实现命令标记为成功。 +9. 每个增量持续执行 R9 门禁;能力等级只能依据本机 FreeCAD 黄金证据提升。 + +## 4. 单项完成定义 + +一个命令、对象或格式只有同时满足以下条件才可从 `experimental` 提升: + +1. 锁定 FreeCAD 的成功、失败和边界 fixture 已保存。 +2. Facade 合同、取消、过期结果、Undo/Redo 和诊断均有测试。 +3. 几何/求解结果在声明公差内,拓扑引用不使用瞬时索引。 +4. 保存、加载、版本迁移和相关格式 round-trip 无静默丢失。 +5. Chrome、Firefox、Safari 目标版本的 Worker/OPFS/交互证据通过。 +6. 内存、性能、安全、许可证和能力矩阵已更新。 + +`compatible` 允许已记录且可解释的浏览器差异;`exact` 要求锁定版本的声明语义无差异。无法在浏览器安全实现的桌面能力必须标为受限代理,不能隐藏。 + +## 5. 每批统一命令 + +```bash +./npmw run probe:freecad-reference +./npmw run test:golden:freecad +./npmw run test:occt-history +./npmw run verify +git diff --check +jq empty config/compatibility-matrix.json +``` + +O2 完整桌面 oracle 建立后,所有相关探针和黄金命令必须显式设置 `FREECAD_CMD`,防止回退到仅含 Part 的缓存构建。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 0ae0585..b10d308 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1364,7 +1364,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | CAD Workspace zones | FreeCAD 风格的菜单、全局工具栏、Workbench Navigation Bar、Document Tabs、Combo View、Viewport、Task Dock、Function Rail、Report View | Dock 状态、命令 enabled/disabled、任务生命周期由 Facade 事件驱动 | | Model/Property/Task mock | 按 Document/Object/Body/Feature、Data/View、Apply/OK/Cancel 和警告状态组织 | 接入真实对象投影、Property metadata、TaskSession 和 Selection service | | `src/styles.css` | 桌面 300px Combo View、360px Task Dock、44px 命令轨道,并有窄屏内部滚动布局 | 对照锁定 FreeCAD 版本做视觉回归,不改变业务语义 | -| 构建验收 | `npm run build` 通过;核心路由返回 200;已验收 1440x900 和 390x844 截图 | 增加 Playwright/视觉回归与键盘无障碍自动化 | +| 构建验收 | `./npmw run build` 通过;核心路由返回 200;已验收 1440x900 和 390x844 截图 | 增加 Playwright/视觉回归与键盘无障碍自动化 | 页面阶段的 viewport 仍是可交互的静态几何示意,不能宣称已完成 Three.js、FreeCAD 几何或存储能力。业务接入必须从 manifest 和冻结 DOM 结构开始,并通过 BitBybit 唯一入口完成。 @@ -1554,7 +1554,7 @@ P0 基线/治理 6. `P4-01/P4-04`:验证最小 Document/Object 投影、特征追加、事务版本和撤销/重做边界。 7. `P8-01`:为新建文档、工作台切换、选择 Pad、打开 Task Dock、特征提交和属性编辑建立第一批自动化场景。 -迭代退出条件:`npm run build`、类型检查、Facade-only 架构检查和桌面/移动页面截图全部通过;提交信息必须包含任务 ID,例如 `P1-01: define facade contract`。 +迭代退出条件:`./npmw run build`、类型检查、Facade-only 架构检查和桌面/移动页面截图全部通过;提交信息必须包含任务 ID,例如 `P1-01: define facade contract`。 ### 16.7 任务执行和变更控制 @@ -1611,7 +1611,7 @@ P0 基线/治理 | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 67 个场景,包括 Part/PartDesign Mirrored/MultiTransform/Pattern/Hole、TopoRef generation/候选替换、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理;E2E/黄金几何待补齐 | -本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。 +本迭代验证命令:`./npmw run check:facade-boundary`、`./npmw run test:facade`、`./npmw run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。 ### 16.10 P2 持久化/视口验证记录 @@ -1738,3 +1738,13 @@ Sketcher 当前边界:领域模型可往返保存点、线、圆、弧、椭 本项目原先以系统 Node `v20.19.2` 执行 npm,而依赖要求 Node 22,因而出现 `EBADENGINE`。现已增加项目内、SHA-256 校验的 Node `22.23.2`/npm `10.9.8` 运行时;日常命令统一使用 `./npmw` 或 `./nodew`,系统解释器不被修改。详见 [项目运行时基线](project-runtime.zh-CN.md)。 “完整 FreeCAD 对标”不等于已有页面或少量 OCCT 特征。稳定子形状命名、Sketcher 求解器、Expression/单位、依赖 DAG/重计算、剩余工作台和 FCStd/交换格式兼容已拆成独立的可验收任务包、阶段门和黄金测试,详见 [完整 FreeCAD 对标实施方案](freecad-full-parity-plan.zh-CN.md)。FreeCAD `1.1.1` 标签已核对并记录精确提交;构建参数和 WASM 端口仍须通过该方案的 F0 基线门后才能宣称兼容。 + +### 16.18 全功能范围与主任务账本 + +全功能范围已经按锁定源码的 34 个 `src/Mod` 目录重新审计,并补入 App/Base/Gui 的文档、事务、Property、选择、偏好、多文档、Python 控制台、宏、插件、剪贴板和打印行为。逐模块任务、依赖波次和退出条件见 [FreeCAD 1.1.1 全功能对标主任务账本](freecad-complete-parity-backlog.zh-CN.md)。 + +主任务账本把原来笼统的“其他模块”拆分为数据/交换、应用/扩展、Robot/Plot、脚本/桌面通用和开发/测试模块。任何能力即使由于浏览器安全或外部二进制无法端口,也必须登记为带诊断和数据保留策略的 `unsupported`,不能从清册中消失。 + +执行顺序固定为:范围/oracle → 平台内核 → OCCT 原生历史/TopoRef → planegcs Sketcher → Part/PartDesign → FCStd 双向往返 → TechDraw/Spreadsheet/Draft/Plot → Assembly/BIM/Mesh/Surface → FEM/CAM/Robot/Inspection/数据与应用模块 → 发布门禁。所有项目 Node/npm 命令只允许通过 `./npmw` 执行。 + +几何实现遵循 Bitbybit 优先策略:先复用其 OCCT Worker、建模、IO、三角化和缓存能力,再对缺口扩展锁定的 Bitbybit/OCCT 源码绑定并用 Emscripten 构建自有 WASM provider。自构建代码不得绕过 `BitBybitWebCadFacade`,并须通过来源/补丁/SBOM/hash、句柄生命周期和 Bitbybit↔自构建 provider↔FreeCAD 三方黄金差分门禁。对应任务为主账本 `BYB-01..08`。 diff --git a/native/occt-history/CMakeLists.txt b/native/occt-history/CMakeLists.txt new file mode 100644 index 0000000..204445e --- /dev/null +++ b/native/occt-history/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.20) +project(bitbybit_occt_history LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT OCCT_DIR) + message(FATAL_ERROR "OCCT_DIR must point to an Emscripten OCCT build containing OpenCASCADEConfig.cmake") +endif() + +find_package(OpenCASCADE REQUIRED CONFIG PATHS "${OCCT_DIR}" NO_DEFAULT_PATH) + +add_executable(bitbybit_occt_history occt_history.cpp) +target_include_directories(bitbybit_occt_history PRIVATE "${OCCT_DIR}/include/opencascade") +target_link_libraries(bitbybit_occt_history PRIVATE TKDESTEP TKXSBase TKDE TKBO TKBRep TKTopAlgo TKPrim TKShHealing TKGeomAlgo TKGeomBase TKG3d TKG2d TKMath TKernel) +target_compile_options(bitbybit_occt_history PRIVATE -fexceptions -O2) +target_link_options(bitbybit_occt_history PRIVATE + --bind + -fexceptions + -sMODULARIZE=1 + -sEXPORT_ES6=1 + -sENVIRONMENT=web,worker,node + -sALLOW_MEMORY_GROWTH=1 + -sINITIAL_MEMORY=134217728 + -sMAXIMUM_MEMORY=2147483648 + -sNO_EXIT_RUNTIME=1 +) +set_target_properties(bitbybit_occt_history PROPERTIES OUTPUT_NAME "bitbybit-occt-history") diff --git a/native/occt-history/README.md b/native/occt-history/README.md new file mode 100644 index 0000000..5519c55 --- /dev/null +++ b/native/occt-history/README.md @@ -0,0 +1,34 @@ +# Native OCCT history provider + +This provider is compiled from the cached OCCT checkout selected by +`OCCT_SOURCE_DIR` and exposes the native Boolean history API through Emscripten +Embind. The provider keeps `TopoDS_Shape` handles and history queries in one +WASM instance. A handle from `@bitbybit-dev/occt` must never be passed directly +to this module because WebAssembly linear memories are independent. + +Build and verify it with the project wrapper: + +```bash +./npmw run build:occt-history +./npmw run test:occt-history +``` + +The build also publishes the JS/WASM pair to +`public/native/occt-history/`, which is the default URL used by +`NativeOcctHistoryWorkerProvider`. The generated artifacts remain ignored; a +clean checkout must run the build after restoring the pinned OCCT source and +Emscripten toolchain. + +The exported `booleanHistory(object, tool, operation)` function supports +`fuse`, `cut`, and `common`. It returns the native result shape, per-input +`modified`/`generated`/`deleted` records, and capability flags. The provider also +exports `shapeToStep(shape)` and `booleanHistoryFromStep(objectStep, toolStep, +operation)`. The latter is the cross-WASM transport: Bitbybit exports STEP text, +the native provider reads it with `STEPControl_Reader`, returns the Boolean +result again as `resultStep`, and no linear-memory pointer crosses the boundary. + +The TypeScript boundary mapper is `mapNativeOcctHistoryRecords`; the reusable +`createNativeOcctStepHistoryBridge` exports Bitbybit ShapeHandles to STEP and +converts provider source names into document object IDs before calling +`captureNativeTopologyHistory`. The direct handle API remains for calls that +stay inside this single native WASM instance. diff --git a/native/occt-history/build.sh b/native/occt-history/build.sh new file mode 100755 index 0000000..69b2898 --- /dev/null +++ b/native/occt-history/build.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OCCT_SOURCE_DIR="${OCCT_SOURCE_DIR:-/home/mes123456/working/WebCam/occt}" +OCCT_BUILD_DIR="${OCCT_BUILD_DIR:-${ROOT_DIR}/.cache/bitbybit/occt-history-build}" +DIST_DIR="${OCCT_HISTORY_DIST_DIR:-${ROOT_DIR}/native/occt-history/dist}" +PUBLIC_DIR="${OCCT_HISTORY_PUBLIC_DIR:-${ROOT_DIR}/public/native/occt-history}" +JOBS="${JOBS:-$(nproc)}" + +if [[ ! -f "${OCCT_SOURCE_DIR}/CMakeLists.txt" ]]; then + echo "OCCT source checkout not found: ${OCCT_SOURCE_DIR}" >&2 + exit 1 +fi +if ! command -v emcmake >/dev/null || ! command -v em++ >/dev/null; then + echo "Emscripten emcmake/em++ are required." >&2 + exit 1 +fi + +emcmake cmake -S "${OCCT_SOURCE_DIR}" -B "${OCCT_BUILD_DIR}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_MAKE_PROGRAM="$(command -v ninja)" \ + -DCMAKE_CXX_FLAGS="-fexceptions" \ + -DCMAKE_CXX_FLAGS_RELEASE="-O0 -DNDEBUG -fexceptions" \ + -DBUILD_RELEASE_DISABLE_EXCEPTIONS=OFF \ + -DBUILD_LIBRARY_TYPE=Static \ + -DBUILD_MODULE_FoundationClasses=ON \ + -DBUILD_MODULE_ModelingData=ON \ + -DBUILD_MODULE_ModelingAlgorithms=OFF \ + -DBUILD_MODULE_Visualization=OFF \ + -DBUILD_MODULE_ApplicationFramework=OFF \ + -DBUILD_MODULE_DataExchange=ON \ + -DBUILD_MODULE_Draw=OFF \ + -DBUILD_ADDITIONAL_TOOLKITS="TKBO;TKDESTEP" \ + -DBUILD_DOC_Overview=OFF \ + -DBUILD_Inspector=OFF \ + -DBUILD_SAMPLES_QT=OFF \ + -DBUILD_USE_PCH=OFF \ + -DUSE_FREETYPE=OFF \ + -DUSE_TK=OFF \ + -DUSE_TBB=OFF \ + -DUSE_RAPIDJSON=OFF + +cmake --build "${OCCT_BUILD_DIR}" --target \ + TKernel TKMath TKG2d TKG3d TKGeomBase TKGeomAlgo TKShHealing TKPrim TKTopAlgo TKBRep TKBO \ + TKDE TKXSBase TKDESTEP \ + --parallel "${JOBS}" +mkdir -p "${DIST_DIR}" + +em++ "${ROOT_DIR}/native/occt-history/occt_history.cpp" \ + -I"${OCCT_BUILD_DIR}/include/opencascade" \ + -I"${OCCT_SOURCE_DIR}/src" \ + -std=c++17 -O2 -fexceptions --bind \ + -Wl,--start-group \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBO.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDESTEP.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKXSBase.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDE.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBRep.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKTopAlgo.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKPrim.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKShHealing.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKGeomAlgo.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKGeomBase.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKG3d.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKG2d.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKMath.a" \ + "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKernel.a" \ + -Wl,--end-group \ + -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node \ + -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=134217728 -sMAXIMUM_MEMORY=2147483648 \ + -sNO_EXIT_RUNTIME=1 \ + -o "${DIST_DIR}/bitbybit-occt-history.js" + +cp "${ROOT_DIR}/native/occt-history/package.json" "${DIST_DIR}/package.json" +mkdir -p "${PUBLIC_DIR}" +cp "${DIST_DIR}/bitbybit-occt-history.js" "${PUBLIC_DIR}/bitbybit-occt-history.js" +cp "${DIST_DIR}/bitbybit-occt-history.wasm" "${PUBLIC_DIR}/bitbybit-occt-history.wasm" +sha256sum "${DIST_DIR}/bitbybit-occt-history.js" "${DIST_DIR}/bitbybit-occt-history.wasm" diff --git a/native/occt-history/occt_history.cpp b/native/occt-history/occt_history.cpp new file mode 100644 index 0000000..38b6c3e --- /dev/null +++ b/native/occt-history/occt_history.cpp @@ -0,0 +1,247 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace +{ +struct HistoryRecord +{ + std::string relation; + std::string kind; + int sourceIndex = -1; + int resultIndex = -1; +}; + +std::string kindName(const TopAbs_ShapeEnum theKind) +{ + switch (theKind) + { + case TopAbs_VERTEX: return "vertex"; + case TopAbs_EDGE: return "edge"; + case TopAbs_WIRE: return "wire"; + case TopAbs_FACE: return "face"; + case TopAbs_SHELL: return "shell"; + case TopAbs_SOLID: return "solid"; + case TopAbs_COMPSOLID: return "compsolid"; + case TopAbs_COMPOUND: return "compound"; + default: return "shape"; + } +} + +std::vector subShapes(const TopoDS_Shape& theShape, const TopAbs_ShapeEnum theKind) +{ + std::vector aShapes; + for (TopExp_Explorer anExplorer(theShape, theKind); anExplorer.More(); anExplorer.Next()) + { + aShapes.push_back(anExplorer.Current()); + } + return aShapes; +} + +std::string writeStep(const TopoDS_Shape& theShape); + +int resultIndex(const TopoDS_Shape& theShape, + const TopoDS_Shape& theResult, + const TopAbs_ShapeEnum theKind) +{ + int anIndex = 0; + for (TopExp_Explorer anExplorer(theResult, theKind); anExplorer.More(); anExplorer.Next(), ++anIndex) + { + if (anExplorer.Current().IsSame(theShape)) + { + return anIndex; + } + } + return -1; +} + +template +emscripten::val runBoolean(TAlgorithm& theAlgorithm, + const TopoDS_Shape& theObject, + const TopoDS_Shape& theTool) +{ + theAlgorithm.Build(); + if (!theAlgorithm.IsDone() || theAlgorithm.HasErrors()) + { + throw std::runtime_error("OCCT boolean operation failed while collecting native history."); + } + + const TopoDS_Shape aResult = theAlgorithm.Shape(); + emscripten::val aRecords = emscripten::val::array(); + const TopAbs_ShapeEnum aKinds[] = {TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE}; + const TopoDS_Shape aSources[] = {theObject, theTool}; + const char* aSourceNames[] = {"object", "tool"}; + unsigned int aRecordIndex = 0; + + for (int aSource = 0; aSource < 2; ++aSource) + { + for (const TopAbs_ShapeEnum aKind : aKinds) + { + const auto aSubShapes = subShapes(aSources[aSource], aKind); + for (std::size_t aSourceIndex = 0; aSourceIndex < aSubShapes.size(); ++aSourceIndex) + { + const TopoDS_Shape& anInput = aSubShapes[aSourceIndex]; + const auto& aModified = theAlgorithm.Modified(anInput); + for (NCollection_List::Iterator anIterator(aModified); anIterator.More(); anIterator.Next()) + { + const int anOutputIndex = resultIndex(anIterator.Value(), aResult, aKind); + if (anOutputIndex < 0) continue; + emscripten::val aRecord = emscripten::val::object(); + aRecord.set("relation", "modified"); + aRecord.set("source", aSourceNames[aSource]); + aRecord.set("kind", kindName(aKind)); + aRecord.set("sourceIndex", static_cast(aSourceIndex)); + aRecord.set("resultIndex", anOutputIndex); + aRecords.set(aRecordIndex++, aRecord); + } + const auto& aGenerated = theAlgorithm.Generated(anInput); + for (NCollection_List::Iterator anIterator(aGenerated); anIterator.More(); anIterator.Next()) + { + const int anOutputIndex = resultIndex(anIterator.Value(), aResult, aKind); + if (anOutputIndex < 0) continue; + emscripten::val aRecord = emscripten::val::object(); + aRecord.set("relation", "generated"); + aRecord.set("source", aSourceNames[aSource]); + aRecord.set("kind", kindName(aKind)); + aRecord.set("sourceIndex", static_cast(aSourceIndex)); + aRecord.set("resultIndex", anOutputIndex); + aRecords.set(aRecordIndex++, aRecord); + } + if (theAlgorithm.IsDeleted(anInput)) + { + emscripten::val aRecord = emscripten::val::object(); + aRecord.set("relation", "deleted"); + aRecord.set("source", aSourceNames[aSource]); + aRecord.set("kind", kindName(aKind)); + aRecord.set("sourceIndex", static_cast(aSourceIndex)); + aRecord.set("resultIndex", -1); + aRecords.set(aRecordIndex++, aRecord); + } + } + } + } + + emscripten::val aResponse = emscripten::val::object(); + aResponse.set("provider", "occt-native"); + aResponse.set("occtVersion", OCC_VERSION_COMPLETE); + aResponse.set("result", aResult); + aResponse.set("resultStep", writeStep(aResult)); + aResponse.set("records", aRecords); + aResponse.set("hasModified", theAlgorithm.HasModified()); + aResponse.set("hasGenerated", theAlgorithm.HasGenerated()); + aResponse.set("hasDeleted", theAlgorithm.HasDeleted()); + return aResponse; +} + +TopoDS_Shape makeBox(const double theX, const double theY, const double theZ) +{ + if (theX <= 0.0 || theY <= 0.0 || theZ <= 0.0) + { + throw std::invalid_argument("Box dimensions must be positive."); + } + return BRepPrimAPI_MakeBox(theX, theY, theZ).Shape(); +} + +std::string occtVersion() +{ + return OCC_VERSION_COMPLETE; +} + +emscripten::val booleanHistory(const TopoDS_Shape& theObject, + const TopoDS_Shape& theTool, + const std::string& theOperation) +{ + if (theOperation == "fuse") + { + BRepAlgoAPI_Fuse anAlgorithm(theObject, theTool); + return runBoolean(anAlgorithm, theObject, theTool); + } + if (theOperation == "cut") + { + BRepAlgoAPI_Cut anAlgorithm(theObject, theTool); + return runBoolean(anAlgorithm, theObject, theTool); + } + if (theOperation == "common") + { + BRepAlgoAPI_Common anAlgorithm(theObject, theTool); + return runBoolean(anAlgorithm, theObject, theTool); + } + throw std::invalid_argument("Unsupported Boolean operation. Expected fuse, cut or common."); +} + +TopoDS_Shape readStep(const std::string& theText) +{ + if (theText.empty()) + { + throw std::invalid_argument("STEP text must not be empty."); + } + std::istringstream aStream(theText); + STEPControl_Reader aReader; + const IFSelect_ReturnStatus aStatus = aReader.ReadStream("bitbybit.step", aStream); + if (aStatus != IFSelect_RetDone || aReader.NbRootsForTransfer() <= 0 || !aReader.TransferRoots()) + { + throw std::runtime_error("OCCT could not read and transfer the supplied STEP text."); + } + const TopoDS_Shape aShape = aReader.OneShape(); + if (aShape.IsNull()) + { + throw std::runtime_error("OCCT STEP transfer produced a null shape."); + } + return aShape; +} + +std::string writeStep(const TopoDS_Shape& theShape) +{ + if (theShape.IsNull()) + { + throw std::invalid_argument("Cannot serialize a null OCCT shape."); + } + STEPControl_Writer aWriter; + if (aWriter.Transfer(theShape, STEPControl_AsIs) != IFSelect_RetDone) + { + throw std::runtime_error("OCCT could not transfer the shape to STEP."); + } + std::ostringstream aStream; + if (aWriter.WriteStream(aStream) != IFSelect_RetDone) + { + throw std::runtime_error("OCCT could not write the shape to STEP."); + } + return aStream.str(); +} + +emscripten::val booleanHistoryFromStep(const std::string& theObjectStep, + const std::string& theToolStep, + const std::string& theOperation) +{ + return booleanHistory(readStep(theObjectStep), readStep(theToolStep), theOperation); +} +} + +EMSCRIPTEN_BINDINGS(bitbybit_occt_history) +{ + emscripten::class_("TopoDS_Shape") + .constructor<>() + .function("isNull", &TopoDS_Shape::IsNull) + .function("shapeType", &TopoDS_Shape::ShapeType) + .function("isSame", &TopoDS_Shape::IsSame); + emscripten::function("occtVersion", &occtVersion); + emscripten::function("makeBox", &makeBox); + emscripten::function("booleanHistory", &booleanHistory); + emscripten::function("shapeToStep", &writeStep); + emscripten::function("booleanHistoryFromStep", &booleanHistoryFromStep); +} diff --git a/native/occt-history/package.json b/native/occt-history/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/native/occt-history/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/native/occt-history/smoke-test.mjs b/native/occt-history/smoke-test.mjs new file mode 100644 index 0000000..288a4ad --- /dev/null +++ b/native/occt-history/smoke-test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import createOcctHistory from './dist/bitbybit-occt-history.js' + +const occt = await createOcctHistory() +assert.match(occt.occtVersion(), /^8\./) +const object = occt.makeBox(10, 10, 10) +const tool = occt.makeBox(5, 5, 5) +const response = occt.booleanHistory(object, tool, 'cut') +const objectStep = occt.shapeToStep(object) +const toolStep = occt.shapeToStep(tool) +assert.match(objectStep, /^ISO-10303-21;/) +assert.match(toolStep, /^ISO-10303-21;/) +const serializedResponse = occt.booleanHistoryFromStep(objectStep, toolStep, 'cut') +assert.equal(response.provider, 'occt-native') +assert.equal(response.records.every((record) => ['generated', 'modified', 'deleted'].includes(record.relation)), true) +assert.equal(typeof response.hasModified, 'boolean') +assert.equal(typeof response.hasGenerated, 'boolean') +assert.equal(typeof response.hasDeleted, 'boolean') +assert.equal(serializedResponse.provider, 'occt-native') +assert.equal(serializedResponse.records.length > 0, true) +assert.match(serializedResponse.resultStep, /^ISO-10303-21;/) +console.log(JSON.stringify({ occtVersion: response.occtVersion, recordCount: response.records.length, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted }, null, 2)) diff --git a/package.json b/package.json index ca17596..048ebe3 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,19 @@ "preview": "vite preview", "check:baseline": "node scripts/check-baseline.mjs", "check:freecad-source": "node scripts/verify-freecad-source.mjs", + "check:bitbybit-history": "node scripts/check-bitbybit-history-surface.mjs", "check:facade-boundary": "node scripts/check-facade-boundary.mjs", "fetch:freecad-source": "node scripts/fetch-freecad-source.mjs", "configure:freecad-native": "node scripts/configure-freecad-native.mjs", "build:freecad-native": "node scripts/build-freecad-native.mjs", + "build:occt-history": "bash native/occt-history/build.sh", + "test:occt-history": "node native/occt-history/smoke-test.mjs", "test:facade": "tsx --test tests/facade.test.ts", "test:golden": "node --test tests/goldenReplay.test.mjs", "test:golden:freecad": "node scripts/run-freecad-golden.mjs", + "probe:freecad-reference": "node scripts/run-freecad-reference-probe.mjs", "check:runtime": "node scripts/check-runtime.mjs", - "verify": "npm run check:runtime && npm run check:baseline && npm run check:facade-boundary && npm run test:golden && npm run test:facade && npm run build" + "verify": "npm run check:runtime && npm run check:baseline && npm run check:bitbybit-history && npm run check:facade-boundary && npm run test:golden && npm run test:facade && npm run build" }, "dependencies": { "@bitbybit-dev/occt": "1.1.1", diff --git a/scripts/check-bitbybit-history-surface.mjs b/scripts/check-bitbybit-history-surface.mjs new file mode 100644 index 0000000..f6ff224 --- /dev/null +++ b/scripts/check-bitbybit-history-surface.mjs @@ -0,0 +1,83 @@ +import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { resolve } from 'node:path' + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))) +const run = promisify(execFile) +const packageJson = JSON.parse(await readFile(resolve(root, 'node_modules/@bitbybit-dev/occt/package.json'), 'utf8')) +const matrix = JSON.parse(await readFile(resolve(root, 'config/compatibility-matrix.json'), 'utf8')) +const sourcePath = resolve(root, matrix.bitbybitSource.localSnapshot) +const { stdout: sourceCommitOutput } = await run('git', ['-C', sourcePath, 'rev-parse', 'HEAD']) +const sourceCommit = sourceCommitOutput.trim() +if (sourceCommit !== matrix.bitbybitSource.commit) throw new Error(`Bitbybit source commit mismatch: expected ${matrix.bitbybitSource.commit}, got ${sourceCommit}.`) +if (packageJson.version !== matrix.bitbybitSource.version) throw new Error(`Bitbybit package version mismatch: expected ${matrix.bitbybitSource.version}, got ${packageJson.version}.`) +const sourceCppFiles = (await run('git', ['-C', sourcePath, 'ls-tree', '-r', '--name-only', 'HEAD'])).stdout + .split('\n').filter((file) => /\.(c|cc|cpp|cxx|h|hh|hpp)$/.test(file)) +if (sourceCppFiles.length !== 0) throw new Error(`Bitbybit source snapshot unexpectedly contains native source files (${sourceCppFiles.length}); review the custom binding build inputs.`) +const declarationPath = resolve(root, 'node_modules/@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt.d.ts') +const declarations = await readFile(declarationPath, 'utf8') + +const interfaceBody = (name) => { + const match = declarations.match(new RegExp(`export interface ${name} extends ClassHandle \\{([\\s\\S]*?)\\n\\}`)) + if (!match) throw new Error(`Bitbybit declaration is missing interface ${name}.`) + return match[1] +} +const hasMethod = (body, name) => new RegExp(`\\n\\s*${name}\\(`).test(body) +const assert = (condition, message) => { + if (!condition) throw new Error(message) +} + +const booleanInterfaces = ['BRepAlgoAPI_Fuse', 'BRepAlgoAPI_Cut', 'BRepAlgoAPI_Common'] +const booleanHistorySurface = Object.fromEntries(booleanInterfaces.map((name) => { + const body = interfaceBody(name) + return [name, { + generated: hasMethod(body, 'Generated'), + modified: hasMethod(body, 'Modified'), + deleted: hasMethod(body, 'IsDeleted'), + hasGenerated: hasMethod(body, 'HasGenerated') + }] +})) +const builderBody = interfaceBody('BOPAlgo_Builder') +const listBody = interfaceBody('TopTools_ListOfShape') + +for (const [name, surface] of Object.entries(booleanHistorySurface)) { + assert(!surface.generated && !surface.modified && !surface.deleted, `${name} unexpectedly exposes native Generated/Modified/IsDeleted; review the adapter contract.`) + assert(surface.hasGenerated, `${name} must retain HasGenerated for capability reporting.`) +} +assert(hasMethod(builderBody, 'Modified'), 'BOPAlgo_Builder.Modified(shape) is required for the partial history surface.') +for (const method of ['Size', 'IsEmpty', 'Append', 'First']) { + assert(hasMethod(listBody, method), `TopTools_ListOfShape.${method}() is required for the partial history surface.`) +} + +console.log(JSON.stringify({ + provider: 'bitbybit-occt', + version: packageJson.version, + sourceCommit, + sourceForm: matrix.bitbybitSource.sourceForm, + missingBuildInputs: matrix.bitbybitSource.missingBuildInputs, + declarationPath: 'node_modules/@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt.d.ts', + nativeBooleanHistory: { generated: false, modified: false, deleted: false }, + partialBuilderHistory: { modified: true, listIndexAccess: false }, + nativeExtension: await (async () => { + const result = { status: 'not-built' } + for (const [key, relativePath, expectedHash] of [ + ['javascript', matrix.nativeOcctHistory.artifact.javascript, matrix.nativeOcctHistory.artifact.javascriptSha256], + ['wasm', matrix.nativeOcctHistory.artifact.wasm, matrix.nativeOcctHistory.artifact.wasmSha256] + ]) { + try { + const bytes = await readFile(resolve(root, relativePath)) + const hash = createHash('sha256').update(bytes).digest('hex') + if (hash !== expectedHash) throw new Error(`${key} hash mismatch: expected ${expectedHash}, got ${hash}.`) + result[key] = 'hash-ok' + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + } + if (result.javascript === 'hash-ok' && result.wasm === 'hash-ok') result.status = 'built-hash-ok' + return result + })(), + status: 'audit-pass' +}, null, 2)) diff --git a/scripts/check-facade-boundary.mjs b/scripts/check-facade-boundary.mjs index c10ddda..e347a0e 100644 --- a/scripts/check-facade-boundary.mjs +++ b/scripts/check-facade-boundary.mjs @@ -5,7 +5,7 @@ const sourceRoot = new URL('../src/', import.meta.url) const forbidden = [ /from\s+["']three(?:\/|["'])/, /from\s+["']@types\/three/, /from\s+["']sqlite3?/, /from\s+["']@sqlite/, /from\s+["']opfs/, /SharedArrayBuffer/, /\bpostMessage\s*\(/, ] -const allowDirectRuntime = new Set(['facade/threeViewport.ts', 'facade/threeViewport.tsx', 'facade/persistenceWorker.ts', 'facade/projectStore.ts', 'facade/geometryWorker.ts']) +const allowDirectRuntime = new Set(['facade/threeViewport.ts', 'facade/threeViewport.tsx', 'facade/persistenceWorker.ts', 'facade/projectStore.ts', 'facade/geometryWorker.ts', 'facade/nativeHistoryWorkerClient.ts', 'facade/nativeHistoryWorkerEntry.ts']) async function walk(relative = '') { const directory = new URL(relative, sourceRoot) diff --git a/scripts/freecad-reference-probe.py b/scripts/freecad-reference-probe.py new file mode 100644 index 0000000..27c65f7 --- /dev/null +++ b/scripts/freecad-reference-probe.py @@ -0,0 +1,68 @@ +import importlib +import json + +import FreeCAD as App + + +def property_metadata(obj): + properties = [] + for name in obj.PropertiesList: + try: + type_id = obj.getTypeIdOfProperty(name) + except Exception: + type_id = "unknown" + try: + group = obj.getGroupOfProperty(name) + except Exception: + group = "" + try: + status = list(obj.getPropertyStatus(name)) + except Exception: + status = [] + properties.append({"name": name, "typeId": type_id, "group": group, "status": status}) + return properties + + +def probe_object(document, type_id): + try: + obj = document.addObject(type_id, "Reference" + type_id.replace(":", "_")) + except Exception as error: + return {"typeId": type_id, "available": False, "error": str(error)} + return { + "typeId": type_id, + "available": True, + "runtimeTypeId": obj.TypeId, + "properties": property_metadata(obj), + } + + +def probe_module(name): + try: + module = importlib.import_module(name) + return {"name": name, "available": True, "file": getattr(module, "__file__", None)} + except Exception as error: + return {"name": name, "available": False, "error": str(error)} + + +document = App.newDocument("ReferenceProbe") +version = App.Version() +result = { + "schemaVersion": 1, + "baselineId": "freecad-1.1.1", + "freecadVersion": ".".join(str(value) for value in version[:3]), + "revision": str(version[3]), + "gitBranch": str(version[6]) if len(version) > 6 else "", + "gitCommit": str(version[7]) if len(version) > 7 else "", + "guiUp": bool(getattr(App, "GuiUp", False)), + "buildPurpose": "headless-reference-oracle", + "modules": [probe_module(name) for name in [ + "Part", "Material", "Measure", "Sketcher", "PartDesign", "TechDraw", + "Spreadsheet", "Draft", "Mesh", "Fem", "CAM", "Assembly", + ]], + "objects": [probe_object(document, type_id) for type_id in [ + "Part::Box", "Part::Cylinder", "Part::Sphere", "Part::Cone", + "Part::Feature", "PartDesign::Feature", "Sketcher::SketchObject", + ]], +} +App.closeDocument(document.Name) +print("FREECAD_REFERENCE_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":"))) diff --git a/scripts/run-freecad-reference-probe.mjs b/scripts/run-freecad-reference-probe.mjs new file mode 100644 index 0000000..0b71e46 --- /dev/null +++ b/scripts/run-freecad-reference-probe.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const localOracle = resolve(root, '.cache/freecad/install-native/bin/FreeCADCmd') +const candidates = process.env.FREECAD_CMD ? [process.env.FREECAD_CMD] : [localOracle, 'FreeCADCmd', 'freecadcmd'] +let command = null +for (const candidate of candidates) { + const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', timeout: 15000 }) + if (!probe.error || probe.error.code !== 'ENOENT') { command = candidate; break } +} +if (!command) throw new Error('FreeCADCmd 1.1.1 is unavailable. Set FREECAD_CMD to the locked oracle executable.') + +const execution = spawnSync(command, [resolve(root, 'scripts/freecad-reference-probe.py')], { + cwd: root, + encoding: 'utf8', + timeout: 120000, + maxBuffer: 20 * 1024 * 1024, + env: { ...process.env }, +}) +const output = `${execution.stdout || ''}\n${execution.stderr || ''}` +const resultLine = output.split(/\r?\n/).find((line) => line.startsWith('FREECAD_REFERENCE_RESULT=')) +if (execution.error || execution.status !== 0 || !resultLine) { + throw new Error(`FreeCAD reference probe failed with status ${execution.status}: ${execution.error?.message || output.trim()}`) +} +const result = JSON.parse(resultLine.slice('FREECAD_REFERENCE_RESULT='.length)) +if (result.freecadVersion !== '1.1.1') throw new Error(`Expected FreeCAD 1.1.1, received ${result.freecadVersion}`) +console.log(JSON.stringify({ command, result }, null, 2)) diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts index 0577001..53933a4 100644 --- a/src/facade/fcstd.ts +++ b/src/facade/fcstd.ts @@ -22,11 +22,19 @@ export type FcstdEntryMetadata = { export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked' +export type FcstdPropertySummary = { + name: string + typeId: string + element: string + value: string +} + export type FcstdObjectSummary = { name: string label: string typeId: string propertyCount: number + properties: FcstdPropertySummary[] support: FcstdObjectSupport } @@ -179,6 +187,18 @@ const propertyValue = (property: Record): string => { return '' } +const propertyElement = (property: Record): string => { + const entry = Object.entries(property).find(([name, value]) => !name.startsWith('@_') && Boolean(value && typeof value === 'object')) + return entry?.[0] || '' +} + +const propertySummaries = (properties: Record[]): FcstdPropertySummary[] => properties.map((property) => ({ + name: attribute(property, 'name') || '', + typeId: attribute(property, 'type') || propertyElement(property) || 'unknown', + element: propertyElement(property), + value: propertyValue(property), +})) + const parseDocumentXml = (bytes: Uint8Array) => { const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes) if (/ { const properties = asArray((((data?.Properties as Record | undefined)?.Property) as Record | Record[] | undefined)) const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label') const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy' - return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support } + return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, properties: propertySummaries(properties), support } }) return { schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown', diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 7bf1caa..ec4fc48 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -1,6 +1,8 @@ import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import type { Inputs } from '@bitbybit-dev/occt' -import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, MirrorInput, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types' +import { mapNativeOcctHistoryRecords } from './nativeHistoryProvider' +import { NativeOcctHistoryCoordinator, type NativeOcctHistoryProvider } from './nativeHistoryProtocol' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs } from './topologyNaming' type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer @@ -230,6 +232,34 @@ export class BitbybitGeometryRuntime { private sequence = 0 private readonly shapes = new Map() private readonly kernelReferences = new Map() + private nativeHistory: { provider: NativeOcctHistoryProvider; coordinator: NativeOcctHistoryCoordinator } | null = null + private readonly nativeHistoryDocumentVersions = new Map() + + configureNativeHistory(provider: NativeOcctHistoryProvider | null, timeoutMs = 120_000) { + this.nativeHistory?.coordinator.cancel() + if (provider === null) { + this.nativeHistory = null + this.nativeHistoryDocumentVersions.clear() + return + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new RangeError('Native history timeoutMs must be a positive safe integer.') + this.nativeHistory = { + provider, + coordinator: new NativeOcctHistoryCoordinator((documentId) => this.nativeHistoryDocumentVersions.get(documentId) ?? null, timeoutMs), + } + } + + nativeHistoryCapabilities() { + return this.nativeHistory?.provider.capabilities() ?? { + providerId: 'occt-native.history-step', + providerVersion: 'unconfigured', + occtVersion: 'unknown', + availability: 'unavailable' as const, + operations: [], + transport: 'step-text' as const, + reason: 'Native OCCT history provider is not configured.', + } + } capabilities(): GeometryCapabilities { return { ...this.capabilitiesState } } @@ -471,6 +501,30 @@ export class BitbybitGeometryRuntime { return { faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs, entries } } + async topologyHistory(input: NativeTopologyHistoryInput): Promise { + const nativeHistory = this.nativeHistory + if (!nativeHistory) throw new Error('Native OCCT history provider is not configured.') + if (!input.operation) throw new Error('Native OCCT history requires a supported Boolean operation.') + if (input.inputs.length !== 2) throw new Error('Native OCCT history requires exactly an object and a tool input.') + validateDocumentContext(input) + this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion) + const [objectInput, toolInput] = input.inputs + const [objectStep, toolStep] = await Promise.all([ + this.exportStep(objectInput.shape, `${objectInput.objectId}.step`), + this.exportStep(toolInput.shape, `${toolInput.objectId}.step`), + ]) + const execution = await nativeHistory.coordinator.capture(nativeHistory.provider, { + documentId: input.documentId, + documentVersion: input.documentVersion, + operationId: input.operationId, + operation: input.operation, + objectStep: objectStep.text, + toolStep: toolStep.text, + }) + if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`) + return mapNativeOcctHistoryRecords(execution.response.history, { object: objectInput.objectId, tool: toolInput.objectId }) + } + async release(shape: ShapeHandle): Promise { const entry = this.shapes.get(shape.id) if (!entry) return @@ -487,6 +541,7 @@ export class BitbybitGeometryRuntime { } dispose() { + this.nativeHistory?.coordinator.cancel() this.cancelInitialization?.() this.shapes.clear() this.kernelReferences.clear() @@ -496,6 +551,8 @@ export class BitbybitGeometryRuntime { this.worker = null this.initialization = null this.cancelInitialization = null + this.nativeHistoryDocumentVersions.clear() + this.nativeHistory = null this.capabilitiesState = unavailableCapabilities() } diff --git a/src/facade/index.ts b/src/facade/index.ts index 27d4881..7904c9b 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -11,8 +11,15 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences' export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences' -export { captureSignatureTopologyHistory } from './topologyHistory' +export { captureNativeTopologyHistory, captureSignatureTopologyHistory } from './topologyHistory' export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory' +export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from './nativeHistoryProvider' +export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' +export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol' +export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol' +export type { NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest } from './nativeHistoryProtocol' +export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient' +export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient' export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchExternalGeometry, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' export { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol' @@ -20,4 +27,4 @@ export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverE export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine' export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine' export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd' -export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd' +export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport, FcstdPropertySummary } from './fcstd' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 2ccd422..c5e52f4 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -809,7 +809,7 @@ export function createMockFacade(): BitBybitWebCadFacade { task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, - geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), mirror: (input) => geometryRuntime.mirror(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, + geometry: { capabilities: () => geometryRuntime.capabilities(), configureNativeHistory: (provider, timeoutMs) => geometryRuntime.configureNativeHistory(provider, timeoutMs), nativeHistoryCapabilities: () => geometryRuntime.nativeHistoryCapabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), mirror: (input) => geometryRuntime.mirror(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), topologyHistory: (input) => geometryRuntime.topologyHistory(input), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/nativeHistoryProtocol.ts b/src/facade/nativeHistoryProtocol.ts new file mode 100644 index 0000000..74ec142 --- /dev/null +++ b/src/facade/nativeHistoryProtocol.ts @@ -0,0 +1,138 @@ +import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' + +export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const + +export type NativeOcctHistoryCapabilities = { + providerId: string + providerVersion: string + occtVersion: string + availability: 'available' | 'unavailable' + operations: NativeOcctHistoryOperation[] + transport: 'step-text' + reason?: string +} + +export type NativeOcctHistoryRequest = { + protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION + requestId: string + documentId: string + documentVersion: number + operationId: string + operation: NativeOcctHistoryOperation + objectStep: string + toolStep: string +} + +export type NativeOcctHistoryProtocolResponse = { + protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION + requestId: string + documentId: string + documentVersion: number + operationId: string + provider: NativeOcctHistoryCapabilities + history: NativeOcctHistoryResponse +} + +export interface NativeOcctHistoryProvider { + capabilities(): NativeOcctHistoryCapabilities + capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise +} + +export class NativeOcctHistoryUnavailableError extends Error { + readonly code = 'NATIVE_OCCT_HISTORY_UNAVAILABLE' + + constructor(readonly provider: NativeOcctHistoryCapabilities) { + super(provider.reason || `Native OCCT history provider '${provider.providerId}' is unavailable.`) + this.name = 'NativeOcctHistoryUnavailableError' + } +} + +const abortError = () => new DOMException('Native OCCT history request cancelled.', 'AbortError') + +const assertRequest = (request: NativeOcctHistoryRequest) => { + if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`) + if (!request.requestId.trim() || !request.documentId.trim() || !request.operationId.trim()) throw new TypeError('Native OCCT history request IDs must be non-empty strings.') + if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Native OCCT history documentVersion must be a non-negative integer.') + if (!request.objectStep.startsWith('ISO-10303-21;') || !request.toolStep.startsWith('ISO-10303-21;')) throw new TypeError('Native OCCT history transport requires ISO-10303-21 STEP text.') +} + +const assertResponse = (request: NativeOcctHistoryRequest, response: NativeOcctHistoryProtocolResponse) => { + if (response.protocolVersion !== request.protocolVersion || response.requestId !== request.requestId || response.documentId !== request.documentId || response.documentVersion !== request.documentVersion || response.operationId !== request.operationId) throw new Error('Native OCCT history response does not match its request context.') + if (response.history.provider !== 'occt-native' || response.history.occtVersion !== response.provider.occtVersion) throw new Error('Native OCCT history response provider metadata is inconsistent.') + return response +} + +export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvider { + constructor(private readonly module: NativeOcctHistoryStepProvider, private readonly providerVersion = '8.0.0-embind') {} + + capabilities(): NativeOcctHistoryCapabilities { + const occtVersion = this.module.occtVersion() + return { + providerId: 'occt-native.history-step', + providerVersion: this.providerVersion, + occtVersion, + availability: 'available', + operations: ['fuse', 'cut', 'common'], + transport: 'step-text', + } + } + + async capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise { + assertRequest(request) + if (signal.aborted) throw abortError() + const provider = this.capabilities() + const history = await Promise.resolve().then(() => this.module.booleanHistoryFromStep(request.objectStep, request.toolStep, request.operation)) + if (signal.aborted) throw abortError() + return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history }) + } +} + +export class UnavailableNativeOcctHistoryProvider implements NativeOcctHistoryProvider { + constructor(private readonly message = 'No native OCCT history WASM artifact is installed.') {} + + capabilities(): NativeOcctHistoryCapabilities { + return { providerId: 'occt-native.history-step', providerVersion: 'unavailable', occtVersion: 'unknown', availability: 'unavailable', operations: [], transport: 'step-text', reason: this.message } + } + + capture(): Promise { + return Promise.reject(new NativeOcctHistoryUnavailableError(this.capabilities())) + } +} + +export type NativeOcctHistoryExecution = { + status: 'completed' | 'cancelled' | 'stale' | 'timed-out' + requestId: string + response?: NativeOcctHistoryProtocolResponse +} + +export class NativeOcctHistoryCoordinator { + private active: { requestId: string; controller: AbortController } | null = null + private sequence = 0 + + constructor(private readonly currentDocumentVersion: (documentId: string) => number | null, private readonly timeoutMs = 120_000) {} + + cancel() { this.active?.controller.abort() } + + async capture(provider: NativeOcctHistoryProvider, input: Omit): Promise { + this.active?.controller.abort() + const requestId = `native-history-${++this.sequence}` + const controller = new AbortController() + this.active = { requestId, controller } + const request: NativeOcctHistoryRequest = { ...input, protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId } + let timeout: ReturnType | undefined + try { + const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new DOMException('Native OCCT history request timed out.', 'TimeoutError')) }, this.timeoutMs) }) + const response = await Promise.race([provider.capture(request, controller.signal), timeoutPromise]) + if (controller.signal.aborted || this.active?.requestId !== requestId) return { status: 'cancelled', requestId } + if (this.currentDocumentVersion(input.documentId) !== input.documentVersion) return { status: 'stale', requestId } + return { status: 'completed', requestId, response } + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') return { status: 'timed-out', requestId } + if (controller.signal.aborted || (error instanceof Error && error.name === 'AbortError')) return { status: 'cancelled', requestId } + throw error + } finally { + if (timeout) clearTimeout(timeout) + if (this.active?.requestId === requestId) this.active = null + } + } +} diff --git a/src/facade/nativeHistoryProvider.ts b/src/facade/nativeHistoryProvider.ts new file mode 100644 index 0000000..1c27077 --- /dev/null +++ b/src/facade/nativeHistoryProvider.ts @@ -0,0 +1,80 @@ +import type { NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types' + +export type NativeOcctHistoryRecord = { + relation: 'modified' | 'generated' | 'deleted' + source: 'object' | 'tool' + kind: string + sourceIndex: number + resultIndex: number +} + +export type NativeOcctHistoryResponse = { + provider: 'occt-native' + occtVersion: string + resultStep?: string + records: NativeOcctHistoryRecord[] + hasModified: boolean + hasGenerated: boolean + hasDeleted: boolean +} + +export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common' + +export type NativeOcctHistoryStepProvider = { + occtVersion(): string + booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse +} + +export type NativeOcctHistoryStepGeometry = { + exportStep(shape: ShapeHandle, fileName?: string): Promise<{ format: 'step'; fileName: string; mediaType: string; text: string }> +} + +const kinds: SubshapeRef['kind'][] = ['vertex', 'edge', 'face'] +const isKind = (value: string): value is SubshapeRef['kind'] => kinds.includes(value as SubshapeRef['kind']) + +export const mapNativeOcctHistoryRecords = ( + response: NativeOcctHistoryResponse, + sourceObjectIds: { object: string; tool: string }, +): NativeTopologyHistoryRecord[] => { + if (response.provider !== 'occt-native' || !/^8\./.test(response.occtVersion)) throw new Error('Unsupported native OCCT history provider response.') + return response.records.map((record) => { + if (!isKind(record.kind)) throw new Error(`Unsupported native OCCT subshape kind: ${record.kind}`) + if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0) throw new RangeError('Native OCCT sourceIndex must be a non-negative safe integer.') + const sourceObjectId = sourceObjectIds[record.source] + if (!sourceObjectId) throw new Error(`Missing native OCCT source object ID for ${record.source}.`) + if (record.relation === 'deleted') { + return { sourceObjectId, sourceKind: record.kind, sourceIndex: record.sourceIndex, relation: 'deleted' } + } + if (!Number.isSafeInteger(record.resultIndex) || record.resultIndex < 0) throw new RangeError('Native OCCT resultIndex must be a non-negative safe integer.') + return { + sourceObjectId, + sourceKind: record.kind, + sourceIndex: record.sourceIndex, + relation: record.relation, + resultKind: record.kind, + resultIndexes: [record.resultIndex], + } + }) +} + +/** + * Adapts Bitbybit STEP exports to the standalone OCCT history WASM module. + * ShapeHandle values never cross this boundary; only versioned STEP text does. + */ +export const createNativeOcctStepHistoryBridge = ( + geometry: NativeOcctHistoryStepGeometry, + provider: NativeOcctHistoryStepProvider, +) => async (input: NativeTopologyHistoryInput): Promise => { + if (!input.operation) throw new Error('Native OCCT STEP history requires a supported Boolean operation.') + if (input.inputs.length !== 2) throw new Error('Native OCCT STEP history requires exactly an object and a tool input.') + const [objectInput, toolInput] = input.inputs + const [objectStep, toolStep] = await Promise.all([ + geometry.exportStep(objectInput.shape, `${objectInput.objectId}.step`), + geometry.exportStep(toolInput.shape, `${toolInput.objectId}.step`), + ]) + if (objectStep.format !== 'step' || toolStep.format !== 'step' || typeof objectStep.text !== 'string' || typeof toolStep.text !== 'string') { + throw new Error('Bitbybit STEP export returned an invalid transport payload.') + } + const response = provider.booleanHistoryFromStep(objectStep.text, toolStep.text, input.operation) + return mapNativeOcctHistoryRecords(response, { object: objectInput.objectId, tool: toolInput.objectId }) +} diff --git a/src/facade/nativeHistoryWorkerClient.ts b/src/facade/nativeHistoryWorkerClient.ts new file mode 100644 index 0000000..847e0b6 --- /dev/null +++ b/src/facade/nativeHistoryWorkerClient.ts @@ -0,0 +1,111 @@ +import type { NativeOcctHistoryResponse, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' +import { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, type NativeOcctHistoryCapabilities, type NativeOcctHistoryProvider, type NativeOcctHistoryProtocolResponse, type NativeOcctHistoryRequest } from './nativeHistoryProtocol' + +type WorkerLike = Pick & { + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void + addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void + removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void +} + +type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string } + +const abortError = () => new DOMException('Native OCCT history Worker request cancelled.', 'AbortError') + +export type NativeOcctHistoryWorkerOptions = { + moduleUrl?: string + initializationTimeoutMs?: number + workerFactory?: () => WorkerLike +} + +const defaultWorkerFactory = () => new Worker(new URL('./nativeHistoryWorkerEntry.ts', import.meta.url), { type: 'module', name: 'occt-native-history' }) + +export class NativeOcctHistoryWorkerProvider implements NativeOcctHistoryProvider { + private readonly worker: WorkerLike + private readonly options: Required> + private readonly pending = new Map void; reject: (error: Error) => void }>() + private initialization: Promise | null = null + private initializationReject: ((error: Error) => void) | null = null + private current: NativeOcctHistoryCapabilities = { providerId: 'occt-native.history-step', providerVersion: 'unavailable', occtVersion: 'unknown', availability: 'unavailable', operations: [], transport: 'step-text', reason: 'Native OCCT history Worker has not been initialized.' } + private readonly onMessage = (event: MessageEvent) => { + if (event.data.type === 'ready') { + this.current = event.data.capabilities + return + } + if (event.data.type === 'error') { + const requestId = event.data.requestId + const pending = requestId ? this.pending.get(requestId) : null + if (pending && requestId) { this.pending.delete(requestId); pending.reject(new Error(event.data.error)) } + if (!requestId) this.initializationReject?.(new Error(event.data.error)) + return + } + const pending = this.pending.get(event.data.response.requestId) + if (!pending) return + this.pending.delete(event.data.response.requestId) + pending.resolve(event.data.response) + } + private readonly onError = (event: ErrorEvent) => { + const error = new Error(event.message || 'Native OCCT history Worker failed.') + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + this.initialization = null + this.initializationReject?.(error) + this.initializationReject = null + this.current = { ...this.current, availability: 'unavailable', reason: error.message } + } + + constructor(options: NativeOcctHistoryWorkerOptions = {}) { + this.worker = (options.workerFactory || defaultWorkerFactory)() + this.options = { + moduleUrl: options.moduleUrl || '/native/occt-history/bitbybit-occt-history.js', + initializationTimeoutMs: options.initializationTimeoutMs ?? 120_000, + } + this.worker.addEventListener('message', this.onMessage) + this.worker.addEventListener('error', this.onError) + } + + capabilities(): NativeOcctHistoryCapabilities { return { ...this.current, operations: [...this.current.operations] } } + + initialize(): Promise { + if (this.current.availability === 'available') return Promise.resolve(this.capabilities()) + if (this.initialization) return this.initialization + this.initialization = new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => fail(new Error('Native OCCT history Worker initialization timed out.')), this.options.initializationTimeoutMs) + const fail = (error: Error) => { if (settled) return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initialization = null; this.initializationReject = null; this.current = { ...this.current, reason: error.message }; reject(error) } + this.initializationReject = fail + const waitForReady = (event: MessageEvent) => { if (event.data.type === 'error') return fail(new Error(event.data.error)); if (event.data.type !== 'ready') return; settled = true; clearTimeout(timer); this.worker.removeEventListener('message', waitForReady); this.initializationReject = null; resolve(this.capabilities()) } + this.worker.addEventListener('message', waitForReady) + this.worker.postMessage({ type: 'initialize', moduleUrl: this.options.moduleUrl }) + }) + return this.initialization + } + + async capture(request: NativeOcctHistoryRequest, signal: AbortSignal): Promise { + if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`) + await this.initialize() + if (signal.aborted) throw abortError() + const response = new Promise((resolve, reject) => { + this.pending.set(request.requestId, { resolve, reject }) + this.worker.postMessage({ type: 'capture', request }) + }) + const cancel = () => { this.worker.postMessage({ type: 'cancel', requestId: request.requestId }); this.pending.get(request.requestId)?.reject(abortError()); this.pending.delete(request.requestId) } + signal.addEventListener('abort', cancel, { once: true }) + try { return await response } finally { signal.removeEventListener('abort', cancel) } + } + + dispose() { + this.worker.postMessage({ type: 'dispose' }) + this.worker.removeEventListener('message', this.onMessage) + this.worker.removeEventListener('error', this.onError) + this.worker.terminate() + this.initializationReject?.(new Error('Native OCCT history Worker disposed.')) + this.initializationReject = null + for (const pending of this.pending.values()) pending.reject(new Error('Native OCCT history Worker disposed.')) + this.pending.clear() + this.initialization = null + } +} + +export type NativeOcctHistoryWorkerModule = NativeOcctHistoryStepProvider & { default?: never } +export type NativeOcctHistoryWorkerResponse = NativeOcctHistoryResponse diff --git a/src/facade/nativeHistoryWorkerEntry.ts b/src/facade/nativeHistoryWorkerEntry.ts new file mode 100644 index 0000000..e915cb8 --- /dev/null +++ b/src/facade/nativeHistoryWorkerEntry.ts @@ -0,0 +1,49 @@ +/// + +import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider' +import type { NativeOcctHistoryCapabilities, NativeOcctHistoryRequest, 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 } + +const scope = self as DedicatedWorkerGlobalScope +let provider: NativeOcctHistoryStepProvider | null = null +const cancelled = new Set() + +const send = (message: WorkerResponse) => scope.postMessage(message) + +const initialize = async (moduleUrl: string) => { + const imported = await import(/* @vite-ignore */ moduleUrl) as { default?: () => Promise } + if (typeof imported.default !== 'function') throw new Error('Native OCCT history module has no default Emscripten factory export.') + provider = await imported.default() + send({ + type: 'ready', + capabilities: { + providerId: 'occt-native.history-step', + providerVersion: '8.0.0-embind', + occtVersion: provider.occtVersion(), + availability: 'available', + operations: ['fuse', 'cut', 'common'], + transport: 'step-text', + }, + }) +} + +scope.onmessage = ({ data }: MessageEvent) => { + void (async () => { + try { + if (data.type === 'initialize') return await initialize(data.moduleUrl) + if (data.type === 'dispose') { provider = null; scope.close(); return } + if (data.type === 'cancel') { cancelled.add(data.requestId); return } + if (!provider) throw new Error('Native OCCT history Worker is not initialized.') + if (cancelled.delete(data.request.requestId)) return + const history = provider.booleanHistoryFromStep(data.request.objectStep, data.request.toolStep, data.request.operation) + if (cancelled.delete(data.request.requestId)) return + send({ type: 'response', response: { protocolVersion: data.request.protocolVersion, requestId: data.request.requestId, documentId: data.request.documentId, documentVersion: data.request.documentVersion, operationId: data.request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' }, history } }) + } catch (error) { + send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) }) + } + })() +} + +export {} diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index c4d65d3..0410ac0 100644 --- a/src/facade/recomputeEngine.ts +++ b/src/facade/recomputeEngine.ts @@ -1,7 +1,7 @@ import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { cloneSketch, solveSketch } from './sketcher' import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, MirrorInput, MultiTransformStep, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types' -import { captureSignatureTopologyHistory } from './topologyHistory' +import { captureNativeTopologyHistory, captureSignatureTopologyHistory } from './topologyHistory' import { migrateTopoRefs } from './topologyReferences' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' @@ -49,6 +49,7 @@ export type RecomputeGeometryRuntime = { fillet(input: FilletInput): Promise chamfer(input: ChamferInput): Promise topology?(shape: ShapeHandle, precision?: number): Promise + topologyHistory?(input: { documentId: string; documentVersion: number; operationId: string; operation?: 'fuse' | 'cut' | 'common'; inputs: Array<{ objectId: string; shape: ShapeHandle }>; result: ShapeHandle }): Promise release(shape: ShapeHandle): Promise } @@ -303,6 +304,8 @@ const placementForObject = (object: DocumentObjectSnapshot) => { const topologyForObject = async ( geometry: RecomputeGeometryRuntime, object: DocumentObjectSnapshot, + document: DocumentSnapshot, + shapes: Map, shape: ShapeHandle, context: RecomputeNodeContext, ): Promise => { @@ -319,11 +322,28 @@ const topologyForObject = async ( }, signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] }, })) as ObjectTopologySnapshot['entries'] - const history = captureSignatureTopologyHistory( - `${object.id}:generation:${context.generation}`, - previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [], - entries, - ) + const operationId = `${object.id}:generation:${context.generation}` + const linkedSources = ['Base', 'Tool', 'Profile'] + .map((propertyName) => linkedObject(object, propertyName, document)) + .filter((candidate): candidate is DocumentObjectSnapshot => Boolean(candidate)) + const sourceObjects = [...new Map(linkedSources.map((source) => [source.id, source])).values()] + const nativeInputs = sourceObjects + .map((source) => ({ source, shape: shapes.get(source.id) })) + .filter((entry): entry is { source: DocumentObjectSnapshot; shape: ShapeHandle } => Boolean(entry.shape && entry.source.topology?.entries.length)) + const nativeOperation = object.typeId === 'Part::Fuse' ? 'fuse' : object.typeId === 'Part::Cut' ? 'cut' : object.typeId === 'Part::Common' ? 'common' : undefined + const nativeRecords = geometry.topologyHistory && nativeOperation && nativeInputs.length > 0 + ? await geometry.topologyHistory({ + documentId: context.documentId, + documentVersion: context.documentVersion, + operationId, + operation: nativeOperation, + inputs: nativeInputs.map(({ source, shape: inputShape }) => ({ objectId: source.id, shape: inputShape })), + result: shape, + }) + : undefined + const history = nativeRecords + ? captureNativeTopologyHistory(operationId, nativeInputs.map(({ source }) => ({ objectId: source.id, entries: source.topology!.entries })), entries, nativeRecords) + : captureSignatureTopologyHistory(operationId, previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [], entries) return { shapeId: shape.id, documentVersion: context.documentVersion, @@ -597,7 +617,7 @@ export const createFacadeGeometryRecomputeExecutor = ( } let topology: ObjectTopologySnapshot | undefined try { - topology = await topologyForObject(geometry, object, result, context) + topology = await topologyForObject(geometry, object, document, shapes, result, context) } catch (error) { await Promise.allSettled([geometry.release(result)]) throw error diff --git a/src/facade/topologyHistory.ts b/src/facade/topologyHistory.ts index c5d72bc..0d05467 100644 --- a/src/facade/topologyHistory.ts +++ b/src/facade/topologyHistory.ts @@ -1,4 +1,4 @@ -import type { SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types' +import type { NativeTopologyHistoryRecord, SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types' import { matchSubshapes, type SubshapeSignature } from './topologyNaming' export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature } @@ -7,6 +7,53 @@ export type TopologyHistoryRelation = StoredTopologyHistoryRelation export type TopologyHistoryResult = StoredTopologyHistoryResult const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}` +const emptyCounts = (): TopologyHistoryResult['counts'] => ({ preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 }) +const entriesOfKind = (entries: TopologyHistoryEntry[], kind: SubshapeRef['kind']) => entries.filter((entry) => entry.ref.kind === kind) + +export const captureNativeTopologyHistory = ( + operationId: string, + inputs: Array<{ objectId: string; entries: TopologyHistoryEntry[] }>, + output: TopologyHistoryEntry[], + records: NativeTopologyHistoryRecord[], +): TopologyHistoryResult => { + if (!operationId.trim()) throw new RangeError('Topology history operationId is required.') + const inputById = new Map(inputs.map((input) => [input.objectId, input])) + if (inputById.size !== inputs.length) throw new RangeError('Native topology history input object IDs must be unique.') + const counts = emptyCounts() + const relations: TopologyHistoryRelation[] = [] + const seen = new Set() + + for (const record of records) { + const input = inputById.get(record.sourceObjectId) + if (!input) throw new RangeError(`Native topology history references unknown input object ${record.sourceObjectId}.`) + const sources = entriesOfKind(input.entries, record.sourceKind) + if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0 || record.sourceIndex >= sources.length) throw new RangeError(`Native topology history source index is out of range for ${record.sourceObjectId} ${record.sourceKind}.`) + const source = sources[record.sourceIndex] + if (record.relation === 'deleted') { + if (record.resultIndexes?.length) throw new RangeError('Deleted native topology history records cannot reference result indexes.') + const key = `${record.sourceObjectId}:${source.ref.persistentId}:deleted` + if (!seen.has(key)) { + seen.add(key) + relations.push({ relation: 'deleted', sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, score: 1 }) + counts.deleted += 1 + } + continue + } + const resultKind = record.resultKind ?? record.sourceKind + const results = entriesOfKind(output, resultKind) + if (!record.resultIndexes?.length) throw new RangeError(`${record.relation} native topology history records require result indexes.`) + for (const resultIndex of record.resultIndexes) { + if (!Number.isSafeInteger(resultIndex) || resultIndex < 0 || resultIndex >= results.length) throw new RangeError(`Native topology history result index is out of range for ${resultKind}.`) + const result = results[resultIndex] + const key = `${record.sourceObjectId}:${source.ref.persistentId}:${record.relation}:${result.ref.persistentId}` + if (seen.has(key)) continue + seen.add(key) + relations.push({ relation: record.relation, sourceObjectId: record.sourceObjectId, sourcePersistentId: source.ref.persistentId, resultPersistentId: result.ref.persistentId, score: 1 }) + counts[record.relation] += 1 + } + } + return { operationId, provider: 'occt-native', relations, counts } +} export const captureSignatureTopologyHistory = ( operationId: string, @@ -21,7 +68,7 @@ export const captureSignatureTopologyHistory = ( return { ref: { ...entry.ref, persistentId: key }, signature: entry.signature } })) const matches = matchSubshapes(previous, output) - const counts: TopologyHistoryResult['counts'] = { preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 } + const counts = emptyCounts() const relations = matches.map((match): TopologyHistoryRelation => { if (match.status === 'new') return { relation: 'generated', resultPersistentId: match.current.persistentId, score: match.score } if (match.status === 'deleted') { diff --git a/src/facade/types.ts b/src/facade/types.ts index c6633b7..d495914 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -4,6 +4,7 @@ import type { Quantity, QuantityDimension } from './units' import type { SketchConstraint, SketchExternalGeometry, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher' import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine' import type { FcstdArchiveLimits, FcstdInspection } from './fcstd' +import type { NativeOcctHistoryCapabilities, NativeOcctHistoryProvider } from './nativeHistoryProtocol' export type ModelTreeItem = { id: string @@ -221,11 +222,27 @@ export type TopologyHistoryRelation = { export type TopologyHistoryResult = { operationId: string - provider: 'signature-fallback' + provider: 'occt-native' | 'signature-fallback' relations: TopologyHistoryRelation[] counts: Record } +export type NativeTopologyHistoryRecord = { + sourceObjectId: string + sourceKind: SubshapeRef['kind'] + sourceIndex: number + relation: 'preserved' | 'modified' | 'generated' | 'deleted' + resultKind?: SubshapeRef['kind'] + resultIndexes?: number[] +} + +export type NativeTopologyHistoryInput = GeometryDocumentContext & { + operationId: string + operation?: 'fuse' | 'cut' | 'common' + inputs: Array<{ objectId: string; shape: ShapeHandle }> + result: ShapeHandle +} + export type ObjectTopologySnapshot = { shapeId: string documentVersion: number @@ -555,6 +572,8 @@ export interface BitBybitWebCadFacade { } readonly geometry: { capabilities(): GeometryCapabilities + configureNativeHistory?(provider: NativeOcctHistoryProvider | null, timeoutMs?: number): void + nativeHistoryCapabilities?(): NativeOcctHistoryCapabilities initialize(): Promise createBox(input: CreateBoxInput): Promise createCylinder(input: CreateCylinderInput): Promise @@ -575,6 +594,7 @@ export interface BitBybitWebCadFacade { mesh(shape: ShapeHandle, precision?: number): Promise subshapes(shape: ShapeHandle, precision?: number): Promise topology(shape: ShapeHandle, precision?: number): Promise + topologyHistory?(input: NativeTopologyHistoryInput): Promise getObjectShape(objectId: string): ShapeHandle | null release(shape: ShapeHandle): Promise dispose(): void diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 98c8f18..de4804d 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -8,13 +8,16 @@ import { DependencyGraph } from '../src/facade/dependencyGraph' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming' import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences' -import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory' +import { captureNativeTopologyHistory, captureSignatureTopologyHistory } 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 { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient' import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy' import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher' import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol' import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' -import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' +import { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types' const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({ @@ -226,6 +229,194 @@ test('signature topology history conservatively classifies boolean fallback rela assert.deepEqual(new Set(ambiguous.relations[0].candidates?.map((candidate) => candidate.sourceObjectId)), new Set(['base', 'tool'])) }) +test('native topology history maps OCCT relation indexes without transient references', () => { + const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const secondFace = { vertexCoord: [0, 0, 1, 2, 0, 1, 2, 2, 1, 0, 2, 1], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const input = createSubshapeRefs('native-input', 1, [face, secondFace]) + const output = createSubshapeRefs('native-output', 2, [face]) + const result = captureNativeTopologyHistory( + 'boolean-native', + [{ objectId: 'base', entries: input.refs.map((ref, index) => ({ ref, signature: input.signatures[index] })) }], + output.refs.map((ref, index) => ({ ref, signature: output.signatures[index] })), + [ + { sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultIndexes: [0] }, + { sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 1, relation: 'deleted' }, + ], + ) + assert.equal(result.provider, 'occt-native') + assert.equal(result.counts.modified, 1) + assert.equal(result.counts.deleted, 1) + assert.equal(result.relations[0].sourcePersistentId, input.refs[0].persistentId) + assert.equal(result.relations[0].resultPersistentId, output.refs[0].persistentId) + assert.equal(result.relations[1].sourcePersistentId, input.refs[1].persistentId) + assert.throws(() => captureNativeTopologyHistory('boolean-native', [{ objectId: 'base', entries: [] }], [], [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'deleted' }]), /out of range/) +}) + +test('native OCCT provider protocol maps worker records to facade source IDs', () => { + const records = mapNativeOcctHistoryRecords({ + provider: 'occt-native', + occtVersion: '8.0.0', + hasModified: true, + hasGenerated: false, + hasDeleted: true, + records: [ + { relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1 }, + { relation: 'deleted', source: 'tool', kind: 'face', sourceIndex: 2, resultIndex: -1 }, + ], + }, { object: 'base', tool: 'tool' }) + assert.deepEqual(records, [ + { sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [1] }, + { sourceObjectId: 'tool', sourceKind: 'face', sourceIndex: 2, relation: 'deleted' }, + ]) +}) + +test('native OCCT STEP bridge serializes handles before invoking the provider', async () => { + const calls: string[] = [] + const bridge = createNativeOcctStepHistoryBridge( + { exportStep: async (shape) => { calls.push(shape.id); return { format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` } } }, + { + occtVersion: () => '8.0.0', + booleanHistoryFromStep: (objectStep, toolStep, operation) => { + assert.match(objectStep, /base-shape/) + assert.match(toolStep, /tool-shape/) + assert.equal(operation, 'cut') + return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } + }, + }, + ) + const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'cut-1', operation: 'cut', inputs: [ + { objectId: 'base', shape: { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } }, + { objectId: 'tool', shape: { id: 'tool-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } }, + ], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } }) + assert.deepEqual(calls, ['base-shape', 'tool-shape']) + assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }]) +}) + +test('native OCCT history protocol validates STEP context and isolates stale generations', async () => { + const provider = new DirectNativeOcctHistoryProvider({ + occtVersion: () => '8.0.0', + booleanHistoryFromStep: (objectStep, toolStep, operation) => ({ + provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, + resultStep: objectStep, records: [{ relation: operation === 'cut' ? 'modified' : 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }], + }), + }) + assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' }) + const coordinator = new NativeOcctHistoryCoordinator(() => 2) + const execution = await coordinator.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-1', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }) + assert.equal(execution.status, 'completed') + assert.equal(execution.response?.protocolVersion, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) + assert.equal(execution.response?.history.records[0].relation, 'modified') + await assert.rejects(() => provider.capture({ protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'bad', documentId: 'doc', documentVersion: 2, operationId: 'bad', operation: 'cut', objectStep: 'not-step', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal), /STEP text/) + const stale = new NativeOcctHistoryCoordinator(() => 3) + const staleExecution = await stale.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-2', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }) + assert.equal(staleExecution.status, 'stale') +}) + +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' }) + assert.equal(timedOut.status, 'timed-out') + const coordinator = new NativeOcctHistoryCoordinator(() => 1, 120_000) + const pending = coordinator.capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-cancel', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }) + coordinator.cancel() + assert.equal((await pending).status, 'cancelled') +}) + +test('native OCCT Worker provider preserves versioned request and response context', async () => { + const messageListeners = new Set<(event: MessageEvent) => void>() + const errorListeners = new Set<(event: ErrorEvent) => void>() + const fakeWorker = { + postMessage(message: { type: string; request?: { requestId: string; protocolVersion: number; documentId: string; documentVersion: number; operationId: string; objectStep: string; toolStep: string; operation: 'cut' } }) { + if (message.type === 'initialize') { + queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' } } } as MessageEvent))) + } else if (message.type === 'capture' && message.request) { + const request = message.request + const 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: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' }, + history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [] }, + } + queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'response', response } } as MessageEvent))) + } + }, + terminate() {}, + addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).add(listener as never) }, + removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).delete(listener as never) }, + } + const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker }) + const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' } + const response = await provider.capture(request, new AbortController().signal) + assert.equal(response.requestId, request.requestId) + assert.equal(response.documentVersion, request.documentVersion) + assert.equal(provider.capabilities().availability, 'available') + provider.dispose() +}) + +test('native OCCT Worker provider propagates AbortSignal cancellation to the worker', async () => { + const messageListeners = new Set<(event: MessageEvent) => void>() + const cancelRequests: string[] = [] + const fakeWorker = { + postMessage(message: { type: string; requestId?: string }) { + if (message.type === 'initialize') { + queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' } } } as MessageEvent))) + } else if (message.type === 'cancel' && message.requestId) cancelRequests.push(message.requestId) + }, + terminate() {}, + addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.add(listener as never) }, + removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.delete(listener as never) }, + } + const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker }) + const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-cancel-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker-cancel', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' } + const controller = new AbortController() + const pending = provider.capture(request, controller.signal) + await new Promise((resolve) => queueMicrotask(resolve)) + controller.abort() + await assert.rejects(pending, (error: unknown) => error instanceof DOMException && error.name === 'AbortError') + assert.deepEqual(cancelRequests, [request.requestId]) + provider.dispose() +}) + +test('Bitbybit geometry runtime routes configured native history through STEP and the facade mapper', async () => { + const runtime = new BitbybitGeometryRuntime() + runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) + let capturedRequestId = '' + const provider: NativeOcctHistoryProvider = { + capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }), + capture: async (request) => { + capturedRequestId = request.requestId + assert.match(request.objectStep, /base/) + assert.match(request.toolStep, /tool/) + return { + protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, + requestId: request.requestId, + documentId: request.documentId, + documentVersion: request.documentVersion, + operationId: request.operationId, + provider: provider.capabilities(), + history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: true, records: [ + { relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }, + { relation: 'deleted', source: 'tool', kind: 'edge', sourceIndex: 1 }, + ] }, + } + }, + } + runtime.configureNativeHistory(provider, 1000) + const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 }) + const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'cut-runtime', operation: 'cut', inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'tool', shape: handle('tool') }], result: handle('result') }) + assert.match(capturedRequestId, /^native-history-/) + assert.deepEqual(records, [ + { sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }, + { sourceObjectId: 'tool', sourceKind: 'edge', sourceIndex: 1, relation: 'deleted' }, + ]) + runtime.configureNativeHistory(null) + assert.equal(runtime.nativeHistoryCapabilities().availability, 'unavailable') + runtime.dispose() +}) + test('edge and vertex topology signatures are orientation/index independent', () => { const edge = signatureForEdge([0, 0, 0], [2, 0, 0]) assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash) @@ -1489,6 +1680,7 @@ test('FCStd inspection reports recognized, proxy, and Python-backed objects with assert.equal(inspection.label, 'Imported assembly') assert.equal(inspection.schemaVersion, '4') assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body') + assert.deepEqual(inspection.objects.find((object) => object.name === 'Body')?.properties, [{ name: 'Label', typeId: 'App::PropertyString', element: 'String', value: 'Main body' }]) assert.equal(inspection.compatibility.recognizedObjects, 1) assert.equal(inspection.compatibility.proxyObjects, 1) assert.equal(inspection.compatibility.blockedObjects, 1)