feat: WebGL 3D model viewer with smooth vertex normals, water caustics, soft cyan-slate material, floaty controls, and camera position mapping
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
||||
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
||||
|
||||
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
|
||||
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
||||
function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))}
|
||||
joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()});
|
||||
function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)}
|
||||
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
||||
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
||||
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()});
|
||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
|
||||
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
||||
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
|
||||
|
||||
function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
|
||||
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
|
||||
const modelInput=$('#model-input'),modelButton=$('#load-model');
|
||||
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
|
||||
function bytesToBase64(bytes){let value='';for(let i=0;i<bytes.length;i+=0x8000)value+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(value)}
|
||||
async function cacheModel(file){const token=await api('begin_model_save',file.name,file.size),chunk=196608;for(let offset=0;offset<file.size;offset+=chunk){const bytes=new Uint8Array(await file.slice(offset,offset+chunk).arrayBuffer());await api('append_model_chunk',token,bytesToBase64(bytes));if(offset% (chunk*4)===0)await new Promise(requestAnimationFrame)}await api('finish_model_save',token)}
|
||||
async function loadCachedModel(info){const bytes=new Uint8Array(info.size),chunk=196608;for(let offset=0;offset<info.size;offset+=chunk){const response=await api('read_saved_model_chunk',offset,chunk),part=atob(response.data);for(let i=0;i<part.length;i++)bytes[offset+i]=part.charCodeAt(i);if(offset%(chunk*4)===0)await new Promise(requestAnimationFrame)}return window.rovModel.loadBuffer(bytes.buffer,info.name)}
|
||||
modelInput.onchange=async()=>{const file=modelInput.files?.[0],old=modelButton.textContent;if(!file)return;try{if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh');if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');modelButton.disabled=true;modelButton.textContent='Loading…';await new Promise(requestAnimationFrame);const buffer=await file.arrayBuffer(),result=window.rovModel.loadBuffer(buffer,file.name);modelButton.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);if(bridgeReady)cacheModel(file).then(()=>toast('STL saved for the next launch')).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}};
|
||||
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
||||
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
|
||||
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
||||
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
||||
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||
let booted=false;
|
||||
async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=await api('get_saved_model_info');if(saved){try{$('#load-model').textContent='Loading saved STL…';await loadCachedModel(saved);$('#load-model').textContent='Replace STL'}catch(error){$('#load-model').textContent='Load STL';toast(`Saved STL failed: ${error.message}`,true)}}poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}}
|
||||
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
|
||||
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SUB Control</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="window-bar">
|
||||
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize">—</button>
|
||||
<button id="window-max" aria-label="Maximize">□</button>
|
||||
<button id="window-close" class="window-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-shell">
|
||||
<aside class="vehicle-rail">
|
||||
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
|
||||
<div class="eyebrow">LIVE VEHICLE</div>
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||
<svg id="default-model" viewBox="0 0 260 310" role="img">
|
||||
<defs>
|
||||
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
|
||||
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
|
||||
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
|
||||
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
|
||||
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
|
||||
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
|
||||
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
|
||||
</g>
|
||||
<g stroke="#59656a" stroke-width="2">
|
||||
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div class="model-tools">
|
||||
<button id="load-model">Load STL</button>
|
||||
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||
<div class="rail-bottom">
|
||||
<button class="battery-card ghost" data-open="battery-modal">
|
||||
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i>
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</button>
|
||||
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
|
||||
<div class="connection">
|
||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
|
||||
<button id="connect" class="button connect-button">Connect</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="drive-grid">
|
||||
<article class="control-card steer-card">
|
||||
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
|
||||
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
|
||||
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
|
||||
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
|
||||
</div>
|
||||
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer>
|
||||
</article>
|
||||
|
||||
<article class="control-card vertical-card">
|
||||
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
|
||||
<div class="vertical-control">
|
||||
<span>RISE</span>
|
||||
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
||||
<span>DIVE</span>
|
||||
</div>
|
||||
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="command-bar">
|
||||
<div class="commands">
|
||||
<button id="manual" class="button subtle">Manual</button>
|
||||
<button id="arm" class="button arm">Arm</button>
|
||||
<button id="disarm" class="button danger">Disarm</button>
|
||||
</div>
|
||||
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
|
||||
<button class="button subtle" data-open="motors-modal">Motor setup</button>
|
||||
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
|
||||
</section>
|
||||
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
|
||||
<script src="model-viewer.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,518 @@
|
||||
(() => {
|
||||
const canvas = document.querySelector('#model-canvas');
|
||||
const stage = document.querySelector('.rov-stage');
|
||||
const renderScale = Math.max(2.0, window.devicePixelRatio || 2.0);
|
||||
const gl = canvas.getContext('webgl', { alpha: true, antialias: true, preserveDrawingBuffer: false });
|
||||
if (!gl) { window.rovModel = { loadBuffer() { throw new Error('WebGL is unavailable in this WebView'); } }; return; }
|
||||
|
||||
// Pass 1: Geometry shader - renders view-space normals and depth to FBO (Hero-sized 0.88 dynamic projection)
|
||||
const vertexSceneSource = `
|
||||
attribute vec3 aPosition;
|
||||
attribute vec3 aNormal;
|
||||
uniform float uYaw, uPitch, uZoom, uAspect;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPos;
|
||||
void main() {
|
||||
float cy = cos(uYaw), sy = sin(uYaw);
|
||||
vec3 p = vec3(aPosition.x * cy + aPosition.z * sy, aPosition.y, -aPosition.x * sy + aPosition.z * cy);
|
||||
vec3 n = vec3(aNormal.x * cy + aNormal.z * sy, aNormal.y, -aNormal.x * sy + aNormal.z * cy);
|
||||
float cp = cos(uPitch), sp = sin(uPitch);
|
||||
p = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);
|
||||
n = vec3(n.x, n.y * cp - n.z * sp, n.y * sp + n.z * cp);
|
||||
|
||||
// Base scale (1.15 fills 95-100% of canvas frame without over-zooming out)
|
||||
float baseScale = 1.15 * uZoom;
|
||||
|
||||
// Dynamic pixel-perfect aspect projection: fills window canvas without distortion or shrinking
|
||||
vec2 clipPos;
|
||||
if (uAspect >= 1.0) {
|
||||
clipPos = vec2(p.x * baseScale / uAspect, p.y * baseScale);
|
||||
} else {
|
||||
clipPos = vec2(p.x * baseScale, p.y * baseScale * uAspect);
|
||||
}
|
||||
|
||||
gl_Position = vec4(clipPos.x, clipPos.y, p.z * baseScale * .4, 1.);
|
||||
vViewPos = vec3(clipPos.x, clipPos.y, p.z * baseScale);
|
||||
vNormal = normalize(n);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentSceneSource = `
|
||||
precision mediump float;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPos;
|
||||
void main() {
|
||||
vec3 encNormal = normalize(vNormal) * .5 + .5;
|
||||
float encDepth = clamp(vViewPos.z * .4 + .5, .01, .99);
|
||||
gl_FragColor = vec4(encNormal, encDepth);
|
||||
}
|
||||
`;
|
||||
|
||||
// Pass 2: Quad shader - Vibrant Soft Cyan-Slate Material + Water Caustics + Argo Glare (Non-grey)
|
||||
const vertexQuadSource = `
|
||||
attribute vec2 aQuadPos;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
vTexCoord = aQuadPos * .5 + .5;
|
||||
gl_Position = vec4(aQuadPos, 0., 1.);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentQuadSource = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uTexSize;
|
||||
uniform float uTime;
|
||||
varying vec2 vTexCoord;
|
||||
|
||||
// Animated water caustics shader
|
||||
float waterCaustics(vec2 uv, float t) {
|
||||
vec2 p = uv * 8.0;
|
||||
vec2 i = vec2(p);
|
||||
float c = 1.0;
|
||||
float intent = 0.14;
|
||||
for (int n = 0; n < 4; n++) {
|
||||
float t_ang = t * (0.8 - (2.2 / float(n + 1)));
|
||||
vec2 coord = i + vec2(cos(t_ang - i.x) + sin(t_ang + i.y), sin(t_ang - i.y) + cos(t_ang + i.x));
|
||||
c += 1.0 / length(vec2(i.x / (sin(coord.x + t_ang) / intent), i.y / (cos(coord.y + t_ang) / intent)));
|
||||
i = coord;
|
||||
}
|
||||
c /= 4.0;
|
||||
c = 1.15 - sqrt(c);
|
||||
return clamp(pow(max(0.0, c), 2.2) * 1.4, 0.0, 1.4);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 texel = 1. / uTexSize;
|
||||
|
||||
// Subpixel 4-sample Anti-Aliasing for smooth geometry boundaries
|
||||
vec4 s0 = texture2D(uTex, vTexCoord + vec2(-0.25, -0.25) * texel);
|
||||
vec4 s1 = texture2D(uTex, vTexCoord + vec2( 0.25, -0.25) * texel);
|
||||
vec4 s2 = texture2D(uTex, vTexCoord + vec2(-0.25, 0.25) * texel);
|
||||
vec4 s3 = texture2D(uTex, vTexCoord + vec2( 0.25, 0.25) * texel);
|
||||
vec4 center = (s0 + s1 + s2 + s3) * 0.25;
|
||||
|
||||
float alphaCoverage = clamp((s0.a > .001 ? .25 : 0.) + (s1.a > .001 ? .25 : 0.) + (s2.a > .001 ? .25 : 0.) + (s3.a > .001 ? .25 : 0.), 0., 1.);
|
||||
if (alphaCoverage <= .001) {
|
||||
gl_FragColor = vec4(0., 0., 0., 0.);
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 N = normalize(center.rgb * 2. - 1.);
|
||||
float centerDepth = center.a;
|
||||
|
||||
// 1. Light Ambient Occlusion
|
||||
float occlusion = 0.;
|
||||
vec2 offsets[8];
|
||||
offsets[0] = vec2(1.5, 0.); offsets[1] = vec2(-1.5, 0.);
|
||||
offsets[2] = vec2(0., 1.5); offsets[3] = vec2(0., -1.5);
|
||||
offsets[4] = vec2(1.1, 1.1); offsets[5] = vec2(-1.1, 1.1);
|
||||
offsets[6] = vec2(1.1, -1.1); offsets[7] = vec2(-1.1, -1.1);
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
vec4 sampleVal = texture2D(uTex, vTexCoord + offsets[i] * texel);
|
||||
if (sampleVal.a > .001) {
|
||||
float dDiff = sampleVal.a - centerDepth;
|
||||
if (dDiff < -.0005 && dDiff > -.12) {
|
||||
occlusion += clamp(-dDiff * 14.0, 0., 1.);
|
||||
}
|
||||
}
|
||||
}
|
||||
float ao = 1. - clamp(occlusion / 8.0 * 0.36, 0., .20);
|
||||
|
||||
// 2. Soft Luminous Cyan-Slate Material Shading (Vibrant, non-grey)
|
||||
vec3 lightDir = normalize(vec3(.35, .85, .45));
|
||||
float diff = max(dot(N, lightDir), 0.0);
|
||||
vec3 viewDir = vec3(0., 0., 1.);
|
||||
vec3 halfDir = normalize(lightDir + viewDir);
|
||||
float spec = pow(max(dot(N, halfDir), 0.0), 28.0);
|
||||
|
||||
// Argo crisp luminous glare
|
||||
vec3 argoGlare = vec3(0.98, 0.96, 0.92) * spec * 0.95;
|
||||
|
||||
// Soft rim light
|
||||
float fresnel = pow(1. - max(dot(N, viewDir), 0.), 2.0);
|
||||
vec3 rimLight = vec3(0.65, 0.82, 0.95) * fresnel * 0.35;
|
||||
|
||||
// Soft vibrant cyan-slate gradient (non-grey, soft luminous depth)
|
||||
vec3 baseShadow = vec3(.22, .44, .58);
|
||||
vec3 baseBody = vec3(.38, .62, .78);
|
||||
vec3 baseTop = vec3(.88, .92, .96);
|
||||
|
||||
float lFactor = clamp(diff * 0.7 + (N.y * 0.3 + 0.3), 0.0, 1.0);
|
||||
vec3 bodyColor = mix(baseShadow, baseBody, smoothstep(0.0, 0.5, lFactor));
|
||||
bodyColor = mix(bodyColor, baseTop, smoothstep(0.5, 1.0, lFactor));
|
||||
|
||||
// 3. Luminous Water Caustics on top/front facing surfaces
|
||||
float topFacing = clamp(N.y * 0.7 + 0.45, 0.0, 1.0);
|
||||
float causticsVal = waterCaustics(vTexCoord + vec2(sin(uTime * 0.2) * 0.04, uTime * 0.025), uTime * 0.75) * topFacing;
|
||||
vec3 causticColor = vec3(0.30, 0.85, 1.0) * causticsVal * 1.1;
|
||||
|
||||
// Combine soft diffuse, vibrant cyan-slate body, rim light, glare, caustics, and AO
|
||||
vec3 rawColor = (bodyColor + rimLight + argoGlare + causticColor) * ao;
|
||||
|
||||
// Soft filmic highlight compression (preserves vibrant colors without white crushing)
|
||||
vec3 finalColor = rawColor / (rawColor + vec3(0.18)) * 1.08;
|
||||
|
||||
gl_FragColor = vec4(finalColor, alphaCoverage);
|
||||
}
|
||||
`;
|
||||
|
||||
function compileShader(type, source) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, source);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
}
|
||||
|
||||
function createProgram(vSrc, fSrc) {
|
||||
const p = gl.createProgram();
|
||||
gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vSrc));
|
||||
gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fSrc));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
||||
return p;
|
||||
}
|
||||
|
||||
const programScene = createProgram(vertexSceneSource, fragmentSceneSource);
|
||||
const programQuad = createProgram(vertexQuadSource, fragmentQuadSource);
|
||||
|
||||
const locScene = {
|
||||
aPosition: gl.getAttribLocation(programScene, 'aPosition'),
|
||||
aNormal: gl.getAttribLocation(programScene, 'aNormal'),
|
||||
uYaw: gl.getUniformLocation(programScene, 'uYaw'),
|
||||
uPitch: gl.getUniformLocation(programScene, 'uPitch'),
|
||||
uZoom: gl.getUniformLocation(programScene, 'uZoom'),
|
||||
uAspect: gl.getUniformLocation(programScene, 'uAspect')
|
||||
};
|
||||
|
||||
const locQuad = {
|
||||
aQuadPos: gl.getAttribLocation(programQuad, 'aQuadPos'),
|
||||
uTex: gl.getUniformLocation(programQuad, 'uTex'),
|
||||
uTexSize: gl.getUniformLocation(programQuad, 'uTexSize'),
|
||||
uTime: gl.getUniformLocation(programQuad, 'uTime')
|
||||
};
|
||||
|
||||
const bufferScene = gl.createBuffer();
|
||||
const bufferQuad = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
||||
|
||||
let fbo = null, fboTexture = null, fboDepth = null, fboWidth = 0, fboHeight = 0;
|
||||
function updateFbo(w, h) {
|
||||
if (fboWidth === w && fboHeight === h) return;
|
||||
fboWidth = w; fboHeight = h;
|
||||
if (fbo) { gl.deleteFramebuffer(fbo); gl.deleteTexture(fboTexture); gl.deleteRenderbuffer(fboDepth); }
|
||||
fboTexture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
fboDepth = gl.createRenderbuffer();
|
||||
gl.bindRenderbuffer(gl.RENDERBUFFER, fboDepth);
|
||||
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h);
|
||||
|
||||
fbo = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTexture, 0);
|
||||
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, fboDepth);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
}
|
||||
|
||||
let vertexCount = 0, frame = 0;
|
||||
let yaw = 0, pitch = 0, zoom = 1;
|
||||
let targetYaw = 0.68, targetPitch = 0.42, targetZoom = 1.0;
|
||||
let yawVelocity = 0, pitchVelocity = 0;
|
||||
let dragging = false, lastX = 0, lastY = 0;
|
||||
let introAnimating = false, introStartTime = 0, startYaw = 0, startPitch = 0, startZoom = 1;
|
||||
|
||||
// Live joystick & vertical knob control inputs
|
||||
let liveTurn = 0, liveForward = 0, liveVertical = 0;
|
||||
let targetLiveTurn = 0, targetLiveForward = 0, targetLiveVertical = 0;
|
||||
|
||||
const timeStart = performance.now();
|
||||
|
||||
function normalize(data) {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
for (let i = 0; i < data.length; i += 6) {
|
||||
const x = data[i], y = data[i + 1], z = data[i + 2];
|
||||
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
||||
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
|
||||
const scale = 2 / Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9);
|
||||
for (let i = 0; i < data.length; i += 6) {
|
||||
data[i] = (data[i] - cx) * scale;
|
||||
data[i + 1] = (data[i + 1] - cy) * scale;
|
||||
data[i + 2] = (data[i + 2] - cz) * scale;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Smooth vertex normal computation (welds shared vertices to eliminate sharp polygon facet lines)
|
||||
function parseStl(binary) {
|
||||
const view = new DataView(binary), count = binary.byteLength >= 84 ? view.getUint32(80, true) : 0;
|
||||
let rawTriangles = [];
|
||||
|
||||
if (count > 0 && 84 + count * 50 <= binary.byteLength) {
|
||||
for (let t = 0; t < count; t++) {
|
||||
const base = 84 + t * 50;
|
||||
const v0x = view.getFloat32(base + 12, true), v0y = view.getFloat32(base + 16, true), v0z = view.getFloat32(base + 20, true);
|
||||
const v1x = view.getFloat32(base + 24, true), v1y = view.getFloat32(base + 28, true), v1z = view.getFloat32(base + 32, true);
|
||||
const v2x = view.getFloat32(base + 36, true), v2y = view.getFloat32(base + 40, true), v2z = view.getFloat32(base + 44, true);
|
||||
rawTriangles.push(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z);
|
||||
}
|
||||
} else {
|
||||
const text = new TextDecoder().decode(binary), matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
|
||||
const triCount = Math.floor(matches.length / 3);
|
||||
if (triCount < 1) throw new Error('This file does not contain a readable STL mesh');
|
||||
for (let i = 0; i + 2 < matches.length; i += 3) {
|
||||
rawTriangles.push(
|
||||
+matches[i][1], +matches[i][2], +matches[i][3],
|
||||
+matches[i+1][1], +matches[i+1][2], +matches[i+1][3],
|
||||
+matches[i+2][1], +matches[i+2][2], +matches[i+2][3]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const numTriangles = Math.floor(rawTriangles.length / 9);
|
||||
const posMap = new Map();
|
||||
const getVertexIndex = (x, y, z) => {
|
||||
const key = `${Math.round(x * 10000)},${Math.round(y * 10000)},${Math.round(z * 10000)}`;
|
||||
let idx = posMap.get(key);
|
||||
if (idx === undefined) {
|
||||
idx = posMap.size;
|
||||
posMap.set(key, idx);
|
||||
}
|
||||
return idx;
|
||||
};
|
||||
|
||||
// First pass: Index vertices and accumulate smooth face normals
|
||||
const triangleIndices = new Int32Array(numTriangles * 3);
|
||||
for (let t = 0; t < numTriangles; t++) {
|
||||
const b = t * 9;
|
||||
triangleIndices[t * 3] = getVertexIndex(rawTriangles[b], rawTriangles[b+1], rawTriangles[b+2]);
|
||||
triangleIndices[t * 3 + 1] = getVertexIndex(rawTriangles[b+3], rawTriangles[b+4], rawTriangles[b+5]);
|
||||
triangleIndices[t * 3 + 2] = getVertexIndex(rawTriangles[b+6], rawTriangles[b+7], rawTriangles[b+8]);
|
||||
}
|
||||
|
||||
const numUnique = posMap.size;
|
||||
const accumNormals = new Float32Array(numUnique * 3);
|
||||
for (let t = 0; t < numTriangles; t++) {
|
||||
const b = t * 9;
|
||||
const v0x = rawTriangles[b], v0y = rawTriangles[b+1], v0z = rawTriangles[b+2];
|
||||
const v1x = rawTriangles[b+3], v1y = rawTriangles[b+4], v1z = rawTriangles[b+5];
|
||||
const v2x = rawTriangles[b+6], v2y = rawTriangles[b+7], v2z = rawTriangles[b+8];
|
||||
|
||||
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
|
||||
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
|
||||
const nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx;
|
||||
|
||||
const i0 = triangleIndices[t * 3], i1 = triangleIndices[t * 3 + 1], i2 = triangleIndices[t * 3 + 2];
|
||||
accumNormals[i0 * 3] += nx; accumNormals[i0 * 3 + 1] += ny; accumNormals[i0 * 3 + 2] += nz;
|
||||
accumNormals[i1 * 3] += nx; accumNormals[i1 * 3 + 1] += ny; accumNormals[i1 * 3 + 2] += nz;
|
||||
accumNormals[i2 * 3] += nx; accumNormals[i2 * 3 + 1] += ny; accumNormals[i2 * 3 + 2] += nz;
|
||||
}
|
||||
|
||||
// Second pass: Normalize smooth vertex normals
|
||||
for (let i = 0; i < numUnique; i++) {
|
||||
const b = i * 3;
|
||||
const len = Math.hypot(accumNormals[b], accumNormals[b+1], accumNormals[b+2]) || 1;
|
||||
accumNormals[b] /= len; accumNormals[b+1] /= len; accumNormals[b+2] /= len;
|
||||
}
|
||||
|
||||
// Third pass: Assemble final vertex array (6 floats per vertex)
|
||||
const data = new Float32Array(numTriangles * 18);
|
||||
let out = 0;
|
||||
for (let t = 0; t < numTriangles; t++) {
|
||||
const b = t * 9;
|
||||
for (let v = 0; v < 3; v++) {
|
||||
const vx = rawTriangles[b + v * 3], vy = rawTriangles[b + v * 3 + 1], vz = rawTriangles[b + v * 3 + 2];
|
||||
const idx = triangleIndices[t * 3 + v];
|
||||
data[out++] = vx; data[out++] = vy; data[out++] = vz;
|
||||
data[out++] = accumNormals[idx * 3];
|
||||
data[out++] = accumNormals[idx * 3 + 1];
|
||||
data[out++] = accumNormals[idx * 3 + 2];
|
||||
}
|
||||
}
|
||||
|
||||
return normalize(data);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame = 0;
|
||||
if (!vertexCount) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(rect.width * renderScale));
|
||||
const height = Math.max(1, Math.round(rect.height * renderScale));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
updateFbo(width, height);
|
||||
|
||||
const now = performance.now();
|
||||
const currentTime = (now - timeStart) * 0.001;
|
||||
|
||||
// Interpolate live control inputs (turn, forward, vertical)
|
||||
liveTurn += (targetLiveTurn - liveTurn) * 0.15;
|
||||
liveForward += (targetLiveForward - liveForward) * 0.15;
|
||||
liveVertical += (targetLiveVertical - liveVertical) * 0.15;
|
||||
|
||||
// Handle auto-center 3/4 hero camera transition & floaty physics
|
||||
if (introAnimating && !dragging) {
|
||||
const elapsed = (now - introStartTime) / 1200;
|
||||
if (elapsed >= 1.0) {
|
||||
introAnimating = false;
|
||||
yaw = targetYaw; pitch = targetPitch; zoom = targetZoom;
|
||||
} else {
|
||||
const t = 1 - Math.pow(1 - elapsed, 3);
|
||||
yaw = startYaw + (targetYaw - startYaw) * t;
|
||||
pitch = startPitch + (targetPitch - startPitch) * t;
|
||||
zoom = startZoom + (targetZoom - startZoom) * t;
|
||||
}
|
||||
} else {
|
||||
yaw += yawVelocity;
|
||||
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
|
||||
zoom += (targetZoom - zoom) * 0.1;
|
||||
yawVelocity *= 0.92;
|
||||
pitchVelocity *= 0.92;
|
||||
}
|
||||
|
||||
// Effective yaw & pitch including live joystick/heave control offsets
|
||||
const effectiveYaw = yaw + liveTurn * 0.7;
|
||||
const effectivePitch = Math.max(-1.45, Math.min(1.45, pitch + (liveVertical * 0.65 + liveForward * 0.4)));
|
||||
|
||||
// Pass 1: Render Normals & Linear Depth to FBO
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
|
||||
gl.useProgram(programScene);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
|
||||
gl.enableVertexAttribArray(locScene.aPosition);
|
||||
gl.vertexAttribPointer(locScene.aPosition, 3, gl.FLOAT, false, 24, 0);
|
||||
gl.enableVertexAttribArray(locScene.aNormal);
|
||||
gl.vertexAttribPointer(locScene.aNormal, 3, gl.FLOAT, false, 24, 12);
|
||||
|
||||
gl.uniform1f(locScene.uYaw, effectiveYaw);
|
||||
gl.uniform1f(locScene.uPitch, effectivePitch);
|
||||
gl.uniform1f(locScene.uZoom, zoom);
|
||||
gl.uniform1f(locScene.uAspect, width / height);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
|
||||
|
||||
// Pass 2: Soft "Eve"-style Material Pass to Canvas
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
|
||||
gl.useProgram(programQuad);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
|
||||
gl.enableVertexAttribArray(locQuad.aQuadPos);
|
||||
gl.vertexAttribPointer(locQuad.aQuadPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
|
||||
gl.uniform1i(locQuad.uTex, 0);
|
||||
gl.uniform2f(locQuad.uTexSize, width, height);
|
||||
gl.uniform1f(locQuad.uTime, currentTime);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
// Keep animation active if floaty velocity or control interpolation is active
|
||||
if (Math.abs(yawVelocity) > 1e-4 || Math.abs(pitchVelocity) > 1e-4 ||
|
||||
Math.abs(targetLiveTurn - liveTurn) > 1e-3 || Math.abs(targetLiveVertical - liveVertical) > 1e-3 ||
|
||||
introAnimating) {
|
||||
requestDraw();
|
||||
}
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (!frame) frame = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
dragging = true;
|
||||
introAnimating = false;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
yawVelocity = 0;
|
||||
pitchVelocity = 0;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (!dragging) return;
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
yawVelocity = dx * 0.0012;
|
||||
pitchVelocity = dy * 0.0012;
|
||||
yaw += yawVelocity;
|
||||
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', () => dragging = false);
|
||||
canvas.addEventListener('pointercancel', () => dragging = false);
|
||||
|
||||
canvas.addEventListener('wheel', event => {
|
||||
event.preventDefault();
|
||||
introAnimating = false;
|
||||
targetZoom = Math.max(.55, Math.min(2.2, targetZoom * (event.deltaY > 0 ? .9 : 1.1)));
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
addEventListener('resize', requestDraw);
|
||||
|
||||
window.rovModel = {
|
||||
setLiveControl(turn, forward, vertical) {
|
||||
targetLiveTurn = turn || 0;
|
||||
targetLiveForward = forward || 0;
|
||||
targetLiveVertical = vertical || 0;
|
||||
requestDraw();
|
||||
},
|
||||
loadBuffer(data, name) {
|
||||
const vertices = parseStl(data);
|
||||
vertexCount = vertices.length / 6;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||
stage.classList.add('model-loaded');
|
||||
document.querySelector('#model-file-name').textContent = name;
|
||||
|
||||
// Auto-center camera transition to 3/4 corner hero shot upon load
|
||||
yaw = 0;
|
||||
pitch = 0;
|
||||
zoom = 0.75;
|
||||
targetYaw = 0.68;
|
||||
targetPitch = 0.42;
|
||||
targetZoom = 1.0;
|
||||
yawVelocity = 0;
|
||||
pitchVelocity = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
introAnimating = true;
|
||||
introStartTime = performance.now();
|
||||
startYaw = yaw;
|
||||
startPitch = pitch;
|
||||
startZoom = zoom;
|
||||
requestDraw();
|
||||
}, 150);
|
||||
|
||||
requestDraw();
|
||||
return { name, triangles: vertexCount / 3 };
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,12 @@
|
||||
:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
||||
.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff}
|
||||
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)}
|
||||
.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718}
|
||||
.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb}
|
||||
.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544}
|
||||
.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top}
|
||||
.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f}
|
||||
@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}}
|
||||
.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px}
|
||||
.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block;filter:drop-shadow(0 0 1px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.3))}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:.72;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default}
|
||||
Reference in New Issue
Block a user