@doan-labs/peek 1.0.0 → 2.0.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 CHANGED
@@ -9,8 +9,10 @@
9
9
  * what is invisible (opacity 0) to stay small.
10
10
  */
11
11
 
12
- import type { Axes, Persona } from './identity'
12
+ import { blocParts } from './bloc'
13
+ import { type Axes, fnv1a, mulberry32, type Persona } from './identity'
13
14
  import {
15
+ ACCESSORY_INK,
14
16
  type Channel,
15
17
  type Channels,
16
18
  COLORS,
@@ -18,7 +20,10 @@ import {
18
20
  type Expression,
19
21
  FACES,
20
22
  type FaceGeom,
23
+ HAZE,
21
24
  INK,
25
+ type Variant,
26
+ WHEEL,
22
27
  } from './tables'
23
28
 
24
29
  export type Node = {
@@ -54,9 +59,12 @@ export type DrawOptions = {
54
59
  title: string | false
55
60
  /** Keep invisible nodes so an animator can write into them. */
56
61
  live?: boolean
62
+ /** How the face is drawn. Default 'peek'. */
63
+ variant?: Variant
57
64
  }
58
65
 
59
- export type Drawable = Axes & { persona: Persona }
66
+ /** `key`, the tidied name, seeds Haze's blobs; without it, it hazes as ''. */
67
+ export type Drawable = Axes & { persona: Persona; key?: string }
60
68
 
61
69
  /* The frame: viewBox -30 -30 400 400, the floor on its bottom edge. */
62
70
  export const VB = [-30, -30, 400, 400] as const
@@ -69,11 +77,15 @@ export const smooth = (a: number, b: number, v: number) => {
69
77
  const t = clamp((v - a) / (b - a), 0, 1)
70
78
  return t * t * (3 - 2 * t)
71
79
  }
72
- const f2 = (v: number) => String(Math.round(v * 100) / 100)
80
+ export const f2 = (v: number) => String(Math.round(v * 100) / 100)
73
81
  const f3 = (v: number) => String(Math.round(v * 1000) / 1000)
74
82
  const strokeFor = (px: number) =>
75
83
  px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4
76
84
  const RAD = Math.PI / 180
85
+ // the sprout, drawn upward from where it is planted
86
+ const STEM = 'M0 0Q3 -19 0 -36'
87
+ const LEAF0 = 'M0 -33C-24 -30 -38 -52 -25 -53C-12 -54 -2 -43 0 -33Z'
88
+ const LEAF1 = 'M0 -33C5 -54 28 -61 27 -48C26 -36 11 -31 0 -33Z'
77
89
 
78
90
  /** Where a face rests: the body offset, the eye line, one altitude unit. */
79
91
  export function frameOf(face: FaceGeom) {
@@ -208,7 +220,12 @@ export function ringWalk(sh: FaceGeom): { rest: number; min: number } {
208
220
  }
209
221
 
210
222
  type Attrs = Record<string, string | number | false | undefined>
211
- function n(tag: string, key: string, attrs: Attrs, children: Node[] = []) {
223
+ export function n(
224
+ tag: string,
225
+ key: string,
226
+ attrs: Attrs,
227
+ children: Node[] = [],
228
+ ) {
212
229
  const out: Record<string, string> = {}
213
230
  for (const k in attrs) {
214
231
  const v = attrs[k]
@@ -227,14 +244,11 @@ function prune(node: Node): Node | null {
227
244
 
228
245
  export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
229
246
  const sh: FaceGeom = FACES[who.face]
230
- const { body: fill, deep } = COLORS[who.color]
231
247
  const P = who.persona
232
248
  const mul = P.mul as Partial<Record<Channel, number>>
233
249
  const add = P.add as Partial<Record<Channel, number>>
234
250
  const g = (c: Channel) => pose[c] * (mul[c] ?? 1) + (add[c] ?? 0)
235
- const spread = P.spread
236
251
  const id = o.id
237
- const st = strokeFor(o.size)
238
252
  const { baseY, R } = frameOf(sh)
239
253
  const defs: Node[] = []
240
254
 
@@ -363,8 +377,262 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
363
377
  const B = sh.bottom
364
378
  const ty = baseY - alt * R
365
379
  const sc = (v: number) => Math.round(v * 100000) / 100000
366
- const bodyNode =
380
+ const [front, back] = (o.variant === 'bloc' ? blocParts : peekParts)({
381
+ who,
382
+ pose,
383
+ o,
384
+ defs,
385
+ g,
386
+ })
387
+
388
+ // the plates sit a hair out of register at large sizes
389
+ const off = (dx: number, dy: number) =>
390
+ riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
391
+ const av = n(
392
+ 'g',
393
+ 'av',
394
+ {
395
+ transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
396
+ filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
397
+ },
398
+ [
399
+ n(
400
+ 'g',
401
+ 'plateA',
402
+ {
403
+ transform: off(-0.55, 0.45),
404
+ filter: riso ? `url(#${id}-pA)` : undefined,
405
+ },
406
+ front,
407
+ ),
408
+ n(
409
+ 'g',
410
+ 'plateB',
411
+ {
412
+ transform: off(0.6, -0.5),
413
+ filter: riso ? `url(#${id}-pB)` : undefined,
414
+ },
415
+ back,
416
+ ),
417
+ ],
418
+ )
419
+
420
+ // the thing in the corner: a dot lattice from the mark's vocabulary
421
+ const scene: Node[] = []
422
+ if (o.frame !== 'none') {
423
+ const bg =
424
+ o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
425
+ scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
426
+ const th = clamp(g('thing'), 0, 1)
427
+ const dots: Node[] = []
428
+ for (let j = 0; j < 3; j++)
429
+ for (let i = 0; i < 3; i++) {
430
+ const q = j * 3 + i
431
+ const kk = smooth(q / 12, q / 12 + 0.35, th)
432
+ dots.push(
433
+ n('circle', `lattice.${q}`, {
434
+ cx: 312 + i * 18,
435
+ cy: 2 + j * 18,
436
+ r: f2(1.2 + 2.2 * kk),
437
+ opacity: f3(kk * 0.55),
438
+ }),
439
+ )
440
+ }
441
+ scene.push(
442
+ n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
443
+ )
444
+ }
445
+ scene.push(av)
446
+ if (grainy)
447
+ scene.push(
448
+ n('rect', 'grain', {
449
+ ...VBr,
450
+ filter: `url(#${id}-grain)`,
451
+ opacity: o.frame === 'ink' ? 0.3 : 0.16,
452
+ 'pointer-events': 'none',
453
+ }),
454
+ )
455
+
456
+ const root = n(
457
+ 'svg',
458
+ 'root',
459
+ {
460
+ xmlns: 'http://www.w3.org/2000/svg',
461
+ viewBox: VB.join(' '),
462
+ width: o.size,
463
+ height: o.size,
464
+ role: o.title === false ? undefined : 'img',
465
+ 'aria-label': o.title === false ? undefined : o.title,
466
+ 'aria-hidden': o.title === false ? 'true' : undefined,
467
+ },
468
+ [
469
+ n('defs', 'defs', {}, defs),
470
+ n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
471
+ ],
472
+ )
473
+ return o.live ? root : (prune(root) ?? root)
474
+ }
475
+
476
+ /** What a variant draws from: the defs it may add to, and the pose reader. */
477
+ export type Parts = {
478
+ who: Drawable
479
+ pose: Pose
480
+ o: DrawOptions
481
+ defs: Node[]
482
+ /** A channel with the persona applied. */
483
+ g: (c: Channel) => number
484
+ }
485
+
486
+ /*
487
+ * Haze, from the Haze sheet: the body is its own ink plus three soft radial
488
+ * blobs, cut to the outline. A deep one at the bottom, a tint from a hue
489
+ * neighbor low on the outer edge, a glow at the top. Where they sit comes
490
+ * from the name, mulberry32(fnv1a('haze:' + key)), so two faces of one
491
+ * color haze apart.
492
+ * Blob sizes are in body widths; the light level, used below 32px, keeps
493
+ * the face nearly peek where the haze would only be mud.
494
+ */
495
+ const HAZE_LEVEL = {
496
+ medium: {
497
+ deep: [0.502, 0.224],
498
+ glow: [0.749, 0.337],
499
+ tint: [0.4, 0.5],
500
+ tintStops: [
501
+ [0, 0.878],
502
+ [0.15, 0.878],
503
+ [0.618, 0.396],
504
+ ],
505
+ },
506
+ light: {
507
+ deep: [0.298, 0.133],
508
+ glow: [0.549, 0.247],
509
+ tint: [0.31, 0.387],
510
+ tintStops: [
511
+ [0, 0.549],
512
+ [0.55, 0.247],
513
+ ],
514
+ },
515
+ } as const
516
+
517
+ function hazePaint(who: Drawable, o: DrawOptions): Node[] {
518
+ const sh: FaceGeom = FACES[who.face]
519
+ const id = o.id
520
+ const L = HAZE_LEVEL[o.size < 32 ? 'light' : 'medium']
521
+ // the body's box: the crown grown by its radius, down to the bottom
522
+ const xs = sh.crown.poly.map((p) => p[0])
523
+ const ys = sh.crown.poly.map((p) => p[1])
524
+ const x0 = Math.min(...xs) - sh.crown.r
525
+ const y0 = Math.min(...ys) - sh.crown.r
526
+ const W = Math.max(...xs) + sh.crown.r - x0
527
+ const H = sh.bottom - y0
528
+
529
+ // the sheet's own seed and draw order, fitted to its 12 names: never
530
+ // reorder these lines. The tint walks one track, low and out to high and in.
531
+ const rnd = mulberry32(fnv1a(`haze:${who.key ?? ''}`))
532
+ const warm = rnd() < 0.5
533
+ const left = rnd() < 0.5
534
+ const t = rnd()
535
+ const tintX = 0.04 + 0.174 * t
536
+ const tintY = 0.649 + 0.202 * t
537
+ const glowX = 0.38 - 0.12 * rnd()
538
+ const glowY = 0.12 + 0.099 * rnd()
539
+ const deepX = 0.251 - 0.151 * rnd()
540
+ const tintA = 90 - 180 * rnd()
541
+
542
+ const at = (inset: number, onLeft: boolean) =>
543
+ x0 + W * (onLeft ? inset : 1 - inset)
544
+ const i = WHEEL.indexOf(who.color)
545
+ const near = WHEEL[(i + (warm ? WHEEL.length - 1 : 1)) % WHEEL.length]!
546
+ const { body, deep } = COLORS[who.color]
547
+ const blob = (
548
+ k: string,
549
+ color: string,
550
+ [cx, cy, a, rx, ry]: [number, number, number, number, number],
551
+ stops: readonly (readonly [number, number])[],
552
+ ) =>
553
+ n(
554
+ 'radialGradient',
555
+ `haze.${k}`,
556
+ {
557
+ id: `${id}-h${k[0]}`,
558
+ gradientUnits: 'userSpaceOnUse',
559
+ cx: 0,
560
+ cy: 0,
561
+ r: 1,
562
+ gradientTransform: `translate(${f2(cx)} ${f2(cy)}) rotate(${f2(a)}) scale(${f2(rx)} ${f2(ry)})`,
563
+ },
564
+ [...stops, [1, 0] as const].map(([at, op], j) =>
565
+ n('stop', `haze.${k}.${j}`, {
566
+ offset: at,
567
+ 'stop-color': color,
568
+ 'stop-opacity': op,
569
+ }),
570
+ ),
571
+ )
572
+ const fade = ([a, b]: readonly [number, number]) =>
573
+ [
574
+ [0, a],
575
+ [0.55, b],
576
+ ] as const
577
+ const blobs = [
578
+ blob(
579
+ 'deep',
580
+ deep,
581
+ [at(deepX, !left), y0 + H * 0.95, 0, W * 0.5, W * 0.35],
582
+ fade(L.deep),
583
+ ),
584
+ blob(
585
+ 'tint',
586
+ HAZE[near].tint,
587
+ [at(tintX, left), y0 + H * tintY, tintA, W * L.tint[0], W * L.tint[1]],
588
+ L.tintStops,
589
+ ),
590
+ blob(
591
+ 'glow',
592
+ HAZE[who.color].glow,
593
+ [at(glowX, !left), y0 + H * glowY, 0, W * 0.475, W * 0.356],
594
+ fade(L.glow),
595
+ ),
596
+ ]
597
+ const shape = (key: string, paint: string) =>
367
598
  'd' in sh.body
599
+ ? n('path', key, {
600
+ d: sh.body.d,
601
+ fill: paint,
602
+ stroke: sh.body.stroke ? paint : undefined,
603
+ 'stroke-width': sh.body.stroke,
604
+ 'stroke-linejoin': sh.body.stroke ? 'round' : undefined,
605
+ })
606
+ : n('circle', key, {
607
+ cx: sh.body.circle[0],
608
+ cy: sh.body.circle[1],
609
+ r: sh.body.circle[2],
610
+ fill: paint,
611
+ })
612
+ return [
613
+ ...blobs,
614
+ n('g', 'haze', { id: `${id}-hz` }, [
615
+ shape('haze.body', body),
616
+ ...['deep', 'tint', 'glow'].map((k) =>
617
+ shape(`haze.${k}.fill`, `url(#${id}-h${k[0]})`),
618
+ ),
619
+ ]),
620
+ ]
621
+ }
622
+
623
+ /** The peek face, and Haze: the same face with a hazed body. */
624
+ function peekParts({ who, pose, o, defs, g }: Parts): [Node[], Node[]] {
625
+ const sh: FaceGeom = FACES[who.face]
626
+ const { body: fill, deep } = COLORS[who.color]
627
+ const P = who.persona
628
+ const spread = P.spread
629
+ const id = o.id
630
+ const st = strokeFor(o.size)
631
+ const haze = o.variant === 'haze'
632
+ if (haze) defs.push(...hazePaint(who, o))
633
+ const bodyNode = haze
634
+ ? n('use', 'body', { href: `#${id}-hz` })
635
+ : 'd' in sh.body
368
636
  ? n('path', 'body', {
369
637
  d: sh.body.d,
370
638
  fill,
@@ -470,25 +738,47 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
470
738
  const lrx = rx * 1.55
471
739
  const lry = ry * 1.15
472
740
  const top = ry + 1.6 - lower * ry * 1.5
741
+ // a bead has no white to show where it looks, so the whole eye leans
742
+ const [etx, ety] = bead
743
+ ? [ex + side * spread + gx * 6, ey + gy * 6]
744
+ : [ex + side * spread, ey]
745
+ const lids = [
746
+ n('rect', `${kk}.lid`, {
747
+ x: f2(-rx * 2.2),
748
+ y: f2(-ry * 3),
749
+ width: f2(rx * 4.4),
750
+ height: f2(Math.max(0, edge + ry * 3)),
751
+ transform: `rotate(${f2(a)})`,
752
+ fill,
753
+ }),
754
+ n('ellipse', `${kk}.lower`, {
755
+ cx: 0,
756
+ cy: f2(top + lry),
757
+ rx: f2(lrx),
758
+ ry: f2(lry),
759
+ fill,
760
+ }),
761
+ ]
762
+ // Haze: the lids cut the body's own paint, so they never read as patches
763
+ if (haze)
764
+ defs.push(n('clipPath', `${kk}.lidshape`, { id: `${id}-s${i}` }, lids))
473
765
  const parts: Node[] = [
474
766
  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
- ]),
767
+ n(
768
+ 'g',
769
+ `${kk}.lids`,
770
+ { 'clip-path': `url(#${id}-l${i})` },
771
+ haze
772
+ ? [
773
+ n('g', `${kk}.lidpaint`, { 'clip-path': `url(#${id}-s${i})` }, [
774
+ n('use', `${kk}.lidbody`, {
775
+ href: `#${id}-hz`,
776
+ transform: `translate(${f2(-etx)} ${f2(-ety)})`,
777
+ }),
778
+ ]),
779
+ ]
780
+ : lids,
781
+ ),
492
782
  ]
493
783
  // a rimmed eye inks its lid edges too, so the outline follows the lids
494
784
  if (who.eyes === 'ring')
@@ -533,17 +823,7 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
533
823
  opacity: co > 0.01 ? f2(co) : 0,
534
824
  }),
535
825
  )
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
- )
826
+ return n('g', kk, { transform: `translate(${f2(etx)} ${f2(ety)})` }, parts)
547
827
  })
548
828
 
549
829
  // brows
@@ -760,90 +1040,223 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
760
1040
  }
