aboutsummaryrefslogtreecommitdiffstats
path: root/blender/base.py
diff options
context:
space:
mode:
Diffstat (limited to 'blender/base.py')
-rw-r--r--blender/base.py659
1 files changed, 659 insertions, 0 deletions
diff --git a/blender/base.py b/blender/base.py
new file mode 100644
index 0000000..385eb0c
--- /dev/null
+++ b/blender/base.py
@@ -0,0 +1,659 @@
+# -*- 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/<nombre>_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]))