From ee9fce984317b67011eaa1d61e650f716d0f30eb Mon Sep 17 00:00:00 2001 From: hanx Date: Fri, 24 Apr 2026 20:46:57 +0800 Subject: [PATCH 1/5] feat: add heading-based root rotation controls --- src/baseController.js | 143 +++++++++++++--------- src/rotationDecomposition.js | 55 +++++++++ tests/root-rotation-decomposition-test.js | 90 ++++++++++++++ 3 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 src/rotationDecomposition.js create mode 100644 tests/root-rotation-decomposition-test.js diff --git a/src/baseController.js b/src/baseController.js index d9b35e9..9394b2f 100644 --- a/src/baseController.js +++ b/src/baseController.js @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { i18n } from './i18n.js'; +import { composeRootRotation, decomposeRootRotation } from './rotationDecomposition.js'; export class BaseController { constructor(editor) { @@ -8,6 +9,7 @@ export class BaseController { position: { x: 0, y: 0, z: 0 }, quaternion: { x: 0, y: 0, z: 0, w: 1 } }; + this.rootRotationValues = decomposeRootRotation(this.baseValues.quaternion); this.isExpanded = false; this.setupUI(); @@ -197,10 +199,10 @@ export class BaseController { const quatLabel = document.createElement('label'); quatLabel.style.cssText = 'cursor: pointer; display: flex; align-items: center; user-select: none;'; - quatLabel.title = '点击切换四元数欧拉角可视化'; + quatLabel.title = '点击切换 root 旋转曲线可视化'; const quatLabelText = document.createElement('span'); - quatLabelText.textContent = i18n.t('quaternion') + ' (Euler)'; + quatLabelText.textContent = 'Root Rotation'; quatLabel.appendChild(quatLabelText); // 添加关键帧状态圈圈 @@ -251,7 +253,7 @@ export class BaseController { // 创建重置按钮(放在标题行右侧) const quatResetBtn = document.createElement('button'); quatResetBtn.innerHTML = '↺'; - quatResetBtn.title = '重置整个四元数'; + quatResetBtn.title = '重置 root 旋转'; quatResetBtn.style.cssText = 'width: 20px; height: 20px; padding: 0; font-size: 14px; background: var(--bg-input); color: var(--text-secondary); border: 1px solid var(--border-primary); border-radius: 2px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease;'; quatResetBtn.addEventListener('mouseover', () => { quatResetBtn.style.background = 'var(--bg-tertiary)'; @@ -267,12 +269,37 @@ export class BaseController { quatHeaderRow.appendChild(quatResetBtn); quatControl.appendChild(quatHeaderRow); - // Quaternion 控制行 - ['x', 'y', 'z', 'w'].forEach(axis => { + const rotationAxes = [ + { + axis: 'yawZ', + label: 'Yaw Z', + title: '绕世界 Z 轴旋转', + min: -180, + max: 180 + }, + { + axis: 'pitchSide', + label: 'Pitch Side', + title: '绕垂直于世界 Z 和 heading 的轴旋转', + min: -90, + max: 90 + }, + { + axis: 'rollHeading', + label: 'Roll Heading', + title: '绕 root heading 旋转', + min: -180, + max: 180 + } + ]; + + // Root rotation 控制行:底层仍保存 quaternion + rotationAxes.forEach(({ axis, label, title, min, max }) => { const row = document.createElement('div'); row.className = 'joint-control-row'; - row.dataset.quatAxis = axis; + row.dataset.rootRotationAxis = axis; row.style.cssText = 'padding-left: 10px;'; + row.title = title; row.addEventListener('click', (e) => { e.stopPropagation(); @@ -280,24 +307,24 @@ export class BaseController { // 轴标签 const axisLabel = document.createElement('span'); - axisLabel.textContent = axis.toUpperCase() + ':'; - axisLabel.style.cssText = 'width: 20px; font-size: 11px; color: var(--text-primary); transition: color 0.3s ease;'; + axisLabel.textContent = label + ':'; + axisLabel.style.cssText = 'width: 86px; font-size: 11px; color: var(--text-primary); transition: color 0.3s ease;'; row.appendChild(axisLabel); // 滑块 const slider = document.createElement('input'); slider.type = 'range'; - slider.min = -1; - slider.max = 1; - slider.step = 0.01; - slider.value = axis === 'w' ? 1 : 0; + slider.min = min; + slider.max = max; + slider.step = 0.1; + slider.value = this.rootRotationValues[axis]; slider.style.flex = '1'; slider.addEventListener('input', (e) => { const value = parseFloat(e.target.value); - this.baseValues.quaternion[axis] = value; - numberInput.value = value.toFixed(3); - this.normalizeQuaternion(); + this.rootRotationValues[axis] = value; + numberInput.value = value.toFixed(1); + this.updateQuaternionFromRootRotation(); this.applyBaseTransform(); }); @@ -306,19 +333,19 @@ export class BaseController { // 数字输入 const numberInput = document.createElement('input'); numberInput.type = 'number'; - numberInput.min = -1; - numberInput.max = 1; - numberInput.step = 0.01; - numberInput.value = axis === 'w' ? '1.000' : '0.000'; + numberInput.min = min; + numberInput.max = max; + numberInput.step = 0.1; + numberInput.value = this.rootRotationValues[axis].toFixed(1); numberInput.style.cssText = 'width: 70px; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--border-primary); color: var(--text-primary); border-radius: 2px; font-size: 11px; transition: all 0.3s ease;'; numberInput.addEventListener('change', (e) => { let value = parseFloat(e.target.value); - value = Math.max(-1, Math.min(1, value)); - this.baseValues.quaternion[axis] = value; + value = Math.max(min, Math.min(max, value)); + this.rootRotationValues[axis] = value; slider.value = value; - numberInput.value = value.toFixed(3); - this.normalizeQuaternion(); + numberInput.value = value.toFixed(1); + this.updateQuaternionFromRootRotation(); this.applyBaseTransform(); }); @@ -365,21 +392,36 @@ export class BaseController { console.log(`🔄 四元数归一化: ${oldLength.toFixed(4)} → 1.0`); } - // 更新UI - const container = document.getElementById('base-controls'); - ['x', 'y', 'z', 'w'].forEach(axis => { - const row = container.querySelector(`[data-quat-axis="${axis}"]`); - if (row) { - const slider = row.querySelector('input[type="range"]'); - const numberInput = row.querySelector('input[type="number"]'); - const value = q[axis]; - if (slider) slider.value = value; - if (numberInput) numberInput.value = value.toFixed(3); - } - }); + this.updateRootRotationFromQuaternion(); + this.updateRootRotationUI(); } } + updateQuaternionFromRootRotation() { + this.baseValues.quaternion = composeRootRotation(this.rootRotationValues); + } + + updateRootRotationFromQuaternion() { + this.rootRotationValues = decomposeRootRotation(this.baseValues.quaternion); + } + + updateRootRotationUI() { + const container = document.getElementById('base-controls'); + if (!container) return; + + ['yawZ', 'pitchSide', 'rollHeading'].forEach(axis => { + const row = container.querySelector(`[data-root-rotation-axis="${axis}"]`); + if (!row) return; + + const slider = row.querySelector('input[type="range"]'); + const numberInput = row.querySelector('input[type="number"]'); + const value = this.rootRotationValues[axis]; + + if (slider) slider.value = value; + if (numberInput) numberInput.value = value.toFixed(1); + }); + } + applyBaseTransform() { if (!this.editor.robot) return; @@ -450,17 +492,8 @@ export class BaseController { } }); - // 更新 quaternion UI - ['x', 'y', 'z', 'w'].forEach(axis => { - const row = container.querySelector(`[data-quat-axis="${axis}"]`); - if (row) { - const slider = row.querySelector('input[type="range"]'); - const numberInput = row.querySelector('input[type="number"]'); - const value = quaternion[axis]; - if (slider) slider.value = value; - if (numberInput) numberInput.value = value.toFixed(3); - } - }); + this.updateRootRotationFromQuaternion(); + this.updateRootRotationUI(); this.applyBaseTransform(); } @@ -588,7 +621,7 @@ export class BaseController { } resetQuaternion() { - // 重置整个四元数到base值 + // 重置 root 旋转到 base 值 if (this.editor.trajectoryManager.hasTrajectory()) { const currentFrame = this.editor.timelineController.getCurrentFrame(); const baseState = this.editor.trajectoryManager.getBaseState(currentFrame); @@ -596,21 +629,11 @@ export class BaseController { const baseQuat = baseState.base.quaternion; this.baseValues.quaternion = { ...baseQuat }; - // 更新UI - const container = document.getElementById('base-controls'); - ['x', 'y', 'z', 'w'].forEach(axis => { - const row = container.querySelector(`[data-quat-axis="${axis}"]`); - if (row) { - const slider = row.querySelector('input[type="range"]'); - const numberInput = row.querySelector('input[type="number"]'); - const value = baseQuat[axis]; - if (slider) slider.value = value; - if (numberInput) numberInput.value = value.toFixed(3); - } - }); + this.updateRootRotationFromQuaternion(); + this.updateRootRotationUI(); this.applyBaseTransform(); - console.log('✅ Quaternion 已重置到 base 值'); + console.log('✅ Root rotation 已重置到 base 值'); } } } diff --git a/src/rotationDecomposition.js b/src/rotationDecomposition.js new file mode 100644 index 0000000..1dae79a --- /dev/null +++ b/src/rotationDecomposition.js @@ -0,0 +1,55 @@ +import * as THREE from 'three'; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; +const LOCAL_X = new THREE.Vector3(1, 0, 0); + +function toQuaternion(quaternionLike) { + return new THREE.Quaternion( + quaternionLike.x || 0, + quaternionLike.y || 0, + quaternionLike.z || 0, + quaternionLike.w ?? 1 + ).normalize(); +} + +function toPlainQuaternion(quaternion) { + return { + x: quaternion.x, + y: quaternion.y, + z: quaternion.z, + w: quaternion.w + }; +} + +export function composeRootRotation({ yawZ = 0, pitchSide = 0, rollHeading = 0 }) { + const euler = new THREE.Euler( + rollHeading * DEG_TO_RAD, + pitchSide * DEG_TO_RAD, + yawZ * DEG_TO_RAD, + 'ZYX' + ); + + return toPlainQuaternion(new THREE.Quaternion().setFromEuler(euler).normalize()); +} + +export function decomposeRootRotation(quaternionLike) { + const euler = new THREE.Euler().setFromQuaternion(toQuaternion(quaternionLike), 'ZYX'); + + return { + yawZ: euler.z * RAD_TO_DEG, + pitchSide: euler.y * RAD_TO_DEG, + rollHeading: euler.x * RAD_TO_DEG + }; +} + +export function getRootHeadingFromQuaternion(quaternionLike) { + const heading = LOCAL_X.clone().applyQuaternion(toQuaternion(quaternionLike)); + heading.z = 0; + + if (heading.lengthSq() < 1e-12) { + return new THREE.Vector3(1, 0, 0); + } + + return heading.normalize(); +} diff --git a/tests/root-rotation-decomposition-test.js b/tests/root-rotation-decomposition-test.js new file mode 100644 index 0000000..1f13bf4 --- /dev/null +++ b/tests/root-rotation-decomposition-test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import * as THREE from 'three'; +import { + composeRootRotation, + decomposeRootRotation, + getRootHeadingFromQuaternion +} from '../src/rotationDecomposition.js'; + +const EPSILON = 1e-6; + +function assertClose(actual, expected, label, epsilon = EPSILON) { + const diff = Math.abs(actual - expected); + if (diff > epsilon) { + throw new Error(`${label}: expected ${expected}, got ${actual}, diff ${diff}`); + } +} + +function assertVectorClose(actual, expected, label, epsilon = EPSILON) { + assertClose(actual.x, expected.x, `${label}.x`, epsilon); + assertClose(actual.y, expected.y, `${label}.y`, epsilon); + assertClose(actual.z, expected.z, `${label}.z`, epsilon); +} + +function assertEquivalentQuaternion(actual, expected, label, epsilon = EPSILON) { + const direct = Math.abs(actual.x - expected.x) + + Math.abs(actual.y - expected.y) + + Math.abs(actual.z - expected.z) + + Math.abs(actual.w - expected.w); + const negated = Math.abs(actual.x + expected.x) + + Math.abs(actual.y + expected.y) + + Math.abs(actual.z + expected.z) + + Math.abs(actual.w + expected.w); + + if (Math.min(direct, negated) > epsilon) { + throw new Error(`${label}: quaternions differ`); + } +} + +function test(name, fn) { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + console.error(error.message); + process.exitCode = 1; + } +} + +test('yawZ rotates root local +X heading in the world XY plane', () => { + const q = composeRootRotation({ + yawZ: 90, + pitchSide: 0, + rollHeading: 0 + }); + + const heading = getRootHeadingFromQuaternion(q); + + assertVectorClose(heading, new THREE.Vector3(0, 1, 0), 'heading'); +}); + +test('pitchSide rotates around worldZ cross heading after yaw', () => { + const q = composeRootRotation({ + yawZ: 90, + pitchSide: 30, + rollHeading: 0 + }); + + const heading = new THREE.Vector3(1, 0, 0).applyQuaternion(q).normalize(); + const expected = new THREE.Vector3(0, Math.cos(Math.PI / 6), -Math.sin(Math.PI / 6)); + + assertVectorClose(heading, expected, 'pitched heading'); +}); + +test('decomposeRootRotation round-trips editor angles through quaternion storage', () => { + const angles = { + yawZ: 35, + pitchSide: -12, + rollHeading: 18 + }; + const q = composeRootRotation(angles); + const decomposed = decomposeRootRotation(q); + const recomposed = composeRootRotation(decomposed); + + assertClose(decomposed.yawZ, angles.yawZ, 'yawZ'); + assertClose(decomposed.pitchSide, angles.pitchSide, 'pitchSide'); + assertClose(decomposed.rollHeading, angles.rollHeading, 'rollHeading'); + assertEquivalentQuaternion(recomposed, q, 'round trip'); +}); From 884d27ff082f2eeba508f1733e71237c8cb1330d Mon Sep 17 00:00:00 2001 From: hanx Date: Fri, 24 Apr 2026 21:03:07 +0800 Subject: [PATCH 2/5] feat: keep direct quaternion root controls --- src/baseController.js | 100 ++++++++++++++++++++-- src/rotationDecomposition.js | 15 ++++ tests/root-rotation-decomposition-test.js | 10 ++- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/baseController.js b/src/baseController.js index 9394b2f..5776cd1 100644 --- a/src/baseController.js +++ b/src/baseController.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { i18n } from './i18n.js'; -import { composeRootRotation, decomposeRootRotation } from './rotationDecomposition.js'; +import { composeRootRotation, decomposeRootRotation, normalizeQuaternion as normalizeRootQuaternion } from './rotationDecomposition.js'; export class BaseController { constructor(editor) { @@ -202,7 +202,7 @@ export class BaseController { quatLabel.title = '点击切换 root 旋转曲线可视化'; const quatLabelText = document.createElement('span'); - quatLabelText.textContent = 'Root Rotation'; + quatLabelText.textContent = 'Root Rotation / Quaternion'; quatLabel.appendChild(quatLabelText); // 添加关键帧状态圈圈 @@ -293,6 +293,11 @@ export class BaseController { } ]; + const rootRotationLabel = document.createElement('div'); + rootRotationLabel.textContent = 'Heading Decomposition'; + rootRotationLabel.style.cssText = 'padding-left: 10px; margin: 2px 0 4px; font-size: 11px; color: var(--text-tertiary);'; + quatControl.appendChild(rootRotationLabel); + // Root rotation 控制行:底层仍保存 quaternion rotationAxes.forEach(({ axis, label, title, min, max }) => { const row = document.createElement('div'); @@ -325,6 +330,7 @@ export class BaseController { this.rootRotationValues[axis] = value; numberInput.value = value.toFixed(1); this.updateQuaternionFromRootRotation(); + this.updateQuaternionUI(); this.applyBaseTransform(); }); @@ -346,6 +352,7 @@ export class BaseController { slider.value = value; numberInput.value = value.toFixed(1); this.updateQuaternionFromRootRotation(); + this.updateQuaternionUI(); this.applyBaseTransform(); }); @@ -353,6 +360,67 @@ export class BaseController { quatControl.appendChild(row); }); + + const directQuaternionLabel = document.createElement('div'); + directQuaternionLabel.textContent = 'Direct Quaternion'; + directQuaternionLabel.style.cssText = 'padding-left: 10px; margin: 8px 0 4px; font-size: 11px; color: var(--text-tertiary);'; + quatControl.appendChild(directQuaternionLabel); + + // Quaternion 直接控制行:保留底层四元数编辑能力 + ['x', 'y', 'z', 'w'].forEach(axis => { + const row = document.createElement('div'); + row.className = 'joint-control-row'; + row.dataset.quatAxis = axis; + row.style.cssText = 'padding-left: 10px;'; + row.title = `直接编辑 quaternion ${axis.toUpperCase()} 分量`; + + row.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + const axisLabel = document.createElement('span'); + axisLabel.textContent = axis.toUpperCase() + ':'; + axisLabel.style.cssText = 'width: 86px; font-size: 11px; color: var(--text-primary); transition: color 0.3s ease;'; + row.appendChild(axisLabel); + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = -1; + slider.max = 1; + slider.step = 0.01; + slider.value = this.baseValues.quaternion[axis]; + slider.style.flex = '1'; + + slider.addEventListener('input', (e) => { + const value = parseFloat(e.target.value); + this.baseValues.quaternion[axis] = value; + numberInput.value = value.toFixed(3); + this.normalizeQuaternion(); + this.applyBaseTransform(); + }); + + row.appendChild(slider); + + const numberInput = document.createElement('input'); + numberInput.type = 'number'; + numberInput.min = -1; + numberInput.max = 1; + numberInput.step = 0.01; + numberInput.value = this.baseValues.quaternion[axis].toFixed(3); + numberInput.style.cssText = 'width: 70px; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--border-primary); color: var(--text-primary); border-radius: 2px; font-size: 11px; transition: all 0.3s ease;'; + + numberInput.addEventListener('change', (e) => { + let value = parseFloat(e.target.value); + value = Math.max(-1, Math.min(1, value)); + this.baseValues.quaternion[axis] = value; + numberInput.value = value.toFixed(3); + this.normalizeQuaternion(); + this.applyBaseTransform(); + }); + + row.appendChild(numberInput); + quatControl.appendChild(row); + }); container.appendChild(quatControl); } @@ -381,12 +449,12 @@ export class BaseController { q.y = 0; q.z = 0; q.w = 1; + this.updateRootRotationFromQuaternion(); + this.updateRootRotationUI(); + this.updateQuaternionUI(); } else if (length > 0.0001) { const oldLength = length; - q.x /= length; - q.y /= length; - q.z /= length; - q.w /= length; + this.baseValues.quaternion = normalizeRootQuaternion(q); if (Math.abs(oldLength - 1.0) > 0.01) { console.log(`🔄 四元数归一化: ${oldLength.toFixed(4)} → 1.0`); @@ -394,6 +462,7 @@ export class BaseController { this.updateRootRotationFromQuaternion(); this.updateRootRotationUI(); + this.updateQuaternionUI(); } } @@ -401,6 +470,23 @@ export class BaseController { this.baseValues.quaternion = composeRootRotation(this.rootRotationValues); } + updateQuaternionUI() { + const container = document.getElementById('base-controls'); + if (!container) return; + + ['x', 'y', 'z', 'w'].forEach(axis => { + const row = container.querySelector(`[data-quat-axis="${axis}"]`); + if (!row) return; + + const slider = row.querySelector('input[type="range"]'); + const numberInput = row.querySelector('input[type="number"]'); + const value = this.baseValues.quaternion[axis]; + + if (slider) slider.value = value; + if (numberInput) numberInput.value = value.toFixed(3); + }); + } + updateRootRotationFromQuaternion() { this.rootRotationValues = decomposeRootRotation(this.baseValues.quaternion); } @@ -494,6 +580,7 @@ export class BaseController { this.updateRootRotationFromQuaternion(); this.updateRootRotationUI(); + this.updateQuaternionUI(); this.applyBaseTransform(); } @@ -631,6 +718,7 @@ export class BaseController { this.updateRootRotationFromQuaternion(); this.updateRootRotationUI(); + this.updateQuaternionUI(); this.applyBaseTransform(); console.log('✅ Root rotation 已重置到 base 值'); diff --git a/src/rotationDecomposition.js b/src/rotationDecomposition.js index 1dae79a..f983b6c 100644 --- a/src/rotationDecomposition.js +++ b/src/rotationDecomposition.js @@ -22,6 +22,21 @@ function toPlainQuaternion(quaternion) { }; } +export function normalizeQuaternion(quaternionLike) { + const quaternion = new THREE.Quaternion( + quaternionLike.x || 0, + quaternionLike.y || 0, + quaternionLike.z || 0, + quaternionLike.w ?? 1 + ); + + if (quaternion.lengthSq() < 1e-12) { + return { x: 0, y: 0, z: 0, w: 1 }; + } + + return toPlainQuaternion(quaternion.normalize()); +} + export function composeRootRotation({ yawZ = 0, pitchSide = 0, rollHeading = 0 }) { const euler = new THREE.Euler( rollHeading * DEG_TO_RAD, diff --git a/tests/root-rotation-decomposition-test.js b/tests/root-rotation-decomposition-test.js index 1f13bf4..4658b92 100644 --- a/tests/root-rotation-decomposition-test.js +++ b/tests/root-rotation-decomposition-test.js @@ -4,7 +4,8 @@ import * as THREE from 'three'; import { composeRootRotation, decomposeRootRotation, - getRootHeadingFromQuaternion + getRootHeadingFromQuaternion, + normalizeQuaternion } from '../src/rotationDecomposition.js'; const EPSILON = 1e-6; @@ -88,3 +89,10 @@ test('decomposeRootRotation round-trips editor angles through quaternion storage assertClose(decomposed.rollHeading, angles.rollHeading, 'rollHeading'); assertEquivalentQuaternion(recomposed, q, 'round trip'); }); + +test('normalizeQuaternion keeps direct quaternion edits on unit length', () => { + const q = normalizeQuaternion({ x: 0.2, y: -0.1, z: 0.3, w: 1 }); + const length = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w); + + assertClose(length, 1, 'normalized quaternion length'); +}); From ee70d69752964755719ef22819711a37ef7ea00d Mon Sep 17 00:00:00 2001 From: hanx Date: Sun, 26 Apr 2026 16:57:26 +0800 Subject: [PATCH 3/5] feat: visualize palm distance --- index.html | 9 ++ src/baseController.js | 4 + src/i18n.js | 6 ++ src/jointController.js | 4 + src/main.js | 58 +++++++++++ src/palmVisualizer.js | 176 ++++++++++++++++++++++++++++++++++ tests/palm-visualizer.test.js | 47 +++++++++ 7 files changed, 304 insertions(+) create mode 100644 src/palmVisualizer.js create mode 100644 tests/palm-visualizer.test.js diff --git a/index.html b/index.html index c1aa190..4c1f3ac 100644 --- a/index.html +++ b/index.html @@ -442,6 +442,12 @@
原始轨迹 (Base)
编辑后 (Modified)
+
+ 掌距: -- +
+
+ 掌距: -- +
@@ -458,6 +464,9 @@ + diff --git a/src/baseController.js b/src/baseController.js index 5776cd1..44e115e 100644 --- a/src/baseController.js +++ b/src/baseController.js @@ -524,6 +524,10 @@ export class BaseController { // 触发包络线防抖更新 this.editor.scheduleFootprintUpdate(); } + + if (this.editor.updatePalmVisualizers) { + this.editor.updatePalmVisualizers(); + } // 如果当前帧是关键帧,自动更新 this.autoUpdateKeyframe(); diff --git a/src/i18n.js b/src/i18n.js index bb49341..d738f34 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -28,6 +28,9 @@ const translations = { followOff: '🤖 跟随: 关', comOn: '🎯 重心: 开', comOff: '🎯 重心: 关', + palmOn: '🖐 掌心: 开', + palmOff: '🖐 掌心: 关', + palmDistance: '掌距', refreshFootprint: '👣 刷新包络线', autoRefreshOn: '⏱️ 自动刷新: 开', autoRefreshOff: '⏱️ 自动刷新: 关', @@ -219,6 +222,9 @@ const translations = { followOff: '🤖 Follow: Off', comOn: '🎯 COM: On', comOff: '🎯 COM: Off', + palmOn: '🖐 Palm: On', + palmOff: '🖐 Palm: Off', + palmDistance: 'Palm distance', refreshFootprint: '👣 Refresh Footprint', autoRefreshOn: '⏱️ Auto Refresh: On', autoRefreshOff: '⏱️ Auto Refresh: Off', diff --git a/src/jointController.js b/src/jointController.js index da2c219..cbec24c 100644 --- a/src/jointController.js +++ b/src/jointController.js @@ -267,6 +267,10 @@ export class JointController { // 触发包络线防抖更新 this.editor.scheduleFootprintUpdate(); } + + if (this.editor.updatePalmVisualizers) { + this.editor.updatePalmVisualizers(); + } // 如果当前帧是关键帧,自动更新残差 this.autoUpdateKeyframe(); diff --git a/src/main.js b/src/main.js index b0e54e2..6ce8ce1 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import { JointController } from './jointController.js'; import { BaseController } from './baseController.js'; import { TimelineController } from './timelineController.js'; import { COMVisualizer } from './comVisualizer.js'; +import { PalmVisualizer } from './palmVisualizer.js'; import { i18n } from './i18n.js'; import { ThemeManager } from './themeManager.js'; import { CurveEditor } from './curveEditor.js'; @@ -53,6 +54,9 @@ class RobotKeyframeEditor { this.comVisualizerLeft = null; this.comVisualizerRight = null; this.showCOM = true; // 默认显示COM + this.palmVisualizerLeft = null; + this.palmVisualizerRight = null; + this.showPalm = true; // 默认显示掌心距离 // 坐标轴指示器 this.axisGizmo = null; @@ -140,6 +144,8 @@ class RobotKeyframeEditor { // 创建COM可视化器 this.comVisualizerLeft = new COMVisualizer(this.sceneLeft); this.comVisualizerRight = new COMVisualizer(this.sceneRight); + this.palmVisualizerLeft = new PalmVisualizer(this.sceneLeft, document.getElementById('palm-distance-left')); + this.palmVisualizerRight = new PalmVisualizer(this.sceneRight, document.getElementById('palm-distance-right')); // 创建相机 (Z-up 坐标系,正交投影) const viewport = document.getElementById('viewport'); const fullWidth = viewport.clientWidth; @@ -350,6 +356,10 @@ class RobotKeyframeEditor { this.toggleCOM(); }); + document.getElementById('toggle-palm').addEventListener('click', () => { + this.togglePalm(); + }); + // 刷新地面投影包络线 document.getElementById('refresh-footprint').addEventListener('click', () => { this.refreshFootprint(); @@ -471,6 +481,8 @@ class RobotKeyframeEditor { console.log('🎯 更新左侧COM显示'); this.comVisualizerLeft.update(this.robotLeft); } + + this.updatePalmVisualizers(); }); console.log('✅ 右侧机器人模型已添加到场景'); @@ -494,6 +506,8 @@ class RobotKeyframeEditor { this.comVisualizerRight.update(this.robotRight); } } + + this.updatePalmVisualizers(); console.log('✅ 关节控制面板已初始化'); console.log('========================================'); @@ -636,6 +650,8 @@ class RobotKeyframeEditor { this.comVisualizerRight.update(this.robotRight); } } + + this.updatePalmVisualizers(); // 兼容旧代码 this.robot = this.robotRight; } @@ -979,6 +995,48 @@ class RobotKeyframeEditor { } } + togglePalm() { + this.showPalm = !this.showPalm; + const button = document.getElementById('toggle-palm'); + + if (this.showPalm) { + button.textContent = i18n.t('palmOn'); + button.style.background = 'rgba(0, 212, 255, 0.25)'; + button.style.borderColor = 'rgba(0, 212, 255, 0.6)'; + this.updatePalmVisualizers(); + console.log('🖐 显示掌心距离'); + } else { + button.textContent = i18n.t('palmOff'); + button.style.background = 'var(--overlay-bg)'; + button.style.borderColor = 'var(--border-primary)'; + if (this.palmVisualizerLeft) { + this.palmVisualizerLeft.hide(); + } + if (this.palmVisualizerRight) { + this.palmVisualizerRight.hide(); + } + console.log('🖐 隐藏掌心距离'); + } + } + + updatePalmVisualizers() { + if (!this.showPalm) { + return; + } + + if (this.palmVisualizerLeft && this.robotLeft) { + this.palmVisualizerLeft.update(this.robotLeft); + } else if (this.palmVisualizerLeft) { + this.palmVisualizerLeft.hide(); + } + + if (this.palmVisualizerRight && this.robotRight) { + this.palmVisualizerRight.update(this.robotRight); + } else if (this.palmVisualizerRight) { + this.palmVisualizerRight.hide(); + } + } + toggleAutoRefreshFootprint() { this.autoRefreshFootprint = !this.autoRefreshFootprint; const button = document.getElementById('toggle-auto-refresh'); diff --git a/src/palmVisualizer.js b/src/palmVisualizer.js new file mode 100644 index 0000000..61549d6 --- /dev/null +++ b/src/palmVisualizer.js @@ -0,0 +1,176 @@ +import * as THREE from 'three'; + +export const DEFAULT_LEFT_PALM_OFFSET = new THREE.Vector3(0.1115, 0.0030, 0.0); +export const DEFAULT_RIGHT_PALM_OFFSET = new THREE.Vector3(0.1115, -0.0030, 0.0); +const LEFT_WRIST_LINK_NAME = 'left_wrist_yaw_link'; +const RIGHT_WRIST_LINK_NAME = 'right_wrist_yaw_link'; + +function findObjectByName(root, name) { + if (!root) return null; + + const direct = root.getObjectByName(name); + if (direct) return direct; + + let found = null; + root.traverse((child) => { + if (found) return; + if (child.name === name || child.urdfName === name) { + found = child; + } + }); + return found; +} + +function computePalmPosition(link, offset) { + const worldPosition = new THREE.Vector3(); + const worldQuaternion = new THREE.Quaternion(); + + link.updateWorldMatrix(true, false); + link.getWorldPosition(worldPosition); + link.getWorldQuaternion(worldQuaternion); + + return worldPosition.add(offset.clone().applyQuaternion(worldQuaternion)); +} + +export function computePalmInfoFromLinks( + leftWristLink, + rightWristLink, + leftOffset = DEFAULT_LEFT_PALM_OFFSET, + rightOffset = DEFAULT_RIGHT_PALM_OFFSET +) { + if (!leftWristLink || !rightWristLink) { + return null; + } + + const leftPalm = computePalmPosition(leftWristLink, leftOffset); + const rightPalm = computePalmPosition(rightWristLink, rightOffset); + const palmCenter = leftPalm.clone().add(rightPalm).multiplyScalar(0.5); + const distance = leftPalm.distanceTo(rightPalm); + + return { leftPalm, rightPalm, palmCenter, distance }; +} + +export function computePalmInfo(robot) { + const leftWristLink = findObjectByName(robot, LEFT_WRIST_LINK_NAME); + const rightWristLink = findObjectByName(robot, RIGHT_WRIST_LINK_NAME); + + return computePalmInfoFromLinks(leftWristLink, rightWristLink); +} + +export class PalmVisualizer { + constructor(scene, labelElement = null) { + this.scene = scene; + this.labelElement = labelElement; + this.palmSphere = null; + this.leftPalmMarker = null; + this.rightPalmMarker = null; + this.connectionLine = null; + this.lastInfo = null; + this.createVisualization(); + } + + createVisualization() { + const sphereGeometry = new THREE.SphereGeometry(0.1, 32, 16); + const sphereMaterial = new THREE.MeshBasicMaterial({ + color: 0x00d4ff, + transparent: true, + opacity: 0.22, + depthWrite: false + }); + this.palmSphere = new THREE.Mesh(sphereGeometry, sphereMaterial); + this.scene.add(this.palmSphere); + + const markerGeometry = new THREE.SphereGeometry(0.025, 16, 12); + const leftMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0x00ffff }); + const rightMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffd166 }); + this.leftPalmMarker = new THREE.Mesh(markerGeometry, leftMarkerMaterial); + this.rightPalmMarker = new THREE.Mesh(markerGeometry.clone(), rightMarkerMaterial); + this.scene.add(this.leftPalmMarker); + this.scene.add(this.rightPalmMarker); + + const lineGeometry = new THREE.BufferGeometry(); + const lineMaterial = new THREE.LineBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.75 + }); + this.connectionLine = new THREE.Line(lineGeometry, lineMaterial); + this.scene.add(this.connectionLine); + + this.hide(); + } + + update(robot) { + const info = computePalmInfo(robot); + this.lastInfo = info; + + if (!info) { + this.hide(); + return null; + } + + this.palmSphere.position.copy(info.palmCenter); + this.leftPalmMarker.position.copy(info.leftPalm); + this.rightPalmMarker.position.copy(info.rightPalm); + + const positions = new Float32Array([ + info.leftPalm.x, info.leftPalm.y, info.leftPalm.z, + info.rightPalm.x, info.rightPalm.y, info.rightPalm.z + ]); + this.connectionLine.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + this.connectionLine.geometry.computeBoundingSphere(); + + this.show(); + this.updateLabel(info.distance); + return info; + } + + updateLabel(distance) { + if (!this.labelElement) return; + this.labelElement.textContent = Number.isFinite(distance) ? `${distance.toFixed(3)} m` : '--'; + } + + show() { + this.palmSphere.visible = true; + this.leftPalmMarker.visible = true; + this.rightPalmMarker.visible = true; + this.connectionLine.visible = true; + if (this.labelElement) { + this.labelElement.textContent = this.lastInfo ? `${this.lastInfo.distance.toFixed(3)} m` : '--'; + } + } + + hide() { + this.palmSphere.visible = false; + this.leftPalmMarker.visible = false; + this.rightPalmMarker.visible = false; + this.connectionLine.visible = false; + if (this.labelElement) { + this.labelElement.textContent = '--'; + } + } + + setVisible(visible) { + if (visible) { + this.show(); + } else { + this.hide(); + } + } + + dispose() { + this.scene.remove(this.palmSphere); + this.scene.remove(this.leftPalmMarker); + this.scene.remove(this.rightPalmMarker); + this.scene.remove(this.connectionLine); + + this.palmSphere.geometry.dispose(); + this.palmSphere.material.dispose(); + this.leftPalmMarker.geometry.dispose(); + this.leftPalmMarker.material.dispose(); + this.rightPalmMarker.geometry.dispose(); + this.rightPalmMarker.material.dispose(); + this.connectionLine.geometry.dispose(); + this.connectionLine.material.dispose(); + } +} diff --git a/tests/palm-visualizer.test.js b/tests/palm-visualizer.test.js new file mode 100644 index 0000000..8969379 --- /dev/null +++ b/tests/palm-visualizer.test.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import * as THREE from 'three'; +import { + computePalmInfoFromLinks, + DEFAULT_LEFT_PALM_OFFSET, + DEFAULT_RIGHT_PALM_OFFSET +} from '../src/palmVisualizer.js'; + +function assertVectorClose(actual, expected, epsilon = 1e-6) { + assert.ok(actual.distanceTo(expected) < epsilon, `expected ${actual.toArray()} to equal ${expected.toArray()}`); +} + +const root = new THREE.Object3D(); +const leftWrist = new THREE.Object3D(); +const rightWrist = new THREE.Object3D(); + +leftWrist.position.set(1, 2, 3); +rightWrist.position.set(1, 2.5, 3); +root.add(leftWrist); +root.add(rightWrist); +root.updateMatrixWorld(true); + +const info = computePalmInfoFromLinks(leftWrist, rightWrist); + +const expectedLeftPalm = leftWrist.position.clone().add(DEFAULT_LEFT_PALM_OFFSET); +const expectedRightPalm = rightWrist.position.clone().add(DEFAULT_RIGHT_PALM_OFFSET); +const expectedCenter = expectedLeftPalm.clone().add(expectedRightPalm).multiplyScalar(0.5); + +assertVectorClose(info.leftPalm, expectedLeftPalm); +assertVectorClose(info.rightPalm, expectedRightPalm); +assertVectorClose(info.palmCenter, expectedCenter); +assert.equal(info.distance, expectedLeftPalm.distanceTo(expectedRightPalm)); + +leftWrist.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); +rightWrist.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); +root.updateMatrixWorld(true); + +const rotatedInfo = computePalmInfoFromLinks(leftWrist, rightWrist); +const expectedRotatedLeft = leftWrist.position.clone().add(DEFAULT_LEFT_PALM_OFFSET.clone().applyQuaternion(leftWrist.quaternion)); +const expectedRotatedRight = rightWrist.position.clone().add(DEFAULT_RIGHT_PALM_OFFSET.clone().applyQuaternion(rightWrist.quaternion)); + +assertVectorClose(rotatedInfo.leftPalm, expectedRotatedLeft); +assertVectorClose(rotatedInfo.rightPalm, expectedRotatedRight); + +console.log('Palm visualizer tests passed'); From eba62c1f303f7785b8a15aafbd35c4a57840fb01 Mon Sep 17 00:00:00 2001 From: hanx Date: Sun, 26 Apr 2026 17:26:40 +0800 Subject: [PATCH 4/5] feat: group joint controls by side --- src/i18n.js | 6 + src/jointController.js | 332 +++++++++++++++++++++-------------- src/jointGrouping.js | 24 +++ tests/joint-grouping.test.js | 29 +++ 4 files changed, 255 insertions(+), 136 deletions(-) create mode 100644 src/jointGrouping.js create mode 100644 tests/joint-grouping.test.js diff --git a/src/i18n.js b/src/i18n.js index d738f34..0d2869a 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -45,6 +45,9 @@ const translations = { // 基体控制 baseControl: '▶ 基体控制 (Base)', jointControl: '关节控制', + jointGroupCenter: '中', + jointGroupLeft: '左', + jointGroupRight: '右', reset: '重置', alignLowest: '平移对齐', alignLowestTitle: '自动调整XYZ,让高度最低的link与原始轨迹对齐', @@ -239,6 +242,9 @@ const translations = { // Base control baseControl: '▶ Base Control (Base)', jointControl: 'Joint Control', + jointGroupCenter: 'Center', + jointGroupLeft: 'Left', + jointGroupRight: 'Right', reset: 'Reset', alignLowest: 'Align Lowest', alignLowestTitle: 'Auto-adjust XYZ to align the lowest link with the base trajectory', diff --git a/src/jointController.js b/src/jointController.js index cbec24c..8142c0c 100644 --- a/src/jointController.js +++ b/src/jointController.js @@ -1,4 +1,5 @@ import { i18n } from './i18n.js'; +import { JOINT_GROUPS, groupJointsBySide } from './jointGrouping.js'; export class JointController { constructor(joints, editor) { @@ -27,140 +28,198 @@ export class JointController { console.log('✅ 找到 joint-controls 容器'); container.innerHTML = ''; + const groupedJoints = groupJointsBySide(this.joints); + console.log(`🔄 创建 ${this.joints.length} 个关节控制器...`); - this.joints.forEach((joint, index) => { - console.log(` - 创建关节 ${index}: ${joint.name}`); - const control = document.createElement('div'); - control.className = 'joint-control'; - control.dataset.jointIndex = index; - control.style.transition = 'background-color 0.2s'; + JOINT_GROUPS.forEach((group) => { + const section = this.createGroupSection(group, groupedJoints[group.key].length); + const sectionBody = section.querySelector('.joint-group-body'); - const label = document.createElement('label'); - label.style.cssText = 'cursor: pointer; display: flex; align-items: center; user-select: none;'; - label.title = '点击切换曲线显示'; - - const labelText = document.createElement('span'); - labelText.textContent = joint.name || `Joint ${index + 1}`; - label.appendChild(labelText); - - // 添加关键帧状态圈圈 - const keyframeIndicator = document.createElement('span'); - keyframeIndicator.id = `keyframe-indicator-${index}`; - keyframeIndicator.style.cssText = ` - display: none; - width: 10px; - height: 10px; - min-width: 10px; - min-height: 10px; - border-radius: 50%; - margin-left: 8px; - border: 2px solid #f4b942; - box-sizing: border-box; - flex-shrink: 0; - `; - label.appendChild(keyframeIndicator); - - // 点击label切换曲线可见性 - label.addEventListener('click', (e) => { - if (this.editor.curveEditor) { - const curveKey = `joint_${index}`; - const visible = this.editor.curveEditor.toggleCurveVisibility(curveKey, e.shiftKey); - const color = this.editor.curveEditor.getCurveColor(curveKey); - if (color) { - // 更新背景色 - if (visible) { - control.style.backgroundColor = color + '20'; // 20% 透明度 - } else { - control.style.backgroundColor = ''; + groupedJoints[group.key].forEach(({ joint, index }) => { + console.log(` - 创建关节 ${index}: ${joint.name}`); + const control = document.createElement('div'); + control.className = 'joint-control'; + control.dataset.jointIndex = index; + control.style.transition = 'background-color 0.2s'; + + const label = document.createElement('label'); + label.style.cssText = 'cursor: pointer; display: flex; align-items: center; user-select: none;'; + label.title = '点击切换曲线显示'; + + const labelText = document.createElement('span'); + labelText.textContent = joint.name || `Joint ${index + 1}`; + label.appendChild(labelText); + + // 添加关键帧状态圈圈 + const keyframeIndicator = document.createElement('span'); + keyframeIndicator.id = `keyframe-indicator-${index}`; + keyframeIndicator.style.cssText = ` + display: none; + width: 10px; + height: 10px; + min-width: 10px; + min-height: 10px; + border-radius: 50%; + margin-left: 8px; + border: 2px solid #f4b942; + box-sizing: border-box; + flex-shrink: 0; + `; + label.appendChild(keyframeIndicator); + + // 点击label切换曲线可见性 + label.addEventListener('click', (e) => { + if (this.editor.curveEditor) { + const curveKey = `joint_${index}`; + const visible = this.editor.curveEditor.toggleCurveVisibility(curveKey, e.shiftKey); + const color = this.editor.curveEditor.getCurveColor(curveKey); + if (color) { + // 更新背景色 + if (visible) { + control.style.backgroundColor = color + '20'; // 20% 透明度 + } else { + control.style.backgroundColor = ''; + } } } - } - }); - - // 初始化显示状态 - setTimeout(() => { - if (this.editor.curveEditor) { - const curveKey = `joint_${index}`; - const visible = this.editor.curveEditor.isCurveVisible(curveKey); - const color = this.editor.curveEditor.getCurveColor(curveKey); - if (color && visible) { - control.style.backgroundColor = color + '20'; + }); + + // 初始化显示状态 + setTimeout(() => { + if (this.editor.curveEditor) { + const curveKey = `joint_${index}`; + const visible = this.editor.curveEditor.isCurveVisible(curveKey); + const color = this.editor.curveEditor.getCurveColor(curveKey); + if (color && visible) { + control.style.backgroundColor = color + '20'; + } } - } - }, 100); - - control.appendChild(label); + }, 100); + + control.appendChild(label); - // 创建水平布局容器 - const row = document.createElement('div'); - row.className = 'joint-control-row'; - - // 阻止row内的点击事件冒泡到control - row.addEventListener('click', (e) => { - e.stopPropagation(); - }); + // 创建水平布局容器 + const row = document.createElement('div'); + row.className = 'joint-control-row'; + + // 阻止row内的点击事件冒泡到control + row.addEventListener('click', (e) => { + e.stopPropagation(); + }); - // 滑块 - const slider = document.createElement('input'); - slider.type = 'range'; - slider.min = joint.limits.lower; - slider.max = joint.limits.upper; - slider.step = 0.01; - slider.value = 0; - slider.dataset.jointIndex = index; - - slider.addEventListener('input', (e) => { - const value = parseFloat(e.target.value); - this.jointValues[index] = value; - numberInput.value = value.toFixed(3); - this.applyJointValue(index, value); - }); - - row.appendChild(slider); + // 滑块 + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = joint.limits.lower; + slider.max = joint.limits.upper; + slider.step = 0.01; + slider.value = 0; + slider.dataset.jointIndex = index; + + slider.addEventListener('input', (e) => { + const value = parseFloat(e.target.value); + this.jointValues[index] = value; + numberInput.value = value.toFixed(3); + this.applyJointValue(index, value); + }); + + row.appendChild(slider); - // 数字输入(放在滑块右边) - const numberInput = document.createElement('input'); - numberInput.type = 'number'; - numberInput.min = joint.limits.lower; - numberInput.max = joint.limits.upper; - numberInput.step = 0.01; - numberInput.value = '0.000'; - numberInput.dataset.jointIndex = index; - - numberInput.addEventListener('change', (e) => { - let value = parseFloat(e.target.value); - value = Math.max(joint.limits.lower, Math.min(joint.limits.upper, value)); - this.jointValues[index] = value; - slider.value = value; - numberInput.value = value.toFixed(3); - this.applyJointValue(index, value); - }); - - row.appendChild(numberInput); + // 数字输入(放在滑块右边) + const numberInput = document.createElement('input'); + numberInput.type = 'number'; + numberInput.min = joint.limits.lower; + numberInput.max = joint.limits.upper; + numberInput.step = 0.01; + numberInput.value = '0.000'; + numberInput.dataset.jointIndex = index; + + numberInput.addEventListener('change', (e) => { + let value = parseFloat(e.target.value); + value = Math.max(joint.limits.lower, Math.min(joint.limits.upper, value)); + this.jointValues[index] = value; + slider.value = value; + numberInput.value = value.toFixed(3); + this.applyJointValue(index, value); + }); + + row.appendChild(numberInput); - // 添加重置按钮 - const resetBtn = document.createElement('button'); - resetBtn.innerHTML = '↺'; - resetBtn.title = joint.name ? `${i18n.t('resetJointTitle').replace('{name}', joint.name)}` : `Reset Joint ${index + 1}`; - resetBtn.style.cssText = 'width: 20px; height: 20px; padding: 0; font-size: 14px; background: var(--bg-input); color: var(--text-secondary); border: 1px solid var(--border-primary); border-radius: 2px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease;'; - resetBtn.addEventListener('mouseover', () => { - resetBtn.style.background = 'var(--bg-tertiary)'; - }); - resetBtn.addEventListener('mouseout', () => { - resetBtn.style.background = 'var(--bg-input)'; - }); - resetBtn.addEventListener('click', () => { - this.resetJoint(index); + // 添加重置按钮 + const resetBtn = document.createElement('button'); + resetBtn.innerHTML = '↺'; + resetBtn.title = joint.name ? `${i18n.t('resetJointTitle').replace('{name}', joint.name)}` : `Reset Joint ${index + 1}`; + resetBtn.style.cssText = 'width: 20px; height: 20px; padding: 0; font-size: 14px; background: var(--bg-input); color: var(--text-secondary); border: 1px solid var(--border-primary); border-radius: 2px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease;'; + resetBtn.addEventListener('mouseover', () => { + resetBtn.style.background = 'var(--bg-tertiary)'; + }); + resetBtn.addEventListener('mouseout', () => { + resetBtn.style.background = 'var(--bg-input)'; + }); + resetBtn.addEventListener('click', () => { + this.resetJoint(index); + }); + + row.appendChild(resetBtn); + control.appendChild(row); + sectionBody.appendChild(control); }); - - row.appendChild(resetBtn); - control.appendChild(row); - container.appendChild(control); + + container.appendChild(section); }); console.log(`✅ ${this.joints.length} 个关节控制器创建完成`); } + createGroupSection(group, count) { + const section = document.createElement('div'); + section.className = 'joint-group-section'; + section.dataset.jointGroup = group.key; + + const header = document.createElement('button'); + header.type = 'button'; + header.className = 'joint-group-header'; + header.style.cssText = ` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 0 8px 0; + padding: 6px 8px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-primary); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: background-color 0.2s ease, border-color 0.2s ease; + `; + + const label = document.createElement('span'); + label.textContent = `${i18n.t(group.labelKey) || group.fallbackLabel} (${count})`; + header.appendChild(label); + + const icon = document.createElement('span'); + icon.textContent = '▼'; + icon.style.fontSize = '10px'; + header.appendChild(icon); + + const body = document.createElement('div'); + body.className = 'joint-group-body'; + body.style.marginBottom = '10px'; + + header.addEventListener('click', () => { + const collapsed = body.style.display === 'none'; + body.style.display = collapsed ? '' : 'none'; + icon.textContent = collapsed ? '▼' : '▶'; + }); + + section.appendChild(header); + section.appendChild(body); + return section; + } + updateKeyframeIndicators() { const t0 = performance.now(); @@ -239,7 +298,7 @@ export class JointController { if (!this.editor.curveEditor) return; this.joints.forEach((joint, index) => { - const control = document.querySelector(`.joint-control[data-joint-index="${index}"]`); + const control = this.getJointControl(index); if (!control) return; const curveKey = `joint_${index}`; @@ -321,13 +380,10 @@ export class JointController { updateJoints(jointValues) { this.jointValues = [...jointValues]; - const container = document.getElementById('joint-controls'); - const controls = container.querySelectorAll('.joint-control'); - - controls.forEach((control, index) => { - if (index >= jointValues.length) return; + jointValues.forEach((value, index) => { + const control = this.getJointControl(index); + if (!control) return; - const value = jointValues[index]; const slider = control.querySelector('input[type="range"]'); const numberInput = control.querySelector('input[type="number"]'); @@ -371,11 +427,10 @@ export class JointController { this.jointValues[index] = baseValue; // 更新UI - const container = document.getElementById('joint-controls'); - const controls = container.querySelectorAll('.joint-control'); - if (controls[index]) { - const slider = controls[index].querySelector('input[type="range"]'); - const numberInput = controls[index].querySelector('input[type="number"]'); + const control = this.getJointControl(index); + if (control) { + const slider = control.querySelector('input[type="range"]'); + const numberInput = control.querySelector('input[type="number"]'); if (slider) slider.value = baseValue; if (numberInput) numberInput.value = baseValue.toFixed(3); } @@ -386,11 +441,10 @@ export class JointController { } else { // 如果没有轨迹,重置到 0 this.jointValues[index] = 0; - const container = document.getElementById('joint-controls'); - const controls = container.querySelectorAll('.joint-control'); - if (controls[index]) { - const slider = controls[index].querySelector('input[type="range"]'); - const numberInput = controls[index].querySelector('input[type="number"]'); + const control = this.getJointControl(index); + if (control) { + const slider = control.querySelector('input[type="range"]'); + const numberInput = control.querySelector('input[type="number"]'); if (slider) slider.value = 0; if (numberInput) numberInput.value = '0.000'; } @@ -398,4 +452,10 @@ export class JointController { console.log(`✅ 关节 ${index} 已重置到 0`); } } + + getJointControl(index) { + const container = document.getElementById('joint-controls'); + if (!container) return null; + return container.querySelector(`.joint-control[data-joint-index="${index}"]`); + } } diff --git a/src/jointGrouping.js b/src/jointGrouping.js new file mode 100644 index 0000000..1fecff5 --- /dev/null +++ b/src/jointGrouping.js @@ -0,0 +1,24 @@ +export const JOINT_GROUPS = [ + { key: 'center', labelKey: 'jointGroupCenter', fallbackLabel: '中' }, + { key: 'left', labelKey: 'jointGroupLeft', fallbackLabel: '左' }, + { key: 'right', labelKey: 'jointGroupRight', fallbackLabel: '右' } +]; + +export function getJointGroupKey(jointName = '') { + const normalized = String(jointName).toLowerCase(); + if (/(^|[_-])left([_-]|$)/.test(normalized) || /(^|[_-])l([_-]|$)/.test(normalized)) { + return 'left'; + } + if (/(^|[_-])right([_-]|$)/.test(normalized) || /(^|[_-])r([_-]|$)/.test(normalized)) { + return 'right'; + } + return 'center'; +} + +export function groupJointsBySide(joints) { + const grouped = Object.fromEntries(JOINT_GROUPS.map((group) => [group.key, []])); + joints.forEach((joint, index) => { + grouped[getJointGroupKey(joint.name)].push({ joint, index }); + }); + return grouped; +} diff --git a/tests/joint-grouping.test.js b/tests/joint-grouping.test.js new file mode 100644 index 0000000..07a9f4f --- /dev/null +++ b/tests/joint-grouping.test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { + getJointGroupKey, + groupJointsBySide, + JOINT_GROUPS +} from '../src/jointGrouping.js'; + +assert.equal(getJointGroupKey('left_shoulder_pitch_joint'), 'left'); +assert.equal(getJointGroupKey('L_hip_yaw'), 'left'); +assert.equal(getJointGroupKey('right_elbow_joint'), 'right'); +assert.equal(getJointGroupKey('r_knee_joint'), 'right'); +assert.equal(getJointGroupKey('waist_yaw_joint'), 'center'); +assert.equal(getJointGroupKey('head_pitch_joint'), 'center'); + +const grouped = groupJointsBySide([ + { name: 'left_shoulder_pitch_joint' }, + { name: 'waist_yaw_joint' }, + { name: 'right_elbow_joint' }, + { name: 'head_pitch_joint' } +]); + +assert.deepEqual(Object.keys(grouped), JOINT_GROUPS.map((group) => group.key)); +assert.deepEqual(grouped.center.map((item) => item.index), [1, 3]); +assert.deepEqual(grouped.left.map((item) => item.index), [0]); +assert.deepEqual(grouped.right.map((item) => item.index), [2]); + +console.log('Joint grouping tests passed'); From 8144fe31e5dd61c4f02bbc061037ff5024696c24 Mon Sep 17 00:00:00 2001 From: hanx Date: Sun, 26 Apr 2026 19:31:54 +0800 Subject: [PATCH 5/5] fix: translate joint group labels --- src/jointController.js | 6 +++++- src/jointGrouping.js | 6 +++--- tests/joint-grouping.test.js | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/jointController.js b/src/jointController.js index 8142c0c..e4a9100 100644 --- a/src/jointController.js +++ b/src/jointController.js @@ -197,7 +197,11 @@ export class JointController { `; const label = document.createElement('span'); - label.textContent = `${i18n.t(group.labelKey) || group.fallbackLabel} (${count})`; + const labelText = document.createElement('span'); + labelText.dataset.i18n = group.labelKey; + labelText.textContent = i18n.t(group.labelKey) || group.fallbackLabel; + label.appendChild(labelText); + label.appendChild(document.createTextNode(` (${count})`)); header.appendChild(label); const icon = document.createElement('span'); diff --git a/src/jointGrouping.js b/src/jointGrouping.js index 1fecff5..9b2a359 100644 --- a/src/jointGrouping.js +++ b/src/jointGrouping.js @@ -1,7 +1,7 @@ export const JOINT_GROUPS = [ - { key: 'center', labelKey: 'jointGroupCenter', fallbackLabel: '中' }, - { key: 'left', labelKey: 'jointGroupLeft', fallbackLabel: '左' }, - { key: 'right', labelKey: 'jointGroupRight', fallbackLabel: '右' } + { key: 'center', labelKey: 'jointGroupCenter', fallbackLabel: 'Center' }, + { key: 'left', labelKey: 'jointGroupLeft', fallbackLabel: 'Left' }, + { key: 'right', labelKey: 'jointGroupRight', fallbackLabel: 'Right' } ]; export function getJointGroupKey(jointName = '') { diff --git a/tests/joint-grouping.test.js b/tests/joint-grouping.test.js index 07a9f4f..342f106 100644 --- a/tests/joint-grouping.test.js +++ b/tests/joint-grouping.test.js @@ -25,5 +25,6 @@ assert.deepEqual(Object.keys(grouped), JOINT_GROUPS.map((group) => group.key)); assert.deepEqual(grouped.center.map((item) => item.index), [1, 3]); assert.deepEqual(grouped.left.map((item) => item.index), [0]); assert.deepEqual(grouped.right.map((item) => item.index), [2]); +assert.deepEqual(JOINT_GROUPS.map((group) => group.fallbackLabel), ['Center', 'Left', 'Right']); console.log('Joint grouping tests passed');