fusion: nuovo script ExportKinematicGraph_ATL (10 fix per export ATL)

Standalone add-in che importa ExportKinematicGraph come 'base' e ridefinisce solo le funzioni joint con: snap axis (1e-9), limits None se entrambi enable false, origin fallback su entityOne/Two per AsBuilt revolute, disambiguazione nomi duplicati (#2, #3...), flag _orphan, _token per matching, joint1Token/joint2Token sui motionLinks, glbNodeName troncato a 63 char. Log diagnostico + metadata.fixes nel JSON. Script plotter NON toccato.
This commit is contained in:
marco
2026-06-18 21:01:09 +02:00
parent 3725b486a4
commit bbdeb39ab0
2 changed files with 733 additions and 0 deletions

View File

@@ -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).

675
ExportKinematicGraph_ATL.py Normal file
View File

@@ -0,0 +1,675 @@
# -*- coding: utf-8 -*-
"""
ExportKinematicGraph_ATL.py
---------------------------
Variante "ATL" dell'add-in Fusion 360 che esporta hierarchy + joints per
il viewer Three.js.
Perche' esiste un secondo script?
Il modello ATL di Olimpic Sail ha caratteristiche che hanno fatto
emergere alcune lacune nell'export del plotter (vedi BRIDGE_NOTES
sezione "Findings dal viewer ATL (2026-06-18)"):
* nomi joint duplicati,
* `origin` mancante sugli AsBuiltJoint revolute,
* jitter numerico negli assi,
* limits 0..0 ambigui quando i flag enable sono false,
* motionLinks con joint1/joint2 None (limite API),
* nodi orfani con parent/child None.
L'agente viewer ha chiesto esplicitamente di NON toccare
`ExportKinematicGraph.py` (che e' stabile per il plotter) e di
affiancare un secondo script dedicato all'ATL.
Strategia di implementazione:
Importa il modulo `ExportKinematicGraph` come `base` (helper colore,
mesh, hierarchy, walk component sono identici). Sovrascrive solo:
* estrazione joint / asBuiltJoint / motionLink con fix mirati,
* deduplica nomi (`Rivoluzione 5` -> `Rivoluzione 5`, `Rivoluzione 5#2`, ...),
* snap del rumore numerico sull'axis,
* normalizzazione limits a None quando i flag sono entrambi disabilitati,
* origin fallback per revolute AsBuilt da entityOne/entityTwo,
* scarto / flag esplicito per joint orfani,
* emissione di `entityToken` (hex) accanto al `name` per fare matching anche con nomi duplicati o link rotti.
Output:
<cartella scelta>/
export/
meshes/*.obj
hierarchy.json
joints.json
`joints.json` segna `generator: 'ExportKinematicGraph_ATL.py'`.
Come usare in Fusion:
Utilities -> ADD-INS -> Scripts -> Green "+" -> "Create from existing
script" e selezionare questo file. Eseguire. Cartella di destinazione
consigliata: `C:\\Users\\croce\\OneDrive\\Desktop\\export_ATL\\`.
"""
import adsk.core
import adsk.fusion
import json
import os
import sys
import traceback
from datetime import datetime
# =============================================================================
# Caricamento modulo base (ExportKinematicGraph plotter)
# =============================================================================
#
# Lo script ATL riusa gli helper del plotter via import. Cerco il modulo
# in:
# 1) cartella dello script ATL (caso "sviluppo nel repo, entrambi i
# file affiancati in `export grafo fusion/`")
# 2) cartella sorella `..\ExportKinematicGraph\` (caso "installato come
# due add-in separati in %APPDATA%\Autodesk\.../API/Scripts/")
# 3) percorso fisso del repo (fallback hard-coded)
#
# Se il modulo non si trova, lo script abortisce con un messaggio chiaro.
def _locate_base_module():
here = os.path.dirname(os.path.abspath(__file__))
candidates = [
here,
os.path.normpath(os.path.join(here, '..', 'ExportKinematicGraph')),
r'C:\Users\croce\OneDrive\Desktop\export grafo fusion',
]
for path in candidates:
if os.path.isfile(os.path.join(path, 'ExportKinematicGraph.py')):
if path not in sys.path:
sys.path.insert(0, path)
return path
return None
_BASE_PATH = _locate_base_module()
if _BASE_PATH is None:
raise ImportError(
"Impossibile trovare ExportKinematicGraph.py. Cercato in: "
"cartella script, ..\\ExportKinematicGraph\\, repo "
"'export grafo fusion'."
)
# Forziamo reload se il base e' gia' stato importato in una sessione
# Fusion precedente, cosi' eventuali modifiche al plotter sono raccolte.
import importlib
import ExportKinematicGraph as base # noqa: E402
importlib.reload(base)
# =============================================================================
# Logging diagnostico (stdout + UI palette)
# =============================================================================
_LOG_LINES = []
def _log(msg):
line = '[ATL-export] ' + str(msg)
_LOG_LINES.append(line)
try:
# Fusion redirige stdout alla TextCommandPalette in alcune build.
print(line)
except Exception:
pass
# =============================================================================
# Utility numeriche (snap, token)
# =============================================================================
_EPS_AXIS = 1e-9 # snap a zero sotto questa soglia
_EPS_UNIT = 1e-9 # snap a +/-1 se molto vicino
def _snap_axis(axis):
"""Snap del rumore numerico in un vettore axis.
- componenti < EPS_AXIS in modulo -> 0.0
- componenti molto vicine a +/-1 -> +/-1.0
Ritorna None se l'input e' None o vuoto.
"""
if not axis:
return axis
out = []
for v in axis:
try:
fv = float(v)
except Exception:
out.append(v)
continue
if abs(fv) < _EPS_AXIS:
fv = 0.0
elif abs(fv - 1.0) < _EPS_UNIT:
fv = 1.0
elif abs(fv + 1.0) < _EPS_UNIT:
fv = -1.0
out.append(fv)
return out
def _token_of(entity):
"""Ritorna `entity.entityToken` se disponibile, altrimenti None.
Il token e' una stringa opaca univoca per entita' Fusion: utile come
matching key quando i `name` sono duplicati o quando i motion link
perdono il riferimento al `name` del joint.
"""
if entity is None:
return None
try:
tok = getattr(entity, 'entityToken', None)
if tok:
return str(tok)
except Exception:
pass
return None
# =============================================================================
# Limits: normalizzazione
# =============================================================================
def _normalize_limits(limits_dict):
"""Se entrambi i flag min/max enabled sono False, ritorna None.
Cosi' il consumer interpreta correttamente "joint libero, no limiti"
invece di un range 0..0 (che il viewer attualmente filtra via,
facendo sparire lo slider).
Mantiene il dict originale se almeno uno dei due limiti e' attivo,
cosi' `isMinimumValueEnabled=True, isMaximumValueEnabled=False` resta
valido (es: solo lower bound).
"""
if limits_dict is None:
return None
enabled_min = bool(limits_dict.get('isMinimumValueEnabled'))
enabled_max = bool(limits_dict.get('isMaximumValueEnabled'))
if not enabled_min and not enabled_max:
return None
return limits_dict
# =============================================================================
# Origin per AsBuiltJoint: fallback su entityOne/entityTwo
# =============================================================================
def _point3d_to_list(pt):
"""Wrap di point_to_list che accetta gia' liste / tuple."""
if pt is None:
return None
if isinstance(pt, (list, tuple)):
try:
return [float(pt[0]), float(pt[1]), float(pt[2])]
except Exception:
return None
return base.point_to_list(pt)
def _origin_from_entity(entity):
"""Estrae il centro geometrico da una BRepFace cilindrica o BRepEdge
circolare. Best-effort, tutto protetto da try/except: l'API espone
`Surface.origin` per cilindri/coni e `Curve3D.center` per cerchi/archi.
"""
if entity is None:
return None
# BRepFace.geometry -> Surface (Cylinder, Cone, Sphere, Torus, Plane, ...)
try:
geom = getattr(entity, 'geometry', None)
if geom is not None:
# Cylinder/Cone/Torus/Sphere espongono `origin`.
origin = getattr(geom, 'origin', None)
if origin is not None:
return base.point_to_list(origin)
# Circle3D/Ellipse3D/Arc3D espongono `center`.
center = getattr(geom, 'center', None)
if center is not None:
return base.point_to_list(center)
except Exception:
pass
# BRepVertex
try:
pt = getattr(entity, 'geometry', None)
if pt is not None and hasattr(pt, 'x'):
return base.point_to_list(pt)
except Exception:
pass
return None
def _origin_for_as_built(joint):
"""Pipeline a tre stadi per ricavare l'origine di un AsBuiltJoint.
1) `joint.geometry.origin` (raro per AsBuilt, ma proviamo).
2) Centro geometrico di `joint.entityOne` (di solito una BRepFace
cilindrica per i revolute, o un BRepEdge circolare).
3) Centro di `joint.entityTwo` come last resort.
Ritorna `(origin_list, source)` dove `source` e':
'geometry' | 'entityOne' | 'entityTwo' | None
Cosi' loggo la provenienza nelle stats.
"""
pt = base._joint_geometry_point(getattr(joint, 'geometry', None))
if pt:
return pt, 'geometry'
for ent_attr, label in (('entityOne', 'entityOne'), ('entityTwo', 'entityTwo')):
ent = getattr(joint, ent_attr, None)
pt = _origin_from_entity(ent)
if pt:
return pt, label
return None, None
# =============================================================================
# Disambiguazione nomi (fix #2)
# =============================================================================
def _disambiguate_names(records, name_key='name', dup_marker='#'):
"""Modifica in-place la lista `records` rendendo univoci i `name`.
Strategia: scan in ordine. La prima occorrenza tiene il name originale,
dalla seconda in poi viene appeso `#2`, `#3`, ... Conta i collision
per loggarli.
Ritorna il numero di rinominazioni effettuate.
"""
counts = {}
renamed = 0
for rec in records:
if rec is None:
continue
orig = rec.get(name_key)
if not orig:
continue
counts[orig] = counts.get(orig, 0) + 1
if counts[orig] == 1:
continue
new = '{0}{1}{2}'.format(orig, dup_marker, counts[orig])
rec['_originalName'] = orig
rec[name_key] = new
renamed += 1
return renamed
# =============================================================================
# Serializzazione joint con fix ATL
# =============================================================================
def _enrich_axis(motion_info):
"""Applica snap a `axis` e `secondaryAxis` in-place."""
motion_info['axis'] = _snap_axis(motion_info.get('axis'))
motion_info['secondaryAxis'] = _snap_axis(motion_info.get('secondaryAxis'))
return motion_info
def _serialize_joint_atl(j):
"""Variante di base._serialize_joint con: token, axis snappato,
limits normalizzati."""
try:
data = base._serialize_joint(j)
if data is None:
return None
# Snap axis.
data['axis'] = _snap_axis(data.get('axis'))
data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis'))
# Normalize limits.
data['rotationLimits'] = _normalize_limits(data.get('rotationLimits'))
data['slideLimits'] = _normalize_limits(data.get('slideLimits'))
# Token del joint (sopravvive a rename).
data['_token'] = _token_of(j)
# Flag orfano (parent + child entrambi None).
if data.get('parent') is None and data.get('child') is None:
data['_orphan'] = True
return data
except Exception:
return None
def _serialize_as_built_joint_atl(j):
"""Variante di base._serialize_as_built_joint con: token, axis
snappato, limits normalizzati, origin con fallback su entity."""
try:
data = base._serialize_as_built_joint(j)
if data is None:
return None
# Snap axis.
data['axis'] = _snap_axis(data.get('axis'))
data['secondaryAxis'] = _snap_axis(data.get('secondaryAxis'))
# Normalize limits.
data['rotationLimits'] = _normalize_limits(data.get('rotationLimits'))
data['slideLimits'] = _normalize_limits(data.get('slideLimits'))
# Origin con fallback (fondamentale per i revolute).
if data.get('origin') is None:
origin, source = _origin_for_as_built(j)
if origin is not None:
data['origin'] = origin
data['_originSource'] = source
# Token del joint.
data['_token'] = _token_of(j)
# Flag orfano.
if data.get('parent') is None and data.get('child') is None:
data['_orphan'] = True
return data
except Exception:
return None
def _export_joints_atl(design, root_comp):
"""Come base.export_joints ma con _serialize_joint_atl."""
result = []
seen = set()
for comp in base._iter_all_components(design, root_comp):
joints = base._safe_get(comp, 'joints')
if joints is None:
continue
try:
n = joints.count
except Exception:
n = 0
for i in range(n):
try:
j = joints.item(i)
except Exception:
continue
try:
key = j.entityToken
except Exception:
key = id(j)
if key in seen:
continue
seen.add(key)
data = _serialize_joint_atl(j)
if data is not None:
result.append(data)
return result
def _export_as_built_joints_atl(design, root_comp):
"""Come base.export_as_built_joints ma con _serialize_as_built_joint_atl."""
result = []
seen = set()
for comp in base._iter_all_components(design, root_comp):
ab_joints = base._safe_get(comp, 'asBuiltJoints')
if ab_joints is None:
continue
try:
n = ab_joints.count
except Exception:
n = 0
for i in range(n):
try:
j = ab_joints.item(i)
except Exception:
continue
try:
key = j.entityToken
except Exception:
key = id(j)
if key in seen:
continue
seen.add(key)
data = _serialize_as_built_joint_atl(j)
if data is not None:
result.append(data)
return result
def _export_motion_links_atl(design):
"""Come base.export_motion_links ma emette anche i token delle
entita' originali per il matching manuale quando joint1/joint2
sono None (limite API noto).
"""
result = []
root_comp = base._safe_get(design, 'rootComponent')
if root_comp is None:
return result
seen = set()
for comp in base._iter_all_components(design, root_comp):
motion_links = base._safe_get(comp, 'motionLinks')
if motion_links is None:
continue
try:
n = motion_links.count
except Exception:
n = 0
for i in range(n):
try:
ml = motion_links.item(i)
except Exception:
continue
try:
key = ml.entityToken
except Exception:
key = id(ml)
if key in seen:
continue
seen.add(key)
try:
j1 = base._safe_get(ml, 'jointOne')
j2 = base._safe_get(ml, 'jointTwo')
# Anche entityOne/entityTwo (proxy interno) possono dare
# un token utile quando il name e' perso.
e1 = base._safe_get(ml, 'entityOne')
e2 = base._safe_get(ml, 'entityTwo')
result.append({
'name': base._safe_get(ml, 'name'),
'joint1': base._safe_get(j1, 'name'),
'joint2': base._safe_get(j2, 'name'),
'joint1Token': _token_of(j1) or _token_of(e1),
'joint2Token': _token_of(j2) or _token_of(e2),
'ratio': base._safe_get(ml, 'ratio'),
'reversed': base._safe_get(ml, 'isReversed'),
'isSuppressed': base._safe_get(ml, 'isSuppressed'),
'isLightBulbOn': base._safe_get(ml, 'isLightBulbOn'),
'currentValue': base._safe_get(ml, 'currentValue'),
})
except Exception:
continue
return result
# =============================================================================
# Enrich hierarchy: glbNodeName + log
# =============================================================================
# Blender 4.x tronca i nomi degli Object a 63 caratteri (limite Python
# bpy.types.ID.name). Qui emettiamo `glbNodeName` previsto = name[:63]
# cosi' il consumer puo' fare lookup senza indovinare.
_BLENDER_NAME_MAX = 63
def _annotate_hierarchy(nodes):
"""Aggiunge `glbNodeName` a ogni nodo (fix #10)."""
for n in nodes:
nm = n.get('name')
if not nm:
continue
n['glbNodeName'] = nm if len(nm) <= _BLENDER_NAME_MAX else nm[:_BLENDER_NAME_MAX]
# =============================================================================
# Folder dialog
# =============================================================================
def _ask_destination_folder(ui, default_dir=None):
dialog = ui.createFolderDialog()
dialog.title = 'Scegli la cartella di destinazione export ATL'
if default_dir:
try:
dialog.initialDirectory = default_dir
except Exception:
pass
if dialog.showDialog() != adsk.core.DialogResults.DialogOK:
return None
return dialog.folder
# =============================================================================
# Entry point
# =============================================================================
def run(context):
"""Entry point richiamato da Fusion 360.
L'utente sceglie una cartella PARENT. Lo script crea (se serve) la
sottocartella `export/` al suo interno e ci scrive:
meshes/*.obj
hierarchy.json
joints.json
"""
ui = None
try:
app = adsk.core.Application.get()
ui = app.userInterface
product = app.activeProduct
if not isinstance(product, adsk.fusion.Design):
if ui:
ui.messageBox('Nessun design Fusion attivo.')
return
design = product
root_comp = design.rootComponent
doc_name = base._safe_get(app.activeDocument, 'name', 'Untitled')
units = None
try:
units = design.unitsManager.defaultLengthUnits
except Exception:
units = None
api_version = None
try:
api_version = adsk.core.Application.get().version
except Exception:
api_version = None
parent_dir = _ask_destination_folder(ui)
if not parent_dir:
return
if os.path.basename(os.path.normpath(parent_dir)).lower() == 'export':
out_dir = parent_dir
else:
out_dir = os.path.join(parent_dir, 'export')
os.makedirs(out_dir, exist_ok=True)
_log('body-per-body mode: ON (eredita da ExportKinematicGraph)')
_log('base module path: ' + str(_BASE_PATH))
# --- 1) Mesh + hierarchy (riusiamo la pipeline plotter) ---------------
nodes, mesh_stats = base.export_meshes_and_hierarchy(
design, root_comp, out_dir
)
_annotate_hierarchy(nodes)
_log('hierarchy nodes: {0}, mesh ok: {1}, mesh fail: {2}'.format(
len(nodes), mesh_stats['exported_meshes'], mesh_stats['failed_meshes']
))
hierarchy_payload = {
'metadata': {
'documentName': doc_name,
'units': units,
'internalUnit': 'cm',
'scaleFactor': 0.01,
'exportedAt': datetime.utcnow().isoformat() + 'Z',
'apiVersion': api_version,
'generator': 'ExportKinematicGraph_ATL.py',
'profile': 'ATL',
'blenderNameMax': _BLENDER_NAME_MAX,
},
'nodes': nodes,
}
with open(os.path.join(out_dir, 'hierarchy.json'), 'w', encoding='utf-8') as f:
json.dump(hierarchy_payload, f, indent=2, ensure_ascii=False,
default=str)
# --- 2) Joints con fix ATL --------------------------------------------
joints_list = _export_joints_atl(design, root_comp)
as_built_list = _export_as_built_joints_atl(design, root_comp)
motion_links = _export_motion_links_atl(design)
rigid_groups = base.export_rigid_groups(design, root_comp)
# Disambiguazione nomi: i joint e gli asBuilt vivono in spazi nomi
# separati per Fusion, ma il viewer matcha per nome unico globale.
# Disambiguiamo i duplicati cross-section.
joints_renamed = _disambiguate_names(joints_list)
ab_renamed = _disambiguate_names(as_built_list)
# Conteggio orfani / origin recuperati.
orphans_joints = sum(1 for x in joints_list if x.get('_orphan'))
orphans_ab = sum(1 for x in as_built_list if x.get('_orphan'))
origin_recovered = sum(1 for x in as_built_list
if x.get('_originSource') in ('entityOne', 'entityTwo'))
revolutes_no_origin = sum(1 for x in as_built_list
if x.get('type') == 'revolute' and x.get('origin') is None)
_log('joints: {0} (renamed {1}, orphans {2})'.format(
len(joints_list), joints_renamed, orphans_joints))
_log('asBuiltJoints: {0} (renamed {1}, orphans {2}, origin recovered {3}, revolute senza origin {4})'.format(
len(as_built_list), ab_renamed, orphans_ab, origin_recovered, revolutes_no_origin))
_log('motionLinks: {0}'.format(len(motion_links)))
_log('rigidGroups: {0}'.format(len(rigid_groups)))
joints_payload = {
'metadata': {
'documentName': doc_name,
'units': units,
'internalUnit': 'cm',
'scaleFactor': 0.01,
'exportedAt': datetime.utcnow().isoformat() + 'Z',
'apiVersion': api_version,
'generator': 'ExportKinematicGraph_ATL.py',
'profile': 'ATL',
'fixes': {
'duplicateNames': {'renamedJoints': joints_renamed,
'renamedAsBuilt': ab_renamed},
'axisSnap': {'epsilon': _EPS_AXIS},
'limitsNormalized': True,
'orphans': {'joints': orphans_joints,
'asBuilt': orphans_ab},
'asBuiltOrigin': {'recovered': origin_recovered,
'revoluteStillMissing': revolutes_no_origin},
'motionLinkTokens': True,
},
},
'joints': joints_list,
'asBuiltJoints': as_built_list,
'motionLinks': motion_links,
'rigidGroups': rigid_groups,
}
with open(os.path.join(out_dir, 'joints.json'), 'w', encoding='utf-8') as f:
json.dump(joints_payload, f, indent=2, ensure_ascii=False,
default=str)
# Salva il log accanto per debug.
with open(os.path.join(out_dir, 'export_atl.log'), 'w', encoding='utf-8') as f:
f.write('\n'.join(_LOG_LINES) + '\n')
if ui:
ui.messageBox(
'Export ATL completato.\n\n'
'Cartella: {0}\n\n'
'Nodi gerarchia: {1}\n'
'Mesh esportate: {2} (fallite: {3})\n\n'
'Joints: {4} (rinominati {5}, orfani {6})\n'
'As-Built Joints: {7} (rinominati {8}, origin recuperati {9}, revolute senza origin {10})\n'
'Motion Links: {11}\n'
'Rigid Groups: {12}\n\n'
'Log dettagliato: export_atl.log'.format(
out_dir,
len(nodes),
mesh_stats['exported_meshes'],
mesh_stats['failed_meshes'],
len(joints_list), joints_renamed, orphans_joints,
len(as_built_list), ab_renamed, origin_recovered, revolutes_no_origin,
len(motion_links),
len(rigid_groups),
)
)
except Exception:
if ui:
ui.messageBox(
'Errore durante l\'export ATL:\n\n' + traceback.format_exc()
)