P7/P4: harden recompute and FCStd boundaries
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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` 还要求锁定版本后的逐项差异为零。
|
||||
|
||||
@@ -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 构建验证。
|
||||
|
||||
|
||||
124
package-lock.json
generated
124
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
39
src/App.tsx
39
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}
|
||||
/>
|
||||
) : (
|
||||
<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
243
src/facade/fcstd.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
208
src/facade/recomputeEngine.ts
Normal file
208
src/facade/recomputeEngine.ts
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user