# -*- coding: utf-8 -*- """Base comun para las escenas 3D del canal (Blender 5.2, EEVEE, headless). Idea central: no se usan keyframes. El render recorre los frames uno por uno y antes de cada uno llama a una funcion actualizar(f) que coloca todo. Asi la fisica y la geometria se calculan exactas en cada cuadro y no hay que pelear con la API de acciones por capas de Blender 5.x. El tiempo sale de out/_timeline.json, el mismo archivo que usa build_video.py para colocar el audio: la sincronia es por construccion. """ import json, math, os, sys import bpy ROOT = os.environ.get("PROY", "/tmp/Firefox/videos-t") FPS = 30 W, H = 1080, 1920 # --- paleta del canal (sRGB) -------------------------------------------------- HEX = {"ambar": "FFD166", "verde": "06D6A0", "rosa": "EF476F", "celeste": "4CC9F0", "gris": "8D99AE", "fondo": "0B0F1A", "azul": "1D3B6E", "riel": "1B2233", "blanco": "FFFFFF", "punto": "243050"} def srgb(h): """Hex sRGB -> lineal, que es lo que espera Blender en los materiales.""" def c(v): v /= 255.0 return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 h = HEX.get(h, h).lstrip("#") return tuple(c(int(h[i:i + 2], 16)) for i in (0, 2, 4)) def escena(muestras=None): """Escena limpia, vertical, EEVEE, fondo transparente y color sin tonemap.""" bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene sc.render.engine = 'BLENDER_EEVEE' sc.eevee.taa_render_samples = int(muestras or os.environ.get("MUESTRAS", 16)) sc.render.resolution_x, sc.render.resolution_y = W, H sc.render.resolution_percentage = int(os.environ.get("PCT", 100)) sc.render.fps = FPS sc.render.film_transparent = True # el fondo punteado se compone aparte sc.render.image_settings.file_format = 'PNG' sc.render.image_settings.color_mode = 'RGBA' sc.render.image_settings.compression = 25 # 'Standard' deja los colores tal cual se escriben; AgX los lavaria y el # canal quedaria con otra paleta que los videos de Manim. for prop, val in (("view_transform", 'Standard'), ("look", 'None')): try: setattr(sc.view_settings, prop, val) except Exception as e: print(f" aviso: view_settings.{prop} -> {e}") mundo = bpy.data.worlds.new("mundo") mundo.use_nodes = True mundo.node_tree.nodes["Background"].inputs[0].default_value = (*srgb("fondo"), 1) mundo.node_tree.nodes["Background"].inputs[1].default_value = 0.45 sc.world = mundo return sc def poner(nodo, nombre, valor): """setattr/socket tolerante: avisa en vez de romper si cambio el nombre.""" try: if nombre in nodo.inputs: nodo.inputs[nombre].default_value = valor return True except Exception: pass print(f" aviso: no existe la entrada '{nombre}'") return False def material(nombre, color, rug=0.35, metal=0.0, emis=0.0, alpha=1.0): m = bpy.data.materials.new(nombre) m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") col = (*(color if isinstance(color, tuple) else srgb(color)), 1.0) poner(b, "Base Color", col) poner(b, "Roughness", rug) poner(b, "Metallic", metal) if emis: poner(b, "Emission Color", col) poner(b, "Emission Strength", emis) if alpha < 1.0: poner(b, "Alpha", alpha) m.blend_method = 'BLEND' if hasattr(m, "blend_method") else m.blend_method return m def objeto(nombre, malla, mat=None, coleccion=None): ob = bpy.data.objects.new(nombre, malla) (coleccion or bpy.context.collection).objects.link(ob) if mat is not None: ob.data.materials.append(mat) return ob def malla_de(nombre, verts, faces, suave=True): me = bpy.data.meshes.new(nombre) me.from_pydata(verts, [], faces) me.update() if suave: for p in me.polygons: p.use_smooth = True return me def curva_poly(nombre, splines, grosor=0.01, radios=None, mat=None): """Una curva con varias splines POLY; radios opcional por punto.""" cu = bpy.data.curves.new(nombre, 'CURVE') cu.dimensions = '3D' cu.bevel_depth = grosor cu.bevel_resolution = 1 cu.use_fill_caps = True for k, pts in enumerate(splines): sp = cu.splines.new('POLY') sp.points.add(len(pts) - 1) for i, p in enumerate(pts): sp.points[i].co = (p[0], p[1], p[2], 1.0) if radios is not None: sp.points[i].radius = radios[k][i] return objeto(nombre, cu, mat) def rehacer_curva(ob, splines, radios=None): """Reescribe los puntos de una curva ya creada (misma cantidad de puntos).""" for sp, pts, k in zip(ob.data.splines, splines, range(len(splines))): for i, p in enumerate(pts): sp.points[i].co = (p[0], p[1], p[2], 1.0) if radios is not None: sp.points[i].radius = radios[k][i] def camara(loc, mira=(0, 0, 0), lente=50): cd = bpy.data.cameras.new("cam") cd.lens = lente cam = bpy.data.objects.new("cam", cd) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam apuntar(cam, loc, mira) return cam def apuntar(ob, loc, mira): """Coloca ob en loc mirando a 'mira' (convencion de camara: -Z adelante).""" from mathutils import Vector ob.location = loc d = Vector(mira) - Vector(loc) ob.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() def luz(nombre, tipo, loc, energia, color="blanco", tam=2.0, mira=None): ld = bpy.data.lights.new(nombre, tipo) ld.energy = energia ld.color = srgb(color) if tipo == 'AREA': ld.size = tam if tipo == 'POINT': ld.shadow_soft_size = tam ob = bpy.data.objects.new(nombre, ld) bpy.context.collection.objects.link(ob) if mira is not None: apuntar(ob, loc, mira) else: ob.location = loc return ob # --- tiempo ------------------------------------------------------------------- class Tiempo: """Traduce el timeline del canal a frames.""" def __init__(self, nombre): with open(os.path.join(ROOT, "out", f"{nombre}_timeline.json")) as f: self.tl = json.load(f) self.beats = self.tl["beats"] self.total = self.tl["total"] self.n_frames = int(round(self.total * FPS)) def rango(self, i): """(frame inicial, frame final) del beat i, 1-based inclusive.""" b = self.beats[i] a = int(round(b["start"] * FPS)) + 1 z = int(round((b["start"] + b["dur"]) * FPS)) return a, z def t(self, f): return (f - 1) / FPS def p(self, f, i): """Progreso 0..1 dentro del beat i (fuera del beat: 0 antes, 1 despues).""" a, z = self.rango(i) if f <= a: return 0.0 if f >= z: return 1.0 return (f - a) / max(1, z - a) def suave(x): x = max(0.0, min(1.0, x)) return x * x * (3 - 2 * x) def mezcla(a, b, x): return a + (b - a) * suave(x) # --- render ------------------------------------------------------------------- def render_secuencia(nombre, tiempo, actualizar, desde=None, hasta=None): sc = bpy.context.scene carpeta = os.path.join(ROOT, "render", nombre) os.makedirs(carpeta, exist_ok=True) a = int(desde or os.environ.get("DESDE", 1)) z = int(hasta or os.environ.get("HASTA", tiempo.n_frames)) salto = os.environ.get("SALTO") # para pruebas: 1 de cada N frames = range(a, z + 1, int(salto) if salto else 1) import time t0 = time.time() hechos = 0 for f in frames: destino = os.path.join(carpeta, f"f{f:04d}.png") if os.environ.get("SEGUIR") and os.path.exists(destino): continue sc.frame_set(f) actualizar(f) sc.render.filepath = destino bpy.ops.render.render(write_still=True) hechos += 1 if hechos % 25 == 0: d = time.time() - t0 print(f" [{nombre}] frame {f}/{z} {d / hechos:.2f}s/frame " f"faltan {(len(frames) - hechos) * d / hechos / 60:.1f} min", flush=True) print(f"[{nombre}] {hechos} frames en {(time.time() - t0) / 60:.1f} min", flush=True) # --- texto 3D ----------------------------------------------------------------- FUENTE_RUTA = "/usr/share/fonts/TTF/Roboto-Bold.ttf" _fuente = None def texto(cuerpo, tam=0.18, color="blanco", align='CENTER', emis=1.8, plano_xz=True): """Texto plano, emisivo, en el plano XZ (para camaras que miran por -Y).""" global _fuente if _fuente is None: _fuente = bpy.data.fonts.load(FUENTE_RUTA) cu = bpy.data.curves.new("txt", 'FONT') cu.body = cuerpo cu.font = _fuente cu.size = tam cu.align_x = align cu.align_y = 'CENTER' ob = objeto("txt", cu, material(f"m_txt_{color}", color, emis=emis, rug=0.6)) if plano_xz: ob.rotation_euler = (math.pi / 2, 0, 0) return ob def cilindro(nombre, radio, largo, mat=None, lados=20): """Cilindro centrado en el origen, eje +Z, para reubicar por frame.""" verts, faces = [], [] for i in range(lados): a = 2 * math.pi * i / lados verts.append((radio * math.cos(a), radio * math.sin(a), -largo / 2)) verts.append((radio * math.cos(a), radio * math.sin(a), largo / 2)) for i in range(lados): j = (i + 1) % lados faces.append((2 * i, 2 * j, 2 * j + 1, 2 * i + 1)) tapa_a = [2 * i for i in range(lados)][::-1] tapa_b = [2 * i + 1 for i in range(lados)] faces += [tuple(tapa_a), tuple(tapa_b)] me = malla_de(nombre, verts, faces, suave=False) for p in me.polygons[:lados]: p.use_smooth = True return objeto(nombre, me, mat) def esfera(nombre, radio, mat=None, seg=24, anillos=14): verts, faces = [], [] for i in range(1, anillos): phi = math.pi * i / anillos for j in range(seg): th = 2 * math.pi * j / seg verts.append((radio * math.sin(phi) * math.cos(th), radio * math.sin(phi) * math.sin(th), radio * math.cos(phi))) norte = len(verts); verts.append((0, 0, radio)) sur = len(verts); verts.append((0, 0, -radio)) for i in range(anillos - 2): for j in range(seg): a = i * seg + j b = i * seg + (j + 1) % seg faces.append((a, b, b + seg, a + seg)) for j in range(seg): faces.append((norte, (j + 1) % seg, j)) faces.append((sur, (anillos - 2) * seg + j, (anillos - 2) * seg + (j + 1) % seg)) return objeto(nombre, malla_de(nombre, verts, faces), mat) def orientar(ob, desde, hasta): """Coloca un cilindro creado con cilindro() entre dos puntos.""" from mathutils import Vector a, b = Vector(desde), Vector(hasta) ob.location = (a + b) / 2 d = b - a ob.rotation_euler = d.to_track_quat('Z', 'Y').to_euler() def toro(nombre, R, r, mat=None, u=64, v=24): verts, faces = [], [] for i in range(u): a = 2 * math.pi * i / u ca, sa = math.cos(a), math.sin(a) for j in range(v): b = 2 * math.pi * j / v rr = R + r * math.cos(b) verts.append((rr * ca, rr * sa, r * math.sin(b))) for i in range(u): i2 = (i + 1) % u for j in range(v): j2 = (j + 1) % v faces.append((i * v + j, i2 * v + j, i2 * v + j2, i * v + j2)) return objeto(nombre, malla_de(nombre, verts, faces), mat) HOLGURA = 0.55 # 0.5 = sin juego; un poco mas deja luz entre flancos def engranaje_conico(nombre, eje, N=18, gamma=45.0, d_i=0.50, d_o=0.70, alto=0.055, mat=None, nd=10, por_diente=10, fase=0.0, espiral=0.0, espesor=0.10, hueco=0.0): """Engranaje conico con el apice en el origen y eje 'eje'. gamma = semiangulo del cono de paso. Dos engranajes engranan a 90 grados cuando sus gammas suman 90: 45+45 para los del diferencial, 72+18 para corona y pinon. espiral = torsion del diente a lo largo de la generatriz (conico espiral, que es lo que se usa de verdad; en recto se ve de juguete). espesor = cuanto se extruye el cuerpo hacia atras, para que sea un solido y no una cascara. """ from mathutils import Vector a = Vector(eje).normalized() u = Vector((0, 0, 1)) if abs(a.z) < 0.9 else Vector((1, 0, 0)) u = (u - a * u.dot(a)).normalized() v = a.cross(u) cg, sg = math.cos(math.radians(gamma)), math.sin(math.radians(gamma)) nphi = N * por_diente verts, faces = [], [] def perfil(x): return max(0.0, min(1.0, (math.cos(x) + 0.30) / 0.60)) def punto(d, phi, h): rad = u * math.cos(phi) + v * math.sin(phi) base = (a * cg + rad * sg) * d nrm = rad * cg - a * sg return base + nrm * h for i in range(nd + 1): s_ = i / nd d = d_i + (d_o - d_i) * s_ for j in range(nphi): phi = 2 * math.pi * j / nphi # el diente arranca bajo en el extremo interior y crece hacia afuera # el diente se apaga en el borde interior: si no, el cierre del # cuerpo queda como un abanico de aletas # el diente va mitad por encima y mitad por debajo del cono de paso: # asi el de enfrente entra en el hueco en vez de atravesar el cuerpo h = alto * (perfil(N * phi + fase + espiral * s_) - HOLGURA) * (s_ ** 0.8) p = punto(d, phi, h) verts.append((p.x, p.y, p.z)) for i in range(nd): for j in range(nphi): j2 = (j + 1) % nphi faces.append((i * nphi + j, i * nphi + j2, (i + 1) * nphi + j2, (i + 1) * nphi + j)) # cuerpo: se extruye el borde exterior hacia atras y se cierra con el fondo o_ext = nd * nphi base_ext = len(verts) for j in range(nphi): phi = 2 * math.pi * j / nphi # se extruye desde el diente, no desde el cono: asi el perfil llega a la # cara exterior como en un engranaje de verdad h = alto * (perfil(N * phi + fase + espiral) - HOLGURA) # el cuerpo va del lado de atras (lejos del apice), no hacia el engranaje # de enfrente p = punto(d_o, phi, h) + a * espesor verts.append((p.x, p.y, p.z)) for j in range(nphi): j2 = (j + 1) % nphi faces.append((o_ext + j, o_ext + j2, base_ext + j2, base_ext + j)) base_int = len(verts) r_h = max(hueco, 0.02) for j in range(nphi): phi = 2 * math.pi * j / nphi rad = u * math.cos(phi) + v * math.sin(phi) p = rad * r_h + a * (d_i * cg) + a * espesor * 0.35 verts.append((p.x, p.y, p.z)) for j in range(nphi): j2 = (j + 1) % nphi faces.append((base_ext + j, base_ext + j2, base_int + j2, base_int + j)) faces.append((j2, j, base_int + j, base_int + j2)) me = malla_de(nombre, verts, faces, suave=False) import bmesh bm = bmesh.new() bm.from_mesh(me) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me) bm.free() ob = objeto(nombre, me, mat) # la cara dentada suave (es una superficie continua, como un engranaje # mecanizado); el cuerpo plano, para que se lean los cantos for k, pol in enumerate(me.polygons): pol.use_smooth = k < nd * nphi return ob def anillo_plano(nombre, r_int, r_ext, espesor, mat=None, lados=64): """Anillo macizo (brida, corona de tornillos, llanta).""" verts, faces = [], [] for j in range(lados): ang = 2 * math.pi * j / lados c, s_ = math.cos(ang), math.sin(ang) for (r, z) in ((r_int, -espesor / 2), (r_ext, -espesor / 2), (r_ext, espesor / 2), (r_int, espesor / 2)): verts.append((r * c, r * s_, z)) for j in range(lados): j2 = (j + 1) % lados for k in range(4): k2 = (k + 1) % 4 faces.append((j * 4 + k, j2 * 4 + k, j2 * 4 + k2, j * 4 + k2)) return objeto(nombre, malla_de(nombre, verts, faces, suave=False), mat) def neumatico(nombre, R, r, mat=None, nu=72, nv=28, tacos=26, prof=0.055): """Toro con banda de rodadura: tacos y dos canales longitudinales.""" verts, faces = [], [] for i in range(nu): aa = 2 * math.pi * i / nu ca, sa = math.cos(aa), math.sin(aa) for j in range(nv): b = 2 * math.pi * j / nv cb, sb = math.cos(b), math.sin(b) rr = r if abs(sb) < 0.72: # zona de rodadura taco = 0.5 + 0.5 * math.cos(tacos * aa + 3.0 * b) canal = 1.0 if abs(sb) > 0.22 and abs(sb) < 0.5 else 0.0 rr -= prof * (0.45 * (taco < 0.45) + 0.55 * canal) verts.append(((R + rr * cb) * ca, (R + rr * cb) * sa, rr * sb)) for i in range(nu): i2 = (i + 1) % nu for j in range(nv): j2 = (j + 1) % nv faces.append((i * nv + j, i2 * nv + j, i2 * nv + j2, i * nv + j2)) return objeto(nombre, malla_de(nombre, verts, faces), mat) def mundo_estudio(fuerza=0.55, arriba="#8FA6C4", abajo="#10141F"): """Gradiente de horizonte en el mundo. No se ve (el film es transparente) pero es lo que el metal refleja: sin esto el acero queda plano.""" w = bpy.data.worlds.new("estudio") w.use_nodes = True nt = w.node_tree for n in list(nt.nodes): if n.type != 'OUTPUT_WORLD': nt.nodes.remove(n) sal = next(n for n in nt.nodes if n.type == 'OUTPUT_WORLD') fondo = nt.nodes.new("ShaderNodeBackground") geo = nt.nodes.new("ShaderNodeNewGeometry") sep = nt.nodes.new("ShaderNodeSeparateXYZ") mapa = nt.nodes.new("ShaderNodeMapRange") rampa = nt.nodes.new("ShaderNodeValToRGB") mapa.inputs[1].default_value = -0.45 mapa.inputs[2].default_value = 0.85 rampa.color_ramp.elements[0].color = (*srgb(abajo), 1) rampa.color_ramp.elements[1].color = (*srgb(arriba), 1) rampa.color_ramp.elements[0].position = 0.15 rampa.color_ramp.elements[1].position = 0.92 nt.links.new(geo.outputs["Incoming"], sep.inputs[0]) nt.links.new(sep.outputs["Z"], mapa.inputs[0]) nt.links.new(mapa.outputs[0], rampa.inputs[0]) nt.links.new(rampa.outputs["Color"], fondo.inputs[0]) fondo.inputs[1].default_value = fuerza nt.links.new(fondo.outputs[0], sal.inputs[0]) bpy.context.scene.world = w return w def metal(nombre, color="#B9C0CC", rug=0.26, met=1.0): return material(nombre, color, rug=rug, metal=met) def rueda_completa(nombre, mats, R=0.36, r=0.135, ancho=0.20, radios=5): """Neumatico con tacos + llanta de aleacion con radios + disco de freno. Devuelve el grupo, con el eje de giro en X.""" g = bpy.data.objects.new(nombre, None) bpy.context.collection.objects.link(g) goma = neumatico(f"{nombre}_goma", R, r, mats["goma"]) goma.rotation_euler = (0, math.pi / 2, 0) goma.parent = g r_int = R - r * 0.72 aro = anillo_plano(f"{nombre}_aro", r_int - 0.045, r_int + 0.02, ancho, mats["alu"]) aro.rotation_euler = (0, math.pi / 2, 0) aro.parent = g cubo = cilindro(f"{nombre}_cubo", 0.085, ancho * 0.9, mats["alu"], lados=24) cubo.rotation_euler = (0, math.pi / 2, 0) cubo.parent = g for k in range(radios): ang = 2 * math.pi * k / radios d = (0.0, math.cos(ang), math.sin(ang)) rad = cilindro(f"{nombre}_r{k}", 0.040, 1.0, mats["alu"], lados=12) orientar(rad, tuple(c * 0.075 for c in d), tuple(c * (r_int - 0.02) for c in d)) rad.scale = (1, 1, (r_int - 0.02) - 0.075) rad.parent = g disco = cilindro(f"{nombre}_disco", r_int - 0.09, 0.035, mats["freno"], lados=40) disco.rotation_euler = (0, math.pi / 2, 0) disco.location = (-ancho * 0.55, 0, 0) disco.parent = g return g def tornillos(nombre, n, radio, eje_x, largo=0.05, r_t=0.028, mat=None, padre=None): """Corona de bulones sobre una brida.""" g = bpy.data.objects.new(nombre, None) bpy.context.collection.objects.link(g) for k in range(n): ang = 2 * math.pi * k / n t = cilindro(f"{nombre}_{k}", r_t, largo, mat, lados=8) t.rotation_euler = (0, math.pi / 2, 0) t.location = (eje_x, radio * math.cos(ang), radio * math.sin(ang)) t.parent = g if padre: g.parent = padre return g def sector_anillo(nombre, r_int, r_ext, espesor, ang0, ang1, mat=None, lados=28): """Pared curva: un sector de corona, con espesor a lo largo de su eje. Sirve para armar una caja de diferencial con ventanas.""" verts, faces = [], [] n = max(3, lados) for j in range(n + 1): ang = ang0 + (ang1 - ang0) * j / n c, s_ = math.cos(ang), math.sin(ang) for (r, z) in ((r_int, -espesor / 2), (r_ext, -espesor / 2), (r_ext, espesor / 2), (r_int, espesor / 2)): verts.append((r * c, r * s_, z)) for j in range(n): for k in range(4): k2 = (k + 1) % 4 faces.append((j * 4 + k, (j + 1) * 4 + k, (j + 1) * 4 + k2, j * 4 + k2)) faces.append((0, 3, 2, 1)) o = n * 4 faces.append((o, o + 1, o + 2, o + 3)) return objeto(nombre, malla_de(nombre, verts, faces, suave=False), mat) # --- madera procedural ---------------------------------------------------------- def madera(nombre, claro="#9A6A40", oscuro="#4E2E17", escala=4.0, rug=0.42, veta=(1, 1, 9)): """Veta de madera: ondas distorsionadas por ruido, estiradas en un eje.""" m = bpy.data.materials.new(nombre) m.use_nodes = True nt = m.node_tree b = nt.nodes.get("Principled BSDF") tc = nt.nodes.new("ShaderNodeTexCoord") mp = nt.nodes.new("ShaderNodeMapping") mp.inputs["Scale"].default_value = veta ola = nt.nodes.new("ShaderNodeTexWave") ola.wave_type = 'RINGS' ola.inputs["Scale"].default_value = escala ola.inputs["Distortion"].default_value = 7.0 ola.inputs["Detail"].default_value = 4.0 ola.inputs["Detail Scale"].default_value = 1.6 rampa = nt.nodes.new("ShaderNodeValToRGB") rampa.color_ramp.elements[0].color = (*srgb(oscuro), 1) rampa.color_ramp.elements[1].color = (*srgb(claro), 1) rampa.color_ramp.elements[0].position = 0.25 rampa.color_ramp.elements[1].position = 0.85 nt.links.new(tc.outputs["Object"], mp.inputs["Vector"]) nt.links.new(mp.outputs["Vector"], ola.inputs["Vector"]) nt.links.new(ola.outputs["Fac"], rampa.inputs["Fac"]) nt.links.new(rampa.outputs["Color"], b.inputs["Base Color"]) poner(b, "Roughness", rug) bump = nt.nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.08 nt.links.new(ola.outputs["Fac"], bump.inputs["Height"]) nt.links.new(bump.outputs["Normal"], b.inputs["Normal"]) return m def caja(nombre, tam, loc=(0, 0, 0), mat=None, rot=(0, 0, 0)): """Prisma con el origen en su centro (asi Godot saca la caja de colision directamente del tamano de la malla).""" sx, sy, sz = (t / 2 for t in tam) verts = [(x, y, z) for x in (-sx, sx) for y in (-sy, sy) for z in (-sz, sz)] faces = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] ob = objeto(nombre, malla_de(nombre, verts, faces, suave=False), mat) ob.location = loc ob.rotation_euler = rot return ob # --- puente con Godot: Blender disena, Godot simula ------------------------------ GODOT = os.environ.get("GODOT", "godot") GODOT_PROY = os.path.join(ROOT, "godot") def exportar_fisica(nombre, objetos, cfg): """Exporta a .glb solo los objetos que participan de la fisica (el nombre define el tipo de cuerpo, ver godot/sim.gd) y escribe el config.""" carpeta = os.path.join(GODOT_PROY, "trabajo") os.makedirs(carpeta, exist_ok=True) glb = os.path.join(carpeta, f"{nombre}.glb") for ob in bpy.context.scene.objects: ob.select_set(False) for ob in objetos: ob.select_set(True) bpy.context.view_layer.objects.active = objetos[0] bpy.ops.export_scene.gltf(filepath=glb, export_format='GLB', use_selection=True, export_yup=True, export_apply=True, export_materials='NONE', export_animations=False) cfg = dict(cfg, glb=glb, salida=os.path.join(carpeta, nombre)) ruta = os.path.join(carpeta, f"{nombre}.json") with open(ruta, "w") as f: json.dump(cfg, f, indent=1) print(f"[{nombre}] {len(objetos)} objetos -> {glb}", flush=True) return ruta def correr_godot(ruta_cfg): import subprocess, time t0 = time.time() r = subprocess.run([GODOT, "--headless", "--path", GODOT_PROY, "--fixed-fps", "240", "--", ruta_cfg], capture_output=True, text=True) for l in (r.stdout + r.stderr).splitlines(): if "[sim]" in l or "ERROR" in l or "SCRIPT" in l: print(" godot:", l, flush=True) if r.returncode != 0: raise SystemExit(f"godot fallo ({r.returncode})") print(f" godot tardo {time.time() - t0:.1f}s", flush=True) def cargar_sim(nombre): """-> (dict nombre->indice, array [frames, cuerpos, 7]) en coords de Blender.""" import numpy as np base_ = os.path.join(GODOT_PROY, "trabajo", nombre) cab = json.load(open(base_ + ".cab.json")) datos = np.fromfile(base_ + ".bin", dtype=np.float32) n = len(cab["nombres"]) datos = datos.reshape(-1, n, 7) return {k: i for i, k in enumerate(cab["nombres"])}, datos def poner_pose(ob, fila): ob.rotation_mode = 'QUATERNION' ob.location = (float(fila[0]), float(fila[1]), float(fila[2])) ob.rotation_quaternion = (float(fila[3]), float(fila[4]), float(fila[5]), float(fila[6]))