import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest' import type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, ExecuteCommandInput, FacadeEvent, FacadeListener, FacadeRequestContext, FacadeState, ModelTreeItem, MultiTransformValue, DocumentObjectSnapshot, ObjectPropertySnapshot, PropertyValue, PlacementValue, SetPropertyInput, SetExpressionInput, RecomputeResult, ResolveTopologyReferenceInput, ShapeHandle, TaskSnapshot, Unsubscribe, } from './types' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' import { BitbybitGeometryRuntime } from './geometryRuntime' import { ThreeViewportAdapter } from './threeViewport' import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph' import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units' import { cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch, type SketchConstraint, type SketchExternalGeometry, type SketchGeometry, type SketchSnapshot } from './sketcher' import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine' import { inspectFcstdArchive } from './fcstd' import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics' import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences' 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('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('cone') ? 'Part::Cone' : 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('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' 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 }, ...(item.type === 'feature' ? [{ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false, recompute: true }] : []), ...(item.type === 'feature' ? [{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }, recompute: 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('box')) return [ { name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, { name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, { name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, ] if (item.id.startsWith('cylinder')) return [ { name: 'Radius', label: 'Radius', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true }, { name: 'Height', label: 'Height', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, { name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, ] if (item.id.startsWith('sphere')) return [ { name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true }, ] if (item.id.startsWith('cone')) return [ { name: 'Radius1', label: 'Bottom radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true }, { name: 'Radius2', label: 'Top radius', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true }, { name: 'Height', label: 'Height', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, { name: 'Angle', label: 'Angle', group: 'Cone', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, ] if (item.id.startsWith('union') || item.id.startsWith('cut') || item.id.startsWith('intersection')) return [ { name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, ] 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: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ] if (item.id.startsWith('revolution')) return [ { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', 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.id.startsWith('chamfer')) return [ { name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true }, { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, ] if (item.id.startsWith('mirrored')) return [ { name: 'Base', label: 'Base feature', group: 'Mirrored', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Plane', label: 'Mirror plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'YZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'], recompute: true }, { name: 'Fuse', label: 'Fuse result', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true }, ] if (item.id.startsWith('multi-transform')) return [ { name: 'Base', label: 'Base feature', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: { steps: [{ id: 'linear-1', type: 'linear', occurrences: 2, length: 20, direction: 'Horizontal' }] }, recompute: true }, ] if (item.id.startsWith('linear-pattern')) return [ { name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 2, recompute: true }, { name: 'Length', label: 'Length', group: 'Pattern', scope: 'data', type: 'App::PropertyLength', value: 20, unit: 'mm', recompute: true }, { name: 'Direction', label: 'Direction', group: 'Pattern', scope: 'data', type: 'App::PropertyEnumeration', value: 'Horizontal', options: ['Horizontal', 'Vertical', 'Normal'], recompute: true }, ] if (item.id.startsWith('polar-pattern')) return [ { name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 3, recompute: true }, { name: 'Angle', label: 'Angle', group: 'Pattern', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, { name: 'Axis', label: 'Axis', group: 'Pattern', scope: 'data', type: 'App::PropertyEnumeration', value: 'Normal', options: ['Normal', 'Horizontal', 'Vertical'], recompute: true }, ] if (item.id.startsWith('hole')) return [ { name: 'Base', label: 'Base', group: 'Hole', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true }, { name: 'Diameter', label: 'Diameter', group: 'Hole', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true }, { name: 'Depth', label: 'Depth', group: 'Hole', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true }, { name: 'Type', label: 'Type', group: 'Hole', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all'], 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', readOnly: true, recompute: true }, ] return [] } const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined }) const clonePropertyValue = (value: PropertyValue): PropertyValue => { if (Array.isArray(value)) return [...value] if (!value || typeof value !== 'object') return value if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } } if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) } if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined } return { ...value } } 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, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined, }) const collectDependencyEdges = (document: Pick): DependencyEdge[] => { const objectIds = new Set(document.objects.map((object) => object.id)) const edges: DependencyEdge[] = [] for (const object of document.objects) { for (const property of object.properties) { if (property.type === 'App::PropertyLink' && typeof property.value === 'string' && objectIds.has(property.value)) edges.push({ sourceId: object.id, targetId: property.value, relation: 'link', propertyName: property.name }) if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId }) if (property.expression) { for (const reference of expressionReferences(property.expression)) { const separator = reference.lastIndexOf('.') const targetId = separator > 0 ? reference.slice(0, separator) : '' if (objectIds.has(targetId)) edges.push({ sourceId: object.id, targetId, relation: 'expression', propertyName: property.name, reference }) } } } for (const external of object.sketch?.externalGeometry ?? []) { if (objectIds.has(external.source.objectId)) edges.push({ sourceId: object.id, targetId: external.source.objectId, relation: 'topo-ref', propertyName: `ExternalGeometry:${external.id}`, reference: external.source.persistentId }) } } return edges } const expressionReferences = (expression: string): string[] => { try { return evaluateQuantityExpression(expression).references } catch { const references = new Set() for (const match of expression.matchAll(/[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*/g)) references.add(match[0]) return [...references] } } 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 } document.dependencies = collectDependencyEdges(document) document.recompute = createRecomputeSnapshot(objects.map((object) => object.id)) return document } const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', '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 implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch']) const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape']) const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'mirrored', 'multi-transform', 'linear-pattern', 'polar-pattern', 'hole']) const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution']) const shapeTypeIds = new Set([ 'Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'Part::Feature', 'PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::Mirrored', 'PartDesign::MultiTransform', 'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole', ]) 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' }, revolution: { label: 'Revolution', detail: 'Angle 360 deg' }, fillet: { label: 'Fillet', detail: 'Radius 3 mm' }, chamfer: { label: 'Chamfer', detail: 'Length 2 mm' }, mirrored: { label: 'Mirrored', detail: 'Whole Shape across YZ plane' }, 'multi-transform': { label: 'Multi-transform', detail: 'Ordered whole-shape transformations' }, 'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' }, 'polar-pattern': { label: 'Polar Pattern', detail: '3 occurrences over 360 deg' }, hole: { label: 'Hole', detail: 'Simple 5 mm diameter hole' }, primitive: { label: 'Box', detail: '10 × 10 × 10 mm' }, union: { label: 'Union', detail: 'Boolean fuse' }, cut: { label: 'Cut', detail: 'Boolean difference' }, intersection: { label: 'Intersection', detail: 'Boolean common' }, } const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string, selectedTypeId?: 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 (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' } if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` } if (partCommands.has(commandId) && activeWorkbench !== 'Part') return { id: commandId, status: 'disabled', reason: `Switch to Part to use ${commandId}.` } if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` } if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } if (selectedObjectId && !selectedTypeId) return { id: commandId, status: 'disabled', reason: 'The selected object no longer exists in this document.' } if (commandId === 'solve-sketch' && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a Sketcher sketch before solving constraints.' } if (shapeSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId)) return { id: commandId, status: 'disabled', reason: 'Select a solid or shape-producing feature for this operation.' } if (featureSelectionCommands.has(commandId) && selectedTypeId && !shapeTypeIds.has(selectedTypeId) && selectedTypeId !== 'Sketcher::SketchObject') return { id: commandId, status: 'disabled', reason: 'Select a sketch or solid feature as the Part Design source.' } 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::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) if (property.type === 'App::PropertyInteger' && !Number.isSafeInteger(value)) throw new TypeError(`${property.label} requires an integer value.`) if (property.name === 'Occurrences' && ((value as number) < 2 || (value as number) > 100)) throw new RangeError('Occurrences must be between 2 and 100.') if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) if (property.type === 'App::PropertyAngle' && (value as number) > 360) throw new RangeError(`${property.label} must be between 0 and 360 degrees.`) 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::PropertyVector') validateVectorValue(property.label, value) if (property.type === 'App::PropertyPlacement') { if (!value || typeof value !== 'object' || Array.isArray(value) || !('position' in value) || !('rotation' in value)) throw new TypeError(`${property.label} requires a Placement value.`) const placement = value as PlacementValue validateVectorValue(`${property.label} position`, placement.position) validateVectorValue(`${property.label} rotation axis`, placement.rotation?.axis) if (!placement.rotation || typeof placement.rotation.angle !== 'number' || !Number.isFinite(placement.rotation.angle)) throw new TypeError(`${property.label} rotation angle must be finite.`) if (placement.rotation.angle < 0 || placement.rotation.angle > 360) throw new RangeError(`${property.label} rotation angle must be between 0 and 360 degrees.`) const axis = placement.rotation.axis if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`) } if (property.type === 'App::PropertyMultiTransform') validateMultiTransformValue(value) if (property.type === 'App::PropertyLinkList' || property.type === 'App::PropertyStringList') { if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) throw new TypeError(`${property.label} requires a string list.`) if (property.type === 'App::PropertyLinkList') { const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])])) if (value.some((entry) => !knownIds.has(entry))) throw new RangeError(`${property.label} contains a target that does not exist in this document.`) } } 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.type === 'App::PropertyLinkSub') { if (value !== null && (typeof value !== 'object' || Array.isArray(value))) throw new TypeError(`${property.label} requires a TopoRef value.`) if (value !== null) { const topoRef = parseTopoRef(JSON.stringify(value)) if (!document.objects.some((object) => object.id === topoRef.objectId)) 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.') } function validateMultiTransformValue(value: unknown): asserts value is MultiTransformValue { if (!value || typeof value !== 'object' || Array.isArray(value) || !('steps' in value) || !Array.isArray(value.steps)) throw new TypeError('Transformations requires an ordered step list.') if (value.steps.length < 1 || value.steps.length > 6) throw new RangeError('Transformations requires between 1 and 6 steps.') const ids = new Set() let instances = 1 for (const rawStep of value.steps) { if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) throw new TypeError('Each transformation step requires a structured value.') const step = rawStep as Record if (typeof step.id !== 'string' || !step.id.trim() || ids.has(step.id)) throw new RangeError('Transformation step IDs must be non-empty and unique.') ids.add(step.id) if (step.type === 'linear') { if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Linear occurrences must be an integer between 2 and 10.') if (typeof step.length !== 'number' || !Number.isFinite(step.length) || step.length <= 0) throw new RangeError('Linear length must be greater than zero.') if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.direction))) throw new RangeError('Linear direction is invalid.') instances *= step.occurrences as number } else if (step.type === 'polar') { if (!Number.isSafeInteger(step.occurrences) || (step.occurrences as number) < 2 || (step.occurrences as number) > 10) throw new RangeError('Polar occurrences must be an integer between 2 and 10.') if (typeof step.angle !== 'number' || !Number.isFinite(step.angle) || step.angle <= 0 || step.angle > 360) throw new RangeError('Polar angle must be greater than zero and no more than 360 degrees.') if (!['Horizontal', 'Vertical', 'Normal'].includes(String(step.axis))) throw new RangeError('Polar axis is invalid.') instances *= step.occurrences as number } else if (step.type === 'mirrored') { if (!['XY plane', 'XZ plane', 'YZ plane'].includes(String(step.plane))) throw new RangeError('Mirror plane is invalid.') instances *= 2 } else throw new RangeError('Transformation step type is invalid.') if (instances > 100) throw new RangeError('Multi-transform cannot create more than 100 instances.') } } function validateVectorValue(label: string, value: unknown): asserts value is { x: number; y: number; z: number } { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} requires a Vector value.`) const vector = value as Record if (!['x', 'y', 'z'].every((key) => typeof vector[key] === 'number' && Number.isFinite(vector[key]))) throw new TypeError(`${label} components must be finite.`) } const expressionVariables = (document: DocumentSnapshot): ReadonlyMap => { const variables = new Map() for (const object of document.objects) for (const property of object.properties) { if (typeof property.value !== 'number' || !Number.isFinite(property.value)) continue const quantity = property.unit && getUnit(property.unit) ? quantityFromUnit(property.value, property.unit) : quantityFromNumber(property.value) variables.set(`${object.id}.${property.name}`, quantity) } return variables } const expectedExpressionValue = (property: ObjectPropertySnapshot, expression: string, document: DocumentSnapshot) => { const result = evaluateQuantityExpression(expression, expressionVariables(document)) const expectedDimension = quantityDimensionForUnit(property.unit) let quantity = result.value if (quantity.dimension === 'dimensionless' && expectedDimension !== 'dimensionless') { const unit = property.unit ? getUnit(property.unit) : undefined if (!unit) throw new TypeError(`${property.label} requires a registered unit.`) quantity = { value: quantity.value * unit.factor, dimension: expectedDimension } } if (quantity.dimension !== expectedDimension) throw new TypeError(`${property.label} expression has dimension ${quantity.dimension}; expected ${expectedDimension}.`) const value = property.unit ? convertQuantity(quantity, property.unit) : quantity.value if (!Number.isFinite(value)) throw new RangeError(`${property.label} expression result must be finite.`) return { value, references: result.references } } const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable) => { const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) const plan = graph.plan(objectIds) const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id)) const objectStates = { ...recompute.objectStates } for (const objectId of plan.affected) objectStates[objectId] = 'touched' const dirtyObjects = Object.entries(objectStates).filter(([, status]) => status === 'touched' || status === 'error' || status === 'upstream-failed').map(([objectId]) => objectId) document.recompute = { ...recompute, status: 'idle', objectStates, dirtyObjects, order: [], errors: [] } for (const objectId of plan.affected) { const item = document.tree.find((candidate) => candidate.id === objectId) if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty' const object = document.objects.find((candidate) => candidate.id === objectId) const status = object?.properties.find((property) => property.name === 'Status') if (status && status.value !== 'Warning') status.value = 'Touched' } return plan } export function createMockFacade(): BitBybitWebCadFacade { const projectPersistence = createSqliteProjectPersistence() const geometryRuntime = new BitbybitGeometryRuntime() const featureShapes = new Map() 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 recomputeCoordinator = new RecomputeCoordinator( createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes), (documentId) => state.document.id === documentId ? state.document.version : null, ) const releaseFeatureShape = (objectId: string) => { const shape = featureShapes.get(objectId) featureShapes.delete(objectId) if (shape) void geometryRuntime.release(shape) } const clearFeatureShapes = () => { const retained = [...featureShapes.values()] featureShapes.clear() void Promise.all(retained.map((shape) => geometryRuntime.release(shape))) } 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(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: Object.fromEntries(Object.entries(state.task.draft).map(([key, value]) => [key, key === 'transformations' ? clonePropertyValue(value as MultiTransformValue) : value])) } : 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().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const existing = state.document.tree.filter((item) => item.id.startsWith(base)).length return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}` } const appendFeature = (document: DocumentSnapshot, commandId: string, draft: Record = {}): { document: DocumentSnapshot; objectId: string } => { const primitiveType = commandId === 'primitive' && ['Box', 'Cylinder', 'Sphere', 'Cone'].includes(String(draft.primitiveType)) ? String(draft.primitiveType) : 'Box' const definition = commandId === 'primitive' ? { label: primitiveType, detail: primitiveType === 'Box' ? '10 × 10 × 10 mm' : `${primitiveType} primitive` } : 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 })) const isPartObject = partCommands.has(commandId) if (type === 'body' || isPartObject) tree.push({ ...item, children: type === 'body' ? [] : undefined }) else { const body = tree.find((entry) => entry.type === 'body') if (body) body.children = [...(body.children || []), objectId] tree.push(item) } const objectSnapshot = createObjectSnapshot(item) const sourceId = typeof draft.source === 'string' && document.objects.some((object) => object.id === draft.source) ? draft.source : '' const sourceObject = sourceId ? document.objects.find((object) => object.id === sourceId) : undefined objectSnapshot.properties = objectSnapshot.properties.map((property) => { if (!property.recompute) return property const candidate = draft[property.name.toLowerCase()] if (property.type === 'App::PropertyLink' && typeof candidate === 'string' && candidate.length > 0) return { ...property, value: candidate } if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id } if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id } if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate } if (property.type === 'App::PropertyMultiTransform' && candidate && typeof candidate === 'object') return { ...property, value: clonePropertyValue(candidate as MultiTransformValue) } if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate } if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate } return property }) if (isPartObject && commandId !== 'primitive') { objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property) } for (const property of objectSnapshot.properties.filter((candidate) => candidate.recompute && !candidate.readOnly)) validatePropertyValue(document, property, property.value) if (commandId === 'linear-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Length')?.value) <= 0) throw new RangeError('Linear pattern length must be greater than zero.') if (commandId === 'polar-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Angle')?.value) <= 0) throw new RangeError('Polar pattern angle must be greater than zero.') if (commandId === 'hole') { if (Number(objectSnapshot.properties.find((property) => property.name === 'Diameter')?.value) <= 0) throw new RangeError('Hole diameter must be greater than zero.') if (Number(objectSnapshot.properties.find((property) => property.name === 'Depth')?.value) <= 0) throw new RangeError('Hole depth must be greater than zero.') } const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value) })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot] const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined if (tipObject) { const tip = tipObject.properties.find((property) => property.name === 'Tip') if (tip) tip.value = objectId } const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects } nextDocument.dependencies = collectDependencyEdges(nextDocument) nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0) markDocumentTouched(nextDocument, tipObject ? [objectId, tipObject.id] : [objectId]) return { document: nextDocument, 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: clonePropertyValue(value), expression: undefined, expressionError: undefined } const treeItem = document.tree.find((item) => item.id === objectId) if (propertyName === 'Label' && treeItem) treeItem.label = String(value) document.dependencies = collectDependencyEdges(document) if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) document.version += 1 document.dirty = true commit({ ...state, document }) notify(`${sourceProperty.label} updated`) } const resolveTopologyReference = (input: ResolveTopologyReferenceInput) => { const document = cloneDocumentSnapshot(state.document) const resolved = resolveDocumentTopologyReference(document, input) document.dependencies = collectDependencyEdges(document) markDocumentTouched(document, [input.ownerObjectId]) document.version += 1 document.dirty = true const diagnostics = state.diagnostics.filter((diagnostic) => diagnostic.topologyRepair?.ownerObjectId !== input.ownerObjectId || diagnostic.topologyRepair.referenceName !== input.referenceName) commit({ ...state, document, diagnostics }) notify(`${input.referenceName} topology reference replaced`) return { ...resolved, candidates: resolved.candidates ? [...resolved.candidates] : undefined } } const setExpression = ({ objectId, propertyName, expression }: SetExpressionInput) => { const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) 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] if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`) if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) const evaluated = expectedExpressionValue(sourceProperty, expression, state.document) const references = expressionReferences(expression) const variables = expressionVariables(state.document) for (const reference of references) if (!variables.has(reference)) throw new ReferenceError(`Unknown expression reference: ${reference}.`) const document = cloneDocumentSnapshot(state.document) const property = document.objects[objectIndex].properties[propertyIndex] property.value = evaluated.value property.expression = expression.trim() property.expressionError = undefined document.dependencies = collectDependencyEdges(document) if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) document.version += 1 document.dirty = true commit({ ...state, document }) notify(`${sourceProperty.label} expression updated`) } const recomputeDocument = (): RecomputeResult => { const document = cloneDocumentSnapshot(state.document) const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id)) const dirtyIds = recompute.dirtyObjects.length > 0 ? recompute.dirtyObjects : document.objects.filter((object) => object.properties.some((property) => property.recompute && property.expressionError)).map((object) => object.id) const plan = graph.plan(dirtyIds) const generation = recompute.generation + 1 const errors: RecomputeResult['errors'] = [] const objectStates = { ...recompute.objectStates } for (const objectId of plan.affected) objectStates[objectId] = 'recomputing' for (const cycle of plan.cycles) { const message = `Dependency cycle: ${cycle.join(' -> ')}` for (const objectId of cycle) { objectStates[objectId] = 'error'; errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) } } for (const objectId of plan.order) { const dependencies = graph.dependenciesOf(objectId) if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')) { objectStates[objectId] = 'upstream-failed' } else if (dependencies.some((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed')) { objectStates[objectId] = 'upstream-suppressed' } else { const object = document.objects.find((candidate) => candidate.id === objectId) objectStates[objectId] = object?.properties.some((property) => property.name === 'Suppressed' && property.value === true) ? 'suppressed' : 'up-to-date' } if (objectStates[objectId] === 'suppressed' || objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId) const item = document.tree.find((candidate) => candidate.id === objectId) if (item && item.state !== 'readonly') item.state = objectStates[objectId] === 'upstream-failed' ? 'warning' : item.type === 'body' ? 'active' : 'valid' const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') if (status) status.value = objectStates[objectId] === 'suppressed' ? 'Suppressed' : objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning' } 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 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 } } const recomputeDocumentAsync = async (options: RecomputeExecutionOptions = {}) => { const source = cloneDocumentSnapshot(state.document) const result = await recomputeCoordinator.run(source, options) if ((result.status !== 'completed' && result.status !== 'failed') || state.document.id !== source.id || state.document.version !== source.version) return result const document = cloneDocumentSnapshot(state.document) const updates = new Map(result.objectUpdates.map((object) => [object.id, object])) document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object) const topologyMigration = migrateDocumentTopologyReferences(document, result.objectUpdates.map((object) => object.id)) document.dependencies = collectDependencyEdges(document) for (const objectId of result.affected) { if (result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId) const item = document.tree.find((candidate) => candidate.id === objectId) if (!item || item.state === 'readonly') continue if (result.objectStates[objectId] === 'up-to-date' || result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') item.state = item.type === 'body' ? 'active' : 'valid' else if (result.objectStates[objectId] === 'error' || result.objectStates[objectId] === 'upstream-failed') item.state = 'warning' const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') if (status) status.value = result.objectStates[objectId] === 'suppressed' ? 'Suppressed' : result.objectStates[objectId] === 'upstream-suppressed' ? 'Upstream suppressed' : result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning' } document.recompute = { generation: result.generation, status: result.status, objectStates: result.objectStates, dirtyObjects: [...new Set([...(source.recompute?.dirtyObjects ?? []).filter((objectId) => !result.affected.includes(objectId)), ...result.dirtyObjects])], order: result.order, errors: result.errors, } for (const objectId of topologyMigration.changedOwnerIds) { document.recompute.objectStates[objectId] = 'touched' if (!document.recompute.dirtyObjects.includes(objectId)) document.recompute.dirtyObjects.push(objectId) const item = document.tree.find((candidate) => candidate.id === objectId) if (item && item.state !== 'readonly') item.state = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'warning' : 'dirty' const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') if (status) status.value = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'Topology reference requires repair' : 'Touched' } const nextDiagnostics = buildRecomputeDiagnostics({ document, generation: result.generation, affected: result.affected, objectStates: result.objectStates, errors: result.errors }) const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => { const sourceTopology = document.objects.find((object) => object.id === issue.sourceObjectId)?.topology const candidates = issue.candidates?.length ? issue.candidates : sourceTopology?.entries.filter((entry) => entry.ref.kind === issue.kind && entry.ref.status !== 'deleted').map((entry) => entry.ref.persistentId) ?? [] return { id: `topology:${document.id}:${result.generation}:${issue.ownerObjectId}:${issue.referenceName}:${index}`, source: 'geometry', severity: issue.status === 'deleted' ? 'error' : 'warning', code: issue.status === 'deleted' ? 'TOPOLOGY_REFERENCE_DELETED' : 'TOPOLOGY_REFERENCE_AMBIGUOUS', message: issue.status === 'deleted' ? `${issue.referenceName} no longer resolves on ${issue.sourceObjectId}; select a replacement subshape.` : `${issue.referenceName} resolves to multiple subshapes on ${issue.sourceObjectId}: ${(issue.candidates ?? []).join(', ')}.`, objectId: issue.ownerObjectId, documentId: document.id, documentVersion: document.version, generation: result.generation, rootCauseObjectId: issue.sourceObjectId, dependencyPath: [issue.ownerObjectId, issue.sourceObjectId], topologyRepair: candidates.length > 0 ? { ownerObjectId: issue.ownerObjectId, referenceName: issue.referenceName, candidates: [...new Set(candidates)] } : undefined, repairActions: [ { id: 'select-object', label: 'Select reference owner', targetObjectId: issue.ownerObjectId, enabled: true }, { id: 'recompute-root', label: 'Recompute after replacing reference', targetObjectId: issue.ownerObjectId, enabled: false, reason: 'Select one current subshape and replace the ambiguous or deleted reference first.' }, ], }}) const replaced = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics) const topologyOwners = new Set(topologyMigration.issues.map((issue) => issue.ownerObjectId)) const diagnostics = [...replaced.filter((diagnostic) => !diagnostic.code.startsWith('TOPOLOGY_REFERENCE_') || diagnostic.documentId !== document.id || !diagnostic.objectId || !topologyOwners.has(diagnostic.objectId)), ...topologyDiagnostics] 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 => { 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) if (!loaded) return null clearFeatureShapes() const document = cloneDocumentSnapshot(loaded) commit({ ...state, document, selectedObjectId: '', task: null }) await recomputeDocumentAsync({ dirtyObjectIds: document.objects.map((object) => object.id) }) notify(`Loaded ${document.label}`) return getState().document } const getSketch = (objectId: string) => { const object = state.document.objects.find((candidate) => candidate.id === objectId) return object?.sketch ? cloneSketch(object.sketch) : null } const updateSketch = (objectId: string, update: (sketch: SketchSnapshot) => void) => { const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) const source = state.document.objects[objectIndex] if (!source.sketch) throw new TypeError(`${objectId} is not a Sketcher object.`) const document = cloneDocumentSnapshot(state.document) const sketch = document.objects[objectIndex].sketch as SketchSnapshot update(sketch) const solved = solveSketch(sketch) document.objects[objectIndex].sketch = solved.snapshot const status = document.objects[objectIndex].properties.find((property) => property.name === 'ConstraintStatus') if (status) status.value = solved.status === 'solved' ? 'Fully constrained' : solved.status === 'under-constrained' ? `Under-constrained (${solved.degreesOfFreedom} DOF)` : solved.status === 'conflicting' ? 'Conflicting constraints' : 'Invalid constraints' document.dependencies = collectDependencyEdges(document) markDocumentTouched(document, [objectId]) document.version += 1 document.dirty = true commit({ ...state, document }) return cloneSketch(solved.snapshot) } const addSketchGeometry = (objectId: string, geometry: SketchGeometry) => updateSketch(objectId, (sketch) => { if (sketch.geometry.some((candidate) => candidate.id === geometry.id)) throw new RangeError(`Sketch geometry already exists: ${geometry.id}`) sketch.geometry.push(cloneSketchGeometry(geometry)) }) const addSketchExternalGeometry = (objectId: string, external: SketchExternalGeometry) => updateSketch(objectId, (sketch) => { if (!external.id.trim()) throw new RangeError('External geometry id is required.') if (sketch.externalGeometry.some((candidate) => candidate.id === external.id)) throw new RangeError(`External geometry already exists: ${external.id}`) if (external.source.objectId === objectId) throw new RangeError('A sketch cannot import external geometry from itself.') if (external.source.status === 'deleted') throw new RangeError('Deleted topology cannot be imported as external geometry.') sketch.externalGeometry.push({ ...external, source: { ...external.source, candidates: external.source.candidates ? [...external.source.candidates] : undefined }, projection: cloneSketchGeometry(external.projection), construction: true }) }) const addSketchConstraint = (objectId: string, constraint: SketchConstraint) => updateSketch(objectId, (sketch) => { if (sketch.constraints.some((candidate) => candidate.id === constraint.id)) throw new RangeError(`Sketch constraint already exists: ${constraint.id}`) sketch.constraints.push(cloneSketchConstraint(constraint)) }) const solveSketchObject = (objectId: string) => { const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) const source = state.document.objects[objectIndex] if (!source.sketch) throw new TypeError(`${objectId} is not a Sketcher object.`) const result = solveSketch(source.sketch) const document = cloneDocumentSnapshot(state.document) document.objects[objectIndex].sketch = result.snapshot const status = document.objects[objectIndex].properties.find((property) => property.name === 'ConstraintStatus') if (status) status.value = result.status === 'solved' ? 'Fully constrained' : result.status === 'under-constrained' ? `Under-constrained (${result.degreesOfFreedom} DOF)` : result.status === 'conflicting' ? 'Conflicting constraints' : 'Invalid constraints' markDocumentTouched(document, [objectId]) document.version += 1 document.dirty = true commit({ ...state, document }) return result } const applyTask = () => { const task = state.task if (!task || task.status !== 'preview') return const result = appendFeature(state.document, task.commandId, task.draft) if (!result.objectId) { state = { ...state, task: { ...task, status: 'completed' } } emitState() return } commit({ ...state, document: result.document, selectedObjectId: result.objectId, task: { ...task, status: 'completed' } }) notify(`${result.document.tree.find((item) => item.id === result.objectId)?.label || featureCommands[task.commandId]?.label || task.commandId} 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 selectedTypeId = state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId 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}`, 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 } emit({ type: 'command.started', commandId, context }) if (commandId === 'new-document') { clearFeatureShapes(); 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}`, 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') }) } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (commandId === 'check-shape') { const objectId = state.selectedObjectId const shape = featureShapes.get(objectId) const reportFailure = (code: string, message: string) => { 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) } if (!shape) reportFailure('SHAPE_NOT_RECOMPUTED', `No valid Shape is cached for ${objectId}; recompute the document first.`) else void geometryRuntime.topology(shape, 0.05).then((topology) => { 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}`, 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) }).catch((error: unknown) => reportFailure('SHAPE_CHECK_FAILED', error instanceof Error ? error.message : String(error))) } else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') } else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null }) else if (featureCommands[commandId]) beginTask(commandId, partCommands.has(commandId) && commandId !== 'primitive' ? { source: state.selectedObjectId || null, base: state.selectedObjectId || '', tool: '' } : { 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 })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, 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), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), 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), mirror: (input) => geometryRuntime.mirror(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}`, 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 }