aboutsummaryrefslogtreecommitdiffstats
path: root/blender/galton.py
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 20:21:47 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 20:21:47 -0300
commit59355909f2de9236af8168a26c70bcf6caa3b285 (patch)
tree686186e2086f81aa22ad25e78eb29fca3cbadc9a /blender/galton.py
download100cia-videos-59355909f2de9236af8168a26c70bcf6caa3b285.tar.gz
100cia-videos-59355909f2de9236af8168a26c70bcf6caa3b285.zip
Import video pipeline as it was
Diffstat (limited to 'blender/galton.py')
-rw-r--r--blender/galton.py316
1 files changed, 316 insertions, 0 deletions
diff --git a/blender/galton.py b/blender/galton.py
new file mode 100644
index 0000000..522bc81
--- /dev/null
+++ b/blender/galton.py
@@ -0,0 +1,316 @@
+# -*- coding: utf-8 -*-
+"""GALTON - 500 bolitas de acero contra 12 filas de clavos.
+
+Reparto del trabajo:
+ * Blender disena el tablero (clavos, tolva, compuerta, casilleros, bolitas)
+ y lo exporta a glTF: el nombre de cada objeto le dice a Godot que es.
+ * Godot (Jolt) simula los rebotes, a 240 Hz, y devuelve la pose de cada
+ bolita a 30 fps (godot/sim.gd).
+ * Blender vuelve a leer esas poses y renderiza con los materiales de verdad.
+
+ MODO=sim blender -b -P blender/galton.py # exporta y corre Godot
+ blender -b -P blender/galton.py # renderiza
+"""
+import math, os, random, sys
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import bpy
+from base import *
+
+NOMBRE = "galton"
+N_BOLAS = 500
+RB = 0.012 # radio de la bolita
+DX = 0.12 # paso entre clavos
+DY = DX * math.sqrt(3) / 2
+RP = 0.015 # radio del clavo
+FILAS = 12
+NB = FILAS + 1 # casilleros
+W2 = NB * DX / 2 # medio ancho interior
+ALTO_CAS = 0.65 # alto de los separadores
+Z_ULT = ALTO_CAS + 0.07 # ultima fila de clavos
+Z_FILA = [Z_ULT + (FILAS - 1 - i) * DY for i in range(FILAS)]
+Z_SAL = Z_FILA[0] + 0.10 # boca de la tolva
+BOCA = float(os.environ.get("T_BOCA", 0.04)) # media boca
+TOLVA_X, TOLVA_H, TOLVA_V = 0.33, 0.48, 0.62
+PROF = 0.08 # profundidad del canal (Y)
+GRAV = float(os.environ.get("T_G", 12.0))
+PRE = 2.5 # segundos de fisica antes del frame 1: las bolitas se asientan
+BEAT_UNA = 1 # cae una sola bolita
+BEAT_SUELTA = 3 # se abre la compuerta
+REB = float(os.environ.get("T_REB", 0.10))
+AMORT = float(os.environ.get("T_AM", 6.0))
+
+
+def x_clavos(i):
+ off = 0.5 if (FILAS - 1 - i) % 2 == 0 else 0.0
+ xs = []
+ j = -10
+ while True:
+ x = (j + off) * DX
+ j += 1
+ if x > W2 - 0.04:
+ break
+ if x >= -W2 + 0.04:
+ xs.append(x)
+ return xs
+
+
+# --- diseno -------------------------------------------------------------------
+def construir():
+ sc = escena(muestras=int(os.environ.get("MUESTRAS", 24)))
+ mundo_estudio(fuerza=0.6)
+ M = {
+ "laton": metal("laton", "#D9A441", rug=0.22),
+ "acero": metal("acero", "#E4E8EE", rug=0.10),
+ "chapa": metal("chapa", "#A9B2BF", rug=0.30),
+ "panel": material("panel", "#070B14", rug=0.75),
+ "madera": madera("madera"),
+ "base": madera("base", claro="#7A5230", oscuro="#3A2211", escala=3.0),
+ }
+ fis = [] # lo que va a Godot
+
+ # clavos: cilindros a lo largo de Y, del panel del fondo al vidrio
+ for i, z in enumerate(Z_FILA):
+ for k, x in enumerate(x_clavos(i)):
+ c = cilindro(f"fijo_clavo_{i:02d}_{k:02d}", RP, PROF, M["laton"], lados=24)
+ c.rotation_euler = (math.pi / 2, 0, 0)
+ c.location = (x, 0, z)
+ fis.append(c)
+
+ # separadores de los casilleros y paredes
+ for k in range(NB + 1):
+ x = (k - NB / 2) * DX
+ ext = k in (0, NB)
+ grosor = 0.02 if ext else 0.006
+ alto = (Z_SAL + 0.02) if ext else ALTO_CAS
+ xo = x + (-grosor / 2 if k == 0 else grosor / 2 if k == NB else 0)
+ fis.append(caja(f"caja_sep_{k:02d}", (grosor, PROF, alto), (xo, 0, alto / 2),
+ M["chapa"]))
+ fis.append(caja("caja_piso", (2 * W2 + 0.04, PROF, 0.02), (0, 0, -0.01), M["chapa"]))
+
+ # tolva: dos planos a 60 grados y un tramo vertical
+ dx_t, dz_t = TOLVA_X - BOCA, TOLVA_H
+ largo = math.hypot(dx_t, dz_t)
+ ang = math.atan2(dz_t, dx_t)
+ for s in (-1, 1):
+ cx = s * (BOCA + dx_t / 2)
+ cz = Z_SAL + dz_t / 2
+ # el espesor va hacia afuera de la tolva
+ nx, nz = s * math.sin(ang), -math.cos(ang)
+ w = caja(f"caja_tolva_{'i' if s < 0 else 'd'}", (largo, PROF, 0.016),
+ (cx + nx * 0.008, 0, cz + nz * 0.008), M["chapa"])
+ w.rotation_euler = (0, -s * ang, 0)
+ fis.append(w)
+ v = caja(f"caja_tolva_v{'i' if s < 0 else 'd'}", (0.016, PROF, TOLVA_V),
+ (s * (TOLVA_X + 0.008), 0, Z_SAL + TOLVA_H + TOLVA_V / 2), M["chapa"])
+ fis.append(v)
+ compuerta = caja("compuerta_0", (2 * BOCA + 0.04, PROF, 0.012),
+ (0, 0, Z_SAL - 0.008), M["chapa"])
+ fis.append(compuerta)
+
+ # bolitas: una grilla arriba de la tolva; caen y se acomodan antes del frame 1
+ me_bola = esfera("bola_malla", RB, M["acero"], seg=20, anillos=12).data
+ bpy.data.objects.remove(bpy.data.objects["bola_malla"])
+ bolas = []
+ rnd = random.Random(7)
+ paso = 2 * RB * 1.08
+ por_fila = int((2 * (TOLVA_X - 0.02)) / paso)
+ sola = objeto("bola_000", me_bola, None) # la que cae sola, retenida bajo la compuerta
+ sola.location = (0.004, 0, Z_SAL - 0.032)
+ bolas.append(sola)
+ for n in range(1, N_BOLAS):
+ fila, col = divmod(n - 1, por_fila)
+ x = (col - (por_fila - 1) / 2) * paso + (paso / 2 if fila % 2 else 0) * 0.5
+ x += rnd.uniform(-0.002, 0.002)
+ z = Z_SAL + TOLVA_H + 0.03 + fila * paso * 0.95
+ ob = objeto(f"bola_{n:03d}", me_bola, None)
+ ob.location = (x, 0, z)
+ bolas.append(ob)
+ fis += bolas
+
+ # --- lo que es solo visual ---
+ panel = caja("panel", (2 * W2 + 0.10, 0.02, Z_SAL + TOLVA_H + TOLVA_V + 0.10),
+ (0, PROF / 2 + 0.01, (Z_SAL + TOLVA_H + TOLVA_V) / 2), M["panel"])
+ for s in (-1, 1):
+ caja(f"marco_{s}", (0.07, PROF + 0.06, Z_SAL + 0.12),
+ (s * (W2 + 0.055), 0.0, (Z_SAL + 0.12) / 2 - 0.02), M["madera"])
+ caja("base", (2 * W2 + 0.40, 0.36, 0.10), (0, 0.05, -0.07), M["base"])
+ caja("pie", (2 * W2 + 0.60, 0.50, 0.04), (0, 0.05, -0.14), M["base"])
+ return dict(fis=fis, bolas=bolas, M=M, compuerta=compuerta)
+
+
+def simular(T, obj):
+ f_suelta = T.rango(BEAT_SUELTA)[0]
+ f_una = T.rango(BEAT_UNA)[0] + 8
+ cfg = {
+ "congelados": ["bola_000"],
+ "duracion": PRE + T.n_frames / FPS + 0.2,
+ "gravedad": GRAV,
+ "reglas": {
+ "bola_": {"friccion": 0.10, "rebote": REB, "densidad": 7800, "ccd": True,
+ "plano": True, "amortiguar": AMORT, "dormir": False},
+ "fijo_": {"friccion": 0.10, "rebote": REB},
+ "caja_": {"friccion": 0.25, "rebote": 0.20},
+ "compuerta_": {"friccion": 0.25, "rebote": 0.0},
+ },
+ "eventos": [{"t": PRE + (f_una - 1) / FPS, "accion": "soltar", "prefijo": "bola_000"},
+ {"t": PRE + (f_suelta - 1) / FPS, "accion": "quitar", "prefijo": "compuerta_"}],
+ }
+ correr_godot(exportar_fisica(NOMBRE, obj["fis"], cfg))
+
+
+# --- trazos para explicar -----------------------------------------------------
+def camino(movs):
+ """Polilinea de un camino por la grilla: movs = lista de -1/+1."""
+ pts = [(0.0, -0.05, Z_SAL - 0.02)]
+ x = 0.0
+ for i, m in enumerate(movs):
+ pts.append((x, -0.05, Z_FILA[i] + RP + RB))
+ x += m * DX / 2
+ pts.append((x, -0.05, 0.35))
+ return pts
+
+
+def recortar(pts, u):
+ """Primeros u (0..1) del largo de la polilinea."""
+ L = [0.0]
+ for a, b in zip(pts, pts[1:]):
+ L.append(L[-1] + math.dist(a, b))
+ obj_L = u * L[-1]
+ out = [pts[0]]
+ for i in range(1, len(pts)):
+ if L[i] <= obj_L:
+ out.append(pts[i])
+ else:
+ a, b = pts[i - 1], pts[i]
+ s = (obj_L - L[i - 1]) / max(1e-9, L[i] - L[i - 1])
+ out.append(tuple(a[k] + (b[k] - a[k]) * s for k in range(3)))
+ break
+ while len(out) < len(pts):
+ out.append(out[-1])
+ return out
+
+
+def main():
+ T = Tiempo(NOMBRE)
+ obj = construir()
+ if os.environ.get("MODO") == "sim":
+ simular(T, obj)
+ return
+ idx, D = cargar_sim(NOMBRE)
+ pre = int(round(PRE * FPS))
+ bolas = obj["bolas"]
+ ib = [idx[b.name] for b in bolas]
+ import numpy as np
+ xs_fin = D[-1, ib, 0]
+ zs_fin = D[-1, ib, 2]
+ cas = np.clip(np.round(xs_fin / DX).astype(int) + FILAS // 2, 0, NB - 1)
+ cuenta = np.bincount(cas, minlength=NB)
+ print(f"[{NOMBRE}] casilleros: {cuenta.tolist()} (en el tablero: {int((zs_fin < ALTO_CAS).sum())})")
+
+ # la bolita que se destaca: la que cae sola en el beat 1
+ i_una = idx["bola_000"]
+ una = next(b for b in bolas if b.name == "bola_000")
+ f_sale = int(np.argmax(D[:, i_una, 2] < Z_SAL - 0.05)) - pre + 1
+ f_llega = int(np.argmax(D[:, i_una, 2] < 0.6)) - pre + 1
+ m_una = material("una", "rosa", rug=0.2, metal=0.3, emis=1.6)
+ estela = curva_poly("estela", [[(0, 0, 0)] * 24], grosor=0.006,
+ radios=[[0.0] * 24], mat=material("estela", "rosa", emis=2.5))
+
+ # caminos: el de la punta (beat 5) y varios al medio (beat 6)
+ punta = curva_poly("c_punta", [camino([-1] * FILAS)], grosor=0.010,
+ mat=material("cp", "rosa", emis=3.0))
+ rnd = random.Random(3)
+ medios = []
+ for k in range(10):
+ m = [-1] * 6 + [1] * 6
+ rnd.shuffle(m)
+ medios.append(camino(m))
+ c_medios = curva_poly("c_medios", medios, grosor=0.006,
+ mat=material("cm", "verde", emis=2.6))
+
+ # campana: normal ajustada a las alturas finales de cada casillero
+ alturas = []
+ for k in range(NB):
+ sel = zs_fin[cas == k]
+ alturas.append(float(sel.max() + RB) if len(sel) else 0.0)
+ mu = float(np.mean(xs_fin[zs_fin < ALTO_CAS]))
+ sd = float(np.std(xs_fin[zs_fin < ALTO_CAS]))
+ area = sum(alturas) * DX
+ xs_c = [(-W2 + 2 * W2 * i / 120) for i in range(121)]
+ campana_pts = [(x, -0.05, 0.004 + area / (sd * math.sqrt(2 * math.pi))
+ * math.exp(-0.5 * ((x - mu) / sd) ** 2)) for x in xs_c]
+ campana = curva_poly("campana", [campana_pts], grosor=0.012,
+ mat=material("cc", "ambar", emis=3.2))
+ print(f"[{NOMBRE}] mu={mu:.3f} sd={sd:.3f} (= {sd / (DX / 2):.2f} medios pasos; "
+ f"binomial ideal: {math.sqrt(FILAS) / 2 * 2:.2f})")
+
+ # marco del casillero del medio (beat 10)
+ m_marco = material("marco_medio", "verde", emis=2.8)
+ h_med = alturas[NB // 2] + 0.03
+ marco = curva_poly("marco_medio", [[(-DX / 2, -0.05, 0.0), (-DX / 2, -0.05, h_med),
+ (DX / 2, -0.05, h_med), (DX / 2, -0.05, 0.0)]],
+ grosor=0.008, mat=m_marco)
+
+ # camara: de la tolva al tablero entero
+ lente = 50.0
+ cam = camara((0, -5, 1), (0, 0, 1), lente=lente)
+ cam.data.sensor_fit = 'VERTICAL'
+ cam.data.sensor_height = 36.0
+ A = (2.10, 2.0) # (z centro, alto visible)
+ B = (0.60, 3.10)
+ luz("key", 'AREA', (-1.8, -3.0, 3.2), 520, "blanco", tam=3.0, mira=(0, 0, 1.2))
+ luz("fill", 'AREA', (2.4, -2.6, 0.6), 110, "blanco", tam=3.0, mira=(0, 0, 0.8))
+ luz("top", 'AREA', (0.0, -0.8, 3.6), 220, "blanco", tam=2.0, mira=(0, 0, 1.5))
+
+ def actualizar(f):
+ i = min(f - 1 + pre, len(D) - 1)
+ for b, j in zip(bolas, ib):
+ poner_pose(b, D[i, j])
+
+ # camara
+ m = suave((f - f_sale) / max(1, f_llega - f_sale + 10))
+ zc = mezcla(A[0], B[0], m)
+ h = mezcla(A[1], B[1], m)
+ deriva = 0.03 * math.sin(f / 90.0)
+ dist = h * lente / 36.0
+ apuntar(cam, (deriva, -dist, zc + 0.08), (deriva * 0.5, 0, zc))
+
+ # la bolita destacada
+ vis = 1.0 if (T.rango(1)[0] <= f <= T.rango(4)[0]) else 0.0
+ if vis:
+ if una.material_slots and una.material_slots[0].link != 'OBJECT':
+ una.material_slots[0].link = 'OBJECT'
+ if una.material_slots:
+ una.material_slots[0].material = m_una
+ elif una.material_slots:
+ una.material_slots[0].link = 'DATA'
+ pts, rad = [], []
+ for k in range(24):
+ q = D[max(0, i - (23 - k)), i_una]
+ pts.append((q[0], -0.03, q[2]))
+ rad.append(vis * (k / 23.0) ** 1.5)
+ rehacer_curva(estela, [pts], [rad])
+
+ # caminos
+ up = suave(T.p(f, 5) / 0.7) * (1.0 - suave((T.p(f, 7) - 0.0) / 0.2))
+ rehacer_curva(punta, [recortar(camino([-1] * FILAS), up)])
+ punta.scale = (1, 1, 1) if 0 < up else (0, 0, 0)
+ cm = []
+ for k, c in enumerate(medios):
+ u = suave((T.p(f, 6) - k * 0.06) / 0.35)
+ cm.append(recortar(c, u))
+ rehacer_curva(c_medios, cm)
+ fuera = 1.0 - suave(T.p(f, 7) / 0.2)
+ c_medios.scale = (1, 1, 1) if (T.p(f, 6) > 0 and fuera > 0.01) else (0, 0, 0)
+
+ # campana
+ uc = suave(T.p(f, 7) / 0.6)
+ rehacer_curva(campana, [recortar(campana_pts, uc)])
+ campana.scale = (1, 1, 1) if uc > 0 else (0, 0, 0)
+
+ marco.scale = (1, 1, 1) if T.p(f, 10) > 0.02 else (0, 0, 0)
+
+ render_secuencia(NOMBRE, T, actualizar)
+
+
+main()