@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
@@ -4,12 +4,15 @@
4
4
  * const ed = createAvatarEditor(div, { value: identity.look, onChange: (look) => … });
5
5
  * <ots-avatar-editor></ots-avatar-editor> → el.value = {...}; el.addEventListener('change', e => e.detail)
6
6
  *
7
- * Valor: { kind: 'character'|'realistic'|'image', character: spec, avatar: { url, body }, image }
7
+ * Valor: { kind: 'character'|'plush'|'image', character: spec, material: 'plush'|'vinyl', avatar, image }
8
+ * (un 'realistic' heredado se abre como 'character'; `avatar` solo se conserva).
8
9
  *
9
10
  * - Personaje 2D: estilos, forma (se deforma arrastrando sus puntos, con simetría), color,
10
11
  * acabado, ojos, boca, cara y accesorios (arrastrables). Deshacer/rehacer, aleatorio,
11
12
  * exportar SVG/JSON, vista previa hablando/con ánimos/gestos.
12
- * - Realista 3D: modelo .glb para TalkingHead (rig Mixamo + blendshapes ARKit + visemas Oculus).
13
+ * - Personaje 3D: la MISMA spec y las mismas pestañas, con vista previa 3D (src/character3d, se
14
+ * arrastra para girarlo) y material peluche/vinilo. Los puntos arrastrables son del 2D: en 3D se
15
+ * ocultan (forma, ojos y boca se cambian desde las pestañas).
13
16
  * - Imagen: retrato fijo.
14
17
  *
15
18
  * Idioma: atributo `lang` (o la propiedad nativa `el.lang`, u `opts.locale` en createAvatarEditor).
@@ -20,18 +23,19 @@
20
23
 
21
24
  import {
22
25
  normalizeCharacter, renderCharacter, createCharacter, applyPreset, randomCharacter, layout, bodyPoints,
23
- SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, ACCESSORY_GROUPS, PRESETS, BODY_POINTS,
26
+ SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, ACCESSORY_GROUPS, PRESETS, BODY_POINTS,
24
27
  CHARACTER_MOODS, CHARACTER_GESTURES, CHARACTER_MORPHS, partLabel,
25
28
  } from './Character.js';
26
29
  import { translator, detectLocale, getLocale, onLocaleChange, formatNumber } from '../i18n/index.js';
30
+ import { STYLES as ART_STYLES } from './styles.js';
31
+ import { loadCharacter3D } from '../avatar/three.js';
27
32
 
28
33
  const VIEW = [-20, -50, 240, 270];
29
34
  const AXIS = 100;
30
35
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
31
36
 
