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:
1 parent
360755144c
commit
19fba42876
2 files changed
+301
No files matched your search
@@ -6,6 +6,9 @@
|
|||||||
# Calibration data (per-installation, ricostruibile)
|
# Calibration data (per-installation, ricostruibile)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Brainstorming session files (visual companion)
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.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.
|
||||||
Reference in new issue
Block a user