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>
183 lines
6.8 KiB
HTML
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>
|