import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest' import type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, Diagnostic, ExecuteCommandInput, FacadeEvent, FacadeListener, FacadeRequestContext, FacadeState, ModelTreeItem, DocumentObjectSnapshot, ObjectPropertySnapshot, PropertyValue, SetPropertyInput, TaskSnapshot, Unsubscribe, } from './types' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' import { BitbybitGeometryRuntime } from './geometryRuntime' import { ThreeViewportAdapter } from './threeViewport' 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'] }, ] const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, { name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true }, { name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true }, ] const viewProperties = (): ObjectPropertySnapshot[] => [ { name: 'Visibility', label: 'Visibility', group: 'Display', scope: 'view', type: 'App::PropertyBool', value: true }, { name: 'DisplayMode', label: 'Display mode', group: 'Display', scope: 'view', type: 'App::PropertyEnumeration', value: 'Flat lines', options: ['Flat lines', 'Shaded', 'Wireframe'] }, { name: 'Transparency', label: 'Transparency', group: 'Display', scope: 'view', type: 'App::PropertyPercent', value: 0, unit: '%' }, { name: 'ShapeColor', label: 'Shape color', group: 'Appearance', scope: 'view', type: 'App::PropertyColor', value: '#579a9c' }, { name: 'LineColor', label: 'Line color', group: 'Appearance', scope: 'view', type: 'App::PropertyColor', value: '#8cb7b6' }, { name: 'LineWidth', label: 'Line width', group: 'Appearance', scope: 'view', type: 'App::PropertyFloat', value: 1, unit: 'px' }, { name: 'SelectionStyle', label: 'Selection style', group: 'View state', scope: 'view', type: 'App::PropertyEnumeration', value: 'Object + edges', options: ['Object + edges', 'Object', 'Bound box'] }, ] const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { if (item.id.startsWith('pad')) return [ { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] if (item.id.startsWith('pocket')) return [ { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'], recompute: true }, { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] if (item.id.startsWith('fillet')) return [ { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, ] if (item.type === 'sketch') return [ { name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true }, { name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true }, ] if (item.type === 'body') return [ { name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', recompute: true }, ] return [] } const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()] }) const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) })), }) const createDocument = (label = 'Pump Housing'): 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: initialTree.map(createObjectSnapshot), }) const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) const systemCommands = new Set(['new-document', 'save', 'select-object']) const featureCommands: Record = { 'create-body': { label: 'Body', detail: 'Part Design body' }, 'create-sketch': { label: 'Sketch', detail: 'Fully constrained' }, pad: { label: 'Pad', detail: 'Length 42 mm' }, pocket: { label: 'Pocket', detail: 'Through all' }, fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, } const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' } if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' } if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } return { id: commandId, status: 'enabled' } } const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPropertySnapshot, value: PropertyValue) => { if (property.readOnly) throw new Error(`${property.label} is read-only.`) if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`) if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`) if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) if (property.type === 'App::PropertyLength' && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`) if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`) if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`) if (property.type === 'App::PropertyLink') { if (value !== null && typeof value !== 'string') throw new TypeError(`${property.label} requires an object link.`) const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])])) if (value !== null && !knownIds.has(value)) throw new RangeError(`${property.label} target does not exist in this document.`) } if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.') } export function createMockFacade(): BitBybitWebCadFacade { const projectPersistence = createSqliteProjectPersistence() const geometryRuntime = new BitbybitGeometryRuntime() const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } const listeners = new Set() const undoStack: FacadeState[] = [] const redoStack: FacadeState[] = [] let requestSequence = 0 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 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`) } const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() } const beginTask = (commandId: string, draft: Record = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task } const nextFeatureId = (label: string) => { const base = label.toLowerCase() const existing = state.document.tree.filter((item) => item.label.toLowerCase().startsWith(base)).length return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}` } const appendFeature = (document: DocumentSnapshot, commandId: string): { document: DocumentSnapshot; objectId: string } => { const definition = featureCommands[commandId] if (!definition) return { document, objectId: '' } const objectId = nextFeatureId(definition.label) const type = commandId === 'create-sketch' ? 'sketch' : commandId === 'create-body' ? 'body' : 'feature' const item: ModelTreeItem = { id: objectId, label: definition.label, type, state: type === 'body' ? 'active' : 'valid', detail: definition.detail } const tree: ModelTreeItem[] = document.tree.map((entry) => ({ ...entry, children: entry.children ? [...entry.children] : undefined })) if (type === 'body') tree.push({ ...item, children: [] }) else { const body = tree.find((entry) => entry.type === 'body') if (body) body.children = [...(body.children || []), objectId] tree.push(item) } return { document: { ...document, version: document.version + 1, dirty: true, tree, objects: [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })) })), createObjectSnapshot(item)] }, objectId } } const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => { const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) const sourceObject = state.document.objects[objectIndex] const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName) if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) const sourceProperty = sourceObject.properties[propertyIndex] validatePropertyValue(state.document, sourceProperty, value) if (Object.is(sourceProperty.value, value)) return const document = cloneDocumentSnapshot(state.document) const object = document.objects[objectIndex] object.properties[propertyIndex] = { ...object.properties[propertyIndex], value } const treeItem = document.tree.find((item) => item.id === objectId) if (propertyName === 'Label' && treeItem) treeItem.label = String(value) if (sourceProperty.recompute && treeItem && treeItem.state !== 'active') { treeItem.state = 'dirty' const status = object.properties.find((property) => property.name === 'Status') if (status) status.value = 'Touched' } document.version += 1 document.dirty = true commit({ ...state, document }) notify(`${sourceProperty.label} updated`) } const applyTask = () => { const task = state.task if (!task || task.status !== 'preview') return const result = appendFeature(state.document, task.commandId) if (!result.objectId) { state = { ...state, task: { ...task, status: 'completed' } } emitState() return } commit({ ...state, document: result.document, selectedObjectId: result.objectId, task: { ...task, status: 'completed' } }) notify(`${featureCommands[task.commandId].label} created`) } const execute = ({ commandId, payload }: ExecuteCommandInput) => { const requestId = `req-${++requestSequence}` const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId) if (status.status === 'disabled') { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'COMMAND_DISABLED', message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } 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 } emit({ type: 'command.started', commandId, context }) if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) else if (commandId === 'save') { 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 } state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } emit({ type: 'diagnostic.added', diagnostic, context }); notify('Save failed; export a recovery package') }) } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } 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 })) } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; 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), 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() } }, project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), 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), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => 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 } }) }) return facade }