Compare commits

..

36 Commits

Author SHA1 Message Date
bc7adb500b feat: propagate feature suppression in recompute 2026-08-02 22:37:41 -04:00
fca408784d feat: recompute geometry after document load 2026-08-02 21:48:42 -04:00
10921a086e feat: maintain Part Design body tip 2026-08-02 21:40:44 -04:00
2a0b14b26d feat: expose persisted project summaries 2026-08-02 21:22:32 -04:00
ce64381677 fix: route workspace project open action 2026-08-02 21:10:15 -04:00
2a467cf8ea fix: distinguish missing recovery snapshots 2026-08-02 20:58:38 -04:00
9cc5e7847e fix: support worker memory resource fallback 2026-08-02 20:54:48 -04:00
4be5f521ff fix: style project rows as accessible buttons 2026-08-02 20:52:01 -04:00
10c7f23b17 feat: report shape topology checks 2026-08-02 20:46:02 -04:00
61360473fe feat: restore saved documents through facade 2026-08-02 20:40:34 -04:00
498e3f79ba feat: complete boolean task links and recompute closure 2026-08-02 20:30:04 -04:00
f24ed40db8 fix: constrain task angle inputs 2026-08-02 19:51:16 -04:00
9b6082acbe docs: record Part Design task parameters 2026-08-02 19:45:17 -04:00
288260b1e2 feat: wire Part Design task parameters 2026-08-02 19:44:54 -04:00
ed3d1cddc5 feat: export cached Part shapes 2026-08-02 19:37:56 -04:00
e84e1e77ef feat: align Part primitive task controls 2026-08-02 19:35:43 -04:00
ca9b18ea59 feat: persist primitive task parameters 2026-08-02 19:33:43 -04:00
589b2eb0f6 feat: add Part shape diagnostics 2026-08-02 19:31:25 -04:00
1bbee75761 feat: render cached Part shapes in viewport 2026-08-02 19:27:37 -04:00
42e59ac068 feat: add Part primitive task selection 2026-08-02 19:25:37 -04:00
9e15a6b10d feat: connect Part primitives and booleans 2026-08-02 19:22:59 -04:00
fe89d89540 docs: record live diagnostics and angle properties 2026-08-02 19:07:04 -04:00
de533d23bc feat: expose live runtime diagnostics 2026-08-02 19:05:31 -04:00
a133970f20 feat: integrate revolution into facade recompute 2026-08-02 18:57:19 -04:00
d7294dfcac docs: record OCCT feature integration baseline 2026-08-02 18:41:52 -04:00
e60bd614f4 docs: update compatibility capabilities 2026-08-02 18:38:46 -04:00
66c11f6890 P3/P4: execute OCCT features and export shapes 2026-08-02 18:35:04 -04:00
709cfea1eb P2/P5: coordinate tabs and render feature preview 2026-08-02 18:09:03 -04:00
cb6877be36 DAG: execute recompute levels concurrently 2026-08-02 17:52:26 -04:00
0c8efc8b69 TSN: report deleted subshape matches 2026-08-02 17:45:38 -04:00
160f00aec7 P3: expose face edge vertex topology through geometry facade 2026-08-02 17:44:42 -04:00
ed5b1f3b67 DAG: expose deterministic recompute levels 2026-08-02 17:42:10 -04:00
a4b9d04ff4 EXU: add dimensional powers and area volume units 2026-08-02 17:39:28 -04:00
171fd670af P4: extend sketch constraints and expression functions 2026-08-02 17:35:56 -04:00
9c76f33f3d P7/P8: add topology proxies and recovery reports 2026-08-02 17:24:50 -04:00
4cc349a589 P7/P4: harden recompute and FCStd boundaries 2026-08-02 14:33:41 -04:00
21 changed files with 2132 additions and 138 deletions

View File

@@ -2,9 +2,9 @@
"schemaVersion": 1, "schemaVersion": 1,
"freecadBaseline": "freecad-1.1.1", "freecadBaseline": "freecad-1.1.1",
"workbenches": { "workbenches": {
"Part Design": { "status": "ui-prototype", "level": "read-only", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, "Part Design": { "status": "facade-partial", "level": "experimental", "commands": ["create-body", "create-sketch", "pad", "pocket", "revolution", "fillet", "chamfer", "linear-pattern", "polar-pattern"] },
"Part": { "status": "ui-prototype", "level": "read-only", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, "Part": { "status": "facade-partial", "level": "experimental", "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"] }, "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"] }, "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"] }, "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"] }, "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"] }, "Surface": { "status": "ui-manifest", "level": "unsupported", "commands": ["bspline", "bezier", "fill-surface", "loft-surface", "trim-surface"] },
"Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] }
}, },
"facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "undo-redo"] },
"sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "persistence"] },
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
},
"rules": [ "rules": [
"UI status does not imply geometry or file compatibility.", "UI status does not imply geometry or file compatibility.",
"Every implementation must update this matrix before claiming support.", "Every implementation must update this matrix before claiming support.",

View File

@@ -7,5 +7,5 @@
"viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" }, "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" },
"persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v4-resource-autosave-properties-dependencies-sketch" }, "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v4-resource-autosave-properties-dependencies-sketch" },
"targetBrowsers": "Capability matrix to be locked in P0-02", "targetBrowsers": "Capability matrix to be locked in P0-02",
"status": "expression-dag-and-subshape-signature-integration" "status": "occt-feature-recompute-through-all-shape-cache-export-integration"
} }

View File

