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

@@ -2,7 +2,7 @@
"baselineId": "bitbybit-web-cad-runtime-0.1", "baselineId": "bitbybit-web-cad-runtime-0.1",
"entrypoint": "BitBybitWebCadFacade", "entrypoint": "BitBybitWebCadFacade",
"frontendFramework": "React 18 + Vite 6 + TypeScript 5", "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" }, "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" }, "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", "targetBrowsers": "Capability matrix to be locked in P0-02",

View File

@@ -1595,7 +1595,7 @@ P0 基线/治理
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | 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-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 | | 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对象缓存与文档增量同步尚未实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry对象缓存与文档增量同步尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史E2E/黄金几何待补齐 |
@@ -1648,3 +1648,19 @@ P0 基线/治理
| 相同 Box 双句柄 | `PASS` | 释放第一个句柄后第二个仍可网格化;最终引用释放才删除 OCCT 缓存形状 | | 相同 Box 双句柄 | `PASS` | 释放第一个句柄后第二个仍可网格化;最终引用释放才删除 OCCT 缓存形状 |
浏览器验证使用 `crossOriginIsolated=true` 的 Chrome、专用 OCCT Worker 和 `precision=0.05`。上述曲面误差均小于网格精度;这是三角网格显示误差,不代表 B-Rep 几何尺寸误差。 浏览器验证使用 `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 mmUnion | 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 需实现依赖感知的细粒度安全回收和长会话内存基准。

View File

@@ -378,7 +378,8 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
const loadGeometry = async () => { const loadGeometry = async () => {
const capabilities = await facade.geometry.initialize() const capabilities = await facade.geometry.initialize()
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable') 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) { if (cancelled) {
await facade.geometry.release(shape) await facade.geometry.release(shape)
shape = null shape = null

View File

@@ -1,6 +1,6 @@
import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker'
import type { Inputs } from '@bitbybit-dev/occt' 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 KernelShapeReference = Inputs.OCCT.TopoDSShapePointer
type KernelMesh = Inputs.OCCT.DecomposedMeshDto 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.`) if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a finite non-negative number.`)
} }
const validateDocumentVersion = (value: number) => { const validateDocumentContext = (input: GeometryDocumentContext) => {
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('documentVersion must be a non-negative safe integer.') 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) => { 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.width, 'width')
finitePositive(input.length, 'length') finitePositive(input.length, 'length')
finitePositive(input.height, 'height') finitePositive(input.height, 'height')
validateDocumentVersion(input.documentVersion) validateDocumentContext(input)
const center = input.center ?? [0, 0, 0] const center = input.center ?? [0, 0, 0]
validateVector(center, 'center') validateVector(center, 'center')
} }
@@ -52,13 +53,13 @@ export const validateCylinderInput = (input: CreateCylinderInput) => {
validateVector(input.center ?? [0, 0, 0], 'center') validateVector(input.center ?? [0, 0, 0], 'center')
validateVector(input.direction ?? [0, 1, 0], 'direction', false) validateVector(input.direction ?? [0, 1, 0], 'direction', false)
validateAngle(input.angle ?? 360, 'angle') validateAngle(input.angle ?? 360, 'angle')
validateDocumentVersion(input.documentVersion) validateDocumentContext(input)
} }
export const validateSphereInput = (input: CreateSphereInput) => { export const validateSphereInput = (input: CreateSphereInput) => {
finitePositive(input.radius, 'radius') finitePositive(input.radius, 'radius')
validateVector(input.center ?? [0, 0, 0], 'center') validateVector(input.center ?? [0, 0, 0], 'center')
validateDocumentVersion(input.documentVersion) validateDocumentContext(input)
} }
export const validateConeInput = (input: CreateConeInput) => { export const validateConeInput = (input: CreateConeInput) => {
@@ -69,18 +70,39 @@ export const validateConeInput = (input: CreateConeInput) => {
validateVector(input.center ?? [0, 0, 0], 'center') validateVector(input.center ?? [0, 0, 0], 'center')
validateVector(input.direction ?? [0, 1, 0], 'direction', false) validateVector(input.direction ?? [0, 1, 0], 'direction', false)
validateAngle(input.angle ?? 360, 'angle') validateAngle(input.angle ?? 360, 'angle')
validateDocumentVersion(input.documentVersion) validateDocumentContext(input)
} }
export const validatePlacementInput = (input: ApplyPlacementInput) => { export const validatePlacementInput = (input: ApplyPlacementInput) => {
validateVector(input.placement.translation, 'translation') validateVector(input.placement.translation, 'translation')
validateVector(input.placement.rotationAxis, 'rotationAxis', false) validateVector(input.placement.rotationAxis, 'rotationAxis', false)
validateAngle(input.placement.rotationAngle, 'rotationAngle', true) 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) => { 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[]) => { 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], center: input.center ?? [0, 0, 0],
originOnCenter: input.originOnCenter ?? true, originOnCenter: input.originOnCenter ?? true,
}) })
return this.registerShape(kernelShape, input.documentVersion) return this.registerShape(kernelShape, input.documentId, input.documentVersion)
} }
async createCylinder(input: CreateCylinderInput): Promise<ShapeHandle> { async createCylinder(input: CreateCylinderInput): Promise<ShapeHandle> {
@@ -208,14 +230,14 @@ export class BitbybitGeometryRuntime {
angle: input.angle ?? 360, angle: input.angle ?? 360,
originOnCenter: input.originOnCenter ?? false, originOnCenter: input.originOnCenter ?? false,
}) })
return this.registerShape(kernelShape, input.documentVersion) return this.registerShape(kernelShape, input.documentId, input.documentVersion)
} }
async createSphere(input: CreateSphereInput): Promise<ShapeHandle> { async createSphere(input: CreateSphereInput): Promise<ShapeHandle> {
validateSphereInput(input) validateSphereInput(input)
const client = await this.readyClient() const client = await this.readyClient()
const kernelShape = await client.occt.shapes.solid.createSphere({ radius: input.radius, center: input.center ?? [0, 0, 0] }) 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> { async createCone(input: CreateConeInput): Promise<ShapeHandle> {
@@ -229,7 +251,7 @@ export class BitbybitGeometryRuntime {
center: input.center ?? [0, 0, 0], center: input.center ?? [0, 0, 0],
direction: input.direction ?? [0, 1, 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> { async applyPlacement(input: ApplyPlacementInput): Promise<ShapeHandle> {
@@ -243,7 +265,32 @@ export class BitbybitGeometryRuntime {
rotationAngle: input.placement.rotationAngle, rotationAngle: input.placement.rotationAngle,
scaleFactor: 1, 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> { async mesh(shape: ShapeHandle, precision = 0.05): Promise<MeshAsset> {
@@ -266,7 +313,7 @@ export class BitbybitGeometryRuntime {
} }
this.kernelReferences.delete(entry.reference.hash) this.kernelReferences.delete(entry.reference.hash)
const client = this.client 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() { dispose() {
@@ -288,11 +335,12 @@ export class BitbybitGeometryRuntime {
return this.client return this.client
} }
private registerShape(reference: KernelShapeReference, documentVersion: number) { private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) {
const handle: ShapeHandle = { const handle: ShapeHandle = {
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`, id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,
kernel: 'bitbybit-occt', kernel: 'bitbybit-occt',
kind: 'solid', kind: 'solid',
documentId,
documentVersion, documentVersion,
} }
const kernelReference = this.kernelReferences.get(reference.hash) const kernelReference = this.kernelReferences.get(reference.hash)

View File

@@ -1,14 +1,50 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import createBitbybitDevOcct from '@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt' 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' import { initializationComplete, onMessageInput } from '@bitbybit-dev/occt-worker'
const workerScope = self as DedicatedWorkerGlobalScope 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 () => { const initialize = async () => {
try { try {
const occt = await createBitbybitDevOcct() const occt = await createBitbybitDevOcct()
initializationComplete(occt, undefined) initializationComplete(occt, createWebCadPlugins(occt))
workerScope.onmessage = ({ data }) => onMessageInput(data, (message) => workerScope.postMessage(message)) workerScope.onmessage = ({ data }) => onMessageInput(data, (message) => workerScope.postMessage(message))
} catch (error) { } catch (error) {
workerScope.postMessage({ workerScope.postMessage({

View File

@@ -1,6 +1,6 @@
export { createMockFacade } from './mockFacade' export { createMockFacade } from './mockFacade'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport' 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 { 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'

View File

@@ -132,7 +132,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, 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() } }, 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 }, 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() }, viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
} }

View File

@@ -54,6 +54,7 @@ export type ShapeHandle = {
readonly id: string readonly id: string
readonly kernel: 'bitbybit-occt' readonly kernel: 'bitbybit-occt'
readonly kind: 'solid' readonly kind: 'solid'
readonly documentId: string
readonly documentVersion: number 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 width: number
length: number length: number
height: number height: number
center?: [number, number, number] center?: [number, number, number]
originOnCenter?: boolean originOnCenter?: boolean
documentVersion: number
} }
export type CreateCylinderInput = { export type CreateCylinderInput = GeometryDocumentContext & {
radius: number radius: number
height: number height: number
center?: [number, number, number] center?: [number, number, number]
direction?: [number, number, number] direction?: [number, number, number]
angle?: number angle?: number
originOnCenter?: boolean originOnCenter?: boolean
documentVersion: number
} }
export type CreateSphereInput = { export type CreateSphereInput = GeometryDocumentContext & {
radius: number radius: number
center?: [number, number, number] center?: [number, number, number]
documentVersion: number
} }
export type CreateConeInput = { export type CreateConeInput = GeometryDocumentContext & {
radius1: number radius1: number
radius2: number radius2: number
height: number height: number
center?: [number, number, number] center?: [number, number, number]
direction?: [number, number, number] direction?: [number, number, number]
angle?: number angle?: number
documentVersion: number
} }
export type Placement = { export type Placement = {
@@ -117,10 +119,25 @@ export type Placement = {
rotationAngle: number rotationAngle: number
} }
export type ApplyPlacementInput = { export type ApplyPlacementInput = GeometryDocumentContext & {
shape: ShapeHandle shape: ShapeHandle
placement: Placement 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 = { export type CommandState = {
@@ -246,6 +263,9 @@ export interface BitBybitWebCadFacade {
createSphere(input: CreateSphereInput): Promise<ShapeHandle> createSphere(input: CreateSphereInput): Promise<ShapeHandle>
createCone(input: CreateConeInput): Promise<ShapeHandle> createCone(input: CreateConeInput): Promise<ShapeHandle>
applyPlacement(input: ApplyPlacementInput): 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> mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
release(shape: ShapeHandle): Promise<void> release(shape: ShapeHandle): Promise<void>
dispose(): void dispose(): void

View File

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { createMockFacade } from '../src/facade/mockFacade' import { createMockFacade } from '../src/facade/mockFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' 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' import type { ShapeHandle } from '../src/facade/types'
test('facade exposes a stable initial document projection', () => { 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', () => { test('geometry boundary validates dimensions before invoking OCCT', () => {
assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentVersion: 1 }), /width/) 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], documentVersion: 1 }), /center/) 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, documentVersion: 0 })) assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentId: 'doc', documentVersion: 0 }))
assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentVersion: 1 }), /direction/) assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /direction/)
assert.throws(() => validateSphereInput({ radius: -1, documentVersion: 1 }), /radius/) assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/)
assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, 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', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentVersion: 2 }), /rotationAxis/) 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', () => { 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.doesNotThrow(() => assertShapeHandleIntegrity({ ...expected }, expected))
assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/) assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/)
assert.throws(() => assertShapeHandleIntegrity({ ...expected, id: 'shape-two' }, 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', () => { 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, { 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 }], 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: [], edgeList: [], pointsList: [],