diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index b529f8a..6d952df 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "freecadBaseline": "freecad-1.1.1", "workbenches": { - "Part Design": { "status": "ui-prototype", "level": "read-only", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, - "Part": { "status": "ui-prototype", "level": "read-only", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, - "Sketcher": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical"] }, + "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, + "Part": { "status": "ui-manifest", "level": "unsupported", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, + "Sketcher": { "status": "facade-model", "level": "experimental", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical", "solve-sketch"] }, "Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] }, "TechDraw": { "status": "ui-prototype", "level": "read-only", "commands": ["new-page", "view-part", "section-view", "dimension", "annotation"] }, "Spreadsheet": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sheet", "set-alias", "expression", "bind-property"] }, @@ -16,6 +16,12 @@ "Surface": { "status": "ui-manifest", "level": "unsupported", "commands": ["bspline", "bezier", "fill-surface", "loft-surface", "trim-surface"] }, "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } }, + "facadeCapabilities": { + "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "pad", "pocket", "revolution", "fillet", "chamfer", "step-export", "stl-export"] }, + "document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "sync-recompute", "async-generation-recompute", "undo-redo"] }, + "sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "basic-solver", "persistence"] }, + "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] } + }, "rules": [ "UI status does not imply geometry or file compatibility.", "Every implementation must update this matrix before claiming support.", diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 3928f8a..cc5deca 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -315,3 +315,28 @@ Sketcher 已有 Facade 事务模型和基础求解器适配器:Sketch 几何/ Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade,支持全边或显式索引、参数验证、句柄版本和 Worker 隔离。由于 OCCT 瞬时边索引不是持久命名,当前接口仍不能作为 FreeCAD PartDesign 的长期选边引用;必须等 TSN 的边级 `TopoRef`、历史映射和参数化重算完成后,才能标记为 `compatible`。 Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape;对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。 + +## 17. 本轮后续实现记录与下一批任务 + +本轮继续执行了重算、文件安全和命令能力三个关键边界。实现与验收必须按下面的定义理解,不能把“接口存在”解释成“FreeCAD 业务已完成”: + +| 任务 | 实施措施 | 验证结果 | 剩余边界 | +|---|---|---|---| +| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation;后发运行取消前发;节点完成后校验 AbortSignal、文档 ID/版本和当前 generation;结果状态为 `completed/failed/cancelled/stale` | 取消、过期版本、依赖失败传播和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape;尚未实现 Worker 并行层 | +| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 | +| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 | +| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 | +| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip | +| P1-02 命令准确性 | manifest 仍可完整展示;未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | `linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | + +后续按以下顺序连续开发: + +1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加失败保留最近有效 Shape、抑制、恢复上游后的最小闭包重算和诊断树。 +2. `P3-04/P3-05`:建立 Pad/Pocket/Revolution/Fillet/Chamfer 参数到 OCCT Worker 的输入适配和 Shape 资源回写;Pattern/Hole 继续保持 disabled,直到真实 executor 可复现。 +3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。 +4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM,按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。 +5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。 +6. `FC-04/05/06/10`:增加 XML/Shape/Expression/Link 映射,未知字段原样保留,完成 WebCAD↔FCStd↔FreeCAD 差异报告。 +7. `P6-03/P8-02`:把 disabled 原因、重算进度/取消、FCStd 报告和能力矩阵接入 React,并通过目标浏览器回放和视觉基线。 + +启用规则:`RecomputeExecutionResult.status=completed` 只表示已注册节点成功;FCStd `metadata-compatible` 只表示安全元数据可读;只有 FreeCAD 对照黄金测试、取消/失败/只读、保存/加载和跨浏览器门禁都通过,才能把对象或命令从 `experimental` 提升到 `compatible`,`exact` 还要求锁定版本后的逐项差异为零。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index cedee17..6ab7ddb 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1403,13 +1403,13 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | 全量静态页面和 CAD Workspace | `DONE` | `src/App.tsx`、`src/styles.css`;桌面/移动截图验收通过 | | 工作台/菜单/命令机器可读 manifest | `DONE` | `src/freecadManifest.ts`,13 个工作台和命令分组 | | BitBybit Web CAD Facade 最小合同 | `DONE` | `src/facade/types.ts`、`src/facade/mockFacade.ts`;真实内核适配仍待 P3/P4 | -| FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 | +| FreeCAD/OCCT 几何 WASM | `IN PROGRESS` | `src/facade/geometryRuntime.ts` 已通过 BitBybit OCCT 1.1.1 Worker 接入基本体、布尔、Pad/Pocket/Revolution、Fillet/Chamfer 和 STEP/ASCII STL 导出;FreeCAD 自构建内核和完整特征历史仍未完成 | | Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`,WebGL2 适配器已挂入 Facade | | React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 Facade 事件驱动 | -| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | `projectSchema.ts`、`persistenceWorker.ts` 和 `projectStore.ts` 已落地;Worker 具备 OPFS VFS/内存降级和 Document/Object 保存恢复,自动保存、迁移回滚、配额与跨标签写者仍待 P2-02/P2-06 | -| 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 | +| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 | +| 标准格式与 FCStd 兼容 | `IN PROGRESS` | BitBybit STEP/ASCII STL 导出已接入;`src/facade/fcstd.ts` 已完成安全 ZIP 预检和 Document.xml 元数据/代理报告,FCStd 对象映射、BRep 读写和 round-trip 仍待 P7 | | Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` | -| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 | +| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 35 个 Facade 测试、入口守卫和构建;跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 | ### 16.3 阶段门和交付节奏 @@ -1589,18 +1589,18 @@ P0 基线/治理 | P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 | | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | -| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 | -| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;Expression 求值、单位换算、多选 mixed 与链接循环仍待实现 | -| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | +| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、容器规则和原子崩溃恢复仍待实现 | +| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 已落地,函数库、locale、多选 mixed 和持久引用迁移仍待实现 | +| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;稳定子形状命名尚未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | -| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;取消、进度、Through all/Up to face 和文档重计算接线尚未完成 | +| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 generation/cancel 协议已完成,真实特征参数执行、Through all/Up to face 和几何结果回写尚未完成 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 35 个场景,包括 async generation 重算、FCStd 安全检查、schema、串行持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 | 本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。 diff --git a/package-lock.json b/package-lock.json index 9b9f000..f767ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@bitbybit-dev/occt": "1.1.1", "@bitbybit-dev/occt-worker": "1.1.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fast-xml-parser": "^5.10.1", + "fflate": "^0.8.3", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -809,6 +811,18 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1272,6 +1286,18 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", @@ -1422,6 +1448,45 @@ "node": ">=6" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1443,7 +1508,7 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true + "license": "MIT" }, "node_modules/fsevents": { "version": "2.3.3", @@ -1468,6 +1533,18 @@ "node": ">=6.9.0" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1564,6 +1641,21 @@ "node": ">=18" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1721,6 +1813,21 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/three": { "version": "0.185.1", "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", @@ -2339,6 +2446,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f586e71..777e2df 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@bitbybit-dev/occt": "1.1.1", "@bitbybit-dev/occt-worker": "1.1.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fast-xml-parser": "5.10.1", + "fflate": "0.8.3", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index b07d125..e1814cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ import { Trash2, Undo2, Redo2, + RefreshCw, Upload, UserRound, WandSparkles, @@ -53,7 +54,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' -import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade' +import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -190,7 +191,7 @@ function App() { showNotice={showNotice} /> ) : ( - + )} {notice &&
{notice}
} @@ -199,6 +200,7 @@ function App() { function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; facade: BitBybitWebCadFacade; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) { const [openMenu, setOpenMenu] = useState(null) + const document = facade.getState().document const handleMenuCommand = (command: string) => { setOpenMenu(null) if (command === 'help' || command === 'shortcuts') onNavigate('help') @@ -220,8 +222,8 @@ function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page
-
Pump Housing / {workbench}
- Unsaved changes +
{document.label} / {workbench}
+ {document.dirty ? 'Unsaved changes' : 'Saved locally'}
facade.history.undo()} disabled={!facade.history.canUndo()} /> facade.history.redo()} disabled={!facade.history.canRedo()} /> @@ -257,6 +259,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set { facade.gui.command.execute({ commandId: 'new-document' }); showNotice('New document prepared') }} /> showNotice('Project browser opened')} /> { facade.gui.command.execute({ commandId: 'save' }); showNotice('Saved to local workspace') }} /> + { void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Recompute completed' : `Recompute ${result.status}`)) }} />
{ facade.gui.command.execute({ commandId: 'create-body' }); showNotice('Create Body task opened') }} active={workbench === 'Part Design'} /> { facade.gui.command.execute({ commandId: 'create-sketch' }); showNotice('Select a plane to create a sketch') }} /> @@ -269,11 +272,11 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
- +
- mm · 18 objects · v0.1 draft + {document.units} · {document.objects.length} objects · v{document.version}
- {bottomOpen &&
Recompute completedDocument / Body / Fillet142 ms
Reference face may change after Pocket editjust now
} + {bottomOpen &&
{document.recompute?.status === 'failed' ? : }Recompute {document.recompute?.status || 'idle'}{document.recompute?.order.length || 0} objects in plangeneration {document.recompute?.generation || 0}
{(document.recompute?.errors || []).slice(0, 2).map((error) =>
{error.message}{error.code}
)}
}
) @@ -420,12 +423,12 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo return
{workbench}Body / Fillet
setSelectedObject('')}>
SelectedEdgesDatum
} -function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { +function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice, facade }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { const pages: Record, ReactNode> = { start: , projects: , - import: , - export: , + import: , + export: , settings: , help: , diagnostics: , @@ -459,9 +462,19 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: return
} />
/
Sort by
{projects.map((project, index) =>
{project.name}{project.path}
{project.objects} objects{project.modified}{project.state}
)}
Local storage3.4 GB of 10 GB used · Last backup just now
} -function FileFlowPage({ mode, onNavigate, showNotice }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void }) { +function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { const isImport = mode === 'import' - return
onNavigate('start')} />
Step 01 / 03

{isImport ? 'Select a CAD file' : 'Select an export format'}

Local only
{isImport ? <>
Drop a model hereor browse from your deviceSTEP · IGES · STL · OBJ · PLY · FCStd
Imported objects will appear as editable or read-only proxies depending on format support.
:
{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
+ const [fcstdReport, setFcstdReport] = useState(null) + const fileInputRef = useRef(null) + const inspectFile = async (file: File) => { + if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return } + try { + const report = facade.project.fcstd.inspect(new Uint8Array(await file.arrayBuffer())) + setFcstdReport(report) + showNotice(`FCStd inspected: ${report.compatibility.level}`) + } catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) } + } + return
onNavigate('start')} />
Step 01 / 03

