Files
Presa-Mano/docs/superpowers/specs/2026-05-21-pose-3d-tab-design.md
T
Adriano Dal PastroandClaude Opus 4.7 19fba42876 docs: spec design tab "Pose 3D" (editor visuale + jog dei polsi)
Specifica per nuova tab GUI con viewport Three.js, gizmo
translate/rotate sui polsi DX e SX, modalita' Editor (preview) /
Jog (auto-move al mouseup), safety guard sui movimenti grandi,
checkbox precise toggle, save/load named poses in config.yaml.

Brainstorming nella sessione 2026-05-21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:14:46 +02:00

15 KiB
Raw Blame History

Design — Tab "Pose 3D" (editor visuale + jog dei polsi)

Data: 2026-05-21 Stato: approvato per implementazione

Obiettivo

Aggiungere una tab "3D" alla GUI di presa-mano che permetta di:

  1. Visualizzare la posa corrente dei polsi destro e sinistro del robot attivo in un viewport 3D.
  2. Trascinare in 3D (gizmo translate+rotate) un polso alla volta per definire una nuova posa.
  3. Validare la posa via il bridge G1 e (se in modalità Jog) muovere il robot reale.
  4. Salvare/caricare pose con nome per riuso.

Sostituisce il workflow attuale che richiede di editare manualmente campi numerici X/Y/Z + RPY nella tab Braccio DX, e fornisce una rappresentazione visuale per le 3 UCS di riferimento (base, polso DX, polso SX) finora invisibili.

Modalità di lavoro

Toggle in header tab, due valori mutuamente esclusivi:

  • Editor: il drag aggiorna SOLO la posa ghost locale (preview). Il bottone "Applica" abilita un commit simulato (il ghost diventa "current" lato client, robot non si muove). Utile per pianificare/visualizzare senza rischio.
  • Jog: il drag aggiorna il ghost; al rilascio del mouse (mouseup) parte automaticamente un comando reale wrist_absolute con dry_run=false. Nessun bottone Apply. 1 drag = 1 move.

Safety guard in modalità Jog: prima dell'auto-apply, calcolo ||ghost_target − current_wrist||. Se supera pose_3d.live_max_step_m (default 0.15 m), mostro un dialog di conferma "Movimento richiesto X mm. Procedi?" prima di inviare al robot.

Flag precise: tutti i comandi reali (Editor Apply non incluso — è sim locale) hanno per default precise=true (controllo accurato del bridge). Il pannello espone un checkbox "Precise" abilitato di default; l'utente può disattivarlo se sa che il movimento è approssimativo / fuori workspace nominale (evita arms: error_timeout su movimenti grandi). Il toggle è di sessione, non persistito.

Architettura

