@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,124 @@
1
+ // SPECTACLES — the first of the EXTRAS, and the ones that change a face
2
+ // most for the least geometry: two frames, a bridge, and the character is
3
+ // suddenly somebody in particular.
4
+ //
5
+ // Every style is stacked plates, the same trick as a pupil on a white:
6
+ // a FRAME (a ring outline, so it is a frame rather than a disc) and,
7
+ // where the style wants one, a LENS filled behind it. The catalogue is
8
+ // really two questions — round or square, and see-through or not.
9
+ //
10
+ // WHERE THEY SIT is the whole difficulty, and it is not a constant. An
11
+ // `orb` eye is a ball standing three quarters of its own radius off the
12
+ // head; a plate eye is almost flush. So the eyes publish `eyeProud` and
13
+ // the layout hands it over — spectacles placed for a flat eye cut
14
+ // straight through an eyeball, which is exactly what happened.
15
+ const STYLE = {
16
+ none: null,
17
+ // wire rounds. Thin, dark, the reading-glasses read
18
+ round: { outline: 'ring', wf: 1.28, hf: 1.28, thick: .16 },
19
+ // heavy squares — the ones you actually notice at sheet scale
20
+ square: { outline: 'rring', wf: 1.34, hf: 1.14, thick: .22, r: .34 },
21
+ // filled lenses in ink: shades. No frame of its own, because a dark
22
+ // lens IS its own silhouette and a frame round it just goes muddy
23
+ sunnies: { outline: 'rring', wf: 1.42, hf: 1.02, thick: .16, r: .3, lens: 'ink' },
24
+ // a pale lens behind a dark frame: the goggle read, and the one
25
+ // style where the eye still shows THROUGH something
26
+ goggles: { outline: 'ring', wf: 1.5, hf: 1.5, thick: .26, lens: 'lite' },
27
+ // ONE lens, on one side, and the asymmetry is the joke
28
+ monocle: { outline: 'ring', wf: 1.3, hf: 1.3, thick: .18, single: true },
29
+ };
30
+
31
+ export const SPECS_STYLES = Object.keys(STYLE);
32
+
33
+ export const Specs = {
34
+ // AFTER the eyes and the brows: it reads their sizes off the layout,
35
+ // and it has to sit in front of whatever they built.
36
+ id: 'specs', label: 'specs', order: 4,
37
+
38
+ // Anecdotal, like the brows and for the same reason: glasses on every
39
+ // character stop being a character and become the house style.
40
+ gen: (rng, C) => ({
41
+ style: C.pick(rng, 'style', [['none', 88], ['round', 4], ['square', 3],
42
+ ['sunnies', 2], ['goggles', 2], ['monocle', 1]]),
43
+ size: C.range(rng, 'size', .92, 1.18), // × the eye's own size
44
+ ink: C.chance(rng, 'ink', .72), // frame in ink, or in the warm
45
+ side: rng.chance(.5) ? 1 : -1, // which eye a monocle takes
46
+ }),
47
+
48
+ meta: () => ({
49
+ style: { label: 'style', pick: SPECS_STYLES },
50
+ size: { label: 'size', range: [.6, 1.6] },
51
+ ink: { label: 'dark frame', bool: true },
52
+ }),
53
+
54
+ build(add, P, L) {
55
+ const S = P.specs;
56
+ const st = STYLE[S.style];
57
+ if (!st) return;
58
+ const r = L.eyeSize * S.size;
59
+ // ink, or the accessory colour — never the character's own warm, which
60
+ // on a humanoid is another skin tone and vanishes
61
+ const frame = S.ink ? L.ink : L.acc;
62
+ // clear of whatever the eyes built, plus air. `eyeProud` is in eye
63
+ // units, so it scales with them.
64
+ const lift = L.eyeSize * (L.eyeProud + .16);
65
+ // spectacles are plastic, not whatever the character is poured in
66
+ const fin = 'glossy';
67
+ const w = r * st.wf, h = r * st.hf;
68
+ const d = r * .18;
69
+
70
+ for (const [id, side] of [['specL', -1], ['specR', 1]]) {
71
+ if (st.single && side !== S.side) continue;
72
+ // `onFace`, not `at`: a lens is wider than the eye under it, so
73
+ // it is the one that walks at the silhouette
74
+ const a = L.onFace(side * L.eyeX, L.eyeY, { halfW: w });
75
+
76
+ if (st.lens) {
77
+ // behind the frame, and a hair smaller, so the frame's inner
78
+ // edge always overlaps it and no seam of skin shows between
79
+ add({ type: 'plate', id: id + 'Lens', outline: st.outline === 'ring' ? 'ellipse' : 'rect',
80
+ w: w * .94, h: h * .94, r: st.r == null ? undefined : Math.min(w, h) * st.r,
81
+ p: a.p, n: a.n, d: d * .5, bevel: d * .2,
82
+ proud: lift - d * .3,
83
+ color: st.lens === 'ink' ? L.ink : L.lite , finish: fin });
84
+ }
85
+ add({ type: 'plate', id, outline: st.outline,
86
+ w, h, thick: st.thick,
87
+ r: st.r == null ? undefined : Math.min(w, h) * st.r,
88
+ p: a.p, n: a.n, d, bevel: Math.min(d * .5, Math.min(w, h) * st.thick * .6),
89
+ proud: lift, color: frame , finish: fin });
90
+ }
91
+
92
+ // THE BRIDGE — a short bar between the two, on the midline. A pair
93
+ // of lenses with nothing joining them reads as two stickers.
94
+ if (!st.single) {
95
+ const b = L.at(0, L.eyeY);
96
+ // the lens centre's WORLD x, from the same call that placed the
97
+ // frames — `eyeX` is a face coordinate and `w` a world length,
98
+ // and multiplying them gave a bar that reached neither lens
99
+ const xw = Math.abs(L.onFace(L.eyeX, L.eyeY, { halfW: w }).p[0]);
100
+ // run out past each frame's inner edge, most of the way through
101
+ // its rim: the bevel eats a hair off the band's ends, and a bar
102
+ // that only kisses the rim's midline can still fall short of it
103
+ const span = xw - w * (1 - st.thick * .75);
104
+ if (span > 0)
105
+ add({ type: 'plate', id: 'specBridge', outline: 'band', curve: 'line',
106
+ w: span, h: r * .09, tube: r * .09,
107
+ p: b.p, n: b.n, d: d * .8, bevel: d * .3,
108
+ proud: lift - d * .1, color: frame , finish: fin });
109
+ }
110
+
111
+ // and a STRAP for the goggles: one band round the head, which is
112
+ // the only thing that says these are worn rather than held
113
+ if (st.strap) {
114
+ for (const side of [-1, 1]) {
115
+ const a = L.onFace(side * (L.eyeX + .62), L.eyeY, { facing: .12 });
116
+ add({ type: 'plate', id: 'specStrap' + (side < 0 ? 'L' : 'R'),
117
+ outline: 'band', curve: 'line',
118
+ w: r * .5, h: r * .16, tube: r * .16,
119
+ p: a.p, n: a.n, d: d * .7, bevel: d * .25,
120
+ proud: r * .1, color: frame , finish: fin });
121
+ }
122
+ }
123
+ },
124
+ };
@@ -0,0 +1,41 @@
1
+ import type { Group, Material, Mesh, Texture } from "three";
2
+
3
+ export interface GRecipe {
4
+ seed: number;
5
+ species: string | null;
6
+ body: string | null;
7
+ stance: string | null;
8
+ palette: string | null;
9
+ colorIx: number | null;
10
+ material: string | null;
11
+ parts: Record<string, { params?: unknown | null; rr?: number }>;
12
+ }
13
+
14
+ export type MaterialFor = (
15
+ finish: string,
16
+ color: string,
17
+ shell?: boolean,
18
+ print?: { key: string; tex: Texture } | null,
19
+ ) => Material;
20
+
21
+ export interface GlossBuilt {
22
+ group: Group;
23
+ head: Group;
24
+ face: Record<string, Mesh>;
25
+ P: Record<string, unknown>;
26
+ L: { s: number; H: number; W: number; cy: number };
27
+ bounds: { w: number; h: number; cy: number; minY: number; maxY: number };
28
+ stats: { buildMs: number; verts: number; meshes: number };
29
+ }
30
+
31
+ export const GPARTS: readonly { id: string; label: string }[];
32
+ export const GPART_BY_ID: Record<string, { id: string; label: string }>;
33
+ export const BODY_IDS: readonly string[];
34
+ export const BODY_WEIGHTS: readonly [string, number][];
35
+ export const STANCE_IDS: readonly string[];
36
+ export const STANCE_WEIGHTS: readonly [string, number][];
37
+
38
+ export function newGRecipe(seed?: number): GRecipe;
39
+ export function ensureGParams(recipe: GRecipe): GRecipe;
40
+ export function rerollGPart(recipe: GRecipe, id: string): void;
41
+ export function buildGloss(recipe: GRecipe, options: { materialFor: MaterialFor }): GlossBuilt;
@@ -0,0 +1,286 @@
1
+ // ---------------------------------------------------------------
2
+ // THE RIG — recipe in, character out, and the recipe is the ONLY state, so
3
+ // the same JSON gives the same character on any machine.
4
+ //
5
+ // recipe = {
6
+ // seed, // one integer; the whole character
7
+ // body, // which shape (one for now: sphere)
8
+ // palette, colorIx, // which five colours, and which of them
9
+ // material, // one of `MATERIAL_IDS`
10
+ // parts: { [id]: { params, rr? } }
11
+ // }
12
+ //
13
+ // Every part gen()s its params, the layout measures once, each part
14
+ // pushes SPECS, and this file stamps them. A part never touches
15
+ // three.js and never builds a material — it names a colour from the
16
+ // layout and a shape from the catalogue.
17
+ //
18
+ // The face comes back as a map of meshes (`eyeL`, `mouth`, …) and that
19
+ // map is the animation surface: a blink is `eyeL.scale.y`, a glance is
20
+ // a nudge in x, and neither costs a rebuild. That is the whole reason
21
+ // the features are separate objects instead of one welded model.
22
+ // ---------------------------------------------------------------
23
+ import * as THREE from 'three';
24
+ import { makeRng, hashStr } from '../rng.js';
25
+ import { plateGeometry, solidGeometry, skullGeometry, basisAt } from './gshape.js';
26
+ import { buildGlossLayout } from './glayout.js';
27
+ import { GPARTS, GPART_BY_ID } from './gparts/index.js';
28
+ import { PALETTES, PALETTE_BY_ID, PALETTE_DEAL_IDS, INK, SCLERA, MAW, tint, luma } from './gpalette.js';
29
+ import { MATERIAL_WEIGHTS } from './gmedia.js';
30
+ import { gcastingFor, pickGBody, pickGStance, GSPECIES, GSPECIES_WEIGHTS } from './gspecies.js';
31
+ import { isMeshForm } from './gform.js';
32
+ import { clothPrint } from './gtexture.js';
33
+
34
+ export { GPARTS, GPART_BY_ID };
35
+
36
+ export const BODY_IDS = ['sphere', 'cube', 'rock', 'slime'];
37
+
38
+ // How an un-opinionated species is poured. WEIGHTED, not uniform: the
39
+ // ball and the block carry the sheet, and the two bent forms are the
40
+ // treats — dealt evenly they were 40% of a sheet, and a rock is a
41
+ // strong enough silhouette that four of them in a row stop being a
42
+ // surprise. A humanoid is neither shape, and says so in its profile.
43
+ // The lump shapes lost a little of the wildcard's share when they
44
+ // became SPECIES of their own — a dedicated rock deal plus the old
45
+ // wildcard rate made half the sheet geology.
46
+ export const BODY_WEIGHTS = [['sphere', 38], ['cube', 36], ['rock', 16], ['slime', 10]];
47
+
48
+ export const STANCE_IDS = ['none', 'biped'];
49
+
50
+ // Head-only carries the sheet — it is the house look, and a body is
51
+ // the treat, the same bargain as the bent forms above. Frequency is
52
+ // art direction here like everywhere else.
53
+ export const STANCE_WEIGHTS = [['none', 69], ['biped', 31]];
54
+
55
+ export function newGRecipe(seed = (Math.random() * 1e9) | 0) {
56
+ return { seed, species: null, body: null, stance: null, palette: null, colorIx: null,
57
+ material: null, parts: {} };
58
+ }
59
+
60
+ // a WEIGHTED pick, because "how often" is most of the art direction
61
+ // here: brows are anecdotal, noses are a quarter of the sheet, chrome
62
+ // is once a sheet, and a uniform pick over a table cannot say any of
63
+ // it. It lives out here rather than on `partRng` because the CAST rng
64
+ // needs it too — a finish is dealt by frequency the same as a style.
65
+ // Costs exactly one draw, so it can replace a `pick` without shifting
66
+ // anything downstream of it in the stream.
67
+ const wpick = (rng, pairs) => {
68
+ let total = 0; for (const p of pairs) total += p[1];
69
+ let x = rng.r(0, total);
70
+ for (const p of pairs) if ((x -= p[1]) < 0) return p[0];
71
+ return pairs[pairs.length - 1][0];
72
+ };
73
+
74
+ const partRng = (recipe, id) => {
75
+ const rng = makeRng(hashStr(`${recipe.seed}:g:${id}:${recipe.parts[id]?.rr || 0}`));
76
+ rng.wpick = pairs => wpick(rng, pairs);
77
+ return rng;
78
+ };
79
+
80
+ /** fills in anything the recipe has not already been TOLD. Every field
81
+ * uses ??=, which is what lets the crowd's filters pin one dimension
82
+ * and let the rest roll. */
83
+ export function ensureGParams(recipe) {
84
+ const rng = makeRng(hashStr(`${recipe.seed}:gcast`));
85
+ // the species FIRST: it loads the dice for everything after it —
86
+ // the body form it prefers, and every part's own rolls
87
+ recipe.species ??= wpick(rng, GSPECIES_WEIGHTS);
88
+ // a stance PINNED before the deal (the crowd's filter) narrows the
89
+ // body: the modeled forms cannot stand on a frame — see below
90
+ const wantFrame = !!recipe.stance && recipe.stance !== 'none';
91
+ recipe.body ??= pickGBody(recipe.species, rng, BODY_WEIGHTS,
92
+ wantFrame ? { without: isMeshForm } : {});
93
+ // A species may NAME a palette and a material, and exactly one does.
94
+ // The standing rule is that it must not — what a character is made of is a
95
+ // separate lever, and a lavender panda is still a panda. The
96
+ // humanoid is the exception the rule was waiting for: it is made of
97
+ // SKIN, and a chrome one is not a humanoid in another finish, it is
98
+ // a different object. Everyone else still leaves both alone, and a
99
+ // pinned filter still wins over either (the ??= runs first).
100
+ const SP = GSPECIES[recipe.species];
101
+ recipe.palette ??= SP?.palette ?? rng.pick(PALETTE_DEAL_IDS);
102
+ recipe.material ??= SP?.material ?? wpick(rng, MATERIAL_WEIGHTS);
103
+ recipe.colorIx ??= rng.ri(0, PALETTE_BY_ID[recipe.palette].colors.length - 1);
104
+ // dealt LAST in the cast stream, so existing seeds keep the species,
105
+ // body and colours they already had. A modeled form never takes a
106
+ // frame — a rock's foot and a slime's base are already its bottom —
107
+ // but a PINNED stance (the crowd's filter) still wins, because the
108
+ // ??= runs first.
109
+ recipe.stance ??= isMeshForm(recipe.body) ? 'none'
110
+ : pickGStance(recipe.species, rng, STANCE_WEIGHTS);
111
+
112
+ const C = gcastingFor(recipe.species);
113
+ for (const part of GPARTS) {
114
+ recipe.parts[part.id] ??= {};
115
+ recipe.parts[part.id].params ??= part.gen(partRng(recipe, part.id), C(part.id));
116
+ }
117
+ return recipe;
118
+ }
119
+
120
+ export function rerollGPart(recipe, id) {
121
+ const slot = recipe.parts[id];
122
+ if (!slot) return;
123
+ slot.rr = (slot.rr || 0) + 1;
124
+ slot.params = null;
125
+ ensureGParams(recipe);
126
+ }
127
+
128
+ /** the four colours every part draws from, all off the one palette. */
129
+ function colorsFor(recipe) {
130
+ const pal = PALETTE_BY_ID[recipe.palette] ?? PALETTES[0];
131
+ const body = pal.colors[recipe.colorIx % pal.colors.length];
132
+
133
+ // The warm bits (cheeks) take ANOTHER colour from the same five, and
134
+ // it has to be the WARMEST one — not, as it was, the one furthest in
135
+ // lightness. Furthest-in-lightness picked the blue out of a pale
136
+ // palette often enough that cheeks came out cold, which does not read
137
+ // as a blush at all: it reads as tears. Score by how far toward red
138
+ // a colour sits, and break ties on contrast against the body so it
139
+ // still shows up.
140
+ const warmth = hex => {
141
+ const n = parseInt(hex.slice(1), 16);
142
+ return ((n >> 16 & 255) - (n & 255)) / 255;
143
+ };
144
+ let warm = body, best = -Infinity;
145
+ for (const c of pal.colors) {
146
+ if (c === body) continue;
147
+ const score = warmth(c) * 2 + Math.abs(luma(c) - luma(body)) * .5;
148
+ if (score > best) { best = score; warm = c; }
149
+ }
150
+ return { body, warm, ink: INK, sclera: SCLERA, maw: MAW, lite: tint(body, .55) };
151
+ }
152
+
153
+ /**
154
+ * recipe → { group, face, P, L, stats }.
155
+ * `face` maps a feature id to its mesh.
156
+ */
157
+ export function buildGloss(recipe, { materialFor } = {}) {
158
+ ensureGParams(recipe);
159
+ const P = Object.fromEntries(GPARTS.map(p => [p.id, recipe.parts[p.id].params]));
160
+ const L = buildGlossLayout(P, colorsFor(recipe), recipe.body, recipe.stance ?? 'none');
161
+ const finish = recipe.material;
162
+
163
+ const t0 = performance.now();
164
+ const specs = [];
165
+ const add = s => specs.push(s);
166
+ for (const part of GPARTS) part.build(add, P, L);
167
+
168
+ const group = new THREE.Group();
169
+ // THE HEAD IS ITS OWN GROUP, pivoted at its centre. The gaze spring
170
+ // turns the head, and on a character with a torso the torso must not turn
171
+ // with it — so the pages write yaw/pitch to `head`, not `group`. On
172
+ // a head-only character that is the whole character, same as it always was, just
173
+ // pivoting about its middle instead of about the floor.
174
+ const head = new THREE.Group();
175
+ head.position.y = L.cy;
176
+ head.userData.restY = L.cy;
177
+ group.add(head);
178
+ const face = {};
179
+ let verts = 0;
180
+
181
+ for (const spec of specs) {
182
+ // the exponent travels as `exp`, NEVER `n`: a placed solid also
183
+ // carries its surface normal as `n`, and the two silently collide
184
+ // in the spec literal — an array reaches Math.pow and every vertex
185
+ // goes NaN. It happened; the key is different so it cannot again.
186
+ // `mesh` is arrays built elsewhere and stamped here — the skull
187
+ // (gskull.js) and the hair (ghair.js). `skull` is the old name for
188
+ // it and still works.
189
+ const geo = spec.type === 'mesh' || spec.type === 'skull'
190
+ ? skullGeometry(spec.mesh ?? spec.skull)
191
+ : spec.type === 'solid'
192
+ ? solidGeometry(spec.rx, spec.ry, spec.rz, spec.exp ?? 2, spec.dome, spec.domeFrom)
193
+ : plateGeometry(spec);
194
+ // The SHELL is the body — head, torso and limbs, the character's own
195
+ // pour. Everything else is a feature set into it, and takes
196
+ // whatever `gmedia.js` says a feature may wear — nobody knits an
197
+ // eye, and a plate's UVs could not carry a stitch even if somebody
198
+ // did. A knitted bear's belly, though, is knitted.
199
+ const shell = spec.id === 'body' || !!spec.shell;
200
+ // A spec may NAME its finish. Hair does (a wool humanoid is a
201
+ // knitted head, not a knitted haircut) and so does the frame's
202
+ // outfit — cloth, gloves, shoes are `acc`, never the pour. A spec
203
+ // may also carry a PRINT: the torso's screen-printed graphic, baked
204
+ // once per (motif, cloth, ink) in `gtexture.js`.
205
+ const print = spec.print
206
+ ? clothPrint(spec.print.motif, spec.print.cloth, spec.print.ink) : null;
207
+ const mat = materialFor(spec.finish ?? finish, spec.color, shell, print);
208
+ const mesh = new THREE.Mesh(geo, mat);
209
+ mesh.name = spec.id;
210
+
211
+ if (spec.pos) {
212
+ mesh.position.set(spec.pos[0], spec.pos[1], spec.pos[2]);
213
+ // an arm hanging a little out from the shoulder — the one
214
+ // rotation a placed solid may carry
215
+ if (spec.tilt) mesh.rotation.z = spec.tilt;
216
+ } else {
217
+ const b = basisAt(spec.p, spec.n, spec.proud ?? 0, spec.roll ?? 0);
218
+ mesh.position.copy(b.position);
219
+ mesh.quaternion.copy(b.quaternion);
220
+ // an offset is in the FEATURE's own plane, not the world's — so a
221
+ // catchlight stays up-and-left of its eye however the eye is
222
+ // tilted or wherever on the head it landed
223
+ if (spec.offset) { mesh.translateX(spec.offset[0]); mesh.translateY(spec.offset[1]); }
224
+ }
225
+
226
+ // Only the body casts, and whatever ASKS to. A face feature lies
227
+ // flush against a body that is already casting, and the key's blur
228
+ // would smear its shadow back across the very face it sits on —
229
+ // but hair is a real volume standing off the head, and hair that
230
+ // casts nothing reads as paint.
231
+ mesh.castShadow = spec.id === 'body' || !!spec.cast;
232
+ mesh.userData.shut = !!spec.shut;
233
+ // how far this feature may slide when the character looks somewhere, in
234
+ // its own plane. A pupil gets most of its white; a white eye gets
235
+ // almost nothing. Absent means "use the default for a whole eye".
236
+ if (spec.travel) mesh.userData.travel = spec.travel;
237
+ // how far a lid slides DOWN as the eye closes
238
+ if (spec.lidDrop) mesh.userData.lidDrop = spec.lidDrop;
239
+ // or how far it ROLLS — the ball eye's hemisphere cap pivots about
240
+ // the ball's centre instead of sliding, in radians
241
+ if (spec.lidRoll) mesh.userData.lidRoll = spec.lidRoll;
242
+ // where a pupil parks in its white. The blink pulls it back to
243
+ // centre, because the white squashes about the EYE's middle and a
244
+ // pupil left up at the top would be squashed about its own and
245
+ // slide straight out of the closing lid.
246
+ if (spec.anchorY) mesh.userData.anchorY = spec.anchorY;
247
+
248
+ // frame meshes live on the GROUP and hold still under the gaze;
249
+ // everything else is on the head and turns with it. Specs are
250
+ // authored in world y, so a head child gives the pivot's share back.
251
+ if (spec.frame) {
252
+ group.add(mesh);
253
+ } else {
254
+ mesh.position.y -= L.cy;
255
+ head.add(mesh);
256
+ // ...and only what is ON the head is a face: the animator slides
257
+ // its entries with the gaze, and a torso that slid would walk
258
+ if (spec.id !== 'body') face[spec.id] = mesh;
259
+ }
260
+ verts += geo.attributes.position.count;
261
+ }
262
+
263
+ // WHAT WAS ACTUALLY BUILT. `L.H` is the BODY's height and nothing
264
+ // else, so a page that fits a character by it is fitting the head and
265
+ // ignoring whatever is standing on top of it — a bunny came out 1.76×
266
+ // taller than its cell believed and grew into the row above. Ears,
267
+ // horns and a dropped maw all live out here, and every one of them
268
+ // is inside these bounds.
269
+ const box = new THREE.Box3();
270
+ group.updateMatrixWorld(true);
271
+ group.traverse(child => {
272
+ if (!child.isMesh) return;
273
+ child.geometry.computeBoundingBox();
274
+ box.union(child.geometry.boundingBox.clone().applyMatrix4(child.matrixWorld));
275
+ });
276
+ const bounds = {
277
+ w: box.max.x - box.min.x, h: box.max.y - box.min.y,
278
+ cy: (box.min.y + box.max.y) / 2, // the character's real middle, not the head's
279
+ minY: box.min.y, maxY: box.max.y,
280
+ };
281
+
282
+ return {
283
+ group, head, face, P, L, bounds,
284
+ stats: { buildMs: Math.round(performance.now() - t0), verts, meshes: specs.length },
285
+ };
286
+ }