From df0053cfe502bd264e21230168325acb57e2b00c Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 02:39:37 -0400 Subject: [PATCH] feat: establish facade-first runtime boundary --- README.md | 1 + config/compatibility-matrix.json | 24 + config/freecad-baseline.json | 10 + config/runtime-baseline.json | 10 + docs/web-cad-implementation-plan.zh-CN.md | 29 +- package-lock.json | 535 +++++++++++++++++++++- package.json | 10 +- scripts/check-facade-boundary.mjs | 34 ++ src/App.tsx | 72 +-- src/facade/index.ts | 3 + src/facade/mockFacade.ts | 71 +++ src/facade/threeViewport.ts | 75 +++ src/facade/types.ts | 106 +++++ src/styles.css | 1 + tests/facade.test.ts | 47 ++ vite.config.ts | 10 + 16 files changed, 1004 insertions(+), 34 deletions(-) create mode 100644 config/compatibility-matrix.json create mode 100644 config/freecad-baseline.json create mode 100644 config/runtime-baseline.json create mode 100644 scripts/check-facade-boundary.mjs create mode 100644 src/facade/index.ts create mode 100644 src/facade/mockFacade.ts create mode 100644 src/facade/threeViewport.ts create mode 100644 src/facade/types.ts create mode 100644 tests/facade.test.ts diff --git a/README.md b/README.md index 285c1c6..2bb16f3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary. ## Current scope - React + Vite frontend shell +- Three.js `0.185.1` is pinned for the internal Viewport Adapter; React does not import Three.js directly. - FreeCAD-style application menus, workbench navigation, Combo View, Task Dock, viewport and Report view - Machine-readable workbench and command manifest in `src/freecadManifest.ts` - Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md` diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json new file mode 100644 index 0000000..b529f8a --- /dev/null +++ b/config/compatibility-matrix.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "freecadBaseline": "freecad-1.1.1", + "workbenches": { + "Part Design": { "status": "ui-prototype", "level": "read-only", "commands": ["create-body", "create-sketch", "pad", "pocket", "fillet", "chamfer", "linear-pattern", "polar-pattern"] }, + "Part": { "status": "ui-prototype", "level": "read-only", "commands": ["primitive", "union", "cut", "intersection", "check-shape"] }, + "Sketcher": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sketch", "line", "arc", "circle", "rectangle", "constrain-horizontal", "constrain-vertical"] }, + "Draft": { "status": "ui-prototype", "level": "read-only", "commands": ["working-plane", "snap-settings", "draft-line", "draft-wire", "move", "rotate"] }, + "TechDraw": { "status": "ui-prototype", "level": "read-only", "commands": ["new-page", "view-part", "section-view", "dimension", "annotation"] }, + "Spreadsheet": { "status": "ui-prototype", "level": "read-only", "commands": ["new-sheet", "set-alias", "expression", "bind-property"] }, + "Assembly": { "status": "ui-prototype", "level": "read-only", "commands": ["insert-component", "coincident", "concentric", "distance", "interference"] }, + "BIM": { "status": "ui-manifest", "level": "unsupported", "commands": ["wall", "structure", "window", "ifc-export"] }, + "CAM": { "status": "ui-manifest", "level": "unsupported", "commands": ["new-job", "stock", "profile-path", "simulate", "post-process"] }, + "FEM": { "status": "ui-manifest", "level": "unsupported", "commands": ["new-analysis", "material", "mesh", "solve", "results"] }, + "Mesh": { "status": "ui-prototype", "level": "read-only", "commands": ["mesh-import", "analyze-mesh", "repair-mesh", "fill-holes", "mesh-export"] }, + "Surface": { "status": "ui-manifest", "level": "unsupported", "commands": ["bspline", "bezier", "fill-surface", "loft-surface", "trim-surface"] }, + "Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] } + }, + "rules": [ + "UI status does not imply geometry or file compatibility.", + "Every implementation must update this matrix before claiming support.", + "Unsupported commands remain visible only when the workbench manifest exposes an explicit reason." + ] +} diff --git a/config/freecad-baseline.json b/config/freecad-baseline.json new file mode 100644 index 0000000..871c8fc --- /dev/null +++ b/config/freecad-baseline.json @@ -0,0 +1,10 @@ +{ + "baselineId": "freecad-1.1.1", + "freecadVersion": "1.1.1", + "sourceRef": "FreeCAD-1.1.1", + "sourceUrl": "https://github.com/FreeCAD/FreeCAD/releases/tag/1.1.1", + "uiContract": "FreeCAD official desktop UI and Developers Handbook", + "compatibilityLevels": ["exact", "compatible", "read-only", "proxy", "unsupported"], + "status": "baseline-pending-source-verification", + "notes": "The exact source commit and build flags must be recorded by P0-01 before geometry implementation." +} diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json new file mode 100644 index 0000000..35efc21 --- /dev/null +++ b/config/runtime-baseline.json @@ -0,0 +1,10 @@ +{ + "baselineId": "bitbybit-web-cad-runtime-0.1", + "entrypoint": "BitBybitWebCadFacade", + "frontendFramework": "React 18 + Vite 6 + TypeScript 5", + "geometry": { "provider": "BitBybit internal adapter", "status": "planned" }, + "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "adapter-skeleton" }, + "persistence": { "database": "SQLite WASM", "filesystem": "OPFS", "writer": "single worker", "status": "planned" }, + "targetBrowsers": "Capability matrix to be locked in P0-02", + "status": "contract-first-prototype" +} diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 49bf3c7..f04057d 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -147,6 +147,8 @@ BitBybit Web CAD Facade(唯一入口) 建议以当前正式版 FreeCAD 1.1.1 的官方发布标签为初始基线,并在 POC 启动时记录精确源码提交;后续升级作为独立兼容迁移。项目不把“有 FreeCAD 风格外观”或“能调用几个 OCCT 操作”视为完成。完整性必须按固定 FreeCAD 基线版本建立可追踪矩阵,至少覆盖以下四层: +最终产品目标是对标锁定版本 FreeCAD 的完整功能语义,而不是只完成当前的 PartDesign 静态页面。任何暂未实现的工作台、命令、对象、属性、任务生命周期、撤销/重计算、文件互操作或脚本能力,都必须在兼容矩阵中保持可见状态,不能用“后续支持”替代完成定义。 + 1. **界面层**:菜单、工具栏、工作台、Combo View、Model/Tasks、Property Editor、Data/View 属性、报告视图、选择视图、任务确认/取消、快捷键、导航预设、首选项和文档标签。 2. **应用业务层**:Document、DocumentObject、Property、PropertyLink、Expression、事务、撤销/重做、依赖 DAG、状态机、重计算、错误恢复、对象生命周期和视图状态分离。 3. **工作台层**:官方核心工作台的命令、参数、选择前置条件、结果类型、失败行为和对象映射。第一批覆盖 Part、PartDesign、Sketcher、Draft、TechDraw、Spreadsheet、Mesh、Assembly;随后覆盖 Path、FEM、Arch/BIM、Robot、Raytracing/Rendering 和其他官方核心模块。 @@ -1400,12 +1402,14 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | FreeCAD UI 区域、工作台和核心术语盘点 | `DONE` | 官方文档依据已纳入第 15 节;界面 manifest 已落地 | | 全量静态页面和 CAD Workspace | `DONE` | `src/App.tsx`、`src/styles.css`;桌面/移动截图验收通过 | | 工作台/菜单/命令机器可读 manifest | `DONE` | `src/freecadManifest.ts`,13 个工作台和命令分组 | -| BitBybit Web CAD Facade 真实合同 | `PLANNED` | 由 P1 工作包冻结;当前只有页面层静态替身 | -| FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前 viewport 是静态示意 | -| Three.js 真实视口适配器 | `PLANNED` | 由 P5 工作包实施;当前不保存 Three.js 对象作为领域真值 | +| BitBybit Web CAD Facade 最小合同 | `DONE` | `src/facade/types.ts`、`src/facade/mockFacade.ts`;真实内核适配仍待 P3/P4 | +| FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 | +| Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`,WebGL2 适配器已挂入 Facade | +| React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 Facade 事件驱动 | | SQLite WASM + OPFS 持久化 | `PLANNED` | 由 P2 工作包实施;当前页面仅展示存储状态 | | 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 | -| 自动化测试、性能门禁和发布流水线 | `PLANNED` | 由 P8 工作包实施 | +| Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` | +| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 | ### 16.3 阶段门和交付节奏 @@ -1569,3 +1573,20 @@ P0 基线/治理 6. 阶段门证据可由其他成员在干净环境复现。 阶段门未通过时,任务只能保持 `IN PROGRESS` 或 `BLOCKED`,不得为了排期标记完成。 + +### 16.9 迭代 1 执行记录 + +本次开发已完成第一批合同和入口任务,状态如下: + +| 任务 | 状态 | 实际证据 | +|---|---|---| +| 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-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | +| P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | +| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | +| P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | +| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期和禁用命令;E2E/黄金几何待补齐 | + +本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。下一迭代继续完成 P0-01/P0-02 的精确锁定和 P1-02/P1-03 的命令状态、事件及诊断协议。 diff --git a/package-lock.json b/package-lock.json index 4c412d1..3624108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.185.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/three": "^0.185.3", "@vitejs/plugin-react": "^4.3.4", + "tsx": "^4.23.1", "typescript": "^5.7.2", "vite": "^6.0.5" } @@ -283,6 +286,12 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1091,6 +1100,12 @@ "win32" ] }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1163,6 +1178,32 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true + }, + "node_modules/@types/three": { + "version": "0.185.3", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.3.tgz", + "integrity": "sha512-8TqTn1+fjPWuJ4mR6Igtg56DCf9b5EeAlwhb5xaa6WlBrsg7SvG0NQbyctGRkHCKwg0uftfCspOEsTXelgktMA==", + "dev": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1350,6 +1391,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1430,6 +1477,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1612,6 +1665,11 @@ "node": ">=0.10.0" } }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1628,6 +1686,481 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index e826279..77eb7d6 100644 --- a/package.json +++ b/package.json @@ -6,17 +6,23 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "check:facade-boundary": "node scripts/check-facade-boundary.mjs", + "test:facade": "tsx --test tests/facade.test.ts", + "verify": "npm run check:facade-boundary && npm run test:facade && npm run build" }, "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.185.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/three": "^0.185.3", "@vitejs/plugin-react": "^4.3.4", + "tsx": "^4.23.1", "typescript": "^5.7.2", "vite": "^6.0.5" } diff --git a/scripts/check-facade-boundary.mjs b/scripts/check-facade-boundary.mjs new file mode 100644 index 0000000..d08d33b --- /dev/null +++ b/scripts/check-facade-boundary.mjs @@ -0,0 +1,34 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +const sourceRoot = new URL('../src/', import.meta.url) +const forbidden = [ + /from\s+["']three(?:\/|["'])/, /from\s+["']@types\/three/, /from\s+["']sqlite3?/, /from\s+["']@sqlite/, /from\s+["']opfs/, /SharedArrayBuffer/, /\bpostMessage\s*\(/, +] +const allowDirectRuntime = new Set(['facade/threeViewport.ts', 'facade/threeViewport.tsx', 'facade/persistenceWorker.ts', 'facade/geometryWorker.ts']) + +async function walk(relative = '') { + const directory = new URL(relative, sourceRoot) + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const next = join(relative, entry.name) + if (entry.isDirectory()) files.push(...await walk(next)) + else if (/\.(ts|tsx)$/.test(entry.name)) files.push(next) + } + return files +} + +const violations = [] +for (const file of await walk()) { + if (allowDirectRuntime.has(file)) continue + const content = await readFile(new URL(file, sourceRoot), 'utf8') + for (const pattern of forbidden) if (pattern.test(content)) violations.push(`${file}: ${pattern}`) +} + +if (violations.length) { + console.error('Facade-only boundary failed:') + violations.forEach((violation) => console.error(`- ${violation}`)) + process.exit(1) +} +console.log(`Facade-only boundary passed (${(await walk()).length} TypeScript source files checked).`) diff --git a/src/App.tsx b/src/App.tsx index 969981e..05da850 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComponentType, ReactNode } from 'react' import { AlertTriangle, @@ -53,6 +53,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' +import { createMockFacade, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -104,16 +105,6 @@ const projects = [ { name: 'Valve Body Study', path: 'Shared / Review / Valve Body Study', modified: '22 Jul 2026', objects: 31, state: 'Read only' }, ] -const treeData = [ - { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, - { id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] }, - { id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' }, - { id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' }, - { id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' }, - { id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' }, - { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, -] - const IconButton = ({ icon: IconComponent, label, onClick, active = false, disabled = false }: { icon: Icon; label: string; onClick?: () => void; active?: boolean; disabled?: boolean }) => ( - {leftTab === 'model' ?
Property view

