P3-02 P5-02: integrate Bitbybit OCCT geometry runtime
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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-256;FreeCAD 自构建内核仍待锁定 |
|
||||||
| 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 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 建立多对象网格缓存,而不是仅加载一个启动预览实体。
|
||||||
|
|||||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
39
src/App.tsx
39
src/App.tsx
@@ -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 }) {
|
||||||
|
|||||||
200
src/facade/geometryRuntime.ts
Normal file
200
src/facade/geometryRuntime.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/facade/geometryWorker.ts
Normal file
23
src/facade/geometryWorker.ts
Normal 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 {}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user