From 59355909f2de9236af8168a26c70bcf6caa3b285 Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 20:21:47 -0300 Subject: Import video pipeline as it was --- .gitignore | 10 + README.md | 252 +++++++++++++++++ blender/base.py | 659 ++++++++++++++++++++++++++++++++++++++++++++ blender/bloques.py | 279 +++++++++++++++++++ blender/bolapeluda.py | 260 +++++++++++++++++ blender/caos.py | 219 +++++++++++++++ blender/diferencial.py | 304 ++++++++++++++++++++ blender/domino.py | 539 ++++++++++++++++++++++++++++++++++++ blender/galton.py | 316 +++++++++++++++++++++ brand/README.md | 50 ++++ brand/avatar-1024.png | Bin 0 -> 88757 bytes brand/avatar-plano-1024.png | Bin 0 -> 87007 bytes brand/avatar-plano.svg | 5 + brand/avatar.svg | 3 + brand/banner-1920.png | Bin 0 -> 58751 bytes brand/banner.svg | 4 + brand/make_logo.py | 235 ++++++++++++++++ brand/wordmark-2400.png | Bin 0 -> 57299 bytes brand/wordmark-claro.svg | 3 + brand/wordmark-mono.svg | 3 + brand/wordmark-plano.svg | 5 + brand/wordmark.svg | 3 + godot/project.godot | 13 + godot/sim.gd | 223 +++++++++++++++ godot/sim.tscn | 6 + scenes/overlay.py | 43 +++ scenes/tiktok.py | 156 +++++++++++ scenes/v10_buffon.py | 150 ++++++++++ scenes/v11_collatz.py | 171 ++++++++++++ scenes/v12_simpson.py | 188 +++++++++++++ scenes/v13_cuerda.py | 161 +++++++++++ scenes/v14_gabriel.py | 172 ++++++++++++ scenes/v15_mobius.py | 241 ++++++++++++++++ scenes/v16_bayes.py | 160 +++++++++++ scenes/v17_cicloide.py | 275 ++++++++++++++++++ scenes/v18_dados.py | 189 +++++++++++++ scenes/v1_montyhall.py | 166 +++++++++++ scenes/v2_nueves.py | 121 ++++++++ scenes/v3_cumple.py | 143 ++++++++++ scenes/v4_luna.py | 178 ++++++++++++ scenes/v5_japones.py | 160 +++++++++++ scenes/v6_benford.py | 131 +++++++++ scenes/v7_hilbert.py | 135 +++++++++ scenes/v8_reuleaux.py | 158 +++++++++++ scenes/v9_regla72.py | 124 +++++++++ scripts/build_blender.py | 58 ++++ scripts/build_video.py | 93 +++++++ scripts/gen_audio.py | 119 ++++++++ scripts/music.py | 166 +++++++++++ scripts/narration.py | 431 +++++++++++++++++++++++++++++ scripts/numspell.py | 57 ++++ scripts/regen.py | 46 ++++ scripts/repick.py | 48 ++++ scripts/verify.py | 53 ++++ scripts/verify_final.py | 50 ++++ 55 files changed, 7734 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 blender/base.py create mode 100644 blender/bloques.py create mode 100644 blender/bolapeluda.py create mode 100644 blender/caos.py create mode 100644 blender/diferencial.py create mode 100644 blender/domino.py create mode 100644 blender/galton.py create mode 100644 brand/README.md create mode 100644 brand/avatar-1024.png create mode 100644 brand/avatar-plano-1024.png create mode 100644 brand/avatar-plano.svg create mode 100644 brand/avatar.svg create mode 100644 brand/banner-1920.png create mode 100644 brand/banner.svg create mode 100644 brand/make_logo.py create mode 100644 brand/wordmark-2400.png create mode 100644 brand/wordmark-claro.svg create mode 100644 brand/wordmark-mono.svg create mode 100644 brand/wordmark-plano.svg create mode 100644 brand/wordmark.svg create mode 100644 godot/project.godot create mode 100644 godot/sim.gd create mode 100644 godot/sim.tscn create mode 100644 scenes/overlay.py create mode 100644 scenes/tiktok.py create mode 100644 scenes/v10_buffon.py create mode 100644 scenes/v11_collatz.py create mode 100644 scenes/v12_simpson.py create mode 100644 scenes/v13_cuerda.py create mode 100644 scenes/v14_gabriel.py create mode 100644 scenes/v15_mobius.py create mode 100644 scenes/v16_bayes.py create mode 100644 scenes/v17_cicloide.py create mode 100644 scenes/v18_dados.py create mode 100644 scenes/v1_montyhall.py create mode 100644 scenes/v2_nueves.py create mode 100644 scenes/v3_cumple.py create mode 100644 scenes/v4_luna.py create mode 100644 scenes/v5_japones.py create mode 100644 scenes/v6_benford.py create mode 100644 scenes/v7_hilbert.py create mode 100644 scenes/v8_reuleaux.py create mode 100644 scenes/v9_regla72.py create mode 100644 scripts/build_blender.py create mode 100644 scripts/build_video.py create mode 100644 scripts/gen_audio.py create mode 100644 scripts/music.py create mode 100644 scripts/narration.py create mode 100644 scripts/numspell.py create mode 100644 scripts/regen.py create mode 100644 scripts/repick.py create mode 100644 scripts/verify.py create mode 100644 scripts/verify_final.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7da0c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Generated output: renders, audio, final videos and scratch space +/render/ +/out/ +/media/ +/tmp/ +/audio/ +/godot/trabajo/ +/godot/.godot/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcced52 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# 24 videos verticales para TikTok — 100% generados localmente + +Todo corre offline: **Manim** para la animación 2D, **Blender** (EEVEE, headless) para +las escenas 3D, **Godot** (motor de física Jolt, headless) para la física de +rígidos, **qwentts.cpp** (voz clonada rioplatense) para la locución y las +muestras de piano de `keysound` para la música. + +## Los videos (`out/`) + +| Archivo | Tema | Duración | Gancho | +|---|---|---|---| +| `montyhall.mp4` | Problema de Monty Hall | 39,5 s | genera debate en comentarios | +| `nueves.mp4` | 0,999… = 1 | 38,8 s | genera debate en comentarios | +| `cumple.mp4` | Paradoja del cumpleaños | 38,6 s | dato que sorprende | +| `luna.mp4` | Doblar un papel hasta la Luna | 38,4 s | dato que sorprende | +| `japones.mp4` | Multiplicar con líneas | 38,8 s | útil, se guarda y se comparte | +| `benford.mp4` | Ley de Benford (detectar fraude) | 42,8 s | dato que sorprende + útil | +| `hilbert.mp4` | El hotel infinito | 37,1 s | genera debate en comentarios | +| `reuleaux.mp4` | Por qué las tapas son redondas | 36,0 s | satisfactorio de mirar | +| `regla72.mp4` | La regla del 72 | 35,5 s | útil, se guarda | +| `buffon.mp4` | Sacar π tirando agujas | 36,7 s | belleza matemática | +| `collatz.mp4` | Conjetura de Collatz (3n+1) | 46,2 s | misterio sin resolver | +| `simpson.mp4` | Paradoja de Simpson | 53,0 s | genera debate + útil | +| `cuerda.mp4` | Una cuerda alrededor de la Tierra | 45,4 s | dato que sorprende | +| `gabriel.mp4` | El cuerno de Gabriel | 40,5 s | belleza matemática | +| `mobius.mp4` | Cinta de Möbius | 46,3 s | para hacer en casa | +| `bayes.mp4` | Falso positivo (test médico) | 46,9 s | genera debate + útil | +| `cicloide.mp4` | Braquistócrona: la curva más rápida | 47,6 s | satisfactorio de mirar | +| `dados.mp4` | Dados no transitivos | 50,6 s | genera debate en comentarios | +| `bolapeluda.mp4` | Teorema de la bola peluda (3D) | 41,9 s | satisfactorio de mirar | +| `caos.mp4` | Tres péndulos dobles y 1 mm (3D) | 40,6 s | hipnótico, se mira entero | +| `diferencial.mp4` | Cómo funciona el diferencial (3D) | 44,1 s | útil, se guarda y se comparte | +| `galton.mp4` | Tablero de Galton: 500 bolitas forman la campana (Blender + Godot) | 45,7 s | satisfactorio + pregunta en comentarios | +| `bloques.mp4` | Pila armónica: bloques que sobresalen de la mesa (Blender + Godot) | 48,0 s | para hacer en casa + dato que sorprende | +| `domino.mp4` | Dominó ×1,5: de 5 mm al Aconcagua (Blender + Godot) | 66,7 s | exponencial + pregunta en comentarios (más de 1 min) | + +Formato: 1080×1920, 30 fps, H.264 + AAC 48 kHz estéreo, **−13,8 a −15,3 LUFS** +medidos (el objetivo de TikTok es −14, así no te lo bajan ni te lo suben), +pico real por debajo de −1 dBTP. +Subtítulos quemados: el 80 % mira sin audio. + +## Textos sugeridos para publicar + +**montyhall** — ¿Te quedás o cambiás? El 87% se equivoca 🚪 +`#matematica #montyhall #datocurioso #probabilidad #argentina` + +**nueves** — 0,999… es EXACTAMENTE 1. Peleame en los comentarios 🧮 +`#matematica #mindblown #datocurioso #numeros #viral` + +**cumple** — Con 23 personas ya hay 50% de que dos cumplan el mismo día 🎂 +`#paradoja #datocurioso #matematica #probabilidad #sabiasque` + +**luna** — Doblá un papel 42 veces y llegás a la Luna 🌙 +`#exponencial #datocurioso #ciencia #mindblown #sabiasque` + +**japones** — Así multiplican en Japón, sin tablas ✏️ +`#trucosmatematicos #japon #escuela #tarea #aprendeentiktok` + +**benford** — Los números inventados se notan. Así agarran fraudes 🕵️ +`#benford #fraude #estadistica #datocurioso #sabiasque` + +**hilbert** — Un hotel lleno con infinitas habitaciones… y entran infinitos más 🏨 +`#infinito #matematica #paradoja #mindblown #hilbert` + +**reuleaux** — Por qué las tapas de alcantarilla son redondas 🛞 +`#datocurioso #geometria #ingenieria #sabiasque #reuleaux` + +**regla72** — La cuenta que te dice cuándo se duplica tu plata 💸 +`#finanzaspersonales #inversiones #interescompuesto #inflacion #argentina` + +**buffon** — Tiro agujas al piso y sale π. Sin ningún círculo 🎲 +`#pi #matematica #azar #datocurioso #mindblown` + +**collatz** — Elegí cualquier número. Nadie pudo demostrar por qué siempre cae al 1 🌀 +`#collatz #matematica #misterio #datocurioso #mindblown` + +**simpson** — Gana en los dos grupos y pierde en el total. Así te miente un promedio 📊 +`#estadistica #paradoja #datos #sabiasque #matematica` + +**cuerda** — Le agrego 1 metro a una cuerda que rodea la Tierra. ¿Cuánto se levanta? 🌍 +`#datocurioso #matematica #mindblown #sabiasque #ciencia` + +**gabriel** — Lo podés llenar de pintura, pero no lo podés pintar 🎺 +`#infinito #matematica #paradoja #calculo #mindblown` + +**mobius** — Cortá esta cinta al medio y NO van a salir dos ✂️📄 +`#mobius #topologia #experimento #hacelovos #datocurioso` + +**bayes** — El test acierta el 99% y te dio positivo. La chance de estar enfermo es 9% 🧪 +`#probabilidad #estadistica #salud #sabiasque #bayes` + +**cicloide** — La recta es el camino más corto… y pierde la carrera 🎢 +`#fisica #matematica #satisfying #mindblown #cicloide` + +**dados** — Elegí el dado que quieras. Elijo después y te gano igual 🎲 +`#probabilidad #dados #juegos #datocurioso #matematica` + +**bolapeluda** — No podés peinar una esfera sin dejar un remolino. Es un teorema 🌀 +`#topologia #matematica #3d #datocurioso #mindblown` + +**caos** — Tres péndulos idénticos salvo 1 milímetro. A los 5 segundos no se parecen en nada 🎲 +`#caos #fisica #simulacion #mindblown #efectomariposa` + +**diferencial** — Por qué tu auto puede doblar sin que una rueda patine ⚙️ +`#mecanica #autos #ingenieria #sabiasque #comofunciona` + +**galton** — 500 bolitas, cada una al azar… y todas juntas dibujan una campana 🔔 +`#probabilidad #estadistica #galton #satisfying #matematica` + +**bloques** — Apilé bloques hasta que el de arriba quedó en el aire. ¿Hasta dónde llegás vos? 📚 +`#fisica #experimento #matematica #paraHacerEnCasa #sabiasque` + +**domino** — Una ficha de 5 mm puede tirar un edificio. ¿En qué ficha se cae tu casa? 🏠 +`#domino #fisica #exponencial #satisfying #sabiasque` + +Subí uno por día, mismo horario, y alterná los tres tipos: + +- **debate** (comentarios, que es lo que más empuja el alcance): montyhall, nueves, + hilbert, simpson, cuerda, dados +- **útil** (se guarda y se comparte): japones, regla72, benford, bayes, diferencial, bloques +- **espectáculo** (se mira hasta el final): reuleaux, buffon, mobius, gabriel, collatz, + luna, cumple, cicloide, bolapeluda, caos, galton, domino + +Los tres últimos (`bolapeluda`, `caos`, `diferencial`) están hechos en Blender: sirven +para cortar la monotonía visual de Manim sin cambiar el estilo del canal, porque la +capa de interfaz (chip, barra y subtítulos) es la misma. + +`mobius` es el único con llamado a la acción físico ("hacelo con una hoja"): +esos suelen traer videos-respuesta, que valen doble. + +### Blender + Godot (`galton`, `bloques`) + +Blender diseña y Godot calcula la física: + +1. `MODO=sim blender -b -P blender/.py` arma la escena y exporta a glTF solo + los objetos físicos. El prefijo del nombre dice qué es cada uno para Godot + (`fijo_` malla estática, `caja_` caja estática, `compuerta_` estática que se saca + con un evento, `bola_` esfera rígida, `bloque_` caja rígida). Después corre + `godot --headless --fixed-fps 240 -- godot/trabajo/.json`. +2. `godot/sim.gd` carga el glb en tiempo de ejecución, crea los cuerpos, aplica los + eventos del guion (soltar, sacar la compuerta) en el segundo exacto del timeline + y graba la pose de cada cuerpo a 30 fps en coordenadas de Blender + (`godot/trabajo/.bin`). +3. `blender -b -P blender/.py` lee esas poses y renderiza con los materiales + de verdad (madera procedural, latón, acero). + +Nada del movimiento está animado a mano. Algunos números que salen de la simulación: + +- **galton**: casilleros `[0 1 7 29 70 104 96 83 65 35 10 0 0]` (desvío de 1,82 + casilleros; el binomial ideal de 12 filas da 1,73). La columna del medio tiene + **96**: es la respuesta a la pregunta del final. +- **bloques**: la pila de 5 con corrimientos armónicos ×0,95 se sostiene con el de + arriba entero en el aire (8,5 % de bloque). La torre de 16 no se mueve hasta que + se le apoya un bloque más en la punta izquierda de arriba: se caen 10 de 17. + +Dos ajustes que hicieron falta en Godot: + +- **galton**: una amortiguación lineal de 6/s, que hace de roce contra el vidrio. + Sin ella las bolitas toman demasiada velocidad lateral y el reparto sale plano. +- **bloques**: física a 1920 Hz y mundo ×5 (`"hz"` y `"escala"` en el config). Jolt + tolera 2 cm de penetración y a 240 Hz la torre de 16 se hundía y se volcaba sola. + +Ojo: con la notebook Optimus, EEVEE usa la Intel salvo que se lance con +`__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia`, y así es 3,6 veces +más rápido (1,5 s/frame contra 5,5 en galton). + +## Cómo regenerar + +```bash +conda run -n manim python scripts/gen_audio.py montyhall # voz + verificación +conda run -n manim manim render -v ERROR --disable_caching \ + --format=mp4 -o montyhall scenes/v1_montyhall.py MontyHall +conda run -n manim python scripts/build_video.py montyhall # música + mezcla + mux +conda run -n manim python scripts/verify_final.py montyhall # QA sobre el MP4 final +``` + +`DRAFT=1` antes del render baja a 540×960 para iterar rápido. + +## Cómo está armado + +``` +scripts/narration.py guiones: (texto que se locuta, subtítulo en pantalla) +scripts/gen_audio.py sintetiza cada frase y la verifica transcribiéndola con Whisper +scripts/numspell.py pasa "99,9%" a "noventa y nueve coma nueve por ciento" + para poder comparar la transcripción contra el guion +scripts/regen.py regenera una frase probando 5 semillas +scripts/repick.py reelige la mejor toma ya sintetizada +scripts/verify.py QA: transcribe todos los WAV y marca los dudosos +scripts/verify_final.py QA final: saca el audio del MP4 ya mezclado y lo transcribe, + así se prueba que la voz se entiende por encima del piano +scripts/music.py cama de piano (progresión propia) + ducking bajo la voz +scripts/build_video.py arma la pista siguiendo el timeline y la pega al video +scenes/tiktok.py base: 9:16, subtítulos, barra de progreso, "beats" +scenes/v*.py una escena por video (Manim) +scenes/overlay.py capa de interfaz para los videos 3D: la misma barra, el mismo + chip y los mismos subtítulos, renderizados con alfa +blender/base.py base de las escenas 3D: paleta, cámara, primitivas, tiempo +blender/*.py una escena por video 3D +scripts/build_blender.py compone fondo + 3D + overlay + audio en una sola pasada +``` + +### Los videos 3D + +Se renderizan headless con Blender 5.2 y EEVEE: + +```bash +conda run -n manim manim -s --disable_caching -o fondo scenes/overlay.py Fondo # trama de puntos +NAME=caos CHIP=Caos CHIPCOL=rosa conda run -n manim manim --transparent \ + --disable_caching -o caos scenes/overlay.py Overlay # capa + timeline +blender -b --factory-startup --python blender/caos.py # secuencia PNG con alfa +conda run -n manim python scripts/build_blender.py caos # composicion + audio +``` + +Dos decisiones que se pagaron solas: + +- **Sin keyframes.** El render recorre los frames uno por uno y antes de cada uno + llama a `actualizar(f)`, que coloca todo. Así la física se calcula exacta en cada + cuadro, se pueden renderizar frames salteados para revisar el encuadre, y no hay + que pelear con la API de acciones por capas de Blender 5.x. +- **El fondo no está en la escena 3D.** Blender renderiza con `film_transparent` y el + fondo punteado se compone abajo con ffmpeg, así es pixel por pixel el mismo que el + de los 18 videos de Manim. +- **El metal necesita algo que reflejar.** `mundo_estudio()` pone un gradiente de + horizonte en el mundo: no se ve (el film es transparente) pero es lo único que + separa el acero del plástico gris. Sin eso, un material metálico queda plano. + +`blender/base.py` genera las piezas mecánicas por código: + +| Función | Qué hace | +|---|---| +| `engranaje_conico` | Engranaje cónico con semiángulo de cono y dientes en espiral. Dos engranan a 90° cuando sus γ suman 90: 45+45 para los del diferencial, 72+18 para corona y piñón (y ahí la relación de dientes queda fijada por sin72/sin18 = 3,07, que es por qué son 43 y 14) | +| `rueda_completa` | Neumático con tacos y canales + llanta de aleación con radios + disco de freno | +| `sector_anillo` | Pared curva: con dos de estas y dos bridas sale una caja de diferencial con ventanas, que es lo que deja ver los engranajes de adentro | +| `anillo_plano`, `tornillos` | Bridas y coronas de bulones | + +Los PNG intermedios quedan en `render//` (unos 4 GB los tres). Sirven para +recomponer con otro audio o subtítulos sin volver a renderizar, que son entre 30 y +55 minutos por video; si no hacen falta, se borran. + +`MUESTRAS=8` baja las muestras de EEVEE (16 por defecto, 64 de fábrica: eso son +10 s/frame en una GTX 1050 y 1,6 s/frame con 8). `SALTO=55` renderiza un frame de +cada 55 para armar la hoja de contacto sin esperar el render entero. + +La sincronía no se estima: la escena escribe `out/_timeline.json` con el +tiempo real de cada bloque y el audio se coloca en esas marcas exactas. + +## Sobre la música + +Las progresiones (Am–F–C–G, Em–C–G–D, Am–Em–Dm–E, Cm–G#–A#–Gm, A–E–F#m–D, etc.) son propias y están tocadas con muestras +de piano sueltas, así que el audio es tuyo y no te lo va a marcar el Content ID. +Si preferís un audio en tendencia de TikTok, subilo igual y agregalo desde la app: +la voz ya está mezclada a −14 LUFS y se sigue entendiendo por encima. 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/_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])) diff --git a/blender/bloques.py b/blender/bloques.py new file mode 100644 index 0000000..19e9991 --- /dev/null +++ b/blender/bloques.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +"""BLOQUES - la pila armonica que sobresale de la mesa sin limite. + +Blender disena (mesa, piso, bloques de madera) y exporta a glTF; Godot (Jolt) +decide que se sostiene y que se cae. Hay tres experimentos, cada uno en su +propio carril de la mesa (Y distinta) para que no se toquen; al renderizar se +trae al frente el que toca y se ocultan los otros. + + A: la escalera "obvia", cada bloque corrido medio largo -> se cae + B: 5 bloques armonicos (1/2, 1/4, 1/6, 1/8, 1/10) -> se sostiene + C: 16 bloques armonicos; en el beat 8 se le apoya uno mas -> se cae todo + +Los corrimientos armonicos se escalan por C_SEG = 0,95: asi el centro de masa +de cada subpila queda (1-C)/2 = 2,5 % de largo adentro del borde que la +sostiene, en todos los pisos. Sin ese margen, el solver decide al azar. +""" +import math, os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bpy +from base import * + +NOMBRE = "bloques" +L, H, P = 1.0, 0.16, 0.50 # largo, alto, profundidad del bloque +C_SEG = 0.95 +C_TORRE = float(os.environ.get("T_C", C_SEG)) # la torre alta necesita mas margen con el solver +Z_PISO = -3.5 # mesa de 70 cm con bloques de 20 cm +GRAV = 49.0 # 9,81 / 0,2: bloques de 20 cm de verdad +CARRIL = {"a": 0.0, "b": 3.0, "c": 6.0, "x": 6.0} +N_B, N_C = 5, 16 +DX_EXTRA = float(os.environ.get("T_DX", -0.45)) # donde se apoya el bloque de mas + + +def pila(n, c=C_SEG): + """Centros x de una pila armonica de n bloques (indice 0 = el de arriba).""" + xs = [0.0] * n + x = -L / 2 # la mesa hace de bloque n+1 + for k in range(n, 0, -1): + x += c * L / (2 * k) + xs[k - 1] = x + return xs + + +def z_de(n, k): + """Altura del centro del bloque k (0 = arriba) en una pila de n.""" + return (n - 1 - k) * H + H / 2 + + +def construir(): + escena(muestras=int(os.environ.get("MUESTRAS", 24))) + mundo_estudio(fuerza=0.55) + M = { + "bloque": madera("bloque", claro="#E2B880", oscuro="#B07A45", escala=5.0, rug=0.5), + "arriba": material("arriba", "ambar", rug=0.35), + "mesa": madera("mesa", claro="#6B4426", oscuro="#2E1A0C", escala=2.5, rug=0.35, + veta=(0.4, 1, 1)), + "piso": madera("piso", claro="#3A3F4B", oscuro="#1E222B", escala=1.5, rug=0.6, + veta=(0.3, 1, 1)), + "invisible": material("inv", "gris"), + } + fis = [] + mesa = caja("caja_mesa", (6.0, 9.0, 0.3), (-3.0, 3.0, -0.15), M["invisible"]) + piso = caja("caja_piso", (30.0, 30.0, 0.2), (0.0, 3.0, Z_PISO - 0.1), M["invisible"]) + mesa.hide_render = piso.hide_render = True + fis += [mesa, piso] + # lo que se ve de la mesa y el piso: solo el carril del frente + caja("mesa_v", (6.0, 2.4, 0.3), (-3.0, 0.4, -0.15), M["mesa"]) + m_pata = metal("pata", "#2A2F3A", rug=0.4) + caja("pata", (0.14, 0.14, -Z_PISO - 0.3), (-0.35, -0.55, (Z_PISO - 0.3) / 2), m_pata) + caja("pata2", (0.14, 0.14, -Z_PISO - 0.3), (-0.35, 1.3, (Z_PISO - 0.3) / 2), m_pata) + caja("piso_v", (14.0, 5.0, 0.2), (1.0, 1.0, Z_PISO - 0.1), M["piso"]) + + bl = {} + # A: escalera "obvia" + for k in range(5): + x = -0.25 + 0.5 * (4 - k) + bl[f"a{k}"] = caja(f"bloque_a{k}", (L, P, H), (x, CARRIL["a"], z_de(5, k)), M["bloque"]) + # B: armonica de 5 + for k, x in enumerate(pila(N_B)): + bl[f"b{k}"] = caja(f"bloque_b{k}", (L, P, H), (x, CARRIL["b"], z_de(N_B, k)), + M["arriba"] if k == 0 else M["bloque"]) + # C: armonica de 16 y el bloque de mas, esperando arriba + xc = pila(N_C, C_TORRE) + for k, x in enumerate(xc): + bl[f"c{k:02d}"] = caja(f"bloque_c{k:02d}", (L, P, H), (x, CARRIL["c"], z_de(N_C, k)), + M["arriba"] if k == 0 else M["bloque"]) + bl["x"] = caja("bloque_x", (L, P, H), (xc[0] + DX_EXTRA, CARRIL["x"], z_de(N_C, 0) + H + 0.005), + M["bloque"]) + fis += list(bl.values()) + return dict(fis=fis, bl=bl, M=M) + + +def eventos(T): + t = lambda f: (f - 1) / FPS + fa = T.rango(1)[0] + int(0.55 * (T.rango(1)[1] - T.rango(1)[0])) + fx = T.rango(8)[0] + int(0.70 * (T.rango(8)[1] - T.rango(8)[0])) + return {"a": fa, "b": T.rango(4)[0], "c": T.rango(7)[0], "x": fx}, [ + {"t": t(fa), "accion": "soltar", "prefijo": "bloque_a"}, + {"t": t(T.rango(4)[0]), "accion": "soltar", "prefijo": "bloque_b"}, + {"t": t(T.rango(7)[0]), "accion": "soltar", "prefijo": "bloque_c"}, + {"t": t(fx), "accion": "soltar", "prefijo": "bloque_x"}, + ] + + +def simular(T, obj): + _, ev = eventos(T) + cfg = { + "duracion": T.n_frames / FPS + 0.2, + "gravedad": GRAV, + "escala": float(os.environ.get("T_ESC", 5.0)), + "hz": int(os.environ.get("T_HZ", 1920)), + "congelados": ["bloque_"], + "reglas": { + "bloque_": {"friccion": 0.55, "rebote": 0.08, "densidad": 600}, + "caja_": {"friccion": 0.6, "rebote": 0.05}, + }, + "eventos": ev, + } + correr_godot(exportar_fisica(NOMBRE, obj["fis"], cfg)) + + +def main(): + T = Tiempo(NOMBRE) + obj = construir() + if os.environ.get("MODO") == "sim": + simular(T, obj) + return + idx, D = cargar_sim(NOMBRE) + bl, M = obj["bl"], obj["M"] + suelta, _ = eventos(T) + xc = pila(N_C, C_TORRE) + + # reporte: que quedo en pie + for g, n in (("a", 5), ("b", N_B), ("c", N_C)): + nom = [k for k in bl if k.startswith(g) and k != "x"] + zmin = min(D[-1, idx[bl[k].name], 2] for k in nom) + zmin_antes = min(D[min(len(D) - 1, suelta["x"] - 5), idx[bl[k].name], 2] for k in nom) + print(f"[{NOMBRE}] pila {g}: z minima al final {zmin:+.2f} " + f"(antes del bloque extra {zmin_antes:+.2f})") + print(f"[{NOMBRE}] B: el de arriba arranca en x={pila(N_B)[0] - L / 2:+.3f} " + f"(sobresale {pila(N_B)[0] + L / 2:.3f}); C sobresale {xc[0] + L / 2:.3f}") + + # --- dibujos ------------------------------------------------------------- + m_borde = material("borde", "celeste", emis=2.5) + borde = curva_poly("borde", [[(0, -0.30, z0), (0, -0.30, z0 + 0.09)] + for z0 in [0.02 + 0.16 * i for i in range(18)]], + grosor=0.012, mat=m_borde) + etiquetas = [] + xb = pila(N_B) + for k in range(N_B): + num = texto(f"1/{2 * (k + 1)}", tam=0.15, color="ambar") + # el corrimiento de este bloque respecto del de abajo, a su derecha + num.location = (xb[k] + L / 2 + 0.16, -0.30, z_de(N_B, k)) + etiquetas.append(num) + aire = curva_poly("aire", [[(0, -0.3, 0.9), (xb[0] - L / 2, -0.3, 0.9)]], + grosor=0.02, mat=material("aire", "rosa", emis=3.0)) + aire_t = texto("en el aire", tam=0.16, color="rosa") + aire_t.location = (0.45, -0.3, 1.08) + # centros de masa de cada subpila, sobre el borde que la sostiene + cms = [] + for k in range(1, N_B + 1): + x_cm = sum(xb[:k]) / k + e = esfera(f"cm{k}", 0.045, material(f"mcm{k}", "ambar", emis=3.0)) + e.location = (x_cm, -0.30, z_de(N_B, k - 1) - H / 2) + cms.append(e) + cuenta = texto("16 bloques", tam=0.22, color="blanco") + sobre = texto("sobresale 1,6 bloques", tam=0.18, color="ambar") + regla = curva_poly("regla", [[(0, -0.3, 2.78), (xc[0] + L / 2, -0.3, 2.78)]], + grosor=0.018, mat=material("regla", "ambar", emis=3.0)) + + lente = 50.0 + cam = camara((0, -8, 1), (0, 0, 0), lente=lente) + cam.data.sensor_fit = 'VERTICAL' + cam.data.sensor_height = 36.0 + luz("key", 'AREA', (-3.0, -4.0, 5.0), 1400, "blanco", tam=4.0, mira=(0.5, 0, 0.8)) + luz("fill", 'AREA', (4.0, -3.0, 0.5), 350, "blanco", tam=4.0, mira=(0.5, 0, 0.5)) + luz("rim", 'AREA', (1.0, 4.0, 4.0), 600, "blanco", tam=3.0, mira=(0.5, 0, 1.0)) + + # (frame, x centro, z del contenido, alto visible) + r = T.rango + tomas = [ + (1, 0.55, 0.40, 5.6), + (suelta["a"] + 5, 0.55, 0.40, 5.6), + (r(1)[1] + 10, 0.9, -1.7, 7.4), + (r(2)[0] + 18, 0.45, 0.45, 4.8), + (r(5)[1], 0.45, 0.45, 4.8), + (r(6)[0] + 10, 0.50, 0.9, 5.6), + (r(6)[1], 0.55, 1.35, 6.4), + (r(8)[1] - 4, 0.55, 1.35, 6.4), + (r(9)[0] + 30, 1.2, -1.3, 8.4), + (T.n_frames, 1.2, -1.4, 8.6), + ] + + def camara_en(f): + for (f0, *a), (f1, *b) in zip(tomas, tomas[1:]): + if f0 <= f <= f1: + u = suave((f - f0) / max(1, f1 - f0)) + return [p + (q - p) * u for p, q in zip(a, b)] + return tomas[-1][1:] + + def n_c(f): + """Cuantos bloques tiene la pila C mientras se arma (beat 6).""" + return 5 + (N_C - 5) * suave(T.p(f, 6) / 0.8) + + def actualizar(f): + i = min(f - 1, len(D) - 1) + grupo = "a" if f < r(2)[0] + 10 else "b" if f < r(6)[0] else "c" + for k, ob in bl.items(): + g = "x" if k == "x" else k[0] + vis = (g == grupo) or (g == "x" and grupo == "c") + ob.hide_render = not vis + if not vis: + continue + fila = list(D[i, idx[ob.name]]) + fila[1] -= CARRIL[g] + poner_pose(ob, fila) + if g == "a" and f < suelta["a"]: + # de la pila prolija a la escalera, antes de soltarla + kk = int(k[1:]) + u = suave(T.p(f, 1) / 0.45) + ob.location.x = mezcla(-0.45, fila[0], u) + elif g == "b" and f < suelta["b"]: + # se arma desde arriba: cada bloque entra desde la izquierda + kk = int(k[1:]) + u = suave((T.p(f, 3) - kk * 0.17) / 0.16) if f >= r(3)[0] else 0.0 + if f < r(3)[0]: + u = suave(T.p(f, 2) / 0.6) if kk == 0 else 0.0 + ob.location.x = mezcla(fila[0] - 3.5, fila[0], u) + ob.hide_render = u <= 0.001 + elif g == "c" and f < suelta["c"]: + kk = int(k[1:]) + n = n_c(f) + n0 = int(math.floor(n)) + u = n - n0 + def pos(nn): + if kk >= nn: + return None + return pila(nn, C_TORRE)[kk], z_de(nn, kk) + p0, p1 = pos(n0), pos(min(N_C, n0 + 1)) + if p0 is None and p1 is None: + ob.hide_render = True + continue + if p0 is None: # el bloque nuevo entra por abajo desde la izquierda + p0 = (p1[0] - 3.0, p1[1]) + x = p0[0] + (p1[0] - p0[0]) * u if p1 else p0[0] + z = p0[1] + (p1[1] - p0[1]) * u if p1 else p0[1] + ob.location = (x, 0.0, z) + elif g == "x" and f < suelta["x"]: + u = suave((T.p(f, 8) - 0.15) / 0.45) + ob.hide_render = u <= 0.001 + ob.location.z = fila[2] + 2.0 * (1 - u) + + # dibujos + v_borde = T.p(f, 3) > 0 and f < r(9)[0] + borde.hide_render = not v_borde + for k, t in enumerate(etiquetas): + t.hide_render = not (r(3)[0] + (k * 0.17 + 0.08) * (r(3)[1] - r(3)[0]) <= f < r(6)[0]) + v_aire = r(4)[0] + 20 <= f < r(6)[0] + aire.hide_render = aire_t.hide_render = not v_aire + for k, e in enumerate(cms): + e.hide_render = not (r(5)[0] + k * 8 <= f < r(6)[0]) + v_c = r(6)[0] + 5 <= f < r(8)[1] + cuenta.hide_render = not v_c + n = n_c(f) + cuenta.data.body = f"{int(round(n))} bloques" + top_z = z_de(int(round(n)), 0) + H / 2 + cuenta.location = (-0.45, -0.3, top_z + 0.35) + v_regla = r(7)[0] <= f < r(8)[1] + regla.hide_render = sobre.hide_render = not v_regla + sobre.location = (1.2, -0.3, 2.98) + + cx, cz, h = camara_en(f) + dist = h * lente / 36.0 + zc = cz - 0.10 * h # el contenido queda arriba de los subtitulos + apuntar(cam, (cx - 0.16 * h, -dist, zc + 0.16 * h), (cx, 0, zc)) + + render_secuencia(NOMBRE, T, actualizar) + + +main() diff --git a/blender/bolapeluda.py b/blender/bolapeluda.py new file mode 100644 index 0000000..a260784 --- /dev/null +++ b/blender/bolapeluda.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +"""BOLA PELUDA - el teorema de la bola peluda (Brouwer, 1912). + +Cada pelo se acuesta en la direccion del campo tangente v(p) = A x p, y lo hace +tanto como el campo sea fuerte: donde |v| se anula (los dos polos del eje A) el +pelo no tiene hacia donde acostarse y queda parado. O sea que el remolino no +esta dibujado a mano, sale de la misma cuenta que peina el resto. +""" +import math, os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import bpy +from mathutils import Vector, Matrix +from base import * + +NOMBRE = "bolapeluda" +R = 1.15 # radio de la esfera +LARGO = 0.34 # largo del pelo +K = 7 # puntos por pelo +N = 1500 # cantidad de pelos +UMBRAL = 0.42 # |v| por debajo de esto, el pelo no llega a acostarse +CZ = 0.50 # altura del centro en el cuadro + +# Peinados: (eje, tipo). Los ejes viven casi en el plano de la pantalla (XZ) +# para que los DOS ceros caigan sobre la silueta y se vean juntos. +# rot -> campo v = A x p (peina en circulos, como las latitudes) +# mer -> campo v = A - (A.p) p (peina de un polo al otro, por meridianos) +# Los dos se anulan exactamente donde p es paralelo a A: ahi esta el remolino. +PEINADOS = [((0.80, -0.05, 0.60), "rot"), + ((-0.58, -0.05, 0.82), "mer"), + ((0.97, -0.08, -0.22), "rot"), + ((0.12, -0.05, 0.99), "mer"), + ((-0.86, -0.06, -0.50), "rot"), + ((0.80, -0.05, 0.60), "rot")] + + +def normaliza(v): + n = math.sqrt(sum(c * c for c in v)) + return tuple(c / n for c in v) if n > 1e-9 else (0.0, 0.0, 1.0) + + +def cruz(a, b): + return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) + + +def puntos_esfera(n): + """Espiral de Fibonacci: reparte n puntos casi parejos sobre la esfera.""" + pts, ga = [], math.pi * (3 - math.sqrt(5)) + for i in range(n): + z = 1 - 2 * (i + 0.5) / n + r = math.sqrt(max(0.0, 1 - z * z)) + th = ga * i + pts.append((r * math.cos(th), r * math.sin(th), z)) + return pts + + +def campo(p, eje, tipo): + if tipo == "mer": + d = sum(a * b for a, b in zip(eje, p)) + return tuple(eje[i] - d * p[i] for i in range(3)) + return cruz(eje, p) + + +def pelo(p, eje, peinado, tipo="rot", radio=R, largo=LARGO): + """Devuelve los K puntos del pelo nacido en p y cuanto vale |v| ahi.""" + v = campo(p, eje, tipo) + m = math.sqrt(sum(c * c for c in v)) + if m > 1e-6: + t = tuple(c / m for c in v) + else: # en el cero no hay direccion: queda parado + t = normaliza(cruz(p, (0.0, 0.0, 1.0)) if abs(p[2]) < 0.9 else (1.0, 0.0, 0.0)) + b = peinado * min(1.0, m / UMBRAL) + pos = [tuple(c * radio for c in p)] + paso = largo / (K - 1) + for j in range(1, K): + s = j / (K - 1.0) + phi = b * (math.pi / 2) * s ** 0.85 + cs, sn = math.cos(phi), math.sin(phi) + d = (p[0] * cs + t[0] * sn, p[1] * cs + t[1] * sn, p[2] * cs + t[2] * sn) + a = pos[-1] + pos.append((a[0] + d[0] * paso, a[1] + d[1] * paso, a[2] + d[2] * paso)) + return pos, m + + +def pelos_toro(nu=60, nv=24, RT=0.90, rt=0.34, largo=0.22): + """Pelos de la rosquilla, peinados por el tangente toroidal: nunca se anula.""" + splines, radios = [], [] + for i in range(nu): + u = 2 * math.pi * i / nu + cu, su = math.cos(u), math.sin(u) + for j in range(nv): + w = 2 * math.pi * j / nv + cw, sw = math.cos(w), math.sin(w) + p = ((RT + rt * cw) * cu, (RT + rt * cw) * su, rt * sw) + n = (cw * cu, cw * su, sw) # normal + t = (-su, cu, 0.0) # tangente toroidal, |t| = 1 + pos = [p] + paso = largo / (K - 1) + for k in range(1, K): + s = k / (K - 1.0) + phi = (math.pi / 2) * s ** 0.85 + cs, sn = math.cos(phi), math.sin(phi) + d = (n[0] * cs + t[0] * sn, n[1] * cs + t[1] * sn, n[2] * cs + t[2] * sn) + a = pos[-1] + pos.append((a[0] + d[0] * paso, a[1] + d[1] * paso, a[2] + d[2] * paso)) + splines.append(pos) + radios.append([1.0 - 0.72 * (k / (K - 1.0)) for k in range(K)]) + return splines, radios + + +def construir(T): + sc = escena() + lente, ALTO = 70.0, 6.0 + camara((0.0, -lente / 36.0 * ALTO, 0.0), (0.0, 0.0, 0.0), lente=lente) + bpy.context.scene.camera.data.sensor_fit = 'VERTICAL' + bpy.context.scene.camera.data.sensor_height = 36.0 + + luz("key", 'AREA', (-3.2, -4.6, 3.8), 800, "blanco", tam=5.0, mira=(0, 0, CZ)) + luz("fill", 'AREA', (3.8, -3.6, -1.2), 260, "celeste", tam=5.0, mira=(0, 0, CZ)) + luz("rim", 'AREA', (0.4, 4.2, 2.6), 620, "ambar", tam=4.0, mira=(0, 0, CZ)) + + rig = bpy.data.objects.new("rig", None) + bpy.context.collection.objects.link(rig) + rig.location = (0, 0, CZ) + + m_piel = material("piel", "azul", rug=0.55, metal=0.1) + bola = esfera("bola", R * 0.985, m_piel, seg=64, anillos=36) + bola.parent = rig + + base_pts = puntos_esfera(N) + splines = [pelo(p, PEINADOS[0][0], 0.0)[0] for p in base_pts] + radios = [[1.0 - 0.70 * (j / (K - 1.0)) for j in range(K)] for _ in base_pts] + pel_a = curva_poly("pelo_a", splines, grosor=0.0135, radios=radios, + mat=material("m_pelo", "ambar", rug=0.42, emis=0.55)) + pel_b = curva_poly("pelo_b", splines, grosor=0.0135, radios=radios, + mat=material("m_remol", "rosa", rug=0.42, emis=1.9)) + pel_a.parent = pel_b.parent = rig + + # marcas de los ceros + marcas = [] + for k in range(2): + g = bpy.data.objects.new(f"marca{k}", None) + bpy.context.collection.objects.link(g) + g.parent = rig + anillo = toro(f"anillo{k}", 0.34, 0.024, + material(f"mm{k}", "rosa", emis=3.2), u=48, v=10) + anillo.parent = g + marcas.append(g) + + # la rosquilla, peinada de una + sp_t, ra_t = pelos_toro() + rig_t = bpy.data.objects.new("rig_toro", None) + bpy.context.collection.objects.link(rig_t) + rig_t.location = (0, 0, CZ) + rosquilla = toro("rosquilla", 0.90, 0.335, material("piel_t", "azul", rug=0.55)) + r