diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 4c1baea..b698b10 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -25,6 +25,61 @@ consumer moves. Seated-only, parallel path — same discipline as R16. --- +## ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch) + +*PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a +window-shop stop. Files `rigs.js` (`makeActor` gains an opt-in `setSitting`), `sim.js` (the seeded +choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.* + +### The finding I had to solve first +The R16 seated re-plant lives in `spawnRig` (drummer path) and is **scale-naive** — it drops `inner` by the +posed lowest-bone height, which only lands at y=0 for **unscaled** rigs. But the street crowd runs on +`makeActor` rigs that the sim scales **per-citizen** (`fig.scale = height/1.75`). So bench-sit is *not* the +one-line "reuse the existing seam" the brief hoped for — the re-plant had to become **scale-aware**. + +### What shipped +- **`makeActor(rig, {…, sitClip})`** gains a third action `sitA`, **played only on `setSitting(true)`** — so + walkers, placeholders, and `?classic` (no `sitClip` → `sitA` null → `setSitting` no-ops) are byte-identical: + the sit action never advances the mixer and the plant never moves. +- **`setSitting(s)`** snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and + does the **scale-aware re-plant**: after baking the pose it measures the posed lowest bone's *world* y and + drops `inner` by `(worldLowestY − figY) / fig.scale.y`, landing the posed soles on the footpath at any + citizen scale. Standing up restores the cached `bindPlantY` exactly. Tags `procitySeated`. +- **`sim.js`**: a **dedicated** rng stream `sitRng = rng(citySeed,'benchsit',id)` (independently keyed → does + **not** shift `turn`/`loit`/`patron`, so the walk/loiter pattern and the identity signature are unchanged). + At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips `c.sit` when a sit + clip is loaded (`?classic` inert at the source). The near-tier update toggles `setSitting(c.sit && loiter>0)`. + `RigPool.release` forces `setSitting(false)` so a pooled rig is never handed out mid-sit. +- No lean: bench-sitters sit **upright** (natural on a verandah step), so the drummer's `seatedLean` is not + applied here — only `setSitting`'s pose + re-plant. No bench-position binding — they sit where they loitered + (the footpath/verandah edge under the awnings). + +### Byte-identical proof (in-shell, default boot, seed 20261990) +Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; `Box3` is bind-pose for skinned +meshes and lies here): + +| metric | standing | seated | +1 s sit-idle | stood back | +|---|---|---|---|---| +| head bone y | 1.466 | **1.070** | 1.071 | 1.466 | +| bone span | 1.667 | **1.299** (∈ seated gate [0.9,1.5]) | 1.285 | 1.667 | +| lowest bone y (feet) | 0.027 | **0.000** (planted) | 0.005 (holds) | 0.027 | +| `inner.position.y` | 0.929 | 0.548 | 0.548 | **0.929** (restored) | +| `procitySeated` | false | **true** | true | false | + +Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant **holds** through +the sit-idle, and standing up restores the rig **byte-for-byte**. `innerRotY:π` throughout (facing convention +intact). Draws **35 · tris 6k** at the seated block (seated peds are already-near rigs — ~0 draw delta). + +- **Seeded path fires**: 6–9 distinct peds sat naturally per ~100 s soak (of ~10 window-shop loiters). +- **Determinism unchanged**: identity signature `-4,2#0:5:1:1.711:1.370:0:-1` — no `sit` term; plan golden + `0x3fa36874`, 15277/15277. +- **Classic covenant airtight at the source**: `?classic=1` → `fleetSitClip:false`, 31 loiters over the soak + but `everSat:0` (the gate never flips `c.sit`), zero fetch delta, `anySeatedNow:0`. + +Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained. + +--- + ## 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`, diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index 146bb48..02dde59 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -223,12 +223,19 @@ export function seatedLean(spine, angle = 0.34) { // ---- 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, nominalHeight = 1.75 } = {}) { +export function makeActor(rig, { walkClip, idleClip, sitClip = 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); const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name))); const idleA = _action(mixer, inner, 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 sitA = sitClip ? _action(mixer, inner, sitClip) : null; + let seatBone = null; + if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); + let sitting = 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); } @@ -246,6 +253,32 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {} if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); } else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer } + // 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. + function setSitting(s) { + if (!sitA || s === sitting) return; + 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 { + sitA.stop(); + inner.position.y = bindPlantY; // restore the standing feet-plant + if (idleA) idleA.setEffectiveWeight(1); + fig.userData.procitySeated = false; + } + } // seed the clip phase so the crowd isn't in lockstep function setPhase(p) { if (walkA) walkA.time = p * (walkA.getClip().duration || 1); @@ -253,6 +286,6 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {} } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } - return { fig, inner, mixer, head, nominalHeight: nom, setMoving, setPhase, dispose, + return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose, hasClips: !!(walkA && idleA) }; } diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index d0e00f0..5af44cc 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -34,6 +34,7 @@ const PATRON_RANGE = 18; // m — a ped ducks into a shop it const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent) const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) +const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded) // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. @@ -91,6 +92,7 @@ class RigPool { } release(actor) { if (!actor) return; + if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant let arr = this.free.get(actor.pedIndex); if (!arr) this.free.set(actor.pedIndex, arr = []); arr.push(actor); @@ -255,6 +257,7 @@ export class CitizenSim { x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0, turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), + sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron }; if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } this._placeOnLane(c); @@ -303,6 +306,7 @@ export class CitizenSim { _acc: 0, turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), + sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron }; // assign a real ped type if the fleet is already up (roster can grow after upgrade) if (this.mode === 'rig' && this.fleet.ready) { @@ -485,7 +489,7 @@ export class CitizenSim { this._setImpostorLayer(atlas); this.mode = 'rig'; - this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip }); + this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs const assign = c => { const pk = pickRig(this.fleet, c.pedRoll); @@ -545,7 +549,14 @@ export class CitizenSim { c.edge = next; c.forward = ne.a === node ? 1 : -1; c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance - if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop + if (c.loit() < 0.10 + c.loiterTend * 0.28) { + c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop + // R17 bench-sit: a seeded few of those become a bench-sit. Roll ALWAYS (dedicated stream → + // deterministic, never shifts turn/loit/patron), but only flip c.sit when a sit clip is loaded + // so ?classic (no sit.glb) is inert at the source, not just at the render. + const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false; + c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip); + } } this._placeOnLane(c); // patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to @@ -683,7 +694,12 @@ export class CitizenSim { const a = c.actor; a.fig.position.set(c.x, 0, c.z); a.fig.rotation.y = c.facing; - a.setMoving?.(c.loiter <= 0); + // R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath + // edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null), + // so every non-sitting ped is byte-identical. + const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting); + if (a.setSitting) a.setSitting(wantSit); + if (!wantSit) a.setMoving?.(c.loiter <= 0); } else if (want === 'mid') { mid.push(c); }