Lane D R17 (v3.2): bench-sit loiter (ledger #2) — the carried R16 stretch

Street peds occasionally sit during a window-shop loiter (verandah/footpath
edge). makeActor gains an opt-in setSitting with a SCALE-AWARE re-plant (the
R16 spawnRig re-plant was scale-naive; the sim scales these rigs per-citizen),
played only on sit so walkers/placeholders/?classic are byte-identical. Seeded
via a dedicated benchsit rng stream (no shift to turn/loit/patron; identity
signature unchanged). Classic inert at the source (roll gated on sitClip).

In-shell (default, seed 20261990): head bone 1.466->1.070, bone span
1.667->1.299 (in seated gate [0.9,1.5]), feet re-plant to y=0 and hold through
sit-idle, stand-up restores inner.y 0.929 byte-for-byte, procitySeated tagged,
innerRotY:pi throughout. 9 distinct peds sat naturally per soak. Classic:
fleetSitClip:false, everSat:0. Plan golden 0x3fa36874 unchanged. Draws 35/tris 6k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 14:53:22 +10:00
parent 483a4b75c6
commit 64ded4ea01
3 changed files with 109 additions and 5 deletions

View File

@ -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**: 69 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`,

View File

@ -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) };
}

View File

@ -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);
}