P3-02 P5-02: integrate Bitbybit OCCT geometry runtime

This commit is contained in:
2026-08-02 06:45:49 -04:00
parent feb2eb8be6
commit 9f2d11e151
14 changed files with 454 additions and 12 deletions

View File

@@ -0,0 +1,200 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt'
import type { CreateBoxInput, GeometryCapabilities, MeshAsset, ShapeHandle } from './types'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
type KernelMesh = Inputs.OCCT.DecomposedMeshDto
type ShapeEntry = { handle: ShapeHandle; reference: KernelShapeReference }
const unavailableCapabilities = (): GeometryCapabilities => ({
provider: 'Bitbybit OCCT',
version: '1.1.1',
status: typeof Worker === 'undefined' ? 'unavailable' : 'idle',
worker: typeof Worker !== 'undefined',
wasm: typeof WebAssembly !== 'undefined',
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.`)
}
export const validateBoxInput = (input: CreateBoxInput) => {
finitePositive(input.width, 'width')
finitePositive(input.length, 'length')
finitePositive(input.height, 'height')
if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('documentVersion must be a non-negative safe integer.')
const center = input.center ?? [0, 0, 0]
if (center.length !== 3 || center.some((coordinate) => !Number.isFinite(coordinate))) throw new RangeError('center must contain three finite coordinates.')
}
export const assertShapeHandleIntegrity = (actual: ShapeHandle, expected: ShapeHandle) => {
if (actual.id !== expected.id || actual.kernel !== expected.kernel || actual.kind !== expected.kind || 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
positions.push(...face.vertexCoord)
if (face.normalCoord.length === face.vertexCoord.length) normals.push(...face.normalCoord)
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): MeshAsset => {
const positions: number[] = []
const normals: number[] = []
const indices: number[] = []
mesh.faceList.forEach((face) => appendFace(face, positions, normals, indices))
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])
}
}
return {
shapeId: shape.id,
topologyVersion: shape.documentVersion,
positions: new Float32Array(positions),
normals: new Float32Array(normals),
indices: new Uint32Array(indices),
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>()
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.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,
})
const handle: ShapeHandle = {
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,
kernel: 'bitbybit-occt',
kind: 'solid',
documentVersion: input.documentVersion,
}
this.shapes.set(handle.id, { handle, reference: kernelShape })
return handle
}
async mesh(shape: ShapeHandle, precision = 0.05): Promise<MeshAsset> {
finitePositive(precision, 'precision')
const client = await this.readyClient()
const entry = this.resolveShape(shape)
const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false })
return normalizeBitbybitMesh(entry.handle, mesh)
}
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)
const client = this.client
if (client && this.capabilitiesState.status === 'ready') await client.occt.deleteShape({ shape: entry.reference })
}
dispose() {
this.cancelInitialization?.()
this.shapes.clear()
this.client?.occtWorkerManager.cleanPromisesMade()
this.worker?.terminate()
this.client = null
this.worker = null
this.initialization = null
this.cancelInitialization = 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 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)
}
}