-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
99 lines (89 loc) · 27.8 KB
/
Copy pathapp.js
File metadata and controls
99 lines (89 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
(() => {
'use strict';
const DATA=window.OPENWING_DATA, B=DATA.baseline;
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
const mix3=(a,b,t)=>[lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)];
const hex=h=>{h=h.replace('#','');return [parseInt(h.slice(0,2),16)/255,parseInt(h.slice(2,4),16)/255,parseInt(h.slice(4,6),16)/255]};
const C={skin1:hex('#42d7f5'),skin2:hex('#806df2'),rib:hex('#f2d56b'),spar:hex('#ff8e5b'),rear:hex('#56e3a5'),joiner:hex('#e8edf4'),aileron:hex('#ff637d'),fixture:hex('#7b8ba0'),sensor:hex('#58e6a5')};
// Minimal matrix/vector utilities.
const M4={
identity:()=>new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),
perspective:(fovy,aspect,near,far)=>{const f=1/Math.tan(fovy/2),nf=1/(near-far);return new Float32Array([f/aspect,0,0,0,0,f,0,0,0,0,(far+near)*nf,-1,0,0,2*far*near*nf,0])},
lookAt:(eye,target,up)=>{let zx=eye[0]-target[0],zy=eye[1]-target[1],zz=eye[2]-target[2];let zlen=Math.hypot(zx,zy,zz)||1;zx/=zlen;zy/=zlen;zz/=zlen;let xx=up[1]*zz-up[2]*zy,xy=up[2]*zx-up[0]*zz,xz=up[0]*zy-up[1]*zx;let xlen=Math.hypot(xx,xy,xz)||1;xx/=xlen;xy/=xlen;xz/=xlen;let yx=zy*xz-zz*xy,yy=zz*xx-zx*xz,yz=zx*xy-zy*xx;return new Float32Array([xx,yx,zx,0,xy,yy,zy,0,xz,yz,zz,0,-(xx*eye[0]+xy*eye[1]+xz*eye[2]),-(yx*eye[0]+yy*eye[1]+yz*eye[2]),-(zx*eye[0]+zy*eye[1]+zz*eye[2]),1])},
translate:(x,y,z)=>new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,x,y,z,1]),
};
const V3={sub:(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]],cross:(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],norm:a=>{const l=Math.hypot(...a)||1;return a.map(v=>v/l)}};
class Mesh{
constructor(gl,{positions,normals,colors,indices,mode='triangles',alpha=1,depthWrite=true,name='',group='base',explode=[0,0,0]}){this.gl=gl;this.name=name;this.group=group;this.visible=true;this.alpha=alpha;this.depthWrite=depthWrite;this.mode=mode;this.explode=explode;this.offset=[0,0,0];this.buffers={};this.count=indices.length;this.indices=[...indices];this.indexType=gl?((Math.max(...indices)>65535)?gl.UNSIGNED_INT:gl.UNSIGNED_SHORT):0;this.upload(positions,normals,colors,indices)}
upload(p,n,c,i){this.basePositions=new Float32Array(p);this.positions=new Float32Array(p);this.normals=new Float32Array(n);this.baseColors=new Float32Array(c);this.colors=new Float32Array(c);if(!this.gl)return;const gl=this.gl;const add=(key,target,data,usage=gl.STATIC_DRAW)=>{const b=gl.createBuffer();gl.bindBuffer(target,b);gl.bufferData(target,data,usage);this.buffers[key]=b};add('p',gl.ARRAY_BUFFER,this.positions,gl.DYNAMIC_DRAW);add('n',gl.ARRAY_BUFFER,this.normals);add('c',gl.ARRAY_BUFFER,this.colors,gl.DYNAMIC_DRAW);add('i',gl.ELEMENT_ARRAY_BUFFER,this.indexType===gl.UNSIGNED_INT?new Uint32Array(i):new Uint16Array(i))}
updatePositions(arr){this.positions=new Float32Array(arr);if(!this.gl)return;const gl=this.gl;gl.bindBuffer(gl.ARRAY_BUFFER,this.buffers.p);gl.bufferSubData(gl.ARRAY_BUFFER,0,this.positions)}
updateColors(arr){this.colors=new Float32Array(arr);if(!this.gl)return;const gl=this.gl;gl.bindBuffer(gl.ARRAY_BUFFER,this.buffers.c);gl.bufferSubData(gl.ARRAY_BUFFER,0,this.colors)}
draw(renderer){if(!this.visible)return;renderer.drawMesh(this)}
}
class Renderer{
constructor(canvas){this.canvas=canvas;const gl=canvas.getContext('webgl2',{antialias:true,alpha:false,preserveDrawingBuffer:true});this.gl=gl;this.software=!gl;if(this.software){this.ctx=canvas.getContext('2d');this.fov=Math.PI/4;this.camera=null;return}this.program=this.createProgram();this.loc={p:gl.getAttribLocation(this.program,'aPosition'),n:gl.getAttribLocation(this.program,'aNormal'),c:gl.getAttribLocation(this.program,'aColor'),model:gl.getUniformLocation(this.program,'uModel'),view:gl.getUniformLocation(this.program,'uView'),proj:gl.getUniformLocation(this.program,'uProjection'),light:gl.getUniformLocation(this.program,'uLight'),alpha:gl.getUniformLocation(this.program,'uAlpha'),ambient:gl.getUniformLocation(this.program,'uAmbient')};gl.enable(gl.DEPTH_TEST);gl.enable(gl.CULL_FACE);gl.cullFace(gl.BACK);gl.enable(gl.BLEND);gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);this.view=M4.identity();this.proj=M4.identity();this.model=M4.identity()}
createProgram(){const gl=this.gl,vs=`#version 300 es\nprecision highp float;in vec3 aPosition;in vec3 aNormal;in vec3 aColor;uniform mat4 uModel,uView,uProjection;out vec3 vN;out vec3 vC;out vec3 vP;void main(){vec4 wp=uModel*vec4(aPosition,1.0);vP=wp.xyz;vN=mat3(uModel)*aNormal;vC=aColor;gl_Position=uProjection*uView*wp;}`,fs=`#version 300 es\nprecision highp float;in vec3 vN;in vec3 vC;in vec3 vP;uniform vec3 uLight;uniform float uAlpha,uAmbient;out vec4 outColor;void main(){float d=max(dot(normalize(vN),normalize(uLight)),0.0);float rim=pow(1.0-max(abs(normalize(vN).z),0.0),2.0)*0.18;vec3 col=vC*(uAmbient+d*0.72)+rim*vec3(.25,.65,1.0);outColor=vec4(col,uAlpha);}`;const sh=(type,src)=>{const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(s));return s};const p=gl.createProgram();gl.attachShader(p,sh(gl.VERTEX_SHADER,vs));gl.attachShader(p,sh(gl.FRAGMENT_SHADER,fs));gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw Error(gl.getProgramInfoLog(p));return p}
resize(){const dpr=Math.min(devicePixelRatio||1,2),w=Math.floor(this.canvas.clientWidth*dpr),h=Math.floor(this.canvas.clientHeight*dpr);if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;if(this.gl)this.gl.viewport(0,0,w,h)}return w/h}
begin(camera){this.camera=camera;const a=this.resize();if(this.software){const x=this.ctx,dpr=this.canvas.width/this.canvas.clientWidth;x.setTransform(dpr,0,0,dpr,0,0);const g=x.createRadialGradient(this.canvas.clientWidth*.5,this.canvas.clientHeight*.44,20,this.canvas.clientWidth*.5,this.canvas.clientHeight*.44,this.canvas.clientWidth*.65);g.addColorStop(0,'#143759');g.addColorStop(.42,'#091625');g.addColorStop(1,'#050a13');x.fillStyle=g;x.fillRect(0,0,this.canvas.clientWidth,this.canvas.clientHeight);return}const gl=this.gl;this.proj=M4.perspective(Math.PI/4,a,.5,5000);this.view=M4.lookAt(camera.eye(),camera.target,[0,1,0]);gl.clearColor(.018,.04,.075,1);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.useProgram(this.program);gl.uniformMatrix4fv(this.loc.view,false,this.view);gl.uniformMatrix4fv(this.loc.proj,false,this.proj);gl.uniform3fv(this.loc.light,new Float32Array([.25,.8,.45]));gl.uniform1f(this.loc.ambient,.32)}
project(v){const eye=this.camera.eye(),fwd=V3.norm(V3.sub(this.camera.target,eye)),right=V3.norm(V3.cross(fwd,[0,1,0])),up=V3.cross(right,fwd),d=V3.sub(v,eye),z=d[0]*fwd[0]+d[1]*fwd[1]+d[2]*fwd[2];if(z<1)return null;const xx=d[0]*right[0]+d[1]*right[1]+d[2]*right[2],yy=d[0]*up[0]+d[1]*up[1]+d[2]*up[2],scale=(this.canvas.clientHeight*.5)/Math.tan(this.fov/2);return [this.canvas.clientWidth*.5+xx*scale/z,this.canvas.clientHeight*.5-yy*scale/z,z]}
drawSoftware(m){const x=this.ctx,p=m.positions,c=m.colors,off=m.offset;if(m.mode==='lines'){x.strokeStyle=`rgba(${Math.round(c[0]*255)},${Math.round(c[1]*255)},${Math.round(c[2]*255)},${m.alpha})`;x.lineWidth=1.15;x.beginPath();for(let k=0;k<m.indices.length;k+=2){const ia=m.indices[k]*3,ib=m.indices[k+1]*3,a=this.project([p[ia]+off[0],p[ia+1]+off[1],p[ia+2]+off[2]]),b=this.project([p[ib]+off[0],p[ib+1]+off[1],p[ib+2]+off[2]]);if(a&&b){x.moveTo(a[0],a[1]);x.lineTo(b[0],b[1])}}x.stroke();return}const tris=[],count=m.indices.length/3,stride=Math.max(1,Math.ceil(count/2800));for(let k=0;k<m.indices.length;k+=3*stride){const ids=[m.indices[k],m.indices[k+1],m.indices[k+2]];if(ids.some(v=>v===undefined))continue;const q=ids.map(id=>this.project([p[id*3]+off[0],p[id*3+1]+off[1],p[id*3+2]+off[2]]));if(q.some(v=>!v))continue;tris.push({q,z:(q[0][2]+q[1][2]+q[2][2])/3,id:ids[0]})}tris.sort((a,b)=>b.z-a.z);for(const t of tris){const i=t.id*3,shade=.62+.38*Math.max(0,m.normals[i+1]||0),r=Math.round(clamp(c[i]*shade,0,1)*255),g=Math.round(clamp(c[i+1]*shade,0,1)*255),b=Math.round(clamp(c[i+2]*shade,0,1)*255);x.fillStyle=`rgba(${r},${g},${b},${m.alpha})`;x.beginPath();x.moveTo(t.q[0][0],t.q[0][1]);x.lineTo(t.q[1][0],t.q[1][1]);x.lineTo(t.q[2][0],t.q[2][1]);x.closePath();x.fill()}}
drawMesh(m){if(this.software){this.drawSoftware(m);return}const gl=this.gl;const pos=[m.offset[0],m.offset[1],m.offset[2]];gl.uniformMatrix4fv(this.loc.model,false,M4.translate(...pos));gl.uniform1f(this.loc.alpha,m.alpha);gl.depthMask(m.depthWrite);const bind=(buf,loc)=>{gl.bindBuffer(gl.ARRAY_BUFFER,buf);gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,3,gl.FLOAT,false,0,0)};bind(m.buffers.p,this.loc.p);bind(m.buffers.n,this.loc.n);bind(m.buffers.c,this.loc.c);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,m.buffers.i);const mode=m.mode==='lines'?gl.LINES:gl.TRIANGLES;gl.drawElements(mode,m.count,m.indexType,0);gl.depthMask(true)}
}
class Camera{
constructor(canvas){this.canvas=canvas;this.az=-.62;this.el=.42;this.distance=1050;this.target=[0,45,30];this.drag=false;this.last=[0,0];this.mode='orbit';canvas.addEventListener('pointerdown',e=>{this.drag=true;this.last=[e.clientX,e.clientY];this.mode=e.shiftKey?'pan':'orbit';canvas.setPointerCapture(e.pointerId)});canvas.addEventListener('pointermove',e=>{if(!this.drag)return;const dx=e.clientX-this.last[0],dy=e.clientY-this.last[1];this.last=[e.clientX,e.clientY];if(this.mode==='orbit'){this.az-=dx*.007;this.el=clamp(this.el-dy*.007,-1.35,1.35)}else{const s=this.distance*.0015;this.target[0]-=dx*s*Math.cos(this.az);this.target[2]+=dx*s*Math.sin(this.az);this.target[1]+=dy*s}});canvas.addEventListener('pointerup',()=>this.drag=false);canvas.addEventListener('pointercancel',()=>this.drag=false);canvas.addEventListener('wheel',e=>{e.preventDefault();this.distance=clamp(this.distance*Math.exp(e.deltaY*.001),250,2400)},{passive:false})}
eye(){const ce=Math.cos(this.el);return [this.target[0]+this.distance*ce*Math.sin(this.az),this.target[1]+this.distance*Math.sin(this.el),this.target[2]+this.distance*ce*Math.cos(this.az)]}
setView(v){if(v==='top'){this.az=0;this.el=1.52;this.distance=1450;this.target=[0,20,30]}else if(v==='front'){this.az=-Math.PI/2;this.el=.08;this.distance=1350;this.target=[0,20,30]}else{this.az=-.62;this.el=.42;this.distance=1050;this.target=[0,45,30]}}
}
function calcNormals(p,idx){const n=new Float32Array(p.length);for(let i=0;i<idx.length;i+=3){const ia=idx[i]*3,ib=idx[i+1]*3,ic=idx[i+2]*3,a=[p[ia],p[ia+1],p[ia+2]],b=[p[ib],p[ib+1],p[ib+2]],c=[p[ic],p[ic+1],p[ic+2]],q=V3.cross(V3.sub(b,a),V3.sub(c,a));for(const j of [ia,ib,ic]){n[j]+=q[0];n[j+1]+=q[1];n[j+2]+=q[2]}}for(let i=0;i<n.length;i+=3){const q=V3.norm([n[i],n[i+1],n[i+2]]);n[i]=q[0];n[i+1]=q[1];n[i+2]=q[2]}return [...n]}
function naca4412(x,upper=true){const m=.04,p=.4,t=.12;let yc,dy;if(x<p){yc=m/(p*p)*(2*p*x-x*x);dy=2*m/(p*p)*(p-x)}else{yc=m/((1-p)*(1-p))*((1-2*p)+2*p*x-x*x);dy=2*m/((1-p)*(1-p))*(p-x)}const yt=5*t*(.2969*Math.sqrt(Math.max(x,0))-.126*x-.3516*x*x+.2843*x*x*x-.1015*x*x*x*x),th=Math.atan(dy);return upper?[x-yt*Math.sin(th),yc+yt*Math.cos(th)]:[x+yt*Math.sin(th),yc-yt*Math.cos(th)]}
function sectionLoop(count=42){const pts=[];for(let i=0;i<count;i++){const b=Math.PI*i/(count-1),x=(1+Math.cos(b))/2;pts.push(naca4412(x,true))}for(let i=1;i<count-1;i++){const b=Math.PI*(count-1-i)/(count-1),x=(1+Math.cos(b))/2;pts.push(naca4412(x,false))}return pts}
function chordAt(s){return lerp(B.rootChordMm,B.tipChordMm,Math.abs(s)/B.semiSpanMm)}
function twistAt(s){const a=Math.abs(s);return a<=300?lerp(0,-1,a/300):lerp(-1,-2,(a-300)/300)}
function transformSection(span,xn,yn){const c=chordAt(span),tw=twistAt(span)*Math.PI/180,zc=(xn-.25)*c,y0=yn*c,yD=Math.abs(span)*Math.tan(B.dihedralDeg*Math.PI/180);const y=yD+y0*Math.cos(tw)-zc*Math.sin(tw),z=zc*Math.cos(tw)+y0*Math.sin(tw);return [span,y,z]}
function vertexColor(span,mode){const t=Math.abs(span)/600;if(mode==='aero'){const q=Math.sin((1-t)*Math.PI/2);return mix3(hex('#315fef'),hex('#ffe06f'),q)}if(mode==='fea'){const q=Math.pow(1-t,1.4);return q>.55?mix3(hex('#ffc34f'),hex('#ff596f'),(q-.55)/.45):mix3(hex('#3f7df5'),hex('#ffc34f'),q/.55)}if(mode==='optimise')return mix3(hex('#50d9c8'),hex('#9b7ef8'),t);return mix3(C.skin1,C.skin2,t)}
function makeWing(gl,colorMode='base'){const spans=49,loop=sectionLoop(38),p=[],c=[],meta=[],idx=[];for(let i=0;i<spans;i++){const s=lerp(-600,600,i/(spans-1));for(const q of loop){const v=transformSection(s,q[0],q[1]);p.push(...v);c.push(...vertexColor(s,colorMode));meta.push({span:s,baseY:v[1]})}}const L=loop.length;for(let i=0;i<spans-1;i++)for(let j=0;j<L;j++){const a=i*L+j,b=i*L+(j+1)%L,d=(i+1)*L+j,e=(i+1)*L+(j+1)%L;idx.push(a,d,b,b,d,e)}return {mesh:new Mesh(gl,{positions:p,normals:calcNormals(p,idx),colors:c,indices:idx,alpha:.9,name:'Wing skin',group:'skin'}),meta}}
function boxGeometry(cx,cy,cz,sx,sy,sz,color){const x=sx/2,y=sy/2,z=sz/2,p=[cx-x,cy-y,cz-z,cx+x,cy-y,cz-z,cx+x,cy+y,cz-z,cx-x,cy+y,cz-z,cx-x,cy-y,cz+z,cx+x,cy-y,cz+z,cx+x,cy+y,cz+z,cx-x,cy+y,cz+z],idx=[0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,1,2,6,1,6,5,0,4,7,0,7,3],co=[];for(let i=0;i<8;i++)co.push(...color);return {positions:p,normals:calcNormals(p,idx),colors:co,indices:idx}}
function makeBox(gl,args){return new Mesh(gl,{...boxGeometry(args.cx,args.cy,args.cz,args.sx,args.sy,args.sz,args.color),alpha:args.alpha??1,name:args.name,group:args.group,explode:args.explode||[0,0,0]})}
function makeLine(gl,points,color,name,group='overlay'){const p=points.flat(),n=[],c=[],idx=[];for(let i=0;i<points.length;i++){n.push(0,1,0);c.push(...color);if(i<points.length-1)idx.push(i,i+1)}return new Mesh(gl,{positions:p,normals:n,colors:c,indices:idx,mode:'lines',alpha:1,name,group})}
function createStructure(gl){const arr=[];for(const side of [-1,1])for(const s0 of B.ribStationsMm){if(s0===0&&side===1)continue;const s=s0*side,loop=sectionLoop(30),pts=loop.map(q=>transformSection(s,q[0],q[1]));pts.push(pts[0]);const rib=makeLine(gl,pts,C.rib,`Rib ${s} mm`,'structure');rib.explode=[Math.sign(s)*.45,0,0];arr.push(rib)}
// Spars as segmented boxes aligned approximately to span; thin segments visually follow dihedral.
for(const side of [-1,1])for(const spec of [{pct:.30,col:C.spar,name:'Main spar',h:18,w:5},{pct:.70,col:C.rear,name:'Rear spar',h:12,w:4}])for(let s=0;s<600;s+=50){const sm=s+25,span=side*sm,ch=chordAt(span),z=(spec.pct-.25)*ch,y=Math.abs(span)*Math.tan(Math.PI/60);const b=makeBox(gl,{cx:span,cy:y,cz:z,sx:52,sy:spec.h,sz:spec.w,color:spec.col,name:spec.name,group:'structure',explode:[side*.12,0,spec.pct>.5?-.05:.05]});arr.push(b)}
// Joiner V arms and rear pin simplified.
for(const side of [-1,1]){const span=side*75,y=75*Math.tan(Math.PI/60),z=(.30-.25)*chordAt(75);arr.push(makeBox(gl,{cx:span,cy:y,cz:z,sx:152,sy:18,sz:4,color:C.joiner,name:'V joiner',group:'structure',explode:[side*.2,.12,0]}))}
arr.push(makeBox(gl,{cx:0,cy:5,cz:(.70-.25)*240,sx:124,sy:6,sz:6,color:C.joiner,name:'Rear pin',group:'structure'}));return arr}
function createControls(gl){const arr=[];for(const side of [-1,1]){const sm=450,s=side*sm,c=chordAt(s),y=Math.abs(s)*Math.tan(Math.PI/60),z=(.87-.25)*c;arr.push(makeBox(gl,{cx:s,cy:y,cz:z,sx:240,sy:5,sz:c*.23,color:C.aileron,name:'Aileron',group:'controls',alpha:.9,explode:[side*.18,.25,.12]}));const servoSpan=side*420,sc=chordAt(servoSpan);arr.push(makeBox(gl,{cx:servoSpan,cy:Math.abs(servoSpan)*Math.tan(Math.PI/60)-3,cz:(.58-.25)*sc,sx:40,sy:18,sz:26,color:hex('#6db5ff'),name:'Servo',group:'controls',explode:[side*.14,-.16,0]}));for(const hs of [360,450,540]){const sp=side*hs,hc=chordAt(sp);arr.push(makeBox(gl,{cx:sp,cy:Math.abs(sp)*Math.tan(Math.PI/60),cz:(.75-.25)*hc,sx:9,sy:7,sz:7,color:C.joiner,name:'Hinge',group:'controls',explode:[side*.15,.18,.08]}))}}return arr}
function createGuides(gl){const arr=[];for(const side of [-1,1]){for(const pct of [0,.25,1]){const pts=[];for(let s=0;s<=600;s+=25){const sp=side*s,c=chordAt(sp),tw=twistAt(sp)*Math.PI/180,zc=(pct-.25)*c,y=Math.abs(sp)*Math.tan(Math.PI/60)-zc*Math.sin(tw),z=zc*Math.cos(tw);pts.push([sp,y,z])}arr.push(makeLine(gl,pts, pct===.25?C.joiner:hex('#67a8ff'),pct===.25?'Quarter-chord axis':'Loft guide','guides'))}}return arr}
function createAero(gl){const arr=[];const samples=DATA.spanLoads.filter((_,i)=>i%2===0);for(const side of [-1,1])for(const r of samples){const s=side*r.semi_span_y_m*1000,c=r.local_chord_m*1000,y=Math.abs(s)*Math.tan(Math.PI/60)+8,z=(.25-.25)*c,len=r.lift_per_unit_span_N_per_m*2.4;const pts=[[s,y,z],[s,y+len,z],[s-5,y+len-10,z],[s,y+len,z],[s+5,y+len-10,z]];arr.push(makeLine(gl,pts,hex('#ffe06f'),'Lift vector','aero'))}return arr}
function createTest(gl){const arr=[];arr.push(makeBox(gl,{cx:0,cy:-45,cz:10,sx:150,sy:70,sz:110,color:C.fixture,name:'Root fixture',group:'test',alpha:.75}));for(const side of [-1,1])for(const r of DATA.loadBands){const s=side*r.span_mid_mm,c=chordAt(s),y=Math.abs(s)*Math.tan(Math.PI/60)+25,z=0;arr.push(makeBox(gl,{cx:s,cy:y,cz:z,sx:35,sy:5,sz:c*.7,color:hex('#83a2bb'),name:'Load saddle',group:'test',alpha:.85,explode:[0,.18,0]}));const len=r.total_directional_force_n*5;arr.push(makeLine(gl,[[s,y+5,z],[s,y+5+len,z]],hex('#ffb44d'),'Proof load','test'))}for(const side of [-1,1])for(const s0 of [75,300,450,600]){const s=side*s0,c=chordAt(s),y=Math.abs(s)*Math.tan(Math.PI/60)+18,z=(.3-.25)*c;arr.push(makeBox(gl,{cx:s,cy:y,cz:z,sx:8,sy:8,sz:8,color:C.sensor,name:'Sensor',group:'test'}))}return arr}
function createDigital(gl){const arr=[],nodes=[];for(let i=0;i<9;i++){const a=i/9*Math.PI*2,r=430+(i%2)*70,n=[Math.cos(a)*r,140+Math.sin(i*1.7)*90,Math.sin(a)*r*.55];nodes.push(n);arr.push(makeBox(gl,{cx:n[0],cy:n[1],cz:n[2],sx:14,sy:14,sz:14,color:i%2?C.skin1:C.skin2,name:'Digital-thread node',group:'digital'}))}for(let i=0;i<nodes.length;i++)arr.push(makeLine(gl,[nodes[i],nodes[(i+1)%nodes.length]],hex('#4bcfe8'),'Digital thread','digital'));return arr}
const canvas=$('#glCanvas'),renderer=new Renderer(canvas),gl=renderer.gl,camera=new Camera(canvas);if(renderer.software){document.querySelector('.live-badge').innerHTML='<span></span>LIVE CANVAS 3D';document.querySelector('#statusText').textContent='Software 3D renderer active'}
let wingData=makeWing(gl),wing=wingData.mesh;const objects=[wing,...createStructure(gl),...createControls(gl),...createGuides(gl),...createAero(gl),...createTest(gl),...createDigital(gl)];
const groups={};for(const o of objects)(groups[o.group]??=[]).push(o);
let versionIndex=0,candidate='balanced',effect=0,autoRotate=false,tourTimer=null,frame=0,lastFps=performance.now(),fpsCount=0;
function interpRows(rows,x,key){if(x<=rows[0].x_m)return rows[0][key];for(let i=1;i<rows.length;i++)if(x<=rows[i].x_m){const a=rows[i-1],b=rows[i],t=(x-a.x_m)/(b.x_m-a.x_m);return lerp(a[key],b[key],t)}return rows.at(-1)[key]}
function applyWingVisual(time=0){const v=DATA.versions[versionIndex].id,mode=v==='0.5'?'aero':v==='0.6'?'fea':v==='0.7'?'optimise':'base',colors=[];for(const m of wingData.meta)colors.push(...vertexColor(m.span,mode));wing.updateColors(colors);const pos=[...wing.basePositions],amp=effect/100;for(let i=0;i<wingData.meta.length;i++){const m=wingData.meta[i],x=Math.abs(m.span)/1000;let d=0;if(v==='0.6'){if($('#effectLabel').textContent.includes('Modal')){const s=x/.6;d=(Math.cosh(1.875*s)-Math.cos(1.875*s)-.7341*(Math.sinh(1.875*s)-Math.sin(1.875*s)))*18*amp*Math.sin(time*3)}else d=interpRows(DATA.feaPositive,x,'vertical_displacement_m')*1000*amp*20}pos[i*3+1]=m.baseY+d}wing.updatePositions(pos)}
function setGroup(group,visible){(groups[group]||[]).forEach(o=>o.visible=visible)}
function updateScene(){const id=DATA.versions[versionIndex].id;setGroup('skin',$('#skinToggle').checked);setGroup('structure',$('#structureToggle').checked);setGroup('controls',$('#aileronsToggle').checked);['guides','aero','test','digital'].forEach(g=>setGroup(g,false));if($('#overlayToggle').checked){if(id==='0.2')setGroup('guides',true);if(id==='0.5')setGroup('aero',true);if(id==='0.8')setGroup('test',true);if(id==='0.9')setGroup('digital',true)}const ex=(id==='0.4'?effect/100:0);for(const o of objects){o.offset=[o.explode[0]*ex*250,o.explode[1]*ex*180,o.explode[2]*ex*180]}wing.alpha=$('#structureToggle').checked?.28:.92;wing.depthWrite=!$('#structureToggle').checked;$('#chartCanvas').classList.toggle('visible',['0.5','0.6','0.7','0.8'].includes(id));drawChart();updateLegend();applyWingVisual(performance.now()/1000)}
function updateLegend(){const id=DATA.versions[versionIndex].id,items=[];if($('#skinToggle').checked)items.push(['#54e4ff','Aerodynamic skin']);if($('#structureToggle').checked)items.push(['#f2d56b','Ribs'],['#ff8e5b','Main spar'],['#56e3a5','Rear spar']);if(id==='0.5'&&$('#overlayToggle').checked)items.push(['#ffe06f','Spanwise lift']);if(id==='0.6')items.push(['#ff596f','High bending demand'],['#3f7df5','Low demand']);if(id==='0.8'&&$('#overlayToggle').checked)items.push(['#ffb44d','Proof loads'],['#58e6a5','Sensors']);$('#sceneLegend').innerHTML=items.map(i=>`<span class="legend-item"><i style="background:${i[0]}"></i>${i[1]}</span>`).join('')}
function setVersion(index,fromTour=false){versionIndex=clamp(index,0,8);const v=DATA.versions[versionIndex];location.hash=`v${v.id}`;$$('.version-button').forEach((b,i)=>b.classList.toggle('active',i===versionIndex));$('#progressBar').style.width=`${(versionIndex+1)/9*100}%`;$('#versionTag').textContent=v.tag;$('#versionTitle').textContent=`v${v.id} · ${v.title}`;$('#versionSummary').textContent=v.summary;$('#featureList').innerHTML=v.features.map(x=>`<li>${x}</li>`).join('');$('#metricGrid').innerHTML=v.metrics.map(([a,b])=>`<div class="metric"><b>${b}</b><span>${a}</span></div>`).join('');$('#candidateSection').hidden=v.id!=='0.7';$('#effectLabel').textContent=v.id==='0.6'?'FEA deformation':v.id==='0.4'?'Exploded view':v.id==='0.5'?'Load scale':v.id==='0.8'?'Test load scale':'Scene effect';const defaults={skin:true,structure:['0.3','0.4','0.6','0.7','0.8','0.9'].includes(v.id),controls:['0.3','0.4','0.7','0.8','0.9'].includes(v.id),overlay:['0.2','0.5','0.8','0.9'].includes(v.id)};$('#skinToggle').checked=defaults.skin;$('#structureToggle').checked=defaults.structure;$('#aileronsToggle').checked=defaults.controls;$('#overlayToggle').checked=defaults.overlay;effect=v.id==='0.6'?55:v.id==='0.4'?30:0;$('#effectRange').value=effect;$('#effectOutput').textContent=`${effect}%`;$('#statusText').textContent=`v${v.id} ${v.title} scene active`;updateScene();if(!fromTour)stopTour(false)}
function buildNav(){const nav=$('#versionNav');DATA.versions.forEach((v,i)=>{const b=document.createElement('button');b.className='version-button';b.innerHTML=`<b>v${v.id}</b><span>${v.title}</span><small>${v.tag}</small>`;b.onclick=()=>setVersion(i);nav.appendChild(b)})}
function chartCtx(){const c=$('#chartCanvas'),dpr=Math.min(devicePixelRatio||1,2),w=Math.round(c.clientWidth*dpr),h=Math.round(c.clientHeight*dpr);if(c.width!==w||c.height!==h){c.width=w;c.height=h}const x=c.getContext('2d');x.setTransform(dpr,0,0,dpr,0,0);return {x,w:c.clientWidth,h:c.clientHeight}}
function plotLine(ctx,points,box,color){ctx.strokeStyle=color;ctx.lineWidth=1.8;ctx.beginPath();points.forEach((p,i)=>{const xx=box.x+(p[0]-box.xmin)/(box.xmax-box.xmin)*box.w,yy=box.y+box.h-(p[1]-box.ymin)/(box.ymax-box.ymin)*box.h;i?ctx.lineTo(xx,yy):ctx.moveTo(xx,yy)});ctx.stroke()}
function drawChart(){const id=DATA.versions[versionIndex].id;if(!['0.5','0.6','0.7','0.8'].includes(id))return;const {x,w,h}=chartCtx();x.clearRect(0,0,w,h);x.fillStyle='rgba(5,12,22,.9)';x.fillRect(0,0,w,h);const box={x:38,y:30,w:w-52,h:h-48};x.strokeStyle='rgba(155,195,225,.18)';x.lineWidth=1;x.strokeRect(box.x,box.y,box.w,box.h);x.font='10px system-ui';x.fillStyle='#a8bed0';let title='',sub='';if(id==='0.5'){title='AERODYNAMIC POLAR';sub='CL and total CD vs angle of attack';const rows=DATA.alphaSweep,xs=rows.map(r=>r.alpha_deg),ys=rows.map(r=>r.CL),ds=rows.map(r=>r.CD_total);Object.assign(box,{xmin:Math.min(...xs),xmax:Math.max(...xs),ymin:Math.min(...ys)-.05,ymax:Math.max(...ys)+.05});plotLine(x,rows.map(r=>[r.alpha_deg,r.CL]),box,'#54e4ff');plotLine(x,rows.map(r=>[r.alpha_deg,r.CD_total]),box,'#ffb44d')}else if(id==='0.6'){title='STATIC DISPLACEMENT';sub='Equivalent beam result · display scale independent';const rows=DATA.feaPositive;Object.assign(box,{xmin:0,xmax:.6,ymin:0,ymax:Math.max(...rows.map(r=>r.vertical_displacement_m*1000))*1.1});plotLine(x,rows.map(r=>[r.x_m,r.vertical_displacement_m*1000]),box,'#ff647c')}else if(id==='0.7'){title='OPTIMISATION TRADE';sub=`${candidate.toUpperCase()} candidate concept`;const sets={lightweight:[[0,82],[1,57],[2,37],[3,65]],balanced:[[0,70],[1,76],[2,69],[3,80]],stiff:[[0,45],[1,95],[2,91],[3,58]]};Object.assign(box,{xmin:0,xmax:3,ymin:0,ymax:100});plotLine(x,sets[candidate],box,'#a78bfa');for(const p of sets[candidate]){const xx=box.x+p[0]/3*box.w,yy=box.y+box.h-p[1]/100*box.h;x.fillStyle='#54e4ff';x.beginPath();x.arc(xx,yy,3,0,Math.PI*2);x.fill()}}else{title='PROOF-LOAD DISTRIBUTION';sub='12 padded load saddles · positive limit';const rows=DATA.loadBands;Object.assign(box,{xmin:0,xmax:600,ymin:0,ymax:Math.max(...rows.map(r=>r.total_directional_force_n))*1.1});plotLine(x,rows.map(r=>[r.span_mid_mm,r.total_directional_force_n]),box,'#ffb44d')}x.fillStyle='#e8f4ff';x.font='700 10px system-ui';x.fillText(title,12,15);x.fillStyle='#7f98ad';x.font='9px system-ui';x.fillText(sub,12,26)}
function render(t){frame=t/1000;if(autoRotate&&!camera.drag)camera.az+=.0022;const id=DATA.versions[versionIndex].id;if(id==='0.6')applyWingVisual(frame);renderer.begin(camera);const transparent=objects.filter(o=>o.visible&&o.alpha<1),solid=objects.filter(o=>o.visible&&o.alpha>=1);for(const o of solid)o.draw(renderer);for(const o of transparent)o.draw(renderer);fpsCount++;if(t-lastFps>700){$('#fpsText').textContent=`${Math.round(fpsCount*1000/(t-lastFps))} FPS`;fpsCount=0;lastFps=t}requestAnimationFrame(render)}
function startTour(){if(tourTimer)return;$('#tourBtn').textContent='■ Stop tour';tourTimer=setInterval(()=>setVersion((versionIndex+1)%9,true),5200)}
function stopTour(update=true){if(tourTimer){clearInterval(tourTimer);tourTimer=null}if(update)$('#tourBtn').textContent='▶ Guided tour';else $('#tourBtn').textContent='▶ Guided tour'}
buildNav();
['skinToggle','structureToggle','aileronsToggle','overlayToggle','wireToggle'].forEach(id=>$('#'+id).addEventListener('change',()=>{if(id==='wireToggle'){/* wireframe visual uses translucent skin + structure lines */$('#structureToggle').checked=$('#wireToggle').checked||$('#structureToggle').checked}updateScene()}));
$('#rotateToggle').addEventListener('change',e=>autoRotate=e.target.checked);
$('#effectRange').addEventListener('input',e=>{effect=+e.target.value;$('#effectOutput').textContent=`${effect}%`;updateScene()});
$('#candidateButtons').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;candidate=b.dataset.candidate;$$('#candidateButtons button').forEach(x=>x.classList.toggle('active',x===b));drawChart();$('#statusText').textContent=`Optimisation candidate: ${candidate}`});
$$('[data-view]').forEach(b=>b.onclick=()=>camera.setView(b.dataset.view));$('#resetViewBtn').onclick=()=>camera.setView('iso');
$('#screenshotBtn').onclick=()=>{const a=document.createElement('a');a.download=`OpenWing-UAV-v${DATA.versions[versionIndex].id}.png`;a.href=canvas.toDataURL('image/png');a.click()};
$('#tourBtn').onclick=()=>tourTimer?stopTour():startTour();$('#helpBtn').onclick=()=>$('#helpDialog').showModal();$('#closeHelp').onclick=()=>$('#helpDialog').close();
window.addEventListener('keydown',e=>{if(e.target.matches('input,button'))return;if(/^[1-9]$/.test(e.key))setVersion(+e.key-1);if(e.key.toLowerCase()==='r')camera.setView('iso');if(e.key.toLowerCase()==='w'){$('#wireToggle').click()}if(e.key.toLowerCase()==='e'){effect=effect?0:70;$('#effectRange').value=effect;$('#effectOutput').textContent=`${effect}%`;updateScene()}if(e.code==='Space'){e.preventDefault();tourTimer?stopTour():startTour()}});
const hash=location.hash.match(/v(0\.[1-9])/);setVersion(hash?DATA.versions.findIndex(v=>v.id===hash[1]):0);camera.setView('iso');requestAnimationFrame(render);setTimeout(()=>$('#loading').classList.add('hidden'),650);
if('serviceWorker' in navigator&&location.protocol.startsWith('http'))navigator.serviceWorker.register('sw.js').catch(()=>{});
})();