@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
@@ -16,14 +16,14 @@
16
16
  */
17
17
 
18
18
  import '../i18n/messages/character.js';
19
- import { t as globalT } from '../i18n/index.js';
19
+ import { t as globalT, addCatalog } from '../i18n/index.js';
20
20
 
21
21
  const n = (v) => Math.round(v * 100) / 100;
22
22
 
23
23
  /**
24
24
  * Nombre traducido de una pieza del catálogo.
25
25
  * @param {(key:string, vars?:object)=>string} [t] traductor (translator(locale)); por defecto el global
26
- * @param {'shapes'|'eyes'|'brows'|'mouths'|'cheeks'|'patterns'|'finishes'|'accessories'|'groups'|'presets'|'moods'|'gestures'|'morphs'|'effects'} kind
26
+ * @param {'shapes'|'eyes'|'brows'|'mouths'|'cheeks'|'patterns'|'finishes'|'textures'|'accessories'|'groups'|'presets'|'moods'|'gestures'|'morphs'|'effects'} kind
27
27
  * @param {string} id
28
28
  * @returns {string} el texto traducido, el `label` en español de la pieza o, en último caso, el id
29
29
  */
@@ -378,12 +378,43 @@ export const FINISHES = [
378
378
  { id: 'sketch', label: 'Boceto' },
379
379
  ];
380
380
 
381
+ // ───────────────────────────── texturas ─────────────────────────────
382
+ // Detalle de superficie (se dibuja en skin.js). 'auto' la elige según forma, color y acabado.
383
+
384
+ export const TEXTURES = [
385
+ { id: 'auto', label: 'Automática' },
386
+ { id: 'none', label: 'Lisa' },
387
+ { id: 'fur', label: 'Pelo' },
388
+ { id: 'fuzz', label: 'Pelusa' },
389
+ { id: 'scales', label: 'Escamas' },
390
+ { id: 'feathers', label: 'Plumas' },
391
+ { id: 'speckles', label: 'Pecas' },
392
+ { id: 'ridges', label: 'Anillos' },
393
+ { id: 'gloss', label: 'Gelatina' },
394
+ { id: 'panels', label: 'Paneles' },
395
+ ];
396
+
397
+ addCatalog({
398
+ es: { character: { textures: Object.fromEntries(TEXTURES.map((x) => [x.id, x.label])) }, editor: { color: { texture: 'Textura' } } },
399
+ en: { character: { textures: { auto: 'Auto', none: 'Smooth', fur: 'Fur', fuzz: 'Fuzz', scales: 'Scales', feathers: 'Feathers', speckles: 'Speckles', ridges: 'Ridges', gloss: 'Jelly', panels: 'Panels' } }, editor: { color: { texture: 'Texture' } } },
400
+ pt: { character: { textures: { auto: 'Automática', none: 'Lisa', fur: 'Pelo', fuzz: 'Penugem', scales: 'Escamas', feathers: 'Penas', speckles: 'Sardas', ridges: 'Anéis', gloss: 'Gelatina', panels: 'Painéis' } }, editor: { color: { texture: 'Textura' } } },
401
+ });
402
+
381
403
  // ───────────────────────────── accesorios ─────────────────────────────
382
- // c = { k (escala por ancho de cabeza), w, h (caja del cuerpo), sp, r (ojos), color, color2, eyeDy, mouthDy }
383
- // Cada uno: { id, label, group, anchor, layer: 'back'|'front', color, color2, draw(c) }
404
+ // c = { k (escala por ancho de cabeza), w, h (caja del cuerpo), sp, r (ojos), color, color2, eyeDy, mouthDy, grow }
405
+ // Cada uno: { id, label, group, anchor, layer: 'back'|'front', color, color2, draw(c), grow?, shape?(c) }
384
406
 
385
407
  const S = (w) => `stroke="#1f2937" stroke-width="${w}" stroke-linejoin="round" stroke-linecap="round"`;
386
408
 
