feat: add versioned topology reference migration
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
110
src/facade/topologyReferences.ts
Normal file
110
src/facade/topologyReferences.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user