Files
syncro_multi_agente/ExportKinematicGraph.py

1711 lines
59 KiB
Python

# -*- coding: utf-8 -*-
"""
ExportKinematicGraph.py
-----------------------
Script per Autodesk Fusion 360 che esporta in una cartella scelta
dall'utente:
<cartella scelta>/
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/<safe>.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:
<cartella scelta>/
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()
)