Files
CydandClaude Fable 5 afc3fd839e Vendor dpl3-revive: the Division/DPL3 renderer, now ours
Bring the graphics-dev collaborator's dpl3-revive into the repo as first-class
project code (they've handed it off; it's ours now). This is the proven
Division renderer that our in-process rt_draw has been trying to be.

What's here:
- parser/  B2Z/V2Z/SVT/SCN/SPL/BGF/BMF/BSL decoders (pure Python).
- spec/    reverse-engineered format + the definitive VelociRender wire
           protocol (from the original DIVISION source, matches our live
           VPX node/action tables exactly).
- source-ref/  read-only copies of the original DIVISION C (BIZREAD.C,
           DPLTYPES.H, DPL.H) that define the formats.
- patha/   the "virtual VelociRender board": vrboard.py (24-action protocol
           server), vrview.py (numpy software rasterizer, the reference),
           vrview_gl.py (moderngl GPU backend, 832x512@60Hz), plus the
           run/replay/regress tooling and evidence renders. Drives FLYK/BLADE/
           Star Trek demos AND our btl4opt/rpl4opt game binaries.
- viewer/  WebGL archive generators (.py); prebuilt HTML/data regeneratable.
- samples/ small test models/textures.
- bt*.raw.bin  real BTL4OPT arena wire captures (kept for offline renderer
           testing/regression against OUR game).

.gitignore keeps the multi-hundred-MB demo capture dumps + debug logs +
regeneratable viewer bundles out of history (they stay on disk).

Phase 0 of the integration is validated: their board decodes our bt8 capture
with zero errors (3748 nodes, 507 instances, 4 mechs) and renders our arena
(terrain/dome/sky, correct Division DAC gamma). Plan + status in memory;
integration continues in emulator/RENDERER-COLLAB.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:06:25 -05:00