@@ -310,8 +310,40 @@
TSN 已完成面级量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`。签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射。TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。 TSN 已完成面级量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`。签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射。TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。
Sketcher 已有 Facade 事务模型和基础求解器适配器Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4。该适配器的职责是验证数据模型和提供可替换协议它不宣称已经等价于 FreeCAD `planegcs`。SK-03 的 planegcs WASM、SK-07 的完整约束分类、SK-09 的交互回放和 SK-10 的 B-spline 仍是兼容门禁。 Sketcher 已有 Facade 事务模型和基础求解器适配器Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4;基础约束现覆盖 Diameter、Symmetric、Tangent。该适配器的职责是验证数据模型和提供可替换协议;它不宣称已经等价于 FreeCAD `planegcs`。SK-03 的 planegcs WASM、SK-07 的完整约束分类、SK-09 的交互回放和 SK-10 的 B-spline 仍是兼容门禁。
Bitbybit 的 OCCT `filletEdges``chamferEdges` 已进入 Geometry Facade支持全边或显式索引、参数验证、句柄版本和 Worker 隔离。由于 OCCT 瞬时边索引不是持久命名,当前接口仍不能作为 FreeCAD PartDesign 的长期选边引用;必须等 TSN 的边级 `TopoRef`、历史映射和参数化重算完成后,才能标记为 `compatible` 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”而宣称参数化文件兼容。 Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。
本轮又补齐了拓扑和恢复边界:面之外增加了由 BitBybit 网格端点派生的 edge/vertex 签名,并通过 `geometry.topology()` 返回面/边/点引用,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态SQLite/OPFS Project API 增加 `recovery(documentId)`报告完整性、schema、最后保存版本和降级警告。持久化写入现在在支持的浏览器使用 Web Locks 跨标签页互斥,并通过 BroadcastChannel 广播保存版本;无 Web Locks 环境明确报告 `local-queue`,不能解释为跨标签页安全。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。
## 17. 本轮后续实现记录与下一批任务
本轮继续执行了重算、文件安全和命令能力三个关键边界。实现与验收必须按下面的定义理解不能把“接口存在”解释成“FreeCAD 业务已完成”:
| 任务 | 实施措施 | 验证结果 | 剩余边界 |
|---|---|---|---|
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation后发运行取消前发节点完成后校验 AbortSignal、文档 ID/版本和当前 generation结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Revolution/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链、Revolution 角度/反向和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandleUp to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
| DAG-08 失败/抑制传播 | 上游 `error` 将下游置为 `upstream-failed`Feature 的可持久化 `Suppressed` 属性将自身置为 `suppressed`、下游置为 `upstream-suppressed`,不误报重算失败;抑制时释放缓存 Shape解除后只重算依赖闭包 | 上游失败、抑制跳过、Shape 释放、保存恢复和解除抑制最小闭包测试通过 | 多配置抑制、Body Tip 重定向、完整 FreeCAD 状态机和 Report 诊断树仍待实现 |
| P4-06 任务提交与重算闭包 | Task 草稿在 Facade 事务内写入 Boolean Base/Tool 等链接;新对象和依赖闭包进入 `touched/dirty`React OK 通过 `recomputeAsync()` 提交并反馈结果 | Boolean Task 链接/依赖测试、Task dirty 快照和完整验证通过 | Apply 预览仍未持久化临时 Shape多选选择集、抑制对象和完整 FreeCAD Task 校验仍待实现 |
| DAG-07 Body/Tip 与来源链接 | Part Design 特征提交时按来源类型绑定 Profile/Base更新只读 Body.Tip并将 Body 与新特征一并纳入 dirty/recompute 闭包Undo/Redo 恢复 Tip | Pad Task、Body.Tip、Undo/Redo 回归测试通过 | 单实体规则、Tip 抑制/重定向、复杂容器和跨 Body 支持仍待实现 |
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver不是 `planegcs` WASM高级约束、拖拽、外部几何和 B-spline 未完成 |
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型未知类型为 proxyPython 类型为 blocked脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
| P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()` 将选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态、保留 Undo 边界并按完整对象图异步重算Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空和加载重算调用通过测试 | FCStd 导入仍是只读 metadata/proxy跨文档 ShapeHandle 和 Shape 持久化仍待完成 |
| P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 的浏览器中使用内存资源表,保留 SHA-256 身份和引用计数;恢复报告区分数据库完整但目标文档缺失的 `unavailable`;销毁 Worker 时清空临时资源 | Worker 类型检查、生产构建通过 | 内存资源会随刷新丢失;配额回收、持久资源校验和大文件压力仍待实现 |
| PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 |
| P1-02 命令准确性 | manifest 仍可完整展示Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor命令状态同时检查工作台和对象类型选择谓词其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
后续按以下顺序连续开发:
1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播语义并支持解除抑制后的最小闭包重算继续补齐多配置抑制、Body Tip 重定向和诊断树。最近有效 Shape 缓存已进入 Facade但尚未持久化。
2. `P3-04/P3-05`Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写Pocket Through all 已接入;继续补齐 Up to face、拓扑历史和 Pattern/Hole未完成前保持 disabled。
3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。
4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。
5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。
6. `FC-04/05/06/10`:增加 XML/Shape/Expression/Link 映射,未知字段原样保留,完成 WebCAD↔FCStd↔FreeCAD 差异报告。
7. `P6-03/P8-02`:把 disabled 原因、重算进度/取消、FCStd 报告和能力矩阵接入 React并通过目标浏览器回放和视觉基线。
启用规则:`RecomputeExecutionResult.status=completed` 只表示已注册节点成功FCStd `metadata-compatible` 只表示安全元数据可读;只有 FreeCAD 对照黄金测试、取消/失败/只读、保存/加载和跨浏览器门禁都通过,才能把对象或命令从 `experimental` 提升到 `compatible``exact` 还要求锁定版本后的逐项差异为零。

View File

@@ -1403,13 +1403,13 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| 全量静态页面和 CAD Workspace | `DONE` | `src/App.tsx``src/styles.css`;桌面/移动截图验收通过 | | 全量静态页面和 CAD Workspace | `DONE` | `src/App.tsx``src/styles.css`;桌面/移动截图验收通过 |
| 工作台/菜单/命令机器可读 manifest | `DONE` | `src/freecadManifest.ts`13 个工作台和命令分组 | | 工作台/菜单/命令机器可读 manifest | `DONE` | `src/freecadManifest.ts`13 个工作台和命令分组 |
| BitBybit Web CAD Facade 最小合同 | `DONE` | `src/facade/types.ts``src/facade/mockFacade.ts`;真实内核适配仍待 P3/P4 | | 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 | | Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`WebGL2 适配器已挂入 Facade |
| React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 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 | | SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 |
| 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 | | 标准格式与 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` | | Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts``scripts/check-facade-boundary.mjs` |
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器黄金几何仍待后续 | | 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 46 个 Facade 测试、入口守卫和构建;跨浏览器黄金几何、压力、fuzz 和发布流水线仍待 P8 |
### 16.3 阶段门和交付节奏 ### 16.3 阶段门和交付节奏
@@ -1456,7 +1456,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| P2-02 | 建立 Persistence Worker 和单写者队列 | Worker、请求/响应协议 | P1-03 | 1000 次事务无丢失且不阻塞 UI | | P2-02 | 建立 Persistence Worker 和单写者队列 | Worker、请求/响应协议 | P1-03 | 1000 次事务无丢失且不阻塞 UI |
| P2-03 | 实现 OPFS 数据库/资源管理 | 哈希、引用计数、配额处理 | P2-01 | 资源可写入、校验和清理 | | P2-03 | 实现 OPFS 数据库/资源管理 | 哈希、引用计数、配额处理 | P2-01 | 资源可写入、校验和清理 |
| P2-04 | 实现自动保存、恢复和备份 | Project API、恢复报告 | P2-02/P2-03 | 强刷后恢复最后有效保存点 | | P2-04 | 实现自动保存、恢复和备份 | Project API、恢复报告 | P2-02/P2-03 | 强刷后恢复最后有效保存点 |
| P2-05 | 实现多标签页通知和锁冲突 | BroadcastChannel、单写者提示 | P2-02 | 第二标签页不能静默覆盖 | | P2-05 | 实现多标签页通知和锁冲突 | BroadcastChannel、Web Locks 互斥、`subscribeExternalChanges`、单写者提示 | P2-02 | 支持 Web Locks 时第二标签页不能静默覆盖;不支持时能力矩阵明确标记 `local-queue` 降级 |
| P2-06 | 实现能力检测和降级 | capability matrix、备份入口 | P2-03 | 不支持 OPFS 时可显式导出恢复 | | P2-06 | 实现能力检测和降级 | capability matrix、备份入口 | P2-03 | 不支持 OPFS 时可显式导出恢复 |
#### P3FreeCAD/OCCT 几何运行时 #### P3FreeCAD/OCCT 几何运行时
@@ -1466,7 +1466,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| P3-01 | 复现 FreeCAD/OCCT/BitBybit 构建 | WASM 构建脚本、资源清单 | G0/G1 | 干净环境生成同一 hash | | P3-01 | 复现 FreeCAD/OCCT/BitBybit 构建 | WASM 构建脚本、资源清单 | G0/G1 | 干净环境生成同一 hash |
| P3-02 | 定义 ShapeHandle、SubshapeRef、MeshAsset | 句柄/拓扑/三角化类型 | P1-04 | 不暴露底层指针或临时索引 | | P3-02 | 定义 ShapeHandle、SubshapeRef、MeshAsset | 句柄/拓扑/三角化类型 | P1-04 | 不暴露底层指针或临时索引 |
| P3-03 | 实现基本体和变换 | Box、Cylinder、Sphere、Cone、Placement | P3-01 | 尺寸和包围盒误差在预算内 | | P3-03 | 实现基本体和变换 | Box、Cylinder、Sphere、Cone、Placement | P3-01 | 尺寸和包围盒误差在预算内 |
| P3-04 | 实现 Boolean、Pad/Pocket、Revolution | Kernel Adapter、诊断 | P3-03 | 可取消,失败保留有效结果 | | P3-04 | 实现 Boolean、Pad/Pocket、Revolution | Kernel Adapter、诊断Pocket `throughAll` 基体范围计算 | P3-03 | 可取消,失败保留有效结果;`Up to face` 未实现时必须返回明确诊断 |
| P3-05 | 实现 Fillet、Chamfer、Pattern、Hole | 特征执行器 | P3-04 | 错误包含输入和子形状上下文 | | P3-05 | 实现 Fillet、Chamfer、Pattern、Hole | 特征执行器 | P3-04 | 错误包含输入和子形状上下文 |
| P3-06 | 建立性能、内存和资源回收基准 | benchmark 报告 | P3-03 | 达到预算或形成降级决策 | | P3-06 | 建立性能、内存和资源回收基准 | benchmark 报告 | P3-03 | 达到预算或形成降级决策 |
@@ -1476,7 +1476,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
|---|---|---|---|---| |---|---|---|---|---|
| P4-01 | 实现 Document/Object/Container/Feature/Shape | 领域包和序列化投影 | G1 | UUID、类型、标签和生命周期稳定 | | P4-01 | 实现 Document/Object/Container/Feature/Shape | 领域包和序列化投影 | G1 | UUID、类型、标签和生命周期稳定 |
| P4-02 | 实现 Property/Link/Expression/Unit | 元数据、校验和单位换算 | P4-01 | Data/View 编辑器由 metadata 驱动 | | P4-02 | 实现 Property/Link/Expression/Unit | 元数据、校验和单位换算 | P4-01 | Data/View 编辑器由 metadata 驱动 |
| P4-03 | 实现依赖 DAG、脏标记和拓扑调度 | recompute scheduler | P4-01 | 只重算受影响闭包 | | P4-03 | 实现依赖 DAG、脏标记和拓扑调度 | recompute scheduler、稳定 `levels`、同层并发执行 | P4-01 | 只重算受影响闭包;同层对象无依赖;调度器以 `Promise.all` 并发执行并按计划顺序提交结果,未来再下沉到并行 Worker |
| P4-04 | 实现事务、撤销/重做和保存点 | Command Bus、undo journal | P4-03 | 100 次撤销/重做可回到原 hash | | P4-04 | 实现事务、撤销/重做和保存点 | Command Bus、undo journal | P4-03 | 100 次撤销/重做可回到原 hash |
| P4-05 | 实现 Body/Tip/特征顺序/可见性 | PartDesign 领域规则 | P4-01/P3-04 | 非法多实体和 Tip 操作被拒绝 | | P4-05 | 实现 Body/Tip/特征顺序/可见性 | PartDesign 领域规则 | P4-01/P3-04 | 非法多实体和 Tip 操作被拒绝 |
| P4-06 | 实现诊断树和失败恢复 | Diagnostic model、repair actions | P4-03 | 失败节点可定位、回退或修复 | | P4-06 | 实现诊断树和失败恢复 | Diagnostic model、repair actions | P4-03 | 失败节点可定位、回退或修复 |
@@ -1486,7 +1486,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| ID | 任务 | 输出 | 前置 | 退出条件 | | ID | 任务 | 输出 | 前置 | 退出条件 |
|---|---|---|---|---| |---|---|---|---|---|
| P5-01 | 实现 WebGL2 renderer、相机和场景生命周期 | Viewport Adapter | P1-04 | 创建/销毁视口无 GPU 泄漏 | | P5-01 | 实现 WebGL2 renderer、相机和场景生命周期 | Viewport Adapter | P1-04 | 创建/销毁视口无 GPU 泄漏 |
| P5-02 | 实现 Shape/mesh 增量更新和缓存 | BufferGeometry、材质缓存 | P3-02 | 单特征更新不重建整个场景 | | P5-02 | 实现 Shape/mesh 增量更新和缓存 | BufferGeometry、材质缓存Pad/Pocket OCCT 预览链 | P3-02 | 单特征更新不重建整个场景;预览链在卸载时释放全部 Shape 句柄 |
| P5-03 | 实现对象/面/边/点拾取和预选 | Selection mapping | P3-02/P4-01 | 拾取返回稳定 SubshapeRef | | P5-03 | 实现对象/面/边/点拾取和预选 | Selection mapping | P3-02/P4-01 | 拾取返回稳定 SubshapeRef |
| P5-04 | 实现导航、标准视图、剖切、网格和测量 | Gui/Viewport commands | P5-01 | 视口和文档状态可区分 | | P5-04 | 实现导航、标准视图、剖切、网格和测量 | Gui/Viewport commands | P5-01 | 视口和文档状态可区分 |
| P5-05 | 建立百万三角形和大装配基准 | 性能/内存报告 | P5-02 | 达到预算或触发 LOD 后备 | | P5-05 | 建立百万三角形和大装配基准 | 性能/内存报告 | P5-02 | 达到预算或触发 LOD 后备 |
@@ -1498,7 +1498,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
|---|---|---|---|---| |---|---|---|---|---|
| P6-01 | 将静态页面接入 Facade Provider | projection adapter | P1-05/P4-01 | React 不维护第二套文档真值 | | P6-01 | 将静态页面接入 Facade Provider | projection adapter | P1-05/P4-01 | React 不维护第二套文档真值 |
| P6-02 | 接入 Model/Tasks/Property/Selection 投影 | Combo View、Task Dock | P4-02/P4-05 | 选择、属性、任务单向同步 | | P6-02 | 接入 Model/Tasks/Property/Selection 投影 | Combo View、Task Dock | P4-02/P4-05 | 选择、属性、任务单向同步 |
| P6-03 | 接入命令/工作台状态 | manifest runtime adapter | P1-02/P4-03 | disabled 原因可展示且不会执行 | | P6-03 | 接入命令/工作台状态 | manifest runtime adapter | P1-02/P4-03 | disabled 原因可展示且不会执行Diagnostics 页面实时展示 Facade/OCCT/SQLite/OPFS 能力 |
| P6-04 | 接入真实 Viewport 和报告视图 | Three.js adapter、diagnostics | P5-03/P4-06 | 可见预览、提交和错误结果 | | P6-04 | 接入真实 Viewport 和报告视图 | Three.js adapter、diagnostics | P5-03/P4-06 | 可见预览、提交和错误结果 |
| P6-05 | 完成 Part/Part Design/Sketcher 垂直 UI | Core 1 页面 | P3-04/P4-05/P5-04 | G5 流程通过 | | P6-05 | 完成 Part/Part Design/Sketcher 垂直 UI | Core 1 页面 | P3-04/P4-05/P5-04 | G5 流程通过 |
| P6-06 | 完成键盘、窄屏、只读和恢复状态 | a11y/UX regression | P6-02 | 核心流程可键盘完成 | | P6-06 | 完成键盘、窄屏、只读和恢复状态 | a11y/UX regression | P6-02 | 核心流程可键盘完成 |
@@ -1509,7 +1509,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
|---|---|---|---|---| |---|---|---|---|---|
| P7-01 | STEP/IGES/网格导入 | importer、进度、取消 | P3-03/P4-01 | 样例树、单位和形状可检查 | | P7-01 | STEP/IGES/网格导入 | importer、进度、取消 | P3-03/P4-01 | 样例树、单位和形状可检查 |
| P7-02 | STEP/IGES/网格/GLB 导出 | exporter、单位/材质策略 | P3-02/P5-02 | 目标工具可重新打开 | | P7-02 | STEP/IGES/网格/GLB 导出 | exporter、单位/材质策略 | P3-02/P5-02 | 目标工具可重新打开 |
| P7-03 | Web CAD 项目包导入导出 | manifest、hash、schema version | P2-04 | 干净浏览器可恢复 | | P7-03 | Web CAD 项目包导入导出 | manifest、hash、schema versionFacade Shape 的 STEP/STL 下载 | P2-04 | 干净浏览器可恢复;没有有效 Shape 时导出按钮必须给出重计算诊断 |
| P7-04 | FCStd A 档对象映射 | compatibility importer | P4-01/P4-02 | 支持对象有等级,其余有 proxy 报告 | | P7-04 | FCStd A 档对象映射 | compatibility importer | P4-01/P4-02 | 支持对象有等级,其余有 proxy 报告 |
| P7-05 | FCStd B/C 档差异评估 | golden samples、差异报告 | P7-04 | 无静默丢失 | | P7-05 | FCStd B/C 档差异评估 | golden samples、差异报告 | P7-04 | 无静默丢失 |
@@ -1585,22 +1585,25 @@ P0 基线/治理
| P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立Three.js 锁定 `0.185.1`Bitbybit OCCT Worker/OCCT/Base 锁定 `1.1.1` 并记录 WASM SHA-256FreeCAD 自构建内核仍待锁定 | | P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立Three.js 锁定 `0.185.1`Bitbybit OCCT Worker/OCCT/Base 锁定 `1.1.1` 并记录 WASM SHA-256FreeCAD 自构建内核仍待锁定 |
| P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | | P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 |
| P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | | P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 |
| P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 | | P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象类型和命令清单返回 enabled/disabled 原因Part 布尔/检查仅接受 Shape-producing 对象Sketcher 求解仅接受 SketchPart Design 特征拒绝文件夹和无几何对象 |
| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 | | P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 |
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`React 工作区已通过事件投影工作台、选择、文档树和通知 | | 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 | | 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-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body新增 Part Design 特征会原子更新 Body.Tip并按 Sketch/实体来源写入 Profile/BaseFeature 支持可持久化抑制、下游跳过、Shape 释放和解除后的最小闭包重算;文档版本、dirtyUndo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、完整容器/Tip 重定向规则和原子崩溃恢复仍待实现 |
| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`Expression 求值、单位换算、多选 mixed 与链接循环仍待实现 | | 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 v1`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写1000 次压力、迁移回滚恢复演练和跨标签写者仍待补齐 | | 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-05 通过 Web Locks/降级能力标记覆盖 |
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | | Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 |
| Document list/load/recovery transaction | `PASS` | `project.list()` 从 SQLite/内存快照返回 documentId、版本、对象数、dirty/readOnly 和更新时间;`app.document.load(documentId)` 通过 Project Facade 恢复快照,取消旧重算、释放缓存 Shape、清空选择/任务、提交可撤销的活动文档并按完整对象图异步重算Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页按真实 ID 打开 |
| P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh |
| P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | 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-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;句柄携带 documentId/version底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名OCCT 历史命名未实现 |
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm自动化浏览器黄金测试尚未进入 CI | | 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 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry对象缓存与文档增量同步尚未实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 46 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、抑制传播/恢复、OCCT 特征执行器、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 构建验证。 本迭代验证命令:`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 构建验证。
@@ -1618,7 +1621,7 @@ P0 基线/治理
| Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 | | Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 |
| 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter不会假称 OPFS 已可用 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter不会假称 OPFS 已可用 |
仍未通过 G4/G5自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 仍未通过 G4/G5自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、完整 FCStd Shape 资源持久化和完整 FreeCAD/OCCT 计算尚未完成;当前 Facade 已有内存 Shape 缓存和 Pad/Pocket/Fillet/Chamfer executor但这些仍由 P2-02 至 P4-06 的剩余任务负责。
### 16.11 P3 Bitbybit OCCT 几何运行时验证记录 ### 16.11 P3 Bitbybit OCCT 几何运行时验证记录
@@ -1680,17 +1683,21 @@ OCCT Boolean 结果可能与源形状共享底层拓扑。1.1.1 的 `deleteShape
| X `[1,2]`、Y `[-1,1]` 面绕 Y 轴 360° Revolution | 212 顶点、208 三角形 | X `[-2,2]`、Y `[-1,1]`、Z 约 `[-1.9854,1.9854]` | | X `[1,2]`、Y `[-1,1]` 面绕 Y 轴 360° Revolution | 212 顶点、208 三角形 | X `[-2,2]`、Y `[-1,1]`、Z 约 `[-1.9854,1.9854]` |
| 释放全部特征结果后访问 Pocket 基体 | `PASS` | 基体仍为 24 顶点、12 三角形,包围盒不变 | | 释放全部特征结果后访问 Pocket 基体 | `PASS` | 基体仍为 24 顶点、12 三角形,包围盒不变 |
Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。当前 Pocket 是长度模式;`Through all``Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现。 Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。Pocket `Through all` 现在读取基体网格投影范围,按范围加余量生成对称切削工具;`Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现,未支持时返回 `UP_TO_FACE_UNSUPPORTED`,不会退化为固定长度
### 16.15 P4-02 Property 元数据与编辑器验证记录 ### 16.15 P4-02 Property 元数据与编辑器验证记录
DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值。每个对象包含 FreeCAD 风格的 `typeId` 和类型化 PropertyString、Length、Bool、Enumeration、Link、Color、Percent、Floatmetadata 同时声明 Data/View scope、group、unit、readOnly、hidden、recompute、options 和 expression。React 不再按 Pad/Pocket 名称硬编码属性行。 DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值。每个对象包含 FreeCAD 风格的 `typeId` 和类型化 PropertyString、Length、Angle、Bool、Enumeration、Link、Color、Percent、Floatmetadata 同时声明 Data/View scope、group、unit、readOnly、hidden、recompute、options 和 expression。React 不再按 Pad/Pocket 名称硬编码属性行Angle 属性使用 deg 单位和 0360°输入约束
| 检查 | 结果 | 证据 | | 检查 | 结果 | 证据 |
|---|---|---| |---|---|---|
| Data 编辑 | `PASS` | 浏览器将 Pad Length 从 42 改为 50树版本 v18→v19、对象 Status→Touched、tree state→dirty | | Data 编辑 | `PASS` | 浏览器将 Pad Length 从 42 改为 50树版本 v18→v19、对象 Status→Touched、tree state→dirty |
| View/Data 分离 | `PASS` | Visibility 等 View 属性提交文档事务但不把几何对象标记 Touched | | View/Data 分离 | `PASS` | Visibility 等 View 属性提交文档事务但不把几何对象标记 Touched |
| 类型控件 | `PASS` | View 标签由 metadata 生成 1 个 checkbox、2 个 enumeration、2 个 numeric、2 个 color 控件 | | 类型控件 | `PASS` | View 标签由 metadata 生成 1 个 checkbox、2 个 enumeration、2 个 numeric、2 个 color 控件 |
| Part primitive Task | `PASS` | Part 工作台任务可选择 Box/Cylinder/Sphere/Cone尺寸和角度草稿写入 `Part::*` 对象属性并进入 OCCT 重算 |
| Part Boolean Task | `PASS` | Union/Cut/Intersection 任务提供 Base/Tool 对象选择槽,链接写入对象依赖图;确认后将新对象及其依赖闭包标记 dirty 并触发异步重算 |
| Part Design Task | `PASS` | Pad/Pocket/Revolution/Fillet/Chamfer 任务草稿写入 Length/Type/Angle/Reversed/Midplane/Radius/Distance 属性,确认后仍通过 Facade 历史边界提交 |
| 选择谓词 | `PASS` | 选择 Origin/文件夹后 Part Union/Check geometry 被禁用;选择非 Sketch 对象后 Sketcher Solve 被禁用,并返回可解释原因 |
| 校验 | `PASS` | 负 Length、越界 Percent、未知 Enumeration、无效颜色、缺失 Link、只读 Property 在 Facade 拒绝 | | 校验 | `PASS` | 负 Length、越界 Percent、未知 Enumeration、无效颜色、缺失 Link、只读 Property 在 Facade 拒绝 |
| Undo/Redo | `PASS` | Length 42→50 可撤销回 42、重做至 50Property 和树投影同步 | | Undo/Redo | `PASS` | Length 42→50 可撤销回 42、重做至 50Property 和树投影同步 |
| SQLite/OPFS | `PASS` | Chrome `sqlite-opfs` 保存 v19 后重新加载 Length=61、Status=Touched、tree state=dirty | | SQLite/OPFS | `PASS` | Chrome `sqlite-opfs` 保存 v19 后重新加载 Length=61、Status=Touched、tree state=dirty |
@@ -1698,7 +1705,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值
持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。 持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。
当前限制Expression/Quantity 已完成基础词法、四则运算、长度/角度/百分比换算、对象属性引用维度校验;函数库、locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 当前限制Expression/Quantity 已完成基础词法、四则运算、`^` 幂运算、长度/面积/体积/角度/百分比换算、对象属性引用维度校验,以及 `abs/sin/cos/tan/atan2/min/max/clamp/round/pow` 函数的基础维度规则locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。
### 16.17 EXU/DAG/TSN 基础切片验证记录 ### 16.17 EXU/DAG/TSN 基础切片验证记录
@@ -1716,7 +1723,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值
尚未完成的 TSN 工作OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。 尚未完成的 TSN 工作OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。
Sketcher 当前边界:基础适配器支持点/线/圆/弧、Coincident/Horizontal/Vertical/Distance/Radius/Angle/Equal/Block 的确定性约束处理;完整 FreeCAD `planegcs` 数值求解、冗余约束分类、B-spline、外部几何、自动约束和拖拽交互仍需 SK-03、SK-07、SK-09、SK-10 的 WASM/浏览器回放门禁。 Sketcher 当前边界:基础适配器支持点/线/圆/弧、Coincident/Horizontal/Vertical/Distance/Radius/Diameter/Angle/Equal/Symmetric/Tangent/Block 的确定性约束处理;完整 FreeCAD `planegcs` 数值求解、冗余约束分类、B-spline、外部几何、自动约束和拖拽交互仍需 SK-03、SK-07、SK-09、SK-10 的 WASM/浏览器回放门禁。
### 16.16 引擎版本修复与完整 FreeCAD 对标专项 ### 16.16 引擎版本修复与完整 FreeCAD 对标专项

124
package-lock.json generated
View File

@@ -11,6 +11,8 @@
"@bitbybit-dev/occt": "1.1.1", "@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1", "@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1", "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"fast-xml-parser": "^5.10.1",
"fflate": "^0.8.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -809,6 +811,18 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "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" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.10", "version": "2.11.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
@@ -1422,6 +1448,45 @@
"node": ">=6" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1443,7 +1508,7 @@
"version": "0.8.3", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true "license": "MIT"
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
@@ -1468,6 +1533,18 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1564,6 +1641,21 @@
"node": ">=18" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1721,6 +1813,21 @@
"node": ">=0.10.0" "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": { "node_modules/three": {
"version": "0.185.1", "version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -20,6 +20,8 @@
"@bitbybit-dev/occt": "1.1.1", "@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1", "@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1", "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"fast-xml-parser": "5.10.1",
"fflate": "0.8.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -45,6 +45,7 @@ import {
Trash2, Trash2,
Undo2, Undo2,
Redo2, Redo2,
RefreshCw,
Upload, Upload,
UserRound, UserRound,
WandSparkles, WandSparkles,
@@ -53,7 +54,7 @@ import {
ZoomOut, ZoomOut,
} from 'lucide-react' } from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' 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 ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId type Workbench = WorkbenchId
@@ -135,6 +136,11 @@ function App() {
} }
}), [facade]) }), [facade])
useEffect(() => facade.project.subscribeExternalChanges((notice) => {
setNotice(`Project saved in another tab · v${notice.documentVersion}`)
window.setTimeout(() => setNotice(''), 2600)
}), [facade])
useEffect(() => () => facade.geometry.dispose(), [facade]) useEffect(() => () => facade.geometry.dispose(), [facade])
useEffect(() => { useEffect(() => {
@@ -175,6 +181,7 @@ function App() {
{page === 'workspace' ? ( {page === 'workspace' ? (
<Workspace <Workspace
workbench={workbench} workbench={workbench}
onNavigate={navigate}
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks') }} setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks') }}
leftTab={leftTab} leftTab={leftTab}
setLeftTab={setLeftTab} setLeftTab={setLeftTab}
@@ -190,7 +197,7 @@ function App() {
showNotice={showNotice} 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>} {notice && <div className="toast"><CheckCircle2 size={16} />{notice}</div>}
</div> </div>
@@ -199,6 +206,7 @@ function App() {
function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; facade: BitBybitWebCadFacade; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) { 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 [openMenu, setOpenMenu] = useState<MenuName | null>(null)
const document = facade.getState().document
const handleMenuCommand = (command: string) => { const handleMenuCommand = (command: string) => {
setOpenMenu(null) setOpenMenu(null)
if (command === 'help' || command === 'shortcuts') onNavigate('help') if (command === 'help' || command === 'shortcuts') onNavigate('help')
@@ -220,8 +228,8 @@ function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page
</nav> </nav>
<div className="topbar-spacer" /> <div className="topbar-spacer" />
<div className="topbar-context"> <div className="topbar-context">
<div className="context-document"><span className="context-dot" />Pump Housing <span className="context-separator">/</span> {workbench}</div> <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" />Unsaved changes</span> <span className="save-state"><span className="save-dot" />{document.dirty ? 'Unsaved changes' : 'Saved locally'}</span>
</div> </div>
<IconButton icon={Undo2} label="Undo" onClick={() => facade.history.undo()} disabled={!facade.history.canUndo()} /> <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()} /> <IconButton icon={Redo2} label="Redo" onClick={() => facade.history.redo()} disabled={!facade.history.canRedo()} />
@@ -233,8 +241,9 @@ function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page
) )
} }
function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, document, facade, workbenchIcon: WorkbenchIcon, showNotice }: { function Workspace({ workbench, onNavigate, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, document, facade, workbenchIcon: WorkbenchIcon, showNotice }: {
workbench: Workbench workbench: Workbench
onNavigate: (page: Page) => void
setWorkbench: (value: Workbench) => void setWorkbench: (value: Workbench) => void
leftTab: 'model' | 'tasks' leftTab: 'model' | 'tasks'
setLeftTab: (tab: 'model' | 'tasks') => void setLeftTab: (tab: 'model' | 'tasks') => void
@@ -255,8 +264,9 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
<div className="workbench-picker"><WorkbenchIcon size={17} /><select value={workbench} onChange={(event) => setWorkbench(event.target.value as Workbench)} aria-label="Workbench">{Object.keys(workbenchDefinitions).map((option) => <option key={option}>{option}</option>)}</select><ChevronDown size={14} /></div> <div className="workbench-picker"><WorkbenchIcon size={17} /><select value={workbench} onChange={(event) => setWorkbench(event.target.value as Workbench)} aria-label="Workbench">{Object.keys(workbenchDefinitions).map((option) => <option key={option}>{option}</option>)}</select><ChevronDown size={14} /></div>
<div className="toolbar-divider" /> <div className="toolbar-divider" />
<IconButton icon={FilePlus2} label="New document" onClick={() => { facade.gui.command.execute({ commandId: 'new-document' }); showNotice('New document prepared') }} /> <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={FolderOpen} label="Open project" onClick={() => onNavigate('projects')} />
<IconButton icon={Save} label="Save project" onClick={() => { facade.gui.command.execute({ commandId: 'save' }); showNotice('Saved to local workspace') }} /> <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" /> <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={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') }} /> <IconButton icon={CircleDot} label="Create sketch" onClick={() => { facade.gui.command.execute({ commandId: 'create-sketch' }); showNotice('Select a plane to create a sketch') }} />
@@ -269,11 +279,11 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
</div> </div>
<WorkbenchNavBar workbench={workbench} setWorkbench={setWorkbench} showNotice={showNotice} /> <WorkbenchNavBar workbench={workbench} setWorkbench={setWorkbench} showNotice={showNotice} />
<div className="document-tabs"> <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="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> <button className="new-tab" title="New document"><Plus size={16} /></button>
<div className="document-tab-spacer" /> <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>
<div className="workspace-content"> <div className="workspace-content">
<aside className="combo-panel left-panel"> <aside className="combo-panel left-panel">
@@ -281,7 +291,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
{leftTab === 'model' ? <div className="combo-model"><ModelTree document={document} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{document.tree.find((item) => item.id === selectedObject)?.label || 'No selection'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div><PropertyPanel key={rightTab} facade={facade} objectId={selectedObject} scope={rightTab} showNotice={showNotice} /></div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>} {leftTab === 'model' ? <div className="combo-model"><ModelTree document={document} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{document.tree.find((item) => item.id === selectedObject)?.label || 'No selection'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div><PropertyPanel key={rightTab} facade={facade} objectId={selectedObject} scope={rightTab} showNotice={showNotice} /></div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>}
</aside> </aside>
<section className="viewport-region"> <section className="viewport-region">
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} facade={facade} showNotice={showNotice} /> <Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} facade={facade} documentVersion={document.version} recomputeGeneration={document.recompute?.generation ?? 0} showNotice={showNotice} />
<div className="viewport-bottom-left"><div className="view-chip"><Circle size={8} fill="currentColor" /> Perspective</div><div className="view-chip">Grid 10 mm</div></div> <div className="viewport-bottom-left"><div className="view-chip"><Circle size={8} fill="currentColor" /> Perspective</div><div className="view-chip">Grid 10 mm</div></div>
<div className="viewport-bottom-right"><div className="axis-widget"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span><div className="axis-origin" /></div></div> <div className="viewport-bottom-right"><div className="axis-widget"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span><div className="axis-origin" /></div></div>
</section> </section>
@@ -290,7 +300,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
</div> </div>
<div className={`bottom-drawer ${bottomOpen ? 'is-open' : ''}`}> <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> <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> </div>
</main> </main>
) )
@@ -337,7 +347,31 @@ function TreeItem({ item, level, expanded, onToggle, itemsById, selectedObject,
function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
const definition = workbenchDefinitions[workbench] const definition = workbenchDefinitions[workbench]
const isSketch = workbench === 'Sketcher' const isSketch = workbench === 'Sketcher'
return <div className="task-panel"><div className="task-actions-top"><button className="button button-primary" onClick={() => { facade.task.apply(); showNotice('Task accepted') }}>OK</button><button className="button button-outline" onClick={() => { facade.task.update({ preview: true }); showNotice('Preview applied') }}>Apply</button><button className="button button-quiet" onClick={() => { facade.task.cancel(); showNotice('Task cancelled') }}>Cancel</button></div><div className="task-header"><div className="task-icon"><Pencil size={16} /></div><div><span className="eyebrow">Active command</span><h2>{isSketch ? 'Edit Sketch' : definition.taskTitle}</h2></div><Badge tone="cyan">Preview</Badge></div><div className="task-body"><div className="task-step"><span className="step-index">1</span><div><strong>{isSketch ? 'Geometry and constraints' : definition.objectType}</strong><span>{definition.taskSummary}</span></div></div><label className="field-label">Primary value <span className="field-unit">mm</span><input className="field-input" value={isSketch ? 'Fully constrained' : '42.00'} readOnly /></label><label className="field-label">Operation<select className="field-input"><option>{workbench === 'Part Design' ? 'Dimension' : 'Contextual preview'}</option><option>Through all</option><option>Up to face</option></select></label><label className="check-row"><input type="checkbox" defaultChecked /><span>Preview result in viewport</span></label><div className="task-note"><AlertTriangle size={14} /><span>Changes remain local until the document is recomputed.</span></div></div></div> const activeTask = facade.task.getActive()
const primitiveType = activeTask?.commandId === 'primitive' && typeof activeTask.draft.primitiveType === 'string' ? activeTask.draft.primitiveType : 'Box'
const primitiveFields = primitiveType === 'Box' ? ['width', 'length', 'height'] : primitiveType === 'Cylinder' ? ['radius', 'height', 'angle'] : primitiveType === 'Sphere' ? ['radius'] : ['radius1', 'radius2', 'height', 'angle']
const primitiveUnits: Record<string, string> = { width: 'mm', length: 'mm', height: 'mm', radius: 'mm', radius1: 'mm', radius2: 'mm', angle: 'deg' }
const primitiveFallbacks: Record<string, number> = { width: 10, length: 10, height: 10, radius: 5, radius1: 5, radius2: 0, angle: 360 }
const primitiveLabel = (field: string) => field === 'radius1' ? 'Bottom radius' : field === 'radius2' ? 'Top radius' : field[0].toUpperCase() + field.slice(1)
const featureFields: Record<string, { key: string; label: string; unit: string; fallback: number }> = {
pad: { key: 'length', label: 'Length', unit: 'mm', fallback: 42 },
pocket: { key: 'length', label: 'Length', unit: 'mm', fallback: 18 },
revolution: { key: 'angle', label: 'Angle', unit: 'deg', fallback: 360 },
fillet: { key: 'radius', label: 'Radius', unit: 'mm', fallback: 3 },
chamfer: { key: 'distance', label: 'Distance', unit: 'mm', fallback: 2 },
}
const currentFeatureField = activeTask?.commandId ? featureFields[activeTask.commandId] : undefined
const activeCommand = activeTask?.commandId
const activeDocument = facade.app.document.getActive()
const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection'
const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId))
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
const acceptTask = () => {
if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return }
facade.task.apply()
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Task accepted and recompute completed' : `Task accepted; recompute ${result.status}`))
}
return <div className="task-panel"><div className="task-actions-top"><button className="button button-primary" onClick={acceptTask}>OK</button><button className="button button-outline" onClick={() => { facade.task.update({ preview: true }); showNotice('Preview applied') }}>Apply</button><button className="button button-quiet" onClick={() => { facade.task.cancel(); showNotice('Task cancelled') }}>Cancel</button></div><div className="task-header"><div className="task-icon"><Pencil size={16} /></div><div><span className="eyebrow">Active command</span><h2>{isSketch ? 'Edit Sketch' : definition.taskTitle}</h2></div><Badge tone="cyan">Preview</Badge></div><div className="task-body"><div className="task-step"><span className="step-index">1</span><div><strong>{isSketch ? 'Geometry and constraints' : definition.objectType}</strong><span>{definition.taskSummary}</span></div></div>{activeCommand === 'primitive' && <><label className="field-label">Primitive type<select className="field-input" value={primitiveType} onChange={(event) => facade.task.update({ primitiveType: event.target.value })}><option>Box</option><option>Cylinder</option><option>Sphere</option><option>Cone</option></select></label>{primitiveFields.map((field) => <label className="field-label" key={field}>{primitiveLabel(field)} <span className="field-unit">{primitiveUnits[field]}</span><input className="field-input" type="number" min={field === 'radius2' ? 0 : 0.001} max={primitiveUnits[field] === 'deg' ? 360 : undefined} step={primitiveUnits[field] === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[field] === 'number' ? Number(activeTask?.draft[field]) : primitiveFallbacks[field]} onChange={(event) => facade.task.update({ [field]: Number(event.target.value) })} /></label>)}</>}{booleanCommand && <><label className="field-label">Base<select className="field-input" value={draftLink('base')} onChange={(event) => facade.task.update({ base: event.target.value })}><option value="">Select base</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label><label className="field-label">Tool<select className="field-input" value={draftLink('tool')} onChange={(event) => facade.task.update({ tool: event.target.value })}><option value="">Select tool</option>{shapeObjects.map((object) => <option value={object.id} key={object.id}>{object.properties.find((property) => property.name === 'Label')?.value || object.id}</option>)}</select></label></>}{currentFeatureField && <label className="field-label">{currentFeatureField.label} <span className="field-unit">{currentFeatureField.unit}</span><input className="field-input" type="number" min={0.001} max={currentFeatureField.unit === 'deg' ? 360 : undefined} step={currentFeatureField.unit === 'deg' ? 1 : 0.1} value={typeof activeTask?.draft[currentFeatureField.key] === 'number' ? Number(activeTask?.draft[currentFeatureField.key]) : currentFeatureField.fallback} onChange={(event) => facade.task.update({ [currentFeatureField.key]: Number(event.target.value) })} /></label>}{activeCommand === 'pocket' && <label className="field-label">Type<select className="field-input" value={typeof activeTask?.draft.type === 'string' ? activeTask.draft.type : 'Through all'} onChange={(event) => facade.task.update({ type: event.target.value })}><option>Dimension</option><option>Through all</option><option>Up to face</option></select></label>}{['pad', 'pocket', 'revolution'].includes(activeCommand || '') && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.reversed === true} onChange={(event) => facade.task.update({ reversed: event.target.checked })} /><span>Reversed</span></label>}{activeCommand === 'pad' && <label className="check-row"><input type="checkbox" checked={activeTask?.draft.midplane === true} onChange={(event) => facade.task.update({ midplane: event.target.checked })} /><span>Symmetric to plane</span></label>}{!activeCommand || activeCommand === 'create-sketch' ? <><label className="field-label">Primary value <span className="field-unit">mm</span><input className="field-input" value={isSketch ? 'Fully constrained' : '42.00'} readOnly /></label><label className="field-label">Operation<select className="field-input"><option>{workbench === 'Part Design' ? 'Dimension' : 'Contextual preview'}</option><option>Through all</option><option>Up to face</option></select></label></> : null}<label className="check-row"><input type="checkbox" defaultChecked /><span>Preview result in viewport</span></label><div className="task-note"><AlertTriangle size={14} /><span>Changes remain local until the document is recomputed.</span></div></div></div>
} }
function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) { function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) {
@@ -369,12 +403,12 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))] const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))]
editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select> editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
} else if (property.type === 'App::PropertyColor') editor = <label className="property-color"><input type="color" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)} /><span>{String(property.value).toUpperCase()}</span></label> } else if (property.type === 'App::PropertyColor') editor = <label className="property-color"><input type="color" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)} /><span>{String(property.value).toUpperCase()}</span></label>
else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label> else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyAngle' ? 360 : property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label>
else editor = <input className="property-control" defaultValue={String(property.value ?? '')} aria-label={property.label} onBlur={(event) => { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /> else editor = <input className="property-control" defaultValue={String(property.value ?? '')} aria-label={property.label} onBlur={(event) => { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} />
return <><div className="property-row"><span className="property-label">{property.label}</span><div className="property-editor">{editor}</div></div>{property.expression && <div className="property-expression"><Code2 size={12} /><span>{property.expression}</span></div>}</> return <><div className="property-row"><span className="property-label">{property.label}</span><div className="property-editor">{editor}</div></div>{property.expression && <div className="property-expression"><Code2 size={12} /><span>{property.expression}</span></div>}</>
} }
function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { function Viewport({ selectedObject, setSelectedObject, workbench, facade, documentVersion, recomputeGeneration, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; documentVersion: number; recomputeGeneration: number; showNotice: (message: string) => void }) {
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const adapterRef = useRef<BitBybitViewportAdapter | null>(null) const adapterRef = useRef<BitBybitViewportAdapter | null>(null)
useEffect(() => { useEffect(() => {
@@ -383,7 +417,8 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
const adapter = facade.viewport.createAdapter() const adapter = facade.viewport.createAdapter()
adapterRef.current = adapter adapterRef.current = adapter
let cancelled = false let cancelled = false
let shape: ShapeHandle | null = null const shapes: ShapeHandle[] = []
let ownsShapes = false
try { try {
adapter.mount(host) adapter.mount(host)
} catch (error) { } catch (error) {
@@ -396,13 +431,25 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
const capabilities = await facade.geometry.initialize() const capabilities = await facade.geometry.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable') if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable')
const document = facade.app.document.getActive() const document = facade.app.document.getActive()
shape = await facade.geometry.createBox({ width: 2.7, length: 1.4, height: 1.6, center: [0, 0, 0], originOnCenter: true, documentId: document.id, documentVersion: document.version }) const featureIds = [selectedObject, 'fillet', 'chamfer', 'revolution', 'intersection', 'cut', 'union', 'pocket', 'pad', 'box', 'cylinder', 'sphere', 'cone'].filter(Boolean)
if (cancelled) { const storedShape = featureIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((shape): shape is ShapeHandle => Boolean(shape))
await facade.geometry.release(shape) if (storedShape) {
shape = null const mesh = await facade.geometry.mesh(storedShape, 0.05)
if (!cancelled) adapter.setMesh(mesh)
return return
} }
const mesh = await facade.geometry.mesh(shape, 0.05) ownsShapes = true
const profile = { outer: [[-1.35, -0.7, 0], [1.35, -0.7, 0], [1.35, 0.7, 0], [-1.35, 0.7, 0]] as [number, number, number][] }
const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
shapes.push(pad)
const pocketProfile = { outer: [[-0.55, -0.32, 0], [0.55, -0.32, 0], [0.55, 0.32, 0], [-0.55, 0.32, 0]] as [number, number, number][] }
const pocket = await facade.geometry.pocket({ base: pad, profile: pocketProfile, length: 1.8, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
shapes.push(pocket)
if (cancelled) {
await Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
return
}
const mesh = await facade.geometry.mesh(pocket, 0.05)
if (!cancelled) adapter.setMesh(mesh) if (!cancelled) adapter.setMesh(mesh)
} }
void loadGeometry().catch((error: unknown) => { void loadGeometry().catch((error: unknown) => {
@@ -410,25 +457,25 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
}) })
return () => { return () => {
cancelled = true cancelled = true
if (shape) void facade.geometry.release(shape) if (ownsShapes) void Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
adapter.dispose() adapter.dispose()
adapterRef.current = null adapterRef.current = null
} }
}, [facade, showNotice]) }, [facade, showNotice, selectedObject, documentVersion, recomputeGeneration])
useEffect(() => adapterRef.current?.setSelection(selectedObject), [selectedObject]) useEffect(() => adapterRef.current?.setSelection(selectedObject), [selectedObject])
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> 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> = { const pages: Record<Exclude<Page, 'workspace'>, ReactNode> = {
start: <StartPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />, start: <StartPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />,
projects: <ProjectsPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />, projects: <ProjectsPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} facade={facade} />,
import: <FileFlowPage mode="import" onNavigate={onNavigate} showNotice={showNotice} />, import: <FileFlowPage mode="import" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} />, export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
settings: <SettingsPage onNavigate={onNavigate} showNotice={showNotice} />, settings: <SettingsPage onNavigate={onNavigate} showNotice={showNotice} />,
help: <HelpPage onNavigate={onNavigate} showNotice={showNotice} />, help: <HelpPage onNavigate={onNavigate} showNotice={showNotice} />,
diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} />, diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
sync: <SyncPage onNavigate={onNavigate} showNotice={showNotice} />, sync: <SyncPage onNavigate={onNavigate} showNotice={showNotice} />,
} }
return <div className="page-shell"><div className="page-content">{pages[page as Exclude<Page, 'workspace'>]}</div></div> return <div className="page-shell"><div className="page-content">{pages[page as Exclude<Page, 'workspace'>]}</div></div>
@@ -455,13 +502,58 @@ function ProjectCard({ project, index, onClick }: { project: typeof projects[num
return <button className="project-card" onClick={onClick}><div className={`project-preview preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="project-card-info"><div className="project-card-title"><strong>{project.name}</strong><MoreHorizontal size={15} /></div><span>{project.path}</span><div className="project-card-meta"><span>{project.modified}</span><span>{project.objects} objects</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge></div></div></button> return <button className="project-card" onClick={onClick}><div className={`project-preview preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="project-card-info"><div className="project-card-title"><strong>{project.name}</strong><MoreHorizontal size={15} /></div><span>{project.path}</span><div className="project-card-meta"><span>{project.modified}</span><span>{project.objects} objects</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge></div></div></button>
} }
function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice, facade }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
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> const [savedProjects, setSavedProjects] = useState<ProjectSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
void facade.project.list().then((items) => { if (!cancelled) setSavedProjects(items) }).catch((error: unknown) => { if (!cancelled) showNotice(`Project list unavailable: ${error instanceof Error ? error.message : String(error)}`) }).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [facade, showNotice])
const openSavedProject = async (documentId: string) => {
const loaded = await facade.app.document.load(documentId)
if (!loaded) { showNotice('No saved local snapshot found'); return }
showNotice(`Loaded ${loaded.label}`)
onOpenWorkspace()
}
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">{loading ? <div className="project-list-empty">Loading local projects</div> : savedProjects.length === 0 ? <div className="project-list-empty">No saved local projects</div> : savedProjects.map((project, index) => { const state = project.readOnly ? 'Read only' : project.dirty ? 'Unsaved' : 'Saved'; return <button className="project-list-row" key={project.documentId} onClick={() => void openSavedProject(project.documentId)}><div className={`list-thumbnail preview-${index % 4}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.label}</strong><span>Local / {project.documentId}</span></div><span className="list-count">{project.objectCount} objects</span><span className="list-modified">{new Date(project.updatedAt).toLocaleDateString()}</span><Badge tone={state === 'Unsaved' ? 'amber' : state === 'Read only' ? 'muted' : 'green'}>{state}</Badge><span className="row-action" title="Open project"><ArrowRight size={16} /></span></button> })}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>{savedProjects.length} saved project{savedProjects.length === 1 ? '' : 's'} · browser-managed capacity</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' 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 [selectedFormat, setSelectedFormat] = useState<'STEP' | 'STL' | 'GLB / GLTF' | 'Web CAD package'>('STEP')
const [exporting, setExporting] = useState(false)
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)}`) }
}
const exportModel = async () => {
if (selectedFormat !== 'STEP' && selectedFormat !== 'STL') { showNotice(`${selectedFormat} export is not implemented yet`); return }
const objectIds = ['fillet', 'chamfer', 'revolution', 'intersection', 'cut', 'union', 'pocket', 'pad', 'box', 'cylinder', 'sphere', 'cone']
const shape = objectIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((candidate): candidate is ShapeHandle => Boolean(candidate))
if (!shape) { showNotice('Recompute a valid Part or Part Design shape before exporting'); return }
setExporting(true)
try {
const result = selectedFormat === 'STEP' ? await facade.geometry.exportStep(shape, `${facade.app.document.getActive().label}.step`) : await facade.geometry.exportStl(shape, `${facade.app.document.getActive().label}.stl`)
const blob = new Blob([result.text], { type: result.mediaType })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = result.fileName
anchor.click()
URL.revokeObjectURL(url)
showNotice(`${result.format.toUpperCase()} export downloaded`)
} catch (error) {
showNotice(`Export failed: ${error instanceof Error ? error.message : String(error)}`)
} finally { setExporting(false) }
}
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 ${selectedFormat === format ? 'is-selected' : ''}`} disabled={format === 'IGES' || format === 'GLB / GLTF' || format === 'Web CAD package'} onClick={() => { setSelectedFormat(format as typeof selectedFormat); 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' : 'Not implemented'}</small>{selectedFormat === format && <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" disabled={isImport ? false : exporting} onClick={() => { if (isImport) showNotice(fcstdReport ? 'Metadata review complete; import mapping remains disabled' : 'Choose a file to review'); else void exportModel() }}><span>{isImport ? 'Continue' : exporting ? 'Exporting' : `Export ${selectedFormat}`}</span>{exporting ? <RefreshCw size={15} /> : <ArrowRight size={15} />}</button></div></section></div></div>
} }
function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) { function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) {
@@ -489,8 +581,20 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) {
return <div className="shortcut-row"><kbd>{keyName}</kbd><span>{label}</span></div> return <div className="shortcut-row"><kbd>{keyName}</kbd><span>{label}</span></div>
} }
function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) { function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
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> const [recovery, setRecovery] = useState<{ integrity: string; lastSavedVersion: number | null; warnings: string[] } | null>(null)
const [geometry, setGeometry] = useState(() => facade.geometry.capabilities())
const document = facade.getState().document
const persistence = facade.project.capabilities()
const geometryTone = geometry.status === 'ready' ? 'green' : geometry.status === 'failed' || geometry.status === 'unavailable' ? 'amber' : 'cyan'
const geometryValue = geometry.status === 'ready' ? 'Ready' : geometry.status === 'initializing' ? 'Loading' : geometry.status
useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice])
useEffect(() => {
let disposed = false
void facade.geometry.initialize().then((capabilities) => { if (!disposed) setGeometry(capabilities) }).catch((error: unknown) => { if (!disposed) showNotice(`Geometry capability probe failed: ${error instanceof Error ? error.message : String(error)}`) })
return () => { disposed = true }
}, [facade, showNotice])
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={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} 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={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value={String(document.recompute?.errors.length || 0)} detail="Recompute diagnostics from the current document" tone={document.recompute?.errors.length ? 'amber' : 'green'} 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 }) { function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {

View File

@@ -11,10 +11,11 @@ export type DependencyEdge = {
export type RecomputePlan = { export type RecomputePlan = {
affected: string[] affected: string[]
order: string[] order: string[]
levels: string[][]
cycles: string[][] cycles: string[][]
} }
export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'error' | 'upstream-failed' export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'suppressed' | 'upstream-suppressed' | 'error' | 'upstream-failed'
export type RecomputeSnapshot = { export type RecomputeSnapshot = {
generation: number generation: number
@@ -85,7 +86,14 @@ export class DependencyGraph {
if (!cyclic.has(nodeId)) order.push(nodeId) if (!cyclic.has(nodeId)) order.push(nodeId)
} }
for (const nodeId of affected) visitDependencies(nodeId) for (const nodeId of affected) visitDependencies(nodeId)
return { affected: [...affected], order, cycles } const levelByNode = new Map<string, number>()
for (const nodeId of order) {
const level = Math.max(0, ...this.dependenciesOf(nodeId).filter((dependency) => affected.has(dependency)).map((dependency) => (levelByNode.get(dependency) ?? 0) + 1))
levelByNode.set(nodeId, level)
}
const levels: string[][] = []
for (const nodeId of order) { const level = levelByNode.get(nodeId) ?? 0; (levels[level] ??= []).push(nodeId) }
return { affected: [...affected], order, levels, cycles }
} }
findCycles(scope: Set<string> = this.nodes): string[][] { findCycles(scope: Set<string> = this.nodes): string[][] {

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

@@ -0,0 +1,261 @@
import { unzipSync } from 'fflate'
import { XMLParser } from 'fast-xml-parser'
import type { DocumentSnapshot, DocumentObjectSnapshot, ModelTreeItem, ObjectPropertySnapshot } from './types'
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
proxyDocument: DocumentSnapshot
}
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 hashString = (value: string) => {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) hash = Math.imul(hash ^ value.charCodeAt(index), 16777619)
return (hash >>> 0).toString(16).padStart(8, '0')
}
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,
}
}
const createProxyProperty = (name: string, label: string, value: string): ObjectPropertySnapshot => ({ name, label, group: 'FCStd import', scope: 'data', type: 'App::PropertyString', value, readOnly: true })
const createProxyDocument = (inspection: Omit<FcstdInspection, 'proxyDocument'>): DocumentSnapshot => {
const documentId = `fcstd-${hashString(`${inspection.label}|${inspection.schemaVersion}|${inspection.objects.map((object) => object.name).join('|')}`)}`
const tree: ModelTreeItem[] = inspection.objects.map((object) => ({ id: object.name, label: object.label, type: object.typeId.includes('Body') ? 'body' : object.typeId.includes('Sketch') ? 'sketch' : 'feature', state: 'readonly', detail: `${object.typeId} · ${object.support}` }))
const objects: DocumentObjectSnapshot[] = inspection.objects.map((object) => ({ id: object.name, typeId: object.typeId, properties: [createProxyProperty('Label', 'Label', object.label), createProxyProperty('TypeId', 'Type', object.typeId), createProxyProperty('ImportSupport', 'Import support', object.support), createProxyProperty('PropertyCount', 'Property count', String(object.propertyCount))] }))
return { id: documentId, label: inspection.label, version: 1, dirty: false, readOnly: true, units: 'mm', tree, objects, dependencies: [], recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(objects.map((object) => [object.id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] } }
}
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'
const inspection: Omit<FcstdInspection, 'proxyDocument'> = {
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,
},
}
return { ...inspection, proxyDocument: createProxyDocument(inspection) }
}

