P3-04: add document-scoped OCCT booleans
This commit is contained in:
@@ -378,7 +378,8 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
|
||||
const loadGeometry = async () => {
|
||||
const capabilities = await facade.geometry.initialize()
|
||||
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable')
|
||||
shape = await facade.geometry.createBox({ width: 2.7, length: 1.4, height: 1.6, center: [0, 0, 0], originOnCenter: true, documentVersion: facade.app.document.getActive().version })
|
||||
const document = facade.app.document.getActive()
|
||||
shape = await facade.geometry.createBox({ width: 2.7, length: 1.4, height: 1.6, center: [0, 0, 0], originOnCenter: true, documentId: document.id, documentVersion: document.version })
|
||||
if (cancelled) {
|
||||
await facade.geometry.release(shape)
|
||||
shape = null
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import createBitbybitDevOcct from '@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt'
|
||||
import type { MainModule, TopoDS_Shape } from '@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt'
|
||||
import { initializationComplete, onMessageInput } from '@bitbybit-dev/occt-worker'
|
||||
|
||||
const workerScope = self as DedicatedWorkerGlobalScope
|
||||
|
||||
const createWebCadPlugins = (occt: MainModule) => ({
|
||||
dependencies: {},
|
||||
boolean: {
|
||||
intersection: (inputs: { shapes: TopoDS_Shape[]; keepEdges: boolean }) => {
|
||||
if (inputs.shapes.length < 2) throw new Error('Intersection requires at least two shapes.')
|
||||
let current = inputs.shapes[0]
|
||||
let ownsCurrent = false
|
||||
try {
|
||||
for (const next of inputs.shapes.slice(1)) {
|
||||
const operation = new occt.BRepAlgoAPI_Common(current, next)
|
||||
operation.Build()
|
||||
if (!operation.IsDone() || operation.HasErrors()) {
|
||||
operation.delete()
|
||||
throw new Error('OCCT common operation failed.')
|
||||
}
|
||||
const common = operation.Shape()
|
||||
operation.delete()
|
||||
if (ownsCurrent) current.delete()
|
||||
current = common
|
||||
ownsCurrent = true
|
||||
}
|
||||
if (!inputs.keepEdges) {
|
||||
const unified = occt.ShapeUpgrade_UnifySameDomain_Perform(current, true, true, false)
|
||||
current.delete()
|
||||
current = unified
|
||||
}
|
||||
return current
|
||||
} catch (error) {
|
||||
if (ownsCurrent) current.delete()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
const occt = await createBitbybitDevOcct()
|
||||
initializationComplete(occt, undefined)
|
||||
initializationComplete(occt, createWebCadPlugins(occt))
|
||||
workerScope.onmessage = ({ data }) => onMessageInput(data, (message) => workerScope.postMessage(message))
|
||||
} catch (error) {
|
||||
workerScope.postMessage({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from './geometryRuntime'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, MeshAsset, ModelTreeItem, PersistenceCapabilities, Placement, ProjectResource, ProjectSaveResult, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, MeshAsset, ModelTreeItem, PersistenceCapabilities, Placement, ProjectResource, ProjectSaveResult, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'
|
||||
|
||||
@@ -132,7 +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), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
|
||||
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(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,
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ShapeHandle = {
|
||||
readonly id: string
|
||||
readonly kernel: 'bitbybit-occt'
|
||||
readonly kind: 'solid'
|
||||
readonly documentId: string
|
||||
readonly documentVersion: number
|
||||
}
|
||||
|
||||
@@ -76,39 +77,40 @@ export type MeshAsset = {
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateBoxInput = {
|
||||
export type GeometryDocumentContext = {
|
||||
documentId: string
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type CreateBoxInput = GeometryDocumentContext & {
|
||||
width: number
|
||||
length: number
|
||||
height: number
|
||||
center?: [number, number, number]
|
||||
originOnCenter?: boolean
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type CreateCylinderInput = {
|
||||
export type CreateCylinderInput = GeometryDocumentContext & {
|
||||
radius: number
|
||||
height: number
|
||||
center?: [number, number, number]
|
||||
direction?: [number, number, number]
|
||||
angle?: number
|
||||
originOnCenter?: boolean
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type CreateSphereInput = {
|
||||
export type CreateSphereInput = GeometryDocumentContext & {
|
||||
radius: number
|
||||
center?: [number, number, number]
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type CreateConeInput = {
|
||||
export type CreateConeInput = GeometryDocumentContext & {
|
||||
radius1: number
|
||||
radius2: number
|
||||
height: number
|
||||
center?: [number, number, number]
|
||||
direction?: [number, number, number]
|
||||
angle?: number
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type Placement = {
|
||||
@@ -117,10 +119,25 @@ export type Placement = {
|
||||
rotationAngle: number
|
||||
}
|
||||
|
||||
export type ApplyPlacementInput = {
|
||||
export type ApplyPlacementInput = GeometryDocumentContext & {
|
||||
shape: ShapeHandle
|
||||
placement: Placement
|
||||
documentVersion: number
|
||||
}
|
||||
|
||||
export type BooleanUnionInput = GeometryDocumentContext & {
|
||||
shapes: ShapeHandle[]
|
||||
keepEdges?: boolean
|
||||
}
|
||||
|
||||
export type BooleanCutInput = GeometryDocumentContext & {
|
||||
base: ShapeHandle
|
||||
tools: ShapeHandle[]
|
||||
keepEdges?: boolean
|
||||
}
|
||||
|
||||
export type BooleanIntersectionInput = GeometryDocumentContext & {
|
||||
shapes: ShapeHandle[]
|
||||
keepEdges?: boolean
|
||||
}
|
||||
|
||||
export type CommandState = {
|
||||
@@ -246,6 +263,9 @@ export interface BitBybitWebCadFacade {
|
||||
createSphere(input: CreateSphereInput): Promise<ShapeHandle>
|
||||
createCone(input: CreateConeInput): Promise<ShapeHandle>
|
||||
applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle>
|
||||
union(input: BooleanUnionInput): Promise<ShapeHandle>
|
||||
cut(input: BooleanCutInput): Promise<ShapeHandle>
|
||||
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
|
||||
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
dispose(): void
|
||||
|
||||
Reference in New Issue
Block a user