feat: apply object placement during recompute
This commit is contained in:
@@ -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