400 lines
22 KiB
TypeScript
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))
|
|
}
|
|
}
|