@pieai/swimmer-avatar-kit 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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +23 -0
  3. package/README.md +125 -0
  4. package/UPSTREAM.md +47 -0
  5. package/dist/avatar.d.ts +2 -0
  6. package/dist/avatar.js +69 -0
  7. package/dist/avatar.js.map +1 -0
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/materials.d.ts +20 -0
  12. package/dist/materials.js +61 -0
  13. package/dist/materials.js.map +1 -0
  14. package/dist/react-three-fiber.d.ts +16 -0
  15. package/dist/react-three-fiber.js +52 -0
  16. package/dist/react-three-fiber.js.map +1 -0
  17. package/dist/recipe.d.ts +18 -0
  18. package/dist/recipe.js +118 -0
  19. package/dist/recipe.js.map +1 -0
  20. package/dist/types.d.ts +62 -0
  21. package/dist/types.js +2 -0
  22. package/dist/types.js.map +1 -0
  23. package/package.json +102 -0
  24. package/upstream-lock.json +38 -0
  25. package/vendor/kindergrimm/LICENSE +24 -0
  26. package/vendor/kindergrimm/src/gloss/catmullClark.js +129 -0
  27. package/vendor/kindergrimm/src/gloss/gface.d.ts +25 -0
  28. package/vendor/kindergrimm/src/gloss/gface.js +226 -0
  29. package/vendor/kindergrimm/src/gloss/gform.js +201 -0
  30. package/vendor/kindergrimm/src/gloss/ghair.js +534 -0
  31. package/vendor/kindergrimm/src/gloss/glayout.js +365 -0
  32. package/vendor/kindergrimm/src/gloss/gmedia.d.ts +28 -0
  33. package/vendor/kindergrimm/src/gloss/gmedia.js +470 -0
  34. package/vendor/kindergrimm/src/gloss/gpalette.d.ts +10 -0
  35. package/vendor/kindergrimm/src/gloss/gpalette.js +205 -0
  36. package/vendor/kindergrimm/src/gloss/gparts/blush.js +57 -0
  37. package/vendor/kindergrimm/src/gloss/gparts/body.js +47 -0
  38. package/vendor/kindergrimm/src/gloss/gparts/brows.js +55 -0
  39. package/vendor/kindergrimm/src/gloss/gparts/crest.js +88 -0
  40. package/vendor/kindergrimm/src/gloss/gparts/eyes.js +357 -0
  41. package/vendor/kindergrimm/src/gloss/gparts/frame.js +225 -0
  42. package/vendor/kindergrimm/src/gloss/gparts/hair.js +100 -0
  43. package/vendor/kindergrimm/src/gloss/gparts/hat.js +163 -0
  44. package/vendor/kindergrimm/src/gloss/gparts/index.js +33 -0
  45. package/vendor/kindergrimm/src/gloss/gparts/mark.js +139 -0
  46. package/vendor/kindergrimm/src/gloss/gparts/mouth.js +170 -0
  47. package/vendor/kindergrimm/src/gloss/gparts/nose.js +63 -0
  48. package/vendor/kindergrimm/src/gloss/gparts/specs.js +124 -0
  49. package/vendor/kindergrimm/src/gloss/grig.d.ts +41 -0
  50. package/vendor/kindergrimm/src/gloss/grig.js +286 -0
  51. package/vendor/kindergrimm/src/gloss/gshape.js +421 -0
  52. package/vendor/kindergrimm/src/gloss/gspecies.d.ts +10 -0
  53. package/vendor/kindergrimm/src/gloss/gspecies.js +338 -0
  54. package/vendor/kindergrimm/src/gloss/gtexture.js +406 -0
  55. package/vendor/kindergrimm/src/rng.d.ts +11 -0
  56. package/vendor/kindergrimm/src/rng.js +27 -0
