# -*- 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()