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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -9
- package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
- package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
- package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
- package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
- package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
- package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
- package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
- package/templates/animation-studio/AGENTS.md +71 -0
- package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
- package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
- package/templates/animation-studio/aura.assets.json +669 -0
- package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
- package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
- package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
- package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
- package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
- package/templates/animation-studio/dist/scene/integ.document.json +1 -0
- package/templates/animation-studio/dist/scene/working.document.json +723 -0
- package/templates/animation-studio/dist/scene/working.history.json +1 -0
- package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
- package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
- package/templates/animation-studio/package.json +32 -0
- package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
- package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
- package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
- package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
- package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
- package/templates/animation-studio/public/clip-library/idle.json +23035 -0
- package/templates/animation-studio/public/clip-library/manifest.json +154 -0
- package/templates/animation-studio/public/clip-library/nod.json +173903 -0
- package/templates/animation-studio/public/clip-library/point.json +69879 -0
- package/templates/animation-studio/public/clip-library/react.json +66084 -0
- package/templates/animation-studio/public/clip-library/run.json +9648 -0
- package/templates/animation-studio/public/clip-library/talk.json +69879 -0
- package/templates/animation-studio/public/clip-library/walk.json +8283 -0
- package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
- package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
- package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
- package/templates/animation-studio/scripts/anim.ts +103 -0
- package/templates/animation-studio/scripts/animation-scene.ts +457 -0
- package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
- package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
- package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
- package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
- package/templates/animation-studio/scripts/build-characters.ts +1543 -0
- package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
- package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
- package/templates/animation-studio/scripts/determinism-check.ts +48 -0
- package/templates/animation-studio/scripts/generate-scene.ts +95 -0
- package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
- package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
- package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
- package/templates/animation-studio/scripts/preview-server.ts +52 -0
- package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
- package/templates/animation-studio/scripts/render-core.ts +360 -0
- package/templates/animation-studio/scripts/render-live.ts +813 -0
- package/templates/animation-studio/scripts/render-parallel.ts +70 -0
- package/templates/animation-studio/scripts/render-server.ts +119 -0
- package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
- package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
- package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
- package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
- package/templates/animation-studio/scripts/texture-bake.ts +297 -0
- package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
- package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
- package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
- package/templates/animation-studio/src/animation-performance.ts +556 -0
- package/templates/animation-studio/src/aura-assets.ts +71 -0
- package/templates/animation-studio/src/character.ts +40 -0
- package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
- package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
- package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
- package/templates/animation-studio/src/empty-document.ts +77 -0
- package/templates/animation-studio/src/episode-document.ts +442 -0
- package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
- package/templates/animation-studio/src/fidelity.ts +129 -0
- package/templates/animation-studio/src/graph.ts +14 -0
- package/templates/animation-studio/src/procedural-hdri.ts +243 -0
- package/templates/animation-studio/src/profile.ts +97 -0
- package/templates/animation-studio/src/render-live-route.ts +42 -0
- package/templates/animation-studio/src/render-modes.ts +137 -0
- package/templates/animation-studio/src/scene-player.ts +867 -0
- package/templates/animation-studio/src/set-templates.ts +361 -0
- package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
- package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
- package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
- package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
- package/templates/animation-studio/tsconfig.json +26 -0
- package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
- package/templates/character-controller/README.md +33 -0
- package/templates/character-controller/index.html +19 -0
- package/templates/character-controller/package.json +23 -0
- package/templates/character-controller/playwright.config.ts +14 -0
- package/templates/character-controller/src/controller.ts +44 -0
- package/templates/character-controller/src/main.ts +62 -0
- package/templates/character-controller/tests/route-health.spec.ts +26 -0
- package/templates/character-controller/tests/screenshot.spec.ts +9 -0
- package/templates/character-controller/tsconfig.json +15 -0
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/episode-builder/README.md +4 -4
- package/templates/episode-builder/package.json +1 -1
- package/templates/episode-builder/src/aura-assets.ts +1 -1
- package/templates/episode-builder/src/builder.ts +4 -4
- package/templates/episode-builder/src/episode.ts +21 -21
- package/templates/episode-builder/src/main.ts +20 -20
- package/templates/episode-builder/src/render-plan.ts +13 -13
- package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
- package/templates/fighting-game/package.json +1 -1
- package/templates/mini-game/package.json +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
- package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
- package/templates/cartoon-studio/aura.assets.json +0 -400
- package/templates/cartoon-studio/package.json +0 -33
- package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
- package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
- package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
- package/templates/cartoon-studio/scripts/episode.ts +0 -323
- package/templates/cartoon-studio/scripts/render-live.ts +0 -873
- package/templates/cartoon-studio/src/aura-assets.ts +0 -64
- package/templates/cartoon-studio/src/characters.ts +0 -65
- package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
- package/templates/cartoon-studio/src/episode.ts +0 -306
- package/templates/cartoon-studio/src/main.ts +0 -400
- package/templates/cartoon-studio/src/render-live-route.ts +0 -913
- package/templates/cartoon-studio/src/render-plan.ts +0 -567
- package/templates/cartoon-studio/src/review.ts +0 -99
- package/templates/cartoon-studio/src/studio.ts +0 -101
- package/templates/cartoon-studio/test-results/.last-run.json +0 -4
- package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
- package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
- package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
- package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
- package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
- package/templates/cartoon-studio/tsconfig.json +0 -14
- package/templates/prompt-cartoon-channel/index.html +0 -13
- package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
- /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
- /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
- /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();
|