@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/LICENSE +21 -0
- package/README.md +64 -1
- package/dist/bloc.d.ts +2 -0
- package/dist/core.d.ts +9 -0
- package/dist/draw.d.ts +24 -2
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +22 -2
- package/dist/index.d.ts +1 -7
- package/dist/index.js +719 -142
- package/dist/peek.svelte +75 -0
- package/dist/peek.svelte.d.ts +5 -0
- package/dist/svelte-peek.d.ts +14 -0
- package/dist/svelte-peek.js +2205 -0
- package/dist/svelte.d.ts +3 -0
- package/dist/svelte.js +1650 -0
- package/dist/svg.d.ts +3 -1
- package/dist/svg.js +737 -166
- package/dist/tables.d.ts +67 -1
- package/dist/vue.d.ts +17 -0
- package/dist/vue.js +2302 -0
- package/package.json +30 -5
- package/src/bloc.ts +326 -0
- package/src/core.ts +39 -0
- package/src/draw.ts +522 -109
- package/src/identity.ts +11 -0
- package/src/index.ts +1 -26
- package/src/peek.svelte +75 -0
- package/src/peek.svelte.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/svelte-peek.ts +18 -0
- package/src/svelte.ts +3 -0
- package/src/svg.ts +30 -2
- package/src/tables.ts +74 -2
- package/src/vue.ts +133 -0
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
'
|
|
1225
|
+
'headwear',
|
|
777
1226
|
{
|
|
778
|
-
transform:
|
|
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
|
-
|
|
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
|
-
'
|
|
1241
|
+
'neckwear',
|
|
786
1242
|
{
|
|
787
|
-
transform:
|
|
788
|
-
|
|
1243
|
+
transform: `translate(${m.x} ${f2(m.y + g('my') + bottom + 8)})`,
|
|
1244
|
+
fill: INK.ink,
|
|
789
1245
|
},
|
|
790
|
-
[
|
|
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
|
-
|
|
817
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
}
|