diff --git a/.gitignore b/.gitignore index 6fdf31cb9..feee300cf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,9 @@ scripts/* scripts/regression/* !scripts/regression/validate_sdf_cli_truth.ts !scripts/regression/run_shadow_hand_hover_regression.mjs +!scripts/regression/run_default_link_click_hover_regression.mjs !scripts/regression/validate_unitree_ros_usd_export_benchmark.mjs +!scripts/regression/benchmark_import_pre_resolve.ts !scripts/mujoco/ scripts/mujoco/* !scripts/mujoco/mjcf_compare.ts @@ -59,4 +61,8 @@ __pycache__/ !scripts/regression/validate_unitree_ros_usda_browser_smoke.mjs !scripts/regression/validate_unitree_selected_browser.mjs !scripts/regression/validate_unitree_ros_usda_selected_browser.mjs +!scripts/regression/validate_source_editor_mjcf_browser.mjs +!scripts/regression/validate_source_editor_unitree_browser.mjs snapshot-default-page.png + +.omx/ diff --git a/CLAUDE.md b/CLAUDE.md index 4b2742dfd..e21e2f68c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,843 +1,31 @@ -# URDF-Studio Agent Guide +# URDF Studio Claude Entrypoint -本文件是仓库内 AI Coding Agent 的统一执行规范,基于仓库当前结构(核对日期:2026-04-06)整理。 +此文件保留为兼容入口,避免根目录再次维护一份会漂移的长篇 agent 文档。 -## 1. 目标与范围 +开始前先读: -- 项目:URDF Studio(机器人设计、装配、可视化与导出工作台) -- 技术栈:React 19.2 + TypeScript 5.8 + Three.js/R3F + Vite 6.2 + Tailwind CSS 4.1 + Zustand 5 -- 核心能力: - - 单模式 `Editor` 编辑(统一承载拓扑、几何、碰撞、测量与硬件配置) - - 多 URDF 组装、桥接关节与工作区文件管理 - - `URDF` / `MJCF` / `SDF` / `USD` / `Xacro` / `ZIP` / `.usp` 导入导出 - - AI 生成、AI 审阅、PDF/CSV 报告 - - 可复用 `react-robot-canvas` 画布封装与对外发布 +1. [AGENTS.md](AGENTS.md) - 当前仓库的执行规范、架构红线、验证要求 +2. [docs/CATALOG.md](docs/CATALOG.md) - 按任务类型分流到对应领域文档 -适用任务: +最关键的项目约束: -- 代码实现与重构 -- UI/交互改造 -- 3D/R3F/Three.js/URDF/USD 相关功能扩展 -- Workspace / import-export / hydration / roundtrip 流程开发 -- AI 审阅、提示词上下文与报告导出相关开发 +- 依赖方向保持 `app -> features -> store -> shared -> core -> types` +- `core/` 保持纯逻辑,不引入 React / UI / Feature 依赖 +- 优先复用现有 hooks、utils、components,不新增重复抽象 +- 新增 `ResizeObserver`、timer、worker listener、THREE 资源时必须对称 cleanup -## 2. Prompt 文档来源(Source of Truth) - -建议按“单主文档 + 按需补充”读取: - -- 主 Source of Truth(默认先读): - - `docs/prompts/CLAUDE.md`:项目架构、目录职责、依赖规则、Viewer/样式约束 -- 轻量补充入口(仅在任务相关时读取): - - `docs/prompts/urdf-viewer.md` - - `docs/prompts/URDF_STUDIO_STYLE_GUIDE.md` - - `docs/prompts/overview.md` -- 非 prompts 补充文档(边界/发布/runtime 审计任务时读取): - - `docs/architecture-boundaries.md` - - `docs/robot-canvas-lib.md` - - `docs/runtime-fallback-audit.md` -- 若文档描述与当前 `src/` 真实结构冲突,以仓库现状为准,并优先回补 `CLAUDE.md` - -AI 审阅标准输入(以仓库真实路径为准): - -- `src/features/ai-assistant/config/urdf_inspect_standard_en.md` -- `src/features/ai-assistant/config/urdf_inspect_stantard_zh.md` - -说明: - -- 中文文件名当前仍是 `stantard`(仓库现状),不要擅自改名,除非任务明确要求修复命名。 - -## 2.5 本机 ROS 2 环境(2026-03-27 已核实) - -- 本机已安装 ROS 2 Humble,`ros2` 实际路径为 `/opt/ros/humble/bin/ros2`。 -- 当前非交互 shell 默认可能未注入 ROS 环境,直接执行 `ros2` 可能出现 `command not found`。 -- 运行 ROS 2 / RViz / URDF 检查相关命令前,优先先执行: - -```bash -source /opt/ros/humble/setup.bash -``` - -- 用户的 `/home/xyk/.bashrc` 当前已包含: - -```bash -source /opt/ros/humble/setup.bash -``` - -- 若 agent 需要核对 ROS 侧真值(如 `ros2`、`rviz2`、URDF 可视化或相关 CLI),不要先假设系统未安装 ROS,应先 source 上述环境后再检查。 - -## 2.6 Skill-first 替代策略(降低 MCP / prompt token 开销) - -默认原则: - -- 若需求本质上是“工作流指导、最佳实践、排障框架、测试套路、设计约束”,优先使用 skill,而不是在 prompt 里显式堆一串 MCP/tool 名称。 -- skill 负责压缩“怎么做”的上下文;只有在确实需要执行外部能力时,才继续调用对应 MCP/tool。 -- 不要把 skill 当成 capability replacement。skill 能替代的是提示词和决策开销,不是浏览器点击、远程 API、Figma 读取、`.pen` 编辑这类真实执行能力本身。 - -本仓库优先替代映射: - -- 浏览器验证 / 页面联调 / 截图: - - 优先 `webapp-testing`、`playwright`、`browser-automation` - - 营销或文档截图优先 `screenshots` - - 仅当需要真实浏览器交互、DOM 快照、网络面板或 DevTools 级检查时,再使用 Playwright / Chrome DevTools MCP -- 3D / R3F / Three.js / WebGL: - - 优先 `threejs-skills` - - 不要在 prompt 中重复展开 Three.js 基础套路 -- URDF Studio UI 改造: - - 优先 `urdf-studio-style` - - 通用前端视觉与交互再补 `frontend-design` 或 `ui-ux-pro-max` -- 调试 / 问题定位: - - 优先 `systematic-debugging`、`debugger` - - 不要一开始就堆浏览器 MCP、trace MCP、console MCP;先按调试 workflow 收敛问题 -- 测试 / QA: - - 优先 `testing-qa` - - 细分场景再补 `javascript-testing-patterns`、`e2e-testing-patterns`、`webapp-testing` -- 库文档 / 最新框架资料: - - 优先 `context7-auto-research` - - OpenAI 相关优先 `openai-docs` - - 仅当 skill 无法覆盖时,再直接使用 Context7 / Web 搜索类工具 -- 代码审阅 / 风险扫描: - - 优先 `requesting-code-review`、`find-bugs`、`code-reviewer` -- 泛搜索 / 调研: - - 优先 `search-specialist` - - 深度外部调研再补 `exa-search` 或 `deep-research` - -使用约束: - -- 同一任务优先选择 1 个主 skill;只有主 skill 明显不足时,再补 1 到 2 个辅助 skill。 -- 不要为了“保险”同时声明多个重叠 skill,例如浏览器任务同时堆 `playwright`、`playwright-skill`、`browser-automation`、`webapp-testing`。 -- 若任务已经能被 repo 内现成脚本、测试、build 命令完成,优先本地命令;不要为了形式统一改走 MCP。 -- 若任务要求真实外部状态读取或修改,例如浏览器点击、Figma 节点读取、在线搜索、设计文件写入、MCP 服务侧资源查询,则 skill 不能单独替代该能力。 - -## 3. 当前结构快照(2026-04-06) - -顶层目录(核心): - -- `src/`:业务源码 -- `docs/`:Agent 上下文、架构边界、runtime 审计与对外库说明 -- `packages/react-robot-canvas/`:对外发布包工作区 -- `public/`:静态资源(字体、logo、Monaco、USD bindings 等) -- `scripts/`:辅助脚本;高频子树包括 `regression/`、`versioning/`、`mujoco/`、`codex_key_router/` -- `log/`:本地运行日志与排障输出 -- `.tmp/`:脚本/构建使用的临时 scratch 目录 -- `output/`:导出结果与用户可见验证产物 -- `tmp/`:浏览器截图、trace、临时调试与中间产物 -- `test/`:外部工程镜像、浏览器回归样本与大型 fixtures -- `dist/`:构建产物 -- `.worktrees/`:本地 git worktree 隔离工作区 - -`src/` 一级模块: - -- `app/`:应用编排层;负责 App shell、viewer 组合、导入导出流程、workspace/source sync、USD hydration/roundtrip、document loading 与 worker handoff 协调;当前重点子树为 `components/unified-viewer/*`、`components/header/*`、`components/settings/*`、`hooks/file-export/*` -- `features/`:业务功能层 - - `ai-assistant/` - - `assembly/` - - `code-editor/` - - `editor/` - - `file-io/` - - `hardware-config/` - - `property-editor/` - - `robot-tree/` - - `urdf-viewer/` -- `store/`:Zustand 状态层 -- `shared/`:共享组件、3D 基础设施、hooks、i18n、数据、调试桥接、workers、通用工具 -- `core/`:纯逻辑、解析器、robot core、mesh loaders、parse workers、runtime diagnostics -- `lib/`:面向外部复用的 `RobotCanvas` 封装、类型与样式入口 -- `styles/`:全局样式与语义 token -- `types/`:跨模块类型定义 - -补充说明: - -- `src/app/components/unified-viewer/*` 是统一 viewer 的当前热区,承载 mode module loading、scene root、overlay、raycast interactivity 与 joints panel 适配。 -- `src/app/hooks/file-export/*` 是应用级导出 workflow 的辅助子树;`useFileExport.ts` 作为入口编排这些 helper。 -- `src/app/components/header/*`、`src/app/components/settings/*` 已独立成子树,Header/Settings 不再全部堆在单文件里。 -- `src/features/urdf-viewer/runtime/*` 现已细分为 `embed/`、`hydra/`、`viewer/`、`types/`、`vendor/`,不要只把它理解成单一 runtime 目录。 -- `shared/data/` 已存在,承载 inspection criteria、motor library 等共享静态数据。 -- `shared/data/*` 是共享静态数据 canonical source;`features/hardware-config/data/motorLibrary.ts` 等同名文件当前仅作 re-export / 兼容层使用。 -- `shared/debug/` 已存在,承载 regression/debug bridge,不要把调试桥接逻辑散落到业务组件里。 -- `shared/workers/closedLoopMotionPreview.worker.ts` 当前承载共享闭环运动预览 worker;需要跨 viewer 复用的 worker 优先收口到 `shared/workers/`。 -- `core/loaders/workers/*` 当前承载 `collada` / `obj` / `stl` 解析 worker;mesh 解析不要重新在 feature 层复制一套 worker 管线。 -- `app/components/` 现已细分出 `header/*`、`settings/*`、`unified-viewer/*` 等子树;新增 App 级 UI 逻辑优先先落到对应子目录。 -- `app/hooks/file-export/*` 当前承载导出进度、assembly history、project export、USD export 辅助逻辑;不要再把这些 helper 回塞进 `useFileExport.ts` 单文件。 -- `features/robot-tree/components/` 当前已拆出 `tree-editor/*` 与 `tree-node/*` 子树;树结构相关 UI 优先按这两个方向继续拆分。 -- `features/property-editor/utils/geometry-conversion/*` 已作为几何转换细分子树存在;几何转换逻辑不要再全部堆回单一 util 文件。 -- `shared/components/3d/workspace/*` 已作为共享画布宿主子树存在;应用层 `WorkspaceCanvas` 主要负责组合,而底层 WebGL 清理与错误边界更多在共享层维护。 -- `src/lib/` 当前已细分 `components/` 与 `hooks/`,对外封装不再只有单一入口文件。 -- 单元测试当前主要采用源码邻近放置(`src/**/*.test.*`);`test/` 不再是唯一测试入口。 -- `test/` 当前重点样本集包括 `test/unitree_model`、`test/gazebo_models`、`test/awesome_robot_descriptions_repos`、`test/usd-viewer`。 -- 当前已存在多个 worker 子树:`src/app/workers`、`src/features/code-editor/workers`、`src/features/file-io/workers`、`src/features/property-editor/workers`、`src/features/urdf-viewer/workers`、`src/core/loaders/workers`、`src/shared/workers`。 - -## 4. 架构红线(必须遵守) - -应用运行时代码必须保持“只向下依赖”的单向结构: - -`app -> features -> store -> shared -> core -> types` - -按层约束理解为: - -- `app` 可以编排 `features/store/shared/core/types`,但不能把业务细节反向塞回下层。 -- `features` 可以依赖 `store/shared/core/types`,禁止依赖 `app`。 -- `store` 与 `shared` 不应新增对 `features` 的运行时依赖。 -- `core` 保持纯函数,不引入 React/UI/Feature 依赖。 -- `types` 只提供类型与常量,不回指上层。 -- 使用 `@/` 路径别名指向 `src/`。 - -`src/lib/` 与 `packages/react-robot-canvas/` 约束: - -- `src/lib/` 视为对外复用封装层,只收稳定、通用、与应用壳无关的能力。 -- 应用内部不要把 `src/lib/` 当业务逻辑 source of truth。 -- 若能力仍强依赖 `robotStore`、workspace、app overlays 或特定业务流程,不要急于抽进 `src/lib/`。 - -当前存量例外(运行时代码,仅记录,禁止扩散): - -- `src/shared/components/Panel/JointControlItem.tsx` 依赖 `@/store/robotStore` -- `src/shared/hooks/useEffectiveTheme.ts` 依赖 `@/store/uiStore` -- `src/features/ai-assistant/utils/pdfExport.ts` 依赖 `@/features/file-io/components/InspectionReportTemplate` - -当前测试期例外(仅测试,不作为运行时先例): - -- `src/features/file-io/utils/usdFloatingRoundtrip.test.ts` 依赖 `urdf-viewer` 的 runtime/utils 做 roundtrip 验证 -- `src/features/file-io/utils/usdGo2Roundtrip.test.ts` 依赖 `urdf-viewer` 的 runtime/utils 做 roundtrip 验证 - -## 4.5 调试优先:默认少兜底 - -本仓库默认采用“debuggability first”原则:兜底不是默认美德,很多 silent fallback 会掩盖真实问题、污染状态并拉高排障成本。 - -必须遵循: - -- 默认优先暴露真实错误,不要为了“看起来还能跑”就吞错、改写异常或偷偷切到备用路径。 -- 禁止新增 `catch -> 返回空值/默认值/旧缓存/伪成功状态` 这一类 silent fallback,除非任务明确要求保活且能证明收益大于调试损失。 -- 禁止在导入、导出、hydration、roundtrip、解析、viewer 初始化这类 source-of-truth 链路里做不透明兜底;这些链路一旦异常,优先报出原始错误和上下文。 -- 导入准备、robot import、USD stage preparation、hydration、prepared export、archive/roundtrip 这类 worker bridge / off-main-thread 链路,默认必须 **fail fast**;不要因为 worker 不可用、worker 初始化失败或 postMessage 失败,就在同一调用路径里悄悄改走主线程实现。 -- 禁止用“自动重试 + 自动降级 + 自动切换备用实现”掩盖根因,尤其是格式解析、资源加载、3D runtime 初始化与 store 同步流程。 -- 若确实必须保留窄兜底,必须同时满足: - - 保留原始错误信息、栈与触发条件,不能吞掉; - - 能被用户或开发者明确观察到,例如 console/error state/debug panel 中可见; - - 不得悄悄改写 source of truth,不得制造“表面成功、数据已偏”的假象; - - 需要在注释或实现附近说明为何必须兜底,以及主路径失败时实际降级到什么。 - -实现倾向: - -- 优先选择 fail fast、显式 error state、显式禁用某按钮/面板,也不要返回一个看似正常但不可 debug 的假结果。 -- 如果要保活 UI,优先把失败隔离在边界处,并把错误显式透传到调用方或调试通道,而不是在底层 util 里悄悄吃掉。 -- 调试期发现历史遗留 silent fallback 时,可以顺手收紧,但前提是不破坏本次任务边界。 - -错误处理补充约束: - -- 不要静默吞掉错误(空 catch)。 -- 错误信息应包含足够上下文,至少覆盖触发阶段、关键输入和模块来源,便于快速定位。 -- 可恢复错误(如网络超时)优先采用有限重试;默认不做自动降级。确需降级时,必须满足上文“窄兜底”全部条件并明确说明原因。 -- 不可恢复错误应尽早失败并上抛,由上层边界统一处理与展示。 - -内存 / 生命周期补充约束: - -- 每次新增或修改 `ResizeObserver`、全局事件监听、RAF、`setTimeout` / `setInterval`、worker listener、`ImageBitmap`、object URL、THREE 材质/几何体/纹理、OffscreenCanvas/runtime owner 时,必须同时检查并实现对称 cleanup。 -- shared worker / singleton runtime 若为了复用而常驻,必须明确其所有者和释放边界;不能以“下次可能复用”为理由保留已经失效的 scene graph、driver、observer、message listener 或 pending request。 -- 新增 shared worker / singleton runtime 时,代码评审里必须能直接指出对应的 `dispose*` / `reset*` / owner teardown 调用点;如果说不清谁负责释放,就不要引入该常驻对象。 -- 临时调试缓存、prepared snapshot、context map 必须有上限、淘汰策略或显式 dispose/reset 路径;不要默认无限增长。 -- 做 memory / fallback 审计时,优先检查:worker bridge 的 `pendingRequests` / `workerUnavailable` 分支、`addEventListener/removeEventListener` 是否成对、`ResizeObserver.disconnect()`、`URL.revokeObjectURL()`、`ImageBitmap.close()`、以及 THREE 资源释放是否覆盖材质/几何体/纹理/renderer。 - -## 4.6 设计哲学补充:符合 Linux 哲学与 Linus taste - -适用范围: - -- 所有新增代码、重构、API 设计、状态流调整、worker bridge、viewer/runtime 适配层 - -优先级: - -- 这是一级工程约束,不是“风格建议”。当“模式统一”“抽象好看”“先包一层再说”和 Linux 哲学 / Linus taste 冲突时,默认前者让路。 - -默认工程取向: - -- 优先简单直接的数据流与控制流,不为“理论优雅”引入额外抽象层。 -- 优先解决真实问题,不为未来也许会出现的场景预埋复杂框架。 -- 优先把复杂度消灭在设计里,而不是把复杂度包进 helper、manager、factory、coordinator 名字里。 -- 若一个设计需要靠大量注释解释“为什么这样绕”,通常说明设计本身就不够好。 - -必须遵循: - -- 小而清晰的接口优先。函数、hook、store action、worker message shape 都应尽量只表达一件事。 -- 优先组合现有稳定模块,不轻易新增“万能层”“统一抽象层”“Base\*”或过度泛化的中间封装。 -- 数据结构优先于分支堆砌。能通过更好的数据建模消掉 `if/else` 和特殊情况,就不要继续堆条件。 -- 能删除就先删除;能合并特殊情况就先合并;能把例外变成正常数据形态,就不要再保留分支补丁。 -- 命名必须直白,优先用真实语义描述所有权、生命周期和失败路径,避免 `misc`、`manager`、`helper`、`temp` 这类弱语义命名。 -- 禁止把坏状态悄悄修平。出现异常状态时,应优先暴露不变量被破坏的位置,而不是在下游补丁式兼容。 -- 新逻辑默认先问“能不能删掉特殊情况”,再问“要不要新增分支”。 -- 新抽象必须证明自己减少了调用点的整体复杂度;若只是把复杂度移动到别处,则不要抽象。 -- 公共层不得为迁就单一业务场景而污染接口;宁可让特例留在边界层,也不要把整个共享层做脏。 -- 性能优化应以真实热点为目标,避免引入牺牲可读性的投机性缓存、记忆化和多态分发。 -- 错误处理要保留锋利边界。不要因为想让调用方“省心”就吞掉上下文、来源和失败条件。 - -Linus taste 落地检查: - -- 这段代码能不能通过更直接的数据结构或更少的状态把一半逻辑删掉? -- 这个改动是否减少了特殊情况,而不是重新包装特殊情况? -- 这个接口是否能让调用方一眼看懂,而不需要追三层抽象? -- 这个状态/生命周期的 owner 是否唯一且明确? -- 这段逻辑是否在解决真实需求,而不是为了架构好看? -- 如果未来要删掉这段代码,边界是否清楚、代价是否可控? - -明确不鼓励: - -- 为了“模式统一”引入仓库当前并不需要的架构层。 -- 过度 OO、过度继承、过度配置化、过度泛型化。 -- 把复杂交互拆成大量彼此弱关联的小文件,导致阅读路径碎片化。 -- 用 silent fallback、隐式同步、魔法默认值维持表面整洁。 -- 为避免修改旧代码而额外包一层适配器,结果使主路径更难理解。 - -## 5. 单模式开发语义 - -- `Editor`:统一承载运动链拓扑、几何体/材质/纹理、碰撞与测量、电机与硬件参数配置 - -实现新功能前,必须先判断属于 `Editor` 下的哪类子能力,避免跨子系统逻辑耦合。 - -快速映射: - -- 统一公开入口落在 `features/editor` -- 具体实现位于 `features/urdf-viewer`(包含原拓扑/硬件与几何/碰撞/测量能力) -- 跨子域共享交互优先落在 `app` 编排层或 `shared/components/3d` - -## 6. App 编排层(当前重点) - -关键入口: - -- `src/app/App.tsx`:根组件,装配 Providers、懒加载模态框、全局导入导出入口、回归调试桥接 -- `src/app/AppLayout.tsx`:应用壳、Header、TreeEditor、PropertyEditor、UnifiedViewer、workspace/source 同步主编排 -- `src/app/components/UnifiedViewer.tsx`:组合 `Editor` 两个子域场景(拓扑/硬件 + 几何/碰撞/测量),统一 selection/hover/preview/tool mode/resource scope -- `src/app/components/WorkspaceCanvas.tsx`:应用层对共享画布入口的 re-export;底层 `WorkspaceCanvas` runtime 在 `src/shared/components/3d/workspace/*` -- `src/app/components/AppLayoutOverlays.tsx` 与 `src/app/utils/overlayLoaders.ts`:懒加载业务浮层(如 bridge create、collision optimization) -- `src/app/components/ConnectedDocumentLoadingOverlay.tsx` / `src/app/components/DocumentLoadingOverlay.tsx` / `src/app/components/ImportPreparationOverlay.tsx`:导入准备、文档加载与 worker 进度反馈 -- `src/app/components/SnapshotDialog.tsx`:统一快照导出与预览弹层 -- `src/app/components/unified-viewer/*`:统一 viewer 的 scene root、overlay、derived state、mode module loader 与 joints panel 适配 -- `src/app/components/header/*`:Header actions/menus/overflow/toolbox 等子结构;`ToolboxMenu` 为纯渲染器(接收 `items: ToolboxItem[]`),`ToolboxItem` 类型收口在 `header/types.ts` -- `src/app/components/settings/*`:设置面板子页与 about pane - -当前 `app/hooks/*` 职责: - -- `useAppShellState.ts` / `useAppEffects.ts` / `useAppLayoutEffects.ts` / `useAppState.ts`:App shell、mode/panel、副作用与 layout 编排 -- `useViewerOrchestration.ts`:selection / hover / pulse / focus / transform pending 协调 -- `useFileImport.ts`:App 级导入流程;组合 `store + parsers + feature file-io` -- `useFileExport.ts`:App 级导出流程;组合当前 viewer 状态、assembly history、USD roundtrip、project export、archive assets -- `hooks/file-export/*`:导出 workflow 的 helper 子树,当前主要包含 `src/app/hooks/file-export/assemblyHistory.ts`、`src/app/hooks/file-export/progress.ts`、`src/app/hooks/file-export/projectExport.ts`、`src/app/hooks/file-export/usdExport.ts` -- `useWorkspaceSourceSync.ts` / `useWorkspaceMutations.ts` / `useLibraryFileActions.ts`:workspace、source code、assembly、library 文件行为编排 -- `src/app/hooks/useWorkspaceModeTransitions.ts` / `src/app/hooks/useWorkspaceOverlayActions.ts`:workspace 视图切换、浮层动作与 UI 呈现收口 -- `src/app/hooks/usePreparedUsdViewerAssets.ts` / `src/app/hooks/useAnimatedWorkspaceViewerRobotData.ts` / `src/app/hooks/useSourceCodeEditorWarmup.ts`:viewer 资产预热、workspace viewer 动画数据与代码编辑器预热 -- `src/app/hooks/useImportInputBinding.ts`:App 级文件输入绑定与导入入口整合 -- `src/app/hooks/useEditableSourcePatches.ts` / `src/app/hooks/useUnsavedChangesPrompt.ts`:编辑源码 patch 生命周期与离开保护 -- `useCollisionOptimizationWorkflow.ts`:碰撞优化 UI 流程与状态协调 -- `usePendingHistoryCoordinator.ts`:pending history 与 viewer/export 生命周期协同 -- `useToolItems.tsx`:工具箱工具注册表;定义有哪些工具、每个工具怎么打开,返回 `items` 与 `openTool(key)` 统一路由。新增内部工具时**唯一需要改的文件** -- `robotImportWorkerBridge.ts` / `importPreparationWorkerBridge.ts`:App 层 worker bridge,连接导入准备与 robot import worker - -当前 `app/utils/*` 重点: - -- USD/roundtrip/hydration:`usdExportContext.ts`、`usdHydrationPersistence.ts`、`usdStageHydration.ts`、`liveUsdRoundtripExport.ts`、`usdRoundtripExportArchive.ts` -- 导出辅助:`exportArchiveAssets.ts`、`usdBinaryArchive.ts`、`urdfSourceExportUtils.ts`、`currentUsdExportMode.ts` -- 编辑器与显示:`sourceCodeDisplay.ts`、`sourceCodeEditorLoader.ts` -- 历史与缓存:`pendingHistory.ts`、`pendingUsdCache.ts` -- 导入准备 / 文档加载:`documentLoadFlow.ts`、`contentChangeAppMode.ts`、`importPreparation.ts`、`importPreparationTransfer.ts`、`importPackageAssetReferences.ts`、`contextualPreResolvedImports.ts` -- workspace/source:`workspaceGeneratedSourceState.ts`、`workspaceSourceSyncUtils.ts`、`workspaceViewerAnimation.ts`、`workspaceViewerPresentation.ts` -- Unified viewer 切换与 viewport handoff:`unifiedViewerForcedSessionState.ts`、`unifiedViewerHandoffReadyState.ts`、`unifiedViewerLoadReleaseState.ts`、`unifiedViewerOptionsRestore.ts`、`unifiedViewerResourceScopes.ts`、`unifiedViewerSceneMode.ts`、`unifiedViewerSceneProps.ts`、`unifiedViewerViewportState.ts`、`viewerViewportHandoff.ts` -- Worker payload / transfer:`robotImportWorkerPayload.ts`、`importPreparationTransfer.ts`、`usdBinaryArchiveWorkerTransfer.ts` -- App workers:`app/workers/importPreparation.worker.ts`、`app/workers/robotImport.worker.ts`、`app/workers/usdBinaryArchive.worker.ts` - -约束: - -- 只要逻辑横跨多个 store、多个 feature、viewer 当前状态或 hydration/export 生命周期,优先放在 `app`。 -- 单一 feature 内闭环的逻辑不要硬塞进 `app`。 -- `features/file-io` 负责底层文件能力,`app/hooks/useFileImport.ts` / `useFileExport.ts` 才是当前应用工作流 source of truth。 -- `app` 当前会直接 deep import 若干 feature 内部 `utils/*`;新增长期编排能力时,优先通过 feature 的 `index.ts` 或 facade 暴露稳定入口,避免 `app` 继续绑死内部文件布局。 - -## 7. Editor 统一 Viewer 子域要求(`editor` 入口 + `urdf-viewer` 实现) - -本节即该子域的规范入口(不再维护旧的双入口描述)。 - -3D / Three.js / R3F / WebGL 相关任务补充要求: - -- 若任务涉及 3D 场景、Three.js、React Three Fiber、WebGL 或 `Editor` 两个 viewer 子域能力,agent 必须优先使用 `threejs-skills` skill。 -- 在满足本仓库架构约束的前提下,优先复用该 skill 中关于场景组织、材质、动画循环、交互和资源释放的最佳实践。 - -当前结构: - -- `src/features/editor/index.ts` -- `src/features/urdf-viewer/components/*` -- `src/features/urdf-viewer/hooks/*` -- `src/features/urdf-viewer/utils/*` -- `src/features/urdf-viewer/runtime/*` - -关键约束: - -- 扩展功能优先放入 hooks 或新增组件,避免回到独立壳组件形态。 -- 保持 `RobotNode <-> JointNode` 交替递归渲染模式。 -- TransformControls 与拖拽链路依赖的引用注册必须完整可追踪。 -- 材质必须通过 `materials.ts` / `urdfMaterials.ts` 或共享工厂复用,不要在高频路径直接 `new` 材质。 -- 使用 `RobotState` 与现有共享类型,避免 `any`。 -- 与闭环、碰撞拖拽相关的能力优先复用: - - `useViewerController` - - `useMouseInteraction` - - `useHoverDetection` - - `useVisualizationEffects` - -## 8. Editor 几何/碰撞/测量子域要求(`urdf-viewer` 目录) - -对应文档:`docs/prompts/urdf-viewer.md` - -当前结构: - -- React 层: - - `src/features/urdf-viewer/components/*` - - `src/features/urdf-viewer/hooks/*` - - `src/features/urdf-viewer/types.ts` -- 运行时与嵌入层: - - `src/features/urdf-viewer/runtime/embed/*` - - `src/features/urdf-viewer/runtime/hydra/*` - - `src/features/urdf-viewer/runtime/types/*` - - `src/features/urdf-viewer/runtime/vendor/*` - - `src/features/urdf-viewer/runtime/viewer/*` - - `src/features/urdf-viewer/runtime/UPSTREAM.md` -- 工具与适配层: - - `src/features/urdf-viewer/utils/*` -- Worker 层: - - `src/features/urdf-viewer/workers/*` - -关键边界: - -- `runtime/*` 是 vendored usd-viewer runtime,当前来源见 `src/features/urdf-viewer/runtime/UPSTREAM.md`。 -- URDF Studio 应该把 runtime 输出适配到自己的 `ViewerRobotDataResolution` / `RobotData`,不要在 `src/core/parsers/usd/*` 重复实现 viewer runtime 职责。 -- `public/usd/bindings/*` 必须保持可在浏览器运行时 fetch,不要迁入源码模块。 - -关键文件与职责: - -- `UsdWasmStage.tsx`:WASM stage 嵌入入口 -- `UsdOffscreenStage.tsx`:offscreen canvas + worker 模式下的 USD viewer 宿主 -- `src/features/urdf-viewer/components/ViewerCanvas.tsx`:viewer 画布层与共享 canvas 适配 -- `src/features/urdf-viewer/components/ViewerToolbar.tsx`:viewer 顶部工具条 -- `src/features/urdf-viewer/components/ViewerLoadingHud.tsx`:viewer/stage loading 状态 HUD 与反馈层 -- `src/features/urdf-viewer/utils/viewerRobotData.ts`:统一 viewer 层消费的数据形态 -- `src/features/urdf-viewer/utils/viewerResourceScope.ts`:围绕 source file / assets / robot links 构建稳定资源域 -- `src/features/urdf-viewer/utils/usdExportBundle.ts`:USD viewer 场景快照与导出缓存协调 -- `src/features/urdf-viewer/utils/usdRuntimeRobotHydration.ts`:runtime -> RobotData hydration -- `src/features/urdf-viewer/utils/usdSceneRobotResolution.ts`:场景级 robot resolution -- `src/features/urdf-viewer/utils/usdViewerRobotAdapter.ts`:viewer runtime / snapshot 到应用数据的适配 -- `src/features/urdf-viewer/utils/usdOffscreenViewerProtocol.ts`:主线程与 offscreen worker 的协议与消息形态 -- `src/features/urdf-viewer/utils/usdOffscreenViewerWorkerClient.ts`:主线程对 offscreen worker 的请求封装 -- `src/features/urdf-viewer/utils/usdWorkerRendererSupport.ts` / `src/features/urdf-viewer/utils/usdWorkerOrbit.ts`:worker 渲染支持与轨道控制协同 -- `src/features/urdf-viewer/utils/usdStageOpenPreparationWorkerBridge.ts` / `src/features/urdf-viewer/utils/usdPreparedExportCacheWorkerBridge.ts`:prepared-open / prepared-export 链路的 worker bridge -- `src/features/urdf-viewer/utils/runtimeSceneMetadata.ts`:runtime scene metadata 的标准化读模型 -- `src/features/urdf-viewer/utils/visualizationFactories.ts`:辅助可视化对象创建 -- `src/features/urdf-viewer/utils/dispose.ts`:THREE 资源清理 - -关键约束: - -- 优先复用 `utils/materials.ts`、`utils/urdfMaterials.ts`、`utils/dispose.ts`,不要散落临时材质与几何体。 -- Props 与共享类型统一收口到 `types.ts`。 -- 可视化对象新增优先通过 `visualizationFactories.ts`。 -- 卸载、切换、reload、hydration rollback 时必须确认 THREE 资源释放。 -- `JointsPanel` 仍位于 `src/shared/components/Panel/JointsPanel.tsx`,共享逻辑不要回灌到 viewer 私有层。 - -### 8.1 USD worker / metadata 链路约束(2026-03-30) - -适用范围: - -- `src/features/urdf-viewer/runtime/hydra/render-delegate/*` -- `src/features/urdf-viewer/workers/*` -- `src/features/urdf-viewer/utils/usd*` -- `src/app/hooks/useFileImport.ts` / `src/app/hooks/useFileExport.ts` 中会消费 worker 结果的 USD 工作流 - -必须遵循: - -- 涉及 USD stage preparation、runtime metadata extraction、robot hydration、prepared export cache、roundtrip archive 的修复,默认优先放在 `worker/runtime` 链路里完成,不要为了“先跑通”把修复偷偷搬到主线程 adapter 或 debug bridge。 -- `runtime/hydra/render-delegate/*` 产出的 metadata snapshot 是该链路的 source of truth;若 snapshot 缺字段,应修 worker/runtime 生成逻辑,而不是在上层 UI、store 或 regression bridge 做补丁式回填。 -- 禁止新增“worker 结果缺失 -> 主线程重建 metadata -> 静默继续”的 fallback。worker 真失败就应显式暴露,或直接把根因修在 worker。 -- 对 folded fixed link、collision-only semantic child link、synthetic parent-child pair 这类推断,只能基于 stage/truth 中的明确证据推进;不要做纯命名猜测式补 link。 -- 若 stage 原始数据本身不包含目标语义(例如 fixture 里没有对应 child link 证据),应如实暴露这一事实,不要在 UI 层伪造“看起来正确”的 metadata。 - -验证要求: - -- 改动上述链路时,除定向单测外,必须额外跑 `test/unitree_model` 的整套 USD 浏览器批量验证,确认所有主要 Unitree 样本仍满足: - - `loaded = true` - - `stageReady = true` - - `stagePreparationMode = "worker"` - - `metadataSource` 指向 worker/runtime 结果(当前应为 `usd-stage-cpp` 或同类 worker source) -- 浏览器批量验证产物默认写入 `tmp/regression/`,并在最终回复中明确说明结果文件路径。 -- 若 `test/unitree_model` fixture 缺少外部 mesh 资源,允许出现可解释的 mesh lookup error;但这不能作为跳过 worker metadata 验证、跳过 hydration 验证或引入 fallback 的理由。 - -### 8.2 USD offscreen / runtime 生命周期约束(2026-03-30) - -适用范围: - -- `src/features/urdf-viewer/components/UsdOffscreenStage.tsx` -- `src/features/urdf-viewer/workers/usdOffscreenViewer.worker.ts` -- `src/features/urdf-viewer/runtime/hydra/render-delegate/*` -- `src/shared/utils/three/dispose.ts` - -必须遵循: - -- worker/offscreen renderer 的主线程宿主只负责 handoff、尺寸同步与错误透传;不要在主线程重新构建 runtime truth 或默默补 runtime 缺失状态。 -- teardown 必须完整释放 observer、DOM/worker 事件监听、RAF/timer、OffscreenCanvas 关联 runtime、scene graph 与 driver 引用。 -- 若 runtime 注册了全局 handler / registry / active owner,新增能力时必须同时提供对称的 unregister/reset;禁止只注册不解绑。 -- worker 侧若创建了 `ImageBitmap`、object URL、临时 geometry/material/texture,销毁路径必须显式 close/revoke/dispose,不能假设 GC 或 `Texture.dispose()` 会兜底。 -- 任何“reload 后继续复用旧 delegate / 旧 render interface / 旧 stage metadata”的做法都必须有明确生命周期边界,禁止通过全局单例把旧实例挂死。 - -## 9. File I/O、Workspace 与导出链路 - -当前职责拆分: - -- `src/features/file-io/`:格式检测、BOM、project import/export、archive/asset registry、USD/SDF export、ExportDialog/ExportProgressDialog、snapshot/pdf hooks、导入/导出 worker bridge -- `src/app/hooks/useFileImport.ts`:应用级导入工作流 -- `src/app/hooks/useFileExport.ts`:应用级导出工作流 -- `src/app/hooks/file-export/*`:应用导出流程的子模块 helper;当前主要包括 `assemblyHistory.ts`、`progress.ts`、`projectExport.ts`、`usdExport.ts` -- `src/features/robot-tree/`:structure/workspace 文件树、树编辑器、上下文菜单、布局;组件层已拆分为 `tree-editor/*` 与 `tree-node/*` -- `src/features/assembly/`:桥接组件创建与组装入口 -- `src/features/property-editor/`:属性编辑、几何编辑、碰撞优化、`geometry-conversion/*` 与 mesh worker - -当前工作流事实: - -- `features/file-io/hooks/useFileExport.ts` 已移除,应用导出 source of truth 在 `app/hooks/useFileExport.ts`。 -- 应用导入 source of truth 在 `src/app/hooks/useFileImport.ts`;不要在 `features/file-io` 内恢复旧导入 hook。 -- 新增导出辅助逻辑时,优先补到 `app/hooks/file-export/*`,而不是把 `useFileExport.ts` 重新做成大而全单文件。 -- `.usp` project import/export、USD prepared export cache、live USD roundtrip archive 已进入主工作流。 -- `projectArchive.worker.ts`、`usdExport.worker.ts`、`usdBinaryArchive.worker.ts` 已进入主导出链路;涉及大型归档或序列化任务时,优先走现有 worker/transfer 路径。 -- `projectImport.worker.ts` 已进入 project import 链路;项目归档导入相关问题优先在 worker/bridge 修,不要把补丁塞回 UI。 -- `DisconnectedWorkspaceUrdfExportDialog.tsx` 是 workspace 断联 URDF 导出的特例入口;相关逻辑不要重新塞回通用导出弹层。 -- `ExportProgressDialog.tsx` / `ExportProgressView.tsx` 是当前长时导出反馈的统一 UI,不要在业务层重新发明一套导出进度弹层。 -- `output/` 可包含用户可见导出与回归结果;新的临时浏览器验证产物仍默认写入 `tmp/`。 - -多 URDF 组装相关改动,需重点检查: - -- 命名空间前缀冲突规避 -- `BridgeJoint` 连接合法性 -- 合并导出行为一致性 -- workspace 与 structure 视图切换时的 source file / selected file 同步 - -## 10. Shared / Core / Lib 约束 - -`shared/`: - -- `shared/components/3d/*` 是双 viewer 共享的 3D 基础设施 source of truth。 -- 当前已分化出 `scene/*`、`renderers/*`、`helpers/*`、`unified-transform-controls/*` 等子树;涉及画布基础设施时优先先定位到对应子目录,而不是直接在 viewer 组件层搜索。 -- `shared/components/3d/workspace/*` 已承载共享 `WorkspaceCanvas` 宿主、renderer cleanup 与 WebGL 能力检查;App 层画布入口主要做业务编排。 -- 优先复用: - - `ReferenceGrid` - - `GroundShadowPlane` - - `SceneLighting` - - `WorkspaceOrbitControls` - - `UnifiedTransformControls` - - `SnapshotManager` - - `TransformConfirmOverlay` -- `MeshAssetNode.tsx`、`MeshRenderers.tsx` 与 `shared/components/3d/renderers/*` 属于共享 mesh 呈现层;涉及 primitive/object 生命周期时优先在这里集中治理。 -- `GLTFRendererImpl.tsx` 已存在于共享渲染层;不要在 feature 内重新拼 glTF 渲染入口。 -- 通用 THREE 释放优先使用 `shared/utils/three/dispose.ts` 或 viewer 已有 `dispose.ts`。 -- `shared/workers/closedLoopMotionPreview.worker.ts` 是共享 worker,不要把同类预览逻辑复制回 feature 私有 worker。 -- `shared/debug/regressionBridge.ts` 仅做调试/回归桥接,不要让业务层依赖调试 API 才能工作。 -- DAE / OBJ / STL mesh 渲染优先复用 `shared/components/3d/renderers/*`,其底层 mesh/collada 处理依赖 `core/loaders/*`;不要在 feature 组件里重新拼一套 loader + renderer 管线。 - -`core/`: - -- 当前包含 `parsers/`、`loaders/`、`loaders/workers/`、`robot/`、`stl-compressor/`、`utils/`。 -- 维持纯逻辑与可测试性,不引入 React/UI/DOM。 -- Mesh、MJCF、URDF、USD、Xacro 解析器与 robot algebra 统一留在 `core/`。 -- `core/utils/runtimeDiagnostics.ts`、`core/utils/ensureWorkerXmlDomApis.ts` 等运行时辅助也归 `core`,不要把这类纯工具上移到 feature。 - -`lib/`: - -- 当前主要对外暴露 `RobotCanvas`、`lib/types.ts`、`lib/styles.css` 与 `lib/hooks/useControllableState.ts`。 -- 只有确认“与应用壳无关、可稳定发布”的能力才允许抽入 `lib/`。 -- `packages/react-robot-canvas/dist/*` 当前作为包发布产物保留在仓库内;除非任务明确针对发包结果,禁止手改这些文件,统一通过包内 build / `scripts/postbuild.mjs` / prepack 流程生成。 - -## 11. 状态管理与文件职责提示 - -Zustand 关键 Store: - -- `robotStore`:模型 CRUD、Undo/Redo、派生计算、closed loop constraints -- `uiStore`:mode/theme/lang/sidebar/panels/view options 等(含持久化) -- `selectionStore`:选中、悬停、pulse、focus -- `assetsStore`:mesh/texture/robot files/motor library/USD scene snapshot/prepared export cache -- `assemblyStore`:多 URDF 组装、组件管理、BridgeJoint、组装历史 -- `assemblySelectionStore`:workspace 中的组件 / bridge / source file 选区作用域 -- `collisionTransformStore`:碰撞 gizmo 的瞬时 pending transform -- `jointInteractionPreviewStore`:跨 viewer 的关节交互预览与瞬时状态 - -约束: - -- 共享 UI 组件不要直接操纵多个 store;跨 store 协调优先放 `app/hooks/*`。 -- 新增持久化字段前,先确认是否属于 `uiStore` / `assetsStore` 的长期状态,而不是一次性交互缓存。 -- USD hydration / roundtrip 中间态优先落在 `assetsStore` 缓存或 `app/utils/*`,不要在组件局部散落。 - -## 12. UI 风格与可访问性要求 - -对应文档:`docs/prompts/URDF_STUDIO_STYLE_GUIDE.md` - -必须遵循: - -- 使用语义色 token,不散落硬编码色值 -- 组件在 `light + dark + prefers-contrast: more` 下都需可读 -- 暗色层次使用 `base/surface/elevated`,避免纯黑硬切 -- 状态表达不只依赖颜色,补充图标/文本/形态差异 -- Focus 态可见,建议统一 `ring-system-blue/30` - -蓝色强约束: - -- `#0088FF` 仅用于 Slider/进度线/细线型高亮(`slider-accent`) -- `#0088FF` 禁止用于主按钮实底、小字号正文链接、大面积背景 -- 主按钮底色使用 `system-blue-solid`,文本/图标强调用 `system-blue` - -面板文案约束: - -- 常驻工具面板默认使用短标签、短标题、短状态文案;不要为了“解释清楚”塞整段说明。 -- 像测量、吸附、显示开关这类高频操作,优先直接提供可选项,不额外重复解释“该选项是什么”。 -- 只有首次使用门槛高、流程较长或存在明显误操作成本的区域(如 toolbox、批量优化、复杂导入导出流程)才保留简短 helper copy。 -- 若一个面板已经能通过标题、字段名、占位文案和按钮标签表达清楚,就删除冗余说明文本。 - -## 13. 代码变更工作流(建议) - -1. 定位任务所属模式与模块边界。 -2. 明确改动是在 `app` 编排层、单一 `feature`、还是 `shared/core` 通用层。 -3. 检查依赖方向是否符合架构红线,避免新增例外。 -4. 优先复用现有 hooks/utils/components,不重复造轮子。 -5. 保持类型完整性,避免 `any`。 -6. 涉及 3D/USD/mesh 时,检查材质缓存、资源释放、hydration/export 生命周期。 -7. 做最小必要改动并验证。 - -单文件与模块化策略: - -- 默认优先模块化拆分,不把新能力堆到单个大文件。 -- 允许单文件:仅限小改动(文案、样式微调、局部 bug 修复)且不引入新职责。 -- 必须拆分的场景: - - 同时引入“状态 + 视图 + 业务逻辑” - - 新增可复用逻辑(优先抽为 hook/utils) - - 文件已明显过大且继续修改会降低可维护性 -- 对 `Editor` 两个 viewer 子域 / `AppLayout` / `UnifiedViewer` 的改动,优先新增 hooks/components,避免继续增厚主组件。 - -当前明确热点文件(新增逻辑优先抽离,而不是继续增厚): - -- `src/features/property-editor/utils/geometryConversion.ts` -- `src/features/file-io/utils/usdExport.ts` -- `src/features/urdf-viewer/components/UsdWasmStage.tsx` -- `src/features/urdf-viewer/utils/usdExportBundle.ts` -- `src/app/hooks/useFileExport.ts` -- `src/app/AppLayout.tsx` - -## 14. 浏览器/MCP 截图与收尾清理 - -- 启动浏览器时默认使用无头模式(`headless: true` / `--headless`),除非用户明确要求打开可见浏览器窗口。 -- 若任务中使用了 Chrome DevTools、Playwright、浏览器自动化或其他 MCP 工具,浏览器验证产物默认统一写入仓库内 `tmp/`,可按需放在 `tmp/screenshots/`、`tmp/playwright/`、`tmp/chrome-devtools/` 等子目录。 -- 禁止将浏览器截图或其他临时验证产物直接写到仓库根目录。 -- `output/` 仅用于用户可见导出结果、回归归档或明确要保留的产物;临时截图/trace 不要默认写入 `output/`。 -- 浏览器截图前,默认先关闭会遮挡主体画面的侧栏、浮层和调试面板,尤其是 `Editor` 的选项面板。 -- 除非任务明确要求保留 UI 状态,截图应优先提供无遮挡 clean shot;如需展示面板,建议额外补一张无遮挡截图。 -- 完成验证后必须清理残留上下文,关闭不再使用的浏览器标签页、DevTools 面板、Playwright 页面、隔离 context 与临时调试会话。 -- 若测试过程中启动了额外的浏览器实例、调试端口、后台线程、本地预览服务或临时辅助进程,结束前应主动关闭,除非用户明确要求保留。 -- 最终回复中,若保留了任何必须继续运行的进程,需要明确说明用途与访问地址;否则默认应清理干净。 - -## 15. 验收清单 - -- [ ] Light / Dark / 高对比模式下可读性通过 -- [ ] 无新增分散硬编码颜色 -- [ ] 3D 资源无明显泄漏(材质/几何体/纹理释放) -- [ ] worker/offscreen 生命周期完整释放(listener / observer / timer / worker / global handler / ImageBitmap) -- [ ] 新增的 observer / listener / timer / object URL / ImageBitmap / pending request map 均有对称 cleanup 或容量上限 -- [ ] USD hydration / roundtrip / export 未破坏当前 source-of-truth 流程 -- [ ] 浏览器验证产物已放入 `tmp/`,未新增根目录截图/trace -- [ ] 浏览器测试结束后无残留 Chrome DevTools / Playwright 会话、线程或临时进程 -- [ ] 变更符合模块职责,没有破坏依赖方向 -- [ ] 新增依赖未扩大现有架构例外 -- [ ] 未新增 silent fallback / 吞错式兜底,异常路径仍可定位和 debug -- [ ] import/export/hydration 等 source-of-truth worker bridge 在失败时显式报错,没有静默主线程降级 -- [ ] 若改动 USD worker / metadata 链路,已完成 `test/unitree_model` 全量浏览器验证且结果落盘到 `tmp/regression/` -- [ ] 若改动运行时代码,已完成对应测试或构建验证 - -推荐命令: +常用命令: ```bash npm run dev npm run lint npm run typecheck npm run test -npm run build npm run verify:fast npm run verify:full - -# 快速查看当前模块结构 -find src -maxdepth 3 -type d | sort - -# 查看 app / features 主入口 -find src/app src/features -maxdepth 2 -type f | sort | sed -n '1,240p' - -# 大仓库搜索时默认排除 vendor / 产物 / 临时目录 -rg -n "pattern" src docs scripts packages/react-robot-canvas \ - -g '!test/**' -g '!.tmp/**' -g '!tmp/**' -g '!dist/**' -g '!node_modules/**' - -# 检查潜在反向依赖(重点关注 core/shared/store 对 features 的引用) -rg -n "from ['\"]@/features/" src/core src/shared src/store - -# 检查 feature 间直接耦合(控制存量,不新增) -rg -n "from ['\"]@/features/" src/features - -# 检查 shared 对 store 的依赖(控制存量,不新增) -rg -n "from ['\"]@/store/" src/shared - -# 检查硬编码色值 -rg -n "#[0-9A-Fa-f]{3,8}" src - -# 检查 #0088FF 使用范围 -rg -n "#0088FF|#0088ff" src | rg -v "Slider.tsx|styles/index.css" - -# 仓库级快速验证 -npm run check -npm run test:unit:app-hooks - -# USD worker / metadata 链路回归 -node --test \ - src/features/urdf-viewer/runtime/hydra/render-delegate/robot-metadata-stage-fallback.test.js \ - src/features/urdf-viewer/runtime/hydra/render-delegate/folded-fixed-link-truth.test.js - -npx tsx --test \ - src/features/urdf-viewer/utils/usdViewerRobotAdapter.test.ts \ - src/features/urdf-viewer/utils/usdRuntimeRobotHydration.test.ts - -# Unitree roundtrip / archive 样本验证 -npx tsx scripts/regression/validate_unitree_model_roundtrip_archive.ts - -# 现成 fixture 回归入口 -npm run test:fixtures:imports -npm run test:fixtures:myosuite-imports -npm run test:fixtures:unitree-usd -npm run test:fixtures:unitree-ros-urdfs -npm run test:fixtures:unitree-ros-usda - -# 打包对外库(仅在改到 src/lib 或 packages/react-robot-canvas 时执行) -npm run build:package:react-robot-canvas - -# 额外工具 -bash scripts/inspect_urdf.sh path/to/robot.urdf --compact -npm run codex:retry -npm run codex:key-router:deploy:dry ``` 说明: -- 根 `package.json` 当前已提供统一的 `npm run lint`、`npm run test`、`npm run build`、`npm run check`、`npm run verify:fast`、`npm run verify:fixtures`、`npm run verify:full` 脚本;默认优先用这些入口,再按模块补跑定向测试。 - -## 16. 常用 test 样本索引(给 agent 直接复用) - -当任务需要真实模型做导入、viewer、hydration、roundtrip、导出或浏览器回归验证时,优先复用 `test/` 里现成样本,不要临时去外网再找模型。 - -### 16.1 USD / worker / roundtrip 主样本 - -- `test/unitree_model/Go2/usd/go2.usd` - - Unitree 四足基准样本;适合 USD stage open、worker metadata、hydration、viewer smoke test。 -- `test/unitree_model/Go2W/usd/go2w.usd` - - Go2 轮足变体;适合验证变体资产、命名差异与 roundtrip 稳定性。 -- `test/unitree_model/B2/usd/b2.usd` - - 更大体量四足样本;适合检查 stage truth、folded fixed link 与复杂 link/joint 结构。 -- `test/unitree_model/H1-2/h1_2/h1_2.usd` - - Humanoid USD 样本;适合验证双足/人形链路与 viewer hydration。 -- `test/unitree_model/H1-2/h1_2_handless/h1_2_handless.usd` - - H1-2 handless 变体;适合检查 asset/配置差异下的 runtime 行为。 -- `test/unitree_model/B2/usd/b2.viewer_roundtrip.usd` -- `test/unitree_model/Go2/usd/go2.viewer_roundtrip.usd` -- `test/unitree_model/Go2W/usd/go2w.viewer_roundtrip.usd` - - 以上 roundtrip 产物可用于导出后 diff、回归对照与 viewer roundtrip 验证。 - -默认规则: - -- 只要改动 USD worker、runtime metadata、hydration、prepared export cache、roundtrip archive,默认先用 `test/unitree_model`。 -- 不要只拿单个 Go2 过一遍就宣称链路稳定;这类改动默认至少覆盖 `Go2 + B2 + H1-2`。 - -### 16.2 SDF / Gazebo 资产链路样本 - -- `test/gazebo_models/camera/model.sdf` - - 轻量 smoke 样本;适合快速验证 SDF 导入是否整体可用。 -- `test/gazebo_models/cordless_drill/model.sdf` - - 小型对象样本;同时包含 `DAE + STL + texture`,适合 mesh/材质路径回归。 -- `test/gazebo_models/bus_stop/model.sdf` - - 复合场景样本;包含多 mesh、贴图与混合格式,适合资源解析和路径解析回归。 -- `test/gazebo_models/apartment/model.sdf` - - 大场景样本;适合验证大体量静态环境、纹理与 viewer 性能/稳定性。 -- `test/gazebo_models/camera/model-1_2.sdf` -- `test/gazebo_models/camera/model-1_3.sdf` -- `test/gazebo_models/camera/model-1_4.sdf` -- `test/gazebo_models/cordless_drill/model-1_2.sdf` -- `test/gazebo_models/cordless_drill/model-1_3.sdf` -- `test/gazebo_models/cordless_drill/model-1_4.sdf` - - 版本化 SDF 文件可用于验证不同 schema/version 兼容性。 - -### 16.3 URDF / 外部仓库镜像样本 - -- `test/awesome_robot_descriptions_repos/anymal_c_simple_description/urdf/anymal.urdf` - - 纹理与 `DAE` 较完整的四足 URDF;适合常规 URDF viewer/import 回归。 -- `test/awesome_robot_descriptions_repos/mini_cheetah_urdf/urdf/mini_cheetah.urdf` - - 混合 `OBJ/STL` 资产链路;适合 mesh loader 与相对路径解析回归。 -- `test/awesome_robot_descriptions_repos/cassie_description/urdf/cassie_v4.urdf` - - 双足/人形 URDF;适合复杂关节层级和碰撞/惯性链路检查。 -- `test/awesome_robot_descriptions_repos/fanuc_m710ic_description/urdf/m710ic70.urdf` - - 工业机械臂;适合关节轴、层级清晰度与属性编辑面板回归。 -- `test/awesome_robot_descriptions_repos/models/franka_description/urdf/panda_arm_hand.urdf` - - `gltf + ktx2 + png/bin` 资产链路;适合现代 mesh 资源解析与纹理引用验证。 -- `test/awesome_robot_descriptions_repos/onshape-to-robot-examples/quadruped_urdf/robot.urdf` - - 结构较直接的 Onshape 导出样本;适合快速排查导入器对简化 URDF 的兼容性。 - -### 16.4 MJCF / MuJoCo 样本 - -- `test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_go2/go2.xml` - - 标准 MuJoCo menagerie 样本;适合 MJCF 导入与 Unitree 对照验证。 -- `test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_go2/scene.xml` - - 带场景包装的 MJCF;适合检查 scene 级引用与资源装配。 - -### 16.5 样本选择建议 - -- 快速 smoke: - - `test/gazebo_models/camera/model.sdf` - - `test/awesome_robot_descriptions_repos/fanuc_m710ic_description/urdf/m710ic70.urdf` - - `test/unitree_model/Go2/usd/go2.usd` -- 资源加载/路径解析回归: - - `test/gazebo_models/bus_stop/model.sdf` - - `test/awesome_robot_descriptions_repos/models/franka_description/urdf/panda_arm_hand.urdf` - - `test/awesome_robot_descriptions_repos/mini_cheetah_urdf/urdf/mini_cheetah.urdf` -- 复杂层级/人形链路: - - `test/unitree_model/H1-2/h1_2/h1_2.usd` - - `test/awesome_robot_descriptions_repos/cassie_description/urdf/cassie_v4.urdf` -- USD worker / metadata / roundtrip: - - 整套 `test/unitree_model` - -使用要求: - -- 在任务描述、脚本参数、回归记录里直接写具体文件路径,不要只写“跑一下 test 里的模型”。 -- 若任务只影响某一种格式,优先选对应格式样本,不要默认把所有大样本都跑一遍。 -- 若新增了长期稳定、可重复复用的高价值样本,可顺手补充到本节,而不是只留在临时聊天记录里。 - -## 17. Prompt 编写建议(给 AI 指令使用者) - -- 具体化:明确 `Link` / `Joint` 名称、源文件名、当前模式 -- 结构化:描述期望父子连接关系、workspace 还是 structure 视图、是否涉及 merged assembly -- 导出/回归:说明目标格式(`URDF` / `MJCF` / `USD` / `.usp`)、是否要保留 roundtrip 能力 -- 物理约束:涉及电机替换时给出力矩、减速比、阻尼/摩擦范围 - -以上可显著提升 AI 输出的可执行性与一致性。 - -## 18. 近期结构变化记录(2026-03 / 2026-04-06) - -- App 编排层已明显增厚:导入导出、USD hydration、workspace/source sync、overlay lazy loading 主要在 `src/app/*` 完成。 -- `src/app/*` 新增了 document loading / import preparation / robot import / usd binary archive 等 worker bridge 与 transfer utils;导入和大文件导出不再只靠主线程串行处理。 -- `src/app/components/unified-viewer/*` 与 `src/app/hooks/file-export/*` 已拆成稳定子树;`UnifiedViewer.tsx` / `useFileExport.ts` 作为入口编排,细分逻辑优先放到这些子模块。 -- `src/app/components/WorkspaceCanvas.tsx` 当前只是应用层导出入口;共享画布 runtime、WebGL 检查、renderer cleanup 与 error boundary 已沉到 `src/shared/components/3d/workspace/*`。 -- `UnifiedViewer` 周边已新增一批 forced session、handoff ready、load release、resource scope、viewport handoff 工具;涉及 viewer 切换状态时优先复用这些工具,而不是在组件里散写本地状态机。 -- `features/file-io/hooks/useFileExport.ts` 已移除,当前应用导出工作流 source of truth 为 `src/app/hooks/useFileExport.ts`。 -- `features/file-io` 目前仅保留 `usePdfExport.tsx`、`useSnapshot.ts` 等局部 hooks;应用导入工作流 source of truth 为 `src/app/hooks/useFileImport.ts`。 -- `features/file-io/*` 目前已接入 `projectArchive.worker.ts`、`projectImport.worker.ts`、`usdExport.worker.ts` 与 `ExportProgressDialog.tsx`,project import/export 与长时导出默认应走 worker + progress UI。 -- `robot-tree` 已拆成 `tree-editor/*` 与 `tree-node/*` 子树;结构树 UI 与文件浏览器 UI 不再继续堆到单个组件里。 -- `property-editor` 已形成 `components/* + hooks/* + utils/geometry-conversion/* + workers/*` 结构;几何转换和 mesh analysis 优先在 util/worker 链路解决。 -- `urdf-viewer` 已形成“React 层 + vendored runtime + adapter/utils + workers”结构,并新增 `UsdOffscreenStage.tsx`、`usdOffscreenViewer.worker.ts` 与 offscreen 协议/渲染支持工具。 -- `urdf-viewer/runtime/*` 现已细分出 `types/*` 与 `vendor/*` 子树;涉及上游 vendored runtime 的协议类型与兼容性适配优先落这里。 -- `urdf-viewer` 已新增一批 USD roundtrip/hydration/resolution/prepared-open/prepared-export 工具,相关 source-of-truth 默认收口在 runtime/worker 链路。 -- `shared/data/` 与 `shared/debug/` 已加入仓库,分别承载共享静态数据和回归调试桥接。 -- `shared/workers/*` 与 `core/loaders/workers/*` 已进入常规结构,worker 并行能力不再只存在于 feature/app 层。 -- 根 `package.json` 已提供统一的 `lint` / `test` / `check` / `verify:*` 入口;文档与 agent workflow 不应再假设“只能按模块手拼命令”。 -- collision overlay material / render order 已下沉到 `src/shared/utils/three/collisionOverlayMaterial.ts`,`core` 不应再直接依赖 `features/urdf-viewer/utils/materials`。 -- `src/lib/*` 与 `packages/react-robot-canvas/*` 已进入稳定工作区,用于通用画布封装与发布。 -- `src/lib/` 当前已细分 `components/*` 与 `hooks/*`;对外复用逻辑不再只靠单一 `index.ts` 暴露。 -- `packages/react-robot-canvas/` 当前附带 `dist/` 发布产物,默认由包构建脚本与 `scripts/postbuild.mjs` 维护。 -- `docs/` 现已补充 `architecture-boundaries.md`、`robot-canvas-lib.md`、`runtime-fallback-audit.md`,做边界说明、对外库说明与 runtime fallback 审计参考。 -- `test/` 中包含外部工程镜像和大型样本,修改前先确认是否真的是本次任务范围。 -- 2026-03-30:USD runtime metadata 的 folded collision semantic child link 修复已明确收口到 `runtime/hydra/render-delegate/*` 的 worker/runtime 链路,不应再把同类问题修到主线程 adapter、UI store 或 regression bridge。 -- 2026-03-30:`test/unitree_model` 目前是 USD worker/stage metadata 链路的主浏览器回归样本集;后续改动该链路时,默认要补跑整套 Unitree 验证,而不是只跑单个 Go2 fixture。 -- 2026-04-11:ToolboxMenu 引入工具注册表模式。`useToolItems` hook(`app/hooks/useToolItems.tsx`)作为工具定义与路由的单一入口,新增内部工具只需改该文件 + i18n。`ToolboxMenu` 改为纯渲染器,接收 `items: ToolboxItem[]`。`AppExposedActions` 新增 `openTool(key)` 统一路由。已有 `openAIInspection` / `openAIConversation` / `openIkTool` / `openCollisionOptimizer` 字段全部保留(向后兼容)。 +- 旧的 `CLAUDE.md` 长文档内容已经收敛到 `AGENTS.md` 和 `docs/*` +- 如果这里和仓库现状冲突,以 `AGENTS.md` 与实际代码结构为准 diff --git a/README.md b/README.md index 8fcf8a5d2..9ee12b890 100644 --- a/README.md +++ b/README.md @@ -235,15 +235,16 @@ Git hooks are wired through Husky + lint-staged + Commitlint: - `pre-commit`: formats staged files and runs ESLint / Stylelint on the staged diff - `commit-msg`: validates Conventional Commit messages -The repository still does **not** force a single root `npm test` command because verification remains module- and fixture-driven. +`npm test` stays limited to repo-contained tests that do not require the external fixture corpora under `test/`. Validation is typically done through: - targeted `node --test` / `npx tsx --test` runs next to the changed module - focused regression scripts under `scripts/regression/` +- `npm test` for the fast repo-contained lane used by `npm run verify:fast` - `npm run build` - package workspace builds when touching `src/lib` or `packages/react-robot-canvas` -- fixture-driven checks against large corpora under `test/`, especially `test/unitree_model`, `test/gazebo_models`, `test/awesome_robot_descriptions_repos`, and `test/usd-viewer` +- fixture-driven checks under `test/` via `npm run test:fixtures:*` / `npm run verify:full`, especially `test/unitree_model`, `test/gazebo_models`, `test/awesome_robot_descriptions_repos`, and `test/usd-viewer` ## Documentation diff --git a/README_CN.md b/README_CN.md index 4a51426b4..97bed599b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -235,15 +235,16 @@ Git hooks 通过 Husky + lint-staged + Commitlint 接入: - `pre-commit`:对 staged 文件执行格式化,并在 staged diff 上运行 ESLint / Stylelint - `commit-msg`:校验 Conventional Commit 提交信息 -目前仓库仍然**没有**强行统一成单一的根级 `npm test`,因为测试验证依旧以模块定向和 fixture 回归为主。 +`npm test` 当前只覆盖仓库内可自给的测试,不包含依赖 `test/` 外部大型语料的 fixture 回归。 通常通过以下方式完成验证: - 在改动模块旁边运行定向 `node --test` / `npx tsx --test` - 运行 `scripts/regression/` 下的定向回归脚本 +- 使用 `npm test` 跑 `npm run verify:fast` 采用的仓库内快速测试 - 执行 `npm run build` - 如果改动了 `src/lib` 或 `packages/react-robot-canvas`,补跑包构建 -- 针对 `test/` 下的大型 fixture 语料做回归检查,尤其是 `test/unitree_model`、`test/gazebo_models`、`test/awesome_robot_descriptions_repos`、`test/usd-viewer` +- 通过 `npm run test:fixtures:*` / `npm run verify:full` 针对 `test/` 下的大型 fixture 语料做回归检查,尤其是 `test/unitree_model`、`test/gazebo_models`、`test/awesome_robot_descriptions_repos`、`test/usd-viewer` ## 文档入口 diff --git a/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md b/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md new file mode 100644 index 000000000..59bf67eae --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-ai-inspection-professional-badge-toggle-design.md @@ -0,0 +1,75 @@ +# AI Inspection Professional Badge Toggle Design + +**Date:** 2026-04-14 + +**Goal** + +Enable the status badge in AI inspection professional mode to toggle the corresponding inspection item selection. Clicking `已纳入` or `未纳入` should behave the same as toggling that item from the left inspection sidebar. + +**Scope** + +- Only change the professional-mode setup view in the AI inspection modal. +- Only make the per-item status badge clickable. +- Keep the rest of the card read-only. +- Preserve existing behavior in normal mode, running state, and report state. + +**Current Behavior** + +- In professional mode, the right-side detail panel shows each inspection item as a card. +- The badge text reflects selection state with `已纳入` or `未纳入`. +- The badge is display-only and cannot change selection. +- Selection changes are currently driven from the left sidebar and the normal-mode setup view. + +**Desired Behavior** + +- In professional mode setup, clicking the status badge toggles the item selection. +- When the item is selected, clicking `已纳入` removes it from the run. +- When the item is not selected, clicking `未纳入` includes it in the run. +- The right card state, left sidebar state, and summary metrics stay synchronized because they continue to share the same `selectedItems` state. + +**Design** + +## UI structure + +- Replace the badge-only `span` in `InspectionSetupView.tsx` with a semantic `button`. +- Keep the visual style close to the existing badge so the change is behavioral, not visual redesign. +- Add a small hover/focus treatment consistent with current token usage. + +## Data flow + +- `AIInspectionModal.tsx` remains the owner of `selectedItems`. +- Pass a new `onToggleItem(categoryId, itemId)` callback into `InspectionSetupView`. +- The callback updates `selectedItems` with the same add/remove semantics already used by the sidebar item toggle. + +## Interaction rules + +- The badge is clickable only in setup view professional mode. +- No change to whole-card click behavior. +- No extra confirmation dialog. +- No i18n changes are required because the existing labels already match the desired states. + +## Accessibility + +- Use a real `button` so keyboard users can trigger the change. +- Keep the visible label as the current included/skipped text. +- Add an `aria-pressed` state tied to item selection. + +## Testing + +- Add a test in `AIInspectionModal.test.tsx` that enters professional mode, clicks the right-side status badge, and verifies: + - the right-side item state toggles from included to skipped or the reverse + - the selection summary updates + - the left sidebar selection count remains synchronized + +**Files** + +- Modify `src/features/ai-assistant/components/AIInspectionModal.tsx` +- Modify `src/features/ai-assistant/components/InspectionSetupView.tsx` +- Modify `src/features/ai-assistant/components/AIInspectionModal.test.tsx` + +**Out of Scope** + +- Making the entire inspection item card clickable +- Changing normal-mode selection behavior +- Refactoring shared selection helpers between the sidebar and setup view +- Any report-view interaction changes diff --git a/docs/update-rules.md b/docs/update-rules.md index 451b47efd..6a120cc1e 100644 --- a/docs/update-rules.md +++ b/docs/update-rules.md @@ -47,7 +47,7 @@ npm run dev npm run lint npm run typecheck -npm run test +npm run test # 仓库内快速测试;不依赖 test/ 大型 fixture 语料 npm run build npm run verify:fast npm run verify:full @@ -81,6 +81,10 @@ npm run test:fixtures:unitree-ros-usda npm run build:package:react-robot-canvas ``` +说明: +`npm run verify:fast` 只跑仓库内可自给的检查。 +依赖 `test/` 目录大型语料的回归,统一走 `npm run test:fixtures:*` 或 `npm run verify:full`。 + ## 5. 测试样本索引 ### USD / worker / roundtrip 主样本(`test/unitree_model/`) diff --git a/docs/viewer.md b/docs/viewer.md index b3fcccd5c..09ed1997f 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -7,15 +7,16 @@ URDF Studio 只有 `Editor` 一个编辑模式,统一承载三个子域: -| 子域 | 典型任务 | -|------|---------| -| 拓扑 | Link / Joint 增删、拓扑编辑、关节参数 | +| 子域 | 典型任务 | +| ------------------ | ---------------------------------------------- | +| 拓扑 | Link / Joint 增删、拓扑编辑、关节参数 | | 几何 / 碰撞 / 测量 | Visual / Collision、mesh、材质、纹理、碰撞变换 | -| 硬件配置 | 电机型号、传动比、阻尼、摩擦 | +| 硬件配置 | 电机型号、传动比、阻尼、摩擦 | 新增功能前,先判断属于哪类子能力,避免跨子系统逻辑缠绕。 快速映射: + - 统一公开入口:`features/editor/index.ts` - 具体实现:`features/urdf-viewer/`(包含拓扑/硬件与几何/碰撞/测量) - 跨子域共享交互:`app/` 编排层 或 `shared/components/3d/` @@ -79,12 +80,15 @@ features/urdf-viewer/ 适用范围:`runtime/hydra/render-delegate/*`、`workers/*`、`utils/usd*`、`app/hooks/useFile*.ts` 中消费 worker 结果的 USD 工作流 必须遵循: + - USD stage preparation、runtime metadata、robot hydration、prepared export cache、roundtrip archive 的修复,默认优先放在 worker/runtime 链路完成,不要搬到主线程 adapter 或 debug bridge - `runtime/hydra/render-delegate/*` 产出的 metadata snapshot 是该链路的 source of truth;缺字段应修 worker/runtime 生成逻辑 - 禁止新增"worker 结果缺失 -> 主线程重建 metadata -> 静默继续"的 fallback - 对 folded fixed link、collision-only semantic child link 的推断只能基于 stage/truth 中的明确证据,不做纯命名猜测 +- `visual_*` / `collision_*` / `group_*` / `xform_*` / `scene` / `root` 这类 roundtrip 容器 prim 不是 link identity;runtime metadata 不得把它们提升为 synthetic link 或 fixed joint 验证要求: + - 改动上述链路时,必须跑 `test/unitree_model` 整套 USD 浏览器验证 - 至少覆盖 `Go2 + B2 + H1-2` - 浏览器验证产物写入 `tmp/regression/` @@ -94,6 +98,7 @@ features/urdf-viewer/ 适用范围:`UsdOffscreenStage.tsx`、`usdOffscreenViewer.worker.ts`、`runtime/hydra/render-delegate/*`、`shared/utils/three/dispose.ts` 必须遵循: + - 主线程宿主只负责 handoff、尺寸同步与错误透传;不要重建 runtime truth - teardown 必须完整释放 observer、DOM/worker 事件监听、RAF/timer、OffscreenCanvas 关联 runtime、scene graph 与 driver 引用 - runtime 全局 handler/registry/active owner 必须提供对称的 unregister/reset @@ -102,17 +107,17 @@ features/urdf-viewer/ ## 8. 关键 utils 职责速查 -| 文件 | 职责 | -|------|------| -| `viewerRobotData.ts` | 统一 viewer 层消费的数据形态 | -| `viewerResourceScope.ts` | source file / assets / robot links 资源域 | -| `usdExportBundle.ts` | USD 场景快照与导出缓存协调 | -| `usdRuntimeRobotHydration.ts` | runtime -> RobotData hydration | -| `usdSceneRobotResolution.ts` | 场景级 robot resolution | -| `usdViewerRobotAdapter.ts` | viewer runtime / snapshot 到应用数据适配 | -| `usdOffscreenViewerWorkerClient.ts` | 主线程对 offscreen worker 请求封装 | -| `usdStageOpenPreparationWorkerBridge.ts` | prepared-open 链路 worker bridge | -| `usdPreparedExportCacheWorkerBridge.ts` | prepared-export 链路 worker bridge | -| `runtimeSceneMetadata.ts` | runtime scene metadata 标准化读模型 | -| `visualizationFactories.ts` | 辅助可视化对象创建 | -| `dispose.ts` | THREE 资源清理 | +| 文件 | 职责 | +| ---------------------------------------- | ----------------------------------------- | +| `viewerRobotData.ts` | 统一 viewer 层消费的数据形态 | +| `viewerResourceScope.ts` | source file / assets / robot links 资源域 | +| `usdExportBundle.ts` | USD 场景快照与导出缓存协调 | +| `usdRuntimeRobotHydration.ts` | runtime -> RobotData hydration | +| `usdSceneRobotResolution.ts` | 场景级 robot resolution | +| `usdViewerRobotAdapter.ts` | viewer runtime / snapshot 到应用数据适配 | +| `usdOffscreenViewerWorkerClient.ts` | 主线程对 offscreen worker 请求封装 | +| `usdStageOpenPreparationWorkerBridge.ts` | prepared-open 链路 worker bridge | +| `usdPreparedExportCacheWorkerBridge.ts` | prepared-export 链路 worker bridge | +| `runtimeSceneMetadata.ts` | runtime scene metadata 标准化读模型 | +| `visualizationFactories.ts` | 辅助可视化对象创建 | +| `dispose.ts` | THREE 资源清理 | diff --git a/package.json b/package.json index 96f11177e..dbba7ae76 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,12 @@ "generate": "npm run ai-prompts:generate", "generate:check": "node scripts/generate_ai_prompt_templates.mjs --check && node scripts/generate_inspection_criteria.mjs --check", "build:with-generate": "npm run generate && npm run build", - "test": "npm run test:unit:app-hooks && npm run test:fixtures:imports", + "test": "node --import tsx --test vite.config.test.ts && npm run test:unit:app-hooks", "test:fixtures:imports": "esbuild scripts/regression/validate_import_fixture_matrix.ts --bundle --platform=node --format=cjs --alias:@=./src --external:jsdom --outfile=tmp/regression/fixture-import-matrix.cjs && node tmp/regression/fixture-import-matrix.cjs", "test:fixtures:myosuite-imports": "esbuild scripts/regression/validate_myosuite_imports.ts --bundle --platform=node --format=cjs --alias:@=./src --external:jsdom --outfile=tmp/regression/myosuite-import-matrix.cjs && node tmp/regression/myosuite-import-matrix.cjs", "test:fixtures:unitree-ros-usd-export-benchmark": "node scripts/regression/validate_unitree_ros_usd_export_benchmark.mjs", + "test:fixtures:unitree-ros-usd-export-e2e-benchmark": "node scripts/regression/validate_unitree_ros_usd_export_e2e_benchmark.mjs", + "test:fixtures:unitree-ros-isaacsim-truth": "npx tsx scripts/regression/validate_unitree_ros_usd_against_isaacsim.ts", "test:fixtures:unitree-ros-urdfs": "npx tsx scripts/regression/validate_unitree_ros_urdfs.ts", "test:fixtures:unitree-usd": "node scripts/regression/validate_unitree_selected_browser.mjs", "verify:fast": "npm run format:check && npm run lint && npm run typecheck:quality && npm run test && npm run build", @@ -70,7 +72,7 @@ "test:fixtures:unitree-ros-usda": "node scripts/regression/validate_unitree_ros_usda_selected_browser.mjs", "test:fixtures": "npm run test:fixtures:imports && npm run test:fixtures:unitree-ros-urdfs && npm run test:fixtures:unitree-usd && npm run test:fixtures:unitree-ros-usda && npm run test:fixtures:unitree-ros-usd-export-benchmark", "dev:with-generate": "node scripts/start_dev_server.mjs --generate", - "test:unit:app-hooks": "node --test src/app/hooks/useEditableSourcePatches.test.ts src/app/hooks/workspaceGeneratedSourceState.test.ts src/app/utils/assemblyRootComponentSelection.test.ts src/app/utils/importPreparation.workerSafe.test.ts src/app/components/UnifiedViewer.typecheck.test.ts src/app/handoff/bootstrap.test.ts src/app/utils/popupHandoffImport.test.ts" + "test:unit:app-hooks": "node --import tsx --test src/app/hooks/useEditableSourcePatches.test.ts src/app/hooks/workspaceGeneratedSourceState.test.ts src/app/utils/assemblyRootComponentSelection.test.ts src/app/utils/importPreparation.workerSafe.test.ts src/app/components/UnifiedViewer.typecheck.test.ts src/app/handoff/bootstrap.test.ts src/app/utils/popupHandoffImport.test.ts" }, "license": "Apache-2.0", "dependencies": { diff --git a/scripts/regression/run_default_link_click_hover_regression.mjs b/scripts/regression/run_default_link_click_hover_regression.mjs new file mode 100644 index 000000000..3df08dc71 --- /dev/null +++ b/scripts/regression/run_default_link_click_hover_regression.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { setTimeout as delay } from 'node:timers/promises'; + +import puppeteer from 'puppeteer'; + +const DEFAULT_SITE_URL = 'http://127.0.0.1:4173/?regressionDebug=1'; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_OUTPUT_PATH = path.resolve('tmp/regression/default_link_click_hover_results.json'); +const DEFAULT_SCREENSHOT_PATH = path.resolve( + 'tmp/regression/default_link_click_hover_regression.png', +); + +function fail(message) { + throw new Error(message); +} + +function parseInteger(value, flagName) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + fail(`Invalid value for ${flagName}: ${value}`); + } + return parsed; +} + +function parseArgs(argv) { + const options = { + siteUrl: DEFAULT_SITE_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + outputPath: DEFAULT_OUTPUT_PATH, + screenshotPath: DEFAULT_SCREENSHOT_PATH, + headed: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + const nextValue = () => { + const value = argv[index + 1]; + if (value == null) { + fail(`Missing value for ${arg}`); + } + index += 1; + return value; + }; + + switch (arg) { + case '--site-url': + options.siteUrl = nextValue(); + break; + case '--timeout-ms': + options.timeoutMs = parseInteger(nextValue(), '--timeout-ms'); + break; + case '--output': + options.outputPath = path.resolve(nextValue()); + break; + case '--screenshot': + options.screenshotPath = path.resolve(nextValue()); + break; + case '--headed': + options.headed = true; + break; + case '--help': + case '-h': + console.log(`Usage: + node scripts/regression/run_default_link_click_hover_regression.mjs [options] + +Options: + --site-url Dev server URL. Default: ${DEFAULT_SITE_URL} + --timeout-ms Timeout for page operations. Default: ${DEFAULT_TIMEOUT_MS} + --output Result JSON path. Default: ${DEFAULT_OUTPUT_PATH} + --screenshot Screenshot path. Default: ${DEFAULT_SCREENSHOT_PATH} + --headed Launch a headed browser. +`); + process.exit(0); + break; + default: + fail(`Unknown argument: ${arg}`); + } + } + + return options; +} + +async function retryPageAction(action, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + try { + return await action(); + } catch (error) { + lastError = error; + await delay(150); + } + } + + throw new Error(`Timed out while ${label}: ${lastError?.message ?? 'unknown error'}`); +} + +async function writeJsonAtomic(filePath, value) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await fs.rename(tempPath, filePath); +} + +function summarizeSelection(selection) { + if (!selection?.type || !selection?.id) { + return { + type: null, + id: null, + subType: null, + objectIndex: null, + highlightObjectId: null, + }; + } + + return { + type: selection.type, + id: selection.id, + subType: selection.subType ?? null, + objectIndex: selection.objectIndex ?? null, + highlightObjectId: selection.highlightObjectId ?? null, + }; +} + +function matchesBaseLinkVisual(selection) { + return ( + selection?.type === 'link' && + selection.id === 'base_link' && + selection.subType === 'visual' && + (selection.objectIndex ?? 0) === 0 + ); +} + +async function waitForDebugApi(page, timeoutMs) { + await retryPageAction( + () => + page.waitForFunction( + () => { + return Boolean( + window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot && + window.__URDF_STUDIO_DEBUG__?.getProjectedInteractionTargets, + ); + }, + { timeout: Math.min(timeoutMs, 5_000) }, + ), + timeoutMs, + 'waiting for regression debug API', + ); +} + +async function setViewerToolMode(page, toolMode, timeoutMs) { + await retryPageAction( + () => + page.evaluate( + (nextToolMode) => window.__URDF_STUDIO_DEBUG__?.setViewerToolMode?.(nextToolMode) ?? null, + toolMode, + ), + timeoutMs, + `setting viewer tool mode to ${toolMode}`, + ); + + await retryPageAction( + () => + page.waitForFunction( + (expectedToolMode) => + window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.()?.viewer?.toolMode === + expectedToolMode, + { timeout: Math.min(timeoutMs, 5_000) }, + toolMode, + ), + timeoutMs, + `waiting for viewer tool mode ${toolMode}`, + ); +} + +async function getBaseLinkVisualTarget(page, timeoutMs) { + await retryPageAction( + () => + page.waitForFunction( + () => { + const targets = window.__URDF_STUDIO_DEBUG__?.getProjectedInteractionTargets?.() ?? []; + return targets.some( + (entry) => + entry?.type === 'link' && + entry?.id === 'base_link' && + entry?.subType === 'visual' && + Number.isFinite(entry?.clientX) && + Number.isFinite(entry?.clientY), + ); + }, + { timeout: Math.min(timeoutMs, 5_000) }, + ), + timeoutMs, + 'waiting for base_link visual target', + ); + + return await retryPageAction( + () => + page.evaluate(() => { + const targets = window.__URDF_STUDIO_DEBUG__?.getProjectedInteractionTargets?.() ?? []; + return ( + targets.find( + (entry) => + entry?.type === 'link' && entry?.id === 'base_link' && entry?.subType === 'visual', + ) ?? null + ); + }), + timeoutMs, + 'reading base_link visual target', + ); +} + +async function readInteractionSnapshot(page, timeoutMs) { + return await retryPageAction( + () => + page.evaluate(() => { + const interaction = + window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.()?.interaction ?? null; + return { + selection: interaction?.selection ?? null, + hoveredSelection: interaction?.hoveredSelection ?? null, + }; + }), + timeoutMs, + 'reading interaction snapshot', + ); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const browser = await puppeteer.launch({ + headless: options.headed ? false : true, + defaultViewport: { + width: 1600, + height: 1100, + deviceScaleFactor: 1, + }, + }); + + let result = null; + + try { + const page = await browser.newPage(); + page.setDefaultTimeout(options.timeoutMs); + + await page.goto(options.siteUrl, { + waitUntil: 'domcontentloaded', + timeout: options.timeoutMs, + }); + + await waitForDebugApi(page, options.timeoutMs); + await setViewerToolMode(page, 'select', options.timeoutMs); + + const target = await getBaseLinkVisualTarget(page, options.timeoutMs); + if (!target) { + fail('Could not resolve a projected base_link visual hover target.'); + } + + await page.mouse.move(target.clientX, target.clientY); + await delay(180); + + const beforeClick = await readInteractionSnapshot(page, options.timeoutMs); + + await page.mouse.down(); + await delay(40); + const duringPointerDown = await readInteractionSnapshot(page, options.timeoutMs); + await page.mouse.up(); + + const immediateAfterClick = await readInteractionSnapshot(page, options.timeoutMs); + await delay(140); + const afterSettleWindow = await readInteractionSnapshot(page, options.timeoutMs); + await delay(260); + const afterExtendedIdle = await readInteractionSnapshot(page, options.timeoutMs); + + await fs.mkdir(path.dirname(options.screenshotPath), { recursive: true }); + await page.screenshot({ + path: options.screenshotPath, + type: 'png', + }); + + result = { + siteUrl: options.siteUrl, + target: { + id: target.id, + subType: target.subType, + objectIndex: target.objectIndex ?? null, + clientX: target.clientX, + clientY: target.clientY, + }, + snapshots: { + beforeClick: { + selection: summarizeSelection(beforeClick.selection), + hoveredSelection: summarizeSelection(beforeClick.hoveredSelection), + }, + duringPointerDown: { + selection: summarizeSelection(duringPointerDown.selection), + hoveredSelection: summarizeSelection(duringPointerDown.hoveredSelection), + }, + immediateAfterClick: { + selection: summarizeSelection(immediateAfterClick.selection), + hoveredSelection: summarizeSelection(immediateAfterClick.hoveredSelection), + }, + afterSettleWindow: { + selection: summarizeSelection(afterSettleWindow.selection), + hoveredSelection: summarizeSelection(afterSettleWindow.hoveredSelection), + }, + afterExtendedIdle: { + selection: summarizeSelection(afterExtendedIdle.selection), + hoveredSelection: summarizeSelection(afterExtendedIdle.hoveredSelection), + }, + }, + screenshotPath: options.screenshotPath, + generatedAt: new Date().toISOString(), + }; + + await writeJsonAtomic(options.outputPath, result); + + assert.equal( + matchesBaseLinkVisual(beforeClick.hoveredSelection), + true, + 'expected the initial mouse move to hover the default base_link visual', + ); + assert.equal( + matchesBaseLinkVisual(immediateAfterClick.selection), + true, + 'expected the click to select the default base_link visual', + ); + assert.equal( + matchesBaseLinkVisual(immediateAfterClick.hoveredSelection), + true, + 'expected hover to remain on base_link immediately after the click', + ); + assert.equal( + matchesBaseLinkVisual(afterSettleWindow.hoveredSelection), + true, + 'expected hover to remain on base_link after the short settle window', + ); + assert.equal( + matchesBaseLinkVisual(afterExtendedIdle.hoveredSelection), + true, + 'expected hover to remain on base_link after the extended idle check', + ); + } finally { + await browser.close(); + } +} + +await main(); diff --git a/scripts/regression/validate_import_fixture_matrix.ts b/scripts/regression/validate_import_fixture_matrix.ts index be962546f..8fac6dcb9 100644 --- a/scripts/regression/validate_import_fixture_matrix.ts +++ b/scripts/regression/validate_import_fixture_matrix.ts @@ -1,281 +1,100 @@ -import assert from 'node:assert/strict'; -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; -import { JSDOM } from 'jsdom'; - -import { resolveRobotFileData } from '@/core/parsers/importRobotFile'; -import type { RobotFile } from '@/types'; - -type FixtureKind = 'mjcf' | 'sdf' | 'urdf'; - -type FixtureDefinition = { - id: string; - kind: FixtureKind; - relativePath: string; - supportRoot?: string; - minLinks: number; - minJoints: number; -}; - -type FixtureSummary = { - id: string; - kind: FixtureKind; - relativePath: string; - status: string; - linkCount: number; - jointCount: number; - rootLinkId: string | null; -}; - -const DEFAULT_OUTPUT_PATH = path.resolve('tmp/regression/fixture-import-matrix.json'); -const TEXT_FILE_EXTENSIONS = new Set([ - '.material', - '.sdf', - '.config', - '.xml', - '.mjcf', - '.urdf', - '.xacro', -]); - -const FIXTURES: FixtureDefinition[] = [ - { - id: 'mjcf-unitree-go2', - kind: 'mjcf', - relativePath: 'test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_go2/go2.xml', - supportRoot: 'test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_go2', - minLinks: 4, - minJoints: 3, - }, - { - id: 'mjcf-unitree-h1', - kind: 'mjcf', - relativePath: 'test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_h1/h1.xml', - supportRoot: 'test/awesome_robot_descriptions_repos/mujoco_menagerie/unitree_h1', - minLinks: 4, - minJoints: 3, - }, - { - id: 'mjcf-franka-panda', - kind: 'mjcf', - relativePath: - 'test/awesome_robot_descriptions_repos/mujoco_menagerie/franka_emika_panda/panda.xml', - supportRoot: 'test/awesome_robot_descriptions_repos/mujoco_menagerie/franka_emika_panda', - minLinks: 4, - minJoints: 3, - }, - { - id: 'gazebo-camera', - kind: 'sdf', - relativePath: 'test/gazebo_models/camera/model.sdf', - supportRoot: 'test/gazebo_models/camera', - minLinks: 1, - minJoints: 0, - }, - { - id: 'gazebo-cordless-drill', - kind: 'sdf', - relativePath: 'test/gazebo_models/cordless_drill/model.sdf', - supportRoot: 'test/gazebo_models/cordless_drill', - minLinks: 1, - minJoints: 0, - }, - { - id: 'gazebo-bus-stop', - kind: 'sdf', - relativePath: 'test/gazebo_models/bus_stop/model.sdf', - supportRoot: 'test/gazebo_models/bus_stop', - minLinks: 1, - minJoints: 0, - }, - { - id: 'unitree-urdf-go2', - kind: 'urdf', - relativePath: - 'test/awesome_robot_descriptions_repos/unitree_ros/robots/go2_description/urdf/go2_description.urdf', - minLinks: 4, - minJoints: 3, - }, - { - id: 'unitree-urdf-b2', - kind: 'urdf', - relativePath: - 'test/awesome_robot_descriptions_repos/unitree_ros/robots/b2_description/urdf/b2_description.urdf', - minLinks: 4, - minJoints: 3, - }, - { - id: 'unitree-urdf-h1_2', - kind: 'urdf', - relativePath: - 'test/awesome_robot_descriptions_repos/unitree_ros/robots/h1_2_description/h1_2.urdf', - minLinks: 4, - minJoints: 3, - }, -]; - -function installDomGlobals() { - const dom = new JSDOM('', { contentType: 'text/html' }); - - globalThis.window = dom.window as typeof globalThis.window; - globalThis.document = dom.window.document as typeof globalThis.document; - globalThis.DOMParser = dom.window.DOMParser as typeof DOMParser; - globalThis.XMLSerializer = dom.window.XMLSerializer as typeof XMLSerializer; - globalThis.Node = dom.window.Node as typeof Node; - globalThis.Element = dom.window.Element as typeof Element; - globalThis.Document = dom.window.Document as typeof Document; - globalThis.self = globalThis; +import { promisify } from 'node:util'; + +import { + buildFixtureMatrix, + DATASET_NAMES, + installDomGlobals, + parseCliArgs, + writeReport, + type DatasetName, + type FixtureSummary, +} from './importFixtureMatrixShared'; + +const execFileAsync = promisify(execFile); +const SCRIPT_PATH = path.resolve('scripts/regression/validate_import_fixture_matrix.ts'); + +function filterSummaries( + summaries: FixtureSummary[], + matches: string[], + limit: number | null, +): FixtureSummary[] { + const normalizedMatches = matches.map((match) => match.trim().toLowerCase()).filter(Boolean); + const filtered = normalizedMatches.length + ? summaries.filter((row) => { + const haystack = [ + row.id, + row.dataset, + row.supportRoot, + row.entryPath, + row.relativePath, + row.classification, + row.actualStatus, + ] + .join('\n') + .toLowerCase(); + return normalizedMatches.every((match) => haystack.includes(match)); + }) + : summaries; + + return limit == null ? filtered : filtered.slice(0, limit); } -function parseArgs(argv: string[]) { - const options = { - outputPath: DEFAULT_OUTPUT_PATH, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === '--output') { - const value = argv[index + 1]; - if (!value) { - throw new Error('Missing value for --output'); - } - options.outputPath = path.resolve(value); - index += 1; - continue; - } - - if (arg === '--help' || arg === '-h') { - console.log(`Usage: node validate_import_fixture_matrix.js [--output ]`); - process.exit(0); - } - - throw new Error(`Unknown argument: ${arg}`); - } - - return options; -} - -async function collectFiles( - rootDir: string, - predicate: (absolutePath: string) => boolean, -): Promise { - const result: string[] = []; - - async function visit(currentDir: string) { - const entries = await readdir(currentDir, { withFileTypes: true }); - for (const entry of entries) { - const absolutePath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - await visit(absolutePath); - continue; - } - if (predicate(absolutePath)) { - result.push(absolutePath); - } - } +async function runDatasetSubprocess(dataset: DatasetName): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'fixture-matrix-')); + const outputPath = path.join(tempDir, `${dataset}.json`); + + try { + await execFileAsync('npx', ['tsx', SCRIPT_PATH, '--dataset', dataset, '--output', outputPath], { + cwd: process.cwd(), + env: { + ...process.env, + MATRIX_CHILD_RUN: '1', + }, + maxBuffer: 1024 * 1024 * 8, + }); + + const report = JSON.parse(await readFile(outputPath, 'utf8')) as { + summaries?: FixtureSummary[]; + }; + return Array.isArray(report.summaries) ? report.summaries : []; + } finally { + await rm(tempDir, { recursive: true, force: true }); } - - await visit(rootDir); - return result.sort((left, right) => left.localeCompare(right)); -} - -async function buildMjcfAvailableFiles(rootDir: string): Promise { - const sourceFiles = await collectFiles(rootDir, (absolutePath) => { - const extension = path.extname(absolutePath).toLowerCase(); - return extension === '.xml' || extension === '.mjcf'; - }); - - return Promise.all( - sourceFiles.map(async (absolutePath) => ({ - name: absolutePath, - content: await readFile(absolutePath, 'utf8'), - format: 'mjcf' as const, - })), - ); -} - -async function buildSdfFileContents(rootDir: string): Promise> { - const textFiles = await collectFiles(rootDir, (absolutePath) => - TEXT_FILE_EXTENSIONS.has(path.extname(absolutePath).toLowerCase()), - ); - - const entries = await Promise.all( - textFiles.map( - async (absolutePath) => [absolutePath, await readFile(absolutePath, 'utf8')] as const, - ), - ); - - return Object.fromEntries(entries); -} - -async function loadFixture(definition: FixtureDefinition): Promise { - const absolutePath = path.resolve(definition.relativePath); - const file: RobotFile = { - name: absolutePath, - content: await readFile(absolutePath, 'utf8'), - format: definition.kind, - }; - - const result = - definition.kind === 'mjcf' - ? resolveRobotFileData(file, { - availableFiles: await buildMjcfAvailableFiles( - path.resolve(definition.supportRoot ?? path.dirname(definition.relativePath)), - ), - }) - : definition.kind === 'sdf' - ? resolveRobotFileData(file, { - allFileContents: await buildSdfFileContents( - path.resolve(definition.supportRoot ?? path.dirname(definition.relativePath)), - ), - }) - : resolveRobotFileData(file); - - assert.equal( - result.status, - 'ready', - `${definition.id} should parse successfully (got ${result.status})`, - ); - - assert.ok(result.robotData.rootLinkId, `${definition.id} should produce a root link`); - assert.ok( - Object.keys(result.robotData.links).length >= definition.minLinks, - `${definition.id} should produce at least ${definition.minLinks} links`, - ); - assert.ok( - Object.keys(result.robotData.joints).length >= definition.minJoints, - `${definition.id} should produce at least ${definition.minJoints} joints`, - ); - - return { - id: definition.id, - kind: definition.kind, - relativePath: definition.relativePath, - status: result.status, - linkCount: Object.keys(result.robotData.links).length, - jointCount: Object.keys(result.robotData.joints).length, - rootLinkId: result.robotData.rootLinkId, - }; } async function main() { - installDomGlobals(); - const { outputPath } = parseArgs(process.argv.slice(2)); - const summaries = [] as FixtureSummary[]; - - for (const fixture of FIXTURES) { - summaries.push(await loadFixture(fixture)); + const { outputPath, datasets, matches, limit } = parseCliArgs(process.argv.slice(2)); + + const summaries = + datasets.length === 1 + ? (installDomGlobals(), + await buildFixtureMatrix({ + datasets, + matches, + limit, + })) + : filterSummaries( + ( + await DATASET_NAMES.reduce>(async (pending, dataset) => { + const collected = await pending; + if (!datasets.includes(dataset)) { + return collected; + } + return collected.concat(await runDatasetSubprocess(dataset)); + }, Promise.resolve([])) + ).sort((left, right) => left.id.localeCompare(right.id)), + matches, + limit, + ); + + const report = await writeReport(outputPath, summaries); + if (!process.env.MATRIX_CHILD_RUN) { + console.log(JSON.stringify(report, null, 2)); } - - const report = { - validatedAt: new Date().toISOString(), - sampleCount: summaries.length, - summaries, - }; - - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); - console.log(JSON.stringify(report, null, 2)); } main().catch((error) => { diff --git a/scripts/regression/validate_source_editor_mjcf_browser.mjs b/scripts/regression/validate_source_editor_mjcf_browser.mjs new file mode 100644 index 000000000..0a20c2572 --- /dev/null +++ b/scripts/regression/validate_source_editor_mjcf_browser.mjs @@ -0,0 +1,460 @@ +#!/usr/bin/env node + +import { access, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +import puppeteer from 'puppeteer'; + +const SITE_BASE_URL = 'http://127.0.0.1:4173'; +const SITE_URL = `${SITE_BASE_URL}/?regressionDebug=1`; +const OUTPUT_PATH = path.resolve('tmp/regression/source-editor-mjcf-browser.json'); +const FIXTURE_PATH = path.resolve('tmp/source-editor-mjcf-patch-demo.xml'); +const SITE_TIMEOUT_MS = 120_000; +const STEP_TIMEOUT_MS = 30_000; + +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function isSiteReachable(url) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch(url, { + signal: controller.signal, + redirect: 'follow', + cache: 'no-store', + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeoutId); + } +} + +async function ensureSite() { + if (await isSiteReachable(SITE_BASE_URL)) { + return { stop: async () => {} }; + } + + const child = spawn( + 'npm', + ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + detached: true, + }, + ); + + const deadline = Date.now() + SITE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isSiteReachable(SITE_BASE_URL)) { + return { + stop: async () => { + if (child.exitCode != null || child.signalCode != null) { + return; + } + try { + process.kill(-child.pid, 'SIGTERM'); + } catch {} + await delay(500); + }, + }; + } + + if (child.exitCode != null) { + throw new Error(`dev server exited early with code ${child.exitCode}`); + } + + await delay(500); + } + + throw new Error(`Timed out waiting for ${SITE_BASE_URL}`); +} + +async function setAutoApplyPreference(page, enabled) { + await page.evaluate((nextEnabled) => { + const raw = localStorage.getItem('urdf-studio-ui'); + const parsed = raw ? JSON.parse(raw) : { state: {} }; + parsed.state = { + ...(parsed.state ?? {}), + sourceCodeAutoApply: nextEnabled, + }; + localStorage.setItem('urdf-studio-ui', JSON.stringify(parsed)); + }, enabled); +} + +async function openPage(browser) { + const page = await browser.newPage(); + await page.setViewport({ width: 1600, height: 1000 }); + const consoleErrors = []; + const consoleWarnings = []; + const pageErrors = []; + + page.on('console', (message) => { + const type = String(message.type() || '').toLowerCase(); + if (type === 'error') { + consoleErrors.push({ + text: message.text(), + location: message.location(), + }); + return; + } + + if (type === 'warn' || type === 'warning') { + consoleWarnings.push({ + text: message.text(), + location: message.location(), + }); + } + }); + + page.on('pageerror', (error) => { + pageErrors.push({ + message: error?.message ?? String(error), + stack: error?.stack ?? null, + }); + }); + + await page.goto(SITE_URL, { + waitUntil: 'domcontentloaded', + timeout: SITE_TIMEOUT_MS, + }); + await page.waitForFunction(() => Boolean(window.__URDF_STUDIO_DEBUG__), { + timeout: STEP_TIMEOUT_MS, + }); + await page.evaluate(() => { + window.__URDF_STUDIO_DEBUG__?.setBeforeUnloadPromptEnabled?.(false); + }); + + return { + page, + consoleErrors, + consoleWarnings, + pageErrors, + }; +} + +async function resetWorkspace(page) { + await page.goto(SITE_URL, { + waitUntil: 'domcontentloaded', + timeout: STEP_TIMEOUT_MS, + }); + await page.waitForFunction(() => Boolean(window.__URDF_STUDIO_DEBUG__), { + timeout: STEP_TIMEOUT_MS, + }); + await page.evaluate(() => { + window.__URDF_STUDIO_DEBUG__?.setBeforeUnloadPromptEnabled?.(false); + }); +} + +async function importFixture(page) { + const fileInput = await page.waitForSelector('input[type="file"]', { + timeout: STEP_TIMEOUT_MS, + }); + await fileInput.uploadFile(FIXTURE_PATH); + + await page.waitForFunction( + () => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.(); + return snapshot?.selectedFile?.name === 'source-editor-mjcf-patch-demo.xml'; + }, + { timeout: STEP_TIMEOUT_MS }, + ); +} + +async function openSourceEditor(page) { + await page.waitForFunction( + () => + [...document.querySelectorAll('button')].some((candidate) => + /source code/i.test(candidate.textContent?.trim() ?? ''), + ), + { timeout: STEP_TIMEOUT_MS }, + ); + + await page.evaluate(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => + /source code/i.test(candidate.textContent?.trim() ?? ''), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('Source Code button not found'); + } + button.click(); + }); + + await page.waitForFunction( + () => + Boolean(window.monaco?.editor?.getModels?.().length) && + [...document.querySelectorAll('*')].some((node) => node.textContent?.trim() === 'MJCF/XML'), + { timeout: STEP_TIMEOUT_MS }, + ); +} + +async function replaceInEditor(page, fromText, toText) { + await page.evaluate( + ({ from, to }) => { + const monaco = window.monaco; + const model = monaco?.editor?.getModels?.()[0]; + if (!model) { + throw new Error('Monaco model not found'); + } + + const offset = model.getValue().indexOf(from); + if (offset < 0) { + throw new Error(`Target text not found: ${from}`); + } + + const start = model.getPositionAt(offset); + const end = model.getPositionAt(offset + from.length); + const editors = monaco.editor.getEditors?.() ?? []; + const editor = editors[0] ?? null; + + if ( + !editor || + typeof editor.focus !== 'function' || + typeof editor.setSelection !== 'function' + ) { + throw new Error('Monaco editor instance not available'); + } + + editor.focus(); + editor.setSelection( + new monaco.Selection(start.lineNumber, start.column, end.lineNumber, end.column), + ); + }, + { from: fromText, to: toText }, + ); + + await page.keyboard.type(toText); +} + +async function clickSave(page) { + await page.evaluate(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => { + const label = candidate.textContent?.trim().toLowerCase(); + return label === 'save'; + }); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('Save button not found'); + } + button.click(); + }); +} + +async function readEditorState(page) { + return await page.evaluate(() => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.() ?? null; + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.() ?? null; + const joints = Object.values(snapshot?.store?.joints ?? {}); + const footJoint = joints.find((joint) => joint && joint.name === 'foot_joint') ?? null; + const runtimeLinks = Object.values(runtime?.links ?? {}); + const runtimeFoot = runtimeLinks.find((link) => link && link.name === 'foot_link') ?? null; + const saveButton = [...document.querySelectorAll('button')].find((candidate) => { + const label = candidate.textContent?.trim().toLowerCase(); + return label === 'save'; + }); + const dirtyBadgeVisible = [...document.querySelectorAll('*')].some( + (node) => node.textContent?.trim().toLowerCase() === 'modified', + ); + const modelValue = window.monaco?.editor?.getModels?.()[0]?.getValue?.() ?? null; + + return { + selectedFile: snapshot?.selectedFile + ? { + name: snapshot.selectedFile.name, + format: snapshot.selectedFile.format, + } + : null, + footJointOriginZ: footJoint?.origin?.xyz?.z ?? null, + runtimeFootPositionZ: Array.isArray(runtimeFoot?.position) ? runtimeFoot.position[2] : null, + saveDisabled: saveButton instanceof HTMLButtonElement ? saveButton.disabled : null, + dirtyBadgeVisible, + modelValue, + }; + }); +} + +function assertClose(actual, expected, label) { + if (!Number.isFinite(actual) || Math.abs(actual - expected) > 1e-6) { + throw new Error(`${label} expected ${expected}, received ${actual}`); + } +} + +async function verifyAutoApply(page) { + await resetWorkspace(page); + await setAutoApplyPreference(page, true); + await resetWorkspace(page); + await importFixture(page); + await openSourceEditor(page); + + const before = await readEditorState(page); + assertClose(before.footJointOriginZ, 0.15, 'auto-apply baseline joint origin'); + assertClose(before.runtimeFootPositionZ, 0.25, 'auto-apply baseline runtime z'); + + await replaceInEditor(page, 'pos="0 0 0.15"', 'pos="0 0 0.25"'); + await page.waitForFunction( + () => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.() ?? null; + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.() ?? null; + const joints = Object.values(snapshot?.store?.joints ?? {}); + const footJoint = joints.find((joint) => joint && joint.name === 'foot_joint') ?? null; + const runtimeLinks = Object.values(runtime?.links ?? {}); + const runtimeFoot = runtimeLinks.find((link) => link && link.name === 'foot_link') ?? null; + const saveButton = [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent?.trim().toLowerCase() === 'save', + ); + const dirtyBadgeVisible = [...document.querySelectorAll('*')].some( + (node) => node.textContent?.trim().toLowerCase() === 'modified', + ); + + return ( + footJoint?.origin?.xyz?.z === 0.25 && + Array.isArray(runtimeFoot?.position) && + Math.abs(runtimeFoot.position[2] - 0.5) < 1e-6 && + saveButton instanceof HTMLButtonElement && + saveButton.disabled === true && + dirtyBadgeVisible === false + ); + }, + { timeout: STEP_TIMEOUT_MS }, + ); + + return { + before, + after: await readEditorState(page), + }; +} + +async function verifyManualSave(page) { + await resetWorkspace(page); + await setAutoApplyPreference(page, false); + await resetWorkspace(page); + await importFixture(page); + await openSourceEditor(page); + + const before = await readEditorState(page); + assertClose(before.footJointOriginZ, 0.15, 'manual baseline joint origin'); + assertClose(before.runtimeFootPositionZ, 0.25, 'manual baseline runtime z'); + + await replaceInEditor(page, 'pos="0 0 0.15"', 'pos="0 0 0.35"'); + await page.waitForFunction( + () => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.() ?? null; + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.() ?? null; + const joints = Object.values(snapshot?.store?.joints ?? {}); + const footJoint = joints.find((joint) => joint && joint.name === 'foot_joint') ?? null; + const runtimeLinks = Object.values(runtime?.links ?? {}); + const runtimeFoot = runtimeLinks.find((link) => link && link.name === 'foot_link') ?? null; + const saveButton = [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent?.trim().toLowerCase() === 'save', + ); + const dirtyBadgeVisible = [...document.querySelectorAll('*')].some( + (node) => node.textContent?.trim().toLowerCase() === 'modified', + ); + + return ( + footJoint?.origin?.xyz?.z === 0.15 && + Array.isArray(runtimeFoot?.position) && + Math.abs(runtimeFoot.position[2] - 0.25) < 1e-6 && + saveButton instanceof HTMLButtonElement && + saveButton.disabled === false && + dirtyBadgeVisible === true + ); + }, + { timeout: STEP_TIMEOUT_MS }, + ); + + const beforeSave = await readEditorState(page); + await clickSave(page); + await page.waitForFunction( + () => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.() ?? null; + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.() ?? null; + const joints = Object.values(snapshot?.store?.joints ?? {}); + const footJoint = joints.find((joint) => joint && joint.name === 'foot_joint') ?? null; + const runtimeLinks = Object.values(runtime?.links ?? {}); + const runtimeFoot = runtimeLinks.find((link) => link && link.name === 'foot_link') ?? null; + const saveButton = [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent?.trim().toLowerCase() === 'save', + ); + const dirtyBadgeVisible = [...document.querySelectorAll('*')].some( + (node) => node.textContent?.trim().toLowerCase() === 'modified', + ); + + return ( + footJoint?.origin?.xyz?.z === 0.35 && + Array.isArray(runtimeFoot?.position) && + Math.abs(runtimeFoot.position[2] - 0.6) < 1e-6 && + saveButton instanceof HTMLButtonElement && + saveButton.disabled === true && + dirtyBadgeVisible === false + ); + }, + { timeout: STEP_TIMEOUT_MS }, + ); + + return { + before, + beforeSave, + after: await readEditorState(page), + }; +} + +async function main() { + if (!(await pathExists(FIXTURE_PATH))) { + throw new Error(`Missing MJCF fixture: ${FIXTURE_PATH}`); + } + + const site = await ensureSite(); + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + protocolTimeout: 120_000, + }); + + try { + const { page, consoleErrors, consoleWarnings, pageErrors } = await openPage(browser); + const autoApply = await verifyAutoApply(page); + const manualSave = await verifyManualSave(page); + const report = { + ok: true, + fixturePath: FIXTURE_PATH, + siteUrl: SITE_URL, + autoApply, + manualSave, + consoleErrors, + consoleWarnings, + pageErrors, + }; + + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await writeFile(OUTPUT_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + + if (consoleErrors.length > 0 || pageErrors.length > 0) { + throw new Error('Browser regression encountered console/page errors'); + } + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } finally { + await browser.close(); + await site.stop(); + } +} + +main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/scripts/regression/validate_source_editor_unitree_browser.mjs b/scripts/regression/validate_source_editor_unitree_browser.mjs new file mode 100644 index 000000000..2b2903625 --- /dev/null +++ b/scripts/regression/validate_source_editor_unitree_browser.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node + +import { access, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +import puppeteer from 'puppeteer'; + +const SITE_BASE_URL = 'http://127.0.0.1:4173'; +const SITE_URL = `${SITE_BASE_URL}/?regressionDebug=1`; +const FIXTURE_PATH = path.resolve('tmp/laikago_description_import.zip'); +const OUTPUT_PATH = path.resolve('tmp/regression/source-editor-unitree-browser.json'); +const SITE_TIMEOUT_MS = 120_000; +const STEP_TIMEOUT_MS = 60_000; + +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function isSiteReachable(url) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch(url, { + signal: controller.signal, + redirect: 'follow', + cache: 'no-store', + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeoutId); + } +} + +async function ensureSite() { + if (await isSiteReachable(SITE_BASE_URL)) { + return { stop: async () => {} }; + } + + const child = spawn( + 'npm', + ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + detached: true, + }, + ); + + const deadline = Date.now() + SITE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isSiteReachable(SITE_BASE_URL)) { + return { + stop: async () => { + if (child.exitCode != null || child.signalCode != null) { + return; + } + try { + process.kill(-child.pid, 'SIGTERM'); + } catch {} + await delay(500); + }, + }; + } + + if (child.exitCode != null) { + throw new Error(`dev server exited early with code ${child.exitCode}`); + } + + await delay(500); + } + + throw new Error(`Timed out waiting for ${SITE_BASE_URL}`); +} + +async function setAutoApplyPreference(page, enabled) { + await page.evaluate((nextEnabled) => { + const raw = localStorage.getItem('urdf-studio-ui'); + const parsed = raw ? JSON.parse(raw) : { state: {} }; + parsed.state = { + ...(parsed.state ?? {}), + sourceCodeAutoApply: nextEnabled, + }; + localStorage.setItem('urdf-studio-ui', JSON.stringify(parsed)); + }, enabled); +} + +async function openPage(browser) { + const page = await browser.newPage(); + await page.setViewport({ width: 1600, height: 1000 }); + const consoleErrors = []; + const pageErrors = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push({ + text: message.text(), + location: message.location(), + }); + } + }); + + page.on('pageerror', (error) => { + pageErrors.push({ + message: error?.message ?? String(error), + stack: error?.stack ?? null, + }); + }); + + await page.goto(SITE_URL, { + waitUntil: 'domcontentloaded', + timeout: SITE_TIMEOUT_MS, + }); + await page.waitForFunction(() => Boolean(window.__URDF_STUDIO_DEBUG__), { + timeout: STEP_TIMEOUT_MS, + }); + await page.evaluate(() => { + window.__URDF_STUDIO_DEBUG__?.setBeforeUnloadPromptEnabled?.(false); + }); + + return { + page, + consoleErrors, + pageErrors, + }; +} + +async function importFixture(page) { + const input = await page.waitForSelector('input[type="file"]', { + timeout: STEP_TIMEOUT_MS, + }); + await input.uploadFile(FIXTURE_PATH); + await page.waitForFunction( + () => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.(); + return ( + snapshot?.selectedFile?.name === 'laikago_description/urdf/laikago.urdf' && + snapshot?.store?.name === 'laikago' + ); + }, + { timeout: STEP_TIMEOUT_MS }, + ); + await delay(1_000); +} + +async function openSourceEditor(page) { + await page.waitForFunction( + () => + [...document.querySelectorAll('button')].some((candidate) => + /source code/i.test(candidate.textContent?.trim() ?? ''), + ), + { timeout: STEP_TIMEOUT_MS }, + ); + + await page.evaluate(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => + /source code/i.test(candidate.textContent?.trim() ?? ''), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('Source Code button not found'); + } + button.click(); + }); + + await page.waitForFunction(() => (window.monaco?.editor?.getModels?.().length ?? 0) > 0, { + timeout: STEP_TIMEOUT_MS, + }); +} + +async function closeSourceEditor(page) { + await page.evaluate(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => { + const text = candidate.textContent?.trim() ?? ''; + return text === 'Close' || text === '关闭'; + }); + if (button instanceof HTMLButtonElement) { + button.click(); + } + }); +} + +async function getEditorCode(page) { + return await page.evaluate(() => window.monaco.editor.getModels()[0]?.getValue() ?? ''); +} + +async function replaceFirst(page, fromText, toText) { + await page.evaluate( + ({ from, to }) => { + const monaco = window.monaco; + const model = monaco.editor.getModels()[0]; + const editor = monaco.editor.getEditors()[0]; + const offset = model.getValue().indexOf(from); + if (offset < 0) { + throw new Error(`Target text not found: ${from}`); + } + + const start = model.getPositionAt(offset); + const end = model.getPositionAt(offset + from.length); + editor.executeEdits('regression', [ + { + range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), + text: to, + }, + ]); + }, + { from: fromText, to: toText }, + ); +} + +async function clickSave(page) { + await page.evaluate(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => { + const label = candidate.textContent?.trim().toLowerCase(); + return label === 'save' || label === '保存'; + }); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('Save button not found'); + } + button.click(); + }); +} + +async function readSaveState(page) { + return await page.evaluate(() => { + const saveButton = [...document.querySelectorAll('button')].find((candidate) => { + const label = candidate.textContent?.trim().toLowerCase(); + return label === 'save' || label === '保存'; + }); + const modified = [...document.querySelectorAll('*')].some((node) => { + const text = node.textContent?.trim(); + return text === 'MODIFIED' || text === 'Modified' || text === '已修改'; + }); + + return { + disabled: saveButton instanceof HTMLButtonElement ? saveButton.disabled : null, + modified, + }; + }); +} + +async function readRobotState(page) { + return await page.evaluate(() => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.() ?? null; + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.() ?? null; + const frFoot = (snapshot?.store?.links ?? []).find((link) => link.name === 'FR_foot') ?? null; + const runtimeVisual = (runtime?.visualMeshes ?? []).find((mesh) => mesh.link === 'FR_foot'); + + return { + selectedFile: snapshot?.selectedFile?.name ?? null, + storeRadius: frFoot?.visual?.dimensions?.x ?? null, + runtimeScale: runtimeVisual?.scale?.[0] ?? null, + }; + }); +} + +async function runScenario(page, { autoApply, nextRadius }) { + await setAutoApplyPreference(page, autoApply); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: SITE_TIMEOUT_MS, + }); + await page.waitForFunction(() => Boolean(window.__URDF_STUDIO_DEBUG__), { + timeout: STEP_TIMEOUT_MS, + }); + await page.evaluate(() => { + window.__URDF_STUDIO_DEBUG__?.setBeforeUnloadPromptEnabled?.(false); + }); + + await importFixture(page); + await openSourceEditor(page); + + const initialCode = await getEditorCode(page); + const initialState = await readRobotState(page); + + await replaceFirst(page, 'radius="0.0165"', `radius="${nextRadius}"`); + await delay(500); + + const pendingState = { + save: await readSaveState(page), + robot: await readRobotState(page), + }; + + if (!autoApply) { + await clickSave(page); + } + + await page.waitForFunction( + (expectedRadius) => { + const snapshot = window.__URDF_STUDIO_DEBUG__?.getRegressionSnapshot?.(); + const frFoot = (snapshot?.store?.links ?? []).find((link) => link.name === 'FR_foot'); + return Math.abs((frFoot?.visual?.dimensions?.x ?? 0) - expectedRadius) < 1e-6; + }, + { timeout: STEP_TIMEOUT_MS }, + Number(nextRadius), + ); + await page.waitForFunction( + (expectedRadius) => { + const runtime = window.__URDF_STUDIO_DEBUG__?.getRuntimeSceneTransforms?.(); + const mesh = (runtime?.visualMeshes ?? []).find((entry) => entry.link === 'FR_foot'); + return Math.abs((mesh?.scale?.[0] ?? 0) - expectedRadius) < 1e-6; + }, + { timeout: STEP_TIMEOUT_MS }, + Number(nextRadius), + ); + + const appliedState = { + save: await readSaveState(page), + robot: await readRobotState(page), + }; + + await closeSourceEditor(page); + + return { + autoApply, + initialHasRawFoot: initialCode.includes(''), + initialHasPrefixedFoot: initialCode.includes('laikago_FR_foot'), + initialState, + pendingState, + appliedState, + }; +} + +function validateScenario(scenario, expectedRadius) { + if (!scenario.initialHasRawFoot || scenario.initialHasPrefixedFoot) { + throw new Error('Source editor did not show the raw imported URDF.'); + } + + if (Math.abs((scenario.initialState.storeRadius ?? 0) - 0.0165) > 1e-6) { + throw new Error('Initial store radius did not match the imported Unitree URDF.'); + } + + if (Math.abs((scenario.initialState.runtimeScale ?? 0) - 0.0165) > 1e-6) { + throw new Error('Initial runtime radius did not match the imported Unitree URDF.'); + } + + if (!scenario.autoApply) { + if ( + scenario.pendingState.save.disabled !== false || + scenario.pendingState.save.modified !== true + ) { + throw new Error('Manual-save scenario did not expose a dirty editable state.'); + } + + if (Math.abs((scenario.pendingState.robot.storeRadius ?? 0) - 0.0165) > 1e-6) { + throw new Error('Manual-save scenario mutated store state before save.'); + } + + if (Math.abs((scenario.pendingState.robot.runtimeScale ?? 0) - 0.0165) > 1e-6) { + throw new Error('Manual-save scenario mutated runtime state before save.'); + } + } else if ( + scenario.appliedState.save.disabled !== true || + scenario.appliedState.save.modified !== false + ) { + throw new Error('Auto-apply scenario should not leave the editor dirty.'); + } + + if (Math.abs((scenario.appliedState.robot.storeRadius ?? 0) - expectedRadius) > 1e-6) { + throw new Error('Applied store radius did not match the edited Unitree URDF.'); + } + + if (Math.abs((scenario.appliedState.robot.runtimeScale ?? 0) - expectedRadius) > 1e-6) { + throw new Error('Applied runtime radius did not match the edited Unitree URDF.'); + } +} + +async function main() { + if (!(await pathExists(FIXTURE_PATH))) { + throw new Error(`Missing fixture archive: ${FIXTURE_PATH}`); + } + + const site = await ensureSite(); + const browser = await puppeteer.launch({ headless: 'new' }); + + try { + const { page, consoleErrors, pageErrors } = await openPage(browser); + + const manualScenario = await runScenario(page, { + autoApply: false, + nextRadius: '0.03', + }); + const autoScenario = await runScenario(page, { + autoApply: true, + nextRadius: '0.031', + }); + + validateScenario(manualScenario, 0.03); + validateScenario(autoScenario, 0.031); + + const report = { + generatedAt: new Date().toISOString(), + manualScenario, + autoScenario, + consoleErrors, + pageErrors, + }; + + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await writeFile(OUTPUT_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } finally { + await browser.close(); + await site.stop(); + } +} + +await main(); diff --git a/scripts/regression/validate_unitree_ros_usda_selected_browser.mjs b/scripts/regression/validate_unitree_ros_usda_selected_browser.mjs index 7276d30a1..5a68e9fdd 100755 --- a/scripts/regression/validate_unitree_ros_usda_selected_browser.mjs +++ b/scripts/regression/validate_unitree_ros_usda_selected_browser.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFile } from 'node:fs/promises'; +import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; @@ -9,13 +9,11 @@ import { setTimeout as delay } from 'node:timers/promises'; const OUTPUT_PATH = path.resolve('tmp/regression/unitree-ros-usda-selected.json'); const SITE_URL = 'http://127.0.0.1:4173/?regressionDebug=1'; const SITE_BASE_URL = 'http://127.0.0.1:4173'; -const MODELS = [ - 'go2_description/urdf/go2_description.usda', - 'b2_description/urdf/b2_description.usda', - 'h1_2_description/h1_2.usda', -]; const MAX_ATTEMPTS = 2; const SITE_TIMEOUT_MS = 120_000; +const MODEL_TIMEOUT_MS = 600_000; +const URDF_FIXTURE_ROOT = path.resolve('test/unitree_ros/robots'); +const USDA_FIXTURE_ROOT = path.resolve('test/unitree_ros_usda'); function runCommand(command, args) { return new Promise((resolve, reject) => { @@ -39,6 +37,61 @@ async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')); } +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function collectUrdfFiles(rootDir) { + const files = []; + const entries = await readdir(rootDir, { withFileTypes: true }); + + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolutePath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectUrdfFiles(absolutePath))); + continue; + } + if ( + entry.isFile() && + entry.name.toLowerCase().endsWith('.urdf') && + absolutePath.includes(`${path.sep}urdf${path.sep}`) + ) { + files.push(absolutePath); + } + } + + return files; +} + +async function discoverModels() { + const urdfFiles = await collectUrdfFiles(URDF_FIXTURE_ROOT); + const discoveredModels = []; + + for (const absoluteUrdfPath of urdfFiles) { + const relativeUrdfPath = path.relative(URDF_FIXTURE_ROOT, absoluteUrdfPath).replace(/\\/g, '/'); + const packageDir = path.dirname(path.dirname(relativeUrdfPath)); + const fileStem = path.basename(relativeUrdfPath, '.urdf'); + const usdaRelativePath = `${packageDir}/urdf/${fileStem}.usda`; + if (await pathExists(path.join(USDA_FIXTURE_ROOT, usdaRelativePath))) { + discoveredModels.push(usdaRelativePath); + } + } + + const extraModels = ['b2_description_mujoco/xml/b2_description.usda']; + for (const relativePath of extraModels) { + if (await pathExists(path.join(USDA_FIXTURE_ROOT, relativePath))) { + discoveredModels.push(relativePath); + } + } + + return [...new Set(discoveredModels)].sort((left, right) => left.localeCompare(right)); +} + async function isSiteReachable(url) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5_000); @@ -61,12 +114,16 @@ async function ensureSite() { return { stop: async () => {} }; } - const child = spawn('npm', ['run', 'preview', '--', '--host', '127.0.0.1', '--port', '4173'], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env, - detached: true, - }); + const child = spawn( + 'npm', + ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + detached: true, + }, + ); const deadline = Date.now() + SITE_TIMEOUT_MS; while (Date.now() < deadline) { @@ -90,13 +147,99 @@ async function ensureSite() { throw new Error(`Timed out waiting for preview at ${SITE_BASE_URL}`); } +function hasFiniteVector(value, expectedLength) { + return ( + Array.isArray(value) && + value.length === expectedLength && + value.every((entry) => Number.isFinite(Number(entry))) + ); +} + +function hasSceneBindingCoverage(result) { + const sceneSummary = result?.selectedUsdSceneSummary; + const baseLink = sceneSummary?.baseLink; + if (!sceneSummary || !baseLink) { + return false; + } + + const hasAnyBaseLinkBinding = + baseLink.bindingSummary?.withDescriptorMaterialId > 0 || + baseLink.bindingSummary?.withGeometryMaterialId > 0 || + baseLink.bindingSummary?.withGeomSubsetSections > 0; + const linkTransform = baseLink.transform ?? baseLink.runtimeLinkTransform ?? null; + const maxDimension = hasFiniteVector(baseLink.bounds?.size, 3) + ? Math.max(...baseLink.bounds.size.map((entry) => Number(entry))) + : Number.NaN; + const hasRuntimeBaseLinkTransform = + (hasFiniteVector(linkTransform?.position, 3) && + hasFiniteVector(linkTransform?.quaternion, 4)) || + (Array.isArray(baseLink.runtimeVisualMeshTransforms) && + baseLink.runtimeVisualMeshTransforms.some( + (entry) => hasFiniteVector(entry?.position, 3) && hasFiniteVector(entry?.quaternion, 4), + )); + + return Boolean( + sceneSummary.available === true && + sceneSummary.fileName === result?.selectedFileName && + baseLink.found === true && + baseLink.visualDescriptorCount > 0 && + hasAnyBaseLinkBinding && + baseLink.bindingSummary?.withoutAnyMaterialBinding < baseLink.bindingSummary?.descriptorCount && + hasFiniteVector(baseLink.bounds?.size, 3) && + baseLink.bounds.size.every((entry) => Number(entry) > 0) && + Number.isFinite(maxDimension) && + maxDimension < 10 && + hasRuntimeBaseLinkTransform, + ); +} + +function shouldRequireSceneBindingCoverage(result) { + return Boolean( + typeof result?.selectedFileName === 'string' && + (result.selectedFileName.endsWith('.viewer_roundtrip.usd') || + result.selectedFileName.endsWith('.usd')), + ); +} + +function isExpectedMainThreadModel(result) { + const targetPath = String( + result?.selectedFileName || result?.targetFileName || result?.modelKey || '', + ) + .replace(/\\/g, '/') + .toLowerCase(); + + return ( + targetPath.includes('/h1_2/') || + targetPath.includes('/h1_2_handless/') || + targetPath.includes('/h1_with_hand') + ); +} + function validateResult(result) { + const hasResolvedRobotData = + result?.workerResolveEntry?.status === 'resolved' || + result?.runtimeResolveEntry?.status === 'resolved'; + const requiresWorkerPipeline = shouldRequireSceneBindingCoverage(result); + const expectsOffscreenRenderer = !isExpectedMainThreadModel(result); + const usesOffscreenRenderer = + result?.workerResolveEntry?.status === 'resolved' && + !result?.runtimeResolveEntry && + result?.orbitInteraction?.canvasLabel === 'usd-offscreen-canvas' && + result?.orbitInteraction?.changed === true; + const usesMainThreadRenderer = result?.runtimeResolveEntry?.status === 'resolved'; return Boolean( result?.loaded === true && - result?.runtimePresent === true && + hasResolvedRobotData && result?.stageReady === true && result?.metadataSourcePass === true && - result?.selectedFileName === result?.targetFileName, + result?.selectedFileName === result?.targetFileName && + result?.stagePreparationMode === 'worker' && + (!requiresWorkerPipeline || hasSceneBindingCoverage(result)) && + (!expectsOffscreenRenderer || usesOffscreenRenderer) && + (expectsOffscreenRenderer || usesMainThreadRenderer) && + (result?.consoleErrors?.length ?? 0) === 0 && + (result?.consoleWarnings?.length ?? 0) === 0 && + (result?.pageErrors?.length ?? 0) === 0, ); } @@ -105,6 +248,7 @@ function summarizeFailures(report) { .filter((result) => !validateResult(result)) .map((result) => ({ modelKey: result.modelKey, + error: result.error ?? null, loaded: result.loaded, runtimePresent: result.runtimePresent, stageReady: result.stageReady, @@ -113,58 +257,124 @@ function summarizeFailures(report) { metadataSourcePass: result.metadataSourcePass, selectedFileName: result.selectedFileName, targetFileName: result.targetFileName, + selectedUsdSceneSummary: result.selectedUsdSceneSummary ?? null, + orbitInteraction: result.orbitInteraction ?? null, consoleErrors: result.consoleErrors, + consoleWarnings: result.consoleWarnings, pageErrors: result.pageErrors, })); } -async function main() { - let lastReport = null; - const site = await ensureSite(); - try { - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { +function buildPerModelOutputPath(modelKey) { + const fileName = modelKey.replace(/[\\/]/g, '__').replace(/[^a-zA-Z0-9._-]+/g, '_'); + return path.resolve('tmp/regression/unitree-ros-usda-selected', `${fileName}.json`); +} + +async function writeAggregateReport(models, results) { + const report = { + generatedAtUtc: new Date().toISOString(), + workspace: process.cwd(), + siteUrl: SITE_URL, + summary: { + modelCount: models.length, + passedCount: results.filter((result) => validateResult(result)).length, + failedCount: results.filter((result) => !validateResult(result)).length, + models, + }, + results, + }; + + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await writeFile(OUTPUT_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + return report; +} + +async function runModelRegression(modelKey) { + let lastError = null; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const modelOutputPath = buildPerModelOutputPath(modelKey); + try { await runCommand('node', [ 'scripts/regression/run_unitree_ros_usda_browser_regression.mjs', '--site-url', SITE_URL, '--no-start', + '--timeout-ms', + String(MODEL_TIMEOUT_MS), '--output', - OUTPUT_PATH, - ...MODELS.flatMap((model) => ['--model', model]), + modelOutputPath, + '--model', + modelKey, ]); - lastReport = await readJson(OUTPUT_PATH); - - const failures = summarizeFailures(lastReport); - if (failures.length === 0) { - console.log( - JSON.stringify( - { - output: OUTPUT_PATH, - modelCount: lastReport.summary?.modelCount ?? MODELS.length, - passedCount: MODELS.length, - failedCount: 0, - }, - null, - 2, - ), - ); - return; + const report = await readJson(modelOutputPath); + const result = report?.results?.[0] ?? null; + if (result) { + return result; } + lastError = new Error(`Missing regression result for ${modelKey}`); + } catch (error) { + lastError = error; + } - if (attempt < MAX_ATTEMPTS) { - console.warn( - `[validate-unitree-ros-usda-selected-browser] retrying after attempt ${attempt}`, - failures, - ); - } + if (attempt < MAX_ATTEMPTS) { + console.warn( + `[validate-unitree-ros-usda-selected-browser] retrying model ${modelKey} after attempt ${attempt}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); + } + } + + return { + modelKey, + error: lastError instanceof Error ? lastError.message : String(lastError), + loaded: false, + runtimePresent: false, + stageReady: false, + stagePreparationMode: null, + metadataSource: null, + metadataSourcePass: false, + selectedFileName: null, + targetFileName: modelKey, + selectedUsdSceneSummary: null, + consoleErrors: [], + pageErrors: [], + }; +} + +async function main() { + const models = await discoverModels(); + if (models.length === 0) { + throw new Error(`No Unitree ROS USDA fixtures were discovered under ${USDA_FIXTURE_ROOT}`); + } + + const site = await ensureSite(); + try { + const results = []; + for (const modelKey of models) { + results.push(await runModelRegression(modelKey)); } + const report = await writeAggregateReport(models, results); + const failures = summarizeFailures(report); + if (failures.length === 0) { + console.log( + JSON.stringify( + { + output: OUTPUT_PATH, + modelCount: report.summary.modelCount, + passedCount: report.summary.passedCount, + failedCount: 0, + }, + null, + 2, + ), + ); + return; + } + + throw new Error(`Unitree USDA browser validation failed: ${JSON.stringify(failures, null, 2)}`); } finally { await site.stop(); } - - throw new Error( - `Unitree USDA browser validation failed: ${JSON.stringify(summarizeFailures(lastReport), null, 2)}`, - ); } main().catch((error) => { diff --git a/scripts/regression/validate_unitree_selected_browser.mjs b/scripts/regression/validate_unitree_selected_browser.mjs index 0d85c8c34..dc6fffbbf 100755 --- a/scripts/regression/validate_unitree_selected_browser.mjs +++ b/scripts/regression/validate_unitree_selected_browser.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFile } from 'node:fs/promises'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; @@ -9,9 +9,10 @@ import { setTimeout as delay } from 'node:timers/promises'; const OUTPUT_PATH = path.resolve('tmp/regression/unitree-browser-selected.json'); const SITE_URL = 'http://127.0.0.1:4173/?regressionDebug=1'; const SITE_BASE_URL = 'http://127.0.0.1:4173'; -const MODELS = ['Go2', 'B2', 'H1-2']; +const MODELS = ['Go2', 'Go2W', 'B2', 'H1', 'H1-2', 'H1-2-Handless', 'G1-23DoF', 'G1-29DoF']; const MAX_ATTEMPTS = 2; const SITE_TIMEOUT_MS = 120_000; +const MODEL_TIMEOUT_MS = 600_000; function runCommand(command, args) { return new Promise((resolve, reject) => { @@ -35,6 +36,15 @@ async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')); } +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + async function isSiteReachable(url) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5_000); @@ -57,12 +67,16 @@ async function ensureSite() { return { stop: async () => {} }; } - const child = spawn('npm', ['run', 'preview', '--', '--host', '127.0.0.1', '--port', '4173'], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env, - detached: true, - }); + const child = spawn( + 'npm', + ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + detached: true, + }, + ); const deadline = Date.now() + SITE_TIMEOUT_MS; while (Date.now() < deadline) { @@ -93,14 +107,133 @@ function hasResolvedRobotData(result) { ); } +function hasFiniteVector(value, expectedLength) { + return ( + Array.isArray(value) && + value.length === expectedLength && + value.every((entry) => Number.isFinite(Number(entry))) + ); +} + +function hasSceneBindingCoverage(result) { + const sceneSummary = result?.selectedUsdSceneSummary; + const baseLink = sceneSummary?.baseLink; + if (!sceneSummary || !baseLink) { + return false; + } + + const hasAnyBaseLinkBinding = + baseLink.bindingSummary?.withDescriptorMaterialId > 0 || + baseLink.bindingSummary?.withGeometryMaterialId > 0 || + baseLink.bindingSummary?.withGeomSubsetSections > 0; + const linkTransform = baseLink.transform ?? baseLink.runtimeLinkTransform ?? null; + const maxDimension = hasFiniteVector(baseLink.bounds?.size, 3) + ? Math.max(...baseLink.bounds.size.map((entry) => Number(entry))) + : Number.NaN; + const hasRuntimeBaseLinkTransform = + (hasFiniteVector(linkTransform?.position, 3) && + hasFiniteVector(linkTransform?.quaternion, 4)) || + (Array.isArray(baseLink.runtimeVisualMeshTransforms) && + baseLink.runtimeVisualMeshTransforms.some( + (entry) => hasFiniteVector(entry?.position, 3) && hasFiniteVector(entry?.quaternion, 4), + )); + + return Boolean( + sceneSummary.available === true && + sceneSummary.fileName === result?.selectedFileName && + baseLink.found === true && + baseLink.visualDescriptorCount > 0 && + hasAnyBaseLinkBinding && + baseLink.bindingSummary?.withoutAnyMaterialBinding < baseLink.bindingSummary?.descriptorCount && + hasFiniteVector(baseLink.bounds?.size, 3) && + baseLink.bounds.size.every((entry) => Number(entry) > 0) && + Number.isFinite(maxDimension) && + maxDimension < 10 && + hasRuntimeBaseLinkTransform, + ); +} + +function hasExpectedB2VisualMaterialRendering(result) { + const targetPath = String(result?.selectedFileName || result?.targetFileName || '').toLowerCase(); + if (!targetPath.includes('unitree_model/b2/')) { + return true; + } + + const summary = result?.selectedUsdVisualMaterialSummary; + if (!summary || !Array.isArray(summary.meshes) || summary.meshes.length === 0) { + return false; + } + + const materialColorsByName = new Map(); + for (const mesh of summary.meshes) { + if (mesh?.overrideColor || mesh?.hasOverrideMaterial) { + return false; + } + const materials = Array.isArray(mesh?.materials) ? mesh.materials : []; + for (const material of materials) { + const name = String(material?.name || '').trim(); + const color = String(material?.color || '') + .trim() + .toLowerCase(); + if (name && color && !materialColorsByName.has(name)) { + materialColorsByName.set(name, color); + } + } + } + + const parseHexColor = (value) => { + const normalized = String(value || '') + .trim() + .toLowerCase(); + const match = /^#([0-9a-f]{6})$/.exec(normalized); + return match ? Number.parseInt(match[1], 16) : Number.NaN; + }; + + const accentColor = parseHexColor(materialColorsByName.get('material_______024')); + return ( + materialColorsByName.get('material_______023') === '#000000' && + Number.isFinite(accentColor) && + accentColor >= 0x000000 && + accentColor <= 0x101010 + ); +} + +function isExpectedMainThreadModel(result) { + const targetPath = String( + result?.selectedFileName || result?.targetFileName || result?.modelKey || '', + ) + .replace(/\\/g, '/') + .toLowerCase(); + + return ( + targetPath.includes('/b2/') || + targetPath.includes('b2_description') || + targetPath.includes('/h1_2/') || + targetPath.includes('/h1_2_handless/') + ); +} + function validateResult(result) { + const expectsOffscreenRenderer = !isExpectedMainThreadModel(result); + const usesOffscreenRenderer = + result?.workerResolveEntry?.status === 'resolved' && + !result?.runtimeResolveEntry && + result?.orbitInteraction?.canvasLabel === 'usd-offscreen-canvas' && + result?.orbitInteraction?.changed === true; + const usesMainThreadRenderer = result?.runtimeResolveEntry?.status === 'resolved'; return Boolean( result?.loaded === true && - result?.runtimePresent === true && hasResolvedRobotData(result) && result?.stageReady === true && result?.stagePreparationMode === 'worker' && - result?.metadataSourcePass === true, + result?.metadataSourcePass === true && + hasSceneBindingCoverage(result) && + hasExpectedB2VisualMaterialRendering(result) && + (!expectsOffscreenRenderer || usesOffscreenRenderer) && + (expectsOffscreenRenderer || usesMainThreadRenderer) && + (result?.consoleErrors?.length ?? 0) === 0 && + (result?.consoleWarnings?.length ?? 0) === 0 && + (result?.pageErrors?.length ?? 0) === 0, ); } @@ -117,58 +250,126 @@ function summarizeFailures(report) { stagePreparationMode: result.stagePreparationMode, metadataSource: result.metadataSource, metadataSourcePass: result.metadataSourcePass, + selectedUsdSceneSummary: result.selectedUsdSceneSummary ?? null, + selectedUsdVisualMaterialSummary: result.selectedUsdVisualMaterialSummary ?? null, + orbitInteraction: result.orbitInteraction ?? null, consoleErrors: result.consoleErrors, + consoleWarnings: result.consoleWarnings, pageErrors: result.pageErrors, })); } -async function main() { - let lastReport = null; - const site = await ensureSite(); - try { - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { +function buildPerModelOutputPath(modelKey) { + const fileName = modelKey.replace(/[\\/]/g, '__').replace(/[^a-zA-Z0-9._-]+/g, '_'); + return path.resolve('tmp/regression/unitree-browser-selected', `${fileName}.json`); +} + +async function writeAggregateReport(results) { + const report = { + generatedAtUtc: new Date().toISOString(), + workspace: process.cwd(), + siteUrl: SITE_URL, + summary: { + modelCount: MODELS.length, + passedCount: results.filter((result) => validateResult(result)).length, + failedCount: results.filter((result) => !validateResult(result)).length, + models: MODELS, + }, + results, + }; + + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await writeFile(OUTPUT_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + return report; +} + +async function runModelRegression(modelKey) { + let lastError = null; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const modelOutputPath = buildPerModelOutputPath(modelKey); + try { await runCommand('node', [ 'scripts/regression/run_unitree_browser_regression.mjs', '--site-url', SITE_URL, '--no-start', + '--timeout-ms', + String(MODEL_TIMEOUT_MS), '--output', - OUTPUT_PATH, - ...MODELS.flatMap((model) => ['--model', model]), + modelOutputPath, + '--model', + modelKey, ]); - lastReport = await readJson(OUTPUT_PATH); - - const failures = summarizeFailures(lastReport); - if (failures.length === 0) { - console.log( - JSON.stringify( - { - output: OUTPUT_PATH, - modelCount: lastReport.summary?.modelCount ?? MODELS.length, - passedCount: MODELS.length, - failedCount: 0, - }, - null, - 2, - ), - ); - return; + if (!(await pathExists(modelOutputPath))) { + throw new Error(`Missing regression output for ${modelKey}`); } - + const report = await readJson(modelOutputPath); + const result = report?.results?.[0] ?? null; + if (result) { + return result; + } + throw new Error(`Missing regression result for ${modelKey}`); + } catch (error) { + lastError = error; if (attempt < MAX_ATTEMPTS) { console.warn( - `[validate-unitree-selected-browser] retrying after attempt ${attempt}`, - failures, + `[validate-unitree-selected-browser] retrying model ${modelKey} after attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, ); } } + } + + return { + modelKey, + sampleId: modelKey, + error: lastError instanceof Error ? lastError.message : String(lastError), + loaded: false, + runtimePresent: false, + workerResolveEntry: null, + runtimeResolveEntry: null, + stageReady: false, + stagePreparationMode: null, + metadataSource: null, + metadataSourcePass: false, + selectedUsdSceneSummary: null, + selectedUsdVisualMaterialSummary: null, + orbitInteraction: null, + consoleErrors: [], + consoleWarnings: [], + pageErrors: [], + }; +} + +async function main() { + const site = await ensureSite(); + try { + const results = []; + for (const modelKey of MODELS) { + results.push(await runModelRegression(modelKey)); + } + const report = await writeAggregateReport(results); + const failures = summarizeFailures(report); + if (failures.length === 0) { + console.log( + JSON.stringify( + { + output: OUTPUT_PATH, + modelCount: report.summary.modelCount, + passedCount: report.summary.passedCount, + failedCount: 0, + }, + null, + 2, + ), + ); + return; + } + + throw new Error(`Unitree USD browser validation failed: ${JSON.stringify(failures, null, 2)}`); } finally { await site.stop(); } - - throw new Error( - `Unitree USD browser validation failed: ${JSON.stringify(summarizeFailures(lastReport), null, 2)}`, - ); } main().catch((error) => { diff --git a/src/app/App.tsx b/src/app/App.tsx index 651690704..29d05a181 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -26,7 +26,11 @@ import { } from './utils/documentLoadFlow'; import { peekPreResolvedRobotImport } from './utils/preResolvedRobotImportCache'; import { prewarmUsdSelectionInBackground } from './utils/usdSelectionPrewarm'; +import { prewarmUsdViewerRuntimesInBackground } from './utils/usdRuntimeStartupPrewarm'; +import { commitResolvedRobotLoad } from './utils/commitResolvedRobotLoad'; +import { resolveUsdViewerRoundtripSelection } from './utils/usdViewerRoundtripSelection'; import { resolveAppModeAfterRobotContentChange } from './utils/contentChangeAppMode'; +import { resolveExportErrorMessage } from './utils/exportErrorMessage'; import { mapRobotImportProgressToDocumentLoadPercent, resolveBootstrapDocumentLoadPhase, @@ -34,6 +38,7 @@ import { } from './utils/documentLoadProgress'; import { buildStandaloneImportAssetWarning, + canProceedWithStandaloneImportAssetWarning, collectStandaloneImportSupportAssetPaths, } from './utils/importPackageAssetReferences'; import { @@ -84,7 +89,7 @@ interface AppContentProps { import type { RobotImportResult } from '@/core/parsers/importRobotFile'; import { resolveMJCFSource } from '@/core/parsers/mjcf/mjcfSourceResolver'; import { translations, type Language } from '@/shared/i18n'; -import { isAssetLibraryOnlyFormat, isLibraryRobotExportableFormat } from '@/shared/utils'; +import { isLibraryRobotExportableFormat } from '@/shared/utils'; import type { ExportDialogConfig } from '@/features/file-io/components/ExportDialog/ExportDialog'; import type { ExportProgressState } from '@/features/file-io/types'; import { getUsdStageExportHandler } from '@/features/editor'; @@ -479,10 +484,11 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} useState(null); // UI Store - const { lang, setAppMode, openSettings } = useUIStore( + const { lang, setAppMode, setSidebarTab, openSettings } = useUIStore( useShallow((state) => ({ lang: state.lang, setAppMode: state.setAppMode, + setSidebarTab: state.setSidebarTab, openSettings: state.openSettings, })), ); @@ -531,17 +537,6 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} const applyResolvedRobotImport = useCallback( (file: RobotFile, importResult: RobotImportResult) => { if (importResult.status === 'ready' || importResult.status === 'needs_hydration') { - if (importResult.status === 'ready') { - setRobot(importResult.robotData, { - resetHistory: true, - label: file.format === 'usd' ? 'Load USD stage' : 'Load imported robot', - }); - - if (file.format === 'xacro' && importResult.resolvedUrdfContent) { - setOriginalUrdfContent(importResult.resolvedUrdfContent); - } - markUnsavedChangesBaselineSaved('robot'); - } const currentDocumentLoadState = useAssetsStore.getState().documentLoadState; setDocumentLoadState( preserveDocumentLoadProgressForSameFile({ @@ -595,41 +590,18 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} }); showToast(message, 'info'); }, - [setDocumentLoadState, setOriginalUrdfContent, setRobot, showToast, t], - ); - - const commitResolvedFileSelection = useCallback( - (file: RobotFile, options?: { reloadViewer?: boolean }) => { - const assetLibraryOnlyFile = isAssetLibraryOnlyFormat(file.format); - const originalFileFormat = - file.format === 'urdf' || - file.format === 'mjcf' || - file.format === 'usd' || - file.format === 'xacro' || - file.format === 'sdf' - ? file.format - : null; - if (options?.reloadViewer !== false) { - setViewerReloadKey((value) => value + 1); - } - setSelectedFile(file); - setOriginalUrdfContent(assetLibraryOnlyFile ? '' : file.content); - setOriginalFileFormat(originalFileFormat); - setSelection({ type: null, id: null }); - const currentAppMode = useUIStore.getState().appMode; - const nextAppMode = resolveAppModeAfterRobotContentChange(currentAppMode); - if (nextAppMode !== currentAppMode) { - setAppMode(nextAppMode); - } - }, - [setAppMode, setOriginalFileFormat, setOriginalUrdfContent, setSelectedFile, setSelection], + [setDocumentLoadState, showToast, t], ); // Keep one internal loader so debug automation can force a reload of the // currently selected file without changing normal click behavior. const loadRobotFile = useCallback( - async (file: RobotFile, options?: { forceReload?: boolean }) => { + async (requestedFile: RobotFile, options?: { forceReload?: boolean }) => { const liveAssetsState = useAssetsStore.getState(); + const file = resolveUsdViewerRoundtripSelection( + requestedFile, + liveAssetsState.availableFiles, + ); const currentSelectedFile = liveAssetsState.selectedFile; const nextLoadSupportContextKey = buildRobotLoadSupportContextKey({ availableFiles: liveAssetsState.availableFiles, @@ -674,14 +646,16 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} const message = t.importPackageAssetBundleHint .replace('{packages}', assetLabel) .replace('{assets}', assetLabel); - setDocumentLoadState({ - status: 'error', - fileName: file.name, - format: file.format, - error: message, - }); showToast(message, 'info'); - return; + if (!canProceedWithStandaloneImportAssetWarning(file)) { + setDocumentLoadState({ + status: 'error', + fileName: file.name, + format: file.format, + error: message, + }); + return; + } } const currentResolvedMjcfSource = @@ -738,7 +712,21 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} if (shouldCommitResolvedRobotSelection(preResolvedImportResult)) { lastLoadSupportContextKeyRef.current = nextLoadSupportContextKey; - commitResolvedFileSelection(file, { reloadViewer: shouldReloadViewer }); + commitResolvedRobotLoad({ + currentAppMode: useUIStore.getState().appMode, + file, + importResult: preResolvedImportResult, + markRobotBaselineSaved: () => markUnsavedChangesBaselineSaved('robot'), + onViewerReload: () => setViewerReloadKey((value) => value + 1), + reloadViewer: shouldReloadViewer, + setAppMode, + setOriginalFileFormat, + setOriginalUrdfContent, + setRobot, + setSelectedFile, + setSelection, + setSidebarTab, + }); } applyResolvedRobotImport(file, preResolvedImportResult); if ( @@ -767,7 +755,13 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} { availableFiles: liveAssetsState.availableFiles, assets: liveAssetsState.assets, - usdRobotData: liveAssetsState.getUsdPreparedExportCache(file.name)?.robotData ?? null, + allFileContents: liveAssetsState.allFileContents, + // Fresh USD loads must go through worker hydration instead of short- + // circuiting through any previously prepared cache for the same path. + usdRobotData: + file.format === 'usd' + ? null + : (liveAssetsState.getUsdPreparedExportCache(file.name)?.robotData ?? null), }, { onProgress: (progress) => { @@ -833,7 +827,21 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} if (shouldCommitResolvedRobotSelection(importResult)) { lastLoadSupportContextKeyRef.current = nextLoadSupportContextKey; - commitResolvedFileSelection(file, { reloadViewer: shouldReloadViewer }); + commitResolvedRobotLoad({ + currentAppMode: useUIStore.getState().appMode, + file, + importResult, + markRobotBaselineSaved: () => markUnsavedChangesBaselineSaved('robot'), + onViewerReload: () => setViewerReloadKey((value) => value + 1), + reloadViewer: shouldReloadViewer, + setAppMode, + setOriginalFileFormat, + setOriginalUrdfContent, + setRobot, + setSelectedFile, + setSelection, + setSidebarTab, + }); } applyResolvedRobotImport(file, importResult); if (!shouldReloadViewer && importResult.status === 'ready' && file.format === 'mjcf') { @@ -853,9 +861,15 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} }, [ applyResolvedRobotImport, - commitResolvedFileSelection, setDocumentLoadState, setAppMode, + setOriginalFileFormat, + setOriginalUrdfContent, + setRobot, + setSelectedFile, + setSelection, + setSidebarTab, + setViewerReloadKey, showToast, t, ], @@ -870,6 +884,43 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} loadRobotByNameRef.current = loadRobotFile; + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const requestIdle = window.requestIdleCallback?.bind(window); + const cancelIdle = window.cancelIdleCallback?.bind(window); + let idleHandle: number | null = null; + let timeoutHandle: number | null = null; + + const runPrewarm = () => { + prewarmUsdViewerRuntimesInBackground(); + }; + + if (requestIdle) { + idleHandle = requestIdle( + () => { + runPrewarm(); + }, + { timeout: 1200 }, + ); + + return () => { + if (idleHandle !== null && cancelIdle) { + cancelIdle(idleHandle); + } + }; + } + + timeoutHandle = window.setTimeout(runPrewarm, 16); + return () => { + if (timeoutHandle !== null) { + window.clearTimeout(timeoutHandle); + } + }; + }, []); + useEffect(() => { if (typeof window === 'undefined') { return; @@ -887,6 +938,8 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} setRegressionAppHandlers({ getAvailableFiles: () => useAssetsStore.getState().availableFiles, getSelectedFile: () => useAssetsStore.getState().selectedFile, + getUsdSceneSnapshot: (fileName: string) => + useAssetsStore.getState().getUsdSceneSnapshot(fileName), getDocumentLoadState: () => useAssetsStore.getState().documentLoadState, getRobotState: () => ({ name: useRobotStore.getState().name, @@ -976,10 +1029,7 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} showToast(result.warnings[0], 'info'); } } catch (error) { - showToast( - error instanceof Error && error.message ? error.message : t.exportFailedParse, - 'error', - ); + showToast(resolveExportErrorMessage(error, t), 'error'); } finally { setProjectExportProgress(null); setIsExporting(false); @@ -1231,10 +1281,7 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} } setDisconnectedWorkspaceUrdfDialog(null); } catch (error) { - showToast( - error instanceof Error && error.message ? error.message : t.exportFailedParse, - 'error', - ); + showToast(resolveExportErrorMessage(error, t), 'error'); } finally { setIsDisconnectedWorkspaceUrdfExporting(false); } @@ -1361,10 +1408,7 @@ export function AppContent({ extensions, onExposeActions }: AppContentProps = {} } setIsExportDialogOpen(false); } catch (error) { - showToast( - error instanceof Error && error.message ? error.message : t.exportFailedParse, - 'error', - ); + showToast(resolveExportErrorMessage(error, t), 'error'); } finally { setIsExporting(false); } diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 64d8e3756..048ed90e8 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -3,14 +3,17 @@ * Main application layout with Header and workspace area */ import React, { useRef, useCallback, useEffect, useMemo, useState, lazy, Suspense } from 'react'; +import type { RootState } from '@react-three/fiber'; import { useShallow } from 'zustand/react/shallow'; import { Header } from './components/Header'; import { IkToolPanel } from './components/IkToolPanel'; import { AppLayoutOverlays } from './components/AppLayoutOverlays'; import { ConnectedDocumentLoadingOverlay } from './components/ConnectedDocumentLoadingOverlay'; +import { FilePreviewWindow } from './components/FilePreviewWindow'; import { FileDropOverlay } from './components/FileDropOverlay'; import { ImportPreparationOverlay } from './components/ImportPreparationOverlay'; import { SnapshotDialog } from './components/SnapshotDialog'; +import { resolveSnapshotCaptureAction } from './components/snapshot-preview/resolveSnapshotCaptureAction'; import { loadBridgeCreateModalModule, loadCollisionOptimizationDialogModule, @@ -66,9 +69,12 @@ import { } from '@/store'; import type { BridgeJoint, RobotFile, UrdfJoint, UrdfLink } from '@/types'; import { translations } from '@/shared/i18n'; -import type { SnapshotCaptureOptions } from '@/shared/components/3d'; +import { + captureWorkspaceCameraSnapshot, + type SnapshotCaptureAction, + type SnapshotCaptureOptions, +} from '@/shared/components/3d'; import { normalizeMergedAppMode } from '@/shared/utils/appMode'; -import { hasSingleDofJoints } from '@/shared/utils/jointTypes'; import { isAssetLibraryOnlyFormat, ROBOT_IMPORT_ACCEPT_ATTRIBUTE } from '@/shared/utils'; import { toDocumentLoadLifecycleState } from '@/store/assetsStore'; import { markUnsavedChangesBaselineSaved } from './utils/unsavedChangesBaseline'; @@ -77,6 +83,8 @@ import { resolveDocumentLoadingOverlayTargetFileName } from './utils/documentLoa import { clearIkDragHelperSelection } from './utils/ikDragSession'; import { resolveIkToolSelectionState } from './utils/ikToolSelectionState'; import { resolveAssemblyRootComponentSelectionAvailability } from './utils/assemblyRootComponentSelection'; +import { buildSimpleModeDraftFile } from './utils/simpleModeDrafts'; +import type { SnapshotPreviewSession } from './components/snapshot-preview/types'; interface ProModeRoundtripSession { baselineSnapshot: string; @@ -103,13 +111,11 @@ interface AppLayoutProps { headerSecondaryAction?: HeaderAction; // View config viewConfig: { - showToolbar: boolean; showOptionsPanel: boolean; showJointPanel: boolean; }; setViewConfig: React.Dispatch< React.SetStateAction<{ - showToolbar: boolean; showOptionsPanel: boolean; showJointPanel: boolean; }> @@ -165,6 +171,7 @@ export function AppLayout({ sidebarTab, sourceCodeAutoApply, setViewOption, + groundPlaneOffset, } = useUIStore( useShallow((state) => ({ appMode: state.appMode, @@ -176,6 +183,7 @@ export function AppLayout({ sidebarTab: state.sidebarTab, sourceCodeAutoApply: state.sourceCodeAutoApply, setViewOption: state.setViewOption, + groundPlaneOffset: state.groundPlaneOffset, })), ); @@ -245,6 +253,7 @@ export function AppLayout({ renameRobotFolder, clearRobotLibrary, getUsdPreparedExportCache, + usdPreparedExportCaches, setDocumentLoadState, } = useAssetsStore( useShallow((state) => ({ @@ -265,6 +274,7 @@ export function AppLayout({ renameRobotFolder: state.renameRobotFolder, clearRobotLibrary: state.clearRobotLibrary, getUsdPreparedExportCache: state.getUsdPreparedExportCache, + usdPreparedExportCaches: state.usdPreparedExportCaches, setDocumentLoadState: state.setDocumentLoadState, })), ); @@ -343,6 +353,7 @@ export function AppLayout({ const snapshotActionRef = useRef< ((options?: Partial) => Promise) | null >(null); + const viewerCanvasStateRef = useRef(null); const transformPendingRef = useRef(false); const pendingUsdAssemblyFileRef = useRef(null); const proModeRoundtripSessionRef = useRef(null); @@ -353,6 +364,9 @@ export function AppLayout({ const [isCollisionOptimizerOpen, setIsCollisionOptimizerOpen] = useState(false); const [isSnapshotDialogOpen, setIsSnapshotDialogOpen] = useState(false); const [isSnapshotCapturing, setIsSnapshotCapturing] = useState(false); + const [snapshotPreviewSession, setSnapshotPreviewSession] = + useState(null); + const snapshotPreviewCaptureActionRef = useRef(null); const [isIkToolPanelOpen, setIsIkToolPanelOpen] = useState(false); const [shouldRenderBridgeModal, setShouldRenderBridgeModal] = useState(false); const [bridgePreview, setBridgePreview] = useState(null); @@ -402,6 +416,8 @@ export function AppLayout({ viewerSourceFilePath, workspaceViewerMjcfSourceFile, sourceCodeDocuments, + hasSimpleModeSourceEdits, + draftUrdfContent, filePreview, previewRobot, previewFileName, @@ -450,18 +466,37 @@ export function AppLayout({ }, selectedFile, shouldRenderAssembly, - showToast, workspaceAssemblyRenderFailureReason, }); const previewFile = previewFileName ? (availableFiles.find((file) => file.name === previewFileName) ?? null) : null; + const selectedFileDraftSourceContent = useMemo(() => { + if (!selectedFile) { + return null; + } + + return ( + sourceCodeDocuments.find((document) => document.filePath === selectedFile.name)?.content ?? + selectedFile.content + ); + }, [selectedFile, sourceCodeDocuments]); + + const preparedAssetSourceFiles = useMemo( + () => + [selectedFile, previewFile].filter((file): file is RobotFile => + Boolean(file && file.format === 'usd'), + ), + [previewFile, selectedFile], + ); const viewerAssets = usePreparedUsdViewerAssets({ assemblyState, assets, availableFiles, + additionalSourceFiles: preparedAssetSourceFiles, + preparedExportCaches: usdPreparedExportCaches, getUsdPreparedExportCache, shouldRenderAssembly, }); @@ -491,7 +526,7 @@ export function AppLayout({ handleRequestSwitchTreeEditorToStructure, handleSwitchTreeEditorToProMode, } = useWorkspaceModeTransitions({ - previewFile, + previewFile: null, selectedFile, availableFiles, allFileContents, @@ -530,7 +565,7 @@ export function AppLayout({ failedToParseFormat: t.failedToParseFormat, }, pendingUsdAssemblyFileRef, - previewFile, + previewFile: null, selectedFile, setDocumentLoadState, setRobot, @@ -541,8 +576,8 @@ export function AppLayout({ // Keep drag-time joint previews scoped to the active viewer runtime. Feeding them // through AppLayout forces the tree and property sidebars into high-frequency re-render. - const previewContextRobot = previewRobot ?? robot; - const isPreviewingWorkspaceSource = Boolean(previewRobot); + const previewContextRobot = robot; + const isPreviewingWorkspaceSource = false; const ikToolSelectionState = useMemo( () => resolveIkToolSelectionState({ @@ -637,6 +672,7 @@ export function AppLayout({ patchEditableSourceAddCollisionBody, patchEditableSourceDeleteCollisionBody, patchEditableSourceUpdateCollisionBody, + patchEditableSourceUpdateJointLimit, patchEditableSourceRenameEntities, } = useEditableSourcePatches({ selectedFile, @@ -647,11 +683,6 @@ export function AppLayout({ setAllFileContents, showToast, }); - const jointPanelAvailable = useMemo( - () => hasSingleDofJoints((previewRobot ?? viewerRobot)?.joints), - [previewRobot?.joints, viewerRobot?.joints], - ); - const { handleNameChange, handleUpdate, @@ -689,6 +720,7 @@ export function AppLayout({ patchEditableSourceAddCollisionBody, patchEditableSourceDeleteCollisionBody, patchEditableSourceUpdateCollisionBody, + patchEditableSourceUpdateJointLimit, patchEditableSourceRenameEntities, setSelection, setPendingCollisionTransform, @@ -745,8 +777,6 @@ export function AppLayout({ setIsBridgeModalOpen, addBridge, setIsCollisionOptimizerOpen, - setViewConfig, - setPendingViewerToolMode, }); const { @@ -791,7 +821,8 @@ export function AppLayout({ documentFlavor: document.documentFlavor, readOnly: document.readOnly, validationEnabled: document.validationEnabled, - onCodeChange: (newCode: string) => handleCodeChange(newCode, document.changeTarget), + onCodeChange: (newCode: string, applyRequest) => + handleCodeChange(newCode, document.changeTarget, applyRequest), onDownload: document.readOnly ? undefined : () => { @@ -801,9 +832,77 @@ export function AppLayout({ [handleCodeChange, sourceCodeDocuments], ); + const viewerSourceFile = useMemo( + () => + getViewerSourceFile({ + selectedFile, + shouldRenderAssembly, + workspaceSourceFile: workspaceViewerMjcfSourceFile, + }), + [selectedFile, shouldRenderAssembly, workspaceViewerMjcfSourceFile], + ); + + const handleCloseSnapshotDialog = useCallback(() => { + setIsSnapshotDialogOpen(false); + setSnapshotPreviewSession(null); + snapshotPreviewCaptureActionRef.current = null; + }, []); + + const handleSnapshotPreviewCaptureActionChange = useCallback( + (action: SnapshotCaptureAction | null) => { + snapshotPreviewCaptureActionRef.current = action; + }, + [], + ); + const handleSnapshot = useCallback(() => { + const viewerCanvasState = viewerCanvasStateRef.current; + const cameraSnapshot = viewerCanvasState + ? captureWorkspaceCameraSnapshot(viewerCanvasState) + : null; + const viewportAspectRatio = + cameraSnapshot?.aspectRatio ?? + (viewerCanvasState?.size.width && viewerCanvasState.size.height + ? viewerCanvasState.size.width / viewerCanvasState.size.height + : 16 / 9); + + snapshotPreviewCaptureActionRef.current = null; + setSnapshotPreviewSession({ + theme, + cameraSnapshot, + viewportAspectRatio, + robotName: viewerRobot.name || 'robot', + robot: viewerRobot, + assets: viewerAssets, + availableFiles, + urdfContent: urdfContentForViewer, + viewerSourceFormat, + sourceFilePath: viewerSourceFilePath, + sourceFile: viewerSourceFile, + jointAngleState, + jointMotionState, + showVisual, + isMeshPreview: selectedFile?.format === 'mesh', + viewerReloadKey, + groundPlaneOffset, + }); setIsSnapshotDialogOpen(true); - }, []); + }, [ + availableFiles, + groundPlaneOffset, + jointAngleState, + jointMotionState, + selectedFile?.format, + showVisual, + theme, + urdfContentForViewer, + viewerAssets, + viewerReloadKey, + viewerRobot, + viewerSourceFile, + viewerSourceFilePath, + viewerSourceFormat, + ]); const handleSetIkDragActive = useCallback( (active: boolean) => { @@ -825,10 +924,9 @@ export function AppLayout({ ); const handleOpenIkTool = useCallback(() => { - setViewConfig((prev) => ({ ...prev, showToolbar: true })); handleSetIkDragActive(true); setIsIkToolPanelOpen(true); - }, [handleSetIkDragActive, setViewConfig]); + }, [handleSetIkDragActive]); const { items: toolboxItems, openTool } = useToolItems({ t, @@ -856,25 +954,30 @@ export function AppLayout({ const handleIkDragActiveChange = useCallback( (active: boolean) => { - if (active) { - setViewConfig((prev) => ({ ...prev, showToolbar: true })); - } handleSetIkDragActive(active); }, - [handleSetIkDragActive, setViewConfig], + [handleSetIkDragActive], ); const handleCaptureSnapshot = useCallback( async (options: SnapshotCaptureOptions) => { - if (!snapshotActionRef.current) { + const captureAction = resolveSnapshotCaptureAction({ + liveCaptureAction: snapshotActionRef.current, + frozenPreviewCaptureAction: snapshotPreviewCaptureActionRef.current, + preferFrozenPreviewCapture: Boolean(snapshotPreviewSession), + }); + + if (!captureAction) { showToast(t.snapshotFailed, 'info'); return; } try { setIsSnapshotCapturing(true); - await snapshotActionRef.current(options); - setIsSnapshotDialogOpen(false); + await captureAction({ + ...options, + cameraSnapshot: snapshotPreviewSession?.cameraSnapshot ?? null, + }); } catch (error) { console.error('Snapshot failed:', error); showToast(t.snapshotFailed, 'info'); @@ -882,7 +985,7 @@ export function AppLayout({ setIsSnapshotCapturing(false); } }, - [showToast, t], + [handleCloseSnapshotDialog, showToast, snapshotPreviewSession, t], ); const { @@ -922,6 +1025,7 @@ export function AppLayout({ labels: { failedToParseFormat: t.failedToParseFormat, importPackageAssetBundleHint: t.importPackageAssetBundleHint, + importPrimitiveGeometryHint: t.importPrimitiveGeometryHint, usdPreviewRequiresOpen: t.usdPreviewRequiresOpen, xacroSourceOnlyPreviewHint: t.xacroSourceOnlyPreviewHint, }, @@ -929,6 +1033,90 @@ export function AppLayout({ showToast, }); + const handleRequestLoadRobot = useCallback( + async ( + file: RobotFile, + intent: 'direct' | 'save-draft' | 'discard', + ): Promise<'loaded' | 'needs-draft-confirm' | 'blocked'> => { + if (selectedFile?.name === file.name) { + return 'loaded'; + } + + const shouldGuardLibrarySwitch = + sidebarTab === 'structure' && + !shouldRenderAssembly && + Boolean(selectedFile) && + hasSimpleModeSourceEdits; + + if (!shouldGuardLibrarySwitch || intent === 'discard') { + onLoadRobot(file); + return 'loaded'; + } + + if (intent === 'direct') { + return 'needs-draft-confirm'; + } + + if (!selectedFile) { + return 'blocked'; + } + + const fallbackStandaloneDraftUrdfContent = + selectedFile.format === 'mjcf' + ? draftUrdfContent + : (draftUrdfContent ?? urdfContentForViewer); + const draftFile = buildSimpleModeDraftFile({ + selectedFile, + currentSourceContent: selectedFileDraftSourceContent, + fallbackUrdfContent: fallbackStandaloneDraftUrdfContent, + availableFiles, + }); + + if (!draftFile) { + showToast(t.simpleModeDraftSaveFailed, 'info'); + return 'blocked'; + } + + const existingDraftIndex = availableFiles.findIndex((entry) => entry.name === draftFile.name); + const nextAvailableFiles = + existingDraftIndex === -1 + ? [...availableFiles, draftFile] + : availableFiles.map((entry, index) => + index === existingDraftIndex ? draftFile : entry, + ); + setAvailableFiles(nextAvailableFiles); + setAllFileContents({ + ...allFileContents, + [draftFile.name]: draftFile.content, + }); + markUnsavedChangesBaselineSaved('robot'); + showToast( + t.simpleModeDraftSaved.replace('{name}', draftFile.name.split('/').pop() || draftFile.name), + 'success', + ); + + onLoadRobot(file); + return 'loaded'; + }, + [ + allFileContents, + availableFiles, + draftUrdfContent, + hasSimpleModeSourceEdits, + onLoadRobot, + selectedFile, + selectedFileDraftSourceContent, + setAllFileContents, + setAvailableFiles, + shouldRenderAssembly, + showToast, + sidebarTab, + t.simpleModeDraftSaveFailed, + t.simpleModeDraftSaved, + urdfContentForViewer, + ], + ); + return (
@@ -1004,8 +1192,10 @@ export function AppLayout({ collapsed={sidebar.leftCollapsed} onToggle={() => toggleSidebar('left')} availableFiles={availableFiles} - onLoadRobot={onLoadRobot} - currentFileName={previewFileName ?? selectedFile?.name} + onLoadRobot={handlePreviewFileWithFeedback} + onRequestLoadRobot={handleRequestLoadRobot} + currentFileName={selectedFile?.name} + sourceFilePath={viewerSourceFilePath} assemblyState={assemblyState} onAddComponent={handleAddComponent} onDeleteLibraryFile={handleDeleteLibraryFile} @@ -1020,6 +1210,8 @@ export function AppLayout({ onSwitchToProMode={handleSwitchTreeEditorToProMode} onRequestSwitchToStructure={handleRequestSwitchTreeEditorToStructure} isReadOnly={isPreviewingWorkspaceSource} + showJointPanel={viewConfig.showJointPanel} + onJointAngleChange={handleJointChange} /> {/* Viewer Container — z-0 stacking context keeps floating panels below sidebars (z-20); @@ -1039,28 +1231,23 @@ export function AppLayout({ onHover={handleHover} onUpdate={handleUpdate} assets={viewerAssets} + allFileContents={allFileContents} lang={lang} theme={theme} showVisual={showVisual} setShowVisual={handleSetShowVisual} snapshotAction={snapshotActionRef} - showToolbar={viewConfig.showToolbar} - setShowToolbar={(show) => setViewConfig((prev) => ({ ...prev, showToolbar: show }))} + onCanvasCreated={(state) => { + viewerCanvasStateRef.current = state; + }} showOptionsPanel={viewConfig.showOptionsPanel} setShowOptionsPanel={handleSetDetailOptionsPanelVisibility} - showJointPanel={viewConfig.showJointPanel && jointPanelAvailable} - setShowJointPanel={(show) => - setViewConfig((prev) => ({ ...prev, showJointPanel: show })) - } + showJointPanel={false} availableFiles={availableFiles} urdfContent={urdfContentForViewer} viewerSourceFormat={viewerSourceFormat} sourceFilePath={viewerSourceFilePath} - sourceFile={getViewerSourceFile({ - selectedFile, - shouldRenderAssembly, - workspaceSourceFile: workspaceViewerMjcfSourceFile, - })} + sourceFile={viewerSourceFile} onRobotDataResolved={handleRobotDataResolved} onDocumentLoadEvent={handleViewerDocumentLoadEvent} onRuntimeRobotLoaded={handleViewerRuntimeRobotLoaded} @@ -1081,8 +1268,6 @@ export function AppLayout({ onAssemblyTransform={handleAssemblyTransform} onComponentTransform={handleComponentTransform} onBridgeTransform={handleBridgeTransform} - filePreview={filePreview} - onClosePreview={handleClosePreview} ikDragActive={ikDragActive} pendingViewerToolMode={pendingViewerToolMode} onConsumePendingViewerToolMode={() => setPendingViewerToolMode(null)} @@ -1093,7 +1278,7 @@ export function AppLayout({ ) : null}
+ toggleSidebar('right')} readOnlyMessage={isPreviewingWorkspaceSource ? t.previewReadOnlyHint : undefined} jointTypeLocked={Boolean(propertyEditorSelectionContext.selectedClosedLoopBridge)} + sourceFilePath={viewerSourceFilePath} /> @@ -1134,7 +1333,9 @@ export function AppLayout({ isOpen={isSnapshotDialogOpen} isCapturing={isSnapshotCapturing} lang={lang} - onClose={() => setIsSnapshotDialogOpen(false)} + previewSession={snapshotPreviewSession} + onPreviewCaptureActionChange={handleSnapshotPreviewCaptureActionChange} + onClose={handleCloseSnapshotDialog} onCapture={handleCaptureSnapshot} /> @@ -1159,7 +1360,8 @@ export function AppLayout({ isCollisionOptimizerOpen={isCollisionOptimizerOpen} loadingOptimizerLabel={t.loadingOptimizer} collisionOptimizationSource={collisionOptimizationSource} - assets={assets} + assets={viewerAssets} + sourceFilePath={viewerSourceFilePath} selection={selection} onCloseCollisionOptimizer={() => setIsCollisionOptimizerOpen(false)} onSelectCollisionTarget={handlePreviewCollisionOptimizationTarget} diff --git a/src/app/components/AppLayoutOverlays.tsx b/src/app/components/AppLayoutOverlays.tsx index fbb8e2360..85d477ed5 100644 --- a/src/app/components/AppLayoutOverlays.tsx +++ b/src/app/components/AppLayoutOverlays.tsx @@ -41,6 +41,7 @@ interface AppLayoutOverlaysProps { loadingOptimizerLabel: string; collisionOptimizationSource: CollisionOptimizationSource; assets: Record; + sourceFilePath?: string; selection: InteractionSelection; onCloseCollisionOptimizer: () => void; onSelectCollisionTarget: (target: CollisionTargetRef) => void; @@ -73,6 +74,7 @@ export function AppLayoutOverlays({ loadingOptimizerLabel, collisionOptimizationSource, assets, + sourceFilePath, selection, onCloseCollisionOptimizer, onSelectCollisionTarget, @@ -105,6 +107,7 @@ export function AppLayoutOverlays({ source={collisionOptimizationSource} lang={lang} assets={assets} + sourceFilePath={sourceFilePath} selection={selection} onClose={onCloseCollisionOptimizer} onSelectTarget={onSelectCollisionTarget} diff --git a/src/app/components/FilePreviewWindow.test.tsx b/src/app/components/FilePreviewWindow.test.tsx new file mode 100644 index 000000000..3c7c5a85d --- /dev/null +++ b/src/app/components/FilePreviewWindow.test.tsx @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import type { DocumentLoadState } from '@/store/assetsStore'; +import type { RobotFile } from '@/types'; + +import { FilePreviewWindow } from './FilePreviewWindow.tsx'; + +function installDom() { + const dom = new JSDOM('
', { + url: 'http://localhost/', + pretendToBeVisual: true, + }); + + (globalThis as { window?: Window }).window = dom.window as unknown as Window; + (globalThis as { document?: Document }).document = dom.window.document; + Object.defineProperty(globalThis, 'navigator', { + value: dom.window.navigator, + configurable: true, + }); + (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement = dom.window.HTMLElement; + (globalThis as { SVGElement?: typeof SVGElement }).SVGElement = dom.window.SVGElement; + (globalThis as { Node?: typeof Node }).Node = dom.window.Node; + (globalThis as { Event?: typeof Event }).Event = dom.window.Event; + (globalThis as { MouseEvent?: typeof MouseEvent }).MouseEvent = dom.window.MouseEvent; + (globalThis as { KeyboardEvent?: typeof KeyboardEvent }).KeyboardEvent = dom.window.KeyboardEvent; + (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame }).requestAnimationFrame = + dom.window.requestAnimationFrame.bind(dom.window); + (globalThis as { cancelAnimationFrame?: typeof cancelAnimationFrame }).cancelAnimationFrame = + dom.window.cancelAnimationFrame.bind(dom.window); + (globalThis as { getComputedStyle?: typeof getComputedStyle }).getComputedStyle = + dom.window.getComputedStyle.bind(dom.window); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + return dom; +} + +function createImageFile(name: string): RobotFile { + return { + name, + format: 'mesh', + content: '', + }; +} + +function createRobotFile(name: string): RobotFile { + return { + name, + format: 'urdf', + content: '', + }; +} + +function renderWindow(options: { + root: Root; + file: RobotFile | null; + assets?: Record; + documentLoadState?: DocumentLoadState; + onClose?: () => void; +}) { + const documentLoadState: DocumentLoadState = options.documentLoadState ?? { + status: 'ready', + fileName: options.file?.name ?? null, + format: options.file?.format ?? null, + error: null, + }; + + return act(async () => { + options.root.render( + {})} + />, + ); + }); +} + +test('FilePreviewWindow renders image previews in a floating window and closes from the header control', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + const root = createRoot(container); + + const imageFile = createImageFile('textures/poster.png'); + let closeCount = 0; + + try { + await renderWindow({ + root, + file: imageFile, + assets: { + 'textures/poster.png': 'blob:image-preview', + }, + onClose: () => { + closeCount += 1; + }, + }); + + assert.equal(container.textContent?.includes('File Preview: poster.png'), true); + + const image = container.querySelector('img'); + assert.ok(image, 'expected the preview window to render an image preview'); + assert.equal(image.getAttribute('src'), 'blob:image-preview'); + + const closeButton = container.querySelector('button[aria-label="Close Preview"]'); + assert.ok(closeButton, 'expected a close button in the preview window header'); + + await act(async () => { + closeButton.dispatchEvent( + new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ); + }); + + assert.equal(closeCount, 1); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('FilePreviewWindow shows a loading state while preview content is still resolving', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + const root = createRoot(container); + + const robotFile = createRobotFile('robots/demo.urdf'); + + try { + await renderWindow({ + root, + file: robotFile, + documentLoadState: { + status: 'loading', + fileName: robotFile.name, + format: robotFile.format, + error: null, + }, + }); + + assert.equal(container.textContent?.includes('Loading robot...'), true); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('FilePreviewWindow exposes an add action for addable preview files', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + const root = createRoot(container); + + const robotFile = createRobotFile('robots/demo.urdf'); + const addedFiles: string[] = []; + + try { + await act(async () => { + root.render( + {}} + onAddComponent={(file) => { + addedFiles.push(file.name); + }} + />, + ); + }); + + const addButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add'), + ); + assert.ok(addButton, 'expected an add button in the preview window header'); + + await act(async () => { + addButton.dispatchEvent( + new dom.window.MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ); + }); + + assert.deepEqual(addedFiles, [robotFile.name]); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/app/components/FilePreviewWindow.tsx b/src/app/components/FilePreviewWindow.tsx new file mode 100644 index 000000000..847d03d16 --- /dev/null +++ b/src/app/components/FilePreviewWindow.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useMemo } from 'react'; +import { AlertCircle, FileCode, LoaderCircle, Plus } from 'lucide-react'; + +import { DraggableWindow } from '@/shared/components'; +import { useDraggableWindow } from '@/shared/hooks'; +import { translations } from '@/shared/i18n'; +import { + classifyLibraryFileKind, + isLibraryComponentAddableFile, +} from '@/shared/utils/robotFileSupport'; +import type { Language } from '@/store'; +import type { DocumentLoadLifecycleState, DocumentLoadState } from '@/store/assetsStore'; +import type { RobotFile, RobotState, Theme } from '@/types'; + +const LazyUnifiedViewer = React.lazy(async () => ({ + default: (await import('./UnifiedViewer')).UnifiedViewer, +})); + +interface FilePreviewWindowProps { + file: RobotFile | null; + previewRobot: RobotState | null; + previewState?: { urdfContent: string; fileName: string }; + assets: Record; + allFileContents: Record; + availableFiles: RobotFile[]; + documentLoadState: DocumentLoadState; + lang: Language; + theme: Theme; + onClose: () => void; + onAddComponent?: (file: RobotFile) => void; +} + +function normalizeAssetPath(path: string): string { + return path.replace(/^\/+/, ''); +} + +function resolveAssetUrl(path: string, assets: Record): string | null { + const normalizedPath = normalizeAssetPath(path); + return assets[path] ?? assets[normalizedPath] ?? assets[`/${normalizedPath}`] ?? null; +} + +function resolvePreviewDocumentLoadLifecycleState(file: RobotFile): DocumentLoadLifecycleState { + return { + status: 'ready', + fileName: file.name, + format: file.format, + }; +} + +export function FilePreviewWindow({ + file, + previewRobot, + previewState, + assets, + allFileContents, + availableFiles, + documentLoadState, + lang, + theme, + onClose, + onAddComponent, +}: FilePreviewWindowProps) { + const t = translations[lang]; + const isOpen = Boolean(file); + const windowState = useDraggableWindow({ + isOpen, + defaultPosition: { x: 180, y: 120 }, + defaultSize: { width: 700, height: 500 }, + minSize: { width: 420, height: 320 }, + centerOnMount: true, + enableMinimize: true, + enableMaximize: true, + clampResizeToViewport: false, + dragBounds: { + allowNegativeX: true, + minVisibleWidth: 120, + bottomMargin: 50, + }, + }); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const fileKind = file ? classifyLibraryFileKind(file) : null; + const imageUrl = useMemo(() => { + if (!file || fileKind !== 'image') { + return null; + } + + return resolveAssetUrl(file.name, assets); + }, [assets, file, fileKind]); + + const previewLoadState = useMemo(() => { + if (!file || documentLoadState.fileName !== file.name) { + return 'idle' as const; + } + + if (documentLoadState.status === 'loading' || documentLoadState.status === 'hydrating') { + return 'loading' as const; + } + + if (documentLoadState.status === 'error') { + return 'error' as const; + } + + return 'ready' as const; + }, [documentLoadState.fileName, documentLoadState.status, file]); + + const previewLifecycleState = useMemo( + () => (file ? resolvePreviewDocumentLoadLifecycleState(file) : null), + [file], + ); + + if (!file) { + return null; + } + + const displayName = file.name.split('/').pop() ?? file.name; + const canRender3dPreview = Boolean( + previewState?.urdfContent && previewRobot && fileKind !== 'image', + ); + const canAddComponent = Boolean(onAddComponent && isLibraryComponentAddableFile(file)); + const showLoadingState = previewLoadState === 'loading' && !canRender3dPreview && !imageUrl; + const errorMessage = + previewLoadState === 'error' ? (documentLoadState.error ?? t.noPreviewImage) : t.noPreviewImage; + + return ( + + + + {t.filePreview}: {displayName} + + + } + headerActions={ + canAddComponent ? ( + + ) : null + } + className="z-[110] flex flex-col overflow-hidden rounded-lg border border-border-black bg-panel-bg shadow-2xl" + headerClassName="flex h-11 items-center justify-between border-b border-border-black bg-element-bg px-3" + showResizeHandles + closeTitle={t.closePreview} + maximizeTitle={t.expand} + restoreTitle={t.collapse} + minimizeTitle={t.minimize} + > +
+ {canRender3dPreview && previewState && previewRobot && previewLifecycleState ? ( + + + {t.loadingRobot} +
+ } + > + {}} + onUpdate={() => {}} + assets={assets} + allFileContents={allFileContents} + lang={lang} + theme={theme} + showVisual={true} + showOptionsPanel={false} + showJointPanel={false} + availableFiles={availableFiles} + urdfContent={previewState.urdfContent} + sourceFilePath={file.name} + sourceFile={file} + selection={previewRobot.selection} + isMeshPreview={file.format === 'mesh'} + documentLoadState={previewLifecycleState} + showUsageGuide={false} + /> + + ) : imageUrl ? ( +
+ {displayName} +
+ ) : showLoadingState ? ( +
+ + {t.loadingRobot} +
+ ) : ( +
+ + {errorMessage} +
+ )} + +
+ ); +} diff --git a/src/app/components/Header.test.tsx b/src/app/components/Header.test.tsx index 7119a54e9..7f0b442a8 100644 --- a/src/app/components/Header.test.tsx +++ b/src/app/components/Header.test.tsx @@ -32,7 +32,6 @@ function renderHeader() { onClick: () => {}, }, viewConfig: { - showToolbar: true, showOptionsPanel: true, showJointPanel: true, }, @@ -58,3 +57,10 @@ test('Header does not reserve empty center dock width when no toolbar is mounted assert.match(markup, /min-w-0/); assert.doesNotMatch(markup, /min-w-\[240px\]/); }); + +test('Header uses a slimmer top bar height', () => { + const markup = renderHeader(); + + assert.match(markup, /h-11/, 'header should keep a slightly slimmer top bar height'); + assert.doesNotMatch(markup, /h-12/, 'header should no longer use the taller top bar height'); +}); diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 73be229fc..5edae9b58 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -37,7 +37,6 @@ interface HeaderProps { onSnapshot: () => void; // View config viewConfig: { - showToolbar: boolean; showOptionsPanel: boolean; showJointPanel: boolean; }; @@ -106,7 +105,7 @@ export function Header({ return (
{/* Left Section - Logo & Menus */}
@@ -146,7 +145,7 @@ export function Header({
{ + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.width, + '560px', + 'snapshot dialog should default to a narrower width that does not over-stretch the shell', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog defaults the grid toggle to enabled with the visible Grid label', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + }), + ); + }); + + const labelTexts = Array.from(container.querySelectorAll('div')) + .map((element) => element.textContent?.trim()) + .filter(Boolean); + assert.ok( + labelTexts.includes('Grid'), + 'snapshot dialog should expose the positive Grid label instead of Hide Grid', + ); + assert.ok( + !labelTexts.includes('Hide Grid'), + 'snapshot dialog should no longer render the old negative grid label', + ); + + const gridSwitch = container.querySelector('[role="switch"]'); + assert.ok(gridSwitch, 'snapshot dialog should render the grid switch'); + assert.equal( + gridSwitch?.getAttribute('aria-checked'), + 'true', + 'grid should be visible by default when the dialog opens', + ); + assert.equal( + gridSwitch?.getAttribute('aria-label'), + 'Grid', + 'grid switch aria label should match the visible positive label', + ); + assert.match( + gridSwitch?.parentElement?.className ?? '', + /\bjustify-start\b/, + 'grid switch row should align the control to the left edge of its field', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog renders the live preview state without the frozen-view hint copy', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'refreshing', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const previewImage = container.querySelector('img[alt="Snapshot live preview"]'); + assert.ok(previewImage, 'snapshot dialog should render the latest preview image'); + assert.equal(previewImage?.getAttribute('src'), 'blob:preview'); + assert.equal( + previewImage?.getAttribute('draggable'), + 'false', + 'snapshot dialog preview image should opt out of native browser drag behavior', + ); + + const textContent = container.textContent ?? ''; + assert.match(textContent, /Live Preview/); + assert.match(textContent, /Updating preview/); + assert.doesNotMatch(textContent, /Based on the view when this dialog opened/); + assert.doesNotMatch(textContent, /Final export quality still follows the selected resolution/); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog keeps the live preview inside the scrollable content area', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const scrollableContent = container.querySelector('.overflow-y-auto'); + assert.ok(scrollableContent, 'snapshot dialog should keep a scrollable content region'); + assert.match( + scrollableContent.textContent ?? '', + /Live Preview/, + 'preview content should stay inside the scrollable body instead of competing with the footer', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog keeps the live preview inside an adaptive shell instead of letting it consume the full card width', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const scrollableContent = container.querySelector('.overflow-y-auto') as HTMLElement | null; + assert.ok(scrollableContent, 'snapshot dialog should render the scrollable body'); + assert.match( + scrollableContent.className, + /\bflex-col\b/, + 'scrollable body should stack sections in a flex column so the preview can consume extra height', + ); + + const previewCard = container.querySelector( + '[data-testid="snapshot-preview-card"]', + ) as HTMLElement | null; + assert.ok(previewCard, 'snapshot dialog should render the preview card'); + assert.match( + previewCard.className, + /\bflex-1\b/, + 'preview card should expand to use spare dialog height', + ); + + const previewShell = container.querySelector( + '[data-testid="snapshot-preview-frame-shell"]', + ) as HTMLElement | null; + assert.ok(previewShell, 'snapshot dialog should render the preview frame shell'); + assert.equal( + previewShell.style.maxWidth, + '360px', + 'default snapshot dialog width should keep the preview inside a conservative adaptive cap', + ); + + const previewFrame = container.querySelector( + '[data-testid="snapshot-preview-frame"]', + ) as HTMLElement | null; + assert.ok(previewFrame, 'snapshot dialog should render the preview frame'); + assert.match( + previewFrame.className, + /\bw-full\b/, + 'preview frame should use the available card width', + ); + assert.doesNotMatch( + previewFrame.className, + /max-w-\[280px\]/, + 'preview frame should no longer be trapped inside the old narrow width cap', + ); + } finally { + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog auto-fits its default height to the rendered content when the viewport allows it', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerHeight'); + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'scrollHeight', + ); + const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'offsetHeight', + ); + + Object.defineProperty(dom.window, 'innerHeight', { + value: 900, + configurable: true, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return this.className.includes('overflow-y-auto') ? 596 : 0; + }, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this.className.includes('h-10')) { + return 40; + } + if (this.className.includes('border-t')) { + return 46; + } + return 0; + }, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.height, + '682px', + 'snapshot dialog should shrink to the content-fitted desktop height instead of keeping a fixed tall shell', + ); + } finally { + if (originalInnerHeightDescriptor) { + Object.defineProperty(dom.window, 'innerHeight', originalInnerHeightDescriptor); + } + if (originalScrollHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'scrollHeight', + originalScrollHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + if (originalOffsetHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { offsetHeight?: number }).offsetHeight; + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog caps its auto-fitted height to the available viewport when the content is taller', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerHeight'); + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'scrollHeight', + ); + const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'offsetHeight', + ); + + Object.defineProperty(dom.window, 'innerHeight', { + value: 680, + configurable: true, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return this.className.includes('overflow-y-auto') ? 700 : 0; + }, + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this.className.includes('h-10')) { + return 40; + } + if (this.className.includes('border-t')) { + return 46; + } + return 0; + }, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const windowRoot = container.firstElementChild as HTMLElement | null; + assert.ok(windowRoot, 'snapshot dialog should render a draggable window root'); + assert.equal( + windowRoot.style.height, + '656px', + 'snapshot dialog should clamp the fitted height to the current viewport limit', + ); + } finally { + if (originalInnerHeightDescriptor) { + Object.defineProperty(dom.window, 'innerHeight', originalInnerHeightDescriptor); + } + if (originalScrollHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'scrollHeight', + originalScrollHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + if (originalOffsetHeightDescriptor) { + Object.defineProperty( + dom.window.HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeightDescriptor, + ); + } else { + delete (dom.window.HTMLElement.prototype as { offsetHeight?: number }).offsetHeight; + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog collapses its settings sections into one column on narrow viewports', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerWidthDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerWidth'); + + Object.defineProperty(dom.window, 'innerWidth', { + value: 430, + configurable: true, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + }), + ); + }); + + const outputSectionTitle = Array.from(container.querySelectorAll('div')).find( + (element) => element.textContent?.trim() === 'Output', + ); + assert.ok(outputSectionTitle, 'snapshot dialog should render the output section title'); + + const outputSectionGrid = outputSectionTitle.parentElement?.querySelector( + '.grid', + ) as HTMLElement | null; + assert.ok(outputSectionGrid, 'output section should render a settings grid'); + assert.match( + outputSectionGrid.className, + /\bgrid-cols-1\b/, + 'narrow snapshot dialog widths should collapse settings into a single column', + ); + + const sceneSectionTitle = Array.from(container.querySelectorAll('div')).find( + (element) => element.textContent?.trim() === 'Scene', + ); + assert.ok(sceneSectionTitle, 'snapshot dialog should render the scene section title'); + + const sceneSectionGrid = sceneSectionTitle.parentElement?.querySelector( + '.grid', + ) as HTMLElement | null; + assert.ok(sceneSectionGrid, 'scene section should render a settings grid'); + assert.match( + sceneSectionGrid.className, + /\bgrid-cols-1\b/, + 'scene settings should also collapse to a single column on narrow widths', + ); + } finally { + if (originalInnerWidthDescriptor) { + Object.defineProperty(dom.window, 'innerWidth', originalInnerWidthDescriptor); + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); + +test('SnapshotDialog shrinks the preview cap further on narrow layouts so the settings area stays readable', async () => { + const dom = installDom(); + const container = dom.window.document.getElementById('root'); + assert.ok(container, 'root container should exist'); + + const root = createRoot(container); + const originalInnerWidthDescriptor = Object.getOwnPropertyDescriptor(dom.window, 'innerWidth'); + + Object.defineProperty(dom.window, 'innerWidth', { + value: 430, + configurable: true, + }); + + try { + await act(async () => { + root.render( + React.createElement(SnapshotDialog, { + isOpen: true, + isCapturing: false, + lang: 'en', + onClose: () => {}, + onCapture: () => {}, + previewState: { + status: 'ready', + imageUrl: 'blob:preview', + aspectRatio: 16 / 9, + }, + }), + ); + }); + + const previewShell = container.querySelector( + '[data-testid="snapshot-preview-frame-shell"]', + ) as HTMLElement | null; + assert.ok(previewShell, 'snapshot dialog should render the compact preview shell'); + assert.equal( + previewShell.style.maxWidth, + '300px', + 'narrow layouts should reduce the preview cap so the preview does not overwhelm the dialog', + ); + } finally { + if (originalInnerWidthDescriptor) { + Object.defineProperty(dom.window, 'innerWidth', originalInnerWidthDescriptor); + } + await act(async () => { + root.unmount(); + }); + dom.window.close(); + } +}); diff --git a/src/app/components/SnapshotDialog.tsx b/src/app/components/SnapshotDialog.tsx index f365dea7c..eb24f050e 100644 --- a/src/app/components/SnapshotDialog.tsx +++ b/src/app/components/SnapshotDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Camera, X } from 'lucide-react'; import { Button, @@ -12,9 +12,12 @@ import { DraggableWindow } from '@/shared/components'; import { useDraggableWindow } from '@/shared/hooks'; import { DEFAULT_SNAPSHOT_CAPTURE_OPTIONS, + type SnapshotCaptureAction, type SnapshotCaptureOptions, } from '@/shared/components/3d'; import { translations, type Language } from '@/shared/i18n'; +import { SnapshotPreviewRenderer } from './snapshot-preview/SnapshotPreviewRenderer'; +import type { SnapshotDialogPreviewState, SnapshotPreviewSession } from './snapshot-preview/types'; const SNAPSHOT_RESOLUTION_OPTIONS = [ { value: '1280', label: '720p' }, @@ -29,12 +32,58 @@ const PANEL_SECTION_CLASS_NAME = const FIELD_ROW_CLASS_NAME = 'grid grid-cols-[78px_minmax(0,1fr)] items-center gap-2'; const FIELD_LABEL_CLASS_NAME = 'truncate text-[10px] font-medium tracking-[0.01em] text-text-secondary'; +const SNAPSHOT_DIALOG_DEFAULT_SIZE = { + width: 560, + height: 690, +} as const; +const SNAPSHOT_DIALOG_MIN_SIZE = { + width: 360, + height: 420, +} as const; +const SNAPSHOT_DIALOG_HEADER_HEIGHT = 40; +const SNAPSHOT_DIALOG_VIEWPORT_MARGIN = 24; +const SNAPSHOT_DIALOG_VIEWPORT_MIN_HEIGHT = 320; +const SNAPSHOT_DIALOG_COMPACT_LAYOUT_WIDTH = 520; +const SNAPSHOT_PREVIEW_MIN_WIDTH = 220; +const SNAPSHOT_PREVIEW_REGULAR_MAX_WIDTH = 360; +const SNAPSHOT_PREVIEW_COMPACT_MAX_WIDTH = 300; +const SNAPSHOT_PREVIEW_REGULAR_WIDTH_GUTTER = 180; +const SNAPSHOT_PREVIEW_COMPACT_WIDTH_GUTTER = 96; + +const clamp = (value: number, min: number, max: number) => { + if (max < min) { + return min; + } + return Math.max(min, Math.min(max, value)); +}; + +const resolveSnapshotDialogHeight = ({ + scrollContentHeight, + footerHeight, + viewportHeight, +}: { + scrollContentHeight: number; + footerHeight: number; + viewportHeight: number; +}) => { + const viewportLimit = Math.max( + SNAPSHOT_DIALOG_VIEWPORT_MIN_HEIGHT, + viewportHeight - SNAPSHOT_DIALOG_VIEWPORT_MARGIN, + ); + const minHeight = Math.min(SNAPSHOT_DIALOG_MIN_SIZE.height, viewportLimit); + const naturalHeight = SNAPSHOT_DIALOG_HEADER_HEIGHT + footerHeight + scrollContentHeight; + return clamp(naturalHeight, minHeight, viewportLimit); +}; + interface SnapshotDialogProps { isOpen: boolean; isCapturing: boolean; lang: Language; onClose: () => void; onCapture: (options: SnapshotCaptureOptions) => Promise | void; + previewSession?: SnapshotPreviewSession | null; + previewState?: SnapshotDialogPreviewState; + onPreviewCaptureActionChange?: (action: SnapshotCaptureAction | null) => void; } function SnapshotSection({ title, children }: { title: string; children: React.ReactNode }) { @@ -63,6 +112,9 @@ export function SnapshotDialog({ lang, onClose, onCapture, + previewSession = null, + previewState, + onPreviewCaptureActionChange, }: SnapshotDialogProps) { const t = translations[lang]; const [resolutionPreset, setResolutionPreset] = useState( @@ -81,11 +133,18 @@ export function SnapshotDialog({ DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle, ); const [hideGrid, setHideGrid] = useState(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); + const [internalPreviewState, setInternalPreviewState] = useState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); + const scrollBodyRef = useRef(null); + const footerRef = useRef(null); const windowState = useDraggableWindow({ isOpen, - defaultSize: { width: 560, height: 332 }, - minSize: { width: 500, height: 308 }, + defaultSize: SNAPSHOT_DIALOG_DEFAULT_SIZE, + minSize: SNAPSHOT_DIALOG_MIN_SIZE, centerOnMount: true, enableMinimize: false, enableMaximize: false, @@ -98,7 +157,7 @@ export function SnapshotDialog({ }, }); - useEffect(() => { + useLayoutEffect(() => { if (!isOpen) { return; } @@ -113,7 +172,46 @@ export function SnapshotDialog({ setDofMode(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.dofMode); setBackgroundStyle(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.backgroundStyle); setHideGrid(DEFAULT_SNAPSHOT_CAPTURE_OPTIONS.hideGrid); - }, [isOpen]); + setInternalPreviewState({ + status: 'idle', + imageUrl: null, + aspectRatio: previewSession?.viewportAspectRatio ?? 16 / 9, + }); + }, [isOpen, previewSession?.viewportAspectRatio]); + + useLayoutEffect(() => { + if (!isOpen) { + return; + } + + const scrollBody = scrollBodyRef.current; + const footer = footerRef.current; + + if (!scrollBody || !footer) { + return; + } + + const nextHeight = resolveSnapshotDialogHeight({ + scrollContentHeight: scrollBody.scrollHeight, + footerHeight: footer.offsetHeight, + viewportHeight: window.innerHeight, + }); + + windowState.setSize((currentSize) => + currentSize.height === nextHeight ? currentSize : { ...currentSize, height: nextHeight }, + ); + }, [ + isOpen, + lang, + internalPreviewState.aspectRatio, + internalPreviewState.imageUrl, + internalPreviewState.status, + previewState?.imageUrl, + previewState?.status, + previewSession?.viewportAspectRatio, + previewState?.aspectRatio, + windowState.setSize, + ]); useEffect(() => { if (imageFormat === 'jpeg' && backgroundStyle === 'transparent') { @@ -256,7 +354,7 @@ export function SnapshotDialog({ shadow: lang === 'zh' ? '阴影' : 'Shadow', ground: lang === 'zh' ? '地面' : 'Ground', dof: lang === 'zh' ? '景深' : 'DoF', - grid: lang === 'zh' ? '隐藏网格' : 'Hide Grid', + grid: lang === 'zh' ? '网格' : 'Grid', }), [lang], ); @@ -277,6 +375,32 @@ export function SnapshotDialog({ ? 80 : 60 : 'lossless'; + const effectivePreviewState = previewState ?? internalPreviewState; + const isCompactLayout = windowState.size.width <= SNAPSHOT_DIALOG_COMPACT_LAYOUT_WIDTH; + const settingsGridClassName = isCompactLayout + ? 'grid grid-cols-1 gap-y-1.5' + : 'grid grid-cols-2 gap-x-3 gap-y-1.5'; + const previewCardClassName = `rounded-xl border border-border-black bg-element-bg px-3 py-2 shadow-sm ${ + isCompactLayout ? 'flex min-h-[220px] flex-col' : 'flex min-h-[260px] flex-1 flex-col' + }`; + const previewStatusText = + effectivePreviewState.status === 'loading' || effectivePreviewState.status === 'idle' + ? t.snapshotPreviewLoading + : effectivePreviewState.status === 'refreshing' + ? t.snapshotPreviewRefreshing + : effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewReady; + const previewAspectRatio = + effectivePreviewState.aspectRatio > 0 ? effectivePreviewState.aspectRatio : 16 / 9; + const previewFrameMaxWidth = clamp( + windowState.size.width - + (isCompactLayout + ? SNAPSHOT_PREVIEW_COMPACT_WIDTH_GUTTER + : SNAPSHOT_PREVIEW_REGULAR_WIDTH_GUTTER), + SNAPSHOT_PREVIEW_MIN_WIDTH, + isCompactLayout ? SNAPSHOT_PREVIEW_COMPACT_MAX_WIDTH : SNAPSHOT_PREVIEW_REGULAR_MAX_WIDTH, + ); if (!isOpen) { return null; @@ -319,9 +443,12 @@ export function SnapshotDialog({ closeTitle={t.close} >
-
+
-
+
-
+
setHideGrid(!checked)} disabled={isCapturing} ariaLabel={t.snapshotHideGrid} - className="w-full justify-end" + className="w-full justify-start" />
-
-
-
-
- {captureSummary} +
+
+
+
+ {t.snapshotPreviewTitle} +
+
+
+ {previewStatusText} +
-
- - +
+ {effectivePreviewState.imageUrl ? ( +
+ {t.snapshotPreviewAlt} + {effectivePreviewState.status === 'refreshing' ? ( +
+
+ {t.snapshotPreviewRefreshing} +
+
+ ) : null} +
+ ) : ( +
+ {effectivePreviewState.status === 'error' + ? t.snapshotPreviewFailed + : t.snapshotPreviewLoading} +
+ )} +
+
+
+ +
+
+ {captureSummary} +
+ {effectivePreviewState.status === 'error' ? ( +
+ {t.snapshotPreviewRetryingHint} +
+ ) : null}
+ +
+
+ + +
+
+ {!previewState && previewSession ? ( + + ) : null} ); } diff --git a/src/app/components/UnifiedViewer.tsx b/src/app/components/UnifiedViewer.tsx index a6eb40a36..b650c2a4e 100644 --- a/src/app/components/UnifiedViewer.tsx +++ b/src/app/components/UnifiedViewer.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import type { RootState } from '@react-three/fiber'; import type { Group as ThreeGroup, Object3D as ThreeObject3D } from 'three'; import type { AppMode, @@ -28,6 +29,7 @@ import { } from '@/features/editor'; import { resolveViewerJointScopeKey } from '@/app/utils/viewerJointScopeKey'; import { resolveUnifiedViewerForcedSessionState } from '@/app/utils/unifiedViewerForcedSessionState'; +import { resolveUnifiedViewerUsageGuideVisibility } from '@/app/utils/unifiedViewerUsageGuide'; import { captureUnifiedViewerOptionsVisibility, shouldRestoreUnifiedViewerOptionsPanel, @@ -77,13 +79,14 @@ interface UnifiedViewerProps { ) => void; onUpdate: (type: 'link' | 'joint', id: string, data: any) => void; assets: Record; + allFileContents: Record; lang: Language; theme: Theme; showVisual?: boolean; setShowVisual?: (show: boolean) => void; + showUsageGuide?: boolean; snapshotAction?: React.RefObject; - showToolbar?: boolean; - setShowToolbar?: (show: boolean) => void; + onCanvasCreated?: (state: RootState) => void; showOptionsPanel?: boolean; setShowOptionsPanel?: (show: boolean) => void; showJointPanel?: boolean; @@ -162,13 +165,14 @@ export const UnifiedViewer = React.memo( onHover, onUpdate, assets, + allFileContents, lang, theme, showVisual, setShowVisual, + showUsageGuide, snapshotAction, - showToolbar = true, - setShowToolbar, + onCanvasCreated, showOptionsPanel = true, setShowOptionsPanel, showJointPanel = true, @@ -246,6 +250,7 @@ export const UnifiedViewer = React.memo( sourceFilePath, sourceFile, assets, + allFileContents, availableFiles, assemblyState, sourceSceneAssemblyComponentId, @@ -395,6 +400,10 @@ export const UnifiedViewer = React.memo( const showWorldOriginAxesPreference = useUIStore((state) => state.viewOptions.showAxes); const showUsageGuidePreference = useUIStore((state) => state.viewOptions.showUsageGuide); const showWorldOriginAxes = showWorldOriginAxesPreference && !viewerController.showOrigins; + const effectiveShowUsageGuide = resolveUnifiedViewerUsageGuideVisibility( + showUsageGuidePreference, + showUsageGuide, + ); const handleWorkspacePointerDownCapture = React.useCallback( (event: React.PointerEvent) => { @@ -500,16 +509,9 @@ export const UnifiedViewer = React.memo( return; } - setShowToolbar?.(true); viewerController.handleToolModeChange(pendingViewerToolMode); onConsumePendingViewerToolMode?.(); - }, [ - isViewerMode, - onConsumePendingViewerToolMode, - pendingViewerToolMode, - setShowToolbar, - viewerController, - ]); + }, [isViewerMode, onConsumePendingViewerToolMode, pendingViewerToolMode, viewerController]); useEffect(() => { void preloadViewerModeModules().catch((error) => { @@ -550,6 +552,7 @@ export const UnifiedViewer = React.memo( renderKey={`viewer:stable:${viewerReloadKey}`} containerRef={viewerController.containerRef} snapshotAction={snapshotAction} + onCreated={onCanvasCreated} onPointerDownCapture={handleWorkspacePointerDownCapture} onPointerMissed={handleViewerPointerMissed} onMouseMove={viewerController.handleMouseMove} @@ -572,8 +575,7 @@ export const UnifiedViewer = React.memo( }, }} background={WORKSPACE_CANVAS_BACKGROUND} - contextLostMessage={t.webglContextRestoring} - showUsageGuide={showUsageGuidePreference} + showUsageGuide={effectiveShowUsageGuide} overlays={ @@ -94,7 +94,7 @@ export function HeaderActions({