// SOLARGOD comets layer (Stage 5) — famous periodic comets from JPL SBDB, drawn // with their full elliptical orbit (shared Kepler core), a marker, and an // anti-sunward tail whose length grows as 1/r² near the Sun. Elliptical only // (e < 0.98). On by default (brief §5). Gate: Halley's aphelion reaches beyond // Neptune's orbit — visible in the compressed view, near it. export default function create(ctx) { const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx; const WANT = [ { key: '1P', color: '#8fd3ff' }, { key: '2P', color: '#ffd24d' }, { key: '67P', color: '#9d8cff' }, { key: '96P', color: '#6fcf97' }, ]; const N = 512; const glyph = makeDot(); const comets = []; let on = true; const _e = {}, _abs = {}, _dir = {}, _tmp = {}; ui.addLayer('comets', 'Comets', true, (v) => { on = v; for (const c of comets) { c.line.visible = v; c.spr.visible = v; c.tail.visible = v; c.lbl.obj.visible = v; } }); ui.setStatus('comets', 'fetching SBDB…', 'warn'); load(); async function load() { try { const j = await (await fetch(`${CONFIG.proxy.sbdb}?fields=full_name,a,e,i,om,w,ma,epoch&sb-kind=c&limit=500`)).json(); const F = j.fields, ix = (k) => F.indexOf(k); for (const w of WANT) { const row = j.data.find((r) => { const fn = r[0].trim(); return fn.startsWith(w.key + '/') || fn.startsWith(w.key + ' '); }); if (!row) continue; const name = row[ix('full_name')].trim(); const el = { a: +row[ix('a')], e: +row[ix('e')], i: +row[ix('i')], om: +row[ix('om')], w: +row[ix('w')], ma: +row[ix('ma')], epoch: +row[ix('epoch')] }; if (!(el.a > 0) || el.e >= 0.98) continue; // elliptical only (brief §3) build(w, name, el); } ui.setStatus('comets', comets.length ? `${comets.length} comets · SBDB` : 'none resolved', comets.length ? 'ok' : 'err'); } catch (err) { console.warn('[solargod] comets', err.message); ui.setStatus('comets', 'SBDB unavailable', 'err'); } } function build(w, name, el) { // full orbit (absolute view-world, rides worldGroup) const au = ephem.orbitPathFromElements(el, N); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3)); const line = new THREE.LineLoop(geo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.5 })); line.frustumCulled = false; line.visible = on; worldGroup.add(line); // marker const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(w.color), transparent: true, depthWrite: false })); spr.visible = on; scene.add(spr); // tail: a 2-vertex line, anti-sunward, updated per frame (render-local space) const tgeo = new THREE.BufferGeometry(); tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3)); const tail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false })); tail.frustumCulled = false; tail.visible = on; scene.add(tail); const lbl = makeLabel(spr, name, 'craft-label'); lbl.div.style.color = w.color; lbl.obj.visible = on; const c = { w, name, el, geo, line, spr, tail, tgeo, lbl, auPath: au, lastT: lib.centuriesSinceJ2000(ctx.clock.jd) }; fillOrbit(c); comets.push(c); } function fillOrbit(c) { const pos = c.geo.attributes.position.array; for (let k = 0; k <= N; k++) { _tmp.x = c.auPath[k * 3]; _tmp.y = c.auPath[k * 3 + 1]; _tmp.z = c.auPath[k * 3 + 2]; scale.viewFromEcl(_tmp, _tmp); pos[k * 3] = _tmp.x; pos[k * 3 + 1] = _tmp.y; pos[k * 3 + 2] = _tmp.z; } c.geo.attributes.position.needsUpdate = true; c.geo.computeBoundingSphere(); } let lastTextMs = 0; return { id: 'comets', onClockTick(simMs, jd) { if (!on) return; const transitioning = scale.isTransitioning(); const camDist = ctx.camera.position.length(); // label DOM writes throttled ≤5 Hz (brief §11) const nowMs = performance.now(); const updateText = nowMs - lastTextMs > 200; if (updateText) lastTextMs = nowMs; for (const c of comets) { ephem.smallBodyEcl(c.el, jd, _e); const r = Math.hypot(_e.x, _e.y, _e.z); scale.viewFromEcl(_e, _abs); toLocal(_abs, c.spr.position); c.spr.scale.setScalar(Math.max(0.02, camDist * 0.014)); // anti-sunward tail (away from Sun = +unit position), length ∝ 1/r² const inv = 1 / (r || 1); lib.eclToWorld({ x: _e.x * inv, y: _e.y * inv, z: _e.z * inv }, _dir); const len = lib.clamp(3 / (r * r), 0.3, 8); const tp = c.tgeo.attributes.position.array; tp[0] = c.spr.position.x; tp[1] = c.spr.position.y; tp[2] = c.spr.position.z; tp[3] = c.spr.position.x + _dir.x * len; tp[4] = c.spr.position.y + _dir.y * len; tp[5] = c.spr.position.z + _dir.z * len; c.tgeo.attributes.position.needsUpdate = true; if (updateText) c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`; // orbit rebuild on T-drift or scale transition (brief §11) const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - c.lastT) > CONFIG.orbitRebuildCy; if (needAu) { c.auPath = ephem.orbitPathFromElements(c.el, N); c.lastT = lib.centuriesSinceJ2000(jd); } if (needAu || transitioning) fillOrbit(c); } }, handlePick(raycaster) { const hits = raycaster.intersectObjects(comets.filter((c) => c.spr.visible).map((c) => c.spr), false); if (hits.length) { const c = comets.find((x) => x.spr === hits[0].object); if (c) ui.toast(c.lbl.div.textContent); return true; } return false; }, }; function makeDot() { const s = 48, cv = document.createElement('canvas'); cv.width = cv.height = s; const g = cv.getContext('2d'); const grd = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); grd.addColorStop(0, '#fff'); grd.addColorStop(0.4, 'rgba(255,255,255,0.7)'); grd.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = grd; g.beginPath(); g.arc(s / 2, s / 2, s / 2, 0, 7); g.fill(); const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t; } }