diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 40d6a7e..e4ea0f9 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,56 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification + +*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`, +`band.js`. Verified against C's landed `stage.backline[]` + E's `sit.glb`; F's flip/gate pending (I built to +F's published seated-stature contract). **Byte-identical proof holds** (the load-bearing requirement).* + +### The drummer sits (ledger #2) — and Fable's prescribed `_rotWithHips` is WRONG for these rigs +The R14 recon + F's brief prescribed a `_rotWithHips` variant (keep `Hips.quaternion` to preserve the +pelvic tilt). **In-shell it MANGLES the pose** — head folds to hip height, body laid flat — because the +fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure `_rotOnly`'s own +comment documents ("a different-orientation source lays it flat"). So the sit clip **drops `Hips.quaternion` +like every other clip** (rides the existing `_rotOnly`); the seated read comes from the leg-bone bends + +a new **seated foot-replant** in `spawnRig`. `_rotWithHips` is removed. +- **`spawnRig({seated})`** (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED + lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns + skip it entirely. +- **The drummer**: uses `fleet.sitClip` when present (retiring the R14 `SEAT_DROP` sink — it sits on the + riser and the re-plant seats it), tags `fig.userData.procitySeated = true` (F's gate). Reads seated from + the **crowd POV** (the kit hides the lower body); measured stature **~1.1–1.3 m** (F's seated gate + [0.9,1.5] ✓). Falls back to the `SEAT_DROP` standing-sunk hack if `sitClip` is absent (?noassets, or + before F wires `{sit}`), so it never breaks a boot. +- **Honest limit**: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a + bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual + pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on + the deck, passes the seated gate) — good enough for the flip. + +### Byte-identical proof (keepers / browsers / walkers) — the load-bearing part +The changes are a PARALLEL/opt-in path: `_rotOnly` untouched (walk/idle unchanged), `spawnRig`'s re-plant +is `seated`-gated (default off), `fleet.sitClip` is additive, `makeActor` (walkers) untouched. Verified +in-shell with `fleet.sitClip` **null AND loaded**: streamed walkers **188/188** face travel, keeper is a +standing rig (stature **1.718**, `innerRotY:π`, `replanted:false` — the seated re-plant does NOT run). No +existing consumer moves. + +### → Lane F: the `{sit}` load seam (classic-safe by construction) +`loadPedFleet(base, { sit })` — pass **`sit: `** (true on the default boot, **false under +`?classic=1`**). Gated because a raw `sit.glb` fetch would break the classic zero-fetch-delta covenant; +default is `sit:false`, and absent/off → `fleet.sitClip` null → the drummer falls back gracefully. My code +is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes `sit:true` +(same commit as F's [0.9,1.5] seated-stature gate). + +### Amp at C's `stage.backline[]` (ledger #5) +`_addBackline` now plants at **C's `stage.backline[0]`** (`{x,z,ry,y}`, up-stage, clear of every bandPose ++ watchPoint) instead of D's R15 hardcoded pose — C confirmed `backline[0]` == that pose, so nothing moves +on screen (pub 1.44, 0.32, −3.27, ry=π; **0.75 m bass clearance**). Since C dropped its primitive +ampStacks, D now owns the whole backline: **placeholder-persists** (a primitive amp shows immediately, the +`guitar_amp` GLB swaps in), and the primitive is the **`?noassets` fallback** (fleet-gated, no fetch). +Verified: GLB swaps in, primitive under `?noassets`, no crash, 0 fetch. + +--- + ## ROUND 15 — v3.0 close: the amp one-liner (ledger #4) *PROCITY-D, 2026-07-16. Sole task: move `band.js _addBackline` to C's §3 pose. Everything else (continuity, diff --git a/web/js/citizens/band.js b/web/js/citizens/band.js index eec55ea..90cda30 100644 --- a/web/js/citizens/band.js +++ b/web/js/citizens/band.js @@ -59,6 +59,17 @@ function micStandPrim() { g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z) return tagOwn(g); } +// R16: the backline amp fallback (C dropped its primitive ampStacks; this stands in when the guitar_amp +// GLB is absent / ?noassets). A room prop (not fig-local): base at y=0, grille on the +Z (crowd) face. +function ampPrim() { + const g = new THREE.Group(); + const cab = new THREE.MeshStandardMaterial({ color: AMP, roughness: 0.7, metalness: 0.05 }); + const cloth = new THREE.MeshStandardMaterial({ color: AMPCLOTH, roughness: 0.9 }); + const box = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.29), cab); box.position.y = 0.3; + const grille = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.46, 0.02), cloth); grille.position.set(0, 0.3, 0.15); + g.add(box, grille); + return tagOwn(g); +} // a cheap kit: kick drum + snare + a floor tom + two cymbals on stands. Reads as "drum kit" behind the // seated drummer; the GLB replaces it when present. Sits on the riser (y = SEAT_DROP compensates the sink). function drumKitPrim() { @@ -125,15 +136,18 @@ export class GigCrew { } // one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. - _make(target, key, { x, y, z, ry, height, pedIndex = null }) { + // `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the + // fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip. + _make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) { const r = rng(this.citySeed, 'gig', key); const h = height != null ? height : 1.6 + r() * 0.3; let actor, kind, usedIndex = null; if (this.fleet && this.fleet.ready) { const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); const rig = pk && this.fleet.all[pk.index]; - const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() }); - if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; } // usedIndex: the fleet ped shown — F's continuity smoke reads it + const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded + const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : this.fleet.idleClip, phase: r(), seated: useSit }); + if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; } } if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; } actor.fig.position.set(x, y, z); @@ -158,9 +172,12 @@ export class GigCrew { stage.bandPoses.forEach((p, i) => { const seated = p.role === 'drums' || !!p.seated; const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser) - const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs + // R16: a real sit clip seats the rig on the riser (spawnRig re-plants the feet) → no sink; only the + // no-clip fallback still sinks SEAT_DROP so the kit hides the standing legs. + const seatedRig = seated && !!(this.fleet && this.fleet.sitClip); + const y = seated ? poseY - (seatedRig ? 0 : SEAT_DROP) : poseY; const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`, - { x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null, + { x: p.x, y, z: p.z, ry: p.ry, seated, pedIndex: distinct.length ? distinct[i % distinct.length] : null, height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 }); const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated, base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] }; @@ -218,24 +235,31 @@ export class GigCrew { }); } - // A guitar amp as backline dressing, planted on the deck just off the riser (the classic pub backline). - // Dressing, not an instrument — planted in the room group (this.props), GLB-only (skipped under ?noassets). + // The backline amp, planted at C's up-stage `stage.backline[0]` (R16 ledger #5 — C dropped its primitive + // ampStacks; D owns the whole backline now). Placeholder-persists: a primitive amp shows immediately (the + // ?noassets fallback too), then E's guitar_amp GLB swaps in when it resolves. Falls back to the R15 + // hardcoded up-stage-right pose if C's backline isn't present. Room-local coords; grille to the crowd. _addBackline(roomGroup, stage, gen) { - if (!this.fleet || !this.fleet.ready) return; + const slot = (stage.backline && stage.backline[0]) || + { x: stage.x + (stage.w || 3) * 0.36, z: stage.z - (stage.d || 3) * 0.22, ry: 0 }; + const place = (obj) => { + obj.position.set(slot.x, stage.deckY || 0, slot.z); + if (slot.ry != null) obj.rotation.y = slot.ry; + roomGroup.add(obj); this.props.push(obj); return obj; + }; + const prim = place(ampPrim()); // primitive amp immediately (asset-free backline) + if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch loadManifest().then((mani) => { if (!mani || !mani.fittings || this._gen !== gen) return; const entry = mani.fittings.guitar_amp; if (!entry || !entry.file) return; return loadGLB(`depot:${entry.file}`).then((gltf) => { - if (!gltf || this._gen !== gen) return; + if (!gltf || this._gen !== gen || !prim.parent) return; // room rebuilt / disposed mid-flight const amp = gltf.scene.clone(true); amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } }); - // R15 (C's §3 ruling): up-stage-right behind the front line — clears the bass rig and offsets from - // C's primitive ampStacks (at ±w·0.28, z−d·0.18), so GLB + primitive read as a flanking backline. - // The old z+0.15 was down-stage and interpenetrated the bassist. Room-local coords; grille to the crowd. - amp.position.set(stage.x + (stage.w || 3) * 0.36, (stage.deckY || 0), stage.z - (stage.d || 3) * 0.22); - roomGroup.add(amp); - this.props.push(amp); + place(amp); // GLB swaps in over the primitive + const i = this.props.indexOf(prim); if (i >= 0) this.props.splice(i, 1); + disposePrim(prim); // free the primitive's own geo/mats }); }); } @@ -277,7 +301,7 @@ export class GigCrew { for (const ex of m.extra) disposePrim(ex); // GLB clones (shared geo/mats) are skipped inside m.actor.dispose && m.actor.dispose(); } - for (const p of this.props) { if (p.parent) p.parent.remove(p); } // GLB dressing — cached geo/mats, detach only + for (const p of this.props) disposePrim(p); // GLB dressing → detach only; the ?noassets primitive amp → freed (ownGeo) this.members.length = 0; this.props.length = 0; this._gen++; // invalidate any GLB load still in flight diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index 56a1c45..cb624aa 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -36,6 +36,12 @@ function canonRig(r) { // 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))); +// 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) { @@ -45,10 +51,14 @@ function loadRig(ref) { // 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. -export function loadPedFleet(base = 'models/peds/') { +// `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. +export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) { const fleet = { normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas - walkClip: null, idleClip: null, + walkClip: null, idleClip: null, sitClip: null, ready: false, whenReady: null, }; // DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array @@ -69,6 +79,9 @@ export function loadPedFleet(base = 'models/peds/') { 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); } })); fleet.whenReady = Promise.all(jobs).then(() => { fleet.normal = nSlots.filter(Boolean); fleet.comical = cSlots.filter(Boolean); @@ -169,14 +182,28 @@ function _disposeInner(inner) { } // ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ---- -export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0 } = {}) { +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); - if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); } + 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; + } + } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } return { fig, inner, mixer, height, head, dispose }; }