feat: apply object placement during recompute

This commit is contained in:
2026-08-03 00:36:16 -04:00
parent 6bfbdf5057
commit 501f43f1c0
7 changed files with 169 additions and 12 deletions

View File

@@ -17,7 +17,7 @@
"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": { "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", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] }, "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "signature-topology-history-fallback", "undo-redo"] }, "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "signature-topology-history-fallback", "undo-redo"] },
"persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v5", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] }, "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v5", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] },
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] }, "sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },

View File

@@ -1,6 +1,6 @@
# Web FreeCAD 接续执行状态 # Web FreeCAD 接续执行状态
更新时间2026-08-03P2 schema v5 编辑中暂停 更新时间2026-08-03对象 Placement 重算里程碑已提交,待远端认证后推送
## 1. 当前目标 ## 1. 当前目标
@@ -10,9 +10,9 @@
- 分支:`main` - 分支:`main`
- 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git`
- 最近已推送里程碑:`63446d5 docs: add continuation execution status` - 最近已推送里程碑:`6bfbdf5 feat: add project recovery and resource governance`
-完整门禁:`./npmw run verify` 通过 -近已推送里程碑的完整门禁:`./npmw run verify` 通过
- Facade 测试55/55 通过 - 当前 Placement 里程碑验证:`./npmw run verify` 通过Facade 61/61diff/JSON 校验通过
- 生产构建:通过 - 生产构建:通过
- 本地开发地址:`http://127.0.0.1:5173/` - 本地开发地址:`http://127.0.0.1:5173/`
- 兼容真值表:`config/compatibility-matrix.json` - 兼容真值表:`config/compatibility-matrix.json`
@@ -27,6 +27,7 @@
5. 椭圆/B-spline 领域模型;基础 TypeScript solver 对未支持曲线返回明确诊断。 5. 椭圆/B-spline 领域模型;基础 TypeScript solver 对未支持曲线返回明确诊断。
6. 外部草图几何、稳定 TopoRef、持久化与 `topo-ref` DAG 依赖边。 6. 外部草图几何、稳定 TopoRef、持久化与 `topo-ref` DAG 依赖边。
7. Vector、Placement、LinkList/StringList 结构化属性合同Placement 深拷贝、校验、撤销、持久化和 UI 编辑器。 7. Vector、Placement、LinkList/StringList 结构化属性合同Placement 深拷贝、校验、撤销、持久化和 UI 编辑器。
8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。
相关提交按新到旧: 相关提交按新到旧:
@@ -48,13 +49,13 @@
- Pattern 当前复制整个 Shape不是 FreeCAD 局部特征历史。 - Pattern 当前复制整个 Shape不是 FreeCAD 局部特征历史。
- Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。 - Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。
- 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。 - 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。
- Placement 已进入文档属性,但尚未完整映射到所有特征执行器的 FreeCAD 局部坐标语义。 - Placement 已进入文档属性,并接入 Facade 的对象级 Shape 重算;尚未完整映射到 Body/Tip、Support、AttachmentOffset 或所有特征执行器的 FreeCAD 局部坐标语义。
- FCStd 仍以安全预检、metadata/proxy 和只读报告为主,尚未双向原生往返。 - FCStd 仍以安全预检、metadata/proxy 和只读报告为主,尚未双向原生往返。
## 5. 下一步执行顺序 ## 5. 下一步执行顺序
1. 完成 P2 持久化门禁1000 次事务压力、迁移失败回滚、资源配额/垃圾回收、崩溃恢复点 1. 补齐 P2 浏览器实证门禁:真实 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件和崩溃注入合同层与 fallback 测试已由 `6bfbdf5` 完成
2. 将对象 Placement 可靠接入 Part 基本体与可兼容特征的 OCCT 重算,补包围盒/撤销/保存黄金测试 2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销保存和恢复的真实 Worker 路径Node/fallback 合同已通过
3. 继续 TSN把拓扑快照和历史结果接入实际重算 generation迁移外部几何与 LinkSub暴露 ambiguous/deleted 修复流程。 3. 继续 TSN把拓扑快照和历史结果接入实际重算 generation迁移外部几何与 LinkSub暴露 ambiguous/deleted 修复流程。
4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC此前继续补充回放夹具、约束分类和 Sketch 编辑事务。
5. 扩展 PartDesignMirrored、MultiTransform、Loft/Pipe/Groove、Thickness/Draft以及 Pattern/Hole 完整语义。 5. 扩展 PartDesignMirrored、MultiTransform、Loft/Pipe/Groove、Thickness/Draft以及 Pattern/Hole 完整语义。
@@ -80,8 +81,28 @@ git log -5 --oneline
## 7. 2026-08-03 P2 接续进展 ## 7. 2026-08-03 P2 接续进展
本轮已完成 schema v5 迁移运行器、最近 5 版文档检查点、`loadCheckpoint()`、配额预检、资源清扫计划/API 和 1000 次写队列压力实现。聚焦测试与生产构建已通过;提交前仍需运行完整 `./npmw run verify` 和 JSON/diff 校验 本轮已完成 schema v5 迁移运行器、最近 5 版文档检查点、`loadCheckpoint()`、配额预检、资源清扫计划/API 和 1000 次写队列压力实现。完整门禁已通过,并由提交 `6bfbdf5` 推送到云仓库
证据边界Node/fallback 证明迁移合同、队列、检查点与纯资源策略;真实浏览器 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件与崩溃注入仍是 P2/P8 后续任务,不能因本轮测试而标记完整 G4/G5。 证据边界Node/fallback 证明迁移合同、队列、检查点与纯资源策略;真实浏览器 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件与崩溃注入仍是 P2/P8 后续任务,不能因本轮测试而标记完整 G4/G5。
本里程碑提交后,下一执行点为“将对象 Placement 接入 Part 基本体 OCCT 重算并建立包围盒/保存黄金测试”,随后回到 TSN generation 历史写回。 ## 8. 2026-08-03 Placement 里程碑快照
Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文档:
- `src/facade/recomputeEngine.ts`Shape 生成成功后读取对象 `Placement`,通过 `geometry.applyPlacement()` 执行平移/轴角旋转;恒等 Placement 不创建额外 Shape成功后释放局部 Shape缓存只提交变换后的句柄。
- `tests/facade.test.ts`:新增非恒等 Placement 与恒等 Placement 的执行器测试。
- `docs/continuation-status.zh-CN.md``config/compatibility-matrix.json` 及两份实施方案:记录本里程碑的能力边界和验证证据。
已完成的当前工作区验证:
```text
./npmw run test:facade -> 61/61 通过
./npmw run build -> 通过
```
完整 `./npmw run verify``git diff --check``jq empty config/compatibility-matrix.json` 均已通过Placement 已作为独立里程碑提交。远端 HTTP 当前缺少可用用户名/凭据,`git push origin main` 未成功,待认证恢复后推送。
1. 浏览器 OCCT 包围盒黄金证据若无法自动化,保持 P3-03 为 `IN PROGRESS`
2. 接着推进 TSN generation 历史写回,不把签名回退误标为 OCCT 原生历史。
本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。