{isImport ? 'Select a CAD file' : 'Select an export format'}

Local only
{isImport ? <>
Drop a model hereor browse from your device { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} />STEP · IGES · STL · OBJ · PLY · FCStd
FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.
{fcstdReport &&
{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}
} :
{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => )}
}
} function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) { @@ -490,7 +503,7 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) { } function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) { - return
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
+ return
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
} function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) { diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts new file mode 100644 index 0000000..2d1f0fd --- /dev/null +++ b/src/facade/fcstd.ts @@ -0,0 +1,243 @@ +import { unzipSync } from 'fflate' +import { XMLParser } from 'fast-xml-parser' + +export type FcstdArchiveLimits = { + maxArchiveBytes: number + maxEntries: number + maxEntryBytes: number + maxTotalUncompressedBytes: number + maxCompressionRatio: number +} + +export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'thumbnail' | 'script' | 'resource' + +export type FcstdEntryMetadata = { + path: string + compressedBytes: number + uncompressedBytes: number + compressionMethod: number + role: FcstdEntryRole +} + +export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked' + +export type FcstdObjectSummary = { + name: string + label: string + typeId: string + propertyCount: number + support: FcstdObjectSupport +} + +export type FcstdCompatibilityReport = { + level: 'metadata-compatible' | 'partial' | 'blocked' + readOnly: true + codeExecutionBlocked: true + recognizedObjects: number + proxyObjects: number + blockedObjects: number + unknownTypeIds: string[] + warnings: string[] +} + +export type FcstdInspection = { + format: 'FCStd' + schemaVersion: string + label: string + entries: FcstdEntryMetadata[] + objects: FcstdObjectSummary[] + compatibility: FcstdCompatibilityReport +} + +export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = { + maxArchiveBytes: 256 * 1024 * 1024, + maxEntries: 20_000, + maxEntryBytes: 128 * 1024 * 1024, + maxTotalUncompressedBytes: 512 * 1024 * 1024, + maxCompressionRatio: 200, +} + +const recognizedTypeIds = new Set([ + 'App::DocumentObjectGroup', + 'App::FeaturePython', + 'Part::Feature', + 'Part::FeaturePython', + 'PartDesign::Body', + 'PartDesign::Feature', + 'PartDesign::Pad', + 'PartDesign::Pocket', + 'PartDesign::Fillet', + 'PartDesign::Chamfer', + 'PartDesign::Revolution', + 'Sketcher::SketchObject', +]) + +const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId) + +const entryRole = (path: string): FcstdEntryRole => { + const lower = path.toLowerCase() + if (lower === 'document.xml') return 'document' + if (lower === 'guidocument.xml') return 'gui-document' + if (lower === 'thumbnails/thumbnail.png') return 'thumbnail' + if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape' + if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script' + return 'resource' +} + +const validateEntryPath = (path: string) => { + if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || ''}`) + const segments = path.split('/') + if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`) +} + +const findEndOfCentralDirectory = (bytes: Uint8Array) => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const minimum = Math.max(0, bytes.byteLength - 65_557) + for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) { + if (view.getUint32(offset, true) === 0x06054b50) return offset + } + throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.') +} + +const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => { + if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`) + if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.') + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const eocd = findEndOfCentralDirectory(bytes) + const diskNumber = view.getUint16(eocd + 4, true) + const centralDirectoryDisk = view.getUint16(eocd + 6, true) + const entriesOnDisk = view.getUint16(eocd + 8, true) + const entryCount = view.getUint16(eocd + 10, true) + const directorySize = view.getUint32(eocd + 12, true) + const directoryOffset = view.getUint32(eocd + 16, true) + const commentLength = view.getUint16(eocd + 20, true) + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.') + if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.') + if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`) + if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.') + + const decoder = new TextDecoder('utf-8', { fatal: true }) + const entries: FcstdEntryMetadata[] = [] + const paths = new Set() + let cursor = directoryOffset + let totalUncompressed = 0 + for (let index = 0; index < entryCount; index += 1) { + if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.') + const flags = view.getUint16(cursor + 8, true) + const compressionMethod = view.getUint16(cursor + 10, true) + const compressedBytes = view.getUint32(cursor + 20, true) + const uncompressedBytes = view.getUint32(cursor + 24, true) + const nameLength = view.getUint16(cursor + 28, true) + const extraLength = view.getUint16(cursor + 30, true) + const entryCommentLength = view.getUint16(cursor + 32, true) + const diskStart = view.getUint16(cursor + 34, true) + const next = cursor + 46 + nameLength + extraLength + entryCommentLength + if (next > eocd) throw new Error('FCStd central-directory entry is truncated.') + if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.') + if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.') + if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`) + if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.') + const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength)) + validateEntryPath(path) + if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`) + paths.add(path) + if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`) + const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes + if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`) + totalUncompressed += uncompressedBytes + if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`) + entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) }) + cursor = next + } + if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.') + return entries +} + +const asArray = (value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value] + +const attribute = (node: unknown, name: string): string => { + if (!node || typeof node !== 'object') return '' + const record = node as Record + const value = record[`@_${name}`] ?? record[name] + return value === undefined || value === null ? '' : String(value) +} + +const propertyValue = (property: Record): string => { + for (const value of Object.values(property)) { + if (!value || typeof value !== 'object') continue + const candidate = attribute(value, 'value') + if (candidate) return candidate + } + return '' +} + +const parseDocumentXml = (bytes: Uint8Array) => { + const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + if (/ + const root = (parsed.Document ?? parsed) as Record + const objectDeclarations = asArray((((root.Objects as Record | undefined)?.Object) as Record | Record[] | undefined)) + const objectData = asArray((((root.ObjectData as Record | undefined)?.Object) as Record | Record[] | undefined)) + const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data])) + const documentProperties = asArray((((root.Properties as Record | undefined)?.Property) as Record | Record[] | undefined)) + const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label') + const objects = objectDeclarations.map((declaration): FcstdObjectSummary => { + const name = attribute(declaration, 'name') || '' + const typeId = attribute(declaration, 'type') || 'App::DocumentObject' + const data = dataByName.get(name) + const properties = asArray((((data?.Properties as Record | undefined)?.Property) as Record | Record[] | undefined)) + const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label') + const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy' + return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support } + }) + return { + schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown', + label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document', + objects, + } +} + +export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial = {}): FcstdInspection => { + const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides } + for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`) + const entries = inspectZipDirectory(bytes, limits) + const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml') + if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.') + const files = unzipSync(bytes) + for (const entry of entries) { + const content = files[entry.path] + if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`) + } + const document = parseDocumentXml(files[documentEntry.path]) + const warnings: string[] = [] + const scriptEntries = entries.filter((entry) => entry.role === 'script') + if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`) + const blockedObjects = document.objects.filter((object) => object.support === 'blocked') + if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`) + const proxyObjects = document.objects.filter((object) => object.support === 'proxy') + if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`) + const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible' + return { + format: 'FCStd', + schemaVersion: document.schemaVersion, + label: document.label, + entries, + objects: document.objects, + compatibility: { + level, + readOnly: true, + codeExecutionBlocked: true, + recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length, + proxyObjects: proxyObjects.length, + blockedObjects: blockedObjects.length, + unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(), + warnings, + }, + } +} diff --git a/src/facade/index.ts b/src/facade/index.ts index 801ca10..90709bf 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -7,3 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' +export { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine' +export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine' +export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd' +export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 3cd27c7..e01c6a9 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -25,6 +25,8 @@ import { ThreeViewportAdapter } from './threeViewport' import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph' import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units' import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher' +import { executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' +import { inspectFcstdArchive } from './fcstd' const initialTree: ModelTreeItem[] = [ { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, @@ -130,6 +132,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => { const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) const systemCommands = new Set(['new-document', 'save', 'select-object']) +const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch']) const featureCommands: Record = { 'create-body': { label: 'Body', detail: 'Part Design body' }, 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, @@ -142,6 +145,7 @@ const featureCommands: Record = { const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' } + if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' } if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' } if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } return { id: commandId, status: 'enabled' } @@ -212,6 +216,10 @@ export function createMockFacade(): BitBybitWebCadFacade { const geometryRuntime = new BitbybitGeometryRuntime() const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } + const recomputeCoordinator = new RecomputeCoordinator( + executeFacadeRecomputeNode, + (documentId) => state.document.id === documentId ? state.document.version : null, + ) const listeners = new Set() const undoStack: FacadeState[] = [] const redoStack: FacadeState[] = [] @@ -332,6 +340,35 @@ export function createMockFacade(): BitBybitWebCadFacade { emitState() return { ...plan, generation, status: nextRecompute.status, errors } } + const recomputeDocumentAsync = async (options: RecomputeExecutionOptions = {}) => { + const source = cloneDocumentSnapshot(state.document) + const result = await recomputeCoordinator.run(source, options) + if ((result.status !== 'completed' && result.status !== 'failed') || state.document.id !== source.id || state.document.version !== source.version) return result + + const document = cloneDocumentSnapshot(state.document) + const updates = new Map(result.objectUpdates.map((object) => [object.id, object])) + document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object) + for (const objectId of result.affected) { + const item = document.tree.find((candidate) => candidate.id === objectId) + if (!item || item.state === 'readonly') continue + if (result.objectStates[objectId] === 'up-to-date') item.state = item.type === 'body' ? 'active' : 'valid' + else if (result.objectStates[objectId] === 'error' || result.objectStates[objectId] === 'upstream-failed') item.state = 'warning' + const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') + if (status) status.value = result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning' + } + document.recompute = { + generation: result.generation, + status: result.status, + objectStates: result.objectStates, + dirtyObjects: result.dirtyObjects, + order: result.order, + errors: result.errors, + } + state = { ...state, document } + if (document.dirty) autosave.schedule(document) + emitState() + return result + } const getSketch = (objectId: string) => { const object = state.document.objects.find((candidate) => candidate.id === objectId) return object?.sketch ? cloneSketch(object.sketch) : null @@ -395,7 +432,8 @@ export function createMockFacade(): BitBybitWebCadFacade { const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId) if (status.status === 'disabled') { - const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'COMMAND_DISABLED', message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } + const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED' + const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId } @@ -412,17 +450,18 @@ export function createMockFacade(): BitBybitWebCadFacade { } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') } + else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null }) else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } const facade: BitBybitWebCadFacade = { - app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, + app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, - project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource }, + project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts new file mode 100644 index 0000000..7f49bfe --- /dev/null +++ b/src/facade/recomputeEngine.ts @@ -0,0 +1,208 @@ +import { DependencyGraph, type RecomputeState } from './dependencyGraph' +import { cloneSketch, solveSketch } from './sketcher' +import type { DocumentObjectSnapshot, DocumentSnapshot } from './types' + +export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' + +export type RecomputeExecutionError = { + objectId: string + code: string + message: string +} + +export type RecomputeNodeContext = { + documentId: string + documentVersion: number + generation: number + signal: AbortSignal +} + +export type RecomputeNodeResult = { + status: 'success' | 'failed' + errors?: RecomputeExecutionError[] + updatedObject?: DocumentObjectSnapshot +} + +export type RecomputeNodeExecutor = ( + object: DocumentObjectSnapshot, + document: DocumentSnapshot, + context: RecomputeNodeContext, +) => Promise + +export type RecomputeProgress = { + generation: number + documentVersion: number + objectId: string + completed: number + total: number + state: RecomputeState +} + +export type RecomputeExecutionOptions = { + dirtyObjectIds?: string[] + onProgress?: (progress: RecomputeProgress) => void +} + +export type RecomputeExecutionResult = { + generation: number + documentVersion: number + status: RecomputeExecutionStatus + affected: string[] + order: string[] + completed: string[] + failed: string[] + skipped: string[] + dirtyObjects: string[] + objectStates: Record + objectUpdates: DocumentObjectSnapshot[] + errors: RecomputeExecutionError[] +} + +const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError' + +export class RecomputeCoordinator { + private active: { generation: number; controller: AbortController } | null = null + private generation = 0 + + constructor( + private readonly executeNode: RecomputeNodeExecutor, + private readonly currentDocumentVersion: (documentId: string) => number | null, + ) {} + + cancel() { + this.active?.controller.abort() + } + + async run(document: DocumentSnapshot, options: RecomputeExecutionOptions = {}): Promise { + this.active?.controller.abort() + const controller = new AbortController() + const generation = Math.max(this.generation, document.recompute?.generation ?? 0) + 1 + this.generation = generation + this.active = { generation, controller } + + const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) + const dirtyObjectIds = options.dirtyObjectIds ?? document.recompute?.dirtyObjects ?? [] + const plan = graph.plan(dirtyObjectIds) + const objectStates: Record = { + ...Object.fromEntries(document.objects.map((object) => [object.id, 'up-to-date' as const])), + ...(document.recompute?.objectStates ?? {}), + } + for (const objectId of plan.affected) objectStates[objectId] = 'recomputing' + + const completed: string[] = [] + const failed: string[] = [] + const skipped: string[] = [] + const errors: RecomputeExecutionError[] = [] + const objectUpdates: DocumentObjectSnapshot[] = [] + const objectById = new Map(document.objects.map((object) => [object.id, object])) + + for (const cycle of plan.cycles) { + const message = `Dependency cycle: ${cycle.join(' -> ')}` + for (const objectId of cycle) { + objectStates[objectId] = 'error' + failed.push(objectId) + errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) + } + } + + const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => { + if (status === 'cancelled' || status === 'stale') { + for (const objectId of plan.affected) if (!completed.includes(objectId)) objectStates[objectId] = 'touched' + } + if (this.active?.generation === generation) this.active = null + return { + generation, + documentVersion: document.version, + status, + affected: plan.affected, + order: plan.order, + completed, + failed, + skipped, + dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] !== 'up-to-date'), + objectStates, + objectUpdates, + errors, + } + } + + for (const objectId of plan.order) { + if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled') + if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale') + + const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed') + if (failedDependency) { + objectStates[objectId] = 'upstream-failed' + skipped.push(objectId) + errors.push({ objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` }) + options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: 'upstream-failed' }) + continue + } + + const object = objectById.get(objectId) + if (!object) { + objectStates[objectId] = 'error' + failed.push(objectId) + errors.push({ objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` }) + continue + } + + try { + const result = await this.executeNode(object, document, { + documentId: document.id, + documentVersion: document.version, + generation, + signal: controller.signal, + }) + if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled') + if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale') + if (result.status === 'failed') { + objectStates[objectId] = 'error' + failed.push(objectId) + errors.push(...(result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }])) + } else { + objectStates[objectId] = 'up-to-date' + completed.push(objectId) + if (result.updatedObject) objectUpdates.push(result.updatedObject) + } + } catch (error) { + if (controller.signal.aborted || isAbortError(error)) return terminalResult('cancelled') + objectStates[objectId] = 'error' + failed.push(objectId) + errors.push({ objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) }) + } + options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: objectStates[objectId] }) + } + + if (this.active?.generation === generation) this.active = null + return terminalResult(errors.length > 0 ? 'failed' : 'completed') + } +} + +export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => { + if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError') + const expressionError = object.properties.find((property) => property.expressionError) + if (expressionError) { + return { + status: 'failed', + errors: [{ objectId: object.id, code: 'EXPRESSION_ERROR', message: expressionError.expressionError as string }], + } + } + if (!object.sketch) return { status: 'success' } + + const solved = solveSketch(object.sketch) + if (solved.status === 'conflicting' || solved.status === 'invalid') { + return { + status: 'failed', + errors: solved.diagnostics.map((diagnostic) => ({ objectId: object.id, code: diagnostic.code, message: diagnostic.message })), + } + } + const updatedObject: DocumentObjectSnapshot = { + ...object, + properties: object.properties.map((property) => property.name === 'ConstraintStatus' + ? { ...property, value: solved.status === 'solved' ? 'Fully constrained' : `Under-constrained (${solved.degreesOfFreedom} DOF)` } + : { ...property, options: property.options ? [...property.options] : undefined }), + sketch: cloneSketch(solved.snapshot), + } + return { status: 'success', updatedObject } +} diff --git a/src/facade/types.ts b/src/facade/types.ts index 064802f..b152b67 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -2,6 +2,8 @@ import type { CommandDefinition, WorkbenchId } from '../freecadManifest' import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph' import type { Quantity, QuantityDimension } from './units' import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher' +import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine' +import type { FcstdArchiveLimits, FcstdInspection } from './fcstd' export type ModelTreeItem = { id: string @@ -317,6 +319,8 @@ export interface BitBybitWebCadFacade { setProperty(input: SetPropertyInput): void setExpression(input: SetExpressionInput): void recompute(): RecomputeResult + recomputeAsync(options?: RecomputeExecutionOptions): Promise + cancelRecompute(): void getDependencies(): DependencyEdge[] } expression: { @@ -364,6 +368,9 @@ export interface BitBybitWebCadFacade { capabilities(): PersistenceCapabilities save(document?: DocumentSnapshot): Promise load(documentId: string): Promise + fcstd: { + inspect(bytes: Uint8Array, limits?: Partial): FcstdInspection + } resource: { put(bytes: Uint8Array, mediaType: string): Promise get(hash: string): Promise diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 005eb8d..1e5f1e0 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -1,5 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { strToU8, zipSync } from 'fflate' import { createMockFacade } from '../src/facade/mockFacade' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' @@ -7,8 +8,36 @@ import { DependencyGraph } from '../src/facade/dependencyGraph' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming' import { createSketch, solveSketch } from '../src/facade/sketcher' +import { RecomputeCoordinator } from '../src/facade/recomputeEngine' +import { inspectFcstdArchive } from '../src/facade/fcstd' import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' -import type { ShapeHandle } from '../src/facade/types' +import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types' + +const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({ + id: 'doc-recompute', + label: 'Recompute fixture', + version: 1, + dirty: true, + readOnly: false, + units: 'mm', + tree: [ + { id: 'root', label: 'Root', type: 'feature', state: 'dirty' }, + { id: 'child', label: 'Child', type: 'feature', state: 'dirty' }, + ], + objects: [ + { id: 'root', typeId: 'Part::Feature', properties: [] }, + { id: 'child', typeId: 'Part::Feature', properties: [] }, + ], + dependencies: edges, + recompute: { + generation: 0, + status: 'idle', + objectStates: { root: 'touched', child: 'touched' }, + dirtyObjects: ['root'], + order: [], + errors: [], + }, +}) test('facade exposes a stable initial document projection', () => { const facade = createMockFacade() @@ -174,6 +203,66 @@ test('dependency graph propagates dirty state and orders dependencies', () => { assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']]) }) +test('generation-aware recompute cancels an older run before accepting its result', async () => { + let version = 1 + const coordinator = new RecomputeCoordinator(async (object, _document, context) => { + if (context.generation === 1) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 30) + context.signal.addEventListener('abort', () => { + clearTimeout(timer) + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, { once: true }) + }) + } + return { status: 'success', updatedObject: object } + }, () => version) + const document = recomputeDocumentFixture() + const first = coordinator.run(document) + const second = coordinator.run(document) + assert.equal((await first).status, 'cancelled') + const accepted = await second + assert.equal(accepted.status, 'completed') + assert.equal(accepted.generation, 2) + assert.deepEqual(accepted.completed, ['root']) + version = 2 +}) + +test('recompute rejects results from an obsolete document version', async () => { + let version = 1 + const coordinator = new RecomputeCoordinator(async () => { + version = 2 + return { status: 'success' } + }, () => version) + const result = await coordinator.run(recomputeDocumentFixture()) + assert.equal(result.status, 'stale') + assert.deepEqual(result.dirtyObjects, ['root']) +}) + +test('recompute failure marks dependent objects as upstream-failed', async () => { + const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }]) + const coordinator = new RecomputeCoordinator(async (object) => object.id === 'root' + ? { status: 'failed', errors: [{ objectId: object.id, code: 'TEST_FAILURE', message: 'expected failure' }] } + : { status: 'success' }, () => 1) + const result = await coordinator.run(document) + assert.equal(result.status, 'failed') + assert.deepEqual(result.failed, ['root']) + assert.deepEqual(result.skipped, ['child']) + assert.equal(result.objectStates.child, 'upstream-failed') + assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED')) +}) + +test('facade async recompute commits only an accepted generation', async () => { + const facade = createMockFacade() + facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 }) + const result = await facade.app.document.recomputeAsync() + assert.equal(result.status, 'completed') + assert.equal(facade.app.document.getActive().recompute?.generation, result.generation) + assert.equal(facade.app.document.getActive().recompute?.dirtyObjects.length, 0) +}) + test('persistence writes are serialized and continue after a failed write', async () => { const queue = new PersistenceWriteQueue() let inFlight = 0 @@ -248,6 +337,17 @@ test('disabled commands return a reason instead of mutating the document', () => assert.equal(facade.getState().document.version, before) }) +test('manifest commands without a Bitbybit executor are explicitly unsupported', () => { + const facade = createMockFacade() + const before = facade.getState().document.version + const state = facade.gui.command.getState('linear-pattern') + assert.equal(state.status, 'disabled') + assert.match(state.reason || '', /business executor/) + facade.gui.command.execute({ commandId: 'linear-pattern' }) + assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED') + assert.equal(facade.getState().document.version, before) +}) + test('command events carry a document-scoped request context', () => { const facade = createMockFacade() const events: string[] = [] @@ -334,6 +434,55 @@ test('project persistence remains behind the facade contract', async () => { assert.equal(facade.project.capabilities().mode, 'sqlite-memory') }) +test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => { + const documentXml = ` + + + + + + + + + + + + +` + const archive = zipSync({ + 'Document.xml': strToU8(documentXml), + 'GuiDocument.xml': strToU8(''), + 'Macro/unsafe.py': strToU8('raise RuntimeError("must not run")'), + }) + const inspection = inspectFcstdArchive(archive) + assert.equal(inspection.label, 'Imported assembly') + assert.equal(inspection.schemaVersion, '4') + assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body') + assert.equal(inspection.compatibility.recognizedObjects, 1) + assert.equal(inspection.compatibility.proxyObjects, 1) + assert.equal(inspection.compatibility.blockedObjects, 1) + assert.equal(inspection.compatibility.level, 'blocked') + assert.equal(inspection.compatibility.codeExecutionBlocked, true) + assert.deepEqual(inspection.compatibility.unknownTypeIds, ['Vendor::CustomFeature']) + assert.ok(inspection.entries.some((entry) => entry.role === 'script')) +}) + +test('FCStd inspection rejects missing metadata, traversal paths, and suspicious compression ratios', () => { + assert.throws(() => inspectFcstdArchive(zipSync({ 'GuiDocument.xml': strToU8('') })), /Document\.xml/) + assert.throws(() => inspectFcstdArchive(zipSync({ '../Document.xml': strToU8('') })), /Unsafe FCStd entry path/) + const compressed = zipSync({ 'Document.xml': strToU8(`${' '.repeat(20_000)}`) }) + assert.throws(() => inspectFcstdArchive(compressed, { maxCompressionRatio: 2 }), /compression ratio/) +}) + +test('FCStd inspection is available only through the facade project boundary', () => { + const facade = createMockFacade() + const archive = zipSync({ 'Document.xml': strToU8('') }) + const inspection = facade.project.fcstd.inspect(archive) + assert.equal(inspection.format, 'FCStd') + assert.equal(inspection.compatibility.level, 'metadata-compatible') + assert.equal(inspection.compatibility.readOnly, true) +}) + test('project resources use content identity and reference counting', async () => { const facade = createMockFacade() const bytes = new Uint8Array([1, 2, 3, 5, 8])