diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index e31e583..09ee935 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 internal adapter", "status": "planned" }, + "geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "basic-runtime-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 0d9f864..0a51849 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1582,7 +1582,7 @@ P0 基线/治理 | 任务 | 状态 | 实际证据 | |---|---|---| | P0-01 FreeCAD 基线文件 | `IN PROGRESS` | `config/freecad-baseline.json` 已建立;精确源码提交和构建参数仍需 POC 验证 | -| P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立;Three.js 锁定为当前 npm 最新 `0.185.1`,其他内核版本待锁定 | +| P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立;Three.js 锁定 `0.185.1`,Bitbybit OCCT Worker/OCCT/Base 锁定 `1.1.1` 并记录 WASM SHA-256;FreeCAD 自构建内核仍待锁定 | | P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | | P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | | P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 | @@ -1594,6 +1594,10 @@ P0 基线/治理 | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | 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-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box 创建与三角化通过;Cylinder/Sphere/Cone、Placement、单位误差预算和黄金模型尚未实现 | +| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | 本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。 @@ -1613,3 +1617,20 @@ P0 基线/治理 | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 | 仍未通过 G4/G5:自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 + +### 16.11 P3 Bitbybit OCCT 几何运行时验证记录 + +本次接入使用 Bitbybit 官方发布的 OCCT WASM 包,不把它表述为 FreeCAD 业务逻辑已经移植。FreeCAD 的 DocumentObject、Property、Expression、重计算、PartDesign Body/Tip、Sketcher 求解器、稳定拓扑命名和 FCStd 映射仍由 P4/P7 后续任务实现。 + +| 检查 | 结果 | 证据 | +|---|---|---| +| 精确依赖 | `PASS` | `@bitbybit-dev/occt-worker`、`@bitbybit-dev/occt`、`@bitbybit-dev/base` 均为 `1.1.1`,无浮动版本 | +| WASM 资源 | `PASS` | 原始资源 34,524,750 字节;SHA-256 `1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656` | +| Worker 隔离 | `PASS` | OCCT 初始化和计算在专用 module Worker;React 及页面不导入 OCCT/Worker/Three | +| Facade Box | `PASS` | Chrome 中创建 `2 × 3 × 4` Box,得到 24 个面顶点、12 个三角形,所有坐标为有限数 | +| 轴语义 | `PASS` | `originOnCenter=true` 时包围盒为 X `[-1,1]`、Y `[-2,2]`、Z `[-1.5,1.5]`,即 Bitbybit Box 的 `width→X`、`height→Y`、`length→Z` | +| 句柄生命周期 | `PASS` | Facade 不公开 OCCT hash;释放后再次网格化被拒绝,已知 ID 的字段篡改触发完整性错误 | +| Three 网格消费 | `PASS` | `MeshAsset` 转为 Three `BufferGeometry`,旧 geometry 在替换/销毁时释放;选择变化不再重建场景 | +| 生产构建 | `PASS` | Vite 输出独立 `geometryWorker` 与 OCCT WASM;WASM 约 34.5 MB,gzip 约 8.6 MB | + +限制和下一任务:P3-02 还需设计稳定 `SubshapeRef` 的持久命名算法;P3-03 继续加入 Cylinder、Sphere、Cone 和 Placement,并用黄金包围盒/体积核验;P5-02 需要按文档对象 ID 建立多对象网格缓存,而不是仅加载一个启动预览实体。 diff --git a/package-lock.json b/package-lock.json index 16665c7..01ce324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "bitbybit-web-cad", "version": "0.1.0", "dependencies": { + "@bitbybit-dev/occt": "1.1.1", + "@bitbybit-dev/occt-worker": "1.1.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", "lucide-react": "^0.468.0", "react": "^18.3.1", @@ -290,6 +292,40 @@ "node": ">=6.9.0" } }, + "node_modules/@bitbybit-dev/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitbybit-dev/base/-/base-1.1.1.tgz", + "integrity": "sha512-UkZmTPvyYvVSvWw2QWMAdIJJ5o1RtPL5/CuNk68E6mFD5D3sYLWDsytNiYebvm2zTo3MpCX+Ig8d3h8EtQSmcA==", + "funding": { + "type": "corporate", + "url": "https://bitbybit.dev/auth/pick-plan" + } + }, + "node_modules/@bitbybit-dev/occt": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitbybit-dev/occt/-/occt-1.1.1.tgz", + "integrity": "sha512-Bt9rVSryd2BhE/QXGz6GRgsmApAGzjhDGQC2nIK6eQ0/4XWvGfvmFBLPaeSXGzHbbpQ0uGMY9IX4BabK7oLoww==", + "dependencies": { + "@bitbybit-dev/base": "1.1.1" + }, + "funding": { + "type": "corporate", + "url": "https://bitbybit.dev/auth/pick-plan" + } + }, + "node_modules/@bitbybit-dev/occt-worker": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitbybit-dev/occt-worker/-/occt-worker-1.1.1.tgz", + "integrity": "sha512-aSprAJ8KjKPc9Ba68CqB4aVDechCtwb5jKtMtiKofpKVhlkKlvKKZphjzHKKbHkKj4GWSFD3bad4Oh32BiF5SA==", + "dependencies": { + "@bitbybit-dev/occt": "1.1.1", + "rxjs": "7.8.2" + }, + "funding": { + "type": "corporate", + "url": "https://bitbybit.dev/auth/pick-plan" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -1651,6 +1687,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1698,6 +1742,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/tsx": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", diff --git a/package.json b/package.json index 5a7ef66..09e0089 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "verify": "npm run check:facade-boundary && npm run test:facade && npm run build" }, "dependencies": { + "@bitbybit-dev/occt": "1.1.1", + "@bitbybit-dev/occt-worker": "1.1.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", "lucide-react": "^0.468.0", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 43f0722..b09dffe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,7 +53,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' -import { createMockFacade, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem } from './facade' +import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ShapeHandle } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -135,6 +135,8 @@ function App() { } }), [facade]) + useEffect(() => () => facade.geometry.dispose(), [facade]) + useEffect(() => { const handlePopState = () => setPage(pageFromPath()) window.addEventListener('popstate', handlePopState) @@ -357,20 +359,47 @@ function ViewProperties({ showNotice }: { showNotice: (message: string) => void function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { const hostRef = useRef(null) + const adapterRef = useRef(null) useEffect(() => { const host = hostRef.current if (!host) return const adapter = facade.viewport.createAdapter() + adapterRef.current = adapter + let cancelled = false + let shape: ShapeHandle | null = null try { adapter.mount(host) - adapter.setSelection(selectedObject) - return () => adapter.dispose() } catch (error) { showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`) adapter.dispose() + adapterRef.current = null + return } - }, [facade, showNotice, selectedObject]) - return
{workbench}Body / Fillet
setSelectedObject('')}>
{ event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}>
Pad
SelectedEdgesDatum
+ 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 }) + if (cancelled) { + await facade.geometry.release(shape) + shape = null + return + } + const mesh = await facade.geometry.mesh(shape, 0.05) + if (!cancelled) adapter.setMesh(mesh) + } + void loadGeometry().catch((error: unknown) => { + if (!cancelled) showNotice(`Geometry preview unavailable: ${error instanceof Error ? error.message : String(error)}`) + }) + return () => { + cancelled = true + if (shape) void facade.geometry.release(shape) + adapter.dispose() + adapterRef.current = null + } + }, [facade, showNotice]) + + useEffect(() => adapterRef.current?.setSelection(selectedObject), [selectedObject]) + return
{workbench}Body / Fillet
setSelectedObject('')}>
SelectedEdgesDatum
} function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts new file mode 100644 index 0000000..bf32f73 --- /dev/null +++ b/src/facade/geometryRuntime.ts @@ -0,0 +1,200 @@ +import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' +import type { Inputs } from '@bitbybit-dev/occt' +import type { CreateBoxInput, GeometryCapabilities, MeshAsset, ShapeHandle } from './types' + +type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer +type KernelMesh = Inputs.OCCT.DecomposedMeshDto +type ShapeEntry = { handle: ShapeHandle; reference: KernelShapeReference } + +const unavailableCapabilities = (): GeometryCapabilities => ({ + provider: 'Bitbybit OCCT', + version: '1.1.1', + status: typeof Worker === 'undefined' ? 'unavailable' : 'idle', + worker: typeof Worker !== 'undefined', + wasm: typeof WebAssembly !== 'undefined', + reason: typeof Worker === 'undefined' ? 'Web Workers are not available in this runtime.' : undefined, +}) + +const finitePositive = (value: number, name: string) => { + if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a finite number greater than zero.`) +} + +export const validateBoxInput = (input: CreateBoxInput) => { + finitePositive(input.width, 'width') + finitePositive(input.length, 'length') + finitePositive(input.height, 'height') + if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('documentVersion must be a non-negative safe integer.') + const center = input.center ?? [0, 0, 0] + if (center.length !== 3 || center.some((coordinate) => !Number.isFinite(coordinate))) throw new RangeError('center must contain three finite coordinates.') +} + +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}`) +} + +const appendFace = (face: Inputs.OCCT.DecomposedFaceDto, positions: number[], normals: number[], indices: number[]) => { + if (face.vertexCoord.length % 3 !== 0 || face.triIndexes.length % 3 !== 0) throw new Error(`Bitbybit OCCT returned malformed arrays for face ${face.faceIndex}.`) + if (face.vertexCoord.some((coordinate) => !Number.isFinite(coordinate)) || face.normalCoord.some((coordinate) => !Number.isFinite(coordinate))) throw new Error(`Bitbybit OCCT returned non-finite coordinates for face ${face.faceIndex}.`) + if (face.normalCoord.length !== 0 && face.normalCoord.length !== face.vertexCoord.length) throw new Error(`Bitbybit OCCT returned mismatched normals for face ${face.faceIndex}.`) + const faceVertexCount = face.vertexCoord.length / 3 + if (face.triIndexes.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= faceVertexCount)) throw new Error(`Bitbybit OCCT returned an out-of-range triangle index for face ${face.faceIndex}.`) + const vertexOffset = positions.length / 3 + positions.push(...face.vertexCoord) + if (face.normalCoord.length === face.vertexCoord.length) normals.push(...face.normalCoord) + else for (let index = 0; index < face.vertexCoord.length; index += 3) normals.push(0, 0, 0) + for (const index of face.triIndexes) indices.push(vertexOffset + index) +} + +export const normalizeBitbybitMesh = (shape: ShapeHandle, mesh: KernelMesh): MeshAsset => { + const positions: number[] = [] + const normals: number[] = [] + const indices: number[] = [] + mesh.faceList.forEach((face) => appendFace(face, positions, normals, indices)) + if (positions.length === 0 || indices.length === 0) throw new Error('Bitbybit OCCT returned an empty mesh for the shape.') + + const min: [number, number, number] = [Infinity, Infinity, Infinity] + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity] + for (let index = 0; index < positions.length; index += 3) { + for (let axis = 0; axis < 3; axis += 1) { + min[axis] = Math.min(min[axis], positions[index + axis]) + max[axis] = Math.max(max[axis], positions[index + axis]) + } + } + return { + shapeId: shape.id, + topologyVersion: shape.documentVersion, + positions: new Float32Array(positions), + normals: new Float32Array(normals), + indices: new Uint32Array(indices), + bounds: { min, max }, + } +} + +export class BitbybitGeometryRuntime { + private capabilitiesState = unavailableCapabilities() + private client: BitByBitOCCT | null = null + private worker: Worker | null = null + private initialization: Promise | null = null + private cancelInitialization: (() => void) | null = null + private sequence = 0 + private readonly shapes = new Map() + + capabilities(): GeometryCapabilities { return { ...this.capabilitiesState } } + + initialize(): Promise { + if (this.capabilitiesState.status === 'ready') return Promise.resolve(this.capabilities()) + if (this.initialization) return this.initialization + if (typeof Worker === 'undefined' || typeof WebAssembly === 'undefined') { + this.capabilitiesState = { ...unavailableCapabilities(), status: 'unavailable', reason: 'WebAssembly and Web Workers are required for Bitbybit OCCT.' } + return Promise.resolve(this.capabilities()) + } + + this.capabilitiesState = { ...this.capabilitiesState, status: 'initializing', reason: undefined } + this.initialization = new Promise((resolve, reject) => { + let settled = false + const client = new BitByBitOCCT() + const worker = new Worker(new URL('./geometryWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-occt' }) + this.client = client + this.worker = worker + const timeout = window.setTimeout(() => fail(new Error('Bitbybit OCCT initialization timed out.')), 120_000) + const subscription = client.occtWorkerManager.occWorkerState$.subscribe(({ state }) => { + if (state !== OccStateEnum.initialised || settled) return + settled = true + window.clearTimeout(timeout) + subscription.unsubscribe() + this.cancelInitialization = null + this.capabilitiesState = { ...this.capabilitiesState, status: 'ready', worker: true, wasm: true } + resolve(this.capabilities()) + }) + const fail = (error: Error) => { + const wasSettled = settled + settled = true + window.clearTimeout(timeout) + subscription.unsubscribe() + worker.terminate() + this.shapes.clear() + this.worker = null + this.client = null + this.initialization = null + this.cancelInitialization = null + this.capabilitiesState = { ...this.capabilitiesState, status: 'failed', reason: error.message } + if (!wasSettled) reject(error) + } + this.cancelInitialization = () => fail(new Error('Bitbybit OCCT initialization was cancelled.')) + worker.addEventListener('error', (event) => fail(new Error(event.message || 'Bitbybit OCCT worker failed.')), { once: true }) + worker.addEventListener('message', ({ data }) => { + if (data?.type === 'occ-initialization-failed') fail(new Error(data.error || 'Bitbybit OCCT initialization failed.')) + }) + client.occtWorkerManager.errorCallback = (error) => { this.capabilitiesState = { ...this.capabilitiesState, reason: error } } + client.init(worker) + }) + return this.initialization + } + + async createBox(input: CreateBoxInput): Promise { + validateBoxInput(input) + const client = await this.readyClient() + const kernelShape = await client.occt.shapes.solid.createBox({ + width: input.width, + length: input.length, + height: input.height, + center: input.center ?? [0, 0, 0], + originOnCenter: input.originOnCenter ?? true, + }) + const handle: ShapeHandle = { + id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`, + kernel: 'bitbybit-occt', + kind: 'solid', + documentVersion: input.documentVersion, + } + this.shapes.set(handle.id, { handle, reference: kernelShape }) + return handle + } + + async mesh(shape: ShapeHandle, precision = 0.05): Promise { + finitePositive(precision, 'precision') + const client = await this.readyClient() + const entry = this.resolveShape(shape) + const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false }) + return normalizeBitbybitMesh(entry.handle, mesh) + } + + async release(shape: ShapeHandle): Promise { + const entry = this.shapes.get(shape.id) + if (!entry) return + this.assertHandle(shape, entry.handle) + this.shapes.delete(shape.id) + const client = this.client + if (client && this.capabilitiesState.status === 'ready') await client.occt.deleteShape({ shape: entry.reference }) + } + + dispose() { + this.cancelInitialization?.() + this.shapes.clear() + this.client?.occtWorkerManager.cleanPromisesMade() + this.worker?.terminate() + this.client = null + this.worker = null + this.initialization = null + this.cancelInitialization = null + this.capabilitiesState = unavailableCapabilities() + } + + private async readyClient() { + const capabilities = await this.initialize() + if (capabilities.status !== 'ready' || !this.client) throw new Error(capabilities.reason || 'Bitbybit OCCT is unavailable.') + return this.client + } + + private resolveShape(shape: ShapeHandle) { + if (shape.kernel !== 'bitbybit-occt') throw new Error(`Unsupported geometry kernel: ${shape.kernel}`) + const entry = this.shapes.get(shape.id) + if (!entry) throw new Error(`Shape handle is unknown or has been released: ${shape.id}`) + this.assertHandle(shape, entry.handle) + return entry + } + + private assertHandle(actual: ShapeHandle, expected: ShapeHandle) { + assertShapeHandleIntegrity(actual, expected) + } +} diff --git a/src/facade/geometryWorker.ts b/src/facade/geometryWorker.ts new file mode 100644 index 0000000..7913cc6 --- /dev/null +++ b/src/facade/geometryWorker.ts @@ -0,0 +1,23 @@ +/// + +import createBitbybitDevOcct from '@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt' +import { initializationComplete, onMessageInput } from '@bitbybit-dev/occt-worker' + +const workerScope = self as DedicatedWorkerGlobalScope + +const initialize = async () => { + try { + const occt = await createBitbybitDevOcct() + initializationComplete(occt, undefined) + workerScope.onmessage = ({ data }) => onMessageInput(data, (message) => workerScope.postMessage(message)) + } catch (error) { + workerScope.postMessage({ + type: 'occ-initialization-failed', + error: error instanceof Error ? error.message : String(error), + }) + } +} + +void initialize() + +export {} diff --git a/src/facade/index.ts b/src/facade/index.ts index 396fbe3..258b51a 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -1,5 +1,6 @@ export { createMockFacade } from './mockFacade' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore' export { ThreeViewportAdapter } from './threeViewport' +export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBoxInput } from './geometryRuntime' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' -export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types' +export type { BitBybitViewportAdapter, BitBybitWebCadFacade, CommandState, CreateBoxInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, MeshAsset, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index bd17100..57ca309 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -14,6 +14,7 @@ import type { Unsubscribe, } from './types' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' +import { BitbybitGeometryRuntime } from './geometryRuntime' import { ThreeViewportAdapter } from './threeViewport' const initialTree: ModelTreeItem[] = [ @@ -51,6 +52,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO export function createMockFacade(): BitBybitWebCadFacade { const projectPersistence = createSqliteProjectPersistence() + const geometryRuntime = new BitbybitGeometryRuntime() const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } const listeners = new Set() @@ -130,6 +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), 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/threeViewport.ts b/src/facade/threeViewport.ts index 72363e6..1c4a41a 100644 --- a/src/facade/threeViewport.ts +++ b/src/facade/threeViewport.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import type { BitBybitViewportAdapter } from './types' +import type { BitBybitViewportAdapter, MeshAsset } from './types' /** Internal renderer boundary. React receives only the adapter contract. */ export class ThreeViewportAdapter implements BitBybitViewportAdapter { @@ -20,6 +20,7 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter { this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false) + this.renderer.domElement.dataset.geometrySource = 'three-fallback' host.appendChild(this.renderer.domElement) const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439) grid.rotation.x = 0 @@ -35,7 +36,6 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter { this.scene.add(key) const render = () => { if (!this.renderer || !this.scene || !this.camera) return - this.selection && (this.selection.rotation.y += 0.002) this.renderer.render(this.scene, this.camera) this.frame = requestAnimationFrame(render) } @@ -43,6 +43,32 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter { render() } + setMesh(mesh: MeshAsset | null) { + if (!this.selection) return + if (!mesh) { + this.selection.visible = false + if (this.renderer) { + this.renderer.domElement.dataset.geometrySource = 'none' + delete this.renderer.domElement.dataset.triangleCount + } + return + } + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3)) + geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3)) + geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)) + if (!mesh.normals.some((component) => component !== 0)) geometry.computeVertexNormals() + geometry.computeBoundingSphere() + this.selection.geometry.dispose() + this.selection.geometry = geometry + this.selection.visible = true + this.selection.position.set(0, -mesh.bounds.min[1], 0) + if (this.renderer) { + this.renderer.domElement.dataset.geometrySource = 'bitbybit-occt' + this.renderer.domElement.dataset.triangleCount = String(mesh.indices.length / 3) + } + } + resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) { if (!this.renderer || !this.camera) return this.camera.aspect = Math.max(width, 1) / Math.max(height, 1) diff --git a/src/facade/types.ts b/src/facade/types.ts index b890145..991a8cd 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -41,6 +41,50 @@ export type ProjectResource = { refCount: number } +export type GeometryCapabilities = { + provider: 'Bitbybit OCCT' + version: '1.1.1' + status: 'idle' | 'initializing' | 'ready' | 'failed' | 'unavailable' + worker: boolean + wasm: boolean + reason?: string +} + +export type ShapeHandle = { + readonly id: string + readonly kernel: 'bitbybit-occt' + readonly kind: 'solid' + readonly documentVersion: number +} + +export type SubshapeRef = { + shapeId: string + kind: 'face' | 'edge' | 'vertex' + persistentId: string + topologyVersion: number +} + +export type MeshAsset = { + shapeId: string + topologyVersion: number + positions: Float32Array + normals: Float32Array + indices: Uint32Array + bounds: { + min: [number, number, number] + max: [number, number, number] + } +} + +export type CreateBoxInput = { + width: number + length: number + height: number + center?: [number, number, number] + originOnCenter?: boolean + documentVersion: number +} + export type CommandState = { id: string status: 'hidden' | 'disabled' | 'enabled' | 'active' @@ -97,6 +141,7 @@ export type ViewportBackend = 'webgl2' | 'webgpu' export interface BitBybitViewportAdapter { mount(host: HTMLElement): void resize(width: number, height: number, devicePixelRatio?: number): void + setMesh(mesh: MeshAsset | null): void setSelection(objectId: string): void dispose(): void getBackend(): ViewportBackend @@ -155,6 +200,14 @@ export interface BitBybitWebCadFacade { release(hash: string): Promise } } + readonly geometry: { + capabilities(): GeometryCapabilities + initialize(): Promise + createBox(input: CreateBoxInput): Promise + mesh(shape: ShapeHandle, precision?: number): Promise + release(shape: ShapeHandle): Promise + dispose(): void + } readonly viewport: { createAdapter(): BitBybitViewportAdapter } diff --git a/src/styles.css b/src/styles.css index cc91b41..71e6764 100644 --- a/src/styles.css +++ b/src/styles.css @@ -299,7 +299,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .combo-model { height: calc(100% - 39px); min-height: 0; display: flex; flex-direction: column; }.model-tree { min-height: 190px; flex: 1 1 58%; display: flex; flex-direction: column; }.tree-toolbar { display: flex; gap: 5px; padding: 10px 9px; }.tree-search { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 8px; border: 1px solid var(--line); background: var(--bg-raised); border-radius: 3px; color: var(--text-muted); }.tree-search input { font-size: 11px; }.tree-root { padding: 0 4px; overflow: auto; }.tree-document-row { height: 29px; display: flex; align-items: center; gap: 6px; padding: 0 8px; color: var(--text); font-size: 11px; font-weight: 650; }.tree-version { margin-left: auto; color: var(--text-muted); font-size: 9px; font-weight: 500; }.tree-row { width: 100%; min-height: 29px; display: flex; align-items: center; gap: 5px; padding-right: 8px; border: 0; border-left: 2px solid transparent; background: transparent; color: var(--text-soft); text-align: left; font-size: 11px; cursor: pointer; }.tree-row:hover { background: var(--bg-hover); color: var(--text); }.tree-row.is-selected { color: var(--text); border-left-color: var(--cyan); background: var(--cyan-soft); }.tree-row.is-active .tree-label { color: var(--cyan); }.tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tree-spacer { width: 13px; }.tree-child { color: var(--text-muted); }.tree-children .tree-row { padding-left: 29px !important; }.active-marker { width: 5px; height: 5px; margin-left: auto; background: var(--cyan); border-radius: 50%; }.tree-footer { display: flex; gap: 13px; margin-top: auto; padding: 12px 12px 14px; border-top: 1px solid var(--line-soft); color: var(--text-muted); font-size: 9px; }.tree-footer span { display: inline-flex; align-items: center; gap: 5px; }.legend-dot { width: 5px; height: 5px; display: inline-block; border-radius: 50%; }.legend-dot.valid { background: var(--green); }.legend-dot.warning { background: var(--amber); } .task-panel { height: calc(100% - 39px); display: flex; flex-direction: column; overflow: auto; }.task-header { display: flex; align-items: center; gap: 9px; padding: 18px 15px; border-bottom: 1px solid var(--line-soft); }.task-header .badge { margin-left: auto; }.task-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--cyan); background: var(--cyan-soft); border-radius: 4px; }.task-header h2 { margin: 4px 0 0; font-size: 13px; }.task-body { padding: 16px 15px; }.task-step { display: flex; gap: 9px; padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--line-soft); }.step-index { width: 19px; height: 19px; display: grid; place-items: center; color: #142124; background: var(--cyan); border-radius: 50%; font-size: 10px; font-weight: 700; flex-shrink: 0; }.task-step strong, .task-step span { display: block; }.task-step strong { font-size: 11px; }.task-step span { color: var(--text-muted); font-size: 10px; line-height: 1.4; margin-top: 3px; }.field-label { display: block; color: var(--text-soft); font-size: 10px; margin-bottom: 14px; }.field-unit { float: right; color: var(--text-muted); }.field-input { width: 100%; height: 30px; display: block; margin-top: 6px; padding: 0 8px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); font-size: 11px; }.field-input:focus { border-color: #527479; outline: none; }.check-row { display: flex; align-items: center; gap: 7px; color: var(--text-soft); font-size: 10px; margin-top: 3px; }.check-row input { accent-color: var(--cyan); }.task-note { display: flex; align-items: flex-start; gap: 7px; margin-top: 18px; color: var(--amber); font-size: 10px; line-height: 1.4; }.task-footer { display: flex; gap: 5px; margin-top: auto; padding: 11px 12px; border-top: 1px solid var(--line); }.task-footer .button { flex: 1; padding: 0 7px; min-height: 31px; font-size: 11px; } .viewport-region { min-width: 0; position: relative; background: #0e1417; overflow: hidden; }.viewport { position: absolute; inset: 0; display: flex; flex-direction: column; }.viewport-header { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid rgba(85, 113, 119, .22); background: rgba(15, 22, 25, .86); z-index: 1; }.viewport-title { display: flex; flex-direction: column; gap: 3px; }.viewport-title strong { font-size: 11px; font-weight: 600; }.viewport-title .eyebrow { color: var(--text-muted); }.viewport-actions { display: flex; gap: 3px; }.viewport-grid { min-height: 0; flex: 1; position: relative; display: grid; place-items: center; overflow: hidden; cursor: crosshair; }.grid-lines { position: absolute; inset: -25%; opacity: .37; transform: perspective(500px) rotateX(62deg) translateY(13%); background-image: linear-gradient(rgba(81, 130, 136, .22) 1px, transparent 1px), linear-gradient(90deg, rgba(81, 130, 136, .22) 1px, transparent 1px); background-size: 28px 28px; }.mock-part { position: relative; z-index: 2; width: 285px; height: 180px; transform: translate(5%, -4%) skewY(-20deg) rotate(21deg); filter: drop-shadow(24px 22px 0 rgba(0, 0, 0, .16)); cursor: pointer; }.part-front { position: absolute; inset: 0; border: 1px solid #8bd2cf; background: #447d82; box-shadow: inset -12px -14px 0 rgba(13, 36, 41, .25); }.part-top { position: absolute; width: 285px; height: 64px; top: -48px; left: 0; border: 1px solid #83c7c6; background: #60999a; transform: skewX(-35deg); transform-origin: bottom left; }.part-side { position: absolute; width: 50px; height: 180px; right: -34px; top: -24px; border: 1px solid #6ba9a9; background: #34636b; transform: skewY(55deg); transform-origin: top left; }.part-hole { position: absolute; z-index: 3; width: 38px; height: 38px; border-radius: 50%; border: 7px solid #223f44; background: #111b1e; box-shadow: 0 0 0 1px #95d9d5, inset 0 0 0 3px #17282c; }.part-hole.one { left: 55px; top: 51px; }.part-hole.two { right: 50px; top: 51px; }.part-cut { position: absolute; width: 125px; height: 25px; left: 81px; bottom: 31px; border: 1px solid rgba(168, 224, 219, .72); background: rgba(31, 70, 74, .42); }.part-selected .part-front { background: #4e9799; border-color: #b9f1eb; box-shadow: 0 0 0 2px rgba(94, 214, 214, .3), inset -12px -14px 0 rgba(13, 36, 41, .25); }.mock-datum-plane { position: absolute; z-index: 1; width: 320px; height: 205px; border: 1px dashed rgba(239, 190, 114, .42); background: rgba(239, 190, 114, .04); transform: translate(28%, 25%) skewY(-22deg) rotate(21deg); }.selection-callout { position: absolute; z-index: 3; top: 25%; left: 68%; display: flex; align-items: center; gap: 7px; color: var(--cyan); font-size: 10px; }.callout-line { width: 42px; height: 1px; background: var(--cyan); position: relative; }.callout-line::after { content: ''; width: 5px; height: 5px; position: absolute; left: 0; top: -2px; border-radius: 50%; background: var(--cyan); }.viewport-legend { position: absolute; z-index: 4; left: 16px; bottom: 14px; display: flex; gap: 12px; color: var(--text-muted); font-size: 9px; }.viewport-legend span { display: inline-flex; align-items: center; gap: 5px; }.legend-swatch { width: 12px; height: 3px; display: inline-block; border-radius: 2px; }.legend-swatch.selected { background: var(--cyan); }.legend-swatch.edge { background: #8cb7b6; }.legend-swatch.datum { border-top: 1px dashed var(--amber); }.viewport-bottom-left { position: absolute; z-index: 5; left: 16px; bottom: 42px; display: flex; gap: 5px; }.view-chip { display: flex; align-items: center; gap: 5px; padding: 5px 7px; color: var(--text-muted); background: rgba(25, 34, 38, .85); border: 1px solid rgba(93, 124, 129, .32); border-radius: 3px; font-size: 9px; }.axis-widget { width: 64px; height: 64px; position: relative; margin: 0 23px 23px 0; }.axis-origin { width: 7px; height: 7px; position: absolute; left: 28px; top: 30px; border-radius: 50%; background: #d9e3e3; }.axis-widget::before, .axis-widget::after { content: ''; position: absolute; left: 31px; top: 31px; width: 29px; height: 1px; transform-origin: left center; }.axis-widget::before { background: #d77f7e; transform: rotate(-18deg); }.axis-widget::after { background: #78d19f; transform: rotate(-80deg); }.axis-x, .axis-y, .axis-z { position: absolute; font-size: 9px; font-weight: 700; }.axis-x { right: 0; top: 19px; color: #e58b88; }.axis-y { left: 18px; top: 0; color: #7bd7a0; }.axis-z { left: 19px; bottom: 0; color: #8abcf0; } -.three-viewport-host { position: absolute; inset: 0; z-index: 1; opacity: .28; pointer-events: none; }.three-viewport-host canvas { width: 100%; height: 100%; display: block; } +.three-viewport-host { position: absolute; inset: 0; z-index: 1; opacity: 1; pointer-events: none; }.three-viewport-host canvas { width: 100%; height: 100%; display: block; } .properties-panel { display: flex; flex-direction: column; }.property-heading { display: flex; align-items: flex-start; justify-content: space-between; min-height: 75px; padding: 16px 13px 10px; }.property-heading h2 { margin: 5px 0 0; font-size: 15px; font-weight: 650; }.property-tabs { height: 34px; }.property-tabs button { padding: 0 14px; }.properties-scroll { overflow: auto; padding-bottom: 20px; }.property-group { border-bottom: 1px solid var(--line-soft); padding: 0 13px 10px; }.property-group-title { display: flex; align-items: center; justify-content: space-between; padding: 12px 0 8px; color: var(--text-muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.property-row { min-height: 28px; display: grid; grid-template-columns: 43% 57%; align-items: center; gap: 5px; }.property-label { color: var(--text-muted); font-size: 10px; }.property-value { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; border: 0; padding: 4px 0; background: transparent; color: var(--text-soft); text-align: right; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-value:disabled { opacity: 1; }.property-value.is-editable, .property-value.is-link { color: var(--text); cursor: pointer; }.property-value.is-editable:hover { color: var(--cyan); }.property-value.is-link { color: var(--cyan); }.status-pill { height: 16px; display: inline-flex; align-items: center; padding: 0 4px; border-radius: 2px; font-size: 8px; }.status-pill.green { color: var(--green); background: var(--green-soft); }.status-pill.amber { color: var(--amber); background: var(--amber-soft); }.expression-row { min-height: 29px; display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 10px; }.expression-row .expression-value { margin-left: auto; color: var(--cyan); }.expression-row button { display: grid; place-items: center; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; }.expression-row button:hover { color: var(--text); } .bottom-drawer { height: 35px; flex-shrink: 0; border-top: 1px solid var(--line); background: var(--bg-panel); transition: height .16s ease; }.bottom-drawer.is-open { height: 122px; }.bottom-drawer-header { height: 35px; display: flex; align-items: center; justify-content: space-between; }.drawer-tabs { height: 100%; display: flex; align-items: stretch; gap: 2px; padding-left: 9px; }.drawer-tabs button { display: flex; align-items: center; gap: 6px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); font-size: 10px; padding: 0 8px; cursor: pointer; }.drawer-tabs button.is-active { color: var(--text); border-bottom-color: var(--cyan); }.drawer-toggle { width: 32px; height: 30px; margin-right: 5px; display: grid; place-items: center; border: 0; color: var(--text-muted); background: transparent; cursor: pointer; }.drawer-toggle:hover { color: var(--text); }.bottom-drawer-content { padding: 5px 13px 9px; border-top: 1px solid var(--line-soft); }.report-line { min-height: 30px; display: flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 10px; }.report-context { color: var(--text-muted); }.report-line button { border: 0; background: transparent; color: var(--cyan); font-size: 10px; cursor: pointer; }.report-time { margin-left: auto; color: var(--text-muted); }.icon-green { color: var(--green); }.icon-amber { color: var(--amber); }.icon-cyan { color: var(--cyan); }.icon-muted { color: var(--text-muted); } .toast { position: fixed; z-index: 30; right: 22px; bottom: 22px; display: flex; align-items: center; gap: 8px; padding: 11px 13px; color: #10231d; background: var(--green); border-radius: 4px; box-shadow: 0 10px 25px rgba(0, 0, 0, .28); font-size: 11px; font-weight: 650; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 6ac49e5..204e828 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -3,6 +3,8 @@ 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 } from '../src/facade/geometryRuntime' +import type { ShapeHandle } from '../src/facade/types' test('facade exposes a stable initial document projection', () => { const facade = createMockFacade() @@ -11,6 +13,36 @@ test('facade exposes a stable initial document projection', () => { assert.equal(state.document.id, 'doc-pump-housing') assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body') assert.equal(facade.gui.workbench.getActive(), 'Part Design') + assert.equal(facade.geometry.capabilities().provider, 'Bitbybit 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: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentVersion: 1 }), /center/) + assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentVersion: 0 })) +}) + +test('geometry handles reject forged document ownership', () => { + const expected: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentVersion: 4 } + assert.doesNotThrow(() => assertShapeHandleIntegrity({ ...expected }, expected)) + assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/) + assert.throws(() => assertShapeHandleIntegrity({ ...expected, id: 'shape-two' }, expected), /integrity/) +}) + +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 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: [], + } as never) + assert.deepEqual([...mesh.positions], [0, 0, 0, 2, 0, 0, 0, 3, 0]) + assert.deepEqual([...mesh.indices], [0, 1, 2]) + assert.deepEqual(mesh.bounds, { min: [0, 0, 0], max: [2, 3, 0] }) + assert.equal(mesh.topologyVersion, 7) + assert.throws(() => normalizeBitbybitMesh(shape, { + faceList: [{ faceIndex: 2, vertexCoord: [0, 0, 0], normalCoord: [], triIndexes: [0, 1, 0], numberOfTriangles: 1 }], + edgeList: [], pointsList: [], + } as never), /out-of-range/) }) test('project schema is versioned and covers the FreeCAD document graph', () => { diff --git a/vite.config.ts b/vite.config.ts index e3d5465..86272f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ }, }, optimizeDeps: { - exclude: ['@sqlite.org/sqlite-wasm'], + exclude: ['@sqlite.org/sqlite-wasm', '@bitbybit-dev/occt', '@bitbybit-dev/occt-worker'], + }, + worker: { + format: 'es', }, build: { chunkSizeWarningLimit: 600,