John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
446 lines
26 KiB
Python
446 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane D — R41 §41.3 acceptance shots: THE STREET STOPS BEING ONE PERSON COPY-PASTED.
|
||
|
||
tools/.venv/bin/python tools/qa/r41_shots.py [--seed N] [--outdir DIR] [--soak MS]
|
||
|
||
Two reproducible frames, both chosen by MEASUREMENT rather than by hand:
|
||
|
||
1. r41_street_postures.jpg the camera pose comes from geometry, not taste: the bench station with
|
||
the most patronage door points around it (the one block where BOTH R41 street states can fire in
|
||
a single frame), stood off on the carriageway and occlusion-raycast before it is accepted. Then
|
||
it HOLDS and waits — postures run on 6-20 s dwells, so the honest way to shoot the busy moment is
|
||
to stand still until it happens. Every candidate figure is ray-tested from the lens too: "inside
|
||
the frustum" is not "in the picture", and three earlier runs reported 3-6 rigs in shot while the
|
||
JPEG showed footpath and a gum-tree billboard.
|
||
2. r41_browse_interior.jpg the shop with the most patronage occupants that actually has browse
|
||
points, entered through the shell's own enterShop and framed from Lane C's room.spawn — the view
|
||
the game gives you walking in. The browser rigs are the peds who ducked in off the street, now
|
||
playing browse.glb instead of the shopkeeper's idle.
|
||
[R42 §42.4] This arm used to scan the candidates, exit, and re-enter the winner — ~25 s later,
|
||
by which time its patronage occupants had walked back out and the room rebuilt empty. It is now
|
||
shot IN PLACE, inside the winning room, the moment a candidate beats the incumbent; the occupancy
|
||
decay it used to lose to is printed in the sidecar so the old failure stays legible.
|
||
|
||
Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and
|
||
reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is
|
||
height-RELATIVE on purpose (see human_line): fails on a giant (>2.0 m) or a fold (<55% of that
|
||
citizen's own nominal height), not on an absolute seated band that a wall lean legitimately breaks.
|
||
|
||
Fresh headless context, own no-store server (this project's documented ES-module cache burn).
|
||
"""
|
||
import sys, os, time, json, socket, subprocess, pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||
PORT = int(os.environ.get('PROCITY_R41_SHOT_PORT', '8983'))
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
SOAK = 60000
|
||
OUTDIR = ROOT / 'docs' / 'shots' / 'laneD'
|
||
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||
if '--soak' in sys.argv: SOAK = int(sys.argv[sys.argv.index('--soak') + 1])
|
||
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
|
||
|
||
LO, HI = 1.4, 2.0 # R10 no-giants standing band
|
||
SEAT_LO, SEAT_HI = 0.9, 1.5 # R16 seated band
|
||
|
||
NOSTORE = r'''
|
||
import sys, http.server, functools
|
||
class H(http.server.SimpleHTTPRequestHandler):
|
||
def end_headers(self):
|
||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
super().end_headers()
|
||
def log_message(self, *a): pass
|
||
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever()
|
||
'''
|
||
|
||
# ── PLACE: stand on the road opposite the bench nearest the retail heart ──────────────────────────
|
||
# Deterministic and re-findable: the town anchor (DBG's densest-shop-cluster centroid) picks the
|
||
# block, the sim's OWN benchStationsFor picks the bench, and the camera stands out on the carriageway
|
||
# so the footpath reads unobstructed. Standing ON the footpath was tried first and framed a shopfront
|
||
# wall — the peds are at the back of a 3.5 m verge, so there is no room to back off beside them.
|
||
PLACE = r"""
|
||
async () => {
|
||
const P = window.PROCITY, C = P.citizens;
|
||
const THREE = await import('three');
|
||
const mod = await import('./js/citizens/sim.js');
|
||
const V3 = P.scene.position.constructor;
|
||
const ray = new THREE.Raycaster(); ray.far = 40;
|
||
// Score every bench station by how many patronage door points sit within 14 m: that is the block
|
||
// where BOTH R41 street states fire (bench sits at the station, shopfront leans off those doors),
|
||
// so it is the one place a single frame can hold both. Camera 7 m out on the carriageway at a 45°
|
||
// three-quarter, looking a little further down the footpath. Occlusion-raycast before accepting —
|
||
// an unchecked pose put the camera inside a building twice, high score and a grey wall.
|
||
const doors = [];
|
||
if (C.shopsByChunk) for (const list of C.shopsByChunk.values()) for (const d of list) doors.push(d);
|
||
const clear = (px, pz, tx, tz) => {
|
||
const d = Math.hypot(tx - px, tz - pz);
|
||
ray.set(new V3(px, 1.6, pz), new V3((tx - px) / d, 0, (tz - pz) / d));
|
||
let h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh && !x.object.isSprite);
|
||
if (h.length && h[0].distance < d - 0.8) return false;
|
||
for (const [ax, az] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||
ray.set(new V3(px, 1.6, pz), new V3(ax, 0, az));
|
||
h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh);
|
||
if (h.length && h[0].distance < 1.3) return false;
|
||
}
|
||
return true;
|
||
};
|
||
const out = [];
|
||
for (let i = 0; i < C.edges.length; i++) {
|
||
const e = C.edges[i];
|
||
if ((e.width || 4) < 10) continue;
|
||
const L = e.len, ux = e.ux, uz = e.uz, nxE = -uz, nzE = ux;
|
||
for (const st of mod.benchStationsFor(e)) {
|
||
let nd = 0;
|
||
for (const d of doors) if (Math.hypot(d.x - st.x, d.z - st.z) < 14) nd++;
|
||
if (!nd) continue;
|
||
// which side of the centreline the bench is on → put the camera on the road, same side
|
||
const bd = Math.hypot(st.x - (e.A.x + ux * st.s), st.z - (e.A.z + uz * st.s)) || 1;
|
||
const nx = (st.x - (e.A.x + ux * st.s)) / bd, nz = (st.z - (e.A.z + uz * st.s)) / bd;
|
||
// ON THE CARRIAGEWAY at a quarter of the half-width from the centreline, 8 m back along the
|
||
// street, aimed square at the bench. This is the geometry every readable PROCITY street shot
|
||
// has: the footpath fills the mid-ground at 12-15 m, the verandah is above the heads, and
|
||
// nothing can get between the lens and the subject. Every closer variant tried (4 m, 7 m off
|
||
// the ped) put the camera under an awning or inside a shopfront window box.
|
||
const back = Math.min(bd * 0.55, 9); // out toward the road, but stay on the deck
|
||
for (const sgn of [1, -1]) {
|
||
const px = st.x - nx * back - ux * 5 * sgn, pz = st.z - nz * back - uz * 5 * sgn;
|
||
out.push({ nd, px, pz, yaw: Math.atan2(-(st.x - px), -(st.z - pz)), bx: st.x, bz: st.z });
|
||
}
|
||
}
|
||
}
|
||
out.sort((p, q) => q.nd - p.nd);
|
||
const keep = [];
|
||
for (const c of out) { if (keep.length >= 6) break; if (clear(c.px, c.pz, c.bx, c.bz)) keep.push(c); }
|
||
return { ok: keep.length > 0, cands: keep, stations: out.length / 2 | 0 };
|
||
}
|
||
"""
|
||
|
||
# ── FRAME: how good is the view from RIGHT HERE, right now — states and clips inside a tight box ───
|
||
FRAME = r"""
|
||
async () => {
|
||
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
|
||
const THREE = await import('three');
|
||
const cam = P.camera; cam.updateMatrixWorld(true);
|
||
// Per-ped VISIBILITY raycast. "Inside the frustum" is not "in the picture": three frames running
|
||
// measured 3-6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. A ped hidden
|
||
// behind a verandah post, a tree or a shopfront is not evidence of anything, so each one is
|
||
// ray-tested from the lens and only the ones that can actually be SEEN are counted.
|
||
const ray = new THREE.Raycaster(); ray.far = 30;
|
||
const visible = (c) => {
|
||
const dx = c.x - cam.position.x, dz = c.z - cam.position.z, d = Math.hypot(dx, dz);
|
||
ray.set(new V3(cam.position.x, 1.35, cam.position.z), new V3(dx / d, 0, dz / d));
|
||
const h = ray.intersectObject(P.scene, true)
|
||
.filter(x => !x.object.isSkinnedMesh && !x.object.isSprite && !x.object.isPoints);
|
||
return !(h.length && h[0].distance < d - 0.5);
|
||
};
|
||
const v = new V3(); const rows = [];
|
||
for (const c of C.activeCitizens()) {
|
||
if (!c.actor || c.actorKind !== 'rig') continue;
|
||
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
|
||
if (d < 3.0 || d > 20) continue; // close enough to READ
|
||
v.set(c.x, 1.0, c.z).project(cam);
|
||
if (v.z > 1 || Math.abs(v.x) > 0.62 || Math.abs(v.y) > 0.55) continue; // comfortably in shot
|
||
if (!visible(c)) continue; // and not behind a post
|
||
const posedNow = c.loiter > 0;
|
||
rows.push({ id: c.id, d: +d.toFixed(1),
|
||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
||
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
|
||
clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
||
: (posedNow ? c.posture.idle : c.posture.walk) });
|
||
}
|
||
const states = new Set(rows.map(r => r.state)), clips = new Set(rows.map(r => r.clip));
|
||
const posed = rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length;
|
||
return { n: rows.length, states: [...states], clips: clips.size, posed,
|
||
score: clips.size * 100 + rows.length * 25 + posed * 350 };
|
||
}
|
||
"""
|
||
|
||
# ── measure every figure in the frame (the human-sized line) ──────────────────────────────────────
|
||
MEASURE = r"""
|
||
() => {
|
||
const P = window.PROCITY, C = P.citizens;
|
||
const V3 = P.scene.position.constructor;
|
||
const cam = P.camera; cam.updateMatrixWorld(true);
|
||
const v = new V3(); const rows = [];
|
||
for (const c of C.activeCitizens()) {
|
||
if (!c.actor || c.actorKind !== 'rig') continue;
|
||
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
|
||
if (d < 2.2 || d > 26) continue;
|
||
v.set(c.x, 1.0, c.z).project(cam);
|
||
if (v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) continue;
|
||
let lo = 1e9, hi = -1e9;
|
||
c.actor.inner.updateWorldMatrix(true, true);
|
||
c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
|
||
// NB `c.sit` / `c.glance` are LATCHED flags (R17/R29 set them at a node and never clear them);
|
||
// the ped is only actually posed while `c.loiter > 0`. Reading them raw mislabels a walker as
|
||
// seated — which is exactly how this harness first reported a 1.63 m "seated" figure.
|
||
const posedNow = c.loiter > 0;
|
||
rows.push({ id: c.id, d: +d.toFixed(1),
|
||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
||
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
|
||
clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
||
: (posedNow ? c.posture.idle : c.posture.walk),
|
||
seated: !!c.bench || !!(posedNow && c.sit), h: +c.height.toFixed(3),
|
||
stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3) });
|
||
}
|
||
rows.sort((a, b) => a.d - b.d);
|
||
return { rows, info: window.DBG.info(), clips: C.clipStats() };
|
||
}
|
||
"""
|
||
|
||
# ── the browse arm. [Lane D R42 §42.4] SHOOT THE WINNER WHILE IT IS STILL THE WINNER. ─────────────
|
||
# The R41 shape of this arm was scan-then-re-enter: walk up to 8 occupied shops at ~3.1 s each, keep
|
||
# the id of the one with the most browsers, then `enterShop` it again and shoot. That is
|
||
# deterministically red, and the reason is measured below and printed with every run — the browsers
|
||
# are a function of LIVE patronage occupancy, and over the ~25 s the scan costs, the occupants of the
|
||
# shop picked first (the fullest one, which is also the one whose visitors are furthest through their
|
||
# dwell) have walked back out. The re-entry then builds the same room with nobody in it and the arm
|
||
# reports "no browsers" about a game that was working. F wired it warn-level with that reason at the
|
||
# call site; this is the fix.
|
||
#
|
||
# The fix is not a longer wait or a re-roll — it is to stop leaving. Python now drives the scan one
|
||
# candidate at a time and takes the frame + the measurements + the JPEG INSIDE the room, the moment a
|
||
# candidate beats the incumbent. Nothing is re-entered, so nothing can go stale between choosing and
|
||
# shooting, and the scan keeps its original "best of several, not first that works" property.
|
||
INT_CANDS = r"""
|
||
() => {
|
||
const P = window.PROCITY, C = P.citizens, D = window.DBG;
|
||
D.setSegment(2);
|
||
// Shops that currently hold patronage occupants — occupancy is what puts BROWSERS on the floor (F
|
||
// stands one per browse point per occupant). Not every room type HAS browse points (a market stall
|
||
// has one, an op-shop three), so several are tried and the BEST kept rather than the first that
|
||
// works: taking the first gave a one-browser stall on one run and a three-browser op-shop on the next.
|
||
return (P.plan.shops || [])
|
||
.map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count }))
|
||
.filter((s) => s.count > 0).sort((a, b) => b.count - a.count);
|
||
}
|
||
"""
|
||
|
||
INT_TRY = r"""
|
||
async (id) => {
|
||
const P = window.PROCITY, D = window.DBG, C = P.citizens;
|
||
D.enterShop(id);
|
||
await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb
|
||
const room = P.interiorMode.current;
|
||
return { id, inside: !!room,
|
||
browsePoints: room ? (room.browsePoints || []).length : 0,
|
||
browsers: ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length,
|
||
occupants: C.occupancyOf(id).count };
|
||
}
|
||
"""
|
||
|
||
INT_LEAVE = "async () => { window.DBG.exitShop(); await new Promise(r => setTimeout(r, 500)); }"
|
||
OCC_OF = "(id) => window.PROCITY.citizens.occupancyOf(id).count"
|
||
|
||
INT_FRAME = r"""
|
||
() => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
const room = P.interiorMode.current;
|
||
const km = P.interiorMode.keepers;
|
||
const figs = (km ? km.keepers : []).map(k => k.actor.fig);
|
||
if (!figs.length) return { ok: false, why: 'no keeper/browser figures' };
|
||
// Stand where the PLAYER stands walking in (Lane C's room.spawn) and look at the centroid of the
|
||
// shop's people — so the frame is the view the game actually gives you, not a staged angle. Backed
|
||
// off toward the spawn wall if that puts the camera on top of somebody.
|
||
let mx = 0, mz = 0; for (const f of figs) { mx += f.position.x; mz += f.position.z; } mx /= figs.length; mz /= figs.length;
|
||
const sp = room.spawn || { x: 0, z: 0 };
|
||
let px = sp.x, pz = sp.z;
|
||
let d = Math.hypot(mx - px, mz - pz);
|
||
if (d < 2.6) { const k = 2.6 / (d || 1); px = mx - (mx - px) * k; pz = mz - (mz - pz) * k; d = 2.6; }
|
||
P.camera.position.set(px, 1.62, pz);
|
||
P.camera.lookAt(new V3(mx, 1.15, mz));
|
||
P.camera.updateMatrixWorld(true);
|
||
P.renderer.info.reset();
|
||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||
// who is actually inside the frame
|
||
const v = new V3(); let seen = 0;
|
||
for (const f of figs) { v.set(f.position.x, 1.0, f.position.z).project(P.camera);
|
||
if (v.z <= 1 && Math.abs(v.x) < 0.9 && Math.abs(v.y) < 0.9) seen++; }
|
||
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+mx.toFixed(2), +mz.toFixed(2)],
|
||
figures: figs.length, inFrame: seen,
|
||
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
|
||
}
|
||
"""
|
||
|
||
INT_MEASURE = r"""
|
||
() => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
const km = P.interiorMode.keepers;
|
||
const rows = [];
|
||
for (const k of (km ? km.keepers : [])) {
|
||
const a = k.actor; if (!a.inner) { rows.push({ kind: k.browse ? 'browser' : 'keeper', placeholder: true }); continue; }
|
||
let lo = 1e9, hi = -1e9;
|
||
a.inner.updateWorldMatrix(true, true);
|
||
a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
|
||
rows.push({ kind: k.browse ? 'browser' : 'keeper', shopId: k.shopId, type: k.type, h: +(a.height || 0).toFixed(3),
|
||
clip: (a.mixer && a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name)[0] || null,
|
||
pending: k.want || null,
|
||
stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3),
|
||
pos: [+a.fig.position.x.toFixed(2), +a.fig.position.z.toFixed(2)] });
|
||
}
|
||
return { rows, info: window.DBG.info() };
|
||
}
|
||
"""
|
||
|
||
|
||
def port_up(port):
|
||
with socket.socket() as s:
|
||
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
|
||
|
||
|
||
def serve():
|
||
p = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for _ in range(80):
|
||
if port_up(PORT): return p
|
||
time.sleep(0.1)
|
||
p.terminate(); raise SystemExit(f'could not serve on :{PORT}')
|
||
|
||
|
||
def human_line(rows):
|
||
"""The R10 no-giants line — measured, and measured against the law it is actually about.
|
||
|
||
An absolute seated band was tried first and it was the WRONG instrument for R41: the library's
|
||
four sits run from bolt-upright-in-a-chair to slumped, and its four wall leans take 15-25% off a
|
||
standing crown by construction (you are leaning BACK). A 1.32 m leaner is not a defect, and a
|
||
fixed [0.9,1.5] seated band called it one. What the R10 law forbids is a GIANT, and what R16
|
||
forbids is a FOLD (Hips.quaternion laying the body flat, head down at hip height). So: stature is
|
||
reported for every figure with its ratio to that citizen's own nominal height, and the gate fails
|
||
on stature > 2.0 m (giant), stature < 0.55 x height (folded), or a nominal height outside the
|
||
seeded [1.4, 2.0] range."""
|
||
out, bad = [], []
|
||
for r in rows:
|
||
if r.get('placeholder'):
|
||
continue
|
||
h = r.get('h')
|
||
ratio = (r['stature'] / h) if h else None
|
||
if r['stature'] > HI: bad.append(f"{r.get('id', r.get('kind'))} GIANT {r['stature']}m")
|
||
elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r.get('kind'))} FOLDED {r['stature']}m of {h}m")
|
||
elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r.get('kind'))} nominal height {h}m out of [{LO},{HI}]")
|
||
out.append(f"{r.get('state', r.get('kind'))} {r['stature']}m"
|
||
+ (f" ({ratio:.0%} of its {h}m nominal)" if ratio else ""))
|
||
verdict = 'ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad else 'FAIL: ' + ', '.join(bad)
|
||
return (f"HUMAN-SIZED LINE — {len(out)} figures, stature (feet\u2192crown) measured off the posed "
|
||
f"skeleton: {' \u00b7 '.join(out)}. {verdict}."), not bad
|
||
|
||
|
||
def main():
|
||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||
from playwright.sync_api import sync_playwright
|
||
srv = serve()
|
||
rc = 0
|
||
try:
|
||
with sync_playwright() as p:
|
||
b = p.chromium.launch()
|
||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||
errs = []
|
||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1')
|
||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=40000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
pg.evaluate("() => { window.DBG.setSegment(2); window.DBG.shot('crossroads_busy'); }")
|
||
print(f'soaking {SOAK} ms so the crowd reaches its posture steady state…')
|
||
pg.wait_for_timeout(SOAK)
|
||
|
||
# ── shot 1: the street ────────────────────────────────────────────────────────────────
|
||
# The camera pose is chosen ONCE, from geometry (the bench station with the most shop
|
||
# doors around it — the one block where both R41 street states can fire in one frame) and
|
||
# occlusion-raycast before it is accepted. Then it HOLDS and waits: postures come and go
|
||
# on 6–20 s dwells, so the honest way to shoot the busy moment is to stand still until it
|
||
# happens. Chasing subjects with a moving camera was tried first and lost them to
|
||
# rig-reacquisition every time.
|
||
place = pg.evaluate(PLACE)
|
||
print(f"place: {place.get('stations', 0)} bench stations near shop doors; "
|
||
f"{len(place.get('cands', []))} clear camera poses")
|
||
best, m = None, None
|
||
for cand in place.get('cands', [])[:4]:
|
||
for _ in range(2):
|
||
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
|
||
pg.wait_for_timeout(2500)
|
||
for _ in range(14): # ~35 s of holding this pose
|
||
fr = pg.evaluate(FRAME)
|
||
if fr['n'] and (not best or fr['score'] > best['score']):
|
||
mm = pg.evaluate(MEASURE)
|
||
pg.screenshot(path=str(OUTDIR / 'r41_street_postures.jpg'), type='jpeg', quality=90)
|
||
best, m = dict(fr, **{k: cand[k] for k in ('px', 'pz', 'yaw')}), mm
|
||
print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {fr['score']} n={fr['n']} "
|
||
f"clips={fr['clips']} posed={fr['posed']} states={fr['states']}")
|
||
if best and best['posed'] >= 2 and best['clips'] >= 4: break
|
||
pg.wait_for_timeout(2500)
|
||
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
|
||
if best and best['posed'] >= 2 and best['clips'] >= 4: break
|
||
if not best:
|
||
print(' never framed anybody'); rc = 1
|
||
else:
|
||
line, ok = human_line(m['rows'])
|
||
name = 'r41_street_postures.jpg'
|
||
states = {}
|
||
for r in m['rows']: states[r['state']] = states.get(r['state'], 0) + 1
|
||
cap = (f"PROCITY R41 §41.3 — {name}\n"
|
||
f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) yaw {best['yaw']:.3f}\n"
|
||
f"{len(m['rows'])} near-tier rigs in frame · states {states} · "
|
||
f"{len(set(r['clip'] for r in m['rows']))} distinct clips: "
|
||
f"{', '.join(sorted(set(r['clip'] for r in m['rows'])))}\n"
|
||
f"draws {m['info']['drawCalls']} / budget {m['info']['budget']['draws']} · tris {m['info']['tris']}\n"
|
||
f"clips resident: {m['clips']['groups']} groups / {m['clips']['clips']} clips / {m['clips']['bytes']} B\n"
|
||
f"{line}\n")
|
||
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
|
||
print(cap)
|
||
if not ok: rc = 1
|
||
|
||
# ── shot 2: a browsing interior (shot from inside — see INT_CANDS) ────────────────────
|
||
cands = pg.evaluate(INT_CANDS)
|
||
print(f'interior: {len(cands)} occupied shops; scanning up to 8, shooting in place')
|
||
best, tried = None, []
|
||
for s in cands[:8]:
|
||
t = pg.evaluate(INT_TRY, s['id'])
|
||
tried.append({**s, **{k: t[k] for k in ('browsers', 'browsePoints', 'occupants')}})
|
||
print(f" {s['name'][:28]:<28} ({s['type']:<8}) occ {t['occupants']}/{s['count']} · "
|
||
f"{t['browsePoints']} browse points · {t['browsers']} browsers")
|
||
if t['inside'] and t['browsePoints'] and t['browsers'] and (not best or t['browsers'] > best['browsers']):
|
||
fr = pg.evaluate(INT_FRAME)
|
||
if fr.get('ok'):
|
||
im = pg.evaluate(INT_MEASURE)
|
||
pg.screenshot(path=str(OUTDIR / 'r41_browse_interior.jpg'), type='jpeg', quality=90)
|
||
best = {**t, 'shop': s, 'frame': fr, 'meas': im}
|
||
print(f" → shot in place: {fr['figures']} figures, {fr['inFrame']} in frame, "
|
||
f"{fr['draws']} draws")
|
||
if best and best['browsers'] >= 3: break
|
||
pg.evaluate(INT_LEAVE)
|
||
if not best:
|
||
print(f' no occupied shop had browse points with browsers; tried {tried[:6]}'); rc = 1
|
||
else:
|
||
# The R41 bug, measured rather than asserted: what the OLD scan-then-re-enter path would
|
||
# have walked back into. If this has decayed toward 0 the arm was red for exactly that
|
||
# reason, and shooting in place is what fixed it.
|
||
stale = pg.evaluate(OCC_OF, best['shop']['id'])
|
||
fr, im = best['frame'], best['meas']
|
||
browsers = [r for r in im['rows'] if r['kind'] == 'browser']
|
||
line, ok = human_line(im['rows'])
|
||
name = 'r41_browse_interior.jpg'
|
||
cap = (f"PROCITY R41 §41.3 — {name}\n"
|
||
f"seed {SEED} · {best['shop']['name']} ({best['shop']['type']}) · "
|
||
f"{best['occupants']} patronage occupants at entry · {best['browsePoints']} browse points · "
|
||
f"{len(browsers)} browsers\n"
|
||
f"shot IN PLACE (R42 §42.4): the frame is taken inside the winning room during the "
|
||
f"scan, never on a re-entry. Occupancy of that shop at entry {best['occupants']} → "
|
||
f"{stale} by the end of the scan — the decay the old re-entry path walked into.\n"
|
||
f"figures: {json.dumps(im['rows'])}\n"
|
||
f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n"
|
||
f"{line}\n")
|
||
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
|
||
print(cap)
|
||
if not ok or not browsers: rc = 1
|
||
if errs:
|
||
print('CONSOLE ERRORS:', errs[:6]); rc = 1
|
||
b.close()
|
||
finally:
|
||
srv.terminate()
|
||
print('\033[32m● shots written\033[0m' if rc == 0 else '\033[31m● problems — see above\033[0m')
|
||
return rc
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|