409
+ // Piezas que «crecen» del cuerpo (grow: 'skin' lleva además la textura de la piel). Con c.grow
410
+ // van detrás del cuerpo con la base hundida en él; shape(c) da su silueta para la unión.
411
+ const ellD = (rx, ry, cy) => `M${-rx} ${cy}a${rx} ${ry} 0 1 0 ${rx * 2} 0a${rx} ${ry} 0 1 0 ${-rx * 2} 0Z`;
412
+ const hornD = (s, grow) => (grow ? `M${31 * s} 28L${30 * s} 14Q${44 * s} -8 ${30 * s} -30Q${28 * s} -8 ${12 * s} 10L${10 * s} 26Z` : `M${30 * s} 14Q${44 * s} -8 ${30 * s} -30Q${28 * s} -8 ${12 * s} 10Z`);
413
+ const catEarD = (s, grow) => (grow ? `M${n(46.2 * s)} 28L${36 * s} -28L${n(1.4 * s)} 14Z` : `M${44 * s} 16L${36 * s} -28L${8 * s} 6Z`);
414
+ const tailD = (c) => `M${n(c.w * 0.3)} -12Q${n(c.w * 0.8)} 0 ${n(c.w * 0.62)} -40`;
415
+ const ANGEL_WING = 'M0 0Q30 -40 70 -30Q58 -18 66 -10Q50 -4 58 8Q36 10 40 22Q14 18 0 10Z';
416
+ const BAT_WING = 'M0 0L28 -36L70 -24Q60 -10 64 6Q50 -2 44 12Q34 2 24 16Q16 4 0 10Z';
417
+
387
418
  export const ACCESSORY_GROUPS = [
388
419
  ['head', 'Cabeza'],
389
420
  ['eyes', 'Ojos'],
@@ -456,10 +487,10 @@ export const ACCESSORIES = [
456
487
  draw: (c) => `<g transform="scale(${n(c.k)}) translate(-24 6)">${[0, 72, 144, 216, 288].map((a) => `<ellipse cx="0" cy="-11" rx="8" ry="11" transform="rotate(${a})" fill="${c.color}" stroke="#1f2937" stroke-width="2"/>`).join('')}<circle r="7" fill="${c.color2}" stroke="#1f2937" stroke-width="2"/></g>`,
457
488
  },
458
489
  { id: 'halo', label: 'Aureola', group: 'head', anchor: 'top', color: '#fde047', draw: (c) => `<g transform="scale(${n(c.k)})"><ellipse cy="-22" rx="34" ry="9" fill="none" stroke="${c.color}" stroke-width="7"/><ellipse cy="-22" rx="34" ry="9" fill="none" stroke="#fff" stroke-width="2" opacity=".6"/></g>` },
459
- { id: 'horns', label: 'Cuernos', group: 'head', anchor: 'top', color: '#dc2626', draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-30 14Q-44 -8 -30 -30Q-28 -8 -12 10Z" fill="${c.color}" ${S(3)}/><path d="M30 14Q44 -8 30 -30Q28 -8 12 10Z" fill="${c.color}" ${S(3)}/></g>` },
460
- { id: 'antenna', label: 'Antenas', group: 'head', anchor: 'top', color: '#1f2937', color2: '#22d3ee', draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-14 10Q-18 -14 -30 -28M14 10Q18 -14 30 -28" stroke="${c.color}" stroke-width="3.5" fill="none" stroke-linecap="round"/><circle cx="-30" cy="-30" r="7" fill="${c.color2}" ${S(2.5)}/><circle cx="30" cy="-30" r="7" fill="${c.color2}" ${S(2.5)}/></g>` },
461
- { id: 'bunny', label: 'Orejas de conejo', group: 'head', anchor: 'top', color: '#f5f5f4', color2: '#fbcfe8', draw: (c) => `<g transform="scale(${n(c.k)})"><g transform="translate(-18 8) rotate(-12)"><ellipse cy="-34" rx="12" ry="36" fill="${c.color}" ${S(3)}/><ellipse cy="-32" rx="6" ry="26" fill="${c.color2}"/></g><g transform="translate(18 8) rotate(14)"><ellipse cy="-34" rx="12" ry="36" fill="${c.color}" ${S(3)}/><ellipse cy="-32" rx="6" ry="26" fill="${c.color2}"/></g></g>` },
462
- { id: 'catears', label: 'Orejas de gato', group: 'head', anchor: 'top', color: '#1f2937', color2: '#f9a8d4', draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-44 16L-36 -28L-8 6Z" fill="${c.color}" ${S(3)}/><path d="M-36 6L-32 -14L-18 4Z" fill="${c.color2}"/><path d="M44 16L36 -28L8 6Z" fill="${c.color}" ${S(3)}/><path d="M36 6L32 -14L18 4Z" fill="${c.color2}"/></g>` },
490
+ { id: 'horns', label: 'Cuernos', group: 'head', anchor: 'top', color: '#dc2626', grow: 'part', shape: (c) => [-1, 1].map((s) => ({ d: hornD(s, c.grow), tf: `scale(${n(c.k)})` })), draw: (c) => `<g transform="scale(${n(c.k)})">${[-1, 1].map((s) => `<path d="${hornD(s, c.grow)}" fill="${c.color}" ${S(3)}/>`).join('')}</g>` },
491
+ { id: 'antenna', label: 'Antenas', group: 'head', anchor: 'top', color: '#1f2937', color2: '#22d3ee', grow: 'part', shape: (c) => [-1, 1].map((s) => ({ d: `M${s * 14} ${c.grow ? 22 : 10}Q${s * 18} -14 ${s * 30} -28`, tf: `scale(${n(c.k)})`, sw: 3.5, sl: 16 })), draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-14 ${c.grow ? 22 : 10}Q-18 -14 -30 -28M14 ${c.grow ? 22 : 10}Q18 -14 30 -28" stroke="${c.color}" stroke-width="3.5" fill="none" stroke-linecap="round"/><circle cx="-30" cy="-30" r="7" fill="${c.color2}" ${S(2.5)}/><circle cx="30" cy="-30" r="7" fill="${c.color2}" ${S(2.5)}/></g>` },
492
+ { id: 'bunny', label: 'Orejas de conejo', group: 'head', anchor: 'top', color: '#f5f5f4', color2: '#fbcfe8', grow: 'skin', shape: (c) => [[-18, -12], [18, 14]].map(([x, a]) => ({ d: ellD(12, 36, -34), tf: `scale(${n(c.k)}) translate(${x} 8) rotate(${a})` })), draw: (c) => `<g transform="scale(${n(c.k)})"><g transform="translate(-18 8) rotate(-12)"><ellipse cy="-34" rx="12" ry="36" fill="${c.color}" ${S(3)}/><ellipse cy="-32" rx="6" ry="26" fill="${c.color2}"/></g><g transform="translate(18 8) rotate(14)"><ellipse cy="-34" rx="12" ry="36" fill="${c.color}" ${S(3)}/><ellipse cy="-32" rx="6" ry="26" fill="${c.color2}"/></g></g>` },
493
+ { id: 'catears', label: 'Orejas de gato', group: 'head', anchor: 'top', color: '#1f2937', color2: '#f9a8d4', grow: 'skin', shape: (c) => [-1, 1].map((s) => ({ d: catEarD(s, c.grow), tf: `scale(${n(c.k)})` })), draw: (c) => `<g transform="scale(${n(c.k)})"><path d="${catEarD(-1, c.grow)}" fill="${c.color}" ${S(3)}/><path d="M-36 6L-32 -14L-18 4Z" fill="${c.color2}"/><path d="${catEarD(1, c.grow)}" fill="${c.color}" ${S(3)}/><path d="M36 6L32 -14L18 4Z" fill="${c.color2}"/></g>` },
463
494
  { id: 'santa', label: 'Papá Noel', group: 'head', anchor: 'top', color: '#dc2626', color2: '#fafafa', draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-42 10Q-30 -40 10 -44Q44 -44 56 -8Q40 -26 26 -20Q40 0 42 10Z" fill="${c.color}" ${S(3)}/><circle cx="56" cy="-6" r="10" fill="${c.color2}" ${S(2.5)}/><rect x="-48" y="2" width="96" height="18" rx="9" fill="${c.color2}" ${S(3)}/></g>` },
464
495
  { id: 'hardhat', label: 'Casco de obra', group: 'head', anchor: 'top', color: '#facc15', draw: (c) => `<g transform="scale(${n(c.k)})"><path d="M-40 12Q-40 -34 0 -34Q40 -34 40 12Z" fill="${c.color}" ${S(3)}/><path d="M-56 12H56Q56 20 0 20Q-56 20 -56 12Z" fill="${shade(c.color, -0.1)}" ${S(3)}/><path d="M-8 -34V10M8 -34V10" stroke="${shade(c.color, -0.2)}" stroke-width="4"/></g>` },
465
496
  { id: 'headband', label: 'Cinta ninja', group: 'head', anchor: 'eyes', clip: true, color: '#dc2626', color2: '#9ca3af', draw: (c) => `<rect x="${n(-c.w)}" y="${n(-c.r * 3.4)}" width="${n(c.w * 2)}" height="${n(c.r * 1.4)}" fill="${c.color}"/><rect x="-9" y="${n(-c.r * 3.25)}" width="18" height="${n(c.r * 1.1)}" rx="2" fill="${c.color2}"/>`, extra: (c) => `<path d="M${n(c.w * 0.5)} ${n(-c.r * 2.7)}q18 4 26 16M${n(c.w * 0.5)} ${n(-c.r * 2.7)}q22 -2 30 8" stroke="${c.color}" stroke-width="5" fill="none" stroke-linecap="round"/>` },
@@ -510,9 +541,9 @@ export const ACCESSORIES = [
510
541
  draw: (c) => `<path d="M${n(-c.w * 0.3)} ${n(-c.above * 0.7)}Q${n(-c.w * 0.62)} ${n(-c.above * 0.2)} ${n(-c.w * 0.62)} ${n(c.below)}H${n(c.w * 0.62)}Q${n(c.w * 0.62)} ${n(-c.above * 0.2)} ${n(c.w * 0.3)} ${n(-c.above * 0.7)}Z" fill="${c.color}" ${S(3)}/><path d="M${n(-c.w * 0.64)} ${n(c.below - 7)}H${n(c.w * 0.64)}V${n(c.below + 3)}H${n(-c.w * 0.64)}Z" fill="${c.color2}" ${S(2)}/>${[-0.5, -0.25, 0, 0.25, 0.5].map((k) => `<path d="M${n(k * c.w)} ${n(c.below - 4)}v5" stroke="#111" stroke-width="2.5"/>`).join('')}`,
511
542
  },
512
543
  { id: 'superhero', label: 'Capa de héroe', group: 'back', anchor: 'neck', layer: 'back', color: '#2563eb', draw: (c) => `<path d="M${n(-c.w * 0.3)} ${n(-c.above * 0.7)}Q${n(-c.w * 0.75)} ${n(0)} ${n(-c.w * 0.66)} ${n(c.below + 6)}Q0 ${n(c.below - 4)} ${n(c.w * 0.66)} ${n(c.below + 6)}Q${n(c.w * 0.75)} 0 ${n(c.w * 0.3)} ${n(-c.above * 0.7)}Z" fill="${c.color}" ${S(3)}/>` },
513
- { id: 'wings', label: 'Alas de ángel', group: 'back', anchor: 'center', layer: 'back', color: '#fafafa', draw: (c) => [-1, 1].map((s) => `<g transform="translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)"><path d="M0 0Q30 -40 70 -30Q58 -18 66 -10Q50 -4 58 8Q36 10 40 22Q14 18 0 10Z" fill="${c.color}" ${S(2.5)}/></g>`).join('') },
514
- { id: 'batwings', label: 'Alas de murciélago', group: 'back', anchor: 'center', layer: 'back', color: '#3b0764', draw: (c) => [-1, 1].map((s) => `<g transform="translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)"><path d="M0 0L28 -36L70 -24Q60 -10 64 6Q50 -2 44 12Q34 2 24 16Q16 4 0 10Z" fill="${c.color}" ${S(2.5)}/></g>`).join('') },
515
- { id: 'tail', label: 'Cola de diablillo', group: 'back', anchor: 'bottom', layer: 'back', color: '#dc2626', draw: (c) => `<path d="M${n(c.w * 0.3)} -12Q${n(c.w * 0.8)} 0 ${n(c.w * 0.62)} -40" stroke="${c.color}" stroke-width="5" fill="none" stroke-linecap="round"/><path d="M${n(c.w * 0.62)} -52L${n(c.w * 0.54)} -36L${n(c.w * 0.72)} -38Z" fill="${c.color}" ${S(2)}/>` },
544
+ { id: 'wings', label: 'Alas de ángel', group: 'back', anchor: 'center', layer: 'back', color: '#fafafa', grow: 'part', shape: (c) => [-1, 1].map((s) => ({ d: ANGEL_WING, tf: `translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)` })), draw: (c) => [-1, 1].map((s) => `<g transform="translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)"><path d="${ANGEL_WING}" fill="${c.color}" ${S(2.5)}/></g>`).join('') },
545
+ { id: 'batwings', label: 'Alas de murciélago', group: 'back', anchor: 'center', layer: 'back', color: '#3b0764', grow: 'part', shape: (c) => [-1, 1].map((s) => ({ d: BAT_WING, tf: `translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)` })), draw: (c) => [-1, 1].map((s) => `<g transform="translate(${n(s * c.w * 0.4)} -10) scale(${s} 1)"><path d="${BAT_WING}" fill="${c.color}" ${S(2.5)}/></g>`).join('') },
546
+ { id: 'tail', label: 'Cola de diablillo', group: 'back', anchor: 'bottom', layer: 'back', color: '#dc2626', grow: 'part', shape: (c) => [{ d: tailD(c), sw: 5, sl: c.w * 0.3 }], draw: (c) => `<path d="${tailD(c)}" stroke="${c.color}" stroke-width="5" fill="none" stroke-linecap="round"/><path d="M${n(c.w * 0.62)} -52L${n(c.w * 0.54)} -36L${n(c.w * 0.72)} -38Z" fill="${c.color}" ${S(2)}/>` },
516
547
  { id: 'jetpack', label: 'Mochila cohete', group: 'back', anchor: 'center', layer: 'back', color: '#94a3b8', draw: (c) => [-1, 1].map((s) => `<rect x="${n(s * c.w * 0.42 - 12)}" y="-30" width="24" height="56" rx="10" fill="${c.color}" ${S(2.5)}/><path d="M${n(s * c.w * 0.42 - 8)} 26L${n(s * c.w * 0.42)} 46L${n(s * c.w * 0.42 + 8)} 26Z" fill="#f97316"/>`).join('') },
517
548
  // ── extras ──
518
549
  { id: 'parrot', label: 'Loro', group: 'extra', anchor: 'shoulder', color: '#16a34a', color2: '#ef4444', draw: () => `<g transform="translate(0 -14)"><path d="M-4 16Q-14 -2 -2 -14Q12 -20 14 -4Q14 10 4 18Z" fill="#16a34a" stroke="#1f2937" stroke-width="2"/><path d="M-2 -14Q6 -24 14 -14" fill="#ef4444" stroke="#1f2937" stroke-width="2"/><circle cx="4" cy="-6" r="2.2" fill="#111"/><path d="M12 -6q8 2 4 8l-4 -2Z" fill="#facc15" stroke="#1f2937" stroke-width="1.5"/><path d="M-4 16l-6 12M2 18l-2 12" stroke="#2563eb" stroke-width="3.5" stroke-linecap="round"/></g>` },
@@ -556,4 +587,4 @@ export const PRESETS = [
556
587
  ];
557
588
 
558
589
  /** Listas por tipo (para partLabel). Moods y gestos viven en Character.js y solo tienen catálogo. */
559
- const CATALOG = { shapes: SHAPES, eyes: EYES, brows: BROWS, mouths: MOUTHS, cheeks: CHEEKS, patterns: PATTERNS, finishes: FINISHES, accessories: ACCESSORIES, groups: ACCESSORY_GROUPS, presets: PRESETS };
590
+ const CATALOG = { shapes: SHAPES, eyes: EYES, brows: BROWS, mouths: MOUTHS, cheeks: CHEEKS, patterns: PATTERNS, finishes: FINISHES, textures: TEXTURES, accessories: ACCESSORIES, groups: ACCESSORY_GROUPS, presets: PRESETS };
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Piel del personaje 2D: texturas procedurales de superficie y uniones de las piezas que
3
+ * «crecen» del cuerpo (pinchos, cuernos, alas, brazos, orejas, colas).
4
+ *
5
+ * Texturas (spec.texture, catálogo TEXTURES de parts.js): pelo, pelusa, escamas, plumas, pecas,
6
+ * anillos, gelatina y paneles. 'auto' (o ausente) elige una según forma, patrón y acabado, y el
7
+ * azar de cada textura sale de un hash de la spec: el mismo personaje (la misma semilla de
8
+ * identidad) siempre tiene la misma piel, y las semillas antiguas no cambian de forma ni color.
9
+ *
10
+ * Todo es estático y barato: <pattern> en espacio de usuario (con la deformación del contorno
11
+ * aplicada por patternTransform), trazos recortados al cuerpo y, solo en calidad 'full', un
12
+ * grano feTurbulence que no se usa nunca mientras el contorno se anima. Calidades:
13
+ * full · todo · lite · sin filtros · off · sin textura
14
+ *
15
+ * Piezas unidas: comparten el relleno del cuerpo (degradado en espacio de usuario + textura),
16
+ * van detrás del cuerpo con la base hundida en él y el contorno del cuerpo se recorta con una
17
+ * máscara donde nacen (silueta única, con «filete» en la base), más una sombra de contacto.
18
+ */
19
+
20
+ import { shade, hexToRgb } from './parts.js';
21
+
22
+ const n = (v) => Math.round(v * 100) / 100;
23
+
24
+ /** Azar determinista (mulberry32). */
25
+ function rng(seed) {
26
+ let a = seed >>> 0 || 1;
27
+ return () => {
28
+ a = (a + 0x6d2b79f5) >>> 0;
29
+ let t = a;
30
+ t = Math.imul(t ^ (t >>> 15), t | 1);
31
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
32
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
33
+ };
34
+ }
35
+
36
+ /** Hash FNV-1a de lo que define el aspecto (no los puntos editados: arrastrar no cambia la piel). */
37
+ export function skinHash(spec) {
38
+ const s = `${spec.body.shape}|${spec.body.color}|${spec.finish}|${spec.body.pattern}|${spec.body.patternColor}|${spec.eyes.type}`;
39
+ let h = 0x811c9dc5;
40
+ for (let i = 0; i < s.length; i++) {
41
+ h ^= s.charCodeAt(i);
42
+ h = Math.imul(h, 0x01000193) >>> 0;
43
+ }
44
+ return h >>> 0;
45
+ }
46
+
47
+ // Candidatas para 'auto' por forma (se elige con el hash; las repetidas pesan más).
48
+ const AUTO = {
49
+ ghost: ['gloss', 'gloss', 'speckles'],
50
+ drop: ['gloss', 'gloss', 'scales'],
51
+ blob: ['gloss', 'fuzz', 'speckles'],
52
+ heart: ['gloss', 'fuzz'],
53
+ mushroom: ['speckles', 'fuzz'],
54
+ cloud: ['fuzz', 'fur'],
55
+ wide: ['scales', 'ridges', 'fuzz', 'speckles'],
56
+ tall: ['ridges', 'scales', 'fur', 'speckles'],
57
+ squircle: ['scales', 'fuzz', 'speckles', 'panels'],
58
+ };
59
+ const AUTO_ANY = ['fur', 'fuzz', 'scales', 'feathers', 'speckles', 'ridges', 'gloss', 'fuzz', 'fur', 'none'];
60
+
61
+ /** Textura efectiva de una spec normalizada ('auto' → la que le va a ese personaje). */
62
+ export function textureOf(spec) {
63
+ const t = spec.texture;
64
+ if (t && t !== 'auto') return t;
65
+ const f = spec.finish;
66
+ const sh = spec.body.shape;
67
+ if (f === 'neon' || f === 'sketch') return 'none';
68
+ if (sh === 'robot' || spec.body.pattern === 'circuit') return 'panels';
69
+ if (sh === 'cat' || spec.body.pattern === 'tiger' || spec.accessories.some((a) => a.id === 'catears' || a.id === 'bunny')) return 'fur';
70
+ const h = skinHash(spec);
71
+ if (f === 'clay') return h % 3 ? 'fuzz' : 'speckles';
72
+ const pool = AUTO[sh] || AUTO_ANY;
73
+ return pool[h % pool.length];
74
+ }
75
+
76
+ // ───────────────────────────── geometría del borde ─────────────────────────────
77
+
78
+ /**
79
+ * Muestras sobre el contorno (la misma Catmull-Rom que splinePath): [x, y, nx, ny] con la
80
+ * normal exterior. `per`: muestras por tramo.
81
+ */
82
+ export function edgeSamples(pts, per) {
83
+ const N = pts.length;
84
+ const P = (i) => pts[(i + N) % N];
85
+ const out = [];
86
+ for (let i = 0; i < N; i++) {
87
+ const [p0, p1, p2, p3] = [P(i - 1), P(i), P(i + 1), P(i + 2)];
88
+ const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
89
+ const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
90
+ for (let k = 0; k < per; k++) {
91
+ const t = (k + 0.5) / per;
92
+ const u = 1 - t;
93
+ const x = u * u * u * p1[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t * t * t * p2[0];
94
+ const y = u * u * u * p1[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t * t * t * p2[1];
95
+ const dx = 3 * u * u * (c1[0] - p1[0]) + 6 * u * t * (c2[0] - c1[0]) + 3 * t * t * (p2[0] - c2[0]);
96
+ const dy = 3 * u * u * (c1[1] - p1[1]) + 6 * u * t * (c2[1] - c1[1]) + 3 * t * t * (p2[1] - c2[1]);
97
+ const l = Math.hypot(dx, dy) || 1;
98
+ out.push([x, y, dy / l, -dx / l]);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ // ───────────────────────────── teselas ─────────────────────────────
105
+
106
+ /** Copias de un elemento (caja [x0,y0,x1,y1]) que caen dentro de la tesela W×H, para que no se corte. */
107
+ function wrap(b, W, H) {
108
+ const out = [];
109
+ for (const dx of [-W, 0, W]) {
110
+ for (const dy of [-H, 0, H]) {
111
+ if (b[2] + dx < 0 || b[0] + dx > W || b[3] + dy < 0 || b[1] + dy > H) continue;
112
+ out.push([dx, dy]);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ const dot = (x, y, r) => `M${n(x - r)} ${n(y)}a${n(r)} ${n(r)} 0 1 0 ${n(r * 2)} 0a${n(r)} ${n(r)} 0 1 0 ${n(-r * 2)} 0`;
118
+ const ink = (d, color, op, w) => (d ? `<path d="${d}" fill="none" stroke="${color}" stroke-opacity="${op}" stroke-width="${n(w)}" stroke-linecap="round"/>` : '');
119
+ const blot = (d, color, op) => (d ? `<path d="${d}" fill="${color}" fill-opacity="${op}"/>` : '');
120
+
121
+ /** Mechones cortos, peinados hacia abajo. */
122
+ function furTile(u, r) {
123
+ const W = 16 * u;
124
+ let dk = '';
125
+ let lt = '';
126
+ for (let i = 0; i < 11; i++) {
127
+ const x = r() * W;
128
+ const y = r() * W;
129
+ const a = Math.PI / 2 + (r() - 0.5) * 0.9;
130
+ const len = (3.5 + r() * 3.2) * u;
131
+ const dx = Math.cos(a) * len;
132
+ const dy = Math.sin(a) * len;
133
+ const bend = (r() - 0.5) * 2.2 * u;
134
+ const qx = dx / 2 - Math.sin(a) * bend;
135
+ const qy = dy / 2 + Math.cos(a) * bend;
136
+ for (const [ox, oy] of wrap([Math.min(x, x + dx) - u, Math.min(y, y + dy) - u, Math.max(x, x + dx) + u, Math.max(y, y + dy) + u], W, W)) {
137
+ dk += `M${n(x + ox)} ${n(y + oy)}q${n(qx)} ${n(qy)} ${n(dx)} ${n(dy)}`;
138
+ lt += `M${n(x + ox - 0.8 * u)} ${n(y + oy - 0.6 * u)}q${n(qx)} ${n(qy)} ${n(dx * 0.8)} ${n(dy * 0.8)}`;
139
+ }
140
+ }
141
+ return { W, H: W, body: ink(dk, '#000', 0.2, 0.85 * u) + ink(lt, '#fff', 0.22, 0.6 * u) };
142
+ }
143
+
144
+ /** Punteado fino de dos tonos. */
145
+ function fuzzTile(u, r) {
146
+ const W = 9 * u;
147
+ let dk = '';
148
+ let lt = '';
149
+ for (let i = 0; i < 12; i++) {
150
+ const x = r() * W;
151
+ const y = r() * W;
152
+ const rr = (0.35 + r() * 0.45) * u;
153
+ for (const [ox, oy] of wrap([x - rr, y - rr, x + rr, y + rr], W, W)) {
154
+ if (i % 2) lt += dot(x + ox, y + oy, rr);
155
+ else dk += dot(x + ox, y + oy, rr);
156
+ }
157
+ }
158
+ return { W, H: W, body: blot(dk, '#000', 0.14) + blot(lt, '#fff', 0.2) };
159
+ }
160
+
161
+ /** Escamas solapadas: medias lunas con brillo interior. */
162
+ function scalesTile(u) {
163
+ const rr = 5 * u;
164
+ const W = rr * 2;
165
+ const H = rr * 1.6;
166
+ let dk = '';
167
+ let lt = '';
168
+ for (const [cx, cy] of [[0, 0], [W, 0], [rr, H / 2]]) {
169
+ for (const [ox, oy] of wrap([cx - rr, cy, cx + rr, cy + rr], W, H)) {
170
+ const x = cx + ox;
171
+ const y = cy + oy;
172
+ dk += `M${n(x - rr)} ${n(y)}a${n(rr)} ${n(rr)} 0 0 0 ${n(rr * 2)} 0`;
173
+ lt += `M${n(x - rr * 0.55)} ${n(y + rr * 0.3)}a${n(rr * 0.6)} ${n(rr * 0.5)} 0 0 0 ${n(rr * 1.1)} 0`;
174
+ }
175
+ }
176
+ return { W, H, body: ink(dk, '#000', 0.2, 0.75 * u) + ink(lt, '#fff', 0.22, 0.6 * u) };
177
+ }
178
+
179
+ /** Plumas: U alargadas en filas alternas, con su cañón. */
180
+ function feathersTile(u) {
181
+ const W = 12 * u;
182
+ const H = 10 * u;
183
+ const hw = 5.6 * u;
184
+ const hh = 9 * u;
185
+ let dk = '';
186
+ let lt = '';
187
+ for (const [cx, cy] of [[0, 0], [W, 0], [W / 2, H / 2]]) {
188
+ for (const [ox, oy] of wrap([cx - hw, cy, cx + hw, cy + hh], W, H)) {
189
+ const x = cx + ox;
190
+ const y = cy + oy;
191
+ dk += `M${n(x - hw)} ${n(y)}Q${n(x - hw)} ${n(y + hh * 0.9)} ${n(x)} ${n(y + hh)}Q${n(x + hw)} ${n(y + hh * 0.9)} ${n(x + hw)} ${n(y)}`;
192
+ lt += `M${n(x)} ${n(y + u)}V${n(y + hh * 0.75)}`;
193
+ }
194
+ }
195
+ return { W, H, body: ink(dk, '#000', 0.17, 0.8 * u) + ink(lt, '#fff', 0.24, 0.6 * u) };
196
+ }
197
+
198
+ /** Pecas sueltas (para piezas; el cuerpo las lleva repartidas a mano). */
199
+ function speckTile(u, r) {
200
+ const W = 14 * u;
201
+ let dk = '';
202
+ for (let i = 0; i < 4; i++) {
203
+ const x = r() * W;
204
+ const y = r() * W;
205
+ const rr = (0.5 + r() * 0.9) * u;
206
+ for (const [ox, oy] of wrap([x - rr, y - rr, x + rr, y + rr], W, W)) dk += dot(x + ox, y + oy, rr);
207
+ }
208
+ return { W, H: W, body: blot(dk, '#000', 0.16) };
209
+ }
210
+
211
+ const TILES = { fur: furTile, fuzz: fuzzTile, scales: scalesTile, feathers: feathersTile, speckles: speckTile };
212
+
213
+ // ───────────────────────────── piel del cuerpo ─────────────────────────────
214
+
215
+ /**
216
+ * Textura de un cuerpo. L: layout actual (puede ir deformado); L0: el de reposo.
217
+ * o = { id, d (contorno), fill (relleno del cuerpo), col, line, lw (contorno o null), quality }
218
+ * @returns {{ tex:string, defs:string, inner:string, rim:string, part:string }}
219
+ * inner va recortado al cuerpo (bajo los brillos del acabado); rim, tras el contorno;
220
+ * part es el relleno de textura para piezas unidas ('' si esta textura no lleva).
221
+ */
222
+ export function renderSkin(spec, L, L0, o) {
223
+ const tex = textureOf(spec);
224
+ const out = { tex, defs: '', inner: '', rim: '', part: '' };
225
+ if (tex === 'none' || o.quality === 'off') return out;
226
+ const r = rng(skinHash(spec) ^ 0x9e3779b9);
227
+ const u = Math.max(0.6, L0.unit);
228
+ const { box } = L0;
229
+ // la piel se estira con el contorno (aplastar, estirar, gelatina…) en vez de deslizarse
230
+ const sx = L.box.w / (box.w || 1);
231
+ const sy = L.box.h / (box.h || 1);
232
+ const by0 = box.y + box.h;
233
+ const by = L.box.y + L.box.h;
234
+ const same = Math.abs(sx - 1) < 1e-3 && Math.abs(sy - 1) < 1e-3 && Math.abs(L.cx - L0.cx) < 0.05 && Math.abs(by - by0) < 0.05;
235
+ const tf = same ? '' : `translate(${n(L.cx)} ${n(by)}) scale(${n(sx * 1000) / 1000} ${n(sy * 1000) / 1000}) translate(${n(-L0.cx)} ${n(-by0)})`;
236
+ const ptf = tf ? ` patternTransform="${tf}"` : '';
237
+ const gtf = tf ? ` transform="${tf}"` : '';
238
+ const lite = o.quality === 'lite';
239
+
240
+ const tile = TILES[tex]?.(u, r);
241
+ if (tile) {
242
+ out.defs += `<pattern id="${o.id}-tx" width="${n(tile.W)}" height="${n(tile.H)}" patternUnits="userSpaceOnUse"${ptf}>${tile.body}</pattern>`;
243
+ out.part = `url(#${o.id}-tx)`;
244
+ if (tex !== 'speckles') out.inner += `<path d="${o.d}" fill="${out.part}"/>`;
245
+ }
246
+ const W = box.w;
247
+ const H = box.h;
248
+ const X = box.x;
249
+ const Y = box.y;
250
+
251
+ if (tex === 'speckles') {
252
+ let dk = '';
253
+ let lt = '';
254
+ for (let i = 0; i < 34; i++) {
255
+ const x = X + r() * W;
256
+ const y = Y + r() ** 0.8 * H;
257
+ const rr = (0.5 + r() ** 2 * 1.7) * u;
258
+ if (i % 6 === 5) lt += dot(x, y, rr * 0.8);
259
+ else dk += dot(x, y, rr);
260
+ }
261
+ out.inner += `<g${gtf}>${blot(dk, '#000', 0.17)}${blot(lt, '#fff', 0.3)}</g>`;
262
+ }
263
+ if (tex === 'ridges') {
264
+ let dk = '';
265
+ let lt = '';
266
+ const k = 7;
267
+ for (let i = 0; i < k; i++) {
268
+ const y = Y + (H * (i + 0.75)) / (k + 0.5);
269
+ const sag = H * 0.05 * (1 - Math.abs(i / (k - 1) - 0.5));
270
+ dk += `M${n(X - 4)} ${n(y)}Q${n(X + W / 2)} ${n(y + sag * 2)} ${n(X + W + 4)} ${n(y)}`;
271
+ lt += `M${n(X - 4)} ${n(y + 1.6 * u)}Q${n(X + W / 2)} ${n(y + 1.6 * u + sag * 2)} ${n(X + W + 4)} ${n(y + 1.6 * u)}`;
272
+ }
273
+ out.inner += `<g${gtf}>${ink(dk, '#000', 0.15, 1.5 * u)}${ink(lt, '#fff', 0.2, 1.1 * u)}</g>`;
274
+ out.defs += `<pattern id="${o.id}-tx" width="${n(8 * u)}" height="${n(6 * u)}" patternUnits="userSpaceOnUse"${ptf}>${ink(`M0 ${n(3 * u)}H${n(8 * u)}`, '#000', 0.14, 1.2 * u)}</pattern>`;
275
+ out.part = `url(#${o.id}-tx)`;
276
+ }
277
+ if (tex === 'panels') {
278
+ const seam = (y) => `M${n(X - 4)} ${n(y)}H${n(X + W + 4)}`;
279
+ out.inner += `<g${gtf}>${ink(seam(Y + H * 0.3) + seam(Y + H * 0.84), '#000', 0.18, 1.1 * u)}${ink(seam(Y + H * 0.3 + 1.3 * u) + seam(Y + H * 0.84 + 1.3 * u), '#fff', 0.25, 0.9 * u)}`;
280
+ let rv = '';
281
+ let hl = '';
282
+ for (const [a, b] of [[0.13, 0.17], [0.87, 0.17], [0.1, 0.42], [0.9, 0.42], [0.1, 0.74], [0.9, 0.74]]) {
283
+ rv += dot(X + a * W, Y + b * H, 1.7 * u);
284
+ hl += dot(X + a * W - 0.5 * u, Y + b * H - 0.5 * u, 0.6 * u);
285
+ }
286
+ out.inner += `${blot(rv, '#000', 0.2)}${blot(hl, '#fff', 0.55)}</g>`;
287
+ }
288
+ if (tex === 'gloss') {
289
+ // gelatina: luz interior en el borde, reflejo curvo y burbujitas dentro (sobre el contorno actual)
290
+ const { box: b } = L;
291
+ out.inner += `<path d="${o.d}" fill="none" stroke="#fff" stroke-opacity=".28" stroke-width="${n(b.w * 0.07)}"/>`;
292
+ out.inner += `<ellipse cx="${n(L.cx)}" cy="${n(b.y + b.h * 0.86)}" rx="${n(b.w * 0.3)}" ry="${n(b.h * 0.1)}" fill="${shade(o.col, 0.5)}" opacity=".3"/>`;
293
+ out.inner += ink(`M${n(b.x + b.w * 0.16)} ${n(b.y + b.h * 0.5)}Q${n(b.x + b.w * 0.16)} ${n(b.y + b.h * 0.2)} ${n(b.x + b.w * 0.42)} ${n(b.y + b.h * 0.1)}`, '#fff', 0.55, 2.4 * u);
294
+ let bub = '';
295
+ let shine = '';
296
+ for (let i = 0; i < 5; i++) {
297
+ const x = b.x + b.w * (0.22 + r() * 0.56);
298
+ const y = b.y + b.h * (0.62 + r() * 0.26);
299
+ const rr = (1.4 + r() * 2.2) * u;
300
+ bub += dot(x, y, rr);
301
+ shine += dot(x - rr * 0.35, y - rr * 0.35, rr * 0.3);
302
+ }
303
+ out.inner += `<path d="${bub}" fill="#fff" fill-opacity=".1" stroke="#fff" stroke-opacity=".38" stroke-width="${n(0.6 * u)}"/>${blot(shine, '#fff', 0.7)}`;
304
+ }
305
+ if (tex === 'fuzz' && !lite && spec.finish !== 'clay') {
306
+ // grano fino (solo estático: en 'lite' y durante deformaciones no se pinta el filtro)
307
+ out.defs += `<filter id="${o.id}-grain" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="1.1" numOctaves="1" seed="${skinHash(spec) % 97}"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .8 0"/></filter>`;
308
+ out.inner += `<rect x="${n(L.box.x - 5)}" y="${n(L.box.y - 5)}" width="${n(L.box.w + 10)}" height="${n(L.box.h + 10)}" filter="url(#${o.id}-grain)" opacity=".09"/>`;
309
+ }
310
+
311
+ // borde: mechones de pelo o pelusa que rompen la silueta (abajo no: apoya en el suelo)
312
+ if (tex === 'fur') {
313
+ // mismo nº de muestras y de tiradas siempre: al deformarse, cada mechón sigue siendo el mismo
314
+ const S = edgeSamples(L.pts, 3);
315
+ let d = '';
316
+ for (const [x, y, nx, ny] of S) {
317
+ const len = (2.6 + r() * 3) * u;
318
+ const tilt = (r() - 0.5) * 0.9 + 0.25 * Math.sign(nx || 1);
319
+ if (ny > 0.55) continue;
320
+ const bw = 2.3 * u;
321
+ const tx = -ny;
322
+ const ty = nx;
323
+ const ix = x - nx * 1.2 * u;
324
+ const iy = y - ny * 1.2 * u;
325
+ d += `M${n(ix - tx * bw)} ${n(iy - ty * bw)}L${n(x + nx * len + tx * len * tilt)} ${n(y + ny * len + ty * len * tilt + len * 0.2)}L${n(ix + tx * bw)} ${n(iy + ty * bw)}`;
326
+ }
327
+ const st = o.line ? ` stroke="${o.line.color}" stroke-width="${n(Math.min(o.line.w, 2.4) * 0.75)}"${o.line.op ? ` stroke-opacity="${o.line.op}"` : ''} stroke-linejoin="round"` : '';
328
+ out.rim = `<path d="${d}" fill="${o.fill}"${st}/>`;
329
+ if (tile) out.rim += `<path d="${d}" fill="${out.part}"/>`;
330
+ }
331
+ if (tex === 'fuzz') {
332
+ const S = edgeSamples(L.pts, 5);
333
+ let d = '';
334
+ for (const [x, y, nx, ny] of S) {
335
+ const len = (1.2 + r() * 1.6) * u;
336
+ const a = (r() - 0.5) * 0.8;
337
+ if (ny > 0.6) continue;
338
+ d += `M${n(x - nx * 0.6 * u)} ${n(y - ny * 0.6 * u)}l${n((nx - ny * a) * len)} ${n((ny + nx * a) * len)}`;
339
+ }
340
+ out.rim = ink(d, o.line ? o.line.color : shade(o.col, -0.35), o.line?.op ?? 0.5, 0.8 * u);
341
+ }
342
+ return out;
343
+ }
344
+
345
+ // ───────────────────────────── piezas unidas ─────────────────────────────
346
+
347
+ const mixHex = (a, b, k) => {
348
+ const A = hexToRgb(a);
349
+ const B = hexToRgb(b);
350
+ return `#${A.map((v, i) => Math.round(v + (B[i] - v) * k).toString(16).padStart(2, '0')).join('')}`;
351
+ };
352
+
353
+ /** Color del degradado del cuerpo en un punto (para piezas con transformación propia, p. ej. manos). */
354
+ export function skinAt(skin, x, y) {
355
+ if (!skin?.stops) return skin?.solid || '#888888';
356
+ const { box, stops } = skin;
357
+ const u = (x - box.x) / (box.w || 1);
358
+ const v = (y - box.y) / (box.h || 1);
359
+ const t = Math.min(1, Math.hypot(u - 0.36, v - 0.28) / 0.85);
360
+ for (let i = 1; i < stops.length; i++) {
361
+ if (t <= stops[i][0]) return mixHex(stops[i - 1][1], stops[i][1], (t - stops[i - 1][0]) / (stops[i][0] - stops[i - 1][0]));
362
+ }
363
+ return stops[stops.length - 1][1];
364
+ }
365
+
366
+ /** Atributos de un trazo de silueta para piezas de piel (sin contorno si el cuerpo no lo tiene). */
367
+ export function skinStroke(skin, w = skin.lw) {
368
+ return skin.line ? ` stroke="${skin.line}" stroke-width="${n(w)}" stroke-linejoin="round"${skin.lineOp ? ` stroke-opacity="${skin.lineOp}"` : ''}` : '';
369
+ }
370
+
371
+ /**
372
+ * Una o varias siluetas (`d` en coordenadas del cuerpo) pintadas con la piel: relleno del cuerpo,
373
+ * luz, textura y por último el trazo (doble de ancho: la mitad interior la tapa el cuerpo).
374
+ */
375
+ export function skinPaths(skin, d, o = {}) {
376
+ const fill = o.fill || skin.fill;
377
+ let s = `<path d="${d}" fill="${fill}"/>`;
378
+ if (o.lit !== false && skin.lit && o.fill) s += `<path d="${d}" fill="${skin.lit}"/>`;
379
+ if (skin.tex && o.tex !== false) s += `<path d="${d}" fill="${skin.tex}"/>`;
380
+ if (o.extra) s += o.extra;
381
+ if (skin.line) s += `<path d="${d}" fill="none"${skinStroke(skin)}/>`;
382
+ return s;
383
+ }
384
+
385
+ /**
386
+ * Unión de piezas al cuerpo. shapes: [{ d, tf?, sw?, sl? }] (sw: pieza de trazo con ese ancho;
387
+ * sl: largo de su raíz). Devuelve
388
+ * shadow · sombra de contacto sobre las piezas, junto al borde del cuerpo (va detrás del cuerpo)
389
+ * mask · siluetas en negro que recortan el contorno del cuerpo donde nace cada pieza
390
+ */
391
+ export function joinSVG(skin, shapes, key) {
392
+ if (!shapes.length) return { shadow: '', mask: '' };
393
+ const T = (s) => (s.tf ? ` transform="${s.tf}"` : '');
394
+ let mask = '';
395
+ let clip = '';
396
+ let roots = '';
397
+ for (const s of shapes) {
398
+ if (s.sw) {
399
+ mask += `<path d="${s.d}"${T(s)} fill="none" stroke="#000" stroke-width="${n(s.sw)}" stroke-linecap="round"/>`;
400
+ if (s.sl) roots += `<path d="${s.d}"${T(s)} fill="none" stroke="#000" stroke-opacity=".2" stroke-width="${n(s.sw)}" stroke-dasharray="${n(s.sl)} 9999"/>`;
401
+ } else {
402
+ mask += `<path d="${s.d}"${T(s)} fill="#000"/>`;
403
+ clip += `<path d="${s.d}"${T(s)}/>`;
404
+ }
405
+ }
406
+ let shadow = roots;
407
+ if (clip) {
408
+ const w = skin.box.w;
409
+ shadow += `<clipPath id="${skin.uid}-${key}">${clip}</clipPath><g clip-path="url(#${skin.uid}-${key})" fill="none" stroke="#000"><path d="${skin.d}" stroke-opacity=".1" stroke-width="${n(w * 0.16)}"/><path d="${skin.d}" stroke-opacity=".12" stroke-width="${n(w * 0.07)}"/></g>`;
410
+ }
411
+ return { shadow, mask };
412
+ }