create-aura3d 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -3
  4. package/dist/index.js.map +1 -1
  5. package/package.json +11 -9
  6. package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
  7. package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
  8. package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
  9. package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
  10. package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
  11. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
  12. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
  13. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
  14. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
  15. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
  16. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
  17. package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
  18. package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
  19. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
  20. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
  21. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
  22. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
  23. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
  24. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
  25. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
  26. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
  27. package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
  28. package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
  29. package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
  30. package/templates/animation-studio/AGENTS.md +71 -0
  31. package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
  32. package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
  33. package/templates/animation-studio/aura.assets.json +669 -0
  34. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  35. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  36. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  37. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  38. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  39. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  40. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  41. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
  42. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  43. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  44. package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
  45. package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
  46. package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
  47. package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
  48. package/templates/animation-studio/dist/scene/integ.document.json +1 -0
  49. package/templates/animation-studio/dist/scene/working.document.json +723 -0
  50. package/templates/animation-studio/dist/scene/working.history.json +1 -0
  51. package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
  52. package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
  53. package/templates/animation-studio/package.json +32 -0
  54. package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
  55. package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
  56. package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
  57. package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
  58. package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
  59. package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
  60. package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
  61. package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
  62. package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
  63. package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
  64. package/templates/animation-studio/public/clip-library/idle.json +23035 -0
  65. package/templates/animation-studio/public/clip-library/manifest.json +154 -0
  66. package/templates/animation-studio/public/clip-library/nod.json +173903 -0
  67. package/templates/animation-studio/public/clip-library/point.json +69879 -0
  68. package/templates/animation-studio/public/clip-library/react.json +66084 -0
  69. package/templates/animation-studio/public/clip-library/run.json +9648 -0
  70. package/templates/animation-studio/public/clip-library/talk.json +69879 -0
  71. package/templates/animation-studio/public/clip-library/walk.json +8283 -0
  72. package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
  73. package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
  74. package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
  75. package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
  76. package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
  77. package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
  78. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
  79. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
  80. package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
  81. package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
  82. package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
  83. package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
  84. package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
  85. package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
  86. package/templates/animation-studio/scripts/anim.ts +103 -0
  87. package/templates/animation-studio/scripts/animation-scene.ts +457 -0
  88. package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
  89. package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
  90. package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
  91. package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
  92. package/templates/animation-studio/scripts/build-characters.ts +1543 -0
  93. package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
  94. package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
  95. package/templates/animation-studio/scripts/determinism-check.ts +48 -0
  96. package/templates/animation-studio/scripts/generate-scene.ts +95 -0
  97. package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
  98. package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
  99. package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
  100. package/templates/animation-studio/scripts/preview-server.ts +52 -0
  101. package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
  102. package/templates/animation-studio/scripts/render-core.ts +360 -0
  103. package/templates/animation-studio/scripts/render-live.ts +813 -0
  104. package/templates/animation-studio/scripts/render-parallel.ts +70 -0
  105. package/templates/animation-studio/scripts/render-server.ts +119 -0
  106. package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
  107. package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
  108. package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
  109. package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
  110. package/templates/animation-studio/scripts/texture-bake.ts +297 -0
  111. package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
  112. package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
  113. package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
  114. package/templates/animation-studio/src/animation-performance.ts +556 -0
  115. package/templates/animation-studio/src/aura-assets.ts +71 -0
  116. package/templates/animation-studio/src/character.ts +40 -0
  117. package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
  118. package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
  119. package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
  120. package/templates/animation-studio/src/empty-document.ts +77 -0
  121. package/templates/animation-studio/src/episode-document.ts +442 -0
  122. package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
  123. package/templates/animation-studio/src/fidelity.ts +129 -0
  124. package/templates/animation-studio/src/graph.ts +14 -0
  125. package/templates/animation-studio/src/procedural-hdri.ts +243 -0
  126. package/templates/animation-studio/src/profile.ts +97 -0
  127. package/templates/animation-studio/src/render-live-route.ts +42 -0
  128. package/templates/animation-studio/src/render-modes.ts +137 -0
  129. package/templates/animation-studio/src/scene-player.ts +867 -0
  130. package/templates/animation-studio/src/set-templates.ts +361 -0
  131. package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
  132. package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
  133. package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
  134. package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
  135. package/templates/animation-studio/tsconfig.json +26 -0
  136. package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
  137. package/templates/character-controller/README.md +33 -0
  138. package/templates/character-controller/index.html +19 -0
  139. package/templates/character-controller/package.json +23 -0
  140. package/templates/character-controller/playwright.config.ts +14 -0
  141. package/templates/character-controller/src/controller.ts +44 -0
  142. package/templates/character-controller/src/main.ts +62 -0
  143. package/templates/character-controller/tests/route-health.spec.ts +26 -0
  144. package/templates/character-controller/tests/screenshot.spec.ts +9 -0
  145. package/templates/character-controller/tsconfig.json +15 -0
  146. package/templates/cinematic-scene/package.json +1 -1
  147. package/templates/episode-builder/README.md +4 -4
  148. package/templates/episode-builder/package.json +1 -1
  149. package/templates/episode-builder/src/aura-assets.ts +1 -1
  150. package/templates/episode-builder/src/builder.ts +4 -4
  151. package/templates/episode-builder/src/episode.ts +21 -21
  152. package/templates/episode-builder/src/main.ts +20 -20
  153. package/templates/episode-builder/src/render-plan.ts +13 -13
  154. package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
  155. package/templates/fighting-game/package.json +1 -1
  156. package/templates/mini-game/package.json +1 -1
  157. package/templates/product-viewer/package.json +1 -1
  158. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
  159. package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
  160. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
  161. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
  162. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
  163. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
  164. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
  165. package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
  166. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
  167. package/templates/cartoon-studio/aura.assets.json +0 -400
  168. package/templates/cartoon-studio/package.json +0 -33
  169. package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
  170. package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
  171. package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
  172. package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
  173. package/templates/cartoon-studio/scripts/episode.ts +0 -323
  174. package/templates/cartoon-studio/scripts/render-live.ts +0 -873
  175. package/templates/cartoon-studio/src/aura-assets.ts +0 -64
  176. package/templates/cartoon-studio/src/characters.ts +0 -65
  177. package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
  178. package/templates/cartoon-studio/src/episode.ts +0 -306
  179. package/templates/cartoon-studio/src/main.ts +0 -400
  180. package/templates/cartoon-studio/src/render-live-route.ts +0 -913
  181. package/templates/cartoon-studio/src/render-plan.ts +0 -567
  182. package/templates/cartoon-studio/src/review.ts +0 -99
  183. package/templates/cartoon-studio/src/studio.ts +0 -101
  184. package/templates/cartoon-studio/test-results/.last-run.json +0 -4
  185. package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
  186. package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
  187. package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
  188. package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
  189. package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
  190. package/templates/cartoon-studio/tsconfig.json +0 -14
  191. package/templates/prompt-cartoon-channel/index.html +0 -13
  192. package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
  193. /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
  194. /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
  195. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
  196. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
  197. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
  198. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
  199. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
  200. /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
  201. /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
  202. /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
  203. /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
  204. /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
  205. /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
  206. /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
  207. /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
  208. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
  209. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
  210. /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
  211. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
  212. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
  213. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
  214. /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
  215. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tsconfig.json +0 -0