{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}

{rightTab === 'data' ? : }
:
Combo View task tab

Use the right Task panel for command parameters. This tab stays available for selection and dependency context.

} + {leftTab === 'model' ?
Property view

{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}

{rightTab === 'data' ? : }
:
Combo View task tab

Use the right Task panel for command parameters. This tab stays available for selection and dependency context.

}
- +
Perspective
Grid 10 mm
XYZ
@@ -299,18 +303,18 @@ function FunctionRail({ workbench, showNotice }: { workbench: Workbench; showNot return } -function ModelTree({ selectedObject, setSelectedObject, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) { +function ModelTree({ document, selectedObject, setSelectedObject, showNotice }: { document: DocumentSnapshot; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) { const [expanded, setExpanded] = useState>({ origin: false, body: true, reference: false }) return
-
Pump Housingv18
- {treeData.map((item) => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)} +
{document.label}v{document.version}
+ {document.tree.map((item) => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)}
ValidNeeds review
} -function TreeItem({ item, level, expanded, onToggle, selectedObject, setSelectedObject, showNotice }: { item: typeof treeData[number]; level: number; expanded?: boolean; onToggle: () => void; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) { +function TreeItem({ item, level, expanded, onToggle, selectedObject, setSelectedObject, showNotice }: { item: ModelTreeItem; level: number; expanded?: boolean; onToggle: () => void; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) { const hasChildren = item.children && item.children.length > 0 const itemIcon = item.type === 'body' ? Box : item.type === 'sketch' ? CircleDot : item.type === 'feature' ? SquareStack : Layers3 const ItemIcon = itemIcon @@ -345,8 +349,22 @@ function ViewProperties({ showNotice }: { showNotice: (message: string) => void return
Display
showNotice('Visibility toggled')} /> showNotice('Display mode menu opened')} /> showNotice('Transparency editor opened')} />
Appearance
showNotice('Color picker opened')} /> showNotice('Color picker opened')} /> showNotice('Line width editor opened')} />
View state
} -function Viewport({ selectedObject, setSelectedObject, workbench, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; showNotice: (message: string) => void }) { - return
{workbench}Body / Fillet
setSelectedObject('')}>
{ event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}>
Pad
SelectedEdgesDatum
+function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { + const hostRef = useRef(null) + useEffect(() => { + const host = hostRef.current + if (!host) return + const adapter = facade.viewport.createAdapter() + try { + adapter.mount(host) + adapter.setSelection(selectedObject) + return () => adapter.dispose() + } catch (error) { + showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`) + adapter.dispose() + } + }, [facade, showNotice, selectedObject]) + return
{workbench}Body / Fillet
setSelectedObject('')}>
{ event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}>
Pad
SelectedEdgesDatum
} function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) { diff --git a/src/facade/index.ts b/src/facade/index.ts new file mode 100644 index 0000000..d1cf89f --- /dev/null +++ b/src/facade/index.ts @@ -0,0 +1,3 @@ +export { createMockFacade } from './mockFacade' +export { ThreeViewportAdapter } from './threeViewport' +export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts new file mode 100644 index 0000000..7faf3f1 --- /dev/null +++ b/src/facade/mockFacade.ts @@ -0,0 +1,71 @@ +import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest' +import type { + BitBybitWebCadFacade, + CommandState, + DocumentSnapshot, + ExecuteCommandInput, + FacadeEvent, + FacadeListener, + FacadeState, + ModelTreeItem, + TaskSnapshot, + Unsubscribe, +} from './types' +import { ThreeViewportAdapter } from './threeViewport' + +const initialTree: ModelTreeItem[] = [ + { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, + { id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] }, + { id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' }, + { id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' }, + { id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' }, + { id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' }, + { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, +] + +const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({ + id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), +}) + +const commandState = (commandId: string, activeWorkbench: WorkbenchId): CommandState => { + const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) + if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' } + if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' } + return { id: commandId, status: 'enabled' } +} + +export function createMockFacade(): BitBybitWebCadFacade { + let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '' } + const listeners = new Set() + let requestSequence = 0 + + const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) + const emitState = () => emit({ type: 'state.changed', state: getState() }) + const getState = () => ({ ...state, document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) + const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } + const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } + const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() } + const beginTask = (commandId: string, draft: Record = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task } + const execute = ({ commandId, payload }: ExecuteCommandInput) => { + const requestId = `req-${++requestSequence}` + const status = commandState(commandId, state.activeWorkbench) + if (status.status === 'disabled') { emit({ type: 'command.failed', commandId, requestId, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId } + emit({ type: 'command.started', commandId, requestId }) + if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') } + else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } } + else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) + else if (commandId === 'create-body') beginTask('create-body') + else if (commandId === 'create-sketch') beginTask('create-sketch') + emit({ type: 'command.completed', commandId, requestId }); emitState(); return requestId + } + + const facade: BitBybitWebCadFacade = { + app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } }, + gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, + 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: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, + viewport: { createAdapter: () => new ThreeViewportAdapter() }, + getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, + } + return facade +} diff --git a/src/facade/threeViewport.ts b/src/facade/threeViewport.ts new file mode 100644 index 0000000..72363e6 --- /dev/null +++ b/src/facade/threeViewport.ts @@ -0,0 +1,75 @@ +import * as THREE from 'three' +import type { BitBybitViewportAdapter } from './types' + +/** Internal renderer boundary. React receives only the adapter contract. */ +export class ThreeViewportAdapter implements BitBybitViewportAdapter { + private host: HTMLElement | null = null + private renderer: THREE.WebGLRenderer | null = null + private scene: THREE.Scene | null = null + private camera: THREE.PerspectiveCamera | null = null + private selection: THREE.Mesh | null = null + private frame = 0 + + mount(host: HTMLElement) { + this.host = host + this.scene = new THREE.Scene() + this.scene.background = new THREE.Color(0x0e1417) + this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000) + this.camera.position.set(3.8, 3.1, 5.2) + this.camera.lookAt(0, 0, 0) + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false) + host.appendChild(this.renderer.domElement) + const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439) + grid.rotation.x = 0 + this.scene.add(grid) + const geometry = new THREE.BoxGeometry(2.7, 1.6, 1.4) + const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 }) + this.selection = new THREE.Mesh(geometry, material) + this.selection.position.y = 0.8 + this.scene.add(this.selection) + this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1)) + const key = new THREE.DirectionalLight(0xffffff, 2.4) + key.position.set(4, 6, 4) + this.scene.add(key) + const render = () => { + if (!this.renderer || !this.scene || !this.camera) return + this.selection && (this.selection.rotation.y += 0.002) + this.renderer.render(this.scene, this.camera) + this.frame = requestAnimationFrame(render) + } + this.resize(host.clientWidth || 1, host.clientHeight || 1) + render() + } + + resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) { + if (!this.renderer || !this.camera) return + this.camera.aspect = Math.max(width, 1) / Math.max(height, 1) + this.camera.updateProjectionMatrix() + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) + this.renderer.setSize(Math.max(width, 1), Math.max(height, 1), false) + } + + setSelection(objectId: string) { + if (!this.selection) return + const material = this.selection.material as THREE.MeshStandardMaterial + material.color.set(objectId ? 0x5ed6d6 : 0x579a9c) + material.emissive.set(objectId ? 0x123c3c : 0x000000) + } + + dispose() { + cancelAnimationFrame(this.frame) + this.selection?.geometry.dispose() + if (this.selection?.material instanceof THREE.Material) this.selection.material.dispose() + this.renderer?.dispose() + this.renderer?.domElement.remove() + this.host = null + this.renderer = null + this.scene = null + this.camera = null + this.selection = null + } + + getBackend() { return 'webgl2' as const } +} diff --git a/src/facade/types.ts b/src/facade/types.ts new file mode 100644 index 0000000..faa7e06 --- /dev/null +++ b/src/facade/types.ts @@ -0,0 +1,106 @@ +import type { CommandDefinition, WorkbenchId } from '../freecadManifest' + +export type ModelTreeItem = { + id: string + label: string + type: 'document' | 'folder' | 'body' | 'sketch' | 'feature' + state?: 'active' | 'valid' | 'warning' | 'readonly' + detail?: string + children?: string[] +} + +export type DocumentSnapshot = { + id: string + label: string + version: number + dirty: boolean + readOnly: boolean + units: string + tree: ModelTreeItem[] +} + +export type CommandState = { + id: string + status: 'hidden' | 'disabled' | 'enabled' | 'active' + reason?: string +} + +export type TaskSnapshot = { + id: string + commandId: string + title: string + status: 'idle' | 'preview' | 'committing' | 'cancelled' | 'completed' | 'failed' + draft: Record +} + +export type FacadeState = { + apiVersion: '0.1' + activeWorkbench: WorkbenchId + selectedObjectId: string + document: DocumentSnapshot + task: TaskSnapshot | null + lastNotice: string +} + +export type FacadeEvent = + | { type: 'state.changed'; state: FacadeState } + | { type: 'notice'; message: string } + | { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; requestId: string; message?: string } + +export type FacadeListener = (event: FacadeEvent) => void +export type Unsubscribe = () => void + +export type ViewportBackend = 'webgl2' | 'webgpu' + +export interface BitBybitViewportAdapter { + mount(host: HTMLElement): void + resize(width: number, height: number, devicePixelRatio?: number): void + setSelection(objectId: string): void + dispose(): void + getBackend(): ViewportBackend +} + +export type ExecuteCommandInput = { + commandId: string + payload?: Record +} + +export interface BitBybitWebCadFacade { + readonly app: { + document: { + getActive(): DocumentSnapshot + create(label?: string): DocumentSnapshot + markDirty(): void + } + } + readonly gui: { + workbench: { + list(): WorkbenchId[] + getActive(): WorkbenchId + setActive(id: WorkbenchId): void + } + command: { + getState(commandId: string): CommandState + list(workbench: WorkbenchId): CommandDefinition[] + execute(input: ExecuteCommandInput): string + } + } + readonly selection: { + getObjectId(): string + select(objectId: string): void + clear(): void + } + readonly task: { + getActive(): TaskSnapshot | null + begin(commandId: string, draft?: Record): TaskSnapshot + update(draft: Record): void + apply(): void + cancel(): void + } + readonly viewport: { + createAdapter(): BitBybitViewportAdapter + } + getState(): FacadeState + subscribe(listener: FacadeListener): Unsubscribe + notify(message: string): void +} diff --git a/src/styles.css b/src/styles.css index 8b5c2f3..de24315 100644 --- a/src/styles.css +++ b/src/styles.css @@ -299,6 +299,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .combo-model { height: calc(100% - 39px); min-height: 0; display: flex; flex-direction: column; }.model-tree { min-height: 190px; flex: 1 1 58%; display: flex; flex-direction: column; }.tree-toolbar { display: flex; gap: 5px; padding: 10px 9px; }.tree-search { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 8px; border: 1px solid var(--line); background: var(--bg-raised); border-radius: 3px; color: var(--text-muted); }.tree-search input { font-size: 11px; }.tree-root { padding: 0 4px; overflow: auto; }.tree-document-row { height: 29px; display: flex; align-items: center; gap: 6px; padding: 0 8px; color: var(--text); font-size: 11px; font-weight: 650; }.tree-version { margin-left: auto; color: var(--text-muted); font-size: 9px; font-weight: 500; }.tree-row { width: 100%; min-height: 29px; display: flex; align-items: center; gap: 5px; padding-right: 8px; border: 0; border-left: 2px solid transparent; background: transparent; color: var(--text-soft); text-align: left; font-size: 11px; cursor: pointer; }.tree-row:hover { background: var(--bg-hover); color: var(--text); }.tree-row.is-selected { color: var(--text); border-left-color: var(--cyan); background: var(--cyan-soft); }.tree-row.is-active .tree-label { color: var(--cyan); }.tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tree-spacer { width: 13px; }.tree-child { color: var(--text-muted); }.tree-children .tree-row { padding-left: 29px !important; }.active-marker { width: 5px; height: 5px; margin-left: auto; background: var(--cyan); border-radius: 50%; }.tree-footer { display: flex; gap: 13px; margin-top: auto; padding: 12px 12px 14px; border-top: 1px solid var(--line-soft); color: var(--text-muted); font-size: 9px; }.tree-footer span { display: inline-flex; align-items: center; gap: 5px; }.legend-dot { width: 5px; height: 5px; display: inline-block; border-radius: 50%; }.legend-dot.valid { background: var(--green); }.legend-dot.warning { background: var(--amber); } .task-panel { height: calc(100% - 39px); display: flex; flex-direction: column; overflow: auto; }.task-header { display: flex; align-items: center; gap: 9px; padding: 18px 15px; border-bottom: 1px solid var(--line-soft); }.task-header .badge { margin-left: auto; }.task-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--cyan); background: var(--cyan-soft); border-radius: 4px; }.task-header h2 { margin: 4px 0 0; font-size: 13px; }.task-body { padding: 16px 15px; }.task-step { display: flex; gap: 9px; padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--line-soft); }.step-index { width: 19px; height: 19px; display: grid; place-items: center; color: #142124; background: var(--cyan); border-radius: 50%; font-size: 10px; font-weight: 700; flex-shrink: 0; }.task-step strong, .task-step span { display: block; }.task-step strong { font-size: 11px; }.task-step span { color: var(--text-muted); font-size: 10px; line-height: 1.4; margin-top: 3px; }.field-label { display: block; color: var(--text-soft); font-size: 10px; margin-bottom: 14px; }.field-unit { float: right; color: var(--text-muted); }.field-input { width: 100%; height: 30px; display: block; margin-top: 6px; padding: 0 8px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); font-size: 11px; }.field-input:focus { border-color: #527479; outline: none; }.check-row { display: flex; align-items: center; gap: 7px; color: var(--text-soft); font-size: 10px; margin-top: 3px; }.check-row input { accent-color: var(--cyan); }.task-note { display: flex; align-items: flex-start; gap: 7px; margin-top: 18px; color: var(--amber); font-size: 10px; line-height: 1.4; }.task-footer { display: flex; gap: 5px; margin-top: auto; padding: 11px 12px; border-top: 1px solid var(--line); }.task-footer .button { flex: 1; padding: 0 7px; min-height: 31px; font-size: 11px; } .viewport-region { min-width: 0; position: relative; background: #0e1417; overflow: hidden; }.viewport { position: absolute; inset: 0; display: flex; flex-direction: column; }.viewport-header { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid rgba(85, 113, 119, .22); background: rgba(15, 22, 25, .86); z-index: 1; }.viewport-title { display: flex; flex-direction: column; gap: 3px; }.viewport-title strong { font-size: 11px; font-weight: 600; }.viewport-title .eyebrow { color: var(--text-muted); }.viewport-actions { display: flex; gap: 3px; }.viewport-grid { min-height: 0; flex: 1; position: relative; display: grid; place-items: center; overflow: hidden; cursor: crosshair; }.grid-lines { position: absolute; inset: -25%; opacity: .37; transform: perspective(500px) rotateX(62deg) translateY(13%); background-image: linear-gradient(rgba(81, 130, 136, .22) 1px, transparent 1px), linear-gradient(90deg, rgba(81, 130, 136, .22) 1px, transparent 1px); background-size: 28px 28px; }.mock-part { position: relative; z-index: 2; width: 285px; height: 180px; transform: translate(5%, -4%) skewY(-20deg) rotate(21deg); filter: drop-shadow(24px 22px 0 rgba(0, 0, 0, .16)); cursor: pointer; }.part-front { position: absolute; inset: 0; border: 1px solid #8bd2cf; background: #447d82; box-shadow: inset -12px -14px 0 rgba(13, 36, 41, .25); }.part-top { position: absolute; width: 285px; height: 64px; top: -48px; left: 0; border: 1px solid #83c7c6; background: #60999a; transform: skewX(-35deg); transform-origin: bottom left; }.part-side { position: absolute; width: 50px; height: 180px; right: -34px; top: -24px; border: 1px solid #6ba9a9; background: #34636b; transform: skewY(55deg); transform-origin: top left; }.part-hole { position: absolute; z-index: 3; width: 38px; height: 38px; border-radius: 50%; border: 7px solid #223f44; background: #111b1e; box-shadow: 0 0 0 1px #95d9d5, inset 0 0 0 3px #17282c; }.part-hole.one { left: 55px; top: 51px; }.part-hole.two { right: 50px; top: 51px; }.part-cut { position: absolute; width: 125px; height: 25px; left: 81px; bottom: 31px; border: 1px solid rgba(168, 224, 219, .72); background: rgba(31, 70, 74, .42); }.part-selected .part-front { background: #4e9799; border-color: #b9f1eb; box-shadow: 0 0 0 2px rgba(94, 214, 214, .3), inset -12px -14px 0 rgba(13, 36, 41, .25); }.mock-datum-plane { position: absolute; z-index: 1; width: 320px; height: 205px; border: 1px dashed rgba(239, 190, 114, .42); background: rgba(239, 190, 114, .04); transform: translate(28%, 25%) skewY(-22deg) rotate(21deg); }.selection-callout { position: absolute; z-index: 3; top: 25%; left: 68%; display: flex; align-items: center; gap: 7px; color: var(--cyan); font-size: 10px; }.callout-line { width: 42px; height: 1px; background: var(--cyan); position: relative; }.callout-line::after { content: ''; width: 5px; height: 5px; position: absolute; left: 0; top: -2px; border-radius: 50%; background: var(--cyan); }.viewport-legend { position: absolute; z-index: 4; left: 16px; bottom: 14px; display: flex; gap: 12px; color: var(--text-muted); font-size: 9px; }.viewport-legend span { display: inline-flex; align-items: center; gap: 5px; }.legend-swatch { width: 12px; height: 3px; display: inline-block; border-radius: 2px; }.legend-swatch.selected { background: var(--cyan); }.legend-swatch.edge { background: #8cb7b6; }.legend-swatch.datum { border-top: 1px dashed var(--amber); }.viewport-bottom-left { position: absolute; z-index: 5; left: 16px; bottom: 42px; display: flex; gap: 5px; }.view-chip { display: flex; align-items: center; gap: 5px; padding: 5px 7px; color: var(--text-muted); background: rgba(25, 34, 38, .85); border: 1px solid rgba(93, 124, 129, .32); border-radius: 3px; font-size: 9px; }.axis-widget { width: 64px; height: 64px; position: relative; margin: 0 23px 23px 0; }.axis-origin { width: 7px; height: 7px; position: absolute; left: 28px; top: 30px; border-radius: 50%; background: #d9e3e3; }.axis-widget::before, .axis-widget::after { content: ''; position: absolute; left: 31px; top: 31px; width: 29px; height: 1px; transform-origin: left center; }.axis-widget::before { background: #d77f7e; transform: rotate(-18deg); }.axis-widget::after { background: #78d19f; transform: rotate(-80deg); }.axis-x, .axis-y, .axis-z { position: absolute; font-size: 9px; font-weight: 700; }.axis-x { right: 0; top: 19px; color: #e58b88; }.axis-y { left: 18px; top: 0; color: #7bd7a0; }.axis-z { left: 19px; bottom: 0; color: #8abcf0; } +.three-viewport-host { position: absolute; inset: 0; z-index: 1; opacity: .28; pointer-events: none; }.three-viewport-host canvas { width: 100%; height: 100%; display: block; } .properties-panel { display: flex; flex-direction: column; }.property-heading { display: flex; align-items: flex-start; justify-content: space-between; min-height: 75px; padding: 16px 13px 10px; }.property-heading h2 { margin: 5px 0 0; font-size: 15px; font-weight: 650; }.property-tabs { height: 34px; }.property-tabs button { padding: 0 14px; }.properties-scroll { overflow: auto; padding-bottom: 20px; }.property-group { border-bottom: 1px solid var(--line-soft); padding: 0 13px 10px; }.property-group-title { display: flex; align-items: center; justify-content: space-between; padding: 12px 0 8px; color: var(--text-muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.property-row { min-height: 28px; display: grid; grid-template-columns: 43% 57%; align-items: center; gap: 5px; }.property-label { color: var(--text-muted); font-size: 10px; }.property-value { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; border: 0; padding: 4px 0; background: transparent; color: var(--text-soft); text-align: right; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-value:disabled { opacity: 1; }.property-value.is-editable, .property-value.is-link { color: var(--text); cursor: pointer; }.property-value.is-editable:hover { color: var(--cyan); }.property-value.is-link { color: var(--cyan); }.status-pill { height: 16px; display: inline-flex; align-items: center; padding: 0 4px; border-radius: 2px; font-size: 8px; }.status-pill.green { color: var(--green); background: var(--green-soft); }.status-pill.amber { color: var(--amber); background: var(--amber-soft); }.expression-row { min-height: 29px; display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 10px; }.expression-row .expression-value { margin-left: auto; color: var(--cyan); }.expression-row button { display: grid; place-items: center; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; }.expression-row button:hover { color: var(--text); } .bottom-drawer { height: 35px; flex-shrink: 0; border-top: 1px solid var(--line); background: var(--bg-panel); transition: height .16s ease; }.bottom-drawer.is-open { height: 122px; }.bottom-drawer-header { height: 35px; display: flex; align-items: center; justify-content: space-between; }.drawer-tabs { height: 100%; display: flex; align-items: stretch; gap: 2px; padding-left: 9px; }.drawer-tabs button { display: flex; align-items: center; gap: 6px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); font-size: 10px; padding: 0 8px; cursor: pointer; }.drawer-tabs button.is-active { color: var(--text); border-bottom-color: var(--cyan); }.drawer-toggle { width: 32px; height: 30px; margin-right: 5px; display: grid; place-items: center; border: 0; color: var(--text-muted); background: transparent; cursor: pointer; }.drawer-toggle:hover { color: var(--text); }.bottom-drawer-content { padding: 5px 13px 9px; border-top: 1px solid var(--line-soft); }.report-line { min-height: 30px; display: flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 10px; }.report-context { color: var(--text-muted); }.report-line button { border: 0; background: transparent; color: var(--cyan); font-size: 10px; cursor: pointer; }.report-time { margin-left: auto; color: var(--text-muted); }.icon-green { color: var(--green); }.icon-amber { color: var(--amber); }.icon-cyan { color: var(--cyan); }.icon-muted { color: var(--text-muted); } .toast { position: fixed; z-index: 30; right: 22px; bottom: 22px; display: flex; align-items: center; gap: 8px; padding: 11px 13px; color: #10231d; background: var(--green); border-radius: 4px; box-shadow: 0 10px 25px rgba(0, 0, 0, .28); font-size: 11px; font-weight: 650; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts new file mode 100644 index 0000000..38086d1 --- /dev/null +++ b/tests/facade.test.ts @@ -0,0 +1,47 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createMockFacade } from '../src/facade/mockFacade' + +test('facade exposes a stable initial document projection', () => { + const facade = createMockFacade() + const state = facade.getState() + assert.equal(state.apiVersion, '0.1') + assert.equal(state.document.id, 'doc-pump-housing') + assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body') + assert.equal(facade.gui.workbench.getActive(), 'Part Design') +}) + +test('workbench and selection changes are event driven', () => { + const facade = createMockFacade() + const events: string[] = [] + const unsubscribe = facade.subscribe((event) => events.push(event.type)) + facade.gui.workbench.setActive('Sketcher') + facade.selection.select('sketch') + unsubscribe() + assert.equal(facade.getState().activeWorkbench, 'Sketcher') + assert.equal(facade.selection.getObjectId(), 'sketch') + assert.ok(events.includes('state.changed')) + assert.ok(events.includes('notice')) +}) + +test('commands and tasks preserve apply/cancel lifecycle', () => { + const facade = createMockFacade() + facade.gui.command.execute({ commandId: 'create-sketch' }) + assert.equal(facade.task.getActive()?.status, 'preview') + facade.task.update({ support: 'XY_Plane' }) + assert.equal(facade.task.getActive()?.draft.support, 'XY_Plane') + facade.task.cancel() + assert.equal(facade.task.getActive()?.status, 'cancelled') +}) + +test('disabled commands return a reason instead of mutating the document', () => { + const facade = createMockFacade() + const before = facade.getState().document.version + const state = facade.gui.command.getState('pad') + assert.equal(state.status, 'enabled') + facade.gui.workbench.setActive('Sketcher') + const disabled = facade.gui.command.getState('pad') + assert.equal(disabled.status, 'disabled') + assert.match(disabled.reason || '', /Part Design/) + assert.equal(facade.getState().document.version, before) +}) diff --git a/vite.config.ts b/vite.config.ts index 7c0006b..0ddb78e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,4 +7,14 @@ export default defineConfig({ port: 5173, strictPort: false, }, + build: { + chunkSizeWarningLimit: 600, + rollupOptions: { + output: { + manualChunks: { + three: ['three'], + }, + }, + }, + }, })