feat: resolve topology reference candidates

This commit is contained in:
2026-08-03 01:28:16 -04:00
parent ffbe2c69dc
commit 56903dbeb2
12 changed files with 160 additions and 22 deletions

View File

@@ -452,7 +452,16 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
} else if (property.type === 'App::PropertyLinkSub') {
const ref = property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value ? property.value : null
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span><IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
const source = ref ? facade.app.document.getObject(ref.objectId) : null
const candidates = ref ? (ref.candidates?.length ? ref.candidates : source?.topology?.entries.filter((entry) => entry.ref.kind === ref.kind && entry.ref.status !== 'deleted').map((entry) => entry.ref.persistentId) ?? []) : []
const replace = (candidatePersistentId: string) => {
if (!candidatePersistentId) return
try {
facade.app.document.resolveTopologyReference({ ownerObjectId: objectId, referenceName: property.name, candidatePersistentId })
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? `${property.label} reference replaced` : `Reference replaced; recompute ${result.status}`))
} catch (error) { showNotice(error instanceof Error ? error.message : String(error)) }
}
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span>{(ref.status === 'ambiguous' || ref.status === 'deleted') && candidates.length > 0 ? <select className="property-control property-topology-candidates" aria-label={`${property.label} replacement`} defaultValue="" onChange={(event) => replace(event.target.value)}><option value="">Replace</option>{candidates.map((candidate) => <option value={candidate} key={candidate}>{candidate}</option>)}</select> : null}<IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
} else if (property.type === 'App::PropertyPlacement') {
const placement = property.value as PlacementValue
const updatePlacement = (path: 'position' | 'axis' | 'angle', key: 'x' | 'y' | 'z' | null, next: number) => {
@@ -670,12 +679,22 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
function DocumentDiagnosticNode({ node, facade, showNotice, child = false }: { node: DiagnosticTreeNode; facade: BitBybitWebCadFacade; showNotice: (message: string) => void; child?: boolean }) {
const diagnostic = node.diagnostic
const topologyRepair = diagnostic.topologyRepair
const [topologyCandidate, setTopologyCandidate] = useState(topologyRepair?.candidates[0] ?? '')
const runRepair = (actionId: 'select-object' | 'recompute-root' | 'suppress-root') => {
void facade.diagnostics.repair(diagnostic.id, actionId).then((result) => showNotice(result.message))
}
const replaceTopologyReference = () => {
if (!topologyRepair || !topologyCandidate) return
try {
facade.app.document.resolveTopologyReference({ ...topologyRepair, candidatePersistentId: topologyCandidate })
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Topology reference replaced' : `Reference replaced; recompute ${result.status}`))
} catch (error) { showNotice(error instanceof Error ? error.message : String(error)) }
}
return <div className={`document-diagnostic ${child ? 'is-child' : ''}`}>
<div className="document-diagnostic-main"><span className={`diagnostic-severity ${diagnostic.severity}`}><AlertTriangle size={14} /></span><div><strong>{diagnostic.code}</strong><span>{diagnostic.message}</span><small>{diagnostic.objectId || diagnostic.source}{diagnostic.dependencyPath && diagnostic.dependencyPath.length > 1 ? ` · ${diagnostic.dependencyPath.join(' → ')}` : ''}{diagnostic.generation ? ` · generation ${diagnostic.generation}` : ''}</small></div></div>
{diagnostic.repairActions?.length ? <div className="diagnostic-actions">{diagnostic.repairActions.map((action) => <button key={action.id} className="button button-quiet" disabled={!action.enabled} title={action.reason || action.label} onClick={() => runRepair(action.id)}>{action.id === 'select-object' ? <Search size={13} /> : action.id === 'recompute-root' ? <RefreshCw size={13} /> : <Pause size={13} />}{action.label}</button>)}</div> : null}
{topologyRepair ? <div className="topology-repair"><select className="property-control" aria-label="Replacement subshape" value={topologyCandidate} onChange={(event) => setTopologyCandidate(event.target.value)}>{topologyRepair.candidates.map((candidate) => <option value={candidate} key={candidate}>{candidate}</option>)}</select><button className="button button-quiet" onClick={replaceTopologyReference} disabled={!topologyCandidate}><Check size={13} />Replace reference</button></div> : null}
{node.children.length > 0 ? <div className="diagnostic-children">{node.children.map((entry) => <DocumentDiagnosticNode key={entry.diagnostic.id} node={entry} facade={facade} showNotice={showNotice} child />)}</div> : null}
</div>
}

View File

@@ -15,6 +15,7 @@ export const cloneDiagnostic = (diagnostic: Diagnostic): Diagnostic => ({
...diagnostic,
dependencyPath: diagnostic.dependencyPath ? [...diagnostic.dependencyPath] : undefined,
repairActions: diagnostic.repairActions?.map(cloneAction),
topologyRepair: diagnostic.topologyRepair ? { ...diagnostic.topologyRepair, candidates: [...diagnostic.topologyRepair.candidates] } : undefined,
})
const rootCausePath = (objectId: string, graph: DependencyGraph, states: Record<string, RecomputeState>) => {

View File

@@ -7,9 +7,9 @@ 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, 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 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, ResolveTopologyReferenceInput, 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 { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
export { captureSignatureTopologyHistory } from './topologyHistory'
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'

View File

@@ -19,6 +19,7 @@ import type {
SetPropertyInput,
SetExpressionInput,
RecomputeResult,
ResolveTopologyReferenceInput,
ShapeHandle,
TaskSnapshot,
Unsubscribe,
@@ -32,7 +33,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 { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef } from './topologyReferences'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -464,6 +465,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
commit({ ...state, document })
notify(`${sourceProperty.label} updated`)
}
const resolveTopologyReference = (input: ResolveTopologyReferenceInput) => {
const document = cloneDocumentSnapshot(state.document)
const resolved = resolveDocumentTopologyReference(document, input)
document.dependencies = collectDependencyEdges(document)
markDocumentTouched(document, [input.ownerObjectId])
document.version += 1
document.dirty = true
const diagnostics = state.diagnostics.filter((diagnostic) => diagnostic.topologyRepair?.ownerObjectId !== input.ownerObjectId || diagnostic.topologyRepair.referenceName !== input.referenceName)
commit({ ...state, document, diagnostics })
notify(`${input.referenceName} topology reference replaced`)
return { ...resolved, candidates: resolved.candidates ? [...resolved.candidates] : undefined }
}
const setExpression = ({ objectId, propertyName, expression }: SetExpressionInput) => {
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
@@ -566,7 +579,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
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 topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => ({
const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => {
const sourceTopology = document.objects.find((object) => object.id === issue.sourceObjectId)?.topology
const candidates = issue.candidates?.length
? issue.candidates
: sourceTopology?.entries.filter((entry) => entry.ref.kind === issue.kind && entry.ref.status !== 'deleted').map((entry) => entry.ref.persistentId) ?? []
return {
id: `topology:${document.id}:${result.generation}:${issue.ownerObjectId}:${issue.referenceName}:${index}`,
source: 'geometry',
severity: issue.status === 'deleted' ? 'error' : 'warning',
@@ -580,11 +598,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
generation: result.generation,
rootCauseObjectId: issue.sourceObjectId,
dependencyPath: [issue.ownerObjectId, issue.sourceObjectId],
topologyRepair: candidates.length > 0 ? { ownerObjectId: issue.ownerObjectId, referenceName: issue.referenceName, candidates: [...new Set(candidates)] } : undefined,
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]
@@ -738,7 +757,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
}
const facade: BitBybitWebCadFacade = {
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },

View File

@@ -1,4 +1,4 @@
import type { DocumentSnapshot, ObjectTopologySnapshot, SubshapeRef, TopoRefValue } from './types'
import type { DocumentSnapshot, ObjectTopologySnapshot, ResolveTopologyReferenceInput, SubshapeRef, TopoRefValue } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = TopoRefValue
@@ -19,6 +19,7 @@ export type TopologyReferenceMigrationIssue = {
ownerObjectId: string
sourceObjectId: string
referenceName: string
kind: SubshapeRef['kind']
status: 'ambiguous' | 'deleted'
persistentId: string
candidates?: string[]
@@ -166,7 +167,7 @@ export const migrateDocumentTopologyReferences = (
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 })
if (migrated.status === 'ambiguous' || migrated.status === 'deleted') issues.push({ ownerObjectId, sourceObjectId: record.objectId, referenceName, kind: record.kind, status: migrated.status, persistentId: migrated.persistentId, candidates: migrated.candidates ? [...migrated.candidates] : undefined })
return migrated
}
for (const object of document.objects) {
@@ -178,3 +179,32 @@ export const migrateDocumentTopologyReferences = (
}
return { changedOwnerIds: [...changedOwnerIds], issues }
}
export const resolveDocumentTopologyReference = (
document: DocumentSnapshot,
input: ResolveTopologyReferenceInput,
): PersistedTopoRef => {
const owner = document.objects.find((object) => object.id === input.ownerObjectId)
if (!owner) throw new Error(`Topology reference owner does not exist: ${input.ownerObjectId}`)
let current: PersistedTopoRef | null = null
let replace: ((record: PersistedTopoRef) => void) | null = null
if (input.referenceName.startsWith('ExternalGeometry:')) {
const externalId = input.referenceName.slice('ExternalGeometry:'.length)
const external = owner.sketch?.externalGeometry.find((candidate) => candidate.id === externalId)
if (external) { current = external.source; replace = (record) => { external.source = record } }
} else {
const property = owner.properties.find((candidate) => candidate.name === input.referenceName && candidate.type === 'App::PropertyLinkSub')
if (property?.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value) {
current = property.value
replace = (record) => { property.value = record }
}
}
if (!current || !replace) throw new Error(`Topology reference does not exist: ${input.ownerObjectId}.${input.referenceName}`)
const source = document.objects.find((object) => object.id === current?.objectId)
if (!source?.topology) throw new Error(`Source object has no current topology snapshot: ${current.objectId}`)
const candidate = source.topology.entries.find((entry) => entry.ref.persistentId === input.candidatePersistentId && entry.ref.kind === current?.kind)
if (!candidate) throw new RangeError(`Topology candidate is not available for ${input.referenceName}: ${input.candidatePersistentId}`)
const resolved = createPersistedTopoRef(current.objectId, { ...candidate.ref, status: 'stable', candidates: undefined }, source.topology.generation)
replace(resolved)
return resolved
}

View File

@@ -90,6 +90,12 @@ export type SetPropertyInput = {
value: PropertyValue
}
export type ResolveTopologyReferenceInput = {
ownerObjectId: string
referenceName: string
candidatePersistentId: string
}
export type PersistenceCapabilities = {
mode: 'sqlite-opfs' | 'sqlite-memory' | 'unavailable'
sqliteWasm: boolean
@@ -381,6 +387,11 @@ export type Diagnostic = {
rootCauseObjectId?: string
dependencyPath?: string[]
repairActions?: DiagnosticRepairAction[]
topologyRepair?: {
ownerObjectId: string
referenceName: string
candidates: string[]
}
resolved?: boolean
}
@@ -457,6 +468,7 @@ export interface BitBybitWebCadFacade {
load(documentId: string): Promise<DocumentSnapshot | null>
markDirty(): void
setProperty(input: SetPropertyInput): void
resolveTopologyReference(input: ResolveTopologyReferenceInput): TopoRefValue
setExpression(input: SetExpressionInput): void
recompute(): RecomputeResult
recomputeAsync(options?: RecomputeExecutionOptions): Promise<RecomputeExecutionResult>

View File

@@ -378,8 +378,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.property-number .property-control { min-width: 0; }
.property-link-control { color: var(--cyan); }
.property-link-sub { display: flex; min-width: 0; align-items: center; gap: 4px; color: var(--cyan); font-size: 10px; }
.property-link-sub > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.property-link-sub > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.property-link-sub .icon-button { width: 24px; height: 24px; flex: 0 0 24px; }
.property-topology-candidates { min-width: 0; max-width: 112px; }
.topology-repair { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin: 8px 0 0 34px; }
.property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; }
.property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; }
.property-expression { min-height: 20px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--cyan); font-size: 9px; }