@@ -0,0 +1,1543 @@
1
+ /**
2
+ * build-characters.ts — Aura3D-authored, procedurally generated rigged animation
3
+ * characters as REAL binary glTF 2.0 (.glb).
4
+ *
5
+ * DEFAULT (high-fidelity humanoid cast). By default this emits TWO genuinely
6
+ * better, properly-rigged humanoid characters — "Miko" (cyan) and "Luma" (taller,
7
+ * warm gold) — built on a FULL humanoid bone chain so the standard retargeter and
8
+ * the resolver's rig grader both read them as A-grade:
9
+ *
10
+ * hips → spine → chest → neck → head
11
+ * chest → {left,right}Shoulder → UpperArm → LowerArm → Hand
12
+ * hips → {left,right}UpperLeg → LowerLeg → Foot → Toes
13
+ *
14
+ * That is 22 standard-named joints (vs. the legacy 7-node mascot), so the limb
15
+ * chains needed for gesture / point / walk / foot-work are all present — the rig is
16
+ * meaningfully higher fidelity, not a stub. Joint NODE NAMES use the canonical
17
+ * Mixamo/VRM-style names ("hips","spine","chest","neck","head","leftShoulder",
18
+ * "leftUpperArm","leftLowerArm","leftHand","leftUpperLeg","leftLowerLeg","leftFoot",
19
+ * "leftToes", …) so `resolve-asset.ts`'s humanoid/arm/leg hint detector and the
20
+ * engine's `inferHumanoidRig` recognise the skeleton.
21
+ *
22
+ * The mesh is built from SMOOTH ELLIPSOID/CAPSULE limb segments (UV-sphere lobes),
23
+ * one segment rigidly skinned per joint plus blended shoulder/hip joins, at higher
24
+ * ring/sector resolution than the legacy mascot, so the silhouette reads as a
25
+ * articulated figure rather than stacked crates. Each character ships FIVE materials
26
+ * (body / skin / dark / glow / accent) for real vertex-colour-like shading variety
27
+ * — emitted as separate glTF primitives that share one skin.
28
+ *
29
+ * Each GLB contains:
30
+ * - a skinned mesh split into primitives by material (POSITION / NORMAL +
31
+ * JOINTS_0 / WEIGHTS_0), every primitive carrying the `mouthOpen` morph target
32
+ * (zero deltas except on the mouth lobe) so the glTF morph constraint holds,
33
+ * - the 22-joint humanoid skeleton, with skins[].inverseBindMatrices,
34
+ * - 4 animation clips — Idle, Wave, Walk, Talk — sampling node rotations/translations
35
+ * across spine/arms/legs (real limb motion, not just a head bob),
36
+ * - ONE mouth morph target named `mouthOpen` (POSITION deltas that drop the mouth
37
+ * lobe to open the mouth), exposed via each primitive `targets`, the mesh
38
+ * `weights`, and `meshes[].extras.targetNames = ["mouthOpen"]`.
39
+ *
40
+ * LOW-FI fallback (`--low-fi`). The legacy 7-node rounded mascots are retained ONLY
41
+ * behind an explicit `--low-fi` flag (previz / smoke-test cast). They are NOT the
42
+ * default any more.
43
+ *
44
+ * The buffers are authored by hand (accessors / bufferViews / min-max), little
45
+ * endian, 4-byte aligned, packed into one GLB BIN chunk. No three.js, no glTF
46
+ * exporter dependency — just typed-array math.
47
+ *
48
+ * Run: `npx tsx scripts/build-characters.ts` (default: humanoid cast)
49
+ * `npx tsx scripts/build-characters.ts --low-fi` (legacy 7-node mascots)
50
+ * (also runnable from the monorepo with the same command in this directory.)
51
+ */
52
+
53
+ import { createHash } from "node:crypto";
54
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
55
+ import { dirname, resolve } from "node:path";
56
+ import { fileURLToPath } from "node:url";
57
+ import {
58
+ ATLAS_REGIONS,
59
+ bakeAtlasPNG,
60
+ type AtlasRegion,
61
+ type BakeColors,
62
+ type RegionName
63
+ } from "./texture-bake.ts";
64
+
65
+ const __dirname = dirname(fileURLToPath(import.meta.url));
66
+ const TEMPLATE_ROOT = resolve(__dirname, "..");
67
+ const OUTPUT_DIR = resolve(TEMPLATE_ROOT, "public/aura-assets");
68
+ const MANIFEST_PATH = resolve(TEMPLATE_ROOT, "aura.assets.json");
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Small math helpers (column-major mat4, glTF/Aura convention).
72
+ // ---------------------------------------------------------------------------
73
+ type Vec3 = [number, number, number];
74
+ type Quat = [number, number, number, number];
75
+ type Mat4 = number[]; // length 16, column-major
76
+
77
+ function identity(): Mat4 {
78
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
79
+ }
80
+
81
+ function translation(x: number, y: number, z: number): Mat4 {
82
+ const m = identity();
83
+ m[12] = x;
84
+ m[13] = y;
85
+ m[14] = z;
86
+ return m;
87
+ }
88
+
89
+ function invertTranslation(m: Mat4): Mat4 {
90
+ // Our joint binds are pure translations, so the inverse-bind is just a negated
91
+ // translation. (Computed generically would also work; this keeps it exact.)
92
+ return translation(-m[12]!, -m[13]!, -m[14]!);
93
+ }
94
+
95
+ function quatFromAxisAngle(axis: Vec3, angle: number): Quat {
96
+ const half = angle / 2;
97
+ const s = Math.sin(half);
98
+ const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
99
+ return [(axis[0] / len) * s, (axis[1] / len) * s, (axis[2] / len) * s, Math.cos(half)];
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Geometry builder: accumulate smooth ellipsoid lobes into skinned meshes,
104
+ // grouped by material so eyes/mouth get their own colour.
105
+ // ---------------------------------------------------------------------------
106
+ interface MeshAccumulator {
107
+ positions: number[]; // xyz triples
108
+ normals: number[]; // xyz triples
109
+ joints: number[]; // 4 per vertex (joint indices)
110
+ weights: number[]; // 4 per vertex
111
+ indices: number[];
112
+ morphDeltas: number[]; // xyz triples, one per vertex (mouthOpen)
113
+ uvs: number[]; // uv pairs, one per vertex (TEXCOORD_0; filled by assignAtlasUVs)
114
+ }
115
+
116
+ function makeAccumulator(): MeshAccumulator {
117
+ return { positions: [], normals: [], joints: [], weights: [], indices: [], morphDeltas: [], uvs: [] };
118
+ }
119
+
120
+ interface EllipsoidOptions {
121
+ /** Lobe center in model space. */
122
+ readonly center: Vec3;
123
+ /** Ellipsoid radii along each axis. */
124
+ readonly radii: Vec3;
125
+ /** Joint index every vertex of this lobe is rigidly bound to (weight 1). */
126
+ readonly joint: number;
127
+ /** Optional mouthOpen morph delta applied to every vertex of this lobe. */
128
+ readonly morphDelta?: Vec3;
129
+ readonly rings?: number;
130
+ readonly sectors?: number;
131
+ }
132
+
133
+ /** Append a smooth ellipsoid (UV sphere scaled by radii) skinned to one joint. */
134
+ function addEllipsoid(mesh: MeshAccumulator, options: EllipsoidOptions): void {
135
+ const rings = options.rings ?? 24;
136
+ const sectors = options.sectors ?? 32;
137
+ const morph = options.morphDelta ?? [0, 0, 0];
138
+ const base = mesh.positions.length / 3;
139
+ for (let r = 0; r <= rings; r += 1) {
140
+ const phi = (r / rings) * Math.PI; // 0..pi (top -> bottom)
141
+ const sinP = Math.sin(phi);
142
+ const cosP = Math.cos(phi);
143
+ for (let s = 0; s <= sectors; s += 1) {
144
+ const theta = (s / sectors) * Math.PI * 2;
145
+ const nx = sinP * Math.cos(theta);
146
+ const ny = cosP;
147
+ const nz = sinP * Math.sin(theta);
148
+ mesh.positions.push(
149
+ options.center[0] + nx * options.radii[0],
150
+ options.center[1] + ny * options.radii[1],
151
+ options.center[2] + nz * options.radii[2]
152
+ );
153
+ // Ellipsoid normal = normalize(unitDir / radii).
154
+ let mnx = nx / options.radii[0];
155
+ let mny = ny / options.radii[1];
156
+ let mnz = nz / options.radii[2];
157
+ const nl = Math.hypot(mnx, mny, mnz) || 1;
158
+ mnx /= nl;
159
+ mny /= nl;
160
+ mnz /= nl;
161
+ mesh.normals.push(mnx, mny, mnz);
162
+ mesh.joints.push(options.joint, 0, 0, 0);
163
+ mesh.weights.push(1, 0, 0, 0);
164
+ mesh.morphDeltas.push(morph[0], morph[1], morph[2]);
165
+ }
166
+ }
167
+ const stride = sectors + 1;
168
+ for (let r = 0; r < rings; r += 1) {
169
+ for (let s = 0; s < sectors; s += 1) {
170
+ const a = base + r * stride + s;
171
+ const b = base + (r + 1) * stride + s;
172
+ const c = base + (r + 1) * stride + s + 1;
173
+ const d = base + r * stride + s + 1;
174
+ // Wound for outward-facing normals (front +z faces the camera).
175
+ mesh.indices.push(a, d, b, b, d, c);
176
+ }
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Skeleton definition. Joint indices are referenced by JOINTS_0 above.
182
+ // Bind transforms are pure translations placing each joint in model space.
183
+ // ---------------------------------------------------------------------------
184
+ interface JointDef {
185
+ readonly name: string;
186
+ readonly parent: number; // -1 for root
187
+ readonly bind: Vec3; // model-space bind translation (relative to parent)
188
+ }
189
+
190
+ // Material indices (one glTF primitive per material). The humanoid cast uses all five;
191
+ // the legacy mascot uses only BODY/DARK/GLOW (the extra two stay empty → no primitive).
192
+ const MAT_BODY = 0; // torso / limb cloth
193
+ const MAT_DARK = 1; // pupils + mouth + boots/gloves
194
+ const MAT_GLOW = 2; // eyes + accent tip
195
+ const MAT_SKIN = 3; // head / hands (warmer skin tone) — humanoid only
196
+ const MAT_ACCENT = 4; // belt / collar / trim — humanoid only
197
+
198
+ // Each material primitive samples its OWN region of the shared base-colour atlas. This
199
+ // maps a glTF material index to its atlas region (see texture-bake.ts ATLAS_REGIONS).
200
+ const REGION_FOR_MATERIAL: Record<number, RegionName> = {
201
+ [MAT_BODY]: "body",
202
+ [MAT_DARK]: "dark",
203
+ [MAT_GLOW]: "glow",
204
+ [MAT_SKIN]: "skin",
205
+ [MAT_ACCENT]: "accent"
206
+ };
207
+
208
+ /**
209
+ * Assign PLANAR UVs for every vertex of a material accumulator into that material's atlas
210
+ * region. We project on the part's dominant plane: front-facing parts (head/body/eyes)
211
+ * unwrap on XY, top/sole-facing parts fall back to XZ if the part is flatter in Y. A small
212
+ * inset keeps UVs off the region's bilinear-bleed gutter. This is a genuine per-part planar
213
+ * unwrap (not a single shared quad), so the baked shading lands consistently across the body.
214
+ */
215
+ function assignAtlasUVs(mesh: MeshAccumulator, region: AtlasRegion): void {
216
+ const n = mesh.positions.length / 3;
217
+ if (n === 0) {
218
+ mesh.uvs = [];
219
+ return;
220
+ }
221
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
222
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
223
+ for (let i = 0; i < n; i += 1) {
224
+ const x = mesh.positions[i * 3]!;
225
+ const y = mesh.positions[i * 3 + 1]!;
226
+ const z = mesh.positions[i * 3 + 2]!;
227
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
228
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
229
+ if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
230
+ }
231
+ const spanX = maxX - minX || 1e-4;
232
+ const spanY = maxY - minY || 1e-4;
233
+ const spanZ = maxZ - minZ || 1e-4;
234
+ // Project on XY normally; if the part is much flatter vertically than in depth (a sole /
235
+ // belt slab), project on XZ so the top face still receives texel detail.
236
+ const useXZ = spanY < spanX * 0.45 && spanY < spanZ * 0.45;
237
+ const inset = 0.04; // keep UVs inside the region gutter
238
+ const ru = (region.u1 - region.u0) * (1 - inset * 2);
239
+ const rv = (region.v1 - region.v0) * (1 - inset * 2);
240
+ const ou = region.u0 + (region.u1 - region.u0) * inset;
241
+ const ov = region.v0 + (region.v1 - region.v0) * inset;
242
+ const out: number[] = [];
243
+ for (let i = 0; i < n; i += 1) {
244
+ const x = mesh.positions[i * 3]!;
245
+ const y = mesh.positions[i * 3 + 1]!;
246
+ const z = mesh.positions[i * 3 + 2]!;
247
+ let lu: number;
248
+ let lv: number;
249
+ if (useXZ) {
250
+ lu = (x - minX) / spanX;
251
+ lv = (z - minZ) / spanZ;
252
+ } else {
253
+ lu = (x - minX) / spanX;
254
+ // Flip V so texture "top" (forehead / region top) aligns with the part's top (+Y).
255
+ lv = 1 - (y - minY) / spanY;
256
+ }
257
+ out.push(ou + lu * ru, ov + lv * rv);
258
+ }
259
+ mesh.uvs = out;
260
+ }
261
+
262
+ type AccessoryStyle = "antenna" | "ears";
263
+
264
+ interface CharacterDesign {
265
+ readonly id: string;
266
+ readonly name: string;
267
+ /** Body color (base color factor of the body material). */
268
+ readonly bodyColor: [number, number, number, number];
269
+ /** Glow/eye accent color. */
270
+ readonly accentColor: [number, number, number, number];
271
+ /** Skin tone (head + hands) — humanoid cast. */
272
+ readonly skinColor: [number, number, number, number];
273
+ /** Belt / collar / trim — humanoid cast. */
274
+ readonly trimColor: [number, number, number, number];
275
+ /** Overall vertical scale multiplier (Luma is taller than Miko). */
276
+ readonly heightScale: number;
277
+ /** Limb/body girth multiplier (Miko is rounder/chunkier). */
278
+ readonly girth: number;
279
+ /** Head-top accessory that differentiates the silhouettes. */
280
+ readonly accessory: AccessoryStyle;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // HUMANOID skeleton (default) — a FULL 22-joint chain with canonical bone NODE
285
+ // NAMES so the resolver's humanoid/arm/leg hint detector and the engine's
286
+ // inferHumanoidRig recognise it as a graded-A rig. Binds are pure translations
287
+ // relative to the parent, laying the figure out in a standard T/A pose.
288
+ // ---------------------------------------------------------------------------
289
+ const HUMANOID_JOINTS: readonly JointDef[] = [
290
+ { name: "hips", parent: -1, bind: [0, 0.95, 0] },
291
+ { name: "spine", parent: 0, bind: [0, 0.16, 0] },
292
+ { name: "chest", parent: 1, bind: [0, 0.18, 0] },
293
+ { name: "neck", parent: 2, bind: [0, 0.16, 0] },
294
+ { name: "head", parent: 3, bind: [0, 0.10, 0] },
295
+ // Left arm chain (chest → shoulder → upperArm → lowerArm → hand). Laid out in a
296
+ // relaxed A-pose (down + out) rather than a wide T so the silhouette reads naturally.
297
+ { name: "leftShoulder", parent: 2, bind: [0.07, 0.10, 0] },
298
+ { name: "leftUpperArm", parent: 5, bind: [0.11, -0.02, 0] },
299
+ { name: "leftLowerArm", parent: 6, bind: [0.14, -0.20, 0] },
300
+ { name: "leftHand", parent: 7, bind: [0.10, -0.18, 0] },
301
+ // Right arm chain.
302
+ { name: "rightShoulder", parent: 2, bind: [-0.07, 0.10, 0] },
303
+ { name: "rightUpperArm", parent: 9, bind: [-0.11, -0.02, 0] },
304
+ { name: "rightLowerArm", parent: 10, bind: [-0.14, -0.20, 0] },
305
+ { name: "rightHand", parent: 11, bind: [-0.10, -0.18, 0] },
306
+ // Left leg chain (hips → upperLeg → lowerLeg → foot → toes).
307
+ { name: "leftUpperLeg", parent: 0, bind: [0.11, -0.05, 0] },
308
+ { name: "leftLowerLeg", parent: 13, bind: [0, -0.40, 0] },
309
+ { name: "leftFoot", parent: 14, bind: [0, -0.40, 0] },
310
+ { name: "leftToes", parent: 15, bind: [0, -0.04, 0.10] },
311
+ // Right leg chain.
312
+ { name: "rightUpperLeg", parent: 0, bind: [-0.11, -0.05, 0] },
313
+ { name: "rightLowerLeg", parent: 17, bind: [0, -0.40, 0] },
314
+ { name: "rightFoot", parent: 18, bind: [0, -0.40, 0] },
315
+ { name: "rightToes", parent: 19, bind: [0, -0.04, 0.10] }
316
+ ];
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Legacy MASCOT skeleton (low-fi fallback) — the original 7-node rig.
320
+ // ---------------------------------------------------------------------------
321
+ const MASCOT_JOINTS: readonly JointDef[] = [
322
+ { name: "root", parent: -1, bind: [0, 0, 0] },
323
+ { name: "spine", parent: 0, bind: [0, 0.42, 0] },
324
+ { name: "head", parent: 1, bind: [0, 0.5, 0] },
325
+ { name: "armL", parent: 1, bind: [0.26, 0.16, 0] },
326
+ { name: "armR", parent: 1, bind: [-0.26, 0.16, 0] },
327
+ { name: "legL", parent: 0, bind: [0.13, 0, 0] },
328
+ { name: "legR", parent: 0, bind: [-0.13, 0, 0] }
329
+ ];
330
+
331
+ // The active skeleton is selected at startup by the --low-fi flag (see main()).
332
+ const LOW_FI = process.argv.includes("--low-fi");
333
+ const JOINTS: readonly JointDef[] = LOW_FI ? MASCOT_JOINTS : HUMANOID_JOINTS;
334
+
335
+ const JOINT_INDEX = Object.fromEntries(JOINTS.map((joint, index) => [joint.name, index])) as Record<string, number>;
336
+
337
+ /** World-space bind position of a joint (sum of bind translations up the chain). */
338
+ function jointWorldBind(index: number): Vec3 {
339
+ let current = index;
340
+ const out: Vec3 = [0, 0, 0];
341
+ while (current >= 0) {
342
+ const joint = JOINTS[current]!;
343
+ out[0] += joint.bind[0];
344
+ out[1] += joint.bind[1];
345
+ out[2] += joint.bind[2];
346
+ current = joint.parent;
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Dispatch to the active body builder (humanoid by default, mascot under --low-fi). */
352
+ function buildBody(design: CharacterDesign): Map<number, MeshAccumulator> {
353
+ const parts = LOW_FI ? buildMascotBody(design) : buildHumanoidBody(design);
354
+ // Planar-unwrap each material's geometry into its atlas region so every primitive
355
+ // carries TEXCOORD_0 that samples the baked base-colour texture.
356
+ for (const [materialIndex, mesh] of parts) {
357
+ const regionName = REGION_FOR_MATERIAL[materialIndex];
358
+ if (regionName) assignAtlasUVs(mesh, ATLAS_REGIONS[regionName]);
359
+ }
360
+ return parts;
361
+ }
362
+
363
+ /**
364
+ * Build a rounded mascot skinned to the legacy MASCOT_JOINTS, with a mouthOpen morph
365
+ * on the mouth lobe. Returns one MeshAccumulator per material index. (Low-fi fallback.)
366
+ */
367
+ function buildMascotBody(design: CharacterDesign): Map<number, MeshAccumulator> {
368
+ const parts = new Map<number, MeshAccumulator>([
369
+ [MAT_BODY, makeAccumulator()],
370
+ [MAT_DARK, makeAccumulator()],
371
+ [MAT_GLOW, makeAccumulator()]
372
+ ]);
373
+ const body = parts.get(MAT_BODY)!;
374
+ const dark = parts.get(MAT_DARK)!;
375
+ const glow = parts.get(MAT_GLOW)!;
376
+ const g = design.girth;
377
+ const h = design.heightScale;
378
+
379
+ const headW = jointWorldBind(JOINT_INDEX.head!);
380
+ const spineW = jointWorldBind(JOINT_INDEX.spine!);
381
+ const armLW = jointWorldBind(JOINT_INDEX.armL!);
382
+ const armRW = jointWorldBind(JOINT_INDEX.armR!);
383
+ const legLW = jointWorldBind(JOINT_INDEX.legL!);
384
+ const legRW = jointWorldBind(JOINT_INDEX.legR!);
385
+
386
+ // Egg body (bound to spine).
387
+ addEllipsoid(body, {
388
+ center: [spineW[0], spineW[1] + 0.02 * h, spineW[2]],
389
+ radii: [0.32 * g, 0.34 * h, 0.30 * g],
390
+ joint: JOINT_INDEX.spine!
391
+ });
392
+ // Big round head (bound to head) — large head reads "cute mascot".
393
+ addEllipsoid(body, {
394
+ center: [headW[0], headW[1] + 0.06 * h, headW[2]],
395
+ radii: [0.40 * g, 0.38 * h, 0.40 * g],
396
+ joint: JOINT_INDEX.head!
397
+ });
398
+
399
+ // Eyes — glowing accent lobes on the front of the face.
400
+ const eyeY = headW[1] + 0.10 * h;
401
+ const eyeZ = headW[2] + 0.32 * g;
402
+ for (const sx of [1, -1]) {
403
+ addEllipsoid(glow, {
404
+ center: [headW[0] + sx * 0.15 * g, eyeY, eyeZ],
405
+ radii: [0.10 * g, 0.12 * h, 0.07 * g],
406
+ joint: JOINT_INDEX.head!,
407
+ rings: 10,
408
+ sectors: 12
409
+ });
410
+ // Dark pupil just in front of each eye.
411
+ addEllipsoid(dark, {
412
+ center: [headW[0] + sx * 0.15 * g, eyeY, eyeZ + 0.04 * g],
413
+ radii: [0.045 * g, 0.06 * h, 0.045 * g],
414
+ joint: JOINT_INDEX.head!,
415
+ rings: 8,
416
+ sectors: 10
417
+ });
418
+ }
419
+
420
+ // Mouth — dark lobe below the eyes WITH the mouthOpen morph (drops to open).
421
+ addEllipsoid(dark, {
422
+ center: [headW[0], headW[1] - 0.07 * h, headW[2] + 0.34 * g],
423
+ radii: [0.12 * g, 0.05 * h, 0.05 * g],
424
+ joint: JOINT_INDEX.head!,
425
+ rings: 8,
426
+ sectors: 12,
427
+ morphDelta: [0, -0.14 * h, 0.02 * g]
428
+ });
429
+
430
+ // Arms (bound to armL / armR) — rounded, hanging at the sides — with hand nubs.
431
+ for (const [armW, joint, sx] of [
432
+ [armLW, JOINT_INDEX.armL!, 1],
433
+ [armRW, JOINT_INDEX.armR!, -1]
434
+ ] as const) {
435
+ addEllipsoid(body, {
436
+ center: [armW[0] + sx * 0.04 * g, armW[1] - 0.16 * h, armW[2]],
437
+ radii: [0.10 * g, 0.20 * h, 0.10 * g],
438
+ joint,
439
+ rings: 10,
440
+ sectors: 12
441
+ });
442
+ addEllipsoid(body, {
443
+ center: [armW[0] + sx * 0.05 * g, armW[1] - 0.34 * h, armW[2]],
444
+ radii: [0.09 * g, 0.09 * h, 0.09 * g],
445
+ joint,
446
+ rings: 8,
447
+ sectors: 10
448
+ });
449
+ }
450
+
451
+ // Feet (bound to legL / legR) — rounded.
452
+ for (const [legW, joint] of [
453
+ [legLW, JOINT_INDEX.legL!],
454
+ [legRW, JOINT_INDEX.legR!]
455
+ ] as const) {
456
+ addEllipsoid(body, {
457
+ center: [legW[0], legW[1] - 0.20 * h, legW[2] + 0.04 * g],
458
+ radii: [0.13 * g, 0.14 * h, 0.17 * g],
459
+ joint,
460
+ rings: 10,
461
+ sectors: 12
462
+ });
463
+ }
464
+
465
+ // Per-character head accessory (distinct silhouettes).
466
+ if (design.accessory === "antenna") {
467
+ // Thin stalk + glowing tip ball.
468
+ addEllipsoid(body, {
469
+ center: [headW[0], headW[1] + 0.42 * h, headW[2]],
470
+ radii: [0.03 * g, 0.13 * h, 0.03 * g],
471
+ joint: JOINT_INDEX.head!,
472
+ rings: 8,
473
+ sectors: 8
474
+ });
475
+ addEllipsoid(glow, {
476
+ center: [headW[0], headW[1] + 0.57 * h, headW[2]],
477
+ radii: [0.075 * g, 0.075 * h, 0.075 * g],
478
+ joint: JOINT_INDEX.head!,
479
+ rings: 10,
480
+ sectors: 12
481
+ });
482
+ } else {
483
+ // Two tall ear bumps on top of the head.
484
+ for (const sx of [1, -1]) {
485
+ addEllipsoid(body, {
486
+ center: [headW[0] + sx * 0.22 * g, headW[1] + 0.34 * h, headW[2]],
487
+ radii: [0.10 * g, 0.17 * h, 0.09 * g],
488
+ joint: JOINT_INDEX.head!,
489
+ rings: 10,
490
+ sectors: 12
491
+ });
492
+ }
493
+ }
494
+
495
+ return parts;
496
+ }
497
+
498
+ interface LimbOptions {
499
+ /** Cross-section radius at the `from` (parent) end. */
500
+ readonly rFrom: number;
501
+ /** Cross-section radius at the `to` (child) end. Defaults to `rFrom` (no taper). */
502
+ readonly rTo?: number;
503
+ /** Joint that owns the `from` end (weight 1 there). */
504
+ readonly joint: number;
505
+ /**
506
+ * Optional joint that owns the `to` end. When given, vertices blend smoothly from
507
+ * `joint` → `childJoint` along the bone so the limb deforms naturally across the
508
+ * elbow/knee instead of tearing. When omitted the whole segment is rigid to `joint`.
509
+ */
510
+ readonly childJoint?: number;
511
+ readonly rings?: number;
512
+ readonly sectors?: number;
513
+ }
514
+
515
+ /**
516
+ * Append a tapered CAPSULE limb segment along the parent→child axis. The cross-section
517
+ * radius is interpolated from `rFrom` (parent end) to `rTo` (child end) so the arm/leg
518
+ * naturally narrows toward the wrist/ankle, and the surface is closed with smooth round
519
+ * caps. Higher default ring/sector counts than before for a smoother silhouette.
520
+ *
521
+ * Skinning: if `childJoint` is supplied, each vertex's weight is split between the parent
522
+ * and child joints by its position along the bone (a smoothstep centred on the midpoint),
523
+ * so the join at the next joint bends rather than separates. Otherwise the segment is
524
+ * rigidly skinned to `joint` (weight 1).
525
+ */
526
+ function addLimbSegment(mesh: MeshAccumulator, from: Vec3, to: Vec3, options: LimbOptions): void {
527
+ const rings = options.rings ?? 24;
528
+ const sectors = options.sectors ?? 28;
529
+ const rFrom = options.rFrom;
530
+ const rTo = options.rTo ?? options.rFrom;
531
+ const dx = to[0] - from[0];
532
+ const dy = to[1] - from[1];
533
+ const dz = to[2] - from[2];
534
+ const len = Math.hypot(dx, dy, dz) || 1e-4;
535
+ const mid: Vec3 = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2];
536
+ const capR = Math.max(rFrom, rTo);
537
+ const half = len / 2 + capR * 0.5; // slight overlap at the joints for a continuous surface
538
+ const ux = dx / len, uy = dy / len, uz = dz / len; // unit bone direction
539
+ // Orthonormal basis with the bone direction as the local +Y axis.
540
+ let ax = 1, ay = 0, az = 0;
541
+ if (Math.abs(uy) > 0.99) { ax = 1; ay = 0; az = 0; } // arbitrary perpendicular for vertical bones
542
+ // Gram-Schmidt: e1 = normalize(a - (a·u)u)
543
+ const d = ax * ux + ay * uy + az * uz;
544
+ let e1x = ax - d * ux, e1y = ay - d * uy, e1z = az - d * uz;
545
+ const e1l = Math.hypot(e1x, e1y, e1z) || 1; e1x /= e1l; e1y /= e1l; e1z /= e1l;
546
+ // e2 = u × e1
547
+ const e2x = uy * e1z - uz * e1y;
548
+ const e2y = uz * e1x - ux * e1z;
549
+ const e2z = ux * e1y - uy * e1x;
550
+ const base = mesh.positions.length / 3;
551
+ const smoothstep = (t: number): number => {
552
+ const c = Math.min(1, Math.max(0, t));
553
+ return c * c * (3 - 2 * c);
554
+ };
555
+ for (let ri = 0; ri <= rings; ri += 1) {
556
+ const v = ri / rings; // 0..1 along the bone (0 = parent end)
557
+ const y = (v - 0.5) * 2 * half; // local Y position (-half..half)
558
+ // Tapered radius parent→child, with rounded end caps (sin envelope near the tips).
559
+ const lerpR = rFrom + (rTo - rFrom) * v;
560
+ const cap = Math.sin(Math.PI * v); // 0 at tips, 1 at middle
561
+ const cr = lerpR * (0.78 + 0.22 * cap);
562
+ // Weight blend along the bone (only meaningful when childJoint is given). The child
563
+ // joint takes over across a band centred on the limb midpoint.
564
+ const wChild = options.childJoint !== undefined ? smoothstep((v - 0.35) / 0.5) : 0;
565
+ const wParent = 1 - wChild;
566
+ for (let si = 0; si <= sectors; si += 1) {
567
+ const theta = (si / sectors) * Math.PI * 2;
568
+ const lx = Math.cos(theta) * cr;
569
+ const lz = Math.sin(theta) * cr;
570
+ // world = mid + lx*e1 + y*u + lz*e2
571
+ const px = mid[0] + lx * e1x + y * ux + lz * e2x;
572
+ const py = mid[1] + lx * e1y + y * uy + lz * e2y;
573
+ const pz = mid[2] + lx * e1z + y * uz + lz * e2z;
574
+ mesh.positions.push(px, py, pz);
575
+ // Normal points radially outward (lx*e1 + lz*e2 direction).
576
+ let nx = lx * e1x + lz * e2x;
577
+ let ny = lx * e1y + lz * e2y;
578
+ let nz = lx * e1z + lz * e2z;
579
+ const nl = Math.hypot(nx, ny, nz) || 1; nx /= nl; ny /= nl; nz /= nl;
580
+ mesh.normals.push(nx, ny, nz);
581
+ if (options.childJoint !== undefined && wChild > 0) {
582
+ mesh.joints.push(options.joint, options.childJoint, 0, 0);
583
+ mesh.weights.push(wParent, wChild, 0, 0);
584
+ } else {
585
+ mesh.joints.push(options.joint, 0, 0, 0);
586
+ mesh.weights.push(1, 0, 0, 0);
587
+ }
588
+ mesh.morphDeltas.push(0, 0, 0);
589
+ }
590
+ }
591
+ const stride = sectors + 1;
592
+ for (let ri = 0; ri < rings; ri += 1) {
593
+ for (let si = 0; si < sectors; si += 1) {
594
+ const a = base + ri * stride + si;
595
+ const b = base + (ri + 1) * stride + si;
596
+ const c = base + (ri + 1) * stride + si + 1;
597
+ const dd = base + ri * stride + si + 1;
598
+ mesh.indices.push(a, dd, b, b, dd, c);
599
+ }
600
+ }
601
+ }
602
+
603
+ interface HandOptions {
604
+ /** Wrist position (the joint origin the whole hand is skinned to). */
605
+ readonly wrist: Vec3;
606
+ /** Unit vector pointing from the wrist toward the fingertips (the arm axis, away from body). */
607
+ readonly forward: Vec3;
608
+ /** Joint index every vertex of the hand is rigidly bound to (the hand joint). */
609
+ readonly joint: number;
610
+ /** Overall girth multiplier (design.girth). */
611
+ readonly g: number;
612
+ /** Overall vertical scale (design.heightScale). */
613
+ readonly h: number;
614
+ }
615
+
616
+ /**
617
+ * Append a STYLISED CARTOON HAND skinned rigidly to the hand joint: a flattened palm
618
+ * box-ish lobe plus four short finger nubs along the leading edge and a thumb nub off the
619
+ * inner side. It is deliberately low-detail (stubby, merged-looking digits) — a cartoon
620
+ * mitten-hand that reads as a hand rather than a paddle, NOT an anatomically separated set
621
+ * of fingers (separating them risks self-intersection at the knuckles under the dual-bone
622
+ * blend, and we keep the skeleton at 21 joints with no per-finger bones, so every digit is
623
+ * rigid to the single hand joint).
624
+ *
625
+ * The hand is built in a local frame: +F = forward (toward fingertips), +U = world up-ish,
626
+ * +S = side (thumb side). Fingers fan slightly so the silhouette isn't a single slab.
627
+ */
628
+ function addHand(mesh: MeshAccumulator, options: HandOptions): void {
629
+ const { wrist, joint, g, h } = options;
630
+ // Orthonormal hand frame. F = forward (down the arm). U = a world-up-ish vector made
631
+ // perpendicular to F. S = F × U (the palm's side axis, toward the thumb on +S).
632
+ const fl = Math.hypot(options.forward[0], options.forward[1], options.forward[2]) || 1;
633
+ const F: Vec3 = [options.forward[0] / fl, options.forward[1] / fl, options.forward[2] / fl];
634
+ let upx = 0, upy = 1, upz = 0;
635
+ const dotFU = F[0] * upx + F[1] * upy + F[2] * upz;
636
+ let Ux = upx - dotFU * F[0], Uy = upy - dotFU * F[1], Uz = upz - dotFU * F[2];
637
+ const ul = Math.hypot(Ux, Uy, Uz) || 1; Ux /= ul; Uy /= ul; Uz /= ul;
638
+ const U: Vec3 = [Ux, Uy, Uz];
639
+ const S: Vec3 = [
640
+ F[1] * U[2] - F[2] * U[1],
641
+ F[2] * U[0] - F[0] * U[2],
642
+ F[0] * U[1] - F[1] * U[0]
643
+ ];
644
+ // Map a local (f,u,s) offset (in metres) to world space from the wrist.
645
+ const at = (f: number, u: number, s: number): Vec3 => [
646
+ wrist[0] + F[0] * f + U[0] * u + S[0] * s,
647
+ wrist[1] + F[1] * f + U[1] * u + S[1] * s,
648
+ wrist[2] + F[2] * f + U[2] * u + S[2] * s
649
+ ];
650
+
651
+ // Palm — a flattened ellipsoid (thin along U, wider along S) just past the wrist.
652
+ const palmF = 0.05 * g; // palm centre distance from wrist along the arm
653
+ const palmHalfLen = 0.055 * g; // palm reach toward the knuckles
654
+ addEllipsoid(mesh, {
655
+ center: at(palmF, -0.01 * h, 0),
656
+ radii: [0.058 * g, 0.026 * h, 0.05 * g],
657
+ joint, rings: 14, sectors: 18
658
+ });
659
+
660
+ // Four finger nubs fanned across the leading (knuckle) edge of the palm. Each is a small
661
+ // capsule from a knuckle point out to a fingertip; stubby (cartoon) length.
662
+ const knuckleF = palmF + palmHalfLen * 0.8;
663
+ const fingerLen = 0.045 * g;
664
+ const fingerR = 0.016 * g;
665
+ // Side offsets for the 4 fingers (index→pinky), slightly fanned and shortened toward pinky.
666
+ const fingerSpread: readonly { s: number; len: number }[] = [
667
+ { s: 0.034 * g, len: fingerLen * 0.92 }, // index
668
+ { s: 0.011 * g, len: fingerLen * 1.0 }, // middle (longest)
669
+ { s: -0.012 * g, len: fingerLen * 0.95 }, // ring
670
+ { s: -0.034 * g, len: fingerLen * 0.8 } // pinky (shortest)
671
+ ];
672
+ for (const finger of fingerSpread) {
673
+ const root = at(knuckleF, -0.005 * h, finger.s);
674
+ // Fan: tips spread a touch further apart in S than the roots.
675
+ const tip = at(knuckleF + finger.len, 0.004 * h, finger.s * 1.18);
676
+ addLimbSegment(mesh, root, tip, {
677
+ rFrom: fingerR, rTo: fingerR * 0.7, joint, rings: 8, sectors: 10
678
+ });
679
+ }
680
+
681
+ // Thumb — a stubbier, lower nub off the inner (+S) side of the palm, angled back toward
682
+ // the body and downward so it reads as opposable rather than a 5th finger in the row.
683
+ const thumbRoot = at(palmF * 0.6, -0.012 * h, 0.05 * g);
684
+ const thumbTip = at(palmF * 0.6 + 0.03 * g, -0.022 * h, 0.075 * g);
685
+ addLimbSegment(mesh, thumbRoot, thumbTip, {
686
+ rFrom: 0.018 * g, rTo: 0.013 * g, joint, rings: 8, sectors: 10
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Build a properly-rigged HUMANOID skinned to HUMANOID_JOINTS (default cast). Every
692
+ * limb segment is skinned to its own joint, so the full performance vocabulary
693
+ * (gesture / point / walk / foot-work) drives real geometry. Returns one
694
+ * MeshAccumulator per material index (body/skin/dark/glow/accent).
695
+ */
696
+ function buildHumanoidBody(design: CharacterDesign): Map<number, MeshAccumulator> {
697
+ const parts = new Map<number, MeshAccumulator>([
698
+ [MAT_BODY, makeAccumulator()],
699
+ [MAT_DARK, makeAccumulator()],
700
+ [MAT_GLOW, makeAccumulator()],
701
+ [MAT_SKIN, makeAccumulator()],
702
+ [MAT_ACCENT, makeAccumulator()]
703
+ ]);
704
+ const body = parts.get(MAT_BODY)!;
705
+ const dark = parts.get(MAT_DARK)!;
706
+ const glow = parts.get(MAT_GLOW)!;
707
+ const skin = parts.get(MAT_SKIN)!;
708
+ const accent = parts.get(MAT_ACCENT)!;
709
+ const g = design.girth;
710
+ const h = design.heightScale;
711
+ const w = (name: string): Vec3 => jointWorldBind(JOINT_INDEX[name]!);
712
+ const J = (name: string): number => JOINT_INDEX[name]!;
713
+
714
+ // ── Torso: hips (pelvis) → spine (narrow waist) → chest (broad ribcage). The radii
715
+ // now taper like a real torso — wide-ish pelvis, pinched waist, broad chest — instead
716
+ // of three near-equal stacked balls, so the silhouette reads as a person, not a snowman.
717
+ // Each lobe is a connecting tapered segment between its joint and the next joint so the
718
+ // torso is one continuous tube; the lobe at each joint blends weight into the next bone.
719
+ addLimbSegment(body, w("hips"), w("spine"), {
720
+ rFrom: 0.165 * g, rTo: 0.125 * g, joint: J("hips"), childJoint: J("spine")
721
+ });
722
+ addLimbSegment(body, w("spine"), w("chest"), {
723
+ rFrom: 0.125 * g, rTo: 0.185 * g, joint: J("spine"), childJoint: J("chest")
724
+ });
725
+ // Chest cap: broad upper ribcage rounding off toward the shoulders/neck.
726
+ addEllipsoid(body, { center: [w("chest")[0], w("chest")[1] + 0.04 * h, w("chest")[2]], radii: [0.195 * g, 0.13 * h, 0.135 * g], joint: J("chest") });
727
+ // Pelvis cap so the bottom of the torso closes smoothly above the legs.
728
+ addEllipsoid(body, { center: [w("hips")[0], w("hips")[1] - 0.02 * h, w("hips")[2]], radii: [0.165 * g, 0.11 * h, 0.13 * g], joint: J("hips") });
729
+ // Belt at the hips + collar at the chest (accent trim).
730
+ addEllipsoid(accent, { center: [w("hips")[0], w("hips")[1] + 0.07 * h, w("hips")[2]], radii: [0.155 * g, 0.035 * h, 0.125 * g], joint: J("hips") });
731
+ addEllipsoid(accent, { center: [w("chest")[0], w("chest")[1] + 0.105 * h, w("chest")[2]], radii: [0.135 * g, 0.04 * h, 0.115 * g], joint: J("neck") });
732
+
733
+ // ── Neck (tapered into the chest + head) + head (skin tone). ──
734
+ addLimbSegment(skin, [w("neck")[0], w("neck")[1] - 0.03 * h, w("neck")[2]], [w("neck")[0], w("neck")[1] + 0.06 * h, w("neck")[2]], {
735
+ rFrom: 0.072 * g, rTo: 0.058 * g, joint: J("chest"), childJoint: J("head"), rings: 12, sectors: 20
736
+ });
737
+ const headW = [w("head")[0], w("head")[1] + 0.07 * h, w("head")[2]] as Vec3;
738
+ // Head is slightly egg-shaped (a touch narrower at the jaw) for a more human skull.
739
+ addEllipsoid(skin, { center: headW, radii: [0.145 * g, 0.165 * h, 0.15 * g], joint: J("head"), rings: 28, sectors: 36 });
740
+ // Small jaw/chin lobe so the lower face isn't a perfect sphere.
741
+ addEllipsoid(skin, { center: [headW[0], headW[1] - 0.10 * h, headW[2] + 0.03 * g], radii: [0.095 * g, 0.075 * h, 0.105 * g], joint: J("head"), rings: 16, sectors: 22 });
742
+
743
+ // ── Face geometry (skin) that FRAMES the eye spheres so they read as set in sockets,
744
+ // plus a brow ridge, defined nose bridge, and an upper-lip/chin shelf around the mouth.
745
+ // We build the face additively from skin lobes (the head is one sphere; we can't boolean
746
+ // a recess), so "sockets" are formed by a raised brow above + raised cheek/lower-lid
747
+ // below each eye, leaving the eye sitting in the gap between them. This reads as an eye
748
+ // socket in silhouette and shading without changing the skeleton or the eye spheres.
749
+ const eyeY = headW[1] + 0.02 * h;
750
+ const eyeZ = headW[2] + 0.12 * g;
751
+ const eyeDX = 0.055 * g;
752
+ // Brow ridge — one skin bar spanning above both eyes, slightly proud of the face so the
753
+ // forehead overhangs the eyes (the top of each socket).
754
+ addEllipsoid(skin, {
755
+ center: [headW[0], eyeY + 0.05 * h, headW[2] + 0.115 * g],
756
+ radii: [0.115 * g, 0.024 * h, 0.045 * g],
757
+ joint: J("head"), rings: 12, sectors: 22
758
+ });
759
+ for (const sx of [1, -1]) {
760
+ // Lower lid / cheekbone — a small skin ridge just below & outside each eye, forming the
761
+ // bottom of the socket so the eye nestles between brow (above) and cheek (below).
762
+ addEllipsoid(skin, {
763
+ center: [headW[0] + sx * (eyeDX + 0.006 * g), eyeY - 0.042 * h, headW[2] + 0.108 * g],
764
+ radii: [0.046 * g, 0.02 * h, 0.04 * g],
765
+ joint: J("head"), rings: 10, sectors: 16
766
+ });
767
+ // Eyeball (glow) seated slightly back into the socket gap.
768
+ addEllipsoid(glow, { center: [headW[0] + sx * eyeDX, eyeY, eyeZ], radii: [0.04 * g, 0.05 * h, 0.03 * g], joint: J("head"), rings: 14, sectors: 18 });
769
+ // Pupil (dark) on the front of the eyeball.
770
+ addEllipsoid(dark, { center: [headW[0] + sx * eyeDX, eyeY, eyeZ + 0.02 * g], radii: [0.018 * g, 0.024 * h, 0.018 * g], joint: J("head"), rings: 12, sectors: 14 });
771
+ }
772
+ // Nose — a vertical skin bridge from between the brows down to the tip, raised off the
773
+ // face so it casts/reads as a real nose between the two sockets.
774
+ addEllipsoid(skin, { center: [headW[0], eyeY - 0.01 * h, headW[2] + 0.135 * g], radii: [0.018 * g, 0.05 * h, 0.03 * g], joint: J("head"), rings: 12, sectors: 14 });
775
+ addEllipsoid(skin, { center: [headW[0], headW[1] - 0.02 * h, headW[2] + 0.15 * g], radii: [0.026 * g, 0.03 * h, 0.034 * g], joint: J("head"), rings: 12, sectors: 14 });
776
+ // Upper-lip / mouth shelf (skin) — a small raised band above the mouth so the mouth sits
777
+ // in a defined area rather than floating on the jaw.
778
+ addEllipsoid(skin, { center: [headW[0], headW[1] - 0.055 * h, headW[2] + 0.135 * g], radii: [0.055 * g, 0.02 * h, 0.028 * g], joint: J("head"), rings: 10, sectors: 18 });
779
+ // Mouth (dark, with mouthOpen morph) — unchanged drop-to-open morph keeps lip-sync working.
780
+ addEllipsoid(dark, {
781
+ center: [headW[0], headW[1] - 0.08 * h, headW[2] + 0.13 * g],
782
+ radii: [0.05 * g, 0.022 * h, 0.025 * g],
783
+ joint: J("head"),
784
+ rings: 12, sectors: 18,
785
+ morphDelta: [0, -0.06 * h, 0.01 * g]
786
+ });
787
+ // Hair / cap (accent) atop the head differentiates the silhouettes.
788
+ if (design.accessory === "antenna") {
789
+ addEllipsoid(accent, { center: [headW[0], headW[1] + 0.13 * h, headW[2] - 0.01 * g], radii: [0.155 * g, 0.10 * h, 0.155 * g], joint: J("head"), rings: 18, sectors: 24 });
790
+ addEllipsoid(glow, { center: [headW[0], headW[1] + 0.24 * h, headW[2]], radii: [0.03 * g, 0.03 * h, 0.03 * g], joint: J("head"), rings: 12, sectors: 14 });
791
+ } else {
792
+ addEllipsoid(accent, { center: [headW[0], headW[1] + 0.12 * h, headW[2] - 0.02 * g], radii: [0.165 * g, 0.12 * h, 0.16 * g], joint: J("head"), rings: 18, sectors: 24 });
793
+ }
794
+
795
+ // ── Arms: shoulder lobe (blends chest→upperArm) + tapered upperArm + lowerArm + hand.
796
+ // Upper arm is thicker at the deltoid and tapers to the elbow; forearm tapers to a
797
+ // slim wrist. Each segment blends its weight into the next joint so elbows/wrists bend.
798
+ for (const side of ["left", "right"] as const) {
799
+ const sh = w(`${side}Shoulder`);
800
+ const ua = w(`${side}UpperArm`);
801
+ const la = w(`${side}LowerArm`);
802
+ const hd = w(`${side}Hand`);
803
+ // Rounded shoulder/deltoid — bound to the shoulder but pulling toward the upper arm.
804
+ addEllipsoid(body, { center: [(sh[0] + ua[0]) / 2, (sh[1] + ua[1]) / 2, sh[2]], radii: [0.078 * g, 0.078 * h, 0.078 * g], joint: J(`${side}Shoulder`), rings: 16, sectors: 20 });
805
+ addLimbSegment(body, ua, la, { rFrom: 0.062 * g, rTo: 0.046 * g, joint: J(`${side}UpperArm`), childJoint: J(`${side}LowerArm`) });
806
+ // Elbow joint sphere blended across upper/lower arm so the bend stays continuous.
807
+ addEllipsoid(body, { center: la, radii: [0.05 * g, 0.05 * h, 0.05 * g], joint: J(`${side}LowerArm`), rings: 14, sectors: 16 });
808
+ addLimbSegment(body, la, hd, { rFrom: 0.046 * g, rTo: 0.036 * g, joint: J(`${side}LowerArm`), childJoint: J(`${side}Hand`) });
809
+ // Hand (skin): a stylised cartoon hand — flattened palm + four short finger nubs + a
810
+ // thumb — skinned rigidly to the hand joint. Forward = wrist→handTip direction (down the
811
+ // arm, away from the body), so the fingers point away from the wrist. The thumb sits on
812
+ // the +S (toward-body) side via the side offset in addHand.
813
+ const forward: Vec3 = [hd[0] - la[0], hd[1] - la[1], hd[2] - la[2]];
814
+ addHand(skin, { wrist: hd, forward, joint: J(`${side}Hand`), g, h });
815
+ }
816
+
817
+ // ── Legs: tapered thigh + shin + foot + toes, per side (boots are dark). Thigh is
818
+ // thick at the hip and tapers to the knee; shin tapers to a slim ankle. Weights blend
819
+ // into the next joint so knees and ankles bend.
820
+ for (const side of ["left", "right"] as const) {
821
+ const ul = w(`${side}UpperLeg`);
822
+ const ll = w(`${side}LowerLeg`);
823
+ const ft = w(`${side}Foot`);
824
+ const to = w(`${side}Toes`);
825
+ // Hip/thigh root sphere so the leg joins the pelvis smoothly.
826
+ addEllipsoid(body, { center: [ul[0], ul[1] + 0.02 * h, ul[2]], radii: [0.092 * g, 0.092 * h, 0.092 * g], joint: J(`${side}UpperLeg`), rings: 16, sectors: 20 });
827
+ addLimbSegment(body, ul, ll, { rFrom: 0.088 * g, rTo: 0.058 * g, joint: J(`${side}UpperLeg`), childJoint: J(`${side}LowerLeg`) });
828
+ // Knee sphere blended across thigh/shin.
829
+ addEllipsoid(body, { center: ll, radii: [0.062 * g, 0.062 * h, 0.062 * g], joint: J(`${side}LowerLeg`), rings: 14, sectors: 16 });
830
+ addLimbSegment(body, ll, ft, { rFrom: 0.058 * g, rTo: 0.042 * g, joint: J(`${side}LowerLeg`), childJoint: J(`${side}Foot`) });
831
+ // Foot + toe block (dark boot).
832
+ addEllipsoid(dark, { center: [ft[0], ft[1] + 0.02 * h, ft[2] + 0.03 * g], radii: [0.06 * g, 0.05 * h, 0.10 * g], joint: J(`${side}Foot`), rings: 16, sectors: 18 });
833
+ addEllipsoid(dark, { center: [to[0], to[1] + 0.02 * h, to[2] + 0.02 * g], radii: [0.055 * g, 0.04 * h, 0.06 * g], joint: J(`${side}Toes`), rings: 12, sectors: 14 });
834
+ }
835
+
836
+ return parts;
837
+ }
838
+
839
+ // ---------------------------------------------------------------------------
840
+ // Animation clips. Each clip is a set of channels; a channel = (node, path,
841
+ // times[], values[]). We sample rotations (quaternion) and translations.
842
+ // ---------------------------------------------------------------------------
843
+ interface Channel {
844
+ readonly jointName: string;
845
+ readonly path: "rotation" | "translation";
846
+ readonly times: number[];
847
+ /** Flat values: 4 per keyframe for rotation, 3 for translation. */
848
+ readonly values: number[];
849
+ }
850
+
851
+ interface Clip {
852
+ readonly name: string;
853
+ readonly channels: Channel[];
854
+ }
855
+
856
+ function rotChannel(jointName: string, times: number[], quats: Quat[]): Channel {
857
+ return { jointName, path: "rotation", times, values: quats.flat() };
858
+ }
859
+
860
+ function transChannel(jointName: string, times: number[], translations: Vec3[]): Channel {
861
+ return { jointName, path: "translation", times, values: translations.flat() };
862
+ }
863
+
864
+ /**
865
+ * Bone-name map so the clip authors target the ACTIVE skeleton. The mascot and the
866
+ * humanoid use different joint names; clips below reference these abstract slots and
867
+ * are emitted only for joints that exist on the active skeleton.
868
+ */
869
+ interface ClipBones {
870
+ readonly spine: string;
871
+ readonly head: string;
872
+ readonly armL: string; // the joint a "wave"/"arm swing" rotates (upper arm)
873
+ readonly armR: string;
874
+ readonly legL: string; // the joint a "step" rotates (upper leg)
875
+ readonly legR: string;
876
+ readonly root: string; // the joint a body bob translates
877
+ readonly forearmL?: string;
878
+ readonly forearmR?: string;
879
+ }
880
+
881
+ function activeClipBones(): ClipBones {
882
+ return LOW_FI
883
+ ? { spine: "spine", head: "head", armL: "armL", armR: "armR", legL: "legL", legR: "legR", root: "root" }
884
+ : { spine: "spine", head: "head", armL: "leftUpperArm", armR: "rightUpperArm", legL: "leftUpperLeg", legR: "rightUpperLeg", root: "hips", forearmL: "leftLowerArm", forearmR: "rightLowerArm" };
885
+ }
886
+
887
+ function buildClips(): Clip[] {
888
+ const B = activeClipBones();
889
+ // Idle: gentle spine sway + head bob (subtle).
890
+ const idle: Clip = {
891
+ name: "Idle",
892
+ channels: [
893
+ rotChannel(B.spine, [0, 1, 2], [
894
+ quatFromAxisAngle([0, 0, 1], 0.04),
895
+ quatFromAxisAngle([0, 0, 1], -0.04),
896
+ quatFromAxisAngle([0, 0, 1], 0.04)
897
+ ]),
898
+ rotChannel(B.head, [0, 1, 2], [
899
+ quatFromAxisAngle([1, 0, 0], -0.03),
900
+ quatFromAxisAngle([1, 0, 0], 0.05),
901
+ quatFromAxisAngle([1, 0, 0], -0.03)
902
+ ])
903
+ ]
904
+ };
905
+
906
+ // Wave: right arm raises and waves; spine leans slightly. On the humanoid the
907
+ // forearm flexes too (real elbow motion), which the mascot can't do.
908
+ const waveChannels: Channel[] = [
909
+ rotChannel(B.armR, [0, 0.5, 1, 1.5, 2], [
910
+ quatFromAxisAngle([0, 0, 1], 0),
911
+ quatFromAxisAngle([0, 0, 1], -2.2),
912
+ quatFromAxisAngle([0, 0, 1], -2.5),
913
+ quatFromAxisAngle([0, 0, 1], -2.2),
914
+ quatFromAxisAngle([0, 0, 1], -2.5)
915
+ ]),
916
+ rotChannel(B.spine, [0, 1, 2], [
917
+ quatFromAxisAngle([0, 0, 1], 0.08),
918
+ quatFromAxisAngle([0, 0, 1], 0.12),
919
+ quatFromAxisAngle([0, 0, 1], 0.08)
920
+ ])
921
+ ];
922
+ if (B.forearmR) {
923
+ waveChannels.push(rotChannel(B.forearmR, [0, 0.5, 1, 1.5, 2], [
924
+ quatFromAxisAngle([0, 1, 0], 0),
925
+ quatFromAxisAngle([0, 1, 0], 0.6),
926
+ quatFromAxisAngle([0, 1, 0], -0.4),
927
+ quatFromAxisAngle([0, 1, 0], 0.6),
928
+ quatFromAxisAngle([0, 1, 0], -0.4)
929
+ ]));
930
+ }
931
+ const wave: Clip = { name: "Wave", channels: waveChannels };
932
+
933
+ // Walk: alternating legs + arms swing, slight body bob in translation.
934
+ const walk: Clip = {
935
+ name: "Walk",
936
+ channels: [
937
+ rotChannel(B.legL, [0, 0.5, 1], [
938
+ quatFromAxisAngle([1, 0, 0], 0.6),
939
+ quatFromAxisAngle([1, 0, 0], -0.6),
940
+ quatFromAxisAngle([1, 0, 0], 0.6)
941
+ ]),
942
+ rotChannel(B.legR, [0, 0.5, 1], [
943
+ quatFromAxisAngle([1, 0, 0], -0.6),
944
+ quatFromAxisAngle([1, 0, 0], 0.6),
945
+ quatFromAxisAngle([1, 0, 0], -0.6)
946
+ ]),
947
+ rotChannel(B.armL, [0, 0.5, 1], [
948
+ quatFromAxisAngle([1, 0, 0], -0.5),
949
+ quatFromAxisAngle([1, 0, 0], 0.5),
950
+ quatFromAxisAngle([1, 0, 0], -0.5)
951
+ ]),
952
+ rotChannel(B.armR, [0, 0.5, 1], [
953
+ quatFromAxisAngle([1, 0, 0], 0.5),
954
+ quatFromAxisAngle([1, 0, 0], -0.5),
955
+ quatFromAxisAngle([1, 0, 0], 0.5)
956
+ ]),
957
+ transChannel(B.root, [0, 0.25, 0.5, 0.75, 1], [
958
+ [0, 0, 0],
959
+ [0, 0.05, 0],
960
+ [0, 0, 0],
961
+ [0, 0.05, 0],
962
+ [0, 0, 0]
963
+ ])
964
+ ]
965
+ };
966
+
967
+ // Talk: conversational body life for dialogue scenes — head nods, spine breathes,
968
+ // both forearms make small beat gestures (humanoid). The mascot gets the head/spine
969
+ // version (no forearms), which is still a usable "talking" idle.
970
+ const talkChannels: Channel[] = [
971
+ rotChannel(B.head, [0, 0.6, 1.2, 1.8, 2.4], [
972
+ quatFromAxisAngle([1, 0, 0], 0.06),
973
+ quatFromAxisAngle([1, 0, 0], -0.05),
974
+ quatFromAxisAngle([1, 0, 0], 0.04),
975
+ quatFromAxisAngle([1, 0, 0], -0.06),
976
+ quatFromAxisAngle([1, 0, 0], 0.06)
977
+ ]),
978
+ rotChannel(B.spine, [0, 1.2, 2.4], [
979
+ quatFromAxisAngle([1, 0, 0], 0.02),
980
+ quatFromAxisAngle([1, 0, 0], -0.03),
981
+ quatFromAxisAngle([1, 0, 0], 0.02)
982
+ ])
983
+ ];
984
+ if (B.forearmL && B.forearmR) {
985
+ talkChannels.push(rotChannel(B.forearmL, [0, 0.8, 1.6, 2.4], [
986
+ quatFromAxisAngle([1, 0, 0], -0.2),
987
+ quatFromAxisAngle([1, 0, 0], -0.5),
988
+ quatFromAxisAngle([1, 0, 0], -0.2),
989
+ quatFromAxisAngle([1, 0, 0], -0.5)
990
+ ]));
991
+ talkChannels.push(rotChannel(B.forearmR, [0, 0.8, 1.6, 2.4], [
992
+ quatFromAxisAngle([1, 0, 0], -0.5),
993
+ quatFromAxisAngle([1, 0, 0], -0.2),
994
+ quatFromAxisAngle([1, 0, 0], -0.5),
995
+ quatFromAxisAngle([1, 0, 0], -0.2)
996
+ ]));
997
+ }
998
+ const talk: Clip = { name: "Talk", channels: talkChannels };
999
+
1000
+ return [idle, wave, walk, talk];
1001
+ }
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // GLB encoder. Builds JSON + BIN chunk from accessors/bufferViews.
1005
+ // ---------------------------------------------------------------------------
1006
+ const COMPONENT_FLOAT = 5126;
1007
+ const COMPONENT_USHORT = 5123;
1008
+ const TARGET_ARRAY_BUFFER = 34962;
1009
+ const TARGET_ELEMENT_ARRAY_BUFFER = 34963;
1010
+
1011
+ interface PendingAccessor {
1012
+ readonly bytes: Uint8Array;
1013
+ readonly componentType: number;
1014
+ readonly count: number;
1015
+ readonly type: string;
1016
+ readonly target?: number;
1017
+ readonly min?: number[];
1018
+ readonly max?: number[];
1019
+ }
1020
+
1021
+ interface GLTFBuild {
1022
+ json: Record<string, unknown>;
1023
+ accessors: PendingAccessor[];
1024
+ /** Raw image blobs (e.g. PNG) appended as extra bufferViews after the accessors. The
1025
+ * json's images[].bufferView indices point past accessors.length into these (resolved
1026
+ * in encodeGLB, which knows the final bufferView count). */
1027
+ imageBlobs: Uint8Array[];
1028
+ }
1029
+
1030
+ function f32(values: number[]): Uint8Array {
1031
+ const arr = new Float32Array(values);
1032
+ return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
1033
+ }
1034
+
1035
+ function u16(values: number[]): Uint8Array {
1036
+ const arr = new Uint16Array(values);
1037
+ return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
1038
+ }
1039
+
1040
+ function minMax(values: number[], components: number): { min: number[]; max: number[] } {
1041
+ const min = new Array<number>(components).fill(Infinity);
1042
+ const max = new Array<number>(components).fill(-Infinity);
1043
+ for (let i = 0; i < values.length; i += components) {
1044
+ for (let c = 0; c < components; c += 1) {
1045
+ const v = values[i + c]!;
1046
+ if (v < min[c]!) min[c] = v;
1047
+ if (v > max[c]!) max[c] = v;
1048
+ }
1049
+ }
1050
+ return { min, max };
1051
+ }
1052
+
1053
+ function buildGLTF(design: CharacterDesign, partsByMaterial: Map<number, MeshAccumulator>, clips: Clip[]): GLTFBuild {
1054
+ const accessors: PendingAccessor[] = [];
1055
+ const pushAccessor = (a: PendingAccessor): number => {
1056
+ accessors.push(a);
1057
+ return accessors.length - 1;
1058
+ };
1059
+
1060
+ // --- One primitive per (non-empty) material group. Every primitive carries the
1061
+ // mouthOpen morph target (zeros except the mouth lobe) so all primitives in the
1062
+ // mesh declare the same single target, satisfying the glTF morph constraint. ---
1063
+ const primitives: Record<string, unknown>[] = [];
1064
+ for (const [materialIndex, mesh] of partsByMaterial) {
1065
+ if (mesh.positions.length === 0) continue;
1066
+ const posMM = minMax(mesh.positions, 3);
1067
+ const positionAccessor = pushAccessor({
1068
+ bytes: f32(mesh.positions),
1069
+ componentType: COMPONENT_FLOAT,
1070
+ count: mesh.positions.length / 3,
1071
+ type: "VEC3",
1072
+ target: TARGET_ARRAY_BUFFER,
1073
+ min: posMM.min,
1074
+ max: posMM.max
1075
+ });
1076
+ const normalAccessor = pushAccessor({
1077
+ bytes: f32(mesh.normals),
1078
+ componentType: COMPONENT_FLOAT,
1079
+ count: mesh.normals.length / 3,
1080
+ type: "VEC3",
1081
+ target: TARGET_ARRAY_BUFFER
1082
+ });
1083
+ const jointsAccessor = pushAccessor({
1084
+ bytes: u16(mesh.joints),
1085
+ componentType: COMPONENT_USHORT,
1086
+ count: mesh.joints.length / 4,
1087
+ type: "VEC4",
1088
+ target: TARGET_ARRAY_BUFFER
1089
+ });
1090
+ const weightsAccessor = pushAccessor({
1091
+ bytes: f32(mesh.weights),
1092
+ componentType: COMPONENT_FLOAT,
1093
+ count: mesh.weights.length / 4,
1094
+ type: "VEC4",
1095
+ target: TARGET_ARRAY_BUFFER
1096
+ });
1097
+ const uvMM = minMax(mesh.uvs, 2);
1098
+ const uvAccessor = pushAccessor({
1099
+ bytes: f32(mesh.uvs),
1100
+ componentType: COMPONENT_FLOAT,
1101
+ count: mesh.uvs.length / 2,
1102
+ type: "VEC2",
1103
+ target: TARGET_ARRAY_BUFFER,
1104
+ min: uvMM.min,
1105
+ max: uvMM.max
1106
+ });
1107
+ const indexAccessor = pushAccessor({
1108
+ bytes: u16(mesh.indices),
1109
+ componentType: COMPONENT_USHORT,
1110
+ count: mesh.indices.length,
1111
+ type: "SCALAR",
1112
+ target: TARGET_ELEMENT_ARRAY_BUFFER
1113
+ });
1114
+ const morphMM = minMax(mesh.morphDeltas, 3);
1115
+ const morphAccessor = pushAccessor({
1116
+ bytes: f32(mesh.morphDeltas),
1117
+ componentType: COMPONENT_FLOAT,
1118
+ count: mesh.morphDeltas.length / 3,
1119
+ type: "VEC3",
1120
+ target: TARGET_ARRAY_BUFFER,
1121
+ min: morphMM.min,
1122
+ max: morphMM.max
1123
+ });
1124
+ primitives.push({
1125
+ attributes: {
1126
+ POSITION: positionAccessor,
1127
+ NORMAL: normalAccessor,
1128
+ TEXCOORD_0: uvAccessor,
1129
+ JOINTS_0: jointsAccessor,
1130
+ WEIGHTS_0: weightsAccessor
1131
+ },
1132
+ indices: indexAccessor,
1133
+ material: materialIndex,
1134
+ targets: [{ POSITION: morphAccessor }]
1135
+ });
1136
+ }
1137
+
1138
+ // --- Inverse bind matrices accessor (shared skin) ---
1139
+ const ibmValues: number[] = [];
1140
+ for (let index = 0; index < JOINTS.length; index += 1) {
1141
+ const world = translation(...jointWorldBind(index));
1142
+ const inverse = invertTranslation(world);
1143
+ ibmValues.push(...inverse);
1144
+ }
1145
+ const ibmAccessor = pushAccessor({
1146
+ bytes: f32(ibmValues),
1147
+ componentType: COMPONENT_FLOAT,
1148
+ count: JOINTS.length,
1149
+ type: "MAT4"
1150
+ });
1151
+
1152
+ // --- Animation accessors ---
1153
+ const gltfAnimations: Record<string, unknown>[] = [];
1154
+ for (const clip of clips) {
1155
+ const channels: Record<string, unknown>[] = [];
1156
+ const samplers: Record<string, unknown>[] = [];
1157
+ for (const channel of clip.channels) {
1158
+ const timeMM = minMax(channel.times, 1);
1159
+ const inputAccessor = pushAccessor({
1160
+ bytes: f32(channel.times),
1161
+ componentType: COMPONENT_FLOAT,
1162
+ count: channel.times.length,
1163
+ type: "SCALAR",
1164
+ min: timeMM.min,
1165
+ max: timeMM.max
1166
+ });
1167
+ const components = channel.path === "rotation" ? 4 : 3;
1168
+ const outputAccessor = pushAccessor({
1169
+ bytes: f32(channel.values),
1170
+ componentType: COMPONENT_FLOAT,
1171
+ count: channel.values.length / components,
1172
+ type: channel.path === "rotation" ? "VEC4" : "VEC3"
1173
+ });
1174
+ const samplerIndex = samplers.length;
1175
+ samplers.push({ input: inputAccessor, output: outputAccessor, interpolation: "LINEAR" });
1176
+ channels.push({
1177
+ sampler: samplerIndex,
1178
+ target: { node: NODE_INDEX_FOR_JOINT[channel.jointName], path: channel.path }
1179
+ });
1180
+ }
1181
+ gltfAnimations.push({ name: clip.name, samplers, channels });
1182
+ }
1183
+
1184
+ // --- Nodes ---
1185
+ // Node layout: 0 = mesh node (skinned), then one node per joint.
1186
+ const MESH_NODE = 0;
1187
+ const jointNodeIndices = JOINTS.map((_, index) => index + 1);
1188
+
1189
+ const nodes: Record<string, unknown>[] = [];
1190
+ nodes.push({ name: `${design.id}_mesh`, mesh: 0, skin: 0 });
1191
+ for (let index = 0; index < JOINTS.length; index += 1) {
1192
+ const joint = JOINTS[index]!;
1193
+ const node: Record<string, unknown> = {
1194
+ name: joint.name,
1195
+ translation: joint.bind
1196
+ };
1197
+ const children = JOINTS
1198
+ .map((other, otherIndex) => (other.parent === index ? jointNodeIndices[otherIndex]! : -1))
1199
+ .filter((value) => value >= 0);
1200
+ if (children.length > 0) node.children = children;
1201
+ nodes.push(node);
1202
+ }
1203
+
1204
+ const rootJointNode = jointNodeIndices[0]!;
1205
+ const sceneNodes = [MESH_NODE, rootJointNode];
1206
+
1207
+ const bc = design.bodyColor;
1208
+ const ac = design.accentColor;
1209
+ const sc = design.skinColor;
1210
+ const tc = design.trimColor;
1211
+
1212
+ // --- Bake the procedural base-colour atlas PNG and embed it as a glTF image. The image's
1213
+ // bufferView is appended AFTER all accessor bufferViews, so its index is accessors.length
1214
+ // (no more accessors are pushed below). Every material now carries a baseColorTexture
1215
+ // sampling its atlas region; baseColorFactor drops to white so the texture shows fully
1216
+ // (the prior per-material colour is baked INTO the atlas region instead). ---
1217
+ const darkColor = [0.04, 0.05, 0.09, 1] as [number, number, number, number];
1218
+ const bakeColors: BakeColors = {
1219
+ bodyColor: bc,
1220
+ accentColor: ac,
1221
+ skinColor: sc,
1222
+ trimColor: tc,
1223
+ darkColor
1224
+ };
1225
+ const { png: atlasPNG, size: atlasSize } = bakeAtlasPNG(bakeColors);
1226
+ const imageBufferViewIndex = accessors.length; // first image blob's bufferView
1227
+ const TEX_INDEX = 0; // single shared atlas texture
1228
+ const TEXCOORD = 0;
1229
+ const baseColorTexture = { index: TEX_INDEX, texCoord: TEXCOORD };
1230
+ const white = [1, 1, 1, 1] as [number, number, number, number];
1231
+
1232
+ const json: Record<string, unknown> = {
1233
+ asset: { version: "2.0", generator: `Aura3D animation-studio build-characters.ts (CC0 procedural ${LOW_FI ? "mascot" : "humanoid"})` },
1234
+ scene: 0,
1235
+ scenes: [{ name: `${design.name} scene`, nodes: sceneNodes }],
1236
+ nodes,
1237
+ meshes: [
1238
+ {
1239
+ name: `${design.id}_body`,
1240
+ primitives,
1241
+ weights: [0],
1242
+ extras: { targetNames: ["mouthOpen"] }
1243
+ }
1244
+ ],
1245
+ skins: [
1246
+ {
1247
+ name: `${design.id}_skin`,
1248
+ inverseBindMatrices: ibmAccessor,
1249
+ joints: jointNodeIndices,
1250
+ skeleton: rootJointNode
1251
+ }
1252
+ ],
1253
+ animations: gltfAnimations,
1254
+ images: [
1255
+ { name: `${design.id}_atlas`, mimeType: "image/png", bufferView: imageBufferViewIndex }
1256
+ ],
1257
+ samplers: [
1258
+ { magFilter: 9729, minFilter: 9987, wrapS: 33071, wrapT: 33071 } // linear, mip-linear, clamp
1259
+ ],
1260
+ textures: [{ name: `${design.id}_atlas_tex`, source: 0, sampler: 0 }],
1261
+ materials: [
1262
+ {
1263
+ // MAT_BODY — clothing fabric region of the atlas.
1264
+ name: `${design.id}_body_mat`,
1265
+ pbrMetallicRoughness: { baseColorFactor: white, baseColorTexture, metallicFactor: 0.0, roughnessFactor: 0.5 },
1266
+ emissiveFactor: [bc[0] * 0.06, bc[1] * 0.06, bc[2] * 0.06]
1267
+ },
1268
+ {
1269
+ // MAT_DARK (pupils + mouth + boots) — dark region of the atlas.
1270
+ name: `${design.id}_dark_mat`,
1271
+ pbrMetallicRoughness: { baseColorFactor: white, baseColorTexture, metallicFactor: 0.0, roughnessFactor: 0.7 },
1272
+ emissiveFactor: [0, 0, 0]
1273
+ },
1274
+ {
1275
+ // MAT_GLOW (eyes + accessory tip) — glow region of the atlas.
1276
+ name: `${design.id}_glow_mat`,
1277
+ pbrMetallicRoughness: { baseColorFactor: white, baseColorTexture, metallicFactor: 0.0, roughnessFactor: 0.35 },
1278
+ emissiveFactor: [ac[0] * 0.85, ac[1] * 0.85, ac[2] * 0.85]
1279
+ },
1280
+ {
1281
+ // MAT_SKIN (head + hands) — skin region of the atlas; humanoid only.
1282
+ name: `${design.id}_skin_mat`,
1283
+ pbrMetallicRoughness: { baseColorFactor: white, baseColorTexture, metallicFactor: 0.0, roughnessFactor: 0.6 },
1284
+ emissiveFactor: [sc[0] * 0.04, sc[1] * 0.04, sc[2] * 0.04]
1285
+ },
1286
+ {
1287
+ // MAT_ACCENT (belt / collar / hair / trim) — accent region; humanoid only.
1288
+ name: `${design.id}_accent_mat`,
1289
+ pbrMetallicRoughness: { baseColorFactor: white, baseColorTexture, metallicFactor: 0.1, roughnessFactor: 0.45 },
1290
+ emissiveFactor: [tc[0] * 0.05, tc[1] * 0.05, tc[2] * 0.05]
1291
+ }
1292
+ ]
1293
+ };
1294
+
1295
+ void atlasSize;
1296
+ return { json, accessors, imageBlobs: [atlasPNG] };
1297
+ }
1298
+
1299
+ // Map joint name -> NODE index (joints are nodes 1..N; mesh is node 0).
1300
+ const NODE_INDEX_FOR_JOINT: Record<string, number> = Object.fromEntries(
1301
+ JOINTS.map((joint, index) => [joint.name, index + 1])
1302
+ );
1303
+
1304
+ // ---------------------------------------------------------------------------
1305
+ // Pack accessors into bufferViews + a single binary buffer, write GLB.
1306
+ // ---------------------------------------------------------------------------
1307
+ function align4(n: number): number {
1308
+ return (n + 3) & ~3;
1309
+ }
1310
+
1311
+ function encodeGLB(build: GLTFBuild): Uint8Array {
1312
+ const { json, accessors, imageBlobs } = build;
1313
+ const bufferViews: Record<string, unknown>[] = [];
1314
+ const chunks: Uint8Array[] = [];
1315
+ let byteOffset = 0;
1316
+
1317
+ const jsonAccessors = accessors.map((accessor, index) => {
1318
+ const padded = align4(accessor.bytes.byteLength);
1319
+ const view: Record<string, unknown> = {
1320
+ buffer: 0,
1321
+ byteOffset,
1322
+ byteLength: accessor.bytes.byteLength
1323
+ };
1324
+ if (accessor.target !== undefined) view.target = accessor.target;
1325
+ bufferViews.push(view);
1326
+
1327
+ const padding = padded - accessor.bytes.byteLength;
1328
+ chunks.push(accessor.bytes);
1329
+ if (padding > 0) chunks.push(new Uint8Array(padding));
1330
+ byteOffset += padded;
1331
+
1332
+ const out: Record<string, unknown> = {
1333
+ bufferView: index,
1334
+ componentType: accessor.componentType,
1335
+ count: accessor.count,
1336
+ type: accessor.type
1337
+ };
1338
+ if (accessor.min) out.min = accessor.min;
1339
+ if (accessor.max) out.max = accessor.max;
1340
+ return out;
1341
+ });
1342
+
1343
+ // Image blobs (PNG atlas) get their own bufferViews appended after the accessor views,
1344
+ // matching the indices buildGLTF assigned to images[].bufferView (accessors.length + i).
1345
+ // These carry no `target` (they're not vertex/index data).
1346
+ for (const blob of imageBlobs ?? []) {
1347
+ const padded = align4(blob.byteLength);
1348
+ bufferViews.push({ buffer: 0, byteOffset, byteLength: blob.byteLength });
1349
+ chunks.push(blob);
1350
+ const padding = padded - blob.byteLength;
1351
+ if (padding > 0) chunks.push(new Uint8Array(padding));
1352
+ byteOffset += padded;
1353
+ }
1354
+
1355
+ const binLength = byteOffset;
1356
+ json.bufferViews = bufferViews;
1357
+ json.accessors = jsonAccessors;
1358
+ json.buffers = [{ byteLength: binLength }];
1359
+
1360
+ // Assemble BIN chunk.
1361
+ const bin = new Uint8Array(binLength);
1362
+ let cursor = 0;
1363
+ for (const chunk of chunks) {
1364
+ bin.set(chunk, cursor);
1365
+ cursor += chunk.byteLength;
1366
+ }
1367
+
1368
+ // JSON chunk (4-byte aligned, padded with spaces).
1369
+ const jsonText = JSON.stringify(json);
1370
+ const jsonBytes = new TextEncoder().encode(jsonText);
1371
+ const jsonPadded = align4(jsonBytes.byteLength);
1372
+ const jsonChunk = new Uint8Array(jsonPadded).fill(0x20); // spaces
1373
+ jsonChunk.set(jsonBytes, 0);
1374
+
1375
+ const binPadded = align4(bin.byteLength);
1376
+ const binChunk = new Uint8Array(binPadded); // zero pad
1377
+ binChunk.set(bin, 0);
1378
+
1379
+ const totalLength = 12 + 8 + jsonChunk.byteLength + 8 + binChunk.byteLength;
1380
+ const glb = new Uint8Array(totalLength);
1381
+ const dv = new DataView(glb.buffer);
1382
+ let o = 0;
1383
+ dv.setUint32(o, 0x46546c67, true); // "glTF"
1384
+ dv.setUint32(o + 4, 2, true); // version
1385
+ dv.setUint32(o + 8, totalLength, true);
1386
+ o += 12;
1387
+ // JSON chunk
1388
+ dv.setUint32(o, jsonChunk.byteLength, true);
1389
+ dv.setUint32(o + 4, 0x4e4f534a, true); // "JSON"
1390
+ glb.set(jsonChunk, o + 8);
1391
+ o += 8 + jsonChunk.byteLength;
1392
+ // BIN chunk
1393
+ dv.setUint32(o, binChunk.byteLength, true);
1394
+ dv.setUint32(o + 4, 0x004e4942, true); // "BIN\0"
1395
+ glb.set(binChunk, o + 8);
1396
+
1397
+ return glb;
1398
+ }
1399
+
1400
+ // ---------------------------------------------------------------------------
1401
+ // Character designs.
1402
+ // ---------------------------------------------------------------------------
1403
+ const DESIGNS: readonly { design: CharacterDesign; file: string }[] = [
1404
+ {
1405
+ // Written to the catalog name the manifest/typed-assets reference so this is
1406
+ // the LIVE cast, not an unreferenced *.authored.glb side-product. This
1407
+ // overwrites the previously-polluted miko.catalog.glb.
1408
+ file: "miko.catalog.glb",
1409
+ design: {
1410
+ id: "miko",
1411
+ name: "Miko",
1412
+ // Cyan-suited humanoid with cyan hair-cap + glowing crest (antenna slot).
1413
+ bodyColor: [0.22, 0.55, 0.72, 1],
1414
+ accentColor: [0.6, 0.96, 1, 1],
1415
+ skinColor: [0.86, 0.66, 0.52, 1],
1416
+ trimColor: [0.3, 0.78, 0.88, 1],
1417
+ heightScale: 1.0,
1418
+ girth: 1.06,
1419
+ accessory: "antenna"
1420
+ }
1421
+ },
1422
+ {
1423
+ // Written to the catalog name the manifest/typed-assets reference (replaces
1424
+ // the prior luma2.catalog.glb astronaut).
1425
+ file: "luma2.catalog.glb",
1426
+ design: {
1427
+ id: "luma",
1428
+ name: "Luma",
1429
+ // Warm-gold-suited humanoid, slimmer build, rounded hair (ears slot).
1430
+ bodyColor: [0.78, 0.56, 0.26, 1],
1431
+ accentColor: [1, 0.9, 0.55, 1],
1432
+ skinColor: [0.9, 0.72, 0.58, 1],
1433
+ trimColor: [0.95, 0.82, 0.45, 1],
1434
+ heightScale: 1.08,
1435
+ girth: 0.9,
1436
+ accessory: "ears"
1437
+ }
1438
+ }
1439
+ ];
1440
+
1441
+ function meshBounds(parts: Map<number, MeshAccumulator>): { extents: Vec3; min: Vec3; max: Vec3 } {
1442
+ const min: Vec3 = [Infinity, Infinity, Infinity];
1443
+ const max: Vec3 = [-Infinity, -Infinity, -Infinity];
1444
+ for (const mesh of parts.values()) {
1445
+ for (let i = 0; i < mesh.positions.length; i += 3) {
1446
+ for (let c = 0; c < 3; c += 1) {
1447
+ const v = mesh.positions[i + c]!;
1448
+ if (v < min[c]!) min[c] = v;
1449
+ if (v > max[c]!) max[c] = v;
1450
+ }
1451
+ }
1452
+ }
1453
+ return { extents: [max[0]! - min[0]!, max[1]! - min[1]!, max[2]! - min[2]!], min, max };
1454
+ }
1455
+
1456
+ function main(): void {
1457
+ mkdirSync(OUTPUT_DIR, { recursive: true });
1458
+ console.log(
1459
+ LOW_FI
1460
+ ? `[build-characters] LOW-FI mode (--low-fi): legacy ${MASCOT_JOINTS.length}-node mascots.`
1461
+ : `[build-characters] DEFAULT mode: high-fidelity ${HUMANOID_JOINTS.length}-joint humanoid cast (full limb chains, graded-A rig).`
1462
+ );
1463
+ const clips = buildClips();
1464
+ const emitted = new Map<string, string>(); // file -> sha256 hex
1465
+ for (const { design, file } of DESIGNS) {
1466
+ const parts = buildBody(design);
1467
+ const build = buildGLTF(design, parts, clips);
1468
+ const glb = encodeGLB(build);
1469
+ const outPath = resolve(OUTPUT_DIR, file);
1470
+ writeFileSync(outPath, glb);
1471
+ const vertexCount = Array.from(parts.values()).reduce((sum, m) => sum + m.positions.length / 3, 0);
1472
+ const triCount = Array.from(parts.values()).reduce((sum, m) => sum + m.indices.length / 3, 0);
1473
+ const bounds = meshBounds(parts);
1474
+ const hash = createHash("sha256").update(glb).digest("hex");
1475
+ emitted.set(file, hash);
1476
+ console.log(
1477
+ `${file}: ${glb.byteLength} bytes | ${vertexCount} verts / ${triCount} tris | ` +
1478
+ `joints=${JOINTS.length} clips=${clips.length} (${clips.map((c) => c.name).join(",")}) morph=mouthOpen\n` +
1479
+ ` bounds=[${bounds.extents.map((v) => v.toFixed(3)).join(", ")}] sha256-${hash}`
1480
+ );
1481
+ }
1482
+ // The generator binds prompt casts to NEUTRAL copies (cast-a/cast-b) of the two leads
1483
+ // (see src/director/prompt-to-scene.ts). Re-copy them byte-for-byte from the freshly
1484
+ // textured leads so the default generated cast picks up the new bitmap textures too.
1485
+ if (!LOW_FI) {
1486
+ const COPIES: readonly { from: string; to: string }[] = [
1487
+ { from: "miko.catalog.glb", to: "cast-a.catalog.glb" },
1488
+ { from: "luma2.catalog.glb", to: "cast-b.catalog.glb" }
1489
+ ];
1490
+ for (const { from, to } of COPIES) {
1491
+ const bytes = readFileSync(resolve(OUTPUT_DIR, from));
1492
+ writeFileSync(resolve(OUTPUT_DIR, to), bytes);
1493
+ const hash = createHash("sha256").update(bytes).digest("hex");
1494
+ emitted.set(to, hash);
1495
+ console.log(`${to}: copied from ${from} | ${bytes.byteLength} bytes sha256-${hash}`);
1496
+ }
1497
+ }
1498
+
1499
+ console.log(`\nWrote authored GLBs to ${OUTPUT_DIR}`);
1500
+ verifyManifestHashes(emitted);
1501
+ }
1502
+
1503
+ /**
1504
+ * Gate: fail if the asset manifest records a hash that does not match the actual
1505
+ * bytes of the file on disk. This catches the exact class of bug that polluted
1506
+ * miko.catalog.glb (a file overwritten without re-syncing its manifest hash).
1507
+ * Runs across EVERY manifest asset whose output file exists, not just the two we
1508
+ * just emitted, so a stale hash anywhere in aura.assets.json is reported.
1509
+ */
1510
+ function verifyManifestHashes(emitted: ReadonlyMap<string, string>): void {
1511
+ if (!existsSync(MANIFEST_PATH)) {
1512
+ console.warn(`\n[hash-gate] skipped: ${MANIFEST_PATH} not found`);
1513
+ return;
1514
+ }
1515
+ const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as {
1516
+ assets?: { id?: string; outputPath?: string; hash?: string }[];
1517
+ };
1518
+ const mismatches: string[] = [];
1519
+ for (const asset of manifest.assets ?? []) {
1520
+ if (!asset.outputPath || !asset.hash) continue;
1521
+ const filePath = resolve(TEMPLATE_ROOT, asset.outputPath);
1522
+ if (!existsSync(filePath)) {
1523
+ mismatches.push(`${asset.id ?? asset.outputPath}: file missing (${asset.outputPath})`);
1524
+ continue;
1525
+ }
1526
+ const actual = `sha256-${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`;
1527
+ if (actual !== asset.hash) {
1528
+ mismatches.push(`${asset.id ?? asset.outputPath}: manifest ${asset.hash} != file ${actual}`);
1529
+ }
1530
+ }
1531
+ if (mismatches.length > 0) {
1532
+ console.error(
1533
+ `\n[hash-gate] FAILED — manifest hashes are out of sync with the files:\n ${mismatches.join("\n ")}\n` +
1534
+ ` Regenerate aura.assets.json + src/aura-assets.ts from the actual GLB bytes ` +
1535
+ `(the freshly emitted hashes are: ${[...emitted].map(([f, h]) => `${f}=sha256-${h}`).join(", ")}).`
1536
+ );
1537
+ process.exitCode = 1;
1538
+ return;
1539
+ }
1540
+ console.log("[hash-gate] OK — every manifest hash matches its file on disk.");
1541
+ }
1542
+
1543
+ main();