P3-04: add document-scoped OCCT booleans

This commit is contained in:
2026-08-02 07:19:22 -04:00
parent 9e08f99429
commit 9dc5cfe3fd
9 changed files with 174 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt'
import type { ApplyPlacementInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, MeshAsset, ShapeHandle } from './types'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, MeshAsset, ShapeHandle } from './types'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
type KernelMesh = Inputs.OCCT.DecomposedMeshDto
@@ -24,8 +24,9 @@ const finiteNonNegative = (value: number, name: string) => {
if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a finite non-negative number.`)
}
const validateDocumentVersion = (value: number) => {
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('documentVersion must be a non-negative safe integer.')
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) => {
@@ -41,7 +42,7 @@ export const validateBoxInput = (input: CreateBoxInput) => {
finitePositive(input.width, 'width')
finitePositive(input.length, 'length')
finitePositive(input.height, 'height')
validateDocumentVersion(input.documentVersion)
validateDocumentContext(input)
const center = input.center ?? [0, 0, 0]
validateVector(center, 'center')
}
@@ -52,13 +53,13 @@ export const validateCylinderInput = (input: CreateCylinderInput) => {
validateVector(input.center ?? [0, 0, 0], 'center')
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
validateAngle(input.angle ?? 360, 'angle')
validateDocumentVersion(input.documentVersion)
validateDocumentContext(input)
}
export const validateSphereInput = (input: CreateSphereInput) => {
finitePositive(input.radius, 'radius')
validateVector(input.center ?? [0, 0, 0], 'center')
validateDocumentVersion(input.documentVersion)
validateDocumentContext(input)
}
export const validateConeInput = (input: CreateConeInput) => {
@@ -69,18 +70,39 @@ export const validateConeInput = (input: CreateConeInput) => {
validateVector(input.center ?? [0, 0, 0], 'center')
validateVector(input.direction ?? [0, 1, 0], 'direction', false)
validateAngle(input.angle ?? 360, 'angle')
validateDocumentVersion(input.documentVersion)
validateDocumentContext(input)
}
export const validatePlacementInput = (input: ApplyPlacementInput) => {
validateVector(input.placement.translation, 'translation')
validateVector(input.placement.rotationAxis, 'rotationAxis', false)
validateAngle(input.placement.rotationAngle, 'rotationAngle', true)
validateDocumentVersion(input.documentVersion)
validateDocumentContext(input)
validateShapeContext(input, input.shape)
}
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)
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}`)
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[]) => {
@@ -194,7 +216,7 @@ export class BitbybitGeometryRuntime {
center: input.center ?? [0, 0, 0],
originOnCenter: input.originOnCenter ?? true,
})
return this.registerShape(kernelShape, input.documentVersion)
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
}
async createCylinder(input: CreateCylinderInput): Promise<ShapeHandle> {
@@ -208,14 +230,14 @@ export class BitbybitGeometryRuntime {
angle: input.angle ?? 360,
originOnCenter: input.originOnCenter ?? false,
})
return this.registerShape(kernelShape, input.documentVersion)
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.documentVersion)
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
}
async createCone(input: CreateConeInput): Promise<ShapeHandle> {
@@ -229,7 +251,7 @@ export class BitbybitGeometryRuntime {
center: input.center ?? [0, 0, 0],
direction: input.direction ?? [0, 1, 0],
})
return this.registerShape(kernelShape, input.documentVersion)
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
}
async applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle> {
@@ -243,7 +265,32 @@ export class BitbybitGeometryRuntime {
rotationAngle: input.placement.rotationAngle,
scaleFactor: 1,
})
return this.registerShape(kernelShape, input.documentVersion)
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 mesh(shape: ShapeHandle, precision = 0.05): Promise<MeshAsset> {
@@ -266,7 +313,7 @@ export class BitbybitGeometryRuntime {
}
this.kernelReferences.delete(entry.reference.hash)
const client = this.client
if (client && this.capabilitiesState.status === 'ready') await client.occt.deleteShape({ shape: entry.reference })
if (client && this.capabilitiesState.status === 'ready' && this.shapes.size === 0) await client.occt.cleanAllCache()
}
dispose() {
@@ -288,11 +335,12 @@ export class BitbybitGeometryRuntime {
return this.client
}
private registerShape(reference: KernelShapeReference, documentVersion: number) {
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)