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:
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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.78–0.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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, 4–6
|
||||
* 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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 2–3 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 4–8 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`
|
||||
* (4–6) 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 (0–1).
|
||||
* 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. 6–8 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.85–1.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 };
|
||||
}
|
||||
@@ -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 Hill–style low-poly humanoid from measured proportions.
|
||||
*
|
||||
* Design targets (SH1 / early RE, not Roblox):
|
||||
* - ~600–1200 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;
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user