Skip to content

Instantly share code, notes, and snippets.

@sshh12
Created June 14, 2026 15:52
Show Gist options
  • Select an option

  • Save sshh12/84fbc14911652cfdf4c9b1297dda1d18 to your computer and use it in GitHub Desktop.

Select an option

Save sshh12/84fbc14911652cfdf4c9b1297dda1d18 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlanetLab — Procedural Planet, Moon & Small-Body Builder</title>
<style>
:root{
--bg:#0a0c10; --panel:#12161d; --panel2:#1a1f29; --line:#283040;
--txt:#cfd8e6; --dim:#8a97ad; --accent:#ff8a4c; --accent2:#5cc8ff;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--txt);
font-family:"Segoe UI",system-ui,sans-serif;font-size:13px;overflow:hidden}
#app{display:flex;height:100vh}
#panel{width:352px;min-width:352px;height:100%;overflow-y:auto;
background:var(--panel);border-right:1px solid var(--line);padding:14px 14px 60px}
#panel h1{font-size:16px;margin:0 0 2px;letter-spacing:.5px}
#panel h1 span{color:var(--accent)}
.sub{color:var(--dim);font-size:11px;margin-bottom:12px;line-height:1.5}
.sec{border:1px solid var(--line);border-radius:8px;margin-bottom:9px;background:var(--panel2)}
.sec>.hd{padding:8px 11px;cursor:pointer;display:flex;justify-content:space-between;
align-items:center;font-weight:600;letter-spacing:.3px;user-select:none;font-size:12.5px}
.sec>.hd .tg{color:var(--dim);font-weight:400}
.sec>.bd{padding:4px 11px 11px;display:none}
.sec.open>.bd{display:block}
.row{display:flex;align-items:center;justify-content:space-between;margin:8px 0;gap:8px}
.row label{color:var(--dim);flex:1;font-size:11.5px}
.row .val{color:var(--accent2);font-variant-numeric:tabular-nums;width:42px;text-align:right;font-size:11px}
input[type=range]{flex:1.3;accent-color:var(--accent);height:4px}
select,input[type=number]{background:#0d1016;color:var(--txt);border:1px solid var(--line);
border-radius:5px;padding:5px 7px;font-size:12px;width:100%}
button{background:#222a36;color:var(--txt);border:1px solid var(--line);border-radius:6px;
padding:8px 10px;cursor:pointer;font-size:12px;font-weight:600;transition:.12s}
button:hover{background:#2d3848;border-color:#3a475c}
button.primary{background:var(--accent);color:#1a1206;border-color:var(--accent)}
button.primary:hover{filter:brightness(1.08)}
button.alt{background:#1c2a38;border-color:#274056;color:var(--accent2)}
.btnrow{display:flex;gap:6px;margin:10px 0}
.btnrow button{flex:1}
#stage{flex:1;position:relative;display:flex;align-items:center;justify-content:center;
background:radial-gradient(circle at 50% 40%,#0d1119,#05060a 70%)}
#view{display:block;cursor:grab;touch-action:none}
#view:active{cursor:grabbing}
#hud{position:absolute;top:14px;left:16px;font-size:11px;color:var(--dim);
pointer-events:none;line-height:1.7;text-shadow:0 1px 3px #000}
#hud b{color:var(--accent)}
#spin{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);
display:flex;gap:8px;align-items:center;background:#0d1119cc;border:1px solid var(--line);
border-radius:20px;padding:6px 14px;font-size:11px;color:var(--dim);backdrop-filter:blur(4px)}
#spin button{padding:4px 10px;border-radius:14px}
#codexBtn{position:absolute;top:14px;right:16px}
#codex{position:absolute;inset:0;background:#070a0fee;backdrop-filter:blur(6px);
overflow-y:auto;padding:34px 46px 60px;display:none;z-index:20}
#codex.show{display:block}
#codex h2{color:var(--accent);margin:0 0 4px}
#codex .cx{color:var(--dim);max-width:920px;margin-bottom:18px;line-height:1.6}
#codex .cat{margin:20px 0 8px;color:var(--accent2);font-size:14px;letter-spacing:.5px;
border-bottom:1px solid var(--line);padding-bottom:5px}
#codex table{border-collapse:collapse;width:100%;max-width:1040px;font-size:12.5px}
#codex td{border-bottom:1px solid #1a212c;padding:6px 10px;vertical-align:top}
#codex td:first-child{color:var(--txt);font-weight:600;width:200px}
#codex td:nth-child(2){color:var(--dim);width:210px;font-style:italic}
#codex td:last-child{color:#aebbcd}
#codexClose{position:fixed;top:18px;right:30px;z-index:21}
.pipe{font-size:10.5px;color:var(--dim);line-height:1.7;margin-top:4px}
.pipe .on{color:var(--accent)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#222a36;border-radius:5px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div id="app">
<div id="panel">
<h1>Planet<span>Lab</span></h1>
<div class="sub">A complete world forge. Every slider is a real process that shapes a body's
appearance from orbit — figure, illumination/phase, surface geology, ice, atmosphere, rings &
moons. Open the <b>Codex</b> (top-right) for the full taxonomy.</div>
<div class="sec open" data-sec>
<div class="hd">World preset <span class="tg">▾</span></div>
<div class="bd">
<select id="preset"></select>
<div class="btnrow">
<button id="surprise" class="alt">🎲 Surprise me</button>
<button id="reseed">↻ New seed</button>
</div>
<div class="row"><label>Seed</label><input type="number" id="seed" style="width:90px" value="1337"></div>
<div class="pipe" id="pipe"></div>
</div>
</div>
<div class="sec" data-sec><div class="hd">Figure & illumination <span class="tg">▾</span></div><div class="bd" id="grpFig"></div></div>
<div class="sec" data-sec><div class="hd">Endogenic — internal forces <span class="tg">▾</span></div><div class="bd" id="grpEndo"></div></div>
<div class="sec" data-sec><div class="hd">Exogenic — surface reshaping <span class="tg">▾</span></div><div class="bd" id="grpExo"></div></div>
<div class="sec" data-sec><div class="hd">Cryo / ice / fluids <span class="tg">▾</span></div><div class="bd" id="grpCryo"></div></div>
<div class="sec" data-sec><div class="hd">Albedo provinces (relief-free) <span class="tg">▾</span></div><div class="bd" id="grpAlb"></div></div>
<div class="sec" data-sec><div class="hd">Atmosphere & clouds <span class="tg">▾</span></div><div class="bd" id="grpAtmo"></div></div>
<div class="sec" data-sec><div class="hd">Rings & moons <span class="tg">▾</span></div><div class="bd" id="grpRing"></div></div>
<div class="sec open" data-sec><div class="hd">Coloration & render <span class="tg">▾</span></div><div class="bd" id="grpColor"></div></div>
<div class="btnrow">
<button id="build" class="primary" style="flex:2">⚙ Rebuild planet</button>
<button id="save" class="alt">⬇ PNG</button>
</div>
</div>
<div id="stage">
<canvas id="view" width="720" height="720"></canvas>
<div id="hud"></div>
<button id="codexBtn" class="alt">📖 Process Codex</button>
<div id="spin">
<button id="spinToggle" class="alt">⏸ spin</button>
<span>drag rotate · wheel zoom</span>
</div>
<div id="codex">
<button id="codexClose" class="primary">✕ close</button>
<h2>The Planetary Process Codex — complete dimension set</h2>
<div class="cx">A body's appearance from orbit is fully determined by a finite set of axes:
its <b>figure</b>, the <b>illumination geometry</b> (phase/terminator), the <b>geological &
atmospheric processes</b> that ran on it, its <b>coloration chemistry</b>, and its
<b>companions</b> (rings, moons). PlanetLab now implements all of them. Below, ● = an adjustable
layer in this build.</div>
<div id="codexBody"></div>
</div>
</div>
</div>
<script>
"use strict";
/* ============================================================ *
* PlanetLab v2 — complete procedural planetary appearance.
* Pipeline: figure → noise base → endogenic → exogenic → cryo
* → albedo provinces → color → render(phase, clouds,
* rings, moons, atmosphere, emissive).
* ============================================================ */
/* ---------- RNG + 3D simplex ---------- */
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);
t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const GRAD3=[[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],
[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]];
let PERM=new Uint8Array(512),GP=new Uint8Array(512);
function seedNoise(seed){
const r=mulberry32(seed>>>0);const p=new Uint8Array(256);
for(let i=0;i<256;i++)p[i]=i;
for(let i=255;i>0;i--){const j=(r()*(i+1))|0;const t=p[i];p[i]=p[j];p[j]=t;}
for(let i=0;i<512;i++){PERM[i]=p[i&255];GP[i]=PERM[i]%12;}
}
const F3=1/3,G3=1/6;
function snoise(x,y,z){
let n0,n1,n2,n3;
const s=(x+y+z)*F3;
const i=Math.floor(x+s),j=Math.floor(y+s),k=Math.floor(z+s);
const t=(i+j+k)*G3;
const x0=x-(i-t),y0=y-(j-t),z0=z-(k-t);
let i1,j1,k1,i2,j2,k2;
if(x0>=y0){
if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0;}
else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1;}
else{i1=0;j1=0;k1=1;i2=1;j2=0;k2=1;}
}else{
if(y0<z0){i1=0;j1=0;k1=1;i2=0;j2=1;k2=1;}
else if(x0<z0){i1=0;j1=1;k1=0;i2=0;j2=1;k2=1;}
else{i1=0;j1=1;k1=0;i2=1;j2=1;k2=0;}
}
const x1=x0-i1+G3,y1=y0-j1+G3,z1=z0-k1+G3;
const x2=x0-i2+2*G3,y2=y0-j2+2*G3,z2=z0-k2+2*G3;
const x3=x0-1+3*G3,y3=y0-1+3*G3,z3=z0-1+3*G3;
const ii=i&255,jj=j&255,kk=k&255;
let t0=0.6-x0*x0-y0*y0-z0*z0;
if(t0<0)n0=0;else{const g=GRAD3[GP[ii+PERM[jj+PERM[kk]]]];t0*=t0;n0=t0*t0*(g[0]*x0+g[1]*y0+g[2]*z0);}
let t1=0.6-x1*x1-y1*y1-z1*z1;
if(t1<0)n1=0;else{const g=GRAD3[GP[ii+i1+PERM[jj+j1+PERM[kk+k1]]]];t1*=t1;n1=t1*t1*(g[0]*x1+g[1]*y1+g[2]*z1);}
let t2=0.6-x2*x2-y2*y2-z2*z2;
if(t2<0)n2=0;else{const g=GRAD3[GP[ii+i2+PERM[jj+j2+PERM[kk+k2]]]];t2*=t2;n2=t2*t2*(g[0]*x2+g[1]*y2+g[2]*z2);}
let t3=0.6-x3*x3-y3*y3-z3*z3;
if(t3<0)n3=0;else{const g=GRAD3[GP[ii+1+PERM[jj+1+PERM[kk+1]]]];t3*=t3;n3=t3*t3*(g[0]*x3+g[1]*y3+g[2]*z3);}
return 32*(n0+n1+n2+n3);
}
function fbm(x,y,z,oct,lac,gain){let a=0.5,f=1,s=0,n=0;for(let o=0;o<oct;o++){s+=a*snoise(x*f,y*f,z*f);n+=a;a*=gain;f*=lac;}return s/n;}
function ridged(x,y,z,oct,lac,gain){let a=0.5,f=1,s=0,n=0;for(let o=0;o<oct;o++){let v=1-Math.abs(snoise(x*f,y*f,z*f));v*=v;s+=a*v;n+=a;a*=gain;f*=lac;}return s/n;}
/* ---------- math helpers ---------- */
function lerp(a,b,t){return a+(b-a)*t;}
function clamp(v,a,b){return v<a?a:v>b?b:v;}
function smooth(e0,e1,x){const t=clamp((x-e0)/(e1-e0||1),0,1);return t*t*(3-2*t);}
function rampColor(stops,t){
t=clamp(t,0,1);
for(let i=0;i<stops.length-1;i++){const a=stops[i],b=stops[i+1];
if(t>=a[0]&&t<=b[0]){const k=(t-a[0])/(b[0]-a[0]||1);
return[lerp(a[1],b[1],k),lerp(a[2],b[2],k),lerp(a[3],b[3],k)];}}
const l=stops[stops.length-1];return[l[1],l[2],l[3]];
}
function nrm(v){const l=Math.hypot(v[0],v[1],v[2])||1;return[v[0]/l,v[1]/l,v[2]/l];}
function mm3(A,B){const C=[[0,0,0],[0,0,0],[0,0,0]];for(let i=0;i<3;i++)for(let j=0;j<3;j++){let s=0;for(let k=0;k<3;k++)s+=A[i][k]*B[k][j];C[i][j]=s;}return C;}
/* ============== PRESETS ============== */
const PRESETS={
"🔴 Mars — rusted volcanic world":{mode:"rocky",palette:"mars",oblate:.005,
tectonics:.55,mountains:.6,scarps:.25,wrinkle:.2,volcano:.7,floods:.35,
craters:.45,craterSize:.6,relaxation:.1,multiring:.3,secondaries:.4,
erosion:.4,dunes:.4,stratify:.4,weather:.15,sea:0,iceCap:.18,seasonalFrost:.4,
oxidation:.85,relief:1.1,albedoProv:.4,slopeStreaks:.5,dustStorm:.0,
atmThickness:.12,rayleigh:.12,atmoGlow:.5,rivers:.35,masswaste:.4,windStreaks:.6,
yardangs:.5,rifts:.35,castShadow:.4,pldSpiral:.5,plumeSwell:.6,degradation:.3,
devilTracks:.5,latBands:.3,inverted:.25,ambient:.04},
"🌑 Moon — airless & cratered":{mode:"airless",palette:"moon",
tectonics:.15,mountains:.25,wrinkle:.4,craters:.95,craterSize:.7,relaxation:0,
multiring:.6,secondaries:.6,volcano:.1,floods:.6,weather:.7,relief:1.3,
albedoProv:.7,dichotomy:.3,oppSurge:.6,swirls:.6,castShadow:.55,lommel:.7,degradation:.45,ambient:.02,atmoGlow:0},
"☿ Mercury — scorched, shrunk":{mode:"airless",palette:"mercury",
tectonics:.4,mountains:.3,scarps:.85,craters:.9,craterSize:.6,multiring:.5,
secondaries:.5,volcano:.15,floods:.4,weather:.55,relief:1.2,oxidation:.05,
albedoProv:.5,ambient:.02,atmoGlow:0},
"🌍 Earth-like — ocean world":{mode:"rocky",palette:"earth",oblate:.01,
tectonics:.7,mountains:.7,scarps:.15,volcano:.3,floods:.1,craters:.04,
erosion:.7,dunes:.2,sea:.52,iceCap:.4,seasonalFrost:.2,oxidation:.1,weather:.05,
relief:.9,clouds:.6,cloudOpacity:.6,cloudShadows:.4,cityLights:.5,albedoProv:.3,
atmThickness:.25,rayleigh:.7,atmoGlow:1.1,rivers:.6,masswaste:.3,cyclones:.6,
multiDeck:.4,lightning:.3,castShadow:.25,coastlines:.6,glacial:.3,ambient:.05},
"🟡 Venus — cloud-shrouded":{mode:"rocky",palette:"venus",
tectonics:.5,mountains:.5,volcano:.85,floods:.55,craters:.1,craterSize:.5,
erosion:.15,oxidation:.3,relief:.95,clouds:1,cloudOpacity:1,haze:1,
atmThickness:.7,limbDarkening:.25,atmoGlow:1.6,coronae:.7,tesserae:.7,cyclones:.2,
plumeSwell:.5,volcanicPlains:.6,ambient:.06},
"🪐 Saturn — ringed gas giant":{mode:"gas",palette:"saturn",oblate:.1,axialTilt:27,
bands:11,bandWarp:.35,bandIrregular:.6,altColor:.6,storms:.4,hexagon:1,
polarVortex:.4,bandContrast:.7,rings:1,ringInner:1.25,ringOuter:2.35,
ringOpacity:.78,ringSpokes:.5,ringForward:.5,limbDarkening:.45,atmoGlow:.9,relief:.22,ambient:.04},
"🪐 Jupiter — banded gas giant":{mode:"gas",palette:"jupiter",oblate:.065,
bands:13,bandWarp:.5,bandIrregular:.7,altColor:.7,storms:.7,redspot:1,
polarVortex:.7,bandContrast:.85,limbDarkening:.5,atmoGlow:1.3,relief:.25,moons:2,ambient:.04},
"🔵 Neptune — windy ice giant":{mode:"gas",palette:"neptune",oblate:.017,
bands:5,bandWarp:.3,bandIrregular:.3,altColor:.3,storms:.4,darkspot:1,
bandContrast:.4,limbDarkening:.5,atmoGlow:1.5,relief:.18,ambient:.04},
"🩵 Uranus — sideways ice giant":{mode:"gas",palette:"uranus",oblate:.022,axialTilt:98,
bands:3,bandWarp:.08,bandIrregular:.1,altColor:.1,storms:.05,bandContrast:.08,
rings:1,ringInner:1.5,ringOuter:1.95,ringOpacity:.32,limbDarkening:.55,atmoGlow:1.5,relief:.1,ambient:.04},
"🧊 Europa — fractured ice shell":{mode:"icy",palette:"europa",
tectonics:.1,mountains:.1,craters:.08,craterSize:.4,relaxation:.5,floods:.25,
erosion:.2,linea:.6,doubleRidge:.8,chaos:.5,iceCap:.1,relief:.5,
albedoProv:.3,polarTholin:0,oxidation:0,weather:.05,ambient:.05,atmoGlow:.2},
"❄ Enceladus — tiger-stripe geysers":{mode:"icy",palette:"enceladus",
tectonics:.15,craters:.2,craterSize:.5,relaxation:.6,floods:.2,erosion:.15,
linea:.3,tiger:.8,iceCap:.2,relief:.6,weather:.02,ambient:.05,atmoGlow:.15},
"🟤 Ganymede — grooved terrain":{mode:"icy",palette:"ganymede",
tectonics:.3,craters:.6,craterSize:.55,relaxation:.55,multiring:.6,sulci:.85,
floods:.2,erosion:.15,iceCap:.1,weather:.3,relief:.6,albedoProv:.5,ambient:.03},
"⚫ Callisto — saturated palimpsests":{mode:"icy",palette:"callisto",
tectonics:.1,craters:.95,craterSize:.6,relaxation:.7,multiring:.85,floods:.1,
weather:.6,relief:.7,albedoProv:.4,ambient:.03},
"🌗 Iapetus — two-tone walnut":{mode:"icy",palette:"iapetus",oblate:.03,
tectonics:.1,craters:.8,craterSize:.6,relaxation:.4,multiring:.4,equatorRidge:1,
hemiAlbedo:1,floods:.05,weather:.4,relief:.7,ambient:.03},
"🌋 Io — sulfur volcano hell":{mode:"rocky",palette:"io",
tectonics:.2,mountains:.3,volcano:1,floods:.5,craters:0,erosion:.1,dunes:.1,
sulfur:1,relief:1,albedoProv:.6,ambient:.05},
"🟠 Titan — methane & dunes":{mode:"rocky",palette:"titan",
tectonics:.25,mountains:.2,volcano:.1,floods:.1,craters:.1,erosion:.6,dunes:.85,
sea:.12,hydrocarbon:1,iceCap:.1,tholin:1,relief:.6,clouds:.35,cloudOpacity:.5,
haze:.7,hazeLayers:.7,atmThickness:.6,limbDarkening:.2,atmoGlow:1.1,rivers:.5,polarHood:.6,ambient:.05},
"🌫 Pluto — frozen tholin dwarf":{mode:"icy",palette:"pluto",
tectonics:.2,craters:.4,craterSize:.55,relaxation:.5,nitrogenGlacier:.8,
sublimation:.6,floods:.2,erosion:.15,iceCap:.3,linea:.1,polarTholin:.7,
weather:.3,relief:.7,albedoProv:.5,hazeLayers:.8,atmThickness:.2,glacialFlow:.6,ambient:.04,atmoGlow:.4},
"🥔 Vesta — irregular protoplanet":{mode:"airless",palette:"vesta",
irregular:.55,oblate:.12,tectonics:.2,mountains:.4,craters:.9,craterSize:.7,
multiring:.5,equatorRidge:.4,weather:.4,relief:1.4,albedoProv:.5,ambient:.03},
"☄ Asteroid — rubble potato":{mode:"airless",palette:"vesta",
irregular:1,lobes:1,tectonics:.1,mountains:.2,craters:.85,craterSize:.8,
weather:.5,relief:1.6,albedoProv:.4,ambient:.02},
"🏜 Desert / dune world":{mode:"rocky",palette:"desert",
tectonics:.4,mountains:.45,volcano:.15,floods:.1,craters:.1,erosion:.3,dunes:1,
stratify:.3,sea:0,iceCap:.05,oxidation:.5,relief:.85,albedoProv:.4,atmoGlow:.6,ambient:.05},
/* ===== Beyond the solar system — same dimensions, different settings ===== */
"🌶 Hot Jupiter — tidally-locked giant":{mode:"gas",palette:"hotjup",oblate:.08,
bands:8,bandWarp:.5,bandIrregular:.6,altColor:.5,storms:.6,bandContrast:.7,
tidalLock:1,lavaWorld:.45,atmoGlow:1.4,relief:.2,ambient:.02},
"🌎 Super-Earth — massive rocky":{mode:"rocky",palette:"earth",oblate:.008,
tectonics:.9,mountains:.85,scarps:.2,volcano:.5,floods:.15,craters:.03,
erosion:.8,dunes:.2,sea:.5,iceCap:.3,seasonalFrost:.2,clouds:.5,cloudOpacity:.55,
oxidation:.1,relief:1.0,albedoProv:.3,atmoGlow:1.1,cityLights:.0,ambient:.05},
"🌊 Ocean world — global sea":{mode:"rocky",palette:"ocean",
tectonics:.4,mountains:.5,volcano:.2,floods:.1,craters:.03,erosion:.5,
sea:.92,iceCap:.25,clouds:.55,cloudOpacity:.6,relief:.7,atmoGlow:1.2,ambient:.05},
"♨ Hycean world — hot ocean + H₂ haze":{mode:"rocky",palette:"ocean",
tectonics:.3,mountains:.4,volcano:.15,craters:.02,erosion:.4,sea:.97,iceCap:.04,
clouds:.8,cloudOpacity:.75,tholin:.1,relief:.5,haze:.5,hazeLayers:.5,
atmThickness:.5,rayleigh:.25,atmoGlow:1.8,ambient:.06},
"🔥 Lava world — magma ocean":{mode:"rocky",palette:"lavaworld",
tectonics:.4,mountains:.5,volcano:1,floods:.6,craters:.05,erosion:.05,
lavaWorld:1,relief:1.0,albedoProv:.3,atmoGlow:.8,ambient:.03},
"👁 Eyeball world — locked ice + substellar sea":{mode:"rocky",palette:"ocean",
tectonics:.3,mountains:.4,volcano:.15,erosion:.3,sea:.3,tidalLock:1,frozen:1,
clouds:.3,cloudOpacity:.4,relief:.7,atmoGlow:1.0,ambient:.04},
"💎 Carbon / diamond world":{mode:"rocky",palette:"carbon",
tectonics:.4,mountains:.5,volcano:.3,floods:.2,craters:.5,craterSize:.6,
erosion:.2,dunes:.2,weather:.4,relief:1.0,albedoProv:.4,atmoGlow:.3,ambient:.03},
"⚙ Super-Io — tidally-heated exomoon":{mode:"rocky",palette:"io",
tectonics:.3,mountains:.4,volcano:1,floods:.6,craters:0,erosion:.1,sulfur:1,
lavaWorld:.35,relief:1.1,albedoProv:.6,atmoGlow:.3,ambient:.04},
"🫧 Mini-Neptune — hazy envelope":{mode:"gas",palette:"subneptune",oblate:.03,
bands:4,bandWarp:.15,bandIrregular:.2,altColor:.15,storms:.1,bandContrast:.12,
atmoGlow:1.6,relief:.1,ambient:.04},
"❄ Snowball world — fully glaciated":{mode:"rocky",palette:"snowball",
tectonics:.5,mountains:.6,volcano:.1,erosion:.4,sea:.5,frozen:1,iceCap:.5,
clouds:.4,cloudOpacity:.5,relief:.8,atmoGlow:1.0,ambient:.05},
"🪨 Chthonian — stripped hot core":{mode:"rocky",palette:"chthonian",
tectonics:.5,mountains:.5,volcano:.7,floods:.5,craters:.2,erosion:.1,
lavaWorld:.5,tidalLock:.6,oxidation:.2,relief:1.0,atmoGlow:.4,ambient:.03},
"🌑 Rogue planet — starless drifter":{mode:"rocky",palette:"callisto",
tectonics:.3,mountains:.4,craters:.5,craterSize:.6,erosion:.1,frozen:1,iceCap:.4,
weather:.4,relief:.8,sunEl:-5,atmoGlow:0,ambient:.13},
"☄️ Comet — active icy nucleus":{mode:"airless",palette:"callisto",
irregular:.9,lobes:.5,tectonics:.1,mountains:.2,craters:.4,craterSize:.7,
sublimation:.6,weather:.3,relief:1.5,tail:1,albedoProv:.4,sunEl:10,ambient:.04},
"🌬 Cometary planet — evaporating hot Neptune":{mode:"gas",palette:"subneptune",oblate:.05,
bands:5,bandWarp:.25,bandIrregular:.3,altColor:.2,storms:.2,bandContrast:.25,
tidalLock:.7,tail:1,atmoGlow:1.5,relief:.12,ambient:.03}
};
const PALETTES={
mars:[[0,60,28,18],[.35,120,55,32],[.55,165,82,46],[.75,200,120,70],[.92,225,170,130],[1,240,225,210]],
moon:[[0,42,42,48],[.4,80,80,88],[.6,120,120,128],[.8,165,165,172],[1,210,210,214]],
mercury:[[0,55,48,44],[.4,95,84,76],[.65,140,122,108],[.85,180,160,142],[1,215,200,185]],
earth:[[0,40,90,70],[.4,70,130,75],[.6,110,150,80],[.72,150,140,100],[.85,120,95,70],[1,235,238,242]],
venus:[[0,90,60,28],[.4,150,110,50],[.65,190,150,75],[.85,215,185,120],[1,235,215,170]],
europa:[[0,150,140,130],[.45,205,195,185],[.7,228,222,216],[1,245,243,240]],
enceladus:[[0,200,205,212],[.5,230,234,240],[1,250,252,255]],
ganymede:[[0,90,80,72],[.45,140,128,116],[.7,180,170,158],[1,212,205,196]],
callisto:[[0,55,48,44],[.4,95,84,74],[.7,140,124,108],[1,185,168,150]],
iapetus:[[0,60,52,44],[.5,150,138,122],[1,225,218,205]],
io:[[0,90,70,20],[.3,180,150,40],[.5,225,200,70],[.7,235,170,55],[.85,200,90,40],[1,245,230,150]],
titan:[[0,90,55,25],[.35,140,95,45],[.6,175,130,70],[.8,200,160,100],[1,220,195,150]],
jupiter:[[0,150,110,80],[.5,210,185,150],[1,245,238,225]],
saturn:[[0,180,150,110],[.5,220,200,160],[1,245,235,210]],
neptune:[[0,30,55,120],[.5,45,90,170],[1,120,175,230]],
uranus:[[0,150,200,205],[.5,175,215,218],[1,205,233,233]],
desert:[[0,120,85,50],[.4,170,130,80],[.65,205,170,115],[.85,225,200,150],[1,240,225,195]],
pluto:[[0,90,72,60],[.4,140,115,98],[.65,180,160,140],[.85,210,195,178],[1,238,232,222]],
vesta:[[0,70,62,54],[.45,120,108,94],[.7,160,146,128],[1,200,186,166]],
// ---- exoplanet / theoretical worlds ----
lavaworld:[[0,28,14,11],[.4,60,30,18],[.7,110,55,30],[1,160,95,55]],
carbon:[[0,16,16,20],[.5,38,38,44],[.8,70,70,78],[1,110,110,118]],
hotjup:[[0,110,55,48],[.5,200,120,88],[1,248,212,182]],
subneptune:[[0,80,105,118],[.5,135,158,162],[1,195,210,210]],
chthonian:[[0,38,22,16],[.5,90,55,38],[.8,150,100,70],[1,195,150,115]],
snowball:[[0,195,212,228],[.5,222,233,244],[1,248,250,255]],
ocean:[[0,8,34,72],[.45,18,66,116],[.62,34,96,150],[.8,90,150,190],[1,190,215,238]]
};
/* ============== STATE ============== */
const D={
seed:1337,preset:Object.keys(PRESETS)[0],mode:"rocky",palette:"mars",
// figure & illumination
oblate:0,irregular:0,lobes:0,axialTilt:0,sunAz:300,sunEl:18,ambient:.04,relief:1.1,
// circumplanetary extras
aurora:0,tail:0,
// endogenic
tectonics:.55,mountains:.6,scarps:0,wrinkle:0,volcano:.7,floods:.35,
// exogenic
craters:.45,craterSize:.6,relaxation:0,multiring:0,secondaries:0,
erosion:.4,dunes:.4,stratify:0,weather:.15,
// cryo
sea:0,iceCap:.18,seasonalFrost:0,linea:0,doubleRidge:0,tiger:0,sulci:0,
chaos:0,nitrogenGlacier:0,sublimation:0,hydrocarbon:0,equatorRidge:0,
// albedo provinces
albedoProv:0,dichotomy:0,hemiAlbedo:0,polarTholin:0,slopeStreaks:0,
// atmosphere & clouds
clouds:0,cloudOpacity:.6,dustStorm:0,atmoGlow:0,cityLights:0,oppSurge:0,
atmThickness:0,rayleigh:0,haze:0,hazeLayers:0,limbDarkening:0,cloudShadows:0,
// gas
bands:9,bandWarp:.45,bandIrregular:.5,altColor:.5,storms:.7,redspot:0,
darkspot:0,polarVortex:0,hexagon:0,bandContrast:.6,
// rings & moons
rings:0,ringInner:1.3,ringOuter:2.2,ringTilt:.5,ringOpacity:.85,moons:0,
// exoworld physics
tidalLock:0,lavaWorld:0,frozen:0,
// pass-2 processes: erosion family, regional structure, albedo, cryo detail, optics
rivers:0,masswaste:0,rifts:0,coronae:0,tesserae:0,yardangs:0,
windStreaks:0,swirls:0,provinces:0,faculae:0,
cryoDome:0,cantaloupe:0,wispy:0,polygons:0,pldSpiral:0,
castShadow:0,starTemp:.5,earthshine:0,lommel:0,cyclones:0,multiDeck:0,lightning:0,ringSpokes:0,polarHood:0,
// pass-3: regional structure, glacial/coastal, freeze-state, optics
plumeSwell:0,volcanicPlains:0,glacial:0,coastlines:0,inverted:0,degradation:0,
glacialFlow:0,frozenSea:0,seaIce:0,spiders:0,latBands:0,devilTracks:0,ringForward:0,
// coloration
oxidation:0,tholin:0,sulfur:0
};
const S=Object.assign({},D);
/* ============== MAPS ============== */
const W=1024,H=512;
const Hf=new Float32Array(W*H); // elevation
const Ab=new Float32Array(W*H); // albedo multiplier
const Tg=new Uint8Array(W*H); // 0 rock,1 ocean,2 mare/lava,3 ice,4 fracture,5 sand,6 hcarbon-lake,7 n2-glacier
const Rb=new Float32Array(W*H); // brightness add (rays)
const Cl=new Float32Array(W*H); // cloud density (upper deck)
const Cl2=new Float32Array(W*H); // cloud density (lower deck)
const Shp=new Float32Array(W*H); // figure radial multiplier (irregular bodies)
const En=new Float32Array(W*H); // emissive (city lights)
const colR=new Uint8ClampedArray(W*H),colG=new Uint8ClampedArray(W*H),colB=new Uint8ClampedArray(W*H);
const Nx=new Float32Array(W*H),Ny=new Float32Array(W*H),Nz=new Float32Array(W*H);
let HEMI=[1,0,0]; // dichotomy / albedo hemisphere axis (model space)
let WIND=[1,0,0]; // global prevailing wind direction (model space)
function dirOf(x,y){
const lon=(x/W)*2*Math.PI-Math.PI, lat=Math.PI/2-(y/H)*Math.PI, cl=Math.cos(lat);
return[cl*Math.sin(lon),Math.sin(lat),cl*Math.cos(lon)];
}
function texelOf(d){
const lon=Math.atan2(d[0],d[2]), lat=Math.asin(clamp(d[1],-1,1));
let u=((lon+Math.PI)/(2*Math.PI))*W|0; if(u<0)u=0;if(u>=W)u=W-1;
let v=((Math.PI/2-lat)/Math.PI)*H|0; if(v<0)v=0;if(v>=H)v=H-1;
return v*W+u;
}
function randDir(rng){const z=rng()*2-1,a=rng()*2*Math.PI,r=Math.sqrt(1-z*z);return[r*Math.cos(a),z,r*Math.sin(a)];}
/* ============== BUILD ============== */
function build(){
const t0=performance.now();
seedNoise(S.seed);
const rng=mulberry32(S.seed^0x9e37);
Hf.fill(0);Ab.fill(1);Tg.fill(0);Rb.fill(0);Cl.fill(0);Shp.fill(1);En.fill(0);
HEMI=randDir(rng);
if(S.mode==="gas") buildGas(rng); else buildSolid(rng);
if(S.irregular>0) buildShape(rng);
buildClouds(rng);
computeNormals(S.mode==="gas");
computeColor(S.mode==="gas");
buildEmissive();
drawPipe();
hud(`built in ${(performance.now()-t0)|0} ms`);
}
function normField(){let mn=1e9,mx=-1e9;for(let i=0;i<Hf.length;i++){if(Hf[i]<mn)mn=Hf[i];if(Hf[i]>mx)mx=Hf[i];}
const r=mx-mn||1;for(let i=0;i<Hf.length;i++)Hf[i]=(Hf[i]-mn)/r;}
function buildSolid(rng){
const cont=S.tectonics,mtn=S.mountains;
WIND=randDir(rng);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){
const d=dirOf(x,y),i=y*W+x;
let h=fbm(d[0]*1.4,d[1]*1.4,d[2]*1.4,6,2.0,.55);
h+=0.4*fbm(d[0]*3.5,d[1]*3.5,d[2]*3.5,5,2.1,.5);
let r=ridged(d[0]*2.2,d[1]*2.2,d[2]*2.2,5,2.0,.55);
h=h*(0.4+0.6*cont)+(r-0.4)*1.3*mtn;
// hemispheric dichotomy: degree-1 elevation offset along HEMI axis
if(S.dichotomy>0){const dot=d[0]*HEMI[0]+d[1]*HEMI[1]+d[2]*HEMI[2];h+=dot*0.45*S.dichotomy;}
Hf[i]=h;
}
normField();
if(S.craters>0) stampCraters(rng);
if(S.plumeSwell>0)plumeSwell(rng);
if(S.volcano>0) stampVolcanoes(rng);
if(S.floods>0) floodBasins();
if(S.volcanicPlains>0)volcanicPlains();
if(S.wrinkle>0) wrinkleRidges();
if(S.scarps>0) lobateScarps(rng);
if(S.rifts>0) riftGrabens(rng);
if(S.coronae>0) coronae(rng);
if(S.tesserae>0)tesserae();
if(S.erosion>0) thermalErode(Math.round(S.erosion*14));
if(S.degradation>0)degradeCraters(S.degradation);
if(S.rivers>0) flowErode(S.rivers);
if(S.masswaste>0)massWaste(S.masswaste);
if(S.glacial>0) glacialErode(S.glacial);
if(S.stratify>0)stratify();
if(S.dunes>0) applyDunes();
if(S.yardangs>0)yardangs();
if(S.coastlines>0)coastlines();
if(S.inverted>0)invertedRelief();
// cryo / ice refinements
if(S.linea>0) stampLineae(rng);
if(S.tiger>0) stampTigerStripes(rng);
if(S.sulci>0) stampSulci(rng);
if(S.chaos>0) applyChaos(rng);
if(S.cryoDome>0) cryoDomes(rng);
if(S.cantaloupe>0) cantaloupe();
if(S.nitrogenGlacier>0) nitrogenGlacier();
if(S.glacialFlow>0) glacialFlow();
if(S.sublimation>0) sublimation();
if(S.equatorRidge>0) equatorRidge();
if(S.polygons>0) polygonCracks();
if(S.wispy>0) wispyTerrain(rng);
tagOceanIce();
if(S.frozenSea>0) frozenSea();
if(S.seaIce>0) seaIce();
if(S.spiders>0) spiders(rng);
if(S.pldSpiral>0) pldSpiral();
// relief-free albedo provinces (overlay after tagging)
if(S.windStreaks>0) windStreaks();
if(S.swirls>0) magSwirls();
if(S.provinces>0) sharpProvinces(rng);
if(S.faculae>0) faculae(rng);
}
/* impact craters w/ relaxation, multiring, secondaries, dichotomy density */
function stampCraters(rng){
let n=Math.round(S.craters*420);
for(let c=0;c<n;c++){
let cd=randDir(rng);
// dichotomy: bias more craters to the "old" hemisphere
if(S.dichotomy>0){const dot=cd[0]*HEMI[0]+cd[1]*HEMI[1]+cd[2]*HEMI[2];
if(dot>0 && rng()<S.dichotomy*0.6){cd=randDir(rng);}}
const big=rng()<0.12;
let rad=(0.006+rng()*0.05*S.craterSize)*(big?2.4:1);
const depth=rad*(0.7+rng()*0.5);
const fresh=rng()<0.18;
const peak=big&&rng()<0.6;
const relax=S.relaxation*(0.4+rng()*0.6); // 0=sharp,1=flat palimpsest
stampCrater(cd,rad,depth,fresh,peak,relax,c);
// multi-ring basins for the largest impacts
if(big&&S.multiring>0&&rng()<S.multiring){
const nr=2+(S.multiring*3|0);
for(let k=1;k<=nr;k++){const rr=rad*(1+k*0.6);
ringGraben(cd,rr,depth*0.25*(1-k/(nr+1)));}
}
// secondary crater chains
if(fresh&&S.secondaries>0){
const ns=(S.secondaries*14)|0;
for(let s=0;s<ns;s++){
const t=rad*(1.4+rng()*3);const br=rng()*2*Math.PI;
const sd=rotAround(cd,br,t);
stampCrater(sd,rad*(0.15+rng()*0.2),depth*0.2,false,false,0.2,c*7+s);
}
}
}
}
function stampCrater(cd,rad,depth,fresh,peak,relax,seed){
const cx=Math.atan2(cd[0],cd[2]),cy=Math.asin(cd[1]);
const px=((cx+Math.PI)/(2*Math.PI))*W,py=((Math.PI/2-cy)/Math.PI)*H;
const span=Math.ceil(rad*1.9/Math.PI*H)+2;
for(let dy=-span;dy<=span;dy++){let yy=(py+dy)|0;if(yy<0||yy>=H)continue;
const latw=Math.max(0.15,Math.cos(Math.PI/2-(yy/H)*Math.PI));
const xspan=Math.ceil(span/latw)+1;
for(let dx=-xspan;dx<=xspan;dx++){let xx=((px+dx)%W+W)%W|0;
const d=dirOf(xx,yy);
const dot=clamp(d[0]*cd[0]+d[1]*cd[1]+d[2]*cd[2],-1,1);
const t=Math.acos(dot)/rad,i=yy*W+xx;
if(t<1.3){
const rim=depth*0.55*Math.exp(-((t-1)/0.18)*((t-1)/0.18));
const bowl=t<1?-depth*(1-t*t):0;
let dh=rim+bowl;
if(peak&&t<0.22)dh+=depth*0.8*(1-(t/0.22)*(t/0.22));
Hf[i]+=dh*(1-relax*0.85); // viscous relaxation flattens relief
Ab[i]*=fresh?lerp(1,1.4,clamp(1-t,0,1)):lerp(1,1.05,clamp(1-t,0,1));
if(relax>0.4&&t<1)Ab[i]*=1.04; // palimpsest = bright circular patch
}
if(fresh&&t>=1&&t<6){
const bearing=Math.atan2(d[1]-cd[1],d[0]-cd[0]);
const ray=Math.pow(Math.abs(Math.sin(bearing*7+seed)),8);
if(ray>0.4)Rb[i]+=ray*0.5*Math.exp(-t/3);
}
}
}
}
function ringGraben(cd,rad,amp){
const px=((Math.atan2(cd[0],cd[2])+Math.PI)/(2*Math.PI))*W,py=((Math.PI/2-Math.asin(cd[1]))/Math.PI)*H;
const span=Math.ceil(rad*1.3/Math.PI*H)+2;
for(let dy=-span;dy<=span;dy++){let yy=(py+dy)|0;if(yy<0||yy>=H)continue;
const latw=Math.max(0.15,Math.cos(Math.PI/2-(yy/H)*Math.PI)),xspan=Math.ceil(span/latw)+1;
for(let dx=-xspan;dx<=xspan;dx++){let xx=((px+dx)%W+W)%W|0;
const d=dirOf(xx,yy);const ang=Math.acos(clamp(d[0]*cd[0]+d[1]*cd[1]+d[2]*cd[2],-1,1));
const dd=Math.abs(ang-rad);if(dd<0.02){const i=yy*W+xx;Hf[i]-=amp*(1-dd/0.02);}}}
}
function rotAround(axis,ang,dist){ // return dir at angular dist from axis at bearing ang
let t=[axis[1],axis[2],axis[0]];const e=nrm([axis[1]*t[2]-axis[2]*t[1],axis[2]*t[0]-axis[0]*t[2],axis[0]*t[1]-axis[1]*t[0]]);
const f=nrm([axis[1]*e[2]-axis[2]*e[1],axis[2]*e[0]-axis[0]*e[2],axis[0]*e[1]-axis[1]*e[0]]);
const sd=Math.sin(dist),cd2=Math.cos(dist),ca=Math.cos(ang),sa=Math.sin(ang);
return nrm([axis[0]*cd2+sd*(e[0]*ca+f[0]*sa),axis[1]*cd2+sd*(e[1]*ca+f[1]*sa),axis[2]*cd2+sd*(e[2]*ca+f[2]*sa)]);
}
function stampVolcanoes(rng){
const n=Math.round(S.volcano*(S.sulfur?60:22));
for(let c=0;c<n;c++){
const cd=randDir(rng),rad=0.04+rng()*0.10,hgt=(0.12+rng()*0.18)*S.relief;
const px=((Math.atan2(cd[0],cd[2])+Math.PI)/(2*Math.PI))*W,py=((Math.PI/2-Math.asin(cd[1]))/Math.PI)*H;
const span=Math.ceil(rad*1.6/Math.PI*H)+2;
for(let dy=-span;dy<=span;dy++){let yy=(py+dy)|0;if(yy<0||yy>=H)continue;
const latw=Math.max(0.15,Math.cos(Math.PI/2-(yy/H)*Math.PI)),xspan=Math.ceil(span/latw)+1;
for(let dx=-xspan;dx<=xspan;dx++){let xx=((px+dx)%W+W)%W|0;
const d=dirOf(xx,yy);const t=Math.acos(clamp(d[0]*cd[0]+d[1]*cd[1]+d[2]*cd[2],-1,1))/rad,i=yy*W+xx;
if(t<1.4){Hf[i]+=hgt*Math.exp(-t*t*2.2);
if(t<0.14){Hf[i]-=hgt*0.32;Tg[i]=2;Ab[i]*=0.55;} // collapse caldera
else if(t<0.9&&S.sulfur)Tg[i]=2;
else if(t<1.1&&!S.sulfur)Ab[i]*=lerp(1,0.85,1-t); } // pyroclastic mantle
}}
}
normField();
}
function floodBasins(){
const level=lerp(0.18,0.5,S.floods);
for(let i=0;i<Hf.length;i++)if(Hf[i]<level&&Tg[i]!==3){Hf[i]=level-(level-Hf[i])*0.12;Tg[i]=2;Ab[i]*=0.62;}
}
function wrinkleRidges(){ // branching compressional ridges on mare plains
const amp=S.wrinkle*0.012;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]!==2)continue;
const d=dirOf(x,y);const r=ridged(d[0]*9+5,d[1]*9,d[2]*9+5,3,2.2,.5);
if(r>0.55)Hf[i]+=(r-0.55)*amp*6;}
}
function lobateScarps(rng){ // one-sided thrust scarps (planetary contraction)
const n=Math.round(S.scarps*18);
for(let c=0;c<n;c++){const pn=randDir(rng),off=(rng()-0.5)*1.2,amp=0.02*S.scarps;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);
const s=Math.asin(clamp(d[0]*pn[0]+d[1]*pn[1]+d[2]*pn[2],-1,1))-off*0.3;
const w=0.05;if(s>-w&&s<w){const i=y*W+x;Hf[i]+=amp*smooth(-w,w,s);}}}
}
function thermalErode(iter){
const talus=0.004,k=0.5;
for(let it=0;it<iter;it++)for(let y=1;y<H-1;y++){const yo=y*W;
for(let x=0;x<W;x++){const i=yo+x,l=yo+((x-1+W)%W),r=yo+((x+1)%W),u=i-W,dn=i+W;
let dmax=0,tgt=-1;for(const j of[l,r,u,dn]){const df=Hf[i]-Hf[j];if(df>dmax){dmax=df;tgt=j;}}
if(dmax>talus){const mv=(dmax-talus)*k*0.5;Hf[i]-=mv;Hf[tgt]+=mv;}}}
}
function stratify(){ // sedimentary / polar layered banding (albedo + micro-terrace)
const a=S.stratify;
for(let i=0;i<Hf.length;i++){const band=Math.sin(Hf[i]*60);Ab[i]*=1+band*0.10*a;Hf[i]+=band*0.0025*a;}
}
function applyDunes(){
const amp=S.dunes*0.05;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]===2||Tg[i]===3)continue;
const d=dirOf(x,y);const warp=fbm(d[0]*1.5,d[1]*1.5,d[2]*1.5,3,2,.5);
const ripple=Math.sin((d[0]*40+d[2]*30)+warp*6);const flat=clamp(1-Math.abs(Hf[i]-0.4)*3,0,1);
if(ripple>0){Hf[i]+=ripple*amp*flat;if(!Tg[i])Tg[i]=5;}}
}
/* tidal lineae + Europa double-ridges / cycloids */
function stampLineae(rng){
const n=Math.round(S.linea*40),dr=S.doubleRidge;
for(let c=0;c<n;c++){
const pn=randDir(rng);const width=0.004+rng()*0.012;const reddish=rng()<0.6;
const cyc=dr>0.3?0.04*Math.sin:null; // cycloidal modulation flag
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);
let s=Math.asin(clamp(d[0]*pn[0]+d[1]*pn[1]+d[2]*pn[2],-1,1));
if(cyc)s-=0.03*dr*Math.sin(Math.atan2(d[0],d[2])*8); // scalloped cycloid path
const dist=Math.abs(s);
if(dist<width*2){const i=y*W+x;
if(dr>0.2){ // raised double ridge: two flanks + central groove
const u=dist/width;Hf[i]+=(Math.exp(-((u-0.7)*(u-0.7))*6)-0.6*Math.exp(-u*u*9))*0.02*dr;
}else if(dist<width)Hf[i]-=0.01*(1-dist/width);
if(dist<width){Tg[i]=4;Ab[i]*=reddish?0.82:1.1;if(reddish)Rb[i]-=0.12*(1-dist/width);}
}}
}
}
function stampTigerStripes(rng){
const base=randDir(rng);
for(let s=0;s<4;s++){const off=(s-1.5)*0.06;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);if(d[1]>-0.3)continue;
const dist=Math.abs(Math.asin(clamp(d[0]*base[0]+d[1]*base[1]+d[2]*base[2],-1,1))-off);
if(dist<0.01){const i=y*W+x;Hf[i]-=0.02;Tg[i]=4;Ab[i]*=0.7;Rb[i]-=0.2;}}}
}
function stampSulci(rng){ // Ganymede grooved-terrain swaths: bands of parallel grooves
const a=S.sulci;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
const region=fbm(d[0]*2.5,d[1]*2.5,d[2]*2.5,3,2,.5);
if(region>0.55-0.25*a){const ang=fbm(d[0]*1.5,d[1]*1.5,d[2]*1.5,2,2,.5)*6;
const groove=Math.sin((d[0]*Math.cos(ang)+d[2]*Math.sin(ang))*90+d[1]*40);
Hf[i]+=groove*0.01*a;Ab[i]*=1+groove*0.06*a;}}
}
function applyChaos(rng){
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
const m=fbm(d[0]*6,d[1]*6,d[2]*6,3,2.3,.5);
if(m>0.25*(1.2-S.chaos))Hf[i]+=(Math.round(m*8)/8-m)*0.15;}
}
function nitrogenGlacier(){ // Pluto Sputnik Planitia: bright flat fill + convection polygons
const a=S.nitrogenGlacier,level=0.34;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;
if(Hf[i]<level){const d=dirOf(x,y);
Hf[i]=level-(level-Hf[i])*0.05;Tg[i]=7;Ab[i]*=1.25;
// Voronoi-ish polygon ridges
const cell=ridged(d[0]*22,d[1]*22,d[2]*22,2,2,.5);
if(cell>0.78)Hf[i]+=(cell-0.78)*0.04*a;}}
}
function sublimation(){ // scalloped pits + bladed terrain on equator-facing ice
const a=S.sublimation;
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI;const eq=clamp(1-Math.abs(lat)*1.4,0,1);
for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
const pit=fbm(d[0]*30,d[1]*30,d[2]*30,2,2.4,.5);
if(pit>0.2)Hf[i]-=(pit-0.2)*0.03*a*eq;
const blade=Math.abs(Math.sin(d[1]*120));Hf[i]+=blade*0.006*a*eq*smooth(0.4,0.6,Hf[i]);}}
}
function equatorRidge(){ // Iapetus / Vesta equatorial mountain belt
const a=S.equatorRidge;
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI;const b=Math.exp(-(lat*lat)/(0.01));
if(b<0.02)continue;for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
Hf[i]+=b*0.12*a*(0.7+0.3*fbm(d[0]*8,d[1]*8,d[2]*8,2,2,.5));}}
}
/* ---- flow-routing hydraulic erosion → dendritic rivers, deltas, lakebeds ---- */
function flowErode(str){
const N=W*H, order=new Int32Array(N);
for(let i=0;i<N;i++)order[i]=i;
order.sort((a,b)=>Hf[b]-Hf[a]); // process high → low
const acc=new Float32Array(N), down=new Int32Array(N);down.fill(-1);
for(let k=0;k<N;k++){const i=order[k],x=i%W,y=(i/W)|0;acc[i]+=1;
let best=-1,bh=Hf[i];
for(let dy=-1;dy<=1;dy++){const ny=y+dy;if(ny<0||ny>=H)continue;
for(let dx=-1;dx<=1;dx++){if(!dx&&!dy)continue;const nx=(x+dx+W)%W,j=ny*W+nx;
if(Hf[j]<bh){bh=Hf[j];best=j;}}}
down[i]=best;if(best>=0)acc[best]+=acc[i];
}
const sea=S.sea;
for(let i=0;i<N;i++){const a=acc[i];
Hf[i]-=Math.min(str*0.02*Math.pow(a,0.45),0.07); // stream-power incision
if(a>40){
if(down[i]>=0&&Hf[down[i]]<sea&&sea>0)Ab[i]*=1.35; // delta fan at river mouth
else if(down[i]<0){Hf[i]+=0.004;Ab[i]*=1.18;} // closed-basin lakebed (bright playa)
else if(Tg[i]!==1&&Tg[i]!==6){Ab[i]*=0.78;if(!Tg[i])Tg[i]=8;} // incised channel (tag 8)
}
}
normField();
}
function massWaste(str){
for(let y=1;y<H-1;y++){const yo=y*W;
for(let x=0;x<W;x++){const i=yo+x,dn=i+W,l=yo+((x-1+W)%W),r=yo+((x+1)%W);
const grad=Math.max(Math.abs(Hf[r]-Hf[l]),Math.abs(Hf[dn]-Hf[i]));
if(grad>0.03){const mv=grad*str*0.3,tgt=Hf[dn]<Hf[i]?dn:i;Hf[i]-=mv*0.5;Hf[tgt]+=mv*0.5;Ab[i]*=1.04;}}}
}
function riftGrabens(rng){
const n=Math.round(S.rifts*9);
for(let c=0;c<n;c++){const pn=randDir(rng),off=(rng()-0.5)*1.0,w=0.02+rng()*0.02,depth=0.05*S.rifts;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);
const s=Math.asin(clamp(d[0]*pn[0]+d[1]*pn[1]+d[2]*pn[2],-1,1))-off*0.3,as=Math.abs(s);
const i=y*W+x;
if(as<w)Hf[i]-=depth*(1-(as/w)*(as/w)); // flat-floored trough with steep walls
else if(as<w*1.5)Hf[i]+=depth*0.18;}} // rift shoulders
}
function coronae(rng){
const n=Math.round(S.coronae*14);
for(let c=0;c<n;c++){const cd=randDir(rng),rad=0.05+rng()*0.07;
radialStamp(cd,rad,1.3,(i,t)=>{Hf[i]+=0.04*Math.exp(-t*t*2)*S.coronae;
Hf[i]-=Math.exp(-((t-1)/0.12)*((t-1)/0.12))*0.02*S.coronae;});} // central rise + fracture moat
}
function tesserae(){
const a=S.tesserae;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
if(fbm(d[0]*2+30,d[1]*2,d[2]*2+30,3,2,.5)>0.5-0.3*a&&Hf[i]>0.45){
const r1=ridged(d[0]*14,d[1]*14,d[2]*14,2,2,.5),r2=ridged(d[2]*14+5,d[0]*14,d[1]*14+5,2,2,.5);
Hf[i]+=(r1+r2-1)*0.03*a;}} // crosshatched two-axis deformation (Venus highlands)
}
function windStreaks(){
const a=S.windStreaks;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
let ob=0;for(const dd of[0.02,0.05,0.09,0.14]){const j=texelOf([d[0]-WIND[0]*dd,d[1]-WIND[1]*dd,d[2]-WIND[2]*dd]);
ob=Math.max(ob,Hf[j]-Hf[i]-dd*0.5);}
if(ob>0.01)Ab[i]*=1-a*0.22*clamp(ob*6,0,1);} // dark deposition tail downwind of obstacles
}
function magSwirls(){
const a=S.swirls;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
const wp=fbm(d[0]*2,d[1]*2,d[2]*2,3,2,.5)*3;
const s=ridged(d[0]*4+Math.cos(wp),d[1]*4,d[2]*4+Math.sin(wp),3,2.2,.5);
if(s>0.6)Ab[i]*=1+a*(s-0.6)*1.6;} // bright sinuous swirls (Reiner Gamma)
}
function sharpProvinces(rng){
const a=S.provinces,n=8+((a*16)|0),pts=[];
for(let k=0;k<n;k++)pts.push({d:randDir(rng),v:0.7+rng()*0.55});
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
let best=-9,bv=1;for(const p of pts){const dot=d[0]*p.d[0]+d[1]*p.d[1]+d[2]*p.d[2];if(dot>best){best=dot;bv=p.v;}}
Ab[i]*=lerp(1,bv,a);} // Voronoi compositional units (sharp edges)
}
function faculae(rng){
const n=Math.round(S.faculae*28);
for(let c=0;c<n;c++){const cd=randDir(rng),rad=0.008+rng()*0.018;
radialStamp(cd,rad,1.0,(i,t)=>{Ab[i]*=1+S.faculae*1.6*(1-t);});} // bright salt spots (Occator)
}
function cryoDomes(rng){
const n=Math.round(S.cryoDome*16);
for(let c=0;c<n;c++){const cd=randDir(rng),rad=0.02+rng()*0.03,h=0.04+rng()*0.04;
radialStamp(cd,rad,1.4,(i,t)=>{Hf[i]+=h*Math.exp(-t*t*2.2);Ab[i]*=1+0.2*Math.exp(-t*t*2);});}
}
function cantaloupe(){
const a=S.cantaloupe;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
Hf[i]-=(1-ridged(d[0]*16,d[1]*16,d[2]*16,2,2,.5))*0.025*a;} // dimpled cells (Triton)
}
function wispyTerrain(rng){
const n=Math.round(S.wispy*30);
for(let c=0;c<n;c++){const pn=randDir(rng),w=0.003+rng()*0.005;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);
const s=Math.abs(Math.asin(clamp(d[0]*pn[0]+d[1]*pn[1]+d[2]*pn[2],-1,1)));
if(s<w){const i=y*W+x;Ab[i]*=1+S.wispy*0.4*(1-s/w);}}} // bright braided fracture cliffs (Dione)
}
function polygonCracks(){
const a=S.polygons;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]!==3&&Tg[i]!==7&&Hf[i]>0.5)continue;
const d=dirOf(x,y),c=ridged(d[0]*40,d[1]*40,d[2]*40,2,2,.5);
if(c>0.8){Hf[i]-=(c-0.8)*0.02*a;Ab[i]*=1-0.1*a;}} // thermal-contraction polygons
}
function yardangs(){
const a=S.yardangs;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Hf[i]<0.35||Hf[i]>0.6)continue;
const d=dirOf(x,y),g=Math.abs(Math.sin((d[0]*WIND[2]-d[2]*WIND[0])*60));
Hf[i]-=g*0.01*a;} // wind-fluted ridges (Medusae Fossae)
}
function pldSpiral(){
const a=S.pldSpiral;
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI;if(Math.abs(lat)<1.0)continue;
const r=Math.PI/2-Math.abs(lat);
for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]!==3)continue;const lon=(x/W)*2*Math.PI;
if(Math.sin(lon+r*40)>0.5){Hf[i]-=0.006*a;Ab[i]*=0.95;}}} // spiral troughs in polar layered deposits
}
/* ---- pass-3: regional structure, glacial, coastal, freeze-state ---- */
function plumeSwell(rng){
const n=Math.round(S.plumeSwell*5);
for(let c=0;c<n;c++){const cd=randDir(rng),rad=0.3+rng()*0.25,amp=0.12*S.plumeSwell;
radialStamp(cd,rad,1.0,(i,t)=>{Hf[i]+=amp*Math.exp(-t*t*1.4);});} // broad regional uplift (Tharsis)
normField();
}
function volcanicPlains(){
const a=S.volcanicPlains;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
if(fbm(d[0]*2.5+12,d[1]*2.5,d[2]*2.5+12,3,2,.5)>0.55-0.2*a){
Hf[i]=lerp(Hf[i],0.6,0.8*a);Tg[i]=2;Ab[i]*=0.7;}} // constructional positive basalt plateau
}
function glacialErode(str){
for(let y=1;y<H-1;y++){const lat=Math.PI/2-(y/H)*Math.PI,cold=clamp(Math.abs(Math.sin(lat))*1.4-0.3,0,1);if(cold<0.05)continue;
const yo=y*W;for(let x=0;x<W;x++){const i=yo+x,l=yo+((x-1+W)%W),r=yo+((x+1)%W),u=i-W,dn=i+W;
const avg=(Hf[l]+Hf[r]+Hf[u]+Hf[dn])*0.25;
Hf[i]=lerp(Hf[i],Math.min(avg,Hf[i]+0.02),str*cold*0.5);}} // U-valley floor flattening (glacial)
}
function coastlines(){
const sea=S.sea,w=0.012*S.coastlines;if(sea<=0)return;
for(let i=0;i<Hf.length;i++){const dh=Hf[i]-sea;
if(dh>0&&dh<w)Hf[i]=sea+dh*0.3; // wave-cut bench
else if(dh>=w&&dh<w*2)Hf[i]+=w*0.4;} // backing sea-cliff
}
function invertedRelief(){
const a=S.inverted;
for(let i=0;i<Hf.length;i++){if(Tg[i]===8)Hf[i]+=0.02*a;else if(!Tg[i])Hf[i]-=0.01*a;} // channels stand proud
normField();
}
function degradeCraters(a){
const tmp=Float32Array.from(Hf);
for(let y=1;y<H-1;y++){const yo=y*W;for(let x=0;x<W;x++){const i=yo+x,l=yo+((x-1+W)%W),r=yo+((x+1)%W),u=i-W,dn=i+W;
Hf[i]=lerp(tmp[i],(tmp[l]+tmp[r]+tmp[u]+tmp[dn])*0.25,a*0.4);}} // age-softened crater rims
}
function glacialFlow(){
const a=S.glacialFlow;
for(let it=0;it<3;it++)for(let y=1;y<H-1;y++){const yo=y*W;for(let x=0;x<W;x++){const i=yo+x;if(Tg[i]!==7)continue;
const dn=i+W,r=yo+((x+1)%W),l=yo+((x-1+W)%W),u=i-W;
for(const j of[dn,u,l,r])if(Hf[j]<Hf[i]-0.005&&Tg[j]!==7){const mv=(Hf[i]-Hf[j])*0.3*a;Hf[j]+=mv;Tg[j]=7;Ab[j]*=1.2;}}} // lobate N2 tongues
}
function frozenSea(){
const a=S.frozenSea;
for(let y=0;y<H;y++){const cold=Math.abs(Math.sin(Math.PI/2-(y/H)*Math.PI));
for(let x=0;x<W;x++){const i=y*W+x;if((Tg[i]===1||Tg[i]===6)&&cold>1-a)Tg[i]=3;}} // freeze a lid on cold seas
}
function seaIce(){
const a=S.seaIce;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]!==3)continue;const d=dirOf(x,y);
if(ridged(d[0]*30,d[1]*30,d[2]*30,2,2,.5)>0.82-0.1*a)Tg[i]=S.hydrocarbon>0?6:1;} // dark leads in pack ice
}
function spiders(rng){
const n=Math.round(S.spiders*40);
for(let c=0;c<n;c++){const cd=randDir(rng);if(Math.abs(cd[1])<0.6)continue;
radialStamp(cd,0.02,1.0,(i,t)=>{const x=i%W,y=(i/W)|0,d=dirOf(x,y);
const az=Math.atan2(d[0]-cd[0],d[2]-cd[2]);
if(Math.pow(Math.abs(Math.sin(az*4)),6)>0.3){Hf[i]-=0.004*(1-t)*S.spiders;Ab[i]*=1-0.3*S.spiders*(1-t);}});} // araneiform CO2 spiders
}
/* generic radial stamp helper (great-circle distance kernel) */
function radialStamp(cd,rad,reach,fn){
const px=((Math.atan2(cd[0],cd[2])+Math.PI)/(2*Math.PI))*W,py=((Math.PI/2-Math.asin(cd[1]))/Math.PI)*H;
const span=Math.ceil(rad*reach/Math.PI*H)+2;
for(let dy=-span;dy<=span;dy++){let yy=(py+dy)|0;if(yy<0||yy>=H)continue;
const latw=Math.max(0.15,Math.cos(Math.PI/2-(yy/H)*Math.PI)),xs=Math.ceil(span/latw)+1;
for(let dx=-xs;dx<=xs;dx++){let xx=((px+dx)%W+W)%W|0;const d=dirOf(xx,yy);
const t=Math.acos(clamp(d[0]*cd[0]+d[1]*cd[1]+d[2]*cd[2],-1,1))/rad;
if(t<reach)fn(yy*W+xx,t);}}
}
function stampCyclone(cd,rad,spin){
const e0=[cd[1],cd[2],cd[0]];const e=nrm([cd[1]*e0[2]-cd[2]*e0[1],cd[2]*e0[0]-cd[0]*e0[2],cd[0]*e0[1]-cd[1]*e0[0]]);
const f=nrm([cd[1]*e[2]-cd[2]*e[1],cd[2]*e[0]-cd[0]*e[2],cd[0]*e[1]-cd[1]*e[0]]);
radialStamp(cd,rad,1.6,(i,t)=>{const x=i%W,y=(i/W)|0,d=dirOf(x,y);
const az=Math.atan2(d[0]*f[0]+d[1]*f[1]+d[2]*f[2],d[0]*e[0]+d[1]*e[1]+d[2]*e[2]);
const spiral=0.5+0.5*Math.sin(az+spin*t*7);const eye=smooth(0,0.18,t);
Cl[i]=Math.max(Cl[i],spiral*Math.exp(-t*t*0.7)*eye);});
}
function tagOceanIce(){
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI,polar=Math.abs(Math.sin(lat));
for(let x=0;x<W;x++){const i=y*W+x,d=dirOf(x,y);
const effSea=S.hydrocarbon>0?S.sea*(0.2+0.8*polar):S.sea; // hydrocarbon lakes cluster at the poles (Titan)
if(S.sea>0&&Hf[i]<effSea&&Tg[i]!==2){Tg[i]=S.hydrocarbon>0?6:1;}
const edge=S.iceCap*1.1-0.12*fbm(d[0]*4,d[1]*4,d[2]*4,3,2,.5);
if(S.iceCap>0&&polar>(1-edge)&&Tg[i]!==1&&Tg[i]!==6){Tg[i]=3;Hf[i]=Math.max(Hf[i],S.sea+0.02)+0.01;}}}
}
/* irregular figure: triaxial + lobes + low-freq radial noise → small bodies */
function buildShape(rng){
const a=S.irregular;const axB=1-0.3*a,axC=1-0.45*a*(1-S.lobes*0.3);
const lobeDir=randDir(rng),lobeDir2=randDir(rng);
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
let r=1/Math.sqrt(d[0]*d[0]+(d[1]/axC)*(d[1]/axC)+(d[2]/axB)*(d[2]/axB)); // ellipsoid
r*=1+0.18*a*fbm(d[0]*2.2,d[1]*2.2,d[2]*2.2,4,2.1,.55);
if(S.lobes>0){ // contact-binary bulges
const l1=Math.max(0,d[0]*lobeDir[0]+d[1]*lobeDir[1]+d[2]*lobeDir[2]);
const l2=Math.max(0,d[0]*lobeDir2[0]+d[1]*lobeDir2[1]+d[2]*lobeDir2[2]);
r*=1+S.lobes*0.25*(Math.pow(l1,3)+Math.pow(l2,3))-S.lobes*0.12;
}
Shp[i]=r;}
}
function buildClouds(rng){
Cl.fill(0);Cl2.fill(0);
if(S.clouds<=0&&S.cyclones<=0)return;
if(S.clouds>0)for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
Cl[i]=smooth(0.45-0.4*S.clouds,0.75-0.2*S.clouds,fbm(d[0]*3+20,d[1]*3,d[2]*3+20,5,2.2,.55));
if(S.multiDeck>0)Cl2[i]=smooth(0.5-0.3*S.clouds,0.82,fbm(d[0]*5+50,d[1]*5,d[2]*5+50,4,2.3,.5)); // lower deck
}
if(S.cyclones>0){const n=Math.round(S.cyclones*8); // spiral storm systems (hurricanes/fronts)
for(let c=0;c<n;c++)stampCyclone(randDir(rng),0.08+rng()*0.10,rng()<0.5?1:-1);}
}
/* gas giants: zonal jets + altitude-color + vortices + hexagon */
function buildGas(rng){
const bands=S.bands,warpA=S.bandWarp,irr=S.bandIrregular;
// build an irregular jet-latitude lookup so bands aren't evenly spaced/symmetric
const jets=[];let acc=0;
for(let b=0;b<bands*2+2;b++){acc+=0.6+irr*(rng()-0.5)*1.1;jets.push(acc);}
const jmax=acc;
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI;
let bandV=0.5+0.5*Math.sin((Math.sin(lat)*0.5+0.5)*jmax*Math.PI*2/(bands)); // base
for(let x=0;x<W;x++){const d=dirOf(x,y),i=y*W+x;
const warp=fbm(d[0]*2.2,d[1]*0.7,d[2]*2.2,5,2.1,.55)*warpA;
const turb=fbm(d[0]*7,d[1]*2,d[2]*7,4,2.3,.5)*0.25;
let band=0.5+0.5*Math.sin(lat*bands*2+warp*6);
band=0.5+(band-0.5)*(0.4+0.6*S.bandContrast); // contrast (Uranus≈flat)
// Saturn hexagon: 6-fold perturbation at high north latitude
if(S.hexagon>0&&lat>0.9){const hx=Math.cos(Math.atan2(d[0],d[2])*6)*0.5+0.5;band=lerp(band,hx,S.hexagon*smooth(0.9,1.3,lat));}
Hf[i]=band*0.7+turb+0.3;Ab[i]=1;
// altitude-coded color: bright = high deck
if(S.altColor>0)Ab[i]*=lerp(1,0.7+band*0.6,S.altColor);
}
}
const ns=Math.round(S.storms*22);
for(let c=0;c<ns;c++)stampStorm(rng,0.02+rng()*0.04,rng()<0.5?1.25:0.8,rng());
if(S.redspot) stampStorm(rng,0.075,1.55,0.0,true,-0.32);
if(S.darkspot)stampStorm(rng,0.06,0.45,0.0,false,0.3);
if(S.polarVortex>0){ // Juno-style polar cyclone clusters
for(const pole of[1,-1]){const ring=5,r=0.16;
for(let k=0;k<ring;k++){const a=k/ring*2*Math.PI;
const lat=pole*1.25,lon=a;
const cd=[Math.cos(lat)*Math.sin(lon),Math.sin(lat),Math.cos(lat)*Math.cos(lon)];
stampStormDir(cd,0.05,1.3,false);}
stampStormDir([0,pole,0],0.05,1.3,false);}
}
normField();
}
function stampStorm(rng,rad,bright,seed,red,fixLat){
const lat=fixLat!==undefined?fixLat*Math.PI/2:(rng()-0.5)*2.2,lon=(seed||rng())*2*Math.PI-Math.PI;
stampStormDir([Math.cos(lat)*Math.sin(lon),Math.sin(lat),Math.cos(lat)*Math.cos(lon)],rad,bright,red);
}
function stampStormDir(cd,rad,bright,red){
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const d=dirOf(x,y);
const t=Math.acos(clamp(d[0]*cd[0]+d[1]*cd[1]+d[2]*cd[2],-1,1))/rad;
if(t<1.6){const i=y*W+x,sw=Math.exp(-t*t*1.3);
Hf[i]+=sw*0.25*(bright-1);Ab[i]*=lerp(1,bright,sw);
if(red)Rb[i]+=sw*0.6;else if(bright<1)Rb[i]-=sw*0.4;}}
}
function computeNormals(isGas){
const relief=S.relief*(isGas?0.6:1.0);
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI,cl=Math.max(0.05,Math.cos(lat));
const yo=y*W,up=(y>0?yo-W:yo),dn=(y<H-1?yo+W:yo);
for(let x=0;x<W;x++){const l=yo+((x-1+W)%W),r=yo+((x+1)%W),i=yo+x;
const dhx=(Hf[r]-Hf[l])/cl*relief*8,dhy=(Hf[dn]-Hf[up])*relief*8;
let nx=-dhx,ny=-dhy,nz=1;const inv=1/Math.hypot(nx,ny,nz);
Nx[i]=nx*inv;Ny[i]=ny*inv;Nz[i]=nz*inv;}}
}
function computeColor(isGas){
const pal=PALETTES[S.palette]||PALETTES.mars;
for(let y=0;y<H;y++){const lat=Math.PI/2-(y/H)*Math.PI,polar=Math.abs(Math.sin(lat));
for(let x=0;x<W;x++){const i=y*W+x,d=dirOf(x,y);
let c;
if(isGas){c=rampColor(pal,Hf[i]);
if(S.bandContrast<0.2)c=[lerp(c[0],pal[1][1],0.5),lerp(c[1],pal[1][2],0.5),lerp(c[2],pal[1][3],0.5)];
// polar hood darkening
const hood=smooth(0.8,1.4,Math.abs(lat))*0.3;c=[c[0]*(1-hood),c[1]*(1-hood),c[2]*(1-hood*0.7)];
}else{
c=rampColor(pal,Hf[i]);const tg=Tg[i];
if(tg===1){const dep=clamp((S.sea-Hf[i])*4,0,1);c=[lerp(40,8,dep),lerp(95,30,dep),lerp(150,70,dep)];}
else if(tg===6){const dep=clamp((S.sea-Hf[i])*4,0,1);c=[lerp(35,12,dep),lerp(28,8,dep),lerp(20,6,dep)];} // hydrocarbon
else if(tg===2){c=S.sulfur?[c[0],c[1]*0.8,c[2]*0.4]:[c[0]*0.5,c[1]*0.5,c[2]*0.55];}
else if(tg===3){c=[235,240,248];}
else if(tg===7){c=[lerp(c[0],235,0.6),lerp(c[1],240,0.6),lerp(c[2],245,0.6)];} // N2 glacier
else if(tg===4){c=[c[0]*0.7+60,c[1]*0.6+25,c[2]*0.6+20];}
else if(tg===8){c=S.hydrocarbon>0?[c[0]*0.45,c[1]*0.4,c[2]*0.4]:[c[0]*0.6,c[1]*0.66,c[2]*0.82];} // river channel
// relief-free albedo provinces
if(S.albedoProv>0){const m=fbm(d[0]*2.3+7,d[1]*2.3,d[2]*2.3+7,4,2.1,.55);
const f=1+m*0.3*S.albedoProv;c=[c[0]*f,c[1]*f,c[2]*f];}
// hemispheric albedo dichotomy (Iapetus): leading dark / trailing bright
if(S.hemiAlbedo>0){const dot=d[0]*HEMI[0]+d[1]*HEMI[1]+d[2]*HEMI[2];
const f=lerp(1,dot>0?0.35:1.25,S.hemiAlbedo);c=[c[0]*f,c[1]*f,c[2]*f];}
// slope streaks: downslope dark albedo lineae (relief-free color)
if(S.slopeStreaks>0){const slope=1-Nz[i];if(slope>0.25){
const st=fbm(d[0]*30,d[1]*60,d[2]*30,2,2,.5);if(st>0.1){const f=1-0.35*S.slopeStreaks*slope;c=[c[0]*f,c[1]*f,c[2]*f];}}}
if(S.latBands>0){const lb=1+0.25*S.latBands*Math.sin(polar*9);c=[c[0]*lb,c[1]*lb,c[2]*lb];} // zonal albedo belts
if(S.devilTracks>0&&ridged(d[0]*38+3,d[1]*38,d[2]*38+3,2,2.2,.5)>0.76){const f=1-0.22*S.devilTracks;c=[c[0]*f,c[1]*f,c[2]*f];} // dust-devil tracks
// seasonal frost: faint bright halo beyond permanent cap
if(S.seasonalFrost>0&&polar>(1-S.iceCap*1.1-S.seasonalFrost*0.4)&&tg!==3){
const f=smooth(1-S.iceCap*1.1-S.seasonalFrost*0.4,1-S.iceCap*1.1,polar);
c=[lerp(c[0],235,f*0.7),lerp(c[1],240,f*0.7),lerp(c[2],248,f*0.7)];}
// chemistry tints
if(S.oxidation>0){const ox=S.oxidation*0.55;c=[lerp(c[0],c[0]*1.15+40,ox),lerp(c[1],c[1]*0.8,ox),lerp(c[2],c[2]*0.6,ox)];}
if(S.tholin>0){const th=S.tholin*0.4;c=[lerp(c[0],200,th),lerp(c[1],130,th),lerp(c[2],70,th)];}
if(S.polarTholin>0){const pt=S.polarTholin*smooth(0.55,0.95,polar);c=[lerp(c[0],150,pt),lerp(c[1],70,pt),lerp(c[2],55,pt)];}
if(S.weather>0){const w=S.weather*0.5;c=[c[0]*(1-w*0.45),c[1]*(1-w*0.55),c[2]*(1-w*0.6)];}
}
const a=Ab[i],rb=Rb[i]*120;
colR[i]=clamp(c[0]*a+rb,0,255);colG[i]=clamp(c[1]*a+rb,0,255);colB[i]=clamp(c[2]*a+rb,0,255);
}
}
}
function buildEmissive(){
if(S.cityLights<=0)return;
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;if(Tg[i]===1||Tg[i]===6||Tg[i]===3)continue;
const d=dirOf(x,y);const m=fbm(d[0]*9,d[1]*9,d[2]*9,3,2.2,.5);
if(m>0.45){En[i]=clamp((m-0.45)*4,0,1)*S.cityLights;}}
}
/* ================= RENDER ================= */
const view=document.getElementById("view"),vctx=view.getContext("2d");
let VW=view.width,VH=view.height,img=vctx.createImageData(VW,VH);
let yaw=0.4,pitch=0.25,zoom=0.74,autoSpin=true;
let starBuf=null;
function makeStars(){starBuf=new Uint8ClampedArray(VW*VH*3);const r=mulberry32(99);
for(let i=0;i<VW*VH;i++){starBuf[i*3]=5;starBuf[i*3+1]=6;starBuf[i*3+2]=10;}
for(let s=0;s<VW*VH/650;s++){const x=(r()*VW)|0,y=(r()*VH)|0,b=120+r()*135,i=(y*VW+x)*3;
starBuf[i]=b;starBuf[i+1]=b;starBuf[i+2]=b*1.05;}}
makeStars();
function render(){
const cx=VW/2,cy=VH/2,R=Math.min(VW,VH)/2*zoom;
// model->view = Rx(pitch) · Rz(axialTilt) · Ry(yaw) — obliquity tilts the spin axis (Uranus on its side)
const ty=yaw,tz=(S.axialTilt||0)*Math.PI/180,tx=pitch;
const cyA=Math.cos(ty),syA=Math.sin(ty),czA=Math.cos(tz),szA=Math.sin(tz),cxA=Math.cos(tx),sxA=Math.sin(tx);
const M=mm3([[1,0,0],[0,cxA,-sxA],[0,sxA,cxA]],mm3([[czA,-szA,0],[szA,czA,0],[0,0,1]],[[cyA,0,syA],[0,1,0],[-syA,0,cyA]]));
const r00=M[0][0],r01=M[0][1],r02=M[0][2],r10=M[1][0],r11=M[1][1],r12=M[1][2],r20=M[2][0],r21=M[2][1],r22=M[2][2];
// sun in view space
const az=S.sunAz*Math.PI/180,el=S.sunEl*Math.PI/180;
const sx=Math.sin(az)*Math.cos(el),sy=Math.sin(el),sz=Math.cos(az)*Math.cos(el);
const SUN=nrm([sx,sy,sz]);
// sun in model space (Rᵀ·SUN)
const smx=r00*SUN[0]+r10*SUN[1]+r20*SUN[2],smy=r01*SUN[0]+r11*SUN[1]+r21*SUN[2],smz=r02*SUN[0]+r12*SUN[1]+r22*SUN[2];
const data=img.data;
const isGas=S.mode==="gas",atmo=S.atmoGlow||0;
const obl=S.oblate,az2=1-obl,irr=S.irregular>0;
const pal=PALETTES[S.palette]||PALETTES.mars;
// body-matched limb glow: warm for warm palettes, blue only where Rayleigh is strong
const gpal=PALETTES[S.palette]||PALETTES.mars,gt=gpal[gpal.length-1];
let glow=[gt[1],gt[2],gt[3]];
{const rb=(S.rayleigh||0)*0.7;glow=[lerp(glow[0],150,rb),lerp(glow[1],180,rb),lerp(glow[2],225,rb)];}
const ambient=S.ambient;
// sky/haze tint: shifts blue with Rayleigh, warm-tan otherwise (Titan)
const sky=[lerp(205,150,S.rayleigh||0),lerp(190,178,S.rayleigh||0),lerp(160,215,S.rayleigh||0)];
// star color temperature: 0=red dwarf, .5=sun-like white, 1=blue
const stt=S.starTemp;
const sunCol=stt<0.5?[1,lerp(0.6,1,stt*2),lerp(0.34,1,stt*2)]:[lerp(1,0.72,(stt-0.5)*2),lerp(1,0.82,(stt-0.5)*2),1];
// ring geometry (model space equatorial plane y=0)
const hasRing=S.rings>0,rI=S.ringInner,rO=S.ringOuter;
// moons (model-space positions in equatorial plane)
const moons=[];
if(S.moons>0){const mr=mulberry32(S.seed^0x55).bind(null);
for(let m=0;m<S.moons;m++){const a=(m/Math.max(1,S.moons))*2*Math.PI+1,dd=2.4+m*0.7;
moons.push({p:[Math.cos(a)*dd,0.12*(m-0.5),Math.sin(a)*dd],r:0.14+0.03*m});}}
for(let py=0;py<VH;py++){
const ny0=(py-cy)/R;
for(let px=0;px<VW;px++){
const o=(py*VW+px)*4,nx0=(px-cx)/R;
// ---- ring sample (compute first for depth compositing) ----
let ringHit=false,ringZ=-2,ringR=0,ringG2=0,ringB=0,ringA=0;
if(hasRing){
// view ray point (nx0,ny0,z); model = Rᵀ·(nx0,ny0,z). plane y_model=0.
// Ay + z*By = 0 → Ay = r01*nx0+r11*ny0 ; By = r21
const Ay=r01*nx0+r11*ny0, By=r21;
if(Math.abs(By)>1e-4){const z=-Ay/By;
const Ax=r00*nx0+r10*ny0,Bx=r20, Az=r02*nx0+r12*ny0,Bz=r22;
const mx=Ax+z*Bx, mz=Az+z*Bz, rr=Math.hypot(mx,mz);
if(rr>=rI&&rr<=rO){ringHit=true;ringZ=z;
const tt=(rr-rI)/(rO-rI);
// banded structure + Cassini/Encke-style divisions (gaps you can see through)
let dens=0.55+0.45*Math.sin(tt*38)+0.2*Math.sin(tt*140);
dens*=1-0.9*Math.exp(-((tt-0.46)*(tt-0.46))/0.0008); // Cassini Division
dens*=1-0.8*Math.exp(-((tt-0.72)*(tt-0.72))/0.00018); // Encke Gap
dens*=smooth(0,0.05,tt)*smooth(0,0.05,1-tt); // soft inner/outer edge
if(S.ringSpokes>0){const az=Math.atan2(mx,mz);dens*=1-S.ringSpokes*0.6*Math.pow(Math.abs(Math.cos(az*3.5)),6);} // radial spokes
ringA=clamp(dens,0,1)*S.ringOpacity;
const cc=rampColor(pal,0.42+0.45*Math.sin(tt*6));
// ring lit unless inside planet shadow
let shadow=1;const proj=mx*smx+0+mz*smz; // approx planet-shadow test in plane
const perp=Math.hypot(mx-proj*smx, -proj*smy, mz-proj*smz);
if(proj<0&&perp<1.0)shadow=0.15;
ringR=cc[0]*shadow;ringG2=cc[1]*shadow;ringB=cc[2]*shadow;
if(S.ringForward>0){const fwd=S.ringForward*Math.max(0,-SUN[2])*0.8; // backlit forward-scatter
ringA=clamp(ringA*(1+fwd*2),0,1);ringR+=fwd*120;ringG2+=fwd*120;ringB+=fwd*120;}}}
}
// ---- planet intersection ----
let hit=false,hz=-2,d0,d1,d2,gnx,gny,gnz;
if(!irr){
// ellipsoid (axes 1,az2,1). transform: scale y. solve in view via model quadratic.
if(obl<1e-3){
const r2=nx0*nx0+ny0*ny0;
if(r2<=1){hit=true;const nz=Math.sqrt(1-r2);hz=nz;
d0=r00*nx0+r10*ny0+r20*nz; d1=r01*nx0+r11*ny0+r21*nz; d2=r02*nx0+r12*ny0+r22*nz;
gnx=d0;gny=d1;gnz=d2;}
}else{
// model ray A + z B ; ellipsoid x²+ (y/az2)² + z²=1
const Ax=r00*nx0+r10*ny0,Bx=r20, Ay=r01*nx0+r11*ny0,By=r21, Az=r02*nx0+r12*ny0,Bz=r22;
const iy=1/(az2*az2);
const A=Bx*Bx+By*By*iy+Bz*Bz, Bq=2*(Ax*Bx+Ay*By*iy+Az*Bz), C=Ax*Ax+Ay*Ay*iy+Az*Az-1;
const disc=Bq*Bq-4*A*C;
if(disc>=0){const z=(-Bq+Math.sqrt(disc))/(2*A);hit=true;hz=z;
d0=Ax+z*Bx;d1=Ay+z*By;d2=Az+z*Bz;
gnx=d0;gny=d1*iy;gnz=d2;const gl=Math.hypot(gnx,gny,gnz)||1;gnx/=gl;gny/=gl;gnz/=gl;}
}
}else{
// irregular star-shaped figure: ray-march radius field Shp(dir)
const Ax=r00*nx0+r10*ny0,Bx=r20,Ay=r01*nx0+r11*ny0,By=r21,Az=r02*nx0+r12*ny0,Bz=r22;
const maxR=1.5;const rad2=nx0*nx0+ny0*ny0;
if(rad2<maxR*maxR){
let zf=Math.sqrt(Math.max(0.0001,maxR*maxR-rad2)),prev=1,found=false,zh=0;
for(let st=0;st<26;st++){const z=zf-st*(2*zf/26);
const mx=Ax+z*Bx,my=Ay+z*By,mz=Az+z*Bz,len=Math.hypot(mx,my,mz);
if(len<0.01)continue;const sh=Shp[texelOf([mx/len,my/len,mz/len])];const f=len-sh;
if(f<=0&&st>0){const z2=zf-(st-1)*(2*zf/26);zh=lerp(z,z2,(0-f)/((prev)-(f)||1));found=true;break;}
prev=f;}
if(found){const mx=Ax+zh*Bx,my=Ay+zh*By,mz=Az+zh*Bz,len=Math.hypot(mx,my,mz)||1;
hit=true;hz=zh;d0=mx/len;d1=my/len;d2=mz/len;gnx=d0;gny=d1;gnz=d2;}
}
}
let rr,gg,bb;
let drewPlanet=false;
if(hit){
const dl=Math.hypot(d0,d1,d2)||1;const dd0=d0/dl,dd1=d1/dl,dd2=d2/dl;
const ti=texelOf([dd0,dd1,dd2]);
// tangent frame around geometric normal (use sphere dir basis)
let ex=dd2,ey=0,ez=-dd0;let el2=Math.hypot(ex,ey,ez)||1;ex/=el2;ez/=el2;
const nox=dd1*ez-dd2*ey,noy=dd2*ex-dd0*ez,noz=dd0*ey-dd1*ex;
const tnx=Nx[ti],tny=Ny[ti],tnz=Nz[ti];
// bump normal in model space, blended toward geometric normal for irregular
let wnx=tnx*ex+tny*nox+tnz*gnx, wny=tnx*ey+tny*noy+tnz*gny, wnz=tnx*ez+tny*noz+tnz*gnz;
// fade surface relief toward the smooth geometric normal near the terminator (kills night-side facets)
{const gdk=clamp((gnx*smx+gny*smy+gnz*smz+0.05)/0.28,0,1);
wnx=lerp(gnx,wnx,gdk);wny=lerp(gny,wny,gdk);wnz=lerp(gnz,wnz,gdk);
const wl=Math.hypot(wnx,wny,wnz)||1;wnx/=wl;wny/=wl;wnz/=wl;}
// model-space lighting (sun in model space) — keeps phase stable under spin
let dif=wnx*smx+wny*smy+wnz*smz;
const tw=S.atmThickness||0; // thicker air → wider twilight band
const lit=smooth(-0.05-tw*0.4,0.08+tw*0.45,dif);
let light=ambient+(1-ambient)*Math.max(0,dif)*lit;
// opposition surge (backscatter near zero phase)
if(S.oppSurge>0){const vdot=dd0* (r02*0+ r12*0+ r22*1); /*view z in model*/
const vm0=r20,vm1=r21,vm2=r22;const vdotn=wnx*vm0+wny*vm1+wnz*vm2;
if(dif>0.2&&vdotn>0.6)light+=S.oppSurge*0.3*Math.pow(vdotn,6);}
// Lommel-Seeliger photometry (flat full-disk regolith, not limb-darkened Lambert)
if(S.lommel>0){const vz=r20*wnx+r21*wny+r22*wnz,mu=Math.max(0.05,vz),mu0=Math.max(0,dif);
light=lerp(light,ambient+(1-ambient)*(mu0/(mu0+mu))*lit,S.lommel);}
// cast shadows: march toward the sun across the heightfield (long shadows at low sun)
if(S.castShadow>0&&dif>0.02){let occ=0;
for(let s=1;s<=8;s++){const st=s*0.018,j=texelOf([dd0+smx*st,dd1+smy*st,dd2+smz*st]);
if(Hf[j]>Hf[ti]+st*dif*6*S.relief)occ++;}
occ=clamp(occ/5,0,1); // fractional → soft penumbra (8 samples)
if(occ>0)light=lerp(light,ambient*0.7,occ);} // shadowed, not pure black (sky fill)
const tg=Tg[ti];
// specular / sun-glint for liquids & ice
if((tg===1||tg===3||tg===6)&&dif>0){
const hvx=smx,hvy=smy,hvz=smz+1,hl=Math.hypot(hvx,hvy,hvz)||1;
let spec=(wnx*hvx+wny*hvy+wnz*hvz)/hl;if(spec<0)spec=0;
light+=Math.pow(spec,tg===6?40:28)*(tg===1?0.9:tg===6?1.1:0.5);}
rr=colR[ti]*light;gg=colG[ti]*light;bb=colB[ti]*light;
rr*=sunCol[0];gg*=sunCol[1];bb*=sunCol[2]; // illuminant spectral tint
if(S.earthshine>0){const es=S.earthshine*0.16*Math.max(0,0.25-dif);rr+=es*45;gg+=es*60;bb+=es*85;}
// ---- exoworld / tidal-lock surface physics ----
if(S.frozen>0||S.lavaWorld>0||S.tidalLock>0){
const sdot=dd0*smx+dd1*smy+dd2*smz; // 1=substellar, -1=antistellar
if(S.frozen>0){let cold=S.frozen;
if(S.tidalLock>0)cold*=clamp(1-smooth(-0.05,0.55,sdot),0,1);
if(cold>0.01){rr=lerp(rr,228*light,cold);gg=lerp(gg,237*light,cold);bb=lerp(bb,246*light,cold);}}
if(S.tidalLock>0&&S.frozen>0){ // eyeball: substellar meltwater sea
const sea=smooth(0.5,0.85,sdot);
if(sea>0){rr=lerp(rr,16*light,sea);gg=lerp(gg,68*light,sea);bb=lerp(bb,118*light,sea);
const hl=Math.hypot(smx,smy,smz+1)||1;let sp=(wnx*smx+wny*smy+wnz*(smz+1))/hl;if(sp<0)sp=0;
const g=Math.pow(sp,30)*sea;rr+=g*120;gg+=g*130;bb+=g*140;}}
if(S.lavaWorld>0){ // molten rock self-emission (glows on night side)
let molten=S.lavaWorld;
if(S.tidalLock>0)molten*=smooth(-0.05,0.5,sdot);
// crack pattern from height so it reads as molten fissures, not a flat glow
const crack=clamp(0.6+0.8*(Hf[ti]-0.4),0,1);molten*=0.5+0.5*crack;
if(molten>0.01){rr=lerp(rr,22,molten*0.6);gg=lerp(gg,9,molten*0.6);bb=lerp(bb,6,molten*0.6);
const e=molten*molten;rr+=e*240;gg+=e*92;bb+=e*22;}}
}
// ring shadow on planet surface
if(hasRing&&smy!==0){const s=-dd1/smy;if(s>0){const hx=dd0+s*smx,hz2=dd2+s*smz,hr=Math.hypot(hx,hz2);
if(hr>=rI&&hr<=rO)light*=0.45,rr*=0.55,gg*=0.55,bb*=0.55;}}
// moon shadow (transit) on planet
for(const mo of moons){const L=[smx,smy,smz];
const toM=[mo.p[0]-dd0,mo.p[1]-dd1,mo.p[2]-dd2];const proj=toM[0]*L[0]+toM[1]*L[1]+toM[2]*L[2];
if(proj>0){const px2=toM[0]-proj*L[0],py2=toM[1]-proj*L[1],pz2=toM[2]-proj*L[2];
const sh=smooth(mo.r,mo.r*0.5,Math.hypot(px2,py2,pz2)); // umbra→penumbra soft edge
if(sh>0&&dif>0){const f=1-0.7*sh;rr*=f;gg*=f;bb*=f;}}}
// cloud shadows cast onto the surface below
if(S.cloudShadows>0&&S.clouds>0){const cs=Cl[texelOf([dd0+smx*0.05,dd1+smy*0.05,dd2+smz*0.05])];
if(cs>0.1){const sh=1-cs*S.cloudShadows*0.55;rr*=sh;gg*=sh;bb*=sh;}}
// clouds composite: lower (warm) deck then upper (white) deck → gaps show the deck below
if(S.clouds>0){const clight=ambient+(1-ambient)*Math.max(0,dif);
if(S.multiDeck>0){const cv2=Cl2[ti];if(cv2>0){const a2=cv2*S.cloudOpacity*0.8;
rr=lerp(rr,235*clight,a2);gg=lerp(gg,210*clight,a2);bb=lerp(bb,180*clight,a2);}}
const cv=Cl[ti];if(cv>0){const cc=255*clight,a=cv*S.cloudOpacity;
rr=lerp(rr,cc,a);gg=lerp(gg,cc,a);bb=lerp(bb,cc*1.02,a);
if(S.lightning>0&&dif<0.06&&cv>0.55&&Math.random()<0.0006*S.lightning){rr+=210;gg+=220;bb+=255;}}}
// emissive city lights on night side
if(S.cityLights>0&&dif<0.1){const e=En[ti]*(1-Math.max(0,dif)*10);if(e>0){rr+=e*180;gg+=e*160;bb+=e*90;}}
// aurora: emissive oval around the magnetic poles, brightest on the night side
if(S.aurora>0){const pl=Math.abs(dd1);
const oval=smooth(0.74,0.84,pl)*(1-smooth(0.9,0.99,pl));
if(oval>0){const flick=0.55+0.45*Math.sin(Hf[ti]*80+dd0*30);
const a=S.aurora*oval*flick*(1-clamp(dif,0,1)*0.7);
rr+=a*55;gg+=a*185;bb+=a*110;}}
// dust storm haze
if(S.dustStorm>0){const k=S.dustStorm*0.6;rr=lerp(rr,200*light+40,k);gg=lerp(gg,150*light+30,k);bb=lerp(bb,90*light+20,k);}
// ---- atmosphere: limb darkening → haze veil → Rayleigh → glow ----
const limbW=1-Math.abs(hz);
if(S.limbDarkening>0){const ld=1-S.limbDarkening*0.7*Math.pow(limbW,1.4);rr*=ld;gg*=ld;bb*=ld;}
if(S.haze>0){const veil=clamp(S.haze*(0.25+0.75*limbW*limbW)*(1+(S.polarHood||0)*smooth(0.55,0.9,Math.abs(dd1))),0,1);
rr=lerp(rr,sky[0]*(0.4+0.6*light),veil);gg=lerp(gg,sky[1]*(0.4+0.6*light),veil);bb=lerp(bb,sky[2]*(0.4+0.6*light),veil);}
if(S.rayleigh>0){const edge=Math.pow(limbW,3);
const day=edge*Math.max(0,dif)*S.rayleigh;rr+=day*40;gg+=day*95;bb+=day*185; // blue day limb
const twi=Math.exp(-dif*dif*55)*edge*S.rayleigh;rr+=twi*130;gg+=twi*48;bb+=twi*12;} // sunset ring
if(atmo){const limb=Math.pow(limbW,2.0)*atmo*0.4;rr+=glow[0]*limb;gg+=glow[1]*limb;bb+=glow[2]*limb;}
drewPlanet=true;
}
// ---- composite ring + planet + background ----
if(ringHit&&(!hit||ringZ>hz)){
// ring in front of planet (or planet missed)
let baseR,baseG,baseB;
if(drewPlanet){baseR=rr;baseG=gg;baseB=bb;}
else{const si=(py*VW+px)*3;baseR=starBuf[si];baseG=starBuf[si+1];baseB=starBuf[si+2];}
rr=lerp(baseR,ringR,ringA);gg=lerp(baseG,ringG2,ringA);bb=lerp(baseB,ringB,ringA);
drewPlanet=true;
}else if(ringHit&&hit&&ringZ<hz){
// ring behind planet: planet already drawn, ring hidden — nothing
}
if(!drewPlanet){
const si=(py*VW+px)*3;let br=starBuf[si],bg=starBuf[si+1],bb2=starBuf[si+2];
const r2=nx0*nx0+ny0*ny0;
const halo=0.1+(S.atmThickness||0)*0.3; // thicker air → taller glow envelope
if(atmo&&!irr&&r2<1+halo+0.04&&r2>0.9){const g=clamp(1-(Math.sqrt(r2)-1)/halo,0,1)*0.35*atmo;
if(g>0){const sun2=Math.max(0,(nx0*SUN[0]+ny0*SUN[1]));br+=glow[0]*g*(0.4+sun2);bg+=glow[1]*g*(0.4+sun2);bb2+=glow[2]*g*(0.4+sun2);}}
// detached aerosol haze layers above the limb (Titan / Pluto)
if(S.hazeLayers>0&&!irr&&r2>1&&r2<1.55){const hh=Math.sqrt(r2)-1;
let layer=0;for(let L=0;L<5;L++){const alt=0.03+L*0.06;layer+=Math.exp(-((hh-alt)*(hh-alt))/0.0008);}
const env=Math.exp(-hh*7),sun2=Math.max(0,(nx0*SUN[0]+ny0*SUN[1]));
const g=(layer*0.4+env*0.3)*S.hazeLayers*(0.35+sun2);br+=sky[0]*g;bg+=sky[1]*g;bb2+=sky[2]*g;}
rr=br;gg=bg;bb=bb2;
}
// ---- comet coma + anti-sunward tail (active small bodies / evaporating planets) ----
if(S.tail>0){
const tdx=-SUN[0],tdy=-SUN[1],tl=Math.hypot(tdx,tdy)||1,ux=tdx/tl,uy=tdy/tl;
const along=nx0*ux+ny0*uy, perp=Math.abs(nx0*uy-ny0*ux);
const coma=Math.exp(-(nx0*nx0+ny0*ny0)*3.2)*0.9;
let tail=0;
if(along>0){const wid=0.14+along*0.30;tail=Math.exp(-(perp*perp)/(wid*wid))*Math.exp(-along*0.45)*0.8;}
const g=(coma+tail)*S.tail;
rr+=g*140;gg+=g*170;bb+=g*215; // straight bluish ion tail + white coma
// curved warm dust tail, offset from the ion tail
const ca=Math.cos(0.35),sa=Math.sin(0.35),ux2=ux*ca-uy*sa,uy2=ux*sa+uy*ca;
const al2=nx0*ux2+ny0*uy2,pp2=nx0*uy2-ny0*ux2;
if(al2>0){const w2=0.18+al2*0.4,dust=Math.exp(-(pp2*pp2)/(w2*w2))*Math.exp(-al2*0.4)*0.7*S.tail;
rr+=dust*180;gg+=dust*150;bb+=dust*90;}
}
// ---- moons (lit spheres, drawn over everything if in front) ----
for(const mo of moons){
// project moon center to view
const mvx=r00*mo.p[0]+r01*mo.p[1]+r02*mo.p[2];
const mvy=r10*mo.p[0]+r11*mo.p[1]+r12*mo.p[2];
const mvz=r20*mo.p[0]+r21*mo.p[1]+r22*mo.p[2];
const ddx=nx0-mvx,ddy=ny0-mvy;const rm2=ddx*ddx+ddy*ddy;
if(rm2<mo.r*mo.r&&(mvz>hz||!hit)){const nz=Math.sqrt(mo.r*mo.r-rm2)/mo.r;
// moon surface normal view-space
const mnx=ddx/mo.r,mny=ddy/mo.r,mnz=nz;
let dif=mnx*SUN[0]+mny*SUN[1]+mnz*SUN[2];if(dif<0)dif=0;
const l=0.05+0.95*dif;rr=150*l;gg=145*l;bb=140*l;drewPlanet=true;}
}
data[o]=rr;data[o+1]=gg;data[o+2]=bb;data[o+3]=255;
}
}
vctx.putImageData(img,0,0);
}
function loop(){if(autoSpin)yaw+=0.0030;render();requestAnimationFrame(loop);}
/* interaction */
let drag=false,lx=0,ly=0;
view.addEventListener("pointerdown",e=>{drag=true;lx=e.clientX;ly=e.clientY;view.setPointerCapture(e.pointerId);});
view.addEventListener("pointermove",e=>{if(!drag)return;yaw+=(e.clientX-lx)*0.006;pitch=clamp(pitch+(e.clientY-ly)*0.006,-1.3,1.3);lx=e.clientX;ly=e.clientY;});
view.addEventListener("pointerup",()=>drag=false);
view.addEventListener("wheel",e=>{e.preventDefault();zoom=clamp(zoom*(e.deltaY>0?0.93:1.07),0.3,2.4);},{passive:false});
/* ================= UI ================= */
const SLIDERS=[
["grpFig","oblate","Oblateness (rotational flattening)",0,.3,.005],
["grpFig","irregular","Irregular figure (small body)",0,1,.01],
["grpFig","lobes","Contact-binary lobes",0,1,.01],
["grpFig","axialTilt","Axial tilt / obliquity °",0,120,1],
["grpFig","sunAz","Sun azimuth °",0,360,1],
["grpFig","sunEl","Sun elevation °",-60,60,1],
["grpFig","ambient","Night-side ambient (phase)",0,.3,.005],
["grpFig","relief","Relief exaggeration",.1,2,.01],
["grpFig","castShadow","Cast shadows (low-sun terrain)",0,1,.01],
["grpFig","lommel","Lommel-Seeliger (flat regolith)",0,1,.01],
["grpFig","starTemp","Star color (red↔blue)",0,1,.01],
["grpFig","earthshine","Earthshine (night-side fill)",0,1,.01],
["grpEndo","tectonics","Tectonics / continents",0,1,.01],
["grpEndo","mountains","Mountain ridges (orogeny)",0,1,.01],
["grpEndo","scarps","Lobate thrust scarps",0,1,.01],
["grpEndo","wrinkle","Wrinkle ridges (on plains)",0,1,.01],
["grpEndo","volcano","Volcanism (shields)",0,1,.01],
["grpEndo","floods","Flood basalts / maria",0,1,.01],
["grpEndo","rifts","Rift valleys / grabens",0,1,.01],
["grpEndo","coronae","Coronae (ring structures)",0,1,.01],
["grpEndo","tesserae","Tesserae (deformed highlands)",0,1,.01],
["grpEndo","plumeSwell","Mantle-plume swell (Tharsis rise)",0,1,.01],
["grpEndo","volcanicPlains","Volcanic plains / plateaus",0,1,.01],
["grpExo","craters","Impact bombardment",0,1,.01],
["grpExo","craterSize","Crater scale",0,1,.01],
["grpExo","relaxation","Viscous relaxation (palimpsests)",0,1,.01],
["grpExo","multiring","Multi-ring basins",0,1,.01],
["grpExo","secondaries","Secondary crater chains",0,1,.01],
["grpExo","erosion","Thermal erosion (diffusion)",0,1,.01],
["grpExo","rivers","Hydraulic rivers (flow-routing)",0,1,.01],
["grpExo","masswaste","Mass wasting (landslides)",0,1,.01],
["grpExo","glacial","Glacial erosion (U-valleys)",0,1,.01],
["grpExo","coastlines","Coastlines / wave-cut benches",0,1,.01],
["grpExo","inverted","Inverted relief (deflation)",0,1,.01],
["grpExo","degradation","Crater degradation (age)",0,1,.01],
["grpExo","yardangs","Wind erosion / yardangs",0,1,.01],
["grpExo","dunes","Aeolian dunes",0,1,.01],
["grpExo","stratify","Sedimentary stratification",0,1,.01],
["grpExo","weather","Space weathering (age)",0,1,.01],
["grpCryo","sea","Sea level",0,1,.01],
["grpCryo","hydrocarbon","Hydrocarbon (vs water) seas",0,1,.01],
["grpCryo","iceCap","Polar ice caps",0,1,.01],
["grpCryo","seasonalFrost","Seasonal frost halo",0,1,.01],
["grpCryo","linea","Tidal fractures (lineae)",0,1,.01],
["grpCryo","doubleRidge","Double ridges / cycloids",0,1,.01],
["grpCryo","tiger","Tiger stripes (geysers)",0,1,.01],
["grpCryo","sulci","Grooved terrain (sulci)",0,1,.01],
["grpCryo","chaos","Chaos terrain",0,1,.01],
["grpCryo","nitrogenGlacier","Nitrogen glacier (Sputnik)",0,1,.01],
["grpCryo","sublimation","Sublimation / bladed terrain",0,1,.01],
["grpCryo","equatorRidge","Equatorial ridge",0,1,.01],
["grpCryo","cryoDome","Cryovolcanic domes",0,1,.01],
["grpCryo","cantaloupe","Cantaloupe terrain (Triton)",0,1,.01],
["grpCryo","wispy","Wispy fracture terrain",0,1,.01],
["grpCryo","polygons","Contraction polygons",0,1,.01],
["grpCryo","pldSpiral","Polar layered spiral troughs",0,1,.01],
["grpCryo","glacialFlow","Glacial flow tongues (N₂)",0,1,.01],
["grpCryo","frozenSea","Frozen-over seas",0,1,.01],
["grpCryo","seaIce","Sea ice / leads",0,1,.01],
["grpCryo","spiders","Araneiform CO₂ spiders",0,1,.01],
["grpAlb","albedoProv","Albedo provinces (relief-free)",0,1,.01],
["grpAlb","dichotomy","Hemispheric crustal dichotomy",0,1,.01],
["grpAlb","hemiAlbedo","Hemispheric albedo (Iapetus)",0,1,.01],
["grpAlb","polarTholin","Polar tholin cap (Charon)",0,1,.01],
["grpAlb","slopeStreaks","Slope streaks / RSL",0,1,.01],
["grpAlb","windStreaks","Wind streaks (downwind tails)",0,1,.01],
["grpAlb","swirls","Magnetic swirls (Reiner Gamma)",0,1,.01],
["grpAlb","provinces","Sharp compositional provinces",0,1,.01],
["grpAlb","faculae","Bright salt deposits (faculae)",0,1,.01],
["grpAlb","latBands","Latitudinal albedo bands",0,1,.01],
["grpAlb","devilTracks","Dust-devil tracks",0,1,.01],
["grpAtmo","atmThickness","Atmosphere thickness (scale height)",0,1,.01],
["grpAtmo","rayleigh","Rayleigh scatter (blue limb + sunset)",0,1,.01],
["grpAtmo","haze","Aerosol haze veil (Mie)",0,1,.01],
["grpAtmo","hazeLayers","Detached haze layers (Titan/Pluto)",0,1,.01],
["grpAtmo","limbDarkening","Photometric limb darkening",0,1,.01],
["grpAtmo","clouds","Cloud cover",0,1,.01],
["grpAtmo","cloudOpacity","Cloud opacity",0,1,.01],
["grpAtmo","cloudShadows","Cloud shadows on surface",0,1,.01],
["grpAtmo","cyclones","Cyclonic storms (spiral weather)",0,1,.01],
["grpAtmo","multiDeck","Multi-deck clouds",0,1,.01],
["grpAtmo","lightning","Lightning (night side)",0,1,.01],
["grpAtmo","polarHood","Polar haze hood",0,1,.01],
["grpAtmo","dustStorm","Global dust storm",0,1,.01],
["grpAtmo","atmoGlow","Atmosphere limb glow",0,2,.01],
["grpAtmo","cityLights","Night-side city lights",0,1,.01],
["grpAtmo","oppSurge","Opposition surge",0,1,.01],
["grpAtmo","bands","Cloud bands (gas)",2,16,1],
["grpAtmo","bandWarp","Jet-stream turbulence",0,1,.01],
["grpAtmo","bandIrregular","Jet spacing irregularity",0,1,.01],
["grpAtmo","altColor","Altitude-coded color",0,1,.01],
["grpAtmo","bandContrast","Band contrast (Uranus↔Jupiter)",0,1,.01],
["grpAtmo","storms","Storm vortices",0,1,.01],
["grpAtmo","polarVortex","Polar cyclone clusters",0,1,.01],
["grpAtmo","hexagon","Polar hexagon (Saturn)",0,1,.01],
["grpRing","rings","Rings on/off",0,1,1],
["grpRing","ringInner","Ring inner radius",1.1,2,.01],
["grpRing","ringOuter","Ring outer radius",1.5,3,.01],
["grpRing","ringOpacity","Ring opacity",0,1,.01],
["grpRing","moons","Moons",0,3,1],
["grpRing","tail","Comet tail / coma",0,1,.01],
["grpRing","ringSpokes","Ring spokes",0,1,.01],
["grpRing","ringForward","Ring forward-scatter (backlit)",0,1,.01],
["grpAtmo","aurora","Auroral ovals (polar)",0,1,.01],
["grpFig","tidalLock","Tidal lock (substellar↔antistellar)",0,1,.01],
["grpEndo","lavaWorld","Magma ocean / lava self-emission",0,1,.01],
["grpCryo","frozen","Global glaciation (snowball)",0,1,.01],
["grpColor","oxidation","Oxidation / rust",0,1,.01],
["grpColor","tholin","Tholin haze (organics)",0,1,.01],
["grpColor","sulfur","Sulfur volcanism",0,1,.01]
];
const RENDER_ONLY=new Set(["oblate","axialTilt","sunAz","sunEl","ambient","atmoGlow","oppSurge","dustStorm","rings","ringInner","ringOuter","ringOpacity","moons","cloudOpacity","tidalLock","lavaWorld","frozen","aurora","tail","atmThickness","rayleigh","haze","hazeLayers","limbDarkening","cloudShadows","castShadow","starTemp","earthshine","lommel","lightning","ringSpokes","polarHood","ringForward"]);
const rowRefs={};
function makeSlider(key,label,mn,mx,st){
const row=document.createElement("div");row.className="row";
row.innerHTML=`<label>${label}</label><input type="range" min="${mn}" max="${mx}" step="${st}" value="${S[key]}"><span class="val"></span>`;
const inp=row.querySelector("input"),val=row.querySelector(".val");
const show=()=>val.textContent=(+S[key]).toFixed(st<1?2:0);
inp.addEventListener("input",()=>{S[key]=+inp.value;show();RENDER_ONLY.has(key)?null:scheduleBuild();});
show();row._sync=()=>{inp.value=S[key];show();};return row;
}
SLIDERS.forEach(([grp,key,label,mn,mx,st])=>{const row=makeSlider(key,label,mn,mx,st);rowRefs[key]=row;document.getElementById(grp).appendChild(row);});
(function(){const row=document.createElement("div");row.className="row";
row.innerHTML=`<label>Palette</label><select id="palSel" style="flex:1.3"></select>`;
document.getElementById("grpColor").prepend(row);const sel=row.querySelector("#palSel");
Object.keys(PALETTES).forEach(k=>{const o=document.createElement("option");o.value=k;o.textContent=k;sel.appendChild(o);});
sel.value=S.palette;sel.addEventListener("change",()=>{S.palette=sel.value;scheduleBuild();});rowRefs._pal=sel;})();
const presetSel=document.getElementById("preset");
Object.keys(PRESETS).forEach(name=>{const o=document.createElement("option");o.value=name;o.textContent=name;presetSel.appendChild(o);});
presetSel.value=S.preset;
presetSel.addEventListener("change",()=>applyPreset(presetSel.value));
function applyPreset(name){const p=PRESETS[name];if(!p)return;Object.assign(S,D);Object.assign(S,p);
S.preset=name;S.seed=+document.getElementById("seed").value;syncUI();build();}
function syncUI(){Object.values(rowRefs).forEach(r=>{if(r&&r._sync)r._sync();});if(rowRefs._pal)rowRefs._pal.value=S.palette;presetSel.value=S.preset;}
let buildTimer=null;function scheduleBuild(){clearTimeout(buildTimer);buildTimer=setTimeout(build,150);}
document.getElementById("seed").addEventListener("change",e=>{S.seed=+e.target.value;build();});
document.getElementById("reseed").addEventListener("click",()=>{S.seed=(Math.random()*99999)|0;document.getElementById("seed").value=S.seed;build();});
document.getElementById("build").addEventListener("click",build);
document.getElementById("surprise").addEventListener("click",()=>{const names=Object.keys(PRESETS);
applyPreset(names[(Math.random()*names.length)|0]);S.seed=(Math.random()*99999)|0;document.getElementById("seed").value=S.seed;
["tectonics","craters","volcano","erosion","dunes","oxidation","craterSize"].forEach(k=>{if(S[k]!==undefined)S[k]=clamp(S[k]+(Math.random()-0.5)*0.4,0,1);});
syncUI();build();});
document.getElementById("spinToggle").addEventListener("click",e=>{autoSpin=!autoSpin;e.target.textContent=autoSpin?"⏸ spin":"▶ spin";});
document.getElementById("save").addEventListener("click",()=>{const a=document.createElement("a");a.download="planetlab_"+S.seed+".png";a.href=view.toDataURL("image/png");a.click();});
document.querySelectorAll("[data-sec]>.hd").forEach(hd=>hd.addEventListener("click",()=>hd.parentElement.classList.toggle("open")));
function hud(extra){document.getElementById("hud").innerHTML=
`<b>${S.preset.replace(/^[^ ]+ /,"")}</b><br>mode: ${S.mode} · palette: ${S.palette}<br>seed ${S.seed} · ${extra||""}`;}
function drawPipe(){const a=[];const add=(c,k)=>{if(S[k]>0)a.push(c);};
if(S.irregular>0)a.push("irregular-figure");if(S.oblate>0.01)a.push("oblate");
if(S.mode==="gas"){a.push("zonal-jets");if(S.storms>0)a.push("storms");if(S.hexagon>0)a.push("hexagon");if(S.polarVortex>0)a.push("polar-cyclones");}
else{add("tectonics","tectonics");add("orogeny","mountains");add("scarps","scarps");add("volcanism","volcano");
add("maria","floods");add("impacts","craters");add("relaxation","relaxation");add("multiring","multiring");
add("erosion","erosion");add("strata","stratify");add("aeolian","dunes");add("weathering","weather");
add("ocean","sea");add("ice","iceCap");add("lineae","linea");add("sulci","sulci");add("N₂-glacier","nitrogenGlacier");
add("sublimation","sublimation");add("dichotomy","dichotomy");add("albedo-prov","albedoProv");}
if(S.clouds>0)a.push("clouds");if(S.rings>0)a.push("rings");if(S.moons>0)a.push("moons");
document.getElementById("pipe").innerHTML="pipeline → "+a.map(x=>`<span class="on">${x}</span>`).join(" → ");}
/* codex */
const CODEX=[
["0 · Figure & illumination (whole-disk geometry)",[
["Rotational oblateness ●","Jupiter, Saturn","Fast spin flattens the figure into an ellipsoid — visible in the silhouette."],
["Irregular / triaxial figure ●","Vesta, Phobos","Bodies below ~400 km don't relax to spheres; the lumpy outline is their identity."],
["Contact-binary lobes ●","67P, Arrokoth","Two bodies merged at low speed → bilobed peanut shape."],
["Phase / terminator ●","every spacecraft image","Sun direction sets the day/night line; orbital photos are rarely full disks."],
["Opposition surge ●","full Moon","Regolith backscatters light straight back near zero phase angle."],
["Tidal locking ●","Io, Moon near side, hot Jupiters","Synchronous rotation fixes a substellar point → permanent day/night asymmetry."],
["Cast shadows ●","low-sun Moon/Vesta","Topography throws long shadows toward the terminator."],
["Lommel-Seeliger law ●","full Moon","Regolith scatters to a flat, even disk — not limb-darkened Lambert."],
["Star color temperature ●","red-dwarf planets","The illuminating star's color tints the lit hemisphere."],
["Earthshine ●","new-Moon crescent","A companion's reflected light faintly lifts the night side."]]],
["A · Endogenic (internal heat & tectonics)",[
["Tectonism ●","Mars rifts, Earth ranges","Crust stretches/folds → grabens, fold belts."],
["Lobate thrust scarps ●","Mercury","Global contraction thrusts crust over itself in one-sided cliffs."],
["Wrinkle ridges ●","lunar maria","Compression of lava plains into branching ridges."],
["Shield volcanism ●","Olympus Mons","Hotspot basalt stacks into giant edifices + collapse calderas."],
["Flood basalts / maria ●","lunar seas","Effusive lava drowns lowlands into dark plains."],
["Magma ocean / lava self-emission ●","Io, early Earth, lava worlds","Molten rock glows on its own — visible even on the night side."],
["Rift valleys / grabens ●","Valles Marineris, E. Africa","Crust pulls apart into down-dropped linear troughs."],
["Coronae ●","Venus","Mantle upwelling makes ring-fracture / annular structures."],
["Tesserae ●","Venus highlands","Intensely deformed crosshatched-ridge terrain."],
["Mantle-plume swell ●","Tharsis (Mars)","Broad regional uplift doming a whole province."],
["Volcanic plains / plateaus ●","Deccan, Lakshmi Planum","Constructional positive-relief flood-basalt plateaus."]]],
["B · Exogenic (impacts, water, wind, ice)",[
["Impact cratering ●","the Moon","Bowls, rims, central peaks, ejecta rays; density = age."],
["Viscous relaxation ●","Callisto palimpsests","Warm ice flattens craters into ghost patches."],
["Multi-ring basins ●","Orientale, Valhalla","Giant impacts make concentric ring systems."],
["Secondary chains ●","all big impacts","Ejecta blocks gouge radial crater chains."],
["Hydraulic erosion ●","Mars outflow, Earth","Water carves valleys, canyons, channels."],
["Aeolian ●","Titan dunes","Wind builds dunes, ripples, streaks, mantling."],
["Sedimentary strata ●","Mars PLD","Layered deposition exposed as banding."],
["Space weathering ●","airless bodies","Solar wind darkens/reddens mature surfaces."],
["Hydraulic rivers ●","Mars, Earth","Flow-routing carves dendritic valleys, deltas & lakebeds."],
["Mass wasting ●","Valles Marineris walls","Oversteepened slopes fail into landslides & talus."],
["Wind erosion / yardangs ●","Medusae Fossae","Wind flutes soft deposits into parallel ridges."],
["Glacial erosion ●","Yosemite, Mars","Ice carves U-valleys & flattens valley floors."],
["Coastlines / benches ●","Earth, Mars shoreline","Waves cut a bench + sea-cliff at the waterline."],
["Inverted relief ●","Mars channels","Indurated lows stand proud as terrain deflates."],
["Crater degradation ●","lunar highlands","Old craters soften and infill with age."]]],
["C · Cryo / ice / fluids",[
["Tidal lineae ●","Europa","Tidal stress cracks the ice shell."],
["Double ridges / cycloids ●","Europa","Raised twin ridges along scalloped arcs."],
["Tiger stripes ●","Enceladus","Warm fractures vent geyser plumes."],
["Grooved terrain (sulci) ●","Ganymede","Swaths of densely packed parallel grooves."],
["Chaos terrain ●","Europa","Diapirism breaks crust into jumbled blocks."],
["Nitrogen glacier ●","Pluto Sputnik P.","Bright N₂ ice fills basins, convects in polygons."],
["Sublimation / blades ●","Pluto, comets","Ice sublimes into pits and bladed ridges."],
["Hydrocarbon seas ●","Titan","Dark methane/ethane lakes, polar."],
["Equatorial ridge ●","Iapetus","A sharp belt of mountains along the equator."],
["Global glaciation (snowball) ●","Snowball Earth, Mars ice ages","Runaway ice-albedo freezes the entire surface white."],
["Cryovolcanic domes ●","Ahuna Mons (Ceres)","Viscous icy lava builds domes & lobate flows."],
["Cantaloupe terrain ●","Triton","Dimpled cellular diapir terrain."],
["Wispy fracture terrain ●","Dione, Rhea","Bright braided networks of fracture cliffs."],
["Contraction polygons ●","Triton, Mars permafrost","Thermal contraction cracks ice into polygons."],
["Polar layered spirals ●","Mars PLD","Spiral troughs cut the layered polar ice deposits."],
["Glacial flow tongues ●","Pluto Sputnik P.","N₂ ice overtops basins and creeps out in lobate tongues."],
["Frozen-over seas ●","Titan winter lakes","A cold sea freezes a flat bright ice lid over the basin."],
["Sea ice / leads ●","Earth pack ice","Floating ice cracks into floes split by dark open-water leads."],
["Araneiform spiders ●","Mars S. polar","Sublimating CO₂ gas carves radial dendritic dark fans."]]],
["D · Albedo provinces (decoupled from relief)",[
["Albedo provinces ●","Moon, Mars dust","Composition/dust maps independent of topography."],
["Crustal dichotomy ●","Mars","Degree-1 hemispheric asymmetry + crater-age gradient."],
["Hemispheric albedo ●","Iapetus","Dust infall darkens one whole hemisphere."],
["Polar tholin cap ●","Charon","Cold-trapped gas reddens a pole."],
["Slope streaks / RSL ●","Mars","Downslope dark lineae, pure albedo."],
["Wind streaks ●","Mars","Bright/dark tails deposited downwind of craters."],
["Magnetic swirls ●","Moon (Reiner Gamma)","Sinuous bright patterns unrelated to topography."],
["Compositional provinces ●","Moon mare/highland","Sharp-edged distinct albedo/terrain units."],
["Faculae / salt deposits ●","Ceres (Occator)","Compact, very-bright evaporite spots."],
["Latitudinal albedo bands ●","Mars, Triton","Broad bright/dark zonal belts by latitude."],
["Dust-devil tracks ●","Mars","Criss-crossing dark trails where dust is scoured off."]]],
["E · Atmosphere, clouds & companions",[
["Atmosphere thickness ●","Venus thick ↔ Mars thin","Scale height sets limb extent, twilight width & surface veiling."],
["Rayleigh scattering ●","Earth blue limb","Molecular scatter → blue day-limb + reddened sunset ring at the terminator."],
["Aerosol haze veil (Mie) ●","Venus, Titan","Smog desaturates the disk and can fully hide the surface."],
["Detached haze layers ●","Titan, Pluto","Stacked aerosol layers form concentric bright arcs above the limb."],
["Limb darkening ●","gas giants","Photometric darkening of the disk toward the edge."],
["Cloud shadows ●","Earth, Jupiter","Cloud decks cast shadows on the surface/deck below."],
["Zonal jets / bands ●","Jupiter, Saturn","Convection + rotation → alternating belts/zones."],
["Altitude-coded color ●","Jupiter","Cloud-deck height sets brown↔white."],
["Storm vortices ●","Great Red Spot","Long-lived anticyclones between jets."],
["Polar cyclone clusters ●","Jupiter (Juno)","Rings of cyclones around the poles."],
["Polar hexagon ●","Saturn","Standing 6-lobed polar jet."],
["Cloud layer over surface ●","Earth, Venus, Titan","Semi-opaque clouds (Venus hides the surface)."],
["Global dust storm ●","Mars","Haze washes out the whole disk."],
["Axial tilt / obliquity ●","Uranus (98°)","Tilts the spin axis & ring plane — seasons; Uranus lies on its side."],
["Rings + divisions + shadows ●","Saturn","Ice annulus with Cassini/Encke gaps; mutual ring/planet shadowing."],
["Moons + transit shadows ●","Jovian system","Satellites cast shadow dots on the primary."],
["Auroral ovals ●","Earth, gas giants","Magnetospheric emission ringing the poles on the night side."],
["Comet coma + tail ●","comets, evaporating planets","Sublimating volatiles form a coma and an anti-sunward tail."],
["City lights (night side) ●","Earth","Biosignature emissive on the dark hemisphere."],
["Cyclonic weather ●","Earth hurricanes, Venus Y","Spiral storm systems & fronts in the cloud layer."],
["Multi-deck clouds ●","Jupiter, Earth","Stacked cloud decks; gaps reveal the layer below."],
["Lightning ●","Jupiter, Earth night side","Transient flashes in storm clouds on the dark side."],
["Ring spokes ●","Saturn B-ring","Radial markings rotating across the rings."],
["Ring forward-scatter ●","Saturn backlit","Rings flare bright when backlit at high phase angle."],
["Eclipse penumbra ●","moon transit shadow","Soft umbra→penumbra gradient at a cast shadow's edge."],
["Dust + ion comet tails ●","comets","A curved warm dust tail offset from the straight blue ion tail."],
["Polar haze hood ●","Mars, Titan","Aerosol concentrates into a darker polar cap of haze."]]]
];
(function(){let html="";CODEX.forEach(([cat,rows])=>{html+=`<div class="cat">${cat}</div><table>`;
rows.forEach(([a,b,c])=>html+=`<tr><td>${a}</td><td>${b}</td><td>${c}</td></tr>`);html+="</table>";});
html+=`<div class="cx" style="margin-top:22px">● = adjustable layer in this build. A world's whole-disk
appearance = figure × illumination × (which processes ran, how hard, in what order) × chemistry × companions.</div>`;
document.getElementById("codexBody").innerHTML=html;})();
document.getElementById("codexBtn").addEventListener("click",()=>document.getElementById("codex").classList.add("show"));
document.getElementById("codexClose").addEventListener("click",()=>document.getElementById("codex").classList.remove("show"));
/* GO — supports planetlab.html?p=<name substring> to deep-link a preset and hold still for capture */
(function(){const q=new URLSearchParams(location.search).get("p")||(location.hash?decodeURIComponent(location.hash.slice(1)):"");
let start=S.preset;
if(q){const hit=Object.keys(PRESETS).find(n=>n.toLowerCase().includes(q.toLowerCase()));if(hit)start=hit;}
applyPreset(start);
if(q){autoSpin=false;const t=document.getElementById("spinToggle");if(t)t.textContent="▶ spin";}
})();
window.addEventListener("hashchange",()=>{const q=location.hash?decodeURIComponent(location.hash.slice(1)):"";if(!q)return;
const hit=Object.keys(PRESETS).find(n=>n.toLowerCase().includes(q.toLowerCase()));
if(hit){applyPreset(hit);autoSpin=false;const t=document.getElementById("spinToggle");if(t)t.textContent="▶ spin";}});
loop();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment