feat: persist generation topology history
This commit is contained in:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user