Initial commit: Fusion->Blender->GLB pipeline + contract for ThreeJS bridge
This commit is contained in:
524
build_glb_from_fusion_export.py
Normal file
524
build_glb_from_fusion_export.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
build_glb_from_fusion_export.py
|
||||
-------------------------------
|
||||
Script Blender che ricostruisce un assieme Fusion 360 esportato dallo
|
||||
script `ExportKinematicGraph.py` (cartella con `hierarchy.json` + `meshes/`)
|
||||
e produce un file `.glb`.
|
||||
|
||||
Uso (da terminale):
|
||||
blender --background --python build_glb_from_fusion_export.py -- \
|
||||
path/to/export/hierarchy.json path/to/output/plotter.glb
|
||||
|
||||
Argomenti dopo il `--`:
|
||||
1. Percorso al file `hierarchy.json` esportato da Fusion.
|
||||
2. Percorso del file `.glb` di output.
|
||||
|
||||
Comportamento:
|
||||
* Pulisce la scena Blender.
|
||||
* Per ogni nodo della gerarchia crea un Empty (con UUID -> nome univoco).
|
||||
* Imposta il parent secondo `parentFullPathName`.
|
||||
* Applica la `transform` 4x4 LOCALE letta dal JSON (Fusion = row-major,
|
||||
Blender = column-major: facciamo il transpose).
|
||||
* Se il nodo ha `meshFile`, importa l'OBJ/STL e lo parenta all'Empty
|
||||
con trasformazione locale identita'.
|
||||
* Applica lo scaleFactor (cm -> m) al root in modo che l'export GLB
|
||||
finisca in metri (convenzione glTF).
|
||||
* Esporta in `.glb` (binary glTF 2.0).
|
||||
|
||||
Robustezza:
|
||||
* Compatibile con Blender 3.x e 4.x (rileva l'importer OBJ corretto).
|
||||
* Tutto incapsulato in try/except con log su stderr; exit code != 0
|
||||
in caso di errori non recuperabili.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from mathutils import Matrix
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Argomenti CLI dopo il "--"
|
||||
# =============================================================================
|
||||
|
||||
def _parse_args():
|
||||
"""Estrae gli argomenti passati dopo il `--` (convenzione Blender)."""
|
||||
argv = sys.argv
|
||||
if '--' not in argv:
|
||||
raise SystemExit(
|
||||
'Uso: blender --background --python build_glb_from_fusion_export.py '
|
||||
'-- <hierarchy.json> <output.glb>'
|
||||
)
|
||||
extra = argv[argv.index('--') + 1:]
|
||||
if len(extra) < 2:
|
||||
raise SystemExit(
|
||||
'Servono 2 argomenti dopo "--": hierarchy.json e output.glb'
|
||||
)
|
||||
return extra[0], extra[1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pulizia scena
|
||||
# =============================================================================
|
||||
|
||||
def _wipe_scene():
|
||||
"""Rimuove tutto dalla scena attiva (oggetti, collezioni orphan, ecc.)."""
|
||||
# Selezione di tutto e cancellazione.
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
# Pulizia di datablock orfani (mesh / material / image residui).
|
||||
for collection_attr in ('meshes', 'materials', 'images', 'curves',
|
||||
'lights', 'cameras'):
|
||||
try:
|
||||
data = getattr(bpy.data, collection_attr)
|
||||
for item in list(data):
|
||||
if item.users == 0:
|
||||
data.remove(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Conversione matrice JSON -> mathutils.Matrix
|
||||
# =============================================================================
|
||||
|
||||
def _matrix_from_list(values):
|
||||
"""Converte una lista di 16 float (row-major, come esportato da Fusion)
|
||||
in `mathutils.Matrix`. Ritorna l'identita' se `values` non e' valido.
|
||||
"""
|
||||
if not values or len(values) != 16:
|
||||
return Matrix.Identity(4)
|
||||
rows = [
|
||||
values[0:4],
|
||||
values[4:8],
|
||||
values[8:12],
|
||||
values[12:16],
|
||||
]
|
||||
return Matrix(rows)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Import mesh (OBJ / STL) cross-version Blender
|
||||
# =============================================================================
|
||||
|
||||
def _import_mesh(filepath):
|
||||
"""Importa un OBJ o STL e ritorna la lista di Object importati.
|
||||
|
||||
Gestisce sia Blender 4.x (operator nativo `wm.obj_import`) sia 3.x
|
||||
(addon `import_scene.obj`). Per STL usa `import_mesh.stl` (presente
|
||||
in entrambi).
|
||||
"""
|
||||
before = set(bpy.data.objects)
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
|
||||
try:
|
||||
if ext == '.obj':
|
||||
# Blender 4.x: importer C nativo.
|
||||
if hasattr(bpy.ops.wm, 'obj_import'):
|
||||
bpy.ops.wm.obj_import(filepath=filepath)
|
||||
else:
|
||||
# Blender 3.x: addon Python.
|
||||
bpy.ops.import_scene.obj(filepath=filepath)
|
||||
elif ext == '.stl':
|
||||
if hasattr(bpy.ops.wm, 'stl_import'):
|
||||
bpy.ops.wm.stl_import(filepath=filepath)
|
||||
else:
|
||||
bpy.ops.import_mesh.stl(filepath=filepath)
|
||||
else:
|
||||
print('[WARN] Estensione mesh non supportata: {0}'.format(ext),
|
||||
file=sys.stderr)
|
||||
return []
|
||||
except Exception:
|
||||
print('[ERROR] Import mesh fallito: {0}\n{1}'.format(
|
||||
filepath, traceback.format_exc()), file=sys.stderr)
|
||||
return []
|
||||
|
||||
return [obj for obj in bpy.data.objects if obj not in before]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Costruzione gerarchia
|
||||
# =============================================================================
|
||||
|
||||
def _make_unique_empty_name(node):
|
||||
"""Nome univoco per l'Empty di un nodo, usando il fullPathName."""
|
||||
# Blender limita i nomi a 63 char; fullPathName puo' essere lungo:
|
||||
# usiamo gli ultimi 50 char + un hash breve per evitare collisioni.
|
||||
name = node.get('name') or 'node'
|
||||
full = node.get('fullPathName') or node.get('id') or name
|
||||
suffix = '#{0:08x}'.format(abs(hash(full)) & 0xFFFFFFFF)
|
||||
return (name[:50] + suffix)
|
||||
|
||||
|
||||
# Cache materiali condivisi: chiave = tupla RGBA arrotondata -> bpy.Material
|
||||
_MATERIAL_CACHE = {}
|
||||
|
||||
|
||||
def _make_color_material(color):
|
||||
"""Ritorna un materiale Blender con `Base Color` = color (RGBA 0-1).
|
||||
|
||||
`color` puo' essere None / lista [r,g,b] / lista [r,g,b,a].
|
||||
Il materiale viene cached per RGBA arrotondata, in modo che colori
|
||||
uguali condividano lo stesso material slot.
|
||||
Ritorna None se `color` non e' valido.
|
||||
"""
|
||||
if not color or not isinstance(color, list) or len(color) < 3:
|
||||
return None
|
||||
try:
|
||||
r, g, b = float(color[0]), float(color[1]), float(color[2])
|
||||
a = float(color[3]) if len(color) >= 4 else 1.0
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Chiave cache: arrotonda a 3 decimali (~256 livelli per canale).
|
||||
key = (round(r, 3), round(g, 3), round(b, 3), round(a, 3))
|
||||
cached = _MATERIAL_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
mat = bpy.data.materials.new(name='FusionMat_{0:02x}{1:02x}{2:02x}'.format(
|
||||
int(r * 255), int(g * 255), int(b * 255)))
|
||||
mat.use_nodes = True
|
||||
try:
|
||||
bsdf = mat.node_tree.nodes.get('Principled BSDF')
|
||||
if bsdf is not None:
|
||||
bsdf.inputs['Base Color'].default_value = (r, g, b, a)
|
||||
# Se c'e' trasparenza, attiviamo l'alpha blending.
|
||||
if a < 1.0:
|
||||
bsdf.inputs['Alpha'].default_value = a
|
||||
mat.blend_method = 'BLEND'
|
||||
except Exception:
|
||||
# Fallback: colore di viewport (sempre disponibile).
|
||||
try:
|
||||
mat.diffuse_color = (r, g, b, a)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Anche il colore "viewport" (per Solid view e fallback).
|
||||
try:
|
||||
mat.diffuse_color = (r, g, b, a)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_MATERIAL_CACHE[key] = mat
|
||||
return mat
|
||||
|
||||
|
||||
def build_scene(hierarchy_path):
|
||||
"""Costruisce la scena Blender a partire da hierarchy.json.
|
||||
|
||||
Ritorna l'Empty 'root' a cui tutto il modello e' parentato.
|
||||
"""
|
||||
with open(hierarchy_path, 'r', encoding='utf-8') as f:
|
||||
payload = json.load(f)
|
||||
|
||||
nodes = payload.get('nodes', [])
|
||||
metadata = payload.get('metadata', {}) or {}
|
||||
scale_factor = float(metadata.get('scaleFactor') or 0.01)
|
||||
base_dir = os.path.dirname(os.path.abspath(hierarchy_path))
|
||||
|
||||
# Indice: id (== fullPathName, vuoto per il root) -> Empty Blender.
|
||||
id_to_empty = {}
|
||||
|
||||
# 1) Pass 1: crea tutti gli Empty senza parenting.
|
||||
for node in nodes:
|
||||
node_id = node.get('id')
|
||||
if node_id is None:
|
||||
continue
|
||||
|
||||
empty = bpy.data.objects.new(_make_unique_empty_name(node), None)
|
||||
empty.empty_display_type = 'ARROWS'
|
||||
empty.empty_display_size = 0.1
|
||||
bpy.context.collection.objects.link(empty)
|
||||
|
||||
# Salviamo metadati utili come custom properties.
|
||||
# I campi `fusion_name` e `fusion_path` sono il contratto ufficiale col
|
||||
# viewer Three.js (vedi FUSION_GLB_CONTRACT.md §3.2): devono coincidere
|
||||
# byte-per-byte con `Joint.child` / `Joint.childFullPath` del joints.json.
|
||||
try:
|
||||
occ_name = node.get('name') or ''
|
||||
full_path = node.get('fullPathName') or ''
|
||||
empty['fusion_name'] = occ_name
|
||||
empty['fusion_path'] = full_path
|
||||
# Alias storici (back-compat, possono essere rimossi in futuro).
|
||||
empty['fusion_id'] = node_id
|
||||
empty['fusion_fullPathName'] = full_path
|
||||
empty['fusion_componentName'] = node.get('componentName') or ''
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
id_to_empty[node_id] = empty
|
||||
|
||||
# 2) Pass 2: SOLO parenting + import mesh. Il transform viene applicato
|
||||
# dopo in un BFS top-down (vedi pass 3).
|
||||
root_node_id = '__ROOT__'
|
||||
for node in nodes:
|
||||
node_id = node.get('id')
|
||||
empty = id_to_empty.get(node_id)
|
||||
if empty is None:
|
||||
continue
|
||||
|
||||
# Parent:
|
||||
# parentFullPathName == None -> nessun parent (e' il root)
|
||||
# parentFullPathName == '' -> figlio del root (export vecchio)
|
||||
# altrimenti -> id del parent
|
||||
parent_path = node.get('parentFullPathName')
|
||||
if parent_path is not None and node_id != root_node_id:
|
||||
# Retrocompatibilita': stringa vuota = root.
|
||||
if parent_path == '':
|
||||
parent_path = root_node_id
|
||||
parent_empty = id_to_empty.get(parent_path)
|
||||
if parent_empty is None:
|
||||
# Orfano: parent non trovato, agganciamo al root se esiste.
|
||||
parent_empty = id_to_empty.get(root_node_id)
|
||||
if parent_empty is not None:
|
||||
print('[WARN] Parent non trovato per "{0}" (cercavo "{1}'
|
||||
'"). Aggancio al root.'.format(node_id, parent_path),
|
||||
file=sys.stderr)
|
||||
if parent_empty is not None and parent_empty is not empty:
|
||||
empty.parent = parent_empty
|
||||
|
||||
# Mesh associata. Parentata all'Empty con identita': la posizione
|
||||
# finale verra' dall'Empty (gestita nel pass 3).
|
||||
mesh_rel = node.get('meshFile')
|
||||
if mesh_rel:
|
||||
mesh_abs = os.path.join(base_dir, mesh_rel)
|
||||
if not os.path.isfile(mesh_abs):
|
||||
print('[WARN] Mesh non trovata: {0}'.format(mesh_abs),
|
||||
file=sys.stderr)
|
||||
continue
|
||||
imported = _import_mesh(mesh_abs)
|
||||
# Materiale colorato dal `color` del nodo (se presente). I
|
||||
# materiali sono cached per RGBA cosi' colori uguali condividono
|
||||
# lo stesso material slot nel GLB (output piu' piccolo).
|
||||
mat_color = _make_color_material(node.get('color'))
|
||||
for obj in imported:
|
||||
obj.parent = empty
|
||||
obj.matrix_local = Matrix.Identity(4)
|
||||
if mat_color is not None:
|
||||
try:
|
||||
# Sovrascriviamo tutti gli slot esistenti per essere
|
||||
# sicuri che il colore Fusion venga rispettato.
|
||||
if obj.type == 'MESH':
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(mat_color)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Trovare il nodo "root" e applicare lo scaleFactor cm -> m.
|
||||
root_empty = id_to_empty.get(root_node_id)
|
||||
if root_empty is None:
|
||||
# Fallback: prendiamo qualsiasi Empty senza parent.
|
||||
for e in id_to_empty.values():
|
||||
if e.parent is None:
|
||||
root_empty = e
|
||||
break
|
||||
|
||||
if root_empty is not None and scale_factor and scale_factor != 1.0:
|
||||
# Scala uniforme applicata SOLO al root: i figli ereditano la scala.
|
||||
root_empty.scale = (scale_factor, scale_factor, scale_factor)
|
||||
|
||||
# Forziamo l'update della scene cosi' matrix_world del root e' calcolato
|
||||
# prima di propagare le world matrices dei figli.
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# 4) Pass 4: BFS top-down e assegnazione di matrix_world.
|
||||
# IMPORTANTE: `Occurrence.transform2` di Fusion ritorna la matrice in
|
||||
# coordinate WORLD/ROOT (NON relative al parent immediato). Quindi NON
|
||||
# possiamo settare `matrix_local` (darebbe traslazioni duplicate per i
|
||||
# nodi dentro sub-assembly non a origine). Settiamo `matrix_world` e
|
||||
# Blender calcola da solo il local relativo al parent.
|
||||
# La matrice esportata e' in cm Fusion: la convertiamo in metri
|
||||
# moltiplicando per scale_root (che include lo scaleFactor).
|
||||
from collections import deque
|
||||
scale_root_mat = Matrix.Scale(scale_factor, 4) if scale_factor else Matrix.Identity(4)
|
||||
|
||||
id_to_node = {n.get('id'): n for n in nodes}
|
||||
children_map = {}
|
||||
for n in nodes:
|
||||
p = n.get('parentFullPathName')
|
||||
if p is None:
|
||||
continue
|
||||
if p == '':
|
||||
p = root_node_id
|
||||
children_map.setdefault(p, []).append(n.get('id'))
|
||||
|
||||
visited = {root_node_id}
|
||||
queue = deque([root_node_id])
|
||||
while queue:
|
||||
cur_id = queue.popleft()
|
||||
for child_id in children_map.get(cur_id, []):
|
||||
if child_id in visited:
|
||||
continue
|
||||
visited.add(child_id)
|
||||
queue.append(child_id)
|
||||
child_empty = id_to_empty.get(child_id)
|
||||
child_node = id_to_node.get(child_id)
|
||||
if child_empty is None or child_node is None:
|
||||
continue
|
||||
mat_world_cm = _matrix_from_list(child_node.get('transform'))
|
||||
# world finale (m) = scale_root @ world_fusion (cm)
|
||||
child_empty.matrix_world = scale_root_mat @ mat_world_cm
|
||||
|
||||
return root_empty
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Joints / Motion Links -> glTF scene.extras
|
||||
# =============================================================================
|
||||
|
||||
def embed_joints(joints_json_path):
|
||||
"""Carica joints.json e lo salva come custom property della Scene.
|
||||
|
||||
Con `export_extras=True` Blender mette questo dict in `scene.extras`
|
||||
del glTF, quindi in Three.js puoi leggerlo via
|
||||
`gltf.parser.json.scenes[i].extras.fusion`.
|
||||
|
||||
Inoltre converte le coordinate da Fusion (cm) a metri (m) cosi' i
|
||||
valori sono coerenti col modello esportato col `scaleFactor`.
|
||||
"""
|
||||
if not joints_json_path or not os.path.isfile(joints_json_path):
|
||||
print('[INFO] joints.json non trovato, skip embedding joints/motion.',
|
||||
file=sys.stderr)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(joints_json_path, 'r', encoding='utf-8') as f:
|
||||
payload = json.load(f)
|
||||
except Exception:
|
||||
print('[WARN] joints.json non leggibile:\n' + traceback.format_exc(),
|
||||
file=sys.stderr)
|
||||
return
|
||||
|
||||
# Scaling cm -> m sulle posizioni (origin) e sui valori dei limiti
|
||||
# lineari (slideLimits / slideValue). Gli assi sono direzioni
|
||||
# unitarie -> non vanno scalati. Le rotazioni sono in radianti
|
||||
# -> non vanno scalate.
|
||||
metadata = payload.get('metadata', {}) or {}
|
||||
scale = float(metadata.get('scaleFactor') or 0.01)
|
||||
|
||||
def _scale_point(p):
|
||||
if p is None or not isinstance(p, list):
|
||||
return p
|
||||
return [v * scale for v in p]
|
||||
|
||||
def _scale_limits(lim, is_linear):
|
||||
if lim is None or not is_linear:
|
||||
return lim
|
||||
out = dict(lim)
|
||||
for k in ('minimumValue', 'maximumValue', 'restValue'):
|
||||
if out.get(k) is not None:
|
||||
try:
|
||||
out[k] = out[k] * scale
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def _scale_joint(j):
|
||||
j = dict(j)
|
||||
for k in ('origin', 'originTwo'):
|
||||
if k in j:
|
||||
j[k] = _scale_point(j.get(k))
|
||||
if 'slideLimits' in j:
|
||||
j['slideLimits'] = _scale_limits(j.get('slideLimits'), True)
|
||||
if 'slideValue' in j and j.get('slideValue') is not None:
|
||||
try:
|
||||
j['slideValue'] = j['slideValue'] * scale
|
||||
except Exception:
|
||||
pass
|
||||
# rotationLimits / rotationValue restano invariati (radianti).
|
||||
return j
|
||||
|
||||
fusion_data = {
|
||||
'metadata': metadata,
|
||||
'joints': [_scale_joint(j) for j in payload.get('joints', [])],
|
||||
'asBuiltJoints': [_scale_joint(j) for j in payload.get('asBuiltJoints', [])],
|
||||
'motionLinks': payload.get('motionLinks', []),
|
||||
'rigidGroups': payload.get('rigidGroups', []),
|
||||
}
|
||||
|
||||
# Custom property sulla Scene -> finisce in scene.extras del glTF.
|
||||
scene = bpy.context.scene
|
||||
try:
|
||||
# Serializziamo in stringa JSON: glTF custom properties supportano
|
||||
# primitivi/array/dict ma alcuni viewer non gestiscono dict annidati
|
||||
# profondi. Una stringa JSON e' sempre safe da parsare in Three.js.
|
||||
scene['fusion'] = json.dumps(fusion_data, ensure_ascii=False)
|
||||
print('[OK] Joints embedded: joints={0} as_built={1} '
|
||||
'motion_links={2} rigid_groups={3}'.format(
|
||||
len(fusion_data['joints']),
|
||||
len(fusion_data['asBuiltJoints']),
|
||||
len(fusion_data['motionLinks']),
|
||||
len(fusion_data['rigidGroups'])))
|
||||
except Exception:
|
||||
print('[WARN] Embed joints fallito:\n' + traceback.format_exc(),
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export GLB
|
||||
# =============================================================================
|
||||
|
||||
def export_glb(output_path):
|
||||
"""Esporta la scena corrente in formato GLB (glTF 2.0 binario)."""
|
||||
out_dir = os.path.dirname(os.path.abspath(output_path))
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
export_apply=True, # applica modifiers
|
||||
export_yup=True, # convenzione glTF: Y-up
|
||||
use_visible=False,
|
||||
use_selection=False,
|
||||
export_extras=True, # custom properties -> extras glTF
|
||||
export_materials='EXPORT', # esporta i materiali Principled BSDF
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
try:
|
||||
hierarchy_path, output_path = _parse_args()
|
||||
|
||||
if not os.path.isfile(hierarchy_path):
|
||||
raise SystemExit(
|
||||
'File hierarchy non trovato: {0}'.format(hierarchy_path))
|
||||
|
||||
print('[INFO] Hierarchy: {0}'.format(hierarchy_path))
|
||||
print('[INFO] Output: {0}'.format(output_path))
|
||||
|
||||
_wipe_scene()
|
||||
root = build_scene(hierarchy_path)
|
||||
if root is None:
|
||||
print('[WARN] Nessun nodo root identificato.', file=sys.stderr)
|
||||
|
||||
# Cerca joints.json accanto al hierarchy e lo incorpora nel GLB
|
||||
# come scene.extras.fusion (stringa JSON).
|
||||
joints_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(hierarchy_path)), 'joints.json')
|
||||
embed_joints(joints_path)
|
||||
|
||||
export_glb(output_path)
|
||||
|
||||
print('[OK] Esportato: {0}'.format(output_path))
|
||||
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
print('[ERROR] Build GLB fallito:\n' + traceback.format_exc(),
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user