diff --git a/js/layers/moons.js b/js/layers/moons.js new file mode 100644 index 0000000..92577f3 --- /dev/null +++ b/js/layers/moons.js @@ -0,0 +1,43 @@ +// SOLARGOD moons layer (Stage 3) — every moon in the registry as a lit sphere, +// positioned by ctx.bodyWorld (main.js computes the local compressed offset in the +// parent's equatorial plane; retrograde via negative period). Moons draw at the +// SAME exaggeration as their parent, so the true radius ratio (e.g. Io/Jupiter +// ≈ 0.026) is preserved. Labels appear only when the parent (or the moon) is the +// focus, to keep the wide view clean (brief §5). + +export default function create(ctx) { + const { THREE, scene, CONFIG, ui, scale, bodies, focus, toLocal, effectiveE, makeLabel, registerPick } = ctx; + + const entries = []; + for (const id in bodies) { + const b = bodies[id]; + if (b.type !== 'moon') continue; + const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(b.color), roughness: 1, metalness: 0 }); + const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 16), mat); + mesh.visible = true; + scene.add(mesh); + registerPick(mesh, id); + const lbl = makeLabel(mesh, b.name, 'body-label moon'); + lbl.obj.visible = false; // CSS2DRenderer honors obj.visible (not div.style.display) + entries.push({ id, b, mesh, lbl }); + } + + let show = true; + ui.addLayer('moons', 'Moons', true, (on) => { show = on; for (const e of entries) e.mesh.visible = on; }); + ui.setStatus('moons', `${entries.length} moons`, 'ok'); + + return { + id: 'moons', + onClockTick(simMs, jd) { + for (const e of entries) { + const abs = ctx.bodyWorld[e.id]; + if (!abs) continue; + toLocal(abs, e.mesh.position); + e.mesh.scale.setScalar(scale.drawRadius(e.b.radiusKm, effectiveE(e.b.parent))); + const near = focus.id === e.b.parent || focus.id === e.id; + e.lbl.obj.visible = show && near; + e.lbl.div.classList.toggle('focused', e.id === focus.id); + } + }, + }; +} diff --git a/js/layers/rings.js b/js/layers/rings.js new file mode 100644 index 0000000..089f788 --- /dev/null +++ b/js/layers/rings.js @@ -0,0 +1,66 @@ +// SOLARGOD rings layer (Stage 3) — Saturn's textured annulus + Uranus's thin +// schematic ring. Sized in true km scaled by the parent's draw scale, tilted with +// the parent's axial tilt, DoubleSide + transparent (brief §7). Texture optional: +// a flat color fallback keeps the ring visible if the PNG is missing. + +export default function create(ctx) { + const { THREE, scene, lib, ui, scale, bodies, toLocal, effectiveE, CONFIG } = ctx; + const AU_KM = lib.AU_KM; + const RING_FALLBACK = { saturn: '#c9b98f', uranus: '#8fa3a8' }; + + const entries = []; + for (const id of ['saturn', 'uranus']) { + const b = bodies[id]; + if (!b.rings) continue; + const ratio = b.rings.innerKm / b.rings.outerKm; + const geo = new THREE.RingGeometry(ratio, 1, 128, 1); + radialUV(geo); // remap UV so a radial ring texture maps across width + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(b.rings.color || RING_FALLBACK[id] || '#bbb'), + side: THREE.DoubleSide, transparent: true, opacity: id === 'saturn' ? 0.9 : 0.45, depthWrite: false, + }); + if (b.rings.texture) { + new THREE.TextureLoader().load(CONFIG.textures.path + b.rings.texture, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; mat.map = tex; mat.color.set(0xffffff); mat.needsUpdate = true; + }, undefined, () => { /* keep flat color */ }); + } + const group = new THREE.Group(); + group.rotation.z = (b.axialTiltDeg || 0) * lib.DEG; // same tilt as the planet group + const mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI / 2; // lay flat into the equatorial (XZ) plane + group.add(mesh); + scene.add(group); + entries.push({ id, b, group, mesh }); + } + + let show = true; + ui.addLayer('rings', 'Rings', true, (on) => { show = on; for (const e of entries) e.group.visible = on; }); + ui.setStatus('rings', 'Saturn · Uranus', 'ok'); + + return { + id: 'rings', + onClockTick() { + const K = scale.getK(); + for (const e of entries) { + const abs = ctx.bodyWorld[e.id]; + if (!abs) continue; + toLocal(abs, e.group.position); + const E = effectiveE(e.id); + e.mesh.scale.setScalar((e.b.rings.outerKm / AU_KM) * K * E); // outer km → view units + } + }, + }; +} + +// Remap RingGeometry UVs so u runs 0→1 across the radius (for a 1-D ring texture). +function radialUV(geo) { + const pos = geo.attributes.position, uv = geo.attributes.uv; + const inner = Math.hypot(pos.getX(0), pos.getY(0)); + let outer = inner; + for (let i = 0; i < pos.count; i++) outer = Math.max(outer, Math.hypot(pos.getX(i), pos.getY(i))); + for (let i = 0; i < pos.count; i++) { + const r = Math.hypot(pos.getX(i), pos.getY(i)); + uv.setXY(i, (r - inner) / (outer - inner || 1), 1); + } + uv.needsUpdate = true; +} diff --git a/js/main.js b/js/main.js index dc5e29c..4163618 100644 --- a/js/main.js +++ b/js/main.js @@ -339,7 +339,7 @@ const ctx = { moonsOf, PLANET_ORDER, }; -const LAYER_MODULES = ['./layers/planets.js']; +const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js']; const activeLayers = []; async function loadLayers() { const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p)));