diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index 7aa5479..b8032ff 100644 --- a/config/runtime-baseline.json +++ b/config/runtime-baseline.json @@ -2,7 +2,7 @@ "baselineId": "bitbybit-web-cad-runtime-0.1", "entrypoint": "BitBybitWebCadFacade", "frontendFramework": "React 18 + Vite 6 + TypeScript 5", - "geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "primitive-and-placement-integration" }, + "geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "primitive-placement-and-boolean-integration" }, "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "adapter-skeleton" }, "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "worker-skeleton" }, "targetBrowsers": "Capability matrix to be locked in P0-02", diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 885beba..3879e03 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1595,7 +1595,7 @@ P0 基线/治理 | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | -| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;底层 OCCT hash 不出运行时,句柄篡改/释放后访问被拒绝;稳定子形状命名尚未实现 | +| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;稳定子形状命名尚未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | @@ -1648,3 +1648,19 @@ P0 基线/治理 | 相同 Box 双句柄 | `PASS` | 释放第一个句柄后第二个仍可网格化;最终引用释放才删除 OCCT 缓存形状 | 浏览器验证使用 `crossOriginIsolated=true` 的 Chrome、专用 OCCT Worker 和 `precision=0.05`。上述曲面误差均小于网格精度;这是三角网格显示误差,不代表 B-Rep 几何尺寸误差。 + +### 16.13 P3-04 Boolean 子阶段验证记录 + +Facade 已提供 `union`、`cut`、`intersection` 三个文档作用域 API。每个操作至少需要规定数量的输入,所有 ShapeHandle 必须属于结果 `documentId`,且输入版本不能高于结果版本;运算返回新句柄,源句柄保持有效。 + +Bitbybit OCCT Worker 1.1.1 的内置 Intersection 包装在调用 `BRepAlgoAPI_Common.Build()` 之前检查 `HasGenerated()`,可对正常相交实体返回空 Compound。项目没有静默接受空结果,而是在自有 Worker 插件中直接按正确顺序执行 `Build()`、检查 `IsDone()/HasErrors()`、取得 `Shape()` 并统一同域面。调用仍通过 Bitbybit `OCCTWorkerManager`,React 和页面无法访问 Worker 或 OCCT。 + +| 算例 | 结果 | 包围盒 | +|---|---|---| +| 两个 4 mm Box,第二个沿 X 偏移 2 mm,Union | 12 三角形 | X `[-2,4]`、Y/Z `[-2,2]` | +| 同一输入,Cut `A-B` | 12 三角形 | X `[-2,0]`、Y/Z `[-2,2]` | +| 同一输入,Intersection | 12 三角形 | X `[0,2]`、Y/Z `[-2,2]` | +| 释放三个结果后访问两个源 Box | `PASS` | 源包围盒分别保持 `[-2,2]` 和 X `[0,4]` | +| 跨文档/未来版本/输入不足 | `PASS` | Facade 同步拒绝,不触发 OCCT 运算 | + +OCCT Boolean 结果可能与源形状共享底层拓扑。1.1.1 的 `deleteShape` 会执行全量几何清理,逐个删除结果可能破坏仍存活源形状。因此当前 Facade 在句柄释放时更新内部引用计数,并在活动句柄归零后统一 `cleanAllCache()`;Worker 终止时也会释放全部 WASM 内存。P3-06 需实现依赖感知的细粒度安全回收和长会话内存基准。 diff --git a/src/App.tsx b/src/App.tsx index b09dffe..8eb90bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 271b123..480c060 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -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 { @@ -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 { 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 { @@ -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 { @@ -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 { + 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 { + 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 { + 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 { @@ -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) diff --git a/src/facade/geometryWorker.ts b/src/facade/geometryWorker.ts index 7913cc6..72a4897 100644 --- a/src/facade/geometryWorker.ts +++ b/src/facade/geometryWorker.ts @@ -1,14 +1,50 @@ /// 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({ diff --git a/src/facade/index.ts b/src/facade/index.ts index 06ba6de..33f5136 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -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' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 110d449..0e3d41e 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -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, } diff --git a/src/facade/types.ts b/src/facade/types.ts index 9239d9f..aeddef7 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -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 createCone(input: CreateConeInput): Promise applyPlacement(input: ApplyPlacementInput): Promise + union(input: BooleanUnionInput): Promise + cut(input: BooleanCutInput): Promise + intersection(input: BooleanIntersectionInput): Promise mesh(shape: ShapeHandle, precision?: number): Promise release(shape: ShapeHandle): Promise dispose(): void diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 939336e..360b8b6 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { createMockFacade } from '../src/facade/mockFacade' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' -import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from '../src/facade/geometryRuntime' +import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from '../src/facade/geometryRuntime' import type { ShapeHandle } from '../src/facade/types' test('facade exposes a stable initial document projection', () => { @@ -17,24 +17,34 @@ test('facade exposes a stable initial document projection', () => { }) test('geometry boundary validates dimensions before invoking OCCT', () => { - assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentVersion: 1 }), /width/) - assert.throws(() => validateBoxInput({ width: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentVersion: 1 }), /center/) - assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentVersion: 0 })) - assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentVersion: 1 }), /direction/) - assert.throws(() => validateSphereInput({ radius: -1, documentVersion: 1 }), /radius/) - assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentVersion: 1 }), /radius/) - assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentVersion: 2 }), /rotationAxis/) + assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentId: 'doc', documentVersion: 1 }), /width/) + assert.throws(() => validateBoxInput({ width: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentId: 'doc', documentVersion: 1 }), /center/) + assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentId: 'doc', documentVersion: 0 })) + assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /direction/) + assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/) + assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/) + assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/) }) test('geometry handles reject forged document ownership', () => { - const expected: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentVersion: 4 } + const expected: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 4 } assert.doesNotThrow(() => assertShapeHandleIntegrity({ ...expected }, expected)) assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/) assert.throws(() => assertShapeHandleIntegrity({ ...expected, id: 'shape-two' }, expected), /integrity/) + assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentId: 'doc-two' }, expected), /integrity/) +}) + +test('boolean geometry rejects cross-document and future-version operands', () => { + const first: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 2 } + const second: ShapeHandle = { ...first, id: 'shape-two', documentVersion: 3 } + assert.doesNotThrow(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 3 })) + assert.throws(() => validateBooleanUnionInput({ shapes: [first, { ...second, documentId: 'doc-two' }], documentId: 'doc-one', documentVersion: 3 }), /another document/) + assert.throws(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 2 }), /newer/) + assert.throws(() => validateBooleanUnionInput({ shapes: [first], documentId: 'doc-one', documentVersion: 2 }), /at least 2/) }) test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () => { - const shape: ShapeHandle = { id: 'shape-test', kernel: 'bitbybit-occt', kind: 'solid', documentVersion: 7 } + const shape: ShapeHandle = { id: 'shape-test', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 7 } const mesh = normalizeBitbybitMesh(shape, { faceList: [{ faceIndex: 1, vertexCoord: [0, 0, 0, 2, 0, 0, 0, 3, 0], normalCoord: [0, 0, 1, 0, 0, 1, 0, 0, 1], triIndexes: [0, 1, 2], numberOfTriangles: 1 }], edgeList: [], pointsList: [],