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

@@ -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()