# Tab "Pose 3D" Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Aggiungere alla GUI presa-mano una tab "3D Pose" che mostra entrambi i polsi del robot attivo in un viewport Three.js, permette di trascinarli con un gizmo (translate+rotate) e di applicare la posa al robot in modalità Editor (simulato) o Jog (move reale al mouseup, con safety guard sul delta). **Architecture:** Nuovo router FastAPI `/api/pose_3d/*` (state, apply, poses CRUD, unlock proxy). Frontend vanilla JS + Three.js già bundled in `web/vendor/three/`. TransformControls (non ancora presente) viene aggiunto in `web/vendor/three/addons/controls/`. Persistenza pose con nome in `config.yaml > pose_3d.named_poses`. **Tech Stack:** FastAPI + Pydantic + numpy (backend), Three.js 0.x (vendored locally) + vanilla JS (frontend), pytest + TestClient + MagicMock (tests). Spec: `docs/superpowers/specs/2026-05-21-pose-3d-tab-design.md`. --- ## File Structure **Created:** - `src/presa_mano/api/pose_3d_routes.py` — router e Pydantic models - `tests/test_pose_3d_routes.py` — test integration su endpoint - `tests/test_pose_3d_quaternion.py` — test unit su quaternion normalization - `web/pose_3d.js` — scene Three.js + UI logic - `web/pose_3d.css` — styles della tab - `web/vendor/three/addons/controls/TransformControls.js` — vendored **Modified:** - `src/presa_mano/main.py` — registra il nuovo router - `config.yaml` — aggiunge `pose_3d.{live_max_step_m, named_poses}` - `web/index.html` — aggiunge tab "Pose 3D" alla nav, panel HTML, link CSS, import del modulo JS --- ### Task 1: Config schema + skeleton del router **Files:** - Modify: `config.yaml` - Create: `src/presa_mano/api/pose_3d_routes.py` - Modify: `src/presa_mano/main.py:12-25` (import), `:39-50` (include_router) - Test: `tests/test_pose_3d_routes.py` - [ ] **Step 1: Aggiungi sezione `pose_3d` al config** Apri `config.yaml`, trova la sezione `tab_right_arm:` e DOPO la sua chiusura (prima di `grasp:` o sezione successiva) aggiungi: ```yaml pose_3d: live_max_step_m: 0.15 named_poses: {} ``` - [ ] **Step 2: Scrivi il test failing per GET /state** Crea `tests/test_pose_3d_routes.py`: ```python """Integration test del router /api/pose_3d/*.""" from __future__ import annotations from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient from presa_mano.main import app client = TestClient(app) def _fake_wrist_pair(): return { "left_wrist": {"position": [0.10, 0.20, 0.30], "quaternion": [0, 0, 0, 1]}, "right_wrist": {"position": [0.30, -0.20, 0.30], "quaternion": [0, 0, 0, 1]}, } def test_state_returns_both_wrists(): with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock() g1.wrist.return_value = _fake_wrist_pair() g1_factory.return_value = g1 r = client.get("/api/pose_3d/state") assert r.status_code == 200 body = r.json() assert "wrist" in body assert body["wrist"]["left"]["position"] == [0.10, 0.20, 0.30] assert body["wrist"]["right"]["position"] == [0.30, -0.20, 0.30] assert "poses" in body assert "live_max_step_m" in body assert body["robot_active"] in ("g1", "h1") ``` - [ ] **Step 3: Run test to verify it fails** Run: `uv run pytest tests/test_pose_3d_routes.py::test_state_returns_both_wrists -v` Expected: FAIL (router non esiste). - [ ] **Step 4: Crea il router con endpoint /state** Crea `src/presa_mano/api/pose_3d_routes.py`: ```python """Endpoint per la tab "Pose 3D": editor visuale + jog dei polsi.""" from __future__ import annotations import logging from typing import Any, Literal, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from ..clients import G1Unavailable from ..config import get_config from ..timing import Stopwatch from . import _deps router = APIRouter() log = logging.getLogger(__name__) class WristPose(BaseModel): position: list[float] quaternion: list[float] class NamedPose(BaseModel): side: Literal["left", "right"] position: list[float] = Field(min_length=3, max_length=3) quaternion: list[float] = Field(min_length=4, max_length=4) note: Optional[str] = None @router.get("/state") def get_state() -> dict[str, Any]: sw = Stopwatch() try: with sw.step("g1_wrist"): w = _deps.g1().wrist() except G1Unavailable as e: raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}") cfg = get_config().get("pose_3d", {}) or {} return { "robot_active": _deps.active_robot_key(), "wrist": { "left": { "position": list(w["left_wrist"]["position"]), "quaternion": list(w["left_wrist"]["quaternion"]), }, "right": { "position": list(w["right_wrist"]["position"]), "quaternion": list(w["right_wrist"]["quaternion"]), }, }, "poses": cfg.get("named_poses") or {}, "live_max_step_m": float(cfg.get("live_max_step_m", 0.15)), "timings_ms": sw.as_dict(), } ``` - [ ] **Step 5: Registra il router in main.py** In `src/presa_mano/main.py`, nella tupla di import dei routers, aggiungi `pose_3d_routes`: ```python from .api import ( agent_routes, calibration_routes, config_routes, grasp_routes, home_routes, models_routes, perception_routes, pose_3d_routes, posture_routes, right_arm_routes, robot_routes, wrist_routes, work_plane_routes, ) ``` E dopo l'ultima riga `app.include_router(work_plane_routes.router, ...)` aggiungi: ```python app.include_router(pose_3d_routes.router, prefix="/api/pose_3d", tags=["pose_3d"]) ``` - [ ] **Step 6: Run test to verify it passes** Run: `uv run pytest tests/test_pose_3d_routes.py::test_state_returns_both_wrists -v` Expected: PASS. - [ ] **Step 7: Test G1 unavailable** Aggiungi a `tests/test_pose_3d_routes.py`: ```python def test_state_502_when_g1_unavailable(): from presa_mano.clients import G1Unavailable with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock() g1.wrist.side_effect = G1Unavailable("connection refused") g1_factory.return_value = g1 r = client.get("/api/pose_3d/state") assert r.status_code == 502 ``` Run: `uv run pytest tests/test_pose_3d_routes.py -v` → both PASS. - [ ] **Step 8: Commit** ```bash git add config.yaml src/presa_mano/api/pose_3d_routes.py src/presa_mano/main.py tests/test_pose_3d_routes.py git commit -m "feat(pose_3d): router skeleton + GET /state" ``` --- ### Task 2: Quaternion normalization + POST /apply **Files:** - Create: `tests/test_pose_3d_quaternion.py` - Modify: `src/presa_mano/api/pose_3d_routes.py` - Modify: `tests/test_pose_3d_routes.py` - [ ] **Step 1: Test failing per quaternion normalization** Crea `tests/test_pose_3d_quaternion.py`: ```python """Unit test della normalizzazione quaternion server-side.""" from __future__ import annotations import math import numpy as np import pytest from presa_mano.api.pose_3d_routes import normalize_quaternion def test_already_normalized_is_unchanged(): q = [0.0, 0.0, 0.0, 1.0] out = normalize_quaternion(q) assert np.allclose(out, q) def test_not_normalized_gets_normalized_to_unit_length(): # scala 2.0 q = [0.0, 0.0, 0.0, 2.0] out = normalize_quaternion(q) norm = math.sqrt(sum(x*x for x in out)) assert abs(norm - 1.0) < 1e-9 def test_degenerate_zero_quat_raises(): with pytest.raises(ValueError, match="degenerate"): normalize_quaternion([0.0, 0.0, 0.0, 0.0]) def test_near_zero_quat_raises(): with pytest.raises(ValueError, match="degenerate"): normalize_quaternion([1e-9, 0.0, 0.0, 1e-9]) ``` - [ ] **Step 2: Run to verify FAIL** Run: `uv run pytest tests/test_pose_3d_quaternion.py -v` Expected: FAIL (normalize_quaternion non importabile). - [ ] **Step 3: Implementa la helper** In `src/presa_mano/api/pose_3d_routes.py`, dopo gli import aggiungi: ```python import math def normalize_quaternion(q: list[float]) -> list[float]: """Normalizza un quaternion a lunghezza unitaria. Solleva ValueError se la norma e' troppo piccola per essere normalizzata in modo sensato (es. quaternion zero, sotto 1e-6).""" n = math.sqrt(sum(float(x)*float(x) for x in q)) if n < 1e-6: raise ValueError(f"quaternion is degenerate (norm={n:.3e})") return [float(x) / n for x in q] ``` - [ ] **Step 4: Run quaternion test → PASS** Run: `uv run pytest tests/test_pose_3d_quaternion.py -v` Expected: 4 PASS. - [ ] **Step 5: Test failing per POST /apply (dry-run)** Aggiungi a `tests/test_pose_3d_routes.py`: ```python def test_apply_dry_run_calls_g1_with_dry_run_true(): captured = {} def _abs(**kw): captured.update(kw) return {"ok": True, "reached_target": True} with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock() g1.wrist_absolute.side_effect = _abs g1_factory.return_value = g1 r = client.post("/api/pose_3d/apply", json={ "side": "right", "position": [0.30, -0.20, 0.30], "quaternion": [0, 0, 0, 1], "dry_run": True, }) assert r.status_code == 200 body = r.json() assert body["applied"] is False # Verifica che il client G1 sia stato creato con dry_run=True # (passato a _deps.g1(dry_run=...)) assert g1_factory.call_args.kwargs.get("dry_run") is True assert captured["side"] == "right" assert captured["position"] == [0.30, -0.20, 0.30] def test_apply_real_call(): with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock() g1.wrist_absolute.return_value = {"ok": True, "reached_target": True} g1_factory.return_value = g1 r = client.post("/api/pose_3d/apply", json={ "side": "left", "position": [0.1, 0.2, 0.3], "quaternion": [0, 0, 0, 1], "dry_run": False, }) assert r.status_code == 200 body = r.json() assert body["applied"] is True assert g1_factory.call_args.kwargs.get("dry_run") is False def test_apply_422_bad_side(): r = client.post("/api/pose_3d/apply", json={ "side": "middle", "position": [0,0,0], "quaternion": [0,0,0,1], }) assert r.status_code == 422 def test_apply_422_degenerate_quaternion(): with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1_factory.return_value = MagicMock() r = client.post("/api/pose_3d/apply", json={ "side": "right", "position": [0,0,0], "quaternion": [0,0,0,0], }) assert r.status_code == 422 assert "degenerate" in r.json()["detail"] def test_apply_normalizes_quaternion_before_forwarding(): captured = {} def _abs(**kw): captured.update(kw) return {"ok": True} with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock(); g1.wrist_absolute.side_effect = _abs g1_factory.return_value = g1 # quat con scala 2.0 r = client.post("/api/pose_3d/apply", json={ "side": "right", "position": [0,0,0], "quaternion": [0, 0, 0, 2.0], }) assert r.status_code == 200 # Il g1 deve ricevere il quat normalizzato (norma 1) q = captured["quaternion"] norm = sum(x*x for x in q) ** 0.5 assert abs(norm - 1.0) < 1e-6 ``` - [ ] **Step 6: Run to FAIL** Run: `uv run pytest tests/test_pose_3d_routes.py -v` Expected: i nuovi test FAIL (endpoint /apply non esiste). - [ ] **Step 7: Implementa POST /apply** In `src/presa_mano/api/pose_3d_routes.py`, aggiungi i model e la route: ```python class ApplyReq(BaseModel): side: Literal["left", "right"] position: list[float] = Field(min_length=3, max_length=3) quaternion: list[float] = Field(min_length=4, max_length=4) dry_run: bool = True precise: bool = True @router.post("/apply") def post_apply(req: ApplyReq) -> dict[str, Any]: sw = Stopwatch() try: quat_norm = normalize_quaternion(req.quaternion) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) try: with sw.step("wrist_absolute"): res = _deps.g1(dry_run=req.dry_run).wrist_absolute( side=req.side, position=list(req.position), quaternion=quat_norm, precise=req.precise, ) except G1Unavailable as e: raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}") return { "result": res, "applied": not req.dry_run, "side": req.side, "position": list(req.position), "quaternion": quat_norm, "timings_ms": sw.as_dict(), } ``` - [ ] **Step 8: Run all pose_3d tests → PASS** Run: `uv run pytest tests/test_pose_3d_routes.py tests/test_pose_3d_quaternion.py -v` Expected: tutti i test passano. - [ ] **Step 9: Commit** ```bash git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_routes.py tests/test_pose_3d_quaternion.py git commit -m "feat(pose_3d): POST /apply + quaternion normalization" ``` --- ### Task 3: Named poses CRUD **Files:** - Modify: `src/presa_mano/api/pose_3d_routes.py` - Modify: `tests/test_pose_3d_routes.py` - [ ] **Step 1: Test failing per PUT /poses/{name}** Aggiungi a `tests/test_pose_3d_routes.py`: ```python def test_put_pose_persists_in_config(): payload = { "side": "right", "position": [0.30, -0.20, 0.30], "quaternion": [0, 0, 0, 1], "note": "test pose", } r = client.put("/api/pose_3d/poses/test1", json=payload) assert r.status_code == 200 body = r.json() assert body["pose"]["side"] == "right" assert body["pose"]["note"] == "test pose" # Verifica che sia anche in GET /poses r2 = client.get("/api/pose_3d/poses") assert "test1" in r2.json() def test_put_pose_422_bad_name(): r = client.put("/api/pose_3d/poses/with spaces!", json={ "side": "right", "position": [0,0,0], "quaternion": [0,0,0,1], }) assert r.status_code == 422 assert "name" in r.json()["detail"].lower() def test_delete_pose_removes_it(): # prima salva client.put("/api/pose_3d/poses/todelete", json={ "side": "left", "position": [0,0,0], "quaternion": [0,0,0,1], }) # poi cancella r = client.delete("/api/pose_3d/poses/todelete") assert r.status_code == 200 # verifica non presente r2 = client.get("/api/pose_3d/poses") assert "todelete" not in r2.json() def test_delete_pose_404_when_missing(): r = client.delete("/api/pose_3d/poses/nonexistent") assert r.status_code == 404 ``` - [ ] **Step 2: Run to FAIL** Run: `uv run pytest tests/test_pose_3d_routes.py -v` Expected: 4 nuovi test FAIL. - [ ] **Step 3: Implementa CRUD endpoint** In `src/presa_mano/api/pose_3d_routes.py` aggiungi: ```python import re from ..config import update_config _NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$") def _validate_name(name: str) -> None: if not _NAME_RE.match(name): raise HTTPException( status_code=422, detail=f"name must match {_NAME_RE.pattern}", ) @router.get("/poses") def get_poses() -> dict[str, Any]: cfg = get_config().get("pose_3d", {}) or {} return cfg.get("named_poses") or {} @router.put("/poses/{name}") def put_pose(name: str, req: NamedPose) -> dict[str, Any]: _validate_name(name) try: quat_norm = normalize_quaternion(req.quaternion) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) pose = { "side": req.side, "position": list(req.position), "quaternion": quat_norm, } if req.note is not None: pose["note"] = req.note cfg = get_config().get("pose_3d", {}) or {} poses = dict(cfg.get("named_poses") or {}) poses[name] = pose update_config({"pose_3d": {"named_poses": poses}}) return {"name": name, "pose": pose} @router.delete("/poses/{name}") def delete_pose(name: str) -> dict[str, Any]: _validate_name(name) cfg = get_config().get("pose_3d", {}) or {} poses = dict(cfg.get("named_poses") or {}) if name not in poses: raise HTTPException(status_code=404, detail=f"pose '{name}' non trovata") del poses[name] update_config({"pose_3d": {"named_poses": poses}}) return {"deleted": name} ``` - [ ] **Step 4: Run → PASS** Run: `uv run pytest tests/test_pose_3d_routes.py -v` Expected: tutti i test passano. La fixture `_restore_config_yaml` (già in conftest.py) ripristina il file dopo ogni test. - [ ] **Step 5: Commit** ```bash git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_routes.py git commit -m "feat(pose_3d): named poses GET/PUT/DELETE" ``` --- ### Task 4: POST /poses/{name}/go **Files:** - Modify: `src/presa_mano/api/pose_3d_routes.py` - Modify: `tests/test_pose_3d_routes.py` - [ ] **Step 1: Test failing** Aggiungi: ```python def test_go_pose_404_when_missing(): r = client.post("/api/pose_3d/poses/missing/go", json={"dry_run": True}) assert r.status_code == 404 def test_go_pose_executes_via_g1_with_stored_values(): captured = {} def _abs(**kw): captured.update(kw) return {"ok": True} # salva una posa client.put("/api/pose_3d/poses/test_go", json={ "side": "left", "position": [0.11, 0.22, 0.33], "quaternion": [0, 0, 0, 1], }) with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: g1 = MagicMock(); g1.wrist_absolute.side_effect = _abs g1_factory.return_value = g1 r = client.post("/api/pose_3d/poses/test_go/go", json={"dry_run": False, "precise": True}) assert r.status_code == 200 assert captured["side"] == "left" assert captured["position"] == [0.11, 0.22, 0.33] assert captured["precise"] is True ``` - [ ] **Step 2: Run → FAIL** Run: `uv run pytest tests/test_pose_3d_routes.py -v` - [ ] **Step 3: Implementa** ```python class GoReq(BaseModel): dry_run: bool = True precise: bool = True @router.post("/poses/{name}/go") def post_go_pose(name: str, req: GoReq) -> dict[str, Any]: _validate_name(name) cfg = get_config().get("pose_3d", {}) or {} poses = cfg.get("named_poses") or {} pose = poses.get(name) if pose is None: raise HTTPException(status_code=404, detail=f"pose '{name}' non trovata") sw = Stopwatch() try: with sw.step("wrist_absolute"): res = _deps.g1(dry_run=req.dry_run).wrist_absolute( side=pose["side"], position=list(pose["position"]), quaternion=list(pose["quaternion"]), precise=req.precise, ) except G1Unavailable as e: raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}") return { "name": name, "result": res, "applied": not req.dry_run, "timings_ms": sw.as_dict(), } ``` - [ ] **Step 4: Run → PASS** Run: `uv run pytest tests/test_pose_3d_routes.py -v` - [ ] **Step 5: Commit** ```bash git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_routes.py git commit -m "feat(pose_3d): POST /poses/{name}/go" ``` --- ### Task 5: POST /unlock proxy **Files:** - Modify: `src/presa_mano/api/pose_3d_routes.py` - Modify: `tests/test_pose_3d_routes.py` - [ ] **Step 1: Test failing** Aggiungi: ```python def test_unlock_proxies_to_bridge(): def _post(url, *args, **kw): assert url.endswith("/unlock") resp = MagicMock() resp.json.return_value = {"ok": True, "message": "unlocked"} return resp with patch("presa_mano.api.pose_3d_routes.httpx.post", side_effect=_post): r = client.post("/api/pose_3d/unlock") assert r.status_code == 200 assert r.json()["ok"] is True ``` - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implementa** In `pose_3d_routes.py`, dopo gli import esistenti aggiungi: ```python import httpx ``` E sotto le altre routes: ```python @router.post("/unlock") def post_unlock() -> dict[str, Any]: """Proxy a POST {bridge}/unlock. Recovery rapido da arms: error_timeout.""" cfg = get_config() key = _deps.active_robot_key() base = ((cfg.get("services", {}) or {}).get(key, {}) or {}).get("base_url") if not base: raise HTTPException( status_code=502, detail=f"services.{key}.base_url non configurato", ) try: r = httpx.post(f"{base.rstrip('/')}/unlock", timeout=3.0) r.raise_for_status() except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"bridge unlock: {e}") return r.json() ``` - [ ] **Step 4: Run → PASS** Run: `uv run pytest tests/test_pose_3d_routes.py tests/test_pose_3d_quaternion.py -v` Expected: tutti i test del backend passano. - [ ] **Step 5: Run l'INTERA suite per non-regression** Run: `uv run pytest tests/ -q` Expected: tutti i test passano. - [ ] **Step 6: Commit** ```bash git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_routes.py git commit -m "feat(pose_3d): POST /unlock proxy + non-regression check" ``` --- ### Task 6: Vendor TransformControls.js **Files:** - Create: `web/vendor/three/addons/controls/TransformControls.js` - [ ] **Step 1: Trova la versione di Three.js in uso** Run: `grep -m1 -E 'REVISION|version' web/vendor/three/build/three.module.js | head -3` Expected: una stringa tipo `REVISION = '160'` o simile. Annotare il numero. - [ ] **Step 2: Scarica TransformControls.js dalla stessa release** Run (sostituendo `` con il numero del passo 1): ```bash curl -fsSL \ "https://raw.githubusercontent.com/mrdoob/three.js/r/examples/jsm/controls/TransformControls.js" \ -o web/vendor/three/addons/controls/TransformControls.js ``` Verifica: ```bash ls -la web/vendor/three/addons/controls/TransformControls.js head -5 web/vendor/three/addons/controls/TransformControls.js ``` Expected: file di ~40-60 KB, prima riga contiene `import` da `'three'`. - [ ] **Step 3: Smoke test che caricabile dal browser** Run il container se non già su: ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano ``` Quando healthy: ```bash until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done curl -fsS -o /dev/null -w "%{http_code}\n" \ http://localhost:8890/static/vendor/three/addons/controls/TransformControls.js ``` Expected: `200`. - [ ] **Step 4: Commit** ```bash git add web/vendor/three/addons/controls/TransformControls.js git commit -m "chore(web): vendor TransformControls (per tab Pose 3D)" ``` --- ### Task 7: HTML + CSS della tab "Pose 3D" **Files:** - Modify: `web/index.html` (nav + nuovo `
` + link CSS + import del modulo JS) - Create: `web/pose_3d.css` - [ ] **Step 1: Aggiungi il bottone nav e la section** In `web/index.html`, dopo `` (riga ~19) aggiungi: ```html ``` Poi, DOPO la chiusura della section `#tab-right-arm` e PRIMA di `#tab-ee`, aggiungi: ```html
Editor
click polso = seleziona · gizmo: T sposta / R ruota · drag = muovi · orbit = wheel/drag fondo · pan = shift+drag
``` - [ ] **Step 2: Aggiungi link CSS e import modulo nello head/footer** Cerca `` (o equivalente) nell'head di `web/index.html` e aggiungi DOPO: ```html ``` In fondo al file, dopo `` aggiungi: ```html ``` - [ ] **Step 3: Crea il CSS** Crea `web/pose_3d.css`: ```css #tab-pose-3d .p3-layout { display: grid; grid-template-columns: 1fr 360px; gap: 16px; height: calc(100vh - 140px); min-height: 600px; } #tab-pose-3d .p3-viewport { position: relative; background: #0d1014; border: 1px solid #2a2f36; border-radius: 6px; overflow: hidden; } #tab-pose-3d .p3-viewport canvas { display: block; width: 100%; height: 100%; } #tab-pose-3d .p3-mode-badge { position: absolute; top: 10px; left: 10px; padding: 4px 10px; background: rgba(20, 30, 40, 0.85); border: 1px solid #4da3ff; border-radius: 4px; color: #4da3ff; font-size: 13px; font-weight: bold; } #tab-pose-3d .p3-mode-badge.jog { border-color: #ff5e7e; color: #ff5e7e; animation: p3-blink 1.4s ease-in-out infinite; } @keyframes p3-blink { 50% { opacity: 0.5; } } #tab-pose-3d .p3-help { position: absolute; bottom: 8px; left: 10px; right: 10px; font-size: 11px; color: #888; } #tab-pose-3d .p3-sidebar { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; } #tab-pose-3d .p3-section { background: #1a1f26; border: 1px solid #2a2f36; border-radius: 6px; padding: 10px 12px; } #tab-pose-3d .p3-section h3 { margin: 0 0 8px 0; font-size: 13px; color: #ccc; } #tab-pose-3d .p3-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } #tab-pose-3d .p3-row:last-child { margin-bottom: 0; } #tab-pose-3d .p3-row label { font-size: 13px; color: #ddd; } #tab-pose-3d .p3-check { display: block; margin-top: 8px; font-size: 13px; color: #ddd; } #tab-pose-3d .p3-readout { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px 12px; font-family: monospace; font-size: 12px; color: #ddd; margin-bottom: 8px; } #tab-pose-3d .p3-readout span { color: #4da3ff; } #tab-pose-3d .p3-status { font-size: 12px; color: #888; margin-bottom: 8px; } #tab-pose-3d .p3-status.modified { color: #f0ad4e; } #tab-pose-3d .p3-status.ok { color: #3ecf8e; } #tab-pose-3d .p3-status.error { color: #e74c3c; } #tab-pose-3d button { background: #2a3038; border: 1px solid #3a4048; color: #e0e0e0; padding: 5px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; } #tab-pose-3d button:hover { background: #34404a; } #tab-pose-3d button:disabled { opacity: 0.4; cursor: not-allowed; } #tab-pose-3d select, #tab-pose-3d input[type=text] { background: #0d1014; border: 1px solid #2a2f36; color: #e0e0e0; padding: 4px 6px; border-radius: 3px; font-size: 12px; flex: 1; min-width: 0; } #tab-pose-3d .p3-error { background: #3a1014; border: 1px solid #e74c3c; border-radius: 4px; padding: 8px; color: #ffcccc; font-size: 12px; } ``` - [ ] **Step 4: Rebuild e verifica visiva** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri `http://localhost:8890/` nel browser, click su tab "Pose 3D". Devi vedere: - A sinistra: canvas vuoto (nero) con badge "Editor" in alto-sx. - A destra: pannelli "Modalità", "Polso attivo", "Posa target", "Pose salvate", "Sblocca braccia". (Nessuna scena 3D ancora, è normale: il JS non è ancora scritto.) - [ ] **Step 5: Commit** ```bash git add web/index.html web/pose_3d.css git commit -m "feat(pose_3d): markup HTML + CSS della tab" ``` --- ### Task 8: Scene Three.js setup **Files:** - Create: `web/pose_3d.js` - [ ] **Step 1: Crea il file con scene + camera + render loop** Crea `web/pose_3d.js`: ```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 makeWristMarker(color) { const group = new THREE.Group(); const sphere = new THREE.Mesh( new THREE.SphereGeometry(0.025, 16, 12), new THREE.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.5 }), ); group.add(sphere); group.add(makeUCS(0.07)); group.userData.sphere = sphere; 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); // Torso const torso = new THREE.Mesh( new THREE.BoxGeometry(0.20, 0.40, 0.50), new THREE.MeshStandardMaterial({ color: 0x4a5560, transparent: true, opacity: 0.7 }), ); torso.position.copy(TORSO_POS); scene.add(torso); // 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; // 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 THREE.OrbitControls(camera, renderer.domElement); orbit.target.set(0, 0, 0.3); orbit.update(); const transform = new TransformControls(camera, renderer.domElement); transform.setMode('translate'); // Disable orbit while dragging gizmo transform.addEventListener('dragging-changed', (e) => { orbit.enabled = !e.value; }); scene.add(transform); 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(); } // ----- init triggered on first activation ----- let inited = false; function ensureInit() { if (inited) return; if (!$('p3Canvas')) return; inited = true; initScene(); } // 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(); } ``` - [ ] **Step 2: Rebuild e verifica** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri il browser su `http://localhost:8890/`, click tab "Pose 3D". Devi vedere: - Grid sul piano Z=0 - Una terna RGB centrale (base UCS) di 20 cm - Un box grigio (torso) sopra - 2 sfere (blu = dx, rossa = sx) alle spalle del torso, ciascuna con la sua terna RGB piccola - Puoi orbitare con drag, zoom con wheel - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): scene Three.js base (torso, polsi, UCS, grid)" ``` --- ### Task 9: Polling /state + readout sidebar **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi polling e funzioni di update** In `web/pose_3d.js`, AGGIUNGI in fondo al file (prima dell'observer della tab): ```javascript // ----- 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 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(); 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 = ''; 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; } // ----- side change listener ----- function bindSideControls() { for (const r of document.querySelectorAll('input[name=p3Side]')) { r.addEventListener('change', () => { state.active_side = r.value; updateReadout(); }); } } ``` E modifica `initScene()` per chiamare il polling DOPO la fine della funzione (cerca `animate();` dentro initScene e dopo la chiusura di initScene aggiungi): Sostituisci la parte finale di `ensureInit()`: ```javascript function ensureInit() { if (inited) return; if (!$('p3Canvas')) return; inited = true; initScene(); bindSideControls(); pollState(); pollTimer = setInterval(pollState, POLL_MS); } ``` - [ ] **Step 2: Rebuild e verifica** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri tab Pose 3D. Devi vedere: - Le 2 sfere si muovono alla posa reale del robot (lette via /state) - I numeri X/Y/Z/RPY nella sidebar mostrano la posa del polso attivo (default destro) - Cambiando radio "Destro/Sinistro" cambia il readout - Le sfere si aggiornano ogni secondo - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): polling /state + readout sidebar" ``` --- ### Task 10: Selezione polso + gizmo (translate/rotate) **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi raycaster + selezione + gizmo attach** In `web/pose_3d.js`, prima di `bindSideControls()` aggiungi: ```javascript // ----- 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); if (hits.length > 0) { const sphere = hits[0].object; const side = sphere.parent.userData.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]); state.ghosts[side].visible = true; updateReadout(); } function ensureGhostInit(side) { // Se non c'è target locale, allinea ghost al reale (preparato per drag) if (!state.target[side]) { const real = state.current[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]); } } } // ----- 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(); }); } ``` Modifica `ensureInit()` per chiamare anche le nuove setup: ```javascript function ensureInit() { if (inited) return; if (!$('p3Canvas')) return; inited = true; initScene(); setupSelection(); bindSideControls(); bindGizmoControls(); bindGizmoEvents(); pollState(); pollTimer = setInterval(pollState, POLL_MS); } ``` Disabilita anche l'aggiornamento del ghost durante il polling quando c'è un target attivo (modifica `updateRealWrists` o creane una nuova `updateGhosts`): Aggiungi dopo `updateRealWrists`: ```javascript 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) const real = state.current[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 { ghost.visible = false; } } } ``` E in `pollState`, DOPO `updateRealWrists()` aggiungi: ```javascript syncGhostsToTargetOrReal(); ``` - [ ] **Step 2: Rebuild e verifica** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri tab Pose 3D. Verifica: - Click sulla sfera destra → gizmo (3 frecce colorate) appare sul polso destro - Trascina freccia X → la sfera ghost si muove sull'asse X (il polso reale resta dov'è — è solo target locale) - Readout sidebar si aggiorna in tempo reale, status mostra "● modificata" - Premi `r` da tastiera → frecce diventano 3 anelli (rotate) - Premi `t` → torna a translate - Click su empty space + click sull'altra sfera → gizmo si sposta sull'altro polso - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): selezione polso + gizmo translate/rotate sul ghost" ``` --- ### Task 11: Editor mode — Apply + Annulla **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi handler Apply (Editor) e Cancel** Aggiungi in `web/pose_3d.js`: ```javascript // ----- 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); } ``` E in `ensureInit()` aggiungi prima del polling: ```javascript bindEditorButtons(); ``` - [ ] **Step 2: Rebuild e test manuale** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri tab Pose 3D, click polso destro, trascina di +5 cm in X. Click "Applica (Editor)". Verifica: - Status diventa "✓ Editor: target committato (simulato)" - La sfera "reale" del polso destro si sposta sul ghost (commit locale) - Il robot fisico NON si muove (verifica con `docker logs robotserver-presa-mano-1 --tail 5` — vedrai `[DRY-RUN]`) - Il polling successivo riporta la sfera dove sta davvero il robot (sovrascrive il commit simulato): è atteso, perché in Editor mode il polling è autoritativo. - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): Editor mode Apply + Cancel" ``` --- ### Task 12: Jog mode + Precise checkbox + safety guard **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi mode toggle e auto-apply on mouseup** Aggiungi in `web/pose_3d.js`: ```javascript // ----- 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); } }); } ``` E in `ensureInit()`: ```javascript bindModeControls(); bindPreciseControl(); bindJogAutoApply(); ``` - [ ] **Step 2: Rebuild e test manuale** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` **ATTENZIONE: il prossimo test muove il robot reale. Assicurati che sia in posizione safe e che l'area attorno al polso sia libera.** Apri tab Pose 3D. Verifica: 1. Switch Mode = Jog → badge diventa rosso lampeggiante "🔴 JOG (live)", il blocco Apply/Annulla scompare. 2. Click sulla sfera del polso destro, trascina +20 mm in X (movimento piccolo). Rilascia. Il robot si muove di ~2 cm. Status: "✓ Jog: raggiunto". 3. Switch Polso = Sinistro. Click polso sinistro, drag +20 mm in Y. Rilascia. Anche il sinistro si muove. 4. Drag grande (es. +200 mm in X) → appare dialog "Movimento richiesto X mm > soglia". Click No → niente succede. Click Sì → robot tenta il movimento. 5. Se il bridge va in `arms: error_timeout` (es. su precise non convergente), l'errore appare in rosso nella sidebar. Disattiva la checkbox Precise e riprova → dovrebbe convergere meglio. - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): Jog mode auto-apply + safety + Precise toggle" ``` --- ### Task 13: Save/Load/Delete named poses **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi handler save / go / delete** Aggiungi in `web/pose_3d.js`: ```javascript // ----- 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); } ``` E in `ensureInit()`: ```javascript bindPoseButtons(); ``` - [ ] **Step 2: Rebuild e test manuale** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri tab Pose 3D in Editor mode. Verifica: 1. Trascina il polso destro di qualche cm. Scrivi nome `test1` nel campo e click "Salva". → la posa appare nel dropdown. 2. Annulla il target. Drag il polso da un'altra parte, click "Salva" con nome `test2`. → 2 voci nel dropdown. 3. Seleziona `test1` dal dropdown, click "Vai" (Editor mode = dry-run) → status: "Posa applicata (simulato)". 4. Switch Jog mode, seleziona `test2`, click "Vai", conferma. → robot va su `test2` reale. 5. Seleziona `test1`, click "✕" e conferma. → sparisce dal dropdown. 6. Verifica `config.yaml`: `cat config.yaml | grep -A5 named_poses` → vedi `test2` con i suoi valori. - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): save/load/delete named poses" ``` --- ### Task 14: Bottone Sblocca braccia **Files:** - Modify: `web/pose_3d.js` - [ ] **Step 1: Aggiungi handler unlock** Aggiungi in `web/pose_3d.js`: ```javascript 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); } ``` E in `ensureInit()`: ```javascript bindUnlockButton(); ``` - [ ] **Step 2: Rebuild e test** ```bash cd /opt/docker/robotserver && docker compose up -d --build presa-mano until curl -fsS http://localhost:8890/health >/dev/null 2>&1; do sleep 1; done ``` Apri tab Pose 3D. Provoca un timeout (Jog + drag grande con Precise ON), poi click "Sblocca braccia" → status verde "Braccia sbloccate". Successivi drag/move funzionano di nuovo. - [ ] **Step 3: Commit** ```bash git add web/pose_3d.js git commit -m "feat(pose_3d): bottone Sblocca braccia" ``` --- ### Task 15: Test end-to-end + non-regression + memoria **Files:** - (nessun file di codice) - Create: nuovo memo in `~/.claude/projects/-opt-docker-robotserver-presa-mano/memory/` - [ ] **Step 1: Non-regression suite completa** ```bash uv run pytest tests/ -q ``` Expected: tutti i test passano (backend pose_3d + esistenti). - [ ] **Step 2: Test e2e manuale (golden path)** 1. `docker compose up -d --build presa-mano` se non già su. 2. Apri `http://localhost:8890/` → tab "Pose 3D". 3. Verifica scene 3D visibile, 2 sfere sui polsi reali. 4. Editor mode: drag polso destro, click Apply → vedi commit simulato. 5. Jog mode: drag piccolo del polso destro, mouseup → robot si muove. Status verde. 6. Save una posa "verifica_e2e". Cambia il polso. Vai a "verifica_e2e" in Editor → simula OK. 7. Sblocca braccia: funziona. 8. Cancella "verifica_e2e": sparisce dal dropdown e dal config.yaml. - [ ] **Step 3: Salva memo del progetto** Crea il file `/home/adriano/.claude/projects/-opt-docker-robotserver-presa-mano/memory/project_pose_3d_tab_done.md`: ```markdown --- name: project-pose-3d-tab-done description: Tab "Pose 3D" implementata 2026-05-21 — viewport Three.js + gizmo + Editor/Jog mode + named poses metadata: type: project --- Implementata la tab "Pose 3D" come da spec `docs/superpowers/specs/2026-05-21-pose-3d-tab-design.md` e plan `docs/superpowers/plans/2026-05-21-pose-3d-tab.md`. Cosa fa: - Viewport Three.js con torso + 2 polso-sfere + UCS reali (eredita i vendor in `web/vendor/three/`; aggiunto TransformControls). - Click su un polso → gizmo translate/rotate sul ghost. - Editor mode: drag locale, Apply commit simulato (dry-run). - Jog mode: drag → mouseup → wrist_absolute reale (con safety guard sul delta > `pose_3d.live_max_step_m`). - Checkbox Precise (default ON, disattivabile per movimenti grandi). - Named poses salvati in `config.yaml > pose_3d.named_poses`. - Bottone Sblocca braccia (proxy a `/unlock` del bridge G1). Endpoint nuovi: `/api/pose_3d/{state,apply,poses,poses/{name}, poses/{name}/go,unlock}`. **Why:** sostituire l'editing numerico delle pose nella tab Braccio DX con un'interfaccia visuale 3D, e dare un unico posto per gestire entrambi i polsi (destro e sinistro) con preset riutilizzabili. **How to apply:** quando l'utente chiede di muovere il polso visualmente o di gestire pose nominate, indirizzarlo qui. La tab Braccio DX resta valida per il workflow specifico di presa pestello (detect + move-toward + custom offsets) che NON entra in pose_3d. Migration path: i `pose_3d.named_poses` potranno migrare ai preset del bridge G1 in futuro (vedi [[reference-g1-api-server-mcp]]). ``` E aggiungi una riga a `~/.claude/projects/-opt-docker-robotserver-presa-mano/memory/MEMORY.md`: ```markdown - [Tab Pose 3D: implementata 2026-05-21](project_pose_3d_tab_done.md) — viewport Three.js + gizmo + Editor/Jog + named poses; endpoint /api/pose_3d/* ``` - [ ] **Step 4: Commit finale** ```bash git add docs/superpowers/plans/2026-05-21-pose-3d-tab.md git commit -m "docs(pose_3d): implementation plan completato" ``` --- ## Self-Review **Spec coverage** — ho coperto: - ✓ Obiettivo & modalità (Task 1, 11, 12) - ✓ Architettura (Task 1-5 backend, 6-14 frontend) - ✓ Scene Three.js: torso, link, polsi, UCS, grid (Task 8) - ✓ Interazione gizmo (Task 10) - ✓ Pannello sidebar con tutti i campi (Task 7) - ✓ State client (Task 8-14) - ✓ Backend endpoint completo (state/apply/poses/go/unlock) (Task 1-5) - ✓ Quaternion normalization (Task 2) - ✓ Edge cases (Task 1-4, 422/404/502) - ✓ Error handling frontend (Task 11-14) - ✓ Testing unit + integration (Task 1-5) - ✓ Test manuale (Task 15) - ✓ Memo memoria (Task 15) **Placeholder scan:** nessun TBD/TODO non risolto. Tutti i blocchi di codice mostrati per intero. **Type consistency:** - `NamedPose` definito Task 1, riusato Task 3-4 ✓ - `ApplyReq` definito Task 2, semantica coerente in /poses/{name}/go (Task 4) tramite ricostruzione ✓ - `state.target[side]`, `state.current[side]`, `state.precise`, `state.mode` consistenti tra Task 8-14 ✓ - `normalize_quaternion` usato in /apply (Task 2) e /poses/{name} PUT (Task 3) ✓ **Scope:** un singolo plan, una feature coerente. OK.