Initial commit: Fusion->Blender->GLB pipeline + contract for ThreeJS bridge

This commit is contained in:
marco
2026-06-09 17:43:13 +02:00
commit cdf4bfb3ab
9 changed files with 2829 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Build artifacts e output binari (rigenerati da ExportKinematicGraph + build_glb)
*.glb
*.gltf
*.obj
*.mtl
*.stl
*.step
*.stp
hierarchy.json
joints.json
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyo
# IDE / editor
.vscode/
.idea/
*.swp
*.swo
# Windows / OneDrive
Thumbs.db
desktop.ini
~$*
# Log e file temporanei
*.log
*.tmp
*.bak

42
AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# Istruzioni per gli agent (Copilot Chat & simili)
Questo repository è **condiviso tra due agent**:
- **Agent A — Fusion/Blender side**: lavora su `ExportKinematicGraph.py` (add-in Autodesk Fusion 360) e `build_glb_from_fusion_export.py` (script Blender). Genera `plotter.glb` + `joints.json`.
- **Agent B — Three.js viewer side**: consuma `plotter.glb` in un viewer web (React + Three.js) tramite `FusionRig.js`. Vive in **un altro workspace/repo**.
## Regole d'oro
1. **Prima di toccare l'export o il viewer, leggi [FUSION_GLB_CONTRACT.md](FUSION_GLB_CONTRACT.md).** È la fonte di verità su schema JSON, convenzioni `userData`, unità di misura, ecc. Se serve cambiare qualcosa, modifica **prima** il contratto e commit-a in un branch dedicato.
2. **Annotare i problemi aperti in [BRIDGE_NOTES.md](BRIDGE_NOTES.md)**, sezione "Risposte ai problemi aperti". Marcare `[OPEN]` o `[RESOLVED YYYY-MM-DD]`.
3. **Non rinominare i 5 driver** (`Motore A`, `Motore asse X`, `Motore asse Y`, `Asse Penna ` con spazio finale, `asse Z pneumatico M5?`) senza un commit coordinato anche sul viewer. Vedi §4 del contratto.
4. **Mai** committare:
- file binari di output (`*.glb`, `*.obj`, `hierarchy.json`, `joints.json` rigenerabili) — già esclusi via `.gitignore`
- credenziali, token, chiavi SSH
5. **Branch convention**: `feature/<nome-breve>` per modifiche, mai push diretto su `main`. Aprire PR/MR su Gitea.
6. **Commit message**: prima riga ≤ 72 char, prefisso area:
- `fusion: ...` per modifiche ad `ExportKinematicGraph.py`
- `blender: ...` per `build_glb_from_fusion_export.py`
- `contract: ...` per il `.md` di contratto
- `bridge: ...` per le note operative
7. **Operazioni distruttive**: niente `git push --force`, niente `git reset --hard` su branch condivisi. Mai.
## Come l'altro agent legge questo contratto
L'agent Three.js può scaricare il contratto direttamente dalla raw URL Gitea:
```
https://git.automationdev.info/automationkriz/syncro_multi_agente/raw/branch/main/FUSION_GLB_CONTRACT.md
```
In alternativa, può clonare il repo e aggiungerlo come folder al workspace VS Code per averlo sempre in contesto.
## Stack tecnico (riepilogo)
- **Fusion 360 API**: Python 3.10 (integrato in Fusion). Add-in installato in `%APPDATA%\Autodesk\Autodesk Fusion 360\API\Scripts\ExportKinematicGraph\`.
- **Blender 4.2 LTS**: Python 3.11. Script eseguito in modalità `--background`.
- **Output**: `plotter.glb` (binario glTF 2.0) in `C:\Users\croce\OneDrive\Desktop\export\`.
## Comandi tipici
Vedi sezione "Comandi operativi" di [BRIDGE_NOTES.md](BRIDGE_NOTES.md).

73
BRIDGE_NOTES.md Normal file
View File

@@ -0,0 +1,73 @@
# Note operative — produttore GLB (Fusion → Blender)
Questo file è la sponda **produttore** del [FUSION_GLB_CONTRACT.md](FUSION_GLB_CONTRACT.md).
Tutte le risposte ai problemi `[OPEN]` del contratto vivono qui finché non vengono spostate nello stato `[RESOLVED]` del contratto stesso.
---
## Risposte ai problemi aperti del contratto §8
### 1) Motion link con `joint1: null`
**Stato:** è un limite dell'API Fusion. Quando l'utente crea il motion link tramite la finestra "Collegamento movimento", Fusion lo memorizza con un riferimento interno (entity-token) ma il proxy `motionLink.entityOne` può ritornare `None` se il joint è stato creato/rinominato dopo il link. L'add-in **non** può inventarsi il nome.
**Workaround consigliato a chi consuma:**
- I 5 driver (`A`, `X`, `Y`, `PEN`, `Z`) NON sono mai `null` su `joint2`: i link con `joint2 == driver` sono usabili anche senza `joint1` (basta non propagare nulla).
- Per i link con `joint2: null` AND `joint1: null` (es. `"Collegamento movimento 32"`, `"Collegamento movimento 4"`): sono rumore, ignorabili.
- Per i link con `joint1: null` ma `joint2` valorizzato (es. `"Collegamento movimento 14"``Motore asse X`, `"Collegamento movimento 28"``Rivoluzione 26`, `"Collegamento movimento 38"``Asse Penna `):
- **`Motore asse X`** è il driver, non ha bisogno di slave esterni — la traslazione del carrello X è già descritta dal rigid group `Gruppo rigido 6` (`end stop X PIastrina:1 | binario SX:1 | Cinghia T5:1`).
- **`Asse Penna `** idem: lo slider muove direttamente `guida lineare:1`, gli altri pezzi seguono via rigid groups.
**TL;DR per il viewer:** ignora tranquillamente i link con `joint1: null`. Tutta la propagazione necessaria passa dai rigid groups + i 5 driver diretti.
### 2) `Motore A` esiste in `asBuiltJoints`?
**Confermato.** Verificato sul file corrente: presente con nome esatto `"Motore A"`, tipo `revolute`, `child = "6627T331_Stepper Motor (1) (1):1"`, `axis ≈ [0, 0, 1]`.
L'eventuale mismatch lato viewer dipende dal `child` name: nel JSON è esattamente `"6627T331_Stepper Motor (1) (1):1"` (due " (1)" di seguito), che nasce dal fatto che in Fusion il componente è stato copiato/incollato due volte. Se il viewer non lo trova, il bug è nel matching, non nell'export.
### 3) Convenzione multi-macchina
Da concordare quando servirà. Proposta minimale:
```
exports/
plotter/
plotter.glb
plotter.joints.json
<altra_macchina>/
<altra_macchina>.glb
<altra_macchina>.joints.json
```
Lato viewer un file `machines.json` indicizza nome → coppia (glb, json, DRIVERS map).
---
## Stato attuale dell'export (rispetto alla checklist §6 del contratto)
| Voce checklist | Stato | Note |
|---|---|---|
| GLB in metri | ✅ | `build_glb_from_fusion_export.py` applica `scaleFactor=0.01` al root, mesh OBJ importate in cm sono rimpicciolite di 100× → metri |
| Gerarchia Empty 1:1 | ✅ | Pass 1 crea un Empty per ogni `node` del `hierarchy.json` |
| `fusion_name` e `fusion_path` per ogni Empty | ✅ (dal 2026-06-09) | Aggiunti come custom properties; coincidono byte-per-byte con `Joint.child` / `Joint.childFullPath` |
| Mesh figlie degli Empty, non joinate | ✅ | OBJ importati e re-parentati all'Empty corrispondente |
| Stato salvato = posa "tutti i driver a 0" | ⚠️ | Posa "as built" di Fusion al momento dell'export. Se l'utente esporta con `slideValue ≠ 0` o `rotationValue ≠ 0` la posa neutra slitta. **Convenzione:** in Fusion riportare i 5 driver a zero prima di lanciare lo script. |
| Manifest embedded `scene["fusion"]` | ✅ | Il GLB contiene già il JSON completo in `scene.extras.fusion` |
---
## Comandi operativi
Rigenera GLB dopo modifica codice:
```powershell
.\build_glb.bat "C:\Users\croce\OneDrive\Desktop\export"
```
Riallinea script Fusion (da fare dopo ogni `edit` di `ExportKinematicGraph.py`):
```powershell
python -c "import py_compile; py_compile.compile(r'C:\Users\croce\OneDrive\Desktop\export grafo fusion\ExportKinematicGraph.py', doraise=True); print('OK')"; if($LASTEXITCODE -eq 0){ Copy-Item -Force 'C:\Users\croce\OneDrive\Desktop\export grafo fusion\ExportKinematicGraph.py' (Join-Path $env:APPDATA 'Autodesk\Autodesk Fusion 360\API\Scripts\ExportKinematicGraph'); Write-Host "Sync OK" }
```
Sanity-check sui driver nel JSON:
```powershell
$j = Get-Content 'C:\Users\croce\OneDrive\Desktop\export\joints.json' -Raw | ConvertFrom-Json
$names = @('Motore asse X','Motore asse Y','Asse Penna ','asse Z pneumatico M5?')
foreach($n in $names){ $j.joints | Where-Object name -eq $n | Select-Object name,type,child,axis,@{n='limits';e={ if($_.slideLimits){'slide '+$_.slideLimits.minimumValue+'..'+$_.slideLimits.maximumValue}elseif($_.rotationLimits){'rot '+$_.rotationLimits.minimumValue+'..'+$_.rotationLimits.maximumValue} }} | Format-List }
$j.asBuiltJoints | Where-Object name -eq 'Motore A' | Select-Object name,type,child,axis | Format-List
```

1710
ExportKinematicGraph.py Normal file

File diff suppressed because it is too large Load Diff

223
FUSION_GLB_CONTRACT.md Normal file
View File

@@ -0,0 +1,223 @@
# 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.

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# syncro_multi_agente
Pipeline **Autodesk Fusion 360 → Blender → GLB** + contratto di interfaccia per il viewer **Three.js**.
Repository condiviso tra:
- **Agent A** (questo repo) — produce `plotter.glb` e `joints.json` da Fusion.
- **Agent B** (altro workspace) — consuma il GLB in un viewer web con `FusionRig.js`.
## Contenuto
| File | Scopo |
|---|---|
| [FUSION_GLB_CONTRACT.md](FUSION_GLB_CONTRACT.md) | **Fonte di verità.** Schema `joints.json`, convenzioni `userData` nel GLB, unità di misura, lista dei 5 driver pilotati. |
| [BRIDGE_NOTES.md](BRIDGE_NOTES.md) | Risposte ai problemi `[OPEN]` del contratto e stato della checklist. |
| [THREEJS_USAGE.md](THREEJS_USAGE.md) | Snippet copia-incolla per caricare e pilotare `plotter.glb` in Three.js. |
| [AGENTS.md](AGENTS.md) | Regole operative per Copilot Chat (entrambi gli agent). |
| `ExportKinematicGraph.py` | Add-in/script Fusion 360: produce `hierarchy.json` + `joints.json` + meshes OBJ. |
| `build_glb_from_fusion_export.py` | Script Blender: importa la mesh, ricostruisce la gerarchia con `userData`, embedda il manifest, esporta `plotter.glb`. |
| `build_glb.bat` | Wrapper Windows per lanciare Blender headless. |
## Quick start (lato produttore)
1. In **Fusion 360**: `Utilities → ADD-INS → Scripts → ExportKinematicGraph → Run`.
2. Lo script chiede una cartella di output (default: `C:\Users\croce\OneDrive\Desktop\export`).
3. Da PowerShell:
```powershell
cd "C:\Users\croce\OneDrive\Desktop\export grafo fusion"
.\build_glb.bat "C:\Users\croce\OneDrive\Desktop\export"
```
4. Output: `plotter.glb` pronto per il viewer.
## Lato consumatore (Three.js)
Vedi [THREEJS_USAGE.md](THREEJS_USAGE.md) e [FUSION_GLB_CONTRACT.md §3-§5](FUSION_GLB_CONTRACT.md).
## Convenzioni di lavoro
Vedi [AGENTS.md](AGENTS.md) — in particolare le regole su branch, commit message e cosa NON committare.
## Stato attuale
Vedi sezione "Stato attuale dell'export" in [BRIDGE_NOTES.md](BRIDGE_NOTES.md).

119
THREEJS_USAGE.md Normal file
View File

@@ -0,0 +1,119 @@
# Three.js — come usare plotter.glb
## Caricamento e accesso a joints/motion links
```javascript
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('plotter.glb', (gltf) => {
const root = gltf.scene;
scene.add(root);
// ----------------------------------------------------------
// 1) Dati Fusion (joints, asBuiltJoints, motionLinks)
// Salvati in scene.extras.fusion come stringa JSON
// ----------------------------------------------------------
const sceneIndex = gltf.parser.json.scene ?? 0;
const sceneJson = gltf.parser.json.scenes[sceneIndex];
let fusion = null;
try {
const raw = sceneJson?.extras?.fusion;
fusion = raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('Fusion extras non parsabile', e);
}
if (fusion) {
console.log('Joints:', fusion.joints.length);
console.log('AsBuilt:', fusion.asBuiltJoints.length);
console.log('MotionLinks:', fusion.motionLinks.length);
console.log('Metadata:', fusion.metadata);
}
// ----------------------------------------------------------
// 2) Indice nome -> Object3D per trovare i nodi del joint
// Ogni Empty ha userData.fusion_fullPathName
// ----------------------------------------------------------
const byFullPath = new Map();
root.traverse((obj) => {
const fp = obj.userData?.fusion_fullPathName;
if (fp) byFullPath.set(fp, obj);
});
// ----------------------------------------------------------
// 3) Esempio: applicare un joint slider
// ----------------------------------------------------------
const j = fusion.joints.find(jt => jt.type === 'slider');
if (j) {
const child = byFullPath.get(j.childFullPath);
if (child) {
const axis = new THREE.Vector3(...j.axis).normalize();
// Sposta il child lungo l'asse di 100 mm = 0.1 m
child.position.addScaledVector(axis, 0.1);
}
}
// ----------------------------------------------------------
// 4) Esempio: applicare un joint revolute
// ----------------------------------------------------------
const r = fusion.joints.find(jt => jt.type === 'revolute');
if (r) {
const child = byFullPath.get(r.childFullPath);
if (child) {
const axis = new THREE.Vector3(...r.axis).normalize();
const angle = Math.PI / 4; // 45 gradi
// Rotazione attorno all'asse, applicata in local space
child.rotateOnAxis(axis, angle);
}
}
});
```
## Struttura di `scene.extras.fusion`
```jsonc
{
"metadata": { "units": "mm", "scaleFactor": 0.01, ... },
"joints": [
{
"name": "joint_carrello_y",
"type": "slider", // rigid | revolute | slider | cylindrical | pin_slot | planar | ball
"parent": "CARRELLO_X",
"child": "CARRELLO_Y",
"parentFullPath": "TELAIO:1+CARRELLO_X:1",
"childFullPath": "TELAIO:1+CARRELLO_X:1+CARRELLO_Y:1",
"origin": [x, y, z], // in metri
"axis": [x, y, z], // direzione unitaria
"secondaryAxis": [...], // solo pin_slot / planar
"slideLimits": { "minimumValue": 0, "maximumValue": 0.22, ... }, // metri
"rotationLimits": { ... }, // radianti
"slideValue": 0.05, // metri (posizione corrente)
"rotationValue": 0.5, // radianti
"isSuppressed": false,
"isLightBulbOn": true,
"isLocked": false
}
],
"asBuiltJoints": [ /* stessa shape di joints */ ],
"motionLinks": [
{
"name": "ML1",
"joint1": "joint_motor",
"joint2": "joint_belt",
"ratio": 2.5,
"reversed": false,
"isSuppressed": false
}
]
}
```
## Note
- **Unità:** GLB e dati joint sono in **metri**.
- **Asse Y-up:** convenzione glTF (cambiata da Blender al momento dell'export).
- **Nodi Fusion:** ogni Empty Blender ha `userData.fusion_fullPathName`, `userData.fusion_componentName`, `userData.fusion_id` (visibili in `Object3D.userData` lato Three.js).
- **Mesh:** sono parentate sotto gli Empty corrispondenti. Per muovere un sotto-assieme, sposta l'Empty (Three.js eredita le trasformazioni come ti aspetti).
- **Motion links:** la logica di "vincolo" tra joints (ratio, reversed) la devi implementare tu nel render loop: leggi `slideValue/rotationValue` di joint1, calcola joint2 = joint1 × ratio (con segno), applica a `byFullPath.get(joint2.childFullPath)`.

64
build_glb.bat Normal file
View File

@@ -0,0 +1,64 @@
@echo off
REM ============================================================
REM build_glb.bat
REM Lancia Blender in background per ricostruire il GLB
REM partendo dall'export Fusion (export\hierarchy.json).
REM
REM Uso: doppio click, oppure:
REM build_glb.bat (default: export\ -> export\plotter.glb)
REM build_glb.bat C:\percorso\export (cartella custom)
REM build_glb.bat C:\percorso\export out.glb (output custom)
REM ============================================================
setlocal
REM --- Path Blender (modifica qui se cambi versione) ---
set "BLENDER=C:\Program Files\Blender Foundation\Blender 4.2\blender.exe"
REM --- Argomenti ---
set "EXPORT_DIR=%~1"
if "%EXPORT_DIR%"=="" set "EXPORT_DIR=%~dp0export"
set "OUTPUT=%~2"
if "%OUTPUT%"=="" set "OUTPUT=%EXPORT_DIR%\plotter.glb"
set "HIERARCHY=%EXPORT_DIR%\hierarchy.json"
set "SCRIPT=%~dp0build_glb_from_fusion_export.py"
REM --- Controlli ---
if not exist "%BLENDER%" (
echo [ERRORE] Blender non trovato in: %BLENDER%
echo Modifica la variabile BLENDER in questo file.
pause
exit /b 1
)
if not exist "%HIERARCHY%" (
echo [ERRORE] hierarchy.json non trovato in: %HIERARCHY%
echo Lancia prima l'export da Fusion 360.
pause
exit /b 1
)
if not exist "%SCRIPT%" (
echo [ERRORE] Script Blender non trovato: %SCRIPT%
pause
exit /b 1
)
echo.
echo Blender: %BLENDER%
echo Script: %SCRIPT%
echo Hierarchy: %HIERARCHY%
echo Output: %OUTPUT%
echo.
"%BLENDER%" --background --python "%SCRIPT%" -- "%HIERARCHY%" "%OUTPUT%"
set "RC=%ERRORLEVEL%"
echo.
if "%RC%"=="0" (
echo [OK] GLB generato: %OUTPUT%
) else (
echo [ERRORE] Blender ha terminato con codice %RC%
)
pause
endlocal

View File

@@ -0,0 +1,524 @@
# -*- coding: utf-8 -*-
"""
build_glb_from_fusion_export.py
-------------------------------
Script Blender che ricostruisce un assieme Fusion 360 esportato dallo
script `ExportKinematicGraph.py` (cartella con `hierarchy.json` + `meshes/`)
e produce un file `.glb`.
Uso (da terminale):
blender --background --python build_glb_from_fusion_export.py -- \
path/to/export/hierarchy.json path/to/output/plotter.glb
Argomenti dopo il `--`:
1. Percorso al file `hierarchy.json` esportato da Fusion.
2. Percorso del file `.glb` di output.
Comportamento:
* Pulisce la scena Blender.
* Per ogni nodo della gerarchia crea un Empty (con UUID -> nome univoco).
* Imposta il parent secondo `parentFullPathName`.
* Applica la `transform` 4x4 LOCALE letta dal JSON (Fusion = row-major,
Blender = column-major: facciamo il transpose).
* Se il nodo ha `meshFile`, importa l'OBJ/STL e lo parenta all'Empty
con trasformazione locale identita'.
* Applica lo scaleFactor (cm -> m) al root in modo che l'export GLB
finisca in metri (convenzione glTF).
* Esporta in `.glb` (binary glTF 2.0).
Robustezza:
* Compatibile con Blender 3.x e 4.x (rileva l'importer OBJ corretto).
* Tutto incapsulato in try/except con log su stderr; exit code != 0
in caso di errori non recuperabili.
"""
import bpy
import json
import math
import os
import sys
import traceback
from mathutils import Matrix
# =============================================================================
# Argomenti CLI dopo il "--"
# =============================================================================
def _parse_args():
"""Estrae gli argomenti passati dopo il `--` (convenzione Blender)."""
argv = sys.argv
if '--' not in argv:
raise SystemExit(
'Uso: blender --background --python build_glb_from_fusion_export.py '
'-- <hierarchy.json> <output.glb>'
)
extra = argv[argv.index('--') + 1:]
if len(extra) < 2:
raise SystemExit(
'Servono 2 argomenti dopo "--": hierarchy.json e output.glb'
)
return extra[0], extra[1]
# =============================================================================
# Pulizia scena
# =============================================================================
def _wipe_scene():
"""Rimuove tutto dalla scena attiva (oggetti, collezioni orphan, ecc.)."""
# Selezione di tutto e cancellazione.
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
# Pulizia di datablock orfani (mesh / material / image residui).
for collection_attr in ('meshes', 'materials', 'images', 'curves',
'lights', 'cameras'):
try:
data = getattr(bpy.data, collection_attr)
for item in list(data):
if item.users == 0:
data.remove(item)
except Exception:
pass
# =============================================================================
# Conversione matrice JSON -> mathutils.Matrix
# =============================================================================
def _matrix_from_list(values):
"""Converte una lista di 16 float (row-major, come esportato da Fusion)
in `mathutils.Matrix`. Ritorna l'identita' se `values` non e' valido.
"""
if not values or len(values) != 16:
return Matrix.Identity(4)
rows = [
values[0:4],
values[4:8],
values[8:12],
values[12:16],
]
return Matrix(rows)
# =============================================================================
# Import mesh (OBJ / STL) cross-version Blender
# =============================================================================
def _import_mesh(filepath):
"""Importa un OBJ o STL e ritorna la lista di Object importati.
Gestisce sia Blender 4.x (operator nativo `wm.obj_import`) sia 3.x
(addon `import_scene.obj`). Per STL usa `import_mesh.stl` (presente
in entrambi).
"""
before = set(bpy.data.objects)
ext = os.path.splitext(filepath)[1].lower()
try:
if ext == '.obj':
# Blender 4.x: importer C nativo.
if hasattr(bpy.ops.wm, 'obj_import'):
bpy.ops.wm.obj_import(filepath=filepath)
else:
# Blender 3.x: addon Python.
bpy.ops.import_scene.obj(filepath=filepath)
elif ext == '.stl':
if hasattr(bpy.ops.wm, 'stl_import'):
bpy.ops.wm.stl_import(filepath=filepath)
else:
bpy.ops.import_mesh.stl(filepath=filepath)
else:
print('[WARN] Estensione mesh non supportata: {0}'.format(ext),
file=sys.stderr)
return []
except Exception:
print('[ERROR] Import mesh fallito: {0}\n{1}'.format(
filepath, traceback.format_exc()), file=sys.stderr)
return []
return [obj for obj in bpy.data.objects if obj not in before]
# =============================================================================
# Costruzione gerarchia
# =============================================================================
def _make_unique_empty_name(node):
"""Nome univoco per l'Empty di un nodo, usando il fullPathName."""
# Blender limita i nomi a 63 char; fullPathName puo' essere lungo:
# usiamo gli ultimi 50 char + un hash breve per evitare collisioni.
name = node.get('name') or 'node'
full = node.get('fullPathName') or node.get('id') or name
suffix = '#{0:08x}'.format(abs(hash(full)) & 0xFFFFFFFF)
return (name[:50] + suffix)
# Cache materiali condivisi: chiave = tupla RGBA arrotondata -> bpy.Material
_MATERIAL_CACHE = {}
def _make_color_material(color):
"""Ritorna un materiale Blender con `Base Color` = color (RGBA 0-1).
`color` puo' essere None / lista [r,g,b] / lista [r,g,b,a].
Il materiale viene cached per RGBA arrotondata, in modo che colori
uguali condividano lo stesso material slot.
Ritorna None se `color` non e' valido.
"""
if not color or not isinstance(color, list) or len(color) < 3:
return None
try:
r, g, b = float(color[0]), float(color[1]), float(color[2])
a = float(color[3]) if len(color) >= 4 else 1.0
except Exception:
return None
# Chiave cache: arrotonda a 3 decimali (~256 livelli per canale).
key = (round(r, 3), round(g, 3), round(b, 3), round(a, 3))
cached = _MATERIAL_CACHE.get(key)
if cached is not None:
return cached
mat = bpy.data.materials.new(name='FusionMat_{0:02x}{1:02x}{2:02x}'.format(
int(r * 255), int(g * 255), int(b * 255)))
mat.use_nodes = True
try:
bsdf = mat.node_tree.nodes.get('Principled BSDF')
if bsdf is not None:
bsdf.inputs['Base Color'].default_value = (r, g, b, a)
# Se c'e' trasparenza, attiviamo l'alpha blending.
if a < 1.0:
bsdf.inputs['Alpha'].default_value = a
mat.blend_method = 'BLEND'
except Exception:
# Fallback: colore di viewport (sempre disponibile).
try:
mat.diffuse_color = (r, g, b, a)
except Exception:
pass
# Anche il colore "viewport" (per Solid view e fallback).
try:
mat.diffuse_color = (r, g, b, a)
except Exception:
pass
_MATERIAL_CACHE[key] = mat
return mat
def build_scene(hierarchy_path):
"""Costruisce la scena Blender a partire da hierarchy.json.
Ritorna l'Empty 'root' a cui tutto il modello e' parentato.
"""
with open(hierarchy_path, 'r', encoding='utf-8') as f:
payload = json.load(f)
nodes = payload.get('nodes', [])
metadata = payload.get('metadata', {}) or {}
scale_factor = float(metadata.get('scaleFactor') or 0.01)
base_dir = os.path.dirname(os.path.abspath(hierarchy_path))
# Indice: id (== fullPathName, vuoto per il root) -> Empty Blender.
id_to_empty = {}
# 1) Pass 1: crea tutti gli Empty senza parenting.
for node in nodes:
node_id = node.get('id')
if node_id is None:
continue
empty = bpy.data.objects.new(_make_unique_empty_name(node), None)
empty.empty_display_type = 'ARROWS'
empty.empty_display_size = 0.1
bpy.context.collection.objects.link(empty)
# Salviamo metadati utili come custom properties.
# I campi `fusion_name` e `fusion_path` sono il contratto ufficiale col
# viewer Three.js (vedi FUSION_GLB_CONTRACT.md §3.2): devono coincidere
# byte-per-byte con `Joint.child` / `Joint.childFullPath` del joints.json.
try:
occ_name = node.get('name') or ''
full_path = node.get('fullPathName') or ''
empty['fusion_name'] = occ_name
empty['fusion_path'] = full_path
# Alias storici (back-compat, possono essere rimossi in futuro).
empty['fusion_id'] = node_id
empty['fusion_fullPathName'] = full_path
empty['fusion_componentName'] = node.get('componentName') or ''
except Exception:
pass
id_to_empty[node_id] = empty
# 2) Pass 2: SOLO parenting + import mesh. Il transform viene applicato
# dopo in un BFS top-down (vedi pass 3).
root_node_id = '__ROOT__'
for node in nodes:
node_id = node.get('id')
empty = id_to_empty.get(node_id)
if empty is None:
continue
# Parent:
# parentFullPathName == None -> nessun parent (e' il root)
# parentFullPathName == '' -> figlio del root (export vecchio)
# altrimenti -> id del parent
parent_path = node.get('parentFullPathName')
if parent_path is not None and node_id != root_node_id:
# Retrocompatibilita': stringa vuota = root.
if parent_path == '':
parent_path = root_node_id
parent_empty = id_to_empty.get(parent_path)
if parent_empty is None:
# Orfano: parent non trovato, agganciamo al root se esiste.
parent_empty = id_to_empty.get(root_node_id)
if parent_empty is not None:
print('[WARN] Parent non trovato per "{0}" (cercavo "{1}'
'"). Aggancio al root.'.format(node_id, parent_path),
file=sys.stderr)
if parent_empty is not None and parent_empty is not empty:
empty.parent = parent_empty
# Mesh associata. Parentata all'Empty con identita': la posizione
# finale verra' dall'Empty (gestita nel pass 3).
mesh_rel = node.get('meshFile')
if mesh_rel:
mesh_abs = os.path.join(base_dir, mesh_rel)
if not os.path.isfile(mesh_abs):
print('[WARN] Mesh non trovata: {0}'.format(mesh_abs),
file=sys.stderr)
continue
imported = _import_mesh(mesh_abs)
# Materiale colorato dal `color` del nodo (se presente). I
# materiali sono cached per RGBA cosi' colori uguali condividono
# lo stesso material slot nel GLB (output piu' piccolo).
mat_color = _make_color_material(node.get('color'))
for obj in imported:
obj.parent = empty
obj.matrix_local = Matrix.Identity(4)
if mat_color is not None:
try:
# Sovrascriviamo tutti gli slot esistenti per essere
# sicuri che il colore Fusion venga rispettato.
if obj.type == 'MESH':
obj.data.materials.clear()
obj.data.materials.append(mat_color)
except Exception:
pass
# 3) Trovare il nodo "root" e applicare lo scaleFactor cm -> m.
root_empty = id_to_empty.get(root_node_id)
if root_empty is None:
# Fallback: prendiamo qualsiasi Empty senza parent.
for e in id_to_empty.values():
if e.parent is None:
root_empty = e
break
if root_empty is not None and scale_factor and scale_factor != 1.0:
# Scala uniforme applicata SOLO al root: i figli ereditano la scala.
root_empty.scale = (scale_factor, scale_factor, scale_factor)
# Forziamo l'update della scene cosi' matrix_world del root e' calcolato
# prima di propagare le world matrices dei figli.
bpy.context.view_layer.update()
# 4) Pass 4: BFS top-down e assegnazione di matrix_world.
# IMPORTANTE: `Occurrence.transform2` di Fusion ritorna la matrice in
# coordinate WORLD/ROOT (NON relative al parent immediato). Quindi NON
# possiamo settare `matrix_local` (darebbe traslazioni duplicate per i
# nodi dentro sub-assembly non a origine). Settiamo `matrix_world` e
# Blender calcola da solo il local relativo al parent.
# La matrice esportata e' in cm Fusion: la convertiamo in metri
# moltiplicando per scale_root (che include lo scaleFactor).
from collections import deque
scale_root_mat = Matrix.Scale(scale_factor, 4) if scale_factor else Matrix.Identity(4)
id_to_node = {n.get('id'): n for n in nodes}
children_map = {}
for n in nodes:
p = n.get('parentFullPathName')
if p is None:
continue
if p == '':
p = root_node_id
children_map.setdefault(p, []).append(n.get('id'))
visited = {root_node_id}
queue = deque([root_node_id])
while queue:
cur_id = queue.popleft()
for child_id in children_map.get(cur_id, []):
if child_id in visited:
continue
visited.add(child_id)
queue.append(child_id)
child_empty = id_to_empty.get(child_id)
child_node = id_to_node.get(child_id)
if child_empty is None or child_node is None:
continue
mat_world_cm = _matrix_from_list(child_node.get('transform'))
# world finale (m) = scale_root @ world_fusion (cm)
child_empty.matrix_world = scale_root_mat @ mat_world_cm
return root_empty
# =============================================================================
# Joints / Motion Links -> glTF scene.extras
# =============================================================================
def embed_joints(joints_json_path):
"""Carica joints.json e lo salva come custom property della Scene.
Con `export_extras=True` Blender mette questo dict in `scene.extras`
del glTF, quindi in Three.js puoi leggerlo via
`gltf.parser.json.scenes[i].extras.fusion`.
Inoltre converte le coordinate da Fusion (cm) a metri (m) cosi' i
valori sono coerenti col modello esportato col `scaleFactor`.
"""
if not joints_json_path or not os.path.isfile(joints_json_path):
print('[INFO] joints.json non trovato, skip embedding joints/motion.',
file=sys.stderr)
return
try:
with open(joints_json_path, 'r', encoding='utf-8') as f:
payload = json.load(f)
except Exception:
print('[WARN] joints.json non leggibile:\n' + traceback.format_exc(),
file=sys.stderr)
return
# Scaling cm -> m sulle posizioni (origin) e sui valori dei limiti
# lineari (slideLimits / slideValue). Gli assi sono direzioni
# unitarie -> non vanno scalati. Le rotazioni sono in radianti
# -> non vanno scalate.
metadata = payload.get('metadata', {}) or {}
scale = float(metadata.get('scaleFactor') or 0.01)
def _scale_point(p):
if p is None or not isinstance(p, list):
return p
return [v * scale for v in p]
def _scale_limits(lim, is_linear):
if lim is None or not is_linear:
return lim
out = dict(lim)
for k in ('minimumValue', 'maximumValue', 'restValue'):
if out.get(k) is not None:
try:
out[k] = out[k] * scale
except Exception:
pass
return out
def _scale_joint(j):
j = dict(j)
for k in ('origin', 'originTwo'):
if k in j:
j[k] = _scale_point(j.get(k))
if 'slideLimits' in j:
j['slideLimits'] = _scale_limits(j.get('slideLimits'), True)
if 'slideValue' in j and j.get('slideValue') is not None:
try:
j['slideValue'] = j['slideValue'] * scale
except Exception:
pass
# rotationLimits / rotationValue restano invariati (radianti).
return j
fusion_data = {
'metadata': metadata,
'joints': [_scale_joint(j) for j in payload.get('joints', [])],
'asBuiltJoints': [_scale_joint(j) for j in payload.get('asBuiltJoints', [])],
'motionLinks': payload.get('motionLinks', []),
'rigidGroups': payload.get('rigidGroups', []),
}
# Custom property sulla Scene -> finisce in scene.extras del glTF.
scene = bpy.context.scene
try:
# Serializziamo in stringa JSON: glTF custom properties supportano
# primitivi/array/dict ma alcuni viewer non gestiscono dict annidati
# profondi. Una stringa JSON e' sempre safe da parsare in Three.js.
scene['fusion'] = json.dumps(fusion_data, ensure_ascii=False)
print('[OK] Joints embedded: joints={0} as_built={1} '
'motion_links={2} rigid_groups={3}'.format(
len(fusion_data['joints']),
len(fusion_data['asBuiltJoints']),
len(fusion_data['motionLinks']),
len(fusion_data['rigidGroups'])))
except Exception:
print('[WARN] Embed joints fallito:\n' + traceback.format_exc(),
file=sys.stderr)
# =============================================================================
# Export GLB
# =============================================================================
def export_glb(output_path):
"""Esporta la scena corrente in formato GLB (glTF 2.0 binario)."""
out_dir = os.path.dirname(os.path.abspath(output_path))
if out_dir and not os.path.isdir(out_dir):
os.makedirs(out_dir, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=output_path,
export_format='GLB',
export_apply=True, # applica modifiers
export_yup=True, # convenzione glTF: Y-up
use_visible=False,
use_selection=False,
export_extras=True, # custom properties -> extras glTF
export_materials='EXPORT', # esporta i materiali Principled BSDF
)
# =============================================================================
# Main
# =============================================================================
def main():
try:
hierarchy_path, output_path = _parse_args()
if not os.path.isfile(hierarchy_path):
raise SystemExit(
'File hierarchy non trovato: {0}'.format(hierarchy_path))
print('[INFO] Hierarchy: {0}'.format(hierarchy_path))
print('[INFO] Output: {0}'.format(output_path))
_wipe_scene()
root = build_scene(hierarchy_path)
if root is None:
print('[WARN] Nessun nodo root identificato.', file=sys.stderr)
# Cerca joints.json accanto al hierarchy e lo incorpora nel GLB
# come scene.extras.fusion (stringa JSON).
joints_path = os.path.join(
os.path.dirname(os.path.abspath(hierarchy_path)), 'joints.json')
embed_joints(joints_path)
export_glb(output_path)
print('[OK] Esportato: {0}'.format(output_path))
except SystemExit:
raise
except Exception:
print('[ERROR] Build GLB fallito:\n' + traceback.format_exc(),
file=sys.stderr)
raise SystemExit(1)
if __name__ == '__main__':
main()