Initial commit: Fusion->Blender->GLB pipeline + contract for ThreeJS bridge
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
42
AGENTS.md
Normal 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
73
BRIDGE_NOTES.md
Normal 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
1710
ExportKinematicGraph.py
Normal file
File diff suppressed because it is too large
Load Diff
223
FUSION_GLB_CONTRACT.md
Normal file
223
FUSION_GLB_CONTRACT.md
Normal 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
42
README.md
Normal 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
119
THREEJS_USAGE.md
Normal 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
64
build_glb.bat
Normal 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
|
||||||
524
build_glb_from_fusion_export.py
Normal file
524
build_glb_from_fusion_export.py
Normal 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()
|
||||||
Reference in New Issue
Block a user