@7ots/cli 0.1.0 → 0.1.3

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 (50) hide show
  1. package/.env.example +17 -1
  2. package/cli/7ots.mjs +136 -36
  3. package/cli/lib/assistant.mjs +337 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/i18n.mjs +87 -17
  6. package/cli/lib/install.mjs +29 -1
  7. package/cli/lib/pet-core.mjs +3 -2
  8. package/cli/lib/pet-server.mjs +30 -0
  9. package/cli/lib/prompts.mjs +200 -8
  10. package/cli/lib/terminal.mjs +27 -6
  11. package/cli/pet/electron/main.cjs +79 -28
  12. package/cli/pet/pet.html +55 -6
  13. package/dist/7ots.esm.js +370 -142
  14. package/dist/7ots.esm.js.map +4 -4
  15. package/dist/7ots.iife.js +370 -142
  16. package/dist/7ots.iife.js.map +4 -4
  17. package/llms.txt +199 -7
  18. package/package.json +1 -2
  19. package/server/channels/apuchat.mjs +8 -5
  20. package/server/channels/apumail.mjs +13 -3
  21. package/server/channels/index.mjs +5 -3
  22. package/server/platform/apuchat.mjs +266 -0
  23. package/server/platform/auth.mjs +39 -10
  24. package/server/platform/connect.mjs +347 -0
  25. package/server/platform/db.mjs +55 -0
  26. package/server/platform/look.mjs +20 -0
  27. package/server/platform/orquesta.mjs +499 -0
  28. package/server/platform/routes.mjs +57 -8
  29. package/server/platform/runtime.mjs +75 -15
  30. package/server/platform/store.mjs +130 -9
  31. package/server/server.mjs +14 -3
  32. package/skills/7ots/SKILL.md +1 -1
  33. package/src/avatar/AvatarStage.js +54 -9
  34. package/src/avatar/three.js +62 -0
  35. package/src/character/AvatarEditor.js +174 -50
  36. package/src/character/Character.js +164 -26
  37. package/src/character/motion.js +95 -34
  38. package/src/character/parts.js +43 -12
  39. package/src/character/skin.js +412 -0
  40. package/src/character/styles.js +1429 -0
  41. package/src/character3d/Character3D.js +1693 -0
  42. package/src/character3d/body.js +154 -0
  43. package/src/character3d/fx.js +323 -0
  44. package/src/character3d/index.js +281 -0
  45. package/src/character3d/materials.js +429 -0
  46. package/src/character3d/stage.js +187 -0
  47. package/src/i18n/messages/character.js +18 -42
  48. package/src/i18n/messages/platform.js +135 -0
  49. package/src/identity/schema.js +16 -8
  50. package/src/index.js +1 -0
