Files
Web_FreeCAD_Bitbybit/src/facade/mockFacade.ts

548 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest'
import type {
BitBybitWebCadFacade,
CommandState,
DocumentSnapshot,
Diagnostic,
ExecuteCommandInput,
FacadeEvent,
FacadeListener,
FacadeRequestContext,
FacadeState,
ModelTreeItem,
DocumentObjectSnapshot,
ObjectPropertySnapshot,
PropertyValue,
SetPropertyInput,
SetExpressionInput,
RecomputeResult,
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, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd'
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.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('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: '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.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()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined })
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 })), sketch: object.sketch ? cloneSketch(object.sketch) : 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<DocumentSnapshot, 'objects'>): 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.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 })
}
}
}
}
return edges
}
const expressionReferences = (expression: string): string[] => {
try {
return evaluateQuantityExpression(expression).references
} catch {
const references = new Set<string>()
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', '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', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
const featureCommands: Record<string, { label: string; detail: string }> = {
'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' },
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): 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.' }
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') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
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::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.')
}
const expressionVariables = (document: DocumentSnapshot): ReadonlyMap<string, Quantity> => {
const variables = new Map<string, Quantity>()
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<string>) => {
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<string, ShapeHandle>()
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 clearFeatureShapes = () => {
const retained = [...featureShapes.values()]
featureShapes.clear()
void Promise.all(retained.map((shape) => geometryRuntime.release(shape)))
}
const listeners = new Set<FacadeListener>()
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<string, unknown> = {}) => { 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, draft: Record<string, unknown> = {}): { 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)
if (commandId === 'primitive') {
objectSnapshot.properties = objectSnapshot.properties.map((property) => {
const candidate = draft[property.name.toLowerCase()]
return typeof candidate === 'number' && Number.isFinite(candidate) ? { ...property, value: candidate } : property
})
}
if (isPartObject && commandId !== 'primitive') {
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
}
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
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)
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, 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 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 }) }
}
if (errors.length === 0) {
for (const objectId of plan.order) {
objectStates[objectId] = 'up-to-date'
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item?.state === 'dirty') item.state = item.type === 'body' ? 'active' : 'valid'
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
if (status?.value === 'Touched') status.value = 'Valid'
}
} else {
for (const objectId of plan.affected) if (objectStates[objectId] !== 'error') objectStates[objectId] = 'upstream-failed'
for (const objectId of plan.affected) {
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'active') item.state = '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
state = { ...state, document }
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)
for (const objectId of result.affected) {
const item = document.tree.find((candidate) => candidate.id === objectId)
if (!item || item.state === 'readonly') continue
if (result.objectStates[objectId] === 'up-to-date') 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] === 'up-to-date' ? 'Valid' : 'Warning'
}
document.recompute = {
generation: result.generation,
status: result.status,
objectStates: result.objectStates,
dirtyObjects: result.dirtyObjects,
order: result.order,
errors: result.errors,
}
state = { ...state, document }
if (document.dirty) autosave.schedule(document)
emitState()
return result
}
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'
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({ ...geometry } as SketchGeometry)
})
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({ ...constraint } as SketchConstraint)
})
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 status = commandState(commandId, state.activeWorkbench, state.selectedObjectId)
if (status.status === 'disabled') {
const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
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}`, 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 (commandId === 'check-shape') {
const objectId = state.selectedObjectId
const shape = featureShapes.get(objectId)
const reportFailure = (code: string, message: string) => {
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message, objectId, requestId }
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.mesh(shape, 0.05).then((mesh) => {
if (mesh.indices.length === 0 || mesh.positions.length === 0) throw new Error('Shape mesh is empty.')
notify(`Shape check passed: ${mesh.subshapes?.length || 0} subshapes`)
}).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, { 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 } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, 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, 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), 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(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
}
void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error) }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) })
return facade
}