PROCITY/web/js/citizens/rigs.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

554 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane D — the rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405500
// (loadRig / spawnRig / _canon / canonRig / _rotOnly / head-bone height-normalize / the shared
// walk+idle clip driving all 19 peds). This stack is proven; the changes for PROCITY are:
// • loaders come from core/loaders.js (promise-cached, fail-soft) instead of an inline one
// • randomness is injected (a seeded stream) — no Math.random() in identity/placement
// • the figure is wrapped in an outer Group whose origin sits at the feet (ground = local y=0),
// so the sim can move/rotate a walker without re-planting it every frame.
//
// House law: one canonical clip bank drives every character (shared skeleton namespace), never a
// per-character rig. SkeletonUtils.clone gives each spawn its own skeleton off a shared base mesh.
import * as THREE from 'three';
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
import { loadGLB } from '../core/loaders.js';
// The 19 rigged peds (17 normal + 2 in the curveball pool) copied byte-identical from 90sDJsim into
// web/models/peds/.
// [R36 wave 2.5 — Fable ruling on F's §36.4 hold] `normal` is the CLASSIC-COVENANTED base 17 (the exact
// pre-July-20 list, order preserved — pickRig maps r01 through pool.length, so the length IS the identity).
// The 5 trellis'd DJ bodies (b339402, fbx_to_ped, 4k tris) live in `djs` and join the roster only when
// loadPedFleet's `djs` opt is on — they are APPENDED, so the default-boot pool is byte-identical to the
// pre-ruling 22-list and `?classic=1` is byte-identical to pre-July-20 (fetch surface AND crowd identity).
//
// [R42 §42.4 — THE RECAST] Ruling 1 (PROCITY is 1990s Australia) retired five bodies. Lane E's §42.3
// audit rendered all 24 and named them: a purple `EL CHUPACABRA` luchador, a ~3-heads-tall cartoon
// child, a robed wuxia/fantasy elder, a WW2 infantryman, and `dj_phrtt_01` — whose single 1024² atlas
// is bare skin end to end (there is NO garment on the asset) and which the `djs = dance` default put in
// every non-`?classic` crowd. All five are gone from this table; their replacements come from the same
// Mixamo Ch## family the roster already is, verified byte-for-byte against the contract (67 nodes /
// 65 joints / 1 prim / 1 mtl / 1 image 1024² WebP / no Draco / 0 baked anims, bone-name set identical
// after `_canon`, 3 456 of 3 460 bindable tracks over all 54 shipped clips — the reference's own score).
//
// THE SWAP IS IN PLACE, AT THE SAME INDEX, AND THAT IS THE WHOLE DESIGN. `pickRig` indexes these arrays
// and `fleet.all` (= normal ++ comical) is the impostor atlas's subject order and every citizen's stored
// `pedIndex`, so DELETING a name would shorten a pool, renumber every slot after it, and move every
// seeded identity in the town — the R2 fleet-order bug arriving by a different route. Replacing at the
// index leaves all three pool LENGTHS (17 / 5 / 2), every `pickRig` mapping and every `pedIndex` exactly
// as they were: the crowd changes appearance and nothing changes about which slot each citizen holds.
// Measured, not asserted: the R41 identity signature over 150 active citizens is byte-identical across
// this swap (tools/qa/r42_cast.py, arm 3).
//
// `comical` is now a misnomer kept on purpose: both members are ordinary people (E's ruling-1 call —
// the curveball pool stops being a curveball), but the KEY is load-bearing for `fleet.comical`,
// `pickRig`'s 8% branch and two QA gates, so the name stays and the meaning is documented here.
export const PED_NAMES = {
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
'man_grunge_01', 'man_casual_04', 'man_athletic_01', 'man_bouncer_01', 'person_casual_01',
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
// ^6 was man_elder_01 ^9 was man_soldier_ww2_01
djs: ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'man_smart_01'], // gated: dance-scene bodies, NOT in classic's pool. [4] was dj_phrtt_01 (unclothed — R42 §42.4)
comical: ['woman_smart_02', 'person_youth_01'], // was comical_luchador_01, comical_boy_01
};
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig');
// [R41 §41.3] exported so clipbank.js runs Lane E's 46 library clips through the IDENTICAL two-step
// (`_canon` then `_rotOnly`) that walk/idle/sit/look/dance have ridden since R2 — one code path, so a
// library clip cannot diverge from a base clip in how it binds. E measured 65 raw tracks → 64 after
// the filter on all 46; clipbank.js re-asserts non-empty per clip at load.
export const canonName = _canon;
function canonRig(r) {
if (!r) return r;
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach(a => a.tracks.forEach(t => { t.name = _canon(t.name); }));
return r;
}
// shared-clip filter: keep limb/spine rotations only — drop ALL position tracks (a different-scale
// source inflates/crumples the target) AND Hips.quaternion (a different-orientation source lays it
// flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
export const rotOnlyClip = _rotOnly; // [R41 §41.3] same filter for the library clips — see canonName
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
// (head folded to hip height — verified in-shell), exactly the failure the comment above documents for
// walk/idle. So the sit clip drops Hips.quaternion like every other clip; the seated read comes from the
// leg-bone bends + spawnRig's seated foot-replant. Reads seated from the crowd POV (the kit hides the legs).
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
function loadRig(ref) {
return loadGLB(ref).then(g => (g ? { scene: g.scene, anims: g.animations || [] } : null));
}
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
// `opts.sit` (R16): also load sit.glb → fleet.sitClip (the seated-drummer pose). GATED because a raw
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
// `opts.djs` (R36 w2.5): admit the 5 trellis'd DJ bodies into the normal roster. DEFAULTS TO `dance` —
// the shell already passes `dance: !CLASSIC`, so the classic-ness signal arrives without a shell edit and
// the bodies ride the same `!CLASSIC` gate as the sit/look/dance clips: `?classic=1` fetches none of the
// five and keeps the covenanted 17-pool; every other boot gets all 22. Separable for callers that want
// dance clips without the bodies (or vice versa).
// `opts.clips` (R41 §41.3): Lane E's 46-clip MOTION LIBRARY (web/models/clips/*.glb +
// web/assets/motion_manifest.json). DEFAULTS TO `dance` for exactly the reason `djs` does — the shell
// already passes `dance: !CLASSIC` (index.html:232), so the classic-ness signal arrives with ZERO
// shell edits and the library rides the same gate as the sit/look/dance clips. `?classic=1` ⇒ no
// manifest fetch, no clip-GLB fetch, and (because the module is reached by dynamic `import()`) not
// even a clipbank.js fetch: the zero-fetch-delta covenant is untouched. `?noassets=1` never calls
// loadPedFleet at all. On, the bank fetches THREE things at boot — the 16 KB manifest and the two
// eager groups (postures.js BOOT_GROUPS: idles.glb 1 029 496 B + locomotion.glb 212 776 B) — and
// everything else lazily on first demand. `fleet.ready` deliberately does NOT depend on the bank: a
// dead manifest or a 404 group leaves the R2 8-clip fleet running exactly as it does today.
// `?clips=0` is the CONTROL ARM, and it exists because `?classic=1` is a bad control for this: it
// changes the ped pool, the sit/look/dance clips, the fog, the game and half the shell at once, so a
// draw or heap delta measured against it says nothing about the motion library specifically. This
// flag turns off the library and NOTHING else. Read the same way furniture.js reads ?noassets.
const CLIPS_OFF = (() => { try { return new URLSearchParams(location.search).get('clips') === '0'; } catch { return false; } })();
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance,
clips = dance, clipBase = null, manifestUrl = null, clipGroups = null } = {}) {
if (CLIPS_OFF) clips = false;
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
bank: null, // R41 ClipBank | null (null ⇒ every consumer takes its base clip)
ready: false, whenReady: null,
};
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
// order (and thus every pickRig index + identity signature) depends on GLB load-completion timing,
// which varies per run/machine and silently breaks "same seed → same crowd". Compact after all
// settle (filter(Boolean) preserves the fixed relative order, so a missing file is deterministic too).
// R36 w2.5: the DJ bodies extend the roster AFTER the base 17, so gating them off shortens the pool
// without renumbering a single base slot — classic's pickRig mapping is the pre-July-20 one exactly.
const normalNames = djs ? [...PED_NAMES.normal, ...PED_NAMES.djs] : PED_NAMES.normal;
const nSlots = new Array(normalNames.length).fill(null);
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
const jobs = [];
normalNames.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; nSlots[i] = canonRig(r); } })));
PED_NAMES.comical.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; cSlots[i] = canonRig(r); } })));
// shared clips: canonicalise track names, keep rotation only
jobs.push(loadRig(`${base}walk.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.walkClip = _rotOnly(c); } }));
jobs.push(loadRig(`${base}idle.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
// [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes
// `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is
// correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to
// recover and no foot re-plant needed (that trap was sit's alone — R16).
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } }));
// dance clips (mesh-free mixamorig): the club dancers play a REAL move instead of procedural sway.
// GATED like sit/look — ?classic fetches nothing so the zero-fetch-delta covenant holds. The per-dancer
// pick is deterministic (a separate seeded stream in _make), so 'same seed -> same crowd' is preserved.
const DANCE_CLIPS = ['dance_party', 'dance_medium', 'dance_drink', 'dance_sway'];
const danceSlots = new Array(DANCE_CLIPS.length).fill(null);
if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } })));
// [R41 §41.3] the motion library. Dynamic import ⇒ clipbank.js is not even fetched when the gate is
// off. Fail-soft twice over: the import and the boot both swallow, so `fleet.bank` stays null and
// every downstream `bank && bank.get(...)` falls straight through to the base clip.
// Published SYNCHRONOUSLY, before the import even starts: consumers that must make a STREAM-
// AFFECTING decision (the sim's bench/lean/pause rolls) need a boot-stable answer to "is the
// library on for this boot", not "has it landed yet" — otherwise the first second of a boot would
// draw a different number of randoms than the rest of it, and 'same seed → same crowd' would
// depend on network timing. `fleet.bank` stays the RESIDENCY question; this is the GATE question.
fleet.clipsRequested = !!clips;
if (clips) jobs.push(
Promise.all([import('./clipbank.js'), import('./postures.js')]).then(([cb, po]) => {
const bank = new cb.ClipBank({
clipBase: clipBase || base.replace(/peds\/?$/, 'clips/'),
manifestUrl: manifestUrl || base.replace(/models\/peds\/?$/, 'assets/motion_manifest.json'),
});
fleet.bank = bank; // published immediately: lazy groups can be
return bank.boot(clipGroups || po.BOOT_GROUPS); // kicked before the boot groups land
}).catch((e) => { console.warn('[rigs] motion library unavailable, base clips stay:', e && e.message); }));
fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean);
fleet.all = [...fleet.normal, ...fleet.comical];
fleet.danceClips = danceSlots.filter(Boolean);
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
return fleet;
});
return fleet;
}
// deterministic ped choice: ~8% comical curveball, else a normal, indexed by a seeded stream.
// Returns { rig, index } — index is into fleet.all (stable per ped type → impostor atlas cell).
export function pickRig(fleet, r01) {
const comical = r01 < 0.08 && fleet.comical.length ? fleet.comical : null;
const pool = comical || fleet.normal;
if (!pool.length) return null;
const rig = pool[(r01 * 997 % pool.length) | 0];
const index = fleet.all.indexOf(rig);
return { rig, index: index < 0 ? 0 : index };
}
// ---- buildFigure: clone a rig, height-normalise off the head bone, plant feet ----
// Returns { fig, inner, head }. `fig` is an outer Group whose origin is at the feet (ground = y=0)
// — set fig.position to the ground point and fig.rotation.y to the heading. NOTE materials are
// shared across SkeletonUtils clones (cheap); don't mutate them per-instance.
const _wp = new THREE.Vector3();
const _bb = new THREE.Box3();
const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
// FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z,
// but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry
// (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on
// purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was
// the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked
// (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own
// mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct,
// near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a
// Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and
// all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract.
inner.rotation.y = Math.PI;
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise so the crown sits at `height` above the feet, then plant feet.
// NB the Mixamo skeleton's ORIGIN is the hips (~mid-body), so the head bone's world Y in bind pose
// is head-above-hips (~half the standing height), NOT the standing height. Normalising by that alone
// makes every figure ~2× too tall (the R9 interior-giant blocker). Normalise by the feet→crown SPAN
// (top head bone minus lowest bone) so a requested 1.75 m rig is 1.75 m from sole to crown.
let headY = 0, minY = Infinity;
inner.traverse(o => {
if (o.isBone) {
o.getWorldPosition(_wp);
if (/head/i.test(o.name)) headY = Math.max(headY, _wp.y); // crown (HeadTop_End is highest)
minY = Math.min(minY, _wp.y); // sole (lowest foot/toe bone)
}
});
const span = headY - minY; // feet→crown standing height in the rig's native units
if (headY > 1e-4 && span > 1e-4) {
inner.scale.setScalar(height / span);
} else {
// no head bone found — fall back to bounding-box height so we never ship a giant/ant
_bb.setFromObject(inner); _bb.getSize(_bs);
if (_bs.y > 1e-4) inner.scale.setScalar(height / _bs.y);
}
fig.updateWorldMatrix(true, true);
minY = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); minY = Math.min(minY, _wp.y); } });
if (minY === Infinity) { _bb.setFromObject(inner); minY = _bb.min.y; }
inner.position.y = -minY; // feet at outer y=0
let head = null;
inner.traverse(o => { if (!head && o.isBone && /head/i.test(o.name)) head = o; });
return { fig, inner, head, nominalHeight: height };
}
// bind a clip to an inner figure, keeping only tracks whose target bones exist (else three spams
// "No target node found" per missing bone). Returns the AnimationAction (not yet played).
function _action(mixer, inner, clip) {
if (!clip) return null;
const nodes = new Set();
inner.traverse(o => { if (o.name) nodes.add(o.name); });
const bindable = clip.tracks.filter(t => nodes.has(t.name.split('.')[0]));
const useClip = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
return mixer.clipAction(useClip);
}
// Dispose only what a SkeletonUtils clone actually OWNS: its Skeleton's per-instance bone texture.
// Geometry + materials are SHARED by reference across every clone and the fleet base rig — disposing
// them here would tear down GPU resources still in use by all sibling citizens of the same ped type.
function _disposeInner(inner) {
const skels = new Set();
inner.traverse(o => { if (o.isSkinnedMesh && o.skeleton) skels.add(o.skeleton); });
skels.forEach(s => s.dispose?.());
}
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, seated = false } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head } = buildFigure(rig, height);
fig.rotation.y = ry;
const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen);
const bindPlantY = inner.position.y; // R41: the STANDING feet-plant, to restore before any re-plant
let curAct = act;
let seatBone = null;
if (act) {
act.time = phase * (act.getClip().duration || 0); act.play();
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
// buildFigure planted the BIND pose; a sit clip lowers the hips + swings the legs, so re-plant the POSED
// feet to y=0: tick the mixer once to bake the pose, find the lowest bone, drop the figure so the soles
// return to the deck. One-time (a drummer's feet don't travel); this recovers the hip descent that
// _rotWithHips leaves out by dropping the (scale-unsafe) Hips.position track.
if (seated) {
mixer.update(0);
inner.updateWorldMatrix(true, true);
let m = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
if (isFinite(m)) inner.position.y -= m;
// R17 pelvic-lean: cache the mid-spine bone so seatedLean() can tilt the torso forward each frame (the
// sit clip can't — Hips.quaternion is unusable on these rigs). Seated-only; standing rigs get no seatBone.
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
}
}
// [R41 §41.3] setClip — swap this single-clip figure onto a motion-library clip AFTER it was built.
// This is the self-healing seam: a keeper / browser / gig member is posed with whatever clip exists
// at spawn (the R2 idle in the worst case) and upgrades in place the frame its lazily-fetched group
// lands, so WHICH clip it was assigned never depends on load timing — only how soon it shows.
//
// The re-plant is the R29 lesson generalised. `_rotOnly` drops the Hips POSITION track (it must —
// a foreign-scale source would inflate the rig), so a clip's authored vertical motion comes out as
// the FEET moving instead of the hips: measured up to +0.205 m on look.glb. spawnRig's original
// seated re-plant samples ONCE at t=0, which is right for a fixed pose and wrong for a clip whose
// lowest bone travels. So this samples the posed skeleton at PLANT_SAMPLES points across the clip
// and plants the MINIMUM — the soles can then never sink through the floor, and the residual float
// is the clip's own authored range, not an arbitrary phase's error. Absolute (reset → measure →
// correct), never incremental, and scale-aware via fig.scale.y.
const PLANT_SAMPLES = 6;
function _plant() {
inner.position.y = bindPlantY;
const clipDur = (curAct && curAct.getClip().duration) || 0;
const t0 = curAct ? curAct.time : 0;
let lo = Infinity;
for (let i = 0; i < PLANT_SAMPLES; i++) {
if (curAct) curAct.time = clipDur * (i / PLANT_SAMPLES);
mixer.update(0);
inner.updateWorldMatrix(true, true);
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); if (_wp.y < lo) lo = _wp.y; } });
if (clipDur <= 0) break;
}
if (curAct) { curAct.time = t0; mixer.update(0); }
if (!isFinite(lo)) return;
const S = fig.scale.y || 1;
inner.position.y -= (lo - fig.position.y) / S;
}
function setClip(next, { seated: st = seated, phase: ph = phase } = {}) {
if (!next) return false;
const na = _action(mixer, inner, next);
if (!na) return false;
if (curAct && curAct !== na) { curAct.stop(); curAct.setEffectiveWeight(0); }
if (next._pcLoop === 'pingpong') na.setLoop(THREE.LoopPingPong, Infinity);
curAct = na;
na.reset().play().setEffectiveWeight(1);
na.time = ph * (next.duration || 0);
_plant();
if (st) {
fig.userData.procitySeated = true;
seatBone = null;
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
}
return true;
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, get seatBone() { return seatBone; }, setClip, dispose };
}
// R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets
// the bone's quaternion from the clip every frame, so this is a CONSTANT offset (not an accumulation). Only
// seated figs (drummer, bench-sit loiterers) call it; standing rigs have no seatBone → never leaned.
const _LEAN_AXIS = new THREE.Vector3(1, 0, 0);
const _LEAN_Q = new THREE.Quaternion();
export function seatedLean(spine, angle = 0.34) {
if (spine) spine.quaternion.multiply(_LEAN_Q.setFromAxisAngle(_LEAN_AXIS, angle));
}
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
const mixer = new THREE.AnimationMixer(inner);
// [R41 §41.3] one AnimationAction per SOURCE clip, memoised. Pooled actors are recycled across
// citizens of the same ped type, so the per-citizen idle/walk cannot be baked in at construction —
// it is swapped on acquire (≤3/frame, NEW_RIG_PER_FRAME). Memoising by source clip means a pool
// slot that has seen five different idles pays five bindings, not five per acquire; three.js shares
// the PropertyMixer bindings per (root, track) across all of them, so the marginal cost of an extra
// action is its interpolants, not another copy of the skeleton.
const _acts = new Map();
function _act(clip) {
if (!clip) return null;
let a = _acts.get(clip);
if (a === undefined) {
a = _action(mixer, inner, clip);
// library clips whose measured loop seam does not close play ping-pong instead of popping
// (clipbank.js LOOP_SEAM_DEG) — `browse_pick_up` becomes crate-digging rather than a teleport.
if (a && clip._pcLoop === 'pingpong') a.setLoop(THREE.LoopPingPong, Infinity);
_acts.set(clip, a);
}
return a;
}
const baseWalkA = _act(walkClip || rig.anims.find(c => /walk/i.test(c.name)));
const baseIdleA = _act(idleClip || rig.anims.find(c => /idle/i.test(c.name)));
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
const baseSitA = sitClip ? _act(sitClip) : null;
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
const baseLookA = lookClip ? _act(lookClip) : null;
// R41: the four live slots. They START at the base actions, so an actor nobody swaps is the R40
// actor exactly; setIdleClip/setWalkClip/setSitting(_,clip)/setLooking(_,_,clip) re-point them.
let walkA = baseWalkA, idleA = baseIdleA, sitA = baseSitA, lookA = baseLookA;
let seatBone = null;
if (baseSitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
// [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
// exists, so walkers/?classic pay nothing. R41: built lazily too, since a lean clip can arrive later.
let footBones = null;
function _footBones() {
if (footBones !== null) return footBones;
const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); });
footBones = fb.length ? fb : false;
return footBones;
}
if (baseLookA) _footBones();
let sitting = false, looking = false;
let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
if (idleA) { idleA.play(); idleA.setEffectiveWeight(0); }
// walk↔idle: instant on the first apply / fade<=0 (fresh spawn must be posted immediately),
// otherwise a real crossFade transfers weight from the resting action to the target.
function setMoving(m, fade = 0.28) {
if (m === moving) return;
const first = moving === null;
moving = m;
if (!(walkA && idleA)) return;
const to = m ? walkA : idleA, from = m ? idleA : walkA;
to.enabled = from.enabled = true;
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
}
// [R41 §41.3] the per-citizen resting/moving clips. Called on ACQUIRE only (the pool hands a recycled
// actor to a new citizen), before setPhase + setMoving(_, 0) — so `moving = null` here is enough to
// make the next setMoving apply instantly and no crossfade can be left mid-flight. Passing null
// restores the base clip, which is exactly what a citizen whose assigned clip has not landed yet
// (or whose boot has no bank at all) gets ⇒ the R40 actor, byte for byte.
function setIdleClip(clip) {
const a = clip ? (_act(clip) || baseIdleA) : baseIdleA;
if (!a || a === idleA) return;
if (idleA) { idleA.setEffectiveWeight(0); idleA.stop(); }
idleA = a; idleA.enabled = true; idleA.reset().play(); idleA.setEffectiveWeight(0);
moving = null;
}
function setWalkClip(clip) {
const a = clip ? (_act(clip) || baseWalkA) : baseWalkA;
if (!a || a === walkA) return;
if (walkA) { walkA.setEffectiveWeight(0); walkA.stop(); }
walkA = a; walkA.enabled = true; walkA.reset().play(); walkA.setEffectiveWeight(0);
moving = null;
}
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
// R41: `clip` (optional) is a motion-library sit variant (sitlean.glb) for THIS citizen; omitted /
// null ⇒ the R16 sit.glb pose, unchanged. With neither, setSitting stays the no-op it has always
// been under ?classic.
function setSitting(s, clip = null) {
const target = s ? ((clip && _act(clip)) || baseSitA) : null;
if (s && !target) return; // nothing to sit with → inert (classic / no clip)
if (!s && !sitting) return; // already standing
if (s && sitting && target === sitA) return; // already in this exact pose
if (s && sitA && sitA !== target) sitA.stop(); // swapping pose mid-sit
if (s) sitA = target;
sitting = s;
moving = null; // force the next setMoving to re-apply after standing up
if (s) {
if (walkA) walkA.setEffectiveWeight(0);
if (idleA) idleA.setEffectiveWeight(0);
sitA.reset().play().setEffectiveWeight(1);
mixer.update(0); // bake the sit pose before measuring
inner.updateWorldMatrix(true, true);
let w = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); w = Math.min(w, _wp.y); } });
const S = fig.scale.y || 1;
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
fig.userData.procitySeated = true;
} else {
if (sitA) sitA.stop();
sitA = baseSitA; // back to the base pose for the next citizen
inner.position.y = bindPlantY; // restore the standing feet-plant
if (idleA) idleA.setEffectiveWeight(1);
fig.userData.procitySeated = false;
}
}
// [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting:
// it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
// R41: `clip` (optional) is any STANDING posed library clip for this citizen — a lean against a
// shopfront (sitlean.glb's four lean_wall_*) rides the identical seam as R29's glance, because it is
// the identical problem: a standing clip, no hip descent to recover, but a per-frame foot float that
// plantFeet() absorbs. Omitted / null ⇒ look.glb, unchanged.
function setLooking(l, fade = 0.3, clip = null) {
const target = l ? ((clip && _act(clip)) || baseLookA) : null;
if (l && !target) return; // no standing pose available → inert (classic)
if (!l && !looking) return;
if (l && looking && target === lookA) return; // already in this exact pose
if (l && lookA && lookA !== target) lookA.stop(); // swapping pose mid-look
if (l) { lookA = target; _footBones(); }
looking = l;
moving = null; // force the next setMoving to re-apply on the way out
if (l) {
lookA.reset().play();
if (idleA) idleA.crossFadeTo(lookA, fade, false);
else lookA.setEffectiveWeight(1);
if (walkA) walkA.setEffectiveWeight(0);
} else {
if (idleA) { idleA.reset().play(); if (lookA) lookA.crossFadeTo(idleA, fade, false); }
else if (lookA) lookA.setEffectiveWeight(0);
lookA = baseLookA; // back to the base pose for the next citizen
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
}
}
// [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant
// ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height
// independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET
// rising instead: lowest bone ranges 0.009…+0.205 m over the loop vs idle's 0.04…0.00. A 20 cm hover.
// Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin:
// reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset →
// measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion
// exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's.
// Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran.
function plantFeet() {
if (!footBones || !looking) return;
inner.position.y = bindPlantY; // reference pose
inner.updateWorldMatrix(true, true);
let w = Infinity;
for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; }
if (!isFinite(w)) return;
const S = fig.scale.y || 1;
inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
}
// seed the clip phase so the crowd isn't in lockstep
function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
if (sitA) sitA.time = p * (sitA.getClip().duration || 1); // R41: so does a row of bench-sitters
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _acts.clear(); _disposeInner(inner); }
return { fig, inner, mixer, head, get seatBone() { return seatBone; }, nominalHeight: nom,
setMoving, setSitting, setLooking, setIdleClip, setWalkClip, plantFeet, setPhase, dispose,
hasClips: !!(walkA && idleA) };
}