commit cdf4bfb3ab48dcf5c8323a952e35e9bb1755f7bd Author: marco Date: Tue Jun 9 17:43:13 2026 +0200 Initial commit: Fusion->Blender->GLB pipeline + contract for ThreeJS bridge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ece98a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed42bfc --- /dev/null +++ b/AGENTS.md @@ -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/` 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). diff --git a/BRIDGE_NOTES.md b/BRIDGE_NOTES.md new file mode 100644 index 0000000..8db1f0c --- /dev/null +++ b/BRIDGE_NOTES.md @@ -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 + / + .glb + .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 +``` diff --git a/ExportKinematicGraph.py b/ExportKinematicGraph.py new file mode 100644 index 0000000..1654778 --- /dev/null +++ b/ExportKinematicGraph.py @@ -0,0 +1,1710 @@ +# -*- coding: utf-8 -*- +""" +ExportKinematicGraph.py +----------------------- +Script per Autodesk Fusion 360 che esporta in una cartella scelta +dall'utente: + + / + export/ + meshes/*.obj (o .stl in fallback) -> una mesh per Component + hierarchy.json -> gerarchia occurrences + transform + joints.json -> joints + as-built + motion links + +Lo script crea SEMPRE la sottocartella `export/` dentro la cartella +selezionata (se l'utente sceglie gia' una cartella chiamata "export" +non viene aggiunto un secondo livello). + +Pensato per essere ricostruito in Blender / Three.js. Le mesh sono +deduplicate per Component: occurrences che condividono lo stesso +component puntano allo stesso file mesh (instancing). + +Come usare: + 1. In Fusion 360 -> Utilities -> ADD-INS -> Scripts -> Green "+" + -> "Create from existing script" e selezionare questo file. + 2. Eseguire lo script ("Run"). + 3. Scegliere la cartella di destinazione nel FolderDialog. + +Note: + * L'API di Fusion 360 puo' variare tra release: tutte le proprieta' + potenzialmente non disponibili sono protette da try/except. + * Unita' interna Fusion = cm. Vertici OBJ e translation delle matrici + sono in cm: il client (Blender) deve moltiplicare per scaleFactor=0.01 + per ottenere metri. +""" + +import adsk.core +import adsk.fusion +import json +import os +import re +import traceback +from datetime import datetime + + +# ============================================================================= +# CONFIGURAZIONE FILTRI (modifica qui se vuoi cambiare cosa viene escluso) +# ============================================================================= + +# Liste di pattern (regex, case-insensitive) per ESCLUDERE occurrences/components +# dall'export. Se il nome dell'occurrence O il nome del component matcha uno +# di questi pattern, l'occurrence non viene esportata (ne' mesh, ne' nodo +# nella hierarchy). +# +# Default: viti, dadi, rondelle, bulloni e norme ISO standard. +# Imposta a [] per non escludere nulla. +EXCLUDE_NAME_PATTERNS = [ + r'\bvite\b', r'\bviti\b', + r'\bdado\b', r'\bdadi\b', + r'\brondella\b', r'\brondelle\b', + r'\bnut\b', r'\bnuts\b', + r'\bwasher\b', + r'\bscrew\b', r'\bbolt\b', + r'\brivet\b', + r'\bISO[_ ]?4762\b', # vite a testa cilindrica esagono incassato + r'\bISO[_ ]?10673\b', # rondella + r'\bcap[_ ]?head[_ ]?screw\b', + r'\bsocket[_ ]?head\b', + r'\bhead[_ ]?screw\b', +] + +# Pre-compilazione: alternativa OR, case-insensitive. None = niente esclusioni. +_EXCLUDE_RE = (re.compile('|'.join(EXCLUDE_NAME_PATTERNS), re.IGNORECASE) + if EXCLUDE_NAME_PATTERNS else None) + + +def _is_name_excluded(*names): + """True se uno qualsiasi dei nomi passati matcha i pattern di esclusione.""" + if _EXCLUDE_RE is None: + return False + for n in names: + if n and _EXCLUDE_RE.search(n): + return True + return False + + +# ============================================================================= +# Utility di conversione +# ============================================================================= + +def matrix_to_list(matrix): + """Converte una adsk.core.Matrix3D in una lista [16] (row-major). + + Restituisce None se la matrice non e' valida. + Three.js usa column-major di default, ma e' banale convertirla lato JS: + qui esportiamo in row-major per leggibilita'. + """ + if matrix is None: + return None + try: + # asArray() in Fusion ritorna 16 float in row-major order. + return list(matrix.asArray()) + except Exception: + return None + + +def vector_to_list(vector): + """Converte un adsk.core.Vector3D in [x, y, z]. None se non valido.""" + if vector is None: + return None + try: + return [vector.x, vector.y, vector.z] + except Exception: + return None + + +def point_to_list(point): + """Converte un adsk.core.Point3D in [x, y, z]. None se non valido.""" + if point is None: + return None + try: + return [point.x, point.y, point.z] + except Exception: + return None + + +# ============================================================================= +# Estrazione colore (appearance) di un Component / Occurrence +# ============================================================================= + +# Nomi tipici della "diffuse / base color" nelle appearance di Fusion. +# Cerchiamo per nome PRIMA di accettare il primo ColorProperty trovato, +# perche' una stessa appearance puo' avere piu' ColorProperty (es. specular, +# transparent_color, ecc.) e prendere il primo darebbe il colore sbagliato. +# +# Ordine: piu' specifico -> meno specifico. +# - generic_* : shader plastica/vernice (Color/surface_albedo/...). +# - metal_* : shader metallo (Aluminum, Steel, Brass, Copper, ...). +# - layered_* : appearance Autodesk Generic v2. +# - solid_albedo / opaque_albedo: varianti. +_COLOR_PROP_NAMES = ( + 'Color', + 'surface_albedo', + 'opaque_albedo', + 'solid_albedo', + 'generic_diffuse', + 'metal_color', # Autodesk Metal: colore principale + 'metal_f0', # reflectance metallico (fallback) + 'layered_diffuse', + 'wall_color', + 'interior_color', + 'unifiedbitmap_Bitmap', # ultimo fallback +) + +# Colori di default per "famiglia" di metalli, usati quando l'appearance +# Fusion non espone alcun ColorProperty utilizzabile (es. shader basato solo +# su bitmap). Valori in **sRGB 0-1** (verranno convertiti in linear dopo). +_APPEARANCE_NAME_DEFAULTS = ( + # (substring case-insensitive, [r, g, b, a] sRGB) + ('alumin', [0.86, 0.87, 0.88, 1.0]), # alluminio chiaro + ('steel', [0.62, 0.62, 0.64, 1.0]), # acciaio + ('iron', [0.55, 0.55, 0.58, 1.0]), + ('stainless',[0.78, 0.78, 0.80, 1.0]), + ('chrome', [0.85, 0.86, 0.88, 1.0]), + ('brass', [0.83, 0.69, 0.22, 1.0]), + ('bronze', [0.55, 0.39, 0.20, 1.0]), + ('copper', [0.72, 0.45, 0.20, 1.0]), + ('zinc', [0.70, 0.72, 0.73, 1.0]), + ('nickel', [0.78, 0.76, 0.70, 1.0]), + ('titanium', [0.62, 0.60, 0.58, 1.0]), + ('gold', [1.00, 0.78, 0.34, 1.0]), + ('silver', [0.90, 0.91, 0.92, 1.0]), + ('metal', [0.70, 0.71, 0.72, 1.0]), # generico +) + + +def _srgb_to_linear(c): + """Converte un canale sRGB (0-1) in linear RGB (0-1).""" + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def _srgb_list_to_linear(rgba_srgb): + r, g, b, a = rgba_srgb + return [_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b), a] + + +def _maybe_collapse_near_white(rgba_linear): + """Safety net molto conservativo: intercetta SOLO i colori che sono + firma chiara di un'appearance metallica Fusion non riconosciuta + (≈0.913 grigio quasi-bianco). NON tocca bianchi puri o grigi medi. + """ + if rgba_linear is None: + return None + r, g, b, a = rgba_linear + # Firma Fusion metallo: linear ≈ 0.815 (= sRGB 0.913), variazione < 0.01. + if 0.79 < r < 0.84 and 0.79 < g < 0.84 and 0.79 < b < 0.84: + return [0.5, 0.5, 0.5, a] + return rgba_linear + + +def _default_from_appearance_name(appearance): + """Se l'appearance NON espone color properties utilizzabili, ritorna un + colore plausibile basato sul nome dell'appearance (es. 'Aluminum - Brushed' + -> grigio chiaro). Ritorna None se nessuna corrispondenza.""" + try: + name = (appearance.name or '').lower() + except Exception: + return None + if not name: + return None + for key, rgba in _APPEARANCE_NAME_DEFAULTS: + if key in name: + return _srgb_list_to_linear(rgba) + return None + + +def _appearance_is_metal(appearance): + """Heuristica: True se l'appearance e' di tipo "metallo". + + Riconosciamo un metallo se: + a) il nome contiene una keyword in _APPEARANCE_NAME_DEFAULTS; + b) OPPURE le sue properties contengono nomi `metal_*` (firma tipica + delle BRDF metalliche Autodesk, anche se l'appearance e' renamed). + """ + if appearance is None: + return False + # a) by name + try: + name = (appearance.name or '').lower() + except Exception: + name = '' + if name: + for key, _ in _APPEARANCE_NAME_DEFAULTS: + if key in name: + return True + # b) by metal_* properties + try: + props = appearance.appearanceProperties + if props is not None: + for i in range(props.count): + try: + pn = (props.item(i).name or '').lower() + except Exception: + continue + if pn.startswith('metal_') or pn == 'metalness': + return True + except Exception: + pass + return False + + +def _color_from_appearance(appearance): + """Estrae [r, g, b, a] (0-1 float, **linear RGB**) dalla diffuse color. + + Strategia: + 0. se l'appearance e' un METALLO (per nome o per presenza di property + `metal_*`), ritorna il default scuro della mappa per il tipo + riconosciuto (o un grigio neutro). Motivo: in Fusion le appearance + metalliche hanno un `Color` quasi bianco (≈0.91) perche' l'aspetto + grigio viene dalla BRDF metallica (riflessioni/Fresnel), non dal + Color puro. Esportare quel valore in un materiale opaco renderizza + pezzi quasi bianchi in Three.js. + 1. altrimenti cerca un ColorProperty con name in _COLOR_PROP_NAMES; + 2. fallback al primo ColorProperty disponibile (scartando specular/ + transparent/emissive/normal). + Converte da sRGB (Fusion) a linear RGB perche' glTF baseColorFactor e' + in linear space. + """ + if appearance is None: + return None + + # 0a) Appearance "Opaque(R,G,B)" (colore custom Fusion). Il nome + # contiene direttamente i 3 valori RGB 0-255 -> li parsiamo e usiamo + # quelli, e' il modo PIU' affidabile per i colori custom. + try: + ap_name = appearance.name or '' + except Exception: + ap_name = '' + if ap_name.startswith('Opaque('): + try: + inner = ap_name[len('Opaque('):].rstrip(') ') + parts = [p.strip() for p in inner.split(',')] + if len(parts) >= 3: + r = int(parts[0]) / 255.0 + g = int(parts[1]) / 255.0 + b = int(parts[2]) / 255.0 + a = (int(parts[3]) / 255.0) if len(parts) >= 4 else 1.0 + return _srgb_list_to_linear((r, g, b, a)) + except Exception: + pass + + # 0b) Metallo: vince sui ColorProperty. + if _appearance_is_metal(appearance): + named = _default_from_appearance_name(appearance) + if named is not None: + return named + # Heuristica gia' positiva ma nome sconosciuto -> generic metal. + return _srgb_list_to_linear([0.70, 0.71, 0.72, 1.0]) + + try: + props = appearance.appearanceProperties + except Exception: + props = None + + found_by_name = None + found_first = None + + if props is not None: + for i in range(props.count): + try: + p = props.item(i) + except Exception: + continue + if not isinstance(p, adsk.core.ColorProperty): + continue + try: + c = p.value + if c is None: + continue + rgba_srgb = ( + c.red / 255.0, + c.green / 255.0, + c.blue / 255.0, + c.opacity / 255.0, + ) + except Exception: + continue + + try: + pname = p.name + except Exception: + pname = '' + + # Scartiamo property note per essere NON il colore base. + pname_l = (pname or '').lower() + if ('specular' in pname_l or 'transparent' in pname_l or + 'emiss' in pname_l or 'normal' in pname_l): + continue + + if found_first is None: + found_first = rgba_srgb + + if pname in _COLOR_PROP_NAMES: + found_by_name = rgba_srgb + break + + src = found_by_name or found_first + if src is not None: + return _maybe_collapse_near_white(_srgb_list_to_linear(src)) + + return None + + +# ============================================================================= +# Estrazione COLORE dal MATERIAL (Steel, Aluminum, ecc.) +# ============================================================================= +# Il `Material` in Fusion e' la proprieta' FISICA del body (acciaio, +# alluminio, ottone, ...). E' molto piu' affidabile dell'appearance perche': +# - non dipende dal tema visivo / appearance scelta; +# - copre TUTTI i pezzi (Fusion assegna sempre un material di default); +# - i nomi sono standardizzati nelle librerie Autodesk. +# Strategia: cerchiamo nel nome del material delle keyword (steel, aluminum, +# ...) e mappiamo a un colore realistico in sRGB. + +# Colori realistici per famiglia di material, in **sRGB 0-1** (verranno +# convertiti a linear dopo). Ordine = priorita': la prima keyword che matcha +# vince, quindi le piu' specifiche vanno PRIMA (es. 'stainless' prima di +# 'steel'). +_MATERIAL_NAME_COLORS = ( + # (substring case-insensitive nel nome material, [r, g, b, a] sRGB) + # --- METALLI --- + ('stainless', [0.78, 0.80, 0.82, 1.0]), # acciaio inox lucido + ('inox', [0.78, 0.80, 0.82, 1.0]), # IT + ('cast iron', [0.32, 0.32, 0.33, 1.0]), # ghisa scura + ('ghisa', [0.32, 0.32, 0.33, 1.0]), + ('iron', [0.42, 0.42, 0.44, 1.0]), + ('ferro', [0.42, 0.42, 0.44, 1.0]), + ('steel', [0.42, 0.43, 0.45, 1.0]), # acciaio scuro realistico + ('acciaio', [0.42, 0.43, 0.45, 1.0]), # IT + ('aluminum', [0.66, 0.67, 0.69, 1.0]), # alluminio grigio medio + ('aluminium', [0.66, 0.67, 0.69, 1.0]), + ('alluminio', [0.66, 0.67, 0.69, 1.0]), + ('brass', [0.82, 0.66, 0.22, 1.0]), # ottone giallo + ('ottone', [0.82, 0.66, 0.22, 1.0]), + ('bronze', [0.62, 0.42, 0.20, 1.0]), # bronzo + ('bronzo', [0.62, 0.42, 0.20, 1.0]), + ('copper', [0.72, 0.42, 0.20, 1.0]), # rame + ('rame', [0.72, 0.42, 0.20, 1.0]), + ('zinc', [0.70, 0.72, 0.73, 1.0]), + ('zinco', [0.70, 0.72, 0.73, 1.0]), + ('nickel', [0.78, 0.76, 0.70, 1.0]), + ('titanium', [0.62, 0.60, 0.58, 1.0]), + ('titanio', [0.62, 0.60, 0.58, 1.0]), + ('gold', [0.95, 0.74, 0.30, 1.0]), + ('silver', [0.88, 0.89, 0.90, 1.0]), + ('chrome', [0.84, 0.86, 0.88, 1.0]), + # --- PLASTICHE (controlla 'plastica' italiano PRIMA di 'plastic') --- + ('plastica, trasparente', [0.85, 0.92, 0.95, 0.45]), + ('plastica, bianco', [0.92, 0.92, 0.92, 1.0]), + ('plastica, nero', [0.08, 0.08, 0.08, 1.0]), + ('abs', [0.85, 0.85, 0.85, 1.0]), # ABS bianco di default + ('nylon', [0.92, 0.90, 0.85, 1.0]), # nylon avorio + ('polyethylene', [0.88, 0.88, 0.88, 1.0]), + ('polypropylene', [0.85, 0.85, 0.82, 1.0]), + ('polycarbonate', [0.78, 0.85, 0.90, 0.6]), # PC semi-trasparente + ('pvc', [0.30, 0.30, 0.35, 1.0]), # PVC scuro + ('peek', [0.75, 0.62, 0.30, 1.0]), # PEEK ambrato + ('teflon', [0.95, 0.95, 0.95, 1.0]), + ('ptfe', [0.95, 0.95, 0.95, 1.0]), + ('delrin', [0.92, 0.92, 0.90, 1.0]), + ('acetal', [0.92, 0.92, 0.90, 1.0]), + ('plastica', [0.70, 0.70, 0.72, 1.0]), # generico IT + ('plastic', [0.50, 0.50, 0.55, 1.0]), # generico EN + # --- GOMMA / ELASTOMERI --- + ('rubber', [0.08, 0.08, 0.08, 1.0]), # gomma nera + ('gomma', [0.08, 0.08, 0.08, 1.0]), + ('silicone', [0.85, 0.85, 0.85, 1.0]), + ('epdm', [0.10, 0.10, 0.10, 1.0]), + # --- VETRO --- + ('glass', [0.85, 0.92, 0.95, 0.35]), + ('vetro', [0.85, 0.92, 0.95, 0.35]), + # --- LEGNO --- + ('wood', [0.55, 0.40, 0.25, 1.0]), + ('legno', [0.55, 0.40, 0.25, 1.0]), + # --- ALTRO --- + ('ceramic', [0.92, 0.92, 0.88, 1.0]), + ('ceramica', [0.92, 0.92, 0.88, 1.0]), + ('concrete', [0.65, 0.65, 0.62, 1.0]), + ('calcestruzzo', [0.65, 0.65, 0.62, 1.0]), +) + + +def _color_from_material(material): + """Mappa il nome del Material Fusion a un colore realistico (linear RGB). + + Ritorna None se il material non e' riconosciuto. + """ + if material is None: + return None + try: + name = (material.name or '').lower() + except Exception: + return None + if not name: + return None + for key, rgba_srgb in _MATERIAL_NAME_COLORS: + if key in name: + return _srgb_list_to_linear(rgba_srgb) + return None + + +def _extract_material_color_from_component(comp): + """Cerca un material assegnato al component o ai suoi body e ritorna + il colore mappato. Ritorna None se nessun material e' riconosciuto. + """ + if comp is None: + return None + # 1) Material a livello di component (alcune versioni Fusion). + try: + col = _color_from_material(_safe_get(comp, 'material')) + if col is not None: + return col + except Exception: + pass + # 2) Material sui body (caso standard in Fusion). + try: + bodies = _safe_get(comp, 'bRepBodies') + if bodies is not None: + for i in range(bodies.count): + try: + b = bodies.item(i) + col = _color_from_material(_safe_get(b, 'material')) + if col is not None: + return col + except Exception: + continue + except Exception: + pass + return None + + +def _extract_component_color(comp): + """Ritorna [r,g,b,a] del component. + + Priorita': + 1. MATERIAL fisico del body (Steel, Aluminum, Brass, ...): mappa + deterministica a colori realistici. + 2. Appearance (colore visivo Fusion): per pezzi con appearance + custom colorata (rosso, nero, verde, ...) che non hanno material + specifico riconosciuto. + """ + if comp is None: + return None + # 1) Material -> colore realistico. + col = _extract_material_color_from_component(comp) + if col is not None: + return col + # 2) Appearance (component, poi bodies). + try: + col = _color_from_appearance(_safe_get(comp, 'appearance')) + if col is not None: + return col + except Exception: + pass + try: + bodies = _safe_get(comp, 'bRepBodies') + if bodies is not None: + for i in range(bodies.count): + try: + b = bodies.item(i) + col = _color_from_appearance(_safe_get(b, 'appearance')) + if col is not None: + return col + except Exception: + continue + except Exception: + pass + return None + + +def _extract_occurrence_color(occ): + """Ritorna [r,g,b,a] dell'appearance OVERRIDE sull'occurrence. + + Diverso da `_extract_component_color`: qui leggiamo SOLO l'appearance + perche' l'occurrence puo' avere un override visivo (es. lo stesso pezzo + usato 4 volte con colori diversi). NON usiamo material qui (il material + e' una proprieta' del component, non dell'occurrence). + Ritorna None se l'occurrence non ha override. + """ + if occ is None: + return None + try: + return _color_from_appearance(_safe_get(occ, 'appearance')) + except Exception: + return None + + +# ============================================================================= +# Estrazione "perfetta": guarda OGNI body dell'occurrence e prende il +# material/appearance del body dominante (volume maggiore). Esporta anche +# i nomi per debug. +# ============================================================================= + +def _collect_body_info(bodies): + """Restituisce lista di {volume, materialName, appearanceName, body} + per ogni body, ordinata per volume decrescente. + """ + items = [] + if bodies is None: + return items + try: + n = bodies.count + except Exception: + return items + for i in range(n): + try: + b = bodies.item(i) + except Exception: + continue + try: + vol = float(b.volume) if b.volume is not None else 0.0 + except Exception: + vol = 0.0 + try: + mat = b.material + mat_name = (mat.name if mat else '') or '' + except Exception: + mat_name = '' + try: + app = b.appearance + app_name = (app.name if app else '') or '' + except Exception: + app_name = '' + items.append({ + 'volume': vol, + 'materialName': mat_name, + 'appearanceName': app_name, + 'body': b, + }) + items.sort(key=lambda x: x['volume'], reverse=True) + return items + + +def _extract_color_and_names_for_occurrence(occ): + """Strategia "perfetta" per riprodurre la situazione Fusion. + + Per ogni occurrence: + 1. legge i bodies in contesto OCCURRENCE (riflettono override assembly); + 2. prende il body dominante (volume maggiore); + 3. ricava il colore con priorita': + a. material del body (Steel/Aluminum/Brass/...) -> colore realistico + b. appearance del body + c. appearance dell'occurrence (override locale) + d. appearance del component nativo + 4. esporta anche `materialName` e `appearanceName` del body dominante + nel JSON per debug e per uso lato Three.js. + + Ritorna (color_rgba_linear_or_None, materialName_str, appearanceName_str). + """ + if occ is None: + return (None, '', '') + + # 1) Bodies in contesto occurrence. + bodies = None + try: + bodies = occ.bRepBodies + except Exception: + bodies = None + items = _collect_body_info(bodies) + + # 1b) Fallback: bodies del component nativo (se l'occurrence non li + # espone direttamente, raro). + if not items: + try: + comp = occ.component + items = _collect_body_info(_safe_get(comp, 'bRepBodies')) + except Exception: + items = [] + + if not items: + # Nessun body -> probabilmente un assembly/empty: leggiamo solo + # l'appearance del component. + try: + col = _color_from_appearance(_safe_get(occ.component, 'appearance')) + except Exception: + col = None + return (col, '', '') + + dominant = items[0] + body = dominant['body'] + mat_name = dominant['materialName'] + app_name = dominant['appearanceName'] + + color = None + + # a0) Appearance "Opaque(R,G,B)" = colore custom esplicito dell'utente: + # ha priorita' assoluta sul material (es. emergency stop rosso con + # material=Acciaio). + if app_name.startswith('Opaque('): + try: + color = _color_from_appearance(body.appearance) + except Exception: + color = None + + # a) Material -> colore realistico. + if color is None: + try: + color = _color_from_material(body.material if body.material else None) + except Exception: + color = None + + # b) Appearance del body. + if color is None: + try: + color = _color_from_appearance(body.appearance) + except Exception: + color = None + + # c) Appearance dell'occurrence (override). + if color is None: + try: + color = _color_from_appearance(_safe_get(occ, 'appearance')) + except Exception: + color = None + + # d) Appearance del component nativo. + if color is None: + try: + color = _color_from_appearance(_safe_get(occ.component, 'appearance')) + except Exception: + color = None + + return (color, mat_name, app_name) + + +# ============================================================================= +# Mappatura tipi di joint +# ============================================================================= + +# Mappa "nome classe geometry/motion" -> stringa tipo. +# Usiamo il nome della classe del jointMotion / geometryOrOriginOne per +# essere compatibili tra versioni: gli enum int variano e i nomi sono stabili. +_JOINT_TYPE_MAP = { + 'RigidJointMotion': 'rigid', + 'RevoluteJointMotion': 'revolute', + 'SliderJointMotion': 'slider', + 'CylindricalJointMotion': 'cylindrical', + 'PinSlotJointMotion': 'pin_slot', + 'PlanarJointMotion': 'planar', + 'BallJointMotion': 'ball', +} + +# Costanti enum di adsk.fusion.JointTypes (fallback se jointMotion mancante). +_JOINT_TYPE_ENUM_MAP = { + 0: 'rigid', + 1: 'revolute', + 2: 'slider', + 3: 'cylindrical', + 4: 'pin_slot', + 5: 'planar', + 6: 'ball', +} + + +def joint_type_to_string(joint): + """Restituisce una stringa che descrive il tipo di joint. + + Prova prima a leggere `joint.jointMotion` (presente sia su Joint + che su AsBuiltJoint) e a usare il nome della classe. + Fallback sull'enum `joint.jointType`. Ritorna 'unknown' se nulla + funziona. + """ + # 1) Via jointMotion (piu' affidabile tra versioni) + try: + motion = joint.jointMotion + if motion is not None: + cls_name = type(motion).__name__ + if cls_name in _JOINT_TYPE_MAP: + return _JOINT_TYPE_MAP[cls_name] + except Exception: + pass + + # 2) Via enum jointType + try: + jt = joint.jointType + if jt in _JOINT_TYPE_ENUM_MAP: + return _JOINT_TYPE_ENUM_MAP[jt] + except Exception: + pass + + return 'unknown' + + +# ============================================================================= +# Estrazione dati joint motion (assi, limiti, valore corrente) +# ============================================================================= + +def _safe_get(obj, attr, default=None): + """Ritorna `obj.attr` proteggendo con try/except. Default se assente.""" + try: + value = getattr(obj, attr, default) + return value if value is not None else default + except Exception: + return default + + +def _extract_limits(limits_obj): + """Converte un *JointLimits in dict serializzabile.""" + if limits_obj is None: + return None + try: + return { + 'minimumValue': _safe_get(limits_obj, 'minimumValue'), + 'maximumValue': _safe_get(limits_obj, 'maximumValue'), + 'restValue': _safe_get(limits_obj, 'restValue'), + 'isMinimumValueEnabled': _safe_get(limits_obj, 'isMinimumValueEnabled'), + 'isMaximumValueEnabled': _safe_get(limits_obj, 'isMaximumValueEnabled'), + 'isRestValueEnabled': _safe_get(limits_obj, 'isRestValueEnabled'), + } + except Exception: + return None + + +def _extract_motion_info(joint): + """Estrae assi/limiti/valore corrente dal jointMotion del joint. + + Restituisce un dict con chiavi (eventualmente None): + axis, secondaryAxis, rotationLimits, slideLimits, + rotationValue, slideValue, primaryAxisVector + Adatto sia a Joint che ad AsBuiltJoint. + """ + info = { + 'axis': None, + 'secondaryAxis': None, + 'rotationLimits': None, + 'slideLimits': None, + 'rotationValue': None, + 'slideValue': None, + } + + motion = _safe_get(joint, 'jointMotion') + if motion is None: + return info + + cls_name = type(motion).__name__ + + # Asse primario (presente nella maggior parte dei tipi). + info['axis'] = vector_to_list(_safe_get(motion, 'rotationAxisVector')) \ + or vector_to_list(_safe_get(motion, 'slideDirectionVector')) \ + or vector_to_list(_safe_get(motion, 'normalDirectionVector')) + + # Tipo per tipo, popoliamo cio' che ha senso. + if cls_name == 'RevoluteJointMotion': + info['rotationLimits'] = _extract_limits(_safe_get(motion, 'rotationLimits')) + info['rotationValue'] = _safe_get(motion, 'rotationValue') + + elif cls_name == 'SliderJointMotion': + info['slideLimits'] = _extract_limits(_safe_get(motion, 'slideLimits')) + info['slideValue'] = _safe_get(motion, 'slideValue') + + elif cls_name == 'CylindricalJointMotion': + info['rotationLimits'] = _extract_limits(_safe_get(motion, 'rotationLimits')) + info['slideLimits'] = _extract_limits(_safe_get(motion, 'slideLimits')) + info['rotationValue'] = _safe_get(motion, 'rotationValue') + info['slideValue'] = _safe_get(motion, 'slideValue') + + elif cls_name == 'PinSlotJointMotion': + info['rotationLimits'] = _extract_limits(_safe_get(motion, 'rotationLimits')) + info['slideLimits'] = _extract_limits(_safe_get(motion, 'slideLimits')) + info['rotationValue'] = _safe_get(motion, 'rotationValue') + info['slideValue'] = _safe_get(motion, 'slideValue') + info['secondaryAxis'] = vector_to_list( + _safe_get(motion, 'slideDirectionVector')) + + elif cls_name == 'PlanarJointMotion': + info['slideLimits'] = _extract_limits(_safe_get(motion, 'primarySlideLimits')) + info['rotationLimits'] = _extract_limits(_safe_get(motion, 'rotationLimits')) + info['secondaryAxis'] = vector_to_list( + _safe_get(motion, 'primarySlideDirectionVector')) + + # Rigid e Ball non hanno limiti/valori in senso classico. + return info + + +def _occurrence_name(occ): + """Ritorna il nome 'leggibile' di una Occurrence (None se assente).""" + if occ is None: + return None + try: + return occ.name + except Exception: + return None + + +def _joint_geometry_point(geom): + """Tenta di ricavare il punto di origine da un JointGeometry/JointOrigin.""" + if geom is None: + return None + # JointGeometry espone `origin` (Point3D). JointOrigin espone `geometry`. + try: + origin = _safe_get(geom, 'origin') + if origin is not None: + return point_to_list(origin) + except Exception: + pass + try: + inner = _safe_get(geom, 'geometry') + if inner is not None: + return point_to_list(_safe_get(inner, 'origin')) + except Exception: + pass + return None + + +# ============================================================================= +# Export gerarchia componenti / occurrences +# ============================================================================= + +def export_occurrences(root_comp): + """Esporta ricorsivamente tutte le occurrences a partire dal root. + + Restituisce una lista piatta di dict (la gerarchia e' descritta dai + campi `parent` / `children` con `fullPathName`). + """ + items = [] + + def _walk(occ, parent_full_path): + try: + full_path = occ.fullPathName + except Exception: + full_path = _safe_get(occ, 'name') + + # Componente referenziato dall'occurrence. + comp = _safe_get(occ, 'component') + comp_name = _safe_get(comp, 'name') + + # Figli (sotto-occurrences). + child_paths = [] + children_col = _safe_get(occ, 'childOccurrences') + if children_col is not None: + try: + count = children_col.count + except Exception: + count = 0 + for i in range(count): + try: + child = children_col.item(i) + child_paths.append(_safe_get(child, 'fullPathName')) + except Exception: + continue + + items.append({ + 'name': _safe_get(occ, 'name'), + 'fullPathName': full_path, + 'occurrenceName': _safe_get(occ, 'name'), + 'componentName': comp_name, + 'parent': parent_full_path, + 'children': child_paths, + 'isLightBulbOn': _safe_get(occ, 'isLightBulbOn'), + 'isSuppressed': _safe_get(occ, 'isSuppressed'), + 'isGrounded': _safe_get(occ, 'isGrounded'), + 'transform': matrix_to_list(_safe_get(occ, 'transform2')) + or matrix_to_list(_safe_get(occ, 'transform')), + }) + + # Ricorsione sui figli. + if children_col is not None: + try: + count = children_col.count + except Exception: + count = 0 + for i in range(count): + try: + _walk(children_col.item(i), full_path) + except Exception: + continue + + # Le occurrences di primo livello sono in root_comp.occurrences. + root_occs = _safe_get(root_comp, 'occurrences') + if root_occs is not None: + try: + count = root_occs.count + except Exception: + count = 0 + for i in range(count): + try: + _walk(root_occs.item(i), None) + except Exception: + continue + + return items + + +# ============================================================================= +# Iterazione di tutti i componenti del design +# ============================================================================= + +def _iter_all_components(design, root_comp): + """Yield di tutti i Component definiti nel design. + + Fusion espone `design.allComponents` che contiene OGNI component + (root + sotto-componenti, anche quelli usati piu' volte come + occurrences). I joint, as-built joints e motion links vivono nella + collezione `Component.joints` / `asBuiltJoints` / `motionLinks` del + component dove sono stati creati, NON nel root. + + Fallback: se `allComponents` non e' disponibile, facciamo un walk + ricorsivo via occurrences (i component vengono deduplicati per id). + """ + seen = set() + + def _push(c): + if c is None: + return False + try: + key = c.id + except Exception: + key = id(c) + if key in seen: + return False + seen.add(key) + return True + + all_comps = _safe_get(design, 'allComponents') + if all_comps is not None: + try: + n = all_comps.count + except Exception: + n = 0 + for i in range(n): + try: + c = all_comps.item(i) + except Exception: + continue + if _push(c): + yield c + return + + # Fallback: walk via occurrences a partire dal root. + if _push(root_comp): + yield root_comp + + def _walk_comp(comp): + occs = _safe_get(comp, 'occurrences') + if occs is None: + return + try: + n = occs.count + except Exception: + n = 0 + for i in range(n): + try: + occ = occs.item(i) + except Exception: + continue + child_comp = _safe_get(occ, 'component') + if _push(child_comp): + yield child_comp + yield from _walk_comp(child_comp) + + yield from _walk_comp(root_comp) + + +# ============================================================================= +# Export Joints +# ============================================================================= + +def _serialize_joint(j): + """Serializza un singolo Joint in dict. None se fallisce.""" + try: + geom_one = _safe_get(j, 'geometryOrOriginOne') + geom_two = _safe_get(j, 'geometryOrOriginTwo') + occ_one = _safe_get(j, 'occurrenceOne') + occ_two = _safe_get(j, 'occurrenceTwo') + + motion_info = _extract_motion_info(j) + + return { + 'name': _safe_get(j, 'name'), + 'type': joint_type_to_string(j), + 'parent': _occurrence_name(occ_one), + 'child': _occurrence_name(occ_two), + 'parentFullPath': _safe_get(occ_one, 'fullPathName'), + 'childFullPath': _safe_get(occ_two, 'fullPathName'), + 'origin': _joint_geometry_point(geom_one), + 'originTwo': _joint_geometry_point(geom_two), + 'axis': motion_info['axis'], + 'secondaryAxis': motion_info['secondaryAxis'], + 'rotationLimits': motion_info['rotationLimits'], + 'slideLimits': motion_info['slideLimits'], + 'rotationValue': motion_info['rotationValue'], + 'slideValue': motion_info['slideValue'], + 'isSuppressed': _safe_get(j, 'isSuppressed'), + 'isLightBulbOn': _safe_get(j, 'isLightBulbOn'), + 'isLocked': _safe_get(j, 'isLocked'), + } + except Exception: + return None + + +def export_joints(design, root_comp=None): + """Esporta tutti i Joint del design (di TUTTI i component, non solo root). + + Itera `design.allComponents` (fallback: walk ricorsivo) e raccoglie + `component.joints` da ognuno. I joint sono deduplicati per token entityToken + quando disponibile. + """ + if root_comp is None: + root_comp = _safe_get(design, 'rootComponent') + + result = [] + seen = set() + + for comp in _iter_all_components(design, root_comp): + joints = _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 + + # Dedup tra component diversi (stesso joint puo' essere visto + # da piu' parent component in alcuni edge case). + try: + key = j.entityToken + except Exception: + key = id(j) + if key in seen: + continue + seen.add(key) + + data = _serialize_joint(j) + if data is not None: + result.append(data) + + return result + + +# ============================================================================= +# Export As-Built Joints +# ============================================================================= + +def _serialize_as_built_joint(j): + """Serializza un singolo AsBuiltJoint. None se fallisce.""" + try: + occ_one = _safe_get(j, 'occurrenceOne') + occ_two = _safe_get(j, 'occurrenceTwo') + geom = _safe_get(j, 'geometry') + + motion_info = _extract_motion_info(j) + + return { + 'name': _safe_get(j, 'name'), + 'type': joint_type_to_string(j), + 'parent': _occurrence_name(occ_one), + 'child': _occurrence_name(occ_two), + 'parentFullPath': _safe_get(occ_one, 'fullPathName'), + 'childFullPath': _safe_get(occ_two, 'fullPathName'), + 'origin': _joint_geometry_point(geom), + 'axis': motion_info['axis'], + 'secondaryAxis': motion_info['secondaryAxis'], + 'rotationLimits': motion_info['rotationLimits'], + 'slideLimits': motion_info['slideLimits'], + 'rotationValue': motion_info['rotationValue'], + 'slideValue': motion_info['slideValue'], + 'isSuppressed': _safe_get(j, 'isSuppressed'), + 'isLightBulbOn': _safe_get(j, 'isLightBulbOn'), + 'isLocked': _safe_get(j, 'isLocked'), + } + except Exception: + return None + + +def export_as_built_joints(design, root_comp=None): + """Esporta tutti gli As-Built Joints del design (TUTTI i component).""" + if root_comp is None: + root_comp = _safe_get(design, 'rootComponent') + + result = [] + seen = set() + + for comp in _iter_all_components(design, root_comp): + ab_joints = _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(j) + if data is not None: + result.append(data) + + return result + + +# ============================================================================= +# Export Motion Links (best-effort) +# ============================================================================= + +def export_motion_links(design): + """Esporta i Motion Links del design (best-effort). + + I motion link vivono in `Component.motionLinks` del component in cui + sono stati creati (di solito il root, ma non sempre). Iteriamo tutti + i component per essere sicuri di prenderli tutti. + + Adattare se la versione API in uso espone altri campi (es. `ratio`, + `isReversed`, `currentValue`). + """ + result = [] + root_comp = _safe_get(design, 'rootComponent') + if root_comp is None: + return result + + seen = set() + + for comp in _iter_all_components(design, root_comp): + motion_links = _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 = _safe_get(ml, 'jointOne') + j2 = _safe_get(ml, 'jointTwo') + + result.append({ + 'name': _safe_get(ml, 'name'), + 'joint1': _safe_get(j1, 'name'), + 'joint2': _safe_get(j2, 'name'), + 'ratio': _safe_get(ml, 'ratio'), + 'reversed': _safe_get(ml, 'isReversed'), + 'isSuppressed': _safe_get(ml, 'isSuppressed'), + 'isLightBulbOn': _safe_get(ml, 'isLightBulbOn'), + 'currentValue': _safe_get(ml, 'currentValue'), + }) + except Exception: + continue + + return result + + +# ============================================================================= +# Export Rigid Groups +# ============================================================================= + +def export_rigid_groups(design, root_comp=None): + """Esporta tutti i Rigid Groups del design. + + Un RigidGroup vincola insieme un set di Occurrences come se fossero + saldate: muovendo una si muovono tutte. Nel JSON salviamo per ogni + gruppo la lista dei `fullPathName` delle occurrences vincolate, cosi' + in Three.js puoi implementare il vincolo lato client (es: quando muovi + una occurrence applichi la stessa trasformazione delta a tutte le altre + del gruppo). + + Itera tutti i Component (i rigid groups vivono nel component dove sono + stati creati, di solito il root ma non sempre) per essere esaustivi. + """ + if root_comp is None: + root_comp = _safe_get(design, 'rootComponent') + + result = [] + seen = set() + + for comp in _iter_all_components(design, root_comp): + groups = _safe_get(comp, 'rigidGroups') + if groups is None: + continue + try: + n = groups.count + except Exception: + n = 0 + for i in range(n): + try: + g = groups.item(i) + except Exception: + continue + + # Dedup tra component diversi. + try: + key = g.entityToken + except Exception: + key = id(g) + if key in seen: + continue + seen.add(key) + + try: + occs_col = _safe_get(g, 'occurrences') + occ_paths = [] + occ_names = [] + if occs_col is not None: + try: + m = occs_col.count + except Exception: + m = 0 + for k in range(m): + try: + o = occs_col.item(k) + occ_paths.append(_safe_get(o, 'fullPathName')) + occ_names.append(_safe_get(o, 'name')) + except Exception: + continue + + result.append({ + 'name': _safe_get(g, 'name'), + 'occurrenceNames': occ_names, + 'occurrencePaths': occ_paths, + 'isSuppressed': _safe_get(g, 'isSuppressed'), + 'isLightBulbOn': _safe_get(g, 'isLightBulbOn'), + 'includeChildren': _safe_get(g, 'includeChildren'), + }) + except Exception: + continue + + return result + + +# ============================================================================= +# Export Geometria (OBJ per componente) + hierarchy.json +# ============================================================================= + +# Caratteri ammessi nel nome file. Tutto il resto diventa underscore. +_FILENAME_SANITIZE_RE = re.compile(r'[^A-Za-z0-9._\-]+') + + +def _safe_filename(name, used_set, max_len=80): + """Genera un nome file 'safe' a partire da `name`, deduplicato. + + - Sostituisce caratteri non alfanumerici con `_`. + - Tronca a `max_len` caratteri. + - Aggiunge `_2`, `_3`, ... se il nome e' gia' presente in `used_set`. + - Aggiunge il nome scelto a `used_set` e lo ritorna. + """ + base = _FILENAME_SANITIZE_RE.sub('_', name or '').strip('_') + if not base: + base = 'unnamed' + base = base[:max_len] + + candidate = base + i = 2 + while candidate.lower() in used_set: + suffix = '_{0}'.format(i) + candidate = (base[:max_len - len(suffix)] + suffix) + i += 1 + used_set.add(candidate.lower()) + return candidate + + +def _component_has_bodies(comp): + """True se il component contiene almeno una BRepBody esportabile.""" + if comp is None: + return False + try: + bodies = comp.bRepBodies + if bodies is None: + return False + 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 + except Exception: + continue + return False + except Exception: + 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' + try: + opts = export_mgr.createOBJExportOptions(component, obj_path) + if export_mgr.execute(opts): + return (obj_path, 'obj') + except Exception: + pass + + # 2) Fallback STL (universalmente supportato per Component). + 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: + pass + if export_mgr.execute(opts): + return (stl_path, 'stl') + except Exception: + pass + + return (None, None) + + +def export_meshes_and_hierarchy(design, root_comp, out_dir): + """Esporta le mesh dei component (deduplicato) + costruisce la gerarchia. + + Workflow: + * Scorre tutte le occurrences visibili (lightBulbOn + non suppressed). + * Per ogni occurrence, se il suo Component contiene bodies e non e' + ancora stato esportato, esporta `meshes/.obj` (o .stl in + fallback). Le occurrences che condividono lo stesso Component + puntano allo stesso meshFile (instancing in Blender). + * Costruisce una lista piatta di "nodes" con id, parent, children, + transform locale 4x4 e meshFile relativo. + + Ritorna `(nodes, stats)` dove stats e' un dict con conteggi. + """ + meshes_dir = os.path.join(out_dir, 'meshes') + os.makedirs(meshes_dir, exist_ok=True) + + em = design.exportManager + nodes = [] + # comp.id -> path relativo del meshFile gia' esportato (per dedup). + component_to_meshfile = {} + used_filenames = set() + stats = {'exported_meshes': 0, 'failed_meshes': 0, 'skipped_invisible': 0} + + def _maybe_export_component_mesh(comp): + """Esporta la mesh del component se non gia' fatto. Ritorna path o None.""" + if comp is None: + return None + try: + cid = comp.id + except Exception: + cid = id(comp) + if cid in component_to_meshfile: + return component_to_meshfile[cid] + if not _component_has_bodies(comp): + component_to_meshfile[cid] = None + return None + + name = _safe_get(comp, 'name') or 'component' + safe = _safe_filename(name, used_filenames) + filepath_noext = os.path.join(meshes_dir, safe) + actual_path, fmt = _export_component_mesh(em, comp, filepath_noext) + if actual_path is None: + stats['failed_meshes'] += 1 + component_to_meshfile[cid] = None + return None + stats['exported_meshes'] += 1 + # Path relativo POSIX (per portabilita' tra OS). + rel = 'meshes/' + os.path.basename(actual_path) + component_to_meshfile[cid] = rel + return rel + + def _is_visible_and_active(occ): + try: + if occ.isSuppressed: + return False + except Exception: + pass + try: + if occ.isLightBulbOn is False: + return False + except Exception: + pass + # Filtro per nome (viti, dadi, rondelle, ecc. configurati in cima + # al file). Controlliamo sia il nome dell'occurrence sia quello + # del component. + try: + occ_name = _safe_get(occ, 'name') + comp_name = _safe_get(_safe_get(occ, 'component'), 'name') + if _is_name_excluded(occ_name, comp_name): + stats['skipped_excluded'] = stats.get('skipped_excluded', 0) + 1 + return False + except Exception: + pass + return True + + # Nodo root (rappresenta il root component). + root_id = '__ROOT__' + root_node = { + 'id': root_id, + 'name': _safe_get(root_comp, 'name', 'root') or 'root', + 'fullPathName': '', + 'parentFullPathName': None, + 'componentName': _safe_get(root_comp, 'name'), + 'children': [], # popolato sotto + 'meshFile': _maybe_export_component_mesh(root_comp), + 'transform': matrix_to_list(adsk.core.Matrix3D.create()), + 'color': _extract_component_color(root_comp), + } + nodes.append(root_node) + + def _walk(occ, parent_full_path): + if not _is_visible_and_active(occ): + stats['skipped_invisible'] += 1 + return + + full_path = _safe_get(occ, 'fullPathName') or _safe_get(occ, 'name') + comp = _safe_get(occ, 'component') + + mesh_file = _maybe_export_component_mesh(comp) + + # Children visibili (per popolare children[]). + children_paths = [] + children_col = _safe_get(occ, 'childOccurrences') + if children_col is not None: + try: + n = children_col.count + except Exception: + n = 0 + for i in range(n): + try: + c = children_col.item(i) + if _is_visible_and_active(c): + cp = _safe_get(c, 'fullPathName') or _safe_get(c, 'name') + if cp: + children_paths.append(cp) + except Exception: + continue + + # Colore + nomi material/appearance del body dominante. + color, mat_name, app_name = _extract_color_and_names_for_occurrence(occ) + + nodes.append({ + 'id': full_path, + 'name': _safe_get(occ, 'name'), + 'fullPathName': full_path, + 'parentFullPathName': parent_full_path, + 'componentName': _safe_get(comp, 'name'), + 'children': children_paths, + 'meshFile': mesh_file, + # Transform LOCALE (rispetto al parent), in cm (unita' interna Fusion). + 'transform': matrix_to_list(_safe_get(occ, 'transform2')) + or matrix_to_list(_safe_get(occ, 'transform')), + 'color': color, + 'materialName': mat_name, + 'appearanceName': app_name, + }) + + # Ricorsione. + if children_col is not None: + try: + n = children_col.count + except Exception: + n = 0 + for i in range(n): + try: + _walk(children_col.item(i), full_path) + except Exception: + continue + + # Children del root: passiamo `root_id` come parent path in modo che + # lo script di import (Blender) trovi `id_to_empty[root_id]` e li + # parenti correttamente al nodo root. + root_occs = _safe_get(root_comp, 'occurrences') + if root_occs is not None: + try: + n = root_occs.count + except Exception: + n = 0 + for i in range(n): + try: + c = root_occs.item(i) + if _is_visible_and_active(c): + cp = _safe_get(c, 'fullPathName') or _safe_get(c, 'name') + if cp: + root_node['children'].append(cp) + except Exception: + continue + for i in range(n): + try: + _walk(root_occs.item(i), root_id) + except Exception: + continue + + return nodes, stats + + +# ============================================================================= +# Salvataggio file (FolderDialog) +# ============================================================================= + +def _ask_destination_folder(ui, default_dir=None): + """Apre un FolderDialog e ritorna la cartella scelta (None se annullato).""" + dialog = ui.createFolderDialog() + dialog.title = 'Scegli la cartella di destinazione export Fusion' + 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 dello script Fusion +# ============================================================================= + +def run(context): + """Entry point richiamato da Fusion 360 quando si esegue lo script. + + L'utente sceglie una cartella "parent" nel FolderDialog. Lo script + crea SEMPRE una sottocartella `export/` al suo interno con la + seguente struttura: + + / + export/ + meshes/*.obj (o .stl in fallback) + 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 = _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 + + # Chiediamo la cartella PARENT di destinazione. + parent_dir = _ask_destination_folder(ui) + if not parent_dir: + return # utente ha annullato + + # Creiamo SEMPRE una sottocartella `export/` per non sporcare la + # cartella scelta dall'utente e per essere coerenti con quanto + # si aspetta lo script Blender (build_glb.bat default = export/). + # Se l'utente ha gia' selezionato direttamente una cartella chiamata + # "export", non aggiungiamo un secondo livello. + 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) + + # --- 1) Mesh + hierarchy.json --------------------------------------- + nodes, mesh_stats = export_meshes_and_hierarchy(design, root_comp, out_dir) + + hierarchy_payload = { + 'metadata': { + 'documentName': doc_name, + 'units': units, + 'internalUnit': 'cm', # Fusion lavora internamente in cm + 'scaleFactor': 0.01, # cm -> m per Blender/Three.js + 'exportedAt': datetime.utcnow().isoformat() + 'Z', + 'apiVersion': api_version, + 'generator': 'ExportKinematicGraph.py', + }, + 'nodes': nodes, + } + + hierarchy_path = os.path.join(out_dir, 'hierarchy.json') + with open(hierarchy_path, 'w', encoding='utf-8') as f: + json.dump(hierarchy_payload, f, indent=2, ensure_ascii=False, default=str) + + # --- 2) joints.json -------------------------------------------------- + joints_payload = { + 'metadata': { + 'documentName': doc_name, + 'units': units, + 'internalUnit': 'cm', + 'scaleFactor': 0.01, + 'exportedAt': datetime.utcnow().isoformat() + 'Z', + 'apiVersion': api_version, + 'generator': 'ExportKinematicGraph.py', + }, + 'joints': export_joints(design, root_comp), + 'asBuiltJoints': export_as_built_joints(design, root_comp), + 'motionLinks': export_motion_links(design), + 'rigidGroups': export_rigid_groups(design, root_comp), + } + + joints_path = os.path.join(out_dir, 'joints.json') + with open(joints_path, 'w', encoding='utf-8') as f: + json.dump(joints_payload, f, indent=2, ensure_ascii=False, default=str) + + if ui: + ui.messageBox( + 'Export completato.\n\n' + 'Cartella: {0}\n\n' + 'Nodi gerarchia: {1}\n' + 'Mesh esportate: {2}\n' + 'Mesh fallite: {3}\n' + 'Occurrences invisibili/suppresse: {4}\n' + 'Occurrences escluse da filtro nome: {5}\n\n' + 'Joints: {6}\n' + 'As-Built Joints: {7}\n' + 'Motion Links: {8}\n' + 'Rigid Groups: {9}'.format( + out_dir, + len(nodes), + mesh_stats['exported_meshes'], + mesh_stats['failed_meshes'], + mesh_stats['skipped_invisible'], + mesh_stats.get('skipped_excluded', 0), + len(joints_payload['joints']), + len(joints_payload['asBuiltJoints']), + len(joints_payload['motionLinks']), + len(joints_payload['rigidGroups']), + ) + ) + + except Exception: + if ui: + ui.messageBox( + 'Errore durante l\'export:\n\n' + traceback.format_exc() + ) diff --git a/FUSION_GLB_CONTRACT.md b/FUSION_GLB_CONTRACT.md new file mode 100644 index 0000000..d93c0a7 --- /dev/null +++ b/FUSION_GLB_CONTRACT.md @@ -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 → "" (se non c'è match esatto) +[FusionRig] driver "Y" (Motore asse Y) NON trovato. (se serve sistemare i nomi) +[FusionRig] child non risolto per "" (se userData.fusion_name è sbagliato) +[FusionRig] X (Motore asse X) { axis, origin, child, rigidComponent: [...] } +``` + +Se l'agent GLB chiede "perché Y non si muove", la console contiene la risposta in 3 righe. + +--- + +## 8. Cose da chiarire / problemi aperti + +> Aggiornare questa sezione man mano. Mantenere un punto per problema, marcando `[OPEN] / [RESOLVED YYYY-MM-DD]`. + +- [OPEN] I motion-link `joint1: null` di Fusion vanno risolti per nome (es. `Scorrimento 1`, `Rivoluzione 9`)? Oppure l'agent Fusion può ri-esportarli con i nomi corretti? +- [OPEN] Confermare che `Motore A` esista davvero in `asBuiltJoints` con quel nome esatto (problemi di matching nel viewer suggeriscono possibili varianti). +- [OPEN] Definire una convenzione per nuove "macchine" (oltre al plotter): file `.glb` + `.joints.json` + variant di `DRIVERS` nel viewer. + +--- + +## 9. Come "centralizzare" la collaborazione fra i due agent + +Tre opzioni in ordine crescente di automazione: + +1. **Repo condiviso + `AGENTS.md` / `copilot-instructions.md`** (consigliato, zero infra) + - Mettere questo file in un repo Git unico (sotto `docs/`) o in un repo dedicato `plotter-contract/` che entrambi i progetti includono come submodule o tramite `git read-tree`. + - In ogni repo aggiungere `AGENTS.md` con: *"Prima di toccare l'export GLB/JSON o il viewer, leggere `docs/FUSION_GLB_CONTRACT.md`. Le modifiche al contratto vanno proposte in PR su quel file."* + - VS Code Copilot Chat carica automaticamente `AGENTS.md` come instructions. + +2. **Issue tracker condiviso (GitHub Projects / linear)** + - Un solo board con label `contract` per task che attraversano i due agent. + - Ogni issue cross-side referenzia la riga di `FUSION_GLB_CONTRACT.md` rilevante. + +3. **MCP server condiviso** (massima automazione, più infra) + - Esporre il contratto come tool MCP (`fusion_contract.get_joint("Motore asse X")` → restituisce nome canonico, child atteso, axis, fuzzy tokens). + - Entrambi gli agent si connettono allo stesso server MCP e leggono il contratto on-demand. + - Vantaggio: cambia il contratto in un posto, entrambi gli agent ricevono subito la versione nuova. + - Costo: scrivere e mantenere un piccolo server MCP (Python/Node ~150 righe). + +**Raccomandazione:** partire dall'opzione 1 (questo file + `AGENTS.md` in entrambi i repo). Passare a 3 solo se le iterazioni diventano frequenti e i naming change rompono spesso il viewer. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e19780c --- /dev/null +++ b/README.md @@ -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). diff --git a/THREEJS_USAGE.md b/THREEJS_USAGE.md new file mode 100644 index 0000000..5c61095 --- /dev/null +++ b/THREEJS_USAGE.md @@ -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)`. diff --git a/build_glb.bat b/build_glb.bat new file mode 100644 index 0000000..2da7a6a --- /dev/null +++ b/build_glb.bat @@ -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 diff --git a/build_glb_from_fusion_export.py b/build_glb_from_fusion_export.py new file mode 100644 index 0000000..034f7d5 --- /dev/null +++ b/build_glb_from_fusion_export.py @@ -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 ' + '-- ' + ) + 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()