` |
+| 目标 | 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 @@
机器人关键帧编辑器
+