@@ -0,0 +1,1693 @@
1
+ /**
2
+ * Personaje 3D de 7ots a partir de la MISMA spec (look.character) que el 2D: silueta, colores,
3
+ * estampado, ojos, cejas, boca, mejillas y accesorios salen de normalizeCharacter/layout, así
4
+ * que cada ot es la versión «peluche» de sí mismo. Los gestos reutilizan las poses de
5
+ * motion.js (GESTURES), los ánimos los mismos valores que Character.js y el habla los visemas.
6
+ *
7
+ * Esta clase solo construye y anima un THREE.Group (`root`); quién lo dibuja es una vista del
8
+ * escenario compartido (stage.js / index.js).
9
+ */
10
+
11
+ import * as THREE from 'three';
12
+ import { normalizeCharacter, layout, bodyPoints } from '../character/Character.js';
13
+ import { stylizeSpec } from '../character/styles.js';
14
+
15
+ /**
16
+ * Acabado 3D de cada estilo artístico (styles.js): la paleta sale de stylizeSpec y aquí se elige
17
+ * el material. Los estilos sin entrada se deducen del acabado 2D (contorno → toon).
18
+ */
19
+ const STYLE_FINISH = {
20
+ ukiyoe: 'toon', glass: 'glossy', bauhaus: 'toon', manuscript: 'toon', glitch: 'neon', clay: 'clay', ink: 'sketch',
21
+ pop: 'toon', watercolor: 'soft3d', pixel: 'toon', synthwave: 'neon', nouveau: 'toon', cubism: 'toon', crayon: 'sketch', blueprint: 'neon', pointillism: 'soft3d', woodcut: 'sketch',
22
+ };
23
+ function styled3D(spec) {
24
+ const s = stylizeSpec(spec);
25
+ if (!s._style) return s;
26
+ const f2 = s.finish;
27
+ s.finish = STYLE_FINISH[s._style.id] || (f2 === 'clay' || f2 === 'sketch' ? f2 : s._style.lw > 0 ? 'toon' : f2 === 'flat' ? 'soft3d' : f2);
28
+ return s;
29
+ }
30
+ import { shade, luminance } from '../character/parts.js';
31
+ import { textureOf } from '../character/skin.js';
32
+ import { GESTURES, GESTURE_ALIAS, VISEMES, EASE, clamp, lerp, approach, textToVisemes, babbleVisemes, HAND_POSES, MORPHS, EFFECTS, shapePoints, seeded } from '../character/motion.js';
33
+ import { U, bodyGeometry, surfaceProbe } from './body.js';
34
+ import { bodyMaterials, surfaceMaterial, mat, shadowTexture, CHEEK_IDS } from './materials.js';
35
+ import { FX3D, glyph, disposeFx } from './fx.js';
36
+
37
+ const DEG = Math.PI / 180;
38
+ const TAU = Math.PI * 2;
39
+ const Z = new THREE.Vector3(0, 0, 1);
40
+ const DUMMY = new THREE.Object3D();
41
+
42
+ // Mismos valores que Character.js (MOOD)
43
+ const MOOD = {
44
+ neutral: { curve: 0.15, tilt: 0 },
45
+ happy: { curve: 0.85, tilt: -0.25, squash: 0.85 },
46
+ angry: { curve: -0.45, tilt: 1 },
47
+ sad: { curve: -0.85, tilt: -1 },
48
+ fear: { curve: -0.35, tilt: -0.8, eye: 1.15, open: 0.15 },
49
+ disgust: { curve: -0.55, tilt: 0.6, squash: 0.8 },
50
+ love: { curve: 0.75, tilt: -0.3 },
51
+ sleep: { curve: 0, tilt: 0, closed: true },
52
+ surprised: { curve: 0, tilt: -0.4, eye: 1.2, open: 0.45, lift: 4 },
53
+ };
54
+ const MOOD_BASE = { curve: 0, tilt: 0, squash: 1, eye: 1, open: 0, lift: 0 };
55
+ const moodVals = (n) => ({ ...MOOD_BASE, ...(MOOD[n] || MOOD.neutral) });
56
+ export const MOODS_3D = Object.keys(MOOD);
57
+ export const GESTURES_3D = Object.keys(GESTURES);
58
+
59
+ const BEAD = new Set(['dot', 'kawaii', 'bean', 'sparkle', 'teary', 'button', 'pixel', 'villain']);
60
+ const SHINY = new Set(['kawaii', 'sparkle', 'teary']);
61
+ const LIDS = { sleepy: [0.5, 0], tired: [0.42, 0], angry: [0.42, 1], sad: [0.42, -1], focused: [0.33, 0] };
62
+ const OPEN_MIN = { grin: 0.35, laugh: 0.6, open: 0.35, vampire: 0.25, buck: 0.12, braces: 0.35, monster: 0.45, gap: 0.35, toothless: 0.45, drool: 0.2, mustachio: 0.1 };
63
+ const TEETH = new Set(['grin', 'braces', 'monster', 'gap', 'mustachio', 'vampire', 'buck']);
64
+ const ROUND = new Set(['oh', 'kiss', 'whistle']);
65
+ const CURVE_BIAS = { frown: -0.7, flat: -0.15, smile: 0.25, smirk: 0.2, pout: -0.5, cat: 0.3 };
66
+
67
+ // Efectos y metamorfosis que acompañan a cada ánimo (mismos que Character.js)
68
+ const MOOD_FX = {
69
+ angry: [['spikes', { ms: 900 }], ['anger'], ['steam']],
70
+ surprised: [['exclaim']],
71
+ love: [['hearts'], ['blush']],
72
+ happy: [['sparkles']],
73
+ sad: [['tears']],
74
+ fear: [['sweat']],
75
+ disgust: [['squish', { ms: 700 }]],
76
+ sleep: [['zzz', { hold: true, tag: 'mood' }]],
77
+ };
78
+ const MORPH_MS = 1800;
79
+ const MORPH_IN = 380;
80
+ const MORPH_OUT = 300;
81
+ const FX_OUT = 300;
82
+ export const MORPHS_3D = Object.keys(MORPHS);
83
+ export const EFFECTS_3D = Object.keys(EFFECTS);
84
+ // escala del rig por metamorfosis de deformación: [x, y]
85
+ const SCALE_MORPH = {
86
+ puff: (k) => [1 + 0.2 * k, 1 + 0.14 * k],
87
+ squish: (k) => [1 + 0.22 * k, 1 - 0.22 * k],
88
+ stretch: (k) => [1 - 0.12 * k, 1 + 0.26 * k],
89
+ melt: (k) => [1 + 0.06 * k, 1 - 0.38 * k],
90
+ };
91
+
92
+ const nowMs = () => performance.now();
93
+ const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
94
+ const shared = (o) => { o.userData.shared = true; return o; };
95
+
96
+ /** Malla con posición/rotación/escala opcionales. */
97
+ function M(geo, material, p, r, s) {
98
+ const m = new THREE.Mesh(geo, material);
99
+ if (p) m.position.set(...p);
100
+ if (r) m.rotation.set(...r);
101
+ if (s) (typeof s === 'number' ? m.scale.setScalar(s) : m.scale.set(...s));
102
+ return m;
103
+ }
104
+ const G = (...children) => { const g = new THREE.Group(); if (children.length) g.add(...children); return g; };
105
+
106
+ export class Character3D {
107
+ /**
108
+ * @param {object} spec look.character (se normaliza)
109
+ * @param {object} [opts] { quality: 'high'|'low', material: 'plush'|'auto', idle: true }
110
+ */
111
+ constructor(spec, opts = {}) {
112
+ this.opts = opts;
113
+ this.quality = opts.quality || 'high';
114
+ this.q = this.quality === 'high' ? 1 : 0.45;
115
+ this.root = new THREE.Group();
116
+ this.rig = new THREE.Group();
117
+ this.root.add(this.rig);
118
+ this.yaw = 0;
119
+ this.view = null;
120
+ this.phase = Math.random() * TAU;
121
+ this.moodName = 'neutral';
122
+ this.mv = moodVals('neutral');
123
+ this.sleepK = 0;
124
+ this.look = { x: 0, y: 0, tx: 0, ty: 0, at: -1e9 };
125
+ this.mo = { open: 0, target: 0, msx: 1, tmsx: 1 };
126
+ this.blinkAt = -1e9;
127
+ this.nextBlink = nowMs() + 600 + Math.random() * 3000;
128
+ this.gest = null;
129
+ this.speech = null;
130
+ this.shells = 0;
131
+ this.morphs = new Map();
132
+ this.fxs = [];
133
+ this.fxSeq = 0;
134
+ this.fxg = new THREE.Group(); // efectos que no giran con el cuerpo
135
+ this.root.add(this.fxg);
136
+ this.build(spec);
137
+ }
138
+
139
+ // ───────────────────────────── construcción ─────────────────────────────
140
+
141
+ build(spec) {
142
+ this.disposeRig();
143
+ this.clearMorphObjects();
144
+ for (const f of this.fxs) disposeFx(f.obj);
145
+ this.fxs = [];
146
+ const s = (this.spec = styled3D(normalizeCharacter(spec)));
147
+ const st = s._style; // estilo artístico: sin pelo, con su paleta y su contorno
148
+ const pts = bodyPoints(s);
149
+ const L = (this.L = layout(s));
150
+ const B = (this.B = bodyGeometry(pts, L.box, this.quality));
151
+ const probe = surfaceProbe(B.geometry);
152
+ const W = (this.W = L.box.w * U);
153
+ const H = (this.H = L.box.h * U);
154
+ this.depth = B.depth;
155
+ const kk = (this.kk = Math.sqrt(W * H) / 1.25); // escala de accesorios (1 ≈ ot medio)
156
+ const Y = (this.Y = (y) => (B.bottom - y) * U);
157
+ const X = (this.X = (x) => (x - L.cx) * U);
158
+ const tex = (this.tex = textureOf(s));
159
+ const plush = (this.plush = !st && ((this.opts.material || 'plush') === 'plush' || ['fur', 'fuzz', 'feathers'].includes(tex)));
160
+ const furLen = (this.furLen = plush ? (tex === 'fuzz' || tex === 'none' ? 0.05 : 0.065) * kk : 0);
161
+ const density = (this.density = furLen ? (this.quality === 'high' ? 1 / (furLen * 0.2) : 1 / (furLen * 0.32)) : 0);
162
+ this.maxShells = this.quality === 'high' ? 28 : 10;
163
+ const body = s.body.color;
164
+ const dark = luminance(body) < 0.28;
165
+ const pal = (this.pal = {
166
+ body,
167
+ brow: s.brows.color || shade(body, dark ? 0.6 : -0.6),
168
+ lip: s.mouth.color || shade(body, dark ? 0.6 : -0.55),
169
+ });
170
+ this.furs = [];
171
+ this.parts = [];
172
+ this.anims = [];
173
+ this.bodyMeshes = [];
174
+ this.zGrid = null;
175
+ this.hideMouth = false;
176
+
177
+ // cuerpo
178
+ const bm = bodyMaterials({ color: body, patternColor: s.body.patternColor, pattern: s.body.pattern, finish: s.finish, plush, furLen, density, cheekColor: s.cheeks.color, texture: tex });
179
+ this.bodyU = bm.uniforms;
180
+ this.bodyMat = bm.base;
181
+ const r3 = L.r * U;
182
+ const eyeY = Y(L.eyeY);
183
+ this.r3 = r3;
184
+ this.eyeY = eyeY;
185
+ bm.uniforms.uDims.value.set(W, H, eyeY, r3);
186
+ bm.uniforms.uSp.value = L.sp * U;
187
+ const bodyMesh = shared(new THREE.Mesh(B.geometry, bm.base));
188
+ bodyMesh.userData.ownMaterial = true;
189
+ this.rig.add(bodyMesh);
190
+ this.bodyMeshes.push(bodyMesh);
191
+ if (bm.shell) this.bodyMeshes.push(this.addShells(B.geometry, bm, this.rig));
192
+ this.probe = probe;
193
+ // contorno de dibujo animado (toon/sketch): casco invertido
194
+ const lineW = st ? st.lw * 0.0042 : s.finish === 'toon' ? 0.016 : 0.009;
195
+ if (s.finish === 'toon' || s.finish === 'sketch' || (st && st.lw > 0)) {
196
+ const ol = new THREE.Mesh(B.geometry, surfaceMaterial(bm.uniforms, { outline: true, color: st?.ink || '#1f2937', off: furLen * 1.05 + lineW * kk, wobble: s.finish === 'sketch' ? 0.6 : 0 }));
197
+ ol.userData.ownMaterial = true;
198
+ ol.userData.noBounds = true;
199
+ this.rig.add(ol);
200
+ this.bodyMeshes.push(ol);
201
+ }
202
+
203
+ // ojos
204
+ this.eyes = [];
205
+ const single = s.eyes.type === 'cyclops';
206
+ const xs = single ? [0] : [-L.sp * U, L.sp * U];
207
+ xs.forEach((x, i) => {
208
+ const side = single ? 1 : i ? 1 : -1;
209
+ const hit = probe.front(x, eyeY);
210
+ const eye = this.buildEye(s.eyes.type, single ? r3 * 1.7 : r3, side);
211
+ eye.root.position.copy(hit.p);
212
+ eye.root.quaternion.setFromUnitVectors(Z, hit.n.clone().lerp(Z, 0.5).normalize());
213
+ this.rig.add(eye.root);
214
+ this.eyes.push(eye);
215
+ this.part(eye.root, true);
216
+ if (bm.uniforms.uBald.value[i]) bm.uniforms.uBald.value[i].set(hit.p.x, hit.p.y, hit.p.z, (single ? r3 * 1.7 : r3) * 1.05);
217
+ });
218
+
219
+ // cejas
220
+ this.brows = [];
221
+ if (s.brows.type !== 'none') {
222
+ const bt = s.brows.type;
223
+ const w = { thick: 0.2, thin: 0.06, bushy: 0.16, dots: 0.18, soft: 0.09 }[bt] || 0.11;
224
+ const uni = bt === 'unibrow';
225
+ const list = uni ? [0] : xs.length === 1 ? [0] : xs;
226
+ for (const x of list) {
227
+ const side = x < 0 ? -1 : 1;
228
+ const by = eyeY + r3 * 1.65 * (single ? 1.5 : 1);
229
+ const hit = probe.front(x, by);
230
+ const half = uni ? L.sp * U + r3 : r3 * 0.85;
231
+ const V = (a, b) => new THREE.Vector3(a * r3 * side, b * r3, r3 * 0.06);
232
+ let curve;
233
+ if (bt === 'arched') curve = new THREE.QuadraticBezierCurve3(V(-0.9, -0.25), V(0.1, 0.7), V(0.95, -0.05));
234
+ else if (bt === 'angled') { curve = new THREE.CurvePath(); curve.add(new THREE.LineCurve3(V(-0.9, -0.15), V(0.35, 0.35))); curve.add(new THREE.LineCurve3(V(0.35, 0.35), V(0.9, 0.05))); }
235
+ else curve = new THREE.QuadraticBezierCurve3(new THREE.Vector3(-half, 0, 0), new THREE.Vector3(0, r3 * (bt === 'soft' ? 0.45 : 0.35), r3 * 0.12), new THREE.Vector3(half, 0, 0));
236
+ const geo = bt === 'dots' ? this.sph(r3 * 0.3, 16, 12).scale(1.3, 0.8, 0.6) : new THREE.TubeGeometry(curve, bt === 'angled' ? 24 : 16, r3 * w, 8, false);
237
+ const m = M(geo, mat('felt', pal.brow));
238
+ const g = G(m);
239
+ g.position.copy(hit.p).addScaledVector(hit.n, furLen * 0.7 + r3 * 0.05);
240
+ g.userData.base = g.position.y;
241
+ this.rig.add(g);
242
+ this.brows.push({ g, side: uni ? 0 : side });
243
+ this.part(g, true);
244
+ }
245
+ }
246
+
247
+ // boca
248
+ this.buildMouth(s, probe, X(L.cx), Y(L.mouthY), L.mw * U, bm);
249
+ this.part(this.mouthRig.g, true);
250
+
251
+ // mejillas (en el shader: también tiñen el pelo); bigotes de gato en 3D
252
+ const ct = s.cheeks.type;
253
+ if (ct === 'whiskers') {
254
+ const wm = mat('matte', '#1f2937');
255
+ for (const sd of [-1, 1]) {
256
+ for (const k of [-0.25, 0, 0.25]) {
257
+ const x0 = sd * L.sp * U * 0.7;
258
+ const y0 = eyeY - r3 * (2.4 + k * 2);
259
+ const h0 = probe.front(x0, y0);
260
+ if (!h0) continue;
261
+ const a = h0.p.clone().addScaledVector(h0.n, furLen * 0.8 + 0.004);
262
+ const b = new THREE.Vector3(sd * (L.sp * U + r3 * 2.6), eyeY - r3 * (2.2 + k * 3.2), a.z * 0.75 + furLen);
263
+ const mid = a.clone().lerp(b, 0.5);
264
+ mid.z += 0.02 * kk;
265
+ const wg = G(M(new THREE.TubeGeometry(new THREE.QuadraticBezierCurve3(a, mid, b), 8, Math.max(0.002, r3 * 0.05), 5), wm));
266
+ this.rig.add(wg);
267
+ this.part(wg, true);
268
+ }
269
+ }
270
+ } else if (ct !== 'none') {
271
+ const cx = L.sp * U + r3 * 0.45;
272
+ const cy = eyeY - r3 * 1.8;
273
+ const hit = probe.front(cx, cy);
274
+ bm.uniforms.uCheek.value.set(cx, cy, hit ? hit.p.z : 0, r3 * (ct === 'blush' ? 1.0 : 0.7));
275
+ bm.uniforms.uCheekOn.value = 1;
276
+ bm.uniforms.uCheekType.value = CHEEK_IDS[ct] || 1;
277
+ const cc = s.cheeks.color || '#ff6b8a';
278
+ if (ct === 'stars') bm.uniforms.uCheekColor.value.set('#facc15');
279
+ else if (ct === 'freckles') bm.uniforms.uCheekColor.value.set(shade(s.cheeks.color || shade(body, -0.25), -0.35));
280
+ else if (ct === 'hearts') bm.uniforms.uCheekColor.value.set(s.cheeks.color || '#ff4d7a');
281
+ else bm.uniforms.uCheekColor.value.set(cc);
282
+ }
283
+
284
+ // accesorios
285
+ for (const a of s.accessories) {
286
+ const o = this.buildAccessory(a, { L, X, Y, W, H, kk, probe, eyeY, r3, bm });
287
+ if (!o) continue;
288
+ // pivote en el centro del accesorio: escala/giro de la spec alrededor de sí mismo
289
+ o.updateMatrixWorld(true);
290
+ const c = o.userData.surface ? new THREE.Vector3() : new THREE.Box3().setFromObject(o).getCenter(new THREE.Vector3());
291
+ const piv = G(o);
292
+ o.position.sub(c);
293
+ piv.position.copy(c);
294
+ if (!o.userData.surface) {
295
+ piv.position.x += a.x * U;
296
+ piv.position.y -= a.y * U;
297
+ piv.rotation.z -= a.rot * DEG;
298
+ piv.scale.multiplyScalar(a.scale);
299
+ if (a.flip) piv.scale.x *= -1;
300
+ }
301
+ this.rig.add(piv);
302
+ if (!o.userData.surface) this.part(piv, false);
303
+ }
304
+ if (this.hideMouth) this.mouthRig.g.visible = false;
305
+
306
+ // manos (posturas de motion.js HAND_POSES; visibles con la metamorfosis «hands»)
307
+ const hg = this.sph(0.1 * kk, 24, 16).scale(1, 1.1, 0.85);
308
+ const fg = new THREE.CapsuleGeometry(0.032 * kk, 0.07 * kk, 4, 8).translate(0, 0.1 * kk, 0.02 * kk);
309
+ this.hands = [-1, 1].map((side) => {
310
+ const g = G();
311
+ if (plush) this.addFur(hg, body, g, 0.7);
312
+ else g.add(M(hg, bm.base));
313
+ const finger = plush ? this.addFur(fg, body, G(), 0.5) : M(fg, bm.base);
314
+ finger.visible = false;
315
+ g.add(finger);
316
+ g.userData.finger = finger;
317
+ g.visible = false;
318
+ this.rig.add(g);
319
+ return g;
320
+ });
321
+ // contexto de los efectos; las lágrimas bajan por la mejilla pegadas a la superficie
322
+ const tears = this.eyes.map((e) => {
323
+ const x0 = e.root.position.x;
324
+ const out = [];
325
+ for (let i = 0; i <= 6; i++) {
326
+ const h = probe.front(x0 + Math.sign(x0 || 1) * r3 * 0.05 * i, eyeY - r3 * (0.9 + i * 0.5));
327
+ const p = h.p.clone().addScaledVector(h.n, furLen + 0.006 * kk);
328
+ out.push([p.x, p.y, p.z]);
329
+ }
330
+ return out;
331
+ });
332
+ this.fxc = { W, H, D: B.depth, eyeY, r: r3, sp: L.sp * U, mouth: this.mouthRig.g.position, low: this.quality === 'low', tears };
333
+
334
+ // sombra de contacto
335
+ const sh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: shadowTexture(), transparent: true, depthWrite: false, toneMapped: false }));
336
+ sh.rotation.x = -Math.PI / 2;
337
+ sh.position.y = 0.002;
338
+ sh.scale.set(W * 1.55, B.depth * 2.6, 1);
339
+ sh.renderOrder = -1;
340
+ this.shadow = sh;
341
+ this.root.add(sh);
342
+
343
+ // encuadre en reposo
344
+ this.rig.updateMatrixWorld(true);
345
+ // solo lo visible (la cavidad de la boca y las manos ocultas no cuentan)
346
+ const bounds = (this.bounds = new THREE.Box3());
347
+ const tmp = new THREE.Box3();
348
+ this.rig.traverseVisible((o) => {
349
+ if (!o.isMesh || o.isInstancedMesh || o.userData.noBounds) return;
350
+ if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
351
+ bounds.union(tmp.copy(o.geometry.boundingBox).applyMatrix4(o.matrixWorld));
352
+ });
353
+ bounds.expandByScalar(this.furLen);
354
+ this.shells = -1;
355
+ this.setShells(this.maxShells);
356
+ }
357
+
358
+ /** Registra una pieza que sigue a la silueta en shape:<id> (face: pegada a la superficie). */
359
+ part(o, face) {
360
+ this.parts.push({ o, p0: o.position.clone(), face, d: new THREE.Vector3() });
361
+ }
362
+
363
+ /** z de la cara frontal en (x, y) desde una rejilla perezosa (null fuera de la silueta). */
364
+ frontZ(x, y) {
365
+ const n = 12;
366
+ const W = this.W;
367
+ const H = this.H;
368
+ if (!this.zGrid) {
369
+ const gz = (this.zGrid = new Float32Array(n * n));
370
+ for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) gz[j * n + i] = this.probe.front((i / (n - 1) - 0.5) * W, (j / (n - 1)) * H)?.p.z ?? NaN;
371
+ }
372
+ const fi = clamp((x / W + 0.5) * (n - 1), 0, n - 1);
373
+ const fj = clamp((y / H) * (n - 1), 0, n - 1);
374
+ const i = Math.min(n - 2, Math.floor(fi));
375
+ const j = Math.min(n - 2, Math.floor(fj));
376
+ const u = fi - i;
377
+ const v = fj - j;
378
+ const g = this.zGrid;
379
+ const a = g[j * n + i], b = g[j * n + i + 1], c = g[(j + 1) * n + i], d = g[(j + 1) * n + i + 1];
380
+ if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c) || Number.isNaN(d)) {
381
+ const near = g[Math.round(fj) * n + Math.round(fi)];
382
+ return Number.isNaN(near) ? null : near;
383
+ }
384
+ return lerp(lerp(a, b, u), lerp(c, d, u), v);
385
+ }
386
+
387
+ /** Geometrías con resolución según la calidad (la multitud usa menos segmentos). */
388
+ sph(r, w = 32, h = 16, ...rest) {
389
+ const q = this.q;
390
+ return new THREE.SphereGeometry(r, Math.max(8, Math.round(w * q)), Math.max(6, Math.round(h * q)), ...rest);
391
+ }
392
+
393
+ tor(r, t, rs = 12, ts = 48, arc = TAU) {
394
+ const q = this.q;
395
+ return new THREE.TorusGeometry(r, t, Math.max(4, Math.round(rs * q)), Math.max(8, Math.round(ts * q)), arc);
396
+ }
397
+
398
+ /** Capas de pelo (InstancedMesh) para una geometría con materiales de bodyMaterials. */
399
+ addShells(geo, bm, parent) {
400
+ const inst = new THREE.InstancedMesh(geo, bm.shell, this.maxShells);
401
+ inst.frustumCulled = false;
402
+ inst.userData.ownMaterial = true;
403
+ parent.add(inst);
404
+ this.furs.push({ inst, u: bm.uniforms });
405
+ return inst;
406
+ }
407
+
408
+ /** Pieza peluda (accesorio, mano, pompón): sólido + capas. */
409
+ addFur(geo, color, parent, lenK = 0.8) {
410
+ const bm = bodyMaterials({ color, patternColor: color, pattern: 'none', finish: 'clay', plush: true, furLen: this.furLen * lenK || 0.04, density: this.density || 60 });
411
+ bm.uniforms.uDims.value.set(1, 1e-3, -9, 0.01);
412
+ const base = new THREE.Mesh(geo, bm.base);
413
+ base.userData.ownMaterial = true;
414
+ parent.add(base);
415
+ this.addShells(geo, bm, parent);
416
+ return parent;
417
+ }
418
+
419
+ setShells(n) {
420
+ n = Math.max(0, Math.min(this.maxShells, Math.round(n)));
421
+ if (n === this.shells) return;
422
+ this.shells = n;
423
+ for (const f of this.furs) {
424
+ f.inst.count = n;
425
+ f.u.uShells.value = Math.max(1, n);
426
+ }
427
+ }
428
+
429
+ buildEye(type, r, side) {
430
+ const e = this.spec.eyes;
431
+ const root = G();
432
+ const main = G();
433
+ const gaze = G();
434
+ root.add(main);
435
+ main.add(gaze);
436
+ const ink = mat('gloss', e.ink);
437
+ let t = type;
438
+ if (t === 'wink') t = side > 0 ? 'happy' : 'round';
439
+ let noBlink = false;
440
+ if (BEAD.has(t)) {
441
+ const k = t === 'kawaii' ? 1.15 : t === 'dot' ? 0.75 : t === 'button' ? 1 : 0.9;
442
+ const sc = t === 'bean' ? [0.55, 1.2, 0.5] : t === 'dot' ? [0.8, 1.05, 0.6] : [0.92, 1, 0.6];
443
+ gaze.add(M(this.sph(r * k, 32, 24), ink, [0, 0, -r * 0.18], null, sc));
444
+ if (t === 'button') gaze.add(M(this.tor(r * 0.72, r * 0.07, 8, 32), mat('gloss', shade(e.ink, 0.25)), [0, 0, r * 0.45]));
445
+ if (SHINY.has(t)) {
446
+ gaze.add(M(this.sph(r * 0.2, 12, 8), mat('emissive', '#ffffff'), [-r * 0.32, r * 0.36, r * 0.38]));
447
+ gaze.add(M(this.sph(r * 0.09, 10, 6), mat('emissive', '#ffffff'), [r * 0.34, -r * 0.34, r * 0.38]));
448
+ }
449
+ } else if (t === 'happy' || t === 'closed' || t === 'cross') {
450
+ noBlink = true;
451
+ if (t === 'cross') {
452
+ for (const a of [45, -45]) main.add(M(new THREE.CapsuleGeometry(r * 0.13, r * 1.3, 4, 8), mat('felt', e.ink), [0, 0, r * 0.1], [0, 0, a * DEG]));
453
+ } else {
454
+ main.add(M(this.tor(r * 0.72, r * 0.14, 10, 24, Math.PI), mat('felt', e.ink), [0, t === 'happy' ? -r * 0.25 : r * 0.25, r * 0.08], [0, 0, t === 'happy' ? 0 : Math.PI]));
455
+ }
456
+ } else if (t === 'star' || t === 'heart') {
457
+ const shp = new THREE.Shape();
458
+ if (t === 'star') {
459
+ for (let i = 0; i < 10; i++) {
460
+ const a = -Math.PI / 2 + (i * Math.PI) / 5;
461
+ const rr = i % 2 ? r * 0.5 : r * 1.1;
462
+ i ? shp.lineTo(Math.cos(a) * rr, -Math.sin(a) * rr) : shp.moveTo(Math.cos(a) * rr, -Math.sin(a) * rr);
463
+ }
464
+ } else {
465
+ const q = r * 1.05;
466
+ shp.moveTo(0, -q * 0.9);
467
+ shp.bezierCurveTo(-q * 1.4, q * 0.1, -q * 0.7, q * 1.05, 0, q * 0.35);
468
+ shp.bezierCurveTo(q * 0.7, q * 1.05, q * 1.4, q * 0.1, 0, -q * 0.9);
469
+ }
470
+ const geo = new THREE.ExtrudeGeometry(shp, { depth: r * 0.2, bevelEnabled: true, bevelThickness: r * 0.12, bevelSize: r * 0.1, bevelSegments: 3, curveSegments: 10 });
471
+ gaze.add(M(geo, mat('gloss', t === 'heart' ? '#ef3b5d' : e.iris), [0, 0, -r * 0.05]));
472
+ } else if (t === 'robot' || t === 'led' || t === 'glow' || t === 'pixel') {
473
+ if (t === 'robot') {
474
+ main.add(M(new THREE.BoxGeometry(r * 2, r * 1.6, r * 0.3), mat('plastic', '#0b1220'), [0, 0, 0]));
475
+ gaze.add(M(new THREE.BoxGeometry(r * 1.3, r * 0.9, r * 0.1), mat('emissive', e.iris), [0, 0, r * 0.16]));
476
+ } else if (t === 'led') gaze.add(M(new THREE.CapsuleGeometry(r * 0.3, r * 1.6, 4, 12), mat('emissive', e.iris), [0, 0, r * 0.05], [0, 0, Math.PI / 2]));
477
+ else gaze.add(M(this.sph(r * 0.95, 24, 16), mat('emissive', e.iris), [0, 0, -r * 0.3], null, [1, 1, 0.6]));
478
+ } else if (t === 'spiral' || t === 'dollar') {
479
+ // globo blanco + espiral que gira / «$» verde
480
+ main.add(M(this.sph(r, 32, 24), mat('gloss', '#ffffff'), [0, 0, -r * 0.2], null, [1, 1, 0.75]));
481
+ const zf = r * 0.56;
482
+ let geo;
483
+ if (t === 'spiral') {
484
+ const p = [];
485
+ for (let i = 0; i <= 48; i++) { const a = (i / 48) * Math.PI * 5; const rr = r * (0.06 + 0.78 * (i / 48)); p.push(new THREE.Vector3(Math.cos(a) * rr, Math.sin(a) * rr, zf - (i / 48) * r * 0.12)); }
486
+ geo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(p), 96, r * 0.07, 6, false);
487
+ } else {
488
+ const p = [[0.45, 0.5], [0, 0.66], [-0.45, 0.42], [-0.28, 0.1], [0.28, -0.1], [0.45, -0.42], [0, -0.66], [-0.45, -0.5]].map(([x, y]) => new THREE.Vector3(x * r * 0.6, y * r * 0.6, zf));
489
+ geo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(p), 40, r * 0.09, 6, false);
490
+ }
491
+ const sp = M(geo, mat('gloss', t === 'spiral' ? e.ink : '#16a34a'));
492
+ gaze.add(sp);
493
+ if (t === 'dollar') gaze.add(M(new THREE.CapsuleGeometry(r * 0.07, r * 0.95, 3, 6), mat('gloss', '#16a34a'), [0, 0, zf]));
494
+ else this.anims.push((sec) => { sp.rotation.z = -sec * 4 * side; });
495
+ } else {
496
+ // globo ocular: esclerótica + iris + pupila (+ párpado)
497
+ const big = t === 'googly' ? 1.15 : t === 'wide' ? 1.12 : t === 'anime' ? 1.05 : 1;
498
+ const R = r * big;
499
+ const scl = M(this.sph(R, 32, 24), mat('gloss', t === 'googly' || t === 'hypno' ? '#ffffff' : e.white), [0, 0, -R * 0.2], null, t === 'anime' ? [0.9, 1.2, 0.75] : t === 'focused' ? [1, 0.75, 0.75] : t === 'almond' ? [1.15, 0.72, 0.75] : [1, 1, 0.75]);
500
+ main.add(scl);
501
+ gaze.position.z = -R * 0.2;
502
+ if (t === 'almond') gaze.scale.set(1, 0.85, 1);
503
+ if (t === 'lashes') {
504
+ // tres pestañas arriba, hacia fuera
505
+ for (let i = 0; i < 3; i++) {
506
+ const ca = Math.cos(side > 0 ? (30 + i * 30) * DEG : (150 - i * 30) * DEG);
507
+ const sa = Math.sin((30 + i * 30) * DEG);
508
+ main.add(M(new THREE.CapsuleGeometry(R * 0.07, R * 0.32, 3, 6), mat('felt', e.ink), [ca * R * 1.05, sa * R * 1.05, R * 0.2], [0, 0, Math.atan2(sa, ca) - Math.PI / 2]));
509
+ }
510
+ }
511
+ const irisR = t === 'cat' ? 0.92 : t === 'anime' ? 0.7 : t === 'almond' ? 0.72 : t === 'wide' || t === 'googly' ? 0 : 0.58;
512
+ if (irisR) gaze.add(M(this.sph(R * irisR, 24, 16), mat('gloss', e.iris), [0, 0, R * 0.62], null, t === 'anime' ? [1, 1.3, 0.3] : [1, 1, 0.3]));
513
+ const pr = t === 'wide' ? 0.3 : t === 'googly' ? 0.55 : t === 'cat' ? 0.5 : 0.32;
514
+ gaze.add(M(this.sph(R * pr, 20, 12), ink, [0, 0, R * 0.68], null, t === 'cat' ? [0.3, 1.5, 0.3] : [1, 1, 0.32]));
515
+ gaze.add(M(this.sph(R * 0.11, 10, 6), mat('emissive', '#ffffff'), [-R * 0.22, R * 0.26, R * 0.78]));
516
+ const lid = LIDS[t];
517
+ if (lid) {
518
+ const lg = this.sph(R * 1.07, 32, 12, 0, TAU, 0, Math.PI * lid[0]);
519
+ const lm = M(lg, mat('felt', shade(this.spec.body.color, -0.06)), [0, 0, -R * 0.2], [0, 0, lid[1] * side * 0.45], [1, 1, 0.8]);
520
+ main.add(lm);
521
+ }
522
+ }
523
+ // ojos cerrados (dormir): arco ∪
524
+ const closed = M(this.tor(r * 0.72, r * 0.13, 10, 24, Math.PI), mat('felt', e.ink), [0, r * 0.25, r * 0.08], [0, 0, Math.PI]);
525
+ closed.visible = false;
526
+ root.add(closed);
527
+ return { root, main, gaze, closed, noBlink };
528
+ }
529
+
530
+ buildMouth(s, probe, mx, my, mw, bm) {
531
+ const type = s.mouth.type;
532
+ const g = G();
533
+ const hit = probe.front(mx, my);
534
+ const lift = this.plush ? this.furLen * 0.55 : 0.006;
535
+ g.position.copy(hit.p).addScaledVector(hit.n, lift);
536
+ this.rig.add(g);
537
+ if (bm.uniforms.uBald.value[2]) bm.uniforms.uBald.value[2].set(hit.p.x, hit.p.y, hit.p.z, mw * 1.1);
538
+ const mouth = (this.mouthRig = { g, type, line: null, cav: null, min: OPEN_MIN[type] || 0, bias: CURVE_BIAS[type] || 0, mw, round: null });
539
+ const lipM = mat('felt', this.pal.lip);
540
+
541
+ // línea (sonrisa/ceño) con morph targets: base recta, +1 sonrisa, -1 ceño, ajustada a la superficie
542
+ const custom = type === 'grit' || type === 'lips';
543
+ if (!OPEN_MIN[type] && !ROUND.has(type) && type !== 'beak' && !custom) {
544
+ const line = (curve) => {
545
+ const p = [];
546
+ for (let i = 0; i <= 8; i++) {
547
+ const u = i / 4 - 1;
548
+ const x = u * mw * (type === 'tiny' ? 0.5 : type === 'pout' ? 0.5 : 1);
549
+ const y = curve * mw * 0.5 * (u * u - 0.45) + (type === 'smirk' ? u * mw * 0.12 : 0) + (type === 'wavy' ? Math.sin(u * Math.PI * 2) * mw * 0.12 : 0);
550
+ const z = probe.front(mx + x, my + y).p.z - hit.p.z;
551
+ p.push(new THREE.Vector3(x, y, z));
552
+ }
553
+ return new THREE.TubeGeometry(new THREE.CatmullRomCurve3(p), 32, Math.max(0.006, mw * 0.085), 8, false);
554
+ };
555
+ const geo = line(0);
556
+ const up = line(1);
557
+ const dn = line(-1);
558
+ geo.morphAttributes.position = [up.attributes.position, dn.attributes.position];
559
+ geo.morphAttributes.normal = [up.attributes.normal, dn.attributes.normal];
560
+ up.dispose();
561
+ dn.dispose();
562
+ mouth.line = M(geo, lipM);
563
+ mouth.line.morphTargetInfluences = [0, 0];
564
+ g.add(mouth.line);
565
+ if (type === 'tongue') g.add(M(new THREE.CapsuleGeometry(mw * 0.32, mw * 0.3, 4, 12), mat('gloss', '#ff7a8a'), [mw * 0.1, -mw * 0.38, mw * 0.05], null, [1, 1, 0.45]));
566
+ }
567
+ if (ROUND.has(type)) {
568
+ mouth.round = G(
569
+ M(this.tor(mw * 0.32, mw * 0.1, 10, 24), type === 'kiss' ? mat('gloss', '#e11d48') : lipM),
570
+ M(new THREE.CircleGeometry(mw * 0.3, 20), mat('matte', '#3a0d14'), [0, 0, -0.002]),
571
+ );
572
+ if (type === 'whistle') mouth.round.position.x = mw * 0.35;
573
+ g.add(mouth.round);
574
+ }
575
+ if (type === 'beak') {
576
+ g.add(M(new THREE.ConeGeometry(mw * 0.6, mw * 1.1, 24), mat('plastic', '#f59e0b'), [0, 0, mw * 0.4], [Math.PI / 2, 0, 0], [1, 1, 0.55]));
577
+ }
578
+ // cavidad (abierta) tipo aplique de fieltro: oscuro + lengua + dientes
579
+ if (type === 'grit') {
580
+ // dientes apretados: placa blanca con rayas oscuras (se abre en vertical al hablar)
581
+ const gr = G(M(new THREE.BoxGeometry(mw * 2, mw * 0.5, mw * 0.12), mat('plastic', '#ffffff')));
582
+ const lm = mat('matte', '#1f2937');
583
+ gr.add(M(new THREE.BoxGeometry(mw * 2, mw * 0.05, mw * 0.02), lm, [0, 0, mw * 0.065]));
584
+ for (let i = 1; i < 5; i++) gr.add(M(new THREE.BoxGeometry(mw * 0.05, mw * 0.5, mw * 0.02), lm, [(i / 5 - 0.5) * mw * 2, 0, mw * 0.065]));
585
+ gr.add(M(this.tor(1, 0.06, 6, 4).rotateZ(Math.PI / 4).scale(mw * 1.45, mw * 0.36, mw * 0.2), lipM));
586
+ mouth.grit = gr;
587
+ g.add(gr);
588
+ }
589
+ if (type === 'lips') {
590
+ // labios: arriba dos lóbulos (arco de cupido), abajo uno; se separan al abrir
591
+ const lc = mat('gloss', s.mouth.color || '#e11d48');
592
+ const up = G();
593
+ for (const sx of [-1, 1]) up.add(M(this.sph(1, 20, 12), lc, [sx * mw * 0.42, 0, 0], [0, 0, sx * 0.25], [mw * 0.55, mw * 0.22, mw * 0.2]));
594
+ const lo = M(this.sph(1, 24, 12), lc, [0, 0, 0], null, [mw * 0.85, mw * 0.28, mw * 0.22]);
595
+ const hole = M(this.sph(1, 20, 10), mat('gloss', '#3a0d14'), [0, 0, -mw * 0.05], null, [mw * 0.7, 0.001, mw * 0.1]);
596
+ mouth.lips = { up, lo, hole };
597
+ g.add(up, lo, hole);
598
+ }
599
+ if (type !== 'beak' && !ROUND.has(type) && !custom) {
600
+ const cav = G();
601
+ const sq = type === 'robot' || type === 'square';
602
+ cav.add(M(sq ? new THREE.BoxGeometry(2, 2, 1) : this.sph(1, 28, 16), mat('gloss', '#3a0d14'), [0, 0, 0], null, sq ? [1, 0.5, 0.5] : [1, 1, 1]));
603
+ const tongue = M(this.sph(0.6, 20, 12), mat('felt', '#ff7a8a'), [0, -0.45, 0.35], null, [1, 0.55, 0.5]);
604
+ cav.add(tongue);
605
+ if (TEETH.has(type)) cav.add(M(new THREE.BoxGeometry(type === 'buck' ? 0.6 : 1.4, 0.3, 0.3), mat('plastic', '#ffffff'), [0, 0.68, 0.55]));
606
+ if (type === 'vampire' || type === 'monster') for (const sx of [-0.5, 0.5]) cav.add(M(new THREE.ConeGeometry(0.14, 0.4, 8), mat('plastic', '#ffffff'), [sx, 0.62, 0.62], [Math.PI, 0, 0]));
607
+ cav.add(M(this.tor(1, 0.09, 8, 32), lipM, [0, 0, 0.1], null, [1, 1, 1]));
608
+ cav.visible = false;
609
+ mouth.cav = cav;
610
+ g.add(cav);
611
+ }
612
+ }
613
+
614
+ /** Accesorios 3D (subconjunto del catálogo 2D); devuelve un Object3D ya en su ancla o null. */
615
+ buildAccessory(a, c) {
616
+ const { kk, probe, H, W, eyeY, r3, Y, X, L, bm } = c;
617
+ const u2 = 0.01 * kk; // 1 px del dibujo 2D
618
+ const col = a.color;
619
+ const col2 = a.color2;
620
+ const id = a.id;
621
+ const top = probe.top(0, 0).p;
622
+ const headAt = (dy) => { const s = probe.section(top.y - dy * kk); return s; };
623
+ const fur = (geo, color, k = 0.8) => this.addFur(geo, color, G(), k);
624
+ const g = G();
625
+ const onTop = (o, sink = 0.05) => { g.position.set(top.x, top.y - sink * kk, 0); g.add(o); return g; };
626
+ const capFor = (dy) => {
627
+ const s = headAt(dy);
628
+ return { w: s.w, d: s.d, y: top.y - dy * kk };
629
+ };
630
+ switch (id) {
631
+ case 'crown': case 'tiara': {
632
+ const m = mat('metal', col);
633
+ const n = id === 'crown' ? 6 : 3;
634
+ const R = 0.24 * kk;
635
+ if (id === 'crown') g.add(M(new THREE.CylinderGeometry(R, R * 1.05, 0.12 * kk, 32, 1, true), m));
636
+ else g.add(M(this.tor(R, 0.02 * kk, 8, 32, Math.PI), m, [0, 0, 0], [-Math.PI / 2 + 0.3, 0, 0]));
637
+ for (let i = 0; i < n; i++) {
638
+ const ang = id === 'crown' ? (i / n) * TAU : (i / (n - 1)) * Math.PI;
639
+ const p = [Math.cos(ang) * R, 0.1 * kk, id === 'crown' ? Math.sin(ang) * R : Math.sin(ang) * R * 0.3 + 0.05 * kk];
640
+ g.add(M(new THREE.ConeGeometry(0.05 * kk, 0.12 * kk, 12), m, p));
641
+ g.add(M(this.sph(0.025 * kk, 12, 8), mat('gloss', id === 'crown' ? '#dc2626' : '#60a5fa'), [p[0], p[1] + 0.07 * kk, p[2]]));
642
+ }
643
+ return onTop(G(...g.children.splice(0)), 0.02);
644
+ }
645
+ case 'tophat': {
646
+ const h = G(
647
+ M(new THREE.CylinderGeometry(0.36 * kk, 0.36 * kk, 0.025 * kk, 40), mat('felt', col)),
648
+ M(new THREE.CylinderGeometry(0.22 * kk, 0.24 * kk, 0.4 * kk, 40), mat('felt', col), [0, 0.2 * kk, 0]),
649
+ M(new THREE.CylinderGeometry(0.245 * kk, 0.245 * kk, 0.07 * kk, 40), mat('felt', col2 === '#ffffff' ? '#dc2626' : col2), [0, 0.05 * kk, 0]),
650
+ );
651
+ h.rotation.z = -0.12;
652
+ return onTop(h, 0.06);
653
+ }
654
+ case 'beanie': case 'santa': case 'cap': case 'hardhat': case 'viking': case 'beret': {
655
+ const cp = capFor(id === 'beret' ? 0.14 : 0.24);
656
+ const dome = this.sph(1, 40, 20, 0, TAU, 0, Math.PI / 2).scale(cp.w * 1.08, top.y - cp.y + 0.05 * kk, cp.d * 1.1);
657
+ const o = G();
658
+ if (id === 'beanie' || id === 'santa') {
659
+ o.add(fur(dome, col, 0.7));
660
+ o.add(fur(this.sph(0.09 * kk, 20, 14), id === 'santa' ? '#fafafa' : col2, 1));
661
+ o.children[1].position.y = top.y - cp.y + 0.08 * kk;
662
+ if (id === 'santa') o.add(fur(this.tor(1, 0.12, 12, 40).rotateX(Math.PI / 2).scale(cp.w * 1.08, kk * 0.5, cp.d * 1.1), '#fafafa', 0.9));
663
+ } else if (id === 'beret') {
664
+ o.add(M(dome, mat('felt', col), null, [0, 0, -0.25], [1.25, 0.45, 1.2]));
665
+ } else {
666
+ const m = id === 'viking' ? mat('metal', '#a3a3a3') : id === 'cap' ? mat('felt', col) : mat('plastic', col);
667
+ o.add(M(dome, m));
668
+ if (id === 'cap') o.add(M(new THREE.CylinderGeometry(cp.w * 0.75, cp.w * 0.75, 0.02 * kk, 32, 1, false, -Math.PI / 2, Math.PI), mat('felt', col2 === '#ffffff' ? shade(col, -0.2) : col2), [0, 0.01, cp.d * 0.9], null, [1, 1, 0.9]));
669
+ if (id === 'hardhat') o.add(M(new THREE.CylinderGeometry(cp.w * 1.25, cp.w * 1.25, 0.02 * kk, 40), mat('plastic', col), [0, 0, 0.02]));
670
+ if (id === 'viking') for (const sx of [-1, 1]) o.add(M(new THREE.ConeGeometry(0.06 * kk, 0.3 * kk, 16), mat('plastic', '#fef3c7'), [sx * cp.w * 1.05, (top.y - cp.y) * 0.5, 0], [0, 0, -sx * 1.0]));
671
+ }
672
+ o.position.y = cp.y;
673
+ return o;
674
+ }
675
+ case 'hair': {
676
+ const hg = this.sph(1, 36, 20).scale(0.3 * kk, 0.17 * kk, 0.26 * kk);
677
+ const o = fur(hg, col, 1.3);
678
+ o.position.set(0, top.y + 0.02 * kk, 0.04 * kk);
679
+ o.rotation.x = 0.15;
680
+ return o;
681
+ }
682
+ case 'party': case 'wizard': {
683
+ const h = id === 'party' ? 0.42 : 0.62;
684
+ const o = G(M(new THREE.ConeGeometry((id === 'party' ? 0.16 : 0.27) * kk, h * kk, 32), mat(id === 'party' ? 'plastic' : 'felt', col), [0, (h / 2) * kk, 0]));
685
+ if (id === 'party') o.add(fur(this.sph(0.06 * kk, 16, 10), col2, 1));
686
+ if (id === 'party') o.children[1].position.y = h * kk;
687
+ if (id === 'wizard') o.add(M(new THREE.CylinderGeometry(0.42 * kk, 0.42 * kk, 0.02 * kk, 40), mat('felt', col)));
688
+ o.rotation.z = -0.15;
689
+ return onTop(o, 0.04);
690
+ }
691
+ case 'cowboy': {
692
+ const o = G(
693
+ M(this.sph(1, 40, 10, 0, TAU, Math.PI * 0.35, Math.PI * 0.3), mat('felt', col), [0, 0.05 * kk, 0], null, [0.55 * kk, 0.12 * kk, 0.45 * kk]),
694
+ M(new THREE.CapsuleGeometry(0.2 * kk, 0.1 * kk, 6, 24), mat('felt', col), [0, 0.15 * kk, 0], null, [1, 1, 0.8]),
695
+ M(new THREE.CylinderGeometry(0.205 * kk, 0.205 * kk, 0.04 * kk, 32), mat('felt', shade(col, -0.35)), [0, 0.1 * kk, 0]),
696
+ );
697
+ return onTop(o, 0.05);
698
+ }
699
+ case 'chef': {
700
+ const o = G(M(new THREE.CylinderGeometry(0.2 * kk, 0.2 * kk, 0.14 * kk, 32), mat('felt', '#fafafa')));
701
+ for (const [x, y, z, r] of [[0, 0.2, 0, 0.17], [-0.13, 0.15, 0, 0.12], [0.13, 0.15, 0, 0.12], [0, 0.15, 0.1, 0.12]]) o.add(M(this.sph(r * kk, 20, 14), mat('felt', '#fafafa'), [x * kk, y * kk, z * kk]));
702
+ return onTop(o, 0.04);
703
+ }
704
+ case 'grad': {
705
+ const o = G(
706
+ M(new THREE.CylinderGeometry(0.17 * kk, 0.19 * kk, 0.1 * kk, 32), mat('felt', col)),
707
+ M(new THREE.BoxGeometry(0.46 * kk, 0.025 * kk, 0.46 * kk), mat('felt', col), [0, 0.06 * kk, 0], [0, Math.PI / 4, 0]),
708
+ M(new THREE.CylinderGeometry(0.008 * kk, 0.008 * kk, 0.16 * kk, 6), mat('felt', col2 === '#ffffff' ? '#facc15' : col2), [0.2 * kk, -0.01 * kk, 0]),
709
+ );
710
+ return onTop(o, 0.03);
711
+ }
712
+ case 'bow': case 'flower': {
713
+ const o = G();
714
+ if (id === 'bow') {
715
+ for (const sx of [-1, 1]) o.add(M(new THREE.ConeGeometry(0.08 * kk, 0.15 * kk, 20), mat('plastic', col), [sx * 0.08 * kk, 0, 0], [0, 0, sx * Math.PI / 2], [1, 1, 0.5]));
716
+ o.add(M(this.sph(0.04 * kk, 16, 10), mat('plastic', col)));
717
+ } else {
718
+ for (let i = 0; i < 5; i++) { const an = (i / 5) * TAU; o.add(M(this.sph(0.055 * kk, 16, 10), mat('felt', col), [Math.cos(an) * 0.06 * kk, Math.sin(an) * 0.06 * kk, 0], null, [1, 1, 0.4])); }
719
+ o.add(M(this.sph(0.04 * kk, 16, 10), mat('felt', '#facc15'), [0, 0, 0.01 * kk]));
720
+ }
721
+ o.position.set(0.14 * kk, top.y - 0.02 * kk, 0.06 * kk);
722
+ o.rotation.z = -0.3;
723
+ return o;
724
+ }
725
+ case 'halo': {
726
+ const o = M(this.tor(0.22 * kk, 0.025 * kk, 12, 48), mat('emissive', col), null, [Math.PI / 2, 0, 0]);
727
+ o.position.y = top.y + 0.14 * kk;
728
+ return o;
729
+ }
730
+ case 'horns': case 'catears': case 'bunny': case 'antenna': case 'sprout': {
731
+ for (const sx of id === 'sprout' ? [0] : [-1, 1]) {
732
+ const s = G();
733
+ if (id === 'horns') s.add(M(new THREE.ConeGeometry(0.06 * kk, 0.22 * kk, 20), mat('gloss', col), [0, 0.09 * kk, 0]));
734
+ if (id === 'catears') s.add(fur(new THREE.ConeGeometry(0.12 * kk, 0.22 * kk, 24).scale(1, 1, 0.45).translate(0, 0.09 * kk, 0), col, 0.8));
735
+ if (id === 'bunny') {
736
+ s.add(fur(this.sph(1, 24, 16).scale(0.08 * kk, 0.26 * kk, 0.05 * kk).translate(0, 0.22 * kk, 0), col, 0.8));
737
+ s.add(M(this.sph(1, 20, 12), mat('felt', col2), [0, 0.22 * kk, 0.035 * kk], null, [0.045 * kk, 0.18 * kk, 0.02 * kk]));
738
+ }
739
+ if (id === 'antenna') {
740
+ s.add(M(new THREE.CylinderGeometry(0.01 * kk, 0.01 * kk, 0.22 * kk, 8), mat('plastic', col), [0, 0.11 * kk, 0]));
741
+ s.add(M(this.sph(0.045 * kk, 16, 10), mat('emissive', col2), [0, 0.23 * kk, 0]));
742
+ }
743
+ if (id === 'sprout') {
744
+ s.add(M(new THREE.CylinderGeometry(0.012 * kk, 0.015 * kk, 0.16 * kk, 8), mat('plastic', '#16a34a'), [0, 0.08 * kk, 0]));
745
+ for (const lx of [-1, 1]) s.add(M(this.sph(1, 16, 10), mat('plastic', col), [lx * 0.07 * kk, 0.17 * kk, 0], [0, 0, lx * 0.5], [0.08 * kk, 0.035 * kk, 0.02 * kk]));
746
+ }
747
+ const x = sx * (id === 'antenna' ? 0.12 : 0.22) * kk;
748
+ const t = probe.top(x, 0).p;
749
+ s.position.set(x, t.y - 0.03 * kk, 0);
750
+ s.rotation.z = -sx * (id === 'bunny' ? 0.15 : id === 'catears' ? 0.3 : 0.35);
751
+ g.add(s);
752
+ }
753
+ return g;
754
+ }
755
+ case 'headphones': case 'headband': case 'bandana': {
756
+ const y = id === 'bandana' ? top.y - 0.12 * kk : eyeY + (id === 'headband' ? r3 * 2.2 : 0);
757
+ const s = probe.section(y);
758
+ if (id === 'headphones') {
759
+ const R = s.w + 0.04 * kk;
760
+ g.add(M(this.tor(R, 0.025 * kk, 10, 40, Math.PI), mat('plastic', col), [0, y, 0], null, [1, (top.y - y + 0.06 * kk) / R, 1]));
761
+ for (const sx of [-1, 1]) g.add(M(new THREE.CylinderGeometry(0.1 * kk, 0.1 * kk, 0.07 * kk, 28), mat('plastic', col2), [sx * R, y, 0], [0, 0, Math.PI / 2]));
762
+ return g;
763
+ }
764
+ g.add(M(this.tor(1, 0.05, 10, 48), mat('felt', col), [0, y, 0], [Math.PI / 2, 0, 0], [s.w * 1.04, s.d * 1.06, kk * 0.9]));
765
+ return g;
766
+ }
767
+ case 'glasses': case 'square': case 'nerd': case 'sunglasses': case 'aviator': case 'heartglasses': case 'starglasses': case '3d': case 'monocle': case 'goggles': case 'visor': case 'eyepatch': {
768
+ const ez = Math.max(...this.eyes.map((e) => e.root.position.z)) + r3 * 0.55;
769
+ const rr = r3 * (id === 'nerd' ? 1.7 : 1.5);
770
+ const sides = id === 'monocle' ? [1] : id === 'eyepatch' ? [-1] : [-1, 1];
771
+ const fm = mat(id === '3d' ? 'plastic' : 'gloss', col);
772
+ for (const sx of sides) {
773
+ const x = sx * L.sp * U;
774
+ if (id === 'eyepatch') { g.add(M(this.sph(rr * 0.9, 24, 12), mat('felt', col), [x, eyeY, ez - rr * 0.5], null, [1, 0.85, 0.35])); continue; }
775
+ if (id === 'visor') continue;
776
+ const square = id === 'square' || id === 'nerd' || id === '3d';
777
+ const ring = this.tor(rr, rr * 0.13, 8, square ? 4 : 32);
778
+ g.add(M(ring, fm, [x, eyeY, ez], [0, 0, square ? Math.PI / 4 : 0], square ? [1.15, 0.85, 1] : [1, 1, 1]));
779
+ const tint = id === 'sunglasses' || id === 'aviator' ? '#0f172a' : id === 'goggles' ? col2 : id === '3d' ? (sx < 0 ? '#ef4444' : '#22d3ee') : id === 'heartglasses' ? '#f9a8d4' : id === 'starglasses' ? '#fde68a' : null;
780
+ if (tint) {
781
+ const lens = M(new THREE.CircleGeometry(rr, square ? 4 : 32), new THREE.MeshPhysicalMaterial({ color: tint, roughness: 0.05, clearcoat: 1, transparent: true, opacity: id === 'sunglasses' ? 0.92 : 0.55 }), [x, eyeY, ez - 0.002], [0, 0, square ? Math.PI / 4 : 0], square ? [1.15, 0.85, 1] : [1, 1, 1]);
782
+ g.add(lens);
783
+ }
784
+ }
785
+ if (id === 'visor') g.add(M(new THREE.CapsuleGeometry(rr * 0.8, L.sp * U * 2.2, 6, 16), new THREE.MeshPhysicalMaterial({ color: col, roughness: 0.05, clearcoat: 1, transparent: true, opacity: 0.75 }), [0, eyeY, ez], [0, 0, Math.PI / 2], [1, 1, 0.3]));
786
+ if (sides.length === 2 && id !== 'visor') g.add(M(new THREE.CylinderGeometry(rr * 0.1, rr * 0.1, L.sp * U * 2 - rr * 2, 8), fm, [0, eyeY + rr * 0.15, ez], [0, 0, Math.PI / 2]));
787
+ if (id === 'eyepatch') { const s = probe.section(eyeY + r3); g.add(M(this.tor(1, 0.02, 6, 48), mat('felt', col), [0, eyeY + r3 * 0.4, 0], [Math.PI / 2, -0.35, 0], [s.w * 1.03, s.d * 1.05, kk])); }
788
+ return g;
789
+ }
790
+ case 'mustache': case 'handlebar': case 'beard': case 'wizardbeard': case 'goatee': {
791
+ const my = Y(L.mouthY);
792
+ const hit = probe.front(0, my);
793
+ if (id === 'mustache' || id === 'handlebar') {
794
+ const o = G();
795
+ for (const sx of [-1, 1]) o.add(fur(this.sph(1, 20, 12).scale(0.1 * kk, 0.04 * kk, 0.04 * kk).rotateZ(sx * (id === 'handlebar' ? -0.4 : 0.25)).translate(sx * 0.085 * kk, 0, 0), col, 0.6));
796
+ o.position.copy(hit.p).add(new THREE.Vector3(0, L.mw * U * 0.5, this.furLen * 0.6));
797
+ return o;
798
+ }
799
+ const long = id === 'wizardbeard' ? 1.8 : id === 'goatee' ? 0.6 : 1;
800
+ const wB = id === 'goatee' ? 0.08 : 0.3;
801
+ const o = fur(this.sph(1, 28, 18).scale(wB * kk, 0.16 * long * kk, 0.12 * kk), col, 1.1);
802
+ o.position.set(0, my - 0.1 * long * kk, hit.p.z - 0.04 * kk);
803
+ return o;
804
+ }
805
+ case 'clownnose': case 'nose': case 'pignose': {
806
+ const ny = Y(L.anchors.nose.y);
807
+ const hit = probe.front(0, ny);
808
+ const o = G();
809
+ if (id === 'clownnose') o.add(M(this.sph(0.075 * kk, 24, 16), mat('gloss', col)));
810
+ else if (id === 'nose') o.add(M(this.sph(0.035 * kk, 20, 12), mat('gloss', col), null, null, [1.3, 0.9, 0.8]));
811
+ else {
812
+ o.add(M(new THREE.CylinderGeometry(0.08 * kk, 0.08 * kk, 0.05 * kk, 28), mat('felt', col), null, [Math.PI / 2, 0, 0], [1, 1, 0.75]));
813
+ for (const sx of [-1, 1]) o.add(M(this.sph(0.018 * kk, 12, 8), mat('matte', shade(col, -0.5)), [sx * 0.03 * kk, 0, 0.026 * kk]));
814
+ }
815
+ o.position.copy(hit.p).addScaledVector(hit.n, this.furLen * 0.6 + 0.02 * kk);
816
+ return o;
817
+ }
818
+ case 'bowtie': case 'tie': case 'medal': case 'bell': case 'scarf': case 'pearls': case 'ruff': {
819
+ const ny = Y(L.anchors.neck.y);
820
+ const s = probe.section(ny);
821
+ const z = s.z + this.furLen * 0.6;
822
+ if (id === 'scarf' || id === 'ruff') {
823
+ g.add(fur(this.tor(1, 0.1, 14, 48).rotateX(Math.PI / 2).scale(s.w * 1.05, kk * (id === 'ruff' ? 0.9 : 0.75), s.d * 1.08), col, 0.9));
824
+ if (id === 'scarf') g.add(fur(new THREE.BoxGeometry(0.1 * kk, 0.26 * kk, 0.04 * kk, 2, 6, 2).translate(0.12 * kk, -0.12 * kk, z + 0.02 * kk), col, 0.9));
825
+ g.position.y = ny;
826
+ return g;
827
+ }
828
+ if (id === 'pearls') {
829
+ for (let i = 0; i <= 12; i++) { const an = Math.PI * (i / 12); g.add(M(this.sph(0.025 * kk, 12, 8), mat('gloss', col), [Math.cos(an) * s.w * 1.02, ny - Math.sin(an) * 0.03 * kk, Math.sin(an) * s.d * 1.05])); }
830
+ return g;
831
+ }
832
+ const o = G();
833
+ if (id === 'bowtie') {
834
+ for (const sx of [-1, 1]) o.add(M(new THREE.ConeGeometry(0.07 * kk, 0.14 * kk, 20), mat('felt', col), [sx * 0.065 * kk, 0, 0], [0, 0, sx * Math.PI / 2], [1, 1, 0.5]));
835
+ o.add(M(this.sph(0.035 * kk, 16, 10), mat('felt', shade(col, -0.2))));
836
+ } else if (id === 'tie') {
837
+ o.add(M(this.sph(0.035 * kk, 16, 10), mat('felt', shade(col, -0.2))));
838
+ o.add(M(new THREE.ConeGeometry(0.06 * kk, 0.3 * kk, 4), mat('felt', col), [0, -0.17 * kk, 0], [Math.PI, Math.PI / 4, 0], [1, 1, 0.35]));
839
+ } else if (id === 'medal') {
840
+ o.add(M(new THREE.CylinderGeometry(0.06 * kk, 0.06 * kk, 0.015 * kk, 32), mat('metal', col), [0, -0.08 * kk, 0], [Math.PI / 2, 0, 0]));
841
+ o.add(M(new THREE.BoxGeometry(0.04 * kk, 0.1 * kk, 0.005 * kk), mat('felt', col2), [0, -0.02 * kk, 0]));
842
+ } else {
843
+ o.add(M(this.sph(0.06 * kk, 20, 14), mat('metal', col), [0, -0.05 * kk, 0]));
844
+ g.add(M(this.tor(1, 0.045, 10, 48), mat('felt', col2), [0, ny, 0], [Math.PI / 2, 0, 0], [s.w * 1.03, s.d * 1.05, kk]));
845
+ }
846
+ o.position.set(0, ny, z);
847
+ g.add(o);
848
+ return g;
849
+ }
850
+ case 'cape': case 'superhero': {
851
+ const s = probe.section(H * 0.5);
852
+ const geo = new THREE.CylinderGeometry(s.w * 1.02, s.w * 1.35, H * 0.75, 32, 1, true, Math.PI / 2 + 0.35, Math.PI - 0.7);
853
+ geo.scale(1, 1, (s.d / s.w) * 1.1);
854
+ const m = new THREE.MeshPhysicalMaterial({ color: col, roughness: 0.6, sheen: 0.6, side: THREE.DoubleSide });
855
+ g.add(M(geo, m, [0, H * 0.4, 0]));
856
+ return g;
857
+ }
858
+ case 'wings': case 'batwings': {
859
+ const s = probe.section(H * 0.5);
860
+ for (const sx of [-1, 1]) {
861
+ const w = G(M(this.sph(1, 24, 12), id === 'wings' ? mat('felt', col) : mat('plastic', col), [sx * 0.2 * kk, 0.08 * kk, 0], null, [0.24 * kk, 0.15 * kk, 0.03 * kk]));
862
+ w.position.set(sx * s.w * 0.5, H * 0.55, -s.d * 0.85);
863
+ w.rotation.set(0, sx * 0.5, sx * 0.35);
864
+ g.add(w);
865
+ }
866
+ return g;
867
+ }
868
+ case 'tail': {
869
+ const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(0, 0.12 * kk, -0.1), new THREE.Vector3(0.12 * kk, 0.1 * kk, -0.45 * kk), new THREE.Vector3(0.25 * kk, 0.32 * kk, -0.5 * kk)]);
870
+ g.add(M(new THREE.TubeGeometry(curve, 24, 0.02 * kk, 8), mat('gloss', col)));
871
+ g.add(M(new THREE.ConeGeometry(0.06 * kk, 0.1 * kk, 4), mat('gloss', col), [0.25 * kk, 0.36 * kk, -0.5 * kk], null, [1, 1, 0.4]));
872
+ return g;
873
+ }
874
+ case 'jetpack': {
875
+ const s = probe.section(H * 0.5);
876
+ for (const sx of [-1, 1]) {
877
+ g.add(M(new THREE.CapsuleGeometry(0.07 * kk, 0.22 * kk, 6, 16), mat('metal', col), [sx * 0.09 * kk, H * 0.5, -s.d - 0.04 * kk]));
878
+ g.add(M(new THREE.ConeGeometry(0.05 * kk, 0.14 * kk, 16), mat('emissive', '#f97316'), [sx * 0.09 * kk, H * 0.5 - 0.22 * kk, -s.d - 0.04 * kk], [Math.PI, 0, 0]));
879
+ }
880
+ return g;
881
+ }
882
+ case 'pirate': {
883
+ // tricornio: copa + tres alas levantadas + calavera
884
+ const cp = capFor(0.2);
885
+ const hh = top.y - cp.y + 0.06 * kk;
886
+ const fm = mat('felt', col);
887
+ const o = G(M(this.sph(1, 32, 16, 0, TAU, 0, Math.PI / 2), fm, null, null, [cp.w * 0.95, hh * 1.1, cp.d]));
888
+ for (let i = 0; i < 3; i++) {
889
+ const f = G(M(this.sph(1, 24, 10), fm, [0, 0, cp.d * 0.85], [-0.75, 0, 0], [cp.w * 0.95, 0.1 * kk, 0.07 * kk]));
890
+ f.rotation.y = (i / 3) * TAU;
891
+ f.position.y = hh * 0.12;
892
+ o.add(f);
893
+ }
894
+ const bone = mat('felt', col2);
895
+ const sk = G(M(this.sph(0.045 * kk, 16, 10), bone));
896
+ for (const a of [0.7, -0.7]) sk.add(M(new THREE.CapsuleGeometry(0.012 * kk, 0.11 * kk, 3, 6), bone, [0, 0, -0.01 * kk], [0, 0, a]));
897
+ for (const sx of [-1, 1]) sk.add(M(this.sph(0.011 * kk, 8, 6), mat('matte', '#111827'), [sx * 0.016 * kk, 0.005 * kk, 0.04 * kk]));
898
+ sk.position.set(0, hh * 0.5, cp.d * 0.97);
899
+ sk.rotation.x = -0.3;
900
+ o.add(sk);
901
+ o.position.y = cp.y;
902
+ return o;
903
+ }
904
+ case 'pipe': case 'lollipop': {
905
+ const my = Y(L.mouthY);
906
+ const p0 = probe.front(L.mw * U * 0.5, my).p;
907
+ const o = G();
908
+ o.position.copy(p0);
909
+ o.position.z += this.furLen * 0.5;
910
+ const stick = (a, b, r, m) => {
911
+ const d = b.clone().sub(a);
912
+ const cy = M(new THREE.CylinderGeometry(r, r, d.length(), 8), m);
913
+ cy.position.copy(a).addScaledVector(d, 0.5);
914
+ cy.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize());
915
+ return cy;
916
+ };
917
+ const V = (x, y, z) => new THREE.Vector3(x * u2, y * u2, z * u2);
918
+ if (id === 'pipe') {
919
+ o.add(stick(V(0, 0, 0), V(22, -10, 14), 2 * u2, mat('gloss', '#1f2937')));
920
+ const bowl = V(26, -16, 16);
921
+ o.add(M(new THREE.CylinderGeometry(8 * u2, 7 * u2, 16 * u2, 20), mat('gloss', col), bowl.toArray()));
922
+ o.add(M(new THREE.CircleGeometry(6.5 * u2, 16), mat('matte', '#292524'), [bowl.x, bowl.y + 8.05 * u2, bowl.z], [-Math.PI / 2, 0, 0]));
923
+ const smoke = [0, 1, 2].map(() => M(this.sph(1, 10, 8), mat('felt', '#d6d3d1')));
924
+ o.add(...smoke);
925
+ this.anims.push((sec) => smoke.forEach((b, i) => {
926
+ const q = (sec * 0.5 + i / 3) % 1;
927
+ b.position.set(bowl.x + Math.sin(q * 7 + i) * 4 * u2, bowl.y + (10 + q * 40) * u2, bowl.z);
928
+ b.scale.setScalar(Math.max(1e-4, (2 + q * 5) * u2 * Math.sin(Math.PI * q)));
929
+ }));
930
+ } else {
931
+ o.add(stick(V(0, 0, 0), V(26, 10, 10), 1.6 * u2, mat('plastic', '#fafafa')));
932
+ const cnd = G(M(new THREE.CylinderGeometry(11 * u2, 11 * u2, 4 * u2, 28), mat('gloss', col), null, [Math.PI / 2, 0, 0]));
933
+ const sw = [];
934
+ for (let i = 0; i <= 30; i++) { const a = (i / 30) * Math.PI * 4; const rr = (1 + 8 * (i / 30)) * u2; sw.push(new THREE.Vector3(Math.cos(a) * rr, Math.sin(a) * rr, 2.2 * u2)); }
935
+ cnd.add(M(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(sw), 48, 1 * u2, 5), mat('plastic', '#ffffff')));
936
+ cnd.position.copy(V(31, 13, 12));
937
+ o.add(cnd);
938
+ }
939
+ return o;
940
+ }
941
+ case 'bandaid': case 'ninjamask': case 'facemask': {
942
+ // parches pegados a la superficie: la geometría del cuerpo inflada y recortada por una caja
943
+ const D = this.depth;
944
+ const off = this.furLen * 1.02 + 0.004 * kk;
945
+ const patch = (color, box, kind, round = 0, dOff = 0) => {
946
+ const m = new THREE.Mesh(this.B.geometry, surfaceMaterial(bm.uniforms, { color, off: off + dOff, box, kind, round }));
947
+ m.userData.ownMaterial = true;
948
+ m.userData.noBounds = true;
949
+ this.bodyMeshes.push(m);
950
+ return m;
951
+ };
952
+ if (id === 'bandaid') {
953
+ const x = L.sp * U + r3 * 0.4;
954
+ const y = eyeY - r3 * 2.4;
955
+ g.add(patch(col, { c: [x, y, D], h: [12 * u2, 4.5 * u2, D], rot: 30 * DEG }, 'plastic', 4.4 * u2));
956
+ g.add(patch(shade(col, -0.1), { c: [x, y, D], h: [4 * u2, 4.6 * u2, D], rot: 30 * DEG }, 'plastic', 0, 0.002 * kk));
957
+ } else if (id === 'ninjamask') {
958
+ const y1 = eyeY - r3 * 1.6;
959
+ g.add(patch(col, { c: [0, y1 / 2, 0], h: [9, y1 / 2, 9], rot: 0 }, 'felt'));
960
+ this.hideMouth = true;
961
+ } else {
962
+ const my = Y(L.mouthY);
963
+ const mw = L.mw * U;
964
+ g.add(patch(col, { c: [0, my - 5 * u2, D], h: [mw + 12 * u2, 17 * u2, D], rot: 0 }, 'felt', 8 * u2));
965
+ this.hideMouth = true;
966
+ }
967
+ g.userData.surface = true;
968
+ return g;
969
+ }
970
+ case 'scar': {
971
+ const x0 = -L.sp * U;
972
+ const lift = this.furLen * 0.9 + 0.004 * kk;
973
+ const on = (x, y) => { const h = probe.front(x, y); return h.p.clone().addScaledVector(h.n, lift); };
974
+ const pts = [];
975
+ for (let i = 0; i <= 8; i++) { const q = i / 8; pts.push(on(x0 + lerp(-6, 6, q) * u2, eyeY + r3 * lerp(2.6, -2.4, q))); }
976
+ const sm = mat('felt', col);
977
+ g.add(M(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 16, 1.4 * u2, 5), sm));
978
+ for (const dy of [-8, 0, 8]) {
979
+ const yy = eyeY - dy * u2;
980
+ const xs = x0 + (-dy / 8) * 2 * u2;
981
+ g.add(M(new THREE.TubeGeometry(new THREE.LineCurve3(on(xs - 4 * u2, yy), on(xs + 4 * u2, yy)), 2, 1.1 * u2, 5), sm));
982
+ }
983
+ g.userData.surface = true;
984
+ return g;
985
+ }
986
+ case 'parrot': {
987
+ const x = X(L.anchors.shoulder.x);
988
+ const t = probe.top(x, 0).p;
989
+ const o = G();
990
+ const k = kk * 1.1;
991
+ o.add(M(this.sph(1, 20, 14), mat('felt', col), [0, 0.09 * k, 0], [0.2, 0, 0], [0.065 * k, 0.1 * k, 0.065 * k]));
992
+ o.add(M(this.sph(0.055 * k, 18, 12), mat('felt', col), [0, 0.2 * k, 0.015 * k]));
993
+ o.add(M(new THREE.ConeGeometry(0.03 * k, 0.07 * k, 10), mat('felt', col2), [0, 0.265 * k, -0.01 * k], [-0.4, 0, 0]));
994
+ o.add(M(new THREE.ConeGeometry(0.02 * k, 0.05 * k, 10), mat('gloss', '#facc15'), [0, 0.19 * k, 0.07 * k], [Math.PI / 2 + 0.5, 0, 0]));
995
+ for (const sx of [-1, 1]) o.add(M(this.sph(0.011 * k, 8, 6), mat('gloss', '#111111'), [sx * 0.035 * k, 0.21 * k, 0.04 * k]));
996
+ o.add(M(new THREE.CapsuleGeometry(0.018 * k, 0.12 * k, 3, 8), mat('felt', '#2563eb'), [0, 0.02 * k, -0.06 * k], [-0.9, 0, 0]));
997
+ for (const sx of [-1, 1]) o.add(M(this.sph(1, 14, 10), mat('felt', shade(col, -0.2)), [sx * 0.06 * k, 0.1 * k, -0.005 * k], [0.2, 0, sx * 0.15], [0.02 * k, 0.07 * k, 0.05 * k]));
998
+ const y0 = t.y - 0.01 * kk;
999
+ o.position.set(x, y0, 0);
1000
+ o.rotation.y = -0.4;
1001
+ const ph = Math.random() * TAU;
1002
+ this.anims.push((sec) => { o.rotation.x = Math.sin(sec * 2.2 + ph) * 0.08; o.position.y = y0 + Math.max(0, Math.sin(sec * 1.3 + ph)) * 0.01 * kk; });
1003
+ return G(o);
1004
+ }
1005
+ case 'sparkles': case 'hearts': case 'zzz': {
1006
+ // piezas de los efectos (geometría compartida) colocadas como en el dibujo 2D
1007
+ const list = id === 'sparkles'
1008
+ ? [[-0.75, -0.55, 9], [0.8, -0.35, 7], [0.7, 0.55, 6], [-0.8, 0.35, 5]].map(([a, b, r]) => [a * W * 0.62, H * 0.5 - b * H * 0.55, r * 1.3])
1009
+ : id === 'hearts'
1010
+ ? [[-40, -10, 7], [36, -18, 9], [8, -34, 6]].map(([x, y, r]) => [x * u2, top.y - y * u2, r])
1011
+ : [[30, -10, 9], [46, -26, 6.5], [58, -38, 5]].map(([x, y, r]) => [x * u2, top.y - y * u2, r]);
1012
+ const gm = mat('gloss', id === 'hearts' ? '#f43f5e' : col);
1013
+ const items = list.map(([x, y, r], i) => {
1014
+ const m = M(glyph(id === 'sparkles' ? 'star' : id === 'hearts' ? 'heart' : 'z'), gm, [x, y, this.depth * 0.5]);
1015
+ m.scale.setScalar(r * u2);
1016
+ m.userData.r = r * u2;
1017
+ m.userData.i = i;
1018
+ return m;
1019
+ });
1020
+ g.add(...items);
1021
+ this.anims.push((sec) => items.forEach((m) => {
1022
+ const i = m.userData.i;
1023
+ if (id === 'sparkles') { m.scale.setScalar(m.userData.r * (0.7 + 0.3 * Math.sin(sec * 3 + i * 1.7))); m.rotation.z = sec * 0.6 + i; }
1024
+ else m.rotation.y = Math.sin(sec * 1.5 + i) * 0.4;
1025
+ }));
1026
+ return g;
1027
+ }
1028
+ case 'sweat': {
1029
+ const h = probe.front(L.sp * U + r3 * 2.4, eyeY + r3 * 2.4);
1030
+ const m = M(glyph('drop'), mat('gloss', col), null, [0, 0, -0.15]);
1031
+ m.scale.setScalar(7 * u2);
1032
+ m.position.copy(h.p).addScaledVector(h.n, this.furLen + 6 * u2);
1033
+ g.add(m);
1034
+ return g;
1035
+ }
1036
+ case 'helmet': {
1037
+ // casco espacial: burbuja de cristal + aro
1038
+ const R = Math.max(W, H) * 0.62;
1039
+ const glass = M(this.sph(R, 48, 32), mat('glass', col), [0, H * 0.5, 0]);
1040
+ glass.renderOrder = 2;
1041
+ glass.userData.noBounds = true;
1042
+ g.add(glass);
1043
+ g.add(M(this.tor(R * 0.78, 0.035 * kk, 10, 48), mat('metal', col2), [0, H * 0.5 - R * 0.62, 0], [Math.PI / 2, 0, 0]));
1044
+ return g;
1045
+ }
1046
+ default:
1047
+ return null;
1048
+ }
1049
+ }
1050
+
1051
+ disposeRig() {
1052
+ this.rig.traverse((m) => {
1053
+ if (!m.isMesh) return;
1054
+ if (!m.geometry.userData.shared) m.geometry.dispose();
1055
+ if (m.userData.ownMaterial) m.material.dispose();
1056
+ });
1057
+ this.rig.clear();
1058
+ if (this.shadow) {
1059
+ this.shadow.material.dispose();
1060
+ this.shadow.geometry.dispose();
1061
+ this.root.remove(this.shadow);
1062
+ this.shadow = null;
1063
+ }
1064
+ }
1065
+
1066
+ // ───────────────────────────── animación ─────────────────────────────
1067
+
1068
+ blinkValue(t) {
1069
+ const bt = t - this.blinkAt;
1070
+ if (bt < 0 || bt >= 180) return 0;
1071
+ return bt < 70 ? EASE.in(bt / 70) ** 0.5 : 1 - EASE.out((bt - 70) / 110);
1072
+ }
1073
+
1074
+ /** Avanza un frame. t: performance.now() ms; dt: ms. */
1075
+ tick(t, dt) {
1076
+ const rm = reducedMotion();
1077
+ const idle = this.opts.idle !== false;
1078
+ // parpadeo y miradas espontáneas
1079
+ if (idle && t >= this.nextBlink) {
1080
+ if (this.moodName !== 'sleep' && !rm) {
1081
+ this.blinkAt = t;
1082
+ if (Math.random() < 0.2) this.nextDouble = t + 240;
1083
+ if (t - this.look.at > 3000 && Math.random() < 0.45) {
1084
+ const back = Math.abs(this.look.tx) + Math.abs(this.look.ty) > 0.1 && Math.random() < 0.6;
1085
+ this.look.tx = back ? 0 : (Math.random() - 0.5) * 0.9;
1086
+ this.look.ty = back ? 0 : (Math.random() - 0.5) * 0.5;
1087
+ }
1088
+ }
1089
+ this.nextBlink = t + 1800 + Math.random() * 3600;
1090
+ }
1091
+ if (this.nextDouble && t >= this.nextDouble) { this.blinkAt = t; this.nextDouble = 0; }
1092
+
1093
+ // voz → boca
1094
+ if (this.speech && !this.speech.tick(t)) this.endSpeech();
1095
+ const mo = this.mo;
1096
+ mo.open = approach(mo.open, mo.target, dt, mo.target > mo.open ? 35 : 70);
1097
+ mo.msx = approach(mo.msx, mo.tmsx, dt, 60);
1098
+ const look = this.look;
1099
+ look.x = rm ? look.tx : approach(look.x, look.tx, dt, 70);
1100
+ look.y = rm ? look.ty : approach(look.y, look.ty, dt, 70);
1101
+ const tgt = moodVals(this.moodName);
1102
+ for (const k in MOOD_BASE) this.mv[k] = rm ? tgt[k] : approach(this.mv[k], tgt[k], dt, 90);
1103
+ this.sleepK = approach(this.sleepK, this.moodName === 'sleep' ? 1 : 0, dt, rm ? 1 : 260);
1104
+
1105
+ // gesto (poses de motion.js)
1106
+ const pose = { x: 0, y: 0, r: 0, sx: 1, sy: 1, lx: 0, ly: 0, blink: 0, eye: 1, lift: 0, open: 0, curve: 0, msx: 1 };
1107
+ let spin = 0;
1108
+ const gest = this.gest;
1109
+ if (gest) {
1110
+ const age = t - gest.start;
1111
+ const tt = clamp(age / gest.ms);
1112
+ Object.assign(pose, gest.def.pose(tt, rm ? 0 : age, gest));
1113
+ if (gest.dir < 0) { pose.x = -pose.x; pose.r = -pose.r; pose.lx = -pose.lx; }
1114
+ if (gest.name === 'spin') {
1115
+ spin = EASE.inOut(clamp((tt - 0.12) / 0.76)) * TAU;
1116
+ pose.sx = 1 + (pose.sy < 1 ? (1 - pose.sy) * 0.6 : 0);
1117
+ }
1118
+ for (const f of gest.def.fx || []) {
1119
+ if (tt >= f[0] && !gest.fired.has(f)) {
1120
+ gest.fired.add(f);
1121
+ this.effect(f[1], { dir: gest.dir, ...(f[2] || {}) });
1122
+ }
1123
+ }
1124
+ if (tt >= 1) this.endGesture();
1125
+ }
1126
+ if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
1127
+
1128
+ // metamorfosis: fases como en 2D (entra con rebote, sale suave)
1129
+ let ksx = 1;
1130
+ let ksy = 1;
1131
+ const act = {};
1132
+ for (const [slot, mm] of this.morphs) {
1133
+ if (mm.phase !== 'out' && t >= mm.until) { mm.phase = 'out'; mm.outAt = t; mm.k0 = mm.k; }
1134
+ if (mm.phase === 'in') {
1135
+ const q = clamp((t - mm.start) / MORPH_IN);
1136
+ mm.k = rm ? 1 : mm.shape ? EASE.inOut(q) : EASE.backOut(q);
1137
+ if (q >= 1) mm.phase = 'on';
1138
+ } else if (mm.phase === 'on') mm.k = 1;
1139
+ else {
1140
+ const q = clamp((t - mm.outAt) / MORPH_OUT);
1141
+ mm.k = rm ? 0 : mm.k0 * (1 - EASE.inOut(q));
1142
+ if (q >= 1) { this.morphs.delete(slot); this.endMorph(slot); continue; }
1143
+ }
1144
+ act[slot] = mm;
1145
+ const sc = SCALE_MORPH[slot];
1146
+ if (sc) { const [a, b] = sc(mm.k); ksx *= a; ksy *= b; }
1147
+ }
1148
+ if (act.shape && !act.shape.ready) this.startShape(act.shape);
1149
+ const shapeK = act.shape?.ready ? act.shape.k : 0;
1150
+ this.bodyU.uMorph.value.set(act.melt?.k || 0, rm ? 0 : act.jelly?.k || 0, shapeK, 0);
1151
+
1152
+ // cuerpo
1153
+ const m = this.mv;
1154
+ const W = this.W;
1155
+ const H = this.H;
1156
+ const kk = this.kk;
1157
+ const sec = t / 1000;
1158
+ const breath = rm || !idle ? 0 : Math.sin(sec * TAU / 3.4 + this.phase);
1159
+ const talk = mo.open;
1160
+ const sx = pose.sx * (1 - 0.015 * talk) * (1 - 0.006 * breath) * ksx;
1161
+ const sy = pose.sy * (1 + 0.03 * talk) * (1 + 0.014 * breath) * ksy;
1162
+ const rig = this.rig;
1163
+ rig.position.set(pose.x * W, -pose.y * H, 0);
1164
+ rig.rotation.set(look.y * 0.12, this.yaw + look.x * 0.35 + spin + (rm || !idle ? 0 : Math.sin(sec * 0.7 + this.phase) * 0.04), -(pose.r + look.x * 2) * DEG);
1165
+ rig.scale.set(sx, sy, sx);
1166
+ const air = Math.max(0, -pose.y);
1167
+ this.shadow.scale.set(W * 1.55 * (1 - air * 0.9) * ksx, this.shadow.scale.y, 1);
1168
+ this.shadow.material.opacity = 1 - air * 1.5;
1169
+
1170
+ // piezas que siguen a la silueta (shape:<id>)
1171
+ for (const p of this.parts) p.o.position.copy(p.p0).addScaledVector(p.d, shapeK);
1172
+
1173
+ // ojos
1174
+ const blink = Math.max(this.blinkValue(t), pose.blink, this.sleepK > 0.02 ? Math.min(1, this.sleepK * 1.1) : 0);
1175
+ const closed = this.sleepK > 0.85;
1176
+ const lx = clamp(look.x + pose.lx, -1, 1);
1177
+ const ly = clamp(look.y + pose.ly, -1, 1);
1178
+ const esc = m.eye * pose.eye;
1179
+ for (const e of this.eyes) {
1180
+ e.main.visible = !closed;
1181
+ e.closed.visible = closed;
1182
+ e.gaze.rotation.set(ly * 0.4, lx * 0.5, 0);
1183
+ const k = e.noBlink ? 1 : Math.max(0.08, 1 - blink);
1184
+ e.main.scale.set(esc, esc * (m.squash || 1) * k, esc);
1185
+ }
1186
+ for (const b of this.brows) {
1187
+ b.g.position.y += (m.lift + pose.lift) * U + (m.eye * pose.eye - 1) * this.L.r * U * 1.65;
1188
+ b.g.rotation.z = -b.side * m.tilt * 14 * DEG;
1189
+ }
1190
+
1191
+ // boca
1192
+ const mt = this.mouthRig;
1193
+ const curve = clamp(m.curve + pose.curve + mt.bias, -1, 1);
1194
+ const open = clamp(Math.max(mo.open + pose.open, m.open, mt.min));
1195
+ const msx = mo.msx * pose.msx;
1196
+ mt.g.scale.x = msx;
1197
+ if (mt.line) {
1198
+ mt.line.morphTargetInfluences[0] = Math.max(0, curve);
1199
+ mt.line.morphTargetInfluences[1] = Math.max(0, -curve);
1200
+ mt.line.visible = open <= 0.12;
1201
+ }
1202
+ if (mt.cav) {
1203
+ const show = open > 0.12;
1204
+ mt.cav.visible = show;
1205
+ if (show) {
1206
+ const w = mt.mw * (0.75 + Math.max(0, curve) * 0.15);
1207
+ const h = mt.mw * (0.12 + open * 0.75);
1208
+ mt.cav.scale.set(w, h, mt.mw * 0.18);
1209
+ mt.cav.position.y = -h * 0.55 + curve * mt.mw * 0.1;
1210
+ }
1211
+ }
1212
+ if (mt.round) mt.round.scale.setScalar(0.85 + open * 0.6);
1213
+ if (mt.grit) { mt.grit.scale.y = 1 + open * 1.6; mt.grit.position.y = curve * mt.mw * 0.08; }
1214
+ if (mt.lips) {
1215
+ const gap = open * mt.mw * 0.45;
1216
+ mt.lips.up.position.y = mt.mw * 0.16 + gap * 0.5 + curve * mt.mw * 0.05;
1217
+ mt.lips.lo.position.y = -mt.mw * 0.14 - gap * 0.5;
1218
+ mt.lips.hole.scale.y = Math.max(0.001, gap * 0.6);
1219
+ }
1220
+
1221
+ // metamorfosis de dibujo: pinchos, cuernos, alas, manos
1222
+ const mob = this.mobj;
1223
+ const ms = act.spikes;
1224
+ if (ms) {
1225
+ const o = mob.spikes || (mob.spikes = this.makeSpikes(ms.o.color));
1226
+ o.visible = true;
1227
+ const list = o.userData.list;
1228
+ for (let i = 0; i < list.length; i++) {
1229
+ const it = list[i];
1230
+ const ki = Math.max(1e-4, EASE.backOut(clamp(ms.k * 1.5 - it.ord * 0.07)) * (ms.phase === 'out' ? ms.k : 1));
1231
+ DUMMY.position.copy(it.p);
1232
+ DUMMY.quaternion.copy(it.q);
1233
+ DUMMY.scale.set(ki, ki, ki);
1234
+ DUMMY.updateMatrix();
1235
+ o.setMatrixAt(i, DUMMY.matrix);
1236
+ }
1237
+ o.instanceMatrix.needsUpdate = true;
1238
+ } else if (mob.spikes) mob.spikes.visible = false;
1239
+ const mh = act.horns;
1240
+ if (mh) {
1241
+ const o = mob.horns || (mob.horns = this.makeHorns(mh.o.color));
1242
+ o.visible = true;
1243
+ for (const h of o.children) h.scale.setScalar(Math.max(1e-4, mh.k));
1244
+ } else if (mob.horns) mob.horns.visible = false;
1245
+ const mw = act.wings;
1246
+ if (mw) {
1247
+ const o = mob.wings || (mob.wings = this.makeWings(mw.o.color));
1248
+ o.visible = true;
1249
+ const fast = mw.o.flap === 'fast';
1250
+ const fl = rm ? 0 : Math.sin((t - mw.start) / 1000 * TAU * (fast ? 3.2 : 0.9)) * (fast ? 32 : 11) * DEG;
1251
+ for (const w of o.children) {
1252
+ const s = w.userData.side;
1253
+ w.rotation.set(0, s * (0.55 - fl), s * 0.25);
1254
+ w.scale.setScalar(Math.max(1e-4, mw.k));
1255
+ }
1256
+ } else if (mob.wings) mob.wings.visible = false;
1257
+
1258
+ // manos (HAND_POSES en unidades del dibujo 2D → 3D)
1259
+ const mhd = act.hands;
1260
+ for (let i = 0; i < 2; i++) {
1261
+ const hnd = this.hands[i];
1262
+ hnd.visible = !!mhd;
1263
+ if (!mhd) { hnd.userData.on = false; continue; }
1264
+ const s = i ? 1 : -1;
1265
+ const L = this.L;
1266
+ const c2 = this.handCtx || (this.handCtx = { cx: L.cx, W: L.box.w, H: L.box.h, mid: L.box.y + L.box.h * 0.55, mouthY: L.mouthY, mw: L.mw, sp: L.sp, eyeY: L.eyeY, pts: L.pts });
1267
+ const p = (HAND_POSES[mhd.o.pose] || HAND_POSES.rest)(s, rm ? 0 : t - mhd.start, mhd.o.dir || 1, c2);
1268
+ const x = this.X(p.x ?? c2.cx + s * p.u * c2.W * 0.5);
1269
+ const y = this.Y(p.y ?? c2.mid + p.v * c2.H * 0.5);
1270
+ const fz = this.frontZ(x, y);
1271
+ const z = fz == null ? this.depth * 0.15 : fz + 0.06 * kk + this.furLen;
1272
+ const rz = -s * (p.ang || 0) * DEG;
1273
+ if (!hnd.userData.on || rm) { hnd.position.set(x, y, z); hnd.rotation.z = rz; hnd.userData.on = true; }
1274
+ else {
1275
+ hnd.position.set(approach(hnd.position.x, x, dt, 70), approach(hnd.position.y, y, dt, 70), approach(hnd.position.z, z, dt, 70));
1276
+ hnd.rotation.z = approach(hnd.rotation.z, rz, dt, 70);
1277
+ }
1278
+ const type = p.type || 'open';
1279
+ const fg = hnd.userData.finger;
1280
+ fg.visible = type === 'point' || type === 'thumb' || type === 'thumbdown';
1281
+ fg.rotation.z = type === 'thumbdown' ? Math.PI : 0;
1282
+ hnd.scale.setScalar(Math.max(0.01, mhd.k * (p.s || 1)) * (type === 'open' ? 1 : 0.9));
1283
+ }
1284
+
1285
+ // efectos
1286
+ let blush = 0;
1287
+ if (this.fxs.length) {
1288
+ const c = this.fxc;
1289
+ this.fxs = this.fxs.filter((f) => {
1290
+ const age = t - f.start;
1291
+ let alpha = 1;
1292
+ let p;
1293
+ if (f.loop) {
1294
+ if (!f.hold && age >= f.ms && f.end == null) f.end = t;
1295
+ p = rm ? 0.3 : (age % f.cycle) / f.cycle;
1296
+ } else {
1297
+ if (age >= f.ms) { disposeFx(f.obj); return false; }
1298
+ p = rm ? 0.5 : age / f.ms;
1299
+ }
1300
+ if (f.end != null) {
1301
+ alpha = 1 - clamp((t - f.end) / FX_OUT);
1302
+ if (alpha <= 0) { disposeFx(f.obj); return false; }
1303
+ }
1304
+ f.def.draw(c, f, p, rm ? 0 : age, alpha, seeded(f.seed));
1305
+ if (f.blush) blush = Math.max(blush, f.blush);
1306
+ return true;
1307
+ });
1308
+ }
1309
+ this.bodyU.uBlush.value = blush;
1310
+ this.fxg.position.copy(rig.position);
1311
+ this.fxg.rotation.y = this.yaw;
1312
+
1313
+ for (const f of this.furs) f.u.uTime.value = sec;
1314
+ for (const a of this.anims) a(rm ? 0 : sec);
1315
+ }
1316
+
1317
+ /** Pinchos: conos instanciados en el contorno (sin la base) y en la espalda. */
1318
+ makeSpikes(color) {
1319
+ const L = this.L;
1320
+ const list = [];
1321
+ const up = new THREE.Vector3(0, 1, 0);
1322
+ const ctr = new THREE.Vector3(0, this.H * 0.5, 0);
1323
+ L.pts.forEach(([px, py], i) => {
1324
+ if (i >= 6 && i <= 10) return;
1325
+ const p = new THREE.Vector3(this.X(px), this.Y(py), 0);
1326
+ const d = p.clone().sub(ctr).normalize();
1327
+ p.addScaledVector(d, -0.02 * this.kk);
1328
+ list.push({ p, q: new THREE.Quaternion().setFromUnitVectors(up, d), ord: Math.min(i, 16 - i) });
1329
+ });
1330
+ for (const f of [0.82, 0.62, 0.42]) {
1331
+ const y = this.H * f;
1332
+ const s = this.probe.section(y);
1333
+ list.push({ p: new THREE.Vector3(0, y, -s.d * 0.9), q: new THREE.Quaternion().setFromUnitVectors(up, new THREE.Vector3(0, 0.4, -1).normalize()), ord: 3 + list.length * 0.1 });
1334
+ }
1335
+ const geo = new THREE.ConeGeometry(0.05 * this.kk, 0.16 * this.kk, Math.max(6, Math.round(12 * this.q))).translate(0, 0.08 * this.kk, 0);
1336
+ const o = new THREE.InstancedMesh(geo, mat(this.plush ? 'felt' : 'gloss', color || shade(this.spec.body.color, -0.12)), list.length);
1337
+ o.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
1338
+ o.frustumCulled = false;
1339
+ o.userData.list = list;
1340
+ o.userData.noBounds = true;
1341
+ this.rig.add(o);
1342
+ return o;
1343
+ }
1344
+
1345
+ /** Cuernos de la metamorfosis: dos conos curvos arriba. */
1346
+ makeHorns(color) {
1347
+ const L = this.L;
1348
+ const g = G();
1349
+ const m = mat('gloss', color || '#f5f0e6');
1350
+ for (const i of [1, 15]) {
1351
+ const [px, py] = L.pts[i];
1352
+ const s = px > L.cx ? 1 : -1;
1353
+ const h = G(M(new THREE.ConeGeometry(0.06 * this.kk, 0.24 * this.kk, 16).translate(0, 0.12 * this.kk, 0), m));
1354
+ h.children[0].rotation.z = -s * 0.25;
1355
+ const x = this.X(px) * 0.8;
1356
+ h.position.set(x, this.probe.top(x, 0).p.y - 0.04 * this.kk, 0);
1357
+ h.rotation.z = -s * 0.45;
1358
+ g.add(h);
1359
+ }
1360
+ g.userData.noBounds = true;
1361
+ this.rig.add(g);
1362
+ return g;
1363
+ }
1364
+
1365
+ /** Alas de la metamorfosis: abanico de plumas a la espalda. */
1366
+ makeWings(color) {
1367
+ const g = G();
1368
+ const kk = this.kk;
1369
+ const s0 = this.probe.section(this.H * 0.6);
1370
+ const m = mat(this.plush ? 'felt' : 'plastic', color || '#ffffff');
1371
+ for (const side of [-1, 1]) {
1372
+ const w = G();
1373
+ for (let i = 0; i < 3; i++) {
1374
+ const f = M(this.sph(1, 20, 12), m, [side * (0.16 + i * 0.05) * kk, (0.1 - i * 0.07) * kk, 0], [0, 0, side * (0.5 - i * 0.35)], [(0.2 - i * 0.03) * kk, 0.07 * kk, 0.025 * kk]);
1375
+ w.add(f);
1376
+ }
1377
+ w.position.set(side * s0.w * 0.45, this.H * 0.62, -s0.d * 0.7);
1378
+ w.userData.side = side;
1379
+ g.add(w);
1380
+ }
1381
+ g.userData.noBounds = true;
1382
+ this.rig.add(g);
1383
+ return g;
1384
+ }
1385
+
1386
+ /** Prepara shape:<id>: atributos aShape/aShapeN en un clon de la geometría y desplazamientos de cara. */
1387
+ startShape(mm) {
1388
+ mm.ready = true;
1389
+ const pts2 = mm.target;
1390
+ const L2 = layout(this.spec, pts2);
1391
+ const B2 = bodyGeometry(pts2, L2.box, this.quality);
1392
+ const src = B2.geometry.attributes;
1393
+ if (src.position.count !== this.B.geometry.attributes.position.count) return;
1394
+ let cl = this.shapeGeo;
1395
+ if (!cl) {
1396
+ cl = this.shapeGeo = this.B.geometry.clone();
1397
+ cl.userData = {}; // copy() comparte userData: sin esto heredaría shared=true
1398
+ for (const b of this.bodyMeshes) b.geometry = cl;
1399
+ }
1400
+ cl.setAttribute('aShape', src.position.clone());
1401
+ cl.setAttribute('aShapeN', src.normal.clone());
1402
+ const kx = (L2.box.w * U) / this.W;
1403
+ const ky = (L2.box.h * U) / this.H;
1404
+ const pr2 = surfaceProbe(B2.geometry);
1405
+ for (const p of this.parts) {
1406
+ const { x, y } = p.p0;
1407
+ const x2 = x * kx;
1408
+ const y2 = y * ky;
1409
+ p.d.set(x2 - x, y2 - y, p.face ? pr2.front(x2, y2).p.z - this.probe.front(x, y).p.z : 0);
1410
+ }
1411
+ }
1412
+
1413
+ /** Limpia lo que deja una metamorfosis al terminar. */
1414
+ endMorph(slot) {
1415
+ if (slot === 'shape') {
1416
+ for (const b of this.bodyMeshes) b.geometry = this.B.geometry;
1417
+ this.shapeGeo?.dispose();
1418
+ this.shapeGeo = null;
1419
+ for (const p of this.parts) { p.d.set(0, 0, 0); p.o.position.copy(p.p0); }
1420
+ }
1421
+ if (slot === 'hands') this.handCtx = null;
1422
+ }
1423
+
1424
+ /** Al reconstruir: las piezas de las metamorfosis se rehacen solas en el siguiente frame. */
1425
+ clearMorphObjects() {
1426
+ if (this.shapeGeo) { this.shapeGeo.dispose(); this.shapeGeo = null; }
1427
+ this.mobj = {};
1428
+ this.handCtx = null;
1429
+ for (const mm of this.morphs.values()) mm.ready = false;
1430
+ }
1431
+
1432
+ /** LOD: capas de pelo según la altura en pantalla (px). */
1433
+ lod(px) {
1434
+ if (!this.plush) return;
1435
+ this.setShells(clamp(px / (this.quality === 'high' ? 14 : 20), 3, this.maxShells));
1436
+ }
1437
+
1438
+ // ───────────────────────────── API ─────────────────────────────
1439
+
1440
+ /** Ánimo (se interpola); lanza sus efectos (MOOD_FX) salvo { fx: false } u opts.moodFx === false. */
1441
+ mood(name, o = {}) {
1442
+ if (!MOOD[name]) return false;
1443
+ if (name === this.moodName) return true;
1444
+ this.moodName = name;
1445
+ this.clearEffects('mood');
1446
+ if (this.opts.moodFx !== false && o.fx !== false) for (const [fx, fo] of MOOD_FX[name] || []) this.effect(fx, fo || {});
1447
+ return true;
1448
+ }
1449
+
1450
+ endGesture() {
1451
+ const old = this.gest;
1452
+ if (!old) return;
1453
+ this.gest = null;
1454
+ const hands = this.morphs.get('hands');
1455
+ if (old.def.hands && hands && !hands.byGesture && hands.userPose !== undefined) hands.o.pose = hands.userPose;
1456
+ const wings = this.morphs.get('wings');
1457
+ if (old.def.wings && wings && !wings.byGesture) wings.o.flap = wings.userFlap;
1458
+ }
1459
+
1460
+ gesture(name, o = {}) {
1461
+ const gname = Object.hasOwn(GESTURES, name) ? name : GESTURE_ALIAS[name];
1462
+ if (!gname) return false;
1463
+ if (typeof o === 'number') o = { ms: o };
1464
+ const def = GESTURES[gname];
1465
+ const t = nowMs();
1466
+ const ms = clamp(Number(o.ms) || def.ms, 200, 60000);
1467
+ const dir = o.dir === -1 || o.mirror === true ? -1 : 1;
1468
+ if (this.gest) this.endGesture();
1469
+ this.gest = { name: gname, def, start: t, ms, dir, fired: new Set() };
1470
+ // manos y alas que pide el gesto (si ya estaban, solo cambian de postura)
1471
+ const hands = this.morphs.get('hands');
1472
+ if (def.hands) {
1473
+ if (hands && hands.phase !== 'out') {
1474
+ hands.o.pose = def.hands;
1475
+ hands.o.dir = dir;
1476
+ if (hands.byGesture) hands.until = t + ms - MORPH_OUT;
1477
+ } else this.morph('hands', { pose: def.hands, dir, ms: ms - MORPH_OUT, byGesture: true });
1478
+ } else if (hands?.byGesture && hands.phase !== 'out') hands.until = t;
1479
+ const wings = this.morphs.get('wings');
1480
+ if (def.wings) {
1481
+ if (wings && wings.phase !== 'out') {
1482
+ wings.o.flap = def.wings;
1483
+ if (wings.byGesture) wings.until = t + ms - MORPH_OUT;
1484
+ } else this.morph('wings', { flap: def.wings, ms: ms - MORPH_OUT, byGesture: true });
1485
+ } else if (wings?.byGesture && wings.phase !== 'out') wings.until = t;
1486
+ return true;
1487
+ }
1488
+
1489
+ /** Metamorfosis temporal: spikes|wings|hands|horns|puff|squish|stretch|melt|jelly|shape:<id>. o: { ms, hold, pose, dir, flap, color } */
1490
+ morph(name, o = {}) {
1491
+ if (typeof name !== 'string') return false;
1492
+ if (o === false) return this.unmorph(name);
1493
+ if (typeof o === 'number') o = { ms: o };
1494
+ if (!Object.hasOwn(MORPHS, name) && Object.hasOwn(FX3D, name)) return this.effect(name, o);
1495
+ let slot = name;
1496
+ let target = null;
1497
+ if (name.startsWith('shape:')) {
1498
+ target = shapePoints(name.slice(6));
1499
+ if (!target) return false;
1500
+ slot = 'shape';
1501
+ } else if (!Object.hasOwn(MORPHS, name)) return false;
1502
+ const t = nowMs();
1503
+ const hold = o.hold === true || o.ms === Infinity;
1504
+ const ms = hold ? Infinity : clamp(Number(o.ms) || MORPH_MS, 200, 600000);
1505
+ const user = !o.byGesture;
1506
+ const color = /^#[0-9a-f]{6}$/i.test(o.color || '') ? o.color : undefined;
1507
+ const ex = this.morphs.get(slot);
1508
+ if (ex) {
1509
+ if (ex.phase === 'out') { ex.phase = 'in'; ex.start = t - MORPH_IN * clamp(ex.k); }
1510
+ ex.until = Math.max(user ? 0 : ex.until, t + ms);
1511
+ if (user) ex.byGesture = false;
1512
+ if (slot === 'shape' && ex.target !== target) { ex.target = target; ex.ready = false; }
1513
+ if (o.pose) ex.o.pose = o.pose;
1514
+ if (o.dir) ex.o.dir = o.dir;
1515
+ if (o.flap) ex.o.flap = o.flap;
1516
+ if (user) { ex.userPose = ex.o.pose; ex.userFlap = ex.o.flap; }
1517
+ } else {
1518
+ const mo2 = { pose: name === 'hands' ? o.pose || 'rest' : undefined, dir: o.dir === -1 ? -1 : 1, flap: o.flap, color };
1519
+ this.morphs.set(slot, {
1520
+ name: slot, start: t, until: t + ms, phase: 'in', k: 0, o: mo2, target, shape: slot === 'shape', ready: false,
1521
+ byGesture: !user, userPose: user ? mo2.pose : undefined, userFlap: user ? mo2.flap : undefined,
1522
+ });
1523
+ }
1524
+ return true;
1525
+ }
1526
+
1527
+ unmorph(name) {
1528
+ const t = nowMs();
1529
+ const slot = typeof name === 'string' && name.startsWith('shape') ? 'shape' : name;
1530
+ for (const [k, m] of this.morphs) {
1531
+ if (slot && k !== slot) continue;
1532
+ if (m.phase !== 'out') { m.phase = 'out'; m.outAt = t; m.k0 = m.k; }
1533
+ }
1534
+ return true;
1535
+ }
1536
+
1537
+ /** Efecto de CHARACTER_EFFECTS (los 16 de motion.js). o: { ms, hold, dir, tag } */
1538
+ effect(name, o = {}) {
1539
+ if (typeof name !== 'string') return false;
1540
+ if (typeof o === 'number') o = { ms: o };
1541
+ if (Object.hasOwn(MORPHS, name) || name.startsWith('shape:')) return this.morph(name, o);
1542
+ if (!Object.hasOwn(FX3D, name)) return false;
1543
+ const def = FX3D[name];
1544
+ const f = {
1545
+ name, def, start: nowMs(), ms: clamp(Number(o.ms) || (def.loop ? def.ms * 1.5 : def.ms), 100, 600000), cycle: def.ms,
1546
+ loop: !!def.loop || o.hold === true, hold: o.hold === true, dir: o.dir === -1 ? -1 : 1, o, tag: o.tag,
1547
+ seed: (++this.fxSeq * 7919) ^ Math.floor(this.phase * 1e4), end: null, obj: null,
1548
+ };
1549
+ const obj = def.make(this.fxc, f);
1550
+ if (obj) {
1551
+ f.obj = obj;
1552
+ (def.rig ? this.rig : this.fxg).add(obj);
1553
+ def.draw(this.fxc, f, 0, 0, 1, seeded(f.seed));
1554
+ }
1555
+ this.fxs.push(f);
1556
+ if (this.fxs.length > 24) disposeFx(this.fxs.shift().obj);
1557
+ return true;
1558
+ }
1559
+
1560
+ clearEffects(name) {
1561
+ const t = nowMs();
1562
+ for (const f of this.fxs) if (!name || f.name === name || f.tag === name) f.end ??= t;
1563
+ }
1564
+
1565
+ blink() { this.blinkAt = nowMs(); }
1566
+
1567
+ lookAt(x, y) {
1568
+ this.look.tx = clamp(Number(x) || 0, -1, 1);
1569
+ this.look.ty = clamp(Number(y) || 0, -1, 1);
1570
+ this.look.at = nowMs();
1571
+ }
1572
+
1573
+ /** Mirar a un punto de la pantalla (coordenadas de cliente). */
1574
+ lookAtPoint(clientX, clientY) {
1575
+ const p = this.view?.screenOf(this);
1576
+ if (!p) return;
1577
+ this.lookAt((clientX - p.x) / (window.innerWidth / 2), (clientY - p.y) / (window.innerHeight / 2));
1578
+ }
1579
+
1580
+ mouth(v) { this.mo.target = clamp(Number(v) || 0); }
1581
+
1582
+ viseme(id, open) {
1583
+ const v = VISEMES[id] || VISEMES.X;
1584
+ this.mo.target = clamp(open ?? v.open);
1585
+ this.mo.tmsx = v.w;
1586
+ return !!VISEMES[id];
1587
+ }
1588
+
1589
+ endSpeech() {
1590
+ const sp = this.speech;
1591
+ this.speech = null;
1592
+ this.mo.target = 0;
1593
+ this.mo.tmsx = 1;
1594
+ sp?.resolve?.();
1595
+ }
1596
+
1597
+ /** Habla: texto (visemas) o duración en ms (balbuceo). Devuelve una promesa con stop(). */
1598
+ speak(input, o = {}) {
1599
+ if (this.speech) this.endSpeech();
1600
+ let seq;
1601
+ let dur;
1602
+ if (typeof input === 'number') {
1603
+ dur = clamp(input, 0, 600000);
1604
+ seq = babbleVisemes(dur, Math.floor(Math.random() * 1e6));
1605
+ } else {
1606
+ const text = String(input ?? '');
1607
+ seq = textToVisemes(text);
1608
+ dur = Number(o.durationMs) || Math.max(300, (text.length / 14 / (Number(o.rate) || 1)) * 1000);
1609
+ }
1610
+ let resolve;
1611
+ const pr = new Promise((r) => { resolve = r; });
1612
+ if (!seq.length || !dur) { resolve(); return Object.assign(pr, { stop() {} }); }
1613
+ let Wt = 0;
1614
+ for (const it of seq) { it.t0 = Wt; Wt += it.w; }
1615
+ const start = nowMs();
1616
+ let idx = 0;
1617
+ const gain = Number(o.gain) || 1;
1618
+ const sp = {
1619
+ resolve,
1620
+ tick: (t) => {
1621
+ if (t - start >= dur) return false;
1622
+ const pos = clamp((t - start) / dur) * Wt;
1623
+ while (idx < seq.length - 1 && seq[idx + 1].t0 <= pos) idx++;
1624
+ const it = seq[idx];
1625
+ const v = VISEMES[it.v] || VISEMES.X;
1626
+ const local = clamp((pos - it.t0) / (it.w || 1));
1627
+ this.mo.target = clamp(v.open * (0.82 + 0.18 * Math.sin(Math.PI * local)) * gain);
1628
+ this.mo.tmsx = v.w;
1629
+ return true;
1630
+ },
1631
+ };
1632
+ this.speech = sp;
1633
+ return Object.assign(pr, { stop: () => { if (this.speech === sp) this.endSpeech(); } });
1634
+ }
1635
+
1636
+ stopSpeaking() { if (this.speech) this.endSpeech(); }
1637
+
1638
+ /** Boca guiada por audio real (AnalyserNode): RMS con ganancia automática; o.text da la forma. Devuelve stop(). */
1639
+ lipsync(analyser, o = {}) {
1640
+ this.stopSpeaking();
1641
+ if (!analyser || typeof analyser.getByteTimeDomainData !== 'function') return () => {};
1642
+ const n = analyser.fftSize || 512;
1643
+ const bytes = new Uint8Array(n);
1644
+ const gain = Number(o.gain) || 1;
1645
+ const seq = o.text ? textToVisemes(String(o.text)) : [];
1646
+ let Wt = 0;
1647
+ for (const it of seq) { it.t0 = Wt; Wt += it.w; }
1648
+ const dur = Number(o.durationMs) || Math.max(300, (String(o.text || '').length / 14) * 1000);
1649
+ const start = nowMs();
1650
+ let idx = 0;
1651
+ let peak = 0.05;
1652
+ const sp = {
1653
+ tick: (t) => {
1654
+ if (analyser.context?.state === 'closed') return false;
1655
+ analyser.getByteTimeDomainData(bytes);
1656
+ let rms = 0;
1657
+ for (let i = 0; i < n; i++) { const v = (bytes[i] - 128) / 128; rms += v * v; }
1658
+ rms = Math.sqrt(rms / n);
1659
+ peak = Math.max(rms, peak * 0.995, 0.02);
1660
+ const open = clamp(clamp((rms - 0.006) / (peak * 0.7)) ** 0.8 * gain);
1661
+ let w = 1;
1662
+ if (seq.length && t - start < dur) {
1663
+ const pos = clamp((t - start) / dur) * Wt;
1664
+ while (idx < seq.length - 1 && seq[idx + 1].t0 <= pos) idx++;
1665
+ w = (VISEMES[seq[idx].v] || VISEMES.X).w;
1666
+ }
1667
+ this.mo.target = open;
1668
+ this.mo.tmsx = lerp(1, w, clamp(open * 2));
1669
+ return true;
1670
+ },
1671
+ };
1672
+ this.speech = sp;
1673
+ return () => { if (this.speech === sp) this.endSpeech(); };
1674
+ }
1675
+
1676
+ update(next) {
1677
+ this.build(next);
1678
+ }
1679
+
1680
+ get state() {
1681
+ return {
1682
+ mood: this.moodName, gesture: this.gest?.name || null, speaking: !!this.speech, shells: this.shells,
1683
+ morphs: [...this.morphs.keys()].filter((k) => this.morphs.get(k).phase !== 'out'), effects: this.fxs.map((f) => f.name),
1684
+ };
1685
+ }
1686
+
1687
+ dispose() {
1688
+ for (const f of this.fxs) disposeFx(f.obj);
1689
+ this.fxs = [];
1690
+ this.disposeRig();
1691
+ this.root.removeFromParent();
1692
+ }
1693
+ }