819 lines
72 KiB
TypeScript
819 lines
72 KiB
TypeScript
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<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.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<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', '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<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' },
|
||
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<string>()
|
||
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<string, unknown>
|
||
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<string, unknown>
|
||
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<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 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<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(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<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().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<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)
|
||
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<DiagnosticRepairResult> => {
|
||
const diagnostic = state.diagnostics.find((candidate) => candidate.id === diagnosticId && !candidate.resolved)
|
||
const action = diagnostic?.repairActions?.find((candidate) => candidate.id === actionId)
|
||
if (!diagnostic || !action || !action.enabled) return { diagnosticId, actionId, status: 'unavailable', message: action?.reason || 'The diagnostic or repair action is no longer available.' }
|
||
if (!state.document.objects.some((object) => object.id === action.targetObjectId)) return { diagnosticId, actionId, status: 'unavailable', message: 'The repair target no longer exists in the active document.' }
|
||
if (actionId === 'select-object') {
|
||
select(action.targetObjectId)
|
||
notify(`Selected diagnostic root ${action.targetObjectId}`)
|
||
return { diagnosticId, actionId, status: 'completed', message: 'Root object selected.' }
|
||
}
|
||
if (actionId === 'suppress-root') setProperty({ objectId: action.targetObjectId, propertyName: 'Suppressed', value: true })
|
||
const result = await recomputeDocumentAsync({ dirtyObjectIds: [...new Set([...(state.document.recompute?.dirtyObjects ?? []), action.targetObjectId])] })
|
||
const completed = result.status === 'completed'
|
||
notify(completed ? 'Diagnostic branch repaired' : 'Diagnostic repair did not complete')
|
||
return { diagnosticId, actionId, status: completed ? 'completed' : 'failed', message: completed ? 'The affected dependency branch recomputed successfully.' : `Recompute finished with status ${result.status}.` }
|
||
}
|
||
const loadDocument = async (documentId: string) => {
|
||
recomputeCoordinator.cancel()
|
||
const loaded = await projectPersistence.load(documentId)
|
||
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
|
||
}
|