View File

@@ -1,7 +1,7 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt' import type { Inputs } from '@bitbybit-dev/occt'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef } from './types' import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef, SubshapeTopology } from './types'
import { createSubshapeRefs } from './topologyNaming' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs } from './topologyNaming'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
type KernelMesh = Inputs.OCCT.DecomposedMeshDto type KernelMesh = Inputs.OCCT.DecomposedMeshDto
@@ -408,7 +408,11 @@ export class BitbybitGeometryRuntime {
validatePocketInput(input) validatePocketInput(input)
const base = this.resolveShape(input.base).reference const base = this.resolveShape(input.base).reference
const client = await this.readyClient() const client = await this.readyClient()
const tool = await this.createExtrusion(client, input) const direction = input.direction ?? [0, 1, 0]
const featureInput = input.throughAll
? { ...input, length: await this.throughAllLength(client, base, direction), symmetricToPlane: true }
: input
const tool = await this.createExtrusion(client, featureInput)
const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false }) const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false })
return this.registerShape(kernelShape, input.documentId, input.documentVersion) return this.registerShape(kernelShape, input.documentId, input.documentVersion)
} }
@@ -438,6 +442,15 @@ export class BitbybitGeometryRuntime {
return (await this.mesh(shape, precision)).subshapes ?? [] return (await this.mesh(shape, precision)).subshapes ?? []
} }
async topology(shape: ShapeHandle, precision = 0.05): Promise<SubshapeTopology> {
finitePositive(precision, 'precision')
const entry = this.resolveShape(shape)
const client = await this.readyClient()
const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false })
const faces = mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes }))
return { faces: createSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, edges: createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, vertices: createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs }
}
async release(shape: ShapeHandle): Promise<void> { async release(shape: ShapeHandle): Promise<void> {
const entry = this.shapes.get(shape.id) const entry = this.shapes.get(shape.id)
if (!entry) return if (!entry) return
@@ -493,6 +506,25 @@ export class BitbybitGeometryRuntime {
return client.occt.operations.extrude({ shape: face, direction: extrusion }) return client.occt.operations.extrude({ shape: face, direction: extrusion })
} }
private async throughAllLength(client: BitByBitOCCT, shape: KernelShapeReference, direction: Point3) {
const mesh = await client.occt.shapeToMesh({ shape, precision: 0.1, adjustYtoZ: false })
const directionLength = magnitude(direction)
const normalized = direction.map((coordinate) => coordinate / directionLength) as Point3
let minimum = Infinity
let maximum = -Infinity
for (const face of mesh.faceList) {
for (let index = 0; index < face.vertexCoord.length; index += 3) {
const point: Point3 = [face.vertexCoord[index], face.vertexCoord[index + 1], face.vertexCoord[index + 2]]
const projection = dot(point, normalized)
minimum = Math.min(minimum, projection)
maximum = Math.max(maximum, projection)
}
}
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) throw new Error('Through-all pocket cannot determine the base Shape extent.')
const span = Math.max(maximum - minimum, 1)
return span + Math.max(1, span * 0.05)
}
private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) { private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) {
const handle: ShapeHandle = { const handle: ShapeHandle = {
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`, id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,

View File

@@ -3,7 +3,11 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
export { ThreeViewportAdapter } from './threeViewport' export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming' export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd'

View File

@@ -16,6 +16,7 @@ import type {
SetPropertyInput, SetPropertyInput,
SetExpressionInput, SetExpressionInput,
RecomputeResult, RecomputeResult,
ShapeHandle,
TaskSnapshot, TaskSnapshot,
Unsubscribe, Unsubscribe,
} from './types' } from './types'
@@ -25,6 +26,8 @@ import { ThreeViewportAdapter } from './threeViewport'
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph' import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units' import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher' import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -36,12 +39,13 @@ const initialTree: ModelTreeItem[] = [
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
] ]
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
{ name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true }, { name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true },
{ name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true }, { name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true },
...(item.type === 'feature' ? [{ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false, recompute: true }] : []),
] ]
const viewProperties = (): ObjectPropertySnapshot[] => [ const viewProperties = (): ObjectPropertySnapshot[] => [
@@ -55,6 +59,29 @@ const viewProperties = (): ObjectPropertySnapshot[] => [
] ]
const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
if (item.id.startsWith('box')) return [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
]
if (item.id.startsWith('cylinder')) return [
{ name: 'Radius', label: 'Radius', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
{ name: 'Height', label: 'Height', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
{ name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
]
if (item.id.startsWith('sphere')) return [
{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
]
if (item.id.startsWith('cone')) return [
{ name: 'Radius1', label: 'Bottom radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
{ name: 'Radius2', label: 'Top radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
{ name: 'Height', label: 'Height', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
{ name: 'Angle', label: 'Angle', group: 'Cone', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
]
if (item.id.startsWith('union') || item.id.startsWith('cut') || item.id.startsWith('intersection')) return [
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
]
if (item.id.startsWith('pad')) return [ if (item.id.startsWith('pad')) return [
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' }, { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
@@ -68,16 +95,25 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
] ]
if (item.id.startsWith('revolution')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
]
if (item.id.startsWith('fillet')) return [ if (item.id.startsWith('fillet')) return [
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true },
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
] ]
if (item.id.startsWith('chamfer')) return [
{ name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true },
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
]
if (item.type === 'sketch') return [ if (item.type === 'sketch') return [
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true }, { name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true },
{ name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true }, { name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true },
] ]
if (item.type === 'body') return [ if (item.type === 'body') return [
{ name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', recompute: true }, { name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', readOnly: true, recompute: true },
] ]
return [] return []
} }
@@ -128,22 +164,55 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
return document return document
} }
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object']) 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', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer'])
const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution'])
const shapeTypeIds = new Set([
'Part::Box',
'Part::Cylinder',
'Part::Sphere',
'Part::Cone',
'Part::Fuse',
'Part::Cut',
'Part::Common',
'Part::Feature',
'PartDesign::Feature',
'PartDesign::Pad',
'PartDesign::Pocket',
'PartDesign::Revolution',
'PartDesign::Fillet',
'PartDesign::Chamfer',
])
const featureCommands: Record<string, { label: string; detail: string }> = { const featureCommands: Record<string, { label: string; detail: string }> = {
'create-body': { label: 'Body', detail: 'Part Design body' }, 'create-body': { label: 'Body', detail: 'Part Design body' },
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
pad: { label: 'Pad', detail: 'Length 42 mm' }, pad: { label: 'Pad', detail: 'Length 42 mm' },
pocket: { label: 'Pocket', detail: 'Through all' }, pocket: { label: 'Pocket', detail: 'Through all' },
revolution: { label: 'Revolution', detail: 'Angle 360 deg' },
fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
primitive: { label: 'Box', detail: '10 × 10 × 10 mm' },
union: { label: 'Union', detail: 'Boolean fuse' },
cut: { label: 'Cut', detail: 'Boolean difference' },
intersection: { label: 'Intersection', detail: 'Boolean common' },
} }
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string, selectedTypeId?: string): CommandState => {
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) 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 (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' } 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 (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
if (partCommands.has(commandId) && activeWorkbench !== 'Part') return { id: commandId, status: 'disabled', reason: `Switch to Part to use ${commandId}.` }
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
if (selectedObjectId && !selectedTypeId) return { id: commandId, status: 'disabled', reason: 'The selected object no longer exists in this document.' }
if (commandId === 'solve-sketch' && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a Sketcher sketch before solving constraints.' }
if (shapeSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId)) return { id: commandId, status: 'disabled', reason: 'Select a solid or shape-producing feature for this operation.' }
if (featureSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId) && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a sketch or solid feature as the Part Design source.' }
return { id: commandId, status: 'enabled' } return { id: commandId, status: 'enabled' }
} }
@@ -151,8 +220,9 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (property.readOnly) throw new Error(`${property.label} is read-only.`) if (property.readOnly) throw new Error(`${property.label} is read-only.`)
if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`) if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`)
if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`) if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
if (property.type === 'App::PropertyLength' && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`)
if (property.type === 'App::PropertyAngle' && (value as number) > 360) throw new RangeError(`${property.label} must be between 0 and 360 degrees.`)
if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`) if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`)
if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`) if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`)
if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`) if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`)
@@ -210,8 +280,23 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<str
export function createMockFacade(): BitBybitWebCadFacade { export function createMockFacade(): BitBybitWebCadFacade {
const projectPersistence = createSqliteProjectPersistence() const projectPersistence = createSqliteProjectPersistence()
const geometryRuntime = new BitbybitGeometryRuntime() const geometryRuntime = new BitbybitGeometryRuntime()
const featureShapes = new Map<string, ShapeHandle>()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) 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: [] } let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const recomputeCoordinator = new RecomputeCoordinator(
createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
(documentId) => state.document.id === documentId ? state.document.version : null,
)
const releaseFeatureShape = (objectId: string) => {
const shape = featureShapes.get(objectId)
featureShapes.delete(objectId)
if (shape) void geometryRuntime.release(shape)
}
const clearFeatureShapes = () => {
const retained = [...featureShapes.values()]
featureShapes.clear()
void Promise.all(retained.map((shape) => geometryRuntime.release(shape)))
}
const listeners = new Set<FacadeListener>() const listeners = new Set<FacadeListener>()
const undoStack: FacadeState[] = [] const undoStack: FacadeState[] = []
const redoStack: FacadeState[] = [] const redoStack: FacadeState[] = []
@@ -230,23 +315,48 @@ export function createMockFacade(): BitBybitWebCadFacade {
const existing = state.document.tree.filter((item) => item.label.toLowerCase().startsWith(base)).length const existing = state.document.tree.filter((item) => item.label.toLowerCase().startsWith(base)).length
return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}` return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}`
} }
const appendFeature = (document: DocumentSnapshot, commandId: string): { document: DocumentSnapshot; objectId: string } => { const appendFeature = (document: DocumentSnapshot, commandId: string, draft: Record<string, unknown> = {}): { document: DocumentSnapshot; objectId: string } => {
const definition = featureCommands[commandId] const primitiveType = commandId === 'primitive' && ['Box', 'Cylinder', 'Sphere', 'Cone'].includes(String(draft.primitiveType)) ? String(draft.primitiveType) : 'Box'
const definition = commandId === 'primitive' ? { label: primitiveType, detail: primitiveType === 'Box' ? '10 × 10 × 10 mm' : `${primitiveType} primitive` } : featureCommands[commandId]
if (!definition) return { document, objectId: '' } if (!definition) return { document, objectId: '' }
const objectId = nextFeatureId(definition.label) const objectId = nextFeatureId(definition.label)
const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature' const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature'
const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail } const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail }
const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined })) const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined }))
if (type === 'body') tree.push({ ...item, children: [] }) const isPartObject = partCommands.has(commandId)
if (type === 'body' || isPartObject) tree.push({ ...item, children: type === 'body' ? [] : undefined })
else { else {
const body = tree.find((entry) => entry.type === 'body') const body = tree.find((entry) => entry.type === 'body')
if (body) body.children = [...(body.children || []), objectId] if (body) body.children = [...(body.children || []), objectId]
tree.push(item) tree.push(item)
} }
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), createObjectSnapshot(item)] const objectSnapshot = createObjectSnapshot(item)
const sourceId = typeof draft.source === 'string' && document.objects.some((object) => object.id === draft.source) ? draft.source : ''
const sourceObject = sourceId ? document.objects.find((object) => object.id === sourceId) : undefined
objectSnapshot.properties = objectSnapshot.properties.map((property) => {
if (!property.recompute) return property
const candidate = draft[property.name.toLowerCase()]
if (property.type === 'App::PropertyLink' && typeof candidate === 'string' && candidate.length > 0) return { ...property, value: candidate }
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate }
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate }
if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate }
return property
})
if (isPartObject && commandId !== 'primitive') {
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
}
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined
if (tipObject) {
const tip = tipObject.properties.find((property) => property.name === 'Tip')
if (tip) tip.value = objectId
}
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects } const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
nextDocument.dependencies = collectDependencyEdges(nextDocument) nextDocument.dependencies = collectDependencyEdges(nextDocument)
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0) nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
markDocumentTouched(nextDocument, tipObject ? [objectId, tipObject.id] : [objectId])
return { document: nextDocument, objectId } return { document: nextDocument, objectId }
} }
const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => { const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => {
@@ -279,7 +389,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`)
const sourceProperty = sourceObject.properties[propertyIndex] const sourceProperty = sourceObject.properties[propertyIndex]
if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`) if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`)
if (!['App::PropertyLength', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`)
const evaluated = expectedExpressionValue(sourceProperty, expression, state.document) const evaluated = expectedExpressionValue(sourceProperty, expression, state.document)
const references = expressionReferences(expression) const references = expressionReferences(expression)
const variables = expressionVariables(state.document) const variables = expressionVariables(state.document)
@@ -310,20 +420,21 @@ export function createMockFacade(): BitBybitWebCadFacade {
const message = `Dependency cycle: ${cycle.join(' -> ')}` const message = `Dependency cycle: ${cycle.join(' -> ')}`
for (const objectId of cycle) { objectStates[objectId] = 'error'; errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) } for (const objectId of cycle) { objectStates[objectId] = 'error'; errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) }
} }
if (errors.length === 0) { for (const objectId of plan.order) {
for (const objectId of plan.order) { const dependencies = graph.dependenciesOf(objectId)
objectStates[objectId] = 'up-to-date' if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')) {
const item = document.tree.find((candidate) => candidate.id === objectId) objectStates[objectId] = 'upstream-failed'
if (item?.state === 'dirty') item.state = item.type === 'body' ? 'active' : 'valid' } else if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed')) {
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') objectStates[objectId] = 'upstream-suppressed'
if (status?.value === 'Touched') status.value = 'Valid' } else {
} const object = document.objects.find((candidate) => candidate.id === objectId)
} else { objectStates[objectId] = object?.properties.some((property) => property.name === 'Suppressed' && property.value === true) ? 'suppressed' : 'up-to-date'
for (const objectId of plan.affected) if (objectStates[objectId] !== 'error') objectStates[objectId] = 'upstream-failed'
for (const objectId of plan.affected) {
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'active') item.state = 'warning'
} }
if (objectStates[objectId] === 'suppressed' || objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId)
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'readonly') item.state = objectStates[objectId] === 'upstream-failed' ? 'warning' : item.type === 'body' ? 'active' : 'valid'
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
if (status) status.value = objectStates[objectId] === 'suppressed' ? 'Suppressed' : objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
} }
const nextRecompute = { generation, status: errors.length === 0 ? 'completed' as const : 'failed' as const, objectStates, dirtyObjects: errors.length === 0 ? [] : plan.affected, order: plan.order, errors } const nextRecompute = { generation, status: errors.length === 0 ? 'completed' as const : 'failed' as const, objectStates, dirtyObjects: errors.length === 0 ? [] : plan.affected, order: plan.order, errors }
document.recompute = nextRecompute document.recompute = nextRecompute
@@ -332,6 +443,47 @@ export function createMockFacade(): BitBybitWebCadFacade {
emitState() emitState()
return { ...plan, generation, status: nextRecompute.status, errors } 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) {
if (result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId)
const item = document.tree.find((candidate) => candidate.id === objectId)
if (!item || item.state === 'readonly') continue
if (result.objectStates[objectId] === 'up-to-date' || result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') 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] === 'suppressed' ? 'Suppressed' : result.objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : 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 loadDocument = async (documentId: string) => {
recomputeCoordinator.cancel()
const loaded = await projectPersistence.load(documentId)
if (!loaded) return null
clearFeatureShapes()
const document = cloneDocumentSnapshot(loaded)
commit({ ...state, document, selectedObjectId: '', task: null })
await recomputeDocumentAsync({ dirtyObjectIds: document.objects.map((object) => object.id) })
notify(`Loaded ${document.label}`)
return getState().document
}
const getSketch = (objectId: string) => { const getSketch = (objectId: string) => {
const object = state.document.objects.find((candidate) => candidate.id === objectId) const object = state.document.objects.find((candidate) => candidate.id === objectId)
return object?.sketch ? cloneSketch(object.sketch) : null return object?.sketch ? cloneSketch(object.sketch) : null
@@ -381,26 +533,28 @@ export function createMockFacade(): BitBybitWebCadFacade {
const applyTask = () => { const applyTask = () => {
const task = state.task const task = state.task
if (!task || task.status !== 'preview') return if (!task || task.status !== 'preview') return
const result = appendFeature(state.document, task.commandId) const result = appendFeature(state.document, task.commandId, task.draft)
if (!result.objectId) { if (!result.objectId) {
state = { ...state, task: { ...task, status: 'completed' } } state = { ...state, task: { ...task, status: 'completed' } }
emitState() emitState()
return return
} }
commit({ ...state, document: result.document, selectedObjectId: result.objectId, task: { ...task, status: 'completed' } }) commit({ ...state, document: result.document, selectedObjectId: result.objectId, task: { ...task, status: 'completed' } })
notify(`${featureCommands[task.commandId].label} created`) notify(`${result.document.tree.find((item) => item.id === result.objectId)?.label || featureCommands[task.commandId]?.label || task.commandId} created`)
} }
const execute = ({ commandId, payload }: ExecuteCommandInput) => { const execute = ({ commandId, payload }: ExecuteCommandInput) => {
const requestId = `req-${++requestSequence}` const requestId = `req-${++requestSequence}`
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } 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) const selectedTypeId = state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId)
if (status.status === 'disabled') { 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] } 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 emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
} }
emit({ type: 'command.started', commandId, context }) emit({ type: 'command.started', commandId, context })
if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) }
else if (commandId === 'save') { else if (commandId === 'save') {
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 } const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
commit({ ...state, document: savedDocument }) commit({ ...state, document: savedDocument })
@@ -411,19 +565,40 @@ export function createMockFacade(): BitBybitWebCadFacade {
}) })
} }
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
else if (commandId === 'check-shape') {
const objectId = state.selectedObjectId
const shape = featureShapes.get(objectId)
const reportFailure = (code: string, message: string) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message, objectId, requestId }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context })
notify(message)
}
if (!shape) reportFailure('SHAPE_NOT_RECOMPUTED', `No valid Shape is cached for ${objectId}; recompute the document first.`)
else void geometryRuntime.topology(shape, 0.05).then((topology) => {
const count = topology.faces.length + topology.edges.length + topology.vertices.length
if (count === 0) throw new Error('Shape topology is empty.')
const message = `Shape check passed: ${topology.faces.length} faces, ${topology.edges.length} edges, ${topology.vertices.length} vertices`
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'info', code: 'SHAPE_CHECK_PASSED', message, objectId, requestId }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context })
notify(message)
}).catch((error: unknown) => reportFailure('SHAPE_CHECK_FAILED', error instanceof Error ? error.message : String(error)))
}
else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') } else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') }
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null })
else if (featureCommands[commandId]) beginTask(commandId, partCommands.has(commandId) && commandId !== 'primitive' ? { source: state.selectedObjectId || null, base: state.selectedObjectId || '', tool: '' } : { source: state.selectedObjectId || null })
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId emit({ type: 'command.completed', commandId, context }); emitState(); return requestId
} }
const facade: BitBybitWebCadFacade = { 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(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, 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') } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); 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 } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, 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() } }, 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(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), 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() }, 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), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() }, viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
} }

View File

@@ -1,11 +1,13 @@
import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm' import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema'
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectResource } from './types' import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSummary } from './types'
type PersistenceRequest = type PersistenceRequest =
| { id: number; type: 'initialize' } | { id: number; type: 'initialize' }
| { id: number; type: 'list-projects' }
| { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'save-document'; document: DocumentSnapshot }
| { id: number; type: 'load-document'; documentId: string } | { id: number; type: 'load-document'; documentId: string }
| { id: number; type: 'recovery-report'; documentId: string }
| { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string }
| { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'get-resource'; hash: string }
| { id: number; type: 'release-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
@@ -13,8 +15,10 @@ type PersistenceRequest =
type PersistenceResponse = type PersistenceResponse =
| { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities }
| { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] }
| { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] }
| { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null }
| { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport }
| { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource }
| { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null }
| { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'resource-released' }
@@ -30,6 +34,7 @@ const workerScope = globalThis as unknown as WorkerScope
let sqlite3: Sqlite3Static | undefined let sqlite3: Sqlite3Static | undefined
let database: Database | undefined let database: Database | undefined
let assetsDirectory: FileSystemDirectoryHandle | undefined let assetsDirectory: FileSystemDirectoryHandle | undefined
const transientResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' } let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' }
const cloneTree = (tree: ModelTreeItem[]) => tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) const cloneTree = (tree: ModelTreeItem[]) => tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined }))
@@ -98,6 +103,21 @@ const saveDocument = (document: DocumentSnapshot) => {
return { documentId: document.id, documentVersion: document.version, persistedAt: now, mode: capabilities.mode } return { documentId: document.id, documentVersion: document.version, persistedAt: now, mode: capabilities.mode }
} }
const listProjects = (): ProjectSummary[] => {
if (!database) throw new Error('Persistence database is not initialized.')
const rows = database.exec({
sql: `SELECT documents.id AS document_id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at, COUNT(objects.id) AS object_count
FROM documents
JOIN projects ON projects.id = documents.project_id
LEFT JOIN objects ON objects.document_id = documents.id
GROUP BY documents.id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at
ORDER BY projects.updated_at DESC`,
rowMode: 'object',
returnValue: 'resultRows',
}) as Array<Record<string, string | number>>
return rows.map((row) => ({ documentId: String(row.document_id), label: String(row.label), documentVersion: Number(row.version), objectCount: Number(row.object_count), dirty: Boolean(row.dirty), readOnly: Boolean(row.read_only), updatedAt: Number(row.updated_at) }))
}
const loadDocument = (documentId: string): DocumentSnapshot | null => { const loadDocument = (documentId: string): DocumentSnapshot | null => {
if (!database) throw new Error('Persistence database is not initialized.') if (!database) throw new Error('Persistence database is not initialized.')
const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>> const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
@@ -133,9 +153,26 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
} }
} }
const recoveryReport = (documentId: string): ProjectRecoveryReport => {
if (!database) throw new Error('Persistence database is not initialized.')
const integrityRows = database.exec({ sql: 'PRAGMA integrity_check', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
const integrityValue = String(integrityRows[0]?.integrity_check ?? '')
const documents = database.exec({ sql: 'SELECT version, dirty FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
const warnings: string[] = []
if (!documents[0]) warnings.push('No saved document exists for this ID.')
if (integrityValue !== 'ok') warnings.push(`SQLite integrity check returned: ${integrityValue || 'no result'}.`)
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, warnings }
}
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => { const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
if (!database) throw new Error('Persistence database is not initialized.') if (!database) throw new Error('Persistence database is not initialized.')
const hash = await hashBytes(bytes) const hash = await hashBytes(bytes)
if (!capabilities.opfs) {
const existing = transientResources.get(hash)
const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 }
transientResources.set(hash, { resource, bytes: new Uint8Array(bytes.slice(0)) })
return { ...resource }
}
const directory = await getAssetsDirectory() const directory = await getAssetsDirectory()
const handle = await directory.getFileHandle(hash, { create: true }) const handle = await directory.getFileHandle(hash, { create: true })
const writable = await handle.createWritable() const writable = await handle.createWritable()
@@ -149,6 +186,10 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<Proje
} }
const getResource = async (hash: string): Promise<ArrayBuffer | null> => { const getResource = async (hash: string): Promise<ArrayBuffer | null> => {
if (!capabilities.opfs) {
const resource = transientResources.get(hash)
return resource ? resource.bytes.slice().buffer : null
}
const directory = await getAssetsDirectory() const directory = await getAssetsDirectory()
try { try {
const handle = await directory.getFileHandle(hash) const handle = await directory.getFileHandle(hash)
@@ -161,6 +202,13 @@ const getResource = async (hash: string): Promise<ArrayBuffer | null> => {
const releaseResource = async (hash: string) => { const releaseResource = async (hash: string) => {
if (!database) throw new Error('Persistence database is not initialized.') if (!database) throw new Error('Persistence database is not initialized.')
if (!capabilities.opfs) {
const existing = transientResources.get(hash)
if (!existing) return
if (existing.resource.refCount > 1) existing.resource.refCount -= 1
else transientResources.delete(hash)
return
}
const resources = database.exec({ sql: 'SELECT ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>> const resources = database.exec({ sql: 'SELECT ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
const row = resources[0] const row = resources[0]
if (!row) return if (!row) return
@@ -175,10 +223,12 @@ const releaseResource = async (hash: string) => {
const handle = async (request: PersistenceRequest): Promise<PersistenceResponse> => { const handle = async (request: PersistenceRequest): Promise<PersistenceResponse> => {
try { try {
if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() } if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() }
if (request.type === 'dispose') { database?.close(); database = undefined; return { id: request.id, ok: true, type: 'disposed' } } if (request.type === 'dispose') { database?.close(); database = undefined; transientResources.clear(); return { id: request.id, ok: true, type: 'disposed' } }
await initialize() await initialize()
if (request.type === 'list-projects') return { id: request.id, ok: true, type: 'projects-listed', projects: listProjects() }
if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } } if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } }
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) } if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) }
if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) } if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) }
if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) } if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) }
if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } } if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } }

View File

@@ -1,9 +1,9 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types' import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, Unsubscribe } from './types'
import { cloneSketch } from './sketcher' import { cloneSketch } from './sketcher'
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string } type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string } type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string } type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' } const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
@@ -11,8 +11,11 @@ const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...do
export interface ProjectPersistenceClient { export interface ProjectPersistenceClient {
initialize(): Promise<PersistenceCapabilities> initialize(): Promise<PersistenceCapabilities>
capabilities(): PersistenceCapabilities capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
list(): Promise<ProjectSummary[]>
save(document: DocumentSnapshot): Promise<ProjectSaveResult> save(document: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null> load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
resource: { resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource> put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null> get(hash: string): Promise<Uint8Array | null>
@@ -67,17 +70,27 @@ export class ProjectAutosaveScheduler {
export class SqliteProjectPersistence implements ProjectPersistenceClient { export class SqliteProjectPersistence implements ProjectPersistenceClient {
private readonly worker: Worker | null private readonly worker: Worker | null
private readonly senderId = `tab-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`
private readonly changeChannel: BroadcastChannel | null
private readonly externalChangeListeners = new Set<(notice: ProjectChangeNotice) => void>()
private nextRequestId = 0 private nextRequestId = 0
private currentCapabilities = unavailable private currentCapabilities = unavailable
private initialized: Promise<PersistenceCapabilities> | null = null private initialized: Promise<PersistenceCapabilities> | null = null
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>() private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>() private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>() private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
private readonly writeQueue = new PersistenceWriteQueue() private readonly writeQueue = new PersistenceWriteQueue()
constructor() { constructor() {
this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' }) this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Worker is unavailable; using a transient in-memory project store.' } this.changeChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('bitbybit-project-changes')
;(this.changeChannel as (BroadcastChannel & { unref?: () => void }) | null)?.unref?.()
this.changeChannel?.addEventListener('message', (event: MessageEvent<ProjectChangeNotice>) => {
const notice = event.data
if (!notice || notice.senderId === this.senderId || notice.kind !== 'document-saved') return
this.externalChangeListeners.forEach((listener) => listener({ ...notice }))
})
if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, crossTabWriteLock: this.crossTabWriteLockMode(), reason: 'Worker is unavailable; using a transient in-memory project store.' }
if (this.worker) { if (this.worker) {
this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) } this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) }
this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() } this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() }
@@ -86,33 +99,68 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
capabilities() { return { ...this.currentCapabilities } } capabilities() { return { ...this.currentCapabilities } }
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe {
this.externalChangeListeners.add(listener)
return () => this.externalChangeListeners.delete(listener)
}
initialize() { initialize() {
if (!this.worker) return Promise.resolve(this.currentCapabilities) if (!this.worker) return Promise.resolve(this.currentCapabilities)
if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = response.capabilities; return this.capabilities() }) if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = { ...response.capabilities, crossTabWriteLock: this.crossTabWriteLockMode() }; return this.capabilities() })
return this.initialized return this.initialized
} }
async list() {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) return [...this.fallbackSnapshots.values()].map(({ document, savedAt }) => ({ documentId: document.id, label: document.label, documentVersion: document.version, objectCount: document.objects.length, dirty: document.dirty, readOnly: document.readOnly, updatedAt: savedAt })).sort((left, right) => right.updatedAt - left.updatedAt)
const response = await this.request({ type: 'list-projects' })
if (!response.ok || response.type !== 'projects-listed') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.projects.map((project) => ({ ...project }))
}
save(document: DocumentSnapshot) { save(document: DocumentSnapshot) {
return this.writeQueue.run(async () => { return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize() await this.initialize()
if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } } if (!this.worker) {
const snapshot = cloneDocument(document)
const persistedAt = Date.now()
this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt })
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode }
this.announceSaved(result.documentId, result.documentVersion)
return result
}
const response = await this.request({ type: 'save-document', document }) const response = await this.request({ type: 'save-document', document })
if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode } const result = { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
}) this.announceSaved(result.documentId, result.documentVersion)
return result
}))
} }
async load(documentId: string) { async load(documentId: string) {
await this.writeQueue.drain() await this.writeQueue.drain()
await this.initialize() await this.initialize()
if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null } if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot.document) : null }
const response = await this.request({ type: 'load-document', documentId }) const response = await this.request({ type: 'load-document', documentId })
if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.document return response.document
} }
async recovery(documentId: string): Promise<ProjectRecoveryReport> {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) {
const snapshot = this.fallbackSnapshots.get(documentId)?.document
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
}
const response = await this.request({ type: 'recovery-report', documentId })
if (!response.ok || response.type !== 'recovery-report') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.report
}
readonly resource = { readonly resource = {
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => { put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize() await this.initialize()
if (!this.worker) { if (!this.worker) {
const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}` const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}`
@@ -125,7 +173,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer]) const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer])
if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.resource return response.resource
}), })),
get: async (hash: string) => { get: async (hash: string) => {
await this.writeQueue.drain() await this.writeQueue.drain()
await this.initialize() await this.initialize()
@@ -134,19 +182,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.bytes ? new Uint8Array(response.bytes) : null return response.bytes ? new Uint8Array(response.bytes) : null
}, },
release: (hash: string) => this.writeQueue.run(async () => { release: (hash: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize() await this.initialize()
if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return } if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return }
const response = await this.request({ type: 'release-resource', hash }) const response = await this.request({ type: 'release-resource', hash })
if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
}), })),
} }
async dispose() { async dispose() {
await this.writeQueue.drain() await this.writeQueue.drain()
if (!this.worker) return if (this.worker) {
await this.request({ type: 'dispose' }) await this.request({ type: 'dispose' })
this.worker.terminate() this.worker.terminate()
}
this.changeChannel?.close()
}
private crossTabWriteLockMode(): 'web-locks' | 'local-queue' {
return typeof navigator !== 'undefined' && 'locks' in navigator ? 'web-locks' : 'local-queue'
}
private withCrossTabWriteLock<T>(operation: () => Promise<T>): Promise<T> {
if (typeof navigator === 'undefined' || !navigator.locks) return operation()
return navigator.locks.request<Promise<T>>('bitbybit-project-write', { mode: 'exclusive' }, (lock) => {
if (!lock) throw new Error('Unable to acquire the cross-tab project write lock.')
return operation()
}).then((result) => result)
}
private announceSaved(documentId: string, documentVersion: number) {
this.changeChannel?.postMessage({ documentId, documentVersion, senderId: this.senderId, kind: 'document-saved' } satisfies ProjectChangeNotice)
} }
private request(input: WorkerInput, transfer: Transferable[] = []) { private request(input: WorkerInput, transfer: Transferable[] = []) {

View File

@@ -0,0 +1,378 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } 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' | 'suppressed' | 'failed'
errors?: RecomputeExecutionError[]
updatedObject?: DocumentObjectSnapshot
}
export type RecomputeNodeExecutor = (
object: DocumentObjectSnapshot,
document: DocumentSnapshot,
context: RecomputeNodeContext,
) => Promise<RecomputeNodeResult>
export type RecomputeGeometryRuntime = {
capabilities(): { status: string }
createBox(input: CreateBoxInput): Promise<ShapeHandle>
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle>
union(input: BooleanUnionInput): Promise<ShapeHandle>
cut(input: BooleanCutInput): Promise<ShapeHandle>
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
revolution(input: RevolutionInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
release(shape: ShapeHandle): Promise<void>
}
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[]
levels: string[][]
completed: string[]
suppressed: 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 suppressed: 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 (objectStates[objectId] !== 'up-to-date' && objectStates[objectId] !== 'suppressed' && objectStates[objectId] !== 'upstream-suppressed') objectStates[objectId] = 'touched'
}
}
if (this.active?.generation === generation) this.active = null
return {
generation,
documentVersion: document.version,
status,
affected: plan.affected,
order: plan.order,
levels: plan.levels,
completed,
suppressed,
failed,
skipped,
dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] === 'touched' || objectStates[objectId] === 'recomputing' || objectStates[objectId] === 'error' || objectStates[objectId] === 'upstream-failed'),
objectStates,
objectUpdates,
errors,
}
}
let processed = 0
for (const level of plan.levels) {
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
// Nodes in a level have no dependencies on each other. Execute them together,
// then merge outcomes in plan order so persistence and UI events stay deterministic.
const outcomes = await Promise.all(level.map(async (objectId) => {
const dependencies = graph.dependenciesOf(objectId)
const failedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
if (failedDependency) return { objectId, state: 'upstream-failed' as const, error: { objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` } }
const suppressedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed')
if (suppressedDependency) return { objectId, state: 'upstream-suppressed' as const }
const object = objectById.get(objectId)
if (!object) return { objectId, state: 'error' as const, error: { objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` } }
try {
const result = await this.executeNode(object, document, {
documentId: document.id,
documentVersion: document.version,
generation,
signal: controller.signal,
})
if (result.status === 'failed') {
return {
objectId,
state: 'error' as const,
errors: result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }],
}
}
if (result.status === 'suppressed') return { objectId, state: 'suppressed' as const }
return { objectId, state: 'up-to-date' as const, updatedObject: result.updatedObject }
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return { objectId, state: 'cancelled' as const }
return { objectId, state: 'error' as const, error: { objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) } }
}
}))
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
for (const outcome of outcomes) {
if (outcome.state === 'cancelled') return terminalResult('cancelled')
objectStates[outcome.objectId] = outcome.state
processed += 1
if (outcome.state === 'up-to-date') {
completed.push(outcome.objectId)
if (outcome.updatedObject) objectUpdates.push(outcome.updatedObject)
} else if (outcome.state === 'suppressed') {
suppressed.push(outcome.objectId)
skipped.push(outcome.objectId)
} else if (outcome.state === 'upstream-suppressed') {
skipped.push(outcome.objectId)
} else if (outcome.state === 'upstream-failed') {
skipped.push(outcome.objectId)
if (outcome.error) errors.push(outcome.error)
} else {
failed.push(outcome.objectId)
if ('errors' in outcome && outcome.errors) errors.push(...outcome.errors)
else if (outcome.error) errors.push(outcome.error)
}
options.onProgress?.({ generation, documentVersion: document.version, objectId: outcome.objectId, completed: processed, total: plan.order.length, state: outcome.state })
}
}
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')
if (object.properties.some((property) => property.name === 'Suppressed' && property.value === true)) return { status: 'suppressed' }
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 }
}
const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value
const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => {
const value = propertyValue(object, name)
return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined
}
const pointsEqual = (left: [number, number, number], right: [number, number, number], tolerance = 1e-7) => left.every((value, index) => Math.abs(value - right[index]) <= tolerance)
const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: PlanarProfile; code?: string; message?: string } => {
if (!sketch) return { code: 'PROFILE_MISSING', message: 'Feature profile does not reference a Sketcher object.' }
const geometry = sketch.geometry.filter((candidate) => !candidate.construction)
if (geometry.some((candidate) => candidate.type !== 'line')) return { code: 'PROFILE_UNSUPPORTED', message: 'OCCT feature recompute currently requires a closed line-loop sketch profile.' }
const segments = geometry.filter((candidate): candidate is Extract<typeof candidate, { type: 'line' }> => candidate.type === 'line')
if (segments.length < 3) return { code: 'PROFILE_OPEN', message: 'Feature profile requires at least three connected line segments.' }
const first = segments[0]
const ring: [number, number, number][] = [[first.start.x, first.start.y, 0]]
let current: [number, number, number] = [first.end.x, first.end.y, 0]
const remaining = segments.slice(1)
while (remaining.length > 0 && !pointsEqual(current, ring[0])) {
const index = remaining.findIndex((segment) => pointsEqual([segment.start.x, segment.start.y, 0], current) || pointsEqual([segment.end.x, segment.end.y, 0], current))
if (index < 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form a closed loop.' }
const segment = remaining.splice(index, 1)[0]
if (pointsEqual([segment.start.x, segment.start.y, 0], current)) current = [segment.end.x, segment.end.y, 0]
else current = [segment.start.x, segment.start.y, 0]
ring.push(current)
}
if (!pointsEqual(current, ring[0]) || remaining.length > 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form one closed loop.' }
ring.pop()
return { profile: { outer: ring } }
}
const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] })
/**
* Adds real OCCT feature execution without putting transient ShapeHandles in the
* persisted document snapshot. The map is deliberately owned by the Facade and
* keeps the last successful shape when a later feature fails.
*/
export const createFacadeGeometryRecomputeExecutor = (
geometry: RecomputeGeometryRuntime,
shapes: Map<string, ShapeHandle> = new Map(),
): RecomputeNodeExecutor => async (object, document, context) => {
const base = await executeFacadeRecomputeNode(object, document, context)
if (base.status === 'suppressed') {
const previous = shapes.get(object.id)
shapes.delete(object.id)
if (previous) await geometry.release(previous)
return base
}
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution'
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined }
if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.')
if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
const numberProperty = (name: string, fallback: number) => {
const value = propertyValue(object, name)
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
try {
let result: ShapeHandle
if (object.typeId === 'Part::Box') {
result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) })
} else if (object.typeId === 'Part::Cylinder') {
result = await geometry.createCylinder({ ...documentContext, radius: numberProperty('Radius', 5), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
} else if (object.typeId === 'Part::Sphere') {
result = await geometry.createSphere({ ...documentContext, radius: numberProperty('Radius', 5) })
} else if (object.typeId === 'Part::Cone') {
result = await geometry.createCone({ ...documentContext, radius1: numberProperty('Radius1', 5), radius2: numberProperty('Radius2', 0), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) })
} else if (object.typeId === 'Part::Fuse' || object.typeId === 'Part::Cut' || object.typeId === 'Part::Common') {
const baseObject = linkedObject(object, 'Base', document)
const toolObject = linkedObject(object, 'Tool', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
const toolShape = toolObject ? shapes.get(toolObject.id) : undefined
if (!baseShape || !toolShape) return geometryFailure(object.id, 'BOOLEAN_SHAPE_MISSING', 'Boolean operation requires recomputed Base and Tool shapes.')
if (object.typeId === 'Part::Fuse') result = await geometry.union({ ...documentContext, shapes: [baseShape, toolShape] })
else if (object.typeId === 'Part::Cut') result = await geometry.cut({ ...documentContext, base: baseShape, tools: [toolShape] })
else result = await geometry.intersection({ ...documentContext, shapes: [baseShape, toolShape] })
} else if (object.typeId === 'PartDesign::Pad') {
result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true })
} else if (object.typeId === 'PartDesign::Pocket') {
const pocketType = propertyValue(object, 'Type')
if (pocketType === 'Up to face') return geometryFailure(object.id, 'UP_TO_FACE_UNSUPPORTED', 'Pocket Up to face requires a persistent support face and is not implemented yet.')
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.')
result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' })
} else if (object.typeId === 'PartDesign::Revolution') {
const angle = numberProperty('Angle', 360)
const reversed = propertyValue(object, 'Reversed') === true
result = await geometry.revolution({
...documentContext,
profile: profile.profile as PlanarProfile,
angle,
axisOrigin: [0, 0, 0],
axisDirection: reversed ? [0, -1, 0] : [0, 1, 0],
})
} else if (object.typeId === 'PartDesign::Fillet') {
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.')
result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) })
} else {
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Chamfer base has no valid recomputed Shape.')
result = await geometry.chamfer({ ...documentContext, base: baseShape, distance: numberProperty('Distance', 1) })
}
if (context.signal.aborted) {
await geometry.release(result)
throw new DOMException('Recompute cancelled.', 'AbortError')
}
const previous = shapes.get(object.id)
shapes.set(object.id, result)
if (previous && previous.id !== result.id) await geometry.release(previous)
return base
} catch (error) {
if (context.signal.aborted || isAbortError(error)) throw error
return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error))
}
}

View File

@@ -6,15 +6,18 @@ export type SketchGeometry =
| { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean }
| { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean } | { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean }
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' } export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
export type SketchConstraint = export type SketchConstraint =
| { id: string; type: 'coincident'; first: SketchPointRef; second: SketchPointRef; driving?: boolean } | { id: string; type: 'coincident'; first: SketchPointRef; second: SketchPointRef; driving?: boolean }
| { id: string; type: 'horizontal' | 'vertical'; geometryId: string; driving?: boolean } | { id: string; type: 'horizontal' | 'vertical'; geometryId: string; driving?: boolean }
| { id: string; type: 'distance' | 'distanceX' | 'distanceY'; first: SketchPointRef; second: SketchPointRef; value: number; driving?: boolean } | { id: string; type: 'distance' | 'distanceX' | 'distanceY'; first: SketchPointRef; second: SketchPointRef; value: number; driving?: boolean }
| { id: string; type: 'radius'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'radius'; geometryId: string; value: number; driving?: boolean }
| { id: string; type: 'diameter'; geometryId: string; value: number; driving?: boolean }
| { id: string; type: 'angle'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'angle'; geometryId: string; value: number; driving?: boolean }
| { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean } | { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
| { id: string; type: 'symmetric'; first: SketchPointRef; second: SketchPointRef; center: SketchPointRef; driving?: boolean }
| { id: string; type: 'tangent'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
| { id: string; type: 'block'; geometryId: string; driving?: boolean } | { id: string; type: 'block'; geometryId: string; driving?: boolean }
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid' export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
@@ -80,7 +83,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri
} }
const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => { const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => {
if (geometry.type === 'point') return geometry.position if (geometry.type === 'point' && point === 'position') return geometry.position
if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end
if ((geometry.type === 'circle' || geometry.type === 'arc') && point === 'center') return geometry.center if ((geometry.type === 'circle' || geometry.type === 'arc') && point === 'center') return geometry.center
diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` }) diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` })
@@ -91,6 +94,15 @@ const pointKey = (ref: SketchPointRef) => `${ref.geometryId}.${ref.point}`
const distance = (left: SketchPoint, right: SketchPoint) => Math.hypot(left.x - right.x, left.y - right.y) const distance = (left: SketchPoint, right: SketchPoint) => Math.hypot(left.x - right.x, left.y - right.y)
const lineLength = (geometry: SketchGeometry) => geometry.type === 'line' ? distance(geometry.start, geometry.end) : geometry.type === 'circle' || geometry.type === 'arc' ? geometry.radius : 0 const lineLength = (geometry: SketchGeometry) => geometry.type === 'line' ? distance(geometry.start, geometry.end) : geometry.type === 'circle' || geometry.type === 'arc' ? geometry.radius : 0
const lineCircleTangentResidual = (line: SketchGeometry, circle: SketchGeometry) => {
if (line.type !== 'line' || (circle.type !== 'circle' && circle.type !== 'arc')) return Infinity
const dx = line.end.x - line.start.x
const dy = line.end.y - line.start.y
const length = Math.hypot(dx, dy)
if (length === 0) return Infinity
return Math.abs(((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length) - circle.radius
}
const isBlocked = (geometryId: string, blocked: Set<string>) => blocked.has(geometryId) const isBlocked = (geometryId: string, blocked: Set<string>) => blocked.has(geometryId)
const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => { const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => {
@@ -114,6 +126,10 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius - constraint.value) : Infinity return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius - constraint.value) : Infinity
} }
if (constraint.type === 'diameter') {
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius * 2 - constraint.value) : Infinity
}
if (constraint.type === 'angle') { if (constraint.type === 'angle') {
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
if (!candidate || candidate.type !== 'line') return Infinity if (!candidate || candidate.type !== 'line') return Infinity
@@ -124,6 +140,25 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d
const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics) const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics)
return first && second ? Math.abs(lineLength(first) - lineLength(second)) : Infinity return first && second ? Math.abs(lineLength(first) - lineLength(second)) : Infinity
} }
if (constraint.type === 'symmetric') {
const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics)
const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics)
const centerGeometry = findGeometry(geometry, constraint.center.geometryId, constraint.id, diagnostics)
if (!firstGeometry || !secondGeometry || !centerGeometry) return Infinity
const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics)
const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics)
const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics)
return first && second && center ? Math.hypot((first.x + second.x) / 2 - center.x, (first.y + second.y) / 2 - center.y) : Infinity
}
if (constraint.type === 'tangent') {
const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics)
const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics)
if (!first || !second) return Infinity
if (first.type === 'line' && (second.type === 'circle' || second.type === 'arc')) return Math.abs(lineCircleTangentResidual(first, second))
if (second.type === 'line' && (first.type === 'circle' || first.type === 'arc')) return Math.abs(lineCircleTangentResidual(second, first))
if ((first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc')) return Math.abs(distance(first.center, second.center) - first.radius - second.radius)
return Infinity
}
if (constraint.type === 'block') return 0 if (constraint.type === 'block') return 0
if (constraint.type !== 'coincident' && constraint.type !== 'distance' && constraint.type !== 'distanceX' && constraint.type !== 'distanceY') return Infinity if (constraint.type !== 'coincident' && constraint.type !== 'distance' && constraint.type !== 'distanceX' && constraint.type !== 'distanceY') return Infinity
const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics) const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics)
@@ -162,6 +197,10 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
const candidate = geometryById.get(constraint.geometryId) const candidate = geometryById.get(constraint.geometryId)
if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue }
if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value
} else if (constraint.type === 'diameter') {
const candidate = geometryById.get(constraint.geometryId)
if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue }
if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value / 2
} else if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') { } else if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') {
const firstGeometry = geometryById.get(constraint.first.geometryId) const firstGeometry = geometryById.get(constraint.first.geometryId)
const secondGeometry = geometryById.get(constraint.second.geometryId) const secondGeometry = geometryById.get(constraint.second.geometryId)
@@ -188,6 +227,38 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
else adjustPoint(secondGeometry, constraint.second.point, { x: second.x, y: first.y + (dy < 0 ? -constraint.value : constraint.value) }, blocked) else adjustPoint(secondGeometry, constraint.second.point, { x: second.x, y: first.y + (dy < 0 ? -constraint.value : constraint.value) }, blocked)
} }
} }
} else if (constraint.type === 'symmetric') {
const firstGeometry = geometryById.get(constraint.first.geometryId)
const secondGeometry = geometryById.get(constraint.second.geometryId)
const centerGeometry = geometryById.get(constraint.center.geometryId)
if (!firstGeometry || !secondGeometry || !centerGeometry) { findGeometry(snapshot.geometry, !firstGeometry ? constraint.first.geometryId : !secondGeometry ? constraint.second.geometryId : constraint.center.geometryId, constraint.id, diagnostics); continue }
const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics)
const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics)
const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics)
if (!first || !second || !center) continue
const reflected = { x: 2 * center.x - first.x, y: 2 * center.y - first.y }
if (!isBlocked(secondGeometry.id, blocked)) adjustPoint(secondGeometry, constraint.second.point, reflected, blocked)
} else if (constraint.type === 'tangent') {
const first = geometryById.get(constraint.firstGeometryId)
const second = geometryById.get(constraint.secondGeometryId)
if (!first || !second) { findGeometry(snapshot.geometry, !first ? constraint.firstGeometryId : constraint.secondGeometryId, constraint.id, diagnostics); continue }
const line = first.type === 'line' ? first : second.type === 'line' ? second : null
const circle = first.type === 'line' ? second : second.type === 'line' ? first : null
if (line && circle && (circle.type === 'circle' || circle.type === 'arc') && !isBlocked(circle.id, blocked)) {
const dx = line.end.x - line.start.x
const dy = line.end.y - line.start.y
const length = Math.hypot(dx, dy) || 1
const signed = ((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length
const delta = circle.radius - Math.abs(signed)
const sign = signed < 0 ? -1 : 1
circle.center = { x: circle.center.x + dy / length * delta * sign, y: circle.center.y - dx / length * delta * sign }
} else if (first && second && (first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc') && !isBlocked(second.id, blocked)) {
const dx = second.center.x - first.center.x
const dy = second.center.y - first.center.y
const length = Math.hypot(dx, dy) || 1
const target = first.radius + second.radius
second.center = { x: first.center.x + dx / length * target, y: first.center.y + dy / length * target }
}
} else if (constraint.type === 'equal') { } else if (constraint.type === 'equal') {
const first = geometryById.get(constraint.firstGeometryId) const first = geometryById.get(constraint.firstGeometryId)
const second = geometryById.get(constraint.secondGeometryId) const second = geometryById.get(constraint.secondGeometryId)

View File

@@ -7,7 +7,7 @@ export type FaceMeshInput = {
} }
export type SubshapeSignature = { export type SubshapeSignature = {
kind: 'face' kind: 'face' | 'edge' | 'vertex'
canonical: string canonical: string
hash: string hash: string
centroid: [number, number, number] centroid: [number, number, number]
@@ -72,6 +72,59 @@ export const signatureForFace = (face: FaceMeshInput, tolerance = 1e-5): Subshap
return { kind: 'face', canonical, hash: hashString(canonical), centroid, bounds: { min, max }, area, normal } return { kind: 'face', canonical, hash: hashString(canonical), centroid, bounds: { min, max }, area, normal }
} }
const pointCanonical = (point: [number, number, number], tolerance: number) => point.map((value) => quantize(value, tolerance)).join(',')
export const signatureForEdge = (start: [number, number, number], end: [number, number, number], tolerance = 1e-5): SubshapeSignature => {
if (![...start, ...end].every(Number.isFinite)) throw new RangeError('An edge signature requires finite endpoint coordinates.')
const ordered = pointCanonical(start, tolerance) <= pointCanonical(end, tolerance) ? [start, end] : [end, start]
const midpoint: [number, number, number] = [(ordered[0][0] + ordered[1][0]) / 2, (ordered[0][1] + ordered[1][1]) / 2, (ordered[0][2] + ordered[1][2]) / 2]
const length = Math.hypot(ordered[1][0] - ordered[0][0], ordered[1][1] - ordered[0][1], ordered[1][2] - ordered[0][2])
const min: [number, number, number] = [Math.min(ordered[0][0], ordered[1][0]), Math.min(ordered[0][1], ordered[1][1]), Math.min(ordered[0][2], ordered[1][2])]
const max: [number, number, number] = [Math.max(ordered[0][0], ordered[1][0]), Math.max(ordered[0][1], ordered[1][1]), Math.max(ordered[0][2], ordered[1][2])]
const canonical = ['edge', `ends=${pointCanonical(ordered[0], tolerance)}:${pointCanonical(ordered[1], tolerance)}`, `length=${quantize(length, tolerance)}`].join('|')
return { kind: 'edge', canonical, hash: hashString(canonical), centroid: midpoint, bounds: { min, max }, area: 0, normal: [0, 0, 0] }
}
export const signatureForVertex = (point: [number, number, number], tolerance = 1e-5): SubshapeSignature => {
if (!point.every(Number.isFinite)) throw new RangeError('A vertex signature requires finite coordinates.')
const canonical = `vertex|point=${pointCanonical(point, tolerance)}`
return { kind: 'vertex', canonical, hash: hashString(canonical), centroid: [...point] as [number, number, number], bounds: { min: [...point] as [number, number, number], max: [...point] as [number, number, number] }, area: 0, normal: [0, 0, 0] }
}
const refsForSignatures = (shapeId: string, topologyVersion: number, signatures: SubshapeSignature[]): SubshapeRef[] => {
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(`${signature.kind}:${signature.hash}`, (occurrences.get(`${signature.kind}:${signature.hash}`) ?? 0) + 1))
return signatures.map((signature) => {
const key = `${signature.kind}:${signature.hash}`
const duplicate = (occurrences.get(key) ?? 0) > 1
const persistentId = `topo-${signature.kind}-${signature.hash}`
return { shapeId, kind: signature.kind, persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.kind === signature.kind && candidate.hash === signature.hash).map((candidate) => `topo-${candidate.kind}-${candidate.hash}`) : undefined }
})
}
export const createEdgeSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
const signatures = new Map<string, SubshapeSignature>()
for (const face of faces) for (let index = 0; index + 2 < face.triIndexes.length; index += 3) {
const points = [facePoint(face.vertexCoord, face.triIndexes[index]), facePoint(face.vertexCoord, face.triIndexes[index + 1]), facePoint(face.vertexCoord, face.triIndexes[index + 2])]
for (let edgeIndex = 0; edgeIndex < 3; edgeIndex += 1) {
const signature = signatureForEdge(points[edgeIndex], points[(edgeIndex + 1) % 3], tolerance)
signatures.set(signature.hash, signature)
}
}
const values = [...signatures.values()]
return { refs: refsForSignatures(shapeId, topologyVersion, values), signatures: values }
}
export const createVertexSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
const signatures = new Map<string, SubshapeSignature>()
for (const face of faces) for (let index = 0; index < face.vertexCoord.length; index += 3) {
const signature = signatureForVertex(facePoint(face.vertexCoord, index / 3), tolerance)
signatures.set(signature.hash, signature)
}
const values = [...signatures.values()]
return { refs: refsForSignatures(shapeId, topologyVersion, values), signatures: values }
}
export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => { export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
const signatures = faces.map((face) => signatureForFace(face, tolerance)) const signatures = faces.map((face) => signatureForFace(face, tolerance))
const occurrences = new Map<string, number>() const occurrences = new Map<string, number>()
@@ -107,5 +160,6 @@ export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: Su
matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' }) matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' })
} else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' }) } else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' })
} }
for (const entry of previous) if (!used.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' })
return matches return matches
} }

View File

@@ -2,6 +2,8 @@ import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph' import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
import type { Quantity, QuantityDimension } from './units' import type { Quantity, QuantityDimension } from './units'
import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher' import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
export type ModelTreeItem = { export type ModelTreeItem = {
id: string id: string
@@ -19,7 +21,7 @@ export type ObjectPropertySnapshot = {
label: string label: string
group: string group: string
scope: 'data' | 'view' scope: 'data' | 'view'
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat'
value: PropertyValue value: PropertyValue
unit?: string unit?: string
readOnly?: boolean readOnly?: boolean
@@ -73,9 +75,17 @@ export type PersistenceCapabilities = {
sqliteWasm: boolean sqliteWasm: boolean
opfs: boolean opfs: boolean
schemaVersion: number schemaVersion: number
crossTabWriteLock?: 'web-locks' | 'local-queue'
reason?: string reason?: string
} }
export type ProjectChangeNotice = {
documentId: string
documentVersion: number
senderId: string
kind: 'document-saved'
}
export type ProjectSaveResult = { export type ProjectSaveResult = {
documentId: string documentId: string
documentVersion: number documentVersion: number
@@ -83,6 +93,26 @@ export type ProjectSaveResult = {
mode: PersistenceCapabilities['mode'] mode: PersistenceCapabilities['mode']
} }
export type ProjectSummary = {
documentId: string
label: string
documentVersion: number
objectCount: number
dirty: boolean
readOnly: boolean
updatedAt: number
}
export type ProjectRecoveryReport = {
documentId: string
mode: PersistenceCapabilities['mode']
schemaVersion: number
integrity: 'ok' | 'failed' | 'unavailable'
lastSavedVersion: number | null
dirtyAtLastSave: boolean | null
warnings: string[]
}
export type ProjectResource = { export type ProjectResource = {
hash: string hash: string
byteLength: number byteLength: number
@@ -117,6 +147,12 @@ export type SubshapeRef = {
candidates?: string[] candidates?: string[]
} }
export type SubshapeTopology = {
faces: SubshapeRef[]
edges: SubshapeRef[]
vertices: SubshapeRef[]
}
export type MeshAsset = { export type MeshAsset = {
shapeId: string shapeId: string
topologyVersion: number topologyVersion: number
@@ -231,6 +267,7 @@ export type PadInput = GeometryDocumentContext & LinearFeatureParameters
export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & { export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & {
base: ShapeHandle base: ShapeHandle
throughAll?: boolean
} }
export type RevolutionInput = GeometryDocumentContext & { export type RevolutionInput = GeometryDocumentContext & {
@@ -313,10 +350,13 @@ export interface BitBybitWebCadFacade {
getActive(): DocumentSnapshot getActive(): DocumentSnapshot
getObject(objectId: string): DocumentObjectSnapshot | null getObject(objectId: string): DocumentObjectSnapshot | null
create(label?: string): DocumentSnapshot create(label?: string): DocumentSnapshot
load(documentId: string): Promise<DocumentSnapshot | null>
markDirty(): void markDirty(): void
setProperty(input: SetPropertyInput): void setProperty(input: SetPropertyInput): void
setExpression(input: SetExpressionInput): void setExpression(input: SetExpressionInput): void
recompute(): RecomputeResult recompute(): RecomputeResult
recomputeAsync(options?: RecomputeExecutionOptions): Promise<RecomputeExecutionResult>
cancelRecompute(): void
getDependencies(): DependencyEdge[] getDependencies(): DependencyEdge[]
} }
expression: { expression: {
@@ -362,8 +402,14 @@ export interface BitBybitWebCadFacade {
} }
readonly project: { readonly project: {
capabilities(): PersistenceCapabilities capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
list(): Promise<ProjectSummary[]>
save(document?: DocumentSnapshot): Promise<ProjectSaveResult> save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null> load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
fcstd: {
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
}
resource: { resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource> put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null> get(hash: string): Promise<Uint8Array | null>
@@ -390,6 +436,8 @@ export interface BitBybitWebCadFacade {
revolution(input: RevolutionInput): Promise<ShapeHandle> revolution(input: RevolutionInput): Promise<ShapeHandle>
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset> mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]> subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]>
topology(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
getObjectShape(objectId: string): ShapeHandle | null
release(shape: ShapeHandle): Promise<void> release(shape: ShapeHandle): Promise<void>
dispose(): void dispose(): void
} }

View File

@@ -18,6 +18,12 @@ const definitions: UnitDefinition[] = [
{ id: 'm', symbol: 'm', dimension: 'length', factor: 1000 }, { id: 'm', symbol: 'm', dimension: 'length', factor: 1000 },
{ id: 'in', symbol: 'in', dimension: 'length', factor: 25.4 }, { id: 'in', symbol: 'in', dimension: 'length', factor: 25.4 },
{ id: 'ft', symbol: 'ft', dimension: 'length', factor: 304.8 }, { id: 'ft', symbol: 'ft', dimension: 'length', factor: 304.8 },
{ id: 'mm2', symbol: 'mm2', dimension: 'area', factor: 1 },
{ id: 'cm2', symbol: 'cm2', dimension: 'area', factor: 100 },
{ id: 'm2', symbol: 'm2', dimension: 'area', factor: 1_000_000 },
{ id: 'mm3', symbol: 'mm3', dimension: 'volume', factor: 1 },
{ id: 'cm3', symbol: 'cm3', dimension: 'volume', factor: 1_000 },
{ id: 'm3', symbol: 'm3', dimension: 'volume', factor: 1_000_000_000 },
{ id: 'deg', symbol: 'deg', dimension: 'angle', factor: 1 }, { id: 'deg', symbol: 'deg', dimension: 'angle', factor: 1 },
{ id: 'rad', symbol: 'rad', dimension: 'angle', factor: 180 / Math.PI }, { id: 'rad', symbol: 'rad', dimension: 'angle', factor: 180 / Math.PI },
{ id: '%', symbol: '%', dimension: 'percent', factor: 1 }, { id: '%', symbol: '%', dimension: 'percent', factor: 1 },
@@ -63,7 +69,7 @@ const tokenize = (source: string): Token[] => {
while (index < source.length) { while (index < source.length) {
const character = source[index] const character = source[index]
if (/\s/.test(character)) { index += 1; continue } if (/\s/.test(character)) { index += 1; continue }
if ('+-*/()'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } if ('+-*/^(),'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue }
if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue } if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue }
const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/) const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)
if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue } if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue }
@@ -95,6 +101,46 @@ const divide = (left: Quantity, right: Quantity): Quantity => {
throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`) throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`)
} }
const power = (base: Quantity, exponent: Quantity): Quantity => {
if (exponent.dimension !== 'dimensionless' || !Number.isInteger(exponent.value)) throw new TypeError('Exponent must be a dimensionless integer.')
if (base.dimension === 'dimensionless') return { value: Math.pow(base.value, exponent.value), dimension: 'dimensionless' }
if (exponent.value === 1) return { ...base }
if (exponent.value === 2 && base.dimension === 'length') return { value: base.value ** 2, dimension: 'area' }
if (exponent.value === 3 && base.dimension === 'length') return { value: base.value ** 3, dimension: 'volume' }
throw new TypeError(`Cannot raise ${base.dimension} to the power ${exponent.value}.`)
}
const evaluateFunction = (name: string, args: Quantity[]): Quantity => {
const normalized = name.toLowerCase()
if (normalized === 'abs' || normalized === 'round') {
if (args.length !== 1) throw new TypeError(`${name} requires one argument.`)
return { value: normalized === 'abs' ? Math.abs(args[0].value) : Math.round(args[0].value), dimension: args[0].dimension }
}
if (normalized === 'sin' || normalized === 'cos' || normalized === 'tan') {
if (args.length !== 1 || (args[0].dimension !== 'angle' && args[0].dimension !== 'dimensionless')) throw new TypeError(`${name} requires an angle or dimensionless argument.`)
const radians = args[0].dimension === 'angle' ? args[0].value * Math.PI / 180 : args[0].value
return { value: Math[normalized](radians), dimension: 'dimensionless' }
}
if (normalized === 'atan2') {
if (args.length !== 2 || !sameDimension(args[0], args[1])) throw new TypeError('atan2 requires two values with the same dimension.')
return { value: Math.atan2(args[0].value, args[1].value) * 180 / Math.PI, dimension: 'angle' }
}
if (normalized === 'min' || normalized === 'max') {
if (args.length < 1 || args.some((argument) => !sameDimension(argument, args[0]))) throw new TypeError(`${name} requires at least one set of same-dimension values.`)
const values = args.map((argument) => argument.value)
return { value: normalized === 'min' ? Math.min(...values) : Math.max(...values), dimension: args[0].dimension }
}
if (normalized === 'clamp') {
if (args.length !== 3 || !sameDimension(args[0], args[1]) || !sameDimension(args[0], args[2])) throw new TypeError('clamp requires three values with the same dimension.')
return { value: Math.min(Math.max(args[0].value, args[1].value), args[2].value), dimension: args[0].dimension }
}
if (normalized === 'pow') {
if (args.length !== 2 || args[0].dimension !== 'dimensionless' || args[1].dimension !== 'dimensionless') throw new TypeError('pow requires two dimensionless values.')
return { value: Math.pow(args[0].value, args[1].value), dimension: 'dimensionless' }
}
throw new ReferenceError(`Unknown expression function: ${name}.`)
}
class QuantityParser { class QuantityParser {
private index = 0 private index = 0
readonly references = new Set<string>() readonly references = new Set<string>()
@@ -139,7 +185,13 @@ class QuantityParser {
private parseUnary(): Quantity { private parseUnary(): Quantity {
if (this.match('+')) return this.parseUnary() if (this.match('+')) return this.parseUnary()
if (this.match('-')) { const value = this.parseUnary(); return { ...value, value: -value.value } } if (this.match('-')) { const value = this.parseUnary(); return { ...value, value: -value.value } }
return this.parsePrimary() return this.parsePower()
}
private parsePower(): Quantity {
const base = this.parsePrimary()
if (!this.match('^')) return base
return power(base, this.parseUnary())
} }
private parsePrimary(): Quantity { private parsePrimary(): Quantity {
@@ -157,6 +209,14 @@ class QuantityParser {
} }
if (token.kind === 'identifier') { if (token.kind === 'identifier') {
if (token.text.toLowerCase() === 'pi') return quantityFromNumber(Math.PI) if (token.text.toLowerCase() === 'pi') return quantityFromNumber(Math.PI)
if (this.match('(')) {
const args: Quantity[] = []
if (!this.match(')')) {
do args.push(this.parseAddSub()); while (this.match(','))
if (!this.match(')')) throw new SyntaxError(`Missing ')' at position ${this.peek().position}.`)
}
return evaluateFunction(token.text, args)
}
const value = this.variables.get(token.text) const value = this.variables.get(token.text)
if (!value) throw new ReferenceError(`Unknown expression reference: ${token.text}.`) if (!value) throw new ReferenceError(`Unknown expression reference: ${token.text}.`)
this.references.add(token.text) this.references.add(token.text)

View File

@@ -166,9 +166,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.toolbar-select select { appearance: none; border: 1px solid var(--line); background: var(--bg-raised); color: var(--text-soft); border-radius: 4px; height: 36px; padding: 0 28px 0 9px; } .toolbar-select select { appearance: none; border: 1px solid var(--line); background: var(--bg-raised); color: var(--text-soft); border-radius: 4px; height: 36px; padding: 0 28px 0 9px; }
.toolbar-select svg { margin-left: -24px; pointer-events: none; } .toolbar-select svg { margin-left: -24px; pointer-events: none; }
.project-list { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--bg-panel); } .project-list { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--bg-panel); }
.project-list-row { min-height: 72px; display: grid; grid-template-columns: 58px minmax(180px, 1fr) 100px 105px 95px 32px; gap: 14px; align-items: center; padding: 7px 14px 7px 8px; border-bottom: 1px solid var(--line-soft); } .project-list-row { width: 100%; min-height: 72px; display: grid; grid-template-columns: 58px minmax(180px, 1fr) 100px 105px 95px 32px; gap: 14px; align-items: center; padding: 7px 14px 7px 8px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; color: inherit; text-align: left; cursor: pointer; }
.project-list-row:last-child { border-bottom: 0; } .project-list-row:last-child { border-bottom: 0; }
.project-list-row:hover { background: var(--bg-hover); } .project-list-row:hover { background: var(--bg-hover); }
.project-list-empty { min-height: 112px; display: grid; place-items: center; color: var(--text-muted); font-size: 12px; }
.list-thumbnail { width: 58px; height: 56px; border-radius: 3px; } .list-thumbnail { width: 58px; height: 56px; border-radius: 3px; }
.list-thumbnail .preview-shape { width: 42px; height: 28px; } .list-thumbnail .preview-shape { width: 42px; height: 28px; }
.list-main strong, .list-main span { display: block; } .list-main strong, .list-main span { display: block; }

View File

@@ -1,14 +1,43 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { strToU8, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade' import { createMockFacade } from '../src/facade/mockFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
import { DependencyGraph } from '../src/facade/dependencyGraph' import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming' import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createSketch, solveSketch } from '../src/facade/sketcher' import { createSketch, solveSketch } from '../src/facade/sketcher'
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { inspectFcstdArchive } from '../src/facade/fcstd'
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' 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', () => { test('facade exposes a stable initial document projection', () => {
const facade = createMockFacade() const facade = createMockFacade()
@@ -96,6 +125,17 @@ test('quantity expressions convert units and reject incompatible dimensions', ()
assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5) assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5)
assert.equal(quantityFromNumber(7).dimension, 'dimensionless') assert.equal(quantityFromNumber(7).dimension, 'dimensionless')
assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent') assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent')
assert.ok(Math.abs(evaluateQuantityExpression('sin(90 deg)').value.value - 1) < 1e-12)
assert.equal(evaluateQuantityExpression('max(2 mm, 1 cm)').value.value, 10)
assert.equal(evaluateQuantityExpression('clamp(15, 0, 10)').value.value, 10)
assert.equal(evaluateQuantityExpression('round(2.6)').value.value, 3)
assert.equal(evaluateQuantityExpression('2 mm ^ 2').value.dimension, 'area')
assert.equal(evaluateQuantityExpression('2 mm ^ 3').value.dimension, 'volume')
assert.equal(evaluateQuantityExpression('1 cm2 + 100 mm2').value.value, 200)
assert.equal(evaluateQuantityExpression('2 ^ 3').value.value, 8)
assert.throws(() => evaluateQuantityExpression('sin(2 mm)'), /angle or dimensionless/)
assert.throws(() => evaluateQuantityExpression('unknown(1)'), /Unknown expression function/)
assert.throws(() => evaluateQuantityExpression('2 mm ^ 0.5'), /dimensionless integer/)
}) })
test('topology signatures are independent of transient face indexes and flag ambiguity', () => { test('topology signatures are independent of transient face indexes and flag ambiguity', () => {
@@ -111,6 +151,21 @@ test('topology signatures are independent of transient face indexes and flag amb
const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }]) const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
assert.equal(matches[0].status, 'stable') assert.equal(matches[0].status, 'stable')
assert.equal(matches[0].previousId, first.refs[0].persistentId) assert.equal(matches[0].previousId, first.refs[0].persistentId)
const deleted = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }, { ref: duplicate.refs[1], signature: duplicate.signatures[1] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
assert.equal(deleted.at(-1)?.status, 'deleted')
})
test('edge and vertex topology signatures are orientation/index independent', () => {
const edge = signatureForEdge([0, 0, 0], [2, 0, 0])
assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash)
assert.equal(signatureForVertex([1, 2, 3]).hash, signatureForVertex([1.000001, 2, 3], 1e-5).hash)
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const edges = createEdgeSubshapeRefs('shape-topology', 3, [face])
const vertices = createVertexSubshapeRefs('shape-topology', 3, [face])
assert.equal(edges.refs.length, 5)
assert.equal(vertices.refs.length, 4)
assert.ok(edges.refs.every((ref) => ref.kind === 'edge' && ref.persistentId.startsWith('topo-edge-')))
assert.ok(vertices.refs.every((ref) => ref.kind === 'vertex' && ref.persistentId.startsWith('topo-vertex-')))
}) })
test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => { test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => {
@@ -132,6 +187,24 @@ test('basic sketch solver applies geometric constraints and reports remaining de
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED')) assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
}) })
test('Sketcher solver supports diameter, symmetric and tangent constraints at the model boundary', () => {
const sketch = createSketch('advanced-constraints', [
{ id: 'circle-1', type: 'circle', center: { x: 0, y: 1 }, radius: 1 },
{ id: 'line-1', type: 'line', start: { x: -5, y: 0 }, end: { x: 5, y: 0 } },
{ id: 'point-a', type: 'point', position: { x: -2, y: 2 } },
{ id: 'point-b', type: 'point', position: { x: 4, y: 0 } },
{ id: 'center', type: 'point', position: { x: 1, y: 1 } },
], [
{ id: 'diameter-1', type: 'diameter', geometryId: 'circle-1', value: 4 },
{ id: 'tangent-1', type: 'tangent', firstGeometryId: 'line-1', secondGeometryId: 'circle-1' },
{ id: 'symmetric-1', type: 'symmetric', first: { geometryId: 'point-a', point: 'position' }, second: { geometryId: 'point-b', point: 'position' }, center: { geometryId: 'center', point: 'position' } },
])
const result = solveSketch(sketch)
assert.ok(result.residual <= 1e-7)
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).radius, 2)
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).center.y, 2)
})
test('sketcher operations are facade transactions and survive the project fallback store', async () => { test('sketcher operations are facade transactions and survive the project fallback store', async () => {
const facade = createMockFacade() const facade = createMockFacade()
const before = facade.app.document.getActive().version const before = facade.app.document.getActive().version
@@ -156,6 +229,9 @@ test('Sketcher solve command is selection-aware and emits the standard command l
facade.gui.command.execute({ commandId: 'solve-sketch' }) facade.gui.command.execute({ commandId: 'solve-sketch' })
assert.deepEqual(events, ['command.started', 'command.completed']) assert.deepEqual(events, ['command.started', 'command.completed'])
assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved') assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved')
facade.selection.select('pad')
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
assert.match(facade.gui.command.getState('solve-sketch').reason || '', /Sketcher sketch/)
facade.selection.clear() facade.selection.clear()
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled') assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
}) })
@@ -169,9 +245,281 @@ test('dependency graph propagates dirty state and orders dependencies', () => {
const plan = graph.plan(['pad']) const plan = graph.plan(['pad'])
assert.deepEqual(plan.cycles, []) assert.deepEqual(plan.cycles, [])
assert.deepEqual(plan.order, ['pad', 'pocket', 'fillet', 'body']) assert.deepEqual(plan.order, ['pad', 'pocket', 'fillet', 'body'])
assert.deepEqual(plan.levels, [['pad'], ['pocket'], ['fillet'], ['body']])
assert.deepEqual(plan.affected, ['pad', 'pocket', 'fillet', 'body']) assert.deepEqual(plan.affected, ['pad', 'pocket', 'fillet', 'body'])
graph.addEdge({ sourceId: 'pad', targetId: 'body', relation: 'expression' }) graph.addEdge({ sourceId: 'pad', targetId: 'body', relation: 'expression' })
assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']]) assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']])
const branched = new DependencyGraph([{ sourceId: 'left', targetId: 'root', relation: 'link' }, { sourceId: 'right', targetId: 'root', relation: 'link' }], ['root', 'left', 'right'])
assert.deepEqual(branched.plan(['root']).levels, [['root'], ['left', 'right']])
})
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 executes independent dependency levels concurrently and merges in stable order', async () => {
const document: DocumentSnapshot = {
...recomputeDocumentFixture(),
tree: [
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
{ id: 'left', label: 'Left', type: 'feature', state: 'dirty' },
{ id: 'right', label: 'Right', type: 'feature', state: 'dirty' },
],
objects: [
{ id: 'root', typeId: 'Part::Feature', properties: [] },
{ id: 'left', typeId: 'Part::Feature', properties: [] },
{ id: 'right', typeId: 'Part::Feature', properties: [] },
],
dependencies: [
{ sourceId: 'left', targetId: 'root', relation: 'link' },
{ sourceId: 'right', targetId: 'root', relation: 'link' },
],
recompute: {
generation: 0,
status: 'idle',
objectStates: { root: 'touched', left: 'touched', right: 'touched' },
dirtyObjects: ['root'],
order: [],
errors: [],
},
}
let active = 0
let maximumActive = 0
const coordinator = new RecomputeCoordinator(async (object) => {
active += 1
maximumActive = Math.max(maximumActive, active)
await new Promise((resolve) => setTimeout(resolve, 5))
active -= 1
return { status: 'success', updatedObject: object }
}, () => 1)
const result = await coordinator.run(document)
assert.equal(result.status, 'completed')
assert.equal(maximumActive, 2)
assert.deepEqual(result.order, ['root', 'left', 'right'])
assert.deepEqual(result.completed, ['root', 'left', 'right'])
assert.deepEqual(result.levels, [['root'], ['left', 'right']])
})
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('recompute suppression skips dependent nodes without reporting a failure', async () => {
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
document.objects[0].properties.push({ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true })
const executed: string[] = []
const coordinator = new RecomputeCoordinator(async (object, source, context) => {
executed.push(object.id)
return executeFacadeRecomputeNode(object, source, context)
}, () => 1)
const result = await coordinator.run(document)
assert.equal(result.status, 'completed')
assert.deepEqual(executed, ['root'])
assert.deepEqual(result.suppressed, ['root'])
assert.deepEqual(result.skipped, ['root', 'child'])
assert.equal(result.objectStates.root, 'suppressed')
assert.equal(result.objectStates.child, 'upstream-suppressed')
assert.deepEqual(result.dirtyObjects, [])
assert.deepEqual(result.errors, [])
})
test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains the last valid Shape', async () => {
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-feature', documentVersion: 2 })
const calls: string[] = []
let padShouldFail = false
const runtime: RecomputeGeometryRuntime = {
capabilities: () => ({ status: 'ready' }),
createBox: async () => shape('box-shape'),
createCylinder: async () => shape('cylinder-shape'),
createSphere: async () => shape('sphere-shape'),
createCone: async () => shape('cone-shape'),
union: async () => shape('union-shape'),
cut: async () => shape('cut-shape'),
intersection: async () => shape('intersection-shape'),
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}`); return shape('pocket-shape') },
revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); return shape('revolution-shape') },
fillet: async () => shape('fillet-shape'),
chamfer: async () => shape('chamfer-shape'),
release: async (released) => { calls.push(`release:${released.id}`) },
}
const sketch = { id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('sketch', [
{ id: 'a', type: 'line' as const, start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
{ id: 'b', type: 'line' as const, start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
{ id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
{ id: 'd', type: 'line' as const, start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
]) }
const pad = { id: 'pad', typeId: 'PartDesign::Pad', properties: [
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10, unit: 'mm' },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
] }
const pocket = { id: 'pocket', typeId: 'PartDesign::Pocket', properties: [
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'] },
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5, unit: 'mm' },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
] }
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), version: 2, objects: [sketch, pad, pocket], dependencies: [
{ sourceId: 'pad', targetId: 'sketch', relation: 'link' },
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
{ sourceId: 'pocket', targetId: 'sketch', relation: 'link' },
], recompute: { generation: 0, status: 'idle', objectStates: { sketch: 'touched', pad: 'touched', pocket: 'touched' }, dirtyObjects: ['sketch'], order: [], errors: [] } }
const shapes = new Map<string, ShapeHandle>()
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
const result = await new RecomputeCoordinator(executor, () => 2).run(document)
assert.equal(result.status, 'completed')
assert.deepEqual(calls.slice(0, 2), ['pad:3', 'pocket:pad-shape:true'])
assert.equal(shapes.get('pad')?.id, 'pad-shape')
assert.equal(shapes.get('pocket')?.id, 'pocket-shape')
padShouldFail = true
const failed = await executor(pad, document, context)
assert.equal(failed.status, 'failed')
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
assert.equal(shapes.get('pad')?.id, 'pad-shape')
const upToFace = { ...pocket, properties: pocket.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Up to face' } : property) }
const unsupported = await executor(upToFace, document, context)
assert.equal(unsupported.status, 'failed')
assert.equal(unsupported.errors?.[0].code, 'UP_TO_FACE_UNSUPPORTED')
const revolution = { id: 'revolution', typeId: 'PartDesign::Revolution', properties: [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 270, unit: 'deg' },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
] }
const revolutionResult = await executor(revolution, document, context)
assert.equal(revolutionResult.status, 'success')
assert.equal(shapes.get('revolution')?.id, 'revolution-shape')
assert.ok(calls.includes('revolution:270:-1'))
const suppressedPad = { ...pad, properties: [...pad.properties, { name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
const suppressedResult = await executor(suppressedPad, document, context)
assert.equal(suppressedResult.status, 'suppressed')
assert.equal(shapes.has('pad'), false)
assert.ok(calls.includes('release:pad-shape'))
})
test('Part primitive and boolean nodes execute through the same Shape cache', async () => {
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-part', documentVersion: 2 })
const calls: string[] = []
const runtime: RecomputeGeometryRuntime = {
capabilities: () => ({ status: 'ready' }),
createBox: async (input) => { calls.push(`box:${input.width}:${input.length}:${input.height}`); return shape('box-shape') },
createCylinder: async () => shape('cylinder-shape'),
createSphere: async () => shape('sphere-shape'),
createCone: async () => shape('cone-shape'),
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('fuse-shape') },
cut: async () => shape('cut-shape'),
intersection: async () => shape('common-shape'),
pad: async () => shape('pad-shape'),
pocket: async () => shape('pocket-shape'),
revolution: async () => shape('revolution-shape'),
fillet: async () => shape('fillet-shape'),
chamfer: async () => shape('chamfer-shape'),
release: async () => undefined,
}
const boxProperties = (width: number) => [
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: width },
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5 },
]
const document: DocumentSnapshot = {
id: 'doc-part', label: 'Part fixture', version: 2, dirty: true, readOnly: false, units: 'mm',
tree: [{ id: 'boxA', label: 'Box', type: 'feature', state: 'dirty' }, { id: 'boxB', label: 'Box001', type: 'feature', state: 'dirty' }, { id: 'fuse', label: 'Union', type: 'feature', state: 'dirty' }],
objects: [
{ id: 'boxA', typeId: 'Part::Box', properties: boxProperties(2) },
{ id: 'boxB', typeId: 'Part::Box', properties: boxProperties(3) },
{ id: 'fuse', typeId: 'Part::Fuse', properties: [
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxA' },
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxB' },
] },
],
dependencies: [{ sourceId: 'fuse', targetId: 'boxA', relation: 'link' }, { sourceId: 'fuse', targetId: 'boxB', relation: 'link' }],
recompute: { generation: 0, status: 'idle', objectStates: { boxA: 'touched', boxB: 'touched', fuse: 'touched' }, dirtyObjects: ['boxA', 'boxB'], order: [], errors: [] },
}
const shapes = new Map<string, ShapeHandle>()
const result = await new RecomputeCoordinator(createFacadeGeometryRecomputeExecutor(runtime, shapes), () => 2).run(document)
assert.equal(result.status, 'completed')
assert.equal(shapes.get('boxA')?.id, 'box-shape')
assert.equal(shapes.get('fuse')?.id, 'fuse-shape')
assert.ok(calls.includes('box:2:4:5'))
assert.ok(calls.includes('union:box-shape,box-shape'))
})
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('feature suppression persists and unsuppress recomputes only its dependent closure', async () => {
const facade = createMockFacade()
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
const suppressed = facade.app.document.recompute()
assert.equal(suppressed.status, 'completed')
assert.deepEqual(suppressed.order, ['pocket', 'fillet', 'body'])
assert.deepEqual(new Set(suppressed.affected), new Set(['pocket', 'fillet', 'body']))
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'suppressed')
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'upstream-suppressed')
await facade.project.save()
assert.equal((await facade.project.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'Suppressed')?.value, true)
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: false })
const restored = await facade.app.document.recomputeAsync()
assert.equal(restored.status, 'completed')
assert.deepEqual(restored.order, ['pocket', 'fillet', 'body'])
assert.deepEqual(new Set(restored.affected), new Set(['pocket', 'fillet', 'body']))
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'up-to-date')
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'up-to-date')
}) })
test('persistence writes are serialized and continue after a failed write', async () => { test('persistence writes are serialized and continue after a failed write', async () => {
@@ -196,6 +544,27 @@ test('persistence writes are serialized and continue after a failed write', asyn
assert.equal(results[2].status, 'fulfilled') assert.equal(results[2].status, 'fulfilled')
}) })
test('project persistence broadcasts saved document versions across clients', async () => {
const writer = createSqliteProjectPersistence()
const observer = createSqliteProjectPersistence()
const notice = new Promise<{ documentId: string; documentVersion: number }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Expected cross-tab save notice.')), 250)
const unsubscribe = observer.subscribeExternalChanges((change) => {
clearTimeout(timer)
unsubscribe()
resolve(change)
})
})
const document = recomputeDocumentFixture()
const saved = await writer.save(document)
assert.equal(writer.capabilities().crossTabWriteLock, 'local-queue')
const received = await notice
assert.equal(received.documentId, saved.documentId)
assert.equal(received.documentVersion, saved.documentVersion)
await writer.dispose()
await observer.dispose()
})
test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => { test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => {
const savedVersions: number[] = [] const savedVersions: number[] = []
const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5) const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5)
@@ -248,6 +617,28 @@ test('disabled commands return a reason instead of mutating the document', () =>
assert.equal(facade.getState().document.version, before) 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('implemented commands enforce their FreeCAD workbench preconditions', () => {
const facade = createMockFacade()
facade.gui.workbench.setActive('Sketcher')
assert.equal(facade.gui.command.getState('create-body').status, 'disabled')
assert.match(facade.gui.command.getState('create-body').reason || '', /Part Design/)
assert.equal(facade.gui.command.getState('new-sketch').status, 'enabled')
facade.gui.workbench.setActive('Part Design')
assert.equal(facade.gui.command.getState('new-sketch').status, 'disabled')
assert.equal(facade.gui.command.getState('pocket').status, 'enabled')
})
test('command events carry a document-scoped request context', () => { test('command events carry a document-scoped request context', () => {
const facade = createMockFacade() const facade = createMockFacade()
const events: string[] = [] const events: string[] = []
@@ -328,10 +719,84 @@ test('project persistence remains behind the facade contract', async () => {
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 }) facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 })
const saved = await facade.project.save() const saved = await facade.project.save()
assert.equal(saved.documentId, 'doc-pump-housing') assert.equal(saved.documentId, 'doc-pump-housing')
const projects = await facade.project.list()
assert.equal(projects.length, 1)
const { updatedAt, ...summary } = projects[0]
assert.ok(updatedAt > 0)
assert.deepEqual(summary, { documentId: 'doc-pump-housing', label: 'Pump Housing', documentVersion: saved.documentVersion, objectCount: 7, dirty: true, readOnly: false })
const loaded = await facade.project.load('doc-pump-housing') const loaded = await facade.project.load('doc-pump-housing')
assert.equal(loaded?.label, 'Pump Housing') assert.equal(loaded?.label, 'Pump Housing')
assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55) assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 61 })
const restored = await facade.app.document.load('doc-pump-housing')
assert.equal(restored?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
assert.equal(restored?.recompute?.status, 'completed')
assert.equal(facade.selection.getObjectId(), '')
assert.equal(facade.task.getActive(), null)
assert.equal(facade.project.capabilities().mode, 'sqlite-memory') assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
const recovery = await facade.project.recovery('doc-pump-housing')
assert.equal(recovery.integrity, 'ok')
assert.equal(recovery.lastSavedVersion, loaded?.version)
})
test('recovery report identifies missing snapshots in the transient fallback store', async () => {
const facade = createMockFacade()
const recovery = await facade.project.recovery('missing-document')
assert.equal(recovery.integrity, 'unavailable')
assert.equal(recovery.lastSavedVersion, null)
assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning)))
})
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'))
assert.equal(inspection.proxyDocument.readOnly, true)
assert.equal(inspection.proxyDocument.objects.length, 3)
assert.equal(inspection.proxyDocument.objects[1].properties.find((property) => property.name === 'ImportSupport')?.value, 'proxy')
})
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 () => { test('project resources use content identity and reference counting', async () => {
@@ -354,7 +819,7 @@ test('Part Design feature tasks commit a document object and remain undoable', (
const before = facade.app.document.getActive() const before = facade.app.document.getActive()
facade.gui.command.execute({ commandId: 'pad' }) facade.gui.command.execute({ commandId: 'pad' })
assert.equal(facade.task.getActive()?.commandId, 'pad') assert.equal(facade.task.getActive()?.commandId, 'pad')
facade.task.update({ length: 42, reversed: false }) facade.task.update({ length: 55, reversed: true, midplane: true })
facade.task.apply() facade.task.apply()
const committed = facade.app.document.getActive() const committed = facade.app.document.getActive()
@@ -363,9 +828,57 @@ test('Part Design feature tasks commit a document object and remain undoable', (
assert.equal(committed.version, before.version + 1) assert.equal(committed.version, before.version + 1)
assert.equal(facade.selection.getObjectId(), 'pad001') assert.equal(facade.selection.getObjectId(), 'pad001')
assert.equal(committed.dirty, true) assert.equal(committed.dirty, true)
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Length')?.value, 55)
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Reversed')?.value, true)
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Midplane')?.value, true)
assert.equal(committed.objects.find((object) => object.id === 'body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
assert.ok(committed.recompute?.dirtyObjects.includes('pad001'))
facade.history.undo() facade.history.undo()
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false) assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
facade.history.redo() facade.history.redo()
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true) assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
facade.gui.command.execute({ commandId: 'revolution' })
assert.equal(facade.task.getActive()?.commandId, 'revolution')
facade.task.apply()
const revolution = facade.app.document.getObject('revolution')
assert.equal(revolution?.typeId, 'PartDesign::Revolution')
assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 360)
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
})
test('Part workbench commands create primitive and boolean document objects', () => {
const facade = createMockFacade()
facade.gui.workbench.setActive('Part')
assert.equal(facade.gui.command.getState('primitive').status, 'enabled')
assert.equal(facade.gui.command.getState('check-shape').status, 'enabled')
facade.gui.command.execute({ commandId: 'check-shape' })
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'SHAPE_NOT_RECOMPUTED')
facade.gui.command.execute({ commandId: 'primitive' })
facade.task.apply()
assert.equal(facade.app.document.getObject('box')?.typeId, 'Part::Box')
facade.gui.command.execute({ commandId: 'primitive' })
facade.task.update({ primitiveType: 'Cylinder', radius: 3, height: 12, angle: 180 })
facade.task.apply()
assert.equal(facade.app.document.getObject('cylinder')?.typeId, 'Part::Cylinder')
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Radius')?.value, 3)
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Height')?.value, 12)
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Angle')?.value, 180)
assert.equal(facade.gui.command.getState('union').status, 'enabled')
facade.gui.command.execute({ commandId: 'union' })
facade.task.update({ base: 'box', tool: 'cylinder' })
facade.task.apply()
assert.equal(facade.app.document.getObject('union')?.typeId, 'Part::Fuse')
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Base')?.value, 'box')
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Tool')?.value, 'cylinder')
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'union').map((edge) => edge.targetId).sort(), ['box', 'cylinder'])
facade.selection.select('origin')
assert.equal(facade.gui.command.getState('union').status, 'disabled')
assert.match(facade.gui.command.getState('union').reason || '', /solid or shape-producing/)
assert.equal(facade.gui.command.getState('check-shape').status, 'disabled')
}) })