Files
Presa-Mano/docs/superpowers/specs/2026-05-21-pose-3d-tab-design.md
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

299 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```js
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.
```python
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
```yaml
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.