Files
Presa-Mano/web/pose_3d.js
T

706 lines
24 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';
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,
};
// Posizioni torso e spalle hardcoded (riferimento visivo, non IK)
const TORSO_POS = new THREE.Vector3(0, 0, 0.40);
const SHOULDER = {
right: new THREE.Vector3(0, -0.20, 0.60),
left: new THREE.Vector3(0, 0.20, 0.60),
};
// ----- 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 + base UCS
const grid = new THREE.GridHelper(2, 20, 0x444444, 0x222222);
grid.rotation.x = Math.PI / 2; // piano XY (z=0)
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.01);
frontArrow.scale.set(0.18, 0.045, 1); // più largo ma sottile, "etichetta a terra"
scene.add(frontArrow);
// Body silhouette: torso + head + legs + shoulders
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x4a5560, transparent: true, opacity: 0.6,
});
// Torso: 20cm x 40cm x 50cm, centered at TORSO_POS
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.20, 0.40, 0.50), bodyMat);
torso.position.copy(TORSO_POS);
scene.add(torso);
// Head: sfera 12cm sopra il torso
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.09, 24, 16), bodyMat,
);
head.position.set(TORSO_POS.x, TORSO_POS.y, TORSO_POS.z + 0.25 + 0.09);
scene.add(head);
// Hips (rectangle) just below torso
const hips = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.36, 0.10), bodyMat);
hips.position.set(TORSO_POS.x, TORSO_POS.y, TORSO_POS.z - 0.25 - 0.05);
scene.add(hips);
// Gambe: 2 cilindri verticali, da hip a Z=0
const legGeom = new THREE.CylinderGeometry(0.06, 0.06, 0.55, 16);
for (const ly of [-0.10, 0.10]) {
const leg = new THREE.Mesh(legGeom, bodyMat);
leg.rotation.x = Math.PI / 2; // asse cilindro su Z
leg.position.set(TORSO_POS.x, TORSO_POS.y + ly, (TORSO_POS.z - 0.30 - 0.275));
scene.add(leg);
}
// Spalle: 2 sferette in corrispondenza degli SHOULDER
const shoulderGeom = new THREE.SphereGeometry(0.04, 16, 12);
for (const side of ['right', 'left']) {
const sh = new THREE.Mesh(shoulderGeom, bodyMat);
sh.position.copy(SHOULDER[side]);
scene.add(sh);
}
// Arms
for (const side of ['right', 'left']) {
const wrist = makeWristMarker(side === 'right' ? 0x4da3ff : 0xff5e7e);
wrist.position.copy(SHOULDER[side]); // posizione di partenza, sarà aggiornata dal polling
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];
}
// ----- 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 updateReadout() {
const side = state.active_side;
const pose = state.target[side] || state.current[side];
if (!pose) {
$('p3X').textContent = '—';
return;
}
const p = pose.position, q = pose.quaternion;
$('p3X').textContent = (p[0] * 1000).toFixed(1);
$('p3Y').textContent = (p[1] * 1000).toFixed(1);
$('p3Z').textContent = (p[2] * 1000).toFixed(1);
const rpy = quatToRPYdeg(q);
$('p3R').textContent = rpy[0].toFixed(1);
$('p3P').textContent = rpy[1].toFixed(1);
$('p3W').textContent = rpy[2].toFixed(1);
$('p3Status').textContent = state.target[side] ? '● modificata' : '— polso fermo';
$('p3Status').className = 'p3-status' + (state.target[side] ? ' modified' : '');
}
// ----- 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();
});
}
// ----- 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;
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();
}
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);
}
// ----- 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';
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';
}
} 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();
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();
}