feat: expose recompute diagnostic repair tree

This commit is contained in:
2026-08-02 22:56:38 -04:00
parent bc7adb500b
commit aeafc6c607
10 changed files with 265 additions and 18 deletions

View File

@@ -54,7 +54,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId
@@ -586,6 +586,7 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
const [geometry, setGeometry] = useState(() => facade.geometry.capabilities())
const document = facade.getState().document
const persistence = facade.project.capabilities()
const diagnosticTree = facade.diagnostics.tree()
const geometryTone = geometry.status === 'ready' ? 'green' : geometry.status === 'failed' || geometry.status === 'unavailable' ? 'amber' : 'cyan'
const geometryValue = geometry.status === 'ready' ? 'Ready' : geometry.status === 'initializing' ? 'Loading' : geometry.status
useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice])
@@ -594,7 +595,24 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
void facade.geometry.initialize().then((capabilities) => { if (!disposed) setGeometry(capabilities) }).catch((error: unknown) => { if (!disposed) showNotice(`Geometry capability probe failed: ${error instanceof Error ? error.message : String(error)}`) })
return () => { disposed = true }
}, [facade, showNotice])
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value={String(document.recompute?.errors.length || 0)} detail="Recompute diagnostics from the current document" tone={document.recompute?.errors.length ? 'amber' : 'green'} onClick={() => showNotice('Document diagnostics opened')} /></section></div>
return <div className="diagnostics-page">
<PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} />
<div className="health-grid"><HealthCard label="WebAssembly" value={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} icon={AlertTriangle} /></div>
<section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /></section>
<section className="diagnostic-tree-panel panel-surface"><div className="section-title"><div><span className="section-kicker">Document</span><h3>Diagnostic tree</h3></div><strong className={diagnosticTree.length ? 'icon-amber' : 'icon-green'}>{diagnosticTree.length} root causes</strong></div>{diagnosticTree.length === 0 ? <div className="diagnostic-empty"><CheckCircle2 size={16} />No active document diagnostics</div> : <div className="diagnostic-tree">{diagnosticTree.map((node) => <DocumentDiagnosticNode key={node.diagnostic.id} node={node} facade={facade} showNotice={showNotice} />)}</div>}</section>
</div>
}
function DocumentDiagnosticNode({ node, facade, showNotice, child = false }: { node: DiagnosticTreeNode; facade: BitBybitWebCadFacade; showNotice: (message: string) => void; child?: boolean }) {
const diagnostic = node.diagnostic
const runRepair = (actionId: 'select-object' | 'recompute-root' | 'suppress-root') => {
void facade.diagnostics.repair(diagnostic.id, actionId).then((result) => showNotice(result.message))
}
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}
{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>
}
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {

120
src/facade/diagnostics.ts Normal file
View File

@@ -0,0 +1,120 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import type { Diagnostic, DiagnosticRepairAction, DiagnosticTreeNode, DocumentSnapshot } from './types'
type RecomputeDiagnosticInput = {
document: DocumentSnapshot
generation: number
affected: string[]
objectStates: Record<string, RecomputeState>
errors: Array<{ objectId: string; code: string; message: string }>
}
const cloneAction = (action: DiagnosticRepairAction): DiagnosticRepairAction => ({ ...action })
export const cloneDiagnostic = (diagnostic: Diagnostic): Diagnostic => ({
...diagnostic,
dependencyPath: diagnostic.dependencyPath ? [...diagnostic.dependencyPath] : undefined,
repairActions: diagnostic.repairActions?.map(cloneAction),
})
const rootCausePath = (objectId: string, graph: DependencyGraph, states: Record<string, RecomputeState>) => {
const path = [objectId]
const visited = new Set(path)
let current = objectId
while (states[current] === 'upstream-failed') {
const dependency = graph.dependenciesOf(current).find((candidate) => states[candidate] === 'error' || states[candidate] === 'upstream-failed')
if (!dependency || visited.has(dependency)) break
path.push(dependency)
visited.add(dependency)
current = dependency
}
return path
}
const repairActions = (document: DocumentSnapshot, targetObjectId: string, rootCode?: string): DiagnosticRepairAction[] => {
const target = document.objects.find((object) => object.id === targetObjectId)
const suppressibleFeature = target?.properties.some((property) => property.name === 'Suppressed' && property.type === 'App::PropertyBool') ?? false
const canSuppress = suppressibleFeature && rootCode !== 'DEPENDENCY_CYCLE'
return [
{ id: 'select-object', label: 'Select root object', targetObjectId, enabled: Boolean(target) },
{ id: 'recompute-root', label: 'Recompute affected branch', targetObjectId, enabled: Boolean(target) },
{ id: 'suppress-root', label: 'Suppress failing feature', targetObjectId, enabled: canSuppress, reason: canSuppress ? undefined : rootCode === 'DEPENDENCY_CYCLE' ? 'Dependency cycles must be repaired by changing a reference or expression.' : 'The root object is not a suppressible feature.' },
]
}
export const buildRecomputeDiagnostics = ({ document, generation, affected, objectStates, errors }: RecomputeDiagnosticInput): Diagnostic[] => {
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const errorsByObject = new Map<string, Array<{ code: string; message: string }>>()
for (const error of errors) {
const entries = errorsByObject.get(error.objectId) ?? []
entries.push({ code: error.code, message: error.message })
errorsByObject.set(error.objectId, entries)
}
const diagnostics: Diagnostic[] = []
for (const objectId of affected) {
const state = objectStates[objectId]
if (state !== 'error' && state !== 'upstream-failed') continue
const path = rootCausePath(objectId, graph, objectStates)
const rootCauseObjectId = path.at(-1) as string
const rootCode = errorsByObject.get(rootCauseObjectId)?.[0]?.code
const objectErrors = errorsByObject.get(objectId)
const entries = objectErrors?.length ? objectErrors : [{ code: 'UPSTREAM_FAILED', message: `Skipped because ${rootCauseObjectId} failed to recompute.` }]
for (const [index, error] of entries.entries()) {
diagnostics.push({
id: `recompute:${document.id}:${generation}:${objectId}:${error.code}:${index}`,
source: 'recompute',
severity: state === 'error' ? 'error' : 'warning',
code: error.code,
message: error.message,
objectId,
documentId: document.id,
documentVersion: document.version,
generation,
rootCauseObjectId,
dependencyPath: path,
repairActions: repairActions(document, rootCauseObjectId, rootCode),
})
}
}
return diagnostics
}
export const buildDiagnosticTree = (diagnostics: Diagnostic[]): DiagnosticTreeNode[] => {
const active = diagnostics.filter((diagnostic) => !diagnostic.resolved).map(cloneDiagnostic)
const childrenByRoot = new Map<string, Diagnostic[]>()
const roots: Diagnostic[] = []
const rootKeys = new Set<string>()
const keyFor = (diagnostic: Diagnostic, objectId = diagnostic.objectId) => `${diagnostic.source}:${diagnostic.documentId ?? ''}:${diagnostic.generation ?? ''}:${objectId ?? diagnostic.id}`
for (const diagnostic of active.filter((candidate) => !candidate.rootCauseObjectId || candidate.objectId === candidate.rootCauseObjectId)) {
const key = keyFor(diagnostic)
if (rootKeys.has(key)) {
const children = childrenByRoot.get(key) ?? []
children.push(diagnostic)
childrenByRoot.set(key, children)
} else {
rootKeys.add(key)
roots.push(diagnostic)
}
}
for (const diagnostic of active.filter((candidate) => candidate.rootCauseObjectId && candidate.objectId !== candidate.rootCauseObjectId)) {
const key = keyFor(diagnostic, diagnostic.rootCauseObjectId)
if (!rootKeys.has(key)) {
roots.push(diagnostic)
rootKeys.add(keyFor(diagnostic))
continue
}
const children = childrenByRoot.get(key) ?? []
children.push(diagnostic)
childrenByRoot.set(key, children)
}
return roots.map((diagnostic) => ({
diagnostic,
children: childrenByRoot.get(keyFor(diagnostic))?.map((child) => ({ diagnostic: child, children: [] })) ?? [],
}))
}
export const replaceRecomputeDiagnostics = (existing: Diagnostic[], documentId: string, affected: string[], next: Diagnostic[]) => {
const affectedIds = new Set(affected)
return [...existing.filter((diagnostic) => diagnostic.source !== 'recompute' || diagnostic.documentId !== documentId || !diagnostic.objectId || !affectedIds.has(diagnostic.objectId)), ...next].map(cloneDiagnostic)
}

View File

@@ -1,9 +1,10 @@
export { createMockFacade } from './mockFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
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, 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 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 { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'

View File

@@ -4,6 +4,8 @@ import type {
CommandState,
DocumentSnapshot,
Diagnostic,
DiagnosticRepairAction,
DiagnosticRepairResult,
ExecuteCommandInput,
FacadeEvent,
FacadeListener,
@@ -28,6 +30,7 @@ import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimension
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd'
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -304,7 +307,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() })
const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const getState = () => ({ ...state, diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() }
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
@@ -438,7 +441,11 @@ export function createMockFacade(): BitBybitWebCadFacade {
}
const nextRecompute = { generation, status: errors.length === 0 ? 'completed' as const : 'failed' as const, objectStates, dirtyObjects: errors.length === 0 ? [] : plan.affected, order: plan.order, errors }
document.recompute = nextRecompute
state = { ...state, document }
const nextDiagnostics = buildRecomputeDiagnostics({ document, generation, affected: plan.affected, objectStates, errors })
const diagnostics = replaceRecomputeDiagnostics(state.diagnostics, document.id, plan.affected, nextDiagnostics)
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })
if (document.dirty) autosave.schedule(document)
emitState()
return { ...plan, generation, status: nextRecompute.status, errors }
@@ -464,15 +471,35 @@ export function createMockFacade(): BitBybitWebCadFacade {
generation: result.generation,
status: result.status,
objectStates: result.objectStates,
dirtyObjects: result.dirtyObjects,
dirtyObjects: [...new Set([...(source.recompute?.dirtyObjects ?? []).filter((objectId) => !result.affected.includes(objectId)), ...result.dirtyObjects])],
order: result.order,
errors: result.errors,
}
state = { ...state, document }
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)
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 })
if (document.dirty) autosave.schedule(document)
emitState()
return result
}
const repairDiagnostic = async (diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult> => {
const diagnostic = state.diagnostics.find((candidate) => candidate.id === diagnosticId && !candidate.resolved)
const action = diagnostic?.repairActions?.find((candidate) => candidate.id === actionId)
if (!diagnostic || !action || !action.enabled) return { diagnosticId, actionId, status: 'unavailable', message: action?.reason || 'The diagnostic or repair action is no longer available.' }
if (!state.document.objects.some((object) => object.id === action.targetObjectId)) return { diagnosticId, actionId, status: 'unavailable', message: 'The repair target no longer exists in the active document.' }
if (actionId === 'select-object') {
select(action.targetObjectId)
notify(`Selected diagnostic root ${action.targetObjectId}`)
return { diagnosticId, actionId, status: 'completed', message: 'Root object selected.' }
}
if (actionId === 'suppress-root') setProperty({ objectId: action.targetObjectId, propertyName: 'Suppressed', value: true })
const result = await recomputeDocumentAsync({ dirtyObjectIds: [...new Set([...(state.document.recompute?.dirtyObjects ?? []), action.targetObjectId])] })
const completed = result.status === 'completed'
notify(completed ? 'Diagnostic branch repaired' : 'Diagnostic repair did not complete')
return { diagnosticId, actionId, status: completed ? 'completed' : 'failed', message: completed ? 'The affected dependency branch recomputed successfully.' : `Recompute finished with status ${result.status}.` }
}
const loadDocument = async (documentId: string) => {
recomputeCoordinator.cancel()
const loaded = await projectPersistence.load(documentId)
@@ -549,7 +576,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId, selectedTypeId)
if (status.status === 'disabled') {
const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'command', severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
}
@@ -559,7 +586,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
commit({ ...state, document: savedDocument })
void projectPersistence.save(savedDocument).catch((error: unknown) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'error', code: 'PERSISTENCE_SAVE_FAILED', message: error instanceof Error ? error.message : String(error), requestId }
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'persistence', severity: 'error', code: 'PERSISTENCE_SAVE_FAILED', message: error instanceof Error ? error.message : String(error), requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context }); notify('Save failed; export a recovery package')
})
@@ -569,7 +596,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const objectId = state.selectedObjectId
const shape = featureShapes.get(objectId)
const reportFailure = (code: string, message: string) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message, objectId, requestId }
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'geometry', severity: 'warning', code, message, objectId, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context })
notify(message)
@@ -579,7 +606,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const count = topology.faces.length + topology.edges.length + topology.vertices.length
if (count === 0) throw new Error('Shape topology is empty.')
const message = `Shape check passed: ${topology.faces.length} faces, ${topology.edges.length} edges, ${topology.vertices.length} vertices`
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'info', code: 'SHAPE_CHECK_PASSED', message, objectId, requestId }
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'geometry', severity: 'info', code: 'SHAPE_CHECK_PASSED', message, objectId, requestId, documentId: state.document.id, documentVersion: state.document.version }
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context })
notify(message)
@@ -597,11 +624,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
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('') },
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
}
void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error) }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) })
void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'persistence', severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error), documentId: state.document.id, documentVersion: state.document.version }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) })
return facade
}

