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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+269 -22
View File
@@ -1,42 +1,289 @@
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; 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 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); 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)} 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; 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 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 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)} 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; window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()};
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 releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
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; 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 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))} 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 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()} 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;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('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();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()}); 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()); function openModal(id){
$('#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 el=typeof id==='string'?document.getElementById(id):id;
const modelInput=$('#model-input'),modelButton=$('#load-model'); if(!el)return;
modelButton.onclick=()=>{modelInput.value='';modelInput.click()}; el.classList.remove('closing');
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}}; if(!el.open)el.showModal();
$('#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'); function closeModal(dialog){
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)}; if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
$('#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)}}; 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 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 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)} 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; 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)}} async function boot(){
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)} if(booted||!window.pywebview?.api)return;
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge(); 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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SUB Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <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"> <main class="app-shell">
<aside class="vehicle-rail"> <aside class="vehicle-rail">
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header> <header class="brand pywebview-drag-region">
<div class="eyebrow">LIVE VEHICLE</div> <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="rov-stage" aria-label="Top view of three-thruster ROV">
<div class="water-ring r1"></div><div class="water-ring r2"></div> <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> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools"> <div class="model-tools">
<button id="load-model">Load STL</button> <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> </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"> <div class="rail-bottom">
<button class="battery-card ghost" data-open="battery-modal"> <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> <b><em id="battery-fill"></em></b>
</button> </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> </div>
</aside> </aside>
<section class="workspace"> <section class="workspace">
<header class="topbar"> <header class="topbar">
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
<div class="connection"> <div class="connection">
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select> <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 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">•••</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> <button id="connect" class="button connect-button">Connect</button>
</div> </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> </header>
<section class="drive-grid"> <section class="drive-grid">
<article class="control-card steer-card"> <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 id="joystick" class="joystick" touch-action="none">
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div> <canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
<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> </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>
<article class="control-card vertical-card"> <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"> <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> <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> </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> </article>
</section> </section>
<section class="command-bar"> <section class="bottom-actions">
<div class="commands"> <section class="command-bar">
<button id="manual" class="button subtle">Manual</button> <div class="commands">
<button id="arm" class="button arm">Arm</button> <button id="manual" class="button subtle"><span>Manual</span></button>
<button id="disarm" class="button danger">Disarm</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>
</div> <button id="disarm" class="button danger"><span>Disarm</span></button>
<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> </div>
<button class="button subtle" data-open="motors-modal">Motor setup</button> <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 id="logs" class="icon-button" title="Open session logs">⌁</button> <button class="button subtle" data-open="motors-modal">Motor Setup</button>
</section>
</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> </section>
</main> </main>
<div id="toast" class="toast"></div> <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><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="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><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="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="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="model-viewer.js"></script>
<script src="joystick-3d.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </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 canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage'); const stage = document.querySelector('.rov-stage');
const ctx = canvas.getContext('2d'); if (!canvas || !stage) return;
let vertices = null, frame = 0, yaw = 0, pitch = 0, zoom = 1;
let dragging = false, lastX = 0, lastY = 0; let gl = null;
// Still below native resolution, but high enough for clean assembly contours. let shaderProgram = null;
const renderScale = .82; 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) { if (isBinary) {
for (let t = 0; t < binaryCount; t++) { const view = new DataView(buffer);
const offset = 84 + t * 50 + 12; const count = view.getUint32(80, true);
for (let v = 0; v < 3; v++) { if (count === 0) throw new Error('Empty binary STL');
raw.push(view.getFloat32(offset + v * 12, true));
raw.push(view.getFloat32(offset + v * 12 + 4, true)); positions = new Float32Array(count * 9);
raw.push(view.getFloat32(offset + v * 12 + 8, true)); 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 { } else {
const text = new TextDecoder().decode(buffer); const text = new TextDecoder().decode(buffer);
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)]; 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'); if (matches.length < 3) throw new Error('Invalid ASCII STL');
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]); 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() { // Center to origin and scale to fit unit sphere
frame=0;if(!vertices)return; let minX = Infinity, minY = Infinity, minZ = Infinity;
const rect=canvas.getBoundingClientRect(); let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
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} for (let i = 0; i < positions.length; i += 3) {
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height); const x = positions[i], y = positions[i + 1], z = positions[i + 2];
const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d'); if (x < minX) minX = x; if (x > maxX) maxX = x;
mask.width=width;mask.height=height; if (y < minY) minY = y; if (y > maxY) maxY = y;
const size=Math.min(width,height)*.43*zoom,cx=width/2,cy=height/2; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
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();
} }
// 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 = { window.rovModel = {
loadBuffer(buffer, name) { loadBuffer(buffer, name = 'Main Assembly V1 (2).stl') {
vertices=parseStl(buffer);stage.classList.add('model-loaded'); console.log(`[ROV 3D] Loading STL buffer "${name}" (${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB)...`);
document.querySelector('#model-file-name').textContent=name; const statusEl = document.querySelector('#model-status');
yaw=0;pitch=0;zoom=1; if (statusEl) {
requestDraw(); statusEl.className = 'model-status';
return {name,triangles:vertices.length/9}; 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) { load(payload) {
if (!payload?.data) throw new Error('The selected STL returned no mesh data'); if (!payload) throw new Error('No model payload provided');
const binary=atob(payload.data),bytes=new Uint8Array(binary.length); if (payload instanceof ArrayBuffer) {
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i); return this.loadBuffer(payload);
return this.loadBuffer(bytes.buffer,payload.name); }
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
+167 -17
View File
@@ -6,6 +6,10 @@ import queue
import threading import threading
import time import time
import base64 import base64
import shutil
import http.server
import socketserver
import urllib.parse
from pathlib import Path from pathlib import Path
import webview import webview
@@ -56,6 +60,7 @@ class ControlApi:
"ports": [p.device for p in list_ports.comports()], "ports": [p.device for p in list_ports.comports()],
"battery_config": self.battery_config, "battery_config": self.battery_config,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
@@ -81,6 +86,56 @@ class ControlApi:
self._window.destroy() self._window.destroy()
return True return True
def choose_model(self):
selected = None
# 1. Native Windows topmost open file dialog (instant, reliable)
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
file_path = filedialog.askopenfilename(
title="Select 3D STL Vehicle Model",
initialdir=str(Path.home() / "Downloads"),
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
)
root.destroy()
if file_path:
selected = (file_path,)
except Exception:
pass
# 2. Fallback to pywebview create_file_dialog
if not selected and self._window:
try:
selected = self._window.create_file_dialog(
webview.FileDialog.OPEN,
directory=str(Path.home()),
allow_multiple=False,
file_types=("STL mesh (*.stl)", "All files (*.*)"),
)
except Exception:
selected = None
if not selected:
return None
path = Path(selected[0])
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
content = path.read_bytes()
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(path.name, encoding="utf-8")
try:
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
except Exception:
pass
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
def save_model(self, name, data): def save_model(self, name, data):
safe_name = Path(str(name)).name safe_name = Path(str(name)).name
if not safe_name.lower().endswith(".stl"): if not safe_name.lower().endswith(".stl"):
@@ -96,24 +151,55 @@ class ControlApi:
self.model_cache_name.write_text(safe_name, encoding="utf-8") self.model_cache_name.write_text(safe_name, encoding="utf-8")
return True return True
def saved_model(self): def get_saved_model_info(self):
self._migrate_legacy_model()
try: try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024: if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl" name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")} return {"name": name, "size": self.model_cache.stat().st_size}
except (OSError, ValueError): except (OSError, ValueError):
pass pass
try:
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
return self._model_payload(path)
except (OSError, ValueError, TypeError, KeyError):
pass
return None return None
@staticmethod def read_saved_model_chunk(self, offset, size=262144):
def _model_payload(path): offset, size = int(offset), min(int(size), 524288)
return {"name": path.name, "data": base64.b64encode(path.read_bytes()).decode("ascii")} if offset < 0 or size < 1 or not self.model_cache.is_file():
raise ValueError("Saved model is unavailable")
with self.model_cache.open("rb") as stream:
stream.seek(offset)
content = stream.read(size)
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
def _migrate_legacy_model(self):
if not self.model_cache.is_file():
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
if default_asset.is_file():
try:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(default_asset, self.model_cache)
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
return
except Exception:
pass
try:
if self.model_path.is_file():
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(path, self.model_cache)
self.model_cache_name.write_text(path.name, encoding="utf-8")
except Exception:
pass
def saved_model(self):
self._migrate_legacy_model()
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
except (OSError, ValueError):
pass
return None
def connect(self, port, baud=57600, rate=20): def connect(self, port, baud=57600, rate=20):
if not port: if not port:
@@ -146,6 +232,14 @@ class ControlApi:
return value return value
def command(self, name): def command(self, name):
if not self.worker.connection or not self.worker.target_system:
if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode")
elif name == "arm":
raise RuntimeError("Connect to vehicle before arming")
elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat")
if name == "manual": if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode() self.worker.set_manual_mode()
@@ -156,7 +250,7 @@ class ControlApi:
self.worker.log("OPERATOR", "clicked DISARM") self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False) self.worker.command_arm(False)
else: else:
raise ValueError("Unknown command") raise ValueError(f"Unknown command: {name}")
return True return True
def set_servo_function(self, servo, motor): def set_servo_function(self, servo, motor):
@@ -279,23 +373,79 @@ class ControlApi:
self.worker.disconnect() self.worker.disconnect()
def _apply_dwm_styling(window):
try:
hwnd = None
if hasattr(window, "native") and window.native:
hwnd = int(window.native.Handle.ToInt64())
if hwnd:
import ctypes
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
ctypes.windll.dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
)
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
ctypes.windll.dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
)
except Exception:
pass
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress HTTP request logs in console
def do_GET(self):
unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
project_root = Path(__file__).resolve().parent
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
if not model_file.is_file():
model_file = project_root / "runtime" / "vehicle_model.stl"
if model_file.is_file():
data = model_file.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
return
return super().do_GET()
def start_local_server(directory: Path) -> int:
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
return port
def main(): def main():
api = ControlApi() api = ControlApi()
ui_path = Path(__file__).resolve().parent / "web" / "index.html" api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html"
window = webview.create_window( window = webview.create_window(
"SUB Control", url=str(ui_path), js_api=api, "SUB Control", url=app_url, js_api=api,
width=1120, height=760, min_size=(900, 650), width=1400, height=808, min_size=(1125, 690),
background_color="#111719", frameless=True, easy_drag=False, background_color="#111719", frameless=True, easy_drag=False,
shadow=True, shadow=True,
) )
api._window = window api._window = window
window.events.shown += lambda: _apply_dwm_styling(window)
window.events.maximized += lambda: setattr(api, "maximized", True) window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False) window.events.restored += lambda: setattr(api, "maximized", False)
try: try:
webview.start(debug=False) webview.start(debug=True)
finally: finally:
api.close() api.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()