761
1041
  const trait = n('g', 'trait', { transform: traitT }, [shape])
762
1042
 
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
- [
1043
+ // Wardrobe lives on the same body transform as the face. Glasses grow
1044
+ // around the eye sockets, never around the pupils, so gaze stays free.
1045
+ const wardrobe: Node[] = []
1046
+ // strokes follow the same ramp as the face, capped so small hats stay hats
1047
+ const w = (px: number) => f2(px * Math.min(st, 1.6))
1048
+ const line = {
1049
+ fill: 'none',
1050
+ stroke: INK.ink,
1051
+ 'stroke-width': w(5.25),
1052
+ 'stroke-linecap': 'round',
1053
+ 'stroke-linejoin': 'round',
1054
+ }
1055
+ if (who.eyewear !== 'none') {
1056
+ const lx = sh.eyes[0][0] - spread
1057
+ const rr = sh.eyes[1][0] + spread
1058
+ const ey = sh.eyes[0][1]
1059
+ const gap = rr - lx
1060
+ const erx = Math.min(rx + 9, (gap - 15) / 2)
1061
+ const ery = ry + 9
1062
+ const glasses = who.eyewear === 'glasses'
1063
+ const lensX = glasses ? erx : Math.min(rx + 11, (gap - 12) / 2)
1064
+ const top = glasses ? ey - 4 : ey - ry - 2
1065
+ const frames: Node[] = [
1066
+ n('path', 'eyewear.bridge', {
1067
+ ...line,
1068
+ d: `M${f2(lx + lensX)} ${f2(top)}Q${f2((lx + rr) / 2)} ${f2(top - (glasses ? 13 : 0))} ${f2(rr - lensX)} ${f2(top)}`,
1069
+ }),
1070
+ ]
1071
+ for (const [i, cx] of [lx, rr].entries()) {
1072
+ const key = `eyewear.lens${i}`
1073
+ frames.push(
1074
+ n('path', `${key}.arm`, {
1075
+ ...line,
1076
+ d:
1077
+ i === 0
1078
+ ? `M${f2(cx - lensX - 9)} ${f2(top + 2)}L${f2(cx - lensX)} ${f2(top)}`
1079
+ : `M${f2(cx + lensX)} ${f2(top)}L${f2(cx + lensX + 9)} ${f2(top + 2)}`,
1080
+ }),
1081
+ )
1082
+ if (glasses) {
1083
+ frames.push(
1084
+ n('ellipse', key, {
1085
+ ...line,
1086
+ cx: f2(cx),
1087
+ cy: ey,
1088
+ rx: f2(erx),
1089
+ ry: f2(ery),
1090
+ }),
1091
+ )
1092
+ frames.push(
1093
+ n('path', `${key}.glint`, {
1094
+ ...line,
1095
+ stroke: INK.paper,
1096
+ 'stroke-width': w(3),
1097
+ d: `M${f2(cx - erx * 0.65)} ${f2(ey - ery * 0.45)}Q${f2(cx - erx * 0.5)} ${f2(ey - ery * 0.8)} ${f2(cx - erx * 0.2)} ${f2(ey - ery * 0.86)}`,
1098
+ }),
1099
+ )
1100
+ } else {
1101
+ const h = ry * 2 + 9
1102
+ frames.push(
1103
+ n('path', key, {
1104
+ d: `M${f2(cx - lensX)} ${f2(top)}H${f2(cx + lensX)}V${f2(top + h * 0.55)}Q${f2(cx + lensX)} ${f2(top + h)} ${f2(cx)} ${f2(top + h)}Q${f2(cx - lensX)} ${f2(top + h)} ${f2(cx - lensX)} ${f2(top + h * 0.55)}Z`,
1105
+ fill: INK.ink,
1106
+ stroke: INK.ink,
1107
+ 'stroke-width': w(3),
1108
+ 'stroke-linejoin': 'round',
1109
+ }),
1110
+ )
1111
+ frames.push(
1112
+ n('path', `${key}.glint`, {
1113
+ ...line,
1114
+ stroke: INK.paper,
1115
+ 'stroke-width': w(4),
1116
+ d: `M${f2(cx - lensX * 0.58)} ${f2(top + 19)}l8 -8m-1 15 6 -6`,
1117
+ }),
1118
+ n('circle', `${key}.pin`, {
1119
+ cx: f2(cx + lensX * 0.55),
1120
+ cy: f2(top + h - 9),
1121
+ r: 1.7,
1122
+ fill: INK.paper,
1123
+ }),
1124
+ )
1125
+ }
1126
+ }
1127
+ wardrobe.push(n('g', 'eyewear', {}, frames))
1128
+ }
1129
+ if (who.headwear !== 'none') {
1130
+ // Reserve the side opposite the crest. The seat stays fixed through
1131
+ // expressions, so a moving crest never makes a hat switch sides.
1132
+ const side = who.trait === 'ring' || who.trait === 'dot' ? -1 : 1
1133
+ const cap = who.headwear === 'cap'
1134
+ const sprout = who.headwear === 'sprout'
1135
+ // The sheet measures placement across the crown, not along its arc.
1136
+ // Solve that horizontal offset so low, round faces keep the brim above
1137
+ // their brows instead of walking the hat down the side of the body.
1138
+ const inset = sprout ? -6 : cap ? -8 : 0
1139
+ const offset = sprout ? 34 : 40
1140
+ let lo = 0
1141
+ let hi = 160
1142
+ for (let i = 0; i < 16; i++) {
1143
+ const mid = (lo + hi) / 2
1144
+ const p = seat(sh.crown, inset, side * mid)
1145
+ if (side * (p.x - 170) < offset) lo = mid
1146
+ else hi = mid
1147
+ }
1148
+ const anchor = seat(sh.crown, inset, (side * (lo + hi)) / 2)
1149
+ const angle = clamp(anchor.a, -40, 40)
1150
+ const parts: Node[] = []
1151
+ if (cap) {
1152
+ parts.push(
1153
+ n('path', 'headwear.crown', {
1154
+ ...line,
1155
+ fill: INK.paper,
1156
+ d: 'M-33 0C-35 -20 -20 -39 0 -39C20 -39 35 -20 33 0Z',
1157
+ }),
1158
+ n('path', 'headwear.seam', {
1159
+ ...line,
1160
+ 'stroke-width': w(3.5),
1161
+ d: 'M-4 -38Q9 -24 8 -1',
1162
+ }),
1163
+ n('path', 'headwear.brim', {
1164
+ ...line,
1165
+ 'stroke-width': w(7),
1166
+ d: side > 0 ? 'M-35 1Q8 4 52 3' : 'M-52 3Q-8 4 35 1',
1167
+ }),
1168
+ n('circle', 'headwear.button', {
1169
+ cx: 0,
1170
+ cy: -40,
1171
+ r: 3.5,
1172
+ fill: INK.ink,
1173
+ }),
1174
+ )
1175
+ } else if (sprout) {
1176
+ parts.push(
1177
+ // a body-colored halo keeps the ink stem off an ink ground
1178
+ n('path', 'headwear.halo', {
1179
+ ...line,
1180
+ stroke: fill,
1181
+ 'stroke-width': w(11),
1182
+ d: `${STEM}${LEAF0}${LEAF1}`,
1183
+ }),
1184
+ n('path', 'headwear.stem', {
1185
+ ...line,
1186
+ 'stroke-width': w(4.5),
1187
+ d: STEM,
1188
+ }),
1189
+ n('path', 'headwear.leaf0', {
1190
+ ...line,
1191
+ 'stroke-width': w(4),
1192
+ fill: COLORS.mint.body,
1193
+ d: LEAF0,
1194
+ }),
1195
+ n('path', 'headwear.leaf1', {
1196
+ ...line,
1197
+ 'stroke-width': w(4),
1198
+ fill: COLORS.mint.deep,
1199
+ d: LEAF1,
1200
+ }),
1201
+ )
1202
+ } else {
1203
+ parts.push(
1204
+ n('path', 'headwear.wings', {
1205
+ d: 'M-3 0C-13 -16 -28 -23 -29 -10C-31 2 -26 19 -16 13L-3 4L3 4L16 13C26 19 31 2 29 -10C28 -23 13 -16 3 0Z',
1206
+ fill: INK.ink,
1207
+ // the same halo, painted under the fill
1208
+ stroke: fill,
1209
+ 'stroke-width': w(6),
1210
+ 'stroke-linejoin': 'round',
1211
+ 'paint-order': 'stroke',
1212
+ }),
1213
+ n('ellipse', 'headwear.knot', {
1214
+ cx: 0,
1215
+ cy: 1,
1216
+ rx: 6,
1217
+ ry: 8,
1218
+ fill: ACCESSORY_INK.knot,
1219
+ }),
1220
+ )
1221
+ }
1222
+ wardrobe.push(
774
1223
  n(
775
1224
  'g',
776
- 'plateA',
1225
+ 'headwear',
777
1226
  {
778
- transform: off(-0.55, 0.45),
779
- filter: riso ? `url(#${id}-pA)` : undefined,
1227
+ transform: `translate(${f2(anchor.x)} ${f2(anchor.y)}) rotate(${f2(sprout ? angle * 0.5 : angle)})`,
780
1228
  },
781
- [bodyNode, ...cheeks, trait],
1229
+ parts,
782
1230
  ),
1231
+ )
1232
+ }
1233
+ if (who.neckwear === 'tie') {
1234
+ // The knot follows the mouth's lowest edge, including its skew. The
1235
+ // floor clips the blade, just as it clips the creature itself.
1236
+ const skew = Math.abs(Math.sin(g('mk') * RAD)) * mw
1237
+ const bottom = Math.max(0, mt * kb, mb * kb) + skew + m.sw * st
1238
+ wardrobe.push(
783
1239
  n(
784
1240
  'g',
785
- 'plateB',
1241
+ 'neckwear',
786
1242
  {
787
- transform: off(0.6, -0.5),
788
- filter: riso ? `url(#${id}-pB)` : undefined,
1243
+ transform: `translate(${m.x} ${f2(m.y + g('my') + bottom + 8)})`,
1244
+ fill: INK.ink,
789
1245
  },
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),
1246
+ [
1247
+ n('path', 'neckwear.knot', {
1248
+ d: 'M-9 0Q-11 0 -9 4L-5 11Q0 15 5 11L9 4Q11 0 9 0Z',
813
1249
  }),
814
- )
815
- }
816
- scene.push(
817
- n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
1250
+ n('path', 'neckwear.blade', {
1251
+ d: 'M-4 15L-13 63Q-14 66 -11 69L0 79L11 69Q14 66 13 63L4 15Z',
1252
+ }),
1253
+ ],
1254
+ ),
818
1255
  )
819
1256
  }
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
1257
 
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)
1258
+ return [
1259
+ [bodyNode, ...cheeks, trait],
1260
+ [...eyes, ...brows, mouth, ...wardrobe],
1261
+ ]
849
1262
  }