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>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-21 15:14:46 +02:00
1 parent 360755144c
commit 19fba42876
2 files changed
+301

No files matched your search

+3
View File
@@ -6,6 +6,9 @@
# Calibration data (per-installation, ricostruibile)
data/
# Brainstorming session files (visual companion)
.superpowers/
# Python
__pycache__/
*.py[cod]
@@ -0,0 +1,298 @@
# 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.