- pipeline/dump_bird.mjs: imports web/js/world/magpie.js UNMODIFIED (bare 'three' resolved to the repo's vendored build through a node resolve hook) and dumps birdGeometry() — so the A/B is against Lane B's actual bird, not a port. MEASURED 182 tris / 216 verts. --sim walks a player past a territory on magpie.js's own clock: drawn 54.1% of frames, and OF THOSE perched 80.5% / swooping 7.7% / returning 11.8%. - pipeline/bird_to_glb.py: wraps the dump as a GLB in E's frame (head +Z, +Y up) so the identical render_views.py rig shoots both; --fold applies the perch pose (x x 0.42). glb_stat re-measures 182. - pipeline/render_views.py --noemit: strips emission from BOTH candidates. normalize.py's emissiveFactor 0.28 is albedo-MODULATED; a vertex-coloured mesh cannot express that in glTF and Blender writes a flat 0.28 that lifts a black bird to grey. Off both sides or the picture lies. - pipeline/view_sheet.py --ab: the strip as a GRID (one line per candidate, stacked) plus two linear motion-blur blocks at 25% and 100% of the bird's width, and a wrapped notes footer. - docs/shots/laneE/r39_magpie_ab.png — the sheet Fable rules on. Recommendation in LANE_E_NOTES; picture first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
5.2 KiB
JavaScript
96 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
||
// PROCITY Lane E — dump_bird.mjs (R39, the magpie A/B)
|
||
//
|
||
// Dump Lane B's PROCEDURAL magpie geometry, by RUNNING LANE B'S OWN MODULE — not by re-implementing
|
||
// it. `web/js/world/magpie.js` is imported unmodified; the bare `three` / `three/addons/` specifiers
|
||
// its imports use are resolved to the repo's OWN vendored build through a node resolve hook, so the
|
||
// vertices dumped here are byte-for-byte the vertices the browser gets. That matters: an A/B render
|
||
// against a hand-ported strawman proves nothing, and the whole point of R39 item 1 is that Fable
|
||
// rules on the picture.
|
||
//
|
||
// node pipeline/dump_bird.mjs [OUT.json]
|
||
//
|
||
// Writes { tris, verts, position[], normal[], color[], index[]|null } in the mesh's own metric frame
|
||
// (metres, three's +Y up, nose along −Z). `pipeline/bird_to_glb.py` turns it into a GLB so the same
|
||
// `render_views.py` rig that shot E's tinted GLB can shoot B's bird from the same cameras.
|
||
import { registerHooks } from 'node:module';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { writeFileSync } from 'node:fs';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = resolve(HERE, '..');
|
||
const VENDOR = pathToFileURL(resolve(ROOT, 'web/vendor/three.module.js')).href;
|
||
const ADDONS = pathToFileURL(resolve(ROOT, 'web/vendor/addons')).href + '/';
|
||
|
||
// The repo's importmap, as a node resolver: "three" and "three/addons/*" only.
|
||
registerHooks({
|
||
resolve(spec, ctx, next) {
|
||
if (spec === 'three') return { url: VENDOR, shortCircuit: true };
|
||
if (spec.startsWith('three/addons/')) {
|
||
return { url: ADDONS + spec.slice('three/addons/'.length), shortCircuit: true };
|
||
}
|
||
return next(spec, ctx);
|
||
},
|
||
});
|
||
|
||
const { generatePlan } = await import(pathToFileURL(resolve(ROOT, 'web/js/citygen/plan.js')).href);
|
||
const { createMagpie } = await import(pathToFileURL(resolve(ROOT, 'web/js/world/magpie.js')).href);
|
||
|
||
const plan = generatePlan(20261990);
|
||
const scene = { add() {}, remove() {} };
|
||
const camera = { position: { x: 0, y: 1.6, z: 0 } };
|
||
const m = createMagpie({ scene, plan, citySeed: 20261990, townKey: null, camera, chunks: null, lighting: null, force: true });
|
||
const mesh = m.group.children.find((c) => c.isInstancedMesh);
|
||
const g = mesh.geometry;
|
||
|
||
const idx = g.index ? Array.from(g.index.array) : null;
|
||
const pos = Array.from(g.attributes.position.array);
|
||
const nrm = g.attributes.normal ? Array.from(g.attributes.normal.array) : null;
|
||
const col = g.attributes.color ? Array.from(g.attributes.color.array) : null;
|
||
const tris = idx ? idx.length / 3 : pos.length / 9;
|
||
|
||
const out = {
|
||
source: 'web/js/world/magpie.js :: birdGeometry() via createMagpie()',
|
||
tris, verts: pos.length / 3,
|
||
material: { vertexColors: true, roughness: 0.62, metalness: 0, side: 'DoubleSide', wind: 'wing' },
|
||
bbox: (() => {
|
||
g.computeBoundingBox();
|
||
const b = g.boundingBox;
|
||
return { min: [b.min.x, b.min.y, b.min.z], max: [b.max.x, b.max.y, b.max.z],
|
||
size: [b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z] };
|
||
})(),
|
||
// the perch pose is the same geometry squashed in X (magpie.js `place(..., folded)`)
|
||
foldedScaleX: 0.42,
|
||
index: idx, position: pos, normal: nrm, color: col,
|
||
};
|
||
const dst = process.argv.find((a) => a.endsWith('.json')) || resolve(HERE, '_bird_b.json');
|
||
writeFileSync(dst, JSON.stringify(out));
|
||
console.log(`bird: ${tris} triangles, ${out.verts} verts, bbox size ${out.bbox.size.map((v) => v.toFixed(3)).join(' × ')} m → ${dst}`);
|
||
|
||
// ── --sim: WHICH POSE IS THE PLAYER ACTUALLY LOOKING AT? ────────────────────────────────────────
|
||
// The A/B is usually argued as "the swoop is the whole point", but magpie.js's own clock says the
|
||
// swoop is the minority state: COOLDOWN 5.5 s perched against SWOOP_T 1.25 + RETURN_T 1.9 = 3.15 s
|
||
// in the air, and the mesh is drawn out to DEFEND_R × 2.2 = 74.8 m where it can only be perched.
|
||
// So walk a player down the street past a territory at WALK speed and COUNT the frames.
|
||
if (process.argv.includes('--sim')) {
|
||
const t = m.territories[0] || { x: 0, z: 0 };
|
||
const DT = 1 / 60, SPEED = 4.6, OFFSET = 4.0; // WALK m/s, and how far off the perch you pass
|
||
globalThis.window = { PROCITY: { game: { day: 1 } } };
|
||
const tally = {};
|
||
let frames = 0;
|
||
for (let i = 0; i < 60 * 60; i++) { // 60 s of walking
|
||
const s = -140 + i * DT * SPEED; // straight past the perch, 140 m either side
|
||
camera.position.x = t.x + s; camera.position.z = t.z + OFFSET;
|
||
m.update(DT);
|
||
tally[m.state.mode] = (tally[m.state.mode] || 0) + 1;
|
||
if (m.count > 0) frames++;
|
||
}
|
||
const drawn = Object.entries(tally).filter(([k]) => k === 'perched' || k === 'swooping' || k === 'returning');
|
||
const total = drawn.reduce((a, [, v]) => a + v, 0);
|
||
console.log(`sim: 60 s walk at ${SPEED} m/s, ${OFFSET} m off the perch — modes ${JSON.stringify(tally)}`);
|
||
console.log(`sim: bird DRAWN in ${frames} of 3600 frames (${(frames / 36).toFixed(1)}%)`);
|
||
for (const [k, v] of drawn) console.log(`sim: ${k.padEnd(10)} ${v} frames = ${(100 * v / total).toFixed(1)}% of the frames it is on screen`);
|
||
}
|