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