Initial public release of PSX Adventure Engine

Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
This commit is contained in:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"name": "@psx/tools",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"harness": "tsx src/cli.ts",
"cast": "tsx src/creator/build.ts",
"creator": "tsx src/creator/build.ts",
"creator:ui": "vite --config web/vite.config.ts",
"creator:ui:build": "vite build --config web/vite.config.ts",
"test": "vitest run",
"preview": "tsx src/preview/cli.ts"
},
"dependencies": {
"@psx/shared": "workspace:*",
"jpeg-js": "^0.4.4",
"pngjs": "^7.0.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/pngjs": "^6.0.5",
"@types/three": "^0.185.1",
"three": "^0.185.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
+262
View File
@@ -0,0 +1,262 @@
import type { CanonicalBone } from "@psx/shared";
/**
* Procedural animation clips authored against the canonical skeleton.
*
* Because every channel targets a canonical bone *name* rather than a joint
* index, a clip generated for one character plays on any other character the
* harness produces (GDD §10.2). Nothing here depends on the figure's
* proportions — only on its topology.
*
* Rotation-only, and deliberately so: root translation would fight the
* runtime's `CharacterMover`, which owns the character's position. The mover
* drives where the body goes; these clips only say what the limbs do.
*
* ## Axis convention
*
* The harness skeleton uses **identity rest rotations** and local translations
* only (Y-up, Z-forward, right-handed — same as the runtime). Under that setup:
*
* - A limb hanging down the Y axis swings **backward (Z)** under **+X** pitch.
* - Forward swing (toward +Z, the facing direction) is therefore **X**.
* - Knee flex (shin folds back toward Z when the thigh is vertical) is **+X**.
* - Elbow flex (hand comes forward toward +Z when the upper arm hangs) is **X**.
* - Spine lean toward +Z (forward) is **+X** (children sit along +Y).
*/
export interface Keyframe {
time: number;
/** Euler XYZ in radians, converted to quaternions at export. */
rotation: [number, number, number];
}
export interface AnimationChannel {
bone: CanonicalBone;
keyframes: Keyframe[];
}
export interface AnimationClip {
name: string;
duration: number;
loop: boolean;
channels: AnimationChannel[];
}
/** Samples per cycle. Low on purpose — PSX animation was chunky. */
const SAMPLES = 8;
/**
* Builds a channel by sampling a function over one cycle.
* The final key repeats the first so looping playback has no seam.
*/
function cycle(
bone: CanonicalBone,
duration: number,
fn: (phase: number) => [number, number, number],
): AnimationChannel {
const keyframes: Keyframe[] = [];
for (let i = 0; i <= SAMPLES; i++) {
const phase = i / SAMPLES;
keyframes.push({ time: phase * duration, rotation: fn(phase % 1) });
}
return { bone, keyframes };
}
const wave = (phase: number, offset = 0): number => Math.sin((phase + offset) * Math.PI * 2);
/** Neutral pose — every bone at rest. Used as the bind/reset clip. */
export function buildIdlePose(): AnimationClip {
return {
name: "TPose",
duration: 0.001,
loop: false,
channels: [{ bone: "Hips", keyframes: [{ time: 0, rotation: [0, 0, 0] }] }],
};
}
/**
* Breathing idle. Survival horror characters stand tense, so the motion is
* small and slow — big idle sway reads as comedic.
*/
export function buildIdleClip(duration = 4): AnimationClip {
return {
name: "Idle",
duration,
loop: true,
channels: [
// Spine children sit along +Y: +X leans forward (+Z). Tiny breath.
cycle("Spine", duration, (p) => [wave(p) * 0.022, 0, 0]),
cycle("Spine1", duration, (p) => [wave(p, 0.08) * 0.018, 0, 0]),
cycle("Neck", duration, (p) => [wave(p, 0.15) * -0.03, wave(p, 0.3) * 0.04, 0]),
// Soft arm hang: slight X is forward; elbows rest slightly flexed (X).
cycle("LeftArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, 0.05]),
cycle("RightArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, -0.05]),
cycle("LeftForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]),
cycle("RightForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]),
],
};
}
/**
* Walk cycle. Arms swing in opposition to the legs — the single detail that
* makes a walk read as a walk rather than a shuffle.
*/
export function buildWalkClip(duration = 1, stride = 0.55): AnimationClip {
return {
name: "Walk",
duration,
loop: true,
channels: [
// Counter-rotation through the torso, small enough not to look like a strut.
cycle("Hips", duration, (p) => [0, wave(p) * 0.06, 0]),
// Slight forward lean (+X with +Y spine).
cycle("Spine", duration, (p) => [0.05, wave(p) * -0.05, 0]),
cycle("Spine2", duration, (p) => [0.02, wave(p) * -0.07, 0]),
// Hips: X is forward (+Z). Opposite legs are half a cycle apart.
cycle("LeftUpLeg", duration, (p) => [-wave(p) * stride, 0, 0]),
cycle("RightUpLeg", duration, (p) => [-wave(p, 0.5) * stride, 0, 0]),
// Knees only flex one way: +X folds the shin back (foot toward Z when vertical).
// Peak flex just after the hip starts the swing (phase offset 0.25 / 0.75).
cycle("LeftLeg", duration, (p) => [Math.max(0, wave(p, 0.25)) * stride * 1.15, 0, 0]),
cycle("RightLeg", duration, (p) => [Math.max(0, wave(p, 0.75)) * stride * 1.15, 0, 0]),
// Plantar flex follows the stride so the sole doesn't skate.
cycle("LeftFoot", duration, (p) => [-wave(p, 0.15) * 0.22, 0, 0]),
cycle("RightFoot", duration, (p) => [-wave(p, 0.65) * 0.22, 0, 0]),
// Arms opposite the legs (0.5 phase), X forward.
cycle("LeftArm", duration, (p) => [-wave(p, 0.5) * stride * 0.55, 0, 0.08]),
cycle("RightArm", duration, (p) => [-wave(p) * stride * 0.55, 0, -0.08]),
// Elbows: X flexes the hand forward. Extra bend on the forward swing.
cycle("LeftForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p, 0.5)) * 0.32, 0, 0]),
cycle("RightForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p)) * 0.32, 0, 0]),
],
};
}
/** Run cycle: longer stride, deeper forward lean, faster loop. */
export function buildRunClip(duration = 0.62): AnimationClip {
const walk = buildWalkClip(duration, 0.95);
return {
...walk,
name: "Run",
channels: walk.channels.map((channel) =>
channel.bone === "Spine"
? {
...channel,
keyframes: channel.keyframes.map((key) => ({
...key,
// Deeper forward lean for the run.
rotation: [0.18, key.rotation[1], key.rotation[2]] as [number, number, number],
})),
}
: channel,
),
};
}
/**
* Quick-turn: the 180° spin from GDD §6.2. Body-only — the runtime rotates the
* character's yaw itself, so this adds the lean and arm swing that sell it.
*/
export function buildQuickTurnClip(duration = 0.22): AnimationClip {
const arc = (phase: number): number => Math.sin(phase * Math.PI);
const keys = (fn: (phase: number) => [number, number, number]): Keyframe[] =>
Array.from({ length: 5 }, (_, i) => {
const phase = i / 4;
return { time: phase * duration, rotation: fn(phase) };
});
return {
name: "QuickTurn",
duration,
loop: false,
channels: [
{ bone: "Spine", keyframes: keys((p) => [0, arc(p) * -0.4, 0]) },
{ bone: "Spine2", keyframes: keys((p) => [0, arc(p) * -0.3, 0]) },
// X is forward; arms brace into the spin.
{ bone: "LeftArm", keyframes: keys((p) => [arc(p) * -0.5, 0, 0.25]) },
{ bone: "RightArm", keyframes: keys((p) => [arc(p) * 0.35, 0, -0.25]) },
{ bone: "LeftUpLeg", keyframes: keys((p) => [arc(p) * -0.35, 0, 0]) },
{ bone: "RightUpLeg", keyframes: keys((p) => [arc(p) * 0.35, 0, 0]) },
],
};
}
/**
* Corpse / ambient body pose — not a locomotion clip.
*
* Weight settles into the hips, spine folds, head hangs, arms go limp. Used by
* room NPCs so they don't stand in the breathing Idle like living party members.
*/
export function buildSlumpedClip(): AnimationClip {
const hold = (bone: CanonicalBone, rotation: [number, number, number]): AnimationChannel => ({
bone,
keyframes: [
{ time: 0, rotation },
{ time: 0.001, rotation },
],
});
return {
name: "Slumped",
duration: 0.001,
loop: false,
channels: [
// Collapse into a sit-lean against a wall: pelvis tucks, spine curls forward.
hold("Hips", [0.12, 0.08, 0.04]),
hold("Spine", [0.45, 0.18, 0.1]),
hold("Spine1", [0.35, 0.12, 0.06]),
hold("Spine2", [0.2, 0.08, 0.04]),
hold("Neck", [0.55, 0.25, 0.12]),
hold("Head", [0.4, 0.2, 0.08]),
// Arms hang heavy — X brings hands forward/down rather than locking behind.
hold("LeftShoulder", [0.1, 0, 0.15]),
hold("RightShoulder", [0.1, 0, -0.15]),
hold("LeftArm", [0.35, 0.1, 0.55]),
hold("RightArm", [0.55, -0.15, -0.65]),
hold("LeftForeArm", [-0.85, 0.1, 0.05]),
hold("RightForeArm", [-0.7, -0.1, -0.05]),
hold("LeftHand", [0.1, 0, 0.15]),
hold("RightHand", [0.15, 0, -0.2]),
// Legs splay loosely; +X on the knee folds the shin back.
hold("LeftUpLeg", [-0.35, 0.2, 0.12]),
hold("RightUpLeg", [-0.25, -0.25, -0.15]),
hold("LeftLeg", [0.85, 0, 0]),
hold("RightLeg", [1.05, 0, 0]),
hold("LeftFoot", [-0.2, 0.05, 0]),
hold("RightFoot", [-0.15, -0.05, 0]),
],
};
}
export function buildDefaultClips(): AnimationClip[] {
return [
buildIdleClip(),
buildWalkClip(),
buildRunClip(),
buildQuickTurnClip(),
buildSlumpedClip(),
];
}
/** Euler XYZ to quaternion, matching three.js's default order and glTF XYZW. */
export function eulerToQuaternion(
x: number,
y: number,
z: number,
): [number, number, number, number] {
const cx = Math.cos(x / 2);
const sx = Math.sin(x / 2);
const cy = Math.cos(y / 2);
const sy = Math.sin(y / 2);
const cz = Math.cos(z / 2);
const sz = Math.sin(z / 2);
return [
sx * cy * cz + cx * sy * sz,
cx * sy * cz - sx * cy * sz,
cx * cy * sz + sx * sy * cz,
cx * cy * cz - sx * sy * sz,
];
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
/**
* Build every campaign character via the modular creator.
*
* pnpm cast
*
* Recipes live in `creator/presets.ts` — swap hair / clothes / accessories
* there rather than re-running photo analysis.
*/
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const result = spawnSync(process.execPath, ["--import", "tsx", join(here, "../creator/build.ts")], {
stdio: "inherit",
cwd: join(here, "../../.."),
});
process.exit(result.status ?? 1);
+117
View File
@@ -0,0 +1,117 @@
import type { FigureSpec } from "../image/testFixtures.js";
import { DEFAULT_FIGURE } from "../image/testFixtures.js";
/**
* Cast definitions for the Ashgrove Precinct campaign.
*
* Each entry is a synthetic, measurable figure used when no Grok reference is
* available. Proportions and palettes are distinct so the code-drawn meshes
* read as different people at 240p.
*/
export interface CastMember {
id: string;
name: string;
height: number;
/** Role in the campaign — player, ambient body, or both. */
role: "player" | "ambient" | "both";
description: string;
figure: FigureSpec;
}
export const CAST: CastMember[] = [
{
id: "survivor",
name: "Survivor",
height: 1.72,
role: "player",
description: "Lone survivor in a canvas jacket — the default player body.",
figure: {
...DEFAULT_FIGURE,
colors: {
hair: { r: 42, g: 38, b: 34 },
skin: { r: 168, g: 128, b: 102 },
torso: { r: 86, g: 92, b: 60 },
arm: { r: 74, g: 80, b: 52 },
leg: { r: 48, g: 50, b: 58 },
foot: { r: 92, g: 66, b: 44 },
},
backpack: {
fromY: 0.2,
toY: 0.48,
width: 0.28,
color: { r: 62, g: 56, b: 44 },
},
},
},
{
id: "researcher",
name: "Dr. Hale",
height: 1.68,
role: "ambient",
description: "Manor researcher in a pale lab coat — found in the study.",
figure: {
...DEFAULT_FIGURE,
neckY: 0.13,
shoulderY: 0.19,
crotchY: 0.54,
torsoWidth: 0.24,
armReach: 0.36,
colors: {
hair: { r: 210, g: 200, b: 185 },
skin: { r: 210, g: 178, b: 152 },
torso: { r: 220, g: 218, b: 210 },
arm: { r: 210, g: 208, b: 200 },
leg: { r: 55, g: 58, b: 72 },
foot: { r: 40, g: 40, b: 48 },
},
},
},
{
id: "officer",
name: "Sgt. Marrow",
height: 1.82,
role: "ambient",
description: "Uniformed officer — slumped in the cellar.",
figure: {
...DEFAULT_FIGURE,
neckY: 0.12,
shoulderY: 0.18,
crotchY: 0.52,
torsoWidth: 0.3,
headWidth: 0.095,
legWidth: 0.1,
armWidth: 0.085,
armReach: 0.4,
colors: {
hair: { r: 28, g: 28, b: 30 },
skin: { r: 145, g: 110, b: 88 },
torso: { r: 40, g: 52, b: 48 },
arm: { r: 36, g: 46, b: 42 },
leg: { r: 32, g: 36, b: 40 },
foot: { r: 28, g: 28, b: 28 },
},
},
},
{
id: "groundskeeper",
name: "Ellis",
height: 1.75,
role: "ambient",
description: "Groundskeeper in a stained work coat — foyer and kitchen.",
figure: {
...DEFAULT_FIGURE,
crotchY: 0.56,
torsoWidth: 0.28,
legWidth: 0.095,
colors: {
hair: { r: 90, g: 70, b: 50 },
skin: { r: 175, g: 130, b: 100 },
torso: { r: 120, g: 78, b: 48 },
arm: { r: 100, g: 70, b: 48 },
leg: { r: 70, g: 72, b: 78 },
foot: { r: 55, g: 48, b: 40 },
},
},
},
];
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import { chdir } from "node:process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { GrokBridge } from "./grok/GrokBridge.js";
import { defaultOutputPath, formatReport, runPipeline } from "./pipeline.js";
/**
* `psx-harness` — photo to rigged, animated GLB.
*
* pnpm harness --describe "a lone survivor in a canvas jacket" --name Survivor
* pnpm harness --image tools/fixtures/survivor-reference.jpg --name Survivor
*
* Paths are resolved from the monorepo root so the same flags work whether
* you invoke via the root script, `pnpm --filter @psx/tools harness`, or
* `tsx tools/src/cli.ts` from the repo root.
*/
/** `tools/src/cli.ts` → monorepo root is two levels up. */
const WORKSPACE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
interface Args {
describe?: string;
image?: string;
name?: string;
out?: string;
height?: number;
help?: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = {};
for (let i = 0; i < argv.length; i++) {
const token = argv[i]!;
// pnpm/npm insert a bare `--` between the script name and user args.
if (token === "--") continue;
const next = () => argv[++i];
switch (token) {
case "--describe":
case "-d":
args.describe = next();
break;
case "--image":
case "-i":
args.image = next();
break;
case "--name":
case "-n":
args.name = next();
break;
case "--out":
case "-o":
args.out = next();
break;
case "--height":
args.height = Number(next());
break;
case "--help":
case "-h":
args.help = true;
break;
default:
if (token.startsWith("-")) throw new Error(`unknown flag: ${token}`);
}
}
return args;
}
const USAGE = `
psx-harness — generate a rigged, animated PSX character from a reference image
Usage:
psx-harness --describe "<description>" [options]
psx-harness --image <path> [options]
Options:
-d, --describe <text> Generate the reference through the Grok CLI (image_gen)
-i, --image <path> Use an existing reference image (PNG or JPEG)
-n, --name <name> Character name [default: Character]
-o, --out <path> Output .glb path [default: assets/characters/<name>.glb]
--height <metres> Target world height [default: 1.7]
-h, --help Show this message
The reference should be a full-body front view on a plain background. Generation
is cached by prompt, so re-running the same description costs nothing.
`.trim();
async function main(): Promise<void> {
// Always run relative to the monorepo root so default outputs land in
// `assets/characters/` and documented fixture paths resolve.
if (existsSync(resolve(WORKSPACE_ROOT, "pnpm-workspace.yaml"))) {
chdir(WORKSPACE_ROOT);
}
const args = parseArgs(process.argv.slice(2));
if (args.help || (!args.describe && !args.image)) {
console.log(USAGE);
process.exit(args.help ? 0 : 1);
}
const name = args.name ?? "Character";
const outputPath = args.out ?? defaultOutputPath(name);
if (args.describe && !args.image) {
const bridge = new GrokBridge();
if (!(await bridge.isAvailable())) {
console.error(
"Grok CLI not found on PATH. Install it, set GROK_BIN, or pass --image with an existing reference.",
);
process.exit(1);
}
console.log("Generating reference image (this takes about a minute)…");
}
const result = await runPipeline({
...(args.image ? { imagePath: args.image } : {}),
...(args.describe ? { describe: args.describe } : {}),
name,
outputPath,
...(args.height ? { height: args.height } : {}),
});
console.log(`\n${name}\n${formatReport(result)}\n`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { assembleCharacter } from "./assemble.js";
import { CHARACTER_PRESETS } from "./presets.js";
import { listParts } from "./parts.js";
function parseGlb(glb: Uint8Array) {
const loader = new GLTFLoader();
const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
return new Promise<import("three/examples/jsm/loaders/GLTFLoader.js").GLTF>((resolve, reject) => {
loader.parse(buffer, "", resolve, reject);
});
}
describe("character creator", () => {
it("exposes hair / upper / lower / feet / accessory parts", () => {
expect(listParts("hair").length).toBeGreaterThan(2);
expect(listParts("upper").length).toBeGreaterThan(3);
expect(listParts("lower").length).toBeGreaterThan(1);
expect(listParts("feet").length).toBeGreaterThan(1);
expect(listParts("accessory").length).toBeGreaterThan(1);
});
it("assembles male and female presets into loadable skinned GLBs", async () => {
const male = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!);
const female = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "researcher")!);
expect(male.stats.triangles).toBeGreaterThan(400);
expect(female.stats.triangles).toBeGreaterThan(400);
expect(male.recipe.body).toBe("male");
expect(female.recipe.body).toBe("female");
for (const result of [male, female]) {
const gltf = await parseGlb(result.glb);
let skinned: SkinnedMesh | null = null;
gltf.scene.traverse((object) => {
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
});
expect(skinned).not.toBeNull();
expect(skinned!.skeleton.bones.length).toBe(22);
expect(gltf.animations.some((clip: ThreeClip) => clip.name === "Walk")).toBe(true);
expect(gltf.animations.some((clip: ThreeClip) => clip.name === "Slumped")).toBe(true);
const bones = new Map<string, Bone>();
gltf.scene.traverse((object) => {
if ((object as Bone).isBone) bones.set(object.name, object as Bone);
});
expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm");
}
});
it("rejects body-locked parts on the wrong sex", () => {
expect(() =>
assembleCharacter({
id: "bad",
name: "Bad",
body: "male",
height: 1.7,
palette: CHARACTER_PRESETS[0]!.palette,
parts: { lower: "lower.skirt" },
}),
).toThrow(/female/);
});
it("mass/muscle/fat sliders change torso girth", () => {
const base = CHARACTER_PRESETS.find((p) => p.id === "survivor")!;
const bare = {
hair: "hair.none",
upper: "upper.none",
lower: "lower.none",
feet: "feet.none",
accessory: "accessory.none",
} as const;
const lean = assembleCharacter({
...base,
id: "lean",
bodyStyle: { mass: -1, muscle: -0.5, fat: 0 },
parts: { ...bare },
});
const heavy = assembleCharacter({
...base,
id: "heavy",
bodyStyle: { mass: 1, muscle: 1, fat: 1 },
parts: { ...bare },
});
// A-pose arms dominate mid-height X. Shoulder girdle (just below neck)
// is where mass/muscle/fat expand the torso loft most clearly.
const girdleHalfW = (positions: number[], height: number) => {
const y0 = height * 0.78;
const y1 = height * 0.86;
let m = 0;
for (let i = 0; i < positions.length; i += 3) {
const y = positions[i + 1]!;
if (y < y0 || y > y1) continue;
m = Math.max(m, Math.abs(positions[i]!));
}
return m;
};
const leanW = girdleHalfW(lean.mesh.positions, base.height);
const heavyW = girdleHalfW(heavy.mesh.positions, base.height);
expect(heavyW).toBeGreaterThan(leanW * 1.35);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { buildDefaultClips } from "../anim/procedural.js";
import { exportGlb, meshStatistics } from "../export/glb.js";
import { appendMesh, computeFlatNormals, createMesh, triangleCount, vertexCount } from "../mesh/MeshBuilder.js";
import { buildSkeleton } from "../rig/skeleton.js";
import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js";
import { buildBaseBody } from "./body.js";
import { normalizeBodyStyle } from "./bodyStyle.js";
import { normalizeHeadStyle } from "./headStyle.js";
import { applyCoverage, type CoverageBand } from "./coverage.js";
import { PART_CATALOGUE } from "./parts.js";
import { measurementsFor } from "./proportions.js";
import type { AssembledCharacter, CharacterRecipe, PartContext, PartSlot } from "./types.js";
const SLOT_ORDER: PartSlot[] = ["lower", "upper", "feet", "hair", "accessory"];
/**
* Assemble a character from a modular recipe.
*
* base body (male|female) + equipped parts → single skinned mesh → GLB
*/
export function assembleCharacter(recipe: CharacterRecipe): AssembledCharacter {
const measurements = measurementsFor(recipe.body);
const skeleton = buildSkeleton(measurements, { height: recipe.height });
const bodyStyle = normalizeBodyStyle(recipe.bodyStyle);
const headStyle = normalizeHeadStyle(recipe.headStyle);
// Resolve the outfit first: the body is built knowing what will cover it, so
// the skin underneath can be deleted rather than fought with padding.
const ctx: PartContext = {
skeleton,
body: recipe.body,
palette: recipe.palette,
bodyStyle,
headStyle,
};
const worn: { def: (typeof PART_CATALOGUE) extends ReadonlyMap<string, infer D> ? D : never; id: string }[] = [];
const coverage: CoverageBand[] = [];
for (const slot of SLOT_ORDER) {
const partId = recipe.parts[slot];
if (!partId || partId.endsWith(".none")) continue;
const def = PART_CATALOGUE.get(partId);
if (!def) throw new Error(`Unknown part "${partId}" on recipe "${recipe.id}"`);
if (def.slot !== slot) throw new Error(`Part "${partId}" is slot ${def.slot}, expected ${slot}`);
if (def.body && def.body !== recipe.body) {
throw new Error(`Part "${partId}" is only valid on ${def.body} bodies`);
}
worn.push({ def, id: partId });
if (def.coverage) coverage.push(...def.coverage);
}
const mesh = createMesh();
const skin = buildBaseBody(skeleton, recipe.body, recipe.palette, bodyStyle, headStyle);
const hidden = applyCoverage(skin, coverage, { recessDepth: skeleton.height * 0.009 });
appendMesh(mesh, hidden.mesh);
for (const { def, id } of worn) {
appendMesh(mesh, def.build({
...ctx,
...(recipe.partColors?.[id] ? { color: recipe.partColors[id] } : {}),
}));
}
computeFlatNormals(mesh);
computeSkinWeights(mesh, skeleton);
const inverseBindMatrices = computeInverseBindMatrices(skeleton);
const clips = buildDefaultClips();
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, {
name: recipe.name,
clips,
generator: "psx-adventure-engine character creator",
});
const meshStats = meshStatistics(mesh);
return {
recipe,
skeleton,
mesh,
glb,
stats: {
vertices: meshStats.vertices,
triangles: meshStats.triangles,
bones: skeleton.joints.length,
clips: clips.length,
skinHidden: hidden.dropped,
skinRecessed: hidden.recessed,
},
};
}
export function formatAssemblyReport(result: AssembledCharacter): string {
const { recipe, stats } = result;
const parts = Object.entries(recipe.parts)
.filter(([, id]) => id && !id.endsWith(".none"))
.map(([slot, id]) => `${slot}=${id}`)
.join(", ");
const style = normalizeBodyStyle(recipe.bodyStyle);
const face = normalizeHeadStyle(recipe.headStyle);
return [
` body ${recipe.body} @ ${recipe.height.toFixed(2)}m`,
` physique mass=${style.mass.toFixed(2)} muscle=${style.muscle.toFixed(2)} fat=${style.fat.toFixed(2)}`,
` face length=${face.length.toFixed(2)} jaw=${face.jaw.toFixed(2)} brow=${face.brow.toFixed(2)}`,
` parts ${parts || "(bare)"}`,
` mesh ${stats.vertices} vertices, ${stats.triangles} triangles`,
` skin ${stats.skinHidden} triangles hidden under clothing, ${stats.skinRecessed} recessed`,
` rig ${stats.bones} bones, ${stats.clips} clips`,
].join("\n");
}
// Re-export for tests / diagnostics.
export { vertexCount, triangleCount };
+573
View File
@@ -0,0 +1,573 @@
import type { CanonicalBone } from "@psx/shared";
import type { Rgb } from "../image/Raster.js";
import {
add,
computeFlatNormals,
createMesh,
lerp,
loft,
normalise,
orientedBox,
scale,
sub,
vertexCount,
type LoftKey,
type MeshData,
type Vec3Tuple,
} from "../mesh/MeshBuilder.js";
import { jointWorld, type Skeleton } from "../rig/skeleton.js";
import type { BodyStyle } from "./bodyStyle.js";
import type { HeadStyle } from "./headStyle.js";
import { PART, tagLoft, tagRange, type BodyPart } from "./coverage.js";
import { bodyMetrics, torsoPlan, type BodyMetrics } from "./metrics.js";
import type { BodyType, CreatorPalette } from "./types.js";
/**
* Base body mesh — skin only. Every clothed character derives from this.
*
* Shape comes entirely from {@link bodyMetrics}; this file decides only where
* the rings go and how many. The stylisation is in the ring counts, not in the
* proportions — a PSX figure that is honestly proportioned and coarsely
* tessellated reads as an adult at 240p, where a lanky one reads as a
* marionette.
*
* Note that `computeFlatNormals` is a misnomer: it averages face normals into
* shared vertices, so every loft here is smooth-shaded and the faceting you see
* is the silhouette, not the shading. The face relies on that — eyes and mouth
* are painted, so the head is kept as clean unbroken surface for the texture and
* only the nose, which changes the silhouette, stays as geometry.
*
* Structure, root to tip:
* torso crotch → glutes → hip shelf → waist → ribs → chest → girdle
* neck C7 → jaw, buried at both ends
* head jaw → chin → cheek → brow → cranium → crown, plus nose and ears
* arms deltoid socket buried in the girdle → upper → fore → palm → thumb
* legs hip socket buried in the pelvis → thigh → knee → calf → ankle
* feet heel → instep → ball → toe, lofted along the foot axis
*
* Every piece is tagged with its region and loft parameter (see
* `coverage.ts`), so a garment can claim the skin it covers and the assembler
* can delete it. The loft parameters quoted in the comments below are the same
* numbers garments cite in their coverage bands — move a key here and the
* matching band moves with it.
*/
/** Torso reads round at 8 sides; limbs stay cheap. */
const TORSO_SIDES = 8;
const ARM_SIDES = 6;
const LEG_SIDES = 6;
const HEAD_SIDES = 8;
const FOOT_SIDES = 5;
/** Stable ring frames: rx across the body, rz front-to-back. */
const ACROSS: Vec3Tuple = [1, 0, 0];
export interface HeadLandmarks {
head: Vec3Tuple;
neck: Vec3Tuple;
crown: Vec3Tuple;
brow: Vec3Tuple;
browFwd: Vec3Tuple;
occiput: Vec3Tuple;
headW: number;
/** Half-depth of the skull, front to back. */
headD: number;
height: number;
}
/**
* Skull reference points for hair and headgear.
*
* Body style is ignored — a hat doesn't care how heavy its wearer is — but head
* style is not: the sliders reshape the skull these anchors sit on, so hair
* built off a neutral head would clip through a long or square one.
*/
export function headLandmarks(
skeleton: Skeleton,
body: BodyType,
headStyle: HeadStyle | null = null,
): HeadLandmarks {
const m = bodyMetrics(skeleton, body, null, headStyle);
const head = jointWorld(skeleton, "Head");
const neck = jointWorld(skeleton, "Neck");
const headW = m.headHalfW;
const crown: Vec3Tuple = [0, m.crownY, m.headZ + headW * 0.05];
const brow: Vec3Tuple = [0, m.browY, m.headZ + m.headHalfD * 0.5];
const browFwd: Vec3Tuple = [0, m.browY - headW * 0.04, m.headZ + m.headHalfD * 0.92];
const occiput: Vec3Tuple = [
0,
m.browY - headW * 0.12,
m.headZ - m.headHalfD * 0.78,
];
return {
head,
neck,
crown,
brow,
browFwd,
occiput,
headW,
headD: m.headHalfD,
height: skeleton.height,
};
}
export function buildBaseBody(
skeleton: Skeleton,
body: BodyType,
palette: CreatorPalette,
bodyStyle: BodyStyle | null = null,
headStyle: HeadStyle | null = null,
): MeshData {
const mesh = createMesh();
const m = bodyMetrics(skeleton, body, bodyStyle, headStyle);
const skin = palette.skin;
buildTorso(mesh, m, skin);
buildChestForms(mesh, m, skin);
buildNeck(mesh, m, skin);
buildHead(mesh, m, skin);
buildArms(mesh, m, skin);
buildLegs(mesh, m, skin);
computeFlatNormals(mesh);
return mesh;
}
// --- torso -----------------------------------------------------------------
function buildTorso(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
const h = m.height;
const hips = m.at("Hips");
// Ring plan lives in metrics.ts so torso garments loft on the same one.
tagLoft(
mesh,
loft(mesh, torsoPlan(m), skin, {
sides: TORSO_SIDES,
rings: 13,
caps: true,
startRight: ACROSS,
}),
PART.TORSO,
);
// Groin bridge — fills between the leg roots so the crotch reads solid from
// the front and under a skirt hem.
const legRootX = Math.abs(m.at("LeftUpLeg")[0]);
const bridgeFrom = vertexCount(mesh);
loft(
mesh,
[
{ t: 0, c: [0, m.hipY - h * 0.02, hips[2] + h * 0.012], rx: legRootX * 0.8, rz: m.hip.rz * 0.5 },
{
t: 0.6,
c: [0, m.crotchY + h * 0.012, hips[2] + h * 0.014],
rx: legRootX * 0.62,
rz: m.hip.rz * 0.42,
},
{ t: 1, c: [0, m.crotchY - h * 0.008, hips[2] + h * 0.008], rx: legRootX * 0.4, rz: m.hip.rz * 0.3 },
],
skin,
{ sides: 5, rings: 4, caps: true, startRight: ACROSS },
);
// The bridge lives at the crotch, so it hides with the pelvis.
tagRange(mesh, bridgeFrom, PART.TORSO, 0.02);
}
/** Breasts (female) or a flat pectoral shelf (male), on the front of the ribcage. */
function buildChestForms(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
if (m.female) {
const { r, x, y, z, out } = m.bust;
if (r <= 0) return;
for (const side of [-1, 1] as const) {
const from = vertexCount(mesh);
loft(
mesh,
[
// Rooted inside the ribcage so the seam never separates.
{ t: 0, c: [side * x, y + r * 0.15, z - r * 0.5], rx: r * 0.92, rz: r * 0.92 },
{ t: 0.55, c: [side * x, y, z + out * 0.45], rx: r, rz: r },
{ t: 1, c: [side * x * 0.98, y - r * 0.22, z + out], rx: r * 0.42, rz: r * 0.42 },
],
skin,
{ sides: 5, rings: 3, caps: true, startRight: ACROSS },
);
// Chest height on the torso loft — hides under any upper garment.
tagRange(mesh, from, PART.TORSO, 0.7);
}
return;
}
const { rx, rz, x, y, z } = m.pec;
// Flat chest at rest — a pec pad at neutral muscle just adds a seam to
// shade wrong. It grows out of the chest wall only when the slider asks.
if (rz < m.height * 0.004) return;
for (const side of [-1, 1] as const) {
const from = vertexCount(mesh);
loft(
mesh,
[
{ t: 0, c: [side * x, y + rx * 0.42, z - rz * 1.6], rx: rx * 0.86, rz: rz * 0.7 },
{ t: 1, c: [side * x, y - rx * 0.5, z - rz * 0.2], rx, rz },
],
skin,
{ sides: 5, rings: 3, caps: true, startRight: ACROSS },
);
tagRange(mesh, from, PART.TORSO, 0.7);
}
}
// --- neck and head ---------------------------------------------------------
function buildNeck(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
const h = m.height;
const neck = m.at("Neck");
const neckLoft = loft(
mesh,
[
// Buried in the trapezius — uncapped, or the cap pokes through the collar.
{ t: 0, c: [0, m.collarY - h * 0.035, neck[2]], rx: m.neck.rx * 1.35, rz: m.neck.rz * 1.3 },
{ t: 0.4, c: [0, m.collarY + h * 0.008, neck[2] + h * 0.002], rx: m.neck.rx, rz: m.neck.rz },
// Just under the jaw, tilted slightly forward with the cervical curve.
{ t: 1, c: [0, m.neckTopY, neck[2] + h * 0.007], rx: m.neck.rx * 0.92, rz: m.neck.rz * 0.94 },
],
skin,
{ sides: 6, rings: 4, capStart: false, capEnd: true, startRight: ACROSS },
);
tagLoft(mesh, neckLoft, PART.NECK);
}
/**
* Head loft, chin (t=0) to crown (t=1).
*
* Shape is entirely `m.cranium` — the sex face base times the three head
* sliders. The ring plan below decides only *where* the rings sit; how wide the
* jaw is against the cheekbones, whether the forehead stands up or slopes back,
* and how far the brow juts are all read off the metrics, the same way the
* torso reads its widths. Every hard-coded fraction still here is one no face
* varies by.
*/
function buildHead(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
const hw = m.headHalfW;
const hd = m.headHalfD;
const z = m.headZ;
const cr = m.cranium;
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
// Skull mass sits behind the face plane; the jaw is narrower and set back.
const keys: LoftKey[] = [
// Under the jaw, inside the neck.
{ t: 0.0, c: [0, m.chinY - hw * 0.16, z + hd * 0.04], rx: hw * cr.chinW * 0.8, rz: hd * cr.chinD * 0.58 },
// Chin — narrow and thrust forward. This is what stops the head reading as
// an egg: the lower face must be a wedge, not a hemisphere.
{ t: 0.12, c: [0, m.chinY, z + hd * cr.chinOut], rx: hw * cr.chinW, rz: hd * cr.chinD },
// Jawline, halfway to the jaw angle.
//
// The centre sits at 0.12, not the 0.18 it used to: `rz` here is nearly the
// jaw's, so a centre that far forward pushed this ring's front surface past
// both the jaw above it and the chin below. Reading down the face the front
// went 0.96 → 1.00 → 0.92, and that non-monotonic step is a shelf across the
// lower face, not a jawline. Keep the profile falling: 0.96 → 0.94 → 0.92.
{
t: 0.24,
c: [0, m.chinY + (m.jawY - m.chinY) * 0.6, z + hd * 0.12],
rx: hw * mix(cr.chinW, cr.jawW, 0.62),
rz: hd * mix(cr.chinD, cr.jawD, 0.71),
},
// Jaw angle / masseter — widest point of the lower face.
{ t: 0.38, c: [0, m.jawY, z + hd * 0.06], rx: hw * cr.jawW, rz: hd * cr.jawD },
// Cheekbone — the head's widest ring.
{ t: 0.55, c: [0, m.mouthY + (m.browY - m.mouthY) * 0.5, z + hd * 0.06], rx: hw * cr.cheekW, rz: hd * 0.98 },
// Brow ridge, carried by the ring itself rather than a bar glued on top.
// A separate slab here shades as a hard step across exactly the band the
// texture paints eyes into; rolled into the loft it stays one continuous
// surface and the slider still reads in profile.
{
t: 0.68,
c: [0, m.browY, z + hd * (0.02 + 0.012 * cr.browOut)],
rx: hw * cr.cheekW * 0.97,
// Spans ~0.96·hd soft to ~1.09·hd heavy, so the brow sits behind the
// cheekbone at one end and a centimetre proud of it at the other. That is
// the whole slider now, and it stays a swelling of the skull rather than
// a step the texture has to paint around.
rz: hd * (0.96 + 0.065 * cr.browOut),
},
// Upper cranium. On a heavy brow this drifts back over the occiput; on a
// soft one the forehead stands up instead.
{
t: 0.83,
c: [0, m.browY + (m.crownY - m.browY) * 0.52, z - hd * 0.08 * cr.foreheadSlope],
rx: hw * 0.92,
rz: hd * 0.94,
},
// Crown.
{ t: 0.95, c: [0, m.crownY - hw * 0.1, z - hd * 0.12 * cr.foreheadSlope], rx: hw * 0.56, rz: hd * 0.54 },
{ t: 1.0, c: [0, m.crownY, z - hd * 0.12 * cr.foreheadSlope], rx: hw * 0.18, rz: hd * 0.18 },
];
// 13 rings, not 10: the chin juts forward of the jaw on purpose, and at 10
// the hermite sweep resolves that turn as a hard crease across the lower face
// — a shading break on a smooth head rather than a jawline.
tagLoft(mesh, loft(mesh, keys, skin, { sides: HEAD_SIDES, rings: 13, caps: true, startRight: ACROSS }), PART.HEAD);
const featuresFrom = vertexCount(mesh);
// Nose — the one feature that stays geometry, because it is the only one that
// changes the silhouette. Eyes and mouth are painted, so the face is left as
// clean surface for them; a modelled brow or lip at this scale only fights
// the texture and shades as a crease across it.
//
// Anchored to the face plane rather than a fixed fraction of the skull. The
// old placement put the tip at ~0.96·hd while the surface at nose height sits
// near 1.0·hd, so the wedge was buried and read as a flat plate.
// Lofted rather than boxed: a box cannot taper, so it reads as a slab stuck
// to the face from the front however well it sits in profile. Four sides and
// three rings is 20 triangles for a wedge that narrows to the tip.
const noseY = m.mouthY + (m.browY - m.mouthY) * 0.5;
const faceZ = z + hd * 0.97;
const proj = hw * 0.2 * cr.noseOut;
const noseW = hw * cr.noseW;
loft(
mesh,
[
// Root sits behind the face plane, so the bridge never opens a gap.
{
t: 0,
c: [0, m.browY - hw * 0.04 * cr.noseLen, faceZ - hw * 0.12],
rx: noseW * 0.07,
rz: hw * 0.05,
},
{
t: 0.6,
c: [0, noseY, faceZ + proj * 0.5],
rx: noseW * 0.1,
rz: hw * 0.09,
},
// Tip — the widest ring, and the only part clear of the face.
{
t: 1,
c: [0, noseY - hw * 0.22 * cr.noseLen, faceZ + proj],
rx: noseW * 0.12,
rz: hw * 0.1,
},
],
skin,
{ sides: 4, rings: 3, caps: true, startRight: ACROSS },
);
// Ears, flat against the skull just behind the cheekbone.
for (const side of [-1, 1] as const) {
orientedBox(
mesh,
[side * hw * 0.88, m.browY - hw * 0.34, z - hd * 0.08],
hw * 0.07,
hw * 0.26 * cr.earSize,
hw * 0.14 * cr.earSize,
[side, 0, 0.25],
skin,
);
}
// Face features sit at brow height; nothing in the catalogue hides them, and
// nothing should — an open-fronted hood over a hidden face reads decapitated.
tagRange(mesh, featuresFrom, PART.HEAD, 0.66);
}
// --- arms ------------------------------------------------------------------
function buildArms(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
const h = m.height;
for (const prefix of ["Left", "Right"] as const) {
const side = prefix === "Left" ? 1 : -1;
const shoulder = m.at(`${prefix}Shoulder` as CanonicalBone);
const arm = m.at(`${prefix}Arm` as CanonicalBone);
const foreArm = m.at(`${prefix}ForeArm` as CanonicalBone);
const hand = m.at(`${prefix}Hand` as CanonicalBone);
const down = normalise(sub(hand, arm));
const wristOut = normalise(sub(hand, foreArm));
const handEnd = add(hand, scale(wristOut, m.handLength));
const keys: LoftKey[] = [
// Inside the girdle — no cap, so no disk floating in the chest.
{
t: 0.0,
c: [side * m.clavX, shoulder[1] - h * 0.012, shoulder[2]],
rx: m.deltoidR * 0.94,
rz: m.deltoidR,
},
// Deltoid cap. This, not the trunk, sets the shoulder silhouette — but it
// must sit *under* the trapezius line, or it reads as an epaulette.
{
t: 0.1,
c: [side * m.socketX, arm[1] + h * 0.002, arm[2]],
rx: m.deltoidR,
rz: m.deltoidR * 1.04,
},
// Deltoid insertion — the arm proper takes over.
{
t: 0.22,
c: add(arm, scale(down, h * 0.045)),
rx: m.upperArm.rx * 1.06,
rz: m.upperArm.rz * 1.06,
},
{ t: 0.4, c: lerp(arm, foreArm, 0.62), rx: m.upperArm.rx * 0.92, rz: m.upperArm.rz * 0.92 },
// Elbow.
{ t: 0.52, c: foreArm, rx: m.elbow.rx, rz: m.elbow.rz },
// Forearm belly, just below the elbow.
{ t: 0.62, c: lerp(foreArm, hand, 0.2), rx: m.foreArm.rx, rz: m.foreArm.rz },
{ t: 0.8, c: lerp(foreArm, hand, 0.72), rx: m.wrist.rx * 1.18, rz: m.wrist.rz * 1.12 },
// Wrist — the section turns flat here and stays flat through the palm.
{ t: 0.86, c: hand, rx: m.wrist.rx, rz: m.wrist.rz },
{ t: 0.93, c: lerp(hand, handEnd, 0.45), rx: m.palm.rx, rz: m.palm.rz },
// Knuckles, then the fingers close as one paddle.
{ t: 0.97, c: lerp(hand, handEnd, 0.78), rx: m.palm.rx * 0.94, rz: m.palm.rz * 0.94 },
{ t: 1.0, c: handEnd, rx: m.palm.rx * 0.7, rz: m.palm.rz * 0.62 },
];
// startRight along X puts rx across the body and rz front-to-back, so the
// palm faces the thigh at any A-pose angle.
tagLoft(
mesh,
loft(mesh, keys, skin, {
sides: ARM_SIDES,
rings: 11,
capStart: false,
capEnd: true,
startRight: ACROSS,
}),
side > 0 ? PART.ARM_L : PART.ARM_R,
);
// Thumb — a wedge off the front-inboard corner of the palm.
const thumbFrom = vertexCount(mesh);
const thumbAt = lerp(hand, handEnd, 0.32);
orientedBox(
mesh,
add(thumbAt, [side * -m.palm.rx * 0.5, 0, m.palm.rz * 0.75]),
m.thumbR * 0.62,
m.thumbR * 1.5,
m.thumbR * 0.8,
[side * -0.25, -0.55, 0.8],
skin,
);
tagRange(mesh, thumbFrom, side > 0 ? PART.ARM_L : PART.ARM_R, 0.93);
}
}
// --- legs and feet ---------------------------------------------------------
function buildLegs(mesh: MeshData, m: BodyMetrics, skin: Rgb): void {
const h = m.height;
const hips = m.at("Hips");
for (const prefix of ["Left", "Right"] as const) {
const side = prefix === "Left" ? 1 : -1;
const upLeg = m.at(`${prefix}UpLeg` as CanonicalBone);
const leg = m.at(`${prefix}Leg` as CanonicalBone);
const foot = m.at(`${prefix}Foot` as CanonicalBone);
const legX = Math.abs(upLeg[0]);
const keys: LoftKey[] = [
// Buried inside the pelvis bowl — uncapped, so the hip owns the join.
{
t: 0.0,
c: [side * legX * 0.6, m.hipY + h * 0.012, hips[2] + m.buttZ * 0.5],
rx: m.hipSocket.rx,
rz: m.hipSocket.rz,
},
// Upper thigh, still wide where it meets the glute.
{
t: 0.1,
c: [side * legX * 0.95, m.crotchY + h * 0.01, upLeg[2] - h * 0.004],
rx: m.thigh.rx * 1.1,
rz: m.thigh.rz * 1.12,
},
{ t: 0.24, c: [side * legX, upLeg[1] - h * 0.03, upLeg[2]], rx: m.thigh.rx, rz: m.thigh.rz },
// Above the knee the thigh narrows to the condyles.
{ t: 0.42, c: lerp(upLeg, leg, 0.78), rx: m.thigh.rx * 0.78, rz: m.thigh.rz * 0.8 },
{ t: 0.52, c: leg, rx: m.knee.rx, rz: m.knee.rz },
// Calf belly — behind the shin axis, and high on the leg.
{
t: 0.65,
c: add(lerp(leg, foot, 0.26), [0, 0, m.calfZ]),
rx: m.calf.rx,
rz: m.calf.rz,
},
{ t: 0.82, c: add(lerp(leg, foot, 0.62), [0, 0, m.calfZ * 0.4]), rx: m.calf.rx * 0.72, rz: m.calf.rz * 0.7 },
// Ankle.
{ t: 1.0, c: [foot[0], foot[1], foot[2]], rx: m.ankle.rx, rz: m.ankle.rz },
];
tagLoft(
mesh,
loft(mesh, keys, skin, {
sides: LEG_SIDES,
rings: 10,
capStart: false,
capEnd: false,
startRight: ACROSS,
}),
side > 0 ? PART.LEG_L : PART.LEG_R,
);
buildFoot(mesh, m, skin, foot, side > 0 ? PART.FOOT_L : PART.FOOT_R);
}
}
/**
* Foot lofted along its own axis (heel → toe) rather than dangling off the
* shin, so the ring plane is vertical and `rz` is the foot's height above the
* floor. Every section is sized to touch y=0 exactly where it should: heel and
* ball on the ground, arch lifted.
*/
function buildFoot(
mesh: MeshData,
m: BodyMetrics,
skin: Rgb,
ankle: Vec3Tuple,
part: BodyPart,
): void {
const x = ankle[0];
const z = ankle[2];
const hw = m.footHalfW;
const toeZ = z + m.footLength - m.heelBack;
const heelZ = z - m.heelBack;
const keys: LoftKey[] = [
// Back of the heel, rounded off the floor.
{ t: 0.0, c: [x, m.ankleHeight * 0.62, heelZ], rx: hw * 0.62, rz: m.ankleHeight * 0.62 },
// Heel pad — on the ground.
{ t: 0.16, c: [x, m.ankleHeight * 0.52, heelZ + m.footLength * 0.1], rx: hw * 0.78, rz: m.ankleHeight * 0.52 },
// Under the ankle: tallest section, arch lifted off the floor.
{ t: 0.36, c: [x, m.ankleHeight * 0.62, z + m.footLength * 0.06], rx: hw * 0.84, rz: m.ankleHeight * 0.56 },
// Midfoot / instep.
{ t: 0.58, c: [x, m.ankleHeight * 0.44, z + m.footLength * 0.28], rx: hw * 0.92, rz: m.ankleHeight * 0.44 },
// Ball of the foot — widest, back on the ground.
{ t: 0.82, c: [x, m.ankleHeight * 0.34, z + m.footLength * 0.5], rx: hw, rz: m.ankleHeight * 0.34 },
// Toes.
{ t: 1.0, c: [x, m.ankleHeight * 0.24, toeZ], rx: hw * 0.82, rz: m.ankleHeight * 0.24 },
];
tagLoft(mesh, loft(mesh, keys, skin, { sides: FOOT_SIDES, rings: 7, caps: true, startRight: ACROSS }), part);
// Ankle collar — bridges the shin ring into the top of the foot.
const collarFrom = vertexCount(mesh);
loft(
mesh,
[
{ t: 0, c: [x, ankle[1] + m.height * 0.012, z], rx: m.ankle.rx, rz: m.ankle.rz },
{ t: 1, c: [x, m.ankleHeight * 0.66, z + m.footLength * 0.04], rx: hw * 0.8, rz: m.ankleHeight * 0.6 },
],
skin,
{ sides: FOOT_SIDES, rings: 3, caps: true, startRight: ACROSS },
);
tagRange(mesh, collarFrom, part, 0.35);
}
export const SKIN = {
fair: { r: 220, g: 185, b: 160 } satisfies Rgb,
light: { r: 200, g: 160, b: 130 } satisfies Rgb,
medium: { r: 168, g: 128, b: 100 } satisfies Rgb,
tan: { r: 145, g: 105, b: 78 } satisfies Rgb,
deep: { r: 90, g: 62, b: 48 } satisfies Rgb,
};
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
BODY_STYLE_DEFAULTS,
normalizeBodyStyle,
physiqueFor,
physiqueFromBodyStyle,
} from "./bodyStyle.js";
describe("bodyStyle", () => {
it("normalizes missing / packed values", () => {
expect(normalizeBodyStyle(null)).toEqual(BODY_STYLE_DEFAULTS);
expect(normalizeBodyStyle({ m: 0.5, u: -0.25, f: 0.1 })).toEqual({
mass: 0.5,
muscle: -0.25,
fat: 0.1,
});
expect(normalizeBodyStyle({ mass: 2, muscle: -3, fat: -1 })).toEqual({
mass: 1,
muscle: -1,
fat: 0,
});
});
it("maps extremes to bulkier / leaner physiques", () => {
const lean = physiqueFromBodyStyle({ mass: -1, muscle: 0, fat: 0 });
const heavy = physiqueFromBodyStyle({ mass: 1, muscle: 1, fat: 1 });
expect(heavy.bulk).toBeGreaterThan(lean.bulk);
expect(heavy.shoulderF).toBeGreaterThan(lean.shoulderF);
expect(heavy.waistF).toBeGreaterThan(lean.waistF);
});
it("stacks sex base under slider multipliers", () => {
const male = physiqueFor("male", { mass: 0, muscle: 0, fat: 0 });
const female = physiqueFor("female", { mass: 0, muscle: 0, fat: 0 });
expect(male.bulk).toBeGreaterThan(female.bulk);
expect(male.shoulderF).toBeGreaterThan(female.shoulderF);
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Body style — three appearance sliders from ludus (dreamfall-style morphs).
*
* mass 1 lean … +1 heavy
* muscle 1 soft … +1 muscular
* fat 0 base … +1 fat
*
* Maps onto loft physique scalars used by the body + clothing builders.
*/
import type { BodyType } from "./types.js";
export interface BodyStyle {
/** 1 lean … +1 heavy */
mass: number;
/** 1 soft … +1 muscular */
muscle: number;
/** 0 base … +1 fat */
fat: number;
}
export const BODY_STYLE_DEFAULTS: BodyStyle = Object.freeze({
mass: 0,
muscle: 0,
fat: 0,
});
/** Slider metadata for the creator UI. */
export const BODY_STYLE_SLIDERS = Object.freeze([
{
id: "mass" as const,
label: "Mass",
min: -1,
max: 1,
step: 0.01,
left: "Lean",
right: "Heavy",
},
{
id: "muscle" as const,
label: "Muscle",
min: -1,
max: 1,
step: 0.01,
left: "Soft",
right: "Muscular",
},
{
id: "fat" as const,
label: "Fat",
min: 0,
max: 1,
step: 0.01,
left: "Base",
right: "Heavy",
},
]);
function clamp(v: number, lo: number, hi: number): number {
if (!Number.isFinite(v)) return lo;
return Math.max(lo, Math.min(hi, v));
}
/** Normalize a partial body bag. Missing keys become neutral. */
export function normalizeBodyStyle(raw: unknown): BodyStyle {
if (!raw || typeof raw !== "object") {
return { ...BODY_STYLE_DEFAULTS };
}
const o = raw as Record<string, unknown>;
// Accept packed wire form { m, u, f } as well as full names.
const mass = o.mass ?? o.m;
const muscle = o.muscle ?? o.u;
const fat = o.fat ?? o.f;
return {
mass: clamp(Number(mass ?? 0), -1, 1),
muscle: clamp(Number(muscle ?? 0), -1, 1),
fat: clamp(Number(fat ?? 0), 0, 1),
};
}
export function isDefaultBodyStyle(style: BodyStyle): boolean {
const s = normalizeBodyStyle(style);
return s.mass === 0 && s.muscle === 0 && s.fat === 0;
}
/**
* Ease unit strength so mid-slider stays mild and the last third punches
* harder into caricature (leaner / bulkier / more cut / fatter).
*/
function easeExtreme(t: number): number {
const a = clamp(t, 0, 1);
return a * 0.35 + a * a * 0.25 + a * a * a * 0.4;
}
function shapedSigned(v: number): number {
if (v === 0) return 0;
return Math.sign(v) * easeExtreme(Math.abs(v));
}
/** Multipliers applied to loft half-widths (≈1 at neutral). */
export interface Physique {
bulk: number;
waistF: number;
shoulderF: number;
chestF: number;
armF: number;
legF: number;
headF: number;
style: BodyStyle;
}
/**
* Map mass/muscle/fat onto loft physique scalars (ludus physiqueFromBodyStyle).
* Deterministic — no rng jitter so the creator UI is stable.
*/
export function physiqueFromBodyStyle(style: BodyStyle | null | undefined): Physique {
const s = normalizeBodyStyle(style);
const mass = shapedSigned(s.mass);
const muscle = shapedSigned(s.muscle);
const fat = easeExtreme(s.fat);
// Peak gains at shaped = ±1 — readable under clothing at 240p.
const bulkBase = 1 + mass * 0.32 + fat * 0.26 + muscle * 0.1;
const waistBase = 1 + fat * 0.48 + mass * 0.2 - muscle * 0.18;
const shoulderBase = 1 + muscle * 0.42 + mass * 0.16 + fat * 0.1;
const chestBase = 1 + muscle * 0.28 + mass * 0.14 + fat * 0.18;
const armBase = 1 + muscle * 0.48 + mass * 0.14 + fat * 0.12;
const legBase = 1 + fat * 0.36 + mass * 0.2 + muscle * 0.16;
const headBase = 1 + mass * 0.06 + fat * 0.05;
return {
bulk: bulkBase,
waistF: Math.max(0.52, waistBase),
shoulderF: Math.max(0.62, shoulderBase),
chestF: Math.max(0.65, chestBase),
armF: Math.max(0.58, armBase),
legF: Math.max(0.6, legBase),
headF: Math.max(0.85, headBase),
style: s,
};
}
/**
* Sex base (per sex) × slider multipliers.
*
* These sit near 1.0 on purpose. Breadth differences between the male and
* female bases live in the width tables in `proportions.ts`, which
* `bodyMetrics` reads directly; anything but ~1 here would discount those a
* second time — which is exactly what the earlier 0.780.86 "slender" bases
* did, taking a third off every limb. What is left here is the girth
* difference breadth alone doesn't capture: limbs and neck.
*/
export function physiqueFor(
body: BodyType,
style: BodyStyle | null | undefined = null,
): Physique {
const female = body === "female";
const base = {
bulk: female ? 0.97 : 1.0,
waistF: female ? 0.98 : 1.0,
shoulderF: female ? 0.96 : 1.0,
chestF: female ? 0.98 : 1.0,
armF: female ? 0.9 : 1.0,
legF: female ? 0.95 : 1.0,
headF: female ? 1.01 : 1.0,
};
const slider = physiqueFromBodyStyle(style);
return {
bulk: base.bulk * slider.bulk,
waistF: base.waistF * slider.waistF,
shoulderF: base.shoulderF * slider.shoulderF,
chestF: base.chestF * slider.chestF,
armF: base.armF * slider.armF,
legF: base.legF * slider.legF,
headF: base.headF * slider.headF,
style: slider.style,
};
}
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env node
/**
* Build modular characters from recipes.
*
* pnpm cast # all presets → assets/characters/
* pnpm creator -- --list # list parts + presets
* pnpm creator -- --id survivor # one recipe
* pnpm creator -- --id survivor --texture # UV + Grok Imagine albedo
* pnpm creator -- --id survivor --texture --force # repaint, replacing the cache
* pnpm creator -- --id survivor --texture --notes "charcoal business suit"
* pnpm creator -- --id survivor --albedo painted.png # import, no Grok
* pnpm creator -- --id survivor --uv-guide # UV guide only (no Grok)
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { textureCharacter } from "../texture/textureCharacter.js";
import { assembleCharacter, formatAssemblyReport } from "./assemble.js";
import { listParts, PART_DEFINITIONS } from "./parts.js";
import { CHARACTER_PRESETS, presetById } from "./presets.js";
const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const OUT_DIR = join(ROOT, "assets", "characters");
function parseArgs(argv: string[]) {
const args = {
list: false,
id: undefined as string | undefined,
help: false,
texture: false,
uvGuide: false,
force: false,
albedo: undefined as string | undefined,
notes: undefined as string | undefined,
};
for (let i = 0; i < argv.length; i++) {
const token = argv[i]!;
if (token === "--") continue;
if (token === "--list" || token === "-l") args.list = true;
else if (token === "--id" || token === "-i") args.id = argv[++i];
else if (token === "--texture" || token === "-t") args.texture = true;
else if (token === "--uv-guide") args.uvGuide = true;
else if (token === "--force" || token === "-f") args.force = true;
else if (token === "--albedo") args.albedo = argv[++i];
else if (token === "--notes" || token === "-n") args.notes = argv[++i];
else if (token === "--help" || token === "-h") args.help = true;
}
return args;
}
function printList(): void {
console.log("Bodies: male | female\n");
console.log("Slots & parts:");
for (const slot of ["hair", "upper", "lower", "feet", "accessory"] as const) {
const parts = listParts(slot);
console.log(` ${slot}`);
for (const part of parts) {
const body = part.body ? ` [${part.body}]` : "";
console.log(` ${part.id.padEnd(22)} ${part.name}${body}`);
}
}
console.log("\nPresets:");
for (const preset of CHARACTER_PRESETS) {
console.log(` ${preset.id.padEnd(16)} ${preset.name} (${preset.body})`);
}
}
async function buildOne(
id: string,
opts: {
texture?: boolean;
uvGuide?: boolean;
force?: boolean;
albedo?: string;
notes?: string;
} = {},
): Promise<void> {
const preset = presetById(id);
if (!preset) {
console.error(`Unknown preset "${id}". Use --list to see options.`);
process.exit(1);
}
// --notes overrides the preset's wardrobe direction for this run, so prompt
// wording can be iterated on without editing presets.ts
const recipe = opts.notes ? { ...preset, styleNotes: opts.notes } : preset;
await mkdir(OUT_DIR, { recursive: true });
if (opts.texture || opts.uvGuide || opts.albedo) {
// Relative to the shell's cwd, which under `pnpm creator` is tools/, not
// the repo root — so say where we looked rather than leaking a bare ENOENT.
const albedoPath = opts.albedo ? resolve(opts.albedo) : undefined;
const importAlbedo = albedoPath
? new Uint8Array(
await readFile(albedoPath).catch(() => {
throw new Error(`Cannot read --albedo image at ${albedoPath}`);
}),
)
: undefined;
const label = importAlbedo
? "texture, imported"
: opts.uvGuide
? "uv-guide"
: opts.force
? "texture, forced"
: "texture";
console.log(`\n→ ${recipe.name} (${recipe.id}) [${label}]`);
const tex = await textureCharacter(recipe, {
outDir: OUT_DIR,
guideOnly: Boolean(opts.uvGuide && !opts.texture && !importAlbedo),
force: Boolean(opts.force),
...(importAlbedo ? { importAlbedo } : {}),
});
const outPath = join(OUT_DIR, `${recipe.id}.glb`);
await writeFile(outPath, tex.glb);
await writeFile(
join(OUT_DIR, `${recipe.id}.recipe.json`),
JSON.stringify({ ...recipe, stats: tex.assembled.stats, textured: tex.textured }, null, 2),
);
console.log(formatAssemblyReport(tex.assembled));
console.log(` uv-guide ${tex.guidePath}`);
if (tex.albedoPath) {
const how = tex.imported ? " (imported)" : tex.cached ? " (cached)" : opts.force ? " (regenerated)" : "";
console.log(` albedo ${tex.albedoPath}${how}`);
}
if (tex.costUsd > 0) console.log(` cost $${tex.costUsd.toFixed(3)}`);
console.log(` output ${outPath} (${(tex.glb.byteLength / 1024).toFixed(1)} KB)${tex.textured ? " textured" : ""}`);
return;
}
const result = assembleCharacter(recipe);
const outPath = join(OUT_DIR, `${recipe.id}.glb`);
await writeFile(outPath, result.glb);
await writeFile(
join(OUT_DIR, `${recipe.id}.recipe.json`),
JSON.stringify(
{
...recipe,
stats: result.stats,
},
null,
2,
),
);
console.log(`\n→ ${recipe.name} (${recipe.id})`);
console.log(formatAssemblyReport(result));
console.log(` output ${outPath} (${(result.glb.byteLength / 1024).toFixed(1)} KB)`);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(`Usage:
pnpm creator -- --list
pnpm creator -- --id survivor
pnpm creator -- --id survivor --uv-guide # bake UV template PNG only
pnpm creator -- --id survivor --texture # UV + Grok Imagine albedo → textured GLB
pnpm creator -- --id survivor --texture --force # ignore the cached albedo and repaint it
pnpm creator -- --id survivor --texture --notes "charcoal three-piece business suit"
pnpm creator -- --id survivor --albedo painted.png # import an albedo, no Grok involved
pnpm cast # build every preset (vertex colour)
`);
return;
}
if (args.list) {
printList();
return;
}
if (args.id) {
await buildOne(args.id, {
texture: args.texture,
uvGuide: args.uvGuide,
force: args.force,
...(args.albedo ? { albedo: args.albedo } : {}),
...(args.notes ? { notes: args.notes } : {}),
});
return;
}
// Default: all presets (vertex colour — texturing is opt-in per id).
await mkdir(OUT_DIR, { recursive: true });
console.log(`Building ${CHARACTER_PRESETS.length} characters → ${OUT_DIR}\n`);
for (const recipe of CHARACTER_PRESETS) {
await buildOne(recipe.id);
}
console.log(`\nDone. ${PART_DEFINITIONS.length} parts available in the catalog.`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { assembleCharacter } from "./assemble.js";
import { buildBaseBody } from "./body.js";
import { applyCoverage, PART } from "./coverage.js";
import { CHARACTER_PRESETS } from "./presets.js";
import { measurementsFor } from "./proportions.js";
import { buildSkeleton } from "../rig/skeleton.js";
import { vertexCount } from "../mesh/MeshBuilder.js";
const palette = CHARACTER_PRESETS[0]!.palette;
function bareMale() {
const skeleton = buildSkeleton(measurementsFor("male"), { height: 1.78 });
return { skeleton, mesh: buildBaseBody(skeleton, "male", palette, null) };
}
describe("body region tags", () => {
it("tags every base body vertex with a region", () => {
const { mesh } = bareMale();
const n = vertexCount(mesh);
expect(mesh.parts.length).toBe(n);
const untagged = mesh.parts.filter((p) => p === PART.NONE).length;
expect(untagged).toBe(0);
// Both arms, both legs, both feet, plus head / neck / torso.
expect(new Set(mesh.parts).size).toBe(9);
});
it("runs t from 0 to 1 along each region", () => {
const { mesh } = bareMale();
for (const part of [PART.TORSO, PART.ARM_L, PART.LEG_R, PART.HEAD]) {
const ts = mesh.ts.filter((_, i) => mesh.parts[i] === part);
expect(Math.min(...ts)).toBeLessThanOrEqual(0.05);
expect(Math.max(...ts)).toBeGreaterThanOrEqual(0.95);
}
});
});
describe("hiding skin under clothing", () => {
it("leaves the body untouched when nothing is worn", () => {
const { mesh } = bareMale();
const result = applyCoverage(mesh, []);
expect(result.mesh).toBe(mesh);
expect(result.dropped).toBe(0);
});
it("drops enclosed triangles and keeps the rest", () => {
const { mesh } = bareMale();
const before = mesh.indices.length / 3;
const result = applyCoverage(mesh, [{ part: PART.TORSO, tMin: -0.01, tMax: 0.97 }]);
expect(result.dropped).toBeGreaterThan(0);
expect(result.kept).toBeGreaterThan(0);
expect(result.kept + result.recessed + result.dropped).toBe(before);
expect(result.mesh.indices.length / 3).toBe(result.kept + result.recessed);
// Nothing outside the claimed region may be dropped: the head still has to
// be a closed surface after a shirt hides the chest.
const heads = result.mesh.parts.filter((p) => p === PART.HEAD).length;
const headsBefore = mesh.parts.filter((p) => p === PART.HEAD).length;
expect(heads).toBe(headsBefore);
});
it("recesses the rim inward rather than dropping it", () => {
const { mesh } = bareMale();
// A band ending mid-torso: the skin around t=0.5 is the collar of the hole.
const result = applyCoverage(mesh, [{ part: PART.TORSO, tMin: -0.01, tMax: 0.5 }], {
recessDepth: 0.02,
});
expect(result.recessed).toBeGreaterThan(0);
// Recessed skin must sit inside the original surface, never outside it.
const radius = (m: typeof mesh, i: number) =>
Math.hypot(m.positions[i * 3]!, m.positions[i * 3 + 2]!);
let moved = 0;
for (let i = 0; i < vertexCount(result.mesh); i++) {
if (result.mesh.parts[i] !== PART.TORSO) continue;
const t = result.mesh.ts[i]!;
if (t < 0.35 || t > 0.5) continue;
const original = mesh.ts.findIndex(
(v, j) => mesh.parts[j] === PART.TORSO && Math.abs(v - t) < 1e-6,
);
if (original < 0) continue;
if (radius(result.mesh, i) < radius(mesh, original) - 1e-4) moved++;
}
expect(moved).toBeGreaterThan(0);
});
it("hides several hundred triangles on a fully dressed preset", () => {
const dressed = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!);
expect(dressed.stats.skinHidden).toBeGreaterThan(200);
// The recess exists so openings read as skin, so it must not be empty
// either — a figure with every rim dropped shows hollow cuffs.
expect(dressed.stats.skinRecessed).toBeGreaterThan(0);
});
});
+282
View File
@@ -0,0 +1,282 @@
import {
createMesh,
padTags,
vertexCount,
type LoftResult,
type MeshData,
} from "../mesh/MeshBuilder.js";
/**
* Hiding skin under clothing.
*
* Ported from Ludus `equipment/hideMasks.js` + `recessField.js`, which in turn
* came from Dreamfall's `bodyHideUnderOutfit`. The idea: don't try to make
* every garment clear the body by padding — padding fails wherever the two
* lofts sample their rings at different heights, and it costs triangles nobody
* ever sees. Instead the garment *claims* the skin it covers, and that skin is
* deleted. The outer mesh then owns the form outright.
*
* Three classes, in Ludus's precedence order:
*
* DROP triangle fully enclosed by a garment — removed from the body
* RECESS triangle covered but within a rim of an opening (collar, cuff,
* hem) — kept, but pushed inward so it reads as skin *inside* the
* opening rather than z-fighting the cloth
* KEEP nothing covers it
*
* Limbs drop rather than recess: a recess deep enough to read on a torso
* punches straight through the far side of a forearm.
*
* Coverage is a claim that nothing shows through. Only closed shells belong in
* a coverage list — an open or banded piece that claims skin leaves you looking
* at the inside of the character.
*/
export const PART = {
NONE: 0,
HEAD: 1,
NECK: 2,
TORSO: 3,
ARM_L: 4,
ARM_R: 5,
LEG_L: 6,
LEG_R: 7,
FOOT_L: 8,
FOOT_R: 9,
} as const;
export type BodyPart = (typeof PART)[keyof typeof PART];
/** Limbs drop outright; only the torso and head recess. */
const LIMB_PARTS = new Set<number>([
PART.ARM_L,
PART.ARM_R,
PART.LEG_L,
PART.LEG_R,
PART.FOOT_L,
PART.FOOT_R,
]);
/** A band of one body region's loft, `tMin`..`tMax` in loft parameter. */
export interface CoverageBand {
part: BodyPart;
tMin: number;
tMax: number;
}
export const CLASS = { KEEP: 0, RECESS: 1, DROP: 2 } as const;
/** Tag a loft's vertices: `t` runs 0 at the first ring to 1 at the last. */
export function tagLoft(mesh: MeshData, result: LoftResult, part: BodyPart): void {
const { firstVertex, rings, sides } = result;
if (rings < 1 || sides < 1) return;
padTags(mesh, firstVertex);
const span = Math.max(1, rings - 1);
for (let ring = 0; ring < rings; ring++) {
const t = ring / span;
for (let s = 0; s < sides; s++) {
const v = firstVertex + ring * sides + s;
mesh.parts[v] = part;
mesh.ts[v] = t;
}
}
}
/** Tag everything added since `from` with one region and a fixed parameter. */
export function tagRange(mesh: MeshData, from: number, part: BodyPart, t: number): void {
padTags(mesh, from);
for (let v = from; v < vertexCount(mesh); v++) {
mesh.parts[v] = part;
mesh.ts[v] = t;
}
}
export interface CoverageOptions {
/**
* How close to the edge of coverage a vertex must be to recess instead of
* drop, in loft parameter. Roughly one ring on the torso.
*/
rimBand?: number;
/** How far inward recessed skin is pushed, in metres. */
recessDepth?: number;
/** Graph-distance rings over which the recess ramps up from the seam. */
recessRings?: number;
}
export interface CoverageResult {
mesh: MeshData;
kept: number;
recessed: number;
dropped: number;
}
/**
* Remove the skin a garment set covers, and recess what sits inside its
* openings. Returns a fresh mesh; the input is untouched.
*/
export function applyCoverage(
body: MeshData,
bands: CoverageBand[],
options: CoverageOptions = {},
): CoverageResult {
const triangles = body.indices.length / 3;
if (bands.length === 0) {
return { mesh: body, kept: triangles, recessed: 0, dropped: 0 };
}
const rimBand = options.rimBand ?? 0.09;
const recessDepth = options.recessDepth ?? 0.014;
const recessRings = options.recessRings ?? 2;
const n = vertexCount(body);
padTags(body, n);
const covers = (part: number, t: number): boolean => {
for (const b of bands) if (part === b.part && t >= b.tMin && t <= b.tMax) return true;
return false;
};
const covered = new Uint8Array(n);
const nearRim = new Uint8Array(n);
const isLimb = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const part = body.parts[i]!;
const t = body.ts[i]!;
isLimb[i] = LIMB_PARTS.has(part) ? 1 : 0;
if (part === PART.NONE || !covers(part, t)) continue;
covered[i] = 1;
// Just inside an opening: coverage would lapse a rim's width away.
if (!covers(part, t - rimBand) || !covers(part, t + rimBand)) nearRim[i] = 1;
}
const classes = new Uint8Array(triangles);
for (let t = 0; t < triangles; t++) {
const a = body.indices[t * 3]!;
const b = body.indices[t * 3 + 1]!;
const c = body.indices[t * 3 + 2]!;
const cov = covered[a]! + covered[b]! + covered[c]!;
if (cov === 0) {
classes[t] = CLASS.KEEP;
continue;
}
const rim = nearRim[a]! || nearRim[b]! || nearRim[c]!;
const limb = isLimb[a]! || isLimb[b]! || isLimb[c]!;
if (cov === 3 && !rim) classes[t] = CLASS.DROP;
else if (limb && !rim) classes[t] = CLASS.DROP;
else classes[t] = CLASS.RECESS;
}
// Vertices touched by kept geometry pin the recess to depth 0, so the
// recessed sheet stays welded to the visible body instead of floating inside
// the collar as a detached shell.
const onKept = new Uint8Array(n);
const inRecess = new Uint8Array(n);
for (let t = 0; t < triangles; t++) {
const target = classes[t] === CLASS.KEEP ? onKept : classes[t] === CLASS.RECESS ? inRecess : null;
if (!target) continue;
target[body.indices[t * 3]!] = 1;
target[body.indices[t * 3 + 1]!] = 1;
target[body.indices[t * 3 + 2]!] = 1;
}
const depth = recessField(body, inRecess, onKept, recessDepth, recessRings);
const out = createMesh();
const remap = new Int32Array(n).fill(-1);
const emit = (v: number): number => {
const existing = remap[v]!;
if (existing >= 0) return existing;
const index = vertexCount(out);
const d = depth[v]!;
out.positions.push(
body.positions[v * 3]! - body.normals[v * 3]! * d,
body.positions[v * 3 + 1]! - body.normals[v * 3 + 1]! * d,
body.positions[v * 3 + 2]! - body.normals[v * 3 + 2]! * d,
);
out.normals.push(body.normals[v * 3]!, body.normals[v * 3 + 1]!, body.normals[v * 3 + 2]!);
out.colors.push(
body.colors[v * 4]!,
body.colors[v * 4 + 1]!,
body.colors[v * 4 + 2]!,
body.colors[v * 4 + 3]!,
);
if (body.uvs.length > 0) out.uvs.push(body.uvs[v * 2] ?? 0, body.uvs[v * 2 + 1] ?? 0);
out.parts.push(body.parts[v]!);
out.ts.push(body.ts[v]!);
remap[v] = index;
return index;
};
let kept = 0;
let recessed = 0;
for (let t = 0; t < triangles; t++) {
if (classes[t] === CLASS.DROP) continue;
if (classes[t] === CLASS.KEEP) kept++;
else recessed++;
out.indices.push(
emit(body.indices[t * 3]!),
emit(body.indices[t * 3 + 1]!),
emit(body.indices[t * 3 + 2]!),
);
}
return { mesh: out, kept, recessed, dropped: triangles - kept - recessed };
}
/**
* Per-vertex recess depth: 0 on any vertex shared with kept surface, ramping to
* full depth over `rings` steps of graph distance. A constant offset instead of
* a ramp gives either a hard step at the collar or a detached sheet.
*
* Graph distance over mesh edges, not geodesic — at this triangle count the
* difference is invisible.
*/
function recessField(
mesh: MeshData,
inRecess: Uint8Array,
boundary: Uint8Array,
depth: number,
rings: number,
): Float32Array {
const n = vertexCount(mesh);
const field = new Float32Array(n);
if (depth <= 0) return field;
const neighbours: number[][] = Array.from({ length: n }, () => []);
for (let t = 0; t < mesh.indices.length; t += 3) {
const a = mesh.indices[t]!;
const b = mesh.indices[t + 1]!;
const c = mesh.indices[t + 2]!;
neighbours[a]!.push(b, c);
neighbours[b]!.push(a, c);
neighbours[c]!.push(a, b);
}
const dist = new Int32Array(n).fill(-1);
const queue: number[] = [];
for (let i = 0; i < n; i++) {
if (boundary[i]) {
dist[i] = 0;
queue.push(i);
}
}
for (let head = 0; head < queue.length; head++) {
const v = queue[head]!;
const d = dist[v]!;
if (d >= rings) continue;
for (const nb of neighbours[v]!) {
if (dist[nb] !== -1) continue;
dist[nb] = d + 1;
queue.push(nb);
}
}
for (let i = 0; i < n; i++) {
if (!inRecess[i]) continue;
// Unreached vertices sit deep inside the covered area: full depth.
const d = dist[i]!;
const u = d === -1 ? 1 : Math.min(1, d / rings);
field[i] = depth * (u * u * (3 - 2 * u));
}
return field;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
craniumFor,
HEAD_STYLE_DEFAULTS,
isDefaultHeadStyle,
normalizeHeadStyle,
} from "./headStyle.js";
describe("headStyle", () => {
it("normalizes missing / packed values", () => {
expect(normalizeHeadStyle(null)).toEqual(HEAD_STYLE_DEFAULTS);
expect(normalizeHeadStyle({ l: 0.5, j: -0.25, b: 0.1 })).toEqual({
length: 0.5,
jaw: -0.25,
brow: 0.1,
});
expect(normalizeHeadStyle({ length: 2, jaw: -3, brow: 9 })).toEqual({
length: 1,
jaw: -1,
brow: 1,
});
expect(isDefaultHeadStyle(normalizeHeadStyle({}))).toBe(true);
});
it("gives the sexes different faces at neutral sliders", () => {
const male = craniumFor("male");
const female = craniumFor("female");
// The taper is the tell: female cheekbones carry more of the width while
// the jaw and chin narrow under them.
expect(female.jawW).toBeLessThan(male.jawW);
expect(female.chinW).toBeLessThan(male.chinW);
expect(female.cheekW).toBeGreaterThan(male.cheekW);
// Softer profile: little brow ridge, a more vertical forehead, smaller nose.
expect(female.browOut).toBeLessThan(male.browOut);
expect(female.foreheadSlope).toBeLessThan(male.foreheadSlope);
expect(female.noseLen).toBeLessThan(male.noseLen);
});
it("trades width against length so a long face doesn't just grow", () => {
const round = craniumFor("male", { length: -1, jaw: 0, brow: 0 });
const long = craniumFor("male", { length: 1, jaw: 0, brow: 0 });
expect(long.faceLengthF).toBeGreaterThan(round.faceLengthF);
expect(long.widthF).toBeLessThan(round.widthF);
});
it("squares the lower face on the jaw slider", () => {
const tapered = craniumFor("male", { length: 0, jaw: -1, brow: 0 });
const square = craniumFor("male", { length: 0, jaw: 1, brow: 0 });
expect(square.jawW).toBeGreaterThan(tapered.jawW);
expect(square.chinW).toBeGreaterThan(tapered.chinW);
});
it("flattens the brow ridge to nothing at the soft end", () => {
const soft = craniumFor("male", { length: 0, jaw: 0, brow: -1 });
const heavy = craniumFor("male", { length: 0, jaw: 0, brow: 1 });
expect(heavy.browOut).toBeGreaterThan(soft.browOut);
expect(heavy.noseOut).toBeGreaterThan(soft.noseOut);
// Drives the depth of the brow *ring* in the head loft, so it has to stay
// non-negative — a negative would invert the ring through the skull.
expect(soft.browOut).toBeLessThan(0.15);
expect(soft.browOut).toBeGreaterThanOrEqual(0);
});
it("keeps the sliders stacked on the sex base, not replacing it", () => {
const male = craniumFor("male", { length: 0, jaw: 1, brow: 0 });
const female = craniumFor("female", { length: 0, jaw: 1, brow: 0 });
// A square-jawed woman still has a narrower jaw than a square-jawed man.
expect(female.jawW).toBeLessThan(male.jawW);
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* Head style — three appearance sliders, the face's answer to `bodyStyle.ts`.
*
* length 1 round/short … +1 long/narrow
* jaw 1 tapered … +1 square/heavy
* brow 1 soft/flat … +1 heavy brow, strong nose
*
* Three axes are enough because a PSX head has three things a viewer can read
* at 240p: the skull's proportion, the shape of the lower face, and the
* profile. Eye colour and freckles are the texture's job; these move geometry.
*
* The sliders sit on top of a per-sex face base — see {@link craniumFor}. That
* base is where the male and female heads actually differ, so a neutral
* character of each sex already reads as a different person rather than the
* same skull scaled by a couple of percent.
*/
import type { BodyType } from "./types.js";
export interface HeadStyle {
/** 1 round & short … +1 long & narrow */
length: number;
/** 1 tapered chin … +1 square, heavy jaw */
jaw: number;
/** 1 smooth profile … +1 heavy brow and nose */
brow: number;
}
export const HEAD_STYLE_DEFAULTS: HeadStyle = Object.freeze({
length: 0,
jaw: 0,
brow: 0,
});
/** Slider metadata for the creator UI. */
export const HEAD_STYLE_SLIDERS = Object.freeze([
{
id: "length" as const,
label: "Face length",
min: -1,
max: 1,
step: 0.01,
left: "Round",
right: "Long",
},
{
id: "jaw" as const,
label: "Jaw",
min: -1,
max: 1,
step: 0.01,
left: "Tapered",
right: "Square",
},
{
id: "brow" as const,
label: "Brow",
min: -1,
max: 1,
step: 0.01,
left: "Soft",
right: "Heavy",
},
]);
function clamp(v: number, lo: number, hi: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(lo, Math.min(hi, v));
}
/** Normalize a partial head bag. Missing keys become neutral. */
export function normalizeHeadStyle(raw: unknown): HeadStyle {
if (!raw || typeof raw !== "object") {
return { ...HEAD_STYLE_DEFAULTS };
}
const o = raw as Record<string, unknown>;
// Accept packed wire form { l, j, b } as well as full names.
const length = o.length ?? o.l;
const jaw = o.jaw ?? o.j;
const brow = o.brow ?? o.b;
return {
length: clamp(Number(length ?? 0), -1, 1),
jaw: clamp(Number(jaw ?? 0), -1, 1),
brow: clamp(Number(brow ?? 0), -1, 1),
};
}
export function isDefaultHeadStyle(style: HeadStyle): boolean {
const s = normalizeHeadStyle(style);
return s.length === 0 && s.jaw === 0 && s.brow === 0;
}
/**
* Ease unit strength so mid-slider stays mild and the last third pushes into
* caricature. Same curve as the body sliders, so the two panels feel alike.
*/
function easeExtreme(t: number): number {
const a = clamp(t, 0, 1);
return a * 0.35 + a * a * 0.25 + a * a * a * 0.4;
}
function shapedSigned(v: number): number {
if (v === 0) return 0;
return Math.sign(v) * easeExtreme(Math.abs(v));
}
/**
* Skull shape scalars. Widths are fractions of the head's half-width, so
* `jawW: 0.92` is a jaw ring 92% as wide as the cheekbones; the rest are
* multipliers on the base head dimensions (≈1 on the male base).
*/
export interface Cranium {
/** Multipliers on head half-width / half-depth / chin-to-crown height. */
widthF: number;
depthF: number;
faceLengthF: number;
/** Chin ring: breadth, depth, and how far it juts forward. */
chinW: number;
chinD: number;
chinOut: number;
/** Jaw-angle ring — the widest point of the lower face. */
jawW: number;
jawD: number;
/** Cheekbone breadth, the head's widest ring. */
cheekW: number;
/** Brow ridge size and projection. Near 0 = no ridge; the bar is dropped. */
browOut: number;
/** 0 = vertical forehead … 1+ = cranium drifting back over the occiput. */
foreheadSlope: number;
/** Nose length (down), projection (forward) and breadth (across). */
noseLen: number;
noseOut: number;
noseW: number;
/** Ear scale. */
earSize: number;
style: HeadStyle;
}
/**
* The sex face bases.
*
* Male numbers reproduce the head `body.ts` drew before the sliders existed, so
* a default male is unchanged. The female base is the actual edit: cheekbones
* carry the width while the jaw and chin narrow under them (the tapered read),
* the brow ridge nearly disappears, the forehead stands up rather than sloping
* back, and the nose comes down in all three axes.
*/
function faceBase(body: BodyType): Omit<Cranium, "style"> {
if (body === "female") {
return {
widthF: 0.985,
depthF: 0.965,
faceLengthF: 0.955,
chinW: 0.42,
chinD: 0.58,
chinOut: 0.28,
jawW: 0.83,
jawD: 0.86,
cheekW: 1.03,
browOut: 0.4,
foreheadSlope: 0.55,
noseLen: 0.86,
noseOut: 0.84,
noseW: 0.85,
earSize: 0.92,
};
}
return {
widthF: 1,
depthF: 1,
faceLengthF: 1,
chinW: 0.5,
chinD: 0.62,
chinOut: 0.3,
jawW: 0.92,
jawD: 0.9,
cheekW: 1,
browOut: 1,
foreheadSlope: 1,
noseLen: 1,
noseOut: 1,
noseW: 1,
earSize: 1,
};
}
/**
* Sex face base × slider multipliers.
*
* Peak gains are tuned to stay inside a believable adult skull: the head never
* changes size much (that is the body's `headF`), it changes *proportion*.
*/
export function craniumFor(
body: BodyType,
style: HeadStyle | null | undefined = null,
): Cranium {
const s = normalizeHeadStyle(style);
const len = shapedSigned(s.length);
const jaw = shapedSigned(s.jaw);
const brow = shapedSigned(s.brow);
const base = faceBase(body);
return {
// A long face is also a narrow one — otherwise the head just grows.
widthF: base.widthF * (1 - len * 0.075),
depthF: base.depthF * (1 + len * 0.045),
faceLengthF: base.faceLengthF * (1 + len * 0.17),
chinW: base.chinW * (1 + jaw * 0.44),
chinD: base.chinD * (1 + jaw * 0.14),
chinOut: base.chinOut * (1 + jaw * 0.18),
jawW: base.jawW * (1 + jaw * 0.19),
jawD: base.jawD * (1 + jaw * 0.12),
// Cheeks widen slightly with the jaw and narrow with a long face, so the
// taper from cheekbone to chin stays legible at both ends of both sliders.
cheekW: base.cheekW * (1 + jaw * 0.04) * (1 - len * 0.04),
browOut: Math.max(0, base.browOut * (1 + brow * 0.95)),
foreheadSlope: Math.max(0, base.foreheadSlope * (1 + brow * 0.45)),
noseLen: base.noseLen * (1 + brow * 0.24),
noseOut: base.noseOut * (1 + brow * 0.34),
noseW: base.noseW * (1 + brow * 0.16),
earSize: base.earSize,
style: s,
};
}
+436
View File
@@ -0,0 +1,436 @@
import type { CanonicalBone } from "@psx/shared";
import { jointWorld, type Skeleton } from "../rig/skeleton.js";
import type { LoftKey, Vec3Tuple } from "../mesh/MeshBuilder.js";
import { physiqueFor, type BodyStyle, type Physique } from "./bodyStyle.js";
import { craniumFor, type Cranium, type HeadStyle } from "./headStyle.js";
import { measurementsFor } from "./proportions.js";
import type { BodyType } from "./types.js";
/**
* Every dimension of a figure, in metres, derived once.
*
* Before this existed, `body.ts` and `parts.ts` each carried their own magic
* half-widths scaled off `height / 1.72`, and clothing fitted the body only
* because both files had been nudged in tandem. Sizes now come from one place:
* skin builds at the metrics, garments build at `padded(metrics, thickness)`.
* A jacket is the body plus 12 mm of cloth, which is what a jacket is.
*
* Widths come from {@link measurementsFor} (fractions of height); depths and
* the offsets a front-view photo cannot show — how far the glutes sit aft of
* the hip axis, how far the calf sits behind the shin — are anatomical
* constants applied here.
*/
/** Elliptical cross-section: half-width across the ring, half-depth through it. */
export interface Section {
rx: number;
rz: number;
}
const sec = (rx: number, rz: number): Section => ({ rx, rz });
export interface BodyMetrics {
height: number;
female: boolean;
physique: Physique;
/** Joint lookup, so callers don't re-import the skeleton helpers. */
at: (bone: CanonicalBone) => Vec3Tuple;
// --- torso levels (world Y) ---
crotchY: number;
gluteY: number;
hipY: number;
waistY: number;
ribY: number;
chestY: number;
girdleY: number;
collarY: number;
neckTopY: number;
// --- torso sections ---
crotch: Section;
glute: Section;
hip: Section;
waist: Section;
rib: Section;
chest: Section;
girdle: Section;
collar: Section;
neck: Section;
/** Z offsets from the spine line: glutes aft, belly and sternum forward. */
buttZ: number;
bellyZ: number;
chestZ: number;
/** Breast form (female). `out` is projection past the chest wall. */
bust: { r: number; x: number; y: number; z: number; out: number };
/** Pectoral shelf (male) — a flat pad, not a muscle. */
pec: { rx: number; rz: number; x: number; y: number; z: number };
// --- arm ---
clavX: number;
socketX: number;
deltoidR: number;
upperArm: Section;
elbow: Section;
foreArm: Section;
wrist: Section;
/** Palm: rx is thickness (knuckle to palm), rz is breadth. */
palm: Section;
handLength: number;
thumbR: number;
// --- leg ---
hipSocket: Section;
thigh: Section;
knee: Section;
calf: Section;
ankle: Section;
/** Calf mass sits behind the shin axis. */
calfZ: number;
// --- foot ---
footHalfW: number;
footLength: number;
heelBack: number;
ankleHeight: number;
// --- head ---
headHalfW: number;
headHalfD: number;
chinY: number;
jawY: number;
mouthY: number;
browY: number;
crownY: number;
headZ: number;
/**
* Face shape scalars — sex base × head sliders. The dimensions above already
* have the size terms baked in; this carries the *proportions* the head loft
* needs (how wide the jaw is against the cheekbones, how far the brow juts).
*/
cranium: Cranium;
}
export function bodyMetrics(
skeleton: Skeleton,
body: BodyType,
bodyStyle: BodyStyle | null = null,
headStyle: HeadStyle | null = null,
): BodyMetrics {
const h = skeleton.height;
const female = body === "female";
const phy = physiqueFor(body, bodyStyle);
const cranium = craniumFor(body, headStyle);
const { bulk, waistF, shoulderF, chestF, armF, legF, headF } = phy;
const w = measurementsFor(body).widths;
const at = (bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone);
// Half-breadths in metres before physique.
const halfShoulder = (w.shoulder / 2) * h;
const halfChest = (w.chest / 2) * h;
const halfWaist = (w.waist / 2) * h;
const halfHip = (w.hip / 2) * h;
const halfHead = (w.head / 2) * h;
const halfNeck = (w.neck / 2) * h;
const hips = at("Hips");
const spine = at("Spine");
const spine1 = at("Spine1");
const spine2 = at("Spine2");
const neckJoint = at("Neck");
const headJoint = at("Head");
const shoulderJoint = at("LeftArm");
// Levels. The skeleton already carries the landmark heights; these name the
// ones the loft needs and that no bone sits on.
const crotchY = hips[1] - h * (female ? 0.033 : 0.031);
const gluteY = hips[1] - h * 0.012;
const hipY = hips[1] + h * 0.012;
const waistY = spine[1] + (spine1[1] - spine[1]) * 0.35;
const ribY = spine1[1] + (spine2[1] - spine1[1]) * 0.45;
const chestY = spine2[1] - h * 0.005;
// The Neck bone is C7 — the base of the neck, level with the shoulders — so
// the collar ring sits *above* the girdle and the trapezius slopes between.
const girdleY = shoulderJoint[1];
const collarY = neckJoint[1] + h * 0.008;
// Head size and proportion, needed here because the neck ends inside the jaw.
const headHalfW = halfHead * headF * cranium.widthF;
const chinY = h - h * 0.132 * headF * cranium.faceLengthF;
// Root ring of the head loft — under the jaw, buried in the neck.
const headBottomY = chinY - headHalfW * 0.16;
// The neck stops just inside that ring. Pinning it to the Head joint instead
// (as this did before the face sliders) leaves it fixed while a round face
// lifts the chin: past about 0.5 on the length slider the head floats off
// and the neck's end cap shows as a disk under a detached jaw.
const neckTopY = headBottomY + h * 0.011;
// Trunk at shoulder level stops short of the acromion; the deltoids carry
// the silhouette out the rest of the way.
const girdleRx = halfShoulder * 0.78 * bulk * shoulderF;
const deltoidR = h * 0.031 * Math.sqrt(bulk) * (0.72 + 0.34 * armF);
const chestRx = halfChest * bulk * chestF;
const waistRx = halfWaist * bulk * waistF;
const hipRx = halfHip * bulk * (female ? 1 : 0.98);
return {
height: h,
female,
physique: phy,
at,
crotchY,
gluteY,
hipY,
waistY,
ribY,
chestY,
girdleY,
collarY,
neckTopY,
// The crotch ring is the saddle between the thighs, not a hip section.
crotch: sec(hipRx * 0.4, hipRx * 0.5),
glute: sec(hipRx * 0.94, hipRx * (female ? 0.86 : 0.8)),
hip: sec(hipRx, hipRx * 0.78),
waist: sec(waistRx, waistRx * 0.76),
rib: sec(chestRx * 0.93, chestRx * 0.74),
chest: sec(chestRx, chestRx * (female ? 0.76 : 0.79)),
girdle: sec(girdleRx, girdleRx * 0.62),
// Tight to the neck: this is the torso's top ring, and its end cap is only
// hidden if the neck loft is wider than it at that height.
collar: sec(halfNeck * 1.12 * bulk, halfNeck * 1.24 * bulk),
neck: sec(halfNeck * bulk, halfNeck * 1.14 * bulk),
buttZ: -h * (female ? 0.02 : 0.016),
bellyZ: h * (0.004 + phy.style.fat * 0.016),
chestZ: h * 0.008,
bust: {
r: h * (female ? 0.032 : 0) * (0.88 + 0.24 * chestF),
x: halfChest * 0.46,
y: chestY + h * 0.006,
z: spine2[2] + chestRx * 0.6,
out: h * 0.026,
},
// Only emerges past the chest wall on a muscular slider; at rest it stays
// buried and the chest keeps its flat PSX plane.
pec: {
rx: chestRx * 0.56,
rz: h * 0.03 * Math.max(0, phy.style.muscle),
x: chestRx * 0.42,
y: chestY + h * 0.016,
z: spine2[2] + chestRx * 0.66,
},
clavX: halfShoulder * 0.3,
socketX: Math.abs(shoulderJoint[0]),
deltoidR,
upperArm: sec((w.upperArm / 2) * h * armF * bulk, (w.upperArm / 2) * h * armF * bulk * 0.94),
elbow: sec((w.forearm / 2) * h * armF * bulk * 1.02, (w.forearm / 2) * h * armF * bulk * 0.98),
foreArm: sec((w.forearm / 2) * h * armF * bulk, (w.forearm / 2) * h * armF * bulk * 0.9),
wrist: sec(h * 0.016 * armF, h * 0.02 * armF),
palm: sec(h * 0.011 * armF, h * 0.026 * armF),
handLength: h * 0.095,
thumbR: h * 0.011,
hipSocket: sec(hipRx * 0.58, hipRx * 0.6),
thigh: sec((w.thigh / 2) * h * legF * bulk, (w.thigh / 2) * h * legF * bulk * 0.92),
knee: sec(h * 0.031 * legF, h * 0.033 * legF),
calf: sec((w.calf / 2) * h * legF * bulk, (w.calf / 2) * h * legF * bulk * 1.08),
ankle: sec(h * 0.021 * legF, h * 0.026 * legF),
calfZ: -h * 0.009,
footHalfW: (w.foot / 2) * h,
footLength: h * 0.152,
heelBack: h * 0.038,
ankleHeight: h * 0.045,
// Size comes from the body (`headF`), proportion from the face sliders.
// Levels are measured down from the crown, so a longer face drops the chin
// rather than lifting the skull off the neck.
headHalfW,
headHalfD: halfHead * headF * cranium.depthF * 1.26,
chinY,
jawY: h - h * 0.105 * headF * cranium.faceLengthF,
mouthY: h - h * 0.098 * headF * cranium.faceLengthF,
browY: h - h * 0.056 * headF * cranium.faceLengthF,
crownY: h,
headZ: headJoint[2],
cranium,
};
}
/**
* The torso ring plan, crotch (t=0) to collar (t=1).
*
* Shared by the skin and by every torso garment. A garment that invents its own
* key list will sooner or later skip a section the body has — the labcoat that
* went hem → hip skipped the glutes, and the trousers underneath poked through
* the back of it. Take the body's plan, grow it by cloth, cut it at the hem.
*
* The `t` values here are the coverage parameters garments cite in
* `parts.ts`: waist 0.4, chest 0.7, girdle 0.87.
*/
export function torsoPlan(m: BodyMetrics): LoftKey[] {
const h = m.height;
const hips = m.at("Hips");
const spine = m.at("Spine");
const spine1 = m.at("Spine1");
const spine2 = m.at("Spine2");
const neck = m.at("Neck");
return [
// Saddle between the thighs. Narrow and tucked forward so the crotch closes
// instead of showing daylight between two leg tubes.
{ t: 0.0, c: [0, m.crotchY, hips[2] + h * 0.008], rx: m.crotch.rx, rz: m.crotch.rz },
// Glutes — the widest part of the figure from behind, set aft of the axis.
{ t: 0.08, c: [0, m.gluteY, hips[2] + m.buttZ], rx: m.glute.rx, rz: m.glute.rz },
// Hip shelf / iliac crest.
{ t: 0.17, c: [0, m.hipY, hips[2] + m.buttZ * 0.4], rx: m.hip.rx, rz: m.hip.rz },
// Lower abdomen.
{
t: 0.28,
c: [0, (m.hipY + m.waistY) * 0.5, spine[2] + m.bellyZ],
rx: (m.hip.rx + m.waist.rx) * 0.5,
rz: (m.hip.rz + m.waist.rz) * 0.52,
},
// Waist — narrowest, and on the female base the whole silhouette turns here.
{ t: 0.4, c: [0, m.waistY, spine[2] + m.bellyZ * 0.8], rx: m.waist.rx, rz: m.waist.rz },
// Lower ribs.
{ t: 0.55, c: [0, m.ribY, spine1[2] + m.chestZ * 0.7], rx: m.rib.rx, rz: m.rib.rz },
// Chest at nipple line.
{ t: 0.7, c: [0, m.chestY, spine2[2] + m.chestZ], rx: m.chest.rx, rz: m.chest.rz },
// Clavicle shelf: flattens front-to-back before the shoulders.
{
t: 0.83,
c: [0, m.chestY + (m.girdleY - m.chestY) * 0.72, spine2[2] + m.chestZ * 0.6],
rx: (m.chest.rx + m.girdle.rx) * 0.5,
rz: (m.chest.rz + m.girdle.rz) * 0.5,
},
// Shoulder girdle — trunk only; the deltoids extend past this.
{ t: 0.87, c: [0, m.girdleY, neck[2] + h * 0.004], rx: m.girdle.rx, rz: m.girdle.rz },
// Trapezius. Two keys close together, or the slope from acromion to neck
// reads as one hard step — a square coat hanger instead of a shoulder.
{
t: 0.94,
c: [0, m.girdleY + (m.collarY - m.girdleY) * 0.45, neck[2] + h * 0.005],
rx: m.girdle.rx * 0.7,
rz: m.girdle.rz * 0.82,
},
{ t: 1.0, c: [0, m.collarY, neck[2] + h * 0.006], rx: m.collar.rx, rz: m.collar.rz },
];
}
/**
* Cut a plan off below `hemY`, replacing everything under it with one ring
* interpolated at that height. `t` is renormalised so the result still runs
* 0..1 — coverage bands are quoted against the *body's* plan, so they are read
* before this is applied.
*/
export function cutBelow(keys: LoftKey[], hemY: number): LoftKey[] {
const above = keys.filter((k) => k.c[1] > hemY);
if (above.length === keys.length || above.length < 2) return keys;
const first = above[0]!;
const below = keys[keys.length - above.length - 1]!;
const span = first.c[1] - below.c[1];
const f = span > 1e-6 ? (hemY - below.c[1]) / span : 0;
const mix = (a: number, b: number) => a + (b - a) * f;
const hem: LoftKey = {
t: 0,
c: [0, hemY, mix(below.c[2], first.c[2])],
rx: mix(below.rx, first.rx),
rz: mix(below.rz, first.rz),
};
const t0 = first.t;
return [
hem,
...above.map((k) => ({ ...k, t: (k.t - t0) / (1 - t0) })),
];
}
/**
* Replace the bottom of a plan with an open, flared hem.
*
* A coat is not a body: taking the torso plan all the way down inherits the
* crotch saddle, and the coat tapers to a point between the legs instead of
* hanging as a tube. Cut at the hips, then drop one wide ring to the hem.
*/
export function withHem(
keys: LoftKey[],
hemY: number,
hem: Section,
hemZ: number,
share = 0.2,
): LoftKey[] {
const above = keys.filter((k) => k.c[1] > hemY);
if (above.length < 2) return keys;
const t0 = above[0]!.t;
const rescale = (t: number) => share + ((t - t0) / (1 - t0)) * (1 - share);
return [
{ t: 0, c: [0, hemY, hemZ], rx: hem.rx, rz: hem.rz },
...above.map((k) => ({ ...k, t: rescale(k.t) })),
];
}
/** As {@link cutBelow}, from the other end: keep everything under `waistY`. */
export function cutAbove(keys: LoftKey[], waistY: number): LoftKey[] {
const below = keys.filter((k) => k.c[1] < waistY);
if (below.length === keys.length || below.length < 2) return keys;
const last = below[below.length - 1]!;
const above = keys[below.length]!;
const span = above.c[1] - last.c[1];
const f = span > 1e-6 ? (waistY - last.c[1]) / span : 0;
const mix = (a: number, b: number) => a + (b - a) * f;
const band: LoftKey = {
t: 1,
c: [0, waistY, mix(last.c[2], above.c[2])],
rx: mix(last.rx, above.rx),
rz: mix(last.rz, above.rz),
};
const t1 = last.t + (above.t - last.t) * f;
return [...below.map((k) => ({ ...k, t: k.t / t1 })), band];
}
/**
* The same figure, grown by `pad` metres of cloth.
*
* Garments loft on these so they clear the skin by a known thickness instead of
* a percentage — a 12 mm jacket is 12 mm at every scale, and a heavy coat over
* a heavy body still clears it.
*/
export function padded(metrics: BodyMetrics, pad: number): BodyMetrics {
const grow = (s: Section): Section => sec(s.rx + pad, s.rz + pad);
return {
...metrics,
crotch: grow(metrics.crotch),
glute: grow(metrics.glute),
hip: grow(metrics.hip),
waist: grow(metrics.waist),
rib: grow(metrics.rib),
chest: grow(metrics.chest),
girdle: grow(metrics.girdle),
collar: grow(metrics.collar),
neck: grow(metrics.neck),
deltoidR: metrics.deltoidR + pad,
upperArm: grow(metrics.upperArm),
elbow: grow(metrics.elbow),
foreArm: grow(metrics.foreArm),
wrist: grow(metrics.wrist),
hipSocket: grow(metrics.hipSocket),
thigh: grow(metrics.thigh),
knee: grow(metrics.knee),
calf: grow(metrics.calf),
ankle: grow(metrics.ankle),
footHalfW: metrics.footHalfW + pad,
};
}
+850
View File
@@ -0,0 +1,850 @@
import type { CanonicalBone } from "@psx/shared";
import type { Rgb } from "../image/Raster.js";
import {
add,
computeFlatNormals,
createMesh,
cross,
domeShell,
dot,
lerp,
loft,
normalise,
orientedBox,
scale,
sub,
tube,
tubePath,
type DomeShellOptions,
type LoftKey,
type MeshData,
type Vec3Tuple,
} from "../mesh/MeshBuilder.js";
import { jointWorld, type Skeleton } from "../rig/skeleton.js";
import { headLandmarks } from "./body.js";
import { PART, type CoverageBand } from "./coverage.js";
import { bodyMetrics, cutAbove, cutBelow, padded, torsoPlan, withHem } from "./metrics.js";
import type { PartContext, PartDefinition } from "./types.js";
/**
* Outfit part builders. Each returns a mesh in bind pose on the shared
* skeleton; the assembler merges them with the base body and re-skins once.
*
* Clothing is lofted like the body (ludus keyframes) — slightly larger, 46
* sides for a PS1 / FF8 angular read.
*/
const TORSO_SIDES = 8;
const LIMB_SIDES = 6;
/** Ring frames pinned across the body, matching the skin loft. */
const ACROSS: Vec3Tuple = [1, 0, 0];
const at = (skeleton: Skeleton, bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone);
const finish = (mesh: MeshData): MeshData => {
computeFlatNormals(mesh);
return mesh;
};
// --- hair ------------------------------------------------------------------
function hairLandmarks(ctx: PartContext) {
const { skeleton, body, headStyle } = ctx;
const hl = headLandmarks(skeleton, body, headStyle);
const { head, neck, crown, brow, browFwd, occiput, headW, headD, height: h } = hl;
const pad = 1.14;
const skullAxis = normalise(sub(crown, head));
const face: Vec3Tuple = [0, 0, 1];
const hairFrom = add(
lerp(head, crown, 0.5),
add(scale(skullAxis, headW * 0.04), scale(face, headW * 0.03)),
);
const hairTo = add(crown, scale(skullAxis, headW * 0.12));
const halfW = headW * pad;
// Depth tracks the real skull rather than breadth: the face sliders trade one
// against the other, so a long head is narrower *and* deeper, and a cap sized
// off width alone would let the occiput through the back of the hair.
const halfD = headD * pad * 0.778;
return {
h,
head,
neck,
headHalf: headW,
crown,
brow,
browFwd,
occiput,
hairFrom,
hairTo,
halfW,
halfD,
centreZ: (brow[2] + occiput[2]) * 0.5,
hairlineY: hairFrom[1],
crownY: hairTo[1],
browFwdZ: browFwd[2],
capHeight: Math.hypot(hairTo[0] - hairFrom[0], hairTo[1] - hairFrom[1], hairTo[2] - hairFrom[2]),
};
}
function baseCapOptions(style: "short" | "long" | "bun" | "undercut" | "messy"): DomeShellOptions {
const faceDir: Vec3Tuple = [0, 0, 1];
const faceCut = { height: 0.62, halfWidth: 0.62, depth: 0.12 };
const common = {
sections: 4,
sides: 8,
faceDir,
faceCut,
frontScale: 0.9,
frontScaleTop: 0.68,
backScale: 1.12,
topScale: 0.82,
};
if (style === "long") return { ...common, backScale: 1.2, topScale: 0.84, faceCut: { ...faceCut, halfWidth: 0.58 } };
if (style === "bun") return { ...common, topScale: 0.78 };
if (style === "undercut") return { ...common, faceCut: { ...faceCut, height: 0.68 }, topScale: 0.8 };
return common;
}
type BackScalpLength = "crop" | "nape" | "shoulder";
function capFrame(lm: ReturnType<typeof hairLandmarks>, cap: DomeShellOptions) {
const from = lm.hairFrom;
const to = lm.hairTo;
const axisN = normalise(sub(to, from));
const faceWanted = cap.faceDir ?? ([0, 0, 1] as Vec3Tuple);
let face = sub(faceWanted, scale(axisN, dot(faceWanted, axisN)));
if (Math.hypot(face[0], face[1], face[2]) < 1e-5) {
const alt: Vec3Tuple = Math.abs(axisN[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
face = sub(alt, scale(axisN, dot(alt, axisN)));
}
face = normalise(face);
const side = normalise(cross(axisN, face));
const hw = lm.halfW;
const hd = lm.halfD;
const backScale = cap.backScale ?? 1.12;
const ringPoint = (cos: number, sin: number): Vec3Tuple => {
const depthScale = sin >= 0 ? (cap.frontScale ?? 0.9) : backScale;
return add(from, add(scale(side, -cos * hw), scale(face, sin * hd * depthScale)));
};
const inv = Math.SQRT1_2;
return {
axisN,
face,
side,
ringCentre: from,
backCentre: ringPoint(0, -1),
backLeft: ringPoint(inv, -inv),
backRight: ringPoint(-inv, -inv),
hw,
hd,
backScale,
};
}
function addBackScalp(
mesh: MeshData,
ctx: PartContext,
lm: ReturnType<typeof hairLandmarks>,
color: Rgb,
length: BackScalpLength,
cap: DomeShellOptions,
): void {
const { headHalf: skinW, neck } = lm;
const spine2 = at(ctx.skeleton, "Spine2");
const spine1 = at(ctx.skeleton, "Spine1");
const frame = capFrame(lm, cap);
const { axisN, face, backCentre, backLeft, backRight } = frame;
const seamHalfW =
0.5 *
Math.hypot(backLeft[0] - backRight[0], backLeft[1] - backRight[1], backLeft[2] - backRight[2]);
const depthRatio = length === "crop" ? 0.4 : length === "nape" ? 0.42 : 0.45;
const tubeHalfD = seamHalfW * depthRatio;
const sinkIn = scale(face, tubeHalfD * 0.92);
const seamOverlap = scale(axisN, skinW * 0.04);
const backTop = add(add(backCentre, seamOverlap), sinkIn);
const seamLeft = add(add(backLeft, seamOverlap), sinkIn);
const seamRight = add(add(backRight, seamOverlap), sinkIn);
const backMid = add(backTop, add(scale(axisN, -skinW * 0.55), scale(face, skinW * 0.05)));
let end: Vec3Tuple;
let widths: number[];
if (length === "crop") {
end = add(backMid, add(scale(axisN, -skinW * 0.4), scale(face, skinW * 0.03)));
widths = [seamHalfW, seamHalfW * 0.92, seamHalfW * 0.72];
} else if (length === "nape") {
end = add([0, neck[1] - skinW * 0.1, neck[2] - skinW * 0.12] as Vec3Tuple, sinkIn);
widths = [seamHalfW, seamHalfW * 0.88, seamHalfW * 0.58];
} else {
const shoulderY = Math.min(spine2[1], spine1[1] + (spine2[1] - spine1[1]) * 0.35);
end = add(
[0, shoulderY - skinW * 0.05, spine2[2] - skinW * 0.22] as Vec3Tuple,
scale(face, tubeHalfD * 0.5),
);
widths = [seamHalfW, seamHalfW * 0.9, seamHalfW * 0.65, seamHalfW * 0.48];
}
if (length === "shoulder") {
const nape = add(
[backTop[0], neck[1] - skinW * 0.05, neck[2] - skinW * 0.16] as Vec3Tuple,
scale(face, tubeHalfD * 0.7),
);
tubePath(mesh, [backTop, backMid, nape, end], widths, depthRatio, color, {
sides: 6,
sectionsPerSegment: 2,
});
for (const [s, corner] of [[-1, seamLeft], [1, seamRight]] as const) {
tubePath(
mesh,
[
corner,
[s * seamHalfW * 0.9, nape[1], nape[2] + skinW * 0.04],
[s * seamHalfW * 0.65, end[1] + skinW * 0.05, end[2] + skinW * 0.05],
],
[skinW * 0.24, skinW * 0.22, skinW * 0.15],
0.7,
color,
{ sides: 5, sectionsPerSegment: 2 },
);
}
} else {
tubePath(mesh, [backTop, backMid, end], widths, depthRatio, color, {
sides: 6,
sectionsPerSegment: 2,
});
}
orientedBox(
mesh,
backTop,
seamHalfW * 0.95,
skinW * 0.08,
tubeHalfD * 0.9,
normalise(add(scale(face, -1), scale(axisN, -0.12))),
color,
);
}
function addSideburns(
mesh: MeshData,
lm: ReturnType<typeof hairLandmarks>,
color: Rgb,
lengthScale: number,
thicknessScale = 1,
): void {
const drop = lm.headHalf * (0.55 + lengthScale * 0.45);
const yTop = lm.hairlineY - lm.headHalf * 0.02;
const yBot = yTop - drop;
const z = lm.centreZ - lm.headHalf * 0.05;
const x = lm.halfW * 0.9;
const tw = lm.headHalf * 0.16 * thicknessScale;
const td = lm.headHalf * 0.2 * thicknessScale;
for (const side of [-1, 1] as const) {
tube(
mesh,
[side * x, yTop, z],
[side * x * 0.95, yBot, z - lm.headHalf * 0.05],
tw,
tw * 0.85,
td / Math.max(tw, 1e-6),
color,
{ sides: 5, sections: 1, caps: true },
);
}
}
function hairCap(ctx: PartContext, style: "short" | "long" | "bun" | "undercut" | "messy"): MeshData {
const mesh = createMesh();
const color = ctx.color ?? ctx.palette.hair;
const lm = hairLandmarks(ctx);
const cap = baseCapOptions(style);
domeShell(mesh, lm.hairFrom, lm.hairTo, lm.halfW, lm.halfD, color, cap);
const cutTop = lerp(lm.hairFrom, lm.hairTo, 0.55);
orientedBox(
mesh,
add(cutTop, [0, lm.headHalf * 0.02, lm.headHalf * 0.05]),
lm.halfW * 0.55,
lm.headHalf * 0.06,
lm.headHalf * 0.08,
[0, 0.35, 1],
color,
);
for (const s of [-1, 1] as const) {
orientedBox(
mesh,
add(lm.hairFrom, [s * lm.halfW * 0.55, lm.headHalf * 0.04, lm.headHalf * 0.08]),
lm.headHalf * 0.14,
lm.headHalf * 0.16,
lm.headHalf * 0.1,
[s * 0.15, 0.2, 1],
color,
);
}
const backLength: BackScalpLength =
style === "long" ? "shoulder" : style === "messy" || style === "bun" ? "nape" : "crop";
addBackScalp(mesh, ctx, lm, color, backLength, cap);
if (style === "short") addSideburns(mesh, lm, color, 0.35, 0.9);
if (style === "long") addSideburns(mesh, lm, color, 0.55, 1.05);
if (style === "messy") {
addSideburns(mesh, lm, color, 0.45, 1.0);
for (const t of [
{ c: add(lm.crown, [0, lm.headHalf * 0.06, lm.headHalf * 0.04]), hr: lm.headHalf * 0.38, hu: lm.headHalf * 0.2, hf: lm.headHalf * 0.28, dir: [0, 1, 0.2] as Vec3Tuple },
{ c: add(lm.crown, [-lm.halfW * 0.35, -lm.headHalf * 0.02, -lm.headHalf * 0.08]), hr: lm.headHalf * 0.28, hu: lm.headHalf * 0.2, hf: lm.headHalf * 0.22, dir: [-0.4, 0.9, -0.1] as Vec3Tuple },
{ c: add(lm.crown, [lm.halfW * 0.32, 0, -lm.headHalf * 0.12]), hr: lm.headHalf * 0.26, hu: lm.headHalf * 0.22, hf: lm.headHalf * 0.2, dir: [0.35, 1, -0.2] as Vec3Tuple },
]) {
orientedBox(mesh, t.c, t.hr, t.hu, t.hf, t.dir, color);
}
}
if (style === "bun") {
const bunCentre: Vec3Tuple = [0, lm.crown[1] - lm.headHalf * 0.02, lm.occiput[2] - lm.headHalf * 0.1];
tube(
mesh,
[0, lm.crown[1] - lm.headHalf * 0.1, lm.centreZ - lm.halfD * 0.2],
bunCentre,
lm.headHalf * 0.22,
lm.headHalf * 0.32,
1,
color,
{ sides: 6, sections: 1 },
);
orientedBox(mesh, bunCentre, lm.headHalf * 0.42, lm.headHalf * 0.35, lm.headHalf * 0.38, [0, 0.4, -1], color);
}
if (style === "undercut") {
orientedBox(
mesh,
add(lm.crown, [0, -lm.headHalf * 0.02, lm.headHalf * 0.02]),
lm.halfW * 0.5,
lm.headHalf * 0.12,
lm.headHalf * 0.26,
[0, 0.55, 0.3],
color,
);
}
return finish(mesh);
}
// --- upper body clothing ---------------------------------------------------
function upperGarment(
ctx: PartContext,
kind: "shirt" | "jacket" | "coat" | "labcoat" | "uniform" | "workcoat",
): MeshData {
const mesh = createMesh();
const { skeleton, palette, body, bodyStyle } = ctx;
const h = skeleton.height;
const color = ctx.color ?? palette.primary;
const trim = palette.secondary;
const long = kind === "coat" || kind === "labcoat";
const heavy = kind === "jacket" || kind === "workcoat" || kind === "uniform";
// Cloth thickness in metres, not a percentage: a jacket clears the skin by
// the same 2 cm on a heavy body as on a lean one.
const cloth = h * (long ? 0.014 : heavy ? 0.011 : 0.007);
const skin = bodyMetrics(skeleton, body, bodyStyle);
const m = padded(skin, cloth);
// Body's ring plan, grown by cloth and cut at the hem — a garment that
// invents its own key list eventually skips a section the body has.
const plan = torsoPlan(m);
const torsoKeys = long
? withHem(
plan,
m.crotchY - h * 0.075,
// Wide enough to hang clear of a flared skirt underneath.
{ rx: m.hip.rx * 1.28, rz: m.hip.rz * 1.22 },
m.at("Hips")[2] + m.buttZ * 0.3,
)
: cutBelow(plan, m.waistY - h * 0.05);
// Ring count matters more here than on the skin: the garment only clears the
// body if its chords sit outside the body's, so the shoulder slope needs
// rings on both sides of the trapezius key, not a single chord across it.
loft(mesh, torsoKeys, color, { sides: TORSO_SIDES, rings: 17, caps: true, startRight: ACROSS });
// Sleeves — the skin arm loft, stopped at the cuff.
for (const prefix of ["Left", "Right"] as const) {
const side = prefix === "Left" ? 1 : -1;
const shoulder = m.at(`${prefix}Shoulder` as CanonicalBone);
const arm = m.at(`${prefix}Arm` as CanonicalBone);
const foreArm = m.at(`${prefix}ForeArm` as CanonicalBone);
const hand = m.at(`${prefix}Hand` as CanonicalBone);
const sleeveEnd = kind === "shirt" ? lerp(foreArm, hand, 0.55) : lerp(foreArm, hand, 0.92);
const down = normalise(sub(hand, arm));
const sleeveKeys: LoftKey[] = [
{
t: 0.0,
c: [side * m.clavX, shoulder[1] - h * 0.012, shoulder[2]],
rx: m.deltoidR * 0.94,
rz: m.deltoidR,
},
{
// Same t as the skin's deltoid key so both lofts peak on a ring.
t: 0.1,
c: [side * m.socketX, arm[1] + h * 0.002, arm[2]],
rx: m.deltoidR,
rz: m.deltoidR * 1.04,
},
{
t: 0.22,
c: add(arm, scale(down, h * 0.045)),
rx: m.upperArm.rx * 1.06,
rz: m.upperArm.rz * 1.06,
},
{ t: 0.45, c: lerp(arm, foreArm, 0.62), rx: m.upperArm.rx * 0.94, rz: m.upperArm.rz * 0.94 },
{ t: 0.6, c: foreArm, rx: m.elbow.rx, rz: m.elbow.rz },
{ t: 0.8, c: lerp(foreArm, sleeveEnd, 0.6), rx: m.foreArm.rx * 0.95, rz: m.foreArm.rz * 0.95 },
{ t: 1.0, c: sleeveEnd, rx: m.wrist.rx * 1.3, rz: m.wrist.rz * 1.2 },
];
loft(mesh, sleeveKeys, color, {
sides: LIMB_SIDES,
rings: 11,
capStart: false,
capEnd: true,
startRight: ACROSS,
});
// Cuff accent
loft(
mesh,
[
{ t: 0, c: lerp(sleeveEnd, foreArm, 0.1), rx: m.wrist.rx * 1.36, rz: m.wrist.rz * 1.26 },
{ t: 1, c: sleeveEnd, rx: m.wrist.rx * 1.38, rz: m.wrist.rz * 1.28 },
],
trim,
{ sides: LIMB_SIDES, rings: 2, caps: true, startRight: ACROSS },
);
}
// Low stand collar (not a brick tower)
if (kind !== "shirt") {
const neck = m.at("Neck");
loft(
mesh,
[
{ t: 0, c: [0, m.collarY, neck[2] + h * 0.005], rx: m.neck.rx * 1.2, rz: m.neck.rz * 1.18 },
{
t: 1,
c: [0, m.collarY + h * 0.03, neck[2] + h * 0.008],
rx: m.neck.rx * 1.26,
rz: m.neck.rz * 1.22,
},
],
trim,
{ sides: 6, rings: 2, caps: true, startRight: ACROSS },
);
}
return finish(mesh);
}
function lowerGarment(ctx: PartContext, kind: "pants" | "cargo" | "skirt"): MeshData {
const mesh = createMesh();
const { skeleton, palette, body, bodyStyle } = ctx;
const h = skeleton.height;
const color = ctx.color ?? palette.secondary;
const cloth = h * (kind === "cargo" ? 0.011 : 0.008);
const m = padded(bodyMetrics(skeleton, body, bodyStyle), cloth);
const hips = at(skeleton, "Hips");
const spine = at(skeleton, "Spine");
const waistbandY = m.waistY + h * 0.01;
if (kind === "skirt") {
// Full A-line: waistband → hip shelf → past the crotch → knee hem. It must
// wrap the whole pelvis, or the uncapped leg roots show as open tubes.
const kneeY = at(skeleton, "LeftLeg")[1] + h * 0.02;
loft(
mesh,
[
{ t: 0.0, c: [0, waistbandY, spine[2] + m.bellyZ], rx: m.waist.rx, rz: m.waist.rz },
{
t: 0.24,
c: [0, m.hipY, hips[2] + m.buttZ * 0.4],
rx: m.hip.rx * 1.04,
rz: m.hip.rz * 1.06,
},
// Below the crotch — the hem is already clear of the leg roots here.
{
t: 0.52,
c: [0, m.crotchY - h * 0.05, hips[2]],
rx: m.hip.rx * 1.16,
rz: m.hip.rz * 1.12,
},
{ t: 1.0, c: [0, kneeY, hips[2]], rx: m.hip.rx * 1.34, rz: m.hip.rz * 1.24 },
],
color,
{ sides: TORSO_SIDES, rings: 8, caps: true, startRight: ACROSS },
);
// Waist sash
loft(
mesh,
[
{ t: 0, c: [0, waistbandY - h * 0.012, spine[2] + m.bellyZ], rx: m.waist.rx * 1.03, rz: m.waist.rz * 1.03 },
{ t: 1, c: [0, waistbandY + h * 0.018, spine[2] + m.bellyZ], rx: m.waist.rx * 0.99, rz: m.waist.rz * 0.99 },
],
palette.primary,
{ sides: TORSO_SIDES, rings: 3, caps: true, startRight: ACROSS },
);
return finish(mesh);
}
// Pants: a hip yoke on the body's own ring plan, cut at the waistband, then
// two legs whose roots stay buried inside it.
loft(
mesh,
cutAbove(torsoPlan(m), waistbandY),
color,
// Rings no denser than the layer above: an inner layer that samples a
// curve more finely than an outer one bulges through it at the glutes.
{ sides: TORSO_SIDES, rings: 6, caps: true, startRight: ACROSS },
);
for (const prefix of ["Left", "Right"] as const) {
const side = prefix === "Left" ? 1 : -1;
const upLeg = at(skeleton, `${prefix}UpLeg` as CanonicalBone);
const leg = at(skeleton, `${prefix}Leg` as CanonicalBone);
const foot = at(skeleton, `${prefix}Foot` as CanonicalBone);
const legX = Math.abs(upLeg[0]);
const hemY = lerp(leg, foot, 0.9);
const legKeys: LoftKey[] = [
// Buried in the yoke.
{
t: 0.0,
c: [side * legX * 0.6, m.hipY + h * 0.012, hips[2] + m.buttZ * 0.5],
rx: m.hipSocket.rx,
rz: m.hipSocket.rz,
},
{
t: 0.1,
c: [side * legX * 0.95, m.crotchY + h * 0.01, upLeg[2] - h * 0.004],
rx: m.thigh.rx * 1.1,
rz: m.thigh.rz * 1.12,
},
{ t: 0.26, c: [side * legX, upLeg[1] - h * 0.03, upLeg[2]], rx: m.thigh.rx, rz: m.thigh.rz },
{ t: 0.45, c: lerp(upLeg, leg, 0.78), rx: m.thigh.rx * 0.84, rz: m.thigh.rz * 0.86 },
{ t: 0.56, c: leg, rx: m.knee.rx * 1.12, rz: m.knee.rz * 1.12 },
{ t: 0.72, c: add(lerp(leg, foot, 0.26), [0, 0, m.calfZ]), rx: m.calf.rx, rz: m.calf.rz },
// Hem breaks over the boot rather than shrink-wrapping the ankle.
{ t: 1.0, c: hemY, rx: m.calf.rx * 0.86, rz: m.calf.rz * 0.86 },
];
loft(mesh, legKeys, color, {
sides: LIMB_SIDES,
rings: 11,
capStart: false,
capEnd: true,
startRight: ACROSS,
});
}
return finish(mesh);
}
function footwear(ctx: PartContext, kind: "boots" | "shoes"): MeshData {
const mesh = createMesh();
const { skeleton, palette, body, bodyStyle } = ctx;
const h = skeleton.height;
const color = ctx.color ?? palette.leather;
const sole = palette.secondary;
const m = padded(bodyMetrics(skeleton, body, bodyStyle), h * 0.008);
const soleLift = h * 0.012;
for (const prefix of ["Left", "Right"] as const) {
const ankleJoint = at(skeleton, `${prefix}Foot` as CanonicalBone);
const leg = at(skeleton, `${prefix}Leg` as CanonicalBone);
const x = ankleJoint[0];
const z = ankleJoint[2];
const hw = m.footHalfW;
const heelZ = z - m.heelBack;
const toeZ = z + m.footLength - m.heelBack + h * 0.006;
// Shaft: from the cuff down over the ankle, on the skin leg sections.
const top = kind === "boots" ? lerp(leg, ankleJoint, 0.55) : lerp(leg, ankleJoint, 0.86);
loft(
mesh,
[
{ t: 0, c: top, rx: m.calf.rx * 0.9, rz: m.calf.rz * 0.9 },
{ t: 0.6, c: [x, ankleJoint[1] + h * 0.01, z], rx: m.ankle.rx * 1.15, rz: m.ankle.rz * 1.1 },
{ t: 1, c: [x, m.ankleHeight * 0.7, z + h * 0.004], rx: hw * 0.86, rz: m.ankleHeight * 0.66 },
],
color,
{ sides: LIMB_SIDES, rings: kind === "boots" ? 5 : 4, capStart: true, capEnd: false, startRight: ACROSS },
);
// Shell over the foot — the skin foot's ring plan, lifted onto a sole.
loft(
mesh,
[
{ t: 0.0, c: [x, soleLift + m.ankleHeight * 0.62, heelZ], rx: hw * 0.66, rz: m.ankleHeight * 0.62 },
{ t: 0.18, c: [x, soleLift + m.ankleHeight * 0.56, heelZ + m.footLength * 0.1], rx: hw * 0.82, rz: m.ankleHeight * 0.56 },
{ t: 0.4, c: [x, soleLift + m.ankleHeight * 0.66, z + m.footLength * 0.06], rx: hw * 0.9, rz: m.ankleHeight * 0.6 },
{ t: 0.62, c: [x, soleLift + m.ankleHeight * 0.5, z + m.footLength * 0.28], rx: hw * 0.97, rz: m.ankleHeight * 0.48 },
{ t: 0.85, c: [x, soleLift + m.ankleHeight * 0.4, z + m.footLength * 0.5], rx: hw * 1.02, rz: m.ankleHeight * 0.4 },
{ t: 1.0, c: [x, soleLift + m.ankleHeight * 0.32, toeZ], rx: hw * 0.88, rz: m.ankleHeight * 0.32 },
],
color,
{ sides: LIMB_SIDES, rings: 6, caps: true, startRight: ACROSS },
);
// Sole slab, sitting on the floor. Spans heel to toe exactly: run it off
// its own length and it juts out behind the heel, where the shin has a
// claim on the weights and a stride shears it into a blade.
orientedBox(
mesh,
[x, soleLift * 0.5, (heelZ + toeZ) * 0.5],
hw * 1.02,
soleLift * 0.5,
(toeZ - heelZ) * 0.5,
[0, 0, 1],
sole,
);
}
return finish(mesh);
}
function accessory(ctx: PartContext, kind: "backpack" | "belt" | "glasses" | "satchel"): MeshData {
const mesh = createMesh();
const { skeleton, palette } = ctx;
const h = skeleton.height;
const s = h / 1.72;
const color = ctx.color ?? palette.accent;
if (kind === "backpack") {
const spine2 = at(skeleton, "Spine2");
const spine1 = at(skeleton, "Spine1");
const centre = lerp(spine1, spine2, 0.5);
loft(
mesh,
[
{
t: 0,
c: [centre[0], centre[1] - h * 0.06, centre[2] - h * 0.065],
rx: 0.065 * s,
rz: 0.05 * s,
},
{
t: 0.5,
c: [centre[0], centre[1], centre[2] - h * 0.07],
rx: 0.072 * s,
rz: 0.055 * s,
},
{
t: 1,
c: [centre[0], centre[1] + h * 0.08, centre[2] - h * 0.06],
rx: 0.068 * s,
rz: 0.052 * s,
},
],
color,
{ sides: 5, rings: 4, caps: true },
);
for (const side of [-1, 1]) {
loft(
mesh,
[
{
t: 0,
c: [side * h * 0.07, spine2[1] + h * 0.01, spine2[2]],
rx: 0.008 * s,
rz: 0.008 * s,
},
{
t: 1,
c: [side * h * 0.07, spine1[1] - h * 0.01, spine1[2] - h * 0.04],
rx: 0.008 * s,
rz: 0.008 * s,
},
],
palette.leather,
{ sides: 4, rings: 2, caps: true },
);
}
}
if (kind === "belt") {
// Rides on the hip shelf, over whatever the lower slot put there.
const m = padded(bodyMetrics(skeleton, ctx.body, ctx.bodyStyle), h * 0.014);
const beltY = m.hipY - h * 0.005;
loft(
mesh,
[
{ t: 0, c: [0, beltY, m.at("Hips")[2] + m.buttZ * 0.4], rx: m.hip.rx, rz: m.hip.rz },
{
t: 1,
c: [0, beltY + h * 0.026, m.at("Hips")[2] + m.buttZ * 0.35],
rx: m.hip.rx * 0.98,
rz: m.hip.rz * 0.98,
},
],
color,
{ sides: TORSO_SIDES, rings: 2, caps: true, startRight: ACROSS },
);
const buckleZ = m.at("Hips")[2] + m.hip.rz + h * 0.004;
loft(
mesh,
[
{ t: 0, c: [0, beltY + h * 0.004, buckleZ], rx: h * 0.018, rz: h * 0.008 },
{ t: 1, c: [0, beltY + h * 0.022, buckleZ], rx: h * 0.016, rz: h * 0.008 },
],
palette.metal,
{ sides: 4, rings: 2, caps: true, startRight: ACROSS },
);
}
if (kind === "glasses") {
const hl = headLandmarks(ctx.skeleton, ctx.body, ctx.headStyle);
const y = hl.brow[1] - hl.headW * 0.12;
const z = hl.browFwd[2] + hl.headW * 0.05;
const eyeX = hl.headW * 0.32;
for (const side of [-1, 1]) {
orientedBox(
mesh,
[side * eyeX, y, z],
hl.headW * 0.2,
hl.headW * 0.14,
hl.headW * 0.07,
[0, 0, 1],
palette.metal,
);
}
tube(
mesh,
[-eyeX * 0.55, y, z],
[eyeX * 0.55, y, z],
h * 0.004,
h * 0.004,
1,
palette.metal,
{ sides: 4, sections: 1 },
);
}
if (kind === "satchel") {
const hip = at(skeleton, "Hips");
loft(
mesh,
[
{
t: 0,
c: [h * 0.12, hip[1] - h * 0.02, 0],
rx: 0.04 * s,
rz: 0.025 * s,
},
{
t: 1,
c: [h * 0.12, hip[1] - h * 0.09, 0],
rx: 0.045 * s,
rz: 0.028 * s,
},
],
color,
{ sides: 5, rings: 3, caps: true },
);
loft(
mesh,
[
{ t: 0, c: [0, hip[1] + h * 0.12, 0], rx: 0.007 * s, rz: 0.007 * s },
{ t: 1, c: [h * 0.12, hip[1], 0], rx: 0.007 * s, rz: 0.007 * s },
],
palette.leather,
{ sides: 4, rings: 2, caps: true },
);
}
return finish(mesh);
}
// --- coverage ---------------------------------------------------------------
/**
* Skin each closed garment encloses, in body loft parameters.
*
* The numbers are read off the key lists in `body.ts` — the torso loft runs
* crotch 0 → waist 0.4 → chest 0.7 → collar 1, arms deltoid 0.1 → elbow 0.52 →
* wrist 0.86 → fingertip 1, legs hip 0 → knee 0.52 → ankle 1. Bands stop a
* little short of each opening so a rim of skin recesses inside the cuff or
* collar instead of ending on a hard cloth edge.
*
* Hair claims nothing: the caps are open-fronted, and hiding the scalp under
* one shows straight through the face cut.
*/
const bothArms = (tMin: number, tMax: number): CoverageBand[] => [
{ part: PART.ARM_L, tMin, tMax },
{ part: PART.ARM_R, tMin, tMax },
];
const bothLegs = (tMin: number, tMax: number): CoverageBand[] => [
{ part: PART.LEG_L, tMin, tMax },
{ part: PART.LEG_R, tMin, tMax },
];
const bothFeet: CoverageBand[] = [
{ part: PART.FOOT_L, tMin: -0.01, tMax: 1.01 },
{ part: PART.FOOT_R, tMin: -0.01, tMax: 1.01 },
];
/** Waist-hem tops: torso from below the hem up to the collar, sleeve to cuff. */
const TORSO_TO_COLLAR = (hemT: number): CoverageBand[] => [
{ part: PART.TORSO, tMin: hemT, tMax: 0.97 },
];
const SHIRT_COVER: CoverageBand[] = [...TORSO_TO_COLLAR(0.3), ...bothArms(-0.01, 0.62)];
const JACKET_COVER: CoverageBand[] = [...TORSO_TO_COLLAR(0.28), ...bothArms(-0.01, 0.76)];
// Hem falls past the crotch, so the whole trunk goes.
const COAT_COVER: CoverageBand[] = [
{ part: PART.TORSO, tMin: -0.01, tMax: 0.97 },
...bothArms(-0.01, 0.76),
...bothLegs(-0.01, 0.3),
];
const PANTS_COVER: CoverageBand[] = [
{ part: PART.TORSO, tMin: -0.01, tMax: 0.38 },
...bothLegs(-0.01, 0.9),
];
const SKIRT_COVER: CoverageBand[] = [
{ part: PART.TORSO, tMin: -0.01, tMax: 0.38 },
...bothLegs(-0.01, 0.24),
];
const BOOTS_COVER: CoverageBand[] = [...bothFeet, ...bothLegs(0.86, 1.01)];
const SHOES_COVER: CoverageBand[] = [...bothFeet, ...bothLegs(0.97, 1.01)];
// --- catalog registration --------------------------------------------------
export const PART_DEFINITIONS: PartDefinition[] = [
// Hair
{ id: "hair.none", slot: "hair", name: "None", build: () => createMesh() },
{ id: "hair.short", slot: "hair", name: "Short", build: (c) => hairCap(c, "short") },
{ id: "hair.long", slot: "hair", name: "Long", build: (c) => hairCap(c, "long") },
{ id: "hair.bun", slot: "hair", name: "Bun", build: (c) => hairCap(c, "bun") },
{ id: "hair.undercut", slot: "hair", name: "Undercut", build: (c) => hairCap(c, "undercut") },
{ id: "hair.messy", slot: "hair", name: "Messy", build: (c) => hairCap(c, "messy") },
// Upper
{ id: "upper.none", slot: "upper", name: "None", build: () => createMesh() },
{ id: "upper.shirt", slot: "upper", name: "Shirt", build: (c) => upperGarment(c, "shirt"), coverage: SHIRT_COVER },
{ id: "upper.jacket", slot: "upper", name: "Jacket", build: (c) => upperGarment(c, "jacket"), coverage: JACKET_COVER },
{ id: "upper.coat", slot: "upper", name: "Coat", build: (c) => upperGarment(c, "coat"), coverage: COAT_COVER },
{ id: "upper.labcoat", slot: "upper", name: "Lab Coat", build: (c) => upperGarment(c, "labcoat"), coverage: COAT_COVER },
{ id: "upper.uniform", slot: "upper", name: "Uniform", build: (c) => upperGarment(c, "uniform"), coverage: JACKET_COVER },
{ id: "upper.workcoat", slot: "upper", name: "Work Coat", build: (c) => upperGarment(c, "workcoat"), coverage: JACKET_COVER },
// Lower
{ id: "lower.none", slot: "lower", name: "None", build: () => createMesh() },
{ id: "lower.pants", slot: "lower", name: "Pants", build: (c) => lowerGarment(c, "pants"), coverage: PANTS_COVER },
{ id: "lower.cargo", slot: "lower", name: "Cargo Pants", build: (c) => lowerGarment(c, "cargo"), coverage: PANTS_COVER },
{ id: "lower.skirt", slot: "lower", name: "Skirt", body: "female", build: (c) => lowerGarment(c, "skirt"), coverage: SKIRT_COVER },
// Feet
{ id: "feet.none", slot: "feet", name: "None", build: () => createMesh() },
{ id: "feet.boots", slot: "feet", name: "Boots", build: (c) => footwear(c, "boots"), coverage: BOOTS_COVER },
{ id: "feet.shoes", slot: "feet", name: "Shoes", build: (c) => footwear(c, "shoes"), coverage: SHOES_COVER },
// Accessories
{ id: "accessory.none", slot: "accessory", name: "None", build: () => createMesh() },
{ id: "accessory.backpack", slot: "accessory", name: "Backpack", build: (c) => accessory(c, "backpack") },
{ id: "accessory.belt", slot: "accessory", name: "Belt", build: (c) => accessory(c, "belt") },
{ id: "accessory.glasses", slot: "accessory", name: "Glasses", build: (c) => accessory(c, "glasses") },
{ id: "accessory.satchel", slot: "accessory", name: "Satchel", build: (c) => accessory(c, "satchel") },
];
export const PART_CATALOGUE: ReadonlyMap<string, PartDefinition> = new Map(
PART_DEFINITIONS.map((part) => [part.id, part]),
);
export function listParts(slot?: PartDefinition["slot"]): PartDefinition[] {
return PART_DEFINITIONS.filter((part) => (slot ? part.slot === slot : true) && part.id !== `${part.slot}.none`);
}
+197
View File
@@ -0,0 +1,197 @@
import type { Rgb } from "../image/Raster.js";
import { SKIN } from "./body.js";
import type { CharacterRecipe, CreatorPalette } from "./types.js";
const rgb = (r: number, g: number, b: number): Rgb => ({ r, g, b });
function palette(partial: Partial<CreatorPalette> & Pick<CreatorPalette, "skin" | "hair">): CreatorPalette {
return {
primary: rgb(70, 75, 55),
secondary: rgb(45, 48, 52),
accent: rgb(120, 100, 60),
metal: rgb(140, 140, 145),
leather: rgb(70, 50, 35),
...partial,
};
}
/**
* Game cast as modular outfits. Add a recipe, run `pnpm cast`, get a GLB.
*/
export const CHARACTER_PRESETS: CharacterRecipe[] = [
{
id: "survivor",
name: "Survivor",
body: "male",
height: 1.72,
// Wiry survivor — lean, a bit of muscle.
bodyStyle: { mass: -0.25, muscle: 0.2, fat: 0.05 },
// Gaunt and long-featured — the face the player looks at most.
headStyle: { length: 0.45, jaw: 0.15, brow: 0.3 },
styleNotes:
"olive drab field jacket over a grey tee, dirt-worn canvas cargo trousers, " +
"scuffed brown leather boots, dried mud at the hems, stubble",
palette: palette({
skin: SKIN.medium,
hair: rgb(40, 36, 32),
primary: rgb(78, 82, 52),
secondary: rgb(48, 50, 55),
accent: rgb(90, 70, 40),
leather: rgb(85, 60, 40),
}),
parts: {
hair: "hair.short",
upper: "upper.jacket",
lower: "lower.cargo",
feet: "feet.boots",
accessory: "accessory.backpack",
},
},
{
id: "researcher",
name: "Dr. Hale",
body: "female",
height: 1.66,
bodyStyle: { mass: -0.15, muscle: -0.1, fat: 0.05 },
// Fine-boned, neutral length — reads academic next to the others.
headStyle: { length: 0.2, jaw: -0.35, brow: -0.2 },
styleNotes:
"clean off-white lab coat over a navy blouse and slim charcoal trousers, " +
"plain black flats, wire-rimmed glasses, no weathering",
palette: palette({
skin: SKIN.fair,
hair: rgb(200, 190, 175),
primary: rgb(230, 228, 220),
secondary: rgb(50, 52, 65),
accent: rgb(80, 90, 110),
leather: rgb(60, 50, 45),
}),
parts: {
hair: "hair.bun",
upper: "upper.labcoat",
lower: "lower.pants",
feet: "feet.shoes",
accessory: "accessory.glasses",
},
partColors: {
"upper.labcoat": rgb(235, 233, 225),
},
},
{
id: "officer",
name: "Sgt. Marrow",
body: "male",
height: 1.82,
// Built cop — heavier mass + muscle.
bodyStyle: { mass: 0.35, muscle: 0.45, fat: 0.1 },
// Slab of a head: short, square, heavy brow.
headStyle: { length: -0.35, jaw: 0.8, brow: 0.65 },
styleNotes:
"dark slate police uniform, brass buttons and a gold shoulder flash, " +
"black duty belt, polished black boots, crisp and pressed",
palette: palette({
skin: SKIN.tan,
hair: rgb(28, 28, 30),
primary: rgb(38, 50, 46),
secondary: rgb(32, 36, 40),
accent: rgb(160, 140, 50),
leather: rgb(35, 30, 28),
}),
parts: {
hair: "hair.undercut",
upper: "upper.uniform",
lower: "lower.pants",
feet: "feet.boots",
accessory: "accessory.belt",
},
},
{
id: "groundskeeper",
name: "Ellis",
body: "male",
height: 1.75,
// Soft bulk from outdoor work / beer.
bodyStyle: { mass: 0.2, muscle: 0.1, fat: 0.35 },
// Round and jowly to match the soft bulk.
headStyle: { length: -0.5, jaw: 0.3, brow: 0.1 },
styleNotes:
"faded rust-brown work coat, heavy tan canvas trousers, oil stains at the " +
"knees and cuffs, worn steel-toe boots",
palette: palette({
skin: SKIN.tan,
hair: rgb(90, 70, 50),
primary: rgb(120, 78, 48),
secondary: rgb(70, 72, 78),
accent: rgb(100, 80, 50),
leather: rgb(55, 45, 35),
}),
parts: {
hair: "hair.messy",
upper: "upper.workcoat",
lower: "lower.cargo",
feet: "feet.boots",
accessory: "accessory.satchel",
},
},
// Extra starter outfits — ready for new NPCs / player variants.
{
id: "civilian-f",
name: "Civilian",
body: "female",
height: 1.64,
bodyStyle: { mass: 0, muscle: -0.05, fat: 0.1 },
headStyle: { length: -0.15, jaw: 0.1, brow: 0.15 },
styleNotes:
"muted plum wool overcoat, dark knee-length skirt, black tights, simple " +
"low-heeled shoes, everyday and unremarkable",
palette: palette({
skin: SKIN.light,
hair: rgb(55, 35, 28),
primary: rgb(90, 70, 95),
secondary: rgb(50, 48, 55),
leather: rgb(80, 55, 45),
}),
parts: {
hair: "hair.long",
upper: "upper.coat",
lower: "lower.skirt",
feet: "feet.shoes",
accessory: "accessory.none",
},
},
{
id: "civilian-m",
name: "Townsman",
body: "male",
height: 1.76,
bodyStyle: { mass: 0.05, muscle: 0, fat: 0.15 },
headStyle: { length: 0.1, jaw: -0.2, brow: -0.35 },
styleNotes:
"charcoal two-piece business suit, white dress shirt, burgundy tie, " +
"polished black oxfords, slightly rumpled after a long day",
palette: palette({
skin: SKIN.light,
hair: rgb(70, 60, 50),
primary: rgb(95, 90, 85),
secondary: rgb(55, 55, 60),
leather: rgb(65, 48, 38),
}),
parts: {
hair: "hair.short",
upper: "upper.shirt",
lower: "lower.pants",
feet: "feet.shoes",
accessory: "accessory.belt",
},
},
];
export function presetById(id: string): CharacterRecipe | undefined {
return CHARACTER_PRESETS.find((preset) => preset.id === id);
}
+104
View File
@@ -0,0 +1,104 @@
import type { Measurements } from "../image/analyze.js";
import type { BodyType } from "./types.js";
/**
* Authored body proportions for the two base meshes.
*
* These are the single source of truth for figure shape: `buildSkeleton` reads
* the landmarks, `bodyMetrics` reads the widths, and every garment sizes itself
* off the metrics. Change a number here and the skin, the clothes and the rig
* all move together.
*
* Values are fractions of total height — landmarks measured **down from the
* crown**, widths as **full breadth** (so `hip: 0.19` on a 1.78 m figure is a
* 34 cm hip). They sit close to standard adult anthropometry (a 7.5-head
* figure) rather than the FF8 lanky read the first pass used: the art
* direction in `tools/fixtures/survivor-reference.jpg` is grounded
* survival-horror, where a stick figure reads as a child at 240p.
*
* The stylisation lives elsewhere — low ring counts, hard facets, flat colour.
* The proportions themselves stay honest so silhouettes read at distance and
* animation retargeting behaves.
*/
function landmark(y: number) {
return { y, source: "derived" as const };
}
/** Adult male — 7.5 heads, moderate shoulder V, real limb girth. */
export const MALE_MEASUREMENTS: Measurements = {
pixelHeight: 1000,
pixelWidth: 440,
landmarks: {
// Neck base sits at the trapezius line, not under the jaw — the old 0.12
// gave a swan neck that made the head look shrunken.
neck: landmark(0.175),
shoulder: landmark(0.182),
chest: landmark(0.26),
waist: landmark(0.37),
hip: landmark(0.48),
crotch: landmark(0.52),
knee: landmark(0.715),
ankle: landmark(0.955),
},
widths: {
head: 0.087,
neck: 0.062,
// Biacromial (bone). The deltoids push the silhouette out to ~0.26.
shoulder: 0.245,
chest: 0.174,
waist: 0.155,
hip: 0.191,
thigh: 0.098,
calf: 0.068,
foot: 0.055,
upperArm: 0.058,
forearm: 0.05,
},
// Half the arm span: centreline to fingertip. Vitruvian — span ≈ height.
armReach: 0.51,
stance: 0.048,
aspect: 2.4,
};
/** Adult female — narrower shoulders, higher and narrower waist, wider hips. */
export const FEMALE_MEASUREMENTS: Measurements = {
pixelHeight: 1000,
pixelWidth: 415,
landmarks: {
neck: landmark(0.172),
shoulder: landmark(0.18),
chest: landmark(0.265),
waist: landmark(0.355),
hip: landmark(0.485),
crotch: landmark(0.525),
knee: landmark(0.715),
ankle: landmark(0.955),
},
widths: {
head: 0.084,
neck: 0.055,
shoulder: 0.215,
chest: 0.163,
waist: 0.136,
hip: 0.202,
thigh: 0.1,
calf: 0.062,
foot: 0.05,
upperArm: 0.05,
forearm: 0.043,
},
armReach: 0.49,
stance: 0.044,
aspect: 2.45,
};
export function measurementsFor(body: BodyType): Measurements {
return body === "female" ? FEMALE_MEASUREMENTS : MALE_MEASUREMENTS;
}
/** Default heights when a recipe doesn't set one. */
export const DEFAULT_HEIGHT: Record<BodyType, number> = {
male: 1.78,
female: 1.66,
};
+119
View File
@@ -0,0 +1,119 @@
import type { Rgb } from "../image/Raster.js";
import type { CoverageBand } from "./coverage.js";
import type { MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";
import type { BodyStyle } from "./bodyStyle.js";
import type { HeadStyle } from "./headStyle.js";
/**
* Modular character creator — PSX-budget outfit assembly.
*
* Two base bodies (male / female) share the canonical skeleton. Clothing,
* hair and accessories are separate low-poly parts drawn in code, then merged,
* skinned and exported as one GLB. Swap parts to mint every cast member
* without re-running photo analysis.
*/
export type BodyType = "male" | "female";
export type { BodyStyle, HeadStyle };
/** Equipment slots. Body is always present; the rest are optional overlays. */
export type PartSlot = "hair" | "upper" | "lower" | "feet" | "accessory";
export type PartId = string;
export interface CreatorPalette {
skin: Rgb;
hair: Rgb;
/** Fallback dyes when a part doesn't specify its own colour. */
primary: Rgb;
secondary: Rgb;
accent: Rgb;
metal: Rgb;
leather: Rgb;
}
/** Context handed to every part builder. */
export interface PartContext {
skeleton: Skeleton;
body: BodyType;
palette: CreatorPalette;
/** Mass / muscle / fat sliders (ludus-style). */
bodyStyle: BodyStyle;
/** Face length / jaw / brow sliders. Hair and headgear fit off these. */
headStyle: HeadStyle;
/** Optional per-part colour override. */
color?: Rgb;
}
export type PartBuilder = (ctx: PartContext) => MeshData;
export interface PartDefinition {
id: PartId;
slot: PartSlot;
name: string;
/** If set, only valid on this body type. */
body?: BodyType;
build: PartBuilder;
/**
* Skin this piece encloses, as bands of the body's loft parameters. The
* assembler deletes that skin so the garment owns the form outright.
*
* Only claim what a *closed* shell covers. Claiming skin under an open or
* banded piece shows the inside of the character through the gaps.
*/
coverage?: CoverageBand[];
}
/** A complete character recipe. */
export interface CharacterRecipe {
id: string;
name: string;
body: BodyType;
height: number;
palette: CreatorPalette;
/**
* Physique sliders (ludus mass / muscle / fat).
* Omitted = average build.
*/
bodyStyle?: BodyStyle;
/**
* Face sliders (length / jaw / brow) on top of the sex face base.
* Omitted = the neutral face for this body type.
*/
headStyle?: HeadStyle;
/**
* Free-text art direction for the texture pass — wardrobe, material, wear.
* e.g. "charcoal three-piece business suit, white shirt, burgundy tie,
* polished black oxfords".
*
* Steers the Grok albedo only; it cannot change the silhouette, which is
* fixed by the equipped parts. Ignored when building vertex-colour GLBs.
*
* Part of the texture prompt, so editing it invalidates the cached albedo on
* its own — no need to force a regenerate after changing it.
*/
styleNotes?: string;
/** Part ids keyed by slot. Missing slots = bare for that region. */
parts: Partial<Record<PartSlot, PartId>>;
/** Optional colour overrides keyed by part id. */
partColors?: Record<PartId, Rgb>;
}
export interface AssembledCharacter {
recipe: CharacterRecipe;
skeleton: Skeleton;
mesh: MeshData;
glb: Uint8Array;
stats: {
vertices: number;
triangles: number;
bones: number;
clips: number;
/** Body triangles deleted because a garment enclosed them. */
skinHidden: number;
/** Body triangles kept but pushed inside a collar / cuff opening. */
skinRecessed: number;
};
}
+234
View File
@@ -0,0 +1,234 @@
import { describe, expect, it } from "vitest";
import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { buildDefaultClips } from "../anim/procedural.js";
import { analyzeReference } from "../image/analyze.js";
import { denoise, segmentForeground } from "../image/segment.js";
import { DEFAULT_FIGURE, renderFigure } from "../image/testFixtures.js";
import { buildHumanoidMesh } from "../mesh/humanoid.js";
import { triangleCount, vertexCount } from "../mesh/MeshBuilder.js";
import { buildSkeleton } from "../rig/skeleton.js";
import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "../rig/skin.js";
import { exportGlb } from "./glb.js";
/**
* The exporter is hand-rolled, so "it produced bytes" proves nothing. These
* tests feed the output back through three's own `GLTFLoader` — the exact
* parser the runtime uses — and assert on the reconstructed scene graph.
*/
function buildCharacter() {
const raster = renderFigure(DEFAULT_FIGURE);
const mask = denoise(segmentForeground(raster));
const analysis = analyzeReference(raster, mask);
const skeleton = buildSkeleton(analysis.measurements, { height: 1.7 });
const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette);
computeSkinWeights(mesh, skeleton);
const inverseBindMatrices = computeInverseBindMatrices(skeleton);
const clips = buildDefaultClips();
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name: "TestCharacter", clips });
return { glb, mesh, skeleton, clips, analysis };
}
/** Parses GLB bytes with the real loader. */
function parseGlb(glb: Uint8Array) {
const loader = new GLTFLoader();
const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
return new Promise<import("three/examples/jsm/loaders/GLTFLoader.js").GLTF>((resolve, reject) => {
loader.parse(buffer, "", resolve, reject);
});
}
describe("GLB container", () => {
it("writes a valid header", () => {
const { glb } = buildCharacter();
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
expect(view.getUint32(0, true)).toBe(0x46546c67); // "glTF"
expect(view.getUint32(4, true)).toBe(2);
expect(view.getUint32(8, true)).toBe(glb.byteLength);
});
it("keeps every chunk 4-byte aligned", () => {
const { glb } = buildCharacter();
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
let offset = 12;
while (offset < glb.byteLength) {
const chunkLength = view.getUint32(offset, true);
expect(chunkLength % 4).toBe(0);
offset += 8 + chunkLength;
}
// Chunks must tile the file exactly.
expect(offset).toBe(glb.byteLength);
});
});
describe("GLTFLoader round trip", () => {
it("parses without error", async () => {
const { glb } = buildCharacter();
const gltf = await parseGlb(glb);
expect(gltf.scene).toBeDefined();
});
it("reconstructs a skinned mesh with the expected geometry", async () => {
const { glb, mesh } = buildCharacter();
const gltf = await parseGlb(glb);
let skinned: SkinnedMesh | null = null;
gltf.scene.traverse((object) => {
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
});
expect(skinned).not.toBeNull();
const geometry = skinned!.geometry;
expect(geometry.getAttribute("position").count).toBe(vertexCount(mesh));
expect(geometry.index!.count / 3).toBe(triangleCount(mesh));
// Vertex colours carry the palette; without them the character is white.
expect(geometry.getAttribute("color")).toBeDefined();
expect(geometry.getAttribute("normal")).toBeDefined();
expect(geometry.getAttribute("skinIndex")).toBeDefined();
expect(geometry.getAttribute("skinWeight")).toBeDefined();
});
it("binds all 22 canonical bones", async () => {
const { glb, skeleton } = buildCharacter();
const gltf = await parseGlb(glb);
let skinned: SkinnedMesh | null = null;
gltf.scene.traverse((object) => {
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
});
expect(skinned!.skeleton.bones).toHaveLength(skeleton.joints.length);
const names = skinned!.skeleton.bones.map((bone: Bone) => bone.name);
expect(names).toContain("Hips");
expect(names).toContain("Head");
expect(names).toContain("LeftHand");
expect(names).toContain("RightToeBase");
});
it("preserves the bone hierarchy", async () => {
const { glb } = buildCharacter();
const gltf = await parseGlb(glb);
const bones = new Map<string, Bone>();
gltf.scene.traverse((object) => {
if ((object as Bone).isBone) bones.set(object.name, object as Bone);
});
// A flat list of bones would still load but would not animate correctly.
expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm");
expect(bones.get("LeftLeg")?.parent?.name).toBe("LeftUpLeg");
expect(bones.get("Spine")?.parent?.name).toBe("Hips");
});
it("carries every animation clip with named bone tracks", async () => {
const { glb, clips } = buildCharacter();
const gltf = await parseGlb(glb);
expect(gltf.animations).toHaveLength(clips.length);
const walk = gltf.animations.find((clip: ThreeClip) => clip.name === "Walk");
expect(walk).toBeDefined();
expect(walk!.duration).toBeGreaterThan(0);
expect(walk!.tracks.length).toBeGreaterThan(0);
// Tracks must target bone names, which is what makes clips portable
// between characters produced by the harness.
const targets = walk!.tracks.map((track) => track.name.split(".")[0]);
expect(targets).toContain("LeftUpLeg");
expect(targets).toContain("RightUpLeg");
});
it("keeps the character upright and roughly the requested height", async () => {
const { glb } = buildCharacter();
const gltf = await parseGlb(glb);
let skinned: SkinnedMesh | null = null;
gltf.scene.traverse((object) => {
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
});
skinned!.geometry.computeBoundingBox();
const bbox = skinned!.geometry.boundingBox!;
// Feet near the origin, crown near the target height.
expect(bbox.min.y).toBeGreaterThan(-0.05);
expect(bbox.max.y).toBeGreaterThan(1.5);
expect(bbox.max.y).toBeLessThan(1.85);
// Taller than wide — a figure, not a puddle.
expect(bbox.max.y - bbox.min.y).toBeGreaterThan(bbox.max.x - bbox.min.x);
});
});
describe("skin weights", () => {
it("normalises every vertex to exactly one", () => {
const { mesh } = buildCharacter();
const stats = skinStatistics(mesh);
expect(stats.unweighted).toBe(0);
expect(stats.maxInfluences).toBeLessThanOrEqual(4);
});
it("assigns hand vertices to the hand bone, not the torso", () => {
const { mesh, skeleton } = buildCharacter();
const handIndex = skeleton.index.get("LeftHand")!;
const foreArmIndex = skeleton.index.get("LeftForeArm")!;
const handWorld = skeleton.joints[handIndex]!.world;
// Find the vertex nearest the left hand joint.
let nearest = -1;
let nearestDistance = Infinity;
for (let v = 0; v < vertexCount(mesh); v++) {
const distance = Math.hypot(
mesh.positions[v * 3]! - handWorld[0],
mesh.positions[v * 3 + 1]! - handWorld[1],
mesh.positions[v * 3 + 2]! - handWorld[2],
);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = v;
}
}
const influences = [0, 1, 2, 3].map((i) => ({
joint: mesh.joints[nearest * 4 + i]!,
weight: mesh.weights[nearest * 4 + i]!,
}));
const dominant = influences.reduce((best, entry) => (entry.weight > best.weight ? entry : best));
// Distance-to-joint skinning would let the spine claim this vertex; the
// segment-based version must not.
expect([handIndex, foreArmIndex]).toContain(dominant.joint);
});
it("keeps left-side vertices off right-side bones", () => {
const { mesh, skeleton } = buildCharacter();
const rightBones = new Set(
skeleton.joints
.map((joint, index) => ({ joint, index }))
.filter(({ joint }) => joint.name.startsWith("Right"))
.map(({ index }) => index),
);
let violations = 0;
for (let v = 0; v < vertexCount(mesh); v++) {
const x = mesh.positions[v * 3]!;
if (x < 0.15) continue; // clearly on the left side
for (let i = 0; i < 4; i++) {
if (rightBones.has(mesh.joints[v * 4 + i]!) && mesh.weights[v * 4 + i]! > 0.2) {
violations++;
}
}
}
expect(violations).toBe(0);
});
});
+418
View File
@@ -0,0 +1,418 @@
import { eulerToQuaternion, type AnimationClip } from "../anim/procedural.js";
import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";
/**
* glTF 2.0 / GLB writer.
*
* Hand-rolled rather than using three's `GLTFExporter`, which expects a browser
* (Blob, FileReader) and a live scene graph. The pipeline is a Node process
* holding plain arrays, so emitting the container directly is both simpler and
* dependency-free — GLB is a JSON chunk and a binary chunk with 4-byte
* alignment, and that is the whole format.
*/
const GLB_MAGIC = 0x46546c67; // "glTF"
const GLB_VERSION = 2;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;
const COMPONENT_FLOAT = 5126;
const COMPONENT_UNSIGNED_SHORT = 5123;
const COMPONENT_UNSIGNED_INT = 5125;
const TARGET_ARRAY_BUFFER = 34962;
const TARGET_ELEMENT_ARRAY_BUFFER = 34963;
interface Accessor {
bufferView: number;
componentType: number;
count: number;
type: string;
min?: number[];
max?: number[];
normalized?: boolean;
}
interface BufferView {
buffer: 0;
byteOffset: number;
byteLength: number;
target?: number;
byteStride?: number;
}
/** Accumulates binary data with the 4-byte alignment glTF requires. */
class BinaryWriter {
private readonly chunks: Uint8Array[] = [];
private length = 0;
readonly bufferViews: BufferView[] = [];
readonly accessors: Accessor[] = [];
private align(): void {
const padding = (4 - (this.length % 4)) % 4;
if (padding === 0) return;
this.chunks.push(new Uint8Array(padding));
this.length += padding;
}
private addView(bytes: Uint8Array, target?: number): number {
this.align();
const byteOffset = this.length;
this.chunks.push(bytes);
this.length += bytes.byteLength;
this.bufferViews.push({
buffer: 0,
byteOffset,
byteLength: bytes.byteLength,
...(target !== undefined ? { target } : {}),
});
return this.bufferViews.length - 1;
}
addFloats(values: ArrayLike<number>, type: string, target?: number, withBounds = false): number {
const array = Float32Array.from(values as number[]);
const view = this.addView(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), target);
const components = COMPONENTS_PER_TYPE[type]!;
const accessor: Accessor = {
bufferView: view,
componentType: COMPONENT_FLOAT,
count: array.length / components,
type,
};
if (withBounds) {
const min = new Array<number>(components).fill(Infinity);
const max = new Array<number>(components).fill(-Infinity);
for (let i = 0; i < array.length; i += components) {
for (let c = 0; c < components; c++) {
const value = array[i + c]!;
if (value < min[c]!) min[c] = value;
if (value > max[c]!) max[c] = value;
}
}
accessor.min = min;
accessor.max = max;
}
this.accessors.push(accessor);
return this.accessors.length - 1;
}
addIndices(values: number[]): number {
// 16-bit where it fits: a PSX-budget mesh almost always does, and it halves
// the index buffer.
const needs32 = values.some((value) => value > 65535);
const array = needs32 ? Uint32Array.from(values) : Uint16Array.from(values);
const view = this.addView(
new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
TARGET_ELEMENT_ARRAY_BUFFER,
);
this.accessors.push({
bufferView: view,
componentType: needs32 ? COMPONENT_UNSIGNED_INT : COMPONENT_UNSIGNED_SHORT,
count: values.length,
type: "SCALAR",
});
return this.accessors.length - 1;
}
addJoints(values: number[]): number {
const array = Uint16Array.from(values);
const view = this.addView(
new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
TARGET_ARRAY_BUFFER,
);
this.accessors.push({
bufferView: view,
componentType: COMPONENT_UNSIGNED_SHORT,
count: values.length / 4,
type: "VEC4",
});
return this.accessors.length - 1;
}
/** Raw bytes (e.g. embedded PNG) — no accessor, just a bufferView. */
addRaw(bytes: Uint8Array): number {
return this.addView(bytes);
}
finish(): Uint8Array {
this.align();
const out = new Uint8Array(this.length);
let offset = 0;
for (const chunk of this.chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
}
const COMPONENTS_PER_TYPE: Record<string, number> = {
SCALAR: 1,
VEC2: 2,
VEC3: 3,
VEC4: 4,
MAT4: 16,
};
export interface ExportOptions {
name?: string;
clips?: AnimationClip[];
/** Written into the asset's generator string for provenance. */
generator?: string;
/**
* Optional image bytes for baseColorTexture (PNG or JPEG).
* Requires mesh.uvs to be populated (TEXCOORD_0).
*/
baseColorImage?: Uint8Array;
/** MIME type for baseColorImage. Default: sniff from magic bytes, else image/png. */
baseColorMimeType?: string;
}
export function exportGlb(
mesh: MeshData,
skeleton: Skeleton,
inverseBindMatrices: Float32Array,
options: ExportOptions = {},
): Uint8Array {
const writer = new BinaryWriter();
const name = options.name ?? "Character";
const clips = options.clips ?? [];
const hasUvs = mesh.uvs.length >= (mesh.positions.length / 3) * 2 && mesh.uvs.length > 0;
const imageBytes = options.baseColorImage;
const hasTexture = Boolean(imageBytes && hasUvs);
const imageMime =
options.baseColorMimeType ??
(imageBytes ? sniffImageMime(imageBytes) : "image/png");
// --- mesh attributes -----------------------------------------------------
const positionAccessor = writer.addFloats(mesh.positions, "VEC3", TARGET_ARRAY_BUFFER, true);
const normalAccessor = writer.addFloats(mesh.normals, "VEC3", TARGET_ARRAY_BUFFER);
const colorAccessor = writer.addFloats(mesh.colors, "VEC4", TARGET_ARRAY_BUFFER);
const uvAccessor = hasUvs
? writer.addFloats(mesh.uvs, "VEC2", TARGET_ARRAY_BUFFER)
: undefined;
const jointsAccessor = writer.addJoints(mesh.joints);
const weightsAccessor = writer.addFloats(mesh.weights, "VEC4", TARGET_ARRAY_BUFFER);
const indexAccessor = writer.addIndices(mesh.indices);
const ibmAccessor = writer.addFloats(inverseBindMatrices, "MAT4");
let imageBufferView: number | undefined;
if (hasTexture && imageBytes) {
imageBufferView = writer.addRaw(imageBytes);
}
// --- node graph ----------------------------------------------------------
// Node 0 is the skinned mesh; joints follow, so joint N is node N+1.
const JOINT_NODE_OFFSET = 1;
const nodes: Array<Record<string, unknown>> = [
{ name, mesh: 0, skin: 0 },
];
for (const joint of skeleton.joints) {
const children = skeleton.joints
.map((other, index) => ({ other, index }))
.filter(({ other }) => other.parent === joint.name)
.map(({ index }) => index + JOINT_NODE_OFFSET);
nodes.push({
name: joint.name,
translation: joint.local,
...(children.length > 0 ? { children } : {}),
});
}
const rootJointIndex = skeleton.joints.findIndex((joint) => joint.parent === null);
// --- animations ----------------------------------------------------------
const animations = clips.map((clip) => buildAnimation(clip, skeleton, writer, JOINT_NODE_OFFSET));
const attributes: Record<string, number> = {
POSITION: positionAccessor,
NORMAL: normalAccessor,
COLOR_0: colorAccessor,
JOINTS_0: jointsAccessor,
WEIGHTS_0: weightsAccessor,
};
if (uvAccessor !== undefined) attributes["TEXCOORD_0"] = uvAccessor;
const material: Record<string, unknown> = {
name: hasTexture ? "PSXTextured" : "PSXVertexColor",
pbrMetallicRoughness: {
baseColorFactor: [1, 1, 1, 1],
metallicFactor: 0,
roughnessFactor: 1,
...(hasTexture ? { baseColorTexture: { index: 0 } } : {}),
},
doubleSided: false,
};
const gltf: Record<string, unknown> = {
asset: {
version: "2.0",
generator: options.generator ?? "psx-adventure-engine harness",
},
scene: 0,
scenes: [{ nodes: [0, rootJointIndex + JOINT_NODE_OFFSET] }],
nodes,
meshes: [
{
name: `${name}Mesh`,
primitives: [
{
attributes,
indices: indexAccessor,
material: 0,
},
],
},
],
skins: [
{
name: `${name}Skin`,
inverseBindMatrices: ibmAccessor,
skeleton: rootJointIndex + JOINT_NODE_OFFSET,
joints: skeleton.joints.map((_, index) => index + JOINT_NODE_OFFSET),
},
],
materials: [material],
...(hasTexture
? {
textures: [{ source: 0, sampler: 0 }],
images: [{ bufferView: imageBufferView, mimeType: imageMime }],
// Nearest sampling — PSX chunky texels.
samplers: [
{
magFilter: 9728, // NEAREST
minFilter: 9728, // NEAREST
wrapS: 33071, // CLAMP_TO_EDGE
wrapT: 33071,
},
],
}
: {}),
...(animations.length > 0 ? { animations } : {}),
accessors: writer.accessors,
bufferViews: writer.bufferViews,
buffers: [{ byteLength: 0 }],
};
const binary = writer.finish();
(gltf["buffers"] as Array<{ byteLength: number }>)[0]!.byteLength = binary.byteLength;
return packGlb(gltf, binary);
}
function buildAnimation(
clip: AnimationClip,
skeleton: Skeleton,
writer: BinaryWriter,
jointNodeOffset: number,
): Record<string, unknown> {
const samplers: Array<Record<string, unknown>> = [];
const channels: Array<Record<string, unknown>> = [];
for (const channel of clip.channels) {
const jointIndex = skeleton.index.get(channel.bone);
// A clip may reference bones a given rig does not have; skip rather than
// fail, so clips stay portable across characters (GDD §10.2).
if (jointIndex === undefined) continue;
const times = channel.keyframes.map((key) => key.time);
// Ensure consecutive quats take the *short* arc (dot ≥ 0). LINEAR
// interpolation of XYZW otherwise spins the long way around.
const rotations: number[] = [];
let prev: [number, number, number, number] | null = null;
for (const key of channel.keyframes) {
let q = eulerToQuaternion(key.rotation[0], key.rotation[1], key.rotation[2]) as [
number,
number,
number,
number,
];
if (prev && prev[0] * q[0] + prev[1] * q[1] + prev[2] * q[2] + prev[3] * q[3] < 0) {
q = [-q[0], -q[1], -q[2], -q[3]];
}
rotations.push(q[0], q[1], q[2], q[3]);
prev = q;
}
// Rest rotations are identity, so a clip's quaternion is the final value.
const input = writer.addFloats(times, "SCALAR", undefined, true);
const output = writer.addFloats(rotations, "VEC4");
samplers.push({ input, output, interpolation: "LINEAR" });
channels.push({
sampler: samplers.length - 1,
target: { node: jointIndex + jointNodeOffset, path: "rotation" },
});
}
return { name: clip.name, samplers, channels };
}
function packGlb(gltf: unknown, binary: Uint8Array): Uint8Array {
const jsonText = JSON.stringify(gltf);
const jsonBytes = new TextEncoder().encode(jsonText);
// Both chunks pad to 4 bytes: JSON with spaces, BIN with zeros, per spec.
const jsonPadding = (4 - (jsonBytes.byteLength % 4)) % 4;
const binPadding = (4 - (binary.byteLength % 4)) % 4;
const jsonChunkLength = jsonBytes.byteLength + jsonPadding;
const binChunkLength = binary.byteLength + binPadding;
const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength;
const out = new Uint8Array(totalLength);
const view = new DataView(out.buffer);
let offset = 0;
view.setUint32(offset, GLB_MAGIC, true);
view.setUint32(offset + 4, GLB_VERSION, true);
view.setUint32(offset + 8, totalLength, true);
offset += 12;
view.setUint32(offset, jsonChunkLength, true);
view.setUint32(offset + 4, CHUNK_JSON, true);
offset += 8;
out.set(jsonBytes, offset);
// 0x20 is a space; JSON chunks pad with spaces so the text stays valid.
out.fill(0x20, offset + jsonBytes.byteLength, offset + jsonChunkLength);
offset += jsonChunkLength;
view.setUint32(offset, binChunkLength, true);
view.setUint32(offset + 4, CHUNK_BIN, true);
offset += 8;
out.set(binary, offset);
return out;
}
/** Convenience for the pipeline report. */
export function meshStatistics(mesh: MeshData): { vertices: number; triangles: number } {
const { min, max } = bounds(mesh);
void min;
void max;
return { vertices: vertexCount(mesh), triangles: mesh.indices.length / 3 };
}
function sniffImageMime(bytes: Uint8Array): string {
if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return "image/png";
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return "image/jpeg";
}
if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42) {
return "image/webp";
}
return "image/png";
}
+112
View File
@@ -0,0 +1,112 @@
import { chmod, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { GrokBridge } from "./GrokBridge.js";
/**
* Cache behaviour, driven by a stub standing in for the Grok CLI.
*
* Generation costs real money, so the rule these tests pin down is: pay once
* per prompt, and pay again only when explicitly asked to — and when you do,
* the new image replaces the old one rather than piling up beside it.
*/
/** A fake `grok` that writes a numbered image into `--cwd` and logs the call. */
const STUB = `#!/usr/bin/env node
const { appendFileSync, readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const argv = process.argv.slice(2);
if (argv.includes("--version")) {
process.stdout.write("grok-stub 0.0.0\\n");
process.exit(0);
}
const log = process.env.STUB_LOG;
let calls = 0;
try {
calls = readFileSync(log, "utf8").split("\\n").filter(Boolean).length;
} catch {}
calls += 1;
appendFileSync(log, "call\\n");
const cwd = argv[argv.indexOf("--cwd") + 1];
writeFileSync(join(cwd, \`gen-\${calls}.png\`), \`png-\${calls}\`);
process.stdout.write(JSON.stringify({ text: "done", total_cost_usd: 0.05 }));
`;
const dirs: string[] = [];
async function makeBridge() {
const dir = await mkdtemp(join(tmpdir(), "grok-bridge-"));
dirs.push(dir);
const stubPath = join(dir, "grok-stub.cjs");
await writeFile(stubPath, STUB);
await chmod(stubPath, 0o755);
const log = join(dir, "calls.log");
process.env["STUB_LOG"] = log;
const outputDir = join(dir, "cache");
const bridge = new GrokBridge({ command: stubPath, outputDir, timeoutMs: 20_000 });
const callCount = async () =>
(await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean).length;
return { bridge, outputDir, callCount };
}
afterEach(() => {
delete process.env["STUB_LOG"];
});
describe("GrokBridge cache", () => {
it("pays once per prompt, then serves the cached image", async () => {
const { bridge, callCount } = await makeBridge();
const request = { prompt: "a survivor atlas" };
const first = await bridge.generate(request);
expect(first.cached).toBe(false);
expect(await readFile(first.path, "utf8")).toBe("png-1");
const second = await bridge.generate(request);
expect(second.cached).toBe(true);
expect(second.costUsd).toBe(0);
expect(second.path).toBe(first.path);
// The CLI was never invoked a second time.
expect(await callCount()).toBe(1);
});
it("force repaints and replaces the cache entry in place", async () => {
const { bridge, outputDir, callCount } = await makeBridge();
const request = { prompt: "a survivor atlas" };
await bridge.generate(request);
const forced = await bridge.generate({ ...request, force: true });
expect(forced.cached).toBe(false);
expect(await readFile(forced.path, "utf8")).toBe("png-2");
expect(await callCount()).toBe(2);
// One slot, one image — the stale file is gone rather than shadowing.
const slots = await readdir(outputDir);
expect(slots).toHaveLength(1);
const images = await readdir(join(outputDir, slots[0]!));
expect(images).toEqual(["gen-2.png"]);
// And the *unforced* path now serves the repainted image, which is the
// whole point: a forced run must land in the slot it is replacing.
const after = await bridge.generate(request);
expect(after.cached).toBe(true);
expect(await readFile(after.path, "utf8")).toBe("png-2");
expect(await callCount()).toBe(2);
});
it("keeps different prompts in different slots", async () => {
const { bridge, outputDir } = await makeBridge();
await bridge.generate({ prompt: "one" });
await bridge.generate({ prompt: "two", force: true });
expect(await readdir(outputDir)).toHaveLength(2);
});
});
+291
View File
@@ -0,0 +1,291 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { access, mkdir, readdir, rm, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { join, resolve } from "node:path";
/**
* Bridge to the Grok Build CLI for reference-image generation.
*
* `/imagine` is not a CLI subcommand — it is a bundled *skill* that documents
* two agent tools, `image_gen` and `image_edit`. Neither is reachable from the
* shell directly, so the bridge drives a headless single-turn session
* (`grok -p … --output-format json`) and lets the agent make the tool call,
* then recovers the file it wrote.
*
* Two consequences shape the design:
*
* 1. The agent decides where the file lands. We give it a dedicated working
* directory and diff the contents rather than trusting the path it reports
* in prose, which is free-form and occasionally wrapped or annotated.
* 2. Generation costs real money and takes ~60-90s, so results are cached by
* prompt hash. Re-running the pipeline on the same prompt is free.
*/
export interface GrokBridgeOptions {
/** Executable name or absolute path. */
command?: string;
/** Where generated images are written and cached. */
outputDir?: string;
timeoutMs?: number;
model?: string;
}
export interface ImageRequest {
prompt: string;
aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "auto";
/** Existing image(s) to edit. Present means `image_edit` rather than `image_gen`. */
sourceImages?: string[];
/**
* Re-run even if this prompt is already cached, and replace the cache entry
* with the new image. Costs money every time — see {@link GrokBridge.generate}.
*
* Deliberately absent from {@link hashRequest}: a forced run has to land in
* the *same* cache slot it is replacing, or the stale image would keep
* winning on the next unforced call.
*/
force?: boolean;
}
export interface ImageResult {
path: string;
prompt: string;
cached: boolean;
costUsd: number;
elapsedMs: number;
}
export class GrokUnavailableError extends Error {
constructor(command: string) {
super(
`Grok CLI not found (looked for "${command}"). Install it, or pass an explicit image ` +
`with --image to skip generation.`,
);
this.name = "GrokUnavailableError";
}
}
export class GrokGenerationError extends Error {
constructor(
message: string,
readonly stderr: string,
) {
super(message);
this.name = "GrokGenerationError";
}
}
interface GrokJsonResponse {
text?: string;
stopReason?: string;
total_cost_usd?: number;
sessionId?: string;
}
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
export class GrokBridge {
private readonly command: string;
private readonly outputDir: string;
private readonly timeoutMs: number;
private readonly model: string | undefined;
constructor(options: GrokBridgeOptions = {}) {
this.command = options.command ?? process.env["GROK_BIN"] ?? "grok";
this.outputDir = resolve(options.outputDir ?? "assets/references");
this.timeoutMs = options.timeoutMs ?? 5 * 60_000;
this.model = options.model;
}
/** True when the CLI is on PATH and responds. */
async isAvailable(): Promise<boolean> {
try {
await this.exec(["--version"], 20_000);
return true;
} catch {
return false;
}
}
/**
* Generate (or edit) a reference image.
*
* The prompt is wrapped in an instruction that pins the agent to a single
* tool call and a predictable output location — left to its own devices it
* may narrate, ask questions, or save somewhere else.
*
* `request.force` skips the cache and overwrites it. Worth reaching for
* because the cache key is the prompt, not the mesh: edit a character's face
* or physique without renaming it and the prompt is unchanged, so an unforced
* run happily returns the texture painted for the *old* body.
*/
async generate(request: ImageRequest): Promise<ImageResult> {
const started = Date.now();
const key = hashRequest(request);
const workDir = join(this.outputDir, key);
if (request.force) {
// Clear the slot first, so the fresh image is unambiguously the one that
// `findCached` picks up next time (it takes the first name in sort order,
// which a leftover file could otherwise win).
await this.clearCache(workDir);
} else {
const cachedPath = await this.findCached(workDir);
if (cachedPath) {
return { path: cachedPath, prompt: request.prompt, cached: true, costUsd: 0, elapsedMs: 0 };
}
}
await mkdir(workDir, { recursive: true });
const before = await listImages(workDir);
const args = ["-p", this.buildInstruction(request), "--output-format", "json", "--always-approve", "--cwd", workDir];
if (this.model) args.push("--model", this.model);
const { stdout, stderr } = await this.exec(args, this.timeoutMs);
let response: GrokJsonResponse = {};
try {
response = JSON.parse(stdout) as GrokJsonResponse;
} catch {
throw new GrokGenerationError("Grok did not return parseable JSON", stderr || stdout.slice(0, 500));
}
// Prefer a file that actually appeared over the path quoted in prose.
const after = await listImages(workDir);
const created = after.filter((file) => !before.includes(file));
const path = created[0] ?? extractPath(response.text ?? "");
if (!path) {
throw new GrokGenerationError(
`Grok produced no image. It said: ${(response.text ?? "").slice(0, 300)}`,
stderr,
);
}
return {
path: created[0] ? join(workDir, created[0]) : path,
prompt: request.prompt,
cached: false,
costUsd: response.total_cost_usd ?? 0,
elapsedMs: Date.now() - started,
};
}
private buildInstruction(request: ImageRequest): string {
const aspect = request.aspectRatio ?? "3:4";
if (request.sourceImages?.length) {
return [
`Call image_edit exactly once with these source images: ${request.sourceImages.join(", ")}.`,
`Transformation: ${request.prompt}`,
`Save the resulting image into the current working directory.`,
`Do not ask questions. Reply with only the absolute file path on the final line.`,
].join("\n");
}
return [
`Call image_gen exactly once with aspect_ratio ${aspect}.`,
`Prompt: ${request.prompt}`,
`Save the resulting image into the current working directory.`,
`Do not ask questions and do not generate more than one image.`,
`Reply with only the absolute file path on the final line.`,
].join("\n");
}
private async findCached(workDir: string): Promise<string | null> {
try {
const images = await listImages(workDir);
return images[0] ? join(workDir, images[0]) : null;
} catch {
return null;
}
}
/**
* Drop the images in one cache slot.
*
* Only image files, and only inside the hashed directory — never the slot
* itself or anything else the agent left behind. `workDir` is built from a
* hex digest, so it cannot escape `outputDir`.
*/
private async clearCache(workDir: string): Promise<void> {
for (const name of await listImages(workDir)) {
await rm(join(workDir, name), { force: true });
}
}
private exec(args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolvePromise, reject) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(this.command, args, { stdio: ["ignore", "pipe", "pipe"] });
} catch {
reject(new GrokUnavailableError(this.command));
return;
}
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new GrokGenerationError(`Grok timed out after ${timeoutMs}ms`, stderr));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
child.on("error", (error: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(error.code === "ENOENT" ? new GrokUnavailableError(this.command) : error);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
reject(new GrokGenerationError(`Grok exited with code ${code}`, stderr));
return;
}
resolvePromise({ stdout, stderr });
});
});
}
}
function hashRequest(request: ImageRequest): string {
return createHash("sha256")
.update(JSON.stringify([request.prompt, request.aspectRatio, request.sourceImages]))
.digest("hex")
.slice(0, 16);
}
async function listImages(dir: string): Promise<string[]> {
const entries = await readdir(dir).catch(() => [] as string[]);
const images: string[] = [];
for (const entry of entries) {
const dot = entry.lastIndexOf(".");
if (dot === -1 || !IMAGE_EXTENSIONS.has(entry.slice(dot).toLowerCase())) continue;
const info = await stat(join(dir, entry)).catch(() => null);
if (info?.isFile()) images.push(entry);
}
return images.sort();
}
/** Last absolute-looking path in the agent's prose, as a fallback. */
function extractPath(text: string): string | null {
const matches = text.match(/\/[^\s"'`]+\.(?:jpg|jpeg|png|webp)/gi);
return matches?.[matches.length - 1] ?? null;
}
/** Confirms a path exists and is readable before the pipeline commits to it. */
export async function ensureReadable(path: string): Promise<void> {
await access(path, constants.R_OK);
}
+61
View File
@@ -0,0 +1,61 @@
import type { ImageRequest } from "./GrokBridge.js";
/**
* Reference-sheet prompt templates.
*
* The analysis stage measures a silhouette, so the prompt is engineered for
* *measurability* rather than beauty: a plain background it can flood-fill
* away, flat lighting so shadows do not read as body mass, limbs held clear of
* the torso so the row-width profile can separate them, and the whole figure
* inside frame so no landmark is cropped.
*/
export interface CharacterPromptOptions {
/** Free-form description: "a lone survivor in a heavy canvas jacket". */
description: string;
/** Held clear of the body so arms and torso separate in the silhouette. */
pose?: "a-pose" | "t-pose";
}
const MEASURABILITY_CLAUSE =
"Flat, even, shadowless lighting. Plain uniform neutral grey background with nothing else in frame. " +
"The entire body is visible from the top of the head to the soles of the boots, with clear space " +
"above and below. Straight-on front view, camera at chest height, no perspective foreshortening. " +
"No text, no labels, no props on the ground, no cast shadow.";
export function characterReferencePrompt(options: CharacterPromptOptions): ImageRequest {
const pose =
options.pose === "t-pose"
? "standing in a symmetrical T-pose with both arms straight out horizontally at shoulder height, " +
"legs straight and slightly apart, palms down"
: "standing in a symmetrical A-pose with both arms held down and away from the body at about " +
"45 degrees, clear of the torso, legs straight and slightly apart";
return {
prompt: `Full-body character reference sheet. ${options.description}, ${pose}. ${MEASURABILITY_CLAUSE} Low-poly PSX-era survival horror video game character art, muted desaturated palette, strong readable silhouette.`,
aspectRatio: "3:4",
};
}
/**
* Variation of an existing character, for a cast that shares proportions.
* Uses `image_edit` so the silhouette stays measurable and consistent.
*/
export function characterVariantPrompt(
sourceImage: string,
change: string,
): ImageRequest {
return {
prompt: `${change}. Keep the exact same pose, framing, body proportions, camera angle, flat lighting and plain grey background.`,
sourceImages: [sourceImage],
aspectRatio: "3:4",
};
}
/** Prop/item reference — GDD §10.1's secondary pipeline. */
export function propReferencePrompt(description: string): ImageRequest {
return {
prompt: `A single ${description}, isolated product shot, centred in frame, straight-on side view. ${MEASURABILITY_CLAUSE} Low-poly PSX-era survival horror game asset, muted palette.`,
aspectRatio: "1:1",
};
}
+62
View File
@@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import jpeg from "jpeg-js";
import { PNG } from "pngjs";
/** Decoded RGBA image, row-major, 4 bytes per pixel. */
export interface Raster {
width: number;
height: number;
data: Uint8Array;
}
export interface Rgb {
r: number;
g: number;
b: number;
}
export async function loadRaster(path: string): Promise<Raster> {
const bytes = await readFile(path);
const extension = extname(path).toLowerCase();
if (extension === ".png") {
const png = PNG.sync.read(bytes);
return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
}
if (extension === ".jpg" || extension === ".jpeg") {
// `useTArray` keeps the result a Uint8Array rather than a Node Buffer.
const decoded = jpeg.decode(bytes, { useTArray: true });
return { width: decoded.width, height: decoded.height, data: new Uint8Array(decoded.data) };
}
throw new Error(`Unsupported image format: ${extension || path}. Use PNG or JPEG.`);
}
export const pixelIndex = (raster: Raster, x: number, y: number): number =>
(y * raster.width + x) * 4;
export function pixelAt(raster: Raster, x: number, y: number): Rgb {
const i = pixelIndex(raster, x, y);
return { r: raster.data[i]!, g: raster.data[i + 1]!, b: raster.data[i + 2]! };
}
/** Perceptual-ish distance. Cheap, and good enough to separate a flat backdrop. */
export function colorDistance(a: Rgb, b: Rgb): number {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
// Green weighted highest, matching luminance sensitivity.
return Math.sqrt(dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11);
}
export const luminance = (color: Rgb): number =>
(color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722) / 255;
/** Debug output: write a mask or raster to PNG so a human can eyeball it. */
export function rasterToPng(raster: Raster): Buffer {
const png = new PNG({ width: raster.width, height: raster.height });
png.data = Buffer.from(raster.data);
return PNG.sync.write(png);
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { analyzeReference } from "./analyze.js";
import { colorDistance } from "./Raster.js";
import { denoise, segmentForeground } from "./segment.js";
import { DEFAULT_FIGURE, PACKED_FIGURE, renderFigure } from "./testFixtures.js";
/**
* Ground truth is known here, so tolerances are tight where the analyzer
* genuinely measures and loose where it admits to deriving.
*/
function analyze(spec = DEFAULT_FIGURE) {
const raster = renderFigure(spec);
const mask = denoise(segmentForeground(raster));
return { ...analyzeReference(raster, mask), mask };
}
/** Landmarks are normalised to the *body*, the fixture to the *image*. */
function toBodySpace(spec: typeof DEFAULT_FIGURE, imageFraction: number): number {
return (imageFraction - spec.headTop) / (spec.soleY - spec.headTop);
}
describe("segmentation", () => {
it("separates the figure from a flat backdrop", () => {
const { mask } = analyze();
expect(mask.coverage).toBeGreaterThan(0.05);
expect(mask.coverage).toBeLessThan(0.6);
});
it("keeps a backdrop-coloured patch enclosed by the body", () => {
// A grey badge in the middle of the torso, the exact colour of the
// backdrop. A global colour threshold would punch a hole through the
// character; a flood fill from the border cannot reach it.
const spec = DEFAULT_FIGURE;
const raster = renderFigure(spec);
const midX = Math.round(spec.width / 2);
const torsoY = Math.round(((spec.shoulderY + spec.crotchY) / 2) * spec.height);
for (let y = torsoY - 12; y <= torsoY + 12; y++) {
for (let x = midX - 12; x <= midX + 12; x++) {
const i = (y * spec.width + x) * 4;
raster.data[i] = spec.backdrop.r;
raster.data[i + 1] = spec.backdrop.g;
raster.data[i + 2] = spec.backdrop.b;
}
}
const mask = denoise(segmentForeground(raster));
expect(mask.data[torsoY * mask.width + midX]).toBe(1);
});
});
describe("analyzeReference landmarks", () => {
it("measures the neck near the head/body junction", () => {
const { measurements } = analyze();
expect(measurements.landmarks.neck.y).toBeCloseTo(
toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.neckY),
1,
);
});
it("measures the crotch where the legs separate", () => {
const { measurements } = analyze();
expect(measurements.landmarks.crotch.source).toBe("measured");
expect(measurements.landmarks.crotch.y).toBeCloseTo(
toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.crotchY),
1,
);
});
it("measures the ankle above the boot flare", () => {
const { measurements } = analyze();
expect(measurements.landmarks.ankle.y).toBeGreaterThan(0.85);
expect(measurements.landmarks.ankle.y).toBeLessThan(0.98);
});
it("orders every landmark head to toe", () => {
const { landmarks } = analyze().measurements;
const order = [
landmarks.neck.y,
landmarks.shoulder.y,
landmarks.chest.y,
landmarks.waist.y,
landmarks.hip.y,
landmarks.crotch.y,
landmarks.knee.y,
landmarks.ankle.y,
];
for (let i = 1; i < order.length; i++) {
expect(order[i]).toBeGreaterThanOrEqual(order[i - 1]!);
}
});
});
describe("analyzeReference robustness", () => {
it("rejects an implausible shoulder caused by worn gear", () => {
// The pack merges with the collar, so the width test fires under the jaw.
// The analyzer must notice and fall back rather than rig arms to the chin.
const { measurements, notes } = analyze(PACKED_FIGURE);
expect(measurements.landmarks.shoulder.source).toBe("derived");
expect(notes.join(" ")).toContain("plausible band");
// Still lands somewhere a shoulder could actually be.
expect(measurements.landmarks.shoulder.y).toBeGreaterThan(0.12);
expect(measurements.landmarks.shoulder.y).toBeLessThan(0.3);
});
it("keeps the torso width free of the arms", () => {
const { measurements } = analyze();
// Torso is 0.26 of image height; arms add ~0.15 per side if wrongly merged.
const expected = DEFAULT_FIGURE.torsoWidth / (DEFAULT_FIGURE.soleY - DEFAULT_FIGURE.headTop);
expect(measurements.widths.chest).toBeLessThan(expected * 1.4);
});
it("reports a thigh wider than a calf", () => {
const { measurements } = analyze();
expect(measurements.widths.thigh).toBeGreaterThan(0);
expect(measurements.widths.calf).toBeGreaterThan(0);
});
it("survives a figure whose legs never separate", () => {
const { measurements, notes } = analyze({ ...DEFAULT_FIGURE, legGap: 0 });
expect(measurements.landmarks.crotch.y).toBeGreaterThan(0.3);
expect(measurements.landmarks.crotch.y).toBeLessThan(0.75);
if (measurements.landmarks.crotch.source === "derived") {
expect(notes.join(" ")).toContain("crotch");
}
});
});
describe("palette sampling", () => {
it("recovers the colour of each body region", () => {
const { palette } = analyze();
const { colors } = DEFAULT_FIGURE;
// Generous tolerance: the sampler insets from edges and takes a median.
expect(colorDistance(palette.hair, colors.hair)).toBeLessThan(40);
expect(colorDistance(palette.skin, colors.skin)).toBeLessThan(40);
expect(colorDistance(palette.torsoUpper, colors.torso)).toBeLessThan(40);
expect(colorDistance(palette.leg, colors.leg)).toBeLessThan(40);
expect(colorDistance(palette.foot, colors.foot)).toBeLessThan(40);
});
it("distinguishes the arms from the torso", () => {
const { palette } = analyze();
expect(colorDistance(palette.arm, DEFAULT_FIGURE.colors.arm)).toBeLessThan(45);
});
it("never returns the backdrop colour for a body region", () => {
const { palette } = analyze();
for (const [region, color] of Object.entries(palette)) {
expect(
colorDistance(color, DEFAULT_FIGURE.backdrop),
`${region} sampled the backdrop`,
).toBeGreaterThan(8);
}
});
});
describe("analyzeReference on a real generated reference", () => {
// The image Grok's image_gen actually produced: a survivor in a bulky jacket
// wearing a high pack. Synthetic fixtures cannot reproduce JPEG ringing,
// painted shading, or gear that genuinely merges with the collar.
const fixture = new URL("../../fixtures/survivor-reference.jpg", import.meta.url).pathname;
it("produces an anatomically ordered figure", async () => {
const { loadRaster } = await import("./Raster.js");
const raster = await loadRaster(fixture);
const mask = denoise(segmentForeground(raster));
const { measurements, palette } = analyzeReference(raster, mask);
const { landmarks, widths } = measurements;
expect(landmarks.neck.y).toBeGreaterThan(0.08);
expect(landmarks.neck.y).toBeLessThan(0.2);
// Must not be dragged up to the arm-separation line by the loose jacket.
expect(landmarks.crotch.y).toBeGreaterThan(0.5);
expect(landmarks.crotch.y).toBeLessThan(0.62);
expect(landmarks.ankle.y).toBeGreaterThan(0.85);
// Torso tapers downward, and never includes the arms.
expect(widths.chest).toBeGreaterThan(widths.hip);
expect(widths.chest).toBeLessThan(0.35);
expect(widths.thigh).toBeGreaterThan(widths.calf);
// The jacket is olive and the trousers are dark; they must not be confused.
expect(palette.torsoUpper.g).toBeGreaterThan(palette.leg.g);
}, 30_000);
});
+538
View File
@@ -0,0 +1,538 @@
import { luminance, pixelAt, type Raster, type Rgb } from "./Raster.js";
import { isForeground, type Mask } from "./segment.js";
/**
* Turns a reference image into measurements the mesh builder can draw from.
*
* The core trick is **run structure per scanline** rather than raw width. For a
* front-facing figure with arms held clear of the body, a horizontal scan
* yields a predictable pattern:
*
* head/neck 1 run
* shoulders+arms 3 runs [left arm | torso | right arm]
* below the arms 1 run (torso only)
* below the crotch 2 runs [left leg | right leg]
*
* The transitions between those counts are the landmarks, and they are far more
* robust than thresholding widths: they survive baggy clothing, backpack
* straps, and the figure being off-centre.
*
* Anything that cannot be measured falls back to a documented anthropometric
* ratio, and `MeasurementSource` records which is which so the caller can tell
* a measurement from an assumption.
*/
export type MeasurementSource = "measured" | "derived";
export interface Landmark {
/** Normalised 0 (top of head) to 1 (soles). */
y: number;
source: MeasurementSource;
}
export interface Measurements {
/** Pixel bounds used, for debugging overlays. */
pixelHeight: number;
pixelWidth: number;
landmarks: {
neck: Landmark;
shoulder: Landmark;
chest: Landmark;
waist: Landmark;
hip: Landmark;
crotch: Landmark;
knee: Landmark;
ankle: Landmark;
};
/** All widths normalised against total body height. */
widths: {
head: number;
neck: number;
shoulder: number;
chest: number;
waist: number;
hip: number;
thigh: number;
calf: number;
foot: number;
upperArm: number;
forearm: number;
};
/** Half the full arm span, normalised to body height. */
armReach: number;
/** Horizontal offset of each foot centre from the body midline. */
stance: number;
/** Body height in pixels over width, for sanity checks. */
aspect: number;
}
export interface Palette {
hair: Rgb;
skin: Rgb;
torsoUpper: Rgb;
torsoLower: Rgb;
arm: Rgb;
hand: Rgb;
leg: Rgb;
foot: Rgb;
}
export interface ReferenceAnalysis {
measurements: Measurements;
palette: Palette;
/** Warnings where a landmark could not be measured and was derived. */
notes: string[];
}
interface Run {
start: number;
end: number;
}
/** Runs shorter than this fraction of body width are speckle, not anatomy. */
const MIN_RUN_FRACTION = 0.012;
function runsForRow(mask: Mask, y: number, minRunWidth: number): Run[] {
const runs: Run[] = [];
let start = -1;
for (let x = 0; x <= mask.width; x++) {
const solid = x < mask.width && isForeground(mask, x, y);
if (solid && start === -1) start = x;
else if (!solid && start !== -1) {
if (x - start >= minRunWidth) runs.push({ start, end: x - 1 });
start = -1;
}
}
return runs;
}
const runWidth = (run: Run): number => run.end - run.start + 1;
/** The run containing the body midline, or the widest as a fallback. */
function centralRun(runs: Run[], midX: number): Run | null {
if (runs.length === 0) return null;
const containing = runs.find((run) => midX >= run.start && midX <= run.end);
if (containing) return containing;
return runs.reduce((widest, run) => (runWidth(run) > runWidth(widest) ? run : widest));
}
export function analyzeReference(raster: Raster, mask: Mask): ReferenceAnalysis {
const notes: string[] = [];
const { minY, maxY, minX, maxX } = mask.bounds;
const pixelHeight = maxY - minY + 1;
const pixelWidth = maxX - minX + 1;
const minRunWidth = Math.max(2, Math.floor(pixelWidth * MIN_RUN_FRACTION));
// Midline from the centre of mass, not the bounding box: an asymmetric pose
// (a backpack, one arm lower) would otherwise skew the box.
const midX = centreOfMassX(mask);
const rows: Array<{ y: number; runs: Run[]; central: Run | null }> = [];
for (let y = minY; y <= maxY; y++) {
const runs = runsForRow(mask, y, minRunWidth);
rows.push({ y, runs, central: centralRun(runs, midX) });
}
const norm = (y: number): number => (y - minY) / Math.max(1, pixelHeight - 1);
const at = (t: number) => rows[Math.min(rows.length - 1, Math.max(0, Math.round(t * (rows.length - 1))))]!;
// --- neck: narrowest central run in the upper third --------------------
let neckIndex = -1;
let neckWidth = Number.POSITIVE_INFINITY;
for (let i = Math.floor(rows.length * 0.06); i < Math.floor(rows.length * 0.3); i++) {
const central = rows[i]?.central;
if (!central) continue;
if (runWidth(central) < neckWidth) {
neckWidth = runWidth(central);
neckIndex = i;
}
}
if (neckIndex === -1) {
neckIndex = Math.floor(rows.length * 0.13);
neckWidth = runWidth(at(0.13).central ?? { start: 0, end: 10 });
notes.push("neck not measurable; assumed at 13% of height");
}
// --- shoulder: where the torso itself widens past the neck -------------
//
// Deliberately driven by the *central* run rather than by a 3-run split.
// Gear that sticks out sideways — a backpack, a rifle, a raised collar —
// produces side runs above the true shoulder line and fools a run-count test
// into placing the shoulder on top of the neck.
// Shoulder breadth runs roughly 2.2-2.7x neck breadth on an adult, so the
// yoke announces itself as an abrupt multiple of the neck — not as a gentle
// widening, which a collar or scarf also produces.
//
// The run-count guard matters as much as the width test: gear that projects
// sideways (a backpack, a raised hood) fragments the scanline into four or
// more runs while the central one is still narrow. Those rows are rejected
// outright rather than mistaken for the shoulder line.
const torsoSearchEnd = Math.floor(rows.length * 0.45);
const shoulderIndex = firstSustained(
rows,
neckIndex + 1,
torsoSearchEnd,
(row) =>
row.runs.length <= 3 && row.central !== null && runWidth(row.central) >= neckWidth * 2,
Math.max(2, Math.floor(rows.length * 0.02)),
);
// Anthropometric plausibility gate.
//
// Head height (crown to chin) is the one vertical measurement that is
// reliable here, and adult shoulder height sits about 1.35-2.1 head heights
// below the crown. A "shoulder" outside that band is not a shoulder — on a
// figure wearing a high pack the gear merges with the collar and the width
// test fires just under the jaw. Rather than trust it, fall back to 1.6 head
// heights and say so.
const headHeightRows = Math.max(1, neckIndex);
const shoulderMin = Math.round(headHeightRows * 1.35);
const shoulderMax = Math.round(headHeightRows * 2.1);
let shoulderSource: MeasurementSource = "measured";
let resolvedShoulder = shoulderIndex;
if (resolvedShoulder === -1) {
resolvedShoulder = Math.round(headHeightRows * 1.6);
shoulderSource = "derived";
notes.push("shoulder not measurable; derived as 1.6 head heights below the crown");
} else if (resolvedShoulder < shoulderMin || resolvedShoulder > shoulderMax) {
notes.push(
`shoulder measured at ${(norm(rows[resolvedShoulder]!.y)).toFixed(3)} of height, outside the ` +
`plausible band — worn gear likely merged with the collar; derived from head height instead`,
);
resolvedShoulder = Math.round(headHeightRows * 1.6);
shoulderSource = "derived";
}
resolvedShoulder = Math.min(rows.length - 1, Math.max(0, resolvedShoulder));
// --- crotch: where one run becomes two, and *stays* two ----------------
//
// A long coat hem, a belt, or a dangling strap all notch the silhouette
// briefly. Real legs stay separated all the way to the floor, so the split
// must persist across most of the remaining height to count.
// Exactly two runs, not "two or more": with the arms still clear of the body
// a scanline reads [arm | torso | arm] — three runs — and a `>= 2` test would
// call the point where the *arms* separate the crotch. Two runs and only two
// means the arms have ended and what remains is a pair of legs.
const legSearchStart = Math.floor(rows.length * 0.4);
let crotchSource: MeasurementSource = "measured";
let crotchIndex = firstSustained(
rows,
legSearchStart,
Math.floor(rows.length * 0.75),
(row) => row.runs.length === 2,
Math.max(4, Math.floor(rows.length * 0.12)),
);
if (crotchIndex === -1) {
crotchIndex = Math.floor(rows.length * 0.53);
crotchSource = "derived";
notes.push("legs never cleanly separate; crotch assumed at 53% of height");
}
// --- ankle: narrowest leg span between crotch and the boot flare -------
const footSearchStart = crotchIndex + Math.floor((rows.length - crotchIndex) * 0.55);
let ankleIndex = -1;
let ankleWidth = Number.POSITIVE_INFINITY;
for (let i = footSearchStart; i < rows.length - Math.floor(rows.length * 0.02); i++) {
const total = rows[i]!.runs.reduce((sum, run) => sum + runWidth(run), 0);
if (total > 0 && total < ankleWidth) {
ankleWidth = total;
ankleIndex = i;
}
}
if (ankleIndex === -1) {
ankleIndex = Math.floor(rows.length * 0.94);
notes.push("ankle not measurable; assumed at 94% of height");
}
// Knee sits midway between crotch and ankle on a standing figure. There is no
// reliable silhouette cue for it through trousers, so this one is always
// derived rather than pretending to measure it.
const kneeIndex = Math.round((crotchIndex + ankleIndex) / 2);
const chestIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.25);
const waistIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.68);
const hipIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.88);
// --- widths ------------------------------------------------------------
const centralWidthAt = (index: number): number => {
const central = rows[Math.min(rows.length - 1, Math.max(0, index))]?.central;
return central ? runWidth(central) : 0;
};
const headWidth = maxRunWidthBetween(rows, 0, neckIndex);
/**
* Torso width is only observable where the arms are clear of the body. On
* rows where they overlap, the central run is torso *plus* both arms and
* over-reports by 50% or more, so those rows are not used at all — the
* reference width comes from the band where the silhouette actually
* separates, and the unmeasurable rows are scaled from it.
*/
const separatedWidths: number[] = [];
for (let i = resolvedShoulder; i < crotchIndex; i++) {
const row = rows[i]!;
if (row.runs.length >= 3 && row.central) separatedWidths.push(runWidth(row.central));
}
const torsoReference =
separatedWidths.length > 0 ? median(separatedWidths) : centralWidthAt(waistIndex);
const torsoWidthAt = (index: number, fallbackRatio: number): number => {
const row = rows[Math.min(rows.length - 1, Math.max(0, index))];
return row && row.runs.length >= 3 && row.central
? runWidth(row.central)
: torsoReference * fallbackRatio;
};
// Shoulder breadth tracks chest breadth closely on a clothed figure.
const shoulderWidth = torsoWidthAt(resolvedShoulder, 1);
// Arm thickness from the side runs where they are cleanly separated.
const armRuns = rows
.slice(resolvedShoulder, crotchIndex)
.filter((row) => row.runs.length >= 3)
.flatMap((row) => [row.runs[0]!, row.runs[row.runs.length - 1]!]);
const upperArmWidth =
armRuns.length > 0
? median(armRuns.slice(0, Math.max(1, armRuns.length >> 1)).map(runWidth))
: headWidth * 0.42;
const forearmWidth =
armRuns.length > 0
? median(armRuns.slice(Math.max(1, armRuns.length >> 1)).map(runWidth))
: upperArmWidth * 0.8;
const legRunsAt = (index: number): Run[] => rows[index]?.runs ?? [];
/**
* Width of a *single* leg near `index`. Only rows where the legs are actually
* split are usable — a merged row measures both legs plus the gap, and a row
* still under a coat hem measures the coat.
*/
const singleLegWidth = (index: number, searchSpan: number): number => {
const widths: number[] = [];
for (let i = index; i < Math.min(rows.length, index + searchSpan); i++) {
const runs = legRunsAt(i);
if (runs.length >= 2) widths.push(median(runs.map(runWidth)));
}
return widths.length > 0 ? median(widths) : 0;
};
const thighSpan = Math.max(3, Math.floor((kneeIndex - crotchIndex) * 0.5));
const thighWidth =
singleLegWidth(Math.round(crotchIndex + (kneeIndex - crotchIndex) * 0.3), thighSpan) ||
headWidth * 0.55;
const calfWidth = singleLegWidth(kneeIndex, thighSpan) || thighWidth * 0.78;
// The boot is widest at its sole, so take the maximum across the bottom band
// rather than a sample a few rows from the very bottom, which catches only
// the toe tip and reports a foot narrower than the ankle.
let footWidth = 0;
for (let i = Math.max(ankleIndex, rows.length - Math.floor(rows.length * 0.06)); i < rows.length; i++) {
const runs = legRunsAt(i);
if (runs.length === 0) continue;
footWidth = Math.max(footWidth, Math.max(...runs.map(runWidth)));
}
if (footWidth === 0) footWidth = calfWidth * 1.3;
// Arm reach: the widest point anywhere above the crotch.
let reachPixels = 0;
for (let i = 0; i < crotchIndex; i++) {
const row = rows[i]!;
if (row.runs.length === 0) continue;
const span = row.runs[row.runs.length - 1]!.end - row.runs[0]!.start + 1;
if (span > reachPixels) reachPixels = span;
}
// Stance: horizontal offset of the feet from the midline.
const footRuns = legRunsAt(rows.length - 3);
const stancePixels =
footRuns.length >= 2
? Math.abs((footRuns[0]!.start + runWidth(footRuns[0]!) / 2) - midX)
: pixelWidth * 0.08;
const measurements: Measurements = {
pixelHeight,
pixelWidth,
landmarks: {
neck: { y: norm(rows[neckIndex]!.y), source: "measured" },
shoulder: { y: norm(rows[resolvedShoulder]!.y), source: shoulderSource },
chest: { y: norm(rows[chestIndex]!.y), source: "derived" },
waist: { y: norm(rows[waistIndex]!.y), source: "derived" },
hip: { y: norm(rows[hipIndex]!.y), source: "derived" },
crotch: { y: norm(rows[crotchIndex]!.y), source: crotchSource },
knee: { y: norm(rows[kneeIndex]!.y), source: "derived" },
ankle: { y: norm(rows[ankleIndex]!.y), source: "measured" },
},
widths: {
head: headWidth / pixelHeight,
neck: neckWidth / pixelHeight,
shoulder: shoulderWidth / pixelHeight,
chest: torsoWidthAt(chestIndex, 1) / pixelHeight,
waist: torsoWidthAt(waistIndex, 0.92) / pixelHeight,
hip: torsoWidthAt(hipIndex, 0.95) / pixelHeight,
thigh: thighWidth / pixelHeight,
calf: calfWidth / pixelHeight,
foot: footWidth / pixelHeight,
upperArm: upperArmWidth / pixelHeight,
forearm: forearmWidth / pixelHeight,
},
armReach: reachPixels / 2 / pixelHeight,
stance: stancePixels / pixelHeight,
aspect: pixelHeight / pixelWidth,
};
const palette = samplePalette(raster, mask, rows, midX, {
neckIndex,
resolvedShoulder,
chestIndex,
waistIndex,
crotchIndex,
kneeIndex,
ankleIndex,
});
return { measurements, palette, notes };
}
interface PaletteIndices {
neckIndex: number;
resolvedShoulder: number;
chestIndex: number;
waistIndex: number;
crotchIndex: number;
kneeIndex: number;
ankleIndex: number;
}
/**
* Median colour per body region.
*
* Median rather than mean: a mean blends a dark jacket and a light shirt into a
* muddy average that appears nowhere in the reference, whereas the median lands
* on whichever actually dominates the region.
*/
function samplePalette(
raster: Raster,
mask: Mask,
rows: Array<{ y: number; runs: Run[]; central: Run | null }>,
midX: number,
indices: PaletteIndices,
): Palette {
const sampleCentral = (fromIndex: number, toIndex: number): Rgb => {
const samples: Rgb[] = [];
const step = Math.max(1, Math.floor((toIndex - fromIndex) / 24));
for (let i = fromIndex; i < toIndex; i += step) {
const row = rows[i];
if (!row?.central) continue;
// Inset from the silhouette edge to dodge outlines and JPEG ringing.
const inset = Math.max(1, Math.floor(runWidth(row.central) * 0.25));
for (let x = row.central.start + inset; x <= row.central.end - inset; x += 3) {
if (isForeground(mask, x, row.y)) samples.push(pixelAt(raster, x, row.y));
}
}
return medianColor(samples);
};
const sampleSideRuns = (fromIndex: number, toIndex: number): Rgb => {
const samples: Rgb[] = [];
for (let i = fromIndex; i < toIndex; i++) {
const row = rows[i];
if (!row || row.runs.length < 3) continue;
for (const run of [row.runs[0]!, row.runs[row.runs.length - 1]!]) {
const centre = Math.round((run.start + run.end) / 2);
if (isForeground(mask, centre, row.y)) samples.push(pixelAt(raster, centre, row.y));
}
}
return samples.length > 0 ? medianColor(samples) : sampleCentral(fromIndex, toIndex);
};
const headTop = 0;
const headEnd = indices.neckIndex;
const hairEnd = headTop + Math.round((headEnd - headTop) * 0.34);
// The face is the lighter half of the head region; hair the darker cap.
const hair = sampleCentral(headTop, Math.max(headTop + 1, hairEnd));
const skin = sampleCentral(hairEnd, Math.max(hairEnd + 1, headEnd));
const armStart = indices.resolvedShoulder;
const armSplit = Math.round(armStart + (indices.crotchIndex - armStart) * 0.55);
return {
hair,
skin,
torsoUpper: sampleCentral(indices.resolvedShoulder, indices.waistIndex),
torsoLower: sampleCentral(indices.waistIndex, indices.crotchIndex),
arm: sampleSideRuns(armStart, armSplit),
hand: sampleSideRuns(armSplit, indices.crotchIndex),
leg: sampleCentral(indices.crotchIndex, indices.ankleIndex),
foot: sampleCentral(indices.ankleIndex, rows.length - 1),
};
}
function centreOfMassX(mask: Mask): number {
let sum = 0;
let count = 0;
for (let y = mask.bounds.minY; y <= mask.bounds.maxY; y++) {
for (let x = mask.bounds.minX; x <= mask.bounds.maxX; x++) {
if (mask.data[y * mask.width + x] !== 1) continue;
sum += x;
count++;
}
}
return count > 0 ? sum / count : mask.width / 2;
}
/**
* First index in `[from, to)` where `predicate` holds and keeps holding for
* `sustain` consecutive rows. Transient silhouette features — a strap, a hem,
* a stray highlight — satisfy a predicate for a row or two; anatomy does not.
*/
function firstSustained(
rows: Array<{ runs: Run[]; central: Run | null }>,
from: number,
to: number,
predicate: (row: { runs: Run[]; central: Run | null }) => boolean,
sustain: number,
): number {
for (let i = Math.max(0, from); i < Math.min(rows.length, to); i++) {
if (!predicate(rows[i]!)) continue;
let held = 0;
while (held < sustain && i + held < rows.length && predicate(rows[i + held]!)) held++;
if (held >= sustain) return i;
}
return -1;
}
function maxRunWidthBetween(
rows: Array<{ central: Run | null }>,
fromIndex: number,
toIndex: number,
): number {
let widest = 0;
for (let i = fromIndex; i < toIndex; i++) {
const central = rows[i]?.central;
if (central) widest = Math.max(widest, runWidth(central));
}
return widest;
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
return sorted[sorted.length >> 1]!;
}
function medianColor(samples: Rgb[]): Rgb {
if (samples.length === 0) return { r: 128, g: 128, b: 128 };
// Median by luminance keeps a real pixel rather than inventing a channel mix.
const sorted = [...samples].sort((a, b) => luminance(a) - luminance(b));
return sorted[sorted.length >> 1]!;
}
+179
View File
@@ -0,0 +1,179 @@
import { colorDistance, pixelAt, type Raster, type Rgb } from "./Raster.js";
/**
* Foreground extraction.
*
* The reference prompt asks for a plain uniform backdrop, which makes this
* tractable without a segmentation model: sample the corners for the backdrop
* colour, then flood-fill inward from the border. Flood-fill rather than a
* global colour threshold matters — a global test would also erase any part of
* the *character* that happens to match the backdrop (grey clothing against
* grey), whereas a fill only removes background actually connected to the edge.
*/
export interface Mask {
width: number;
height: number;
/** 1 = foreground, 0 = background. */
data: Uint8Array;
/** Tight bounds of the foreground. */
bounds: { minX: number; minY: number; maxX: number; maxY: number };
coverage: number;
}
export interface SegmentOptions {
/** Colour distance under which a pixel counts as backdrop. */
tolerance?: number;
/** Fraction of the shorter side sampled at each corner. */
cornerSampleRatio?: number;
}
const DEFAULT_TOLERANCE = 42;
/** Median of the four corner patches, so one odd corner cannot skew it. */
export function estimateBackdrop(raster: Raster, sampleRatio = 0.06): Rgb {
const size = Math.max(2, Math.floor(Math.min(raster.width, raster.height) * sampleRatio));
const reds: number[] = [];
const greens: number[] = [];
const blues: number[] = [];
const corners: Array<[number, number]> = [
[0, 0],
[raster.width - size, 0],
[0, raster.height - size],
[raster.width - size, raster.height - size],
];
for (const [originX, originY] of corners) {
for (let y = originY; y < originY + size; y++) {
for (let x = originX; x < originX + size; x++) {
const pixel = pixelAt(raster, x, y);
reds.push(pixel.r);
greens.push(pixel.g);
blues.push(pixel.b);
}
}
}
return { r: median(reds), g: median(greens), b: median(blues) };
}
export function segmentForeground(raster: Raster, options: SegmentOptions = {}): Mask {
const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
const backdrop = estimateBackdrop(raster, options.cornerSampleRatio);
const { width, height } = raster;
const total = width * height;
// Start as all-foreground; the fill carves the background away.
const data = new Uint8Array(total).fill(1);
// Iterative stack rather than recursion — a 1024x1365 image overflows the
// call stack immediately with a recursive fill.
const stack: number[] = [];
const pushIfBackdrop = (x: number, y: number) => {
if (x < 0 || y < 0 || x >= width || y >= height) return;
const index = y * width + x;
if (data[index] === 0) return;
if (colorDistance(pixelAt(raster, x, y), backdrop) > tolerance) return;
data[index] = 0;
stack.push(index);
};
for (let x = 0; x < width; x++) {
pushIfBackdrop(x, 0);
pushIfBackdrop(x, height - 1);
}
for (let y = 0; y < height; y++) {
pushIfBackdrop(0, y);
pushIfBackdrop(width - 1, y);
}
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = (index - x) / width;
pushIfBackdrop(x + 1, y);
pushIfBackdrop(x - 1, y);
pushIfBackdrop(x, y + 1);
pushIfBackdrop(x, y - 1);
}
const mask: Mask = {
width,
height,
data,
bounds: { minX: width, minY: height, maxX: -1, maxY: -1 },
coverage: 0,
};
let filled = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[y * width + x] !== 1) continue;
filled++;
if (x < mask.bounds.minX) mask.bounds.minX = x;
if (x > mask.bounds.maxX) mask.bounds.maxX = x;
if (y < mask.bounds.minY) mask.bounds.minY = y;
if (y > mask.bounds.maxY) mask.bounds.maxY = y;
}
}
mask.coverage = filled / total;
if (mask.bounds.maxX < 0) {
throw new Error("Segmentation found no foreground — is the background plain and uniform?");
}
return mask;
}
/**
* Removes salt-and-pepper speckle left by JPEG ringing near the silhouette
* edge, which otherwise adds spurious width to the row profile.
*/
export function denoise(mask: Mask, minNeighbours = 5): Mask {
const { width, height, data } = mask;
const out = new Uint8Array(data);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let neighbours = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
neighbours += data[(y + dy) * width + (x + dx)]!;
}
}
const index = y * width + x;
if (data[index] === 1 && neighbours < minNeighbours - 2) out[index] = 0;
else if (data[index] === 0 && neighbours > minNeighbours + 1) out[index] = 1;
}
}
return { ...mask, data: out };
}
export const isForeground = (mask: Mask, x: number, y: number): boolean =>
x >= 0 && y >= 0 && x < mask.width && y < mask.height && mask.data[y * mask.width + x] === 1;
/** Foreground pixel count and horizontal extent for one scanline. */
export function rowProfile(mask: Mask, y: number): { count: number; minX: number; maxX: number } {
let count = 0;
let minX = mask.width;
let maxX = -1;
for (let x = 0; x < mask.width; x++) {
if (mask.data[y * mask.width + x] !== 1) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
}
return { count, minX, maxX };
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 === 0
? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2)
: sorted[mid]!;
}
+177
View File
@@ -0,0 +1,177 @@
import type { Raster, Rgb } from "./Raster.js";
/**
* Synthetic reference figures for testing the analyzer.
*
* Deterministic and with known ground truth, so a landmark assertion means
* something precise. Testing only against a real generated image would prove
* the analyzer works on *that* image; these prove it works against stated
* proportions, and let us build adversarial cases — a backpack that merges with
* the collar, legs that never separate — on demand.
*/
export interface FigureSpec {
width: number;
height: number;
backdrop: Rgb;
/** All values are fractions of image height, measured from the crown. */
headTop: number;
neckY: number;
shoulderY: number;
crotchY: number;
ankleY: number;
soleY: number;
headWidth: number;
neckWidth: number;
torsoWidth: number;
legWidth: number;
legGap: number;
footWidth: number;
armWidth: number;
/** Arms separate from the torso below this height. */
armSeparationY: number;
armReach: number;
/** Ankle width as a fraction of thigh width. */
ankleTaper: number;
colors: {
hair: Rgb;
skin: Rgb;
torso: Rgb;
arm: Rgb;
leg: Rgb;
foot: Rgb;
};
/** Optional pack that projects sideways beside the neck, merging with it. */
backpack?: { fromY: number; toY: number; width: number; color: Rgb };
}
export const DEFAULT_FIGURE: FigureSpec = {
width: 512,
height: 768,
backdrop: { r: 128, g: 128, b: 128 },
headTop: 0.02,
neckY: 0.14,
shoulderY: 0.2,
crotchY: 0.55,
ankleY: 0.93,
soleY: 0.98,
headWidth: 0.1,
neckWidth: 0.05,
torsoWidth: 0.26,
legWidth: 0.09,
legGap: 0.035,
footWidth: 0.11,
armWidth: 0.075,
armSeparationY: 0.3,
armReach: 0.38,
ankleTaper: 0.62,
colors: {
hair: { r: 40, g: 34, b: 30 },
skin: { r: 198, g: 156, b: 130 },
torso: { r: 86, g: 92, b: 60 },
arm: { r: 74, g: 80, b: 52 },
leg: { r: 48, g: 50, b: 58 },
foot: { r: 92, g: 66, b: 44 },
},
};
export function renderFigure(spec: FigureSpec = DEFAULT_FIGURE): Raster {
const { width, height } = spec;
const data = new Uint8Array(width * height * 4);
const put = (x: number, y: number, color: Rgb) => {
if (x < 0 || y < 0 || x >= width || y >= height) return;
const i = (y * width + x) * 4;
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = 255;
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) put(x, y, spec.backdrop);
}
const midX = Math.round(width / 2);
const py = (t: number) => Math.round(t * height);
const px = (t: number) => Math.round(t * height); // widths are height-relative
// Half-widths must be integers: a fractional loop bound produces fractional
// array indices, which typed arrays silently discard, and the shape simply
// never gets drawn.
const bar = (fromY: number, toY: number, halfWidth: number, color: Rgb, centre = midX) => {
const half = Math.round(halfWidth);
for (let y = py(fromY); y < py(toY); y++) {
for (let x = centre - half; x <= centre + half; x++) put(x, y, color);
}
};
// Head: a hair cap over a face, so the palette sampler has two bands to find.
const headHalf = Math.round(px(spec.headWidth) / 2);
const hairEnd = spec.headTop + (spec.neckY - spec.headTop) * 0.34;
bar(spec.headTop, hairEnd, headHalf, spec.colors.hair);
bar(hairEnd, spec.neckY, headHalf, spec.colors.skin);
bar(spec.neckY, spec.shoulderY, Math.round(px(spec.neckWidth) / 2), spec.colors.skin);
if (spec.backpack) {
bar(
spec.backpack.fromY,
spec.backpack.toY,
Math.round(px(spec.backpack.width) / 2),
spec.backpack.color,
);
}
bar(spec.shoulderY, spec.crotchY, Math.round(px(spec.torsoWidth) / 2), spec.colors.torso);
// Arms: angled outward, leaving the torso at `armSeparationY`.
const armHalf = Math.round(px(spec.armWidth) / 2);
const torsoHalf = Math.round(px(spec.torsoWidth) / 2);
const reachPx = px(spec.armReach);
const armStartY = py(spec.shoulderY);
const armEndY = py(spec.crotchY);
for (let y = armStartY; y < armEndY; y++) {
const t = (y - armStartY) / Math.max(1, armEndY - armStartY);
const offset = Math.round(torsoHalf * 0.6 + (reachPx - armHalf - torsoHalf * 0.6) * t);
for (const side of [-1, 1]) {
const centre = midX + side * offset;
for (let x = centre - armHalf; x <= centre + armHalf; x++) put(x, y, spec.colors.arm);
}
}
// Legs and boots.
const legHalf = Math.round(px(spec.legWidth) / 2);
const gapHalf = Math.round(px(spec.legGap) / 2);
const footHalf = Math.round(px(spec.footWidth) / 2);
const ankleHalf = Math.round(legHalf * spec.ankleTaper);
for (const side of [-1, 1]) {
const centre = midX + side * (gapHalf + legHalf);
// Legs taper from thigh to ankle, so the ankle is a genuine local minimum
// in the width profile rather than an arbitrary row of a constant column.
const fromY = py(spec.crotchY);
const toY = py(spec.ankleY);
for (let y = fromY; y < toY; y++) {
const t = (y - fromY) / Math.max(1, toY - fromY);
const half = Math.round(legHalf + (ankleHalf - legHalf) * t);
for (let x = centre - half; x <= centre + half; x++) put(x, y, spec.colors.leg);
}
bar(spec.ankleY, spec.soleY, footHalf, spec.colors.foot, centre);
}
return { width, height, data };
}
/** The adversarial case: a high pack that merges with the collar in silhouette. */
export const PACKED_FIGURE: FigureSpec = {
...DEFAULT_FIGURE,
backpack: {
fromY: 0.155,
toY: 0.45,
width: 0.3,
color: { r: 70, g: 64, b: 50 },
},
};
+27
View File
@@ -0,0 +1,27 @@
import type { Img2MeshRequest, Img2MeshResult, MeshProviderAdapter } from "../types.js";
/**
* Stub MeshProviderAdapter for interface tests.
*
* Characters are produced by the code-drawn pipeline (`runPipeline` /
* `pnpm harness`), not by an external mesh API. This mock remains so anything
* still typed against `MeshProviderAdapter` has a configured stand-in.
*/
export class MockMeshProvider implements MeshProviderAdapter {
readonly name = "mock" as const;
constructor(private readonly placeholderGlbPath = "assets/placeholder/character.glb") {}
isConfigured(): boolean {
return true;
}
async generate(request: Img2MeshRequest): Promise<Img2MeshResult> {
return {
glbPath: this.placeholderGlbPath,
provider: "mock",
triangleCount: request.targetTriangles ?? 900,
generationSeconds: 0,
};
}
}
+18
View File
@@ -0,0 +1,18 @@
export * from "./types.js";
export { GrokBridge } from "./grok/GrokBridge.js";
export { characterReferencePrompt } from "./grok/prompts.js";
export { runPipeline, formatReport, defaultOutputPath } from "./pipeline.js";
export type { PipelineOptions, PipelineResult } from "./pipeline.js";
export { analyzeReference } from "./image/analyze.js";
export { loadRaster } from "./image/Raster.js";
export { buildSkeleton } from "./rig/skeleton.js";
export { buildHumanoidMesh } from "./mesh/humanoid.js";
export { exportGlb } from "./export/glb.js";
export { buildDefaultClips } from "./anim/procedural.js";
/** Modular character creator — preferred path for game cast. */
export { assembleCharacter, formatAssemblyReport } from "./creator/assemble.js";
export { CHARACTER_PRESETS, presetById } from "./creator/presets.js";
export { PART_DEFINITIONS, listParts } from "./creator/parts.js";
export type { CharacterRecipe, BodyType, PartSlot, CreatorPalette } from "./creator/types.js";
/** @deprecated Prefer the modular creator (`assembleCharacter`). Kept for interface tests. */
export { MockMeshProvider } from "./img2mesh/mockProvider.js";
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import {
box,
computeFlatNormals,
createMesh,
domeShell,
loft,
tube,
vertexCount,
type MeshData,
type Vec3Tuple,
} from "./MeshBuilder.js";
const COLOR = { r: 180, g: 140, b: 100 };
/** Mean of triangle normals dotted with (centroid meshCentre). Outward > 0. */
function outwardScore(mesh: MeshData): number {
computeFlatNormals(mesh);
let cx = 0;
let cy = 0;
let cz = 0;
const n = vertexCount(mesh);
for (let i = 0; i < n; i++) {
cx += mesh.positions[i * 3]!;
cy += mesh.positions[i * 3 + 1]!;
cz += mesh.positions[i * 3 + 2]!;
}
cx /= n;
cy /= n;
cz /= n;
let score = 0;
let faces = 0;
for (let i = 0; i < mesh.indices.length; i += 3) {
const a = mesh.indices[i]! * 3;
const b = mesh.indices[i + 1]! * 3;
const c = mesh.indices[i + 2]! * 3;
const px = (mesh.positions[a]! + mesh.positions[b]! + mesh.positions[c]!) / 3;
const py = (mesh.positions[a + 1]! + mesh.positions[b + 1]! + mesh.positions[c + 1]!) / 3;
const pz = (mesh.positions[a + 2]! + mesh.positions[b + 2]! + mesh.positions[c + 2]!) / 3;
// Use the geometric normal of the triangle (not the averaged vertex normal)
// so the score measures winding, not smoothing.
const abx = mesh.positions[b]! - mesh.positions[a]!;
const aby = mesh.positions[b + 1]! - mesh.positions[a + 1]!;
const abz = mesh.positions[b + 2]! - mesh.positions[a + 2]!;
const acx = mesh.positions[c]! - mesh.positions[a]!;
const acy = mesh.positions[c + 1]! - mesh.positions[a + 1]!;
const acz = mesh.positions[c + 2]! - mesh.positions[a + 2]!;
let nx = aby * acz - abz * acy;
let ny = abz * acx - abx * acz;
let nz = abx * acy - aby * acx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len;
ny /= len;
nz /= len;
score += nx * (px - cx) + ny * (py - cy) + nz * (pz - cz);
faces++;
}
return faces > 0 ? score / faces : 0;
}
describe("mesh winding", () => {
it("emits outward-facing fronts on a vertical tube", () => {
const mesh = createMesh();
const from: Vec3Tuple = [0, 0, 0];
const to: Vec3Tuple = [0, 1, 0];
tube(mesh, from, to, 0.2, 0.15, 0.8, COLOR, { sides: 6, sections: 2 });
expect(outwardScore(mesh)).toBeGreaterThan(0.05);
});
it("emits outward-facing fronts on a diagonal tube (A-pose arm case)", () => {
const mesh = createMesh();
tube(mesh, [0, 1, 0], [0.4, 0.4, 0.1], 0.08, 0.06, 0.9, COLOR, { sides: 6, sections: 3 });
expect(outwardScore(mesh)).toBeGreaterThan(0.05);
});
it("emits outward-facing fronts on a keyframed loft (ludus torso path)", () => {
const mesh = createMesh();
loft(
mesh,
[
{ t: 0, c: [0, 0.9, 0], rx: 0.14, rz: 0.1 },
{ t: 0.4, c: [0, 1.1, 0.01], rx: 0.11, rz: 0.08 },
{ t: 0.75, c: [0, 1.3, 0.015], rx: 0.16, rz: 0.1 },
{ t: 1, c: [0, 1.45, 0.02], rx: 0.06, rz: 0.055 },
],
COLOR,
{ sides: 6, rings: 8 },
);
expect(outwardScore(mesh)).toBeGreaterThan(0.05);
});
it("emits outward-facing fronts on an axis-aligned box", () => {
const mesh = createMesh();
box(mesh, [0, 0.5, 0], [0.2, 0.3, 0.15], COLOR);
expect(outwardScore(mesh)).toBeGreaterThan(0.05);
});
it("emits outward-facing fronts on a hair dome shell", () => {
const mesh = createMesh();
// Angled skull axis (hairline → crown), face +Z — same as the head mesh.
const from: Vec3Tuple = [0, 1.52, 0.02];
const to: Vec3Tuple = [0, 1.66, 0.01];
domeShell(mesh, from, to, 0.1, 0.1, COLOR, {
sections: 4,
sides: 8,
frontScale: 0.92,
frontScaleTop: 0.7,
backScale: 1.1,
topScale: 0.82,
faceDir: [0, 0, 1],
});
expect(outwardScore(mesh)).toBeGreaterThan(0.04);
// Crown fan specifically: average normal near the apex should point along +axis.
const axis: Vec3Tuple = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
const al = Math.hypot(axis[0], axis[1], axis[2]) || 1;
const axisN: Vec3Tuple = [axis[0] / al, axis[1] / al, axis[2] / al];
const apex: Vec3Tuple = [to[0] + axisN[0] * 0.01, to[1] + axisN[1] * 0.01, to[2] + axisN[2] * 0.01];
let score = 0;
let faces = 0;
for (let i = 0; i < mesh.indices.length; i += 3) {
const ia = mesh.indices[i]! * 3;
const ib = mesh.indices[i + 1]! * 3;
const ic = mesh.indices[i + 2]! * 3;
const px = (mesh.positions[ia]! + mesh.positions[ib]! + mesh.positions[ic]!) / 3;
const py = (mesh.positions[ia + 1]! + mesh.positions[ib + 1]! + mesh.positions[ic + 1]!) / 3;
const pz = (mesh.positions[ia + 2]! + mesh.positions[ib + 2]! + mesh.positions[ic + 2]!) / 3;
if (Math.hypot(px - apex[0], py - apex[1], pz - apex[2]) > 0.08) continue;
const abx = mesh.positions[ib]! - mesh.positions[ia]!;
const aby = mesh.positions[ib + 1]! - mesh.positions[ia + 1]!;
const abz = mesh.positions[ib + 2]! - mesh.positions[ia + 2]!;
const acx = mesh.positions[ic]! - mesh.positions[ia]!;
const acy = mesh.positions[ic + 1]! - mesh.positions[ia + 1]!;
const acz = mesh.positions[ic + 2]! - mesh.positions[ia + 2]!;
let nx = aby * acz - abz * acy;
let ny = abz * acx - abx * acz;
let nz = abx * acy - aby * acx;
const len = Math.hypot(nx, ny, nz) || 1;
score += (nx / len) * axisN[0] + (ny / len) * axisN[1] + (nz / len) * axisN[2];
faces++;
}
expect(faces).toBeGreaterThan(0);
expect(score / faces).toBeGreaterThan(0.2);
});
});
+809
View File
@@ -0,0 +1,809 @@
import type { Rgb } from "../image/Raster.js";
/**
* Minimal indexed triangle mesh with vertex colours.
*
* Vertex colours rather than a texture atlas: the PSX drew plenty of untextured
* gouraud geometry, and the palette we extract from the reference is per-region
* anyway. Faceted normals keep the Silent Hill / early-3D look without textures.
*/
export interface MeshData {
positions: number[];
normals: number[];
colors: number[];
/** Optional TEXCOORD_0 (u,v per vertex). Empty until unwrap runs. */
uvs: number[];
indices: number[];
/** One entry per vertex, filled in by the skinning pass. */
joints: number[];
weights: number[];
/**
* Body region id per vertex, and where along that region's loft the vertex
* sits (0 at the root ring, 1 at the tip). Written by the tagging helpers in
* `creator/coverage.ts`; garments use them to claim the skin they cover.
* Build-time only — the GLB exporter ignores both.
*/
parts: number[];
ts: number[];
}
export const createMesh = (): MeshData => ({
positions: [],
normals: [],
colors: [],
uvs: [],
indices: [],
joints: [],
weights: [],
parts: [],
ts: [],
});
/** Append `source` geometry into `target`, remapping indices. Skin weights cleared. */
export function appendMesh(target: MeshData, source: MeshData): void {
const base = vertexCount(target);
target.positions.push(...source.positions);
target.normals.push(...source.normals);
target.colors.push(...source.colors);
if (source.uvs.length > 0) {
// Keep UV streams aligned when present; leave empty if neither side has them.
if (target.uvs.length === 0 && base > 0) {
for (let i = 0; i < base; i++) target.uvs.push(0, 0);
}
target.uvs.push(...source.uvs);
} else if (target.uvs.length > 0) {
const n = vertexCount(source);
for (let i = 0; i < n; i++) target.uvs.push(0, 0);
}
// Region tags are sparse — pad both sides to full length so vertex i in the
// merged mesh still names vertex i's region.
padTags(target, base);
padTags(source, vertexCount(source));
target.parts.push(...source.parts);
target.ts.push(...source.ts);
for (const index of source.indices) target.indices.push(index + base);
// Weights are recomputed for the combined mesh after assembly.
target.joints = [];
target.weights = [];
}
/** Fill any untagged tail with the "no region" sentinel. */
export function padTags(mesh: MeshData, count: number): void {
while (mesh.parts.length < count) mesh.parts.push(0);
while (mesh.ts.length < count) mesh.ts.push(0);
}
export type Vec3Tuple = [number, number, number];
export const vertexCount = (mesh: MeshData): number => mesh.positions.length / 3;
export const triangleCount = (mesh: MeshData): number => mesh.indices.length / 3;
function pushVertex(mesh: MeshData, position: Vec3Tuple, color: Rgb): number {
const index = vertexCount(mesh);
mesh.positions.push(position[0], position[1], position[2]);
mesh.normals.push(0, 0, 0);
// glTF COLOR_0 is linear float; the reference is sRGB bytes.
mesh.colors.push(srgbToLinear(color.r), srgbToLinear(color.g), srgbToLinear(color.b), 1);
return index;
}
const srgbToLinear = (channel: number): number => {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
function pushTriangle(mesh: MeshData, a: number, b: number, c: number): void {
mesh.indices.push(a, b, c);
}
function pushQuad(mesh: MeshData, a: number, b: number, c: number, d: number): void {
pushTriangle(mesh, a, b, c);
pushTriangle(mesh, a, c, d);
}
export function add(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
export function sub(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
export function scale(v: Vec3Tuple, s: number): Vec3Tuple {
return [v[0] * s, v[1] * s, v[2] * s];
}
export function lerp(a: Vec3Tuple, b: Vec3Tuple, t: number): Vec3Tuple {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export function length(v: Vec3Tuple): number {
return Math.hypot(v[0], v[1], v[2]);
}
export function normalise(v: Vec3Tuple): Vec3Tuple {
const len = length(v);
return len > 1e-8 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 1, 0];
}
export function cross(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
export function dot(a: Vec3Tuple, b: Vec3Tuple): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
/**
* Local frame with `axis` as the primary direction (limb length).
*
* Pass `hintRight` from the previous ring to **parallel-transport** the frame
* along a bent limb. Without that, each ring picks world-up independently and
* the right vector can spin the long way around the bone (classic tube twist —
* sleeves looking wound 23 turns along an A-pose arm).
*/
export function basisFromAxis(
axis: Vec3Tuple,
hintRight?: Vec3Tuple,
): { forward: Vec3Tuple; right: Vec3Tuple; up: Vec3Tuple } {
const forward = normalise(axis);
if (hintRight) {
// Project previous right onto the plane ⊥ forward, then take the short flip.
let right = sub(hintRight, scale(forward, dot(hintRight, forward)));
if (length(right) > 1e-6) {
right = normalise(right);
if (dot(right, hintRight) < 0) right = scale(right, -1);
const up = normalise(cross(forward, right));
return { forward, right, up };
}
}
const worldUp: Vec3Tuple = Math.abs(forward[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
const right = normalise(cross(worldUp, forward));
const up = normalise(cross(forward, right));
return { forward, right, up };
}
interface Ring {
indices: number[];
/** Frame right vector — fed into the next ring for parallel transport. */
right: Vec3Tuple;
}
/**
* Builds a cross-section ring in a plane perpendicular to `axis`.
*
* `sides` 4 = classic PSX box limb; 6 is the Silent Hill sweet spot (reads
* round at 240p without burning the triangle budget).
*/
function orientedRing(
mesh: MeshData,
centre: Vec3Tuple,
axis: Vec3Tuple,
halfWidth: number,
halfDepth: number,
color: Rgb,
sides: number,
hintRight?: Vec3Tuple,
): Ring {
const { right, up } = basisFromAxis(axis, hintRight);
const indices: number[] = [];
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2 + Math.PI / sides;
// Elliptical profile: width across the body, depth front-to-back.
const rx = Math.cos(angle) * halfWidth;
const ry = Math.sin(angle) * halfDepth;
const position = add(centre, add(scale(right, rx), scale(up, ry)));
indices.push(pushVertex(mesh, position, color));
}
return { indices, right };
}
/**
* Skins the side wall between two rings.
*
* Rings are emitted CCW when looking *along* the extrusion axis (from `lower`
* toward `upper`). For three.js / glTF (CCW front faces) the outward side of
* the tube is then the winding lower[i] → lower[next] → upper[next] → upper[i].
* The previous order (lower → upper → upper_next → lower_next) produced
* inward-facing fronts, so the runtime only showed the mesh interior.
*/
function bridgeRings(mesh: MeshData, lower: Ring, upper: Ring): void {
const n = lower.indices.length;
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
pushQuad(
mesh,
lower.indices[i]!,
lower.indices[next]!,
upper.indices[next]!,
upper.indices[i]!,
);
}
}
function capRing(mesh: MeshData, ring: Ring, axis: Vec3Tuple, outward: boolean): void {
// Fan from the first vertex. Fine for convex rings of 48 sides.
const [first, ...rest] = ring.indices;
if (first === undefined || rest.length < 2) return;
// Ring is CCW looking along +axis. The +axis end cap (outward) keeps that
// order; the axis end cap reverses it so the normal points out of the tube.
for (let i = 0; i < rest.length - 1; i++) {
if (outward) pushTriangle(mesh, first, rest[i]!, rest[i + 1]!);
else pushTriangle(mesh, first, rest[i + 1]!, rest[i]!);
}
void axis;
}
export interface TubeOptions {
/** Cross-section sides. 4 = blocky, 6 = SH1-ish. */
sides?: number;
/** Intermediate rings for skinning bend. */
sections?: number;
/** Cap the ends. */
caps?: boolean;
/**
* Optional per-section width/depth scale curve, t in 0..1.
* Used for muscle/joint bulges (elbow, knee, calf).
*/
profile?: (t: number) => { width: number; depth: number };
}
/**
* A tapered limb between two points. Rings sit perpendicular to the bone so
* diagonal arms stay round instead of becoming stair-stepped world boxes.
*/
export function tube(
mesh: MeshData,
from: Vec3Tuple,
to: Vec3Tuple,
fromHalfWidth: number,
toHalfWidth: number,
depthRatio: number,
color: Rgb,
options: TubeOptions | number = {},
): void {
// Back-compat: older call sites passed `sections` as a bare number.
const opts: TubeOptions = typeof options === "number" ? { sections: options } : options;
const sections = Math.max(1, opts.sections ?? 3);
const sides = Math.max(4, opts.sides ?? 6);
const caps = opts.caps ?? true;
const axis = sub(to, from);
if (length(axis) < 1e-6) return;
const rings: Ring[] = [];
let prevRight: Vec3Tuple | undefined;
for (let s = 0; s <= sections; s++) {
const t = s / sections;
const centre = lerp(from, to, t);
const baseWidth = fromHalfWidth + (toHalfWidth - fromHalfWidth) * t;
const profile = opts.profile?.(t) ?? { width: 1, depth: 1 };
const halfWidth = baseWidth * profile.width;
const halfDepth = baseWidth * depthRatio * profile.depth;
const ring = orientedRing(mesh, centre, axis, halfWidth, halfDepth, color, sides, prevRight);
prevRight = ring.right;
rings.push(ring);
}
for (let s = 0; s < sections; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);
if (caps) {
capRing(mesh, rings[0]!, axis, false);
capRing(mesh, rings[rings.length - 1]!, axis, true);
}
}
/**
* Multi-segment path (e.g. full arm shoulder→elbow→wrist) with continuous
* skinning rings so joints don't show a hard seam.
*/
export function tubePath(
mesh: MeshData,
points: Vec3Tuple[],
halfWidths: number[],
depthRatio: number,
color: Rgb,
options: { sides?: number; sectionsPerSegment?: number; caps?: boolean } = {},
): void {
if (points.length < 2) return;
const sides = options.sides ?? 6;
const sectionsPerSegment = options.sectionsPerSegment ?? 2;
const caps = options.caps ?? true;
const rings: Ring[] = [];
const axes: Vec3Tuple[] = [];
// Parallel-transport the ring frame along the whole path so elbows don't
// introduce a multi-turn twist (right·prev would otherwise jump when the
// bone axis crosses the world-up singularity).
let prevRight: Vec3Tuple | undefined;
for (let segment = 0; segment < points.length - 1; segment++) {
const from = points[segment]!;
const to = points[segment + 1]!;
const fromW = halfWidths[segment] ?? halfWidths[halfWidths.length - 1]!;
const toW = halfWidths[segment + 1] ?? fromW;
const axis = sub(to, from);
if (length(axis) < 1e-6) continue;
const start = segment === 0 ? 0 : 1; // skip duplicate ring at shared joints
for (let s = start; s <= sectionsPerSegment; s++) {
const t = s / sectionsPerSegment;
const centre = lerp(from, to, t);
// Slight bulge mid-segment so elbows/knees don't look like broomsticks.
const bulge = 1 + 0.06 * Math.sin(t * Math.PI);
const halfWidth = (fromW + (toW - fromW) * t) * bulge;
const ring = orientedRing(
mesh,
centre,
axis,
halfWidth,
halfWidth * depthRatio,
color,
sides,
prevRight,
);
prevRight = ring.right;
rings.push(ring);
axes.push(axis);
}
}
for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);
if (caps && rings.length > 0) {
capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false);
capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true);
}
}
/**
* Keyframed cross-section for {@link loft} — same idea as ludus `loftPart`.
* `rx` is half-width on the ring right axis, `rz` half-depth on ring up.
*/
export interface LoftKey {
/** 0..1 path parameter (keys must be sorted ascending). */
t: number;
c: Vec3Tuple;
rx: number;
rz: number;
}
export interface LoftOptions {
/** Cross-section sides. 4 = PSX diamond limb, 6 = angular torso. */
sides?: number;
/** Sampled rings along the key path (defaults to keys.length). */
rings?: number;
/** Cap both ends (default true). Overridden by capStart / capEnd. */
caps?: boolean;
/** Cap the first ring (limb root). Default = caps. */
capStart?: boolean;
/** Cap the last ring (limb tip). Default = caps. */
capEnd?: boolean;
/**
* Seed for the first ring's right axis (`rx` runs along it; `rz` along
* forward × right). Without it the first ring picks world-up, which flips
* 90° as a limb crosses ~26° from vertical — fine for round sections, wrong
* for flat ones like a palm or a foot.
*/
startRight?: Vec3Tuple;
}
const hermite01 = (t: number): number => t * t * (3 - 2 * t);
/**
* Loft an angular tube through keyframed rings (ludus gladiator style).
*
* Centres + independent rx/rz give real anatomical silhouettes; low `sides`
* (46) keeps the PS1 / FF8 faceted look. Ring frames are parallel-transported
* along the path so bent limbs don't twist.
*/
/** Where a loft's vertices landed, so callers can tag them by region. */
export interface LoftResult {
firstVertex: number;
rings: number;
sides: number;
}
export function loft(
mesh: MeshData,
keys: LoftKey[],
color: Rgb,
options: LoftOptions = {},
): LoftResult {
const firstVertex = vertexCount(mesh);
if (keys.length < 2) return { firstVertex, rings: 0, sides: 0 };
const sides = Math.max(4, options.sides ?? 6);
const ringCount = Math.max(2, options.rings ?? keys.length);
const caps = options.caps ?? true;
const capStart = options.capStart ?? caps;
const capEnd = options.capEnd ?? caps;
// Sample smooth centres / radii along the key curve.
type Sample = { c: Vec3Tuple; rx: number; rz: number };
const samples: Sample[] = [];
for (let i = 0; i < ringCount; i++) {
const t = i / (ringCount - 1);
let k = 0;
while (k < keys.length - 2 && (keys[k + 1]?.t ?? 1) < t) k++;
const a = keys[k]!;
const b = keys[k + 1]!;
const span = Math.max(1e-6, b.t - a.t);
const ft = hermite01(Math.min(1, Math.max(0, (t - a.t) / span)));
samples.push({
c: lerp(a.c, b.c, ft),
rx: a.rx + (b.rx - a.rx) * ft,
rz: a.rz + (b.rz - a.rz) * ft,
});
}
const rings: Ring[] = [];
const axes: Vec3Tuple[] = [];
let prevRight: Vec3Tuple | undefined = options.startRight;
for (let i = 0; i < ringCount; i++) {
const prev = samples[Math.max(0, i - 1)]!;
const next = samples[Math.min(ringCount - 1, i + 1)]!;
const axis = sub(next.c, prev.c);
if (length(axis) < 1e-8) {
// Degenerate — fall back to vertical.
const fallback: Vec3Tuple = [0, 1, 0];
const ring = orientedRing(
mesh,
samples[i]!.c,
fallback,
samples[i]!.rx,
samples[i]!.rz,
color,
sides,
prevRight,
);
prevRight = ring.right;
rings.push(ring);
axes.push(fallback);
continue;
}
const ring = orientedRing(
mesh,
samples[i]!.c,
axis,
samples[i]!.rx,
samples[i]!.rz,
color,
sides,
prevRight,
);
prevRight = ring.right;
rings.push(ring);
axes.push(axis);
}
for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);
if (rings.length > 0) {
if (capStart) capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false);
if (capEnd) capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true);
}
// Caps reuse ring vertices, so the vertex block is exactly rings x sides.
return { firstVertex, rings: ringCount, sides };
}
/** Rectangular face opening on lower rings — clears forehead / eyes. */
export interface DomeFaceCut {
/**
* How far up the cap (0 at hairline → 1 at crown) the cut extends.
* Default 0.62 — open through the eye band, closed over the upper scalp.
*/
height?: number;
/**
* Half-width of the cut as a fraction of the ring half-width (01).
* Default 0.62 — temples still wrap; centre front is open.
*/
halfWidth?: number;
/**
* Face-depth of the cut plane as a fraction of halfDepth.
* Default 0.14 — flat “window” pulled back off the face.
*/
depth?: number;
}
export interface DomeShellOptions {
/** Number of rings along the skull axis (not counting the apex). */
sections?: number;
/** Ring sides. 68 reads as a helmet at PSX resolution. */
sides?: number;
/**
* How far the face half of each ring extends relative to halfDepth
* at the hairline. ~0.851.0 covers forehead scalp.
*/
frontScale?: number;
/** Front scale at the crown end of the cap. */
frontScaleTop?: number;
/** How far the occiput half extends relative to halfDepth. */
backScale?: number;
/** Radial scale at the crown ring (keep high so the top isn't bald). */
topScale?: number;
/** World-space face direction (default +Z). */
faceDir?: Vec3Tuple;
/**
* Rectangular cut on the face side of lower rings so forehead and eyes
* stay clear. Temples and crown remain covered.
*/
faceCut?: DomeFaceCut | true;
/** Close the crown end with an apex fan. */
apex?: boolean;
}
/**
* Skull-cap shell extruded along an arbitrary axis (hairline → crown).
*
* Rings lie in planes perpendicular to `from→to`, so the cap follows the
* angled top of the head instead of stacking as a vertical chimney. Face/back
* asymmetry uses the projection of `faceDir` into each ring plane.
*/
export function domeShell(
mesh: MeshData,
from: Vec3Tuple,
to: Vec3Tuple,
halfWidth: number,
halfDepth: number,
color: Rgb,
options: DomeShellOptions = {},
): void {
const axis = sub(to, from);
const axisLen = length(axis);
if (axisLen < 1e-6) return;
const sections = Math.max(2, options.sections ?? 4);
const sides = Math.max(6, options.sides ?? 8);
const frontScaleBottom = options.frontScale ?? 0.88;
const frontScaleTop = options.frontScaleTop ?? frontScaleBottom * 0.72;
const backScale = options.backScale ?? 1.12;
const topScale = options.topScale ?? 0.82;
const withApex = options.apex ?? true;
const faceCutOpt = options.faceCut === true ? {} : options.faceCut;
const cutHeight = faceCutOpt?.height ?? (faceCutOpt ? 0.62 : 0);
const cutHalfWidthFrac = faceCutOpt?.halfWidth ?? 0.62;
const cutDepthFrac = faceCutOpt?.depth ?? 0.14;
const useFaceCut = faceCutOpt != null && cutHeight > 0;
const axisN = normalise(axis);
const faceWanted = options.faceDir ?? ([0, 0, 1] as Vec3Tuple);
// Face vector in the ring plane (perp to skull axis).
let face = sub(faceWanted, scale(axisN, dot(faceWanted, axisN)));
if (length(face) < 1e-5) {
// Axis parallel to faceDir — pick a stable fallback.
const alt: Vec3Tuple = Math.abs(axisN[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
face = sub(alt, scale(axisN, dot(alt, axisN)));
}
face = normalise(face);
// side × face should align with axis for a right-handed frame.
// side = axis × face → looking along axis, face is "up" of the ring, side is "right".
const side = normalise(cross(axisN, face));
const rings: Ring[] = [];
for (let s = 0; s <= sections; s++) {
const t = s / sections;
const radial = 1 - (1 - topScale) * (t * t * 0.85);
const bulge = 1 + 0.05 * Math.sin(t * Math.PI);
const centre = lerp(from, to, t);
const hw = halfWidth * radial * bulge;
const hd = halfDepth * radial * bulge;
const frontScale = frontScaleBottom + (frontScaleTop - frontScaleBottom) * t;
// Soft falloff of the cut as we approach its top edge (keeps crown solid).
const cutBlend =
useFaceCut && t < cutHeight ? 1 - smoothstep(cutHeight * 0.72, cutHeight, t) : 0;
const cutW = hw * cutHalfWidthFrac;
const cutD = hd * cutDepthFrac;
const indices: number[] = [];
for (let i = 0; i < sides; i++) {
// CCW looking along axis: match tube orientedRing convention
// (cos,sin) → (side, +face) so bridgeRings faces outward.
const angle = (i / sides) * Math.PI * 2 + Math.PI / sides;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const depthScale = sin >= 0 ? frontScale : backScale;
let sideAmt = -cos * hw;
let faceAmt = sin * hd * depthScale;
// Rectangular face window: pull centre-front verts back off the face so
// forehead + eyes read clear; temples (|side| > cutW) still wrap forward.
if (cutBlend > 0 && sin > 0.02) {
const sideAbs = Math.abs(sideAmt);
if (sideAbs <= cutW) {
// Hard rectangle in the middle; slight ease at the vertical edges.
const edge = sideAbs / Math.max(cutW, 1e-6);
const rect = edge < 0.85 ? 1 : 1 - (edge - 0.85) / 0.15;
const pull = cutBlend * rect;
faceAmt = faceAmt * (1 - pull) + cutD * pull;
}
}
const offset = add(scale(side, sideAmt), scale(face, faceAmt));
indices.push(pushVertex(mesh, add(centre, offset), color));
}
rings.push({ indices, right: side });
}
for (let s = 0; s < rings.length - 1; s++) {
bridgeRings(mesh, rings[s]!, rings[s + 1]!);
}
if (withApex) {
const top = rings[rings.length - 1]!;
// Slight past `to` along the skull axis.
const apex = pushVertex(mesh, add(to, scale(axisN, axisLen * 0.06)), color);
const n = top.indices.length;
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
// Ring is CCW looking along +axis (from→to). Apex is on the +axis end:
// keep ring order so the fan normal points outward along +axisN.
pushTriangle(mesh, apex, top.indices[i]!, top.indices[next]!);
}
}
}
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / Math.max(edge1 - edge0, 1e-6)));
return t * t * (3 - 2 * t);
}
/** Axis-aligned box, still useful for props and simple feet. */
export function box(
mesh: MeshData,
centre: Vec3Tuple,
half: Vec3Tuple,
color: Rgb,
): void {
const axis: Vec3Tuple = [0, 1, 0];
const lower = orientedRing(
mesh,
[centre[0], centre[1] - half[1], centre[2]],
axis,
half[0],
half[2],
color,
4,
);
const upper = orientedRing(
mesh,
[centre[0], centre[1] + half[1], centre[2]],
axis,
half[0],
half[2],
color,
4,
);
bridgeRings(mesh, lower, upper);
capRing(mesh, lower, axis, false);
capRing(mesh, upper, axis, true);
}
/**
* Oriented box — centre, half-extents in local right/up/forward, and a forward
* axis. Used for hands and boots that must point along the limb.
*
* Each face is wound CCW when viewed from outside so front-face culling keeps
* the exterior visible.
*/
export function orientedBox(
mesh: MeshData,
centre: Vec3Tuple,
halfRight: number,
halfUp: number,
halfForward: number,
forwardAxis: Vec3Tuple,
color: Rgb,
): void {
const { forward, right, up } = basisFromAxis(forwardAxis);
// Index map: bit0 = +right, bit1 = +up, bit2 = +forward
const corner = (r: number, u: number, f: number): Vec3Tuple =>
add(
centre,
add(scale(right, r * halfRight), add(scale(up, u * halfUp), scale(forward, f * halfForward))),
);
const idx = [
pushVertex(mesh, corner(-1, -1, -1), color), // 0
pushVertex(mesh, corner(1, -1, -1), color), // 1
pushVertex(mesh, corner(1, 1, -1), color), // 2
pushVertex(mesh, corner(-1, 1, -1), color), // 3
pushVertex(mesh, corner(-1, -1, 1), color), // 4
pushVertex(mesh, corner(1, -1, 1), color), // 5
pushVertex(mesh, corner(1, 1, 1), color), // 6
pushVertex(mesh, corner(-1, 1, 1), color), // 7
];
// forward (looking toward F): CCW is 0→3→2→1
pushQuad(mesh, idx[0]!, idx[3]!, idx[2]!, idx[1]!);
// +forward
pushQuad(mesh, idx[4]!, idx[5]!, idx[6]!, idx[7]!);
// up
pushQuad(mesh, idx[0]!, idx[1]!, idx[5]!, idx[4]!);
// +up
pushQuad(mesh, idx[3]!, idx[7]!, idx[6]!, idx[2]!);
// right
pushQuad(mesh, idx[0]!, idx[4]!, idx[7]!, idx[3]!);
// +right
pushQuad(mesh, idx[1]!, idx[2]!, idx[6]!, idx[5]!);
}
/**
* Flat normals, computed per triangle and accumulated per shared vertex then
* re-normalised. Shared vertices on a flat face stay faceted enough for the
* era; adjacent coplanar quads don't get a hard crease through the middle.
*/
export function computeFlatNormals(mesh: MeshData): void {
const positions = mesh.positions;
const normals = new Array<number>(positions.length).fill(0);
for (let i = 0; i < mesh.indices.length; i += 3) {
const a = mesh.indices[i]! * 3;
const b = mesh.indices[i + 1]! * 3;
const c = mesh.indices[i + 2]! * 3;
const abx = positions[b]! - positions[a]!;
const aby = positions[b + 1]! - positions[a + 1]!;
const abz = positions[b + 2]! - positions[a + 2]!;
const acx = positions[c]! - positions[a]!;
const acy = positions[c + 1]! - positions[a + 1]!;
const acz = positions[c + 2]! - positions[a + 2]!;
const nx = aby * acz - abz * acy;
const ny = abz * acx - abx * acz;
const nz = abx * acy - aby * acx;
for (const vertex of [a, b, c]) {
normals[vertex] = normals[vertex]! + nx;
normals[vertex + 1] = normals[vertex + 1]! + ny;
normals[vertex + 2] = normals[vertex + 2]! + nz;
}
}
for (let i = 0; i < normals.length; i += 3) {
const x = normals[i]!;
const y = normals[i + 1]!;
const z = normals[i + 2]!;
const len = Math.hypot(x, y, z);
if (len > 1e-8) {
normals[i] = x / len;
normals[i + 1] = y / len;
normals[i + 2] = z / len;
} else {
normals[i] = 0;
normals[i + 1] = 1;
normals[i + 2] = 0;
}
}
mesh.normals = normals;
}
/** Axis-aligned bounds, needed for the GLB accessor min/max. */
export function bounds(mesh: MeshData): { min: Vec3Tuple; max: Vec3Tuple } {
const min: Vec3Tuple = [Infinity, Infinity, Infinity];
const max: Vec3Tuple = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < mesh.positions.length; i += 3) {
for (let axis = 0; axis < 3; axis++) {
const value = mesh.positions[i + axis]!;
if (value < min[axis]!) min[axis] = value;
if (value > max[axis]!) max[axis] = value;
}
}
return { min, max };
}
+231
View File
@@ -0,0 +1,231 @@
import type { CanonicalBone } from "@psx/shared";
import type { Measurements, Palette } from "../image/analyze.js";
import { jointWorld, type Skeleton } from "../rig/skeleton.js";
import {
add,
computeFlatNormals,
createMesh,
lerp,
normalise,
orientedBox,
scale,
sub,
tube,
tubePath,
type MeshData,
type Vec3Tuple,
} from "./MeshBuilder.js";
/**
* Silent Hillstyle low-poly humanoid from measured proportions.
*
* Design targets (SH1 / early RE, not Roblox):
* - ~6001200 triangles, faceted, vertex-coloured
* - Hex cross-sections so limbs read round at 240p
* - Rings oriented along bones (A-pose arms stay clean)
* - Soft torso taper (shoulder → chest → waist → hip)
* - Slightly oversized head, short neck, defined shoulder mass
* - Feet point forward with a heel; hands are flat paddles
*
* Geometry is still drawn between canonical joints so skin weights and clips
* stay portable across every character the harness produces.
*/
export interface HumanoidOptions {
/** Cross-section sides for limbs (6 ≈ SH1). */
sides?: number;
/** Body depth as a fraction of width. Front view cannot measure this. */
depthRatio?: number;
}
export function buildHumanoidMesh(
skeleton: Skeleton,
measurements: Measurements,
palette: Palette,
options: HumanoidOptions = {},
): MeshData {
const mesh = createMesh();
const sides = options.sides ?? 6;
const depthRatio = options.depthRatio ?? 0.58;
const { height } = skeleton;
const across = (fraction: number): number => fraction * height;
const at = (bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone);
const half = (widthFraction: number): number => across(widthFraction) / 2;
const hips = at("Hips");
const spine = at("Spine");
const spine1 = at("Spine1");
const spine2 = at("Spine2");
const neck = at("Neck");
const head = at("Head");
const hipW = half(measurements.widths.hip);
const waistW = half(measurements.widths.waist) * 0.92;
const chestW = half(measurements.widths.chest);
const shoulderW = half(measurements.widths.shoulder);
const neckW = half(measurements.widths.neck);
const headW = half(measurements.widths.head);
// --- pelvis / torso ------------------------------------------------------
// Wider hips, pinched waist, full chest, soft shoulder shelf — the SH1
// jacket silhouette. Slight forward lean on the upper chest for depth.
const pelvisTop = lerp(hips, spine, 0.35);
tube(mesh, hips, pelvisTop, hipW * 1.05, hipW * 0.98, depthRatio + 0.08, palette.torsoLower, {
sides,
sections: 2,
});
// Drop the hip joint centre slightly so the pelvis has volume under the belt.
const belly = lerp(spine, spine1, 0.35);
tube(mesh, pelvisTop, belly, hipW * 0.95, waistW, depthRatio + 0.05, palette.torsoLower, {
sides,
sections: 3,
profile: (t) => ({ width: 1 - 0.04 * Math.sin(t * Math.PI), depth: 1 }),
});
const rib = lerp(spine1, spine2, 0.55);
const ribPoint: Vec3Tuple = [rib[0], rib[1], rib[2] + height * 0.012];
tube(mesh, belly, ribPoint, waistW, chestW * 0.98, depthRatio + 0.1, palette.torsoUpper, {
sides,
sections: 3,
});
// Shoulder yoke — wider than the ribcage, flatter in depth (coat shoulders).
const yoke: Vec3Tuple = [spine2[0], spine2[1] + height * 0.01, spine2[2] + height * 0.008];
tube(mesh, ribPoint, yoke, chestW * 0.95, shoulderW * 0.92, depthRatio * 0.85, palette.torsoUpper, {
sides,
sections: 2,
});
// Collar / neck base.
tube(mesh, yoke, neck, neckW * 1.55, neckW * 1.15, 0.85, palette.torsoUpper, {
sides: 6,
sections: 1,
});
// --- neck + head ---------------------------------------------------------
// Short neck, then an egg-shaped head (taller than wide, slightly deeper face).
tube(mesh, neck, head, neckW, neckW * 0.95, 0.9, palette.skin, { sides: 6, sections: 1 });
const crownY = height;
const chin = head;
const crown: Vec3Tuple = [0, crownY, head[2] * 0.3];
const brow = lerp(chin, crown, 0.45);
const browFwd: Vec3Tuple = [brow[0], brow[1], brow[2] + headW * 0.15];
// Face (skin) — slightly forward of the skull centreline.
tube(mesh, chin, browFwd, headW * 0.72, headW * 0.88, 0.95, palette.skin, {
sides: 6,
sections: 2,
profile: (t) => ({ width: 0.9 + 0.15 * Math.sin(t * Math.PI), depth: 1 }),
});
// Cranial mass + hair cap sitting on top.
const skull: Vec3Tuple = [0, (brow[1] + crownY) * 0.5, head[2] * 0.2];
const hairTop: Vec3Tuple = [0, crownY, head[2] * 0.15];
tube(mesh, browFwd, skull, headW * 0.9, headW * 0.95, 1.05, palette.skin, {
sides: 6,
sections: 1,
caps: false,
});
tube(mesh, skull, hairTop, headW * 0.98, headW * 0.7, 1.05, palette.hair, {
sides: 6,
sections: 2,
profile: (t) => ({ width: 1 + 0.08 * (1 - t), depth: 1 + 0.05 * (1 - t) }),
});
// --- shoulders + arms ----------------------------------------------------
for (const prefix of ["Left", "Right"] as const) {
const side = prefix === "Left" ? 1 : -1;
const shoulder = at(`${prefix}Shoulder` as CanonicalBone);
const arm = at(`${prefix}Arm` as CanonicalBone);
const foreArm = at(`${prefix}ForeArm` as CanonicalBone);
const hand = at(`${prefix}Hand` as CanonicalBone);
const upperHalf = half(measurements.widths.upperArm) * 0.95;
const foreHalf = half(measurements.widths.forearm) * 0.95;
// Deltoid mass bridging torso to upper arm (the SH "coat shoulder").
const deltoid = lerp(shoulder, arm, 0.35);
tube(mesh, shoulder, deltoid, upperHalf * 1.25, upperHalf * 1.1, 1.05, palette.torsoUpper, {
sides,
sections: 2,
});
// Continuous arm path so the elbow bends without a hard mesh break.
tubePath(
mesh,
[deltoid, arm, foreArm, hand],
[upperHalf * 1.05, upperHalf, foreHalf * 1.05, foreHalf * 0.88],
0.92,
palette.arm,
{ sides, sectionsPerSegment: 2 },
);
// Hand paddle past the wrist, flattened.
const handDir = normalise(sub(hand, foreArm));
const handCentre = add(hand, scale(handDir, across(0.028)));
orientedBox(
mesh,
handCentre,
foreHalf * 0.85,
foreHalf * 0.35,
across(0.03),
handDir,
palette.hand,
);
void side;
}
// --- legs + feet ---------------------------------------------------------
for (const prefix of ["Left", "Right"] as const) {
const upLeg = at(`${prefix}UpLeg` as CanonicalBone);
const leg = at(`${prefix}Leg` as CanonicalBone);
const foot = at(`${prefix}Foot` as CanonicalBone);
const toe = at(`${prefix}ToeBase` as CanonicalBone);
const thighHalf = half(measurements.widths.thigh) * 1.02;
const calfHalf = half(measurements.widths.calf);
// Hip join: slight flair so legs don't spike out of a flat pelvis.
const hipJoin = lerp(hips, upLeg, 0.55);
tube(mesh, hipJoin, upLeg, hipW * 0.42, thighHalf * 1.05, depthRatio + 0.15, palette.leg, {
sides,
sections: 1,
});
tubePath(
mesh,
[upLeg, leg, foot],
[thighHalf, thighHalf * 0.92, calfHalf * 0.9],
depthRatio + 0.18,
palette.leg,
{ sides, sectionsPerSegment: 3 },
);
// Boot: sole sits on y=0 so the character is grounded (not floating).
const footHalfW = half(measurements.widths.foot) * 0.95;
const footAxis = normalise(sub(toe, foot));
// If toe is almost under ankle, force forward +Z so boots still read.
const forward: Vec3Tuple =
Math.hypot(footAxis[0], footAxis[2]) < 0.2 ? [0, 0, 1] : [footAxis[0], 0, footAxis[2]];
const bootTop = Math.max(across(0.045), foot[1] * 0.55);
const bootCentre: Vec3Tuple = [
foot[0],
bootTop * 0.5,
foot[2] + across(0.025),
];
orientedBox(
mesh,
bootCentre,
footHalfW,
bootTop * 0.5,
across(0.055),
normalise(forward),
palette.foot,
);
}
computeFlatNormals(mesh);
return mesh;
}
+176
View File
@@ -0,0 +1,176 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { analyzeReference, type ReferenceAnalysis } from "./image/analyze.js";
import { loadRaster } from "./image/Raster.js";
import { denoise, segmentForeground } from "./image/segment.js";
import { buildHumanoidMesh } from "./mesh/humanoid.js";
import { buildSkeleton, type Skeleton } from "./rig/skeleton.js";
import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "./rig/skin.js";
import { buildDefaultClips, type AnimationClip } from "./anim/procedural.js";
import { exportGlb, meshStatistics } from "./export/glb.js";
import { GrokBridge, type ImageResult } from "./grok/GrokBridge.js";
import { characterReferencePrompt } from "./grok/prompts.js";
import type { MeshData } from "./mesh/MeshBuilder.js";
/**
* Photo to rigged, animated GLB — Milestone 3 end to end.
*
* reference image → silhouette → measurements + palette
* → mesh drawn in code
* → canonical skeleton + automatic skin weights
* → procedural clips
* → GLB
*
* The reference can be generated through the Grok bridge or supplied as a file;
* everything downstream is deterministic, so the same image always yields the
* same character.
*/
export interface PipelineOptions {
/** Existing reference image. Takes precedence over `describe`. */
imagePath?: string;
/** Text description, generated into a reference via the Grok bridge. */
describe?: string;
name?: string;
outputPath: string;
/** Target world height in metres. */
height?: number;
/** Also write the analysis as JSON beside the GLB. */
writeReport?: boolean;
clips?: AnimationClip[];
grok?: GrokBridge;
}
export interface PipelineResult {
glbPath: string;
reportPath: string | null;
image: { path: string; generated: boolean; costUsd: number };
analysis: ReferenceAnalysis;
skeleton: Skeleton;
mesh: MeshData;
stats: {
vertices: number;
triangles: number;
bones: number;
clips: number;
maxInfluences: number;
averageInfluences: number;
unweightedVertices: number;
glbBytes: number;
};
}
export async function runPipeline(options: PipelineOptions): Promise<PipelineResult> {
const { imagePath, generated, costUsd } = await resolveReference(options);
// --- analyse -------------------------------------------------------------
const raster = await loadRaster(imagePath);
const mask = denoise(segmentForeground(raster));
const analysis = analyzeReference(raster, mask);
// --- rig and mesh --------------------------------------------------------
// The skeleton is built first: the mesh is drawn *between* its joints, so the
// two can never disagree about where a knee is.
const skeleton = buildSkeleton(analysis.measurements, { height: options.height ?? 1.7 });
const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette);
computeSkinWeights(mesh, skeleton);
const inverseBindMatrices = computeInverseBindMatrices(skeleton);
// --- animate and export --------------------------------------------------
const clips = options.clips ?? buildDefaultClips();
const name = options.name ?? "Character";
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name, clips });
await mkdir(dirname(options.outputPath), { recursive: true });
await writeFile(options.outputPath, glb);
let reportPath: string | null = null;
if (options.writeReport !== false) {
reportPath = options.outputPath.replace(/\.glb$/i, "") + ".analysis.json";
await writeFile(
reportPath,
JSON.stringify(
{
name,
sourceImage: imagePath,
generated,
measurements: analysis.measurements,
palette: analysis.palette,
notes: analysis.notes,
},
null,
2,
),
);
}
const meshStats = meshStatistics(mesh);
const skinStats = skinStatistics(mesh);
return {
glbPath: options.outputPath,
reportPath,
image: { path: imagePath, generated, costUsd },
analysis,
skeleton,
mesh,
stats: {
vertices: meshStats.vertices,
triangles: meshStats.triangles,
bones: skeleton.joints.length,
clips: clips.length,
maxInfluences: skinStats.maxInfluences,
averageInfluences: skinStats.averageInfluences,
unweightedVertices: skinStats.unweighted,
glbBytes: glb.byteLength,
},
};
}
async function resolveReference(
options: PipelineOptions,
): Promise<{ imagePath: string; generated: boolean; costUsd: number }> {
if (options.imagePath) {
return { imagePath: options.imagePath, generated: false, costUsd: 0 };
}
if (!options.describe) {
throw new Error("provide either imagePath or describe");
}
const bridge = options.grok ?? new GrokBridge();
const request = characterReferencePrompt({ description: options.describe });
const result: ImageResult = await bridge.generate(request);
return { imagePath: result.path, generated: !result.cached, costUsd: result.costUsd };
}
/** Human-readable summary for the CLI. */
export function formatReport(result: PipelineResult): string {
const { stats, analysis } = result;
const lines: string[] = [];
lines.push(` source ${result.image.path}${result.image.generated ? " (generated)" : ""}`);
lines.push(` output ${result.glbPath} (${(stats.glbBytes / 1024).toFixed(1)} KB)`);
lines.push(` mesh ${stats.vertices} vertices, ${stats.triangles} triangles`);
lines.push(` rig ${stats.bones} canonical bones, ${stats.clips} clips`);
lines.push(
` skinning max ${stats.maxInfluences} influences, ` +
`avg ${stats.averageInfluences.toFixed(2)}, ${stats.unweightedVertices} unweighted`,
);
const { landmarks } = analysis.measurements;
const derived = Object.entries(landmarks)
.filter(([, landmark]) => landmark.source === "derived")
.map(([name]) => name);
lines.push(` measured ${Object.keys(landmarks).length - derived.length}/${Object.keys(landmarks).length} landmarks`);
if (derived.length > 0) lines.push(` derived ${derived.join(", ")}`);
for (const note of analysis.notes) lines.push(` note ${note}`);
return lines.join("\n");
}
/** Default output location for a named character. */
export const defaultOutputPath = (name: string): string =>
join("assets", "characters", `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.glb`);
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* Offscreen mesh review.
*
* pnpm preview # bare male + female base bodies
* pnpm preview -- --wire # with topology overlay
* pnpm preview -- --clay # flat clay instead of skin palette
* pnpm preview -- --id survivor # an assembled preset
* pnpm preview -- --body female --style mass=0.6,muscle=-0.2,fat=0.3
* pnpm preview -- --body male --face length=-0.4,jaw=0.8,brow=0.6
* pnpm preview -- --backfaces # magenta = inverted winding
*/
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { rasterToPng, type Raster } from "../image/Raster.js";
import { computeFlatNormals } from "../mesh/MeshBuilder.js";
import { buildSkeleton } from "../rig/skeleton.js";
import { assembleCharacter } from "../creator/assemble.js";
import { buildBaseBody } from "../creator/body.js";
import { normalizeBodyStyle, type BodyStyle } from "../creator/bodyStyle.js";
import { normalizeHeadStyle, type HeadStyle } from "../creator/headStyle.js";
import { DEFAULT_HEIGHT, measurementsFor } from "../creator/proportions.js";
import { presetById } from "../creator/presets.js";
import type { BodyType, CreatorPalette } from "../creator/types.js";
import { buildDefaultClips } from "../anim/procedural.js";
import { poseMesh } from "./pose.js";
import { renderSheet, stackRasters, type RenderOptions } from "./render.js";
const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const OUT_DIR = join(ROOT, "assets", "generated", "preview");
const NEUTRAL_PALETTE: CreatorPalette = {
skin: { r: 200, g: 160, b: 130 },
hair: { r: 60, g: 45, b: 38 },
primary: { r: 90, g: 96, b: 110 },
secondary: { r: 60, g: 64, b: 74 },
accent: { r: 150, g: 60, b: 50 },
metal: { r: 150, g: 152, b: 158 },
leather: { r: 80, g: 58, b: 42 },
};
const VIEWS = [
{ yaw: 0 },
{ yaw: 35 },
{ yaw: 90 },
{ yaw: 180 },
];
interface Args {
id?: string;
body?: BodyType;
style: Partial<BodyStyle>;
face: Partial<HeadStyle>;
wire: boolean;
clay: boolean;
backfaces: boolean;
size: number;
out?: string;
clip?: string;
/** Phases through the clip to render, 0..1. */
phases: number[];
help: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { style: {}, face: {}, wire: false, clay: false, backfaces: false, size: 420, phases: [], help: false };
for (let i = 0; i < argv.length; i++) {
const token = argv[i]!;
if (token === "--") continue;
if (token === "--id" || token === "-i") args.id = argv[++i];
else if (token === "--body" || token === "-b") args.body = argv[++i] as BodyType;
else if (token === "--wire" || token === "-w") args.wire = true;
else if (token === "--clay") args.clay = true;
else if (token === "--backfaces") args.backfaces = true;
else if (token === "--size" || token === "-s") args.size = Number(argv[++i]);
else if (token === "--out" || token === "-o") args.out = argv[++i];
else if (token === "--style" || token === "--face") {
const bag = token === "--face" ? args.face : args.style;
for (const pair of (argv[++i] ?? "").split(",")) {
const [key, value] = pair.split("=");
if (key && value !== undefined) {
(bag as Record<string, number>)[key.trim()] = Number(value);
}
}
} else if (token === "--clip" || token === "-c") args.clip = argv[++i];
else if (token === "--phase" || token === "-p") {
args.phases = (argv[++i] ?? "").split(",").map(Number).filter(Number.isFinite);
} else if (token === "--help" || token === "-h") args.help = true;
}
return args;
}
function baseBody(body: BodyType, style: BodyStyle, face: HeadStyle) {
const skeleton = buildSkeleton(measurementsFor(body), { height: DEFAULT_HEIGHT[body] });
const mesh = buildBaseBody(skeleton, body, NEUTRAL_PALETTE, style, face);
computeFlatNormals(mesh);
return mesh;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
[
"pnpm preview bare male + female base bodies",
"pnpm preview -- --wire topology overlay",
"pnpm preview -- --clay flat clay shading",
"pnpm preview -- --backfaces magenta = inverted winding",
"pnpm preview -- --id survivor assembled preset",
"pnpm preview -- --body female --style mass=0.5,fat=0.3",
"pnpm preview -- --body male --face length=-0.4,jaw=0.8,brow=0.6",
"pnpm preview -- --id survivor --clip Walk four phases of a clip",
"pnpm preview -- --id survivor --clip Walk --phase 0.25,0.5",
"pnpm preview -- --size 520 --out /tmp/base.png",
].join("\n"),
);
return;
}
const options: RenderOptions = {
size: args.size,
wireframe: args.wire,
showBackfaces: args.backfaces,
...(args.clay ? { clay: { r: 186, g: 182, b: 176 } } : {}),
};
const rows: Raster[] = [];
let label: string;
if (args.id) {
const recipe = presetById(args.id);
if (!recipe) throw new Error(`Unknown preset "${args.id}"`);
const assembled = assembleCharacter(recipe);
if (args.clip) {
// Deformation, not silhouette: one view, several moments in the cycle.
const clip = buildDefaultClips().find(
(c) => c.name.toLowerCase() === args.clip!.toLowerCase(),
);
if (!clip) {
throw new Error(
`Unknown clip "${args.clip}". Have: ${buildDefaultClips().map((c) => c.name).join(", ")}`,
);
}
const phases = args.phases.length > 0 ? args.phases : [0, 0.25, 0.5, 0.75];
for (const yaw of [90, 35]) {
rows.push(
renderSheet(
phases.map((phase) => ({
mesh: poseMesh(assembled.mesh, assembled.skeleton, clip, phase * clip.duration),
yaw,
})),
options,
),
);
}
label = `${recipe.id}-${clip.name.toLowerCase()}`;
} else {
rows.push(renderSheet(VIEWS.map((v) => ({ mesh: assembled.mesh, ...v })), options));
label = recipe.id;
}
} else {
const style = normalizeBodyStyle(args.style);
const face = normalizeHeadStyle(args.face);
const bodies: BodyType[] = args.body ? [args.body] : ["male", "female"];
for (const body of bodies) {
const mesh = baseBody(body, style, face);
console.log(
`${body.padEnd(7)} ${mesh.positions.length / 3} vertices, ${mesh.indices.length / 3} triangles`,
);
rows.push(renderSheet(VIEWS.map((v) => ({ mesh, ...v })), options));
}
label = args.body ? `base-${args.body}` : "base-body";
}
const sheet = stackRasters(rows);
const outPath = args.out ?? join(OUT_DIR, `${label}.png`);
await mkdir(resolve(outPath, ".."), { recursive: true });
await writeFile(outPath, rasterToPng(sheet));
console.log(`${outPath} ${sheet.width}x${sheet.height}`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+160
View File
@@ -0,0 +1,160 @@
import { CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared";
import type { AnimationClip } from "../anim/procedural.js";
import { computeFlatNormals, vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";
/**
* Linear blend skinning on the CPU, so a clip can be inspected offscreen.
*
* Skin weights are only wrong in ways you can see once the skeleton moves —
* a heel split 50/50 between shin and foot looks perfect in bind pose and
* shears into a wedge the moment the ankle pitches. Rendering a posed mesh is
* the only way to catch that without launching the game.
*
* Matches the runtime's convention: rest rotations are identity, rest
* translations are the joint's local offset, and the inverse bind matrix is a
* translation by the negated rest world position.
*/
/** Column-major 4x4, matching glTF. */
type Mat4 = Float32Array;
function identity(): Mat4 {
const m = new Float32Array(16);
m[0] = 1;
m[5] = 1;
m[10] = 1;
m[15] = 1;
return m;
}
function multiply(a: Mat4, b: Mat4): Mat4 {
const out = new Float32Array(16);
for (let col = 0; col < 4; col++) {
for (let row = 0; row < 4; row++) {
let sum = 0;
for (let k = 0; k < 4; k++) sum += a[k * 4 + row]! * b[col * 4 + k]!;
out[col * 4 + row] = sum;
}
}
return out;
}
/** Translation composed with an Euler XYZ rotation — the clips' channel form. */
function trs(translation: readonly number[], euler: readonly [number, number, number]): Mat4 {
const [x, y, z] = euler;
const cx = Math.cos(x);
const sx = Math.sin(x);
const cy = Math.cos(y);
const sy = Math.sin(y);
const cz = Math.cos(z);
const sz = Math.sin(z);
// R = Rz * Ry * Rx, matching eulerToQuaternion in anim/procedural.ts.
const m = identity();
m[0] = cy * cz;
m[1] = cy * sz;
m[2] = -sy;
m[4] = sx * sy * cz - cx * sz;
m[5] = sx * sy * sz + cx * cz;
m[6] = sx * cy;
m[8] = cx * sy * cz + sx * sz;
m[9] = cx * sy * sz - sx * cz;
m[10] = cx * cy;
m[12] = translation[0]!;
m[13] = translation[1]!;
m[14] = translation[2]!;
return m;
}
/** Sample a channel's keyframes, lerping the Euler triples. */
function sampleEuler(clip: AnimationClip, bone: CanonicalBone, time: number): [number, number, number] {
const channel = clip.channels.find((c) => c.bone === bone);
if (!channel || channel.keyframes.length === 0) return [0, 0, 0];
const keys = channel.keyframes;
if (time <= keys[0]!.time) return keys[0]!.rotation;
const last = keys[keys.length - 1]!;
if (time >= last.time) return last.rotation;
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i]!;
const b = keys[i + 1]!;
if (time > b.time) continue;
const span = b.time - a.time;
const f = span > 1e-6 ? (time - a.time) / span : 0;
return [
a.rotation[0] + (b.rotation[0] - a.rotation[0]) * f,
a.rotation[1] + (b.rotation[1] - a.rotation[1]) * f,
a.rotation[2] + (b.rotation[2] - a.rotation[2]) * f,
];
}
return last.rotation;
}
/** Skinning matrices, one per joint, in the skeleton's own joint order. */
export function poseMatrices(skeleton: Skeleton, clip: AnimationClip, time: number): Mat4[] {
const world: Mat4[] = [];
const skin: Mat4[] = [];
for (const [i, joint] of skeleton.joints.entries()) {
const local = trs(joint.local, sampleEuler(clip, joint.name, time));
const parentName = CANONICAL_HIERARCHY[joint.name];
const parentIndex = parentName === null ? -1 : (skeleton.index.get(parentName) ?? -1);
world[i] = parentIndex >= 0 ? multiply(world[parentIndex]!, local) : local;
// Inverse bind is a pure translation by the negated rest world position.
const inverseBind = identity();
inverseBind[12] = -joint.world[0];
inverseBind[13] = -joint.world[1];
inverseBind[14] = -joint.world[2];
skin[i] = multiply(world[i]!, inverseBind);
}
return skin;
}
/** A copy of `mesh` deformed into the clip's pose at `time`. */
export function poseMesh(
mesh: MeshData,
skeleton: Skeleton,
clip: AnimationClip,
time: number,
): MeshData {
const skin = poseMatrices(skeleton, clip, time);
const count = vertexCount(mesh);
const posed: MeshData = { ...mesh, positions: new Array<number>(count * 3).fill(0) };
for (let v = 0; v < count; v++) {
const x = mesh.positions[v * 3]!;
const y = mesh.positions[v * 3 + 1]!;
const z = mesh.positions[v * 3 + 2]!;
let ox = 0;
let oy = 0;
let oz = 0;
let total = 0;
for (let i = 0; i < 4; i++) {
const weight = mesh.weights[v * 4 + i] ?? 0;
if (weight <= 0) continue;
const m = skin[mesh.joints[v * 4 + i]!];
if (!m) continue;
total += weight;
ox += weight * (m[0]! * x + m[4]! * y + m[8]! * z + m[12]!);
oy += weight * (m[1]! * x + m[5]! * y + m[9]! * z + m[13]!);
oz += weight * (m[2]! * x + m[6]! * y + m[10]! * z + m[14]!);
}
if (total <= 0) {
ox = x;
oy = y;
oz = z;
}
posed.positions[v * 3] = ox;
posed.positions[v * 3 + 1] = oy;
posed.positions[v * 3 + 2] = oz;
}
posed.normals = [];
computeFlatNormals(posed);
return posed;
}
+397
View File
@@ -0,0 +1,397 @@
import type { Raster, Rgb } from "../image/Raster.js";
import { bounds, type MeshData, type Vec3Tuple } from "../mesh/MeshBuilder.js";
/**
* Software rasteriser for mesh review.
*
* The creator emits GLB, which means the only way to judge a silhouette used to
* be opening the web viewer. That is a bad loop for authoring a *base* mesh,
* where every change is a judgement call about shape. This renders the same
* `MeshData` the exporter sees, offscreen, to a PNG contact sheet: front /
* three-quarter / side, with head-unit rules for proportion checks.
*
* Orthographic on purpose — perspective foreshortening lies about proportion,
* and the game's fixed cameras are near-ortho at range anyway.
*/
export interface RenderOptions {
/** Tile size in pixels. */
size?: number;
/** Camera yaw in degrees. 0 = front (looking down Z), 90 = the figure's left. */
yaw?: number;
/** Camera pitch in degrees. Positive looks down on the figure. */
pitch?: number;
/** Background fill. */
background?: Rgb;
/** Override every vertex colour with flat clay — shape reads without palette noise. */
clay?: Rgb | null;
/** Draw triangle edges over the shading. */
wireframe?: boolean;
/** Paint back-facing triangles magenta instead of culling (winding check). */
showBackfaces?: boolean;
/** Horizontal rules every head-height (figure height / 7.5) plus a centre line. */
guides?: boolean;
/** Vertical fraction of the tile the figure fills. */
fill?: number;
}
const BACKFACE: Rgb = { r: 255, g: 0, b: 190 };
const GUIDE: Rgb = { r: 62, g: 70, b: 84 };
const CENTRE_GUIDE: Rgb = { r: 82, g: 92, b: 110 };
const WIRE: Rgb = { r: 20, g: 22, b: 28 };
const DEFAULT_BG: Rgb = { r: 30, g: 33, b: 40 };
/** Key / fill / rim in view space — a three-point setup so the far side isn't black. */
const LIGHTS: { dir: Vec3Tuple; intensity: number }[] = [
{ dir: [-0.45, 0.55, 0.7], intensity: 0.85 },
{ dir: [0.7, 0.1, 0.35], intensity: 0.3 },
{ dir: [0.1, -0.3, -0.8], intensity: 0.22 },
];
const AMBIENT = 0.22;
const linearToSrgb = (c: number): number => {
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, Math.round(v * 255)));
};
const norm = (v: Vec3Tuple): Vec3Tuple => {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
};
const NORMALISED_LIGHTS = LIGHTS.map((l) => ({ dir: norm(l.dir), intensity: l.intensity }));
function rotate(p: Vec3Tuple, yaw: number, pitch: number): Vec3Tuple {
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const x = p[0] * cy + p[2] * sy;
const z = -p[0] * sy + p[2] * cy;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
return [x, p[1] * cp - z * sp, p[1] * sp + z * cp];
}
export interface View {
raster: Raster;
/** Pixels per world metre — callers overlay their own annotations with it. */
scale: number;
}
export function renderMesh(mesh: MeshData, options: RenderOptions = {}): View {
const size = options.size ?? 420;
const yaw = ((options.yaw ?? 0) * Math.PI) / 180;
const pitch = ((options.pitch ?? 0) * Math.PI) / 180;
const bg = options.background ?? DEFAULT_BG;
const clay = options.clay ?? null;
const fill = options.fill ?? 0.88;
const width = size;
const height = size;
const data = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i++) {
data[i * 4] = bg.r;
data[i * 4 + 1] = bg.g;
data[i * 4 + 2] = bg.b;
data[i * 4 + 3] = 255;
}
const depth = new Float32Array(width * height).fill(-Infinity);
const { min, max } = bounds(mesh);
const modelHeight = Math.max(1e-6, max[1] - min[1]);
// Fit on height alone so male / female / clothed tiles stay directly comparable.
const scale = (height * fill) / modelHeight;
const centreY = (min[1] + max[1]) / 2;
const originX = width / 2;
const originY = height / 2;
const project = (p: Vec3Tuple): { x: number; y: number; z: number } => {
const r = rotate(p, yaw, pitch);
return {
x: originX + r[0] * scale,
y: originY - (r[1] - centreY) * scale,
z: r[2],
};
};
if (options.guides !== false) {
drawGuides(data, width, height, min, max, centreY, scale, originX, originY);
}
const vertexCount = mesh.positions.length / 3;
const screen = new Float32Array(vertexCount * 3);
const viewNormal = new Float32Array(vertexCount * 3);
for (let v = 0; v < vertexCount; v++) {
const p = project([
mesh.positions[v * 3]!,
mesh.positions[v * 3 + 1]!,
mesh.positions[v * 3 + 2]!,
]);
screen[v * 3] = p.x;
screen[v * 3 + 1] = p.y;
screen[v * 3 + 2] = p.z;
const n = rotate(
[mesh.normals[v * 3] ?? 0, mesh.normals[v * 3 + 1] ?? 1, mesh.normals[v * 3 + 2] ?? 0],
yaw,
pitch,
);
viewNormal[v * 3] = n[0];
viewNormal[v * 3 + 1] = n[1];
viewNormal[v * 3 + 2] = n[2];
}
const edges: [number, number][] = [];
for (let t = 0; t < mesh.indices.length; t += 3) {
const ia = mesh.indices[t]!;
const ib = mesh.indices[t + 1]!;
const ic = mesh.indices[t + 2]!;
const ax = screen[ia * 3]!;
const ay = screen[ia * 3 + 1]!;
const az = screen[ia * 3 + 2]!;
const bx = screen[ib * 3]!;
const by = screen[ib * 3 + 1]!;
const bz = screen[ib * 3 + 2]!;
const cx = screen[ic * 3]!;
const cy = screen[ic * 3 + 1]!;
const cz = screen[ic * 3 + 2]!;
// Screen y is flipped, so a CCW (front-facing) triangle has negative area.
const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
if (area === 0) continue;
const back = area > 0;
if (back && !options.showBackfaces) continue;
const minX = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
const maxX = Math.min(width - 1, Math.ceil(Math.max(ax, bx, cx)));
const minY = Math.max(0, Math.floor(Math.min(ay, by, cy)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(ay, by, cy)));
if (minX > maxX || minY > maxY) continue;
const inv = 1 / area;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const px = x + 0.5;
const py = y + 0.5;
let w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * inv;
let w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * inv;
let w2 = 1 - w0 - w1;
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
// w0 is opposite c, w1 opposite a, w2 opposite b.
const la = w1;
const lb = w2;
const lc = w0;
const z = la * az + lb * bz + lc * cz;
const at = y * width + x;
if (z <= depth[at]!) continue;
depth[at] = z;
let shade: Rgb;
if (back) {
shade = BACKFACE;
} else {
const nx = la * viewNormal[ia * 3]! + lb * viewNormal[ib * 3]! + lc * viewNormal[ic * 3]!;
const ny =
la * viewNormal[ia * 3 + 1]! +
lb * viewNormal[ib * 3 + 1]! +
lc * viewNormal[ic * 3 + 1]!;
const nz =
la * viewNormal[ia * 3 + 2]! +
lb * viewNormal[ib * 3 + 2]! +
lc * viewNormal[ic * 3 + 2]!;
const n = norm([nx, ny, nz]);
let lit = AMBIENT;
for (const light of NORMALISED_LIGHTS) {
const d = n[0] * light.dir[0] + n[1] * light.dir[1] + n[2] * light.dir[2];
// Half-lambert: PSX-era vertex lighting never went fully black either.
lit += Math.max(0, d * 0.5 + 0.5) * light.intensity;
}
if (clay) {
shade = {
r: linearToSrgb(srgbToLinear(clay.r) * lit),
g: linearToSrgb(srgbToLinear(clay.g) * lit),
b: linearToSrgb(srgbToLinear(clay.b) * lit),
};
} else {
const cr = la * mesh.colors[ia * 4]! + lb * mesh.colors[ib * 4]! + lc * mesh.colors[ic * 4]!;
const cg =
la * mesh.colors[ia * 4 + 1]! +
lb * mesh.colors[ib * 4 + 1]! +
lc * mesh.colors[ic * 4 + 1]!;
const cb =
la * mesh.colors[ia * 4 + 2]! +
lb * mesh.colors[ib * 4 + 2]! +
lc * mesh.colors[ic * 4 + 2]!;
shade = {
r: linearToSrgb(cr * lit),
g: linearToSrgb(cg * lit),
b: linearToSrgb(cb * lit),
};
}
}
data[at * 4] = shade.r;
data[at * 4 + 1] = shade.g;
data[at * 4 + 2] = shade.b;
}
}
if (options.wireframe && !back) {
edges.push([ia, ib], [ib, ic], [ic, ia]);
}
}
if (options.wireframe) {
for (const [a, b] of edges) {
drawLine(
data,
depth,
width,
height,
screen[a * 3]!,
screen[a * 3 + 1]!,
screen[a * 3 + 2]!,
screen[b * 3]!,
screen[b * 3 + 1]!,
screen[b * 3 + 2]!,
WIRE,
);
}
}
return { raster: { width, height, data }, scale };
}
const srgbToLinear = (channel: number): number => {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
/** Head-unit rules + centre line, drawn under the figure. */
function drawGuides(
data: Uint8Array,
width: number,
height: number,
min: Vec3Tuple,
max: Vec3Tuple,
centreY: number,
scale: number,
originX: number,
originY: number,
): void {
const figureHeight = max[1] - min[1];
const headUnit = figureHeight / 7.5;
const put = (x: number, y: number, c: Rgb) => {
if (x < 0 || y < 0 || x >= width || y >= height) return;
const i = (y * width + x) * 4;
data[i] = c.r;
data[i + 1] = c.g;
data[i + 2] = c.b;
};
for (let u = 0; u <= 7.5; u += 1) {
const worldY = max[1] - u * headUnit;
const y = Math.round(originY - (worldY - centreY) * scale);
for (let x = 0; x < width; x++) put(x, y, GUIDE);
}
for (let y = 0; y < height; y++) put(Math.round(originX), y, CENTRE_GUIDE);
}
/** Depth-tested line for the wireframe overlay. */
function drawLine(
data: Uint8Array,
depth: Float32Array,
width: number,
height: number,
x0: number,
y0: number,
z0: number,
x1: number,
y1: number,
z1: number,
color: Rgb,
): void {
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const at = y * width + x;
const z = z0 + (z1 - z0) * t;
// Small bias so edges win against their own faces but stay hidden behind others.
if (z < depth[at]! - 1e-4) continue;
data[at * 4] = color.r;
data[at * 4 + 1] = color.g;
data[at * 4 + 2] = color.b;
}
}
export interface SheetTile {
mesh: MeshData;
yaw?: number;
pitch?: number;
}
/** Horizontal contact sheet — one tile per view, shared scale. */
export function renderSheet(tiles: SheetTile[], options: RenderOptions = {}): Raster {
const size = options.size ?? 420;
const views = tiles.map((tile) =>
renderMesh(tile.mesh, {
...options,
...(tile.yaw === undefined ? {} : { yaw: tile.yaw }),
...(tile.pitch === undefined ? {} : { pitch: tile.pitch }),
}),
);
const width = size * views.length;
const data = new Uint8Array(width * size * 4);
for (let i = 0; i < views.length; i++) {
const src = views[i]!.raster;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const from = (y * size + x) * 4;
const to = (y * width + i * size + x) * 4;
data[to] = src.data[from]!;
data[to + 1] = src.data[from + 1]!;
data[to + 2] = src.data[from + 2]!;
data[to + 3] = 255;
}
}
}
// Tile separators.
for (let i = 1; i < views.length; i++) {
for (let y = 0; y < size; y++) {
const to = (y * width + i * size) * 4;
data[to] = 12;
data[to + 1] = 13;
data[to + 2] = 16;
}
}
return { width, height: size, data };
}
/** Stack contact sheets vertically (e.g. a male row above a female row). */
export function stackRasters(rows: Raster[]): Raster {
const width = Math.max(...rows.map((r) => r.width));
const height = rows.reduce((sum, r) => sum + r.height, 0);
const data = new Uint8Array(width * height * 4);
let offsetY = 0;
for (const row of rows) {
for (let y = 0; y < row.height; y++) {
for (let x = 0; x < row.width; x++) {
const from = (y * row.width + x) * 4;
const to = ((offsetY + y) * width + x) * 4;
data[to] = row.data[from]!;
data[to + 1] = row.data[from + 1]!;
data[to + 2] = row.data[from + 2]!;
data[to + 3] = 255;
}
}
offsetY += row.height;
}
return { width, height, data };
}
+185
View File
@@ -0,0 +1,185 @@
import { CANONICAL_BONES, CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared";
import type { Measurements } from "../image/analyze.js";
/**
* Builds a canonical skeleton scaled to a measured figure.
*
* Bone *positions* come from the reference wherever the analyzer measured
* something; the ones a front-view silhouette cannot show — how far forward the
* shoulder joint sits inside the deltoid, the depth of the hip socket — come
* from fixed anatomical ratios. Those are the same for every character, so
* clips authored against the canonical skeleton stay portable (GDD §10.2).
*
* Coordinates are metres, Y up, Z forward, origin between the feet — matching
* the runtime's world space, so an exported GLB drops straight in.
*/
export interface Joint {
name: CanonicalBone;
parent: CanonicalBone | null;
/** Rest position in world space. */
world: [number, number, number];
/** Rest position relative to the parent joint; what the GLB node stores. */
local: [number, number, number];
}
export interface Skeleton {
joints: Joint[];
index: Map<CanonicalBone, number>;
/** Total height in metres, feet to crown. */
height: number;
}
export interface SkeletonOptions {
/** Target world height in metres. PSX protagonists sit around 1.7. */
height?: number;
}
/** Depth of the body, as a fraction of height. Not observable from the front. */
const BODY_DEPTH = 0.06;
export function buildSkeleton(
measurements: Measurements,
options: SkeletonOptions = {},
): Skeleton {
const height = options.height ?? 1.7;
const { landmarks, widths } = measurements;
// The analyzer measures downward from the crown; the skeleton is built upward
// from the floor, so every landmark is flipped here once.
const up = (fromCrown: number): number => (1 - fromCrown) * height;
const across = (fraction: number): number => fraction * height;
const shoulderY = up(landmarks.shoulder.y);
const neckY = up(landmarks.neck.y);
const hipY = up(landmarks.hip.y);
const crotchY = up(landmarks.crotch.y);
const kneeY = up(landmarks.knee.y);
const ankleY = up(landmarks.ankle.y);
const chestY = up(landmarks.chest.y);
const waistY = up(landmarks.waist.y);
// Hips sit slightly above the crotch: the joint is inside the pelvis, not at
// the gap between the legs.
const hipsRootY = crotchY + (hipY - crotchY) * 0.4 + height * 0.015;
const halfHip = across(widths.hip) / 2;
const halfShoulder = across(widths.shoulder) / 2;
// Slightly wider stance than pure silhouette so legs clear the coat hem.
const legX = Math.max(halfHip * 0.48, across(Math.max(measurements.stance, 0.045)));
// Deltoid sits inboard of the silhouette edge.
const shoulderJointX = halfShoulder * 0.82;
// Shoulder joint to fingertip, split on standard segment ratios
// (humerus 42% / radius 33% / hand 25%). The hand length is left to the mesh:
// the Hand joint is the wrist.
const armLength = Math.max(across(measurements.armReach) - shoulderJointX, height * 0.32);
const upperArmLength = armLength * 0.423;
const forearmLength = armLength * 0.332;
const handLength = armLength * 0.245;
// Soft A-pose (~22° from the body) — SH1 rest pose, not a hard T or 45° V.
const armAngle = (22 * Math.PI) / 180;
const armDrop = Math.cos(armAngle);
const armOut = Math.sin(armAngle);
// Mild chest forward offset so the torso has a front, not a flat slab.
const chestZ = across(BODY_DEPTH) * 0.35;
const world = new Map<CanonicalBone, [number, number, number]>();
const set = (bone: CanonicalBone, x: number, y: number, z: number) =>
world.set(bone, [x, y, z]);
set("Hips", 0, hipsRootY, 0);
set("Spine", 0, hipsRootY + (waistY - hipsRootY) * 0.5, chestZ * 0.15);
set("Spine1", 0, waistY + (chestY - waistY) * 0.45, chestZ * 0.55);
set("Spine2", 0, chestY + (shoulderY - chestY) * 0.15, chestZ * 0.75);
set("Neck", 0, neckY - height * 0.01, chestZ * 0.4);
set("Head", 0, neckY + height * 0.035, chestZ * 0.35);
for (const side of [-1, 1] as const) {
const prefix = side < 0 ? "Right" : "Left";
// Clavicle sits near the neck; shoulder joint out at the deltoid.
set(
`${prefix}Shoulder` as CanonicalBone,
side * halfShoulder * 0.28,
shoulderY + height * 0.005,
chestZ * 0.5,
);
set(
`${prefix}Arm` as CanonicalBone,
side * shoulderJointX,
shoulderY - height * 0.01,
chestZ * 0.25,
);
set(
`${prefix}ForeArm` as CanonicalBone,
side * (shoulderJointX + upperArmLength * armOut),
shoulderY - upperArmLength * armDrop,
chestZ * 0.1,
);
set(
`${prefix}Hand` as CanonicalBone,
side * (shoulderJointX + (upperArmLength + forearmLength) * armOut),
shoulderY - (upperArmLength + forearmLength) * armDrop,
chestZ * 0.05,
);
set(`${prefix}UpLeg` as CanonicalBone, side * legX, hipsRootY - height * 0.01, 0);
set(`${prefix}Leg` as CanonicalBone, side * legX, kneeY, 0);
set(`${prefix}Foot` as CanonicalBone, side * legX, ankleY, 0);
set(
`${prefix}ToeBase` as CanonicalBone,
side * legX,
across(0.02),
across(BODY_DEPTH) * 2.1,
);
}
void handLength;
const joints: Joint[] = [];
const index = new Map<CanonicalBone, number>();
// Parents must precede children so local offsets can be resolved in one pass.
for (const bone of orderedBones()) {
const position = world.get(bone);
if (!position) throw new Error(`skeleton is missing bone ${bone}`);
const parent = CANONICAL_HIERARCHY[bone];
const parentPosition = parent ? world.get(parent) : null;
const local: [number, number, number] = parentPosition
? [
position[0] - parentPosition[0],
position[1] - parentPosition[1],
position[2] - parentPosition[2],
]
: [...position];
index.set(bone, joints.length);
joints.push({ name: bone, parent, world: position, local });
}
return { joints, index, height };
}
/** Depth-first order, parents before children. */
function orderedBones(): CanonicalBone[] {
const out: CanonicalBone[] = [];
const visit = (bone: CanonicalBone) => {
out.push(bone);
for (const child of CANONICAL_BONES) {
if (CANONICAL_HIERARCHY[child] === bone) visit(child);
}
};
visit("Hips");
return out;
}
/** Rest-pose world position of a bone. */
export function jointWorld(skeleton: Skeleton, bone: CanonicalBone): [number, number, number] {
const joint = skeleton.joints[skeleton.index.get(bone)!];
if (!joint) throw new Error(`unknown bone ${bone}`);
return joint.world;
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { assembleCharacter } from "../creator/assemble.js";
import { CHARACTER_PRESETS } from "../creator/presets.js";
import { vertexCount } from "../mesh/MeshBuilder.js";
/**
* Weight defects are invisible in bind pose — they only show once a clip runs.
* These assert the two that produced visible tearing: the far leg claiming a
* share of this one (dragging heels across during a stride) and the shin
* claiming half of every heel vertex (shearing the boot as the ankle pitches).
*/
const survivor = () => assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!);
describe("skin weights", () => {
it("keeps every vertex fully weighted", () => {
const { mesh } = survivor();
for (let v = 0; v < vertexCount(mesh); v++) {
let sum = 0;
for (let i = 0; i < 4; i++) sum += mesh.weights[v * 4 + i]!;
expect(sum).toBeCloseTo(1, 5);
}
});
it("never lets one leg influence geometry on the other side", () => {
const { mesh, skeleton } = survivor();
const name = (i: number) => skeleton.joints[i]!.name;
let offenders = 0;
for (let v = 0; v < vertexCount(mesh); v++) {
const x = mesh.positions[v * 3]!;
// Only judge geometry clearly lateralised — the pelvis straddles both.
if (Math.abs(x) < skeleton.height * 0.03) continue;
const wantSide = x > 0 ? "Left" : "Right";
for (let i = 0; i < 4; i++) {
const weight = mesh.weights[v * 4 + i]!;
if (weight < 0.02) continue;
const bone = name(mesh.joints[v * 4 + i]!);
if (!bone.startsWith("Left") && !bone.startsWith("Right")) continue;
if (!bone.startsWith(wantSide)) offenders++;
}
}
expect(offenders).toBe(0);
});
it("gives the heel to the foot, not the shin", () => {
const { mesh, skeleton } = survivor();
const h = skeleton.height;
const name = (i: number) => skeleton.joints[i]!.name;
let heels = 0;
for (let v = 0; v < vertexCount(mesh); v++) {
const y = mesh.positions[v * 3 + 1]!;
const z = mesh.positions[v * 3 + 2]!;
// Behind the ankle and below it: heel and the back of the sole.
if (y > h * 0.05 || z > -h * 0.02) continue;
heels++;
let foot = 0;
let shin = 0;
for (let i = 0; i < 4; i++) {
const weight = mesh.weights[v * 4 + i]!;
const bone = name(mesh.joints[v * 4 + i]!);
if (bone.endsWith("Foot") || bone.endsWith("ToeBase")) foot += weight;
else if (bone.endsWith("Leg") && !bone.endsWith("UpLeg")) shin += weight;
}
expect(foot).toBeGreaterThan(shin);
}
expect(heels).toBeGreaterThan(10);
});
});
+299
View File
@@ -0,0 +1,299 @@
import { CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared";
import type { MeshData, Vec3Tuple } from "../mesh/MeshBuilder.js";
import { vertexCount } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "./skeleton.js";
/**
* Automatic skin weights — the auto-rig half of GDD §10.
*
* Weights come from distance to a bone's *segment* (parent joint to child
* joint), not to the joint point. Distance-to-point is the obvious approach and
* it is wrong: every vertex along a long bone would be pulled toward whichever
* end it happened to sit nearer, so a forearm would tear in half at its middle.
* Distance to the segment is uniform along the bone's length, which is what
* "belongs to this bone" actually means.
*
* glTF allows four influences per vertex, which is also more than a PSX-era
* character needs — most vertices end up with one or two.
*/
export interface SkinOptions {
/**
* Falloff exponent. Higher gives crisper, more rigid joints; lower gives
* softer, more rubbery bending. 4 reads as "articulated puppet", which is
* exactly the era's look.
*/
falloff?: number;
/** Influences below this fraction of the strongest are dropped. */
cullRatio?: number;
}
const MAX_INFLUENCES = 4;
/** Bones that should never claim a vertex — they have no geometry of their own. */
const NON_DEFORMING: ReadonlySet<CanonicalBone> = new Set<CanonicalBone>([]);
/**
* Extra distance charged per metre a vertex sits on the far side of the body
* from a left/right bone.
*
* Without it the opposite leg still clears the cull ratio at a 17 cm stance —
* bind pose looks fine, and then the walk cycle drags heel vertices toward the
* other foot as the legs separate. Scaling with the offset rather than
* rejecting outright keeps midline geometry (pelvis, groin) smooth.
*/
const MIRROR_PENALTY = 2;
interface SegmentShape {
/** Extend the scoring segment backward past its own joint, in bone lengths. */
headExtend?: number;
/** Pull the scoring segment short of the child joint, in bone lengths. */
tailTrim?: number;
}
/**
* Where a bone's *scoring* segment differs from the bone itself.
*
* Distance-to-segment cannot separate two bones that meet at a joint: anything
* near that joint is equidistant from both, and splits 50/50. Mid-limb that is
* harmless. At a joint where the geometry continues past it in a direction only
* the child owns, it tears — the heel sits behind and below the ankle, so half
* of every heel vertex was driven by the shin and the boot sheared into a
* wedge as the ankle pitched.
*
* The fix is to say so explicitly, per bone, rather than to invent a rule.
*/
const SEGMENT_SHAPE: Partial<Record<CanonicalBone, SegmentShape>> = {
// The foot's mass extends back to the heel, ~45% of the ankle→toe length
// behind the ankle. Claim it, and keep the shin off it.
LeftFoot: { headExtend: 0.55 },
RightFoot: { headExtend: 0.55 },
LeftLeg: { tailTrim: 0.12 },
RightLeg: { tailTrim: 0.12 },
// Same story, milder: the palm continues past the wrist.
LeftForeArm: { tailTrim: 0.1 },
RightForeArm: { tailTrim: 0.1 },
};
/** +1 for a left-side bone, 1 for a right-side one, 0 for the spine. */
function boneSide(name: CanonicalBone): number {
if (name.startsWith("Left")) return 1;
if (name.startsWith("Right")) return -1;
return 0;
}
interface BoneSegment {
jointIndex: number;
name: CanonicalBone;
head: Vec3Tuple;
tail: Vec3Tuple;
/** +1 left, 1 right, 0 central. */
side: number;
}
/**
* A bone's influence region runs from its own joint to its child's. Leaf bones
* (hands, toes, head) have no child, so they get a stub extending along the
* direction they inherit from their parent.
*/
function buildSegments(skeleton: Skeleton): BoneSegment[] {
const segments: BoneSegment[] = [];
for (const [jointIndex, joint] of skeleton.joints.entries()) {
if (NON_DEFORMING.has(joint.name)) continue;
const children = skeleton.joints.filter((other) => CANONICAL_HIERARCHY[other.name] === joint.name);
if (children.length > 0) {
// Average the children so a fork (Hips → both legs, Spine2 → both
// shoulders) points down the body's axis rather than at one branch.
const tail: Vec3Tuple = [0, 0, 0];
for (const child of children) {
tail[0] += child.world[0] / children.length;
tail[1] += child.world[1] / children.length;
tail[2] += child.world[2] / children.length;
}
segments.push(shape(jointIndex, joint.name, joint.world, tail));
continue;
}
const parent = joint.parent ? skeleton.joints[skeleton.index.get(joint.parent)!] : null;
const direction: Vec3Tuple = parent
? [
joint.world[0] - parent.world[0],
joint.world[1] - parent.world[1],
joint.world[2] - parent.world[2],
]
: [0, 1, 0];
const length = Math.hypot(...direction) || 1;
const stub = skeleton.height * 0.05;
segments.push(
shape(jointIndex, joint.name, joint.world, [
joint.world[0] + (direction[0] / length) * stub,
joint.world[1] + (direction[1] / length) * stub,
joint.world[2] + (direction[2] / length) * stub,
]),
);
}
return segments;
}
/** Apply this bone's {@link SEGMENT_SHAPE} entry to its raw head/tail. */
function shape(
jointIndex: number,
name: CanonicalBone,
head: Vec3Tuple,
tail: Vec3Tuple,
): BoneSegment {
const adjust = SEGMENT_SHAPE[name];
const side = boneSide(name);
if (!adjust) return { jointIndex, name, head, tail, side };
const dx = tail[0] - head[0];
const dy = tail[1] - head[1];
const dz = tail[2] - head[2];
const headExtend = adjust.headExtend ?? 0;
const tailTrim = adjust.tailTrim ?? 0;
return {
jointIndex,
name,
side,
head: [head[0] - dx * headExtend, head[1] - dy * headExtend, head[2] - dz * headExtend],
tail: [tail[0] - dx * tailTrim, tail[1] - dy * tailTrim, tail[2] - dz * tailTrim],
};
}
/** Shortest distance from a point to a finite segment. */
function distanceToSegment(point: Vec3Tuple, head: Vec3Tuple, tail: Vec3Tuple): number {
const dx = tail[0] - head[0];
const dy = tail[1] - head[1];
const dz = tail[2] - head[2];
const lengthSq = dx * dx + dy * dy + dz * dz;
if (lengthSq < 1e-12) return Math.hypot(point[0] - head[0], point[1] - head[1], point[2] - head[2]);
let t =
((point[0] - head[0]) * dx + (point[1] - head[1]) * dy + (point[2] - head[2]) * dz) / lengthSq;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return Math.hypot(
point[0] - (head[0] + dx * t),
point[1] - (head[1] + dy * t),
point[2] - (head[2] + dz * t),
);
}
export function computeSkinWeights(
mesh: MeshData,
skeleton: Skeleton,
options: SkinOptions = {},
): void {
const falloff = options.falloff ?? 4;
const cullRatio = options.cullRatio ?? 0.08;
const segments = buildSegments(skeleton);
// Keeps the inverse-distance weight finite for a vertex sitting exactly on a
// bone, and sets the scale at which "close" stops meaning anything.
const epsilon = skeleton.height * 0.004;
const count = vertexCount(mesh);
mesh.joints = new Array<number>(count * 4).fill(0);
mesh.weights = new Array<number>(count * 4).fill(0);
const scored: Array<{ jointIndex: number; weight: number }> = [];
for (let v = 0; v < count; v++) {
const point: Vec3Tuple = [
mesh.positions[v * 3]!,
mesh.positions[v * 3 + 1]!,
mesh.positions[v * 3 + 2]!,
];
scored.length = 0;
let strongest = 0;
for (const segment of segments) {
// Charge for being on the wrong side of the body, so the far leg cannot
// claim a share of this one and drag it across during a stride.
const wrongSide = segment.side === 0 ? 0 : Math.max(0, -segment.side * point[0]);
const distance =
distanceToSegment(point, segment.head, segment.tail) + wrongSide * MIRROR_PENALTY + epsilon;
const weight = 1 / Math.pow(distance, falloff);
if (weight > strongest) strongest = weight;
scored.push({ jointIndex: segment.jointIndex, weight });
}
// Keep the four strongest, discarding anything negligible next to the best.
const kept = scored
.filter((entry) => entry.weight >= strongest * cullRatio)
.sort((a, b) => b.weight - a.weight)
.slice(0, MAX_INFLUENCES);
const total = kept.reduce((sum, entry) => sum + entry.weight, 0) || 1;
for (let i = 0; i < MAX_INFLUENCES; i++) {
const entry = kept[i];
mesh.joints[v * 4 + i] = entry?.jointIndex ?? 0;
mesh.weights[v * 4 + i] = entry ? entry.weight / total : 0;
}
}
}
/**
* Inverse bind matrices: the transform taking a vertex from model space into
* each joint's local space at rest. Rest poses here are translation-only, so
* the inverse is a translation by the negated world position — no general
* matrix inversion required.
*/
export function computeInverseBindMatrices(skeleton: Skeleton): Float32Array {
const matrices = new Float32Array(skeleton.joints.length * 16);
for (const [i, joint] of skeleton.joints.entries()) {
const offset = i * 16;
// Column-major identity.
matrices[offset] = 1;
matrices[offset + 5] = 1;
matrices[offset + 10] = 1;
matrices[offset + 15] = 1;
// Translation occupies the last column in column-major order.
matrices[offset + 12] = -joint.world[0];
matrices[offset + 13] = -joint.world[1];
matrices[offset + 14] = -joint.world[2];
}
return matrices;
}
/** Diagnostics for the pipeline report and for tests. */
export function skinStatistics(mesh: MeshData): {
maxInfluences: number;
averageInfluences: number;
unweighted: number;
} {
const count = vertexCount(mesh);
let maxInfluences = 0;
let totalInfluences = 0;
let unweighted = 0;
for (let v = 0; v < count; v++) {
let influences = 0;
let sum = 0;
for (let i = 0; i < 4; i++) {
const weight = mesh.weights[v * 4 + i]!;
if (weight > 0) influences++;
sum += weight;
}
if (sum < 0.999) unweighted++;
totalInfluences += influences;
maxInfluences = Math.max(maxInfluences, influences);
}
return {
maxInfluences,
averageInfluences: count > 0 ? totalInfluences / count : 0,
unweighted,
};
}
+185
View File
@@ -0,0 +1,185 @@
import { PNG } from "pngjs";
import { vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
export interface UvGuideOptions {
size?: number;
/** Draw wireframe edges over the filled islands. */
wireframe?: boolean;
}
/**
* Rasterise the mesh's UVs into a guide sheet for Imagine:
* - fill each triangle with its vertex colour (linear→approx sRGB)
* - optional white wireframe so islands stay legible
* - skip degenerate / zero-area UV tris (chart borders)
* - dark background outside islands
*/
export function bakeUvGuide(mesh: MeshData, options: UvGuideOptions = {}): Buffer {
if (mesh.uvs.length < vertexCount(mesh) * 2) {
throw new Error("Mesh has no UVs — run unwrapCharacterAtlas first");
}
const size = options.size ?? 512;
const wireframe = options.wireframe ?? true;
const png = new PNG({ width: size, height: size });
const data = png.data;
// Dark charcoal background.
for (let i = 0; i < size * size; i++) {
data[i * 4] = 22;
data[i * 4 + 1] = 24;
data[i * 4 + 2] = 26;
data[i * 4 + 3] = 255;
}
const toPx = (u: number, v: number): [number, number] => {
// Match glTF: (0,0) = upper-left of the image, V increases downward.
// Our unwrap puts the head at low V and feet at high V, so the guide
// sheet reads head-at-top the same way the mesh samples the albedo.
const x = Math.min(size - 1, Math.max(0, Math.floor(u * (size - 1) + 0.5)));
const y = Math.min(size - 1, Math.max(0, Math.floor(v * (size - 1) + 0.5)));
return [x, y];
};
const linToByte = (c: number) => {
// Rough linear→sRGB so skin/cloth read closer to the vertex palette.
const s = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, Math.round(s * 255)));
};
// Painter's algorithm: draw back-facing / deeper tris first so the front
// silhouette of each island wins in the guide (cleaner for Imagine).
type Tri = { ia: number; ib: number; ic: number; depth: number };
const tris: Tri[] = [];
for (let t = 0; t < mesh.indices.length; t += 3) {
const ia = mesh.indices[t]!;
const ib = mesh.indices[t + 1]!;
const ic = mesh.indices[t + 2]!;
const ua = mesh.uvs[ia * 2]!;
const va = mesh.uvs[ia * 2 + 1]!;
const ub = mesh.uvs[ib * 2]!;
const vb = mesh.uvs[ib * 2 + 1]!;
const uc = mesh.uvs[ic * 2]!;
const vc = mesh.uvs[ic * 2 + 1]!;
if (Math.max(ua, ub, uc) - Math.min(ua, ub, uc) > 0.4) continue;
if (Math.max(va, vb, vc) - Math.min(va, vb, vc) > 0.4) continue;
// Depth = average model Z (front = larger Z draws later).
const depth =
(mesh.positions[ia * 3 + 2]! + mesh.positions[ib * 3 + 2]! + mesh.positions[ic * 3 + 2]!) / 3;
tris.push({ ia, ib, ic, depth });
}
tris.sort((a, b) => a.depth - b.depth);
for (const tri of tris) {
const { ia, ib, ic } = tri;
const [ax, ay] = toPx(mesh.uvs[ia * 2]!, mesh.uvs[ia * 2 + 1]!);
const [bx, by] = toPx(mesh.uvs[ib * 2]!, mesh.uvs[ib * 2 + 1]!);
const [cx, cy] = toPx(mesh.uvs[ic * 2]!, mesh.uvs[ic * 2 + 1]!);
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
if (Math.abs(area) < 0.5) continue;
const r = linToByte((mesh.colors[ia * 4]! + mesh.colors[ib * 4]! + mesh.colors[ic * 4]!) / 3);
const g = linToByte(
(mesh.colors[ia * 4 + 1]! + mesh.colors[ib * 4 + 1]! + mesh.colors[ic * 4 + 1]!) / 3,
);
const b = linToByte(
(mesh.colors[ia * 4 + 2]! + mesh.colors[ib * 4 + 2]! + mesh.colors[ic * 4 + 2]!) / 3,
);
fillTriangle(data, size, ax, ay, bx, by, cx, cy, r, g, b);
if (wireframe) {
drawLine(data, size, ax, ay, bx, by, 235, 235, 230);
drawLine(data, size, bx, by, cx, cy, 235, 235, 230);
drawLine(data, size, cx, cy, ax, ay, 235, 235, 230);
}
}
return PNG.sync.write(png);
}
function setPx(
data: Buffer,
size: number,
x: number,
y: number,
r: number,
g: number,
b: number,
): void {
if (x < 0 || y < 0 || x >= size || y >= size) return;
const i = (y * size + x) * 4;
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
}
/** Barycentric fill. */
function fillTriangle(
data: Buffer,
size: number,
ax: number,
ay: number,
bx: number,
by: number,
cx: number,
cy: number,
r: number,
g: number,
b: number,
): void {
const minX = Math.max(0, Math.min(ax, bx, cx));
const maxX = Math.min(size - 1, Math.max(ax, bx, cx));
const minY = Math.max(0, Math.min(ay, by, cy));
const maxY = Math.min(size - 1, Math.max(ay, by, cy));
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
if (Math.abs(area) < 1e-6) return;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const w0 = (bx - ax) * (y - ay) - (by - ay) * (x - ax);
const w1 = (cx - bx) * (y - by) - (cy - by) * (x - bx);
const w2 = (ax - cx) * (y - cy) - (ay - cy) * (x - cx);
if (
(w0 >= 0 && w1 >= 0 && w2 >= 0 && area > 0) ||
(w0 <= 0 && w1 <= 0 && w2 <= 0 && area < 0)
) {
setPx(data, size, x, y, r, g, b);
}
}
}
}
function drawLine(
data: Buffer,
size: number,
x0: number,
y0: number,
x1: number,
y1: number,
r: number,
g: number,
b: number,
): void {
let dx = Math.abs(x1 - x0);
let dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
for (;;) {
setPx(data, size, x, y, r, g, b);
if (x === x1 && y === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Stdin/stdout CLI for the creator UI texture API.
*
* echo '{"mode":"guide","recipe":{...}}' | tsx src/texture/cli.ts
* echo '{"mode":"texture","force":true,"recipe":{...}}' | tsx src/texture/cli.ts
* echo '{"mode":"texture","albedoBase64":"...","recipe":{...}}' | tsx src/texture/cli.ts
*
* Prints one JSON line: { ok, glbBase64, guidePath, albedoPath, ... }
*/
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { CharacterRecipe } from "../creator/types.js";
import { textureCharacter } from "./textureCharacter.js";
const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const OUT_DIR = join(ROOT, "assets", "characters");
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
async function main(): Promise<void> {
const raw = await readStdin();
const payload = JSON.parse(raw) as {
recipe?: CharacterRecipe;
mode?: "guide" | "texture";
force?: boolean;
/** Import this albedo instead of generating one (PNG/JPEG, base64). */
albedoBase64?: string;
};
if (!payload.recipe?.id || !payload.recipe.name || !payload.recipe.body) {
console.log(JSON.stringify({ ok: false, error: "Expected { recipe, mode }" }));
process.exit(1);
}
const imported = payload.albedoBase64
? new Uint8Array(Buffer.from(payload.albedoBase64, "base64"))
: undefined;
// An imported albedo is a texture run by definition — there is nothing to ask
// Grok for, but the caller still wants a textured GLB back.
const mode = payload.mode === "texture" || imported ? "texture" : "guide";
const result = await textureCharacter(payload.recipe, {
outDir: OUT_DIR,
guideOnly: mode === "guide",
force: payload.force === true,
...(imported ? { importAlbedo: imported } : {}),
});
const { writeFile, mkdir } = await import("node:fs/promises");
await mkdir(OUT_DIR, { recursive: true });
const glbPath = join(OUT_DIR, `${payload.recipe.id}.glb`);
await writeFile(glbPath, result.glb);
await writeFile(
join(OUT_DIR, `${payload.recipe.id}.recipe.json`),
`${JSON.stringify({ ...payload.recipe, stats: result.assembled.stats, textured: result.textured }, null, 2)}\n`,
);
console.log(
JSON.stringify({
ok: true,
mode,
forced: payload.force === true,
textured: result.textured,
imported: result.imported,
cached: result.cached,
costUsd: result.costUsd,
guidePath: result.guidePath,
albedoPath: result.albedoPath,
glbPath,
glbBase64: Buffer.from(result.glb).toString("base64"),
guideUrl: `/assets-characters/${payload.recipe.id}.uv-guide.png?t=${Date.now()}`,
albedoUrl: result.albedoPath
? `/assets-characters/${payload.recipe.id}.albedo.png?t=${Date.now()}`
: null,
bytes: result.glb.byteLength,
}),
);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({ ok: false, error: message }));
process.exit(1);
});
void dirname;
+239
View File
@@ -0,0 +1,239 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { buildDefaultClips } from "../anim/procedural.js";
import { assembleCharacter } from "../creator/assemble.js";
import { PART_CATALOGUE } from "../creator/parts.js";
import type { AssembledCharacter, CharacterRecipe } from "../creator/types.js";
import { exportGlb } from "../export/glb.js";
import type { Rgb } from "../image/Raster.js";
import { GrokBridge, GrokUnavailableError } from "../grok/GrokBridge.js";
import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js";
import { bakeUvGuide } from "./bakeUvGuide.js";
import { unwrapCharacterAtlas } from "./unwrap.js";
export interface TextureCharacterOptions {
/** Output directory for guide / albedo / glb sidecars. */
outDir: string;
/** UV guide resolution. */
guideSize?: number;
/** Skip Grok and only write the UV guide (offline / CI). */
guideOnly?: boolean;
/**
* Repaint even if this character's prompt is already cached, replacing the
* cached image. Costs a fresh generation every time.
*/
force?: boolean;
/**
* Use these image bytes as the albedo instead of generating one.
*
* The escape hatch from the Grok bridge: bake the UV guide, paint it in
* anything — Photoshop, Aseprite, some other model — and hand the result
* back. Grok is never invoked, so this path needs no CLI, no auth and no
* money, and it is the supported way to use a different image generator.
*
* PNG or JPEG; JPEG is re-encoded so the GLB's mime type stays honest.
*/
importAlbedo?: Uint8Array;
/** Optional override for Grok bridge. */
grok?: GrokBridge;
}
export interface TextureCharacterResult {
assembled: AssembledCharacter;
guidePath: string;
albedoPath: string | null;
glb: Uint8Array;
textured: boolean;
cached: boolean;
/** The albedo came from {@link TextureCharacterOptions.importAlbedo}. */
imported: boolean;
costUsd: number;
}
/**
* Full path: assemble → cylindrical UV unwrap → bake guide → Grok Imagine
* edit → re-export GLB with baseColorTexture.
*/
export async function textureCharacter(
recipe: CharacterRecipe,
options: TextureCharacterOptions,
): Promise<TextureCharacterResult> {
const outDir = resolve(options.outDir);
await mkdir(outDir, { recursive: true });
const assembled = assembleCharacter(recipe);
const { mesh, skeleton } = assembled;
// Unwrap may duplicate verts for the front|back fold — re-skin after.
unwrapCharacterAtlas(mesh, skeleton);
computeSkinWeights(mesh, skeleton);
const inverseBindMatrices = computeInverseBindMatrices(skeleton);
const clips = buildDefaultClips();
const guidePng = bakeUvGuide(mesh, { size: options.guideSize ?? 512, wireframe: true });
const guidePath = join(outDir, `${recipe.id}.uv-guide.png`);
await writeFile(guidePath, guidePng);
if (options.guideOnly) {
// White vertex colours so untextured path still works; no map.
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, {
name: recipe.name,
clips,
generator: "psx-adventure-engine character creator (uv-guide)",
});
return {
assembled: { ...assembled, mesh, glb },
guidePath,
albedoPath: null,
glb,
textured: false,
cached: false,
imported: false,
costUsd: 0,
};
}
// Either the caller brings its own albedo, or Grok paints one.
let rawAlbedo: Buffer;
let cached = false;
let costUsd = 0;
const imported = Boolean(options.importAlbedo?.length);
if (options.importAlbedo?.length) {
rawAlbedo = Buffer.from(options.importAlbedo);
} else {
const grok = options.grok ?? new GrokBridge({ outputDir: join(outDir, ".grok-texture-cache") });
const available = await grok.isAvailable();
if (!available) {
throw new GrokUnavailableError("grok");
}
const prompt = texturePrompt(recipe);
const result = await grok.generate({
prompt,
aspectRatio: "1:1",
sourceImages: [guidePath],
...(options.force ? { force: true } : {}),
});
rawAlbedo = await readFile(result.path);
cached = result.cached;
costUsd = result.costUsd;
}
// Imagine (or the user's paint tool) may hand back JPEG; re-encode to PNG so
// the GLB mime type stays honest.
const albedoPng = await ensurePng(rawAlbedo);
const albedoPath = join(outDir, `${recipe.id}.albedo.png`);
await writeFile(albedoPath, albedoPng);
// Texture carries colour — neutralise vertex colours so they don't tint the map.
for (let i = 0; i < mesh.colors.length; i += 4) {
mesh.colors[i] = 1;
mesh.colors[i + 1] = 1;
mesh.colors[i + 2] = 1;
mesh.colors[i + 3] = 1;
}
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, {
name: recipe.name,
clips,
generator: "psx-adventure-engine character creator (textured)",
baseColorImage: new Uint8Array(albedoPng),
baseColorMimeType: "image/png",
});
return {
assembled: { ...assembled, mesh, glb },
guidePath,
albedoPath,
glb,
textured: true,
cached,
imported,
costUsd,
};
}
function hex({ r, g, b }: Rgb): string {
const c = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
return `#${c(r)}${c(g)}${c(b)}`;
}
/** "a, b and c" — reads better to the model than a bare comma list. */
function sentenceList(items: string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}
/**
* Prompt for the albedo pass.
*
* Three things it has to get right, in order: that this is an atlas and not a
* portrait, what the figure is actually wearing, and what the wearer wants it
* to look like. The middle one is derived from the equipped parts rather than
* written down — an earlier version named "jacket, cargo pants and boots"
* literally, which is the survivor's kit, so every other character was being
* asked for someone else's outfit.
*
* `recipe.styleNotes` is the steering wheel and deliberately outranks the
* palette: asking for a charcoal suit should win over an olive `primary`.
*/
export function texturePrompt(recipe: CharacterRecipe): string {
const worn = Object.entries(recipe.parts)
.filter(([, id]) => id && !String(id).endsWith(".none"))
.map(([slot, id]) => {
const key = String(id);
const def = PART_CATALOGUE.get(key);
// Hair styles are named bare ("Short", "Bun"), which reads as a garment
// in a list of clothes unless it is spelled out.
const name = `${(def?.name ?? key).toLowerCase()}${slot === "hair" ? " hair" : ""}`;
const override = recipe.partColors?.[key];
return override ? `${name} (${hex(override)})` : name;
});
const garments = worn.length ? sentenceList(worn) : "bare skin";
const raw = recipe.styleNotes?.trim();
// Terminate it, or the next sentence runs straight on from the direction.
const notes = raw ? (/[.!?]$/.test(raw) ? raw : `${raw}.`) : undefined;
return [
"CRITICAL: This is a 3D model UV texture atlas / unwrap sheet, NOT a character portrait or turnaround.",
"The white wireframe lines mark mesh islands. Paint albedo colours INTO those islands only.",
"Do NOT redraw the character as a front-view illustration. Do NOT invent new silhouette shapes.",
"Keep the dark charcoal background pure black/empty outside the islands.",
"Preserve island shapes exactly — only recolour the filled regions.",
"Style: PSX / early PlayStation survival-horror game texture — flat, limited muted palette, soft dirt, no photoreal skin pores, no text.",
`Character kit: ${recipe.name}, ${recipe.body} body, wearing ${garments}.`,
`Base palette — skin ${hex(recipe.palette.skin)}, hair ${hex(recipe.palette.hair)}, primary ${hex(recipe.palette.primary)}, secondary ${hex(recipe.palette.secondary)}, accent ${hex(recipe.palette.accent)}, metal ${hex(recipe.palette.metal)}, leather ${hex(recipe.palette.leather)}.`,
// Placed after the palette so the model reads it as the overriding note.
...(notes
? [
`WARDROBE DIRECTION (authoritative — follow this over the base palette where they disagree): ${notes}`,
"Keep the garment cut implied by the mesh islands; change only the colour, material and detailing to match that direction.",
]
: []),
"Atlas layout: LEFT half = full-body FRONT (head at top, feet at bottom); RIGHT half = full-body BACK (mirrored, same height).",
`The face and the ${garments} should read as a clear standing figure on each half.`,
"Paint face features on the upper centre of the LEFT half. Output a square texture atlas.",
].join(" ");
}
/** Re-encode JPEG/WebP outputs from Imagine as PNG for GLB embedding. */
async function ensurePng(bytes: Buffer): Promise<Buffer> {
const isPng = bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
if (isPng) return bytes;
const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8;
if (isJpeg) {
const jpeg = await import("jpeg-js");
const { PNG } = await import("pngjs");
const decoded = jpeg.decode(bytes, { useTArray: true, maxMemoryUsageInMB: 256 });
const png = new PNG({ width: decoded.width, height: decoded.height });
png.data = Buffer.from(decoded.data);
return PNG.sync.write(png);
}
// Unknown — pass through and let mime sniff handle it.
return bytes;
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { presetById } from "../creator/presets.js";
import type { CharacterRecipe } from "../creator/types.js";
import { texturePrompt } from "./textureCharacter.js";
/**
* The prompt is the whole steering surface for the albedo pass, and it is
* cheap to get subtly wrong in ways only a $0.05 generation would reveal.
*/
const base: CharacterRecipe = {
id: "suit",
name: "Broker",
body: "male",
height: 1.8,
palette: {
skin: { r: 200, g: 160, b: 130 },
hair: { r: 40, g: 36, b: 32 },
primary: { r: 60, g: 62, b: 68 },
secondary: { r: 30, g: 32, b: 36 },
accent: { r: 120, g: 30, b: 40 },
metal: { r: 150, g: 152, b: 158 },
leather: { r: 40, g: 30, b: 26 },
},
parts: {
hair: "hair.short",
upper: "upper.shirt",
lower: "lower.pants",
feet: "feet.shoes",
accessory: "accessory.none",
},
};
describe("texturePrompt", () => {
it("names the garments actually equipped, not a hard-coded outfit", () => {
const prompt = texturePrompt(base);
expect(prompt).toContain("wearing short hair, shirt, pants and shoes");
// The old prompt named the survivor's kit for every character.
expect(prompt).not.toContain("cargo pants");
expect(prompt).not.toContain("backpack");
// Slots that are ".none" are not garments.
expect(prompt).not.toContain("none");
});
it("uses readable part names rather than raw ids", () => {
const prompt = texturePrompt({
...base,
parts: { upper: "upper.labcoat", lower: "lower.cargo" },
});
expect(prompt).toContain("lab coat");
expect(prompt).toContain("cargo pants");
expect(prompt).not.toContain("upper.labcoat");
});
it("carries the palette so the texture matches the model's colours", () => {
const prompt = texturePrompt(base);
expect(prompt).toContain("skin #c8a082");
expect(prompt).toContain("accent #781e28");
});
it("quotes per-part colour overrides next to the garment", () => {
const prompt = texturePrompt({
...base,
partColors: { "upper.shirt": { r: 255, g: 255, b: 255 } },
});
expect(prompt).toContain("shirt (#ffffff)");
});
it("adds wardrobe direction only when set, and marks it authoritative", () => {
expect(texturePrompt(base)).not.toContain("WARDROBE DIRECTION");
const steered = texturePrompt({
...base,
styleNotes: "charcoal three-piece business suit, burgundy tie",
});
expect(steered).toContain("WARDROBE DIRECTION");
expect(steered).toContain("charcoal three-piece business suit, burgundy tie.");
expect(steered).toContain("authoritative");
// Direction must land after the palette, or it reads as the thing being
// overridden rather than the thing overriding.
expect(steered.indexOf("WARDROBE DIRECTION")).toBeGreaterThan(steered.indexOf("Base palette"));
});
it("treats blank notes as absent", () => {
expect(texturePrompt({ ...base, styleNotes: " " })).not.toContain("WARDROBE DIRECTION");
});
it("changes with the notes, which is what busts the albedo cache", () => {
const a = texturePrompt({ ...base, styleNotes: "navy suit" });
const b = texturePrompt({ ...base, styleNotes: "charcoal suit" });
expect(a).not.toBe(b);
});
it("survives a character wearing nothing", () => {
const prompt = texturePrompt({ ...base, parts: {} });
expect(prompt).toContain("bare skin");
});
it("describes the shipped business-suit preset as a suit", () => {
const prompt = texturePrompt(presetById("civilian-m")!);
expect(prompt).toContain("business suit");
expect(prompt).toContain("WARDROBE DIRECTION");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import { buildDefaultClips } from "../anim/procedural.js";
import { assembleCharacter } from "../creator/assemble.js";
import { CHARACTER_PRESETS } from "../creator/presets.js";
import { exportGlb } from "../export/glb.js";
import { vertexCount } from "../mesh/MeshBuilder.js";
import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js";
import { bakeUvGuide } from "./bakeUvGuide.js";
import { PART } from "../creator/coverage.js";
import type { MeshData } from "../mesh/MeshBuilder.js";
import { unwrapCharacterAtlas } from "./unwrap.js";
/**
* Texel density per triangle: UV area over world area, so 1.0 after
* normalising to the median is "average sized in the atlas".
*/
function headDensities(mesh: MeshData): number[] {
const P = (i: number) =>
[mesh.positions[i * 3]!, mesh.positions[i * 3 + 1]!, mesh.positions[i * 3 + 2]!] as const;
const UV = (i: number) => [mesh.uvs[i * 2]!, mesh.uvs[i * 2 + 1]!] as const;
const out: number[] = [];
for (let t = 0; t < mesh.indices.length; t += 3) {
const ia = mesh.indices[t]!;
const ib = mesh.indices[t + 1]!;
const ic = mesh.indices[t + 2]!;
if ((mesh.parts[ia] ?? 0) !== PART.HEAD) continue;
const [ax, ay, az] = P(ia);
const [bx, by, bz] = P(ib);
const [cx, cy, cz] = P(ic);
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
const world =
0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx);
if (world < 1e-9) continue;
const [au, av] = UV(ia);
const [bu, bv] = UV(ib);
const [cu, cv] = UV(ic);
const uv = Math.abs((bu - au) * (cv - av) - (bv - av) * (cu - au)) * 0.5;
out.push(uv / world);
}
return out;
}
describe("UV unwrap + guide", () => {
it("assigns UVs in 0..1 for every vertex via multi-island atlas", () => {
const result = assembleCharacter(CHARACTER_PRESETS[0]!);
unwrapCharacterAtlas(result.mesh, result.skeleton);
computeSkinWeights(result.mesh, result.skeleton);
const n = vertexCount(result.mesh);
expect(result.mesh.uvs.length).toBe(n * 2);
for (let i = 0; i < result.mesh.uvs.length; i++) {
const v = result.mesh.uvs[i]!;
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
it("bakes a non-empty PNG guide with front and back body halves", () => {
const result = assembleCharacter(CHARACTER_PRESETS[0]!);
unwrapCharacterAtlas(result.mesh, result.skeleton);
const png = bakeUvGuide(result.mesh, { size: 256 });
expect(png.byteLength).toBeGreaterThan(500);
expect(png[0]).toBe(0x89);
expect(png[1]).toBe(0x50);
// Front half (u < 0.5) and back half (u > 0.5) should both be used.
let front = 0;
let back = 0;
for (let i = 0; i < result.mesh.uvs.length; i += 2) {
if (result.mesh.uvs[i]! < 0.5) front++;
else back++;
}
expect(front).toBeGreaterThan(50);
expect(back).toBeGreaterThan(50);
});
it("keeps texel density even around the head", () => {
// A flat X projection starves the sides of every round form: on a loft ring
// `x = c + R·cos(a)`, so density `dx/da` collapses to zero exactly at the
// left and right silhouette. That is what smeared the side of the head —
// 18% of its surface was running under a quarter of median density.
// Placing `u` by angle instead holds it near uniform.
const result = assembleCharacter(CHARACTER_PRESETS[0]!);
unwrapCharacterAtlas(result.mesh, result.skeleton);
const densities = headDensities(result.mesh).sort((a, b) => a - b);
expect(densities.length).toBeGreaterThan(50);
const median = densities[Math.floor(densities.length / 2)]!;
expect(median).toBeGreaterThan(0);
const starved = densities.filter((d) => d / median < 0.25).length;
// Some residue is unavoidable in a front|back atlas: the ears and the
// nose's side strips are surfaces perpendicular to X, so they have no
// extent to project however `u` is chosen. They are small.
expect(starved / densities.length).toBeLessThan(0.15);
const p10 = densities[Math.floor(densities.length * 0.1)]!;
expect(p10 / median).toBeGreaterThan(0.15);
});
it("exports a GLB with TEXCOORD_0 and embedded PNG image chunk", () => {
const result = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!);
unwrapCharacterAtlas(result.mesh, result.skeleton);
computeSkinWeights(result.mesh, result.skeleton);
const ibm = computeInverseBindMatrices(result.skeleton);
const guide = bakeUvGuide(result.mesh, { size: 128 });
const glb = exportGlb(result.mesh, result.skeleton, ibm, {
name: "TexTest",
clips: buildDefaultClips(),
baseColorImage: new Uint8Array(guide),
baseColorMimeType: "image/png",
});
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
expect(view.getUint32(0, true)).toBe(0x46546c67);
const jsonLen = view.getUint32(12, true);
const jsonBytes = glb.subarray(20, 20 + jsonLen);
const jsonText = new TextDecoder().decode(jsonBytes).replace(/\0/g, "").trim();
const gltf = JSON.parse(jsonText) as {
meshes: Array<{ primitives: Array<{ attributes: Record<string, number> }> }>;
images?: Array<{ mimeType: string }>;
textures?: unknown[];
materials: Array<{ pbrMetallicRoughness?: { baseColorTexture?: { index: number } } }>;
};
expect(gltf.meshes[0]!.primitives[0]!.attributes["TEXCOORD_0"]).toBeDefined();
expect(gltf.images?.[0]?.mimeType).toBe("image/png");
expect(gltf.textures?.length).toBe(1);
expect(gltf.materials[0]!.pbrMetallicRoughness?.baseColorTexture?.index).toBe(0);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";
/**
* Front | back orthographic body sheet with **triangle-consistent** side
* assignment (no UV tris spanning the fold).
*
* ┌────────────┬────────────┐
* │ FRONT │ BACK │
* │ head → feet│ head → feet│ same metric scale + shared height
* └────────────┴────────────┘
*
* Vertices used by both front and back faces are duplicated so each half
* gets a clean silhouette. That stops the horizontal "rubber band" edges
* that appeared when one corner of a tri went left and another went right.
*
* `u` is not raw X, though — see {@link sheetX}. A straight orthographic
* projection starves the sides of every round form of texels, which is what
* smeared the side of the head.
*/
const MARGIN = 0.04;
const GAP = 0.03;
/** Ring buckets per body region. Comfortably above the highest ring count. */
const RING_BINS = 32;
/**
* The ellipse one loft ring traces, so a vertex on it can be placed by angle.
*
* A ring's vertices sit at `x = cx + rx·cos(a)`, `z = cz + rz·sin(a)`. Project
* that flat down X and texel density is `dx/da = rx·sin(a)`, which falls to
* **zero** at `a = 0, π` — exactly the left and right silhouette. Those
* triangles collapse to slivers and a few texels get stretched across the whole
* side of the form. Measured on a male head: 18% of its surface under a quarter
* of median density, the worst of it at 4%.
*
* Recovering `a` and spacing `u` by it instead holds density constant around
* the ring. Centre and radii are per ring rather than per region, so the chin
* and the cheekbone each get their own width, and offset regions like the arms
* work without assuming they straddle x = 0.
*
* Rings come from bucketing the loft parameter the body tags onto every vertex.
* That beats bucketing by height: feet are lofted heel-to-toe, so their rings
* are vertical planes.
*
* Untagged geometry — every garment — is left alone. Extending the fix there
* means tagging clothing the way `coverage.ts` tags skin.
*/
interface RingFrame {
/** Centre and half-extent of the ring, across (x) and through (z). */
cx: number;
rx: number;
cz: number;
rz: number;
}
/** Per-ring frames, indexed by original vertex. `null` = untagged, leave alone. */
function ringFrames(mesh: MeshData, n0: number): (RingFrame | null)[] {
const frames: (RingFrame | null)[] = new Array(n0).fill(null);
const groups = new Map<number, number[]>();
for (let i = 0; i < n0; i++) {
const part = mesh.parts[i] ?? 0;
if (part === 0) continue;
let group = groups.get(part);
if (!group) {
group = [];
groups.set(part, group);
}
group.push(i);
}
const xLo = new Float64Array(RING_BINS);
const xHi = new Float64Array(RING_BINS);
const zLo = new Float64Array(RING_BINS);
const zHi = new Float64Array(RING_BINS);
for (const indices of groups.values()) {
xLo.fill(Number.POSITIVE_INFINITY);
xHi.fill(Number.NEGATIVE_INFINITY);
zLo.fill(Number.POSITIVE_INFINITY);
zHi.fill(Number.NEGATIVE_INFINITY);
const binOf = (i: number) => {
const t = mesh.ts[i] ?? 0;
return Math.min(RING_BINS - 1, Math.max(0, Math.floor(t * RING_BINS)));
};
for (const i of indices) {
const bin = binOf(i);
const x = mesh.positions[i * 3]!;
const z = mesh.positions[i * 3 + 2]!;
if (x < xLo[bin]!) xLo[bin] = x;
if (x > xHi[bin]!) xHi[bin] = x;
if (z < zLo[bin]!) zLo[bin] = z;
if (z > zHi[bin]!) zHi[bin] = z;
}
for (const i of indices) {
const bin = binOf(i);
const rx = (xHi[bin]! - xLo[bin]!) * 0.5;
const rz = (zHi[bin]! - zLo[bin]!) * 0.5;
// A ring with no width or no depth has no angle to measure.
if (!(rx > 1e-6) || !(rz > 1e-6)) continue;
frames[i] = {
cx: (xLo[bin]! + xHi[bin]!) * 0.5,
rx,
cz: (zLo[bin]! + zHi[bin]!) * 0.5,
rz,
};
}
}
return frames;
}
/**
* Where this vertex sits along its sheet, in units of the ring's half-width.
*
* `±1` is the silhouette, `0` is dead centre of the sheet. Values outside
* `[-1, 1]` are legitimate and are the point of the whole exercise — see below.
*/
function sheetX(frame: RingFrame, x: number, z: number, back: boolean): number {
const xRel = (x - frame.cx) / frame.rx;
const zRel = (z - frame.cz) / frame.rz;
// Is this vertex actually *on* the ring the frame describes?
//
// Rings are found by bucketing the loft parameter, and the nose and ears are
// tagged with a single constant one, so they land in a skull ring's bucket and
// inherit a frame they were never part of. An ear sits at the frame's x
// extreme but nowhere near its z centre, and the angle that falls out of that
// flings it clear of the head — it showed up in the atlas as a bar wider than
// the figure, right where a texturing model would read a hat brim.
//
// A vertex genuinely on the ellipse has a normalised radius near 1: exactly
// 1.08 for an eight-sided ring, at most about 1.12 for a six-sided one, since
// `rx`/`rz` are the sampled extents rather than the true semi-axes. Anything
// outside that band is not on this ring, so it keeps its flat projection.
const r = Math.hypot(xRel, zRel);
if (r < 0.85 || r > 1.2) return x;
// Normalise the ellipse to a circle, then take the angle: 0 = the +x
// silhouette, π/2 = facing the camera, π = the x silhouette.
let a = Math.atan2(zRel, xRel);
if (back) a = -a;
// `a` came back on (−π, π]. A vertex on the far side of the seam from its own
// sheet has to be read on the *near* branch, or it lands on the wrong edge.
if (a < -Math.PI / 2) a += 2 * Math.PI;
// A sheet legitimately overruns its silhouette by half a column — 22.5° at
// eight sides — to give the straddling quad some width. Past that is noise.
const OVERSHOOT = Math.PI / 4;
a = Math.max(-OVERSHOOT, Math.min(Math.PI + OVERSHOOT, a));
// Linear in angle, so texel density is constant around the ring.
return frame.cx + frame.rx * (1 - (2 * a) / Math.PI);
}
export function unwrapCharacterAtlas(mesh: MeshData, skeleton: Skeleton): void {
const n0 = vertexCount(mesh);
if (n0 === 0) return;
// Before any duplication: the region/ring tags only cover the original
// vertices, and `duplicateVertex` does not carry them across.
const frames = ringFrames(mesh, n0);
// --- classify each triangle as front or back -----------------------------
const triSide: ("front" | "back")[] = [];
for (let t = 0; t < mesh.indices.length; t += 3) {
const ia = mesh.indices[t]!;
const ib = mesh.indices[t + 1]!;
const ic = mesh.indices[t + 2]!;
const nz =
(mesh.normals[ia * 3 + 2]! + mesh.normals[ib * 3 + 2]! + mesh.normals[ic * 3 + 2]!) / 3;
triSide.push(nz >= 0 ? "front" : "back");
}
// Which sides need each original vertex?
const needFront = new Uint8Array(n0);
const needBack = new Uint8Array(n0);
for (let t = 0; t < triSide.length; t++) {
const ia = mesh.indices[t * 3]!;
const ib = mesh.indices[t * 3 + 1]!;
const ic = mesh.indices[t * 3 + 2]!;
if (triSide[t] === "front") {
needFront[ia] = 1;
needFront[ib] = 1;
needFront[ic] = 1;
} else {
needBack[ia] = 1;
needBack[ib] = 1;
needBack[ic] = 1;
}
}
// Map original vert → back-side duplicate (if it also needs a front UV).
const backDup = new Int32Array(n0).fill(-1);
for (let i = 0; i < n0; i++) {
if (needFront[i] && needBack[i]) {
backDup[i] = duplicateVertex(mesh, i);
} else if (!needFront[i] && needBack[i]) {
// Only used on the back — keep original index, mark as back-only.
backDup[i] = i;
}
}
// Rewrite indices: back tris use the back duplicate when present.
for (let t = 0; t < triSide.length; t++) {
if (triSide[t] !== "back") continue;
for (let k = 0; k < 3; k++) {
const src = mesh.indices[t * 3 + k]!;
const mapped = backDup[src] ?? -1;
if (mapped >= 0) mesh.indices[t * 3 + k] = mapped;
}
}
const n = vertexCount(mesh);
// side[i] = 0 front, 1 back
const side = new Uint8Array(n);
for (let i = 0; i < n0; i++) {
if (needFront[i] && needBack[i]) {
side[i] = 0; // original stays front
side[backDup[i]!] = 1;
} else if (needBack[i]) {
side[i] = 1;
} else {
side[i] = 0;
}
}
// Any brand-new verts only from dup are already set; leftover defaults front.
// A duplicate carries its source's ring frame; positions were copied verbatim.
const origOf = new Int32Array(n);
for (let i = 0; i < n; i++) origOf[i] = i;
for (let i = 0; i < n0; i++) {
const dup = backDup[i]!;
if (dup >= 0 && dup !== i) origOf[dup] = i;
}
// --- place every vertex along its sheet ----------------------------------
// Has to happen before the bounds are taken: a column straddling the
// silhouette is deliberately pushed outside `±rx`, so the sheet is a little
// wider than the figure's outline and the packing must allow for it.
const xUv = new Float64Array(n);
for (let i = 0; i < n; i++) {
const frame = frames[origOf[i]!] ?? null;
const x = mesh.positions[i * 3]!;
xUv[i] = frame
? sheetX(frame, x, mesh.positions[i * 3 + 2]!, side[i] === 1)
: x;
}
const { min, max } = bounds(mesh);
const padM = Math.max(skeleton.height * 0.015, 0.008);
let xLo = Number.POSITIVE_INFINITY;
let xHi = Number.NEGATIVE_INFINITY;
for (let i = 0; i < n; i++) {
if (xUv[i]! < xLo) xLo = xUv[i]!;
if (xUv[i]! > xHi) xHi = xUv[i]!;
}
const minX = xLo - padM;
const maxX = xHi + padM;
const minY = min[1]! - padM * 0.35;
const maxY = max[1]! + padM * 0.35;
const width = Math.max(maxX - minX, 1e-4);
const height = Math.max(maxY - minY, 1e-4);
// --- pack with uniform scale ---------------------------------------------
const halfW = 0.5 - MARGIN - GAP * 0.5;
const fullH = 1 - MARGIN * 2;
const scale = Math.min(halfW / width, fullH / height);
const contentW = width * scale;
const contentH = height * scale;
const frontU0 = MARGIN + (halfW - contentW) * 0.5;
const backU0 = 0.5 + GAP * 0.5 + (halfW - contentW) * 0.5;
const v0 = MARGIN + (fullH - contentH) * 0.5;
mesh.uvs = new Array(n * 2);
for (let i = 0; i < n; i++) {
const x = xUv[i]!;
const y = mesh.positions[i * 3 + 1]!;
const tx = (x - minX) / width;
// ty: 0 = feet, 1 = head in model space.
const ty = (y - minY) / height;
let u: number;
if (side[i]) {
u = backU0 + (1 - tx) * contentW; // mirrored back
} else {
u = frontU0 + tx * contentW;
}
// glTF UV origin is the **upper-left** of the texture image, with V
// increasing downward. Put the head at low V (top of the sheet) and feet
// at high V (bottom) so albedo maps aren't upside-down on the mesh.
const v = v0 + (1 - ty) * contentH;
mesh.uvs[i * 2] = clamp01(u);
mesh.uvs[i * 2 + 1] = clamp01(v);
}
}
/** @deprecated */
export function unwrapCylindrical(mesh: MeshData, _margin?: number): void {
const { min, max } = bounds(mesh);
const height = Math.max(max[1]! - min[1]!, 1);
unwrapCharacterAtlas(mesh, { height, joints: [], index: new Map() } as unknown as Skeleton);
}
function duplicateVertex(mesh: MeshData, index: number): number {
const ni = vertexCount(mesh);
const i = index;
mesh.positions.push(
mesh.positions[i * 3]!,
mesh.positions[i * 3 + 1]!,
mesh.positions[i * 3 + 2]!,
);
mesh.normals.push(mesh.normals[i * 3]!, mesh.normals[i * 3 + 1]!, mesh.normals[i * 3 + 2]!);
mesh.colors.push(
mesh.colors[i * 4]!,
mesh.colors[i * 4 + 1]!,
mesh.colors[i * 4 + 2]!,
mesh.colors[i * 4 + 3]!,
);
if (mesh.joints.length >= (i + 1) * 4) {
mesh.joints.push(
mesh.joints[i * 4]!,
mesh.joints[i * 4 + 1]!,
mesh.joints[i * 4 + 2]!,
mesh.joints[i * 4 + 3]!,
);
mesh.weights.push(
mesh.weights[i * 4]!,
mesh.weights[i * 4 + 1]!,
mesh.weights[i * 4 + 2]!,
mesh.weights[i * 4 + 3]!,
);
}
return ni;
}
function clamp01(x: number): number {
return Math.max(0, Math.min(1, x));
}
+69
View File
@@ -0,0 +1,69 @@
import type { CanonicalBone, ColliderDef } from "@psx/shared";
/**
* Content harness interfaces — GDD §10.4, Appendix C.
*
* The shipped Milestone 3 path is *code-drawn*, not a third-party mesh API:
* reference image → silhouette → measurements → tubes on the canonical
* skeleton → automatic skin weights → procedural clips → GLB. See
* `pipeline.ts` / `pnpm harness`.
*
* These types remain for the broader GDD contract (props, colliders, future
* alternate generators). Prefer `runPipeline` for characters.
*/
/** How the mesh was produced. `"code"` is the default harness path. */
export type MeshProvider = "code" | "meshy" | "triposr" | "img2threejs" | "mock";
export type MeshStyle = "realistic" | "lowpoly" | "psx";
export interface Img2MeshRequest {
image: Buffer | string;
style?: MeshStyle;
provider?: MeshProvider;
/** Target triangle budget after PSX decimation. */
targetTriangles?: number;
}
export interface Img2MeshResult {
glbPath: string;
provider: MeshProvider;
triangleCount: number;
generationSeconds: number;
}
export interface RigResult {
glbPath: string;
/** Source rigger bone name → canonical bone name. */
boneMap: Record<string, CanonicalBone>;
/** Canonical bones the rigger could not produce. */
missingBones: CanonicalBone[];
/** Auto-generated collision, limited to shapes box3d-wasm actually binds. */
colliders: ColliderDef[];
}
export interface AnimationClipResult {
name: string;
glbPath: string;
durationSeconds: number;
frameRate: number;
/** Bones the clip actually animates; the rest fall back to bind pose. */
animatedBones: CanonicalBone[];
}
export type AnimationSource = "pose" | "video" | "procedural";
export interface CharacterHarness {
fromPhoto(
image: Buffer | string,
options?: Img2MeshRequest,
): Promise<{ mesh: RigResult; defaultPose?: AnimationClipResult }>;
generateAnimation(from: AnimationSource, input: unknown): Promise<AnimationClipResult>;
}
/** Optional external mesh-provider plug-in (not used by the default pipeline). */
export interface MeshProviderAdapter {
readonly name: MeshProvider;
isConfigured(): boolean;
generate(request: Img2MeshRequest): Promise<Img2MeshResult>;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PSX Character Creator</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<aside class="panel panel--left" id="controls">
<header class="panel__header">
<h1>Character Creator</h1>
<p class="panel__sub">body · parts · palette → GLB</p>
</header>
<section class="section">
<h2>Preset</h2>
<label class="field">
<span>Load cast recipe</span>
<select id="preset"></select>
</label>
<div class="row">
<label class="field grow">
<span>Name</span>
<input id="name" type="text" autocomplete="off" />
</label>
<label class="field grow">
<span>Id</span>
<input id="id" type="text" autocomplete="off" spellcheck="false" />
</label>
</div>
</section>
<section class="section">
<h2>Body</h2>
<div class="seg" id="body" role="group" aria-label="Body type">
<button type="button" data-body="male" class="seg__btn is-active">Male</button>
<button type="button" data-body="female" class="seg__btn">Female</button>
</div>
<label class="field">
<span>Height <em id="height-label">1.72 m</em></span>
<input id="height" type="range" min="1.45" max="2.05" step="0.01" value="1.72" />
</label>
<div class="body-style" id="body-style" aria-label="Physique">
<label class="field field--slider">
<span>Mass <em id="mass-label">0.00</em></span>
<div class="slider-ends"><i>Lean</i><i>Heavy</i></div>
<input id="mass" type="range" min="-1" max="1" step="0.01" value="0" />
</label>
<label class="field field--slider">
<span>Muscle <em id="muscle-label">0.00</em></span>
<div class="slider-ends"><i>Soft</i><i>Muscular</i></div>
<input id="muscle" type="range" min="-1" max="1" step="0.01" value="0" />
</label>
<label class="field field--slider">
<span>Fat <em id="fat-label">0.00</em></span>
<div class="slider-ends"><i>Base</i><i>Heavy</i></div>
<input id="fat" type="range" min="0" max="1" step="0.01" value="0" />
</label>
<button type="button" class="btn btn--ghost btn--small" id="body-style-reset" title="Reset physique to average">
Reset physique
</button>
</div>
<label class="field">
<span>Skin tone</span>
<div class="swatches" id="skin-swatches"></div>
</label>
</section>
<section class="section">
<h2>Face</h2>
<p class="hint">
Male and female bases already have their own skulls; these reshape
whichever one is selected.
</p>
<div class="body-style" id="head-style" aria-label="Face">
<label class="field field--slider">
<span>Face length <em id="face-length-label">0.00</em></span>
<div class="slider-ends"><i>Round</i><i>Long</i></div>
<input id="face-length" type="range" min="-1" max="1" step="0.01" value="0" />
</label>
<label class="field field--slider">
<span>Jaw <em id="face-jaw-label">0.00</em></span>
<div class="slider-ends"><i>Tapered</i><i>Square</i></div>
<input id="face-jaw" type="range" min="-1" max="1" step="0.01" value="0" />
</label>
<label class="field field--slider">
<span>Brow <em id="face-brow-label">0.00</em></span>
<div class="slider-ends"><i>Soft</i><i>Heavy</i></div>
<input id="face-brow" type="range" min="-1" max="1" step="0.01" value="0" />
</label>
<button type="button" class="btn btn--ghost btn--small" id="head-style-reset" title="Reset face to the neutral head for this body">
Reset face
</button>
</div>
</section>
<section class="section">
<h2>Parts</h2>
<div id="parts"></div>
</section>
<section class="section">
<h2>Palette</h2>
<div class="palette" id="palette"></div>
</section>
<section class="section">
<h2>Texture</h2>
<p class="hint">
Unwrap UVs, bake a guide sheet, then paint albedo with Grok Imagine
(dev server). Results are cached by prompt — the prompt doesn't
include the mesh, so after reshaping a body or face you need
Regenerate to see it repainted.
</p>
<label class="field">
<span>Wardrobe / style notes</span>
<textarea
id="style-notes"
rows="3"
spellcheck="false"
placeholder="charcoal three-piece business suit, white shirt, burgundy tie, polished black oxfords"
></textarea>
</label>
<p class="hint">
Steers colour, material and detailing only — the cut is fixed by the
equipped parts, so pick a suit-shaped upper first. Editing this
invalidates the cached albedo by itself.
</p>
<div class="actions">
<button type="button" class="btn" id="uv-guide" title="Cylindrical unwrap + UV guide PNG">
Bake UV guide
</button>
<button type="button" class="btn btn--accent" id="texture-grok" title="UV guide → Grok Imagine → textured GLB (uses the cached albedo if there is one)">
Texture with Grok
</button>
<button type="button" class="btn" id="texture-regen" title="Repaint with Grok, ignoring and overwriting the cached albedo — costs a fresh generation">
Regenerate texture
</button>
<button type="button" class="btn" id="albedo-import" title="Apply an albedo PNG/JPEG you painted yourself — no Grok, no cost">
Import albedo…
</button>
<input id="albedo-file" type="file" accept="image/png,image/jpeg" hidden />
</div>
<p class="hint">
Import applies any image painted over the UV guide, so you can use a
different generator or paint it by hand.
</p>
<div class="tex-previews" id="tex-previews" hidden>
<figure class="tex-card">
<figcaption>UV guide</figcaption>
<a id="uv-guide-link" href="#" target="_blank" rel="noreferrer">
<img id="uv-guide-img" alt="UV guide" />
</a>
</figure>
<figure class="tex-card" id="albedo-card" hidden>
<figcaption>Albedo</figcaption>
<a id="albedo-link" href="#" target="_blank" rel="noreferrer">
<img id="albedo-img" alt="Albedo texture" />
</a>
</figure>
</div>
<p class="status" id="tex-status" aria-live="polite"></p>
</section>
<section class="section section--actions">
<h2>Export</h2>
<div class="actions">
<button type="button" class="btn btn--primary" id="download-glb">Download GLB</button>
<button type="button" class="btn" id="download-recipe">Download recipe</button>
<button type="button" class="btn" id="copy-recipe">Copy recipe JSON</button>
<button type="button" class="btn btn--accent" id="save-assets" title="Writes to assets/characters/ (dev server only)">
Save to assets/
</button>
</div>
<p class="status" id="status" aria-live="polite"></p>
</section>
</aside>
<main class="stage">
<canvas id="viewport"></canvas>
<div class="stage__hud">
<div class="stage__stats" id="stats">Assembling…</div>
<div class="stage__controls">
<label class="inline">
<span>Clip</span>
<select id="clip">
<option value="Idle">Idle</option>
<option value="Walk">Walk</option>
<option value="Run">Run</option>
<option value="QuickTurn">QuickTurn</option>
<option value="Slumped">Slumped</option>
</select>
</label>
<label class="inline checkbox">
<input type="checkbox" id="auto-rotate" checked />
<span>Spin</span>
</label>
</div>
</div>
</main>
<aside class="panel panel--right">
<header class="panel__header">
<h2>Recipe</h2>
</header>
<pre id="recipe-json" class="code"></pre>
</aside>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
import {
BackSide,
BoxGeometry,
CircleGeometry,
Color,
ConeGeometry,
CylinderGeometry,
FogExp2,
Group,
Mesh,
MeshLambertMaterial,
PlaneGeometry,
Scene,
type Material,
} from "three";
/** Muted outdoor palette — fog-choked manor grounds, not neon. */
const PALETTE = {
skyTop: 0x6a7a72,
skyHorizon: 0xa8a890,
fog: 0x8a9080,
grass: 0x3d4a32,
grassDark: 0x2a3424,
dirt: 0x5c4a32,
dirtLight: 0x6e5a3e,
path: 0x6a5c48,
bark: 0x3a2e22,
foliage: 0x2f3d28,
foliageDark: 0x243020,
rock: 0x5a5850,
rockDark: 0x3e3c36,
trunk: 0x2c241c,
};
function lambert(color: number, opts: { flat?: boolean; side?: typeof BackSide } = {}): MeshLambertMaterial {
return new MeshLambertMaterial({
color,
flatShading: opts.flat ?? true,
...(opts.side !== undefined ? { side: opts.side } : {}),
});
}
function addMesh(
parent: Group,
geometry: ConstructorParameters<typeof Mesh>[0],
material: Material,
x: number,
y: number,
z: number,
sx = 1,
sy = 1,
sz = 1,
rotY = 0,
): Mesh {
const mesh = new Mesh(geometry, material);
mesh.position.set(x, y, z);
mesh.scale.set(sx, sy, sz);
mesh.rotation.y = rotY;
mesh.receiveShadow = false;
mesh.castShadow = false;
parent.add(mesh);
return mesh;
}
/** Low-poly pine: trunk + stacked cones. */
function makeTree(foliage: MeshLambertMaterial, bark: MeshLambertMaterial, scale = 1): Group {
const g = new Group();
const trunk = new Mesh(new CylinderGeometry(0.08, 0.12, 0.7, 5), bark);
trunk.position.y = 0.35;
g.add(trunk);
const tiers = [
{ y: 0.85, r: 0.55, h: 0.7 },
{ y: 1.25, r: 0.42, h: 0.6 },
{ y: 1.55, r: 0.28, h: 0.5 },
];
for (const tier of tiers) {
const cone = new Mesh(new ConeGeometry(tier.r, tier.h, 6), foliage);
cone.position.y = tier.y;
g.add(cone);
}
g.scale.setScalar(scale);
return g;
}
/** Dead / bare tree — just trunk and a few branch boxes. */
function makeDeadTree(bark: MeshLambertMaterial, scale = 1): Group {
const g = new Group();
const trunk = new Mesh(new CylinderGeometry(0.06, 0.1, 1.4, 5), bark);
trunk.position.y = 0.7;
g.add(trunk);
const arm = new Mesh(new BoxGeometry(0.7, 0.07, 0.07), bark);
arm.position.set(0.2, 1.15, 0);
arm.rotation.z = -0.5;
g.add(arm);
const arm2 = new Mesh(new BoxGeometry(0.45, 0.06, 0.06), bark);
arm2.position.set(-0.15, 1.35, 0.05);
arm2.rotation.z = 0.65;
g.add(arm2);
g.scale.setScalar(scale);
return g;
}
function makeRock(rock: MeshLambertMaterial, rockDark: MeshLambertMaterial, scale = 1): Group {
const g = new Group();
const a = new Mesh(new BoxGeometry(0.5, 0.28, 0.4), rock);
a.position.y = 0.12;
a.rotation.y = 0.3;
g.add(a);
const b = new Mesh(new BoxGeometry(0.28, 0.18, 0.32), rockDark);
b.position.set(0.18, 0.08, 0.1);
g.add(b);
g.scale.setScalar(scale);
return g;
}
/**
* Builds a fixed outdoor set for the character creator:
* grass clearing, dirt path, pines, rocks, fog sky.
* Keeps triangle counts low so the preview stays snappy.
*/
export function buildEnvironment(scene: Scene): Group {
const root = new Group();
root.name = "environment";
// --- atmosphere ----------------------------------------------------------
scene.background = new Color(PALETTE.skyHorizon);
scene.fog = new FogExp2(PALETTE.fog, 0.085);
// Gradient sky dome (inside of a sphere-ish cone stack of rings via large sphere).
const skyGeo = new ConeGeometry(18, 14, 8, 1, true);
// Cone points up; flip so we stand inside a bowl of sky.
const sky = new Mesh(skyGeo, lambert(PALETTE.skyTop, { side: BackSide, flat: true }));
sky.position.y = 5;
sky.rotation.x = Math.PI;
// Paint horizon band with a second inverted cone / ring.
root.add(sky);
const horizon = new Mesh(
new CylinderGeometry(17, 17, 4, 10, 1, true),
lambert(PALETTE.skyHorizon, { side: BackSide, flat: true }),
);
horizon.position.y = 1.5;
root.add(horizon);
// --- ground --------------------------------------------------------------
const grassMat = lambert(PALETTE.grass);
const grassDarkMat = lambert(PALETTE.grassDark);
const dirtMat = lambert(PALETTE.dirt);
const pathMat = lambert(PALETTE.path);
// Large grass disc (circle reads softer than a huge square at the fog edge).
const ground = new Mesh(new CircleGeometry(14, 12), grassMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = 0;
root.add(ground);
// Darker grass patches.
for (const [x, z, s] of [
[-2.2, -1.8, 1.4],
[2.8, 1.2, 1.1],
[-1.0, 3.0, 1.3],
[3.5, -2.5, 0.9],
] as const) {
const patch = new Mesh(new CircleGeometry(s, 6), grassDarkMat);
patch.rotation.x = -Math.PI / 2;
patch.position.set(x, 0.005, z);
root.add(patch);
}
// Dirt clearing under the character.
const clearing = new Mesh(new CircleGeometry(1.35, 8), dirtMat);
clearing.rotation.x = -Math.PI / 2;
clearing.position.y = 0.01;
root.add(clearing);
// Path leading toward camera / back.
const path = new Mesh(new PlaneGeometry(1.1, 8), pathMat);
path.rotation.x = -Math.PI / 2;
path.position.set(0, 0.012, 3.2);
root.add(path);
const pathBack = new Mesh(new PlaneGeometry(0.9, 5), pathMat);
pathBack.rotation.x = -Math.PI / 2;
pathBack.position.set(0.4, 0.012, -3.5);
pathBack.rotation.z = 0.15;
root.add(pathBack);
// --- props ---------------------------------------------------------------
const bark = lambert(PALETTE.bark);
const foliage = lambert(PALETTE.foliage);
const foliageDark = lambert(PALETTE.foliageDark);
const rock = lambert(PALETTE.rock);
const rockDark = lambert(PALETTE.rockDark);
const trunk = lambert(PALETTE.trunk);
const trees: Array<[number, number, number, number]> = [
[-3.2, -2.4, 1.15, 0.4],
[-4.5, 1.2, 1.35, 1.1],
[3.8, -1.6, 1.05, -0.3],
[4.2, 2.0, 1.4, 0.8],
[-2.8, 3.6, 0.95, 0.2],
[2.5, 4.0, 1.2, -0.6],
[-5.5, -0.5, 1.5, 0],
[5.0, -3.2, 1.1, 1.4],
];
trees.forEach(([x, z, s, rot], i) => {
const tree = makeTree(i % 2 === 0 ? foliage : foliageDark, bark, s);
tree.position.set(x, 0, z);
tree.rotation.y = rot;
root.add(tree);
});
// A couple of bare trees for RE / SH silhouette variety.
for (const [x, z, s, rot] of [
[-4.0, 3.8, 1.1, 0.5],
[4.8, 0.5, 0.95, -0.8],
] as const) {
const dead = makeDeadTree(trunk, s);
dead.position.set(x, 0, z);
dead.rotation.y = rot;
root.add(dead);
}
for (const [x, z, s, rot] of [
[1.6, -1.1, 0.7, 0.2],
[-1.4, -1.4, 0.9, 1.0],
[2.2, 1.8, 0.55, -0.4],
[-2.5, 0.8, 0.65, 0.7],
[0.8, 2.4, 0.45, 0.1],
] as const) {
const r = makeRock(rock, rockDark, s);
r.position.set(x, 0, z);
r.rotation.y = rot;
root.add(r);
}
// Low hedge / bush clumps (cones).
for (const [x, z, s] of [
[-1.8, 2.2, 0.45],
[1.9, -2.0, 0.38],
[-3.5, -3.0, 0.5],
] as const) {
const bush = new Mesh(new ConeGeometry(0.45 * s * 2, 0.5 * s * 2, 5), foliageDark);
bush.position.set(x, 0.2 * s * 2, z);
root.add(bush);
}
// Distant low hills (flattened boxes at fog edge).
const hillMat = lambert(PALETTE.grassDark);
for (const [x, z, sx, sy, sz] of [
[-8, -6, 5, 1.2, 3],
[7, -7, 4, 0.9, 3.5],
[-6, 8, 6, 1.4, 3],
[9, 5, 4, 1.1, 4],
] as const) {
addMesh(root, new BoxGeometry(1, 1, 1), hillMat, x, sy * 0.35, z, sx, sy, sz);
}
scene.add(root);
return root;
}
export { PALETTE as ENV_PALETTE };
+769
View File
@@ -0,0 +1,769 @@
import { assembleCharacter } from "../../src/creator/assemble";
import { SKIN } from "../../src/creator/body";
import {
BODY_STYLE_DEFAULTS,
normalizeBodyStyle,
type BodyStyle,
} from "../../src/creator/bodyStyle";
import {
HEAD_STYLE_DEFAULTS,
normalizeHeadStyle,
type HeadStyle,
} from "../../src/creator/headStyle";
import { PART_DEFINITIONS } from "../../src/creator/parts";
import { CHARACTER_PRESETS } from "../../src/creator/presets";
import type {
BodyType,
CharacterRecipe,
CreatorPalette,
PartSlot,
} from "../../src/creator/types";
import type { Rgb } from "../../src/image/Raster";
import { CharacterPreview } from "./preview";
const SLOTS: PartSlot[] = ["hair", "upper", "lower", "feet", "accessory"];
const PALETTE_KEYS: (keyof CreatorPalette)[] = [
"skin",
"hair",
"primary",
"secondary",
"accent",
"metal",
"leather",
];
const SKIN_OPTIONS: { id: string; name: string; color: Rgb }[] = [
{ id: "fair", name: "Fair", color: SKIN.fair },
{ id: "light", name: "Light", color: SKIN.light },
{ id: "medium", name: "Medium", color: SKIN.medium },
{ id: "tan", name: "Tan", color: SKIN.tan },
{ id: "deep", name: "Deep", color: SKIN.deep },
];
interface CreatorState {
id: string;
name: string;
body: BodyType;
height: number;
bodyStyle: BodyStyle;
headStyle: HeadStyle;
styleNotes: string;
palette: CreatorPalette;
parts: Partial<Record<PartSlot, string>>;
}
function clonePalette(palette: CreatorPalette): CreatorPalette {
return {
skin: { ...palette.skin },
hair: { ...palette.hair },
primary: { ...palette.primary },
secondary: { ...palette.secondary },
accent: { ...palette.accent },
metal: { ...palette.metal },
leather: { ...palette.leather },
};
}
function cloneBodyStyle(style?: BodyStyle | null): BodyStyle {
return normalizeBodyStyle(style ?? BODY_STYLE_DEFAULTS);
}
function cloneHeadStyle(style?: HeadStyle | null): HeadStyle {
return normalizeHeadStyle(style ?? HEAD_STYLE_DEFAULTS);
}
function fromPreset(preset: CharacterRecipe): CreatorState {
return {
id: preset.id,
name: preset.name,
body: preset.body,
height: preset.height,
bodyStyle: cloneBodyStyle(preset.bodyStyle),
headStyle: cloneHeadStyle(preset.headStyle),
styleNotes: preset.styleNotes ?? "",
palette: clonePalette(preset.palette),
parts: { ...preset.parts },
};
}
function defaultState(): CreatorState {
const preset = CHARACTER_PRESETS[0]!;
return fromPreset(preset);
}
function rgbToHex({ r, g, b }: Rgb): string {
const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
return `#${h(r)}${h(g)}${h(b)}`;
}
function hexToRgb(hex: string): Rgb {
const raw = hex.replace("#", "");
const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
const n = Number.parseInt(full, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function nearestSkinId(skin: Rgb): string {
let best = SKIN_OPTIONS[0]!;
let bestDist = Infinity;
for (const option of SKIN_OPTIONS) {
const d =
(option.color.r - skin.r) ** 2 +
(option.color.g - skin.g) ** 2 +
(option.color.b - skin.b) ** 2;
if (d < bestDist) {
bestDist = d;
best = option;
}
}
return best.id;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "character";
}
function partOptions(slot: PartSlot, body: BodyType) {
return PART_DEFINITIONS.filter((part) => part.slot === slot && (!part.body || part.body === body));
}
function buildRecipe(state: CreatorState): CharacterRecipe {
const parts: CharacterRecipe["parts"] = {};
for (const slot of SLOTS) {
const id = state.parts[slot];
if (id) parts[slot] = id;
}
const bodyStyle = cloneBodyStyle(state.bodyStyle);
const headStyle = cloneHeadStyle(state.headStyle);
const styleNotes = state.styleNotes.trim();
return {
id: state.id || slugify(state.name),
name: state.name || "Character",
body: state.body,
height: state.height,
bodyStyle,
headStyle,
...(styleNotes ? { styleNotes } : {}),
palette: clonePalette(state.palette),
parts,
};
}
function formatStyle(n: number): string {
return (Math.round(n * 100) / 100).toFixed(2);
}
function recipeJson(recipe: CharacterRecipe, stats?: { vertices: number; triangles: number; bones: number; clips: number }): string {
return JSON.stringify(
{
...recipe,
...(stats ? { stats } : {}),
},
null,
2,
);
}
function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
const state = defaultState();
let latestGlb: Uint8Array | null = null;
let latestRecipe: CharacterRecipe | null = null;
let rebuildTimer = 0;
let rebuildToken = 0;
const el = {
preset: document.querySelector<HTMLSelectElement>("#preset")!,
name: document.querySelector<HTMLInputElement>("#name")!,
id: document.querySelector<HTMLInputElement>("#id")!,
body: document.querySelector<HTMLElement>("#body")!,
height: document.querySelector<HTMLInputElement>("#height")!,
heightLabel: document.querySelector<HTMLElement>("#height-label")!,
mass: document.querySelector<HTMLInputElement>("#mass")!,
massLabel: document.querySelector<HTMLElement>("#mass-label")!,
muscle: document.querySelector<HTMLInputElement>("#muscle")!,
muscleLabel: document.querySelector<HTMLElement>("#muscle-label")!,
fat: document.querySelector<HTMLInputElement>("#fat")!,
fatLabel: document.querySelector<HTMLElement>("#fat-label")!,
bodyStyleReset: document.querySelector<HTMLButtonElement>("#body-style-reset")!,
faceLength: document.querySelector<HTMLInputElement>("#face-length")!,
faceLengthLabel: document.querySelector<HTMLElement>("#face-length-label")!,
faceJaw: document.querySelector<HTMLInputElement>("#face-jaw")!,
faceJawLabel: document.querySelector<HTMLElement>("#face-jaw-label")!,
faceBrow: document.querySelector<HTMLInputElement>("#face-brow")!,
faceBrowLabel: document.querySelector<HTMLElement>("#face-brow-label")!,
headStyleReset: document.querySelector<HTMLButtonElement>("#head-style-reset")!,
skinSwatches: document.querySelector<HTMLElement>("#skin-swatches")!,
parts: document.querySelector<HTMLElement>("#parts")!,
palette: document.querySelector<HTMLElement>("#palette")!,
stats: document.querySelector<HTMLElement>("#stats")!,
recipeJson: document.querySelector<HTMLElement>("#recipe-json")!,
status: document.querySelector<HTMLElement>("#status")!,
clip: document.querySelector<HTMLSelectElement>("#clip")!,
autoRotate: document.querySelector<HTMLInputElement>("#auto-rotate")!,
downloadGlb: document.querySelector<HTMLButtonElement>("#download-glb")!,
downloadRecipe: document.querySelector<HTMLButtonElement>("#download-recipe")!,
copyRecipe: document.querySelector<HTMLButtonElement>("#copy-recipe")!,
saveAssets: document.querySelector<HTMLButtonElement>("#save-assets")!,
uvGuide: document.querySelector<HTMLButtonElement>("#uv-guide")!,
textureGrok: document.querySelector<HTMLButtonElement>("#texture-grok")!,
textureRegen: document.querySelector<HTMLButtonElement>("#texture-regen")!,
albedoImport: document.querySelector<HTMLButtonElement>("#albedo-import")!,
albedoFile: document.querySelector<HTMLInputElement>("#albedo-file")!,
styleNotes: document.querySelector<HTMLTextAreaElement>("#style-notes")!,
texStatus: document.querySelector<HTMLElement>("#tex-status")!,
texPreviews: document.querySelector<HTMLElement>("#tex-previews")!,
uvGuideImg: document.querySelector<HTMLImageElement>("#uv-guide-img")!,
uvGuideLink: document.querySelector<HTMLAnchorElement>("#uv-guide-link")!,
albedoCard: document.querySelector<HTMLElement>("#albedo-card")!,
albedoImg: document.querySelector<HTMLImageElement>("#albedo-img")!,
albedoLink: document.querySelector<HTMLAnchorElement>("#albedo-link")!,
viewport: document.querySelector<HTMLCanvasElement>("#viewport")!,
};
const preview = new CharacterPreview(el.viewport);
preview.setAutoRotate(el.autoRotate.checked);
function setStatus(message: string, kind: "ok" | "err" | "" = ""): void {
el.status.textContent = message;
el.status.classList.remove("is-ok", "is-err");
if (kind === "ok") el.status.classList.add("is-ok");
if (kind === "err") el.status.classList.add("is-err");
}
function setTexStatus(message: string, kind: "ok" | "err" | "" = ""): void {
el.texStatus.textContent = message;
el.texStatus.classList.remove("is-ok", "is-err");
if (kind === "ok") el.texStatus.classList.add("is-ok");
if (kind === "err") el.texStatus.classList.add("is-err");
}
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
function setTextureBusy(busy: boolean): void {
el.uvGuide.disabled = busy;
el.textureGrok.disabled = busy;
el.textureRegen.disabled = busy;
el.albedoImport.disabled = busy;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
interface TextureRun {
mode: "guide" | "texture";
force?: boolean;
/** Apply this image as the albedo instead of calling Grok. */
albedo?: File;
}
async function runTexture({ mode, force = false, albedo }: TextureRun): Promise<void> {
const recipe = buildRecipe(state);
setTextureBusy(true);
setTexStatus(
albedo
? `Applying ${albedo.name}`
: mode === "guide"
? "Baking UV guide…"
: force
? "Repainting with Grok Imagine, ignoring the cache… (can take ~1 min)"
: "Texturing with Grok Imagine… (can take ~1 min)",
);
try {
const albedoBase64 = albedo
? bytesToBase64(new Uint8Array(await albedo.arrayBuffer()))
: undefined;
const response = await fetch("/api/texture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipe, mode, force, albedoBase64 }),
});
const body = (await response.json()) as {
ok?: boolean;
error?: string;
glbBase64?: string;
guideUrl?: string;
albedoUrl?: string | null;
textured?: boolean;
imported?: boolean;
cached?: boolean;
costUsd?: number;
bytes?: number;
};
if (!response.ok || !body.ok || !body.glbBase64) {
throw new Error(body.error ?? `HTTP ${response.status}`);
}
latestGlb = base64ToBytes(body.glbBase64);
latestRecipe = recipe;
if (body.guideUrl) {
el.texPreviews.hidden = false;
el.uvGuideImg.src = body.guideUrl;
el.uvGuideLink.href = body.guideUrl;
}
if (body.albedoUrl) {
el.albedoCard.hidden = false;
el.albedoImg.src = body.albedoUrl;
el.albedoLink.href = body.albedoUrl;
} else {
el.albedoCard.hidden = true;
}
await preview.setGlb(latestGlb, el.clip.value);
const bits = [
mode === "guide"
? "UV guide ready"
: !body.textured
? "Done"
: body.imported
? "Albedo imported"
: force
? "Repainted — cache replaced"
: "Textured GLB ready",
body.bytes ? `${(body.bytes / 1024).toFixed(0)} KB` : "",
body.cached ? "cached" : "",
body.costUsd && body.costUsd > 0 ? `$${body.costUsd.toFixed(3)}` : "",
].filter(Boolean);
setTexStatus(bits.join(" · "), "ok");
el.stats.textContent = [
body.textured
? body.imported
? "Textured (imported albedo)"
: "Textured (Grok albedo)"
: "UV guide baked (vertex colour GLB)",
body.bytes ? `${(body.bytes / 1024).toFixed(1)} KB` : "",
recipe.body + " @ " + recipe.height.toFixed(2) + " m",
]
.filter(Boolean)
.join("\n");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTexStatus(message, "err");
} finally {
setTextureBusy(false);
}
}
function syncBodyStyleControls(): void {
el.mass.value = String(state.bodyStyle.mass);
el.muscle.value = String(state.bodyStyle.muscle);
el.fat.value = String(state.bodyStyle.fat);
el.massLabel.textContent = formatStyle(state.bodyStyle.mass);
el.muscleLabel.textContent = formatStyle(state.bodyStyle.muscle);
el.fatLabel.textContent = formatStyle(state.bodyStyle.fat);
}
function syncHeadStyleControls(): void {
el.faceLength.value = String(state.headStyle.length);
el.faceJaw.value = String(state.headStyle.jaw);
el.faceBrow.value = String(state.headStyle.brow);
el.faceLengthLabel.textContent = formatStyle(state.headStyle.length);
el.faceJawLabel.textContent = formatStyle(state.headStyle.jaw);
el.faceBrowLabel.textContent = formatStyle(state.headStyle.brow);
}
function syncFormFromState(): void {
el.name.value = state.name;
el.id.value = state.id;
el.height.value = String(state.height);
el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
syncBodyStyleControls();
syncHeadStyleControls();
el.styleNotes.value = state.styleNotes;
for (const btn of el.body.querySelectorAll<HTMLButtonElement>(".seg__btn")) {
btn.classList.toggle("is-active", btn.dataset.body === state.body);
}
const skinId = nearestSkinId(state.palette.skin);
for (const swatch of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
swatch.classList.toggle("is-active", swatch.dataset.skin === skinId);
}
for (const key of PALETTE_KEYS) {
const input = el.palette.querySelector<HTMLInputElement>(`input[data-key="${key}"]`);
if (input) input.value = rgbToHex(state.palette[key]);
}
renderParts();
}
function renderPresets(): void {
el.preset.innerHTML = "";
const blank = document.createElement("option");
blank.value = "";
blank.textContent = "— custom —";
el.preset.append(blank);
for (const preset of CHARACTER_PRESETS) {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = `${preset.name} (${preset.id})`;
el.preset.append(option);
}
el.preset.value = state.id;
}
function renderSkinSwatches(): void {
el.skinSwatches.innerHTML = "";
for (const option of SKIN_OPTIONS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "swatch";
btn.title = option.name;
btn.dataset.skin = option.id;
btn.style.background = rgbToHex(option.color);
btn.addEventListener("click", () => {
state.palette.skin = { ...option.color };
const skinInput = el.palette.querySelector<HTMLInputElement>(`input[data-key="skin"]`);
if (skinInput) skinInput.value = rgbToHex(option.color);
for (const s of el.skinSwatches.querySelectorAll(".swatch")) {
s.classList.toggle("is-active", s === btn);
}
scheduleRebuild();
});
el.skinSwatches.append(btn);
}
}
function renderPalette(): void {
el.palette.innerHTML = "";
for (const key of PALETTE_KEYS) {
const label = document.createElement("label");
label.className = "color-field";
const span = document.createElement("span");
span.textContent = key;
const input = document.createElement("input");
input.type = "color";
input.dataset.key = key;
input.value = rgbToHex(state.palette[key]);
input.addEventListener("input", () => {
state.palette[key] = hexToRgb(input.value);
if (key === "skin") {
const skinId = nearestSkinId(state.palette.skin);
for (const s of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
s.classList.toggle("is-active", s.dataset.skin === skinId);
}
}
scheduleRebuild();
});
label.append(span, input);
el.palette.append(label);
}
}
function renderParts(): void {
el.parts.innerHTML = "";
for (const slot of SLOTS) {
const wrap = document.createElement("div");
wrap.className = "part-slot";
const label = document.createElement("span");
label.className = "part-slot__label";
label.textContent = slot;
wrap.append(label);
const row = document.createElement("div");
row.className = "chip-row";
const options = partOptions(slot, state.body);
const current = state.parts[slot] ?? `${slot}.none`;
// If current part is invalid for body (e.g. skirt on male), clear it.
if (!options.some((p) => p.id === current)) {
state.parts[slot] = options[0]?.id ?? `${slot}.none`;
}
for (const part of options) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
chip.textContent = part.name;
chip.dataset.part = part.id;
if (part.id === (state.parts[slot] ?? `${slot}.none`)) chip.classList.add("is-active");
chip.addEventListener("click", () => {
state.parts[slot] = part.id;
for (const c of row.querySelectorAll(".chip")) c.classList.remove("is-active");
chip.classList.add("is-active");
scheduleRebuild();
});
row.append(chip);
}
wrap.append(row);
el.parts.append(wrap);
}
}
function scheduleRebuild(): void {
window.clearTimeout(rebuildTimer);
rebuildTimer = window.setTimeout(() => {
void rebuild();
}, 80);
}
async function rebuild(): Promise<void> {
const token = ++rebuildToken;
const recipe = buildRecipe(state);
el.recipeJson.textContent = recipeJson(recipe);
el.stats.textContent = "Assembling…";
try {
const t0 = performance.now();
const result = assembleCharacter(recipe);
if (token !== rebuildToken) return;
latestGlb = result.glb;
latestRecipe = result.recipe;
const ms = performance.now() - t0;
el.recipeJson.textContent = recipeJson(result.recipe, result.stats);
const bs = result.recipe.bodyStyle ?? BODY_STYLE_DEFAULTS;
const hs = result.recipe.headStyle ?? HEAD_STYLE_DEFAULTS;
el.stats.textContent = [
`${result.stats.vertices} verts · ${result.stats.triangles} tris · ${result.stats.bones} bones`,
`${(result.glb.byteLength / 1024).toFixed(1)} KB · ${ms.toFixed(0)} ms`,
`${result.recipe.body} @ ${result.recipe.height.toFixed(2)} m`,
`mass ${formatStyle(bs.mass)} · muscle ${formatStyle(bs.muscle)} · fat ${formatStyle(bs.fat)}`,
`face ${formatStyle(hs.length)} · jaw ${formatStyle(hs.jaw)} · brow ${formatStyle(hs.brow)}`,
].join("\n");
await preview.setGlb(result.glb, el.clip.value);
if (token !== rebuildToken) return;
setStatus("");
} catch (error) {
if (token !== rebuildToken) return;
const message = error instanceof Error ? error.message : String(error);
el.stats.textContent = "Assemble failed";
setStatus(message, "err");
console.error(error);
}
}
// Events -------------------------------------------------------------------
el.preset.addEventListener("change", () => {
const preset = CHARACTER_PRESETS.find((p) => p.id === el.preset.value);
if (!preset) return;
Object.assign(state, fromPreset(preset));
syncFormFromState();
scheduleRebuild();
});
el.name.addEventListener("input", () => {
state.name = el.name.value;
// Keep id in sync while it still looks auto-derived.
if (!el.id.dataset.manual) {
state.id = slugify(state.name);
el.id.value = state.id;
}
el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
scheduleRebuild();
});
el.id.addEventListener("input", () => {
el.id.dataset.manual = "1";
state.id = slugify(el.id.value) || el.id.value;
el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
scheduleRebuild();
});
el.body.addEventListener("click", (event) => {
const btn = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-body]");
if (!btn?.dataset.body) return;
state.body = btn.dataset.body as BodyType;
for (const b of el.body.querySelectorAll(".seg__btn")) b.classList.remove("is-active");
btn.classList.add("is-active");
renderParts();
scheduleRebuild();
});
el.height.addEventListener("input", () => {
state.height = Number(el.height.value);
el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
scheduleRebuild();
});
function bindStyleSlider(
input: HTMLInputElement,
label: HTMLElement,
key: keyof BodyStyle,
): void {
input.addEventListener("input", () => {
state.bodyStyle[key] = Number(input.value);
label.textContent = formatStyle(state.bodyStyle[key]);
scheduleRebuild();
});
}
bindStyleSlider(el.mass, el.massLabel, "mass");
bindStyleSlider(el.muscle, el.muscleLabel, "muscle");
bindStyleSlider(el.fat, el.fatLabel, "fat");
function bindFaceSlider(
input: HTMLInputElement,
label: HTMLElement,
key: keyof HeadStyle,
): void {
input.addEventListener("input", () => {
state.headStyle[key] = Number(input.value);
label.textContent = formatStyle(state.headStyle[key]);
scheduleRebuild();
});
}
bindFaceSlider(el.faceLength, el.faceLengthLabel, "length");
bindFaceSlider(el.faceJaw, el.faceJawLabel, "jaw");
bindFaceSlider(el.faceBrow, el.faceBrowLabel, "brow");
el.bodyStyleReset.addEventListener("click", () => {
state.bodyStyle = cloneBodyStyle(BODY_STYLE_DEFAULTS);
syncBodyStyleControls();
scheduleRebuild();
});
el.headStyleReset.addEventListener("click", () => {
state.headStyle = cloneHeadStyle(HEAD_STYLE_DEFAULTS);
syncHeadStyleControls();
scheduleRebuild();
});
el.clip.addEventListener("change", () => {
preview.play(el.clip.value);
});
el.autoRotate.addEventListener("change", () => {
preview.setAutoRotate(el.autoRotate.checked);
});
el.downloadGlb.addEventListener("click", () => {
if (!latestGlb || !latestRecipe) {
setStatus("Nothing to download yet", "err");
return;
}
const bytes = new Uint8Array(latestGlb);
downloadBlob(
`${latestRecipe.id}.glb`,
new Blob([bytes], { type: "model/gltf-binary" }),
);
setStatus(`Downloaded ${latestRecipe.id}.glb`, "ok");
});
el.downloadRecipe.addEventListener("click", () => {
if (!latestRecipe) {
setStatus("Nothing to download yet", "err");
return;
}
const text = recipeJson(latestRecipe);
downloadBlob(`${latestRecipe.id}.recipe.json`, new Blob([text], { type: "application/json" }));
setStatus(`Downloaded ${latestRecipe.id}.recipe.json`, "ok");
});
el.copyRecipe.addEventListener("click", async () => {
if (!latestRecipe) {
setStatus("Nothing to copy yet", "err");
return;
}
try {
await navigator.clipboard.writeText(recipeJson(latestRecipe));
setStatus("Recipe JSON copied", "ok");
} catch {
setStatus("Clipboard blocked — select the JSON panel instead", "err");
}
});
el.saveAssets.addEventListener("click", async () => {
if (!latestGlb || !latestRecipe) {
setStatus("Nothing to save yet", "err");
return;
}
setStatus("Saving to assets/characters/…");
try {
const response = await fetch("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: latestRecipe.id,
recipe: {
...latestRecipe,
},
glbBase64: bytesToBase64(latestGlb),
}),
});
const body = (await response.json()) as { ok?: boolean; error?: string; bytes?: number; glb?: string };
if (!response.ok || !body.ok) {
throw new Error(body.error ?? `HTTP ${response.status}`);
}
setStatus(`Saved ${latestRecipe.id}.glb (${((body.bytes ?? 0) / 1024).toFixed(1)} KB)`, "ok");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Save failed: ${message}`, "err");
}
});
el.styleNotes.addEventListener("input", () => {
state.styleNotes = el.styleNotes.value;
// Doesn't touch geometry, but keeps the recipe JSON panel truthful.
scheduleRebuild();
});
el.uvGuide.addEventListener("click", () => {
void runTexture({ mode: "guide" });
});
el.textureGrok.addEventListener("click", () => {
void runTexture({ mode: "texture" });
});
el.textureRegen.addEventListener("click", () => {
void runTexture({ mode: "texture", force: true });
});
el.albedoImport.addEventListener("click", () => {
el.albedoFile.click();
});
el.albedoFile.addEventListener("change", () => {
const file = el.albedoFile.files?.[0];
// Reset first, so picking the same file twice still fires a change event.
el.albedoFile.value = "";
if (file) void runTexture({ mode: "texture", albedo: file });
});
// Boot ---------------------------------------------------------------------
renderPresets();
renderSkinSwatches();
renderPalette();
syncFormFromState();
void rebuild();
+284
View File
@@ -0,0 +1,284 @@
import {
AmbientLight,
AnimationMixer,
Color,
DirectionalLight,
Group,
HemisphereLight,
LoopOnce,
LoopRepeat,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
NearestFilter,
OrthographicCamera,
PerspectiveCamera,
PlaneGeometry,
RGBAFormat,
Scene,
SkinnedMesh,
SRGBColorSpace,
WebGLRenderTarget,
WebGLRenderer,
type AnimationAction,
type AnimationClip,
type Material,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { buildEnvironment, ENV_PALETTE } from "./environment";
/**
* Three.js viewport for the character creator.
* Outdoor PSX-style set, fog, sun lighting, and a low-res nearest upscale.
*/
export class CharacterPreview {
private readonly renderer: WebGLRenderer;
private readonly scene = new Scene();
private readonly camera: PerspectiveCamera;
private readonly controls: OrbitControls;
private readonly loader = new GLTFLoader();
private readonly root = new Group();
private readonly clock = { last: performance.now() };
/** Offscreen low-res buffer → nearest-neighbor blit (GTE-ish look). */
private readonly rt: WebGLRenderTarget;
private readonly blitScene = new Scene();
private readonly blitCam = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
private readonly blitMat: MeshBasicMaterial;
private viewW = 1;
private viewH = 1;
private mixer: AnimationMixer | null = null;
private actions = new Map<string, AnimationAction>();
private character: Group | null = null;
private env: Group | null = null;
private raf = 0;
private autoRotate = true;
private disposed = false;
private loadGen = 0;
constructor(private readonly canvas: HTMLCanvasElement) {
this.renderer = new WebGLRenderer({
canvas,
antialias: false,
alpha: false,
powerPreference: "high-performance",
});
this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
this.renderer.setPixelRatio(1);
this.renderer.outputColorSpace = SRGBColorSpace;
this.camera = new PerspectiveCamera(38, 1, 0.08, 28);
this.camera.position.set(1.55, 1.25, 2.65);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 0.85, 0);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.minDistance = 1.0;
this.controls.maxDistance = 7;
this.controls.maxPolarAngle = Math.PI * 0.48;
this.controls.minPolarAngle = 0.15;
this.scene.add(this.root);
this.env = buildEnvironment(this.scene);
this.setupLighting();
// Low-res render target; size updated on resize.
this.rt = new WebGLRenderTarget(320, 240, {
format: RGBAFormat,
minFilter: NearestFilter,
magFilter: NearestFilter,
depthBuffer: true,
stencilBuffer: false,
});
this.rt.texture.generateMipmaps = false;
this.blitMat = new MeshBasicMaterial({
map: this.rt.texture,
depthTest: false,
depthWrite: false,
});
const blitQuad = new Mesh(new PlaneGeometry(2, 2), this.blitMat);
this.blitScene.add(blitQuad);
this.onResize();
window.addEventListener("resize", this.onResize);
this.tick();
}
private setupLighting(): void {
// Cool overcast sky / warm dirt bounce — PSX adventure outdoor.
const hemi = new HemisphereLight(0xb0b8a0, 0x3a3220, 0.55);
hemi.position.set(0, 4, 0);
// Late-afternoon key (sun).
const sun = new DirectionalLight(0xffe2b0, 1.35);
sun.position.set(4.5, 7.5, 3.2);
// Cool sky fill from the opposite side.
const fill = new DirectionalLight(0x8898a8, 0.4);
fill.position.set(-3.5, 2.5, -2.5);
// Soft ambient so flats don't crush to black.
const ambient = new AmbientLight(0x4a5040, 0.38);
// Weak rim so silhouettes read against fog.
const rim = new DirectionalLight(0xc8d0c0, 0.25);
rim.position.set(-1.5, 3.0, -4.0);
this.scene.add(hemi, sun, fill, ambient, rim);
}
setAutoRotate(enabled: boolean): void {
this.autoRotate = enabled;
this.controls.autoRotate = enabled;
this.controls.autoRotateSpeed = 1.15;
}
async setGlb(glb: Uint8Array, clipName = "Idle"): Promise<void> {
const gen = ++this.loadGen;
const gltf = await this.parseGlb(glb);
if (this.disposed || gen !== this.loadGen) return;
if (this.character) {
this.root.remove(this.character);
this.disposeObject(this.character);
this.character = null;
}
this.mixer = null;
this.actions.clear();
const scene = gltf.scene as Group;
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
// Keep Grok albedo map when present; otherwise vertex colours.
const prior = mesh.material as Material & { map?: import("three").Texture | null };
const loadedMap = !Array.isArray(prior) && prior.map ? prior.map : null;
mesh.material = new MeshLambertMaterial({
vertexColors: !loadedMap,
flatShading: true,
fog: true,
...(loadedMap ? { map: loadedMap } : {}),
});
if (loadedMap) {
loadedMap.magFilter = NearestFilter;
loadedMap.minFilter = NearestFilter;
loadedMap.needsUpdate = true;
}
if ((mesh as SkinnedMesh).isSkinnedMesh) {
mesh.frustumCulled = false;
}
});
this.root.add(scene);
this.character = scene;
this.mixer = new AnimationMixer(scene);
for (const clip of gltf.animations) {
const action = this.mixer.clipAction(clip);
const once = clip.name === "QuickTurn" || clip.name === "Slumped";
action.setLoop(once ? LoopOnce : LoopRepeat, Infinity);
action.clampWhenFinished = once;
this.actions.set(clip.name, action);
}
this.play(clipName);
this.mixer.update(0);
}
play(clipName: string): void {
if (!this.mixer) return;
const next = this.actions.get(clipName);
if (!next) return;
for (const [name, action] of this.actions) {
if (name === clipName) continue;
if (action.isRunning()) action.fadeOut(0.15);
}
next.reset().fadeIn(0.15).play();
}
dispose(): void {
this.disposed = true;
cancelAnimationFrame(this.raf);
window.removeEventListener("resize", this.onResize);
this.controls.dispose();
if (this.character) this.disposeObject(this.character);
if (this.env) this.disposeObject(this.env);
this.rt.dispose();
this.blitMat.dispose();
this.renderer.dispose();
}
private parseGlb(glb: Uint8Array): Promise<{ scene: Group; animations: AnimationClip[] }> {
const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
return new Promise((resolve, reject) => {
this.loader.parse(buffer, "", (gltf) => resolve(gltf as { scene: Group; animations: AnimationClip[] }), reject);
});
}
private disposeObject(root: Group): void {
root.traverse((object) => {
const mesh = object as Mesh;
if (mesh.isMesh) {
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material?.dispose();
}
});
}
private readonly onResize = (): void => {
const parent = this.canvas.parentElement;
if (!parent) return;
const width = parent.clientWidth;
const height = parent.clientHeight;
if (width === 0 || height === 0) return;
this.viewW = width;
this.viewH = height;
// Internal PSX-ish resolution (~320px wide), keep aspect.
const internalW = 320;
const internalH = Math.max(180, Math.round(internalW * (height / width)));
this.rt.setSize(internalW, internalH);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
// Full canvas for the upscale blit.
this.renderer.setSize(width, height, false);
};
private readonly tick = (): void => {
if (this.disposed) return;
this.raf = requestAnimationFrame(this.tick);
const now = performance.now();
const dt = Math.min(0.05, (now - this.clock.last) / 1000);
this.clock.last = now;
this.mixer?.update(dt);
this.controls.autoRotate = this.autoRotate;
this.controls.update();
// Pass 1: scene → low-res RT
this.renderer.setRenderTarget(this.rt);
this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
this.renderer.clear();
this.renderer.render(this.scene, this.camera);
// Pass 2: nearest upscale to canvas (chunky pixels).
this.renderer.setRenderTarget(null);
this.renderer.clear();
this.renderer.render(this.blitScene, this.blitCam);
};
}
+542
View File
@@ -0,0 +1,542 @@
:root {
--ink: #c8d0c0;
--ink-dim: #7d8878;
--ink-bright: #eef2e6;
--bg: #05060a;
--panel: #0a0e0c;
--panel-elevated: #101612;
--border: #47513f;
--border-soft: #2a3228;
--accent: #c9a227;
--accent-dim: #8a7020;
--danger: #a33b2e;
--ok: #6a9a5a;
--mono: "Courier New", ui-monospace, monospace;
color-scheme: dark;
font-family: var(--mono);
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: none;
font-smooth: never;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
overflow: hidden;
}
#app {
display: grid;
grid-template-columns: minmax(280px, 340px) 1fr minmax(240px, 300px);
height: 100vh;
min-height: 0;
}
/* --- panels --------------------------------------------------------------- */
.panel {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--panel);
border-right: 2px solid var(--border);
overflow: hidden;
}
.panel--right {
border-right: none;
border-left: 2px solid var(--border);
}
.panel__header {
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
}
.panel__header h1,
.panel__header h2 {
margin: 0;
font-size: 0.95rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-bright);
}
.panel__sub {
margin: 0.35rem 0 0;
font-size: 0.7rem;
color: var(--ink-dim);
letter-spacing: 0.06em;
}
.panel--left {
overflow-y: auto;
overflow-x: hidden;
}
.section {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-soft);
}
.section h2 {
margin: 0 0 0.65rem;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.section--actions {
padding-bottom: 1.25rem;
}
.hint {
margin: 0 0 0.65rem;
font-size: 0.68rem;
line-height: 1.4;
color: var(--ink-dim);
letter-spacing: 0.03em;
}
.tex-previews {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
margin-top: 0.65rem;
}
.tex-card {
margin: 0;
padding: 0.35rem;
background: var(--panel-elevated);
border: 1px solid var(--border-soft);
}
.tex-card figcaption {
margin-bottom: 0.3rem;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-dim);
}
.tex-card img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: contain;
background: #111;
image-rendering: pixelated;
}
.tex-card a {
display: block;
text-decoration: none;
}
.btn:disabled {
opacity: 0.45;
cursor: wait;
}
/* --- form controls -------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.65rem;
font-size: 0.75rem;
color: var(--ink-dim);
}
.field span em {
font-style: normal;
color: var(--ink-bright);
float: right;
}
.body-style {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0 0 0.75rem;
padding: 0.55rem 0.6rem 0.45rem;
background: var(--panel-elevated);
border: 1px solid var(--border-soft);
}
.field--slider {
margin-bottom: 0.4rem;
}
.slider-ends {
display: flex;
justify-content: space-between;
margin: 0 0 0.1rem;
font-size: 0.6rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-dim);
opacity: 0.75;
}
.slider-ends i {
font-style: normal;
}
.btn--ghost {
background: transparent;
border: 1px solid var(--border-soft);
color: var(--ink-dim);
}
.btn--ghost:hover {
border-color: var(--border);
color: var(--ink);
}
.btn--small {
align-self: flex-start;
padding: 0.25rem 0.55rem;
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.field.grow {
flex: 1;
min-width: 0;
}
.row {
display: flex;
gap: 0.5rem;
}
input[type="text"],
select {
width: 100%;
padding: 0.4rem 0.5rem;
background: var(--panel-elevated);
border: 1px solid var(--border);
color: var(--ink-bright);
font: inherit;
font-size: 0.8rem;
outline: none;
}
input[type="text"]:focus,
select:focus {
border-color: var(--accent);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.seg__btn {
padding: 0.45rem 0.5rem;
background: var(--panel-elevated);
border: 1px solid var(--border);
color: var(--ink-dim);
font: inherit;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.seg__btn:hover {
color: var(--ink-bright);
border-color: var(--ink-dim);
}
.seg__btn.is-active {
color: var(--bg);
background: var(--accent);
border-color: var(--accent);
}
.part-slot {
margin-bottom: 0.75rem;
}
.part-slot__label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-dim);
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.chip {
padding: 0.28rem 0.45rem;
background: var(--panel-elevated);
border: 1px solid var(--border-soft);
color: var(--ink);
font: inherit;
font-size: 0.68rem;
letter-spacing: 0.04em;
cursor: pointer;
}
.chip:hover {
border-color: var(--border);
color: var(--ink-bright);
}
.chip.is-active {
border-color: var(--accent);
color: var(--accent);
background: rgba(201, 162, 39, 0.1);
}
.chip:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.palette {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.color-field {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.7rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.color-field input[type="color"] {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
}
.swatches {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.swatch {
width: 1.55rem;
height: 1.55rem;
border: 2px solid var(--border-soft);
cursor: pointer;
padding: 0;
}
.swatch.is-active {
border-color: var(--accent);
outline: 1px solid var(--accent);
}
/* --- actions -------------------------------------------------------------- */
.actions {
display: grid;
gap: 0.4rem;
}
.btn {
padding: 0.5rem 0.65rem;
background: var(--panel-elevated);
border: 1px solid var(--border);
color: var(--ink-bright);
font: inherit;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.btn:hover {
border-color: var(--ink-dim);
}
.btn:active {
transform: translateY(1px);
}
.btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
font-weight: bold;
}
.btn--primary:hover {
background: #d4b03a;
border-color: #d4b03a;
}
.btn--accent {
border-color: var(--accent-dim);
color: var(--accent);
}
.status {
min-height: 1.2rem;
margin: 0.55rem 0 0;
font-size: 0.68rem;
color: var(--ink-dim);
letter-spacing: 0.04em;
}
.status.is-ok {
color: var(--ok);
}
.status.is-err {
color: var(--danger);
}
/* --- stage ---------------------------------------------------------------- */
.stage {
position: relative;
min-width: 0;
min-height: 0;
/* Fallback while WebGL boots — matches outdoor fog palette. */
background: linear-gradient(180deg, #6a7a72 0%, #a8a890 42%, #3d4a32 100%);
}
#viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.stage__hud {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
padding: 0.85rem 1rem;
pointer-events: none;
background: linear-gradient(transparent, rgba(5, 6, 10, 0.85));
}
.stage__stats {
font-size: 0.7rem;
color: var(--ink-dim);
letter-spacing: 0.06em;
white-space: pre-line;
line-height: 1.45;
}
.stage__controls {
display: flex;
gap: 0.85rem;
align-items: center;
pointer-events: auto;
}
.inline {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
color: var(--ink-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inline select {
width: auto;
min-width: 7rem;
}
.checkbox input {
accent-color: var(--accent);
}
/* --- recipe json ---------------------------------------------------------- */
.panel--right .panel__header {
flex-shrink: 0;
}
.code {
flex: 1;
margin: 0;
padding: 0.85rem 1rem;
overflow: auto;
font-size: 0.68rem;
line-height: 1.45;
color: var(--ink);
white-space: pre-wrap;
word-break: break-word;
}
/* --- responsive ----------------------------------------------------------- */
@media (max-width: 1100px) {
#app {
grid-template-columns: minmax(260px, 300px) 1fr;
}
.panel--right {
display: none;
}
}
@media (max-width: 760px) {
#app {
grid-template-columns: 1fr;
grid-template-rows: 45vh 1fr;
}
.panel--left {
order: 2;
border-right: none;
border-top: 2px solid var(--border);
}
.stage {
order: 1;
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@creator/*": ["../src/*"]
}
},
"include": ["src/**/*.ts", "vite.config.ts"]
}
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { IncomingMessage, ServerResponse } from "node:http";
import { defineConfig, type Plugin } from "vite";
const root = fileURLToPath(new URL(".", import.meta.url));
const toolsSrc = resolve(root, "../src");
const toolsDir = resolve(root, "..");
const workspaceRoot = resolve(root, "../..");
const assetsDir = resolve(workspaceRoot, "assets/characters");
const textureCli = resolve(toolsSrc, "texture/cli.ts");
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolveBody, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolveBody(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(body));
}
function runTextureCli(payload: unknown): Promise<Record<string, unknown>> {
return new Promise((resolvePromise, reject) => {
const child = spawn(
process.execPath,
["--import", "tsx", textureCli],
{
cwd: toolsDir,
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
child.on("error", reject);
child.on("close", (code) => {
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "";
try {
const json = JSON.parse(line) as Record<string, unknown>;
if (code !== 0 && !json["ok"]) {
reject(new Error(String(json["error"] ?? (stderr || `texture cli exited ${code}`))));
return;
}
resolvePromise(json);
} catch {
reject(new Error(stderr || stdout || `texture cli exited ${code}`));
}
});
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
});
}
/** Dev-only APIs: save GLB, UV guide / Grok texture, serve character assets. */
function creatorApiPlugin(): Plugin {
return {
name: "psx-creator-api",
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
// GET /assets-characters/<file>
if (req.method === "GET" && req.url?.startsWith("/assets-characters/")) {
const name = decodeURIComponent(req.url.slice("/assets-characters/".length).split("?")[0] ?? "");
if (!name || name.includes("..") || name.includes("/")) {
res.statusCode = 400;
res.end("bad path");
return;
}
try {
const bytes = await readFile(resolve(assetsDir, name));
const ext = extname(name).toLowerCase();
const mime =
ext === ".png"
? "image/png"
: ext === ".jpg" || ext === ".jpeg"
? "image/jpeg"
: ext === ".glb"
? "model/gltf-binary"
: "application/octet-stream";
res.statusCode = 200;
res.setHeader("Content-Type", mime);
res.setHeader("Cache-Control", "no-store");
res.end(bytes);
} catch {
res.statusCode = 404;
res.end("not found");
}
return;
}
if (req.method !== "POST") {
next();
return;
}
try {
if (req.url === "/api/save") {
const raw = await readBody(req);
const payload = JSON.parse(raw.toString("utf8")) as {
id?: string;
recipe?: unknown;
glbBase64?: string;
};
const id = payload.id?.replace(/[^a-z0-9_-]/gi, "") ?? "";
if (!id || !payload.recipe || !payload.glbBase64) {
sendJson(res, 400, { error: "Expected { id, recipe, glbBase64 }" });
return;
}
await mkdir(assetsDir, { recursive: true });
const glbPath = resolve(assetsDir, `${id}.glb`);
const recipePath = resolve(assetsDir, `${id}.recipe.json`);
const glb = Buffer.from(payload.glbBase64, "base64");
await writeFile(glbPath, glb);
await writeFile(recipePath, `${JSON.stringify(payload.recipe, null, 2)}\n`);
sendJson(res, 200, { ok: true, glb: glbPath, recipe: recipePath, bytes: glb.byteLength });
return;
}
if (req.url === "/api/texture") {
const raw = await readBody(req);
const payload = JSON.parse(raw.toString("utf8")) as {
recipe?: unknown;
mode?: "guide" | "texture";
force?: boolean;
albedoBase64?: string;
};
if (!payload.recipe) {
sendJson(res, 400, { error: "Expected { recipe, mode }" });
return;
}
const result = await runTextureCli({
recipe: payload.recipe,
mode: payload.mode === "texture" ? "texture" : "guide",
force: payload.force === true,
...(payload.albedoBase64 ? { albedoBase64: payload.albedoBase64 } : {}),
});
sendJson(res, result["ok"] ? 200 : 500, result);
return;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendJson(res, 500, { error: message });
return;
}
next();
});
},
};
}
export default defineConfig({
root,
plugins: [creatorApiPlugin()],
resolve: {
alias: {
"@creator": toolsSrc,
},
},
server: {
port: 5174,
open: false,
fs: {
allow: [workspaceRoot, dirname(toolsSrc)],
},
},
build: {
outDir: resolve(root, "dist"),
emptyOutDir: true,
target: "es2022",
},
optimizeDeps: {
include: [
"three",
"three/examples/jsm/loaders/GLTFLoader.js",
"three/examples/jsm/controls/OrbitControls.js",
],
},
});