P3/P4: execute OCCT features and export shapes

This commit is contained in:
2026-08-02 18:35:04 -04:00
parent 709cfea1eb
commit 66c11f6890
9 changed files with 251 additions and 28 deletions

View File

@@ -324,7 +324,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| 任务 | 实施措施 | 验证结果 | 剩余边界 | | 任务 | 实施措施 | 验证结果 | 剩余边界 |
|---|---|---|---| |---|---|---|---|
| P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation后发运行取消前发节点完成后校验 AbortSignal、文档 ID/版本和当前 generation结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度 | 取消、过期版本、依赖失败传播、独立节点并行和 Facade 提交测试通过 | 每个 PartDesign 特征尚未绑定真实 OCCT 输入/输出 Shape同层并行目前是调度层并发不等同于每节点独立 Worker尚未实现最近有效 Shape 保留 | | P1-03 / DAG-06 重算执行器 | `RecomputeCoordinator` 为每次运行分配 generation后发运行取消前发节点完成后校验 AbortSignal、文档 ID/版本和当前 generation结果状态为 `completed/failed/cancelled/stale`;按 DAG `levels` 使用 `Promise.all` 并行执行同层节点,并按稳定计划顺序合并结果和进度;可选的 `createFacadeGeometryRecomputeExecutor` 在 OCCT Worker ready 时执行真实 Pad/Pocket/Fillet/Chamfer | 取消、过期版本、依赖失败传播、独立节点并行、闭合 Sketch 特征链和 Facade 提交测试通过 | 默认数据模型仍不持久化 ShapeHandleThrough all/Up to face、拓扑历史映射、每节点独立 Worker 和完整 PartDesign 特征仍待实现;失败时缓存保留最近有效 Shape |
| DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 | | DAG-08 失败传播 | 上游 `error` 将下游置为 `upstream-failed`,保存错误根因和 dirty 闭包;成功后只提交当前文档版本 | 上游失败和下游跳过测试通过 | 抑制对象、最近有效 Shape、完整 FreeCAD 状态机和 Report 树仍待实现 |
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver不是 `planegcs` WASM高级约束、拖拽、外部几何和 B-spline 未完成 | | 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-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
@@ -333,8 +333,8 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
后续按以下顺序连续开发: 后续按以下顺序连续开发:
1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加失败保留最近有效 Shape、抑制、恢复上游后的最小闭包重算和诊断树。 1. `P4-06/DAG-08`:同步/异步重算统一状态机,增加抑制、恢复上游后的最小闭包重算和诊断树;最近有效 Shape 缓存已进入 Facade但尚未持久化
2. `P3-04/P3-05`建立 Pad/Pocket/Revolution/Fillet/Chamfer 参数到 OCCT Worker 输入适配和 Shape 资源回写Pattern/Hole 继续保持 disabled,直到真实 executor 可复现 2. `P3-04/P3-05`Pad/Pocket/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写;继续补齐 Through all/Up to face、Revolution 参数化、拓扑历史和 Pattern/Hole,未完成前保持 disabled。
3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。 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。 4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。
5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。 5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。

View File

