feat: persist generation topology history

This commit is contained in:
2026-08-03 01:12:25 -04:00
parent 0fc2cfe41b
commit ffbe2c69dc
16 changed files with 404 additions and 63 deletions

View File

@@ -7,7 +7,7 @@ import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
@@ -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, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types'
import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
id: 'doc-recompute',
@@ -110,8 +110,8 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', ()
})
test('project schema is versioned and covers the FreeCAD document graph', () => {
assert.equal(PROJECT_SCHEMA_VERSION, 5)
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5])
assert.equal(PROJECT_SCHEMA_VERSION, 6)
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6])
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
})
@@ -781,6 +781,141 @@ test('object Placement transforms recomputed shapes before committing the cache'
await persistence.dispose()
})
test('geometry recompute captures generation topology history and isolates capture failures', async () => {
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
let currentFace = square
let topologyShouldFail = false
const releases: string[] = []
const shape = (id: string, documentVersion: number): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-topology-generation', documentVersion })
const runtime: RecomputeGeometryRuntime = {
capabilities: () => ({ status: 'ready' }),
createBox: async (input) => shape(`box-${input.documentVersion}`, input.documentVersion),
createCylinder: async (input) => shape('cylinder', input.documentVersion),
createSphere: async (input) => shape('sphere', input.documentVersion),
createCone: async (input) => shape('cone', input.documentVersion),
applyPlacement: async (input) => input.shape,
union: async (input) => shape('union', input.documentVersion),
cut: async (input) => shape('cut', input.documentVersion),
intersection: async (input) => shape('intersection', input.documentVersion),
pad: async (input) => shape('pad', input.documentVersion),
pocket: async (input) => shape('pocket', input.documentVersion),
revolution: async (input) => shape('revolution', input.documentVersion),
fillet: async (input) => shape('fillet', input.documentVersion),
chamfer: async (input) => shape('chamfer', input.documentVersion),
topology: async (handle): Promise<SubshapeTopology> => {
if (topologyShouldFail) throw new Error('topology capture failed')
const created = createSubshapeRefs(handle.id, handle.documentVersion, [currentFace])
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
},
release: async (released) => { releases.push(released.id) },
}
const object: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
const shapes = new Map<string, ShapeHandle>()
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
const firstDocument: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-topology-generation', version: 1, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
const first = await executor(object, firstDocument, { documentId: firstDocument.id, documentVersion: 1, generation: 1, signal: new AbortController().signal })
assert.equal(first.status, 'success')
assert.equal(first.updatedObject?.topology?.generation, 1)
assert.equal(first.updatedObject?.topology?.history.counts.generated, 1)
assert.equal(first.updatedObject?.topology?.entries[0].ref.status, 'new')
currentFace = translated
const secondObject = first.updatedObject as DocumentObjectSnapshot
const secondDocument = { ...firstDocument, version: 2, objects: [secondObject] }
const second = await executor(secondObject, secondDocument, { documentId: secondDocument.id, documentVersion: 2, generation: 2, signal: new AbortController().signal })
assert.equal(second.status, 'success')
assert.equal(second.updatedObject?.topology?.generation, 2)
assert.equal(second.updatedObject?.topology?.migration.previousGeneration, 1)
assert.equal(second.updatedObject?.topology?.history.counts.modified, 1)
assert.equal(second.updatedObject?.topology?.entries[0].ref.persistentId, first.updatedObject?.topology?.entries[0].ref.persistentId)
assert.equal(shapes.get('box')?.id, 'box-2')
const persistence = createSqliteProjectPersistence()
await persistence.save({ ...secondDocument, objects: [second.updatedObject as DocumentObjectSnapshot] })
assert.deepEqual((await persistence.load(secondDocument.id))?.objects[0].topology, second.updatedObject?.topology)
await persistence.dispose()
const staleObject = second.updatedObject as DocumentObjectSnapshot
const staleDocument = { ...secondDocument, version: 3, objects: [staleObject] }
await assert.rejects(() => executor(staleObject, staleDocument, { documentId: staleDocument.id, documentVersion: 3, generation: 3, signal: new AbortController().signal, isCurrent: () => false }), /no longer current/)
assert.equal(shapes.get('box')?.id, 'box-2')
assert.ok(releases.includes('box-3'))
topologyShouldFail = true
const thirdObject = second.updatedObject as DocumentObjectSnapshot
const thirdDocument = { ...secondDocument, version: 4, objects: [thirdObject] }
const failed = await executor(thirdObject, thirdDocument, { documentId: thirdDocument.id, documentVersion: 4, generation: 4, signal: new AbortController().signal })
assert.equal(failed.status, 'failed')
assert.match(failed.errors?.[0].message ?? '', /topology capture failed/)
assert.equal(shapes.get('box')?.id, 'box-2')
assert.ok(releases.includes('box-4'))
})
test('generation topology migration updates LinkSub and external geometry without forcing ambiguous or deleted refs', () => {
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const previous = createSubshapeRefs('shape-old', 1, [face])
const current = createSubshapeRefs('shape-new', 2, [face, face])
const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] }))
const currentEntries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] }))
const initialDuplicate = migrateTopoRefs('source', [], currentEntries, 1)
assert.equal(initialDuplicate.counts.ambiguous, 2)
const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2)
const topology: ObjectTopologySnapshot = {
shapeId: 'shape-new', documentVersion: 2, generation: 2,
entries: currentEntries,
migration: { previousGeneration: 1, matches: migration.matches },
history: captureSignatureTopologyHistory('source:generation:2', [{ objectId: 'source', entries: previousEntries }], currentEntries),
}
const reference = createPersistedTopoRef('source', previous.refs[0], 1)
const sketch = createSketch('owner')
sketch.externalGeometry.push({ id: 'external', source: { ...reference }, projection: { id: 'projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true })
const document: DocumentSnapshot = {
...recomputeDocumentFixture(), id: 'doc-topology-migration', version: 2,
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'sketch' }],
objects: [
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
{ id: 'owner', typeId: 'Sketcher::SketchObject', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: { ...reference } }], sketch },
], dependencies: [],
}
const ambiguous = migrateDocumentTopologyReferences(document, ['source'])
const property = document.objects[1].properties[0].value
const external = document.objects[1].sketch?.externalGeometry[0].source
assert.equal(property && typeof property === 'object' && !Array.isArray(property) && 'status' in property ? property.status : null, 'ambiguous')
assert.equal(external?.status, 'ambiguous')
assert.equal(external?.generation, 2)
assert.equal(external?.candidates?.length, 2)
assert.deepEqual(ambiguous.changedOwnerIds, ['owner'])
assert.deepEqual(ambiguous.issues.map((issue) => issue.referenceName), ['SupportFace', 'ExternalGeometry:external'])
const deletedMigration = migrateTopoRefs('source', previousEntries, [], 3)
document.objects[0].topology = {
shapeId: 'shape-empty', documentVersion: 3, generation: 3, entries: [],
migration: { previousGeneration: 1, matches: deletedMigration.matches },
history: captureSignatureTopologyHistory('source:generation:3', [{ objectId: 'source', entries: previousEntries }], []),
}
document.objects[1].properties[0].value = { ...reference }
if (document.objects[1].sketch) document.objects[1].sketch.externalGeometry[0].source = { ...reference }
const deleted = migrateDocumentTopologyReferences(document, ['source'])
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'deleted')
assert.equal(document.objects[1].sketch?.externalGeometry[0].source.status, 'deleted')
assert.ok(deleted.issues.every((issue) => issue.status === 'deleted'))
const currentSingle = createSubshapeRefs('shape-later', 4, [face])
const currentSingleEntries = currentSingle.refs.map((ref, index) => ({ ref: { ...ref, persistentId: reference.persistentId }, signature: currentSingle.signatures[index] }))
const skippedGeneration = migrateTopoRefs('source', [], currentSingleEntries, 4)
document.objects[0].topology = {
shapeId: 'shape-later', documentVersion: 4, generation: 4, entries: currentSingleEntries,
migration: { previousGeneration: 3, matches: skippedGeneration.matches },
history: captureSignatureTopologyHistory('source:generation:4', [], currentSingleEntries),
}
document.objects[1].properties[0].value = { ...reference }
const resolvedAcrossGap = migrateDocumentTopologyReferences(document, ['source'])
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'stable')
assert.equal((document.objects[1].properties[0].value as typeof reference).generation, 4)
assert.equal(resolvedAcrossGap.issues.length, 1)
})
test('facade async recompute commits only an accepted generation', async () => {
const facade = createMockFacade()
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })