P3-02 P5-02: integrate Bitbybit OCCT geometry runtime

This commit is contained in:
2026-08-02 06:45:49 -04:00
parent feb2eb8be6
commit 9f2d11e151
14 changed files with 454 additions and 12 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 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" }, "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

@@ -1582,7 +1582,7 @@ P0 基线/治理
| 任务 | 状态 | 实际证据 | | 任务 | 状态 | 实际证据 |
|---|---|---| |---|---|---|
| P0-01 FreeCAD 基线文件 | `IN PROGRESS` | `config/freecad-baseline.json` 已建立;精确源码提交和构建参数仍需 POC 验证 | | 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-256FreeCAD 自构建内核仍待锁定 |
| P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | | P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 |
| P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | | P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 |
| P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 | | P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 |
@@ -1594,6 +1594,10 @@ P0 基线/治理
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
| 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-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/黄金几何待补齐 | | 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 构建验证。 本迭代验证命令:`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 已可用 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter不会假称 OPFS 已可用 |
仍未通过 G4/G5自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 仍未通过 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 WorkerReact 及页面不导入 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 WASMWASM 约 34.5 MBgzip 约 8.6 MB |
限制和下一任务P3-02 还需设计稳定 `SubshapeRef` 的持久命名算法P3-03 继续加入 Cylinder、Sphere、Cone 和 Placement并用黄金包围盒/体积核验P5-02 需要按文档对象 ID 建立多对象网格缓存,而不是仅加载一个启动预览实体。

49
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "bitbybit-web-cad", "name": "bitbybit-web-cad",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1", "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -290,6 +292,40 @@
"node": ">=6.9.0" "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": { "node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
@@ -1651,6 +1687,14 @@
"fsevents": "~2.3.2" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1698,6 +1742,11 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tsx": {
"version": "4.23.1", "version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",

View File

@@ -15,6 +15,8 @@
"verify": "npm run check:facade-boundary && npm run test:facade && npm run build" "verify": "npm run check:facade-boundary && npm run test:facade && npm run build"
}, },
"dependencies": { "dependencies": {
"@bitbybit-dev/occt": "1.1.1",
"@bitbybit-dev/occt-worker": "1.1.1",
"@sqlite.org/sqlite-wasm": "3.53.0-build1", "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -53,7 +53,7 @@ import {
ZoomOut, ZoomOut,
} from 'lucide-react' } from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' 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 Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId type Workbench = WorkbenchId
@@ -135,6 +135,8 @@ function App() {
} }
}), [facade]) }), [facade])
useEffect(() => () => facade.geometry.dispose(), [facade])
useEffect(() => { useEffect(() => {
const handlePopState = () => setPage(pageFromPath()) const handlePopState = () => setPage(pageFromPath())
window.addEventListener('popstate', handlePopState) 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 }) { function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const adapterRef = useRef<BitBybitViewportAdapter | null>(null)
useEffect(() => { useEffect(() => {
const host = hostRef.current const host = hostRef.current
if (!host) return if (!host) return
const adapter = facade.viewport.createAdapter() const adapter = facade.viewport.createAdapter()
adapterRef.current = adapter
let cancelled = false
let shape: ShapeHandle | null = null
try { try {
adapter.mount(host) adapter.mount(host)
adapter.setSelection(selectedObject)
return () => adapter.dispose()
} catch (error) { } catch (error) {
showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`) showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
adapter.dispose() adapter.dispose()
adapterRef.current = null
return
} }
}, [facade, showNotice, selectedObject]) const loadGeometry = async () => {
return <div className="viewport"><div className="viewport-header"><div className="viewport-title"><span className="eyebrow">{workbench}</span><strong>Body / Fillet</strong></div><div className="viewport-actions"><IconButton icon={ZoomOut} label="Zoom out" /><IconButton icon={ZoomIn} label="Zoom in" /><IconButton icon={Rotate3D} label="Orbit view" active /></div></div><div className="viewport-grid" onClick={() => setSelectedObject('')}><div className="three-viewport-host" ref={hostRef} aria-label="Three.js viewport" /><div className="grid-lines" /><div className={`mock-part ${selectedObject === 'pad' || selectedObject.startsWith('pad') ? 'part-selected' : ''}`} onClick={(event) => { event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}><div className="part-top" /><div className="part-front"><span className="part-hole one" /><span className="part-hole two" /><span className="part-cut" /></div><div className="part-side" /></div><div className="mock-datum-plane" /><div className="selection-callout"><span className="callout-line" /><span>Pad</span></div></div><div className="viewport-legend"><span><span className="legend-swatch selected" />Selected</span><span><span className="legend-swatch edge" />Edges</span><span><span className="legend-swatch datum" />Datum</span></div></div> 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 <div className="viewport"><div className="viewport-header"><div className="viewport-title"><span className="eyebrow">{workbench}</span><strong>Body / Fillet</strong></div><div className="viewport-actions"><IconButton icon={ZoomOut} label="Zoom out" /><IconButton icon={ZoomIn} label="Zoom in" /><IconButton icon={Rotate3D} label="Orbit view" active /></div></div><div className="viewport-grid" onClick={() => setSelectedObject('')}><div className="three-viewport-host" ref={hostRef} aria-label="Three.js viewport" /></div><div className="viewport-legend"><span><span className="legend-swatch selected" />Selected</span><span><span className="legend-swatch edge" />Edges</span><span><span className="legend-swatch datum" />Datum</span></div></div>
} }
function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {

View File

@@ -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<GeometryCapabilities> | null = null
private cancelInitialization: (() => void) | null = null
private sequence = 0
private readonly shapes = new Map<string, ShapeEntry>()
capabilities(): GeometryCapabilities { return { ...this.capabilitiesState } }
initialize(): Promise<GeometryCapabilities> {
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<GeometryCapabilities>((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<ShapeHandle> {
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<MeshAsset> {
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<void> {
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)
}
}

View File

@@ -0,0 +1,23 @@
/// <reference lib="webworker" />
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 {}

View File

@@ -1,5 +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 } 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 { 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'

View File

@@ -14,6 +14,7 @@ import type {
Unsubscribe, Unsubscribe,
} from './types' } from './types'
import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore'
import { BitbybitGeometryRuntime } from './geometryRuntime'
import { ThreeViewportAdapter } from './threeViewport' import { ThreeViewportAdapter } from './threeViewport'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
@@ -51,6 +52,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
export function createMockFacade(): BitBybitWebCadFacade { export function createMockFacade(): BitBybitWebCadFacade {
const projectPersistence = createSqliteProjectPersistence() const projectPersistence = createSqliteProjectPersistence()
const geometryRuntime = new BitbybitGeometryRuntime()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document)) 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: [] } let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const listeners = new Set<FacadeListener>() const listeners = new Set<FacadeListener>()
@@ -130,6 +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), 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

@@ -1,5 +1,5 @@
import * as THREE from 'three' 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. */ /** Internal renderer boundary. React receives only the adapter contract. */
export class ThreeViewportAdapter implements BitBybitViewportAdapter { 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 = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false) this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
this.renderer.domElement.dataset.geometrySource = 'three-fallback'
host.appendChild(this.renderer.domElement) host.appendChild(this.renderer.domElement)
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439) const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
grid.rotation.x = 0 grid.rotation.x = 0
@@ -35,7 +36,6 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
this.scene.add(key) this.scene.add(key)
const render = () => { const render = () => {
if (!this.renderer || !this.scene || !this.camera) return if (!this.renderer || !this.scene || !this.camera) return
this.selection && (this.selection.rotation.y += 0.002)
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
this.frame = requestAnimationFrame(render) this.frame = requestAnimationFrame(render)
} }
@@ -43,6 +43,32 @@ export class ThreeViewportAdapter implements BitBybitViewportAdapter {
render() 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)) { resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
if (!this.renderer || !this.camera) return if (!this.renderer || !this.camera) return
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1) this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)

View File

@@ -41,6 +41,50 @@ export type ProjectResource = {
refCount: number 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 = { export type CommandState = {
id: string id: string
status: 'hidden' | 'disabled' | 'enabled' | 'active' status: 'hidden' | 'disabled' | 'enabled' | 'active'
@@ -97,6 +141,7 @@ export type ViewportBackend = 'webgl2' | 'webgpu'
export interface BitBybitViewportAdapter { export interface BitBybitViewportAdapter {
mount(host: HTMLElement): void mount(host: HTMLElement): void
resize(width: number, height: number, devicePixelRatio?: number): void resize(width: number, height: number, devicePixelRatio?: number): void
setMesh(mesh: MeshAsset | null): void
setSelection(objectId: string): void setSelection(objectId: string): void
dispose(): void dispose(): void
getBackend(): ViewportBackend getBackend(): ViewportBackend
@@ -155,6 +200,14 @@ export interface BitBybitWebCadFacade {
release(hash: string): Promise<void> release(hash: string): Promise<void>
} }
} }
readonly geometry: {
capabilities(): GeometryCapabilities
initialize(): Promise<GeometryCapabilities>
createBox(input: CreateBoxInput): Promise<ShapeHandle>
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
release(shape: ShapeHandle): Promise<void>
dispose(): void
}
readonly viewport: { readonly viewport: {
createAdapter(): BitBybitViewportAdapter createAdapter(): BitBybitViewportAdapter
} }

View File

@@ -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); } .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; } .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; } .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); } .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); } .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; } .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; }

View File

@@ -3,6 +3,8 @@ 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 } from '../src/facade/geometryRuntime'
import type { ShapeHandle } from '../src/facade/types'
test('facade exposes a stable initial document projection', () => { test('facade exposes a stable initial document projection', () => {
const facade = createMockFacade() 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.id, 'doc-pump-housing')
assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body') assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body')
assert.equal(facade.gui.workbench.getActive(), 'Part Design') 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', () => { test('project schema is versioned and covers the FreeCAD document graph', () => {

View File

@@ -18,7 +18,10 @@ export default defineConfig({
}, },
}, },
optimizeDeps: { optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm'], exclude: ['@sqlite.org/sqlite-wasm', '@bitbybit-dev/occt', '@bitbybit-dev/occt-worker'],
},
worker: {
format: 'es',
}, },
build: { build: {
chunkSizeWarningLimit: 600, chunkSizeWarningLimit: 600,