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);