feat: add structured multi-transform
This commit is contained in:
@@ -15,7 +15,7 @@ import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolver
|
||||
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
||||
import { inspectFcstdArchive } from '../src/facade/fcstd'
|
||||
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateMirrorInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
|
||||
|
||||
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
|
||||
id: 'doc-recompute',
|
||||
@@ -745,6 +745,69 @@ test('Mirrored recompute mirrors a whole Shape across a document plane and relea
|
||||
assert.equal(calls.includes('release:mirror-2'), false)
|
||||
})
|
||||
|
||||
test('MultiTransform composes ordered whole-shape steps and releases every transient instance', async () => {
|
||||
const calls: string[] = []
|
||||
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-multi-transform', documentVersion: 5 })
|
||||
let copySequence = 0
|
||||
let mirrorSequence = 0
|
||||
let unionSequence = 0
|
||||
let failPlacementAt = 0
|
||||
const runtime: RecomputeGeometryRuntime = {
|
||||
capabilities: () => ({ status: 'ready' }),
|
||||
createBox: async () => shape('box-shape'),
|
||||
createCylinder: async () => shape('cylinder'), createSphere: async () => shape('sphere'), createCone: async () => shape('cone'),
|
||||
applyPlacement: async (input) => {
|
||||
calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAngle}`)
|
||||
if (failPlacementAt > 0 && calls.filter((call) => call.startsWith('placement:')).length === failPlacementAt) throw new Error('multi placement failed')
|
||||
return shape(`copy-${++copySequence}`)
|
||||
},
|
||||
mirror: async (input) => { calls.push(`mirror:${input.shape.id}:${input.normal.join(',')}`); return shape(`mirror-${++mirrorSequence}`) },
|
||||
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape(`multi-result-${++unionSequence}`) },
|
||||
cut: async () => shape('cut'), intersection: async () => shape('intersection'), 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 base: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
|
||||
const transformations = { steps: [
|
||||
{ id: 'mirror-1', type: 'mirrored' as const, plane: 'YZ plane' as const },
|
||||
{ id: 'linear-1', type: 'linear' as const, occurrences: 3, length: 20, direction: 'Vertical' as const },
|
||||
] }
|
||||
const multi: DocumentObjectSnapshot = { id: 'multi-transform', typeId: 'PartDesign::MultiTransform', properties: [
|
||||
{ name: 'Base', label: 'Base', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: 'box' },
|
||||
{ name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: transformations },
|
||||
] }
|
||||
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-multi-transform', version: 5, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'multi-transform', label: 'Multi-transform', type: 'feature' }], objects: [base, multi], dependencies: [{ sourceId: multi.id, targetId: base.id, relation: 'link' }] }
|
||||
const shapes = new Map<string, ShapeHandle>()
|
||||
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
||||
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
||||
await executor(base, document, context)
|
||||
const completed = await executor(multi, document, context)
|
||||
assert.equal(completed.status, 'success')
|
||||
assert.equal(shapes.get(multi.id)?.id, 'multi-result-1')
|
||||
assert.ok(calls.includes('mirror:box-shape:1,0,0'))
|
||||
assert.ok(calls.includes('union:box-shape,mirror-1,copy-1,copy-2,copy-3,copy-4'))
|
||||
for (const id of ['mirror-1', 'copy-1', 'copy-2', 'copy-3', 'copy-4']) assert.ok(calls.includes(`release:${id}`))
|
||||
|
||||
const placementCalls = calls.filter((call) => call.startsWith('placement:')).length
|
||||
failPlacementAt = placementCalls + 2
|
||||
const failed = await executor(multi, document, { ...context, generation: 2 })
|
||||
assert.equal(failed.status, 'failed')
|
||||
assert.match(failed.errors?.[0].message ?? '', /multi placement failed/)
|
||||
assert.equal(shapes.get(multi.id)?.id, 'multi-result-1')
|
||||
assert.ok(calls.includes('release:mirror-2'))
|
||||
assert.ok(calls.includes('release:copy-5'))
|
||||
assert.equal(calls.includes('release:multi-result-1'), false)
|
||||
|
||||
const excessive = { ...multi, properties: multi.properties.map((property) => property.name === 'Transformations' ? { ...property, value: { steps: [
|
||||
{ id: 'linear-a', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Horizontal' as const },
|
||||
{ id: 'linear-b', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Vertical' as const },
|
||||
{ id: 'mirror-a', type: 'mirrored' as const, plane: 'XY plane' as const },
|
||||
] } } : property) }
|
||||
const invalid = await executor(excessive, { ...document, objects: [base, excessive] }, { ...context, generation: 3 })
|
||||
assert.equal(invalid.status, 'failed')
|
||||
assert.equal(invalid.errors?.[0].code, 'MULTITRANSFORM_INVALID')
|
||||
assert.match(invalid.errors?.[0].message ?? '', /more than 100/)
|
||||
})
|
||||
|
||||
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[] = []
|
||||
@@ -1153,10 +1216,10 @@ test('disabled commands return a reason instead of mutating the document', () =>
|
||||
test('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
|
||||
const facade = createMockFacade()
|
||||
const before = facade.getState().document.version
|
||||
const state = facade.gui.command.getState('multi-transform')
|
||||
const state = facade.gui.command.getState('additive-loft')
|
||||
assert.equal(state.status, 'disabled')
|
||||
assert.match(state.reason || '', /business executor/)
|
||||
facade.gui.command.execute({ commandId: 'multi-transform' })
|
||||
facade.gui.command.execute({ commandId: 'additive-loft' })
|
||||
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
|
||||
assert.equal(facade.getState().document.version, before)
|
||||
})
|
||||
@@ -1229,6 +1292,46 @@ test('Placement properties are deeply cloned, validated, undoable and persisted'
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
test('MultiTransform tasks validate, deeply clone, undo and persist ordered steps', async () => {
|
||||
const facade = createMockFacade()
|
||||
assert.equal(facade.gui.command.getState('multi-transform').status, 'enabled')
|
||||
facade.gui.command.execute({ commandId: 'multi-transform' })
|
||||
const transformations: MultiTransformValue = { steps: [
|
||||
{ id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' },
|
||||
{ id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' },
|
||||
] }
|
||||
facade.task.update({ transformations })
|
||||
facade.task.apply()
|
||||
transformations.steps[1] = { id: 'polar-1', type: 'polar', occurrences: 9, angle: 90, axis: 'Horizontal' }
|
||||
const created = facade.app.document.getObject('multi-transform')
|
||||
assert.equal(created?.typeId, 'PartDesign::MultiTransform')
|
||||
assert.equal(created?.properties.find((property) => property.name === 'Base')?.value, 'pad')
|
||||
assert.deepEqual(created?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [
|
||||
{ id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' },
|
||||
{ id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' },
|
||||
] })
|
||||
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'multi-transform')
|
||||
|
||||
const revised: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] }
|
||||
facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: revised })
|
||||
revised.steps[0] = { id: 'linear-1', type: 'linear', occurrences: 2, length: 1, direction: 'Horizontal' }
|
||||
assert.deepEqual(facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] })
|
||||
assert.throws(() => facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: { steps: [
|
||||
{ id: 'a', type: 'linear', occurrences: 10, length: 1, direction: 'Horizontal' },
|
||||
{ id: 'b', type: 'polar', occurrences: 10, angle: 360, axis: 'Normal' },
|
||||
{ id: 'c', type: 'mirrored', plane: 'YZ plane' },
|
||||
] } }), /more than 100/)
|
||||
facade.history.undo()
|
||||
assert.equal((facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value as MultiTransformValue).steps[1].type, 'polar')
|
||||
facade.history.redo()
|
||||
|
||||
const persistence = createSqliteProjectPersistence()
|
||||
await persistence.save(facade.app.document.getActive())
|
||||
const loaded = await persistence.load('doc-pump-housing')
|
||||
assert.deepEqual(loaded?.objects.find((object) => object.id === 'multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] })
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
test('LinkSub properties preserve versioned TopoRefs and create topology dependencies', async () => {
|
||||
const facade = createMockFacade()
|
||||
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
||||
|
||||
Reference in New Issue
Block a user