View File

@@ -308,7 +308,7 @@
本轮已完成 `EXU-02..EXU-06` 的基础版本Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker因此当前是可验证的业务调度层不是完整几何重算。 本轮已完成 `EXU-02..EXU-06` 的基础版本Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker因此当前是可验证的业务调度层不是完整几何重算。
属性领域合同现覆盖标量、枚举、颜色、Vector、Placement、ObjectLink、ObjectLinkList/StringList 和版本化 SubshapeLink。Placement/Vector 使用结构化值并做有限数及非零旋转轴校验,复合值深拷贝进入 Undo/Redo 与持久化;真实特征执行器对对象 Placement 的 FreeCAD 局部坐标语义、File/Shape 摘要和多选 mixed 编辑仍未完成。 属性领域合同现覆盖标量、枚举、颜色、Vector、Placement、ObjectLink、ObjectLinkList/StringList 和版本化 SubshapeLink。Placement/Vector 使用结构化值并做有限数、角度范围及非零旋转轴校验,复合值深拷贝进入 Undo/Redo 与持久化。Facade 的对象级几何重算已在 Shape 生成后执行 Placement并对恒等变换、畸形结构、变换失败释放和最近有效缓存保留给出测试证据这不是 FreeCAD Body/Tip、Support、AttachmentOffset 或特征局部坐标链的等价实现,File/Shape 摘要和多选 mixed 编辑仍未完成。
TSN 已完成面/边/顶点量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`。重复候选使用唯一候选 ID一旧多新明确保持 ambiguous面匹配可跨纯平移迁移。版本化 TopoRef JSON 只保存对象、种类、持久 ID、拓扑版本、generation、状态、签名和候选显式拒绝 transient `faceIndex/edgeIndex/vertexIndex/subshapeIndex`。它仍没有 OCCT 历史 `Generated/Modified/Deleted` 信息、曲线/曲面参数签名、邻接图和跨特征布尔历史映射TSN-03、TSN-07 与完整 TSN-08 仍是 FreeCAD 稳定子形状命名的必要任务。 TSN 已完成面/边/顶点量化签名、唯一/重复候选判定和匹配 API并挂接到 Bitbybit `MeshAsset`。重复候选使用唯一候选 ID一旧多新明确保持 ambiguous面匹配可跨纯平移迁移。版本化 TopoRef JSON 只保存对象、种类、持久 ID、拓扑版本、generation、状态、签名和候选显式拒绝 transient `faceIndex/edgeIndex/vertexIndex/subshapeIndex`。它仍没有 OCCT 历史 `Generated/Modified/Deleted` 信息、曲线/曲面参数签名、邻接图和跨特征布尔历史映射TSN-03、TSN-07 与完整 TSN-08 仍是 FreeCAD 稳定子形状命名的必要任务。

View File

@@ -1603,7 +1603,7 @@ P0 基线/治理
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh | | 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 不出运行时;`geometry.topology()` 生成面/边/点签名TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重复拆分保持歧义、纯平移面可继承 ID。OCCT 历史命名、邻接/曲率签名和布尔映射仍未实现 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;句柄携带 documentId/version底层 OCCT hash 不出运行时;`geometry.topology()` 生成面/边/点签名TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重复拆分保持歧义、纯平移面可继承 ID。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 和浏览器矩阵已验证基础 PlacementFacade 重算 executor 现在对对象 Placement 做严格结构校验,在 Shape 生成后执行平移/轴角旋转,恒等变换跳过额外句柄,失败时释放临时 Shape 并保留最近有效缓存Node/fallback 已覆盖失败释放、畸形输入和保存恢复。0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm自动化浏览器黄金/体积测试尚未进入 CI |
| 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 持久化仍待完成 | | 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 持久化仍待完成 |
| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 ShapeHole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 | | P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 ShapeHole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
@@ -1656,6 +1656,8 @@ P0 基线/治理
| Box Placement `translation=[5,-1,2]` | 24 顶点、12 三角形 | 变换后 X `[4,6]`、Y `[-3,1]`、Z `[0.5,3.5]`,与解析值一致 | | Box Placement `translation=[5,-1,2]` | 24 顶点、12 三角形 | 变换后 X `[4,6]`、Y `[-3,1]`、Z `[0.5,3.5]`,与解析值一致 |
| 相同 Box 双句柄 | `PASS` | 释放第一个句柄后第二个仍可网格化;最终引用释放才删除 OCCT 缓存形状 | | 相同 Box 双句柄 | `PASS` | 释放第一个句柄后第二个仍可网格化;最终引用释放才删除 OCCT 缓存形状 |
对象 Placement 的 Facade 重算合同:没有 `Placement` 属性时保持原有 Shape存在但结构畸形、轴为零或角度越界时返回 `GEOMETRY_EXECUTION_FAILED`,且不调用几何创建器;非恒等变换失败会释放本次生成的局部 Shape不覆盖上一次成功缓存。SQLite fallback 保存/加载后重新执行同一 Placement验证结构化值未被扁平化。该合同仍只覆盖对象级变换不覆盖 FreeCAD Body/Tip、Support、AttachmentOffset 或特征局部坐标链。
浏览器验证使用 `crossOriginIsolated=true` 的 Chrome、专用 OCCT Worker 和 `precision=0.05`。上述曲面误差均小于网格精度;这是三角网格显示误差,不代表 B-Rep 几何尺寸误差。 浏览器验证使用 `crossOriginIsolated=true` 的 Chrome、专用 OCCT Worker 和 `precision=0.05`。上述曲面误差均小于网格精度;这是三角网格显示误差,不代表 B-Rep 几何尺寸误差。
### 16.13 P3-04 Boolean 子阶段验证记录 ### 16.13 P3-04 Boolean 子阶段验证记录

View File

@@ -278,6 +278,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
validateVectorValue(`${property.label} position`, placement.position) validateVectorValue(`${property.label} position`, placement.position)
validateVectorValue(`${property.label} rotation axis`, placement.rotation?.axis) validateVectorValue(`${property.label} rotation axis`, placement.rotation?.axis)
if (!placement.rotation || typeof placement.rotation.angle !== 'number' || !Number.isFinite(placement.rotation.angle)) throw new TypeError(`${property.label} rotation angle must be finite.`) if (!placement.rotation || typeof placement.rotation.angle !== 'number' || !Number.isFinite(placement.rotation.angle)) throw new TypeError(`${property.label} rotation angle must be finite.`)
if (placement.rotation.angle < 0 || placement.rotation.angle > 360) throw new RangeError(`${property.label} rotation angle must be between 0 and 360 degrees.`)
const axis = placement.rotation.axis const axis = placement.rotation.axis
if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`) if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`)
} }

View File

@@ -252,6 +252,47 @@ export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object,
} }
const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value
const placementComponent = (value: unknown, name: string) => {
if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`Object Placement ${name} must be finite.`)
return value
}
const structuredValue = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const placementForObject = (object: DocumentObjectSnapshot) => {
const property = object.properties.find((candidate) => candidate.name === 'Placement')
if (!property) return null
if (property.type !== 'App::PropertyPlacement') throw new TypeError('Object Placement must use App::PropertyPlacement.')
const value = property.value
if (!structuredValue(value)) throw new TypeError('Object Placement has an invalid structure.')
const placementValue = value as Record<string, unknown>
const positionValue = placementValue.position
const rotationValue = placementValue.rotation
if (!structuredValue(positionValue) || !structuredValue(rotationValue) || !structuredValue(rotationValue.axis)) throw new TypeError('Object Placement has an invalid structure.')
const positionRecord = positionValue as Record<string, unknown>
const rotationRecord = rotationValue as Record<string, unknown>
const axisRecord = rotationRecord.axis as Record<string, unknown>
const position = {
x: placementComponent(positionRecord.x, 'position.x'),
y: placementComponent(positionRecord.y, 'position.y'),
z: placementComponent(positionRecord.z, 'position.z'),
}
const axis = {
x: placementComponent(axisRecord.x, 'rotation.axis.x'),
y: placementComponent(axisRecord.y, 'rotation.axis.y'),
z: placementComponent(axisRecord.z, 'rotation.axis.z'),
}
const angle = placementComponent(rotationRecord.angle, 'rotation.angle')
if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError('Object Placement rotation axis cannot be zero.')
if (angle < 0 || angle > 360) throw new RangeError('Object Placement rotation angle must be between 0 and 360 degrees.')
const identity = position.x === 0 && position.y === 0 && position.z === 0 && angle === 0
return identity ? null : {
translation: [position.x, position.y, position.z] as [number, number, number],
rotationAxis: [axis.x, axis.y, axis.z] as [number, number, number],
rotationAngle: angle,
}
}
const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => { const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => {
const value = propertyValue(object, name) const value = propertyValue(object, name)
return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined
@@ -316,6 +357,7 @@ export const createFacadeGeometryRecomputeExecutor = (
} }
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion } const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
try { try {
const placement = placementForObject(object)
let result: ShapeHandle let result: ShapeHandle
if (object.typeId === 'Part::Box') { if (object.typeId === 'Part::Box') {
result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) }) result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) })
@@ -416,6 +458,19 @@ export const createFacadeGeometryRecomputeExecutor = (
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Chamfer base has no valid recomputed Shape.') 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) }) result = await geometry.chamfer({ ...documentContext, base: baseShape, distance: numberProperty('Distance', 1) })
} }
if (placement) {
const unplaced = result
let placed: ShapeHandle | undefined
try {
placed = await geometry.applyPlacement({ ...documentContext, shape: unplaced, placement })
await geometry.release(unplaced)
result = placed
} catch (error) {
if (placed) await Promise.allSettled([geometry.release(placed)])
else await Promise.allSettled([geometry.release(unplaced)])
throw error
}
}
if (context.signal.aborted) { if (context.signal.aborted) {
await geometry.release(result) await geometry.release(result)
throw new DOMException('Recompute cancelled.', 'AbortError') throw new DOMException('Recompute cancelled.', 'AbortError')

View File

@@ -704,6 +704,83 @@ test('Part primitive and boolean nodes execute through the same Shape cache', as
assert.ok(calls.includes('union:box-shape,box-shape')) assert.ok(calls.includes('union:box-shape,box-shape'))
}) })
test('object Placement transforms recomputed shapes before committing the cache', async () => {
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-placement', documentVersion: 3 })
const calls: string[] = []
let localShapeId = 'box-local'
let placementShouldFail = false
const runtime: RecomputeGeometryRuntime = {
capabilities: () => ({ status: 'ready' }),
createBox: async () => { calls.push(`box:${localShapeId}`); return shape(localShapeId) },
createCylinder: async () => shape('cylinder-local'),
createSphere: async () => shape('sphere-local'),
createCone: async () => shape('cone-local'),
applyPlacement: async (input) => {
calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAxis.join(',')}:${input.placement.rotationAngle}`)
if (placementShouldFail) throw new Error('OCCT placement failed')
return shape('box-placed')
},
union: async () => shape('union'),
cut: async () => shape('cut'),
intersection: async () => shape('common'),
pad: async () => shape('pad'),
pocket: async () => shape('pocket'),
revolution: async () => shape('revolution'),
fillet: async () => shape('fillet'),
chamfer: async () => shape('chamfer'),
release: async (released) => { calls.push(`release:${released.id}`) },
}
const object = { id: 'box', typeId: 'Part::Box', properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 3 },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 5, y: -2, z: 1 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 90 } } },
] }
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-placement', version: 3, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
const shapes = new Map<string, ShapeHandle>()
const result = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal })
assert.equal(result.status, 'success')
assert.equal(shapes.get('box')?.id, 'box-placed')
assert.deepEqual(calls, ['box:box-local', 'placement:box-local:5,-2,1:0,1,0:90', 'release:box-local'])
const identity = { ...object, id: 'box-identity', properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } } : property) }
await createFacadeGeometryRecomputeExecutor(runtime, shapes)(identity, { ...document, objects: [identity] }, { documentId: document.id, documentVersion: document.version, generation: 2, signal: new AbortController().signal })
assert.equal(calls.filter((call) => call.startsWith('placement:')).length, 1)
localShapeId = 'box-retry'
placementShouldFail = true
const failed = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 3, signal: new AbortController().signal })
assert.equal(failed.status, 'failed')
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
assert.match(failed.errors?.[0].message ?? '', /OCCT placement failed/)
assert.equal(shapes.get('box')?.id, 'box-placed')
assert.ok(calls.includes('release:box-retry'))
assert.equal(calls.includes('release:box-placed'), false)
const malformed = { ...object, properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: null, rotation: {} } as never } : property) }
const boxCallsBeforeMalformedInput = calls.filter((call) => call.startsWith('box:')).length
const malformedResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(malformed, { ...document, objects: [malformed] }, { documentId: document.id, documentVersion: document.version, generation: 4, signal: new AbortController().signal })
assert.equal(malformedResult.status, 'failed')
assert.equal(malformedResult.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
assert.match(malformedResult.errors?.[0].message ?? '', /invalid structure/)
assert.equal(calls.filter((call) => call.startsWith('box:')).length, boxCallsBeforeMalformedInput)
const persistence = createSqliteProjectPersistence()
await persistence.save(document)
const loaded = await persistence.load(document.id)
const loadedObject = loaded?.objects.find((candidate) => candidate.id === object.id)
assert.ok(loaded && loadedObject)
calls.length = 0
localShapeId = 'box-restored'
placementShouldFail = false
const restoredShapes = new Map<string, ShapeHandle>()
const restored = await createFacadeGeometryRecomputeExecutor(runtime, restoredShapes)(loadedObject, loaded, { documentId: loaded.id, documentVersion: loaded.version, generation: 5, signal: new AbortController().signal })
assert.equal(restored.status, 'success')
assert.equal(restoredShapes.get('box')?.id, 'box-placed')
assert.ok(calls.includes('placement:box-restored:5,-2,1:0,1,0:90'))
await persistence.dispose()
})
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 })
@@ -921,6 +998,7 @@ test('Placement properties are deeply cloned, validated, undoable and persisted'
const stored = facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value const stored = facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value
assert.deepEqual(stored, { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } }) assert.deepEqual(stored, { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } })
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 0 }, angle: 0 } } }), /axis cannot be zero/) assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 0 }, angle: 0 } } }), /axis cannot be zero/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 361 } } }), /between 0 and 360/)
facade.history.undo() facade.history.undo()
assert.deepEqual(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value, { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }) assert.deepEqual(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value, { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } })
facade.history.redo() facade.history.redo()