diff --git a/docs/superpowers/plans/2026-05-21-pose-3d-tab.md b/docs/superpowers/plans/2026-05-21-pose-3d-tab.md new file mode 100644 index 0000000..74049bc --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-pose-3d-tab.md @@ -0,0 +1,2055 @@ +# 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.