P3-04: add OCCT pad pocket and revolution

This commit is contained in:
2026-08-02 07:30:29 -04:00
parent 9dc5cfe3fd
commit 8d8d1b7c26
8 changed files with 189 additions and 6 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, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, MeshAsset, ShapeHandle } from './types'
import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle } from './types'
type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
type KernelMesh = Inputs.OCCT.DecomposedMeshDto
@@ -101,6 +101,64 @@ export const validateBooleanCutInput = (input: BooleanCutInput) => {
export const validateBooleanIntersectionInput = (input: BooleanIntersectionInput) => validateBooleanShapes(input, input.shapes, 2)
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 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
}
export const validatePlanarProfile = (profile: PlanarProfile) => {
const rings = [normalizedRing(profile.outer), ...(profile.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)
}
export const validatePadInput = (input: PadInput) => 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')
}
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}`)
}
@@ -293,6 +351,35 @@ export class BitbybitGeometryRuntime {
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 pocket(input: PocketInput): Promise<ShapeHandle> {
validatePocketInput(input)
const base = this.resolveShape(input.base).reference
const client = await this.readyClient()
const tool = await this.createExtrusion(client, input)
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 face = await this.createProfileFace(client, input.profile)
const kernelShape = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.feature.revolution', {
shape: face,
axisOrigin: input.axisOrigin ?? [0, 0, 0],
axisDirection: input.axisDirection ?? [0, 1, 0],
angle: input.angle ?? 360,
}) as KernelShapeReference
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
}
async mesh(shape: ShapeHandle, precision = 0.05): Promise<MeshAsset> {
finitePositive(precision, 'precision')
const entry = this.resolveShape(shape)
@@ -335,6 +422,27 @@ export class BitbybitGeometryRuntime {
return this.client
}
private async createProfileFace(client: BitByBitOCCT, profile: PlanarProfile): Promise<KernelShapeReference> {
const rings = [normalizedRing(profile.outer), ...(profile.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 })
}
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
let face = await this.createProfileFace(client, input.profile)
if (input.symmetricToPlane) {
const translation = extrusion.map((coordinate) => -coordinate / 2) as Point3
face = await client.occt.transforms.translate({ shape: face, translation })
}
return client.occt.operations.extrude({ shape: face, direction: extrusion })
}
private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) {
const handle: ShapeHandle = {
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,

View File

@@ -39,6 +39,25 @@ const createWebCadPlugins = (occt: MainModule) => ({
}
},
},
feature: {
revolution: (inputs: { shape: TopoDS_Shape; axisOrigin: [number, number, number]; axisDirection: [number, number, number]; angle: number }) => {
const point = new occt.gp_Pnt(inputs.axisOrigin[0], inputs.axisOrigin[1], inputs.axisOrigin[2])
const direction = new occt.gp_Dir(inputs.axisDirection[0], inputs.axisDirection[1], inputs.axisDirection[2])
const axis = new occt.gp_Ax1(point, direction)
const operation = inputs.angle >= 360
? new occt.BRepPrimAPI_MakeRevol(inputs.shape, axis)
: new occt.BRepPrimAPI_MakeRevol(inputs.shape, axis, inputs.angle * Math.PI / 180, false)
try {
if (!operation.IsDone()) throw new Error('OCCT revolution failed.')
return operation.Shape()
} finally {
operation.delete()
axis.delete()
direction.delete()
point.delete()
}
},
},
})
const initialize = async () => {

View File

@@ -1,6 +1,6 @@
export { createMockFacade } from './mockFacade'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from './geometryRuntime'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
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'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, RevolutionInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types'

View File

@@ -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), 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() },
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), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(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,
}

View File

@@ -140,6 +140,34 @@ export type BooleanIntersectionInput = GeometryDocumentContext & {
keepEdges?: boolean
}
export type Point3 = [number, number, number]
export type PlanarProfile = {
outer: Point3[]
holes?: Point3[][]
}
export type LinearFeatureParameters = {
profile: PlanarProfile
length: number
direction?: Point3
reversed?: boolean
symmetricToPlane?: boolean
}
export type PadInput = GeometryDocumentContext & LinearFeatureParameters
export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & {
base: ShapeHandle
}
export type RevolutionInput = GeometryDocumentContext & {
profile: PlanarProfile
axisOrigin?: Point3
axisDirection?: Point3
angle?: number
}
export type CommandState = {
id: string
status: 'hidden' | 'disabled' | 'enabled' | 'active'
@@ -266,6 +294,9 @@ export interface BitBybitWebCadFacade {
union(input: BooleanUnionInput): Promise<ShapeHandle>
cut(input: BooleanCutInput): Promise<ShapeHandle>
intersection(input: BooleanIntersectionInput): Promise<ShapeHandle>
pad(input: PadInput): Promise<ShapeHandle>
pocket(input: PocketInput): Promise<ShapeHandle>
revolution(input: RevolutionInput): Promise<ShapeHandle>
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
release(shape: ShapeHandle): Promise<void>
dispose(): void