@@ -0,0 +1,100 @@
1
+ // THE HAIR — the humanoid's only, and the part that turns a head into a
2
+ // character. The shapes are all in `ghair.js`; this file is the dice.
3
+ //
4
+ // The DEFAULT IS BALD, and that is deliberate rather than lazy: a cast
5
+ // style table is exclusive, so a species that says nothing about hair
6
+ // gets none, and only the humanoid casts real styles. A bear with a
7
+ // ponytail is one line away and it is not one anybody wanted.
8
+ import { buildHair, HAIR_STYLES } from '../ghair.js';
9
+ import { HAIR_IDS, HAIR_WEIGHTS } from '../gpalette.js';
10
+
11
+ export { HAIR_STYLES };
12
+
13
+ export const Hair = {
14
+ id: 'hair', label: 'hair', order: 2,
15
+
16
+ gen: (rng, C) => ({
17
+ // bald unless a species asks — see the header
18
+ style: C.pick(rng, 'style', [['bald', 100]]),
19
+ // hair has its own colour table, never the body's palette
20
+ color: C.pick(rng, 'color', HAIR_WEIGHTS),
21
+ // how far the clumps stand off the head. WIDE, because flat-and-
22
+ // sleek against big-and-soft is most of the difference between two
23
+ // heads wearing the same cut.
24
+ vol: C.range(rng, 'vol', .7, 1.5),
25
+ // how many clumps: fewer and chunkier, or more and finer
26
+ density: C.range(rng, 'density', .8, 1.25),
27
+ // how far each clump wraps past its own slice — the overlap
28
+ width: C.range(rng, 'width', .9, 1.15),
29
+ // how unequal the tips are. This is what stops a hem reading as a
30
+ // line somebody ruled across the head.
31
+ jag: C.range(rng, 'jag', .6, 1.5),
32
+ wave: C.range(rng, 'wave', .7, 1.35),
33
+ part: C.range(rng, 'part', .7, 1.3),
34
+ // how hard an upswept style lifts. Only the styles that ask.
35
+ pomp: C.range(rng, 'pomp', .75, 1.35),
36
+ // where this head's whorl sits, nudged off the style's own spot —
37
+ // two heads with the same cut still comb differently
38
+ whorl: C.range(rng, 'whorl', -.35, .35),
39
+ // how long and how present the point strands are — the momiage in
40
+ // front of the ears, the loose fringe wisps, the flyaways
41
+ wisps: C.range(rng, 'wisps', .75, 1.3),
42
+ // ONE seed for every per-clump variation on the head. It has to be
43
+ // a rolled parameter rather than live randomness: hair is rebuilt
44
+ // on every boil frame, and anything re-rolled per frame shimmers.
45
+ seed: rng.r(0, 6.283),
46
+ // the tied-up styles
47
+ // Just BEHIND the ear — about a right angle off the nose. Tied
48
+ // further round than that the tail hangs behind the head and the
49
+ // whole silhouette is lost from the front, which is the only angle
50
+ // the sheet ever sees; tied forward of it, it covers the cheek.
51
+ tailAng: C.range(rng, 'tailAng', 1.45, 1.85), // radians off the face
52
+ tailY: C.range(rng, 'tailY', .5, .78), // how high it is tied
53
+ tailLen: C.range(rng, 'tailLen', .8, 1.9), // × head height
54
+ bunAng: C.range(rng, 'bunAng', .7, 1.15),
55
+ bunR: C.range(rng, 'bunR', .17, .26),
56
+ // THE AHOGE, and it is worth a knob of its own: one strand off the
57
+ // crown reads as a whole personality, so it is dealt often but not
58
+ // to everyone.
59
+ ahoge: C.chance(rng, 'ahoge', .22),
60
+ ahogeAng: rng.r(-.5, .5),
61
+ ahogeDir: rng.chance(.5) ? 1 : -1,
62
+ }),
63
+
64
+ meta: () => ({
65
+ style: { label: 'cut', pick: HAIR_STYLES },
66
+ color: { label: 'colour', pick: HAIR_IDS },
67
+ vol: { label: 'volume', range: [.5, 2] },
68
+ wave: { label: 'wave', range: [0, 2] },
69
+ part: { label: 'parting', range: [0, 2] },
70
+ density: { label: 'clumps', range: [.6, 1.6] },
71
+ width: { label: 'clump width', range: [.7, 1.4] },
72
+ jag: { label: 'ragged tips', range: [0, 2] },
73
+ pomp: { label: 'sweep up', range: [0, 2] },
74
+ whorl: { label: 'whorl', range: [-1, 1] },
75
+ wisps: { label: 'wisps', range: [.3, 2] },
76
+ seed: { label: 'shuffle', range: [0, 6.28] },
77
+ tailY: { label: 'tied at', range: [.2, .8] },
78
+ tailLen: { label: 'tail length', range: [.4, 2.4] },
79
+ tailAng: { label: 'tails apart', range: [1.2, 2.6] },
80
+ bunR: { label: 'bun size', range: [.1, .35] },
81
+ ahoge: { label: 'ahoge', bool: true },
82
+ }),
83
+
84
+ build(add, P, L) {
85
+ for (const piece of buildHair(P, L)) {
86
+ // a bun is a ball, and the body's own solid primitive already
87
+ // makes those — no reason for `ghair.js` to loft a sphere
88
+ if (piece.ball) {
89
+ add({ type: 'solid', id: piece.id, color: L.hair, cast: true, finish: 'hair',
90
+ rx: piece.ball.r, ry: piece.ball.r * .92, rz: piece.ball.r,
91
+ pos: [piece.ball.pos[0], piece.ball.pos[1] + L.cy, piece.ball.pos[2]] });
92
+ continue;
93
+ }
94
+ // hair is a real volume standing off the head, not a feature lying
95
+ // flush on it, so unlike every other face part it CASTS
96
+ add({ type: 'mesh', id: piece.id, mesh: piece.mesh,
97
+ pos: [0, L.cy, 0], color: L.hair, cast: true, finish: 'hair' });
98
+ }
99
+ },
100
+ };
@@ -0,0 +1,163 @@
1
+ // HEADWEAR — the last of the extras and the loudest, because unlike a
2
+ // mark or a pair of glasses a hat changes the SILHOUETTE, which is the
3
+ // only thing that survives at sheet scale.
4
+ //
5
+ // Its relationship with the HAIR splits the catalogue in two, and
6
+ // getting that wrong is what a first pass does:
7
+ //
8
+ // HUG a beanie or a cap is PULLED ON, and a real one compresses
9
+ // the hair under it — it wants the head's own radius plus a
10
+ // little, and the hair poking out below the rim is the read.
11
+ // Sized to clear a big soft cut instead, it stands a whole
12
+ // hair's volume off the skull at every height, including
13
+ // down at the ears where there is no hair at all, and comes
14
+ // out as a bowl balanced on the head.
15
+ // PERCH a headband, a bow, a flower, a crown — these sit ON TOP of
16
+ // whatever is up there and genuinely need `L.hairTop`, which
17
+ // the hair publishes via `hairOuter`. Same edge as the eyes
18
+ // publishing `eyeProud` for the spectacles.
19
+ //
20
+ // The vocabulary is deliberately three shapes of thing:
21
+ //
22
+ // DOME a sphere cut short (`dome`) — a beanie, a cap's crown
23
+ // BAND a sphere ZONE, cut at both ends (`domeFrom`) — a brim, a
24
+ // cuff, a headband. This is what `domeFrom` was added for.
25
+ // PLATE the flat stuff — a bow, a flower, a crown's points
26
+ //
27
+ // Colour comes off the character's own palette (`warm`, `lite`, `ink`) rather
28
+ // than a new table. A hat in an unrelated colour is a different
29
+ // object; a hat in the family's colours is an accessory.
30
+ const STYLE = {
31
+ none: null,
32
+ // pulled down over the crown, with a rolled cuff at the rim
33
+ beanie: { dome: .42, cuff: [.38, .47], hug: 1.09, bare: true },
34
+ // a thin zone lying round the head: the headband
35
+ band: { band: [.26, .33], hug: 1.05, bare: true },
36
+ // points standing round the crown
37
+ crown: { points: 6, grow: 1.03 },
38
+ // one bow on the side of the head
39
+ bow: { bow: true, grow: 1.02 },
40
+ // and one flower, same place, quieter
41
+ flower: { bloom: true, grow: 1.02 },
42
+ };
43
+
44
+ export const HAT_STYLES = Object.keys(STYLE);
45
+
46
+ /**
47
+ * Does this hat go on a BARE head?
48
+ *
49
+ * A beanie and a headband are pulled ON, and there is no arrangement of
50
+ * sizes that lets one share a skull with a big soft haircut: sized to
51
+ * clear the hair it is a bowl balanced on the head, sized to the skull
52
+ * it vanishes underneath, and squashing the hair under its rim — which
53
+ * was built, and worked — still leaves the two fighting for the same
54
+ * few millimetres at every seed. So they are simply worn on a bare
55
+ * head, which is a real look and needs no negotiation at all.
56
+ *
57
+ * `ghair.js` reads this off the LAYOUT and builds nothing. The hats
58
+ * that PERCH (a bow, a flower, a crown) sit on top of the hair as
59
+ * before and are not listed here.
60
+ */
61
+ export function hatBare(P) {
62
+ return !!STYLE[P.hat?.style]?.bare;
63
+ }
64
+
65
+ export const Hat = {
66
+ // after the hair, whose outer radius it needs
67
+ id: 'hat', label: 'hat', order: 3,
68
+
69
+ // Rare. A hat is a strong statement and the sheet can carry a few.
70
+ gen: (rng, C) => ({
71
+ // NOBODY wears a hat unless a species asks — see the header. A bear
72
+ // in a beanie is one line away and it is not one anybody wanted.
73
+ style: C.pick(rng, 'style', [['none', 100]]),
74
+ // WHICH accessory colour. The layout turns this into a hex that is
75
+ // guaranteed to stand clear of the skin and the hair.
76
+ accIx: rng.ri(0, 9),
77
+ size: C.range(rng, 'size', .94, 1.12),
78
+ // which side a bow or a flower sits on, and how far round
79
+ side: rng.chance(.5) ? 1 : -1,
80
+ lean: C.range(rng, 'lean', .7, 1.15),
81
+ }),
82
+
83
+ meta: () => ({
84
+ style: { label: 'style', pick: HAT_STYLES },
85
+ accIx: { label: 'colour', range: [0, 9] },
86
+ size: { label: 'size', range: [.8, 1.3] },
87
+ lean: { label: 'placement', range: [.4, 1.5] },
88
+ }),
89
+
90
+ build(add, P, L) {
91
+ const T = P.hat;
92
+ const st = STYLE[T.style];
93
+ if (!st) return;
94
+ const col = L.acc;
95
+ // An accessory is NOT made of the character: left to inherit the shell's
96
+ // finish a beanie on a humanoid is poured in SKIN. `acc` is the
97
+ // soft plastic they all wear, and its sheen is self-coloured —
98
+ // rubber's white one washed a brick red out to pale pink.
99
+ const fin = 'acc';
100
+ // HUG the head and let the hair squash under it, or PERCH on top
101
+ // of whatever the hair is doing — see the header
102
+ const k = (st.hug ?? (L.hairTop * st.grow)) * T.size;
103
+ const rx = L.rx * k, ry = L.ry * k, rz = L.rz * k;
104
+ const solid = (id, extra) => add({
105
+ type: 'solid', id, rx, ry, rz, exp: L.exp, cast: true, finish: fin,
106
+ pos: [0, L.cy, 0], color: col, ...extra });
107
+
108
+ if (st.dome) {
109
+ solid('hat', { dome: st.dome });
110
+ // the CUFF: a zone, slightly fatter than the crown it rolls
111
+ // under, which is the whole read of a knitted brim
112
+ if (st.cuff)
113
+ add({ type: 'solid', id: 'hatCuff', exp: L.exp, cast: true,
114
+ rx: rx * 1.045, ry: ry * 1.03, rz: rz * 1.045,
115
+ dome: st.cuff[1], domeFrom: st.cuff[0], finish: fin,
116
+ pos: [0, L.cy, 0], color: col });
117
+ }
118
+ if (st.band)
119
+ add({ type: 'solid', id: 'hat', exp: L.exp, cast: true,
120
+ rx, ry, rz, dome: st.band[1], domeFrom: st.band[0], finish: fin,
121
+ pos: [0, L.cy, 0], color: col });
122
+
123
+ // THE CROWN — points standing round the top on `L.top`, the same
124
+ // anchor the ears use
125
+ if (st.points) {
126
+ for (let i = 0; i < st.points; i++) {
127
+ const t = (i / (st.points - 1) - .5) * 1.5;
128
+ const a = L.top(t);
129
+ const u = L.s * .16 * T.size;
130
+ add({ type: 'plate', id: 'hatPoint' + i, outline: 'tri', flip: true,
131
+ w: u * .5, h: u, p: a.p, n: [a.n[0] * .5, a.n[1] * .5, 1],
132
+ roll: -t * .5, d: u * .35, bevel: u * .16,
133
+ offset: [0, u * .35], color: col, cast: true, finish: fin });
134
+ }
135
+ }
136
+
137
+ // A BOW or a FLOWER, off to one side of the crown — the two extras
138
+ // that are placed rather than worn
139
+ if (st.bow || st.bloom) {
140
+ const a = L.top(T.side * T.lean * .62);
141
+ const u = L.s * (st.bow ? .2 : .17) * T.size;
142
+ const n = [a.n[0] * .6, a.n[1] * .6, .8];
143
+ if (st.bloom) {
144
+ add({ type: 'plate', id: 'hatBloom', outline: 'flower', petals: 5, amp: .34,
145
+ w: u, h: u, p: a.p, n, d: u * .3, bevel: u * .14,
146
+ proud: u * .1, color: col, cast: true, finish: fin });
147
+ add({ type: 'plate', id: 'hatBloomEye', outline: 'ellipse',
148
+ w: u * .3, h: u * .3, p: a.p, n, d: u * .2, bevel: u * .09,
149
+ proud: u * .34, color: L.sclera, finish: fin });
150
+ return;
151
+ }
152
+ // two loops and a knot: the cheapest bow that reads as one
153
+ for (const s of [-1, 1])
154
+ add({ type: 'plate', id: 'hatBow' + (s < 0 ? 'L' : 'R'), outline: 'tri',
155
+ w: u * .5, h: u * .8, p: a.p, n, roll: s * Math.PI / 2,
156
+ offset: [s * u * .62, 0], d: u * .3, bevel: u * .13,
157
+ proud: u * .08, color: col, cast: true, finish: fin });
158
+ add({ type: 'plate', id: 'hatKnot', outline: 'ellipse',
159
+ w: u * .26, h: u * .26, p: a.p, n,
160
+ d: u * .3, bevel: u * .13, proud: u * .2, color: col, finish: fin });
161
+ }
162
+ },
163
+ };
@@ -0,0 +1,33 @@
1
+ // The registry. Order is build order, and the only hard requirement is
2
+ // that `body` runs first — the layout it establishes is what every
3
+ // face part places against.
4
+ //
5
+ // Adding a face part = one file here + one line below. It never
6
+ // touches the rig, the shapes it can use are in `gshape.js`, and it
7
+ // places itself through `L.at`.
8
+ import { Body } from './body.js';
9
+ import { Frame } from './frame.js';
10
+ import { Crest } from './crest.js';
11
+ import { Hair } from './hair.js';
12
+ import { Eyes } from './eyes.js';
13
+ import { Brows } from './brows.js';
14
+ import { Nose } from './nose.js';
15
+ import { Mouth } from './mouth.js';
16
+ import { Blush } from './blush.js';
17
+ // THE EXTRAS. Everything above is what a character IS; these are what it is
18
+ // WEARING or what has happened to it, and all three are dealt rare on
19
+ // purpose — an accessory on every character is not a character, it is the
20
+ // house style.
21
+ import { Specs } from './specs.js';
22
+ import { Hat } from './hat.js';
23
+ import { Mark } from './mark.js';
24
+
25
+ // There WAS a `muzzle` part here — a second storey on the lower face
26
+ // in patch, lump and snout. All three are gone. The flat patch read as
27
+ // a white block behind the mouth, and the two solid ones pushed the
28
+ // face out into a snout that fought the head's own silhouette. What
29
+ // carries a species turned out to be the EARS and the eye patches, not
30
+ // a second lump of geometry.
31
+ export const GPARTS = [Body, Frame, Crest, Hair, Hat, Eyes, Brows, Specs, Nose, Mouth,
32
+ Blush, Mark];
33
+ export const GPART_BY_ID = Object.fromEntries(GPARTS.map(p => [p.id, p]));
@@ -0,0 +1,139 @@
1
+ // MARKS — the small stuff painted or stuck on a face: a scar, a
2
+ // plaster, freckles, a beauty spot, ink tears. The drawn generator has
3
+ // had these since the beginning (`src/parts/extras.js`) and they do the
4
+ // same job here: they are the difference between a mascot and a
5
+ // character, and they cost almost nothing.
6
+ //
7
+ // EVERY ONE IS ASYMMETRIC OR SCATTERED, on purpose. A mark mirrored
8
+ // neatly onto both cheeks stops reading as something that HAPPENED to
9
+ // this character and starts reading as decoration that came out of the mould
10
+ // — which is the one thing a scar must not do. So a scar takes a side,
11
+ // freckles are a scatter, and only `tears` is a pair (it is a face
12
+ // doing something, not a mark).
13
+ //
14
+ // Marks lie FLAT: barely proud, no bevel to speak of. They are the one
15
+ // family in the lab that is painted rather than moulded, and a mark
16
+ // with a fat rounded rim reads as a lump of vinyl stuck on a cheek.
17
+ const STYLE = {
18
+ none: null,
19
+ // one stroke across a brow, and a second short one crossing it
20
+ scar: { kind: 'scar', ticks: 1 },
21
+ // the stitched version: a stroke with rungs
22
+ sutures: { kind: 'scar', ticks: 4 },
23
+ // a plaster: a pad with a lighter middle
24
+ plaster: { kind: 'plaster' },
25
+ // a scatter across the nose and both cheeks
26
+ freckles: { kind: 'dots', n: 7, size: .09, spread: 1 },
27
+ // one small dot, high on a cheek
28
+ spot: { kind: 'dots', n: 1, size: .13, spread: 0 },
29
+ // two ink streaks running down from the eyes — the drawn rig's
30
+ // black tears, and the only mark here that is a pair
31
+ tears: { kind: 'tears' },
32
+ };
33
+
34
+ export const MARK_STYLES = Object.keys(STYLE);
35
+
36
+ export const Mark = {
37
+ // LAST. It goes over everything, including the blush.
38
+ id: 'mark', label: 'marks', order: 6,
39
+
40
+ gen: (rng, C) => ({
41
+ style: C.pick(rng, 'style', [['none', 82], ['freckles', 5], ['scar', 3],
42
+ ['plaster', 3], ['spot', 3], ['sutures', 2],
43
+ ['tears', 2]]),
44
+ side: rng.chance(.5) ? 1 : -1,
45
+ size: C.range(rng, 'size', .85, 1.2),
46
+ roll: rng.r(-.5, .5),
47
+ // a scar over the brow reads as a fight; the same scar on the
48
+ // cheek reads as a scratch. Worth having both.
49
+ high: rng.chance(.55),
50
+ }),
51
+
52
+ meta: () => ({
53
+ style: { label: 'style', pick: MARK_STYLES },
54
+ size: { label: 'size', range: [.5, 1.8] },
55
+ roll: { label: 'tilt', range: [-1.2, 1.2] },
56
+ high: { label: 'over the brow', bool: true },
57
+ }),
58
+
59
+ build(add, P, L) {
60
+ const M = P.mark;
61
+ const st = STYLE[M.style];
62
+ if (!st) return;
63
+ const r = L.eyeR * M.size;
64
+ const sd = M.side;
65
+ // a plaster is not made of skin either — see `acc` in gmedia.js
66
+ const fin = 'acc';
67
+
68
+ // ---- SCAR / SUTURES ------------------------------------------------
69
+ if (st.kind === 'scar') {
70
+ const ay = M.high ? L.eyeY + .34 : L.eyeY - .3;
71
+ const a = L.onFace(sd * (L.eyeX + .06), ay, { halfW: r * .5 });
72
+ const len = r * .58, tube = r * .055;
73
+ add({ type: 'plate', id: 'markScar', outline: 'band', curve: 'line',
74
+ w: len, h: tube, tube,
75
+ p: a.p, n: a.n, roll: M.roll + sd * .5,
76
+ d: tube * 1.2, bevel: tube * .5, proud: tube * .3, color: L.ink });
77
+ // the rungs. One is a crossed scar; four is a stitched seam.
78
+ for (let i = 0; i < st.ticks; i++) {
79
+ const t = st.ticks === 1 ? 0 : (i / (st.ticks - 1) - .5) * 1.3;
80
+ add({ type: 'plate', id: 'markTick' + i, outline: 'band', curve: 'line',
81
+ w: tube * 3.2, h: tube * .8, tube: tube * .8,
82
+ p: a.p, n: a.n, roll: M.roll + sd * .5 + Math.PI / 2,
83
+ offset: [t * len * .78, 0],
84
+ d: tube, bevel: tube * .4, proud: tube * .3, color: L.ink });
85
+ }
86
+ return;
87
+ }
88
+
89
+ // ---- PLASTER -------------------------------------------------------
90
+ if (st.kind === 'plaster') {
91
+ const a = L.onFace(sd * (L.eyeX + .1), L.eyeY - .34, { halfW: r * .62 });
92
+ const w = r * .62, h = r * .26;
93
+ add({ type: 'plate', id: 'markPlaster', outline: 'rect',
94
+ w, h, r: h * .5,
95
+ p: a.p, n: a.n, roll: M.roll + sd * .38,
96
+ d: r * .07, bevel: r * .035, proud: r * .03, color: L.sclera, finish: fin });
97
+ // the pad: the darker middle every plaster has
98
+ add({ type: 'plate', id: 'markPad', outline: 'rect',
99
+ w: w * .38, h: h * .62, r: h * .2,
100
+ p: a.p, n: a.n, roll: M.roll + sd * .38,
101
+ d: r * .05, bevel: r * .02, proud: r * .055, color: L.warm, finish: fin });
102
+ return;
103
+ }
104
+
105
+ // ---- DOTS: freckles, or one beauty spot ----------------------------
106
+ if (st.kind === 'dots') {
107
+ const rr = r * st.size;
108
+ for (let i = 0; i < st.n; i++) {
109
+ // A FIXED scatter, not a rolled one: marks are rebuilt every
110
+ // boil frame, so anything random here would crawl across the
111
+ // face. Two harmonics give a spread that does not read as a row.
112
+ const u = st.n === 1 ? 0 : i / (st.n - 1) * 2 - 1;
113
+ const ax = st.spread
114
+ ? u * (L.eyeX + .34) + Math.sin(i * 2.4) * .06
115
+ : sd * (L.eyeX + .3);
116
+ const ay = st.spread
117
+ ? L.eyeY - .3 + Math.sin(i * 3.9) * .1
118
+ : L.eyeY - .16;
119
+ const a = L.onFace(ax, ay, { halfW: rr });
120
+ add({ type: 'plate', id: 'markDot' + i, outline: 'ellipse',
121
+ w: rr, h: rr, p: a.p, n: a.n,
122
+ d: rr * .5, bevel: rr * .25, proud: rr * .1,
123
+ color: st.n === 1 ? L.ink : L.warm });
124
+ }
125
+ return;
126
+ }
127
+
128
+ // ---- TEARS ---------------------------------------------------------
129
+ // straight down from under each eye, and long: a short one reads as
130
+ // a smudge rather than as something running
131
+ for (const [id, side] of [['markTearL', -1], ['markTearR', 1]]) {
132
+ const a = L.onFace(side * L.eyeX, L.eyeY - .34, { halfW: r * .09 });
133
+ add({ type: 'plate', id, outline: 'band', curve: 'line',
134
+ w: r * .5, h: r * .085, tube: r * .085,
135
+ p: a.p, n: a.n, roll: Math.PI / 2,
136
+ d: r * .06, bevel: r * .03, proud: r * .03, color: L.ink });
137
+ }
138
+ },
139
+ };
@@ -0,0 +1,170 @@
1
+ // THE MOUTH — two families now, and the split is the whole reference:
2
+ //
3
+ // LINES — a stroke along a centreline (smile, cat, zig…). Small,
4
+ // ink, the pastel-egg register. These are what we had.
5
+ //
6
+ // MAWS — a CONSTRUCTED mouth: an ink outline with a dark maroon
7
+ // interior behind it, and optionally a white strip of teeth hanging
8
+ // from the top lip and a warm tongue rising from the bottom. This is
9
+ // the Ferriz read — the shark grin, the zombie teeth, the huge
10
+ // open holler — and it is allowed to be ENORMOUS: half the face and
11
+ // more. The earlier small-and-black rule was for the line family,
12
+ // and the maws are exactly the exception it was waiting for.
13
+ //
14
+ // A maw is stacked plates, nothing else — the same trick as a pupil
15
+ // on a white. Interior parked behind the outline, furniture parked in
16
+ // front of the interior.
17
+ const STYLE = {
18
+ none: null,
19
+ // -- lines: small, ink -------------------------------------------
20
+ smile: { line: true, curve: 'arc', sag: .42, wf: 1, tf: .15 },
21
+ frown: { line: true, curve: 'arc', sag: -.4, wf: .9, tf: .15 },
22
+ flat: { line: true, curve: 'line', sag: 0, wf: .8, tf: .14 },
23
+ cat: { line: true, curve: 'wave', sag: .34, wf: 1.05, tf: .14 },
24
+ zig: { line: true, curve: 'zig', sag: .34, wf: 1, tf: .12 },
25
+ // -- maws: big, built --------------------------------------------
26
+ // mul is the licence to go big: it scales the whole construction
27
+ // `mul` is the licence to go big — but it was written when the eye
28
+ // unit was half this size, and everything here scales off that unit.
29
+ // At 2.3 a maw came out spanning the whole face.
30
+ open: { maw: true, wf: 1, hf: .78, mul: 1.42 },
31
+ holler: { maw: true, wf: .8, hf: 1.05, mul: 1.45 }, // taller than wide
32
+ grin: { maw: true, wf: 1.3, hf: .52, mul: 1.5, allTeeth: true },
33
+ };
34
+
35
+ export const MOUTH_STYLES = Object.keys(STYLE);
36
+
37
+ /** how far the mouth reaches above its own centre, in units of
38
+ * `L.eyeR`. The layout asks, the same way it asks `eyeReach`, so a
39
+ * maw two eyes tall still gets pushed clear of the eyes. */
40
+ export function mouthReach(P) {
41
+ const st = STYLE[P.mouth.style];
42
+ if (!st) return 0;
43
+ return (st.line ? st.tf * 1.4 : st.hf) * (st.mul ?? 1) * P.mouth.size;
44
+ }
45
+
46
+ /** and sideways: the mouth's half-width in units of `L.eyeR`. A maw on
47
+ * a narrow chin is the layout's problem in BOTH axes. */
48
+ export function mouthSpan(P) {
49
+ const st = STYLE[P.mouth.style];
50
+ if (!st) return 0;
51
+ return (st.wf ?? 1) * (st.mul ?? 1) * P.mouth.size;
52
+ }
53
+
54
+ export const Mouth = {
55
+ id: 'mouth', label: 'mouth', order: 5,
56
+
57
+ gen: (rng, C) => ({
58
+ style: C.pick(rng, 'style', [['open', 18], ['smile', 16], ['grin', 12], ['cat', 12],
59
+ ['flat', 10], ['holler', 9], ['zig', 7], ['frown', 6],
60
+ ['none', 10]]),
61
+ // close under the eyes: the face reads as one band of features,
62
+ // not a pair of eyes with a mouth adrift somewhere below them
63
+ y: C.range(rng, 'y', -.2, -.02), // a wish — the layout may push it down
64
+ size: C.range(rng, 'size', .5, .78), // × eye size, × the style's own mul
65
+ proud: C.range(rng, 'proud', .08, .2),
66
+ teeth: C.chance(rng, 'teeth', .6), // a maw's white strip
67
+ tongue: C.chance(rng, 'tongue', .45), // and its warm tongue
68
+ }),
69
+
70
+ meta: () => ({
71
+ style: { label: 'style', pick: MOUTH_STYLES },
72
+ y: { label: 'height', range: [-.8, .1] },
73
+ size: { label: 'size', range: [.4, 2] },
74
+ proud: { label: 'relief', range: [0, .5] },
75
+ teeth: { label: 'teeth', bool: true },
76
+ tongue: { label: 'tongue', bool: true },
77
+ }),
78
+
79
+ build(add, P, L) {
80
+ const M = P.mouth;
81
+ const st = STYLE[M.style];
82
+ if (!st) return;
83
+ const r = L.eyeR * M.size * (st.mul ?? 1) * L.mouthFit;
84
+ const a = L.at(0, L.mouthY);
85
+ const base = r * M.proud * (st.line ? 1 : .4);
86
+
87
+ if (st.line) {
88
+ add({ type: 'plate', id: 'mouth', outline: 'band',
89
+ curve: st.curve, sag: st.sag * r, tube: r * st.tf,
90
+ w: r * st.wf, h: r * st.tf,
91
+ p: a.p, n: a.n,
92
+ d: r * .22, bevel: r * st.tf * .5,
93
+ proud: base, color: L.ink });
94
+ // ONE white fang hanging off the line, point down and a little
95
+ // off-centre — the single tooth is a whole personality
96
+ if (M.teeth && M.tongue) {
97
+ add({ type: 'plate', id: 'mouthFang', outline: 'tri',
98
+ w: r * .16, h: r * .22,
99
+ p: a.p, n: a.n,
100
+ d: r * .18, bevel: r * .05,
101
+ proud: base + r * .05, offset: [r * st.wf * .38, -r * .2],
102
+ color: L.sclera });
103
+ }
104
+ return;
105
+ }
106
+
107
+ // ---- a maw ------------------------------------------------------
108
+ const w = r * st.wf, h = r * st.hf, d = r * .16;
109
+ const corner = Math.min(w, h) * .8;
110
+
111
+ // the outline: the drawn lip line around everything
112
+ add({ type: 'plate', id: 'mouth', outline: 'rect',
113
+ w, h, r: corner,
114
+ p: a.p, n: a.n, d, bevel: d * .5,
115
+ proud: base, color: L.ink });
116
+
117
+ // the interior, behind the lip line
118
+ add({ type: 'plate', id: 'mouthMaw', outline: 'rect',
119
+ w: w * .84, h: h * .8, r: corner * .8,
120
+ p: a.p, n: a.n, d: d * .8, bevel: d * .3,
121
+ proud: base + d * .5, color: st.allTeeth ? L.sclera : L.maw });
122
+
123
+ if (st.allTeeth) {
124
+ // the grin: the interior IS the teeth, split by ink bars
125
+ const bars = w > h * 1.8 ? 3 : 2;
126
+ for (let i = 0; i < bars; i++) {
127
+ const fx = (i + 1) / (bars + 1) * 2 - 1;
128
+ add({ type: 'plate', id: `mouthBar${i}`, outline: 'rect',
129
+ w: w * .035, h: h * .68, r: w * .03,
130
+ p: a.p, n: a.n, d: d * .5, bevel: d * .12,
131
+ proud: base + d * .95, offset: [fx * w * .8, 0],
132
+ color: L.ink });
133
+ }
134
+ // and one horizontal split, upper teeth from lower
135
+ add({ type: 'plate', id: 'mouthSplit', outline: 'rect',
136
+ w: w * .8, h: h * .05, r: h * .04,
137
+ p: a.p, n: a.n, d: d * .5, bevel: d * .12,
138
+ proud: base + d * .95, offset: [0, -h * .06],
139
+ color: L.ink });
140
+ return;
141
+ }
142
+
143
+ if (M.teeth) {
144
+ // Hanging FROM the top lip, and SPLIT. One white slab in a dark
145
+ // mouth reads as a sticking plaster however it is placed — what
146
+ // makes it teeth is the divisions, which is exactly why `grin`
147
+ // was the best-reading mouth on the sheet. Same trick here.
148
+ const tw = w * .62, th = h * .3, ty = h * .8 - th;
149
+ add({ type: 'plate', id: 'mouthTeeth', outline: 'rect',
150
+ w: tw, h: th, r: th * .16,
151
+ p: a.p, n: a.n, d: d * .6, bevel: d * .15,
152
+ proud: base + d * .95, offset: [0, ty],
153
+ color: L.sclera });
154
+ for (let i = 0; i < 3; i++) {
155
+ add({ type: 'plate', id: `mouthTooth${i}`, outline: 'rect',
156
+ w: tw * .035, h: th * .82, r: tw * .03,
157
+ p: a.p, n: a.n, d: d * .4, bevel: d * .1,
158
+ proud: base + d * 1.15, offset: [(i - 1) * tw * .46, ty],
159
+ color: L.ink });
160
+ }
161
+ }
162
+ if (M.tongue) {
163
+ add({ type: 'plate', id: 'mouthTongue', outline: 'ellipse',
164
+ w: w * .42, h: h * .42,
165
+ p: a.p, n: a.n, d: d * .8, bevel: d * .3,
166
+ proud: base + d * .8, offset: [0, -h * .52],
167
+ color: L.warm });
168
+ }
169
+ },
170
+ };
@@ -0,0 +1,63 @@
1
+ // THE NOSE. Small, central, and the one feature that is sometimes a
2
+ // real lump rather than a plate: a button nose wants to be a ball,
3
+ // because half of what sells it is the highlight sliding around a
4
+ // curve the flat features do not have.
5
+ const STYLE = {
6
+ none: null,
7
+ dot: { type: 'plate', outline: 'ellipse', wf: .3, hf: .3 },
8
+ button: { type: 'solid', wf: .42, hf: .42, sink: .45 },
9
+ snout: { type: 'plate', outline: 'rect', wf: .8, hf: .38 },
10
+ beak: { type: 'plate', outline: 'tri', wf: .46, hf: .42, flip: true },
11
+ cat: { type: 'plate', outline: 'tri', wf: .42, hf: .3, flip: true },
12
+ heart: { type: 'plate', outline: 'heart', wf: .38, hf: .34 },
13
+ };
14
+
15
+ export const NOSE_STYLES = Object.keys(STYLE);
16
+
17
+ export const Nose = {
18
+ id: 'nose', label: 'nose', order: 3,
19
+
20
+ // Most characters have NO nose. Two eyes and a mouth is the whole face in
21
+ // the reference, and a nose on every one of them turns a sheet of
22
+ // characters into a sheet of the same character.
23
+ gen: (rng, C) => ({
24
+ style: C.pick(rng, 'style', [['none', 91], ['dot', 3], ['button', 2], ['snout', 1],
25
+ ['cat', 1], ['beak', 1], ['heart', 1]]),
26
+ bias: C.range(rng, 'bias', -.04, .04), // off the midpoint the layout publishes
27
+ size: C.range(rng, 'size', .42, .66), // × eye size
28
+ warm: C.chance(rng, 'warm', .35), // in the palette's warm colour, not ink
29
+ }),
30
+
31
+ meta: () => ({
32
+ style: { label: 'style', pick: NOSE_STYLES },
33
+ bias: { label: 'height', range: [-.2, .2] },
34
+ size: { label: 'size', range: [.3, 1.8] },
35
+ warm: { label: 'warm tone', bool: true },
36
+ }),
37
+
38
+ build(add, P, L) {
39
+ const N = P.nose;
40
+ const st = STYLE[N.style];
41
+ if (!st) return;
42
+ // the layout's midpoint between eyes and mouth: a nose cannot
43
+ // collide with a mouth however either of them is dragged
44
+ const r = L.eyeR * N.size;
45
+ const a = L.at(0, L.noseY + N.bias);
46
+ const color = N.warm ? L.warm : L.ink;
47
+
48
+ const base = 0;
49
+
50
+ if (st.type === 'solid') {
51
+ // sunk most of the way in, so it reads as a bump on the face and
52
+ // not a bead balanced on it
53
+ add({ type: 'solid', id: 'nose', rx: r * st.wf, ry: r * st.hf, rz: r * st.wf,
54
+ p: a.p, n: a.n, proud: base - r * st.wf * st.sink, color });
55
+ return;
56
+ }
57
+ add({ type: 'plate', id: 'nose', outline: st.outline, flip: st.flip,
58
+ w: r * st.wf, h: r * st.hf,
59
+ p: a.p, n: a.n,
60
+ d: r * .3, bevel: Math.min(r * .18, r * st.hf * .45),
61
+ proud: base + r * .12, color });
62
+ },
63
+ };