createOBJExportOptions(component, ...) includeva i body delle sub-occurrences, duplicando la geometria nei nodi padre. Ora esportiamo solo comp.bRepBodies (body propri del Component): per N>1 body, concateniamo gli OBJ riallineando gli indici v/vt/vn. Container senza body propri non generano meshFile. Fallback STL invariato.
1889 lines
66 KiB
Python
1889 lines
66 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 ha almeno una BRepBody PROPRIA visibile.
|
|
|
|
`comp.bRepBodies` espone solo i body che appartengono direttamente al
|
|
component (non quelli ereditati dalle sub-occurrences), quindi un
|
|
component "container" senza body propri ritorna False e non genera
|
|
un meshFile — i suoi figli hanno gia' il proprio nodo + mesh.
|
|
"""
|
|
if comp is None:
|
|
return False
|
|
try:
|
|
bodies = comp.bRepBodies
|
|
if bodies is None:
|
|
return False
|
|
for i in range(bodies.count):
|
|
try:
|
|
b = bodies.item(i)
|
|
if _safe_get(b, 'isVisible', True) is False:
|
|
continue
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _collect_own_visible_bodies(comp):
|
|
"""Ritorna la lista di BRepBody PROPRI del Component (esclude quelli
|
|
appartenenti alle sub-occurrences) che siano visibili."""
|
|
out = []
|
|
try:
|
|
bodies = comp.bRepBodies
|
|
except Exception:
|
|
return out
|
|
if bodies is None:
|
|
return out
|
|
try:
|
|
n = bodies.count
|
|
except Exception:
|
|
return out
|
|
for i in range(n):
|
|
try:
|
|
b = bodies.item(i)
|
|
if _safe_get(b, 'isVisible', True) is False:
|
|
continue
|
|
out.append(b)
|
|
except Exception:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _concatenate_obj_files(input_paths, output_path):
|
|
"""Concatena piu' OBJ in un unico file riallineando gli indici v/vt/vn.
|
|
|
|
Salta le direttive `mtllib` e `usemtl` per evitare riferimenti a .mtl
|
|
multipli (Blender importerebbe materiali non risolvibili). Il colore
|
|
viene comunque applicato lato JSON (`color`) dallo script Blender.
|
|
"""
|
|
v_off = vt_off = vn_off = 0
|
|
out_lines = []
|
|
|
|
for path in input_paths:
|
|
v_local = vt_local = vn_local = 0
|
|
try:
|
|
f = open(path, 'r', encoding='utf-8', errors='replace')
|
|
except Exception:
|
|
continue
|
|
try:
|
|
for line in f:
|
|
s = line.lstrip()
|
|
if not s or s[0] == '#':
|
|
continue
|
|
# Identifica il keyword (prima della prima whitespace).
|
|
# split(None, 1) gestisce sia spazi sia tab.
|
|
head = s.split(None, 1)
|
|
key = head[0] if head else ''
|
|
if key == 'mtllib' or key == 'usemtl':
|
|
continue
|
|
if key == 'v':
|
|
v_local += 1
|
|
out_lines.append(line)
|
|
elif key == 'vt':
|
|
vt_local += 1
|
|
out_lines.append(line)
|
|
elif key == 'vn':
|
|
vn_local += 1
|
|
out_lines.append(line)
|
|
elif key == 'f':
|
|
tokens = s.split()
|
|
new_tokens = ['f']
|
|
for tok in tokens[1:]:
|
|
parts = tok.split('/')
|
|
# v
|
|
try:
|
|
vi = int(parts[0])
|
|
if vi > 0:
|
|
vi += v_off
|
|
except Exception:
|
|
new_tokens.append(tok)
|
|
continue
|
|
vt_s = ''
|
|
if len(parts) >= 2 and parts[1] != '':
|
|
try:
|
|
ti = int(parts[1])
|
|
if ti > 0:
|
|
ti += vt_off
|
|
vt_s = str(ti)
|
|
except Exception:
|
|
vt_s = parts[1]
|
|
vn_s = ''
|
|
if len(parts) >= 3 and parts[2] != '':
|
|
try:
|
|
ni = int(parts[2])
|
|
if ni > 0:
|
|
ni += vn_off
|
|
vn_s = str(ni)
|
|
except Exception:
|
|
vn_s = parts[2]
|
|
if len(parts) == 1:
|
|
new_tokens.append(str(vi))
|
|
elif len(parts) == 2:
|
|
new_tokens.append('{0}/{1}'.format(vi, vt_s))
|
|
else:
|
|
new_tokens.append('{0}/{1}/{2}'.format(vi, vt_s, vn_s))
|
|
out_lines.append(' '.join(new_tokens) + '\n')
|
|
else:
|
|
# 'o', 'g', 's', linee non riconosciute: passa attraverso.
|
|
out_lines.append(line)
|
|
finally:
|
|
try:
|
|
f.close()
|
|
except Exception:
|
|
pass
|
|
v_off += v_local
|
|
vt_off += vt_local
|
|
vn_off += vn_local
|
|
|
|
with open(output_path, 'w', encoding='utf-8') as f:
|
|
f.writelines(out_lines)
|
|
|
|
|
|
def _cleanup_tmp_obj(tmp_paths):
|
|
"""Rimuove gli OBJ temporanei e i loro .mtl associati (se esistono)."""
|
|
for p in tmp_paths:
|
|
try:
|
|
if os.path.exists(p):
|
|
os.remove(p)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
mtl = p[:-4] + '.mtl' if p.lower().endswith('.obj') else None
|
|
if mtl and os.path.exists(mtl):
|
|
os.remove(mtl)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _export_component_mesh(export_mgr, component, filepath_noext):
|
|
"""Esporta un Component come mesh, considerando SOLO i body propri del
|
|
component (esclude i body delle sub-occurrences, che hanno il loro nodo
|
|
proprio nella gerarchia ed evitano cosi' duplicazioni "fantasma").
|
|
|
|
Strategia:
|
|
* 1 solo body proprio: export OBJ diretto del body.
|
|
* N body propri: export di ciascun body in OBJ temporaneo +
|
|
concatenazione manuale con riallineamento indici.
|
|
* Fallback STL: se tutti i tentativi OBJ falliscono, esporta
|
|
tutto il Component in STL (qui Fusion include
|
|
i discendenti, ma il fallback e' raro e
|
|
dichiarato).
|
|
|
|
Ritorna la tupla (path_estensione_inclusa, formato) oppure (None, None).
|
|
"""
|
|
own_bodies = _collect_own_visible_bodies(component)
|
|
obj_path = filepath_noext + '.obj'
|
|
|
|
# 1) Caso normale: esportiamo solo i body propri.
|
|
if own_bodies:
|
|
if len(own_bodies) == 1:
|
|
try:
|
|
opts = export_mgr.createOBJExportOptions(own_bodies[0], obj_path)
|
|
if export_mgr.execute(opts):
|
|
# Pulizia di un eventuale .mtl generato a fianco.
|
|
_cleanup_tmp_obj([]) # no-op: ma rimuoviamo .mtl finale qui sotto.
|
|
try:
|
|
mtl = filepath_noext + '.mtl'
|
|
if os.path.exists(mtl):
|
|
os.remove(mtl)
|
|
except Exception:
|
|
pass
|
|
return (obj_path, 'obj')
|
|
except Exception:
|
|
pass
|
|
else:
|
|
tmp_paths = []
|
|
for idx, body in enumerate(own_bodies):
|
|
tmp = '{0}.__body{1}.obj'.format(filepath_noext, idx)
|
|
try:
|
|
opts = export_mgr.createOBJExportOptions(body, tmp)
|
|
if export_mgr.execute(opts):
|
|
tmp_paths.append(tmp)
|
|
except Exception:
|
|
continue
|
|
if tmp_paths:
|
|
try:
|
|
_concatenate_obj_files(tmp_paths, obj_path)
|
|
return (obj_path, 'obj')
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
_cleanup_tmp_obj(tmp_paths)
|
|
|
|
# 2) Fallback STL sul Component completo (raro, ma manteniamo il safety net).
|
|
# NOTA: lo STL del Component include i body delle sub-occurrences, quindi
|
|
# in caso di fallback potresti rivedere il pattern "geometria fantasma".
|
|
# Loggato dallo stats come 'failed_meshes' a monte se anche questo fallisce.
|
|
stl_path = filepath_noext + '.stl'
|
|
try:
|
|
opts = export_mgr.createSTLExportOptions(component, stl_path)
|
|
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()
|
|
)
|