feat: expose recompute diagnostic repair tree
This commit is contained in:
22
src/App.tsx
22
src/App.tsx
@@ -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
120
src/facade/diagnostics.ts
Normal 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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user