PageSourceSearch

https://alexisrondeau.me/airloom/viewer.js

js alexisrondeau.me collected 2026-09-25 03:38:07 UTC 62,743 bytes, 1,422 lines download raw bytes

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="&#9654;";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="&#10074;&#10074;";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">&#9654;</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.