From 3e44c341454b0c844cb426836466a47943df555e Mon Sep 17 00:00:00 2001 From: FanYunFu <2861114322@qq.com> Date: Sat, 30 May 2026 18:34:38 +0800 Subject: [PATCH 01/32] feat(deps): add closed-chain-ik for end-effector IK --- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + vite.config.js | 4 ++++ 3 files changed, 49 insertions(+) diff --git a/package-lock.json b/package-lock.json index 17a97d8..c7d4c19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "robot-keyframe-editor", "version": "1.0.0", "dependencies": { + "closed-chain-ik": "^0.0.3", "three": "^0.160.0", "urdf-loader": "^0.12.3" }, @@ -714,6 +715,22 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/closed-chain-ik": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/closed-chain-ik/-/closed-chain-ik-0.0.3.tgz", + "integrity": "sha512-ww4CPGbe/OCD2rAUKEOYKmeZlzFZ3o14KrbFULUCGZj6NRYej3T/PPGtJ0qkGyq1FzE6qLMCF45D+Q8eQb4Bsg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.3.0", + "linear-solve": "^1.2.1", + "svd-js": "^1.1.1", + "three-mesh-bvh": "^0.5.11" + }, + "peerDependencies": { + "three": ">=0.120.1", + "urdf-loader": ">=0.9.2" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -766,6 +783,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/linear-solve": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/linear-solve/-/linear-solve-1.2.1.tgz", + "integrity": "sha512-YysEUwkRRtxvjJto9kArBJdH/NCnakI8YOrSDD8yK1EV3+t8UjNzqdCQSU+FaNtYLTCBdlI7fwV6Fbn1EEyLew==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -871,11 +900,26 @@ "node": ">=0.10.0" } }, + "node_modules/svd-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/svd-js/-/svd-js-1.1.1.tgz", + "integrity": "sha512-3Mpd08lu7HPD/aDMnHvhLBKHLdoUv+w9411ZLGjZYIR51e05CFNAY+MYMXLs7leo6jljZ8PGkoCSI1I4lF1Z4Q==", + "license": "MIT" + }, "node_modules/three": { "version": "0.160.1", "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz", "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==" }, + "node_modules/three-mesh-bvh": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.5.24.tgz", + "integrity": "sha512-VTIgfjz8aFoPKTQoMIQQv9jJD4ybFRZuKKE1/kqy78FQcuHQ0+iIWv7C5cSb2inlvs7bNMVY3yRx3RXGZfrvzQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.123.0" + } + }, "node_modules/urdf-loader": { "version": "0.12.6", "resolved": "https://registry.npmjs.org/urdf-loader/-/urdf-loader-0.12.6.tgz", diff --git a/package.json b/package.json index c37ab28..fb207a4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "vite": "^5.0.0" }, "dependencies": { + "closed-chain-ik": "^0.0.3", "three": "^0.160.0", "urdf-loader": "^0.12.3" } diff --git a/vite.config.js b/vite.config.js index dbae365..5065713 100644 --- a/vite.config.js +++ b/vite.config.js @@ -48,6 +48,10 @@ const gitInfo = getGitInfo(); const hostingEnv = getHostingEnvironment(); export default defineConfig({ + optimizeDeps: { + // 仅使用子路径导入 core/urdfHelpers,勿预构建整包(会拉入与 Three r160 不兼容的 IKJointHelper) + exclude: ['closed-chain-ik'] + }, server: { port: 3000, open: true, From e87109a9c9dd750c72a6cd86608beccb50d265e2 Mon Sep 17 00:00:00 2001 From: FanYunFu <2861114322@qq.com> Date: Sat, 30 May 2026 18:34:41 +0800 Subject: [PATCH 02/32] feat(viewport): add overlay/split modes and ghost reference model --- docs/REQUIREMENTS-viewport-ik.md | 406 +++++++++++++++++++++++++++++ src/axisGizmo.js | 30 ++- src/comVisualizer.js | 19 ++ src/cookieManager.js | 8 +- src/videoExporter.js | 29 ++- src/viewportManager.js | 421 +++++++++++++++++++++++++++++++ 6 files changed, 884 insertions(+), 29 deletions(-) create mode 100644 docs/REQUIREMENTS-viewport-ik.md create mode 100644 src/viewportManager.js diff --git a/docs/REQUIREMENTS-viewport-ik.md b/docs/REQUIREMENTS-viewport-ik.md new file mode 100644 index 0000000..e00b358 --- /dev/null +++ b/docs/REQUIREMENTS-viewport-ik.md @@ -0,0 +1,406 @@ +# 需求规格说明:同屏叠显视口与末端 IK 编辑 + +| 属性 | 内容 | +|------|------| +| 文档版本 | v1.0 | +| 状态 | 待评审 / 待开发 | +| 适用产品 | 机器人关键帧编辑器(robot_motion_editor) | +| 主要参考机型 | **Unitree G1**(URDF + seed/unitree 轨迹) | +| 关联文档 | [DEVELOPMENT.md](../DEVELOPMENT.md)、[USAGE.md](../USAGE.md)、[seed_format.md](../seed_format.md) | + +--- + +## 1. 背景与目标 + +### 1.1 背景 + +当前版本在同一页面内采用**左右分屏**双视口:左侧仅显示 CSV 原始轨迹(Base),右侧显示叠加关键帧残差后的编辑轨迹(Modified)。该方式占用横向空间、对比时需视线左右切换,且无法在同一视角下直观感受姿态偏差。 + +编辑能力以**关节空间**(侧栏滑块、曲线编辑器)和**基座空间**为主,**不具备**末端连杆位姿拖拽与逆运动学(IK)求解。 + +### 1.2 目标 + +1. **默认同屏叠显**:参考轨迹与编辑轨迹处于**同一 3D 画面**;参考模型为**半透明 Ghost**,可调颜色,降低遮挡。 +2. **保留左右分屏**为可选项,满足习惯旧交互的用户。 +3. 基于 **closed-chain-ik** 实现**手臂与腿部**的末端空间编辑;末端 **Link** 通过**下拉框**配置。 +4. 以 **Unitree G1** 为主要验收机型;拖拽末端结束时**自动写入当前帧关键帧**(残差体系与现有一致)。 + +### 1.3 非目标(本期不做) + +- 多机器人/多 URDF 同场景编辑 +- 全身同时多末端 IK(仅**单激活链**求解;其余链静止) +- 云端 IK 服务、实时硬件下发 +- 自动从 GLB 生成 URDF +- 足底闭链双足约束(可作为后续增强项) + +--- + +## 2. 术语 + +| 术语 | 定义 | +|------|------| +| Base 轨迹 | CSV 加载的只读原始运动数据 | +| 编辑轨迹 | Base + 关键帧残差插值后的结果 | +| Ghost 参考模型 | 仅展示 Base 状态的机器人实例,半透明、可配色 | +| 编辑模型 | 展示编辑后状态的机器人实例,不透明(或略高于 Ghost 的不透明度) | +| 末端 Link | URDF 运动链末端 `URDFLink`,作为 IK 目标位姿附着点 | +| IK 链 | 从某固定关节到末端 Link 的串联关节集合 | +| 关键帧残差 | 现有 `trajectoryManager` 中 `residual` / `baseResidual` 机制 | + +--- + +## 3. 用户故事 + +| ID | 作为… | 我希望… | 以便… | +|----|--------|---------|--------| +| US-01 | 动画师 | 默认在同一画面看到半透明参考姿态和实心编辑姿态 | 直接对比偏差而不用左右看 | +| US-02 | 动画师 | 用复选框开关参考/编辑模型及各自重心显示 | 聚焦单条轨迹或减少视觉干扰 | +| US-03 | 动画师 | 自定义参考 Ghost 颜色 | 在不同主题/背景下仍清晰可辨 | +| US-04 | 动画师 | 切换回左右分屏模式 | 沿用旧工作流 | +| US-05 | 动画师 | 在侧栏选择末端 Link 并拖拽 3D 手柄 | 用末端空间直觉调整手臂/腿 | +| US-06 | 动画师 | 拖完末端自动在当前帧打关键帧 | 少点一次按钮、与滑块编辑一致 | +| US-07 | 工程师 | 工程文件保存 IK 链配置(末端 Link 等) | 重开工程无需重新配置 G1 | + +--- + +## 4. 功能需求:视口与模型显示(FR-V) + +### FR-V-01 视口模式 + +| 项 | 说明 | +|----|------| +| 模式 | `overlay`(同屏叠显)、`split`(左右分屏) | +| **默认值** | **`overlay`** | +| 切换入口 | 3D 视口顶部工具条:单选或下拉「视口:同屏叠显 / 左右分屏」 | +| 持久化 | 写入 `localStorage`(键名建议 `viewportMode`),与自动保存 Cookie 逻辑独立;加载工程时可选择是否覆盖(默认沿用用户上次选择) | + +**同屏叠显(overlay)** + +- 单一 `THREE.Scene`、单一 `OrthographicCamera`、单一 `OrbitControls`。 +- 同一画布全宽渲染,**不使用** `setScissor` 左右切分。 +- 同时存在 `robotGhost`(Base)与 `robotEdited`(Modified)两个 `URDFRobot` 实例。 + +**左右分屏(split)** + +- 行为与**当前线上版本**等价:左 Base、右 Modified,相机同步,中间分隔线。 +- 实现上可保留现有双场景路径,或由 `viewportManager` 在两种拓扑间切换。 + +### FR-V-02 Ghost 参考模型 + +| 属性 | 要求 | +|------|------| +| 数据来源 | 当前帧 `trajectoryManager.getBaseState(frame)` | +| 不透明度 | **0.35 ~ 0.50**,默认 **0.40**;可在视口工具条用滑块调节(步进 0.05) | +| 深度写入 | `depthWrite: false`(推荐),减轻与编辑模型重叠时的 z-fighting | +| 渲染顺序 | Ghost `renderOrder` 低于编辑模型,保证编辑模型优先显示 | +| 颜色 | 用户可选预设色 + 自定义颜色(``) | +| 预设色(建议) | 青 `#4ec9b0`、绿 `#6a9955`、蓝 `#569cd6`、紫 `#c586c0`(需适配深/浅主题对比度) | +| 交互 | Ghost **不可**被 TransformControls / 射线选中;`raycast` 对 Ghost 子网格关闭或单独 layer | + +### FR-V-03 编辑模型 + +| 属性 | 要求 | +|------|------| +| 数据来源 | 当前帧 `trajectoryManager.getCombinedState(frame)` | +| 外观 | 保持 URDF 原始材质为主,可做轻微色调区分(可选,默认不改色) | +| 不透明度 | 1.0(不透明) | +| 交互 | IK 手柄、跟随相机、COM/包络线默认绑定编辑模型 | + +### FR-V-04 显示开关(复选框) + +视口左上角(或工具条内)提供: + +| 控件 | 控制对象 | 默认 | +|------|----------|------| +| ☑ 显示参考轨迹 (Ghost) | `robotGhost.visible`、Ghost 关联 COM(若开启) | 开 | +| ☑ 显示编辑轨迹 | `robotEdited.visible`、编辑模型 COM | 开 | + +- 切换即时生效,无需刷新。 +- 图例展示当前 Ghost 色块 + 文案,与复选框对齐。 + +### FR-V-05 重心与包络线 + +| 模式 | Ghost | 编辑 | +|------|-------|------| +| COM 标记颜色 | 与 Ghost 主色一致或降饱和 | 保持现有红色系或主题警告色 | +| 包络线 | 可选:仅编辑模型计算;Ghost 不画包络线(默认) | 与现逻辑一致 | +| 复选框关闭 | 对应实例 COM 隐藏 | 同左 | + +### FR-V-06 相机与辅助 UI + +- 移除 overlay 模式下左右角标「原始轨迹 / 编辑后」;改为**图例 + 复选框**(FR-V-04)。 +- split 模式保留左右角标。 +- 右下角 `axisGizmo` 在两种模式下均使用**全宽视口**计算位置。 +- `followRobot` 仅跟踪 **编辑模型** 根位姿。 + +### FR-V-07 视频导出 + +- `overlay`:导出画面为同屏双色(与屏幕一致,尊重复选框状态)。 +- `split`:保持现有左右拼接导出逻辑。 + +### FR-V-08 工程保存 / 自动保存 + +工程 JSON 扩展字段(向后兼容,旧工程缺省则用默认值): + +```json +{ + "viewport": { + "mode": "overlay", + "ghostOpacity": 0.4, + "ghostColor": "#4ec9b0", + "showGhost": true, + "showEdited": true + } +} +``` + +--- + +## 5. 功能需求:末端 IK(FR-IK) + +### FR-IK-01 技术选型 + +| 项 | 要求 | +|----|------| +| 库 | **closed-chain-ik**(`gkjohnson/closed-chain-ik-js`) | +| URDF 桥接 | `URDFUtils.urdfRobotToIKRoot`、`setIKFromUrdf`、`setUrdfFromIK` | +| 3D 交互 | `THREE.TransformControls`(平移 + 旋转,空间:世界或局部可配置,默认**世界**) | +| 求解时机 | `objectChange` / `dragging-changed` 节流;`dragging-changed` 结束且求解成功时触发关键帧(见 FR-IK-06) | + +依赖新增需在 `package.json` 声明;实施前完成与 `three@0.160`、`urdf-loader@0.12.x` 的 Spike 兼容性验证。 + +### FR-IK-02 IK 链与末端 Link 配置 + +**配置 UI(侧栏区块「IK 编辑」)** + +| 控件 | 说明 | +|------|------| +| 启用 IK | 主开关;关闭时隐藏 TransformControls | +| 末端 Link | **下拉框**,选项为当前 URDF 中所有 `URDFLink` 名称(按字母或树序排序) | +| 链根关节(高级,可折叠) | 可选下拉:自动推断 / 手动选择链上某一 `URDFJoint`;默认**自动** | + +**自动推断规则(G1 及通用人形)** + +从所选末端 Link 向上遍历 URDF 树,直到遇到以下**停止关节**之一: + +- 躯干:`waist_*`、`torso`、`pelvis`、`base_link` 的父关节 +- 或对侧分支(遇到非当前肢体的 `left_` / `right_` 前缀切换) + +链上仅包含 `revolute` / `continuous` / `prismatic` 关节;`fixed` 跳过。 + +**G1 默认预设(加载 G1 URDF 后自动填充下拉默认值,可改)** + +| 预设名 | 建议末端 Link(示例,以实际 URDF 为准) | 备注 | +|--------|----------------------------------------|------| +| 左手 | 含 `left` + `wrist` / `hand` / `palm` 的 link | 7 DOF 臂 | +| 右手 | 含 `right` + `wrist` / `hand` / `palm` | 同上 | +| 左脚 | 含 `left` + `ankle` / `foot` | 腿链 | +| 右脚 | 含 `right` + `ankle` / `foot` | 同上 | + +seed 轨迹关节名参考:[seed_format.md](../seed_format.md)(`left_*_joint_dof` 等),**IK 链以 URDF Link/Joint 名为准**,与 CSV 列通过现有 `jointController` 名称对齐。 + +用户可从下拉框任选 Link(不限于四肢),以满足非标准 mesh 命名。 + +### FR-IK-03 求解行为 + +| 项 | 说明 | +|----|------| +| 求解对象 | **仅 `robotEdited`** | +| 浮动基座 | 当前帧 **combined** 的 root 位姿作为固定约束,**不参与**该链 IK(腰关节若在链内则参与) | +| 关节限位 | 使用 URDF `` | +| 目标 | TransformControls 的位姿 → `Goal` 附着末端 link | +| 失败 | 位置/姿态误差超阈值:不提交关节角、Toast/状态栏提示「IK 无解或超限」、不自动关键帧 | +| 性能 | 单链 DOF ≥ 6 时优先 `WorkerSolver`;拖拽过程 ≤ 30ms/帧 为目标(G1 实机 URDF 测定) | + +### FR-IK-04 腿链附加选项(与臂共用面板) + +| 选项 | 默认 | 说明 | +|------|------|------| +| 锁定足底高度 | 关 | 开启时求解后强制末端 link 原点 z = 拖拽开始时 z(软约束或后处理) | +| 仅位置 / 位置+姿态 | 位置+姿态 | 腿预设默认「位置+姿态」;臂同 | + +### FR-IK-05 与残差 / 关节 UI 同步 + +求解成功后: + +1. `URDFUtils.setUrdfFromIK(robotEdited, ikRoot)` +2. `robotEdited.updateMatrixWorld(true)` +3. `jointController` 从 `robotEdited` 同步滑块数值 +4. `baseController` 若腰/基座在链外则保持 combined 基座不变 + +### FR-IK-06 自动关键帧(已确认) + +| 事件 | 行为 | +|------|------| +| TransformControls **`pointerup` 且 `dragging === false`** | 若 IK 成功且相对求解前有可感知变化(关节角 Δ > 1e-4 rad) | +| 调用 | `trajectoryManager.addKeyframe(currentFrame, jointValues, baseValues)`,逻辑与 `jointController.autoUpdateKeyframe` / 手动「添加关键帧」一致 | +| UI 反馈 | 时间轴当前帧出现关键帧标记;曲线编辑器刷新 | +| 拖动中 | 仅预览,**不**每帧写入关键帧 Map | + +若当前帧**已有**关键帧:视为**更新**该帧残差(覆盖),不新增帧号。 + +### FR-IK-07 播放与时间轴 + +- 播放动画时:**禁用** TransformControls,避免与插值冲突。 +- scrub 时间轴:若 IK 开启,将手柄同步到当前帧末端 link 世界位姿(`forward kinematics` 由 `robotEdited` 姿态得出)。 + +### FR-IK-08 工程持久化 + +```json +{ + "ik": { + "enabled": false, + "endEffectorLink": "left_wrist_yaw_link", + "chainRootJoint": null, + "legLockFootZ": false, + "goalMode": "positionAndOrientation" + } +} +``` + +### FR-IK-09 单激活链 + +- 同时仅 **1 条** IK 链激活(一个末端下拉选择)。 +- 切换末端下拉时:重建 IK 树、重定位 TransformControls。 + +--- + +## 6. Unitree G1 验收基准 + +### 6.1 资产 + +- URDF:官方或团队提供的 **G1** 完整包(mesh + urdf),关节数与 seed 示例一致(腰 3 + 腿 6×2 + 臂 7×2 = 29 可动轴,以实际 URDF 为准)。 +- 轨迹:至少 1 条 **seed 格式** G1 CSV(见 `seed_format.md`)及 1 条 unitree 格式。 + +### 6.2 验收场景 + +| # | 场景 | 通过标准 | +|---|------|----------| +| G1-01 | 加载 G1 URDF + seed CSV,默认 overlay | Ghost 半透明,编辑模型实心,同视角可对比 | +| G1-02 | 调整 Ghost 颜色与不透明度 | 实时生效,工程保存可恢复 | +| G1-03 | 切换 split | 与现版左右分屏行为一致 | +| G1-04 | 左手末端下拉 + 拖拽 | 肩肘腕随动,关节限位不爆 | +| G1-05 | 左脚末端下拉 + 拖拽 | 髋膝踝随动;可选锁足高度 | +| G1-06 | 拖末端松手 | 当前帧自动关键帧,导出 CSV 含修改 | +| G1-07 | 播放中 | 无 TransformControls;播放结束可继续 IK | +| G1-08 | 换右臂/右腿预设 | 下拉切换后链正确 | + +--- + +## 7. UI 线框(逻辑布局) + +``` +┌─ 工具栏(加载 URDF / CSV / 工程…)────────────────────────────┐ +├─ main-content ─────────────────────────────────────────────────┤ +│ ┌─ #viewport ─────────────────────────────┐ ┌─ sidebar ────────┐ │ +│ │ [视口: ●同屏叠显 ○左右分屏] │ │ …基体/关节… │ │ +│ │ ☑参考(Ghost) ☑编辑 Ghost色 [■] 透明[===]│ │ ▶ IK 编辑 │ │ +│ │ ┌图例: ■ 参考 ■ 编辑────────────────┐ │ │ ☑ 启用 IK │ │ +│ │ │ [ 3D:Ghost + 实心编辑模型 ] │ │ │ 末端 Link [▼] │ │ +│ │ │ [ TransformControls @ 末端 ] │ │ │ ☐ 锁足高度 │ │ +│ │ └────────────────────────────────────┘ │ │ 目标: ○位姿 ●位姿+姿态│ +│ │ [相机 / COM / 包络线 … 现有按钮] │ └──────────────────┘ │ +│ └────────────────────────────────────────┘ │ +├─ timeline + curve editor(现有)───────────────────────────────┤ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 8. 架构与模块划分 + +| 模块 | 职责 | +|------|------| +| `src/viewportManager.js` | 模式切换、单/双场景、Ghost 材质、复选框、localStorage | +| `src/main.js` | 编排;`updateRobotState` 重命名为语义化 `robotGhost` / `robotEdited` | +| `src/ik/ikChainRegistry.js` | Link 列表、自动链推断、G1 预设 | +| `src/ik/ikSolverService.js` | closed-chain-ik 封装、Worker、误差判定 | +| `src/ik/endEffectorControls.js` | TransformControls、拖拽生命周期、自动关键帧 | +| `src/ik/ikPanel.js` | 侧栏 DOM 与 i18n | +| `index.html` / `i18n.js` | 新控件与文案 | + +**数据流(IK 一次拖拽)** + +``` +用户拖 TransformControls + → ikSolverService.solve(goal) + → setUrdfFromIK(robotEdited) + → jointController 同步 + → pointerup → trajectoryManager.addKeyframe + → timeline / curveEditor 刷新 +``` + +--- + +## 9. 非功能需求(NFR) + +| ID | 类别 | 要求 | +|----|------|------| +| NFR-01 | 隐私 | IK 仅在浏览器本地计算,无上传 | +| NFR-02 | 兼容 | Chrome / Edge 最近两个大版本;WebGL2 | +| NFR-03 | 性能 | G1 overlay 下 60fps 预览(无 IK 拖拽时) | +| NFR-04 | 可维护 | 视口与 IK 解耦,split 模式回归测试清单固定 | +| NFR-05 | 国际化 | 中文/英文键值入 `i18n.js` | +| NFR-06 | 向后兼容 | 旧工程无 `viewport`/`ik` 字段时使用文档默认值 | + +--- + +## 10. 实施阶段与交付物 + +| 阶段 | 范围 | 交付物 | 建议工期 | +|------|------|--------|----------| +| **P1** | FR-V 全部 + 工程字段 | overlay 默认、Ghost、复选框、split 可选、导出 | 3~5 天 | +| **P2** | FR-IK Spike + 单链臂 | closed-chain-ik 集成、下拉 Link、自动关键帧 | 4~6 天 | +| **P3** | 腿链 + G1 验收 | 锁足、预设、Worker、G1-01~08 通过 | 4~5 天 | +| **P4** | 文档与回归 | USAGE/DEVELOPMENT 更新、测试脚本 | 1~2 天 | + +--- + +## 11. 验收标准(总表) + +### 11.1 视口 + +- [ ] 首次打开默认为**同屏叠显** +- [ ] Ghost 不透明度在 **0.35~0.5** 可调,默认 0.4 +- [ ] Ghost **颜色可选**且可保存到工程 +- [ ] 复选框可独立隐藏参考/编辑模型 +- [ ] **左右分屏**可从 UI 切换且功能与现版一致 +- [ ] 视频导出与当前视口模式一致 + +### 11.2 IK + +- [ ] 使用 **closed-chain-ik** 实现求解 +- [ ] **下拉框**可选择任意末端 Link;臂、腿均可配置 +- [ ] 拖拽末端松手后**自动** `addKeyframe`(更新或新建当前帧) +- [ ] 求解失败有提示且不写关键帧 +- [ ] **G1** URDF + seed CSV 通过第 6.2 节全部场景 + +--- + +## 12. 风险与依赖 + +| 风险 | 缓解 | +|------|------| +| closed-chain-ik 与 three 版本不兼容 | P2 首日 Spike;必要时锁版本 | +| G1 URDF Link 命名与预设不一致 | 预设仅作初始值;以下拉手动选择为准 | +| Ghost + 实心模型 z-fighting | depthWrite off + renderOrder | +| 自动关键帧过于频繁 | 仅在 pointerup + 变化阈值通过时写入 | +| split/overlay 双路径维护成本 | 统一 `viewportManager` API,避免 main.js 分支膨胀 | + +--- + +## 13. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-05-30 | 初稿:视口叠显 + Ghost + closed-chain-ik + G1 + 自动关键帧 | + +--- + +## 14. 已确认产品决策(评审锁定) + +1. 参考模型:**半透明 Ghost**,opacity **0.35~0.5**,**可选颜色**。 +2. 默认视口:**同屏叠显**;**左右分屏**保留为可选项。 +3. IK:**closed-chain-ik**;末端 **Link 下拉配置**;**手臂与腿**均支持。 +4. 主验收机型:**Unitree G1**。 +5. 拖末端:**自动打关键帧**(pointerup,更新当前帧残差)。 diff --git a/src/axisGizmo.js b/src/axisGizmo.js index a9bb9d1..038ff74 100644 --- a/src/axisGizmo.js +++ b/src/axisGizmo.js @@ -239,8 +239,12 @@ export class AxisGizmo { this.camera.position.lerpVectors(startPosition, targetPosition, eased); this.controls.target.copy(lookAtTarget); - // 同步左右相机(如果是双视口) - if (this.viewportSide === 'right' && this.editor.cameraLeft) { + // 分屏模式下同步左侧相机 + if ( + this.viewportSide === 'right' && + this.editor.viewportManager?.mode === 'split' && + this.editor.cameraLeft + ) { this.editor.cameraLeft.position.copy(this.camera.position); this.editor.cameraLeft.quaternion.copy(this.camera.quaternion); this.editor.cameraLeft.zoom = this.camera.zoom; @@ -289,35 +293,35 @@ export class AxisGizmo { * 渲染指示器 */ render(renderer) { - // 保存当前渲染状态 const currentRenderTarget = renderer.getRenderTarget(); const currentAutoClear = renderer.autoClear; const currentScissorTest = renderer.getScissorTest(); - - // 获取容器位置和大小 + const currentViewport = new THREE.Vector4(); + renderer.getViewport(currentViewport); + const rect = this.container.getBoundingClientRect(); const viewport = document.getElementById('viewport'); const viewportRect = viewport.getBoundingClientRect(); - - // 计算相对于viewport的位置 + const x = rect.left - viewportRect.left; const y = viewportRect.bottom - rect.bottom; const width = rect.width; const height = rect.height; - - // 设置视口和裁剪 + renderer.setViewport(x, y, width, height); renderer.setScissor(x, y, width, height); renderer.setScissorTest(true); renderer.autoClear = false; - - // 渲染指示器 + renderer.render(this.scene, this.gizmoCamera); - - // 恢复渲染状态 + renderer.setRenderTarget(currentRenderTarget); renderer.autoClear = currentAutoClear; renderer.setScissorTest(currentScissorTest); + renderer.setViewport(currentViewport.x, currentViewport.y, currentViewport.z, currentViewport.w); + if (currentScissorTest) { + renderer.setScissor(currentViewport.x, currentViewport.y, currentViewport.z, currentViewport.w); + } } /** diff --git a/src/comVisualizer.js b/src/comVisualizer.js index 8dd22e9..ba4131f 100644 --- a/src/comVisualizer.js +++ b/src/comVisualizer.js @@ -609,6 +609,25 @@ export class COMVisualizer { } } + /** 切换附着场景(overlay / split 视口切换时) */ + setScene(newScene) { + if (!newScene || newScene === this.scene) return; + const objs = [ + this.comMarker, + this.comLine, + this.comProjection, + this.footprintLine, + this.footprintCenter, + this.footprintAxis1, + this.footprintAxis2 + ].filter(Boolean); + for (const obj of objs) { + this.scene.remove(obj); + newScene.add(obj); + } + this.scene = newScene; + } + dispose() { this.scene.remove(this.comMarker); this.scene.remove(this.comLine); diff --git a/src/cookieManager.js b/src/cookieManager.js index 2a455f5..e34b95e 100644 --- a/src/cookieManager.js +++ b/src/cookieManager.js @@ -453,14 +453,15 @@ console.log(' 🔧 开始加载 URDF...'); editor.urdfLoader.loadFromMap(fileMap, (robot) => { console.log(' 🤖 右侧机器人已创建'); editor.robotRight = robot; - editor.sceneRight.add(editor.robotRight); editor.robot = robot; + editor.viewportManager?.applyEditedRenderOrder(editor.robotRight); // 创建左侧机器人副本 editor.urdfLoader.loadFromMap(new Map(fileMap), async (leftRobot) => { console.log(' 🤖 左侧机器人已创建'); editor.robotLeft = leftRobot; - editor.sceneLeft.add(editor.robotLeft); + editor.viewportManager?.applyGhostMaterialWhenReady(editor.robotLeft); + editor.viewportManager?.attachRobots(); // 初始化控制器 const joints = editor.urdfLoader.getJoints(); @@ -469,6 +470,9 @@ console.log(' 🔧 开始加载 URDF...'); const { BaseController } = await import('./baseController.js'); editor.jointController = new JointController(joints, editor); editor.baseController = new BaseController(editor); + if (editor.ikPanel) { + editor.ikPanel.onUrdfLoaded(); + } resolve(); }); diff --git a/src/videoExporter.js b/src/videoExporter.js index 1a16f48..9f7b8a6 100644 --- a/src/videoExporter.js +++ b/src/videoExporter.js @@ -796,21 +796,22 @@ export class VideoExporter { this.ctx.fillStyle = '#1a1a1a'; this.ctx.fillRect(0, 0, canvasWidth, canvasHeight); - // 渲染左侧场景 renderer.clear(); - renderer.setViewport(0, 0, halfWidth, canvasHeight); - renderer.setScissor(0, 0, halfWidth, canvasHeight); - renderer.setScissorTest(true); - renderer.render(this.editor.sceneLeft, this.editor.cameraLeft); - - // 渲染右侧场景 - renderer.setViewport(halfWidth, 0, halfWidth, canvasHeight); - renderer.setScissor(halfWidth, 0, halfWidth, canvasHeight); - renderer.setScissorTest(true); - renderer.render(this.editor.sceneRight, this.editor.cameraRight); - - // 禁用scissor test - renderer.setScissorTest(false); + const vm = this.editor.viewportManager; + if (vm?.mode === 'overlay' && vm.sceneMain && vm.cameraMain) { + renderer.setScissorTest(false); + renderer.setViewport(0, 0, canvasWidth, canvasHeight); + renderer.render(vm.sceneMain, vm.cameraMain); + } else { + renderer.setViewport(0, 0, halfWidth, canvasHeight); + renderer.setScissor(0, 0, halfWidth, canvasHeight); + renderer.setScissorTest(true); + renderer.render(this.editor.sceneLeft, this.editor.cameraLeft); + renderer.setViewport(halfWidth, 0, halfWidth, canvasHeight); + renderer.setScissor(halfWidth, 0, halfWidth, canvasHeight); + renderer.render(this.editor.sceneRight, this.editor.cameraRight); + renderer.setScissorTest(false); + } // 复制到录制canvas this.ctx.drawImage(renderer.domElement, 0, 0); diff --git a/src/viewportManager.js b/src/viewportManager.js new file mode 100644 index 0000000..88145b8 --- /dev/null +++ b/src/viewportManager.js @@ -0,0 +1,421 @@ +import * as THREE from 'three'; + +const STORAGE_KEY = 'robotEditor_viewport'; +const GHOST_RENDER_ORDER = 0; +const EDITED_RENDER_ORDER = 1; + +/** 参考模型固定绿色半透明 */ +const GHOST_COLOR = '#6a9955'; +const GHOST_OPACITY = 0.4; + +const DEFAULTS = { + mode: 'overlay', + ghostOpacity: GHOST_OPACITY, + ghostColor: GHOST_COLOR, + showGhost: true, + showEdited: true +}; + +export class ViewportManager { + constructor(editor) { + this.editor = editor; + this.mode = DEFAULTS.mode; + this.ghostOpacity = DEFAULTS.ghostOpacity; + this.ghostColor = DEFAULTS.ghostColor; + this.showGhost = DEFAULTS.showGhost; + this.showEdited = DEFAULTS.showEdited; + this.sceneMain = null; + this.cameraMain = null; + this._ghostMaterials = []; + this.loadFromStorage(); + } + + loadFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (data.mode === 'overlay' || data.mode === 'split') this.mode = data.mode; + if (typeof data.ghostOpacity === 'number') { + this.ghostOpacity = Math.min(0.5, Math.max(0.35, data.ghostOpacity)); + } + if (typeof data.ghostColor === 'string') this.ghostColor = data.ghostColor; + if (typeof data.showGhost === 'boolean') this.showGhost = data.showGhost; + if (typeof data.showEdited === 'boolean') this.showEdited = data.showEdited; + } catch (e) { + console.warn('viewport settings load failed', e); + } + } + + saveToStorage() { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + mode: this.mode, + ghostOpacity: this.ghostOpacity, + ghostColor: this.ghostColor, + showGhost: this.showGhost, + showEdited: this.showEdited + })); + } + + getSettingsForProject() { + return { + mode: this.mode, + ghostOpacity: this.ghostOpacity, + ghostColor: this.ghostColor, + showGhost: this.showGhost, + showEdited: this.showEdited + }; + } + + applyProjectSettings(viewport) { + if (!viewport) return; + if (viewport.mode === 'overlay' || viewport.mode === 'split') this.mode = viewport.mode; + if (typeof viewport.ghostOpacity === 'number') { + this.ghostOpacity = Math.min(0.5, Math.max(0.35, viewport.ghostOpacity)); + } + if (typeof viewport.ghostColor === 'string') this.ghostColor = viewport.ghostColor; + if (typeof viewport.showGhost === 'boolean') this.showGhost = viewport.showGhost; + if (typeof viewport.showEdited === 'boolean') this.showEdited = viewport.showEdited; + this.saveToStorage(); + this.syncUiFromState(); + this.setMode(this.mode, { skipStorage: true }); + } + + initScenes(theme, frustumSize = 5) { + const editor = this.editor; + this.sceneMain = new THREE.Scene(); + this._applySceneBackground(this.sceneMain, theme, 'main'); + this._addSceneHelpers(this.sceneMain); + + const viewport = document.getElementById('viewport'); + const fullWidth = Math.max(viewport?.clientWidth || 1, 1); + const fullHeight = Math.max(viewport?.clientHeight || 1, 1); + const aspect = fullWidth / fullHeight; + const fs = frustumSize || editor.frustumSize || 5; + + this.cameraMain = new THREE.OrthographicCamera( + fs * aspect / -2, + fs * aspect / 2, + fs / 2, + fs / -2, + 0.1, + 1000 + ); + const refCam = editor.cameraRight || editor.camera; + if (refCam) { + this.cameraMain.position.copy(refCam.position); + this.cameraMain.quaternion.copy(refCam.quaternion); + this.cameraMain.zoom = refCam.zoom; + } else { + this.cameraMain.position.set(3, 3, 2); + } + this.cameraMain.up.set(0, 0, 1); + this.cameraMain.updateProjectionMatrix(); + } + + syncOverlayCameraFromActive() { + const e = this.editor; + const src = e.controls?.object || e.cameraRight; + if (!this.cameraMain || !src) return; + this.cameraMain.position.copy(src.position); + this.cameraMain.quaternion.copy(src.quaternion); + this.cameraMain.zoom = src.zoom; + this.cameraMain.updateProjectionMatrix(); + } + + _addSceneHelpers(scene) { + const ambient = new THREE.AmbientLight(0xffffff, 0.6); + scene.add(ambient); + const directional = new THREE.DirectionalLight(0xffffff, 0.8); + directional.position.set(5, 5, 10); + scene.add(directional); + const grid = new THREE.GridHelper(10, 20); + grid.rotation.x = Math.PI / 2; + scene.add(grid); + const axes = new THREE.AxesHelper(1); + scene.add(axes); + } + + _applySceneBackground(scene, theme, which) { + if (!scene) return; + if (theme === 'light') { + if (which === 'left' || which === 'main') scene.background = new THREE.Color(0xf0f0f0); + else scene.background = new THREE.Color(0xe8e8e8); + } else { + if (which === 'left' || which === 'main') scene.background = new THREE.Color(0x1a1a1a); + else scene.background = new THREE.Color(0x263238); + } + } + + updateSceneBackgrounds(theme) { + const e = this.editor; + this._applySceneBackground(this.sceneMain, theme, 'main'); + this._applySceneBackground(e.sceneLeft, theme, 'left'); + this._applySceneBackground(e.sceneRight, theme, 'right'); + } + + applyGhostMaterial(robot) { + if (!robot) return; + this.ghostColor = GHOST_COLOR; + this.ghostOpacity = GHOST_OPACITY; + this._ghostMaterials = []; + const color = new THREE.Color(GHOST_COLOR); + robot.traverse((obj) => { + if (!obj.isMesh || !obj.material) return; + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + const newMats = mats.map((m) => { + if (!m || typeof m.clone !== 'function') return m; + const cloned = m.clone(); + cloned.color = color.clone(); + if (cloned.emissive) cloned.emissive = color.clone().multiplyScalar(0.15); + cloned.transparent = true; + cloned.opacity = GHOST_OPACITY; + cloned.depthWrite = false; + this._ghostMaterials.push(cloned); + return cloned; + }); + obj.material = Array.isArray(obj.material) ? newMats : newMats[0]; + obj.renderOrder = GHOST_RENDER_ORDER; + obj.raycast = () => {}; + }); + robot.renderOrder = GHOST_RENDER_ORDER; + } + + /** URDF 网格可能异步加载,加载完成后再着色 */ + applyGhostMaterialWhenReady(robot, attempt = 0) { + if (!robot) return; + this.applyGhostMaterial(robot); + let meshCount = 0; + robot.traverse((obj) => { + if (obj.isMesh) meshCount++; + }); + if (meshCount === 0 && attempt < 20) { + requestAnimationFrame(() => this.applyGhostMaterialWhenReady(robot, attempt + 1)); + } + } + + applyEditedRenderOrder(robot) { + if (!robot) return; + robot.traverse((obj) => { + if (obj.isMesh) { + obj.renderOrder = EDITED_RENDER_ORDER; + } + }); + robot.renderOrder = EDITED_RENDER_ORDER; + } + + attachRobots() { + const e = this.editor; + const ghost = e.robotLeft; + const edited = e.robotRight; + if (!ghost && !edited) return; + + [e.sceneLeft, e.sceneRight, this.sceneMain].forEach((s) => { + if (ghost && s?.children.includes(ghost)) s.remove(ghost); + if (edited && s?.children.includes(edited)) s.remove(edited); + }); + + if (this.mode === 'overlay') { + if (ghost) this.sceneMain.add(ghost); + if (edited) this.sceneMain.add(edited); + e.comVisualizerLeft?.setScene(this.sceneMain); + e.comVisualizerRight?.setScene(this.sceneMain); + } else { + if (ghost) e.sceneLeft.add(ghost); + if (edited) e.sceneRight.add(edited); + e.comVisualizerLeft?.setScene(e.sceneLeft); + e.comVisualizerRight?.setScene(e.sceneRight); + } + + this.applyVisibility(); + } + + applyVisibility() { + const e = this.editor; + if (e.robotLeft) e.robotLeft.visible = this.showGhost; + if (e.robotRight) e.robotRight.visible = this.showEdited; + if (e.comVisualizerLeft) { + e.comVisualizerLeft.setVisible(this.showGhost && e.showCOM); + } + if (e.comVisualizerRight) { + e.comVisualizerRight.setVisible(this.showEdited && e.showCOM); + } + } + + setMode(mode, options = {}) { + this.mode = mode; + if (!options.skipStorage) this.saveToStorage(); + this.updateDomVisibility(); + this.attachRobots(); + + const e = this.editor; + if (mode === 'overlay') { + this.syncOverlayCameraFromActive(); + if (e.controls && this.cameraMain) { + e.controls.object = this.cameraMain; + e.camera = this.cameraMain; + } + if (e.axisGizmo) { + e.axisGizmo.camera = this.cameraMain; + } + } else { + if (e.controls && e.cameraRight) { + e.controls.object = e.cameraRight; + e.camera = e.cameraRight; + } + if (e.axisGizmo) { + e.axisGizmo.camera = e.cameraRight; + } + e.cameraLeft.position.copy(e.cameraRight.position); + e.cameraLeft.quaternion.copy(e.cameraRight.quaternion); + e.cameraLeft.zoom = e.cameraRight.zoom; + e.cameraLeft.updateProjectionMatrix(); + } + + this.handleResize(); + if (e.endEffectorControls) { + e.endEffectorControls.onViewportModeChanged(); + } + } + + updateDomVisibility() { + const overlay = this.mode === 'overlay'; + const splitDivider = document.getElementById('viewport-split-divider'); + const labelLeft = document.getElementById('viewport-label-left'); + const labelRight = document.getElementById('viewport-label-right'); + const overlayBar = document.getElementById('viewport-overlay-controls'); + + if (splitDivider) splitDivider.style.display = overlay ? 'none' : 'block'; + if (labelLeft) labelLeft.style.display = overlay ? 'none' : 'block'; + if (labelRight) labelRight.style.display = overlay ? 'none' : 'block'; + // 视口模式 / Ghost 控制在两种模式下均需可见,否则分屏后无法切回 overlay + if (overlayBar) overlayBar.style.display = 'flex'; + } + + syncUiFromState() { + const modeOverlay = document.getElementById('viewport-mode-overlay'); + const modeSplit = document.getElementById('viewport-mode-split'); + if (modeOverlay) modeOverlay.checked = this.mode === 'overlay'; + if (modeSplit) modeSplit.checked = this.mode === 'split'; + + const showGhost = document.getElementById('show-ghost-model'); + const showEdited = document.getElementById('show-edited-model'); + if (showGhost) showGhost.checked = this.showGhost; + if (showEdited) showEdited.checked = this.showEdited; + + } + + setupUi() { + const modeOverlay = document.getElementById('viewport-mode-overlay'); + const modeSplit = document.getElementById('viewport-mode-split'); + const onMode = () => { + const next = modeOverlay?.checked ? 'overlay' : 'split'; + this.setMode(next); + }; + modeOverlay?.addEventListener('change', onMode); + modeSplit?.addEventListener('change', onMode); + + document.getElementById('show-ghost-model')?.addEventListener('change', (e) => { + this.showGhost = e.target.checked; + this.saveToStorage(); + this.applyVisibility(); + }); + document.getElementById('show-edited-model')?.addEventListener('change', (e) => { + this.showEdited = e.target.checked; + this.saveToStorage(); + this.applyVisibility(); + }); + + this.syncUiFromState(); + this.updateDomVisibility(); + } + + getRenderCamera() { + return this.mode === 'overlay' ? this.cameraMain : this.editor.cameraRight; + } + + getActiveScene() { + return this.mode === 'overlay' ? this.sceneMain : this.editor.sceneRight; + } + + handleResize() { + const e = this.editor; + const viewport = document.getElementById('viewport'); + if (!viewport || !e.renderer) return; + + const fullWidth = viewport.clientWidth; + const fullHeight = viewport.clientHeight; + e.renderer.setSize(fullWidth, fullHeight); + + if (this.mode === 'overlay' && this.cameraMain) { + const aspect = fullWidth / fullHeight; + this.cameraMain.left = e.frustumSize * aspect / -2; + this.cameraMain.right = e.frustumSize * aspect / 2; + this.cameraMain.top = e.frustumSize / 2; + this.cameraMain.bottom = e.frustumSize / -2; + this.cameraMain.updateProjectionMatrix(); + } else { + const halfWidth = fullWidth / 2; + const aspect = halfWidth / fullHeight; + e.cameraLeft.left = e.frustumSize * aspect / -2; + e.cameraLeft.right = e.frustumSize * aspect / 2; + e.cameraLeft.top = e.frustumSize / 2; + e.cameraLeft.bottom = e.frustumSize / -2; + e.cameraLeft.updateProjectionMatrix(); + e.cameraRight.left = e.frustumSize * aspect / -2; + e.cameraRight.right = e.frustumSize * aspect / 2; + e.cameraRight.top = e.frustumSize / 2; + e.cameraRight.bottom = e.frustumSize / -2; + e.cameraRight.updateProjectionMatrix(); + } + } + + render() { + const e = this.editor; + const viewport = document.getElementById('viewport'); + if (!viewport || !e.renderer) return; + + const fullWidth = Math.max(viewport.clientWidth, 1); + const fullHeight = Math.max(viewport.clientHeight, 1); + + e.renderer.setScissorTest(false); + e.renderer.setViewport(0, 0, fullWidth, fullHeight); + + if (this.mode === 'overlay') { + const scene = this.sceneMain || e.sceneRight; + const camera = this.cameraMain || e.cameraRight; + if (!scene || !camera) return; + + if (scene.background) { + e.renderer.setClearColor(scene.background); + } + e.renderer.clear(true, true, true); + e.renderer.render(scene, camera); + } else { + const halfWidth = Math.floor(fullWidth / 2); + if (e.sceneLeft?.background) { + e.renderer.setClearColor(e.sceneLeft.background); + } + e.renderer.clear(true, true, true); + + e.renderer.setViewport(0, 0, halfWidth, fullHeight); + e.renderer.setScissor(0, 0, halfWidth, fullHeight); + e.renderer.setScissorTest(true); + e.renderer.render(e.sceneLeft, e.cameraLeft); + + e.renderer.setViewport(halfWidth, 0, halfWidth, fullHeight); + e.renderer.setScissor(halfWidth, 0, halfWidth, fullHeight); + e.renderer.render(e.sceneRight, e.cameraRight); + + e.renderer.setScissorTest(false); + e.renderer.setViewport(0, 0, fullWidth, fullHeight); + } + + if (e.axisGizmo) { + e.axisGizmo.update(); + e.axisGizmo.render(e.renderer); + } + } +} + +export { GHOST_COLOR, GHOST_OPACITY }; From b9fe1ba701ee1085cd81a23bec1f7c5ed371d0a6 Mon Sep 17 00:00:00 2001 From: FanYunFu <2861114322@qq.com> Date: Sat, 30 May 2026 18:34:54 +0800 Subject: [PATCH 03/32] feat(ik): add end-effector IK panel and solver integration --- src/ik/endEffectorControls.js | 289 ++++++++++++++++++++++++++++++++ src/ik/ikChainRegistry.js | 88 ++++++++++ src/ik/ikPanel.js | 155 +++++++++++++++++ src/ik/ikSolverService.js | 145 ++++++++++++++++ src/ik/ikSolverUtils.js | 9 + src/jointController.js | 24 +++ src/timelineController.js | 10 ++ tests/ik-chain-registry-test.js | 69 ++++++++ 8 files changed, 789 insertions(+) create mode 100644 src/ik/endEffectorControls.js create mode 100644 src/ik/ikChainRegistry.js create mode 100644 src/ik/ikPanel.js create mode 100644 src/ik/ikSolverService.js create mode 100644 src/ik/ikSolverUtils.js create mode 100644 tests/ik-chain-registry-test.js diff --git a/src/ik/endEffectorControls.js b/src/ik/endEffectorControls.js new file mode 100644 index 0000000..156cfaa --- /dev/null +++ b/src/ik/endEffectorControls.js @@ -0,0 +1,289 @@ +import * as THREE from 'three'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import { IkSolverService } from './ikSolverService.js'; +import { inferChainJointNames, getUrdfLinkObject } from './ikChainRegistry.js'; +import { i18n } from '../i18n.js'; + +const _pos = new THREE.Vector3(); +const _quat = new THREE.Quaternion(); +const _euler = new THREE.Euler(); + +export class EndEffectorControls { + constructor(editor) { + this.editor = editor; + this.ikService = new IkSolverService(); + this.enabled = false; + this.endEffectorLinkName = ''; + this.goalMode = 'pose'; + this.lockFootZ = false; + this._footZAtDragStart = null; + this.controls = null; + this._proxy = new THREE.Object3D(); + this._lastSolveSuccess = true; + this._jointSnapshotBeforeDrag = null; + } + + onViewportModeChanged() { + this._reattachControls(); + } + + _getScene() { + return this.editor.viewportManager?.getActiveScene() || this.editor.sceneRight; + } + + _getCamera() { + return this.editor.viewportManager?.getRenderCamera() || this.editor.cameraRight; + } + + _reattachControls() { + if (!this.controls) return; + const scene = this._getScene(); + if (!scene) return; + + if (this._proxy.parent !== scene) { + this._proxy.parent?.remove(this._proxy); + scene.add(this._proxy); + } + if (this.controls.parent !== scene) { + this.controls.parent?.remove(this.controls); + scene.add(this.controls); + } + this.controls.camera = this._getCamera(); + } + + setEnabled(on) { + this.enabled = on; + if (on) { + if (!this.editor.robotRight) { + alert(i18n.t('ikNeedUrdf')); + this.enabled = false; + return false; + } + if (!this.endEffectorLinkName) { + this.editor.updateStatus(i18n.t('ikSolveFailed'), 'error'); + this.enabled = false; + return false; + } + try { + this._ensureControls(); + if (!this.rebuildIk()) { + this.editor.updateStatus(i18n.t('ikSolveFailed'), 'error'); + this.enabled = false; + return false; + } + this.syncGizmoToLink(); + this._reattachControls(); + if (this.controls) { + this.controls.enabled = true; + this.controls.attach(this._proxy); + } + return true; + } catch (err) { + console.error('IK enable failed:', err); + this.editor.updateStatus(i18n.t('ikSolveFailed'), 'error'); + this.enabled = false; + return false; + } + } else if (this.controls) { + this.controls.detach(); + this.controls.enabled = false; + } + return true; + } + + setEndLink(linkName) { + this.endEffectorLinkName = linkName; + if (this.enabled) { + this.rebuildIk(); + this.syncGizmoToLink(); + } + } + + setGoalMode(mode) { + this.goalMode = mode; + } + + setLockFootZ(on) { + this.lockFootZ = on; + } + + rebuildIk() { + const robot = this.editor.robotRight; + if (!robot || !this.endEffectorLinkName) return false; + const chain = inferChainJointNames(robot, this.endEffectorLinkName); + return this.ikService.rebuild(robot, this.endEffectorLinkName, chain); + } + + _ensureControls() { + if (this.controls) { + this.controls.enabled = true; + return; + } + + const camera = this._getCamera(); + this.controls = new TransformControls(camera, this.editor.renderer.domElement); + this.controls.setSpace('world'); + this.controls.size = 0.75; + + this.controls.addEventListener('dragging-changed', (e) => { + this.editor.controls.enabled = !e.value; + this.editor.isIkDragging = e.value; + if (e.value) { + this._footZAtDragStart = this._getLinkWorldPosition()?.z ?? null; + this._jointSnapshotBeforeDrag = this._captureChainJointAngles(); + } else { + this._onDragEnd(); + this.editor.isIkDragging = false; + } + }); + + this.controls.addEventListener('change', () => { + if (this.controls.dragging) { + this._onDragMove(); + } + }); + + const scene = this._getScene(); + if (scene) { + scene.add(this._proxy); + scene.add(this.controls); + } + } + + _getLinkWorldPosition() { + const robot = this.editor.robotRight; + const link = getUrdfLinkObject(robot, this.endEffectorLinkName); + if (!link) return null; + link.getWorldPosition(_pos); + return _pos.clone(); + } + + syncGizmoToLink() { + const robot = this.editor.robotRight; + const link = getUrdfLinkObject(robot, this.endEffectorLinkName); + if (!link) return; + + link.getWorldPosition(_pos); + link.getWorldQuaternion(_quat); + _euler.setFromQuaternion(_quat); + + this._proxy.position.copy(_pos); + this._proxy.quaternion.copy(_quat); + this._proxy.updateMatrixWorld(true); + } + + _onDragMove() { + if (!this.enabled || !this.editor.robotRight) return; + + try { + _pos.copy(this._proxy.position); + _quat.copy(this._proxy.quaternion); + if (this.lockFootZ && this._footZAtDragStart != null) { + _pos.z = this._footZAtDragStart; + } + + const options = { + positionOnly: this.goalMode === 'position' + }; + + const result = this.ikService.solve(this.editor.robotRight, _pos, _quat, options); + this._lastSolveSuccess = result.success; + + this._syncJointUi(); + this.editor.robotRight.updateMatrixWorld(true); + this.syncGizmoToLink(); + } catch (err) { + console.error('IK drag solve failed:', err); + this._lastSolveSuccess = false; + } + } + + _onDragEnd() { + if (!this._lastSolveSuccess) { + this.editor.updateStatus(i18n.t('ikSolveFailed'), 'error'); + this.syncGizmoToLink(); + return; + } + + const changed = this._chainAnglesChanged(); + if (changed) { + this._commitKeyframe(); + } + } + + _captureChainJointAngles() { + const robot = this.editor.robotRight; + const names = this.ikService.chainJointNames; + return names.map((n) => robot.joints[n]?.angle ?? 0); + } + + _chainAnglesChanged() { + if (!this._jointSnapshotBeforeDrag) return true; + const current = this._captureChainJointAngles(); + for (let i = 0; i < current.length; i++) { + if (Math.abs(current[i] - this._jointSnapshotBeforeDrag[i]) > 1e-4) { + return true; + } + } + return false; + } + + _syncJointUi() { + const jc = this.editor.jointController; + if (!jc) return; + jc.syncFromRobotSilent(this.editor.robotRight); + if (this.editor.showCOM && this.editor.comVisualizerRight) { + this.editor.comVisualizerRight.update(this.editor.robotRight); + } + } + + _commitKeyframe() { + const editor = this.editor; + if (!editor.jointController || !editor.trajectoryManager.hasTrajectory()) return; + + const frame = editor.timelineController.getCurrentFrame(); + const jointValues = editor.jointController.getCurrentJointValues(); + const baseValues = editor.baseController + ? editor.baseController.getCurrentBaseValues() + : null; + + const isNew = editor.trajectoryManager.addKeyframe(frame, jointValues, baseValues); + + const keyframes = Array.from(editor.trajectoryManager.keyframes.keys()); + editor.timelineController.updateKeyframeMarkers(keyframes); + editor.jointController.updateKeyframeIndicators(); + if (editor.curveEditor) { + editor.curveEditor.drawDebounced(); + } + + editor.updateStatus(isNew ? i18n.t('addKeyframe') : i18n.t('addKeyframe'), 'success'); + } + + onFrameChanged() { + if (!this.enabled) return; + if (this.controls?.dragging) return; + if (this.editor.timelineController?.isPlaying) return; + this.syncGizmoToLink(); + } + + onPlaybackChanged(isPlaying) { + if (!this.controls) return; + if (isPlaying) { + this.controls.detach(); + this.controls.enabled = false; + } else if (this.enabled) { + this.controls.enabled = true; + this.controls.attach(this._proxy); + this.syncGizmoToLink(); + } + } + + dispose() { + if (this.controls) { + this.controls.detach(); + this.controls.dispose(); + this.controls = null; + } + this.ikService.dispose(); + } +} diff --git a/src/ik/ikChainRegistry.js b/src/ik/ikChainRegistry.js new file mode 100644 index 0000000..77a1ffa --- /dev/null +++ b/src/ik/ikChainRegistry.js @@ -0,0 +1,88 @@ +/** G1 及通用人形末端 Link 预设(模糊匹配 URDF 实际名称) */ +export const G1_END_EFFECTOR_PRESETS = [ + { id: 'left_hand', labelKey: 'ikPresetLeftHand', patterns: [/left.*wrist/i, /left.*hand/i, /left.*palm/i] }, + { id: 'right_hand', labelKey: 'ikPresetRightHand', patterns: [/right.*wrist/i, /right.*hand/i, /right.*palm/i] }, + { id: 'left_foot', labelKey: 'ikPresetLeftFoot', patterns: [/left.*ankle/i, /left.*foot/i] }, + { id: 'right_foot', labelKey: 'ikPresetRightFoot', patterns: [/right.*ankle/i, /right.*foot/i] } +]; + +const CHAIN_STOP_KEYWORDS = [ + 'waist', 'torso', 'pelvis', 'base_link', 'base', 'trunk', 'lumbar' +]; + +/** + * 枚举 URDF 中所有 link 名称 + */ +export function listUrdfLinks(robot) { + const names = []; + if (robot?.links) { + names.push(...Object.keys(robot.links)); + } else { + robot?.traverse((obj) => { + if (obj.isURDFLink && obj.name) names.push(obj.name); + }); + } + return [...new Set(names)].sort((a, b) => a.localeCompare(b)); +} + +/** + * 从末端 link 向上收集运动链上的关节名(至躯干/对侧肢体为止) + */ +export function inferChainJointNames(robot, endEffectorLinkName) { + if (!robot || !endEffectorLinkName) return []; + + const endLink = robot.links?.[endEffectorLinkName] + || robot.frames?.[endEffectorLinkName]; + if (!endLink) return []; + + const sidePrefix = endEffectorLinkName.match(/^(left|right)_/i)?.[1]?.toLowerCase(); + + const jointNames = []; + let current = endLink; + + while (current) { + if (current.isURDFJoint) { + const name = current.urdfName || current.name; + const nameLower = name.toLowerCase(); + + if (CHAIN_STOP_KEYWORDS.some((k) => nameLower.includes(k))) { + break; + } + + if (sidePrefix) { + const other = sidePrefix === 'left' ? 'right' : 'left'; + if (nameLower.includes(`${other}_`) || nameLower.startsWith(`${other}`)) { + break; + } + } + + if (current.jointType !== 'fixed') { + jointNames.unshift(name); + } + } + current = current.parent; + } + + return jointNames; +} + +export function guessDefaultEndLink(robot) { + const links = listUrdfLinks(robot); + for (const preset of G1_END_EFFECTOR_PRESETS) { + for (const link of links) { + if (preset.patterns.some((p) => p.test(link))) { + return link; + } + } + } + return links[0] || ''; +} + +export function getUrdfLinkObject(robot, linkName) { + if (robot?.links?.[linkName]) return robot.links[linkName]; + let found = null; + robot?.traverse((obj) => { + if (obj.isURDFLink && obj.name === linkName) found = obj; + }); + return found; +} diff --git a/src/ik/ikPanel.js b/src/ik/ikPanel.js new file mode 100644 index 0000000..683783d --- /dev/null +++ b/src/ik/ikPanel.js @@ -0,0 +1,155 @@ +import { i18n } from '../i18n.js'; +import { + listUrdfLinks, + guessDefaultEndLink, + G1_END_EFFECTOR_PRESETS +} from './ikChainRegistry.js'; + +export class IkPanel { + constructor(editor) { + this.editor = editor; + this.enabled = false; + this.endEffectorLink = ''; + this.goalMode = 'pose'; + this.lockFootZ = false; + this._container = document.getElementById('ik-controls'); + this._header = document.getElementById('ik-control-header'); + this._render(); + } + + onUrdfLoaded() { + const robot = this.editor.robotRight; + if (!robot) return; + const links = listUrdfLinks(robot); + this.endEffectorLink = guessDefaultEndLink(robot); + this._render(links); + if (this._container) { + this._container.style.display = 'block'; + const title = this._header?.querySelector('h3'); + if (title) title.textContent = i18n.t('ikControlOpen'); + } + this._applyToControls(); + } + + getSettingsForProject() { + return { + enabled: this.enabled, + endEffectorLink: this.endEffectorLink, + chainRootJoint: null, + legLockFootZ: this.lockFootZ, + goalMode: this.goalMode + }; + } + + applyProjectSettings(ik) { + if (!ik) return; + if (typeof ik.endEffectorLink === 'string') this.endEffectorLink = ik.endEffectorLink; + if (ik.goalMode === 'position' || ik.goalMode === 'pose') this.goalMode = ik.goalMode; + if (typeof ik.legLockFootZ === 'boolean') this.lockFootZ = ik.legLockFootZ; + if (typeof ik.enabled === 'boolean') { + this.enabled = ik.enabled && !!this.editor.robotRight; + } + this._render(listUrdfLinks(this.editor.robotRight)); + void this._applyToControls(); + } + + async _applyToControls() { + if (this.editor._ikReady) { + await this.editor._ikReady; + } + const ec = this.editor.endEffectorControls; + if (!ec) { + console.warn('IK controls not ready'); + return; + } + ec.setGoalMode(this.goalMode); + ec.setLockFootZ(this.lockFootZ); + ec.setEndLink(this.endEffectorLink); + const ok = ec.setEnabled(this.enabled); + if (this.enabled && !ok) { + this.enabled = false; + const cb = document.getElementById('ik-enable'); + if (cb) cb.checked = false; + } + } + + _render(links = []) { + if (!this._container) return; + + const presetButtons = G1_END_EFFECTOR_PRESETS.map((p) => { + const match = links.find((l) => p.patterns.some((rx) => rx.test(l))); + if (!match) return ''; + return ``; + }).join(''); + + const options = links.map((l) => + `` + ).join(''); + + this._container.innerHTML = ` + + +
${presetButtons}
+
+ + +
+ + `; + + document.getElementById('ik-enable')?.addEventListener('change', async (e) => { + this.enabled = e.target.checked; + if (this.enabled && !this.editor.robotRight) { + alert(i18n.t('ikNeedUrdf')); + this.enabled = false; + e.target.checked = false; + return; + } + await this._applyToControls(); + if (!this.enabled) e.target.checked = false; + }); + + document.getElementById('ik-end-link')?.addEventListener('change', async (e) => { + this.endEffectorLink = e.target.value; + await this._applyToControls(); + }); + + document.querySelectorAll('.ik-preset-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + this.endEffectorLink = btn.getAttribute('data-link'); + const sel = document.getElementById('ik-end-link'); + if (sel) sel.value = this.endEffectorLink; + await this._applyToControls(); + }); + }); + + document.querySelectorAll('input[name="ik-goal-mode"]').forEach((radio) => { + radio.addEventListener('change', async (e) => { + if (e.target.checked) { + this.goalMode = e.target.value; + await this._applyToControls(); + } + }); + }); + + document.getElementById('ik-lock-foot-z')?.addEventListener('change', async (e) => { + this.lockFootZ = e.target.checked; + await this._applyToControls(); + }); + } +} diff --git a/src/ik/ikSolverService.js b/src/ik/ikSolverService.js new file mode 100644 index 0000000..3bde911 --- /dev/null +++ b/src/ik/ikSolverService.js @@ -0,0 +1,145 @@ +import * as THREE from 'three'; +import { Solver } from 'closed-chain-ik/src/core/Solver.js'; +import { Goal } from 'closed-chain-ik/src/core/Goal.js'; +import { DOF } from 'closed-chain-ik/src/core/Joint.js'; +import { + setIKFromUrdf, + setUrdfFromIK, + urdfRobotToIKRoot +} from 'closed-chain-ik/src/three/urdfHelpers.js'; +import { findIKLinkByName } from './ikSolverUtils.js'; + +const _pos = new THREE.Vector3(); +const _quat = new THREE.Quaternion(); + +export class IkSolverService { + constructor() { + this.ikRoot = null; + this.solver = null; + this.goal = null; + this.closureLink = null; + this.chainJointNames = []; + this.endEffectorLinkName = ''; + } + + dispose() { + this.ikRoot = null; + this.solver = null; + this.goal = null; + this.closureLink = null; + } + + rebuild(robot, endEffectorLinkName, chainJointNames) { + this.dispose(); + if (!robot || !endEffectorLinkName) return false; + + try { + this.endEffectorLinkName = endEffectorLinkName; + this.chainJointNames = chainJointNames || []; + + this.ikRoot = urdfRobotToIKRoot(robot, false); + if (!this.ikRoot) return false; + + setIKFromUrdf(this.ikRoot, robot); + + this.closureLink = findIKLinkByName(this.ikRoot, endEffectorLinkName); + if (!this.closureLink) { + console.warn('IK: link not found in IK tree', endEffectorLinkName); + return false; + } + + this.goal = new Goal(); + this.goal.makeClosure(this.closureLink); + + this.solver = new Solver(this.ikRoot); + this.solver.maxIterations = 8; + this.solver.dampingFactor = 0.01; + + return true; + } catch (err) { + console.error('IK rebuild failed:', err); + this.dispose(); + return false; + } + } + + solve(robot, position, quaternion, options = {}) { + if (!this.solver || !this.goal || !this.ikRoot) { + return { success: false, error: 'IK not initialized' }; + } + + const lockedJoints = this.chainJointNames.length > 0 + ? this._snapshotJointsOutsideChain(robot) + : new Map(); + const lockedBase = this._snapshotBase(robot); + + setIKFromUrdf(this.ikRoot, robot); + + this.goal.setPosition(position.x, position.y, position.z); + if (options.positionOnly) { + this.goal.setGoalDoF(DOF.X, DOF.Y, DOF.Z); + } else { + this.goal.setGoalDoF(DOF.X, DOF.Y, DOF.Z, DOF.EX, DOF.EY, DOF.EZ); + this.goal.setQuaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w); + } + + const status = this.solver.solve(); + setUrdfFromIK(robot, this.ikRoot); + + this._restoreJoints(robot, lockedJoints); + this._restoreBase(robot, lockedBase); + + robot.updateMatrixWorld(true); + + const err = this._estimateError(robot, position, quaternion, options.positionOnly); + const success = err.position < 0.025 && (options.positionOnly || err.rotation < 0.2); + + return { success, status, error: err }; + } + + _snapshotJointsOutsideChain(robot) { + const snap = new Map(); + const chainSet = new Set(this.chainJointNames); + for (const name of Object.keys(robot.joints || {})) { + if (!chainSet.has(name)) { + snap.set(name, robot.joints[name].angle); + } + } + return snap; + } + + _snapshotBase(robot) { + return { + position: robot.position.clone(), + quaternion: robot.quaternion.clone() + }; + } + + _restoreJoints(robot, snap) { + for (const [name, angle] of snap) { + if (robot.joints[name]) { + robot.setJointValue(name, angle); + } + } + } + + _restoreBase(robot, base) { + robot.position.copy(base.position); + robot.quaternion.copy(base.quaternion); + } + + _estimateError(robot, targetPos, targetQuat, positionOnly) { + const link = robot.links?.[this.endEffectorLinkName]; + if (!link) return { position: Infinity, rotation: Infinity }; + + link.getWorldPosition(_pos); + link.getWorldQuaternion(_quat); + + const position = _pos.distanceTo(targetPos); + let rotation = 0; + if (!positionOnly) { + rotation = _quat.angleTo(targetQuat); + } + return { position, rotation }; + } +} diff --git a/src/ik/ikSolverUtils.js b/src/ik/ikSolverUtils.js new file mode 100644 index 0000000..024538b --- /dev/null +++ b/src/ik/ikSolverUtils.js @@ -0,0 +1,9 @@ +export function findIKLinkByName(ikRoot, linkName) { + let found = null; + ikRoot?.traverse((c) => { + if (c.isLink && (c.name === linkName || c.urdfName === linkName)) { + found = c; + } + }); + return found; +} diff --git a/src/jointController.js b/src/jointController.js index e41bd03..bf5917f 100644 --- a/src/jointController.js +++ b/src/jointController.js @@ -277,6 +277,10 @@ export class JointController { return; } + if (this.editor.isIkDragging) { + return; + } + // 防止递归调用 if (this.editor.isUpdatingKeyframe) { return; @@ -346,6 +350,26 @@ export class JointController { return [...this.jointValues]; } + /** IK 求解后同步:更新模型与 UI,不触发自动关键帧 */ + syncFromRobotSilent(robot) { + if (!robot) return; + const container = document.getElementById('joint-controls'); + this.joints.forEach((joint, index) => { + const val = robot.joints[joint.name]?.angle ?? this.jointValues[index] ?? 0; + this.jointValues[index] = val; + if (joint.joint) { + joint.joint.setJointValue(val); + } + const control = container?.querySelector(`.joint-control[data-joint-index="${index}"]`); + if (control) { + const slider = control.querySelector('input[type="range"]'); + const numberInput = control.querySelector('input[type="number"]'); + if (slider) slider.value = val; + if (numberInput) numberInput.value = val.toFixed(3); + } + }); + } + resetToBase() { // 重置到当前帧的base值 if (this.editor.trajectoryManager.hasTrajectory()) { diff --git a/src/timelineController.js b/src/timelineController.js index b8d7bec..b57a085 100644 --- a/src/timelineController.js +++ b/src/timelineController.js @@ -235,6 +235,10 @@ export class TimelineController { if (this.editor.curveEditor) { this.editor.curveEditor.draw(); } + + if (this.editor.endEffectorControls) { + this.editor.endEffectorControls.onFrameChanged(); + } } getCurrentFrame() { @@ -432,6 +436,9 @@ export class TimelineController { if (this.isPlaying || this.frameCount === 0) return; this.isPlaying = true; + if (this.editor.endEffectorControls) { + this.editor.endEffectorControls.onPlaybackChanged(true); + } const playBtn = document.getElementById('play-pause'); if (playBtn) playBtn.textContent = i18n.t('pause'); @@ -449,6 +456,9 @@ export class TimelineController { if (!this.isPlaying) return; this.isPlaying = false; + if (this.editor.endEffectorControls) { + this.editor.endEffectorControls.onPlaybackChanged(false); + } const playBtn = document.getElementById('play-pause'); if (playBtn) playBtn.textContent = i18n.t('play'); diff --git a/tests/ik-chain-registry-test.js b/tests/ik-chain-registry-test.js new file mode 100644 index 0000000..258507c --- /dev/null +++ b/tests/ik-chain-registry-test.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { + inferChainJointNames, + guessDefaultEndLink, + listUrdfLinks +} from '../src/ik/ikChainRegistry.js'; + +function mockRobot(links, jointsTree) { + const linksMap = {}; + for (const [name, parentName] of Object.entries(links)) { + const link = { isURDFLink: true, name, parent: null }; + linksMap[name] = link; + } + for (const [name, parentName] of Object.entries(links)) { + if (parentName) { + linksMap[name].parent = linksMap[parentName] || jointsTree[parentName]; + } + } + const joints = {}; + for (const [jname, cfg] of Object.entries(jointsTree)) { + joints[jname] = { + isURDFJoint: true, + name: jname, + jointType: cfg.type || 'revolute', + parent: linksMap[cfg.parentLink], + angle: 0 + }; + if (cfg.childLink && linksMap[cfg.childLink]) { + linksMap[cfg.childLink].parent = joints[jname]; + } + } + return { links: linksMap, joints }; +} + +console.log('ik-chain-registry tests'); + +const robot = mockRobot( + { + pelvis: null, + left_hip: 'pelvis', + left_knee_link: 'left_hip', + left_ankle_link: 'left_knee_link' + }, + { + left_hip_joint: { parentLink: 'pelvis', childLink: 'left_hip', type: 'revolute' }, + left_knee_joint: { parentLink: 'left_hip', childLink: 'left_knee_link', type: 'revolute' }, + left_ankle_joint: { parentLink: 'left_knee_link', childLink: 'left_ankle_link', type: 'revolute' } + } +); + +const linkNames = listUrdfLinks(robot); +assert.ok(linkNames.includes('left_ankle_link')); + +const chain = inferChainJointNames(robot, 'left_ankle_link'); +assert.ok(chain.includes('left_ankle_joint')); +assert.ok(chain.includes('left_knee_joint')); +assert.equal(chain.includes('left_hip_joint'), true); + +const guess = guessDefaultEndLink({ + links: { + right_wrist_yaw_link: {}, + left_ankle_roll_link: {} + } +}); +assert.ok(guess.length > 0); + +console.log('✅ ik-chain-registry-test passed'); From 981ef6c672cc691c3e7bbe30cb5cced4f5bbfd93 Mon Sep 17 00:00:00 2001 From: FanYunFu <2861114322@qq.com> Date: Sat, 30 May 2026 18:34:57 +0800 Subject: [PATCH 04/32] feat(editor): integrate viewport, IK, theme fixes and panel toggles --- index.html | 105 ++++++++++++++++++++++++++++++-- src/i18n.js | 38 ++++++++++++ src/main.js | 170 ++++++++++++++++++++++++++++++---------------------- 3 files changed, 237 insertions(+), 76 deletions(-) diff --git a/index.html b/index.html index 09de7b3..703ff20 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,39 @@ 机器人关键帧编辑器 +