@7ots/cli 0.1.6 → 0.1.8

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 (61) hide show
  1. package/README.md +3 -3
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +107 -38
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +5 -1
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +352 -11
  11. package/cli/lib/prompts.mjs +31 -17
  12. package/cli/lib/prowl.mjs +110 -0
  13. package/cli/pet/electron/director.cjs +37 -5
  14. package/cli/pet/electron/main.cjs +92 -10
  15. package/cli/pet/pet.html +149 -10
  16. package/cli/pet/settings.html +549 -43
  17. package/dist/7ots.esm.js +216 -137
  18. package/dist/7ots.esm.js.map +4 -4
  19. package/dist/7ots.iife.js +216 -137
  20. package/dist/7ots.iife.js.map +4 -4
  21. package/llms.txt +1 -1
  22. package/package.json +17 -14
  23. package/server/channels/agent.mjs +7 -1
  24. package/server/channels/index.mjs +3 -2
  25. package/server/platform/connect.mjs +4 -1
  26. package/server/platform/db.mjs +14 -1
  27. package/server/platform/device.mjs +101 -4
  28. package/server/platform/mods.mjs +222 -0
  29. package/server/platform/orquesta.mjs +78 -14
  30. package/server/platform/profile.mjs +161 -0
  31. package/server/platform/routes.mjs +39 -33
  32. package/server/platform/runtime.mjs +5 -3
  33. package/server/platform/store.mjs +1 -1
  34. package/server/platform/vm.mjs +688 -17
  35. package/server/server.mjs +12 -2
  36. package/server/tts.mjs +17 -4
  37. package/src/avatar/three.js +3 -2
  38. package/src/character/AvatarEditor.js +95 -7
  39. package/src/character/Character.js +116 -17
  40. package/src/character/TradingCard.js +226 -0
  41. package/src/character/mods/index.js +4 -0
  42. package/src/character/mods/orc.js +64 -0
  43. package/src/character/mods.js +251 -0
  44. package/src/character/more/acc-head.js +213 -0
  45. package/src/character/more/acc-rest.js +342 -0
  46. package/src/character/more/brows.js +39 -0
  47. package/src/character/more/cheeks.js +78 -0
  48. package/src/character/more/eyes.js +288 -0
  49. package/src/character/more/mouths.js +259 -0
  50. package/src/character/more/patterns.js +120 -0
  51. package/src/character/parts.js +100 -0
  52. package/src/character/skin.js +166 -1
  53. package/src/character/styles.js +1285 -9
  54. package/src/character3d/Character3D.js +253 -37
  55. package/src/character3d/index.js +7 -1
  56. package/src/character3d/model.js +367 -0
  57. package/src/i18n/messages/character.js +37 -22
  58. package/src/i18n/messages/platform.js +33 -0
  59. package/src/identity/random.js +17 -3
  60. package/src/identity/schema.js +10 -0
  61. package/src/index.js +1 -0
@@ -11,6 +11,8 @@
11
11
  import * as THREE from 'three';
12
12
  import { normalizeCharacter, layout, bodyPoints } from '../character/Character.js';
13
13
  import { stylizeSpec } from '../character/styles.js';
14
+ import { applyMods, modPart } from '../character/mods.js';
15
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
14
16
 
