@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/README.md +38 -0
- package/dist/animate.d.ts +58 -0
- package/dist/draw.d.ts +69 -0
- package/dist/identity.d.ts +55 -0
- package/dist/identity.js +398 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1700 -0
- package/dist/peek.d.ts +13 -0
- package/dist/svg.d.ts +33 -0
- package/dist/svg.js +1053 -0
- package/dist/tables.d.ts +258 -0
- package/package.json +70 -0
- package/src/animate.ts +670 -0
- package/src/draw.ts +849 -0
- package/src/identity.ts +147 -0
- package/src/index.ts +28 -0
- package/src/peek.tsx +98 -0
- package/src/svg.ts +94 -0
- package/src/tables.ts +414 -0
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
|
+
}
|