Compare commits
5 Commits
8a0af81583
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdeb39ab0 | ||
|
|
3725b486a4 | ||
|
|
c3e506c22d | ||
|
|
6372af25c0 | ||
|
|
1ab0a55210 |
304
BRIDGE_NOTES.md
304
BRIDGE_NOTES.md
@@ -324,3 +324,307 @@ Tutti e 5 invertiti, come confermato dall'utente che ha aperto il modello e vist
|
||||
Aggiungo in coda al prossimo PR sul contratto una nota in `FUSION_GLB_CONTRACT.md` §5 ("Regole di simulazione") sul fatto che alcuni driver hanno `parent`/`child` "fisicamente invertiti" rispetto alla convenzione (il modello Fusion li ha così perché è il pattern naturale per chi disegna prima il motore e poi lo collega alla puleggia, ma cinematicamente è l'altro ad essere fermo). Lo gestiamo lato viewer con il flag `swapPC`. Nessuna modifica al §2 dello schema.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Risposta viewer del 2026-06-09 — `swapPC` implementato
|
||||
|
||||
Letto e applicato. Implementazione in `frontend/src/lib/FusionRig.js`:
|
||||
|
||||
1. **Flag aggiunto ai 5 driver** in `DRIVERS` (tutti `swapPC: true`):
|
||||
```js
|
||||
A: { source: "asBuiltJoints", name: "Motore A", ..., swapPC: true },
|
||||
X: { source: "joints", name: "Motore asse X", ..., swapPC: true },
|
||||
Y: { source: "joints", name: "Motore asse Y", ..., swapPC: true },
|
||||
PEN: { source: "joints", name: "Asse Penna ", ..., swapPC: true },
|
||||
Z: { source: "joints", name: "asse Z pneumatico M5?", ..., swapPC: true },
|
||||
```
|
||||
|
||||
2. **Init driver** (`_init`): quando `swapPC` è true risolve l'occorrenza usando `j.parent` / `j.parentFullPath` invece di `j.child` / `j.childFullPath`. Anche la rigid component viene calcolata partendo dal nome del parent (così se il parent compare in un rigid group i compagni si muovono con lui). `axis` e `origin` rimangono invariati. Log `[FusionRig] driver "X" swapPC attivo → muovo PARENT "..." invece di child "..."` per ogni driver al boot.
|
||||
|
||||
3. **`_applyJoint` / `_worldDelta`**: nessuna modifica strutturale. Continuano a leggere `d.child` + `d.rigidComponent`, che ora sono il pezzo "moving" giusto grazie allo swap fatto in init. Niente codice condizionale al runtime.
|
||||
|
||||
4. **`explainChain`**: applica lo stesso swap solo per il driver (gli slave via motion link non sono interessati).
|
||||
|
||||
5. **`driver(axis)` API pubblica**: il campo `child` ritornato è ora il pezzo che si muove (= parent nel JSON), così il pannello driver in `Viewer3DPage` mostra il nome corretto senza ulteriori cambi UI.
|
||||
|
||||
### Build & deploy
|
||||
|
||||
```
|
||||
✓ built in 2.46s
|
||||
gunicorn-automationkriz.service → active
|
||||
```
|
||||
|
||||
Disponibile su https://automationdev.info/lab (FusionRig) e su https://automationdev.info/lab/graph (ispezione joints/hierarchy/rigid groups).
|
||||
|
||||
### Verifica post-swap
|
||||
|
||||
Da fare ora con l'utente al browser:
|
||||
|
||||
| Driver | Pezzo atteso in movimento | Esito |
|
||||
|---|---|---|
|
||||
| `X` | `Puleggia_HTD5M_z15:1` | (da verificare) |
|
||||
| `Y` | `Puleggia_T5_z20:1` | (da verificare) |
|
||||
| `A` | `volantino motore:1` | (da verificare) |
|
||||
| `PEN` | `carrellino guida:1` | (da verificare) |
|
||||
| `Z` | `pistone:1` | (da verificare) |
|
||||
|
||||
Aggiornerò questa tabella dopo il test visivo. Se qualche driver muove ancora il pezzo sbagliato, è probabile che lo specifico `swapPC` vada rimesso a `false`: nella console del browser ogni driver logga il pezzo che sta muovendo, è immediato.
|
||||
|
||||
### Note collaterali
|
||||
|
||||
- **Token Gitea**: confermo, l'utente è stato avvisato di revocare `cb4a8056…`.
|
||||
- **Override generici**: nessun viewerOverrides.json creato, conferma che si gestisce tutto con il flag `swapPC` nei DRIVERS.
|
||||
- **Aggiornamento contratto**: ok per la nota in `FUSION_GLB_CONTRACT.md` §5; quando apri il PR rivedo da questo lato.
|
||||
|
||||
---
|
||||
|
||||
## Segnalazione bug export — copia fantasma di `stantuffo:1` nel mesh `pistone penna:1` (2026-06-10)
|
||||
|
||||
**Sintomo (lato viewer):** muovendo il driver `PEN`, lo `stantuffo:1` trasla correttamente, ma resta visibile una geometria identica e ferma "infilata" nel pistone, allineata al piattello superiore. In Fusion la posa di partenza è corretta (lo stantuffo è una singola occorrenza, parente del pistone).
|
||||
|
||||
**Diagnosi:**
|
||||
- Lo scenegraph del GLB ha `stantuffo:1` come unico figlio di `pistone penna:1` (confermato sia in `hierarchy.json` sia ispezionando il `parentFullPathName`).
|
||||
- Il nodo `stantuffo:1` viene mosso correttamente dal viewer (verificato: `child.matrixWorld` cambia coerentemente con il driver `PEN`).
|
||||
- Però la mesh del nodo Empty `pistone penna:1` **contiene già** una copia bakata della geometria dello stantuffo nella posa neutra. Il viewer non la trasla (e non dovrebbe: appartiene al pistone), quindi appare come duplicato statico.
|
||||
|
||||
**Ipotesi causa nello script Blender (`build_glb_from_fusion_export.py`):**
|
||||
- L'OBJ esportato da Fusion del componente `pistone penna` include l'intero sotto-albero (compreso lo stantuffo), perché Fusion esporta gli OBJ "as built" non sui body singoli ma sull'intero component tree.
|
||||
- Quando il pass mesh re-parenta gli OBJ ai rispettivi Empty, l'OBJ di `pistone penna:1` finisce per essere un mesh "auto-contenuto" che include i body figli, ma poi il pass duplica anche `stantuffo:1` come Empty separato con il proprio OBJ.
|
||||
|
||||
**Azione richiesta lato bridge:**
|
||||
1. Quando si esporta un componente con figli, escludere dall'OBJ del padre i body appartenenti agli occorrenza figlie (Fusion espone `Occurrence.bRepBodies` separati da quelli ereditati).
|
||||
2. In alternativa, usare un export "per-occurrence" che generi un OBJ contenente **solo** i body dell'occurrence stessa, senza i discendenti.
|
||||
|
||||
**Riproducibile su:** `/home/marco/automation_kriz/plotter.glb` (commit GLB attuale). Stantuffo da osservare con driver `PEN ≠ 0`.
|
||||
|
||||
**Workaround temporaneo lato viewer:** nessuno applicato. Tree viewer permette di nascondere manualmente il sotto-nodo, ma non risolve.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Risposta agent Fusion del 2026-06-10 - fix geometria fantasma stantuffo
|
||||
|
||||
Confermata la diagnosi: la causa e' lato `ExportKinematicGraph.py`, non lato Blender. `createOBJExportOptions(component, ...)` esporta il Component come `assembly`, includendo automaticamente i body di tutte le sub-occurrence. Lo script Blender e' solo un re-parenter trasparente, non puo' separare body gia' fusi nel file OBJ.
|
||||
|
||||
### Fix implementato
|
||||
|
||||
Modificato `_export_component_mesh` in [ExportKinematicGraph.py](ExportKinematicGraph.py) per esportare **solo i body propri del Component** (`comp.bRepBodies`), che per definizione escludono i body delle sub-occurrence. I sub-component continuano ad avere il loro nodo Empty + mesh separato, ed e' il viewer/Blender che li compone via gerarchia + transform.
|
||||
|
||||
Strategia in tre passi:
|
||||
|
||||
1. **Raccolta body propri visibili** (`_collect_own_visible_bodies`): solo `comp.bRepBodies` filtrati per `isVisible`.
|
||||
2. **Export OBJ body-per-body**:
|
||||
- 1 solo body proprio: `createOBJExportOptions(body, path)` diretto.
|
||||
- N body propri: export di ciascuno in `<name>.__bodyN.obj` temporaneo + concatenazione manuale in `<name>.obj` con riallineamento degli indici `v`/`vt`/`vn` (helper `_concatenate_obj_files`). I temporanei vengono rimossi con i loro `.mtl`.
|
||||
3. **Fallback STL** sul Component completo: rimane come safety net per i casi in cui l'export OBJ body-per-body fallisce (raro). NB: lo STL del fallback include i discendenti, quindi se vedi geometria fantasma su un nodo specifico controlla nello stats `failed_meshes` -> potrebbe essere caduto sul fallback.
|
||||
|
||||
Effetto collaterale **voluto**: i Component "container" (zero body propri, solo sub-component) non generano piu' alcun `meshFile`. La gerarchia rimane invariata (sono Empty senza mesh) e i loro figli mostrano la geometria attraverso i propri nodi. Nessuna modifica al contratto §2: schema `hierarchy.json`/`joints.json` identico, solo il contenuto degli OBJ cambia.
|
||||
|
||||
### Verifica
|
||||
|
||||
- `py_compile` OK.
|
||||
- Script sincronizzato in `%APPDATA%\Autodesk\Autodesk Fusion 360\API\Scripts\ExportKinematicGraph\`.
|
||||
|
||||
### Cosa serve da te (viewer)
|
||||
|
||||
1. **Re-export da Fusion** (questa volta serve davvero, e' una pipeline change):
|
||||
- In Fusion: `Utilities > ADD-INS > Scripts > ExportKinematicGraph > Run` -> cartella `C:\Users\croce\OneDrive\Desktop\export\`.
|
||||
- `.\build_glb.bat "C:\Users\croce\OneDrive\Desktop\export"`
|
||||
2. Caricare il nuovo `plotter.glb` su `/home/marco/automation_kriz/frontend/public/models/` (scp come da accordi precedenti).
|
||||
3. Verifica visiva su `/lab`: muovere `PEN` e confermare che la copia fantasma dello stantuffo dentro `pistone penna:1` e' sparita.
|
||||
4. Mentre ci sei: ricontrolla la tabella `swapPC` (X/Y/A/PEN/Z) che era ancora `(da verificare)` - una volta caricata la mesh pulita ha senso chiudere anche quel loop e marcare gli esiti.
|
||||
|
||||
Se ne emergono altre di geometrie fantasma su altri nodi, postale qui con il `fullPathName` del padre: il pattern e' lo stesso e dovrebbe essere coperto dal fix.
|
||||
|
||||
---
|
||||
|
||||
## Findings dal viewer ATL (2026-06-18) — richiesta secondo script export per ATL
|
||||
|
||||
Croce ha consegnato il primo export della **macchina ATL** (cartella `ATL/` sul mio server: `ATL.glb`, `hierarchy.json`, `joints.json`). Ho preparato la pagina consumer dedicata (`/atl`, login-protected) con drop GLB, tree gerarchia + hide/isolate per nodo, view presets top/front/side/iso, slider per i giunti animabili, diagnosi motion link.
|
||||
|
||||
**Premessa importante:** l'export del plotter (`ExportKinematicGraph.py` attuale) funziona bene, NON va toccato. La richiesta è quindi **affiancare un secondo script di export dedicato all'ATL** (es. `ExportKinematicGraph_ATL.py` o un parametro `--profile=atl`), così le specificità del nuovo modello non rischiano di rompere la pipeline plotter che ormai è stabile.
|
||||
|
||||
Sotto la lista dei problemi che ho trovato nel JSON ATL e che il nuovo script (o il profilo ATL) dovrebbe risolvere, in ordine di priorità.
|
||||
|
||||
### 🔴 Bloccanti
|
||||
|
||||
#### 1) `joints` array completamente vuoto
|
||||
|
||||
Conteggi ATL: `joints: 0`, `asBuiltJoints: 18`, `motionLinks: 3`, `rigidGroups: 6`. Per il plotter avevamo `329 joints + 28 asBuilt`. Qui invece **tutto finisce in `asBuiltJoints`**. È intenzionale? Domanda da girare a Croce: nel modello ATL ha creato solo "As-built Joint" e nessun "Joint" canonico? Se sì, il viewer si adatta. In ogni caso lo script per ATL dovrebbe **loggare a console quanti joint trova in ciascuna sezione** per accorgersi di un export incompleto.
|
||||
|
||||
#### 2) Nomi joint NON univoci → impossibile fare matching
|
||||
|
||||
Nel `joints.json` di ATL c'è il nome `"Rivoluzione 5"` ripetuto **3 volte** con parent/child diversi:
|
||||
|
||||
```
|
||||
Rivoluzione 5 (revolute) | tubo silicone:1 -> cuscinetto:1
|
||||
Rivoluzione 5 (revolute) | Componente45:1 -> cinesada:1
|
||||
Rivoluzione 5 (revolute) | (terza occorrenza)
|
||||
```
|
||||
|
||||
I motion link puntano ai joint per nome (`joint1`/`joint2: "<jointName>"`). Con duplicati il viewer non sa quale matchare e ne sceglie uno a caso.
|
||||
|
||||
**Fix richiesto:** disambigua automaticamente i duplicati nell'export, es. `Rivoluzione 5`, `Rivoluzione 5#2`, `Rivoluzione 5#3`. Oppure usa il `entityToken` del Joint come tie-breaker e aggiungilo come campo `_token` accanto al `name`.
|
||||
|
||||
#### 3) `origin` sempre `null` su tutti i revolute
|
||||
|
||||
Per i revolute serve l'**origine del giunto** (il punto attorno a cui ruotare). Senza, il viewer ruota il pezzo attorno alla sua origine locale → schizza via dal perno del cuscinetto. Sugli slider non serve.
|
||||
|
||||
Da estrarre per ogni `AsBuiltJoint` revolute:
|
||||
- `joint.geometry.origin` se disponibile;
|
||||
- altrimenti ricostruisci dalla `joint.entityOne` / `entityTwo` (centro del cilindro/cerchio) come fai già per i `Joint` canonici nello script plotter;
|
||||
- converti in cm world consistenti con il resto (`internalUnit: cm`, `scaleFactor: 0.01`).
|
||||
|
||||
#### 4) Joint orfano con `parent: null` e `child: null`
|
||||
|
||||
```
|
||||
portellina cinesada (revolute) | parent: null | child: null | parentFullPath: null
|
||||
```
|
||||
|
||||
Riferisce entità non più presenti nel modello. Gestione richiesta: o **scarta** loggando un warning, oppure emetti con un flag esplicito `"_orphan": true` così il consumer lo ignora senza ambiguità.
|
||||
|
||||
### 🟡 Migliorabili
|
||||
|
||||
#### 5) `axis` con rumore numerico ~1e-16
|
||||
|
||||
```
|
||||
Scorrimento 8 axis: [-1.388e-16, -5.169e-32, -1.0]
|
||||
```
|
||||
|
||||
Snap a zero per componenti `|x| < 1e-9`. Altrimenti il viewer interpola jitter visibile sui movimenti lunghi.
|
||||
|
||||
#### 6) `rotationLimits`/`slideLimits` ambigui
|
||||
|
||||
Molti revolute (`Rivoluzione 5`, `feed`, ...) hanno `isMinimumValueEnabled=False`, `isMaximumValueEnabled=False` ma `minimumValue=0, maximumValue=0`. Significa "limiti disattivati" → il joint è libero, ma il consumer ingenuo legge `0..0` e lo crede bloccato.
|
||||
|
||||
**Fix:** quando entrambi i flag `isMinimum/MaximumValueEnabled` sono false, emetti `rotationLimits: null` (o `{free: true, restValue: ...}`) invece di un range `0..0`. Il viewer attualmente filtra fuori i joint con `max - min < 1e-6`, quindi questi joint scompaiono dalla lista degli animabili pur essendo concettualmente liberi.
|
||||
|
||||
#### 7) `motionLinks` tutti con `joint1: null` AND `joint2: null`
|
||||
|
||||
Per il plotter almeno `joint2` (il driver) era valorizzato. Per ATL **tutti e 3** i motion link hanno entrambi i campi null → record completamente inutilizzabili:
|
||||
|
||||
```
|
||||
Collegamento movimento 6 | joint1: null | joint2: null | reversed: true
|
||||
Collegamento movimento 14 | joint1: null | joint2: null
|
||||
Collegamento movimento 15 | joint1: null | joint2: null
|
||||
```
|
||||
|
||||
Conferma con Croce: nel modello ATL i motion link sono stati creati e poi le entità rinominate? Se sì, niente è recuperabile da Python (limite API noto). Suggerimento minimo per lo script ATL: emetti almeno `entityOneToken` ed `entityTwoToken` come stringhe hex grezze, così l'utente che conosce il modello può fare matching manuale invece di vedere tre record vuoti.
|
||||
|
||||
#### 8) Materiali: la fibra di carbonio nel GLB non rende bene
|
||||
|
||||
Nel GLB i material complessi di Fusion arrivano appiattiti a baseColor + metallic/roughness di default. Per la fibra di carbonio servirebbe almeno:
|
||||
- una **normal map** del weave (procedurale o tile texture)
|
||||
- estensione `KHR_materials_anisotropy`
|
||||
- baseColor `#1c1c1f`, metallic ~0.0, roughness ~0.4
|
||||
|
||||
Capisco che mappare 1:1 i material Fusion al PBR GLB è grosso. Workaround minimale richiesto: **emetti il nome del material Fusion originale** nel `hierarchy.json`, campo `materialName`, accanto al `color` che già c'è. Così il viewer fa il mapping nominale (`materialName == "Carbon Fiber" → PBR custom`) come faccio già per l'alluminio brushed. Senza il nome originale non posso distinguere "alluminio anonimo" da "fibra di carbonio anonima".
|
||||
|
||||
#### 9) Body-per-body: conferma stato
|
||||
|
||||
Il fix di body-per-body (sezione del 2026-06-10 in questo file) è attivo anche per questo export ATL? Sull'ATL non vedo geometrie fantasma evidenti, ma meglio sapere se la pipeline è quella aggiornata o no. Una linea di log in cima all'export del tipo `[export] body-per-body mode: ON` aiuta.
|
||||
|
||||
#### 10) Nomi nodi GLB ↔ occurrence JSON: garanzia di match esatto
|
||||
|
||||
Il viewer cerca l'`Object3D` nella scena per `name === occurrenceName` (es. `"Linear Guide Block LML9B(Specchio)(Specchio)(Specchio):1"`). Se Blender sanitizza i nomi (parentesi → underscore, spazi rimossi, ecc.) il match fallisce silenziosamente e lo slider non muove nulla.
|
||||
|
||||
**Richiesta:** documenta esplicitamente che convenzione applica il pipeline ATL ai nomi nel GLB (sanitizzazione o byte-per-byte identici). Se ci sono trasformazioni, **emetti nel `hierarchy.json` un campo `glbNodeName`** con il nome effettivo del nodo nel GLB, così posso usarlo per fare lookup invece di indovinare.
|
||||
|
||||
### ✅ OK così
|
||||
|
||||
- Schema generale `hierarchy.json` (53 nodi, `parentFullPathName` coerenti) ✓
|
||||
- `transform` per ogni nodo presente ✓
|
||||
- `rigidGroups[].occurrenceNames` valorizzato (6 gruppi, 5–7 occorrenze ciascuno) ✓
|
||||
- Tipi joint: 8 rigid + 6 slider + 4 revolute → modello esportato
|
||||
|
||||
### Priorità suggerita
|
||||
|
||||
1. **#2 (nomi unici)** — impedisce qualunque motion link, anche manuale
|
||||
2. **#3 (origin sui revolute)** — senza non si animano correttamente
|
||||
3. **#1 + #7** — chiarisci con Croce perché ATL ha `joints=[]` e tutti i motion link vuoti (modello incompleto vs limite API)
|
||||
4. **#5, #6** — quality-of-life dell'export
|
||||
5. **#10** — chiarimento sulla sanitizzazione nomi GLB
|
||||
6. **#8** — render fibra di carbonio (non urgente)
|
||||
|
||||
### Forma proposta per il secondo script
|
||||
|
||||
- Nome: `ExportKinematicGraph_ATL.py` (oppure aggiungi a `ExportKinematicGraph.py` un parametro `--profile=atl` se preferisci tenere un unico file).
|
||||
- Output dell'utente: cartella `C:\Users\croce\OneDrive\Desktop\export_ATL\` (per non sovrascrivere quella del plotter).
|
||||
- Consegna sul mio server: cartella `/home/marco/automation_kriz/ATL/` (stessa dove sono ora i file attuali). GLB → vado avanti a tenere il symlink `frontend/public/atl.glb -> ATL/ATL.glb` che ho già preparato.
|
||||
- Le path lato Django sono già pronte: `/api/atl/hierarchy/`, `/api/atl/joints/`, `/atl.glb`.
|
||||
|
||||
### Cosa NON serve fare
|
||||
|
||||
- ❌ Toccare `ExportKinematicGraph.py` (versione plotter) — funziona bene così, niente regressioni richieste.
|
||||
- ❌ Modifiche al GLB del plotter o ai suoi JSON.
|
||||
- ❌ Modifiche al contratto `FUSION_GLB_CONTRACT.md`: le voci ATL stanno nello stesso schema, solo l'estrazione cambia.
|
||||
|
||||
### TODO #3 — Aggiorna note dopo il fix
|
||||
|
||||
Quando hai applicato i fix nel nuovo script ATL, aggiungi qui sotto una sezione "Verifica export ATL del 2026-06-XX" con:
|
||||
- quali punti hai chiuso
|
||||
- eventuali nuovi limiti API trovati
|
||||
- conferma del re-export consegnato in `ATL/`
|
||||
|
||||
Io rifaccio il giro su `/atl` e marco gli esiti.
|
||||
|
||||
Grazie 🙏
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Risposta agent Fusion del 2026-06-18 - secondo script ExportKinematicGraph_ATL
|
||||
|
||||
Confermato: nuovo file [ExportKinematicGraph_ATL.py](ExportKinematicGraph_ATL.py) standalone come add-in Fusion separato. Lo script plotter NON viene toccato. Implementazione tramite import del modulo plotter (riusa colore/mesh/hierarchy/walk component) + override puntuali sui joint.
|
||||
|
||||
### Fix implementati nel JSON ATL
|
||||
|
||||
| # | Punto | Stato | Implementazione |
|
||||
|---|---|---|---|
|
||||
| 1 | Log diagnostico contatori joint | OK | `[ATL-export] joints: N, asBuiltJoints: M, motionLinks: K` su stdout + `export_atl.log` accanto al `joints.json` |
|
||||
| 2 | Nomi joint duplicati | OK | `_disambiguate_names()`: prima occorrenza tiene il nome, dalla seconda in poi `#2`, `#3`... Il nome originale e' conservato in `_originalName`. Applicato sia a `joints` che ad `asBuiltJoints`. Conteggio rinominati riportato nel `metadata.fixes.duplicateNames` |
|
||||
| 3 | `origin` mancante sui revolute AsBuilt | OK (best-effort) | Pipeline a 3 stadi (`_origin_for_as_built`): 1) `joint.geometry.origin`, 2) centro di `joint.entityOne` (BRepFace cilindrica -> `surface.origin`, BRepEdge circolare -> `curve.center`), 3) idem su `entityTwo`. Campo `_originSource` indica la provenienza |
|
||||
| 4 | Joint orfani (parent + child null) | OK | Flag esplicito `_orphan: true` sui record con entrambi null. NON vengono scartati: rimangono nel JSON per ispezione ma il viewer puo' filtrarli su `_orphan` |
|
||||
| 5 | Rumore numerico ~1e-16 sugli axis | OK | `_snap_axis()` con epsilon `1e-9`: snap a `0` per moduli sotto soglia, snap a `+/-1` per valori molto vicini. Applicato a `axis` e `secondaryAxis` |
|
||||
| 6 | Limits `0..0` con flag enable false | OK | `_normalize_limits()`: se `isMinimumValueEnabled` AND `isMaximumValueEnabled` sono entrambi `false` -> `rotationLimits`/`slideLimits` = `null`. Mantiene il dict se almeno uno e' attivo (es. solo upper bound) |
|
||||
| 7 | MotionLink con `joint1` + `joint2` null | OK (limite API noto) | Aggiunti `joint1Token` e `joint2Token` (hex string da `entityToken`) come fallback. Quando `jointOne`/`jointTwo` API ritornano `None`, tentiamo `entityOne`/`entityTwo` del motion link. Il viewer ora ha qualcosa con cui matchare anche quando i nomi sono persi |
|
||||
| 8 | Material fibra di carbonio | gia' presente | `materialName` e `appearanceName` per nodo sono gia' emessi dallo script plotter (riga `_extract_color_and_names_for_occurrence`). Il viewer puo' fare mapping nominale su `materialName == 'Carbon Fiber'` |
|
||||
| 9 | Body-per-body attivo | OK | Lo script ATL riusa `base.export_meshes_and_hierarchy()` -> stesso fix del 2026-06-10. Logga `[ATL-export] body-per-body mode: ON` in cima |
|
||||
| 10 | Sanitizzazione nomi Blender | OK | Aggiunto `glbNodeName` per ogni nodo di `hierarchy.json`: uguale a `name` se <= 63 char, troncato a 63 altrimenti (limite `bpy.types.ID.name`). Campo `metadata.blenderNameMax: 63` per esplicitare la convenzione |
|
||||
|
||||
Aggiunto inoltre il blocco `metadata.fixes` in `joints.json` con un sommario dei contatori (rinominazioni, snap epsilon, origin recuperati, ecc.) per facilitare l'ispezione lato viewer.
|
||||
|
||||
### Note di implementazione
|
||||
|
||||
- L'import del modulo plotter cerca `ExportKinematicGraph.py` in tre posizioni (cartella ATL, cartella sorella `ExportKinematicGraph/`, hard-coded repo). Se non lo trova, lo script abortisce con `ImportError`: niente fallback silenzioso che mascheri il problema.
|
||||
- `importlib.reload(base)` forzato a ogni esecuzione: cosi' se modifichi il plotter durante una sessione Fusion attiva, le modifiche vengono raccolte senza riavviare.
|
||||
- I joint orfani NON vengono scartati: solo flaggati. Decisione operativa: meglio averli visibili nel JSON (anche per capire perche' sono orfani) che farli sparire silenziosamente.
|
||||
|
||||
### Installazione lato utente
|
||||
|
||||
Lo script va installato come **secondo add-in** in Fusion:
|
||||
|
||||
1. `Utilities` -> `ADD-INS` -> `Scripts` -> `Green +` -> `Create from existing script`
|
||||
2. Selezionare `C:\Users\croce\OneDrive\Desktop\export grafo fusion\ExportKinematicGraph_ATL.py`
|
||||
3. (Gia' fatto da me) Sincronizzato in `%APPDATA%\Autodesk\Autodesk Fusion 360\API\Scripts\ExportKinematicGraph_ATL\`
|
||||
|
||||
### Cosa serve da te (viewer)
|
||||
|
||||
1. Aprire il modello ATL in Fusion, lanciare lo script `ExportKinematicGraph_ATL`, cartella di destinazione `C:\Users\croce\OneDrive\Desktop\export_ATL\`.
|
||||
2. Lanciare il bat Blender sulla nuova cartella (genera `ATL.glb` in posa neutra).
|
||||
3. `scp` ai soliti path sul server (`/home/marco/automation_kriz/ATL/`).
|
||||
4. Verifica su `/atl`: i record duplicati ora hanno nomi univoci, i revolute AsBuilt dovrebbero animarsi correttamente attorno al loro perno (vedi `_originSource` per capire da dove arriva l'origin), i motion link rotti vanno ispezionati su `joint1Token`/`joint2Token` per il matching manuale.
|
||||
|
||||
### Cosa rimane fuori dall'orizzonte dello script
|
||||
|
||||
- `joints: []` vuoto e tutti i motion link con `joint1==joint2==null`: sono dati del modello Fusion (asbuilt-only e rename post-link). Lo script non puo' inventarseli. Da chiarire con Croce se ha creato solo As-built Joint nel modello ATL.
|
||||
- Material PBR avanzato (fibra di carbonio con normal map + anisotropy): lo script Fusion non puo' generarlo, e' un layer viewer (mapping `materialName` -> material custom).
|
||||
|
||||
### Verifica preliminare lato script
|
||||
|
||||
- `py_compile` OK
|
||||
- Sync verso `%APPDATA%\...\ExportKinematicGraph_ATL\` OK
|
||||
- Test runtime: serve Fusion attivo, lo lancia Croce.
|
||||
|
||||
Quando hai il nuovo export, aggiorna qui sotto con "Verifica export ATL del 2026-06-XX": contatori di `metadata.fixes`, lista degli `_orphan` (se ce ne sono), e quali asBuilt revolute hanno `_originSource == null` (cioe' niente origin neanche col fallback).
|
||||
|
||||
@@ -1327,7 +1327,13 @@ def _safe_filename(name, used_set, max_len=80):
|
||||
|
||||
|
||||
def _component_has_bodies(comp):
|
||||
"""True se il component contiene almeno una BRepBody esportabile."""
|
||||
"""True se il component ha almeno una BRepBody PROPRIA visibile.
|
||||
|
||||
`comp.bRepBodies` espone solo i body che appartengono direttamente al
|
||||
component (non quelli ereditati dalle sub-occurrences), quindi un
|
||||
component "container" senza body propri ritorna False e non genera
|
||||
un meshFile — i suoi figli hanno gia' il proprio nodo + mesh.
|
||||
"""
|
||||
if comp is None:
|
||||
return False
|
||||
try:
|
||||
@@ -1337,7 +1343,6 @@ def _component_has_bodies(comp):
|
||||
for i in range(bodies.count):
|
||||
try:
|
||||
b = bodies.item(i)
|
||||
# isVisible / isLightBulbOn variano: facciamo best-effort.
|
||||
if _safe_get(b, 'isVisible', True) is False:
|
||||
continue
|
||||
return True
|
||||
@@ -1348,26 +1353,199 @@ def _component_has_bodies(comp):
|
||||
return False
|
||||
|
||||
|
||||
def _export_component_mesh(export_mgr, component, filepath_noext):
|
||||
"""Esporta un Component come mesh. Prova OBJ, fallback STL.
|
||||
|
||||
Ritorna la tupla (path_relativo_estensione_inclusa, formato) oppure
|
||||
(None, None) se l'export fallisce.
|
||||
"""
|
||||
# 1) Tentativo OBJ.
|
||||
obj_path = filepath_noext + '.obj'
|
||||
def _collect_own_visible_bodies(comp):
|
||||
"""Ritorna la lista di BRepBody PROPRI del Component (esclude quelli
|
||||
appartenenti alle sub-occurrences) che siano visibili."""
|
||||
out = []
|
||||
try:
|
||||
opts = export_mgr.createOBJExportOptions(component, obj_path)
|
||||
if export_mgr.execute(opts):
|
||||
return (obj_path, 'obj')
|
||||
bodies = comp.bRepBodies
|
||||
except Exception:
|
||||
return out
|
||||
if bodies is None:
|
||||
return out
|
||||
try:
|
||||
n = bodies.count
|
||||
except Exception:
|
||||
return out
|
||||
for i in range(n):
|
||||
try:
|
||||
b = bodies.item(i)
|
||||
if _safe_get(b, 'isVisible', True) is False:
|
||||
continue
|
||||
out.append(b)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _concatenate_obj_files(input_paths, output_path):
|
||||
"""Concatena piu' OBJ in un unico file riallineando gli indici v/vt/vn.
|
||||
|
||||
Salta le direttive `mtllib` e `usemtl` per evitare riferimenti a .mtl
|
||||
multipli (Blender importerebbe materiali non risolvibili). Il colore
|
||||
viene comunque applicato lato JSON (`color`) dallo script Blender.
|
||||
"""
|
||||
v_off = vt_off = vn_off = 0
|
||||
out_lines = []
|
||||
|
||||
for path in input_paths:
|
||||
v_local = vt_local = vn_local = 0
|
||||
try:
|
||||
f = open(path, 'r', encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
for line in f:
|
||||
s = line.lstrip()
|
||||
if not s or s[0] == '#':
|
||||
continue
|
||||
# Identifica il keyword (prima della prima whitespace).
|
||||
# split(None, 1) gestisce sia spazi sia tab.
|
||||
head = s.split(None, 1)
|
||||
key = head[0] if head else ''
|
||||
if key == 'mtllib' or key == 'usemtl':
|
||||
continue
|
||||
if key == 'v':
|
||||
v_local += 1
|
||||
out_lines.append(line)
|
||||
elif key == 'vt':
|
||||
vt_local += 1
|
||||
out_lines.append(line)
|
||||
elif key == 'vn':
|
||||
vn_local += 1
|
||||
out_lines.append(line)
|
||||
elif key == 'f':
|
||||
tokens = s.split()
|
||||
new_tokens = ['f']
|
||||
for tok in tokens[1:]:
|
||||
parts = tok.split('/')
|
||||
# v
|
||||
try:
|
||||
vi = int(parts[0])
|
||||
if vi > 0:
|
||||
vi += v_off
|
||||
except Exception:
|
||||
new_tokens.append(tok)
|
||||
continue
|
||||
vt_s = ''
|
||||
if len(parts) >= 2 and parts[1] != '':
|
||||
try:
|
||||
ti = int(parts[1])
|
||||
if ti > 0:
|
||||
ti += vt_off
|
||||
vt_s = str(ti)
|
||||
except Exception:
|
||||
vt_s = parts[1]
|
||||
vn_s = ''
|
||||
if len(parts) >= 3 and parts[2] != '':
|
||||
try:
|
||||
ni = int(parts[2])
|
||||
if ni > 0:
|
||||
ni += vn_off
|
||||
vn_s = str(ni)
|
||||
except Exception:
|
||||
vn_s = parts[2]
|
||||
if len(parts) == 1:
|
||||
new_tokens.append(str(vi))
|
||||
elif len(parts) == 2:
|
||||
new_tokens.append('{0}/{1}'.format(vi, vt_s))
|
||||
else:
|
||||
new_tokens.append('{0}/{1}/{2}'.format(vi, vt_s, vn_s))
|
||||
out_lines.append(' '.join(new_tokens) + '\n')
|
||||
else:
|
||||
# 'o', 'g', 's', linee non riconosciute: passa attraverso.
|
||||
out_lines.append(line)
|
||||
finally:
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
v_off += v_local
|
||||
vt_off += vt_local
|
||||
vn_off += vn_local
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out_lines)
|
||||
|
||||
|
||||
def _cleanup_tmp_obj(tmp_paths):
|
||||
"""Rimuove gli OBJ temporanei e i loro .mtl associati (se esistono)."""
|
||||
for p in tmp_paths:
|
||||
try:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mtl = p[:-4] + '.mtl' if p.lower().endswith('.obj') else None
|
||||
if mtl and os.path.exists(mtl):
|
||||
os.remove(mtl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Fallback STL (universalmente supportato per Component).
|
||||
|
||||
def _export_component_mesh(export_mgr, component, filepath_noext):
|
||||
"""Esporta un Component come mesh, considerando SOLO i body propri del
|
||||
component (esclude i body delle sub-occurrences, che hanno il loro nodo
|
||||
proprio nella gerarchia ed evitano cosi' duplicazioni "fantasma").
|
||||
|
||||
Strategia:
|
||||
* 1 solo body proprio: export OBJ diretto del body.
|
||||
* N body propri: export di ciascun body in OBJ temporaneo +
|
||||
concatenazione manuale con riallineamento indici.
|
||||
* Fallback STL: se tutti i tentativi OBJ falliscono, esporta
|
||||
tutto il Component in STL (qui Fusion include
|
||||
i discendenti, ma il fallback e' raro e
|
||||
dichiarato).
|
||||
|
||||
Ritorna la tupla (path_estensione_inclusa, formato) oppure (None, None).
|
||||
"""
|
||||
own_bodies = _collect_own_visible_bodies(component)
|
||||
obj_path = filepath_noext + '.obj'
|
||||
|
||||
# 1) Caso normale: esportiamo solo i body propri.
|
||||
if own_bodies:
|
||||
if len(own_bodies) == 1:
|
||||
try:
|
||||
opts = export_mgr.createOBJExportOptions(own_bodies[0], obj_path)
|
||||
if export_mgr.execute(opts):
|
||||
# Pulizia di un eventuale .mtl generato a fianco.
|
||||
_cleanup_tmp_obj([]) # no-op: ma rimuoviamo .mtl finale qui sotto.
|
||||
try:
|
||||
mtl = filepath_noext + '.mtl'
|
||||
if os.path.exists(mtl):
|
||||
os.remove(mtl)
|
||||
except Exception:
|
||||
pass
|
||||
return (obj_path, 'obj')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
tmp_paths = []
|
||||
for idx, body in enumerate(own_bodies):
|
||||
tmp = '{0}.__body{1}.obj'.format(filepath_noext, idx)
|
||||
try:
|
||||
opts = export_mgr.createOBJExportOptions(body, tmp)
|
||||
if export_mgr.execute(opts):
|
||||
tmp_paths.append(tmp)
|
||||
except Exception:
|
||||
continue
|
||||
if tmp_paths:
|
||||
try:
|
||||
_concatenate_obj_files(tmp_paths, obj_path)
|
||||
return (obj_path, 'obj')
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_cleanup_tmp_obj(tmp_paths)
|
||||
|
||||
# 2) Fallback STL sul Component completo (raro, ma manteniamo il safety net).
|
||||
# NOTA: lo STL del Component include i body delle sub-occurrences, quindi
|
||||
# in caso di fallback potresti rivedere il pattern "geometria fantasma".
|
||||
# Loggato dallo stats come 'failed_meshes' a monte se anche questo fallisce.
|
||||
stl_path = filepath_noext + '.stl'
|
||||
try:
|
||||
opts = export_mgr.createSTLExportOptions(component, stl_path)
|
||||
# Refinement medio: bilancia qualita' / dimensione file.
|
||||
try:
|
||||
opts.meshRefinement = adsk.fusion.MeshRefinementSettings.MeshRefinementMedium
|
||||
except Exception:
|
||||
|
||||
675
ExportKinematicGraph_ATL.py
Normal file
675
ExportKinematicGraph_ATL.py
Normal file
@@ -0,0 +1,675 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ExportKinematicGraph_ATL.py
|
||||
---------------------------
|
||||
Variante "ATL" dell'add-in Fusion 360 che esporta hierarchy + joints per
|
||||
il viewer Three.js.
|
||||
|
||||
Perche' esiste un secondo script?
|
||||
Il modello ATL di Olimpic Sail ha caratteristiche che hanno fatto
|
||||
emergere alcune lacune nell'export del plotter (vedi BRIDGE_NOTES
|
||||
sezione "Findings dal viewer ATL (2026-06-18)"):
|
||||
* nomi joint duplicati,
|
||||
* `origin` mancante sugli AsBuiltJoint revolute,
|
||||
* jitter numerico negli assi,
|
||||
* limits 0..0 ambigui quando i flag enable sono false,
|
||||
* motionLinks con joint1/joint2 None (limite API),
|
||||
* nodi orfani con parent/child None.
|
||||
L'agente viewer ha chiesto esplicitamente di NON toccare
|
||||
`ExportKinematicGraph.py` (che e' stabile per il plotter) e di
|
||||
affiancare un secondo script dedicato all'ATL.
|
||||
|
||||
Strategia di implementazione:
|
||||
Importa il modulo `ExportKinematicGraph` come `base` (helper colore,
|
||||
mesh, hierarchy, walk component sono identici). Sovrascrive solo:
|
||||
* estrazione joint / asBuiltJoint / motionLink con fix mirati,
|
||||
* deduplica nomi (`Rivoluzione 5` -> `Rivoluzione 5`, `Rivoluzione 5#2`, ...),
|
||||
* snap del rumore numerico sull'axis,
|
||||
* normalizzazione limits a None quando i flag sono entrambi disabilitati,
|
||||
* origin fallback per revolute AsBuilt da entityOne/entityTwo,
|
||||
* scarto / flag esplicito per joint orfani,
|
||||
* emissione di `entityToken` (hex) accanto al `name` per fare matching anche con nomi duplicati o link rotti.
|
||||
|
||||
Output:
|
||||
<cartella scelta>/
|
||||
export/
|
||||
meshes/*.obj
|
||||
hierarchy.json
|
||||
joints.json
|
||||
`joints.json` segna `generator: 'ExportKinematicGraph_ATL.py'`.
|
||||
|
||||
Come usare in Fusion:
|
||||
Utilities -> ADD-INS -> Scripts -> Green "+" -> "Create from existing
|
||||
script" e selezionare questo file. Eseguire. Cartella di destinazione
|
||||
consigliata: `C:\\Users\\croce\\OneDrive\\Desktop\\export_ATL\\`.
|
||||
"""
|
||||
|
||||
import adsk.core
|
||||
import adsk.fusion
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Caricamento modulo base (ExportKinematicGraph plotter)
|
||||
# =============================================================================
|
||||
#
|
||||
# Lo script ATL riusa gli helper del plotter via import. Cerco il modulo
|
||||
# in:
|
||||
# 1) cartella dello script ATL (caso "sviluppo nel repo, entrambi i
|
||||
# file affiancati in `export grafo fusion/`")
|
||||
# 2) cartella sorella `..\ExportKinematicGraph\` (caso "installato come
|
||||
# due add-in separati in %APPDATA%\Autodesk\.../API/Scripts/")
|
||||
# 3) percorso fisso del repo (fallback hard-coded)
|
||||
#
|
||||
# Se il modulo non si trova, lo script abortisce con un messaggio chiaro.
|
||||
|
||||
def _locate_base_module():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [
|
||||
here,
|
||||
os.path.normpath(os.path.join(here, '..', 'ExportKinematicGraph')),
|
||||
r'C:\Users\croce\OneDrive\Desktop\export grafo fusion',
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(os.path.join(path, 'ExportKinematicGraph.py')):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
_BASE_PATH = _locate_base_module()
|
||||
if _BASE_PATH is None:
|
||||
raise ImportError(
|
||||
"Impossibile trovare ExportKinematicGraph.py. Cercato in: "
|
||||
"cartella script, ..\\ExportKinematicGraph\\, repo "
|
||||
"'export grafo fusion'."
|
||||
)
|
||||
|
||||
# Forziamo reload se il base e' gia' stato importato in una sessione
|
||||
# Fusion precedente, cosi' eventuali modifiche al plotter sono raccolte.
|
||||
import importlib
|
||||
import ExportKinematicGraph as base # noqa: E402
|
||||
importlib.reload(base)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Logging diagnostico (stdout + UI palette)
|
||||
# =============================================================================
|
||||
|
||||
_LOG_LINES = []
|
||||
|
||||
|
||||
def _log(msg):
|
||||
line = '[ATL-export] ' + str(msg)
|
||||
_LOG_LINES.append(line)
|
||||
try:
|
||||
# Fusion redirige stdout alla TextCommandPalette in alcune build.
|
||||
print(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility numeriche (snap, token)
|
||||
# =============================================================================
|
||||
|
||||
_EPS_AXIS = 1e-9 # snap a zero sotto questa soglia
|
||||
_EPS_UNIT = 1e-9 # snap a +/-1 se molto vicino
|
||||
|
||||
|
||||
def _snap_axis(axis):
|
||||
"""Snap del rumore numerico in un vettore axis.
|
||||
|
||||
- componenti < EPS_AXIS in modulo -> 0.0
|
||||
- componenti molto vicine a +/-1 -> +/-1.0
|
||||
Ritorna None se l'input e' None o vuoto.
|
||||
"""
|
||||
if not axis:
|
||||
return axis
|
||||
out = []
|
||||
for v in axis:
|
||||
try:
|
||||
fv = float(v)
|
||||
except Exception:
|
||||
out.append(v)
|
||||
continue
|
||||
if abs(fv) < _EPS_AXIS:
|
||||
fv = 0.0
|
||||
elif abs(fv - 1.0) < _EPS_UNIT:
|
||||
fv = 1.0
|
||||
elif abs(fv + 1.0) < _EPS_UNIT:
|
||||
fv = -1.0
|
||||
out.append(fv)
|
||||
return out
|
||||
|
||||
|
||||
def _token_of(entity):
|
||||
"""Ritorna `entity.entityToken` se disponibile, altrimenti None.
|
||||
|
||||
Il token e' una stringa opaca univoca per entita' Fusion: utile come
|
||||
matching key quando i `name` sono duplicati o quando i motion link
|
||||
perdono il riferimento al `name` del joint.
|
||||
"""
|
||||
if entity is None:
|
||||
return None
|
||||
try:
|
||||
tok = getattr(entity, 'entityToken', None)
|
||||
if tok:
|
||||
return str(tok)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Limits: normalizzazione
|
||||
# =============================================================================
|
||||
|
||||
def _normalize_limits(limits_dict):
|
||||
"""Se entrambi i flag min/max enabled sono False, ritorna None.
|
||||
|
||||
Cosi' il consumer interpreta correttamente "joint libero, no limiti"
|
||||
invece di un range 0..0 (che il viewer attualmente filtra via,
|
||||
facendo sparire lo slider).
|
||||
|
||||
Mantiene il dict originale se almeno uno dei due limiti e' attivo,
|
||||
cosi' `isMinimumValueEnabled=True, isMaximumValueEnabled=False` resta
|
||||
valido (es: solo lower bound).
|
||||
"""
|
||||
if limits_dict is None:
|
||||
return None
|
||||
enabled_min = bool(limits_dict.get('isMinimumValueEnabled'))
|
||||
enabled_max = bool(limits_dict.get('isMaximumValueEnabled'))
|
||||
if not enabled_min and not enabled_max:
|
||||
return None
|
||||
return limits_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Origin per AsBuiltJoint: fallback su entityOne/entityTwo
|
||||
# =============================================================================
|
||||
|
||||
def _point3d_to_list(pt):
|
||||
"""Wrap di point_to_list che accetta gia' liste / tuple."""
|
||||
if pt is None:
|
||||
return None
|
||||
if isinstance(pt, (list, tuple)):
|
||||
try:
|
||||
return [float(pt[0]), float(pt[1]), float(pt[2])]
|
||||
except Exception:
|
||||
return None
|
||||
return base.point_to_list(pt)
|
||||
|
||||
|
||||
def _origin_from_entity(entity):
|
||||
"""Estrae il centro geometrico da una BRepFace cilindrica o BRepEdge
|
||||
circolare. Best-effort, tutto protetto da try/except: l'API espone
|
||||
`Surface.origin` per cilindri/coni e `Curve3D.center` per cerchi/archi.
|
||||
"""
|
||||
if entity is None:
|
||||
return None
|
||||
# BRepFace.geometry -> Surface (Cylinder, Cone, Sphere, Torus, Plane, ...)
|
||||
try:
|
||||
geom = getattr(entity, 'geometry', None)
|
||||
if geom is not None:
|
||||
# Cylinder/Cone/Torus/Sphere espongono `origin`.
|
||||
origin = getattr(geom, 'origin', None)
|
||||
if origin is not None:
|
||||
return base.point_to_list(origin)
|
||||
# Circle3D/Ellipse3D/Arc3D espongono `center`.
|
||||
center = getattr(geom, 'center', None)
|
||||
if center is not None:
|
||||
return base.point_to_list(center)
|
||||
except Exception:
|
||||
pass
|
||||
# BRepVertex
|
||||
try:
|
||||
pt = getattr(entity, 'geometry', None)
|
||||
if pt is not None and hasattr(pt, 'x'):
|
||||
return base.point_to_list(pt)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _origin_for_as_built(joint):
|
||||
"""Pipeline a tre stadi per ricavare l'origine di un AsBuiltJoint.
|
||||
|
||||
1) `joint.geometry.origin` (raro per AsBuilt, ma proviamo).
|
||||
2) Centro geometrico di `joint.entityOne` (di solito una BRepFace
|
||||
cilindrica per i revolute, o un BRepEdge circolare).
|
||||
3) Centro di `joint.entityTwo` come last resort.
|
||||
|
||||
Ritorna `(origin_list, source)` dove `source` e':
|
||||
'geometry' | 'entityOne' | 'entityTwo' | None
|
||||
Cosi' loggo la provenienza nelle stats.
|
||||
"""
|
||||
pt = base._joint_geometry_point(getattr(joint, 'geometry', None))
|
||||
if pt:
|
||||
return pt, 'geometry'
|
||||
for ent_attr, label in (('entityOne', 'entityOne'), ('entityTwo', 'entityTwo')):
|
||||
ent = getattr(joint, ent_attr, None)
|
||||
pt = _origin_from_entity(ent)
|
||||
if pt:
|
||||
return pt, label
|
||||
return None, None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Disambiguazione nomi (fix #2)
|
||||
# =============================================================================
|
||||
|
||||
def _disambiguate_names(records, name_key='name', dup_marker='#'):
|
||||
"""Modifica in-place la lista `records` rendendo univoci i `name`.
|
||||
|
||||
Strategia: scan in ordine. La prima occorrenza tiene il name originale,
|
||||
dalla seconda in poi viene appeso `#2`, `#3`, ... Conta i collision
|
||||
per loggarli.
|
||||
|
||||
Ritorna il numero di rinominazioni effettuate.
|
||||
"""
|
||||
counts = {}
|
||||
renamed = 0
|
||||
for rec in records:
|
||||
if rec is None:
|
||||
continue
|
||||
orig = rec.get(name_key)
|
||||
if not orig:
|
||||
continue
|
||||
counts[orig] = counts.get(orig, 0) + 1
|
||||
if counts[orig] == 1:
|
||||
continue
|
||||
new = '{0}{1}{2}'.format(orig, dup_marker, counts[orig])
|
||||
rec['_originalName'] = orig
|
||||
rec[name_key] = new
|
||||
renamed += 1
|
||||
return renamed
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Serializzazione joint con fix ATL
|
||||
# =============================================================================
|
||||
|
||||
def _enrich_axis(motion_info):
|
||||
"""Applica snap a `axis` e `secondaryAxis` in-place."""
|
||||
motion_info['axis'] = _snap_axis(motion_info.get('axis'))
|
||||
motion_info['secondaryAxis'] = _snap_axis(motion_info.get('secondaryAxis'))
|
||||
return motion_info
|
||||
|
||||
|
||||
def _serialize_joint_atl(j):
|
||||
"""Variante di base._serialize_joint con: token, axis snappato,
|
||||
limits normalizzati."""
|
||||
try:
|
||||
data = base._serialize_joint(j)
|
||||
if data is None:
|
||||
return None
|
||||
# Snap axis.
|
||||
data['axis'] = _snap_axis(data.get('axis'))
|
||||
data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis'))
|
||||
# Normalize limits.
|
||||
data['rotationLimits'] = _normalize_limits(data.get('rotationLimits'))
|
||||
data['slideLimits'] = _normalize_limits(data.get('slideLimits'))
|
||||
# Token del joint (sopravvive a rename).
|
||||
data['_token'] = _token_of(j)
|
||||
# Flag orfano (parent + child entrambi None).
|
||||
if data.get('parent') is None and data.get('child') is None:
|
||||
data['_orphan'] = True
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_as_built_joint_atl(j):
|
||||
"""Variante di base._serialize_as_built_joint con: token, axis
|
||||
snappato, limits normalizzati, origin con fallback su entity."""
|
||||
try:
|
||||
data = base._serialize_as_built_joint(j)
|
||||
if data is None:
|
||||
return None
|
||||
# Snap axis.
|
||||
data['axis'] = _snap_axis(data.get('axis'))
|
||||
data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis'))
|
||||
# Normalize limits.
|
||||
data['rotationLimits'] = _normalize_limits(data.get('rotationLimits'))
|
||||
data['slideLimits'] = _normalize_limits(data.get('slideLimits'))
|
||||
# Origin con fallback (fondamentale per i revolute).
|
||||
if data.get('origin') is None:
|
||||
origin, source = _origin_for_as_built(j)
|
||||
if origin is not None:
|
||||
data['origin'] = origin
|
||||
data['_originSource'] = source
|
||||
# Token del joint.
|
||||
data['_token'] = _token_of(j)
|
||||
# Flag orfano.
|
||||
if data.get('parent') is None and data.get('child') is None:
|
||||
data['_orphan'] = True
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _export_joints_atl(design, root_comp):
|
||||
"""Come base.export_joints ma con _serialize_joint_atl."""
|
||||
result = []
|
||||
seen = set()
|
||||
for comp in base._iter_all_components(design, root_comp):
|
||||
joints = base._safe_get(comp, 'joints')
|
||||
if joints is None:
|
||||
continue
|
||||
try:
|
||||
n = joints.count
|
||||
except Exception:
|
||||
n = 0
|
||||
for i in range(n):
|
||||
try:
|
||||
j = joints.item(i)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
key = j.entityToken
|
||||
except Exception:
|
||||
key = id(j)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
data = _serialize_joint_atl(j)
|
||||
if data is not None:
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
def _export_as_built_joints_atl(design, root_comp):
|
||||
"""Come base.export_as_built_joints ma con _serialize_as_built_joint_atl."""
|
||||
result = []
|
||||
seen = set()
|
||||
for comp in base._iter_all_components(design, root_comp):
|
||||
ab_joints = base._safe_get(comp, 'asBuiltJoints')
|
||||
if ab_joints is None:
|
||||
continue
|
||||
try:
|
||||
n = ab_joints.count
|
||||
except Exception:
|
||||
n = 0
|
||||
for i in range(n):
|
||||
try:
|
||||
j = ab_joints.item(i)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
key = j.entityToken
|
||||
except Exception:
|
||||
key = id(j)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
data = _serialize_as_built_joint_atl(j)
|
||||
if data is not None:
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
def _export_motion_links_atl(design):
|
||||
"""Come base.export_motion_links ma emette anche i token delle
|
||||
entita' originali per il matching manuale quando joint1/joint2
|
||||
sono None (limite API noto).
|
||||
"""
|
||||
result = []
|
||||
root_comp = base._safe_get(design, 'rootComponent')
|
||||
if root_comp is None:
|
||||
return result
|
||||
seen = set()
|
||||
for comp in base._iter_all_components(design, root_comp):
|
||||
motion_links = base._safe_get(comp, 'motionLinks')
|
||||
if motion_links is None:
|
||||
continue
|
||||
try:
|
||||
n = motion_links.count
|
||||
except Exception:
|
||||
n = 0
|
||||
for i in range(n):
|
||||
try:
|
||||
ml = motion_links.item(i)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
key = ml.entityToken
|
||||
except Exception:
|
||||
key = id(ml)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
j1 = base._safe_get(ml, 'jointOne')
|
||||
j2 = base._safe_get(ml, 'jointTwo')
|
||||
# Anche entityOne/entityTwo (proxy interno) possono dare
|
||||
# un token utile quando il name e' perso.
|
||||
e1 = base._safe_get(ml, 'entityOne')
|
||||
e2 = base._safe_get(ml, 'entityTwo')
|
||||
result.append({
|
||||
'name': base._safe_get(ml, 'name'),
|
||||
'joint1': base._safe_get(j1, 'name'),
|
||||
'joint2': base._safe_get(j2, 'name'),
|
||||
'joint1Token': _token_of(j1) or _token_of(e1),
|
||||
'joint2Token': _token_of(j2) or _token_of(e2),
|
||||
'ratio': base._safe_get(ml, 'ratio'),
|
||||
'reversed': base._safe_get(ml, 'isReversed'),
|
||||
'isSuppressed': base._safe_get(ml, 'isSuppressed'),
|
||||
'isLightBulbOn': base._safe_get(ml, 'isLightBulbOn'),
|
||||
'currentValue': base._safe_get(ml, 'currentValue'),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enrich hierarchy: glbNodeName + log
|
||||
# =============================================================================
|
||||
|
||||
# Blender 4.x tronca i nomi degli Object a 63 caratteri (limite Python
|
||||
# bpy.types.ID.name). Qui emettiamo `glbNodeName` previsto = name[:63]
|
||||
# cosi' il consumer puo' fare lookup senza indovinare.
|
||||
_BLENDER_NAME_MAX = 63
|
||||
|
||||
|
||||
def _annotate_hierarchy(nodes):
|
||||
"""Aggiunge `glbNodeName` a ogni nodo (fix #10)."""
|
||||
for n in nodes:
|
||||
nm = n.get('name')
|
||||
if not nm:
|
||||
continue
|
||||
n['glbNodeName'] = nm if len(nm) <= _BLENDER_NAME_MAX else nm[:_BLENDER_NAME_MAX]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Folder dialog
|
||||
# =============================================================================
|
||||
|
||||
def _ask_destination_folder(ui, default_dir=None):
|
||||
dialog = ui.createFolderDialog()
|
||||
dialog.title = 'Scegli la cartella di destinazione export ATL'
|
||||
if default_dir:
|
||||
try:
|
||||
dialog.initialDirectory = default_dir
|
||||
except Exception:
|
||||
pass
|
||||
if dialog.showDialog() != adsk.core.DialogResults.DialogOK:
|
||||
return None
|
||||
return dialog.folder
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry point
|
||||
# =============================================================================
|
||||
|
||||
def run(context):
|
||||
"""Entry point richiamato da Fusion 360.
|
||||
|
||||
L'utente sceglie una cartella PARENT. Lo script crea (se serve) la
|
||||
sottocartella `export/` al suo interno e ci scrive:
|
||||
meshes/*.obj
|
||||
hierarchy.json
|
||||
joints.json
|
||||
"""
|
||||
ui = None
|
||||
try:
|
||||
app = adsk.core.Application.get()
|
||||
ui = app.userInterface
|
||||
|
||||
product = app.activeProduct
|
||||
if not isinstance(product, adsk.fusion.Design):
|
||||
if ui:
|
||||
ui.messageBox('Nessun design Fusion attivo.')
|
||||
return
|
||||
design = product
|
||||
|
||||
root_comp = design.rootComponent
|
||||
doc_name = base._safe_get(app.activeDocument, 'name', 'Untitled')
|
||||
|
||||
units = None
|
||||
try:
|
||||
units = design.unitsManager.defaultLengthUnits
|
||||
except Exception:
|
||||
units = None
|
||||
|
||||
api_version = None
|
||||
try:
|
||||
api_version = adsk.core.Application.get().version
|
||||
except Exception:
|
||||
api_version = None
|
||||
|
||||
parent_dir = _ask_destination_folder(ui)
|
||||
if not parent_dir:
|
||||
return
|
||||
|
||||
if os.path.basename(os.path.normpath(parent_dir)).lower() == 'export':
|
||||
out_dir = parent_dir
|
||||
else:
|
||||
out_dir = os.path.join(parent_dir, 'export')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
_log('body-per-body mode: ON (eredita da ExportKinematicGraph)')
|
||||
_log('base module path: ' + str(_BASE_PATH))
|
||||
|
||||
# --- 1) Mesh + hierarchy (riusiamo la pipeline plotter) ---------------
|
||||
nodes, mesh_stats = base.export_meshes_and_hierarchy(
|
||||
design, root_comp, out_dir
|
||||
)
|
||||
_annotate_hierarchy(nodes)
|
||||
_log('hierarchy nodes: {0}, mesh ok: {1}, mesh fail: {2}'.format(
|
||||
len(nodes), mesh_stats['exported_meshes'], mesh_stats['failed_meshes']
|
||||
))
|
||||
|
||||
hierarchy_payload = {
|
||||
'metadata': {
|
||||
'documentName': doc_name,
|
||||
'units': units,
|
||||
'internalUnit': 'cm',
|
||||
'scaleFactor': 0.01,
|
||||
'exportedAt': datetime.utcnow().isoformat() + 'Z',
|
||||
'apiVersion': api_version,
|
||||
'generator': 'ExportKinematicGraph_ATL.py',
|
||||
'profile': 'ATL',
|
||||
'blenderNameMax': _BLENDER_NAME_MAX,
|
||||
},
|
||||
'nodes': nodes,
|
||||
}
|
||||
with open(os.path.join(out_dir, 'hierarchy.json'), 'w', encoding='utf-8') as f:
|
||||
json.dump(hierarchy_payload, f, indent=2, ensure_ascii=False,
|
||||
default=str)
|
||||
|
||||
# --- 2) Joints con fix ATL --------------------------------------------
|
||||
joints_list = _export_joints_atl(design, root_comp)
|
||||
as_built_list = _export_as_built_joints_atl(design, root_comp)
|
||||
motion_links = _export_motion_links_atl(design)
|
||||
rigid_groups = base.export_rigid_groups(design, root_comp)
|
||||
|
||||
# Disambiguazione nomi: i joint e gli asBuilt vivono in spazi nomi
|
||||
# separati per Fusion, ma il viewer matcha per nome unico globale.
|
||||
# Disambiguiamo i duplicati cross-section.
|
||||
joints_renamed = _disambiguate_names(joints_list)
|
||||
ab_renamed = _disambiguate_names(as_built_list)
|
||||
|
||||
# Conteggio orfani / origin recuperati.
|
||||
orphans_joints = sum(1 for x in joints_list if x.get('_orphan'))
|
||||
orphans_ab = sum(1 for x in as_built_list if x.get('_orphan'))
|
||||
origin_recovered = sum(1 for x in as_built_list
|
||||
if x.get('_originSource') in ('entityOne', 'entityTwo'))
|
||||
revolutes_no_origin = sum(1 for x in as_built_list
|
||||
if x.get('type') == 'revolute' and x.get('origin') is None)
|
||||
|
||||
_log('joints: {0} (renamed {1}, orphans {2})'.format(
|
||||
len(joints_list), joints_renamed, orphans_joints))
|
||||
_log('asBuiltJoints: {0} (renamed {1}, orphans {2}, origin recovered {3}, revolute senza origin {4})'.format(
|
||||
len(as_built_list), ab_renamed, orphans_ab, origin_recovered, revolutes_no_origin))
|
||||
_log('motionLinks: {0}'.format(len(motion_links)))
|
||||
_log('rigidGroups: {0}'.format(len(rigid_groups)))
|
||||
|
||||
joints_payload = {
|
||||
'metadata': {
|
||||
'documentName': doc_name,
|
||||
'units': units,
|
||||
'internalUnit': 'cm',
|
||||
'scaleFactor': 0.01,
|
||||
'exportedAt': datetime.utcnow().isoformat() + 'Z',
|
||||
'apiVersion': api_version,
|
||||
'generator': 'ExportKinematicGraph_ATL.py',
|
||||
'profile': 'ATL',
|
||||
'fixes': {
|
||||
'duplicateNames': {'renamedJoints': joints_renamed,
|
||||
'renamedAsBuilt': ab_renamed},
|
||||
'axisSnap': {'epsilon': _EPS_AXIS},
|
||||
'limitsNormalized': True,
|
||||
'orphans': {'joints': orphans_joints,
|
||||
'asBuilt': orphans_ab},
|
||||
'asBuiltOrigin': {'recovered': origin_recovered,
|
||||
'revoluteStillMissing': revolutes_no_origin},
|
||||
'motionLinkTokens': True,
|
||||
},
|
||||
},
|
||||
'joints': joints_list,
|
||||
'asBuiltJoints': as_built_list,
|
||||
'motionLinks': motion_links,
|
||||
'rigidGroups': rigid_groups,
|
||||
}
|
||||
with open(os.path.join(out_dir, 'joints.json'), 'w', encoding='utf-8') as f:
|
||||
json.dump(joints_payload, f, indent=2, ensure_ascii=False,
|
||||
default=str)
|
||||
|
||||
# Salva il log accanto per debug.
|
||||
with open(os.path.join(out_dir, 'export_atl.log'), 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(_LOG_LINES) + '\n')
|
||||
|
||||
if ui:
|
||||
ui.messageBox(
|
||||
'Export ATL completato.\n\n'
|
||||
'Cartella: {0}\n\n'
|
||||
'Nodi gerarchia: {1}\n'
|
||||
'Mesh esportate: {2} (fallite: {3})\n\n'
|
||||
'Joints: {4} (rinominati {5}, orfani {6})\n'
|
||||
'As-Built Joints: {7} (rinominati {8}, origin recuperati {9}, revolute senza origin {10})\n'
|
||||
'Motion Links: {11}\n'
|
||||
'Rigid Groups: {12}\n\n'
|
||||
'Log dettagliato: export_atl.log'.format(
|
||||
out_dir,
|
||||
len(nodes),
|
||||
mesh_stats['exported_meshes'],
|
||||
mesh_stats['failed_meshes'],
|
||||
len(joints_list), joints_renamed, orphans_joints,
|
||||
len(as_built_list), ab_renamed, origin_recovered, revolutes_no_origin,
|
||||
len(motion_links),
|
||||
len(rigid_groups),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
if ui:
|
||||
ui.messageBox(
|
||||
'Errore durante l\'export ATL:\n\n' + traceback.format_exc()
|
||||
)
|
||||
Reference in New Issue
Block a user