@codincod/ornament 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * What a fish is made of, once, for the two things that draw one.
3
+ *
4
+ * On the site, `water.ts` paints onto a canvas and `glass.ts` sets an SVG path, and both
5
+ * have to be drawing the same animal or the homepage and the porthole hold two
6
+ * different species. What they share is one `d` string per beat of the tail.
7
+ *
8
+ * ## Why the body is generated and not a path
9
+ *
10
+ * A rigid body with a hinged tail is what this used to be, and it does not read
11
+ * as swimming. A fish does not tow its tail about behind it: the whole animal
12
+ * is the engine. A wave of curvature runs head to tail down the body, growing
13
+ * as it goes, and the tail is only where the wave finally arrives. That is
14
+ * carangiform swimming, which is most of the fish anybody has watched.
15
+ *
16
+ * So the outline here is built each beat from a spine that carries that wave.
17
+ * The envelope is Lighthill's: nearly still a quarter of the way back from the
18
+ * nose, which is the node every swimming fish has, a little sway at the head
19
+ * because the water pushes back, and the whole excursion at the peduncle. The
20
+ * fins are hung on the spine at the station they grow from and lean with it, so
21
+ * the dorsal tips as the back curves under it and the tail sweeps because the
22
+ * body it is joined to has swept, not because anything rotated it.
23
+ *
24
+ * ## How far it sweeps is half of a pair
25
+ *
26
+ * `SWEEP` here and `STRIDE` in `shoal.ts` are one decision written in two
27
+ * files, and neither is worth changing without the other. Between them they set
28
+ * how much water the animal beats for how far it gets, which is the one thing
29
+ * an onlooker judges swimming by: a fish whose tail works harder than its
30
+ * travel accounts for does not read as trying, it reads as stuck. The pair is
31
+ * checked in `fish_shape.test.ts`.
32
+ *
33
+ * ## Why the animal is one colour
34
+ *
35
+ * It was drawn in layers once, a back over a body with a gill and a pectoral,
36
+ * to give it a lit side. At the size these are drawn it reads as smudge. One
37
+ * silhouette with the eye punched out of it is the older drawing and the better
38
+ * one, and it is what the porthole's hand-placed fish always were.
39
+ *
40
+ * Everything is nose-right in the 200-unit space the porthole's scenes use,
41
+ * with the tail joint at the origin, so a body length in that space is `SPAN`.
42
+ */
43
+
44
+ /** Nose to tail, which is what a caller's body length buys. */
45
+ export const SPAN = 38
46
+
47
+ /** How far forward of the joint the nose sits; the spine's whole length. */
48
+ const NOSE = 29
49
+
50
+ /**
51
+ * The silhouette, as the cubic that used to be the whole fish.
52
+ *
53
+ * Sampling the old curve rather than inventing a profile keeps the animal the
54
+ * one the windows have always held. Its half-height works out to `24t(1-t)`,
55
+ * which peaks at 6 a little forward of the middle.
56
+ */
57
+ const along = (t: number): number => 21 * t * (1 - t) ** 2 + 63 * t ** 2 * (1 - t) + 29 * t ** 3
58
+
59
+ /**
60
+ * Half the body's depth at `t`, plus a peduncle.
61
+ *
62
+ * The old curve closed to a point where the tail joins, which is fine on a
63
+ * drawing that never moves and wrong on one that does: at the top of a stroke
64
+ * the fin is hinged on nothing and reads as having come off. Real fish have a
65
+ * narrow wrist there and so does this one now.
66
+ */
67
+ const half = (t: number): number => 24 * t * (1 - t) + WRIST * (1 - t)
68
+
69
+ /** How deep that wrist is, half of it, in body units. */
70
+ const WRIST = 1.4
71
+
72
+ /** How many stations the outline is built from, along each side. */
73
+ const STATIONS = 20
74
+
75
+ /**
76
+ * How far the tail's tip sweeps, peak to peak, as a share of the body.
77
+ *
78
+ * A fifth of its own length, which is what fast swimmers hold to whatever they
79
+ * are doing: measured across species, tail amplitude stays near a fifth of the
80
+ * body and it is the beat that changes with the speed. It is the number
81
+ * `shoal.ts` pairs its stride with, and the two together are what put this
82
+ * animal in the Strouhal band real ones swim in.
83
+ *
84
+ * ## What was wrong with setting the peduncle instead
85
+ *
86
+ * This used to be a tenth of the body each way, given to the peduncle, on the
87
+ * argument that a tenth of a body length is what a fish of this build does.
88
+ * That amplitude at the peduncle is right. The trouble is that the caudal fin
89
+ * hangs off the end of it: the fin travels with the joint and swings with it as well, and the joint's
90
+ * own slope reaches thirty-odd degrees at the top of a stroke, so the tip went
91
+ * two and a half times as far as the joint did. Measured, it swept 0.72 of a
92
+ * body peak to peak.
93
+ *
94
+ * A fish sweeping three and a half times what a fish sweeps does not read as
95
+ * swimming hard, because nothing about how far it travels agrees with it. It
96
+ * reads as a shape flapping in place, and that was the whole of what was wrong
97
+ * with this ornament. So the number set here is the one anybody watching can
98
+ * see, and the peduncle's swing is solved from it.
99
+ */
100
+ export const SWEEP = 0.2
101
+
102
+ /** How much of a wave fits on the body at once. */
103
+ const WAVES = 0.85
104
+
105
+ const TWIST = 2 * Math.PI * WAVES
106
+
107
+ /**
108
+ * The share of the tail's swing each station gets, from the nose back.
109
+ *
110
+ * Lighthill's envelope, normalised so the peduncle gets all of it. It is a
111
+ * parabola with its floor a quarter of the way back, which is the node: pick a
112
+ * point on a swimming fish that stays put and that is where it is.
113
+ */
114
+ const envelope = (u: number): number => 0.2 - 0.8 * u + 1.6 * u * u
115
+
116
+ /** The wave the body carries, before any swing is given to it. */
117
+ const wave = (u: number, phase: number): number => envelope(u) * Math.sin(TWIST * u - phase)
118
+
119
+ /** How far either side of a station its own slope is read. */
120
+ const SLOPE = 0.6
121
+
122
+ /** The caudal fin: how far back it reaches, how wide it forks, and its notch. */
123
+ const FLUKE_BACK = -9
124
+
125
+ const FLUKE_SPREAD = 7
126
+
127
+ const FLUKE_NOTCH = -7
128
+
129
+ /** How many points of a beat the sweep is measured at. */
130
+ const SAMPLES = 90
131
+
132
+ /**
133
+ * How far the tail's tip travels across a beat, for a given swing at the joint.
134
+ *
135
+ * The tip is not on the spine, so this is the only honest way to ask: it rides
136
+ * the joint and turns with it, and the two do not peak together. Everything
137
+ * else here is linear in `amp`, but the joint's angle is an arctangent of it,
138
+ * so the answer is not a multiple of anything and has to be measured.
139
+ */
140
+ function reach(amp: number): number {
141
+ let low = Number.POSITIVE_INFINITY
142
+ let high = Number.NEGATIVE_INFINITY
143
+
144
+ for (let at = 0; at < SAMPLES; at++) {
145
+ const phase = (at / SAMPLES) * 2 * Math.PI
146
+ const sway = (x: number): number => amp * wave(1 - x / NOSE, phase)
147
+ const angle = Math.atan2(sway(SLOPE) - sway(-SLOPE), 2 * SLOPE)
148
+ const tip = sway(0) + FLUKE_BACK * Math.sin(angle)
149
+
150
+ low = Math.min(low, tip)
151
+ high = Math.max(high, tip)
152
+ }
153
+
154
+ return high - low
155
+ }
156
+
157
+ /**
158
+ * The swing at the peduncle whose tip sweeps `SWEEP`, walked on to.
159
+ *
160
+ * A handful of passes rather than a solve, because `reach` is nearly a straight
161
+ * multiple over the range that matters and each pass takes what is left of the
162
+ * error out.
163
+ */
164
+ function fitted(): number {
165
+ let amp = SWEEP * SPAN
166
+
167
+ for (let pass = 0; pass < 8; pass++) amp *= (SWEEP * SPAN) / reach(amp)
168
+
169
+ return amp
170
+ }
171
+
172
+ /** How far the peduncle swings each way, in body units; see `SWEEP`. */
173
+ const AMP = fitted()
174
+
175
+ /** Where the spine is at one station, and which way it is heading there. */
176
+ interface Station {
177
+ angle: number
178
+ x: number
179
+ y: number
180
+ }
181
+
182
+ function spine(x: number, phase: number): number {
183
+ return AMP * wave(1 - x / NOSE, phase)
184
+ }
185
+
186
+ /** The spine at `x`, with the angle read off its own slope either side. */
187
+ function station(x: number, phase: number): Station {
188
+ const back = spine(x - SLOPE, phase)
189
+ const front = spine(x + SLOPE, phase)
190
+
191
+ return { angle: Math.atan2(front - back, 2 * SLOPE), x, y: spine(x, phase) }
192
+ }
193
+
194
+ /** A point `lift` clear of the spine at `x`, square to the way the spine runs. */
195
+ const clear = (on: Station, lift: number): [number, number] => [
196
+ on.x + Math.sin(on.angle) * lift,
197
+ on.y - Math.cos(on.angle) * lift,
198
+ ]
199
+
200
+ /** The same, for a caller that has only the station it wants. */
201
+ const above = (x: number, lift: number, phase: number): [number, number] =>
202
+ clear(station(x, phase), lift)
203
+
204
+ const fixed = (value: number): string => value.toFixed(2)
205
+
206
+ /**
207
+ * A closed outline through its own points, rounded.
208
+ *
209
+ * Corners land on the midpoints and the points themselves become the controls,
210
+ * which is the cheapest smoothing there is and the right one here: the nose and
211
+ * the peduncle are meant to be round, and twenty stations of a straight-edged
212
+ * fish would show every one of them.
213
+ */
214
+ function rounded(points: readonly [number, number][]): string {
215
+ const mid = (a: [number, number], b: [number, number]): string =>
216
+ `${fixed((a[0] + b[0]) / 2)} ${fixed((a[1] + b[1]) / 2)}`
217
+
218
+ const last = points[points.length - 1]!
219
+ let d = `M${mid(last, points[0]!)}`
220
+
221
+ for (let at = 0; at < points.length; at++) {
222
+ const here = points[at]!
223
+ const next = points[(at + 1) % points.length]!
224
+ d += `Q${fixed(here[0])} ${fixed(here[1])} ${mid(here, next)}`
225
+ }
226
+
227
+ return `${d}Z`
228
+ }
229
+
230
+ /** A straight-sided fin, as its own subpath, wound the way the body is. */
231
+ const fin = (points: readonly [number, number][]): string =>
232
+ `M${points.map(([x, y]) => `${fixed(x)} ${fixed(y)}`).join("L")}Z`
233
+
234
+ /**
235
+ * The dorsal, standing on the back wherever the back is.
236
+ *
237
+ * Its stations are given along the body's own curve and its base is set below
238
+ * the local edge, so it cannot climb off a body that has just bent away from
239
+ * under it. That was the whole of its old trouble: fixed at a height the body
240
+ * only has in the middle.
241
+ */
242
+ function dorsal(phase: number): string {
243
+ const foot = (t: number, over: number): [number, number] => {
244
+ const x = along(t)
245
+ return clear(station(x, phase), half(t) + over)
246
+ }
247
+
248
+ return fin([foot(0.38, -SET), foot(0.52, TIP), foot(0.66, -SET)])
249
+ }
250
+
251
+ /** How far under the body's edge the dorsal is planted, and how far over it. */
252
+ const SET = 0.8
253
+
254
+ const TIP = 5
255
+
256
+ /** The body, both sides of it, off one spine. */
257
+ function outline(phase: number): [number, number][] {
258
+ const top: [number, number][] = []
259
+ const bottom: [number, number][] = []
260
+
261
+ for (let at = 0; at <= STATIONS; at++) {
262
+ const t = at / STATIONS
263
+ const x = along(t)
264
+ const on = station(x, phase)
265
+ const lift = half(t)
266
+
267
+ top.push(clear(on, lift))
268
+ bottom.push(clear(on, -lift))
269
+ }
270
+
271
+ return [...top, ...bottom.reverse()]
272
+ }
273
+
274
+ /** How far inside the body the tail's root is buried, so the join never opens. */
275
+ const ROOT = 2.5
276
+
277
+ /**
278
+ * The tail, hung off the end of the spine.
279
+ *
280
+ * Its points are the old fin's, carried along whatever the peduncle is doing.
281
+ * Nothing rotates it: the joint has swung, so the fin has swung. They are wound
282
+ * the way the body is wound, because every part of the animal is one filled
283
+ * path and a part running the other way would punch a hole in the one under it.
284
+ */
285
+ function tail(phase: number): string {
286
+ const joint = station(0, phase)
287
+ const cos = Math.cos(joint.angle)
288
+ const sin = Math.sin(joint.angle)
289
+
290
+ const carry = ([x, y]: [number, number]): [number, number] => [
291
+ joint.x + x * cos - y * sin,
292
+ joint.y + x * sin + y * cos,
293
+ ]
294
+
295
+ return fin([
296
+ carry([ROOT, 0]),
297
+ carry([FLUKE_BACK, FLUKE_SPREAD]),
298
+ carry([FLUKE_NOTCH, 0]),
299
+ carry([FLUKE_BACK, -FLUKE_SPREAD]),
300
+ ])
301
+ }
302
+
303
+ /** One drawing of the animal: every part of it, and where to punch the eye. */
304
+ export interface Frame {
305
+ /** Which of `STEPS` this is, so a caller can cache what it built from it. */
306
+ readonly at: number
307
+ /**
308
+ * The same animal with a bill on the nose, for the kinds that have one.
309
+ *
310
+ * A whole second drawing rather than a bill to lay over the first. The bill's
311
+ * root is buried in the head, and an ornament is painted at a weight rather
312
+ * than solid, so two fills paint that overlap twice: the swordfish wore a
313
+ * bright wedge from its nose to its eye, which is the seam a reader notices
314
+ * before anything else in the water. One path filled once has no overlap to
315
+ * expose.
316
+ *
317
+ * What it costs is a string per step of the beat, which the cache pays once.
318
+ * The renderer picks by species, since that is the only place that knows.
319
+ */
320
+ readonly billed: string
321
+ readonly d: string
322
+ readonly eye: { readonly r: number; readonly x: number; readonly y: number }
323
+ }
324
+
325
+ /**
326
+ * How many drawings one beat is cut into.
327
+ *
328
+ * They are shared by every fish on the page and built once each, because a
329
+ * shoal of ten at sixty frames a second is otherwise six hundred outlines a
330
+ * second and all of them the same handful of shapes.
331
+ */
332
+ export const STEPS = 48
333
+
334
+ const drawn: (Frame | undefined)[] = new Array(STEPS)
335
+
336
+ const EYE_R = 1.5
337
+
338
+ const EYE_X = 22
339
+
340
+ const EYE_LIFT = 2
341
+
342
+ function build(at: number): Frame {
343
+ const phase = (at / STEPS) * 2 * Math.PI
344
+ const [x, y] = above(EYE_X, EYE_LIFT, phase)
345
+ const d = rounded(outline(phase)) + tail(phase) + dorsal(phase)
346
+
347
+ return {
348
+ at,
349
+ billed: d + bill(phase),
350
+ d,
351
+ eye: { r: EYE_R, x, y },
352
+ }
353
+ }
354
+
355
+ /**
356
+ * A bill, carried rigidly off the nose.
357
+ *
358
+ * Carried and not bent, the way the tail is carried off the joint. A swordfish's
359
+ * bill is skull: it goes where the head goes and it does not flex, so what it
360
+ * does over a beat is swing bodily with the front of the animal. Extending the
361
+ * spine's own wave past the nose would have it whipping, which is the one thing
362
+ * it must not do.
363
+ *
364
+ * It is rooted inside the head rather than butted against it, and wound the way
365
+ * the body is wound, for the same reason every other part is: they are all one
366
+ * filled path and a part running the other way punches a hole in what is under
367
+ * it. A butted bill would want the join to be exact at every step of the beat,
368
+ * and the one step where it was not would show as a gap in the animal's face.
369
+ */
370
+ function bill(phase: number): string {
371
+ const nose = station(NOSE, phase)
372
+ const cos = Math.cos(nose.angle)
373
+ const sin = Math.sin(nose.angle)
374
+
375
+ const carry = ([x, y]: [number, number]): [number, number] => [
376
+ nose.x + x * cos - y * sin,
377
+ nose.y + x * sin + y * cos,
378
+ ]
379
+
380
+ return fin([
381
+ carry([-BILL_ROOT, -BILL_GIRTH]),
382
+ carry([BILL_REACH, 0]),
383
+ carry([-BILL_ROOT, BILL_GIRTH]),
384
+ ])
385
+ }
386
+
387
+ /**
388
+ * How far the bill reaches past the nose, how deep it is at the head, and how
389
+ * far back into the head it is buried, all in body units.
390
+ *
391
+ * Long. A bill is about a third of a swordfish and the whole silhouette turns
392
+ * on it: shorten it to something tasteful and what comes out is a fish with a
393
+ * pointed face. `SPAN` is 38, so this is not far off the real proportion.
394
+ */
395
+ const BILL_REACH = 15
396
+
397
+ const BILL_GIRTH = 1.1
398
+
399
+ const BILL_ROOT = 3
400
+
401
+ /** The drawing for a beat, to the nearest step, built once and kept. */
402
+ export function frameAt(phase: number): Frame {
403
+ const at = ((Math.round((phase / (2 * Math.PI)) * STEPS) % STEPS) + STEPS) % STEPS
404
+ const had = drawn[at]
405
+ if (had) return had
406
+
407
+ const made = build(at)
408
+ drawn[at] = made
409
+ return made
410
+ }