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