Files
Web_FreeCAD_Bitbybit/src/facade/geometryRuntime.ts
wangdequan 97967041e2
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled
feat: prepare FreeCAD private naming worker linkage
2026-08-11 23:16:22 -04:00

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)
}
}