@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.
- package/LICENSE +21 -0
- package/NOTICE.md +23 -0
- package/README.md +125 -0
- package/UPSTREAM.md +47 -0
- package/dist/avatar.d.ts +2 -0
- package/dist/avatar.js +69 -0
- package/dist/avatar.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/materials.d.ts +20 -0
- package/dist/materials.js +61 -0
- package/dist/materials.js.map +1 -0
- package/dist/react-three-fiber.d.ts +16 -0
- package/dist/react-three-fiber.js +52 -0
- package/dist/react-three-fiber.js.map +1 -0
- package/dist/recipe.d.ts +18 -0
- package/dist/recipe.js +118 -0
- package/dist/recipe.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +102 -0
- package/upstream-lock.json +38 -0
- package/vendor/kindergrimm/LICENSE +24 -0
- package/vendor/kindergrimm/src/gloss/catmullClark.js +129 -0
- package/vendor/kindergrimm/src/gloss/gface.d.ts +25 -0
- package/vendor/kindergrimm/src/gloss/gface.js +226 -0
- package/vendor/kindergrimm/src/gloss/gform.js +201 -0
- package/vendor/kindergrimm/src/gloss/ghair.js +534 -0
- package/vendor/kindergrimm/src/gloss/glayout.js +365 -0
- package/vendor/kindergrimm/src/gloss/gmedia.d.ts +28 -0
- package/vendor/kindergrimm/src/gloss/gmedia.js +470 -0
- package/vendor/kindergrimm/src/gloss/gpalette.d.ts +10 -0
- package/vendor/kindergrimm/src/gloss/gpalette.js +205 -0
- package/vendor/kindergrimm/src/gloss/gparts/blush.js +57 -0
- package/vendor/kindergrimm/src/gloss/gparts/body.js +47 -0
- package/vendor/kindergrimm/src/gloss/gparts/brows.js +55 -0
- package/vendor/kindergrimm/src/gloss/gparts/crest.js +88 -0
- package/vendor/kindergrimm/src/gloss/gparts/eyes.js +357 -0
- package/vendor/kindergrimm/src/gloss/gparts/frame.js +225 -0
- package/vendor/kindergrimm/src/gloss/gparts/hair.js +100 -0
- package/vendor/kindergrimm/src/gloss/gparts/hat.js +163 -0
- package/vendor/kindergrimm/src/gloss/gparts/index.js +33 -0
- package/vendor/kindergrimm/src/gloss/gparts/mark.js +139 -0
- package/vendor/kindergrimm/src/gloss/gparts/mouth.js +170 -0
- package/vendor/kindergrimm/src/gloss/gparts/nose.js +63 -0
- package/vendor/kindergrimm/src/gloss/gparts/specs.js +124 -0
- package/vendor/kindergrimm/src/gloss/grig.d.ts +41 -0
- package/vendor/kindergrimm/src/gloss/grig.js +286 -0
- package/vendor/kindergrimm/src/gloss/gshape.js +421 -0
- package/vendor/kindergrimm/src/gloss/gspecies.d.ts +10 -0
- package/vendor/kindergrimm/src/gloss/gspecies.js +338 -0
- package/vendor/kindergrimm/src/gloss/gtexture.js +406 -0
- package/vendor/kindergrimm/src/rng.d.ts +11 -0
- package/vendor/kindergrimm/src/rng.js +27 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ---------------------------------------------------------------
|
|
2
|
+
// THE PALETTES — twelve soft five-colour sets, from the reference
|
|
3
|
+
// sheet. A character picks a PALETTE, then one colour out of it for
|
|
4
|
+
// the body and a second for its warm bits (blush, tongue, an accent).
|
|
5
|
+
// Both come from the same five, which is what stops a sheet of twenty
|
|
6
|
+
// looking like twenty unrelated characters.
|
|
7
|
+
//
|
|
8
|
+
// These were sampled from the swatches by eye — the printed hex codes
|
|
9
|
+
// on the sheet are below legible resolution. Correcting one is editing
|
|
10
|
+
// one string, and nothing downstream reads a colour by name.
|
|
11
|
+
//
|
|
12
|
+
// `INK` is deliberately NOT in any palette. One warm near-black does
|
|
13
|
+
// every eye, brow and mouth in the lab, and that single shared value
|
|
14
|
+
// is most of the family resemblance — the same job the one graphite
|
|
15
|
+
// does for the drawn crowd.
|
|
16
|
+
// ---------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export const INK = '#2b2422';
|
|
19
|
+
|
|
20
|
+
// The white of an eye — warm and slightly off, never paper white. It
|
|
21
|
+
// sits outside the palettes for the same reason INK does: one value
|
|
22
|
+
// across the whole lab is what makes twelve palettes look like one
|
|
23
|
+
// cast rather than twelve unrelated ones.
|
|
24
|
+
export const SCLERA = '#f6f2e9';
|
|
25
|
+
|
|
26
|
+
// Inside an open mouth. Dark warm maroon, shared like INK: every open
|
|
27
|
+
// maw on the sheet is the same pour, which is most of what makes a
|
|
28
|
+
// mouth read as an interior rather than as a dark sticker.
|
|
29
|
+
export const MAW = '#5b2530';
|
|
30
|
+
|
|
31
|
+
/** a colour lifted toward white — the muzzle-patch pour. This is a
|
|
32
|
+
* LIGHTER PART, a second colour of vinyl like a panda's white face,
|
|
33
|
+
* not a highlight; the no-painted-shadows rule is about darkness. */
|
|
34
|
+
export function tint(hex, k = .5) {
|
|
35
|
+
const n = parseInt(hex.slice(1), 16);
|
|
36
|
+
const c = [n >> 16 & 255, n >> 8 & 255, n & 255]
|
|
37
|
+
.map(v => Math.round(v + (255 - v) * k));
|
|
38
|
+
return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PALETTES = [
|
|
42
|
+
{ id: 'dusk', label: 'dusk', colors: ['#CFC6D9', '#7B7BB0', '#F5C48A', '#F7E6C4', '#D9A5A5'] },
|
|
43
|
+
{ id: 'meadow', label: 'meadow', colors: ['#F2E08D', '#9FCFE1', '#E7E3A2', '#F4B8C4', '#F6C9CE'] },
|
|
44
|
+
{ id: 'harbour', label: 'harbour', colors: ['#3D77A6', '#7FA6BE', '#F2E7A0', '#F7D9B0', '#F2704A'] },
|
|
45
|
+
{ id: 'denim', label: 'denim', colors: ['#4A7BA6', '#F2A6A0', '#F7D5D5', '#A67BA6', '#F0EDE6'] },
|
|
46
|
+
{ id: 'mist', label: 'mist', colors: ['#C6CFE8', '#CFC6E8', '#F2D0DE', '#F7DCC0', '#F7C6CE'] },
|
|
47
|
+
{ id: 'bloom', label: 'bloom', colors: ['#F4AFC5', '#F2D0EA', '#E0AFD9', '#B8D4F0', '#B0B9E8'] },
|
|
48
|
+
{ id: 'orchard', label: 'orchard', colors: ['#6B5C7B', '#F5F0E1', '#F2E88A', '#C6DE8A', '#F2A03C'] },
|
|
49
|
+
{ id: 'lagoon', label: 'lagoon', colors: ['#3AB8A0', '#A8D6C0', '#BDD9A0', '#D9E8A8', '#A8DEC8'] },
|
|
50
|
+
{ id: 'melon', label: 'melon', colors: ['#A8D6C8', '#F2B8A0', '#F2A08A', '#F2C6C6', '#E85A3C'] },
|
|
51
|
+
{ id: 'ember', label: 'ember', colors: ['#F2C6D0', '#F2A8A0', '#F29078', '#F2A03C', '#E87830'] },
|
|
52
|
+
{ id: 'moss', label: 'moss', colors: ['#A8D6C0', '#BDE0B0', '#D2E8A8', '#EDE8A0', '#E8C84A'] },
|
|
53
|
+
{ id: 'apricot', label: 'apricot', colors: ['#F7E8D9', '#F7C6A8', '#F2A88A', '#F2907A', '#E8705A'] },
|
|
54
|
+
|
|
55
|
+
// SKIN — the humanoid's, and the only palette that is not a colour
|
|
56
|
+
// scheme but a RANGE of one thing. Four skin tones pale to deep,
|
|
57
|
+
// and a rosy fifth that `colorsFor` will always score as the warm
|
|
58
|
+
// one, so the blush is a flush of the same face rather than a
|
|
59
|
+
// sticker in an unrelated colour. Kept out of the random deal (see
|
|
60
|
+
// `PALETTE_DEAL_IDS`): a skin-toned bear is just a beige bear.
|
|
61
|
+
{ id: 'skin', label: 'skin', colors: ['#F7DFCE', '#EFC4A6', '#D79E76', '#A9704B', '#F0A99A'] },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export const PALETTE_IDS = PALETTES.map(p => p.id);
|
|
65
|
+
// what an un-opinionated character may be poured in. `skin` is asked
|
|
66
|
+
// for by name or pinned by hand, never dealt.
|
|
67
|
+
export const PALETTE_DEAL_IDS = PALETTE_IDS.filter(id => id !== 'skin');
|
|
68
|
+
export const PALETTE_BY_ID = Object.fromEntries(PALETTES.map(p => [p.id, p]));
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------
|
|
71
|
+
// HAIR. Its own table, and that is the point: hair is never the body's
|
|
72
|
+
// colour. A character's five-colour set is what makes a sheet look like
|
|
73
|
+
// one cast; a HEAD's hair is what tells two of them apart, so it is
|
|
74
|
+
// dealt from here instead, exactly as `INK` sits outside the palettes.
|
|
75
|
+
//
|
|
76
|
+
// Weighted, because frequency is art direction here too. The naturals
|
|
77
|
+
// carry the line — a sheet where a third of the heads are mint is a
|
|
78
|
+
// novelty aisle, not a cast of characters — and the dyed half is the
|
|
79
|
+
// treat. `ink` leads: black hair is the commonest hair there is, and it
|
|
80
|
+
// is the one value already doing every brow and eye in the lab.
|
|
81
|
+
// ---------------------------------------------------------------
|
|
82
|
+
export const HAIR_COLORS = [
|
|
83
|
+
{ id: 'ink', label: 'black', hex: '#2B2422', w: 15 },
|
|
84
|
+
{ id: 'espresso', label: 'espresso', hex: '#3B2820', w: 11 },
|
|
85
|
+
{ id: 'chocolate',label: 'chocolate',hex: '#4E3527', w: 10 },
|
|
86
|
+
{ id: 'chestnut', label: 'chestnut', hex: '#6B4430', w: 10 },
|
|
87
|
+
{ id: 'caramel', label: 'caramel', hex: '#9A6C3E', w: 8 },
|
|
88
|
+
{ id: 'honey', label: 'honey', hex: '#C0913F', w: 6 },
|
|
89
|
+
{ id: 'blonde', label: 'blonde', hex: '#DFC072', w: 6 },
|
|
90
|
+
{ id: 'platinum', label: 'platinum', hex: '#E4D8BC', w: 4 },
|
|
91
|
+
{ id: 'ginger', label: 'ginger', hex: '#BC5A2C', w: 5 },
|
|
92
|
+
{ id: 'ash', label: 'ash', hex: '#7E776C', w: 4 },
|
|
93
|
+
{ id: 'silver', label: 'silver', hex: '#C3BFB6', w: 4 },
|
|
94
|
+
// the dyed half — a treat, never the rule
|
|
95
|
+
{ id: 'rose', label: 'rose', hex: '#DE8397', w: 4 },
|
|
96
|
+
{ id: 'lilac', label: 'lilac', hex: '#A08FC4', w: 4 },
|
|
97
|
+
{ id: 'mint', label: 'mint', hex: '#84C1A6', w: 3 },
|
|
98
|
+
{ id: 'sky', label: 'sky', hex: '#7BA2D2', w: 3 },
|
|
99
|
+
{ id: 'plum', label: 'plum', hex: '#65456E', w: 3 },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export const HAIR_IDS = HAIR_COLORS.map(c => c.id);
|
|
103
|
+
export const HAIR_BY_ID = Object.fromEntries(HAIR_COLORS.map(c => [c.id, c]));
|
|
104
|
+
export const HAIR_WEIGHTS = HAIR_COLORS.filter(c => c.w > 0).map(c => [c.id, c.w]);
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------
|
|
107
|
+
// ACCESSORIES. Their own table, for exactly the reason hair has one: a
|
|
108
|
+
// hat, a frame or a plaster has to be VISIBLE, and it cannot be if it
|
|
109
|
+
// is drawn from the character's own five. On a humanoid that is fatal —
|
|
110
|
+
// every colour in the `skin` palette IS a skin tone, so the first
|
|
111
|
+
// beanie came out as a bald head.
|
|
112
|
+
//
|
|
113
|
+
// These are the colours real accessories actually use: strong,
|
|
114
|
+
// slightly dirty, and few. `pickAcc` then guarantees the one chosen
|
|
115
|
+
// stands clear of BOTH the body and the hair, so a red hat never lands
|
|
116
|
+
// on a red character and a black one never lands on black hair.
|
|
117
|
+
// ---------------------------------------------------------------
|
|
118
|
+
// No terracotta and no tan. They were in, and against a skin body they
|
|
119
|
+
// scored well on luma while being the same HUE — a brick beanie on a
|
|
120
|
+
// peach head lit by a warm key came out as another shade of face. Luma
|
|
121
|
+
// is not enough; see `pickAcc`.
|
|
122
|
+
export const ACC_COLORS = [
|
|
123
|
+
'#2B2422', '#F0EAE0', '#C6483E', '#D8C049',
|
|
124
|
+
'#5E9E86', '#3F7FA6', '#6E4E8C', '#8A8E93', '#B8375C', '#3E5C4A',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/** the accessory colour furthest in luma from both the character and its
|
|
128
|
+
* hair, chosen deterministically from a rolled index so the same
|
|
129
|
+
* recipe always gets the same one. */
|
|
130
|
+
export function pickAcc(i, body, hair) {
|
|
131
|
+
// FULL RGB distance, not luma. Scored on lightness alone a terracotta
|
|
132
|
+
// beats a teal against peach skin — and then renders as another shade
|
|
133
|
+
// of face, because it is the same hue and the key light is warm.
|
|
134
|
+
// Distance in colour, not in brightness.
|
|
135
|
+
const rgb = hex => { const n = parseInt(hex.slice(1), 16);
|
|
136
|
+
return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]; };
|
|
137
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
138
|
+
const B = rgb(body), H = rgb(hair);
|
|
139
|
+
let best = ACC_COLORS[0], score = -1;
|
|
140
|
+
for (let k = 0; k < ACC_COLORS.length; k++) {
|
|
141
|
+
const c = ACC_COLORS[(k + i) % ACC_COLORS.length];
|
|
142
|
+
const C = rgb(c);
|
|
143
|
+
// the WORSE of its two clearances decides — an accessory has to
|
|
144
|
+
// stand off the skin and off the hair, not average well
|
|
145
|
+
const d = Math.min(dist(C, B), dist(C, H));
|
|
146
|
+
// the rolled index breaks ties, so a sheet still varies instead of
|
|
147
|
+
// everyone converging on the one safest colour
|
|
148
|
+
const sc = d + (k === 0 ? .05 : 0);
|
|
149
|
+
if (sc > score) { score = sc; best = c; }
|
|
150
|
+
}
|
|
151
|
+
return best;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The OUTFIT: cloth for the torso, gloves, shoes — three picks from
|
|
156
|
+
* `ACC_COLORS`, chosen greedily so each stands clear of the skin, the
|
|
157
|
+
* hair AND the pieces already picked. Same scoring as `pickAcc` (full
|
|
158
|
+
* RGB distance, worst clearance decides) and deterministic from the
|
|
159
|
+
* rolled index, so a recipe always dresses the same way. Gloves and
|
|
160
|
+
* shoes only have to clear the CLOTH and the skin — a glove matching
|
|
161
|
+
* a shoe is a set, not a clash.
|
|
162
|
+
*/
|
|
163
|
+
export function pickOutfit(i, body, hair) {
|
|
164
|
+
const rgb = hex => { const n = parseInt(hex.slice(1), 16);
|
|
165
|
+
return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]; };
|
|
166
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
167
|
+
const pickVs = (ix, against) => {
|
|
168
|
+
let best = ACC_COLORS[0], score = -1;
|
|
169
|
+
for (let k = 0; k < ACC_COLORS.length; k++) {
|
|
170
|
+
const c = ACC_COLORS[(k + ix) % ACC_COLORS.length];
|
|
171
|
+
const C = rgb(c);
|
|
172
|
+
const d = Math.min(...against.map(a => dist(C, rgb(a))));
|
|
173
|
+
const sc = d + (k === 0 ? .05 : 0);
|
|
174
|
+
if (sc > score) { score = sc; best = c; }
|
|
175
|
+
}
|
|
176
|
+
return best;
|
|
177
|
+
};
|
|
178
|
+
const cloth = pickVs(i, [body, hair]);
|
|
179
|
+
const glove = pickVs(i + 3, [body, cloth]);
|
|
180
|
+
const shoe = pickVs(i + 6, [body, cloth]);
|
|
181
|
+
return { cloth, glove, shoe };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** a → b by t, in hex. For the brow, which is the hair's own colour
|
|
185
|
+
* pulled toward ink: a platinum brow at full strength disappears, and
|
|
186
|
+
* an ink one on blonde hair reads as somebody else's eyebrows. */
|
|
187
|
+
export function mix(a, b, t) {
|
|
188
|
+
const A = parseInt(a.slice(1), 16), B = parseInt(b.slice(1), 16);
|
|
189
|
+
const c = [16, 8, 0].map(s =>
|
|
190
|
+
Math.round(((A >> s) & 255) + (((B >> s) & 255) - ((A >> s) & 255)) * t));
|
|
191
|
+
return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** how dark a colour reads, 0..1 — used to keep a feature off a body
|
|
195
|
+
* it would vanish against, never to invent a colour. */
|
|
196
|
+
export function luma(hex) {
|
|
197
|
+
const n = parseInt(hex.slice(1), 16);
|
|
198
|
+
return (.2126 * (n >> 16 & 255) + .7152 * (n >> 8 & 255) + .0722 * (n & 255)) / 255;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// There was a `shade()` here, darkening the body colour for a ring
|
|
202
|
+
// behind each eye. It is gone with the rim: on palettes this pale a
|
|
203
|
+
// darkened body tone does not read as shadow, it reads as a second
|
|
204
|
+
// colour. A shadow on this lab comes from geometry and the studio,
|
|
205
|
+
// never from a colour picked to look like one.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// THE BLUSH. Two warm patches out on the cheeks, in a colour taken
|
|
2
|
+
// from the character's own palette rather than a pink invented for the job —
|
|
3
|
+
// which is what keeps a sheet of twenty looking like one cast.
|
|
4
|
+
//
|
|
5
|
+
// It sits barely proud: a cheek is the one feature that should read as
|
|
6
|
+
// painted on rather than moulded in.
|
|
7
|
+
const STYLE = {
|
|
8
|
+
none: null,
|
|
9
|
+
oval: { outline: 'ellipse', wf: 1, hf: .62, n: 1 },
|
|
10
|
+
round: { outline: 'ellipse', wf: .8, hf: .8, n: 1 },
|
|
11
|
+
stripes:{ outline: 'rect', wf: .8, hf: .1, n: 3 },
|
|
12
|
+
heart: { outline: 'heart', wf: .62, hf: .58, n: 1 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const BLUSH_STYLES = Object.keys(STYLE);
|
|
16
|
+
|
|
17
|
+
export const Blush = {
|
|
18
|
+
id: 'blush', label: 'blush', order: 5,
|
|
19
|
+
|
|
20
|
+
gen: (rng, C) => ({
|
|
21
|
+
style: C.pick(rng, 'style', [['none', 40], ['oval', 20], ['round', 16], ['stripes', 12], ['heart', 12]]),
|
|
22
|
+
out: C.range(rng, 'out', .24, .4), // further out than the eyes
|
|
23
|
+
drop: C.range(rng, 'drop', .2, .38), // and below them
|
|
24
|
+
size: C.range(rng, 'size', .8, 1.25), // × eye size
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
meta: () => ({
|
|
28
|
+
style: { label: 'style', pick: BLUSH_STYLES },
|
|
29
|
+
out: { label: 'outward', range: [0, .8] },
|
|
30
|
+
drop: { label: 'drop', range: [0, .8] },
|
|
31
|
+
size: { label: 'size', range: [.4, 2] },
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
build(add, P, L) {
|
|
35
|
+
const B = P.blush;
|
|
36
|
+
const st = STYLE[B.style];
|
|
37
|
+
if (!st) return;
|
|
38
|
+
const r = L.eyeR * B.size;
|
|
39
|
+
|
|
40
|
+
for (const [id, side] of [['blushL', -1], ['blushR', 1]]) {
|
|
41
|
+
// onFace, not at: a cheek is placed outboard of the eye on
|
|
42
|
+
// purpose, which walks it straight at the silhouette
|
|
43
|
+
const a = L.onFace(side * (L.eyeX + B.out), L.eyeY - B.drop,
|
|
44
|
+
{ halfW: r * st.wf });
|
|
45
|
+
for (let i = 0; i < st.n; i++) {
|
|
46
|
+
// a stripe set is one shape repeated up the cheek
|
|
47
|
+
const dy = st.n === 1 ? 0 : (i - (st.n - 1) / 2) * r * .34;
|
|
48
|
+
add({ type: 'plate', id: st.n === 1 ? id : `${id}${i}`, outline: st.outline,
|
|
49
|
+
w: r * st.wf * (st.n > 1 ? 1 - Math.abs(i - (st.n - 1) / 2) * .22 : 1),
|
|
50
|
+
h: r * st.hf,
|
|
51
|
+
p: a.p, n: a.n, roll: side * .12, offset: [0, dy],
|
|
52
|
+
d: r * .16, bevel: Math.min(r * .09, r * st.hf * .45),
|
|
53
|
+
proud: r * .04, color: L.warm });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// THE BODY. Four forms and two techniques: a sphere and a cube are one
|
|
2
|
+
// superellipsoid with a squareness knob, and a ROCK and a SLIME are
|
|
3
|
+
// MODELED — a low-poly control cage through Catmull-Clark (`gform.js`).
|
|
4
|
+
// The face catalogue never knows which: features place through `L.at`,
|
|
5
|
+
// which is the only thing a form has to provide.
|
|
6
|
+
export const Body = {
|
|
7
|
+
id: 'body', label: 'body', order: 0,
|
|
8
|
+
|
|
9
|
+
// The sheet normalises every character to one HEIGHT, so `r` is nearly
|
|
10
|
+
// invisible and the ratios are everything: squat-and-wide against
|
|
11
|
+
// tall-and-narrow is the only silhouette variety a sphere has. These
|
|
12
|
+
// were ±6% and every character came out the same circle.
|
|
13
|
+
gen: (rng, C) => ({
|
|
14
|
+
r: C.range(rng, 'r', .46, .54),
|
|
15
|
+
// A head is square or WIDER, never taller than it is wide. A tall
|
|
16
|
+
// narrow head reads as a face squeezed in a vice; the whole family
|
|
17
|
+
// sits between a square and a squat oval.
|
|
18
|
+
wide: C.range(rng, 'wide', 1, 1.3),
|
|
19
|
+
tall: C.range(rng, 'tall', .84, 1),
|
|
20
|
+
deep: C.range(rng, 'deep', .86, 1.06),
|
|
21
|
+
// how hard the corners are squared off. Only a `cube` reads it —
|
|
22
|
+
// a sphere is pinned at 2 so it stays an exact ball. 2.8 is a
|
|
23
|
+
// pillow, 5.5 is a proper block with the edges softened.
|
|
24
|
+
corner: C.range(rng, 'corner', 2.8, 5.5),
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
meta: () => ({
|
|
28
|
+
r: { label: 'size', range: [.3, .8] },
|
|
29
|
+
wide: { label: 'width', range: [.7, 1.4] },
|
|
30
|
+
tall: { label: 'height', range: [.7, 1.4] },
|
|
31
|
+
deep: { label: 'depth', range: [.7, 1.2] },
|
|
32
|
+
corner: { label: 'squareness', range: [2, 8] },
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
build(add, P, L) {
|
|
36
|
+
// a modeled form arrives as the SAME arrays the layout raycast the
|
|
37
|
+
// face against — one surface, shared, never rebuilt
|
|
38
|
+
if (L.formMesh) {
|
|
39
|
+
add({ type: 'mesh', id: 'body', mesh: L.formMesh,
|
|
40
|
+
pos: [0, L.cy, 0], color: L.body });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
add({ type: 'solid', id: 'body',
|
|
44
|
+
rx: L.rx, ry: L.ry, rz: L.rz, exp: L.exp,
|
|
45
|
+
pos: [0, L.cy, 0], color: L.body });
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// THE BROWS. Two short bands above the eyes — and almost all of the
|
|
2
|
+
// expression in the whole lab lives in their TILT. A brow rolled down
|
|
3
|
+
// toward the nose is angry, rolled up is worried, and it is the same
|
|
4
|
+
// geometry both times. That is why `roll` is in the plate basis rather
|
|
5
|
+
// than baked into an outline.
|
|
6
|
+
const STYLE = {
|
|
7
|
+
none: null,
|
|
8
|
+
flat: { curve: 'line', sag: 0, wf: .9, tf: .13, roll: 0 },
|
|
9
|
+
angry: { curve: 'line', sag: 0, wf: .85, tf: .15, roll: -.42 },
|
|
10
|
+
sad: { curve: 'line', sag: 0, wf: .85, tf: .13, roll: .38 },
|
|
11
|
+
round: { curve: 'arc', sag: -.3, wf: .9, tf: .12, roll: 0 },
|
|
12
|
+
thick: { curve: 'arc', sag: -.2, wf: .95, tf: .24, roll: 0 },
|
|
13
|
+
worry: { curve: 'arc', sag: .28, wf: .8, tf: .12, roll: .16 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const BROW_STYLES = Object.keys(STYLE);
|
|
17
|
+
|
|
18
|
+
export const Brows = {
|
|
19
|
+
id: 'brows', label: 'brows', order: 2,
|
|
20
|
+
|
|
21
|
+
// ANECDOTAL. A brow changes a face completely, which is exactly why
|
|
22
|
+
// almost nobody gets one — on a sheet where every character has them they
|
|
23
|
+
// stop being expression and become furniture.
|
|
24
|
+
gen: (rng, C) => ({
|
|
25
|
+
style: C.pick(rng, 'style', [['none', 88], ['flat', 3], ['angry', 3], ['round', 2],
|
|
26
|
+
['sad', 2], ['thick', 1], ['worry', 1]]),
|
|
27
|
+
lift: C.range(rng, 'lift', .22, .42), // above the eye, in face coordinates
|
|
28
|
+
size: C.range(rng, 'size', .9, 1.25), // × eye size
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
meta: () => ({
|
|
32
|
+
style: { label: 'style', pick: BROW_STYLES },
|
|
33
|
+
lift: { label: 'lift', range: [.05, .7] },
|
|
34
|
+
size: { label: 'size', range: [.5, 1.8] },
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
build(add, P, L) {
|
|
38
|
+
const st = STYLE[P.brows.style];
|
|
39
|
+
if (!st) return;
|
|
40
|
+
const r = L.eyeR * P.brows.size;
|
|
41
|
+
|
|
42
|
+
for (const [id, side] of [['browL', -1], ['browR', 1]]) {
|
|
43
|
+
const a = L.onFace(side * L.eyeX, L.eyeY + P.brows.lift,
|
|
44
|
+
{ halfW: r * st.wf });
|
|
45
|
+
add({ type: 'plate', id, outline: 'band',
|
|
46
|
+
curve: st.curve, sag: st.sag * r, tube: r * st.tf,
|
|
47
|
+
w: r * st.wf, h: r * st.tf,
|
|
48
|
+
p: a.p, n: a.n, roll: st.roll * side,
|
|
49
|
+
d: r * .22, bevel: r * st.tf * .5,
|
|
50
|
+
// the head's own hair, pulled toward ink by the layout —
|
|
51
|
+
// ink brows under blonde hair read as somebody else's
|
|
52
|
+
proud: r * .1, color: L.browColor ?? L.ink });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// THE CREST — ears, horns, and whatever else breaks the top of the
|
|
2
|
+
// head. This is the other half of why the reference reads as a sheet
|
|
3
|
+
// of CREATURES: a cat, a bear and a bunny are the same ball with the
|
|
4
|
+
// same face, told apart entirely by what sticks up. The silhouette is
|
|
5
|
+
// doing the species work.
|
|
6
|
+
//
|
|
7
|
+
// Ears are PLATES, like the face: flat shapes with a bevelled rim,
|
|
8
|
+
// facing the camera. That is not a cheat — the reference characters' ears
|
|
9
|
+
// really are this flat — and it means an inner-ear is just a smaller
|
|
10
|
+
// warm plate on top, the way an eye's pupil is. They stand on
|
|
11
|
+
// `L.top(t)`, the layout's rim-of-the-head anchor, tilted a little
|
|
12
|
+
// toward the surface normal so a pair fans outward.
|
|
13
|
+
const STYLE = {
|
|
14
|
+
none: null,
|
|
15
|
+
cat: { outline: 'tri', wf: .82, hf: 1.05, inner: .5, lean: .2 },
|
|
16
|
+
bear: { outline: 'ellipse', wf: .76, hf: .76, inner: .55, lean: .12 },
|
|
17
|
+
bunny: { outline: 'rect', wf: .42, hf: 1.2, r: 1, inner: .55, lean: .16, tall: true },
|
|
18
|
+
horns: { outline: 'tri', wf: .4, hf: .78, inner: 0, lean: .55, tone: 'warm' },
|
|
19
|
+
stubs: { outline: 'ellipse', wf: .36, hf: .52, inner: 0, lean: .3, tone: 'warm' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Three hair styles lived here — `tuft`, `mop`, `curl`, ink plates
|
|
23
|
+
// standing on the crown. They were a placeholder for a real haircut and
|
|
24
|
+
// they are gone: hair is `hair.js` now, a lofted shell over the skull
|
|
25
|
+
// with a hem, a colour table and a finish of its own. The crest is back
|
|
26
|
+
// to what it is good at, which is ears and horns.
|
|
27
|
+
|
|
28
|
+
export const CREST_STYLES = Object.keys(STYLE);
|
|
29
|
+
|
|
30
|
+
export const Crest = {
|
|
31
|
+
id: 'crest', label: 'ears', order: 2,
|
|
32
|
+
|
|
33
|
+
gen: (rng, C) => ({
|
|
34
|
+
style: C.pick(rng, 'style', [['none', 26], ['bear', 20], ['cat', 18], ['bunny', 14],
|
|
35
|
+
['horns', 12], ['stubs', 10]]),
|
|
36
|
+
spread: C.range(rng, 'spread', .45, .78), // out along the crown, ±1 is the side
|
|
37
|
+
size: C.range(rng, 'size', .7, .95), // × the head's own radius
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
meta: () => ({
|
|
41
|
+
style: { label: 'style', pick: CREST_STYLES },
|
|
42
|
+
spread: { label: 'apart', range: [.15, 1] },
|
|
43
|
+
size: { label: 'size', range: [.4, 1.6] },
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
build(add, P, L) {
|
|
47
|
+
const C = P.crest;
|
|
48
|
+
const st = STYLE[C.style];
|
|
49
|
+
if (!st) return;
|
|
50
|
+
const u = Math.min(L.rx, L.ry) * .5 * C.size;
|
|
51
|
+
const w = u * st.wf, h = u * st.hf * (st.tall ? 1 : 1);
|
|
52
|
+
const d = u * .3;
|
|
53
|
+
|
|
54
|
+
for (const [id, side] of [['earL', -1], ['earR', 1]]) {
|
|
55
|
+
const a = L.top(side * C.spread);
|
|
56
|
+
// fanned: mostly facing the camera, leaning into the head's own
|
|
57
|
+
// normal so a pair splays the way stuck-on ears do
|
|
58
|
+
const n = [a.n[0] * .45, a.n[1] * .45, 1];
|
|
59
|
+
// MIRRORED: the pair leans the other way round, so each ear
|
|
60
|
+
// carries the tilt its opposite used to. `side` is −1 left,
|
|
61
|
+
// +1 right, and negating it is the whole flip.
|
|
62
|
+
const roll = -side * st.lean;
|
|
63
|
+
// the base is buried: the plate is shifted up its own v so its
|
|
64
|
+
// bottom third is inside the head and it can never hover
|
|
65
|
+
const lift = h * .62;
|
|
66
|
+
|
|
67
|
+
add({ type: 'plate', id, outline: st.outline,
|
|
68
|
+
w, h, r: st.r == null ? undefined : Math.min(w, h) * st.r,
|
|
69
|
+
flip: true,
|
|
70
|
+
p: a.p, n, roll, d, bevel: Math.min(d * .5, w * .4),
|
|
71
|
+
offset: [0, lift],
|
|
72
|
+
color: st.tone === 'warm' ? L.warm : L.body });
|
|
73
|
+
|
|
74
|
+
if (st.inner) {
|
|
75
|
+
// centred in the VISIBLE ear, not on the plate's own middle —
|
|
76
|
+
// the outer plate's lower third is buried in the head, so an
|
|
77
|
+
// inner pad sharing its centre sits at the base of the ear
|
|
78
|
+
add({ type: 'plate', id: id + 'Inner', outline: st.outline,
|
|
79
|
+
w: w * st.inner, h: h * st.inner,
|
|
80
|
+
r: st.r == null ? undefined : Math.min(w, h) * st.inner * st.r,
|
|
81
|
+
flip: true,
|
|
82
|
+
p: a.p, n, roll, d: d * .5, bevel: d * .2,
|
|
83
|
+
offset: [0, lift + h * (1 - st.inner) * .4], proud: d * .7,
|
|
84
|
+
color: L.warm });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|