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

@@ -448,7 +448,12 @@ export class BitbybitGeometryRuntime {
const client = await this.readyClient()
const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false })
const faces = mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes }))
return { faces: createSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, edges: createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, vertices: createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs }
const tolerance = Math.max(1e-5, precision * 0.1)
const faceTopology = createSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
const edgeTopology = createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
const vertexTopology = createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
const entries = [faceTopology, edgeTopology, vertexTopology].flatMap((topology) => topology.refs.map((ref, index) => ({ ref, signature: topology.signatures[index] })))
return { faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs, entries }
}
async release(shape: ShapeHandle): Promise<void> {

View File

@@ -7,10 +7,10 @@ export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION,
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
export { captureSignatureTopologyHistory } from './topologyHistory'
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'
export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher'

View File

@@ -32,7 +32,7 @@ import { cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch,
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd'
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
import { parseTopoRef } from './topologyReferences'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef } from './topologyReferences'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -156,7 +156,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => {
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
...document,
tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })),
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })),
dependencies: document.dependencies?.map((edge) => ({ ...edge })),
recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined,
})
@@ -538,6 +538,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
const document = cloneDocumentSnapshot(state.document)
const updates = new Map(result.objectUpdates.map((object) => [object.id, object]))
document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object)
const topologyMigration = migrateDocumentTopologyReferences(document, result.objectUpdates.map((object) => object.id))
document.dependencies = collectDependencyEdges(document)
for (const objectId of result.affected) {
if (result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId)
const item = document.tree.find((candidate) => candidate.id === objectId)
@@ -555,8 +557,37 @@ export function createMockFacade(): BitBybitWebCadFacade {
order: result.order,
errors: result.errors,
}
for (const objectId of topologyMigration.changedOwnerIds) {
document.recompute.objectStates[objectId] = 'touched'
if (!document.recompute.dirtyObjects.includes(objectId)) document.recompute.dirtyObjects.push(objectId)
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'readonly') item.state = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'warning' : 'dirty'
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
if (status) status.value = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'Topology reference requires repair' : 'Touched'
}
const nextDiagnostics = buildRecomputeDiagnostics({ document, generation: result.generation, affected: result.affected, objectStates: result.objectStates, errors: result.errors })
const diagnostics = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics)
const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => ({
id: `topology:${document.id}:${result.generation}:${issue.ownerObjectId}:${issue.referenceName}:${index}`,
source: 'geometry',
severity: issue.status === 'deleted' ? 'error' : 'warning',
code: issue.status === 'deleted' ? 'TOPOLOGY_REFERENCE_DELETED' : 'TOPOLOGY_REFERENCE_AMBIGUOUS',
message: issue.status === 'deleted'
? `${issue.referenceName} no longer resolves on ${issue.sourceObjectId}; select a replacement subshape.`
: `${issue.referenceName} resolves to multiple subshapes on ${issue.sourceObjectId}: ${(issue.candidates ?? []).join(', ')}.`,
objectId: issue.ownerObjectId,
documentId: document.id,
documentVersion: document.version,
generation: result.generation,
rootCauseObjectId: issue.sourceObjectId,
dependencyPath: [issue.ownerObjectId, issue.sourceObjectId],
repairActions: [
{ id: 'select-object', label: 'Select reference owner', targetObjectId: issue.ownerObjectId, enabled: true },
{ id: 'recompute-root', label: 'Recompute after replacing reference', targetObjectId: issue.ownerObjectId, enabled: false, reason: 'Select one current subshape and replace the ambiguous or deleted reference first.' },
],
}))
const replaced = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics)
const topologyOwners = new Set(topologyMigration.issues.map((issue) => issue.ownerObjectId))
const diagnostics = [...replaced.filter((diagnostic) => !diagnostic.code.startsWith('TOPOLOGY_REFERENCE_') || diagnostic.documentId !== document.id || !diagnostic.objectId || !topologyOwners.has(diagnostic.objectId)), ...topologyDiagnostics]
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${result.generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })

View File

@@ -89,7 +89,7 @@ const saveDocument = (document: DocumentSnapshot) => {
database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] })
const parentByChild = new Map<string, string>()
for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id)
document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal, object?.sketch ? JSON.stringify(object.sketch) : null] }) })
document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json, topology_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal, object?.sketch ? JSON.stringify(object.sketch) : null, object?.topology ? JSON.stringify(object.topology) : null] }) })
for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] })
database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] })
for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] })
@@ -123,7 +123,7 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
const row = documents[0]
if (!row) return null
const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json, topology_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | null>>
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
@@ -137,7 +137,7 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
const properties = propertiesByObject.get(item.id) ?? []
const typeId = properties.find((property) => property.name === 'TypeId')?.value
const row = objects.find((candidate) => String(candidate.id) === item.id)
return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined }
return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined, topology: row?.topology_json ? JSON.parse(String(row.topology_json)) : undefined }
})
return {
id: String(row.id),

View File

@@ -1,4 +1,4 @@
export const PROJECT_SCHEMA_VERSION = 5
export const PROJECT_SCHEMA_VERSION = 6
export type ProjectSchemaMigration = { version: number; sql: string }
@@ -140,4 +140,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [
{ version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' },
{ version: 4, sql: 'ALTER TABLE objects ADD COLUMN sketch_json TEXT;' },
{ version: 5, sql: 'CREATE TABLE IF NOT EXISTS document_checkpoints (document_id TEXT NOT NULL, version INTEGER NOT NULL, snapshot_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (document_id, version), FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS checkpoints_document_created ON document_checkpoints(document_id, created_at DESC);' },
{ version: 6, sql: 'ALTER TABLE objects ADD COLUMN topology_json TEXT;' },
] as const

View File

@@ -1,5 +1,6 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
import { cloneSketch } from './sketcher'
import { cloneObjectTopologySnapshot } from './topologyReferences'
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
@@ -13,7 +14,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => {
if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
return { ...value }
}
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
export interface ProjectPersistenceClient {
initialize(): Promise<PersistenceCapabilities>

View File

@@ -1,6 +1,8 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types'
import { captureSignatureTopologyHistory } from './topologyHistory'
import { migrateTopoRefs } from './topologyReferences'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -15,6 +17,7 @@ export type RecomputeNodeContext = {
documentVersion: number
generation: number
signal: AbortSignal
isCurrent?: () => boolean
}
export type RecomputeNodeResult = {
@@ -44,6 +47,7 @@ export type RecomputeGeometryRuntime = {
revolution(input: RevolutionInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
topology?(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
release(shape: ShapeHandle): Promise<void>
}
@@ -174,6 +178,7 @@ export class RecomputeCoordinator {
documentVersion: document.version,
generation,
signal: controller.signal,
isCurrent: () => this.active?.generation === generation && this.currentDocumentVersion(document.id) === document.version,
})
if (result.status === 'failed') {
return {
@@ -293,6 +298,43 @@ const placementForObject = (object: DocumentObjectSnapshot) => {
rotationAngle: angle,
}
}
const topologyForObject = async (
geometry: RecomputeGeometryRuntime,
object: DocumentObjectSnapshot,
shape: ShapeHandle,
context: RecomputeNodeContext,
): Promise<ObjectTopologySnapshot | undefined> => {
if (!geometry.topology) return undefined
const topology = await geometry.topology(shape, 0.05)
const previous = object.topology?.entries ?? []
const migration = migrateTopoRefs(object.id, previous, topology.entries, context.generation)
const entries = topology.entries.map((entry, index) => ({
ref: {
...migration.matches[index].current,
shapeId: shape.id,
topologyVersion: context.documentVersion,
candidates: migration.matches[index].current.candidates ? [...migration.matches[index].current.candidates as string[]] : undefined,
},
signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] },
})) as ObjectTopologySnapshot['entries']
const history = captureSignatureTopologyHistory(
`${object.id}:generation:${context.generation}`,
previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [],
entries,
)
return {
shapeId: shape.id,
documentVersion: context.documentVersion,
generation: context.generation,
entries,
migration: {
previousGeneration: object.topology?.generation ?? null,
matches: migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })),
},
history,
}
}
const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => {
const value = propertyValue(object, name)
return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined
@@ -471,14 +513,21 @@ export const createFacadeGeometryRecomputeExecutor = (
throw error
}
}
if (context.signal.aborted) {
let topology: ObjectTopologySnapshot | undefined
try {
topology = await topologyForObject(geometry, object, result, context)
} catch (error) {
await Promise.allSettled([geometry.release(result)])
throw error
}
if (context.signal.aborted || context.isCurrent?.() === false) {
await geometry.release(result)
throw new DOMException('Recompute cancelled.', 'AbortError')
throw new DOMException('Recompute result is no longer current.', 'AbortError')
}
const previous = shapes.get(object.id)
shapes.set(object.id, result)
if (previous && previous.id !== result.id) await geometry.release(previous)
return base
return topology ? { ...base, updatedObject: { ...(base.updatedObject ?? object), topology } } : base
} catch (error) {
if (context.signal.aborted || isAbortError(error)) throw error
return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error))

View File

@@ -1,23 +1,10 @@
import type { SubshapeRef } from './types'
import type { SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types'
import { matchSubshapes, type SubshapeSignature } from './topologyNaming'
export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature }
export type TopologyHistoryRelation = {
relation: 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous'
sourceObjectId?: string
sourcePersistentId?: string
resultPersistentId?: string
candidates?: Array<{ sourceObjectId: string; persistentId: string }>
score: number
}
export type TopologyHistoryResult = {
operationId: string
provider: 'signature-fallback'
relations: TopologyHistoryRelation[]
counts: Record<TopologyHistoryRelation['relation'], number>
}
export type TopologyHistoryRelation = StoredTopologyHistoryRelation
export type TopologyHistoryResult = StoredTopologyHistoryResult
const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}`

View File

@@ -1,4 +1,6 @@
import type { SubshapeRef } from './types'
import type { SubshapeRef, SubshapeSignature } from './types'
export type { SubshapeSignature } from './types'
export type FaceMeshInput = {
vertexCoord: number[]
@@ -6,16 +8,6 @@ export type FaceMeshInput = {
triIndexes: number[]
}
export type SubshapeSignature = {
kind: 'face' | 'edge' | 'vertex'
canonical: string
hash: string
centroid: [number, number, number]
bounds: { min: [number, number, number]; max: [number, number, number] }
area: number
normal: [number, number, number]
}
export type SubshapeMatch = {
current: SubshapeRef
previousId?: string
@@ -172,7 +164,10 @@ export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: Su
} else if (best && best.score > 0.5 && second && Math.abs(best.score - second.score) <= 0.05) {
contested.add(best.entry.ref.persistentId); contested.add(second.entry.ref.persistentId)
matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' })
} else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' })
} else {
const status = candidate.ref.status === 'ambiguous' ? 'ambiguous' as const : 'new' as const
matches.push({ current: { ...candidate.ref, status }, score: best?.score ?? 0, status })
}
}
for (const entry of previous) if (!used.has(entry.ref.persistentId) && !contested.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' })
return matches

View File

@@ -1,4 +1,4 @@
import type { SubshapeRef, TopoRefValue } from './types'
import type { DocumentSnapshot, ObjectTopologySnapshot, SubshapeRef, TopoRefValue } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = TopoRefValue
@@ -15,6 +15,37 @@ export type TopologyMigration = {
counts: Record<NonNullable<SubshapeRef['status']>, number>
}
export type TopologyReferenceMigrationIssue = {
ownerObjectId: string
sourceObjectId: string
referenceName: string
status: 'ambiguous' | 'deleted'
persistentId: string
candidates?: string[]
}
export type DocumentTopologyReferenceMigration = {
changedOwnerIds: string[]
issues: TopologyReferenceMigrationIssue[]
}
export const cloneObjectTopologySnapshot = (snapshot: ObjectTopologySnapshot): ObjectTopologySnapshot => ({
...snapshot,
entries: snapshot.entries.map((entry) => ({
ref: { ...entry.ref, candidates: entry.ref.candidates ? [...entry.ref.candidates] : undefined },
signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] },
})),
migration: {
...snapshot.migration,
matches: snapshot.migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })),
},
history: {
...snapshot.history,
counts: { ...snapshot.history.counts },
relations: snapshot.history.relations.map((relation) => relation.candidates ? { ...relation, candidates: relation.candidates.map((candidate) => ({ ...candidate })) } : { ...relation }),
},
})
const transientIndexKeys = new Set(['faceIndex', 'edgeIndex', 'vertexIndex', 'subshapeIndex'])
const statuses = new Set<NonNullable<SubshapeRef['status']>>(['stable', 'ambiguous', 'new', 'deleted'])
const kinds = new Set<SubshapeRef['kind']>(['face', 'edge', 'vertex'])
@@ -98,3 +129,52 @@ export const migrateTopoRefs = (
})
return { records, matches, counts }
}
const migrateReferenceThroughSnapshot = (record: PersistedTopoRef, snapshot: ObjectTopologySnapshot): PersistedTopoRef => {
if (record.objectId.trim() === '' || record.generation >= snapshot.generation) return { ...record, candidates: record.candidates ? [...record.candidates] : undefined }
if (record.status === 'deleted') return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, candidates: undefined }
const stable = snapshot.migration.matches.find((match) => match.status === 'stable' && match.previousId === record.persistentId)
if (stable) return createPersistedTopoRef(record.objectId, stable.current, snapshot.generation)
const ambiguous = snapshot.migration.matches.filter((match) => match.status === 'ambiguous' && match.current.candidates?.includes(record.persistentId))
if (ambiguous.length > 0) return {
...record,
topologyVersion: snapshot.documentVersion,
generation: snapshot.generation,
status: 'ambiguous',
candidates: [...new Set(ambiguous.map((match) => match.current.persistentId))],
}
const deleted = snapshot.migration.matches.some((match) => match.status === 'deleted' && match.previousId === record.persistentId)
if (deleted) return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'deleted', candidates: undefined }
const resolution = resolveTopoRef(record, snapshot.entries.map((entry) => entry.ref))
if (resolution.status === 'resolved' && resolution.ref) return createPersistedTopoRef(record.objectId, resolution.ref, snapshot.generation)
if (resolution.status === 'ambiguous') return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'ambiguous', candidates: resolution.candidates?.map((candidate) => candidate.persistentId) }
return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'deleted', candidates: undefined }
}
export const migrateDocumentTopologyReferences = (
document: DocumentSnapshot,
sourceObjectIds: Iterable<string>,
): DocumentTopologyReferenceMigration => {
const sources = new Map([...new Set(sourceObjectIds)].map((objectId) => {
const source = document.objects.find((object) => object.id === objectId)
return source?.topology ? [objectId, source.topology] as const : null
}).filter((entry): entry is readonly [string, ObjectTopologySnapshot] => Boolean(entry)))
const changedOwnerIds = new Set<string>()
const issues: TopologyReferenceMigrationIssue[] = []
const migrate = (ownerObjectId: string, referenceName: string, record: PersistedTopoRef) => {
const source = sources.get(record.objectId)
if (!source) return record
const migrated = migrateReferenceThroughSnapshot(record, source)
if (migrated.generation !== record.generation || migrated.status !== record.status || migrated.persistentId !== record.persistentId) changedOwnerIds.add(ownerObjectId)
if (migrated.status === 'ambiguous' || migrated.status === 'deleted') issues.push({ ownerObjectId, sourceObjectId: record.objectId, referenceName, status: migrated.status, persistentId: migrated.persistentId, candidates: migrated.candidates ? [...migrated.candidates] : undefined })
return migrated
}
for (const object of document.objects) {
for (const property of object.properties) {
if (property.type !== 'App::PropertyLinkSub' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('schemaVersion' in property.value)) continue
property.value = migrate(object.id, property.name, property.value)
}
for (const external of object.sketch?.externalGeometry ?? []) external.source = migrate(object.id, `ExternalGeometry:${external.id}`, external.source)
}
return { changedOwnerIds: [...changedOwnerIds], issues }
}

View File

@@ -56,6 +56,7 @@ export type DocumentObjectSnapshot = {
typeId: string
properties: ObjectPropertySnapshot[]
sketch?: SketchSnapshot
topology?: ObjectTopologySnapshot
}
export type DocumentSnapshot = {
@@ -177,10 +178,55 @@ export type SubshapeRef = {
candidates?: string[]
}
export type SubshapeSignature = {
kind: SubshapeRef['kind']
canonical: string
hash: string
centroid: [number, number, number]
bounds: { min: [number, number, number]; max: [number, number, number] }
area: number
normal: [number, number, number]
}
export type TopologySnapshotEntry = { ref: SubshapeRef; signature: SubshapeSignature }
export type TopologyMigrationMatch = {
current: SubshapeRef
previousId?: string
score: number
status: 'stable' | 'ambiguous' | 'new' | 'deleted'
}
export type TopologyHistoryRelation = {
relation: 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous'
sourceObjectId?: string
sourcePersistentId?: string
resultPersistentId?: string
candidates?: Array<{ sourceObjectId: string; persistentId: string }>
score: number
}
export type TopologyHistoryResult = {
operationId: string
provider: 'signature-fallback'
relations: TopologyHistoryRelation[]
counts: Record<TopologyHistoryRelation['relation'], number>
}
export type ObjectTopologySnapshot = {
shapeId: string
documentVersion: number
generation: number
entries: TopologySnapshotEntry[]
migration: { previousGeneration: number | null; matches: TopologyMigrationMatch[] }
history: TopologyHistoryResult
}
export type SubshapeTopology = {
faces: SubshapeRef[]
edges: SubshapeRef[]
vertices: SubshapeRef[]
entries: TopologySnapshotEntry[]
}
export type MeshAsset = {