15
17
  /**
16
18
  * Acabado 3D de cada estilo artístico (styles.js): la paleta sale de stylizeSpec y aquí se elige
@@ -19,20 +21,34 @@ import { stylizeSpec } from '../character/styles.js';
19
21
  const STYLE_FINISH = {
20
22
  ukiyoe: 'toon', glass: 'glossy', bauhaus: 'toon', manuscript: 'toon', glitch: 'neon', clay: 'clay', ink: 'sketch',
21
23
  pop: 'toon', watercolor: 'soft3d', pixel: 'toon', synthwave: 'neon', nouveau: 'toon', cubism: 'toon', crayon: 'sketch', blueprint: 'neon', pointillism: 'soft3d', woodcut: 'sketch',
24
+ riso: 'toon', dotmatrix: 'toon', chalk: 'sketch', sticker: 'glossy', felt: 'clay', papercut: 'soft3d', lowpoly: 'toon', marble: 'soft3d', candy: 'glossy',
25
+ holo: 'glossy', sumie: 'sketch', tarot: 'toon', retro70: 'toon', mosaic: 'toon', neonsign: 'neon', graffiti: 'toon', thermal: 'soft3d',
22
26
  };
27
+ /** Piezas del catálogo 2 sin modelo 3D propio → la existente más parecida (campo `as3d` en parts.js). */
28
+ function alias3D(s) {
29
+ s.eyes = { ...s.eyes, type: as3d('eyes', s.eyes.type) };
30
+ s.brows = { ...s.brows, type: as3d('brows', s.brows.type) };
31
+ s.mouth = { ...s.mouth, type: as3d('mouths', s.mouth.type) };
32
+ s.cheeks = { ...s.cheeks, type: as3d('cheeks', s.cheeks.type) };
33
+ s.body = { ...s.body, pattern: as3d('patterns', s.body.pattern) };
34
+ s.finish = as3d('finishes', s.finish);
35
+ s.accessories = s.accessories.map((a) => ({ ...a, id: as3d('accessories', a.id) }));
36
+ return s;
37
+ }
23
38
  function styled3D(spec) {
24
- const s = stylizeSpec(spec);
39
+ const s = alias3D(stylizeSpec(spec));
25
40
  if (!s._style) return s;
26
41
  const f2 = s.finish;
27
42
  s.finish = STYLE_FINISH[s._style.id] || (f2 === 'clay' || f2 === 'sketch' ? f2 : s._style.lw > 0 ? 'toon' : f2 === 'flat' ? 'soft3d' : f2);
28
43
  return s;
29
44
  }
30
- import { shade, luminance } from '../character/parts.js';
45
+ import { shade, luminance, as3d } from '../character/parts.js';
31
46
  import { textureOf } from '../character/skin.js';
32
47
  import { GESTURES, GESTURE_ALIAS, VISEMES, EASE, clamp, lerp, approach, textToVisemes, babbleVisemes, HAND_POSES, MORPHS, EFFECTS, shapePoints, seeded } from '../character/motion.js';
33
48
  import { U, bodyGeometry, surfaceProbe } from './body.js';
34
49
  import { bodyMaterials, surfaceMaterial, mat, shadowTexture, CHEEK_IDS } from './materials.js';
35
50
  import { FX3D, glyph, disposeFx } from './fx.js';
51
+ import { buildModel, tickModel, modelGesture } from './model.js';
36
52
 
37
53
  const DEG = Math.PI / 180;
38
54
  const TAU = Math.PI * 2;
@@ -93,6 +109,26 @@ const nowMs = () => performance.now();
93
109
  const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
94
110
  const shared = (o) => { o.userData.shared = true; return o; };
95
111
 
112
+ // glb de los mods: uno por url, compartido entre ots (sus geometrías no se liberan con el rig)
113
+ const GLB = new Map();
114
+ function loadGlb(url) {
115
+ if (!GLB.has(url)) {
116
+ const p = new Promise((res, rej) => {
117
+ const loader = new GLTFLoader();
118
+ const done = (g) => res(g.scene);
119
+ if (url.startsWith('data:')) {
120
+ const bin = atob(url.slice(url.indexOf(',') + 1));
121
+ const buf = new Uint8Array(bin.length);
122
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
123
+ loader.parse(buf.buffer, '', done, rej);
124
+ } else loader.load(url, done, undefined, rej);
125
+ });
126
+ p.catch(() => GLB.delete(url));
127
+ GLB.set(url, p);
128
+ }
129
+ return GLB.get(url);
130
+ }
131
+
96
132
  /** Malla con posición/rotación/escala opcionales. */
