diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 7ce1f59..ba0319a 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -17,7 +17,7 @@ "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } }, "facadeCapabilities": { - "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "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"] }, "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"] }, diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index d38282f..1f5cd10 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -1,6 +1,6 @@ # Web FreeCAD 接续执行状态 -更新时间:2026-08-03(P2 schema v5 编辑中暂停) +更新时间:2026-08-03(对象 Placement 重算里程碑已提交,待远端认证后推送) ## 1. 当前目标 @@ -10,9 +10,9 @@ - 分支:`main` - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` -- 最近已推送里程碑:`63446d5 docs: add continuation execution status` -- 最新完整门禁:`./npmw run verify` 通过 -- Facade 测试:55/55 通过 +- 最近已推送里程碑:`6bfbdf5 feat: add project recovery and resource governance` +- 最近已推送里程碑的完整门禁:`./npmw run verify` 通过 +- 当前 Placement 里程碑验证:`./npmw run verify` 通过(Facade 61/61),diff/JSON 校验通过 - 生产构建:通过 - 本地开发地址:`http://127.0.0.1:5173/` - 兼容真值表:`config/compatibility-matrix.json` @@ -27,6 +27,7 @@ 5. 椭圆/B-spline 领域模型;基础 TypeScript solver 对未支持曲线返回明确诊断。 6. 外部草图几何、稳定 TopoRef、持久化与 `topo-ref` DAG 依赖边。 7. Vector、Placement、LinkList/StringList 结构化属性合同;Placement 深拷贝、校验、撤销、持久化和 UI 编辑器。 +8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape;变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。 相关提交按新到旧: @@ -48,13 +49,13 @@ - Pattern 当前复制整个 Shape,不是 FreeCAD 局部特征历史。 - Hole 固定原点/法向,尚无面定位、沉孔、锪孔、螺纹和完整孔标准。 - 拓扑历史来自签名保守回退,不是 OCCT/FreeCAD 原生 Generated/Modified/Deleted 历史。 -- Placement 已进入文档属性,但尚未完整映射到所有特征执行器的 FreeCAD 局部坐标语义。 +- Placement 已进入文档属性,并接入 Facade 的对象级 Shape 重算;尚未完整映射到 Body/Tip、Support、AttachmentOffset 或所有特征执行器的 FreeCAD 局部坐标链语义。 - FCStd 仍以安全预检、metadata/proxy 和只读报告为主,尚未双向原生往返。 ## 5. 下一步执行顺序 -1. 完成 P2 持久化门禁:1000 次事务压力、迁移失败回滚、资源配额/垃圾回收、崩溃恢复点。 -2. 将对象 Placement 可靠接入 Part 基本体与可兼容特征的 OCCT 重算,补包围盒/撤销/保存黄金测试。 +1. 补齐 P2 浏览器实证门禁:真实 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件和崩溃注入;合同层与 fallback 测试已由 `6bfbdf5` 完成。 +2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径;Node/fallback 合同已通过。 3. 继续 TSN:把拓扑快照和历史结果接入实际重算 generation,迁移外部几何与 LinkSub,暴露 ambiguous/deleted 修复流程。 4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC;此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 5. 扩展 PartDesign:Mirrored、MultiTransform、Loft/Pipe/Groove、Thickness/Draft,以及 Pattern/Hole 完整语义。 @@ -80,8 +81,28 @@ git log -5 --oneline ## 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。 -本里程碑提交后,下一执行点为“将对象 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 和局部坐标链的完整等价语义。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index b8fe67c..14dd3e0 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -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,因此当前是可验证的业务调度层,不是完整几何重算。 -属性领域合同现覆盖标量、枚举、颜色、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 稳定子形状命名的必要任务。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 80a2991..a133d71 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -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 | | 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-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 和浏览器矩阵已验证基础 Placement;Facade 重算 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/Revolution,Revolution 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 当前为整 Shape,Hole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 | | 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 双句柄 | `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 几何尺寸误差。 ### 16.13 P3-04 Boolean 子阶段验证记录 diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 600fc29..8ba4ecf 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -278,6 +278,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope validateVectorValue(`${property.label} position`, placement.position) 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.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 if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`) } diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index 7d7857d..21f2feb 100644 --- a/src/facade/recomputeEngine.ts +++ b/src/facade/recomputeEngine.ts @@ -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 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 => 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 + 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 + const rotationRecord = rotationValue as Record + const axisRecord = rotationRecord.axis as Record + 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 value = propertyValue(object, name) 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 } try { + const placement = placementForObject(object) let result: ShapeHandle if (object.typeId === 'Part::Box') { result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) }) @@ -416,6 +458,19 @@ export const createFacadeGeometryRecomputeExecutor = ( 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 (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) { await geometry.release(result) throw new DOMException('Recompute cancelled.', 'AbortError') diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 9827e2b..c824584 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -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')) }) +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() + 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() + 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 () => { const facade = createMockFacade() 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 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: 1 }, angle: 361 } } }), /between 0 and 360/) 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 } }) facade.history.redo()