224 lines
10 KiB
Markdown
224 lines
10 KiB
Markdown
# Contratto Fusion 360 → Blender → GLB ⇄ Viewer Three.js
|
||
|
||
Documento di interfaccia **fra l'agent che produce il GLB** (Fusion 360 → Blender → glTF/GLB) e **l'agent che lo consuma nel viewer web** (Three.js + `FusionRig.js`).
|
||
Tutto ciò che è qui deve essere considerato l'unica fonte di verità: se cambia da una parte, va aggiornato in questo file e re-letto dall'altro lato.
|
||
|
||
---
|
||
|
||
## 1. Pipeline e file di scambio
|
||
|
||
```
|
||
Fusion 360 ──(add-in / script)──► joints.json (+ STEP/OBJ/STL gerarchia)
|
||
│
|
||
▼
|
||
Blender ──(importa la mesh + ricostruisce empties + scrive userData)──► plotter.glb
|
||
│
|
||
▼
|
||
Web viewer (React + Three.js) ──► FusionRig.js (cinematica live)
|
||
```
|
||
|
||
Due output:
|
||
- **`plotter.glb`** — geometria + gerarchia + `userData` per ogni nodo (vedi §3).
|
||
- **`joints.json`** — manifest cinematico esportato da Fusion (vedi §2). Può essere embedded nel GLB come `scene.extras.fusion` (stringa JSON).
|
||
|
||
---
|
||
|
||
## 2. Schema `joints.json`
|
||
|
||
```jsonc
|
||
{
|
||
"metadata": {
|
||
"units": "mm",
|
||
"internalUnit": "cm",
|
||
"scaleFactor": 0.01
|
||
},
|
||
"joints": [ /* Joint */ ],
|
||
"asBuiltJoints": [ /* Joint */ ],
|
||
"motionLinks": [ /* MotionLink */ ],
|
||
"rigidGroups": [ /* RigidGroup */ ]
|
||
}
|
||
```
|
||
|
||
### 2.1 `Joint`
|
||
|
||
```jsonc
|
||
{
|
||
"name": "Motore asse X", // chiave univoca dentro la sezione
|
||
"type": "revolute" | "slider" | "rigid" | ...,
|
||
"child": "6627T281_Stepper Motor:1", // occurrenceName foglia
|
||
"parent": "asse X:1", // occurrenceName (può essere "ROOT")
|
||
"childFullPath": "plotter:1+asse X:1+6627T281_Stepper Motor:1",
|
||
"parentFullPath": "plotter:1+asse X:1",
|
||
"axis": [-1, 0, 0], // world-space al momento dell'export, normalizzato
|
||
"origin": [12.34, 5.67, 0.0], // world-space, in cm
|
||
"slideValue": 0.0, // posa corrente (cm) — solo se slider
|
||
"rotationValue": 0.0, // posa corrente (rad) — solo se revolute
|
||
"slideLimits": {
|
||
"isMinimumValueEnabled": true,
|
||
"isMaximumValueEnabled": true,
|
||
"minimumValue": -1.8,
|
||
"maximumValue": 0.0
|
||
},
|
||
"rotationLimits": { /* idem, valori in rad */ }
|
||
}
|
||
```
|
||
|
||
### 2.2 `MotionLink`
|
||
|
||
```jsonc
|
||
{
|
||
"name": "Collegamento movimento 8",
|
||
"joint1": "Scorrimento 1" | null, // slave (può essere null se Fusion non risolve)
|
||
"joint2": "Motore asse Y", // driver (sempre uno dei 5)
|
||
"ratio": 1.0 | null, // null = 1
|
||
"reversed": true
|
||
}
|
||
```
|
||
|
||
### 2.3 `RigidGroup`
|
||
|
||
```jsonc
|
||
{
|
||
"name": "Gruppo 1",
|
||
"occurrenceNames": ["asse X:1", "carrello X:1", "6627T281_Stepper Motor:1", ...]
|
||
}
|
||
```
|
||
|
||
### 2.4 Unità (CRITICO)
|
||
|
||
| Quantità | Unità nel JSON | Unità nel viewer |
|
||
|---|---|---|
|
||
| `origin`, `slideValue`, `slideLimits.*` | **cm** | metri (moltiplicare × 0.01) |
|
||
| `axis` | adimensionale, normalizzato | idem |
|
||
| `rotationValue`, `rotationLimits.*` | **rad** | rad (nessuna conversione) |
|
||
|
||
Il GLB **deve essere in metri** (`metadata.units = "meters"` lato glTF). Se Blender esporta in cm o mm, il viewer deve scalare la scena oppure, meglio, **Blender va configurato per esportare in metri**.
|
||
|
||
---
|
||
|
||
## 3. Convenzioni `plotter.glb`
|
||
|
||
### 3.1 Gerarchia
|
||
|
||
Ogni occorrenza Fusion = un `Object3D` (Empty Blender) **conservato come nodo separato** nel glTF. **Niente merge** delle geometrie: il viewer ha bisogno di muoverle singolarmente.
|
||
|
||
### 3.2 `userData` per nodo
|
||
|
||
Per ogni `Object3D` corrispondente a un'occorrenza Fusion:
|
||
|
||
```jsonc
|
||
{
|
||
"userData": {
|
||
"fusion_name": "6627T281_Stepper Motor:1", // = Joint.child / RigidGroup.occurrenceNames[i]
|
||
"fusion_path": "plotter:1+6627T281_Stepper Motor:1" // = Joint.childFullPath
|
||
}
|
||
}
|
||
```
|
||
|
||
Questi due campi **devono coincidere byte per byte** con i nomi del JSON (compresi `:1`, spazi, accenti, `?`, ecc.). Niente sanitizzazione.
|
||
|
||
Implementazione consigliata in Blender:
|
||
```python
|
||
# In ogni Empty, dopo la ricostruzione:
|
||
empty.name = nome_pulito_safe # qualunque cosa
|
||
empty["fusion_name"] = original_occurrence_name
|
||
empty["fusion_path"] = original_full_path
|
||
```
|
||
> Le custom properties Blender finiscono in `node.extras` nel glTF, che `GLTFLoader` di Three.js espone su `obj.userData`.
|
||
|
||
### 3.3 Manifest embedded (opzionale ma consigliato)
|
||
|
||
Lo stesso `joints.json` può essere embedded nella scena:
|
||
```python
|
||
bpy.context.scene["fusion"] = json.dumps(manifest)
|
||
```
|
||
→ il viewer lo legge da `gltf.parser.json.scenes[0].extras.fusion` (oppure `scene.userData.fusion`). Se assente, il viewer si aspetta che l'utente carichi il `.json` separatamente.
|
||
|
||
---
|
||
|
||
## 4. I 5 driver pilotati nel viewer
|
||
|
||
| Chiave UI | Sezione | `name` (canonico) | Tipo | `child` previsto | `axis` previsto |
|
||
|---|---|---|---|---|---|
|
||
| `A` | `asBuiltJoints` | `Motore A` | revolute | `6627T331_Stepper Motor (1) (1):1` | `[0, 0, 1]` |
|
||
| `X` | `joints` | `Motore asse X` | revolute | `6627T281_Stepper Motor:1` | `[-1, 0, 0]` |
|
||
| `Y` | `joints` | `Motore asse Y` | revolute | `6627T281_Stepper Motor (1):1` | `[0, 0, 1]` |
|
||
| `PEN` | `joints` | `Asse Penna ` *(spazio finale)* | slider | `guida lineare:1` | `[0, 0, 1]` |
|
||
| `Z` | `joints` | `asse Z pneumatico M5?` | slider | `TN10*50:1` | `[0, 0, 1]` |
|
||
|
||
> Il viewer applica match esatto sul `name`; se fallisce, fa fallback **fuzzy** (token-subset case-insensitive). I token attesi sono in `frontend/src/lib/FusionRig.js` const `DRIVERS`. Se vengono cambiati i nomi in Fusion, aggiornare quei token in modo coordinato.
|
||
|
||
---
|
||
|
||
## 5. Regole di simulazione (lato viewer, per documentazione)
|
||
|
||
Implementate in `FusionRig.js`. Servono qui solo perché chi produce il GLB capisca cosa il viewer si aspetta:
|
||
|
||
1. **Rigid groups → rigid components** via union-find: muovere un'occorrenza muove tutta la sua component con lo stesso delta world-space.
|
||
2. **Motion links** propagano valore driver → joint slave (`v_slave = v_driver * ratio * (reversed ? -1 : 1)`). Link con `joint1: null` vengono ignorati: chi produce il GLB deve evitarli quando possibile (o documentare a parte la coppia mancante).
|
||
3. **Posa iniziale** = `matrixWorld` di ogni nodo al loader-end. Ogni update parte da zero, niente accumuli. Quindi lo stato neutro nel GLB **deve essere la posa "zero" di tutti i driver** (`A=0, X=0, Y=0, PEN=0, Z=0`).
|
||
4. **Asse e origine** dei joint sono world-space già normalizzati per le transform di assembly: l'add-in Fusion li deve esportare nel frame world dell'assembly esportato.
|
||
|
||
---
|
||
|
||
## 6. Checklist per chi produce il GLB
|
||
|
||
Prima di consegnare un nuovo `plotter.glb`:
|
||
|
||
- [ ] Il GLB è in metri (scena Blender: Scene → Units → Meters, scale 1.0).
|
||
- [ ] La gerarchia degli Empty Fusion è preservata 1:1 (un Empty per occorrenza, anche per i sotto-assembly).
|
||
- [ ] Ogni Empty ha `fusion_name` e `fusion_path` come custom properties, **identici** al JSON.
|
||
- [ ] Le mesh sono nodi figli degli Empty (non spostate alla scena root, non joinate).
|
||
- [ ] Lo stato della scena salvata = posa "tutti i driver a zero".
|
||
- [ ] (Opzionale) `bpy.context.scene["fusion"] = json.dumps(manifest)` con il JSON completo.
|
||
- [ ] Smoke-test: aprire il GLB nel viewer `/lab`, espandere `[FusionRig] manifest contents` in console, verificare che i 5 driver siano risolti (anche via fuzzy) e che `rigidComponent` di ognuno contenga le occorrenze corrette.
|
||
|
||
---
|
||
|
||
## 7. Output di diagnostica del viewer
|
||
|
||
All'apertura del file il viewer logga in console:
|
||
|
||
```
|
||
[FusionRig] manifest contents ← elenco completo dei joint / motionLinks / rigidGroups
|
||
[FusionRig] driver "X" risolto via fuzzy → "<nome reale>" (se non c'è match esatto)
|
||
[FusionRig] driver "Y" (Motore asse Y) NON trovato. (se serve sistemare i nomi)
|
||
[FusionRig] child non risolto per "<joint>" (se userData.fusion_name è sbagliato)
|
||
[FusionRig] X (Motore asse X) { axis, origin, child, rigidComponent: [...] }
|
||
```
|
||
|
||
Se l'agent GLB chiede "perché Y non si muove", la console contiene la risposta in 3 righe.
|
||
|
||
---
|
||
|
||
## 8. Cose da chiarire / problemi aperti
|
||
|
||
> Aggiornare questa sezione man mano. Mantenere un punto per problema, marcando `[OPEN] / [RESOLVED YYYY-MM-DD]`.
|
||
|
||
- [OPEN] I motion-link `joint1: null` di Fusion vanno risolti per nome (es. `Scorrimento 1`, `Rivoluzione 9`)? Oppure l'agent Fusion può ri-esportarli con i nomi corretti?
|
||
- [OPEN] Confermare che `Motore A` esista davvero in `asBuiltJoints` con quel nome esatto (problemi di matching nel viewer suggeriscono possibili varianti).
|
||
- [OPEN] Definire una convenzione per nuove "macchine" (oltre al plotter): file `<machine>.glb` + `<machine>.joints.json` + variant di `DRIVERS` nel viewer.
|
||
|
||
---
|
||
|
||
## 9. Come "centralizzare" la collaborazione fra i due agent
|
||
|
||
Tre opzioni in ordine crescente di automazione:
|
||
|
||
1. **Repo condiviso + `AGENTS.md` / `copilot-instructions.md`** (consigliato, zero infra)
|
||
- Mettere questo file in un repo Git unico (sotto `docs/`) o in un repo dedicato `plotter-contract/` che entrambi i progetti includono come submodule o tramite `git read-tree`.
|
||
- In ogni repo aggiungere `AGENTS.md` con: *"Prima di toccare l'export GLB/JSON o il viewer, leggere `docs/FUSION_GLB_CONTRACT.md`. Le modifiche al contratto vanno proposte in PR su quel file."*
|
||
- VS Code Copilot Chat carica automaticamente `AGENTS.md` come instructions.
|
||
|
||
2. **Issue tracker condiviso (GitHub Projects / linear)**
|
||
- Un solo board con label `contract` per task che attraversano i due agent.
|
||
- Ogni issue cross-side referenzia la riga di `FUSION_GLB_CONTRACT.md` rilevante.
|
||
|
||
3. **MCP server condiviso** (massima automazione, più infra)
|
||
- Esporre il contratto come tool MCP (`fusion_contract.get_joint("Motore asse X")` → restituisce nome canonico, child atteso, axis, fuzzy tokens).
|
||
- Entrambi gli agent si connettono allo stesso server MCP e leggono il contratto on-demand.
|
||
- Vantaggio: cambia il contratto in un posto, entrambi gli agent ricevono subito la versione nuova.
|
||
- Costo: scrivere e mantenere un piccolo server MCP (Python/Node ~150 righe).
|
||
|
||
**Raccomandazione:** partire dall'opzione 1 (questo file + `AGENTS.md` in entrambi i repo). Passare a 3 solo se le iterazioni diventano frequenti e i naming change rompono spesso il viewer.
|