guts/web/js/world/molecule.js
jing 9f42a2e9ab [lane A] Molecules as pickups: real gut chemistry, procedural, 1 draw each
PROTOTYPE + proposal, not wired into boot. Pickups are B's systems, their
economy is C's, object art is D's — this is a renderer and an argument, offered
to all three. Bench: web/dev/laneA_molecules.html · docs/MOLECULES.md

THE PITCH: the pickups already in L2 ARE molecules. C authored `nutrient`,
`mucin`, `B12` and `antacid ammo` before any of this existed — those are the real
biochemistry of a digestive tract, and glucose/cobalamin/bicarbonate are real
compounds with known shapes. We don't need to invent a pickup language; we need
to stop hiding the one the fiction handed us. GUTS is already a science game (an
eosinophil is a real white blood cell, candida a real yeast, the hiatus a real
choke point) — it just doesn't LOOK like one, because everything on screen is
tinted tissue.

THE RULE THAT MAKES IT WORK: CPK is the scanner. Everything identified is drawn
in the standard element palette (O red, N blue, P orange, metals pink);
everything else stays monochrome tissue. Against six biomes of tinted wall a CPK
molecule is the ONLY saturated foreign colour on screen — it reads as artificial,
valuable and targetable with NO HUD marker. Proof: round2_molecules_in_canal.png.
That's the ART_BIBLE's synthetic-scanner fiction paying rent, and it hands Lane E
a free HUD palette.

WHY PROCEDURAL AND NOT MODELBEAST — and I'm authorised to burn the GPU: a
molecule's shape is already known exactly. Glucose is a hexagonal ring because it
IS one. FLUX+TRELLIS gives a plausible blob in 3-8 min that a chemist clocks as
wrong and that can never be re-derived. Ball-and-stick from an atom list is exact,
rebuilds in ms, and is ONE DRAW CALL per molecule (measured: 7 molecules = 7
draws / 26.5k tris). The split is a principle: small molecules = procedural
(exact geometry known); proteins/enzymes = MODELBEAST blobs (real ones are
3000-atom blobs nobody reads — and PIPELINE says blobby organics are TRELLIS's
sweet spot); cells/creatures = MODELBEAST, D's lane, unaffected.

