Files
2026-10-09 14:00:40 +02:00

159 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Give every paper in src/data/research.json its own Chladni figure and colour field.
Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
and their positions), seeded from the paper id so a re-run gives the same result and a new paper
gets its own. Generation follows the rules learned while hand-tuning the first figures:
- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
(the field is zero along a straight line there), plates are never mirrored (the seams read as
straight lines), and the
weights avoid a = ±b (which zeroes the diagonals u = ±v);
- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
- source figures use three to six sources (two sources draw a straight bisector), spread over
enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
0.35 for three or four); packed tighter they collapse into plain concentric rings;
- one colour patch sits in the middle of the card, so no field is mostly black;
- line density, the share of grid cells a nodal line passes through, stays within the range of
the hand-picked figures, so no card is near-empty or a scribble.
A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
Usage: python3 scripts/research-figures.py [--regenerate] [--report]
"""
import hashlib, json, math, random, sys
PATH = 'src/data/research.json'
ASPECT = 3 # the card canvas is 3:1
Q = 61 # grid for the density check (the site renders at 121)
COLOURS = [
'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
]
# Light tones carry the glow; at most one per field, so every card keeps a dark side.
LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
def field_fn(f, shape=None):
s = {**f, **(shape or {})}
if f['mode'] == 'plate':
a, b, n, m = s['a'], s['b'], s['n'], s['m']
rot = s.get('rotation', 0)
c, si = math.cos(rot), math.sin(rot)
ox, oy = f['origin']
def z(x, y):
dx, dy = x - ox, y - oy
u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
return z
pts = []
for i in range(f['sources']):
t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
k = 2 * math.pi * s['frequency']
inv = 1 / s['elasticity']
return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
def density(f, shape=None):
z = field_fn(f, shape)
h = f['length']; w = h * ASPECT
x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
return cells / (Q - 1) ** 2
def straight_lines(n, m, origin, hx, hy):
"""True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
plate vanish there for every value of the other coordinate, which draws a straight line."""
for centre, half in ((origin[0], hx), (origin[1], hy)):
steps = 400
for i in range(steps + 1):
t = centre - half + 2 * half * i / steps
if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
return True
return False
def rnd(rng, lo, hi, step=0.01):
return round(rng.uniform(lo, hi) / step) * step
def make_plate(rng, rotated):
while True:
n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
if abs(n - m) < 0.35: continue
length = rnd(rng, 1.0, 2.8)
if not (2.4 <= max(n, m) * length <= 8.5): continue
a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
if abs(abs(a) - abs(b)) < 0.25: continue
rotation = rnd(rng, 0.2, 1.3) if rotated else 0
# Keep the whole (rotated) window off both axes.
half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
hx = half_diag if rotated else length * ASPECT / 2
hy = half_diag if rotated else length / 2
origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
if rotated: f['rotation'] = rotation
if straight_lines(n, m, origin, hx, hy): continue
# Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
# (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
ha = None
for _ in range(20):
cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
if abs(abs(cand) - abs(b)) >= 0.2:
ha = cand; break
if ha is None: continue
f['hover'] = {'a': ha}
return f
def make_sources(rng):
while True:
count = rng.choice([3, 3, 4, 4, 5, 6])
length = rnd(rng, 1.8, 2.8)
f = {
'mode': 'sources', 'sources': count,
'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
'spin': rnd(rng, 0, 2 * math.pi / count),
}
if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
return f
def make_field(rng):
while True:
cols = rng.sample(COLOURS, 3)
if sum(c in LIGHT for c in cols) <= 1: break
pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
return {'colors': cols, 'positions': pos}
def generate(pid, lo, hi):
rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
for _ in range(200):
kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
d0, d1 = density(f), density(f, f['hover'])
if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
return f, make_field(rng), d0
raise SystemExit('no acceptable figure for ' + pid)
if __name__ == '__main__':
data = json.load(open(PATH))
# Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
# the two busiest, which read as scribbles at small card sizes.
lo, hi = 0.23, 0.45
regen = '--regenerate' in sys.argv
for p in data['papers']:
if 'figure' in p and not regen: continue
p['figure'], p['field'], d = generate(p['id'], lo, hi)
if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)