321 lines
20 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DPL3 Archive — Virtual World Entertainment, c.1994</title>
<style>
:root{--bg:#0d0b09;--panel:#17130d;--panel2:#201b14;--hair:#3a3126;--amber:#f2a73b;
--ink:#ece2d0;--muted:#9a8d76;--faint:#6f6553;}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{background:var(--bg);color:var(--ink);font:13px/1.5 ui-monospace,Consolas,monospace;display:flex}
#side{width:300px;flex:none;height:100%;display:flex;flex-direction:column;
border-right:1px solid var(--hair);background:var(--panel)}
.head{padding:16px 16px 12px;border-bottom:1px solid var(--hair)}
.eyebrow{color:var(--amber);font-size:10px;letter-spacing:.2em;text-transform:uppercase}
h1{font:650 16px/1.2 system-ui,sans-serif;margin:4px 0 2px}
.sub{color:var(--muted);font-size:11px}
#tabs{display:flex;gap:6px;margin-top:12px}
.tab{flex:1;background:#0d0b09;color:var(--muted);border:1px solid var(--hair);border-radius:6px;
padding:7px 0;font:600 12px system-ui,sans-serif;cursor:pointer}
.tab.on{color:var(--bg);background:var(--amber);border-color:var(--amber)}
#search{width:100%;margin-top:10px;background:#0d0b09;color:var(--ink);border:1px solid var(--hair);
border-radius:6px;padding:8px 10px;font:12px ui-monospace,monospace}
#search:focus{outline:1px solid var(--amber);border-color:var(--amber)}
#count{color:var(--faint);font-size:11px;margin-top:8px}
#list{flex:1;overflow-y:auto;padding:6px}
.grouphdr{font:700 10px system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;
color:var(--amber);padding:14px 10px 5px;display:flex;justify-content:space-between}
.grouphdr span{color:var(--faint);font-weight:400}
.item{padding:8px 10px;border-radius:6px;cursor:pointer;display:flex;justify-content:space-between;
align-items:baseline;gap:8px}
.item:hover{background:var(--panel2)}
.item.on{background:var(--panel2);box-shadow:inset 2px 0 0 var(--amber)}
.item .nm{font-family:system-ui,sans-serif;font-weight:600;color:var(--ink);text-transform:capitalize}
.item .tr{color:var(--faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
.foot{padding:10px 16px;border-top:1px solid var(--hair);color:var(--faint);font-size:11px}
#stage{flex:1;position:relative;min-width:0}
#c{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;touch-action:none}
#c:active{cursor:grabbing}
#hud{position:absolute;left:16px;top:14px;pointer-events:none;text-shadow:0 1px 3px #000}
#hud h2{font:650 18px/1.2 system-ui,sans-serif;text-transform:capitalize}
#hud .meta{color:var(--muted);font-size:12px;margin-top:2px}
#ctrl{position:absolute;right:16px;top:14px;display:flex;gap:10px;color:var(--muted);font-size:12px}
#ctrl label{cursor:pointer;display:flex;gap:6px;align-items:center;background:rgba(23,19,13,.8);
padding:5px 9px;border:1px solid var(--hair);border-radius:6px}
#tip{position:absolute;left:16px;bottom:14px;color:var(--faint);font-size:11px;background:rgba(13,11,9,.6);
padding:5px 9px;border-radius:6px}
#tip b{color:var(--muted);font-weight:600}
#empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--faint);text-align:center;padding:30px}
</style>
</head>
<body>
<aside id="side">
<div class="head">
<div class="eyebrow">DPL3 · Recovered</div>
<h1>Historical Archive</h1>
<div class="sub">Virtual World Entertainment, c.1994</div>
<div id="tabs">
<button class="tab on" data-tab="scenes">Scenes</button>
<button class="tab" data-tab="maps">Maps</button>
<button class="tab" data-tab="models">Models</button>
</div>
<input id="search" placeholder="filter…" autocomplete="off">
<div id="count"></div>
</div>
<div id="list"></div>
<div class="foot">served locally · click to load</div>
</aside>
<main id="stage">
<canvas id="c" tabindex="0"></canvas>
<div id="hud"><h2 id="title"></h2><div class="meta" id="meta"></div></div>
<div id="ctrl">
<label>speed <input type="range" id="speed" min="-1" max="1" step="0.05" value="0" style="width:74px;accent-color:var(--amber)"> <span id="sval" style="color:var(--ink);width:30px;display:inline-block">1.0x</span></label>
<label>gamma <input type="range" id="gamma" min="1" max="1.8" step="0.05" value="1.25" style="width:74px;accent-color:var(--amber)"> <span id="gval" style="color:var(--ink);width:26px;display:inline-block">1.25</span></label>
<label><input type="checkbox" id="spin" checked> auto-rotate</label>
<label><input type="checkbox" id="wire"> wireframe</label>
</div>
<div id="tip"><b>drag</b> look around · <b>scroll</b> zoom/dolly · <b>WASD</b> fly · <b>Q/E</b> down/up · <b>auto-rotate</b> returns to orbit</div>
<div id="empty">select an item from the archive →</div>
</main>
<script>
"use strict";
var M={
mul:function(a,b){var o=new Float32Array(16);for(var r=0;r<4;r++)for(var c=0;c<4;c++){var s=0;
for(var k=0;k<4;k++)s+=a[k*4+r]*b[c*4+k];o[c*4+r]=s;}return o;},
persp:function(f,asp,n,fr){var t=1/Math.tan(f/2),o=new Float32Array(16);
o[0]=t/asp;o[5]=t;o[10]=(fr+n)/(n-fr);o[11]=-1;o[14]=2*fr*n/(n-fr);return o;},
look:function(e,c,u){var z=nrm(sub(e,c)),x=nrm(crs(u,z)),y=crs(z,x);
return new Float32Array([x[0],y[0],z[0],0,x[1],y[1],z[1],0,x[2],y[2],z[2],0,
-dot(x,e),-dot(y,e),-dot(z,e),1]);},
norm3:function(m){return new Float32Array([m[0],m[1],m[2],m[4],m[5],m[6],m[8],m[9],m[10]]);}};
function sub(a,b){return[a[0]-b[0],a[1]-b[1],a[2]-b[2]];}
function crs(a,b){return[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];}
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
function nrm(a){var l=Math.hypot(a[0],a[1],a[2])||1;return[a[0]/l,a[1]/l,a[2]/l];}
var canvas=document.getElementById("c"),gl,prog,loc,cur=null;
// Camera: "orbit" while auto-rotate is on (showcase spin around the model);
// any drag / WASD switches to first-person fly-cam -- camPos is the camera,
// az/el are its yaw/pitch, and the mouse rotates the VIEW around the camera.
var az=0.7,el=0.5,dist=3,target=[0,0,0],camPos=[0,0,0],curKind="scenes";
function fwdVec(){var ce=Math.cos(el);
return [-Math.sin(az)*ce,-Math.sin(el),-Math.cos(az)*ce];}
// Models are inspected turntable-style: controls always orbit the object.
// Scenes are explored: fly-cam once you drag or touch WASD.
function orbiting(){return curKind==="models"||document.getElementById("spin").checked;}
var VS="attribute vec3 aPos;attribute vec3 aNrm;attribute vec2 aUV;"+
"uniform mat4 uMVP;uniform mat4 uView;uniform mat3 uN;uniform vec3 uOffset;uniform vec2 uUVOff;"+
"varying vec3 vN;varying vec2 vUV;varying float vDist;"+
"void main(){vN=uN*aNrm;vUV=aUV+uUVOff;vec3 p=aPos+uOffset;"+
"vDist=length((uView*vec4(p,1.0)).xyz);gl_Position=uMVP*vec4(p,1.0);}";
var FS="precision mediump float;varying vec3 vN;varying vec2 vUV;varying float vDist;"+
"uniform vec3 uColor;uniform sampler2D uTex;uniform float uUseTex;uniform float uInvGamma;"+
"uniform vec3 uFog;uniform vec2 uFogRange;uniform float uFogOn;"+
"void main(){vec3 L=normalize(vec3(0.4,0.7,0.6));float d=abs(dot(normalize(vN),L));"+
"float lit=0.35+0.65*d;vec3 base=(uUseTex>0.5?texture2D(uTex,vUV).rgb*uColor:uColor)*lit;"+
"if(uFogOn>0.5){float f=clamp((vDist-uFogRange.x)/(uFogRange.y-uFogRange.x),0.0,1.0);base=mix(base,uFog,f);}"+
"gl_FragColor=vec4(pow(base,vec3(uInvGamma)),1.0);}"; // Division DAC gamma (slider)
function sh(t,s){var o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);
if(!gl.getShaderParameter(o,gl.COMPILE_STATUS))throw gl.getShaderInfoLog(o);return o;}
function b64bytes(b){var s=atob(b),n=s.length,a=new Uint8Array(n);for(var i=0;i<n;i++)a[i]=s.charCodeAt(i);return a;}
function edges(idx){var o=[];for(var i=0;i<idx.length;i+=3)o.push(idx[i],idx[i+1],idx[i+1],idx[i+2],idx[i+2],idx[i]);return o;}
function initGL(){
gl=canvas.getContext("webgl")||canvas.getContext("experimental-webgl");
if(!gl)throw"WebGL unavailable";
prog=gl.createProgram();gl.attachShader(prog,sh(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,FS));gl.linkProgram(prog);gl.useProgram(prog);
loc={pos:gl.getAttribLocation(prog,"aPos"),nrm:gl.getAttribLocation(prog,"aNrm"),
uv:gl.getAttribLocation(prog,"aUV"),mvp:gl.getUniformLocation(prog,"uMVP"),
view:gl.getUniformLocation(prog,"uView"),n:gl.getUniformLocation(prog,"uN"),
color:gl.getUniformLocation(prog,"uColor"),tex:gl.getUniformLocation(prog,"uTex"),
useTex:gl.getUniformLocation(prog,"uUseTex"),fog:gl.getUniformLocation(prog,"uFog"),
fogRange:gl.getUniformLocation(prog,"uFogRange"),fogOn:gl.getUniformLocation(prog,"uFogOn"),
invGamma:gl.getUniformLocation(prog,"uInvGamma"),offset:gl.getUniformLocation(prog,"uOffset"),
uvoff:gl.getUniformLocation(prog,"uUVOff")};
gl.enable(gl.DEPTH_TEST);
}
function buildModel(md){
var texs=md.textures.map(function(t){
var tx=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,tx);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,t.w,t.h,0,gl.RGBA,gl.UNSIGNED_BYTE,b64bytes(t.rgba));
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);gl.generateMipmap(gl.TEXTURE_2D);return tx;});
var groups=md.groups.map(function(g){
function buf(a,tp){var b=gl.createBuffer();gl.bindBuffer(tp,b);gl.bufferData(tp,a,gl.STATIC_DRAW);return b;}
var el=edges(g.indices);
return {pos:buf(new Float32Array(g.positions),gl.ARRAY_BUFFER),nrm:buf(new Float32Array(g.normals),gl.ARRAY_BUFFER),
uv:buf(new Float32Array(g.uvs),gl.ARRAY_BUFFER),idx:buf(new Uint16Array(g.indices),gl.ELEMENT_ARRAY_BUFFER),
n:g.indices.length,line:buf(new Uint16Array(el),gl.ELEMENT_ARRAY_BUFFER),nline:el.length,
color:g.color,tex:g.texture,anim:g.anim||null};});
var b=md.bounds,ctr=[(b.min[0]+b.max[0])/2,(b.min[1]+b.max[1])/2,(b.min[2]+b.max[2])/2];
var rad=Math.hypot(b.max[0]-b.min[0],b.max[1]-b.min[1],b.max[2]-b.min[2])/2||1;
return {groups:groups,textures:texs,center:ctr,radius:rad,bg:md.background||[0.05,0.045,0.04],fog:md.fog||null};
}
// ---- WASD fly movement -----------------------------------------------------
var keys={};
function moveKeys(){return keys["w"]||keys["a"]||keys["s"]||keys["d"]||keys["q"]||keys["e"];}
function typingTarget(e){var t=e.target;
return t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.isContentEditable);}
window.addEventListener("keydown",function(e){
if(typingTarget(e))return; // don't hijack keys while typing
var k=e.key.toLowerCase();
if("wasdqe".indexOf(k)>=0){keys[k]=true;document.getElementById("spin").checked=false;e.preventDefault();}});
window.addEventListener("keyup",function(e){keys[e.key.toLowerCase()]=false;});
window.addEventListener("blur",function(){keys={};});
function moveSpeed(){ // slider is log-scale: -1..1 -> 0.1x..10x
return Math.pow(10, parseFloat(document.getElementById("speed").value)||0);}
function applyMove(){
if(!cur||curKind==="models"||!moveKeys())return; // models: orbit-only
var spd=cur.radius*0.002*moveSpeed();
// camera-relative: W/S fly along the view direction (including pitch),
// A/D strafe horizontally, Q/E world down/up. Moves the CAMERA itself.
var f=fwdVec();
var r=[Math.cos(az),0,-Math.sin(az)];
if(keys["w"]){camPos[0]+=f[0]*spd;camPos[1]+=f[1]*spd;camPos[2]+=f[2]*spd;}
if(keys["s"]){camPos[0]-=f[0]*spd;camPos[1]-=f[1]*spd;camPos[2]-=f[2]*spd;}
if(keys["d"]){camPos[0]+=r[0]*spd;camPos[2]+=r[2]*spd;}
if(keys["a"]){camPos[0]-=r[0]*spd;camPos[2]-=r[2]*spd;}
if(keys["e"]){camPos[1]+=spd;}
if(keys["q"]){camPos[1]-=spd;}
}
function draw(){
var w=canvas.clientWidth,h=canvas.clientHeight;
if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
if(cur){
applyMove();
var g=parseFloat(document.getElementById("gamma").value)||1.25,ig=1/g,m=cur;
gl.uniform1f(loc.invGamma,ig);
gl.viewport(0,0,w,h);gl.clearColor(Math.pow(m.bg[0],ig),Math.pow(m.bg[1],ig),Math.pow(m.bg[2],ig),1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
var eye,ctr;
if(curKind==="models"||(document.getElementById("spin").checked&&!moveKeys())){
// showcase orbit around the model; keep camPos synced so switching to
// fly-cam (drag / WASD) continues from the same place.
var ce=Math.cos(el);
eye=[target[0]+dist*ce*Math.sin(az),target[1]+dist*Math.sin(el),target[2]+dist*ce*Math.cos(az)];
ctr=target;camPos=eye.slice();
}else{
// first-person fly-cam: the mouse rotates the view around the camera.
var f=fwdVec();
eye=camPos;ctr=[camPos[0]+f[0],camPos[1]+f[1],camPos[2]+f[2]];
}
var dc=Math.hypot(eye[0]-m.center[0],eye[1]-m.center[1],eye[2]-m.center[2])||1;
var proj=M.persp(1.0,w/h,Math.max(m.radius*0.002,dc*0.01),dc*10+m.radius*4),
view=M.look(eye,ctr,[0,1,0]),mvp=M.mul(proj,view);
gl.uniformMatrix4fv(loc.mvp,false,mvp);gl.uniformMatrix4fv(loc.view,false,view);gl.uniformMatrix3fv(loc.n,false,M.norm3(view));
if(m.fog){gl.uniform1f(loc.fogOn,1);gl.uniform3fv(loc.fog,[m.fog[0],m.fog[1],m.fog[2]]);gl.uniform2fv(loc.fogRange,[m.fog[3],m.fog[4]]);}
else gl.uniform1f(loc.fogOn,0);
var wire=document.getElementById("wire").checked;
var now=performance.now()/1000;
for(var k=0;k<m.groups.length;k++){var g=m.groups[k];
gl.bindBuffer(gl.ARRAY_BUFFER,g.pos);gl.enableVertexAttribArray(loc.pos);gl.vertexAttribPointer(loc.pos,3,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,g.nrm);gl.enableVertexAttribArray(loc.nrm);gl.vertexAttribPointer(loc.nrm,3,gl.FLOAT,false,0,0);
gl.bindBuffer(gl.ARRAY_BUFFER,g.uv);gl.enableVertexAttribArray(loc.uv);gl.vertexAttribPointer(loc.uv,2,gl.FLOAT,false,0,0);
var col=g.color,off=[0,0,0],uvo=[0,0];
if(g.anim){
if(g.anim.type==="slide"){
// door leaf: open over `travel` s, dwell `dead` s, close, dwell (ping-pong)
var tr=g.anim.travel||10,dd=g.anim.dead||3,cyc=2*(tr+dd),t=now%cyc,p;
if(t<tr)p=t/tr;else if(t<tr+dd)p=1;
else if(t<2*tr+dd)p=1-(t-tr-dd)/tr;else p=0;
p=p*p*(3-2*p); // smoothstep ease
off=[g.anim.extent[0]*p,g.anim.extent[1]*p,g.anim.extent[2]*p];
}else if(g.anim.type==="pulse"){
var gn=0.7+0.5*Math.sin(now*5); // laser drill glow (approximation)
col=[Math.min(1,g.color[0]*gn+0.25*gn),Math.min(1,g.color[1]*gn),Math.min(1,g.color[2]*gn)];
}else if(g.anim.type==="scroll"){
// texture drift (rivers, smoke, beams): SCROLL u0 v0 du dv, UV/second
var ph=g.anim.phase||[0,0],rt=g.anim.rate;
uvo=[(ph[0]+now*rt[0])%1,(ph[1]+now*rt[1])%1];
}
}
gl.uniform3fv(loc.offset,off);
gl.uniform2fv(loc.uvoff,uvo);
gl.uniform3fv(loc.color,col);
if(g.tex>=0&&!wire){gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,m.textures[g.tex]);gl.uniform1i(loc.tex,0);gl.uniform1f(loc.useTex,1);}
else gl.uniform1f(loc.useTex,0);
if(wire){gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.line);gl.drawElements(gl.LINES,g.nline,gl.UNSIGNED_SHORT,0);}
else{gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,g.idx);gl.drawElements(gl.TRIANGLES,g.n,gl.UNSIGNED_SHORT,0);}}
if(document.getElementById("spin").checked&&!moveKeys())az+=0.004;
}
requestAnimationFrame(draw);
}
// ---- archive UI ------------------------------------------------------------
var MANIFEST={scenes:[],models:[],maps:[]},TAB="scenes";
function pathFor(id,tab){
if(tab==="models")return "data/models/"+id+".json";
if(tab==="maps")return "data/maps/"+id+".json";
return "data/"+id+".json";}
function loadItem(item,el,tab){
curKind=tab;
document.getElementById("tip").innerHTML=(tab==="models")
? "<b>drag</b> orbit · <b>scroll</b> zoom · centred on the model"
: "<b>drag</b> look around · <b>scroll</b> zoom/dolly · <b>WASD</b> fly · <b>Q/E</b> down/up · <b>auto-rotate</b> returns to orbit";
document.getElementById("empty").style.display="none";
document.querySelectorAll(".item").forEach(function(n){n.classList.remove("on");});
if(el)el.classList.add("on");
canvas.focus();
document.getElementById("title").textContent=item.id;
document.getElementById("meta").textContent="loading…";
fetch(pathFor(item.id,tab)).then(function(r){return r.json();}).then(function(md){
cur=buildModel(md);target=cur.center.slice();dist=cur.radius*2.2;az=0.7;el=0.45;
document.getElementById("spin").checked=true;
document.getElementById("meta").textContent=
md.tris.toLocaleString()+" triangles · "+md.groups.length+" surfaces · "+md.textures.length+" textures";
}).catch(function(e){document.getElementById("meta").textContent="failed: "+e;});
}
var PRODUCT_ORDER=["Red Planet","BattleTech","Hull Pressure","Star Trek","Milestone","Engine / Demo"];
function renderList(){
var list=document.getElementById("list");list.innerHTML="";
var arr=MANIFEST[TAB],f=document.getElementById("search").value.toLowerCase();
var groups={},shown=0;
arr.forEach(function(s){
if(f&&s.id.indexOf(f)<0)return;shown++;
var p=s.product||"Engine / Demo";(groups[p]=groups[p]||[]).push(s);});
var order=PRODUCT_ORDER.slice();
Object.keys(groups).forEach(function(p){if(order.indexOf(p)<0)order.push(p);});
order.forEach(function(p){
var items=groups[p];if(!items||!items.length)return;
var h=document.createElement("div");h.className="grouphdr";
h.innerHTML=p+' <span>'+items.length+'</span>';list.appendChild(h);
items.forEach(function(s){
var d=document.createElement("div");d.className="item";
d.innerHTML='<span class="nm">'+s.id+'</span><span class="tr">'+s.tris.toLocaleString()+' △</span>';
d.onclick=function(){loadItem(s,d,TAB);};list.appendChild(d);});});
document.getElementById("count").textContent=shown+" of "+arr.length+" "+TAB;
}
document.getElementById("gamma").addEventListener("input",function(){
document.getElementById("gval").textContent=parseFloat(this.value).toFixed(2);});
document.getElementById("speed").addEventListener("input",function(){
var s=Math.pow(10,parseFloat(this.value)||0);
document.getElementById("sval").textContent=(s>=1?s.toFixed(1):s.toFixed(2))+"x";});
document.querySelectorAll(".tab").forEach(function(b){
b.onclick=function(){TAB=b.dataset.tab;
document.querySelectorAll(".tab").forEach(function(x){x.classList.toggle("on",x===b);});
renderList();};});
document.getElementById("search").addEventListener("input",renderList);
// orbit input
var drag=false,px=0,py=0;
canvas.addEventListener("pointerdown",function(e){drag=true;px=e.clientX;py=e.clientY;canvas.focus();
document.getElementById("spin").checked=false;canvas.setPointerCapture(e.pointerId);});
canvas.addEventListener("pointermove",function(e){if(!drag)return;az-=(e.clientX-px)*0.01;
el+=(e.clientY-py)*0.01;el=Math.max(-1.5,Math.min(1.5,el));px=e.clientX;py=e.clientY;});
canvas.addEventListener("pointerup",function(){drag=false;});
canvas.addEventListener("wheel",function(e){e.preventDefault();
if(orbiting()){dist*=Math.pow(1.0015,e.deltaY);}
else{var f=fwdVec(),step=-(cur?cur.radius:1)*0.00012*moveSpeed()*e.deltaY; // dolly
camPos[0]+=f[0]*step;camPos[1]+=f[1]*step;camPos[2]+=f[2]*step;}},{passive:false});
try{
initGL();draw();
fetch("data/manifest.json").then(function(r){return r.json();}).then(function(m){
MANIFEST.scenes=m.scenes||[];MANIFEST.models=m.models||[];MANIFEST.maps=m.maps||[];renderList();
}).catch(function(e){document.getElementById("empty").textContent="Run python serve.py to load the archive ("+e+")";});
}catch(e){document.getElementById("empty").textContent=String(e);}
</script>
</body>
</html>