feat: apply object placement during recompute
This commit is contained in:
@@ -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"] },
|
||||
|
||||
@@ -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 和局部坐标链的完整等价语义。
|
||||
|
||||
@@ -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 稳定子形状命名的必要任务。
|
||||
|
||||
|
||||
@@ -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 子阶段验证记录
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
|
||||
@@ -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<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 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')
|
||||
|
||||
@@ -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<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 () => {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user