P3-02 P5-02: integrate Bitbybit OCCT geometry runtime
This commit is contained in:
200
src/facade/geometryRuntime.ts
Normal file
200
src/facade/geometryRuntime.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
23
src/facade/geometryWorker.ts
Normal file
23
src/facade/geometryWorker.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import createBitbybitDevOcct from '@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt'
|
||||
import { initializationComplete, onMessageInput } from '@bitbybit-dev/occt-worker'
|
||||
|
||||
const workerScope = self as DedicatedWorkerGlobalScope
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
const occt = await createBitbybitDevOcct()
|
||||
initializationComplete(occt, undefined)
|
||||
workerScope.onmessage = ({ data }) => onMessageInput(data, (message) => workerScope.postMessage(message))
|
||||
} catch (error) {
|
||||
workerScope.postMessage({
|
||||
type: 'occ-initialization-failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void initialize()
|
||||
|
||||
export {}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBoxInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types'
|
||||
export type { BitBybitViewportAdapter, BitBybitWebCadFacade, CommandState, CreateBoxInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, MeshAsset, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore'
|
||||
import { BitbybitGeometryRuntime } from './geometryRuntime'
|
||||
import { ThreeViewportAdapter } from './threeViewport'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
@@ -51,6 +52,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
||||
|
||||
export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const projectPersistence = createSqliteProjectPersistence()
|
||||
const geometryRuntime = new BitbybitGeometryRuntime()
|
||||
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
|
||||
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
|
||||
const listeners = new Set<FacadeListener>()
|
||||
@@ -130,6 +132,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource },
|
||||
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
import type { BitBybitViewportAdapter } from './types'
|
||||
import type { BitBybitViewportAdapter, MeshAsset } from './types'
|
||||
|
||||
/** Internal renderer boundary. React receives only the adapter contract. */
|
||||
export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
@@ -20,6 +20,7 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
|
||||
this.renderer.domElement.dataset.geometrySource = 'three-fallback'
|
||||
host.appendChild(this.renderer.domElement)
|
||||
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
|
||||
grid.rotation.x = 0
|
||||
@@ -35,7 +36,6 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
this.scene.add(key)
|
||||
const render = () => {
|
||||
if (!this.renderer || !this.scene || !this.camera) return
|
||||
this.selection && (this.selection.rotation.y += 0.002)
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.frame = requestAnimationFrame(render)
|
||||
}
|
||||
@@ -43,6 +43,32 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
render()
|
||||
}
|
||||
|
||||
setMesh(mesh: MeshAsset | null) {
|
||||
if (!this.selection) return
|
||||
if (!mesh) {
|
||||
this.selection.visible = false
|
||||
if (this.renderer) {
|
||||
this.renderer.domElement.dataset.geometrySource = 'none'
|
||||
delete this.renderer.domElement.dataset.triangleCount
|
||||
}
|
||||
return
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3))
|
||||
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1))
|
||||
if (!mesh.normals.some((component) => component !== 0)) geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
this.selection.geometry.dispose()
|
||||
this.selection.geometry = geometry
|
||||
this.selection.visible = true
|
||||
this.selection.position.set(0, -mesh.bounds.min[1], 0)
|
||||
if (this.renderer) {
|
||||
this.renderer.domElement.dataset.geometrySource = 'bitbybit-occt'
|
||||
this.renderer.domElement.dataset.triangleCount = String(mesh.indices.length / 3)
|
||||
}
|
||||
}
|
||||
|
||||
resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
|
||||
if (!this.renderer || !this.camera) return
|
||||
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)
|
||||
|
||||
@@ -41,6 +41,50 @@ export type ProjectResource = {
|
||||
refCount: number
|
||||
}
|
||||
|
||||
export type GeometryCapabilities = {
|
||||
provider: 'Bitbybit OCCT'
|
||||
version: '1.1.1'
|
||||
status: 'idle' | 'initializing' | 'ready' | 'failed' | 'unavailable'
|
||||
worker: boolean
|
||||
wasm: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type ShapeHandle = {
|
||||
readonly id: string
|
||||
readonly kernel: 'bitbybit-occt'
|
||||
readonly kind: 'solid'
|
||||
readonly documentVersion: number
|
||||
}
|
||||
|
||||
export type SubshapeRef = {
|
||||
shapeId: string
|
||||
kind: 'face' | 'edge' | 'vertex'
|
||||
persistentId: string
|
||||
topologyVersion: number
|
||||
}
|
||||
|
||||
export type MeshAsset = {
|
||||
shapeId: string
|
||||
topologyVersion: number
|
||||
positions: Float32Array
|
||||
normals: Float32Array
|
||||
indices: Uint32Array
|
||||
bounds: {
|
||||
min: [number, number, number]
|
||||
max: [number, number, number]
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateBoxInput = {
|
||||
width: number
|
||||
length: number
|
||||
height: number
|
||||
center?: [number, number, number]
|
||||
originOnCenter?: boolean
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type CommandState = {
|
||||
id: string
|
||||
status: 'hidden' | 'disabled' | 'enabled' | 'active'
|
||||
@@ -97,6 +141,7 @@ export type ViewportBackend = 'webgl2' | 'webgpu'
|
||||
export interface BitBybitViewportAdapter {
|
||||
mount(host: HTMLElement): void
|
||||
resize(width: number, height: number, devicePixelRatio?: number): void
|
||||
setMesh(mesh: MeshAsset | null): void
|
||||
setSelection(objectId: string): void
|
||||
dispose(): void
|
||||
getBackend(): ViewportBackend
|
||||
@@ -155,6 +200,14 @@ export interface BitBybitWebCadFacade {
|
||||
release(hash: string): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly geometry: {
|
||||
capabilities(): GeometryCapabilities
|
||||
initialize(): Promise<GeometryCapabilities>
|
||||
createBox(input: CreateBoxInput): Promise<ShapeHandle>
|
||||
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
readonly viewport: {
|
||||
createAdapter(): BitBybitViewportAdapter
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user