┌─────────────────────────────────────────────────────────────────┐
│ BROWSER                                                         │
│  web/pose_3d.html (markup) + web/pose_3d.js (Three.js + UI)     │
│  web/pose_3d.css                                                │
│  - Scene Three.js: torso, 2 link, 2 polso-sphere, 3 UCS         │
│  - TransformControls: gizmo translate+rotate sul polso attivo   │
│  - Sidebar: side selector, mode toggle, gizmo toggle, readout,  │
│    bottoni Apply (Editor)/Sblocca, save/load pose               │
│  - Polling GET /api/pose_3d/state ogni 1s per sync con robot    │
└──────────────────────────┬──────────────────────────────────────┘
                           │ HTTP /api/pose_3d/*
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR (FastAPI, presa-mano)                              │
│  src/presa_mano/api/pose_3d_routes.py                           │
│   - GET  /state              -> {left_wrist, right_wrist, poses}│
│   - POST /apply              -> wrist_absolute(side, pos, quat) │
│   - GET  /poses              -> dict {nome: NamedPose}          │
│   - PUT  /poses/{name}       -> salva/aggiorna posa             │
│   - DELETE /poses/{name}     -> rimuove                         │
│   - POST /poses/{name}/go    -> esegue posa salvata             │
│   - POST /unlock             -> proxy POST :8765/unlock         │
│  config.yaml > pose_3d.{named_poses, live_max_step_m}           │
└──────────────────────────┬──────────────────────────────────────┘
                           │ chiama _deps.g1() (robot attivo h1|g1)
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│ G1_API_Server (port 8765, esistente, NON modificato)            │
│  - GET /wrist            -> {left_wrist, right_wrist}           │
│  - POST /wrist/absolute  -> IK + motion                         │
│  - POST /unlock          -> sblocco arms (recovery error_timeout)│
└─────────────────────────────────────────────────────────────────┘

Dipendenze frontend (CDN, no build step):

  • three@0.160 (~600 KB minified)
  • three/examples/jsm/controls/OrbitControls
  • three/examples/jsm/controls/TransformControls

Caricate via <script type="importmap"> direttamente in web/index.html.

Non cambia:

  • La tab Braccio DX esistente — la tab 3D è additiva.
  • Endpoint G1_API_Server.
  • Endpoint wrist_routes.py (/api/wrist/*) generico — riusato sotto il cofano.

Componenti frontend

Scene Three.js

Scene
 ├── lights (ambient + directional)
 ├── GridHelper (piano XY a Z=0, 2m × 2m)
 ├── BaseUCS              terna RGB 200 mm, sempre visibile
 ├── Torso                Box 0.20 × 0.40 × 0.50, opacity 0.7
 │
 ├── ArmRight (Group)
 │    ├── ArmLinkRight    LineSegments shoulder→wrist
 │    ├── WristSphereRight Sphere r=0.04, ciano, hit-test target
 │    └── WristUCSRight   terna RGB 100 mm orientata dal quaternion
 │
 ├── ArmLeft (Group)      come sopra, sfera rossa
 │
 ├── GhostRight (Group)   copia ArmRight con opacity 0.4,
 │                          visibile solo se c'è target locale non applicato
 └── GhostLeft (Group)

Dimensioni torso e posizione spalle: hardcoded sensate per G1 (torso a (0,0,0.40) m, spalla dx ~(0,−0.20,0.60), sx ~(0,+0.20,0.60)). Solo estetica — non influenzano IK (server-side).

Camera: PerspectiveCamera + OrbitControls. Posizione iniziale (1, −1, 0.8) m, target (0, 0, 0.3). Wheel = zoom, drag = orbit, shift+drag = pan.

Interazione

  • Selezione polso: raycaster su mouse-click. Click su WristSphereRight o WristSphereLeft → attiva polso (highlight + TransformControls attached).
  • Click su empty space → deseleziona, gizmo nascosto.
  • TransformControls ha 2 mode: translate (frecce) / rotate (anelli). Toggle nel pannello (radio "T" / "R", oppure tasti t/r da tastiera).
  • Durante drag: posizione/rotazione del Ghost si aggiorna live; il polso "reale" resta fermo. Numeri X/Y/Z/RPY nel pannello aggiornati in tempo reale.
  • Al mouseup:
    • Editor mode: nessuna chiamata HTTP. Ghost resta lì. Per committarlo, bottone Apply.
    • Jog mode: auto-fire POST /apply con dry_run=false. Se ||target−current|| > live_max_step_m, prima dialog di conferma.

Pannello sidebar

┌─────────────────────────────────┐
│ [Editor] [Jog]   ← toggle       │
│                                 │
│ Polso attivo: ● Destro ○ Sinistro │
│ Gizmo: ● Sposta ○ Ruota         │
│ ☑ Precise (controllo accurato)  │
│                                 │
│ ─ Posa target ──────────────    │
│  X [0.300] Y [-0.200] Z [0.300] │ readonly, mm
│  R [0.0°] P [0.0°] W [0.0°]     │ readonly
│  Stato: ● modificata            │
│                                 │
│  (Editor) [Applica] [Annulla]   │
│  (Jog) nessun bottone           │
│                                 │
│ ─ Pose salvate ─────────────    │
│  ▼ [seleziona...]               │
│  [Vai] [Salva con nome] [✕]    │
│                                 │
│  [Sblocca braccia]              │
└─────────────────────────────────┘

State client

state = {
  mode: 'editor' | 'jog',
  active_side: 'right' | 'left' | null,
  gizmo_mode: 'translate' | 'rotate',
  precise: true,                  // checkbox sidebar, default true
  current: { right: {position, quaternion}, left: {position, quaternion} },
  target:  { right: {position, quaternion} | null, left: ... },
  saved_poses: { name: {side, position, quaternion, note?}, ... }
}

Cicli:

  • state.current → aggiornato ogni 1s da GET /api/pose_3d/state (auto-sync).
  • state.target[side] → aggiornato solo durante drag dell'utente.
  • Editor mode + Apply: legge state.target → POST /apply dry_run=true, precise=state.precise → al successo, state.current = state.target localmente; state.target = null.
  • Jog mode + mouseup: legge state.target → check soglia → POST /apply dry_run=false, precise=state.precise → al successo, target = null, next polling sincronizza current con il robot vero.

Componenti backend

Router src/presa_mano/api/pose_3d_routes.py

Tutti gli endpoint operano sul robot attivo globale (_deps.active_robot_key()). Supporto simmetrico per side ∈ {"left", "right"}. Coerente con il principio di selettore robot globale.

class WristPose(BaseModel):
    position: list[float]    # [x, y, z] in m
    quaternion: list[float]  # [x, y, z, w]

class NamedPose(BaseModel):
    side: Literal["left", "right"]
    position: list[float]
    quaternion: list[float]
    note: str | None = None

class StateResponse(BaseModel):
    robot_active: str
    wrist: dict[str, WristPose]      # {"left": ..., "right": ...}
    poses: dict[str, NamedPose]
    live_max_step_m: float
    timings_ms: dict[str, float]

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

# Endpoints
@router.get("/state")          # → StateResponse
@router.post("/apply")         # ApplyReq → {result, applied, timings_ms}
@router.get("/poses")          # → {name: NamedPose}
@router.put("/poses/{name}")   # NamedPose → {name, pose}
@router.delete("/poses/{name}")
@router.post("/poses/{name}/go")  # {dry_run: bool} → apply response
@router.post("/unlock")        # → bridge /unlock response

Schema config

pose_3d:
  live_max_step_m: 0.15
  named_poses:
    # vuoto al primo deploy; popolato dall'utente via API/GUI
    {}

Quaternion normalization

Server-side: prima di passare al bridge, ogni quaternion ricevuto via /apply viene normalizzato. Se ||q|| < 1e-6 → 422 quaternion is degenerate.

Edge cases

Scenario Risposta
side non in {left,right} 422 (Pydantic Literal)
position shape errato 422 (Pydantic)
quaternion degenere (zero norm) 422 esplicito
Bridge G1 down 502 G1 unavailable: <err>
Bridge arms: error_timeout passthrough: result.completed=false, stalled=true. Client mostra UI con bottone Sblocca
PUT /poses/{name} con nome non valido 422 name must match ^[A-Za-z0-9_-]{1,40}$
POST /poses/{name}/go su nome inesistente 404
DELETE /poses/{name} su nome inesistente 404
PUT /poses/{name} su nome esistente 200 (sovrascrive); frontend chiede confirm

Riuso & coesistenza

  • Non duplica la logica di right_arm_routes.py (tab Braccio DX).
  • pose_3d.named_poses indipendente da tab_right_arm.{home_pose,custom_pose}.
  • precise=True ereditato di default da G1Client (modificato in sessione precedente).

Error handling

Frontend

  • Fetch fallisce (5xx/network): banner rosso in cima al pannello con detail, niente alert modali.
  • Bridge stalled dopo Apply: pannello mostra position_error_mm e bottone "Sblocca braccia". Conserva la modifica locale (ghost resta dove l'utente l'aveva trascinato): l'utente può ritentare con Apply senza ridraggare.
  • Polling /state fallisce: dim del riquadro 3D + label "stato non aggiornato — ultimo successo: 12s fa". Non blocca interazioni offline.
  • Drag su polso, poi cambio active radio button: il drag corrente viene committato come target locale e l'altro polso diventa attivo.
  • Jog mode + delta > soglia: dialog confirm() standard con i due bottoni Sì/No.

Backend

  • Tutti gli endpoint hanno Stopwatch step e ritornano timings_ms.
  • Errori da G1Client → 502 con detail.
  • Validazione Pydantic auto su tutti i request body.

Testing

Unit (puro, no I/O)

tests/test_pose_3d_quaternion.py:

  • normalizzazione quaternion non normalizzato → norma 1
  • quaternion zero/degenere → ValueError

Integration (FastAPI TestClient + G1 mockato)

tests/test_pose_3d_routes.py:

  • test_state_returns_both_wrists — mocka _deps.g1().wrist(), verifica struttura.
  • test_state_502_on_g1_down — G1Unavailable → 502.
  • test_apply_dry_run_calls_g1_with_dry_run_true — verifica kwargs passati a wrist_absolute.
  • test_apply_real_calls_g1_dry_run_false.
  • test_apply_422_bad_quaternion_degenerate.
  • test_apply_422_bad_side.
  • test_apply_normalizes_quaternion — passa quat non normalizzato, verifica che al G1 arriva normalizzato.
  • test_put_pose_persists_in_config — usa fixture _restore_config_yaml.
  • test_put_pose_422_bad_name — nome con caratteri non validi.
  • test_get_poses_returns_saved.
  • test_delete_pose_404_when_missing.
  • test_delete_pose_removes.
  • test_go_pose_404_when_missing.
  • test_go_pose_executes_via_apply.
  • test_unlock_proxies_to_bridge — mocka httpx.

Test manuale post-implementazione

  1. Apri tab 3D → torso + 2 polso-sfere con UCS, current pose riflette robot.
  2. Click polso dx → gizmo translate appare.
  3. Trascina freccia X +50 mm → ghost dx avanti di 5 cm, numeri pannello aggiornati.
  4. Editor mode + Apply → ghost diventa current locale, robot non si muove (verifico log).
  5. Jog mode + drag +10 mm + rilascio → robot si muove di 1 cm.
  6. Jog mode + drag +200 mm → dialog conferma; cliccando No, ghost resta lì, robot fermo.
  7. Save pose "test1" → voce in dropdown.
  8. Sposta polso manualmente, poi Load+Vai "test1" → robot torna alla posa salvata.

Out of scope (rinviato a future iterazioni)

  • Visualizzazione realistica del braccio durante drag (richiede IK client-side o nuovo endpoint bridge).
  • Joint mode (controllo per giunto, es. elbow=90°) — il bridge G1 al momento non espone endpoint per setting di singoli giunti; serve estensione del bridge.
  • Streaming continuo del jog (drag attivo → robot insegue in tempo reale) — più complesso e rischioso. Per ora discreto (1 drag = 1 move al mouseup).
  • Migrazione dei named_poses dai config orchestrator ai preset del bridge G1 — pianificata ma fuori scope.
  • URDF reale del G1 con mesh — schema semplificato per ora.
  • Test JS frontend.