P3/P4: execute OCCT features and export shapes

This commit is contained in:
2026-08-02 18:35:04 -04:00
parent 709cfea1eb
commit 66c11f6890
9 changed files with 251 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { DocumentObjectSnapshot, DocumentSnapshot } from './types'
import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -29,6 +29,15 @@ export type RecomputeNodeExecutor = (
context: RecomputeNodeContext,
) => Promise<RecomputeNodeResult>
export type RecomputeGeometryRuntime = {
capabilities(): { status: string }
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
fillet(input: FilletInput): Promise<ShapeHandle>
chamfer(input: ChamferInput): Promise<ShapeHandle>
release(shape: ShapeHandle): Promise<void>
}
export type RecomputeProgress = {
generation: number
documentVersion: number
@@ -217,3 +226,97 @@ export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object,
}
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 === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket'
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 === '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::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 {
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))
}
}