P7/P4: harden recompute and FCStd boundaries

This commit is contained in:
2026-08-02 14:33:41 -04:00
parent 022a5dde2f
commit 4cc349a589
12 changed files with 848 additions and 30 deletions

View File

@@ -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.",

View File

@@ -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、属性数量和类型未知类型为 proxyPython 类型为 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` 还要求锁定版本后的逐项差异为零。

View File

@@ -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文档版本、dirtyUndo/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 构建验证。

124
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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}
/>
) : (
<PageFrame page={page} onNavigate={navigate} onOpenWorkspace={openWorkspace} showNotice={showNotice} />
<PageFrame page={page} onNavigate={navigate} onOpenWorkspace={openWorkspace} showNotice={showNotice} facade={facade} />
)}
{notice && <div className="toast"><CheckCircle2 size={16} />{notice}</div>}
</div>
@@ -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<MenuName | null>(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
</nav>
<div className="topbar-spacer" />
<div className="topbar-context">
<div className="context-document"><span className="context-dot" />Pump Housing <span className="context-separator">/</span> {workbench}</div>
<span className="save-state"><span className="save-dot" />Unsaved changes</span>
<div className="context-document"><span className="context-dot" />{document.label} <span className="context-separator">/</span> {workbench}</div>
<span className="save-state"><span className="save-dot" />{document.dirty ? 'Unsaved changes' : 'Saved locally'}</span>
</div>
<IconButton icon={Undo2} label="Undo" onClick={() => facade.history.undo()} disabled={!facade.history.canUndo()} />
<IconButton icon={Redo2} label="Redo" onClick={() => facade.history.redo()} disabled={!facade.history.canRedo()} />
@@ -257,6 +259,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
<IconButton icon={FilePlus2} label="New document" onClick={() => { facade.gui.command.execute({ commandId: 'new-document' }); showNotice('New document prepared') }} />
<IconButton icon={FolderOpen} label="Open project" onClick={() => showNotice('Project browser opened')} />
<IconButton icon={Save} label="Save project" onClick={() => { facade.gui.command.execute({ commandId: 'save' }); showNotice('Saved to local workspace') }} />
<IconButton icon={RefreshCw} label="Recompute document" disabled={document.readOnly} onClick={() => { void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Recompute completed' : `Recompute ${result.status}`)) }} />
<div className="toolbar-divider" />
<IconButton icon={Box} label="Create body" onClick={() => { facade.gui.command.execute({ commandId: 'create-body' }); showNotice('Create Body task opened') }} active={workbench === 'Part Design'} />
<IconButton icon={CircleDot} label="Create sketch" onClick={() => { 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
</div>
<WorkbenchNavBar workbench={workbench} setWorkbench={setWorkbench} showNotice={showNotice} />
<div className="document-tabs">
<button className="doc-tab is-active"><FileBox size={15} /><span>Pump Housing</span><span className="doc-unsaved" /><X size={13} /></button>
<button className="doc-tab is-active"><FileBox size={15} /><span>{document.label}</span>{document.dirty && <span className="doc-unsaved" />}<X size={13} /></button>
<button className="doc-tab" onClick={() => showNotice('Open Mounting Bracket') }><FileBox size={15} /><span>Mounting Bracket</span><X size={13} /></button>
<button className="new-tab" title="New document"><Plus size={16} /></button>
<div className="document-tab-spacer" />
<span className="document-meta">mm · 18 objects · v0.1 draft</span>
<span className="document-meta">{document.units} · {document.objects.length} objects · v{document.version}</span>
</div>
<div className="workspace-content">
<aside className="combo-panel left-panel">
@@ -290,7 +293,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
</div>
<div className={`bottom-drawer ${bottomOpen ? 'is-open' : ''}`}>
<div className="bottom-drawer-header"><div className="drawer-tabs"><button className="is-active"><PanelBottom size={14} />Report view <span className="tab-count">2</span></button><button><Clock3 size={14} />Jobs <span className="tab-count tab-count-green">1</span></button><button><Code2 size={14} />Diagnostics</button></div><button className="drawer-toggle" onClick={() => setBottomOpen(!bottomOpen)} title={bottomOpen ? 'Collapse bottom panel' : 'Expand bottom panel'}>{bottomOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}</button></div>
{bottomOpen && <div className="bottom-drawer-content"><div className="report-line"><CheckCircle2 size={14} className="icon-green" /><span>Recompute completed</span><span className="report-context">Document / Body / Fillet</span><span className="report-time">142 ms</span></div><div className="report-line"><AlertTriangle size={14} className="icon-amber" /><span>Reference face may change after Pocket edit</span><button onClick={() => showNotice('Dependency review opened')}>Review dependency</button><span className="report-time">just now</span></div></div>}
{bottomOpen && <div className="bottom-drawer-content"><div className="report-line">{document.recompute?.status === 'failed' ? <AlertTriangle size={14} className="icon-amber" /> : <CheckCircle2 size={14} className="icon-green" />}<span>Recompute {document.recompute?.status || 'idle'}</span><span className="report-context">{document.recompute?.order.length || 0} objects in plan</span><span className="report-time">generation {document.recompute?.generation || 0}</span></div>{(document.recompute?.errors || []).slice(0, 2).map((error) => <div className="report-line" key={`${error.objectId}-${error.code}`}><AlertTriangle size={14} className="icon-amber" /><span>{error.message}</span><button onClick={() => showNotice(`Dependency review: ${error.objectId}`)}>Review dependency</button><span className="report-time">{error.code}</span></div>)}</div>}
</div>
</main>
)
@@ -420,12 +423,12 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
return <div className="viewport"><div className="viewport-header"><div className="viewport-title"><span className="eyebrow">{workbench}</span><strong>Body / Fillet</strong></div><div className="viewport-actions"><IconButton icon={ZoomOut} label="Zoom out" /><IconButton icon={ZoomIn} label="Zoom in" /><IconButton icon={Rotate3D} label="Orbit view" active /></div></div><div className="viewport-grid" onClick={() => setSelectedObject('')}><div className="three-viewport-host" ref={hostRef} aria-label="Three.js viewport" /></div><div className="viewport-legend"><span><span className="legend-swatch selected" />Selected</span><span><span className="legend-swatch edge" />Edges</span><span><span className="legend-swatch datum" />Datum</span></div></div>
}
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<Exclude<Page, 'workspace'>, ReactNode> = {
start: <StartPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />,
projects: <ProjectsPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />,
import: <FileFlowPage mode="import" onNavigate={onNavigate} showNotice={showNotice} />,
export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} />,
import: <FileFlowPage mode="import" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
settings: <SettingsPage onNavigate={onNavigate} showNotice={showNotice} />,
help: <HelpPage onNavigate={onNavigate} showNotice={showNotice} />,
diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} />,
@@ -459,9 +462,19 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate:
return <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{projects.map((project, index) => <div className="project-list-row" key={project.name}><div className={`list-thumbnail preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.name}</strong><span>{project.path}</span></div><span className="list-count">{project.objects} objects</span><span className="list-modified">{project.modified}</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge><button className="row-action" title="More project actions" onClick={() => showNotice(`Actions for ${project.name}`)}><MoreHorizontal size={16} /></button></div>)}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>3.4 GB of 10 GB used · Last backup just now</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
}
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 <div className="flow-page"><PageHeader eyebrow={isImport ? 'Import model' : 'Export project'} title={isImport ? 'Bring a model into your workspace.' : 'Export a clean deliverable.'} description={isImport ? 'Review format, units and compatibility before the document is created.' : 'Choose an exchange format and keep the native project available for future edits.'} onBack={() => onNavigate('start')} /><div className="flow-layout"><div className="flow-steps"><FlowStep index="01" title="Choose file" active={!isImport} complete={isImport} /><FlowStep index="02" title="Review mapping" active={isImport} complete={false} /><FlowStep index="03" title="Confirm" active={false} complete={false} /></div><section className="flow-card panel-surface"><div className="flow-card-header"><div><span className="section-kicker">Step 01 / 03</span><h2>{isImport ? 'Select a CAD file' : 'Select an export format'}</h2></div><Badge tone="cyan">Local only</Badge></div>{isImport ? <><div className="drop-zone"><div className="drop-icon"><Upload size={20} /></div><strong>Drop a model here</strong><span>or browse from your device</span><button className="button button-outline" onClick={() => showNotice('File picker opened')}><FolderOpen size={15} />Browse files</button><small>STEP · IGES · STL · OBJ · PLY · FCStd</small></div><div className="flow-note"><AlertTriangle size={15} /><span>Imported objects will appear as editable or read-only proxies depending on format support.</span></div></> : <div className="format-grid">{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => <button key={format} className={`format-card ${index === 0 ? 'is-selected' : ''}`} onClick={() => showNotice(`${format} selected`)}><span className="format-icon">{index === 4 ? <FileBox size={18} /> : <FileText size={18} />}</span><strong>{format}</strong><small>{index === 0 ? 'Best for editable solids' : index === 2 ? 'Mesh export' : 'Exchange format'}</small>{index === 0 && <Check size={15} className="format-check" />}</button>)}</div>}<div className="flow-footer"><button className="button button-quiet" onClick={() => onNavigate('start')}>Cancel</button><button className="button button-primary" onClick={() => showNotice(isImport ? 'Next: review mapping' : 'Export queued')}><span>{isImport ? 'Continue' : 'Export'}</span><ArrowRight size={15} /></button></div></section></div></div>
const [fcstdReport, setFcstdReport] = useState<FcstdInspection | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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 <div className="flow-page"><PageHeader eyebrow={isImport ? 'Import model' : 'Export project'} title={isImport ? 'Bring a model into your workspace.' : 'Export a clean deliverable.'} description={isImport ? 'Review format, units and compatibility before the document is created.' : 'Choose an exchange format and keep the native project available for future edits.'} onBack={() => onNavigate('start')} /><div className="flow-layout"><div className="flow-steps"><FlowStep index="01" title="Choose file" active={!isImport} complete={isImport} /><FlowStep index="02" title="Review mapping" active={isImport} complete={false} /><FlowStep index="03" title="Confirm" active={false} complete={false} /></div><section className="flow-card panel-surface"><div className="flow-card-header"><div><span className="section-kicker">Step 01 / 03</span><h2>{isImport ? 'Select a CAD file' : 'Select an export format'}</h2></div><Badge tone="cyan">Local only</Badge></div>{isImport ? <><div className="drop-zone"><div className="drop-icon"><Upload size={20} /></div><strong>Drop a model here</strong><span>or browse from your device</span><input ref={fileInputRef} type="file" accept=".FCStd,.fcstd" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} /><button className="button button-outline" onClick={() => fileInputRef.current?.click()}><FolderOpen size={15} />Browse files</button><small>STEP · IGES · STL · OBJ · PLY · FCStd</small></div><div className="flow-note"><AlertTriangle size={15} /><span>FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.</span></div>{fcstdReport && <div className="flow-note"><CheckCircle2 size={15} className="icon-green" /><span>{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}</span></div>}</> : <div className="format-grid">{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => <button key={format} className={`format-card ${index === 0 ? 'is-selected' : ''}`} onClick={() => showNotice(`${format} selected`)}><span className="format-icon">{index === 4 ? <FileBox size={18} /> : <FileText size={18} />}</span><strong>{format}</strong><small>{index === 0 ? 'Best for editable solids' : index === 2 ? 'Mesh export' : 'Exchange format'}</small>{index === 0 && <Check size={15} className="format-check" />}</button>)}</div>}<div className="flow-footer"><button className="button button-quiet" onClick={() => onNavigate('start')}>Cancel</button><button className="button button-primary" onClick={() => showNotice(isImport ? (fcstdReport ? 'Metadata review complete; import mapping remains disabled' : 'Choose a file to review') : 'Export queued')}><span>{isImport ? 'Continue' : 'Export'}</span><ArrowRight size={15} /></button></div></section></div></div>
}
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 <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="Runtime package loaded on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · mock domain adapter" tone="green" /><DiagnosticRow name="Geometry Worker" value="Planned" detail="FreeCAD/OCCT WASM P3 gate" tone="cyan" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v1 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="BitBybit OCCT package loads on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value="BitBybit OCCT 1.1.1" detail="WASM Worker with Box, Boolean, feature and export boundaries" tone="green" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v4 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
}
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {

243
src/facade/fcstd.ts Normal file
View File

@@ -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 || '<empty>'}`)
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<string>()
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 = <T>(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<string, unknown>
const value = record[`@_${name}`] ?? record[name]
return value === undefined || value === null ? '' : String(value)
}
const propertyValue = (property: Record<string, unknown>): 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 (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('FCStd Document.xml declarations and entities are not allowed.')
const parsed = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
processEntities: false,
allowBooleanAttributes: false,
}).parse(xml) as Record<string, unknown>
const root = (parsed.Document ?? parsed) as Record<string, unknown>
const objectDeclarations = asArray((((root.Objects as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const objectData = asArray((((root.ObjectData as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
const documentProperties = asArray((((root.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
const name = attribute(declaration, 'name') || '<unnamed>'
const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
const data = dataByName.get(name)
const properties = asArray((((data?.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | 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<FcstdArchiveLimits> = {}): 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,
},
}
}

View File

@@ -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'

View File

@@ -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<string, { label: string; detail: string }> = {
'create-body': { label: 'Body', detail: 'Part Design body' },
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
@@ -142,6 +145,7 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
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<FacadeListener>()
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,

View File

@@ -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<RecomputeNodeResult>
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<string, RecomputeState>
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<RecomputeExecutionResult> {
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<string, RecomputeState> = {
...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 }
}

View File

@@ -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<RecomputeExecutionResult>
cancelRecompute(): void
getDependencies(): DependencyEdge[]
}
expression: {
@@ -364,6 +368,9 @@ export interface BitBybitWebCadFacade {
capabilities(): PersistenceCapabilities
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
fcstd: {
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
}
resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null>

View File

@@ -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<void>((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 = `<?xml version="1.0" encoding="UTF-8"?>
<Document SchemaVersion="4">
<Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Imported assembly"/></Property></Properties>
<Objects Count="3">
<Object type="PartDesign::Body" name="Body"/>
<Object type="Vendor::CustomFeature" name="Custom"/>
<Object type="Part::FeaturePython" name="Scripted"/>
</Objects>
<ObjectData Count="3">
<Object name="Body"><Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Main body"/></Property></Properties></Object>
<Object name="Custom"><Properties Count="0"/></Object>
<Object name="Scripted"><Properties Count="0"/></Object>
</ObjectData>
</Document>`
const archive = zipSync({
'Document.xml': strToU8(documentXml),
'GuiDocument.xml': strToU8('<GuiDocument/>'),
'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('<GuiDocument/>') })), /Document\.xml/)
assert.throws(() => inspectFcstdArchive(zipSync({ '../Document.xml': strToU8('<Document/>') })), /Unsafe FCStd entry path/)
const compressed = zipSync({ 'Document.xml': strToU8(`<Document>${' '.repeat(20_000)}</Document>`) })
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('<Document SchemaVersion="4"><Objects Count="0"/><ObjectData Count="0"/></Document>') })
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])