# -*- coding: utf-8 -*- """GALTON - 500 steel balls against 12 rows of pegs. Division of labour: * Blender designs the board (pegs, hopper, gate, bins, balls) and exports it to glTF: each object's name tells Godot what it is. * Godot (Jolt) simulates the bounces at 240 Hz and returns the pose of each ball at 30 fps (godot/sim.gd). * Blender reads those poses back and renders with the real materials. MODE=sim blender -b -P blender/galton.py # exports and runs Godot blender -b -P blender/galton.py # renders """ import math, os, random, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import bpy from base import * NAME_KEY = "galton" N_BOLAS = 500 RB = 0.012 # ball radius DX = 0.12 # peg pitch DY = DX * math.sqrt(3) / 2 RP = 0.015 # peg radius ROW_COUNT = 12 NB = ROW_COUNT + 1 # bins W2 = NB * DX / 2 # half inner width HOUSE_H = 0.65 # divider height Z_ULT = HOUSE_H + 0.07 # last row of pegs Z_ROW = [Z_ULT + (ROW_COUNT - 1 - i) * DY for i in range(ROW_COUNT)] Z_EXIT = Z_ROW[0] + 0.10 # hopper mouth MOUTH = float(env("T_MOUTH", None, 0.04)) # half mouth width HOPPER_X, HOPPER_H, HOPPER_V = 0.33, 0.48, 0.62 DEPTH = 0.08 # channel depth (Y) GRAVITY = float(os.environ.get("T_G", 12.0)) PRE_ROLL = 2.5 # seconds of physics before frame 1: the balls settle BEAT_ONE = 1 # a single ball drops BEAT_RELEASE = 3 # the gate opens BOUNCE = float(env("T_BOUNCE", None, 0.10)) DAMP = float(env("T_DAMP", None, 6.0)) def x_pegs(i): off = 0.5 if (ROW_COUNT - 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 # --- design ------------------------------------------------------------------- def build_scene(): sc = scene_setup(samples=int(env("SAMPLES", "MUESTRAS", 24))) studio_world(force=0.6) M = { "laton": metal("laton", "#D9A441", rough=0.22), "acero": metal("acero", "#E4E8EE", rough=0.10), "chapa": metal("chapa", "#A9B2BF", rough=0.30), "panel": material("panel", "#070B14", rough=0.75), "madera": madera("madera"), "base": madera("base", light_c="#7A5230", dark="#3A2211", scale_to=3.0), } phys = [] # what goes to Godot # pegs: cylinders along Y, from the back panel to the glass for i, z in enumerate(Z_ROW): for k, x in enumerate(x_pegs(i)): c = cylinder(f"static_clavo_{i:02d}_{k:02d}", RP, DEPTH, M["laton"], sides=24) c.rotation_euler = (math.pi / 2, 0, 0) c.location = (x, 0, z) phys.append(c) # bin dividers and walls for k in range(NB + 1): x = (k - NB / 2) * DX ext = k in (0, NB) thickness_px = 0.02 if ext else 0.006 alto = (Z_EXIT + 0.02) if ext else HOUSE_H xo = x + (-thickness_px / 2 if k == 0 else thickness_px / 2 if k == NB else 0) phys.append(box_obj(f"box_sep_{k:02d}", (thickness_px, DEPTH, alto), (xo, 0, alto / 2), M["chapa"])) phys.append(box_obj("box_piso", (2 * W2 + 0.04, DEPTH, 0.02), (0, 0, -0.01), M["chapa"])) # hopper: two planes at 60 degrees and a vertical section dx_t, dz_t = HOPPER_X - MOUTH, HOPPER_H largo = math.hypot(dx_t, dz_t) ang = math.atan2(dz_t, dx_t) for s in (-1, 1): cx = s * (MOUTH + dx_t / 2) cz = Z_EXIT + dz_t / 2 # the thickness goes outwards from the hopper nx, nz = s * math.sin(ang), -math.cos(ang) w = box_obj(f"box_tolva_{'i' if s < 0 else 'd'}", (largo, DEPTH, 0.016), (cx + nx * 0.008, 0, cz + nz * 0.008), M["chapa"]) w.rotation_euler = (0, -s * ang, 0) phys.append(w) v = box_obj(f"box_tolva_v{'i' if s < 0 else 'd'}", (0.016, DEPTH, HOPPER_V), (s * (HOPPER_X + 0.008), 0, Z_EXIT + HOPPER_H + HOPPER_V / 2), M["chapa"]) phys.append(v) gate = box_obj("gate_0", (2 * MOUTH + 0.04, DEPTH, 0.012), (0, 0, Z_EXIT - 0.008), M["chapa"]) phys.append(gate) # balls: a grid above the hopper; they fall and settle before frame 1 me_bola = sphere("ball_malla", RB, M["acero"], seg_m=20, rings=12).data bpy.data.objects.remove(bpy.data.objects["ball_malla"]) bolas = [] rnd = random.Random(7) step = 2 * RB * 1.08 per_row = int((2 * (HOPPER_X - 0.02)) / step) alone = make_object("ball_000", me_bola, None) # the one that drops alone, held under the gate alone.location = (0.004, 0, Z_EXIT - 0.032) bolas.append(alone) for n in range(1, N_BOLAS): row, col = divmod(n - 1, per_row) x = (col - (per_row - 1) / 2) * step + (step / 2 if row % 2 else 0) * 0.5 x += rnd.uniform(-0.002, 0.002) z = Z_EXIT + HOPPER_H + 0.03 + row * step * 0.95 ob = make_object(f"ball_{n:03d}", me_bola, None) ob.location = (x, 0, z) bolas.append(ob) phys += bolas # --- visual only --- panel = box_obj("panel", (2 * W2 + 0.10, 0.02, Z_EXIT + HOPPER_H + HOPPER_V + 0.10), (0, DEPTH / 2 + 0.01, (Z_EXIT + HOPPER_H + HOPPER_V) / 2), M["panel"]) for s in (-1, 1): box_obj(f"marco_{s}", (0.07, DEPTH + 0.06, Z_EXIT + 0.12), (s * (W2 + 0.055), 0.0, (Z_EXIT + 0.12) / 2 - 0.02), M["madera"]) box_obj("base", (2 * W2 + 0.40, 0.36, 0.10), (0, 0.05, -0.07), M["base"]) box_obj("pie", (2 * W2 + 0.60, 0.50, 0.04), (0, 0.05, -0.14), M["base"]) return dict(phys=phys, bolas=bolas, M=M, gate=gate) def simulate(T, obj): f_release = T.span(BEAT_RELEASE)[0] f_one = T.span(BEAT_ONE)[0] + 8 cfg = { "frozen": ["ball_000"], "duration": PRE_ROLL + T.n_frames / FPS + 0.2, "gravity": GRAVITY, "rules": { "ball_": {"friction": 0.10, "bounce": BOUNCE, "density": 7800, "ccd": True, "planar": True, "damping": DAMP, "can_sleep": False}, "static_": {"friction": 0.10, "bounce": BOUNCE}, "box_": {"friction": 0.25, "bounce": 0.20}, "gate_": {"friction": 0.25, "bounce": 0.0}, }, "events": [{"t": PRE_ROLL + (f_one - 1) / FPS, "action": "release", "prefix": "ball_000"}, {"t": PRE_ROLL + (f_release - 1) / FPS, "action": "remove", "prefix": "gate_"}], } run_godot(export_physics(NAME_KEY, obj["phys"], cfg)) # --- explanatory strokes --------------------------------------------------------- def path_line(movs): """Polyline of a path through the grid: movs = list of -1/+1.""" pts = [(0.0, -0.05, Z_EXIT - 0.02)] x = 0.0 for i, m in enumerate(movs): pts.append((x, -0.05, Z_ROW[i] + RP + RB)) x += m * DX / 2 pts.append((x, -0.05, 0.35)) return pts def trim(pts, u): """First u (0..1) of the polyline length.""" 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 = Timeline(NAME_KEY) obj = build_scene() if env("MODE", "MODO") == "sim": simulate(T, obj) return idx, D = load_sim(NAME_KEY) pre_frames = int(round(PRE_ROLL * 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] house = np.clip(np.round(xs_fin / DX).astype(int) + ROW_COUNT // 2, 0, NB - 1) count = np.bincount(house, minlength=NB) print(f"[{NAME_KEY}] bins: {count.tolist()} (on the board: {int((zs_fin < HOUSE_H).sum())})") # the highlighted ball: the one that drops alone in beat 1 i_one = idx["ball_000"] one_item = next(b for b in bolas if b.name == "ball_000") f_sale = int(np.argmax(D[:, i_one, 2] < Z_EXIT - 0.05)) - pre_frames + 1 f_arrive = int(np.argmax(D[:, i_one, 2] < 0.6)) - pre_frames + 1 m_one = material("una", "rosa", rough=0.2, metal=0.3, emit=1.6) estela = curve_poly("estela", [[(0, 0, 0)] * 24], thickness_px=0.006, radios=[[0.0] * 24], mat=material("estela", "rosa", emit=2.5)) # paths: the edge one (beat 5) and several through the middle (beat 6) tip_pt = curve_poly("c_punta", [path_line([-1] * ROW_COUNT)], thickness_px=0.010, mat=material("cp", "rosa", emit=3.0)) rnd = random.Random(3) mids = [] for k in range(10): m = [-1] * 6 + [1] * 6 rnd.shuffle(m) mids.append(path_line(m)) c_mids = curve_poly("c_medios", mids, thickness_px=0.006, mat=material("cm", "verde", emit=2.6)) # bell: normal curve fitted to the final heights of each bin heights = [] for k in range(NB): sel = zs_fin[house == k] heights.append(float(sel.max() + RB) if len(sel) else 0.0) mu = float(np.mean(xs_fin[zs_fin < HOUSE_H])) sd = float(np.std(xs_fin[zs_fin < HOUSE_H])) area = sum(heights) * DX xs_c = [(-W2 + 2 * W2 * i / 120) for i in range(121)] bell_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] bell = curve_poly("campana", [bell_pts], thickness_px=0.012, mat=material("cc", "ambar", emit=3.2)) print(f"[{NAME_KEY}] mu={mu:.3f} sd={sd:.3f} (= {sd / (DX / 2):.2f} medios pasos; " f"binomial ideal: {math.sqrt(ROW_COUNT) / 2 * 2:.2f})") # frame around the middle bin (beat 10) m_frame = material("marco_medio", "verde", emit=2.8) h_med = heights[NB // 2] + 0.03 frame_box = curve_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)]], thickness_px=0.008, mat=m_frame) # camera: from the hopper to the whole board lens = 50.0 cam = camera_obj((0, -5, 1), (0, 0, 1), lens=lens) cam.data.sensor_fit = 'VERTICAL' cam.data.sensor_height = 36.0 A = (2.10, 2.0) # (z centre, visible height) B = (0.60, 3.10) light_obj("key", 'AREA', (-1.8, -3.0, 3.2), 520, "blanco", size_u=3.0, sight=(0, 0, 1.2)) light_obj("fill", 'AREA', (2.4, -2.6, 0.6), 110, "blanco", size_u=3.0, sight=(0, 0, 0.8)) light_obj("top", 'AREA', (0.0, -0.8, 3.6), 220, "blanco", size_u=2.0, sight=(0, 0, 1.5)) def refresh(f): i = min(f - 1 + pre_frames, len(D) - 1) for b, j in zip(bolas, ib): set_pose(b, D[i, j]) # camera m = suave((f - f_sale) / max(1, f_arrive - f_sale + 10)) zc = mix_m(A[0], B[0], m) h = mix_m(A[1], B[1], m) drift = 0.03 * math.sin(f / 90.0) dist = h * lens / 36.0 aim_at(cam, (drift, -dist, zc + 0.08), (drift * 0.5, 0, zc)) # the highlighted ball visible = 1.0 if (T.span(1)[0] <= f <= T.span(4)[0]) else 0.0 if visible: if one_item.material_slots and one_item.material_slots[0].link != 'OBJECT': one_item.material_slots[0].link = 'OBJECT' if one_item.material_slots: one_item.material_slots[0].material = m_one elif one_item.material_slots: one_item.material_slots[0].link = 'DATA' pts, rad = [], [] for k in range(24): q = D[max(0, i - (23 - k)), i_one] pts.append((q[0], -0.03, q[2])) rad.append(visible * (k / 23.0) ** 1.5) rebuild_curve(estela, [pts], [rad]) # paths up = suave(T.p(f, 5) / 0.7) * (1.0 - suave((T.p(f, 7) - 0.0) / 0.2)) rebuild_curve(tip_pt, [trim(path_line([-1] * ROW_COUNT), up)]) tip_pt.scale = (1, 1, 1) if 0 < up else (0, 0, 0) cm = [] for k, c in enumerate(mids): u = suave((T.p(f, 6) - k * 0.06) / 0.35) cm.append(trim(c, u)) rebuild_curve(c_mids, cm) outside = 1.0 - suave(T.p(f, 7) / 0.2) c_mids.scale = (1, 1, 1) if (T.p(f, 6) > 0 and outside > 0.01) else (0, 0, 0) # bell uc = suave(T.p(f, 7) / 0.6) rebuild_curve(bell, [trim(bell_pts, uc)]) bell.scale = (1, 1, 1) if uc > 0 else (0, 0, 0) frame_box.scale = (1, 1, 1) if T.p(f, 10) > 0.02 else (0, 0, 0) render_sequence(NAME_KEY, T, refresh) main()