humanforge 0.0.0-stage → 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.
@@ -0,0 +1,548 @@
1
+ // Procedural low-poly avatar builder. Every part is generated from three.js
2
+ // primitives at runtime, so the library ships zero mesh/texture assets.
3
+ // Parts are merged per material "role" -> ~15 draw calls per avatar.
4
+ import {
5
+ BufferAttribute, BufferGeometry, CapsuleGeometry, Color, CylinderGeometry, DoubleSide, Euler, Group,
6
+ IcosahedronGeometry, LatheGeometry, Matrix4, Mesh, MeshStandardMaterial, Quaternion, SphereGeometry,
7
+ TorusGeometry, Vector2, Vector3,
8
+ } from 'three';
9
+ import { AvatarConfig, normalizeConfig } from './config';
10
+
11
+ export type Role =
12
+ | 'skin' | 'eyeWhite' | 'iris' | 'pupil' | 'lips' | 'mouthInner' | 'brows' | 'hair' | 'facialHair'
13
+ | 'top' | 'bottom' | 'shoes' | 'sole' | 'headwear' | 'glasses';
14
+
15
+ export interface BuildOptions {
16
+ /** Faceted "low-poly" look (default true). */
17
+ flatShading?: boolean;
18
+ /** Skip everything below the neck (cheaper, for profile bubbles). */
19
+ headOnly?: boolean;
20
+ }
21
+
22
+ export interface AvatarMetrics {
23
+ /** World-space Y of the head centre. */
24
+ headY: number;
25
+ /** Total height in metres (feet at y=0). */
26
+ height: number;
27
+ triangles: number;
28
+ }
29
+
30
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
31
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
32
+ const UP = new Vector3(0, 1, 0);
33
+ const FWD = new Vector3(0, 0, 1);
34
+ const HALF_PI = Math.PI / 2;
35
+
36
+ /** Collects primitives grouped by role, baking transforms so they can be merged. */
37
+ class Parts {
38
+ private byRole = new Map<Role, BufferGeometry[]>();
39
+ /** Optional parent transform applied after each part's own matrix (used for the head). */
40
+ pre: Matrix4 | null = null;
41
+ add(role: Role, geo: BufferGeometry, m?: Matrix4) {
42
+ if (m) geo.applyMatrix4(m);
43
+ if (this.pre) geo.applyMatrix4(this.pre);
44
+ if (!this.byRole.has(role)) this.byRole.set(role, []);
45
+ this.byRole.get(role)!.push(geo);
46
+ }
47
+ /** Capsule spanning two points. */
48
+ segment(role: Role, a: Vector3, b: Vector3, r: number, radial = 10) {
49
+ const dir = b.clone().sub(a);
50
+ const len = Math.max(1e-4, dir.length());
51
+ const m = new Matrix4().compose(
52
+ a.clone().add(b).multiplyScalar(0.5),
53
+ new Quaternion().setFromUnitVectors(UP, dir.normalize()),
54
+ new Vector3(1, 1, 1),
55
+ );
56
+ this.add(role, new CapsuleGeometry(r, len, 3, radial), m);
57
+ }
58
+ entries() {
59
+ return [...this.byRole.entries()].map(([role, geos]) => [role, mergeGeometries(geos)] as const);
60
+ }
61
+ }
62
+
63
+ function mergeGeometries(geos: BufferGeometry[]): BufferGeometry {
64
+ let vCount = 0, iCount = 0;
65
+ for (const g of geos) {
66
+ vCount += g.attributes.position.count;
67
+ iCount += g.index ? g.index.count : g.attributes.position.count;
68
+ }
69
+ const pos = new Float32Array(vCount * 3);
70
+ const nor = new Float32Array(vCount * 3);
71
+ const idx = vCount > 65535 ? new Uint32Array(iCount) : new Uint16Array(iCount);
72
+ let vo = 0, io = 0;
73
+ for (const g of geos) {
74
+ if (!g.attributes.normal) g.computeVertexNormals();
75
+ pos.set(g.attributes.position.array as Float32Array, vo * 3);
76
+ nor.set(g.attributes.normal.array as Float32Array, vo * 3);
77
+ const n = g.attributes.position.count;
78
+ if (g.index) for (let i = 0; i < g.index.count; i++) idx[io++] = g.index.getX(i) + vo;
79
+ else for (let i = 0; i < n; i++) idx[io++] = i + vo;
80
+ vo += n;
81
+ g.dispose();
82
+ }
83
+ // Degenerate pole triangles can leave zero normals; glTF requires unit vectors.
84
+ for (let i = 0; i < nor.length; i += 3) {
85
+ const l = Math.hypot(nor[i], nor[i + 1], nor[i + 2]);
86
+ if (l < 1e-6) { nor[i] = 0; nor[i + 1] = 1; nor[i + 2] = 0; }
87
+ else if (Math.abs(l - 1) > 1e-4) { nor[i] /= l; nor[i + 1] /= l; nor[i + 2] /= l; }
88
+ }
89
+ const out = new BufferGeometry();
90
+ out.setAttribute('position', new BufferAttribute(pos, 3));
91
+ out.setAttribute('normal', new BufferAttribute(nor, 3));
92
+ out.setIndex(new BufferAttribute(idx, 1));
93
+ out.computeBoundingSphere();
94
+ return out;
95
+ }
96
+
97
+ const mat = (pos: Vector3 | [number, number, number], rot?: Euler | Quaternion, scale?: [number, number, number]) =>
98
+ new Matrix4().compose(
99
+ Array.isArray(pos) ? new Vector3(...pos) : pos,
100
+ rot instanceof Euler ? new Quaternion().setFromEuler(rot) : rot ?? new Quaternion(),
101
+ new Vector3(...(scale ?? [1, 1, 1])),
102
+ );
103
+
104
+ const mix = (a: string, b: string, t: number) => '#' + new Color(a).lerp(new Color(b), t).getHexString();
105
+
106
+ export function avatarColors(c: AvatarConfig): Record<Role, string> {
107
+ return {
108
+ skin: c.skin,
109
+ eyeWhite: '#f4f1ec',
110
+ iris: c.eyeColor,
111
+ pupil: '#0b0b0b',
112
+ lips: c.lipColor,
113
+ mouthInner: '#4a1c1c',
114
+ brows: mix(c.hairColor, '#000000', 0.25),
115
+ hair: c.hairColor,
116
+ facialHair: c.facialHair === 'stubble' ? mix(c.skin, c.hairColor, 0.35) : c.hairColor,
117
+ top: c.topColor,
118
+ bottom: c.bottomColor,
119
+ shoes: c.shoesColor,
120
+ sole: c.shoes === 'sandals' ? mix(c.shoesColor, '#000000', 0.2) : '#eeeeee',
121
+ headwear: c.headwearColor,
122
+ glasses: c.glassesColor,
123
+ };
124
+ }
125
+
126
+ /** Builds the avatar geometry (no materials) grouped by role. Pure; works in Node/RN/web. */
127
+ export function buildAvatarGeometry(input: Partial<AvatarConfig>, opts: BuildOptions = {}) {
128
+ const c = normalizeConfig(input);
129
+ const P = new Parts();
130
+
131
+ // ---------- proportions ----------
132
+ const hs = 0.92 + 0.16 * c.height; // height scale
133
+ const w = 0.85 + 0.3 * c.build; // width scale
134
+ const chestF = 0.82 + 0.36 * c.chest;
135
+ const hipF = 0.85 + 0.3 * c.hips;
136
+ const footH = 0.05;
137
+ const legLen = 0.78 * hs;
138
+ const hipY = footH + legLen;
139
+ const torsoLen = 0.5 * hs;
140
+ const shoulderY = hipY + torsoLen;
141
+
142
+ // head ellipsoid radii
143
+ const rx = lerp(0.086, 0.112, c.headWidth);
144
+ const ry = 0.122;
145
+ const rz = 0.108;
146
+ // Head is modelled at "real" size then scaled up for stylised proportions.
147
+ const HS = 1.3;
148
+ const headY = opts.headOnly ? 0 : shoulderY + 0.035 + ry * HS * 0.95;
149
+ P.pre = mat([0, headY, 0.005], undefined, [HS, HS, HS]);
150
+ const H = new Vector3(); // head-local origin (P.pre places it)
151
+ const jawF = lerp(0.72, 1.0, c.jaw);
152
+ const chinF = lerp(-0.12, 0.12, c.chin);
153
+
154
+ /** Head-local deformation shared by skin, hair, beard and feature anchors. */
155
+ const deform = (p: Vector3) => {
156
+ const t = clamp01(-p.y / ry);
157
+ p.x *= lerp(1, jawF, Math.pow(t, 1.4));
158
+ p.z *= lerp(1, 0.9, t * t);
159
+ if (p.y < 0) p.y *= 1 + chinF * t;
160
+ return p;
161
+ };
162
+ /** Point on the face surface (head-local x,y), pushed `out` along the normal. */
163
+ const surface = (x: number, y: number, out = 0) => {
164
+ const ux = x / rx, uy = y / ry;
165
+ const z = rz * Math.sqrt(Math.max(0, 1 - ux * ux - uy * uy));
166
+ const n = new Vector3(x / (rx * rx), y / (ry * ry), z / (rz * rz)).normalize();
167
+ const p = deform(new Vector3(x, y, z));
168
+ return { p: p.addScaledVector(n, out).add(H), n };
169
+ };
170
+ /** Place a primitive on the face, oriented along the surface normal. */
171
+ const onFace = (role: Role, geo: BufferGeometry, x: number, y: number, out: number, local?: Euler, scale?: [number, number, number]) => {
172
+ const { p, n } = surface(x, y, out);
173
+ const q = new Quaternion().setFromUnitVectors(FWD, n);
174
+ if (local) q.multiply(new Quaternion().setFromEuler(local));
175
+ P.add(role, geo, mat(p, q, scale));
176
+ };
177
+ /** Ellipsoidal shell around the head (hair, beard, hats). */
178
+ const shell = (
179
+ role: Role, k: number, phiStart: number, phiLength: number, thetaStart: number, thetaLength: number,
180
+ o: { segs?: [number, number]; flare?: number; lift?: number; scale?: [number, number, number] } = {},
181
+ ) => {
182
+ const [ws, hsg] = o.segs ?? [24, 12];
183
+ const g = new SphereGeometry(1, ws, hsg, phiStart, phiLength, thetaStart, thetaLength);
184
+ const a = g.attributes.position as BufferAttribute;
185
+ const s = o.scale ?? [1, 1, 1];
186
+ const v = new Vector3();
187
+ for (let i = 0; i < a.count; i++) {
188
+ v.fromBufferAttribute(a, i);
189
+ v.set(v.x * rx * k * s[0], v.y * ry * k * s[1], v.z * rz * k * s[2]);
190
+ deform(v);
191
+ if (o.flare && v.y < 0) {
192
+ const f = 1 + o.flare * (-v.y / ry);
193
+ v.x *= f; v.z *= f;
194
+ }
195
+ v.y += o.lift ?? 0;
196
+ a.setXYZ(i, v.x, v.y, v.z);
197
+ }
198
+ g.computeVertexNormals();
199
+ P.add(role, g, mat(H));
200
+ };
201
+ /** Full cap from crown to theta `tf`, plus sides/back to `tb` leaving a face gap of +-gap rad. */
202
+ const hood = (role: Role, k: number, tf: number, tb: number, gap: number, o: Parameters<typeof shell>[6] = {}) => {
203
+ shell(role, k, 0, Math.PI * 2, 0, tf, o);
204
+ shell(role, k, HALF_PI + gap, Math.PI * 2 - 2 * gap, tf - 0.01, tb - tf + 0.01, o);
205
+ };
206
+
207
+ // ---------- head ----------
208
+ {
209
+ const g = new SphereGeometry(1, 28, 20, -HALF_PI);
210
+ const a = g.attributes.position as BufferAttribute;
211
+ const v = new Vector3();
212
+ for (let i = 0; i < a.count; i++) {
213
+ v.fromBufferAttribute(a, i);
214
+ v.set(v.x * rx, v.y * ry, v.z * rz);
215
+ deform(v);
216
+ a.setXYZ(i, v.x, v.y, v.z);
217
+ }
218
+ g.computeVertexNormals();
219
+ P.add('skin', g, mat(H));
220
+ }
221
+
222
+ // ears
223
+ const hijab = c.headwear === 'hijab';
224
+ if (!hijab) {
225
+ const er = lerp(0.018, 0.03, c.earSize);
226
+ for (const sx of [-1, 1]) {
227
+ const p = deform(new Vector3(sx * rx * 0.97, -0.008, -0.006)).add(H);
228
+ P.add('skin', new SphereGeometry(er, 10, 8), mat(p, new Euler(0, sx * 0.35, 0), [0.38, 0.8, 0.55]));
229
+ }
230
+ }
231
+
232
+ // eyes
233
+ const re = lerp(0.013, 0.021, c.eyeSize);
234
+ const ex = rx * lerp(0.3, 0.44, c.eyeSpacing);
235
+ const ey = 0.008;
236
+ for (const sx of [-1, 1]) {
237
+ const x = sx * ex;
238
+ onFace('eyeWhite', new SphereGeometry(re, 14, 10), x, ey, -re * 0.12, undefined, [1, 0.82, 0.55]);
239
+ onFace('iris', new SphereGeometry(re * 0.62, 12, 8), x, ey, re * 0.3, undefined, [1, 1, 0.35]);
240
+ onFace('pupil', new SphereGeometry(re * 0.3, 10, 6), x, ey, re * 0.45, undefined, [1, 1, 0.3]);
241
+ // upper lid: a skin-coloured cap over the top of the eyeball softens the stare
242
+ onFace('skin', new SphereGeometry(re * 1.1, 14, 5, 0, Math.PI * 2, 0, 1.2), x, ey, -re * 0.1,
243
+ new Euler(-0.25, 0, 0), [1.04, 0.85, 0.66]);
244
+ }
245
+
246
+ // brows
247
+ if (c.brows !== 'none') {
248
+ const br = c.brows === 'thick' ? 0.0062 : 0.0036;
249
+ const by = ey + re + 0.016;
250
+ for (const sx of [-1, 1]) {
251
+ if (c.brows === 'arched') {
252
+ onFace('brows', new CapsuleGeometry(br, 0.016, 2, 6), sx * (ex - 0.008), by, 0.002, new Euler(0, 0, HALF_PI + sx * 0.35));
253
+ onFace('brows', new CapsuleGeometry(br, 0.014, 2, 6), sx * (ex + 0.011), by - 0.001, 0.001, new Euler(0, 0, HALF_PI - sx * 0.5));
254
+ } else {
255
+ onFace('brows', new CapsuleGeometry(br, 0.026, 2, 6), sx * ex, by, 0.002, new Euler(0, 0, HALF_PI - sx * 0.08));
256
+ }
257
+ }
258
+ }
259
+
260
+ // nose
261
+ {
262
+ const s = lerp(0.8, 1.25, c.noseSize);
263
+ if (c.nose === 'button') {
264
+ onFace('skin', new SphereGeometry(0.015 * s, 10, 8), 0, -0.03, 0.004, undefined, [1, 0.85, 1]);
265
+ } else if (c.nose === 'straight') {
266
+ onFace('skin', new CapsuleGeometry(0.0085 * s, 0.03 * s, 3, 8), 0, -0.016, 0.004, new Euler(0.45, 0, 0));
267
+ onFace('skin', new SphereGeometry(0.0125 * s, 10, 8), 0, -0.035, 0.008);
268
+ } else {
269
+ onFace('skin', new SphereGeometry(0.014 * s, 12, 8), 0, -0.031, 0.006, undefined, [1.35, 0.85, 0.95]);
270
+ for (const sx of [-1, 1]) onFace('skin', new SphereGeometry(0.008 * s, 8, 6), sx * 0.014 * s, -0.036, -0.003);
271
+ }
272
+ }
273
+
274
+ // facial hair (before mouth so the mouth can sit on top)
275
+ let mouthOut = 0.0015;
276
+ if (c.facialHair === 'stubble' || c.facialHair === 'beard') {
277
+ const k = c.facialHair === 'beard' ? 1.06 : 1.012;
278
+ shell('facialHair', k, HALF_PI - 1.5, 3.0, 1.9, Math.PI - 1.9, { segs: [22, 8] });
279
+ mouthOut += rz * (k - 1) + 0.002;
280
+ }
281
+ if (c.facialHair === 'mustache' || c.facialHair === 'goatee' || c.facialHair === 'beard') {
282
+ for (const sx of [-1, 1]) {
283
+ onFace('facialHair', new CapsuleGeometry(0.0055, 0.016, 2, 6), sx * 0.011, -0.05, mouthOut + 0.002, new Euler(0, 0, HALF_PI + sx * 0.3));
284
+ }
285
+ }
286
+ if (c.facialHair === 'goatee') {
287
+ onFace('facialHair', new SphereGeometry(0.016, 10, 8), 0, -0.1, 0.0, undefined, [1, 1.3, 0.8]);
288
+ }
289
+
290
+ // mouth
291
+ {
292
+ const mw = lerp(0.016, 0.027, c.mouthWidth);
293
+ const my = -0.066;
294
+ if (c.mouth === 'smile') {
295
+ onFace('lips', new TorusGeometry(mw, 0.0042, 6, 14, Math.PI), 0, my + mw * 0.35, mouthOut, new Euler(0, 0, Math.PI), [1, 0.42, 1]);
296
+ } else if (c.mouth === 'neutral') {
297
+ onFace('lips', new CapsuleGeometry(0.0045, mw * 1.6, 2, 8), 0, my, mouthOut, new Euler(0, 0, HALF_PI));
298
+ } else {
299
+ onFace('mouthInner', new SphereGeometry(mw * 0.85, 14, 8), 0, my - 0.003, mouthOut - 0.003, undefined, [1, 0.55, 0.3]);
300
+ onFace('lips', new TorusGeometry(mw * 0.85, 0.004, 6, 18), 0, my - 0.003, mouthOut, undefined, [1, 0.55, 1]);
301
+ }
302
+ }
303
+
304
+ // hair
305
+ const cap = c.headwear === 'cap' || c.headwear === 'beanie' || c.headwear === 'kufi';
306
+ if (!hijab) {
307
+ switch (c.hair) {
308
+ case 'buzz':
309
+ hood('hair', 1.02, 0.98, 1.85, 1.05, { segs: [26, 14] });
310
+ break;
311
+ case 'short':
312
+ case 'sidepart':
313
+ case 'curly':
314
+ case 'ponytail':
315
+ case 'bun':
316
+ hood('hair', 1.07, 0.9, 1.85, 1.12);
317
+ break;
318
+ case 'bob':
319
+ hood('hair', 1.08, 0.82, 2.25, 0.92, { flare: 0.06 });
320
+ break;
321
+ case 'long':
322
+ hood('hair', 1.08, 0.85, 2.05, 0.98);
323
+ break;
324
+ case 'afro':
325
+ hood('hair', 1.42, 0.72, 2.0, 0.95, { segs: [14, 8], lift: 0.02 });
326
+ break;
327
+ }
328
+ if (c.hair === 'sidepart' && !cap) {
329
+ const { p } = surface(-0.03, 0.098, 0.012);
330
+ P.add('hair', new SphereGeometry(0.045, 12, 8), mat(p, new Euler(-0.5, 0, 0.35), [1.5, 0.55, 0.85]));
331
+ }
332
+ if (c.hair === 'curly') {
333
+ // Fibonacci-distributed curls over the scalp.
334
+ const n = 160;
335
+ for (let i = 0; i < n; i++) {
336
+ const yU = 1 - (i / (n - 1)) * 1.4;
337
+ const r = Math.sqrt(Math.max(0, 1 - yU * yU));
338
+ const phi = i * 2.39996;
339
+ const d = new Vector3(Math.cos(phi) * r, yU, Math.sin(phi) * r);
340
+ const theta = Math.acos(d.y);
341
+ const ph = Math.atan2(d.z, -d.x);
342
+ const front = Math.abs(((ph - HALF_PI + Math.PI * 3) % (Math.PI * 2)) - Math.PI) < 1.12;
343
+ if (theta > 1.85 || (front && theta > 0.9) || (cap && theta < 1.2)) continue;
344
+ const p = deform(new Vector3(d.x * rx * 1.08, d.y * ry * 1.08, d.z * rz * 1.08)).add(H);
345
+ P.add('hair', new IcosahedronGeometry(0.015, 0), mat(p, new Euler(i, i * 2, 0)));
346
+ }
347
+ }
348
+ if (c.hair === 'long' && !opts.headOnly) {
349
+ const g = new CylinderGeometry(rx * 1.08, rx * 1.3, 0.3, 16, 3, true, Math.PI - 1.6, 3.2);
350
+ P.add('hair', g, mat([H.x, H.y - 0.13, H.z - 0.008], undefined, [1, 1, rz / rx]));
351
+ }
352
+ if (c.hair === 'ponytail') {
353
+ P.add('hair', new SphereGeometry(0.03, 10, 8), mat([H.x, H.y + 0.03, H.z - rz * 1.08]));
354
+ P.segment('hair', new Vector3(0, H.y + 0.01, H.z - rz * 1.2), new Vector3(0, H.y - 0.15, H.z - rz * 1.45), 0.026);
355
+ }
356
+ if (c.hair === 'bun') {
357
+ P.add('hair', new SphereGeometry(0.048, 12, 10), mat([H.x, H.y + 0.095, H.z - 0.075]));
358
+ }
359
+ }
360
+
361
+ // headwear
362
+ if (c.headwear === 'cap') {
363
+ shell('headwear', 1.13, 0, Math.PI * 2, 0, 1.25);
364
+ const y = ry * 1.13 * Math.cos(1.25);
365
+ const brim = new CylinderGeometry(0.075, 0.075, 0.007, 16, 1, false, -1.35, 2.7);
366
+ P.add('headwear', brim, mat([H.x, H.y + y, H.z + rz * 0.95], new Euler(0.18, 0, 0), [1.15, 1, 1.05]));
367
+ } else if (c.headwear === 'beanie') {
368
+ shell('headwear', 1.12, 0, Math.PI * 2, 0, 1.2, { segs: [20, 10] });
369
+ const t = 1.17;
370
+ const band = new TorusGeometry(1, 0.075, 6, 24);
371
+ P.add('headwear', band, mat([H.x, H.y + ry * 1.12 * Math.cos(t), H.z], new Euler(HALF_PI, 0, 0),
372
+ [rx * 1.14 * Math.sin(t), rz * 1.14 * Math.sin(t), 0.1]));
373
+ P.add('headwear', new IcosahedronGeometry(0.026, 1), mat([H.x, H.y + ry * 1.12 + 0.012, H.z]));
374
+ } else if (c.headwear === 'kufi') {
375
+ shell('headwear', 1.085, 0, Math.PI * 2, 0, 0.98, { segs: [20, 8] });
376
+ } else if (hijab) {
377
+ shell('headwear', 1.11, 0, Math.PI * 2, 0, 0.92);
378
+ shell('headwear', 1.11, HALF_PI + 0.82, Math.PI * 2 - 1.64, 0.91, 2.42 - 0.91);
379
+ shell('headwear', 1.11, 0, Math.PI * 2, 2.4, Math.PI - 2.4, { segs: [24, 4] });
380
+ }
381
+
382
+ // glasses
383
+ if (c.glasses !== 'none') {
384
+ const out = re * 0.75 + 0.007;
385
+ const lr = re * 1.6;
386
+ for (const sx of [-1, 1]) {
387
+ const x = sx * ex;
388
+ if (c.glasses === 'round') onFace('glasses', new TorusGeometry(lr, 0.0024, 5, 20), x, ey, out);
389
+ else onFace('glasses', new TorusGeometry(lr * 1.15, 0.0024, 4, 4), x, ey, out, new Euler(0, 0, Math.PI / 4), [1.2, 0.85, 1]);
390
+ const edge = surface(sx * (ex + lr * 1.05), ey, out).p;
391
+ const ear = deform(new Vector3(sx * rx * 1.02, ey, -0.01)).add(H);
392
+ P.segment('glasses', edge, ear, 0.0022, 5);
393
+ }
394
+ P.segment('glasses', surface(-(ex - lr), ey + 0.004, out).p, surface(ex - lr, ey + 0.004, out).p, 0.0022, 5);
395
+ }
396
+
397
+ P.pre = null;
398
+ if (opts.headOnly) {
399
+ P.segment('skin', new Vector3(0, -0.24, -0.01), new Vector3(0, -ry * HS * 0.55, -0.01), 0.05);
400
+ return finish(P, c, { headY, height: headY + ry * HS * 1.2 });
401
+ }
402
+
403
+ // ---------- body ----------
404
+ const isDress = c.top === 'dress';
405
+ const longTop = c.top === 'kurta' || isDress;
406
+ const sleeveFull = c.top !== 'tank' && c.top !== 'tshirt';
407
+ const hipR = 0.15 * w * hipF;
408
+ const waistR = 0.122 * w * lerp(0.92, 1.12, c.build);
409
+ const chestR = 0.152 * w * chestF;
410
+ const shoulderR = 0.168 * w;
411
+ const neckR = 0.048;
412
+
413
+ // torso profile r(y) relative to hipY
414
+ const prof: [number, number][] = [
415
+ [-0.07, 0.0001], [-0.06, hipR * 0.8], [0.0, hipR], [0.16 * hs, waistR],
416
+ [0.31 * hs, chestR], [0.43 * hs, shoulderR], [torsoLen - 0.005, neckR * 1.7], [torsoLen, 0.0001],
417
+ ];
418
+ const r = (y: number) => {
419
+ for (let i = 1; i < prof.length; i++) {
420
+ if (y <= prof[i][0]) {
421
+ const [y0, r0] = prof[i - 1], [y1, r1] = prof[i];
422
+ return lerp(r0, r1, (y - y0) / (y1 - y0));
423
+ }
424
+ }
425
+ return prof[prof.length - 1][1];
426
+ };
427
+ const band = (role: Role, y0: number, y1: number, fn: (y: number) => number, depth = 0.62, steps = 8) => {
428
+ const pts: Vector2[] = [];
429
+ for (let i = 0; i <= steps; i++) {
430
+ const y = lerp(y0, y1, i / steps);
431
+ pts.push(new Vector2(Math.max(1e-4, fn(y)), y));
432
+ }
433
+ P.add(role, new LatheGeometry(pts, 20), mat([0, hipY, 0], undefined, [1, 1, depth]));
434
+ };
435
+ const cut = 0.1 * hs;
436
+ band(longTop ? 'top' : 'bottom', -0.07, cut, r, 0.62, 5);
437
+ band('top', cut, torsoLen, r, 0.62, 10);
438
+
439
+ // neck
440
+ P.segment('skin', new Vector3(0, shoulderY - 0.04, -0.005), new Vector3(0, headY - ry * HS * 0.55, -0.01), neckR, 12);
441
+
442
+ // legs
443
+ const legX = 0.085 * w * Math.sqrt(hipF);
444
+ const kneeY = footH + legLen * 0.5;
445
+ const thighR = 0.064 * w * Math.sqrt(hipF), shinR = 0.05 * w;
446
+ const pants = c.bottom === 'pants' && !isDress;
447
+ for (const sx of [-1, 1]) {
448
+ const hip = new Vector3(sx * legX, hipY - 0.02, 0);
449
+ const knee = new Vector3(sx * legX * 0.92, kneeY, 0.005);
450
+ const ankle = new Vector3(sx * legX * 0.88, footH + 0.03, -0.005);
451
+ P.segment(pants ? 'bottom' : 'skin', hip, knee, thighR);
452
+ P.segment(pants ? 'bottom' : 'skin', knee, ankle, shinR);
453
+ if (c.bottom === 'shorts' && !isDress) {
454
+ P.segment('bottom', hip, hip.clone().lerp(knee, 0.75), thighR + 0.008);
455
+ }
456
+ // feet
457
+ const fx = sx * legX * 0.88;
458
+ if (c.shoes === 'sandals') {
459
+ P.add('skin', new SphereGeometry(0.045, 10, 8), mat([fx, 0.03, 0.035], undefined, [0.8, 0.5, 1.6]));
460
+ P.add('sole', new CylinderGeometry(0.045, 0.045, 0.012, 12), mat([fx, 0.006, 0.035], undefined, [0.85, 1, 1.7]));
461
+ P.segment('shoes', new Vector3(fx - 0.03, 0.035, 0.05), new Vector3(fx + 0.03, 0.035, 0.05), 0.005, 5);
462
+ } else {
463
+ P.add('shoes', new SphereGeometry(0.05, 12, 8), mat([fx, 0.035, 0.03], undefined, [0.85, 0.6, 1.6]));
464
+ P.add('sole', new CylinderGeometry(0.05, 0.05, 0.014, 12), mat([fx, 0.007, 0.03], undefined, [0.85, 1, 1.65]));
465
+ if (c.shoes === 'boots') P.segment('shoes', new Vector3(fx, 0.04, 0), new Vector3(fx, 0.17, 0), shinR * 1.18);
466
+ }
467
+ }
468
+
469
+ // skirts / tunics
470
+ const skirtTo = (yWorld: number, role: Role, r0: number, flare: number) =>
471
+ band(role, cut * 0.4, yWorld - hipY, (y) => lerp(r0, r0 * flare, (cut * 0.4 - y) / (cut * 0.4 - (yWorld - hipY))), 0.8, 6);
472
+ if (isDress) skirtTo(footH + 0.12, 'top', hipR * 1.04, 1.45);
473
+ else if (c.top === 'kurta') skirtTo(kneeY - 0.02, 'top', hipR * 1.05, 1.25);
474
+ if (!isDress && c.bottom === 'skirt' && c.top !== 'kurta') skirtTo(kneeY - 0.03, 'bottom', hipR * 1.04, 1.35);
475
+ if (!isDress && c.bottom === 'longskirt') skirtTo(footH + 0.1, 'bottom', hipR * 1.04, 1.4);
476
+
477
+ // arms
478
+ for (const sx of [-1, 1]) {
479
+ const S = new Vector3(sx * shoulderR * 0.9, shoulderY - 0.065, -0.005);
480
+ const E = S.clone().add(new Vector3(sx * 0.045, -0.27 * hs, -0.015));
481
+ const W = E.clone().add(new Vector3(sx * 0.02, -0.23 * hs, 0.03));
482
+ const ur = 0.041 * w, fr = 0.034 * w;
483
+ const sleeveRole: Role = c.top === 'tank' ? 'skin' : 'top';
484
+ P.add(sleeveRole, new SphereGeometry(ur * 1.04, 10, 8), mat(S));
485
+ P.segment(sleeveFull ? 'top' : 'skin', S, E, ur);
486
+ P.segment(sleeveFull ? 'top' : 'skin', E, W, fr);
487
+ if (c.top === 'tshirt') P.segment('top', S, S.clone().lerp(E, 0.5), ur + 0.007);
488
+ P.add('skin', new SphereGeometry(0.036, 10, 8), mat(W.clone().add(new Vector3(sx * 0.004, -0.045, 0.004)), undefined, [0.65, 1.15, 0.95]));
489
+ }
490
+
491
+ // garment details
492
+ if (c.top === 'hoodie') {
493
+ P.add('top', new TorusGeometry(0.07, 0.026, 8, 18), mat([0, shoulderY + 0.005, -0.04], new Euler(HALF_PI - 0.35, 0, 0), [1, 0.8, 1]));
494
+ }
495
+ if (longTop) {
496
+ P.add('top', new CylinderGeometry(neckR * 1.15, neckR * 1.3, 0.03, 14, 1, true), mat([0, shoulderY - 0.005, -0.005]));
497
+ }
498
+ if (hijab) {
499
+ band('headwear', torsoLen - 0.2, torsoLen + 0.08, (y) => {
500
+ const t = clamp01((torsoLen + 0.08 - y) / 0.2);
501
+ return lerp(0.085, shoulderR * 1.4, t * t * (3 - 2 * t));
502
+ }, 0.62, 10);
503
+ }
504
+
505
+ const totalH = headY + ry * HS * 1.15 + (c.hair === 'afro' && !hijab ? 0.08 : 0);
506
+ return finish(P, c, { headY, height: totalH });
507
+ }
508
+
509
+ function finish(P: Parts, c: AvatarConfig, m: { headY: number; height: number }) {
510
+ const colors = avatarColors(c);
511
+ const parts = P.entries().map(([role, geometry]) => ({ role, geometry, color: colors[role] }));
512
+ const triangles = parts.reduce((s, p) => s + (p.geometry.index!.count / 3), 0);
513
+ return { config: c, parts, metrics: { ...m, triangles } as AvatarMetrics };
514
+ }
515
+
516
+ export type AvatarGeometry = ReturnType<typeof buildAvatarGeometry>;
517
+
518
+ /** Build a ready-to-render THREE.Group. Call `disposeAvatar` when done. */
519
+ export function buildAvatar(config: Partial<AvatarConfig>, opts: BuildOptions = {}): Group {
520
+ const built = buildAvatarGeometry(config, opts);
521
+ const group = new Group();
522
+ group.name = 'avatar';
523
+ for (const { role, geometry, color } of built.parts) {
524
+ const material = new MeshStandardMaterial({
525
+ color,
526
+ roughness: role === 'glasses' || role === 'pupil' || role === 'iris' ? 0.35 : 0.85,
527
+ metalness: 0,
528
+ flatShading: opts.flatShading ?? true,
529
+ side: DoubleSide,
530
+ });
531
+ material.name = role;
532
+ const mesh = new Mesh(geometry, material);
533
+ mesh.name = role;
534
+ group.add(mesh);
535
+ }
536
+ group.userData = { metrics: built.metrics, config: built.config };
537
+ return group;
538
+ }
539
+
540
+ export function disposeAvatar(group: Group) {
541
+ group.traverse((o) => {
542
+ const m = o as Mesh;
543
+ if (m.isMesh) {
544
+ m.geometry.dispose();
545
+ (m.material as MeshStandardMaterial).dispose();
546
+ }
547
+ });
548
+ }
@@ -0,0 +1,39 @@
1
+ // Compact, URL-safe serialization (~65 chars) so an avatar fits in a DB column,
2
+ // deep link or QR code. Format: "1" + one base64url char per enum/slider + 4 per color.
3
+ import { AvatarConfig, DEFAULT_AVATAR, ENUMS, normalizeConfig } from './config';
4
+
5
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
6
+ const VERSION = '1';
7
+ // Field order is part of the format: append new fields at the end only.
8
+ const KEYS = Object.keys(DEFAULT_AVATAR) as (keyof AvatarConfig)[];
9
+
10
+ export function encodeAvatar(config: AvatarConfig): string {
11
+ let s = VERSION;
12
+ for (const k of KEYS) {
13
+ const v = config[k] as string | number;
14
+ const d = DEFAULT_AVATAR[k];
15
+ if (typeof d === 'number') s += B64[Math.round(Math.min(1, Math.max(0, v as number)) * 63)];
16
+ else if ((d as string).startsWith('#')) {
17
+ const n = parseInt((v as string).slice(1), 16) || 0;
18
+ s += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
19
+ } else s += B64[Math.max(0, ENUMS[k]!.indexOf(v as string))];
20
+ }
21
+ return s;
22
+ }
23
+
24
+ export function decodeAvatar(code: string): AvatarConfig {
25
+ if (!code || code[0] !== VERSION) return { ...DEFAULT_AVATAR };
26
+ const out: Record<string, unknown> = {};
27
+ let i = 1;
28
+ const next = () => B64.indexOf(code[i++] ?? 'A');
29
+ for (const k of KEYS) {
30
+ if (i >= code.length) break;
31
+ const d = DEFAULT_AVATAR[k];
32
+ if (typeof d === 'number') out[k] = next() / 63;
33
+ else if ((d as string).startsWith('#')) {
34
+ const n = (next() << 18) | (next() << 12) | (next() << 6) | next();
35
+ out[k] = '#' + n.toString(16).padStart(6, '0');
36
+ } else out[k] = ENUMS[k]![next()];
37
+ }
38
+ return normalizeConfig(out as Partial<AvatarConfig>);
39
+ }