1 2(function(){ 3"use strict"; 4function b64bytes(s){var b=atob(s),a=new Uint8Array(b.length); 5 for(var i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a} 6var VS="attribute vec3 aP;attribute vec3 aN;attribute vec4 aC;"+ 7 "uniform mat3 uR;uniform vec3 uT;uniform float uS;uniform vec2 uA;"+ 8 "uniform vec2 uPn;"+ 9 "varying vec3 vN;varying vec4 vC;"+ 10 "void main(){vec3 p=uR*(aP-uT);"+ 11 "gl_Position=vec4(p.x*uS*uA.x+uPn.x,p.y*uS*uA.y+uPn.y,-p.z*0.25,1.0);"+ 12 "vN=uR*aN;vC=aC;}"; 13var FS="precision mediump float;varying vec3 vN;varying vec4 vC;"+ 14 "uniform float uF;"+ // per-model fade for cross-fade transitions 15 "void main(){vec3 L=normalize(vec3(0.35,0.48,0.85));"+ 16 "float d=abs(dot(normalize(vN),L));float s=0.45+0.55*d;"+ 17 "gl_FragColor=vec4(vC.rgb*s+0.07,vC.a*uF);}"; 18// CFD streamline ribbons: WebGL clamps line width to 1px, so each 19// segment is expanded to a screen-space quad in the shader; the 20// traveling ripple is computed here too, so animation costs no 21// per-frame uploads at all (phase is a uniform) 22var FLOW_VS="attribute vec3 aP;attribute vec3 aQ;attribute vec2 aE;"+ 23 "attribute vec3 aF;attribute vec3 aC2;"+ // aF=(arc len,line len,speed 24 // factor); aC2=local-speed color 25 "uniform mat3 uR;uniform vec3 uT;uniform float uS;uniform vec2 uA;"+ 26 "uniform vec2 uPn;uniform vec2 uVP;uniform float uW;"+ 27 "uniform float uPh;uniform float uAl;uniform float uPer;"+ 28 "varying float vA;varying float vW;varying vec3 vCol2;"+ 29 "void main(){"+ 30 "vec3 p=uR*(aP-uT);vec3 q=uR*(aQ-uT);"+ 31 "vec2 sp=vec2(p.x*uS*uA.x+uPn.x,p.y*uS*uA.y+uPn.y);"+ 32 "vec2 sq=vec2(q.x*uS*uA.x+uPn.x,q.y*uS*uA.y+uPn.y);"+ 33 "vec2 d=(sq-sp)*uVP;float L=max(length(d),0.0001);"+ 34 "vec2 n=vec2(-d.y,d.x)/L;"+ 35 "vec2 base=mix(sp,sq,aE.x);float z=mix(p.z,q.z,aE.x);"+ 36 "vec2 off=n*aE.y*uW*2.0/uVP;"+ 37 "gl_Position=vec4(base+off,-z*0.25,1.0);"+ 38 "float ph=fract(aF.x/uPer-uPh);"+ 39 "vW=0.5+0.5*cos(ph*6.2832);"+ 40 "float t=aF.x/aF.y;"+ 41 "float env=clamp(min(t/0.12,(1.0-t)/0.12),0.0,1.0);"+ 42 "vA=uAl*env*aF.z;vCol2=aC2;}"; 43var FLOW_FS="precision mediump float;"+ 44 "varying float vA;varying float vW;varying vec3 vCol2;"+ 45 // base hue is the LOCAL velocity magnitude (sequential viridis, 46 // dark purple slow -> yellow fast, baked per-vertex in buildRibbon); 47 // the traveling shimmer modulates brightness on top of that, instead 48 // of replacing it with a fixed light/dark pair 49 "void main(){gl_FragColor=vec4(vCol2*(0.65+0.35*vW),vA);}"; 50 51// default camera: nose-side three-quarter view (the FPV camera faces the 52// viewer); the pre-flip back view was DEF_YAW=-0.9 53var DEF_YAW=Math.PI-0.9,DEF_PITCH=0.8; 54// hover parallax: until the user's first real drag, a hovered canvas 55// leans a few degrees toward the cursor -- a wordless hint that the 56// view is live 3D. One drag anywhere retires the effect for the page. 57var PARALLAX=!(window.matchMedia&& 58 matchMedia("(prefers-reduced-motion: reduce)").matches); 59var blobCache={}; 60// ---- on-demand mesh loading: payloads live in per-candidate 61// frames/gen_XXXX/<hash>.mesh.js files (JSONP-style: they call 62// airloomBlob(id, data)). <script src> injection works over BOTH file:// 63// (where fetch() is CORS-blocked) and GitHub Pages, so index.html stays 64// small no matter how long the run gets. 65var BLOBS={},BLOB_PENDING={}; 66var bsEl=document.getElementById("blob-src"); 67var BLOB_SRC=bsEl?JSON.parse(bsEl.textContent):{}; 68window.airloomBlob=function(id,data){ 69 BLOBS[id]=data; 70 (BLOB_PENDING[id]||[]).forEach(function(r){r()}); 71 delete BLOB_PENDING[id]; 72}; 73function blobAvailable(id){ // known payload: loaded, lazy-loadable or inline 74 return !!(id&&(BLOBS[id]||BLOB_SRC[id]||document.getElementById(id))); 75} 76function ensureBlobs(ids){ // resolve when every needed payload has arrived 77 var need=[]; 78 ids.forEach(function(id){ 79 if(!id||BLOBS[id]||document.getElementById(id)||!BLOB_SRC[id])return; 80 if(need.indexOf(id)<0)need.push(id); 81 }); 82 return Promise.all(need.map(function(id){ 83 return new Promise(function(res){ 84 if(BLOB_PENDING[id]){BLOB_PENDING[id].push(res);return} 85 BLOB_PENDING[id]=[res]; 86 var s=document.createElement("script"); 87 s.src=BLOB_SRC[id]; 88 s.onerror=function(){ // missing file: resolve anyway, viewer shows
89 (BLOB_PENDING[id]||[]).forEach(function(r){r()}); // what it has 90 delete BLOB_PENDING[id]; 91 }; 92 document.head.appendChild(s); 93 }); 94 })); 95} 96// ---- flight telemetry loading: same JSONP pattern as the mesh payloads 97var FLIGHTS={},FLIGHT_PENDING={}; 98var fsEl=document.getElementById("flight-src"); 99var FLIGHT_SRC=fsEl?JSON.parse(fsEl.textContent):{}; 100window.airloomFlight=function(h,scen,data){ 101 var k=h+"|"+scen; 102 FLIGHTS[k]=data; 103 (FLIGHT_PENDING[k]||[]).forEach(function(r){r()}); 104 delete FLIGHT_PENDING[k]; 105}; 106function ensureFlight(h,scen){ 107 var k=h+"|"+scen,src=(FLIGHT_SRC[h]||{})[scen]; 108 if(FLIGHTS[k]||!src)return Promise.resolve(); 109 return new Promise(function(res){ 110 if(FLIGHT_PENDING[k]){FLIGHT_PENDING[k].push(res);return} 111 FLIGHT_PENDING[k]=[res]; 112 var s=document.createElement("script"); 113 s.src=src; 114 s.onerror=function(){ 115 (FLIGHT_PENDING[k]||[]).forEach(function(r){r()}); 116 delete FLIGHT_PENDING[k]; 117 }; 118 document.head.appendChild(s); 119 }); 120} 121// ---- CFD streamline payloads: same JSONP pattern as the flights. 122// <hash>.<scen>.flow.js carries real OpenFOAM RANS streamlines in BODY 123// coordinates; where none exists the viewer falls back to its analytic 124// field. 125var FLOWS={},FLOW_PENDING={}; 126var flsEl=document.getElementById("flow-src"); 127var FLOWLINE_SRC=flsEl?JSON.parse(flsEl.textContent):{}; 128window.airloomFlow=function(h,scen,data){ 129 var k=h+"|"+scen; 130 FLOWS[k]=data; 131 (FLOW_PENDING[k]||[]).forEach(function(r){r()}); 132 delete FLOW_PENDING[k]; 133}; 134function ensureFlowLines(h,scen){ // resolves with the payload or null 135 var k=h+"|"+scen,src=(FLOWLINE_SRC[h]||{})[scen]; 136 if(FLOWS[k]||!src)return Promise.resolve(FLOWS[k]||null); 137 return new Promise(function(res){ 138 var done=function(){res(FLOWS[k]||null)}; 139 if(FLOW_PENDING[k]){FLOW_PENDING[k].push(done);return} 140 FLOW_PENDING[k]=[done]; 141 var s=document.createElement("script"); 142 s.src=src; 143 s.onerror=function(){ 144 (FLOW_PENDING[k]||[]).forEach(function(r){r()}); 145 delete FLOW_PENDING[k]; 146 }; 147 document.head.appendChild(s); 148 }); 149} 150// ---- surface-pressure (Pa) payloads: same JSONP pattern as the flow 151// lines, but kept in a fully separate namespace/data-attribute so the 152// pressure tiles never share state with the streamline viewer instances 153var PRESSURES={},PRESSURE_PENDING={}; 154var psEl=document.getElementById("pressure-src"); 155var PRESSURE_SRC=psEl?JSON.parse(psEl.textContent):{}; 156window.airloomPressure=function(h,scen,data){ 157 var k=h+"|"+scen; 158 PRESSURES[k]=data; 159 (PRESSURE_PENDING[k]||[]).forEach(function(r){r()}); 160 delete PRESSURE_PENDING[k]; 161}; 162function ensurePressure(h,scen){ // resolves with the payload or null 163 var k=h+"|"+scen,src=(PRESSURE_SRC[h]||{})[scen]; 164 if(PRESSURES[k]||!src)return Promise.resolve(PRESSURES[k]||null); 165 return new Promise(function(res){ 166 var done=function(){res(PRESSURES[k]||null)}; 167 if(PRESSURE_PENDING[k]){PRESSURE_PENDING[k].push(done);return} 168 PRESSURE_PENDING[k]=[done]; 169 var s=document.createElement("script"); 170 s.src=src; 171 s.onerror=function(){ 172 (PRESSURE_PENDING[k]||[]).forEach(function(r){r()}); 173 delete PRESSURE_PENDING[k]; 174 }; 175 document.head.appendChild(s); 176 }); 177} 178// diverging pressure colormap (deep blue = suction/low pressure, near-white = 179// freestream, deep red = stagnation/high pressure) -- the common CFD 180// pressure-plot convention 181function cpColor(t){ 182 t=Math.max(0,Math.min(1,t)); 183 var c0=[0.10,0.25,0.65],c1=[0.95,0.95,0.92],c2=[0.75,0.08,0.08]; 184 var a=t<0.5?c0:c1,b=t<0.5?c1:c2,f=t<0.5?t/0.5:(t-0.5)/0.5; 185 return [a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f,a[2]+(b[2]-a[2])*f]; 186} 187// sequential viridis-style colormap for velocity magnitude -- unlike 188// pressure, speed has no meaningful zero-crossing to diverge around (it's 189// always >= 0), so a perceptually-uniform sequential map is the standard 190// CFD convention here rather than the diverging blue-white-red scheme 191// pressure uses (5-stop approximation of matplotlib's viridis) 192function speedColor(t){ 193 t=Math.max(0,Math.min(1,t)); 194 var stops=[[0.267,0.005,0.329],[0.253,0.265,0.530], 195 [0.164,0.471,0.558],[0.135,0.659,0.518], 196 [0.993,0.906,0.144]]; 197 var n=stops.length-1,f=t*n,i=Math.min(n-1,Math.floor(f)),frac=f-i; 198 var a=stops[i],b=stops[i+1]; 199 return [a[0]+(b[0]-a[0])*frac,a[1]+(b[1]-a[1])*frac, 200 a[2]+(b[2]-a[2])*frac]; 201} 202// per-vertex color buffer from a face-order-aligned pressure array: d2.list 203// maps each opacity-sorted draw slot k back to its ORIGINAL face index 204// (the same order cfd.py's _mesh_face_centroids/extract_pressure_sweep 205// used), since decodeBlob re-sorts faces by opacity for the draw call 206function cpColorBuffer(d2,cpArr,lo,hi){ 207 var nf=d2.nf,list=d2.list,C=new Float32Array(nf*12),span=(hi-lo)||1; 208 for(var k=0;k<nf;k++){ 209 var of=list[k],col=cpColor((cpArr[of]-lo)/span); 210 for(var vtx=0;vtx<3;vtx++){ 211 var co=12*k+4*vtx; 212 C[co]=col[0];C[co+1]=col[1];C[co+2]=col[2];C[co+3]=1.0; 213 } 214 } 215 return C; 216} 217function decodeBlob(id){ 218 if(blobCache[id])return blobCache[id]; 219 var d=BLOBS[id]; 220 if(!d){ // inline <script type=application/json> fallback (old pages) 221 var el=document.getElementById(id); 222 if(!el)return null; 223 d=JSON.parse(el.textContent); 224 } 225 var V=new Float32Array(b64bytes(d.v).buffer); 226 var F=d.i==="u16"?new Uint16Array(b64bytes(d.f).buffer) 227 :new Uint32Array(b64bytes(d.f).buffer); 228 var nf=F.length/3; 229 var FC=d.fc?new Uint8Array(b64bytes(d.fc)):new Uint8Array(nf); 230 var PAL=d.p&&d.p.length?d.p:[[138,151,168,1]]; 231 var opaque=[],trans=[]; 232 for(var f=0;f<nf;f++)((PAL[FC[f]]||PAL[0])[3]<0.999?trans:opaque).push(f); 233 var list=opaque.concat(trans),nOpq=opaque.length; 234 var P=new Float32Array(nf*9),N=new Float32Array(nf*9),C=new Float32Array(nf*12); 235 // two-tone mono companion (the wind-tunnel look): evolved parts a 236 // shade darker than the fixed kit, translucency preserved 237 var M=d.pn?new Float32Array(nf*12):null; 238 for(var k=0;k<nf;k++){ 239 var f2=list[k],a=F[3*f2],b=F[3*f2+1],c=F[3*f2+2]; 240 var pc=PAL[FC[f2]]||PAL[0]; 241 var evPart=d.pn&&(d.pn[FC[f2]]==="deck"||d.pn[FC[f2]]==="arms"); 242 var e1x=V[3*b]-V[3*a],e1y=V[3*b+1]-V[3*a+1],e1z=V[3*b+2]-V[3*a+2]; 243 var e2x=V[3*c]-V[3*a],e2y=V[3*c+1]-V[3*a+1],e2z=V[3*c+2]-V[3*a+2]; 244 var nx=e1y*e2z-e1z*e2y,ny=e1z*e2x-e1x*e2z,nz=e1x*e2y-e1y*e2x; 245 var idx=[a,b,c]; 246 for(var v=0;v<3;v++){ 247 var o=9*k+3*v,vi=idx[v]; 248 P[o]=V[3*vi];P[o+1]=V[3*vi+1];P[o+2]=V[3*vi+2]; 249 N[o]=nx;N[o+1]=ny;N[o+2]=nz; 250 var co=12*k+4*v; 251 C[co]=pc[0]/255;C[co+1]=pc[1]/255;C[co+2]=pc[2]/255;C[co+3]=pc[3]; 252 if(M){ 253 M[co]=evPart?0.40:0.74;M[co+1]=evPart?0.39:0.73; 254 M[co+2]=evPart?0.36:0.70;M[co+3]=pc[3]; 255 } 256 } 257 } 258 var entry={P:P,N:N,C:C,M:M,nf:nf,nOpq:nOpq,c:d.c,r:d.r||0.3,ev:null, 259 list:list}; 260 // projected extents at the default yaw (pitch folded in at draw time) 261 // -> lets each viewer start zoomed to fit regardless of model size 262 var cyw=Math.cos(DEF_YAW),syw=Math.sin(DEF_YAW),mx=0,my=0,mz=0; 263 for(var vi=0;vi<V.length;vi+=3){ 264 var X=V[vi]-d.c[0],Y=V[vi+1]-d.c[1],Z=V[vi+2]-d.c[2]; 265 var qx=Math.abs(cyw*X+syw*Y),qy=Math.abs(cyw*Y-syw*X),qz=Math.abs(Z); 266 if(qx>mx)mx=qx;if(qy>
266my)my=qy;if(qz>mz)mz=qz; 267 } 268 entry.mx=mx;entry.my=my;entry.mz=mz; 269 if(d.pn){ // evolved-parts subset (deck + arms) for the diff view 270 var evIdx={}; 271 d.pn.forEach(function(nm,i){if(nm==="deck"||nm==="arms")evIdx[i]=1}); 272 var keep=[]; 273 for(var f4=0;f4<nf;f4++)if(evIdx[FC[f4]])keep.push(f4); 274 if(keep.length){ 275 var Pe=new Float32Array(keep.length*9),Ne=new Float32Array(keep.length*9), 276 Ce=new Float32Array(keep.length*12),Cg=new Float32Array(keep.length*12); 277 for(var k2=0;k2<keep.length;k2++){ 278 var ff=keep[k2],aa=F[3*ff],bb=F[3*ff+1],cc=F[3*ff+2]; 279 var pc2=PAL[FC[ff]]||PAL[0]; 280 var g1x=V[3*bb]-V[3*aa],g1y=V[3*bb+1]-V[3*aa+1],g1z=V[3*bb+2]-V[3*aa+2]; 281 var g2x=V[3*cc]-V[3*aa],g2y=V[3*cc+1]-V[3*aa+1],g2z=V[3*cc+2]-V[3*aa+2]; 282 var mx=g1y*g2z-g1z*g2y,my=g1z*g2x-g1x*g2z,mz=g1x*g2y-g1y*g2x; 283 var ind=[aa,bb,cc]; 284 for(var v2=0;v2<3;v2++){ 285 var o2=9*k2+3*v2,vj=ind[v2]; 286 Pe[o2]=V[3*vj];Pe[o2+1]=V[3*vj+1];Pe[o2+2]=V[3*vj+2]; 287 Ne[o2]=mx;Ne[o2+1]=my;Ne[o2+2]=mz; 288 var co2=12*k2+4*v2; 289 Ce[co2]=pc2[0]/255;Ce[co2+1]=pc2[1]/255;Ce[co2+2]=pc2[2]/255;Ce[co2+3]=1.0; 290 Cg[co2]=0.44;Cg[co2+1]=0.43;Cg[co2+2]=0.40;Cg[co2+3]=0.40; 291 } 292 } 293 entry.ev={P:Pe,N:Ne,Ce:Ce,Cg:Cg,nf:keep.length}; 294 // subset extents -> the diff view fits to deck+arms, not the props 295 var ex2=0,ey2=0,ez2=0; 296 for(var pi=0;pi<Pe.length;pi+=3){ 297 var X2=Pe[pi]-d.c[0],Y2=Pe[pi+1]-d.c[1],Z2=Pe[pi+2]-d.c[2]; 298 var ax=Math.abs(cyw*X2+syw*Y2),ay=Math.abs(cyw*Y2-syw*X2), 299 az=Math.abs(Z2); 300 if(ax>ex2)ex2=ax;if(ay>ey2)ey2=ay;if(az>ez2)ez2=az; 301 } 302 entry.ev.mx=ex2;entry.ev.my=ey2;entry.ev.mz=ez2; 303 } 304 } 305 blobCache[id]=entry; 306 return entry; 307} 308 309// one GL viewer per canvas, created once; loadBlob swaps model data; 310// several viewers may share one state -> they rotate/zoom in sync 311// zoom is relative to a per-viewer fit factor, so 1.0 = model fills the 312// canvas (with a small margin) at the state's base pitch 313function makeState(basePitch){ 314 var bp=(basePitch===undefined?DEF_PITCH:basePitch); 315 var st={yaw:DEF_YAW,pitch:bp,zoom:1.0,panX:0,panY:0,basePitch:bp,viewers:[]}; 316 st.redraw=function(){st.viewers.forEach(function(v){v.draw()})}; 317 st.reset=function(){st.yaw=DEF_YAW;st.pitch=st.basePitch;st.zoom=1.0; 318 st.panX=0;st.panY=0;st.redraw()}; 319 return st; 320} 321function makeViewer(canvas,state,opts){ 322 opts=opts||{}; 323 // antialiasing roughly doubles a context's GPU memory footprint; on a 324 // page that can have a dozen small tiles live at once, that's real 325 // budget on a constrained mobile GPU for a difference invisible at 326 // tile size. Callers with many simultaneous small viewers turn it off. 327 var aa=opts.antialias!==false; 328 var gl=canvas.getContext("webgl",{antialias:aa,alpha:false}) 329 ||canvas.getContext("experimental-webgl",{antialias:aa,alpha:false}); 330 if(!gl)return null; 331 // a context can be silently evicted by the OS/driver well AFTER 332 // creation (GPU memory pressure, backgrounding, another context 333 // stealing the budget) -- without this listener the browser's default 334 // behavior on some mobile builds is to paint the canvas as a broken- 335 // resource glyph instead of just going blank. preventDefault() opts 336 // into recovery semantics; onLost lets the caller show its own 337 // fallback and stop touching this (now-dead) view. 338 var contextLost=false; 339 canvas.addEventListener("webglcontextlost",function(ev){ 340 ev.preventDefault(); 341 contextLost=true; 342 if(opts.onLost)opts.onLost(); 343 },false); 344 function shader(type,src){var sh=gl.createShader(type); 345 gl.shaderSource(sh,src);gl.compileShader(sh);return sh} 346 var prog=gl.createProgram(); 347 gl.attachShader(prog,shader(gl.VERTEX_SHADER,VS)); 348 gl.attachShader(prog,shader(gl.FRAGMENT_SHADER,FS)); 349 gl.linkProgram(prog);gl.useProgram(prog); 350 // second program: the CFD streamline ribbons 351 var prog2=gl.createProgram(); 352 gl.attachShader(prog2,shader(gl.VERTEX_SHADER,FLOW_VS)); 353 gl.attachShader(prog2,shader(gl.FRAGMENT_SHADER,FLOW_FS)); 354 gl.linkProgram(prog2); 355 var f2={}; 356 ["uR","uT","uS","uA","uPn","uVP","uW","uPh","uAl","uPer"] 357 .forEach(function(u){f2[u]=gl.getUniformLocation(prog2,u)}); 358 var f2loc={}; 359 ["aP","aQ","aE","aF","aC2"].forEach(function(a){ 360 f2loc[a]=gl.getAttribLocation(prog2,a)}); 361 function bind2(buf,attr,size){ 362 gl.bindBuffer(gl.ARRAY_BUFFER,buf); 363 gl.enableVertexAttribArray(f2loc[attr]); 364 gl.vertexAttribPointer(f2loc[attr],size,gl.FLOAT,false,0,0); 365 } 366 function bindBuf(buf,attr,size){ 367 gl.bindBuffer(gl.ARRAY_BUFFER,buf); 368 var loc=gl.getAttribLocation(prog,attr); 369 gl.enableVertexAttribArray(loc); 370 gl.vertexAttribPointer(loc,size,gl.FLOAT,false,0,0); 371 } 372 function upload(P,N,C){ 373 var b={aP:gl.createBuffer(),aN:gl.createBuffer(),aC:gl.createBuffer()}; 374 gl.bindBuffer(gl.ARRAY_BUFFER,b.aP);gl.bufferData(gl.ARRAY_BUFFER,P,gl.STATIC_DRAW); 375 gl.bindBuffer(gl.ARRAY_BUFFER,b.aN);gl.bufferData(gl.ARRAY_BUFFER,N,gl.STATIC_DRAW); 376 gl.bindBuffer(gl.ARRAY_BUFFER,b.aC);gl.bufferData(gl.ARRAY_BUFFER,C,gl.STATIC_DRAW); 377 return b; 378 } 379 var uR=gl.getUniformLocation(prog,"uR"),uT=gl.getUniformLocation(prog,"uT"), 380 uS=gl.getUniformLocation(prog,"uS"),uA=gl.getUniformLocation(prog,"uA"), 381 uPn=gl.getUniformLocation(prog,"uPn"),uF=gl.getUniformLocation(prog,"uF"); 382 gl.enable(gl.DEPTH_TEST); 383 gl.clearColor(1.0,1.0,0.973,1.0); 384 var models=[]; // [{bufs, nf, nOpq}], shared center/scale from first 385 var frame={c:[0,0,0],r:0.3};
386 // ---- wind-channel layer: analytic streamlines of the relative wind 387 // past the airframe. Free stream = the telemetry wind vector; 388 // deflection = potential flow around a virtual sphere at the model's 389 // center, so the air visibly parts around the body and rejoins 390 // behind it. Geometry lives in WORLD space inside the GL scene -- 391 // depth-tested against the model (air passes BEHIND the frame where 392 // it should) and orbiting needs no rebuild. The moving dash trains 393 // advance at the real wind speed. 394 var FLN=opts.flowLines||34,FST=52; 395 var flow={on:false,phase:0,n:0,bufs:null, 396 P:new Float32Array(FLN*FST*6),Nb:new Float32Array(FLN*FST*6), 397 Cb:new Float32Array(FLN*FST*8),seeds:[]}; 398 // surface-pressure overlay state -- deliberately separate from `flow` 399 var press={sw:null,alpha:0,data:null}; 400 // stable seed pattern on the upwind disc, biased INTO the craft: most 401 // lines start inside the body's shadow so they bow visibly around it; 402 // only a few ride the outer tube (and those render fainter) 403 for(var fs=0;fs<FLN;fs++){ 404 var rf=[0.14,0.26,0.38,0.52,0.68][fs%5]*(0.92+0.16*Math.random()); 405 flow.seeds.push({r:rf,th:fs*2.39996+Math.random()*0.5}); 406 } 407 function flowRebuild(w,m){ 408 var R=frame.r,C=frame.c,a=0.5*R,ds=0.07*R,per=0.7*R; 409 var ux=w[0]/m,uy=w[1]/m,uz=w[2]/m; 410 // orthonormal frame around the stream direction (u x z-axis, with 411 // an x-axis fallback when the wind is vertical) 412 var e1x=uy,e1y=-ux,e1z=0; 413 var e1m=Math.hypot(e1x,e1y,e1z); 414 if(e1m<1e-4){e1x=1;e1y=0;e1z=0;e1m=1} 415 e1x/=e1m;e1y/=e1m;e1z/=e1m; 416 var e2x=uy*e1z-uz*e1y,e2y=uz*e1x-ux*e1z,e2z=ux*e1y-uy*e1x; 417 var alpha=0.45+0.35*Math.min(1,m/12); 418 var o=0,co=0,n=0; 419 for(var li=0;li<FLN;li++){ 420 var sd=flow.seeds[li],rad=sd.r*R,c1=Math.cos(sd.th),s1=Math.sin(sd.th); 421 // inner lines carry the story; the outer tube stays quiet 422 var ringA=1.15-0.85*sd.r; 423 var px=C[0]-ux*1.5*R+e1x*rad*c1+e2x*rad*s1, 424 py=C[1]-uy*1.5*R+e1y*rad*c1+e2y*rad*s1, 425 pz=C[2]-uz*1.5*R+e1z*rad*c1+e2z*rad*s1; 426 var s=0; 427 for(var st=0;st<FST;st++){ 428 // potential-flow velocity direction at p 429 var rx=px-C[0],ry=py-C[1],rz=pz-C[2]; 430 var rr=Math.hypot(rx,ry,rz)||1e-6; 431 var k=(a*a*a)/(2*rr*rr*rr); 432 var dt2=(ux*rx+uy*ry+uz*rz)/rr; 433 var vx=ux*(1+k)-(rx/rr)*3*k*dt2, 434 vy=uy*(1+k)-(ry/rr)*3*k*dt2, 435 vz=uz*(1+k)-(rz/rr)*3*k*dt2; 436 var vm=Math.hypot(vx,vy,vz)||1e-6; 437 vx/=vm;vy/=vm;vz/=vm; 438 var qx=px+vx*ds,qy=py+vy*ds,qz=pz+vz*ds; 439 // continuous streamline; flow reads from a smooth brightness 440 // ripple traveling downstream (no gaps) 441 var ph=((s/per-flow.phase)%1+1)%1; 442 { 443 var wave=0.62+0.38*Math.cos(ph*6.2832); 444 var t=st/FST,env=Math.min(1,t/0.14,(1-t)/0.14); 445 var aa=alpha*env*ringA*wave; 446 flow.P[o]=px;flow.P[o+1]=py;flow.P[o+2]=pz; 447 flow.P[o+3]=qx;flow.P[o+4]=qy;flow.P[o+5]=qz; 448 for(var v6=0;v6<2;v6++){ 449 // normals face the shader's light: full brightness, no 450 // per-segment lighting shimmer 451 flow.Nb[o+3*v6]=0.35;flow.Nb[o+3*v6+1]=0.48; 452 flow.Nb[o+3*v6+2]=0.85; 453 flow.Cb[co+4*v6]=0.13;flow.Cb[co+4*v6+1]=0.36; 454 flow.Cb[co+4*v6+2]=0.40;flow.Cb[co+4*v6+3]=aa; 455 } 456 o+=6;co+=8;n+=2; 457 } 458 px=qx;py=qy;pz=qz;s+=ds; 459 } 460 } 461 flow.n=n; 462 if(!flow.bufs)flow.bufs={aP:gl.createBuffer(),aN:gl.createBuffer(), 463 aC:gl.createBuffer()}; 464 gl.bindBuffer(gl.ARRAY_BUFFER,flow.bufs.aP); 465 gl.bufferData(gl.ARRAY_BUFFER,flow.P.subarray(0,o),gl.DYNAMIC_DRAW); 466 gl.bindBuffer(gl.ARRAY_BUFFER,flow.bufs.aN); 467 gl.bufferData(gl.ARRAY_BUFFER,flow.Nb.subarray(0,o),gl.DYNAMIC_DRAW); 468 gl.bindBuffer(gl.ARRAY_BUFFER,flow.bufs.aC); 469 gl.bufferData(gl.ARRAY_BUFFER,flow.Cb.subarray(0,co),gl.DYNAMIC_DRAW); 470 } 471 // build the ribbon-quad buffers for one set of streamlines. spdLo/ 472 // spdHi (m/s, shared across every scenario tile -- see 473 // normalize_flow_speed_range's pointSpeedRange) set the sequential 474 // viridis colormap range baked per-vertex, so local flow acceleration 475 // reads as real velocity magnitude, comparable tile to tile, the way 476 // CFD postprocessing tools usually color streamlines 477 function buildRibbon(lines,umag,spdLo,spdHi){ 478 var segs=0;
479 lines.forEach(function(l){segs+=l.p.length/3-1}); 480 var V=segs*6; // 6 verts per segment: two triangles of the ribbon 481 var P=new Float32Array(V*3),Q=new Float32Array(V*3), 482 E=new Float32Array(V*2),F=new Float32Array(V*3), 483 C2=new Float32Array(V*3); 484 var CORNERS=[[0,-1],[0,1],[1,1],[0,-1],[1,1],[1,-1]]; 485 var vi=0,lo=(spdLo!==undefined?spdLo:0.5*umag), 486 hi=(spdHi!==undefined?spdHi:1.3*umag),span=(hi-lo)||1; 487 lines.forEach(function(l){ 488 var n=l.p.length/3,total=0,acc=[0]; 489 for(var i=1;i<n;i++){ 490 total+=Math.hypot(l.p[3*i]-l.p[3*i-3],l.p[3*i+1]-l.p[3*i-2], 491 l.p[3*i+2]-l.p[3*i-1]); 492 acc.push(total); 493 } 494 total=total||1; 495 for(var i2=1;i2<n;i2++){ 496 var fA=0.45+0.55*Math.min(1.4,(l.s[i2-1]||0)/umag); 497 var fB=0.45+0.55*Math.min(1.4,(l.s[i2]||0)/umag); 498 var colA=speedColor(((l.s[i2-1]||0)-lo)/span); 499 var colB=speedColor(((l.s[i2]||0)-lo)/span); 500 for(var c9=0;c9<6;c9++){ 501 var e=CORNERS[c9]; 502 for(var q=0;q<3;q++){ 503 P[3*vi+q]=l.p[3*(i2-1)+q]; 504 Q[3*vi+q]=l.p[3*i2+q]; 505 } 506 E[2*vi]=e[0];E[2*vi+1]=e[1]; 507 F[3*vi]=e[0]?acc[i2]:acc[i2-1]; 508 F[3*vi+1]=total; 509 F[3*vi+2]=e[0]?fB:fA; 510 var col=e[0]?colB:colA; 511 C2[3*vi]=col[0];C2[3*vi+1]=col[1];C2[3*vi+2]=col[2]; 512 vi++; 513 } 514 } 515 }); 516 var bufs={aP:gl.createBuffer(),aQ:gl.createBuffer(), 517 aE:gl.createBuffer(),aF:gl.createBuffer(), 518 aC2:gl.createBuffer()}; 519 [[bufs.aP,P],[bufs.aQ,Q],[bufs.aE,E],[bufs.aF,F],[bufs.aC2,C2]] 520 .forEach(function(b){ 521 gl.bindBuffer(gl.ARRAY_BUFFER,b[0]); 522 gl.bufferData(gl.ARRAY_BUFFER,b[1],gl.STATIC_DRAW); 523 }); 524 return {nv:V,bufs:bufs}; 525 } 526 var view={ 527 canvas:canvas, 528 loadBlob:function(id){view.load([{id:id}])}, 529 setPropAngle:function(theta){ // spin prop clusters; CW/CCW by diagonal 530 for(var m3=0;m3<models.length;m3++){ 531 var pr=models[m3].prop; 532 if(!pr)continue; 533 var cs=[Math.cos(theta),Math.cos(-theta)], 534 sn=[Math.sin(theta),Math.sin(-theta)]; 535 for(var v4=0;v4<pr.base.length;v4+=3){ 536 var q4=pr.cl[v4/3],dir=pr.spin[q4]>0?0:1; 537 var dx4=pr.base[v4]-pr.cc[q4][0],dy4=pr.base[v4+1]-pr.cc[q4][1]; 538 pr.scr[v4]=pr.cc[q4][0]+cs[dir]*dx4-sn[dir]*dy4; 539 pr.scr[v4+1]=pr.cc[q4][1]+sn[dir]*dx4+cs[dir]*dy4; 540 pr.scr[v4+2]=pr.base[v4+2]; 541 } 542 gl.bindBuffer(gl.ARRAY_BUFFER,models[m3].bufs.aP); 543 gl.bufferData(gl.ARRAY_BUFFER,pr.scr,gl.DYNAMIC_DRAW); 544 } 545 }, 546 // fixedFrame: an optional pre-computed {c,r,mx,my,mz} shared across 547 // several loads so swapping models never re-centers or re-fits the 548 // camera (the walkthrough uses one frame for its whole chain) 549 load:function(specs,fixedFrame){ 550 // mono: the wind-tunnel look -- every part one neutral gray 551 // (alpha preserved, so prop disks stay sheer); part colors are 552 // for the evolution views where they carry meaning 553 function monoC(C){ 554 var M=new Float32Array(C.length); 555 for(var mi=0;mi<C.length;mi+=4){ 556 M[mi]=0.62;M[mi+1]=0.61;M[mi+2]=0.58;M[mi+3]=C[mi+3]; 557 } 558 return M; 559 } 560 models=[]; 561 for(var i2=0;i2<specs.length;i2++){ 562 var sp=specs[i2],d2=decodeBlob(sp.id); 563 if(!d2)continue; 564 var CC=sp.mono?(d2.M||monoC(d2.C)):d2.C; 565 if(sp.evolved){ 566 if(!d2.ev)continue; 567 models.push({bufs:upload(d2.ev.P,d2.ev.N,sp.ghost?d2.ev.Cg:d2.ev.Ce), 568 nf:d2.ev.nf,nOpq:sp.ghost?0:d2.ev.nf, 569 fade:sp.fade==null?1:sp.fade}); 570 }else if(sp.propSpin&&d2.nf>d2.nOpq){ 571 // flight tab: props are the (only) translucent tail of the 572 // buffers -- split them into a dynamic model so setPropAngle can 573 // spin each rotor about its own axis, diagonal pairs opposed 574 models.push({bufs:upload(d2.P,d2.N,CC),nf:d2.nOpq,nOpq:d2.nOpq, 575 fade:sp.fade==null?1:sp.fade,d2:d2}); 576 var off=d2.nOpq*9,pP=d2.P.slice(off),pN=d2.N.slice(off), 577 pC=CC.slice(d2.nOpq*12),np=pP.length/3; 578 // assign blades to their 4 rotors: farthest-point seeding + 579 // Lloyd iterations on (x,y) -- robust to any arm sweep, where a 580 // naive quadrant split misassigns blades near the boundaries 581 var seeds=[[pP[0],pP[1]]],v3,q3,s3; 582 while(seeds.length<4){ 583 var bi=0,bd=-1; 584 for(v3=0;v3<pP.length;v3+=3){ 585 var dmin=1e9; 586 for(s3=0;s3<seeds.length;s3++){ 587 var ddx=pP[v3]-seeds[s3][0],ddy=pP[v3+1]-seeds[s3][1]; 588 var dd=ddx*ddx+ddy*ddy; 589 if(dd<dmin)dmin=dd; 590 } 591 if(dmin>bd){bd=dmin;bi=v3} 592 } 593 seeds.push([pP[bi],pP[bi+1]]); 594 } 595 var cl=new Uint8Array(np),cc; 596 for(var it=0;
596it<3;it++){ 597 cc=[[0,0,0],[0,0,0],[0,0,0],[0,0,0]]; 598 for(v3=0;v3<pP.length;v3+=3){ 599 var qb=0,qd=1e9; 600 for(s3=0;s3<4;s3++){ 601 var qx=pP[v3]-seeds[s3][0],qy=pP[v3+1]-seeds[s3][1]; 602 var qq=qx*qx+qy*qy; 603 if(qq<qd){qd=qq;qb=s3} 604 } 605 cl[v3/3]=qb; 606 cc[qb][0]+=pP[v3];cc[qb][1]+=pP[v3+1];cc[qb][2]++; 607 } 608 for(s3=0;s3<4;s3++)if(cc[s3][2]) 609 seeds[s3]=[cc[s3][0]/cc[s3][2],cc[s3][1]/cc[s3][2]]; 610 } 611 cc=seeds.map(function(s6){return [s6[0],s6[1]]}); 612 // counter-rotation by diagonal: sign from the quadrant of each 613 // CLUSTER CENTER (not the vertex), so pairs stay consistent 614 var spin=new Int8Array(4); 615 for(s3=0;s3<4;s3++) 616 spin[s3]=((cc[s3][0]>=0)===(cc[s3][1]>=0))?1:-1; 617 // re-center each cluster on its HUB: the raw vertex centroid of 618 // a decimated 3-blade prop sits off-axis (visible wobble); the 619 // innermost blade-root vertices are symmetric about the shaft 620 var rr=[0,0,0,0]; 621 for(v3=0;v3<pP.length;v3+=3){ 622 var qc=cl[v3/3]; 623 var rdx=pP[v3]-cc[qc][0],rdy=pP[v3+1]-cc[qc][1]; 624 var rd2=rdx*rdx+rdy*rdy; 625 if(rd2>rr[qc])rr[qc]=rd2; 626 } 627 for(s3=0;s3<4;s3++){ 628 var rmax=Math.sqrt(rr[s3])||1,hx8=0,hy8=0,hn8=0; 629 for(v3=0;v3<pP.length;v3+=3){ 630 if(cl[v3/3]!==s3)continue; 631 var ex8=pP[v3]-cc[s3][0],ey8=pP[v3+1]-cc[s3][1]; 632 if(Math.sqrt(ex8*ex8+ey8*ey8)<rmax*0.3){ 633 hx8+=pP[v3];hy8+=pP[v3+1];hn8++; 634 } 635 } 636 if(hn8>6){cc[s3][0]=hx8/hn8;cc[s3][1]=hy8/hn8} 637 } 638 var db={aP:gl.createBuffer(),aN:gl.createBuffer(),aC:gl.createBuffer()}; 639 gl.bindBuffer(gl.ARRAY_BUFFER,db.aP); 640 gl.bufferData(gl.ARRAY_BUFFER,pP,gl.DYNAMIC_DRAW); 641 gl.bindBuffer(gl.ARRAY_BUFFER,db.aN); 642 gl.bufferData(gl.ARRAY_BUFFER,pN,gl.STATIC_DRAW); 643 gl.bindBuffer(gl.ARRAY_BUFFER,db.aC); 644 gl.bufferData(gl.ARRAY_BUFFER,pC,gl.STATIC_DRAW); 645 models.push({bufs:db,nf:d2.nf-d2.nOpq,nOpq:0, 646 fade:sp.fade==null?1:sp.fade, 647 prop:{base:pP,cl:cl,cc:cc,spin:spin, 648 scr:new Float32Array(pP.length)}}); 649 }else{ 650 models.push({bufs:upload(d2.P,d2.N,CC),nf:d2.nf,nOpq:d2.nOpq, 651 fade:sp.fade==null?1:sp.fade,d2:d2}); 652 } 653 var ext=sp.evolved?d2.ev:d2; 654 if(fixedFrame)continue; 655 if(i2===0){frame={c:d2.c,r:d2.r,mx:ext.mx,my:ext.my,mz:ext.mz}} 656 else{frame.mx=Math.max(frame.mx,ext.mx); 657 frame.my=Math.max(frame.my,ext.my); 658 frame.mz=Math.max(frame.mz,ext.mz)} 659 } 660 if(fixedFrame)frame=fixedFrame; 661 gl.uniform3f(uT,frame.c[0],frame.c[1],frame.c[2]); 662 }, 663 setFade:function(i,v){if(models[i])models[i].fade=v}, 664 // real CFD streamlines (body frame, from a .flow.js payload): the 665 // line geometry is static per scenario; only the traveling ripple 666 // is animated. null falls back to the analytic field. 667 setFlowLines:function(data,pose){ 668 if(!data||(!data.lines&&!data.sets)){flow.cfd=null;return} 669 // a SWEEP payload carries one field per attitude: the draw pass 670 // blends the two nearest by the live angle of attack, so the 671 // near field re-wraps as the craft pitches (quasi-steady). 672 // pointSpeedRange (written by normalize_flow_speed_range) is the 673 // shared blue-red colormap domain for LOCAL velocity magnitude, 674 // baked per-vertex in buildRibbon -- same range for every 675 // scenario tile, so a genuinely faster patch of flow reads the 676 // same red everywhere, not just relative to its own scenario 677 var pr=data.pointSpeedRange; 678 if(data.sets&&data.sets.length){ 679 var sets=[]; 680 data.sets.forEach(function(st2){ 681 if(!st2.lines.length)return; 682 var um=Math.hypot(st2.u[0],st2.u[1],st2.u[2])||1; 683 var b=buildRibbon(st2.lines,um,pr&&pr[0],pr&&pr[1]); 684 sets.push({a:st2.a,nv:b.nv,bufs:b.bufs}); 685 }); 686 if(!sets.length){flow.cfd=null;return} 687 flow.cfd={sets:sets,u0:data.u, 688 umag:Math.hypot(data.u[0],data.u[1],data.u[2])||1}; 689 return; 690 } 691 var um=Math.hypot(data.u[0],data.u[1],data.u[2])||1; 692 var b=buildRibbon(data.lines,um,pr&&pr[0],pr&&pr[1]); 693 flow.cfd={nv:b.nv,bufs:b.bufs,umag:um,pose:pose||null}; 694 }, 695 // feed the wind-channel layer: wv = the telemetry's relative wind 696 // (world frame, m/s); dt advances the dash trains at real speed 697 windUpdate:function(wv,dt){ 698 // telemetry wx/wy/wz is the CRAFT's velocity through the air 699 // (vax = vx - wind); the air streams past the OPPOSITE way -- 700 // a headwind must flow nose -> tail 701 var ax=-wv[0],ay=-wv[1],az=-wv[2]; 702 // geometry follows a LOW-PASSED wind: gusts sway the field 703 // smoothly instead of re-aiming every line every frame (flicker) 704 if(!flow.sw)flow.sw=[ax,ay,az]; 705 var k=1-Math.exp(-(dt||0)*4); 706 flow.sw[0]+=(ax-flow.sw[0])*k; 707 flow.sw[1]+=(ay-flow.sw[1])*k; 708 flow.sw[2]+=(az-flow.sw[2])*k; 709 var m=Math.hypot(flow.sw[0],flow.sw[1],flow.sw[2]); 710 if(m<0.15||!models.length){flow.on=false;return} 711 flow.on=true; 712 // dash trains advance in CYCLES per second (12 m/s = 1.5 cyc/s): 713 // real-speed advection would strobe across a whole period a frame 714 flow.phase+=(dt||0)*m/8; 715 flow.liveM=m; 716 if(flow.cfd){ 717 if(flow.cfd.sets&&view.telemetryR){ 718 // instantaneous incoming-flow angle vs the sweep's mean, 719 // in the body x-z plane (same formula the extractor used) -- 720 // this is a DATA lookup (which pre-solved sweep angle to 721 // blend toward), never applied to the model's own rotation 722 var MR=view.telemetryR,aw=flow.sw,u0=flow.cfd.u0;
723 var abx=MR[0]*aw[0]+MR[1]*aw[1]+MR[2]*aw[2]; 724 var abz=MR[6]*aw[0]+MR[7]*aw[1]+MR[8]*aw[2]; 725 flow.alpha=Math.atan2(u0[0]*abz-u0[2]*abx, 726 u0[0]*abx+u0[2]*abz)*57.2958; 727 } 728 return; // CFD geometry is static; only phase/blend animate 729 } 730 flowRebuild(flow.sw,m); 731 }, 732 // ---- surface pressure (Pa): a SEPARATE overlay from the streamline 733 // machinery above, on purpose -- pressure tiles never touch `flow`, 734 // so they can run with no streamlines/wind-channel at all. A sweep 735 // payload ({u, range, sets:[{a,cp}...]}) blends the two attitude 736 // buckets bracketing the live angle of attack, exactly like the 737 // streamline sets do, then repaints the mesh's own color buffer. 738 setPressureData:function(data){ 739 press.data=(data&&data.sets&&data.sets.length)?data:null; 740 }, 741 // wv = relative wind (world frame, m/s), same input as windUpdate 742 pressureUpdate:function(wv,dt){ 743 var ax=-wv[0],ay=-wv[1],az=-wv[2]; 744 if(!press.sw)press.sw=[ax,ay,az]; 745 var k2=1-Math.exp(-(dt||0)*4); 746 press.sw[0]+=(ax-press.sw[0])*k2;press.sw[1]+=(ay-press.sw[1])*k2; 747 press.sw[2]+=(az-press.sw[2])*k2; 748 if(!press.data||!view.telemetryR||!models.length)return; 749 var MR=view.telemetryR,u0=press.data.u,sw=press.sw; 750 var abx=MR[0]*sw[0]+MR[1]*sw[1]+MR[2]*sw[2]; 751 var abz=MR[6]*sw[0]+MR[7]*sw[1]+MR[8]*sw[2]; 752 press.alpha=Math.atan2(u0[0]*abz-u0[2]*abx, 753 u0[0]*abx+u0[2]*abz)*57.2958; 754 // repainting ~23k faces is a real CPU loop + a full GPU buffer 755 // re-upload, not a cheap uniform tweak like the streamline blend 756 // -- doing that on every rAF (up to 60/sec, x6 tiles at once) was 757 // visibly janky. The field only needs to look smoothly time- 758 // varying, not literally recomputed every frame, so throttle it. 759 press.since=(press.since||0)+(dt||0); 760 if(press.since<0.1&&press.lastAlpha!==undefined)return; 761 press.since=0;press.lastAlpha=press.alpha; 762 var ss=press.data.sets,al=press.alpha,lo=ss[0],hi=ss[0]; 763 if(al>=ss[ss.length-1].a){lo=hi=ss[ss.length-1]} 764 else if(al>ss[0].a) 765 for(var si=0;si<ss.length-1;si++) 766 if(al>=ss[si].a&&al<=ss[si+1].a){lo=ss[si];hi=ss[si+1];break} 767 var wb=(lo===hi)?0:(al-lo.a)/(hi.a-lo.a),range=press.data.range; 768 for(var m5=0;m5<models.length;m5++){ 769 var mo=models[m5]; 770 if(!mo.d2||!mo.d2.list)continue; 771 var nf=mo.d2.nf,pArr=new Float32Array(nf); 772 for(var of=0;of<nf;of++) 773 pArr[of]=lo.p[of]*(1-wb)+(hi.p[of]!==undefined?hi.p[of]:lo.p[of])*wb; 774 var C=cpColorBuffer(mo.d2,pArr,range[0],range[1]); 775 gl.bindBuffer(gl.ARRAY_BUFFER,mo.bufs.aC); 776 gl.bufferData(gl.ARRAY_BUFFER,C,gl.DYNAMIC_DRAW); 777 } 778 }, 779 // release the GL context (card viewers churn as the page scrolls; 780 // without this the browser's per-page context cap bites) 781 destroy:function(){ 782 var i=state.viewers.indexOf(view); 783 if(i>=0)state.viewers.splice(i,1); 784 if(ro)ro.disconnect(); 785 var ext=gl.getExtension("WEBGL_lose_context"); 786 if(ext)ext.loseContext(); 787 }, 788 // zoom that fits the CURRENT pitch: draw()'s fit factor is anchored 789 // at basePitch so rotation doesn't breathe, so after rotating the 790 // fit button needs this correction ratio 791 fitZoom:function(){ 792 if(!models.length||frame.mx===undefined)return null; 793 var w=canvas.clientWidth,h=canvas.clientHeight; 794 if(w<2||h<2)return null; 795 var asp=w>h?[h/w,1]:[1,w/h]; 796 function need(p){ 797 var ex=frame.mx*asp[0], 798 ey=(Math.abs(Math.sin(p))*frame.my+ 799 Math.abs(Math.cos(p))*frame.mz)*asp[1]; 800 return Math.max(ex,ey); 801 } 802 var cur=need(state.pitch); 803 return cur>0?need(state.basePitch)/cur:null; 804 }, 805 draw:function(){ 806 if(contextLost||!models.length)return; 807 var dpr=window.devicePixelRatio||1; 808 var w=canvas.clientWidth,h=canvas.clientHeight; 809 if(w<2||h<2)return; 810 if(canvas.width!==Math.round(w*dpr)){ 811 canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)} 812 gl.viewport(0,0,canvas.width,canvas.height); 813 // the hover lean rides on top of the real camera and never 814 // mutates it, so grabbing the model starts from where it looks 815 var yw=state.yaw+(state.hovX||0),pt=state.pitch+(state.hovY||0); 816 var cy2=Math.cos(yw),sy=Math.sin(yw), 817 cp=Math.cos(pt),sp=Math.sin(pt); 818 var Rb=[cy2,-sy*sp,sy*cp, 819 sy,cy2*sp,-cy2*cp, 820 0,cp,sp]; 821 gl.uniformMatrix3fv(uR,false,Rb); 822 var asp=w>h?[h/w,1]:[1,w/h];
823 gl.uniform2f(uA,asp[0],asp[1]); 824 gl.uniform2f(uPn,state.panX||0,state.panY||0); 825 // fit factor: at zoom 1 the model's projected extents (at the 826 // state's base pitch) reach 92% of the canvas on the tighter axis 827 var fit=1; 828 if(frame.mx!==undefined){ 829 var bs=Math.sin(state.basePitch),bc=Math.cos(state.basePitch); 830 var ex=frame.mx*asp[0], 831 ey=(Math.abs(bs)*frame.my+Math.abs(bc)*frame.mz)*asp[1]; 832 fit=0.92*frame.r/(0.85*Math.max(ex,ey)); 833 } 834 gl.uniform1f(uS,0.85*state.zoom*fit/frame.r); 835 gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); 836 for(var m2=0;m2<models.length;m2++){ 837 var mo=models[m2],fade=mo.fade==null?1:mo.fade; 838 if(fade<=0.004)continue; 839 // the model's displayed orientation is camera-orbit only, always 840 // -- never composed with the craft's own flight attitude, so a 841 // user's drag is never fought or overridden by the telemetry 842 gl.uniformMatrix3fv(uR,false,Rb); 843 bindBuf(mo.bufs.aP,"aP",3);bindBuf(mo.bufs.aN,"aN",3); 844 bindBuf(mo.bufs.aC,"aC",4); 845 gl.uniform1f(uF,fade); 846 if(fade<0.996){ // fading: draw everything blended, no depth writes 847 gl.enable(gl.BLEND); 848 gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); 849 gl.depthMask(false); 850 gl.drawArrays(gl.TRIANGLES,0,mo.nf*3); 851 gl.depthMask(true); 852 continue; 853 } 854 gl.disable(gl.BLEND);gl.depthMask(true); 855 if(mo.nOpq>0)gl.drawArrays(gl.TRIANGLES,0,mo.nOpq*3); 856 if(mo.nf>mo.nOpq){ 857 gl.enable(gl.BLEND); 858 gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); 859 gl.depthMask(false); 860 gl.drawArrays(gl.TRIANGLES,mo.nOpq*3,(mo.nf-mo.nOpq)*3); 861 gl.depthMask(true); 862 } 863 } 864 // wind-channel streamlines: blended, depth-TESTED but not 865 // written -- the airframe occludes the air behind it. Drawn in 866 // the same fixed body-orientation reference the mesh now always 867 // renders in (camera-orbit only) -- neither ever rocks with the 868 // craft's own live attitude, so the two stay visually consistent 869 // with each other and neither fights a user's drag 870 var FB=flow.cfd; 871 if(flow.on&&FB&&(FB.nv||FB.sets)){ 872 var FR=Rb; 873 gl.useProgram(prog2); 874 gl.uniformMatrix3fv(f2.uR,false,FR); 875 gl.uniform3f(f2.uT,frame.c[0],frame.c[1],frame.c[2]); 876 gl.uniform1f(f2.uS,0.85*state.zoom*fit/frame.r); 877 gl.uniform2f(f2.uA,asp[0],asp[1]); 878 gl.uniform2f(f2.uPn,state.panX||0,state.panY||0); 879 gl.uniform2f(f2.uVP,canvas.width,canvas.height); 880 // ribbon half-width scales with the viewport: full-screen gets 881 // the full ribbon, the small scenario boxes near-hairlines 882 gl.uniform1f(f2.uW, 883 1.2*dpr*Math.max(0.45,Math.min(1,canvas.clientHeight/420))); 884 gl.uniform1f(f2.uPh,flow.phase); 885 gl.uniform1f(f2.uPer,0.7*frame.r); 886 // ribbon hue now comes from the per-vertex aC2 buffer baked in 887 // buildRibbon (local velocity magnitude, sequential viridis -- 888 // dark purple slow -> yellow fast, shared scale across every 889 // scenario tile) -- no uniform needed 890 var baseAl=0.55+0.3*Math.min(1,(flow.liveM||FB.umag)/12); 891 var drawSet=function(bs,wgt){ 892 if(!bs||!bs.nv||wgt<=0.02)return; 893 gl.uniform1f(f2.uAl,baseAl*wgt); 894 bind2(bs.bufs.aP,"aP",3);bind2(bs.bufs.aQ,"aQ",3); 895 bind2(bs.bufs.aE,"aE",2);bind2(bs.bufs.aF,"aF",3); 896 bind2(bs.bufs.aC2,"aC2",3); 897 gl.drawArrays(gl.TRIANGLES,0,bs.nv); 898 }; 899 gl.enable(gl.BLEND); 900 gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); 901 gl.depthMask(false); 902 if(FB.sets){ 903 // blend the two attitude fields bracketing the live angle 904 var al=flow.alpha||0,ss=FB.sets; 905 var lo=ss[0],hi=ss[0]; 906 if(al>=ss[ss.length-1].a){lo=hi=ss[ss.length-1]} 907 else if(al>ss[0].a) 908 for(var si=0;si<ss.length-1;si++) 909 if(al>=ss[si].a&&al<=ss[si+1].a){ 910 lo=ss[si];hi=ss[si+1];break} 911 var wb=(lo===hi)?0:(al-lo.a)/(hi.a-lo.a); 912 drawSet(lo,1-wb); 913 drawSet(hi,wb); 914 }else{ 915 drawSet(FB,1); 916 } 917 gl.depthMask(true); 918 gl.disable(gl.BLEND); 919 ["aP","aQ","aE","aF","aC2"].forEach(function(a){ 920 gl.disableVertexAttribArray(f2loc[a])}); 921 gl.useProgram(prog); 922 }else if(flow.on&&flow.n>0&&flow.bufs){ 923 gl.uniformMatrix3fv(uR,false,Rb); 924 gl.uniform1f(uF,1); 925 bindBuf(flow.bufs.aP,"aP",3);bindBuf(flow.bufs.aN,"aN",3); 926 bindBuf(flow.bufs.aC,"aC",4); 927 gl.enable(gl.BLEND); 928 gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); 929 gl.depthMask(false); 930 gl.drawArrays(gl.LINES,0,flow.n); 931 gl.depthMask(true); 932 gl.disable(gl.BLEND); 933 } 934 } 935 }; 936 state.viewers.push(view); 937 // the canvas backing store is only resynced to its CSS box size 938 // inside draw() -- fine while something keeps calling redraw(), but 939 // a layout shift with no window "resize" event (a late webfont 940 // swap, a sibling column changing width, a lazy image finishing 941 // load) leaves it stale: CSS then stretches an already-rendered, 942 // too-small bitmap over the new box, which reads as a blocky, 943 // low-res render. A ResizeObserver on the canvas itself catches 944 // every such shift, not just the ones the rest of the page happens 945 // to redraw for. 946 var ro=null; 947 if("ResizeObserver" in window){ 948 ro=new ResizeObserver(function(){view.draw()}); 949 ro.observe(canvas); 950 } 951 // ease the hover lean toward its target; a tiny rAF loop that stops 952 // itself the moment the offsets settle 953 function hovTo(tx,ty){ 954 state.hovTX=tx;state.hovTY=ty; 955 if(state.hovAnim)return; 956 function step(){ 957 state.hovAnim=null; 958 var dx=(state.hovTX||0)-(state.hovX||0), 959 dy=(state.hovTY||0)-(state.hovY||0); 960 if(Math.abs(dx)<0.0008&&Math.abs(dy)<0.0008){ 961 state.hovX=state.hovTX;state.hovY=state.hovTY; 962 state.redraw();return} 963 state.hovX=(state.hovX||0)+dx*0.10; 964 state.hovY=(state.hovY||0)+dy*0.10; 965 state.redraw(); 966 state.hovAnim=requestAnimationFrame(step); 967 } 968 state.hovAnim=requestAnimationFrame(step); 969 } 970 var dragging=false,panning=false,lastX=0,lastY=0,downX=0,downY=0; 971 // touch has no scroll wheel, so a second finger down means "pinch to 972 // zoom" instead of "drag to rotate" -- pointers is keyed by pointerId 973 // so a mouse drag and independent touches never get confused 974 var pointers={},pinchDist=0; 975 function pinchPoints(){ 976 var ids=Object.keys(pointers); 977 return ids.length===2?[pointers[ids[0]],pointers[ids[1]]]:null; 978 } 979 function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y)} 980 canvas.addEventListener("pointerdown",function(e){ 981 pointers[e.pointerId]={x:e.clientX,y:e.clientY}; 982 canvas.setPointerCapture(e.pointerId); 983 e.preventDefault();
983 // belt-and-suspenders alongside touch-action:none 984 // below -- some mobile WebKit versions have only 985 // partially honored touch-action:none for pinch 986 // specifically, not just pan/scroll 987 var pts=pinchPoints(); 988 if(pts){ // second finger just landed: switch to pinch-zoom 989 dragging=false;pinchDist=dist(pts[0],pts[1]); 990 return; 991 } 992 dragging=true;panning=e.metaKey||e.ctrlKey; // cmd/ctrl-drag pans 993 downX=e.clientX;downY=e.clientY; 994 // freeze the lean where it is: fold it into the real camera so the 995 // grab starts from exactly what the eye sees, with no snap 996 if(state.hovAnim){cancelAnimationFrame(state.hovAnim);state.hovAnim=null} 997 state.yaw+=(state.hovX||0);state.pitch+=(state.hovY||0); 998 state.hovX=state.hovY=state.hovTX=state.hovTY=0; 999 lastX=e.clientX;lastY=e.clientY; 1000 canvas.style.cursor="grabbing"}); 1001 canvas.addEventListener("pointermove",function(e){ 1002 if(pointers[e.pointerId])pointers[e.pointerId]={x:e.clientX,y:e.clientY}; 1003 var pts=pinchPoints(); 1004 if(pts){ // two fingers down: pinch overrides rotate entirely 1005 e.preventDefault(); 1006 var d=dist(pts[0],pts[1]); 1007 if(pinchDist>0) 1008 state.zoom=Math.max(0.3,Math.min(8,state.zoom*(d/pinchDist))); 1009 pinchDist=d;state.redraw(); 1010 return; 1011 } 1012 if(!dragging){ 1013 if(!PARALLAX)return; 1014 var r=canvas.getBoundingClientRect(); 1015 hovTo(((e.clientX-r.left)/r.width-0.5)*0.12, 1016 ((e.clientY-r.top)/r.height-0.5)*0.08); 1017 return; 1018 } 1019 e.preventDefault(); 1020 PARALLAX=false; // the invitation worked: the user is driving now 1021 if(panning){ 1022 state.panX+=(e.clientX-lastX)*2/Math.max(1,canvas.clientWidth); 1023 state.panY-=(e.clientY-lastY)*2/Math.max(1,canvas.clientHeight); 1024 }else{ 1025 state.yaw+=(e.clientX-lastX)*0.011; 1026 // clamp strictly short of vertical (Math.PI/2 ~ 1.5708): crossing 1027 // the pole is where yaw-pitch camera math gimbal-locks -- past 1028 // it, further yaw dragging visually reverses and the model looks 1029 // like it snapped 180 degrees. This one handler is shared by 1030 // every 3D viewer on the page (makeViewer is instantiated per 1031 // canvas), so the old +-1.6 clamp (past the pole) hit it anywhere 1032 state.pitch=Math.max(-1.55,Math.min(1.55,state.pitch+(e.clientY-lastY)*0.011)); 1033 } 1034 lastX=e.clientX;lastY=e.clientY;state.redraw()}); 1035 canvas.addEventListener("pointerleave",function(){ 1036 if(state.hovTX||state.hovTY||state.hovX||state.hovY)hovTo(0,0)}); 1037 function pointerEnd(e){ 1038 delete pointers[e.pointerId]; 1039 var remaining=Object.keys(pointers); 1040 if(remaining.length===2){ // dropped from 3 to 2 fingers: resume pinch 1041 var pts=pinchPoints();pinchDist=dist(pts[0],pts[1]); 1042 return; 1043 } 1044 if(remaining.length===1){ // dropped from 2 to 1: resume single-finger 1045 var p=pointers[remaining[0]]; // drag, from 1046 dragging=true;panning=false;pinchDist=0; // where that 1047 lastX=p.x;lastY=p.y; // finger is 1048 return; 1049 } 1050 dragging=false;pinchDist=0;canvas.style.cursor="grab"; 1051 // a press that never moved is a tap, not a grab 1052 if(opts.onTap&&Math.abs(e.clientX-downX)<5&& 1053 Math.abs(e.clientY-downY)<5)opts.onTap(); 1054 } 1055 canvas.addEventListener("pointerup",pointerEnd); 1056 canvas.addEventListener("pointercancel",pointerEnd); 1057 if(opts.wheel!==false) // card viewers keep the page's scroll wheel 1058 canvas.addEventListener("wheel",function(e){ 1059 e.preventDefault(); 1060 state.zoom=Math.max(0.3,Math.min(8,state.zoom*Math.exp(-e.deltaY*0.0016))); 1061 state.redraw()},{passive:false}); 1062 canvas.addEventListener("dblclick",function(){state.reset()}); 1063 return view; 1064} 1065// ---- live card viewers: swap a card's still for a real 3D view when 1066// it nears the viewport, and release the GL context when it leaves. 1067// Hundreds of cards can carry one, but only the on-screen few ever 1068// hold a context; the mesh payloads lazy-load exactly like the stills. 1069function liveCards(o){ 1070 o=o||{}; 1071 if(!("IntersectionObserver" in window))return; 1072 var active=[]; 1073 function activate(vr,img){ 1074 if(vr._cv||vr._pend)return; 1075 vr._pend=1; 1076 vr.classList.add("al-loading"); // the still shows underneath meanwhile 1077 ensureBlobs([img.dataset.mesh]).then(function(){ 1078 vr._pend=0; 1079 if(!vr._on||vr._cv){vr.classList.remove("al-loading");return} // away 1080 var cv=document.createElement("canvas");
1081 cv.className="cv3d"; 1082 var st=makeState(); 1083 // research-log pages can have several of these live near the 1084 // viewport at once; default AA off (real GPU memory per context) 1085 // unless a caller explicitly opts back in via viewerOpts 1086 var vOpts={antialias:false}; 1087 for(var vk in (o.viewerOpts||{}))vOpts[vk]=o.viewerOpts[vk]; 1088 // lost after creation (GPU/OS pressure): drop back to the still 1089 // image cleanly rather than leave a dead canvas on top of it 1090 vOpts.onLost=function(){ 1091 var i=active.indexOf(st);if(i>=0)active.splice(i,1); 1092 if(vr._cv===cv){vr._cv=null;cv.remove()} 1093 }; 1094 var v=makeViewer(cv,st,vOpts); 1095 vr.classList.remove("al-loading"); 1096 if(!v)return; // no webgl: the still stays, no spinner needed 1097 vr.appendChild(cv); 1098 v.load([{id:img.dataset.mesh}]); 1099 vr._cv=cv;cv._view=v;cv._st=st; 1100 active.push(st); 1101 requestAnimationFrame(function(){st.redraw()}); 1102 }); 1103 } 1104 function deactivate(vr){ 1105 vr.classList.remove("al-loading"); 1106 var cv=vr._cv; 1107 if(!cv)return; 1108 vr._cv=null; 1109 var i=active.indexOf(cv._st); 1110 if(i>=0)active.splice(i,1); 1111 if(cv._view)cv._view.destroy(); 1112 cv.remove(); 1113 } 1114 var io=new IntersectionObserver(function(es){ 1115 es.forEach(function(en){ 1116 var vr=en.target,img=vr.querySelector("img.peek"); 1117 if(!img)return; 1118 vr._on=en.isIntersecting?1:0; 1119 if(en.isIntersecting)activate(vr,img); 1120 else deactivate(vr); 1121 }); 1122 },{rootMargin:"250px"}); 1123 document.querySelectorAll(".viewer .vr").forEach(function(vr){ 1124 var img=vr.querySelector("img.peek"); 1125 // failed designs keep their crossed-out still: the red X matters 1126 if(img&&!img.dataset.failed)io.observe(vr); 1127 }); 1128 window.addEventListener("resize",function(){ 1129 active.forEach(function(s){s.redraw()})}); 1130} 1131// ---- swap a canvas for a fresh twin, same id/classes/data-*: WEBGL_lose_ 1132// context is a one-way trip for a given element (getContext afterward 1133// keeps returning the same dead context, not a new one), so a spot that 1134// releases its GL resources and later wants them back needs a new 1135// canvas, not the old one reused. 1136function freshCanvas(cv){ 1137 var n=document.createElement("canvas"); 1138 for(var i=0;i<cv.attributes.length;i++) 1139 n.setAttribute(cv.attributes[i].name,cv.attributes[i].value); 1140 cv.parentNode.replaceChild(n,cv); 1141 return n; 1142} 1143// ---- mount/unmount a whole page section's WebGL work as it scrolls 1144// well clear of the viewport (both directions), so sections give their 1145// contexts back instead of quietly eating into the page's small (and on 1146// mobile, capped) budget; mount() runs again if the reader scrolls back. 1147// Falls back to a one-time mount where IntersectionObserver is missing. 1148function lazySection(el,mount,unmount){ 1149 if(!el)return; 1150 if(!("IntersectionObserver" in window)){mount();return} 1151 var on=false; 1152 new IntersectionObserver(function(es){ 1153 es.forEach(function(en){ 1154 if(en.isIntersecting===on)return; 1155 on=en.isIntersecting; 1156 if(on)mount();else unmount(); 1157 }); 1158 },{rootMargin:"400px"}).observe(el); 1159} 1160 1161 1162// ---- lineage metadata: parent map + the run baseline (gen-0 winner); 1163// pages provide #walk-meta JSON and a window.BASELINE global 1164var wmetaEl=document.getElementById("walk-meta"); 1165var WMETA=wmetaEl?JSON.parse(wmetaEl.textContent):{}; 1166var BASELINE=typeof window.BASELINE==="string"?window.BASELINE:null; 1167function hasEvBlob(x){ // a mesh payload carrying the evolved-parts subset 1168 if(BLOBS["m-"+x])return !!BLOBS["m-"+x].pn; 1169 if(BLOB_SRC["m-"+x])return true; // lazy files always carry pn 1170 var el=document.getElementById("m-"+x); 1171 return !!el&&el.textContent.indexOf('"pn"')>=0; 1172} 1173function walkChainFor(h){ 1174 // FULL ancestry via both parents (a primary-line walk dead-ends when 1175 // parent_a is a parentless designer/immigrant while the deep lineage 1176 // runs through parent_b), ordered oldest generation first, the 1177 // candidate itself last. Returns {all, steps}: every member for 1178 // timeline display, and the 3D-steppable subset. 1179 var seen={},stack=[h]; 1180 while(stack.length){ 1181 var cur=stack.pop(); 1182 if(seen[cur])continue; 1183 seen[cur]=1; 1184 var m=WMETA[cur]; 1185 if(!m)continue; 1186 if(m.p)stack.push(m.p); 1187 if(m.q)stack.push(m.q); 1188 } 1189 delete seen[h]; 1190 // the baseline (gen-0 winner) leads every chain: it is the reference 1191 // the replay starts from and the trail is drawn against 1192 if(BASELINE&&BASELINE!==h)seen[BASELINE]=1; 1193 var anc=Object.keys(seen); 1194 anc.sort(function(a,b){ 1195 var ga=(WMETA[a]||{}).g||0,gb=(WMETA[b]||{}).g||0; 1196 return ga-gb||(a<b?-1:1); 1197 }); 1198 var bi=anc.indexOf(BASELINE); 1199 if(bi>0){anc.splice(bi,1);anc.unshift(BASELINE)} 1200 var all=anc.concat([h]); 1201 return {all:all,steps:all.filter(hasEvBlob)}; 1202} 1203// one shared camera frame for a whole chain: common center + union of 1204// every member's extents, so stepping never re-centers or re-fits -- 1205// only the actual geometry differences move 1206function chainFrame(chain){ 1207 var cyw=Math.cos(DEF_YAW),syw=Math.sin(DEF_YAW); 1208 var ents=[],C=[0,0,0]; 1209 chain.forEach(function(h){ 1210 var e=decodeBlob("m-"+h); 1211 if(e&&e.ev)ents.push(e); 1212 }); 1213 if(!ents.length)return null;
1214 ents.forEach(function(e){C[0]+=e.c[0];C[1]+=e.c[1];C[2]+=e.c[2]}); 1215 C[0]/=ents.length;C[1]/=ents.length;C[2]/=ents.length; 1216 var mx=0,my=0,mz=0; 1217 ents.forEach(function(e){ 1218 // extents are stored about the blob's own center in the DEF_YAW 1219 // frame; shift them by the rotated offset to the common center 1220 var dx=e.c[0]-C[0],dy=e.c[1]-C[1],dz=e.c[2]-C[2]; 1221 var ox=Math.abs(cyw*dx+syw*dy),oy=Math.abs(cyw*dy-syw*dx); 1222 mx=Math.max(mx,e.ev.mx+ox); 1223 my=Math.max(my,e.ev.my+oy); 1224 mz=Math.max(mz,e.ev.mz+Math.abs(dz)); 1225 }); 1226 return {c:C,r:ents[0].r,mx:mx,my:my,mz:mz}; 1227} 1228// lineage trail: every prior frame a gray ghost, depth-graded so the 1229// nearest parent is strongest and the oldest faintest; candidate solid 1230// LAST so it stays crisp on top of the stacked ghosts 1231function trailSpecs(chain){ 1232 var n=chain.length,fs=[]; 1233 for(var i=0;i<n-1;i++) 1234 fs.push({id:"m-"+chain[i],evolved:true,ghost:true, 1235 fade:n>2?0.35+0.65*i/(n-2):1}); 1236 fs.push({id:"m-"+chain[n-1],evolved:true}); 1237 return fs; 1238} 1239// ---- evolution replay component: steps through a candidate's ancestry 1240// from the baseline to the candidate, cross-fading between steps. Pages 1241// hand it their own canvas/timeline/label/prev/next elements. 1242function makeReplay(o){ 1243 // default camera tilt (nose-side three-quarter), same as every other 1244 // viewer; pass pitch to override (e.g. 1.2 for a top-down plan view) 1245 var state=makeState(o.pitch); 1246 var viewer=null,anim=null,timer=null,playBtn=null; 1247 var rep={state:state,chain:[],all:[],frame:null,idx:0}; 1248 rep.redraw=function(){state.redraw()}; 1249 function specs(k){ 1250 var s=[{id:"m-"+rep.chain[k],evolved:true}]; 1251 if(k+1<rep.chain.length) 1252 s.push({id:"m-"+rep.chain[k+1],evolved:true,ghost:true}); 1253 return s; 1254 } 1255 function label(scrollThumb){ 1256 var h=rep.chain[rep.idx],m=WMETA[h]||{}; 1257 var t="step "+(rep.idx+1)+" of "+rep.chain.length+" · g"+m.g+ 1258 (h===BASELINE?" · baseline":"")+ 1259 " · "+h+(m.f?" · "+m.f+" Wh/km":" · invalid"); 1260 if(rep.idx+1<rep.chain.length){ 1261 var h2=rep.chain[rep.idx+1],m2=WMETA[h2]||{}; 1262 t+=" â ghost: g"+m2.g+" · "+h2.slice(0,8); 1263 } 1264 if(o.label)o.label.textContent=t; 1265 if(o.prev)o.prev.disabled=rep.idx===0; 1266 if(o.next)o.next.disabled=rep.idx>=rep.chain.length-1; 1267 if(o.timeline) 1268 o.timeline.querySelectorAll(".wthumb").forEach(function(b){ 1269 var on=+b.dataset.k===rep.idx; 1270 b.classList.toggle("on",on); 1271 // only chase the active thumb on user-driven steps: on an 1272 // inline page (the landing) the open()-time call would drag 1273 // the whole document down to the timeline 1274 if(on&&scrollThumb) 1275 b.scrollIntoView({block:"nearest",inline:"nearest"}); 1276 }); 1277 } 1278 rep.stop=function(){ 1279 if(!timer)return; 1280 clearInterval(timer);timer=null; 1281 if(playBtn){playBtn.innerHTML="▶";playBtn.title="play"} 1282 }; 1283 // autoplay: one step per beat, stops at the end or on any manual input 1284 rep.play=function(){ 1285 if(!playBtn||rep.chain.length<2)return; 1286 if(rep.idx>=rep.chain.length-1)rep.go(0); // at the end: rewind first 1287 playBtn.innerHTML="❚❚";playBtn.title="pause"; 1288 timer=setInterval(function(){ 1289 if(rep.idx>=rep.chain.length-1){rep.stop();return} 1290 rep.go(rep.idx+1); 1291 },1600); 1292 }; 1293 rep.go=function(k){ 1294 if(k<0||k>=rep.chain.length||k===rep.idx||!viewer)return; 1295 if(anim){cancelAnimationFrame(anim);anim=null} 1296 var key=function(s){return s.id+(s.ghost?"|g":"|s")}; 1297 var oldS=specs(rep.idx),newS=specs(k); 1298 var oldK={},newK={}; 1299 oldS.forEach(function(s){oldK[key(s)]=1}); 1300 newS.forEach(function(s){newK[key(s)]=1}); 1301 rep.idx=k; 1302 // union of both steps' models: leavers fade out, joiners fade in 1303 var sp=[],fades=[]; 1304 oldS.forEach(function(s){ 1305 if(!newK[key(s)]){sp.push(s);fades.push([1,0])}}); 1306 newS.forEach(function(s){ 1307 sp.push(s);fades.push(oldK[key(s)]?[1,1]:[0,1])}); 1308 viewer.load(sp.map(function(s,i){ 1309 return {id:s.id,evolved:true,ghost:s.ghost,fade:fades[i][0]}}), 1310 rep.frame); 1311 label(true); 1312 var t0=null,DUR=950; 1313 function tick(ts){ 1314 if(t0===null)t0=ts; 1315 var t=Math.min(1,(ts-t0)/DUR),e=t*(2-t); // ease-out
1316 fades.forEach(function(f,i){viewer.setFade(i,f[0]+(f[1]-f[0])*e)}); 1317 state.redraw(); 1318 if(t<1){anim=requestAnimationFrame(tick)} 1319 else{anim=null;viewer.load(specs(rep.idx),rep.frame); 1320 state.redraw()} 1321 } 1322 anim=requestAnimationFrame(tick); 1323 }; 1324 rep.next=function(){rep.stop();rep.go(rep.idx+1)}; 1325 rep.prev=function(){rep.stop();rep.go(rep.idx-1)}; 1326 rep.open=function(h){ // false when there is nothing to replay 1327 if(!viewer)viewer=makeViewer(o.canvas,state); 1328 var c=walkChainFor(h); 1329 rep.all=c.all;rep.chain=c.steps; 1330 // need >=2 steppable frames AND the chain must reach the candidate 1331 if(!viewer||rep.chain.length<2|| 1332 rep.chain[rep.chain.length-1]!==h){ 1333 rep.chain=[];rep.frame=null; 1334 return false; 1335 } 1336 rep.stop(); 1337 rep.idx=0; 1338 rep.frame=chainFrame(rep.chain); 1339 viewer.load(specs(0),rep.frame); 1340 state.reset(); 1341 if(o.timeline){ 1342 // timeline: play button + one thumbnail per lineage step 1343 // (meta values are trusted generator output: paths, hex, numbers) 1344 var stepIdx={}; 1345 rep.chain.forEach(function(sh,si){stepIdx[sh]=si}); 1346 var tp=['<button class="wplay" title="play">▶</button>']; 1347 rep.all.forEach(function(th){ 1348 var tm=WMETA[th]||{},ti=stepIdx[th]; 1349 var lab=th===BASELINE?"base":"g"+tm.g; 1350 var tt=th+(th===BASELINE?" · baseline":"")+ 1351 (tm.f?" · "+tm.f+" Wh/km":" · invalid"); 1352 var inner=(tm.i?'<img src="'+tm.i+'" alt="'+th+ 1353 '" loading="lazy" decoding="async">':"")+ 1354 "<span>"+lab+"</span>"; 1355 if(ti===undefined){ // ancestor without an embedded 3D model 1356 tp.push('<span class="wthumb off" title="'+tt+ 1357 ' · no 3D model">'+inner+"</span>"); 1358 }else{ 1359 tp.push('<button class="wthumb" data-k="'+ti+'" title="'+tt+ 1360 '">'+inner+"</button>"); 1361 } 1362 }); 1363 o.timeline.innerHTML=tp.join(""); 1364 o.timeline.querySelectorAll(".wthumb").forEach(function(b){ 1365 b.addEventListener("click",function(){ 1366 rep.stop();rep.go(+b.dataset.k)}); 1367 }); 1368 playBtn=o.timeline.querySelector(".wplay"); 1369 playBtn.addEventListener("click", 1370 function(){timer?rep.stop():rep.play()}); 1371 } 1372 label(); 1373 return true; 1374 }; 1375 // release the GL context without touching chain/idx/timeline state -- 1376 // pages that mount this off a scroll observer call this on the way 1377 // out, then reopenViewer() on the way back in, instead of a full 1378 // open() (which would rebuild the timeline and rewind to step 0) 1379 rep.closeViewer=function(){ 1380 if(anim){cancelAnimationFrame(anim);anim=null} 1381 if(viewer){viewer.destroy();viewer=null} 1382 }; 1383 rep.reopenViewer=function(canvas){ 1384 if(viewer||!rep.chain.length)return false; 1385 if(canvas)o.canvas=canvas; 1386 viewer=makeViewer(o.canvas,state); 1387 if(!viewer)return false; 1388 viewer.load(specs(rep.idx),rep.frame); 1389 state.redraw(); 1390 return true; 1391 }; 1392 return rep; 1393} 1394 1395// the trace's MEAN attitude (same basis reconstruction the replay 1396// uses per frame): the pose a steady CFD solution is valid for. The 1397// flow field anchors here -- world-fixed -- while the craft oscillates 1398// within it. 1399function meanPose(d){ 1400 var n=d.x.length,sx=0,sy=0,sz=0; 1401 for(var i=0;i<n;i++){sx+=d.tx[i];sy+=d.ty[i];sz+=d.tz[i]} 1402 var tm=Math.hypot(sx,sy,sz)||1; 1403 var tx=sx/tm,ty=sy/tm,tz=sz/tm; 1404 var hx=d.x[n-1]-d.x[0],hy=d.y[n-1]-d.y[0]; 1405 var hm=Math.hypot(hx,hy)||1;hx/=hm;hy/=hm; 1406 var dot=hx*tx+hy*ty; 1407 var bx=[hx-dot*tx,hy-dot*ty,-dot*tz]; 1408 var bm=Math.hypot(bx[0],bx[1],bx[2])||1; 1409 bx=[bx[0]/bm,bx[1]/bm,bx[2]/bm]; 1410 var by=[ty*bx[2]-tz*bx[1],tz*bx[0]-tx*bx[2],tx*bx[1]-ty*bx[0]]; 1411 return [bx[0],bx[1],bx[2],by[0],by[1],by[2],tx,ty,tz]; 1412} 1413 1414window.AL={makeState:makeState,makeViewer:makeViewer, 1415 decodeBlob:decodeBlob,ensureBlobs:ensureBlobs,ensureFlight:ensureFlight, 1416 ensureFlowLines:ensureFlowLines,ensurePressure:ensurePressure, 1417 blobAvailable:blobAvailable,FLIGHTS:FLIGHTS,FLIGHT_SRC:FLIGHT_SRC, 1418 WMETA:WMETA,BASELINE:BASELINE,DEF_YAW:DEF_YAW,DEF_PITCH:DEF_PITCH, 1419 walkChainFor:walkChainFor,chainFrame:chainFrame,trailSpecs:trailSpecs, 1420 makeReplay:makeReplay,liveCards:liveCards,meanPose:meanPose, 1421 freshCanvas:freshCanvas,lazySection:lazySection}; 1422})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.