# 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 → "" (se non c'è match esatto) [FusionRig] driver "Y" (Motore asse Y) NON trovato. (se serve sistemare i nomi) [FusionRig] child non risolto per "" (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 `.glb` + `.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.