Initial commit 🚀
This commit is contained in:
commit
fd58a8d32f
235 files changed
+19775
No files matched your search
@@ -0,0 +1,158 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user