// SOLARGOD planets layer (Stage 1) — the eight planets + Pluto as textured, // exaggerated spheres on compressed orbit paths, with labels. Positions come from // ctx.bodyWorld (computed centrally in main.js); this layer only renders. Orbit // geometry is rebuilt only when the MEGA/TRUE exponent is animating or elements // drift past CONFIG.orbitRebuildCy (brief §11). export default function create(ctx) { const { THREE, scene, CONFIG, lib, ui, scale, ephem, bodies, worldGroup, clock, focus, toLocal, effectiveE, makeLabel, registerPick, loadTextureInto, PLANET_ORDER } = ctx; const ROSTER = [...PLANET_ORDER, 'pluto']; const orbitMat = new THREE.LineBasicMaterial({ color: CONFIG.colors.orbit, transparent: true, opacity: 0.6 }); const N = CONFIG.orbitSamples; const entries = []; const tmp = new THREE.Vector3(); const _v = {}; for (const id of ROSTER) { const b = bodies[id]; // tilt group holds the spinning sphere so axial tilt + rotation compose. const group = new THREE.Group(); const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(b.color), roughness: 1, metalness: 0 }); const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 32), mat); group.rotation.z = (b.axialTiltDeg || 0) * lib.DEG; group.add(mesh); scene.add(group); loadTextureInto(mat, b.texture); registerPick(mesh, id); const lbl = makeLabel(group, b.name, 'body-label'); // orbit path (absolute view-world, lives under worldGroup) const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3)); const line = new THREE.LineLoop(geo, orbitMat); line.frustumCulled = false; worldGroup.add(line); const e = { id, b, group, mesh, mat, geo, line, lbl, auPath: null, lastT: NaN }; buildAuPath(e); fillView(e); entries.push(e); } function buildAuPath(e) { e.auPath = e.b.ephemId ? ephem.orbitPath(e.b.ephemId, clock.jd, N) : ephem.orbitPathFromElements(e.b.elements, N); e.lastT = lib.centuriesSinceJ2000(clock.jd); } function fillView(e) { const pos = e.geo.attributes.position.array; const au = e.auPath; for (let k = 0; k <= N; k++) { _v.x = au[k * 3]; _v.y = au[k * 3 + 1]; _v.z = au[k * 3 + 2]; scale.viewFromEcl(_v, _v); pos[k * 3] = _v.x; pos[k * 3 + 1] = _v.y; pos[k * 3 + 2] = _v.z; } e.geo.attributes.position.needsUpdate = true; e.geo.computeBoundingSphere(); } let showOrbits = true, showLabels = true; ui.addLayer('orbits', 'Orbit paths', true, (on) => { showOrbits = on; for (const e of entries) e.line.visible = on; }); ui.addLayer('labels', 'Body labels', true, (on) => { showLabels = on; for (const e of entries) e.lbl.obj.visible = on; }); ui.setStatus('orbits', `${entries.length} orbits`, 'ok'); ui.setStatus('labels', 'names shown', 'ok'); return { id: 'planets', onClockTick(simMs, jd, isLive) { const transitioning = scale.isTransitioning(); for (const e of entries) { const abs = ctx.bodyWorld[e.id]; if (!abs) continue; // position (floating-origin JS subtraction) + exaggerated radius toLocal(abs, e.group.position); const r = scale.drawRadius(e.b.radiusKm, effectiveE(e.id)); e.mesh.scale.setScalar(r); // spin: fraction of a rotation since J2000 (retrograde via sign) if (e.b.rotationHours) { const rot = ((jd - lib.J2000_JD) * 24) / e.b.rotationHours; e.mesh.rotation.y = rot * 2 * Math.PI; } // focus highlight e.lbl.div.classList.toggle('focused', e.id === focus.id); // orbit rebuild only when needed const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - e.lastT) > CONFIG.orbitRebuildCy; if (needAu) buildAuPath(e); if (needAu || transitioning) fillView(e); } }, }; }