1001 lines
36 KiB
JavaScript
1001 lines
36 KiB
JavaScript
// Tab "Pose 3D" — viewport Three.js, gizmo translate/rotate, drag dei polsi.
|
|
// Vanilla JS, ES modules.
|
|
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
import URDFLoader from 'three/addons/loaders/URDFLoader.js';
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const POLL_MS = 1000;
|
|
|
|
// ----- helpers geometria -----
|
|
function makeUCS(size = 0.1) {
|
|
// 3 linee X(rosso) Y(verde) Z(blu) dalla origine
|
|
const g = new THREE.Group();
|
|
const mat = (color) => new THREE.LineBasicMaterial({ color, linewidth: 2 });
|
|
const make = (dir, color) => {
|
|
const geom = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(0, 0, 0),
|
|
new THREE.Vector3(dir.x * size, dir.y * size, dir.z * size),
|
|
]);
|
|
return new THREE.Line(geom, mat(color));
|
|
};
|
|
g.add(make(new THREE.Vector3(1, 0, 0), 0xe74c3c));
|
|
g.add(make(new THREE.Vector3(0, 1, 0), 0x3ecf8e));
|
|
g.add(make(new THREE.Vector3(0, 0, 1), 0x4da3ff));
|
|
return g;
|
|
}
|
|
|
|
function makeLabelSprite(text, color = '#ffffff') {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = color;
|
|
ctx.font = 'bold 36px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
const material = new THREE.SpriteMaterial({ map: texture, depthTest: false });
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.scale.set(0.10, 0.05, 1); // ~10cm wide x 5cm tall in scene units
|
|
return sprite;
|
|
}
|
|
|
|
function makeWristMarker(color) {
|
|
const group = new THREE.Group();
|
|
const sphere = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.04, 16, 12),
|
|
new THREE.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.5 }),
|
|
);
|
|
group.add(sphere);
|
|
group.add(makeUCS(0.07));
|
|
group.userData.sphere = sphere;
|
|
group.userData.sphere.userData.kind = 'wrist';
|
|
return group;
|
|
}
|
|
|
|
// ----- state -----
|
|
const state = {
|
|
three: null, // {scene, camera, renderer, orbit, transform}
|
|
arms: { right: null, left: null }, // wrist groups (real)
|
|
ghosts: { right: null, left: null }, // wrist groups (target preview)
|
|
links: { right: null, left: null }, // line shoulder→wrist
|
|
current: { right: null, left: null }, // {position, quaternion}
|
|
target: { right: null, left: null }, // null = nessuna modifica locale
|
|
active_side: 'right',
|
|
mode: 'editor',
|
|
gizmo_mode: 'translate',
|
|
precise: true,
|
|
saved_poses: {},
|
|
live_max_step_m: 0.15,
|
|
robot: null, // URDF robot loaded via urdf-loader (null se non caricato)
|
|
arm_links_m: null, // {upper_m, fore_m} computati da URDF al load
|
|
};
|
|
|
|
function checkWorkspace(side, position_m) {
|
|
// Controllo client-side: il target e' nella sfera di raggio ARM_MAX_REACH_M
|
|
// centrata sulla spalla? Ritorna {feasible, distance_m, max_m}.
|
|
const sh = SHOULDER[side];
|
|
if (!sh) return { feasible: true, distance_m: 0, max_m: ARM_MAX_REACH_M };
|
|
const dx = position_m[0] - sh.x;
|
|
const dy = position_m[1] - sh.y;
|
|
const dz = position_m[2] - sh.z;
|
|
const distance = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
|
return {
|
|
feasible: distance <= ARM_MAX_REACH_M,
|
|
distance_m: distance,
|
|
max_m: ARM_MAX_REACH_M,
|
|
};
|
|
}
|
|
|
|
// Posizioni torso e spalle hardcoded (riferimento visivo, non IK)
|
|
const TORSO_POS = new THREE.Vector3(0, 0, 0.40);
|
|
// Posizioni di riferimento del G1 in base frame (feet on floor convention,
|
|
// URDF alzato di 0.793m). Costanti approssimate, non vengono usate per IK
|
|
// vera ma per: (1) workspace check (centro = spalla, raggio = braccio teso),
|
|
// (2) fallback delle sfere polso quando /state e' offline (posa zero-joint).
|
|
// Costanti in frame G1 base (pelvi all'origine). Sottratto il lift di 0.793m
|
|
// che prima alzava il robot.
|
|
const SHOULDER = {
|
|
right: new THREE.Vector3(0.0, -0.18, 0.26), // ~26cm sopra pelvi
|
|
left: new THREE.Vector3(0.0, 0.18, 0.26),
|
|
};
|
|
const WRIST_ZERO_POSE = {
|
|
right: new THREE.Vector3(0.0, -0.18, -0.34), // ~34cm sotto pelvi (braccia giu')
|
|
left: new THREE.Vector3(0.0, 0.18, -0.34),
|
|
};
|
|
const ARM_MAX_REACH_M = 0.62; // braccio teso G1 (upper+forearm+palm ~10+25+25 cm)
|
|
|
|
// ----- URDF loader -----
|
|
// Carica il modello G1 reale a partire dal URDF vendored sotto
|
|
// /static/vendor/g1/. Le mesh STL referenziate nel URDF (path relativi tipo
|
|
// "meshes/pelvis.STL") vengono risolti contro lo stesso prefisso.
|
|
async function loadG1Urdf(scene) {
|
|
const loader = new URDFLoader();
|
|
loader.loadMeshCb = function (path, manager, onComplete) {
|
|
// URDFLoader risolve gia' i path relativi rispetto alla URL della URDF,
|
|
// quindi `path` arriva come "/static/vendor/g1/meshes/X.STL". Non
|
|
// ri-prefissare.
|
|
const url = path;
|
|
const stl = new STLLoader(manager);
|
|
stl.load(
|
|
url,
|
|
(geometry) => {
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: 0x9099a3, metalness: 0.4, roughness: 0.6,
|
|
transparent: true, opacity: 0.85,
|
|
});
|
|
const mesh = new THREE.Mesh(geometry, mat);
|
|
onComplete(mesh);
|
|
},
|
|
undefined,
|
|
(err) => onComplete(null, err),
|
|
);
|
|
};
|
|
return new Promise((resolve, reject) => {
|
|
loader.load(
|
|
'/static/vendor/g1/g1.urdf',
|
|
(robot) => {
|
|
// Robot rimane all'origine: la pelvi e' lo zero del frame base del G1,
|
|
// coerente con le pose wrist che arrivano da /api/pose_3d/state.
|
|
scene.add(robot);
|
|
state.robot = robot;
|
|
state.arm_links_m = measureArmLinkLengths(robot);
|
|
console.log('[pose_3d] G1 URDF caricato:', robot.name || 'g1',
|
|
'arm links (m):', state.arm_links_m);
|
|
resolve(robot);
|
|
},
|
|
undefined,
|
|
(err) => reject(err),
|
|
);
|
|
});
|
|
}
|
|
|
|
function measureArmLinkLengths(robot) {
|
|
// Misura le lunghezze dei link "upper arm" e "forearm" leggendo le posizioni
|
|
// dei joint right_shoulder_pitch -> right_elbow -> right_wrist_pitch
|
|
// (proxy per la lunghezza dell'avambraccio fino al polso).
|
|
// Default fallback: ~0.25 m upper, ~0.25 m forearm se i joint non si trovano.
|
|
const get = (n) => robot.joints && robot.joints[n] ? robot.joints[n] : null;
|
|
const shp = get('right_shoulder_pitch');
|
|
const elb = get('right_elbow');
|
|
const wrp = get('right_wrist_pitch');
|
|
if (!shp || !elb || !wrp) return { upper_m: 0.25, fore_m: 0.25 };
|
|
// World positions
|
|
const wShoulder = new THREE.Vector3();
|
|
const wElbow = new THREE.Vector3();
|
|
const wWrist = new THREE.Vector3();
|
|
shp.getWorldPosition(wShoulder);
|
|
elb.getWorldPosition(wElbow);
|
|
wrp.getWorldPosition(wWrist);
|
|
return {
|
|
upper_m: wShoulder.distanceTo(wElbow),
|
|
fore_m: wElbow.distanceTo(wWrist),
|
|
};
|
|
}
|
|
|
|
function solveTwoBoneIK(shoulderWorldPos, wristTargetWorldPos, L1, L2, poleHintDir) {
|
|
// Calcola angolo gomito e direzione shoulder->elbow per posizionare
|
|
// l'end-effector (polso) in `wristTargetWorldPos`.
|
|
// Restituisce {elbow_angle, shoulder_pitch, shoulder_roll, shoulder_yaw}
|
|
// basati su convenzione G1 (Unitree): pitch attorno a Y, roll attorno a X, yaw attorno a Z del frame spalla.
|
|
// I valori sono approssimati, calibrabili in seguito.
|
|
const sw = new THREE.Vector3().subVectors(wristTargetWorldPos, shoulderWorldPos);
|
|
let d = sw.length();
|
|
const dmax = (L1 + L2) * 0.999;
|
|
const dmin = Math.abs(L1 - L2) * 1.001;
|
|
if (d > dmax) d = dmax;
|
|
if (d < dmin) d = dmin;
|
|
sw.normalize();
|
|
|
|
// Angolo del gomito (law of cosines), 0 = teso, pi = ripiegato
|
|
const cosE = (L1*L1 + L2*L2 - d*d) / (2*L1*L2);
|
|
const elbow_angle = Math.PI - Math.acos(Math.max(-1, Math.min(1, cosE)));
|
|
|
|
// Direzione "verso la target" in coordinate base del polso shoulder
|
|
// Per il G1 right arm in zero pose: shoulder X = down (verso il basso), Z = forward.
|
|
// Approssimazione: shoulder_pitch ruota attorno a Y (sinistra-destra), shoulder_roll attorno a X (avanti-indietro).
|
|
// - pitch: angolo nel piano XZ (front/back vs down)
|
|
// - roll: angolo fuori dal piano (verso medial/lateral)
|
|
// - yaw: tipicamente 0 per drag puro (rotazione attorno a all'asse del braccio)
|
|
|
|
// pitch: alzare/abbassare il braccio = ruotare attorno a Y dello shoulder frame
|
|
// In zero pose dx, braccio giu' -> world (0, 0, -1). Vogliamo il braccio puntato lungo `sw`.
|
|
// pitch sposta nel piano X-Z (vista frontale: avanti/dietro)
|
|
// roll sposta lateralmente
|
|
const x = sw.x; // forward axis (X base = +x)
|
|
const y = sw.y; // lateral (sinistra +)
|
|
const z = sw.z; // up (+z)
|
|
|
|
// pitch: angolo nel piano XZ. In zero pose (-Z), pitch=0. pitch>0 alza in avanti (+X).
|
|
// arm pointing direction: from shoulder to wrist, in world frame.
|
|
// Se z=-1 (giù) e x=0,y=0 -> pitch=0, roll=0
|
|
// Se x=+1 (avanti) e z=0 -> pitch = +pi/2
|
|
const horiz = Math.sqrt(x*x + y*y);
|
|
const shoulder_pitch = Math.atan2(x, -z); // 0 = giù, +pi/2 = avanti
|
|
// roll: angolo dalla verticale lungo Y (asse laterale)
|
|
const shoulder_roll = Math.atan2(y, Math.sqrt(x*x + z*z)); // 0 = nel piano sagittale
|
|
// yaw: lasciato a 0 per ora (rotazione attorno al braccio); puo' essere
|
|
// tunato dal quaternion target del polso
|
|
const shoulder_yaw = 0;
|
|
|
|
return {
|
|
elbow_angle,
|
|
shoulder_pitch,
|
|
shoulder_roll,
|
|
shoulder_yaw,
|
|
};
|
|
}
|
|
|
|
function applyIKToUrdf(side, target_position, target_quaternion) {
|
|
// Aggiorna i joint dell'URDF G1 per posizionare il polso `side` al target.
|
|
// No-op se l'URDF non e' caricato.
|
|
if (!state.robot || !state.arm_links_m) return;
|
|
const robot = state.robot;
|
|
// Posizione spalla in world frame (legge dall'URDF live)
|
|
const shoulderName = `${side}_shoulder_pitch`;
|
|
const shJoint = robot.joints && robot.joints[shoulderName];
|
|
if (!shJoint) {
|
|
console.warn('[pose_3d] joint non trovato:', shoulderName);
|
|
return;
|
|
}
|
|
const shoulderWorld = new THREE.Vector3();
|
|
shJoint.getWorldPosition(shoulderWorld);
|
|
// Target in world (i target sono gia' in scene/world frame)
|
|
const wristWorld = new THREE.Vector3(target_position[0], target_position[1], target_position[2]);
|
|
|
|
const { upper_m, fore_m } = state.arm_links_m;
|
|
const ik = solveTwoBoneIK(shoulderWorld, wristWorld, upper_m, fore_m);
|
|
|
|
// Per il braccio sinistro, alcuni segni si invertono (mirror)
|
|
const sign = side === 'right' ? 1 : -1;
|
|
|
|
try {
|
|
robot.setJointValue(`${side}_shoulder_pitch`, ik.shoulder_pitch);
|
|
robot.setJointValue(`${side}_shoulder_roll`, ik.shoulder_roll * sign);
|
|
robot.setJointValue(`${side}_shoulder_yaw`, ik.shoulder_yaw);
|
|
robot.setJointValue(`${side}_elbow`, ik.elbow_angle);
|
|
// Wrist orientation: per ora teniamo 0 (raggiunge la posizione, l'orientazione
|
|
// non e' guidata dal target quaternion). Estensione futura: convertire il
|
|
// quaternion target a roll/pitch/yaw del polso URDF.
|
|
robot.setJointValue(`${side}_wrist_roll`, 0);
|
|
robot.setJointValue(`${side}_wrist_pitch`, 0);
|
|
robot.setJointValue(`${side}_wrist_yaw`, 0);
|
|
} catch (e) {
|
|
console.warn('[pose_3d] setJointValue err:', e.message);
|
|
}
|
|
}
|
|
|
|
// ----- scene init -----
|
|
function initScene() {
|
|
const canvas = $('p3Canvas');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
const rect = canvas.parentElement.getBoundingClientRect();
|
|
renderer.setSize(rect.width, rect.height, false);
|
|
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0d1014);
|
|
|
|
const camera = new THREE.PerspectiveCamera(45, rect.width / rect.height, 0.05, 20);
|
|
camera.position.set(1, -1, 0.8);
|
|
camera.up.set(0, 0, 1); // Z up (coerente col base frame del robot)
|
|
camera.lookAt(0, 0, 0.3);
|
|
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x202030, 0.6));
|
|
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
dir.position.set(2, 2, 3);
|
|
scene.add(dir);
|
|
|
|
// Grid (pavimento) posizionata sotto i piedi del robot: la pelvi del G1 e'
|
|
// all'origine del frame base, i piedi sono ~0.793m sotto. Il base UCS resta
|
|
// all'origine (centro robot).
|
|
const grid = new THREE.GridHelper(2, 20, 0x444444, 0x222222);
|
|
grid.rotation.x = Math.PI / 2;
|
|
grid.position.z = -0.793;
|
|
scene.add(grid);
|
|
const baseUCS = makeUCS(0.2);
|
|
scene.add(baseUCS);
|
|
|
|
// "FRONT" label sul piano del pavimento (Z=0.01, sotto tutto) per indicare
|
|
// la direzione +X del base frame. Solo marker visivo, NON interagibile
|
|
// (la raycaster considera solo le sfere dei polsi).
|
|
const frontArrow = makeLabelSprite('▲ FRONT (base +X)', '#ffd54f');
|
|
frontArrow.position.set(0.30, 0, -0.783); // appena sopra la griglia abbassata
|
|
frontArrow.scale.set(0.18, 0.045, 1); // più largo ma sottile, "etichetta a terra"
|
|
scene.add(frontArrow);
|
|
|
|
// Modello G1 reale via URDF (best-effort: se il loader fallisce, niente
|
|
// body — la tab funziona comunque coi soli polsi/UCS).
|
|
loadG1Urdf(scene).catch((e) => {
|
|
console.warn('[pose_3d] URDF G1 non caricato:', e.message);
|
|
});
|
|
|
|
// Arms
|
|
for (const side of ['right', 'left']) {
|
|
const wrist = makeWristMarker(side === 'right' ? 0x4da3ff : 0xff5e7e);
|
|
wrist.position.copy(WRIST_ZERO_POSE[side]); // posizione fallback "braccia giù"
|
|
wrist.userData.side = side;
|
|
scene.add(wrist);
|
|
state.arms[side] = wrist;
|
|
|
|
// Etichetta DX / SX sopra la sfera
|
|
const label = makeLabelSprite(side === 'right' ? 'DX' : 'SX',
|
|
side === 'right' ? '#4da3ff' : '#ff5e7e');
|
|
label.position.set(0, 0, 0.08); // 8cm sopra la sfera (locale al gruppo)
|
|
wrist.add(label);
|
|
|
|
// Linea shoulder → wrist
|
|
const lineGeom = new THREE.BufferGeometry().setFromPoints([
|
|
SHOULDER[side].clone(),
|
|
wrist.position.clone(),
|
|
]);
|
|
const line = new THREE.Line(lineGeom, new THREE.LineBasicMaterial({ color: 0xaaaaaa }));
|
|
scene.add(line);
|
|
state.links[side] = line;
|
|
|
|
// Ghost (preview target), inizialmente nascosto
|
|
const ghost = makeWristMarker(side === 'right' ? 0x4da3ff : 0xff5e7e);
|
|
ghost.userData.sphere.material = ghost.userData.sphere.material.clone();
|
|
ghost.userData.sphere.material.transparent = true;
|
|
ghost.userData.sphere.material.opacity = 0.4;
|
|
ghost.visible = false;
|
|
scene.add(ghost);
|
|
state.ghosts[side] = ghost;
|
|
}
|
|
|
|
const orbit = new OrbitControls(camera, renderer.domElement);
|
|
orbit.target.set(0, 0, 0.3);
|
|
orbit.update();
|
|
|
|
const transform = new TransformControls(camera, renderer.domElement);
|
|
transform.setMode('translate');
|
|
transform.size = 0.6;
|
|
// Disable orbit while dragging gizmo
|
|
transform.addEventListener('dragging-changed', (e) => { orbit.enabled = !e.value; });
|
|
// In r160 the visual helper is exposed via getHelper(); fall back to the
|
|
// transform object itself for older versions.
|
|
const transformHelper = typeof transform.getHelper === 'function'
|
|
? transform.getHelper()
|
|
: transform;
|
|
scene.add(transformHelper);
|
|
|
|
state.three = { scene, camera, renderer, orbit, transform };
|
|
|
|
// Resize handler
|
|
const onResize = () => {
|
|
const r = canvas.parentElement.getBoundingClientRect();
|
|
renderer.setSize(r.width, r.height, false);
|
|
camera.aspect = r.width / r.height;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// Render loop
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
}
|
|
|
|
// ----- fetch helpers -----
|
|
async function jsonFetch(url, opts) {
|
|
const r = await fetch(url, opts);
|
|
const body = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
const err = new Error(body.detail || `HTTP ${r.status}`);
|
|
err.status = r.status;
|
|
throw err;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
// ----- quaternion / RPY conversion (xyzw, deg) -----
|
|
function quatToRPYdeg(q) {
|
|
const [x, y, z, w] = q;
|
|
// ZYX intrinsic = RPY (roll=x, pitch=y, yaw=z)
|
|
const sinr_cosp = 2 * (w * x + y * z);
|
|
const cosr_cosp = 1 - 2 * (x * x + y * y);
|
|
const roll = Math.atan2(sinr_cosp, cosr_cosp);
|
|
const sinp = 2 * (w * y - z * x);
|
|
const pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * Math.PI / 2 : Math.asin(sinp);
|
|
const siny_cosp = 2 * (w * z + x * y);
|
|
const cosy_cosp = 1 - 2 * (y * y + z * z);
|
|
const yaw = Math.atan2(siny_cosp, cosy_cosp);
|
|
return [roll * 180 / Math.PI, pitch * 180 / Math.PI, yaw * 180 / Math.PI];
|
|
}
|
|
|
|
function rpyDegToQuat(roll, pitch, yaw) {
|
|
const r = roll * Math.PI / 180;
|
|
const p = pitch * Math.PI / 180;
|
|
const y = yaw * Math.PI / 180;
|
|
const cr = Math.cos(r / 2), sr = Math.sin(r / 2);
|
|
const cp = Math.cos(p / 2), sp = Math.sin(p / 2);
|
|
const cy = Math.cos(y / 2), sy = Math.sin(y / 2);
|
|
// ZYX intrinsic (matches quatToRPYdeg)
|
|
const qx = sr * cp * cy - cr * sp * sy;
|
|
const qy = cr * sp * cy + sr * cp * sy;
|
|
const qz = cr * cp * sy - sr * sp * cy;
|
|
const qw = cr * cp * cy + sr * sp * sy;
|
|
return [qx, qy, qz, qw];
|
|
}
|
|
|
|
// ----- state apply to scene -----
|
|
function updateRealWrists() {
|
|
for (const side of ['right', 'left']) {
|
|
const pose = state.current[side];
|
|
if (!pose) continue;
|
|
const arm = state.arms[side];
|
|
arm.position.set(pose.position[0], pose.position[1], pose.position[2]);
|
|
arm.quaternion.set(pose.quaternion[0], pose.quaternion[1],
|
|
pose.quaternion[2], pose.quaternion[3]);
|
|
// aggiorna line shoulder→wrist
|
|
const line = state.links[side];
|
|
const pts = [SHOULDER[side].clone(), arm.position.clone()];
|
|
line.geometry.setFromPoints(pts);
|
|
line.geometry.attributes.position.needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
function syncGhostsToTargetOrReal() {
|
|
for (const side of ['right', 'left']) {
|
|
const ghost = state.ghosts[side];
|
|
if (!ghost) continue;
|
|
if (state.target[side]) {
|
|
// ghost segue il target locale (mosso dal gizmo)
|
|
ghost.visible = true;
|
|
} else if (side === state.active_side) {
|
|
// ghost allineato al reale (preparato per drag).
|
|
// Fallback se /state e' offline: copia dall'arm visibile.
|
|
const real = state.current[side];
|
|
const arm = state.arms[side];
|
|
if (real) {
|
|
ghost.position.set(real.position[0], real.position[1], real.position[2]);
|
|
ghost.quaternion.set(real.quaternion[0], real.quaternion[1],
|
|
real.quaternion[2], real.quaternion[3]);
|
|
} else if (arm) {
|
|
ghost.position.copy(arm.position);
|
|
ghost.quaternion.copy(arm.quaternion);
|
|
}
|
|
} else {
|
|
ghost.visible = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function setInputIfNotFocused(id, value) {
|
|
const el = $(id);
|
|
if (!el) return;
|
|
if (document.activeElement === el) return; // user is typing, don't stomp
|
|
el.value = value;
|
|
}
|
|
|
|
function updateReadout() {
|
|
const side = state.active_side;
|
|
const pose = state.target[side] || state.current[side];
|
|
if (!pose) {
|
|
setInputIfNotFocused('p3X', '');
|
|
setInputIfNotFocused('p3Y', '');
|
|
setInputIfNotFocused('p3Z', '');
|
|
setInputIfNotFocused('p3R', '');
|
|
setInputIfNotFocused('p3P', '');
|
|
setInputIfNotFocused('p3W', '');
|
|
return;
|
|
}
|
|
const p = pose.position, q = pose.quaternion;
|
|
setInputIfNotFocused('p3X', (p[0] * 1000).toFixed(1));
|
|
setInputIfNotFocused('p3Y', (p[1] * 1000).toFixed(1));
|
|
setInputIfNotFocused('p3Z', (p[2] * 1000).toFixed(1));
|
|
const rpy = quatToRPYdeg(q);
|
|
setInputIfNotFocused('p3R', rpy[0].toFixed(1));
|
|
setInputIfNotFocused('p3P', rpy[1].toFixed(1));
|
|
setInputIfNotFocused('p3W', rpy[2].toFixed(1));
|
|
$('p3Status').textContent = state.target[side] ? '● modificata' : '— polso fermo';
|
|
$('p3Status').className = 'p3-status' + (state.target[side] ? ' modified' : '');
|
|
// Workspace feasibility coloring + indicator
|
|
applyFeasibilityToActiveGhost();
|
|
}
|
|
|
|
function applyFeasibilityToActiveGhost() {
|
|
const side = state.active_side;
|
|
if (!side) return;
|
|
const pose = state.target[side] || state.current[side];
|
|
if (!pose) {
|
|
// Niente posa, niente indicazione
|
|
setFeasibilityIndicator(null);
|
|
return;
|
|
}
|
|
const check = checkWorkspace(side, pose.position);
|
|
const ghost = state.ghosts[side];
|
|
if (ghost && ghost.userData.sphere) {
|
|
// verde se feasible, rosso se fuori workspace; opacita' invariata
|
|
const color = check.feasible ? 0x3ecf8e : 0xe74c3c;
|
|
ghost.userData.sphere.material.color.setHex(color);
|
|
}
|
|
setFeasibilityIndicator(check);
|
|
}
|
|
|
|
function setFeasibilityIndicator(check) {
|
|
const el = $('p3Feasibility');
|
|
if (!el) return;
|
|
if (!check) {
|
|
el.textContent = '';
|
|
el.className = 'p3-feasibility';
|
|
return;
|
|
}
|
|
const dist_mm = (check.distance_m * 1000).toFixed(0);
|
|
const max_mm = (check.max_m * 1000).toFixed(0);
|
|
if (check.feasible) {
|
|
el.textContent = `✓ raggiungibile (dist spalla ${dist_mm}/${max_mm} mm)`;
|
|
el.className = 'p3-feasibility ok';
|
|
} else {
|
|
const over_mm = (check.distance_m - check.max_m) * 1000;
|
|
el.textContent = `⚠ fuori workspace (${dist_mm}/${max_mm} mm — ${over_mm.toFixed(0)} mm oltre)`;
|
|
el.className = 'p3-feasibility error';
|
|
}
|
|
}
|
|
|
|
// ----- polling -----
|
|
let pollTimer = null;
|
|
async function pollState() {
|
|
try {
|
|
const s = await jsonFetch('/api/pose_3d/state');
|
|
state.current.right = s.wrist.right;
|
|
state.current.left = s.wrist.left;
|
|
state.saved_poses = s.poses || {};
|
|
state.live_max_step_m = s.live_max_step_m || 0.15;
|
|
updateRealWrists();
|
|
syncGhostsToTargetOrReal();
|
|
updateReadout();
|
|
// Aggiorna dropdown pose salvate
|
|
refreshPoseSelect();
|
|
} catch (e) {
|
|
// soft fail: log + segna lo stato
|
|
console.warn('pose_3d /state fail:', e.message);
|
|
}
|
|
}
|
|
|
|
function refreshPoseSelect() {
|
|
const sel = $('p3PoseSelect');
|
|
if (!sel) return;
|
|
const cur = sel.value;
|
|
sel.innerHTML = '<option value="">(seleziona)</option>';
|
|
for (const name of Object.keys(state.saved_poses).sort()) {
|
|
const opt = document.createElement('option');
|
|
opt.value = name; opt.textContent = name;
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.value = cur;
|
|
}
|
|
|
|
// ----- selezione polso via click -----
|
|
function setupSelection() {
|
|
const { renderer, camera, transform } = state.three;
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
const canvas = renderer.domElement;
|
|
|
|
canvas.addEventListener('pointerdown', (e) => {
|
|
// Se sta interagendo con il gizmo, non fare nulla
|
|
if (transform.dragging) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(mouse, camera);
|
|
// Verifica intersezione con sphere destra o sinistra
|
|
const candidates = [state.arms.right.userData.sphere,
|
|
state.arms.left.userData.sphere];
|
|
const hits = raycaster.intersectObjects(candidates, false);
|
|
console.log('[pose_3d] click on canvas, raycast hits:', hits.length);
|
|
if (hits.length > 0) {
|
|
const sphere = hits[0].object;
|
|
const side = sphere.parent.userData.side;
|
|
console.log('[pose_3d] activating side:', side);
|
|
activateSide(side);
|
|
}
|
|
});
|
|
}
|
|
|
|
function activateSide(side) {
|
|
state.active_side = side;
|
|
// sync radio buttons
|
|
for (const r of document.querySelectorAll('input[name=p3Side]')) {
|
|
r.checked = (r.value === side);
|
|
}
|
|
// Attacca gizmo al ghost (lo creiamo se non esiste, posizionandolo sul reale)
|
|
ensureGhostInit(side);
|
|
const { transform } = state.three;
|
|
transform.attach(state.ghosts[side]);
|
|
console.log('[pose_3d] gizmo attached to ghost', side, 'at', state.ghosts[side].position);
|
|
state.ghosts[side].visible = true;
|
|
updateReadout();
|
|
}
|
|
|
|
function ensureGhostInit(side) {
|
|
// Se non c'è target locale, allinea ghost al reale (preparato per drag).
|
|
// Se /state non e' disponibile (G1 offline), fallback alla posizione
|
|
// dell'arm visibile (che usa SHOULDER come default).
|
|
if (state.target[side]) return;
|
|
const real = state.current[side];
|
|
const arm = state.arms[side];
|
|
if (real) {
|
|
state.ghosts[side].position.set(real.position[0], real.position[1], real.position[2]);
|
|
state.ghosts[side].quaternion.set(real.quaternion[0], real.quaternion[1],
|
|
real.quaternion[2], real.quaternion[3]);
|
|
} else if (arm) {
|
|
state.ghosts[side].position.copy(arm.position);
|
|
state.ghosts[side].quaternion.copy(arm.quaternion);
|
|
}
|
|
}
|
|
|
|
// ----- gizmo mode toggle -----
|
|
function bindGizmoControls() {
|
|
for (const r of document.querySelectorAll('input[name=p3Gizmo]')) {
|
|
r.addEventListener('change', () => {
|
|
state.gizmo_mode = r.value;
|
|
state.three.transform.setMode(r.value);
|
|
});
|
|
}
|
|
// tasti t/r
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!$('tab-pose-3d')?.classList.contains('active')) return;
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
if (e.key === 't') { setGizmoMode('translate'); }
|
|
if (e.key === 'r') { setGizmoMode('rotate'); }
|
|
});
|
|
}
|
|
|
|
function setGizmoMode(mode) {
|
|
state.gizmo_mode = mode;
|
|
state.three.transform.setMode(mode);
|
|
for (const r of document.querySelectorAll('input[name=p3Gizmo]')) {
|
|
r.checked = (r.value === mode);
|
|
}
|
|
}
|
|
|
|
// ----- target tracking durante drag gizmo -----
|
|
function bindGizmoEvents() {
|
|
const { transform } = state.three;
|
|
transform.addEventListener('change', () => {
|
|
const side = state.active_side;
|
|
const ghost = state.ghosts[side];
|
|
if (!ghost) return;
|
|
state.target[side] = {
|
|
position: [ghost.position.x, ghost.position.y, ghost.position.z],
|
|
quaternion: [ghost.quaternion.x, ghost.quaternion.y,
|
|
ghost.quaternion.z, ghost.quaternion.w],
|
|
};
|
|
updateReadout();
|
|
});
|
|
}
|
|
|
|
function readReadoutInputs() {
|
|
const num = (id, dflt) => {
|
|
const v = parseFloat($(id).value);
|
|
return Number.isFinite(v) ? v : dflt;
|
|
};
|
|
const side = state.active_side;
|
|
if (!side) return;
|
|
// fallback values: current pose if any, else SHOULDER position via arm
|
|
const fallback = state.current[side] || (state.arms[side] ? {
|
|
position: [state.arms[side].position.x, state.arms[side].position.y, state.arms[side].position.z],
|
|
quaternion: [0, 0, 0, 1],
|
|
} : { position: [0, 0, 0], quaternion: [0, 0, 0, 1] });
|
|
const x_mm = num('p3X', fallback.position[0] * 1000);
|
|
const y_mm = num('p3Y', fallback.position[1] * 1000);
|
|
const z_mm = num('p3Z', fallback.position[2] * 1000);
|
|
const r_deg = num('p3R', 0);
|
|
const p_deg = num('p3P', 0);
|
|
const w_deg = num('p3W', 0);
|
|
const position = [x_mm / 1000, y_mm / 1000, z_mm / 1000];
|
|
const quaternion = rpyDegToQuat(r_deg, p_deg, w_deg);
|
|
state.target[side] = { position, quaternion };
|
|
// Aggiorna ghost in scena
|
|
const ghost = state.ghosts[side];
|
|
if (ghost) {
|
|
ghost.position.set(position[0], position[1], position[2]);
|
|
ghost.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
|
|
ghost.visible = true;
|
|
}
|
|
// Status
|
|
$('p3Status').textContent = '● modificata (da input)';
|
|
$('p3Status').className = 'p3-status modified';
|
|
}
|
|
|
|
function bindReadoutInputs() {
|
|
for (const id of ['p3X', 'p3Y', 'p3Z', 'p3R', 'p3P', 'p3W']) {
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
el.addEventListener('change', readReadoutInputs); // fire on blur / Enter
|
|
}
|
|
}
|
|
|
|
// ----- side change listener -----
|
|
function bindSideControls() {
|
|
for (const r of document.querySelectorAll('input[name=p3Side]')) {
|
|
r.addEventListener('change', () => {
|
|
state.active_side = r.value;
|
|
updateReadout();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ----- Editor mode actions -----
|
|
async function onApplyClick() {
|
|
if (state.mode !== 'editor') return; // in Jog non c'è Apply
|
|
const side = state.active_side;
|
|
const tgt = state.target[side];
|
|
if (!tgt) return;
|
|
try {
|
|
await jsonFetch('/api/pose_3d/apply', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
side, position: tgt.position, quaternion: tgt.quaternion,
|
|
dry_run: true, precise: state.precise,
|
|
}),
|
|
});
|
|
// commit locale: il target diventa il nuovo current (simulato)
|
|
state.current[side] = { position: tgt.position, quaternion: tgt.quaternion };
|
|
state.target[side] = null;
|
|
applyIKToUrdf(side, tgt.position, tgt.quaternion);
|
|
updateRealWrists();
|
|
syncGhostsToTargetOrReal();
|
|
updateReadout();
|
|
$('p3Status').textContent = '✓ Editor: target committato (simulato)';
|
|
$('p3Status').className = 'p3-status ok';
|
|
} catch (e) {
|
|
showError(e.message);
|
|
}
|
|
}
|
|
|
|
function onCancelClick() {
|
|
const side = state.active_side;
|
|
state.target[side] = null;
|
|
syncGhostsToTargetOrReal();
|
|
updateReadout();
|
|
}
|
|
|
|
async function onSyncWithRobot() {
|
|
// Force-refresh dallo stato del robot e ricopia la posa attuale nel target
|
|
// (cosi' gli input riflettono il robot, e l'utente puo' partire da li').
|
|
clearError();
|
|
try {
|
|
await pollState(); // refresha state.current dal backend
|
|
} catch (e) {
|
|
// soft fail; pollState gia' logga internamente
|
|
}
|
|
const side = state.active_side;
|
|
if (!side) return;
|
|
state.target[side] = null;
|
|
// Forza il ghost (e quindi gli input) ad allinearsi sul reale (con fallback arm)
|
|
syncGhostsToTargetOrReal();
|
|
updateReadout();
|
|
$('p3Status').textContent = '✓ Sincronizzato con posa robot';
|
|
$('p3Status').className = 'p3-status ok';
|
|
}
|
|
|
|
function showError(msg) {
|
|
$('p3ErrorBox').style.display = 'block';
|
|
$('p3ErrorText').textContent = msg;
|
|
$('p3Status').textContent = '✗ errore';
|
|
$('p3Status').className = 'p3-status error';
|
|
}
|
|
|
|
function clearError() {
|
|
$('p3ErrorBox').style.display = 'none';
|
|
$('p3ErrorText').textContent = '';
|
|
}
|
|
|
|
function bindEditorButtons() {
|
|
$('p3ApplyBtn').addEventListener('click', onApplyClick);
|
|
$('p3CancelBtn').addEventListener('click', onCancelClick);
|
|
$('p3SyncBtn').addEventListener('click', onSyncWithRobot);
|
|
}
|
|
|
|
// ----- mode toggle -----
|
|
function bindModeControls() {
|
|
for (const r of document.querySelectorAll('input[name=p3Mode]')) {
|
|
r.addEventListener('change', () => {
|
|
state.mode = r.value;
|
|
const badge = $('p3ModeBadge');
|
|
if (state.mode === 'jog') {
|
|
badge.textContent = '🔴 JOG (live)';
|
|
badge.classList.add('jog');
|
|
$('p3EditorActions').style.display = 'none';
|
|
} else {
|
|
badge.textContent = 'Editor';
|
|
badge.classList.remove('jog');
|
|
$('p3EditorActions').style.display = '';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function bindPreciseControl() {
|
|
$('p3Precise').addEventListener('change', (e) => {
|
|
state.precise = e.target.checked;
|
|
});
|
|
}
|
|
|
|
// ----- jog auto-apply al mouseup gizmo -----
|
|
function bindJogAutoApply() {
|
|
const { transform } = state.three;
|
|
transform.addEventListener('dragging-changed', async (e) => {
|
|
if (e.value) return; // inizio drag, niente
|
|
if (state.mode !== 'jog') return;
|
|
const side = state.active_side;
|
|
const tgt = state.target[side];
|
|
if (!tgt) return;
|
|
const cur = state.current[side];
|
|
if (!cur) return;
|
|
const dx = tgt.position[0] - cur.position[0];
|
|
const dy = tgt.position[1] - cur.position[1];
|
|
const dz = tgt.position[2] - cur.position[2];
|
|
const delta = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
|
if (delta > state.live_max_step_m) {
|
|
const ok = confirm(
|
|
`Movimento richiesto ${(delta*1000).toFixed(0)} mm > soglia ${(state.live_max_step_m*1000).toFixed(0)} mm.\n` +
|
|
`Procedere comunque?`,
|
|
);
|
|
if (!ok) return;
|
|
}
|
|
clearError();
|
|
try {
|
|
const res = await jsonFetch('/api/pose_3d/apply', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
side, position: tgt.position, quaternion: tgt.quaternion,
|
|
dry_run: false, precise: state.precise,
|
|
}),
|
|
});
|
|
const r = res.result || {};
|
|
if (r.reached_target === false || r.stalled === true) {
|
|
showError(`Move non completato: residual ${(r.position_error_mm||0).toFixed(1)} mm. ` +
|
|
`Sblocca braccia e riprova, oppure disattiva Precise.`);
|
|
} else {
|
|
$('p3Status').textContent = `✓ Jog: raggiunto (residual ${(r.position_error_mm||0).toFixed(1)} mm)`;
|
|
$('p3Status').className = 'p3-status ok';
|
|
applyIKToUrdf(side, tgt.position, tgt.quaternion);
|
|
state.target[side] = null;
|
|
syncGhostsToTargetOrReal();
|
|
}
|
|
} catch (e) {
|
|
showError(e.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ----- named poses -----
|
|
async function onSavePose() {
|
|
const name = ($('p3PoseName').value || '').trim();
|
|
if (!name) { alert('Inserisci un nome'); return; }
|
|
if (!/^[A-Za-z0-9_-]{1,40}$/.test(name)) {
|
|
alert('Nome: solo [A-Za-z0-9_-], 1-40 caratteri');
|
|
return;
|
|
}
|
|
const side = state.active_side;
|
|
const pose = state.target[side] || state.current[side];
|
|
if (!pose) { alert('Nessuna posa da salvare'); return; }
|
|
if (state.saved_poses[name]) {
|
|
if (!confirm(`Posa "${name}" esiste già — sovrascrivere?`)) return;
|
|
}
|
|
try {
|
|
await jsonFetch(`/api/pose_3d/poses/${encodeURIComponent(name)}`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
side, position: pose.position, quaternion: pose.quaternion,
|
|
}),
|
|
});
|
|
$('p3PoseName').value = '';
|
|
await pollState(); // refresh dropdown
|
|
$('p3PoseSelect').value = name;
|
|
} catch (e) {
|
|
showError(`Salvataggio fallito: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function onGoPose() {
|
|
const name = $('p3PoseSelect').value;
|
|
if (!name) { alert('Seleziona una posa dal dropdown'); return; }
|
|
if (state.mode === 'jog') {
|
|
if (!confirm(`Eseguire posa "${name}" SUL ROBOT REALE? (Jog mode)`)) return;
|
|
}
|
|
const dry_run = (state.mode === 'editor');
|
|
clearError();
|
|
try {
|
|
const res = await jsonFetch(
|
|
`/api/pose_3d/poses/${encodeURIComponent(name)}/go`,
|
|
{
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dry_run, precise: state.precise }),
|
|
},
|
|
);
|
|
const r = res.result || {};
|
|
if (r.reached_target === false || r.stalled === true) {
|
|
showError(`Vai a "${name}": residual ${(r.position_error_mm||0).toFixed(1)} mm`);
|
|
} else {
|
|
$('p3Status').textContent = `✓ Posa "${name}" applicata` +
|
|
(dry_run ? ' (simulato)' : '');
|
|
$('p3Status').className = 'p3-status ok';
|
|
const pose = state.saved_poses[name];
|
|
if (pose) applyIKToUrdf(pose.side, pose.position, pose.quaternion);
|
|
}
|
|
} catch (e) {
|
|
showError(e.message);
|
|
}
|
|
}
|
|
|
|
async function onDeletePose() {
|
|
const name = $('p3PoseSelect').value;
|
|
if (!name) { alert('Seleziona una posa dal dropdown'); return; }
|
|
if (!confirm(`Cancellare definitivamente la posa "${name}"?`)) return;
|
|
try {
|
|
await jsonFetch(`/api/pose_3d/poses/${encodeURIComponent(name)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
await pollState();
|
|
} catch (e) {
|
|
showError(e.message);
|
|
}
|
|
}
|
|
|
|
function bindPoseButtons() {
|
|
$('p3PoseSaveBtn').addEventListener('click', onSavePose);
|
|
$('p3PoseGoBtn').addEventListener('click', onGoPose);
|
|
$('p3PoseDelBtn').addEventListener('click', onDeletePose);
|
|
}
|
|
|
|
async function onUnlock() {
|
|
clearError();
|
|
try {
|
|
await jsonFetch('/api/pose_3d/unlock', { method: 'POST' });
|
|
$('p3Status').textContent = '✓ Braccia sbloccate';
|
|
$('p3Status').className = 'p3-status ok';
|
|
} catch (e) {
|
|
showError(`Unlock fallito: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function bindUnlockButton() {
|
|
$('p3UnlockBtn').addEventListener('click', onUnlock);
|
|
}
|
|
|
|
// ----- init triggered on first activation -----
|
|
let inited = false;
|
|
function ensureInit() {
|
|
if (inited) return;
|
|
if (!$('p3Canvas')) return;
|
|
inited = true;
|
|
initScene();
|
|
setupSelection();
|
|
bindSideControls();
|
|
bindGizmoControls();
|
|
bindGizmoEvents();
|
|
bindEditorButtons();
|
|
bindModeControls();
|
|
bindPreciseControl();
|
|
bindJogAutoApply();
|
|
bindPoseButtons();
|
|
bindUnlockButton();
|
|
bindReadoutInputs();
|
|
pollState();
|
|
pollTimer = setInterval(pollState, POLL_MS);
|
|
}
|
|
|
|
// Observe quando la tab diventa visibile per inizializzare
|
|
const tabObserver = new MutationObserver(() => {
|
|
if ($('tab-pose-3d')?.classList.contains('active')) ensureInit();
|
|
});
|
|
if ($('tab-pose-3d')) {
|
|
tabObserver.observe($('tab-pose-3d'), { attributes: true, attributeFilter: ['class'] });
|
|
// Se è già attivo all'avvio
|
|
if ($('tab-pose-3d').classList.contains('active')) ensureInit();
|
|
}
|