Files
Web_FreeCAD_Bitbybit/src/facade/recomputeEngine.ts

400 lines
22 KiB
TypeScript

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<RecomputeNodeResult>
export type RecomputeGeometryRuntime = {
capabilities(): { status: string }
createBox(input: CreateBoxInput): Promise<ShapeHandle>
createCylinder(input: CreateCylinderInput): Promise<ShapeHandle>
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle>
applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle>
union(input: BooleanUnionInput): Promise<ShapeHandle>
cut(input: BooleanCutInput): Promise<ShapeHandle>
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
revolution(input: RevolutionInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
release(shape: ShapeHandle): Promise<void>
}
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<string, RecomputeState>
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<RecomputeExecutionResult> {
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<string, RecomputeState> = {
...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<typeof candidate, { type: 'line' }> => 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<string, ShapeHandle> = 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))
}
}