guts/web/dev/laneA_molecules.html
jing a99f733452 [lane A] MODELBEAST dresses the molecules: 5 matcaps, and the canal picks space-filling
The right axis wasn't "generate or don't" — it was WHICH HALF. Geometry stays
procedural because a molecule's shape is already known exactly. **Material is
exactly a generation problem**, and it's the whole difference between a textbook
diagram and a game object. So MODELBEAST made the materials.

5 greyscale matcaps, flux_local, 69 seconds, $0.00, 68 KB total:
  mol_glass (O,N) — the big win: oxygen is a red glass marble with internal glow
  mol_matte (C)   — graphite soot; carbon reads as the scaffold it is
  mol_chrome (Co) — B12's cobalt is now a chrome bearing in a cage
  mol_molten (P)  — ATP's phosphate tail is incandescent AND pulses (uTime, free)
  mol_pearl (H)   — satin white; hydrogens stop shouting

The trick is Lane D's own law applied to spheres: author the LUMINANCE, tint
in-shader. derive_maps already greyscales every matcap, so one glass ball serves
oxygen AND nitrogen at their own CPK hues — 5 balls dress the whole periodic
table we care about. They pack into one strip atlas indexed per-vertex by aMat,
so **a molecule is still exactly 1 draw call** however many materials it's made
of (7 molecules = 7 draws, unchanged). ?matcap=0 falls back to the built-in
fake-lit path: the assets-optional law holds.

SPACE-FILLING WINS FOR PICKUPS, and the canal decided it, not me. New
`repr: 'space-filling'` draws every atom at its real van der Waals radius with no
sticks. A/B'd at real pickup size against the real L2 wall, same camera:
ball-and-stick goes spindly and dissolves; the solid blob holds its silhouette —
and it's CHEAPER (21k vs 26k tris, no bond cylinders). Recommendation to B:
space-filling in flight, ball-stick for hero/UI/collect close-ups where the
chemistry is worth reading. Evidence: round2_molecule_materials.png (3-way).

Two defects found by rendering it, both mine, both fixed:
- Tint x luminance darkens TWICE: CPK carbon is 0.4 and a matte ball averages
  0.5, so soot x soot vanished — the first pass sank glucose, caffeine and the
  cobalt into the background. Floored the matcap at 0.22, the same floor the
  fallback's key light always had.
- The molten matcap was my own bad prompt: I asked for an "incandescent white hot
  CORE" and got exactly that — a black ball with a hot spot, so phosphorus lost
  its orange. Re-rolled for the whole sphere to glow (attempt 2 of the <=2
  PIPELINE allows). Lesson for the kit, next to the organ/tissue one: a matcap is
  a LUMINANCE LOOKUP, so a dark-dominant ball makes a dark-dominant object.

Also corrected the doc's own framing — v1 said "I'm authorised to burn GPU and
I'm not going to", which was the wrong axis, not just the wrong tone.