@@ -1409,7 +1409,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 | | SQLite WASM + OPFS 持久化 | `IN PROGRESS` | schema v4、OPFS/内存降级、单写者队列、内容寻址资源、自动保存已落地;迁移回滚、配额回收、跨标签写者和崩溃恢复仍待 P2 |
| 标准格式与 FCStd 兼容 | `IN PROGRESS` | BitBybit STEP/ASCII STL 导出已接入;`src/facade/fcstd.ts` 已完成安全 ZIP 预检和 Document.xml 元数据/代理报告FCStd 对象映射、BRep 读写和 round-trip 仍待 P7 | | 标准格式与 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` | `./npmw run verify` 已通过 35 个 Facade 测试、入口守卫和构建跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 | | 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | `./npmw run verify` 已通过 42 个 Facade 测试、入口守卫和构建跨浏览器、黄金几何、压力、fuzz 和发布流水线仍待 P8 |
### 16.3 阶段门和交付节奏 ### 16.3 阶段门和交付节奏
@@ -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 | 达到预算或形成降级决策 |
@@ -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 | 无静默丢失 |
@@ -1599,9 +1599,9 @@ P0 基线/治理
| 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 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名OCCT 历史命名仍未实现 | | 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 浏览器矩阵;重算 generation/cancel 协议已完成真实特征参数执行、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/PocketFillet/Chamfer 也有 Shape 缓存回写Through all/Up to face、Revolution 参数化和 FCStd Shape 持久化仍待完成 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口预览链会释放 Pad/Pocket 全部 Shape 句柄,对象缓存与文档增量同步尚未实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 41 个场景,包括 async generation 重算、DAG 分层并行、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 42 个场景,包括 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 构建验证。
@@ -1619,7 +1619,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 几何运行时验证记录
@@ -1681,7 +1681,7 @@ 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 元数据与编辑器验证记录

View File

@@ -289,7 +289,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>
@@ -382,7 +382,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
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(() => {
@@ -392,6 +392,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
adapterRef.current = adapter adapterRef.current = adapter
let cancelled = false let cancelled = false
const shapes: ShapeHandle[] = [] const shapes: ShapeHandle[] = []
let ownsShapes = false
try { try {
adapter.mount(host) adapter.mount(host)
} catch (error) { } catch (error) {
@@ -404,6 +405,14 @@ 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()
const featureIds = [selectedObject, 'fillet', 'pocket', 'pad'].filter(Boolean)
const storedShape = featureIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((shape): shape is ShapeHandle => Boolean(shape))
if (storedShape) {
const mesh = await facade.geometry.mesh(storedShape, 0.05)
if (!cancelled) adapter.setMesh(mesh)
return
}
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 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 }) const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
shapes.push(pad) shapes.push(pad)
@@ -422,11 +431,11 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
}) })
return () => { return () => {
cancelled = true cancelled = true
void Promise.all(shapes.map((entry) => facade.geometry.release(entry))) 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>
@@ -474,6 +483,8 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate:
function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { 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'
const [fcstdReport, setFcstdReport] = useState<FcstdInspection | null>(null) 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 fileInputRef = useRef<HTMLInputElement>(null)
const inspectFile = async (file: File) => { const inspectFile = async (file: File) => {
if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return } if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return }
@@ -483,7 +494,27 @@ function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import'
showNotice(`FCStd inspected: ${report.compatibility.level}`) showNotice(`FCStd inspected: ${report.compatibility.level}`)
} catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) } } catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) }
} }
return <div className="flow-page"><PageHeader eyebrow={isImport ? 'Import model' : 'Export project'} title={isImport ? 'Bring a model into your workspace.' : 'Export a clean deliverable.'} description={isImport ? 'Review format, units and compatibility before the document is created.' : 'Choose an exchange format and keep the native project available for future edits.'} onBack={() => onNavigate('start')} /><div className="flow-layout"><div className="flow-steps"><FlowStep index="01" title="Choose file" active={!isImport} complete={isImport} /><FlowStep index="02" title="Review mapping" active={isImport} complete={false} /><FlowStep index="03" title="Confirm" active={false} complete={false} /></div><section className="flow-card panel-surface"><div className="flow-card-header"><div><span className="section-kicker">Step 01 / 03</span><h2>{isImport ? 'Select a CAD file' : 'Select an export format'}</h2></div><Badge tone="cyan">Local only</Badge></div>{isImport ? <><div className="drop-zone"><div className="drop-icon"><Upload size={20} /></div><strong>Drop a model here</strong><span>or browse from your device</span><input ref={fileInputRef} type="file" accept=".FCStd,.fcstd" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} /><button className="button button-outline" onClick={() => fileInputRef.current?.click()}><FolderOpen size={15} />Browse files</button><small>STEP · IGES · STL · OBJ · PLY · FCStd</small></div><div className="flow-note"><AlertTriangle size={15} /><span>FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.</span></div>{fcstdReport && <div className="flow-note"><CheckCircle2 size={15} className="icon-green" /><span>{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}</span></div>}</> : <div className="format-grid">{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => <button key={format} className={`format-card ${index === 0 ? 'is-selected' : ''}`} onClick={() => showNotice(`${format} selected`)}><span className="format-icon">{index === 4 ? <FileBox size={18} /> : <FileText size={18} />}</span><strong>{format}</strong><small>{index === 0 ? 'Best for editable solids' : index === 2 ? 'Mesh export' : 'Exchange format'}</small>{index === 0 && <Check size={15} className="format-check" />}</button>)}</div>}<div className="flow-footer"><button className="button button-quiet" onClick={() => onNavigate('start')}>Cancel</button><button className="button button-primary" onClick={() => showNotice(isImport ? (fcstdReport ? 'Metadata review complete; import mapping remains disabled' : 'Choose a file to review') : 'Export queued')}><span>{isImport ? 'Continue' : 'Export'}</span><ArrowRight size={15} /></button></div></section></div></div> const exportModel = async () => {
if (selectedFormat !== 'STEP' && selectedFormat !== 'STL') { showNotice(`${selectedFormat} export is not implemented yet`); return }
const objectIds = ['fillet', 'chamfer', 'pocket', 'pad']
const shape = objectIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((candidate): candidate is ShapeHandle => Boolean(candidate))
if (!shape) { showNotice('Recompute a valid Part Design feature 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 }) {

View File

@@ -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)
} }
@@ -502,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

@@ -7,7 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } 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 { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine' export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine' export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd' export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } 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,7 +26,7 @@ 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 { executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd' import { inspectFcstdArchive } from './fcstd'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
@@ -38,7 +39,7 @@ 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('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : 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 },
@@ -74,6 +75,10 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ 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 },
@@ -216,12 +221,18 @@ 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( const recomputeCoordinator = new RecomputeCoordinator(
executeFacadeRecomputeNode, createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
(documentId) => state.document.id === documentId ? state.document.version : null, (documentId) => state.document.id === documentId ? state.document.version : null,
) )
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[] = []
@@ -440,7 +451,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
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 })
@@ -458,13 +469,13 @@ export function createMockFacade(): BitBybitWebCadFacade {
} }
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) => { recomputeCoordinator.cancel(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, 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 }, 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), 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(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), 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 }, project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), 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), topology: (shape, precision) => geometryRuntime.topology(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,6 +1,6 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher' import { cloneSketch, solveSketch } from './sketcher'
import type { DocumentObjectSnapshot, DocumentSnapshot } from './types' import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -29,6 +29,15 @@ export type RecomputeNodeExecutor = (
context: RecomputeNodeContext, context: RecomputeNodeContext,
) => Promise<RecomputeNodeResult> ) => Promise<RecomputeNodeResult>
export type RecomputeGeometryRuntime = {
capabilities(): { status: string }
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
release(shape: ShapeHandle): Promise<void>
}
export type RecomputeProgress = { export type RecomputeProgress = {
generation: number generation: number
documentVersion: number documentVersion: number
@@ -217,3 +226,97 @@ export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object,
} }
return { status: 'success', updatedObject } 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 === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket'
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 === '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::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

@@ -257,6 +257,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 & {
@@ -424,6 +425,7 @@ export interface BitBybitWebCadFacade {
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> 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

@@ -8,7 +8,7 @@ import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } 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 { RecomputeCoordinator } from '../src/facade/recomputeEngine' import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { inspectFcstdArchive } from '../src/facade/fcstd' 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 { DocumentSnapshot, ShapeHandle } from '../src/facade/types' import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types'
@@ -345,6 +345,59 @@ test('recompute failure marks dependent objects as upstream-failed', async () =>
assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED')) assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED'))
}) })
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' }),
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') },
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')
})
test('facade async recompute commits only an accepted generation', async () => { test('facade async recompute commits only an accepted generation', async () => {
const facade = createMockFacade() const facade = createMockFacade()
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 }) facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })