feat: add versioned topology reference migration

This commit is contained in:
2026-08-02 23:31:33 -04:00
parent 1ed35273a5
commit 9def4708ea
7 changed files with 180 additions and 26 deletions

View File

@@ -6,6 +6,8 @@ export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitM
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
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, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } 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 { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'

View File

@@ -94,11 +94,18 @@ export const signatureForVertex = (point: [number, number, number], tolerance =
const refsForSignatures = (shapeId: string, topologyVersion: number, signatures: SubshapeSignature[]): SubshapeRef[] => {
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(`${signature.kind}:${signature.hash}`, (occurrences.get(`${signature.kind}:${signature.hash}`) ?? 0) + 1))
return signatures.map((signature) => {
const indexes = new Map<string, number>()
const identities = signatures.map((signature) => {
const key = `${signature.kind}:${signature.hash}`
const duplicate = (occurrences.get(key) ?? 0) > 1
const persistentId = `topo-${signature.kind}-${signature.hash}`
return { shapeId, kind: signature.kind, persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.kind === signature.kind && candidate.hash === signature.hash).map((candidate) => `topo-${candidate.kind}-${candidate.hash}`) : undefined }
const index = (indexes.get(key) ?? 0) + 1
indexes.set(key, index)
return { key, persistentId: `topo-${signature.kind}-${signature.hash}${duplicate ? `~${index}` : ''}` }
})
return signatures.map((signature, index) => {
const identity = identities[index]
const duplicate = (occurrences.get(identity.key) ?? 0) > 1
return { shapeId, kind: signature.kind, persistentId: identity.persistentId, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? identities.filter((candidate) => candidate.key === identity.key).map((candidate) => candidate.persistentId) : undefined }
})
}
@@ -127,39 +134,46 @@ export const createVertexSubshapeRefs = (shapeId: string, topologyVersion: numbe
export const createSubshapeRefs = (shapeId: string, topologyVersion: number, faces: FaceMeshInput[], tolerance = 1e-5): { refs: SubshapeRef[]; signatures: SubshapeSignature[] } => {
const signatures = faces.map((face) => signatureForFace(face, tolerance))
const occurrences = new Map<string, number>()
signatures.forEach((signature) => occurrences.set(signature.hash, (occurrences.get(signature.hash) ?? 0) + 1))
const refs = signatures.map((signature) => {
const duplicate = (occurrences.get(signature.hash) ?? 0) > 1
return { shapeId, kind: 'face' as const, persistentId: `topo-face-${signature.hash}`, topologyVersion, status: duplicate ? 'ambiguous' as const : 'stable' as const, signature: signature.canonical, candidates: duplicate ? signatures.filter((candidate) => candidate.hash === signature.hash).map((candidate) => `topo-face-${candidate.hash}`) : undefined }
})
return { refs, signatures }
return { refs: refsForSignatures(shapeId, topologyVersion, signatures), signatures }
}
const signatureScore = (left: SubshapeSignature, right: SubshapeSignature, tolerance: number) => {
const distance = Math.hypot(...left.centroid.map((value, axis) => value - right.centroid[axis]))
const areaDelta = Math.abs(left.area - right.area)
const boundsDelta = Math.hypot(...left.bounds.min.map((value, axis) => value - right.bounds.min[axis]), ...left.bounds.max.map((value, axis) => value - right.bounds.max[axis]))
if (left.kind !== right.kind || left.kind === 'vertex') return 0
const scale = Math.max(Math.sqrt(Math.max(left.area, right.area)), magnitude(left.bounds.max.map((value, axis) => value - left.bounds.min[axis]) as [number, number, number]), magnitude(right.bounds.max.map((value, axis) => value - right.bounds.min[axis]) as [number, number, number]), tolerance)
const areaDelta = Math.abs(left.area - right.area) / Math.max(left.area, right.area, tolerance * tolerance)
const leftExtent = left.bounds.max.map((value, axis) => value - left.bounds.min[axis])
const rightExtent = right.bounds.max.map((value, axis) => value - right.bounds.min[axis])
const boundsDelta = Math.hypot(...leftExtent.map((value, axis) => value - rightExtent[axis])) / scale
const normalDelta = Math.hypot(...left.normal.map((value, axis) => value - right.normal[axis]))
return Math.max(0, 1 - (distance + areaDelta + boundsDelta + normalDelta) / Math.max(tolerance, 1e-9))
return Math.max(0, 1 - (areaDelta + boundsDelta + normalDelta) / 4)
}
export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, tolerance = 1e-4): SubshapeMatch[] => {
const matches: SubshapeMatch[] = []
const used = new Set<string>()
const contested = new Set<string>()
const currentHashCounts = new Map<string, number>()
for (const entry of current) currentHashCounts.set(`${entry.signature.kind}:${entry.signature.hash}`, (currentHashCounts.get(`${entry.signature.kind}:${entry.signature.hash}`) ?? 0) + 1)
for (const candidate of current) {
const exact = previous.filter((entry) => entry.signature.hash === candidate.signature.hash && !used.has(entry.ref.persistentId))
if (exact.length === 1) { used.add(exact[0].ref.persistentId); matches.push({ current: { ...candidate.ref, persistentId: exact[0].ref.persistentId, status: 'stable' }, previousId: exact[0].ref.persistentId, score: 1, status: 'stable' }); continue }
const scored = previous.map((entry) => ({ entry, score: signatureScore(entry.signature, candidate.signature, tolerance) })).filter((entry) => !used.has(entry.entry.ref.persistentId)).sort((left, right) => right.score - left.score)
const hashKey = `${candidate.signature.kind}:${candidate.signature.hash}`
const exact = previous.filter((entry) => entry.signature.kind === candidate.signature.kind && entry.signature.hash === candidate.signature.hash && !used.has(entry.ref.persistentId))
if (exact.length === 1 && currentHashCounts.get(hashKey) === 1 && candidate.ref.status !== 'ambiguous') { used.add(exact[0].ref.persistentId); matches.push({ current: { ...candidate.ref, persistentId: exact[0].ref.persistentId, status: 'stable', candidates: undefined }, previousId: exact[0].ref.persistentId, score: 1, status: 'stable' }); continue }
if (exact.length > 0 && (exact.length > 1 || (currentHashCounts.get(hashKey) ?? 0) > 1 || candidate.ref.status === 'ambiguous')) {
exact.forEach((entry) => contested.add(entry.ref.persistentId))
matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [...new Set([...exact.map((entry) => entry.ref.persistentId), ...(candidate.ref.candidates ?? [])])] }, score: 1, status: 'ambiguous' })
continue
}
const scored = previous.map((entry) => ({ entry, score: entry.signature.kind === candidate.signature.kind ? signatureScore(entry.signature, candidate.signature, tolerance) : 0 })).filter((entry) => !used.has(entry.entry.ref.persistentId)).sort((left, right) => right.score - left.score || left.entry.ref.persistentId.localeCompare(right.entry.ref.persistentId))
const best = scored[0]
const second = scored[1]
if (best && best.score > 0.9 && (!second || best.score - second.score > 0.05)) {
used.add(best.entry.ref.persistentId)
matches.push({ current: { ...candidate.ref, persistentId: best.entry.ref.persistentId, status: 'stable' }, previousId: best.entry.ref.persistentId, score: best.score, status: 'stable' })
} 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' })
}
for (const entry of previous) if (!used.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' })
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

@@ -0,0 +1,110 @@
import type { SubshapeRef } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = {
schemaVersion: 1
objectId: string
kind: SubshapeRef['kind']
persistentId: string
topologyVersion: number
generation: number
status: NonNullable<SubshapeRef['status']>
signature?: string
candidates?: string[]
}
export type TopoRefResolution = {
status: 'resolved' | 'ambiguous' | 'deleted'
ref?: SubshapeRef
candidates?: SubshapeRef[]
}
export type TopologyMigration = {
records: PersistedTopoRef[]
matches: SubshapeMatch[]
counts: Record<NonNullable<SubshapeRef['status']>, number>
}
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'])
const finiteNonNegativeInteger = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
export const createPersistedTopoRef = (objectId: string, ref: SubshapeRef, generation: number): PersistedTopoRef => {
if (!objectId.trim()) throw new RangeError('TopoRef objectId is required.')
if (!finiteNonNegativeInteger(generation)) throw new RangeError('TopoRef generation must be a non-negative integer.')
if (!ref.persistentId.trim()) throw new RangeError('TopoRef persistentId is required.')
if (!finiteNonNegativeInteger(ref.topologyVersion)) throw new RangeError('TopoRef topologyVersion must be a non-negative integer.')
return {
schemaVersion: 1,
objectId,
kind: ref.kind,
persistentId: ref.persistentId,
topologyVersion: ref.topologyVersion,
generation,
status: ref.status ?? 'stable',
signature: ref.signature,
candidates: ref.candidates ? [...new Set(ref.candidates)] : undefined,
}
}
export const serializeTopoRef = (record: PersistedTopoRef) => JSON.stringify(createPersistedTopoRef(record.objectId, {
shapeId: record.objectId,
kind: record.kind,
persistentId: record.persistentId,
topologyVersion: record.topologyVersion,
status: record.status,
signature: record.signature,
candidates: record.candidates,
}, record.generation))
export const parseTopoRef = (json: string): PersistedTopoRef => {
let value: unknown
try { value = JSON.parse(json) } catch { throw new SyntaxError('TopoRef JSON is invalid.') }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('TopoRef must be a JSON object.')
const source = value as Record<string, unknown>
for (const key of transientIndexKeys) if (key in source) throw new RangeError(`TopoRef must not persist transient ${key}.`)
if (source.schemaVersion !== 1) throw new RangeError('TopoRef schemaVersion is not supported.')
if (typeof source.objectId !== 'string' || typeof source.persistentId !== 'string') throw new TypeError('TopoRef objectId and persistentId must be strings.')
if (!kinds.has(source.kind as SubshapeRef['kind'])) throw new RangeError('TopoRef kind is invalid.')
if (!statuses.has(source.status as NonNullable<SubshapeRef['status']>)) throw new RangeError('TopoRef status is invalid.')
if (!finiteNonNegativeInteger(source.topologyVersion) || !finiteNonNegativeInteger(source.generation)) throw new RangeError('TopoRef version fields must be non-negative integers.')
if (source.signature !== undefined && typeof source.signature !== 'string') throw new TypeError('TopoRef signature must be a string.')
if (source.candidates !== undefined && (!Array.isArray(source.candidates) || !source.candidates.every((candidate) => typeof candidate === 'string'))) throw new TypeError('TopoRef candidates must be strings.')
return createPersistedTopoRef(source.objectId, {
shapeId: source.objectId,
kind: source.kind as SubshapeRef['kind'],
persistentId: source.persistentId,
topologyVersion: source.topologyVersion as number,
status: source.status as NonNullable<SubshapeRef['status']>,
signature: source.signature as string | undefined,
candidates: source.candidates as string[] | undefined,
}, source.generation as number)
}
export const resolveTopoRef = (record: PersistedTopoRef, current: SubshapeRef[]): TopoRefResolution => {
const sameKind = current.filter((ref) => ref.kind === record.kind)
const byId = sameKind.filter((ref) => ref.persistentId === record.persistentId)
if (byId.length === 1 && byId[0].status !== 'ambiguous') return { status: 'resolved', ref: byId[0] }
const bySignature = record.signature ? sameKind.filter((ref) => ref.signature === record.signature) : []
const candidates = byId.length > 0 ? byId : bySignature
if (candidates.length === 1 && candidates[0].status !== 'ambiguous') return { status: 'resolved', ref: candidates[0] }
if (candidates.length > 0) return { status: 'ambiguous', candidates }
return { status: 'deleted' }
}
export const migrateTopoRefs = (
objectId: string,
previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>,
generation: number,
): TopologyMigration => {
const matches = matchSubshapes(previous, current)
const counts: TopologyMigration['counts'] = { stable: 0, ambiguous: 0, new: 0, deleted: 0 }
const records = matches.map((match) => {
counts[match.status] += 1
return createPersistedTopoRef(objectId, match.current, generation)
})
return { records, matches, counts }
}