diff --git a/CLAUDE.md b/CLAUDE.md index e04432d41..9cff8745d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,8 @@ node test/usd-viewer/scripts/cleanup-headless.cjs # 清理自动化残留浏览 单元测试继续邻近源码放置:`src/**/*.test.*` / `src/**/*.spec.*`。不要把普通单元测试搬到统一 `tests/` 目录。 +完整测试金字塔、命令选择和新增测试落点见 [docs/testing.md](docs/testing.md)。浏览器测试体系修复的历史交接与剩余事项见 [docs/testing-rebuild-handoff.md](docs/testing-rebuild-handoff.md)。 + 给 Codex / Claude 等 agent 选择命令时按以下优先级: | 场景 | 命令 | @@ -181,13 +183,15 @@ node test/usd-viewer/scripts/cleanup-headless.cjs # 清理自动化残留浏览 ## 文档导航 -| 任务 | 文档 | -| ------------------------------------- | ---------------------------------------------------- | -| Editor / 3D / Viewer / USD runtime | [docs/viewer.md](docs/viewer.md) | -| 导入导出 / Workspace / 组装 | [docs/file-io.md](docs/file-io.md) | -| UI 样式 / 颜色 / 主题 / 可访问性 | [docs/style-guide.md](docs/style-guide.md) | -| AI 助手 / 审阅 / skill 路由 | [docs/ai-features.md](docs/ai-features.md) | -| 架构边界 / 依赖方向 / 例外 / 设计哲学 | [docs/architecture.md](docs/architecture.md) | -| 验收清单 / 测试样本 / 回归命令 | [docs/update-rules.md](docs/update-rules.md) | -| react-robot-canvas 对外库 | [docs/robot-canvas-lib.md](docs/robot-canvas-lib.md) | -| 完整文档索引 | [docs/CATALOG.md](docs/CATALOG.md) | +| 任务 | 文档 | +| ------------------------------------- | ------------------------------------------------------------------ | +| Editor / 3D / Viewer / USD runtime | [docs/viewer.md](docs/viewer.md) | +| 导入导出 / Workspace / 组装 | [docs/file-io.md](docs/file-io.md) | +| UI 样式 / 颜色 / 主题 / 可访问性 | [docs/style-guide.md](docs/style-guide.md) | +| AI 助手 / 审阅 / skill 路由 | [docs/ai-features.md](docs/ai-features.md) | +| 架构边界 / 依赖方向 / 例外 / 设计哲学 | [docs/architecture.md](docs/architecture.md) | +| 测试金字塔 / 命令选择 / 新增测试落点 | [docs/testing.md](docs/testing.md) | +| 浏览器测试体系修复交接 | [docs/testing-rebuild-handoff.md](docs/testing-rebuild-handoff.md) | +| 验收清单 / 测试样本 / 回归命令 | [docs/update-rules.md](docs/update-rules.md) | +| react-robot-canvas 对外库 | [docs/robot-canvas-lib.md](docs/robot-canvas-lib.md) | +| 完整文档索引 | [docs/CATALOG.md](docs/CATALOG.md) | diff --git a/README.md b/README.md index 8b903d431..5568640f8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Professional URDF design and visualization workstation. Supports rapid editing, collision optimization, modular assembly, parameter configuration, AI generation, and multi-format export. -**Live demo:** [urdf.d-robotics.cc](https://urdf.d-robotics.cc/) +**Live demo:** [urdf.enkeebot.com](https://urdf.enkeebot.com/) [English](./README.md) | [中文](./README_CN.md) diff --git a/README_CN.md b/README_CN.md index cd0c41760..1064a0552 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@ 面向 `URDF`、`MJCF`、`USD`、`Xacro`、`SDF` 和 `.usp` 项目工作流的机器人设计、组装、可视化与导出工作台。支持快速编辑、碰撞优化、模块化组装、参数配置、AI 生成与多格式导出。 -**在线体验:** [urdf.d-robotics.cc](https://urdf.d-robotics.cc/) +**在线体验:** [urdf.enkeebot.com](https://urdf.enkeebot.com/) [English](./README.md) | [中文](./README_CN.md) diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 8e4f708de..65d9b458b 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -2,18 +2,20 @@ > 最后更新:2026-05-26 -主入口:[CLAUDE.md](../CLAUDE.md)(项目定位、目录结构、架构红线、Store 表、常用命令、文档导航) +主入口:[CLAUDE.md](../CLAUDE.md)(项目定位、目录结构、架构红线、Store 表、常用命令、测试分层、文档导航) ## 文档清单 -| 文档 | 内容 | 行数 | -|------|------|------| -| [CLAUDE.md](../CLAUDE.md) | 项目入口:定位、结构、红线、Store、命令、导航 | ~137 | -| [viewer.md](viewer.md) | Editor/Viewer 子域:拓扑、几何/碰撞/测量、USD runtime、offscreen、hydration | ~115 | -| [file-io.md](file-io.md) | 导入导出链路:App 编排、File I/O、Workspace、组装、project archive | ~140 | -| [wasm-build.md](wasm-build.md) | OpenUSD WASM 构建:源码迁移、编译脚本、故障排查 | ~150 | -| [style-guide.md](style-guide.md) | UI 样式:语义色 token、蓝色约束、暗色层级、面板文案、验收标准 | ~70 | -| [ai-features.md](ai-features.md) | AI 助手:环境变量、审阅标准路径、skill-first 路由 | ~60 | -| [architecture.md](architecture.md) | 架构补充:例外清单、debuggability first、Linux 哲学、内存约束、检查命令 | ~115 | -| [update-rules.md](update-rules.md) | 变更工作流:验收清单、增量命令、测试样本索引、浏览器验证、文档更新映射 | ~142 | -| [robot-canvas-lib.md](robot-canvas-lib.md) | 对外库说明:RobotCanvas API、发布流程、后续拆分建议 | ~85 | +| 文档 | 内容 | 行数 | +| -------------------------------------------------------- | --------------------------------------------------------------------------- | ---- | +| [CLAUDE.md](../CLAUDE.md) | 项目入口:定位、结构、红线、Store、命令、导航 | ~137 | +| [viewer.md](viewer.md) | Editor/Viewer 子域:拓扑、几何/碰撞/测量、USD runtime、offscreen、hydration | ~115 | +| [file-io.md](file-io.md) | 导入导出链路:App 编排、File I/O、Workspace、组装、project archive | ~140 | +| [wasm-build.md](wasm-build.md) | OpenUSD WASM 构建:源码迁移、编译脚本、故障排查 | ~150 | +| [style-guide.md](style-guide.md) | UI 样式:语义色 token、蓝色约束、暗色层级、面板文案、验收标准 | ~70 | +| [ai-features.md](ai-features.md) | AI 助手:环境变量、审阅标准路径、skill-first 路由 | ~60 | +| [architecture.md](architecture.md) | 架构补充:例外清单、debuggability first、Linux 哲学、内存约束、检查命令 | ~115 | +| [testing.md](testing.md) | 测试指南:三层测试金字塔、命令入口、新增测试落点、结果阅读 | ~90 | +| [testing-rebuild-handoff.md](testing-rebuild-handoff.md) | 浏览器测试体系修复交接:已知根因、剩余任务、验证标准 | ~130 | +| [update-rules.md](update-rules.md) | 变更工作流:验收清单、增量命令、测试样本索引、浏览器验证、文档更新映射 | ~142 | +| [robot-canvas-lib.md](robot-canvas-lib.md) | 对外库说明:RobotCanvas API、发布流程、后续拆分建议 | ~85 | diff --git a/docs/architecture.md b/docs/architecture.md index 612135b7b..99e755aa3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,9 +22,15 @@ ## 3. 当前存量例外(禁止扩散) 运行时代码: -- `src/shared/hooks/useTheme.ts` -> `@/store/uiStore` -- `src/shared/components/Panel/JointControlItem.tsx` -> `@/store/robotStore` -- `src/features/ai-assistant/utils/pdfExport.ts` -> `@/features/file-io/components/InspectionReportTemplate` +- `src/features/editor/index.ts` -> `src/features/urdf-viewer/index.ts`(Editor facade) +- `src/features/editor/viewerPanelModule.ts` -> `src/features/urdf-viewer/components/ViewerPanels.tsx` +- `src/features/editor/viewerPanelModule.ts` -> `src/features/urdf-viewer/hooks/useResponsivePanelLayout.ts` +- `src/features/editor/viewerPanelModule.ts` -> `src/features/urdf-viewer/hooks/useViewerController.ts` +- `src/lib/components/RobotCanvas.tsx` -> `src/features/urdf-viewer/components/ViewerCanvas.tsx` +- `src/lib/components/RobotCanvas.tsx` -> `src/features/urdf-viewer/components/JointInteraction.tsx` +- `src/lib/components/RobotCanvas.tsx` -> `src/features/urdf-viewer/components/RobotModel.tsx` + +上述例外由 `scripts/tools/dependency_boundaries.mjs` 按 importer + specifier + resolved target 精确匹配,禁止扩大为整层或整 feature 例外。 测试期例外(不作为运行时先例): - `src/features/file-io/utils/usdFloatingRoundtrip.test.ts` -> `urdf-viewer` runtime/utils @@ -35,6 +41,7 @@ - `editor`:统一 Editor 公开入口,通过 `src/features/editor/index.ts` 暴露 - `urdf-viewer`:Editor 实现子目录,通过 `src/features/urdf-viewer/index.ts` 暴露 - `file-io`:导入导出入口,通过 `src/features/file-io/index.ts` 暴露 +- `app` 层新增对 `src/features//...` 子路径的 deep import 必须先收敛到 feature 公开入口;存量 deep import 只保留在 `dependency_boundaries_baseline.json` 的 `knownFeatureDeepImports` ratchet 中,按 `importer -> specifier` 精确计数,修掉后删除对应 baseline 项。 ## 5. Canonical Data Sources @@ -99,14 +106,14 @@ ## 10. 依赖检查命令 -分层红线与 import 循环由 `scripts/tools/dependency_boundaries.mjs` 机器化把关(零依赖,复用 `@/* -> src/*` alias): +分层红线、`app` feature deep import surface 与 import 循环由 `scripts/tools/dependency_boundaries.mjs` 机器化把关(零依赖,复用 `@/* -> src/*` alias): ```bash -npm run deps:audit # 报告越层 import 与循环依赖 -npm run deps:check # CI 阻断门(存量循环走 dependency_boundaries_baseline.json grandfather,仅挡净新增) +npm run deps:audit # 报告越层 import、app feature deep import 与循环依赖 +npm run deps:check # CI 阻断门(cycles/deep imports 走 dependency_boundaries_baseline.json grandfather,仅挡净新增与 stale baseline) ``` -该脚本编码 §1 的方向(core 禁 React/越层、features 禁互相 import、shared/store/lib 禁向上),§3 的存量例外与 editor->urdf-viewer facade、lib RobotCanvas 包装关系已 allowlist。`npm run lint` 已串联 `deps:check`。下列 `rg` 命令仅作快速人工排查备用: +该脚本编码 §1 的方向(core 禁 React/越层、features 禁互相 import、shared/store/lib 禁向上),§3 的存量例外只按精确 importer/specifier/target allowlist。`app` 对 feature 子路径的存量 deep import 单独报告为 `knownFeatureDeepImports`,新增或 baseline stale 都会让 `--check` 失败;import cycles 维持既有 grandfather 机制。`npm run lint` 已串联 `deps:check`。下列 `rg` 命令仅作快速人工排查备用: ```bash # 检查潜在反向依赖(core/shared/store 对 features 的引用) @@ -134,4 +141,3 @@ rg -n "#0088FF|#0088ff" src | rg -v "Slider.tsx|styles/index.css" - **手写 C-ABI emscripten 源**:`src/core/loaders/wasm/collada_mesh_parser.cpp`、`src/core/loaders/wasm/obj_parser.cpp`。它们是**单翻译单元(single TU)**设计——单 `.cpp` + `-flto` + 匿名 `namespace` 内部链接,所有 helper 文件本地。拆成多 TU/头文件**运行时零收益、只增 header 边界摩擦**,故有意保留单文件;要可读性用 section banner 注释而非物理拆分。注意:构建是 **C-ABI `EXPORTED_FUNCTIONS`** 模式(手动 `HEAPU8` marshalling via `*_get_result_ptr` / `*_get_result_size`),**不是 embind**(无 `emscripten/bind.h` / `EMSCRIPTEN_BINDINGS` / `--bind`)。.cpp 风格由 `.clang-format` 固定。 - **生成产物**:`public/wasm/**`(emscripten JS glue + `.wasm` 二进制,由 `scripts/build/rebuild-*-wasm.sh` 生成,**勿手改**,改 `.cpp` 重跑脚本)、`**/*.generated.*`(ESLint 与 audit 一致跳过)。 - **vendored 源**:`third_party/**`(魔改版 OpenUSD)、`src/features/urdf-viewer/runtime/**`(USD WASM runtime)。 - diff --git a/docs/testing-rebuild-handoff.md b/docs/testing-rebuild-handoff.md index a2d2aefd5..68b61455b 100644 --- a/docs/testing-rebuild-handoff.md +++ b/docs/testing-rebuild-handoff.md @@ -3,6 +3,7 @@ > 交接人:Claude。本文档自包含,Codex 可据此直接接手完成剩余工作。 > 目标:让 URDF Studio 的三层测试(尤其是浏览器 E2E 层)真正"一口气全跑通"。 > 语言:中文沟通,代码/路径/命令保持原文。遵守根仓库 `CLAUDE.md` 全部红线。 +> 主入口:[CLAUDE.md](../CLAUDE.md) §测试分层与 AI 选命令规则;面向日常协作者的测试地图见 [testing.md](testing.md)。 --- @@ -43,16 +44,19 @@ ## 2. 已完成的修改(已落盘,勿重复) ### 2.1 修 `npm test` + - `scripts/test/runner/run-node-tests.mjs`:`REPO_ROOT` 由 `../..` 改 `../../..`;usage 文本里的旧路径 `scripts/test/...` 改为 `scripts/test/runner/...`。 - 验证:`npm test` 通过(fast lane 36 测试绿);`--list` 显示 `src` suite 正确解析 538 文件。 ### 2.2 重建地基(新增文件) + - `scripts/test/helpers/browser-helpers.mjs`:导出 `ensureSite / launchBrowser / createPage / uploadFile / uploadDirectory / collectFiles / writeJsonAtomic / ensureDir / triggerRobotLoad / stabilizeDebugPage / isTransientPageContextError / DEFAULT_SITE_URL / DEFAULT_OPERATION_TIMEOUT_MS`。实现整合自 committed 的 `run_menagerie_browser_regression.mjs`、`run_unitree_browser_regression.mjs`、`run_shadow_hand_hover_regression.mjs`。 - `scripts/test/helpers/assertions.mjs`:`createTestSuite / assert / assertEqual / assertGreaterThan / assertNonNull / printSummary`(零依赖)。 - `scripts/test/setup/_clone-util.mjs` + `clone_{all_test_data,mujoco_menagerie,unitree_model,unitree_ros}.mjs`:幂等 git clone(目录已存在则跳过)。 - 验证:全部 format-helper 的 import 链可解析、导出齐全。 ### 2.3 修加载/就绪路径(改 committed 文件) + - `scripts/test/browser/helpers/base-helpers.mjs`: - 引入 `isTransientPageContextError`;`createSession` 支持 `URDF_E2E_HEADED=1` 环境变量(供 run-all `--headed`)。 - `waitForReady`:默认超时 120s;**runtime 已建+有 link 即就绪**(或 status ready/hydrating);status 为 error 抛出含原因;瞬时导航错误重试;超时打印 last state。 @@ -60,11 +64,13 @@ - 4 个格式 helper(`urdf/mjcf/sdf/xacro-helpers.mjs`)的 `importModel`:在等到 `selectedFile.name===fileName` 后追加 `await triggerRobotLoad(page, fileName, timeoutMs)`。 ### 2.4 统一入口(新增) + - `scripts/test/runner/run-all.mjs`:L1 单元 → L2 浏览器 →(可选 `--fixtures`)L3。**失败不中断**,自动从 package.json 发现 `test:browser:*`,**先起一个共享 dev server**(各测试 `ensureSite` 复用、不再各自冷启动 Vite),结尾跑 `cleanup-headless.cjs`,输出汇总表 + `tmp/regression/run-all-summary.json`。支持 `--unit-only/--browser-only/--skip-*/--fixtures/--headed/--filter/--list`。 - `package.json`:新增 `"test:all": "node scripts/test/runner/run-all.mjs"`。 - 验证:`--list` 正确;`--browser-only --filter import` 实跑时共享 server 成功启动并被复用。 ### 2.5 文档(新增) + - `docs/testing.md`:面向非专业用户的测试金字塔指南(三层、命令、新功能往哪加测试、如何读结果)。 --- @@ -74,10 +80,12 @@ > 每跑完浏览器自动化,必须 `node test/usd-viewer/scripts/cleanup-headless.cjs`(CLAUDE.md 红线)。Vite 冷启动在本机较慢(单个浏览器测试约 1.5–3 分钟);强烈建议用 `run-all` 起共享 server 批量跑。 ### T1. package.json 脚本入口统一(已完成) + - 浏览器、E2E、fixture、runner 入口统一指向 `scripts/test/**`。 - `test:browser:all` / `test:all` 由 `scripts/test/runner/run-all.mjs` 自动发现脚本,不再维护手写串联。 ### T2.(高优先)修目录上传路径:URDF + SDF 导入超时 + - **现象(实测)**:`test:browser:sdf-import`(demo_joint_friction、r2_description)与 `test:browser:urdf-import`(a1_description…)**都**在 `importModel` 阶段超时——等 `selectedFile.name === ` 60s 不满足。而 MJCF(theme 测试)能过。 - **根因(强烈怀疑,已具备充分证据)**:`urdf-helpers` / `sdf-helpers` 的 `importModel` 走的是 `uploadDirectory`(puppeteer 往 `webkitdirectory` input `uploadFile(...files)` 原始多文件上传)。**puppeteer 这条路不会设置 `File.webkitRelativePath`**,app 的目录导入逻辑因此拿不到包内相对路径,无法正确识别主文件 / 命名 `selectedFile`。 - 对照:能跑的 **`mjcf-helpers`** 和 committed 的 **`run_unitree_browser_regression.mjs`(`createBundleZip`)走的是"把目录打包成 zip → 上传单个 zip"**,不是原始目录上传。MJCF 能过正因如此。 @@ -88,16 +96,19 @@ - 用临时 diag 脚本取证(仿下述模板)打印真实 `getAvailableFiles()` 与 `selectedFile?.name`:上传后 `page.evaluate(() => window.__URDF_STUDIO_DEBUG__.getAvailableFiles().map(f=>f.name))`。 ### T3. 逐个对齐浏览器测试断言(主体工作量,Phase C) + - 这些 `test_*.mjs` 从未真正跑过,预期会有"断言和真实 app 对不上"的小问题。已知例:`test_theme_switching.mjs` 的 assert 1「theme detectable from DOM」失败——它检查 `documentElement` 上的 `dark/light` class 或 `data-theme`,但 app 实际的主题表示方式不同(需查 `uiStore`/主题应用逻辑确认真实信号,再改断言)。 - 执行方式(推荐):用 `node scripts/test/runner/run-all.mjs --browser-only` 一次性跑全部,拿到 `tmp/regression/run-all-summary.json` 的失败清单;然后**逐个**:读对应 `test_*.mjs` → 用 `--filter ` 单跑 → 据失败信息查 app 真实行为(`src/` + `window.__URDF_STUDIO_DEBUG__` 暴露的接口见 `src/shared/debug/regressionBridge.ts`)→ 修断言或修 helper → 重跑至绿。 - 原则:断言要对齐 app 的**真实**行为,不要为了变绿而把断言改空。改不动/疑似 app bug 的,在测试里标注并汇报,不要静默跳过。 -- 主要 `test_*.mjs` 包括 ai_assistant, assembly_export, collision_optimization, cross_format_assembly, drag_drop_snapshot, hardware_config, ik_drag, language_switching, measure_tool, mjcf_export, mujoco_*, multi_format_import, paint_mode, sdf_model_import, theme_switching, urdf_*, usd_model_import, xacro_import。 +- 主要 `test_*.mjs` 包括 `ai_assistant`、`assembly_export`、`collision_optimization`、`cross_format_assembly`、`drag_drop_snapshot`、`hardware_config`、`ik_drag`、`language_switching`、`measure_tool`、`mjcf_export`、`mujoco_*`、`multi_format_import`、`paint_mode`、`sdf_model_import`、`theme_switching`、`urdf_*`、`usd_model_import`、`xacro_import`。 ### T4. MJCF / E2E 入口(已补齐) + - `test:browser:mujoco-*` 覆盖已位于 `scripts/test/browser/test_mujoco_*.mjs`,由 `package.json` 暴露为独立 npm scripts。 - E2E 入口已位于 `scripts/test/e2e/test_*.mjs`(assembly_bridge / import_export / editor_operations)。 ### T5. 收尾验证 + - `npm test`(L1 仍绿)。 - `node scripts/test/runner/run-all.mjs --browser-only`(L2 全绿;阅读汇总表)。 - `npm run lint && npm run typecheck:quality`;`npm run typecheck` 是含 test/spec 的全仓 TypeScript 债务检查。 @@ -120,6 +131,7 @@ --- ## 5. 验收标准(Done 的定义) + 1. `npm test` 绿;`npm run test:all --browser-only` 汇总表中浏览器测试全绿(或对无法修复项有明确标注与说明)。 2. package.json 无悬空脚本引用。 3. `lint` + `typecheck:quality` 通过;全仓 test/spec 类型债务若未清零,需要在交接中单独标注。 diff --git a/docs/testing.md b/docs/testing.md index 90a48d083..857644800 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,7 +1,8 @@ # 测试指南(Testing Guide) > 面向所有协作者(含非专业用户)的测试地图:测什么、怎么一键跑、新功能往哪加测试。 -> 配套:[update-rules.md](update-rules.md)(验收清单)、[CATALOG.md](CATALOG.md)(文档索引)。 +> 主入口:[CLAUDE.md](../CLAUDE.md) §测试分层与 AI 选命令规则。 +> 配套:[update-rules.md](update-rules.md)(验收清单)、[testing-rebuild-handoff.md](testing-rebuild-handoff.md)(浏览器测试体系修复交接)、[CATALOG.md](CATALOG.md)(文档索引)。 URDF Studio 采用大厂常见的**测试金字塔**分层:底层多而快、顶层少而慢。理解这三层,就知道任何改动该跑哪个命令、该补哪种测试。 @@ -22,12 +23,12 @@ URDF Studio 采用大厂常见的**测试金字塔**分层:底层多而快、 - **放在哪**:紧挨源码,`src/**/*.test.ts`(约 538 个)。新写的工具/纯逻辑就在它旁边建 `xxx.test.ts`。 - **怎么跑**: -| 目的 | 命令 | -|------|------| -| 默认快速冒烟(CI 用) | `npm test` | -| 跑全部单元测试 | `npm run test:unit:all` | -| 只跑某个文件 | `npm run test:unit -- src/core/robot/builders.test.ts` | -| 看有哪些 suite / 各有多少文件 | `npm run test:unit:list` | +| 目的 | 命令 | +| ----------------------------- | ------------------------------------------------------ | +| 默认快速冒烟(CI 用) | `npm test` | +| 跑全部单元测试 | `npm run test:unit:all` | +| 只跑某个文件 | `npm run test:unit -- src/core/robot/builders.test.ts` | +| 看有哪些 suite / 各有多少文件 | `npm run test:unit:list` | - runner:`scripts/test/runner/run-node-tests.mjs`(管理 fast / src / all 等 suite)。 @@ -40,11 +41,11 @@ URDF Studio 采用大厂常见的**测试金字塔**分层:底层多而快、 - `scripts/test/browser/helpers/<格式>-helpers.mjs` —— 各格式(urdf/mjcf/sdf/usd/xacro)的 `importModel`,封装"上传 + 选中 + 加载"。 - **怎么跑**: -| 目的 | 命令 | -|------|------| -| 单个功能 | `npm run test:browser:theme`(或 `:mujoco-import` / `:measure` / `:collision-opt` / `:urdf-import` …) | -| 全部浏览器测试 | `npm run test:browser:all`(内部调用 `run-all --browser-only`,自动发现全部 `test:browser:*`) | -| 看有哪些 | `node scripts/test/runner/run-all.mjs --list` | +| 目的 | 命令 | +| -------------- | ------------------------------------------------------------------------------------------------------ | +| 单个功能 | `npm run test:browser:theme`(或 `:mujoco-import` / `:measure` / `:collision-opt` / `:urdf-import` …) | +| 全部浏览器测试 | `npm run test:browser:all`(内部调用 `run-all --browser-only`,自动发现全部 `test:browser:*`) | +| 看有哪些 | `node scripts/test/runner/run-all.mjs --list` | - **关键机制**:测试通过 URL 上的 `?regressionDebug=1` 暴露 `window.__URDF_STUDIO_DEBUG__` 调试接口(默认关闭,见 CLAUDE.md 红线)。helper 会自动加这个参数。 - **跑完务必清理**(CLAUDE.md 红线):`node test/usd-viewer/scripts/cleanup-headless.cjs`。 diff --git a/docs/update-rules.md b/docs/update-rules.md index 87c8c6eb8..8773aee25 100644 --- a/docs/update-rules.md +++ b/docs/update-rules.md @@ -1,7 +1,7 @@ # 代码变更 → 文档更新 & 验证映射 > 最后更新:2026-05-26 | 覆盖范围:变更工作流、验证命令、测试样本索引 -> 交叉引用:[architecture.md](architecture.md)、[viewer.md](viewer.md)、[file-io.md](file-io.md) +> 交叉引用:[architecture.md](architecture.md)、[viewer.md](viewer.md)、[file-io.md](file-io.md)、[testing.md](testing.md) ## 1. 代码变更工作流 @@ -18,6 +18,7 @@ 允许单文件:仅限小改动(文案、样式微调、局部 bug 修复)且不引入新职责。 必须拆分的场景: + - 同时引入"状态 + 视图 + 业务逻辑" - 新增可复用逻辑(优先抽为 hook/utils) - 文件已明显过大且继续修改会降低可维护性 @@ -43,6 +44,8 @@ 基础命令(dev / lint / typecheck:quality / typecheck 债务检查 / test / build / verify:fast / verify:full)见 [CLAUDE.md](../CLAUDE.md) §常用命令。本节只列项目特有的回归与构建命令: +测试分层、日常命令选择和新增测试落点见 [testing.md](testing.md)。浏览器测试体系修复交接见 [testing-rebuild-handoff.md](testing-rebuild-handoff.md)。 + ```bash # AI / agent 优先入口 npm run test:unit:list @@ -80,41 +83,41 @@ npm run build:package:react-robot-canvas ### USD / worker / roundtrip 主样本(`test/unitree_model/`) -| 样本 | 用途 | -|------|------| -| `Go2/usd/go2.usd` | 四足基准:USD stage open、worker metadata、hydration | -| `Go2W/usd/go2w.usd` | 轮足变体:资产命名差异与 roundtrip 稳定性 | -| `B2/usd/b2.usd` | 更大体量四足:folded fixed link、复杂结构 | -| `H1-2/h1_2/h1_2.usd` | Humanoid:双足/人形链路与 viewer hydration | -| `H1-2/h1_2_handless/h1_2_handless.usd` | Handless 变体:资产差异下的 runtime 行为 | -| `*.viewer_roundtrip.usd` | 导出后 diff、回归对照与 roundtrip 验证 | +| 样本 | 用途 | +| -------------------------------------- | ---------------------------------------------------- | +| `Go2/usd/go2.usd` | 四足基准:USD stage open、worker metadata、hydration | +| `Go2W/usd/go2w.usd` | 轮足变体:资产命名差异与 roundtrip 稳定性 | +| `B2/usd/b2.usd` | 更大体量四足:folded fixed link、复杂结构 | +| `H1-2/h1_2/h1_2.usd` | Humanoid:双足/人形链路与 viewer hydration | +| `H1-2/h1_2_handless/h1_2_handless.usd` | Handless 变体:资产差异下的 runtime 行为 | +| `*.viewer_roundtrip.usd` | 导出后 diff、回归对照与 roundtrip 验证 | ### SDF / Gazebo 样本(`test/gazebo_models/`) -| 样本 | 用途 | -|------|------| -| `camera/model.sdf` | 轻量 smoke | -| `cordless_drill/model.sdf` | DAE + STL + texture 混合 | -| `bus_stop/model.sdf` | 多 mesh + 贴图 + 混合格式 | -| `apartment/model.sdf` | 大场景:纹理 + viewer 性能 | -| `camera/model-1_2.sdf` 等 | 版本化 SDF 兼容性 | +| 样本 | 用途 | +| -------------------------- | -------------------------- | +| `camera/model.sdf` | 轻量 smoke | +| `cordless_drill/model.sdf` | DAE + STL + texture 混合 | +| `bus_stop/model.sdf` | 多 mesh + 贴图 + 混合格式 | +| `apartment/model.sdf` | 大场景:纹理 + viewer 性能 | +| `camera/model-1_2.sdf` 等 | 版本化 SDF 兼容性 | ### URDF 样本(`test/awesome_robot_descriptions_repos/`) -| 样本 | 用途 | -|------|------| -| `anymal_c_simple_description/urdf/anymal.urdf` | 纹理 + DAE 完整四足 | -| `mini_cheetah_urdf/urdf/mini_cheetah.urdf` | OBJ/STL 混合资产 | -| `cassie_description/urdf/cassie_v4.urdf` | 双足复杂关节层级 | -| `fanuc_m760ic_description/urdf/m710ic70.urdf` | 工业机械臂 | +| 样本 | 用途 | +| ---------------------------------------------------- | --------------------- | +| `anymal_c_simple_description/urdf/anymal.urdf` | 纹理 + DAE 完整四足 | +| `mini_cheetah_urdf/urdf/mini_cheetah.urdf` | OBJ/STL 混合资产 | +| `cassie_description/urdf/cassie_v4.urdf` | 双足复杂关节层级 | +| `fanuc_m760ic_description/urdf/m710ic70.urdf` | 工业机械臂 | | `models/franka_description/urdf/panda_arm_hand.urdf` | gltf + ktx2 + png/bin | ### MJCF 样本(`test/awesome_robot_descriptions_repos/mujoco_menagerie/`) -| 样本 | 用途 | -|------|------| -| `unitree_go2/go2.xml` | 标准 MuJoCo menagerie 样本 | -| `unitree_go2/scene.xml` | 带 scene 包装的 MJCF | +| 样本 | 用途 | +| ----------------------- | -------------------------- | +| `unitree_go2/go2.xml` | 标准 MuJoCo menagerie 样本 | +| `unitree_go2/scene.xml` | 带 scene 包装的 MJCF | ### 样本选择建议 @@ -137,12 +140,14 @@ npm run build:package:react-robot-canvas ## 7. 文档更新映射 -| 变更范围 | 应更新文档 | -|----------|-----------| -| 新增 feature / 拆分 feature 目录 | `CLAUDE.md` §src 目录结构、`architecture.md` §4 | -| 新增 store | `CLAUDE.md` §状态管理、`architecture.md` | -| 修改 USD worker / runtime 链路 | `viewer.md` §6-7、`update-rules.md` §5 | -| 修改导入导出流程 | `file-io.md` §2 | -| 修改 UI 样式 / 新增语义色 token | `style-guide.md` §3 | -| 新增架构例外 | `architecture.md` §3 | -| 新增长期稳定测试样本 | `update-rules.md` §5 | +| 变更范围 | 应更新文档 | +| -------------------------------- | --------------------------------------------------- | +| 新增 feature / 拆分 feature 目录 | `CLAUDE.md` §src 目录结构、`architecture.md` §4 | +| 新增 store | `CLAUDE.md` §状态管理、`architecture.md` | +| 修改 USD worker / runtime 链路 | `viewer.md` §6-7、`update-rules.md` §5 | +| 修改导入导出流程 | `file-io.md` §2 | +| 修改 UI 样式 / 新增语义色 token | `style-guide.md` §3 | +| 新增架构例外 | `architecture.md` §3 | +| 新增长期稳定测试样本 | `update-rules.md` §5 | +| 新增测试入口 / 调整测试分层 | `testing.md`、`CLAUDE.md` §测试分层与 AI 选命令规则 | +| 浏览器测试体系修复交接状态变化 | `testing-rebuild-handoff.md`、`testing.md` | diff --git a/index.html b/index.html index 4ceecd354..83e2e680f 100644 --- a/index.html +++ b/index.html @@ -3,35 +3,75 @@ - - - URDF Studio - Professional Robot Design & Visualization Platform | 机器人可视化设计平台 - - - - - - - - + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + URDF Studio — URDF, MJCF, USD & SDF Robot Model Editor + + + + + - + + + +
-
-
+ + + +
diff --git a/package.json b/package.json index 8c9be122f..7bf35c410 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "inspection-criteria:generate": "node scripts/generate/generate_inspection_criteria.mjs", "ai-prompts:generate": "node scripts/generate/generate_ai_prompt_templates.mjs && node scripts/generate/generate_inspection_criteria.mjs", "dev": "vite", - "build": "npm run generate:check && npm run build:app", + "build": "npm run generate:check && npm run build:app && npm run seo:prerender", + "seo:prerender": "node scripts/generate/seo_prerender.mjs", "lint": "npm run lint:eslint && npm run lint:style && npm run lint:google-style && npm run deps:check", "lint:eslint": "eslint . --cache --max-warnings=0", "lint:style": "stylelint \"src/**/*.css\"", @@ -108,6 +109,7 @@ "test:browser:urdf-assembly": "node scripts/test/browser/test_urdf_assembly.mjs", "test:browser:cross-format-assembly": "node scripts/test/browser/test_cross_format_assembly.mjs", "test:browser:assembly-export": "node scripts/test/browser/test_assembly_export.mjs", + "test:browser:joint-pick": "node scripts/test/browser/test_joint_pick.mjs", "test:browser:measure": "node scripts/test/browser/test_measure_tool.mjs", "test:browser:ik-drag": "node scripts/test/browser/test_ik_drag.mjs", "test:browser:collision-opt": "node scripts/test/browser/test_collision_optimization.mjs", diff --git a/public/_headers b/public/_headers index 248e9863d..4f610cdc4 100644 --- a/public/_headers +++ b/public/_headers @@ -2,6 +2,8 @@ Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Resource-Policy: same-site + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin /index.html Cache-Control: no-cache diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..5d58fdd35 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "URDF Studio", + "short_name": "URDF Studio", + "description": "Free in-browser editor and viewer for robot models: URDF, MJCF, USD, SDF and Xacro.", + "lang": "en", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f5f7fb", + "theme_color": "#2563eb", + "icons": [ + { + "src": "/logos/logo.png", + "sizes": "256x256", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/public/robots.txt b/public/robots.txt index 71110b46c..de276fbc2 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://urdf.d-robotics.cc/sitemap.xml +Sitemap: https://urdf.enkeebot.com/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml index 4439882a6..358466694 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,9 +2,21 @@ - https://urdf.d-robotics.cc/ - 2026-03-19 + https://urdf.enkeebot.com/ + + + + 2026-06-19 weekly 1.0 + + https://urdf.enkeebot.com/zh/ + + + + 2026-06-19 + weekly + 0.9 + diff --git a/scripts/generate/seo_prerender.mjs b/scripts/generate/seo_prerender.mjs new file mode 100644 index 000000000..09842cd3e --- /dev/null +++ b/scripts/generate/seo_prerender.mjs @@ -0,0 +1,228 @@ +/** + * SEO pre-render (post-build step). + * + * URDF Studio is a client-rendered WebGL/WASM editor, so true SSR is not viable. + * Instead this runs after `vite build` and turns the built `dist/index.html` into + * crawlable, per-language static pages without a headless browser: + * - rewrites the per-language head region (title/description/canonical/og/twitter/JSON-LD) + * - emits a Chinese variant at `dist/zh/index.html` (``, canonical /zh/) + * - regenerates `dist/sitemap.xml` with both URLs + hreflang alternates + * + * Language-specific regions in `index.html` are delimited by `` and + * `` markers. Missing markers throw rather than silently emitting a + * half-rendered page. The SEO content is hidden from users and replaced by React on mount. + * Asset URLs stay absolute (Vite base `/`), so `/zh/` needs no rewrite. + * + * Usage: node scripts/generate/seo_prerender.mjs (wired into `npm run build`) + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SITE = 'https://urdf.enkeebot.com'; +const LOGO = `${SITE}/logos/logo.png`; +const GITHUB = 'https://github.com/enkeebot/URDF-Studio'; + +const seoContent = { + en: { + ogLocale: 'en_US', + title: 'URDF Studio — URDF, MJCF, USD & SDF Robot Model Editor', + description: + 'Free in-browser editor and viewer for robot models: URDF, MJCF, USD, SDF and Xacro. ' + + 'Edit kinematics, optimize collisions, assemble modules and convert formats.', + url: `${SITE}/`, + inLanguage: ['en', 'zh'], + featureList: [ + 'URDF / MJCF / SDF / USD / Xacro import and export', + 'Collision geometry optimization', + 'Multi-robot modular assembly with bridge joints', + 'Hardware and motor configuration', + 'AI generation and review', + 'PDF and CSV reports', + ], + hero: { + tagline: 'Professional online editor & visualizer for robot models', + sub: + 'Import, edit, visualize and convert URDF, MJCF, USD, SDF and Xacro robots — ' + + 'collision optimization, modular assembly and AI review, all in your browser.', + formatsLabel: 'Supported formats', + noscript: 'URDF Studio needs JavaScript enabled to run the interactive editor.', + }, + }, + zh: { + ogLocale: 'zh_CN', + title: 'URDF Studio — 在线机器人模型编辑与可视化(URDF·MJCF·USD·SDF)', + description: + '免费的浏览器端机器人模型编辑与可视化工作站,支持 URDF、MJCF、USD、SDF、Xacro 的导入、编辑与转换,' + + '提供运动学编辑、碰撞优化、模块组装与 AI 审阅。', + url: `${SITE}/zh/`, + inLanguage: ['zh', 'en'], + featureList: [ + 'URDF / MJCF / SDF / USD / Xacro 导入与导出', + '碰撞几何优化', + '多机器人模块化组装与桥接关节', + '硬件与电机配置', + 'AI 生成与审阅', + 'PDF 与 CSV 报告', + ], + hero: { + tagline: '专业的在线机器人模型编辑与可视化工具', + sub: + '在浏览器中导入、编辑、可视化与转换 URDF、MJCF、USD、SDF、Xacro 机器人模型,' + + '支持碰撞优化、模块组装与 AI 审阅。', + formatsLabel: '支持的格式', + noscript: '运行 URDF Studio 交互式编辑器需要启用 JavaScript。', + }, + }, +}; + +function escHtml(value) { + return String(value).replace(/&/g, '&').replace(//g, '>'); +} + +function escAttr(value) { + return escHtml(value).replace(/"/g, '"'); +} + +function renderHead(lang) { + const c = seoContent[lang]; + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'URDF Studio', + url: c.url, + applicationCategory: 'DesignApplication', + operatingSystem: 'Web browser', + description: c.description, + image: LOGO, + inLanguage: c.inLanguage, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + featureList: c.featureList, + author: { '@type': 'Organization', name: 'enkeebot', url: GITHUB }, + }; + + return [ + ` ${escHtml(c.title)}`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ].join('\n'); +} + +function renderContent(lang) { + const c = seoContent[lang]; + return [ + ` `, + ` `, + ].join('\n'); +} + +function replaceRegion(html, name, inner) { + const region = new RegExp(`()[\\s\\S]*?()`); + if (!region.test(html)) { + throw new Error(`[seo_prerender] marker ${name} not found in built HTML — did the index.html layout change?`); + } + return html.replace(region, `$1\n${inner}\n $2`); +} + +function renderSitemap(lastmod) { + const alternates = [ + ` `, + ` `, + ` `, + ].join('\n'); + + const entries = [ + { loc: `${SITE}/`, priority: '1.0' }, + { loc: `${SITE}/zh/`, priority: '0.9' }, + ] + .map((entry) => + [ + ' ', + ` ${entry.loc}`, + alternates, + ` ${lastmod}`, + ' weekly', + ` ${entry.priority}`, + ' ', + ].join('\n'), + ) + .join('\n'); + + return ( + '\n' + + '\n' + + `${entries}\n` + + '\n' + ); +} + +function resolveLastmod(repoRoot) { + try { + const committed = execSync('git log -1 --format=%cs', { cwd: repoRoot, encoding: 'utf8' }).trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(committed)) { + return committed; + } + } catch { + // Fall back to the current date when git history is unavailable. + } + return new Date().toISOString().slice(0, 10); +} + +function main() { + const here = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(here, '../..'); + const distDir = path.join(repoRoot, 'dist'); + const indexPath = path.join(distDir, 'index.html'); + + if (!existsSync(indexPath)) { + throw new Error(`[seo_prerender] ${indexPath} not found — run "vite build" first.`); + } + + let enHtml = readFileSync(indexPath, 'utf8'); + enHtml = replaceRegion(enHtml, 'SEO:HEAD', renderHead('en')); + enHtml = replaceRegion(enHtml, 'SEO:CONTENT', renderContent('en')); + writeFileSync(indexPath, enHtml); + + let zhHtml = enHtml.replace('', ''); + if (!zhHtml.includes('lang="zh-CN"')) { + throw new Error('[seo_prerender] failed to set zh-CN lang attribute — did the tag change?'); + } + zhHtml = replaceRegion(zhHtml, 'SEO:HEAD', renderHead('zh')); + zhHtml = replaceRegion(zhHtml, 'SEO:CONTENT', renderContent('zh')); + mkdirSync(path.join(distDir, 'zh'), { recursive: true }); + writeFileSync(path.join(distDir, 'zh', 'index.html'), zhHtml); + + const lastmod = resolveLastmod(repoRoot); + writeFileSync(path.join(distDir, 'sitemap.xml'), renderSitemap(lastmod)); + + console.log( + `[seo_prerender] wrote dist/index.html (en), dist/zh/index.html (zh), dist/sitemap.xml (lastmod ${lastmod})`, + ); +} + +main(); diff --git a/scripts/test/browser/test_joint_pick.mjs b/scripts/test/browser/test_joint_pick.mjs new file mode 100644 index 000000000..c1c7ef885 --- /dev/null +++ b/scripts/test/browser/test_joint_pick.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +/** + * Joint-origin Pick browser regression test (Fusion 360 style "Joint"). + * + * Drives the real interactive flow that the debug-API assembly tests skip: + * open the bridge modal, set the relation by clicking links in the canvas, + * activate "Pick parent"/"Pick child", click the components in the 3D view, and + * confirm. Verifies the raycast -> snap resolve -> commit -> auto-align pipeline + * end to end in a real browser. + */ + +import { setTimeout as delay } from 'node:timers/promises'; +import { + createSession, createTestSuite, assert, assertEqual, assertGreaterThan, + waitForReady, getAssemblyState, findAvailableFile, getProjectedInteractionTargets, + clickCanvasTarget, store, writeReport, printSummary, +} from './helpers/base-helpers.mjs'; +import { importModel as importUrdf } from './helpers/urdf-helpers.mjs'; + +const PICK_PARENT = ['Pick parent', '拾取父侧']; +const PICK_CHILD = ['Pick child', '拾取子侧']; +const PARENT_DONE = ['Parent ✓', '父侧 ✓']; +const CHILD_DONE = ['Child ✓', '子侧 ✓']; +const CONFIRM = ['Confirm', '确认']; +const CREATE_BRIDGE = ['Create Bridge', '创建拼接']; + +async function selectionOf(page) { + return page.evaluate(() => { + const snap = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.(); + return snap?.interaction?.selection ?? null; + }); +} + +// Click an empty canvas spot to clear any pre-existing selection so the bridge +// modal's selection-sync does not swallow the first relation pick. +async function clearViewerSelection(page) { + const rect = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) return null; + const r = canvas.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + if (!rect) return; + for (const [fx, fy] of [[0.06, 0.94], [0.94, 0.06], [0.06, 0.06]]) { + await page.mouse.click(rect.x + rect.w * fx, rect.y + rect.h * fy); + await delay(250); + const sel = await selectionOf(page); + if (!sel || !sel.id) return; + } +} + +async function clickByTitle(page, titles) { + return page.evaluate((wanted) => { + for (const title of wanted) { + const button = document.querySelector(`button[title="${title}"]`); + if (button instanceof HTMLElement) { button.click(); return true; } + } + return false; + }, titles); +} + +async function findButton(page, texts) { + return page.evaluate((wanted) => { + const button = Array.from(document.querySelectorAll('button')).find( + (element) => wanted.includes(element.textContent?.trim()), + ); + if (!button) return { exists: false, disabled: false }; + return { exists: true, disabled: button.disabled }; + }, texts); +} + +async function clickButton(page, texts) { + return page.evaluate((wanted) => { + const button = Array.from(document.querySelectorAll('button')).find( + (element) => wanted.includes(element.textContent?.trim()) && !element.disabled, + ); + if (button instanceof HTMLElement) { button.click(); return true; } + return false; + }, texts); +} + +async function waitForButton(page, texts, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await findButton(page, texts)).exists) return true; + await delay(150); + } + return false; +} + +// Attribute a link target to a component by LONGEST component-id prefix, so a +// component id that is a prefix of another (comp_a1 vs comp_a1_1) does not +// swallow the other component's links. +function ownsTarget(targetId, componentId, allIds) { + if (typeof targetId !== 'string' || !targetId.startsWith(`${componentId}_`)) return false; + return !allIds.some( + (other) => + other !== componentId && other.length > componentId.length && targetId.startsWith(`${other}_`), + ); +} + +async function targetsForComponent(page, componentId, allIds, modalSafeMaxX) { + const targets = await getProjectedInteractionTargets(page, { type: 'link' }); + return targets.filter( + (target) => ownsTarget(target.id, componentId, allIds) && Number(target.clientX) < modalSafeMaxX, + ); +} + +async function targetById(page, linkId) { + return (await getProjectedInteractionTargets(page, { type: 'link' })).find((t) => t.id === linkId) ?? null; +} + +// Wait until a component has projected at least `minCount` pickable link targets, +// so we never sample mid-(re)projection after moving/loading it. +async function waitForComponentTargets(page, componentId, allIds, modalSafeMaxX, minCount, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + let last = 0; + while (Date.now() < deadline) { + last = (await targetsForComponent(page, componentId, allIds, modalSafeMaxX)).length; + if (last >= minCount) return last; + await delay(300); + } + return last; +} + +async function main() { + const suite = createTestSuite('Joint Pick'); + const session = await createSession(); + const { page } = session; + const viewportWidth = await page.evaluate(() => window.innerWidth); + // Keep picks clear of the top-right bridge modal (~600px wide). + const modalSafeMaxX = viewportWidth - 640; + + try { + // ── Two-component assembly (same robot twice, child shifted aside) ── + await importUrdf(page, 'a1_description', 'a1.urdf'); + await waitForReady(page); + + await store.initAssembly(page, 'joint_pick_asm'); await delay(300); + const file = await findAvailableFile(page, 'a1.urdf'); + const compA = await store.addComponent(page, file); await delay(500); + const compB = await store.addComponent(page, file); await delay(800); + assert(suite, compA.ok && compB.ok, 'two components added'); + assertEqual(suite, (await getAssemblyState(page)).componentCount, 2, '2 components'); + const allIds = [compA.id, compB.id]; + + // Separate the child sideways so both render fully (no occlusion) and stay + // left of the top-right modal. + await store.updateComponentTransform(page, compB.id, { + position: { x: -1.5, y: 0, z: 0 }, rotation: { r: 0, p: 0, y: 0 }, + }); + await delay(1000); + + // ── Clear selection, then open the bridge modal ── + await clearViewerSelection(page); + assert(suite, await clickByTitle(page, CREATE_BRIDGE), 'create-bridge button clicked'); + assert(suite, await waitForButton(page, PICK_PARENT, 10000), 'bridge modal open with pick UI'); + assert(suite, (await findButton(page, PICK_PARENT)).disabled, 'pick disabled before relation'); + + // ── Set the relation by clicking each component's link in the canvas ── + // Wait for both components to finish projecting so target sampling is stable. + await waitForComponentTargets(page, compA.id, allIds, modalSafeMaxX, 6); + await waitForComponentTargets(page, compB.id, allIds, modalSafeMaxX, 6); + const parentTargets = await targetsForComponent(page, compA.id, allIds, modalSafeMaxX); + const childTargets = await targetsForComponent(page, compB.id, allIds, modalSafeMaxX); + assertGreaterThan(suite, parentTargets.length, 0, 'parent link targets projected'); + assertGreaterThan(suite, childTargets.length, 0, 'child link targets projected'); + const parentTarget = parentTargets[0]; + const childTarget = childTargets[0]; + + // Capture the link each relation click actually selected; the joint pick must + // land on that same link (the layer validates both component AND link). + await clickCanvasTarget(page, parentTarget); await delay(700); + const relParentLinkId = (await selectionOf(page))?.id; + await clickCanvasTarget(page, childTarget); await delay(800); + const relChildLinkId = (await selectionOf(page))?.id; + + const enabled = (await waitForButton(page, PICK_PARENT, 4000)) + && !(await findButton(page, PICK_PARENT)).disabled; + assert(suite, enabled, 'pick enabled after relation'); + + // ── Pick the parent joint origin (re-fetch the link's current point) ── + assert(suite, await clickButton(page, PICK_PARENT), 'activate parent pick'); + await delay(500); + const parentPick = (await targetById(page, relParentLinkId)) ?? parentTarget; + await clickCanvasTarget(page, parentPick); await delay(900); + assert(suite, await waitForButton(page, PARENT_DONE, 5000), 'parent snap committed (raycast→resolve→commit)'); + + // ── Pick the child joint origin ── + assert(suite, await clickButton(page, PICK_CHILD), 'activate child pick'); + await delay(500); + const childPick = (await targetById(page, relChildLinkId)) ?? childTarget; + await clickCanvasTarget(page, childPick); await delay(1000); + assert(suite, await waitForButton(page, CHILD_DONE, 5000), 'child snap committed'); + + // ── Confirm → bridge created + child auto-aligned by the picked snaps ── + const beforeChild = (await getAssemblyState(page)).components.find((c) => c.id === compB.id); + assert(suite, await clickButton(page, CONFIRM), 'confirm bridge'); + await delay(1200); + + const asm = await getAssemblyState(page); + assertEqual(suite, asm.bridgeCount, 1, 'bridge created via pick flow'); + const afterChild = asm.components.find((c) => c.id === compB.id); + assert( + suite, + JSON.stringify(afterChild?.transform) !== JSON.stringify(beforeChild?.transform), + 'child component transform changed by snap alignment', + ); + + const errs = session.errors(); + assert(suite, errs.page.length === 0, `no page errors${errs.page.length ? `: ${errs.page.join(' | ')}` : ''}`); + } finally { + await session.cleanup(); + } + + await writeReport('joint_pick', {}); + process.exitCode = printSummary(suite) ? 0 : 1; +} + +main().catch((e) => { console.error(e); process.exitCode = 1; }); diff --git a/scripts/tools/dependency_boundaries.mjs b/scripts/tools/dependency_boundaries.mjs index 8979c0e2d..e4d5e6998 100644 --- a/scripts/tools/dependency_boundaries.mjs +++ b/scripts/tools/dependency_boundaries.mjs @@ -15,24 +15,58 @@ const SRC = 'src'; const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); const IGNORED_DIRS = new Set(['node_modules', 'runtime']); // urdf-viewer/runtime is vendored -// Documented existing exceptions (docs/architecture.md §3-4) — allowed, not violations. -// editor -> urdf-viewer is the documented facade relationship and is handled in -// checkBoundary; lib/RobotCanvas wrapping the viewer is the package's whole purpose. +// Documented existing exceptions (docs/architecture.md §3-4) — exact importer, +// specifier, and resolved target only. Do not widen these to feature/layer-level +// exceptions; use the baseline ratchets below for grandfathered surfaces. const ALLOWLIST = [ - { importer: 'src/shared/hooks/useTheme.ts', target: 'store' }, - { importer: 'src/shared/components/Panel/JointControlItem.tsx', target: 'store' }, - { importer: 'src/features/ai-assistant/utils/pdfExport.ts', target: 'feature:file-io' }, - { importer: 'src/lib/components/RobotCanvas.tsx', target: 'feature:urdf-viewer' }, + { + importer: 'src/features/editor/index.ts', + specifier: '../urdf-viewer', + resolved: 'src/features/urdf-viewer/index.ts', + }, + { + importer: 'src/features/editor/viewerPanelModule.ts', + specifier: '../urdf-viewer/components/ViewerPanels', + resolved: 'src/features/urdf-viewer/components/ViewerPanels.tsx', + }, + { + importer: 'src/features/editor/viewerPanelModule.ts', + specifier: '../urdf-viewer/hooks/useResponsivePanelLayout', + resolved: 'src/features/urdf-viewer/hooks/useResponsivePanelLayout.ts', + }, + { + importer: 'src/features/editor/viewerPanelModule.ts', + specifier: '../urdf-viewer/hooks/useViewerController', + resolved: 'src/features/urdf-viewer/hooks/useViewerController.ts', + }, + { + importer: 'src/lib/components/RobotCanvas.tsx', + specifier: '../../features/urdf-viewer/components/ViewerCanvas', + resolved: 'src/features/urdf-viewer/components/ViewerCanvas.tsx', + }, + { + importer: 'src/lib/components/RobotCanvas.tsx', + specifier: '../../features/urdf-viewer/components/JointInteraction', + resolved: 'src/features/urdf-viewer/components/JointInteraction.tsx', + }, + { + importer: 'src/lib/components/RobotCanvas.tsx', + specifier: '../../features/urdf-viewer/components/RobotModel', + resolved: 'src/features/urdf-viewer/components/RobotModel.tsx', + }, ]; const BASELINE_PATH = 'scripts/tools/dependency_boundaries_baseline.json'; const options = parseArgs(process.argv.slice(2)); -const knownCycles = await readKnownCycles(); +const baseline = await readBaseline(); +const knownCycles = new Set(baseline.knownCycles || []); +const knownFeatureDeepImportKeys = new Set(baseline.knownFeatureDeepImports || []); const files = await collectSourceFiles(SRC); const fileSet = new Set(files); const boundaryViolations = []; +const observedFeatureDeepImports = new Map(); // importer -> specifier key -> entry const graph = new Map(); // importerRel -> Set for (const relPath of files) { @@ -46,7 +80,12 @@ for (const relPath of files) { if (!targetLayer) { continue; } - const violation = checkBoundary(relPath, importerLayer, targetLayer, spec); + const deepImport = getAppFeatureDeepImport(relPath, importerLayer, resolvedRel, spec); + if (deepImport) { + observedFeatureDeepImports.set(deepImport.key, deepImport); + } + + const violation = checkBoundary(relPath, importerLayer, targetLayer, spec, resolvedRel); if (violation) { boundaryViolations.push(violation); } @@ -60,20 +99,49 @@ for (const relPath of files) { const allCycles = findCycles(graph).map((cycle) => ({ cycle, signature: cycleSignature(cycle) })); const newCycles = allCycles.filter((entry) => !knownCycles.has(entry.signature)); const knownCycleCount = allCycles.length - newCycles.length; +const featureDeepImportEntries = [...observedFeatureDeepImports.values()].sort((a, b) => + a.key.localeCompare(b.key), +); +const knownFeatureDeepImports = featureDeepImportEntries.filter((entry) => + knownFeatureDeepImportKeys.has(entry.key), +); +const newFeatureDeepImports = featureDeepImportEntries.filter( + (entry) => !knownFeatureDeepImportKeys.has(entry.key), +); +const staleFeatureDeepImports = [...knownFeatureDeepImportKeys] + .filter((key) => !observedFeatureDeepImports.has(key)) + .sort(); const report = { boundaryViolations, + featureDeepImports: { + known: knownFeatureDeepImports, + new: newFeatureDeepImports, + stale: staleFeatureDeepImports, + }, newCycles: newCycles.map((entry) => entry.cycle), knownCycleCount, scannedFiles: files.length, }; if (options.json) { - console.log(JSON.stringify({ ...report, allCycleSignatures: allCycles.map((entry) => entry.signature) }, null, 2)); + console.log( + JSON.stringify( + { ...report, allCycleSignatures: allCycles.map((entry) => entry.signature) }, + null, + 2, + ), + ); } else { printReport(report); } -if (options.check && (boundaryViolations.length > 0 || newCycles.length > 0)) { +if ( + options.check && + (boundaryViolations.length > 0 || + newFeatureDeepImports.length > 0 || + staleFeatureDeepImports.length > 0 || + newCycles.length > 0) +) { process.exitCode = 1; } @@ -93,14 +161,19 @@ function parseArgs(args) { return parsed; } -async function readKnownCycles() { +async function readBaseline() { try { const raw = await readFile(path.join(ROOT, BASELINE_PATH), 'utf8'); const parsed = JSON.parse(raw); - return new Set(parsed.knownCycles || []); + return { + knownCycles: Array.isArray(parsed.knownCycles) ? parsed.knownCycles : [], + knownFeatureDeepImports: Array.isArray(parsed.knownFeatureDeepImports) + ? parsed.knownFeatureDeepImports + : [], + }; } catch (error) { if (error && error.code === 'ENOENT') { - return new Set(); + return { knownCycles: [], knownFeatureDeepImports: [] }; } throw error; } @@ -215,11 +288,11 @@ function classifyExternal(spec) { return 'external'; } -function checkBoundary(importerRel, importerLayer, targetLayer, spec) { +function checkBoundary(importerRel, importerLayer, targetLayer, spec, resolvedRel) { if (!importerLayer || targetLayer === 'external' || targetLayer === 'types') { return null; } - if (isAllowlisted(importerRel, targetLayer)) { + if (isAllowlisted(importerRel, spec, resolvedRel)) { return null; } @@ -236,12 +309,7 @@ function checkBoundary(importerRel, importerLayer, targetLayer, spec) { } else if (importerFeature) { if (targetLayer === 'app') { reason = 'feature must not import app (app orchestrates features, not the reverse)'; - } else if ( - targetFeature && - targetFeature !== importerFeature && - targetFeature !== 'editor' && - !(importerFeature === 'editor' && targetFeature === 'urdf-viewer') - ) { + } else if (targetFeature && targetFeature !== importerFeature) { reason = `cross-feature import: features talk via store, not feature:${targetFeature}`; } } else if (importerLayer === 'shared') { @@ -264,8 +332,31 @@ function checkBoundary(importerRel, importerLayer, targetLayer, spec) { return { importer: importerRel, importerLayer, target: spec, targetLayer, reason }; } -function isAllowlisted(importerRel, targetLayer) { - return ALLOWLIST.some((entry) => importerRel === entry.importer && targetLayer === entry.target); +function isAllowlisted(importerRel, spec, resolvedRel) { + return ALLOWLIST.some( + (entry) => + importerRel === entry.importer && spec === entry.specifier && resolvedRel === entry.resolved, + ); +} + +function getAppFeatureDeepImport(importerRel, importerLayer, resolvedRel, spec) { + if (importerLayer !== 'app' || !resolvedRel) { + return null; + } + const match = /^src\/features\/([^/]+)\/(.+)$/.exec(resolvedRel); + if (!match) { + return null; + } + if (/^index\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(match[2])) { + return null; + } + const feature = match[1]; + const key = featureDeepImportKey(importerRel, spec); + return { key, importer: importerRel, specifier: spec, feature, resolved: resolvedRel }; +} + +function featureDeepImportKey(importerRel, spec) { + return `${importerRel} -> ${spec}`; } function findCycles(adjacency) { @@ -313,20 +404,57 @@ function printReport(report) { console.log(`Scanned files: ${report.scannedFiles}`); console.log(''); - console.log(`[${report.boundaryViolations.length === 0 ? 'OK' : 'FAIL'}] layer boundaries: ${report.boundaryViolations.length} violation(s)`); + console.log( + `[${report.boundaryViolations.length === 0 ? 'OK' : 'FAIL'}] layer boundaries: ${report.boundaryViolations.length} violation(s)`, + ); for (const v of report.boundaryViolations.slice(0, 30)) { console.log(` ${v.importer} -> ${v.target}`); console.log(` ${v.reason}`); } - console.log(`[${report.newCycles.length === 0 ? 'OK' : 'FAIL'}] import cycles: ${report.newCycles.length} new, ${report.knownCycleCount} grandfathered`); + const featureDeepImports = report.featureDeepImports; + const featureDeepImportFailed = + featureDeepImports.new.length > 0 || featureDeepImports.stale.length > 0; + console.log( + `[${featureDeepImportFailed ? 'FAIL' : 'OK'}] app feature deep imports: ${featureDeepImports.new.length} new, ${featureDeepImports.known.length} grandfathered, ${featureDeepImports.stale.length} stale`, + ); + if (featureDeepImports.new.length > 0) { + console.log(' New app feature deep imports:'); + for (const entry of featureDeepImports.new.slice(0, 30)) { + console.log(` ${entry.key}`); + console.log(` resolves to ${entry.resolved}`); + } + } + if (featureDeepImports.known.length > 0) { + console.log(' Known app feature deep imports:'); + for (const entry of featureDeepImports.known.slice(0, 30)) { + console.log(` ${entry.key}`); + } + if (featureDeepImports.known.length > 30) { + console.log(` ... ${featureDeepImports.known.length - 30} more`); + } + } + if (featureDeepImports.stale.length > 0) { + console.log(' Stale app feature deep import baseline entries:'); + for (const key of featureDeepImports.stale.slice(0, 30)) { + console.log(` ${key}`); + } + } + + console.log( + `[${report.newCycles.length === 0 ? 'OK' : 'FAIL'}] import cycles: ${report.newCycles.length} new, ${report.knownCycleCount} grandfathered`, + ); for (const cycle of report.newCycles.slice(0, 15)) { console.log(` ${cycle.join(' -> ')}`); } - if (report.boundaryViolations.length > 0 || report.newCycles.length > 0) { + if ( + report.boundaryViolations.length > 0 || + featureDeepImportFailed || + report.newCycles.length > 0 + ) { console.log(''); - console.log('Architecture redline broken. See docs/architecture.md §1-3.'); + console.log('Architecture redline broken. See docs/architecture.md §1-4.'); } } diff --git a/scripts/tools/dependency_boundaries_baseline.json b/scripts/tools/dependency_boundaries_baseline.json index 7dad063a2..e5838062c 100644 --- a/scripts/tools/dependency_boundaries_baseline.json +++ b/scripts/tools/dependency_boundaries_baseline.json @@ -1,6 +1,6 @@ { "generatedAt": "2026-06-05", - "note": "Grandfathered import cycles (2-node sibling/barrel cycles). Ratchet down only; remove a signature when its cycle is broken. New cycles fail dependency_boundaries.mjs --check.", + "note": "Grandfathered import cycles and app feature deep imports. Ratchet down only; remove entries when fixed. New cycles and new/stale feature deep imports fail dependency_boundaries.mjs --check.", "knownCycles": [ "src/types/robot.ts|src/types/usd.ts", "src/core/parsers/filePreview.ts|src/core/parsers/index.ts", @@ -9,5 +9,32 @@ "src/shared/utils/robot/closedLoopMotionPreview.ts|src/shared/utils/robot/closedLoopMotionPreviewWorkerBridge.ts", "src/app/utils/unifiedViewerMountState.ts|src/app/utils/unifiedViewerSceneMode.ts", "src/app/utils/exportArchiveAssets.ts|src/app/utils/exportArchiveAssetsWorker.ts" + ], + "knownFeatureDeepImports": [ + "src/app/components/unified-viewer/modeModuleLoaders.ts -> @/features/editor/viewerPanelModule", + "src/app/hooks/useCollisionOptimizationWorkflow.ts -> @/features/property-editor/utils/collisionOptimization", + "src/app/hooks/useUsdDocumentLifecycle.ts -> @/features/urdf-viewer/utils/usdExportBundle", + "src/app/utils/ikToolSelectionState.ts -> @/features/urdf-viewer/utils/selectedIkDragLink", + "src/app/utils/importPreparation.ts -> @/features/file-io/utils/libraryImportPathCollisions", + "src/app/utils/overlayLoaders.ts -> @/features/assembly/components/BridgeCreateModal", + "src/app/utils/overlayLoaders.ts -> @/features/property-editor/components/CollisionOptimizationDialog", + "src/app/utils/sourceCodeDocuments.ts -> @/features/urdf-viewer/utils/usdPreloadSources.ts", + "src/app/utils/usdBinaryArchive.ts -> @/features/urdf-viewer/utils/usdWasmRuntime", + "src/app/utils/usdExportContext.ts -> @/features/urdf-viewer/utils/usdExportBundle", + "src/app/utils/usdHydrationPersistence.ts -> @/features/urdf-viewer/utils/viewerRobotData", + "src/app/utils/usdHydrationWorkerEvents.ts -> @/features/urdf-viewer/utils/usdOffscreenViewerProtocol", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/usdExportBundle", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/usdOffscreenViewerProtocol", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/usdOffscreenViewerWorkerClient", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/usdPreparedExportCacheWorkerBridge", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/usdPreparedExportCacheWorkerTransfer", + "src/app/utils/usdRobotStateHydration.ts -> @/features/urdf-viewer/utils/viewerRobotData", + "src/app/utils/usdRuntimeStartupPrewarm.ts -> @/features/urdf-viewer/utils/usdOffscreenViewerWorkerClient", + "src/app/utils/usdRuntimeStartupPrewarm.ts -> @/features/urdf-viewer/utils/usdWasmRuntime", + "src/app/utils/usdSelectionPrewarm.ts -> @/features/urdf-viewer/utils/preparedUsdStageOpenCache", + "src/app/utils/usdSelectionPrewarm.ts -> @/features/urdf-viewer/utils/usdBlobBackedUsda", + "src/app/utils/usdSelectionPrewarm.ts -> @/features/urdf-viewer/utils/usdOffscreenViewerWorkerClient", + "src/app/utils/usdSelectionPrewarm.ts -> @/features/urdf-viewer/utils/usdWasmRuntime", + "src/app/workers/usdBinaryArchive.worker.ts -> @/features/urdf-viewer/utils/usdBindingsAssetPaths.ts" ] } diff --git a/src/app/App.tsx b/src/app/App.tsx index bfd7412cf..12aec0dd9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -70,7 +70,7 @@ import type { AIConversationLaunchContext, AIConversationMode, AIConversationSelection, -} from '@/features/ai-assistant/types'; +} from '@/features/ai-assistant'; import type { ExportTarget } from './hooks/file-export/types'; import { createConversationLaunchContext, diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 4c2a12e44..d9674d0ae 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -8,7 +8,7 @@ import { AppLayoutView } from './components/AppLayoutView'; import { resolveSnapshotCaptureAction } from './components/snapshot-preview/resolveSnapshotCaptureAction'; import { preloadSourceCodeEditorRuntime } from './utils/sourceCodeEditorLoader'; import { setOptionsPanelVisibility } from './components/header/viewMenuState.js'; -import type { ToolMode } from '@/features/urdf-viewer/types'; +import type { ToolMode } from '@/features/editor'; import type { AppLayoutProps, ProModeRoundtripSession } from './appLayoutTypes'; import { useAppLayoutEffects } from './hooks/useAppLayoutEffects'; import { useAppLayoutStoreSlices } from './hooks/useAppLayoutStoreSlices'; diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx index b16c745d8..a58bbe121 100644 --- a/src/app/Providers.tsx +++ b/src/app/Providers.tsx @@ -76,6 +76,15 @@ export function Providers({ children }: ProvidersProps) { document.title = t.documentTitle; document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; document.documentElement.setAttribute('data-lang', lang); + + // Keep the canonical link aligned with the active language variant. + const canonical = document.querySelector('link[rel="canonical"]'); + if (canonical) { + canonical.setAttribute( + 'href', + lang === 'zh' ? 'https://urdf.enkeebot.com/zh/' : 'https://urdf.enkeebot.com/', + ); + } }, [lang, t]); return ( diff --git a/src/app/components/AppLayoutOverlays.tsx b/src/app/components/AppLayoutOverlays.tsx index 627324e5c..d9816dcce 100644 --- a/src/app/components/AppLayoutOverlays.tsx +++ b/src/app/components/AppLayoutOverlays.tsx @@ -12,7 +12,7 @@ import type { CollisionOptimizationOperation, CollisionOptimizationSource, CollisionTargetRef, -} from '@/features/property-editor/utils'; +} from '@/features/property-editor'; import type { SourceCodeEditorDocument } from '@/features/code-editor'; const SourceCodeEditor = lazy(() => diff --git a/src/app/components/AppLayoutView.tsx b/src/app/components/AppLayoutView.tsx index 00ee71257..f5efd70a2 100644 --- a/src/app/components/AppLayoutView.tsx +++ b/src/app/components/AppLayoutView.tsx @@ -1,582 +1,19 @@ -import React, { lazy, Suspense, type CSSProperties } from 'react'; -import type { RootState } from '@react-three/fiber'; +import { AppLayoutViewContent } from './app_layout_view_sections'; +import type { AppLayoutViewProps } from './app_layout_view_types'; -import { AppLayoutOverlays } from './AppLayoutOverlays'; -import { FileDropOverlay } from './FileDropOverlay'; -import { Header } from './Header'; -import { IkToolPanel } from './IkToolPanel'; -import { ImportPreparationOverlay } from './ImportPreparationOverlay'; -import { LazyOverlayFallback } from './LazyOverlayFallback'; -import { WorkspaceSidebars } from './workspace/WorkspaceSidebars'; -import { WorkspaceViewerLayer } from './workspace/WorkspaceViewerLayer'; -import type { SnapshotPreviewSession } from './snapshot-preview/types'; -import type { AppLayoutProps } from '../appLayoutTypes'; -import { resolveDocumentLoadingOverlayTargetFileName } from '../utils/documentLoadProgress'; -import type { ToolMode } from '@/features/urdf-viewer/types'; -import type { - SnapshotCaptureAction, - SnapshotCaptureOptions, -} from '@/shared/components/3d/scene/snapshotConfig'; -import type { Language, TranslationKeys } from '@/shared/i18n'; -import { ROBOT_IMPORT_ACCEPT_ATTRIBUTE } from '@/shared/utils'; -import type { RobotFile } from '@/types'; - -const SnapshotDialog = lazy(() => - import('./SnapshotDialog').then((m) => ({ default: m.SnapshotDialog })), -); - -type HeaderProps = React.ComponentProps; -type IkToolPanelProps = React.ComponentProps; -type ImportPreparationOverlayProps = React.ComponentProps; -type WorkspaceViewerLayerProps = React.ComponentProps; -type ViewerProps = WorkspaceViewerLayerProps['viewerProps']; -type WorkspaceSidebarsProps = React.ComponentProps; -type TreeEditorProps = WorkspaceSidebarsProps['treeEditorProps']; -type FilePreviewWindowProps = WorkspaceSidebarsProps['filePreviewWindowProps']; -type PropertyEditorProps = WorkspaceSidebarsProps['propertyEditorProps']; -type AppLayoutOverlaysProps = React.ComponentProps; - -interface WorkspaceLayoutClassNames { - root: string; - viewerLayer: string; - leftSidebarLayer: string; - rightSidebarLayer: string; -} - -interface PropertyEditorSelectionContextView { - robot: PropertyEditorProps['robot']; - selectedClosedLoopBridge: unknown; -} - -interface AppLayoutDragHandlers { - onDragEnter: React.DragEventHandler; - onDragOver: React.DragEventHandler; - onDragLeave: React.DragEventHandler; - onDrop: React.DragEventHandler; -} - -export interface AppLayoutViewProps { - importInputRef: AppLayoutProps['importInputRef']; - importFolderInputRef: AppLayoutProps['importFolderInputRef']; - onOpenExport: AppLayoutProps['onOpenExport']; - onExportProject: AppLayoutProps['onExportProject']; - onOpenSettings: AppLayoutProps['onOpenSettings']; - headerQuickAction: AppLayoutProps['headerQuickAction']; - headerSecondaryAction: AppLayoutProps['headerSecondaryAction']; - viewConfig: AppLayoutProps['viewConfig']; - setViewConfig: AppLayoutProps['setViewConfig']; - isCodeViewerOpen: boolean; - setIsCodeViewerOpen: AppLayoutProps['setIsCodeViewerOpen']; - dragHandlers: AppLayoutDragHandlers; - isFileDragActive: boolean; - t: TranslationKeys; - lang: Language; - theme: ViewerProps['theme']; - toolboxItems: HeaderProps['toolboxItems']; - handleOpenCodeViewer: HeaderProps['onOpenCodeViewer']; - handlePrefetchCodeViewer: HeaderProps['onPrefetchCodeViewer']; - handleSnapshot: HeaderProps['onSnapshot']; - isIkToolPanelOpen: boolean; - ikLinkOptions: IkToolPanelProps['linkOptions']; - selectedIkLinkId: IkToolPanelProps['selectedLinkId']; - selectedIkLinkLabel: IkToolPanelProps['selectedLinkLabel']; - currentIkLinkLabel: IkToolPanelProps['currentLinkLabel']; - ikToolSelectionStatus: IkToolPanelProps['selectionStatus']; - onSelectIkLink: IkToolPanelProps['onSelectLink']; - onIkToolClose: () => void; - workspaceLayoutClassNames: WorkspaceLayoutClassNames; - workspaceOverlaySafeAreaStyle: CSSProperties | undefined; - workspaceOverlayGizmoMargin: ViewerProps['gizmoMargin']; - viewerRobot: ViewerProps['robot']; - editorRobot: ViewerProps['editorRobot']; - mergedAppMode: ViewerProps['mode']; - handleViewerSelectWithBridgePreview: ViewerProps['onSelect']; - handleViewerMeshSelectWithAssemblyClear: ViewerProps['onMeshSelect']; - handleHover: ViewerProps['onHover'] & PropertyEditorProps['onHover']; - handleUpdate: ViewerProps['onUpdate'] & TreeEditorProps['onUpdate'] & PropertyEditorProps['onUpdate']; - viewerAssets: ViewerProps['assets']; - allFileContents: ViewerProps['allFileContents']; - showVisual: TreeEditorProps['showVisual']; - handleSetShowVisual: TreeEditorProps['setShowVisual']; - handleSetDetailOptionsPanelVisibility: ViewerProps['setShowOptionsPanel']; - snapshotActionRef: React.RefObject; - viewerCanvasStateRef: React.MutableRefObject; - availableFiles: ViewerProps['availableFiles']; - urdfContentForViewer: ViewerProps['urdfContent']; - viewerSourceFormat: ViewerProps['viewerSourceFormat']; - viewerSourceFilePath: ViewerProps['sourceFilePath']; - viewerSourceFile: ViewerProps['sourceFile']; - viewerDocumentLifecycleCallbacks: Pick< - ViewerProps, - 'onDocumentLoadEvent' | 'onRuntimeRobotLoaded' | 'onRuntimeSceneReadyForDisplay' - >; - jointAngleState: ViewerProps['jointAngleState']; - jointMotionState: ViewerProps['jointMotionState']; - handleJointChange: ViewerProps['onJointChange']; - selection: AppLayoutOverlaysProps['selection']; - focusTarget: ViewerProps['focusTarget']; - selectedFile: RobotFile | null; - handleWorkspaceTransformPendingChange: ViewerProps['onTransformPendingChange']; - handleCollisionTransform: ViewerProps['onCollisionTransform']; - normalizedAssemblyState: AppLayoutOverlaysProps['assemblyState']; - shouldRenderAssembly: boolean; - assemblySelection: ViewerProps['assemblySelection']; - sourceSceneAssemblyComponentId: ViewerProps['sourceSceneAssemblyComponentId']; - handleAssemblyTransform: ViewerProps['onAssemblyTransform']; - handleComponentTransform: ViewerProps['onComponentTransform']; - handleBridgeTransform: ViewerProps['onBridgeTransform']; - ikDragActive: ViewerProps['ikDragActive']; - pendingViewerToolMode: ToolMode | null; - setPendingViewerToolMode: React.Dispatch>; - viewerReloadKey: ViewerProps['viewerReloadKey']; - documentLoadLifecycleState: ViewerProps['documentLoadState']; - documentLoadState: FilePreviewWindowProps['documentLoadState']; - importPreparationOverlay: WorkspaceViewerLayerProps['importPreparationOverlay']; - assemblyComponentPreparationOverlay: ImportPreparationOverlayProps | null; - previewContextRobot: TreeEditorProps['robot']; - handleSelectWithAssemblyClear: TreeEditorProps['onSelect'] & PropertyEditorProps['onSelect']; - handleSelectGeometryWithAssemblyClear: TreeEditorProps['onSelectGeometry'] & - PropertyEditorProps['onSelectGeometry']; - handleFocus: TreeEditorProps['onFocus']; - handleAddChild: TreeEditorProps['onAddChild']; - handleAddCollisionBody: TreeEditorProps['onAddCollisionBody']; - handleDelete: TreeEditorProps['onDelete']; - handleNameChange: TreeEditorProps['onNameChange']; - leftSidebarCollapsed: boolean; - rightSidebarCollapsed: boolean; - onToggleLeftSidebar: () => void; - onToggleRightSidebar: () => void; - handlePreviewFileWithFeedback: TreeEditorProps['onLoadRobot']; - handleRequestLoadRobot: TreeEditorProps['onRequestLoadRobot']; - handleAddComponent: TreeEditorProps['onAddComponent']; - handleDeleteLibraryFile: TreeEditorProps['onDeleteLibraryFile']; - handleDeleteLibraryFolder: TreeEditorProps['onDeleteLibraryFolder']; - handleRenameLibraryFolder: TreeEditorProps['onRenameLibraryFolder']; - handleDeleteAllLibraryFiles: TreeEditorProps['onDeleteAllLibraryFiles']; - handleExportLibraryFile: TreeEditorProps['onExportLibraryFile']; - handleCreateBridge: TreeEditorProps['onCreateBridge']; - removeComponent: TreeEditorProps['onRemoveComponent']; - removeBridge: TreeEditorProps['onRemoveBridge']; - handleRenameComponent: TreeEditorProps['onRenameComponent']; - handleSwitchTreeEditorToProMode: TreeEditorProps['onSwitchToProMode']; - handleRequestSwitchTreeEditorToStructure: TreeEditorProps['onRequestSwitchToStructure']; - isPreviewingWorkspaceSource: boolean; - handleJointPreview: TreeEditorProps['onJointAnglePreview']; - previewFile: FilePreviewWindowProps['file']; - previewRobot: FilePreviewWindowProps['previewRobot']; - filePreview: FilePreviewWindowProps['previewState']; - handleClosePreview: FilePreviewWindowProps['onClose']; - propertyEditorSelectionContext: PropertyEditorSelectionContextView; - handleUploadAsset: PropertyEditorProps['onUploadAsset']; - motorLibrary: PropertyEditorProps['motorLibrary']; - sourceCodeEditorDocuments: AppLayoutOverlaysProps['sourceCodeDocuments']; - sourceCodeAutoApply: AppLayoutOverlaysProps['autoApplyEnabled']; - isCollisionOptimizerOpen: boolean; - setIsCollisionOptimizerOpen: React.Dispatch>; - collisionOptimizationSource: AppLayoutOverlaysProps['collisionOptimizationSource']; - handlePreviewCollisionOptimizationTarget: AppLayoutOverlaysProps['onSelectCollisionTarget']; - handleApplyCollisionOptimization: AppLayoutOverlaysProps['onApplyCollisionOptimization']; - shouldRenderBridgeModal: AppLayoutOverlaysProps['shouldRenderBridgeModal']; - isBridgeModalOpen: AppLayoutOverlaysProps['isBridgeModalOpen']; - handleCloseBridgeModal: AppLayoutOverlaysProps['onCloseBridgeModal']; - handleCreateBridgeCommit: AppLayoutOverlaysProps['onCreateBridge']; - handleBridgePreviewChange: AppLayoutOverlaysProps['onPreviewBridgeChange']; - isSnapshotDialogOpen: boolean; - isSnapshotCapturing: boolean; - snapshotPreviewSession: SnapshotPreviewSession | null; - handleSnapshotPreviewCaptureActionChange: (action: SnapshotCaptureAction | null) => void; - handleCloseSnapshotDialog: () => void; - handleCaptureSnapshot: (options: SnapshotCaptureOptions) => Promise; -} - -export function AppLayoutView({ - importInputRef, - importFolderInputRef, - onOpenExport, - onExportProject, - onOpenSettings, - headerQuickAction, - headerSecondaryAction, - viewConfig, - setViewConfig, - isCodeViewerOpen, - setIsCodeViewerOpen, - dragHandlers, - isFileDragActive, - t, - lang, - theme, - toolboxItems, - handleOpenCodeViewer, - handlePrefetchCodeViewer, - handleSnapshot, - isIkToolPanelOpen, - ikLinkOptions, - selectedIkLinkId, - selectedIkLinkLabel, - currentIkLinkLabel, - ikToolSelectionStatus, - onSelectIkLink, - onIkToolClose, - workspaceLayoutClassNames, - workspaceOverlaySafeAreaStyle, - workspaceOverlayGizmoMargin, - viewerRobot, - editorRobot, - mergedAppMode, - handleViewerSelectWithBridgePreview, - handleViewerMeshSelectWithAssemblyClear, - handleHover, - handleUpdate, - viewerAssets, - allFileContents, - showVisual, - handleSetShowVisual, - handleSetDetailOptionsPanelVisibility, - snapshotActionRef, - viewerCanvasStateRef, - availableFiles, - urdfContentForViewer, - viewerSourceFormat, - viewerSourceFilePath, - viewerSourceFile, - viewerDocumentLifecycleCallbacks, - jointAngleState, - jointMotionState, - handleJointChange, - selection, - focusTarget, - selectedFile, - handleWorkspaceTransformPendingChange, - handleCollisionTransform, - normalizedAssemblyState, - shouldRenderAssembly, - assemblySelection, - sourceSceneAssemblyComponentId, - handleAssemblyTransform, - handleComponentTransform, - handleBridgeTransform, - ikDragActive, - pendingViewerToolMode, - setPendingViewerToolMode, - viewerReloadKey, - documentLoadLifecycleState, - documentLoadState, - importPreparationOverlay, - assemblyComponentPreparationOverlay, - previewContextRobot, - handleSelectWithAssemblyClear, - handleSelectGeometryWithAssemblyClear, - handleFocus, - handleAddChild, - handleAddCollisionBody, - handleDelete, - handleNameChange, - leftSidebarCollapsed, - rightSidebarCollapsed, - onToggleLeftSidebar, - onToggleRightSidebar, - handlePreviewFileWithFeedback, - handleRequestLoadRobot, - handleAddComponent, - handleDeleteLibraryFile, - handleDeleteLibraryFolder, - handleRenameLibraryFolder, - handleDeleteAllLibraryFiles, - handleExportLibraryFile, - handleCreateBridge, - removeComponent, - removeBridge, - handleRenameComponent, - handleSwitchTreeEditorToProMode, - handleRequestSwitchTreeEditorToStructure, - isPreviewingWorkspaceSource, - handleJointPreview, - previewFile, - previewRobot, - filePreview, - handleClosePreview, - propertyEditorSelectionContext, - handleUploadAsset, - motorLibrary, - sourceCodeEditorDocuments, - sourceCodeAutoApply, - isCollisionOptimizerOpen, - setIsCollisionOptimizerOpen, - collisionOptimizationSource, - handlePreviewCollisionOptimizationTarget, - handleApplyCollisionOptimization, - shouldRenderBridgeModal, - isBridgeModalOpen, - handleCloseBridgeModal, - handleCreateBridgeCommit, - handleBridgePreviewChange, - isSnapshotDialogOpen, - isSnapshotCapturing, - snapshotPreviewSession, - handleSnapshotPreviewCaptureActionChange, - handleCloseSnapshotDialog, - handleCaptureSnapshot, -}: AppLayoutViewProps) { - const hasDisplayableViewerRobot = Object.keys(viewerRobot.links ?? {}).length > 1; +export function AppLayoutView(props: AppLayoutViewProps) { + const hasDisplayableViewerRobot = Object.keys(props.viewerRobot.links ?? {}).length > 1; const shouldSuppressDocumentLoadingOverlay = - shouldRenderAssembly || - Boolean(assemblyComponentPreparationOverlay) || + props.shouldRenderAssembly || + Boolean(props.assemblyComponentPreparationOverlay) || (hasDisplayableViewerRobot && - documentLoadState.status === 'loading' && - documentLoadState.fileName === selectedFile?.name); + props.documentLoadState.status === 'loading' && + props.documentLoadState.fileName === props.selectedFile?.name); return ( -
- - - - )} - /> - -
importInputRef.current?.click()} - onImportFolder={() => importFolderInputRef.current?.click()} - onOpenExport={onOpenExport} - onExportProject={onExportProject} - toolboxItems={toolboxItems} - onOpenCodeViewer={handleOpenCodeViewer} - onPrefetchCodeViewer={handlePrefetchCodeViewer} - onOpenSettings={onOpenSettings} - quickAction={headerQuickAction} - secondaryAction={headerSecondaryAction} - onSnapshot={handleSnapshot} - viewConfig={viewConfig} - viewAvailability={{ jointPanel: true }} - setViewConfig={setViewConfig} - /> - - - -
- { - viewerCanvasStateRef.current = state; - }, - showOptionsPanel: viewConfig.showOptionsPanel, - setShowOptionsPanel: handleSetDetailOptionsPanelVisibility, - showJointPanel: false, - availableFiles, - urdfContent: urdfContentForViewer, - viewerSourceFormat, - sourceFilePath: viewerSourceFilePath, - sourceFile: viewerSourceFile, - onDocumentLoadEvent: viewerDocumentLifecycleCallbacks.onDocumentLoadEvent, - onRuntimeRobotLoaded: viewerDocumentLifecycleCallbacks.onRuntimeRobotLoaded, - onRuntimeSceneReadyForDisplay: - viewerDocumentLifecycleCallbacks.onRuntimeSceneReadyForDisplay, - jointAngleState, - jointMotionState, - onJointChange: handleJointChange, - syncJointChangesToApp: true, - selection, - focusTarget, - isMeshPreview: selectedFile?.format === 'mesh', - onTransformPendingChange: handleWorkspaceTransformPendingChange, - onCollisionTransform: handleCollisionTransform, - assemblyState: normalizedAssemblyState, - assemblyWorkspaceActive: shouldRenderAssembly, - assemblySelection, - sourceSceneAssemblyComponentId, - onAssemblyTransform: handleAssemblyTransform, - onComponentTransform: handleComponentTransform, - onBridgeTransform: handleBridgeTransform, - ikDragActive, - pendingViewerToolMode, - onConsumePendingViewerToolMode: () => setPendingViewerToolMode(null), - viewerReloadKey, - documentLoadState: documentLoadLifecycleState, - gizmoMargin: workspaceOverlayGizmoMargin, - }} - documentLoadingOverlayLang={lang} - documentLoadingOverlayTargetFileName={resolveDocumentLoadingOverlayTargetFileName({ - previewFileName: null, - selectedFileName: selectedFile?.name ?? null, - suppressDocumentLoadingOverlay: shouldSuppressDocumentLoadingOverlay, - documentLoadState, - })} - importPreparationOverlay={importPreparationOverlay} - /> - - -
- - {isSnapshotDialogOpen ? ( - }> - - - ) : null} - - {assemblyComponentPreparationOverlay ? ( - - ) : null} - - setIsCodeViewerOpen(false)} - theme={theme} - lang={lang} - loadingEditorLabel={t.loadingEditor} - isCollisionOptimizerOpen={isCollisionOptimizerOpen} - loadingOptimizerLabel={t.loadingOptimizer} - collisionOptimizationSource={collisionOptimizationSource} - assets={viewerAssets} - sourceFilePath={viewerSourceFilePath} - selection={selection} - onCloseCollisionOptimizer={() => setIsCollisionOptimizerOpen(false)} - onSelectCollisionTarget={handlePreviewCollisionOptimizationTarget} - onApplyCollisionOptimization={handleApplyCollisionOptimization} - assemblyState={normalizedAssemblyState} - shouldRenderBridgeModal={shouldRenderBridgeModal} - loadingBridgeDialogLabel={t.loadingBridgeDialog} - isBridgeModalOpen={isBridgeModalOpen} - onCloseBridgeModal={handleCloseBridgeModal} - onCreateBridge={handleCreateBridgeCommit} - onPreviewBridgeChange={handleBridgePreviewChange} - /> -
+ ); } diff --git a/src/app/components/AppOverlayLayer.tsx b/src/app/components/AppOverlayLayer.tsx index 9cc92579f..12123b703 100644 --- a/src/app/components/AppOverlayLayer.tsx +++ b/src/app/components/AppOverlayLayer.tsx @@ -18,7 +18,7 @@ import type { AIConversationFocusedIssue, AIConversationLaunchContext, AIConversationSelection, -} from '@/features/ai-assistant/types'; +} from '@/features/ai-assistant'; import type { ExportDialogConfig, ExportProgressState } from '@/features/file-io'; import type { InspectionReport, RobotState } from '@/types'; import type { Language } from '@/shared/i18n'; diff --git a/src/app/components/DocumentLoadingOverlay.tsx b/src/app/components/DocumentLoadingOverlay.tsx index 80e1cbfc4..0e3d71a78 100644 --- a/src/app/components/DocumentLoadingOverlay.tsx +++ b/src/app/components/DocumentLoadingOverlay.tsx @@ -12,6 +12,17 @@ interface DocumentLoadingOverlayProps { lang: Language; } +type OverlayProgressMode = Parameters[0]['progressMode']; + +function shouldRenderDocumentLoadingOverlay(state: DocumentLoadState): boolean { + return ( + state.status === 'loading' || + state.status === 'hydrating' || + (state.status === 'error' && Boolean(state.error)) || + (state.status === 'ready' && Boolean(state.message)) + ); +} + function resolveStageLabel(state: DocumentLoadState, lang: Language): string | null { const t = translations[lang]; @@ -45,14 +56,38 @@ function resolveStageLabel(state: DocumentLoadState, lang: Language): string | n } } -export function DocumentLoadingOverlay({ state, lang }: DocumentLoadingOverlayProps) { - const shouldRender = - state.status === 'loading' || - state.status === 'hydrating' || - (state.status === 'error' && Boolean(state.error)) || - (state.status === 'ready' && Boolean(state.message)); +function resolveOverlayProgressMode({ + state, + useIndeterminateStreamingProgress, +}: { + state: DocumentLoadState; + useIndeterminateStreamingProgress: boolean; +}): OverlayProgressMode { + if (state.status === 'error' || (state.status === 'ready' && state.message)) { + return 'percent'; + } - if (!shouldRender) { + if (useIndeterminateStreamingProgress) { + return 'indeterminate'; + } + + return state.progressMode ?? null; +} + +function resolveOverlayProgressPercent(state: DocumentLoadState): number | null | undefined { + if (state.status === 'error') { + return 0; + } + + if (state.status === 'ready' && state.message) { + return 100; + } + + return state.progressPercent; +} + +export function DocumentLoadingOverlay({ state, lang }: DocumentLoadingOverlayProps) { + if (!shouldRenderDocumentLoadingOverlay(state)) { return null; } @@ -65,18 +100,11 @@ export function DocumentLoadingOverlay({ state, lang }: DocumentLoadingOverlayPr loadedCount: state.loadedCount, totalCount: state.totalCount, }); - const overlayProgressMode = - state.status === 'error' || (state.status === 'ready' && state.message) - ? 'percent' - : useIndeterminateStreamingProgress - ? 'indeterminate' - : (state.progressMode ?? null); - const progressPercent = - state.status === 'error' - ? 0 - : state.status === 'ready' && state.message - ? 100 - : state.progressPercent; + const overlayProgressMode = resolveOverlayProgressMode({ + state, + useIndeterminateStreamingProgress, + }); + const progressPercent = resolveOverlayProgressPercent(state); const loadingHudState = buildLoadingHudState({ phase: state.status === 'ready' && state.message ? 'ready' : state.phase, progressMode: overlayProgressMode, diff --git a/src/app/components/FilePreviewWindow.tsx b/src/app/components/FilePreviewWindow.tsx index de6cbbee0..09cd108cb 100644 --- a/src/app/components/FilePreviewWindow.tsx +++ b/src/app/components/FilePreviewWindow.tsx @@ -15,7 +15,7 @@ import { } from '@/app/hooks/workspaceViewerDetailPreferences'; import { getViewerSourceFile } from '@/app/hooks/workspaceSourceSyncUtils'; import { resolveStandaloneViewerSourceFormat } from '@/app/hooks/workspace-source-sync/mjcfViewerRuntimePolicy'; -import type { ViewerJointMotionStateValue } from '@/features/urdf-viewer/types'; +import type { ViewerJointMotionStateValue } from '@/features/editor'; import type { Language } from '@/store'; import type { DocumentLoadLifecycleState, DocumentLoadState } from '@/store/assetsStore'; import type { RobotFile, RobotState, Theme } from '@/types'; diff --git a/src/app/components/UnifiedViewer.tsx b/src/app/components/UnifiedViewer.tsx index f54767f9d..2ea0200c9 100644 --- a/src/app/components/UnifiedViewer.tsx +++ b/src/app/components/UnifiedViewer.tsx @@ -26,9 +26,9 @@ import { type ViewerJointChangeContext, type ViewerJointMotionStateValue, type ViewerRobotSourceFormat, -} from '@/features/urdf-viewer/types'; -import { useViewerController } from '@/features/urdf-viewer/hooks/useViewerController'; -import { resolveDefaultViewerToolMode } from '@/features/urdf-viewer/utils/scopedToolMode'; +} from '@/features/editor'; +import { useViewerController } from '@/features/editor'; +import { resolveDefaultViewerToolMode } from '@/features/editor'; import { resolveViewerJointScopeKey } from '@/app/utils/viewerJointScopeKey'; import { resolveUnifiedViewerForcedSessionState } from '@/app/utils/unifiedViewerForcedSessionState'; import { resolveUnifiedViewerUsageGuideVisibility } from '@/app/utils/unifiedViewerUsageGuide'; diff --git a/src/app/components/ai/AIConversationConnector.tsx b/src/app/components/ai/AIConversationConnector.tsx index c0bf983b4..12d659380 100644 --- a/src/app/components/ai/AIConversationConnector.tsx +++ b/src/app/components/ai/AIConversationConnector.tsx @@ -1,5 +1,5 @@ -import { AIConversationModal } from '@/features/ai-assistant/components/AIConversationModal'; -import type { AIConversationLaunchContext } from '@/features/ai-assistant/types'; +import { AIConversationModal } from '@/features/ai-assistant'; +import type { AIConversationLaunchContext } from '@/features/ai-assistant'; import type { Language } from '@/shared/i18n'; interface AIConversationConnectorProps { diff --git a/src/app/components/ai/AIInspectionConnector.tsx b/src/app/components/ai/AIInspectionConnector.tsx index dcde9ef97..ef412ea0f 100644 --- a/src/app/components/ai/AIInspectionConnector.tsx +++ b/src/app/components/ai/AIInspectionConnector.tsx @@ -1,11 +1,11 @@ import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; -import { AIInspectionModal } from '@/features/ai-assistant/components/AIInspectionModal'; +import { AIInspectionModal } from '@/features/ai-assistant'; import type { AIConversationFocusedIssue, AIConversationSelection, -} from '@/features/ai-assistant/types'; +} from '@/features/ai-assistant'; import { useRobotStore, useSelectionStore } from '@/store'; import type { InspectionReport, RobotState } from '@/types'; import type { Language } from '@/shared/i18n'; diff --git a/src/app/components/app_layout_view_sections.tsx b/src/app/components/app_layout_view_sections.tsx new file mode 100644 index 000000000..20d321d44 --- /dev/null +++ b/src/app/components/app_layout_view_sections.tsx @@ -0,0 +1,497 @@ +import React, { lazy, Suspense } from 'react'; + +import { AppLayoutOverlays } from './AppLayoutOverlays'; +import { FileDropOverlay } from './FileDropOverlay'; +import { Header } from './Header'; +import { IkToolPanel } from './IkToolPanel'; +import { ImportPreparationOverlay } from './ImportPreparationOverlay'; +import { LazyOverlayFallback } from './LazyOverlayFallback'; +import type { AppLayoutViewProps } from './app_layout_view_types'; +import { WorkspaceSidebars } from './workspace/WorkspaceSidebars'; +import { WorkspaceViewerLayer } from './workspace/WorkspaceViewerLayer'; +import { resolveDocumentLoadingOverlayTargetFileName } from '../utils/documentLoadProgress'; +import { ROBOT_IMPORT_ACCEPT_ATTRIBUTE } from '@/shared/utils'; + +const SnapshotDialog = lazy(() => + import('./SnapshotDialog').then((m) => ({ default: m.SnapshotDialog })), +); + +interface AppLayoutViewContentProps extends AppLayoutViewProps { + shouldSuppressDocumentLoadingOverlay: boolean; +} + +export function AppLayoutViewContent(props: AppLayoutViewContentProps) { + const { dragHandlers, workspaceLayoutClassNames } = props; + + return ( +
+ + + + + +
+ + +
+ + + + +
+ ); +} + +function AppLayoutDropOverlay({ isFileDragActive, t }: AppLayoutViewProps) { + return ( + + ); +} + +function AppLayoutImportInputs({ + importInputRef, + importFolderInputRef, +}: AppLayoutViewProps) { + return ( + <> + + )} + /> + + ); +} + +function AppLayoutHeaderSection({ + importInputRef, + importFolderInputRef, + onOpenExport, + onExportProject, + onOpenSettings, + headerQuickAction, + headerSecondaryAction, + viewConfig, + setViewConfig, + toolboxItems, + handleOpenCodeViewer, + handlePrefetchCodeViewer, + handleSnapshot, +}: AppLayoutViewProps) { + return ( +
importInputRef.current?.click()} + onImportFolder={() => importFolderInputRef.current?.click()} + onOpenExport={onOpenExport} + onExportProject={onExportProject} + toolboxItems={toolboxItems} + onOpenCodeViewer={handleOpenCodeViewer} + onPrefetchCodeViewer={handlePrefetchCodeViewer} + onOpenSettings={onOpenSettings} + quickAction={headerQuickAction} + secondaryAction={headerSecondaryAction} + onSnapshot={handleSnapshot} + viewConfig={viewConfig} + viewAvailability={{ jointPanel: true }} + setViewConfig={setViewConfig} + /> + ); +} + +function AppLayoutIkPanelSection({ + isIkToolPanelOpen, + t, + ikLinkOptions, + selectedIkLinkId, + selectedIkLinkLabel, + currentIkLinkLabel, + ikToolSelectionStatus, + onSelectIkLink, + onIkToolClose, +}: AppLayoutViewProps) { + return ( + + ); +} + +function WorkspaceViewerSection({ + workspaceLayoutClassNames, + workspaceOverlaySafeAreaStyle, + workspaceOverlayGizmoMargin, + viewerRobot, + editorRobot, + mergedAppMode, + handleViewerSelectWithBridgePreview, + handleViewerMeshSelectWithAssemblyClear, + handleHover, + handleUpdate, + viewerAssets, + allFileContents, + showVisual, + handleSetShowVisual, + handleSetDetailOptionsPanelVisibility, + snapshotActionRef, + viewerCanvasStateRef, + availableFiles, + urdfContentForViewer, + viewerSourceFormat, + viewerSourceFilePath, + viewerSourceFile, + viewerDocumentLifecycleCallbacks, + jointAngleState, + jointMotionState, + handleJointChange, + selection, + focusTarget, + selectedFile, + handleWorkspaceTransformPendingChange, + handleCollisionTransform, + normalizedAssemblyState, + shouldRenderAssembly, + assemblySelection, + sourceSceneAssemblyComponentId, + handleAssemblyTransform, + handleComponentTransform, + handleBridgeTransform, + ikDragActive, + pendingViewerToolMode, + setPendingViewerToolMode, + viewerReloadKey, + documentLoadLifecycleState, + documentLoadState, + importPreparationOverlay, + lang, + theme, + viewConfig, + shouldSuppressDocumentLoadingOverlay, +}: AppLayoutViewContentProps) { + return ( + { + viewerCanvasStateRef.current = state; + }, + showOptionsPanel: viewConfig.showOptionsPanel, + setShowOptionsPanel: handleSetDetailOptionsPanelVisibility, + showJointPanel: false, + availableFiles, + urdfContent: urdfContentForViewer, + viewerSourceFormat, + sourceFilePath: viewerSourceFilePath, + sourceFile: viewerSourceFile, + onDocumentLoadEvent: viewerDocumentLifecycleCallbacks.onDocumentLoadEvent, + onRuntimeRobotLoaded: viewerDocumentLifecycleCallbacks.onRuntimeRobotLoaded, + onRuntimeSceneReadyForDisplay: + viewerDocumentLifecycleCallbacks.onRuntimeSceneReadyForDisplay, + jointAngleState, + jointMotionState, + onJointChange: handleJointChange, + syncJointChangesToApp: true, + selection, + focusTarget, + isMeshPreview: selectedFile?.format === 'mesh', + onTransformPendingChange: handleWorkspaceTransformPendingChange, + onCollisionTransform: handleCollisionTransform, + assemblyState: normalizedAssemblyState, + assemblyWorkspaceActive: shouldRenderAssembly, + assemblySelection, + sourceSceneAssemblyComponentId, + onAssemblyTransform: handleAssemblyTransform, + onComponentTransform: handleComponentTransform, + onBridgeTransform: handleBridgeTransform, + ikDragActive, + pendingViewerToolMode, + onConsumePendingViewerToolMode: () => setPendingViewerToolMode(null), + viewerReloadKey, + documentLoadState: documentLoadLifecycleState, + gizmoMargin: workspaceOverlayGizmoMargin, + }} + documentLoadingOverlayLang={lang} + documentLoadingOverlayTargetFileName={resolveDocumentLoadingOverlayTargetFileName({ + previewFileName: null, + selectedFileName: selectedFile?.name ?? null, + suppressDocumentLoadingOverlay: shouldSuppressDocumentLoadingOverlay, + documentLoadState, + })} + importPreparationOverlay={importPreparationOverlay} + /> + ); +} + +function WorkspaceSidebarsSection({ + workspaceLayoutClassNames, + previewContextRobot, + handleSelectWithAssemblyClear, + handleSelectGeometryWithAssemblyClear, + handleFocus, + handleAddChild, + handleAddCollisionBody, + handleDelete, + handleNameChange, + handleUpdate, + showVisual, + handleSetShowVisual, + mergedAppMode, + lang, + theme, + leftSidebarCollapsed, + rightSidebarCollapsed, + onToggleLeftSidebar, + onToggleRightSidebar, + availableFiles, + handlePreviewFileWithFeedback, + handleRequestLoadRobot, + selectedFile, + viewerSourceFilePath, + normalizedAssemblyState, + handleAddComponent, + handleDeleteLibraryFile, + handleDeleteLibraryFolder, + handleRenameLibraryFolder, + handleDeleteAllLibraryFiles, + handleExportLibraryFile, + handleCreateBridge, + removeComponent, + removeBridge, + handleRenameComponent, + handleSwitchTreeEditorToProMode, + handleRequestSwitchTreeEditorToStructure, + isPreviewingWorkspaceSource, + viewConfig, + handleJointPreview, + handleJointChange, + previewFile, + previewRobot, + filePreview, + viewerAssets, + allFileContents, + documentLoadState, + handleClosePreview, + propertyEditorSelectionContext, + handleHover, + handleUploadAsset, + motorLibrary, + t, +}: AppLayoutViewProps) { + return ( + + ); +} + +function SnapshotDialogSection({ + isSnapshotDialogOpen, + isSnapshotCapturing, + lang, + snapshotPreviewSession, + handleSnapshotPreviewCaptureActionChange, + handleCloseSnapshotDialog, + handleCaptureSnapshot, + t, +}: AppLayoutViewProps) { + if (!isSnapshotDialogOpen) { + return null; + } + + return ( + }> + + + ); +} + +function AssemblyPreparationOverlaySection({ + assemblyComponentPreparationOverlay, +}: AppLayoutViewProps) { + if (!assemblyComponentPreparationOverlay) { + return null; + } + + return ( + + ); +} + +function AppLayoutOverlaysSection({ + isCodeViewerOpen, + sourceCodeEditorDocuments, + sourceCodeAutoApply, + setIsCodeViewerOpen, + theme, + lang, + t, + isCollisionOptimizerOpen, + setIsCollisionOptimizerOpen, + collisionOptimizationSource, + viewerAssets, + viewerSourceFilePath, + selection, + handlePreviewCollisionOptimizationTarget, + handleApplyCollisionOptimization, + normalizedAssemblyState, + shouldRenderBridgeModal, + isBridgeModalOpen, + handleCloseBridgeModal, + handleCreateBridgeCommit, + handleBridgePreviewChange, +}: AppLayoutViewProps) { + return ( + setIsCodeViewerOpen(false)} + theme={theme} + lang={lang} + loadingEditorLabel={t.loadingEditor} + isCollisionOptimizerOpen={isCollisionOptimizerOpen} + loadingOptimizerLabel={t.loadingOptimizer} + collisionOptimizationSource={collisionOptimizationSource} + assets={viewerAssets} + sourceFilePath={viewerSourceFilePath} + selection={selection} + onCloseCollisionOptimizer={() => setIsCollisionOptimizerOpen(false)} + onSelectCollisionTarget={handlePreviewCollisionOptimizationTarget} + onApplyCollisionOptimization={handleApplyCollisionOptimization} + assemblyState={normalizedAssemblyState} + shouldRenderBridgeModal={shouldRenderBridgeModal} + loadingBridgeDialogLabel={t.loadingBridgeDialog} + isBridgeModalOpen={isBridgeModalOpen} + onCloseBridgeModal={handleCloseBridgeModal} + onCreateBridge={handleCreateBridgeCommit} + onPreviewBridgeChange={handleBridgePreviewChange} + /> + ); +} diff --git a/src/app/components/app_layout_view_types.ts b/src/app/components/app_layout_view_types.ts new file mode 100644 index 000000000..bca52342e --- /dev/null +++ b/src/app/components/app_layout_view_types.ts @@ -0,0 +1,183 @@ +import type React from 'react'; +import type { CSSProperties } from 'react'; +import type { RootState } from '@react-three/fiber'; + +import type { AppLayoutOverlays } from './AppLayoutOverlays'; +import type { Header } from './Header'; +import type { IkToolPanel } from './IkToolPanel'; +import type { ImportPreparationOverlay } from './ImportPreparationOverlay'; +import type { WorkspaceSidebars } from './workspace/WorkspaceSidebars'; +import type { WorkspaceViewerLayer } from './workspace/WorkspaceViewerLayer'; +import type { SnapshotPreviewSession } from './snapshot-preview/types'; +import type { AppLayoutProps } from '../appLayoutTypes'; +import type { ToolMode } from '@/features/editor'; +import type { + SnapshotCaptureAction, + SnapshotCaptureOptions, +} from '@/shared/components/3d/scene/snapshotConfig'; +import type { Language, TranslationKeys } from '@/shared/i18n'; +import type { RobotFile } from '@/types'; + +type HeaderProps = React.ComponentProps; +type IkToolPanelProps = React.ComponentProps; +type ImportPreparationOverlayProps = React.ComponentProps; +type WorkspaceViewerLayerProps = React.ComponentProps; +type ViewerProps = WorkspaceViewerLayerProps['viewerProps']; +type WorkspaceSidebarsProps = React.ComponentProps; +type TreeEditorProps = WorkspaceSidebarsProps['treeEditorProps']; +type FilePreviewWindowProps = WorkspaceSidebarsProps['filePreviewWindowProps']; +type PropertyEditorProps = WorkspaceSidebarsProps['propertyEditorProps']; +type AppLayoutOverlaysProps = React.ComponentProps; + +interface WorkspaceLayoutClassNames { + root: string; + viewerLayer: string; + leftSidebarLayer: string; + rightSidebarLayer: string; +} + +interface PropertyEditorSelectionContextView { + robot: PropertyEditorProps['robot']; + selectedClosedLoopBridge: unknown; +} + +interface AppLayoutDragHandlers { + onDragEnter: React.DragEventHandler; + onDragOver: React.DragEventHandler; + onDragLeave: React.DragEventHandler; + onDrop: React.DragEventHandler; +} + +export interface AppLayoutViewProps { + importInputRef: AppLayoutProps['importInputRef']; + importFolderInputRef: AppLayoutProps['importFolderInputRef']; + onOpenExport: AppLayoutProps['onOpenExport']; + onExportProject: AppLayoutProps['onExportProject']; + onOpenSettings: AppLayoutProps['onOpenSettings']; + headerQuickAction: AppLayoutProps['headerQuickAction']; + headerSecondaryAction: AppLayoutProps['headerSecondaryAction']; + viewConfig: AppLayoutProps['viewConfig']; + setViewConfig: AppLayoutProps['setViewConfig']; + isCodeViewerOpen: boolean; + setIsCodeViewerOpen: AppLayoutProps['setIsCodeViewerOpen']; + dragHandlers: AppLayoutDragHandlers; + isFileDragActive: boolean; + t: TranslationKeys; + lang: Language; + theme: ViewerProps['theme']; + toolboxItems: HeaderProps['toolboxItems']; + handleOpenCodeViewer: HeaderProps['onOpenCodeViewer']; + handlePrefetchCodeViewer: HeaderProps['onPrefetchCodeViewer']; + handleSnapshot: HeaderProps['onSnapshot']; + isIkToolPanelOpen: boolean; + ikLinkOptions: IkToolPanelProps['linkOptions']; + selectedIkLinkId: IkToolPanelProps['selectedLinkId']; + selectedIkLinkLabel: IkToolPanelProps['selectedLinkLabel']; + currentIkLinkLabel: IkToolPanelProps['currentLinkLabel']; + ikToolSelectionStatus: IkToolPanelProps['selectionStatus']; + onSelectIkLink: IkToolPanelProps['onSelectLink']; + onIkToolClose: () => void; + workspaceLayoutClassNames: WorkspaceLayoutClassNames; + workspaceOverlaySafeAreaStyle: CSSProperties | undefined; + workspaceOverlayGizmoMargin: ViewerProps['gizmoMargin']; + viewerRobot: ViewerProps['robot']; + editorRobot: ViewerProps['editorRobot']; + mergedAppMode: ViewerProps['mode']; + handleViewerSelectWithBridgePreview: ViewerProps['onSelect']; + handleViewerMeshSelectWithAssemblyClear: ViewerProps['onMeshSelect']; + handleHover: ViewerProps['onHover'] & PropertyEditorProps['onHover']; + handleUpdate: ViewerProps['onUpdate'] & TreeEditorProps['onUpdate'] & PropertyEditorProps['onUpdate']; + viewerAssets: ViewerProps['assets']; + allFileContents: ViewerProps['allFileContents']; + showVisual: TreeEditorProps['showVisual']; + handleSetShowVisual: TreeEditorProps['setShowVisual']; + handleSetDetailOptionsPanelVisibility: ViewerProps['setShowOptionsPanel']; + snapshotActionRef: React.RefObject; + viewerCanvasStateRef: React.MutableRefObject; + availableFiles: ViewerProps['availableFiles']; + urdfContentForViewer: ViewerProps['urdfContent']; + viewerSourceFormat: ViewerProps['viewerSourceFormat']; + viewerSourceFilePath: ViewerProps['sourceFilePath']; + viewerSourceFile: ViewerProps['sourceFile']; + viewerDocumentLifecycleCallbacks: Pick< + ViewerProps, + 'onDocumentLoadEvent' | 'onRuntimeRobotLoaded' | 'onRuntimeSceneReadyForDisplay' + >; + jointAngleState: ViewerProps['jointAngleState']; + jointMotionState: ViewerProps['jointMotionState']; + handleJointChange: ViewerProps['onJointChange']; + selection: AppLayoutOverlaysProps['selection']; + focusTarget: ViewerProps['focusTarget']; + selectedFile: RobotFile | null; + handleWorkspaceTransformPendingChange: ViewerProps['onTransformPendingChange']; + handleCollisionTransform: ViewerProps['onCollisionTransform']; + normalizedAssemblyState: AppLayoutOverlaysProps['assemblyState']; + shouldRenderAssembly: boolean; + assemblySelection: ViewerProps['assemblySelection']; + sourceSceneAssemblyComponentId: ViewerProps['sourceSceneAssemblyComponentId']; + handleAssemblyTransform: ViewerProps['onAssemblyTransform']; + handleComponentTransform: ViewerProps['onComponentTransform']; + handleBridgeTransform: ViewerProps['onBridgeTransform']; + ikDragActive: ViewerProps['ikDragActive']; + pendingViewerToolMode: ToolMode | null; + setPendingViewerToolMode: React.Dispatch>; + viewerReloadKey: ViewerProps['viewerReloadKey']; + documentLoadLifecycleState: ViewerProps['documentLoadState']; + documentLoadState: FilePreviewWindowProps['documentLoadState']; + importPreparationOverlay: WorkspaceViewerLayerProps['importPreparationOverlay']; + assemblyComponentPreparationOverlay: ImportPreparationOverlayProps | null; + previewContextRobot: TreeEditorProps['robot']; + handleSelectWithAssemblyClear: TreeEditorProps['onSelect'] & PropertyEditorProps['onSelect']; + handleSelectGeometryWithAssemblyClear: TreeEditorProps['onSelectGeometry'] & + PropertyEditorProps['onSelectGeometry']; + handleFocus: TreeEditorProps['onFocus']; + handleAddChild: TreeEditorProps['onAddChild']; + handleAddCollisionBody: TreeEditorProps['onAddCollisionBody']; + handleDelete: TreeEditorProps['onDelete']; + handleNameChange: TreeEditorProps['onNameChange']; + leftSidebarCollapsed: boolean; + rightSidebarCollapsed: boolean; + onToggleLeftSidebar: () => void; + onToggleRightSidebar: () => void; + handlePreviewFileWithFeedback: TreeEditorProps['onLoadRobot']; + handleRequestLoadRobot: TreeEditorProps['onRequestLoadRobot']; + handleAddComponent: TreeEditorProps['onAddComponent']; + handleDeleteLibraryFile: TreeEditorProps['onDeleteLibraryFile']; + handleDeleteLibraryFolder: TreeEditorProps['onDeleteLibraryFolder']; + handleRenameLibraryFolder: TreeEditorProps['onRenameLibraryFolder']; + handleDeleteAllLibraryFiles: TreeEditorProps['onDeleteAllLibraryFiles']; + handleExportLibraryFile: TreeEditorProps['onExportLibraryFile']; + handleCreateBridge: TreeEditorProps['onCreateBridge']; + removeComponent: TreeEditorProps['onRemoveComponent']; + removeBridge: TreeEditorProps['onRemoveBridge']; + handleRenameComponent: TreeEditorProps['onRenameComponent']; + handleSwitchTreeEditorToProMode: TreeEditorProps['onSwitchToProMode']; + handleRequestSwitchTreeEditorToStructure: TreeEditorProps['onRequestSwitchToStructure']; + isPreviewingWorkspaceSource: boolean; + handleJointPreview: TreeEditorProps['onJointAnglePreview']; + previewFile: FilePreviewWindowProps['file']; + previewRobot: FilePreviewWindowProps['previewRobot']; + filePreview: FilePreviewWindowProps['previewState']; + handleClosePreview: FilePreviewWindowProps['onClose']; + propertyEditorSelectionContext: PropertyEditorSelectionContextView; + handleUploadAsset: PropertyEditorProps['onUploadAsset']; + motorLibrary: PropertyEditorProps['motorLibrary']; + sourceCodeEditorDocuments: AppLayoutOverlaysProps['sourceCodeDocuments']; + sourceCodeAutoApply: AppLayoutOverlaysProps['autoApplyEnabled']; + isCollisionOptimizerOpen: boolean; + setIsCollisionOptimizerOpen: React.Dispatch>; + collisionOptimizationSource: AppLayoutOverlaysProps['collisionOptimizationSource']; + handlePreviewCollisionOptimizationTarget: AppLayoutOverlaysProps['onSelectCollisionTarget']; + handleApplyCollisionOptimization: AppLayoutOverlaysProps['onApplyCollisionOptimization']; + shouldRenderBridgeModal: AppLayoutOverlaysProps['shouldRenderBridgeModal']; + isBridgeModalOpen: AppLayoutOverlaysProps['isBridgeModalOpen']; + handleCloseBridgeModal: AppLayoutOverlaysProps['onCloseBridgeModal']; + handleCreateBridgeCommit: AppLayoutOverlaysProps['onCreateBridge']; + handleBridgePreviewChange: AppLayoutOverlaysProps['onPreviewBridgeChange']; + isSnapshotDialogOpen: boolean; + isSnapshotCapturing: boolean; + snapshotPreviewSession: SnapshotPreviewSession | null; + handleSnapshotPreviewCaptureActionChange: (action: SnapshotCaptureAction | null) => void; + handleCloseSnapshotDialog: () => void; + handleCaptureSnapshot: (options: SnapshotCaptureOptions) => Promise; +} diff --git a/src/app/components/header/HeaderActions.tsx b/src/app/components/header/HeaderActions.tsx index 0327d262e..5240c93a3 100644 --- a/src/app/components/header/HeaderActions.tsx +++ b/src/app/components/header/HeaderActions.tsx @@ -29,6 +29,163 @@ interface HeaderActionsProps { t: HeaderTranslations; } +interface InlineActionButtonProps { + action?: HeaderAction; + show: boolean; + showLabel: boolean; +} + +function InlineActionButton({ action, show, showLabel }: InlineActionButtonProps) { + const ActionIcon = action?.icon; + + if (!show || !action || !ActionIcon) { + return null; + } + + return ( + + ); +} + +function SnapshotButton({ + show, + onSnapshot, + label, +}: { + show: boolean; + onSnapshot: () => void; + label: string; +}) { + if (!show) { + return null; + } + + return ( + + ); +} + +function LanguageButton({ + show, + lang, + setLang, + label, +}: { + show: boolean; + lang: 'en' | 'zh'; + setLang: (lang: 'en' | 'zh') => void; + label: string; +}) { + if (!show) { + return null; + } + + return ( + + ); +} + +function resolveNextTheme(theme: Theme): Theme { + if (theme === 'system') { + const isSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + return isSystemDark ? 'light' : 'dark'; + } + + return theme === 'dark' ? 'light' : 'dark'; +} + +function ThemeIcon({ theme }: { theme: Theme }) { + if (theme === 'system') { + return ; + } + + return theme === 'dark' ? : ; +} + +function ThemeButton({ + show, + theme, + setTheme, + label, +}: { + show: boolean; + theme: Theme; + setTheme: (theme: Theme) => void; + label: string; +}) { + if (!show) { + return null; + } + + return ( + + ); +} + +function HeaderDivider({ show }: { show: boolean }) { + return show ?
: null; +} + +function SettingsButton({ + show, + onOpenSettings, + label, +}: { + show: boolean; + onOpenSettings: () => void; + label: string; +}) { + if (!show) { + return null; + } + + return ( + + ); +} + export function HeaderActions({ responsive, lang, @@ -60,77 +217,28 @@ export function HeaderActions({ showSecondaryActionLabel, showDesktopOverflow, } = responsive; - const QuickActionIcon = quickAction?.icon; - const SecondaryActionIcon = secondaryAction?.icon; return (
- {showQuickActionInline && quickAction && QuickActionIcon && ( - - )} - - {showSnapshotInline && ( - - )} - - {showLanguageInline && ( - - )} - - {showThemeInline && ( - - )} - - {(showThemeInline || showDesktopOverflow) && ( -
- )} + + + + + {showDesktopOverflow && ( )} - {showSecondaryActionInline && secondaryAction && SecondaryActionIcon && ( - - )} + - {showSettingsInline && ( - - )} + ; diff --git a/src/app/hooks/useWorkspaceMutations.ts b/src/app/hooks/useWorkspaceMutations.ts index 7329e66ba..3186f3bf7 100644 --- a/src/app/hooks/useWorkspaceMutations.ts +++ b/src/app/hooks/useWorkspaceMutations.ts @@ -13,7 +13,7 @@ import { } from '@/core/robot'; import { cloneAssemblyTransform } from '@/core/robot/assemblyTransforms'; import { useRobotStore } from '@/store'; -import type { ViewerJointChangeContext } from '@/features/urdf-viewer/types'; +import type { ViewerJointChangeContext } from '@/features/editor'; import type { AssemblyTransform, RobotMjcfInspectionTendonSummary, diff --git a/src/app/hooks/workspace-mutations/jointMotion.ts b/src/app/hooks/workspace-mutations/jointMotion.ts index 337fc3dc4..412246f45 100644 --- a/src/app/hooks/workspace-mutations/jointMotion.ts +++ b/src/app/hooks/workspace-mutations/jointMotion.ts @@ -1,6 +1,6 @@ import { resolveJointKey } from '@/core/robot'; import { useRobotStore } from '@/store'; -import type { ViewerJointChangeContext } from '@/features/urdf-viewer/types'; +import type { ViewerJointChangeContext } from '@/features/editor'; import type { JointQuaternion, UrdfJoint } from '@/types'; export interface ResolvedJointMotion { diff --git a/src/app/utils/aiConversationLaunch.ts b/src/app/utils/aiConversationLaunch.ts index 1b2457b32..d73e3e8a8 100644 --- a/src/app/utils/aiConversationLaunch.ts +++ b/src/app/utils/aiConversationLaunch.ts @@ -5,7 +5,7 @@ import type { AIConversationLaunchContext, AIConversationMode, AIConversationSelection, -} from '@/features/ai-assistant/types'; +} from '@/features/ai-assistant'; export function cloneAISnapshot(value: T): T { if (typeof structuredClone === 'function') { diff --git a/src/app/utils/applyEditableSourceChange.ts b/src/app/utils/applyEditableSourceChange.ts index 7f02abe61..807d05946 100644 --- a/src/app/utils/applyEditableSourceChange.ts +++ b/src/app/utils/applyEditableSourceChange.ts @@ -1,4 +1,4 @@ -import type { SourceCodeDirtyRange } from '@/features/code-editor/utils/sourceCodeEditorSession'; +import type { SourceCodeDirtyRange } from '@/features/code-editor'; import type { RobotState } from '@/types'; import type { EditableSourceIncrementalPatch } from './editableSourceIncrementalPatch'; import { diff --git a/src/app/utils/documentLoadProgress.ts b/src/app/utils/documentLoadProgress.ts index a073e7ee1..d2683c35a 100644 --- a/src/app/utils/documentLoadProgress.ts +++ b/src/app/utils/documentLoadProgress.ts @@ -1,5 +1,5 @@ import type { RobotImportProgress } from '@/core/parsers/importRobotFile'; -import type { ViewerDocumentLoadEvent } from '@/features/urdf-viewer/types'; +import type { ViewerDocumentLoadEvent } from '@/features/editor'; import type { DocumentLoadState } from '@/store/assetsStore'; type DocumentLoadTrackedFormat = DocumentLoadState['format']; diff --git a/src/app/utils/editableSourceIncrementalPatchDetection.ts b/src/app/utils/editableSourceIncrementalPatchDetection.ts index a39c1129b..2af7bd6fc 100644 --- a/src/app/utils/editableSourceIncrementalPatchDetection.ts +++ b/src/app/utils/editableSourceIncrementalPatchDetection.ts @@ -2,7 +2,7 @@ import { parseMJCF } from '@/core/parsers/mjcf/mjcfParser'; import { parseJoints } from '@/core/parsers/urdf/parser/jointParser'; import { parseLinks } from '@/core/parsers/urdf/parser/linkParser'; import { parseMaterials } from '@/core/parsers/urdf/parser/materialParser'; -import type { SourceCodeDirtyRange } from '@/features/code-editor/utils/sourceCodeEditorSession'; +import type { SourceCodeDirtyRange } from '@/features/code-editor'; import type { RobotFile, RobotState, UrdfJoint, UrdfLink } from '@/types'; import type { EditableSourceIncrementalPatch } from './editableSourceIncrementalPatch'; diff --git a/src/app/utils/initialLanguage.test.ts b/src/app/utils/initialLanguage.test.ts new file mode 100644 index 000000000..2ce52c999 --- /dev/null +++ b/src/app/utils/initialLanguage.test.ts @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { JSDOM } from 'jsdom'; + +import { getLanguageFromPath, hideSeoLanguagePathFromUserUrl } from './initialLanguage.ts'; + +test('getLanguageFromPath recognizes only the SEO Chinese path prefix', () => { + assert.equal(getLanguageFromPath('/zh/'), 'zh'); + assert.equal(getLanguageFromPath('/zh'), 'zh'); + assert.equal(getLanguageFromPath('/zh/?from=search'), 'zh'); + assert.equal(getLanguageFromPath('/'), null); + assert.equal(getLanguageFromPath('/robots/zh/model'), null); +}); + +test('hideSeoLanguagePathFromUserUrl normalizes direct SEO-page visits for the app', () => { + const dom = new JSDOM('', { + url: 'https://urdf.enkeebot.com/zh/?asset=go2#viewer', + }); + const previousWindow = globalThis.window; + + (globalThis as { window?: Window }).window = dom.window as unknown as Window; + + try { + hideSeoLanguagePathFromUserUrl(); + + assert.equal(dom.window.location.pathname, '/'); + assert.equal(dom.window.location.search, '?asset=go2'); + assert.equal(dom.window.location.hash, '#viewer'); + } finally { + if (previousWindow === undefined) { + delete (globalThis as { window?: Window }).window; + } else { + (globalThis as { window?: Window }).window = previousWindow; + } + dom.window.close(); + } +}); diff --git a/src/app/utils/initialLanguage.ts b/src/app/utils/initialLanguage.ts new file mode 100644 index 000000000..adc945e3b --- /dev/null +++ b/src/app/utils/initialLanguage.ts @@ -0,0 +1,30 @@ +/** + * URL language helpers for the per-language static pages emitted by + * scripts/generate/seo_prerender.mjs. + */ +import type { Language } from '@/shared/i18n'; + +/** Returns the language encoded in a URL path, or null when the path carries no signal. */ +export function getLanguageFromPath(pathname: string): Language | null { + return /^\/zh(\/|$)/.test(pathname) ? 'zh' : null; +} + +/** Reads the URL language signal in the browser; null on the server or when absent. */ +export function getInitialLanguageFromUrl(): Language | null { + if (typeof window === 'undefined') return null; + return getLanguageFromPath(window.location.pathname); +} + +/** Keeps SEO-only language paths from remaining visible in the interactive app. */ +export function hideSeoLanguagePathFromUserUrl(): void { + if (typeof window === 'undefined' || typeof window.history?.replaceState !== 'function') { + return; + } + + if (getLanguageFromPath(window.location.pathname) === null) { + return; + } + + const nextUrl = `/${window.location.search}${window.location.hash}`; + window.history.replaceState(window.history.state, '', nextUrl); +} diff --git a/src/app/utils/unifiedViewerForcedSessionState.ts b/src/app/utils/unifiedViewerForcedSessionState.ts index 76efcc443..0435040a2 100644 --- a/src/app/utils/unifiedViewerForcedSessionState.ts +++ b/src/app/utils/unifiedViewerForcedSessionState.ts @@ -1,4 +1,4 @@ -import type { ToolMode } from '@/features/urdf-viewer/types'; +import type { ToolMode } from '@/features/editor'; interface ResolveUnifiedViewerForcedSessionStateArgs { forcedViewerSession: boolean; diff --git a/src/app/utils/unifiedViewerResourceScopes.ts b/src/app/utils/unifiedViewerResourceScopes.ts index d06450939..d11262989 100644 --- a/src/app/utils/unifiedViewerResourceScopes.ts +++ b/src/app/utils/unifiedViewerResourceScopes.ts @@ -1,7 +1,7 @@ import { createStableViewerResourceScope, type ViewerResourceScope, -} from '@/features/urdf-viewer/utils/viewerResourceScope'; +} from '@/features/editor'; import type { RobotFile, RobotMaterialState, UrdfLink } from '@/types'; interface UnifiedViewerFilePreview { diff --git a/src/app/utils/unifiedViewerSceneProps.test.ts b/src/app/utils/unifiedViewerSceneProps.test.ts index 885d31f55..6c697e903 100644 --- a/src/app/utils/unifiedViewerSceneProps.test.ts +++ b/src/app/utils/unifiedViewerSceneProps.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { ViewerController, ViewerProps } from '@/features/editor'; -import type { ViewerResourceScope } from '@/features/urdf-viewer/utils/viewerResourceScope'; +import type { ViewerResourceScope } from '@/features/editor'; import type { AssemblyState, RobotState } from '@/types'; import { buildUnifiedViewerSceneProps, EMPTY_VIEWER_SELECTION } from './unifiedViewerSceneProps'; diff --git a/src/app/utils/unifiedViewerSceneProps.ts b/src/app/utils/unifiedViewerSceneProps.ts index c984a4016..94f8a5645 100644 --- a/src/app/utils/unifiedViewerSceneProps.ts +++ b/src/app/utils/unifiedViewerSceneProps.ts @@ -1,11 +1,11 @@ import type { Object3D as ThreeObject3D } from 'three'; -import type { ViewerDocumentLoadEvent, ViewerProps } from '@/features/urdf-viewer/types'; -import type { ViewerController } from '@/features/urdf-viewer/hooks/useViewerController'; +import type { ViewerDocumentLoadEvent, ViewerProps } from '@/features/editor'; +import type { ViewerController } from '@/features/editor'; import { buildViewerSceneProps, type ViewerSceneBaseProps, -} from '@/features/urdf-viewer/utils/viewerSceneProps'; -import type { ViewerResourceScope } from '@/features/urdf-viewer/utils/viewerResourceScope'; +} from '@/features/editor'; +import type { ViewerResourceScope } from '@/features/editor'; import type { AssemblyState, AssemblyTransform, RobotData, RobotFile } from '@/types'; import type { AssemblySelection } from '@/store/assemblySelectionStore'; diff --git a/src/app/utils/usdHydrationWorkerEvents.ts b/src/app/utils/usdHydrationWorkerEvents.ts index 1345aa51f..0bd324c74 100644 --- a/src/app/utils/usdHydrationWorkerEvents.ts +++ b/src/app/utils/usdHydrationWorkerEvents.ts @@ -1,5 +1,5 @@ import type { UsdOffscreenViewerWorkerResponse } from '@/features/urdf-viewer/utils/usdOffscreenViewerProtocol'; -import type { ViewerDocumentLoadEvent } from '@/features/urdf-viewer/types'; +import type { ViewerDocumentLoadEvent } from '@/features/editor'; import { recordUsdStageLoadDebug } from '@/shared/debug/usdStageLoadDebug'; interface HandleUsdHydrationWorkerEventOptions { diff --git a/src/architecture-boundaries.test.ts b/src/architecture-boundaries.test.ts index c262b3af4..e27ceec85 100644 --- a/src/architecture-boundaries.test.ts +++ b/src/architecture-boundaries.test.ts @@ -60,6 +60,13 @@ const layerRanks = new Map([ ['app', 5], ]); +const siblingFeatureImportAllowlist = new Set([ + 'src/features/editor/index.ts -> ../urdf-viewer', + 'src/features/editor/viewerPanelModule.ts -> ../urdf-viewer/components/ViewerPanels', + 'src/features/editor/viewerPanelModule.ts -> ../urdf-viewer/hooks/useResponsivePanelLayout', + 'src/features/editor/viewerPanelModule.ts -> ../urdf-viewer/hooks/useViewerController', +]); + function getLayerName(repoPath: string): string | null { const match = /^src\/([^/]+)/.exec(repoPath); const layerName = match?.[1] ?? null; @@ -138,7 +145,7 @@ test('features do not import sibling feature internals', () => { continue; } - if (importerFeature === 'editor' && targetFeature === 'urdf-viewer') { + if (siblingFeatureImportAllowlist.has(`${importer} -> ${specifier}`)) { continue; } diff --git a/src/core/geometry/meshSnapPoints.test.ts b/src/core/geometry/meshSnapPoints.test.ts new file mode 100644 index 000000000..d5ca49761 --- /dev/null +++ b/src/core/geometry/meshSnapPoints.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as THREE from 'three'; + +import { + collectSnapCandidatesFromFace, + getFaceCenter, + getFaceNormal, + getNearestEdgeMidpointOnFace, + getNearestVertexInRadius, + getNearestVertexOnFace, +} from './meshSnapPoints.ts'; + +function vec(x: number, y: number, z: number): THREE.Vector3 { + return new THREE.Vector3(x, y, z); +} + +function assertVecNearlyEqual(actual: THREE.Vector3, expected: THREE.Vector3, message?: string) { + assert.ok(actual.distanceTo(expected) < 1e-6, message ?? `${actual.toArray()} !== ${expected.toArray()}`); +} + +function triangleGeometry(): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array([0, 0, 0, 2, 0, 0, 0, 2, 0]); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + return geometry; +} + +test('getFaceCenter returns the triangle centroid', () => { + assertVecNearlyEqual(getFaceCenter(triangleGeometry(), 0)!, vec(2 / 3, 2 / 3, 0)); +}); + +test('getFaceNormal returns the geometric normal', () => { + assertVecNearlyEqual(getFaceNormal(triangleGeometry(), 0)!, vec(0, 0, 1)); +}); + +test('getNearestVertexOnFace picks the closest triangle vertex', () => { + const geometry = triangleGeometry(); + assertVecNearlyEqual(getNearestVertexOnFace(geometry, 0, vec(0.1, 0.1, 0))!, vec(0, 0, 0)); + assertVecNearlyEqual(getNearestVertexOnFace(geometry, 0, vec(1.9, 0.1, 0))!, vec(2, 0, 0)); +}); + +test('getNearestEdgeMidpointOnFace picks the closest edge midpoint', () => { + assertVecNearlyEqual(getNearestEdgeMidpointOnFace(triangleGeometry(), 0, vec(1, -0.1, 0))!, vec(1, 0, 0)); +}); + +test('getNearestVertexInRadius respects the radius', () => { + const geometry = triangleGeometry(); + assertVecNearlyEqual(getNearestVertexInRadius(geometry, vec(0, 0, 0), 0.5)!, vec(0, 0, 0)); + assert.equal(getNearestVertexInRadius(geometry, vec(5, 5, 5), 0.5), null); +}); + +test('collectSnapCandidatesFromFace honors the filter and attaches the face normal', () => { + const candidates = collectSnapCandidatesFromFace(triangleGeometry(), 0, vec(0.1, 0.1, 0), ['faceCenter']); + assert.equal(candidates.length, 1); + assert.equal(candidates[0].kind, 'faceCenter'); + assertVecNearlyEqual(candidates[0].pointLocal, vec(2 / 3, 2 / 3, 0)); + assertVecNearlyEqual(candidates[0].normalLocal!, vec(0, 0, 1)); +}); + +test('collectSnapCandidatesFromFace returns all face kinds when filter is null', () => { + const candidates = collectSnapCandidatesFromFace(triangleGeometry(), 0, vec(0.1, 0.1, 0), null); + const kinds = candidates.map((candidate) => candidate.kind).sort(); + assert.deepEqual(kinds, ['edgeMidpoint', 'faceCenter', 'surface', 'vertex']); +}); diff --git a/src/core/geometry/meshSnapPoints.ts b/src/core/geometry/meshSnapPoints.ts new file mode 100644 index 000000000..3800f206e --- /dev/null +++ b/src/core/geometry/meshSnapPoints.ts @@ -0,0 +1,239 @@ +import * as THREE from 'three'; + +/** + * Snap point feature kinds for Fusion 360 style joint-origin picking. + * + * - `surface`: raw raycast hit point on the mesh surface. + * - `faceCenter`: centroid of the hit triangle. + * - `bboxCenter`: bounding box center of the whole geometry object. + * - `vertex`: nearest mesh vertex. + * - `edgeMidpoint`: midpoint of the nearest triangle edge. + * + * All functions here are pure and operate in the geometry's LOCAL space; the + * caller is responsible for transforming results to world space with the + * object's `matrixWorld`. + */ +export type SnapPointKind = 'surface' | 'faceCenter' | 'bboxCenter' | 'vertex' | 'edgeMidpoint'; + +export interface LocalSnapPoint { + kind: SnapPointKind; + pointLocal: THREE.Vector3; + /** Geometric (not interpolated) face normal in local space, when available. */ + normalLocal?: THREE.Vector3; +} + +const DEGENERATE_EPSILON_SQ = 1e-16; + +function getFaceVertexIndices( + geometry: THREE.BufferGeometry, + faceIndex: number, +): [number, number, number] | null { + if (!Number.isInteger(faceIndex) || faceIndex < 0) { + return null; + } + + const index = geometry.getIndex(); + const base = faceIndex * 3; + + if (index) { + if (base + 2 >= index.count) { + return null; + } + return [index.getX(base), index.getX(base + 1), index.getX(base + 2)]; + } + + const position = geometry.getAttribute('position'); + if (!position || base + 2 >= position.count) { + return null; + } + return [base, base + 1, base + 2]; +} + +function getVertex(position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, i: number) { + return new THREE.Vector3().fromBufferAttribute(position, i); +} + +/** Returns the three local-space vertices of the triangle at `faceIndex`. */ +export function getFaceVertices( + geometry: THREE.BufferGeometry, + faceIndex: number, +): [THREE.Vector3, THREE.Vector3, THREE.Vector3] | null { + const indices = getFaceVertexIndices(geometry, faceIndex); + const position = geometry.getAttribute('position'); + if (!indices || !position) { + return null; + } + + // Guard against malformed index buffers so a stray index never reads + // `undefined` from the attribute and propagates NaN into the alignment math. + if (indices.some((vertexIndex) => vertexIndex < 0 || vertexIndex >= position.count)) { + return null; + } + + return [getVertex(position, indices[0]), getVertex(position, indices[1]), getVertex(position, indices[2])]; +} + +/** Centroid of the triangle at `faceIndex` (local space). */ +export function getFaceCenter(geometry: THREE.BufferGeometry, faceIndex: number): THREE.Vector3 | null { + const vertices = getFaceVertices(geometry, faceIndex); + if (!vertices) { + return null; + } + + return vertices[0].clone().add(vertices[1]).add(vertices[2]).multiplyScalar(1 / 3); +} + +/** + * Geometric face normal at `faceIndex` (local space). Uses the cross product of + * the triangle edges rather than interpolated vertex normals so the resulting + * frame stays flat and stable for face-to-face mating. + */ +export function getFaceNormal(geometry: THREE.BufferGeometry, faceIndex: number): THREE.Vector3 | null { + const vertices = getFaceVertices(geometry, faceIndex); + if (!vertices) { + return null; + } + + const edge1 = vertices[1].clone().sub(vertices[0]); + const edge2 = vertices[2].clone().sub(vertices[0]); + const normal = new THREE.Vector3().crossVectors(edge1, edge2); + if (normal.lengthSq() < DEGENERATE_EPSILON_SQ) { + return null; + } + + return normal.normalize(); +} + +/** Of the three triangle vertices, the one closest to `localPoint`. */ +export function getNearestVertexOnFace( + geometry: THREE.BufferGeometry, + faceIndex: number, + localPoint: THREE.Vector3, +): THREE.Vector3 | null { + const vertices = getFaceVertices(geometry, faceIndex); + if (!vertices) { + return null; + } + + let nearest = vertices[0]; + let nearestDistance = nearest.distanceToSquared(localPoint); + for (let i = 1; i < vertices.length; i += 1) { + const distance = vertices[i].distanceToSquared(localPoint); + if (distance < nearestDistance) { + nearest = vertices[i]; + nearestDistance = distance; + } + } + + return nearest.clone(); +} + +/** Of the three triangle edge midpoints, the one closest to `localPoint`. */ +export function getNearestEdgeMidpointOnFace( + geometry: THREE.BufferGeometry, + faceIndex: number, + localPoint: THREE.Vector3, +): THREE.Vector3 | null { + const vertices = getFaceVertices(geometry, faceIndex); + if (!vertices) { + return null; + } + + const midpoints = [ + vertices[0].clone().add(vertices[1]).multiplyScalar(0.5), + vertices[1].clone().add(vertices[2]).multiplyScalar(0.5), + vertices[2].clone().add(vertices[0]).multiplyScalar(0.5), + ]; + + let nearest = midpoints[0]; + let nearestDistance = nearest.distanceToSquared(localPoint); + for (let i = 1; i < midpoints.length; i += 1) { + const distance = midpoints[i].distanceToSquared(localPoint); + if (distance < nearestDistance) { + nearest = midpoints[i]; + nearestDistance = distance; + } + } + + return nearest; +} + +/** + * Nearest vertex in the whole geometry within `radius` of `localPoint`. + * + * No spatial index (three-mesh-bvh is intentionally not a dependency), so high + * vertex counts are bounded by `maxSamples` stepped sampling. Prefer + * {@link getNearestVertexOnFace} for the common case; this is the opt-in global + * vertex snap. + */ +export function getNearestVertexInRadius( + geometry: THREE.BufferGeometry, + localPoint: THREE.Vector3, + radius: number, + maxSamples = 20000, +): THREE.Vector3 | null { + const position = geometry.getAttribute('position'); + if (!position || !(radius > 0)) { + return null; + } + + const radiusSq = radius * radius; + const step = Math.max(1, Math.ceil(position.count / Math.max(1, maxSamples))); + const candidate = new THREE.Vector3(); + let nearest: THREE.Vector3 | null = null; + let nearestDistance = radiusSq; + + for (let i = 0; i < position.count; i += step) { + candidate.fromBufferAttribute(position, i); + const distance = candidate.distanceToSquared(localPoint); + if (distance < nearestDistance) { + nearest = candidate.clone(); + nearestDistance = distance; + } + } + + return nearest; +} + +/** + * Collect the candidate snap points for the hit triangle, filtered by `filter` + * (null = all face-level kinds). `surface` and `bboxCenter` are produced by the + * caller (raw hit / object bounds), not here. + */ +export function collectSnapCandidatesFromFace( + geometry: THREE.BufferGeometry, + faceIndex: number, + localHit: THREE.Vector3, + filter: SnapPointKind[] | null, +): LocalSnapPoint[] { + const includes = (kind: SnapPointKind) => !filter || filter.includes(kind); + const normalLocal = getFaceNormal(geometry, faceIndex) ?? undefined; + const candidates: LocalSnapPoint[] = []; + + if (includes('surface')) { + candidates.push({ kind: 'surface', pointLocal: localHit.clone(), normalLocal }); + } + + if (includes('faceCenter')) { + const faceCenter = getFaceCenter(geometry, faceIndex); + if (faceCenter) { + candidates.push({ kind: 'faceCenter', pointLocal: faceCenter, normalLocal }); + } + } + + if (includes('vertex')) { + const vertex = getNearestVertexOnFace(geometry, faceIndex, localHit); + if (vertex) { + candidates.push({ kind: 'vertex', pointLocal: vertex, normalLocal }); + } + } + + if (includes('edgeMidpoint')) { + const edgeMidpoint = getNearestEdgeMidpointOnFace(geometry, faceIndex, localHit); + if (edgeMidpoint) { + candidates.push({ kind: 'edgeMidpoint', pointLocal: edgeMidpoint, normalLocal }); + } + } + + return candidates; +} diff --git a/src/core/geometry/snapGeometry.test.ts b/src/core/geometry/snapGeometry.test.ts new file mode 100644 index 000000000..8a60eff7e --- /dev/null +++ b/src/core/geometry/snapGeometry.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as THREE from 'three'; + +import { + computeEdgeIntersectionFrame, + computeMidPlaneFrame, + makeFrameFromPointAndNormal, +} from './snapGeometry.ts'; + +function vec(x: number, y: number, z: number): THREE.Vector3 { + return new THREE.Vector3(x, y, z); +} + +function assertVecNearlyEqual(actual: THREE.Vector3, expected: THREE.Vector3, message?: string) { + assert.ok(actual.distanceTo(expected) < 1e-6, message ?? `${actual.toArray()} !== ${expected.toArray()}`); +} + +function assertNearly(actual: number, expected: number, message?: string) { + assert.ok(Math.abs(actual - expected) < 1e-6, message ?? `${actual} !== ${expected}`); +} + +test('makeFrameFromPointAndNormal builds an orthonormal right-handed frame at the point', () => { + const frame = makeFrameFromPointAndNormal(vec(1, 2, 3), vec(0, 0, 2)); + + assertVecNearlyEqual(new THREE.Vector3().setFromMatrixPosition(frame), vec(1, 2, 3)); + + const x = new THREE.Vector3(); + const y = new THREE.Vector3(); + const z = new THREE.Vector3(); + frame.extractBasis(x, y, z); + + assertVecNearlyEqual(z, vec(0, 0, 1), 'Z axis should equal the normalized normal'); + assertNearly(x.length(), 1); + assertNearly(y.length(), 1); + assertNearly(x.dot(y), 0); + assertNearly(x.dot(z), 0); + assertNearly(y.dot(z), 0); + assertVecNearlyEqual(new THREE.Vector3().crossVectors(x, y), z, 'frame should be right-handed'); +}); + +test('makeFrameFromPointAndNormal honors the tangent hint', () => { + const frame = makeFrameFromPointAndNormal(vec(0, 0, 0), vec(0, 0, 1), vec(1, 0, 0)); + const x = new THREE.Vector3(); + const y = new THREE.Vector3(); + const z = new THREE.Vector3(); + frame.extractBasis(x, y, z); + assertVecNearlyEqual(x, vec(1, 0, 0)); +}); + +test('computeMidPlaneFrame averages parallel planes at their midpoint', () => { + const frame = computeMidPlaneFrame( + { point: vec(0, 0, 0), normal: vec(0, 0, 1) }, + { point: vec(0, 0, 2), normal: vec(0, 0, 1) }, + ); + assert.ok(frame); + assertVecNearlyEqual(new THREE.Vector3().setFromMatrixPosition(frame!), vec(0, 0, 1)); + + const z = new THREE.Vector3(); + frame!.extractBasis(new THREE.Vector3(), new THREE.Vector3(), z); + assertVecNearlyEqual(z, vec(0, 0, 1)); +}); + +test('computeMidPlaneFrame aligns opposing face normals before averaging', () => { + const frame = computeMidPlaneFrame( + { point: vec(0, 0, 0), normal: vec(0, 0, 1) }, + { point: vec(0, 0, 2), normal: vec(0, 0, -1) }, + ); + assert.ok(frame); + assertVecNearlyEqual(new THREE.Vector3().setFromMatrixPosition(frame!), vec(0, 0, 1)); + + const z = new THREE.Vector3(); + frame!.extractBasis(new THREE.Vector3(), new THREE.Vector3(), z); + assertVecNearlyEqual(z, vec(0, 0, 1)); +}); + +test('computeEdgeIntersectionFrame finds the closest point of skew edges', () => { + const frame = computeEdgeIntersectionFrame( + { origin: vec(0, 0, 0), direction: vec(1, 0, 0) }, + { origin: vec(0, 0, 2), direction: vec(0, 1, 0) }, + ); + assert.ok(frame); + assertVecNearlyEqual(new THREE.Vector3().setFromMatrixPosition(frame!), vec(0, 0, 1)); + + const x = new THREE.Vector3(); + frame!.extractBasis(x, new THREE.Vector3(), new THREE.Vector3()); + assertVecNearlyEqual(x, vec(1, 0, 0), 'X axis should follow edge A'); +}); + +test('computeEdgeIntersectionFrame returns null for parallel edges', () => { + const frame = computeEdgeIntersectionFrame( + { origin: vec(0, 0, 0), direction: vec(1, 0, 0) }, + { origin: vec(0, 1, 0), direction: vec(1, 0, 0) }, + ); + assert.equal(frame, null); +}); diff --git a/src/core/geometry/snapGeometry.ts b/src/core/geometry/snapGeometry.ts new file mode 100644 index 000000000..e1e0e3326 --- /dev/null +++ b/src/core/geometry/snapGeometry.ts @@ -0,0 +1,122 @@ +import * as THREE from 'three'; + +/** A picked planar face sample in world space. */ +export interface PlaneSample { + point: THREE.Vector3; + normal: THREE.Vector3; +} + +/** A picked edge sample in world space (an infinite line through `origin`). */ +export interface EdgeSample { + origin: THREE.Vector3; + direction: THREE.Vector3; +} + +const DIRECTION_EPSILON = 1e-8; +const PARALLEL_EPSILON = 1e-7; + +/** + * Build a right-handed rigid frame (Matrix4) whose +Z axis is `normal`, located + * at `point`. The tangent (X) axis follows `hintTangent` projected onto the + * plane when supplied, otherwise an arbitrary stable axis is chosen. This is the + * single source of truth for "point + normal -> joint origin frame". + */ +export function makeFrameFromPointAndNormal( + point: THREE.Vector3, + normal: THREE.Vector3, + hintTangent?: THREE.Vector3, +): THREE.Matrix4 { + const z = normal.clone(); + if (z.lengthSq() < DIRECTION_EPSILON) { + z.set(0, 0, 1); + } else { + z.normalize(); + } + + let x = new THREE.Vector3(); + if (hintTangent) { + x.copy(hintTangent).sub(z.clone().multiplyScalar(hintTangent.dot(z))); + } + if (x.lengthSq() < DIRECTION_EPSILON) { + const fallback = Math.abs(z.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); + x = fallback.sub(z.clone().multiplyScalar(fallback.dot(z))); + } + x.normalize(); + + const y = new THREE.Vector3().crossVectors(z, x).normalize(); + // Re-orthogonalize X to guarantee an exact orthonormal basis. + x.crossVectors(y, z).normalize(); + + return new THREE.Matrix4().makeBasis(x, y, z).setPosition(point); +} + +/** + * "Between two faces": frame located at the midpoint of the two picked points, + * oriented by the averaged (hemisphere-aligned) normal. The result +Z follows + * the FIRST plane's hemisphere (plane `a` is the orientation anchor), so the + * pick order is significant; the alignment Flip control inverts it when needed. + * Returns null when the combined normal is degenerate. + */ +export function computeMidPlaneFrame(a: PlaneSample, b: PlaneSample): THREE.Matrix4 | null { + const normalA = a.normal.clone(); + const normalB = b.normal.clone(); + if (normalA.lengthSq() < DIRECTION_EPSILON || normalB.lengthSq() < DIRECTION_EPSILON) { + return null; + } + normalA.normalize(); + normalB.normalize(); + + // Align B into A's hemisphere so opposing face normals still average sensibly. + if (normalA.dot(normalB) < 0) { + normalB.negate(); + } + + const averageNormal = normalA.add(normalB); + if (averageNormal.lengthSq() < DIRECTION_EPSILON) { + return null; + } + averageNormal.normalize(); + + const midpoint = a.point.clone().add(b.point).multiplyScalar(0.5); + return makeFrameFromPointAndNormal(midpoint, averageNormal); +} + +/** + * "Two edges intersection": frame at the closest point between the two (possibly + * skew) edge lines. +X follows edge A, +Z is the common perpendicular + * (edgeA x edgeB). Returns null when the edges are parallel. + */ +export function computeEdgeIntersectionFrame(a: EdgeSample, b: EdgeSample): THREE.Matrix4 | null { + const d1 = a.direction.clone(); + const d2 = b.direction.clone(); + if (d1.lengthSq() < DIRECTION_EPSILON || d2.lengthSq() < DIRECTION_EPSILON) { + return null; + } + d1.normalize(); + d2.normalize(); + + const perpendicular = new THREE.Vector3().crossVectors(d1, d2); + if (perpendicular.lengthSq() < PARALLEL_EPSILON) { + return null; + } + + // Closest points between line A (a.origin + s d1) and line B (b.origin + t d2). + const w0 = a.origin.clone().sub(b.origin); + const aa = d1.dot(d1); + const bb = d1.dot(d2); + const cc = d2.dot(d2); + const dd = d1.dot(w0); + const ee = d2.dot(w0); + const denom = aa * cc - bb * bb; + if (Math.abs(denom) < PARALLEL_EPSILON) { + return null; + } + + const s = (bb * ee - cc * dd) / denom; + const t = (aa * ee - bb * dd) / denom; + const closestA = a.origin.clone().add(d1.clone().multiplyScalar(s)); + const closestB = b.origin.clone().add(d2.clone().multiplyScalar(t)); + const intersection = closestA.add(closestB).multiplyScalar(0.5); + + return makeFrameFromPointAndNormal(intersection, perpendicular.normalize(), d1); +} diff --git a/src/core/robot/jointPickAlignment.test.ts b/src/core/robot/jointPickAlignment.test.ts new file mode 100644 index 000000000..9ac0fc64a --- /dev/null +++ b/src/core/robot/jointPickAlignment.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as THREE from 'three'; + +import { createOriginMatrix } from './kinematics.ts'; +import { + buildJointAlignmentDeltaMatrix, + computeBridgeOriginFromSnapFrames, + computePointCoincidentOrigin, + type JointAlignmentDelta, +} from './jointPickAlignment.ts'; + +function rigid(position: [number, number, number], euler: [number, number, number]): THREE.Matrix4 { + return new THREE.Matrix4().compose( + new THREE.Vector3(position[0], position[1], position[2]), + new THREE.Quaternion().setFromEuler(new THREE.Euler(euler[0], euler[1], euler[2], 'ZYX')), + new THREE.Vector3(1, 1, 1), + ); +} + +function assertMatrixNearlyEqual(actual: THREE.Matrix4, expected: THREE.Matrix4, message?: string) { + for (let i = 0; i < 16; i += 1) { + assert.ok( + Math.abs(actual.elements[i] - expected.elements[i]) < 1e-6, + message ?? `matrix element ${i}: ${actual.elements[i]} !== ${expected.elements[i]}`, + ); + } +} + +function snapRelativeToLink(linkWorld: THREE.Matrix4, snapWorld: THREE.Matrix4): THREE.Matrix4 { + return linkWorld.clone().invert().multiply(snapWorld); +} + +const PARENT_LINK = rigid([1, 2, 3], [0.1, 0.2, 0.3]); +const CHILD_LINK = rigid([5, -1, 2], [-0.2, 0.4, 0.1]); +const PARENT_SNAP = rigid([1.5, 2.2, 3.1], [0.5, 0, 0.2]); +const CHILD_SNAP = rigid([4.8, -1.1, 2.3], [0.1, -0.3, 0.6]); + +test('computeBridgeOriginFromSnapFrames makes the child snap frame coincide with the parent snap frame', () => { + const { matrix: origin } = computeBridgeOriginFromSnapFrames({ + parentSnapWorld: PARENT_SNAP, + childSnapWorld: CHILD_SNAP, + parentLinkWorld: PARENT_LINK, + childLinkWorld: CHILD_LINK, + }); + + const childLinkWorldNew = PARENT_LINK.clone().multiply(origin); + const snapRelLink = snapRelativeToLink(CHILD_LINK, CHILD_SNAP); + const childSnapWorldNew = childLinkWorldNew.multiply(snapRelLink); + + assertMatrixNearlyEqual(childSnapWorldNew, PARENT_SNAP); +}); + +test('computeBridgeOriginFromSnapFrames applies the alignment delta in the snap frame', () => { + const alignment: JointAlignmentDelta = { + angleRad: 0.3, + offset: { x: 0.1, y: -0.2, z: 0.05 }, + flip: true, + }; + + const { matrix: origin } = computeBridgeOriginFromSnapFrames({ + parentSnapWorld: PARENT_SNAP, + childSnapWorld: CHILD_SNAP, + parentLinkWorld: PARENT_LINK, + childLinkWorld: CHILD_LINK, + alignment, + }); + + const childLinkWorldNew = PARENT_LINK.clone().multiply(origin); + const snapRelLink = snapRelativeToLink(CHILD_LINK, CHILD_SNAP); + const childSnapWorldNew = childLinkWorldNew.multiply(snapRelLink); + + const expected = PARENT_SNAP.clone().multiply(buildJointAlignmentDeltaMatrix(alignment)); + assertMatrixNearlyEqual(childSnapWorldNew, expected); +}); + +test('computeBridgeOriginFromSnapFrames returns a transform that reconstructs its matrix (ZYX closed)', () => { + const { matrix, transform } = computeBridgeOriginFromSnapFrames({ + parentSnapWorld: PARENT_SNAP, + childSnapWorld: CHILD_SNAP, + parentLinkWorld: PARENT_LINK, + childLinkWorld: CHILD_LINK, + }); + + const reconstructed = createOriginMatrix({ xyz: transform.position, rpy: transform.rotation }); + assertMatrixNearlyEqual(reconstructed, matrix); +}); + +test('computePointCoincidentOrigin meets the points and preserves child orientation', () => { + const parentPoint = new THREE.Vector3(1.5, 2.2, 3.1); + const childPoint = new THREE.Vector3(4.8, -1.1, 2.3); + + const { matrix: origin } = computePointCoincidentOrigin({ + parentSnapPointWorld: parentPoint, + childSnapPointWorld: childPoint, + parentLinkWorld: PARENT_LINK, + childLinkWorld: CHILD_LINK, + }); + + const childLinkWorldNew = PARENT_LINK.clone().multiply(origin); + const childPointRelLink = childPoint.clone().applyMatrix4(CHILD_LINK.clone().invert()); + const childPointNew = childPointRelLink.applyMatrix4(childLinkWorldNew); + + assert.ok(childPointNew.distanceTo(parentPoint) < 1e-6, 'snap points should coincide'); + + const orientationOld = new THREE.Quaternion().setFromRotationMatrix(CHILD_LINK); + const orientationNew = new THREE.Quaternion().setFromRotationMatrix(childLinkWorldNew); + assert.ok(orientationOld.angleTo(orientationNew) < 1e-6, 'child orientation should be preserved'); +}); + +test('buildJointAlignmentDeltaMatrix is identity for the zero alignment', () => { + const identity = buildJointAlignmentDeltaMatrix({ + angleRad: 0, + offset: { x: 0, y: 0, z: 0 }, + flip: false, + }); + assertMatrixNearlyEqual(identity, new THREE.Matrix4()); +}); diff --git a/src/core/robot/jointPickAlignment.ts b/src/core/robot/jointPickAlignment.ts new file mode 100644 index 000000000..112cd06d2 --- /dev/null +++ b/src/core/robot/jointPickAlignment.ts @@ -0,0 +1,113 @@ +import * as THREE from 'three'; + +import type { AssemblyTransform } from '@/types'; + +import { decomposeAssemblyTransformMatrix } from './assemblyBridgeAlignment'; + +/** + * Fusion 360 style "Joint Alignment" adjustment, expressed in the picked snap + * (joint origin) frame: rotate about the joint axis (snap +Z), translate along + * the snap axes, and optionally flip 180 deg about snap +X. + */ +export interface JointAlignmentDelta { + angleRad: number; + offset: { x: number; y: number; z: number }; + flip: boolean; +} + +export const IDENTITY_JOINT_ALIGNMENT: JointAlignmentDelta = { + angleRad: 0, + offset: { x: 0, y: 0, z: 0 }, + flip: false, +}; + +export interface BridgeOriginFromSnapParams { + /** World pose of the parent snap (joint origin) frame. */ + parentSnapWorld: THREE.Matrix4; + /** World pose of the child snap frame, at the child's CURRENT transform. */ + childSnapWorld: THREE.Matrix4; + /** World pose of the parent link frame. */ + parentLinkWorld: THREE.Matrix4; + /** World pose of the child link frame, at the child's CURRENT transform. */ + childLinkWorld: THREE.Matrix4; + /** Joint alignment adjustment in the snap frame. Defaults to identity. */ + alignment?: JointAlignmentDelta; +} + +export interface BridgeOriginResult { + matrix: THREE.Matrix4; + transform: AssemblyTransform; +} + +/** + * Build the alignment delta matrix in the snap frame: + * T(offset) x Rz(angle) x Rx(flip ? pi : 0) + */ +export function buildJointAlignmentDeltaMatrix(alignment: JointAlignmentDelta): THREE.Matrix4 { + const translation = new THREE.Matrix4().makeTranslation( + alignment.offset.x, + alignment.offset.y, + alignment.offset.z, + ); + const rotationZ = new THREE.Matrix4().makeRotationZ(alignment.angleRad); + const flip = new THREE.Matrix4().makeRotationX(alignment.flip ? Math.PI : 0); + + return translation.multiply(rotationZ).multiply(flip); +} + +/** + * Compute the bridge joint origin (child link pose expressed in the parent link + * frame) such that the child snap frame coincides with the parent snap frame + * (modulo the alignment delta) after the child component is re-aligned. + * + * Derivation (all matrices in the same world space; origin is the parent-link -> + * child-link relative pose, so it is invariant to any shared rigid transform): + * origin = parentLinkWorld^-1 . parentSnapWorld . alignment . childSnapWorld^-1 . childLinkWorld + * + * This is the exact inverse of `resolveAlignedAssemblyComponentTransformForBridge`, + * whose contract is `createOriginMatrix(origin) = parentLinkWorld^-1 . childLinkWorld`. + */ +export function computeBridgeOriginFromSnapFrames( + params: BridgeOriginFromSnapParams, +): BridgeOriginResult { + const alignment = params.alignment ?? IDENTITY_JOINT_ALIGNMENT; + const alignmentMatrix = buildJointAlignmentDeltaMatrix(alignment); + + const matrix = new THREE.Matrix4() + .copy(params.parentLinkWorld) + .invert() + .multiply(params.parentSnapWorld) + .multiply(alignmentMatrix) + .multiply(new THREE.Matrix4().copy(params.childSnapWorld).invert()) + .multiply(params.childLinkWorld); + + return { matrix, transform: decomposeAssemblyTransformMatrix(matrix) }; +} + +export interface PointCoincidentOriginParams { + parentSnapPointWorld: THREE.Vector3; + childSnapPointWorld: THREE.Vector3; + parentLinkWorld: THREE.Matrix4; + childLinkWorld: THREE.Matrix4; +} + +/** + * "Point coincident" variant: translate the child so the two snap points meet + * while preserving the child's current orientation (no normal alignment). + * origin = parentLinkWorld^-1 . T(parentPoint - childPoint) . childLinkWorld + */ +export function computePointCoincidentOrigin( + params: PointCoincidentOriginParams, +): BridgeOriginResult { + const delta = params.parentSnapPointWorld.clone().sub(params.childSnapPointWorld); + const childLinkWorldNew = new THREE.Matrix4() + .makeTranslation(delta.x, delta.y, delta.z) + .multiply(params.childLinkWorld); + + const matrix = new THREE.Matrix4() + .copy(params.parentLinkWorld) + .invert() + .multiply(childLinkWorldNew); + + return { matrix, transform: decomposeAssemblyTransformMatrix(matrix) }; +} diff --git a/src/features/ai-assistant/index.ts b/src/features/ai-assistant/index.ts index cf0f7ba11..84ad02140 100644 --- a/src/features/ai-assistant/index.ts +++ b/src/features/ai-assistant/index.ts @@ -32,6 +32,7 @@ export type { InspectionIssue, AIConversationMode, AIConversationMessage, + AIConversationFocusedIssue, AIConversationSelection, AIConversationLaunchContext, AIConversationTurnResult, diff --git a/src/features/assembly/components/bridge-create/BridgeCreateModal.tsx b/src/features/assembly/components/bridge-create/BridgeCreateModal.tsx index 43449a822..d28ce9890 100644 --- a/src/features/assembly/components/bridge-create/BridgeCreateModal.tsx +++ b/src/features/assembly/components/bridge-create/BridgeCreateModal.tsx @@ -41,6 +41,7 @@ import { } from './bridgeCreateModalUtils'; import { useBridgeCreateDraft } from './useBridgeCreateDraft'; import { useBridgeCreateSelectionSync } from './useBridgeCreateSelectionSync'; +import { useJointPickController } from './useJointPickController'; export type { BridgeCreateModalProps } from './bridgeCreateModalTypes'; @@ -145,6 +146,7 @@ export const BridgeCreateModal: React.FC = ({ const { applyEulerRotation, + applyPickedOrigin, applyQuaternionRotation, applySuggestedOrigin, axisX, @@ -572,6 +574,30 @@ export const BridgeCreateModal: React.FC = ({ setPickTarget, }); + const jointPick = useJointPickController({ + isOpen, + parentComponentId: parentCompId, + parentLinkId, + childComponentId: childCompId, + childLinkId, + applyPickedOrigin, + }); + const canPickJointOrigin = Boolean( + parentCompId && childCompId && parentLinkId && childLinkId && parentCompId !== childCompId, + ); + const jointPickHintLabel = + lang === 'zh' + ? jointPick.active + ? jointPick.side === 'parent' + ? '在父组件上单击拾取联接点' + : '在子组件上单击拾取联接点' + : '拾取联接点后自动对齐' + : jointPick.active + ? jointPick.side === 'parent' + ? 'Click the parent component to place the joint origin' + : 'Click the child component to place the joint origin' + : 'Pick joint origins to auto-align'; + const namePlaceholder = suggestedBridgeName || t.bridgeJointNamePlaceholder; useEffect(() => { @@ -896,6 +922,53 @@ export const BridgeCreateModal: React.FC = ({
+
+
+ + +
+

+ {canPickJointOrigin + ? jointPickHintLabel + : lang === 'zh' + ? '先选择父/子组件与 Link' + : 'Select parent/child components and links first'} +

+
{ + originDirtyRef.current = true; + setOriginX(position.x); + setOriginY(position.y); + setOriginZ(position.z); + applyEulerRotation(rotationDeg); + }, + [applyEulerRotation], + ); + const handleOriginXChange = useCallback((value: number) => { originDirtyRef.current = true; setOriginX(value); @@ -153,6 +169,7 @@ export function useBridgeCreateDraft({ return { applyEulerRotation, + applyPickedOrigin, applyQuaternionRotation, applySuggestedOrigin, axisX, diff --git a/src/features/assembly/components/bridge-create/useJointPickController.ts b/src/features/assembly/components/bridge-create/useJointPickController.ts new file mode 100644 index 000000000..e74a2738f --- /dev/null +++ b/src/features/assembly/components/bridge-create/useJointPickController.ts @@ -0,0 +1,164 @@ +import { useCallback, useEffect } from 'react'; +import * as THREE from 'three'; + +import { radToDeg } from '@/core/robot/transforms'; +import { + computeBridgeOriginFromSnapFrames, + computePointCoincidentOrigin, + type JointAlignmentDelta, +} from '@/core/robot/jointPickAlignment'; +import { + useJointPickSessionStore, + type JointPickSide, +} from '@/store/jointPickSessionStore'; +import { useSelectionStore } from '@/store/selectionStore'; + +export type JointAlignMode = 'smartAlign' | 'pointCoincident'; + +interface UseJointPickControllerOptions { + isOpen: boolean; + parentComponentId: string; + parentLinkId: string; + childComponentId: string; + childLinkId: string; + alignment?: JointAlignmentDelta; + alignMode?: JointAlignMode; + applyPickedOrigin: ( + position: { x: number; y: number; z: number }, + rotationDeg: { r: number; p: number; y: number }, + ) => void; +} + +function matrixFrom(serialized: number[]): THREE.Matrix4 { + return new THREE.Matrix4().fromArray(serialized); +} + +/** + * Bridges the joint-pick session store and the bridge-create draft: mirrors the + * relation into the session, blocks normal selection while picking, and converts + * the two committed snap frames into the bridge joint origin (xyz + rpy). + */ +export function useJointPickController({ + isOpen, + parentComponentId, + parentLinkId, + childComponentId, + childLinkId, + alignment, + alignMode = 'smartAlign', + applyPickedOrigin, +}: UseJointPickControllerOptions) { + const active = useJointPickSessionStore((state) => state.active); + const side = useJointPickSessionStore((state) => state.side); + const mode = useJointPickSessionStore((state) => state.mode); + const snapFilter = useJointPickSessionStore((state) => state.snapFilter); + const parentSnap = useJointPickSessionStore((state) => state.parentSnap); + const childSnap = useJointPickSessionStore((state) => state.childSnap); + const startPickAction = useJointPickSessionStore((state) => state.startPick); + const setActive = useJointPickSessionStore((state) => state.setActive); + const setModeAction = useJointPickSessionStore((state) => state.setMode); + const setSnapFilterAction = useJointPickSessionStore((state) => state.setSnapFilter); + const setRelation = useJointPickSessionStore((state) => state.setRelation); + const clearSideAction = useJointPickSessionStore((state) => state.clearSide); + const reset = useJointPickSessionStore((state) => state.reset); + + const setInteractionGuard = useSelectionStore((state) => state.setInteractionGuard); + + // Mirror the chosen relation so the pick layer can reject hits on the wrong + // component/link, and so changing the relation drops now-stale snaps. + useEffect(() => { + if (!isOpen) { + return; + } + setRelation( + parentComponentId || null, + parentLinkId || null, + childComponentId || null, + childLinkId || null, + ); + }, [isOpen, parentComponentId, parentLinkId, childComponentId, childLinkId, setRelation]); + + // Tear the session down when the modal closes. + useEffect(() => { + if (isOpen) { + return; + } + reset(); + }, [isOpen, reset]); + + // While a snap pick is in progress, suppress normal viewer selection so a + // click captures a snap point instead of re-selecting/altering the relation. + useEffect(() => { + if (!isOpen || !active) { + return undefined; + } + setInteractionGuard(() => false); + return () => setInteractionGuard(null); + }, [isOpen, active, setInteractionGuard]); + + // Derive the joint origin whenever both sides have a committed snap. + useEffect(() => { + if (!isOpen || !parentSnap || !childSnap) { + return; + } + + const parentLinkWorld = matrixFrom(parentSnap.linkWorldMatrix); + const childLinkWorld = matrixFrom(childSnap.linkWorldMatrix); + + const result = + alignMode === 'pointCoincident' + ? computePointCoincidentOrigin({ + parentSnapPointWorld: new THREE.Vector3( + parentSnap.pointWorld.x, + parentSnap.pointWorld.y, + parentSnap.pointWorld.z, + ), + childSnapPointWorld: new THREE.Vector3( + childSnap.pointWorld.x, + childSnap.pointWorld.y, + childSnap.pointWorld.z, + ), + parentLinkWorld, + childLinkWorld, + }) + : computeBridgeOriginFromSnapFrames({ + parentSnapWorld: matrixFrom(parentSnap.poseWorldMatrix), + childSnapWorld: matrixFrom(childSnap.poseWorldMatrix), + parentLinkWorld, + childLinkWorld, + alignment, + }); + + const { position, rotation } = result.transform; + applyPickedOrigin(position, { + r: radToDeg(rotation.r), + p: radToDeg(rotation.p), + y: radToDeg(rotation.y), + }); + }, [isOpen, parentSnap, childSnap, alignMode, alignment, applyPickedOrigin]); + + const startPick = useCallback( + (nextSide: JointPickSide) => { + startPickAction(nextSide); + }, + [startPickAction], + ); + + const cancelPick = useCallback(() => { + setActive(false); + }, [setActive]); + + return { + active, + side, + mode, + snapFilter, + parentSnap, + childSnap, + startPick, + cancelPick, + setMode: setModeAction, + setSnapFilter: setSnapFilterAction, + clearSide: clearSideAction, + }; +} diff --git a/src/features/code-editor/index.ts b/src/features/code-editor/index.ts index a2216cdb9..b0cf78699 100644 --- a/src/features/code-editor/index.ts +++ b/src/features/code-editor/index.ts @@ -12,6 +12,10 @@ export type { SourceCodeEditorDocument, SourceCodeEditorProps, } from './components/SourceCodeEditor'; +export type { + SourceCodeDirtyRange, + SourceCodeEditorApplyRequest, +} from './utils/sourceCodeEditorSession'; // App-facing runtime helpers export { preloadMonacoEditor, preloadMonacoEditorWorker } from './utils/monacoLoader'; diff --git a/src/features/file-io/utils/usdExport.test.ts b/src/features/file-io/utils/usdExport.test.ts index 88ee2e6b9..4d878ac4b 100644 --- a/src/features/file-io/utils/usdExport.test.ts +++ b/src/features/file-io/utils/usdExport.test.ts @@ -811,6 +811,10 @@ test('preserves link transforms and writes physics joints into separate USD laye assert.match(baseLayer, /def Sphere "sphere"/); assert.match(baseLayer, /def Xform "collisions"/); assert.doesNotMatch(baseLayer, /def Xform "colliders"/); + assert.match( + baseLayer, + /def Xform "collisions"[\s\S]*def Cube "box" \(\n\s+prepend apiSchemas = \["PhysicsCollisionAPI"\]\n\s*\)/, + ); assert.match(physicsLayer, /over "two_link_robot_description"/); assert.match(physicsLayer, /subLayers = \[\n\s+@two_link_robot_description_base\.usd@\n\s+\]/); diff --git a/src/features/file-io/utils/usdPackageLayers.test.ts b/src/features/file-io/utils/usdPackageLayers.test.ts index 250df53ae..a10e41d70 100644 --- a/src/features/file-io/utils/usdPackageLayers.test.ts +++ b/src/features/file-io/utils/usdPackageLayers.test.ts @@ -201,6 +201,7 @@ test('usd package layers serialize root and sensor configuration prims', () => { const sensorLayer = buildUsdSensorLayerContent('demo_robot_description'); assert.match(rootLayer, /defaultPrim = "demo_robot_description"/); + assert.match(rootLayer, /string "urdfStudio:roundtripMetadata" = "1"/); assert.match(rootLayer, /prepend references = @configuration\/demo_robot_description_base\.usd@/); assert.match(rootLayer, /prepend payload = @configuration\/demo_robot_description_physics\.usd@/); assert.match(rootLayer, /prepend payload = @configuration\/demo_robot_description_sensor\.usd@/); @@ -221,6 +222,7 @@ test('isaacsim usd package layers add a Robot variant and robot sidecar referenc }); assert.match(rootLayer, /string Robot = "Robot"/); + assert.match(rootLayer, /string "urdfStudio:roundtripMetadata" = "1"/); assert.match(rootLayer, /prepend variantSets = \["Physics", "Sensor", "Robot"\]/); assert.match(rootLayer, /prepend payload = @configuration\/demo_robot_robot\.usda@/); assert.match(robotLayer, /prepend apiSchemas = \["IsaacRobotAPI"\]/); @@ -266,6 +268,57 @@ test('usd package layers serialize articulation, joint paths, and mesh collision assert.match(physicsLayer, /uniform token physics:approximation = "convexHull"/); }); +test('usd package layers preserve authored generic UsdPhysics D6 joints without fixed-joint fallback', () => { + const robot = createLayeredRobot(); + robot.joints.child_joint = { + ...robot.joints.child_joint, + id: 'd6_joint', + name: 'D6', + type: JointType.FLOATING, + usdPhysics: { + jointTypeName: 'PhysicsJoint', + limitAxes: { + rotX: { low: -180, high: 180 }, + rotY: { low: 0, high: 0 }, + rotZ: { low: 0, high: 0 }, + transX: { low: 0, high: 0 }, + transY: { low: 0, high: 0 }, + transZ: { low: 0, high: 0 }, + }, + driveAxes: { + rotX: { + type: 'force', + stiffness: 0.04, + damping: 0.002, + targetPosition: 0, + targetVelocity: 0, + }, + }, + }, + }; + const pathMaps = buildUsdLinkPathMaps(robot, 'demo_robot_description'); + const physicsLayer = buildUsdPhysicsLayerContent( + robot, + pathMaps, + 'demo_robot_description', + 'demo_robot_description', + ); + + assert.match(physicsLayer, /def PhysicsJoint "d6_joint"/); + assert.doesNotMatch(physicsLayer, /def PhysicsFixedJoint "d6_joint"/); + assert.match( + physicsLayer, + /prepend apiSchemas = \["PhysicsLimitAPI:transX", "PhysicsLimitAPI:transY", "PhysicsLimitAPI:transZ", "PhysicsLimitAPI:rotX", "PhysicsLimitAPI:rotY", "PhysicsLimitAPI:rotZ", "PhysicsDriveAPI:rotX"\]/, + ); + assert.match(physicsLayer, /float limit:rotX:physics:low = -180/); + assert.match(physicsLayer, /float limit:rotX:physics:high = 180/); + assert.match(physicsLayer, /float limit:transZ:physics:low = 0/); + assert.match(physicsLayer, /float limit:transZ:physics:high = 0/); + assert.match(physicsLayer, /uniform token drive:rotX:physics:type = "force"/); + assert.match(physicsLayer, /float drive:rotX:physics:stiffness = 0\.04/); + assert.match(physicsLayer, /float drive:rotX:physics:damping = 0\.002/); +}); + test('isaacsim usd package layers flatten link prim paths for physics bodies', () => { const robot = createLayeredRobot(); const pathMaps = buildUsdLinkPathMaps(robot, 'demo_robot', { diff --git a/src/features/file-io/utils/usdPackageLayers.ts b/src/features/file-io/utils/usdPackageLayers.ts index f9d3982ac..70400308d 100644 --- a/src/features/file-io/utils/usdPackageLayers.ts +++ b/src/features/file-io/utils/usdPackageLayers.ts @@ -198,10 +198,32 @@ const getAxisToken = (axis: THREE.Vector3 | UrdfJoint['axis'] | undefined): 'X' const jointTypeToUsdType = ( joint: UrdfJoint, ): + | 'PhysicsJoint' | 'PhysicsFixedJoint' | 'PhysicsRevoluteJoint' | 'PhysicsPrismaticJoint' - | 'PhysicsSphericalJoint' => { + | 'PhysicsSphericalJoint' + | 'PhysicsDistanceJoint' => { + const authoredUsdTypeName = String(joint.usdPhysics?.jointTypeName || '').trim(); + if (/^PhysicsJoint$/i.test(authoredUsdTypeName) || /(?:^|Physics)D6Joint$/i.test(authoredUsdTypeName)) { + return 'PhysicsJoint'; + } + if (/^PhysicsFixedJoint$/i.test(authoredUsdTypeName)) { + return 'PhysicsFixedJoint'; + } + if (/^PhysicsRevoluteJoint$/i.test(authoredUsdTypeName)) { + return 'PhysicsRevoluteJoint'; + } + if (/^PhysicsPrismaticJoint$/i.test(authoredUsdTypeName)) { + return 'PhysicsPrismaticJoint'; + } + if (/^PhysicsSphericalJoint$/i.test(authoredUsdTypeName)) { + return 'PhysicsSphericalJoint'; + } + if (/^PhysicsDistanceJoint$/i.test(authoredUsdTypeName)) { + return 'PhysicsDistanceJoint'; + } + const type = String(joint.type || '').toLowerCase(); if (type === 'revolute' || type === 'continuous') { return 'PhysicsRevoluteJoint'; @@ -212,6 +234,9 @@ const jointTypeToUsdType = ( if (type === 'ball' || type === 'spherical') { return 'PhysicsSphericalJoint'; } + if (type === 'floating') { + return 'PhysicsJoint'; + } return 'PhysicsFixedJoint'; }; @@ -294,6 +319,31 @@ function resolveUsdPhysicsLocalPos1(joint: UrdfJoint): [number, number, number] return [x, y, z]; } +const USD_PHYSICS_AXIS_INSTANCE_ORDER = ['transX', 'transY', 'transZ', 'rotX', 'rotY', 'rotZ']; + +function sortUsdPhysicsAxisInstances(left: string, right: string): number { + const leftIndex = USD_PHYSICS_AXIS_INSTANCE_ORDER.indexOf(left); + const rightIndex = USD_PHYSICS_AXIS_INSTANCE_ORDER.indexOf(right); + if (leftIndex >= 0 || rightIndex >= 0) { + return (leftIndex >= 0 ? leftIndex : Number.MAX_SAFE_INTEGER) - + (rightIndex >= 0 ? rightIndex : Number.MAX_SAFE_INTEGER); + } + return left.localeCompare(right); +} + +function getUsdPhysicsAxisKeys(value: Record | null | undefined): string[] { + if (!value || typeof value !== 'object') { + return []; + } + return Object.keys(value) + .filter((key) => /^[A-Za-z0-9_]+$/.test(key)) + .sort(sortUsdPhysicsAxisInstances); +} + +function formatOptionalUsdNumber(value: number | null | undefined): string | null { + return Number.isFinite(Number(value)) ? formatUsdFloat(Number(value)) : null; +} + const serializeJointDefinition = ( joint: UrdfJoint, linkPaths: Map, @@ -316,6 +366,10 @@ const serializeJointDefinition = ( const supportsAxis = typeName === 'PhysicsRevoluteJoint' || typeName === 'PhysicsPrismaticJoint'; const axisToken = getAxisToken(joint.axis); const driveInstanceName = getUsdDriveInstanceName(typeName); + const usdLimitAxisKeys = + typeName === 'PhysicsJoint' ? getUsdPhysicsAxisKeys(joint.usdPhysics?.limitAxes) : []; + const usdDriveAxisKeys = + typeName === 'PhysicsJoint' ? getUsdPhysicsAxisKeys(joint.usdPhysics?.driveAxes) : []; const shouldUseIsaacDefaults = options.layoutProfile === 'isaacsim' && driveInstanceName !== null; const sourceDriveStiffness = Number.isFinite(joint.dynamics?.stiffness) && Math.abs(Number(joint.dynamics?.stiffness)) > 1e-9 @@ -363,6 +417,12 @@ const serializeJointDefinition = ( if (options.layoutProfile === 'isaacsim' && driveInstanceName !== null) { jointApiSchemas.push(`"PhysicsJointStateAPI:${driveInstanceName}"`, '"PhysxJointAPI"'); } + usdLimitAxisKeys.forEach((axis) => { + jointApiSchemas.push(`"PhysicsLimitAPI:${axis}"`); + }); + usdDriveAxisKeys.forEach((axis) => { + jointApiSchemas.push(`"PhysicsDriveAPI:${axis}"`); + }); if (shouldEmitDrive) { jointApiSchemas.push(`"PhysicsDriveAPI:${driveInstanceName}"`); } @@ -409,6 +469,17 @@ const serializeJointDefinition = ( lines.push(`${childIndent}float physics:lowerLimit = ${formatUsdFloat(joint.limit.lower)}`); lines.push(`${childIndent}float physics:upperLimit = ${formatUsdFloat(joint.limit.upper)}`); } + usdLimitAxisKeys.forEach((axis) => { + const limit = joint.usdPhysics?.limitAxes?.[axis]; + const low = formatOptionalUsdNumber(limit?.low); + const high = formatOptionalUsdNumber(limit?.high); + if (low !== null) { + lines.push(`${childIndent}float limit:${axis}:physics:low = ${low}`); + } + if (high !== null) { + lines.push(`${childIndent}float limit:${axis}:physics:high = ${high}`); + } + }); if (shouldEmitDrive) { lines.push(`${childIndent}uniform token drive:${driveInstanceName}:physics:type = "force"`); @@ -431,6 +502,23 @@ const serializeJointDefinition = ( lines.push(`${childIndent}float drive:${driveInstanceName}:physics:targetPosition = 0`); } } + usdDriveAxisKeys.forEach((axis) => { + const drive = joint.usdPhysics?.driveAxes?.[axis]; + const driveType = String(drive?.type || '').trim(); + if (driveType) { + lines.push( + `${childIndent}uniform token drive:${axis}:physics:type = "${escapeUsdString(driveType)}"`, + ); + } + (['stiffness', 'damping', 'maxForce', 'targetPosition', 'targetVelocity'] as const).forEach( + (propertyName) => { + const formatted = formatOptionalUsdNumber(drive?.[propertyName]); + if (formatted !== null) { + lines.push(`${childIndent}float drive:${axis}:physics:${propertyName} = ${formatted}`); + } + }, + ); + }); if (maxJointVelocity !== null) { lines.push( @@ -815,7 +903,7 @@ export const buildUsdRootLayerContent = ( '#usda 1.0', '(', ' customLayerData = {', - ' string urdfStudio:roundtripMetadata = "1"', + ' string "urdfStudio:roundtripMetadata" = "1"', ' }', ` defaultPrim = "${rootPrimName}"`, ' metersPerUnit = 1', @@ -877,7 +965,7 @@ export const buildUsdRootLayerContent = ( '#usda 1.0', '(', ' customLayerData = {', - ' string urdfStudio:roundtripMetadata = "1"', + ' string "urdfStudio:roundtripMetadata" = "1"', ' }', ` defaultPrim = "${rootPrimName}"`, ' upAxis = "Z"', diff --git a/src/features/file-io/utils/usdSceneSerialization.ts b/src/features/file-io/utils/usdSceneSerialization.ts index 86038a156..5d386e746 100644 --- a/src/features/file-io/utils/usdSceneSerialization.ts +++ b/src/features/file-io/utils/usdSceneSerialization.ts @@ -583,13 +583,23 @@ const serializeSceneNode = async ( ? context.geometryByObject.get(object) : undefined; const primMetadata: string[] = []; + const apiSchemas: string[] = []; if (materialRecord && materialSubsets.length === 0) { - primMetadata.push('prepend apiSchemas = ["MaterialBindingAPI"]'); + apiSchemas.push('"MaterialBindingAPI"'); + } + if (object.userData?.usdCollision === true && (primitiveType || isUsdMeshObject(object))) { + apiSchemas.push('"PhysicsCollisionAPI"'); + if (isUsdMeshObject(object)) { + apiSchemas.push('"PhysicsMeshCollisionAPI"'); + } } if (geometryRecord) { primMetadata.push(`prepend references = <${geometryRecord.path}>`); } + if (apiSchemas.length > 0) { + primMetadata.unshift(`prepend apiSchemas = [${Array.from(new Set(apiSchemas)).join(', ')}]`); + } serializeUsdPrimSpecWithMetadata(lines, depth, `def ${typeName} "${name}"`, primMetadata); lines.push(`${indent}{`); diff --git a/src/features/urdf-viewer/components/AssemblyJointPickLayer.tsx b/src/features/urdf-viewer/components/AssemblyJointPickLayer.tsx new file mode 100644 index 000000000..abb74d0cf --- /dev/null +++ b/src/features/urdf-viewer/components/AssemblyJointPickLayer.tsx @@ -0,0 +1,304 @@ +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { useThree } from '@react-three/fiber'; +import { Line } from '@react-three/drei'; +import * as THREE from 'three'; + +import type { AssemblyState } from '@/types'; +import { useJointPickSessionStore, type PickedSnapFrame } from '@/store/jointPickSessionStore'; +import { CoordinateAxes } from '@/shared/components/3d/helpers/CoordinateAxes'; +import { throttle } from '@/shared/utils'; + +import { resolveJointSnapFromHit, type ResolvedJointSnap } from '../utils/jointSnapResolver'; + +const PICK_THROTTLE_MS = 33; +const PICK_MOVE_THRESHOLD_PX = 2; +const PICK_CLICK_DRAG_THRESHOLD_PX = 5; +// Clicks inside these overlay containers must never place a snap point. +const PICK_POINTER_IGNORE_SELECTORS = [ + '.urdf-toolbar', + '.urdf-options-panel', + '.urdf-joint-panel', + '.draggable-window', +]; +const PICK_RENDER_ORDER = 2500; +const FRAME_SIZE = 0.05; + +const SNAP_TONE = { + valid: '#22d3ee', + invalid: '#94a3b8', + parent: '#0ea5e9', + child: '#10b981', +} as const; + +interface AssemblyJointPickLayerProps { + robot: THREE.Object3D | null; + assemblyState: AssemblyState | null; + hidden?: boolean; +} + +interface HoverSnap { + valid: boolean; + point: THREE.Vector3; + pose: THREE.Matrix4 | null; +} + +function decomposeMatrix(matrix: THREE.Matrix4): { + position: [number, number, number]; + quaternion: [number, number, number, number]; +} { + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + matrix.decompose(position, quaternion, scale); + return { + position: [position.x, position.y, position.z], + quaternion: [quaternion.x, quaternion.y, quaternion.z, quaternion.w], + }; +} + +const FrameAxes = memo(({ matrix, opacity = 1 }: { matrix: THREE.Matrix4; opacity?: number }) => { + const { position, quaternion } = useMemo(() => decomposeMatrix(matrix), [matrix]); + return ( + + + + ); +}); + +const SnapDot = memo( + ({ point, tone, radius = 0.007 }: { point: THREE.Vector3; tone: string; radius?: number }) => ( + + + + + ), +); + +const CommittedSnap = memo(({ frame, tone }: { frame: PickedSnapFrame; tone: string }) => { + const matrix = useMemo( + () => new THREE.Matrix4().fromArray(frame.poseWorldMatrix), + [frame.poseWorldMatrix], + ); + const point = useMemo( + () => new THREE.Vector3(frame.pointWorld.x, frame.pointWorld.y, frame.pointWorld.z), + [frame.pointWorld.x, frame.pointWorld.y, frame.pointWorld.z], + ); + return ( + + + + + ); +}); + +export const AssemblyJointPickLayer = memo( + ({ robot, assemblyState, hidden = false }: AssemblyJointPickLayerProps) => { + const { camera, gl } = useThree(); + const active = useJointPickSessionStore((state) => state.active); + const side = useJointPickSessionStore((state) => state.side); + const snapFilter = useJointPickSessionStore((state) => state.snapFilter); + const parentComponentId = useJointPickSessionStore((state) => state.parentComponentId); + const parentLinkId = useJointPickSessionStore((state) => state.parentLinkId); + const childComponentId = useJointPickSessionStore((state) => state.childComponentId); + const childLinkId = useJointPickSessionStore((state) => state.childLinkId); + const parentSnap = useJointPickSessionStore((state) => state.parentSnap); + const childSnap = useJointPickSessionStore((state) => state.childSnap); + const commitSnap = useJointPickSessionStore((state) => state.commitSnap); + + const [hover, setHover] = useState(null); + + const expectedComponentId = side === 'parent' ? parentComponentId : childComponentId; + const expectedLinkId = side === 'parent' ? parentLinkId : childLinkId; + + // Latest values for the throttled DOM handlers (avoids stale closures). + const ctxRef = useRef({ side, snapFilter, expectedComponentId, expectedLinkId, assemblyState, robot }); + useEffect(() => { + ctxRef.current = { side, snapFilter, expectedComponentId, expectedLinkId, assemblyState, robot }; + }, [side, snapFilter, expectedComponentId, expectedLinkId, assemblyState, robot]); + + useEffect(() => { + if (!active || hidden || !robot || !assemblyState) { + setHover(null); + return undefined; + } + + const domElement = gl.domElement; + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + let lastX = 0; + let lastY = 0; + let downX = 0; + let downY = 0; + + const updatePointer = (event: MouseEvent): boolean => { + const rect = domElement.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + return true; + }; + + const raycastSnap = (): { snap: ResolvedJointSnap; valid: boolean } | null => { + const ctx = ctxRef.current; + if (!ctx.robot || !ctx.assemblyState) { + return null; + } + raycaster.setFromCamera(pointer, camera); + const hits = raycaster.intersectObject(ctx.robot, true); + for (const hit of hits) { + if (hit.object.userData?.isHelper || hit.object.userData?.isGizmo) { + continue; + } + const snap = resolveJointSnapFromHit( + { object: hit.object, point: hit.point, faceIndex: hit.faceIndex }, + ctx.assemblyState, + ctx.snapFilter, + ); + if (snap) { + const componentMatches = + !ctx.expectedComponentId || snap.componentId === ctx.expectedComponentId; + const linkMatches = !ctx.expectedLinkId || snap.linkId === ctx.expectedLinkId; + return { snap, valid: componentMatches && linkMatches }; + } + } + return null; + }; + + const handleMoveCore = (event: MouseEvent) => { + const dx = event.clientX - lastX; + const dy = event.clientY - lastY; + if (dx * dx + dy * dy < PICK_MOVE_THRESHOLD_PX * PICK_MOVE_THRESHOLD_PX) { + return; + } + lastX = event.clientX; + lastY = event.clientY; + + if (!updatePointer(event)) { + setHover(null); + return; + } + const result = raycastSnap(); + if (!result) { + setHover(null); + return; + } + setHover({ + valid: result.valid, + point: result.snap.chosen.pointWorld.clone(), + pose: result.valid ? result.snap.chosen.poseWorld.clone() : null, + }); + }; + + const throttledMove = throttle(handleMoveCore, PICK_THROTTLE_MS); + + const handleDown = (event: MouseEvent) => { + downX = event.clientX; + downY = event.clientY; + }; + + const handleClick = (event: MouseEvent) => { + if (event.button !== 0) { + return; + } + const target = event.target as HTMLElement | null; + if (target && PICK_POINTER_IGNORE_SELECTORS.some((selector) => target.closest(selector))) { + return; + } + // Ignore the click that ends an orbit drag. + const ddx = event.clientX - downX; + const ddy = event.clientY - downY; + if ( + ddx * ddx + ddy * ddy > + PICK_CLICK_DRAG_THRESHOLD_PX * PICK_CLICK_DRAG_THRESHOLD_PX + ) { + return; + } + if (!updatePointer(event)) { + return; + } + const result = raycastSnap(); + if (!result || !result.valid) { + return; + } + const { snap } = result; + commitSnap({ + side: ctxRef.current.side, + componentId: snap.componentId, + linkId: snap.linkId, + kind: snap.chosen.kind, + pointWorld: { + x: snap.chosen.pointWorld.x, + y: snap.chosen.pointWorld.y, + z: snap.chosen.pointWorld.z, + }, + poseWorldMatrix: snap.chosen.poseWorld.toArray(), + linkWorldMatrix: snap.linkWorldMatrix.toArray(), + }); + setHover(null); + }; + + domElement.addEventListener('mousemove', throttledMove); + domElement.addEventListener('mousedown', handleDown); + domElement.addEventListener('click', handleClick); + + return () => { + throttledMove.cancel(); + domElement.removeEventListener('mousemove', throttledMove); + domElement.removeEventListener('mousedown', handleDown); + domElement.removeEventListener('click', handleClick); + setHover(null); + }; + }, [active, hidden, robot, assemblyState, camera, gl, commitSnap]); + + const connectorPoints = useMemo(() => { + if (!parentSnap || !childSnap) { + return null; + } + return [ + new THREE.Vector3(parentSnap.pointWorld.x, parentSnap.pointWorld.y, parentSnap.pointWorld.z), + new THREE.Vector3(childSnap.pointWorld.x, childSnap.pointWorld.y, childSnap.pointWorld.z), + ]; + }, [parentSnap, childSnap]); + + if (!active || hidden) { + return null; + } + + return ( + + {hover ? ( + <> + + {hover.valid && hover.pose ? : null} + + ) : null} + {parentSnap ? : null} + {childSnap ? : null} + {connectorPoints ? ( + + ) : null} + + ); + }, +); + +AssemblyJointPickLayer.displayName = 'AssemblyJointPickLayer'; diff --git a/src/features/urdf-viewer/components/CollisionTransformControls.tsx b/src/features/urdf-viewer/components/CollisionTransformControls.tsx index abd5ab3c6..0f9db4c91 100644 --- a/src/features/urdf-viewer/components/CollisionTransformControls.tsx +++ b/src/features/urdf-viewer/components/CollisionTransformControls.tsx @@ -8,9 +8,10 @@ import { useCollisionTransformDragLifecycle } from '../hooks/useCollisionTransfo import { getObjectRPY } from '../utils/collisionTransformMath'; import { resolveCurrentCollisionDraggingControls } from '../utils/collisionTransformControlsShared'; -const COLLISION_TRANSLATE_GIZMO_SIZE = VISUALIZER_UNIFIED_GIZMO_SIZE; -const COLLISION_ROTATE_GIZMO_SIZE = VISUALIZER_UNIFIED_GIZMO_SIZE * 0.84; -const COLLISION_GIZMO_THICKNESS_SCALE = 1.9; +const COLLISION_TRANSLATE_GIZMO_SIZE = VISUALIZER_UNIFIED_GIZMO_SIZE * 0.56; +const COLLISION_ROTATE_GIZMO_SIZE = VISUALIZER_UNIFIED_GIZMO_SIZE * 0.46; +const COLLISION_GIZMO_THICKNESS_SCALE = 1.28; +const COLLISION_COMMITTED_TRANSFORM_EPSILON = 1e-6; export const CollisionTransformControls: React.FC = ({ robot, @@ -40,6 +41,19 @@ export const CollisionTransformControls: React.FC(null); + const queuedPreviewRef = useRef<{ + id: string; + objectIndex?: number; + position: { x: number; y: number; z: number }; + rotation: { r: number; p: number; y: number }; + } | null>(null); + const lastCommittedTransformRef = useRef<{ + id: string; + objectIndex: number; + position: THREE.Vector3; + quaternion: THREE.Quaternion; + } | null>(null); const resolveSelectionLinkId = useCallback( (identity: string | null | undefined) => { @@ -151,33 +165,108 @@ export const CollisionTransformControls: React.FC { - const activeSelection = activeSelectionRef.current; + const reconcileCommittedTransform = useCallback( + (object: THREE.Object3D, selectionId: string, objectIndex: number) => { + const committed = lastCommittedTransformRef.current; + if (!committed) { + return; + } + + if (committed.id !== selectionId || committed.objectIndex !== objectIndex) { + lastCommittedTransformRef.current = null; + return; + } + + const positionMatches = + object.position.distanceToSquared(committed.position) <= + COLLISION_COMMITTED_TRANSFORM_EPSILON * COLLISION_COMMITTED_TRANSFORM_EPSILON; + const rotationMatches = + object.quaternion.angleTo(committed.quaternion) <= COLLISION_COMMITTED_TRANSFORM_EPSILON; + + if (positionMatches && rotationMatches) { + lastCommittedTransformRef.current = null; + return; + } + + object.position.copy(committed.position); + object.quaternion.copy(committed.quaternion); + object.updateMatrixWorld(true); + }, + [], + ); + + const cancelQueuedTransformPreview = useCallback(() => { + if ( + queuedPreviewFrameRef.current !== null && + typeof window !== 'undefined' && + typeof window.cancelAnimationFrame === 'function' + ) { + window.cancelAnimationFrame(queuedPreviewFrameRef.current); + } + + queuedPreviewFrameRef.current = null; + queuedPreviewRef.current = null; + }, []); + + const flushQueuedTransformPreview = useCallback(() => { + queuedPreviewFrameRef.current = null; + const preview = queuedPreviewRef.current; + queuedPreviewRef.current = null; const handleTransformChange = onTransformChangeRef.current; - if (!activeSelection?.id || !handleTransformChange) { + if (!preview || !handleTransformChange) { return; } - object.updateMatrixWorld(true); - const position = object.position; - const rotation = getObjectRPY(object); - - handleTransformChange( - activeSelection.id, - { x: position.x, y: position.y, z: position.z }, - rotation, - activeSelection.objectIndex, - ); + handleTransformChange(preview.id, preview.position, preview.rotation, preview.objectIndex); }, []); + const queueTransformPreview = useCallback( + (object: THREE.Object3D) => { + const activeSelection = activeSelectionRef.current; + if (!activeSelection?.id || !onTransformChangeRef.current) { + return; + } + + object.updateMatrixWorld(true); + const position = object.position; + const rotation = getObjectRPY(object); + + queuedPreviewRef.current = { + id: activeSelection.id, + objectIndex: activeSelection.objectIndex, + position: { x: position.x, y: position.y, z: position.z }, + rotation, + }; + + if (queuedPreviewFrameRef.current !== null) { + return; + } + + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + flushQueuedTransformPreview(); + return; + } + + queuedPreviewFrameRef.current = window.requestAnimationFrame(flushQueuedTransformPreview); + }, + [flushQueuedTransformPreview], + ); + const handleCancelDrag = useCallback(() => { const activeTargetObject = targetObjectRef.current; + cancelQueuedTransformPreview(); if (activeTargetObject) { activeTargetObject.position.copy(originalPositionRef.current); @@ -185,10 +274,11 @@ export const CollisionTransformControls: React.FC { const activeTargetObject = targetObjectRef.current; + cancelQueuedTransformPreview(); if (!activeTargetObject) { return; } @@ -198,7 +288,7 @@ export const CollisionTransformControls: React.FC { const activeTargetObject = targetObjectRef.current; @@ -250,11 +340,13 @@ export const CollisionTransformControls: React.FC cancelQueuedTransformPreview, [cancelQueuedTransformPreview]); + useEffect(() => { // While a drag is in flight, transform previews can churn the `selection` // object identity, re-running this effect. Recomputing/clearing the target @@ -324,6 +416,8 @@ export const CollisionTransformControls: React.FC { if (!isDraggingRef.current) { diff --git a/src/features/urdf-viewer/components/ViewerScene.tsx b/src/features/urdf-viewer/components/ViewerScene.tsx index c81ee1c4f..fa0279765 100644 --- a/src/features/urdf-viewer/components/ViewerScene.tsx +++ b/src/features/urdf-viewer/components/ViewerScene.tsx @@ -1,5 +1,6 @@ import { Suspense, useCallback, useEffect, useMemo, useRef } from 'react'; import { MeasureTool } from './MeasureTool'; +import { AssemblyJointPickLayer } from './AssemblyJointPickLayer'; import { useSnapshotRenderActive } from '@/shared/components/3d/scene/SnapshotRenderContext'; import { setRegressionRuntimeRobot } from '@/shared/debug/regressionState'; import { isRegressionDebugEnabled } from '@/shared/debug/regressionDebugEnabled'; @@ -224,6 +225,12 @@ export const ViewerScene = ({ t={t} /> +