qa GREEN; 16/16 texture provenance verified (synced the FIXED batch json up
first this time, so the box couldn't clobber it on the way back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:31:24 +10:00

183 lines
6.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GUTS — Lane A molecule harness</title>
<!--
Lane A's molecule bench (web/dev/, per ruling #5 — dev-only, never shipped).
PROTOTYPE for a cross-lane proposal: see docs/MOLECULES.md and web/js/world/molecule.js.
http://localhost:8145/dev/laneA_molecules.html spin every molecule in a grid
...?mol=atp one molecule, big
...?shots=1 hide the overlay
DBG.one(id) show a single molecule, framed
DBG.grid() back to the grid
DBG.pose(t) deterministic frame at rotation t (rAF is dead in a hidden pane)
DBG.post(name) POST the canvas to pipeline/shot_sink.py (house evidence tool)
-->
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #05070a; }
canvas { display: block; }
#dbg { position: fixed; left: 10px; bottom: 10px; color: #7fdfff;
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
pointer-events: none; white-space: pre; }
</style>
<script type="importmap">
{ "imports": { "three": "../vendor/three.module.js",
"three/addons/": "../vendor/addons/" } }
</script>
</head>
<body>
<div id="dbg"></div>
<script type="module">
import * as THREE from 'three';
import { buildMolecule, listMolecules, createMoleculeMaterial, MOLECULES, buildMatcapAtlas, matcapUrls } from '../js/world/molecule.js';
const p = new URLSearchParams(location.search);
const on = (k) => p.has(k) && p.get(k) !== '0';
const REPR = p.get('repr') || 'ball-stick'; // ?repr=space-filling
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth || 1280, innerHeight || 720);
document.body.appendChild(renderer.domElement);
renderer.info.autoReset = false;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05070a);
const camera = new THREE.PerspectiveCamera(45, (innerWidth || 1280) / (innerHeight || 720), 0.1, 200);
// MODELBEAST's material families, via the documented asset contract. `?matcap=0` forces the
// built-in fake-lit path — that's the assets-optional law under test, not a debug toggle.
let atlas = null;
if (p.get('matcap') !== '0') {
try {
const { createAssets } = await import('../js/core/assets.js');
const assets = await createAssets({ flags: { localassets: true } });
atlas = await buildMatcapAtlas(matcapUrls(assets));
} catch (e) {
console.info('[molbench] no assets, built-in shading —', e.message);
}
}
// ONE material for every molecule on screen — colour AND material family are per-vertex
// (the matcaps live in one strip atlas), so they still batch to a draw apiece.
const material = createMoleculeMaterial({ matcap: atlas });
const root = new THREE.Group();
scene.add(root);
const ids = listMolecules();
let spinners = [];
function clear() {
for (const c of [...root.children]) { root.remove(c); c.geometry.dispose(); }
spinners = [];
}
function grid() {
clear();
const cols = Math.ceil(Math.sqrt(ids.length));
const rows = Math.ceil(ids.length / cols);
const pitch = 2.6;
ids.forEach((id, i) => {
const m = buildMolecule(id, { fit: 1, detail: 2, material, repr: REPR });
m.position.set((i % cols - (cols - 1) / 2) * pitch, -((i / cols | 0) - (rows - 1) / 2) * pitch, 0);
root.add(m); spinners.push(m);
});
camera.position.set(0, 0, cols * pitch * 1.25);
camera.lookAt(0, 0, 0);
}
/** Every molecule at its TRUE relative size, in a row — the "what is this worth" read. */
function trueScale(unit = 0.42, gap = 0.5) {
clear();
const order = [...ids].sort((a, b) => MOLECULES[a].atoms.length - MOLECULES[b].atoms.length);
const built = order.map((id) => {
const m = buildMolecule(id, { unit, detail: 2, material, repr: REPR });
m.geometry.computeBoundingSphere();
return { m, r: m.geometry.boundingSphere.radius };
});
const total = built.reduce((a, b) => a + b.r * 2 + gap, 0) - gap;
let x = -total / 2;
for (const { m, r } of built) {
x += r; m.position.set(x, 0, 0); x += r + gap;
root.add(m); spinners.push(m);
}
camera.position.set(0, 0, total * 0.62); // frame the row, whatever it adds up to
camera.lookAt(0, 0, 0);
return total;
}
function one(id) {
clear();
const m = buildMolecule(id, { fit: 1, detail: 3, material, repr: REPR });
root.add(m); spinners.push(m);
camera.position.set(0, 0, 3.1);
camera.lookAt(0, 0, 0);
return m.userData;
}
if (p.has('mol')) one(p.get('mol')); else if (on('true')) trueScale(); else grid();
const dbgEl = document.getElementById('dbg');
if (on('shots')) dbgEl.style.display = 'none';
window.DBG = {
scene, camera, renderer, root, MOLECULES,
one, grid, trueScale, ids,
size(w = 1280, h = 720) {
renderer.setPixelRatio(1);
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
return [renderer.domElement.width, renderer.domElement.height];
},
/** Deterministic frame: every molecule at rotation t. The pane runs tabs hidden, so rAF
* never fires and nothing renders unless we ask — same trap as the world harness. */
pose(t = 0.6, w = 1280, h = 720) {
this.size(w, h);
for (const m of spinners) { m.rotation.set(t * 0.55, t, t * 0.2); }
material.uniforms.uTime.value = t;
renderer.render(scene, camera);
const r = { draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
renderer.info.reset();
return r;
},
post(name, port = 8144) {
return new Promise((res, rej) => renderer.domElement.toBlob((b) => {
if (!b || b.size < 8) return rej(new Error('empty canvas — pose() first?'));
fetch(`http://localhost:${port}/shot/${name}.png`, { method: 'POST', body: b })
.then((r) => (r.ok ? res(`${name}.png ${(b.size / 1024) | 0} KB`) : rej(new Error(`sink ${r.status}`))))
.catch(rej);
}, 'image/png'));
},
};
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
let t = 0, last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05); last = now; t += dt * 0.6;
for (const m of spinners) m.rotation.set(t * 0.55, t, t * 0.2);
material.uniforms.uTime.value = t;
renderer.render(scene, camera);
if (!on('shots')) {
const u = spinners.length === 1 ? spinners[0].userData : null;
dbgEl.textContent = u
? `${u.name} ${u.formula}\nrole: ${u.role}\n${u.atoms} atoms · ${u.tris} tris · ${renderer.info.render.calls} draw`
: `${spinners.length} molecules · ${renderer.info.render.calls} draws · ${renderer.info.render.triangles} tris`;
}
renderer.info.reset();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>