diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json
index b529f8a..6d952df 100644
--- a/config/compatibility-matrix.json
+++ b/config/compatibility-matrix.json
@@ -2,9 +2,9 @@
"schemaVersion": 1,
"freecadBaseline": "freecad-1.1.1",
"workbenches": {
- "Part Design": { "status": "ui-prototype", "level": "read-only", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] },
- "Part": { "status": "ui-prototype", "level": "read-only", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] },
- "Sketcher": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical"] },
+ "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] },
+ "Part": { "status": "ui-manifest", "level": "unsupported", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] },
+ "Sketcher": { "status": "facade-model", "level": "experimental", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical", "solve-sketch"] },
"Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] },
"TechDraw": { "status": "ui-prototype", "level": "read-only", "commands": ["new-page", "view-part", "section-view", "dimension", "annotation"] },
"Spreadsheet": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sheet", "set-alias", "expression", "bind-property"] },
@@ -16,6 +16,12 @@
"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"] }
},
+ "facadeCapabilities": {
+ "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "pad", "pocket", "revolution", "fillet", "chamfer", "step-export", "stl-export"] },
+ "document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "sync-recompute", "async-generation-recompute", "undo-redo"] },
+ "sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "basic-solver", "persistence"] },
+ "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
+ },
"rules": [
"UI status does not imply geometry or file compatibility.",
"Every implementation must update this matrix before claiming support.",
diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md
index 3928f8a..cc5deca 100644
--- a/docs/freecad-full-parity-plan.zh-CN.md
+++ b/docs/freecad-full-parity-plan.zh-CN.md
@@ -315,3 +315,28 @@ Sketcher 已有 Facade 事务模型和基础求解器适配器:Sketch 几何/
Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade,支持全边或显式索引、参数验证、句柄版本和 Worker 隔离。由于 OCCT 瞬时边索引不是持久命名,当前接口仍不能作为 FreeCAD PartDesign 的长期选边引用;必须等 TSN 的边级 `TopoRef`、历史映射和参数化重算完成后,才能标记为 `compatible`。
Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape;对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。
+
+## 17. 本轮后续实现记录与下一批任务
+
+本轮继续执行了重算、文件安全和命令能力三个关键边界。实现与验收必须按下面的定义理解,不能把“接口存在”解释成“FreeCAD 业务已完成”:
+
+| 任务 | 实施措施 | 验证结果 | 剩余边界 |
+|---|---|---|---|
+| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale` | 取消、过期版本、依赖失败传播和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape;尚未实现 Worker 并行层 |
+| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 |
+| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 |
+| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
+| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
+| P1-02 命令准确性 | manifest 仍可完整展示;未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | `linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
+
+后续按以下顺序连续开发:
+
+1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加失败保留最近有效 Shape、抑制、恢复上游后的最小闭包重算和诊断树。
+2. `P3-04/P3-05`:建立 Pad/Pocket/Revolution/Fillet/Chamfer 参数到 OCCT Worker 的输入适配和 Shape 资源回写;Pattern/Hole 继续保持 disabled,直到真实 executor 可复现。
+3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。
+4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM,按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。
+5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。
+6. `FC-04/05/06/10`:增加 XML/Shape/Expression/Link 映射,未知字段原样保留,完成 WebCAD↔FCStd↔FreeCAD 差异报告。
+7. `P6-03/P8-02`:把 disabled 原因、重算进度/取消、FCStd 报告和能力矩阵接入 React,并通过目标浏览器回放和视觉基线。
+
+启用规则:`RecomputeExecutionResult.status=completed` 只表示已注册节点成功;FCStd `metadata-compatible` 只表示安全元数据可读;只有 FreeCAD 对照黄金测试、取消/失败/只读、保存/加载和跨浏览器门禁都通过,才能把对象或命令从 `experimental` 提升到 `compatible`,`exact` 还要求锁定版本后的逐项差异为零。
diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md
index cedee17..6ab7ddb 100644
--- a/docs/web-cad-implementation-plan.zh-CN.md
+++ b/docs/web-cad-implementation-plan.zh-CN.md
@@ -1403,13 +1403,13 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| 全量静态页面和 CAD Workspace | `DONE` | `src/App.tsx`、`src/styles.css`;桌面/移动截图验收通过 |
| 工作台/菜单/命令机器可读 manifest | `DONE` | `src/freecadManifest.ts`,13 个工作台和命令分组 |
| BitBybit Web CAD Facade 最小合同 | `DONE` | `src/facade/types.ts`、`src/facade/mockFacade.ts`;真实内核适配仍待 P3/P4 |
-| FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 |
+| FreeCAD/OCCT 几何 WASM | `IN PROGRESS` | `src/facade/geometryRuntime.ts` 已通过 BitBybit OCCT 1.1.1 Worker 接入基本体、布尔、Pad/Pocket/Revolution、Fillet/Chamfer 和 STEP/ASCII STL 导出;FreeCAD 自构建内核和完整特征历史仍未完成 |
| Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`,WebGL2 适配器已挂入 Facade |
| React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 Facade 事件驱动 |
-| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | `projectSchema.ts`、`persistenceWorker.ts` 和 `projectStore.ts` 已落地;Worker 具备 OPFS VFS/内存降级和 Document/Object 保存恢复,自动保存、迁移回滚、配额与跨标签写者仍待 P2-02/P2-06 |
-| 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 |
+| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 |
+| 标准格式与 FCStd 兼容 | `IN PROGRESS` | BitBybit STEP/ASCII STL 导出已接入;`src/facade/fcstd.ts` 已完成安全 ZIP 预检和 Document.xml 元数据/代理报告,FCStd 对象映射、BRep 读写和 round-trip 仍待 P7 |
| Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` |
-| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 |
+| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 35 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
### 16.3 阶段门和交付节奏
@@ -1589,18 +1589,18 @@ P0 基线/治理
| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 |
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 |
| P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker |
-| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 |
-| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;Expression 求值、单位换算、多选 mixed 与链接循环仍待实现 |
-| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 |
+| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、容器规则和原子崩溃恢复仍待实现 |
+| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 已落地,函数库、locale、多选 mixed 和持久引用迁移仍待实现 |
+| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 |
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 |
| P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 |
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;稳定子形状命名尚未实现 |
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI |
-| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;取消、进度、Through all/Up to face 和文档重计算接线尚未完成 |
+| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 generation/cancel 协议已完成,真实特征参数执行、Through all/Up to face 和几何结果回写尚未完成 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 |
-| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 |
+| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 35 个场景,包括 async generation 重算、FCStd 安全检查、schema、串行持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;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 构建验证。
diff --git a/package-lock.json b/package-lock.json
index 9b9f000..f767ebe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,8 @@
"@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
+ "fast-xml-parser": "^5.10.1",
+ "fflate": "^0.8.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -809,6 +811,18 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
+ "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1272,6 +1286,18 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.11.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
@@ -1422,6 +1448,45 @@
"node": ">=6"
}
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
+ "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
+ "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^3.0.0",
+ "fast-xml-builder": "^1.2.0",
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.1",
+ "xml-naming": "^0.3.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1443,7 +1508,7 @@
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "dev": true
+ "license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -1468,6 +1533,18 @@
"node": ">=6.9.0"
}
},
+ "node_modules/is-unsafe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
+ "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1564,6 +1641,21 @@
"node": ">=18"
}
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1721,6 +1813,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/strnum": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
+ "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
@@ -2339,6 +2446,21 @@
}
}
},
+ "node_modules/xml-naming": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index f586e71..777e2df 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
+ "fast-xml-parser": "5.10.1",
+ "fflate": "0.8.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/src/App.tsx b/src/App.tsx
index b07d125..e1814cb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -45,6 +45,7 @@ import {
Trash2,
Undo2,
Redo2,
+ RefreshCw,
Upload,
UserRound,
WandSparkles,
@@ -53,7 +54,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
-import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade'
+import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId
@@ -190,7 +191,7 @@ function App() {
showNotice={showNotice}
/>
) : (
-
+
)}
{notice &&
{notice}
}
@@ -199,6 +200,7 @@ function App() {
function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; facade: BitBybitWebCadFacade; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) {
const [openMenu, setOpenMenu] = useState(null)
+ const document = facade.getState().document
const handleMenuCommand = (command: string) => {
setOpenMenu(null)
if (command === 'help' || command === 'shortcuts') onNavigate('help')
@@ -220,8 +222,8 @@ function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page
-
Pump Housing / {workbench}
-
Unsaved changes
+
{document.label} / {workbench}
+
{document.dirty ? 'Unsaved changes' : 'Saved locally'}
facade.history.undo()} disabled={!facade.history.canUndo()} />
facade.history.redo()} disabled={!facade.history.canRedo()} />
@@ -257,6 +259,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
{ facade.gui.command.execute({ commandId: 'new-document' }); showNotice('New document prepared') }} />
showNotice('Project browser opened')} />
{ facade.gui.command.execute({ commandId: 'save' }); showNotice('Saved to local workspace') }} />
+ { void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Recompute completed' : `Recompute ${result.status}`)) }} />
{ facade.gui.command.execute({ commandId: 'create-body' }); showNotice('Create Body task opened') }} active={workbench === 'Part Design'} />
{ facade.gui.command.execute({ commandId: 'create-sketch' }); showNotice('Select a plane to create a sketch') }} />
@@ -269,11 +272,11 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
-
+
-
mm · 18 objects · v0.1 draft
+
{document.units} · {document.objects.length} objects · v{document.version}
- {bottomOpen &&
Recompute completedDocument / Body / Fillet142 ms
Reference face may change after Pocket editjust now }
+ {bottomOpen &&
{document.recompute?.status === 'failed' ?
:
}
Recompute {document.recompute?.status || 'idle'}{document.recompute?.order.length || 0} objects in plangeneration {document.recompute?.generation || 0}{(document.recompute?.errors || []).slice(0, 2).map((error) =>
{error.message}{error.code} )}
}
)
@@ -420,12 +423,12 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
return
}
-function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {
+function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice, facade }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
const pages: Record, ReactNode> = {
start: ,
projects: ,
- import: ,
- export: ,
+ import: ,
+ export: ,
settings: ,
help: ,
diagnostics: ,
@@ -459,9 +462,19 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate:
return >} />{projects.map((project, index) =>
{project.name}{project.path}
{project.objects} objects{project.modified}{project.state})}
Local storage3.4 GB of 10 GB used · Last backup just now
}
-function FileFlowPage({ mode, onNavigate, showNotice }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
+function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
const isImport = mode === 'import'
- return onNavigate('start')} />
Step 01 / 03
{isImport ? 'Select a CAD file' : 'Select an export format'}
Local only {isImport ? <>
Drop a model hereor browse from your deviceSTEP · IGES · STL · OBJ · PLY · FCStdImported objects will appear as editable or read-only proxies depending on format support. > : {['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
+ const [fcstdReport, setFcstdReport] = useState(null)
+ const fileInputRef = useRef(null)
+ const inspectFile = async (file: File) => {
+ if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return }
+ try {
+ const report = facade.project.fcstd.inspect(new Uint8Array(await file.arrayBuffer()))
+ setFcstdReport(report)
+ showNotice(`FCStd inspected: ${report.compatibility.level}`)
+ } catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) }
+ }
+ return onNavigate('start')} />
Step 01 / 03
{isImport ? 'Select a CAD file' : 'Select an export format'}
Local only {isImport ? <>FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed. {fcstdReport && {fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}
}> : {['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
}
function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) {
@@ -490,7 +503,7 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) {
}
function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
- return onNavigate('start')} actions={} />
Runtime
Capability checks
Last checked just now showNotice('Document diagnostics opened')} />
+ return onNavigate('start')} actions={} />
Runtime
Capability checks
Last checked just now showNotice('Document diagnostics opened')} />
}
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {
diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts
new file mode 100644
index 0000000..2d1f0fd
--- /dev/null
+++ b/src/facade/fcstd.ts
@@ -0,0 +1,243 @@
+import { unzipSync } from 'fflate'
+import { XMLParser } from 'fast-xml-parser'
+
+export type FcstdArchiveLimits = {
+ maxArchiveBytes: number
+ maxEntries: number
+ maxEntryBytes: number
+ maxTotalUncompressedBytes: number
+ maxCompressionRatio: number
+}
+
+export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'thumbnail' | 'script' | 'resource'
+
+export type FcstdEntryMetadata = {
+ path: string
+ compressedBytes: number
+ uncompressedBytes: number
+ compressionMethod: number
+ role: FcstdEntryRole
+}
+
+export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
+
+export type FcstdObjectSummary = {
+ name: string
+ label: string
+ typeId: string
+ propertyCount: number
+ support: FcstdObjectSupport
+}
+
+export type FcstdCompatibilityReport = {
+ level: 'metadata-compatible' | 'partial' | 'blocked'
+ readOnly: true
+ codeExecutionBlocked: true
+ recognizedObjects: number
+ proxyObjects: number
+ blockedObjects: number
+ unknownTypeIds: string[]
+ warnings: string[]
+}
+
+export type FcstdInspection = {
+ format: 'FCStd'
+ schemaVersion: string
+ label: string
+ entries: FcstdEntryMetadata[]
+ objects: FcstdObjectSummary[]
+ compatibility: FcstdCompatibilityReport
+}
+
+export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
+ maxArchiveBytes: 256 * 1024 * 1024,
+ maxEntries: 20_000,
+ maxEntryBytes: 128 * 1024 * 1024,
+ maxTotalUncompressedBytes: 512 * 1024 * 1024,
+ maxCompressionRatio: 200,
+}
+
+const recognizedTypeIds = new Set([
+ 'App::DocumentObjectGroup',
+ 'App::FeaturePython',
+ 'Part::Feature',
+ 'Part::FeaturePython',
+ 'PartDesign::Body',
+ 'PartDesign::Feature',
+ 'PartDesign::Pad',
+ 'PartDesign::Pocket',
+ 'PartDesign::Fillet',
+ 'PartDesign::Chamfer',
+ 'PartDesign::Revolution',
+ 'Sketcher::SketchObject',
+])
+
+const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
+
+const entryRole = (path: string): FcstdEntryRole => {
+ const lower = path.toLowerCase()
+ if (lower === 'document.xml') return 'document'
+ if (lower === 'guidocument.xml') return 'gui-document'
+ if (lower === 'thumbnails/thumbnail.png') return 'thumbnail'
+ if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape'
+ if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script'
+ return 'resource'
+}
+
+const validateEntryPath = (path: string) => {
+ if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || ''}`)
+ const segments = path.split('/')
+ if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`)
+}
+
+const findEndOfCentralDirectory = (bytes: Uint8Array) => {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ const minimum = Math.max(0, bytes.byteLength - 65_557)
+ for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
+ if (view.getUint32(offset, true) === 0x06054b50) return offset
+ }
+ throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.')
+}
+
+const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => {
+ if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`)
+ if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.')
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ const eocd = findEndOfCentralDirectory(bytes)
+ const diskNumber = view.getUint16(eocd + 4, true)
+ const centralDirectoryDisk = view.getUint16(eocd + 6, true)
+ const entriesOnDisk = view.getUint16(eocd + 8, true)
+ const entryCount = view.getUint16(eocd + 10, true)
+ const directorySize = view.getUint32(eocd + 12, true)
+ const directoryOffset = view.getUint32(eocd + 16, true)
+ const commentLength = view.getUint16(eocd + 20, true)
+ if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.')
+ if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.')
+ if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`)
+ if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.')
+
+ const decoder = new TextDecoder('utf-8', { fatal: true })
+ const entries: FcstdEntryMetadata[] = []
+ const paths = new Set()
+ let cursor = directoryOffset
+ let totalUncompressed = 0
+ for (let index = 0; index < entryCount; index += 1) {
+ if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.')
+ const flags = view.getUint16(cursor + 8, true)
+ const compressionMethod = view.getUint16(cursor + 10, true)
+ const compressedBytes = view.getUint32(cursor + 20, true)
+ const uncompressedBytes = view.getUint32(cursor + 24, true)
+ const nameLength = view.getUint16(cursor + 28, true)
+ const extraLength = view.getUint16(cursor + 30, true)
+ const entryCommentLength = view.getUint16(cursor + 32, true)
+ const diskStart = view.getUint16(cursor + 34, true)
+ const next = cursor + 46 + nameLength + extraLength + entryCommentLength
+ if (next > eocd) throw new Error('FCStd central-directory entry is truncated.')
+ if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.')
+ if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.')
+ if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`)
+ if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.')
+ const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength))
+ validateEntryPath(path)
+ if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`)
+ paths.add(path)
+ if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`)
+ const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes
+ if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`)
+ totalUncompressed += uncompressedBytes
+ if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`)
+ entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) })
+ cursor = next
+ }
+ if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.')
+ return entries
+}
+
+const asArray = (value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
+
+const attribute = (node: unknown, name: string): string => {
+ if (!node || typeof node !== 'object') return ''
+ const record = node as Record
+ const value = record[`@_${name}`] ?? record[name]
+ return value === undefined || value === null ? '' : String(value)
+}
+
+const propertyValue = (property: Record): string => {
+ for (const value of Object.values(property)) {
+ if (!value || typeof value !== 'object') continue
+ const candidate = attribute(value, 'value')
+ if (candidate) return candidate
+ }
+ return ''
+}
+
+const parseDocumentXml = (bytes: Uint8Array) => {
+ const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
+ if (/
+ const root = (parsed.Document ?? parsed) as Record
+ const objectDeclarations = asArray((((root.Objects as Record | undefined)?.Object) as Record | Record[] | undefined))
+ const objectData = asArray((((root.ObjectData as Record | undefined)?.Object) as Record | Record[] | undefined))
+ const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
+ const documentProperties = asArray((((root.Properties as Record | undefined)?.Property) as Record | Record[] | undefined))
+ const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
+ const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
+ const name = attribute(declaration, 'name') || ''
+ const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
+ const data = dataByName.get(name)
+ 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 {
+ schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',
+ label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document',
+ objects,
+ }
+}
+
+export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial = {}): FcstdInspection => {
+ const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
+ for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
+ const entries = inspectZipDirectory(bytes, limits)
+ const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml')
+ if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.')
+ const files = unzipSync(bytes)
+ for (const entry of entries) {
+ const content = files[entry.path]
+ if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`)
+ }
+ const document = parseDocumentXml(files[documentEntry.path])
+ const warnings: string[] = []
+ const scriptEntries = entries.filter((entry) => entry.role === 'script')
+ if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`)
+ const blockedObjects = document.objects.filter((object) => object.support === 'blocked')
+ if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`)
+ const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
+ if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
+ const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
+ return {
+ format: 'FCStd',
+ schemaVersion: document.schemaVersion,
+ label: document.label,
+ entries,
+ objects: document.objects,
+ compatibility: {
+ level,
+ readOnly: true,
+ codeExecutionBlocked: true,
+ recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length,
+ proxyObjects: proxyObjects.length,
+ blockedObjects: blockedObjects.length,
+ unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(),
+ warnings,
+ },
+ }
+}
diff --git a/src/facade/index.ts b/src/facade/index.ts
index 801ca10..90709bf 100644
--- a/src/facade/index.ts
+++ b/src/facade/index.ts
@@ -7,3 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade
export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming'
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
+export { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
+export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
+export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
+export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd'
diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts
index 3cd27c7..e01c6a9 100644
--- a/src/facade/mockFacade.ts
+++ b/src/facade/mockFacade.ts
@@ -25,6 +25,8 @@ import { ThreeViewportAdapter } from './threeViewport'
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
+import { executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
+import { inspectFcstdArchive } from './fcstd'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -130,6 +132,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object'])
+const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch'])
const featureCommands: Record = {
'create-body': { label: 'Body', detail: 'Part Design body' },
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
@@ -142,6 +145,7 @@ const featureCommands: Record = {
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
+ if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
return { id: commandId, status: 'enabled' }
@@ -212,6 +216,10 @@ export function createMockFacade(): BitBybitWebCadFacade {
const geometryRuntime = new BitbybitGeometryRuntime()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
+ const recomputeCoordinator = new RecomputeCoordinator(
+ executeFacadeRecomputeNode,
+ (documentId) => state.document.id === documentId ? state.document.version : null,
+ )
const listeners = new Set()
const undoStack: FacadeState[] = []
const redoStack: FacadeState[] = []
@@ -332,6 +340,35 @@ export function createMockFacade(): BitBybitWebCadFacade {
emitState()
return { ...plan, generation, status: nextRecompute.status, errors }
}
+ const recomputeDocumentAsync = async (options: RecomputeExecutionOptions = {}) => {
+ const source = cloneDocumentSnapshot(state.document)
+ const result = await recomputeCoordinator.run(source, options)
+ if ((result.status !== 'completed' && result.status !== 'failed') || state.document.id !== source.id || state.document.version !== source.version) return result
+
+ const document = cloneDocumentSnapshot(state.document)
+ const updates = new Map(result.objectUpdates.map((object) => [object.id, object]))
+ document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object)
+ for (const objectId of result.affected) {
+ const item = document.tree.find((candidate) => candidate.id === objectId)
+ if (!item || item.state === 'readonly') continue
+ if (result.objectStates[objectId] === 'up-to-date') item.state = item.type === 'body' ? 'active' : 'valid'
+ else if (result.objectStates[objectId] === 'error' || result.objectStates[objectId] === 'upstream-failed') item.state = 'warning'
+ const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
+ if (status) status.value = result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
+ }
+ document.recompute = {
+ generation: result.generation,
+ status: result.status,
+ objectStates: result.objectStates,
+ dirtyObjects: result.dirtyObjects,
+ order: result.order,
+ errors: result.errors,
+ }
+ state = { ...state, document }
+ if (document.dirty) autosave.schedule(document)
+ emitState()
+ return result
+ }
const getSketch = (objectId: string) => {
const object = state.document.objects.find((candidate) => candidate.id === objectId)
return object?.sketch ? cloneSketch(object.sketch) : null
@@ -395,7 +432,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench }
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId)
if (status.status === 'disabled') {
- const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'COMMAND_DISABLED', message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
+ const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
+ const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
}
@@ -412,17 +450,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
}
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') }
+ else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null })
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null })
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId
}
const facade: BitBybitWebCadFacade = {
- app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
+ app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
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() } },
- project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource },
+ project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(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), 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), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts
new file mode 100644
index 0000000..7f49bfe
--- /dev/null
+++ b/src/facade/recomputeEngine.ts
@@ -0,0 +1,208 @@
+import { DependencyGraph, type RecomputeState } from './dependencyGraph'
+import { cloneSketch, solveSketch } from './sketcher'
+import type { DocumentObjectSnapshot, DocumentSnapshot } from './types'
+
+export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
+
+export type RecomputeExecutionError = {
+ objectId: string
+ code: string
+ message: string
+}
+
+export type RecomputeNodeContext = {
+ documentId: string
+ documentVersion: number
+ generation: number
+ signal: AbortSignal
+}
+
+export type RecomputeNodeResult = {
+ status: 'success' | 'failed'
+ errors?: RecomputeExecutionError[]
+ updatedObject?: DocumentObjectSnapshot
+}
+
+export type RecomputeNodeExecutor = (
+ object: DocumentObjectSnapshot,
+ document: DocumentSnapshot,
+ context: RecomputeNodeContext,
+) => Promise
+
+export type RecomputeProgress = {
+ generation: number
+ documentVersion: number
+ objectId: string
+ completed: number
+ total: number
+ state: RecomputeState
+}
+
+export type RecomputeExecutionOptions = {
+ dirtyObjectIds?: string[]
+ onProgress?: (progress: RecomputeProgress) => void
+}
+
+export type RecomputeExecutionResult = {
+ generation: number
+ documentVersion: number
+ status: RecomputeExecutionStatus
+ affected: string[]
+ order: string[]
+ completed: string[]
+ failed: string[]
+ skipped: string[]
+ dirtyObjects: string[]
+ objectStates: Record
+ objectUpdates: DocumentObjectSnapshot[]
+ errors: RecomputeExecutionError[]
+}
+
+const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError'
+
+export class RecomputeCoordinator {
+ private active: { generation: number; controller: AbortController } | null = null
+ private generation = 0
+
+ constructor(
+ private readonly executeNode: RecomputeNodeExecutor,
+ private readonly currentDocumentVersion: (documentId: string) => number | null,
+ ) {}
+
+ cancel() {
+ this.active?.controller.abort()
+ }
+
+ async run(document: DocumentSnapshot, options: RecomputeExecutionOptions = {}): Promise {
+ this.active?.controller.abort()
+ const controller = new AbortController()
+ const generation = Math.max(this.generation, document.recompute?.generation ?? 0) + 1
+ this.generation = generation
+ this.active = { generation, controller }
+
+ const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
+ const dirtyObjectIds = options.dirtyObjectIds ?? document.recompute?.dirtyObjects ?? []
+ const plan = graph.plan(dirtyObjectIds)
+ const objectStates: Record = {
+ ...Object.fromEntries(document.objects.map((object) => [object.id, 'up-to-date' as const])),
+ ...(document.recompute?.objectStates ?? {}),
+ }
+ for (const objectId of plan.affected) objectStates[objectId] = 'recomputing'
+
+ const completed: string[] = []
+ const failed: string[] = []
+ const skipped: string[] = []
+ const errors: RecomputeExecutionError[] = []
+ const objectUpdates: DocumentObjectSnapshot[] = []
+ const objectById = new Map(document.objects.map((object) => [object.id, object]))
+
+ for (const cycle of plan.cycles) {
+ const message = `Dependency cycle: ${cycle.join(' -> ')}`
+ for (const objectId of cycle) {
+ objectStates[objectId] = 'error'
+ failed.push(objectId)
+ errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message })
+ }
+ }
+
+ const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => {
+ if (status === 'cancelled' || status === 'stale') {
+ for (const objectId of plan.affected) if (!completed.includes(objectId)) objectStates[objectId] = 'touched'
+ }
+ if (this.active?.generation === generation) this.active = null
+ return {
+ generation,
+ documentVersion: document.version,
+ status,
+ affected: plan.affected,
+ order: plan.order,
+ completed,
+ failed,
+ skipped,
+ dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] !== 'up-to-date'),
+ objectStates,
+ objectUpdates,
+ errors,
+ }
+ }
+
+ for (const objectId of plan.order) {
+ if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
+ if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
+
+ const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
+ if (failedDependency) {
+ objectStates[objectId] = 'upstream-failed'
+ skipped.push(objectId)
+ errors.push({ objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` })
+ options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: 'upstream-failed' })
+ continue
+ }
+
+ const object = objectById.get(objectId)
+ if (!object) {
+ objectStates[objectId] = 'error'
+ failed.push(objectId)
+ errors.push({ objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` })
+ continue
+ }
+
+ try {
+ const result = await this.executeNode(object, document, {
+ documentId: document.id,
+ documentVersion: document.version,
+ generation,
+ signal: controller.signal,
+ })
+ if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
+ if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
+ if (result.status === 'failed') {
+ objectStates[objectId] = 'error'
+ failed.push(objectId)
+ errors.push(...(result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }]))
+ } else {
+ objectStates[objectId] = 'up-to-date'
+ completed.push(objectId)
+ if (result.updatedObject) objectUpdates.push(result.updatedObject)
+ }
+ } catch (error) {
+ if (controller.signal.aborted || isAbortError(error)) return terminalResult('cancelled')
+ objectStates[objectId] = 'error'
+ failed.push(objectId)
+ errors.push({ objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) })
+ }
+ options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: objectStates[objectId] })
+ }
+
+ if (this.active?.generation === generation) this.active = null
+ return terminalResult(errors.length > 0 ? 'failed' : 'completed')
+ }
+}
+
+export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => {
+ if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
+ const expressionError = object.properties.find((property) => property.expressionError)
+ if (expressionError) {
+ return {
+ status: 'failed',
+ errors: [{ objectId: object.id, code: 'EXPRESSION_ERROR', message: expressionError.expressionError as string }],
+ }
+ }
+ if (!object.sketch) return { status: 'success' }
+
+ const solved = solveSketch(object.sketch)
+ if (solved.status === 'conflicting' || solved.status === 'invalid') {
+ return {
+ status: 'failed',
+ errors: solved.diagnostics.map((diagnostic) => ({ objectId: object.id, code: diagnostic.code, message: diagnostic.message })),
+ }
+ }
+ const updatedObject: DocumentObjectSnapshot = {
+ ...object,
+ properties: object.properties.map((property) => property.name === 'ConstraintStatus'
+ ? { ...property, value: solved.status === 'solved' ? 'Fully constrained' : `Under-constrained (${solved.degreesOfFreedom} DOF)` }
+ : { ...property, options: property.options ? [...property.options] : undefined }),
+ sketch: cloneSketch(solved.snapshot),
+ }
+ return { status: 'success', updatedObject }
+}
diff --git a/src/facade/types.ts b/src/facade/types.ts
index 064802f..b152b67 100644
--- a/src/facade/types.ts
+++ b/src/facade/types.ts
@@ -2,6 +2,8 @@ import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
import type { Quantity, QuantityDimension } from './units'
import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
+import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
+import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
export type ModelTreeItem = {
id: string
@@ -317,6 +319,8 @@ export interface BitBybitWebCadFacade {
setProperty(input: SetPropertyInput): void
setExpression(input: SetExpressionInput): void
recompute(): RecomputeResult
+ recomputeAsync(options?: RecomputeExecutionOptions): Promise
+ cancelRecompute(): void
getDependencies(): DependencyEdge[]
}
expression: {
@@ -364,6 +368,9 @@ export interface BitBybitWebCadFacade {
capabilities(): PersistenceCapabilities
save(document?: DocumentSnapshot): Promise
load(documentId: string): Promise
+ fcstd: {
+ inspect(bytes: Uint8Array, limits?: Partial): FcstdInspection
+ }
resource: {
put(bytes: Uint8Array, mediaType: string): Promise
get(hash: string): Promise
diff --git a/tests/facade.test.ts b/tests/facade.test.ts
index 005eb8d..1e5f1e0 100644
--- a/tests/facade.test.ts
+++ b/tests/facade.test.ts
@@ -1,5 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
+import { strToU8, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
@@ -7,8 +8,36 @@ import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming'
import { createSketch, solveSketch } from '../src/facade/sketcher'
+import { RecomputeCoordinator } from '../src/facade/recomputeEngine'
+import { inspectFcstdArchive } from '../src/facade/fcstd'
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
-import type { ShapeHandle } from '../src/facade/types'
+import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types'
+
+const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
+ id: 'doc-recompute',
+ label: 'Recompute fixture',
+ version: 1,
+ dirty: true,
+ readOnly: false,
+ units: 'mm',
+ tree: [
+ { id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
+ { id: 'child', label: 'Child', type: 'feature', state: 'dirty' },
+ ],
+ objects: [
+ { id: 'root', typeId: 'Part::Feature', properties: [] },
+ { id: 'child', typeId: 'Part::Feature', properties: [] },
+ ],
+ dependencies: edges,
+ recompute: {
+ generation: 0,
+ status: 'idle',
+ objectStates: { root: 'touched', child: 'touched' },
+ dirtyObjects: ['root'],
+ order: [],
+ errors: [],
+ },
+})
test('facade exposes a stable initial document projection', () => {
const facade = createMockFacade()
@@ -174,6 +203,66 @@ test('dependency graph propagates dirty state and orders dependencies', () => {
assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']])
})
+test('generation-aware recompute cancels an older run before accepting its result', async () => {
+ let version = 1
+ const coordinator = new RecomputeCoordinator(async (object, _document, context) => {
+ if (context.generation === 1) {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(resolve, 30)
+ context.signal.addEventListener('abort', () => {
+ clearTimeout(timer)
+ const error = new Error('cancelled')
+ error.name = 'AbortError'
+ reject(error)
+ }, { once: true })
+ })
+ }
+ return { status: 'success', updatedObject: object }
+ }, () => version)
+ const document = recomputeDocumentFixture()
+ const first = coordinator.run(document)
+ const second = coordinator.run(document)
+ assert.equal((await first).status, 'cancelled')
+ const accepted = await second
+ assert.equal(accepted.status, 'completed')
+ assert.equal(accepted.generation, 2)
+ assert.deepEqual(accepted.completed, ['root'])
+ version = 2
+})
+
+test('recompute rejects results from an obsolete document version', async () => {
+ let version = 1
+ const coordinator = new RecomputeCoordinator(async () => {
+ version = 2
+ return { status: 'success' }
+ }, () => version)
+ const result = await coordinator.run(recomputeDocumentFixture())
+ assert.equal(result.status, 'stale')
+ assert.deepEqual(result.dirtyObjects, ['root'])
+})
+
+test('recompute failure marks dependent objects as upstream-failed', async () => {
+ const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
+ const coordinator = new RecomputeCoordinator(async (object) => object.id === 'root'
+ ? { status: 'failed', errors: [{ objectId: object.id, code: 'TEST_FAILURE', message: 'expected failure' }] }
+ : { status: 'success' }, () => 1)
+ const result = await coordinator.run(document)
+ assert.equal(result.status, 'failed')
+ assert.deepEqual(result.failed, ['root'])
+ assert.deepEqual(result.skipped, ['child'])
+ assert.equal(result.objectStates.child, 'upstream-failed')
+ assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED'))
+})
+
+test('facade async recompute commits only an accepted generation', async () => {
+ const facade = createMockFacade()
+ facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })
+ const result = await facade.app.document.recomputeAsync()
+ assert.equal(result.status, 'completed')
+ assert.equal(facade.app.document.getActive().recompute?.generation, result.generation)
+ assert.equal(facade.app.document.getActive().recompute?.dirtyObjects.length, 0)
+})
+
test('persistence writes are serialized and continue after a failed write', async () => {
const queue = new PersistenceWriteQueue()
let inFlight = 0
@@ -248,6 +337,17 @@ test('disabled commands return a reason instead of mutating the document', () =>
assert.equal(facade.getState().document.version, before)
})
+test('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
+ const facade = createMockFacade()
+ const before = facade.getState().document.version
+ const state = facade.gui.command.getState('linear-pattern')
+ assert.equal(state.status, 'disabled')
+ assert.match(state.reason || '', /business executor/)
+ facade.gui.command.execute({ commandId: 'linear-pattern' })
+ assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
+ assert.equal(facade.getState().document.version, before)
+})
+
test('command events carry a document-scoped request context', () => {
const facade = createMockFacade()
const events: string[] = []
@@ -334,6 +434,55 @@ test('project persistence remains behind the facade contract', async () => {
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
})
+test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => {
+ const documentXml = `
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ const archive = zipSync({
+ 'Document.xml': strToU8(documentXml),
+ 'GuiDocument.xml': strToU8(''),
+ 'Macro/unsafe.py': strToU8('raise RuntimeError("must not run")'),
+ })
+ const inspection = inspectFcstdArchive(archive)
+ assert.equal(inspection.label, 'Imported assembly')
+ assert.equal(inspection.schemaVersion, '4')
+ assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body')
+ assert.equal(inspection.compatibility.recognizedObjects, 1)
+ assert.equal(inspection.compatibility.proxyObjects, 1)
+ assert.equal(inspection.compatibility.blockedObjects, 1)
+ assert.equal(inspection.compatibility.level, 'blocked')
+ assert.equal(inspection.compatibility.codeExecutionBlocked, true)
+ assert.deepEqual(inspection.compatibility.unknownTypeIds, ['Vendor::CustomFeature'])
+ assert.ok(inspection.entries.some((entry) => entry.role === 'script'))
+})
+
+test('FCStd inspection rejects missing metadata, traversal paths, and suspicious compression ratios', () => {
+ assert.throws(() => inspectFcstdArchive(zipSync({ 'GuiDocument.xml': strToU8('') })), /Document\.xml/)
+ assert.throws(() => inspectFcstdArchive(zipSync({ '../Document.xml': strToU8('') })), /Unsafe FCStd entry path/)
+ const compressed = zipSync({ 'Document.xml': strToU8(`${' '.repeat(20_000)}`) })
+ assert.throws(() => inspectFcstdArchive(compressed, { maxCompressionRatio: 2 }), /compression ratio/)
+})
+
+test('FCStd inspection is available only through the facade project boundary', () => {
+ const facade = createMockFacade()
+ const archive = zipSync({ 'Document.xml': strToU8('') })
+ const inspection = facade.project.fcstd.inspect(archive)
+ assert.equal(inspection.format, 'FCStd')
+ assert.equal(inspection.compatibility.level, 'metadata-compatible')
+ assert.equal(inspection.compatibility.readOnly, true)
+})
+
test('project resources use content identity and reference counting', async () => {
const facade = createMockFacade()
const bytes = new Uint8Array([1, 2, 3, 5, 8])