@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,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
|
+
}
|