32
- export const SAMPLE_3D = [
33
- { label: 'Brunette (TalkingHead, MIT)', url: 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7.0/avatars/brunette.glb', body: 'F' },
34
- ];
37
+ const KINDS = ['character', 'plush', 'image'];
38
+ const MATERIALS = ['plush', 'vinyl'];
35
39
 
36
40
  const TABS = ['styles', 'shape', 'color', 'eyes', 'mouth', 'face', 'acc'];
37
41
  const GESTURES = CHARACTER_GESTURES;
@@ -45,7 +49,7 @@ const CSS = `
45
49
  --ae-bg:#ffffff;--ae-soft:#f4f4fa;--ae-line:#e4e4ee;--ae-fg:#17171f;--ae-muted:#6b6f82;--ae-p:#6d5dfc;--ae-p-soft:#6d5dfc1f;--ae-stage:radial-gradient(circle at 50% 35%,#ffffff,#eceaf7)}
46
50
  @media (prefers-color-scheme:dark){:host{--ae-bg:#17171e;--ae-soft:#1f1f28;--ae-line:#2c2c38;--ae-fg:#ececf3;--ae-muted:#9a9db0;--ae-stage:radial-gradient(circle at 50% 35%,#2a2a36,#15151c)}}
47
51
  :host([theme=light]){--ae-bg:#ffffff;--ae-soft:#f4f4fa;--ae-line:#e4e4ee;--ae-fg:#17171f;--ae-muted:#6b6f82;--ae-stage:radial-gradient(circle at 50% 35%,#ffffff,#eceaf7)}
48
- :host([theme=dark]){--ae-bg:#17171e;--ae-soft:#1f1f28;--ae-line:#2c2c38;--ae-fg:#ececf3;--ae-muted:#9a9db0;--ae-stage:radial-gradient(circle at 50% 35%,#2a2a36,#15151c)}
52
+ :host([theme=dark]){color-scheme:dark;--ae-bg:#17171e;--ae-soft:#1f1f28;--ae-line:#2c2c38;--ae-fg:#ececf3;--ae-muted:#9a9db0;--ae-stage:radial-gradient(circle at 50% 35%,#2a2a36,#15151c)}
49
53
  *{box-sizing:border-box}
50
54
  .wrap{background:var(--ae-bg);border:1px solid var(--ae-line);border-radius:16px;overflow:hidden}
51
55
  .kinds{display:flex;gap:4px;padding:10px;border-bottom:1px solid var(--ae-line);flex-wrap:wrap}
@@ -57,6 +61,7 @@ const CSS = `
57
61
  @media (max-width:760px){.left{border-right:0;border-bottom:1px solid var(--ae-line)}}
58
62
  .stage{position:relative;aspect-ratio:240/270;max-height:420px;margin:0 auto;width:100%;background:var(--ae-stage);touch-action:none;user-select:none}
59
63
  .stage>div,.stage>svg{position:absolute;inset:0;width:100%;height:100%}
64
+ .stage .ch3{cursor:grab}
60
65
  .ov{overflow:visible}
61
66
  .h{cursor:grab;fill:#fff;stroke:var(--ae-p);stroke-width:2.2;outline:none}
62
67
  .h:hover,.h:focus-visible{fill:var(--ae-p)}
@@ -69,7 +74,8 @@ const CSS = `
69
74
  .btn:hover{border-color:var(--ae-p)}
70
75
  .btn:disabled{opacity:.4;cursor:default}
71
76
  .btn.p{background:var(--ae-p);border-color:var(--ae-p);color:#fff}
72
- select.btn{padding:5px 8px}
77
+ select.btn{padding:6px 28px 6px 10px;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239a9db0' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;background-size:14px}
78
+ select.btn option{background:var(--ae-soft);color:var(--ae-fg)}
73
79
  .right{display:flex;flex-direction:column;min-width:0}
74
80
  .tabs{display:flex;gap:2px;padding:8px 8px 0;overflow-x:auto;border-bottom:1px solid var(--ae-line);scrollbar-width:thin}
75
81
  .tabs button{border-radius:9px 9px 0 0;white-space:nowrap;padding:8px 11px}
@@ -81,6 +87,10 @@ select.btn{padding:5px 8px}
81
87
  .opt span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
82
88
  .opt:hover{border-color:var(--ae-line)}
83
89
  .opt[aria-pressed=true]{border-color:var(--ae-p);background:var(--ae-p-soft);color:var(--ae-fg)}
90
+ .arts{grid-template-columns:none;grid-template-rows:repeat(2,auto);grid-auto-flow:column;grid-auto-columns:minmax(78px,86px);overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x proximity;padding-bottom:6px;scrollbar-width:thin}
91
+ .arts .opt{scroll-snap-align:start}
92
+ .arts i{width:100%;aspect-ratio:1;display:block;border-radius:8px;background:var(--ae-line);opacity:.5}
93
+ .arts img{width:100%;aspect-ratio:1;display:block}
84
94
  h4{margin:16px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--ae-muted);font-weight:600}
85
95
  h4:first-child{margin-top:0}
86
96
  .row{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px 14px}
@@ -98,6 +108,8 @@ input[type=text],input[type=url]{font:inherit;width:100%;padding:7px 9px;border-
98
108
  .card{border:1px solid var(--ae-line);border-radius:12px;padding:12px;background:var(--ae-soft)}
99
109
  .sel-acc{margin-top:12px}
100
110
  .hidden{display:none!important}
111
+ :host([simple]) [data-kind=image],:host([simple]) [data-bar=export]{display:none!important}
112
+ :host([simple]) .wrap{border:0;border-radius:0;background:transparent}
101
113
  .alt{padding:18px;display:grid;gap:14px;max-width:640px}
102
114
  .alt img{max-width:160px;border-radius:14px;border:1px solid var(--ae-line)}
103
115
  `;
@@ -106,10 +118,11 @@ const SWATCHES = ['#a0714f', '#f1c27d', '#fcd9b6', '#e0a97e', '#8d5524', '#ef444
106
118
 
107
119
  /** Valor completo del editor a partir de un `look` de identidad (o parcial). */
108
120
  export function normalizeEditorValue(v = {}) {
109
- const kind = ['character', 'realistic', 'image'].includes(v.kind) ? v.kind : 'character';
121
+ const kind = KINDS.includes(v.kind) ? v.kind : 'character'; // 'realistic' (heredado) → character
110
122
  return {
111
123
  kind,
112
124
  character: normalizeCharacter(v.character || {}),
125
+ material: MATERIALS.includes(v.material) ? v.material : 'plush',
113
126
  avatar: { url: typeof v.avatar?.url === 'string' ? v.avatar.url : '', body: v.avatar?.body === 'M' ? 'M' : 'F' },
114
127
  image: typeof v.image === 'string' ? v.image : '',
115
128
  };
@@ -117,12 +130,15 @@ export function normalizeEditorValue(v = {}) {
117
130
 
118
131
  /**
119
132
  * @param {HTMLElement} container
120
- * @param {{ value?: object, onChange?: (v:object)=>void, realistic?: boolean, locale?: string }} [opts]
133
+ * @param {{ value?: object, onChange?: (v:object)=>void, plush?: boolean, simple?: boolean, theme?: 'light'|'dark', locale?: string }} [opts]
134
+ * plush: false oculta el tipo «Personaje 3D» (se acepta también el antiguo `realistic: false`).
121
135
  * locale: idioma de la interfaz ('es', 'en', 'pt'…); se puede cambiar luego con `el.lang = 'en'`.
122
136
  */
123
137
  export function createAvatarEditor(container, opts = {}) {
124
138
  const el = document.createElement('ots-avatar-editor');
125
- if (opts.realistic === false) el.setAttribute('no-realistic', '');
139
+ if (opts.plush === false || opts.realistic === false) el.setAttribute('no-plush', '');
140
+ if (opts.simple) el.setAttribute('simple', ''); // sin selector de tipo ni exportar (p. ej. la portada)
141
+ if (opts.theme) el.setAttribute('theme', opts.theme);
126
142
  if (opts.locale) el.setAttribute('lang', opts.locale);
127
143
  if (opts.value) el.value = opts.value;
128
144
  if (opts.onChange) el.addEventListener('change', (e) => opts.onChange(e.detail));
@@ -215,8 +231,10 @@ export class AvatarEditor extends Base {
215
231
  <div class="left">
216
232
  <div class="stage">
217
233
  <div class="ch"></div>
234
+ <div class="ch3 hidden"></div>
218
235
  <svg class="ov" viewBox="${VIEW.join(' ')}"></svg>
219
236
  </div>
237
+ <div class="bar hidden" data-bar="material"></div>
220
238
  <div class="bar" data-bar="play"></div>
221
239
  <div class="bar" data-bar="export"></div>
222
240
  </div>
@@ -225,7 +243,6 @@ export class AvatarEditor extends Base {
225
243
  <div class="panel" role="tabpanel"></div>
226
244
  </div>
227
245
  </div>
228
- <div class="alt hidden" data-view="realistic"></div>
229
246
  <div class="alt hidden" data-view="image"></div>
230
247
  </div>`;
231
248
  this.$ = (s) => root.querySelector(s);
@@ -240,8 +257,8 @@ export class AvatarEditor extends Base {
240
257
  this._ov.addEventListener('pointerdown', (e) => this._onDown(e));
241
258
  this._ov.addEventListener('keydown', (e) => this._onHandleKey(e));
242
259
  this._ov.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
243
- this.$('.stage').addEventListener('pointermove', (e) => { if (!this._drag) this._ch.lookAtPoint(e.clientX, e.clientY); });
244
- this.$('.stage').addEventListener('pointerleave', () => this._ch.lookAt(0, 0));
260
+ this.$('.stage').addEventListener('pointermove', (e) => { if (!this._drag) this._pv.lookAtPoint(e.clientX, e.clientY); });
261
+ this.$('.stage').addEventListener('pointerleave', () => this._pv.lookAt(0, 0));
245
262
  this.addEventListener('keydown', (e) => {
246
263
  if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return;
247
264
  const t = e.composedPath()[0];
@@ -253,9 +270,55 @@ export class AvatarEditor extends Base {
253
270
  }
254
271
 
255
272
  disconnectedCallback() {
273
+ this._artIO?.disconnect();
256
274
  this._speech?.stop?.();
257
275
  this._offLocale?.();
258
276
  this._offLocale = null;
277
+ cancelAnimationFrame(this._raf3);
278
+ this._raf3 = 0;
279
+ this._ch3?.destroy();
280
+ this._ch3 = null;
281
+ this._ch3P = null;
282
+ }
283
+
284
+ // ───────────── vista previa 3D ─────────────
285
+
286
+ /** Vista previa activa: la 3D en «Personaje 3D» (si cargó), si no la 2D. Misma API. */
287
+ get _pv() {
288
+ return this._v.kind === 'plush' && this._ch3 ? this._ch3 : this._ch;
289
+ }
290
+
291
+ /** Carga (una vez) el ot 3D en la vista previa. Si falla, se queda la 2D con un aviso. */
292
+ _mount3d() {
293
+ if (this._ch3 || this._ch3P) return;
294
+ const p = (this._ch3P = loadCharacter3D()
295
+ .then((m) => {
296
+ if (!this.isConnected || this._ch3P !== p) return;
297
+ this._ch3 = m.createCharacter3D(this.$('.ch3'), this._v.character, {
298
+ material: this._v.material, orbit: true, stage: { key: 'overlay', zIndex: 2147483001, maxDpr: 1.5 },
299
+ });
300
+ if (this._ch3.fallback) throw new Error('webgl');
301
+ this._ch3.mood(this.$('[data-a=mood]')?.value || 'neutral', { fx: false });
302
+ this._renderKind();
303
+ })
304
+ .catch((err) => {
305
+ console.warn('[7ots] vista previa 3D no disponible:', err?.message || err);
306
+ this._ch3?.destroy?.();
307
+ this._ch3 = null;
308
+ this._failed3d = true;
309
+ if (!this.isConnected) return;
310
+ this._renderKind();
311
+ this._msg?.(this._t('editor.plush.unavailable'));
312
+ }));
313
+ }
314
+
315
+ /** Actualiza la vista 3D como mucho una vez por frame (reconstruir la geometría cuesta). */
316
+ _sync3d() {
317
+ if (!this._ch3 || this._raf3) return;
318
+ this._raf3 = requestAnimationFrame(() => {
319
+ this._raf3 = 0;
320
+ this._ch3?.update(this._v.character, { material: this._v.material });
321
+ });
259
322
  }
260
323
 
261
324
  // ───────────── estado ─────────────
@@ -269,6 +332,7 @@ export class AvatarEditor extends Base {
269
332
  mut(next);
270
333
  this._v.character = normalizeCharacter(next);
271
334
  this._ch.update(this._v.character);
335
+ this._sync3d();
272
336
  this._drawOverlay();
273
337
  if (commit) this._commit(panel);
274
338
  }
@@ -314,6 +378,7 @@ export class AvatarEditor extends Base {
314
378
 
315
379
  _renderAll() {
316
380
  this._ch.update(this._c);
381
+ this._sync3d();
317
382
  this._renderKind();
318
383
  this._renderPanel();
319
384
  this._drawOverlay();
@@ -325,7 +390,7 @@ export class AvatarEditor extends Base {
325
390
  const t = this._t;
326
391
  const kinds = this.$('.kinds');
327
392
  kinds.setAttribute('aria-label', t('editor.kinds.label'));
328
- kinds.innerHTML = ['character', ...(this.hasAttribute('no-realistic') ? [] : ['realistic']), 'image']
393
+ kinds.innerHTML = KINDS.filter((k) => k !== 'plush' || !(this.hasAttribute('no-plush') || this.hasAttribute('no-realistic')))
329
394
  .map((k) => `<button type="button" data-kind="${k}">${esc(t(`editor.kinds.${k}`))}</button>`)
330
395
  .join('');
331
396
  this._ov.setAttribute('aria-label', t('editor.controls'));
@@ -341,6 +406,9 @@ export class AvatarEditor extends Base {
341
406
  <select class="btn" data-a="gesture" aria-label="${esc(t('editor.gesture'))}"><option value="">${esc(t('editor.gesturePick'))}</option>${GESTURES.map((g) => `<option value="${g}">${esc(partLabel(t, 'gestures', g))}</option>`).join('')}</select>
342
407
  <select class="btn" data-a="morph" aria-label="${esc(t('editor.morph'))}"><option value="">${esc(t('editor.morphPick'))}</option>${MORPHS.map((m) => `<option value="${m}">${esc(partLabel(t, 'morphs', m))}</option>`).join('')}</select>`;
343
408
  this.$('[data-a=mood]').value = mood;
409
+ this.$('[data-bar=material]').innerHTML = `<span class="hint" style="margin:0">${esc(t('editor.plush.material'))}</span>
410
+ <span class="chips">${MATERIALS.map((m) => `<button type="button" class="chip" data-material="${m}">${esc(t(`editor.plush.materials.${m}`))}</button>`).join('')}</span>
411
+ <span class="hint" style="margin:0">${esc(t('editor.plush.hint'))}</span>`;
344
412
  this.$('[data-bar=export]').innerHTML = `
345
413
  <button class="btn" type="button" data-a="svg">⬇ SVG</button>
346
414
  <button class="btn" type="button" data-a="png">⬇ PNG</button>
@@ -350,11 +418,6 @@ export class AvatarEditor extends Base {
350
418
  this.$('.tabs').innerHTML = TABS.map((id) => `<button type="button" role="tab" data-tab="${id}">${esc(t(`editor.tabs.${id}`))}</button>`).join('');
351
419
  }
352
420
 
353
- /** Texto del catálogo escapado, con variables que ya son HTML (p. ej. `{name}` → <b>…</b>). */
354
- _html(key, html = {}) {
355
- return esc(this._t(key)).replace(/\{(\w+)\}/g, (m, k) => html[k] ?? m);
356
- }
357
-
358
421
  _syncBar() {
359
422
  this.$('[data-a=undo]').disabled = !this._past.length;
360
423
  this.$('[data-a=redo]').disabled = !this._future.length;
@@ -362,30 +425,20 @@ export class AvatarEditor extends Base {
362
425
 
363
426
  _renderKind() {
364
427
  const k = this._v.kind;
428
+ const plush = k === 'plush';
365
429
  this.$$('[data-kind]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.kind === k)));
366
- this.$$('[data-view]').forEach((v) => v.classList.toggle('hidden', v.dataset.view !== k));
367
- if (k === 'realistic') this._renderRealistic();
430
+ // «Personaje 3D» comparte la vista del 2D (mismas pestañas); solo cambia la vista previa
431
+ this.$$('[data-view]').forEach((v) => v.classList.toggle('hidden', v.dataset.view !== (plush ? 'character' : k)));
432
+ if (plush && !this._failed3d) this._mount3d();
433
+ const show3d = plush && !!this._ch3;
434
+ this.$('.ch').classList.toggle('hidden', show3d);
435
+ this.$('.ch3').classList.toggle('hidden', !show3d);
436
+ this.$('.ov').classList.toggle('hidden', plush);
437
+ this.$('[data-bar=material]').classList.toggle('hidden', !plush);
438
+ this.$$('[data-material]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.material === this._v.material)));
368
439
  if (k === 'image') this._renderImage();
369
440
  }
370
441
 
371
- _renderRealistic() {
372
- const a = this._v.avatar;
373
- const t = this._t;
374
- this.$('[data-view=realistic]').innerHTML = `
375
- <div><h4>${esc(t('editor.realistic.title'))}</h4>
376
- <p class="hint" style="margin:0">${esc(t('editor.realistic.intro'))}</p></div>
377
- <label class="f">${esc(t('editor.realistic.url'))}<input type="url" data-m="avatar.url" placeholder="https://…/avatar.glb" value="${esc(a.url)}"></label>
378
- <div><h4>${esc(t('editor.realistic.body'))}</h4><div class="chips">
379
- <button type="button" class="chip" data-m-body="F" aria-pressed="${a.body === 'F'}">${esc(t('editor.realistic.bodyF'))}</button>
380
- <button type="button" class="chip" data-m-body="M" aria-pressed="${a.body === 'M'}">${esc(t('editor.realistic.bodyM'))}</button></div></div>
381
- <div><h4>${esc(t('editor.realistic.samples'))}</h4><div class="chips">${SAMPLE_3D.map((s, i) => `<button type="button" class="chip" data-sample="${i}" aria-pressed="${a.url === s.url}">${esc(s.label)}</button>`).join('')}</div></div>
382
- <div class="card"><strong>${esc(t('editor.realistic.ownTitle'))}</strong>
383
- <p class="hint">· ${this._html('editor.realistic.ownMpfb', { name: '<b>MPFB2 / MakeHuman</b>' })}<br>
384
- · ${this._html('editor.realistic.ownVroid', { name: '<b>VRoid Studio</b>' })}<br>
385
- · ${esc(t('editor.realistic.ownCommercial'))}</p>
386
- <p class="hint">${esc(t('editor.realistic.fallback'))}</p></div>`;
387
- }
388
-
389
442
  _renderImage() {
390
443
  const img = this._v.image;
391
444
  const t = this._t;
@@ -409,11 +462,66 @@ export class AvatarEditor extends Base {
409
462
  return `<div class="grid">${items
410
463
  .map((it) => {
411
464
  const label = esc(partLabel(this._t, kind, it.id));
412
- return `<button type="button" class="opt" ${attr}="${it.id}" aria-pressed="${pressed(it)}" title="${label}">${renderCharacter(mk(it), { open: 0 }, { viewBox: view })}<span>${label}</span></button>`;
465
+ return `<button type="button" class="opt" ${attr}="${it.id}" aria-pressed="${pressed(it)}" title="${label}">${renderCharacter({ ...mk(it), style: 'none' }, { open: 0 }, { viewBox: view })}<span>${label}</span></button>`;
413
466
  })
414
467
  .join('')}</div>`;
415
468
  }
416
469
 
470
+ /**
471
+ * Tira de estilos artísticos (styles.js): el personaje actual en cada estilo, con su fondo.
472
+ * Son muchos SVG con filtros: cada miniatura se rasteriza una vez como <img> estática, solo
473
+ * cuando entra en vista, y se recuerda mientras el personaje (salvo el estilo) no cambie.
474
+ */
475
+ _artStyles() {
476
+ const cur = this._c.style || 'none';
477
+ const opts = ART_STYLES.map((st) => {
478
+ const label = esc(partLabel(this._t, 'styles', st.id));
479
+ const src = this._artThumbs?.get(st.id);
480
+ return `<button type="button" class="opt" data-artstyle="${st.id}" aria-pressed="${st.id === cur}" title="${label}">${src ? `<img alt="" src="${src}">` : '<i></i>'}<span>${label}</span></button>`;
481
+ });
482
+ return `<h4>${esc(this._t('editor.artStyle.title'))}</h4><div class="grid arts">${opts.join('')}</div><p class="hint">${esc(this._t('editor.artStyle.hint'))}</p>`;
483
+ }
484
+
485
+ /** Pinta las miniaturas pendientes de la tira según van apareciendo. */
486
+ _lazyArtThumbs() {
487
+ this._artIO?.disconnect();
488
+ const strip = this.$('.arts');
489
+ if (!strip) return;
490
+ const { style, ...rest } = this._c;
491
+ const view = this._thumbView('full');
492
+ const key = JSON.stringify(rest) + view;
493
+ if (this._artKey !== key) {
494
+ this._artKey = key;
495
+ this._artThumbs = new Map();
496
+ strip.querySelectorAll('img').forEach((img) => img.replaceWith(document.createElement('i')));
497
+ }
498
+ const fill = (btn) => {
499
+ const ph = btn.querySelector('i');
500
+ if (!ph) return;
501
+ const id = btn.dataset.artstyle;
502
+ let src = this._artThumbs.get(id);
503
+ if (!src) {
504
+ src = `data:image/svg+xml,${encodeURIComponent(renderCharacter({ ...rest, style: id }, { open: 0 }, { viewBox: view }))}`;
505
+ this._artThumbs.set(id, src);
506
+ }
507
+ const img = document.createElement('img');
508
+ img.alt = '';
509
+ img.src = src;
510
+ ph.replaceWith(img);
511
+ };
512
+ const todo = [...strip.querySelectorAll('.opt')].filter((b) => b.querySelector('i'));
513
+ if (!todo.length) return;
514
+ if (typeof IntersectionObserver !== 'function') return todo.forEach(fill);
515
+ this._artIO = new IntersectionObserver((entries, io) => {
516
+ for (const e of entries) {
517
+ if (!e.isIntersecting) continue;
518
+ io.unobserve(e.target);
519
+ fill(e.target);
520
+ }
521
+ }, { root: strip, rootMargin: '0px 90px' });
522
+ todo.forEach((b) => this._artIO.observe(b));
523
+ }
524
+
417
525
  _range(path, key, min, max, step, value) {
418
526
  return `<label class="f">${esc(this._t(key))}<input type="range" data-k="${path}" min="${min}" max="${max}" step="${step}" value="${value}"></label>`;
419
527
  }
@@ -432,7 +540,7 @@ export class AvatarEditor extends Base {
432
540
  let h = '';
433
541
  switch (this._tab) {
434
542
  case 'styles':
435
- h = `<h4>${T('editor.styles.title')}</h4>${this._grid('presets', PRESETS, c.preset, 'data-preset', (p) => ({ preset: p.id }), full)}
543
+ h = `${this._artStyles()}<h4>${T('editor.styles.title')}</h4>${this._grid('presets', PRESETS, c.preset, 'data-preset', (p) => ({ preset: p.id }), full)}
436
544
  <p class="hint">${T('editor.styles.hint')}</p>
437
545
  <label class="c" style="margin-top:8px"><input type="checkbox" data-keep-body ${this._keepBody ? 'checked' : ''}> ${T('editor.styles.keepBody')}</label>`;
438
546
  break;
@@ -451,6 +559,7 @@ export class AvatarEditor extends Base {
451
559
  h = `<h4>${T('editor.color.body')}</h4><div class="sw">${SWATCHES.map((s) => `<button type="button" data-swatch="${s}" style="background:${s}" title="${s}" aria-label="${T('editor.color.swatch', { color: s })}"></button>`).join('')}</div>
452
560
  <div class="row" style="margin-top:10px">${this._color('body.color', 'editor.color.custom', c.body.color)}</div>
453
561
  <h4>${T('editor.color.finish')}</h4>${this._grid('finishes', FINISHES, c.finish, 'data-finish', (f) => with_({ finish: f.id }), full)}
562
+ <h4>${T('editor.color.texture')}</h4>${this._grid('textures', TEXTURES, c.texture, 'data-texture', (x) => with_({ texture: x.id }), full)}
454
563
  <h4>${T('editor.color.outline')}</h4><div class="chips">${OUTLINES.map((v) => `<button type="button" class="chip" data-outline="${v}" aria-pressed="${c.outline === v}">${T(`editor.color.outlines.${v}`)}</button>`).join('')}</div>
455
564
  <h4>${T('editor.color.pattern')}</h4>${this._grid('patterns', PATTERNS, c.body.pattern, 'data-pattern', (p) => with_({ body: { ...c.body, pattern: p.id } }), full)}
456
565
  <div class="row" style="margin-top:10px">${this._color('body.patternColor', 'editor.color.patternColor', c.body.patternColor)}</div>`;
@@ -491,7 +600,13 @@ export class AvatarEditor extends Base {
491
600
  break;
492
601
  }
493
602
  }
603
+ const scroll = this.$('.arts')?.scrollLeft || 0;
494
604
  this.$('.panel').innerHTML = h;
605
+ const strip = this.$('.arts');
606
+ if (strip) {
607
+ strip.scrollLeft = scroll;
608
+ this._lazyArtThumbs();
609
+ } else this._artIO?.disconnect();
495
610
  }
496
611
 
497
612
  _accEditor() {
@@ -669,7 +784,10 @@ export class AvatarEditor extends Base {
669
784
  if (!b) return;
670
785
  const d = b.dataset;
671
786
  const c = this._c;
672
- if (d.kind) return this._setMeta((v) => (v.kind = d.kind));
787
+ if (d.kind) {
788
+ this._setMeta((v) => (v.kind = d.kind));
789
+ return this._drawOverlay();
790
+ }
673
791
  if (d.tab) {
674
792
  this._tab = d.tab;
675
793
  this._renderPanel();
@@ -677,9 +795,11 @@ export class AvatarEditor extends Base {
677
795
  return;
678
796
  }
679
797
  if (d.preset) return this._set((n) => Object.assign(n, applyPreset(n, d.preset, this._keepBody ? ['body', 'finish'] : [])));
798
+ if (d.artstyle) return this._set((n) => { n.style = d.artstyle; });
680
799
  if (d.shape) return this._set((n) => { n.body.shape = d.shape; n.body.points = null; });
681
800
  if (d.swatch) return this._set((n) => { n.body.color = d.swatch; });
682
801
  if (d.finish) return this._set((n) => { n.finish = d.finish; });
802
+ if (d.texture) return this._set((n) => { n.texture = d.texture; });
683
803
  if (d.outline) return this._set((n) => { n.outline = d.outline; });
684
804
  if (d.pattern) return this._set((n) => { n.body.pattern = d.pattern; });
685
805
  if (d.eyes) return this._set((n) => { n.eyes.type = d.eyes; });
@@ -705,8 +825,10 @@ export class AvatarEditor extends Base {
705
825
  this._renderPanel();
706
826
  return this._drawOverlay();
707
827
  }
708
- if (d.mBody) return this._setMeta((v) => (v.avatar.body = d.mBody));
709
- if (d.sample) return this._setMeta((v) => { v.avatar = { url: SAMPLE_3D[+d.sample].url, body: SAMPLE_3D[+d.sample].body }; });
828
+ if (d.material) {
829
+ this._setMeta((v) => (v.material = d.material));
830
+ return this._sync3d();
831
+ }
710
832
  const i = this._sel;
711
833
  switch (d.a) {
712
834
  case 'undo': return this._undo();
@@ -745,14 +867,17 @@ export class AvatarEditor extends Base {
745
867
 
746
868
  _onInput(e, final) {
747
869
  const t = e.target;
748
- if (t.dataset?.a === 'mood' && final) return this._ch.mood(t.value);
870
+ if (t.dataset?.a === 'mood' && final) {
871
+ this._ch.mood(t.value);
872
+ return this._ch3?.mood(t.value);
873
+ }
749
874
  if (t.dataset?.a === 'gesture' && final) {
750
- if (t.value) this._ch.gesture(t.value);
875
+ if (t.value) this._pv.gesture(t.value);
751
876
  t.value = '';
752
877
  return;
753
878
  }
754
879
  if (t.dataset?.a === 'morph' && final) {
755
- if (t.value) this._ch.morph(t.value);
880
+ if (t.value) this._pv.morph(t.value);
756
881
  t.value = '';
757
882
  return;
758
883
  }
@@ -764,7 +889,6 @@ export class AvatarEditor extends Base {
764
889
  if (!final) return;
765
890
  return this._setMeta((v) => {
766
891
  if (t.dataset.m === 'image') v.image = t.value.trim();
767
- else v.avatar.url = t.value.trim();
768
892
  });
769
893
  }
770
894
  const k = t.dataset?.k;
@@ -785,7 +909,7 @@ export class AvatarEditor extends Base {
785
909
  _talk() {
786
910
  // Lip-sync por visemas a partir de una frase de ejemplo (en el idioma del editor).
787
911
  this._speech?.stop?.();
788
- this._speech = this._ch.speak(this._t('editor.talkSample'));
912
+ this._speech = this._pv.speak(this._t('editor.talkSample'));
789
913
  }
790
914
 
791
915
  _exportSVG() {