L'overlay tag frontale (D435i) ri-eseguiva la detection ArUco ad ogni tick di pollState() finche' il flag era attivo. Ora gira solo: - al toggle on del checkbox - a fine acquireClouds() (pulsante Acquisisci) se il flag e' attivo Rimosso il piggyback in pollState() e lo stato front_tags.in_flight ormai inutilizzato. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2194 lines
81 KiB
JavaScript
2194 lines
81 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;
|
||
}
|
||
|
||
// Carica la mesh STL della Nuwa HP60C come child del polso destro: segue
|
||
// rigidamente il wrist frame, posizione/orientamento applicati da config.
|
||
// Aggiunge un UCS (assi colorati) all'origine di ogni link URDF. I helper
|
||
// vengono memorizzati in state.joint_ucses per show/hide e resize rapidi.
|
||
// I link "*_hand_*" vengono saltati (mani gia' nascoste).
|
||
function addJointUCSes(robot) {
|
||
const helpers = [];
|
||
const visited = new Set();
|
||
robot.traverse((obj) => {
|
||
if (!obj.name || visited.has(obj.name)) return;
|
||
if (obj.name.startsWith('left_hand_') || obj.name.startsWith('right_hand_')) return;
|
||
if (obj.isURDFLink !== true) return;
|
||
visited.add(obj.name);
|
||
const ucs = makeUCS(0.10); // unit length; resize tramite .scale
|
||
ucs.userData.isJointUCS = true;
|
||
ucs.userData.linkName = obj.name;
|
||
ucs.visible = false; // di default nascosto, abilita dal flag
|
||
obj.add(ucs);
|
||
helpers.push(ucs);
|
||
});
|
||
console.log(`[pose_3d] joint UCSes attached: ${helpers.length}`);
|
||
return helpers;
|
||
}
|
||
|
||
function setJointUCSVisible(visible) {
|
||
for (const h of state.joint_ucses || []) h.visible = visible;
|
||
}
|
||
|
||
function setJointUCSScale(sizeM) {
|
||
const s = sizeM / 0.10;
|
||
for (const h of state.joint_ucses || []) h.scale.setScalar(s);
|
||
}
|
||
|
||
// UCS sempre visibile per ogni sensore fisso modellato nell'URDF
|
||
// (RealSense D435i frontale, Livox MID360 lidar). I link esistono come
|
||
// frame senza mesh; aggiungiamo qui un UCS + label per visualizzarli.
|
||
function addFixedSensorMarkers(robot) {
|
||
const sensors = [
|
||
{ link: 'd435_link', label: 'D435i', color: '#ffd54f', size: 0.06 },
|
||
{ link: 'mid360_link', label: 'MID360', color: '#9b59b6', size: 0.05 },
|
||
];
|
||
for (const s of sensors) {
|
||
const link = (robot.links && robot.links[s.link])
|
||
|| (() => { let f = null; robot.traverse(o => { if (o.name === s.link) f = o; }); return f; })();
|
||
if (!link) continue;
|
||
const ucs = makeUCS(s.size);
|
||
link.add(ucs);
|
||
const label = makeLabelSprite(s.label, s.color);
|
||
label.position.set(0, 0, s.size + 0.02);
|
||
link.add(label);
|
||
}
|
||
}
|
||
|
||
// Nasconde i mesh delle mani (palm + tutte le dita) lasciando i joint nel
|
||
// grafo. Riconosce i nomi che iniziano per `left_hand_` / `right_hand_`.
|
||
function hideHandMeshes(robot) {
|
||
let n = 0;
|
||
robot.traverse((obj) => {
|
||
const name = obj.name || '';
|
||
if (name.startsWith('left_hand_') || name.startsWith('right_hand_')) {
|
||
obj.visible = false;
|
||
n++;
|
||
}
|
||
});
|
||
console.log(`[pose_3d] hand meshes hidden: ${n}`);
|
||
}
|
||
|
||
// Trova il link URDF al quale la Nuwa e' fissata rigidamente (ultimo link
|
||
// della catena polso destro). Ritorna null se l'URDF non e' ancora caricato.
|
||
function getRightWristUrdfLink() {
|
||
const r = state.robot;
|
||
if (!r) return null;
|
||
if (r.links && r.links['right_wrist_yaw_link']) return r.links['right_wrist_yaw_link'];
|
||
let found = null;
|
||
r.traverse((obj) => { if (obj.name === 'right_wrist_yaw_link') found = obj; });
|
||
return found;
|
||
}
|
||
|
||
// Aggancia la camera al link URDF: cosi' segue rotazione+posizione del joint,
|
||
// non solo la posizione della sfera marker.
|
||
function reparentNuwaToUrdf() {
|
||
const cam = state.nuwa_mesh;
|
||
const link = getRightWristUrdfLink();
|
||
if (!cam || !link) return;
|
||
if (cam.parent === link) return;
|
||
link.add(cam); // THREE.Object3D.add() rimuove automaticamente dal vecchio parent
|
||
applyNuwaTransform();
|
||
}
|
||
|
||
function loadNuwaMesh(parent, meshUrl) {
|
||
return new Promise((resolve, reject) => {
|
||
const loader = new STLLoader();
|
||
loader.load(
|
||
meshUrl,
|
||
(geom) => {
|
||
geom.computeVertexNormals();
|
||
const mesh = new THREE.Mesh(
|
||
geom,
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0xb8c1cb, metalness: 0.2, roughness: 0.5,
|
||
emissive: 0x223040, emissiveIntensity: 0.6,
|
||
}),
|
||
);
|
||
mesh.userData.isCustom = true; // protetto dal toggle "nascondi robot"
|
||
mesh.scale.setScalar(0.001); // STL in mm, scena in metri
|
||
// UCS visibile sull'origine della camera (debug orientamento)
|
||
const cucs = makeUCS(0.04);
|
||
const cam = new THREE.Group();
|
||
cam.add(mesh);
|
||
cam.add(cucs);
|
||
// Default visibile anche prima che /state arrivi col config
|
||
cam.position.set(0, 0, 0.1);
|
||
parent.add(cam);
|
||
resolve(cam);
|
||
},
|
||
undefined,
|
||
(e) => { console.warn('[pose_3d] STLLoader error', e); reject(e); },
|
||
);
|
||
});
|
||
}
|
||
|
||
function applyNuwaTransform() {
|
||
const cfg = state.nuwa_cfg;
|
||
const cam = state.nuwa_mesh;
|
||
if (!cam) return;
|
||
if (cfg && cfg.enabled === false) { cam.visible = false; return; }
|
||
cam.visible = true;
|
||
// Se cfg non e' ancora arrivato, lascia i default impostati al load.
|
||
if (!cfg) return;
|
||
const [ox = 0, oy = 0, oz = 0.1] = cfg.offset_m || [];
|
||
cam.position.set(ox, oy, oz);
|
||
const [rd = 0, pd = 0, yd = 0] = cfg.rpy_deg || [];
|
||
const e = new THREE.Euler(
|
||
rd * Math.PI / 180,
|
||
pd * Math.PI / 180,
|
||
yd * Math.PI / 180,
|
||
'XYZ',
|
||
);
|
||
cam.quaternion.setFromEuler(e);
|
||
}
|
||
|
||
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
|
||
nuwa_mesh: null, // mesh camera Nuwa montata sul polso destro (null se disabilitata)
|
||
nuwa_cfg: null, // {enabled, mesh_url, offset_m, rpy_deg} dal config
|
||
joint_ucses: [], // array di THREE.Group, UCS per ogni link URDF (debug)
|
||
clouds_cfg: null, // {nuwa:{...}, d435:{...}} dal config
|
||
cloud_points: { nuwa: null, d435: null }, // THREE.Points obj per camera
|
||
tag_locator: { // sezione "Tag locator" nella sidebar Pose 3D
|
||
marker: null, // THREE.LineSegments (crocetta XYZ) o null
|
||
position_m: null, // [x,y,z] in pelvis frame della crocetta corrente
|
||
joints_at_place: null, // snapshot giunti al click (può essere null se G1 offline)
|
||
},
|
||
front_tags: { // overlay tag visibili dalla camera D435i frontale
|
||
group: null, // THREE.Group child del d435 parent (cloud-like transform)
|
||
},
|
||
};
|
||
|
||
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);
|
||
// Nascondi visivamente i link delle mani (palm, thumb, index, middle,
|
||
// ring, pinky). I joint restano nel grafo per non rompere la catena
|
||
// cinematica; viene oscurato solo il rendering del visual.
|
||
hideHandMeshes(robot);
|
||
// UCS per ogni link (di default nascosti, attivabili da checkbox)
|
||
state.joint_ucses = addJointUCSes(robot);
|
||
applyJointUCSUIState();
|
||
// UCS permanenti per le camere fisse (D435i, MID360 lidar)
|
||
addFixedSensorMarkers(robot);
|
||
// Se la Nuwa e' gia' caricata, riparent sul link URDF cosi' segue i joint
|
||
reparentNuwaToUrdf();
|
||
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,
|
||
};
|
||
}
|
||
|
||
// Imposta direttamente i giunti URDF dai valori reali (q in rad) per nome.
|
||
// Salta silenziosamente i nomi non presenti nell'URDF (joint fisso o
|
||
// non modellato). Usa il suffisso "_joint" se richiesto da URDFLoader.
|
||
function applyJointsToUrdf(joints) {
|
||
const robot = state.robot;
|
||
if (!robot || !joints) return 0;
|
||
let n = 0;
|
||
for (const name in joints) {
|
||
const q = joints[name];
|
||
if (!Number.isFinite(q)) continue;
|
||
let j = robot.joints && (robot.joints[name] || robot.joints[`${name}_joint`]);
|
||
if (!j) continue;
|
||
try {
|
||
robot.setJointValue(j.name, q);
|
||
n++;
|
||
} catch (e) {
|
||
// joint fisso o fuori limiti — ignora
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
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;
|
||
|
||
// Nuwa camera rigida sul polso destro (eye-in-hand): mesh STL, trasformata
|
||
// letta da pose_3d.right_wrist_camera (offset+rpy in wrist frame).
|
||
if (side === 'right') {
|
||
loadNuwaMesh(wrist, '/static/assets/nuwa/nuwa-hp60c.stl')
|
||
.then((cam) => {
|
||
state.nuwa_mesh = cam;
|
||
applyNuwaTransform();
|
||
// Se l'URDF e' gia' caricato, sposta la mesh sul link URDF
|
||
reparentNuwaToUrdf();
|
||
})
|
||
.catch((e) => console.warn('[pose_3d] STL Nuwa non caricato:', e.message));
|
||
}
|
||
|
||
// 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];
|
||
}
|
||
|
||
// Trova il link URDF del palmo per `side`. Usato per posizionare la sfera
|
||
// marker selezionabile esattamente sul TCP visivo (= palm_link del modello).
|
||
function getPalmLink(side) {
|
||
if (!state.robot) return null;
|
||
const name = `${side}_hand_palm_link`;
|
||
if (state.robot.links && state.robot.links[name]) return state.robot.links[name];
|
||
let found = null;
|
||
state.robot.traverse(o => { if (o.name === name) found = o; });
|
||
return found;
|
||
}
|
||
|
||
// ----- state apply to scene -----
|
||
function updateRealWrists() {
|
||
const tmpV = new THREE.Vector3();
|
||
const tmpQ = new THREE.Quaternion();
|
||
for (const side of ['right', 'left']) {
|
||
const arm = state.arms[side];
|
||
if (!arm) continue;
|
||
// Preferiamo la pose world del palm link URDF (cosi' la sfera coincide
|
||
// sempre col TCP visivo dato dai giunti reali via /diagnostics).
|
||
const palm = getPalmLink(side);
|
||
if (palm) {
|
||
palm.updateWorldMatrix(true, false);
|
||
palm.getWorldPosition(tmpV);
|
||
palm.getWorldQuaternion(tmpQ);
|
||
arm.position.copy(tmpV);
|
||
arm.quaternion.copy(tmpQ);
|
||
} else {
|
||
// Fallback: TCP da /state (G1 wrist), usato quando URDF non e' carico
|
||
const pose = state.current[side];
|
||
if (!pose) continue;
|
||
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]);
|
||
}
|
||
// URDF: i giunti reali arrivano da pollState e li applichiamo li' (vedi
|
||
// applyJointsToUrdf). Niente IK qui: l'IK e' un'approssimazione e
|
||
// l'approccio diretto via /diagnostics e' esatto.
|
||
// 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');
|
||
if (s.wrist) {
|
||
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;
|
||
state.nuwa_cfg = s.right_wrist_camera || null;
|
||
state.clouds_cfg = s.clouds || null;
|
||
applyNuwaTransform();
|
||
updateNuwaInputsFromCfg();
|
||
updateD435InputsFromCfg();
|
||
applyCloudParamsFromCfg(s.cloud_params);
|
||
applyCalibratedD435UCS();
|
||
// Applica TUTTI i giunti reali sul modello URDF (29 motori G1)
|
||
state.joints = s.joints || null; // snapshot per tag locator save
|
||
if (s.joints) applyJointsToUrdf(s.joints);
|
||
updateRealWrists();
|
||
syncGhostsToTargetOrReal();
|
||
updateReadout();
|
||
// Aggiorna dropdown pose salvate
|
||
refreshPoseSelect();
|
||
} catch (e) {
|
||
// soft fail: log + segna lo stato
|
||
console.warn('pose_3d /state fail:', e.message);
|
||
}
|
||
// NB: il front-tag overlay NON viene aggiornato qui. La detection ArUco
|
||
// gira solo al cambio dell'immagine (pulsante Acquisisci -> acquireClouds)
|
||
// e al toggle on del flag, non ad ogni tick del poll dello state.
|
||
}
|
||
|
||
function refreshPoseSelect() {
|
||
// Popola i due dropdown filtrati per side (right → DX, left → SX).
|
||
for (const side of ['right', 'left']) {
|
||
const sel = $(POSE_UI[side].select);
|
||
if (!sel) continue;
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">(seleziona)</option>';
|
||
for (const name of Object.keys(state.saved_poses).sort()) {
|
||
const p = state.saved_poses[name];
|
||
if (!p || p.side !== side) continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = name;
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.value = cur;
|
||
}
|
||
}
|
||
|
||
// ----- selezione polso via click -----
|
||
// ----- Tag locator -----
|
||
// Workflow:
|
||
// 1) flagga "Modalità tag click" → entra in placement mode (click su URDF
|
||
// mesh piazza la crocetta, click su sfere polso disabilitato).
|
||
// 2) seleziona tag dal dropdown.
|
||
// 3) click sulla mesh URDF → crocetta arancione + gizmo XYZ agganciato per
|
||
// fine-tuning lungo gli assi pelvis.
|
||
// 4) bottone "Salva" → POST /api/pose_3d/tag_offset/save (pelvis frame).
|
||
// TODO(orientation): per ora salviamo solo CdM del tag. Estensione futura:
|
||
// catturare anche orientamento via gizmo di rotazione sulla crocetta, oppure
|
||
// con 3-click (normale + up).
|
||
const TAG_OPTIONS = {
|
||
'10:40:wrist_sx': { tag_id: 10, tag_size_mm: 40, tag_label: 'wrist_sx' },
|
||
'1:20:nuwa_dx': { tag_id: 1, tag_size_mm: 20, tag_label: 'nuwa_dx' },
|
||
};
|
||
|
||
function getActiveTagInfo() {
|
||
const v = $('p3TagSelect')?.value || '10:40:wrist_sx';
|
||
return TAG_OPTIONS[v] || TAG_OPTIONS['10:40:wrist_sx'];
|
||
}
|
||
|
||
function isTagPlacementMode() {
|
||
return $('p3TagPlaceMode')?.checked === true;
|
||
}
|
||
|
||
function makeTagMarker(sizeMm) {
|
||
// 3 segmenti XYZ centrati. Lunghezza = tag_size così la crocetta scala col tag.
|
||
const halfM = (sizeMm / 1000) / 2;
|
||
const geom = new THREE.BufferGeometry();
|
||
const verts = new Float32Array([
|
||
-halfM, 0, 0, +halfM, 0, 0,
|
||
0, -halfM, 0, 0, +halfM, 0,
|
||
0, 0, -halfM, 0, 0, +halfM,
|
||
]);
|
||
geom.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||
// Arancione brillante, renderOrder alto + depthTest off → sempre visibile.
|
||
const mat = new THREE.LineBasicMaterial({ color: 0xff8800, depthTest: false });
|
||
const seg = new THREE.LineSegments(geom, mat);
|
||
seg.renderOrder = 999;
|
||
return seg;
|
||
}
|
||
|
||
function attachGizmoToTagMarker() {
|
||
if (!state.tag_locator.marker) return;
|
||
const { transform } = state.three;
|
||
transform.setMode('translate');
|
||
transform.attach(state.tag_locator.marker);
|
||
}
|
||
|
||
function placeTagMarker(positionWorld, sizeMm) {
|
||
if (state.tag_locator.marker) {
|
||
if (state.three.transform.object === state.tag_locator.marker) {
|
||
state.three.transform.detach();
|
||
}
|
||
state.three.scene.remove(state.tag_locator.marker);
|
||
state.tag_locator.marker.geometry.dispose();
|
||
state.tag_locator.marker.material.dispose();
|
||
}
|
||
const m = makeTagMarker(sizeMm);
|
||
m.position.copy(positionWorld);
|
||
state.three.scene.add(m);
|
||
state.tag_locator.marker = m;
|
||
// Snapshot giunti al momento del click (resta valido anche dopo gizmo-drag).
|
||
state.tag_locator.joints_at_place = state.joints ? { ...state.joints } : null;
|
||
updateTagMarkerReadout();
|
||
const sv = $('p3TagSaveBtn'); if (sv) sv.disabled = false;
|
||
const cv = $('p3TagClearBtn'); if (cv) cv.disabled = false;
|
||
const st = $('p3TagStatus');
|
||
if (st) {
|
||
const qInfo = state.tag_locator.joints_at_place
|
||
? ` · q snapshot=${Object.keys(state.tag_locator.joints_at_place).length} giunti`
|
||
: ' · ⚠ G1 offline → niente q snapshot';
|
||
st.textContent = 'crocetta piazzata' + qInfo + ' · gizmo per fine-tune · Salva quando ok';
|
||
st.style.color = '';
|
||
}
|
||
attachGizmoToTagMarker();
|
||
}
|
||
|
||
function updateTagMarkerReadout() {
|
||
if (!state.tag_locator.marker) return;
|
||
const p = state.tag_locator.marker.position;
|
||
state.tag_locator.position_m = [p.x, p.y, p.z];
|
||
$('p3TagPosX').value = (p.x * 1000).toFixed(1);
|
||
$('p3TagPosY').value = (p.y * 1000).toFixed(1);
|
||
$('p3TagPosZ').value = (p.z * 1000).toFixed(1);
|
||
}
|
||
|
||
function tryPlaceTagFromClick(raycaster) {
|
||
if (!state.robot) {
|
||
$('p3TagStatus').textContent = 'URDF non ancora caricata, riprova tra un attimo';
|
||
$('p3TagStatus').style.color = 'var(--danger)';
|
||
return false;
|
||
}
|
||
const hits = raycaster.intersectObject(state.robot, true);
|
||
const meshHit = hits.find(h => h.object && h.object.isMesh && h.object.visible);
|
||
if (!meshHit) {
|
||
$('p3TagStatus').textContent = 'click fuori dalla mesh del robot · clicca direttamente sul modello';
|
||
$('p3TagStatus').style.color = 'var(--dim)';
|
||
return false;
|
||
}
|
||
const info = getActiveTagInfo();
|
||
placeTagMarker(meshHit.point, info.tag_size_mm);
|
||
return true;
|
||
}
|
||
|
||
function clearTagMarker() {
|
||
if (!state.tag_locator.marker) return;
|
||
if (state.three.transform.object === state.tag_locator.marker) {
|
||
state.three.transform.detach();
|
||
}
|
||
state.three.scene.remove(state.tag_locator.marker);
|
||
state.tag_locator.marker.geometry.dispose();
|
||
state.tag_locator.marker.material.dispose();
|
||
state.tag_locator.marker = null;
|
||
state.tag_locator.position_m = null;
|
||
state.tag_locator.joints_at_place = null;
|
||
for (const id of ['p3TagPosX','p3TagPosY','p3TagPosZ']) { const el = $(id); if (el) el.value = ''; }
|
||
$('p3TagSaveBtn').disabled = true;
|
||
$('p3TagClearBtn').disabled = true;
|
||
$('p3TagStatus').textContent = isTagPlacementMode()
|
||
? 'click sul modello 3D per piazzare la crocetta'
|
||
: 'attiva la modalità tag click sopra, poi clicca sul modello 3D';
|
||
$('p3TagStatus').style.color = '';
|
||
}
|
||
|
||
async function onTagSave() {
|
||
if (!state.tag_locator.position_m) return;
|
||
const info = getActiveTagInfo();
|
||
const sv = $('p3TagSaveBtn');
|
||
const st = $('p3TagStatus');
|
||
if (sv) sv.disabled = true;
|
||
if (st) { st.textContent = 'salvataggio…'; st.style.color = 'var(--dim)'; }
|
||
try {
|
||
const res = await jsonFetch('/api/pose_3d/tag_offset/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tag_id: info.tag_id,
|
||
tag_size_mm: info.tag_size_mm,
|
||
tag_label: info.tag_label,
|
||
position_pelvis_m: state.tag_locator.position_m,
|
||
joints_at_save_rad: state.tag_locator.joints_at_place,
|
||
}),
|
||
});
|
||
if (st) {
|
||
const arch = res.archived_from ? ' · prev → archive' : '';
|
||
const qBit = state.tag_locator.joints_at_place
|
||
? ` · q=${Object.keys(state.tag_locator.joints_at_place).length} giunti`
|
||
: ' · ⚠ niente q (G1 offline)';
|
||
st.textContent = `salvato${qBit}${arch}`;
|
||
st.style.color = 'var(--ok)';
|
||
}
|
||
} catch (e) {
|
||
if (st) { st.textContent = `errore: ${e.message}`; st.style.color = 'var(--danger)'; }
|
||
} finally {
|
||
if (sv) sv.disabled = false;
|
||
}
|
||
}
|
||
|
||
function setupTagLocator() {
|
||
// Toggle modalita' tag click → aggiorna stato + status testo, e aggancia
|
||
// / sgancia il gizmo dalla crocetta esistente.
|
||
$('p3TagPlaceMode')?.addEventListener('change', () => {
|
||
if (isTagPlacementMode()) {
|
||
$('p3TagStatus').textContent = state.tag_locator.marker
|
||
? 'modalità attiva · click per spostare la crocetta · gizmo per fine-tuning'
|
||
: 'modalità attiva · click sul modello 3D per piazzare';
|
||
$('p3TagStatus').style.color = 'var(--ok)';
|
||
if (state.tag_locator.marker) attachGizmoToTagMarker();
|
||
} else {
|
||
$('p3TagStatus').textContent = 'modalità disattivata · attiva sopra per piazzare';
|
||
$('p3TagStatus').style.color = '';
|
||
// se il gizmo era sulla crocetta, lascialo libero (l'utente magari vuole
|
||
// tornare a editare il polso senza dover ri-cliccare).
|
||
if (state.three.transform.object === state.tag_locator.marker) {
|
||
state.three.transform.detach();
|
||
}
|
||
}
|
||
});
|
||
// Cambio tag → ricreo crocetta con nuova dimensione, mantenendo link.
|
||
$('p3TagSelect')?.addEventListener('change', () => {
|
||
if (state.tag_locator.marker && state.tag_locator.position_m) {
|
||
const info = getActiveTagInfo();
|
||
const pos = new THREE.Vector3(...state.tag_locator.position_m);
|
||
placeTagMarker(pos, info.tag_size_mm, state.tag_locator.link_object);
|
||
}
|
||
});
|
||
$('p3TagSaveBtn')?.addEventListener('click', onTagSave);
|
||
$('p3TagClearBtn')?.addEventListener('click', clearTagMarker);
|
||
// Listener gizmo: aggiorna lettura X/Y/Z quando trascini la crocetta lungo gli assi.
|
||
state.three.transform.addEventListener('change', () => {
|
||
if (state.three.transform.object === state.tag_locator.marker) {
|
||
updateTagMarkerReadout();
|
||
}
|
||
});
|
||
}
|
||
|
||
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);
|
||
// Tag-click mode ATTIVA → click su URDF mesh piazza la crocetta.
|
||
// In questa modalita' i click sulle sfere polso restano disabilitati per
|
||
// non confondere il workflow (l'utente sa che e' in placement mode).
|
||
if (isTagPlacementMode()) {
|
||
tryPlaceTagFromClick(raycaster);
|
||
return;
|
||
}
|
||
// Modalita' normale: click sulle sfere polso → attiva il gizmo sul ghost
|
||
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', () => {
|
||
// Guardia: questo listener gestisce solo le pose dei polsi (ghost).
|
||
// Quando il gizmo e' attaccato alla crocetta tag locator, c'e' un
|
||
// listener separato in setupTagLocator() che fa l'update giusto.
|
||
const attached = transform.object;
|
||
if (!attached) return;
|
||
if (attached !== state.ghosts.right && attached !== state.ghosts.left) return;
|
||
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 (sezioni separate DX e SX) -----
|
||
const POSE_UI = {
|
||
right: { select: 'p3PoseSelectR', go: 'p3PoseGoBtnR', name: 'p3PoseNameR',
|
||
save: 'p3PoseSaveBtnR', del: 'p3PoseDelBtnR', label: 'DX' },
|
||
left: { select: 'p3PoseSelectL', go: 'p3PoseGoBtnL', name: 'p3PoseNameL',
|
||
save: 'p3PoseSaveBtnL', del: 'p3PoseDelBtnL', label: 'SX' },
|
||
};
|
||
|
||
async function onSavePose(side) {
|
||
const ui = POSE_UI[side];
|
||
const name = ($(ui.name).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 pose = state.target[side] || state.current[side];
|
||
if (!pose) { alert(`Nessuna posa ${ui.label} 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,
|
||
}),
|
||
});
|
||
$(ui.name).value = '';
|
||
await pollState(); // refresh dropdown
|
||
$(ui.select).value = name;
|
||
} catch (e) {
|
||
showError(`Salvataggio fallito: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function onGoPose(side) {
|
||
const ui = POSE_UI[side];
|
||
const name = $(ui.select).value;
|
||
if (!name) { alert(`Seleziona una posa ${ui.label} dal dropdown`); return; }
|
||
if (state.mode === 'jog') {
|
||
if (!confirm(`Eseguire posa "${name}" (${ui.label}) SUL ROBOT REALE?`)) 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}" (${ui.label}) 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(side) {
|
||
const ui = POSE_UI[side];
|
||
const name = $(ui.select).value;
|
||
if (!name) { alert(`Seleziona una posa ${ui.label} 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() {
|
||
for (const side of ['right', 'left']) {
|
||
const ui = POSE_UI[side];
|
||
$(ui.save).addEventListener('click', () => onSavePose(side));
|
||
$(ui.go).addEventListener('click', () => onGoPose(side));
|
||
$(ui.del).addEventListener('click', () => onDeletePose(side));
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// UCS al pose hand-eye calibrato della D435i (frame pelvis).
|
||
// La matrice in config e' la convenzione `T @ p_pelvis = p_optical` (cioe'
|
||
// trasforma punti DAL pelvis AL frame ottico camera). Per posizionare la
|
||
// camera nel pelvis frame ci serve l'INVERSA.
|
||
function applyCalibratedD435UCS() {
|
||
if (!state.three) return;
|
||
const cfg = state.clouds_cfg && state.clouds_cfg.d435;
|
||
const m4 = cfg && cfg.transform_pelvis_4x4;
|
||
if (!Array.isArray(m4) || m4.length !== 4) return;
|
||
|
||
const M_pelvis_to_optical = new THREE.Matrix4();
|
||
M_pelvis_to_optical.set(
|
||
m4[0][0], m4[0][1], m4[0][2], m4[0][3],
|
||
m4[1][0], m4[1][1], m4[1][2], m4[1][3],
|
||
m4[2][0], m4[2][1], m4[2][2], m4[2][3],
|
||
m4[3][0], m4[3][1], m4[3][2], m4[3][3],
|
||
);
|
||
// Pose della camera in pelvis frame = inversa.
|
||
const M_optical_in_pelvis = M_pelvis_to_optical.clone().invert();
|
||
|
||
if (!state.d435_calib_ucs) {
|
||
const grp = new THREE.Group();
|
||
// UCS con assi piu' spessi/lunghi per distinguerlo da quelli URDF
|
||
const ucs = makeUCS(0.12);
|
||
grp.add(ucs);
|
||
// Pallina centrale arancione = identifica visivamente la D435i calibrata
|
||
const dot = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.012, 12, 8),
|
||
new THREE.MeshBasicMaterial({ color: 0xff6f4d }),
|
||
);
|
||
grp.add(dot);
|
||
const label = makeLabelSprite('D435i hand-eye', '#ff6f4d');
|
||
label.scale.set(0.14, 0.06, 1); // un po' piu' larga del default
|
||
label.position.set(0, 0, 0.16);
|
||
grp.add(label);
|
||
grp.matrixAutoUpdate = false;
|
||
state.three.scene.add(grp);
|
||
state.d435_calib_ucs = grp;
|
||
}
|
||
state.d435_calib_ucs.matrix.copy(M_optical_in_pelvis);
|
||
state.d435_calib_ucs.matrixWorldNeedsUpdate = true;
|
||
applyD435CalibUCSVisibility();
|
||
}
|
||
|
||
function applyD435CalibUCSVisibility() {
|
||
if (!state.d435_calib_ucs) return;
|
||
const visible = $('p3ShowD435CalibUCS')?.checked !== false;
|
||
state.d435_calib_ucs.visible = visible;
|
||
}
|
||
|
||
// ----- Cloud cross-camera (Nuwa + D435i) -----
|
||
const CLOUD_COLORS = { nuwa: 0xffd54f, d435: 0x4dd0e1 };
|
||
|
||
function readCloudSize(camKey) {
|
||
// Valore UI in mm; PointsMaterial in modalita' sizeAttenuation:true usa
|
||
// world units (metri) → divide per 1000.
|
||
const id = camKey === 'nuwa' ? 'p3NuwaCloudSize' : 'p3D435CloudSize';
|
||
const mm = parseFloat($(id)?.value);
|
||
return Number.isFinite(mm) && mm > 0 ? mm / 1000 : 0.005;
|
||
}
|
||
|
||
function applyCloudSize(camKey) {
|
||
const pts = state.cloud_points[camKey];
|
||
if (!pts) return;
|
||
pts.material.size = readCloudSize(camKey);
|
||
pts.material.needsUpdate = true;
|
||
}
|
||
|
||
function getCloudParent(camKey) {
|
||
// D435i in modalita' hand-eye: child della scene (pelvis = origin scena)
|
||
if (camKey === 'd435' && useD435Handeye()) {
|
||
return state.three && state.three.scene;
|
||
}
|
||
if (!state.robot) return null;
|
||
const linkName = camKey === 'nuwa' ? 'right_wrist_yaw_link' : 'd435_link';
|
||
if (state.robot.links && state.robot.links[linkName]) return state.robot.links[linkName];
|
||
let found = null;
|
||
state.robot.traverse(o => { if (o.name === linkName) found = o; });
|
||
return found;
|
||
}
|
||
|
||
function useD435Handeye() {
|
||
return $('p3D435UseHandeye')?.checked === true;
|
||
}
|
||
|
||
// Restituisce la Matrix4 M_optical_in_pelvis dalla calibrazione hand-eye,
|
||
// oppure null se la matrice non e' presente in config.
|
||
function getD435HandeyeMatrix() {
|
||
const cfg = state.clouds_cfg && state.clouds_cfg.d435;
|
||
const m4 = cfg && cfg.transform_pelvis_4x4;
|
||
if (!Array.isArray(m4) || m4.length !== 4) return null;
|
||
const M = new THREE.Matrix4().set(
|
||
m4[0][0], m4[0][1], m4[0][2], m4[0][3],
|
||
m4[1][0], m4[1][1], m4[1][2], m4[1][3],
|
||
m4[2][0], m4[2][1], m4[2][2], m4[2][3],
|
||
m4[3][0], m4[3][1], m4[3][2], m4[3][3],
|
||
);
|
||
return M.invert(); // pelvis→optical → optical pose in pelvis
|
||
}
|
||
|
||
// Trasformazione optical→link da config: per nuwa eredita il
|
||
// right_wrist_camera (offset+rpy), per d435 usa clouds.d435.
|
||
function getCloudTransform(camKey) {
|
||
const c = state.clouds_cfg || {};
|
||
if (camKey === 'nuwa') {
|
||
const nuwa = state.nuwa_cfg || {};
|
||
const ex = (c.nuwa && c.nuwa.extra_offset_m) || [0,0,0];
|
||
const er = (c.nuwa && c.nuwa.extra_rpy_deg) || [0,0,0];
|
||
const base_off = nuwa.offset_m || [0,0,0.1];
|
||
const base_rpy = nuwa.rpy_deg || [0,0,0];
|
||
return {
|
||
offset_m: [base_off[0]+ex[0], base_off[1]+ex[1], base_off[2]+ex[2]],
|
||
rpy_deg: [base_rpy[0]+er[0], base_rpy[1]+er[1], base_rpy[2]+er[2]],
|
||
};
|
||
}
|
||
const d = c.d435 || {};
|
||
return { offset_m: d.offset_m || [0,0,0], rpy_deg: d.rpy_deg || [-90,0,-90] };
|
||
}
|
||
|
||
function buildOrUpdateCloud(camKey, pointsFlat) {
|
||
const parent = getCloudParent(camKey);
|
||
if (!parent) { console.warn('[pose_3d] cloud parent link non trovato per', camKey); return null; }
|
||
const N = pointsFlat.length;
|
||
const flat = new Float32Array(N * 3);
|
||
const colors = new Float32Array(N * 3);
|
||
const hasRgb = pointsFlat.length > 0 && pointsFlat[0].length >= 6;
|
||
for (let i = 0; i < N; i++) {
|
||
flat[3*i] = pointsFlat[i][0];
|
||
flat[3*i+1] = pointsFlat[i][1];
|
||
flat[3*i+2] = pointsFlat[i][2];
|
||
if (hasRgb) {
|
||
colors[3*i] = pointsFlat[i][3] / 255.0;
|
||
colors[3*i+1] = pointsFlat[i][4] / 255.0;
|
||
colors[3*i+2] = pointsFlat[i][5] / 255.0;
|
||
}
|
||
}
|
||
let pts = state.cloud_points[camKey];
|
||
if (pts) {
|
||
pts.geometry.dispose();
|
||
pts.geometry = new THREE.BufferGeometry();
|
||
pts.geometry.setAttribute('position', new THREE.BufferAttribute(flat, 3));
|
||
if (hasRgb) pts.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
pts.material.vertexColors = hasRgb;
|
||
pts.material.needsUpdate = true;
|
||
} else {
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.BufferAttribute(flat, 3));
|
||
if (hasRgb) geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
// RGB reali se disponibili, altrimenti colore camera-specific come fallback.
|
||
// sizeAttenuation:true → la dimensione e' in metri e scala con la distanza.
|
||
const mat = new THREE.PointsMaterial({
|
||
color: hasRgb ? 0xffffff : CLOUD_COLORS[camKey],
|
||
size: readCloudSize(camKey), sizeAttenuation: true,
|
||
vertexColors: hasRgb,
|
||
});
|
||
pts = new THREE.Points(geom, mat);
|
||
pts.userData.camKey = camKey;
|
||
state.cloud_points[camKey] = pts;
|
||
}
|
||
// Log diagnostico
|
||
let xmin=Infinity,xmax=-Infinity,ymin=Infinity,ymax=-Infinity,zmin=Infinity,zmax=-Infinity;
|
||
for (let i = 0; i < N; i++) {
|
||
const x=flat[3*i], y=flat[3*i+1], z=flat[3*i+2];
|
||
if (x<xmin)xmin=x; if (x>xmax)xmax=x;
|
||
if (y<ymin)ymin=y; if (y>ymax)ymax=y;
|
||
if (z<zmin)zmin=z; if (z>zmax)zmax=z;
|
||
}
|
||
console.log(`[pose_3d] cloud ${camKey}: ${N} pts parent=${parent.name}`,
|
||
`X[${xmin.toFixed(2)},${xmax.toFixed(2)}]`,
|
||
`Y[${ymin.toFixed(2)},${ymax.toFixed(2)}]`,
|
||
`Z[${zmin.toFixed(2)},${zmax.toFixed(2)}]`);
|
||
// (Ri-)attacca al parent corretto e applica transform optical→link
|
||
if (pts.parent !== parent) parent.add(pts);
|
||
applyCloudTransform(camKey);
|
||
applyCloudVisibility();
|
||
return pts;
|
||
}
|
||
|
||
function applyCloudVisibility() {
|
||
const showNuwa = $('p3ShowNuwaCloud')?.checked === true;
|
||
const showD435 = $('p3ShowD435Cloud')?.checked === true;
|
||
if (state.cloud_points.nuwa) state.cloud_points.nuwa.visible = showNuwa;
|
||
if (state.cloud_points.d435) state.cloud_points.d435.visible = showD435;
|
||
}
|
||
|
||
// Applica position+rpy+mirror sui punti del cloud `camKey`. Il mirror (sign
|
||
// flip per asse) avviene IN FRAME OTTICO grazie all'ordine T·R·S di Three.js
|
||
// (lo scale e' la prima operazione applicata al punto locale).
|
||
function applyCloudTransform(camKey) {
|
||
const pts = state.cloud_points[camKey];
|
||
if (!pts) return;
|
||
const sv = readCloudScaleVec(camKey); // flip × scale (frame ottico)
|
||
|
||
// Modalita' hand-eye per D435i: usa direttamente la matrice 4x4 calibrata
|
||
if (camKey === 'd435' && useD435Handeye()) {
|
||
const M = getD435HandeyeMatrix();
|
||
if (M) {
|
||
pts.matrixAutoUpdate = false;
|
||
const S = new THREE.Matrix4().makeScale(sv[0], sv[1], sv[2]);
|
||
pts.matrix.copy(M).multiply(S);
|
||
pts.matrixWorldNeedsUpdate = true;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Modalita' URDF link + offset/rpy (default)
|
||
pts.matrixAutoUpdate = true;
|
||
const t = getCloudTransform(camKey);
|
||
pts.position.set(t.offset_m[0], t.offset_m[1], t.offset_m[2]);
|
||
const e = new THREE.Euler(
|
||
t.rpy_deg[0] * Math.PI/180,
|
||
t.rpy_deg[1] * Math.PI/180,
|
||
t.rpy_deg[2] * Math.PI/180,
|
||
'XYZ',
|
||
);
|
||
pts.quaternion.setFromEuler(e);
|
||
pts.scale.set(sv[0], sv[1], sv[2]);
|
||
}
|
||
|
||
function readCloudFlip(camKey) {
|
||
const tag = camKey === 'nuwa' ? 'Nuwa' : 'D435';
|
||
return [
|
||
$(`p3Flip${tag}X`)?.checked ? -1 : 1,
|
||
$(`p3Flip${tag}Y`)?.checked ? -1 : 1,
|
||
$(`p3Flip${tag}Z`)?.checked ? -1 : 1,
|
||
];
|
||
}
|
||
|
||
// Scale per asse (workaround bug intrinsics-mismatch). Per D435i applichiamo
|
||
// fx_ir/fx_rgb ≈ 0.65 su X e Y; Z corretto. Per Nuwa nessuna correzione.
|
||
function readCloudScale(camKey) {
|
||
if (camKey !== 'd435') return [1, 1, 1];
|
||
const v = parseFloat($('p3D435ScaleXY')?.value);
|
||
const s = Number.isFinite(v) && v > 0 ? v : 1;
|
||
return [s, s, 1];
|
||
}
|
||
|
||
// Combina flip (±1) e correzione scale per asse.
|
||
function readCloudScaleVec(camKey) {
|
||
const f = readCloudFlip(camKey);
|
||
const sc = readCloudScale(camKey);
|
||
return [f[0] * sc[0], f[1] * sc[1], f[2] * sc[2]];
|
||
}
|
||
|
||
// Snapshot dei parametri UI Cloud cross-camera. Mantenuto separato da
|
||
// acquireClouds() perche' usato anche dal save persistente (memorizza la
|
||
// configurazione attiva all'ultima Acquisisci).
|
||
function readCloudParamsFromUI() {
|
||
return {
|
||
step: parseInt($('p3CloudStep').value, 10) || 8,
|
||
max_depth_mm: parseInt($('p3CloudMaxDepth').value, 10) || 2000,
|
||
nuwa_size_mm: parseFloat($('p3NuwaCloudSize').value) || 5,
|
||
d435_size_mm: parseFloat($('p3D435CloudSize').value) || 5,
|
||
d435_scale_xy: parseFloat($('p3D435ScaleXY').value) || 1.0,
|
||
flip_nuwa: [
|
||
$('p3FlipNuwaX').checked,
|
||
$('p3FlipNuwaY').checked,
|
||
$('p3FlipNuwaZ').checked,
|
||
],
|
||
flip_d435: [
|
||
$('p3FlipD435X').checked,
|
||
$('p3FlipD435Y').checked,
|
||
$('p3FlipD435Z').checked,
|
||
],
|
||
d435_use_handeye: $('p3D435UseHandeye').checked,
|
||
};
|
||
}
|
||
|
||
async function saveCloudParamsToConfig() {
|
||
const params = readCloudParamsFromUI();
|
||
try {
|
||
const r = await fetch('/api/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pose_3d: { cloud_params: params } }),
|
||
});
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
} catch (e) {
|
||
console.warn('[pose_3d] save cloud_params failed:', e?.message || e);
|
||
}
|
||
}
|
||
|
||
// Popola UI Cloud cross-camera dai params salvati. Eseguito una volta sola
|
||
// (state.cloud_params_populated) per non sovrascrivere edit utente in corso.
|
||
function applyCloudParamsFromCfg(cloud_params) {
|
||
if (state.cloud_params_populated) return;
|
||
if (!cloud_params || Object.keys(cloud_params).length === 0) return;
|
||
const set = (id, val) => { const el = $(id); if (el && val !== undefined && val !== null) el.value = String(val); };
|
||
const setCb = (id, val) => { const el = $(id); if (el && typeof val === 'boolean') el.checked = val; };
|
||
set('p3CloudStep', cloud_params.step);
|
||
set('p3CloudMaxDepth', cloud_params.max_depth_mm);
|
||
set('p3NuwaCloudSize', cloud_params.nuwa_size_mm);
|
||
set('p3D435CloudSize', cloud_params.d435_size_mm);
|
||
set('p3D435ScaleXY', cloud_params.d435_scale_xy);
|
||
const fn = cloud_params.flip_nuwa || [];
|
||
setCb('p3FlipNuwaX', fn[0]); setCb('p3FlipNuwaY', fn[1]); setCb('p3FlipNuwaZ', fn[2]);
|
||
const fd = cloud_params.flip_d435 || [];
|
||
setCb('p3FlipD435X', fd[0]); setCb('p3FlipD435Y', fd[1]); setCb('p3FlipD435Z', fd[2]);
|
||
setCb('p3D435UseHandeye', cloud_params.d435_use_handeye);
|
||
state.cloud_params_populated = true;
|
||
}
|
||
|
||
async function acquireClouds() {
|
||
// Clamp dei parametri: step oltre ~32 fa 422 sul server pointcloud.
|
||
let step = parseInt($('p3CloudStep').value, 10);
|
||
if (!Number.isFinite(step) || step < 1) step = 8;
|
||
if (step > 32) step = 32;
|
||
$('p3CloudStep').value = String(step);
|
||
let max_depth_mm = parseInt($('p3CloudMaxDepth').value, 10);
|
||
if (!Number.isFinite(max_depth_mm) || max_depth_mm < 100) max_depth_mm = 2000;
|
||
if (max_depth_mm > 5000) max_depth_mm = 5000;
|
||
$('p3CloudMaxDepth').value = String(max_depth_mm);
|
||
$('p3CloudStatus').textContent = 'acquisizione…';
|
||
$('p3CloudStatus').style.color = 'var(--dim)';
|
||
try {
|
||
const r = await jsonFetch(
|
||
`/api/pose_3d/clouds?step=${step}&max_depth_mm=${max_depth_mm}`,
|
||
);
|
||
const parts = [];
|
||
for (const cam of ['nuwa', 'd435']) {
|
||
const c = r[cam];
|
||
if (!c || c.error) {
|
||
parts.push(`${cam}: ${c?.error || 'no data'}`);
|
||
continue;
|
||
}
|
||
buildOrUpdateCloud(cam, c.points);
|
||
parts.push(`${cam}: ${c.n} pts`);
|
||
}
|
||
$('p3CloudStatus').textContent = parts.join(' · ');
|
||
$('p3CloudStatus').style.color = 'var(--ok)';
|
||
// Memorizza i parametri usati in questa acquisizione (best-effort,
|
||
// non blocca il rendering del cloud se il save fallisce).
|
||
saveCloudParamsToConfig();
|
||
// Nuova immagine acquisita: se l'overlay tag e' attivo ri-esegui la
|
||
// detection una volta sola, sul frame appena acquisito.
|
||
if ($('p3ShowFrontTags')?.checked) pollFrontTags();
|
||
} catch (e) {
|
||
$('p3CloudStatus').textContent = `errore: ${e.message}`;
|
||
$('p3CloudStatus').style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
function clearClouds() {
|
||
for (const cam of ['nuwa', 'd435']) {
|
||
const pts = state.cloud_points[cam];
|
||
if (pts) {
|
||
pts.parent?.remove(pts);
|
||
pts.geometry.dispose();
|
||
state.cloud_points[cam] = null;
|
||
}
|
||
}
|
||
$('p3CloudStatus').textContent = '—';
|
||
$('p3CloudStatus').style.color = '';
|
||
}
|
||
|
||
// ----- Front-camera tag finder -----
|
||
// Overlay sulla scena 3D dei marker ArUco visibili dal D435i frontale.
|
||
// Per ogni tag: LineLoop dei 4 corner + sprite label con id, edge_mm
|
||
// (misurato dal depth sensor, non da pnp) e distanza camera→centro.
|
||
// Stesso parent + transform del d435 cloud, cosi' l'overlay segue la
|
||
// camera in modalita' URDF link e in modalita' hand-eye.
|
||
|
||
const FRONT_TAG_COLOR = 0x4dd0e1;
|
||
const FRONT_TAG_LABEL_COLOR = '#4dd0e1';
|
||
|
||
// Label compatta auto-sized: canvas largo quanto il testo, font piccolo,
|
||
// sprite proporzionato al canvas. Evita il clipping di makeLabelSprite che
|
||
// usa canvas fisso 128x64 con font 36px (i nostri "id=20 · 101.4mm · ..."
|
||
// uscivano dal box).
|
||
function makeCompactLabel(text, color = FRONT_TAG_LABEL_COLOR) {
|
||
const fontPx = 24;
|
||
const padX = 8, padY = 4;
|
||
// Misura testo su un canvas temporaneo
|
||
const probe = document.createElement('canvas').getContext('2d');
|
||
probe.font = `${fontPx}px sans-serif`;
|
||
const textW = Math.ceil(probe.measureText(text).width);
|
||
const w = textW + padX * 2;
|
||
const h = fontPx + padY * 2;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = 'rgba(0,0,0,0.65)';
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.fillStyle = color;
|
||
ctx.font = `${fontPx}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, w / 2, h / 2);
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false });
|
||
const sprite = new THREE.Sprite(mat);
|
||
// Scala in metri: ~4 cm di altezza, larghezza proporzionale all'aspect
|
||
const targetH = 0.04;
|
||
sprite.scale.set(targetH * (w / h), targetH, 1);
|
||
return sprite;
|
||
}
|
||
|
||
function applyCloudLikeTransform(obj, camKey) {
|
||
const sv = readCloudScaleVec(camKey);
|
||
if (camKey === 'd435' && useD435Handeye()) {
|
||
const M = getD435HandeyeMatrix();
|
||
if (M) {
|
||
obj.matrixAutoUpdate = false;
|
||
const S = new THREE.Matrix4().makeScale(sv[0], sv[1], sv[2]);
|
||
obj.matrix.copy(M).multiply(S);
|
||
obj.matrixWorldNeedsUpdate = true;
|
||
return;
|
||
}
|
||
}
|
||
obj.matrixAutoUpdate = true;
|
||
const t = getCloudTransform(camKey);
|
||
obj.position.set(t.offset_m[0], t.offset_m[1], t.offset_m[2]);
|
||
const e = new THREE.Euler(
|
||
t.rpy_deg[0] * Math.PI / 180,
|
||
t.rpy_deg[1] * Math.PI / 180,
|
||
t.rpy_deg[2] * Math.PI / 180,
|
||
'XYZ',
|
||
);
|
||
obj.quaternion.setFromEuler(e);
|
||
obj.scale.set(sv[0], sv[1], sv[2]);
|
||
}
|
||
|
||
function clearFrontTagsGroup() {
|
||
const grp = state.front_tags.group;
|
||
if (!grp) return;
|
||
if (grp.parent) grp.parent.remove(grp);
|
||
grp.traverse(o => {
|
||
if (o.geometry) o.geometry.dispose();
|
||
if (o.material) {
|
||
if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
|
||
else o.material.dispose();
|
||
}
|
||
});
|
||
state.front_tags.group = null;
|
||
}
|
||
|
||
function addOneFrontTag(grp, t) {
|
||
const c = t.corners_3d_optical_m;
|
||
if (!Array.isArray(c) || c.length !== 4) return;
|
||
if (c.some(p => !p || p.some(v => !Number.isFinite(v)))) return;
|
||
|
||
// LineLoop (5 verts = close)
|
||
const v = new Float32Array(5 * 3);
|
||
for (let i = 0; i < 4; i++) { v[3*i]=c[i][0]; v[3*i+1]=c[i][1]; v[3*i+2]=c[i][2]; }
|
||
v[12] = c[0][0]; v[13] = c[0][1]; v[14] = c[0][2];
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.BufferAttribute(v, 3));
|
||
const mat = new THREE.LineBasicMaterial({ color: FRONT_TAG_COLOR });
|
||
grp.add(new THREE.Line(geom, mat));
|
||
|
||
// Label "id=X · NNmm · d=DDDmm" leggermente offset dal centro
|
||
const edge_s = Number.isFinite(t.edge_mm) ? `${t.edge_mm.toFixed(1)}mm` : '?';
|
||
const dist_s = Number.isFinite(t.distance_m) ? `${(t.distance_m * 1000).toFixed(0)}mm` : '?';
|
||
const sprite = makeCompactLabel(`id=${t.id} · ${edge_s} · d=${dist_s}`);
|
||
const ctr = t.center_3d_optical_m;
|
||
if (ctr && ctr.every(Number.isFinite)) {
|
||
// offset in -Y ottico = sopra (Y down nella convenzione OpenCV)
|
||
sprite.position.set(ctr[0], ctr[1] - 0.04, ctr[2]);
|
||
}
|
||
grp.add(sprite);
|
||
}
|
||
|
||
function renderFrontTags(tags) {
|
||
clearFrontTagsGroup();
|
||
if (!tags || tags.length === 0) return;
|
||
const parent = getCloudParent('d435');
|
||
if (!parent) {
|
||
console.warn('[pose_3d] front_tags: parent d435 non disponibile');
|
||
return;
|
||
}
|
||
const grp = new THREE.Group();
|
||
grp.name = 'front_tags_group';
|
||
applyCloudLikeTransform(grp, 'd435');
|
||
parent.add(grp);
|
||
state.front_tags.group = grp;
|
||
for (const t of tags) addOneFrontTag(grp, t);
|
||
}
|
||
|
||
async function pollFrontTags() {
|
||
const ids = ($('p3FrontTagIds')?.value || '0-49').trim();
|
||
const status = $('p3FrontTagStatus');
|
||
try {
|
||
const r = await jsonFetch(`/api/pose_3d/front_tags?ids=${encodeURIComponent(ids)}`);
|
||
const tags = r.tags || [];
|
||
renderFrontTags(tags);
|
||
if (status) {
|
||
const ms = r.timings_ms?.total;
|
||
status.textContent = tags.length
|
||
? `${tags.length} tag · ids: ${tags.map(t => t.id).join(',')}${ms != null ? ' · ' + ms.toFixed(0) + 'ms' : ''}`
|
||
: 'nessun tag visibile';
|
||
}
|
||
} catch (e) {
|
||
if (status) status.textContent = `errore: ${e?.message || e}`;
|
||
}
|
||
}
|
||
|
||
function bindFrontTagFinder() {
|
||
const cb = $('p3ShowFrontTags');
|
||
if (!cb) return;
|
||
cb.addEventListener('change', () => {
|
||
if (cb.checked) {
|
||
// Refresh immediato senza aspettare il prossimo tick di pollState
|
||
pollFrontTags();
|
||
} else {
|
||
clearFrontTagsGroup();
|
||
const s = $('p3FrontTagStatus');
|
||
if (s) s.textContent = '—';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
function bindCloudControls() {
|
||
$('p3CloudAcquireBtn')?.addEventListener('click', acquireClouds);
|
||
$('p3CloudClearBtn')?.addEventListener('click', clearClouds);
|
||
$('p3ShowNuwaCloud')?.addEventListener('change', applyCloudVisibility);
|
||
$('p3ShowD435Cloud')?.addEventListener('change', applyCloudVisibility);
|
||
// Mirror toggles: applicano lo scale in frame ottico senza ri-acquisire
|
||
for (const tag of ['Nuwa', 'D435']) {
|
||
const key = tag === 'Nuwa' ? 'nuwa' : 'd435';
|
||
for (const axis of ['X', 'Y', 'Z']) {
|
||
$(`p3Flip${tag}${axis}`)?.addEventListener('change', () => applyCloudTransform(key));
|
||
}
|
||
}
|
||
// Swap modalita' D435i: riparenting + ricalcolo trasformazione
|
||
$('p3D435UseHandeye')?.addEventListener('change', () => {
|
||
const pts = state.cloud_points.d435;
|
||
if (!pts) return;
|
||
const newParent = getCloudParent('d435');
|
||
if (newParent && pts.parent !== newParent) newParent.add(pts);
|
||
applyCloudTransform('d435');
|
||
});
|
||
// Size live per camera (pixel size)
|
||
for (const ev of ['input', 'change']) {
|
||
$('p3NuwaCloudSize')?.addEventListener(ev, () => applyCloudSize('nuwa'));
|
||
$('p3D435CloudSize')?.addEventListener(ev, () => applyCloudSize('d435'));
|
||
}
|
||
// Scale XY workaround D435i (intrinsics-mismatch fix)
|
||
for (const ev of ['input', 'change']) {
|
||
$('p3D435ScaleXY')?.addEventListener(ev, () => applyCloudTransform('d435'));
|
||
}
|
||
}
|
||
|
||
// ----- joint UCS debug toggle -----
|
||
function applyJointUCSUIState() {
|
||
const visible = $('p3ShowJointUCS')?.checked === true;
|
||
const sizeMm = parseFloat($('p3JointUCSSize')?.value || '40');
|
||
setJointUCSVisible(visible);
|
||
setJointUCSScale((Number.isFinite(sizeMm) ? sizeMm : 40) / 1000);
|
||
}
|
||
|
||
function bindJointUCSControls() {
|
||
$('p3ShowJointUCS')?.addEventListener('change', applyJointUCSUIState);
|
||
$('p3JointUCSSize')?.addEventListener('change', applyJointUCSUIState);
|
||
$('p3JointUCSSize')?.addEventListener('input', applyJointUCSUIState);
|
||
$('p3ShowD435CalibUCS')?.addEventListener('change', applyD435CalibUCSVisibility);
|
||
$('p3ShowRobotBody')?.addEventListener('change', applyRobotBodyVisibility);
|
||
}
|
||
|
||
// Toggla solo i mesh "nativi" dell'URDF, lasciando UCS giunti / sensor / Nuwa
|
||
// (taggati con userData.isCustom=true) e label/sprite sempre visibili.
|
||
function applyRobotBodyVisibility() {
|
||
if (!state.robot) return;
|
||
const visible = $('p3ShowRobotBody')?.checked !== false;
|
||
state.robot.traverse((obj) => {
|
||
if (obj.isMesh && !obj.userData.isCustom) {
|
||
obj.visible = visible;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----- Nuwa camera params (offset+rpy in wrist frame) -----
|
||
const NUWA_DEFAULTS = { enabled: true, offset_mm: [0, 0, 100], rpy_deg: [0, 0, -90] };
|
||
|
||
function updateNuwaInputsFromCfg() {
|
||
// Popola solo al primo poll. Poi l'utente controlla i campi finche' non
|
||
// clicca Default (che bypassa questa funzione).
|
||
if (state.nuwa_inputs_populated) return;
|
||
const cfg = state.nuwa_cfg;
|
||
if (!cfg) return;
|
||
const enabled = cfg.enabled !== false;
|
||
const [ox = 0, oy = 0, oz = 0.1] = cfg.offset_m || [];
|
||
const [rd = 0, pd = 0, yd = 0] = cfg.rpy_deg || [];
|
||
const elE = $('p3NuwaEnabled');
|
||
if (elE) elE.checked = enabled;
|
||
const fields = [
|
||
['p3NuwaX', Math.round(ox * 1000)],
|
||
['p3NuwaY', Math.round(oy * 1000)],
|
||
['p3NuwaZ', Math.round(oz * 1000)],
|
||
['p3NuwaR', Math.round(rd)],
|
||
['p3NuwaP', Math.round(pd)],
|
||
['p3NuwaW', Math.round(yd)],
|
||
];
|
||
for (const [id, val] of fields) {
|
||
const el = $(id);
|
||
if (el) el.value = String(val);
|
||
}
|
||
state.nuwa_inputs_populated = true;
|
||
}
|
||
|
||
async function onNuwaApply() {
|
||
const num = (id) => Number($(id).value);
|
||
const offset_m = [num('p3NuwaX') / 1000, num('p3NuwaY') / 1000, num('p3NuwaZ') / 1000];
|
||
const rpy_deg = [num('p3NuwaR'), num('p3NuwaP'), num('p3NuwaW')];
|
||
const enabled = $('p3NuwaEnabled').checked;
|
||
const patch = {
|
||
pose_3d: {
|
||
right_wrist_camera: { enabled, offset_m, rpy_deg },
|
||
},
|
||
};
|
||
try {
|
||
const r = await fetch('/api/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
// riapplica subito senza aspettare il prossimo poll
|
||
state.nuwa_cfg = { ...state.nuwa_cfg, enabled, offset_m, rpy_deg };
|
||
applyNuwaTransform();
|
||
$('p3Status').textContent = '✓ Nuwa applicata';
|
||
$('p3Status').className = 'p3-status ok';
|
||
} catch (e) {
|
||
showError(`Nuwa apply fallito: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function onNuwaReset() {
|
||
$('p3NuwaEnabled').checked = NUWA_DEFAULTS.enabled;
|
||
$('p3NuwaX').value = String(NUWA_DEFAULTS.offset_mm[0]);
|
||
$('p3NuwaY').value = String(NUWA_DEFAULTS.offset_mm[1]);
|
||
$('p3NuwaZ').value = String(NUWA_DEFAULTS.offset_mm[2]);
|
||
$('p3NuwaR').value = String(NUWA_DEFAULTS.rpy_deg[0]);
|
||
$('p3NuwaP').value = String(NUWA_DEFAULTS.rpy_deg[1]);
|
||
$('p3NuwaW').value = String(NUWA_DEFAULTS.rpy_deg[2]);
|
||
}
|
||
|
||
function bindNuwaControls() {
|
||
$('p3NuwaApplyBtn')?.addEventListener('click', onNuwaApply);
|
||
$('p3NuwaResetBtn')?.addEventListener('click', onNuwaReset);
|
||
}
|
||
|
||
// ----- D435i URDF-mode offset (offset_m + rpy_deg vs d435_link) -----
|
||
const D435_DEFAULTS = { offset_mm: [0, 0, 0], rpy_deg: [-90, 90, 0] };
|
||
|
||
function updateD435InputsFromCfg() {
|
||
// Popola solo al primo poll: dopo non si tocca, l'utente controlla.
|
||
if (state.d435_inputs_populated) return;
|
||
const cfg = state.clouds_cfg && state.clouds_cfg.d435;
|
||
if (!cfg) return;
|
||
const [ox = 0, oy = 0, oz = 0] = cfg.offset_m || [];
|
||
const [rd = -90, pd = 90, yd = 0] = cfg.rpy_deg || [];
|
||
const fields = [
|
||
['p3D435X', Math.round(ox * 1000)],
|
||
['p3D435Y', Math.round(oy * 1000)],
|
||
['p3D435Z', Math.round(oz * 1000)],
|
||
['p3D435R', Math.round(rd)],
|
||
['p3D435P', Math.round(pd)],
|
||
['p3D435W', Math.round(yd)],
|
||
];
|
||
for (const [id, val] of fields) {
|
||
const el = $(id);
|
||
if (el) el.value = String(val);
|
||
}
|
||
state.d435_inputs_populated = true;
|
||
}
|
||
|
||
async function onD435Apply() {
|
||
const num = (id) => Number($(id).value);
|
||
const offset_m = [num('p3D435X') / 1000, num('p3D435Y') / 1000, num('p3D435Z') / 1000];
|
||
const rpy_deg = [num('p3D435R'), num('p3D435P'), num('p3D435W')];
|
||
const patch = {
|
||
pose_3d: { clouds: { d435: { offset_m, rpy_deg } } },
|
||
};
|
||
try {
|
||
const r = await fetch('/api/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
state.clouds_cfg = state.clouds_cfg || {};
|
||
state.clouds_cfg.d435 = { ...state.clouds_cfg.d435, offset_m, rpy_deg };
|
||
applyCloudTransform('d435');
|
||
$('p3Status').textContent = '✓ D435i applicata';
|
||
$('p3Status').className = 'p3-status ok';
|
||
} catch (e) {
|
||
showError(`D435i apply fallito: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function onD435Reset() {
|
||
$('p3D435X').value = String(D435_DEFAULTS.offset_mm[0]);
|
||
$('p3D435Y').value = String(D435_DEFAULTS.offset_mm[1]);
|
||
$('p3D435Z').value = String(D435_DEFAULTS.offset_mm[2]);
|
||
$('p3D435R').value = String(D435_DEFAULTS.rpy_deg[0]);
|
||
$('p3D435P').value = String(D435_DEFAULTS.rpy_deg[1]);
|
||
$('p3D435W').value = String(D435_DEFAULTS.rpy_deg[2]);
|
||
}
|
||
|
||
function bindD435Controls() {
|
||
$('p3D435ApplyBtn')?.addEventListener('click', onD435Apply);
|
||
$('p3D435ResetBtn')?.addEventListener('click', onD435Reset);
|
||
}
|
||
|
||
// Tab rail a destra: 3 voci (POSE/CAMERAS/MISC) che mostrano un gruppo
|
||
// alla volta. Solo presentazione: i listener interni alle sezioni nascoste
|
||
// restano agganciati, quindi switchare tab non perde stato.
|
||
function bindTabRail() {
|
||
const tabs = document.querySelectorAll('#tab-pose-3d .p3-tab');
|
||
const groups = document.querySelectorAll('#tab-pose-3d .p3-group');
|
||
if (!tabs.length || !groups.length) return;
|
||
for (const tab of tabs) {
|
||
tab.addEventListener('click', () => {
|
||
const target = tab.dataset.target;
|
||
for (const t of tabs) t.classList.toggle('active', t === tab);
|
||
for (const g of groups) g.hidden = (g.dataset.group !== target);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ----- init triggered on first activation -----
|
||
let inited = false;
|
||
function ensureInit() {
|
||
if (inited) return;
|
||
if (!$('p3Canvas')) return;
|
||
inited = true;
|
||
initScene();
|
||
setupSelection();
|
||
setupTagLocator();
|
||
bindSideControls();
|
||
bindGizmoControls();
|
||
bindGizmoEvents();
|
||
bindEditorButtons();
|
||
bindModeControls();
|
||
bindPreciseControl();
|
||
bindJogAutoApply();
|
||
bindPoseButtons();
|
||
bindUnlockButton();
|
||
bindNuwaControls();
|
||
bindD435Controls();
|
||
bindJointUCSControls();
|
||
bindCloudControls();
|
||
bindReadoutInputs();
|
||
bindTabRail();
|
||
bindFrontTagFinder();
|
||
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();
|
||
}
|