diff options
Diffstat (limited to 'blender/galton.py')
| -rw-r--r-- | blender/galton.py | 316 |
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() |