mapspinner 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/workflows/fps-perf.js +147 -0
- package/.claude/workflows/optimize-dna.js +201 -0
- package/.claude/workflows/shader-bottleneck-dna.js +168 -0
- package/.claude/workflows/speed-dna.js +209 -0
- package/.claude/workflows/startup-perf.js +117 -0
- package/.github/workflows/publish.yml +43 -0
- package/AGENTS.md +265 -0
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +1 -0
- package/README.md +82 -0
- package/examples/basic-sdk-usage.html +114 -0
- package/package.json +28 -0
- package/planet.html +2181 -0
- package/planet.zip +0 -0
- package/scripts/backend-ab.mjs +88 -0
- package/scripts/dev-chrome.cmd +22 -0
- package/scripts/verify.mjs +69 -0
- package/server.js +127 -0
- package/src/anchor-field.js +559 -0
- package/src/gl-render.js +944 -0
- package/src/index.js +41 -0
- package/src/planet-orchestrator.js +790 -0
- package/src/quadtree.js +160 -0
- package/src/shaders/atmosphere.glsl +215 -0
- package/src/shaders/terrain.glsl +2109 -0
- package/src/terrain-gen-controls.js +122 -0
- package/tests/run.js +58 -0
- package/textures/grass-color.jpg +0 -0
- package/textures/grass-displacement.jpg +0 -0
- package/textures/rock-color.jpg +0 -0
- package/textures/rock-displacement.jpg +0 -0
- package/textures/sand-color.jpg +0 -0
- package/textures/sand-displacement.jpg +0 -0
- package/textures/snow-color.jpg +0 -0
- package/textures/snow-displacement.jpg +0 -0
package/planet.html
ADDED
|
@@ -0,0 +1,2181 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html><head><meta charset="utf-8"><title>TerrainView8 -- authentic Proland planet (WebGL2)</title>
|
|
3
|
+
<style>
|
|
4
|
+
html,body{margin:0;height:100%;background:#000;color:#9f9;font:12px monospace;overflow:hidden}
|
|
5
|
+
canvas{display:block;width:100vw;height:100vh}
|
|
6
|
+
#hud{position:fixed;top:8px;left:8px;background:rgba(0,0,0,.55);padding:8px 10px;border:1px solid #3f3;line-height:1.5;pointer-events:none}
|
|
7
|
+
#hud b{color:#cfc}
|
|
8
|
+
#err{position:fixed;bottom:8px;left:8px;color:#f88;background:rgba(0,0,0,.7);padding:6px 8px;max-width:80vw;display:none}
|
|
9
|
+
#panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,.7);padding:8px 10px;border:1px solid #3f3;font:11px monospace;color:#9f9;width:220px}
|
|
10
|
+
#panel label{display:flex;justify-content:space-between;align-items:center;margin:3px 0;gap:6px}
|
|
11
|
+
#panel input[type=range]{flex:1;min-width:0}
|
|
12
|
+
#panel input[type=number]{width:60px;background:#111;color:#9f9;border:1px solid #3f3;font:11px monospace;padding:1px 3px}
|
|
13
|
+
#panel input[type=checkbox]{accent-color:#3f3}
|
|
14
|
+
#panel .grp{border-top:1px solid #3f3;padding-top:4px;margin-top:6px;color:#cfc}
|
|
15
|
+
</style></head>
|
|
16
|
+
<body>
|
|
17
|
+
<!-- The authentic Proland WebGL2 path (proland-planet-orchestrator) renders here. -->
|
|
18
|
+
<canvas id="cwebgl" style="position:fixed;inset:0;width:100vw;height:100vh"></canvas>
|
|
19
|
+
<div id="hud"><b>TerrainView8 (authentic Proland, WebGL2)</b><br>
|
|
20
|
+
Controls: <b>WASD</b>/drag orbit . <b>Q/E</b>/wheel zoom . <b>R</b> reset<br>
|
|
21
|
+
<span id="stats">init...</span></div>
|
|
22
|
+
<div id="panel">
|
|
23
|
+
<div class="grp">Sun</div>
|
|
24
|
+
<label>Zenith<input type="range" id="sunZenith" min="0" max="120" step="1" value="55"></label>
|
|
25
|
+
<label>Azimuth<input type="range" id="sunAzimuth" min="0" max="360" step="1" value="0"></label>
|
|
26
|
+
<label>Rotate<input type="range" id="timeScale" min="0" max="200" step="1" value="0"></label>
|
|
27
|
+
<div class="grp">Render</div>
|
|
28
|
+
<label>Exposure<input type="range" id="exposure" min="0.1" max="8" step="0.05" value="1.4"></label>
|
|
29
|
+
<label>Display<select id="displayMode"><option value="0">Lit</option><option value="1">Normals</option><option value="2">Albedo</option><option value="6">Elevation</option><option value="4">Biome ramp</option><option value="5">Land/Sea</option><option value="7">River field</option><option value="8">River gating</option><option value="9">Biome map</option><option value="10">Canyon field</option><option value="11">Cliffs</option><option value="12">Patches (LOD)</option></select></label>
|
|
30
|
+
<label><span>Wireframe</span><input type="checkbox" id="wireframe"></label>
|
|
31
|
+
</div>
|
|
32
|
+
<button id="genToggle" style="position:fixed;left:8px;top:8px;z-index:30;font:11px monospace;padding:3px 7px">Generation</button>
|
|
33
|
+
<div id="genPanel" style="position:fixed;left:8px;top:34px;z-index:30;display:none;max-height:88vh;overflow:auto;background:rgba(12,16,20,0.92);color:#cfe;font:10px/1.5 monospace;padding:6px 8px;border:1px solid #2a3a44;width:250px"></div>
|
|
34
|
+
<div id="err"></div>
|
|
35
|
+
<script type="module">
|
|
36
|
+
// [P2-6 follow-up] The authentic Proland terrain + atmosphere + ocean run entirely
|
|
37
|
+
// on the WebGL2 path (proland-planet-orchestrator, loaded lazily in frameLoop). The
|
|
38
|
+
// legacy WebGPU device/pipelines/ocean/atmosphere + the JS quadtree + our CPU
|
|
39
|
+
// proland_terrain.wasm (which only drove that dead path) have been DELETED.
|
|
40
|
+
const errEl=document.getElementById('err');
|
|
41
|
+
const showErr=m=>{errEl.style.display='block';errEl.textContent=String(m);window.__pageErr=String(m);console.error(m)};
|
|
42
|
+
window.addEventListener('error',e=>showErr(e.message||e));
|
|
43
|
+
window.addEventListener('unhandledrejection',e=>showErr(e.reason?.message||e.reason||e));
|
|
44
|
+
|
|
45
|
+
// [P2-6 follow-up] The legacy WebGPU render path has been DELETED. The authentic
|
|
46
|
+
// Proland terrain + atmosphere + ocean all run on the WebGL2 path
|
|
47
|
+
// (proland-planet-orchestrator) drawing to #cwebgl. `canvas` is the WebGL2 canvas;
|
|
48
|
+
// camera event handlers + sizing read it.
|
|
49
|
+
const canvas=document.getElementById('cwebgl');
|
|
50
|
+
|
|
51
|
+
function nl_hash(ix,iy,iz){
|
|
52
|
+
const n = (Math.imul(ix,1597) + Math.imul(iy,3571) + Math.imul(iz,7919))|0;
|
|
53
|
+
let state = (Math.imul(n>>>0, 747796405) + 2891336453)>>>0;
|
|
54
|
+
state = (Math.imul(((state >>> ((state>>>28)+4)) ^ state)>>>0, 277803737))>>>0;
|
|
55
|
+
state = ((state >>> 22) ^ state)>>>0;
|
|
56
|
+
return -1.0 + 2.0 * state / 4294967295;
|
|
57
|
+
}
|
|
58
|
+
function value_noise(x,y,z){
|
|
59
|
+
const ix=Math.floor(x),iy=Math.floor(y),iz=Math.floor(z);
|
|
60
|
+
const wx=x-ix, wy=y-iy, wz=z-iz;
|
|
61
|
+
const ux=wx*wx*wx*(wx*(wx*6-15)+10), uy=wy*wy*wy*(wy*(wy*6-15)+10), uz=wz*wz*wz*(wz*(wz*6-15)+10);
|
|
62
|
+
const a=nl_hash(ix,iy,iz), b=nl_hash(ix+1,iy,iz), c=nl_hash(ix,iy+1,iz), d=nl_hash(ix+1,iy+1,iz);
|
|
63
|
+
const e=nl_hash(ix,iy,iz+1), f=nl_hash(ix+1,iy,iz+1), g=nl_hash(ix,iy+1,iz+1), hh=nl_hash(ix+1,iy+1,iz+1);
|
|
64
|
+
const k0=a,k1=b-a,k2=c-a,k3=e-a,k4=a-b-c+d,k5=a-c-e+g,k6=a-b-e+f,k7=-a+b+c-d+e-f-g+hh;
|
|
65
|
+
return k0 + k1*ux + k2*uy + k3*uz + k4*ux*uy + k5*uy*uz + k6*uz*ux + k7*ux*uy*uz;
|
|
66
|
+
}
|
|
67
|
+
function fbm_range(gain,n){let h; if(Math.abs(gain-1)<1e-5)h=n+1; else h=(1-Math.pow(gain,n+1))/(1-gain); return [-h,h];}
|
|
68
|
+
function value_fbm(x,y,z,gain,n){let f=1,a=1,t=0;for(let i=0;i<n;i++){t+=a*value_noise(f*x,f*y,f*z);f*=2;a*=gain;}return t;}
|
|
69
|
+
function value_fbm_scaled(x,y,z,gain,n,mn,mx){const v=value_fbm(x,y,z,gain,n);const rg=fbm_range(gain,n);return mn+(mx-mn)*((v-rg[0])/(rg[1]-rg[0]));}
|
|
70
|
+
function fbm_ridged_range(gain,n,off,exp){return [Math.pow(off-1,exp), Math.pow(off,exp)*fbm_range(gain,n)[1]];}
|
|
71
|
+
function value_ridged_fbm_scaled(x,y,z,gain,n,off,exp,mn,mx){
|
|
72
|
+
let f=1,a=1,t=0,w=1;
|
|
73
|
+
for(let i=0;i<n;i++){let s=value_noise(f*x,f*y,f*z);s=off-Math.abs(s);s=Math.pow(s,exp);s*=w;t+=a*s;f*=2;a*=gain;w=Math.max(0,Math.min(1,s*2));}
|
|
74
|
+
const rg=fbm_ridged_range(gain,n,off,exp);return mn+(mx-mn)*((t-rg[0])/(rg[1]-rg[0]));
|
|
75
|
+
}
|
|
76
|
+
// jsElev -- RADIUS-fraction elevation of the SINGLE terrain fractal. The old line-by-line WGSL
|
|
77
|
+
// cascade mirror was deleted with the cascade (one-fractal collapse); jsElev now delegates to
|
|
78
|
+
// jsBroadShape (the faithful mirror of broadShapeM, the sole rendered field) so every CPU consumer
|
|
79
|
+
// (collision clamp, findLand viewpoints) tracks the rendered surface. Returns elevation / R.
|
|
80
|
+
function jsElev(dx,dy,dz){
|
|
81
|
+
return jsBroadShape(dx,dy,dz) / WEBGL2_TERRAIN_R_M; // metres -> fraction of R
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// jsBroadShape -- faithful JS port of terrain.glsl broadShapeM(dir, reliefMul=1, ridgeMul=0)
|
|
85
|
+
// (the neutral base silhouette). The renderer's elevation is dominated by this continuous
|
|
86
|
+
// world-dir fBm, so the free-fly COLLISION samples it (not the stale jsElev cascade) to meet
|
|
87
|
+
// the visible ground. Returns metres. shash3/snoise3 mirror the GLSL value-noise exactly.
|
|
88
|
+
function jsShash3(px,py,pz){
|
|
89
|
+
let x=px*0.3183099+0.1, y=py*0.3183099+0.2, z=pz*0.3183099+0.3;
|
|
90
|
+
x-=Math.floor(x); y-=Math.floor(y); z-=Math.floor(z);
|
|
91
|
+
const d=x*(y+19.19)+y*(z+19.19)+z*(x+19.19); x+=d; y+=d; z+=d;
|
|
92
|
+
const v=(x+y)*z+(y+z)*x+(z+x)*y; return v-Math.floor(v);
|
|
93
|
+
}
|
|
94
|
+
function jsSnoise3(px,py,pz){
|
|
95
|
+
const ix=Math.floor(px),iy=Math.floor(py),iz=Math.floor(pz);
|
|
96
|
+
const fx=px-ix,fy=py-iy,fz=pz-iz;
|
|
97
|
+
const ux=fx*fx*(3-2*fx),uy=fy*fy*(3-2*fy),uz=fz*fz*(3-2*fz);
|
|
98
|
+
const H=(ox,oy,oz)=>jsShash3(ix+ox,iy+oy,iz+oz);
|
|
99
|
+
const n000=H(0,0,0),n100=H(1,0,0),n010=H(0,1,0),n110=H(1,1,0),
|
|
100
|
+
n001=H(0,0,1),n101=H(1,0,1),n011=H(0,1,1),n111=H(1,1,1);
|
|
101
|
+
const x00=n000+(n100-n000)*ux, x10=n010+(n110-n010)*ux, x01=n001+(n101-n001)*ux, x11=n011+(n111-n011)*ux;
|
|
102
|
+
const y0=x00+(x10-x00)*uy, y1=x01+(x11-x01)*uy;
|
|
103
|
+
return (y0+(y1-y0)*uz)*2-1;
|
|
104
|
+
}
|
|
105
|
+
function jsBroadShape(dx,dy,dz){
|
|
106
|
+
const r=Math.hypot(dx,dy,dz)||1; dx/=r; dy/=r; dz/=r;
|
|
107
|
+
let amp=6500.0, freq=3.0, sum=0.0; // A0=6500, off=-900: matches GLSL broadShapeM (one-fractal collapse)
|
|
108
|
+
// hi-freq elevation noise 4x cut (user 2026-06-06): the GPU broadShapeM scales the o>=6 fine band by
|
|
109
|
+
// uHiFreqCut (0.25); mirror it here so the collision FALLBACK height tracks the rendered surface.
|
|
110
|
+
for(let o=0;o<14;o++){ let nn=jsSnoise3(dx*freq,dy*freq,dz*freq); if(o>=6) nn*=0.25; sum+=amp*nn; amp*=(o<6?0.6:0.82); freq*=2.0; }
|
|
111
|
+
return sum-900.0; // metres, neutral relief (reliefMul=1, ridgeMul=0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- Vector helpers (used by the free-fly camera + WebGL2 sun/orientation math).
|
|
115
|
+
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
116
|
+
const normalize=v=>{const r=Math.hypot(...v)||1;return [v[0]/r,v[1]/r,v[2]/r]};
|
|
117
|
+
|
|
118
|
+
// ---- Camera state
|
|
119
|
+
// Planet radius in world units. 100 = 100x larger than the original R=1
|
|
120
|
+
// scale; the entire math is parameterised by RADIUS so close-surface relief
|
|
121
|
+
// reads as a much smaller fraction of the curvature -> more realistic look.
|
|
122
|
+
// cam.dist is now in RADIUS units (1.0 = surface, 3.0 = orbit).
|
|
123
|
+
// Planet radius in working units. Earth-scale km. Apparent size is set by
|
|
124
|
+
// where the camera starts (cam.pos below), not by this scalar -- RADIUS just
|
|
125
|
+
// sets the units everything else is denominated in.
|
|
126
|
+
const RADIUS=6371.0;
|
|
127
|
+
// WebGL2 Proland overlay works in METERS (its producer generates meter-scale
|
|
128
|
+
// elevation). Surface radius 6.36e6 m; the km-unit world maps to it 1 unit = this many m.
|
|
129
|
+
const WEBGL2_TERRAIN_R_M = 6360000.0;
|
|
130
|
+
const WEBGL2_M_PER_UNIT = WEBGL2_TERRAIN_R_M / RADIUS;
|
|
131
|
+
// EXPOSE the world-scale constants as globals so a scripted browser-witness (page.evaluate runs
|
|
132
|
+
// in a SEPARATE scope and cannot see these module consts) can build a correct camera pose in
|
|
133
|
+
// metres. Without this, witness scripts guessed the metre scale (got undefined -> NaN eye -> black
|
|
134
|
+
// or a default ocean frame) -- the 'scripted witness shows water' root. Now a witness reads
|
|
135
|
+
// window.__RADIUS / __WEBGL2_TERRAIN_R_M / __WEBGL2_M_PER_UNIT and poses exactly like the rAF loop.
|
|
136
|
+
if (typeof window !== 'undefined') {
|
|
137
|
+
window.__RADIUS = RADIUS;
|
|
138
|
+
window.__WEBGL2_TERRAIN_R_M = WEBGL2_TERRAIN_R_M;
|
|
139
|
+
window.__WEBGL2_M_PER_UNIT = WEBGL2_M_PER_UNIT;
|
|
140
|
+
}
|
|
141
|
+
// Free-fly 6-DOF camera. `pos` is world-space position, `yaw`/`pitch` define
|
|
142
|
+
// look direction in world coordinates. WASD translates along view-relative axes,
|
|
143
|
+
// Q/E moves down/up along world-up, mouse drag rotates the look direction.
|
|
144
|
+
// Position is clamped so length(pos) >= RADIUS*1.001 -- prevents camera from
|
|
145
|
+
// dropping below sea level (a true terrain-following clamp would need JS-side
|
|
146
|
+
// elev() to mirror the WGSL version).
|
|
147
|
+
// FREE-FLY camera held as an orthonormal BASIS (fwd, up) in world space, not
|
|
148
|
+
// Euler yaw/pitch. Mouse/keys apply INCREMENTAL rotations to this basis; each
|
|
149
|
+
// frame the up is gently rolled toward the local surface normal (normalize(pos)).
|
|
150
|
+
// This is a single mode (no orbital/FPS switch), never gimbal-locks or flips, and
|
|
151
|
+
// you can look anywhere -- "up rotates toward the normal" and everything works WITH
|
|
152
|
+
// it. (Replaces the yaw/pitch + mode-blend + viewpoint-tween scheme that flipped/
|
|
153
|
+
// tumbled/couldn't-look-down near the ground.)
|
|
154
|
+
const cam={
|
|
155
|
+
pos:[0, 0, 3*RADIUS],
|
|
156
|
+
fwd:[0, 0, -1], // looking toward planet center
|
|
157
|
+
up:[0, 1, 0],
|
|
158
|
+
speed:1.0,
|
|
159
|
+
sunLatBase:0.35, // lower sun (was 0.6) -> longer shadows so even gentle rolling terrain self-shades and the default view reads 3D (see mutable default-sun-oblique-for-relief). 0.35rad ~= 20deg above horizon.
|
|
160
|
+
sunLonBase:0.0,
|
|
161
|
+
sunLonAccum:0.0,
|
|
162
|
+
sunLonRate:0.00002,
|
|
163
|
+
timeScale:0.0,
|
|
164
|
+
exposure:10.0, // bright daylit terrain (atm ground radiance is low-magnitude; the tonemap needs a high exposure, matching the original's ~8-10)
|
|
165
|
+
oceanTimeScale:1.0,
|
|
166
|
+
displayMode:0,
|
|
167
|
+
wireframe:false,
|
|
168
|
+
};
|
|
169
|
+
window.__cam = cam; // expose for headless witness/descent tests (read + move the camera)
|
|
170
|
+
function camReset(){ cam.pos=[0,0,3*RADIUS]; cam.fwd=[0,0,-1]; cam.up=[0,1,0]; }
|
|
171
|
+
// Rotate vector v about unit axis k by angle a (Rodrigues).
|
|
172
|
+
function rotAxis(v,k,a){
|
|
173
|
+
const c=Math.cos(a), s=Math.sin(a);
|
|
174
|
+
const kv=k[0]*v[0]+k[1]*v[1]+k[2]*v[2];
|
|
175
|
+
return [
|
|
176
|
+
v[0]*c + (k[1]*v[2]-k[2]*v[1])*s + k[0]*kv*(1-c),
|
|
177
|
+
v[1]*c + (k[2]*v[0]-k[0]*v[2])*s + k[1]*kv*(1-c),
|
|
178
|
+
v[2]*c + (k[0]*v[1]-k[1]*v[0])*s + k[2]*kv*(1-c),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
const keys={};
|
|
182
|
+
window.__keys = keys; // expose so __diag.flyForward can drive the REAL W-key motion path
|
|
183
|
+
|
|
184
|
+
// VIEWPOINTS (tv8-viewpoints): Ctrl+V jumps to a high-elevation overlook; Space
|
|
185
|
+
// resets to the space view. With the basis camera we just SET pos + aim fwd/up
|
|
186
|
+
// directly (no yaw/pitch tween -- the per-frame up-alignment + free-fly handles
|
|
187
|
+
// orientation). Kept simple and instant.
|
|
188
|
+
const VIEWPOINTS = (() => {
|
|
189
|
+
const names = ["Phantom's Bay","Angel's Reach","Drake Summit","Mistral Coast","Verdant Vale","Cinder Ridge"];
|
|
190
|
+
const found = [];
|
|
191
|
+
for (let i=0;i<6000 && found.length<names.length;i++){
|
|
192
|
+
const a = i*2.399963, y = 1 - 2*(i+0.5)/6000;
|
|
193
|
+
const rxy = Math.sqrt(Math.max(0,1-y*y));
|
|
194
|
+
const d = [Math.cos(a)*rxy, y, Math.sin(a)*rxy];
|
|
195
|
+
const e = jsElev(d[0],d[1],d[2]);
|
|
196
|
+
if (e > 0.0008 && (found.length===0 || found.every(f => (f.d[0]*d[0]+f.d[1]*d[1]+f.d[2]*d[2]) < 0.3))){
|
|
197
|
+
found.push({ d, e });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return found.map((f,i) => ({ name: names[i] || ("Viewpoint "+i), d:f.d, e:f.e }));
|
|
201
|
+
})();
|
|
202
|
+
let vpIndex = -1;
|
|
203
|
+
function aimAt(pos, lookDir){
|
|
204
|
+
cam.pos=[pos[0],pos[1],pos[2]];
|
|
205
|
+
const f=normalize(lookDir); cam.fwd=f;
|
|
206
|
+
let lu=normalize(pos);
|
|
207
|
+
// up = local-up (radial) made perpendicular to fwd. When looking straight DOWN the
|
|
208
|
+
// radial up is ANTIPARALLEL to fwd, so the projection collapses to ~0 -> degenerate up
|
|
209
|
+
// -> broken view matrix (planet vanished off-screen, the gotoDown bug). Detect that
|
|
210
|
+
// (|lu.f| ~ 1) and pick an arbitrary reference axis not parallel to fwd instead.
|
|
211
|
+
let d0 = lu[0]*f[0]+lu[1]*f[1]+lu[2]*f[2];
|
|
212
|
+
if (Math.abs(d0) > 0.999) { lu = Math.abs(f[1])<0.9 ? [0,1,0] : [1,0,0]; d0 = lu[0]*f[0]+lu[1]*f[1]+lu[2]*f[2]; }
|
|
213
|
+
let u=[lu[0]-f[0]*d0, lu[1]-f[1]*d0, lu[2]-f[2]*d0];
|
|
214
|
+
const ul=Math.hypot(...u); cam.up = ul>1e-6 ? [u[0]/ul,u[1]/ul,u[2]/ul] : [0,1,0];
|
|
215
|
+
}
|
|
216
|
+
function gotoSpaceView(){ cam.pos=[0,0,3*RADIUS]; cam.fwd=[0,0,-1]; cam.up=[0,1,0]; vpIndex=-1; }
|
|
217
|
+
function cycleViewpoint(){
|
|
218
|
+
if (VIEWPOINTS.length===0) return;
|
|
219
|
+
vpIndex=(vpIndex+1)%VIEWPOINTS.length; const v=VIEWPOINTS[vpIndex];
|
|
220
|
+
const alt=RADIUS*(1+v.e)+2.0; const pos=[v.d[0]*alt,v.d[1]*alt,v.d[2]*alt];
|
|
221
|
+
// look along a tangent, gently down toward the surface
|
|
222
|
+
const lu=normalize(pos); const t=normalize(cross(lu,[0,1,0]));
|
|
223
|
+
aimAt(pos, [t[0]-lu[0]*0.4, t[1]-lu[1]*0.4, t[2]-lu[2]*0.4]);
|
|
224
|
+
}
|
|
225
|
+
window.__viewpoints = { list:VIEWPOINTS, cycle:cycleViewpoint, space:gotoSpaceView, get index(){return vpIndex;} };
|
|
226
|
+
|
|
227
|
+
addEventListener('keydown',e=>{
|
|
228
|
+
const k = e.key.toLowerCase();
|
|
229
|
+
if (e.ctrlKey && k==='v'){ e.preventDefault(); cycleViewpoint(); return; }
|
|
230
|
+
if (e.key===' ' || k==='spacebar'){ e.preventDefault(); gotoSpaceView(); return; }
|
|
231
|
+
// 'c' toggles ONLY the cull-debug HUD (kept/culled counts + false-cull signature). It does NOT
|
|
232
|
+
// force window.__frustumCull -- the frustum cull follows its default (ON). An earlier version set
|
|
233
|
+
// __frustumCull=false on toggle-off, which DEFEATED the default cull (user saw 3000 quads again).
|
|
234
|
+
if (k==='c'){ window.__cullDebug = !window.__cullDebug;
|
|
235
|
+
if (window.__planetOrch && window.__planetOrch.clearCache) window.__planetOrch.clearCache(); return; }
|
|
236
|
+
keys[k]=true; if(k==='r'){camReset();}
|
|
237
|
+
});
|
|
238
|
+
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false});
|
|
239
|
+
|
|
240
|
+
let dragActive=false, lastMouseX=0, lastMouseY=0;
|
|
241
|
+
canvas.addEventListener('pointerdown', e=>{ dragActive=true; lastMouseX=e.clientX; lastMouseY=e.clientY; canvas.setPointerCapture(e.pointerId); });
|
|
242
|
+
canvas.addEventListener('pointerup', e=>{ dragActive=false; try{canvas.releasePointerCapture(e.pointerId);}catch(_){} });
|
|
243
|
+
canvas.addEventListener('pointermove', e=>{
|
|
244
|
+
if(!dragActive) return;
|
|
245
|
+
const dx=e.clientX-lastMouseX, dy=e.clientY-lastMouseY;
|
|
246
|
+
lastMouseX=e.clientX; lastMouseY=e.clientY;
|
|
247
|
+
// Incremental free-fly look: yaw about the camera UP, pitch about the camera
|
|
248
|
+
// RIGHT. Rotating the basis vectors directly never gimbal-locks and lets you
|
|
249
|
+
// look straight down/up freely (the near-ground "can't look down" was the old
|
|
250
|
+
// pitch clamp + mode blend). right = fwd x up.
|
|
251
|
+
let r=normalize(cross(cam.fwd, cam.up));
|
|
252
|
+
cam.fwd = normalize(rotAxis(cam.fwd, cam.up, -dx*0.005));
|
|
253
|
+
r = normalize(cross(cam.fwd, cam.up));
|
|
254
|
+
cam.fwd = normalize(rotAxis(cam.fwd, r, -dy*0.005));
|
|
255
|
+
// re-orthogonalize up against fwd
|
|
256
|
+
cam.up = normalize(cross(r, cam.fwd));
|
|
257
|
+
});
|
|
258
|
+
// Wheel scales movement speed instead of distance (no more zoom -- fly in).
|
|
259
|
+
// Top-speed cap 1000 -> 200 (user 2026-06-02: 'reduce the top speed ... 5x').
|
|
260
|
+
canvas.addEventListener('wheel', e=>{
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
const factor = Math.exp(-e.deltaY*0.001);
|
|
263
|
+
cam.speed = Math.max(0.001, Math.min(200, cam.speed*factor));
|
|
264
|
+
}, {passive:false});
|
|
265
|
+
|
|
266
|
+
let lastT=performance.now(); let frame=0; let emaMspf=0;
|
|
267
|
+
// Ocean wave clock: advances by dt*oceanTimeScale so the Wave-speed slider can
|
|
268
|
+
// slow/freeze the waves (0=frozen) independently of wall-clock time. Fed to both
|
|
269
|
+
// the FFT step and the fragment foam/shore phase.
|
|
270
|
+
let oceanTime=0;
|
|
271
|
+
// Per-frame diag stream: POST JSON line every diagInterval frames to
|
|
272
|
+
// /diag (server keeps the last 200 lines in a ring, GET /diag/tail). Skipped if fetch absent.
|
|
273
|
+
const diagInterval = 60;
|
|
274
|
+
function postDiag(event) {
|
|
275
|
+
if (typeof fetch === 'undefined') return;
|
|
276
|
+
try {
|
|
277
|
+
fetch('/diag', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
278
|
+
body: JSON.stringify({t: Date.now(), ...event}), keepalive: true});
|
|
279
|
+
} catch(_) {}
|
|
280
|
+
}
|
|
281
|
+
// AGENT COMMAND CHANNEL POLLER (2026-06-07, user: 'maximize live code execution + hot reloading').
|
|
282
|
+
// Poll the server's /cmd queue; run each enqueued JS snippet live (async ok) and POST its result to
|
|
283
|
+
// /diag (kind:'cmd-result'). Lets a headless agent toggle the atlas, hot-reload the shader, and probe
|
|
284
|
+
// live GPU state through the WARM tab with zero reloads -- the simplest iterate loop. Gated on
|
|
285
|
+
// window.__agentCmd!==false so it can be disabled. ~twice/sec.
|
|
286
|
+
let __cmdBusy = false;
|
|
287
|
+
async function pollCmd() {
|
|
288
|
+
if (__cmdBusy || typeof fetch === 'undefined' || window.__agentCmd === false) return;
|
|
289
|
+
__cmdBusy = true;
|
|
290
|
+
try {
|
|
291
|
+
const r = await fetch('/cmd/next'); const cmd = await r.json();
|
|
292
|
+
if (cmd && cmd.js) {
|
|
293
|
+
let result;
|
|
294
|
+
try { result = await (async () => { return eval(cmd.js); })(); }
|
|
295
|
+
catch (e) { result = { error: String(e && e.message || e) }; }
|
|
296
|
+
postDiag({ kind: 'cmd-result', id: cmd.id, result });
|
|
297
|
+
}
|
|
298
|
+
} catch (_) {} finally { __cmdBusy = false; }
|
|
299
|
+
}
|
|
300
|
+
setInterval(pollCmd, 500);
|
|
301
|
+
// P2-6: the authentic Proland WebGL2 terrain (proland-planet-orchestrator) is the
|
|
302
|
+
// renderer. Set window.__useWebGL2Terrain=false to disable (no fallback now).
|
|
303
|
+
if (window.__useWebGL2Terrain === undefined) window.__useWebGL2Terrain = true;
|
|
304
|
+
const stats=document.getElementById('stats');
|
|
305
|
+
// SINGLE-FLIGHT FRAME SCHEDULER (2026-06-12): every frameLoop continuation routes through here so
|
|
306
|
+
// the hidden-tab bootstrap interval can also tick frameLoop without ever stacking parallel rAF
|
|
307
|
+
// chains (each stacked chain would run frameLoop once PER FRAME forever after the tab re-shows).
|
|
308
|
+
let _rafQueued = false;
|
|
309
|
+
function scheduleFrame(){ if (_rafQueued) return; _rafQueued = true;
|
|
310
|
+
requestAnimationFrame(() => { _rafQueued = false; frameLoop(); }); }
|
|
311
|
+
function frameLoop(){
|
|
312
|
+
if (window.__deviceLost) return; // halt loop after device-lost
|
|
313
|
+
const now=performance.now();
|
|
314
|
+
const frameMs = now - lastT; // true inter-frame interval (for FPS)
|
|
315
|
+
const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
|
|
316
|
+
|
|
317
|
+
// ---- Authentic-Proland WebGL2 terrain path. Lazily inits the compiled Proland
|
|
318
|
+
// wasm + thin WebGL2 renderer (proland-planet-orchestrator) on the #cwebgl canvas.
|
|
319
|
+
// The actual render + rAF happens AFTER the camera-update section below (so WASD/
|
|
320
|
+
// drag/zoom + autopilot move the camera before the frame is drawn).
|
|
321
|
+
if (window.__useWebGL2Terrain) {
|
|
322
|
+
if (window.__planetOrchStatus === undefined) {
|
|
323
|
+
window.__planetOrchStatus = 'init';
|
|
324
|
+
// LOADING OVERLAY: the COLD shader compile can take minutes (ANGLE HLSL translation of the big
|
|
325
|
+
// terrain FS, ~188s first-ever on this driver; warm <0.1s). The non-blocking compile keeps the
|
|
326
|
+
// page responsive but the canvas is BLACK until the first render -> looks broken. Show a visible
|
|
327
|
+
// "compiling terrain shader" overlay with elapsed seconds, removed on the first rendered frame.
|
|
328
|
+
(function(){
|
|
329
|
+
const ov = document.createElement('div'); ov.id='__loadOverlay';
|
|
330
|
+
ov.style.cssText='position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#05080f;color:#9fb6d4;font:14px/1.5 monospace;gap:10px;';
|
|
331
|
+
ov.innerHTML='<div style="font-size:16px;color:#cfe0f5">Compiling terrain shader</div>'+
|
|
332
|
+
'<div id="__loadSub" style="opacity:0.7">first load on this GPU can take a minute (cached after)</div>'+
|
|
333
|
+
'<div id="__loadElapsed" style="opacity:0.5">0.0s</div>';
|
|
334
|
+
document.body.appendChild(ov);
|
|
335
|
+
const t0 = performance.now();
|
|
336
|
+
window.__loadTick = setInterval(()=>{ const el=document.getElementById('__loadElapsed'); if(el) el.textContent=((performance.now()-t0)/1000).toFixed(1)+'s'; }, 100);
|
|
337
|
+
})();
|
|
338
|
+
const gw = document.getElementById('cwebgl');
|
|
339
|
+
// size the drawing buffer to the window (x DPR). BUG: previously `canvas.width||
|
|
340
|
+
// innerWidth` -- but canvas IS #cwebgl, whose default drawing-buffer width is 300
|
|
341
|
+
// (truthy) -> the buffer stayed locked at 300x150 (the "resolution dropped"). Size
|
|
342
|
+
// it explicitly from the viewport.
|
|
343
|
+
const _dpr = Math.min(window.devicePixelRatio||1, 2);
|
|
344
|
+
gw.width = Math.round(innerWidth*_dpr); gw.height = Math.round(innerHeight*_dpr);
|
|
345
|
+
// preserveDrawingBuffer:true so headless screenshots / readPixels capture the actual
|
|
346
|
+
// rendered frame (without it the WebGL2 backbuffer reads all-zero in the witness
|
|
347
|
+
// harness). Minor perf cost; worth it for reliable witnessing.
|
|
348
|
+
const glw = gw.getContext('webgl2', {antialias:true, preserveDrawingBuffer:true});
|
|
349
|
+
// SOFTWARE-WEBGL + CONTEXT-LOSS TRIPWIRES (2026-06-11 'rocks everywhere + no normals + slow on
|
|
350
|
+
// AMD'): reproduced -- the AMD GPU process can crash under sustained terrain load; Chrome
|
|
351
|
+
// auto-recovers the tab but may fall back to SOFTWARE WebGL (SwiftShader), which renders the
|
|
352
|
+
// degraded look at a fraction of the speed with NO visible indication. P9: never a silent
|
|
353
|
+
// degraded mode -- detect both and say so in red, in the corner and in /diag.
|
|
354
|
+
(function(){
|
|
355
|
+
const banner = (msg) => {
|
|
356
|
+
let b = document.getElementById('__gpuBanner');
|
|
357
|
+
if (!b) { b = document.createElement('div'); b.id='__gpuBanner';
|
|
358
|
+
b.style.cssText='position:fixed;top:0;left:0;right:0;z-index:10000;background:#7a1111;color:#fff;font:13px/1.6 monospace;padding:6px 12px;text-align:center;';
|
|
359
|
+
document.body.appendChild(b); }
|
|
360
|
+
b.textContent = msg;
|
|
361
|
+
};
|
|
362
|
+
try {
|
|
363
|
+
const ext = glw.getExtension('WEBGL_debug_renderer_info');
|
|
364
|
+
const renderer = ext ? String(glw.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : '';
|
|
365
|
+
window.__gpuRenderer = renderer;
|
|
366
|
+
if (/swiftshader|software|llvmpipe/i.test(renderer)) {
|
|
367
|
+
window.__softwareWebGL = true;
|
|
368
|
+
banner('WebGL IS SOFTWARE-RENDERED (' + renderer.slice(0,60) + ') -- the GPU driver crashed earlier. Restart the BROWSER (not just this tab) to restore hardware rendering.');
|
|
369
|
+
}
|
|
370
|
+
} catch(_) {}
|
|
371
|
+
gw.addEventListener('webglcontextlost', (e) => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
window.__ctxLostAt = performance.now()|0;
|
|
374
|
+
banner('GPU CONTEXT LOST -- the graphics driver crashed. Reload the page; if terrain then looks flat/rocky/slow, restart the browser (software-WebGL fallback).');
|
|
375
|
+
});
|
|
376
|
+
})();
|
|
377
|
+
// The orchestrator works in METERS (its producer generates meter-scale elevation).
|
|
378
|
+
// We drive it in meter-space and scale the km-unit camera into meters via
|
|
379
|
+
// WEBGL2_M_PER_UNIT below.
|
|
380
|
+
import('/src/planet-orchestrator.js')
|
|
381
|
+
.then(m => m.initProlandPlanet(glw, { radius: WEBGL2_TERRAIN_R_M, frustumCull: false }))
|
|
382
|
+
.then(orch => { window.__planetOrch = orch; window.__planetOrchGL = glw; window.__planetOrchStatus='ready';
|
|
383
|
+
// install the unified live generation-control surface (window.__gen) AND apply its
|
|
384
|
+
// CLI-validated DEFAULTS to the wasm at startup (the baked wasm cascade is the old
|
|
385
|
+
// table; __gen.apply() pushes the Earth-scale C4 cascade + seaBias so the rendered
|
|
386
|
+
// planet matches the CLI-validated generation without a wasm rebuild).
|
|
387
|
+
import('/src/terrain-gen-controls.js').then(()=>{ try{ window.__gen && window.__gen.apply(); }catch(_){} }).catch(e=>console.warn('gen-controls',e)); })
|
|
388
|
+
.catch(e => { window.__planetOrchStatus='error:'+(e.message||e); console.error('orch init',e); });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- Self-witnessing autopilot (?autopilot). Drives a continuous space->
|
|
393
|
+
// walking descent over a fixed point and posts a rich diagnostic each second
|
|
394
|
+
// so the descent + LOD + terrain can be verified from GET /diag/tail
|
|
395
|
+
// without any external browser tool. Picks a fixed land target, aims the sun
|
|
396
|
+
// at it, descends exponentially from 3R to the surface, then logs altitude,
|
|
397
|
+
// visible maxLevel, quad count, terrain height under the camera, and whether
|
|
398
|
+
// the camera is above the local surface (no clipping).
|
|
399
|
+
if (window.__autopilot){
|
|
400
|
+
const ap = window.__autopilot;
|
|
401
|
+
ap.t += dt;
|
|
402
|
+
// exponential descent: altR from ~2.0 down to ~0.00002 over AP_DURATION s
|
|
403
|
+
const k = Math.min(1, ap.t / 60.0);
|
|
404
|
+
const altR = 2.0 * Math.pow(0.00001/2.0, k); // 2R -> ~64 m
|
|
405
|
+
const tgt = ap.target;
|
|
406
|
+
const r = RADIUS * (1.0 + altR);
|
|
407
|
+
cam.pos = [tgt[0]*r, tgt[1]*r, tgt[2]*r];
|
|
408
|
+
// look gently down toward the surface (basis camera: set fwd/up directly)
|
|
409
|
+
cam.fwd = normalize([-tgt[0], -tgt[1], -tgt[2]]);
|
|
410
|
+
cam.up = normalize([tgt[0], tgt[1]+0.5, tgt[2]]);
|
|
411
|
+
cam.sunLatBase = Math.asin(tgt[1]); cam.sunLonBase = Math.atan2(tgt[0], tgt[2]); cam.timeScale = 0;
|
|
412
|
+
if (ap.t - ap.lastLog >= 1.0){
|
|
413
|
+
ap.lastLog = ap.t;
|
|
414
|
+
const surfElev = jsElev(tgt[0],tgt[1],tgt[2]); // RADIUS-fraction
|
|
415
|
+
const camAltR = Math.hypot(...cam.pos)/RADIUS - 1.0;
|
|
416
|
+
const fr = window.__webglFrame || {};
|
|
417
|
+
postDiag({kind:'autopilot', t_s:+ap.t.toFixed(1), altR:+camAltR.toFixed(6),
|
|
418
|
+
altKm:+(camAltR*RADIUS).toFixed(3), surfKm:+(surfElev*RADIUS).toFixed(3),
|
|
419
|
+
aboveSurf: camAltR > surfElev, quads:fr.quadCount||0, glError:fr.glError});
|
|
420
|
+
}
|
|
421
|
+
if (ap.t > 62){ window.__autopilot = null; postDiag({kind:'autopilot-done'}); }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ===== FREE-FLY CAMERA (single mode, basis vectors) =====
|
|
425
|
+
// Orthonormalize the stored basis (guards drift/NaN), derive right.
|
|
426
|
+
cam.fwd = normalize(cam.fwd);
|
|
427
|
+
let right = cross(cam.fwd, cam.up); const _rl = Math.hypot(...right);
|
|
428
|
+
right = _rl > 1e-6 ? [right[0]/_rl,right[1]/_rl,right[2]/_rl] : [1,0,0];
|
|
429
|
+
cam.up = normalize(cross(right, cam.fwd));
|
|
430
|
+
const localUp = normalize(cam.pos);
|
|
431
|
+
const altR = Math.max(0.0001, Math.hypot(...cam.pos)/RADIUS - 1.0);
|
|
432
|
+
|
|
433
|
+
// Gently ROLL the camera up toward the local surface normal (localUp), more
|
|
434
|
+
// strongly the lower you are -- so flying toward the ground naturally levels to the
|
|
435
|
+
// horizon, but you can still freely pitch/roll. This is the ONLY orientation
|
|
436
|
+
// coupling (no mode switch): rotate the basis about fwd by a small step that
|
|
437
|
+
// reduces the roll angle between cam.up and the localUp projected into the view
|
|
438
|
+
// plane. Strength eases in from ~5000 km and is FULL by ~500 km (user 2026-06-02:
|
|
439
|
+
// 'slow down and reorient at a higher altitude ~500km') -- the camera is horizon-
|
|
440
|
+
// locked by the time the descent reaches 500 km, instead of only below ~50 km.
|
|
441
|
+
{
|
|
442
|
+
const levelStr = (1 - Math.min(1, Math.max(0, (altR*RADIUS - 500.0) / 4500.0))) * 3.0 * dt;
|
|
443
|
+
if (levelStr > 1e-5) {
|
|
444
|
+
// desired up = localUp made perpendicular to fwd
|
|
445
|
+
const d = localUp[0]*cam.fwd[0]+localUp[1]*cam.fwd[1]+localUp[2]*cam.fwd[2];
|
|
446
|
+
let want = [localUp[0]-cam.fwd[0]*d, localUp[1]-cam.fwd[1]*d, localUp[2]-cam.fwd[2]*d];
|
|
447
|
+
const wl = Math.hypot(...want);
|
|
448
|
+
if (wl > 1e-4) {
|
|
449
|
+
want = [want[0]/wl,want[1]/wl,want[2]/wl];
|
|
450
|
+
// signed roll angle from cam.up to want, about fwd
|
|
451
|
+
const cosA = Math.max(-1,Math.min(1, cam.up[0]*want[0]+cam.up[1]*want[1]+cam.up[2]*want[2]));
|
|
452
|
+
const ang = Math.acos(cosA);
|
|
453
|
+
if (ang > 1e-4) {
|
|
454
|
+
const crs = cross(cam.up, want);
|
|
455
|
+
const sign = Math.sign(crs[0]*cam.fwd[0]+crs[1]*cam.fwd[1]+crs[2]*cam.fwd[2]) || 1;
|
|
456
|
+
const step = sign * Math.min(ang, levelStr * ang);
|
|
457
|
+
cam.up = normalize(rotAxis(cam.up, cam.fwd, step));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const forward = cam.fwd;
|
|
463
|
+
|
|
464
|
+
// Free-fly translation: WASD along fwd/right, E/Q along the camera up (so "up"
|
|
465
|
+
// is always away from the planet near the surface). Speed scales with altitude.
|
|
466
|
+
// CAP the per-frame step to a fraction of the altitude above the surface, so a fast
|
|
467
|
+
// forward press can't punch THROUGH the planet in one frame (the "whole planet
|
|
468
|
+
// disappears when moving forward" bug: an uncapped step at high speed jumped the eye
|
|
469
|
+
// from 1.5R to past the core, landing on the far side with the planet behind the
|
|
470
|
+
// camera). Capping makes forward flight asymptotically approach the surface instead.
|
|
471
|
+
// GROUND HEIGHT FROM THE GPU PROBE, NOT THE CPU MIRROR (movestep-zero-speed-root, user 2026-06-06:
|
|
472
|
+
// 'in some spots the camera hits zero speed before hitting the surface'). altSurfElev fed the
|
|
473
|
+
// anti-tunnel clamp (line below) AND altKmAbove; it was sourced from jsBroadShape (the CPU mirror),
|
|
474
|
+
// which the collision path itself documents as DIVERGING from the GPU-rendered surface. Where the
|
|
475
|
+
// mirror OVER-predicts, RADIUS*(1+altSurfElev) sits ABOVE the true surface -> the surface-gap goes
|
|
476
|
+
// <=0 while the camera is still visibly above ground -> moveStep clamps to ~0.5km = a dead stop
|
|
477
|
+
// before the surface. FIX: source the same render.sampleGroundM the collision clamp uses (GPU-exact,
|
|
478
|
+
// == the rendered vH) so the move-gap and the collision surface are the SAME height. Fall back to the
|
|
479
|
+
// mirror only if the probe is unavailable.
|
|
480
|
+
const _orchMv = window.__planetOrch;
|
|
481
|
+
const _camLenMv = Math.hypot(...cam.pos) || 1;
|
|
482
|
+
const _camDirMv = [cam.pos[0]/_camLenMv, cam.pos[1]/_camLenMv, cam.pos[2]/_camLenMv];
|
|
483
|
+
// ALTITUDE GATE (perf sweep 2026-06-11): sampleGroundM is a synchronous 1px gl.readPixels = a FULL
|
|
484
|
+
// GPU pipeline stall every frame. Above ~20km the probe's precision is irrelevant (collision margin
|
|
485
|
+
// is 2m, max terrain ~15km, the jsBroadShape mirror is within a few hundred m) -- skip the stall and
|
|
486
|
+
// take the existing CPU-mirror fallback. Below 20km (where collision is load-bearing) keep GPU-exact.
|
|
487
|
+
const _altRoughKm = _camLenMv - RADIUS;
|
|
488
|
+
const _gpuGM = (_altRoughKm < 20.0 && _orchMv && _orchMv.render && _orchMv.render.sampleGroundM) ? _orchMv.render.sampleGroundM(_camDirMv) : null;
|
|
489
|
+
const _surfM = (_gpuGM != null && isFinite(_gpuGM)) ? Math.max(0, _gpuGM)
|
|
490
|
+
: Math.max(0, jsBroadShape(cam.pos[0],cam.pos[1],cam.pos[2]));
|
|
491
|
+
const altSurfElev = Math.max(0, Math.min(_surfM, 9500.0)) / WEBGL2_TERRAIN_R_M;
|
|
492
|
+
const altKmAbove = Math.max(1e-3, (Math.hypot(...cam.pos) - RADIUS*(1.0 + altSurfElev)));
|
|
493
|
+
// CAMERA MOVE-STEP. NO ELEVATION SLOWDOWN (user 2026-06-05: 'get rid of the camera slowdown code').
|
|
494
|
+
// The step scales with altitude-above-surface ONLY so the scene still moves at orbit (a constant step
|
|
495
|
+
// is invisible 12000km out); that altitude scaling gives a CONSTANT screen-relative feel, not a
|
|
496
|
+
// slowdown. The previous `Math.min(.., altKmAbove*0.85)` cap is REMOVED -- it shrank the step as you
|
|
497
|
+
// neared the ground (altKmAbove->small) = the near-ground crawl the user disliked. A FIXED minimum
|
|
498
|
+
// floor keeps a usable speed right at the deck, and the punch-through guard is now a hard clamp to a
|
|
499
|
+
// generous fraction of the surface-gap that NEVER crawls (it only catches an extreme single-frame jump).
|
|
500
|
+
let moveStep = dt * cam.speed * altKmAbove * 1.0;
|
|
501
|
+
moveStep = Math.max(moveStep, dt * cam.speed * 8.0); // fixed near-ground floor (km/frame @ slider) -> no crawl (2.0->8.0, user 2026-06-09: deck crawl during close-ground work; 4x faster floor, speed slider still scales on top)
|
|
502
|
+
moveStep = Math.min(moveStep, Math.hypot(...cam.pos) - RADIUS*(1.0 + altSurfElev) + 0.5); // hard anti-tunnel guard only
|
|
503
|
+
if (keys['w']) { cam.pos[0]+=forward[0]*moveStep; cam.pos[1]+=forward[1]*moveStep; cam.pos[2]+=forward[2]*moveStep; }
|
|
504
|
+
if (keys['s']) { cam.pos[0]-=forward[0]*moveStep; cam.pos[1]-=forward[1]*moveStep; cam.pos[2]-=forward[2]*moveStep; }
|
|
505
|
+
if (keys['a']) { cam.pos[0]-=right[0]*moveStep; cam.pos[1]-=right[1]*moveStep; cam.pos[2]-=right[2]*moveStep; }
|
|
506
|
+
if (keys['d']) { cam.pos[0]+=right[0]*moveStep; cam.pos[1]+=right[1]*moveStep; cam.pos[2]+=right[2]*moveStep; }
|
|
507
|
+
if (keys['e']) { cam.pos[0]+=localUp[0]*moveStep; cam.pos[1]+=localUp[1]*moveStep; cam.pos[2]+=localUp[2]*moveStep; }
|
|
508
|
+
if (keys['q']) { cam.pos[0]-=localUp[0]*moveStep; cam.pos[1]-=localUp[1]*moveStep; cam.pos[2]-=localUp[2]*moveStep; }
|
|
509
|
+
|
|
510
|
+
// Minimal collision: free-fly can go as low as it wants but not THROUGH the
|
|
511
|
+
// terrain. Keep the eye a small margin (eyeHeight ~2 m) above the local surface.
|
|
512
|
+
// No "walking floor" -- this is just so you can't fall through the ground.
|
|
513
|
+
const r = Math.hypot(...cam.pos);
|
|
514
|
+
// COLLISION = GPU HEIGHT READBACK (user-chosen): read the EXACT rendered terrain height under the
|
|
515
|
+
// camera straight from the GPU (render.sampleGroundM runs the same hpfSample+broadShapeM the mesh
|
|
516
|
+
// VS uses, into a 1px R32F target). This can NEVER diverge from the rendered surface (the old CPU
|
|
517
|
+
// jsBroadShape mirror under-predicted -> camera went under). Fall back to the mirror only if the
|
|
518
|
+
// probe is unavailable. + a small vDisp micro-relief margin so the sub-vertex relief is cleared.
|
|
519
|
+
// REUSE the GPU ground sample taken above for moveStep (_gpuGM) so the move-gap surface and the
|
|
520
|
+
// collision surface are bit-identical (movestep-zero-speed-root) and we do ONE 1px readback per frame.
|
|
521
|
+
let renderedM;
|
|
522
|
+
const gpuM = _gpuGM;
|
|
523
|
+
// The GPU probe sampleGroundM IS the exact rendered terrain height under the camera (same
|
|
524
|
+
// hpfSample+broadShapeM the mesh VS uses), so no large clearance margin is needed. The old +130m
|
|
525
|
+
// "vDisp micro-relief margin" parked the eye ~132m above the ground (user: "place us 2.0m from the
|
|
526
|
+
// ground" was failing). Use a SMALL ~3m margin for genuine sub-vertex micro-relief only, so the eye
|
|
527
|
+
// lands eyeHeight (~2m) above the true surface.
|
|
528
|
+
// NO clearance margin: the GPU probe sampleGroundM IS the exact rendered terrain height under the
|
|
529
|
+
// camera (same hpfSample+broadShapeM the mesh VS uses), so the clamp surface == the true ground and
|
|
530
|
+
// the eye lands exactly eyeHeight (2.0m) above it. (The old +130m, then +3m, margin made __altM read
|
|
531
|
+
// a height above an inflated reference -> the eye was really ground+margin+2m, not 2m.) The mirror
|
|
532
|
+
// fallback keeps a small +3m only because the CPU jsBroadShape can under-predict vs the GPU.
|
|
533
|
+
if (gpuM != null && isFinite(gpuM)) {
|
|
534
|
+
renderedM = Math.max(0, gpuM); // GPU-exact rendered height; no margin
|
|
535
|
+
} else {
|
|
536
|
+
renderedM = Math.max(0, jsBroadShape(cam.pos[0], cam.pos[1], cam.pos[2])) + 3.0; // mirror fallback (CPU under-predicts)
|
|
537
|
+
}
|
|
538
|
+
// cap = composeHeight's true ceiling CMAX=15000 (terrain.glsl:448), NOT 11000: the GPU probe
|
|
539
|
+
// renderedM is honest up to 15km, but re-capping at 11000 before minR let the camera floor sit
|
|
540
|
+
// BELOW visible 11-15km peaks -> tunnel through the rendered geometry (collision-elev-margin, wb4syopmo).
|
|
541
|
+
const surfElev = Math.max(0.0, Math.min(renderedM, 15000.0)) / WEBGL2_TERRAIN_R_M; // fraction of R (match CMAX)
|
|
542
|
+
const eyeHeight = 2.0 / WEBGL2_M_PER_UNIT; // EXACTLY 2.0 m above ground (km-units = 2m / m-per-unit)
|
|
543
|
+
const minR = RADIUS * (1.0 + surfElev) + eyeHeight;
|
|
544
|
+
if (r < minR) { const s = minR/r; cam.pos[0]*=s; cam.pos[1]*=s; cam.pos[2]*=s; }
|
|
545
|
+
const maxR = RADIUS * 50;
|
|
546
|
+
if (r > maxR) { const s = maxR/r; cam.pos[0]*=s; cam.pos[1]*=s; cam.pos[2]*=s; }
|
|
547
|
+
|
|
548
|
+
// height of the eye above the local ground, in metres (for the HUD readout).
|
|
549
|
+
const heightAboveGroundM = (Math.hypot(...cam.pos) - RADIUS*(1.0 + Math.max(0,surfElev))) * 1000;
|
|
550
|
+
window.__altM = heightAboveGroundM;
|
|
551
|
+
|
|
552
|
+
// The camera (autopilot + free-fly + clamp) has now been fully updated this frame,
|
|
553
|
+
// so render the authentic Proland WebGL2 planet with the CURRENT camera. This is
|
|
554
|
+
// the sole render path; it advances the sun, draws via the orchestrator, updates
|
|
555
|
+
// the HUD/title/diag, schedules the next frame, and returns.
|
|
556
|
+
if (window.__useWebGL2Terrain && window.__planetOrch) {
|
|
557
|
+
// CONTEXT-LOSS TOLERANCE (2026-06-11 AMD crash class): drawing on a lost context wedges the
|
|
558
|
+
// page (witnessed: evaluate timeouts after WEBGL_lose_context). Skip the draw, keep the rAF
|
|
559
|
+
// loop + HUD + banner alive; rendering resumes if the context is restored (or the user reloads).
|
|
560
|
+
if (window.__planetOrchGL && window.__planetOrchGL.isContextLost && window.__planetOrchGL.isContextLost()) {
|
|
561
|
+
scheduleFrame(); return;
|
|
562
|
+
}
|
|
563
|
+
const gw = document.getElementById('cwebgl');
|
|
564
|
+
// keep the drawing buffer matched to the window (x DPR). See the init note: canvas IS
|
|
565
|
+
// #cwebgl, so size from the viewport, not canvas.width (which would lock to 300).
|
|
566
|
+
{ const _dpr = Math.min(window.devicePixelRatio||1, 2); const tw=Math.round(innerWidth*_dpr), th=Math.round(innerHeight*_dpr);
|
|
567
|
+
if (gw.width!==tw||gw.height!==th){ gw.width=tw; gw.height=th; } }
|
|
568
|
+
// Sun longitude = HUD base (sunAzimuth) + accumulated rotation (timeScale slider).
|
|
569
|
+
cam.sunLonAccum += dt * 1000.0 * cam.sunLonRate * cam.timeScale;
|
|
570
|
+
const sM = WEBGL2_M_PER_UNIT;
|
|
571
|
+
const eyeM = [cam.pos[0]*sM, cam.pos[1]*sM, cam.pos[2]*sM];
|
|
572
|
+
const tgtM = [eyeM[0]+cam.fwd[0]*WEBGL2_TERRAIN_R_M, eyeM[1]+cam.fwd[1]*WEBGL2_TERRAIN_R_M, eyeM[2]+cam.fwd[2]*WEBGL2_TERRAIN_R_M];
|
|
573
|
+
const _sLon = cam.sunLonBase + cam.sunLonAccum, _sLat = cam.sunLatBase||0;
|
|
574
|
+
const _sun = [Math.cos(_sLat)*Math.sin(_sLon), Math.sin(_sLat), Math.cos(_sLat)*Math.cos(_sLon)];
|
|
575
|
+
window.__lastSun = _sun;
|
|
576
|
+
oceanTime += dt * cam.oceanTimeScale; // Wave-speed slider scales the ocean clock
|
|
577
|
+
// displayMode precedence pick (NOT bitwise OR): window.__displayMode is the diagnostic override
|
|
578
|
+
// (set by __dbg.setDisplayMode/census/aimDir), cam.displayMode is the Render dropdown. The old
|
|
579
|
+
// (a|b) merged them with bitwise OR -- a|b only equals b when a===0, so a stale __displayMode left
|
|
580
|
+
// at 2 by a diagnostic turned a dropdown pick of 1 into 2|1=3 (invalid mode -> silent lit) = the
|
|
581
|
+
// 'render menu does nothing' report. Explicit precedence: override wins when set, else the menu.
|
|
582
|
+
const _dmEff = (window.__displayMode != null ? window.__displayMode : cam.displayMode) | 0;
|
|
583
|
+
const fr = window.__planetOrch.frame(eyeM, tgtM, 45*Math.PI/180, _dmEff, _sun, oceanTime, cam.up);
|
|
584
|
+
window.__webglFrame = fr;
|
|
585
|
+
// First rendered frame: tear down the loading overlay (compile finished, terrain is on screen).
|
|
586
|
+
if (window.__loadOverlay !== false) { const ov=document.getElementById('__loadOverlay');
|
|
587
|
+
if (ov) ov.remove(); if (window.__loadTick) { clearInterval(window.__loadTick); window.__loadTick=null; } window.__loadOverlay=false; }
|
|
588
|
+
// Passive per-frame health vector -- O(1) read for a witness, no async frames()
|
|
589
|
+
// sweep needed (catches transient glError spikes during camera motion too).
|
|
590
|
+
window.__diagMetrics = { status:window.__planetOrchStatus||'init', quadCount:fr.quadCount|0,
|
|
591
|
+
residentCount:fr.residentCount|0, fallbackCount:fr.fallbackCount|0, culledCount:fr.culledCount|0,
|
|
592
|
+
glError:fr.glError|0, altKm:+(Math.hypot(...cam.pos)-RADIUS).toFixed(1),
|
|
593
|
+
displayMode:_dmEff, pageErr:window.__pageErr||null, t:performance.now()|0 };
|
|
594
|
+
|
|
595
|
+
// FPS (EMA over the true inter-frame interval) + HUD/title.
|
|
596
|
+
const mspfInstant = Math.max(0.001, frameMs);
|
|
597
|
+
emaMspf = (emaMspf === 0) ? mspfInstant : (emaMspf * 0.9 + mspfInstant * 0.1);
|
|
598
|
+
const fps = 1000 / emaMspf;
|
|
599
|
+
document.title = `TerrainView8 - fps: ${fps.toFixed(6)}, mspf: ${emaMspf.toFixed(6)}`;
|
|
600
|
+
frame++;
|
|
601
|
+
const camDistR = Math.hypot(...cam.pos)/RADIUS;
|
|
602
|
+
const quadN = (fr && fr.quadCount) || 0;
|
|
603
|
+
if (frame % diagInterval === 0) {
|
|
604
|
+
// ENRICHED DIAG (2026-06-07): carry the atlas/height state so /diag/tail diagnoses the flat-land +
|
|
605
|
+
// biome-banding headlessly. Sample the GPU ground height + its lateral variation under the camera:
|
|
606
|
+
// a RICH range = relief present; a FLAT (~constant) range with atlas on = the atlas height is being
|
|
607
|
+
// read flat (the user's 'land looks flat'). hasAtlas tells which path is live.
|
|
608
|
+
// PROBE BURST REMOVED (perf sweep 2026-06-11): this block ran 5 back-to-back sampleGroundM
|
|
609
|
+
// draws+readPixels (5 serialized pipeline stalls in one frame = a 1Hz hitch at the deck).
|
|
610
|
+
// groundM keeps ONE probe (center sample); the lateral hRangeM forensic moved to the on-demand
|
|
611
|
+
// window.__diag.hRange() -- same data, zero steady-state cost.
|
|
612
|
+
let hSamp = null, hRangeM = null;
|
|
613
|
+
try {
|
|
614
|
+
const orch = window.__planetOrch, sg = orch && orch.render && orch.render.sampleGroundM;
|
|
615
|
+
if (sg) {
|
|
616
|
+
const p = window.__cam.pos, L = Math.hypot(p[0],p[1],p[2])||1;
|
|
617
|
+
const h0 = sg([p[0]/L, p[1]/L, p[2]/L]);
|
|
618
|
+
if (h0 != null && isFinite(h0)) hSamp = +h0.toFixed(0);
|
|
619
|
+
}
|
|
620
|
+
} catch(_) {}
|
|
621
|
+
postDiag({kind:'frame', frame, fps, mspf:emaMspf, altR:camDistR-1.0,
|
|
622
|
+
quads:quadN, glError:fr&&fr.glError, resident:fr&&fr.residentCount,
|
|
623
|
+
cw:canvas.width, ch:canvas.height,
|
|
624
|
+
floatLinearOK: window.__floatLinearOK,
|
|
625
|
+
// TRIPWIRE (2026-06-11, recurring class): non-zero bakePending while the terrain looks
|
|
626
|
+
// grey/rocky/flat = the UNBAKED-HPF window (see AGENTS.md recurring-class section).
|
|
627
|
+
bakePending: (window.__initTimings||{}).bakeFacesPending,
|
|
628
|
+
// software-WebGL / context-loss tripwires (the AMD crash class): non-null = degraded session
|
|
629
|
+
swgl: window.__softwareWebGL ? 1 : undefined, ctxLostAt: window.__ctxLostAt,
|
|
630
|
+
groundM: hSamp, groundRangeM: hRangeM});
|
|
631
|
+
}
|
|
632
|
+
if (frame % 30 === 0) {
|
|
633
|
+
const altR=(camDistR-1.0); const altM = altR * 6371000;
|
|
634
|
+
const altStr = altM > 1e6 ? `${(altM/1e6).toFixed(1)}Mm`
|
|
635
|
+
: altM > 1e3 ? `${(altM/1e3).toFixed(1)}km` : `${altM.toFixed(0)}m`;
|
|
636
|
+
const hg = window.__altM || 0;
|
|
637
|
+
const hgStr = Math.abs(hg) > 1000 ? `${(hg/1000).toFixed(2)}km` : `${hg.toFixed(1)}m`;
|
|
638
|
+
let line = `ground ${hgStr} . alt ${altStr} . quads ${quadN} . spd ${cam.speed.toFixed(2)} . ${canvas.width}x${canvas.height} . fps ${fps.toFixed(1)}`;
|
|
639
|
+
// TOTAL-CLARITY HUD (2026-06-12 tooling): which GPU/backend this session actually runs, plus the
|
|
640
|
+
// degraded-state tripwires. A day of diagnosis was lost to not knowing at a glance which stack a
|
|
641
|
+
// window used and whether a debug view had silently failed.
|
|
642
|
+
{ const gr = window.__gpuRenderer || '';
|
|
643
|
+
const short = /vulkan/i.test(gr) ? 'vk' : /d3d11|direct3d/i.test(gr) ? 'd3d11' : /swiftshader|software/i.test(gr) ? 'SOFTWARE' : gr ? 'gl?' : '?';
|
|
644
|
+
const vendor = /nvidia/i.test(gr) ? 'nv' : /amd|radeon/i.test(gr) ? 'amd' : /intel/i.test(gr) ? 'intel' : '?';
|
|
645
|
+
line += ` || ${vendor}/${short}`;
|
|
646
|
+
if (window.__softwareWebGL) line += ' SWGL!';
|
|
647
|
+
if (window.__ctxLostAt) line += ' CTXLOST!';
|
|
648
|
+
const bp = (window.__initTimings||{}).bakeFacesPending|0; if (bp > 0) line += ` bake:${bp}`;
|
|
649
|
+
const dmReq = (window.__displayMode != null ? window.__displayMode : cam.displayMode)|0;
|
|
650
|
+
if (dmReq !== 0 && dmReq !== 2 && dmReq !== 4 && window.__debugProgState && window.__debugProgState !== 'ready')
|
|
651
|
+
line += ` DBGVIEW ${window.__debugProgState.slice(0,40)}`;
|
|
652
|
+
}
|
|
653
|
+
// CULL DEBUG HUD (toggle with 'c'): show cull state + the false-cull signature live so the
|
|
654
|
+
// user can SEE when the frustum cull is wrongly dropping on-screen quads. culledOnScreen>0
|
|
655
|
+
// (in RED) is the bug; 'cached' frame means the static-camera re-render path (not a rebuild).
|
|
656
|
+
if (window.__cullDebug) {
|
|
657
|
+
const cs = window.__cullStats || {};
|
|
658
|
+
const bad = (cs.culledOnScreen|0) > 0;
|
|
659
|
+
line += ` || CULL[${window.__frustumCull?'ON':'off'}] ${cs.frame||'?'} kept ${cs.kept|0} culled ${cs.culled|0} ` +
|
|
660
|
+
(bad ? `BADcull-on-screen ${cs.culledOnScreen}` : `onScreenCulled ${cs.culledOnScreen|0}`);
|
|
661
|
+
stats.style.color = bad ? '#ff5555' : '';
|
|
662
|
+
} else { stats.style.color = ''; }
|
|
663
|
+
stats.textContent = line;
|
|
664
|
+
}
|
|
665
|
+
scheduleFrame();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// No orchestrator yet (still initialising) -- keep the loop alive.
|
|
669
|
+
scheduleFrame();
|
|
670
|
+
}
|
|
671
|
+
// Self-witnessing autopilot: enable with ?autopilot. Picks a fixed high-
|
|
672
|
+
// elevation land target (deterministic scan), so the descent trace in
|
|
673
|
+
// GET /diag/tail is reproducible without any browser tool.
|
|
674
|
+
if (location.search.indexOf('autopilot') >= 0){
|
|
675
|
+
let best=null, bestE=-1;
|
|
676
|
+
for (let i=0;i<4000;i++){
|
|
677
|
+
const a = i*2.399963, y = 1 - 2*(i+0.5)/4000; // fibonacci-ish sphere scan
|
|
678
|
+
const rxy = Math.sqrt(Math.max(0,1-y*y));
|
|
679
|
+
const d = [Math.cos(a)*rxy, y, Math.sin(a)*rxy];
|
|
680
|
+
const e = jsElev(d[0],d[1],d[2]);
|
|
681
|
+
if (e > bestE){ bestE = e; best = d; }
|
|
682
|
+
}
|
|
683
|
+
window.__autopilot = { t:0, lastLog:-1, target:best, yaw:0 };
|
|
684
|
+
postDiag({kind:'autopilot-start', targetElevKm:+(bestE*RADIUS).toFixed(3)});
|
|
685
|
+
}
|
|
686
|
+
scheduleFrame();
|
|
687
|
+
// HIDDEN-TAB INIT BOOTSTRAP (2026-06-12 'total clarity' tooling): rAF NEVER fires in a minimized or
|
|
688
|
+
// hidden window, and the whole init chain (GL context, shader compile, HPF bake) lives inside
|
|
689
|
+
// frameLoop -- so a backgrounded tab sat at 'init' FOREVER with zero signal (the day's recurring
|
|
690
|
+
// stuck-at-init mechanism; the compile poll's document.hidden fallback in gl-render only helps once
|
|
691
|
+
// init has started). Tick frameLoop on a timer while the page is hidden so init runs and the page
|
|
692
|
+
// reaches 'ready' regardless of visibility; rendering proper still happens on rAF when visible.
|
|
693
|
+
setInterval(() => { if (document.hidden) { try { frameLoop(); } catch (_) {} } }, 250);
|
|
694
|
+
// Wire HUD panel inputs to cam.
|
|
695
|
+
const $ = id => document.getElementById(id);
|
|
696
|
+
// Sun by ZENITH (0=overhead, 90=horizon, >90=below) + AZIMUTH (compass), the
|
|
697
|
+
// author's control convention. Map to the internal lat/lon sun vector:
|
|
698
|
+
// elevation above horizon = 90 - zenith => sunLatBase = (90-zenith)*PI/180
|
|
699
|
+
// azimuth (deg) => sunLonBase = azimuth*PI/180
|
|
700
|
+
$('sunZenith').addEventListener('input', e => { cam.sunLatBase = (90 - +e.target.value) * Math.PI/180; });
|
|
701
|
+
$('sunAzimuth').addEventListener('input', e => { cam.sunLonBase = (+e.target.value) * Math.PI/180; });
|
|
702
|
+
$('timeScale').addEventListener('input', e => { cam.timeScale = +e.target.value; });
|
|
703
|
+
$('exposure').addEventListener('input', e => { cam.exposure = +e.target.value; });
|
|
704
|
+
$('wireframe').addEventListener('change', e => { cam.wireframe = e.target.checked; window.__wireframe = e.target.checked ? 1 : 0; });
|
|
705
|
+
// OCEAN HUD REMOVED (user 2026-06-02: 'get rid of the ocean options'). The terrain.glsl ocean
|
|
706
|
+
// water branch still renders with FIXED sane defaults set here (no sliders, no listeners on the
|
|
707
|
+
// deleted #o* elements -> no JS throw). oceanTimeScale keeps the waves animating gently.
|
|
708
|
+
cam.oceanAmplitude = 1.0 * (0.4 + 5 / 12.0); // == old default (windSpeed 5)
|
|
709
|
+
cam.oceanChoppiness = 1.6 * (0.5 + 0.5); // == old default (seaState 0.5)
|
|
710
|
+
cam.oceanFoam = 0.5;
|
|
711
|
+
cam.oceanTimeScale = 1.0;
|
|
712
|
+
// Display-mode toggle (tv-display-modes-toggle). Skirts removed: LOD cracks are now hidden
|
|
713
|
+
// by the one-cell overlap ring in the mesh (proland-gl-render), not a dropped skirt curtain.
|
|
714
|
+
// User picking from the dropdown is an explicit action that must WIN over any stale diagnostic
|
|
715
|
+
// override (window.__displayMode left set by __dbg.setDisplayMode/census/aimDir). Clear the override
|
|
716
|
+
// so the menu value takes effect immediately -- otherwise the precedence pick in the render loop
|
|
717
|
+
// keeps showing the stale override and the menu appears dead.
|
|
718
|
+
$('displayMode').addEventListener('change', e => { window.__displayMode = null; cam.displayMode = +e.target.value; });
|
|
719
|
+
// Expose the camera + jsElev for headless witness / descent tests.
|
|
720
|
+
window.__qt = { jsElev, RADIUS, get pxPerVert(){ return window.__pxPerVert || 0; } };
|
|
721
|
+
window.__planet = { cam, get gl(){ return window.__planetOrchGL || null; }, get orch(){ return window.__planetOrch || null; } };
|
|
722
|
+
|
|
723
|
+
// Headless pixel readback on the WebGL2 canvas (#cwebgl). preserveDrawingBuffer:true
|
|
724
|
+
// is set on the context so gl.readPixels returns the actual rendered frame.
|
|
725
|
+
function readPixelAt(x, y) {
|
|
726
|
+
const gl = window.__planetOrchGL; if (!gl) return {err:'no-gl'};
|
|
727
|
+
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
|
|
728
|
+
const xi = Math.max(0, Math.min(w-1, x|0));
|
|
729
|
+
const yi = Math.max(0, Math.min(h-1, y|0));
|
|
730
|
+
const p = new Uint8Array(4);
|
|
731
|
+
// WebGL origin is bottom-left; flip y so callers can pass top-left coords.
|
|
732
|
+
gl.readPixels(xi, h-1-yi, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, p);
|
|
733
|
+
return { r:p[0], g:p[1], b:p[2], a:p[3] };
|
|
734
|
+
}
|
|
735
|
+
window.__readPixelAt = readPixelAt;
|
|
736
|
+
window.__readCenterPixel = () => { const gl=window.__planetOrchGL; return gl ? readPixelAt(gl.drawingBufferWidth/2|0, gl.drawingBufferHeight/2|0) : {err:'no-gl'}; };
|
|
737
|
+
// Fraction of non-clear pixels over the whole #cwebgl buffer (terrain/ocean/sky
|
|
738
|
+
// coverage witness). Clear color is the dark space blue (~<12,<14,<30).
|
|
739
|
+
window.__coverage = () => {
|
|
740
|
+
const gl = window.__planetOrchGL; if (!gl) return {err:'no-gl'};
|
|
741
|
+
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
|
|
742
|
+
const px = new Uint8Array(w*h*4);
|
|
743
|
+
gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px);
|
|
744
|
+
let nonClear=0; for(let i=0;i<w*h;i++){ const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; if(!(r<12&&g<14&&b<30)) nonClear++; }
|
|
745
|
+
return { w, h, coverage:nonClear/(w*h) };
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// ===========================================================================
|
|
749
|
+
// window.__dbg -- debug + test harness. Units are REAL km (RADIUS=6371). All
|
|
750
|
+
// camera moves drive the free-fly camera the WebGL2 render loop reads each frame.
|
|
751
|
+
// ===========================================================================
|
|
752
|
+
window.__dbg = {
|
|
753
|
+
R: RADIUS,
|
|
754
|
+
cam,
|
|
755
|
+
// --- state probes ---
|
|
756
|
+
altKm(){ return Math.hypot(...cam.pos) - RADIUS; },
|
|
757
|
+
altM(){ return this.altKm()*1000; },
|
|
758
|
+
groundM(){ return window.__altM || 0; }, // height above terrain (m)
|
|
759
|
+
status(){ return window.__planetOrchStatus || 'init'; }, // orchestrator init state
|
|
760
|
+
frame(){ return window.__webglFrame || null; }, // last orchestrator frame stats
|
|
761
|
+
glError(){ const f=window.__webglFrame; return f ? f.glError : null; },
|
|
762
|
+
quads(){ const f=window.__webglFrame; return f ? f.quadCount : 0; },
|
|
763
|
+
pageErr(){ return window.__pageErr || null; },
|
|
764
|
+
coverage(){ return window.__coverage(); },
|
|
765
|
+
// one-shot snapshot a witness reads.
|
|
766
|
+
state(){ const f=window.__webglFrame||{}; return {
|
|
767
|
+
status:this.status(), altKm:+this.altKm().toFixed(3), groundM:+this.groundM().toFixed(1),
|
|
768
|
+
quads:f.quadCount||0, glError:f.glError, resident:f.residentCount,
|
|
769
|
+
fwd:cam.fwd.map(v=>+v.toFixed(4)), pos:cam.pos.map(v=>+v.toFixed(2)),
|
|
770
|
+
cw:canvas.width, ch:canvas.height,
|
|
771
|
+
sun:(window.__lastSun||[]).map(v=>+v.toFixed(4)), exposure:cam.exposure,
|
|
772
|
+
pageErr:window.__pageErr||null,
|
|
773
|
+
}; },
|
|
774
|
+
// --- camera control (real units) ---
|
|
775
|
+
goto(altKm=1.5, pitchDown=0.4){
|
|
776
|
+
const p=cam.pos, len=Math.hypot(...p)||1, k=(RADIUS+altKm)/len;
|
|
777
|
+
aimAt([p[0]*k,p[1]*k,p[2]*k], [-p[0],-p[1]+pitchDown*len,-p[2]]);
|
|
778
|
+
return this.altKm();
|
|
779
|
+
},
|
|
780
|
+
space(){ gotoSpaceView(); return this.altKm(); },
|
|
781
|
+
lookDown(){ const p=cam.pos; aimAt([p[0],p[1],p[2]], [-p[0],-p[1],-p[2]]); return cam.fwd.slice(); },
|
|
782
|
+
aim(pos, dir){ aimAt(pos, dir); return {pos:cam.pos.slice(), fwd:cam.fwd.slice()}; },
|
|
783
|
+
gotoOblique(altKm=2, dip=0.3){
|
|
784
|
+
const p=cam.pos, len=Math.hypot(...p)||1, k=(RADIUS+altKm)/len;
|
|
785
|
+
const np=[p[0]*k,p[1]*k,p[2]*k]; const u=[np[0]/(RADIUS+altKm),np[1]/(RADIUS+altKm),np[2]/(RADIUS+altKm)];
|
|
786
|
+
let t=[u[1],-u[0],0]; const tl=Math.hypot(...t)||1; t=[t[0]/tl,t[1]/tl,t[2]/tl];
|
|
787
|
+
aimAt(np, [t[0]-u[0]*dip, t[1]-u[1]*dip, t[2]-u[2]*dip]); return this.altKm();
|
|
788
|
+
},
|
|
789
|
+
gotoDown(altKm=2){ const p=cam.pos, len=Math.hypot(...p)||1, k=(RADIUS+altKm)/len;
|
|
790
|
+
const np=[p[0]*k,p[1]*k,p[2]*k]; aimAt(np, [-np[0],-np[1],-np[2]]); return this.altKm(); },
|
|
791
|
+
yaw(rad=0.5){
|
|
792
|
+
const f=cam.fwd, u=cam.up, ca=Math.cos(rad), sa=Math.sin(rad);
|
|
793
|
+
const dot=f[0]*u[0]+f[1]*u[1]+f[2]*u[2];
|
|
794
|
+
const cx=u[1]*f[2]-u[2]*f[1], cy=u[2]*f[0]-u[0]*f[2], cz=u[0]*f[1]-u[1]*f[0];
|
|
795
|
+
cam.fwd=[f[0]*ca+cx*sa+u[0]*dot*(1-ca), f[1]*ca+cy*sa+u[1]*dot*(1-ca), f[2]*ca+cz*sa+u[2]*dot*(1-ca)];
|
|
796
|
+
return cam.fwd.slice();
|
|
797
|
+
},
|
|
798
|
+
// await N animation frames (deterministic witness timing).
|
|
799
|
+
frames(n=1){ return new Promise(res=>{ let k=0; const step=()=>{ if(++k>=n) res(k); else requestAnimationFrame(step); }; requestAnimationFrame(step); }); },
|
|
800
|
+
// resolution-independent pixel readback: fx,fy in [0,1] (top-left origin).
|
|
801
|
+
pixelFrac(fx=0.5, fy=0.5){
|
|
802
|
+
const gl=window.__planetOrchGL; if(!gl) return {err:'no-gl'};
|
|
803
|
+
return readPixelAt(Math.round(fx*(gl.drawingBufferWidth-1)), Math.round(fy*(gl.drawingBufferHeight-1)));
|
|
804
|
+
},
|
|
805
|
+
// descentTrace: sample state at a list of altitudes (km) looking down.
|
|
806
|
+
async descentTrace(alts=[12000,1200,120,12,2]){
|
|
807
|
+
const out=[];
|
|
808
|
+
for(const km of alts){ this.gotoDown(km); await this.frames(5);
|
|
809
|
+
out.push({ km, alt:+this.altKm().toFixed(2), quads:this.quads(), glError:this.glError() }); }
|
|
810
|
+
return out;
|
|
811
|
+
},
|
|
812
|
+
// --- census: the reliable close-up metric. Set displayMode, render N frames,
|
|
813
|
+
// gl.finish, readPixels the WHOLE canvas, return coverage + distinct-color +
|
|
814
|
+
// mean RGB + per-quadrant coverage. The one trustworthy headless witness. ---
|
|
815
|
+
setDisplayMode(m){ window.__displayMode = m|0; cam.displayMode = m|0; return window.__displayMode; },
|
|
816
|
+
async census(displayMode){
|
|
817
|
+
if(displayMode!==undefined) this.setDisplayMode(displayMode);
|
|
818
|
+
await this.frames(6);
|
|
819
|
+
const gl=window.__planetOrchGL; if(!gl) return {err:'no-gl'};
|
|
820
|
+
gl.finish();
|
|
821
|
+
const w=gl.drawingBufferWidth, h=gl.drawingBufferHeight;
|
|
822
|
+
const px=new Uint8Array(w*h*4); gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px);
|
|
823
|
+
let nz=0, rs=0,gs=0,bs=0; const colors=new Set(); const quad=[0,0,0,0]; const qn=[0,0,0,0];
|
|
824
|
+
for(let y=0;y<h;y++) for(let x=0;x<w;x++){ const i=(y*w+x)*4, r=px[i],g=px[i+1],b=px[i+2];
|
|
825
|
+
const qi=(y<h/2?0:2)+(x<w/2?0:1); qn[qi]++;
|
|
826
|
+
if(r>8||g>8||b>8){ nz++; rs+=r;gs+=g;bs+=b; quad[qi]++;
|
|
827
|
+
if(colors.size<4096) colors.add((r>>3<<10)|(g>>3<<5)|(b>>3)); } }
|
|
828
|
+
return { displayMode:window.__displayMode|0, cov:+(nz/(w*h)).toFixed(3),
|
|
829
|
+
distinctColors:colors.size, meanRGB: nz?[Math.round(rs/nz),Math.round(gs/nz),Math.round(bs/nz)]:[0,0,0],
|
|
830
|
+
quadrants: quad.map((c,i)=>+(c/(qn[i]||1)).toFixed(3)), w, h,
|
|
831
|
+
quads:this.quads(), glError:this.glError() };
|
|
832
|
+
},
|
|
833
|
+
// descend deterministically to altKm looking straight down, settle, census.
|
|
834
|
+
async descend(altKm=2, displayMode){ this.gotoDown(altKm); await this.frames(5); return this.census(displayMode); },
|
|
835
|
+
// fpsProbe: time orchestrator.frame() directly N times (HUD fps is rAF-throttled
|
|
836
|
+
// headless -- this is the real GPU timing). Returns ms/frame + fps.
|
|
837
|
+
fpsProbe(n=60){
|
|
838
|
+
const orch=window.__planetOrch; if(!orch) return {err:'no-orch'};
|
|
839
|
+
const eyeM=cam.pos.map(v=>v*WEBGL2_M_PER_UNIT), tgtM=cam.pos.map((v,i)=>(v+cam.fwd[i])*WEBGL2_M_PER_UNIT);
|
|
840
|
+
const sun=window.__lastSun||[0,0,1];
|
|
841
|
+
const t0=performance.now();
|
|
842
|
+
for(let i=0;i<n;i++) orch.frame(eyeM,tgtM,45*Math.PI/180,0,sun,i*0.016,cam.up);
|
|
843
|
+
const ms=(performance.now()-t0)/n;
|
|
844
|
+
return { frames:n, msPerFrame:+ms.toFixed(2), fps:+(1000/ms).toFixed(1) };
|
|
845
|
+
},
|
|
846
|
+
// lodCoherence: descend through altitudes, assert quadCount is monotonic-ish
|
|
847
|
+
// (more quads as we get closer) and glError stays 0. Returns trace + verdict.
|
|
848
|
+
async lodCoherence(alts=[20000,2000,200,20,2]){
|
|
849
|
+
const tr=await this.descentTrace(alts);
|
|
850
|
+
let monotone=true; for(let i=1;i<tr.length;i++) if(tr[i].quads < tr[i-1].quads*0.5) monotone=false;
|
|
851
|
+
const noErr=tr.every(t=>!t.glError);
|
|
852
|
+
return { trace:tr, monotoneQuadGrowth:monotone, noGlError:noErr, pass:monotone&&noErr };
|
|
853
|
+
},
|
|
854
|
+
// genReport: dump the orchestrator/producer generation params + last frame stats.
|
|
855
|
+
genReport(){ const f=window.__webglFrame||{}; return {
|
|
856
|
+
radiusM: WEBGL2_TERRAIN_R_M, mPerUnit: WEBGL2_M_PER_UNIT,
|
|
857
|
+
status:this.status(),
|
|
858
|
+
frame:{ quads:f.quadCount, resident:f.residentCount, fallback:f.fallbackCount,
|
|
859
|
+
maxFallbackLevel:f.maxFallbackLevel, frontFace:f.face, glError:f.glError } };
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// ===========================================================================
|
|
864
|
+
// window.__diag -- structured one-dispatch terrain diagnostics. Each method
|
|
865
|
+
// returns asserts queryable via a single page.evaluate, so issues are
|
|
866
|
+
// root-caused from runtime state, not screenshots.
|
|
867
|
+
// ===========================================================================
|
|
868
|
+
window.__diag = {
|
|
869
|
+
_gl(){ return window.__planetOrchGL; },
|
|
870
|
+
// ON-DEMAND lateral ground-height range (moved out of the 1Hz diag stream, perf sweep 2026-06-11:
|
|
871
|
+
// the 5 serialized probe readbacks were a per-second frame hitch). Same 5-dir sample as before.
|
|
872
|
+
hRange(){
|
|
873
|
+
const orch = window.__planetOrch, sg = orch && orch.render && orch.render.sampleGroundM;
|
|
874
|
+
if (!sg) return { err: 'no-probe' };
|
|
875
|
+
const p = window.__cam.pos, L = Math.hypot(p[0],p[1],p[2])||1;
|
|
876
|
+
const d0 = [p[0]/L, p[1]/L, p[2]/L];
|
|
877
|
+
const e = 0.0008, hs = [];
|
|
878
|
+
for (const off of [[0,0],[e,0],[-e,0],[0,e],[0,-e]]) {
|
|
879
|
+
const d = [d0[0]+off[0], d0[1]+off[1], d0[2]+off[1]];
|
|
880
|
+
const l = Math.hypot(d[0],d[1],d[2])||1;
|
|
881
|
+
const h = sg([d[0]/l,d[1]/l,d[2]/l]);
|
|
882
|
+
if (h == null || !isFinite(h)) return { err: 'probe-not-ready' }; // lazy probe still compiling
|
|
883
|
+
hs.push(h);
|
|
884
|
+
}
|
|
885
|
+
return { groundM: +hs[0].toFixed(0), hRangeM: +(Math.max(...hs)-Math.min(...hs)).toFixed(0) };
|
|
886
|
+
},
|
|
887
|
+
// (K7) bisect -- the one-call carrier-finder (2026-06-12, codifies the method that cracked the FXC
|
|
888
|
+
// per-callsite root). At the CURRENT pose, A/Bs each isolation toggle against baseline and returns
|
|
889
|
+
// the pixel-diff fraction per toggle: the BIG number names the term carrying whatever you're looking
|
|
890
|
+
// at (splat, texture normals, warp, slope-rock material, the lit normal itself). No camera moves.
|
|
891
|
+
async bisect(){
|
|
892
|
+
const grab = () => this._read();
|
|
893
|
+
const diff = (A, B) => { let d = 0, n = 0;
|
|
894
|
+
for (let i = 0; i < A.px.length; i += 28) { n++;
|
|
895
|
+
if (Math.abs(A.px[i]-B.px[i])+Math.abs(A.px[i+1]-B.px[i+1])+Math.abs(A.px[i+2]-B.px[i+2]) > 12) d++; }
|
|
896
|
+
return +(d/n).toFixed(3); };
|
|
897
|
+
const fr = async () => { await window.__dbg.frames(4); };
|
|
898
|
+
const base = grab(); const out = {};
|
|
899
|
+
window.__texMix = 0; await fr(); out.splat = diff(base, grab()); window.__texMix = 0.85;
|
|
900
|
+
window.__texNrmK = 0; await fr(); out.texNormals = diff(base, grab()); window.__texNrmK = 3;
|
|
901
|
+
window.__texWarp = 0; await fr(); out.warp = diff(base, grab()); window.__texWarp = 1.0;
|
|
902
|
+
window.__flatNormal = 1; await fr(); out.litNormal = diff(base, grab()); window.__flatNormal = 0;
|
|
903
|
+
const g = window.__gen; let sr0 = null;
|
|
904
|
+
if (g && g.state && g.state.biome) { sr0 = g.state.biome.slopeRock.slice();
|
|
905
|
+
g.state.biome.slopeRock = [8,9]; g.apply(); await fr(); out.slopeRockMat = diff(base, grab());
|
|
906
|
+
g.state.biome.slopeRock = sr0; g.apply(); }
|
|
907
|
+
await fr();
|
|
908
|
+
return out;
|
|
909
|
+
},
|
|
910
|
+
// (K8) groundTruth -- probe-side facts under a screen position (default center): height, 150m-FD
|
|
911
|
+
// slope, so render-vs-field disputes resolve in one call (the probe and the wireframe are the
|
|
912
|
+
// geometry truth; if the render disagrees, the shading path is the liar).
|
|
913
|
+
groundTruth(){
|
|
914
|
+
const orch = window.__planetOrch, sg = orch && orch.render && orch.render.sampleGroundM;
|
|
915
|
+
if (!sg) return { err: 'no-probe' };
|
|
916
|
+
const p = window.__cam.pos, L = Math.hypot(p[0],p[1],p[2])||1;
|
|
917
|
+
const u = [p[0]/L, p[1]/L, p[2]/L];
|
|
918
|
+
const h = sg(u); if (h == null) return { err: 'probe-not-ready' };
|
|
919
|
+
const e = 150/6360000;
|
|
920
|
+
const ref = Math.abs(u[1])<0.99?[0,1,0]:[1,0,0];
|
|
921
|
+
let ux=[ref[1]*u[2]-ref[2]*u[1],ref[2]*u[0]-ref[0]*u[2],ref[0]*u[1]-ref[1]*u[0]];
|
|
922
|
+
const xl=Math.hypot(ux[0],ux[1],ux[2]); ux=ux.map(v=>v/xl);
|
|
923
|
+
const uy=[u[1]*ux[2]-u[2]*ux[1],u[2]*ux[0]-u[0]*ux[2],u[0]*ux[1]-u[1]*ux[0]];
|
|
924
|
+
const tap=(ax)=>{const v=[u[0]+ax[0]*e,u[1]+ax[1]*e,u[2]+ax[2]*e];const l=Math.hypot(v[0],v[1],v[2]);return sg([v[0]/l,v[1]/l,v[2]/l]);};
|
|
925
|
+
const du=(tap(ux)-h)/150, dv=(tap(uy)-h)/150;
|
|
926
|
+
return { h: Math.round(h), slope150: +((1 - 1/Math.sqrt(1+du*du+dv*dv))).toFixed(3), camDir: u.map(v=>+v.toFixed(4)) };
|
|
927
|
+
},
|
|
928
|
+
// GPU-TIMER VS/FS attribution (headed browser only -- EXT_disjoint_timer_query_webgl2 is absent
|
|
929
|
+
// in headless node-gl, the long-standing measurement gap). Times the planet draw three ways via
|
|
930
|
+
// the timer-query ext: FULL frame (VS+FS), CHEAP frame (window.__fsCheap short-circuits the FS to
|
|
931
|
+
// a trivial color = VS+raster only), and reports the delta = per-pixel FS cost. Falls back to a
|
|
932
|
+
// gl.finish()-bracketed CPU wall-clock if the ext is missing. reps frames are averaged. This is
|
|
933
|
+
// the MEASURE-FIRST surface: it names whether the bottleneck is the per-vertex 14-oct broadShapeM
|
|
934
|
+
// (VS) or the per-pixel shade (FS) so the most-performant lever is picked, not guessed.
|
|
935
|
+
async gpuTimer(reps=30){
|
|
936
|
+
const g=this._gl(); if(!g) return {err:'no-gl'};
|
|
937
|
+
const d=window.__dbg;
|
|
938
|
+
const ext = g.getExtension('EXT_disjoint_timer_query_webgl2') || g.getExtension('EXT_disjoint_timer_query');
|
|
939
|
+
// time `reps` real rAF frames with a given __fsCheap setting; returns avg GPU ms (ext) or wall ms.
|
|
940
|
+
const timeMode = async (cheap)=>{
|
|
941
|
+
window.__fsCheap = cheap ? 1 : 0; await d.frames(3); // warm + flush the mode change
|
|
942
|
+
if(ext){
|
|
943
|
+
const samples=[];
|
|
944
|
+
for(let i=0;i<reps;i++){
|
|
945
|
+
const q=g.createQuery(); g.beginQuery(ext.TIME_ELAPSED_EXT, q);
|
|
946
|
+
await d.frames(1);
|
|
947
|
+
g.endQuery(ext.TIME_ELAPSED_EXT);
|
|
948
|
+
// poll for the result (1-frame latency); bail if the GPU disjoint flag tripped
|
|
949
|
+
let avail=false,disjoint=false,guard=0;
|
|
950
|
+
while(!avail && guard++<240){ await d.frames(1);
|
|
951
|
+
avail=g.getQueryParameter(q, g.QUERY_RESULT_AVAILABLE);
|
|
952
|
+
disjoint=g.getParameter(ext.GPU_DISJOINT_EXT); if(disjoint) break; }
|
|
953
|
+
if(avail && !disjoint){ samples.push(g.getQueryParameter(q, g.QUERY_RESULT)/1e6); } // ns->ms
|
|
954
|
+
g.deleteQuery(q);
|
|
955
|
+
}
|
|
956
|
+
samples.sort((a,b)=>a-b);
|
|
957
|
+
const med = samples.length ? samples[samples.length>>1] : null;
|
|
958
|
+
return { ms: med, n: samples.length, src:'timer-query' };
|
|
959
|
+
} else {
|
|
960
|
+
const t0=performance.now();
|
|
961
|
+
for(let i=0;i<reps;i++){ await d.frames(1); }
|
|
962
|
+
g.finish();
|
|
963
|
+
return { ms:+((performance.now()-t0)/reps).toFixed(3), n:reps, src:'wall-finish' };
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
const full = await timeMode(false);
|
|
967
|
+
const cheap = await timeMode(true);
|
|
968
|
+
window.__fsCheap = 0; await d.frames(2);
|
|
969
|
+
const fsMs = (full.ms!=null && cheap.ms!=null) ? +(full.ms - cheap.ms).toFixed(3) : null;
|
|
970
|
+
return {
|
|
971
|
+
hasExt: !!ext, src: full.src,
|
|
972
|
+
fullMs: full.ms!=null?+full.ms.toFixed(3):null, // VS + FS (per-pixel shade) + raster
|
|
973
|
+
vsRasterMs: cheap.ms!=null?+cheap.ms.toFixed(3):null, // VS (14-oct broadShapeM x3/vtx) + raster
|
|
974
|
+
fsMs, // per-pixel FS cost = full - cheap
|
|
975
|
+
vsDominant: (fsMs!=null && cheap.ms!=null) ? (cheap.ms > fsMs) : null,
|
|
976
|
+
quads:(window.__lastGLQuads||[]).length, glError:d.glError
|
|
977
|
+
};
|
|
978
|
+
},
|
|
979
|
+
_read(){ const g=this._gl(); const w=g.drawingBufferWidth,h=g.drawingBufferHeight; const px=new Uint8Array(w*h*4); g.finish(); g.readPixels(0,0,w,h,g.RGBA,g.UNSIGNED_BYTE,px); return {px,w,h}; },
|
|
980
|
+
// Deterministic LIT view over a fixed land point at a given altitude (km). Camera looks straight
|
|
981
|
+
// down; the sun is set ~35deg OFF vertical (not straight down) so slopes light and self-shadow ->
|
|
982
|
+
// relief is actually visible in the shading (sun-straight-down washes relief flat). The single
|
|
983
|
+
// tool for measuring lit-mode detail monotonically across altitudes without the night-side /
|
|
984
|
+
// re-randomizing-findLand problems. dir defaults to the canonical land heading.
|
|
985
|
+
async litParkOverLand(altKm, dir){ const u=(()=>{const d=dir||[-0.24,0,-0.97];const l=Math.hypot(d[0],d[1],d[2]);return [d[0]/l,d[1]/l,d[2]/l];})();
|
|
986
|
+
const R=window.__dbg.R, r=R+altKm; cam.pos=[u[0]*r,u[1]*r,u[2]*r]; cam.fwd=[-u[0],-u[1],-u[2]]; cam.up=Math.abs(u[1])<0.9?[0,1,0]:[1,0,0];
|
|
987
|
+
// sun 35deg off the local vertical so the surface is lit but slopes cast relief shading.
|
|
988
|
+
const lat=Math.asin(u[1]), lon=Math.atan2(u[0],u[2]);
|
|
989
|
+
cam.sunLatBase=Math.max(-1.5, lat - 0.6); cam.sunLonBase=lon; cam.sunLonAccum=0; cam.timeScale=0;
|
|
990
|
+
await window.__dbg.frames(8); return {altKm, sunDotUp:this.sunDotUp()}; },
|
|
991
|
+
// (A2) scrollProbe -- the UV-SCROLL witness (user: 'scrolling effects moving over the land').
|
|
992
|
+
// Park lit over land at altKm, capture albedo, translate the camera laterally by stepM (< the
|
|
993
|
+
// FS detail snap cell so the lattice origin does NOT jump), recapture, return mean abs luma diff
|
|
994
|
+
// over the disc. With the snapped-anchor detail coords the grain is world-fixed -> the pattern
|
|
995
|
+
// does not slide with the camera, so the diff is only true parallax (small at distance). A high
|
|
996
|
+
// diff at distance = the lattice is still scrolling with the camera (the bug). displayMode 2
|
|
997
|
+
// (albedo) isolates the material grain from lighting. stepM default 300 (< the 1024m snap cell).
|
|
998
|
+
async scrollProbe(altKm=60, stepM=300){
|
|
999
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready'};
|
|
1000
|
+
const d=window.__dbg;
|
|
1001
|
+
await this.litParkOverLand(altKm);
|
|
1002
|
+
window.__displayMode=2; await d.frames(8);
|
|
1003
|
+
const A=this._read();
|
|
1004
|
+
const p=cam.pos, len=Math.hypot(...p)||1;
|
|
1005
|
+
// a tangent direction perpendicular to the radial (lateral pan over the ground)
|
|
1006
|
+
let t=[-p[1]/len, p[0]/len, 0]; const tl=Math.hypot(...t)||1; t=[t[0]/tl,t[1]/tl,t[2]/tl];
|
|
1007
|
+
cam.pos=[p[0]+t[0]*stepM, p[1]+t[1]*stepM, p[2]+t[2]*stepM];
|
|
1008
|
+
await d.frames(8);
|
|
1009
|
+
const B=this._read();
|
|
1010
|
+
let s=0,n=0; const {px:pa,w,h}=A; const pb=B.px;
|
|
1011
|
+
for(let i=0;i<w*h;i++){ const ra=pa[i*4],ga=pa[i*4+1],ba=pa[i*4+2];
|
|
1012
|
+
if(ra<4&&ga<4&&ba<4) continue;
|
|
1013
|
+
const la=(ra+ga+ba)/3, lb=(pb[i*4]+pb[i*4+1]+pb[i*4+2])/3; s+=Math.abs(la-lb); n++; }
|
|
1014
|
+
window.__displayMode=0; await d.frames(2);
|
|
1015
|
+
return { altKm, stepM, snapCellM:1024, meanAbsLumaDiff:+(s/Math.max(n,1)).toFixed(2),
|
|
1016
|
+
discPx:n, glError:d.glError }; },
|
|
1017
|
+
// (A0b) parkAboveGround -- park altKm above the LOCAL GROUND (not sea level) over a land dir,
|
|
1018
|
+
// at an OBLIQUE pitch so the forward ground fills the frame. Uses the GPU height probe
|
|
1019
|
+
// (orch.render.sampleGroundM) so altitude is relative to the real rendered surface -- fixes the
|
|
1020
|
+
// degenerate nadir-over-tall-mountain pose (litParkOverLand at altKm < peak gave empty frames).
|
|
1021
|
+
// pitch: 0 = straight down, ~0.4 = oblique forward. Returns the framing + a quad/coverage read.
|
|
1022
|
+
async parkAboveGround(altKm=10, dir=null, pitch=0.4){
|
|
1023
|
+
const orch=window.__planetOrch, db=window.__dbg, R_M=WEBGL2_TERRAIN_R_M;
|
|
1024
|
+
let u=dir||window.__landDir||[0.333,-0.258,0.907]; const ul=Math.hypot(...u)||1; u=[u[0]/ul,u[1]/ul,u[2]/ul];
|
|
1025
|
+
const groundM = (orch&&orch.render&&orch.render.sampleGroundM) ? Math.max(0,orch.render.sampleGroundM(u)) : 0;
|
|
1026
|
+
// camera radius in km-units: (R_M + groundM + altKm*1000) metres -> /WEBGL2_M_PER_UNIT km-units.
|
|
1027
|
+
const rM = R_M + groundM + altKm*1000;
|
|
1028
|
+
const rU = rM / WEBGL2_M_PER_UNIT;
|
|
1029
|
+
cam.pos=[u[0]*rU, u[1]*rU, u[2]*rU];
|
|
1030
|
+
// oblique fwd: blend straight-down (-u) with a tangent dir by pitch.
|
|
1031
|
+
let t=[-u[1],u[0],0]; const tl=Math.hypot(...t)||1; t=[t[0]/tl,t[1]/tl,t[2]/tl];
|
|
1032
|
+
let f=[-u[0]*(1-pitch)+t[0]*pitch, -u[1]*(1-pitch)+t[1]*pitch, -u[2]*(1-pitch)+t[2]*pitch];
|
|
1033
|
+
const fl=Math.hypot(...f)||1; cam.fwd=[f[0]/fl,f[1]/fl,f[2]/fl];
|
|
1034
|
+
cam.up=Math.abs(u[1])<0.9?[0,1,0]:[1,0,0];
|
|
1035
|
+
const lat=Math.asin(u[1]), lon=Math.atan2(u[0],u[2]);
|
|
1036
|
+
cam.sunLatBase=Math.max(-1.5, lat-0.6); cam.sunLonBase=lon; cam.sunLonAccum=0; cam.timeScale=0;
|
|
1037
|
+
await db.frames(8);
|
|
1038
|
+
const fr=window.__webglFrame||{};
|
|
1039
|
+
const {px,w,h}=this._read(); let nn=0; for(let i=0;i<w*h;i++){if(!(px[i*4]<4&&px[i*4+1]<4&&px[i*4+2]<4))nn++;}
|
|
1040
|
+
return { altKmAboveGround:altKm, groundM:+groundM.toFixed(0), pitch, quads:fr.quadCount|0,
|
|
1041
|
+
discFrac:+(nn/(w*h)).toFixed(3), glError:fr.glError|0 }; },
|
|
1042
|
+
// (A) day/night at the current view -- rules out terminator-dark as a "bug".
|
|
1043
|
+
sunDotUp(){ const p=cam.pos, up=p.map(v=>v/Math.hypot(...p)); const s=window.__lastSun||[]; return s.length?+(s[0]*up[0]+s[1]*up[1]+s[2]*up[2]).toFixed(3):null; },
|
|
1044
|
+
// (B) lit-vs-albedo luminance over the whole disc -- isolates SHADING darkness
|
|
1045
|
+
// (lit << albedo) from missing geometry (both ~0). Returns means + ratio.
|
|
1046
|
+
async litVsAlbedo(){ const d=window.__dbg;
|
|
1047
|
+
const lum=()=>{ const {px,w,h}=this._read(); let s=0,t=0,bg=0; for(let i=0;i<w*h;i++){const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; if(r<4&&g<4&&b<4){bg++;continue;} s+=(r+g+b)/3; t++;} return {lum:+(s/Math.max(t,1)).toFixed(1), cov:+(t/(w*h)).toFixed(3), bgFrac:+(bg/(w*h)).toFixed(3)}; };
|
|
1048
|
+
window.__displayMode=0; await d.frames(8); const lit=lum();
|
|
1049
|
+
window.__displayMode=2; await d.frames(8); const alb=lum();
|
|
1050
|
+
window.__displayMode=0; await d.frames(4);
|
|
1051
|
+
return { lit, albedo:alb, litOverAlbedo:+(lit.lum/Math.max(alb.lum,1)).toFixed(2), sunDotUp:this.sunDotUp() }; },
|
|
1052
|
+
// (C) black-edge classifier: count near-black pixels that are NOT background
|
|
1053
|
+
// (i.e. dark seams within the terrain) in lit vs albedo. If lit has dark seams
|
|
1054
|
+
// but albedo does not, the black edge is a SHADING discontinuity, not geometry.
|
|
1055
|
+
async blackEdge(){ const d=window.__dbg;
|
|
1056
|
+
const darkInTerrain=()=>{ const {px,w,h}=this._read(); let dark=0,terr=0; for(let i=0;i<w*h;i++){const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; const lum=(r+g+b)/3; if(lum<8) continue; /* bg or seam-black both <8; need neighbor test */ }
|
|
1057
|
+
// simpler: fraction of disc pixels that are very dark (<25) -- seam darkness.
|
|
1058
|
+
let veryDark=0,disc=0; for(let i=0;i<w*h;i++){const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; if(r<4&&g<4&&b<4)continue; disc++; if((r+g+b)/3<25)veryDark++;} return +(veryDark/Math.max(disc,1)).toFixed(3); };
|
|
1059
|
+
window.__displayMode=0; await d.frames(8); const litDark=darkInTerrain();
|
|
1060
|
+
window.__displayMode=2; await d.frames(8); const albDark=darkInTerrain();
|
|
1061
|
+
window.__displayMode=0; await d.frames(4);
|
|
1062
|
+
return { litDarkFrac:litDark, albedoDarkFrac:albDark, shadingArtifact: litDark > albDark*2 + 0.02 }; },
|
|
1063
|
+
// (D) quad census from the last frame -- per-level counts + max level under camera.
|
|
1064
|
+
quadCensus(){ const q=window.__lastGLQuads||[]; const f=window.__webglFrame||{}; const lv={}; let maxL=0, sub=0;
|
|
1065
|
+
for(const x of q){const l=x.quad.level|0; lv[l]=(lv[l]||0)+1; if(l>maxL)maxL=l; if(x.subregion)sub++;}
|
|
1066
|
+
return { total:q.length, byLevel:lv, maxLevel:maxL, fallbackQuads:sub, residentCount:f.residentCount, fallbackCount:f.fallbackCount, culledCount:f.culledCount, glError:f.glError }; },
|
|
1067
|
+
// (E) elevation / feature-frequency for the density issue: generate a coarse face
|
|
1068
|
+
// tile, count sign-changes (zero crossings) per row/col = feature frequency, + sea fraction.
|
|
1069
|
+
density(face=2, level=3){ const p=window.__planetOrch&&window.__planetOrch.producer; if(!p) return {err:'no-p'};
|
|
1070
|
+
let l=level,x=0,y=0,c=[]; while(l>=0){c.unshift({l,x,y}); if(l===0)break; x>>=1;y>>=1;l--;}
|
|
1071
|
+
for(let i=0;i<c.length;i++){const cc=c[i]; p.generateTile(face,cc.l,cc.x,cc.y,i,i===0?-1:i-1);}
|
|
1072
|
+
const buf=p.readTile(c.length-1), W=p.TILE_W; let below=0,t=0,zc=0;
|
|
1073
|
+
for(let yy=2;yy<W-2;yy++){let prev=null; for(let xx=2;xx<W-2;xx++){const v=buf[(yy*W+xx)*4]; t++; if(v<0)below++; if(prev!==null && (prev<0)!==(v<0))zc++; prev=v;}}
|
|
1074
|
+
return { face, level, seaFrac:+(below/t).toFixed(2), zeroCrossingsPerRow:+(zc/(W-4)).toFixed(1) }; },
|
|
1075
|
+
// (F) skirts were removed (replaced by the one-cell overlap ring in the mesh); this diag
|
|
1076
|
+
// is retained only as a no-op stub so older probe scripts that call it do not throw.
|
|
1077
|
+
async skirtEffect(){ return { note:'skirts removed; LOD cracks hidden by overlap ring (no skirtDisable toggle)' }; },
|
|
1078
|
+
// (G) full report at the current view -- one call answers all three issues.
|
|
1079
|
+
async report(){ return { view:{alt:+window.__dbg.altKm().toFixed(1), sunDotUp:this.sunDotUp()},
|
|
1080
|
+
census:this.quadCensus(), litVsAlbedo:await this.litVsAlbedo(), blackEdge:await this.blackEdge() }; },
|
|
1081
|
+
// (gen tuning lives in window.__gen now; the old wasm setGen/getGen/densitySweep helpers
|
|
1082
|
+
// are gone with the deleted cascade producer.)
|
|
1083
|
+
|
|
1084
|
+
// =========================================================================
|
|
1085
|
+
// EFFICIENT-LOOP DEBUG GLOBALS (added 2026-05-28). All are SCREEN-SPACE pixel
|
|
1086
|
+
// reads or runtime-state reads -- never generateTile/readTile (recall
|
|
1087
|
+
// mem-1779953051634: ad-hoc tile regen in the live page gives results that
|
|
1088
|
+
// CONTRADICT the rigorous producer-test.html witness). Each is one dispatch.
|
|
1089
|
+
// =========================================================================
|
|
1090
|
+
|
|
1091
|
+
// (J) assertReady -- the single most important loop-killer guard. A shader
|
|
1092
|
+
// compile-fail makes __planetOrchStatus = 'error:...' and the page LOOKS like a
|
|
1093
|
+
// slow/hung load (recall mem-1779979072187). Every probe gates on this first so
|
|
1094
|
+
// a probe NEVER reports a misleading number against a broken/blank page.
|
|
1095
|
+
async assertReady(maxFrames=180){
|
|
1096
|
+
const d=window.__dbg;
|
|
1097
|
+
for(let i=0;i<maxFrames;i++){
|
|
1098
|
+
const s=window.__planetOrchStatus||'init';
|
|
1099
|
+
if(typeof s==='string' && s.startsWith('error:')) return {ok:false,err:s,frames:i};
|
|
1100
|
+
if(s==='ready'){
|
|
1101
|
+
const f=window.__webglFrame||{};
|
|
1102
|
+
if((f.quadCount|0)>0) return {ok:true,status:s,frames:i,quads:f.quadCount,glError:f.glError};
|
|
1103
|
+
}
|
|
1104
|
+
await d.frames(1);
|
|
1105
|
+
}
|
|
1106
|
+
return {ok:false,err:'not-ready-in-'+maxFrames+'-frames',status:window.__planetOrchStatus||'init',
|
|
1107
|
+
quads:(window.__webglFrame||{}).quadCount|0, pageErr:window.__pageErr||null};
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
// (K) parkAt -- deterministic, reproducible camera placement at named altitude
|
|
1111
|
+
// ladder rungs (recall mem-1779969534623: eyeballed positions made comparisons
|
|
1112
|
+
// noisy; gotoDown once aimed off-screen). Looks straight down at the sub-camera
|
|
1113
|
+
// point so every probe runs from an identical viewpoint across sessions.
|
|
1114
|
+
RUNGS: { space:35000, orbit:2000, midalt:400, lowalt:50, closeup:5 },
|
|
1115
|
+
async parkAt(rung='orbit', settleFrames=8){
|
|
1116
|
+
const km = (typeof rung==='number') ? rung : this.RUNGS[rung];
|
|
1117
|
+
if(km==null) return {err:'unknown-rung:'+rung+' (use space/orbit/midalt/lowalt/closeup or a km number)'};
|
|
1118
|
+
window.__dbg.gotoDown(km);
|
|
1119
|
+
await window.__dbg.frames(settleFrames);
|
|
1120
|
+
return { rung, altKm:+window.__dbg.altKm().toFixed(1), quads:window.__dbg.quads(), glError:window.__dbg.glError() };
|
|
1121
|
+
},
|
|
1122
|
+
// (K2) aimDir -- place the camera OVER an arbitrary world-DIRECTION at altKm, looking straight
|
|
1123
|
+
// down. The witness primitive parkAt lacked: parkAt only reprojects the EXISTING sub-point, so
|
|
1124
|
+
// there was no way to aim at a chosen spot (e.g. wet land for the river/feature witness). dir is
|
|
1125
|
+
// a [x,y,z] world vector (normalized here). Uses the module aimAt so up/fwd are well-formed.
|
|
1126
|
+
async aimDir(dir, altKm=40, settleFrames=30){
|
|
1127
|
+
const L=Math.hypot(dir[0],dir[1],dir[2])||1; const u=[dir[0]/L,dir[1]/L,dir[2]/L];
|
|
1128
|
+
const r=RADIUS+altKm; const pos=[u[0]*r,u[1]*r,u[2]*r];
|
|
1129
|
+
aimAt(pos, [-u[0],-u[1],-u[2]]);
|
|
1130
|
+
await window.__dbg.frames(settleFrames);
|
|
1131
|
+
return { altKm:+window.__dbg.altKm().toFixed(1), quads:window.__dbg.quads(), glError:window.__dbg.glError() };
|
|
1132
|
+
},
|
|
1133
|
+
// (K3) findLand -- sweep world-directions, return the ones over LAND (optionally wet), so a
|
|
1134
|
+
// feature witness can aim aimDir at a confirmed land/wet spot instead of guessing. Reads the
|
|
1135
|
+
// live land/sea via displayMode 5 center pixel (red=land). humidGate>0 also requires the river
|
|
1136
|
+
// field (displayMode 7 center blue) to fire -> wet land with rivers.
|
|
1137
|
+
async findLand(n=24, wantRiver=false){
|
|
1138
|
+
const prev=window.__displayMode; const found=[];
|
|
1139
|
+
for(let i=0;i<n;i++){
|
|
1140
|
+
const t=i/n, phi=Math.acos(1-2*t), th=i*2.399963;
|
|
1141
|
+
const d=[Math.sin(phi)*Math.cos(th), Math.cos(phi), Math.sin(phi)*Math.sin(th)];
|
|
1142
|
+
window.__displayMode=5; await this.aimDir(d, 60, 14);
|
|
1143
|
+
let {px,w,h}=this._read(); let i0=((h>>1)*w+(w>>1))*4; const land = px[i0] > px[i0+2]; // red>blue
|
|
1144
|
+
if(!land) continue;
|
|
1145
|
+
let river=0;
|
|
1146
|
+
if(wantRiver){ window.__displayMode=7; await window.__dbg.frames(10); ({px,w,h}=this._read());
|
|
1147
|
+
let b=0,nn=0; const cx=w>>1,cy=h>>1; for(let y=cy-25;y<cy+25;y++)for(let x=cx-25;x<cx+25;x++){const k=(y*w+x)*4; nn++; if(px[k+2]>px[k]+25&&px[k+2]>55)b++;} river=+(b/nn).toFixed(3); }
|
|
1148
|
+
found.push({dir:d.map(v=>+v.toFixed(3)), river});
|
|
1149
|
+
}
|
|
1150
|
+
window.__displayMode=prev;
|
|
1151
|
+
found.sort((a,b)=>b.river-a.river);
|
|
1152
|
+
return { count:found.length, top: found.slice(0,5) };
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
// (K4) landWitness -- THE reliable scripted LAND witness (user 2026-06-05 'fix the scripted
|
|
1156
|
+
// witness to show land'). Roots the pose in the LIVE GPU height probe (orch.render.sampleGroundM,
|
|
1157
|
+
// the EXACT rendered terrain field) instead of the CPU lab broadShapeM (which diverges from the
|
|
1158
|
+
// live HPF -> the old 'land' dirs were actually ocean). Scans fib-sphere dirs for genuine high
|
|
1159
|
+
// land, parks ground-relative + oblique + sun-lit over the highest, settles, and returns a
|
|
1160
|
+
// land/water census so the caller has PROOF the frame is land (low blueFrac, land-toned meanRGB),
|
|
1161
|
+
// never the teal [45,116,139] ocean frame. altKm = height above the local ground.
|
|
1162
|
+
async landWitness(altKm=4, pitch=0.45, minH=800, samples=900, displayMode=0){
|
|
1163
|
+
const orch=window.__planetOrch, gl=window.__planetOrchGL, cam=window.__dbg.cam;
|
|
1164
|
+
const sg = orch && orch.render && orch.render.sampleGroundM;
|
|
1165
|
+
if(!sg) return {err:'no-sampleGroundM (probe program missing)'};
|
|
1166
|
+
const R_M=window.__WEBGL2_TERRAIN_R_M, MPU=window.__WEBGL2_M_PER_UNIT;
|
|
1167
|
+
let best=null, landCount=0;
|
|
1168
|
+
for(let i=0;i<samples;i++){
|
|
1169
|
+
const y=1-2*(i+0.5)/samples, r=Math.sqrt(Math.max(0,1-y*y)), t=i*2.399963229;
|
|
1170
|
+
const u=[Math.cos(t)*r, y, Math.sin(t)*r];
|
|
1171
|
+
const h=sg(u); if(h>0) landCount++; if(h>minH && (!best||h>best.h)) best={u,h};
|
|
1172
|
+
}
|
|
1173
|
+
if(!best) return {err:'no-land-found', landFrac:+(landCount/samples).toFixed(3)};
|
|
1174
|
+
window.__landDir = best.u;
|
|
1175
|
+
const u=best.u, groundM=Math.max(0,sg(u));
|
|
1176
|
+
const rU=(R_M + groundM + altKm*1000)/MPU;
|
|
1177
|
+
cam.pos=[u[0]*rU, u[1]*rU, u[2]*rU];
|
|
1178
|
+
let tg=[-u[1],u[0],0]; const tl=Math.hypot(...tg)||1; tg=[tg[0]/tl,tg[1]/tl,tg[2]/tl];
|
|
1179
|
+
let f=[-u[0]*(1-pitch)+tg[0]*pitch, -u[1]*(1-pitch)+tg[1]*pitch, -u[2]*(1-pitch)+tg[2]*pitch];
|
|
1180
|
+
const fl=Math.hypot(...f)||1; cam.fwd=[f[0]/fl,f[1]/fl,f[2]/fl];
|
|
1181
|
+
cam.up=Math.abs(u[1])<0.9?[0,1,0]:[1,0,0];
|
|
1182
|
+
const lat=Math.asin(u[1]), lon=Math.atan2(u[0],u[2]);
|
|
1183
|
+
cam.sunLatBase=Math.max(-1.4, lat-0.5); cam.sunLonBase=lon; cam.sunLonAccum=0; cam.timeScale=0;
|
|
1184
|
+
window.__dbg.setDisplayMode(displayMode);
|
|
1185
|
+
await window.__dbg.frames(45);
|
|
1186
|
+
gl.finish();
|
|
1187
|
+
const w=gl.drawingBufferWidth,h=gl.drawingBufferHeight; const px=new Uint8Array(w*h*4);
|
|
1188
|
+
gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px);
|
|
1189
|
+
let n=0,rs=0,gs=0,bs=0,blue=0;
|
|
1190
|
+
for(let i=0;i<px.length;i+=4){const r=px[i],g=px[i+1],b=px[i+2]; if(r<6&&g<6&&b<6)continue;
|
|
1191
|
+
n++; rs+=r;gs+=g;bs+=b; if(b>r+25&&b>g)blue++;}
|
|
1192
|
+
return { landFrac:+(landCount/samples).toFixed(3), bestLandH:Math.round(best.h),
|
|
1193
|
+
bestDir:best.u.map(v=>+v.toFixed(3)), altKm:+window.__dbg.altKm().toFixed(1),
|
|
1194
|
+
census:{ pixels:n, meanRGB:n?[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]:[0,0,0],
|
|
1195
|
+
blueFrac:n?+(blue/n).toFixed(2):0 }, glError:gl.getError() };
|
|
1196
|
+
},
|
|
1197
|
+
|
|
1198
|
+
// (K4b) probeWarm -- the collision probe program is LAZY-compiled on the first sampleGroundM;
|
|
1199
|
+
// while the link is in flight every call returns 0, so a probe-rooted witness that runs too
|
|
1200
|
+
// early sees a planet with NO land (witnessed verify-all 2026-06-11: landFrac 0 on a planet
|
|
1201
|
+
// that is ~35% land). Gate: sample a fib ring until any |h| > 1 m or the timeout lapses.
|
|
1202
|
+
async probeWarm(timeoutMs=180000){
|
|
1203
|
+
const orch=window.__planetOrch; const sg=orch&&orch.render&&orch.render.sampleGroundM;
|
|
1204
|
+
if(!sg) return {ok:false, err:'no-sampleGroundM'};
|
|
1205
|
+
const t0=performance.now();
|
|
1206
|
+
for(;;){
|
|
1207
|
+
for(let i=0;i<24;i++){
|
|
1208
|
+
const y=1-2*(i+0.5)/24, r=Math.sqrt(Math.max(0,1-y*y)), t=i*2.399963229;
|
|
1209
|
+
const h=sg([Math.cos(t)*r, y, Math.sin(t)*r]);
|
|
1210
|
+
if(Math.abs(h)>1) return {ok:true, ms:Math.round(performance.now()-t0)};
|
|
1211
|
+
}
|
|
1212
|
+
if(performance.now()-t0>timeoutMs) return {ok:false, err:'probe-warm-timeout'};
|
|
1213
|
+
await window.__dbg.frames(20);
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
// (K5) coastWitness -- find a genuine SHORELINE (probe-rooted: a dir whose 4-sample ring spans
|
|
1218
|
+
// land AND sub-sea), park oblique over it, and assert the separate-water-surface invariants
|
|
1219
|
+
// (2026-06-11 permanent verify policy): water present, waves animate, seabed shows through the
|
|
1220
|
+
// shallow band, __waterSurface=false flips water px to seabed, no grass-class px on the beach.
|
|
1221
|
+
async coastWitness(altKm=6, samples=2400){
|
|
1222
|
+
const orch=window.__planetOrch, gl=window.__planetOrchGL;
|
|
1223
|
+
const sg = orch && orch.render && orch.render.sampleGroundM;
|
|
1224
|
+
if(!sg) return {err:'no-sampleGroundM'};
|
|
1225
|
+
// PROBE-WARM GATE (perf sweep 2026-06-11): on a fresh page the lazy probe compiles ASYNC, but
|
|
1226
|
+
// this finder loop is synchronous -- every sg() returned null (null<=0 skips the sample) and
|
|
1227
|
+
// the build never got an event-loop turn = guaranteed 'no-coast-found' on any fresh page (the
|
|
1228
|
+
// same race class probeWarm was built for, witnessed via clean-HEAD stash A/B).
|
|
1229
|
+
const pw = await this.probeWarm(); if(!pw.ok) return {err:'probe-warm-timeout'};
|
|
1230
|
+
let best=null;
|
|
1231
|
+
for(let i=0;i<samples && !best;i++){
|
|
1232
|
+
const y=1-2*(i+0.5)/samples, r=Math.sqrt(Math.max(0,1-y*y)), t=i*2.399963229;
|
|
1233
|
+
const u=[Math.cos(t)*r, y, Math.sin(t)*r];
|
|
1234
|
+
const h0=sg(u); if(h0<=0||h0>400) continue; // low coastal land
|
|
1235
|
+
for(const e of [0.004, 0.008, 0.016]){ // 25/50/100km rings: any sub-sea neighbor?
|
|
1236
|
+
for(const d of [[e,0,0],[-e,0,0],[0,e,0],[0,-e,0],[0,0,e],[0,0,-e]]){
|
|
1237
|
+
const v=[u[0]+d[0],u[1]+d[1],u[2]+d[2]]; const L=Math.hypot(...v);
|
|
1238
|
+
const vn=[v[0]/L,v[1]/L,v[2]/L];
|
|
1239
|
+
if(sg(vn) < -5){
|
|
1240
|
+
// BISECT onto the h=0 crossing (a ring hit can sit ~100km from the waterline), then
|
|
1241
|
+
// REQUIRE real depth near it: with the shelf remap a flat coast can be centimetres
|
|
1242
|
+
// deep for kilometres = physically invisible water (witnessed coverage 0 at a true
|
|
1243
|
+
// waterline). Walk seaward from the crossing; accept only if depth < -8m within ~4km,
|
|
1244
|
+
// and park midway so the frame holds beach AND visibly deep water.
|
|
1245
|
+
let a=u, b=vn;
|
|
1246
|
+
for(let s=0;s<14;s++){ const m=[(a[0]+b[0])/2,(a[1]+b[1])/2,(a[2]+b[2])/2];
|
|
1247
|
+
const ml=Math.hypot(...m); const mn=[m[0]/ml,m[1]/ml,m[2]/ml];
|
|
1248
|
+
if(sg(mn)>0) a=mn; else b=mn; }
|
|
1249
|
+
const step=[(b[0]-a[0]),(b[1]-a[1]),(b[2]-a[2])];
|
|
1250
|
+
const sl=Math.hypot(...step)||1e-9;
|
|
1251
|
+
// VISIBLE-DEPTH PARK (perf sweep 2026-06-11): the old -8m-within-4km midpoint park framed
|
|
1252
|
+
// centimetres-deep shelf -- alpha ~0, toggle mask 0 px at a TRUE waterline (witnessed:
|
|
1253
|
+
// midpoint pose 0 px vs a -31m aim 51580/52686 px at the same coast). Walk seaward up to
|
|
1254
|
+
// 15km for water deeper than -30m and park THERE; the 2km oblique still holds the shore.
|
|
1255
|
+
for(let kkm=0.5;kkm<=15.0;kkm+=0.5){
|
|
1256
|
+
const t=(kkm/6360)/sl;
|
|
1257
|
+
const p=[a[0]+step[0]*t, a[1]+step[1]*t, a[2]+step[2]*t];
|
|
1258
|
+
const pl=Math.hypot(...p); const pn=[p[0]/pl,p[1]/pl,p[2]/pl];
|
|
1259
|
+
if(sg(pn) < -30){
|
|
1260
|
+
best={u:pn, h:Math.round(sg(a)), ringKm:Math.round(e*6360), deepAtKm:kkm};
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if(best) break;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if(best) break;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if(!best) return {err:'no-coast-found'};
|
|
1271
|
+
// 2km pose inside the 4km wave-distance fade, SUN-LIT (a nadir view of unlit water gives
|
|
1272
|
+
// sub-quantization wave shading -- ndl/spec ~0 and fresnel flat at dot~1 -- witnessed
|
|
1273
|
+
// animatedPx 0 on a real animated ocean 2026-06-11). Sun pinned oblique like landWitness.
|
|
1274
|
+
const camC=window.__dbg.cam, latC=Math.asin(best.u[1]), lonC=Math.atan2(best.u[0],best.u[2]);
|
|
1275
|
+
camC.sunLatBase=Math.max(-1.4, latC-0.5); camC.sunLonBase=lonC; camC.sunLonAccum=0; camC.timeScale=0;
|
|
1276
|
+
await this.aimDir(best.u, Math.min(altKm, 2), 40);
|
|
1277
|
+
const grab=()=>{ const {px,w,h}=this._read(); return {px,w,h}; };
|
|
1278
|
+
const diffPx=(a,b,stride)=>{ let m=0; for(let i=0;i<a.w*a.h;i+=stride){ const k=i*4;
|
|
1279
|
+
if(Math.abs(a.px[k]-b.px[k])+Math.abs(a.px[k+1]-b.px[k+1])+Math.abs(a.px[k+2]-b.px[k+2])>18) m++; } return m; };
|
|
1280
|
+
const a1=grab();
|
|
1281
|
+
window.__waterSurface=false; await window.__dbg.frames(8);
|
|
1282
|
+
const b=grab();
|
|
1283
|
+
// water-owned pixel MASK from the toggle diff (stride 7)
|
|
1284
|
+
const mask=[]; for(let i=0;i<a1.w*a1.h;i+=7){ const k=i*4;
|
|
1285
|
+
if(Math.abs(a1.px[k]-b.px[k])+Math.abs(a1.px[k+1]-b.px[k+1])+Math.abs(a1.px[k+2]-b.px[k+2])>18) mask.push(k); }
|
|
1286
|
+
window.__waterSurface=true; await window.__dbg.frames(4);
|
|
1287
|
+
const a2=grab(); await window.__dbg.frames(10); // waves animate? compare ONLY water px,
|
|
1288
|
+
const a3=grab(); let animated=0; // fine threshold (wave shading is subtle)
|
|
1289
|
+
for(const k of mask){ if(Math.abs(a2.px[k]-a3.px[k])+Math.abs(a2.px[k+1]-a3.px[k+1])+Math.abs(a2.px[k+2]-a3.px[k+2])>5) animated++; }
|
|
1290
|
+
const total=Math.floor(a1.w*a1.h/7);
|
|
1291
|
+
return { coastDir:best.u.map(v=>+v.toFixed(3)), coastH:Math.round(best.h), ringKm:best.ringKm,
|
|
1292
|
+
waterCoveragePx:mask.length, coverageFrac:+(mask.length/total).toFixed(3),
|
|
1293
|
+
animatedPx:animated, glError:gl.getError(),
|
|
1294
|
+
pass: mask.length > total*0.01 && animated > 0 && gl.getError()===0 };
|
|
1295
|
+
},
|
|
1296
|
+
|
|
1297
|
+
// (K6) shadeKeyWitness -- 'normals height-based instead of slope-based' detector (2026-06-11).
|
|
1298
|
+
// Over the landWitness pose, sample a px grid; for each, probe the GPU ground height + a local
|
|
1299
|
+
// slope estimate under the camera ray's world dir; correlate lit LUMINANCE against ELEVATION and
|
|
1300
|
+
// against SLOPE. PASS = |corr(lum,slope)| materially exceeds |corr(lum,elev)| (shading keys on
|
|
1301
|
+
// slope, not height). Uses center-screen rays only (small-angle approx of the down-looking pose).
|
|
1302
|
+
async shadeKeyWitness(grid=12){
|
|
1303
|
+
const orch=window.__planetOrch; const sg=orch&&orch.render&&orch.render.sampleGroundM;
|
|
1304
|
+
if(!sg) return {err:'no-sampleGroundM'};
|
|
1305
|
+
const lw=await this.landWitness(8, 0.0); if(lw.err) return lw; // straight-down pose over high land
|
|
1306
|
+
const u=window.__landDir; const {px,w,h}=this._read();
|
|
1307
|
+
// tangent frame at u
|
|
1308
|
+
const ref=Math.abs(u[1])<0.99?[0,1,0]:[1,0,0];
|
|
1309
|
+
let ux=[ref[1]*u[2]-ref[2]*u[1], ref[2]*u[0]-ref[0]*u[2], ref[0]*u[1]-ref[1]*u[0]];
|
|
1310
|
+
const xl=Math.hypot(...ux); ux=ux.map(v=>v/xl);
|
|
1311
|
+
const uy=[u[1]*ux[2]-u[2]*ux[1], u[2]*ux[0]-u[0]*ux[2], u[0]*ux[1]-u[1]*ux[0]];
|
|
1312
|
+
const span=0.0015; // ~10km half-span at the 8km pose
|
|
1313
|
+
const lums=[], elevs=[], slopes=[];
|
|
1314
|
+
for(let gy=1;gy<grid;gy++) for(let gx=1;gx<grid;gx++){
|
|
1315
|
+
const fx=(gx/grid-0.5)*2*span, fy=(gy/grid-0.5)*2*span;
|
|
1316
|
+
const d=[u[0]+ux[0]*fx+uy[0]*fy, u[1]+ux[1]*fx+uy[1]*fy, u[2]+ux[2]*fx+uy[2]*fy];
|
|
1317
|
+
const L=Math.hypot(...d); const dn=d.map(v=>v/L);
|
|
1318
|
+
const e0=sg(dn); if(e0<=0) continue; // land only
|
|
1319
|
+
const ds=0.00008; // ~500m slope step
|
|
1320
|
+
const d1=[dn[0]+ux[0]*ds,dn[1]+ux[1]*ds,dn[2]+ux[2]*ds]; const l1=Math.hypot(...d1);
|
|
1321
|
+
const d2=[dn[0]+uy[0]*ds,dn[1]+uy[1]*ds,dn[2]+uy[2]*ds]; const l2=Math.hypot(...d2);
|
|
1322
|
+
const gxm=(sg([d1[0]/l1,d1[1]/l1,d1[2]/l1])-e0)/500, gym=(sg([d2[0]/l2,d2[1]/l2,d2[2]/l2])-e0)/500;
|
|
1323
|
+
const sx=Math.round((0.5+fx/(2*span))* (w-1)), sy=Math.round((0.5+fy/(2*span))*(h-1));
|
|
1324
|
+
const k=(sy*w+sx)*4; const lum=0.299*px[k]+0.587*px[k+1]+0.114*px[k+2];
|
|
1325
|
+
if(lum<6) continue;
|
|
1326
|
+
lums.push(lum); elevs.push(e0); slopes.push(Math.hypot(gxm,gym));
|
|
1327
|
+
}
|
|
1328
|
+
const corr=(a,b)=>{ const n=a.length; if(n<8) return 0;
|
|
1329
|
+
const ma=a.reduce((s,v)=>s+v,0)/n, mb=b.reduce((s,v)=>s+v,0)/n;
|
|
1330
|
+
let sa=0,sb=0,sab=0; for(let i=0;i<n;i++){const da=a[i]-ma,db=b[i]-mb; sa+=da*da; sb+=db*db; sab+=da*db;}
|
|
1331
|
+
return sab/Math.sqrt(Math.max(sa*sb,1e-9)); };
|
|
1332
|
+
const cElev=+corr(lums,elevs).toFixed(3), cSlope=+corr(lums,slopes).toFixed(3);
|
|
1333
|
+
return { n:lums.length, corrElev:cElev, corrSlope:cSlope,
|
|
1334
|
+
pass: lums.length>=20 && Math.abs(cSlope) + 0.05 >= Math.abs(cElev) };
|
|
1335
|
+
},
|
|
1336
|
+
|
|
1337
|
+
// (K7) materialWitness -- 'grass turning into rocky patches' detector (2026-06-11). Parks
|
|
1338
|
+
// STRAIGHT DOWN over LOWLAND (h 250-1200m -- NOT landWitness, which picks the HIGHEST land and
|
|
1339
|
+
// frames snowy peaks; witnessed verify-material [202,212,214] 99.5% grey = snow, a tool mis-pose).
|
|
1340
|
+
// Straight-down at low altitude also keeps aerial haze out of the census. PASS = the rock-grey
|
|
1341
|
+
// class does not dominate a vegetated lowland frame.
|
|
1342
|
+
async materialWitness(altKm=3, samples=1200){
|
|
1343
|
+
const orch=window.__planetOrch; const sg=orch&&orch.render&&orch.render.sampleGroundM;
|
|
1344
|
+
if(!sg) return {err:'no-sampleGroundM'};
|
|
1345
|
+
let best=null;
|
|
1346
|
+
for(let i=0;i<samples;i++){
|
|
1347
|
+
const y=1-2*(i+0.5)/samples, r=Math.sqrt(Math.max(0,1-y*y)), t=i*2.399963229;
|
|
1348
|
+
const u=[Math.cos(t)*r, y, Math.sin(t)*r];
|
|
1349
|
+
const e0=sg(u);
|
|
1350
|
+
if(e0>250 && e0<1200 && Math.abs(y)<0.5){ best={u,h:e0}; break; } // temperate lowland
|
|
1351
|
+
}
|
|
1352
|
+
if(!best) return {err:'no-lowland-found'};
|
|
1353
|
+
await this.aimDir(best.u, altKm, 40);
|
|
1354
|
+
const {px,w,h}=this._read(); let grass=0,grey=0,sand=0,other=0,n=0,rs=0,gs=0,bs=0;
|
|
1355
|
+
for(let i=0;i<w*h;i++){ const R=px[i*4],G=px[i*4+1],B=px[i*4+2];
|
|
1356
|
+
if(R<12&&G<14&&B<30) continue; n++; rs+=R;gs+=G;bs+=B;
|
|
1357
|
+
const mx=Math.max(R,G,B), mn=Math.min(R,G,B);
|
|
1358
|
+
if(G>R&&G>B) grass++;
|
|
1359
|
+
else if(mx-mn<22) grey++; // low-chroma = rock/grey class
|
|
1360
|
+
else if(R>G&&G>B) sand++; else other++; }
|
|
1361
|
+
return { dir:best.u.map(v=>+v.toFixed(3)), lowlandH:Math.round(best.h), n,
|
|
1362
|
+
grassFrac:+(grass/n).toFixed(3), greyFrac:+(grey/n).toFixed(3),
|
|
1363
|
+
sandFrac:+(sand/n).toFixed(3), meanRGB:n?[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]:[0,0,0],
|
|
1364
|
+
pass: n>1000 && grey/n < 0.45 };
|
|
1365
|
+
},
|
|
1366
|
+
|
|
1367
|
+
// (K8) verifyAll -- THE permanent make-sure-it-works suite (user 2026-06-11 'make absolutely
|
|
1368
|
+
// sure your fixes work, permanent policy'). One call, JSON verdicts. Every render/shader fix
|
|
1369
|
+
// runs this (plus its targeted probe) BEFORE commit.
|
|
1370
|
+
async verifyAll(){
|
|
1371
|
+
const out={};
|
|
1372
|
+
out.ready = await this.assertReady(); if(!out.ready.ok) return out;
|
|
1373
|
+
out.probeWarm = await this.probeWarm(); if(!out.probeWarm.ok) return out;
|
|
1374
|
+
out.coast = await this.coastWitness().catch(e=>({err:String(e&&e.message||e)}));
|
|
1375
|
+
out.material = await this.materialWitness().catch(e=>({err:String(e&&e.message||e)}));
|
|
1376
|
+
out.shadeKey = await this.shadeKeyWitness().catch(e=>({err:String(e&&e.message||e)}));
|
|
1377
|
+
out.limb = await this.limbScan().catch(e=>({err:String(e&&e.message||e)}));
|
|
1378
|
+
out.pass = !!(out.coast.pass && out.material.pass && out.shadeKey.pass && out.limb.pass);
|
|
1379
|
+
return out;
|
|
1380
|
+
},
|
|
1381
|
+
|
|
1382
|
+
// (L) limbScan -- NUMERIC detector for grazing-horizon mesh holes/slivers AND
|
|
1383
|
+
// hPrime shard streaks (recall mem-1779968014552 terrain.glsl:73, mem-1779969534623
|
|
1384
|
+
// limb holes). Scans columns across the buffer; within each column finds the lit
|
|
1385
|
+
// terrain band (first..last non-background pixel) and counts BLACK runs INSIDE it
|
|
1386
|
+
// (holes/shards punched through filled terrain). Reports max/total black-run pixels.
|
|
1387
|
+
async limbScan(cols=64){
|
|
1388
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1389
|
+
await window.__dbg.frames(2);
|
|
1390
|
+
const {px,w,h}=this._read();
|
|
1391
|
+
const isBg=(r,g,b)=>r<12&&g<14&&b<30; // space clear color
|
|
1392
|
+
const isBlack=(r,g,b)=>r<10&&g<10&&b<10; // hole/shard punched through terrain
|
|
1393
|
+
let maxRun=0,totalBlack=0,bandCols=0,worstCol=-1;
|
|
1394
|
+
for(let c=0;c<cols;c++){
|
|
1395
|
+
const x=Math.round(c*(w-1)/(cols-1));
|
|
1396
|
+
// find terrain band top..bottom in this column (non-bg span)
|
|
1397
|
+
let top=-1,bot=-1;
|
|
1398
|
+
for(let y=0;y<h;y++){const i=(y*w+x)*4; if(!isBg(px[i],px[i+1],px[i+2])){ if(top<0)top=y; bot=y; }}
|
|
1399
|
+
if(top<0) continue; bandCols++;
|
|
1400
|
+
let run=0;
|
|
1401
|
+
for(let y=top;y<=bot;y++){const i=(y*w+x)*4;
|
|
1402
|
+
if(isBlack(px[i],px[i+1],px[i+2])){ run++; totalBlack++; if(run>maxRun){maxRun=run;worstCol=x;} }
|
|
1403
|
+
else run=0; }
|
|
1404
|
+
}
|
|
1405
|
+
return { cols, bandCols, maxBlackRunPx:maxRun, totalBlackInBandPx:totalBlack,
|
|
1406
|
+
blackFracOfBand:+(totalBlack/Math.max(bandCols*h,1)).toFixed(4), worstColX:worstCol,
|
|
1407
|
+
// PASS = essentially no black holes/shards inside the lit terrain band.
|
|
1408
|
+
pass: maxRun<=2 && totalBlack < bandCols*2 };
|
|
1409
|
+
},
|
|
1410
|
+
|
|
1411
|
+
// (M) hazeProbe -- NUMERIC blue-wash metric for the mid-altitude haze defect
|
|
1412
|
+
// (recall mem-1779969534623 2000-4000km blue haze washing out land). Compares
|
|
1413
|
+
// mean land-band color WITH atmosphere (displayMode 0) vs the albedo reference
|
|
1414
|
+
// (displayMode 2, no atmospheric scatter). blueExcess = how much bluer/greyer the
|
|
1415
|
+
// lit frame is than albedo => the haze magnitude as a single number.
|
|
1416
|
+
async hazeProbe(){
|
|
1417
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1418
|
+
const d=window.__dbg;
|
|
1419
|
+
const meanLand=()=>{ const {px,w,h}=this._read(); let r=0,g=0,b=0,n=0;
|
|
1420
|
+
for(let i=0;i<w*h;i++){const R=px[i*4],G=px[i*4+1],B=px[i*4+2];
|
|
1421
|
+
if(R<12&&G<14&&B<30) continue; // skip background
|
|
1422
|
+
r+=R;g+=G;b+=B;n++; }
|
|
1423
|
+
n=Math.max(n,1); return {r:r/n,g:g/n,b:b/n,n}; };
|
|
1424
|
+
const prev=window.__displayMode|0;
|
|
1425
|
+
window.__displayMode=0; await d.frames(8); const lit=meanLand();
|
|
1426
|
+
window.__displayMode=2; await d.frames(8); const alb=meanLand();
|
|
1427
|
+
window.__displayMode=prev; await d.frames(4);
|
|
1428
|
+
// Blue excess: lit blue lift minus its red lift, normalized. High => hazy/washed.
|
|
1429
|
+
const blueExcess=+(((lit.b-alb.b)-(lit.r-alb.r))/255).toFixed(3);
|
|
1430
|
+
const chromaLoss=+((1-(Math.abs(alb.r-alb.b)+1)/(Math.abs(lit.r-lit.b)+1))).toFixed(3);
|
|
1431
|
+
return { litMean:[Math.round(lit.r),Math.round(lit.g),Math.round(lit.b)],
|
|
1432
|
+
albedoMean:[Math.round(alb.r),Math.round(alb.g),Math.round(alb.b)],
|
|
1433
|
+
blueExcess, chromaLoss, altKm:+d.altKm().toFixed(0),
|
|
1434
|
+
// PASS = haze does not dominate: blue lift over albedo stays modest.
|
|
1435
|
+
pass: blueExcess < 0.12 };
|
|
1436
|
+
},
|
|
1437
|
+
|
|
1438
|
+
// (N) speckleProbe -- NUMERIC high-frequency-energy metric for the close-up
|
|
1439
|
+
// speckle defect (recall mem-1779968014552). Reads a center crop and sums the
|
|
1440
|
+
// absolute luminance gradient between adjacent pixels; smooth terrain => low
|
|
1441
|
+
// energy, speckle => high energy. Run lit (0) AND lit-only (7) to separate
|
|
1442
|
+
// albedo speckle from lighting/normal speckle.
|
|
1443
|
+
async speckleProbe(displayMode=0, cropFrac=0.4){
|
|
1444
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1445
|
+
const d=window.__dbg; const prev=window.__displayMode|0;
|
|
1446
|
+
window.__displayMode=displayMode|0; await d.frames(8);
|
|
1447
|
+
const {px,w,h}=this._read();
|
|
1448
|
+
window.__displayMode=prev; await d.frames(2);
|
|
1449
|
+
const cw=Math.round(w*cropFrac), ch=Math.round(h*cropFrac);
|
|
1450
|
+
const x0=((w-cw)/2)|0, y0=((h-ch)/2)|0;
|
|
1451
|
+
const isBg=(x,y)=>{const i=(y*w+x)*4; return px[i]<12&&px[i+1]<14&&px[i+2]<30;};
|
|
1452
|
+
const lumAt=(x,y)=>{const i=(y*w+x)*4; return (px[i]+px[i+1]+px[i+2])/3;};
|
|
1453
|
+
// Only count INTERIOR terrain pixels -- a pixel whose 3x3 neighborhood is all
|
|
1454
|
+
// non-background. This excludes the planet silhouette edge (disc-against-space),
|
|
1455
|
+
// which otherwise spikes the gradient at high altitude (false speckle).
|
|
1456
|
+
const interior=(x,y)=>{ for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
|
|
1457
|
+
const xx=x+dx,yy=y+dy; if(xx<0||yy<0||xx>=w||yy>=h||isBg(xx,yy)) return false; } return true; };
|
|
1458
|
+
let energy=0,n=0,disc=0;
|
|
1459
|
+
for(let y=y0;y<y0+ch-1;y++) for(let x=x0;x<x0+cw-1;x++){
|
|
1460
|
+
if(!interior(x,y)) continue; disc++;
|
|
1461
|
+
energy+=Math.abs(lumAt(x,y)-lumAt(x+1,y))+Math.abs(lumAt(x,y)-lumAt(x,y+1)); n++; }
|
|
1462
|
+
const meanGrad=+(energy/Math.max(n,1)).toFixed(2);
|
|
1463
|
+
return { displayMode:displayMode|0, cropFrac, meanGradient:meanGrad, interiorPixels:disc,
|
|
1464
|
+
altKm:+d.altKm().toFixed(1),
|
|
1465
|
+
// PASS = adjacent-pixel luminance gradient stays low (smooth, no speckle).
|
|
1466
|
+
// discPixels==0 => disc too small at this altitude to measure (degenerate, not pass).
|
|
1467
|
+
pass: disc>500 && meanGrad < 12, degenerate: disc<=500 };
|
|
1468
|
+
},
|
|
1469
|
+
|
|
1470
|
+
// (O) seamProbe -- NUMERIC shading-seam detector in SCREEN SPACE. Scans the buffer
|
|
1471
|
+
// for sharp luminance discontinuities (an edge sharper than its neighborhood) that
|
|
1472
|
+
// form long axis-aligned runs == tile boundaries (recall mem-1779895485751 shading
|
|
1473
|
+
// seam). Reports the count of strong-edge pixels organized into long straight runs.
|
|
1474
|
+
async seamProbe(threshold=40){
|
|
1475
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1476
|
+
await window.__dbg.frames(2);
|
|
1477
|
+
const {px,w,h}=this._read();
|
|
1478
|
+
const isBg=(x,y)=>{const i=(y*w+x)*4; return px[i]<12&&px[i+1]<14&&px[i+2]<30;};
|
|
1479
|
+
const lumAt=(x,y)=>{const i=(y*w+x)*4; return (px[i]+px[i+1]+px[i+2])/3;};
|
|
1480
|
+
// Only count a step when BOTH pixels are INTERIOR terrain (3x3 all non-bg) -- the
|
|
1481
|
+
// disc silhouette against space is a huge legitimate step that must NOT count as a
|
|
1482
|
+
// shading seam (it spiked the space rung to a false fail in the first baseline).
|
|
1483
|
+
const interior=(x,y)=>{ for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
|
|
1484
|
+
const xx=x+dx,yy=y+dy; if(xx<0||yy<0||xx>=w||yy>=h||isBg(xx,yy)) return false; } return true; };
|
|
1485
|
+
let hSteps=0,vSteps=0,disc=0,maxStep=0;
|
|
1486
|
+
for(let y=1;y<h-1;y++) for(let x=1;x<w-1;x++){
|
|
1487
|
+
if(!interior(x,y)) continue; disc++;
|
|
1488
|
+
if(interior(x+1,y)){const s=Math.abs(lumAt(x,y)-lumAt(x+1,y)); if(s>maxStep)maxStep=s; if(s>threshold)hSteps++;}
|
|
1489
|
+
if(interior(x,y+1)){const s=Math.abs(lumAt(x,y)-lumAt(x,y+1)); if(s>threshold)vSteps++;}
|
|
1490
|
+
}
|
|
1491
|
+
return { threshold, strongHSteps:hSteps, strongVSteps:vSteps, maxStep:+maxStep.toFixed(1),
|
|
1492
|
+
strongStepFrac:+((hSteps+vSteps)/Math.max(disc*2,1)).toFixed(4), interiorPixels:disc,
|
|
1493
|
+
// PASS = few strong INTERIOR steps (real terrain has gentle gradients; seams spike this).
|
|
1494
|
+
pass: disc>500 && (hSteps+vSteps) < disc*0.01, degenerate: disc<=500 };
|
|
1495
|
+
},
|
|
1496
|
+
|
|
1497
|
+
// (P) assertElevLinear -- guard the LINEAR-filter elevation atlas regression
|
|
1498
|
+
// (recall mem-1779979072187 + HEAD 06470fc: NEAREST => square per-texel cells;
|
|
1499
|
+
// needs OES_texture_float_linear). One dispatch confirms the extension + filter.
|
|
1500
|
+
assertElevLinear(){
|
|
1501
|
+
const gl=this._gl(); if(!gl) return {err:'no-gl'};
|
|
1502
|
+
const ext = gl.getExtension('OES_texture_float_linear');
|
|
1503
|
+
const orch=window.__planetOrch||{};
|
|
1504
|
+
const filt = orch.elevFilter || orch.elevTexFilter || null; // orchestrator may expose it
|
|
1505
|
+
return { OES_texture_float_linear: !!ext, reportedElevFilter:filt,
|
|
1506
|
+
// PASS = the float-linear extension is present so the atlas CAN sample LINEAR.
|
|
1507
|
+
pass: !!ext };
|
|
1508
|
+
},
|
|
1509
|
+
|
|
1510
|
+
// (Q) assertAtlasBudget DELETED (perf sweep 2026-06-11): it asserted against orch.cacheLayers,
|
|
1511
|
+
// plumbing the atlas deletion orphaned -- no module consumed cacheLayers, so the probe always
|
|
1512
|
+
// compared the 1920 fallback literal. Dead apparatus, removed with the rest of the plumbing.
|
|
1513
|
+
|
|
1514
|
+
// (R0) reloadShaders -- HOT-RELOAD terrain.glsl + atmosphere.glsl WITHOUT a page reload.
|
|
1515
|
+
// Re-fetches both files (cache-busted), recompiles the GL program, swaps it atomically.
|
|
1516
|
+
// Returns {ok:true} or {ok:false,error:'<compile log>'} so a bad shader edit is reported
|
|
1517
|
+
// INLINE in one dispatch (the old shader keeps running on failure -- no broken page). The
|
|
1518
|
+
// biggest cut to the shader-edit debug loop: edit terrain.glsl -> reloadShaders() -> probe,
|
|
1519
|
+
// all without a nav (which costs init time + risks the ~10-nav browser crash).
|
|
1520
|
+
async reloadShaders(){
|
|
1521
|
+
const orch=window.__planetOrch; const rnd=orch&&orch.render;
|
|
1522
|
+
if(!rnd||!rnd.recompile) return {err:'no-recompile (render.recompile missing)'};
|
|
1523
|
+
const r=await rnd.recompile();
|
|
1524
|
+
if(r.ok){ orch.clearCache && orch.clearCache(); await window.__dbg.frames(4); }
|
|
1525
|
+
return r;
|
|
1526
|
+
},
|
|
1527
|
+
// (R0b) resetDiag -- restore EVERY diagnostic window.__ global to its default, then reload.
|
|
1528
|
+
// Guards the stale-global trap (2026-06-09: a left-over __maxLevel=11 from an LOD test over-smoothed
|
|
1529
|
+
// the whole render = 'worse than the stairsteps'). Run this after any A/B diagnostic sweep. Defaults
|
|
1530
|
+
// mirror the orchestrator/shader defaults: maxLevel 16, splitFactor 1.0, flatNormal/hiFreqCut/cliffAmt
|
|
1531
|
+
// null (=> shader/JS default), vtxDetail 1.0, and all anchor-step A/B toggles off.
|
|
1532
|
+
async resetDiag(){
|
|
1533
|
+
const g=window;
|
|
1534
|
+
g.__maxLevel=16; g.__splitFactor=1.0; g.__flatNormal=0; g.__vtxDetail=1.0;
|
|
1535
|
+
g.__hiFreqCut=null; g.__cliffAmt=null; g.__canyonDepth=null; g.__hpfInset=null;
|
|
1536
|
+
g.__mtnBandWide=0; g.__climateRelief=0; g.__isleWide=0; g.__carveWide=0;
|
|
1537
|
+
const orch=window.__planetOrch; orch && orch.clearCache && orch.clearCache();
|
|
1538
|
+
const r=await this.reloadShaders();
|
|
1539
|
+
return { ...r, reset:['maxLevel=16','splitFactor=1','flatNormal=0','vtxDetail=1','hiFreqCut/cliffAmt/canyonDepth/hpfInset=default','toggles=0'] };
|
|
1540
|
+
},
|
|
1541
|
+
// (R) displayModes -- self-documenting map of the diagnostic displayModes baked
|
|
1542
|
+
// into terrain.glsl (recall mem-1779969534623). Removes the guess of which number
|
|
1543
|
+
// shows what. cycleDisplayModes renders each so a witness can screenshot the set.
|
|
1544
|
+
displayModes(){ return {0:'lit',1:'normals',2:'albedo',4:'biome-ramp',5:'land/sea',6:'signed-elev',7:'river-field',8:'river-gating',9:'biome-map',10:'canyon-field'}; },
|
|
1545
|
+
async cycleDisplayModes(){ const d=window.__dbg; const prev=window.__displayMode|0; const out={};
|
|
1546
|
+
for(const m of [0,1,2,4,5,6,7,8,9,10]){ window.__displayMode=m; await d.frames(6); out[m]=await this.census(m); }
|
|
1547
|
+
window.__displayMode=prev; await d.frames(4); return out; },
|
|
1548
|
+
|
|
1549
|
+
// =========================================================================
|
|
1550
|
+
// MOTION-DOMAIN DEBUG GLOBALS (added 2026-05-29). The static parkAt probes
|
|
1551
|
+
// TELEPORT; every camera-motion bug (fly-through, flip, NaN-lookAt device-loss)
|
|
1552
|
+
// is reproducible ONLY by SIMULATING ACTUAL FRAME-BY-FRAME MOTION (recall
|
|
1553
|
+
// mem-1779785586520: teleport did NOT reproduce). These drive the REAL W-key
|
|
1554
|
+
// path via window.__keys and sample the live page each frame.
|
|
1555
|
+
// =========================================================================
|
|
1556
|
+
|
|
1557
|
+
// (T) parkOblique -- look at the HORIZON (tangent forward + small downward dip),
|
|
1558
|
+
// not the nadir. The disappear-on-W / blocks-not-rendering bugs live in oblique
|
|
1559
|
+
// views (recall mem-1779958788147: cone-cull zeroed on-screen oblique quads).
|
|
1560
|
+
async parkOblique(altKm=50, dip=0.25, settleFrames=8){
|
|
1561
|
+
window.__dbg.gotoOblique(altKm, dip);
|
|
1562
|
+
await window.__dbg.frames(settleFrames);
|
|
1563
|
+
return { altKm:+window.__dbg.altKm().toFixed(1), quads:window.__dbg.quads(), glError:window.__dbg.glError() };
|
|
1564
|
+
},
|
|
1565
|
+
|
|
1566
|
+
// (U) coverageGrid -- n x n hole map. A MISSING BLOCK shows as a near-zero
|
|
1567
|
+
// interior cell surrounded by terrain cells. Far more localizing than one scalar.
|
|
1568
|
+
coverageGrid(n=8){
|
|
1569
|
+
const gl=this._gl(); if(!gl) return {err:'no-gl'};
|
|
1570
|
+
const {px,w,h}=this._read();
|
|
1571
|
+
const isBg=(r,g,b)=>r<12&&g<14&&b<30;
|
|
1572
|
+
const grid=[]; let emptyInterior=0;
|
|
1573
|
+
for(let gy=0;gy<n;gy++){ const row=[];
|
|
1574
|
+
for(let gx=0;gx<n;gx++){
|
|
1575
|
+
let nz=0,tot=0; const x0=(gx*w/n)|0,x1=((gx+1)*w/n)|0,y0=(gy*h/n)|0,y1=((gy+1)*h/n)|0;
|
|
1576
|
+
for(let y=y0;y<y1;y++) for(let x=x0;x<x1;x++){ const i=(y*w+x)*4; tot++; if(!isBg(px[i],px[i+1],px[i+2])) nz++; }
|
|
1577
|
+
row.push(+(nz/Math.max(tot,1)).toFixed(2)); }
|
|
1578
|
+
grid.push(row); }
|
|
1579
|
+
// an interior cell (not on the frame border) that is empty while >=3 of its
|
|
1580
|
+
// 4-neighbours are mostly-terrain is a suspected DROPPED BLOCK (hole).
|
|
1581
|
+
for(let gy=1;gy<n-1;gy++) for(let gx=1;gx<n-1;gx++){
|
|
1582
|
+
if(grid[gy][gx]<0.05){ const nb=[grid[gy-1][gx],grid[gy+1][gx],grid[gy][gx-1],grid[gy][gx+1]].filter(v=>v>0.5).length;
|
|
1583
|
+
if(nb>=3) emptyInterior++; } }
|
|
1584
|
+
return { n, grid, emptyInteriorCells:emptyInterior, pass: emptyInterior===0 };
|
|
1585
|
+
},
|
|
1586
|
+
|
|
1587
|
+
// (V) assertFiniteVP -- the actual draw matrix all-finite + context not lost.
|
|
1588
|
+
// NaN viewProj (degenerate lookAt) -> all verts NaN -> nothing draws.
|
|
1589
|
+
assertFiniteVP(){
|
|
1590
|
+
return { vpFinite: window.__lastVPFinite !== false, deviceLost: !!window.__deviceLost,
|
|
1591
|
+
hasVP: Array.isArray(window.__lastVP),
|
|
1592
|
+
pass: window.__lastVPFinite !== false && !window.__deviceLost };
|
|
1593
|
+
},
|
|
1594
|
+
|
|
1595
|
+
// (W) blockCensus -- per-quad render audit: drawn-full vs ancestor-fallback vs the
|
|
1596
|
+
// total visible, by level. 'blocks not rendering' = quads dropped from the draw.
|
|
1597
|
+
blockCensus(){
|
|
1598
|
+
const q=window.__lastGLQuads||[]; const f=window.__webglFrame||{};
|
|
1599
|
+
const byLevel={}, fbByLevel={}; let fb=0;
|
|
1600
|
+
for(const x of q){ const l=x.quad.level|0; byLevel[l]=(byLevel[l]||0)+1; if(x.subregion){fb++; fbByLevel[l]=(fbByLevel[l]||0)+1;} }
|
|
1601
|
+
return { drawn:q.length, fallback:fb, fullTile:q.length-fb, byLevel, fallbackByLevel:fbByLevel,
|
|
1602
|
+
culledCount:f.culledCount|0, residentCount:f.residentCount|0, fallbackCount:f.fallbackCount|0, glError:f.glError|0 };
|
|
1603
|
+
},
|
|
1604
|
+
|
|
1605
|
+
// (X) quadTransformProbe -- replicate the VS clip transform on the CPU (renderer's
|
|
1606
|
+
// probe()) to count quads projecting fully off-screen. Separates 'culled' (orch
|
|
1607
|
+
// dropped it) from 'transformed off-screen' (VS/projection bug) -- distinct causes.
|
|
1608
|
+
quadTransformProbe(){
|
|
1609
|
+
const orch=window.__planetOrch, rnd=orch&&orch.render; if(!rnd||!rnd.probe) return {err:'no-probe'};
|
|
1610
|
+
const q=window.__lastGLQuads||[]; if(!q.length) return {err:'no-quads'};
|
|
1611
|
+
const eyeM=cam.pos.map(v=>v*WEBGL2_M_PER_UNIT);
|
|
1612
|
+
const camArg={ eye:eyeM, center:[eyeM[0]+cam.fwd[0]*WEBGL2_TERRAIN_R_M,eyeM[1]+cam.fwd[1]*WEBGL2_TERRAIN_R_M,eyeM[2]+cam.fwd[2]*WEBGL2_TERRAIN_R_M], up:cam.up, fovy:45*Math.PI/180 };
|
|
1613
|
+
let off=0,on=0; const out=rnd.probe(q, camArg);
|
|
1614
|
+
for(const o of out){ if(o.ndc.every(c=>c.off)) off++; else on++; }
|
|
1615
|
+
return { totalQuads:q.length, fullyOffScreen:off, onScreen:on, offFrac:+(off/Math.max(q.length,1)).toFixed(3) };
|
|
1616
|
+
},
|
|
1617
|
+
|
|
1618
|
+
// lodCenterProbe -- WITNESS that the deepest-LOD leaves sit UNDER the camera nadir (the
|
|
1619
|
+
// recurring "LOD center lags the camera / wrong area detailed" report). For every deepest
|
|
1620
|
+
// (highest-level) leaf, map its face-local centre through the SAME tangent warp the VS uses
|
|
1621
|
+
// (faceWarp: wx=R*tan((ox/R)*pi/4)) + the face frame to a world direction, and measure the
|
|
1622
|
+
// angular distance to the camera nadir (cam.pos normalized). deepestMinAngToNadir < ~0.5deg
|
|
1623
|
+
// means the finest LOD is directly under the camera (correct); a large angle means the LOD
|
|
1624
|
+
// center has drifted off the camera. Pure read of __lastGLQuads -- run it after any move.
|
|
1625
|
+
lodCenterProbe(){
|
|
1626
|
+
const q=window.__lastGLQuads||[]; if(!q.length) return {err:'no-quads'};
|
|
1627
|
+
const R=WEBGL2_TERRAIN_R_M, K=Math.PI/4;
|
|
1628
|
+
const cl=Math.hypot(...cam.pos)||1; const nadir=[cam.pos[0]/cl,cam.pos[1]/cl,cam.pos[2]/cl];
|
|
1629
|
+
let maxLevel=0; for(const o of q){ const l=(o.quad?o.quad.level:o.level)||0; if(l>maxLevel)maxLevel=l; }
|
|
1630
|
+
let minA=999, maxA=0, n=0;
|
|
1631
|
+
for(const o of q){ const Q=o.quad||o; if((Q.level||0)!==maxLevel) continue; n++;
|
|
1632
|
+
const F=this._FACE_FRAME[o.face]; const cx=Q.ox+Q.l*0.5, cy=Q.oy+Q.l*0.5;
|
|
1633
|
+
const wx=R*Math.tan((cx/R)*K), wy=R*Math.tan((cy/R)*K); const len=Math.hypot(wx,wy,R)||1;
|
|
1634
|
+
const dir=[(wx/len)*F.u[0]+(wy/len)*F.v[0]+(R/len)*F.c[0],
|
|
1635
|
+
(wx/len)*F.u[1]+(wy/len)*F.v[1]+(R/len)*F.c[1],
|
|
1636
|
+
(wx/len)*F.u[2]+(wy/len)*F.v[2]+(R/len)*F.c[2]];
|
|
1637
|
+
const dot=Math.max(-1,Math.min(1,dir[0]*nadir[0]+dir[1]*nadir[1]+dir[2]*nadir[2]));
|
|
1638
|
+
const a=Math.acos(dot)*180/Math.PI; if(a<minA)minA=a; if(a>maxA)maxA=a;
|
|
1639
|
+
}
|
|
1640
|
+
return { maxLevel, nDeepLeaves:n, deepestMinAngToNadir:+minA.toFixed(3),
|
|
1641
|
+
deepestMaxAngToNadir:+maxA.toFixed(3), frame:window.__cullStats?window.__cullStats.frame:null,
|
|
1642
|
+
altKm:Math.round((window.__altM||0)/1000), tracksCamera:(minA<0.5) };
|
|
1643
|
+
},
|
|
1644
|
+
|
|
1645
|
+
// (Y) flyForward -- DRIVE THE REAL W-KEY PATH frame by frame and trace the live
|
|
1646
|
+
// page. Sets window.__keys.w (the actual key the render loop reads), so the exact
|
|
1647
|
+
// moveStep/collision-clamp/quadtree/render code runs. Records per-frame coverage,
|
|
1648
|
+
// quads, finite-VP, device-lost, and the cam.pos delta vs intended step (to catch
|
|
1649
|
+
// a collision-clamp snap). Degenerate-aware: classifies 'flew to space' (planet
|
|
1650
|
+
// legitimately left frame) vs 'terrain wrongly vanished'.
|
|
1651
|
+
async flyForward({frames=40, speed=null, key='w'}={}){
|
|
1652
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1653
|
+
const d=window.__dbg; const prevSpeed=cam.speed; if(speed!=null) cam.speed=speed;
|
|
1654
|
+
const planetInFrame=()=>{ // is the planet center geometrically in front of the camera?
|
|
1655
|
+
const p=cam.pos, len=Math.hypot(...p)||1; const toC=[-p[0]/len,-p[1]/len,-p[2]/len];
|
|
1656
|
+
return (toC[0]*cam.fwd[0]+toC[1]*cam.fwd[1]+toC[2]*cam.fwd[2]) > -0.3; };
|
|
1657
|
+
const trace=[]; let worstDropInFrame=0, anyNonFinite=false, anyDeviceLost=false, snap=0;
|
|
1658
|
+
window.__keys[key]=true;
|
|
1659
|
+
try {
|
|
1660
|
+
for(let i=0;i<frames;i++){
|
|
1661
|
+
const prevPos=cam.pos.slice();
|
|
1662
|
+
await d.frames(1);
|
|
1663
|
+
const moved=Math.hypot(cam.pos[0]-prevPos[0],cam.pos[1]-prevPos[1],cam.pos[2]-prevPos[2]);
|
|
1664
|
+
const cov=this.coverageGrid(4); const fin=this.assertFiniteVP();
|
|
1665
|
+
const inFrame=planetInFrame();
|
|
1666
|
+
const fullCov=cov.grid.flat().filter(v=>v>0.3).length/(4*4); // frac of cells with terrain
|
|
1667
|
+
if(!fin.vpFinite) anyNonFinite=true; if(fin.deviceLost) anyDeviceLost=true;
|
|
1668
|
+
// a position jump much larger than the move (clamp snap) is suspicious
|
|
1669
|
+
const f=window.__webglFrame||{};
|
|
1670
|
+
if(inFrame && fullCov < 0.15 && (f.quadCount|0)>0) worstDropInFrame++;
|
|
1671
|
+
trace.push({ i, altKm:+d.altKm().toFixed(0), quads:f.quadCount|0, fullCov:+fullCov.toFixed(2),
|
|
1672
|
+
inFrame, vpFinite:fin.vpFinite, deviceLost:fin.deviceLost, moved:+moved.toFixed(2) });
|
|
1673
|
+
}
|
|
1674
|
+
} finally { window.__keys[key]=false; if(speed!=null) cam.speed=prevSpeed; }
|
|
1675
|
+
return { frames, speed:speed!=null?speed:prevSpeed, trace,
|
|
1676
|
+
anyNonFinite, anyDeviceLost, dropFramesWhileInFrame:worstDropInFrame,
|
|
1677
|
+
// PASS = terrain never collapsed WHILE the planet was in frame, VP stayed finite,
|
|
1678
|
+
// context never lost. (Coverage legitimately drops when you fly off to space.)
|
|
1679
|
+
pass: worstDropInFrame===0 && !anyNonFinite && !anyDeviceLost };
|
|
1680
|
+
},
|
|
1681
|
+
|
|
1682
|
+
// (Y2) terrainExpected -- horizon-relative: SHOULD the lower-screen show terrain at
|
|
1683
|
+
// this pose? True unless the camera is pitched so far up the planet has left the lower
|
|
1684
|
+
// frame (looking at the sky). Used so rotation probes don't false-fail on a legit
|
|
1685
|
+
// skyward pitch (the rotation analog of flyForward's planetInFrame guard).
|
|
1686
|
+
terrainExpected(){
|
|
1687
|
+
const p=cam.pos, len=Math.hypot(...p)||1; const down=[-p[0]/len,-p[1]/len,-p[2]/len];
|
|
1688
|
+
// dot(fwd, down): >0 means looking down toward the ground -> terrain expected below.
|
|
1689
|
+
// Near the surface even a level/horizon view shows ground in the lower half, so the
|
|
1690
|
+
// threshold is generous; only a strong upward pitch (dot << 0) clears it.
|
|
1691
|
+
return (cam.fwd[0]*down[0]+cam.fwd[1]*down[1]+cam.fwd[2]*down[2]) > -0.25;
|
|
1692
|
+
},
|
|
1693
|
+
|
|
1694
|
+
// (Y3) rotateInPlace -- YAW the camera incrementally about cam.up (the real mouse-look
|
|
1695
|
+
// path: cam.fwd = rotAxis(cam.fwd, cam.up, step)), sampling coverage each step. The
|
|
1696
|
+
// 'turn around and parts not patched' bug is rotation-sensitive; flyForward only
|
|
1697
|
+
// translated. A coverage cell that stays empty while terrain is horizon-expected as we
|
|
1698
|
+
// rotate = the bug. Degenerate-aware via terrainExpected.
|
|
1699
|
+
async rotateInPlace({deg=360, steps=24}={}){
|
|
1700
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1701
|
+
const d=window.__dbg; const stepRad=(deg*Math.PI/180)/steps;
|
|
1702
|
+
const trace=[]; let dropWhileExpected=0, anyNonFinite=false, anyDeviceLost=false;
|
|
1703
|
+
for(let i=0;i<steps;i++){
|
|
1704
|
+
cam.fwd = normalize(rotAxis(cam.fwd, cam.up, stepRad)); // real yaw step
|
|
1705
|
+
await d.frames(3); // let quadtree+tiles settle
|
|
1706
|
+
const cov=this.coverageGrid(4); const fin=this.assertFiniteVP();
|
|
1707
|
+
const fullCov=cov.grid.flat().filter(v=>v>0.3).length/16;
|
|
1708
|
+
const expected=this.terrainExpected(); const f=window.__webglFrame||{};
|
|
1709
|
+
if(!fin.vpFinite) anyNonFinite=true; if(fin.deviceLost) anyDeviceLost=true;
|
|
1710
|
+
if(expected && fullCov < 0.25 && (f.quadCount|0)>0) dropWhileExpected++;
|
|
1711
|
+
trace.push({ i, yawDeg:Math.round(i*deg/steps), altKm:+d.altKm().toFixed(0),
|
|
1712
|
+
quads:f.quadCount|0, fullCov:+fullCov.toFixed(2), expected, vpFinite:fin.vpFinite,
|
|
1713
|
+
emptyInterior:cov.emptyInteriorCells });
|
|
1714
|
+
}
|
|
1715
|
+
return { deg, steps, trace, anyNonFinite, anyDeviceLost, dropStepsWhileExpected:dropWhileExpected,
|
|
1716
|
+
minCovWhileExpected:Math.min(...trace.filter(t=>t.expected).map(t=>t.fullCov),1),
|
|
1717
|
+
// PASS = terrain stays filled through the whole yaw WHILE it is horizon-expected.
|
|
1718
|
+
pass: dropWhileExpected===0 && !anyNonFinite && !anyDeviceLost };
|
|
1719
|
+
},
|
|
1720
|
+
|
|
1721
|
+
// (Y4) flyThenTurn -- the EXACT user gesture: descend via W to low altitude, THEN yaw
|
|
1722
|
+
// turnDeg. Reproduces 'went down and turned around, parts not patched' in one dispatch.
|
|
1723
|
+
async flyThenTurn({descendFrames=12, speed=20, turnDeg=180, turnSteps=18}={}){
|
|
1724
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1725
|
+
const d=window.__dbg;
|
|
1726
|
+
await this.parkOblique(50, 0.25);
|
|
1727
|
+
const prevSpeed=cam.speed; cam.speed=speed; window.__keys.w=true;
|
|
1728
|
+
for(let i=0;i<descendFrames && (window.__altM||1e9)>2500;i++) await d.frames(1);
|
|
1729
|
+
window.__keys.w=false; cam.speed=prevSpeed; await d.frames(4);
|
|
1730
|
+
const descendAltKm=+d.altKm().toFixed(2);
|
|
1731
|
+
const turn=await this.rotateInPlace({deg:turnDeg, steps:turnSteps});
|
|
1732
|
+
return { descendAltKm, turn };
|
|
1733
|
+
},
|
|
1734
|
+
|
|
1735
|
+
// (Z) motionSweep -- the motion analog of baseline(): fly W from each rung, nadir
|
|
1736
|
+
// AND oblique, at several speeds; the COMPLETE-gate for disappear/blocks bugs.
|
|
1737
|
+
async motionSweep(){
|
|
1738
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return (window.__diagMotion={err:'not-ready',detail:rdy});
|
|
1739
|
+
const out={ when:new Date().toISOString(), runs:{} };
|
|
1740
|
+
const cases=[
|
|
1741
|
+
{name:'orbit-nadir', park:()=>this.parkAt('orbit'), speed:50},
|
|
1742
|
+
{name:'lowalt-nadir', park:()=>this.parkAt('lowalt'), speed:5},
|
|
1743
|
+
{name:'lowalt-oblique', park:()=>this.parkOblique(50,0.25), speed:5},
|
|
1744
|
+
{name:'midalt-oblique', park:()=>this.parkOblique(400,0.2), speed:50},
|
|
1745
|
+
{name:'orbit-oblique-fast', park:()=>this.parkOblique(2000,0.15), speed:500},
|
|
1746
|
+
];
|
|
1747
|
+
for(const c of cases){ await c.park(); const r=await this.flyForward({frames:18, speed:c.speed});
|
|
1748
|
+
out.runs[c.name]={ pass:r.pass, dropFrames:r.dropFramesWhileInFrame, nonFinite:r.anyNonFinite,
|
|
1749
|
+
deviceLost:r.anyDeviceLost, minFullCov:Math.min(...r.trace.map(t=>t.fullCov)) }; }
|
|
1750
|
+
// ROTATION cases -- the orientation analog the translation-only sweep missed (user found
|
|
1751
|
+
// 'turn around -> parts not patched'). Yaw 360 in place at low + mid altitude.
|
|
1752
|
+
const rotCases=[
|
|
1753
|
+
{name:'lowalt-yaw360', park:()=>this.parkOblique(50,0.25)},
|
|
1754
|
+
{name:'midalt-yaw360', park:()=>this.parkOblique(400,0.2)},
|
|
1755
|
+
];
|
|
1756
|
+
for(const c of rotCases){ await c.park(); const r=await this.rotateInPlace({deg:360, steps:18});
|
|
1757
|
+
out.runs[c.name]={ pass:r.pass, dropSteps:r.dropStepsWhileExpected, nonFinite:r.anyNonFinite,
|
|
1758
|
+
deviceLost:r.anyDeviceLost, minCovWhileExpected:r.minCovWhileExpected }; }
|
|
1759
|
+
// and the exact user gesture: descend then turn around.
|
|
1760
|
+
{ const r=await this.flyThenTurn({turnDeg:180}); out.runs['fly-then-turn180']={ pass:r.turn.pass,
|
|
1761
|
+
dropSteps:r.turn.dropStepsWhileExpected, descendAltKm:r.descendAltKm, minCovWhileExpected:r.turn.minCovWhileExpected }; }
|
|
1762
|
+
out.pass = Object.values(out.runs).every(R=>R.pass);
|
|
1763
|
+
return (window.__diagMotion=out);
|
|
1764
|
+
},
|
|
1765
|
+
|
|
1766
|
+
// =========================================================================
|
|
1767
|
+
// EFFICIENCY METRIC GLOBALS (added 2026-05-29). The user's three measurable
|
|
1768
|
+
// targets: (1) 4-50 screen-px per triangle edge generally, (2) 1-2 texels per
|
|
1769
|
+
// pixel at all heights, (3) max performance / no bottlenecks. These read the
|
|
1770
|
+
// LAST rendered frame's visible-quad set (__lastGLQuads) + the actual draw
|
|
1771
|
+
// matrix (__lastVP, camera-relative: folds translate(-eye)) and project each
|
|
1772
|
+
// quad's deformed corners to screen -- the rigorous screen-space-error measure,
|
|
1773
|
+
// not a screenshot heuristic. FACE_FRAME mirrors the orchestrator's table (the
|
|
1774
|
+
// single source of truth); kept read-only here so the metric is self-contained.
|
|
1775
|
+
// =========================================================================
|
|
1776
|
+
_FACE_FRAME: [
|
|
1777
|
+
{ c:[ 1,0,0], u:[0,0,-1], v:[0,1,0] }, { c:[-1,0,0], u:[0,0, 1], v:[0,1,0] },
|
|
1778
|
+
{ c:[0, 1,0], u:[1,0,0], v:[0,0,-1] }, { c:[0,-1,0], u:[1,0,0], v:[0,0, 1] },
|
|
1779
|
+
{ c:[0,0, 1], u:[1,0,0], v:[0,1,0] }, { c:[0,0,-1], u:[-1,0,0], v:[0,1,0] },
|
|
1780
|
+
],
|
|
1781
|
+
// Project a face-local quad corner (ox,oy on the cube face, radius rad) to a
|
|
1782
|
+
// screen pixel via __lastVP. Returns {x,y,w} in pixels (w<=0 => behind camera).
|
|
1783
|
+
_projQuadCorner(face, px, py, rad, vp, W, H){
|
|
1784
|
+
const F=this._FACE_FRAME[face]; const len=Math.hypot(px,py,this._R())||1;
|
|
1785
|
+
const wx=(px/len)*F.u[0]+(py/len)*F.v[0]+(this._R()/len)*F.c[0];
|
|
1786
|
+
const wy=(px/len)*F.u[1]+(py/len)*F.v[1]+(this._R()/len)*F.c[1];
|
|
1787
|
+
const wz=(px/len)*F.u[2]+(py/len)*F.v[2]+(this._R()/len)*F.c[2];
|
|
1788
|
+
const X=wx*rad, Y=wy*rad, Z=wz*rad;
|
|
1789
|
+
const cx=vp[0]*X+vp[4]*Y+vp[8]*Z+vp[12];
|
|
1790
|
+
const cy=vp[1]*X+vp[5]*Y+vp[9]*Z+vp[13];
|
|
1791
|
+
const cw=vp[3]*X+vp[7]*Y+vp[11]*Z+vp[15];
|
|
1792
|
+
if(cw<=0) return {x:0,y:0,w:cw};
|
|
1793
|
+
return { x:(cx/cw*0.5+0.5)*W, y:(0.5-cy/cw*0.5)*H, w:cw };
|
|
1794
|
+
},
|
|
1795
|
+
_R(){ return 6360000.0; },
|
|
1796
|
+
// Screen-space area (px^2) of a visible quad's deformed surface (radius ~R), via
|
|
1797
|
+
// the shoelace of its 4 projected corners. Returns 0 if any corner is behind.
|
|
1798
|
+
_quadScreenArea(q, vp, W, H){
|
|
1799
|
+
const R=this._R(), qd=q.quad, ox=qd.ox, oy=qd.oy, l=qd.l;
|
|
1800
|
+
const cs=[[ox,oy],[ox+l,oy],[ox+l,oy+l],[ox,oy+l]]; const p=[];
|
|
1801
|
+
for(const c of cs){ const pr=this._projQuadCorner(q.face,c[0],c[1],R,vp,W,H); if(pr.w<=0) return 0; p.push(pr); }
|
|
1802
|
+
let a=0; for(let i=0;i<4;i++){const j=(i+1)%4; a+=p[i].x*p[j].y-p[j].x*p[i].y;} return Math.abs(a)/2;
|
|
1803
|
+
},
|
|
1804
|
+
|
|
1805
|
+
// (PXP) pxPerPoly -- the screen-space-error metric the user named ("triangle
|
|
1806
|
+
// subdivisions achieve 4-50px per poly generally"). For each ON-SCREEN visible
|
|
1807
|
+
// quad: screen area / (GRID^2 * 2 tris) = px^2 per triangle; sqrt = px per
|
|
1808
|
+
// triangle EDGE (the intuitive "px per poly"). Reports the distribution +
|
|
1809
|
+
// fraction inside the [4,50] target band. GRID=16 => 512 tris/quad (shipped lever).
|
|
1810
|
+
// Default reads the LIVE grid (window.__glGrid set by gl-render) so the metric tracks the
|
|
1811
|
+
// real mesh density; falls back to 16 (the shipped GRID) if the global is not yet set.
|
|
1812
|
+
pxPerPoly(grid){
|
|
1813
|
+
if (grid == null) grid = (typeof window.__glGrid === 'number') ? window.__glGrid : 16;
|
|
1814
|
+
const q=window.__lastGLQuads||[]; const vp=window.__lastVP; if(!vp||!q.length) return {err:'no-frame'};
|
|
1815
|
+
const gl=this._gl(); const W=gl.drawingBufferWidth, H=gl.drawingBufferHeight;
|
|
1816
|
+
const tris=grid*grid*2; const edges=[]; let onScreen=0;
|
|
1817
|
+
for(const x of q){ const area=this._quadScreenArea(x,vp,W,H); if(area<=0) continue;
|
|
1818
|
+
// only count quads that actually overlap the viewport (centroid roughly on screen
|
|
1819
|
+
// OR area large) -- a fully-offscreen but in-front quad would skew the median.
|
|
1820
|
+
const perTri=area/tris; const edge=Math.sqrt(Math.max(perTri,0)); edges.push({edge,level:x.quad.level|0,face:x.face}); onScreen++; }
|
|
1821
|
+
if(!edges.length) return {err:'no-onscreen-quads', total:q.length};
|
|
1822
|
+
edges.sort((a,b)=>a.edge-b.edge); const vals=edges.map(e=>e.edge);
|
|
1823
|
+
const pct=p=>vals[Math.min(vals.length-1,Math.floor(p*vals.length))];
|
|
1824
|
+
const inBand=vals.filter(v=>v>=4&&v<=50).length;
|
|
1825
|
+
return { altKm:+window.__dbg.altKm().toFixed(1), onScreenQuads:onScreen, trisPerQuad:tris,
|
|
1826
|
+
pxPerPolyEdge:{ min:+vals[0].toFixed(2), p10:+pct(0.1).toFixed(2), median:+pct(0.5).toFixed(2),
|
|
1827
|
+
p90:+pct(0.9).toFixed(2), max:+vals[vals.length-1].toFixed(2) },
|
|
1828
|
+
fracInBand_4_50:+(inBand/vals.length).toFixed(3),
|
|
1829
|
+
// PASS = most polys in the 4-50 band (allow tails: coarse-far + maxLevel-floor quads).
|
|
1830
|
+
pass: (inBand/vals.length) >= 0.6 };
|
|
1831
|
+
},
|
|
1832
|
+
|
|
1833
|
+
// (TXP) texelPerPixel -- the material-resolution metric ("materials balance to
|
|
1834
|
+
// 1-2 texels per pixel at all heights"). A tile carries USABLE^2 unique texels
|
|
1835
|
+
// (TILE_W-2*BORDER = 21) across the quad's screen area; texels/pixel =
|
|
1836
|
+
// USABLE^2 / screenArea. >2 => oversampled (blurry, wasting fetch); <1 =>
|
|
1837
|
+
// undersampled (aliased/sparkly). Reports distribution vs the [1,2] target.
|
|
1838
|
+
texelPerPixel(usable=21){
|
|
1839
|
+
const q=window.__lastGLQuads||[]; const vp=window.__lastVP; if(!vp||!q.length) return {err:'no-frame'};
|
|
1840
|
+
const gl=this._gl(); const W=gl.drawingBufferWidth, H=gl.drawingBufferHeight;
|
|
1841
|
+
const texels=usable*usable; const vals=[];
|
|
1842
|
+
for(const x of q){ const area=this._quadScreenArea(x,vp,W,H); if(area<=0) continue;
|
|
1843
|
+
// a fallback-subregion quad samples only scale^2 of the ancestor's texels.
|
|
1844
|
+
const sub=x.subregion; const eff = sub ? texels*(sub.scale*sub.scale) : texels;
|
|
1845
|
+
vals.push(eff/area); }
|
|
1846
|
+
if(!vals.length) return {err:'no-onscreen-quads', total:q.length};
|
|
1847
|
+
vals.sort((a,b)=>a-b);
|
|
1848
|
+
const pct=p=>vals[Math.min(vals.length-1,Math.floor(p*vals.length))];
|
|
1849
|
+
const inBand=vals.filter(v=>v>=1&&v<=2).length;
|
|
1850
|
+
return { altKm:+window.__dbg.altKm().toFixed(1), onScreenQuads:vals.length, texelsPerTile:texels,
|
|
1851
|
+
texelsPerPixel:{ min:+vals[0].toFixed(3), p10:+pct(0.1).toFixed(3), median:+pct(0.5).toFixed(3),
|
|
1852
|
+
p90:+pct(0.9).toFixed(3), max:+vals[vals.length-1].toFixed(3) },
|
|
1853
|
+
fracInBand_1_2:+(inBand/vals.length).toFixed(3),
|
|
1854
|
+
pass: (inBand/vals.length) >= 0.5 };
|
|
1855
|
+
},
|
|
1856
|
+
|
|
1857
|
+
// ---- per-scanline feature-wavelength helpers (shared by featureScale/texelDetail).
|
|
1858
|
+
// From a horizontal band of scanlines across the canvas center we measure the mean
|
|
1859
|
+
// spacing (in PIXELS) between consecutive zero-crossings of a chosen signal. spacing
|
|
1860
|
+
// = the dominant feature WAVELENGTH on screen. Background pixels (near-black, r,g,b<6)
|
|
1861
|
+
// are skipped so the limb/sky don't dilute the land measurement. Returns the
|
|
1862
|
+
// distribution of crossing spacings (px) over the sampled rows.
|
|
1863
|
+
_scanlineWavelengths(signalFn, rowsToSample=24, hipass=false){
|
|
1864
|
+
const {px,w,h}=this._read();
|
|
1865
|
+
const spacings=[];
|
|
1866
|
+
const y0=Math.floor(h*0.30), y1=Math.floor(h*0.70);
|
|
1867
|
+
const step=Math.max(1,Math.floor((y1-y0)/rowsToSample));
|
|
1868
|
+
for(let y=y0;y<y1;y+=step){
|
|
1869
|
+
// build the 1-D signal for this row over land pixels; track its mean for centering.
|
|
1870
|
+
const sig=[]; const xs=[];
|
|
1871
|
+
for(let x=0;x<w;x++){ const i=(y*w+x)*4; const r=px[i],g=px[i+1],b=px[i+2];
|
|
1872
|
+
if(r<6&&g<6&&b<6) continue; // background/limb -> skip
|
|
1873
|
+
sig.push(signalFn(r,g,b)); xs.push(x); }
|
|
1874
|
+
if(sig.length<16) continue;
|
|
1875
|
+
// optional high-pass: subtract a 9-tap moving average to isolate fine detail
|
|
1876
|
+
// (so a slow albedo gradient doesn't read as one giant feature).
|
|
1877
|
+
let s=sig;
|
|
1878
|
+
if(hipass){ s=sig.map((v,k)=>{ let acc=0,n=0; for(let d=-4;d<=4;d++){const j=k+d; if(j>=0&&j<sig.length){acc+=sig[j];n++;}} return v-acc/n; }); }
|
|
1879
|
+
const mean=s.reduce((a,v)=>a+v,0)/s.length;
|
|
1880
|
+
// zero-crossings of (signal - mean): record the on-screen pixel gap between them.
|
|
1881
|
+
let lastX=-1;
|
|
1882
|
+
for(let k=1;k<s.length;k++){ const a=s[k-1]-mean, b=s[k]-mean;
|
|
1883
|
+
if((a<=0&&b>0)||(a>0&&b<=0)){ const cx=xs[k]; if(lastX>=0){ const gap=cx-lastX; if(gap>0) spacings.push(gap*2); /* *2: crossing spacing = half-wavelength */ } lastX=cx; } }
|
|
1884
|
+
}
|
|
1885
|
+
if(!spacings.length) return null;
|
|
1886
|
+
spacings.sort((a,b)=>a-b);
|
|
1887
|
+
const pct=p=>spacings[Math.min(spacings.length-1,Math.floor(p*spacings.length))];
|
|
1888
|
+
return { count:spacings.length, p10:+pct(0.1).toFixed(1), median:+pct(0.5).toFixed(1), p90:+pct(0.9).toFixed(1) };
|
|
1889
|
+
},
|
|
1890
|
+
|
|
1891
|
+
// (FSC) featureScale -- DOMINANT vertex-feature wavelength (px) at a rung. Renders the
|
|
1892
|
+
// signed-vH diagnostic (displayMode 6 packs elevation into RGB: R=+vH/8000, B=-vH/8000)
|
|
1893
|
+
// and measures the on-screen spacing of elevation zero-crossings = the geometric feature
|
|
1894
|
+
// size the user sees. Target: 20-50px vertex features AT ALL SCOPES (the request).
|
|
1895
|
+
async featureScale(rung){
|
|
1896
|
+
const d=window.__dbg; const prev=window.__displayMode|0;
|
|
1897
|
+
if(rung){ await this.parkAt(rung); }
|
|
1898
|
+
window.__displayMode=6; await d.frames(8);
|
|
1899
|
+
// signal = signed elevation reconstructed from the displayMode-6 packing (R - B).
|
|
1900
|
+
const wv=this._scanlineWavelengths((r,g,b)=>(r-b), 24, false);
|
|
1901
|
+
window.__displayMode=prev; await d.frames(2);
|
|
1902
|
+
if(!wv) return { altKm:+d.altKm().toFixed(1), err:'no-land-signal' };
|
|
1903
|
+
const inBand = wv.p10>=20 && wv.median<=50 ? 1 : (wv.median>=20&&wv.median<=50?0.7:0);
|
|
1904
|
+
return { altKm:+d.altKm().toFixed(1), vertFeaturePx:{p10:wv.p10,median:wv.median,p90:wv.p90},
|
|
1905
|
+
samples:wv.count, inBand_20_50:(wv.median>=20&&wv.median<=50), bandScore:inBand };
|
|
1906
|
+
},
|
|
1907
|
+
|
|
1908
|
+
// (TXD) texelDetail -- MATERIAL high-frequency wavelength (px) at a rung. Renders albedo
|
|
1909
|
+
// (displayMode 2) and HIGH-PASSES the luma scanline so a slow biome gradient does not mask
|
|
1910
|
+
// the fine grain; the crossing spacing of the high-passed signal = the texel-detail size
|
|
1911
|
+
// on screen. NOTE FS detail texturing was removed (one fractal only) -- this now measures the
|
|
1912
|
+
// albedo variation from the biome ramp + mesh-resolved fractal, not a separate grain layer.
|
|
1913
|
+
async texelDetail(rung){
|
|
1914
|
+
const d=window.__dbg; const prev=window.__displayMode|0;
|
|
1915
|
+
if(rung){ await this.parkAt(rung); }
|
|
1916
|
+
window.__displayMode=2; await d.frames(8);
|
|
1917
|
+
const wv=this._scanlineWavelengths((r,g,b)=>(r*0.299+g*0.587+b*0.114), 24, true);
|
|
1918
|
+
window.__displayMode=prev; await d.frames(2);
|
|
1919
|
+
if(!wv) return { altKm:+d.altKm().toFixed(1), err:'no-land-signal' };
|
|
1920
|
+
return { altKm:+d.altKm().toFixed(1), texelFeaturePx:{p10:wv.p10,median:wv.median,p90:wv.p90},
|
|
1921
|
+
samples:wv.count, inBand_1_4:(wv.median>=1&&wv.median<=4),
|
|
1922
|
+
hasDetailAboveFadeAlt:(d.altKm()>8 && wv.median<=8) };
|
|
1923
|
+
},
|
|
1924
|
+
|
|
1925
|
+
// (VSW) varianceSweep -- the ALL-SCALES acceptance table. Walks every RUNG (space->closeup)
|
|
1926
|
+
// and at each records: pxPerPoly band, featureScale vertFeaturePx, texelDetail texelFeaturePx,
|
|
1927
|
+
// reliefSD (signed-vH std-dev over the canvas = geometric variation present), shadingSD
|
|
1928
|
+
// (lit-luma std-dev = relief-shading present), albedoSD (albedo-luma std-dev = material
|
|
1929
|
+
// variation present), drawCensus. One dispatch = the full detail/lighting/variance vector
|
|
1930
|
+
// the request asks to validate "all the way down from highest to lowest frequencies".
|
|
1931
|
+
async varianceSweep(){
|
|
1932
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return (window.__varSweep={err:'not-ready',detail:rdy});
|
|
1933
|
+
const d=window.__dbg; const rungs=Object.keys(this.RUNGS);
|
|
1934
|
+
const sd=(mode)=>{ const prev=window.__displayMode|0; window.__displayMode=mode; return d.frames(8).then(()=>{
|
|
1935
|
+
const {px,w,h}=this._read(); let s=0,s2=0,n=0;
|
|
1936
|
+
for(let i=0;i<w*h;i++){const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; if(r<6&&g<6&&b<6)continue;
|
|
1937
|
+
const l=(mode===6)?(r-b):(r*0.299+g*0.587+b*0.114); s+=l; s2+=l*l; n++; }
|
|
1938
|
+
window.__displayMode=prev;
|
|
1939
|
+
return n? +Math.sqrt(Math.max(0,s2/n-(s/n)*(s/n))).toFixed(2) : 0; }); };
|
|
1940
|
+
const out={ when:new Date().toISOString(), rungs:{} };
|
|
1941
|
+
for(const r of rungs){ await this.parkAt(r);
|
|
1942
|
+
let pv=-1,st=0; for(let i=0;i<40&&st<6;i++){await d.frames(2); const t=(window.__lastGLQuads||[]).length; if(t===pv)st++; else st=0; pv=t;}
|
|
1943
|
+
const reliefSD=await sd(6), shadingSD=await sd(0), albedoSD=await sd(2);
|
|
1944
|
+
await d.frames(2);
|
|
1945
|
+
out.rungs[r]={ altKm:+d.altKm().toFixed(0),
|
|
1946
|
+
pxPerPoly:this.pxPerPoly(), featureScale:await this.featureScale(), texelDetail:await this.texelDetail(),
|
|
1947
|
+
reliefSD, shadingSD, albedoSD, draw:this.drawCensus() }; }
|
|
1948
|
+
return (window.__varSweep=out);
|
|
1949
|
+
},
|
|
1950
|
+
|
|
1951
|
+
// (DRW) drawCensus -- per-frame draw-cost census + the FAR-HEMISPHERE WASTE
|
|
1952
|
+
// metric (the dominant bottleneck: all 6 cube faces fully subdivide each
|
|
1953
|
+
// rebuild, but the quads whose deformed center faces AWAY from the camera are
|
|
1954
|
+
// behind the limb and depth-culled -- wasted tile-gen + draw). A quad is
|
|
1955
|
+
// "behind limb" when the angle between cam->quad-center-dir and cam-out-dir
|
|
1956
|
+
// exceeds the horizon half-angle. Also splits quads per cube face.
|
|
1957
|
+
drawCensus(){
|
|
1958
|
+
const q=window.__lastGLQuads||[]; const f=window.__webglFrame||{};
|
|
1959
|
+
const cam=window.__lastGLCam||{}; const eye=cam.eye||cam.pos||[0,0,6360000*1.5];
|
|
1960
|
+
const R=this._R(); const camDist=Math.hypot(eye[0],eye[1],eye[2])||R;
|
|
1961
|
+
// horizon half-angle from the camera: cos(theta_h) = R/camDist (tangent ray).
|
|
1962
|
+
const cosHoriz = Math.min(1, R/camDist);
|
|
1963
|
+
const byFace=[0,0,0,0,0,0]; let behindLimb=0, onScreenish=0;
|
|
1964
|
+
const FF=this._FACE_FRAME;
|
|
1965
|
+
for(const x of q){ byFace[x.face]++;
|
|
1966
|
+
const qd=x.quad, F=FF[x.face]; const cx=qd.ox+qd.l/2, cy=qd.oy+qd.l/2;
|
|
1967
|
+
const len=Math.hypot(cx,cy,R)||1;
|
|
1968
|
+
const wx=(cx/len)*F.u[0]+(cy/len)*F.v[0]+(R/len)*F.c[0];
|
|
1969
|
+
const wy=(cx/len)*F.u[1]+(cy/len)*F.v[1]+(R/len)*F.c[1];
|
|
1970
|
+
const wz=(cx/len)*F.u[2]+(cy/len)*F.v[2]+(R/len)*F.c[2];
|
|
1971
|
+
// surface point on the unit sphere * R; direction from camera to it:
|
|
1972
|
+
const sx=wx*R-eye[0], sy=wy*R-eye[1], sz=wz*R-eye[2]; const sl=Math.hypot(sx,sy,sz)||1;
|
|
1973
|
+
// angle of the surface-normal (wx,wy,wz) vs the camera's outward dir (eye/|eye|):
|
|
1974
|
+
const dotOut=(wx*eye[0]+wy*eye[1]+wz*eye[2])/(camDist); // = cos(angle from sub-cam point)
|
|
1975
|
+
// behind the limb when the point's angular distance from sub-cam exceeds the
|
|
1976
|
+
// horizon: cos(angle) < cos(horiz) i.e. dotOut < cosHoriz.
|
|
1977
|
+
if(dotOut < cosHoriz) behindLimb++; else onScreenish++;
|
|
1978
|
+
}
|
|
1979
|
+
return { totalQuads:q.length, byFace, behindLimbQuads:behindLimb, frontHemiQuads:onScreenish,
|
|
1980
|
+
wastedFrac:+(behindLimb/Math.max(q.length,1)).toFixed(3),
|
|
1981
|
+
residentCount:f.residentCount, fallbackCount:f.fallbackCount, culledCount:f.culledCount,
|
|
1982
|
+
maxFallbackLevel:f.maxFallbackLevel, cached:f.cached, glError:f.glError };
|
|
1983
|
+
},
|
|
1984
|
+
|
|
1985
|
+
// (PRF) profileFrame -- per-stage wall-clock breakdown. Forces a REBUILD frame
|
|
1986
|
+
// (clearCache => full quadtree + tile-gen) and times it vs a cached re-render
|
|
1987
|
+
// frame, isolating tile-generation cost from draw cost. Reports ms + fps for
|
|
1988
|
+
// both, the tiles generated, and the draw-only cost. Runs at the current view.
|
|
1989
|
+
async profileFrame(reps=6){
|
|
1990
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
1991
|
+
const d=window.__dbg, orch=window.__planetOrch;
|
|
1992
|
+
// settle first so we time a representative steady-state view, not mid-stream.
|
|
1993
|
+
await d.frames(12);
|
|
1994
|
+
// (a) REBUILD-frame cost: clear the atlas so the next frame regenerates every
|
|
1995
|
+
// visible tile (the worst-case move cost). Time the single rebuild frame.
|
|
1996
|
+
const tReb=[]; for(let i=0;i<reps;i++){ orch.clearCache(); const t0=performance.now(); await d.frames(1); tReb.push(performance.now()-t0); }
|
|
1997
|
+
const beforeRes=orch.residentCount;
|
|
1998
|
+
// (b) CACHED re-render cost: no movement => _frameCache hit => draw-only.
|
|
1999
|
+
await d.frames(2); const tCached=[]; for(let i=0;i<reps;i++){ const t0=performance.now(); await d.frames(1); tCached.push(performance.now()-t0); }
|
|
2000
|
+
const med=a=>{const s=[...a].sort((x,y)=>x-y); return +s[s.length>>1].toFixed(2);};
|
|
2001
|
+
const census=this.drawCensus();
|
|
2002
|
+
return { altKm:+d.altKm().toFixed(1), reps,
|
|
2003
|
+
rebuildFrameMs:med(tReb), cachedFrameMs:med(tCached),
|
|
2004
|
+
tileGenMs:+(med(tReb)-med(tCached)).toFixed(2),
|
|
2005
|
+
rebuildFps:+(1000/Math.max(med(tReb),0.01)).toFixed(1), cachedFps:+(1000/Math.max(med(tCached),0.01)).toFixed(1),
|
|
2006
|
+
residentTiles:beforeRes, draw:census,
|
|
2007
|
+
// PASS = a steady-state (cached) frame holds >=30fps; rebuild may dip on big jumps.
|
|
2008
|
+
pass: med(tCached) < 33.3 };
|
|
2009
|
+
},
|
|
2010
|
+
|
|
2011
|
+
// (EFF) efficiencyBaseline -- the efficiency analog of baseline(): park at every
|
|
2012
|
+
// rung and record pxPerPoly + texelPerPixel + profileFrame + drawCensus into
|
|
2013
|
+
// window.__effBaseline. One dispatch = the full before/after efficiency vector.
|
|
2014
|
+
async efficiencyBaseline(){
|
|
2015
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return (window.__effBaseline={err:'not-ready',detail:rdy});
|
|
2016
|
+
const rungs=Object.keys(this.RUNGS); const out={ when:new Date().toISOString(), rungs:{} };
|
|
2017
|
+
for(const r of rungs){ await this.parkAt(r);
|
|
2018
|
+
// settle to stable quad count (deep tiles stream in over ~30 frames).
|
|
2019
|
+
let prevQ=-1,stable=0; for(let i=0;i<40 && stable<6;i++){ await window.__dbg.frames(2);
|
|
2020
|
+
const t=(window.__lastGLQuads||[]).length; if(t===prevQ)stable++; else stable=0; prevQ=t; }
|
|
2021
|
+
out.rungs[r]={ altKm:+window.__dbg.altKm().toFixed(0),
|
|
2022
|
+
pxPerPoly:this.pxPerPoly(), texelPerPixel:this.texelPerPixel(),
|
|
2023
|
+
draw:this.drawCensus(), profile:await this.profileFrame(4) }; }
|
|
2024
|
+
out.pass = Object.values(out.rungs).every(R=> (R.pxPerPoly.pass!==false) && (R.profile.pass!==false));
|
|
2025
|
+
return (window.__effBaseline=out);
|
|
2026
|
+
},
|
|
2027
|
+
|
|
2028
|
+
// (SWP) sweepSplit -- calibrate window.__splitFactor live (no rebuild/reload) so
|
|
2029
|
+
// px/poly lands in the 4-50 band. Tries several splitFactor values at the current
|
|
2030
|
+
// view, re-rendering enough frames for the quadtree to re-settle, and reports
|
|
2031
|
+
// pxPerPoly median + band fraction + quad count for each. The efficient-loop
|
|
2032
|
+
// analog of densitySweep: find the right value in ONE dispatch, then set it.
|
|
2033
|
+
async sweepSplit(values=[2.0,1.0,0.5,0.3,0.2,0.12]){
|
|
2034
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return {err:'not-ready',detail:rdy};
|
|
2035
|
+
const orch=window.__planetOrch; const prev=window.__splitFactor;
|
|
2036
|
+
const out=[];
|
|
2037
|
+
for(const sf of values){ window.__splitFactor=sf; orch.clearCache();
|
|
2038
|
+
let pv=-1,st=0; for(let i=0;i<40&&st<6;i++){await window.__dbg.frames(2); const t=(window.__lastGLQuads||[]).length; if(t===pv)st++; else st=0; pv=t;}
|
|
2039
|
+
const px=this.pxPerPoly(), tx=this.texelPerPixel(), dc=this.drawCensus();
|
|
2040
|
+
out.push({ splitFactor:sf, quads:dc.totalQuads, behindLimb:dc.behindLimbQuads, wastedFrac:dc.wastedFrac,
|
|
2041
|
+
fallbackCount:dc.fallbackCount,
|
|
2042
|
+
pxMedian:px.pxPerPolyEdge&&px.pxPerPolyEdge.median, pxBand:px.fracInBand_4_50,
|
|
2043
|
+
txMedian:tx.texelsPerPixel&&tx.texelsPerPixel.median, txBand:tx.fracInBand_1_2 }); }
|
|
2044
|
+
window.__splitFactor=prev; orch.clearCache(); await window.__dbg.frames(8);
|
|
2045
|
+
return { altKm:+window.__dbg.altKm().toFixed(0), restoredTo:prev, trials:out };
|
|
2046
|
+
},
|
|
2047
|
+
|
|
2048
|
+
// (HPF) hpf -- the Hierarchical Parameter Field terraform loop, witnessable in one
|
|
2049
|
+
// dispatch. Lists the scale bands, reads biome/params at a world direction, edits a node
|
|
2050
|
+
// (any band) + re-bakes live, and measures land/sea so an edit's effect is provable. The
|
|
2051
|
+
// anchor field is window.__hpf (anchor-field.js); the baked continental texture lives in
|
|
2052
|
+
// the orchestrator and re-bakes via window.__hpfRebake().
|
|
2053
|
+
hpf: {
|
|
2054
|
+
_f(){ return window.__hpf; },
|
|
2055
|
+
bands(){ const f=this._f(); return f?f.bandsInfo():{err:'no-hpf'}; },
|
|
2056
|
+
// biome/params at the camera's sub-point (or a given world dir).
|
|
2057
|
+
paramsAt(dir){ const f=this._f(); if(!f) return {err:'no-hpf'};
|
|
2058
|
+
const d = dir || (()=>{ const p=cam.pos, l=Math.hypot(...p); return [p[0]/l,p[1]/l,p[2]/l]; })();
|
|
2059
|
+
const s = f.sampleDir(d); const o={}; for(const k of f.PARAM_KEYS) o[k]=+s[k].toFixed(3); return {dir:d.map(v=>+v.toFixed(3)), params:o}; },
|
|
2060
|
+
// edit the node containing a world dir, in a band at an arbitrary DEPTH below the band
|
|
2061
|
+
// base level (depth 0 = base cell; deeper = finer terraform), by param deltas; rebake.
|
|
2062
|
+
edit(band, dir, deltas, depth){ const f=this._f(); if(!f) return {err:'no-hpf'};
|
|
2063
|
+
const d = dir || (()=>{ const p=cam.pos, l=Math.hypot(...p); return [p[0]/l,p[1]/l,p[2]/l]; })();
|
|
2064
|
+
const key = f.editAtDir(band|0, d, deltas||{}, depth|0);
|
|
2065
|
+
if(window.__hpfRebake) window.__hpfRebake();
|
|
2066
|
+
const bi=f.bandsInfo()[band|0]||{};
|
|
2067
|
+
return { band:band|0, key, depth:depth|0, totalEdits:f.totalEdits, maxDepth:bi.maxDepth, params:this.paramsAt(d).params }; },
|
|
2068
|
+
serialize(){ const f=this._f(); return f?f.serialize():{err:'no-hpf'}; },
|
|
2069
|
+
load(json){ const f=this._f(); if(!f) return {err:'no-hpf'}; const ok=f.load(json); if(ok&&window.__hpfRebake)window.__hpfRebake(); return {loaded:ok}; },
|
|
2070
|
+
// land/sea fraction from the displayMode-5 land-sea map (HPF continental structure on screen).
|
|
2071
|
+
async landSea(){ const rdy=await window.__diag.assertReady(); if(!rdy.ok) return {err:'not-ready'};
|
|
2072
|
+
const d=window.__dbg; const prev=window.__displayMode|0; window.__displayMode=5; await d.frames(6);
|
|
2073
|
+
const {px,w,h}=window.__diag._read(); let land=0,sea=0;
|
|
2074
|
+
for(let i=0;i<w*h;i++){const r=px[i*4],g=px[i*4+1],b=px[i*4+2]; if(r<12&&g<14&&b<30)continue; if(r>b)land++; else sea++;}
|
|
2075
|
+
window.__displayMode=prev; await d.frames(2);
|
|
2076
|
+
return { landFrac:+(land/Math.max(land+sea,1)).toFixed(3), discPixels:land+sea }; },
|
|
2077
|
+
// __biomeAt(dir): classify the anchor climate at a world dir into a DISCRETE biome name +
|
|
2078
|
+
// the raw temp/humid/seaBias. Mirrors the GLSL biomeClassColor (terrain.glsl displayMode 9)
|
|
2079
|
+
// so the live classification and the rendered biome map agree. seaBias>0 ~ land (the wasm
|
|
2080
|
+
// cascade also shifts h, so this is the anchor-level class, not the per-pixel elevation).
|
|
2081
|
+
biomeAt(dir){ const f=this._f(); if(!f) return {err:'no-hpf'};
|
|
2082
|
+
const L=Math.hypot(dir[0],dir[1],dir[2])||1; const d=[dir[0]/L,dir[1]/L,dir[2]/L];
|
|
2083
|
+
const s=f.sampleDir(d); const t=s.temp, h=s.humidity, sea=s.seaBias;
|
|
2084
|
+
let biome;
|
|
2085
|
+
if (sea < -200) biome='ocean';
|
|
2086
|
+
else if (t < 0.14) biome='ice';
|
|
2087
|
+
else if (t < 0.30) biome='tundra';
|
|
2088
|
+
else if (t > 0.52 && h < 0.22) biome='desert';
|
|
2089
|
+
else if (t > 0.52 && h < 0.42) biome='savanna';
|
|
2090
|
+
else if (h > 0.66) biome = (t > 0.52) ? 'rainforest' : 'taiga';
|
|
2091
|
+
else if (h > 0.45) biome='forest';
|
|
2092
|
+
else biome='meadow/steppe';
|
|
2093
|
+
return { dir:d.map(v=>+v.toFixed(3)), biome, temp:+t.toFixed(3), humidity:+h.toFixed(3), seaBias:+sea.toFixed(0) }; },
|
|
2094
|
+
},
|
|
2095
|
+
|
|
2096
|
+
// (S) baseline -- THE efficient-loop foundation. One dispatch parks the camera at
|
|
2097
|
+
// every altitude rung and records the full health vector + every probe into
|
|
2098
|
+
// window.__diagBaseline. This snapshot is the ground truth each fix compares
|
|
2099
|
+
// against; replaces the restart-and-eyeball loop entirely.
|
|
2100
|
+
async baseline(){
|
|
2101
|
+
const rdy=await this.assertReady(); if(!rdy.ok) return (window.__diagBaseline={err:'not-ready',detail:rdy});
|
|
2102
|
+
const rungs=Object.keys(this.RUNGS); const out={ when:new Date().toISOString(), env:{
|
|
2103
|
+
elevLinear:this.assertElevLinear() }, rungs:{} };
|
|
2104
|
+
for(const r of rungs){
|
|
2105
|
+
await this.parkAt(r);
|
|
2106
|
+
// Let the deep-LOD tiles finish STREAMING IN before measuring. After a camera jump
|
|
2107
|
+
// the quadtree subdivides and tiles generate over ~30 frames; probing mid-stream
|
|
2108
|
+
// catches transient ancestor-fallback coarseness that the settled view (what the
|
|
2109
|
+
// user sees) does not have. Settle to a STABLE quad/resident count, capped.
|
|
2110
|
+
let prevQ=-1, stable=0;
|
|
2111
|
+
for(let i=0;i<60 && stable<8;i++){ await window.__dbg.frames(2);
|
|
2112
|
+
const q=this.quadCensus(); if(q.total===prevQ && q.fallbackCount===(this._lastFb||q.fallbackCount)) stable++; else stable=0;
|
|
2113
|
+
prevQ=q.total; this._lastFb=q.fallbackCount; }
|
|
2114
|
+
const census=this.quadCensus();
|
|
2115
|
+
// seam/speckle measure fine-detail discontinuities; below LOD 4 the on-screen
|
|
2116
|
+
// tiles are so coarse that legitimate relief aliasing dominates and the metric
|
|
2117
|
+
// is not meaningful. Mark them n/a (not fail) at coarse LOD so the verdict is honest.
|
|
2118
|
+
const coarseLOD = (census.maxLevel|0) < 4;
|
|
2119
|
+
out.rungs[r]={ park:{altKm:+window.__dbg.altKm().toFixed(0)}, coarseLOD,
|
|
2120
|
+
census,
|
|
2121
|
+
limb:await this.limbScan(),
|
|
2122
|
+
// haze measures CONTINENT-wash (lit land tinted blue). At coarse LOD the disc is
|
|
2123
|
+
// tiny and dominated by the legitimate atmospheric limb halo, not continents, so
|
|
2124
|
+
// the blue-excess there is real atmosphere, not the wash defect -> n/a, like seam.
|
|
2125
|
+
haze:coarseLOD ? {na:'coarse-LOD',maxLevel:census.maxLevel, blueExcess:(await this.hazeProbe()).blueExcess} : await this.hazeProbe(),
|
|
2126
|
+
speckle:coarseLOD ? {na:'coarse-LOD',maxLevel:census.maxLevel} : await this.speckleProbe(),
|
|
2127
|
+
seam:coarseLOD ? {na:'coarse-LOD',maxLevel:census.maxLevel} : await this.seamProbe() };
|
|
2128
|
+
}
|
|
2129
|
+
// overall verdict = every APPLICABLE probe at every rung passes (n/a probes skipped).
|
|
2130
|
+
const ok=(p)=>p && (p.na ? true : p.pass);
|
|
2131
|
+
out.pass = Object.values(out.rungs).every(R=>ok(R.limb)&&ok(R.haze)&&ok(R.speckle)&&ok(R.seam))
|
|
2132
|
+
&& out.env.atlas.pass && out.env.elevLinear.pass;
|
|
2133
|
+
return (window.__diagBaseline=out);
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
console.log('[dbg] __dbg + __diag ready -- __diag.report(), __diag.baseline(), __diag.efficiencyBaseline(), __diag.pxPerPoly(), __diag.texelPerPixel(), __diag.drawCensus(), __diag.profileFrame(), __diag.parkAt(rung), __diag.limbScan(), __diag.hazeProbe(), __diag.speckleProbe(), __diag.seamProbe(), __diag.assertReady(), __diag.assertElevLinear(), __diag.displayModes()');
|
|
2137
|
+
|
|
2138
|
+
// ---- GENERATION control panel: auto-built from window.__gen.state, every param a live input ----
|
|
2139
|
+
// Walks the __gen state tree; each scalar / array element becomes a number input wired to
|
|
2140
|
+
// __gen.set(dotPath, value) so the user tunes every generation param with no console or rebuild.
|
|
2141
|
+
(function(){
|
|
2142
|
+
const panel = document.getElementById('genPanel'), toggle = document.getElementById('genToggle');
|
|
2143
|
+
if(!panel || !toggle) return;
|
|
2144
|
+
function rowInput(path, val){
|
|
2145
|
+
const wrap = document.createElement('label');
|
|
2146
|
+
wrap.style.cssText = 'display:flex;justify-content:space-between;gap:6px;align-items:center';
|
|
2147
|
+
const span = document.createElement('span'); span.textContent = path.split('.').slice(-2).join('.'); span.title = path;
|
|
2148
|
+
const inp = document.createElement('input'); inp.type='number'; inp.step='any'; inp.value=val;
|
|
2149
|
+
inp.style.cssText='width:96px;background:#0a0e12;color:#cfe;border:1px solid #2a3a44;font:10px monospace';
|
|
2150
|
+
inp.addEventListener('change', ()=>{ const v=parseFloat(inp.value); if(!isNaN(v)) window.__gen.set(path, v); });
|
|
2151
|
+
wrap.appendChild(span); wrap.appendChild(inp); return wrap;
|
|
2152
|
+
}
|
|
2153
|
+
function walk(obj, prefix, into){
|
|
2154
|
+
for(const k in obj){
|
|
2155
|
+
const v = obj[k], path = prefix? prefix+'.'+k : k;
|
|
2156
|
+
if(Array.isArray(v)){
|
|
2157
|
+
const grp=document.createElement('div'); grp.style.cssText='margin:2px 0;opacity:0.85'; grp.textContent=path;
|
|
2158
|
+
into.appendChild(grp);
|
|
2159
|
+
v.forEach((el,i)=> into.appendChild(rowInput(path+'.'+i, el)));
|
|
2160
|
+
} else if(v && typeof v==='object'){
|
|
2161
|
+
const hdr=document.createElement('div'); hdr.style.cssText='margin:5px 0 2px;color:#7fd;font-weight:bold;border-top:1px solid #2a3a44;padding-top:4px'; hdr.textContent=path;
|
|
2162
|
+
into.appendChild(hdr); walk(v, path, into);
|
|
2163
|
+
} else if(typeof v==='number'){
|
|
2164
|
+
into.appendChild(rowInput(path, v));
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
function rebuild(){
|
|
2169
|
+
panel.innerHTML='';
|
|
2170
|
+
if(!window.__gen){ panel.textContent='__gen not loaded yet'; return; }
|
|
2171
|
+
const bar=document.createElement('div'); bar.style.cssText='display:flex;gap:6px;margin-bottom:4px';
|
|
2172
|
+
const mk=(t,fn)=>{ const b=document.createElement('button'); b.textContent=t; b.style.cssText='font:10px monospace;flex:1'; b.onclick=fn; return b; };
|
|
2173
|
+
bar.appendChild(mk('Apply', ()=>window.__gen.apply()));
|
|
2174
|
+
bar.appendChild(mk('Reset', ()=>{ window.__gen.reset(); rebuild(); }));
|
|
2175
|
+
bar.appendChild(mk('Copy', ()=>navigator.clipboard && navigator.clipboard.writeText(window.__gen.serialize())));
|
|
2176
|
+
panel.appendChild(bar);
|
|
2177
|
+
walk(window.__gen.state, '', panel);
|
|
2178
|
+
}
|
|
2179
|
+
toggle.addEventListener('click', ()=>{ const show = panel.style.display==='none'; panel.style.display = show?'block':'none'; if(show) rebuild(); });
|
|
2180
|
+
})();
|
|
2181
|
+
</script></body></html>
|