diff --git a/D-progress.md b/D-progress.md index 18fa6ec..d566b85 100644 --- a/D-progress.md +++ b/D-progress.md @@ -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.83–2.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.4–4.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.6–1.86 m +across 6 shop types under 3.4–4.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). diff --git a/docs/shots/laneD/r10_browsers_fixed.jpg b/docs/shots/laneD/r10_browsers_fixed.jpg new file mode 100644 index 0000000..6e62dbe Binary files /dev/null and b/docs/shots/laneD/r10_browsers_fixed.jpg differ diff --git a/tools/qa.sh b/tools/qa.sh index afbed3e..2d204e5 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -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' \ diff --git a/tools/qa/interior_scale_check.py b/tools/qa/interior_scale_check.py new file mode 100644 index 0000000..a119c14 --- /dev/null +++ b/tools/qa/interior_scale_check.py @@ -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() diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index 5065518..f8d236f 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -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);