From 09ea182f86f435e28c2492e3d5e9849a5d78e024 Mon Sep 17 00:00:00 2001
From: Protonmatter <102541048+Protonmatter@users.noreply.github.com>
Date: Wed, 23 Sep 2026 23:56:25 +0000
Subject: [PATCH 1/2] Add optional Enhanced Earth oceans, clouds and shadows
---
README.md | 10 ++
apps/web/index.html | 4 +
apps/web/js/atmosphereColumnField.js | 10 +-
apps/web/js/atmosphereColumnManifest.js | 4 +-
apps/web/js/earthOceanMask.js | 6 +
apps/web/js/enhancedEarth.js | 68 +++++++
apps/web/js/orrery.js | 72 +++++++-
apps/web/js/orreryShaders.js | 35 +++-
apps/web/js/planetAppearance.js | 12 +-
docs/IMPLEMENTATION_PLAN.md | 13 +-
docs/REQUIREMENTS.md | 1 +
docs/SPEC.md | 13 ++
docs/STATUS.md | 10 +-
docs/requirements.json | 23 +++
docs/rfcs/0008-enhanced-earth.md | 107 +++++++++++
docs/validation/enhanced-earth/README.md | 92 ++++++++++
docs/validation/enhanced-earth/comparison.jpg | Bin 0 -> 238889 bytes
docs/validation/enhanced-earth/gl-probe.json | 29 +++
tests/python/test_earth_ocean_mask.py | 24 +++
tests/python/test_release_artifact.py | 1 +
tests/web/enhancedEarth.test.mjs | 166 ++++++++++++++++++
tests/web/helpers/orreryHarness.mjs | 1 +
tests/web/illustrativeHaze.test.mjs | 2 +-
tools/build_web.py | 2 +-
tools/enhanced_earth_probe.mjs | 14 ++
tools/prepare_earth_ocean_mask.py | 116 ++++++++++++
tools/render_enhanced_earth_probe.py | 126 +++++++++++++
27 files changed, 944 insertions(+), 17 deletions(-)
create mode 100644 apps/web/js/earthOceanMask.js
create mode 100644 apps/web/js/enhancedEarth.js
create mode 100644 docs/rfcs/0008-enhanced-earth.md
create mode 100644 docs/validation/enhanced-earth/README.md
create mode 100644 docs/validation/enhanced-earth/comparison.jpg
create mode 100644 docs/validation/enhanced-earth/gl-probe.json
create mode 100644 tests/python/test_earth_ocean_mask.py
create mode 100644 tests/web/enhancedEarth.test.mjs
create mode 100644 tools/enhanced_earth_probe.mjs
create mode 100644 tools/prepare_earth_ocean_mask.py
create mode 100644 tools/render_enhanced_earth_probe.py
diff --git a/README.md b/README.md
index fd37277d..2a549de9 100644
--- a/README.md
+++ b/README.md
@@ -113,3 +113,13 @@ Sol is not for navigation, occultation prediction, mission safety or operational
space-weather warnings. `space_weather_operational` remains false. Project code is
MIT OR Apache-2.0; third-party data attribution and outstanding coefficient notice
correspondence are documented separately and are not resolved by that project license.
+
+### Optional Enhanced Earth
+
+Solar System → View controls → Earth layers → **Enhanced Earth** adds darker
+offshore oceans and an elevated historical NASA cloud layer with Sun-directed
+shadows. Clouds drift independently during Play time and freeze when paused.
+The option is off by default; cloud height, motion and lighting are illustrative.
+MODIS swaths and the scientific sea-ice palette suspend the enhancement.
+See [RFC 0008](docs/rfcs/0008-enhanced-earth.md) and the
+[validation record](docs/validation/enhanced-earth/README.md).
diff --git a/apps/web/index.html b/apps/web/index.html
index 5f757864..bd797410 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -25,6 +25,8 @@
+
+
@@ -455,6 +457,8 @@
Tools
Earth layers
+
+
Deeper oceans and raised clouds with Sun-aligned shadows. Clouds drift independently during Play time. Color, nominal 8 km cloud height and motion are illustrative; the NASA imagery keeps its historical date. Uses the Blue Marble cloud layer; suspended for MODIS swaths and the sea-ice analysis palette.
diff --git a/apps/web/js/atmosphereColumnField.js b/apps/web/js/atmosphereColumnField.js
index f957c67a..7925e9fa 100644
--- a/apps/web/js/atmosphereColumnField.js
+++ b/apps/web/js/atmosphereColumnField.js
@@ -213,16 +213,16 @@ vec3 atmosphereCombinedTail(float impact,float x){
// Rationalize altitude before lookup: subtracting a rounded body-sized radius
// quantizes a short interval's height increment and corrupts its column mass.
float radius=length(vec2(impact,x)),height=((impact-u_atmosphereRadiusKm)*(impact+u_atmosphereRadiusKm)+x*x)/(radius+u_atmosphereRadiusKm);
- vec3 packed;
+ vec3 columnValues;
if(height<0.0){
float ground=sqrt(max(0.0,u_atmosphereRadiusKm*u_atmosphereRadiusKm-impact*impact));
float below=max(0.0,ground-x);
- packed=atmosphereOutwardPacked(0.0,ground/u_atmosphereRadiusKm)+vec3(below,below,below*atmosphereOzoneDensity(0.0));
- }else packed=atmosphereOutwardPacked(height,x/max(radius,1e-9));
+ columnValues=atmosphereOutwardPacked(0.0,ground/u_atmosphereRadiusKm)+vec3(below,below,below*atmosphereOzoneDensity(0.0));
+ }else columnValues=atmosphereOutwardPacked(height,x/max(radius,1e-9));
// A uniform scale, not a literal zero, keeps the admitted sampler from being
// constant-folded out of the generator. The runtime value stays 0.
- packed.xy+=texelFetch(u_atmosphereColumnField,ivec2(0),0).rg*u_atmosphereColumnKeep;
- return packed;
+ columnValues.xy+=texelFetch(u_atmosphereColumnField,ivec2(0),0).rg*u_atmosphereColumnKeep;
+ return columnValues;
}
vec2 atmosphereOutwardColumns(float height,float mu){return atmosphereOutwardPacked(height,mu).rg;}
float atmosphereOzoneOutward(float height,float mu){return atmosphereOutwardPacked(height,mu).b;}
diff --git a/apps/web/js/atmosphereColumnManifest.js b/apps/web/js/atmosphereColumnManifest.js
index 12d8bcce..ee7e6dde 100644
--- a/apps/web/js/atmosphereColumnManifest.js
+++ b/apps/web/js/atmosphereColumnManifest.js
@@ -13,7 +13,7 @@ export const ATMOSPHERE_COLUMN_FIELDS=Object.freeze({
"profile_encoding": "atmosphere-profile-binary32-v1",
"profile_sha256": "e15e53579417f828a4f02b401d2c9ddebd78c6f956f5177d1def336ac7d7bed8",
"generator_source_sha256": "6c7c90d4a4b72fc4d9c38bdb191889780e2f3a2cf44375cfe00f87c82c2fb48c",
- "field_source_sha256": "26f9171d8db660af0bbe46b7631ec89b86895e143a181aaa13c2d6afce2115f1",
+ "field_source_sha256": "7da48dcd0061e244de4365de8e8ba1acbce7d27a79a42cbebcda6784ae05ed44",
"solver_source_sha256": "282d74036c31b418ca8e1e43f9fdb4f975cf3ec03e3217da801d54ec945aa635",
"profile_source_sha256": "b8ad270b14c4ef16affc7c31bd7fb5111895bc9cc6332cbe7c8c171d4582b965"
},
@@ -30,7 +30,7 @@ export const ATMOSPHERE_COLUMN_FIELDS=Object.freeze({
"profile_encoding": "atmosphere-profile-binary32-v1",
"profile_sha256": "28e25e74130b1a426f07004ebdc66ca6c8f1094433f305302dab4c7338b15d7e",
"generator_source_sha256": "6c7c90d4a4b72fc4d9c38bdb191889780e2f3a2cf44375cfe00f87c82c2fb48c",
- "field_source_sha256": "26f9171d8db660af0bbe46b7631ec89b86895e143a181aaa13c2d6afce2115f1",
+ "field_source_sha256": "7da48dcd0061e244de4365de8e8ba1acbce7d27a79a42cbebcda6784ae05ed44",
"solver_source_sha256": "282d74036c31b418ca8e1e43f9fdb4f975cf3ec03e3217da801d54ec945aa635",
"profile_source_sha256": "b8ad270b14c4ef16affc7c31bd7fb5111895bc9cc6332cbe7c8c171d4582b965"
}
diff --git a/apps/web/js/earthOceanMask.js b/apps/web/js/earthOceanMask.js
new file mode 100644
index 00000000..bc7d25fb
--- /dev/null
+++ b/apps/web/js/earthOceanMask.js
@@ -0,0 +1,6 @@
+// GENERATED by tools/prepare_earth_ocean_mask.py. Public-domain Natural Earth.
+// Conservative offshore display mask, not a qualified coastline or reflectance map.
+export const OCEAN_MASK_WIDTH=512,OCEAN_MASK_HEIGHT=256;
+export const OCEAN_MASK_SOURCES={"ne_110m_land.geojson":"9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9","ne_110m_glaciated_areas.geojson":"e61e3f45e22f087f861ac2fda6258fc15c849a216d74c27ccf0164eeafbd950a"};
+export const OCEAN_MASK_SHA256='0644b40d075dc4da2ed10928a6dbc40f2924ceaff9a300948b2f2c8781ca2bdf';
+export const OCEAN_MASK_RUNS=[0,3782,24,422,38,17,41,408,104,404,120,150,5,234,123,42,11,28,9,55,13,231,123,31,25,25,9,55,15,216,7,6,123,30,26,25,9,55,20,211,136,30,26,25,9,55,23,197,8,3,131,35,26,25,9,55,23,186,19,3,128,38,26,90,24,182,152,38,22,50,13,34,30,173,152,40,20,44,19,27,38,29,15,128,67,5,80,43,8,1,8,41,22,22,43,29,23,117,70,6,79,44,6,50,23,22,43,29,23,117,71,7,75,101,24,14,58,2,10,10,23,116,72,22,60,99,20,3,13,4,72,8,23,115,76,21,57,99,16,8,89,9,22,40,5,70,81,17,55,100,13,9,123,36,8,21,15,34,84,15,54,60,13,27,11,10,136,24,8,18,32,12,95,13,52,55,19,27,11,10,136,11,7,6,8,16,142,12,52,53,27,21,11,1,177,12,146,13,51,50,35,16,191,10,149,10,51,48,40,2,7,1,196,8,150,9,49,49,42,1,210,2,125,2,26,7,45,9,6,36,381,2,26,7,42,1,17,34,383,1,27,7,35,8,18,32,384,1,27,7,33,10,18,31,413,8,30,12,18,29,414,9,26,16,18,26,250,2,12,1,149,14,21,21,16,24,251,6,9,2,148,14,20,23,14,24,252,10,5,2,111,1,36,15,19,26,6,29,252,15,113,6,31,16,16,63,251,16,110,9,32,16,14,64,248,19,109,18,25,16,13,64,244,23,110,17,26,19,9,49,7,8,241,26,31,2,78,16,27,20,6,50,9,6,240,35,20,11,77,11,27,76,9,7,228,46,17,16,77,9,29,74,9,7,20,2,175,16,15,44,19,22,77,3,30,73,10,10,16,2,174,17,15,44,19,23,113,65,15,9,190,19,15,44,12,31,112,63,18,8,192,17,15,44,10,33,114,61,19,2,201,13,13,46,6,37,114,61,222,13,13,89,114,61,222,13,12,90,114,61,222,14,10,92,113,61,223,13,9,93,5,1,107,61,223,13,7,101,110,64,217,13,6,102,111,63,217,121,111,63,217,123,95,1,13,65,215,124,108,65,214,127,106,65,214,127,96,2,8,67,212,127,96,4,6,68,214,124,96,68,224,124,94,70,224,124,91,73,58,2,151,1,12,124,89,75,210,2,12,124,82,82,208,4,12,124,82,82,21,4,178,9,9,127,82,82,21,4,177,10,7,129,77,87,19,6,6,1,169,10,8,129,76,88,18,7,176,9,9,130,75,88,17,4,180,6,12,130,74,89,41,1,159,6,12,131,72,90,40,3,158,1,16,133,70,91,39,4,7,2,139,2,24,134,70,91,33,1,5,5,6,2,139,2,24,135,69,92,32,22,134,4,24,136,68,91,37,6,6,10,130,4,24,136,67,92,39,2,10,8,131,3,22,141,62,93,41,1,149,7,14,147,59,94,191,7,10,151,57,96,192,7,6,155,55,96,193,7,6,155,55,95,194,7,5,157,55,92,196,169,42,1,12,91,197,170,33,2,5,6,8,90,197,171,31,15,8,89,198,171,30,16,8,89,198,172,29,17,11,84,199,175,26,17,11,83,200,175,26,15,13,83,200,175,25,15,14,82,113,6,82,110,5,62,23,9,21,81,113,7,81,110,7,61,23,5,25,80,113,8,80,110,8,60,5,1,18,4,27,78,113,9,71,2,5,111,8,67,17,4,34,71,113,9,67,122,8,67,30,8,19,69,112,11,29,3,31,127,6,67,30,9,18,69,111,16,25,3,31,9,7,112,5,67,30,9,18,70,109,18,23,5,30,9,7,112,5,68,29,9,18,69,110,18,21,8,28,10,7,186,32,5,18,69,109,19,20,10,27,10,7,189,31,3,7,2,5,73,108,20,19,11,25,12,7,190,30,90,107,21,17,13,25,12,7,193,27,90,103,25,17,13,25,12,9,194,24,90,102,27,13,17,24,12,10,199,18,12,5,73,100,29,13,21,20,12,11,199,17,10,9,71,101,29,12,21,20,13,11,201,13,8,14,69,101,29,12,21,20,13,11,204,11,7,24,59,101,29,12,22,19,10,14,205,41,59,101,30,12,21,19,9,15,206,41,59,100,30,12,21,19,9,16,206,42,59,98,31,12,20,18,10,16,206,43,58,97,32,12,20,7,2,7,12,16,208,42,59,95,33,11,20,15,11,7,1,10,209,45,55,95,33,11,20,14,11,6,3,10,212,45,52,94,35,10,16,15,13,9,1,10,213,45,52,93,38,7,16,15,11,11,1,10,218,41,53,90,40,6,16,15,11,11,3,8,218,42,53,21,1,67,62,15,10,12,4,6,217,44,54,17,4,66,63,16,6,15,227,45,55,6,14,64,64,16,4,17,8,5,213,46,80,58,66,15,3,32,211,47,80,58,67,14,3,32,211,49,78,57,69,13,3,32,210,53,75,55,72,12,3,40,202,55,73,54,74,12,2,46,196,58,70,52,76,13,1,48,194,63,65,52,77,37,1,27,190,65,63,51,79,12,1,23,1,30,186,67,62,50,80,12,2,22,1,31,5,6,174,70,60,48,82,11,2,22,1,32,1,9,174,70,61,46,84,12,2,20,1,45,171,70,62,45,85,22,2,9,10,37,170,71,61,44,87,18,5,9,12,35,171,70,62,43,88,20,2,9,12,40,166,70,62,43,90,34,7,28,1,11,167,69,63,42,90,34,12,20,5,13,165,68,63,43,91,32,12,22,8,8,165,67,65,43,95,27,12,22,8,8,166,66,65,43,104,18,5,5,6,18,9,7,166,65,66,43,108,12,4,13,3,6,1,9,9,7,167,64,65,44,7,6,96,10,4,14,2,7,3,7,183,63,66,44,7,6,110,14,2,7,194,61,66,45,6,7,104,20,2,9,192,60,67,45,5,9,102,21,2,10,26,5,161,58,68,45,3,11,101,22,2,10,26,5,16,2,143,58,68,45,1,13,100,24,1,10,26,6,11,6,144,57,68,45,1,13,98,38,25,6,11,6,145,56,68,45,1,13,98,38,25,6,9,8,146,55,68,44,2,13,97,39,26,5,9,8,148,52,69,43,3,12,97,41,25,5,9,6,152,50,69,41,5,11,95,46,37,6,153,49,69,40,6,11,92,50,18,5,13,6,154,48,69,39,6,12,90,52,18,6,172,48,70,37,7,12,89,54,17,8,170,47,71,37,7,11,89,56,16,8,170,46,73,36,7,11,89,57,15,8,170,46,73,36,7,11,88,59,15,7,170,45,75,35,7,10,89,60,15,6,169,45,76,35,7,10,89,61,189,41,80,34,8,10,89,61,189,39,83,33,8,9,90,61,189,37,85,31,11,8,90,61,189,36,86,30,110,61,188,37,86,30,110,61,188,37,87,29,111,60,188,37,87,28,112,60,188,37,88,27,113,59,188,36,90,25,114,59,188,35,91,24,116,58,188,34,93,22,117,58,188,33,95,21,117,58,187,33,96,19,119,22,2,33,25,5,158,32,97,18,120,18,9,30,25,6,156,33,97,16,122,16,12,28,26,6,156,32,98,9,129,10,18,27,27,8,153,31,239,7,23,24,27,8,153,28,274,21,28,12,149,28,276,19,29,11,149,28,276,18,31,10,149,27,277,18,31,10,149,26,281,13,30,13,149,21,290,9,29,13,149,22,290,9,28,13,150,21,291,9,27,13,151,20,292,9,25,14,152,20,292,9,23,14,154,20,293,7,23,14,154,19,295,6,24,13,155,18,326,12,156,18,326,11,157,18,326,10,157,19,187,5,134,9,158,19,187,6,300,19,187,6,300,18,188,6,300,16,5,9,176,6,301,15,5,9,176,6,301,14,6,9,483,15,5,9,483,17,3,9,484,17,495,17,496,16,498,14,501,11,4101,6,502,10,499,13,498,14,224,5,41,5,220,17,151,12,40,5,14,10,4,10,18,12,218,14,151,18,34,18,1,72,204,11,150,34,16,97,204,11,133,5,7,43,9,105,193,16,133,56,8,115,184,17,109,8,11,60,2,126,179,18,91,96,1,131,171,23,68,261,121,12,26,24,66,264,118,14,6,5,15,24,64,266,84,6,1,5,22,30,2,33,59,270,84,6,1,17,10,65,59,270,68,46,4,65,58,271,57,126,54,274,58,126,46,281,55,129,41,284,45,138,41,285,41,143,19,11,8,292,39,137,24,13,7,292,39,131,14,5,9,15,7,292,39,133,5,13,4,19,7,292,39,151,4,19,5,294,40,8,1,141,4,314,53,142,1,317,52,466,23,8,15,471,9,23,12,5085];
diff --git a/apps/web/js/enhancedEarth.js b/apps/web/js/enhancedEarth.js
new file mode 100644
index 00000000..7b594159
--- /dev/null
+++ b/apps/web/js/enhancedEarth.js
@@ -0,0 +1,68 @@
+// Illustrative presentation only. Source images, epochs and ephemerides are immutable.
+import {OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,OCEAN_MASK_RUNS} from './earthOceanMask.js';
+import {rotationDisplayStepSeconds} from './orreryTime.js';
+
+export const EARTH_CLOUD_HEIGHT_KM=8;
+export const ENHANCED_EARTH_UNIFORMS=['u_earthEnhanced','u_earthCloudShadow','u_cloudPhase','u_cloudScale','u_oceanMask'];
+
+export function enhancedEarthSelected(state){
+ return state.earthEnhanced===true&&state.useTextures!==false&&state.earthCloudSource!=='daily'&&state.earthIce!==true&&!state.galaxy;
+}
+
+export function advanceEarthCloudPhase(phase,realSeconds,simulatedSeconds,rotationHours,enabled){
+ if(!enabled||!Number.isFinite(realSeconds)||realSeconds<=0)return phase;
+ const seconds=rotationDisplayStepSeconds(realSeconds,simulatedSeconds,rotationHours);
+ if(seconds===0)return phase;
+ const drift=seconds/(Math.abs(rotationHours)*3600)*.03;
+ if(!Number.isFinite(drift))return phase;
+ const step=Math.sign(drift)*Math.min(Math.abs(drift),.002*realSeconds);
+ return ((phase+step)%1+1)%1;
+}
+
+export function oceanMaskPixels(){
+ const pixels=new Uint8Array(OCEAN_MASK_WIDTH*OCEAN_MASK_HEIGHT);
+ let offset=0,value=0;
+ for(const run of OCEAN_MASK_RUNS){pixels.fill(value,offset,offset+run);offset+=run;value=255-value;}
+ return pixels;
+}
+
+export function uploadOceanMask(gl){
+ const texture=gl.createTexture();
+ if(!texture)throw new Error('Ocean mask allocation failed');
+ try{
+ gl.activeTexture(gl.TEXTURE0+11);gl.bindTexture(gl.TEXTURE_2D,texture);
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.R8,OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,0,gl.RED,gl.UNSIGNED_BYTE,oceanMaskPixels());
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
+ if(gl.getError()!==gl.NO_ERROR)throw new Error('Ocean mask upload failed');
+ return texture;
+ }catch(error){gl.deleteTexture(texture);throw error;}
+ finally{gl.activeTexture(gl.TEXTURE0);}
+}
+
+// Input p is on the unit sphere before oblate scaling. Return a point on the
+// homothetic cloud ellipsoid in those same unflattened coordinates. The rational
+// root avoids subtractive cancellation for a thin shell and an overhead Sun.
+export const EARTH_CLOUD_GEOMETRY_GLSL=`
+vec3 earthCloudHit(vec3 p,vec3 light,float oblate,float shell){
+ vec3 d=vec3(light.xy,light.z/oblate);
+ float a=dot(d,d),b=dot(p,d),c=dot(p,p)-shell*shell;
+ float root=sqrt(max(0.0,b*b-a*c));
+ float t=b>=0.0 ? -c/max(root+b,1e-12) : (root-b)/max(a,1e-12);
+ return p+d*t;
+}
+`;
+
+// The mask excludes coarse land/ice/coastal margins. Dark-blue gating additionally
+// protects bright ice, land-like colors and islands omitted by the coarse vectors.
+export const EARTH_OCEAN_GLSL=`
+vec3 enhancedOceanColor(vec3 c,float ocean){
+ float blue=max(0.0,c.z-max(c.x,c.y));
+ float bright=max(c.x,max(c.y,c.z));
+ float weight=ocean*min(1.0,blue/.03)*min(1.0,max(0.0,(.25-bright)/.1));
+ float luminance=dot(c,vec3(.2126,.7152,.0722));
+ return c*(1.0-weight)+(c*.8+vec3(luminance)*.2)*.55*weight;
+}
+`;
diff --git a/apps/web/js/orrery.js b/apps/web/js/orrery.js
index a6756a61..62cafed7 100644
--- a/apps/web/js/orrery.js
+++ b/apps/web/js/orrery.js
@@ -17,6 +17,7 @@
import { store } from "./store.js?v=dcca6290db";
import { createShaderPrograms } from "./shaderPrograms.js";
import { linearFilterReference } from './materialColor.js';
+import {EARTH_CLOUD_HEIGHT_KM,ENHANCED_EARTH_UNIFORMS,enhancedEarthSelected,advanceEarthCloudPhase,uploadOceanMask} from './enhancedEarth.js';
import { createHdrPresentation } from './hdrPresentation.js';
import { srgbToLinear } from './surfaceMapping.js';
import { appearanceReference, appearanceReferences, appearanceUniforms, appearanceFallbackColor, earthLayerDescription, earthCloudRole, surfaceReferenceShown } from "./planetAppearance.js";
@@ -160,6 +161,7 @@ const state = (store.orrery = {
galSpeed: 2, // galaxy-view rate (millions of years per real second), decoupled from the planetary rate
showOrbits: true, showSky: true, showConst: false, showLabels: true, showSunEq: false, useTextures: true, galaxy: false,
earthNight: true, earthWeather: true, earthIce: false, earthCloudSource: 'composite', venusRadar: false,
+ earthEnhanced:false, earthCloudPhase:0, earthMaskStatus:'deferred',
appearanceStatus: {},
terrainEnabled:true, opticsEnabled:true, terrainStatus:{}, terrainRendered:{}, opticsStatus:{}, scatteringStatus:{}, scatteringFrame:null,
programStatus:{base:'deferred',physical:'deferred'},programDiagnostics:{},
@@ -194,6 +196,7 @@ const DRAW_LIST = ["Sun", ...PLANET_ORDER, "Moon"];
// live in orreryMath.js; GLSL sources in orreryShaders.js — both imported above.)
// ---------------------------------------------------------------- WebGL2 renderer
+let earthOceanMask=null;
let gl, P = {}, sphere, quadBuf, cel, celBufs = {}, particles = null;
let shaderPrograms=null,programContextGeneration=0;
let hdrPresentation=null,hdrFailure=null,contextGeneration=0,sceneSerial=0,linearFrame=false,graphicsLifecycle=0;
@@ -864,7 +867,7 @@ function finishGL(){
P.glowU = uloc(P.glow, ["u_vp", "u_center", "u_right", "u_up", "u_size", "u_color", "u_pow"]);
Object.assign(P.sphereU,uloc(P.sphere,[...ATMOSPHERE_UNIFORMS,...INCIDENT_FIELD_UNIFORMS,'u_atmosphereColumnField','u_atmosphereOzoneField','u_bodyRadiusKm','u_terrainHeight','u_terrainShadowEnabled','u_terrainShape','u_terrainPoles']));
P.solarU=uloc(P.solar,['u_mvp','u_camObj','u_pass','u_extent','u_atlas','u_quiet','u_frameMix','u_phase','u_displayGain','u_coronaGlow','u_cmeProgress','u_cmeAxis','u_sourceBasis0','u_sourceBasis1','u_projection0','u_projection1','u_observerRadii','u_loopNormal[0]','u_loopTangent[0]','u_loopGain[0]']);
- Object.assign(P.sphereU,uloc(P.sphere,['u_textureLinear']));
+ Object.assign(P.sphereU,uloc(P.sphere,['u_textureLinear',...ENHANCED_EARTH_UNIFORMS]));
for(const name of ['sphere','line','ring','pt','glow','solar'])
Object.assign(P[`${name}U`],uloc(P[name],['u_linearOutput']));
@@ -1933,6 +1936,55 @@ function updateEarthLayerStatus() {
if (caption) caption.textContent = `${ice?.label || 'Sea ice unavailable'} · ${ice?.observation_label || 'Date unavailable'}. Transparent areas have no displayed data.`;
}
+function prepareEarthEnhancement(body,referenceReady) {
+ const selected=body==='Earth'&&referenceReady&&enhancedEarthSelected(state);
+ if(selected&&!earthOceanMask&&state.earthMaskStatus!=='unavailable'){
+ try{earthOceanMask=uploadOceanMask(gl);state.earthMaskStatus='ready';}
+ catch(error){state.earthMaskStatus='unavailable';console.warn('Enhanced Earth:',error.message);}
+ updateEarthLayerStatus();
+ }
+ const asset=appearanceReference('Earth','cloud-composite');
+ const clouds=selected&&state.earthWeather&&referenceTextures[asset.id]?.ready ? referenceTextures[asset.id] : null;
+ return {selected,clouds,phase:state.earthCloudPhase,scale:1+EARTH_CLOUD_HEIGHT_KM/BODY.Earth.radiusKm};
+}
+
+function bindEarthEnhancement(enhancement,locations) {
+ gl.uniform1i(locations.u_earthEnhanced,enhancement.selected&&earthOceanMask?1:0);
+ gl.uniform1i(locations.u_earthCloudShadow,enhancement.clouds?1:0);
+ gl.uniform1f(locations.u_cloudPhase,enhancement.phase);
+ gl.uniform1f(locations.u_cloudScale,enhancement.scale);
+ gl.activeTexture(gl.TEXTURE0+11);gl.bindTexture(gl.TEXTURE_2D,earthOceanMask||whiteTex);
+ gl.uniform1i(locations.u_oceanMask,11);gl.activeTexture(gl.TEXTURE0);
+ // Keep its sampler bound for the shadow, but remove the flattened cloud blend.
+ if(enhancement.clouds)gl.uniform1i(locations.u_earthWeather,0);
+}
+
+function drawEarthClouds(enhancement,pos,rot,rEq,rPol,vp,eye,light,normals,reference) {
+ const u=P.sphereU,model=mul(translate(pos),mul(rot,scaleM([rEq*enhancement.scale,rEq*enhancement.scale,rPol*enhancement.scale])));
+ gl.useProgram(P.sphere);setAtmosphereUniforms(gl,u,null);
+ gl.uniform1i(u.u_linearOutput,linearFrame?1:0);
+ gl.uniformMatrix4fv(u.u_mvp,false,new Float32Array(mul(vp,model)));
+ gl.uniformMatrix4fv(u.u_model,false,new Float32Array(model));
+ gl.uniformMatrix3fv(u.u_nmat,false,new Float32Array(normals));
+ gl.uniform3fv(u.u_cam,new Float32Array(eye));gl.uniform3fv(u.u_light,new Float32Array(light));
+ gl.uniform1f(u.u_oblate,BODY.Earth.polarKm/BODY.Earth.radiusKm);
+ gl.uniform1i(u.u_mode,3);gl.uniform1i(u.u_style,-1);
+ gl.uniform1i(u.u_earthNight,0);gl.uniform1i(u.u_earthIce,0);gl.uniform1i(u.u_earthWeather,0);
+ gl.uniform1i(u.u_earthEnhanced,0);gl.uniform1i(u.u_earthCloudShadow,0);
+ gl.uniform1f(u.u_cloudPhase,enhancement.phase);gl.uniform1f(u.u_cloudScale,enhancement.scale);
+ const uniforms=appearanceUniforms(reference);
+ gl.uniform4fv(u.u_map,new Float32Array(uniforms.map));
+ gl.uniform4fv(u.u_mapLat,new Float32Array(uniforms.lat));
+ gl.uniform4fv(u.u_mapWindow,new Float32Array(uniforms.window));
+ gl.activeTexture(gl.TEXTURE0+3);gl.bindTexture(gl.TEXTURE_2D,enhancement.clouds.tex);gl.uniform1i(u.u_weatherTex,3);
+ gl.activeTexture(gl.TEXTURE0);
+ setHazeUniforms(gl,u,getAtmosphereProfile('Earth'));
+ bindBodyMesh();gl.enable(gl.CULL_FACE);gl.cullFace(gl.BACK);gl.depthMask(false);
+ gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
+ gl.drawElements(gl.TRIANGLES,sphere.count,gl.UNSIGNED_SHORT,0);
+ gl.depthMask(true);gl.disable(gl.CULL_FACE);
+}
+
function bindEarthTextures(enabled,locations=P.sphereU) {
for (const [role, active, flag, sampler, unit] of /** @type {[string,boolean,string,string,number][]} */ ([
['night-lights', state.earthNight, 'u_earthNight', 'u_nightTex', 2],
@@ -2067,6 +2119,8 @@ function drawBody(b, vp, eye) {
gl.uniform1i(sphereUniforms.u_mapNoData, uniforms.nodata);
}
bindEarthTextures(!!referenceTex && b.name === 'Earth',sphereUniforms);
+ const earthEnhancement=prepareEarthEnhancement(b.name,!!referenceTex);
+ bindEarthEnhancement(earthEnhancement,sphereUniforms);
gl.uniform3fv(sphereUniforms.u_sunA, new Float32Array(sunTexd ? sunDiskBasis() : [1, 0, 0]));
// Ring-shadow inputs: the light direction expressed in the BODY frame (Rᵀ·light — rot's
// upper 3×3 is orthonormal, column-major), the annulus radii in equatorial-radius units, the
@@ -2097,6 +2151,8 @@ function drawBody(b, vp, eye) {
gl.drawElements(gl.TRIANGLES,mesh.count,mesh.indexType||gl.UNSIGNED_SHORT,0);
gl.disable(gl.CULL_FACE);
+ if(earthEnhancement.clouds)queueTransparent(pos,eye,()=>drawEarthClouds(earthEnhancement,pos,rot,rEq,rPol,vp,eye,light,normals,reference));
+
// atmosphere limb halo (additive shell, slightly larger, no depth write)
if (atmoStr > 0 && b.name !== "Sun" && b.name !== "Earth" && !profile) {
// A restrained illustrative optical limb, not an atmospheric-height measurement.
@@ -2291,6 +2347,7 @@ function drawMoons(parentName, parentPos, parentDisplayAU, vp, eye, drawn) {
gl.uniform1i(P.sphereU.u_mapNoData, uniforms.nodata);
}
bindEarthTextures(false); // auxiliary layers must never leak from Earth onto moons
+ bindEarthEnhancement({selected:false,clouds:null,phase:0,scale:1},P.sphereU);
gl.uniform1f(P.sphereU.u_oblate, 1);
gl.uniform2fv(P.sphereU.u_ringRad, new Float32Array([0, 0])); // no ring shadow on moons — clear the parent's state
// Nor a moon shadow ON a moon: mutual Galilean events are real but they need each moon's
@@ -2676,6 +2733,10 @@ function tick(now) {
finishPositionUpdate();
stepParticles(dt);
updateRotationDisplay(dt);
+ const cloudAsset=appearanceReference('Earth','cloud-composite');
+ const drift=enhancedEarthSelected(state)&&state.earthWeather&&!prefersReducedMotion()
+ &&referenceVisible.get('Earth')>=2&&referenceTextures[cloudAsset.id]?.ready;
+ state.earthCloudPhase=advanceEarthCloudPhase(state.earthCloudPhase,dt,state.simStepSeconds,BODY.Earth.rotationHours,drift);
}
}
}
@@ -3347,8 +3408,12 @@ async function showFallback(msg) {
if(solarDetail?.status('reference')==='unavailable')solarDetail.retry('reference');
syncSolarPlaybackControls();paint();
});
- for (const [id, key] of [['orreryEarthNight', 'earthNight'], ['orreryEarthWeather', 'earthWeather'], ['orreryEarthIce', 'earthIce']]) {
- bind(id, 'change', e => { state[key] = inputTarget(e).checked; updateEarthLayerStatus(); paint(); updateOrreryAccuracy(); });
+ for (const [id, key] of [['orreryEarthEnhanced', 'earthEnhanced'], ['orreryEarthNight', 'earthNight'], ['orreryEarthWeather', 'earthWeather'], ['orreryEarthIce', 'earthIce']]) {
+ bind(id, 'change', e => {
+ state[key] = inputTarget(e).checked;
+ if(key==='earthEnhanced'&&state[key]&&state.earthMaskStatus==='unavailable')state.earthMaskStatus='deferred';
+ updateEarthLayerStatus(); paint(); updateOrreryAccuracy();
+ });
}
bind('orreryVenusRadar', 'change', e => { state.venusRadar = inputTarget(e).checked; updatePhysicalAppearance(); paint(); updateOrreryAccuracy(); });
bind('orreryEarthCloudSource', 'change', e => {
@@ -3427,6 +3492,7 @@ async function showFallback(msg) {
scatteringTargets?.dispose();scatteringTargets=null;scatteringFrame=null;state.scatteringFrame=null;state.scatteringStatus={};
hdrPresentation?.dispose();hdrPresentation=null;linearFrame=false;state.hdrFrame=null;
state.hdrStatus={state:'deferred',reason:'Graphics context lost.'};
+ if(earthOceanMask)gl.deleteTexture(earthOceanMask);earthOceanMask=null;state.earthMaskStatus='deferred';
gl = null; P = {};
textures = {}; sunTex = { ready: false, tex: null }; ringTex = { ready: false, tex: null };
cancelPendingReferenceTextures();
diff --git a/apps/web/js/orreryShaders.js b/apps/web/js/orreryShaders.js
index f42c1cd7..d66ded19 100644
--- a/apps/web/js/orreryShaders.js
+++ b/apps/web/js/orreryShaders.js
@@ -8,6 +8,8 @@ import { INCIDENT_FIELD_GLSL } from './atmosphereIncident.js';
import { TERRAIN_SHADOW_GLSL } from './terrainShadowShaders.js';
import { DISPLAY_COMPOSITION_GLSL } from './materialColor.js';
import { RING_TRANSPORT_GLSL } from './ringTransportShaders.js';
+import {EARTH_CLOUD_GEOMETRY_GLSL,EARTH_OCEAN_GLSL} from './enhancedEarth.js';
+export {EARTH_CLOUD_GEOMETRY_GLSL,EARTH_OCEAN_GLSL};
const NOISE = `
float h31(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); }
@@ -75,6 +77,9 @@ uniform vec4 u_map; uniform vec4 u_mapLat; uniform vec4 u_mapWindow; uniform int
uniform int u_earthNight; uniform int u_earthWeather; uniform int u_earthIce;
uniform sampler2D u_nightTex; uniform sampler2D u_weatherTex; uniform sampler2D u_iceTex;
uniform int u_textureLinear;
+uniform int u_earthEnhanced,u_earthCloudShadow;
+uniform float u_cloudPhase,u_cloudScale;
+uniform sampler2D u_oceanMask;
uniform vec3 u_hazeRayleighTau; uniform vec3 u_hazeAerosol;
// Illustrative atmospheric haze for the distant, non-physical path. See
// atmosphereOptics.js for the profile columns and the cited closed forms.
@@ -170,6 +175,8 @@ ${ATMOSPHERE_GLSL}
${TERRAIN_SHADOW_GLSL}
${RING_TRANSPORT_GLSL}
${NOISE}
+${EARTH_CLOUD_GEOMETRY_GLSL}
+${EARTH_OCEAN_GLSL}
// Every registered layer of a body shares one source grid. main computes this
// once per fragment (u, v, source latitude) and passes it to each lookup, so the
// same atan pair is not re-evaluated for every layer and coverage test.
@@ -201,6 +208,11 @@ float referenceCoverage(vec3 grid,vec4 sampleColor){
if(u_mapNoData==1&&max(sampleColor.r,max(sampleColor.g,sampleColor.b))<0.00392157) return 0.0;
return u_mapNoData==2 ? sampleColor.a : 1.0;
}
+vec4 earthCloudSample(vec3 p){
+ vec2 uv=referenceGrid(p).xy;
+ uv.x=fract(uv.x-u_cloudPhase);
+ return referenceSample(u_weatherTex,p,uv);
+}
float spotGroup(vec3 p,vec4 s){
if(s.w<=0.0) return 0.0;
float ang=acos(clamp(dot(normalize(p),normalize(s.xyz)),-1.0,1.0));
@@ -247,6 +259,17 @@ void main(){
// bright full-circumference ring that made every planet look like an annular eclipse.
float day=smoothstep(-0.32,0.22,dot(N,normalize(u_light)));
o=vec4(displayOutput(u_atmo*pow(1.0-clamp(dot(N,V),0.0,1.0),2.2)*u_atmoStr*1.4*(0.04+0.96*day)), 1.0); return; }
+ if(u_mode==3){
+ // A separate, raised transparent shell. This branch is removed from the
+ // physical ground specialization; cloud haze remains explicitly illustrative.
+ vec4 clouds=earthCloudSample(p);
+ if(clouds.a<=0.001) discard;
+ float lambert=max(dot(N,normalize(u_light)),0.0);
+ vec3 color=decodeSRGB(coveredRGB(clouds))*(.001+.999*lambert);
+ vec3 path=hazeOverSurface(color,N,V,normalize(u_light),1.0);
+ color+=path;
+ o=vec4(u_linearOutput==1?color:encodeSRGB(color),clouds.a);return;
+ }
if(u_mode==1){
if(u_style<0){
float limb=pow(clamp(dot(N,V),0.0,1.0),0.45);
@@ -314,6 +337,7 @@ void main(){
vec4 mapped=referenceSample(u_tex,p,referenceGridValue.xy);
vec3 sourceRGB=u_mapNoData==2 ? coveredRGB(mapped) : mapped.rgb;
col=mix(decodeSRGB(u_base),u_textureLinear==1 ? sourceRGB : decodeSRGB(sourceRGB),referenceCoverage(referenceGridValue,mapped));
+ if(u_earthEnhanced==1) col=enhancedOceanColor(col,referenceSample(u_oceanMask,p,referenceGridValue.xy).r);
// Earth auxiliaries share the documented WGS84 pixel-area grid, 180W..180E.
// Weather is the provider's dated clouds-and-surface image, never inferred clouds.
if(u_earthWeather==1){
@@ -493,6 +517,13 @@ void main(){
}
}
sunVis*=terrainSunVisibility(surfaceBodyKm,incidentBody);
+ float hazeSunVis=sunVis;
+ if(reference&&u_earthCloudShadow==1&&dot(N,normalize(u_light))>0.0){
+ // Same ellipsoid and phase as the visible clouds. Trace straight geometric
+ // sunlight, not a fixed UV offset; alpha is a display opacity, not optical depth.
+ vec3 hit=earthCloudHit(p,normalize(u_lightObj),u_oblate,u_cloudScale);
+ sunVis*=1.0-.35*earthCloudSample(normalize(hit)).a;
+ }
// The shadow removes DIRECT sunlight only. The 0.05 floor is the light a planet's own
// atmosphere scatters into it, which is why Io's shadow reads as very dark grey rather than
// as a hole in the planet.
@@ -543,7 +574,7 @@ void main(){
// shared encode below handles it; a procedural display recipe is decoded and
// re-encoded so its own downstream handling stays exactly as it was.
vec3 surface=reference ? col : decodeSRGB(col);
- vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),sunVis);
+ vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis);
col=reference ? surface+path : encodeSRGB(surface+path);
displayLimb=vec3(0);
}
@@ -618,7 +649,7 @@ export function physicalEnabledSource(source) {
' // Direct reflected sunlight sees the incident atmospheric column. The\n // single-scattering mode has no invented diffuse-ambient weather term.\n col*=lambert*sunVis*(refracted ? v_incidentTransmission : atmosphereSunTransmission(surfaceBodyKm))\n *u_atmosphereSolarScale*u_atmosphereExposure;'],
[' if(u_atmosphereEnabled==1) col=atmosphereSurfaceColor(',' col=atmosphereSurfaceColor('],
[' if(u_atmosphereEnabled==0)return vec3(0);\n',''],
- [' if(u_atmosphereEnabled==0&&dot(u_hazeRayleighTau,vec3(1))>0.0){\n // Path radiance is linear light. A registered map is still linear here and the\n // shared encode below handles it; a procedural display recipe is decoded and\n // re-encoded so its own downstream handling stays exactly as it was.\n vec3 surface=reference ? col : decodeSRGB(col);\n vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),sunVis);\n col=reference ? surface+path : encodeSRGB(surface+path);\n displayLimb=vec3(0);\n }\n',''],
+ [' if(u_atmosphereEnabled==0&&dot(u_hazeRayleighTau,vec3(1))>0.0){\n // Path radiance is linear light. A registered map is still linear here and the\n // shared encode below handles it; a procedural display recipe is decoded and\n // re-encoded so its own downstream handling stays exactly as it was.\n vec3 surface=reference ? col : decodeSRGB(col);\n vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis);\n col=reference ? surface+path : encodeSRGB(surface+path);\n displayLimb=vec3(0);\n }\n',''],
];
for(const [from,to] of rewrites){
if(source.split(from).length!==2)throw new Error('Physical enabled-flag boundary changed');
diff --git a/apps/web/js/planetAppearance.js b/apps/web/js/planetAppearance.js
index c80022d7..bd26f462 100644
--- a/apps/web/js/planetAppearance.js
+++ b/apps/web/js/planetAppearance.js
@@ -73,7 +73,7 @@ export function appearanceSummary(body, state = {}) {
export function earthLayerDescription(state = {}, compact = false) {
const roles = /** @type {[string, boolean][]} */ ([['night-lights', state.earthNight !== false], [earthCloudRole(state), state.earthWeather !== false], ['sea-ice', state.earthIce === true]]);
- return roles.filter(([, enabled]) => enabled).map(([role]) => {
+ const layers=roles.filter(([, enabled]) => enabled).map(([role]) => {
const asset = appearanceReference('Earth', role);
if (!asset) return `${role}: unavailable`;
const status = state.appearanceStatus?.[asset.id];
@@ -82,4 +82,14 @@ export function earthLayerDescription(state = {}, compact = false) {
return `${label} · ${asset.observation_label}${status === 'ready' ? '' : status === 'unavailable' ? ' · unavailable'
: status === 'deferred' || !status ? ' · loads when Earth is in view at a useful scale' : status === 'queued' ? ' · queued' : ' · loading'}${limits}`;
}).join(' · ');
+ return [layers,enhancedEarthDescription(state)].filter(Boolean).join(' · ');
+}
+
+export function enhancedEarthDescription(state){
+ if(!state.earthEnhanced)return '';
+ if(state.earthCloudSource==='daily')return 'Enhanced Earth suspended for MODIS swaths containing the ground.';
+ if(state.earthIce===true)return 'Enhanced Earth suspended to preserve the scientific sea-ice palette.';
+ if(state.useTextures===false)return 'Enhanced Earth suspended while source textures are off.';
+ const ocean=state.earthMaskStatus==='unavailable'?'Ocean grading unavailable. ':'Darker offshore oceans. ';
+ return `Enhanced Earth · illustrative: ${ocean}Cloud height (nominal 8 km), shadows and drift are display choices, not measured weather. Cloud lighting uses approximate haze. Drift follows Play time; reduced motion holds it.`;
}
diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
index 7ebf3355..9fdb4e97 100644
--- a/docs/IMPLEMENTATION_PLAN.md
+++ b/docs/IMPLEMENTATION_PLAN.md
@@ -1,7 +1,7 @@
# Implementation plan
Status: active
-Updated: 2026-09-11
+Updated: 2026-09-23
This plan describes delivery work, while `STATUS.md` describes implemented product behavior
and `ROADMAP.md` records longer-horizon product direction.
@@ -47,6 +47,17 @@ Exit criteria:
## Next product increments
+### Enhanced Earth review candidate
+
+The user approved the bounded optional mode in [RFC 0008](rfcs/0008-enhanced-earth.md).
+Implementation includes guarded ocean grading, a nominal 8 km cloud shell,
+Sun-directed shadows, independent rotation-gated drift, lifecycle recovery and
+release-fingerprint coverage for the new rendering modules. The default remains off.
+See [validation evidence](validation/enhanced-earth/README.md) for the comparison and
+exact scope. Next gates are draft review, CI Rust/WASM/browser and coverage checks,
+then native/mobile appearance and performance. Local source completion does not
+authorize merge or production promotion.
+
### Operational evidence
- Calibrate physical magnetic units and publish the method.
diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md
index 5e04306e..9886b696 100644
--- a/docs/REQUIREMENTS.md
+++ b/docs/REQUIREMENTS.md
@@ -31,6 +31,7 @@ unrecognized status, and incomplete workflow enforcement.
| `SOL-VIS-005` | Numeric terrain datum, finite geometry, physical extents and source identity |
| `SOL-VIS-006` | Dimensioned optical transfer with distinct reflection, scattering and emission |
| `SOL-VIS-007` | Source band, frame, coverage and finite dynamic appearance playback |
+| `SOL-VIS-009` | Opt-in Enhanced Earth with disclosed ocean grading, raised clouds, aligned shadows and gated drift |
| `SOL-CI-001` | SHA-pinned actions, least privilege, and deployment of the tested SHA |
| `SOL-DOC-001` | Documentation, plans, instructions, and traceability updated with code |
| `SOL-SUPPLY-001` | Locked dependencies and automated ecosystem update review |
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 630bd24a..883c1675 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -32,6 +32,19 @@ reuse the existing view transform without moving physical bodies.
Scientific and source claims remain anchored to public methods and data: NOAA/SWPC products, Helioviewer quicklook imagery and metadata, IERS Earth-orientation data, JPL Horizons/DE441 validation, published analytic ephemerides, and NASA/IAU constants. Sol does not claim proprietary JPL, NOAA, or commercial forecasting algorithms.
+[RFC 0008](rfcs/0008-enhanced-earth.md) adds an optional Enhanced Earth presentation,
+off by default. It retains NASA maps and their epochs, grades dark offshore ocean
+pixels, raises the historical cloud layer by a nominal 8 km, and samples that same
+shell/phase for Sun-directed display shadows. Independent drift advances only with
+visible Earth during Play time; pause, hidden/inactive views and reduced motion
+hold its phase. MODIS ground-containing swaths and the scientific sea-ice palette
+suspend enhancement. Cloud height, drift, opacity-based shadows and cloud haze
+are illustrative; they are not measured weather or volumetric optical transfer.
+The ground keeps its existing reference atmosphere. Source-qualified defaults,
+other bodies, engine snapshots and observation dates remain unchanged. See the
+[validation record](validation/enhanced-earth/README.md) for local evidence and
+outstanding browser/device qualification.
+
Planetary appearance follows [RFC 0004](rfcs/0004-registered-planetary-appearance.md).
Registered, dated mission display maps are separate from calibrated or complete
observations. Earth layers distinguish land/ice, annual night lights, dated satellite
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 8f92ffb0..c442d083 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,6 +1,6 @@
# Sol status
-Updated: 2026-09-13. Scope: inspected local implementation, not production activation.
+Updated: 2026-09-23. Scope: inspected local implementation, not production activation.
## Implementation versus qualification
@@ -37,6 +37,14 @@ while a scoped, tested implementation can be submitted for code review.
## User-visible behavior implemented locally
+The optional Enhanced Earth candidate (SOL-VIS-009 / Accepted RFC 0008) preserves
+the default view and adds darker ocean colors, raised historical clouds, shared-phase
+Sun-directed ground shadows and drift during Play time. Scientific sea-ice and MODIS
+views suspend it. [Candidate validation](validation/enhanced-earth/README.md) records
+renderer lifecycle checks, deterministic mask generation and an actual offscreen
+production-shader comparison. Native browser/mobile and release qualification remain
+open; this candidate does not change the deployed site.
+
Sun, My Sky and Solar System expose selected non-canvas facts, native object controls,
visible mode/source/epoch/limitations and bounded lists. The timeline distinguishes
idealized cycle frames from observed data and treats missing frames as gaps.
diff --git a/docs/requirements.json b/docs/requirements.json
index da7e9cda..4fd1eb6b 100644
--- a/docs/requirements.json
+++ b/docs/requirements.json
@@ -496,6 +496,29 @@
"ci_gates": [
"CI / Web, provider, and browser validation"
]
+ },
+ {
+ "id": "SOL-VIS-009",
+ "status": "implemented",
+ "statement": "Enhanced Earth MUST be opt-in, preserve original NASA sources and engine state, keep cloud geometry and Sun-directed shadows phase-aligned, freeze drift outside visible animation, and suspend visual edits for ground-containing swaths or scientific sea-ice palettes. Height, motion, ocean grading and cloud lighting MUST remain disclosed as illustrative.",
+ "source": "docs/rfcs/0008-enhanced-earth.md#requirements",
+ "implementation": [
+ "apps/web/js/enhancedEarth.js",
+ "apps/web/js/earthOceanMask.js",
+ "apps/web/js/orrery.js",
+ "apps/web/js/orreryShaders.js",
+ "apps/web/js/planetAppearance.js",
+ "apps/web/index.html",
+ "tools/prepare_earth_ocean_mask.py"
+ ],
+ "verification": [
+ "tests/web/enhancedEarth.test.mjs",
+ "tests/python/test_earth_ocean_mask.py",
+ "tools/render_enhanced_earth_probe.py"
+ ],
+ "ci_gates": [
+ "CI / Web, provider, and browser validation"
+ ]
}
]
}
diff --git a/docs/rfcs/0008-enhanced-earth.md b/docs/rfcs/0008-enhanced-earth.md
new file mode 100644
index 00000000..e82d9bf0
--- /dev/null
+++ b/docs/rfcs/0008-enhanced-earth.md
@@ -0,0 +1,107 @@
+# RFC 0008: Optional Enhanced Earth presentation
+
+- Status: Accepted
+- Authors: Codex for Protonmatter
+- Created: 2026-09-23
+- Target: Enhanced Earth review candidate
+- Requirements: `SOL-VIS-009`
+
+## Summary
+
+Add an opt-in Enhanced Earth checkbox: deeper oceans, a raised historical cloud
+layer, Sun-directed offset shadows and independent drift during Play time.
+The owner approved this design in conversation on 2026-09-23. Acceptance authorizes
+implementation; it does not qualify native graphics, merge or deployment.
+
+## Context
+
+Earth already uses NASA surface, cloud, night-light and sea-ice maps. Clouds are
+currently composited on the ground. This change retains those immutable sources
+and their dates. It does not import the lab's artistic Earth or simulate weather.
+
+## Requirements
+
+`SOL-VIS-009`: Enhanced Earth MUST be opt-in and disclosed as illustrative. It
+MUST preserve source-qualified defaults, engine state and non-Earth materials.
+Cloud geometry, shadows and drift MUST share a single map phase. Pausing time,
+hidden/inactive views and reduced motion MUST stop drift. Camera redraws MUST NOT
+advance phase. MODIS clouds-and-ground swaths MUST NOT be lifted or drifted.
+Unavailable cloud imagery MUST NOT create opaque fallback clouds or shadows.
+
+## Design
+
+Use the existing surface and cloud resources. Grade only dark blue pixels inside
+a conservative offshore mask derived offline from the committed Natural Earth
+land/ice polygons. Protect bright ice; suspend enhancement for the sea-ice palette.
+The mask is display support, not a qualified coastline or ocean reflectance map.
+
+Raise the cloud ellipsoid homothetically by a nominal 8 km at the equator
+(slightly less at the poles). Reuse the sphere mesh and base program in a
+transparent cloud pass, back-face culled, depth-tested and without depth writes.
+The base program's existing haze approximation lights the cloud shell; this is
+not dense-cloud or volumetric optical transfer. The ground retains its existing
+reference atmosphere. Cloud shadows trace straight sunlight rays from the oblate
+ground to this same shell and sample the same phase, attenuating direct sunlight
+only. No fixed screen-space or longitude offset is used.
+
+Drift adds 3% of the visible Earth rotation, capped at 0.002 turns per real second,
+using a bounded phase advanced once per successful animation tick. The clock is
+illustrative and never alters observation dates, positions or Earth orientation.
+Daily MODIS or scientific sea-ice palette selection suspends the enhancement; toggling back resumes its phase.
+
+## UX and accessibility
+
+Earth layers contains a native unchecked Enhanced Earth checkbox, a concise help
+paragraph and status describing active, suspended, loading or unavailable layers.
+It is independent of the other planets' appearance mode. Keyboard/touch behavior
+uses existing controls; reduced motion suppresses independent cloud drift.
+
+## Security and privacy
+
+No new runtime dependencies, external requests, user data or schemas. Existing
+bounded/cancellable reference loaders own NASA imagery. One 512x256 R8 mask is
+uploaded lazily, retained per context and disposed on context loss. Upload failure
+leaves oceans ungraded with visible status. The generated mask pins input hashes.
+
+## Alternatives
+
+A fixed UV shadow offset contradicts a moving Sun. Full volumetric clouds need
+different data and performance qualification. Replacing scientific defaults would
+hide display edits; a separate toggle permits direct comparison and rollback.
+
+## Risks
+
+Cloud height, opacity-to-shadow strength and drift are artistic choices, not
+retrieved meteorology. Coarse vectors omit small islands; blue/brightness guards
+and an offshore margin restrict grading, but the mask is not survey accuracy.
+Cloud scattering uses illustrative haze; it is not the ground's qualified optical
+transfer. Physical-height depth is subtle at globe scale. Native GPU/mobile visual
+and performance qualification remain required; CPU draw tests cannot establish it.
+
+## Acceptance criteria
+
+1. Default/off, non-Earth, no textures and MODIS paths retain their original draws.
+2. Ready composite clouds appear once on the shell; missing/off clouds cast no shadow.
+3. Executed shader geometry hits the correct raised oblate shell toward the Sun;
+ tangent/polar/night cases are finite, and opposite light directions reverse offsets.
+4. Draw tests prove shared cloud/shadow phase, elevation, depth/culling/blending,
+ no night-light leakage, and pause/camera/context continuity.
+5. Ocean math preserves masked land, neutral/bright ice and non-blue pixels;
+ deterministic mask regeneration and known land/ocean/polar samples pass.
+
+## Validation
+
+Run focused shader/helper and full-renderer lifecycle tests, all Node tests,
+mask regeneration, Python tests, type/static/doc/SDLC/UX gates and the web build.
+Required CI retains native WebGL2, browser visual, coverage, WASM and release gates.
+
+## Rollout and rollback
+
+Submit a separate draft PR based on the existing default branch. Default is off.
+Merge/deploy only after normal review and required gates. Uncheck Enhanced Earth
+for immediate comparison/rollback; revert the feature commit for full removal.
+
+## Documentation
+
+Update SPEC, requirements catalogue/traceability, README, status and validation
+evidence with the actual local results and remaining qualification limits.
diff --git a/docs/validation/enhanced-earth/README.md b/docs/validation/enhanced-earth/README.md
new file mode 100644
index 00000000..7d59c11f
--- /dev/null
+++ b/docs/validation/enhanced-earth/README.md
@@ -0,0 +1,92 @@
+# Enhanced Earth validation record
+
+Date: 2026-09-23. Scope: local review candidate for RFC 0008 / SOL-VIS-009.
+This record does not claim merge, deployment or native-device qualification.
+
+## Behavior
+
+Earth layers → Enhanced Earth is off by default. It reuses NASA surface/cloud
+maps, protects coarse land/ice with a conservative offshore mask, and darkens
+only dark blue water colors in linear light. A homothetic cloud shell is nominally
+8 km above the equator. Ground shadows intersect that shell toward the Sun and
+use the same drifting UV phase. The cloud pass uses illustrative haze, while the
+ground retains reference optical transfer. No source image, observation epoch,
+ephemeris or other body's appearance is modified.
+
+Cloud drift follows 3% of displayed Earth rotation, capped at 0.002 turns per real
+second. Pause, hidden/inactive scenes and reduced motion freeze the phase; camera
+movement never advances it. MODIS ground-containing swaths and the scientific
+sea-ice palette suspend enhancement. Missing/off clouds have no shell or shadow.
+Failed mask upload leaves the cloud view usable and reports ocean grading as
+unavailable; switching Enhanced Earth off/on retries once.
+
+## Automated evidence
+
+- `tests/web/enhancedEarth.test.mjs` executes production renderer lifecycle and
+ shader geometry/color helpers. Cases cover defaults, scientific-state identity,
+ shell elevation, shared phase, transparent draw state, non-Earth reset, missing
+ maps, swath/sea-ice suspension, drift and rate caps, pause/reduced motion, active
+ hidden/inactive suspension, context loss and explicit upload retry.
+- `tests/python/test_earth_ocean_mask.py` verifies exact offline regeneration and
+ rejects changed source data. The generator is standard-library-only; its
+ Natural Earth source hashes and the decoded pixel hash are committed.
+- `tools/render_enhanced_earth_probe.py` compiles and links the actual base and
+ physical shaders on Linux EGL/Mesa. It renders a controlled base-haze comparison
+ with original NASA maps. Metamorphic checks require visible changes when
+ enhancement, drift or shadows change. A black-ground test verifies that cloud
+ shadows do not attenuate atmospheric path radiance.
+
+The probe initially exposed the existing GLSL local name `packed`, rejected as
+a reserved token by Mesa. Renaming that local to `columnValues` changes no
+formula. `node tools/prepare_atmosphere_columns.mjs` regenerated the pinned
+manifest from the actual source; both 2 MiB numerical field files reproduced
+byte-for-byte. No source-identity check was bypassed or weakened.
+
+Final candidate checks on 2026-09-23:
+
+| Command | Observed result |
+| --- | --- |
+| `npm test` | 1,323 tests passed; no failures or skips. |
+| `PYTHONPATH=tools python -m unittest discover -s tests/python -p 'test_*.py' -v` | 390 tests ran; 5 errors from missing `cargo`/`rustc`, 3 skips, no assertion failures. |
+| `python tools/typecheck_web.py` | 114 files clean. |
+| `python tools/validate_web_static.py` | Passed. |
+| `python tools/validate_sdlc.py` | 24 requirements and workflow/traceability checks passed. |
+| `python tools/validate_docs.py` | 140 Markdown files passed. |
+| `python tools/validate_ux_contract.py` | Structure checks passed. |
+| `python tools/prepare_earth_ocean_mask.py --check` | Exact reproduction passed. |
+| `python tools/render_enhanced_earth_probe.py` | Both production programs compiled/linked; image, drift, shadow and haze-isolation assertions passed. |
+| `git diff --check` | Passed. |
+
+The five Python errors are three native snapshot subcases, the native hybrid-time
+case, and local Rust toolchain evidence capture. An earlier broad rerun ended
+without a summary; the complete confirmation run above finished in 46.666 seconds.
+Release-artifact and ocean-mask cases passed in that full run. The new module
+classification regression was also observed failing before the classifier update
+and passing afterward. The release builder requires source/run metadata and valid
+WASM inputs; no qualified release build was produced without the Rust/WASM toolchain.
+Independent review found two minor issues (haze shadowing and an insufficiently
+active suspension test); both were fixed and confirmed in focused follow-up review.
+
+## Reproduction
+
+```sh
+python tools/prepare_earth_ocean_mask.py --check
+node --experimental-vm-modules --test tests/web/enhancedEarth.test.mjs
+PYTHONPATH=tools python -m unittest tests.python.test_earth_ocean_mask -v
+python tools/render_enhanced_earth_probe.py
+```
+
+The optional EGL probe requires Linux libEGL, Node, NumPy, Pillow and DejaVu Sans.
+It is a diagnostic, not the repository's browser/native graphics gate. The
+[comparison](comparison.jpg) holds maps, camera and Sun direction constant.
+[Probe metadata](gl-probe.json) records renderer, shader/source hashes and changed
+pixel counts. Those counts prove an effect, not its perceptual quality or accuracy.
+
+## Remaining qualification
+
+Native WebGL2/ANGLE, Safari/iOS/mobile appearance, physical-path visual quality,
+whole-web/Rust coverage and frame-time measurements require the normal CI/device
+gates. CPU lifecycle tests and Mesa images do not substitute for those gates.
+The cloud layer has no measured heights, evolving meteorology, multiple
+scattering or volumetric structure. The offshore mask is deliberately coarse and
+omits small islands; it is not qualified for glint, coastlines or ocean analysis.
diff --git a/docs/validation/enhanced-earth/comparison.jpg b/docs/validation/enhanced-earth/comparison.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..28d0a77dcad71d28b5b1a3b67a88be6b3d889618
GIT binary patch
literal 238889
zcmeFZWmKF`w