diff --git a/BRIDGE_NOTES.md b/BRIDGE_NOTES.md index 7295320..cea994f 100644 --- a/BRIDGE_NOTES.md +++ b/BRIDGE_NOTES.md @@ -570,3 +570,61 @@ Quando hai applicato i fix nel nuovo script ATL, aggiungi qui sotto una sezione Io rifaccio il giro su `/atl` e marco gli esiti. Grazie 🙏 + + +--- + +## Risposta agent Fusion del 2026-06-18 - secondo script ExportKinematicGraph_ATL + +Confermato: nuovo file [ExportKinematicGraph_ATL.py](ExportKinematicGraph_ATL.py) standalone come add-in Fusion separato. Lo script plotter NON viene toccato. Implementazione tramite import del modulo plotter (riusa colore/mesh/hierarchy/walk component) + override puntuali sui joint. + +### Fix implementati nel JSON ATL + +| # | Punto | Stato | Implementazione | +|---|---|---|---| +| 1 | Log diagnostico contatori joint | OK | `[ATL-export] joints: N, asBuiltJoints: M, motionLinks: K` su stdout + `export_atl.log` accanto al `joints.json` | +| 2 | Nomi joint duplicati | OK | `_disambiguate_names()`: prima occorrenza tiene il nome, dalla seconda in poi `#2`, `#3`... Il nome originale e' conservato in `_originalName`. Applicato sia a `joints` che ad `asBuiltJoints`. Conteggio rinominati riportato nel `metadata.fixes.duplicateNames` | +| 3 | `origin` mancante sui revolute AsBuilt | OK (best-effort) | Pipeline a 3 stadi (`_origin_for_as_built`): 1) `joint.geometry.origin`, 2) centro di `joint.entityOne` (BRepFace cilindrica -> `surface.origin`, BRepEdge circolare -> `curve.center`), 3) idem su `entityTwo`. Campo `_originSource` indica la provenienza | +| 4 | Joint orfani (parent + child null) | OK | Flag esplicito `_orphan: true` sui record con entrambi null. NON vengono scartati: rimangono nel JSON per ispezione ma il viewer puo' filtrarli su `_orphan` | +| 5 | Rumore numerico ~1e-16 sugli axis | OK | `_snap_axis()` con epsilon `1e-9`: snap a `0` per moduli sotto soglia, snap a `+/-1` per valori molto vicini. Applicato a `axis` e `secondaryAxis` | +| 6 | Limits `0..0` con flag enable false | OK | `_normalize_limits()`: se `isMinimumValueEnabled` AND `isMaximumValueEnabled` sono entrambi `false` -> `rotationLimits`/`slideLimits` = `null`. Mantiene il dict se almeno uno e' attivo (es. solo upper bound) | +| 7 | MotionLink con `joint1` + `joint2` null | OK (limite API noto) | Aggiunti `joint1Token` e `joint2Token` (hex string da `entityToken`) come fallback. Quando `jointOne`/`jointTwo` API ritornano `None`, tentiamo `entityOne`/`entityTwo` del motion link. Il viewer ora ha qualcosa con cui matchare anche quando i nomi sono persi | +| 8 | Material fibra di carbonio | gia' presente | `materialName` e `appearanceName` per nodo sono gia' emessi dallo script plotter (riga `_extract_color_and_names_for_occurrence`). Il viewer puo' fare mapping nominale su `materialName == 'Carbon Fiber'` | +| 9 | Body-per-body attivo | OK | Lo script ATL riusa `base.export_meshes_and_hierarchy()` -> stesso fix del 2026-06-10. Logga `[ATL-export] body-per-body mode: ON` in cima | +| 10 | Sanitizzazione nomi Blender | OK | Aggiunto `glbNodeName` per ogni nodo di `hierarchy.json`: uguale a `name` se <= 63 char, troncato a 63 altrimenti (limite `bpy.types.ID.name`). Campo `metadata.blenderNameMax: 63` per esplicitare la convenzione | + +Aggiunto inoltre il blocco `metadata.fixes` in `joints.json` con un sommario dei contatori (rinominazioni, snap epsilon, origin recuperati, ecc.) per facilitare l'ispezione lato viewer. + +### Note di implementazione + +- L'import del modulo plotter cerca `ExportKinematicGraph.py` in tre posizioni (cartella ATL, cartella sorella `ExportKinematicGraph/`, hard-coded repo). Se non lo trova, lo script abortisce con `ImportError`: niente fallback silenzioso che mascheri il problema. +- `importlib.reload(base)` forzato a ogni esecuzione: cosi' se modifichi il plotter durante una sessione Fusion attiva, le modifiche vengono raccolte senza riavviare. +- I joint orfani NON vengono scartati: solo flaggati. Decisione operativa: meglio averli visibili nel JSON (anche per capire perche' sono orfani) che farli sparire silenziosamente. + +### Installazione lato utente + +Lo script va installato come **secondo add-in** in Fusion: + +1. `Utilities` -> `ADD-INS` -> `Scripts` -> `Green +` -> `Create from existing script` +2. Selezionare `C:\Users\croce\OneDrive\Desktop\export grafo fusion\ExportKinematicGraph_ATL.py` +3. (Gia' fatto da me) Sincronizzato in `%APPDATA%\Autodesk\Autodesk Fusion 360\API\Scripts\ExportKinematicGraph_ATL\` + +### Cosa serve da te (viewer) + +1. Aprire il modello ATL in Fusion, lanciare lo script `ExportKinematicGraph_ATL`, cartella di destinazione `C:\Users\croce\OneDrive\Desktop\export_ATL\`. +2. Lanciare il bat Blender sulla nuova cartella (genera `ATL.glb` in posa neutra). +3. `scp` ai soliti path sul server (`/home/marco/automation_kriz/ATL/`). +4. Verifica su `/atl`: i record duplicati ora hanno nomi univoci, i revolute AsBuilt dovrebbero animarsi correttamente attorno al loro perno (vedi `_originSource` per capire da dove arriva l'origin), i motion link rotti vanno ispezionati su `joint1Token`/`joint2Token` per il matching manuale. + +### Cosa rimane fuori dall'orizzonte dello script + +- `joints: []` vuoto e tutti i motion link con `joint1==joint2==null`: sono dati del modello Fusion (asbuilt-only e rename post-link). Lo script non puo' inventarseli. Da chiarire con Croce se ha creato solo As-built Joint nel modello ATL. +- Material PBR avanzato (fibra di carbonio con normal map + anisotropy): lo script Fusion non puo' generarlo, e' un layer viewer (mapping `materialName` -> material custom). + +### Verifica preliminare lato script + +- `py_compile` OK +- Sync verso `%APPDATA%\...\ExportKinematicGraph_ATL\` OK +- Test runtime: serve Fusion attivo, lo lancia Croce. + +Quando hai il nuovo export, aggiorna qui sotto con "Verifica export ATL del 2026-06-XX": contatori di `metadata.fixes`, lista degli `_orphan` (se ce ne sono), e quali asBuilt revolute hanno `_originSource == null` (cioe' niente origin neanche col fallback). diff --git a/ExportKinematicGraph_ATL.py b/ExportKinematicGraph_ATL.py new file mode 100644 index 0000000..7f76164 --- /dev/null +++ b/ExportKinematicGraph_ATL.py @@ -0,0 +1,675 @@ +# -*- coding: utf-8 -*- +""" +ExportKinematicGraph_ATL.py +--------------------------- +Variante "ATL" dell'add-in Fusion 360 che esporta hierarchy + joints per +il viewer Three.js. + +Perche' esiste un secondo script? + Il modello ATL di Olimpic Sail ha caratteristiche che hanno fatto + emergere alcune lacune nell'export del plotter (vedi BRIDGE_NOTES + sezione "Findings dal viewer ATL (2026-06-18)"): + * nomi joint duplicati, + * `origin` mancante sugli AsBuiltJoint revolute, + * jitter numerico negli assi, + * limits 0..0 ambigui quando i flag enable sono false, + * motionLinks con joint1/joint2 None (limite API), + * nodi orfani con parent/child None. + L'agente viewer ha chiesto esplicitamente di NON toccare + `ExportKinematicGraph.py` (che e' stabile per il plotter) e di + affiancare un secondo script dedicato all'ATL. + +Strategia di implementazione: + Importa il modulo `ExportKinematicGraph` come `base` (helper colore, + mesh, hierarchy, walk component sono identici). Sovrascrive solo: + * estrazione joint / asBuiltJoint / motionLink con fix mirati, + * deduplica nomi (`Rivoluzione 5` -> `Rivoluzione 5`, `Rivoluzione 5#2`, ...), + * snap del rumore numerico sull'axis, + * normalizzazione limits a None quando i flag sono entrambi disabilitati, + * origin fallback per revolute AsBuilt da entityOne/entityTwo, + * scarto / flag esplicito per joint orfani, + * emissione di `entityToken` (hex) accanto al `name` per fare matching anche con nomi duplicati o link rotti. + +Output: + / + export/ + meshes/*.obj + hierarchy.json + joints.json + `joints.json` segna `generator: 'ExportKinematicGraph_ATL.py'`. + +Come usare in Fusion: + Utilities -> ADD-INS -> Scripts -> Green "+" -> "Create from existing + script" e selezionare questo file. Eseguire. Cartella di destinazione + consigliata: `C:\\Users\\croce\\OneDrive\\Desktop\\export_ATL\\`. +""" + +import adsk.core +import adsk.fusion +import json +import os +import sys +import traceback +from datetime import datetime + + +# ============================================================================= +# Caricamento modulo base (ExportKinematicGraph plotter) +# ============================================================================= +# +# Lo script ATL riusa gli helper del plotter via import. Cerco il modulo +# in: +# 1) cartella dello script ATL (caso "sviluppo nel repo, entrambi i +# file affiancati in `export grafo fusion/`") +# 2) cartella sorella `..\ExportKinematicGraph\` (caso "installato come +# due add-in separati in %APPDATA%\Autodesk\.../API/Scripts/") +# 3) percorso fisso del repo (fallback hard-coded) +# +# Se il modulo non si trova, lo script abortisce con un messaggio chiaro. + +def _locate_base_module(): + here = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + here, + os.path.normpath(os.path.join(here, '..', 'ExportKinematicGraph')), + r'C:\Users\croce\OneDrive\Desktop\export grafo fusion', + ] + for path in candidates: + if os.path.isfile(os.path.join(path, 'ExportKinematicGraph.py')): + if path not in sys.path: + sys.path.insert(0, path) + return path + return None + + +_BASE_PATH = _locate_base_module() +if _BASE_PATH is None: + raise ImportError( + "Impossibile trovare ExportKinematicGraph.py. Cercato in: " + "cartella script, ..\\ExportKinematicGraph\\, repo " + "'export grafo fusion'." + ) + +# Forziamo reload se il base e' gia' stato importato in una sessione +# Fusion precedente, cosi' eventuali modifiche al plotter sono raccolte. +import importlib +import ExportKinematicGraph as base # noqa: E402 +importlib.reload(base) + + +# ============================================================================= +# Logging diagnostico (stdout + UI palette) +# ============================================================================= + +_LOG_LINES = [] + + +def _log(msg): + line = '[ATL-export] ' + str(msg) + _LOG_LINES.append(line) + try: + # Fusion redirige stdout alla TextCommandPalette in alcune build. + print(line) + except Exception: + pass + + +# ============================================================================= +# Utility numeriche (snap, token) +# ============================================================================= + +_EPS_AXIS = 1e-9 # snap a zero sotto questa soglia +_EPS_UNIT = 1e-9 # snap a +/-1 se molto vicino + + +def _snap_axis(axis): + """Snap del rumore numerico in un vettore axis. + + - componenti < EPS_AXIS in modulo -> 0.0 + - componenti molto vicine a +/-1 -> +/-1.0 + Ritorna None se l'input e' None o vuoto. + """ + if not axis: + return axis + out = [] + for v in axis: + try: + fv = float(v) + except Exception: + out.append(v) + continue + if abs(fv) < _EPS_AXIS: + fv = 0.0 + elif abs(fv - 1.0) < _EPS_UNIT: + fv = 1.0 + elif abs(fv + 1.0) < _EPS_UNIT: + fv = -1.0 + out.append(fv) + return out + + +def _token_of(entity): + """Ritorna `entity.entityToken` se disponibile, altrimenti None. + + Il token e' una stringa opaca univoca per entita' Fusion: utile come + matching key quando i `name` sono duplicati o quando i motion link + perdono il riferimento al `name` del joint. + """ + if entity is None: + return None + try: + tok = getattr(entity, 'entityToken', None) + if tok: + return str(tok) + except Exception: + pass + return None + + +# ============================================================================= +# Limits: normalizzazione +# ============================================================================= + +def _normalize_limits(limits_dict): + """Se entrambi i flag min/max enabled sono False, ritorna None. + + Cosi' il consumer interpreta correttamente "joint libero, no limiti" + invece di un range 0..0 (che il viewer attualmente filtra via, + facendo sparire lo slider). + + Mantiene il dict originale se almeno uno dei due limiti e' attivo, + cosi' `isMinimumValueEnabled=True, isMaximumValueEnabled=False` resta + valido (es: solo lower bound). + """ + if limits_dict is None: + return None + enabled_min = bool(limits_dict.get('isMinimumValueEnabled')) + enabled_max = bool(limits_dict.get('isMaximumValueEnabled')) + if not enabled_min and not enabled_max: + return None + return limits_dict + + +# ============================================================================= +# Origin per AsBuiltJoint: fallback su entityOne/entityTwo +# ============================================================================= + +def _point3d_to_list(pt): + """Wrap di point_to_list che accetta gia' liste / tuple.""" + if pt is None: + return None + if isinstance(pt, (list, tuple)): + try: + return [float(pt[0]), float(pt[1]), float(pt[2])] + except Exception: + return None + return base.point_to_list(pt) + + +def _origin_from_entity(entity): + """Estrae il centro geometrico da una BRepFace cilindrica o BRepEdge + circolare. Best-effort, tutto protetto da try/except: l'API espone + `Surface.origin` per cilindri/coni e `Curve3D.center` per cerchi/archi. + """ + if entity is None: + return None + # BRepFace.geometry -> Surface (Cylinder, Cone, Sphere, Torus, Plane, ...) + try: + geom = getattr(entity, 'geometry', None) + if geom is not None: + # Cylinder/Cone/Torus/Sphere espongono `origin`. + origin = getattr(geom, 'origin', None) + if origin is not None: + return base.point_to_list(origin) + # Circle3D/Ellipse3D/Arc3D espongono `center`. + center = getattr(geom, 'center', None) + if center is not None: + return base.point_to_list(center) + except Exception: + pass + # BRepVertex + try: + pt = getattr(entity, 'geometry', None) + if pt is not None and hasattr(pt, 'x'): + return base.point_to_list(pt) + except Exception: + pass + return None + + +def _origin_for_as_built(joint): + """Pipeline a tre stadi per ricavare l'origine di un AsBuiltJoint. + + 1) `joint.geometry.origin` (raro per AsBuilt, ma proviamo). + 2) Centro geometrico di `joint.entityOne` (di solito una BRepFace + cilindrica per i revolute, o un BRepEdge circolare). + 3) Centro di `joint.entityTwo` come last resort. + + Ritorna `(origin_list, source)` dove `source` e': + 'geometry' | 'entityOne' | 'entityTwo' | None + Cosi' loggo la provenienza nelle stats. + """ + pt = base._joint_geometry_point(getattr(joint, 'geometry', None)) + if pt: + return pt, 'geometry' + for ent_attr, label in (('entityOne', 'entityOne'), ('entityTwo', 'entityTwo')): + ent = getattr(joint, ent_attr, None) + pt = _origin_from_entity(ent) + if pt: + return pt, label + return None, None + + +# ============================================================================= +# Disambiguazione nomi (fix #2) +# ============================================================================= + +def _disambiguate_names(records, name_key='name', dup_marker='#'): + """Modifica in-place la lista `records` rendendo univoci i `name`. + + Strategia: scan in ordine. La prima occorrenza tiene il name originale, + dalla seconda in poi viene appeso `#2`, `#3`, ... Conta i collision + per loggarli. + + Ritorna il numero di rinominazioni effettuate. + """ + counts = {} + renamed = 0 + for rec in records: + if rec is None: + continue + orig = rec.get(name_key) + if not orig: + continue + counts[orig] = counts.get(orig, 0) + 1 + if counts[orig] == 1: + continue + new = '{0}{1}{2}'.format(orig, dup_marker, counts[orig]) + rec['_originalName'] = orig + rec[name_key] = new + renamed += 1 + return renamed + + +# ============================================================================= +# Serializzazione joint con fix ATL +# ============================================================================= + +def _enrich_axis(motion_info): + """Applica snap a `axis` e `secondaryAxis` in-place.""" + motion_info['axis'] = _snap_axis(motion_info.get('axis')) + motion_info['secondaryAxis'] = _snap_axis(motion_info.get('secondaryAxis')) + return motion_info + + +def _serialize_joint_atl(j): + """Variante di base._serialize_joint con: token, axis snappato, + limits normalizzati.""" + try: + data = base._serialize_joint(j) + if data is None: + return None + # Snap axis. + data['axis'] = _snap_axis(data.get('axis')) + data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis')) + # Normalize limits. + data['rotationLimits'] = _normalize_limits(data.get('rotationLimits')) + data['slideLimits'] = _normalize_limits(data.get('slideLimits')) + # Token del joint (sopravvive a rename). + data['_token'] = _token_of(j) + # Flag orfano (parent + child entrambi None). + if data.get('parent') is None and data.get('child') is None: + data['_orphan'] = True + return data + except Exception: + return None + + +def _serialize_as_built_joint_atl(j): + """Variante di base._serialize_as_built_joint con: token, axis + snappato, limits normalizzati, origin con fallback su entity.""" + try: + data = base._serialize_as_built_joint(j) + if data is None: + return None + # Snap axis. + data['axis'] = _snap_axis(data.get('axis')) + data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis')) + # Normalize limits. + data['rotationLimits'] = _normalize_limits(data.get('rotationLimits')) + data['slideLimits'] = _normalize_limits(data.get('slideLimits')) + # Origin con fallback (fondamentale per i revolute). + if data.get('origin') is None: + origin, source = _origin_for_as_built(j) + if origin is not None: + data['origin'] = origin + data['_originSource'] = source + # Token del joint. + data['_token'] = _token_of(j) + # Flag orfano. + if data.get('parent') is None and data.get('child') is None: + data['_orphan'] = True + return data + except Exception: + return None + + +def _export_joints_atl(design, root_comp): + """Come base.export_joints ma con _serialize_joint_atl.""" + result = [] + seen = set() + for comp in base._iter_all_components(design, root_comp): + joints = base._safe_get(comp, 'joints') + if joints is None: + continue + try: + n = joints.count + except Exception: + n = 0 + for i in range(n): + try: + j = joints.item(i) + except Exception: + continue + try: + key = j.entityToken + except Exception: + key = id(j) + if key in seen: + continue + seen.add(key) + data = _serialize_joint_atl(j) + if data is not None: + result.append(data) + return result + + +def _export_as_built_joints_atl(design, root_comp): + """Come base.export_as_built_joints ma con _serialize_as_built_joint_atl.""" + result = [] + seen = set() + for comp in base._iter_all_components(design, root_comp): + ab_joints = base._safe_get(comp, 'asBuiltJoints') + if ab_joints is None: + continue + try: + n = ab_joints.count + except Exception: + n = 0 + for i in range(n): + try: + j = ab_joints.item(i) + except Exception: + continue + try: + key = j.entityToken + except Exception: + key = id(j) + if key in seen: + continue + seen.add(key) + data = _serialize_as_built_joint_atl(j) + if data is not None: + result.append(data) + return result + + +def _export_motion_links_atl(design): + """Come base.export_motion_links ma emette anche i token delle + entita' originali per il matching manuale quando joint1/joint2 + sono None (limite API noto). + """ + result = [] + root_comp = base._safe_get(design, 'rootComponent') + if root_comp is None: + return result + seen = set() + for comp in base._iter_all_components(design, root_comp): + motion_links = base._safe_get(comp, 'motionLinks') + if motion_links is None: + continue + try: + n = motion_links.count + except Exception: + n = 0 + for i in range(n): + try: + ml = motion_links.item(i) + except Exception: + continue + try: + key = ml.entityToken + except Exception: + key = id(ml) + if key in seen: + continue + seen.add(key) + try: + j1 = base._safe_get(ml, 'jointOne') + j2 = base._safe_get(ml, 'jointTwo') + # Anche entityOne/entityTwo (proxy interno) possono dare + # un token utile quando il name e' perso. + e1 = base._safe_get(ml, 'entityOne') + e2 = base._safe_get(ml, 'entityTwo') + result.append({ + 'name': base._safe_get(ml, 'name'), + 'joint1': base._safe_get(j1, 'name'), + 'joint2': base._safe_get(j2, 'name'), + 'joint1Token': _token_of(j1) or _token_of(e1), + 'joint2Token': _token_of(j2) or _token_of(e2), + 'ratio': base._safe_get(ml, 'ratio'), + 'reversed': base._safe_get(ml, 'isReversed'), + 'isSuppressed': base._safe_get(ml, 'isSuppressed'), + 'isLightBulbOn': base._safe_get(ml, 'isLightBulbOn'), + 'currentValue': base._safe_get(ml, 'currentValue'), + }) + except Exception: + continue + return result + + +# ============================================================================= +# Enrich hierarchy: glbNodeName + log +# ============================================================================= + +# Blender 4.x tronca i nomi degli Object a 63 caratteri (limite Python +# bpy.types.ID.name). Qui emettiamo `glbNodeName` previsto = name[:63] +# cosi' il consumer puo' fare lookup senza indovinare. +_BLENDER_NAME_MAX = 63 + + +def _annotate_hierarchy(nodes): + """Aggiunge `glbNodeName` a ogni nodo (fix #10).""" + for n in nodes: + nm = n.get('name') + if not nm: + continue + n['glbNodeName'] = nm if len(nm) <= _BLENDER_NAME_MAX else nm[:_BLENDER_NAME_MAX] + + +# ============================================================================= +# Folder dialog +# ============================================================================= + +def _ask_destination_folder(ui, default_dir=None): + dialog = ui.createFolderDialog() + dialog.title = 'Scegli la cartella di destinazione export ATL' + if default_dir: + try: + dialog.initialDirectory = default_dir + except Exception: + pass + if dialog.showDialog() != adsk.core.DialogResults.DialogOK: + return None + return dialog.folder + + +# ============================================================================= +# Entry point +# ============================================================================= + +def run(context): + """Entry point richiamato da Fusion 360. + + L'utente sceglie una cartella PARENT. Lo script crea (se serve) la + sottocartella `export/` al suo interno e ci scrive: + meshes/*.obj + hierarchy.json + joints.json + """ + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + + product = app.activeProduct + if not isinstance(product, adsk.fusion.Design): + if ui: + ui.messageBox('Nessun design Fusion attivo.') + return + design = product + + root_comp = design.rootComponent + doc_name = base._safe_get(app.activeDocument, 'name', 'Untitled') + + units = None + try: + units = design.unitsManager.defaultLengthUnits + except Exception: + units = None + + api_version = None + try: + api_version = adsk.core.Application.get().version + except Exception: + api_version = None + + parent_dir = _ask_destination_folder(ui) + if not parent_dir: + return + + if os.path.basename(os.path.normpath(parent_dir)).lower() == 'export': + out_dir = parent_dir + else: + out_dir = os.path.join(parent_dir, 'export') + os.makedirs(out_dir, exist_ok=True) + + _log('body-per-body mode: ON (eredita da ExportKinematicGraph)') + _log('base module path: ' + str(_BASE_PATH)) + + # --- 1) Mesh + hierarchy (riusiamo la pipeline plotter) --------------- + nodes, mesh_stats = base.export_meshes_and_hierarchy( + design, root_comp, out_dir + ) + _annotate_hierarchy(nodes) + _log('hierarchy nodes: {0}, mesh ok: {1}, mesh fail: {2}'.format( + len(nodes), mesh_stats['exported_meshes'], mesh_stats['failed_meshes'] + )) + + hierarchy_payload = { + 'metadata': { + 'documentName': doc_name, + 'units': units, + 'internalUnit': 'cm', + 'scaleFactor': 0.01, + 'exportedAt': datetime.utcnow().isoformat() + 'Z', + 'apiVersion': api_version, + 'generator': 'ExportKinematicGraph_ATL.py', + 'profile': 'ATL', + 'blenderNameMax': _BLENDER_NAME_MAX, + }, + 'nodes': nodes, + } + with open(os.path.join(out_dir, 'hierarchy.json'), 'w', encoding='utf-8') as f: + json.dump(hierarchy_payload, f, indent=2, ensure_ascii=False, + default=str) + + # --- 2) Joints con fix ATL -------------------------------------------- + joints_list = _export_joints_atl(design, root_comp) + as_built_list = _export_as_built_joints_atl(design, root_comp) + motion_links = _export_motion_links_atl(design) + rigid_groups = base.export_rigid_groups(design, root_comp) + + # Disambiguazione nomi: i joint e gli asBuilt vivono in spazi nomi + # separati per Fusion, ma il viewer matcha per nome unico globale. + # Disambiguiamo i duplicati cross-section. + joints_renamed = _disambiguate_names(joints_list) + ab_renamed = _disambiguate_names(as_built_list) + + # Conteggio orfani / origin recuperati. + orphans_joints = sum(1 for x in joints_list if x.get('_orphan')) + orphans_ab = sum(1 for x in as_built_list if x.get('_orphan')) + origin_recovered = sum(1 for x in as_built_list + if x.get('_originSource') in ('entityOne', 'entityTwo')) + revolutes_no_origin = sum(1 for x in as_built_list + if x.get('type') == 'revolute' and x.get('origin') is None) + + _log('joints: {0} (renamed {1}, orphans {2})'.format( + len(joints_list), joints_renamed, orphans_joints)) + _log('asBuiltJoints: {0} (renamed {1}, orphans {2}, origin recovered {3}, revolute senza origin {4})'.format( + len(as_built_list), ab_renamed, orphans_ab, origin_recovered, revolutes_no_origin)) + _log('motionLinks: {0}'.format(len(motion_links))) + _log('rigidGroups: {0}'.format(len(rigid_groups))) + + joints_payload = { + 'metadata': { + 'documentName': doc_name, + 'units': units, + 'internalUnit': 'cm', + 'scaleFactor': 0.01, + 'exportedAt': datetime.utcnow().isoformat() + 'Z', + 'apiVersion': api_version, + 'generator': 'ExportKinematicGraph_ATL.py', + 'profile': 'ATL', + 'fixes': { + 'duplicateNames': {'renamedJoints': joints_renamed, + 'renamedAsBuilt': ab_renamed}, + 'axisSnap': {'epsilon': _EPS_AXIS}, + 'limitsNormalized': True, + 'orphans': {'joints': orphans_joints, + 'asBuilt': orphans_ab}, + 'asBuiltOrigin': {'recovered': origin_recovered, + 'revoluteStillMissing': revolutes_no_origin}, + 'motionLinkTokens': True, + }, + }, + 'joints': joints_list, + 'asBuiltJoints': as_built_list, + 'motionLinks': motion_links, + 'rigidGroups': rigid_groups, + } + with open(os.path.join(out_dir, 'joints.json'), 'w', encoding='utf-8') as f: + json.dump(joints_payload, f, indent=2, ensure_ascii=False, + default=str) + + # Salva il log accanto per debug. + with open(os.path.join(out_dir, 'export_atl.log'), 'w', encoding='utf-8') as f: + f.write('\n'.join(_LOG_LINES) + '\n') + + if ui: + ui.messageBox( + 'Export ATL completato.\n\n' + 'Cartella: {0}\n\n' + 'Nodi gerarchia: {1}\n' + 'Mesh esportate: {2} (fallite: {3})\n\n' + 'Joints: {4} (rinominati {5}, orfani {6})\n' + 'As-Built Joints: {7} (rinominati {8}, origin recuperati {9}, revolute senza origin {10})\n' + 'Motion Links: {11}\n' + 'Rigid Groups: {12}\n\n' + 'Log dettagliato: export_atl.log'.format( + out_dir, + len(nodes), + mesh_stats['exported_meshes'], + mesh_stats['failed_meshes'], + len(joints_list), joints_renamed, orphans_joints, + len(as_built_list), ab_renamed, origin_recovered, revolutes_no_origin, + len(motion_links), + len(rigid_groups), + ) + ) + + except Exception: + if ui: + ui.messageBox( + 'Errore durante l\'export ATL:\n\n' + traceback.format_exc() + )