P3/P4: execute OCCT features and export shapes
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user