@doan-labs/peek 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.
package/src/draw.ts ADDED
@@ -0,0 +1,849 @@
1
+ /*
2
+ * One pure geometry function: an identity and a pose in, a plain node tree
3
+ * out. The per-frame drawing is transcribed from render() in the v1.2 page,
4
+ * so a static frame of an expression is the pose the rig settles to. The
5
+ * animator calls the same function every frame and writes the difference.
6
+ *
7
+ * Structure depends only on the identity and the options, never the pose,
8
+ * so a live tree keeps the same nodes frame to frame. A static tree drops
9
+ * what is invisible (opacity 0) to stay small.
10
+ */
11
+
12
+ import type { Axes, Persona } from './identity'
13
+ import {
14
+ type Channel,
15
+ type Channels,
16
+ COLORS,
17
+ EXPRESSIONS,
18
+ type Expression,
19
+ FACES,
20
+ type FaceGeom,
21
+ INK,
22
+ } from './tables'
23
+
24
+ export type Node = {
25
+ tag: string
26
+ key: string
27
+ attrs: Record<string, string>
28
+ children: Node[]
29
+ }
30
+
31
+ export type Frame = 'ink' | 'bone' | 'paper' | 'none'
32
+ export type Gaze = readonly [x: number, y: number]
33
+
34
+ export type Pose = Channels & {
35
+ /** 0..1, the blink closing both lids. */
36
+ blink: number
37
+ /** The trait's lag behind the body, frame units, clamped to 16. */
38
+ lag: number
39
+ /** The ring thickens when angry: the one place a state is read by name. */
40
+ expression: Expression
41
+ }
42
+
43
+ export type DrawOptions = {
44
+ /** Prefix for every id in the tree. Unique per avatar on a page. */
45
+ id: string
46
+ /** Rendered size in px: drives the stroke ramp and the riso print. */
47
+ size: number
48
+ frame: Frame
49
+ /** false clips the frame to a circle. */
50
+ square: boolean
51
+ /** Riso plates, misregistration and grain. Fades out below 120px. */
52
+ riso: boolean
53
+ /** The accessible name; false marks the avatar decorative. */
54
+ title: string | false
55
+ /** Keep invisible nodes so an animator can write into them. */
56
+ live?: boolean
57
+ }
58
+
59
+ export type Drawable = Axes & { persona: Persona }
60
+
61
+ /* The frame: viewBox -30 -30 400 400, the floor on its bottom edge. */
62
+ export const VB = [-30, -30, 400, 400] as const
63
+ const FLOOR = 370
64
+ const THING: Gaze = [330, 20]
65
+
66
+ export const clamp = (v: number, a: number, b: number) =>
67
+ v < a ? a : v > b ? b : v
68
+ export const smooth = (a: number, b: number, v: number) => {
69
+ const t = clamp((v - a) / (b - a), 0, 1)
70
+ return t * t * (3 - 2 * t)
71
+ }
72
+ const f2 = (v: number) => String(Math.round(v * 100) / 100)
73
+ const f3 = (v: number) => String(Math.round(v * 1000) / 1000)
74
+ const strokeFor = (px: number) =>
75
+ px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4
76
+ const RAD = Math.PI / 180
77
+
78
+ /** Where a face rests: the body offset, the eye line, one altitude unit. */
79
+ export function frameOf(face: FaceGeom) {
80
+ const baseY = FLOOR + face.sink - face.bottom
81
+ const eyeY = face.eyes[0][1]
82
+ // altitude -1 puts the eye line on the floor
83
+ return { baseY, eyeY, R: FLOOR - (eyeY + baseY) }
84
+ }
85
+
86
+ /** The eyes' aim at the thing in the corner, from wherever they are. */
87
+ export function thingGaze(face: FaceGeom, x: number, alt: number): Gaze {
88
+ const { baseY, eyeY, R } = frameOf(face)
89
+ const ey = eyeY + baseY - alt * R
90
+ return [
91
+ clamp((THING[0] - 170 - x) / 150, -1, 1),
92
+ clamp((THING[1] - ey) / 150, -1, 1),
93
+ ]
94
+ }
95
+
96
+ /**
97
+ * The pose an expression settles to. A curious face ends up looking at the
98
+ * thing in the corner; an explicit gaze wins over both.
99
+ */
100
+ export function restPose(
101
+ who: Pick<Axes, 'face'>,
102
+ expression: Expression,
103
+ gaze?: Gaze,
104
+ ): Pose {
105
+ const s = EXPRESSIONS[expression]
106
+ const pose: Pose = { ...s, blink: 0, lag: 0, expression }
107
+ // the choreography's last key leaves curious leaning 7, not the table's 6
108
+ if (expression === 'curious') pose.rot = 7
109
+ const g =
110
+ gaze ??
111
+ (expression === 'curious' ? thingGaze(FACES[who.face], 0, s.alt) : null)
112
+ if (g) [pose.gx, pose.gy] = g
113
+ return pose
114
+ }
115
+
116
+ /**
117
+ * A point on the crown outline grown by `d`, reached by walking `u` along it
118
+ * from the top (where the outward normal points straight up); u > 0 walks
119
+ * right. Returns the point and the normal's bearing in degrees.
120
+ */
121
+ export function seat(
122
+ crown: FaceGeom['crown'],
123
+ d: number,
124
+ u: number,
125
+ ): { x: number; y: number; a: number } {
126
+ const R = Math.max(0, crown.r + d)
127
+ const at = ([x, y]: readonly [number, number], b: number) => ({
128
+ x: x + R * Math.sin(b),
129
+ y: y - R * Math.cos(b),
130
+ a: b / RAD,
131
+ })
132
+ const P = crown.poly
133
+ if (P.length === 1) return at(P[0]!, R ? u / R : 0)
134
+ const n = P.length
135
+ const dir = u >= 0 ? 1 : -1
136
+ let left = Math.abs(u)
137
+ let i = 0
138
+ let b = 0
139
+ for (let step = 0; step < n; step++) {
140
+ const j = (i + dir + n) % n
141
+ const [x0, y0] = P[i]!
142
+ const [x1, y1] = P[j]!
143
+ const ex = x1 - x0
144
+ const ey = y1 - y0
145
+ const L = Math.hypot(ex, ey)
146
+ const nx = (dir * ey) / L
147
+ const ny = (-dir * ex) / L
148
+ const be = Math.atan2(nx, -ny)
149
+ let db = be - b
150
+ while (dir * db < 0) db += dir * 2 * Math.PI
151
+ const arc = R * Math.abs(db)
152
+ if (left <= arc) return at(P[i]!, R ? b + (dir * left) / R : b)
153
+ left -= arc
154
+ if (left <= L)
155
+ return {
156
+ x: x0 + (ex / L) * left + R * nx,
157
+ y: y0 + (ey / L) * left + R * ny,
158
+ a: be / RAD,
159
+ }
160
+ left -= L
161
+ i = j
162
+ b = be
163
+ }
164
+ return at(P[i]!, b)
165
+ }
166
+
167
+ /** Signed distance from a point to the crown outline, negative inside. */
168
+ export function depth(crown: FaceGeom['crown'], x: number, y: number) {
169
+ const P = crown.poly
170
+ if (P.length === 1) return Math.hypot(x - P[0]![0], y - P[0]![1]) - crown.r
171
+ let out = -Infinity
172
+ let near = Infinity
173
+ for (let i = 0; i < P.length; i++) {
174
+ const [ax, ay] = P[i]!
175
+ const [bx, by] = P[(i + 1) % P.length]!
176
+ const ex = bx - ax
177
+ const ey = by - ay
178
+ const L2 = ex * ex + ey * ey
179
+ const L = Math.sqrt(L2)
180
+ out = Math.max(out, ((x - ax) * ey - (y - ay) * ex) / L)
181
+ const t = clamp(((x - ax) * ex + (y - ay) * ey) / L2, 0, 1)
182
+ near = Math.min(near, Math.hypot(x - ax - t * ex, y - ay - t * ey))
183
+ }
184
+ return (out <= 0 ? out : near) - crown.r
185
+ }
186
+
187
+ /*
188
+ * The ring is worn at one o'clock, 158 x 35deg along the crown from the top.
189
+ * On a low or narrow crown that spot sits on the brow's outer end, so the
190
+ * ring walks on down the outline until it clears the end at every height the
191
+ * brow reaches (surprised, the widest persona, the heaviest brow), and rests
192
+ * a little past that so it can still ride up, never back onto the brow.
193
+ * Returns where it rests and how far up it may ride.
194
+ */
195
+ const RING_U = 158 * 35 * RAD
196
+ export function ringWalk(sh: FaceGeom): { rest: number; min: number } {
197
+ const ex = sh.eyes[1][0] + sh.brow.hw + 7
198
+ const top = sh.brow.y - 30
199
+ const bottom = sh.brow.y + 8
200
+ let u = RING_U
201
+ for (; u < 400; u += 2) {
202
+ const s = seat(sh.crown, 8, u)
203
+ const dx = s.x - ex
204
+ const dy = s.y - clamp(s.y, top, bottom)
205
+ if (s.x > ex ? Math.hypot(dx, dy) > 27 : s.y < top - 27) break
206
+ }
207
+ return u > RING_U ? { rest: u + 20, min: u } : { rest: u, min: -Infinity }
208
+ }
209
+
210
+ type Attrs = Record<string, string | number | false | undefined>
211
+ function n(tag: string, key: string, attrs: Attrs, children: Node[] = []) {
212
+ const out: Record<string, string> = {}
213
+ for (const k in attrs) {
214
+ const v = attrs[k]
215
+ if (v !== undefined && v !== false) out[k] = String(v)
216
+ }
217
+ return { tag, key, attrs: out, children }
218
+ }
219
+
220
+ /** Drops what cannot be seen: opacity 0, then groups left empty. */
221
+ function prune(node: Node): Node | null {
222
+ if (node.attrs.opacity === '0') return null
223
+ const children = node.children.map(prune).filter((c) => c !== null)
224
+ if (node.tag === 'g' && children.length === 0) return null
225
+ return { ...node, children }
226
+ }
227
+
228
+ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
229
+ const sh: FaceGeom = FACES[who.face]
230
+ const { body: fill, deep } = COLORS[who.color]
231
+ const P = who.persona
232
+ const mul = P.mul as Partial<Record<Channel, number>>
233
+ const add = P.add as Partial<Record<Channel, number>>
234
+ const g = (c: Channel) => pose[c] * (mul[c] ?? 1) + (add[c] ?? 0)
235
+ const spread = P.spread
236
+ const id = o.id
237
+ const st = strokeFor(o.size)
238
+ const { baseY, R } = frameOf(sh)
239
+ const defs: Node[] = []
240
+
241
+ // the frame
242
+ const VBr = { x: VB[0], y: VB[1], width: VB[2], height: VB[3] }
243
+ defs.push(
244
+ n('clipPath', 'frame.clip', { id: `${id}-f` }, [
245
+ o.square
246
+ ? n('rect', 'frame.clip.shape', VBr)
247
+ : n('circle', 'frame.clip.shape', { cx: 170, cy: 170, r: 200 }),
248
+ ]),
249
+ )
250
+
251
+ // riso: registration error grows with size, nothing at 120px, about 1px
252
+ // per plate at 480px and up; gentle edge wander plus mottled ink voids,
253
+ // a different seed per plate
254
+ const k = o.riso ? smooth(120, 480, o.size) : 0
255
+ const riso = k > 0.01
256
+ const upp = VB[2] / Math.max(o.size, 1)
257
+ const mk = k * k
258
+ const plate = (p: 'A' | 'B', seedA: number, seedB: number, sc: number) =>
259
+ n(
260
+ 'filter',
261
+ `riso.${p}`,
262
+ {
263
+ id: `${id}-p${p}`,
264
+ x: '-20%',
265
+ y: '-20%',
266
+ width: '140%',
267
+ height: '140%',
268
+ 'color-interpolation-filters': 'sRGB',
269
+ },
270
+ [
271
+ n('feTurbulence', `riso.${p}.warp`, {
272
+ type: 'fractalNoise',
273
+ baseFrequency: '0.032',
274
+ numOctaves: 2,
275
+ seed: seedA,
276
+ result: 'warp',
277
+ }),
278
+ n('feDisplacementMap', `riso.${p}.disp`, {
279
+ in: 'SourceGraphic',
280
+ in2: 'warp',
281
+ scale: f2(sc * k),
282
+ xChannelSelector: 'R',
283
+ yChannelSelector: 'G',
284
+ result: 'moved',
285
+ }),
286
+ n('feTurbulence', `riso.${p}.grain`, {
287
+ type: 'fractalNoise',
288
+ baseFrequency: '0.85',
289
+ numOctaves: 2,
290
+ seed: seedB,
291
+ result: 'grain',
292
+ }),
293
+ n('feColorMatrix', `riso.${p}.mask`, {
294
+ in: 'grain',
295
+ type: 'matrix',
296
+ values: `0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${f3(-8 * mk)} 0 0 0 ${f3(1 + 5.85 * mk)}`,
297
+ result: 'mask',
298
+ }),
299
+ n('feComposite', `riso.${p}.out`, {
300
+ in: 'moved',
301
+ in2: 'mask',
302
+ operator: 'in',
303
+ }),
304
+ ],
305
+ )
306
+ const grainy = riso && o.frame !== 'none'
307
+ if (riso) defs.push(plate('A', 4, 11, 3.2), plate('B', 9, 23, 2.2))
308
+ if (grainy) {
309
+ // bone speckle on ink, ink speckle on light grounds
310
+ const [r, gg, b] =
311
+ o.frame === 'ink' ? [0.953, 0.941, 0.91] : [0.102, 0.098, 0.094]
312
+ defs.push(
313
+ n(
314
+ 'filter',
315
+ 'grain.filter',
316
+ {
317
+ id: `${id}-grain`,
318
+ x: 0,
319
+ y: 0,
320
+ width: 1,
321
+ height: 1,
322
+ 'color-interpolation-filters': 'sRGB',
323
+ },
324
+ [
325
+ n('feTurbulence', 'grain.noise', {
326
+ type: 'fractalNoise',
327
+ baseFrequency: '0.9',
328
+ numOctaves: 2,
329
+ seed: 5,
330
+ }),
331
+ n('feColorMatrix', 'grain.matrix', {
332
+ type: 'matrix',
333
+ values: `0 0 0 0 ${r} 0 0 0 0 ${gg} 0 0 0 0 ${b} 7 0 0 0 -5.1`,
334
+ }),
335
+ ],
336
+ ),
337
+ )
338
+ }
339
+
340
+ // colour drains a little when bored
341
+ const dim = clamp(g('dim'), 0, 1)
342
+ if (o.live || dim > 0.01)
343
+ defs.push(
344
+ n(
345
+ 'filter',
346
+ 'dim.filter',
347
+ { id: `${id}-dim`, 'color-interpolation-filters': 'sRGB' },
348
+ [
349
+ n('feColorMatrix', 'dim.matrix', {
350
+ type: 'saturate',
351
+ values: f3(1 - dim * 0.55),
352
+ }),
353
+ ],
354
+ ),
355
+ )
356
+
357
+ // the body
358
+ const alt = g('alt')
359
+ const rot = g('rot')
360
+ const x = g('x')
361
+ const sx = g('sx')
362
+ const sy = g('sy')
363
+ const B = sh.bottom
364
+ const ty = baseY - alt * R
365
+ const sc = (v: number) => Math.round(v * 100000) / 100000
366
+ const bodyNode =
367
+ 'd' in sh.body
368
+ ? n('path', 'body', {
369
+ d: sh.body.d,
370
+ fill,
371
+ stroke: sh.body.stroke ? fill : undefined,
372
+ 'stroke-width': sh.body.stroke,
373
+ 'stroke-linejoin': sh.body.stroke ? 'round' : undefined,
374
+ })
375
+ : n('circle', 'body', {
376
+ cx: sh.body.circle[0],
377
+ cy: sh.body.circle[1],
378
+ r: sh.body.circle[2],
379
+ fill,
380
+ })
381
+
382
+ // eyes
383
+ const eyeS = g('eyeS')
384
+ const rx = sh.rx * eyeS
385
+ const ry = sh.ry * eyeS
386
+ const lid0 = g('lid')
387
+ const lidAsym = g('lidAsym')
388
+ const lower = clamp(g('lower'), 0, 1)
389
+ const blink = pose.blink
390
+ const pupil = g('pupil')
391
+ const gx = clamp(g('gx'), -1.1, 1.1)
392
+ const gy = clamp(g('gy'), -1.1, 1.1)
393
+ const tilt = g('lidTilt')
394
+ const shine = clamp(g('shine'), 0, 1)
395
+ const sw = sh.brow.w * st
396
+ const bead = who.eyes === 'bead'
397
+ const eyes = sh.eyes.map(([ex, ey], i) => {
398
+ const side = i === 0 ? -1 : 1
399
+ const kk = `eye${i}`
400
+ let lid = clamp(lid0 + (i === 0 ? lidAsym : -lidAsym * 0.3), 0, 1)
401
+ lid = lid + (1 - lid) * blink
402
+ defs.push(
403
+ n('clipPath', `${kk}.clip`, { id: `${id}-e${i}` }, [
404
+ n('ellipse', `${kk}.clip.shape`, {
405
+ cx: 0,
406
+ cy: 0,
407
+ rx: f2(rx),
408
+ ry: f2(ry),
409
+ }),
410
+ ]),
411
+ n('clipPath', `${kk}.lidclip`, { id: `${id}-l${i}` }, [
412
+ n('ellipse', `${kk}.lidclip.shape`, {
413
+ cx: 0,
414
+ cy: 0,
415
+ rx: f2(rx + 1.6),
416
+ ry: f2(ry + 1.6),
417
+ }),
418
+ ]),
419
+ )
420
+ // what sits in the socket: a pupil on paper, a bare bead, or a rimmed eye
421
+ const br = Math.min(sh.rx, sh.ry) * 0.62
422
+ const pr = bead ? br * eyeS * (0.4 + 0.6 * pupil) : sh.pr * pupil
423
+ const base = bead ? br : sh.pr
424
+ const px = gx * Math.max(0, sh.rx - base) * 0.9
425
+ const py = gy * Math.max(0, sh.ry - base) * 0.8
426
+ // capped so a small rimmed eye stays an eye, not an ink blot
427
+ const rimW = Math.min(sw * 0.8, Math.min(sh.rx, sh.ry) * 0.28)
428
+ const inner: Node[] = []
429
+ if (!bead)
430
+ inner.push(
431
+ n('ellipse', `${kk}.white`, {
432
+ cx: 0,
433
+ cy: 0,
434
+ rx: f2(rx),
435
+ ry: f2(ry),
436
+ fill: INK.paper,
437
+ }),
438
+ )
439
+ inner.push(
440
+ n('circle', `${kk}.pupil`, {
441
+ cx: f2(px),
442
+ cy: f2(py),
443
+ r: f2(pr),
444
+ fill: INK.ink,
445
+ }),
446
+ n('circle', `${kk}.shine`, {
447
+ cx: f2(px + pr * 0.44),
448
+ cy: f2(py - pr * 0.44),
449
+ r: f2(pr * (bead ? 0.26 : 0.3)),
450
+ fill: INK.paper,
451
+ opacity: f2(shine),
452
+ }),
453
+ )
454
+ if (who.eyes === 'ring')
455
+ inner.push(
456
+ n('ellipse', `${kk}.rim`, {
457
+ cx: 0,
458
+ cy: 0,
459
+ rx: f2(rx - rimW / 2),
460
+ ry: f2(ry - rimW / 2),
461
+ fill: 'none',
462
+ stroke: INK.ink,
463
+ 'stroke-width': f2(rimW),
464
+ }),
465
+ )
466
+ // upper lid: a straight cut, tilted, same as the v1.0 sad and angry lids
467
+ const edge = -ry - 1.6 + lid * (2 * ry + 3.2)
468
+ const a = side * tilt
469
+ // lower lid: a wide arc pushing up from below, the smiling squint
470
+ const lrx = rx * 1.55
471
+ const lry = ry * 1.15
472
+ const top = ry + 1.6 - lower * ry * 1.5
473
+ const parts: Node[] = [
474
+ n('g', `${kk}.inner`, { 'clip-path': `url(#${id}-e${i})` }, inner),
475
+ n('g', `${kk}.lids`, { 'clip-path': `url(#${id}-l${i})` }, [
476
+ n('rect', `${kk}.lid`, {
477
+ x: f2(-rx * 2.2),
478
+ y: f2(-ry * 3),
479
+ width: f2(rx * 4.4),
480
+ height: f2(Math.max(0, edge + ry * 3)),
481
+ transform: `rotate(${f2(a)})`,
482
+ fill,
483
+ }),
484
+ n('ellipse', `${kk}.lower`, {
485
+ cx: 0,
486
+ cy: f2(top + lry),
487
+ rx: f2(lrx),
488
+ ry: f2(lry),
489
+ fill,
490
+ }),
491
+ ]),
492
+ ]
493
+ // a rimmed eye inks its lid edges too, so the outline follows the lids
494
+ if (who.eyes === 'ring')
495
+ parts.push(
496
+ n(
497
+ 'g',
498
+ `${kk}.edges`,
499
+ {
500
+ 'clip-path': `url(#${id}-e${i})`,
501
+ fill: 'none',
502
+ stroke: INK.ink,
503
+ 'stroke-width': f2(rimW),
504
+ },
505
+ [
506
+ n('path', `${kk}.edge`, {
507
+ d: `M${f2(-rx * 2.2)} ${f2(edge)}H${f2(rx * 2.2)}`,
508
+ transform: `rotate(${f2(a)})`,
509
+ opacity: lid > 0.02 ? 1 : 0,
510
+ }),
511
+ n('ellipse', `${kk}.edge.lower`, {
512
+ cx: 0,
513
+ cy: f2(top + lry),
514
+ rx: f2(lrx),
515
+ ry: f2(lry),
516
+ opacity: lower > 0.02 ? 1 : 0,
517
+ }),
518
+ ],
519
+ ),
520
+ )
521
+ // closed eye: a hairline drawn once the lid is down
522
+ const co = smooth(0.86, 1, lid)
523
+ const yy = Math.min(edge, ry) - 1
524
+ const hw = rx * 0.92
525
+ parts.push(
526
+ n('path', `${kk}.closed`, {
527
+ d: `M${f2(-hw)} ${f2(yy - 2)}Q0 ${f2(yy + 6)} ${f2(hw)} ${f2(yy - 2)}`,
528
+ transform: `rotate(${f2(a * 0.6)})`,
529
+ fill: 'none',
530
+ stroke: INK.ink,
531
+ 'stroke-linecap': 'round',
532
+ 'stroke-width': f2(sw * 0.85),
533
+ opacity: co > 0.01 ? f2(co) : 0,
534
+ }),
535
+ )
536
+ return n(
537
+ 'g',
538
+ kk,
539
+ {
540
+ // a bead has no white to show where it looks, so the whole eye leans
541
+ transform: bead
542
+ ? `translate(${f2(ex + side * spread + gx * 6)} ${f2(ey + gy * 6)})`
543
+ : `translate(${f2(ex + side * spread)} ${ey})`,
544
+ },
545
+ parts,
546
+ )
547
+ })
548
+
549
+ // brows
550
+ const browY = g('browY') - (eyeS - 1) * sh.ry * 0.9
551
+ const btilt = g('browTilt')
552
+ const arch = g('browArch')
553
+ const basym = g('browAsym')
554
+ const bw = sw * g('browW')
555
+ const brows = sh.eyes.map(([ex], i) => {
556
+ const side = i === 0 ? -1 : 1
557
+ const hw = sh.brow.hw
558
+ const c = -sh.brow.arch * arch
559
+ // half-length, arch depth and stroke weight per type
560
+ const [w, ca, k2] =
561
+ who.brows === 'bar'
562
+ ? [hw * 0.9, c * 0.3, 1.75]
563
+ : who.brows === 'dash'
564
+ ? [hw * 0.42, c * 0.25, 1.9]
565
+ : [hw, c, 1]
566
+ // wedge: tapered, heavy at the inner end, toward the nose
567
+ const ti = bw * 1.7
568
+ const to = bw * 0.45
569
+ const [tl, tr] = side < 0 ? [to, ti] : [ti, to]
570
+ const wedge = who.brows === 'wedge'
571
+ const [hl, hr] = wedge ? [tl / 2 + to / 2, tr / 2 + to / 2] : [0, 0]
572
+ const half = (t: number) => (wedge ? hl + (hr - hl) * t : (bw * k2) / 2)
573
+ // a brow never leaves the face: raised into the outline, it stops there
574
+ const bx = ex + side * spread
575
+ const by = sh.brow.y + browY + (i === 1 ? -basym : basym * 0.3)
576
+ const cos = Math.cos(side * btilt * RAD)
577
+ const sin = Math.sin(side * btilt * RAD)
578
+ const inside = (dy: number) => {
579
+ for (let t = 0; t <= 1; t += 0.25) {
580
+ const lx = w * (2 * t - 1)
581
+ const ly = 2 * t * (1 - t) * ca
582
+ const px = bx + lx * cos - ly * sin
583
+ const py = by + dy + lx * sin + ly * cos
584
+ if (depth(sh.crown, px, py) + half(t) + 2 > 0) return false
585
+ }
586
+ return true
587
+ }
588
+ let lo = 0
589
+ let hi = 0
590
+ if (!inside(0)) {
591
+ hi = 40
592
+ for (let k = 0; k < 12; k++) {
593
+ const mid = (lo + hi) / 2
594
+ if (inside(mid)) hi = mid
595
+ else lo = mid
596
+ }
597
+ }
598
+ const transform = `translate(${f2(bx)} ${f2(by + hi)}) rotate(${f2(side * btilt)})`
599
+ const key = `brow${i}`
600
+ if (wedge) {
601
+ const dm = ((tl + tr) / 2) * 0.75
602
+ return n('path', key, {
603
+ d: `M${f2(-hw)} ${f2(-tl / 2)}Q0 ${f2(c - dm)} ${f2(hw)} ${f2(-tr / 2)}L${f2(hw)} ${f2(tr / 2)}Q0 ${f2(c + dm)} ${f2(-hw)} ${f2(tl / 2)}Z`,
604
+ transform,
605
+ fill: INK.ink,
606
+ stroke: INK.ink,
607
+ 'stroke-linejoin': 'round',
608
+ 'stroke-width': f2(to),
609
+ })
610
+ }
611
+ return n('path', key, {
612
+ d: `M${f2(-w)} 0Q0 ${f2(ca)} ${f2(w)} 0`,
613
+ transform,
614
+ fill: 'none',
615
+ stroke: INK.ink,
616
+ 'stroke-linecap': 'round',
617
+ 'stroke-linejoin': 'round',
618
+ 'stroke-width': f2(bw * k2),
619
+ })
620
+ })
621
+
622
+ // mouth
623
+ const m = sh.mouth
624
+ const mw = m.w * Math.max(0.05, g('mw'))
625
+ const mt = g('mt') * m.d
626
+ const mb = g('mb') * m.d
627
+ const kb = 4 / 3
628
+ const w7 = mw * 0.72
629
+ // line: one open stroke along the middle of the lips, so it stays a line
630
+ // in every state: a smile, a frown, or flat where the others open
631
+ const mid = ((mt + mb) / 2) * kb
632
+ const d =
633
+ who.mouth === 'poly'
634
+ ? `M${f2(-mw)} 0L0 ${f2(mt)}L${f2(mw)} 0L0 ${f2(mb)}Z`
635
+ : who.mouth === 'box'
636
+ ? `M${f2(-mw)} 0L${f2(-w7)} ${f2(mb)}L${f2(w7)} ${f2(mb)}L${f2(mw)} 0L${f2(w7)} ${f2(mt)}L${f2(-w7)} ${f2(mt)}Z`
637
+ : who.mouth === 'line'
638
+ ? `M${f2(-mw)} 0C${f2(-mw * 0.5)} ${f2(mid)} ${f2(mw * 0.5)} ${f2(mid)} ${f2(mw)} 0`
639
+ : `M${f2(-mw)} 0C${f2(-mw)} ${f2(mb * kb)} ${f2(mw)} ${f2(mb * kb)} ${f2(mw)} 0C${f2(mw)} ${f2(mt * kb)} ${f2(-mw)} ${f2(mt * kb)} ${f2(-mw)} 0Z`
640
+ const open = who.mouth === 'line'
641
+ const mouth = n('path', 'mouth', {
642
+ d,
643
+ transform: `translate(${f2(m.x + g('mx'))} ${f2(m.y + g('my'))}) rotate(${f2(g('mk'))})`,
644
+ fill: open ? 'none' : INK.ink,
645
+ stroke: INK.ink,
646
+ 'stroke-linecap': 'round',
647
+ 'stroke-linejoin': 'round',
648
+ 'stroke-width': f2(open ? sw : m.sw * st),
649
+ })
650
+
651
+ // cheeks
652
+ const blush = clamp(g('blush'), 0, 1)
653
+ const cheeks = sh.cheeks.map(([cx, cy], i) => {
654
+ const key = `cheek${i}`
655
+ const at = {
656
+ opacity: f2(blush),
657
+ transform: `translate(${f2(cx + (i === 0 ? -1 : 1) * spread * 0.6)} ${f2(cy - lower * 4)}) scale(${f2(0.6 + 0.4 * blush)}) translate(${-cx} ${-cy})`,
658
+ }
659
+ if (who.cheeks === 'dots') {
660
+ const r = sh.cry * 0.9
661
+ return n('g', key, { ...at, fill: deep }, [
662
+ n('circle', `${key}.0`, { cx: f2(cx - sh.crx * 1.05), cy, r: f2(r) }),
663
+ n('circle', `${key}.1`, { cx, cy: f2(cy + sh.cry * 0.5), r: f2(r) }),
664
+ n('circle', `${key}.2`, { cx: f2(cx + sh.crx * 1.05), cy, r: f2(r) }),
665
+ ])
666
+ }
667
+ if (who.cheeks === 'lines') {
668
+ const h = sh.cry * 1.3
669
+ return n(
670
+ 'g',
671
+ key,
672
+ {
673
+ ...at,
674
+ fill: 'none',
675
+ stroke: deep,
676
+ 'stroke-linecap': 'round',
677
+ 'stroke-width': f2(sh.cry * 0.78),
678
+ },
679
+ [-1, 0, 1].map((j) => {
680
+ const x0 = cx + j * sh.crx * 0.8
681
+ return n('path', `${key}.${j + 1}`, {
682
+ d: `M${f2(x0 + h * 0.5)} ${f2(cy - h)}L${f2(x0 - h * 0.5)} ${f2(cy + h)}`,
683
+ })
684
+ }),
685
+ )
686
+ }
687
+ return n('ellipse', key, {
688
+ cx,
689
+ cy,
690
+ rx: sh.crx,
691
+ ry: sh.cry,
692
+ fill: deep,
693
+ ...at,
694
+ })
695
+ })
696
+
697
+ // signature trait: each one keeps its v1.2 motion, measured from a seat on
698
+ // the crown outline instead of a spot on its old face
699
+ const hair = g('hair')
700
+ const lag = pose.lag
701
+ let traitT: string
702
+ let shape: Node
703
+ if (who.trait === 'square') {
704
+ const s = seat(sh.crown, 22 + hair * 10, 0)
705
+ traitT = `translate(${f2(s.x)} ${f2(s.y + lag)}) rotate(${f2(s.a + 22 + hair * 23)})`
706
+ shape = n('rect', 'trait.shape', {
707
+ x: -11,
708
+ y: -11,
709
+ width: 22,
710
+ height: 22,
711
+ fill: deep,
712
+ })
713
+ } else if (who.trait === 'fin') {
714
+ const s = seat(sh.crown, -6, -164 * 22 * RAD)
715
+ const r = s.a + (hair >= 0 ? hair * 20 : hair * 55) - lag * 0.8
716
+ traitT = `translate(${f2(s.x)} ${f2(s.y + lag * 0.25)}) rotate(${f2(r)})`
717
+ shape = n('path', 'trait.shape', {
718
+ d: 'M0 0L0 -40A40 40 0 0 1 40 0Z',
719
+ fill: deep,
720
+ })
721
+ } else if (who.trait === 'ring') {
722
+ // worn at one o'clock; rides up when excited, slides when sleepy
723
+ const b = 35 - hair * 14 - (hair < 0 ? hair * -21 : 0) + lag * 0.9
724
+ const w = ringWalk(sh)
725
+ const s = seat(sh.crown, 8, Math.max(w.min, w.rest + 158 * (b - 35) * RAD))
726
+ traitT = `translate(${f2(s.x)} ${f2(s.y)}) scale(${f2(1 + Math.max(0, hair) * 0.1)})`
727
+ shape = n('circle', 'trait.shape', {
728
+ cx: 0,
729
+ cy: 0,
730
+ r: 12.5,
731
+ fill: 'none',
732
+ stroke: deep,
733
+ 'stroke-width': f2(7 * (pose.expression === 'angry' ? 1.25 : 1)),
734
+ })
735
+ } else if (who.trait === 'dot') {
736
+ // rests on the top; lifts clear when perky, rolls over and down the
737
+ // right slope when low
738
+ const s = clamp(-hair, 0, 1.2)
739
+ const th = Math.min(1, s / 0.35) * 61.05 * RAD
740
+ const travel = (Math.max(0, s - 0.35) / 0.65) * 92
741
+ const p = seat(sh.crown, 12.5, 26.1 * th + travel)
742
+ let cy = p.y
743
+ if (hair > 0) cy -= hair * 18
744
+ cy += lag * (s > 0.35 ? 0.3 : 1)
745
+ traitT = `translate(${f2(p.x)} ${f2(cy)}) scale(${f2(1 + Math.max(0, hair) * 0.1)})`
746
+ shape = n('circle', 'trait.shape', { cx: 0, cy: 0, r: 12.5, fill: deep })
747
+ } else {
748
+ // peak: the mark's triangle worn on the crown; lifts when perky, keels
749
+ // over sideways when low
750
+ const s = seat(sh.crown, 4 + Math.max(0, hair) * 9, 0)
751
+ const r = s.a + (hair < 0 ? -hair * 38 : 0) - lag * 0.5
752
+ traitT = `translate(${f2(s.x)} ${f2(s.y + lag)}) rotate(${f2(r)}) scale(${f2(1 + Math.max(0, hair) * 0.1)})`
753
+ shape = n('path', 'trait.shape', {
754
+ d: 'M0 -27L13 -3L-13 -3Z',
755
+ fill: deep,
756
+ stroke: deep,
757
+ 'stroke-width': 6,
758
+ 'stroke-linejoin': 'round',
759
+ })
760
+ }
761
+ const trait = n('g', 'trait', { transform: traitT }, [shape])
762
+
763
+ // the plates sit a hair out of register at large sizes
764
+ const off = (dx: number, dy: number) =>
765
+ riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
766
+ const av = n(
767
+ 'g',
768
+ 'av',
769
+ {
770
+ transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
771
+ filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
772
+ },
773
+ [
774
+ n(
775
+ 'g',
776
+ 'plateA',
777
+ {
778
+ transform: off(-0.55, 0.45),
779
+ filter: riso ? `url(#${id}-pA)` : undefined,
780
+ },
781
+ [bodyNode, ...cheeks, trait],
782
+ ),
783
+ n(
784
+ 'g',
785
+ 'plateB',
786
+ {
787
+ transform: off(0.6, -0.5),
788
+ filter: riso ? `url(#${id}-pB)` : undefined,
789
+ },
790
+ [...eyes, ...brows, mouth],
791
+ ),
792
+ ],
793
+ )
794
+
795
+ // the thing in the corner: a dot lattice from the mark's vocabulary
796
+ const scene: Node[] = []
797
+ if (o.frame !== 'none') {
798
+ const bg =
799
+ o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
800
+ scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
801
+ const th = clamp(g('thing'), 0, 1)
802
+ const dots: Node[] = []
803
+ for (let j = 0; j < 3; j++)
804
+ for (let i = 0; i < 3; i++) {
805
+ const q = j * 3 + i
806
+ const kk = smooth(q / 12, q / 12 + 0.35, th)
807
+ dots.push(
808
+ n('circle', `lattice.${q}`, {
809
+ cx: 312 + i * 18,
810
+ cy: 2 + j * 18,
811
+ r: f2(1.2 + 2.2 * kk),
812
+ opacity: f3(kk * 0.55),
813
+ }),
814
+ )
815
+ }
816
+ scene.push(
817
+ n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
818
+ )
819
+ }
820
+ scene.push(av)
821
+ if (grainy)
822
+ scene.push(
823
+ n('rect', 'grain', {
824
+ ...VBr,
825
+ filter: `url(#${id}-grain)`,
826
+ opacity: o.frame === 'ink' ? 0.3 : 0.16,
827
+ 'pointer-events': 'none',
828
+ }),
829
+ )
830
+
831
+ const root = n(
832
+ 'svg',
833
+ 'root',
834
+ {
835
+ xmlns: 'http://www.w3.org/2000/svg',
836
+ viewBox: VB.join(' '),
837
+ width: o.size,
838
+ height: o.size,
839
+ role: o.title === false ? undefined : 'img',
840
+ 'aria-label': o.title === false ? undefined : o.title,
841
+ 'aria-hidden': o.title === false ? 'true' : undefined,
842
+ },
843
+ [
844
+ n('defs', 'defs', {}, defs),
845
+ n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
846
+ ],
847
+ )
848
+ return o.live ? root : (prune(root) ?? root)
849
+ }