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>
839 lines
51 KiB
Python
839 lines
51 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane F — R42 §42.6 THE PROOF SHOTS. This round is judged by eye, so these ARE the round.
|
||
|
||
tools/.venv/bin/python tools/qa/r42_tour.py [--only NAME[,NAME…]] [--outdir DIR]
|
||
|
||
R42 is a LOOK round: three faults were visible in R41's own committed proof shots, and the deliverable
|
||
is the same frames taken again on the integrated tree, **paired against those committed originals**.
|
||
The R41 files in `docs/shots/laneF_r41/` are the BEFORE — they are in git, nobody can re-shoot them,
|
||
and that is exactly what makes them evidence.
|
||
|
||
pub_furnished R41's hero. The before holds TWO purple `EL CHUPACABRA` luchadors and an
|
||
UNCLOTHED body at the stage edge, in a room of white plastic-looking props.
|
||
Same recipe, same seed, same room, same segment. → pub_furnished_ab.jpg
|
||
record_dj_booth R41's booth: "a plain black box". Same recipe. → record_dj_booth_ab.jpg
|
||
record_dj_booth_door …and the frame R41 never took: the booth from where a player actually stands,
|
||
at Lane C's own `room.spawn` — the door. A composition claim is a claim about
|
||
the entry view, so it has to be shot from the entry.
|
||
street_postures R41's street, from R41's OWN published camera — parsed out of its committed
|
||
sidecar, because re-running R41's live-crowd cluster search lands somewhere
|
||
different every time. → street_postures_ab.jpg
|
||
street_bench the bench claim on its own, shot SIDE-ON (a person facing the camera looks the
|
||
same whichever way the bench under them is turned), with the yaw MEASURED in
|
||
the frame from the plan's road geometry and the scene's instance matrices —
|
||
not read back off B's placement rule.
|
||
credits_panel the F2 surface with the ped rows filled: ruling 3 on screen, no `unverified`.
|
||
|
||
TWO LINES ON EVERY HUMANOID FRAME
|
||
· the R10 human-sized line (stature feet→crown off the POSED skeleton, as a ratio of that figure's
|
||
own nominal height — a giant >2.0 m or a fold <55% fails).
|
||
· the CAST line (Lane D's §42.4 idea, adopted): every figure resolved back to the ped GLB it is
|
||
wearing via `fleet.all[pedIndex].pedName`, with an explicit assertion that none of the five
|
||
retired bodies is in the picture. "The luchador is gone" is a claim about the cast, and a JPEG
|
||
cannot carry it alone.
|
||
|
||
Fresh headless context per frame, own no-store server, seed 20261990, 1280x720.
|
||
Exit 0 = every frame captured, every human-sized line clean, and no retired body in any cast.
|
||
"""
|
||
import sys, os, re, json, time, socket, subprocess, pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||
PORT = int(os.environ.get('PROCITY_R42_TOUR_PORT', '8984'))
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
OUTDIR = ROOT / 'docs' / 'shots' / 'laneF_r42'
|
||
BEFORE = ROOT / 'docs' / 'shots' / 'laneF_r41' # the committed R41 originals — the BEFORE arm
|
||
LO, HI = 1.4, 2.0
|
||
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
|
||
ONLY = None
|
||
if '--only' in sys.argv: ONLY = set(sys.argv[sys.argv.index('--only') + 1].split(','))
|
||
|
||
# Lane E §42.3 / Lane D §42.4 — the five bodies that must never be in a shipped frame again.
|
||
RETIRED = ['comical_luchador_01', 'comical_boy_01', 'man_elder_01', 'man_soldier_ww2_01', 'dj_phrtt_01']
|
||
|
||
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 head(m): print(f"\n\033[1m{m}\033[0m")
|
||
def note(m): print(f" \033[33m·\033[0m {m}")
|
||
|
||
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()
|
||
'''
|
||
|
||
# ── the street: R41's own cluster search, plus the CAST term ──────────────────────────────────────
|
||
# Unchanged in method from r41_tour.py on purpose — a before/after pair whose AFTER was framed by a
|
||
# different algorithm is not a pair. The one addition is `ped`: the frame this round is about WHO is
|
||
# in it, so the scorer also rewards distinct BODIES, and the sidecar can name them.
|
||
STREET_LOOK = r"""
|
||
() => {
|
||
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
|
||
const cam = P.camera;
|
||
const names = (P.fleet.all || []).map(r => r.pedName);
|
||
const people = [];
|
||
for (const c of C.activeCitizens()) {
|
||
if (!c.actor || c.actorKind !== 'rig') continue;
|
||
const posedNow = c.loiter > 0;
|
||
people.push({ id: c.id, x: c.x, z: c.z, ped: names[c.pedIndex] || '?',
|
||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
||
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk') });
|
||
}
|
||
const anchors = people.filter(p => p.state !== 'walk');
|
||
if (!anchors.length) return { ok: false, why: 'no non-walking rig on the street yet', n: people.length };
|
||
const targets = anchors.map(a => ({ x: a.x, z: a.z, state: a.state }));
|
||
for (let i = 0; i < anchors.length; i++)
|
||
for (let j = i + 1; j < anchors.length; j++)
|
||
if (anchors[i].state !== anchors[j].state && Math.hypot(anchors[i].x - anchors[j].x, anchors[i].z - anchors[j].z) < 34)
|
||
targets.push({ x: (anchors[i].x + anchors[j].x) / 2, z: (anchors[i].z + anchors[j].z) / 2,
|
||
state: anchors[i].state + '+' + anchors[j].state });
|
||
let best = null;
|
||
for (const a of targets) {
|
||
for (let i = 0; i < 12; i++) {
|
||
const th = i * Math.PI / 6;
|
||
for (const r of [7, 9.5, 12, 15, 18]) {
|
||
const px = a.x + Math.cos(th) * r, pz = a.z + Math.sin(th) * r;
|
||
cam.position.set(px, 1.62, pz);
|
||
cam.lookAt(new V3(a.x, 1.25, a.z));
|
||
cam.updateMatrixWorld(true);
|
||
const v = new V3(); const seen = [];
|
||
for (const p of people) {
|
||
const d = Math.hypot(p.x - px, p.z - pz);
|
||
if (d < 2.4 || d > 26) continue;
|
||
v.set(p.x, 1.0, p.z).project(cam);
|
||
if (v.z > 1 || Math.abs(v.x) > 0.9 || Math.abs(v.y) > 0.9) continue;
|
||
seen.push(p);
|
||
}
|
||
const states = new Set(seen.map(s => s.state));
|
||
const bodies = new Set(seen.map(s => s.ped));
|
||
const score = states.size * 100 + bodies.size * 40 + seen.length * 10 - r * 4;
|
||
if (!best || score > best.score) best = { score, px, pz, ax: a.x, az: a.z,
|
||
states: [...states], bodies: [...bodies], inFrame: seen.length, anchor: a.state };
|
||
}
|
||
}
|
||
}
|
||
cam.position.set(best.px, 1.62, best.pz);
|
||
cam.lookAt(new V3(best.ax, 1.25, best.az));
|
||
cam.updateMatrixWorld(true);
|
||
// MEASURE IN THE SAME BEAT — searching in one evaluate and measuring in the next lets the dwell
|
||
// clock run between them, and a bench somebody has just got up from is the frame you shoot.
|
||
const v2 = 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;
|
||
v2.set(c.x, 1.0, c.z).project(cam);
|
||
if (v2.z > 1 || Math.abs(v2.x) > 0.92 || Math.abs(v2.y) > 0.92) 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; } });
|
||
const posedNow = c.loiter > 0;
|
||
// the clip is read off the MIXER, not off `c.posture` — posture is the ASSIGNMENT and exists on a
|
||
// ?clips=0 boot that has fetched nothing (R41's lesson, kept).
|
||
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
|
||
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
|
||
rows.push({ id: c.id, d: +d.toFixed(1), ped: names[c.pedIndex] || '?',
|
||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
||
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
|
||
clip: playing[0] || null, h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
||
}
|
||
rows.sort((a, b) => a.d - b.d);
|
||
const states = [...new Set(rows.map(r => r.state))].sort();
|
||
const clips = [...new Set(rows.map(r => r.clip).filter(Boolean))].sort();
|
||
const bodies = [...new Set(rows.map(r => r.ped))].sort();
|
||
// …and a BENCH-SIT BONUS, stated rather than hidden: R41's committed `street_postures.jpg` is a
|
||
// bench-sit frame, and a pair whose halves show different states is not a pair. The bonus biases
|
||
// the AFTER toward the comparable frame; it does not manufacture one, because a bench-sit anchor
|
||
// only exists if a citizen is actually sitting on a Lane B bench in this town at this moment.
|
||
const benchBonus = rows.some(r => r.state === 'bench-sit') ? 150 : 0;
|
||
return { ok: true, ...best, rows, states, clips, bodies, inFrame: rows.length, benchBonus,
|
||
score2: states.length * 100 + bodies.length * 50 + clips.length * 40 + rows.length * 10 + benchBonus,
|
||
info: window.DBG.info(), clipStats: C.clipStats() };
|
||
}
|
||
"""
|
||
|
||
# ── THE BENCH, measured in the frame and NOT from Lane B's rule ───────────────────────────────────
|
||
# `benchStops(plan)` is used for POSITIONS ONLY — to say "this instance is a bench" — which is the
|
||
# same non-circularity B used. The yaw verdict is then computed from two things B's placer never
|
||
# touches: the PLAN's road centreline (Lane A's graph) and the instance's own world matrix.
|
||
BENCH_MEASURE = r"""
|
||
async () => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
const THREE = await import('three');
|
||
const F = await import('./js/world/furniture.js');
|
||
const stops = F.benchStops(P.plan);
|
||
// road geometry, straight from Lane A's graph — not from the furniture module
|
||
const nodes = new Map(P.plan.streets.nodes.map(n => [n.id, n]));
|
||
const edges = P.plan.streets.edges.map(e => { const a = nodes.get(e.a), b = nodes.get(e.b);
|
||
return { id: e.id, ax: a.x, az: a.z, bx: b.x, bz: b.z, kind: e.kind, width: e.width }; });
|
||
const byId = new Map(edges.map(e => [e.id, e]));
|
||
const foot = (e, x, z) => {
|
||
const dx = e.bx - e.ax, dz = e.bz - e.az, L2 = dx * dx + dz * dz || 1, L = Math.sqrt(L2);
|
||
let t = ((x - e.ax) * dx + (z - e.az) * dz) / L2; t = Math.max(0, Math.min(1, t));
|
||
const cx = e.ax + dx * t, cz = e.az + dz * t;
|
||
return { d: Math.hypot(x - cx, z - cz), cx, cz, ux: dx / L, uz: dz / L, e };
|
||
};
|
||
// THE EDGE A BENCH BELONGS TO IS THE ONE ITS STATION NAMES, not the geometrically nearest one.
|
||
// Measured the hard way: nearest-edge attribution reported one bench at 174.4 degrees off "the
|
||
// road" — a bench standing near a corner is often closer to the CROSS street's centreline than to
|
||
// its own, so it correctly faces its own street and the instrument calls that a 180-degree error.
|
||
// `edgeId` is metadata of the station this instance was matched to BY POSITION; the yaw verdict is
|
||
// still computed from Lane A's road geometry and the instance's own matrix, so it stays
|
||
// non-circular. Both readings are returned so the disagreement is visible rather than tuned away.
|
||
const nearestEdgePoint = (x, z) => {
|
||
let best = null;
|
||
for (const e of edges) { const f = foot(e, x, z); if (!best || f.d < best.d) best = f; }
|
||
return best;
|
||
};
|
||
const cam = P.camera; cam.updateMatrixWorld(true);
|
||
const m = new THREE.Matrix4(), pos = new V3(), q = new THREE.Quaternion(), sc = new V3();
|
||
const fwd = new V3(), v = new V3();
|
||
const rows = [];
|
||
P.scene.traverse(o => {
|
||
if (!o.isInstancedMesh) return;
|
||
o.updateWorldMatrix(true, false);
|
||
for (let i = 0; i < o.count; i++) {
|
||
o.getMatrixAt(i, m); m.premultiply(o.matrixWorld); m.decompose(pos, q, sc);
|
||
// POSITION-ONLY identification: is there a bench station under this instance?
|
||
let hit = null;
|
||
for (const s of stops) { const d = Math.hypot(s.x - pos.x, s.z - pos.z); if (d < 0.02) { hit = s; break; } }
|
||
if (!hit) continue;
|
||
fwd.set(0, 0, 1).applyQuaternion(q).normalize(); // the bench FRONT in world space
|
||
const own = byId.has(hit.edgeId) ? foot(byId.get(hit.edgeId), pos.x, pos.z) : nearestEdgePoint(pos.x, pos.z);
|
||
const ne = nearestEdgePoint(pos.x, pos.z);
|
||
const ang = (f) => {
|
||
const toRoad = new V3(f.cx - pos.x, 0, f.cz - pos.z).normalize();
|
||
return Math.acos(Math.max(-1, Math.min(1, fwd.dot(toRoad)))) * 180 / Math.PI;
|
||
};
|
||
const along = new V3(own.ux, 0, own.uz);
|
||
const degTo = ang(own), degNear = ang(ne);
|
||
const degAlong = Math.acos(Math.max(-1, Math.min(1, Math.abs(fwd.dot(along))))) * 180 / Math.PI;
|
||
v.copy(pos); v.y = 1.0; v.project(cam);
|
||
rows.push({ x: +pos.x.toFixed(2), z: +pos.z.toFixed(2), edgeId: hit.edgeId, side: hit.side,
|
||
offset: +own.d.toFixed(2), nearOffset: +ne.d.toFixed(2), nearIsOwn: ne.e.id === hit.edgeId,
|
||
toRoadDeg: +degTo.toFixed(1), nearDeg: +degNear.toFixed(1), alongDeg: +degAlong.toFixed(1),
|
||
camDist: +Math.hypot(pos.x - cam.position.x, pos.z - cam.position.z).toFixed(1),
|
||
inFrame: !(v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) });
|
||
}
|
||
});
|
||
// THE CONTROL, in the same evaluate: shift every station 3 m and re-identify. "coincides" has to
|
||
// be a measurement, or the 2 cm match above is just "there was an instanced mesh somewhere".
|
||
let ctrl = 0;
|
||
P.scene.traverse(o => {
|
||
if (!o.isInstancedMesh) return;
|
||
o.updateWorldMatrix(true, false);
|
||
for (let i = 0; i < o.count; i++) {
|
||
o.getMatrixAt(i, m); m.premultiply(o.matrixWorld); m.decompose(pos, q, sc);
|
||
for (const s of stops) if (Math.hypot(s.x + 3 - pos.x, s.z + 3 - pos.z) < 0.02) { ctrl++; break; }
|
||
}
|
||
});
|
||
// …and choose the camera for the DEDICATED bench frame while the geometry is still in hand.
|
||
// A SIDE-ON view is the one that reads. Standing on the bench's own front line (which is what the
|
||
// street cluster search happens to pick) shows a person facing the camera, and a person facing the
|
||
// camera looks identical whichever way the bench underneath them is turned. In profile, with the
|
||
// road on one side and the shopfront on the other, "the sitter faces the road" is visible.
|
||
let pick = null;
|
||
for (const c of P.citizens.activeCitizens()) {
|
||
if (!c.bench || !c.actor || c.actorKind !== 'rig') continue;
|
||
for (const r of rows) {
|
||
const d = Math.hypot(r.x - c.x, r.z - c.z);
|
||
if (d < 1.2 && (!pick || d < pick.d)) pick = { d, r };
|
||
}
|
||
}
|
||
let shot = null;
|
||
if (pick) {
|
||
const e = byId.get(pick.r.edgeId);
|
||
const f = e ? foot(e, pick.r.x, pick.r.z) : null;
|
||
const toRoad = f ? new V3(f.cx - pick.r.x, 0, f.cz - pick.r.z).normalize() : new V3(0, 0, 1);
|
||
const side = new V3(-toRoad.z, 0, toRoad.x); // 90 deg to the bench front
|
||
shot = { px: +(pick.r.x + side.x * 5.0 + toRoad.x * 2.4).toFixed(3),
|
||
pz: +(pick.r.z + side.z * 5.0 + toRoad.z * 2.4).toFixed(3),
|
||
ax: +(pick.r.x + toRoad.x * 0.3).toFixed(3),
|
||
az: +(pick.r.z + toRoad.z * 0.3).toFixed(3),
|
||
bench: pick.r, sitterDist: +pick.d.toFixed(2) };
|
||
}
|
||
return { stations: stops.length, matched: rows.length, control: ctrl, rows, shot };
|
||
}
|
||
"""
|
||
|
||
# Place the camera the BENCH_MEASURE pick chose, then measure the figures that land in it — in the
|
||
# same beat, for the same reason the street search does: a bench somebody has just got up from is the
|
||
# frame you actually shoot otherwise.
|
||
POSE = r"""
|
||
({ px, pz, ax, az, eye, ly }) => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
P.player.teleport(px, pz, 0);
|
||
P.camera.position.set(px, eye == null ? 1.55 : eye, pz);
|
||
P.camera.lookAt(new V3(ax, ly == null ? 1.05 : ly, az));
|
||
P.chunks.warmup(P.camera.position);
|
||
P.camera.updateMatrixWorld(true);
|
||
const names = (P.fleet.all || []).map(r => r.pedName);
|
||
const cam = P.camera, v = new V3(), rows = [];
|
||
for (const c of P.citizens.activeCitizens()) {
|
||
if (!c.actor || c.actorKind !== 'rig') continue;
|
||
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
|
||
if (d < 1.0 || d > 26) continue;
|
||
v.set(c.x, 1.0, c.z).project(cam);
|
||
if (v.z > 1 || Math.abs(v.x) > 0.92 || Math.abs(v.y) > 0.92) 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; } });
|
||
const posedNow = c.loiter > 0;
|
||
rows.push({ id: c.id, d: +d.toFixed(1), ped: names[c.pedIndex] || '?',
|
||
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow ? 'idle' : 'walk'),
|
||
clip: ((c.actor.mixer && c.actor.mixer._actions) || []).filter(x => x.getEffectiveWeight() > 0.5)
|
||
.map(x => x.getClip().name)[0] || null,
|
||
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
||
}
|
||
rows.sort((a, b) => a.d - b.d);
|
||
return { rows, info: window.DBG.info(), sitters: rows.filter(r => r.state === 'bench-sit').length };
|
||
}
|
||
"""
|
||
|
||
# ── interiors ────────────────────────────────────────────────────────────────────────────────────
|
||
# Same as R41's INT_FRAME, plus a `from: 'door'` arm: stand at Lane C's own `room.spawn` — the point
|
||
# the player is put down on when the door opens — and look at the target from there. R41 shot the
|
||
# booth from 2.5 m at eye 1.45 (a product photo); the fault John reported was about the READ from
|
||
# where you walk in, so the after has to answer that question in the frame that asks it.
|
||
INT_FRAME = r"""
|
||
async ({ target, back, eye, from }) => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
const room = P.interiorMode.current;
|
||
if (!room) return { ok: false, why: 'no room open' };
|
||
const sp = room.spawn || { x: 0, z: 0 };
|
||
let tx, tz, ty = 1.15;
|
||
if (target === 'people') {
|
||
const figs = ((P.interiorMode.keepers || {}).keepers || []).map(k => k.actor.fig);
|
||
if (!figs.length) return { ok: false, why: 'no figures in the room' };
|
||
tx = 0; tz = 0; for (const f of figs) { tx += f.position.x; tz += f.position.z; }
|
||
tx /= figs.length; tz /= figs.length;
|
||
} else if (target === 'centre') {
|
||
tx = 0; tz = -room.dims.D * 0.12; ty = 1.05;
|
||
} else {
|
||
let hit = null;
|
||
room.group.traverse(o => {
|
||
if (hit) return;
|
||
const u = o.userData || {};
|
||
if (target === 'djBooth' && u.djBooth) hit = o;
|
||
});
|
||
if (!hit) return { ok: false, why: `no ${target} in this room` };
|
||
const THREE = await import('three');
|
||
const c = new THREE.Box3().setFromObject(hit).getCenter(new V3());
|
||
tx = c.x; tz = c.z; ty = Math.max(0.95, c.y);
|
||
}
|
||
let px, pz;
|
||
if (from === 'door') { px = sp.x; pz = sp.z; } // the player's real view
|
||
else {
|
||
let dx = sp.x - tx, dz = sp.z - tz; const L = Math.hypot(dx, dz) || 1;
|
||
dx /= L; dz /= L;
|
||
const d = Math.min(Math.max(back, 1.9), Math.max(L, back));
|
||
px = tx + dx * d; pz = tz + dz * d;
|
||
}
|
||
P.camera.position.set(px, eye, pz);
|
||
P.camera.lookAt(new V3(tx, ty, tz));
|
||
P.camera.updateMatrixWorld(true);
|
||
P.renderer.info.reset();
|
||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+tx.toFixed(2), +tz.toFixed(2)],
|
||
spawn: [+sp.x.toFixed(2), +sp.z.toFixed(2)],
|
||
standoff: +Math.hypot(px - tx, pz - tz).toFixed(2),
|
||
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles,
|
||
shop: P.interiorMode.shop, dims: room.dims };
|
||
}
|
||
"""
|
||
|
||
# Lane D's §42.4 measure, adopted whole: the gig crew AND the keepers, each resolved to its body.
|
||
ROOM_MEASURE = r"""
|
||
() => {
|
||
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
||
const names = (P.fleet.all || []).map(r => r.pedName);
|
||
const cam = P.camera; cam.updateMatrixWorld(true);
|
||
const v = new V3(); const rows = [];
|
||
const add = (a, kind, role, pedIndex) => {
|
||
if (!a || !a.inner) { rows.push({ state: kind, role, ped: 'placeholder', h: 0, stature: 0, inFrame: false }); return; }
|
||
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; } });
|
||
v.set(a.fig.position.x, 1.0, a.fig.position.z).project(cam);
|
||
rows.push({ state: kind, role, ped: (pedIndex != null && names[pedIndex]) || '?',
|
||
h: +(a.height || a.nominalHeight || 0).toFixed(3), stature: +(hi - lo).toFixed(3),
|
||
clip: ((a.mixer && a.mixer._actions) || []).filter(x => x.getEffectiveWeight() > 0.5)
|
||
.map(x => x.getClip().name)[0] || null,
|
||
inFrame: !(v.z > 1 || Math.abs(v.x) > 0.92 || Math.abs(v.y) > 0.92) });
|
||
};
|
||
const crew = P.interiorMode.crew;
|
||
if (crew) for (const m of crew.members) add(m.actor, m.part === 'band' ? 'band' : 'crowd', m.role, m.pedIndex);
|
||
for (const k of ((P.interiorMode.keepers || {}).keepers || []))
|
||
add(k.actor, k.browse ? 'browser' : 'keeper', k.type, k.pedIndex);
|
||
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 line — height-RELATIVE (D's R41 finding: a 1.32 m wall-leaner is not a defect, a 2.4 m
|
||
anything is, and a body folded to hip height is the R16 failure this exists to catch)."""
|
||
out, bad = [], []
|
||
for r in rows:
|
||
if r.get('ped') == 'placeholder': continue
|
||
h = r.get('h') or 0
|
||
ratio = (r['stature'] / h) if h else None
|
||
who = r.get('id', r.get('state'))
|
||
if r['stature'] > HI: bad.append(f'{who} GIANT {r["stature"]}m')
|
||
elif h and r['stature'] < 0.55 * h: bad.append(f'{who} FOLDED {r["stature"]}m of {h}m')
|
||
elif h and not (LO <= h <= HI): bad.append(f'{who} nominal {h}m out of [{LO},{HI}]')
|
||
out.append(f"{r['state']}" + (f"[{r.get('clip') or r.get('ped')}]" if (r.get('clip') or r.get('ped')) else '')
|
||
+ f" {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 (R10) — {len(out)} figure(s), stature (feet→crown) measured off the "
|
||
f"posed skeleton: {' · '.join(out)}. {verdict}."), not bad
|
||
|
||
|
||
def cast_line(rows, label):
|
||
"""Lane D's §42.4 idea, adopted. A JPEG cannot say 'the luchador is gone'; this can."""
|
||
peds = [r['ped'] for r in rows if r.get('ped') and r['ped'] != 'placeholder']
|
||
seen = {}
|
||
for p in peds: seen[p] = seen.get(p, 0) + 1
|
||
banned = sorted(set(peds) & set(RETIRED))
|
||
return (f"CAST ({label}) — {len(peds)} figure(s) wearing {len(seen)} distinct bodies: "
|
||
+ ', '.join(f'{k}x{v}' for k, v in sorted(seen.items())) + '. '
|
||
+ ('PERIOD LAW (ruling 1): none of the five retired bodies '
|
||
f'({", ".join(RETIRED)}) is in this frame.'
|
||
if not banned else f'*** RETIRED BODY IN FRAME: {banned} ***')), not banned
|
||
|
||
|
||
def write_shot(pg, name, caption, rows=None, cast_label=None, capture=True):
|
||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||
if capture:
|
||
pg.screenshot(path=str(OUTDIR / f'{name}.jpg'), type='jpeg', quality=92)
|
||
txt = caption
|
||
if rows is not None:
|
||
line, ok = human_line(rows)
|
||
txt += '\n\n' + line
|
||
if not ok: FAIL(f'{name}: {line}')
|
||
cl, cok = cast_line(rows, cast_label or name)
|
||
txt += '\n\n' + cl
|
||
if not cok: FAIL(f'{name}: {cl}')
|
||
(OUTDIR / f'{name}.txt').write_text(txt + '\n')
|
||
OK(f'{name}.jpg + .txt')
|
||
|
||
|
||
def pair(name, before_name, title, before_cap, after_cap):
|
||
"""Composite the committed R41 original beside today's frame. `pipeline/montage.py` is Lane E's
|
||
tool and this is exactly what it was written for (§42 notes) — no second copy of it here."""
|
||
b = BEFORE / f'{before_name}.jpg'
|
||
a = OUTDIR / f'{name}.jpg'
|
||
if not b.exists(): FAIL(f'{name}_ab: the committed R41 before is missing at {b}'); return
|
||
if not a.exists(): FAIL(f'{name}_ab: no after frame to pair'); return
|
||
out = OUTDIR / f'{name}_ab.jpg'
|
||
r = subprocess.run([sys.executable, str(ROOT / 'pipeline' / 'montage.py'), str(out),
|
||
'--cols', '2', '--width', '640', '--title', title,
|
||
f'{b}:{before_cap}', f'{a}:{after_cap}'], capture_output=True, text=True)
|
||
if r.returncode or not out.exists():
|
||
FAIL(f'{name}_ab: montage failed — {r.stdout.strip()} {r.stderr.strip()[:200]}')
|
||
else:
|
||
OK(f'{name}_ab.jpg (R41 committed original | R42 same recipe)')
|
||
|
||
|
||
def new_page(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)))
|
||
return b, pg, errs
|
||
|
||
|
||
def boot(pg, query):
|
||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
|
||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
pg.wait_for_function('() => window.PROCITY.fleet && window.PROCITY.fleet.ready', timeout=25000)
|
||
|
||
|
||
def enter_best(pg, want_type=None, need=None, tries=10):
|
||
js = r"""
|
||
async ({ want, need, tries }) => {
|
||
const P = window.PROCITY, D = window.DBG, C = P.citizens;
|
||
const PREF = ['record', 'opshop', 'book', 'video', 'stall', 'pawn'];
|
||
const shops = (P.plan.shops || []).filter(s => !want || s.type === want)
|
||
.map(s => ({ id: s.id, name: s.name, type: s.type, occ: (C.occupancyOf(s.id) || {}).count || 0 }))
|
||
.sort((a, b) => (b.occ - a.occ) || ((PREF.indexOf(a.type) + 9) % 9) - ((PREF.indexOf(b.type) + 9) % 9));
|
||
const has = (room) => {
|
||
if (!room) return false;
|
||
if (need === 'djBooth') { let f = false; room.group.traverse(o => { if ((o.userData || {}).djBooth) f = true; }); return f; }
|
||
return true;
|
||
};
|
||
let best = null;
|
||
for (const s of shops.slice(0, tries)) {
|
||
D.enterShop(s.id);
|
||
await new Promise(r => setTimeout(r, 2600));
|
||
const room = P.interiorMode.current;
|
||
const ks = ((P.interiorMode.keepers || {}).keepers) || [];
|
||
if (room && has(room)) {
|
||
best = { ok: true, shop: s, tried: shops.length, keepers: ks.length,
|
||
fittings: (room.placement || []).length };
|
||
return best;
|
||
}
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 350));
|
||
}
|
||
return { ok: false, why: `no ${want || 'shop'} satisfied "${need}"`, tried: shops.length };
|
||
}
|
||
"""
|
||
return pg.evaluate(js, dict(want=want_type, need=need, tries=tries))
|
||
|
||
|
||
# ── 1. THE PUB ────────────────────────────────────────────────────────────────────────────────────
|
||
def shot_pub(p):
|
||
head('1. pub_furnished — R41\'s hero, re-shot: dressed props, and a cast with no luchador and no nude')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
|
||
pg.evaluate("() => window.DBG.setSegment(5)")
|
||
pg.wait_for_timeout(1500)
|
||
r = enter_best(pg, 'pub', None)
|
||
if not r.get('ok'):
|
||
FAIL(f"pub_furnished: {r.get('why')}"); return
|
||
pg.wait_for_timeout(1800) # let the gig crew resolve its bodies before the cast is read
|
||
fr = pg.evaluate(INT_FRAME, {'target': 'centre', 'back': 5.2, 'eye': 1.62, 'from': 'standoff'})
|
||
if not fr.get('ok'): FAIL(f"pub_furnished: {fr.get('why')}"); return
|
||
m = pg.evaluate(ROOM_MEASURE)
|
||
inframe = [x for x in m['rows'] if x.get('inFrame')]
|
||
note(f"{r['shop']['name']} · {r['fittings']} fittings · {fr['draws']} draws / {fr['tris']} tris · "
|
||
f"{len(m['rows'])} figures ({len(inframe)} in frame)")
|
||
cap = (f"PROCITY R42 §42.6 — pub_furnished (AFTER)\n"
|
||
f"seed {SEED}, {r['shop']['name']} (pub), segment 5, ?gigs=1 — the SAME recipe as "
|
||
f"docs/shots/laneF_r41/pub_furnished.jpg (Lane C's room.spawn line, back 5.2 m, eye 1.62 m), "
|
||
f"so the pair differs only by what landed this round.\n"
|
||
f"WHAT CHANGED: Lane E §42.1 repainted 46 props in place via a COLOR_0 rewrite (draw delta "
|
||
f"+0, tri delta +0, every POSITION/NORMAL/index accessor sha1-identical to R41) — the white "
|
||
f"blobs are brown vinyl and beer-carpet red because they always carried vertex colour and it "
|
||
f"was bleached to 0.02 saturation, not missing. Lane E §42.3 + Lane D §42.4 swapped five "
|
||
f"bodies at their own index: the purple EL CHUPACABRA luchador and the UNCLOTHED dj_phrtt_01 "
|
||
f"that stood at the stage edge in the before are both gone from the fleet.\n"
|
||
f"{r['fittings']} fittings · {fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris) "
|
||
f"· camera {fr['cam']} → {fr['target']}.")
|
||
write_shot(pg, 'pub_furnished', cap, m['rows'], cast_label='the whole room')
|
||
if errs: FAIL(f'pub_furnished: {len(errs)} console error(s): {errs[:2]}')
|
||
finally:
|
||
b.close()
|
||
pair('pub_furnished', 'pub_furnished',
|
||
'PROCITY R42 §42.6 — THE PUB: R41 as committed, and the same frame today',
|
||
'BEFORE (R41, committed): white plastic-looking props; TWO purple EL CHUPACABRA luchadors; '
|
||
'an unclothed body at the stage edge.',
|
||
'AFTER (R42): the props are painted (E §42.1, +0 draws), and every body in the room is a '
|
||
'person who could stand on a 1996 Sydney street (E §42.3 + D §42.4).')
|
||
|
||
|
||
# ── 2. THE BOOTH ──────────────────────────────────────────────────────────────────────────────────
|
||
def shot_booth(p):
|
||
head('2. record_dj_booth — R41\'s recipe re-shot, AND the frame R41 never took (from the door)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'weather=0&magpie=0&washing=0')
|
||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||
pg.wait_for_timeout(1200)
|
||
r = enter_best(pg, 'record', 'djBooth')
|
||
if not r.get('ok'):
|
||
FAIL(f"record_dj_booth: {r.get('why')}"); return
|
||
# a) the PAIR arm — R41's exact camera recipe, so the before/after is a pair
|
||
fr = pg.evaluate(INT_FRAME, {'target': 'djBooth', 'back': 2.5, 'eye': 1.45, 'from': 'standoff'})
|
||
if not fr.get('ok'): FAIL(f"record_dj_booth: {fr.get('why')}"); return
|
||
m = pg.evaluate(ROOM_MEASURE)
|
||
note(f"pair arm: {r['shop']['name']} · {fr['draws']} draws / {fr['tris']} tris · "
|
||
f"camera {fr['cam']} → {fr['target']} (standoff {fr['standoff']} m)")
|
||
write_shot(pg, 'record_dj_booth',
|
||
f"PROCITY R42 §42.6 — record_dj_booth (AFTER, R41's own camera recipe)\n"
|
||
f"seed {SEED}, {r['shop']['name']} (record), midday. Camera derived exactly as in R41: "
|
||
f"2.5 m off the booth group's bounding-box centre along Lane C's room.spawn line, "
|
||
f"eye 1.45 m — so this is a PAIR with docs/shots/laneF_r41/record_dj_booth.jpg.\n"
|
||
f"WHAT CHANGED: Lane E §42.2a repainted the deck chassis Technics silver (#b9bdc2) — "
|
||
f"contrast against the booth bench went 1.21:1 → 6.74:1 — and Lane C §42.2b composed the "
|
||
f"booth (bench 3 → 7 boxes, a record bay, headphones, and the booth turned to face the "
|
||
f"door: entry-line alignment 49% → 92%). Near-black pixels inside the booth's own screen "
|
||
f"rect: 57.5% → 26.7%.\n"
|
||
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris) · camera {fr['cam']} "
|
||
f"→ {fr['target']}.", m['rows'] if m['rows'] else None, cast_label='the record shop')
|
||
# b) the DOOR arm — the frame the fault was actually reported about
|
||
fr2 = pg.evaluate(INT_FRAME, {'target': 'djBooth', 'back': 0, 'eye': 1.62, 'from': 'door'})
|
||
if not fr2.get('ok'): FAIL(f"record_dj_booth_door: {fr2.get('why')}"); return
|
||
m2 = pg.evaluate(ROOM_MEASURE)
|
||
note(f"door arm: spawn {fr2['spawn']} → booth {fr2['target']} ({fr2['standoff']} m) · "
|
||
f"{fr2['draws']} draws / {fr2['tris']} tris")
|
||
write_shot(pg, 'record_dj_booth_door',
|
||
f"PROCITY R42 §42.6 — record_dj_booth_door (THE PLAYER'S REAL VIEW)\n"
|
||
f"seed {SEED}, {r['shop']['name']} (record), midday. The camera stands at Lane C's own "
|
||
f"`room.spawn` — the exact spot the player is put down on when the door opens — at eye "
|
||
f"height 1.62 m, looking at the booth: {fr2['standoff']} m away, spawn {fr2['spawn']} → "
|
||
f"booth {fr2['target']}.\n"
|
||
f"R41 shot this booth from 2.5 m at eye 1.45 m, which is a product photograph. The fault "
|
||
f"John reported — 'it reads as a plain black box' — is a claim about the ENTRY view, so "
|
||
f"this is the frame that answers it. C §42.2b moved the booth to face this line "
|
||
f"(alignment 49% → 92%).\n"
|
||
f"{fr2['draws']} draws of the ≤350 interior law ({fr2['tris']} tris).",
|
||
m2['rows'] if m2['rows'] else None, cast_label='the record shop')
|
||
if errs: FAIL(f'record_dj_booth: {len(errs)} console error(s): {errs[:2]}')
|
||
finally:
|
||
b.close()
|
||
pair('record_dj_booth', 'record_dj_booth',
|
||
'PROCITY R42 §42.6 — THE DJ BOOTH: R41 as committed, and the same camera today',
|
||
'BEFORE (R41, committed): the rigged 1200 and the TTM-54i are in there, and at normal '
|
||
'viewing distance you cannot tell it is a turntable setup.',
|
||
'AFTER (R42): silver chassis (E §42.2a, 1.21:1 → 6.74:1) in a composed booth (C §42.2b) — '
|
||
'near-black pixels in the booth crop 57.5% → 26.7%.')
|
||
|
||
|
||
# ── 3. THE STREET ─────────────────────────────────────────────────────────────────────────────────
|
||
def shot_street(p):
|
||
head('3. street_postures — the recast crowd, and Lane B\'s benches now facing the road')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'weather=0&magpie=0&washing=0')
|
||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
|
||
# ── THE PAIR FRAME: stand on the BEFORE's own published camera ──────────────────────────────
|
||
# The first cut re-ran R41's cluster search and got a different spot each time, because the
|
||
# search scores a LIVE crowd — one run landed within 40 cm of R41's camera and the next landed
|
||
# 30 m away behind a wall. Re-rolling until it looks right is not a method. The pose is parsed
|
||
# out of the committed R41 sidecar (a magic number I typed would be a different claim), so the
|
||
# AFTER stands exactly where the BEFORE stood and the only variable in the pair is the tree.
|
||
m41 = re.search(r'camera \(([-\d.]+), ([-\d.]+)\) looking at \(([-\d.]+), ([-\d.]+)\)',
|
||
(BEFORE / 'street_postures.txt').read_text())
|
||
if not m41:
|
||
FAIL('street_postures: could not parse the R41 camera out of its committed sidecar'); return
|
||
px, pz, ax, az = (float(g) for g in m41.groups())
|
||
note(f'R41 published camera, parsed from its sidecar: ({px}, {pz}) → ({ax}, {az})')
|
||
sp = pg.evaluate(POSE, dict(px=px, pz=pz, ax=ax, az=az, eye=1.62, ly=1.25))
|
||
pg.wait_for_timeout(1500)
|
||
sp = pg.evaluate(POSE, dict(px=px, pz=pz, ax=ax, az=az, eye=1.62, ly=1.25))
|
||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||
pg.screenshot(path=str(OUTDIR / 'street_postures.jpg'), type='jpeg', quality=92)
|
||
states = sorted({r['state'] for r in sp['rows']})
|
||
clips = sorted({r['clip'] for r in sp['rows'] if r['clip']})
|
||
bodies = sorted({r['ped'] for r in sp['rows']})
|
||
cs = pg.evaluate("() => window.PROCITY.citizens.clipStats()")
|
||
note(f"{len(sp['rows'])} rigs in frame · states {states} · clips {clips} · bodies {bodies} · "
|
||
f"{sp['info']['drawCalls']} draws / {sp['info']['tris']} tris")
|
||
write_shot(pg, 'street_postures',
|
||
f"PROCITY R42 §42.6 — street_postures (AFTER)\n"
|
||
f"seed {SEED}, midday (seg 2). Camera ({px}, {pz}) → ({ax}, {az}) — **R41's own published "
|
||
f"pose**, parsed out of docs/shots/laneF_r41/street_postures.txt rather than re-derived. "
|
||
f"R41 chose it with a live-crowd cluster search; re-running that search on a live crowd "
|
||
f"lands somewhere different every time, so the AFTER stands where the BEFORE stood and the "
|
||
f"only variable in the pair is the tree.\n"
|
||
f"{len(sp['rows'])} rigs in frame · posture states {states} · {len(clips)} distinct clips "
|
||
f"({', '.join(clips)}) · {len(bodies)} distinct bodies · {sp['info']['drawCalls']} draws of "
|
||
f"the ≤300 street law ({sp['info']['tris']} tris) · clip bank {cs}.\n"
|
||
f"R41 read 173 draws / 43,562 tris at this pose. The recast is draw-neutral by "
|
||
f"construction (one primitive, one material per ped — Lane D §42.4); the 802-station pin "
|
||
f"reads 0/0 and a single bookmark moves ±1 run to run.\n"
|
||
f"NB the rows below count NEAR-TIER RIGS only (R41's convention, kept). A citizen far "
|
||
f"enough out to be drawn as a mid-tier billboard has no skeleton and no posture to read, "
|
||
f"so a seated figure visible in the picture can be absent from the list — that is what "
|
||
f"street_bench.jpg is for.",
|
||
sp['rows'], cast_label='the street', capture=False)
|
||
|
||
# ── THE BENCH FRAME: found by polling, shot in the same beat ────────────────────────────────
|
||
STOPS = ['crossroads_busy', 'street_noon', 'crossroads_busy', 'patronage_door', 'night_crowd']
|
||
best, bench_shot = None, None
|
||
for i in range(72):
|
||
if i % 12 == 0:
|
||
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
|
||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||
pg.wait_for_timeout(900)
|
||
r = pg.evaluate(STREET_LOOK)
|
||
if not r.get('ok'):
|
||
pg.wait_for_timeout(600); continue
|
||
if not best or r['score2'] > best['score2']: best = r
|
||
# THE BENCH FRAME, TAKEN IN THE SAME BEAT IT IS FOUND. The first cut computed the pose
|
||
# here and shot it after the loop, and came back with 0 figures in frame: the dwell clock
|
||
# runs, the sitter gets up, and you photograph an empty bench with a caption about a
|
||
# sitter. Measure, pose, capture, re-measure — then move on. (This is the R41 tour's own
|
||
# lesson applied one level down; it cost that harness two frames before it was learned.)
|
||
if not bench_shot and r['anchor'].startswith('bench-sit'):
|
||
bm = pg.evaluate(BENCH_MEASURE)
|
||
if bm.get('shot'):
|
||
bp = pg.evaluate(POSE, dict(bm['shot'], eye=1.55, ly=1.05))
|
||
pg.wait_for_timeout(350)
|
||
if bp['sitters']:
|
||
pg.screenshot(path=str(OUTDIR / 'street_bench.jpg'), type='jpeg', quality=92)
|
||
bench_shot = dict(bm=bm, bp=bp, sh=bm['shot'])
|
||
# whether or not it worked, the pose moved the player — put the search back where
|
||
# it was so the next poll is not measuring a different street.
|
||
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
|
||
pg.evaluate("() => window.DBG.setSegment(2)")
|
||
pg.wait_for_timeout(700)
|
||
if len(r['states']) >= 3 and len(r['clips']) >= 3 and r['inFrame'] >= 4 and bench_shot: break
|
||
pg.wait_for_timeout(450)
|
||
if not bench_shot:
|
||
FAIL('street_bench: no citizen sat on a real Lane B bench in 72 polls — no bench frame')
|
||
else:
|
||
bm, bp, sh = bench_shot['bm'], bench_shot['bp'], bench_shot['sh']
|
||
inf = [x for x in bm['rows'] if x['inFrame']]
|
||
deg = sorted(x['toRoadDeg'] for x in bm['rows'])
|
||
along = sorted(x['alongDeg'] for x in bm['rows'])
|
||
if not bm['rows']:
|
||
FAIL('street_bench: no bench instance matched a benchStops() station within 2 cm')
|
||
elif bm['control'] != 0:
|
||
FAIL(f"street_bench: the 3 m-offset control matched {bm['control']} instance(s) — "
|
||
f"the 2 cm identification is not a measurement")
|
||
else:
|
||
mid = deg[len(deg) // 2]
|
||
corners = [x for x in bm['rows'] if not x['nearIsOwn']]
|
||
note(f"bench yaw: {len(bm['rows'])} of {bm['stations']} stations matched · front-vs-OWN-road "
|
||
f"min {deg[0]}° / med {mid}° / max {deg[-1]}° · front-vs-street med "
|
||
f"{along[len(along) // 2]}° · control 0 · {len(corners)} bench(es) sit nearer a CROSS "
|
||
f"street than their own (max nearest-edge reading {max((x['nearDeg'] for x in corners), default=0)}°)")
|
||
if deg[-1] > 5.0:
|
||
FAIL(f'street_bench: a bench front is {deg[-1]}° off its own road — B\'s §42.5-A fix is '
|
||
f'not what this scene is drawing')
|
||
note(f"bench frame: camera ({sh['px']}, {sh['pz']}) → ({sh['ax']}, {sh['az']}) — side-on, "
|
||
f"{bp['sitters']} sitter(s) and {len(bp['rows'])} figure(s) in frame · "
|
||
f"{bp['info']['drawCalls']} draws")
|
||
write_shot(pg, 'street_bench',
|
||
f"PROCITY R42 §42.6 — street_bench (Lane B §42.5-A, measured in this frame)\n"
|
||
f"seed {SEED}, midday. Camera ({sh['px']}, {sh['pz']}) → ({sh['ax']}, {sh['az']}) — "
|
||
f"5 m to the SIDE of the bench and 2.4 m out into the carriageway, deliberately NOT the "
|
||
f"cluster search's pose: that one stands on the bench's own front line, and a person "
|
||
f"facing the camera looks identical whichever way the bench under them is turned. In "
|
||
f"profile, 'the sitter faces the road' is something you can see.\n"
|
||
f"THE MEASUREMENT, and it is not B's arithmetic read back: bench instances are "
|
||
f"identified by POSITION ONLY (a `benchStops(plan)` station within 2 cm — CONTROL: the "
|
||
f"same stations offset 3 m match {bm['control']} of {bm['matched']}), and then the "
|
||
f"angle is computed between the instance's own world +Z and the foot of the "
|
||
f"perpendicular onto Lane A's road centreline for the edge that station names. "
|
||
f"Neither input is the placement rule.\n"
|
||
f"{bm['matched']} of {bm['stations']} town-wide stations are standing in the streamed "
|
||
f"window ({len(inf)} in this frame). Bench FRONT vs the direction to the road: "
|
||
f"min {deg[0]}° · median {deg[len(deg) // 2]}° · max {deg[-1]}°. Front vs the street's "
|
||
f"own direction: median {along[len(along) // 2]}°. In R41 those two were 90° and 0°.\n"
|
||
f"ONE INSTRUMENT NOTE, recorded rather than tuned away: {len([x for x in bm['rows'] if not x['nearIsOwn']])} "
|
||
f"of {bm['matched']} benches stand nearer a CROSS street's centreline than their own, "
|
||
f"because a station 14 m from a node is close to the corner. Attributing those to the "
|
||
f"geometrically nearest edge reads up to "
|
||
f"{max((x['nearDeg'] for x in bm['rows'] if not x['nearIsOwn']), default=0)}° and is "
|
||
f"WRONG — the bench correctly faces the street it belongs to. This is the same trap "
|
||
f"Lane B named in §42.5-A when they matched instances by expected perpendicular offset.\n"
|
||
f"IN THIS FRAME: {bp['sitters']} citizen(s) on a bench, {len(bp['rows'])} figure(s) "
|
||
f"total, {bp['info']['drawCalls']} draws of the ≤300 street law ({bp['info']['tris']} tris).",
|
||
bp['rows'], cast_label='the bench frame', capture=False)
|
||
if errs: FAIL(f'street: {len(errs)} console error(s): {errs[:2]}')
|
||
finally:
|
||
b.close()
|
||
pair('street_postures', 'street_postures',
|
||
"PROCITY R42 §42.6 — THE STREET: R41 as committed, and its OWN camera today",
|
||
'BEFORE (R41, committed): the R41 roster, and benches standing side-on to the road.',
|
||
"AFTER (R42): the same pose, parsed out of the R41 sidecar. Five bodies recast (E §42.3 / "
|
||
"D §42.4), bench + streetlight yaw fixed (B §42.5-A, 60/60). See street_bench.jpg for the "
|
||
"yaw in profile.")
|
||
|
||
|
||
# ── 4. THE CREDITS ────────────────────────────────────────────────────────────────────────────────
|
||
def shot_credits(p):
|
||
head('4. credits_panel — ruling 3 on screen: the ped rows filled, no `unverified` left on a body')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'plansrc=osm&town=katoomba&weather=0&magpie=0&washing=0')
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(600)
|
||
link = pg.evaluate("() => { const el = document.getElementById('pc-creditlink'); return el ? el.textContent.trim() : null; }")
|
||
pg.keyboard.press('F2')
|
||
pg.wait_for_timeout(1200)
|
||
# scroll to the ped rows — the panel is long and the rows this round filled are the point
|
||
pg.evaluate(r"""() => {
|
||
const el = document.getElementById('pc-credits'); if (!el) return;
|
||
const box = el.querySelector('*') && el;
|
||
const walk = (n) => { for (const c of n.querySelectorAll('*'))
|
||
if (/character roster/i.test(c.textContent || '') && c.children.length < 6) return c; return null; };
|
||
const t = walk(el); if (t) t.scrollIntoView({ block: 'center' });
|
||
}""")
|
||
pg.wait_for_timeout(400)
|
||
st = pg.evaluate(r"""() => {
|
||
const el = document.getElementById('pc-credits');
|
||
const t = el ? el.innerText : '';
|
||
return { open: !!(el && getComputedStyle(el).display !== 'none'),
|
||
entries: (window.PROCITY.credits && window.PROCITY.credits.count) || null,
|
||
chars: t.length,
|
||
osm: /OpenStreetMap contributors/.test(t), odbl: /ODbL/.test(t),
|
||
roster: /character roster/i.test(t),
|
||
withdrawn: /withdrawn from the roster/i.test(t),
|
||
chIds: /Ch01|Ch49|Ch23|Ch31/.test(t),
|
||
unverifiedCount: (t.match(/unverified/gi) || []).length,
|
||
pedUnverified: /(character roster|DJ five)[^]{0,600}?unverified/i.test(t) }; }""")
|
||
note(f"HUD link {link!r} · panel open {st['open']} · roster row {st['roster']} · withdrawn row "
|
||
f"{st['withdrawn']} · Mixamo Ch ids {st['chIds']} · 'unverified' appears {st['unverifiedCount']}× "
|
||
f"· {st['chars']} chars")
|
||
cap = (f"PROCITY R42 §42.6 — credits_panel (RULING 3, on screen)\n"
|
||
f"seed {SEED}, ?plansrc=osm&town=katoomba — an OSM boot, so the ODbL obligation is live and "
|
||
f"the HUD link carries it: {link!r}. F2 opens Lane B's §41.5 surface.\n"
|
||
f"WHAT CHANGED THIS ROUND: R41 shipped the ped roster as amber `unverified` — B could not "
|
||
f"establish the bodies' provenance from anything in this repo. Lane E §42.3-A answered it "
|
||
f"from INSIDE the assets: all 24 source FBX carry Original|ApplicationVendor = 'Mixamo, Inc.' "
|
||
f"in their own headers, and the four unmerged peds kept Mixamo's numbered character ids in "
|
||
f"their material names (Ch43_Body = the luchador), which recovers the id for the other 20. "
|
||
f"So the row now reads a real licence with per-asset evidence, `required: false` is correct, "
|
||
f"and no attribution is owed. A second row records the four bodies WITHDRAWN on ruling 1 — "
|
||
f"'why is that gone' is a question a credits panel should be able to answer.\n"
|
||
f"On screen: the roster row ({st['roster']}), the withdrawn row ({st['withdrawn']}), the "
|
||
f"Mixamo Ch ids ({st['chIds']}), the © OpenStreetMap contributors credit ({st['osm']}) and "
|
||
f"ODbL 1.0 ({st['odbl']}). 'unverified' still appears {st['unverifiedCount']}x in the panel — "
|
||
f"on the PROPS row (props-house-library, 18 house props), which is a different, still-open "
|
||
f"debt and is deliberately not hidden. NO ped row is unverified: {not st['pedUnverified']}.\n"
|
||
f"Zero draws by construction (DOM); credits.json is fetched on first open, not at boot.")
|
||
write_shot(pg, 'credits_panel', cap)
|
||
if not st['open']: FAIL('credits_panel: the panel did not open on F2')
|
||
if not st['roster']: FAIL('credits_panel: the character-roster row is not on screen')
|
||
if not st['withdrawn']: FAIL('credits_panel: the withdrawn-bodies row is not on screen')
|
||
if st['pedUnverified']: FAIL('credits_panel: a ped row still reads `unverified` — ruling 3 is not met')
|
||
if errs: FAIL(f'credits_panel: {len(errs)} console error(s): {errs[:2]}')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def main():
|
||
srv = serve()
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
with sync_playwright() as p:
|
||
if not ONLY or 'pub' in ONLY: shot_pub(p)
|
||
if not ONLY or 'booth' in ONLY: shot_booth(p)
|
||
if not ONLY or 'street' in ONLY: shot_street(p)
|
||
if not ONLY or 'credits' in ONLY: shot_credits(p)
|
||
finally:
|
||
srv.terminate()
|
||
print('')
|
||
if fails:
|
||
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
|
||
for f in fails: print(f' · {f}')
|
||
return 1
|
||
print(f'\033[32m● PASS\033[0m — the tour is in {OUTDIR}, every humanoid frame carrying its R10 line '
|
||
f'and its CAST line, every pair composited against the committed R41 original')
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|