import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { cloneSketch, solveSketch } from './sketcher' import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' export type RecomputeExecutionError = { objectId: string code: string message: string } export type RecomputeNodeContext = { documentId: string documentVersion: number generation: number signal: AbortSignal } export type RecomputeNodeResult = { status: 'success' | 'suppressed' | 'failed' errors?: RecomputeExecutionError[] updatedObject?: DocumentObjectSnapshot } export type RecomputeNodeExecutor = ( object: DocumentObjectSnapshot, document: DocumentSnapshot, context: RecomputeNodeContext, ) => Promise export type RecomputeGeometryRuntime = { capabilities(): { status: string } createBox(input: CreateBoxInput): Promise createCylinder(input: CreateCylinderInput): Promise createSphere(input: CreateSphereInput): Promise createCone(input: CreateConeInput): Promise applyPlacement(input: ApplyPlacementInput): Promise union(input: BooleanUnionInput): Promise cut(input: BooleanCutInput): Promise intersection(input: BooleanIntersectionInput): Promise pad(input: PadInput): Promise pocket(input: PocketInput): Promise revolution(input: RevolutionInput): Promise fillet(input: FilletInput): Promise chamfer(input: ChamferInput): Promise release(shape: ShapeHandle): Promise } export type RecomputeProgress = { generation: number documentVersion: number objectId: string completed: number total: number state: RecomputeState } export type RecomputeExecutionOptions = { dirtyObjectIds?: string[] onProgress?: (progress: RecomputeProgress) => void } export type RecomputeExecutionResult = { generation: number documentVersion: number status: RecomputeExecutionStatus affected: string[] order: string[] levels: string[][] completed: string[] suppressed: string[] failed: string[] skipped: string[] dirtyObjects: string[] objectStates: Record objectUpdates: DocumentObjectSnapshot[] errors: RecomputeExecutionError[] } const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError' export class RecomputeCoordinator { private active: { generation: number; controller: AbortController } | null = null private generation = 0 constructor( private readonly executeNode: RecomputeNodeExecutor, private readonly currentDocumentVersion: (documentId: string) => number | null, ) {} cancel() { this.active?.controller.abort() } async run(document: DocumentSnapshot, options: RecomputeExecutionOptions = {}): Promise { this.active?.controller.abort() const controller = new AbortController() const generation = Math.max(this.generation, document.recompute?.generation ?? 0) + 1 this.generation = generation this.active = { generation, controller } const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) const dirtyObjectIds = options.dirtyObjectIds ?? document.recompute?.dirtyObjects ?? [] const plan = graph.plan(dirtyObjectIds) const objectStates: Record = { ...Object.fromEntries(document.objects.map((object) => [object.id, 'up-to-date' as const])), ...(document.recompute?.objectStates ?? {}), } for (const objectId of plan.affected) objectStates[objectId] = 'recomputing' const completed: string[] = [] const suppressed: string[] = [] const failed: string[] = [] const skipped: string[] = [] const errors: RecomputeExecutionError[] = [] const objectUpdates: DocumentObjectSnapshot[] = [] const objectById = new Map(document.objects.map((object) => [object.id, object])) for (const cycle of plan.cycles) { const message = `Dependency cycle: ${cycle.join(' -> ')}` for (const objectId of cycle) { objectStates[objectId] = 'error' failed.push(objectId) errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message }) } } const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => { if (status === 'cancelled' || status === 'stale') { for (const objectId of plan.affected) { if (objectStates[objectId] !== 'up-to-date' && objectStates[objectId] !== 'suppressed' && objectStates[objectId] !== 'upstream-suppressed') objectStates[objectId] = 'touched' } } if (this.active?.generation === generation) this.active = null return { generation, documentVersion: document.version, status, affected: plan.affected, order: plan.order, levels: plan.levels, completed, suppressed, failed, skipped, dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] === 'touched' || objectStates[objectId] === 'recomputing' || objectStates[objectId] === 'error' || objectStates[objectId] === 'upstream-failed'), objectStates, objectUpdates, errors, } } let processed = 0 for (const level of plan.levels) { if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled') if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale') // Nodes in a level have no dependencies on each other. Execute them together, // then merge outcomes in plan order so persistence and UI events stay deterministic. const outcomes = await Promise.all(level.map(async (objectId) => { const dependencies = graph.dependenciesOf(objectId) const failedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed') if (failedDependency) return { objectId, state: 'upstream-failed' as const, error: { objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` } } const suppressedDependency = dependencies.find((dependencyId) => objectStates[dependencyId] === 'suppressed' || objectStates[dependencyId] === 'upstream-suppressed') if (suppressedDependency) return { objectId, state: 'upstream-suppressed' as const } const object = objectById.get(objectId) if (!object) return { objectId, state: 'error' as const, error: { objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` } } try { const result = await this.executeNode(object, document, { documentId: document.id, documentVersion: document.version, generation, signal: controller.signal, }) if (result.status === 'failed') { return { objectId, state: 'error' as const, errors: result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }], } } if (result.status === 'suppressed') return { objectId, state: 'suppressed' as const } return { objectId, state: 'up-to-date' as const, updatedObject: result.updatedObject } } catch (error) { if (controller.signal.aborted || isAbortError(error)) return { objectId, state: 'cancelled' as const } return { objectId, state: 'error' as const, error: { objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) } } } })) if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled') if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale') for (const outcome of outcomes) { if (outcome.state === 'cancelled') return terminalResult('cancelled') objectStates[outcome.objectId] = outcome.state processed += 1 if (outcome.state === 'up-to-date') { completed.push(outcome.objectId) if (outcome.updatedObject) objectUpdates.push(outcome.updatedObject) } else if (outcome.state === 'suppressed') { suppressed.push(outcome.objectId) skipped.push(outcome.objectId) } else if (outcome.state === 'upstream-suppressed') { skipped.push(outcome.objectId) } else if (outcome.state === 'upstream-failed') { skipped.push(outcome.objectId) if (outcome.error) errors.push(outcome.error) } else { failed.push(outcome.objectId) if ('errors' in outcome && outcome.errors) errors.push(...outcome.errors) else if (outcome.error) errors.push(outcome.error) } options.onProgress?.({ generation, documentVersion: document.version, objectId: outcome.objectId, completed: processed, total: plan.order.length, state: outcome.state }) } } if (this.active?.generation === generation) this.active = null return terminalResult(errors.length > 0 ? 'failed' : 'completed') } } export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => { if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError') if (object.properties.some((property) => property.name === 'Suppressed' && property.value === true)) return { status: 'suppressed' } const expressionError = object.properties.find((property) => property.expressionError) if (expressionError) { return { status: 'failed', errors: [{ objectId: object.id, code: 'EXPRESSION_ERROR', message: expressionError.expressionError as string }], } } if (!object.sketch) return { status: 'success' } const solved = solveSketch(object.sketch) if (solved.status === 'conflicting' || solved.status === 'invalid') { return { status: 'failed', errors: solved.diagnostics.map((diagnostic) => ({ objectId: object.id, code: diagnostic.code, message: diagnostic.message })), } } const updatedObject: DocumentObjectSnapshot = { ...object, properties: object.properties.map((property) => property.name === 'ConstraintStatus' ? { ...property, value: solved.status === 'solved' ? 'Fully constrained' : `Under-constrained (${solved.degreesOfFreedom} DOF)` } : { ...property, options: property.options ? [...property.options] : undefined }), sketch: cloneSketch(solved.snapshot), } return { status: 'success', updatedObject } } const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => { const value = propertyValue(object, name) return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined } const pointsEqual = (left: [number, number, number], right: [number, number, number], tolerance = 1e-7) => left.every((value, index) => Math.abs(value - right[index]) <= tolerance) const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: PlanarProfile; code?: string; message?: string } => { if (!sketch) return { code: 'PROFILE_MISSING', message: 'Feature profile does not reference a Sketcher object.' } const geometry = sketch.geometry.filter((candidate) => !candidate.construction) if (geometry.some((candidate) => candidate.type !== 'line')) return { code: 'PROFILE_UNSUPPORTED', message: 'OCCT feature recompute currently requires a closed line-loop sketch profile.' } const segments = geometry.filter((candidate): candidate is Extract => candidate.type === 'line') if (segments.length < 3) return { code: 'PROFILE_OPEN', message: 'Feature profile requires at least three connected line segments.' } const first = segments[0] const ring: [number, number, number][] = [[first.start.x, first.start.y, 0]] let current: [number, number, number] = [first.end.x, first.end.y, 0] const remaining = segments.slice(1) while (remaining.length > 0 && !pointsEqual(current, ring[0])) { const index = remaining.findIndex((segment) => pointsEqual([segment.start.x, segment.start.y, 0], current) || pointsEqual([segment.end.x, segment.end.y, 0], current)) if (index < 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form a closed loop.' } const segment = remaining.splice(index, 1)[0] if (pointsEqual([segment.start.x, segment.start.y, 0], current)) current = [segment.end.x, segment.end.y, 0] else current = [segment.start.x, segment.start.y, 0] ring.push(current) } if (!pointsEqual(current, ring[0]) || remaining.length > 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form one closed loop.' } ring.pop() return { profile: { outer: ring } } } const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] }) /** * Adds real OCCT feature execution without putting transient ShapeHandles in the * persisted document snapshot. The map is deliberately owned by the Facade and * keeps the last successful shape when a later feature fails. */ export const createFacadeGeometryRecomputeExecutor = ( geometry: RecomputeGeometryRuntime, shapes: Map = new Map(), ): RecomputeNodeExecutor => async (object, document, context) => { const base = await executeFacadeRecomputeNode(object, document, context) if (base.status === 'suppressed') { const previous = shapes.get(object.id) shapes.delete(object.id) if (previous) await geometry.release(previous) return base } if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern'].includes(object.typeId)) return base const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution' const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined } if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.') if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError') const numberProperty = (name: string, fallback: number) => { const value = propertyValue(object, name) return typeof value === 'number' && Number.isFinite(value) ? value : fallback } const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion } try { let result: ShapeHandle if (object.typeId === 'Part::Box') { result = await geometry.createBox({ ...documentContext, width: numberProperty('Width', 10), length: numberProperty('Length', 10), height: numberProperty('Height', 10) }) } else if (object.typeId === 'Part::Cylinder') { result = await geometry.createCylinder({ ...documentContext, radius: numberProperty('Radius', 5), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) }) } else if (object.typeId === 'Part::Sphere') { result = await geometry.createSphere({ ...documentContext, radius: numberProperty('Radius', 5) }) } else if (object.typeId === 'Part::Cone') { result = await geometry.createCone({ ...documentContext, radius1: numberProperty('Radius1', 5), radius2: numberProperty('Radius2', 0), height: numberProperty('Height', 10), angle: numberProperty('Angle', 360) }) } else if (object.typeId === 'Part::Fuse' || object.typeId === 'Part::Cut' || object.typeId === 'Part::Common') { const baseObject = linkedObject(object, 'Base', document) const toolObject = linkedObject(object, 'Tool', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined const toolShape = toolObject ? shapes.get(toolObject.id) : undefined if (!baseShape || !toolShape) return geometryFailure(object.id, 'BOOLEAN_SHAPE_MISSING', 'Boolean operation requires recomputed Base and Tool shapes.') if (object.typeId === 'Part::Fuse') result = await geometry.union({ ...documentContext, shapes: [baseShape, toolShape] }) else if (object.typeId === 'Part::Cut') result = await geometry.cut({ ...documentContext, base: baseShape, tools: [toolShape] }) else result = await geometry.intersection({ ...documentContext, shapes: [baseShape, toolShape] }) } else if (object.typeId === 'PartDesign::Pad') { result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true }) } else if (object.typeId === 'PartDesign::Pocket') { const pocketType = propertyValue(object, 'Type') if (pocketType === 'Up to face') return geometryFailure(object.id, 'UP_TO_FACE_UNSUPPORTED', 'Pocket Up to face requires a persistent support face and is not implemented yet.') const baseObject = linkedObject(object, 'Base', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.') result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' }) } else if (object.typeId === 'PartDesign::Revolution') { const angle = numberProperty('Angle', 360) const reversed = propertyValue(object, 'Reversed') === true result = await geometry.revolution({ ...documentContext, profile: profile.profile as PlanarProfile, angle, axisOrigin: [0, 0, 0], axisDirection: reversed ? [0, -1, 0] : [0, 1, 0], }) } else if (object.typeId === 'PartDesign::Fillet') { const baseObject = linkedObject(object, 'Base', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.') result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) }) } else if (object.typeId === 'PartDesign::LinearPattern') { const baseObject = linkedObject(object, 'Base', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Linear pattern base has no valid recomputed Shape.') const occurrences = numberProperty('Occurrences', 2) if (!Number.isSafeInteger(occurrences) || occurrences < 2 || occurrences > 100) return geometryFailure(object.id, 'PATTERN_OCCURRENCES_INVALID', 'Linear pattern occurrences must be an integer between 2 and 100.') const length = numberProperty('Length', 20) if (!(length > 0)) return geometryFailure(object.id, 'PATTERN_LENGTH_INVALID', 'Linear pattern length must be greater than zero.') const direction = propertyValue(object, 'Direction') const axis: [number, number, number] = direction === 'Vertical' ? [0, 1, 0] : direction === 'Normal' ? [0, 0, 1] : [1, 0, 0] const copies: ShapeHandle[] = [] try { for (let index = 1; index < occurrences; index += 1) { const offset = length * index / (occurrences - 1) copies.push(await geometry.applyPlacement({ ...documentContext, shape: baseShape, placement: { translation: [axis[0] * offset, axis[1] * offset, axis[2] * offset], rotationAxis: [0, 0, 1], rotationAngle: 0 } })) } result = await geometry.union({ ...documentContext, shapes: [baseShape, ...copies] }) } finally { await Promise.allSettled(copies.map((copy) => geometry.release(copy))) } } else { const baseObject = linkedObject(object, 'Base', document) const baseShape = baseObject ? shapes.get(baseObject.id) : undefined if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Chamfer base has no valid recomputed Shape.') result = await geometry.chamfer({ ...documentContext, base: baseShape, distance: numberProperty('Distance', 1) }) } if (context.signal.aborted) { await geometry.release(result) throw new DOMException('Recompute cancelled.', 'AbortError') } const previous = shapes.get(object.id) shapes.set(object.id, result) if (previous && previous.id !== result.id) await geometry.release(previous) return base } catch (error) { if (context.signal.aborted || isAbortError(error)) throw error return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error)) } }