Update UI layout, window controls, and vehicle styling

This commit is contained in:
2026-08-26 06:57:13 +02:00
parent 4fb912bc43
commit e14462f41c
11 changed files with 2424 additions and 197 deletions
+269 -22
View File
@@ -1,42 +1,289 @@
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);
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
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()}
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)}
window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
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()}
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()});
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);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 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){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();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()};
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){const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=()=>reject(reader.error);reader.readAsDataURL(file)});api('save_model',file.name,String(dataUrl).split(',')[1]).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 openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
if(!el)return;
el.classList.remove('closing');
if(!el.open)el.showModal();
}
function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
dialog.classList.add('closing');
let done=false;
const finish=()=>{
if(done)return;
done=true;
dialog.removeEventListener('animationend',finish);
dialog.classList.remove('closing');
dialog.close();
};
dialog.addEventListener('animationend',finish,{once:true});
setTimeout(finish,300);
}
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{
const r=d.getBoundingClientRect();
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
});
});
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
async function handleStlFile(file){
if(!file)return;
try{
if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh (*.stl)');
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…';
const buffer=await file.arrayBuffer();
const result=window.rovModel.loadBuffer(buffer,file.name);
modelButton.textContent='Replace STL';
toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);
if(bridgeReady&&window.pywebview?.api?.save_model){
const dataUrl=await new Promise((resolve,reject)=>{
const reader=new FileReader();
reader.onload=()=>resolve(reader.result);
reader.onerror=()=>reject(reader.error);
reader.readAsDataURL(file);
});
api('save_model',file.name,String(dataUrl).split(',')[1]).catch(e=>console.warn(e));
}
}catch(error){
toast(`STL import failed: ${error.message}`,true);
}finally{
modelButton.disabled=false;
if(modelButton.textContent==='Loading…')modelButton.textContent='Load STL';
}
}
modelButton.onclick=async()=>{
let loaded=false;
if(window.pywebview?.api?.choose_model){
try{
const model=await api('choose_model');
if(model&&model.data){
const res=window.rovModel.load(model);
modelButton.textContent='Replace STL';
toast(`Loaded ${res.name} • ${res.triangles.toLocaleString()} triangles`);
loaded=true;
}
}catch(err){
console.warn('choose_model error:', err);
}
}
if(!loaded&&modelInput){
modelInput.value='';
modelInput.click();
}
};
if(modelInput){
modelInput.onchange=()=>{
const file=modelInput.files?.[0];
if(file)handleStlFile(file);
};
}
if(rovStage){
rovStage.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.add('drag-over')});
rovStage.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over')});
rovStage.addEventListener('drop',e=>{
e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over');
const file=e.dataTransfer?.files?.[0];
if(file)handleStlFile(file);
});
}
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)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=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}};
const armButton=$('#arm');
let armHoldTimer=null, armStart=0, armCharged=false;
const ARM_HOLD_MS=1000;
function updateArmProgress(){
if(!armStart)return;
const elapsed=performance.now()-armStart;
const progress=Math.min(1,elapsed/ARM_HOLD_MS);
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
if(progress>=1){
armCharged=true;
stopArmHold(false);
armButton.classList.add('charged');
setTimeout(()=>armButton.classList.remove('charged'),600);
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
return;
}
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function startArmHold(e){
if(armStart||armCharged)return;
if(e&&e.button!==undefined&&e.button!==0)return;
armStart=performance.now();
armCharged=false;
armButton.classList.add('charging');
armButton.style.setProperty('--arm-progress','0%');
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function stopArmHold(wasCancelled=true){
if(!armStart)return;
const elapsed=performance.now()-armStart;
cancelAnimationFrame(armHoldTimer);
armHoldTimer=null;
armStart=0;
armButton.classList.remove('charging');
armButton.style.setProperty('--arm-progress','0%');
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
toast('Hold for 1s to Arm vehicle');
}
armCharged=false;
}
armButton.addEventListener('pointerdown',startArmHold);
armButton.addEventListener('pointerup',()=>stopArmHold(true));
armButton.addEventListener('pointerleave',()=>stopArmHold(true));
armButton.addEventListener('pointercancel',()=>stopArmHold(true));
armButton.addEventListener('contextmenu',e=>e.preventDefault());
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
$('#logs').onclick=()=>api('open_logs');
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));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)}
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';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 modelLoadPromise=null;
async function loadDefaultModel(){
if(modelLoadPromise)return modelLoadPromise;
modelLoadPromise=(async()=>{
const statusEl=$('#model-status');
// 1. Direct high-speed HTTP fetch (instant, ~20ms)
for(const url of [
'/model.stl',
'/model',
'/runtime/vehicle_model.stl',
'/assets/Main%20Assembly%20V1%20(2).stl',
'../runtime/vehicle_model.stl',
'../assets/Main%20Assembly%20V1%20(2).stl'
]){
try{
const res=await fetch(url);
if(res.ok){
if(statusEl)statusEl.textContent='Loading mesh…';
const buf=await res.arrayBuffer();
if(window.rovModel){
const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl');
$('#load-model').textContent='Replace STL';
console.log('[Direct Fetch] Model loaded:', result);
return true;
}
}
}catch(err){
console.warn(`[Direct Fetch] ${url} failed:`, err);
}
}
// 2. Python Bridge Chunked Stream (fallback)
if(window.pywebview?.api?.get_saved_model_info){
try{
const info=await api('get_saved_model_info');
if(info&&info.size){
const total=info.size, chunk=262144;
const bytes=new Uint8Array(total);
let loaded=0;
if(statusEl){
statusEl.className='model-status';
statusEl.textContent=`Streaming ${info.name} (0%)…`;
}
while(loaded<total){
const reqSize=Math.min(chunk,total-loaded);
const resp=await api('read_saved_model_chunk',loaded,reqSize);
const part=atob(resp.data);
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
loaded+=part.length;
const pct=Math.round((loaded/total)*100);
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(r,0));
}
if(window.rovModel){
const res=window.rovModel.loadBuffer(bytes.buffer,info.name);
$('#load-model').textContent='Replace STL';
return true;
}
}
}catch(e){
console.warn('[Bridge Stream] Failed:', e);
if(statusEl){
statusEl.className='model-status error';
statusEl.textContent=`Bridge Error: ${e.message}`;
}
}
}
return false;
})();
return modelLoadPromise;
}
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('saved_model');if(saved){try{window.rovModel.load(saved);$('#load-model').textContent='Replace STL'}catch(error){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();
async function boot(){
if(booted||!window.pywebview?.api)return;
booted=true;
bridgeReady=true;
loadDefaultModel();
try{
const init=await api('initial_state');
batteryConfig=init.battery_config;
populatePorts(init.ports);
fillBatteryForm();
render(init.state);
poll();
setInterval(async()=>{
if(!connected){
try{populatePorts(await api('refresh_ports'))}catch(_){}
}
},2000);
}catch(e){
toast(`Python bridge: ${e.message}`,true);
}
}
function waitForBridge(){
if(window.pywebview?.api)boot();
else setTimeout(waitForBridge,50);
}
addEventListener('pywebviewready',boot);
if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
}else{
loadDefaultModel();
waitForBridge();
}
+40 -63
View File
@@ -3,117 +3,94 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SUB Control</title>
<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>
<header class="brand pywebview-drag-region">
<span>Sub Control</span>
</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 id="model-status" class="model-status">Initializing…</div>
<div class="model-tools">
<button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
<input id="model-input" type="file" accept=".stl,.STL" 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="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>
<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 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="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button>
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button>
<button id="connect" class="button connect-button">Connect</button>
</div>
<div class="topbar-spacer pywebview-drag-region"></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></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>
<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>
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
</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>
<footer><span class="steer-readout-left">surge <b id="surge-readout">0</b></span><span class="steer-readout-right"><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>
<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>
<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>
<span>dive</span>
</div>
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
<footer><span><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 class="bottom-actions">
<section class="command-bar">
<div class="commands">
<button id="manual" class="button subtle"><span>Manual</span></button>
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
<button id="disarm" class="button danger"><span>Disarm</span></button>
</div>
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label>
<button class="button subtle" data-open="motors-modal">Motor Setup</button>
</section>
</section>
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></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>
<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><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><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><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></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="joystick-3d.js"></script>
<script src="app.js"></script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
/* joystick-3d.js – Clean Canvas joystick with tactile press animation,
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
(() => {
'use strict';
const host = document.getElementById('joystick');
if (!host) return;
let canvas = document.getElementById('joystick-canvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'joystick-canvas';
host.appendChild(canvas);
}
canvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;';
const ctx = canvas.getContext('2d');
let W = 0, H = 0, dpr = 1;
let nx = 0, ny = 0; // normalised knob position: -1 to +1
let dragging = false;
let animId = 0;
let pressAnimId = 0;
let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93
function resize() {
const r = host.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
draw();
}
window.addEventListener('resize', resize);
const TAU = Math.PI * 2;
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) {
ctx.save();
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif';
ctx.fillStyle = '#656f6c';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const charWidths = [];
let totalWidth = 0;
for (let i = 0; i < text.length; i++) {
const w = ctx.measureText(text[i]).width + 1.8;
charWidths.push(w);
totalWidth += w;
}
const totalAngle = totalWidth / radius;
if (!flip) {
let currentAngle = centerAngle - totalAngle / 2;
for (let i = 0; i < text.length; i++) {
const charAngle = charWidths[i] / radius;
const midAngle = currentAngle + charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle + Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle += charAngle;
}
} else {
let currentAngle = centerAngle + totalAngle / 2;
for (let i = 0; i < text.length; i++) {
const charAngle = charWidths[i] / radius;
const midAngle = currentAngle - charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle - Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle -= charAngle;
}
}
ctx.restore();
}
function draw() {
if (!W || !H) return;
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.45;
const baseKnobR = R * 0.22; // 20% larger knob
const knobR = baseKnobR * pressScale;
const travel = R * 0.58;
const dist = Math.hypot(nx, ny);
/* ── 1. SPHERICAL BOWL BASE ── */
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.clip();
// Base smooth light background (clean, no blue edge gradient)
const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R);
bg.addColorStop(0, '#ffffff');
bg.addColorStop(0.55, '#f7f9f8');
bg.addColorStop(0.85, '#edf1f0');
bg.addColorStop(1, '#e3e8e7');
ctx.fillStyle = bg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
// 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65)
if (dist > 0.65) {
const redFactor = Math.min(1, (dist - 0.65) / 0.35);
const angle = Math.atan2(ny, nx);
const edgeX = cx + R * Math.cos(angle);
const edgeY = cy + R * Math.sin(angle);
const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85);
redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`);
redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`);
redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)');
ctx.fillStyle = redGrad;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
// Straight vertical and horizontal axis lines
ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - R * 0.85);
ctx.lineTo(cx, cy + R * 0.85);
ctx.moveTo(cx - R * 0.85, cy);
ctx.lineTo(cx + R * 0.85, cy);
ctx.stroke();
// ── Curved Lowercase Directional Labels ──
const labelR = R * 0.84;
drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false);
drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true);
drawCurvedText('turn left', cx, cy, labelR, Math.PI, false);
drawCurvedText('turn right', cx, cy, labelR, 0, false);
ctx.restore(); // un-clip
// Outer rim border
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.strokeStyle = '#cdd6d4';
ctx.lineWidth = 1.5;
ctx.stroke();
/* ── 2. SOFT KNOB SHADOW ── */
const kx = cx + nx * travel;
const ky = cy + ny * travel;
const shadowY = ky + 2.5;
const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25);
sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)');
sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)');
sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU);
ctx.fillStyle = sg;
ctx.fill();
/* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */
ctx.save();
ctx.translate(kx, ky);
// Lighter, clean metallic/slate gradient
const kg = ctx.createLinearGradient(0, -knobR, 0, knobR);
kg.addColorStop(0, '#687271');
kg.addColorStop(0.45, '#515958');
kg.addColorStop(1, '#3c4443');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = kg;
ctx.fill();
// Clean 1px top highlight bevel
const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR);
bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)');
bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)');
bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)');
bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)');
ctx.strokeStyle = bevel;
ctx.lineWidth = 1;
ctx.stroke();
// Outer light edge border (matching vertical knob)
ctx.strokeStyle = '#d7dedc';
ctx.lineWidth = 1;
ctx.stroke();
// Subtle cyan light glow ONLY when actively holding/dragging
if (dragging) {
const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15);
gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)');
gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)');
gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)');
gg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = gg;
ctx.fill();
}
ctx.restore(); // un-knob translate
ctx.restore(); // top-level save
}
function setPos(x, y) {
const len = Math.hypot(x, y);
if (len > 1) { x /= len; y /= len; }
nx = x;
ny = y;
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
}
function pointerToNorm(e) {
const r = canvas.getBoundingClientRect();
const cx = r.width / 2;
const cy = r.height / 2;
const travel = Math.min(r.width, r.height) * 0.45 * 0.58;
const px = e.clientX - r.left;
const py = e.clientY - r.top;
setPos((px - cx) / travel, (py - cy) / travel);
}
/* ── Tactile Press Animation (matching vertical knob) ── */
function animatePress(toPressed) {
cancelAnimationFrame(pressAnimId);
const startScale = pressScale;
const targetScale = toPressed ? 0.93 : 1.0;
const duration = toPressed ? 110 : 450;
const startTime = performance.now();
function step(now) {
const p = Math.min(1, (now - startTime) / duration);
const curve = toPressed
? p * (2 - p)
: 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35;
pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p);
if (!toPressed) {
pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p);
}
draw();
if (p < 1) {
pressAnimId = requestAnimationFrame(step);
} else {
pressScale = targetScale;
draw();
}
}
pressAnimId = requestAnimationFrame(step);
}
function easeBack() {
cancelAnimationFrame(animId);
const sx = nx, sy = ny, t0 = performance.now();
(function step(now) {
const p = Math.min(1, (now - t0) / 320);
const s = p * p * (3 - 2 * p);
nx = sx * (1 - s);
ny = sy * (1 - s);
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
if (p < 1) animId = requestAnimationFrame(step);
})(t0);
}
canvas.addEventListener('pointerdown', e => {
dragging = true;
canvas.style.cursor = 'grabbing';
cancelAnimationFrame(animId);
animatePress(true);
canvas.setPointerCapture(e.pointerId);
pointerToNorm(e);
});
canvas.addEventListener('pointermove', e => {
if (dragging) pointerToNorm(e);
});
canvas.addEventListener('pointerup', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
canvas.addEventListener('pointercancel', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
resize();
window.joystick3D = {
setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); },
easeBack() { if (!dragging) easeBack(); }
};
})();
+562 -83
View File
@@ -1,104 +1,583 @@
/**
* ARGO 3D Vehicle Model Viewer
* Clean, lightweight, hardware-accelerated WebGL STL renderer with:
* - Dynamic ARGO subsea caustic light shader
* - Curvature & Depth Ambient Occlusion (AO)
* - Titanium sub hull metallic materials + Cyan Fresnel rim glow
* - 60 FPS Orbit Controls (drag, wheel zoom, double-click reset)
* - High-speed Binary & ASCII STL parser with auto-centering
*/
(() => {
'use strict';
const canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage');
const ctx = canvas.getContext('2d');
let vertices = null, frame = 0, yaw = 0, pitch = 0, zoom = 1;
let dragging = false, lastX = 0, lastY = 0;
// Still below native resolution, but high enough for clean assembly contours.
const renderScale = .82;
if (!canvas || !stage) return;
let gl = null;
let shaderProgram = null;
let posBuffer = null;
let normBuffer = null;
let vertexCount = 0;
let modelLoaded = false;
let modelName = 'vehicle_model.stl';
// Uniform & Attribute Locations
let locPos = -1;
let locNorm = -1;
let locMVP = null;
let locNormalMat = null;
let locModelMat = null;
let locTime = null;
let locLight1 = null;
let locLight2 = null;
// Camera & Orbit State
let yaw = -0.45;
let pitch = 0.35;
let targetYaw = -0.45;
let targetPitch = 0.35;
let zoom = 1.0;
let targetZoom = 1.0;
let dragging = false;
let lastX = 0;
let lastY = 0;
let startTime = performance.now();
let isRendering = false;
// Initialize WebGL
function initGL() {
if (gl) return true;
try {
gl = canvas.getContext('webgl', {
antialias: true,
alpha: true,
depth: true,
preserveDrawingBuffer: false,
powerPreference: 'high-performance'
}) || canvas.getContext('experimental-webgl', { antialias: true, alpha: true, depth: true });
} catch (e) {
console.warn('WebGL initialization error:', e);
return false;
}
if (!gl) return false;
const vsSource = `
attribute vec3 a_position;
attribute vec3 a_normal;
uniform mat4 u_mvp;
uniform mat3 u_normalMat;
uniform mat4 u_modelMat;
varying vec3 v_normal;
varying vec3 v_modelPos;
void main() {
v_normal = normalize(u_normalMat * a_normal);
v_modelPos = a_position;
gl_Position = u_mvp * vec4(a_position, 1.0);
}
`;
const fsSource = `
precision mediump float;
varying vec3 v_normal;
varying vec3 v_modelPos;
uniform float u_time;
uniform vec3 u_light1;
uniform vec3 u_light2;
// ARGO subsea caustic wave generator
float getCaustics(vec2 p, float t) {
vec2 p1 = p * 3.8 + vec2(t * 0.35, t * 0.25);
vec2 p2 = p * 6.2 - vec2(t * 0.28, -t * 0.38);
vec2 p3 = p * 8.5 + vec2(-t * 0.18, t * 0.42);
float c = sin(p1.x + sin(p1.y * 1.2 + t * 0.6)) * 0.5 + 0.5;
c += sin(p2.y + cos(p2.x * 1.1 - t * 0.5)) * 0.5 + 0.5;
c += sin(p3.x * 0.8 + p3.y * 0.9 + t * 0.9) * 0.5 + 0.5;
return pow(c / 3.0, 3.0) * 2.2;
}
void main() {
vec3 n = normalize(v_normal);
if (n.z < 0.0) n = -n; // Two-sided CAD shading
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 l1 = normalize(u_light1);
vec3 l2 = normalize(u_light2);
// Diffuse & Ambient Lighting (Soft, lifted shadows)
float diff1 = max(dot(n, l1), 0.0);
float diff2 = max(dot(n, l2), 0.0) * 0.30;
float ambient = 0.46;
// Matte Specular (Soft diffuse sheen, no harsh shine)
vec3 h1 = normalize(l1 + viewDir);
float spec = pow(max(dot(n, h1), 0.0), 10.0) * 0.07;
// Subtle Cyan Fresnel Accent
float fresnel = pow(1.0 - max(dot(n, viewDir), 0.0), 2.8);
// Soft Ambient Occlusion (Gentle curvature without heavy dark shadows)
float dist = length(v_modelPos);
float ao = clamp(0.76 + 0.24 * (dist * 0.9 + abs(n.y) * 0.15), 0.68, 1.0);
// Caustic ripple projection
float caust = getCaustics(v_modelPos.xz + v_modelPos.yy * 0.25, u_time) * max(n.y * 0.65 + 0.35, 0.0);
// Clean Matte White Palette
vec3 hullDark = vec3(0.66, 0.72, 0.76);
vec3 hullLit = vec3(0.96, 0.98, 1.00);
vec3 cyanGlow = vec3(0.35, 0.88, 0.98);
vec3 caustColor = vec3(0.55, 0.92, 1.00);
// Final Composite
vec3 color = mix(hullDark, hullLit, clamp((diff1 * 0.50 + diff2 + ambient) * ao, 0.0, 1.0));
color += caustColor * caust * 0.18;
color += vec3(1.0) * spec;
color += cyanGlow * fresnel * 0.18;
gl_FragColor = vec4(color, 1.0);
}
`;
function createShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(s));
gl.deleteShader(s);
return null;
}
return s;
}
const vs = createShader(gl.VERTEX_SHADER, vsSource);
const fs = createShader(gl.FRAGMENT_SHADER, fsSource);
if (!vs || !fs) return false;
shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vs);
gl.attachShader(shaderProgram, fs);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(shaderProgram));
return false;
}
locPos = gl.getAttribLocation(shaderProgram, 'a_position');
locNorm = gl.getAttribLocation(shaderProgram, 'a_normal');
locMVP = gl.getUniformLocation(shaderProgram, 'u_mvp');
locNormalMat = gl.getUniformLocation(shaderProgram, 'u_normalMat');
locModelMat = gl.getUniformLocation(shaderProgram, 'u_modelMat');
locTime = gl.getUniformLocation(shaderProgram, 'u_time');
locLight1 = gl.getUniformLocation(shaderProgram, 'u_light1');
locLight2 = gl.getUniformLocation(shaderProgram, 'u_light2');
posBuffer = gl.createBuffer();
normBuffer = gl.createBuffer();
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.clearDepth(1.0);
gl.disable(gl.CULL_FACE);
return true;
}
// Matrix math
function mat4Perspective(out, fovy, aspect, near, far) {
const f = 1.0 / Math.tan(fovy / 2);
const nf = 1 / (near - far);
out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0;
out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0;
out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1;
out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0;
}
function mat4Multiply(out, a, b) {
for (let c = 0; c < 4; c++) {
const c4 = c * 4;
const b0 = b[c4], b1 = b[c4 + 1], b2 = b[c4 + 2], b3 = b[c4 + 3];
out[c4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
out[c4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
out[c4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
out[c4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
}
return out;
}
// Fast Binary & ASCII STL Parser
function parseSTL(buffer) {
const isBinary = buffer.byteLength >= 84 &&
(84 + new DataView(buffer).getUint32(80, true) * 50 <= buffer.byteLength);
let positions, normals;
function parseStl(buffer) {
const view = new DataView(buffer);
let raw = [];
const binaryCount = buffer.byteLength >= 84 ? view.getUint32(80, true) : 0;
const isBinary = binaryCount > 0 && 84 + binaryCount * 50 <= buffer.byteLength;
if (isBinary) {
for (let t = 0; t < binaryCount; t++) {
const offset = 84 + t * 50 + 12;
for (let v = 0; v < 3; v++) {
raw.push(view.getFloat32(offset + v * 12, true));
raw.push(view.getFloat32(offset + v * 12 + 4, true));
raw.push(view.getFloat32(offset + v * 12 + 8, true));
const view = new DataView(buffer);
const count = view.getUint32(80, true);
if (count === 0) throw new Error('Empty binary STL');
positions = new Float32Array(count * 9);
normals = new Float32Array(count * 9);
let pIdx = 0, nIdx = 0;
for (let i = 0; i < count; i++) {
const off = 84 + i * 50;
let nx = view.getFloat32(off, true);
let ny = view.getFloat32(off + 4, true);
let nz = view.getFloat32(off + 8, true);
const v0x = view.getFloat32(off + 12, true);
const v0y = view.getFloat32(off + 16, true);
const v0z = view.getFloat32(off + 20, true);
const v1x = view.getFloat32(off + 24, true);
const v1y = view.getFloat32(off + 28, true);
const v1z = view.getFloat32(off + 32, true);
const v2x = view.getFloat32(off + 36, true);
const v2y = view.getFloat32(off + 40, true);
const v2z = view.getFloat32(off + 44, true);
if (nx * nx + ny * ny + nz * nz < 1e-6) {
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
nx = ay * bz - az * by;
ny = az * bx - ax * bz;
nz = ax * by - ay * bx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len; ny /= len; nz /= len;
}
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
for (let k = 0; k < 3; k++) {
normals[nIdx++] = nx;
normals[nIdx++] = ny;
normals[nIdx++] = nz;
}
}
} else {
const text = new TextDecoder().decode(buffer);
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
if (matches.length < 3) throw new Error('This file does not contain a readable STL mesh');
for (let i = 0; i + 2 < matches.length; i += 3) {
for (let j = 0; j < 3; j++) raw.push(+matches[i+j][1], +matches[i+j][2], +matches[i+j][3]);
if (matches.length < 3) throw new Error('Invalid ASCII STL');
const count = Math.floor(matches.length / 3);
positions = new Float32Array(count * 9);
normals = new Float32Array(count * 9);
let pIdx = 0, nIdx = 0;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
const v0x = +matches[i3][1], v0y = +matches[i3][2], v0z = +matches[i3][3];
const v1x = +matches[i3 + 1][1], v1y = +matches[i3 + 1][2], v1z = +matches[i3 + 1][3];
const v2x = +matches[i3 + 2][1], v2y = +matches[i3 + 2][2], v2z = +matches[i3 + 2][3];
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len; ny /= len; nz /= len;
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
for (let k = 0; k < 3; k++) {
normals[nIdx++] = nx;
normals[nIdx++] = ny;
normals[nIdx++] = nz;
}
}
}
if (raw.length < 9) throw new Error('The STL mesh is empty');
let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity;
for(let i=0;i<raw.length;i+=3){minX=Math.min(minX,raw[i]);maxX=Math.max(maxX,raw[i]);minY=Math.min(minY,raw[i+1]);maxY=Math.max(maxY,raw[i+1]);minZ=Math.min(minZ,raw[i+2]);maxZ=Math.max(maxZ,raw[i+2])}
const cx=(minX+maxX)/2,cy=(minY+maxY)/2,cz=(minZ+maxZ)/2,scale=2/Math.max(maxX-minX,maxY-minY,maxZ-minZ,1e-9);
for(let i=0;i<raw.length;i+=3){raw[i]=(raw[i]-cx)*scale;raw[i+1]=(raw[i+1]-cy)*scale;raw[i+2]=(raw[i+2]-cz)*scale}
return new Float32Array(raw);
}
function draw() {
frame=0;if(!vertices)return;
const rect=canvas.getBoundingClientRect();
const width=Math.max(1,Math.round(rect.width*renderScale)),height=Math.max(1,Math.round(rect.height*renderScale));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height);
const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d');
mask.width=width;mask.height=height;
const size=Math.min(width,height)*.43*zoom,cx=width/2,cy=height/2;
const cosY=Math.cos(yaw),sinY=Math.sin(yaw),cosP=Math.cos(pitch),sinP=Math.sin(pitch);
const project=(x,y,z)=>{
const rx=x*cosY+z*sinY,rz=-x*sinY+z*cosY;
return [rx,y*cosP-rz*sinP];
};
maskCtx.fillStyle='#fff';
maskCtx.beginPath();
for(let i=0;i<vertices.length;i+=9){
const a=project(vertices[i],vertices[i+1],vertices[i+2]);
const b=project(vertices[i+3],vertices[i+4],vertices[i+5]);
const c=project(vertices[i+6],vertices[i+7],vertices[i+8]);
maskCtx.moveTo(cx+a[0]*size,cy-a[1]*size);
maskCtx.lineTo(cx+b[0]*size,cy-b[1]*size);
maskCtx.lineTo(cx+c[0]*size,cy-c[1]*size);
maskCtx.closePath();
// Center to origin and scale to fit unit sphere
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}
// Rasterize the union once. Filling faces individually creates faint
// antialiased seams that resemble a wireframe on dense assemblies.
maskCtx.fill();
// A filtered copy creates only the exterior contour; triangle boundaries
// never enter the final image because the source is a single solid mask.
ctx.save();
ctx.filter='drop-shadow(0 0 1.2px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.28))';
ctx.drawImage(mask,0,0);ctx.restore();
const surface=document.createElement('canvas'),surfaceCtx=surface.getContext('2d');
surface.width=width;surface.height=height;
const gradient=surfaceCtx.createLinearGradient(0,0,width,height);
gradient.addColorStop(0,'#ffffff');gradient.addColorStop(.58,'#f4f5f2');gradient.addColorStop(1,'#dfe2de');
surfaceCtx.fillStyle=gradient;surfaceCtx.fillRect(0,0,width,height);
surfaceCtx.globalCompositeOperation='destination-in';surfaceCtx.drawImage(mask,0,0);
ctx.drawImage(surface,0,0);
}
function requestDraw(){if(!frame)frame=requestAnimationFrame(draw)}
canvas.addEventListener('pointerdown',event=>{dragging=true;lastX=event.clientX;lastY=event.clientY;canvas.setPointerCapture(event.pointerId)});
canvas.addEventListener('pointermove',event=>{if(!dragging)return;yaw+=(event.clientX-lastX)*.012;pitch=Math.max(-1.45,Math.min(1.45,pitch+(event.clientY-lastY)*.012));lastX=event.clientX;lastY=event.clientY;requestDraw()});
canvas.addEventListener('pointerup',()=>dragging=false);
canvas.addEventListener('pointercancel',()=>dragging=false);
canvas.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.max(.55,Math.min(2.2,zoom*(event.deltaY>0?.9:1.1)));requestDraw()},{passive:false});
addEventListener('resize',requestDraw);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const cz = (minZ + maxZ) / 2;
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6);
const scale = 2.0 / maxDim;
for (let i = 0; i < positions.length; i += 3) {
positions[i] = (positions[i] - cx) * scale;
positions[i + 1] = (positions[i + 1] - cy) * scale;
positions[i + 2] = (positions[i + 2] - cz) * scale;
}
return { positions, normals, count: positions.length / 3 };
}
// Render Loop
function render() {
if (!modelLoaded || vertexCount === 0) {
isRendering = false;
return;
}
if (!gl) {
if (!initGL()) {
isRendering = false;
return;
}
}
// Inertia damping
yaw += (targetYaw - yaw) * 0.16;
pitch += (targetPitch - pitch) * 0.16;
zoom += (targetZoom - zoom) * 0.16;
// Viewport resize
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(10, Math.round(rect.width * dpr));
const height = Math.max(10, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(shaderProgram);
// Matrix calculations
const aspect = width / height;
const proj = new Float32Array(16);
mat4Perspective(proj, (42 * Math.PI) / 180, aspect, 0.1, 50.0);
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
const dist = 3.2 / zoom;
const model = new Float32Array(16);
model[0] = cosY;
model[1] = sinP * sinY;
model[2] = -cosP * sinY;
model[3] = 0;
model[4] = 0;
model[5] = cosP;
model[6] = sinP;
model[7] = 0;
model[8] = sinY;
model[9] = -sinP * cosY;
model[10] = cosP * cosY;
model[11] = 0;
model[12] = 0;
model[13] = 0;
model[14] = -dist;
model[15] = 1;
const mvp = new Float32Array(16);
mat4Multiply(mvp, proj, model);
const normalMat = new Float32Array([
model[0], model[1], model[2],
model[4], model[5], model[6],
model[8], model[9], model[10],
]);
const elapsedTime = (performance.now() - startTime) * 0.001;
gl.uniformMatrix4fv(locMVP, false, mvp);
gl.uniformMatrix3fv(locNormalMat, false, normalMat);
gl.uniformMatrix4fv(locModelMat, false, model);
gl.uniform1f(locTime, elapsedTime);
gl.uniform3f(locLight1, 0.65, 0.85, 0.60);
gl.uniform3f(locLight2, -0.60, -0.40, 0.30);
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.enableVertexAttribArray(locPos);
gl.vertexAttribPointer(locPos, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
gl.enableVertexAttribArray(locNorm);
gl.vertexAttribPointer(locNorm, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
// Continue animation loop for dynamic caustic wave shimmer
requestAnimationFrame(render);
}
function startRenderLoop() {
if (!isRendering && modelLoaded) {
isRendering = true;
requestAnimationFrame(render);
}
}
// Orbit Pointer Interaction
canvas.addEventListener('pointerdown', e => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', e => {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
targetYaw += dx * 0.012;
targetPitch = Math.max(-1.45, Math.min(1.45, targetPitch + dy * 0.012));
lastX = e.clientX;
lastY = e.clientY;
});
const stopDrag = () => {
dragging = false;
canvas.style.cursor = 'grab';
};
canvas.addEventListener('pointerup', stopDrag);
canvas.addEventListener('pointercancel', stopDrag);
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.90 : 1.11;
targetZoom = Math.max(0.45, Math.min(3.5, targetZoom * factor));
}, { passive: false });
canvas.addEventListener('dblclick', () => {
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
});
// Early context init
initGL();
// Public Interface
window.rovModel = {
loadBuffer(buffer, name) {
vertices=parseStl(buffer);stage.classList.add('model-loaded');
document.querySelector('#model-file-name').textContent=name;
yaw=0;pitch=0;zoom=1;
requestDraw();
return {name,triangles:vertices.length/9};
loadBuffer(buffer, name = 'Main Assembly V1 (2).stl') {
console.log(`[ROV 3D] Loading STL buffer "${name}" (${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB)...`);
const statusEl = document.querySelector('#model-status');
if (statusEl) {
statusEl.className = 'model-status';
statusEl.textContent = `Parsing ${name}…`;
}
try {
const mesh = parseSTL(buffer);
vertexCount = mesh.count;
modelLoaded = true;
modelName = name;
initGL();
if (gl) {
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
} else {
throw new Error('WebGL context unavailable');
}
stage.classList.add('model-loaded');
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
yaw = targetYaw;
pitch = targetPitch;
zoom = targetZoom;
startRenderLoop();
const tris = vertexCount / 3;
console.log(`[ROV 3D] Successfully parsed & uploaded ${tris.toLocaleString()} triangles to GPU.`);
if (statusEl) {
statusEl.textContent = `${name} • ${tris.toLocaleString()} \u0394`;
statusEl.classList.remove('error');
setTimeout(() => statusEl.classList.add('fade-out'), 3500);
}
return { name, triangles: tris };
} catch (err) {
console.error('[ROV 3D Error]', err);
if (statusEl) {
statusEl.className = 'model-status error';
statusEl.textContent = `STL Error: ${err.message}`;
}
throw err;
}
},
load(payload) {
if (!payload?.data) throw new Error('The selected STL returned no mesh data');
const binary=atob(payload.data),bytes=new Uint8Array(binary.length);
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);
return this.loadBuffer(bytes.buffer,payload.name);
if (!payload) throw new Error('No model payload provided');
if (payload instanceof ArrayBuffer) {
return this.loadBuffer(payload);
}
if (payload.buffer instanceof ArrayBuffer) {
return this.loadBuffer(payload.buffer, payload.name);
}
let rawBase64 = '';
let name = 'vehicle_model.stl';
if (typeof payload === 'string') {
rawBase64 = payload.includes(',') ? payload.split(',')[1] : payload;
} else if (payload.data) {
rawBase64 = payload.data.includes(',') ? payload.data.split(',')[1] : payload.data;
name = payload.name || name;
}
const binary = atob(rawBase64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return this.loadBuffer(bytes.buffer, name);
},
resetView() {
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
}
};
// Immediate auto-fetch of runtime default model
for (const url of [
'/model.stl',
'/model',
'/runtime/vehicle_model.stl',
'/assets/Main%20Assembly%20V1%20(2).stl',
'../runtime/vehicle_model.stl',
'../assets/Main%20Assembly%20V1%20(2).stl'
]) {
try {
fetch(url)
.then(r => r.ok ? r.arrayBuffer() : null)
.then(buf => {
if (buf && !modelLoaded) {
window.rovModel.loadBuffer(buf, 'Main Assembly V1 (2).stl');
const btn = document.querySelector('#load-model');
if (btn) btn.textContent = 'Replace STL';
}
})
.catch(() => {});
} catch (_) {}
}
})();
+1069 -12
View File
File diff suppressed because it is too large Load Diff