Lane D round 10: fix interior giant rigs + no-giants QA gate

R9 blocker: interior rig figures were ~2x too tall (keeper crown 3.83m in a 3.4m room,
clipping the ceiling). Root cause in rigs.js buildFigure: it normalised scale by height/headY,
where headY is the head bone's world Y in BIND POSE — but the Mixamo skeleton origin is the hips
(~mid-body), so headY is head-above-hips (~half the standing height), not feet->crown. Every rig
came out ~1.83-2.0x too tall. (The head does NOT move when posed — bind crown 3.209m vs idle
3.205m — so it's a hips-relative measure, not an animation issue.)

Fix: normalise by the feet->crown span (headY - minY, both already computed). buildFigure(rig, h)
now lands the crown at exactly h above planted feet. Fixes keepers, browser rigs, near-tier street
rigs AND the impostor bake (all ride buildFigure). Verified: all 19 fleet rigs crown==1.75m; live
keeper 3.83m -> 1.82m; determinism + dispose-leak soak unchanged.

Regression guard (required): new tools/qa/interior_scale_check.py (Playwright) enters a sample of
shops, injects browser rigs at browse points, asserts every interior fig crown in [1.4,2.0]m AND
< room dims.H AND feet planted. Wired into tools/qa.sh --strict (run_gate => a giant fails qa).
Proven bidirectional: PASS on the fix (24 figs/6 shops), FAIL on a reintroduced giant. Full
qa.sh --strict: 6 passed, 0 failed.

Re-shot laneD/r10_browsers_fixed.jpg. Figures human-sized relative to doors/fittings: yes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 13:01:54 +10:00
parent 8bd4676593
commit 3432fccd1f
5 changed files with 203 additions and 5 deletions

View File

@ -1,5 +1,39 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15)
Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a
3.4 m room, clipping the ceiling). Measured live, not from notes.
- **Root cause (rigs.js `buildFigure`)**: it normalised scale by `height / headY`, where `headY` is
the head bone's world Y in bind pose. But the Mixamo skeleton's ORIGIN is the hips (~mid-body), so
`headY` is head-**above-hips** (~half the standing height) — not the feet→crown height. Result:
every rig ~1.832.0× too tall. (NB Fable's "bind-pose 82 vs posed 180" hypothesis was close on the
numbers but the head does NOT move when posed — bind crown 3.209 m vs idle-posed 3.205 m. The 82 is
head-above-hips; the 175 is the true feet→crown span. So it's a hips-relative measure, not animation.)
- **Fix (one logic line)**: normalise by the feet→crown **span** = `headY - minY` (both already
computed). Now `buildFigure(rig, h)` lands the crown at exactly `h` above planted feet. Verified all
19 fleet rigs → crown == 1.75 m (feet 0); live keeper in "Second Time Redfern" 3.83 m → **1.82 m**.
This one change fixes keepers, browser rigs, near-tier street rigs AND the impostor bake (all ride
`buildFigure`) — street peds looked fine only because they're mostly impostors, which are sized by
requested height and so masked the bug.
- **Regression guard (required)** — new `tools/qa/interior_scale_check.py` (Playwright): enters a
sample of open shops, injects browser rigs at every browse point, and asserts EVERY interior fig's
crown ∈ [1.4, 2.0] m AND < room `dims.H` AND feet planted. Wired into `tools/qa.sh --strict` (gate 6,
`run_gate` ⇒ a giant fails qa). Proven **bidirectional**: PASS on the fix (24 figs / 6 shops), FAIL
on a reintroduced giant (crowns 3.44.05 m, all 6 shops red). `qa.sh --strict` = **6 passed, 0 failed**.
- **Determinism + leak re-verified**: same seed → same keeper height + ped (shop 18 → 1.823 m, idx 10,
identical twice; buildFigure crown stable & == requested); 15 spawn/dispose cycles → geo/tex delta 0.
- **Re-shot** `laneD/r9_browsers_in_shop.jpg``laneD/r10_browsers_fixed.jpg` (kept r9 as the
documented before-state).
**Figures human-sized relative to doors/fittings: yes** — verified live (measured crowns 1.61.86 m
across 6 shop types under 3.44.0 m ceilings) and visible in `r10_browsers_fixed.jpg` (a hi-vis worker
and a hoodie browser both well under the ceiling, in proportion to the shelves and door).
Files touched (mine only): `web/js/citizens/rigs.js` (fix), `tools/qa/interior_scale_check.py` (new
gate), `tools/qa.sh` (+gate block, authorized by ROUND10 §Lane D), `docs/shots/laneD/r10_*.jpg`, docs.
## Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.
Built the D side of the one cross-lane seam (C browse points → D occupancy+browsers → F handoff).

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

View File

@ -96,6 +96,21 @@ else
soft_skip "v2 flags harness" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
fi
# ── Gate 5 (Lane D, round 10): interior figure-scale regression guard ────────
# Enters a sample of open shops and asserts every interior rig figure's crown is human-sized
# [1.4,2.0] m AND under the room ceiling — so the R9 giant blocker (buildFigure normalised off
# head-above-hips → ~2× too tall) can never pass a gate again. run_gate ⇒ a giant FAILS qa.
# Needs the Playwright venv (auto-skips where absent, like the flags harness). Wired per ROUND10 §D.
SCALE_SKIP=0; for a in "$@"; do [ "$a" = "--no-scale" ] && SCALE_SKIP=1; done
if [ "$SCALE_SKIP" = 1 ]; then
soft_skip "interior figure-scale gate" "--no-scale"
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "interior figure-scale (no giants: crown ∈ [1.4,2.0] m, under ceiling · Lane D R10)" \
tools/.venv/bin/python tools/qa/interior_scale_check.py
else
soft_skip "interior figure-scale gate" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

View File

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""PROCITY Lane D — interior figure-scale gate (Round 10 regression guard).
Guards the R9 blocker: interior rig figures came out ~2x too tall (keeper head at 3.83 m in a
3.4 m room) because rigs.js `buildFigure` normalised by the head bone's world Y in BIND POSE —
which is head-above-hips (the skeleton origin), ~half the standing height instead of the
feet->crown span. Fixed in R10 (normalise by `headY - minY`).
This gate boots the real game, enters a sample of open shops, spawns browser rigs at every browse
point (occupancy is 0 right after boot, so we inject it to exercise the browse spawn path
deterministically), and asserts for EVERY spawned interior figure:
1.4 m <= crown(head-bone world Y) <= 2.0 m (human-sized)
crown < room dims.H (never clips the ceiling)
|feet(min-bone world Y)| < 0.05 m (planted on the floor)
Exits non-zero if any figure fails, so tools/qa.sh --strict fails a giant before it can ship.
Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
FOOT_EPS = 0.05 # feet must plant at floor y=0
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
fails = []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)],
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f"could not start http.server on :{PORT}")
# JS: enter is done via DBG; this measures every interior fig after injecting browsers at browse points.
MEASURE_JS = r"""
(shopId) => {
const P = window.PROCITY, THREE = P.THREE, im = P.interiorMode, v = new THREE.Vector3();
const cur = im.current;
if (!cur || P.mode !== 'interior') return { err: 'not in interior' };
// occupancy is 0 right after boot; inject a browser rig at each browse point to exercise the
// browse spawn path (same spawnRig->buildFigure the shell uses when patronage fills a shop).
const bps = cur.browsePoints || [];
bps.slice(0, 3).forEach((pt, i) => {
try { im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: shopId, browse: true,
seedKey: 'qa#' + i, pedIndex: (i * 7) % 19 }); } catch (e) {}
});
const H = +cur.dims.H;
const figs = im.keepers.keepers.map(k => {
const a = k.actor; let crown = 0, foot = Infinity;
a.fig.updateWorldMatrix(true, true);
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3) };
});
return { H: +H.toFixed(2), figs };
}
"""
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — python3 -m venv tools/.venv && tools/.venv/bin/pip install playwright && tools/.venv/bin/playwright install chromium")
print(f"\033[1mINTERIOR FIGURE-SCALE GATE\033[0m (seed {SEED}, band [{LO},{HI}] m, no giants)")
srv = ensure_server()
total_figs = 0
shops_checked = 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1')
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function("() => window.PROCITY && window.PROCITY.fleet && window.PROCITY.fleet.ready", timeout=15000)
except Exception:
sys.exit("fleet never became ready — cannot test rig scale")
pg.evaluate("() => window.DBG.setSegment(2)") # midday: shops open
for t in SAMPLE_TYPES:
sid = pg.evaluate("""(t) => {
const P=window.PROCITY;
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
if(!s) return null; window.DBG.enterShop(s.id); return {id:s.id, name:s.name};
}""", t)
if not sid:
continue
pg.wait_for_timeout(400) # interior build + keeper/browser rigs settle
res = pg.evaluate(MEASURE_JS, sid['id'])
if isinstance(res, dict) and res.get('err'):
FAIL(f"{sid['name']} ({t}): {res['err']}")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()"); pg.wait_for_timeout(150)
continue
H, figs = res['H'], res['figs']
shops_checked += 1
bad = []
for f in figs:
total_figs += 1
if not (LO <= f['crown'] <= HI): bad.append(f"crown {f['crown']}m out of [{LO},{HI}]")
elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m")
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
if bad:
FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad))
else:
crowns = ", ".join(f"{f['crown']:.2f}" for f in figs)
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs crowns=[{crowns}] all human-sized")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150)
b.close()
finally:
if srv: srv.terminate()
print()
if shops_checked == 0:
sys.exit("\033[31m✗ no shops could be entered — gate inconclusive\033[0m")
if fails:
print(f"\033[31m● {len(fails)} FAIL\033[0m — interior giant(s) detected across {shops_checked} shop(s), {total_figs} figs.")
sys.exit(1)
print(f"\033[32m● PASS\033[0m — {total_figs} interior figs across {shops_checked} shops all human-sized [{LO},{HI}] m and under-ceiling.")
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -104,17 +104,22 @@ export function buildFigure(rig, height = 1.75) {
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise off the head bone (Mixamo export scale is unreliable), then plant feet.
// 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);
minY = Math.min(minY, _wp.y);
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)
}
});
if (headY > 1e-4) {
inner.scale.setScalar(height / headY);
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);