Plan dettagliato per la tab "Pose 3D" (writing-plans skill), basato sullo spec del 2026-05-21. 15 task suddivisi in backend (routes + tests TDD) e frontend (Three.js viewport + gizmo + mode toggle Editor/Jog). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2056 lines
62 KiB
Markdown
2056 lines
62 KiB
Markdown
# 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 `<REV>` con il numero del passo 1):
|
|
|
|
```bash
|
|
curl -fsSL \
|
|
"https://raw.githubusercontent.com/mrdoob/three.js/r<REV>/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 `<section>` + 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 `<button class="tab" data-tab="right-arm">Braccio DX</button>` (riga ~19) aggiungi:
|
|
|
|
```html
|
|
<button class="tab" data-tab="pose-3d">Pose 3D</button>
|
|
```
|
|
|
|
Poi, DOPO la chiusura della section `#tab-right-arm` e PRIMA di `#tab-ee`, aggiungi:
|
|
|
|
```html
|
|
<!-- ============================== POSE 3D ============================== -->
|
|
<section class="panel" id="tab-pose-3d">
|
|
<div class="p3-layout">
|
|
<div class="p3-viewport">
|
|
<canvas id="p3Canvas"></canvas>
|
|
<div class="p3-mode-badge" id="p3ModeBadge">Editor</div>
|
|
<div class="p3-help">click polso = seleziona · gizmo: T sposta / R ruota · drag = muovi · orbit = wheel/drag fondo · pan = shift+drag</div>
|
|
</div>
|
|
<aside class="p3-sidebar">
|
|
<div class="p3-section">
|
|
<h3>Modalità</h3>
|
|
<div class="p3-row">
|
|
<label><input type="radio" name="p3Mode" value="editor" checked> Editor (simulato)</label>
|
|
<label><input type="radio" name="p3Mode" value="jog"> Jog (move reale)</label>
|
|
</div>
|
|
<label class="p3-check"><input type="checkbox" id="p3Precise" checked> Precise (controllo accurato)</label>
|
|
</div>
|
|
|
|
<div class="p3-section">
|
|
<h3>Polso attivo</h3>
|
|
<div class="p3-row">
|
|
<label><input type="radio" name="p3Side" value="right" checked> Destro</label>
|
|
<label><input type="radio" name="p3Side" value="left"> Sinistro</label>
|
|
</div>
|
|
<div class="p3-row">
|
|
<label><input type="radio" name="p3Gizmo" value="translate" checked> T sposta</label>
|
|
<label><input type="radio" name="p3Gizmo" value="rotate"> R ruota</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p3-section">
|
|
<h3>Posa target</h3>
|
|
<div class="p3-readout">
|
|
<div>X <span id="p3X">—</span> mm</div>
|
|
<div>Y <span id="p3Y">—</span> mm</div>
|
|
<div>Z <span id="p3Z">—</span> mm</div>
|
|
<div>R <span id="p3R">—</span>°</div>
|
|
<div>P <span id="p3P">—</span>°</div>
|
|
<div>W <span id="p3W">—</span>°</div>
|
|
</div>
|
|
<div class="p3-status" id="p3Status">— polso fermo</div>
|
|
<div class="p3-row" id="p3EditorActions">
|
|
<button id="p3ApplyBtn">Applica (Editor)</button>
|
|
<button id="p3CancelBtn">Annulla modifica</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p3-section">
|
|
<h3>Pose salvate</h3>
|
|
<div class="p3-row">
|
|
<select id="p3PoseSelect"><option value="">(seleziona)</option></select>
|
|
</div>
|
|
<div class="p3-row">
|
|
<button id="p3PoseGoBtn">Vai</button>
|
|
<input type="text" id="p3PoseName" placeholder="nome" maxlength="40">
|
|
<button id="p3PoseSaveBtn">Salva</button>
|
|
<button id="p3PoseDelBtn">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p3-section">
|
|
<button id="p3UnlockBtn">Sblocca braccia</button>
|
|
</div>
|
|
|
|
<div class="p3-section" id="p3ErrorBox" style="display:none">
|
|
<div class="p3-error" id="p3ErrorText"></div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
```
|
|
|
|
- [ ] **Step 2: Aggiungi link CSS e import modulo nello head/footer**
|
|
|
|
Cerca `<link rel="stylesheet" href="/static/right_arm.css">` (o equivalente) nell'head di `web/index.html` e aggiungi DOPO:
|
|
|
|
```html
|
|
<link rel="stylesheet" href="/static/pose_3d.css">
|
|
```
|
|
|
|
In fondo al file, dopo `<script type="module" src="/static/right_arm.js"></script>` aggiungi:
|
|
|
|
```html
|
|
<script type="module" src="/static/pose_3d.js"></script>
|
|
```
|
|
|
|
- [ ] **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 = '<option value="">(seleziona)</option>';
|
|
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.
|