BUILT: water (chaff) · bicarbonate (antacid ammo — HCO3- + HCl is the real
neutralisation, it genuinely fizzes CO2) · glucose (nutrient/score) · caffeine
(overdrive — really gut-absorbed, needs no tutorial) · ATP (boost — the orange
triphosphate tail IS the charge, a pickup that's a diagram of its own mechanic) ·
capsaicin (burn hazard — long greasy tail reads wrong on sight) · B12 (rare
treasure — a cobalt in a corrin cage, the only metal vitamin; C authored it as a
pickup already). Size is a free rarity signal: `unit` mode keeps TRUE relative
sizes, so water is a speck and B12 a chandelier.

Found by rendering it, which is why the bench exists: fuseRing returns vertices
starting adjacent to B, and bonding them from A instead draws a chord across the
ring — still a valid 5-cycle so nothing errors, it just looked like a squashed
pentagon. Fixed in caffeine + ATP's adenine, adjacency documented.

Stated plainly: geometry is idealised (correct connectivity/rings/bond orders,
believable angles, authored flat) NOT crystallographic — real glucose is a chair,
not a hexagon. The B12 is a simplified corrin core. Untested above ~7 on screen;
fifty would want instancing.

qa GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:52:33 +10:00

456 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// world/molecule.js (Lane A) — ball-and-stick molecules, built from real chemistry.
//
// PROTOTYPE / cross-lane proposal (round 2). Not wired into boot. Pickups are Lane B's
// systems, their economy is C's, their art is D's — this is the *renderer* and a design
// argument, offered to all three. Harness: web/dev/laneA_molecules.html
//
// WHY PROCEDURAL AND NOT A GENERATED MESH. Everything else in GUTS that is an object gets
// concepted and put through MODELBEAST (PIPELINE.md). Molecules should not, and it isn't a
// cost argument: a molecule's shape is *known exactly*. Glucose is a hexagonal ring because
// it is a hexagonal ring. Feeding "glucose molecule" to FLUX+TRELLIS would produce a plausible
// blob that a chemist would clock as wrong in a second, at 3-8 minutes a go, and it could never
// be re-derived. Ball-and-stick from an atom list is exact, is ~40 lines of geometry, rebuilds
// instantly, and — the actual point — **reads as science because it IS the notation science
// uses**. The game gets its sciency-ness for free by not faking it.
//
// The look: CPK colours (the standard element palette — O red, N blue, P orange, S yellow,
// metals pink). Every chemistry textbook, every protein viewer, every science documentary uses
// it. Nobody needs to know what it means to feel it. And it does a gameplay job for free:
// against six biomes of monochrome tinted tissue, a CPK molecule is the only thing on screen
// with saturated foreign colour — it reads as *artificial, valuable, targetable* at a glance,
// with no HUD marker. That is the ART_BIBLE's synthetic-scanner fiction paying for itself:
// the ship's scanner identifies a compound and colours it in.
//
// The geometry is IDEALISED, not crystallographic: correct connectivity (which atom bonds to
// which), correct ring sizes, correct bond orders, believable angles — mostly authored flat
// because a flat ring reads instantly and spins beautifully. This is a game, not PyMOL, and
// this comment is here so nobody mistakes it for a structure database later.
//
// No real-time lights anywhere in GUTS (ART_BIBLE), so the shading is faked in-shader from a
// baked key direction. Colorspace: `#include <colorspace_fragment>` is LAW (TECH §Shader law).
import * as THREE from 'three';
const TAU = Math.PI * 2;
// ── elements ────────────────────────────────────────────────────────────────────────────
// `col` is CPK, nudged for a black void background: real CPK carbon is black, which is
// invisible here, so carbon is lifted to a grey that still reads as "not an element with a
// colour". `r` is a display radius in bond-length units — ball-and-stick, so balls are much
// smaller than the van der Waals radii; these are tuned to read, not to measure.
export const ELEMENTS = {
H: { col: 0xf2f2f2, r: 0.26 },
C: { col: 0x63666e, r: 0.40 },
N: { col: 0x3b60ff, r: 0.40 },
O: { col: 0xff3222, r: 0.39 },
P: { col: 0xff9020, r: 0.50 },
S: { col: 0xf5f52a, r: 0.49 },
Cl: { col: 0x35e035, r: 0.44 },
Na: { col: 0xab5cf2, r: 0.52 },
Fe: { col: 0xe06633, r: 0.55 },
Co: { col: 0xf090a0, r: 0.56 },
};
const el = (e) => ELEMENTS[e] || { col: 0xff00ff, r: 0.4 }; // magenta = you typo'd an element
// ── authoring helpers ───────────────────────────────────────────────────────────────────
/** A regular n-gon in the XY plane. Rings are rings; this is most of chemistry's shapes. */
function polygon(n, r, cx = 0, cy = 0, phase = -Math.PI / 2) {
return Array.from({ length: n }, (_, i) => {
const a = phase + (i * TAU) / n;
return [cx + r * Math.cos(a), cy + r * Math.sin(a), 0];
});
}
/**
* The other n-2 vertices of a regular n-gon that shares the edge A-B with an existing ring —
* i.e. a FUSED ring (caffeine's purine, ATP's adenine). Fused bicyclics are the visual
* signature of "this is a serious biomolecule", so it's worth the trig.
*
* **Returned in ring order starting from the vertex adjacent to B and ending adjacent to A**,
* so the caller bonds `Bout[0] … out[last]A` and the shared AB edge closes the ring. Get
* that backwards and you bond A to the far vertex: still a valid 5-cycle, so nothing errors —
* it just draws a chord straight across the ring. It looked like a squashed pentagon and it
* was found by rendering it, which is the whole reason the bench exists.
*
* @param {number[]} away a point the new ring must bulge away from (the host ring's centre)
*/
function fuseRing(A, B, n, away = [0, 0, 0]) {
const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2;
let ex = B[0] - A[0], ey = B[1] - A[1];
const s = Math.hypot(ex, ey); ex /= s; ey /= s;
let px = -ey, py = ex; // edge normal, sign undecided
if (Math.hypot(mx + px - away[0], my + py - away[1]) < Math.hypot(mx - px - away[0], my - py - away[1])) {
px = -px; py = -py; // ...pick the one pointing outward
}
const apothem = (s / 2) / Math.tan(Math.PI / n);
const R = (s / 2) / Math.sin(Math.PI / n);
const cx = mx + px * apothem, cy = my + py * apothem;
const a0 = Math.atan2(A[1] - cy, A[0] - cx);
const aB = Math.atan2(B[1] - cy, B[0] - cx);
const step = TAU / n;
const wrap = (x) => ((x + Math.PI) % TAU + TAU) % TAU - Math.PI; // to (-pi, pi]
const dir = Math.abs(wrap(a0 + step - aB)) < Math.abs(wrap(a0 - step - aB)) ? 1 : -1;
const out = [];
for (let k = 2; k < n; k++) {
const a = a0 + dir * step * k;
out.push([cx + R * Math.cos(a), cy + R * Math.sin(a), 0]);
}
return out;
}
/** Push an atom bonded outward from `from`, away from `origin`, at distance d. */
function outward(from, d, origin = [0, 0, 0]) {
const dx = from[0] - origin[0], dy = from[1] - origin[1];
const L = Math.hypot(dx, dy) || 1;
return [from[0] + (dx / L) * d, from[1] + (dy / L) * d, from[2]];
}
const add = (p, dx, dy, dz = 0) => [p[0] + dx, p[1] + dy, p[2] + dz];
// ── the library ─────────────────────────────────────────────────────────────────────────
// Every molecule here is really in the human gut. That constraint is doing design work: it
// means the pickup table IS the biochemistry of digestion, so the fiction writes itself and
// nothing has to be invented. `role` is a PROPOSAL to Lanes B/C — see docs/MOLECULES.md.
function buildLibrary() {
const M = {};
const def = (id, o) => { M[id] = { id, ...o }; };
// ── water ── the common little pickup. Bent at ~104.5°, which is the one fact everyone
// remembers from school, so it must not be drawn straight.
def('water', {
name: 'Water', formula: 'H₂O', role: 'trickle',
blurb: 'The chaff pickup. Everywhere, worth almost nothing, tops off a sliver of coat.',
atoms: [{ e: 'O', p: [0, 0, 0] }, { e: 'H', p: [0.76, 0.59, 0] }, { e: 'H', p: [-0.76, 0.59, 0] }],
bonds: [[0, 1, 1], [0, 2, 1]],
});
// ── bicarbonate ── the antacid ammo that L2/L3 already reference. Trigonal planar, and it
// is *literally* what neutralises stomach acid in a real body: HCO₃⁻ + HCl → salt + water
// + CO₂. Firing this into the acid sea is real chemistry and it is also just a good weapon.
def('bicarbonate', {
name: 'Bicarbonate', formula: 'HCO₃⁻', role: 'antacid ammo',
blurb: 'Antacid ordnance. Neutralises acid on contact — the real reaction, and it fizzes CO₂.',
atoms: [
{ e: 'C', p: [0, 0, 0] },
{ e: 'O', p: [1.30, 0, 0] },
{ e: 'O', p: [-0.65, 1.126, 0] },
{ e: 'O', p: [-0.65, -1.126, 0] },
{ e: 'H', p: [-1.35, 1.82, 0] },
],
bonds: [[0, 1, 2], [0, 2, 1], [0, 3, 1], [2, 4, 1]],
});
// ── glucose ── the score/nutrient pickup. Pyranose: a six-ring of 5 carbons and ONE oxygen
// (drawn red, top-right, exactly where a chemist expects it), hydroxyls hanging off. This is
// sugar. It is what the gut is FOR.
def('glucose', {
name: 'Glucose', formula: 'C₆H₁₂O₆', role: 'nutrient / score',
blurb: 'Food. The reason the canal exists. Common, stacks, feeds the score multiplier.',
...(() => {
const ring = polygon(6, 1.42);
const atoms = ring.map((p, i) => ({ e: i === 0 ? 'O' : 'C', p })); // ring oxygen at index 0
const bonds = ring.map((_, i) => [i, (i + 1) % 6, 1]);
for (let i = 1; i <= 5; i++) { // hydroxyls on every carbon
if (i === 5) { // ...except C5, which carries CH₂OH
const c = outward(ring[i], 1.45); atoms.push({ e: 'C', p: c }); bonds.push([i, atoms.length - 1, 1]);
const o = add(outward(c, 1.35), 0, 0.35); atoms.push({ e: 'O', p: o }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
atoms.push({ e: 'H', p: add(o, 0.5, 0.75) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
continue;
}
const o = outward(ring[i], 1.38);
atoms.push({ e: 'O', p: o }); bonds.push([i, atoms.length - 1, 1]);
atoms.push({ e: 'H', p: outward(o, 0.95) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
}
return { atoms, bonds };
})(),
});
// ── caffeine ── the overdrive powerup, and a joke that lands without explanation: it is a
// purine (fused 6+5 with four nitrogens), and it is genuinely absorbed through the gut wall.
// Everyone knows what caffeine does to a body; nobody needs a tutorial for this pickup.
def('caffeine', {
name: 'Caffeine', formula: 'C₈H₁₀N₄O₂', role: 'overdrive',
blurb: 'Overdrive. Throttle ceiling up, handling twitchier, and it wears off badly.',
...(() => {
const six = polygon(6, 1.42);
const five = fuseRing(six[2], six[3], 5); // fuse the imidazole onto one edge
const atoms = [
{ e: 'N', p: six[0] }, { e: 'C', p: six[1] }, { e: 'C', p: six[2] },
{ e: 'C', p: six[3] }, { e: 'N', p: six[4] }, { e: 'C', p: six[5] },
{ e: 'N', p: five[0] }, { e: 'C', p: five[1] }, { e: 'N', p: five[2] },
];
const bonds = [
[0, 1, 1], [1, 2, 2], [2, 3, 1], [3, 4, 1], [4, 5, 1], [5, 0, 1], // pyrimidine
[3, 6, 1], [6, 7, 2], [7, 8, 1], [8, 2, 1], // imidazole (B->…->A)
];
const carbonyl = (ci, dir) => { // the two C=O that make it a dione
const o = outward(atoms[ci].p, 1.24, [0, 0, 0]);
atoms.push({ e: 'O', p: [o[0] * dir, o[1] * dir === 0 ? o[1] : o[1], o[2]] });
bonds.push([ci, atoms.length - 1, 2]);
};
carbonyl(1, 1); carbonyl(5, 1);
for (const ni of [0, 4, 8]) { // three methyls — caffeine's tell
const c = outward(atoms[ni].p, 1.47);
atoms.push({ e: 'C', p: c }); bonds.push([ni, atoms.length - 1, 1]);
}
return { atoms, bonds };
})(),
});
// ── ATP ── the boost. Adenine + ribose + a three-phosphate tail, and that orange tail IS the
// energy: a body spends ATP by snapping the last phosphate off. A boost pickup that visibly
// carries three charges is a gameplay diagram of itself.
def('atp', {
name: 'ATP', formula: 'C₁₀H₁₆N₅O₁₃P₃', role: 'boost',
blurb: 'Boost. Literally the cell\'s energy currency — three phosphates, three charges.',
...(() => {
const atoms = [], bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
// adenine, off to the left
const six = polygon(6, 1.40, -5.2, 0.8);
const five = fuseRing(six[2], six[3], 5, [-5.2, 0.8, 0]);
const A = [
push('N', six[0]), push('C', six[1]), push('C', six[2]),
push('C', six[3]), push('N', six[4]), push('C', six[5]),
];
const F = [push('N', five[0]), push('C', five[1]), push('N', five[2])];
bonds.push([A[0], A[1], 2], [A[1], A[2], 1], [A[2], A[3], 2], [A[3], A[4], 1], [A[4], A[5], 2], [A[5], A[0], 1]);
bonds.push([A[3], F[0], 1], [F[0], F[1], 2], [F[1], F[2], 1], [F[2], A[2], 1]); // B->…->A
const nh2 = push('N', outward(six[5], 1.36, [-5.2, 0.8, 0])); // the amine
bonds.push([A[5], nh2, 1]);
// ribose, a five-ring with its own oxygen, hung off the adenine
const rib = polygon(5, 1.20, -1.9, -0.2, 0.6);
const R = rib.map((p, i) => push(i === 0 ? 'O' : 'C', p));
for (let i = 0; i < 5; i++) bonds.push([R[i], R[(i + 1) % 5], 1]);
bonds.push([F[2], R[1], 1]); // base -> sugar
const oh1 = push('O', outward(rib[3], 1.36, [-1.9, -0.2, 0]));
const oh2 = push('O', outward(rib[4], 1.36, [-1.9, -0.2, 0]));
bonds.push([R[3], oh1, 1], [R[4], oh2, 1]);
// the triphosphate tail: P-O-P-O-P marching right, each P with its own oxygens
let prev = R[2];
let x = -0.4;
for (let i = 0; i < 3; i++) {
const brO = push('O', [x, 0.9, 0]); bonds.push([prev, brO, 1]); // bridging oxygen
const p = push('P', [x + 1.25, 1.45, 0]); bonds.push([brO, p, 1]);
bonds.push([p, push('O', [x + 1.25, 2.85, 0]), 2]); // P=O
bonds.push([p, push('O', [x + 1.05, 0.15, 0]), 1]); // P-O⁻
prev = p; x += 1.85;
}
return { atoms, bonds };
})(),
});
// ── capsaicin ── the burn hazard. Aromatic ring at one end, long greasy tail at the other:
// the silhouette says "organic and wrong" from across a room, and the tail makes it tumble
// differently from every compact pickup, which is free readability.
def('capsaicin', {
name: 'Capsaicin', formula: 'C₁₈H₂₇NO₃', role: 'burn hazard',
blurb: 'Chilli. Contact burns the coat. Long-tailed and greasy — reads wrong on sight.',
...(() => {
const atoms = [], bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
const ring = polygon(6, 1.40, -4.6, 0);
const R = ring.map((p) => push('C', p));
for (let i = 0; i < 6; i++) bonds.push([R[i], R[(i + 1) % 6], i % 2 ? 2 : 1]); // aromatic
const oh = push('O', outward(ring[3], 1.36, [-4.6, 0, 0])); // phenol
bonds.push([R[3], oh, 1]);
bonds.push([oh, push('H', outward(ring[3], 2.3, [-4.6, 0, 0])), 1]);
const om = push('O', outward(ring[4], 1.36, [-4.6, 0, 0])); // methoxy
bonds.push([R[4], om, 1]);
bonds.push([om, push('C', outward(ring[4], 2.7, [-4.6, 0, 0])), 1]);
// amide linker + the alkyl tail, zig-zagging like a real chain
const c1 = push('C', [-2.7, -0.9, 0]); bonds.push([R[1], c1, 1]);
const n = push('N', [-1.5, -0.3, 0]); bonds.push([c1, n, 1]);
const co = push('C', [-0.3, -0.9, 0]); bonds.push([n, co, 1]);
bonds.push([co, push('O', [-0.3, -2.3, 0]), 2]);
let prev = co, x = 0.9, up = true;
for (let i = 0; i < 7; i++) {
const c = push('C', [x, up ? -0.25 : -1.15, 0]);
bonds.push([prev, c, i === 5 ? 2 : 1]); // one double bond kink, as in the real thing
prev = c; x += 1.05; up = !up;
}
return { atoms, bonds };
})(),
});
// ── cobalamin (B12) core ── the treasure. B12 is the most structurally complex vitamin there
// is and the only one with a METAL at its heart — a cobalt held in a corrin cage. C already
// authored B12 as a rare pickup before any of this existed. Simplified to the corrin core:
// the whole read is "a jewel in a setting", which is exactly what a rare pickup should be.
def('cobalamin', {
name: 'Cobalamin (B₁₂) core', formula: 'C₆₃H₈₈CoN₁₄O₁₄P', role: 'rare treasure',
blurb: 'The jewel. A cobalt atom held in a corrin cage — the only vitamin with a metal.',
...(() => {
const atoms = [{ e: 'Co', p: [0, 0, 0] }];
const bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
for (let q = 0; q < 4; q++) { // four pyrrole rings around the metal
const a = (q * TAU) / 4 + Math.PI / 4;
const nx = Math.cos(a) * 1.95, ny = Math.sin(a) * 1.95;
const n = push('N', [nx, ny, 0]);
bonds.push([0, n, 1]); // Co-N coordination bond
// the ring bulges outward from the metal
const ring = polygon(5, 1.20, Math.cos(a) * 3.05, Math.sin(a) * 3.05, a + Math.PI);
const C = ring.slice(1).map((p) => push('C', p));
bonds.push([n, C[0], 1], [C[0], C[1], 2], [C[1], C[2], 1], [C[2], C[3], 2], [C[3], n, 1]);
const sub = push('C', outward(ring[2], 1.45)); // a stub of the real side chains
bonds.push([C[1], sub, 1]);
const o = push('O', outward(ring[3], 1.40));
bonds.push([C[2], o, 1]);
}
return { atoms, bonds };
})(),
});
return M;
}
export const MOLECULES = buildLibrary();
export const listMolecules = () => Object.keys(MOLECULES);
// ── material ────────────────────────────────────────────────────────────────────────────
/**
* There are no lights in GUTS, so this fakes one: a baked key direction gives the balls their
* roundness, a tight specular gives the glossy model-kit read, and a fresnel rim in the
* scanner's cyan says "this object has been identified" — the same rim language the wall uses,
* so molecules belong to the world instead of being stickers on it.
*/
export function createMoleculeMaterial({ scan = 0x7fdfff, scanGain = 0.55 } = {}) {
return new THREE.ShaderMaterial({
uniforms: {
uScan: { value: new THREE.Color(scan) },
uScanGain: { value: scanGain },
uKey: { value: new THREE.Vector3(0.35, 0.72, 0.6).normalize() },
},
vertexShader: /* glsl */`
attribute vec3 color;
varying vec3 vColor; varying vec3 vN; varying vec3 vView;
void main() {
vColor = color;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
uniform vec3 uScan, uKey; uniform float uScanGain;
varying vec3 vColor; varying vec3 vN; varying vec3 vView;
void main() {
vec3 N = normalize(vN), V = normalize(vView);
// Key light in VIEW space: it follows the camera, so a molecule tumbling in flight
// never rotates into an unlit pose. Wrapped (0.30 floor) so nothing goes to silhouette.
float diff = 0.30 + 0.70 * max(0.0, dot(N, uKey));
float spec = pow(max(0.0, dot(reflect(-uKey, N), V)), 26.0);
float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0);
vec3 col = vColor * diff + vec3(1.0) * spec * 0.55 + uScan * fres * uScanGain;
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
}
// ── geometry ────────────────────────────────────────────────────────────────────────────
// Atoms and bonds are baked into ONE geometry with per-vertex colour, so a molecule is a
// single draw call however many atoms it has. That matters: these are pickups, and there may
// be dozens live. A naive mesh-per-atom glucose would be 21 draws on its own.
let SPHERE = null, CYL = null;
const templates = (detail) => {
if (!SPHERE) {
SPHERE = new THREE.IcosahedronGeometry(1, detail).toNonIndexed();
CYL = new THREE.CylinderGeometry(1, 1, 1, 9, 1, true).toNonIndexed();
}
return { SPHERE, CYL };
};
function appendGeo(dst, src, matrix, colorHex) {
const nm = new THREE.Matrix3().getNormalMatrix(matrix);
const pos = src.attributes.position, nor = src.attributes.normal;
const c = new THREE.Color(colorHex);
const v = new THREE.Vector3(), n = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i).applyMatrix4(matrix);
n.fromBufferAttribute(nor, i).applyMatrix3(nm).normalize();
dst.position.push(v.x, v.y, v.z);
dst.normal.push(n.x, n.y, n.z);
dst.color.push(c.r, c.g, c.b);
}
}
const Y = new THREE.Vector3(0, 1, 0);
/**
* @param {string} id key in MOLECULES
* @param {object} opts
* @param {number} opts.fit scale so the whole molecule fits this radius (game units).
* Every molecule ends up the same size on screen — right for a
* contact sheet, and right if a pickup must occupy a fixed box.
* @param {number} opts.unit ALTERNATIVE to `fit`: absolute units per bond length, so
* molecules keep their TRUE relative sizes — water is a speck and
* B₁₂ is a chandelier. For pickups this is the better one: size
* tells the player what a thing is worth before they read a
* single colour, and it costs nothing because it's just true.
* @param {number} opts.detail icosphere detail: 2 for hero/close, 1 for a live pickup
* @param {THREE.Material} opts.material share ONE across every molecule (see harness)
* @returns {THREE.Mesh} one mesh, one draw. `.userData` carries name/formula/role/blurb.
*/
export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = null, bondRadius = 0.13 } = {}) {
const spec = MOLECULES[id];
if (!spec) throw new Error(`[molecule] unknown molecule "${id}". Have: ${listMolecules().join(', ')}`);
const { SPHERE: sph, CYL: cyl } = templates(detail);
// Recentre on the atom centroid and solve the scale that makes it `fit`, so every molecule
// arrives the same size on screen no matter how many atoms it has — a pickup is a pickup.
const ps = spec.atoms.map((a) => new THREE.Vector3(...a.p));
const centre = ps.reduce((acc, p) => acc.add(p), new THREE.Vector3()).multiplyScalar(1 / ps.length);
ps.forEach((p) => p.sub(centre));
const extent = Math.max(...ps.map((p, i) => p.length() + el(spec.atoms[i].e).r)) || 1;
const k = unit > 0 ? unit : fit / extent;
const dst = { position: [], normal: [], color: [] };
const m = new THREE.Matrix4();
for (let i = 0; i < ps.length; i++) {
const a = spec.atoms[i], r = el(a.e).r * k;
m.compose(ps[i].clone().multiplyScalar(k), new THREE.Quaternion(), new THREE.Vector3(r, r, r));
appendGeo(dst, sph, m, el(a.e).col);
}
for (const [i, j, order = 1] of spec.bonds) {
const A = ps[i].clone().multiplyScalar(k), B = ps[j].clone().multiplyScalar(k);
const dir = B.clone().sub(A), len = dir.length();
if (len < 1e-6) continue;
const q = new THREE.Quaternion().setFromUnitVectors(Y, dir.clone().normalize());
// A double bond is drawn as two parallel sticks, a triple as three — the notation, again.
// Offset perpendicular to both the bond and the view-ish axis so the split always reads.
const perp = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 0, 1));
if (perp.lengthSq() < 1e-6) perp.set(1, 0, 0);
// Bond radius lives in the same scaled units as the atom radii — ball-and-stick only reads
// as ball-and-stick if the sticks are visibly thinner than the balls.
perp.normalize().multiplyScalar(bondRadius * 1.9 * k);
const rr = bondRadius * k * (order > 1 ? 0.62 : 1);
const offs = order === 1 ? [0] : order === 2 ? [-0.5, 0.5] : [-1, 0, 1];
for (const o of offs) {
const mid = A.clone().add(B).multiplyScalar(0.5).addScaledVector(perp, o);
// Each half of the bond takes its own atom's colour — the classic two-tone stick, and it
// means you can read what's bonded to what without any of the balls being visible.
for (const half of [-1, 1]) {
const c = mid.clone().addScaledVector(dir.clone().normalize(), (len / 4) * half);
m.compose(c, q, new THREE.Vector3(rr, len / 2, rr));
appendGeo(dst, cyl, m, el(spec.atoms[half < 0 ? i : j].e).col);
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(dst.position, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(dst.normal, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(dst.color, 3));
geo.computeBoundingSphere();
const mesh = new THREE.Mesh(geo, material || createMoleculeMaterial());
mesh.name = `molecule ${id}`;
mesh.userData = { id, name: spec.name, formula: spec.formula, role: spec.role, blurb: spec.blurb, atoms: spec.atoms.length, tris: dst.position.length / 9 };
return mesh;
}