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..d1252f4b
--- /dev/null
+++ b/apps/web/js/enhancedEarth.js
@@ -0,0 +1,77 @@
+// 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;
+}
+`;
+
+// 0 on the night side, and 1 only after the cloud is actually lit. The 0.05
+// start keeps a dark twilight veil from covering city lights while cloud RGB
+// is still the 0.001 night floor.
+export const EARTH_CLOUD_COVER_GLSL=`
+float earthCloudCover(float sun){
+ return smoothstep(0.05, 0.22, max(sun, 0.0));
+}
+`;
+
+// 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..02b644bb 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,shell) {
+ 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,shell?null: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,9 @@ function drawBody(b, vp, eye) {
gl.drawElements(gl.TRIANGLES,mesh.count,mesh.indexType||gl.UNSIGNED_SHORT,0);
gl.disable(gl.CULL_FACE);
+ const atmosphereShell=!!profile;
+ if(earthEnhancement.clouds)queueTransparent(pos,eye,()=>drawEarthClouds(earthEnhancement,pos,rot,rEq,rPol,vp,eye,light,normals,reference,atmosphereShell));
+
// 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 +2348,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 +2734,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 +3409,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 +3493,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..77056a48 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_CLOUD_COVER_GLSL,EARTH_OCEAN_GLSL} from './enhancedEarth.js';
+export {EARTH_CLOUD_GEOMETRY_GLSL,EARTH_CLOUD_COVER_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,9 @@ ${ATMOSPHERE_GLSL}
${TERRAIN_SHADOW_GLSL}
${RING_TRANSPORT_GLSL}
${NOISE}
+${EARTH_CLOUD_GEOMETRY_GLSL}
+${EARTH_CLOUD_COVER_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 +209,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 +260,18 @@ 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);
+ float alpha=clouds.a*earthCloudCover(dot(N,normalize(u_light)));
+ if(alpha<=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),alpha);return;
+ }
if(u_mode==1){
if(u_style<0){
float limb=pow(clamp(dot(N,V),0.0,1.0),0.45);
@@ -314,6 +339,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 +519,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 +576,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 +651,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..3089c579
--- /dev/null
+++ b/docs/rfcs/0008-enhanced-earth.md
@@ -0,0 +1,109 @@
+# 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. Night-side coverage falls off so
+Black Marble remains on the ground pass, and a close view with the physical
+atmosphere shell does not also add illustrative cloud haze. 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..1ccef71c
--- /dev/null
+++ b/docs/validation/enhanced-earth/README.md
@@ -0,0 +1,94 @@
+# 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. Night-side coverage falls off so
+Black Marble remains on the ground pass, and a close view with the physical
+atmosphere shell does not also add illustrative cloud haze. 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 00000000..28d0a77d
Binary files /dev/null and b/docs/validation/enhanced-earth/comparison.jpg differ
diff --git a/docs/validation/enhanced-earth/gl-probe.json b/docs/validation/enhanced-earth/gl-probe.json
new file mode 100644
index 00000000..0b03cb56
--- /dev/null
+++ b/docs/validation/enhanced-earth/gl-probe.json
@@ -0,0 +1,29 @@
+{
+ "renderer": "llvmpipe (LLVM 20.1.2, 256 bits)",
+ "version": "OpenGL ES 3.2 Mesa 25.2.8-0ubuntu0.24.04.2",
+ "shader_sha256": {
+ "base": [
+ "3c07618144d40f22ddc7172915f581e8d04e1467125c75010e57ac53670a2b8c",
+ "b84bb0171c0951170b6bbeb8e333bdab4a476c0121bb1cfe76aed821a3804703"
+ ],
+ "physical": [
+ "c1a6e6515c9c8d28d159e2fd08036edae0af72ddb69f57923a431a9cee8f2733",
+ "97b25bca0dfb9e0b858e390172da0f1a7004923e51205f691c254cf1fea9b767"
+ ]
+ },
+ "programs_compiled": [
+ "base",
+ "physical"
+ ],
+ "width": 768,
+ "height": 768,
+ "changed_pixels": 254959,
+ "drift_changed_pixels": 270921,
+ "shadow_changed_pixels": 63090,
+ "scope": "Controlled offscreen OpenGL ES software rendering of production shaders with original NASA maps; base haze path, no browser/native-device qualification.",
+ "source_sha256": {
+ "apps/web/textures/reference/earth-land-2004.jpg": "99f5faad74efe985fbf1714c8be7296ca9999759a1215b65f99b7f1df278dde5",
+ "apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png": "82005bba2cec05b41137b766985d516eaf6b66ea045191d8869d45abd05f7995",
+ "apps/web/js/earthOceanMask.js": "53fcc6ba7460e0ee701893f05b924c8fa4002fe74c31a8a6647f00a66a687e7a"
+ }
+}
diff --git a/tests/python/test_earth_ocean_mask.py b/tests/python/test_earth_ocean_mask.py
new file mode 100644
index 00000000..baf7652c
--- /dev/null
+++ b/tests/python/test_earth_ocean_mask.py
@@ -0,0 +1,24 @@
+"""The committed display mask must reproduce from pinned geography without network."""
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import prepare_earth_ocean_mask as mask
+
+
+class EarthOceanMaskTests(unittest.TestCase):
+ def test_committed_mask_reproduces(self):
+ self.assertEqual(mask.generate(), mask.OUTPUT.read_text())
+
+ def test_changed_source_is_rejected_before_rasterization(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ source = Path(tmp)
+ (source / 'ne_110m_land.geojson').write_text('{"features": []}')
+ with patch.object(mask, 'SOURCE', source):
+ with self.assertRaisesRegex(ValueError, 'Source hash mismatch'):
+ mask.mask_pixels()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/python/test_release_artifact.py b/tests/python/test_release_artifact.py
index 5fbda153..28d54179 100644
--- a/tests/python/test_release_artifact.py
+++ b/tests/python/test_release_artifact.py
@@ -69,6 +69,7 @@ def test_rendering_dependency_changes_invalidate_science_fingerprint(self):
"surfaceReflection.js", "surfaceReflectionShaders.js",
"terrainResources.js", "shaderPrograms.js", "referenceDemand.js",
"hdrPresentation.js", "hdrPresentationShaders.js",
+ "enhancedEarth.js", "earthOceanMask.js",
)
actual_web = Path(__file__).resolve().parents[2] / "apps/web/js"
folder = self.source / "js"
diff --git a/tests/web/enhancedEarth.test.mjs b/tests/web/enhancedEarth.test.mjs
new file mode 100644
index 00000000..c32d0e8c
--- /dev/null
+++ b/tests/web/enhancedEarth.test.mjs
@@ -0,0 +1,177 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import * as shaders from '../../apps/web/js/orreryShaders.js';
+import {geometryInterpreter} from './fixtures/glslFloat32Geometry.mjs';
+import {orreryHarness} from './helpers/orreryHarness.mjs';
+import {appearanceReference} from '../../apps/web/js/planetAppearance.js';
+import {advanceEarthCloudPhase,oceanMaskPixels} from '../../apps/web/js/enhancedEarth.js';
+import {OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,OCEAN_MASK_SHA256} from '../../apps/web/js/earthOceanMask.js';
+import {createHash} from 'node:crypto';
+
+const cloudDraws=h=>h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===3);
+async function boot(t,options={}){
+ const h=await orreryHarness(t,{controls:true,...options});
+ await h.enterOrrery();h.setAnimate(false);h.input('orreryAnchor','Earth','change');await h.settle();
+ t.after(()=>h.leaveOrrery());return h;
+}
+function load(h,role){
+ const asset=appearanceReference('Earth',role),img=h.images.find(i=>i.src?.includes(asset.path));
+ assert.ok(img,`${role} requested`);[img.width,img.height]=asset.dimensions;img.onload();
+}
+
+test('enhancement is opt-in and never advances the scientific clock or changes other bodies',async t=>{
+ const h=await boot(t);assert.equal(cloudDraws(h).length,0);
+ const identity=JSON.stringify([h.state.renderUnix,h.state.bodies]);
+ h.check('orreryEarthEnhanced',true);
+ assert.equal(h.state.earthEnhanced,true);
+ assert.equal(JSON.stringify([h.state.renderUnix,h.state.bodies]),identity);
+ assert.match(h.nodes.orreryEarthLayerStatus.textContent,/illustrative/i);
+ h.check('orreryEarthEnhanced',false);assert.equal(h.state.earthEnhanced,false);
+});
+
+test('raised cloud draw shares shadow phase, omits ground clouds and keeps transparent depth state',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');
+ h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',true);
+ const cloud=cloudDraws(h).at(-1);assert.ok(cloud,'ready cloud map gets its own draw');
+ const ground=h.gpuSubmissions.find(d=>d.kind==='elements'&&d.uniforms.u_earthEnhanced===1&&d.uniforms.u_mode===0);
+ assert.ok(ground);assert.equal(ground.uniforms.u_earthWeather,0,'no double cloud layer');
+ assert.equal(ground.uniforms.u_earthCloudShadow,1);
+ assert.equal(cloud.uniforms.u_cloudPhase,ground.uniforms.u_cloudPhase);
+ assert.equal(cloud.uniforms.u_cloudScale,ground.uniforms.u_cloudScale);
+ const a=ground.uniforms.u_model,b=cloud.uniforms.u_model;
+ const length=m=>Math.hypot(m[0],m[1],m[2]);
+ assert.ok(length(b)>length(a));assert.ok(length(b)/length(a)<1.002,'height is small, not a balloon');
+ assert.equal(cloud.depthWrites,false);assert.ok(cloud.enabled.has(h.gl.DEPTH_TEST));
+ assert.ok(cloud.enabled.has(h.gl.CULL_FACE));assert.deepEqual(cloud.blend,[h.gl.SRC_ALPHA,h.gl.ONE_MINUS_SRC_ALPHA]);
+ assert.equal(cloud.uniforms.u_earthNight,0);assert.equal(cloud.uniforms.u_earthIce,0);
+ assert.equal(cloud.textures.get(3),ground.textures.get(3));
+ assert.ok(cloud.uniforms.u_hazeRayleighTau.some(value=>value!==0),'distant clouds keep illustrative haze');
+});
+
+test('cloud off, missing maps and daily ground-containing swaths cannot lift or cast cloud shadows',async t=>{
+ const h=await boot(t);h.check('orreryEarthEnhanced',true);
+ assert.equal(cloudDraws(h).length,0);
+ load(h,'surface');await h.settle();load(h,'cloud-composite');
+ h.gpuSubmissions.length=0;h.check('orreryEarthWeather',false);
+ assert.equal(cloudDraws(h).length,0);
+ assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthCloudShadow===0));
+ h.check('orreryEarthWeather',true);h.gpuSubmissions.length=0;
+ h.input('orreryEarthCloudSource','daily','change');
+ assert.equal(cloudDraws(h).length,0);assert.match(h.nodes.orreryEarthLayerStatus.textContent,/suspended/i);
+ assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthEnhanced===0));
+});
+
+test('drift advances only with visible animated Earth and survives paused camera redraws',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true);
+ const initial=cloudDraws(h).at(-1)?.uniforms.u_cloudPhase;
+ assert.equal(initial,0);h.setAnimate(true);h.frame(100);h.frame(116);
+ const moved=cloudDraws(h).at(-1).uniforms.u_cloudPhase;assert.ok(moved>0&&moved<0.001);
+ h.setAnimate(false);h.resize(810,605);h.event('orreryCanvas','keydown',{key:'ArrowLeft'});
+ assert.equal(cloudDraws(h).at(-1).uniforms.u_cloudPhase,moved);
+ h.setWindowReducedMotion(true);h.setAnimate(true);h.frame(132);
+ assert.equal(cloudDraws(h).at(-1).uniforms.u_cloudPhase,moved);
+});
+
+test('night cloud cover stays clear until the shell is actually lit',()=>{
+ const g=geometryInterpreter(shaders.EARTH_CLOUD_COVER_GLSL||'');
+ const cover=sun=>g.run('earthCloudCover',[sun]).value;
+ assert.equal(cover(-0.2),0);
+ assert.equal(cover(0),0);
+ assert.equal(cover(1),1);
+ assert.equal(cover(0.05),0);
+});
+
+test('executed shadow geometry follows the Sun and hits the elevated ellipsoid at grazing angles',()=>{
+ const g=geometryInterpreter(shaders.EARTH_CLOUD_GEOMETRY_GLSL||'');
+ const hit=(p,l,oblate=1)=>g.run('earthCloudHit',[p,l,oblate,1.01]).value;
+ const near=(a,b)=>assert.ok(Math.abs(a-b)<2e-6,`${a} != ${b}`);
+ const overhead=hit([1,0,0],[1,0,0]);near(overhead[0],1.01);near(overhead[1],0);
+ const east=hit([1,0,0],[0,1,0]),west=hit([1,0,0],[0,-1,0]);
+ near(east[1],Math.sqrt(.0201));near(east[1],-west[1]);near(Math.hypot(...east),1.01);
+ const polar=hit([0,0,1],[1,0,0],.997);near(Math.hypot(...polar),1.01);
+});
+
+test('executed ocean grading preserves masked land, bright ice and neutral colors',()=>{
+ const g=geometryInterpreter(shaders.EARTH_OCEAN_GLSL||'');
+ const grade=(c,m)=>g.run('enhancedOceanColor',[c,m]).value;
+ for(const [c,m] of [[[.01,.04,.12],0],[[.8,.9,1],1],[[.1,.1,.1],1],[[.3,.2,.1],1]])
+ assert.deepEqual(grade(c,m),c.map(Math.fround));
+ const c=grade([.01,.04,.12],1),want=[.00873378,.02193378,.05713378];
+ c.forEach((v,i)=>assert.ok(Math.abs(v-want[i])<1e-7));
+});
+
+test('phase is continuous through wrapping, frame subdivision, rate limiting and invalid steps',()=>{
+ const step=(phase,dt,sim,enabled=true)=>advanceEarthCloudPhase(phase,dt,sim,24,enabled);
+ assert.ok(Math.abs(step(.999,1,86400)-.001)<1e-12,'capped drift wraps smoothly');
+ assert.ok(Math.abs(step(0,1,3600)-.00125)<1e-12,'drift is relative to displayed rotation');
+ assert.ok(Math.abs(step(step(0,.5,1800),.5,1800)-step(0,1,3600))<1e-12);
+ for(const [dt,sim,enabled] of [[1,3600,false],[0,3600,true],[-1,3600,true],[NaN,3600,true],[1,NaN,true]])
+ assert.equal(step(.3,dt,sim,enabled),.3);
+ assert.ok(Math.abs(step(.001,1,-86400)-.999)<1e-12);
+});
+
+test('mask bytes match their generated hash and preserve land/polar ice while admitting open oceans',()=>{
+ const pixels=oceanMaskPixels();assert.equal(pixels.length,OCEAN_MASK_WIDTH*OCEAN_MASK_HEIGHT);
+ assert.equal(createHash('sha256').update(pixels).digest('hex'),OCEAN_MASK_SHA256);
+ const at=(lon,lat)=>pixels[Math.min(OCEAN_MASK_HEIGHT-1,Math.floor((90-lat)/180*OCEAN_MASK_HEIGHT))*OCEAN_MASK_WIDTH
+ +Math.floor((((lon+180)%360+360)%360)/360*OCEAN_MASK_WIDTH)];
+ for(const [lon,lat] of [[20,10],[-100,40],[100,40],[-45,75],[0,-89],[140,-25]])assert.equal(at(lon,lat),0);
+ for(const [lon,lat] of [[-140,0],[-30,0],[80,-30],[179,0],[-179,0]])assert.equal(at(lon,lat),255);
+});
+
+test('physical ground program gets enhancement uniforms while cloud rendering stays in its own disclosed pass',async t=>{
+ const h=await boot(t,{incidentField:async()=>({values:new Float32Array(4*257*195),width:257,height:195,
+ domain:{minHeightKm:0,maxHeightKm:16,quadratic:true}})});
+ load(h,'surface');await h.settle();load(h,'cloud-composite');
+ h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',true);
+ const ground=h.gpuSubmissions.find(d=>d.uniforms.u_scatteringReady===1&&d.uniforms.u_earthEnhanced===1);
+ assert.ok(ground,'physical atmosphere and enhanced ground coexist');
+ assert.equal(ground.uniforms.u_earthCloudShadow,1);assert.equal(ground.uniforms.u_earthWeather,0);
+ const cloud=cloudDraws(h).at(-1);assert.ok(cloud);assert.equal(cloud.uniforms.u_atmosphereEnabled,0);
+ assert.deepEqual(cloud.uniforms.u_hazeRayleighTau,[0,0,0]);
+ assert.equal(cloud.uniforms.u_cloudPhase,ground.uniforms.u_cloudPhase);
+ assert.deepEqual(h.errors,[]);
+});
+
+test('turning off, hiding, leaving and restoring cannot advance clouds or retain dead mask textures',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true);
+ h.setAnimate(true);h.frame(100);h.setAnimate(false);
+ const phase=h.state.earthCloudPhase;
+ const mask=h.gpuSubmissions.find(d=>d.uniforms.u_earthEnhanced===1).textures.get(11);
+ h.setHidden(true);h.setHidden(false);assert.equal(h.state.earthCloudPhase,phase);
+ h.leaveOrrery();await h.enterOrrery();assert.equal(h.state.earthCloudPhase,phase);
+ h.event('orreryCanvas','webglcontextlost');assert.ok(h.deletedTextures.includes(mask));
+ h.event('orreryCanvas','webglcontextrestored');await h.settle();
+ assert.equal(h.state.earthCloudPhase,phase);assert.equal(h.state.earthEnhanced,true);
+ h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',false);
+ assert.equal(cloudDraws(h).length,0);
+ assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthEnhanced===0));
+});
+
+test('animated suspension cancels frames and resumes without catching up hidden elapsed time',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true);
+ h.setAnimate(true);h.frame(100);const phase=h.state.earthCloudPhase;
+ h.setHidden(true);assert.equal(h.frames.size,0,'hidden animated view cancels its queued tick');
+ h.resize(805,602);assert.equal(h.state.earthCloudPhase,phase);
+ h.setHidden(false);h.frame(100000);
+ assert.ok(h.state.earthCloudPhase>phase&&h.state.earthCloudPhase-phase<.0001,'resume takes a single small step');
+ const resumed=h.state.earthCloudPhase;h.leaveOrrery();assert.equal(h.frames.size,0);
+ h.resize(807,604);assert.equal(h.state.earthCloudPhase,resumed,'inactive redraw cannot move clouds');
+});
+
+test('mask upload failure leaves a usable cloud layer and can be retried explicitly',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');
+ h.setTextureUploadError(pixels=>ArrayBuffer.isView(pixels)&&pixels.byteLength===512*256?h.gl.OUT_OF_MEMORY:0);
+ h.check('orreryEarthEnhanced',true);
+ assert.equal(h.state.earthMaskStatus,'unavailable');assert.ok(cloudDraws(h).length);
+ assert.match(h.nodes.orreryEarthLayerStatus.textContent,/Ocean grading unavailable/);
+ const uploads=h.textureUploads.length;h.resize(810,605);assert.equal(h.textureUploads.length,uploads,'no automatic failure retry loop');
+ h.setTextureUploadError(0);h.check('orreryEarthEnhanced',false);h.check('orreryEarthEnhanced',true);
+ assert.equal(h.state.earthMaskStatus,'ready');
+});
+
+test('scientific sea-ice colors suspend enhancement instead of being dimmed by raised clouds',async t=>{
+ const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true);
+ h.gpuSubmissions.length=0;h.check('orreryEarthIce',true);
+ assert.equal(cloudDraws(h).length,0);assert.match(h.nodes.orreryEarthLayerStatus.textContent,/suspended.*sea.ice/i);
+});
diff --git a/tests/web/fixtures/glslFloat32Geometry.mjs b/tests/web/fixtures/glslFloat32Geometry.mjs
index 66ee0806..2f2b48c1 100644
--- a/tests/web/fixtures/glslFloat32Geometry.mjs
+++ b/tests/web/fixtures/glslFloat32Geometry.mjs
@@ -28,6 +28,10 @@ export function geometryInterpreter(glsl){
const functions=new Map(parsed.statements.filter(ts.isFunctionDeclaration).map(node=>[node.name.text,node]));
const builtins={...Object.fromEntries([...structs].map(([name,fields])=>[name,(...args)=>Object.fromEntries(fields.map((field,i)=>[field,args[i]]))])),vec2:(...a)=>vector(2,a),vec3:(...a)=>vector(3,a),sqrt:x=>f(Math.sqrt(x)),
min:(a,b)=>zip(a,b,Math.min),max:(a,b)=>zip(a,b,Math.max),length:a=>f(Math.sqrt(dot(a,a))),
+ smoothstep:(edge0,edge1,x)=>{
+ const t=Math.min(1,Math.max(0,f(f(f(x)-f(edge0))/f(f(edge1)-f(edge0)))));
+ return f(f(f(t)*f(t))*f(f(3)-f(f(2)*f(t))));
+ },
dot,abs:Math.abs,
normalize:a=>{const n=f(Math.sqrt(dot(a,a)));return a.map(v=>f(v/n));},
floatBitsToUint:x=>{view.setFloat32(0,x,true);return view.getUint32(0,true);},
diff --git a/tests/web/helpers/orreryHarness.mjs b/tests/web/helpers/orreryHarness.mjs
index d48c5083..b864ca87 100644
--- a/tests/web/helpers/orreryHarness.mjs
+++ b/tests/web/helpers/orreryHarness.mjs
@@ -206,6 +206,7 @@ export async function orreryHarness(t, options = {}) {
}
for (const id of ["Textures", "EarthNight", "EarthWeather"]) nodes[`orrery${id}`].checked = true;
nodes.orreryEarthIce.checked = false;
+ nodes.orreryEarthEnhanced = node({ checked: false });
nodes.orreryVenusRadar = node({ checked: false });
nodes.orreryEarthCloudSource = node({ value: "composite" }, "select");
nodes.orrerySpeedUnit.value = "1";
diff --git a/tests/web/illustrativeHaze.test.mjs b/tests/web/illustrativeHaze.test.mjs
index e333c073..fda7f5ad 100644
--- a/tests/web/illustrativeHaze.test.mjs
+++ b/tests/web/illustrativeHaze.test.mjs
@@ -111,7 +111,7 @@ test('aerosol path radiance converges toward the limb instead of diverging', ()
test('only the illustrative program carries the haze, and it replaces the rim rather than stacking', () => {
for (const source of [shaders.SPHERE_FS, shaders.BASE_SPHERE_FS]) {
assert.match(source, /uniform vec3 u_hazeRayleighTau;/);
- assert.ok(source.includes('hazeOverSurface(surface,N,V,normalize(u_light),sunVis)'));
+ assert.ok(source.includes('hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis)'));
assert.ok(source.includes('displayLimb=vec3(0);'), 'a haze body drops the illustrative rim');
assert.ok(source.includes('vec3 displayLimb=u_atmo*fres*u_atmoStr'), 'other bodies keep the rim recipe');
}
diff --git a/tools/build_web.py b/tools/build_web.py
index 849fea8c..6b81f39e 100644
--- a/tools/build_web.py
+++ b/tools/build_web.py
@@ -55,7 +55,7 @@ def validate_visual_source(source_root: Path) -> frozenset[str]:
"solarAppearance.js", "solarAppearanceManifest.js", "solarVolumeShaders.js", "atmosphereOptics.js", "atmosphereShaders.js", "atmosphereIncident.js", "atmosphereIncidentManifest.js",
"atmosphereColumnField.js", "atmosphereColumnManifest.js", "atmosphereScattering.js", "scatteringTargets.js",
"planetPhenomena.js", "planetPhenomenaManifest.js", "solarAssetLoader.js", "physicalRendering.js",
- "illustrativeHaze.js",
+ "illustrativeHaze.js", "enhancedEarth.js", "earthOceanMask.js",
# render.js decides how an observed solar frame reaches the canvas: drawObservedBase()
# chooses between a registered photospheric disk and a whole browse frame, and owns the
# clip and limb treatment that go with each. That is observation rendering, so its hash
diff --git a/tools/enhanced_earth_probe.mjs b/tools/enhanced_earth_probe.mjs
new file mode 100644
index 00000000..9f2c23e0
--- /dev/null
+++ b/tools/enhanced_earth_probe.mjs
@@ -0,0 +1,14 @@
+// Input export for the optional offscreen GLES diagnostic. Run through render_enhanced_earth_probe.py.
+import fs from 'node:fs';
+import * as sh from '../apps/web/js/orreryShaders.js';
+import {buildSphere,lookAt,perspective,mul,scaleM,norm} from '../apps/web/js/orreryMath.js';
+import {BODY} from '../apps/web/js/bodyData.js';
+import {hazeUniformValues} from '../apps/web/js/illustrativeHaze.js';
+import {getAtmosphereProfile} from '../apps/web/js/atmosphereOptics.js';
+import {oceanMaskPixels,EARTH_CLOUD_HEIGHT_KM} from '../apps/web/js/enhancedEarth.js';
+const eye=[2.3,-2.3,1],oblate=BODY.Earth.polarKm/BODY.Earth.radiusKm,cloudScale=1+EARTH_CLOUD_HEIGHT_KM/BODY.Earth.radiusKm;
+const vp=mul(perspective(42*Math.PI/180,1,.01,100),lookAt(eye,[0,0,0],[0,0,1]));
+const model=scaleM([1,1,oblate]),cloudModel=scaleM([cloudScale,cloudScale,cloudScale*oblate]),mesh=buildSphere(48,96);
+const output={programs:{base:[sh.BASE_SPHERE_VS,sh.BASE_SPHERE_FS],physical:[sh.SCATTERING_SPHERE_VS,sh.SCATTERING_SPHERE_FS]},mesh:{pos:[...mesh.pos],idx:[...mesh.idx]},mask:[...oceanMaskPixels()],cloudModel,cloudMvp:mul(vp,cloudModel),uniforms:{
+ u_mvp:mul(vp,model),u_model:model,u_nmat:[1,0,0,0,1,0,0,0,1/oblate],u_cam:eye,u_light:norm([1,-.15,.2]),u_lightObj:norm([1,-.15,.2]),u_oblate:oblate,u_bodyRadiusKm:BODY.Earth.radiusKm,u_map:[.5,1,2,0],u_mapLat:[-Math.PI/2,Math.PI/2,-Math.PI/2,Math.PI/2],u_mapWindow:[1,1,0,0],u_mapNoData:0,u_useTex:1,u_texMode:3,u_tex:0,u_textureLinear:1,u_weatherTex:3,u_nightTex:2,u_iceTex:4,u_oceanMask:11,u_ringTex:1,u_terrainHeight:5,u_atmosphereIncidentField:6,u_atmosphereColumnField:7,u_atmosphereOzoneField:10,u_style:-1,u_mode:0,u_earthWeather:1,u_earthNight:0,u_earthIce:0,u_earthEnhanced:0,u_earthCloudShadow:0,u_cloudScale:cloudScale,u_cloudPhase:0,u_linearOutput:0,u_atmosphereEnabled:0,u_terrainShadowEnabled:0,u_atmo:[.35,.6,1],u_base:[.2,.2,.2],...hazeUniformValues(getAtmosphereProfile('Earth'))}};
+fs.writeFileSync(process.argv[2],JSON.stringify(output));
diff --git a/tools/prepare_earth_ocean_mask.py b/tools/prepare_earth_ocean_mask.py
new file mode 100644
index 00000000..01e38a21
--- /dev/null
+++ b/tools/prepare_earth_ocean_mask.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""Offline, conservative offshore display mask; never a scientific ocean product.
+
+Uses committed public-domain Natural Earth 1:110m land and glaciated-area rings.
+Interior holes remain protected (inland water is not an ocean). A two-pixel
+offshore margin and the shader's dark-blue gate protect the coarse coastline.
+Run with --check to verify deterministic regeneration.
+"""
+import argparse
+import hashlib
+import json
+import math
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SOURCE = ROOT / "tools/ephemeris-data/geography"
+OUTPUT = ROOT / "apps/web/js/earthOceanMask.js"
+WIDTH, HEIGHT = 512, 256
+INPUTS = {
+ "ne_110m_land.geojson": "9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9",
+ "ne_110m_glaciated_areas.geojson": "e61e3f45e22f087f861ac2fda6258fc15c849a216d74c27ccf0164eeafbd950a",
+}
+
+
+def unwrapped(ring):
+ lon = (ring[0][0] + 180) % 360 - 180
+ points = [((lon + 180) * WIDTH / 360, (90 - ring[0][1]) * HEIGHT / 180)]
+ for a, b in zip(ring, ring[1:]):
+ delta = b[0] - a[0]
+ if delta > 180:
+ delta -= 360
+ elif delta < -180:
+ delta += 360
+ lon += delta
+ points.append(((lon + 180) * WIDTH / 360, (90 - b[1]) * HEIGHT / 180))
+ return points
+
+
+def mask_pixels():
+ # Integer bit rows keep rasterization/dilation deterministic without image libraries.
+ rows = [0] * HEIGHT
+ for name, digest in INPUTS.items():
+ data = (SOURCE / name).read_bytes()
+ if hashlib.sha256(data).hexdigest() != digest:
+ raise ValueError(f"Source hash mismatch: {name}")
+ for feature in json.loads(data)["features"]:
+ geometry = feature["geometry"]
+ polygons = [geometry["coordinates"]] if geometry["type"] == "Polygon" else geometry["coordinates"]
+ for polygon in polygons:
+ ring = polygon[0]
+ points = unwrapped(ring)
+ if abs(points[-1][0] - points[0][0]) > WIDTH * .99:
+ # Close polar rings at their westernmost vertex, through the pole.
+ start = min(range(len(points) - 1), key=lambda i: points[i][0])
+ ring = ring[start:-1] + ring[:start] + [ring[start]]
+ points = unwrapped(ring)
+ pole = HEIGHT if sum(p[1] for p in ring) < 0 else 0
+ points += [(points[-1][0], pole), (points[0][0], pole)]
+ for offset in (-WIDTH, 0, WIDTH):
+ shifted = [(x + offset, y) for x, y in points]
+ edges = list(zip(shifted, shifted[1:] + shifted[:1]))
+ for y in range(max(0, math.floor(min(p[1] for p in shifted))),
+ min(HEIGHT, math.ceil(max(p[1] for p in shifted)))):
+ scan = y + .5
+ hits = sorted(a[0] + (scan - a[1]) * (b[0] - a[0]) / (b[1] - a[1])
+ for a, b in edges if (a[1] > scan) != (b[1] > scan))
+ for left, right in zip(hits[::2], hits[1::2]):
+ lo, hi = max(0, math.ceil(left - .5)), min(WIDTH, math.ceil(right - .5))
+ if hi > lo:
+ rows[y] |= ((1 << (hi - lo)) - 1) << lo
+ # Two-pixel dilation, wrapping longitude and clamping the poles.
+ full = (1 << WIDTH) - 1
+ protected = []
+ for y in range(HEIGHT):
+ row = 0
+ for source in rows[max(0, y - 2):min(HEIGHT, y + 3)]:
+ row |= source
+ expanded = row
+ for shift in (1, 2):
+ expanded |= ((row << shift) | (row >> (WIDTH - shift))) & full
+ expanded |= ((row >> shift) | (row << (WIDTH - shift))) & full
+ protected.extend(0 if expanded & (1 << x) else 255 for x in range(WIDTH))
+ return bytes(protected)
+
+
+def generate():
+ pixels = mask_pixels()
+ runs, previous, length = [], 0, 0
+ for value in pixels:
+ if value != previous:
+ runs.append(length)
+ previous, length = value, 0
+ length += 1
+ runs.append(length)
+ return (
+ "// GENERATED by tools/prepare_earth_ocean_mask.py. Public-domain Natural Earth.\n"
+ "// Conservative offshore display mask, not a qualified coastline or reflectance map.\n"
+ f"export const OCEAN_MASK_WIDTH={WIDTH},OCEAN_MASK_HEIGHT={HEIGHT};\n"
+ f"export const OCEAN_MASK_SOURCES={json.dumps(INPUTS, separators=(',', ':'))};\n"
+ f"export const OCEAN_MASK_SHA256='{hashlib.sha256(pixels).hexdigest()}';\n"
+ f"export const OCEAN_MASK_RUNS={json.dumps(runs, separators=(',', ':'))};\n"
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ content = generate()
+ if args.check:
+ if not OUTPUT.exists() or OUTPUT.read_text() != content:
+ raise SystemExit("Earth ocean mask differs; regenerate it")
+ print("OK: Earth ocean mask reproduces exactly")
+ else:
+ OUTPUT.write_text(content)
+ print(f"Wrote {OUTPUT.relative_to(ROOT)} ({len(content)} bytes)")
diff --git a/tools/render_enhanced_earth_probe.py b/tools/render_enhanced_earth_probe.py
new file mode 100644
index 00000000..ca4861a8
--- /dev/null
+++ b/tools/render_enhanced_earth_probe.py
@@ -0,0 +1,126 @@
+"""Optional Linux EGL/Mesa diagnostic using production shaders, not browser qualification.
+
+Requires system libEGL, Node, NumPy and Pillow. No network or browser automation.
+Outputs controlled comparisons and shader/source hashes to --output (default: docs/validation/enhanced-earth).
+"""
+import argparse
+import ctypes as c
+import hashlib
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+ROOT=Path(__file__).resolve().parent.parent
+parser=argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--output',type=Path,default=ROOT/'docs/validation/enhanced-earth')
+args=parser.parse_args()
+OUT=args.output
+OUT.mkdir(parents=True,exist_ok=True)
+with tempfile.TemporaryDirectory() as tmp:
+ data=Path(tmp)/'probe.json'
+ subprocess.run(['node',str(ROOT/'tools/enhanced_earth_probe.mjs'),str(data)],check=True)
+ D=json.loads(data.read_text())
+W=768
+E=c.CDLL('libEGL.so.1');E.eglGetProcAddress.argtypes=[c.c_char_p];E.eglGetProcAddress.restype=c.c_void_p
+
+def egl(name,result,*types):
+ f=getattr(E,name);f.restype=result;f.argtypes=types;return f
+
+def gl(name,result,*types):
+ addr=E.eglGetProcAddress(name.encode());assert addr,name
+ return c.CFUNCTYPE(result,*types)(addr)
+
+I,U,P,F=c.c_int,c.c_uint,c.c_void_p,c.c_float
+platform=c.CFUNCTYPE(P,U,P,c.POINTER(I))(E.eglGetProcAddress(b'eglGetPlatformDisplayEXT'))
+d=platform(0x31DD,None,None);ma,mi=I(),I();assert egl('eglInitialize',U,P,c.POINTER(I),c.POINTER(I))(d,c.byref(ma),c.byref(mi))
+assert egl('eglBindAPI',U,U)(0x30A0)
+attrs=(I*15)(0x3033,1,0x3040,0x40,0x3024,8,0x3023,8,0x3022,8,0x3021,8,0x3025,24,0x3038)
+cfg=P();n=I();assert egl('eglChooseConfig',U,P,c.POINTER(I),c.POINTER(P),I,c.POINTER(I))(d,attrs,c.byref(cfg),1,c.byref(n)) and n.value
+surface=egl('eglCreatePbufferSurface',P,P,P,c.POINTER(I))(d,cfg,(I*5)(0x3057,W,0x3056,W,0x3038))
+ctx=egl('eglCreateContext',P,P,P,P,c.POINTER(I))(d,cfg,None,(I*3)(0x3098,3,0x3038));assert ctx
+assert egl('eglMakeCurrent',U,P,P,P,P)(d,surface,surface,ctx)
+renderer=gl('glGetString',c.c_char_p,U)(0x1F01).decode();version=gl('glGetString',c.c_char_p,U)(0x1F02).decode();print(renderer,version,flush=True)
+create=gl('glCreateShader',U,U);source=gl('glShaderSource',None,U,I,c.POINTER(c.c_char_p),P);compile_=gl('glCompileShader',None,U);getshader=gl('glGetShaderiv',None,U,U,c.POINTER(I));slog=gl('glGetShaderInfoLog',None,U,I,P,c.c_char_p)
+cp=gl('glCreateProgram',U);attach=gl('glAttachShader',None,U,U);link=gl('glLinkProgram',None,U);getprog=gl('glGetProgramiv',None,U,U,c.POINTER(I));plog=gl('glGetProgramInfoLog',None,U,I,P,c.c_char_p)
+programs={}
+for name,shaders in D['programs'].items():
+ p=cp()
+ for kind,text in zip((0x8B31,0x8B30),shaders):
+ s=create(kind);raw=c.c_char_p(text.encode());source(s,1,c.byref(raw),None);compile_(s);ok=I();getshader(s,0x8B81,c.byref(ok))
+ if not ok.value:
+ msg=c.create_string_buffer(50000);slog(s,len(msg),None,msg);raise RuntimeError(msg.value.decode())
+ attach(p,s)
+ link(p);ok=I();getprog(p,0x8B82,c.byref(ok))
+ if not ok.value:
+ msg=c.create_string_buffer(50000);plog(p,len(msg),None,msg);raise RuntimeError(msg.value.decode())
+ programs[name]=p;print('compiled and linked',name,flush=True)
+p=programs['base'];gl('glUseProgram',None,U)(p)
+getloc=gl('glGetUniformLocation',I,U,c.c_char_p)
+ints={'u_mode','u_style','u_useTex','u_texMode','u_mapNoData','u_textureLinear','u_tex','u_weatherTex','u_nightTex','u_iceTex','u_oceanMask','u_ringTex','u_terrainHeight','u_atmosphereIncidentField','u_atmosphereColumnField','u_atmosphereOzoneField','u_earthWeather','u_earthNight','u_earthIce','u_earthEnhanced','u_earthCloudShadow','u_linearOutput','u_atmosphereEnabled','u_terrainShadowEnabled'}
+def uniform(name,value):
+ loc=getloc(p,name.encode())
+ if loc<0:return
+ if isinstance(value,list):
+ arr=(F*len(value))(*value)
+ if len(value) in (9,16):gl('glUniformMatrix'+('3' if len(value)==9 else '4')+'fv',None,I,I,U,c.POINTER(F))(loc,1,0,arr)
+ else:gl(f'glUniform{len(value)}fv',None,I,I,c.POINTER(F))(loc,1,arr)
+ elif name in ints:gl('glUniform1i',None,I,I)(loc,value)
+ else:gl('glUniform1f',None,I,F)(loc,value)
+for name,value in D['uniforms'].items():uniform(name,value)
+# All actual sampler2D uniforms get distinct, valid texture bindings.
+gen=gl('glGenTextures',None,I,c.POINTER(U));bind=gl('glBindTexture',None,U,U);active=gl('glActiveTexture',None,U);param=gl('glTexParameteri',None,U,U,I);teximage=gl('glTexImage2D',None,U,I,I,I,I,I,U,U,P);mip=gl('glGenerateMipmap',None,U)
+def texture(unit,array,internal=0x8058,fmt=0x1908):
+ tex=U();gen(1,c.byref(tex));active(0x84C0+unit);bind(0x0DE1,tex.value)
+ a=np.ascontiguousarray(array,dtype=np.uint8);h,w=a.shape[:2];teximage(0x0DE1,0,internal,w,h,0,fmt,0x1401,a.ctypes.data)
+ param(0x0DE1,0x2801,0x2703);param(0x0DE1,0x2800,0x2601);param(0x0DE1,0x2802,0x2901);param(0x0DE1,0x2803,0x812F);mip(0x0DE1)
+for unit in range(12):texture(unit,np.full((1,1,4),255,dtype=np.uint8))
+texture(0,np.array(Image.open(ROOT/'apps/web/textures/reference/earth-land-2004.jpg').convert('RGBA')),0x8C43)
+cloud=np.array(Image.open(ROOT/'apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png').convert('RGBA'))
+cloud[:,:,:3]=((cloud[:,:,:3].astype(np.uint16)*cloud[:,:,3,None]+127)//255).astype(np.uint8)
+texture(3,cloud)
+texture(11,np.array(D['mask'],dtype=np.uint8).reshape(256,512),0x8229,0x1903)
+genbuf=gl('glGenBuffers',None,I,c.POINTER(U));bindbuf=gl('glBindBuffer',None,U,U);bufdata=gl('glBufferData',None,U,c.c_ssize_t,P,U)
+a=np.array(D['mesh']['pos'],dtype=np.float32);ind=np.array(D['mesh']['idx'],dtype=np.uint16)
+for target,array in [(0x8892,a),(0x8893,ind)]:
+ b=U();genbuf(1,c.byref(b));bindbuf(target,b);bufdata(target,array.nbytes,array.ctypes.data,0x88E4)
+for loc in (0,1):
+ gl('glEnableVertexAttribArray',None,U)(loc);gl('glVertexAttribPointer',None,U,I,U,U,I,P)(loc,3,0x1406,0,12,None)
+enable=gl('glEnable',None,U);disable=gl('glDisable',None,U);depth=gl('glDepthMask',None,U);blend=gl('glBlendFunc',None,U,U)
+gl('glViewport',None,I,I,I,I)(0,0,W,W);enable(0x0B71);enable(0x0B44);gl('glCullFace',None,U)(0x0405);enable(0x0BE2);blend(0x0302,0x0303)
+# SOL sphere triangles use the same winding and back-face culling as production.
+draw=gl('glDrawElements',None,U,I,U,P);read=gl('glReadPixels',None,I,I,I,I,U,U,P)
+geterror=gl('glGetError',U)
+assert geterror()==0,'setup GL error'
+def render(enhanced,phase=0,shadows=True):
+ gl('glClearColor',None,F,F,F,F)(.008,.01,.017,1);depth(1);gl('glClear',None,U)(0x4000|0x0100)
+ uniform('u_model',D['uniforms']['u_model']);uniform('u_mvp',D['uniforms']['u_mvp']);uniform('u_mode',0)
+ uniform('u_cloudPhase',phase);uniform('u_earthWeather',int(not enhanced));uniform('u_earthEnhanced',int(enhanced));uniform('u_earthCloudShadow',int(enhanced and shadows))
+ draw(4,len(ind),0x1403,None)
+ if enhanced:
+ uniform('u_model',D['cloudModel']);uniform('u_mvp',D['cloudMvp']);uniform('u_mode',3);depth(0);draw(4,len(ind),0x1403,None);depth(1)
+ gl('glFinish',None)();pixels=np.empty((W,W,4),dtype=np.uint8);read(0,0,W,W,0x1908,0x1401,pixels.ctypes.data)
+ assert geterror()==0,'render GL error'
+ return Image.fromarray(pixels[::-1,:,:3])
+original=render(False);enhanced=render(True);drifted=render(True,.02);unshadowed=render(True,shadows=False)
+canvas=Image.new('RGB',(W*2,W+88),(8,10,16));canvas.paste(original,(0,70));canvas.paste(enhanced,(W,70));pd=ImageDraw.Draw(canvas)
+font='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';f=ImageFont.truetype(font,22);small=ImageFont.truetype(font,14)
+pd.text((25,12),'Original Earth',font=f,fill='white');pd.text((W+25,12),'Enhanced Earth',font=f,fill='white')
+pd.text((25,44),'Same NASA maps, camera and Sun direction',font=small,fill='#adb6c4');pd.text((W+25,44),'Darker oceans · 8 km cloud shell · Sun-aligned shadows',font=small,fill='#adb6c4')
+pd.text((25,W+64),'Offscreen OpenGL ES software probe · production base shaders / illustrative haze · not a browser qualification',font=small,fill='#adb6c4')
+canvas.save(OUT/'comparison.jpg',quality=92)
+arrays=[np.array(im,dtype=np.int16) for im in (original,enhanced,drifted,unshadowed)]
+# A black ground has no reflected direct light to shadow. Cloud shadows must
+# therefore leave its haze contribution unchanged (both passes use identical clouds).
+black=np.zeros((1,1,4),dtype=np.uint8);black[:,:,3]=255;texture(0,black,0x8C43)
+haze_shadow=np.array(render(True,shadows=True));haze_clear=np.array(render(True,shadows=False))
+assert np.array_equal(haze_shadow,haze_clear),'cloud shadows incorrectly attenuate atmospheric haze'
+assert np.any(arrays[0]!=arrays[1]),'enhancement has no visible effect'
+assert np.any(arrays[1]!=arrays[2]),'cloud phase has no visible effect'
+assert np.any(arrays[1]!=arrays[3]),'cloud shadows have no visible effect'
+result={'renderer':renderer,'version':version,'shader_sha256':{name:[hashlib.sha256(stage.encode()).hexdigest() for stage in stages] for name,stages in D['programs'].items()},'programs_compiled':list(programs),'width':W,'height':W,
+ 'changed_pixels':int(np.any(arrays[0]!=arrays[1],axis=2).sum()),'drift_changed_pixels':int(np.any(arrays[1]!=arrays[2],axis=2).sum()),'shadow_changed_pixels':int(np.any(arrays[1]!=arrays[3],axis=2).sum()),
+ 'scope':'Controlled offscreen OpenGL ES software rendering of production shaders with original NASA maps; base haze path, no browser/native-device qualification.'}
+result['source_sha256']={str(path.relative_to(ROOT)):hashlib.sha256(path.read_bytes()).hexdigest() for path in [ROOT/'apps/web/textures/reference/earth-land-2004.jpg',ROOT/'apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png',ROOT/'apps/web/js/earthOceanMask.js']}
+(OUT/'gl-probe.json').write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(result),flush=True)