97
133
  function M(geo, material, p, r, s) {
98
134
  const m = new THREE.Mesh(geo, material);
@@ -143,7 +179,10 @@ export class Character3D {
143
179
  this.clearMorphObjects();
144
180
  for (const f of this.fxs) disposeFx(f.obj);
145
181
  this.fxs = [];
146
- const s = (this.spec = styled3D(normalizeCharacter(spec)));
182
+ const s = (this.spec = styled3D(applyMods(normalizeCharacter(spec), normalizeCharacter)));
183
+ this.modelMode = false;
184
+ this.modelTok = null;
185
+ if (s._model && s._model.url !== this.modelFailed) return this.buildModel(s); // mod de cuerpo completo (model.js)
147
186
  const st = s._style; // estilo artístico: sin pelo, con su paleta y su contorno
148
187
  const pts = bodyPoints(s);
149
188
  const L = (this.L = layout(s));
@@ -155,7 +194,7 @@ export class Character3D {
155
194
  const kk = (this.kk = Math.sqrt(W * H) / 1.25); // escala de accesorios (1 ≈ ot medio)
156
195
  const Y = (this.Y = (y) => (B.bottom - y) * U);
157
196
  const X = (this.X = (x) => (x - L.cx) * U);
158
- const tex = (this.tex = textureOf(s));
197
+ const tex = (this.tex = as3d('textures', textureOf(s)));
159
198
  const plush = (this.plush = !st && ((this.opts.material || 'plush') === 'plush' || ['fur', 'fuzz', 'feathers'].includes(tex)));
160
199
  const furLen = (this.furLen = plush ? (tex === 'fuzz' || tex === 'none' ? 0.05 : 0.065) * kk : 0);
161
200
  const density = (this.density = furLen ? (this.quality === 'high' ? 1 / (furLen * 0.2) : 1 / (furLen * 0.32)) : 0);
@@ -287,7 +326,7 @@ export class Character3D {
287
326
  if (!o) continue;
288
327
  // pivote en el centro del accesorio: escala/giro de la spec alrededor de sí mismo
289
328
  o.updateMatrixWorld(true);
290
- const c = o.userData.surface ? new THREE.Vector3() : new THREE.Box3().setFromObject(o).getCenter(new THREE.Vector3());
329
+ const c = o.userData.surface ? new THREE.Vector3() : o.userData.pivot ? o.userData.pivot.clone() : new THREE.Box3().setFromObject(o).getCenter(new THREE.Vector3());
291
330
  const piv = G(o);
292
331
  o.position.sub(c);
293
332
  piv.position.copy(c);
@@ -651,7 +690,99 @@ export class Character3D {
651
690
  h.rotation.z = -0.12;
652
691
  return onTop(h, 0.06);
653
692
  }
654
- case 'beanie': case 'santa': case 'cap': case 'hardhat': case 'viking': case 'beret': {
693
+ case 'fedora': case 'bowler': {
694
+ const fm = mat('felt', col);
695
+ const fed = id === 'fedora';
696
+ const h = G(
697
+ // ala (fedora: algo más ancha y levantada atrás)
698
+ M(new THREE.CylinderGeometry((fed ? 0.4 : 0.32) * kk, (fed ? 0.4 : 0.32) * kk, 0.022 * kk, 48), fm, null, fed ? [-0.08, 0, 0] : null),
699
+ fed
700
+ ? M(new THREE.CylinderGeometry(0.19 * kk, 0.23 * kk, 0.24 * kk, 40), fm, [0, 0.12 * kk, 0], null, [1, 1, 0.85])
701
+ : M(this.sph(0.23 * kk, 40, 20, 0, TAU, 0, Math.PI / 2), fm, [0, 0.06 * kk, 0], null, [1, 1.05, 0.92]),
702
+ M(new THREE.CylinderGeometry(0.235 * kk, 0.235 * kk, 0.05 * kk, 40), mat('felt', col2), [0, 0.045 * kk, 0], null, [1, 1, fed ? 0.88 : 0.93]),
703
+ );
704
+ if (!fed) h.add(M(new THREE.CylinderGeometry(0.23 * kk, 0.23 * kk, 0.06 * kk, 40), fm, [0, 0.03 * kk, 0], null, [1, 1, 0.92]));
705
+ if (fed) h.add(M(this.sph(0.12 * kk, 24, 10), mat('felt', shade(col, -0.25)), [0, 0.24 * kk, 0], null, [1, 0.25, 0.6])); // pellizco de la copa
706
+ h.rotation.z = fed ? -0.14 : -0.06;
707
+ return onTop(h, 0.06);
708
+ }
709
+ case 'witch': {
710
+ const fm = mat('felt', col);
711
+ const o = G(M(new THREE.CylinderGeometry(0.46 * kk, 0.46 * kk, 0.02 * kk, 48), fm));
712
+ // cono doblado: tres tramos cada vez más finos e inclinados
713
+ let y = 0;
714
+ let x = 0;
715
+ const segs = [[0.25, 0.17, 0.3, 0], [0.17, 0.09, 0.22, -0.35], [0.09, 0.01, 0.2, -0.9]];
716
+ for (const [r0, r1, h, rz] of segs) {
717
+ const m = M(new THREE.CylinderGeometry(r1 * kk, r0 * kk, h * kk, 32), fm);
718
+ m.geometry.translate(0, (h / 2) * kk, 0);
719
+ m.position.set(x, y, 0);
720
+ m.rotation.z = rz;
721
+ o.add(m);
722
+ x += -Math.sin(rz) * h * kk;
723
+ y += Math.cos(rz) * h * kk;
724
+ }
725
+ o.add(M(new THREE.CylinderGeometry(0.245 * kk, 0.25 * kk, 0.06 * kk, 40), mat('felt', col2), [0, 0.04 * kk, 0]));
726
+ o.add(M(new THREE.BoxGeometry(0.08 * kk, 0.06 * kk, 0.02 * kk), mat('metal', '#facc15'), [0, 0.04 * kk, 0.25 * kk]));
727
+ o.rotation.z = 0.1;
728
+ return onTop(o, 0.05);
729
+ }
730
+ case 'fez': {
731
+ const o = G(
732
+ M(new THREE.CylinderGeometry(0.16 * kk, 0.2 * kk, 0.3 * kk, 36), mat('felt', col), [0, 0.15 * kk, 0]),
733
+ M(new THREE.CylinderGeometry(0.006 * kk, 0.006 * kk, 0.14 * kk, 6), mat('felt', col2), [0.07 * kk, 0.305 * kk, 0.02 * kk], [0, 0, Math.PI / 2]),
734
+ );
735
+ const tas = G(
736
+ M(new THREE.CylinderGeometry(0.006 * kk, 0.006 * kk, 0.2 * kk, 6), mat('felt', col2), [0, -0.1 * kk, 0]),
737
+ M(new THREE.ConeGeometry(0.035 * kk, 0.09 * kk, 12), mat('felt', col2), [0, -0.2 * kk, 0]),
738
+ );
739
+ tas.position.set(0.14 * kk, 0.3 * kk, 0.02 * kk);
740
+ tas.rotation.z = 0.2;
741
+ o.add(tas);
742
+ this.anims.push((sec) => { tas.rotation.z = 0.2 + Math.sin(sec * 2.4) * 0.12; });
743
+ o.rotation.z = -0.1;
744
+ return onTop(o, 0.06);
745
+ }
746
+ case 'sombrero': {
747
+ const fm = mat('felt', col);
748
+ const o = G(
749
+ M(this.tor(0.5 * kk, 0.035 * kk, 10, 56), fm, [0, 0.03 * kk, 0], [Math.PI / 2, 0, 0]),
750
+ M(new THREE.CylinderGeometry(0.52 * kk, 0.52 * kk, 0.02 * kk, 56), fm),
751
+ M(new THREE.CylinderGeometry(0.13 * kk, 0.21 * kk, 0.26 * kk, 36), fm, [0, 0.13 * kk, 0]),
752
+ M(this.sph(0.13 * kk, 32, 12, 0, TAU, 0, Math.PI / 2), fm, [0, 0.26 * kk, 0], null, [1, 0.7, 1]),
753
+ M(new THREE.CylinderGeometry(0.205 * kk, 0.215 * kk, 0.05 * kk, 36), mat('felt', col2), [0, 0.04 * kk, 0]),
754
+ );
755
+ for (let i = 0; i < 12; i++) {
756
+ const a = (i / 12) * TAU;
757
+ o.add(M(this.sph(0.025 * kk, 10, 8), mat('felt', col2), [Math.cos(a) * 0.53 * kk, 0.0, Math.sin(a) * 0.53 * kk]));
758
+ }
759
+ return onTop(o, 0.05);
760
+ }
761
+ case 'bucket': {
762
+ const fm = mat('felt', col);
763
+ const o = G(
764
+ M(new THREE.CylinderGeometry(0.21 * kk, 0.26 * kk, 0.2 * kk, 40), fm, [0, 0.1 * kk, 0]),
765
+ M(this.sph(0.21 * kk, 40, 10, 0, TAU, 0, Math.PI / 2), fm, [0, 0.2 * kk, 0], null, [1, 0.25, 1]),
766
+ M(new THREE.CylinderGeometry(0.27 * kk, 0.37 * kk, 0.06 * kk, 48), mat('felt', shade(col, -0.08)), [0, -0.01 * kk, 0]),
767
+ );
768
+ return onTop(o, 0.08);
769
+ }
770
+ case 'jester': {
771
+ const o = G(fur(new THREE.TorusGeometry(0.24 * kk, 0.04 * kk, 12, 40).rotateX(Math.PI / 2), shade(col, -0.2), 0.6));
772
+ const cols = [col, col2, col];
773
+ [-1, 0, 1].forEach((s, i) => {
774
+ const len = s ? 0.42 : 0.5;
775
+ const horn = G(M(new THREE.ConeGeometry(0.12 * kk, len * kk, 24), mat('felt', cols[i]), [0, (len / 2) * kk, 0]));
776
+ horn.add(M(this.sph(0.04 * kk, 14, 10), mat('metal', '#facc15'), [0, len * kk, 0]));
777
+ horn.position.set(s * 0.12 * kk, 0.02 * kk, 0);
778
+ horn.rotation.z = -s * 0.95;
779
+ o.add(horn);
780
+ const ph = i * 1.7;
781
+ this.anims.push((sec) => { horn.rotation.z = -s * 0.95 + Math.sin(sec * 1.8 + ph) * 0.06; });
782
+ });
783
+ return onTop(o, 0.05);
784
+ }
785
+ case 'beanie': case 'santa': case 'cap': case 'hardhat': case 'viking': case 'beret': case 'propeller': {
655
786
  const cp = capFor(id === 'beret' ? 0.14 : 0.24);
656
787
  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
788
  const o = G();
@@ -663,8 +794,19 @@ export class Character3D {
663
794
  } else if (id === 'beret') {
664
795
  o.add(M(dome, mat('felt', col), null, [0, 0, -0.25], [1.25, 0.45, 1.2]));
665
796
  } else {
666
- const m = id === 'viking' ? mat('metal', '#a3a3a3') : id === 'cap' ? mat('felt', col) : mat('plastic', col);
797
+ const m = id === 'viking' ? mat('metal', '#a3a3a3') : id === 'cap' || id === 'propeller' ? mat('felt', col) : mat('plastic', col);
667
798
  o.add(M(dome, m));
799
+ if (id === 'propeller') {
800
+ // gorra de hélice: gajo de color, varilla y hélice que gira despacio
801
+ const hh = top.y - cp.y + 0.05 * kk;
802
+ o.add(M(this.sph(1, 24, 12, Math.PI / 2 - 0.35, 0.7, 0, Math.PI / 2).scale(cp.w * 1.085, hh * 1.005, cp.d * 1.105), mat('felt', col2)));
803
+ o.add(M(new THREE.CylinderGeometry(0.008 * kk, 0.008 * kk, 0.09 * kk, 8), mat('metal', '#9ca3af'), [0, hh + 0.04 * kk, 0]));
804
+ const prop = G(M(this.sph(0.02 * kk, 12, 8), mat('plastic', col2)));
805
+ for (const [sx, c2] of [[-1, '#ef4444'], [1, '#22c55e']]) prop.add(M(this.sph(1, 16, 8), mat('plastic', c2), [sx * 0.085 * kk, 0, 0], [sx * 0.3, 0, 0], [0.085 * kk, 0.008 * kk, 0.028 * kk]));
806
+ prop.position.y = hh + 0.085 * kk;
807
+ o.add(prop);
808
+ this.anims.push((sec) => { prop.rotation.y = sec * 5; });
809
+ }
668
810
  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
811
  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
812
  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]));
@@ -1044,16 +1186,77 @@ export class Character3D {
1044
1186
  return g;
1045
1187
  }
1046
1188
  default:
1047
- return null;
1189
+ return this.spec._modParts?.[id] ? this.buildModPart(this.spec._modParts[id], a, c) : null;
1048
1190
  }
1049
1191
  }
1050
1192
 
1193
+ /**
1194
+ * Pieza de un mod: su glb (unidades del mod: 1 = 1 px de un ot de 120 de ancho, y hacia
1195
+ * arriba) o, si no trae, su SVG en un plano. Se carga en segundo plano; el pivote es el ancla.
1196
+ */
1197
+ buildModPart(p, a, c) {
1198
+ const { L, X, Y, probe } = c;
1199
+ const at = L.anchors[p.anchor] || L.anchors.center;
1200
+ const sc = (L.box.w / 120) * U;
1201
+ const x0 = X(at.x);
1202
+ const y0 = Y(at.y);
1203
+ const z0 = p.depth === 'center' ? 0 : (probe.front(x0, y0)?.p.z ?? 0) + this.furLen;
1204
+ const g = G();
1205
+ g.position.set(x0, y0, z0);
1206
+ g.userData.pivot = g.position.clone();
1207
+ const plane = () => this.modPlane(p, a, sc);
1208
+ if (!p.glb) return g.add(plane()), g;
1209
+ const holder = G();
1210
+ holder.scale.setScalar(sc);
1211
+ g.add(holder);
1212
+ const tint = p.tint === 'body' ? this.spec.body.color : p.tint === 'color' ? a.color : '';
1213
+ loadGlb(p.glb).then((scene) => {
1214
+ const o = scene.clone(true);
1215
+ o.traverse((m) => {
1216
+ if (!m.isMesh) return;
1217
+ m.geometry.userData.shared = true;
1218
+ if (tint) {
1219
+ m.material = m.material.clone();
1220
+ m.material.color?.set(tint);
1221
+ m.userData.ownMaterial = true;
1222
+ }
1223
+ });
1224
+ holder.add(o);
1225
+ }).catch(() => g.add(plane()));
1226
+ return g;
1227
+ }
1228
+
1229
+ /** SVG de una pieza de mod pintado en un plano (respaldo 3D de los mods sin glb). */
1230
+ modPlane(p, a, sc) {
1231
+ const [vx, vy, vw, vh] = p.view;
1232
+ const def = modPart(this.spec, a.id, shade);
1233
+ const inner = def ? def.draw({ k: 1, color: a.color, color2: a.color2, body: this.spec.body.color }) : '';
1234
+ const px = 256 / Math.max(vw, vh);
1235
+ const cv = document.createElement('canvas');
1236
+ cv.width = Math.max(2, Math.round(vw * px));
1237
+ cv.height = Math.max(2, Math.round(vh * px));
1238
+ const tex = new THREE.CanvasTexture(cv);
1239
+ tex.colorSpace = THREE.SRGBColorSpace;
1240
+ const img = new Image();
1241
+ img.onload = () => {
1242
+ cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
1243
+ tex.needsUpdate = true;
1244
+ };
1245
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vx} ${vy} ${vw} ${vh}" width="${cv.width}" height="${cv.height}">${inner}</svg>`)}`;
1246
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(vw * sc, vh * sc), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
1247
+ m.position.set((vx + vw / 2) * sc, -(vy + vh / 2) * sc, p.layer === 'back' ? -0.01 : 0.01);
1248
+ m.userData.ownMaterial = true;
1249
+ return m;
1250
+ }
1251
+
1051
1252
  disposeRig() {
1052
1253
  this.rig.traverse((m) => {
1053
1254
  if (!m.isMesh) return;
1054
1255
  if (!m.geometry.userData.shared) m.geometry.dispose();
1055
1256
  if (m.userData.ownMaterial) m.material.dispose();
1056
1257
  });
1258
+ this.model?.mixer?.stopAllAction();
1259
+ this.model = null;
1057
1260
  this.rig.clear();
1058
1261
  if (this.shadow) {
1059
1262
  this.shadow.material.dispose();
@@ -1073,6 +1276,7 @@ export class Character3D {
1073
1276
 
1074
1277
  /** Avanza un frame. t: performance.now() ms; dt: ms. */
1075
1278
  tick(t, dt) {
1279
+ if (this.modelMode) return this.tickModel(t, dt);
1076
1280
  const rm = reducedMotion();
1077
1281
  const idle = this.opts.idle !== false;
1078
1282
  // parpadeo y miradas espontáneas
@@ -1125,26 +1329,7 @@ export class Character3D {
1125
1329
  }
1126
1330
  if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
1127
1331
 
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
- }
1332
+ const { ksx, ksy, act } = this.tickMorphs(t, rm);
1148
1333
  if (act.shape && !act.shape.ready) this.startShape(act.shape);
1149
1334
  const shapeK = act.shape?.ready ? act.shape.k : 0;
1150
1335
  this.bodyU.uMorph.value.set(act.melt?.k || 0, rm ? 0 : act.jelly?.k || 0, shapeK, 0);
@@ -1282,7 +1467,42 @@ export class Character3D {
1282
1467
  hnd.scale.setScalar(Math.max(0.01, mhd.k * (p.s || 1)) * (type === 'open' ? 1 : 0.9));
1283
1468
  }
1284
1469
 
1285
- // efectos
1470
+ const blush = this.tickEffects(t, rm);
1471
+ this.bodyU.uBlush.value = blush;
1472
+ this.fxg.position.copy(rig.position);
1473
+ this.fxg.rotation.y = this.yaw;
1474
+
1475
+ for (const f of this.furs) f.u.uTime.value = sec;
1476
+ for (const a of this.anims) a(rm ? 0 : sec);
1477
+ }
1478
+
1479
+ /** Pinchos: conos instanciados en el contorno (sin la base) y en la espalda. */
1480
+ /** Fases de las metamorfosis como en 2D (entra con rebote, sale suave): { ksx, ksy, act }. */
1481
+ tickMorphs(t, rm) {
1482
+ let ksx = 1;
1483
+ let ksy = 1;
1484
+ const act = {};
1485
+ for (const [slot, mm] of this.morphs) {
1486
+ if (mm.phase !== 'out' && t >= mm.until) { mm.phase = 'out'; mm.outAt = t; mm.k0 = mm.k; }
1487
+ if (mm.phase === 'in') {
1488
+ const q = clamp((t - mm.start) / MORPH_IN);
1489
+ mm.k = rm ? 1 : mm.shape ? EASE.inOut(q) : EASE.backOut(q);
1490
+ if (q >= 1) mm.phase = 'on';
1491
+ } else if (mm.phase === 'on') mm.k = 1;
1492
+ else {
1493
+ const q = clamp((t - mm.outAt) / MORPH_OUT);
1494
+ mm.k = rm ? 0 : mm.k0 * (1 - EASE.inOut(q));
1495
+ if (q >= 1) { this.morphs.delete(slot); this.endMorph(slot); continue; }
1496
+ }
1497
+ act[slot] = mm;
1498
+ const sc = SCALE_MORPH[slot];
1499
+ if (sc) { const [a, b] = sc(mm.k); ksx *= a; ksy *= b; }
1500
+ }
1501
+ return { ksx, ksy, act };
1502
+ }
1503
+
1504
+ /** Avanza los efectos; devuelve el rubor que piden. */
1505
+ tickEffects(t, rm) {
1286
1506
  let blush = 0;
1287
1507
  if (this.fxs.length) {
1288
1508
  const c = this.fxc;
@@ -1306,15 +1526,9 @@ export class Character3D {
1306
1526
  return true;
1307
1527
  });
1308
1528
  }
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);
1529
+ return blush;
1315
1530
  }
1316
1531
 
1317
- /** Pinchos: conos instanciados en el contorno (sin la base) y en la espalda. */
1318
1532
  makeSpikes(color) {
1319
1533
  const L = this.L;
1320
1534
  const list = [];
@@ -1412,7 +1626,7 @@ export class Character3D {
1412
1626
 
1413
1627
  /** Limpia lo que deja una metamorfosis al terminar. */
1414
1628
  endMorph(slot) {
1415
- if (slot === 'shape') {
1629
+ if (slot === 'shape' && this.B) {
1416
1630
  for (const b of this.bodyMeshes) b.geometry = this.B.geometry;
1417
1631
  this.shapeGeo?.dispose();
1418
1632
  this.shapeGeo = null;
@@ -1691,3 +1905,5 @@ export class Character3D {
1691
1905
  this.root.removeFromParent();
1692
1906
  }
1693
1907
  }
1908
+
1909
+ Object.assign(Character3D.prototype, { buildModel, tickModel, modelGesture });
@@ -93,6 +93,7 @@ export function createCharacter3D(el, spec, opts = {}) {
93
93
  const ch = new Character3D(spec, { quality: opts.quality || 'high', material: opts.material || 'plush', moodFx: opts.moodFx });
94
94
  scene.add(ch.root);
95
95
  let aspect = 0;
96
+ let rev = ch.boundsRev;
96
97
  const frame = () => {
97
98
  const b = ch.bounds;
98
99
  const size = b.getSize(new THREE.Vector3());
@@ -114,8 +115,9 @@ export function createCharacter3D(el, spec, opts = {}) {
114
115
  bleed: opts.bleed || 0,
115
116
  tick(t, dt) {
116
117
  const a = this.rect.width / this.rect.height;
117
- if (Math.abs(a - aspect) > 1e-3) {
118
+ if (Math.abs(a - aspect) > 1e-3 || rev !== ch.boundsRev) {
118
119
  aspect = a;
120
+ rev = ch.boundsRev; // un mod de cuerpo completo cambia la caja al cargar su modelo
119
121
  camera.aspect = a;
120
122
  frame();
121
123
  }
@@ -239,6 +241,10 @@ export function createCrowd3D(el, opts = {}) {
239
241
  camera.aspect = a;
240
242
  fit();
241
243
  }
244
+ if (chars.some((c) => c.boundsRev !== c.seenRev)) {
245
+ for (const c of chars) c.seenRev = c.boundsRev;
246
+ fit();
247
+ }
242
248
  for (const c of chars) {
243
249
  c.tick(t, dt);
244
250
  c.lod(pixelHeight(this, c));