feat: establish production facade composition root

This commit is contained in:
2026-08-10 16:28:12 -04:00
parent b962a5c3b5
commit 7f10bba38f
11 changed files with 285 additions and 20 deletions

View File

@@ -59,7 +59,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createLinuxcncIframeAdapter, createMockFacade, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import { createLinuxcncIframeAdapter, createProductionFacade, createPumpHousingDemoDocument, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import type { LinkSubListValue, LinkSubValue, ShapeResourceValue, TopoRefValue } from './facade/types'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
@@ -133,7 +133,7 @@ function App() {
const [rightTab, setRightTab] = useState<'data' | 'view'>('data')
const [bottomOpen, setBottomOpen] = useState(true)
const [notice, setNotice] = useState('')
const facade = useMemo(() => createMockFacade(), [])
const facade = useMemo(() => createProductionFacade({ initialDocument: createPumpHousingDemoDocument(), initialSelectedObjectIds: ['pad'] }), [])
const nativeHistory = useMemo(() => new NativeOcctHistoryWorkerProvider(), [])
const [facadeState, setFacadeState] = useState(() => facade.getState())
const [diagnosticFlash, setDiagnosticFlash] = useState<{ objectId: string; token: number } | null>(null)

View File

@@ -0,0 +1,31 @@
import type { ModelTreeItem } from './types'
export type DocumentTemplate = {
id: string
label: string
version: number
dirty: boolean
readOnly: boolean
units: string
tree: ModelTreeItem[]
selectedObjectIds: string[]
}
export const PUMP_HOUSING_DEMO_TEMPLATE: DocumentTemplate = {
id: 'doc-pump-housing',
label: 'Pump Housing',
version: 18,
dirty: true,
readOnly: false,
units: 'mm',
selectedObjectIds: ['pad'],
tree: [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
],
}

View File

@@ -1,4 +1,7 @@
export { createMockFacade } from './mockFacade'
export { createBlankDocument, createMockFacade, createPumpHousingDemoDocument, createWebCadFacade } from './mockFacade'
export type { WebCadFacadeOptions } from './mockFacade'
export { createProductionFacade } from './productionFacade'
export type { ProductionFacadeOptions } from './productionFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export type { SqliteProjectPersistenceOptions } from './projectStore'

View File

@@ -44,16 +44,7 @@ import { decodeFcstdPathProperty, extractFcstdShapeResources, inspectFcstdArchiv
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
import { createCamJob } from './cam'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
]
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
@@ -367,14 +358,36 @@ const expressionReferences = (expression: string): string[] => {
}
}
const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const objects = initialTree.map(createObjectSnapshot)
const document: DocumentSnapshot = { id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects }
const createDocumentFromTemplate = (template: DocumentTemplate): DocumentSnapshot => {
const tree = template.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined }))
const objects = tree.map(createObjectSnapshot)
const document: DocumentSnapshot = { id: template.id, label: template.label, version: template.version, dirty: template.dirty, readOnly: template.readOnly, units: template.units, tree, objects }
document.dependencies = collectDependencyEdges(document)
document.recompute = createRecomputeSnapshot(objects.map((object) => object.id))
return document
}
export const createBlankDocument = (label = 'Untitled document', id = 'doc-untitled'): DocumentSnapshot => ({
id,
label,
version: 0,
dirty: true,
readOnly: false,
units: 'mm',
tree: [],
objects: [],
dependencies: [],
recompute: createRecomputeSnapshot([]),
})
export const createPumpHousingDemoDocument = (): DocumentSnapshot => createDocumentFromTemplate(PUMP_HOUSING_DEMO_TEMPLATE)
export type WebCadFacadeOptions = {
initialDocument?: DocumentSnapshot
initialWorkbench?: WorkbenchId
initialSelectedObjectIds?: string[]
}
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object'])
const camCommandIds = new Set(workbenchDefinitions.CAM.groups.flatMap((group) => group.commands.map((entry) => entry.id)))
@@ -661,13 +674,18 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<str
return plan
}
export function createMockFacade(): BitBybitWebCadFacade {
export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitWebCadFacade {
const projectPersistence = createSqliteProjectPersistence()
const geometryRuntime = new BitbybitGeometryRuntime()
const camJob = createCamJob('Job', 'CAM Job', { min: [0, 0, 0], max: [10, 10, 10], mode: 'from-base-bound-box' })
const featureShapes = new Map<string, ShapeHandle>()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', selectedObjectIds: ['pad'], document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const initialDocument = cloneDocumentSnapshot(options.initialDocument ?? createBlankDocument())
const availableObjectIds = new Set(initialDocument.objects.map((object) => object.id))
const initialSelectedObjectIds = [...new Set(options.initialSelectedObjectIds ?? [])].filter((objectId) => availableObjectIds.has(objectId))
let documentSequence = 0
const nextBlankDocument = (label = 'Untitled document') => createBlankDocument(label, `doc-untitled-${++documentSequence}`)
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: options.initialWorkbench ?? 'Part Design', selectedObjectId: initialSelectedObjectIds[0] ?? '', selectedObjectIds: initialSelectedObjectIds, document: initialDocument, persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const recomputeCoordinator = new RecomputeCoordinator(
createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
(documentId) => state.document.id === documentId ? state.document.version : null,
@@ -1320,7 +1338,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
}
emit({ type: 'command.started', commandId, context })
if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '', selectedObjectIds: [] }) }
if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(), selectedObjectId: '', selectedObjectIds: [] }) }
else if (commandId === 'save') {
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
commit({ ...state, document: savedDocument })
@@ -1386,7 +1404,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, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, 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, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, 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: nextBlankDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, 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, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, 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), state.selectedSubshape), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, getObjectIds: () => [...(state.selectedObjectIds ?? (state.selectedObjectId ? [state.selectedObjectId] : []))], getSubshape: () => cloneSelection(state.selectedSubshape) ?? null, getPreselection: () => cloneSelection(state.preselectedSubshape) ?? null, select, selectObjects, selectSubshape, preselectSubshape, clear: () => select('') },
@@ -1452,3 +1470,10 @@ export function createMockFacade(): BitBybitWebCadFacade {
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
}
export function createMockFacade(): BitBybitWebCadFacade {
return createWebCadFacade({
initialDocument: createPumpHousingDemoDocument(),
initialSelectedObjectIds: PUMP_HOUSING_DEMO_TEMPLATE.selectedObjectIds,
})
}

View File

@@ -0,0 +1,8 @@
import { createWebCadFacade, type WebCadFacadeOptions } from './mockFacade'
import type { BitBybitWebCadFacade } from './types'
export type ProductionFacadeOptions = WebCadFacadeOptions
export function createProductionFacade(options: ProductionFacadeOptions = {}): BitBybitWebCadFacade {
return createWebCadFacade(options)
}