feat: add whole-shape mirrored feature

This commit is contained in:
2026-08-03 01:41:27 -04:00
parent 56903dbeb2
commit 4aa6ca48ad
11 changed files with 133 additions and 24 deletions

View File

@@ -14,7 +14,7 @@ import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
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, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
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'
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
@@ -61,6 +61,7 @@ test('geometry boundary validates dimensions before invoking OCCT', () => {
assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/)
assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/)
assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/)
assert.throws(() => validateMirrorInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, origin: [0, 0, 0], normal: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /normal/)
})
test('geometry handles reject forged document ownership', () => {
@@ -704,6 +705,46 @@ test('Part primitive and boolean nodes execute through the same Shape cache', as
assert.ok(calls.includes('union:box-shape,box-shape'))
})
test('Mirrored recompute mirrors a whole Shape across a document plane and releases fused copies', async () => {
const calls: string[] = []
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-mirrored', documentVersion: 4 })
let mirrorSequence = 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) => input.shape,
mirror: async (input) => { const result = shape(`mirror-${++mirrorSequence}`); calls.push(`mirror:${input.shape.id}:${input.origin.join(',')}:${input.normal.join(',')}`); return result },
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('mirrored-union') },
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 mirrored: DocumentObjectSnapshot = { id: 'mirrored', typeId: 'PartDesign::Mirrored', properties: [
{ name: 'Base', label: 'Base', group: 'Mirrored', scope: 'data', type: 'App::PropertyLink', value: 'box' },
{ name: 'Plane', label: 'Plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'XZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'] },
{ name: 'Fuse', label: 'Fuse', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true },
] }
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-mirrored', version: 4, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'mirrored', label: 'Mirrored', type: 'feature' }], objects: [base, mirrored], dependencies: [{ sourceId: 'mirrored', targetId: 'box', relation: 'link' }] }
const shapes = new Map<string, ShapeHandle>()
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
await executor(base, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
const fused = await executor(mirrored, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
assert.equal(fused.status, 'success')
assert.equal(shapes.get('mirrored')?.id, 'mirrored-union')
assert.ok(calls.includes('mirror:box-shape:0,0,0:0,1,0'))
assert.ok(calls.includes('union:box-shape,mirror-1'))
assert.ok(calls.includes('release:mirror-1'))
const unfused = { ...mirrored, id: 'mirrored-unfused', properties: mirrored.properties.map((property) => property.name === 'Fuse' ? { ...property, value: false } : property) }
const unfusedResult = await executor(unfused, { ...document, objects: [base, unfused] }, { documentId: document.id, documentVersion: 4, generation: 2, signal: new AbortController().signal })
assert.equal(unfusedResult.status, 'success')
assert.equal(shapes.get('mirrored-unfused')?.id, 'mirror-2')
assert.equal(calls.includes('release:mirror-2'), false)
})
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[] = []
@@ -1112,10 +1153,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('mirrored')
const state = facade.gui.command.getState('multi-transform')
assert.equal(state.status, 'disabled')
assert.match(state.reason || '', /business executor/)
facade.gui.command.execute({ commandId: 'mirrored' })
facade.gui.command.execute({ commandId: 'multi-transform' })
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
assert.equal(facade.getState().document.version, before)
})
@@ -1486,6 +1527,17 @@ test('Part Design feature tasks commit a document object and remain undoable', (
assert.equal(hole?.properties.find((property) => property.name === 'Depth')?.value, 12)
assert.equal(hole?.properties.find((property) => property.name === 'Type')?.value, 'Through all')
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'hole')
assert.equal(facade.gui.command.getState('mirrored').status, 'enabled')
facade.gui.command.execute({ commandId: 'mirrored' })
facade.task.update({ plane: 'XZ plane', fuse: false })
facade.task.apply()
const mirrored = facade.app.document.getObject('mirrored')
assert.equal(mirrored?.typeId, 'PartDesign::Mirrored')
assert.equal(mirrored?.properties.find((property) => property.name === 'Base')?.value, 'hole')
assert.equal(mirrored?.properties.find((property) => property.name === 'Plane')?.value, 'XZ plane')
assert.equal(mirrored?.properties.find((property) => property.name === 'Fuse')?.value, false)
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'mirrored')
})
test('Part workbench commands create primitive and boolean document objects', () => {