2318 lines
144 KiB
TypeScript
2318 lines
144 KiB
TypeScript
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
|
|
import type { Inputs } from '@bitbybit-dev/occt'
|
|
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateSphereInput, CreateTorusInput, CreateWedgeInput, DraftInput, ExtrudeInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, GeometryFileImport, GrooveInput, LinearFeatureParameters, LoftInput, MeshAsset, MirrorInput, ModeledThreadInput, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, NativeTopologyHistoryRecords, NativeTopologyHistoryStageCaptureResult, PadInput, PipeInput, PlanarProfile, PocketInput, Point3, ProfileClassification, RevolutionInput, ShapeHandle, ShapeMassProperties, ShapeQualityReport, SubshapeRef, SubshapeTopology, ThicknessInput } from './types'
|
|
import { mapNativeOcctHistoryRecords, type NativeOcctHistoryOperation, type NativeOcctHistoryResponse } from './nativeHistoryProvider'
|
|
import { NativeOcctHistoryCoordinator, type NativeOcctHistoryProvider, type NativeOcctHistoryRequest } from './nativeHistoryProtocol'
|
|
import { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence } from './nativeNamingEvidence'
|
|
import { createAnalyticSubshapeRefs, createEdgeSubshapeRefs, createSubshapeRefs, createTopologyAdjacency, createVertexSubshapeRefs, signatureForEdge, signatureForVertex, type AnalyticEdgeInput, type AnalyticFaceInput, type AnalyticVertexInput } from './topologyNaming'
|
|
|
|
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
|
|
type KernelMesh = Inputs.OCCT.DecomposedMeshDto
|
|
type ShapeEntry = { handle: ShapeHandle; reference: KernelShapeReference }
|
|
type KernelReferenceEntry = { count: number; reference: KernelShapeReference }
|
|
type AnalyticTopologyDescription = {
|
|
faces?: AnalyticFaceInput[]
|
|
edges?: AnalyticEdgeInput[]
|
|
vertices?: AnalyticVertexInput[]
|
|
adjacency?: { faceNeighbors?: number[][]; faceEdges?: number[][]; edgeFaces?: number[][]; edgeVertices?: number[][]; vertexEdges?: number[][] }
|
|
}
|
|
|
|
const unavailableCapabilities = (): GeometryCapabilities => ({
|
|
provider: 'Bitbybit OCCT',
|
|
version: '1.1.1',
|
|
status: typeof Worker === 'undefined' ? 'unavailable' : 'idle',
|
|
worker: typeof Worker !== 'undefined',
|
|
wasm: typeof WebAssembly !== 'undefined',
|
|
shapeCount: 0,
|
|
kernelReferenceCount: 0,
|
|
releasedShapeCount: 0,
|
|
peakShapeCount: 0,
|
|
peakKernelReferenceCount: 0,
|
|
reason: typeof Worker === 'undefined' ? 'Web Workers are not available in this runtime.' : undefined,
|
|
})
|
|
|
|
const finitePositive = (value: number, name: string) => {
|
|
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a finite number greater than zero.`)
|
|
}
|
|
|
|
const finiteNonNegative = (value: number, name: string) => {
|
|
if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a finite non-negative number.`)
|
|
}
|
|
|
|
export const MAX_GEOMETRY_IMPORT_TEXT_BYTES = 128 * 1024 * 1024
|
|
|
|
export const collectGeometryImportText = async (chunks: AsyncIterable<Uint8Array>, signal?: AbortSignal, maxBytes = MAX_GEOMETRY_IMPORT_TEXT_BYTES): Promise<string> => {
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError('Geometry import stream maxBytes must be a positive safe integer.')
|
|
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
let total = 0
|
|
let text = ''
|
|
for await (const chunk of chunks) {
|
|
if (signal?.aborted) throw new DOMException('Geometry import stream cancelled.', 'AbortError')
|
|
if (!(chunk instanceof Uint8Array)) throw new TypeError('Geometry import stream chunks must be Uint8Array values.')
|
|
total += chunk.byteLength
|
|
if (total > maxBytes) throw new RangeError(`Geometry import payload exceeds ${maxBytes} bytes.`)
|
|
text += decoder.decode(chunk, { stream: true })
|
|
}
|
|
if (signal?.aborted) throw new DOMException('Geometry import stream cancelled.', 'AbortError')
|
|
text += decoder.decode()
|
|
if (!text.trim()) throw new TypeError('Geometry import text must be non-empty.')
|
|
return text
|
|
}
|
|
|
|
const validateDocumentContext = (input: GeometryDocumentContext) => {
|
|
if (!input.documentId.trim()) throw new RangeError('documentId must be a non-empty string.')
|
|
if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('documentVersion must be a non-negative safe integer.')
|
|
}
|
|
|
|
const validateVector = (value: [number, number, number], name: string, allowZero = true) => {
|
|
if (value.length !== 3 || value.some((coordinate) => !Number.isFinite(coordinate))) throw new RangeError(`${name} must contain three finite coordinates.`)
|
|
if (!allowZero && value.every((coordinate) => coordinate === 0)) throw new RangeError(`${name} must not be the zero vector.`)
|
|
}
|
|
|
|
const validateAngle = (value: number, name: string, allowZero = false) => {
|
|
if (!Number.isFinite(value) || value > 360 || value < (allowZero ? 0 : Number.EPSILON)) throw new RangeError(`${name} must be between ${allowZero ? '0' : '0 (exclusive)'} and 360 degrees.`)
|
|
}
|
|
|
|
export const validateBoxInput = (input: CreateBoxInput) => {
|
|
finitePositive(input.width, 'width')
|
|
finitePositive(input.length, 'length')
|
|
finitePositive(input.height, 'height')
|
|
validateDocumentContext(input)
|
|
const center = input.center ?? [0, 0, 0]
|
|
validateVector(center, 'center')
|
|
}
|
|
|
|
export const validateCylinderInput = (input: CreateCylinderInput) => {
|
|
finitePositive(input.radius, 'radius')
|
|
finitePositive(input.height, 'height')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
|
|
validateAngle(input.angle ?? 360, 'angle')
|
|
validateDocumentContext(input)
|
|
}
|
|
|
|
export const validateSphereInput = (input: CreateSphereInput) => {
|
|
finitePositive(input.radius, 'radius')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
validateDocumentContext(input)
|
|
}
|
|
|
|
export const validateEllipsoidInput = (input: CreateEllipsoidInput) => {
|
|
validateDocumentContext(input)
|
|
finitePositive(input.radius1, 'radius1')
|
|
finitePositive(input.radius2, 'radius2')
|
|
if (input.radius3 !== undefined && (!Number.isFinite(input.radius3) || input.radius3 < 0)) throw new RangeError('radius3 must be finite and non-negative.')
|
|
const angle1 = input.angle1 ?? -90
|
|
const angle2 = input.angle2 ?? 90
|
|
const angle3 = input.angle3 ?? 360
|
|
if (!Number.isFinite(angle1) || angle1 < -90 || angle1 > 90) throw new RangeError('angle1 must be between -90 and 90 degrees.')
|
|
if (!Number.isFinite(angle2) || angle2 < -90 || angle2 > 90 || angle1 >= angle2) throw new RangeError('angle2 must be between -90 and 90 degrees and greater than angle1.')
|
|
if (!Number.isFinite(angle3) || angle3 <= 0 || angle3 > 360) throw new RangeError('angle3 must be greater than zero and no more than 360 degrees.')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
}
|
|
|
|
export const validateConeInput = (input: CreateConeInput) => {
|
|
finiteNonNegative(input.radius1, 'radius1')
|
|
finiteNonNegative(input.radius2, 'radius2')
|
|
if (input.radius1 === 0 && input.radius2 === 0) throw new RangeError('At least one cone radius must be greater than zero.')
|
|
finitePositive(input.height, 'height')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
|
|
validateAngle(input.angle ?? 360, 'angle')
|
|
validateDocumentContext(input)
|
|
}
|
|
|
|
export const validateTorusInput = (input: CreateTorusInput) => {
|
|
finitePositive(input.majorRadius, 'majorRadius')
|
|
finitePositive(input.minorRadius, 'minorRadius')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
|
|
validateAngle(input.angle ?? 360, 'angle')
|
|
validateDocumentContext(input)
|
|
}
|
|
|
|
export const validateHelixInput = (input: CreateHelixInput) => {
|
|
validateDocumentContext(input)
|
|
finitePositive(input.pitch, 'pitch')
|
|
finitePositive(input.height, 'height')
|
|
finitePositive(input.radius, 'radius')
|
|
if (input.height / input.pitch > 10000) throw new RangeError('helix turn count must not exceed 10000.')
|
|
const angle = input.angle ?? 0
|
|
if (!Number.isFinite(angle) || angle <= -89.99999 || angle >= 89.99999) throw new RangeError('angle must be between -89.99999 and 89.99999 degrees (exclusive).')
|
|
const endRadius = input.radius + input.height * Math.tan(angle * Math.PI / 180)
|
|
if (!(endRadius > 0) || !Number.isFinite(endRadius)) throw new RangeError('helix end radius must be finite and greater than zero.')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
validateVector(input.direction ?? [0, 0, 1], 'direction', false)
|
|
if (input.tolerance !== undefined) finitePositive(input.tolerance, 'tolerance')
|
|
}
|
|
|
|
export const validatePrismInput = (input: CreatePrismInput) => {
|
|
validateDocumentContext(input)
|
|
if (!Number.isSafeInteger(input.polygon) || input.polygon < 3 || input.polygon > 10000) throw new RangeError('polygon must be an integer between 3 and 10000.')
|
|
finitePositive(input.circumradius, 'circumradius')
|
|
finitePositive(input.height, 'height')
|
|
const firstAngle = input.firstAngle ?? 0
|
|
const secondAngle = input.secondAngle ?? 0
|
|
if (!Number.isFinite(firstAngle) || firstAngle < -89.99999 || firstAngle > 89.99999) throw new RangeError('firstAngle must be between -89.99999 and 89.99999 degrees.')
|
|
if (!Number.isFinite(secondAngle) || secondAngle < -89.99999 || secondAngle > 89.99999) throw new RangeError('secondAngle must be between -89.99999 and 89.99999 degrees.')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
}
|
|
|
|
export const validateWedgeInput = (input: CreateWedgeInput) => {
|
|
validateDocumentContext(input)
|
|
for (const name of ['xmin', 'ymin', 'zmin', 'z2min', 'x2min', 'xmax', 'ymax', 'zmax', 'z2max', 'x2max'] as const) {
|
|
if (!Number.isFinite(input[name])) throw new RangeError(`${name} must be finite.`)
|
|
}
|
|
if (!(input.xmax - input.xmin > Number.EPSILON)) throw new RangeError('xmax - xmin must be greater than zero.')
|
|
if (!(input.ymax - input.ymin > Number.EPSILON)) throw new RangeError('ymax - ymin must be greater than zero.')
|
|
if (!(input.zmax - input.zmin > Number.EPSILON)) throw new RangeError('zmax - zmin must be greater than zero.')
|
|
if (input.z2max - input.z2min < 0) throw new RangeError('z2max - z2min must not be negative.')
|
|
if (input.x2max - input.x2min < 0) throw new RangeError('x2max - x2min must not be negative.')
|
|
validateVector(input.center ?? [0, 0, 0], 'center')
|
|
}
|
|
|
|
export const validatePlacementInput = (input: ApplyPlacementInput) => {
|
|
validateVector(input.placement.translation, 'translation')
|
|
validateVector(input.placement.rotationAxis, 'rotationAxis', false)
|
|
validateAngle(input.placement.rotationAngle, 'rotationAngle', true)
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.shape)
|
|
}
|
|
|
|
export const validateMirrorInput = (input: MirrorInput) => {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.shape)
|
|
validateVector(input.origin, 'origin')
|
|
validateVector(input.normal, 'normal', false)
|
|
}
|
|
|
|
const validateShapeContext = (input: GeometryDocumentContext, shape: ShapeHandle) => {
|
|
if (shape.documentId !== input.documentId) throw new Error(`Shape belongs to another document: ${shape.id}`)
|
|
if (shape.documentVersion > input.documentVersion) throw new Error(`Shape version is newer than the result context: ${shape.id}`)
|
|
}
|
|
|
|
const validateBooleanShapes = (input: GeometryDocumentContext, shapes: ShapeHandle[], minimum: number) => {
|
|
validateDocumentContext(input)
|
|
if (shapes.length < minimum) throw new RangeError(`Boolean operation requires at least ${minimum} shape${minimum === 1 ? '' : 's'}.`)
|
|
for (const shape of shapes) validateShapeContext(input, shape)
|
|
}
|
|
|
|
export const validateBooleanUnionInput = (input: BooleanUnionInput) => validateBooleanShapes(input, input.shapes, 2)
|
|
|
|
export const validateBooleanCutInput = (input: BooleanCutInput) => {
|
|
validateBooleanShapes(input, [input.base, ...input.tools], 2)
|
|
if (input.tools.length === 0) throw new RangeError('Boolean cut requires at least one tool shape.')
|
|
}
|
|
|
|
export const validateBooleanIntersectionInput = (input: BooleanIntersectionInput) => validateBooleanShapes(input, input.shapes, 2)
|
|
|
|
const validateEdgeFeature = (input: GeometryDocumentContext & { base: ShapeHandle; indexes?: number[]; radius?: number; distance?: number }, value: number, name: string) => {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.base)
|
|
finitePositive(value, name)
|
|
if (input.indexes?.some((index) => !Number.isSafeInteger(index) || index < 0)) throw new RangeError('Edge indexes must be non-negative safe integers.')
|
|
}
|
|
|
|
export const validateFilletInput = (input: FilletInput) => validateEdgeFeature(input, input.radius, 'radius')
|
|
export const validateChamferInput = (input: ChamferInput) => validateEdgeFeature(input, input.distance, 'distance')
|
|
|
|
export const validateDraftInput = (input: DraftInput) => {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.base)
|
|
if (!Number.isFinite(input.angle) || input.angle <= -89.999 || input.angle >= 89.999 || input.angle === 0) throw new RangeError('Draft angle must be finite, non-zero and between -89.999 and 89.999 degrees.')
|
|
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
|
|
validateVector(input.neutralPlaneOrigin ?? [0, 0, 0], 'neutralPlaneOrigin')
|
|
validateVector(input.neutralPlaneDirection ?? [0, 0, 1], 'neutralPlaneDirection', false)
|
|
if (input.indexes?.some((index) => !Number.isSafeInteger(index) || index < 0)) throw new RangeError('Draft face indexes must be non-negative safe integers.')
|
|
}
|
|
|
|
export const validateThicknessInput = (input: ThicknessInput) => {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.base)
|
|
if (!Number.isFinite(input.offset) || input.offset === 0) throw new RangeError('Thickness offset must be finite and non-zero.')
|
|
if (input.removeFaceIndexes?.some((index) => !Number.isSafeInteger(index) || index < 0)) throw new RangeError('Thickness remove-face indexes must be non-negative safe integers.')
|
|
if (input.joinType !== undefined && input.joinType !== 'Arc' && input.joinType !== 'Intersection') throw new RangeError(`Unsupported Thickness join type: ${String(input.joinType)}`)
|
|
}
|
|
|
|
const samePoint = (left: Point3, right: Point3, tolerance = 1e-9) => left.every((coordinate, axis) => Math.abs(coordinate - right[axis]) <= tolerance)
|
|
const subtract = (left: Point3, right: Point3): Point3 => [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
|
|
const cross = (left: Point3, right: Point3): Point3 => [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]
|
|
const dot = (left: Point3, right: Point3) => left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
|
|
const magnitude = (value: Point3) => Math.hypot(value[0], value[1], value[2])
|
|
|
|
const normalizedRing = (ring: Point3[]) => ring.length > 1 && samePoint(ring[0], ring.at(-1) as Point3) ? ring.slice(0, -1) : [...ring]
|
|
|
|
const offsetProfileRing = (ring: Point3[], direction: Point3, distance: number): Point3[] => {
|
|
const points = normalizedRing(ring)
|
|
const directionLength = magnitude(direction)
|
|
const normal = direction.map((coordinate) => coordinate / directionLength) as Point3
|
|
const origin = points[0]
|
|
const firstEdge = subtract(points[1], origin)
|
|
const firstEdgeLength = magnitude(firstEdge)
|
|
const u = firstEdge.map((coordinate) => coordinate / firstEdgeLength) as Point3
|
|
const v = cross(normal, u)
|
|
const projected = points.map((point): Point2 => {
|
|
const relative = subtract(point, origin)
|
|
return [dot(relative, u), dot(relative, v)]
|
|
})
|
|
const signedArea = projected.reduce((sum, point, index) => {
|
|
const next = projected[(index + 1) % projected.length]
|
|
return sum + point[0] * next[1] - next[0] * point[1]
|
|
}, 0) / 2
|
|
if (Math.abs(signedArea) <= 1e-9) throw new RangeError('Tapered profile outer ring is degenerate.')
|
|
const orientation = Math.sign(signedArea)
|
|
const shifted = projected.map((point, index) => {
|
|
const next = projected[(index + 1) % projected.length]
|
|
const edge: Point2 = [next[0] - point[0], next[1] - point[1]]
|
|
const edgeLength = Math.hypot(...edge)
|
|
if (edgeLength <= 1e-9) throw new RangeError('Tapered profile contains a degenerate edge.')
|
|
const inward: Point2 = [-edge[1] / edgeLength * orientation, edge[0] / edgeLength * orientation]
|
|
return { point: [point[0] + inward[0] * distance, point[1] + inward[1] * distance] as Point2, direction: edge }
|
|
})
|
|
const top = shifted.map((current, index): Point2 => {
|
|
const previous = shifted[(index + shifted.length - 1) % shifted.length]
|
|
const denominator = previous.direction[0] * current.direction[1] - previous.direction[1] * current.direction[0]
|
|
if (Math.abs(denominator) <= 1e-10) throw new RangeError('Tapered profile contains adjacent parallel edges that cannot be offset.')
|
|
const delta: Point2 = [current.point[0] - previous.point[0], current.point[1] - previous.point[1]]
|
|
const parameter = (delta[0] * current.direction[1] - delta[1] * current.direction[0]) / denominator
|
|
return [previous.point[0] + previous.direction[0] * parameter, previous.point[1] + previous.direction[1] * parameter]
|
|
})
|
|
const topArea = top.reduce((sum, point, index) => {
|
|
const next = top[(index + 1) % top.length]
|
|
return sum + point[0] * next[1] - next[0] * point[1]
|
|
}, 0) / 2
|
|
if (Math.sign(topArea) !== orientation || Math.abs(topArea) <= 1e-9) throw new RangeError('Taper angle collapses or inverts the profile.')
|
|
return top.map((point) => [
|
|
origin[0] + u[0] * point[0] + v[0] * point[1] + direction[0],
|
|
origin[1] + u[1] * point[0] + v[1] * point[1] + direction[1],
|
|
origin[2] + u[2] * point[0] + v[2] * point[1] + direction[2],
|
|
])
|
|
}
|
|
|
|
const ringNormal = (ring: Point3[]): Point3 | null => {
|
|
const origin = ring[0]
|
|
for (let index = 1; index < ring.length - 1; index += 1) {
|
|
const candidate = cross(subtract(ring[index], origin), subtract(ring[index + 1], origin))
|
|
if (magnitude(candidate) > 1e-9) return candidate
|
|
}
|
|
return null
|
|
}
|
|
|
|
type Point2 = [number, number]
|
|
|
|
const projectRing = (ring: Point3[], normal: Point3): Point2[] => {
|
|
const axis = normal.reduce((best, value, index) => Math.abs(value) > Math.abs(normal[best]) ? index : best, 0)
|
|
return ring.map((point) => axis === 0 ? [point[1], point[2]] : axis === 1 ? [point[0], point[2]] : [point[0], point[1]])
|
|
}
|
|
|
|
const orientation2d = (a: Point2, b: Point2, c: Point2) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
const onSegment2d = (a: Point2, b: Point2, point: Point2, tolerance = 1e-9) => Math.abs(orientation2d(a, b, point)) <= tolerance && point[0] >= Math.min(a[0], b[0]) - tolerance && point[0] <= Math.max(a[0], b[0]) + tolerance && point[1] >= Math.min(a[1], b[1]) - tolerance && point[1] <= Math.max(a[1], b[1]) + tolerance
|
|
const segmentsIntersect2d = (a: Point2, b: Point2, c: Point2, d: Point2) => {
|
|
const abC = orientation2d(a, b, c)
|
|
const abD = orientation2d(a, b, d)
|
|
const cdA = orientation2d(c, d, a)
|
|
const cdB = orientation2d(c, d, b)
|
|
if (((abC > 1e-9 && abD < -1e-9) || (abC < -1e-9 && abD > 1e-9)) && ((cdA > 1e-9 && cdB < -1e-9) || (cdA < -1e-9 && cdB > 1e-9))) return true
|
|
return onSegment2d(a, b, c) || onSegment2d(a, b, d) || onSegment2d(c, d, a) || onSegment2d(c, d, b)
|
|
}
|
|
|
|
const countRingIntersections = (left: Point2[], right: Point2[] = left, sameRing = true) => {
|
|
let intersections = 0
|
|
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
|
|
const leftNext = (leftIndex + 1) % left.length
|
|
for (let rightIndex = sameRing ? leftIndex + 1 : 0; rightIndex < right.length; rightIndex += 1) {
|
|
const rightNext = (rightIndex + 1) % right.length
|
|
if (sameRing && (leftNext === rightIndex || rightNext === leftIndex)) continue
|
|
if (segmentsIntersect2d(left[leftIndex], left[leftNext], right[rightIndex], right[rightNext])) intersections += 1
|
|
}
|
|
}
|
|
return intersections
|
|
}
|
|
|
|
const pointInRing2d = (point: Point2, ring: Point2[]) => {
|
|
let inside = false
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index, index += 1) {
|
|
const left = ring[index]
|
|
const right = ring[previous]
|
|
if ((left[1] > point[1]) !== (right[1] > point[1]) && point[0] < (right[0] - left[0]) * (point[1] - left[1]) / (right[1] - left[1]) + left[0]) inside = !inside
|
|
}
|
|
return inside
|
|
}
|
|
|
|
export const classifyPlanarProfile = (profile: PlanarProfile): ProfileClassification => {
|
|
const regions = [
|
|
{ outer: normalizedRing(profile.outer), holes: (profile.holes ?? []).map(normalizedRing) },
|
|
...(profile.additionalRegions ?? []).map((region) => ({ outer: normalizedRing(region.outer), holes: (region.holes ?? []).map(normalizedRing) })),
|
|
]
|
|
const rings = regions.flatMap((region) => [region.outer, ...region.holes])
|
|
if (profile.closed === false) return { status: 'open', ringCount: rings.length, selfIntersections: 0 }
|
|
if (rings.some((ring) => ring.length < 3 || !ringNormal(ring))) return { status: 'degenerate', ringCount: rings.length, selfIntersections: 0 }
|
|
const normal = ringNormal(rings[0]) as Point3
|
|
const projectedRegions = regions.map((region) => ({ outer: projectRing(region.outer, normal), holes: region.holes.map((ring) => projectRing(ring, normal)) }))
|
|
const selfIntersections = projectedRegions.reduce((count, region) => {
|
|
const projected = [region.outer, ...region.holes]
|
|
return count + projected.reduce((inner, ring) => inner + countRingIntersections(ring), 0) + projected.reduce((inner, ring, index) => inner + projected.slice(index + 1).reduce((sum, other) => sum + countRingIntersections(ring, other, false), 0), 0)
|
|
}, 0)
|
|
if (selfIntersections > 0) return { status: 'self-intersecting', ringCount: rings.length, selfIntersections }
|
|
const invalidNesting = projectedRegions.some((region) => region.holes.some((hole, holeIndex) => !pointInRing2d(hole[0], region.outer) || region.holes.some((other, otherIndex) => otherIndex !== holeIndex && pointInRing2d(hole[0], other))))
|
|
if (invalidNesting) return { status: 'invalid-nesting', ringCount: rings.length, selfIntersections: 0 }
|
|
const origin = rings[0][0]
|
|
const normalLength = magnitude(normal)
|
|
if (rings.some((ring) => ring.some((point) => Math.abs(dot(normal, subtract(point, origin))) / normalLength > 1e-7))) return { status: 'non-planar', ringCount: rings.length, selfIntersections: 0 }
|
|
return { status: rings.length > 1 ? 'multi-ring' : 'closed', ringCount: rings.length, selfIntersections: 0 }
|
|
}
|
|
|
|
export const validatePlanarProfile = (profile: PlanarProfile) => {
|
|
const classification = classifyPlanarProfile(profile)
|
|
if (classification.status === 'open') throw new RangeError('Profile ring 0 is open.')
|
|
if (classification.status === 'self-intersecting') throw new RangeError('Profile contains self-intersecting edges.')
|
|
if (classification.status === 'invalid-nesting') throw new RangeError('Profile holes must be contained directly inside the outer ring.')
|
|
const rings = [
|
|
normalizedRing(profile.outer),
|
|
...(profile.holes ?? []).map(normalizedRing),
|
|
...(profile.additionalRegions ?? []).flatMap((region) => [normalizedRing(region.outer), ...(region.holes ?? []).map(normalizedRing)]),
|
|
]
|
|
for (const [ringIndex, ring] of rings.entries()) {
|
|
if (ring.length < 3) throw new RangeError(`Profile ring ${ringIndex} requires at least three distinct points.`)
|
|
ring.forEach((point, pointIndex) => {
|
|
validateVector(point, `profile ring ${ringIndex} point ${pointIndex}`)
|
|
if (samePoint(point, ring[(pointIndex + 1) % ring.length])) throw new RangeError(`Profile ring ${ringIndex} contains consecutive duplicate points.`)
|
|
})
|
|
}
|
|
const origin = rings[0][0]
|
|
const normal = ringNormal(rings[0])
|
|
if (!normal) throw new RangeError('Profile ring 0 is collinear.')
|
|
const normalLength = magnitude(normal)
|
|
for (const [ringIndex, ring] of rings.entries()) {
|
|
if (!ringNormal(ring)) throw new RangeError(`Profile ring ${ringIndex} is collinear.`)
|
|
for (const point of ring) if (Math.abs(dot(normal, subtract(point, origin))) / normalLength > 1e-7) throw new RangeError(`Profile ring ${ringIndex} is not coplanar with the outer ring.`)
|
|
}
|
|
}
|
|
|
|
const validateLinearFeature = (input: GeometryDocumentContext & LinearFeatureParameters) => {
|
|
validateDocumentContext(input)
|
|
validatePlanarProfile(input.profile)
|
|
finitePositive(input.length, 'length')
|
|
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
|
|
const taperAngle = input.taperAngle ?? 0
|
|
if (!Number.isFinite(taperAngle) || taperAngle <= -89.999 || taperAngle >= 89.999) throw new RangeError('taperAngle must be finite and between -89.999 and 89.999 degrees.')
|
|
if (taperAngle !== 0 && ((input.profile.holes?.length ?? 0) > 0 || (input.profile.additionalRegions?.length ?? 0) > 0)) throw new RangeError('Tapered linear features currently require one outer profile ring without holes.')
|
|
if (taperAngle !== 0 && input.symmetricToPlane) throw new RangeError('Tapered symmetric linear features must be split into two explicit directions.')
|
|
}
|
|
|
|
export const validatePadInput = (input: PadInput) => validateLinearFeature(input)
|
|
|
|
export const validateExtrudeInput = (input: ExtrudeInput) => validateLinearFeature(input)
|
|
|
|
export const validatePocketInput = (input: PocketInput) => {
|
|
validateLinearFeature(input)
|
|
validateShapeContext(input, input.base)
|
|
}
|
|
|
|
export const validateRevolutionInput = (input: RevolutionInput) => {
|
|
validateDocumentContext(input)
|
|
validatePlanarProfile(input.profile)
|
|
validateVector(input.axisOrigin ?? [0, 0, 0], 'axisOrigin')
|
|
validateVector(input.axisDirection ?? [0, 1, 0], 'axisDirection', false)
|
|
validateAngle(input.angle ?? 360, 'angle')
|
|
if (input.profileRotationAngle !== undefined && (!Number.isFinite(input.profileRotationAngle) || Math.abs(input.profileRotationAngle) > 360)) throw new RangeError('profileRotationAngle must be finite and within 360 degrees.')
|
|
}
|
|
|
|
export const validateGrooveInput = (input: GrooveInput) => {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.base)
|
|
validatePlanarProfile(input.profile)
|
|
validateVector(input.axisOrigin ?? [0, 0, 0], 'axisOrigin')
|
|
validateVector(input.axisDirection ?? [0, 1, 0], 'axisDirection', false)
|
|
validateAngle(input.angle ?? 360, 'angle')
|
|
if (input.profileRotationAngle !== undefined && (!Number.isFinite(input.profileRotationAngle) || Math.abs(input.profileRotationAngle) > 360)) throw new RangeError('profileRotationAngle must be finite and within 360 degrees.')
|
|
}
|
|
|
|
export const validateLoftInput = (input: LoftInput) => {
|
|
validateDocumentContext(input)
|
|
if (!Array.isArray(input.sections) || input.sections.length < 2) throw new RangeError('Loft requires at least two section profiles.')
|
|
if (input.closed && input.sections.length < 3) throw new RangeError('Closed loft requires at least three section profiles.')
|
|
for (const [index, section] of input.sections.entries()) {
|
|
validatePlanarProfile(section)
|
|
if ((section.holes?.length ?? 0) > 0 || (section.additionalRegions?.length ?? 0) > 0) throw new RangeError(`Loft section ${index} contains holes or multiple regions, which are not supported by the simple Bitbybit loft contract.`)
|
|
}
|
|
const mode = input.mode ?? 'standalone'
|
|
if (!['standalone', 'additive', 'subtractive'].includes(mode)) throw new RangeError(`Unsupported loft mode: ${String(mode)}`)
|
|
if (mode === 'standalone') {
|
|
if (input.base) throw new RangeError('Standalone loft must not specify a base Shape.')
|
|
} else {
|
|
if (!input.base) throw new RangeError(`${mode === 'additive' ? 'Additive' : 'Subtractive'} loft requires a base Shape.`)
|
|
validateShapeContext(input, input.base)
|
|
}
|
|
}
|
|
|
|
export const validatePipeInput = (input: PipeInput) => {
|
|
validateDocumentContext(input)
|
|
validatePlanarProfile(input.profile)
|
|
if ((input.profile.holes?.length ?? 0) > 0 || (input.profile.additionalRegions?.length ?? 0) > 0) throw new RangeError('Pipe profile holes and multiple regions are not supported by the simple Bitbybit pipe contract.')
|
|
if (!Array.isArray(input.path) || input.path.length < 2) throw new RangeError('Pipe path requires at least two points.')
|
|
input.path.forEach((point, index) => validateVector(point, `path point ${index}`))
|
|
if (input.path.some((point, index) => index > 0 && samePoint(point, input.path[index - 1]))) throw new RangeError('Pipe path contains consecutive duplicate points.')
|
|
const mode = input.mode ?? 'standalone'
|
|
if (!['standalone', 'additive', 'subtractive'].includes(mode)) throw new RangeError(`Unsupported pipe mode: ${String(mode)}`)
|
|
if (mode === 'standalone') {
|
|
if (input.base) throw new RangeError('Standalone pipe must not specify a base Shape.')
|
|
} else {
|
|
if (!input.base) throw new RangeError(`${mode === 'additive' ? 'Additive' : 'Subtractive'} pipe requires a base Shape.`)
|
|
validateShapeContext(input, input.base)
|
|
}
|
|
}
|
|
|
|
export const validateGeometryFileImport = (input: GeometryFileImport) => {
|
|
validateDocumentContext(input)
|
|
if (!['step', 'iges', 'brep'].includes(input.format)) throw new RangeError(`Unsupported geometry import format: ${input.format}`)
|
|
if (typeof input.text !== 'string' || !input.text.trim()) throw new TypeError('Geometry import text must be non-empty.')
|
|
if (new TextEncoder().encode(input.text).byteLength > MAX_GEOMETRY_IMPORT_TEXT_BYTES) throw new RangeError(`Geometry import payload exceeds ${MAX_GEOMETRY_IMPORT_TEXT_BYTES} bytes.`)
|
|
}
|
|
|
|
export const assertShapeHandleIntegrity = (actual: ShapeHandle, expected: ShapeHandle) => {
|
|
if (actual.id !== expected.id || actual.kernel !== expected.kernel || actual.kind !== expected.kind || actual.documentId !== expected.documentId || actual.documentVersion !== expected.documentVersion) throw new Error(`Shape handle integrity check failed: ${actual.id}`)
|
|
}
|
|
|
|
const appendFace = (face: Inputs.OCCT.DecomposedFaceDto, positions: number[], normals: number[], indices: number[]) => {
|
|
if (face.vertexCoord.length % 3 !== 0 || face.triIndexes.length % 3 !== 0) throw new Error(`Bitbybit OCCT returned malformed arrays for face ${face.faceIndex}.`)
|
|
if (face.vertexCoord.some((coordinate) => !Number.isFinite(coordinate)) || face.normalCoord.some((coordinate) => !Number.isFinite(coordinate))) throw new Error(`Bitbybit OCCT returned non-finite coordinates for face ${face.faceIndex}.`)
|
|
if (face.normalCoord.length !== 0 && face.normalCoord.length !== face.vertexCoord.length) throw new Error(`Bitbybit OCCT returned mismatched normals for face ${face.faceIndex}.`)
|
|
const faceVertexCount = face.vertexCoord.length / 3
|
|
if (face.triIndexes.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= faceVertexCount)) throw new Error(`Bitbybit OCCT returned an out-of-range triangle index for face ${face.faceIndex}.`)
|
|
const vertexOffset = positions.length / 3
|
|
for (const coordinate of face.vertexCoord) positions.push(coordinate)
|
|
if (face.normalCoord.length === face.vertexCoord.length) for (const coordinate of face.normalCoord) normals.push(coordinate)
|
|
else for (let index = 0; index < face.vertexCoord.length; index += 3) normals.push(0, 0, 0)
|
|
for (const index of face.triIndexes) indices.push(vertexOffset + index)
|
|
}
|
|
|
|
export const normalizeBitbybitMesh = (shape: ShapeHandle, mesh: KernelMesh, tolerance = 1e-5, analytic?: AnalyticTopologyDescription | null): MeshAsset => {
|
|
const positions: number[] = []
|
|
const normals: number[] = []
|
|
const indices: number[] = []
|
|
const kernelEdges = mesh.edgeList ?? []
|
|
const kernelVertices = analytic?.vertices?.length ? analytic.vertices.map((vertex) => vertex.point) : (mesh.pointsList ?? [])
|
|
const faceTriangleRanges = mesh.faceList.map((face) => {
|
|
const startTriangle = indices.length / 3
|
|
appendFace(face, positions, normals, indices)
|
|
return { startTriangle, triangleCount: face.triIndexes.length / 3 }
|
|
})
|
|
if (positions.length === 0 || indices.length === 0) throw new Error('Bitbybit OCCT returned an empty mesh for the shape.')
|
|
|
|
const min: [number, number, number] = [Infinity, Infinity, Infinity]
|
|
const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
|
|
for (let index = 0; index < positions.length; index += 3) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
min[axis] = Math.min(min[axis], positions[index + axis])
|
|
max[axis] = Math.max(max[axis], positions[index + axis])
|
|
}
|
|
}
|
|
let analyticTopology: ReturnType<typeof createAnalyticSubshapeRefs> | null = null
|
|
if (analytic?.faces?.length || analytic?.edges?.length || analytic?.vertices?.length) {
|
|
try {
|
|
analyticTopology = createAnalyticSubshapeRefs(shape.id, shape.documentVersion, analytic.faces ?? [], analytic.edges ?? [], analytic.vertices ?? [], tolerance)
|
|
} catch {
|
|
// Invalid or incomplete analytic descriptors must not make tessellation unusable.
|
|
}
|
|
}
|
|
const meshFaces = mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes }))
|
|
const faceTopology = analyticTopology?.faces.refs.length === mesh.faceList.length ? analyticTopology.faces : createSubshapeRefs(shape.id, shape.documentVersion, meshFaces, tolerance)
|
|
const subshapes: SubshapeRef[] = faceTopology.refs
|
|
const subshapeRanges = faceTriangleRanges.flatMap((range, index) => {
|
|
const ref = subshapes[index]
|
|
return ref ? [{ ...range, ref: { ...ref, candidates: ref.candidates ? [...ref.candidates] : undefined } }] : []
|
|
})
|
|
const edgeTopology = analyticTopology?.edges.refs.length === kernelEdges.length && kernelEdges.length > 0 ? analyticTopology.edges : createEdgeSubshapeRefs(shape.id, shape.documentVersion, meshFaces, tolerance)
|
|
const vertexTopology = analyticTopology?.vertices.refs.length === kernelVertices.length && kernelVertices.length > 0 ? analyticTopology.vertices : createVertexSubshapeRefs(shape.id, shape.documentVersion, meshFaces, tolerance)
|
|
const edgeRefs = new Map(edgeTopology.signatures.map((signature, index) => [signature.hash, edgeTopology.refs[index]]))
|
|
const vertexRefs = new Map(vertexTopology.signatures.map((signature, index) => [signature.hash, vertexTopology.refs[index]]))
|
|
const edgeGeometry = new Map<string, { start: [number, number, number]; end: [number, number, number] }>()
|
|
const vertexGeometry = new Map<string, [number, number, number]>()
|
|
const pointAt = (coordinates: number[], index: number): [number, number, number] => [coordinates[index * 3], coordinates[index * 3 + 1], coordinates[index * 3 + 2]]
|
|
const hasKernelEdges = analyticTopology?.edges === edgeTopology && kernelEdges.length > 0
|
|
const hasKernelVertices = analyticTopology?.vertices === vertexTopology && kernelVertices.length > 0
|
|
if (hasKernelVertices) for (const [index, point] of kernelVertices.entries()) {
|
|
const ref = vertexTopology.refs[index]
|
|
if (!ref || point.length !== 3 || point.some((coordinate) => !Number.isFinite(coordinate))) continue
|
|
vertexGeometry.set(ref.persistentId, [...point] as [number, number, number])
|
|
}
|
|
if (hasKernelEdges) for (const [index, edge] of kernelEdges.entries()) {
|
|
const ref = edgeTopology.refs[index]
|
|
if (!ref) continue
|
|
for (let pointIndex = 0; pointIndex + 1 < edge.vertexCoord.length; pointIndex += 1) {
|
|
const start = edge.vertexCoord[pointIndex]
|
|
const end = edge.vertexCoord[pointIndex + 1]
|
|
if (start.length !== 3 || end.length !== 3 || [...start, ...end].some((coordinate) => !Number.isFinite(coordinate))) continue
|
|
edgeGeometry.set(`${ref.persistentId}:${pointIndex}`, { start: [...start] as [number, number, number], end: [...end] as [number, number, number] })
|
|
}
|
|
}
|
|
if (!hasKernelEdges || !hasKernelVertices) for (const face of mesh.faceList) {
|
|
for (let index = 0; index < face.vertexCoord.length / 3; index += 1) {
|
|
if (!hasKernelVertices) {
|
|
const point = pointAt(face.vertexCoord, index)
|
|
const signature = signatureForVertex(point, tolerance)
|
|
if (!vertexGeometry.has(signature.hash)) vertexGeometry.set(signature.hash, point)
|
|
}
|
|
}
|
|
if (!hasKernelEdges) for (let index = 0; index + 2 < face.triIndexes.length; index += 3) {
|
|
const points = [pointAt(face.vertexCoord, face.triIndexes[index]), pointAt(face.vertexCoord, face.triIndexes[index + 1]), pointAt(face.vertexCoord, face.triIndexes[index + 2])]
|
|
for (let edgeIndex = 0; edgeIndex < 3; edgeIndex += 1) {
|
|
const start = points[edgeIndex]
|
|
const end = points[(edgeIndex + 1) % 3]
|
|
const signature = signatureForEdge(start, end, tolerance)
|
|
if (!edgeGeometry.has(signature.hash)) edgeGeometry.set(signature.hash, { start, end })
|
|
}
|
|
}
|
|
}
|
|
const subshapeEdges = [...edgeGeometry.entries()].flatMap(([hash, geometry]) => {
|
|
const ref = hasKernelEdges ? edgeTopology.refs.find((candidate) => hash.startsWith(`${candidate.persistentId}:`)) : edgeRefs.get(hash)
|
|
return ref ? [{ ...geometry, ref: { ...ref, candidates: ref.candidates ? [...ref.candidates] : undefined } }] : []
|
|
})
|
|
const subshapeVertices = [...vertexGeometry.entries()].flatMap(([hash, position]) => {
|
|
const ref = hasKernelVertices ? vertexTopology.refs.find((candidate) => candidate.persistentId === hash) : vertexRefs.get(hash)
|
|
return ref ? [{ position, ref: { ...ref, candidates: ref.candidates ? [...ref.candidates] : undefined } }] : []
|
|
})
|
|
return {
|
|
shapeId: shape.id,
|
|
topologyVersion: shape.documentVersion,
|
|
positions: new Float32Array(positions),
|
|
normals: new Float32Array(normals),
|
|
indices: new Uint32Array(indices),
|
|
subshapes,
|
|
subshapeRanges,
|
|
subshapeEdges,
|
|
subshapeVertices,
|
|
bounds: { min, max },
|
|
}
|
|
}
|
|
|
|
export class BitbybitGeometryRuntime {
|
|
private capabilitiesState = unavailableCapabilities()
|
|
private client: BitByBitOCCT | null = null
|
|
private worker: Worker | null = null
|
|
private initialization: Promise<GeometryCapabilities> | null = null
|
|
private cancelInitialization: (() => void) | null = null
|
|
private sequence = 0
|
|
private readonly shapes = new Map<string, ShapeEntry>()
|
|
private readonly kernelReferences = new Map<number, KernelReferenceEntry>()
|
|
private releasedShapeCount = 0
|
|
private peakShapeCount = 0
|
|
private peakKernelReferenceCount = 0
|
|
private nativeHistory: { provider: NativeOcctHistoryProvider; coordinator: NativeOcctHistoryCoordinator } | null = null
|
|
private readonly nativeHistoryDocumentVersions = new Map<string, number>()
|
|
private readonly nativeKernelSummaries = new Map<string, NonNullable<NativeOcctHistoryResponse['summary']>>()
|
|
|
|
configureNativeHistory(provider: NativeOcctHistoryProvider | null, timeoutMs = 120_000) {
|
|
this.nativeHistory?.coordinator.cancel()
|
|
if (provider === null) {
|
|
this.nativeHistory = null
|
|
this.nativeHistoryDocumentVersions.clear()
|
|
return
|
|
}
|
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new RangeError('Native history timeoutMs must be a positive safe integer.')
|
|
this.nativeHistory = {
|
|
provider,
|
|
coordinator: new NativeOcctHistoryCoordinator((documentId) => this.nativeHistoryDocumentVersions.get(documentId) ?? null, timeoutMs),
|
|
}
|
|
}
|
|
|
|
nativeHistoryCapabilities() {
|
|
return this.nativeHistory?.provider.capabilities() ?? {
|
|
providerId: 'occt-native.history-step',
|
|
providerVersion: 'unconfigured',
|
|
occtVersion: 'unknown',
|
|
availability: 'unavailable' as const,
|
|
operations: [],
|
|
transport: 'step-text' as const,
|
|
reason: 'Native OCCT history provider is not configured.',
|
|
}
|
|
}
|
|
|
|
private async nativeOffsetRevolutionResult(input: {
|
|
documentId: string
|
|
documentVersion: number
|
|
profile: KernelShapeReference
|
|
axisOrigin: Point3
|
|
axisDirection: Point3
|
|
profileRotationAngle: number
|
|
angle: number
|
|
base?: KernelShapeReference
|
|
}): Promise<ShapeHandle | null> {
|
|
const nativeHistory = this.nativeHistory
|
|
const required: NativeOcctHistoryOperation[] = input.base ? ['rotate', 'revolution', 'cut'] : ['rotate', 'revolution']
|
|
if (!nativeHistory || nativeHistory.provider.capabilities().availability !== 'available' || !required.every((operation) => nativeHistory.provider.capabilities().operations.includes(operation))) return null
|
|
const client = await this.readyClient()
|
|
const operationId = `${input.documentId}:geometry-offset-revolution:${++this.sequence}`
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const profileStep = await client.occt.io.saveShapeSTEPAndReturn({ shape: input.profile, fileName: `${operationId}-profile.step`, adjustYtoZ: false, tryDownload: false })
|
|
const rotate = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: `${operationId}:rotate`,
|
|
operation: 'rotate',
|
|
objectStep: profileStep,
|
|
axisOrigin: input.axisOrigin,
|
|
direction: input.axisDirection,
|
|
angle: input.profileRotationAngle,
|
|
})
|
|
const rotatedStep = rotate.response?.history.resultStep
|
|
if (rotate.status !== 'completed' || !rotatedStep) throw new Error(`Native offset profile rotation ${rotate.status}.`)
|
|
const revolution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: `${operationId}:revolution`,
|
|
operation: 'revolution',
|
|
objectStep: rotatedStep,
|
|
axisOrigin: input.axisOrigin,
|
|
direction: input.axisDirection,
|
|
angle: input.angle,
|
|
})
|
|
let resultStep = revolution.response?.history.resultStep
|
|
let resultBrep = revolution.response?.history.resultBrep
|
|
let resultSummary = revolution.response?.history.summary
|
|
if (revolution.status !== 'completed' || !resultStep) throw new Error(`Native offset Revolution ${revolution.status}.`)
|
|
if (input.base) {
|
|
const baseStep = await client.occt.io.saveShapeSTEPAndReturn({ shape: input.base, fileName: `${operationId}-base.step`, adjustYtoZ: false, tryDownload: false })
|
|
const cut = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: `${operationId}:cut`,
|
|
operation: 'cut',
|
|
objectStep: baseStep,
|
|
toolStep: resultStep,
|
|
})
|
|
resultStep = cut.response?.history.resultStep
|
|
resultBrep = cut.response?.history.resultBrep
|
|
resultSummary = cut.response?.history.summary
|
|
if (cut.status !== 'completed' || !resultStep) throw new Error(`Native offset Groove cut ${cut.status}.`)
|
|
}
|
|
const result = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: resultBrep ? 'brep' : 'step', text: resultBrep ?? resultStep })
|
|
if (resultSummary) this.nativeKernelSummaries.set(result.id, resultSummary)
|
|
return result
|
|
}
|
|
|
|
capabilities(): GeometryCapabilities { return { ...this.capabilitiesState } }
|
|
|
|
initialize(): Promise<GeometryCapabilities> {
|
|
if (this.capabilitiesState.status === 'ready') return Promise.resolve(this.capabilities())
|
|
if (this.initialization) return this.initialization
|
|
if (typeof Worker === 'undefined' || typeof WebAssembly === 'undefined') {
|
|
this.capabilitiesState = { ...unavailableCapabilities(), status: 'unavailable', reason: 'WebAssembly and Web Workers are required for Bitbybit OCCT.' }
|
|
return Promise.resolve(this.capabilities())
|
|
}
|
|
|
|
this.capabilitiesState = { ...this.capabilitiesState, status: 'initializing', reason: undefined }
|
|
this.initialization = new Promise<GeometryCapabilities>((resolve, reject) => {
|
|
let settled = false
|
|
const client = new BitByBitOCCT()
|
|
const worker = new Worker(new URL('./geometryWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-occt' })
|
|
this.client = client
|
|
this.worker = worker
|
|
const timeout = window.setTimeout(() => fail(new Error('Bitbybit OCCT initialization timed out.')), 120_000)
|
|
const subscription = client.occtWorkerManager.occWorkerState$.subscribe(({ state }) => {
|
|
if (state !== OccStateEnum.initialised || settled) return
|
|
settled = true
|
|
window.clearTimeout(timeout)
|
|
subscription.unsubscribe()
|
|
this.cancelInitialization = null
|
|
this.capabilitiesState = { ...this.capabilitiesState, status: 'ready', worker: true, wasm: true }
|
|
resolve(this.capabilities())
|
|
})
|
|
const fail = (error: Error) => {
|
|
const wasSettled = settled
|
|
settled = true
|
|
window.clearTimeout(timeout)
|
|
subscription.unsubscribe()
|
|
worker.terminate()
|
|
this.shapes.clear()
|
|
this.kernelReferences.clear()
|
|
this.syncOwnershipMetrics()
|
|
this.worker = null
|
|
this.client = null
|
|
this.initialization = null
|
|
this.cancelInitialization = null
|
|
this.capabilitiesState = { ...this.capabilitiesState, status: 'failed', reason: error.message }
|
|
if (!wasSettled) reject(error)
|
|
}
|
|
this.cancelInitialization = () => fail(new Error('Bitbybit OCCT initialization was cancelled.'))
|
|
worker.addEventListener('error', (event) => fail(new Error(event.message || 'Bitbybit OCCT worker failed.')), { once: true })
|
|
worker.addEventListener('message', ({ data }) => {
|
|
if (data?.type === 'occ-initialization-failed') fail(new Error(data.error || 'Bitbybit OCCT initialization failed.'))
|
|
})
|
|
client.occtWorkerManager.errorCallback = (error) => { this.capabilitiesState = { ...this.capabilitiesState, reason: error } }
|
|
client.init(worker)
|
|
})
|
|
return this.initialization
|
|
}
|
|
|
|
async createBox(input: CreateBoxInput): Promise<ShapeHandle> {
|
|
validateBoxInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.shapes.solid.createBox({
|
|
width: input.width,
|
|
length: input.length,
|
|
height: input.height,
|
|
center: input.center ?? [0, 0, 0],
|
|
originOnCenter: input.originOnCenter ?? true,
|
|
})
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createCylinder(input: CreateCylinderInput): Promise<ShapeHandle> {
|
|
validateCylinderInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.shapes.solid.createCylinder({
|
|
radius: input.radius,
|
|
height: input.height,
|
|
center: input.center ?? [0, 0, 0],
|
|
direction: input.direction ?? [0, 1, 0],
|
|
angle: input.angle ?? 360,
|
|
originOnCenter: input.originOnCenter ?? false,
|
|
})
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createSphere(input: CreateSphereInput): Promise<ShapeHandle> {
|
|
validateSphereInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.shapes.solid.createSphere({ radius: input.radius, center: input.center ?? [0, 0, 0] })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createEllipsoid(input: CreateEllipsoidInput): Promise<ShapeHandle> {
|
|
validateEllipsoidInput(input)
|
|
const angle1 = input.angle1 ?? -90
|
|
const angle2 = input.angle2 ?? 90
|
|
const angle3 = input.angle3 ?? 360
|
|
if (angle1 !== -90 || angle2 !== 90 || angle3 !== 360) throw new RangeError('Bitbybit Ellipsoid trim angles are not available in this runtime.')
|
|
const center = input.center ?? [0, 0, 0]
|
|
const client = await this.readyClient()
|
|
const kernelSphere = await client.occt.shapes.solid.createSphere({ radius: input.radius2, center })
|
|
const radius3 = input.radius3 && input.radius3 > 0 ? input.radius3 : input.radius2
|
|
const kernelShape = await client.occt.transforms.scale3d({ shape: kernelSphere, scale: [1, radius3 / input.radius2, input.radius1 / input.radius2], center })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createCone(input: CreateConeInput): Promise<ShapeHandle> {
|
|
validateConeInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.shapes.solid.createCone({
|
|
radius1: input.radius1,
|
|
radius2: input.radius2,
|
|
height: input.height,
|
|
angle: input.angle ?? 360,
|
|
center: input.center ?? [0, 0, 0],
|
|
direction: input.direction ?? [0, 1, 0],
|
|
})
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createTorus(input: CreateTorusInput): Promise<ShapeHandle> {
|
|
validateTorusInput(input)
|
|
const client = await this.readyClient()
|
|
const center = input.center ?? [0, 0, 0]
|
|
const direction = input.direction ?? [0, 1, 0]
|
|
const angle = input.angle ?? 360
|
|
const kernelShape = await client.occt.shapes.solid.createTorus({ majorRadius: input.majorRadius, minorRadius: input.minorRadius, center, direction, angle })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createHelix(input: CreateHelixInput): Promise<ShapeHandle> {
|
|
validateHelixInput(input)
|
|
const client = await this.readyClient()
|
|
const center = input.center ?? [0, 0, 0]
|
|
const direction = input.direction ?? [0, 0, 1]
|
|
const clockwise = input.leftHanded ?? false
|
|
const tolerance = input.tolerance ?? 1e-10
|
|
const angle = input.angle ?? 0
|
|
const endRadius = input.radius + input.height * Math.tan(angle * Math.PI / 180)
|
|
const kernelShape = angle === 0
|
|
? await client.occt.shapes.wire.createHelixWire({ radius: input.radius, pitch: input.pitch, height: input.height, center, direction, clockwise, tolerance })
|
|
: await client.occt.shapes.wire.createTaperedHelixWire({ startRadius: input.radius, endRadius, pitch: input.pitch, height: input.height, center, direction, clockwise, tolerance })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async modeledThread(input: ModeledThreadInput): Promise<ShapeHandle> {
|
|
validateDocumentContext(input)
|
|
validateShapeContext(input, input.base)
|
|
finitePositive(input.minorDiameter, 'minorDiameter')
|
|
finitePositive(input.majorDiameter, 'majorDiameter')
|
|
finitePositive(input.pitch, 'pitch')
|
|
finitePositive(input.depth, 'depth')
|
|
if (!(input.majorDiameter > input.minorDiameter)) throw new RangeError('majorDiameter must be greater than minorDiameter.')
|
|
const center = input.center ?? [0, 0, 0]
|
|
const direction = input.direction ?? [0, 0, 1]
|
|
validateVector(center, 'center')
|
|
validateVector(direction, 'direction', false)
|
|
const directionLength = magnitude(direction)
|
|
const w = direction.map((coordinate) => coordinate / directionLength) as Point3
|
|
const minorRadius = input.minorDiameter / 2
|
|
const majorRadius = input.majorDiameter / 2
|
|
const client = await this.readyClient()
|
|
const pathRadius = (minorRadius + majorRadius) / 2
|
|
const path = await client.occt.shapes.wire.createHelixWire({ radius: pathRadius, pitch: input.pitch, height: input.depth, center, direction: w, clockwise: input.leftHanded ?? false, tolerance: 1e-10 })
|
|
const radialOverlap = Math.min((majorRadius - minorRadius) * 0.05, input.pitch * 0.01)
|
|
const threadTool = await client.occt.operations.pipeWireCylindrical({ shape: path, radius: (majorRadius - minorRadius) / 2 + radialOverlap, makeSolid: true, trihedronEnum: 'isCorrectedFrenet' as Inputs.OCCT.geomFillTrihedronEnum, forceApproxC1: false })
|
|
const cut = await client.occt.booleans.difference({ shape: this.resolveShape(input.base).reference, shapes: [threadTool], keepEdges: true })
|
|
return this.registerShape(cut, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createPrism(input: CreatePrismInput): Promise<ShapeHandle> {
|
|
validatePrismInput(input)
|
|
const center = input.center ?? [0, 0, 0]
|
|
const firstAngle = (input.firstAngle ?? 0) * Math.PI / 180
|
|
const secondAngle = (input.secondAngle ?? 0) * Math.PI / 180
|
|
const translation: Point3 = [input.height * Math.tan(firstAngle), input.height * Math.tan(secondAngle), input.height]
|
|
const base: Point3[] = Array.from({ length: input.polygon }, (_, index) => {
|
|
const angle = 2 * Math.PI * index / input.polygon
|
|
return [center[0] + input.circumradius * Math.cos(angle), center[1] + input.circumradius * Math.sin(angle), center[2]]
|
|
})
|
|
const top = base.map(([x, y, z]) => [x + translation[0], y + translation[1], z + translation[2]] as Point3)
|
|
const client = await this.readyClient()
|
|
const face = async (points: Point3[]) => {
|
|
const wire = await client.occt.shapes.wire.createPolygonWire({ points })
|
|
return client.occt.shapes.face.createFaceFromWires({ shapes: [wire], planar: true })
|
|
}
|
|
const faces = [await face([...base].reverse()), await face(top)]
|
|
for (let index = 0; index < input.polygon; index += 1) {
|
|
const next = (index + 1) % input.polygon
|
|
faces.push(await face([base[index], base[next], top[next], top[index]]))
|
|
}
|
|
const shell = await client.occt.shapes.shell.sewFaces({ shapes: faces, tolerance: 1e-7 })
|
|
const solid = await client.occt.shapes.solid.fromClosedShell({ shape: shell })
|
|
return this.registerShape(solid, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async createWedge(input: CreateWedgeInput): Promise<ShapeHandle> {
|
|
validateWedgeInput(input)
|
|
const center = input.center ?? [0, 0, 0]
|
|
const point = (x: number, y: number, z: number): Point3 => [center[0] + x, center[1] + y, center[2] + z]
|
|
const a = point(input.xmin, input.ymin, input.zmin)
|
|
const b = point(input.xmax, input.ymin, input.zmin)
|
|
const c = point(input.xmax, input.ymin, input.zmax)
|
|
const d = point(input.xmin, input.ymin, input.zmax)
|
|
const e = point(input.x2min, input.ymax, input.z2min)
|
|
const f = point(input.x2max, input.ymax, input.z2min)
|
|
const g = point(input.x2max, input.ymax, input.z2max)
|
|
const h = point(input.x2min, input.ymax, input.z2max)
|
|
const client = await this.readyClient()
|
|
const distinct = (points: Point3[]) => {
|
|
const ring: Point3[] = []
|
|
for (const candidate of points) {
|
|
const previous = ring[ring.length - 1]
|
|
if (!previous || candidate.some((value, axis) => Math.abs(value - previous[axis]) > 1e-9)) ring.push(candidate)
|
|
}
|
|
if (ring.length > 1 && ring[0].every((value, axis) => Math.abs(value - ring[ring.length - 1][axis]) <= 1e-9)) ring.pop()
|
|
return ring
|
|
}
|
|
const face = async (points: Point3[]) => {
|
|
const ring = distinct(points)
|
|
if (ring.length < 3) return null
|
|
const wire = await client.occt.shapes.wire.createPolygonWire({ points: ring })
|
|
return client.occt.shapes.face.createFaceFromWires({ shapes: [wire], planar: true })
|
|
}
|
|
const faces = (await Promise.all([
|
|
face([a, b, c, d]),
|
|
face([e, h, g, f]),
|
|
face([a, e, f, b]),
|
|
face([d, c, g, h]),
|
|
face([a, d, h, e]),
|
|
face([b, f, g, c]),
|
|
])).filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== null)
|
|
if (faces.length < 4) throw new Error('Wedge construction produced too few non-degenerate faces.')
|
|
const shell = await client.occt.shapes.shell.sewFaces({ shapes: faces, tolerance: 1e-7 })
|
|
const solid = await client.occt.shapes.solid.fromClosedShell({ shape: shell })
|
|
return this.registerShape(solid, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle> {
|
|
validatePlacementInput(input)
|
|
const source = this.resolveShape(input.shape)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.transforms.transform({
|
|
shape: source.reference,
|
|
translation: input.placement.translation,
|
|
rotationAxis: input.placement.rotationAxis,
|
|
rotationAngle: input.placement.rotationAngle,
|
|
scaleFactor: 1,
|
|
})
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async mirror(input: MirrorInput): Promise<ShapeHandle> {
|
|
validateMirrorInput(input)
|
|
const source = this.resolveShape(input.shape)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.transforms.mirrorAlongNormal({ shape: source.reference, origin: input.origin, normal: input.normal })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async union(input: BooleanUnionInput): Promise<ShapeHandle> {
|
|
validateBooleanUnionInput(input)
|
|
const shapes = input.shapes.map((shape) => this.resolveShape(shape).reference)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.booleans.union({ shapes, keepEdges: input.keepEdges ?? false })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async cut(input: BooleanCutInput): Promise<ShapeHandle> {
|
|
validateBooleanCutInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const tools = input.tools.map((shape) => this.resolveShape(shape).reference)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: tools, keepEdges: input.keepEdges ?? false })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async intersection(input: BooleanIntersectionInput): Promise<ShapeHandle> {
|
|
validateBooleanIntersectionInput(input)
|
|
const shapes = input.shapes.map((shape) => this.resolveShape(shape).reference)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.boolean.intersection', { shapes, keepEdges: input.keepEdges ?? false }) as KernelShapeReference
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async fillet(input: FilletInput): Promise<ShapeHandle> {
|
|
validateFilletInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.fillets.filletEdges({ shape: base, radius: input.radius, indexes: input.indexes })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async chamfer(input: ChamferInput): Promise<ShapeHandle> {
|
|
validateChamferInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occt.fillets.chamferEdges({ shape: base, distance: input.distance, indexes: input.indexes })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async draft(input: DraftInput): Promise<ShapeHandle> {
|
|
validateDraftInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const faces = input.indexes ? await Promise.all(input.indexes.map((index) => client.occt.shapes.face.getFace({ shape: base, index }))) : await client.occt.shapes.face.getFaces({ shape: base })
|
|
const kernelShape = await client.occt.draft.draftAngle({ shape: base, faces, direction: input.direction ?? [0, 1, 0], angle: input.angle, neutralPlaneOrigin: input.neutralPlaneOrigin ?? [0, 0, 0], neutralPlaneDirection: input.neutralPlaneDirection ?? [0, 0, 1], flag: input.reversed !== true })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async thickness(input: ThicknessInput): Promise<ShapeHandle> {
|
|
validateThicknessInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const kernelShape = input.removeFaceIndexes?.length
|
|
? await client.occt.operations.makeThickSolidByJoin({
|
|
shape: base,
|
|
shapes: await Promise.all(input.removeFaceIndexes.map((index) => client.occt.shapes.face.getFace({ shape: base, index }))),
|
|
offset: input.offset,
|
|
tolerance: 1e-3,
|
|
intersection: input.joinType === 'Intersection',
|
|
selfIntersection: false,
|
|
joinType: (input.joinType === 'Intersection' ? 'intersection' : 'arc') as Inputs.OCCT.joinTypeEnum,
|
|
removeIntEdges: false,
|
|
})
|
|
: await client.occt.operations.makeThickSolidSimple({ shape: base, offset: input.offset })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async exportStep(shape: ShapeHandle, fileName = `${shape.id}.step`): Promise<GeometryFileExport> {
|
|
const entry = this.resolveShape(shape)
|
|
if (!fileName.toLowerCase().endsWith('.step') && !fileName.toLowerCase().endsWith('.stp')) throw new RangeError('STEP fileName must end with .step or .stp.')
|
|
const client = await this.readyClient()
|
|
const text = await client.occt.io.saveShapeSTEPAndReturn({ shape: entry.reference, fileName, adjustYtoZ: false, tryDownload: false })
|
|
return { format: 'step', fileName, mediaType: 'application/step', text }
|
|
}
|
|
|
|
async exportStl(shape: ShapeHandle, fileName = `${shape.id}.stl`, precision = 0.05): Promise<GeometryFileExport> {
|
|
const entry = this.resolveShape(shape)
|
|
if (!fileName.toLowerCase().endsWith('.stl')) throw new RangeError('STL fileName must end with .stl.')
|
|
finitePositive(precision, 'precision')
|
|
const client = await this.readyClient()
|
|
const text = await client.occt.io.saveShapeStlAndReturn({ shape: entry.reference, fileName, precision, adjustYtoZ: false, tryDownload: false, binary: false })
|
|
return { format: 'stl', fileName, mediaType: 'model/stl', text }
|
|
}
|
|
|
|
async exportIges(shape: ShapeHandle, fileName = `${shape.id}.iges`): Promise<GeometryFileExport> {
|
|
const entry = this.resolveShape(shape)
|
|
if (!fileName.toLowerCase().endsWith('.iges') && !fileName.toLowerCase().endsWith('.igs')) throw new RangeError('IGES fileName must end with .iges or .igs.')
|
|
const client = await this.readyClient()
|
|
const text = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.io.exportIges', { shape: entry.reference }) as string
|
|
if (typeof text !== 'string' || !text.trim()) throw new Error('Bitbybit OCCT returned an empty IGES export.')
|
|
return { format: 'iges', fileName, mediaType: 'model/iges', text }
|
|
}
|
|
|
|
async exportBrep(shape: ShapeHandle, fileName = `${shape.id}.brep`): Promise<GeometryFileExport> {
|
|
const entry = this.resolveShape(shape)
|
|
if (!fileName.toLowerCase().endsWith('.brp') && !fileName.toLowerCase().endsWith('.brep')) throw new RangeError('BRep fileName must end with .brp or .brep.')
|
|
const client = await this.readyClient()
|
|
const text = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.io.exportBrep', { shape: entry.reference }) as string
|
|
if (typeof text !== 'string' || !text.trim()) throw new Error('Bitbybit OCCT returned an empty BRep export.')
|
|
return { format: 'brep', fileName, mediaType: 'application/x-freecad-brep', text }
|
|
}
|
|
|
|
async importShape(input: GeometryFileImport): Promise<ShapeHandle> {
|
|
validateGeometryFileImport(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.io.importShape', { format: input.format, text: input.text }) as KernelShapeReference
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async pad(input: PadInput): Promise<ShapeHandle> {
|
|
validatePadInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await this.createExtrusion(client, input)
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async extrude(input: ExtrudeInput): Promise<ShapeHandle> {
|
|
validateExtrudeInput(input)
|
|
const client = await this.readyClient()
|
|
const kernelShape = await this.createExtrusion(client, input)
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async pocket(input: PocketInput): Promise<ShapeHandle> {
|
|
validatePocketInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const direction = input.direction ?? [0, 1, 0]
|
|
const featureInput = input.throughAll
|
|
? { ...input, length: await this.throughAllLength(client, base, direction), symmetricToPlane: true }
|
|
: input
|
|
const tool = await this.createExtrusion(client, featureInput)
|
|
const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false })
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async revolution(input: RevolutionInput): Promise<ShapeHandle> {
|
|
validateRevolutionInput(input)
|
|
const client = await this.readyClient()
|
|
const originalFace = await this.createProfileFace(client, input.profile)
|
|
const axisOrigin = input.axisOrigin ?? [0, 0, 0]
|
|
const axisDirection = input.axisDirection ?? [0, 1, 0]
|
|
const profileRotationAngle = input.profileRotationAngle ?? 0
|
|
if (profileRotationAngle !== 0) {
|
|
const nativeResult = await this.nativeOffsetRevolutionResult({ documentId: input.documentId, documentVersion: input.documentVersion, profile: originalFace, axisOrigin, axisDirection, profileRotationAngle, angle: input.angle ?? 360 })
|
|
if (nativeResult) return nativeResult
|
|
}
|
|
const face = profileRotationAngle === 0 ? originalFace : await client.occt.transforms.rotateAroundCenter({
|
|
shape: originalFace,
|
|
center: axisOrigin,
|
|
axis: profileRotationAngle < 0 ? axisDirection.map((coordinate) => -coordinate) as Point3 : axisDirection,
|
|
angle: Math.abs(profileRotationAngle),
|
|
})
|
|
const kernelShape = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.feature.revolution', {
|
|
shape: face,
|
|
axisOrigin,
|
|
axisDirection,
|
|
angle: input.angle ?? 360,
|
|
}) as KernelShapeReference
|
|
const normalizedShape = await client.occt.shapes.shape.unifySameDomain({ shape: kernelShape, unifyEdges: true, unifyFaces: true, concatBSplines: false })
|
|
return this.registerShape(normalizedShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async groove(input: GrooveInput): Promise<ShapeHandle> {
|
|
validateGrooveInput(input)
|
|
const base = this.resolveShape(input.base).reference
|
|
const client = await this.readyClient()
|
|
const originalFace = await this.createProfileFace(client, input.profile)
|
|
const axisOrigin = input.axisOrigin ?? [0, 0, 0]
|
|
const axisDirection = input.axisDirection ?? [0, 1, 0]
|
|
const profileRotationAngle = input.profileRotationAngle ?? 0
|
|
if (profileRotationAngle !== 0) {
|
|
const nativeResult = await this.nativeOffsetRevolutionResult({ documentId: input.documentId, documentVersion: input.documentVersion, profile: originalFace, axisOrigin, axisDirection, profileRotationAngle, angle: input.angle ?? 360, base })
|
|
if (nativeResult) return nativeResult
|
|
}
|
|
const face = profileRotationAngle === 0 ? originalFace : await client.occt.transforms.rotateAroundCenter({
|
|
shape: originalFace,
|
|
center: axisOrigin,
|
|
axis: profileRotationAngle < 0 ? axisDirection.map((coordinate) => -coordinate) as Point3 : axisDirection,
|
|
angle: Math.abs(profileRotationAngle),
|
|
})
|
|
const tool = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.feature.revolution', {
|
|
shape: face,
|
|
axisOrigin,
|
|
axisDirection,
|
|
angle: input.angle ?? 360,
|
|
}) as KernelShapeReference
|
|
const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false })
|
|
const normalizedShape = await client.occt.shapes.shape.unifySameDomain({ shape: kernelShape, unifyEdges: true, unifyFaces: true, concatBSplines: false })
|
|
return this.registerShape(normalizedShape, input.documentId, input.documentVersion)
|
|
}
|
|
|
|
async loft(input: LoftInput): Promise<ShapeHandle> {
|
|
validateLoftInput(input)
|
|
const client = await this.readyClient()
|
|
const wires = await Promise.all(input.sections.map((section) => client.occt.shapes.wire.createPolygonWire({ points: normalizedRing(section.outer) })))
|
|
let tool: KernelShapeReference | undefined
|
|
try {
|
|
tool = input.ruled || input.closed
|
|
? await client.occt.operations.loftAdvanced({
|
|
shapes: wires,
|
|
makeSolid: true,
|
|
closed: input.closed ?? false,
|
|
periodic: false,
|
|
straight: input.ruled ?? false,
|
|
nrPeriodicSections: 10,
|
|
useSmoothing: false,
|
|
maxUDegree: 3,
|
|
tolerance: 1e-7,
|
|
parType: 'approxCentripetal' as Inputs.OCCT.approxParametrizationTypeEnum,
|
|
})
|
|
: await client.occt.operations.loft({ shapes: wires, makeSolid: true })
|
|
const mode = input.mode ?? 'standalone'
|
|
if (mode === 'standalone') {
|
|
const result = this.registerShape(tool, input.documentId, input.documentVersion)
|
|
tool = undefined
|
|
return result
|
|
}
|
|
const kernelShape = mode === 'additive'
|
|
? await client.occt.booleans.union({ shapes: [this.resolveShape(input.base as ShapeHandle).reference, tool], keepEdges: false })
|
|
: await client.occt.booleans.difference({ shape: this.resolveShape(input.base as ShapeHandle).reference, shapes: [tool], keepEdges: false })
|
|
tool = undefined
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
} finally {
|
|
// Keep loft input wires alive for the worker's resulting shape cache.
|
|
}
|
|
}
|
|
|
|
async pipe(input: PipeInput): Promise<ShapeHandle> {
|
|
validatePipeInput(input)
|
|
const client = await this.readyClient()
|
|
const path = await client.occt.shapes.wire.createPolylineWire({ points: input.path })
|
|
const profile = await this.createProfileFace(client, input.profile)
|
|
let tool: KernelShapeReference | undefined
|
|
try {
|
|
const ring = normalizedRing(input.profile.outer)
|
|
const center: Point3 = ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length, sum[2] + point[2] / ring.length], [0, 0, 0] as Point3)
|
|
const radii = ring.map((point) => magnitude(subtract(point, center)))
|
|
const radius = radii[0]
|
|
const regular = ring.length >= 3 && radii.every((candidate) => Math.abs(candidate - radius) <= Math.max(1e-7, radius * 1e-6))
|
|
const tangent = subtract(input.path[1], input.path[0])
|
|
const normal = ringNormal(ring)
|
|
const parallel = normal && magnitude(tangent) > 1e-9 && Math.abs(dot(normal, tangent)) / (magnitude(normal) * magnitude(tangent)) > 1 - 1e-6
|
|
if (regular && parallel) {
|
|
tool = await client.occt.operations.pipePolylineWireNGon({ shape: path, radius, nrCorners: ring.length, makeSolid: true, trihedronEnum: 'isConstantNormal' as Inputs.OCCT.geomFillTrihedronEnum, forceApproxC1: false })
|
|
} else tool = await client.occt.operations.pipe({ shape: path, shapes: [profile] })
|
|
const mode = input.mode ?? 'standalone'
|
|
if (mode === 'standalone') {
|
|
const result = this.registerShape(tool, input.documentId, input.documentVersion)
|
|
tool = undefined
|
|
return result
|
|
}
|
|
const kernelShape = mode === 'additive'
|
|
? await client.occt.booleans.union({ shapes: [this.resolveShape(input.base as ShapeHandle).reference, tool], keepEdges: false })
|
|
: await client.occt.booleans.difference({ shape: this.resolveShape(input.base as ShapeHandle).reference, shapes: [tool], keepEdges: false })
|
|
tool = undefined
|
|
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
|
} finally {
|
|
// Operation inputs remain worker-owned until the next cache sweep.
|
|
}
|
|
}
|
|
|
|
async mesh(shape: ShapeHandle, precision = 0.05): Promise<MeshAsset> {
|
|
finitePositive(precision, 'precision')
|
|
const entry = this.resolveShape(shape)
|
|
const client = await this.readyClient()
|
|
const [mesh, analyticDescription] = await Promise.all([
|
|
client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false }),
|
|
client.occtWorkerManager.genericCallToWorkerPromise('plugins.topology.describe', { shape: entry.reference }).catch(() => null),
|
|
])
|
|
return normalizeBitbybitMesh(entry.handle, mesh, Math.max(1e-5, precision * 0.1), analyticDescription as AnalyticTopologyDescription | null)
|
|
}
|
|
|
|
async subshapes(shape: ShapeHandle, precision = 0.05): Promise<SubshapeRef[]> {
|
|
return (await this.mesh(shape, precision)).subshapes ?? []
|
|
}
|
|
|
|
async massProperties(shape: ShapeHandle): Promise<ShapeMassProperties> {
|
|
const entry = this.resolveShape(shape)
|
|
const client = await this.readyClient()
|
|
const result = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.quality.massProperties', { shape: entry.reference }) as ShapeMassProperties
|
|
if (!result || !Number.isFinite(result.volume) || result.volume < 0 || !Number.isFinite(result.surfaceArea) || result.surfaceArea < 0 || !Array.isArray(result.centerOfMass) || result.centerOfMass.length !== 3 || result.centerOfMass.some((value) => !Number.isFinite(value))) throw new Error('Bitbybit OCCT returned invalid Shape mass properties.')
|
|
return { volume: result.volume, surfaceArea: result.surfaceArea, centerOfMass: [...result.centerOfMass] as [number, number, number] }
|
|
}
|
|
|
|
async qualityReport(shape: ShapeHandle): Promise<ShapeQualityReport> {
|
|
const entry = this.resolveShape(shape)
|
|
const client = await this.readyClient()
|
|
const [shapeType, isNull, analysis, validation, boundingBox] = await Promise.all([
|
|
client.occt.shapes.shape.getShapeType({ shape: entry.reference }),
|
|
client.occt.shapes.shape.isNull({ shape: entry.reference }),
|
|
client.occt.brepGraph.analyze({ shape: entry.reference }),
|
|
client.occt.brepGraph.validate({ shape: entry.reference }),
|
|
client.occt.operations.boundingBoxOfShape({ shape: entry.reference }),
|
|
])
|
|
if (!analysis?.ok) throw new Error(`Bitbybit OCCT BRep graph analysis failed: ${analysis?.error ?? 'unknown error'}`)
|
|
if (!validation?.ok) throw new Error(`Bitbybit OCCT BRep graph validation failed: ${validation?.error ?? 'unknown error'}`)
|
|
const counts = [analysis.solids, analysis.faces, analysis.edges, analysis.vertices, validation.errors, validation.warnings]
|
|
if (counts.some((value) => !Number.isSafeInteger(value) || value < 0)) throw new Error('Bitbybit OCCT returned invalid BRep graph counts.')
|
|
if (isNull !== true && isNull !== false && (analysis.solids + analysis.faces + analysis.edges + analysis.vertices === 0)) throw new Error('Bitbybit OCCT returned an indeterminate null state for an empty Shape.')
|
|
const min = boundingBox?.min
|
|
const max = boundingBox?.max
|
|
if (!Array.isArray(min) || min.length !== 3 || min.some((value) => !Number.isFinite(value)) || !Array.isArray(max) || max.length !== 3 || max.some((value) => !Number.isFinite(value))) throw new Error('Bitbybit OCCT returned an invalid Shape bounding box.')
|
|
return {
|
|
shapeType,
|
|
isNull: isNull === true,
|
|
structuralValid: validation.valid === true,
|
|
structuralErrors: validation.errors,
|
|
structuralWarnings: validation.warnings,
|
|
structuralIssues: validation.issues,
|
|
nativeKernelValid: this.nativeKernelSummaries.get(shape.id)?.isValid,
|
|
solids: analysis.solids,
|
|
faces: analysis.faces,
|
|
edges: analysis.edges,
|
|
vertices: analysis.vertices,
|
|
boundingBox: { min: [...min] as Point3, max: [...max] as Point3 },
|
|
}
|
|
}
|
|
|
|
async linearLength(shape: ShapeHandle): Promise<number> {
|
|
const entry = this.resolveShape(shape)
|
|
const client = await this.readyClient()
|
|
const lengths = await client.occt.shapes.edge.getEdgeLengthsOfShape({ shape: entry.reference })
|
|
if (!Array.isArray(lengths) || lengths.some((value) => !Number.isFinite(value) || value < 0)) throw new Error('Bitbybit OCCT returned invalid edge lengths.')
|
|
return lengths.reduce((sum, value) => sum + value, 0)
|
|
}
|
|
|
|
async topology(shape: ShapeHandle, precision = 0.05): Promise<SubshapeTopology> {
|
|
finitePositive(precision, 'precision')
|
|
const entry = this.resolveShape(shape)
|
|
const client = await this.readyClient()
|
|
const [mesh, analyticDescription] = await Promise.all([
|
|
client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false }),
|
|
client.occtWorkerManager.genericCallToWorkerPromise('plugins.topology.describe', { shape: entry.reference }).catch(() => null),
|
|
])
|
|
const faces = mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes }))
|
|
const tolerance = Math.max(1e-5, precision * 0.1)
|
|
const analytic = analyticDescription as AnalyticTopologyDescription | null
|
|
const hasAnalytic = Boolean(analytic?.faces?.length || analytic?.edges?.length || analytic?.vertices?.length)
|
|
let analyticTopology: ReturnType<typeof createAnalyticSubshapeRefs> | null = null
|
|
if (hasAnalytic) {
|
|
try {
|
|
analyticTopology = createAnalyticSubshapeRefs(shape.id, shape.documentVersion, analytic?.faces ?? [], analytic?.edges ?? [], analytic?.vertices ?? [], tolerance)
|
|
} catch {
|
|
// Some valid OCCT surfaces do not expose finite analytic descriptors. Mesh-derived
|
|
// topology remains available and avoids persisting invalid analytic signatures.
|
|
}
|
|
}
|
|
const faceTopology = analyticTopology?.faces ?? createSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
|
|
const edgeTopology = analyticTopology?.edges ?? createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
|
|
const vertexTopology = analyticTopology?.vertices ?? createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance)
|
|
const entries = [faceTopology, edgeTopology, vertexTopology].flatMap((topology) => topology.refs.map((ref, index) => ({ ref, signature: topology.signatures[index] })))
|
|
const adjacency = analyticTopology && analytic?.adjacency ? createTopologyAdjacency({ faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs }, analytic.adjacency) : undefined
|
|
return { faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs, entries, adjacency }
|
|
}
|
|
|
|
async topologyHistory(input: NativeTopologyHistoryInput): Promise<NativeTopologyHistoryRecords> {
|
|
const nativeHistory = this.nativeHistory
|
|
if (!nativeHistory) throw new Error('Native OCCT history provider is not configured.')
|
|
if (!input.operation) throw new Error('Native OCCT history requires a supported operation.')
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const stepByObjectId = new Map<string, Awaited<ReturnType<BitbybitGeometryRuntime['exportStep']>>>()
|
|
const exportHistoryInput = async (source: NativeTopologyHistoryInput['inputs'][number]) => {
|
|
const cached = stepByObjectId.get(source.objectId)
|
|
if (cached) return cached
|
|
const exported = await this.exportStep(source.shape, `${source.objectId}.step`)
|
|
stepByObjectId.set(source.objectId, exported)
|
|
return exported
|
|
}
|
|
if (input.stages?.length) await Promise.all(input.inputs.map(exportHistoryInput))
|
|
const inputIdByObjectId = new Map(input.inputs.map((source, index) => [source.objectId, source.inputId ?? `${input.operationId}:input:${index}`]))
|
|
const stageTransport = input.stages?.length ? {
|
|
inputs: input.inputs.map((source, index) => ({
|
|
inputId: source.inputId ?? `${input.operationId}:input:${index}`,
|
|
objectId: source.objectId,
|
|
role: source.role,
|
|
stageId: source.stageId,
|
|
step: stepByObjectId.get(source.objectId)!.text,
|
|
objectTag: source.objectTag,
|
|
namingEvidence: source.namingEvidence,
|
|
})),
|
|
stages: input.stages.map((stage) => ({
|
|
stageId: stage.stageId,
|
|
operation: stage.operation,
|
|
inputIds: stage.inputObjectIds.map((objectId) => {
|
|
const inputId = inputIdByObjectId.get(objectId)
|
|
if (!inputId) throw new RangeError(`Native OCCT stage ${stage.stageId} references unknown input object ${objectId}.`)
|
|
return inputId
|
|
}),
|
|
ordinal: stage.ordinal,
|
|
})),
|
|
} : {}
|
|
const finalResultObjectId = () => input.stages?.length
|
|
? input.stages[input.stages.length - 1].resultObjectId ?? `${input.operationId}:result`
|
|
: `${input.operationId}:result`
|
|
const captureHistory = (request: Parameters<NativeOcctHistoryCoordinator['capture']>[1]) => nativeHistory.coordinator.capture(nativeHistory.provider, { ...request, ...stageTransport, resultObjectId: finalResultObjectId(), resultObjectTag: input.resultObjectTag })
|
|
const mapHistoryRecords = (response: Parameters<typeof mapNativeOcctHistoryRecords>[0], sourceIds: { object: string; tool: string } | Record<string, string>) => mapNativeOcctHistoryRecords(response, sourceIds, input)
|
|
type NativeStageRequest = Pick<NativeOcctHistoryRequest, 'objectStep'> & Partial<Pick<NativeOcctHistoryRequest, 'toolStep' | 'direction' | 'axisOrigin' | 'angle'>>
|
|
const namingEvidenceForResponse = (response: NativeOcctHistoryResponse, stageId: string, resultObjectId: string) => response.namingEvidence
|
|
? assertNativeNamingEvidence({ ...response.namingEvidence, stageId, resultObjectId })
|
|
: createFinalShapeOnlyNamingEvidence(stageId, resultObjectId)
|
|
const captureStage = async (stage: {
|
|
stageId: string
|
|
operation: NonNullable<NativeTopologyHistoryInput['operation']>
|
|
inputObjectIds: string[]
|
|
resultObjectId: string
|
|
ordinal: number
|
|
sourceIds: { object: string; tool: string } | Record<string, string>
|
|
inputs: NativeTopologyHistoryInput['inputs']
|
|
request: NativeStageRequest
|
|
}) => {
|
|
const transportInputs = stage.inputs.map((source, index) => ({
|
|
inputId: `${stage.stageId}:input:${index}`,
|
|
objectId: source.objectId,
|
|
role: index === 0 ? 'object' : 'tool',
|
|
stageId: source.stageId,
|
|
step: index === 0 ? stage.request.objectStep : stage.request.toolStep ?? stage.request.objectStep,
|
|
objectTag: source.objectTag,
|
|
namingEvidence: source.namingEvidence,
|
|
}))
|
|
const execution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: stage.stageId,
|
|
operation: stage.operation,
|
|
resultObjectId: stage.resultObjectId,
|
|
...(stage.resultObjectId === finalResultObjectId() && input.resultObjectTag !== undefined ? { resultObjectTag: input.resultObjectTag } : {}),
|
|
inputs: transportInputs,
|
|
stages: [{ stageId: stage.stageId, operation: stage.operation, inputIds: transportInputs.map(({ inputId }) => inputId), ordinal: stage.ordinal }],
|
|
...stage.request,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT ${stage.operation} stage history ${execution.status}.`)
|
|
const response = execution.response.history
|
|
if (!response.resultStep?.startsWith('ISO-10303-21;')) throw new Error(`Native OCCT ${stage.operation} stage did not return its result STEP.`)
|
|
const records = mapNativeOcctHistoryRecords(response, stage.sourceIds, {
|
|
inputs: stage.inputs,
|
|
stages: [{ stageId: stage.stageId, operation: stage.operation, inputObjectIds: stage.inputObjectIds, resultObjectId: stage.resultObjectId, ordinal: stage.ordinal }],
|
|
})
|
|
const stageShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: response.resultStep })
|
|
let topology: SubshapeTopology
|
|
try {
|
|
topology = await this.topology(stageShape, 0.05)
|
|
} finally {
|
|
await this.release(stageShape)
|
|
}
|
|
const namingEvidence = namingEvidenceForResponse(response, stage.stageId, stage.resultObjectId)
|
|
return {
|
|
resultStep: response.resultStep,
|
|
capture: { stageId: stage.stageId, operation: stage.operation, inputObjectIds: stage.inputObjectIds, resultObjectId: stage.resultObjectId, ordinal: stage.ordinal, topology, records, namingEvidence } satisfies NativeTopologyHistoryStageCaptureResult,
|
|
}
|
|
}
|
|
const withStageCaptures = (captures: NativeTopologyHistoryStageCaptureResult[]) => {
|
|
for (const capture of captures) if (!capture.namingEvidence) capture.namingEvidence = createFinalShapeOnlyNamingEvidence(capture.stageId, capture.resultObjectId)
|
|
const records = captures[captures.length - 1].records as NativeTopologyHistoryRecords
|
|
Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false })
|
|
return records
|
|
}
|
|
const validateFeatureSides = (requiresAngle: boolean) => {
|
|
const sides = input.featureSides ?? (input.direction ? [{ direction: input.direction, ...(requiresAngle ? { angle: input.angle } : {}) }] : [])
|
|
if (sides.length < 1 || sides.length > 2) throw new Error('Native OCCT feature history requires one or two ordered sides.')
|
|
for (const side of sides) {
|
|
if (side.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...side.direction) <= 0) throw new Error('Native OCCT feature history requires finite non-zero side directions.')
|
|
if (requiresAngle && (typeof side.angle !== 'number' || !Number.isFinite(side.angle) || side.angle <= 0 || side.angle > 360)) throw new Error('Native OCCT feature history requires each side angle in (0, 360].')
|
|
}
|
|
return sides
|
|
}
|
|
const builderRequest = (operation: 'pad' | 'revolution', profileStep: string, side: { direction: Point3; angle?: number }, axisOrigin?: Point3): NativeStageRequest => operation === 'pad'
|
|
? { objectStep: profileStep, direction: side.direction }
|
|
: { objectStep: profileStep, axisOrigin, direction: side.direction, angle: side.angle }
|
|
const captureTwoSidedAdditive = async (
|
|
builderOperation: 'pad' | 'revolution',
|
|
profileInput: NativeTopologyHistoryInput['inputs'][number],
|
|
profileStep: string,
|
|
sides: Array<{ direction: Point3; angle?: number }>,
|
|
axisOrigin?: Point3,
|
|
) => {
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (!capabilities.includes(builderOperation) || !capabilities.includes('fuse')) throw new Error(`Native OCCT provider cannot prove two-sided ${builderOperation} history.`)
|
|
const firstStageId = `${input.operationId}:native-stage:0`
|
|
const secondStageId = `${input.operationId}:native-stage:1`
|
|
const fuseStageId = `${input.operationId}:native-stage:2`
|
|
const firstObjectId = `${firstStageId}:result`
|
|
const secondObjectId = `${secondStageId}:result`
|
|
const first = await captureStage({
|
|
stageId: firstStageId,
|
|
operation: builderOperation,
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: firstObjectId,
|
|
ordinal: 0,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: builderRequest(builderOperation, profileStep, sides[0], axisOrigin),
|
|
})
|
|
const second = await captureStage({
|
|
stageId: secondStageId,
|
|
operation: builderOperation,
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: secondObjectId,
|
|
ordinal: 1,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: builderRequest(builderOperation, profileStep, sides[1], axisOrigin),
|
|
})
|
|
const fused = await captureStage({
|
|
stageId: fuseStageId,
|
|
operation: 'fuse',
|
|
inputObjectIds: [firstObjectId, secondObjectId],
|
|
resultObjectId: finalResultObjectId(),
|
|
ordinal: 2,
|
|
sourceIds: { object: firstObjectId, tool: secondObjectId },
|
|
inputs: [
|
|
{ objectId: firstObjectId, shape: profileInput.shape, stageId: firstStageId, namingEvidence: first.capture.namingEvidence },
|
|
{ objectId: secondObjectId, shape: profileInput.shape, stageId: secondStageId, namingEvidence: second.capture.namingEvidence },
|
|
],
|
|
request: { objectStep: first.resultStep, toolStep: second.resultStep },
|
|
})
|
|
return withStageCaptures([first.capture, second.capture, fused.capture])
|
|
}
|
|
const captureTwoSidedSubtractive = async (
|
|
builderOperation: 'pad' | 'revolution',
|
|
baseInput: NativeTopologyHistoryInput['inputs'][number],
|
|
profileInput: NativeTopologyHistoryInput['inputs'][number],
|
|
baseStep: string,
|
|
profileStep: string,
|
|
sides: Array<{ direction: Point3; angle?: number }>,
|
|
axisOrigin?: Point3,
|
|
) => {
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (!capabilities.includes(builderOperation) || !capabilities.includes('fuse') || !capabilities.includes('cut')) throw new Error(`Native OCCT provider cannot prove two-sided subtractive ${builderOperation} history.`)
|
|
const toolOneStageId = `${input.operationId}:native-stage:0`
|
|
const toolTwoStageId = `${input.operationId}:native-stage:1`
|
|
const fuseStageId = `${input.operationId}:native-stage:2`
|
|
const cutStageId = `${input.operationId}:native-stage:3`
|
|
const toolOneObjectId = `${toolOneStageId}:result`
|
|
const toolTwoObjectId = `${toolTwoStageId}:result`
|
|
const fusedToolObjectId = `${fuseStageId}:result`
|
|
const toolOne = await captureStage({
|
|
stageId: toolOneStageId,
|
|
operation: builderOperation,
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: toolOneObjectId,
|
|
ordinal: 0,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: builderRequest(builderOperation, profileStep, sides[0], axisOrigin),
|
|
})
|
|
const toolTwo = await captureStage({
|
|
stageId: toolTwoStageId,
|
|
operation: builderOperation,
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: toolTwoObjectId,
|
|
ordinal: 1,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: builderRequest(builderOperation, profileStep, sides[1], axisOrigin),
|
|
})
|
|
const fusedTool = await captureStage({
|
|
stageId: fuseStageId,
|
|
operation: 'fuse',
|
|
inputObjectIds: [toolOneObjectId, toolTwoObjectId],
|
|
resultObjectId: fusedToolObjectId,
|
|
ordinal: 2,
|
|
sourceIds: { object: toolOneObjectId, tool: toolTwoObjectId },
|
|
inputs: [
|
|
{ objectId: toolOneObjectId, shape: profileInput.shape, stageId: toolOneStageId, namingEvidence: toolOne.capture.namingEvidence },
|
|
{ objectId: toolTwoObjectId, shape: profileInput.shape, stageId: toolTwoStageId, namingEvidence: toolTwo.capture.namingEvidence },
|
|
],
|
|
request: { objectStep: toolOne.resultStep, toolStep: toolTwo.resultStep },
|
|
})
|
|
const cut = await captureStage({
|
|
stageId: cutStageId,
|
|
operation: 'cut',
|
|
inputObjectIds: [baseInput.objectId, fusedToolObjectId],
|
|
resultObjectId: finalResultObjectId(),
|
|
ordinal: 3,
|
|
sourceIds: { object: baseInput.objectId, tool: fusedToolObjectId },
|
|
inputs: [
|
|
baseInput,
|
|
{ objectId: fusedToolObjectId, shape: profileInput.shape, stageId: fuseStageId, namingEvidence: fusedTool.capture.namingEvidence },
|
|
],
|
|
request: { objectStep: baseStep, toolStep: fusedTool.resultStep },
|
|
})
|
|
return withStageCaptures([toolOne.capture, toolTwo.capture, fusedTool.capture, cut.capture])
|
|
}
|
|
const offsetRevolutionParameters = (sides: Array<{ direction: Point3; angle?: number }>) => {
|
|
const firstAngle = sides[0].angle ?? 0
|
|
const secondAngle = sides[1].angle ?? 0
|
|
const totalAngle = firstAngle + secondAngle
|
|
if (!(firstAngle > 0) || !(secondAngle > 0) || totalAngle > 360) throw new Error('Native OCCT offset Revolution requires two positive angles with a total no greater than 360 degrees.')
|
|
return { direction: sides[0].direction, totalAngle, profileOffsetAngle: -secondAngle }
|
|
}
|
|
const captureOffsetRevolutionAdditive = async (
|
|
profileInput: NativeTopologyHistoryInput['inputs'][number],
|
|
profileStep: string,
|
|
sides: Array<{ direction: Point3; angle?: number }>,
|
|
axisOrigin: Point3,
|
|
) => {
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (!capabilities.includes('rotate') || !capabilities.includes('revolution')) throw new Error('Native OCCT provider cannot prove offset Revolution history.')
|
|
const parameters = offsetRevolutionParameters(sides)
|
|
const rotateStageId = `${input.operationId}:native-stage:0`
|
|
const revolutionStageId = `${input.operationId}:native-stage:1`
|
|
const rotatedProfileObjectId = `${rotateStageId}:result`
|
|
const rotated = await captureStage({
|
|
stageId: rotateStageId,
|
|
operation: 'rotate',
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: rotatedProfileObjectId,
|
|
ordinal: 0,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: { objectStep: profileStep, axisOrigin, direction: parameters.direction, angle: parameters.profileOffsetAngle },
|
|
})
|
|
const revolution = await captureStage({
|
|
stageId: revolutionStageId,
|
|
operation: 'revolution',
|
|
inputObjectIds: [rotatedProfileObjectId],
|
|
resultObjectId: finalResultObjectId(),
|
|
ordinal: 1,
|
|
sourceIds: { object: rotatedProfileObjectId, tool: rotatedProfileObjectId },
|
|
inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId, namingEvidence: rotated.capture.namingEvidence }],
|
|
request: { objectStep: rotated.resultStep, axisOrigin, direction: parameters.direction, angle: parameters.totalAngle },
|
|
})
|
|
return withStageCaptures([rotated.capture, revolution.capture])
|
|
}
|
|
const captureOffsetRevolutionSubtractive = async (
|
|
baseInput: NativeTopologyHistoryInput['inputs'][number],
|
|
profileInput: NativeTopologyHistoryInput['inputs'][number],
|
|
baseStep: string,
|
|
profileStep: string,
|
|
sides: Array<{ direction: Point3; angle?: number }>,
|
|
axisOrigin: Point3,
|
|
) => {
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (!capabilities.includes('rotate') || !capabilities.includes('revolution') || !capabilities.includes('cut')) throw new Error('Native OCCT provider cannot prove offset Groove history.')
|
|
const parameters = offsetRevolutionParameters(sides)
|
|
const rotateStageId = `${input.operationId}:native-stage:0`
|
|
const revolutionStageId = `${input.operationId}:native-stage:1`
|
|
const cutStageId = `${input.operationId}:native-stage:2`
|
|
const rotatedProfileObjectId = `${rotateStageId}:result`
|
|
const revolutionToolObjectId = `${revolutionStageId}:result`
|
|
const rotated = await captureStage({
|
|
stageId: rotateStageId,
|
|
operation: 'rotate',
|
|
inputObjectIds: [profileInput.objectId],
|
|
resultObjectId: rotatedProfileObjectId,
|
|
ordinal: 0,
|
|
sourceIds: { object: profileInput.objectId, tool: profileInput.objectId },
|
|
inputs: [profileInput],
|
|
request: { objectStep: profileStep, axisOrigin, direction: parameters.direction, angle: parameters.profileOffsetAngle },
|
|
})
|
|
const revolution = await captureStage({
|
|
stageId: revolutionStageId,
|
|
operation: 'revolution',
|
|
inputObjectIds: [rotatedProfileObjectId],
|
|
resultObjectId: revolutionToolObjectId,
|
|
ordinal: 1,
|
|
sourceIds: { object: rotatedProfileObjectId, tool: rotatedProfileObjectId },
|
|
inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId, namingEvidence: rotated.capture.namingEvidence }],
|
|
request: { objectStep: rotated.resultStep, axisOrigin, direction: parameters.direction, angle: parameters.totalAngle },
|
|
})
|
|
const cut = await captureStage({
|
|
stageId: cutStageId,
|
|
operation: 'cut',
|
|
inputObjectIds: [baseInput.objectId, revolutionToolObjectId],
|
|
resultObjectId: finalResultObjectId(),
|
|
ordinal: 2,
|
|
sourceIds: { object: baseInput.objectId, tool: revolutionToolObjectId },
|
|
inputs: [baseInput, { objectId: revolutionToolObjectId, shape: profileInput.shape, stageId: revolutionStageId, namingEvidence: revolution.capture.namingEvidence }],
|
|
request: { objectStep: baseStep, toolStep: revolution.resultStep },
|
|
})
|
|
return withStageCaptures([rotated.capture, revolution.capture, cut.capture])
|
|
}
|
|
if (input.operation === 'pad') {
|
|
if (input.inputs.length !== 1) throw new Error('Native OCCT Pad history requires one profile input.')
|
|
const sides = validateFeatureSides(false)
|
|
const [profileInput] = input.inputs
|
|
const profileStep = await exportHistoryInput(profileInput)
|
|
if (sides.length === 2) return captureTwoSidedAdditive('pad', profileInput, profileStep.text, sides)
|
|
const execution = await captureHistory({ documentId: input.documentId, documentVersion: input.documentVersion, operationId: input.operationId, operation: input.operation, objectStep: profileStep.text, direction: sides[0].direction })
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: profileInput.objectId, tool: profileInput.objectId })
|
|
}
|
|
if (input.operation === 'pocket') {
|
|
if (input.inputs.length !== 2) throw new Error('Native OCCT Pocket history requires a base and profile.')
|
|
const sides = validateFeatureSides(false)
|
|
if (!nativeHistory.provider.capabilities().operations.includes('pocket')) throw new Error('Native OCCT history provider does not declare Pocket history.')
|
|
const [baseInput, profileInput] = input.inputs
|
|
const [baseStep, profileStep] = await Promise.all([
|
|
exportHistoryInput(baseInput),
|
|
exportHistoryInput(profileInput),
|
|
])
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (sides.length === 2) return captureTwoSidedSubtractive('pad', baseInput, profileInput, baseStep.text, profileStep.text, sides)
|
|
if (capabilities.includes('pad') && capabilities.includes('cut')) {
|
|
const toolStageId = `${input.operationId}:native-stage:0`
|
|
const cutStageId = `${input.operationId}:native-stage:1`
|
|
const toolObjectId = `${toolStageId}:result`
|
|
const resultObjectId = input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId ?? `${cutStageId}:result` : `${cutStageId}:result`
|
|
const toolExecution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: toolStageId,
|
|
operation: 'pad',
|
|
resultObjectId: toolObjectId,
|
|
inputs: [{ inputId: `${toolStageId}:input:0`, objectId: profileInput.objectId, role: 'object', step: profileStep.text, objectTag: profileInput.objectTag, namingEvidence: profileInput.namingEvidence }],
|
|
stages: [{ stageId: toolStageId, operation: 'pad', inputIds: [`${toolStageId}:input:0`], ordinal: 0 }],
|
|
objectStep: profileStep.text,
|
|
direction: sides[0].direction,
|
|
})
|
|
if (toolExecution.status !== 'completed' || !toolExecution.response) throw new Error(`Native OCCT Pocket tool stage history ${toolExecution.status}.`)
|
|
const toolResponse = toolExecution.response.history
|
|
if (!toolResponse.resultStep?.startsWith('ISO-10303-21;')) throw new Error('Native OCCT Pocket tool stage did not return its result STEP.')
|
|
const toolRecords = mapNativeOcctHistoryRecords(toolResponse, { object: profileInput.objectId, tool: profileInput.objectId }, {
|
|
inputs: [{ objectId: profileInput.objectId, shape: profileInput.shape }],
|
|
stages: [{ stageId: toolStageId, operation: 'pad', inputObjectIds: [profileInput.objectId], resultObjectId: toolObjectId, ordinal: 0 }],
|
|
})
|
|
const toolShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: toolResponse.resultStep })
|
|
let toolTopology: SubshapeTopology
|
|
try {
|
|
toolTopology = await this.topology(toolShape, 0.05)
|
|
} finally {
|
|
await this.release(toolShape)
|
|
}
|
|
const cutExecution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: cutStageId,
|
|
operation: 'cut',
|
|
resultObjectId,
|
|
...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }),
|
|
inputs: [
|
|
{ inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence },
|
|
{ inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolResponse.resultStep, namingEvidence: toolResponse.namingEvidence },
|
|
],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }],
|
|
objectStep: baseStep.text,
|
|
toolStep: toolResponse.resultStep,
|
|
})
|
|
if (cutExecution.status !== 'completed' || !cutExecution.response) throw new Error(`Native OCCT Pocket cut stage history ${cutExecution.status}.`)
|
|
const cutResponse = cutExecution.response.history
|
|
if (!cutResponse.resultStep?.startsWith('ISO-10303-21;')) throw new Error('Native OCCT Pocket cut stage did not return its result STEP.')
|
|
const cutRecords = mapNativeOcctHistoryRecords(cutResponse, { object: baseInput.objectId, tool: toolObjectId }, {
|
|
inputs: [{ objectId: baseInput.objectId, shape: baseInput.shape }, { objectId: toolObjectId, shape: profileInput.shape, stageId: toolStageId }],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1 }],
|
|
})
|
|
const cutShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: cutResponse.resultStep })
|
|
let cutTopology: SubshapeTopology
|
|
try {
|
|
cutTopology = await this.topology(cutShape, 0.05)
|
|
} finally {
|
|
await this.release(cutShape)
|
|
}
|
|
const captures: NativeTopologyHistoryStageCaptureResult[] = [
|
|
{ stageId: toolStageId, operation: 'pad', inputObjectIds: [profileInput.objectId], resultObjectId: toolObjectId, ordinal: 0, topology: toolTopology, records: toolRecords, namingEvidence: namingEvidenceForResponse(toolResponse, toolStageId, toolObjectId) },
|
|
{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1, topology: cutTopology, records: cutRecords, namingEvidence: namingEvidenceForResponse(cutResponse, cutStageId, resultObjectId) },
|
|
]
|
|
const records = cutRecords as NativeTopologyHistoryRecords
|
|
Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false })
|
|
return records
|
|
}
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
toolStep: profileStep.text,
|
|
direction: sides[0].direction,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: profileInput.objectId })
|
|
}
|
|
if (input.operation === 'revolution') {
|
|
if (input.inputs.length !== 1) throw new Error('Native OCCT Revolution history requires one profile.')
|
|
const axisOrigin = input.axisOrigin
|
|
const sides = validateFeatureSides(true)
|
|
if (!axisOrigin || axisOrigin.some((value) => !Number.isFinite(value))) throw new Error('Native OCCT Revolution history requires a finite axis origin.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('revolution')) throw new Error('Native OCCT history provider does not declare Revolution history.')
|
|
const [profileInput] = input.inputs
|
|
const profileStep = await exportHistoryInput(profileInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
if (sides.length === 2) return captureOffsetRevolutionAdditive(profileInput, profileStep.text, sides, axisOrigin)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: profileStep.text,
|
|
axisOrigin,
|
|
direction: sides[0].direction,
|
|
angle: sides[0].angle,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: profileInput.objectId, tool: profileInput.objectId })
|
|
}
|
|
if (input.operation === 'groove') {
|
|
if (input.inputs.length !== 2) throw new Error('Native OCCT Groove history requires a base and profile.')
|
|
const axisOrigin = input.axisOrigin
|
|
const sides = validateFeatureSides(true)
|
|
if (!axisOrigin || axisOrigin.some((value) => !Number.isFinite(value))) throw new Error('Native OCCT Groove history requires a finite axis origin.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('groove')) throw new Error('Native OCCT history provider does not declare Groove history.')
|
|
const [baseInput, profileInput] = input.inputs
|
|
const [baseStep, profileStep] = await Promise.all([
|
|
exportHistoryInput(baseInput),
|
|
exportHistoryInput(profileInput),
|
|
])
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const capabilities = nativeHistory.provider.capabilities().operations
|
|
if (sides.length === 2) return captureOffsetRevolutionSubtractive(baseInput, profileInput, baseStep.text, profileStep.text, sides, axisOrigin)
|
|
if (capabilities.includes('revolution') && capabilities.includes('cut')) {
|
|
const toolStageId = `${input.operationId}:native-stage:0`
|
|
const cutStageId = `${input.operationId}:native-stage:1`
|
|
const toolObjectId = `${toolStageId}:result`
|
|
const resultObjectId = input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId ?? `${cutStageId}:result` : `${cutStageId}:result`
|
|
const toolExecution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: toolStageId,
|
|
operation: 'revolution',
|
|
resultObjectId: toolObjectId,
|
|
inputs: [{ inputId: `${toolStageId}:input:0`, objectId: profileInput.objectId, role: 'object', step: profileStep.text, objectTag: profileInput.objectTag, namingEvidence: profileInput.namingEvidence }],
|
|
stages: [{ stageId: toolStageId, operation: 'revolution', inputIds: [`${toolStageId}:input:0`], ordinal: 0 }],
|
|
objectStep: profileStep.text,
|
|
axisOrigin,
|
|
direction: sides[0].direction,
|
|
angle: sides[0].angle,
|
|
})
|
|
if (toolExecution.status !== 'completed' || !toolExecution.response) throw new Error(`Native OCCT Groove tool stage history ${toolExecution.status}.`)
|
|
const toolResponse = toolExecution.response.history
|
|
if (!toolResponse.resultStep?.startsWith('ISO-10303-21;')) throw new Error('Native OCCT Groove tool stage did not return its result STEP.')
|
|
const toolRecords = mapNativeOcctHistoryRecords(toolResponse, { object: profileInput.objectId, tool: profileInput.objectId }, {
|
|
inputs: [{ objectId: profileInput.objectId, shape: profileInput.shape }],
|
|
stages: [{ stageId: toolStageId, operation: 'revolution', inputObjectIds: [profileInput.objectId], resultObjectId: toolObjectId, ordinal: 0 }],
|
|
})
|
|
const toolShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: toolResponse.resultStep })
|
|
let toolTopology: SubshapeTopology
|
|
try {
|
|
toolTopology = await this.topology(toolShape, 0.05)
|
|
} finally {
|
|
await this.release(toolShape)
|
|
}
|
|
const cutExecution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: cutStageId,
|
|
operation: 'cut',
|
|
resultObjectId,
|
|
...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }),
|
|
inputs: [
|
|
{ inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence },
|
|
{ inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolResponse.resultStep, namingEvidence: toolResponse.namingEvidence },
|
|
],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }],
|
|
objectStep: baseStep.text,
|
|
toolStep: toolResponse.resultStep,
|
|
})
|
|
if (cutExecution.status !== 'completed' || !cutExecution.response) throw new Error(`Native OCCT Groove cut stage history ${cutExecution.status}.`)
|
|
const cutResponse = cutExecution.response.history
|
|
if (!cutResponse.resultStep?.startsWith('ISO-10303-21;')) throw new Error('Native OCCT Groove cut stage did not return its result STEP.')
|
|
const cutRecords = mapNativeOcctHistoryRecords(cutResponse, { object: baseInput.objectId, tool: toolObjectId }, {
|
|
inputs: [{ objectId: baseInput.objectId, shape: baseInput.shape }, { objectId: toolObjectId, shape: profileInput.shape, stageId: toolStageId }],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1 }],
|
|
})
|
|
const cutShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: cutResponse.resultStep })
|
|
let cutTopology: SubshapeTopology
|
|
try {
|
|
cutTopology = await this.topology(cutShape, 0.05)
|
|
} finally {
|
|
await this.release(cutShape)
|
|
}
|
|
const captures: NativeTopologyHistoryStageCaptureResult[] = [
|
|
{ stageId: toolStageId, operation: 'revolution', inputObjectIds: [profileInput.objectId], resultObjectId: toolObjectId, ordinal: 0, topology: toolTopology, records: toolRecords, namingEvidence: namingEvidenceForResponse(toolResponse, toolStageId, toolObjectId) },
|
|
{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1, topology: cutTopology, records: cutRecords, namingEvidence: namingEvidenceForResponse(cutResponse, cutStageId, resultObjectId) },
|
|
]
|
|
const records = cutRecords as NativeTopologyHistoryRecords
|
|
Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false })
|
|
return records
|
|
}
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
toolStep: profileStep.text,
|
|
axisOrigin,
|
|
direction: sides[0].direction,
|
|
angle: sides[0].angle,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: profileInput.objectId })
|
|
}
|
|
if (input.operation === 'loft') {
|
|
if (input.inputs.length !== 2) throw new Error('Native OCCT Loft history requires exactly two section inputs.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('loft')) throw new Error('Native OCCT history provider does not declare Loft history.')
|
|
const [firstInput, secondInput] = input.inputs
|
|
const [firstStep, secondStep] = await Promise.all([
|
|
exportHistoryInput(firstInput),
|
|
exportHistoryInput(secondInput),
|
|
])
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: firstStep.text,
|
|
toolStep: secondStep.text,
|
|
ruled: input.ruled === true,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: firstInput.objectId, tool: secondInput.objectId })
|
|
}
|
|
if (input.operation === 'pipe') {
|
|
if (input.inputs.length !== 2) throw new Error('Native OCCT Pipe history requires exactly one profile and one spine input.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('pipe')) throw new Error('Native OCCT history provider does not declare Pipe history.')
|
|
const [profileInput, spineInput] = input.inputs
|
|
const [profileStep, spineStep] = await Promise.all([
|
|
exportHistoryInput(profileInput),
|
|
exportHistoryInput(spineInput),
|
|
])
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: profileStep.text,
|
|
toolStep: spineStep.text,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: profileInput.objectId, tool: spineInput.objectId })
|
|
}
|
|
if (input.operation === 'fillet') {
|
|
if (input.inputs.length !== 1 || typeof input.radius !== 'number' || !Number.isFinite(input.radius) || input.radius <= 0) throw new Error('Native OCCT Fillet history requires one base input and a finite positive radius.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('fillet')) throw new Error('Native OCCT history provider does not declare Fillet history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
radius: input.radius,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'chamfer') {
|
|
if (input.inputs.length !== 1 || typeof input.distance !== 'number' || !Number.isFinite(input.distance) || input.distance <= 0) throw new Error('Native OCCT Chamfer history requires one base input and a finite positive distance.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('chamfer')) throw new Error('Native OCCT history provider does not declare Chamfer history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
distance: input.distance,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'hole') {
|
|
const position = input.position
|
|
const direction = input.direction
|
|
if (input.inputs.length !== 1 || typeof input.radius !== 'number' || !Number.isFinite(input.radius) || input.radius <= 0 || typeof input.depth !== 'number' || !Number.isFinite(input.depth) || input.depth <= 0 || !position || position.some((value) => !Number.isFinite(value)) || !direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0) throw new Error('Native OCCT Hole history requires one base input, finite positive radius/depth, position and direction.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('hole')) throw new Error('Native OCCT history provider does not declare Hole history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
if (nativeHistory.provider.capabilities().operations.includes('cut')) {
|
|
const toolStageId = `${input.operationId}:native-stage:0`
|
|
const cutStageId = `${input.operationId}:native-stage:1`
|
|
const toolObjectId = `${toolStageId}:result`
|
|
const resultObjectId = input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId ?? `${cutStageId}:result` : `${cutStageId}:result`
|
|
const toolShape = await this.createCylinder({ documentId: input.documentId, documentVersion: input.documentVersion, radius: input.radius, height: input.depth, center: position, direction, originOnCenter: false })
|
|
let toolTopology: SubshapeTopology
|
|
let toolStep: Awaited<ReturnType<BitbybitGeometryRuntime['exportStep']>>
|
|
try {
|
|
[toolTopology, toolStep] = await Promise.all([this.topology(toolShape, 0.05), this.exportStep(toolShape, `${toolObjectId}.step`)])
|
|
} finally {
|
|
await this.release(toolShape)
|
|
}
|
|
const cutExecution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: cutStageId,
|
|
operation: 'cut',
|
|
resultObjectId,
|
|
...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }),
|
|
inputs: [
|
|
{ inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence },
|
|
{ inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolStep.text, namingEvidence: createFinalShapeOnlyNamingEvidence(toolStageId, toolObjectId, 'Hole tool was synthesized by the geometry runtime; provider did not capture its builder naming evidence.') },
|
|
],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }],
|
|
objectStep: baseStep.text,
|
|
toolStep: toolStep.text,
|
|
})
|
|
if (cutExecution.status !== 'completed' || !cutExecution.response) throw new Error(`Native OCCT Hole cut stage history ${cutExecution.status}.`)
|
|
const cutResponse = cutExecution.response.history
|
|
if (!cutResponse.resultStep?.startsWith('ISO-10303-21;')) throw new Error('Native OCCT Hole cut stage did not return its result STEP.')
|
|
const cutRecords = mapNativeOcctHistoryRecords(cutResponse, { object: baseInput.objectId, tool: toolObjectId }, {
|
|
inputs: [{ objectId: baseInput.objectId, shape: baseInput.shape }, { objectId: toolObjectId, shape: baseInput.shape, stageId: toolStageId }],
|
|
stages: [{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1 }],
|
|
})
|
|
const cutShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: cutResponse.resultStep })
|
|
let cutTopology: SubshapeTopology
|
|
try {
|
|
cutTopology = await this.topology(cutShape, 0.05)
|
|
} finally {
|
|
await this.release(cutShape)
|
|
}
|
|
const captures: NativeTopologyHistoryStageCaptureResult[] = [
|
|
{ stageId: toolStageId, operation: 'hole', inputObjectIds: [], resultObjectId: toolObjectId, ordinal: 0, topology: toolTopology, records: [], namingEvidence: createFinalShapeOnlyNamingEvidence(toolStageId, toolObjectId, 'Hole tool was synthesized by the geometry runtime; provider did not capture its builder naming evidence.') },
|
|
{ stageId: cutStageId, operation: 'cut', inputObjectIds: [baseInput.objectId, toolObjectId], resultObjectId, ordinal: 1, topology: cutTopology, records: cutRecords, namingEvidence: namingEvidenceForResponse(cutResponse, cutStageId, resultObjectId) },
|
|
]
|
|
const records = cutRecords as NativeTopologyHistoryRecords
|
|
Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false })
|
|
return records
|
|
}
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
radius: input.radius,
|
|
depth: input.depth,
|
|
position,
|
|
direction,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'draft') {
|
|
const direction = input.direction
|
|
const neutralPlaneOrigin = input.axisOrigin
|
|
const neutralPlaneDirection = input.neutralPlaneDirection
|
|
const angle = input.angle
|
|
if (input.inputs.length !== 1 || !input.faceIndexes || input.faceIndexes.length !== 1 || !Number.isSafeInteger(input.faceIndexes[0]) || input.faceIndexes[0] < 0 || typeof angle !== 'number' || !Number.isFinite(angle) || angle === 0 || angle <= -89.999 || angle >= 89.999 || !direction || direction.some((value) => !Number.isFinite(value)) || Math.hypot(...direction) <= 0 || !neutralPlaneOrigin || neutralPlaneOrigin.some((value) => !Number.isFinite(value)) || !neutralPlaneDirection || neutralPlaneDirection.some((value) => !Number.isFinite(value)) || Math.hypot(...neutralPlaneDirection) <= 0) throw new Error('Native OCCT Draft history requires one base input, one face index, finite angle, direction and neutral plane.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('draft')) throw new Error('Native OCCT history provider does not declare Draft history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
faceIndex: input.faceIndexes[0],
|
|
angle,
|
|
direction,
|
|
axisOrigin: neutralPlaneOrigin,
|
|
neutralPlaneDirection,
|
|
reversed: input.reversed === true,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'thickness') {
|
|
const offset = input.offset
|
|
if (input.inputs.length !== 1 || !input.faceIndexes || input.faceIndexes.length !== 1 || !Number.isSafeInteger(input.faceIndexes[0]) || input.faceIndexes[0] < 0 || typeof offset !== 'number' || !Number.isFinite(offset) || offset === 0 || (input.joinType !== undefined && input.joinType !== 'Arc' && input.joinType !== 'Intersection')) throw new Error('Native OCCT Thickness history requires one base input, one face index, finite non-zero offset and a supported join type.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('thickness')) throw new Error('Native OCCT history provider does not declare Thickness history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: baseStep.text,
|
|
faceIndex: input.faceIndexes[0],
|
|
offset,
|
|
joinType: input.joinType,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'linear-pattern') {
|
|
if (input.inputs.length !== 1 || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT LinearPattern history requires one base input and a finite non-zero translation vector.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('linear-pattern')) throw new Error('Native OCCT history provider does not declare LinearPattern history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({ documentId: input.documentId, documentVersion: input.documentVersion, operationId: input.operationId, operation: input.operation, objectStep: baseStep.text, direction: input.direction })
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'polar-pattern') {
|
|
if (input.inputs.length !== 1 || !input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle === 0 || Math.abs(input.angle) > 360) throw new Error('Native OCCT PolarPattern history requires one base input, a finite axis and a non-zero angle within 360 degrees.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('polar-pattern')) throw new Error('Native OCCT history provider does not declare PolarPattern history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({ documentId: input.documentId, documentVersion: input.documentVersion, operationId: input.operationId, operation: input.operation, objectStep: baseStep.text, axisOrigin: input.axisOrigin, direction: input.direction, angle: input.angle })
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'mirrored') {
|
|
if (input.inputs.length !== 1 || !input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0) throw new Error('Native OCCT Mirrored history requires one base input and a finite mirror plane.')
|
|
if (!nativeHistory.provider.capabilities().operations.includes('mirrored')) throw new Error('Native OCCT history provider does not declare Mirrored history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const execution = await captureHistory({ documentId: input.documentId, documentVersion: input.documentVersion, operationId: input.operationId, operation: input.operation, objectStep: baseStep.text, axisOrigin: input.axisOrigin, direction: input.direction })
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.operation === 'multi-transform') {
|
|
if (input.inputs.length !== 1) throw new Error('Native OCCT MultiTransform history requires one base input.')
|
|
if (input.transforms) {
|
|
if (input.transforms.length < 2 || input.transforms.length > 6) throw new Error('Native ordered MultiTransform history requires between two and six steps.')
|
|
for (const step of input.transforms) {
|
|
if (!['linear', 'polar', 'mirrored'].includes(step.type)) throw new Error('Native OCCT MultiTransform history contains an unsupported transform step.')
|
|
if (step.type === 'linear' && (step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0)) throw new Error('Native OCCT MultiTransform linear history requires a finite non-zero translation vector.')
|
|
if (step.type === 'polar' && (step.axisOrigin.some((value) => !Number.isFinite(value)) || step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0 || !Number.isFinite(step.angle) || step.angle === 0 || Math.abs(step.angle) > 360)) throw new Error('Native OCCT MultiTransform polar history requires a finite axis and a non-zero angle within 360 degrees.')
|
|
if (step.type === 'mirrored' && (step.axisOrigin.some((value) => !Number.isFinite(value)) || step.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...step.direction) <= 0)) throw new Error('Native OCCT MultiTransform mirrored history requires a finite mirror plane.')
|
|
}
|
|
} else {
|
|
if (!input.transformKind || !['linear', 'polar', 'mirrored'].includes(input.transformKind)) throw new Error('Native OCCT MultiTransform history requires one supported transform step.')
|
|
if (input.transformKind === 'linear' && (!input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0)) throw new Error('Native OCCT MultiTransform linear history requires a finite non-zero translation vector.')
|
|
if (input.transformKind === 'polar' && (!input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0 || typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle === 0 || Math.abs(input.angle) > 360)) throw new Error('Native OCCT MultiTransform polar history requires a finite axis and a non-zero angle within 360 degrees.')
|
|
if (input.transformKind === 'mirrored' && (!input.axisOrigin || input.axisOrigin.some((value) => !Number.isFinite(value)) || !input.direction || input.direction.some((value) => !Number.isFinite(value)) || Math.hypot(...input.direction) <= 0)) throw new Error('Native OCCT MultiTransform mirrored history requires a finite mirror plane.')
|
|
}
|
|
if (!nativeHistory.provider.capabilities().operations.includes('multi-transform')) throw new Error('Native OCCT history provider does not declare MultiTransform history.')
|
|
const [baseInput] = input.inputs
|
|
const baseStep = await exportHistoryInput(baseInput)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const stagedOperations = input.transforms?.map((step) => step.type === 'linear' ? 'linear-pattern' as const : step.type === 'polar' ? 'polar-pattern' as const : 'mirrored' as const) ?? []
|
|
if (input.transforms && input.transforms.length >= 2 && stagedOperations.every((operation) => nativeHistory.provider.capabilities().operations.includes(operation))) {
|
|
const captures: NativeTopologyHistoryStageCaptureResult[] = []
|
|
let previousStep = baseStep.text
|
|
let previousObjectId = baseInput.objectId
|
|
let previousStageId: string | undefined
|
|
let previousNamingEvidence = baseInput.namingEvidence
|
|
const declaredResultObjectId = input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId : undefined
|
|
for (let index = 0; index < input.transforms.length; index += 1) {
|
|
const step = input.transforms[index]
|
|
const operation = step.type === 'linear' ? 'linear-pattern' as const : step.type === 'polar' ? 'polar-pattern' as const : 'mirrored' as const
|
|
if (!nativeHistory.provider.capabilities().operations.includes(operation)) throw new Error(`Native OCCT history provider does not declare ${operation}.`)
|
|
const stageId = `${input.operationId}:native-stage:${index}`
|
|
const resultObjectId = index === input.transforms.length - 1 && declaredResultObjectId ? declaredResultObjectId : `${stageId}:result`
|
|
const execution = await nativeHistory.coordinator.capture(nativeHistory.provider, {
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: `${input.operationId}:native-stage:${index}`,
|
|
operation,
|
|
resultObjectId,
|
|
...(index === input.transforms.length - 1 && input.resultObjectTag !== undefined ? { resultObjectTag: input.resultObjectTag } : {}),
|
|
inputs: [{ inputId: `${stageId}:input:0`, objectId: previousObjectId, role: 'object', stageId: previousStageId, step: previousStep, ...(index === 0 && baseInput.objectTag !== undefined ? { objectTag: baseInput.objectTag } : {}), namingEvidence: previousNamingEvidence }],
|
|
stages: [{ stageId, operation, inputIds: [`${stageId}:input:0`], ordinal: index }],
|
|
objectStep: previousStep,
|
|
...(step.type === 'linear' ? { direction: step.direction } : {}),
|
|
...(step.type === 'polar' ? { axisOrigin: step.axisOrigin, direction: step.direction, angle: step.angle } : {}),
|
|
...(step.type === 'mirrored' ? { axisOrigin: step.axisOrigin, direction: step.direction } : {}),
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT MultiTransform stage ${index} history ${execution.status}.`)
|
|
const response = execution.response.history
|
|
if (!response.resultStep?.startsWith('ISO-10303-21;')) throw new Error(`Native OCCT MultiTransform stage ${index} did not return its result STEP.`)
|
|
const records = mapNativeOcctHistoryRecords(response, { object: previousObjectId, tool: previousObjectId }, {
|
|
inputs: [{ objectId: previousObjectId, shape: baseInput.shape, ...(previousStageId ? { stageId: previousStageId } : {}) }],
|
|
stages: [{ stageId, operation, inputObjectIds: [previousObjectId], resultObjectId, ordinal: index }],
|
|
})
|
|
const stageShape = await this.importShape({ documentId: input.documentId, documentVersion: input.documentVersion, format: 'step', text: response.resultStep })
|
|
let topology: SubshapeTopology
|
|
try {
|
|
topology = await this.topology(stageShape, 0.05)
|
|
} finally {
|
|
await this.release(stageShape)
|
|
}
|
|
captures.push({ stageId, operation, inputObjectIds: [previousObjectId], resultObjectId, ordinal: index, topology, records, namingEvidence: namingEvidenceForResponse(response, stageId, resultObjectId) })
|
|
previousStep = response.resultStep
|
|
previousObjectId = resultObjectId
|
|
previousStageId = stageId
|
|
previousNamingEvidence = namingEvidenceForResponse(response, stageId, resultObjectId)
|
|
}
|
|
const records = captures[captures.length - 1].records as NativeTopologyHistoryRecords
|
|
Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false })
|
|
return records
|
|
}
|
|
const execution = await captureHistory({ documentId: input.documentId, documentVersion: input.documentVersion, operationId: input.operationId, operation: input.operation, objectStep: baseStep.text, transformKind: input.transformKind, transforms: input.transforms, axisOrigin: input.axisOrigin, direction: input.direction, angle: input.angle })
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: baseInput.objectId, tool: baseInput.objectId })
|
|
}
|
|
if (input.inputs.length !== 2) throw new Error('Native OCCT history requires exactly an object and a tool input.')
|
|
validateDocumentContext(input)
|
|
this.nativeHistoryDocumentVersions.set(input.documentId, input.documentVersion)
|
|
const [objectInput, toolInput] = input.inputs
|
|
const [objectStep, toolStep] = await Promise.all([
|
|
exportHistoryInput(objectInput),
|
|
exportHistoryInput(toolInput),
|
|
])
|
|
const execution = await captureHistory({
|
|
documentId: input.documentId,
|
|
documentVersion: input.documentVersion,
|
|
operationId: input.operationId,
|
|
operation: input.operation,
|
|
objectStep: objectStep.text,
|
|
toolStep: toolStep.text,
|
|
})
|
|
if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT history ${execution.status}.`)
|
|
return mapHistoryRecords(execution.response.history, { object: objectInput.objectId, tool: toolInput.objectId })
|
|
}
|
|
|
|
async release(shape: ShapeHandle): Promise<void> {
|
|
const entry = this.shapes.get(shape.id)
|
|
if (!entry) return
|
|
this.assertHandle(shape, entry.handle)
|
|
this.shapes.delete(shape.id)
|
|
this.nativeKernelSummaries.delete(shape.id)
|
|
this.releasedShapeCount += 1
|
|
const kernelReference = this.kernelReferences.get(entry.reference.hash)
|
|
if (kernelReference && kernelReference.count > 1) {
|
|
kernelReference.count -= 1
|
|
this.syncOwnershipMetrics()
|
|
return
|
|
}
|
|
this.kernelReferences.delete(entry.reference.hash)
|
|
this.syncOwnershipMetrics()
|
|
const client = this.client
|
|
if (client && this.capabilitiesState.status === 'ready' && this.shapes.size === 0) await client.occt.cleanAllCache()
|
|
}
|
|
|
|
dispose() {
|
|
this.nativeHistory?.coordinator.cancel()
|
|
this.cancelInitialization?.()
|
|
this.shapes.clear()
|
|
this.kernelReferences.clear()
|
|
this.releasedShapeCount = 0
|
|
this.peakShapeCount = 0
|
|
this.peakKernelReferenceCount = 0
|
|
this.syncOwnershipMetrics()
|
|
this.client?.occtWorkerManager.cleanPromisesMade()
|
|
this.worker?.terminate()
|
|
this.client = null
|
|
this.worker = null
|
|
this.initialization = null
|
|
this.cancelInitialization = null
|
|
this.nativeHistoryDocumentVersions.clear()
|
|
this.nativeKernelSummaries.clear()
|
|
this.nativeHistory = null
|
|
this.capabilitiesState = unavailableCapabilities()
|
|
}
|
|
|
|
private async readyClient() {
|
|
const capabilities = await this.initialize()
|
|
if (capabilities.status !== 'ready' || !this.client) throw new Error(capabilities.reason || 'Bitbybit OCCT is unavailable.')
|
|
return this.client
|
|
}
|
|
|
|
private async createProfileFace(client: BitByBitOCCT, profile: PlanarProfile): Promise<KernelShapeReference> {
|
|
const regions = [{ outer: profile.outer, holes: profile.holes }, ...(profile.additionalRegions ?? [])]
|
|
const faces = await Promise.all(regions.map(async (region) => {
|
|
const rings = [normalizedRing(region.outer), ...(region.holes ?? []).map(normalizedRing)]
|
|
const outerNormal = ringNormal(rings[0]) as Point3
|
|
const orientedRings = rings.map((ring, index) => index > 0 && dot(outerNormal, ringNormal(ring) as Point3) > 0 ? [...ring].reverse() : ring)
|
|
const wires = await Promise.all(orientedRings.map((points) => client.occt.shapes.wire.createPolygonWire({ points })))
|
|
return client.occt.shapes.face.createFaceFromWires({ shapes: wires, planar: true })
|
|
}))
|
|
return faces.length === 1 ? faces[0] : client.occt.shapes.compound.makeCompound({ shapes: faces })
|
|
}
|
|
|
|
private async createExtrusion(client: BitByBitOCCT, input: LinearFeatureParameters): Promise<KernelShapeReference> {
|
|
const direction = input.direction ?? [0, 1, 0]
|
|
const directionLength = magnitude(direction)
|
|
const sign = input.reversed ? -1 : 1
|
|
const extrusion: Point3 = direction.map((coordinate) => coordinate / directionLength * input.length * sign) as Point3
|
|
const taperAngle = input.taperAngle ?? 0
|
|
if (taperAngle !== 0) {
|
|
const inset = -input.length * Math.tan(taperAngle * Math.PI / 180)
|
|
const baseRing = normalizedRing(input.profile.outer)
|
|
const topRing = offsetProfileRing(baseRing, extrusion, inset)
|
|
const wires = await Promise.all([baseRing, topRing].map((points) => client.occt.shapes.wire.createPolygonWire({ points })))
|
|
return client.occt.operations.loftAdvanced({
|
|
shapes: wires,
|
|
makeSolid: true,
|
|
closed: false,
|
|
periodic: false,
|
|
straight: true,
|
|
nrPeriodicSections: 10,
|
|
useSmoothing: false,
|
|
maxUDegree: 3,
|
|
tolerance: 1e-7,
|
|
parType: 'approxCentripetal' as Inputs.OCCT.approxParametrizationTypeEnum,
|
|
})
|
|
}
|
|
const regions = [{ outer: input.profile.outer, holes: input.profile.holes }, ...(input.profile.additionalRegions ?? [])]
|
|
let faces = await Promise.all(regions.map((region) => this.createProfileFace(client, region)))
|
|
if (input.symmetricToPlane) {
|
|
const translation = extrusion.map((coordinate) => -coordinate / 2) as Point3
|
|
faces = await Promise.all(faces.map((face) => client.occt.transforms.translate({ shape: face, translation })))
|
|
}
|
|
const solids = await Promise.all(faces.map((face) => client.occt.operations.extrude({ shape: face, direction: extrusion })))
|
|
return solids.length === 1 ? solids[0] : client.occt.shapes.compound.makeCompound({ shapes: solids })
|
|
}
|
|
|
|
private async throughAllLength(client: BitByBitOCCT, shape: KernelShapeReference, direction: Point3) {
|
|
const mesh = await client.occt.shapeToMesh({ shape, precision: 0.1, adjustYtoZ: false })
|
|
const directionLength = magnitude(direction)
|
|
const normalized = direction.map((coordinate) => coordinate / directionLength) as Point3
|
|
let minimum = Infinity
|
|
let maximum = -Infinity
|
|
for (const face of mesh.faceList) {
|
|
for (let index = 0; index < face.vertexCoord.length; index += 3) {
|
|
const point: Point3 = [face.vertexCoord[index], face.vertexCoord[index + 1], face.vertexCoord[index + 2]]
|
|
const projection = dot(point, normalized)
|
|
minimum = Math.min(minimum, projection)
|
|
maximum = Math.max(maximum, projection)
|
|
}
|
|
}
|
|
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) throw new Error('Through-all pocket cannot determine the base Shape extent.')
|
|
const span = Math.max(maximum - minimum, 1)
|
|
return 2 * (span + Math.max(1, span * 0.05))
|
|
}
|
|
|
|
private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) {
|
|
const handle: ShapeHandle = {
|
|
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,
|
|
kernel: 'bitbybit-occt',
|
|
kind: 'solid',
|
|
documentId,
|
|
documentVersion,
|
|
}
|
|
const kernelReference = this.kernelReferences.get(reference.hash)
|
|
if (kernelReference) kernelReference.count += 1
|
|
else this.kernelReferences.set(reference.hash, { count: 1, reference })
|
|
this.shapes.set(handle.id, { handle, reference })
|
|
this.peakShapeCount = Math.max(this.peakShapeCount, this.shapes.size)
|
|
this.peakKernelReferenceCount = Math.max(this.peakKernelReferenceCount, this.kernelReferences.size)
|
|
this.syncOwnershipMetrics()
|
|
return handle
|
|
}
|
|
|
|
private syncOwnershipMetrics() {
|
|
this.capabilitiesState = {
|
|
...this.capabilitiesState,
|
|
shapeCount: this.shapes.size,
|
|
kernelReferenceCount: this.kernelReferences.size,
|
|
releasedShapeCount: this.releasedShapeCount,
|
|
peakShapeCount: this.peakShapeCount,
|
|
peakKernelReferenceCount: this.peakKernelReferenceCount,
|
|
}
|
|
}
|
|
|
|
private resolveShape(shape: ShapeHandle) {
|
|
if (shape.kernel !== 'bitbybit-occt') throw new Error(`Unsupported geometry kernel: ${shape.kernel}`)
|
|
const entry = this.shapes.get(shape.id)
|
|
if (!entry) throw new Error(`Shape handle is unknown or has been released: ${shape.id}`)
|
|
this.assertHandle(shape, entry.handle)
|
|
return entry
|
|
}
|
|
|
|
private assertHandle(actual: ShapeHandle, expected: ShapeHandle) {
|
|
assertShapeHandleIntegrity(actual, expected)
|
|
}
|
|
}
|