feat: establish facade-first runtime boundary

This commit is contained in:
2026-08-02 02:39:37 -04:00
parent 3978833c5c
commit df0053cfe5
16 changed files with 1004 additions and 34 deletions

View File

@@ -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`

View File

@@ -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."
]
}

View File

@@ -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."
}

View File

@@ -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"
}

View File

@@ -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 的命令状态、事件及诊断协议。

535
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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).`)

View File

@@ -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 }) => (
<button className={`icon-button ${active ? 'is-active' : ''}`} title={label} aria-label={label} onClick={onClick} disabled={disabled}>
<IconComponent size={16} strokeWidth={1.8} />
@@ -129,12 +120,20 @@ const Badge = ({ tone, children }: { tone: 'cyan' | 'green' | 'amber' | 'red' |
function App() {
const [page, setPage] = useState<Page>(pageFromPath)
const [workbench, setWorkbench] = useState<Workbench>('Part Design')
const [leftTab, setLeftTab] = useState<'model' | 'tasks'>('model')
const [rightTab, setRightTab] = useState<'data' | 'view'>('data')
const [bottomOpen, setBottomOpen] = useState(true)
const [selectedObject, setSelectedObject] = useState('pad')
const [notice, setNotice] = useState('')
const facade = useMemo(() => createMockFacade(), [])
const [facadeState, setFacadeState] = useState(() => facade.getState())
useEffect(() => facade.subscribe((event) => {
if (event.type === 'state.changed') setFacadeState(event.state)
if (event.type === 'notice') {
setNotice(event.message)
window.setTimeout(() => setNotice(''), 2600)
}
}), [facade])
useEffect(() => {
const handlePopState = () => setPage(pageFromPath())
@@ -159,13 +158,14 @@ function App() {
}
const openWorkspace = () => navigate('workspace')
const workbench = facadeState.activeWorkbench
const selectedObject = facadeState.selectedObjectId
const currentWorkbench = workbenchMeta[workbench]
const CurrentIcon = currentWorkbench.icon
const showNotice = (message: string) => {
setNotice(message)
window.setTimeout(() => setNotice(''), 2600)
}
const showNotice = useCallback((message: string) => facade.notify(message), [facade])
const setWorkbench = useCallback((value: Workbench) => facade.gui.workbench.setActive(value), [facade])
const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade])
return (
<div className="app-shell">
@@ -173,7 +173,7 @@ function App() {
{page === 'workspace' ? (
<Workspace
workbench={workbench}
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks'); showNotice(`${value} workbench loaded`) }}
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks') }}
leftTab={leftTab}
setLeftTab={setLeftTab}
rightTab={rightTab}
@@ -182,6 +182,8 @@ function App() {
setBottomOpen={setBottomOpen}
selectedObject={selectedObject}
setSelectedObject={setSelectedObject}
document={facadeState.document}
facade={facade}
workbenchIcon={CurrentIcon}
showNotice={showNotice}
/>
@@ -229,7 +231,7 @@ function TopBar({ page, workbench, onNavigate, onOpenWorkspace }: { page: Page;
)
}
function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, workbenchIcon: WorkbenchIcon, showNotice }: {
function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, document, facade, workbenchIcon: WorkbenchIcon, showNotice }: {
workbench: Workbench
setWorkbench: (value: Workbench) => void
leftTab: 'model' | 'tasks'
@@ -240,6 +242,8 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
setBottomOpen: (open: boolean) => void
selectedObject: string
setSelectedObject: (id: string) => void
document: DocumentSnapshot
facade: BitBybitWebCadFacade
workbenchIcon: Icon
showNotice: (message: string) => void
}) {
@@ -272,10 +276,10 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
<div className="workspace-content">
<aside className="combo-panel left-panel">
<div className="panel-tabs"><button className={leftTab === 'model' ? 'is-active' : ''} onClick={() => setLeftTab('model')}><ListTree size={14} />Model</button><button className={leftTab === 'tasks' ? 'is-active' : ''} onClick={() => setLeftTab('tasks')}><SlidersHorizontal size={14} />Tasks <span className="tab-count">1</span></button></div>
{leftTab === 'model' ? <div className="combo-model"><ModelTree selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div>{rightTab === 'data' ? <DataProperties selectedObject={selectedObject} showNotice={showNotice} /> : <ViewProperties showNotice={showNotice} />}</div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>}
{leftTab === 'model' ? <div className="combo-model"><ModelTree document={document} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div>{rightTab === 'data' ? <DataProperties selectedObject={selectedObject} showNotice={showNotice} /> : <ViewProperties showNotice={showNotice} />}</div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>}
</aside>
<section className="viewport-region">
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} showNotice={showNotice} />
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} facade={facade} showNotice={showNotice} />
<div className="viewport-bottom-left"><div className="view-chip"><Circle size={8} fill="currentColor" /> Perspective</div><div className="view-chip">Grid 10 mm</div></div>
<div className="viewport-bottom-right"><div className="axis-widget"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span><div className="axis-origin" /></div></div>
</section>
@@ -299,18 +303,18 @@ function FunctionRail({ workbench, showNotice }: { workbench: Workbench; showNot
return <aside className="function-rail" aria-label={`${workbench} commands`}>{groups.map((group) => <div className="rail-group" key={group.label}><span className="rail-group-label">{group.label}</span>{group.commands.slice(0, 5).map((item) => { const IconComponent = iconMap[item.icon] || MoreHorizontal; return <button key={item.id} title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`} aria-label={item.label} onClick={() => showNotice(`${item.label} command staged`)}><IconComponent size={15} /></button> })}</div>)}</aside>
}
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<Record<string, boolean>>({ origin: false, body: true, reference: false })
return <div className="model-tree">
<div className="tree-toolbar"><div className="tree-search"><Search size={14} /><input placeholder="Filter objects" /></div><IconButton icon={MoreHorizontal} label="Model tree actions" /></div>
<div className="tree-root"><div className="tree-document-row"><ChevronDown size={14} /><FileBox size={15} className="icon-cyan" /><span>Pump Housing</span><span className="tree-version">v18</span></div>
{treeData.map((item) => <TreeItem key={item.id} item={item} level={1} expanded={expanded[item.id]} onToggle={() => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)}
<div className="tree-root"><div className="tree-document-row"><ChevronDown size={14} /><FileBox size={15} className="icon-cyan" /><span>{document.label}</span><span className="tree-version">v{document.version}</span></div>
{document.tree.map((item) => <TreeItem key={item.id} item={item} level={1} expanded={expanded[item.id]} onToggle={() => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)}
</div>
<div className="tree-footer"><span><span className="legend-dot valid" />Valid</span><span><span className="legend-dot warning" />Needs review</span></div>
</div>
}
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 <div className="properties-scroll"><div className="property-group"><div className="property-group-title">Display <ChevronDown size={14} /></div><PropertyRow label="Visibility" value="Visible" editable onClick={() => showNotice('Visibility toggled')} /><PropertyRow label="Display mode" value="Flat lines" editable onClick={() => showNotice('Display mode menu opened')} /><PropertyRow label="Transparency" value="0 %" editable onClick={() => showNotice('Transparency editor opened')} /></div><div className="property-group"><div className="property-group-title">Appearance <ChevronDown size={14} /></div><PropertyRow label="Shape color" value="Graphite / 02" editable onClick={() => showNotice('Color picker opened')} /><PropertyRow label="Line color" value="Steel / 04" editable onClick={() => showNotice('Color picker opened')} /><PropertyRow label="Line width" value="1.0 px" editable onClick={() => showNotice('Line width editor opened')} /></div><div className="property-group"><div className="property-group-title">View state <ChevronDown size={14} /></div><PropertyRow label="Selection style" value="Object + edges" /><PropertyRow label="Tessellation" value="Adaptive" /><PropertyRow label="Render cache" value="Ready" tone="green" /></div></div>
}
function Viewport({ selectedObject, setSelectedObject, workbench, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; showNotice: (message: string) => void }) {
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="grid-lines" /><div className={`mock-part ${selectedObject === '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>
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)
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 <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' ? '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>
}
function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {

3
src/facade/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { createMockFacade } from './mockFacade'
export { ThreeViewportAdapter } from './threeViewport'
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types'

71
src/facade/mockFacade.ts Normal file
View File

@@ -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<FacadeListener>()
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<string, unknown> = {}) => { 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
}

View File

@@ -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 }
}

106
src/facade/types.ts Normal file
View File

@@ -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<string, unknown>
}
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<string, unknown>
}
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<string, unknown>): TaskSnapshot
update(draft: Record<string, unknown>): void
apply(): void
cancel(): void
}
readonly viewport: {
createAdapter(): BitBybitViewportAdapter
}
getState(): FacadeState
subscribe(listener: FacadeListener): Unsubscribe
notify(message: string): void
}

View File

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

47
tests/facade.test.ts Normal file
View File

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

View File

@@ -7,4 +7,14 @@ export default defineConfig({
port: 5173,
strictPort: false,
},
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks: {
three: ['three'],
},
},
},
},
})