"It needs more detail" was not a pixel problem. A molecule was a set of atoms at whatever angles the storm left them — three circles near each other, never a thing — and the room was a vacuum with objects in it. VSEPR. Bond springs only ever fixed bond LENGTH. Angular springs now splay the bonds around each atom as far apart as they can go, which is what produces the famous silhouettes: water bent at 104.5, CO2 a straight bar, methane a caltrop, a closed carbon ring a hexagon. Two bonds is where lone pairs matter, so O/N/S carry an explicit `bend`; three or more is just even angular spacing, which lands trigonal at 120 and the caltrop at 90 for free. The springs are deliberately weak (ANGLE_SPRING is ~15% of BOND_SPRING) so geometry loses to the storm, to a blast and to Molly's hands. A rigid molecule that ignores being hit reads as fake instantly. They also needed DAMPING, which was the real bug. Undamped, the mean angle measured correct while water actually swung through a 61 degree range — a broken hinge, not a molecule. Damping the tangential relative velocity (as bondSprings already does radially) cuts the spread from sd 16.5 to 1.3. Two measurement traps worth recording, since both nearly sent me the wrong way: a single-frame angle reading is meaningless in a Brownian storm and showed water at 58 degrees when its mean was 105; and starting a two-arm molecule at exactly 180 degrees is a degenerate equilibrium where the cross product vanishes and it cannot pick a side to fold toward, which looked exactly like a systematic bias. THE SOLVENT. 600 motes advected by the Coulomb field the sim already computes. Deliberately NOT particles: no charge, no valence, no collisions, nothing can touch them, so 600 cost 0.16ms where 116 real atoms cost ~5ms. They carry what the atoms cannot — you see the field move before anything charged does. Drawn under everything and never additive, because 600 additive motes would stack into a wash that eats the element hues and the radicals' white. 13 new tests in test/geometry.test.mjs; 81 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.8 KiB
JavaScript
139 lines
5.8 KiB
JavaScript
import { makeWorld, sim, tick, check, section, report, CFG } from './harness.mjs';
|
|
const { bond } = await import('../src/particle.js');
|
|
|
|
// ============================================================
|
|
// VSEPR — molecules have real shapes.
|
|
//
|
|
// Bond springs only fix bond LENGTH, so before this a molecule was atoms at
|
|
// whatever angles the storm left them: three circles near each other, never
|
|
// a thing. These tests guard the silhouettes that make it read as chemistry
|
|
// — water bent, CO2 a bar, methane a caltrop, benzene a hexagon.
|
|
// ============================================================
|
|
|
|
const deg = (c, a, b) => {
|
|
const ax = a.x - c.x, ay = a.y - c.y, bx = b.x - c.x, by = b.y - c.y;
|
|
return Math.abs(Math.atan2(ax * by - ay * bx, ax * bx + ay * by)) * 180 / Math.PI;
|
|
};
|
|
|
|
// Build a centre with n arms, started OFF the ideal so we prove the springs
|
|
// do the work — but never at exactly 180 for a 2-arm case, which is a
|
|
// degenerate equilibrium (the cross product vanishes and it cannot pick a
|
|
// side to fold toward).
|
|
function molecule(centreEl, armEls, seed = 42) {
|
|
const w = makeWorld(seed);
|
|
w.load({ id: 'lab', molly: { x: -9999, y: -9999 }, particles: [] });
|
|
const c = w.spawn({ x: 640, y: 360, el: centreEl });
|
|
const arms = armEls.map((el, i) => {
|
|
const a = 0.6 + (i / armEls.length) * Math.PI * 1.4; // deliberately wrong
|
|
return w.spawn({ x: 640 + Math.cos(a) * 30, y: 360 + Math.sin(a) * 30, el });
|
|
});
|
|
arms.forEach((a) => bond(c, a));
|
|
return { w, c, arms };
|
|
}
|
|
|
|
// mean + spread of the ADJACENT bond angles over a stretch of live sim
|
|
function measure(w, c, arms, seconds = 3) {
|
|
sim(w, 4);
|
|
const xs = [];
|
|
const n = Math.round(seconds / CFG.DT);
|
|
for (let i = 0; i < n; i++) {
|
|
tick(w);
|
|
const bs = arms.map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) }))
|
|
.sort((p, q) => p.a - q.a);
|
|
const pairs = arms.length === 2 ? 1 : arms.length;
|
|
for (let j = 0; j < pairs; j++) xs.push(deg(c, bs[j].b, bs[(j + 1) % arms.length].b));
|
|
}
|
|
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length);
|
|
return { mean, sd };
|
|
}
|
|
|
|
section('VSEPR — the famous shapes');
|
|
{
|
|
const cases = [
|
|
{ name: 'water is BENT', c: 'O', arms: ['H', 'H'], want: 104.5, tol: 8 },
|
|
{ name: 'CO2 is a straight bar', c: 'C', arms: ['O', 'O'], want: 180, tol: 12 },
|
|
{ name: 'ammonia', c: 'N', arms: ['H', 'H'], want: 107, tol: 8 },
|
|
{ name: 'H2S is tighter than water', c: 'S', arms: ['H', 'H'], want: 92, tol: 10 },
|
|
{ name: 'methane is a caltrop', c: 'C', arms: ['H', 'H', 'H', 'H'], want: 90, tol: 6 },
|
|
{ name: 'trigonal splays to 120', c: 'C', arms: ['H', 'H', 'H'], want: 120, tol: 6 },
|
|
];
|
|
for (const t of cases) {
|
|
const { w, c, arms } = molecule(t.c, t.arms);
|
|
const { mean } = measure(w, c, arms);
|
|
check(t.name, Math.abs(mean - t.want) < t.tol,
|
|
`${mean.toFixed(1)}deg (want ${t.want} +-${t.tol})`);
|
|
}
|
|
}
|
|
|
|
section('VSEPR — water and CO2 must not look the same');
|
|
{
|
|
// the whole point: two bonds on carbon reads as a BAR, two on oxygen as a
|
|
// BEND. If these ever converge the geometry is decorative.
|
|
const a = molecule('O', ['H', 'H']);
|
|
const b = molecule('C', ['O', 'O']);
|
|
const wa = measure(a.w, a.c, a.arms).mean;
|
|
const wb = measure(b.w, b.c, b.arms).mean;
|
|
check('bent and linear are far apart', wb - wa > 50,
|
|
`water ${wa.toFixed(0)}deg vs CO2 ${wb.toFixed(0)}deg`);
|
|
}
|
|
|
|
section('VSEPR — geometry HOLDS, it does not flap');
|
|
{
|
|
// Undamped, the mean angle came out right while water swung through a 61
|
|
// degree range — a broken hinge, not a molecule. ANGLE_DAMP fixed that and
|
|
// this is the regression guard.
|
|
const { w, c, arms } = molecule('O', ['H', 'H']);
|
|
const { sd } = measure(w, c, arms, 5);
|
|
check('water holds a steady bend under the storm', sd < 8, `sd=${sd.toFixed(1)}deg`);
|
|
}
|
|
|
|
section('VSEPR — geometry stays SOFT');
|
|
{
|
|
// It must lose to a blast, or molecules read as rigid props. A detonation
|
|
// beside a molecule should visibly deform it.
|
|
const { w, c, arms } = molecule('C', ['H', 'H', 'H', 'H']);
|
|
sim(w, 5);
|
|
const before = deg(c, arms[0], arms[1]);
|
|
const bomb = w.spawn({ x: c.x + 34, y: c.y, el: 'C' });
|
|
w.overload.detonate(w, bomb);
|
|
tick(w);
|
|
const after = deg(c, arms[0], arms[1]);
|
|
check('a blast deforms the molecule', Math.abs(after - before) > 1,
|
|
`${before.toFixed(0)}deg -> ${after.toFixed(0)}deg`);
|
|
}
|
|
|
|
section('SOLVENT — a medium, not more particles');
|
|
{
|
|
const w = makeWorld(7);
|
|
w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] });
|
|
check('motes are spawned', w.solvent.motes.length === CFG.SOLVENT_N,
|
|
`${w.solvent.motes.length}`);
|
|
check('motes are NOT particles (nothing can interact with them)',
|
|
w.particles.length === 0, `${w.particles.length} particles`);
|
|
|
|
// a charged atom must visibly move the medium around it
|
|
const p = w.spawn({ x: 640, y: 360, el: 'O' });
|
|
p.charge = 1;
|
|
const near = w.solvent.motes
|
|
.map((m) => ({ m, d: Math.hypot(m.x - 640, m.y - 360) }))
|
|
.filter((o) => o.d < 120 && o.d > 20)
|
|
.sort((a, b) => a.d - b.d)[0];
|
|
if (near) {
|
|
const d0 = near.d;
|
|
sim(w, 1.5);
|
|
const d1 = Math.hypot(near.m.x - p.x, near.m.y - p.y);
|
|
check('a charge pushes the medium away from it', d1 > d0,
|
|
`${d0.toFixed(0)}px -> ${d1.toFixed(0)}px`);
|
|
} else check('a charge pushes the medium away from it', false, 'no mote in range');
|
|
|
|
// and the medium must never leak out of the room
|
|
sim(w, 4);
|
|
const B = w.bounds;
|
|
const escaped = w.solvent.motes.filter(
|
|
(m) => m.x < B.x - 1 || m.x > B.x + B.w + 1 || m.y < B.y - 1 || m.y > B.y + B.h + 1).length;
|
|
check('motes wrap and never leak out of bounds', escaped === 0, `${escaped} escaped`);
|
|
}
|
|
|
|
process.exit(report() ? 0 : 1);
|