View File

@@ -293,11 +293,39 @@ export type FacadeRequestContext = {
export type Diagnostic = {
id: string
source: 'command' | 'recompute' | 'geometry' | 'persistence' | 'system'
severity: 'info' | 'warning' | 'error'
code: string
message: string
objectId?: string
requestId?: string
documentId?: string
documentVersion?: number
generation?: number
rootCauseObjectId?: string
dependencyPath?: string[]
repairActions?: DiagnosticRepairAction[]
resolved?: boolean
}
export type DiagnosticRepairAction = {
id: 'select-object' | 'recompute-root' | 'suppress-root'
label: string
targetObjectId: string
enabled: boolean
reason?: string
}
export type DiagnosticTreeNode = {
diagnostic: Diagnostic
children: DiagnosticTreeNode[]
}
export type DiagnosticRepairResult = {
diagnosticId: string
actionId: DiagnosticRepairAction['id']
status: 'completed' | 'failed' | 'unavailable'
message: string
}
export type TaskSnapshot = {
@@ -400,6 +428,11 @@ export interface BitBybitWebCadFacade {
apply(): void
cancel(): void
}
readonly diagnostics: {
list(): Diagnostic[]
tree(): DiagnosticTreeNode[]
repair(diagnosticId: string, actionId: DiagnosticRepairAction['id']): Promise<DiagnosticRepairResult>
}
readonly project: {
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe

View File

@@ -269,6 +269,23 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.diagnostic-value { font-size: 10px; text-align: right; }
.diagnostic-value.green { color: var(--green); }.diagnostic-value.cyan { color: var(--cyan); }.diagnostic-value.amber { color: var(--amber); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }.status-dot.green { background: var(--green); }.status-dot.cyan { background: var(--cyan); }.status-dot.amber { background: var(--amber); }
.diagnostic-tree-panel { margin-top: 16px; padding: 20px; }
.diagnostic-tree-panel .section-title > strong { font-size: 10px; }
.diagnostic-empty { min-height: 64px; display: flex; align-items: center; justify-content: center; gap: 8px; border-top: 1px solid var(--line-soft); color: var(--text-muted); font-size: 11px; }
.diagnostic-tree { border-top: 1px solid var(--line-soft); }
.document-diagnostic { padding: 12px 0; border-bottom: 1px solid var(--line-soft); }
.document-diagnostic.is-child { padding: 10px 0 10px 22px; border-bottom: 0; border-top: 1px solid var(--line-soft); }
.document-diagnostic-main { display: flex; align-items: flex-start; gap: 9px; min-width: 0; }
.document-diagnostic-main > div { min-width: 0; }
.document-diagnostic-main strong, .document-diagnostic-main span, .document-diagnostic-main small { display: block; }
.document-diagnostic-main strong { font-size: 11px; }
.document-diagnostic-main span { margin-top: 3px; color: var(--text-soft); font-size: 10px; line-height: 1.45; }
.document-diagnostic-main small { margin-top: 5px; color: var(--text-muted); font-size: 9px; overflow-wrap: anywhere; }
.diagnostic-severity { width: 24px; height: 24px; flex: 0 0 24px; display: grid; place-items: center; border-radius: 3px; }
.diagnostic-severity.error { color: var(--red); background: var(--red-soft); }.diagnostic-severity.warning { color: var(--amber); background: var(--amber-soft); }.diagnostic-severity.info { color: var(--cyan); background: var(--cyan-soft); }
.diagnostic-actions { display: flex; flex-wrap: wrap; gap: 5px; margin: 9px 0 0 33px; }
.diagnostic-actions .button { min-height: 25px; padding: 0 8px; font-size: 9px; }
.diagnostic-children { margin: 10px 0 -12px 33px; border-left: 1px solid var(--line); }
.sync-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 13px; }
.sync-card { padding: 23px; }
.sync-card-header { display: flex; align-items: center; gap: 11px; }