"""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)