diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 20:50:41 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 20:50:41 -0300 |
| commit | fafaebb051907a848a9406f9da19669c81a83a3b (patch) | |
| tree | c30ea26e6b549e5523af2bae5c39569e9a946b12 /blender/base.py | |
| parent | 59355909f2de9236af8168a26c70bcf6caa3b285 (diff) | |
| download | 100cia-videos-main.tar.gz 100cia-videos-main.zip | |
Translate code, comments and logs to English; English README; configurable paths and env varsHEADmain
Identifiers, docstrings, comments and console messages are now in English.
Narration, subtitles and on-screen text stay in Spanish (they are the video
content). The Blender <-> Godot physics protocol uses English keys and body
prefixes chosen to keep the original creation order, so cached simulations and
renders stay bit-identical. The old Spanish environment variable names are still
accepted.
Diffstat (limited to 'blender/base.py')
| -rw-r--r-- | blender/base.py | 595 |
1 files changed, 301 insertions, 294 deletions
diff --git a/blender/base.py b/blender/base.py index 385eb0c..74b23be 100644 --- a/blender/base.py +++ b/blender/base.py @@ -1,29 +1,36 @@ # -*- coding: utf-8 -*- -"""Base comun para las escenas 3D del canal (Blender 5.2, EEVEE, headless). +"""Common base for the channel's 3D scenes (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. +Key idea: no keyframes are used. The render walks the frames one by one and +calls a refresh(f) function before each one that places everything. That way +physics and geometry are computed exactly on every frame and there is no +fighting with the layered actions API of 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. +Timing comes from out/<name>_timeline.json, the same file build_video.py uses +to place the audio: sync holds by construction. """ import json, math, os, sys import bpy -ROOT = os.environ.get("PROY", "/tmp/Firefox/videos-t") + +def env(name, old=None, default=None): + """Environment variable, also accepting its older Spanish name.""" + return os.environ.get(name, os.environ.get(old, default) if old else default) + + +# project root: defaults to the directory above this file +ROOT = env("PROJECT", "PROY", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) FPS = 30 W, H = 1080, 1920 -# --- paleta del canal (sRGB) -------------------------------------------------- +# --- channel palette (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.""" + """sRGB hex -> linear, which is what Blender expects in materials.""" def c(v): v /= 255.0 return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 @@ -31,73 +38,73 @@ def srgb(h): 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.""" +def scene_setup(samples=None): + """Clean vertical scene, EEVEE, transparent background and no tonemapping.""" 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.eevee.taa_render_samples = int(samples or env("SAMPLES", "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.film_transparent = True # the dotted background is composited separately 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. + # 'Standard' keeps colors exactly as written; AgX would wash them out and the + # channel would end up with a different palette from the Manim videos. 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 + world_obj = bpy.data.worlds.new("mundo") + world_obj.use_nodes = True + world_obj.node_tree.nodes["Background"].inputs[0].default_value = (*srgb("fondo"), 1) + world_obj.node_tree.nodes["Background"].inputs[1].default_value = 0.45 + sc.world = world_obj return sc -def poner(nodo, nombre, valor): - """setattr/socket tolerante: avisa en vez de romper si cambio el nombre.""" +def put(node, obj_name, valor): + """Tolerant setattr/socket: warns instead of breaking if a name changed.""" try: - if nombre in nodo.inputs: - nodo.inputs[nombre].default_value = valor + if obj_name in node.inputs: + node.inputs[obj_name].default_value = valor return True except Exception: pass - print(f" aviso: no existe la entrada '{nombre}'") + print(f" warning: input '{obj_name}' does not exist") return False -def material(nombre, color, rug=0.35, metal=0.0, emis=0.0, alpha=1.0): - m = bpy.data.materials.new(nombre) +def material(obj_name, color, rough=0.35, metal=0.0, emit=0.0, alpha=1.0): + m = bpy.data.materials.new(obj_name) 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) + put(b, "Base Color", col) + put(b, "Roughness", rough) + put(b, "Metallic", metal) + if emit: + put(b, "Emission Color", col) + put(b, "Emission Strength", emit) if alpha < 1.0: - poner(b, "Alpha", alpha) + put(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) +def make_object(obj_name, mesh_obj, mat=None, collection_obj=None): + ob = bpy.data.objects.new(obj_name, mesh_obj) + (collection_obj 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) +def mesh_from(obj_name, verts, faces, suave=True): + me = bpy.data.meshes.new(obj_name) me.from_pydata(verts, [], faces) me.update() if suave: @@ -106,11 +113,11 @@ def malla_de(nombre, verts, faces, suave=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') +def curve_poly(obj_name, splines, thickness_px=0.01, radios=None, mat=None): + """A curve with several POLY splines; optional per-point radii.""" + cu = bpy.data.curves.new(obj_name, 'CURVE') cu.dimensions = '3D' - cu.bevel_depth = grosor + cu.bevel_depth = thickness_px cu.bevel_resolution = 1 cu.use_fill_caps = True for k, pts in enumerate(splines): @@ -120,11 +127,11 @@ def curva_poly(nombre, splines, grosor=0.01, radios=None, mat=None): 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) + return make_object(obj_name, cu, mat) -def rehacer_curva(ob, splines, radios=None): - """Reescribe los puntos de una curva ya creada (misma cantidad de puntos).""" +def rebuild_curve(ob, splines, radios=None): + """Rewrites the points of an existing curve (same number of points).""" 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) @@ -132,54 +139,54 @@ def rehacer_curva(ob, splines, radios=None): sp.points[i].radius = radios[k][i] -def camara(loc, mira=(0, 0, 0), lente=50): +def camera_obj(loc, sight=(0, 0, 0), lens=50): cd = bpy.data.cameras.new("cam") - cd.lens = lente + cd.lens = lens cam = bpy.data.objects.new("cam", cd) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam - apuntar(cam, loc, mira) + aim_at(cam, loc, sight) return cam -def apuntar(ob, loc, mira): - """Coloca ob en loc mirando a 'mira' (convencion de camara: -Z adelante).""" +def aim_at(ob, loc, sight): + """Places ob at loc looking at 'sight' (camera convention: -Z forward).""" from mathutils import Vector ob.location = loc - d = Vector(mira) - Vector(loc) + d = Vector(sight) - 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 +def light_obj(obj_name, kind_m, loc, energy_val, color="blanco", size_u=2.0, sight=None): + ld = bpy.data.lights.new(obj_name, kind_m) + ld.energy = energy_val 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) + if kind_m == 'AREA': + ld.size = size_u + if kind_m == 'POINT': + ld.shadow_soft_size = size_u + ob = bpy.data.objects.new(obj_name, ld) bpy.context.collection.objects.link(ob) - if mira is not None: - apuntar(ob, loc, mira) + if sight is not None: + aim_at(ob, loc, sight) else: ob.location = loc return ob -# --- tiempo ------------------------------------------------------------------- -class Tiempo: - """Traduce el timeline del canal a frames.""" +# --- timing ------------------------------------------------------------------- +class Timeline: + """Turns the channel timeline into frames.""" - def __init__(self, nombre): - with open(os.path.join(ROOT, "out", f"{nombre}_timeline.json")) as f: + def __init__(self, obj_name): + with open(os.path.join(ROOT, "out", f"{obj_name}_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.""" + def span(self, i): + """(first frame, last frame) of beat i, 1-based inclusive.""" b = self.beats[i] a = int(round(b["start"] * FPS)) + 1 z = int(round((b["start"] + b["dur"]) * FPS)) @@ -189,8 +196,8 @@ class Tiempo: 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) + """Progress 0..1 within beat i (outside the beat: 0 before, 1 after).""" + a, z = self.span(i) if f <= a: return 0.0 if f >= z: @@ -203,111 +210,111 @@ def suave(x): return x * x * (3 - 2 * x) -def mezcla(a, b, x): +def mix_m(a, b, x): return a + (b - a) * suave(x) # --- render ------------------------------------------------------------------- -def render_secuencia(nombre, tiempo, actualizar, desde=None, hasta=None): +def render_sequence(obj_name, elapsed_t, refresh, since=None, until=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) + folder = os.path.join(ROOT, "render", obj_name) + os.makedirs(folder, exist_ok=True) + a = int(since or env("FROM_FRAME", "DESDE", 1)) + z = int(until or env("TO_FRAME", "HASTA", elapsed_t.n_frames)) + jump = env("EVERY", "SALTO") # for tests: 1 out of every N + frames = range(a, z + 1, int(jump) if jump else 1) import time t0 = time.time() - hechos = 0 + done = 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): + target = os.path.join(folder, f"f{f:04d}.png") + if env("RESUME", "SEGUIR") and os.path.exists(target): continue sc.frame_set(f) - actualizar(f) - sc.render.filepath = destino + refresh(f) + sc.render.filepath = target bpy.ops.render.render(write_still=True) - hechos += 1 - if hechos % 25 == 0: + done += 1 + if done % 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) + print(f" [{obj_name}] frame {f}/{z} {d / done:.2f}s/frame " + f"faltan {(len(frames) - done) * d / done / 60:.1f} min", flush=True) + print(f"[{obj_name}] {done} frames in {(time.time() - t0) / 60:.1f} min", flush=True) -# --- texto 3D ----------------------------------------------------------------- -FUENTE_RUTA = "/usr/share/fonts/TTF/Roboto-Bold.ttf" -_fuente = None +# --- 3D text ----------------------------------------------------------------- +FONT_PATH = "/usr/share/fonts/TTF/Roboto-Bold.ttf" +_font = 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) +def txt_m(body_obj, size_u=0.18, color="blanco", align='CENTER', emit=1.8, plano_xz=True): + """Flat emissive text on the XZ plane (for cameras looking along -Y).""" + global _font + if _font is None: + _font = bpy.data.fonts.load(FONT_PATH) cu = bpy.data.curves.new("txt", 'FONT') - cu.body = cuerpo - cu.font = _fuente - cu.size = tam + cu.body = body_obj + cu.font = _font + cu.size = size_u cu.align_x = align cu.align_y = 'CENTER' - ob = objeto("txt", cu, material(f"m_txt_{color}", color, emis=emis, rug=0.6)) + ob = make_object("txt", cu, material(f"m_txt_{color}", color, emit=emit, rough=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.""" +def cylinder(obj_name, radio, largo, mat=None, sides=20): + """Cylinder centred on the origin, +Z axis, to be repositioned per frame.""" verts, faces = [], [] - for i in range(lados): - a = 2 * math.pi * i / lados + for i in range(sides): + a = 2 * math.pi * i / sides 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 + for i in range(sides): + j = (i + 1) % sides 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]: + lid_a = [2 * i for i in range(sides)][::-1] + lid_b = [2 * i + 1 for i in range(sides)] + faces += [tuple(lid_a), tuple(lid_b)] + me = mesh_from(obj_name, verts, faces, suave=False) + for p in me.polygons[:sides]: p.use_smooth = True - return objeto(nombre, me, mat) + return make_object(obj_name, me, mat) -def esfera(nombre, radio, mat=None, seg=24, anillos=14): +def sphere(obj_name, radio, mat=None, seg_m=24, rings=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 + for i in range(1, rings): + phi = math.pi * i / rings + for j in range(seg_m): + th = 2 * math.pi * j / seg_m 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.""" + north = len(verts); verts.append((0, 0, radio)) + south = len(verts); verts.append((0, 0, -radio)) + for i in range(rings - 2): + for j in range(seg_m): + a = i * seg_m + j + b = i * seg_m + (j + 1) % seg_m + faces.append((a, b, b + seg_m, a + seg_m)) + for j in range(seg_m): + faces.append((north, (j + 1) % seg_m, j)) + faces.append((south, (rings - 2) * seg_m + j, (rings - 2) * seg_m + (j + 1) % seg_m)) + return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) + + +def orient(ob, since, until): + """Places a cylinder created with cylinder() between two points.""" from mathutils import Vector - a, b = Vector(desde), Vector(hasta) + a, b = Vector(since), Vector(until) 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): +def torus(obj_name, R, r, mat=None, u=64, v=24): verts, faces = [], [] for i in range(u): a = 2 * math.pi * i / u @@ -321,38 +328,38 @@ def toro(nombre, R, r, mat=None, u=64, v=24): 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) + return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) -HOLGURA = 0.55 # 0.5 = sin juego; un poco mas deja luz entre flancos +SLACK = 0.55 # 0.5 = no play; a bit more leaves light between flanks -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'. +def bevel_gear(obj_name, axis_obj, N=18, gamma=45.0, d_i=0.50, d_o=0.70, + alto=0.055, mat=None, nd=10, per_tooth=10, phase=0.0, + spiral=0.0, thickness=0.10, gap_m=0.0): + """Bevel gear with its apex at the origin and axis 'axis_obj'. - 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. + gamma = half-angle of the pitch cone. Two gears mesh at 90 degrees + when their gammas add up to 90: 45+45 for the differential + ones, 72+18 for ring gear and pinion. + spiral = twist of the tooth along the generatrix (spiral bevel, which + is what is really used; straight teeth look like a toy). + thickness = how far the body is extruded backwards, so it is a solid and + not a shell. """ from mathutils import Vector - a = Vector(eje).normalized() + a = Vector(axis_obj).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 + nphi = N * per_tooth verts, faces = [], [] - def perfil(x): + def profile(x): return max(0.0, min(1.0, (math.cos(x) + 0.30) / 0.60)) - def punto(d, phi, h): + def point(d, phi, h): rad = u * math.cos(phi) + v * math.sin(phi) base = (a * cg + rad * sg) * d nrm = rad * cg - a * sg @@ -363,79 +370,79 @@ def engranaje_conico(nombre, eje, N=18, gamma=45.0, d_i=0.50, d_o=0.70, 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) + # the tooth starts low at the inner end and grows outwards + # the tooth fades at the inner edge: otherwise the body closure + # looks like a fan of fins + # the tooth sits half above and half below the pitch cone: + # that way the opposite gear goes into the gap instead of through the body + h = alto * (profile(N * phi + phase + spiral * s_) - SLACK) * (s_ ** 0.8) + p = point(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 + # body: the outer edge is extruded backwards and closed with the bottom 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 + # extruded from the tooth, not from the cone: that way the profile reaches + # the outer face like on a real gear + h = alto * (profile(N * phi + phase + spiral) - SLACK) + # the body goes on the back side (away from the apex), not towards the + # opposite gear + p = point(d_o, phi, h) + a * thickness 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) + r_h = max(gap_m, 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 + p = rad * r_h + a * (d_i * cg) + a * thickness * 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) + me = mesh_from(obj_name, 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 + ob = make_object(obj_name, me, mat) + # the toothed face smooth (it is a continuous surface, like a machined + # gear); the body flat, so the edges read 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).""" +def flat_ring(obj_name, r_int, r_ext, thickness, mat=None, sides=64): + """Solid ring (flange, bolt circle, rim).""" verts, faces = [], [] - for j in range(lados): - ang = 2 * math.pi * j / lados + for j in range(sides): + ang = 2 * math.pi * j / sides 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)): + for (r, z) in ((r_int, -thickness / 2), (r_ext, -thickness / 2), + (r_ext, thickness / 2), (r_int, thickness / 2)): verts.append((r * c, r * s_, z)) - for j in range(lados): - j2 = (j + 1) % lados + for j in range(sides): + j2 = (j + 1) % sides 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) + return make_object(obj_name, mesh_from(obj_name, 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.""" +def tire(obj_name, R, r, mat=None, nu=72, nv=28, lugs=26, depth=0.055): + """Torus with a tread: lugs and two longitudinal grooves.""" verts, faces = [], [] for i in range(nu): aa = 2 * math.pi * i / nu @@ -444,108 +451,108 @@ def neumatico(nombre, R, r, mat=None, nu=72, nv=28, tacos=26, prof=0.055): 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) + if abs(sb) < 0.72: # tread area + lug = 0.5 + 0.5 * math.cos(lugs * aa + 3.0 * b) + groove = 1.0 if abs(sb) > 0.22 and abs(sb) < 0.5 else 0.0 + rr -= depth * (0.45 * (lug < 0.45) + 0.55 * groove) 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) + return make_object(obj_name, mesh_from(obj_name, 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.""" +def studio_world(force=0.55, above="#8FA6C4", below="#10141F"): + """Horizon gradient in the world. It is not visible (the film is transparent) + but it is what metal reflects: without it steel looks flat.""" 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") + out_node = next(n for n in nt.nodes if n.type == 'OUTPUT_WORLD') + background = 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 + map_obj = nt.nodes.new("ShaderNodeMapRange") + ramp = nt.nodes.new("ShaderNodeValToRGB") + map_obj.inputs[1].default_value = -0.45 + map_obj.inputs[2].default_value = 0.85 + ramp.color_ramp.elements[0].color = (*srgb(below), 1) + ramp.color_ramp.elements[1].color = (*srgb(above), 1) + ramp.color_ramp.elements[0].position = 0.15 + ramp.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]) + nt.links.new(sep.outputs["Z"], map_obj.inputs[0]) + nt.links.new(map_obj.outputs[0], ramp.inputs[0]) + nt.links.new(ramp.outputs["Color"], background.inputs[0]) + background.inputs[1].default_value = force + nt.links.new(background.outputs[0], out_node.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 metal(obj_name, color="#B9C0CC", rough=0.26, met=1.0): + return material(obj_name, color, rough=rough, 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) +def full_wheel(obj_name, mats, R=0.36, r=0.135, width_px=0.20, radios=5): + """Tire with lugs + alloy rim with spokes + brake disc. + Returns the group, with the spin axis along X.""" + g = bpy.data.objects.new(obj_name, 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 + rubber = tire(f"{obj_name}_goma", R, r, mats["goma"]) + rubber.rotation_euler = (0, math.pi / 2, 0) + rubber.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 + hoop = flat_ring(f"{obj_name}_aro", r_int - 0.045, r_int + 0.02, width_px, mats["alu"]) + hoop.rotation_euler = (0, math.pi / 2, 0) + hoop.parent = g + cube = cylinder(f"{obj_name}_cubo", 0.085, width_px * 0.9, mats["alu"], sides=24) + cube.rotation_euler = (0, math.pi / 2, 0) + cube.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 = cylinder(f"{obj_name}_r{k}", 0.040, 1.0, mats["alu"], sides=12) + orient(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 = cylinder(f"{obj_name}_disco", r_int - 0.09, 0.035, mats["freno"], sides=40) disco.rotation_euler = (0, math.pi / 2, 0) - disco.location = (-ancho * 0.55, 0, 0) + disco.location = (-width_px * 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) +def screws(obj_name, n, radio, axis_x, largo=0.05, r_t=0.028, mat=None, padre=None): + """Bolt circle on a flange.""" + g = bpy.data.objects.new(obj_name, 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 = cylinder(f"{obj_name}_{k}", r_t, largo, mat, sides=8) t.rotation_euler = (0, math.pi / 2, 0) - t.location = (eje_x, radio * math.cos(ang), radio * math.sin(ang)) + t.location = (axis_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.""" +def ring_sector(obj_name, r_int, r_ext, thickness, ang0, ang1, mat=None, sides=28): + """Curved wall: a ring sector with thickness along its axis. + Used to build a differential housing with windows.""" verts, faces = [], [] - n = max(3, lados) + n = max(3, sides) 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)): + for (r, z) in ((r_int, -thickness / 2), (r_ext, -thickness / 2), + (r_ext, thickness / 2), (r_int, thickness / 2)): verts.append((r * c, r * s_, z)) for j in range(n): for k in range(4): @@ -554,106 +561,106 @@ def sector_anillo(nombre, r_int, r_ext, espesor, ang0, ang1, mat=None, lados=28) 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) + return make_object(obj_name, mesh_from(obj_name, 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) +def madera(obj_name, light_c="#9A6A40", dark="#4E2E17", scale_to=4.0, rough=0.42, grain=(1, 1, 9)): + """Wood grain: waves distorted by noise, stretched along one axis.""" + m = bpy.data.materials.new(obj_name) 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 + mp.inputs["Scale"].default_value = grain + wave_tex = nt.nodes.new("ShaderNodeTexWave") + wave_tex.wave_type = 'RINGS' + wave_tex.inputs["Scale"].default_value = scale_to + wave_tex.inputs["Distortion"].default_value = 7.0 + wave_tex.inputs["Detail"].default_value = 4.0 + wave_tex.inputs["Detail Scale"].default_value = 1.6 + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].color = (*srgb(dark), 1) + ramp.color_ramp.elements[1].color = (*srgb(light_c), 1) + ramp.color_ramp.elements[0].position = 0.25 + ramp.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) + nt.links.new(mp.outputs["Vector"], wave_tex.inputs["Vector"]) + nt.links.new(wave_tex.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"]) + put(b, "Roughness", rough) bump = nt.nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.08 - nt.links.new(ola.outputs["Fac"], bump.inputs["Height"]) + nt.links.new(wave_tex.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) +def box_obj(obj_name, size_u, loc=(0, 0, 0), mat=None, rot=(0, 0, 0)): + """Prism with its origin at the centre (so Godot takes the collision box + straight from the mesh size).""" + sx, sy, sz = (t / 2 for t in size_u) 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 = make_object(obj_name, mesh_from(obj_name, verts, faces, suave=False), mat) ob.location = loc ob.rotation_euler = rot return ob -# --- puente con Godot: Blender disena, Godot simula ------------------------------ +# --- bridge to Godot: Blender designs, Godot simulates ------------------------- GODOT = os.environ.get("GODOT", "godot") -GODOT_PROY = os.path.join(ROOT, "godot") +GODOT_PROJECT = 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 |