aboutsummaryrefslogtreecommitdiffstats
path: root/blender/bolapeluda.py
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 20:50:41 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 20:50:41 -0300
commitfafaebb051907a848a9406f9da19669c81a83a3b (patch)
treec30ea26e6b549e5523af2bae5c39569e9a946b12 /blender/bolapeluda.py
parent59355909f2de9236af8168a26c70bcf6caa3b285 (diff)
download100cia-videos-fafaebb051907a848a9406f9da19669c81a83a3b.tar.gz
100cia-videos-fafaebb051907a848a9406f9da19669c81a83a3b.zip
Translate code, comments and logs to English; English README; configurable paths and env varsHEADmain
Identifiers, docstrings, comments and console messages are now in English. Narration, subtitles and on-screen text stay in Spanish (they are the video content). The Blender <-> Godot physics protocol uses English keys and body prefixes chosen to keep the original creation order, so cached simulations and renders stay bit-identical. The old Spanish environment variable names are still accepted.
Diffstat (limited to 'blender/bolapeluda.py')
-rw-r--r--blender/bolapeluda.py274
1 files changed, 137 insertions, 137 deletions
diff --git a/blender/bolapeluda.py b/blender/bolapeluda.py
index a260784..fe7a377 100644
--- a/blender/bolapeluda.py
+++ b/blender/bolapeluda.py
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
-"""BOLA PELUDA - el teorema de la bola peluda (Brouwer, 1912).
+"""BOLA PELUDA - the hairy ball theorem (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.
+Each hair lies down along the tangent field v(p) = A x p, as much as the
+field is strong: where |v| vanishes (the two poles of axis A) the hair has
+nowhere to lie and stands up. So the swirl is not drawn by hand, it comes
+from the same computation that combs the rest.
"""
import math, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -12,20 +12,20 @@ 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"),
+NAME_KEY = "bolapeluda"
+R = 1.15 # sphere radius
+LARGO = 0.34 # hair length
+K = 7 # points per hair
+N = 1500 # number of hairs
+THRESHOLD = 0.42 # |v| below this, the hair never lies down
+CZ = 0.50 # height of the centre in the frame
+
+# Combings: (axis, kind). The axes lie almost in the screen plane (XZ) so the
+# TWO zeros fall on the silhouette and show together.
+# rot -> field v = A x p (combs in circles, like latitudes)
+# mer -> field v = A - (A.p) p (combs from pole to pole, along meridians)
+# Both vanish exactly where p is parallel to A: that is where the swirl is.
+COMBINGS = [((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"),
@@ -33,17 +33,17 @@ PEINADOS = [((0.80, -0.05, 0.60), "rot"),
((0.80, -0.05, 0.60), "rot")]
-def normaliza(v):
+def normalize_text(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):
+def cross_m(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."""
+def sphere_points(n):
+ """Fibonacci spiral: spreads n points almost evenly over the sphere."""
pts, ga = [], math.pi * (3 - math.sqrt(5))
for i in range(n):
z = 1 - 2 * (i + 0.5) / n
@@ -53,36 +53,36 @@ def puntos_esfera(n):
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 field(p, axis_obj, kind_m):
+ if kind_m == "mer":
+ d = sum(a * b for a, b in zip(axis_obj, p))
+ return tuple(axis_obj[i] - d * p[i] for i in range(3))
+ return cross_m(axis_obj, 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)
+def hair(p, axis_obj, combed, kind_m="rot", radio=R, largo=LARGO):
+ """Returns the K points of the hair rooted at p and the value of |v| there."""
+ v = field(p, axis_obj, kind_m)
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)
+ else: # at the zero there is no direction: it stands up
+ t = normalize_text(cross_m(p, (0.0, 0.0, 1.0)) if abs(p[2]) < 0.9 else (1.0, 0.0, 0.0))
+ b = combed * min(1.0, m / THRESHOLD)
pos = [tuple(c * radio for c in p)]
- paso = largo / (K - 1)
+ step = 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))
+ pos.append((a[0] + d[0] * step, a[1] + d[1] * step, a[2] + d[2] * step))
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."""
+def torus_hairs(nu=60, nv=24, RT=0.90, rt=0.34, largo=0.22):
+ """Donut hairs, combed by the toroidal tangent field: it never vanishes."""
splines, radios = [], []
for i in range(nu):
u = 2 * math.pi * i / nu
@@ -92,169 +92,169 @@ def pelos_toro(nu=60, nv=24, RT=0.90, rt=0.34, largo=0.22):
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
+ t = (-su, cu, 0.0) # toroidal tangent, |t| = 1
pos = [p]
- paso = largo / (K - 1)
+ step = 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))
+ pos.append((a[0] + d[0] * step, a[1] + d[1] * step, a[2] + d[2] * step))
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)
+def build_scene(T):
+ sc = scene_setup()
+ lens, FRAME_H = 70.0, 6.0
+ camera_obj((0.0, -lens / 36.0 * FRAME_H, 0.0), (0.0, 0.0, 0.0), lens=lens)
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))
+ light_obj("key", 'AREA', (-3.2, -4.6, 3.8), 800, "blanco", size_u=5.0, sight=(0, 0, CZ))
+ light_obj("fill", 'AREA', (3.8, -3.6, -1.2), 260, "celeste", size_u=5.0, sight=(0, 0, CZ))
+ light_obj("rim", 'AREA', (0.4, 4.2, 2.6), 620, "ambar", size_u=4.0, sight=(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 = []
+ m_skin = material("piel", "azul", rough=0.55, metal=0.1)
+ ball = sphere("bola", R * 0.985, m_skin, seg_m=64, rings=36)
+ ball.parent = rig
+
+ base_points = sphere_points(N)
+ splines = [hair(p, COMBINGS[0][0], 0.0)[0] for p in base_points]
+ radios = [[1.0 - 0.70 * (j / (K - 1.0)) for j in range(K)] for _ in base_points]
+ hair_a = curve_poly("pelo_a", splines, thickness_px=0.0135, radios=radios,
+ mat=material("m_pelo", "ambar", rough=0.42, emit=0.55))
+ hair_b = curve_poly("pelo_b", splines, thickness_px=0.0135, radios=radios,
+ mat=material("m_remol", "rosa", rough=0.42, emit=1.9))
+ hair_a.parent = hair_b.parent = rig
+
+ # marks on the zeros
+ tick_list = []
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)
+ ring = torus(f"anillo{k}", 0.34, 0.024,
+ material(f"mm{k}", "rosa", emit=3.2), u=48, v=10)
+ ring.parent = g
+ tick_list.append(g)
- # la rosquilla, peinada de una
- sp_t, ra_t = pelos_toro()
+ # the donut, combed in one go
+ sp_t, ra_t = torus_hairs()
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))
- rosquilla.parent = rig_t
- pelo_t = curva_poly("pelo_toro", sp_t, grosor=0.0125, radios=ra_t,
- mat=material("m_pelo_t", "verde", rug=0.42, emis=0.8))
- pelo_t.parent = rig_t
- rig_t.rotation_mode = 'ZYX' # primero gira sobre su eje, despues se inclina
+ donut = torus("rosquilla", 0.90, 0.335, material("piel_t", "azul", rough=0.55))
+ donut.parent = rig_t
+ hair_t = curve_poly("pelo_toro", sp_t, thickness_px=0.0125, radios=ra_t,
+ mat=material("m_pelo_t", "verde", rough=0.42, emit=0.8))
+ hair_t.parent = rig_t
+ rig_t.rotation_mode = 'ZYX' # first it spins about its axis, then it tilts
rig_t.scale = (0, 0, 0)
- etq = {"esf": texto("2", tam=0.52, color="ambar"),
- "ros": texto("0", tam=0.52, color="verde"),
- "pie": texto("agujeros que cuentan", tam=0.19, color="gris")}
- for o in etq.values():
+ tag_m = {"esf": txt_m("2", size_u=0.52, color="ambar"),
+ "ros": txt_m("0", size_u=0.52, color="verde"),
+ "pie": txt_m("agujeros que cuentan", size_u=0.19, color="gris")}
+ for o in tag_m.values():
o.scale = (0, 0, 0)
- return dict(rig=rig, rig_t=rig_t, bola=bola, pel_a=pel_a, pel_b=pel_b,
- marcas=marcas, base=base_pts, etq=etq, piel=m_piel, rosq=rosquilla)
+ return dict(rig=rig, rig_t=rig_t, ball=ball, hair_a=hair_a, hair_b=hair_b,
+ tick_list=tick_list, base=base_points, tag_m=tag_m, skin=m_skin, donut_obj=donut)
def main():
- T = Tiempo(NOMBRE)
- ob = construir(T)
- base, pel_a, pel_b = ob["base"], ob["pel_a"], ob["pel_b"]
+ T = Timeline(NAME_KEY)
+ ob = build_scene(T)
+ base, hair_a, hair_b = ob["base"], ob["hair_a"], ob["hair_b"]
cache = {"clave": None}
- def peinar(idx_eje, peinado):
- clave = (idx_eje, round(peinado, 3))
- if cache["clave"] == clave:
+ def comb(axis_idx, combed):
+ key_name = (axis_idx, round(combed, 3))
+ if cache["clave"] == key_name:
return
- cache["clave"] = clave
- eje = normaliza(PEINADOS[idx_eje][0])
- tipo = PEINADOS[idx_eje][1]
+ cache["clave"] = key_name
+ axis_obj = normalize_text(COMBINGS[axis_idx][0])
+ kind_m = COMBINGS[axis_idx][1]
sp, ra_a, ra_b = [], [], []
for p in base:
- pos, m = pelo(p, eje, peinado, tipo)
+ pos, m = hair(p, axis_obj, combed, kind_m)
sp.append(pos)
tap = [1.0 - 0.70 * (j / (K - 1.0)) for j in range(K)]
- remolino = m < UMBRAL * 0.62 and peinado > 0.25
- ra_a.append([0.0 if remolino else t for t in tap])
- ra_b.append([t * 1.25 if remolino else 0.0 for t in tap])
- rehacer_curva(pel_a, sp, ra_a)
- rehacer_curva(pel_b, sp, ra_b)
+ swirl = m < THRESHOLD * 0.62 and combed > 0.25
+ ra_a.append([0.0 if swirl else t for t in tap])
+ ra_b.append([t * 1.25 if swirl else 0.0 for t in tap])
+ rebuild_curve(hair_a, sp, ra_a)
+ rebuild_curve(hair_b, sp, ra_b)
- def actualizar(f):
+ def refresh(f):
t = T.t(f)
- # se mece en vez de girar: asi los dos ceros no se van al fondo
+ # it rocks instead of spinning: that way the two zeros do not go to the back
ob["rig"].rotation_euler = (0.0, 0.0, 0.26 * math.sin(0.42 * t))
ob["rig_t"].rotation_euler = (0.56, 0.0, 0.5 + 0.22 * t)
- # que peinado toca y cuanto esta peinado
- if f < T.rango(2)[0]:
- idx, peinado = 0, 0.0
- elif f < T.rango(4)[0]:
- idx, peinado = 0, suave(T.p(f, 2) * 1.35)
- elif f < T.rango(6)[0]:
+ # which combing is on and how combed it is
+ if f < T.span(2)[0]:
+ idx, combed = 0, 0.0
+ elif f < T.span(4)[0]:
+ idx, combed = 0, suave(T.p(f, 2) * 1.35)
+ elif f < T.span(6)[0]:
p4 = T.p(f, 4)
idx = 0 if p4 < 0.35 else 1
- peinado = 1.0 - suave(p4 / 0.35) if p4 < 0.35 else suave((p4 - 0.35) / 0.5)
- elif f < T.rango(7)[0]:
- idx, peinado = 1, 1.0
- elif f < T.rango(8)[0]: # desfile de peinados
+ combed = 1.0 - suave(p4 / 0.35) if p4 < 0.35 else suave((p4 - 0.35) / 0.5)
+ elif f < T.span(7)[0]:
+ idx, combed = 1, 1.0
+ elif f < T.span(8)[0]: # parade of combings
q = T.p(f, 7) * 3.0
idx = 1 + min(3, int(q))
sub = q - int(q)
- peinado = min(1.0, sub * 2.6)
+ combed = min(1.0, sub * 2.6)
else:
- idx, peinado = 4, 1.0
- peinar(idx, peinado)
-
- # marcas en los dos ceros del campo
- eje = normaliza(PEINADOS[idx][0])
- vis = suave((T.p(f, 3) - 0.1) / 0.4) if f >= T.rango(3)[0] else 0.0
- if f >= T.rango(9)[0]:
- vis *= 1.0 - suave(T.p(f, 9) / 0.4)
- for k, g in enumerate(ob["marcas"]):
+ idx, combed = 4, 1.0
+ comb(idx, combed)
+
+ # marks on the two zeros of the field
+ axis_obj = normalize_text(COMBINGS[idx][0])
+ visible = suave((T.p(f, 3) - 0.1) / 0.4) if f >= T.span(3)[0] else 0.0
+ if f >= T.span(9)[0]:
+ visible *= 1.0 - suave(T.p(f, 9) / 0.4)
+ for k, g in enumerate(ob["tick_list"]):
s = 1 if k == 0 else -1
- d = tuple(c * s for c in eje)
- # el aro flota por encima del pelo, si no queda enterrado
+ d = tuple(c * s for c in axis_obj)
+ # the ring floats above the hair, otherwise it gets buried
g.location = tuple(c * (R + LARGO * 0.62) for c in d)
g.rotation_euler = Vector(d).to_track_quat('Z', 'Y').to_euler()
- pul = 1.0 + 0.10 * math.sin(t * 4.2)
- g.scale = (vis * pul,) * 3
+ inch = 1.0 + 0.10 * math.sin(t * 4.2)
+ g.scale = (visible * inch,) * 3
- # el planeta: el pelo se vuelve viento
+ # the planet: the hair turns into wind
pl = suave(T.p(f, 8))
- ob["piel"].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = \
+ ob["skin"].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = \
(*tuple(a + (b - a) * pl for a, b in zip(srgb("azul"), srgb("#0E5A73"))), 1)
- # la rosquilla entra en el beat 9
- ent = suave((T.p(f, 9) - 0.15) / 0.5)
- sal = suave((T.p(f, 10) - 0.05) / 0.35)
- s_esf = (1.0 - ent) + sal * 0.60
- ob["rig"].scale = (s_esf,) * 3
- ob["rig"].location = (0.0 - 1.00 * sal, 0.0, CZ + 0.22 * sal)
- s_ros = ent * (1.0 - 0.38 * sal)
- ob["rig_t"].scale = (s_ros,) * 3
- ob["rig_t"].location = (0.0 + 1.02 * sal, 0.0, CZ + 0.22 * sal)
-
- e = ob["etq"]
+ # the donut comes in at beat 9
+ whole = suave((T.p(f, 9) - 0.15) / 0.5)
+ out_node = suave((T.p(f, 10) - 0.05) / 0.35)
+ s_sph = (1.0 - whole) + out_node * 0.60
+ ob["rig"].scale = (s_sph,) * 3
+ ob["rig"].location = (0.0 - 1.00 * out_node, 0.0, CZ + 0.22 * out_node)
+ s_donut = whole * (1.0 - 0.38 * out_node)
+ ob["rig_t"].scale = (s_donut,) * 3
+ ob["rig_t"].location = (0.0 + 1.02 * out_node, 0.0, CZ + 0.22 * out_node)
+
+ e = ob["tag_m"]
e["esf"].location = (-1.02, 0.0, CZ - 0.98)
e["ros"].location = (1.05, 0.0, CZ - 0.98)
e["pie"].location = (0.0, 0.0, CZ - 1.42)
for o in e.values():
- o.scale = (sal,) * 3
+ o.scale = (out_node,) * 3
- render_secuencia(NOMBRE, T, actualizar)
+ render_sequence(NAME_KEY, T, refresh)
main()