@codincod/ornament 0.0.0-stage → 0.2.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,231 @@
1
+ /**
2
+ * The ground, and everything rooted in it.
3
+ *
4
+ * The sand, the hills standing behind it, the reef's mound, the wall at the
5
+ * back, and every plant growing on any of them. It is the one part of the
6
+ * picture that does not move under its own steam: a plant leans on the current
7
+ * and nothing here goes anywhere.
8
+ *
9
+ * ## Why a renderer on a card does not use this
10
+ *
11
+ * Because it can do better. A plant is a short tree of limbs, so a renderer
12
+ * with a vertex shader takes the shape once at rest and bends it on the card,
13
+ * and a frame after that costs two numbers a plant. `flora.ts` hands out
14
+ * `madeOf` for exactly that, and the desktop's renderer reads it.
15
+ *
16
+ * This is the other bargain, and it is the right one for a processor: take the
17
+ * points the plant has already cut for itself and stroke them. `flora.ts` only
18
+ * cuts a plant when the water has moved it far enough to be worth redrawing, so
19
+ * the tolerance it was given is what decides how much of a frame this costs.
20
+ *
21
+ * Both draw the same bed. What differs is where the bending happens.
22
+ */
23
+ import type { Caustics } from "../caustics.ts"
24
+ import type { Flora } from "../flora.ts"
25
+ import type { Reef } from "../reef.ts"
26
+ import type { Seabed } from "../seabed.ts"
27
+ import { type Pen, type Point, TONE } from "./pen.ts"
28
+ import { far, floored } from "./rock.ts"
29
+ import type { Box } from "./sky.ts"
30
+ import { trace } from "./trace.ts"
31
+
32
+ /** How dark each of these may be at the front of the picture. */
33
+ const FLORA_INK = 0.33
34
+ const SAND_INK = 0.19
35
+ const STONE_INK = 0.25
36
+
37
+ /** How thick a blade is against the strand it grows off. */
38
+ const BLADE_GIRTH = 0.8
39
+
40
+ /**
41
+ * How stout a strand has to be before its blades are drawn thinner than it.
42
+ *
43
+ * Under this the difference between the two widths is less than the width of a
44
+ * line, so the whole plant is drawn at one weight and nothing is lost. Over it
45
+ * the strand reads as the thing the blades hang off.
46
+ */
47
+ const BLADE_SPLIT = 0.5
48
+
49
+ /**
50
+ * Where each band of ground sorts.
51
+ *
52
+ * Ground is on a scale of its own, behind everything that swims, and the bands
53
+ * are kept a fifth of a lane apart so a hill never crosses the sand in front of
54
+ * it. The sand is flat rather than a function of distance because there is only
55
+ * one of it and it is the nearest thing in the picture that is not rock.
56
+ */
57
+ const LANE = { cliff: -3.4, hill: -3, mound: -2.6, sand: -2 }
58
+
59
+ /**
60
+ * The water itself, which everything else is drawn into.
61
+ *
62
+ * Behind every band of ground and behind the murk, so it is the one thing in
63
+ * the picture with nothing at all in front of it. Its tone is the wall, which
64
+ * is whatever the surface underneath the water happens to be showing: a
65
+ * desktop's own wallpaper, or a page's own background.
66
+ */
67
+ export function paintWater(pen: Pen, box: Box): void {
68
+ pen.fill(
69
+ [
70
+ [
71
+ { x: 0, y: 0 },
72
+ { x: box.width, y: 0 },
73
+ { x: box.width, y: box.height },
74
+ { x: 0, y: box.height },
75
+ ],
76
+ ],
77
+ { lane: -9, tone: TONE.wall, weight: 0 },
78
+ )
79
+ }
80
+
81
+ /**
82
+ * The ground at every distance it has, each band shut under the box.
83
+ *
84
+ * Far first, near last, which is the order they are drawn in as well as the
85
+ * order they stand in: a band closed off downwards covers everything behind it
86
+ * from its own ridge to the bottom edge, so drawing the near sand first would
87
+ * leave every hill behind it painted over its face.
88
+ */
89
+ export function paintGround(pen: Pen, seabed: Seabed, reef: Reef | null, box: Box): void {
90
+ for (const cliff of seabed.cliffs) {
91
+ pen.fill([floored(cliff.ridge, box)], {
92
+ lane: LANE.cliff + cliff.depth,
93
+ shade: box.height * 0.5,
94
+ weight: far(SAND_INK, cliff.depth),
95
+ })
96
+ }
97
+
98
+ for (const band of seabed.ranges) {
99
+ pen.fill([floored(band.ridge, box)], {
100
+ lane: LANE.hill + band.depth,
101
+ weight: far(SAND_INK, band.depth),
102
+ })
103
+ }
104
+
105
+ if (reef) {
106
+ pen.fill([floored(reef.crest, box)], {
107
+ lane: LANE.mound + reef.depth,
108
+ weight: far(STONE_INK, reef.depth),
109
+ })
110
+ }
111
+
112
+ pen.fill([floored(seabed.ridge, box)], { lane: LANE.sand, weight: SAND_INK })
113
+ }
114
+
115
+ /**
116
+ * Every plant, at the distance it grew at.
117
+ *
118
+ * A plant is a strand and whatever hangs off it, both handed over as points the
119
+ * plant cut for itself, and a coral is a stencil that never moves. Which of the
120
+ * two a plant is, is its own business and not read here beyond that: anything
121
+ * carrying twigs is drawn as twigs and anything else is drawn as lines.
122
+ */
123
+ /**
124
+ * How bright the light on the ground gets, and the least of it at night.
125
+ *
126
+ * Faint. This palette is one colour and its shadow, so a bright web on the sand
127
+ * stops being water and starts being decoration at a threshold well under where
128
+ * instinct puts it. What it is worth is that the ground is no longer one flat
129
+ * tone: a ridge with light moving over it has a shape, and a ridge without has
130
+ * an outline.
131
+ */
132
+ const CAUSTIC_LIGHT = 0.32
133
+
134
+ const MOON_CAUSTIC = 0.22
135
+
136
+ /**
137
+ * How wide one patch of it is against the box, how far apart they are put along
138
+ * a ridge, and how flat.
139
+ *
140
+ * Laid as a run of soft round lights rather than as a line, because a line has
141
+ * one strength for the whole of its length and the entire point of a caustic is
142
+ * that it does not. Overlapping blobs at their own strengths are what a bright
143
+ * band with a dark end is made of here.
144
+ */
145
+ const CAUSTIC_REACH = 0.026
146
+
147
+ const CAUSTIC_STEP = 0.011
148
+
149
+ const CAUSTIC_FLAT = 0.42
150
+
151
+ /** Under this there is nothing to see and a drawing would be a drawing wasted. */
152
+ const CAUSTIC_SEEN = 0.06
153
+
154
+ /**
155
+ * The light on the ground, ridge by ridge.
156
+ *
157
+ * Walked along the outlines the bed already has rather than laid over the box,
158
+ * since the only ground a reader can see is the top of each band and that is
159
+ * exactly what a ridge is. Each one is asked at its own distance, so the bands
160
+ * are lit differently and the bed keeps the depth it is drawn with.
161
+ */
162
+ export function paintCaustics(
163
+ pen: Pen,
164
+ seabed: Seabed,
165
+ caustics: Caustics,
166
+ box: Box,
167
+ daylight: number,
168
+ ): void {
169
+ const overcast = MOON_CAUSTIC + (1 - MOON_CAUSTIC) * daylight
170
+ const reach = box.width * CAUSTIC_REACH
171
+ const apart = Math.max(1, box.width * CAUSTIC_STEP)
172
+
173
+ const along = (ridge: readonly Point[], depth: number, lane: number): void => {
174
+ if (ridge.length < 2) return
175
+
176
+ let last = Number.NEGATIVE_INFINITY
177
+ for (const point of ridge) {
178
+ if (point.x - last < apart) continue
179
+ last = point.x
180
+
181
+ const on = caustics.at(point.x / box.width, depth)
182
+ const lit = CAUSTIC_LIGHT * on * overcast * far(1, depth)
183
+ if (lit <= CAUSTIC_SEEN * CAUSTIC_LIGHT) continue
184
+
185
+ pen.light(point.x, point.y, {
186
+ alpha: lit,
187
+ fall: 1.7,
188
+ lane,
189
+ thin: CAUSTIC_FLAT,
190
+ tone: TONE.sun,
191
+ width: reach,
192
+ })
193
+ }
194
+ }
195
+
196
+ for (const cliff of seabed.cliffs) along(cliff.ridge, cliff.depth, LANE.cliff + cliff.depth)
197
+ for (const band of seabed.ranges) along(band.ridge, band.depth, LANE.hill + band.depth)
198
+ along(seabed.ridge, 1, LANE.sand)
199
+ }
200
+
201
+ export function paintFlora(pen: Pen, flora: Flora): void {
202
+ for (const plant of flora.plants) {
203
+ const weight = far(FLORA_INK, plant.depth)
204
+ const mark = { lane: plant.depth, shade: plant.y, weight }
205
+
206
+ if (plant.twigs.length > 0) {
207
+ const spot = { scale: plant.scale, x: plant.x, y: plant.y }
208
+ for (const twig of plant.twigs) {
209
+ pen.line(trace(twig.d), { ...mark, width: twig.width * plant.scale }, spot)
210
+ }
211
+ continue
212
+ }
213
+
214
+ if (plant.points.length < 2 && plant.blades.length === 0) continue
215
+
216
+ // The strand and its limbs at the plant's own weight, and the blades at a
217
+ // share of it where the plant is stout enough for the two to read apart.
218
+ const parted = plant.girth * (1 - BLADE_GIRTH) >= BLADE_SPLIT
219
+ const strand: Point[][] = plant.points.length > 1 ? [plant.points] : []
220
+
221
+ if (parted) {
222
+ if (strand.length) pen.line(strand, { ...mark, width: plant.girth })
223
+ if (plant.blades.length) {
224
+ pen.line(plant.blades, { ...mark, width: plant.girth * BLADE_GIRTH })
225
+ }
226
+ continue
227
+ }
228
+
229
+ pen.line([...strand, ...plant.blades], { ...mark, width: plant.girth })
230
+ }
231
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A theme's colour, as numbers a renderer can mix.
3
+ *
4
+ * A renderer is handed its two colours as whatever a page's stylesheet hands
5
+ * back, and needs them as sRGB in 0 to 1 to mix the water from them.
6
+ *
7
+ * ## Why the colours are parsed rather than measured
8
+ *
9
+ * The obvious way to turn a theme token into numbers is to fill a pixel with it
10
+ * and read the pixel back, which is the browser doing the colour conversion for
11
+ * you. It is also wrong here. A browser with fingerprinting protection on
12
+ * (Librewolf, Brave, Firefox with `resistFingerprinting`) perturbs canvas
13
+ * readback on purpose, so the same token comes back a slightly different colour
14
+ * on every load, and an ornament whose hue wanders between reloads is an
15
+ * ornament that looks broken.
16
+ *
17
+ * Parsing is therefore the only stable answer. Only what a computed style can
18
+ * actually hand back has to be understood, which is `rgb()` for anything the
19
+ * page states in sRGB and `oklch()` for the theme tokens, since a computed
20
+ * colour keeps the space it was authored in. Anything else returns null and the
21
+ * caller falls back to colours of its own.
22
+ */
23
+
24
+ export type Rgb = readonly [number, number, number]
25
+
26
+ const clamp01 = (value: number): number => Math.min(1, Math.max(0, value))
27
+
28
+ /**
29
+ * Oklab to linear sRGB, then to sRGB. Ottosson's matrices, as published.
30
+ *
31
+ * Out-of-gamut values are clamped per channel, which is what a browser does
32
+ * when it paints one.
33
+ */
34
+ function oklabToRgb(lightness: number, a: number, b: number): Rgb {
35
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
36
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
37
+ const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
38
+
39
+ const linear = [
40
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
41
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
42
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
43
+ ]
44
+
45
+ return linear.map((channel) => {
46
+ const value = clamp01(channel)
47
+ return value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055
48
+ }) as unknown as Rgb
49
+ }
50
+
51
+ const numbers = (css: string): number[] =>
52
+ css
53
+ .slice(css.indexOf("(") + 1, css.lastIndexOf(")"))
54
+ .split(/[\s,/]+/)
55
+ .filter(Boolean)
56
+ .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)))
57
+
58
+ /**
59
+ * A computed colour as sRGB in 0 to 1, or null for anything not understood,
60
+ * which includes the transparent an element with no background of its own
61
+ * reports.
62
+ */
63
+ export function parseColour(css: string): Rgb | null {
64
+ const value = css.trim().toLowerCase()
65
+
66
+ if (value.startsWith("rgb")) {
67
+ const [r, g, b, alpha] = numbers(value)
68
+ if (r == null || g == null || b == null || alpha === 0) return null
69
+ return [clamp01(r / 255), clamp01(g / 255), clamp01(b / 255)]
70
+ }
71
+
72
+ if (value.startsWith("oklch")) {
73
+ const [lightness, chroma, hue, alpha] = numbers(value)
74
+ if (lightness == null || chroma == null || alpha === 0) return null
75
+ const angle = ((hue ?? 0) * Math.PI) / 180
76
+ return oklabToRgb(lightness, chroma * Math.cos(angle), chroma * Math.sin(angle))
77
+ }
78
+
79
+ if (value.startsWith("oklab")) {
80
+ const [lightness, a, b, alpha] = numbers(value)
81
+ if (lightness == null || a == null || b == null || alpha === 0) return null
82
+ return oklabToRgb(lightness, a, b)
83
+ }
84
+
85
+ return null
86
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * A frame of the water, read back.
3
+ *
4
+ * `pen.ts` writes drawings into a block of numbers and this walks them out
5
+ * again. It exists because there is more than one renderer: a canvas walks the
6
+ * frame with a path, a graphics card cuts it into triangles, and both of them
7
+ * are reading the same block, so the one place that knows what the numbers mean
8
+ * should be a file rather than a habit.
9
+ *
10
+ * ## Nothing is allocated
11
+ *
12
+ * A busy sea is a few thousand drawings a frame at sixty frames a second, and
13
+ * an object per drawing is a few hundred thousand objects a second for the
14
+ * collector to take away again. So a reader is one object whose fields are
15
+ * rewritten as it walks, and points stay where they are: what a renderer is
16
+ * handed is where in the block a part starts and how many points it has.
17
+ *
18
+ * Read a field while the reader is standing on the drawing it belongs to. Keep
19
+ * it and it is the next drawing's answer.
20
+ */
21
+ import { FORM, OWN, SPENT, TONE } from "./pen.ts"
22
+
23
+ export { FORM, OWN, SPENT, TONE }
24
+
25
+ /** How many numbers stand in front of the count. */
26
+ const HEAD = 6
27
+
28
+ /** How many numbers one drawing spends before its points. */
29
+ const MARK = 12
30
+
31
+ export interface Held {
32
+ /** How light it is above the water, 0 through 1. */
33
+ daylight: number
34
+ /** How much bigger than its box the scene is drawn. */
35
+ overscan: number
36
+ /** How far the frame has wandered, in px. */
37
+ swayX: number
38
+ swayY: number
39
+ /** How far it has rolled, in radians. */
40
+ tilt: number
41
+ /** Which frame of grain this is. */
42
+ turn: number
43
+ }
44
+
45
+ /**
46
+ * Where a box is pointed at the frame, for a window on a water bigger than it.
47
+ *
48
+ * A scene draws itself at its own size and is held with a hand's own wander,
49
+ * which is the header above. A plate is the other case: the water is larger
50
+ * than the box, the box is pointed at one animal in it, and the animal is
51
+ * magnified until it fills the frame. Both are the same drawing seen from a
52
+ * different place, which is why this is a camera rather than a second kind of
53
+ * frame.
54
+ */
55
+ export interface View {
56
+ /** How far in, 1 being the water's own size. */
57
+ scale: number
58
+ /** The point of the water to put in the middle of the box. */
59
+ x: number
60
+ y: number
61
+ }
62
+
63
+ /** Where the camera is holding the frame, off the front of the block. */
64
+ export function held(floats: Float32Array): Held {
65
+ return {
66
+ daylight: floats[5] ?? 1,
67
+ overscan: floats[3] ?? 1,
68
+ swayX: floats[0] ?? 0,
69
+ swayY: floats[1] ?? 0,
70
+ tilt: floats[2] ?? 0,
71
+ turn: floats[4] ?? 0,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * One drawing at a time, in the order they were made.
77
+ *
78
+ * The order is the picture. Depth sorts anything solid, but two lights over one
79
+ * another are a painting rather than a stack of distances, so a renderer that
80
+ * shuffles this is a renderer drawing a different scene.
81
+ */
82
+ export class Reader {
83
+ /** How many drawings are in this frame. */
84
+ readonly count: number
85
+
86
+ form = 0
87
+ alpha = 1
88
+ fall = 1
89
+ fadeSpan = 0
90
+ fadeTop = 0
91
+ lane = 0
92
+ shade = OWN
93
+ soft = 0
94
+ thin = 1
95
+ tone = 0
96
+ weight = 0
97
+ width = 0
98
+
99
+ /** How many pieces this drawing is in. */
100
+ parts = 0
101
+
102
+ /** The block being walked, for a renderer reading points straight out of it. */
103
+ readonly floats: Float32Array
104
+
105
+ private at = 0
106
+ private read = 0
107
+ private mark = 0
108
+
109
+ constructor(floats: Float32Array) {
110
+ this.floats = floats
111
+ this.count = floats[HEAD] ?? 0
112
+ this.at = HEAD + 1
113
+ }
114
+
115
+ /** Whether the drawing is a round light rather than a shape. */
116
+ get round(): boolean {
117
+ return this.form === FORM.light
118
+ }
119
+
120
+ /** Whether it ends on the light's own curve rather than on an exponent. */
121
+ get spent(): boolean {
122
+ return (this.tone & SPENT) !== 0
123
+ }
124
+
125
+ /** Which colour it is mixed from, with the flags taken off. */
126
+ get hue(): number {
127
+ return this.tone & 0xff
128
+ }
129
+
130
+ /**
131
+ * Stand on the next drawing, or say there is not one.
132
+ *
133
+ * The points are stepped over here rather than by the caller, so a renderer
134
+ * that skips a drawing it cannot use does not have to know how long it was.
135
+ */
136
+ next(): boolean {
137
+ if (this.read >= this.count) return false
138
+ this.read++
139
+ this.at = this.stand(this.at)
140
+ return true
141
+ }
142
+
143
+ /** Where the drawing being stood on begins, for a caller keeping the place. */
144
+ get offset(): number {
145
+ return this.mark
146
+ }
147
+
148
+ /**
149
+ * Stand on the drawing at a given place, and say where the next one begins.
150
+ *
151
+ * A renderer that has to put the frame in another order reads it twice: once
152
+ * to note where each drawing is and how far back it stands, and again in the
153
+ * order it decided on. Both passes are this.
154
+ */
155
+ stand(from: number): number {
156
+ const floats = this.floats
157
+ let at = from
158
+ this.mark = from
159
+
160
+ this.form = floats[at] ?? 0
161
+ this.tone = floats[at + 1] ?? 0
162
+ this.weight = floats[at + 2] ?? 0
163
+ this.shade = floats[at + 3] ?? OWN
164
+ this.alpha = floats[at + 4] ?? 1
165
+ this.lane = floats[at + 5] ?? 0
166
+ this.width = floats[at + 6] ?? 0
167
+ this.fall = floats[at + 7] ?? 1
168
+ this.fadeTop = floats[at + 8] ?? 0
169
+ this.fadeSpan = floats[at + 9] ?? 0
170
+ this.thin = floats[at + 10] ?? 1
171
+ this.soft = floats[at + 11] ?? 0
172
+
173
+ at += MARK
174
+ this.parts = floats[at++] ?? 0
175
+ this.first = at
176
+
177
+ for (let part = 0; part < this.parts; part++) {
178
+ at += 1 + 2 * (floats[at] ?? 0)
179
+ }
180
+ return at
181
+ }
182
+
183
+ /** Where this drawing's first part begins. */
184
+ first = 0
185
+
186
+ /**
187
+ * Every piece of this drawing, as where its points start and how many.
188
+ *
189
+ * Several pieces rather than one, because an animal is not one outline: a
190
+ * fish is a body, a tail swung off its joint and a fin rooted inside the
191
+ * back, and they are meant to overlap. Filled as one silhouette they are the
192
+ * animal; filled one after another, every overlap shows as a seam.
193
+ */
194
+ each(walk: (at: number, points: number) => void): void {
195
+ const floats = this.floats
196
+ let at = this.first
197
+
198
+ for (let part = 0; part < this.parts; part++) {
199
+ const points = floats[at] ?? 0
200
+ walk(at + 1, points)
201
+ at += 1 + 2 * points
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What colour a drawing comes out, and how much of it there is.
3
+ *
4
+ * Every colour in this scene is worked out from two: the theme's own ink and
5
+ * the surface it is standing on. That is what lets a theme switch recolour the
6
+ * whole sea without a single drawing knowing it happened, and it is why nothing
7
+ * in `paint/` names a colour. A drawing says how heavy it is and which of the
8
+ * two it is mixed from, and the answer is worked out here.
9
+ *
10
+ * The exceptions are the lights, which are the only colours in the picture that
11
+ * are not the theme's. A sun is the colour a sun is.
12
+ *
13
+ * ## Where it is read
14
+ *
15
+ * A card reads it per pixel, in the fragment shader, so a shape spanning half
16
+ * the box takes the water's own gradient across its whole height. A canvas
17
+ * cannot: a fill is one colour, or one gradient built before the fill. So the
18
+ * same arithmetic is here in TypeScript, and `flat.ts` decides how many times to
19
+ * ask it. The two agree on every number, which is the point of the file.
20
+ */
21
+
22
+ import type { Rgb } from "./colour.ts"
23
+ import { TONE } from "./pen.ts"
24
+
25
+ /**
26
+ * How much ink is in the water at the surface.
27
+ *
28
+ * The water is not the page's background with things drawn on it. It is a
29
+ * column, lightest at the top where the light comes in and giving out with
30
+ * depth, and this is how far from the background the lit top of it stands.
31
+ */
32
+ const LIT = 0.13
33
+
34
+ /**
35
+ * How fast the light gives out going down.
36
+ *
37
+ * Over 1, so the top half of the box keeps most of it and the bottom goes
38
+ * quickly, which is what looking down a water column actually looks like. A
39
+ * straight line reads as a printed gradient.
40
+ */
41
+ const SINKAGE = 2.1
42
+
43
+ /** The three lights, which are the only colours here the theme does not own. */
44
+ const SUNLIGHT: Rgb = [1, 0.93, 0.76]
45
+ const MOONLIGHT: Rgb = [0.95, 0.92, 0.85]
46
+ const DUSKLIGHT: Rgb = [0.91, 0.64, 0.3]
47
+
48
+ export interface Palette {
49
+ /** The theme's own ink, which everything in the water is drawn towards. */
50
+ ink: Rgb
51
+ /** What the water is standing on, which everything fades into. */
52
+ surface: Rgb
53
+ }
54
+
55
+ const mix = (a: Rgb, b: Rgb, at: number): Rgb => [
56
+ a[0] + (b[0] - a[0]) * at,
57
+ a[1] + (b[1] - a[1]) * at,
58
+ a[2] + (b[2] - a[2]) * at,
59
+ ]
60
+
61
+ /** The water at a height in the box, lit from above and giving up with depth. */
62
+ export function waterAt(palette: Palette, y: number, height: number): Rgb {
63
+ const t = Math.min(1, Math.max(0, y / Math.max(1, height)))
64
+ return mix(palette.surface, mix(palette.surface, palette.ink, LIT), (1 - t) ** SINKAGE)
65
+ }
66
+
67
+ /**
68
+ * One drawing's colour: the water where it is, with its own weight mixed in.
69
+ *
70
+ * `weight` is how much of the thing stopped the light. A body is one weight,
71
+ * because a body at this distance is one shape the light did not come back
72
+ * through, and that is what a silhouette is.
73
+ */
74
+ export function hueOf(
75
+ palette: Palette,
76
+ tone: number,
77
+ weight: number,
78
+ y: number,
79
+ height: number,
80
+ ): Rgb {
81
+ const base = waterAt(palette, y, height)
82
+ const w = Math.min(1, Math.max(0, weight))
83
+
84
+ switch (tone & 0xff) {
85
+ case TONE.shadow:
86
+ return mix(base, palette.surface, w)
87
+ case TONE.sun:
88
+ return SUNLIGHT
89
+ case TONE.moon:
90
+ return MOONLIGHT
91
+ case TONE.dusk:
92
+ return DUSKLIGHT
93
+ case TONE.surface:
94
+ return palette.surface
95
+ case TONE.ink:
96
+ return palette.ink
97
+ // The water column, which on a page is the page itself: whatever is behind
98
+ // the canvas is what you see through it, so nothing is painted at all.
99
+ case TONE.wall:
100
+ return base
101
+ default:
102
+ return mix(base, palette.ink, w)
103
+ }
104
+ }
105
+
106
+ export const css = (hue: Rgb, alpha: number): string =>
107
+ `rgba(${Math.round(hue[0] * 255)},${Math.round(hue[1] * 255)},${Math.round(hue[2] * 255)},${alpha})`
108
+
109
+ /**
110
+ * What is left of a shape's own light at a height in the box.
111
+ *
112
+ * Two shapes of falling off and no others. Everything that fades down the box
113
+ * takes the same exponent, because a light in water does not thin in a straight
114
+ * line and a straight line is what two ends of a gradient draw. `spent` is the
115
+ * one that does not: it holds most of its strength for the first half of its
116
+ * length and then goes quickly, which is what a shaft of sunlight does and no
117
+ * single exponent draws.
118
+ */
119
+ export function leftAt(
120
+ alpha: number,
121
+ fadeTop: number,
122
+ fadeSpan: number,
123
+ fall: number,
124
+ spent: boolean,
125
+ y: number,
126
+ ): number {
127
+ if (fadeSpan <= 0) return alpha
128
+ const down = Math.min(1, Math.max(0, (y - fadeTop) / fadeSpan))
129
+ if (spent) {
130
+ const held = 1 - down * down
131
+ return alpha * held * held
132
+ }
133
+ return alpha * (1 - down) ** fall
134
+ }