diff --git a/SUB Control/assets/Main Assembly V1 (2).stl b/SUB Control/assets/Main Assembly V1 (2).stl new file mode 100644 index 0000000..9156f99 Binary files /dev/null and b/SUB Control/assets/Main Assembly V1 (2).stl differ diff --git a/SUB Control/main_assembly_render.png b/SUB Control/main_assembly_render.png new file mode 100644 index 0000000..0493a66 Binary files /dev/null and b/SUB Control/main_assembly_render.png differ diff --git a/SUB Control/matte_white_render.png b/SUB Control/matte_white_render.png new file mode 100644 index 0000000..f61830a Binary files /dev/null and b/SUB Control/matte_white_render.png differ diff --git a/SUB Control/runtime_model_render.png b/SUB Control/runtime_model_render.png new file mode 100644 index 0000000..3389c99 Binary files /dev/null and b/SUB Control/runtime_model_render.png differ diff --git a/SUB Control/thruster_support_render.png b/SUB Control/thruster_support_render.png new file mode 100644 index 0000000..f22f9a8 Binary files /dev/null and b/SUB Control/thruster_support_render.png differ diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index de997c3..274b7d9 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -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.clientXr.right||e.clientYr.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&&elapsed70){ + 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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;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(loadedsetTimeout(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(); +} diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index c7a86fa..c109a6d 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -3,117 +3,94 @@ - SUB Control + Sub Control -
-
SSUB ControlBuilt-in vehicle
-
- - - -
-
-
PILOT CONSOLE

Drive

- - + +
+
+
+ + + +
-
STEER + THROTTLEDifferential drive
W A S D
+
steer + throttledifferential drive
w a s d
-
- FORWARDREVERSE - TURN LEFTTURN RIGHT -
+
-
0% surge0% turn
+
surge 00 turn
-
VERTICALCenter thruster
R / F
+
verticalcenter thruster
r / f
- RISE + rise
- DIVE + dive
-
0% vertical
+
0 vertical
-
-
- - - -
- - - +
+
+
+ + + +
+ + +
-

Ready. Select a USB port to begin.

+

ready. select a USB port to begin.

logs
- SAFETY CHECK

Arm the vehicle?

Remove propellers or securely restrain the ROV before enabling motor output.

- CONNECTION

Link settings

20 Hz is recommended for smooth intermediate commands on this link.

- POWER

Battery settings

- CONFIGURATION

Motor outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ connection

Link Settings

20 Hz is recommended for smooth intermediate commands on this link.

+ Power

Battery Settings

+ configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ diff --git a/SUB Control/web/joystick-3d.js b/SUB Control/web/joystick-3d.js new file mode 100644 index 0000000..0682c2b --- /dev/null +++ b/SUB Control/web/joystick-3d.js @@ -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(); } + }; +})(); diff --git a/SUB Control/web/model-viewer.js b/SUB Control/web/model-viewer.js index fd3b654..60353eb 100644 --- a/SUB Control/web/model-viewer.js +++ b/SUB Control/web/model-viewer.js @@ -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{ - 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 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 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 (_) {} + } })(); diff --git a/SUB Control/web/styles.css b/SUB Control/web/styles.css index 90852a8..4caac4a 100644 --- a/SUB Control/web/styles.css +++ b/SUB Control/web/styles.css @@ -1,12 +1,1069 @@ -:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif} -.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff} -*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)} -.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718} -.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb} -.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544} -.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top} -.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f} -@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}} -.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px} -.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:.72;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default} +/* SUB Control UI – Clean, minimal interface with native sizing */ + +:root { + color-scheme: dark; + --bg: #0c1012; + --rail: #151a1d; + --panel: #f3f4f1; + --card: #ffffff; + --ink: #16191b; + --muted: #727a7c; + --line: #dfe2dd; + --acid: #c9f277; + --cyan: #72d8e8; + --amber: #f2c675; + --red: #f0958b; + --shadow: 0 16px 50px rgba(0, 0, 0, 0.05); + font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; + font-weight: 300; +} + +* { box-sizing: border-box; } +html { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); +} +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); + font-size: 13px; + color: var(--ink); + -webkit-font-smoothing: antialiased; + zoom: 1.5625; +} +button, select, input { font: inherit; } + +/* ── App Shell ── */ +.app-shell { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 236px 1fr; + overflow: hidden; + background: var(--panel); + border-radius: calc(8px / 1.5625); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05); +} +body.maximized .app-shell { + border-radius: 0; +} + +/* ── Left Rail (Windows Acrylic Frosted Glass with Blue Subsea Glow) ── */ +.vehicle-rail { + position: relative; + display: flex; + flex-direction: column; + padding: 20px 18px 16px; + background: + radial-gradient(ellipse 110% 45% at 50% 28%, rgba(30, 144, 255, 0.18) 0%, rgba(0, 200, 255, 0.08) 45%, transparent 75%), + radial-gradient(ellipse 130% 60% at 50% 0%, rgba(68, 120, 160, 0.22) 0%, transparent 75%), + linear-gradient(175deg, #1b2328 0%, #12171a 50%, #0c1012 100%); + border-right: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: + inset -1px 0 0 rgba(255, 255, 255, 0.02), + inset 0 1px 0 rgba(255, 255, 255, 0.06), + inset 0 40px 80px -20px rgba(0, 170, 255, 0.12); + color: #f1f4f2; + overflow: hidden; +} +.vehicle-rail::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 35%, transparent 70%); + z-index: 1; +} +.vehicle-rail > * { + position: relative; + z-index: 2; +} +.brand { + display: flex; + align-items: center; + font-size: 14px; + font-weight: 300; + letter-spacing: -0.01em; + color: #f1f4f2; +} +.eyebrow { + font-size: 9px; + font-weight: 400; + color: #727c80; + letter-spacing: 0.03em; +} +.vehicle-rail > .eyebrow { + margin-top: 18px; +} +.rov-stage { + position: relative; + height: min(32vh, 195px); + min-height: 155px; + display: grid; + place-items: center; + border-radius: 12px; + transition: background 0.2s ease, border-color 0.2s ease; +} +.rov-stage::before { + content: ""; + position: absolute; + width: 140px; + height: 140px; + border-radius: 50%; + background: radial-gradient(circle, rgba(0, 180, 255, 0.22) 0%, rgba(0, 120, 220, 0.08) 50%, transparent 75%); + filter: blur(16px); + pointer-events: none; + z-index: 1; +} +.rov-stage.drag-over { + border: 1px dashed rgba(79, 195, 247, 0.6); + background: rgba(79, 195, 247, 0.06); +} +.water-ring { + position: absolute; + border: 1px solid rgba(0, 200, 255, 0.22); + box-shadow: 0 0 14px rgba(0, 180, 255, 0.12); + border-radius: 50%; + transform: scaleY(0.55); +} +.water-ring.r1 { width: 175px; height: 175px; } +.water-ring.r2 { width: 125px; height: 125px; border-color: rgba(114, 216, 232, 0.32); } + +.rov-stage #model-canvas { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 3; + cursor: grab; + touch-action: none; +} +.model-status { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + background: rgba(18, 24, 27, 0.85); + border: 1px solid rgba(79, 195, 247, 0.25); + border-radius: 6px; + font-size: 8.5px; + letter-spacing: 0.04em; + color: #8edcf8; + z-index: 5; + pointer-events: none; + white-space: nowrap; + backdrop-filter: blur(6px); + transition: opacity 0.4s ease; +} +.model-status.error { + border-color: rgba(255, 82, 82, 0.5); + color: #ff8a80; + background: rgba(40, 15, 15, 0.9); +} +.model-status.fade-out { + opacity: 0; +} +.model-tools { + display: none !important; +} + +.vehicle-name { + display: flex; + flex-direction: column; + gap: 2px; +} +.vehicle-name small, .battery-card small { + font-size: 8.5px; + color: #687276; + font-weight: 400; +} +.vehicle-name strong { + font-size: 14.5px; + font-weight: 400; + letter-spacing: -0.01em; +} +.vehicle-name span { + font-size: 9.5px; + color: #727c80; +} + +.rail-bottom { + margin-top: auto; +} +.battery-card { + display: grid; + grid-template-columns: 1fr auto; + width: 100%; + padding: 0 0 12px; + text-align: left; + color: inherit; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + cursor: pointer; +} +.battery-card::after { display: none !important; } +.battery-card:hover { transform: none !important; box-shadow: none !important; } +.battery-card > span { + display: flex; + flex-direction: column; + gap: 2px; +} +.battery-card strong { + font-size: 16px; + font-weight: 400; +} +.battery-card i { + align-self: end; + color: #859094; + font-size: 10px; + font-style: normal; +} +.battery-card b { + grid-column: 1 / -1; + height: 3.5px; + margin-top: 6px; + background: #31383c; + border-radius: 999px; + overflow: hidden; +} +.battery-card em { + display: block; + width: 0; + height: 100%; + background: var(--acid); + border-radius: inherit; + transition: width 0.35s; +} + +/* ── Single Solid LED Dot ── */ +.link-state { + display: flex; + align-items: center; + gap: 7px; + margin-top: 10px; + font-size: 9.5px; + color: #929a9c; +} +.link-state span { + width: 6.5px; + height: 6.5px; + border-radius: 50%; + background: var(--red); + box-shadow: none !important; +} +.link-state.online span { + background: var(--acid); + box-shadow: none !important; +} +.link-state strong { + font-weight: 400; +} + +/* ── Right Workspace (Seamless) ── */ +.workspace { + display: flex; + flex-direction: column; + padding: 18px 20px 14px; + color: var(--ink); + background: linear-gradient(155deg, #f7f8f6, #ebefec); + min-width: 0; +} + +/* ── Topbar with Large Circular Window Buttons ── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); + user-select: none; +} +.connection { + display: flex; + align-items: center; + gap: 5px; + -webkit-app-region: no-drag; +} +.topbar-spacer { + flex: 1; + height: 32px; + min-width: 20px; +} +select, input { + border: 1px solid #d4d8d3; + background: #fff; + color: #212527; + border-radius: 999px; + outline: 0; + cursor: pointer; +} +.connection select { + width: 135px; + height: 32px; + padding: 0 12px; + font-size: 10.5px; + cursor: pointer; + position: relative; + z-index: 5; + -webkit-app-region: no-drag; +} +.button, .icon-button { + height: 32px; + border: 1px solid transparent; + border-radius: 999px; + padding: 0 14px; + color: #28302c; + background: #e8ebe6; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10.5px; + font-weight: 400; + transition: transform 0.15s, background 0.15s, box-shadow 0.15s; +} +.button:hover, .icon-button:hover { + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(30, 40, 35, 0.08); +} +.icon-button { + width: 32px; + padding: 0; +} +.icon-button svg { + width: 13px; + height: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon-button .dots-icon { + fill: currentColor; + stroke: none; +} +.connect-button { + background: #171b19; + color: #fff; + padding: 0 15px; +} +.connect-button:hover { background: #282f2c; } + +/* ── Window Actions (Frameless Minimal Icons) ── */ +.window-actions { + display: flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} +.window-actions button { + width: 22px; + height: 22px; + border-radius: 4px; + border: 0; + background: transparent; + color: #7a8588; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; + box-shadow: none; + transition: color 0.2s ease, opacity 0.2s ease; +} +.window-actions button::after { + display: none !important; +} +.window-actions button svg { + width: 13px; + height: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.window-actions button:hover { + background: transparent; + color: #1a1e1d; + box-shadow: none; +} +.window-actions button:active { + opacity: 0.65; +} +.window-actions .window-close:hover { + background: transparent; + color: #e04438; +} +.window-actions .window-close:active { + opacity: 0.65; + color: #b52f25; +} + +/* ── Drive Grid ── */ +.drive-grid { + --joy-size: min(30vh, 215px); + --joy-circle-d: calc(var(--joy-size) * 0.90); + display: grid; + grid-template-columns: minmax(290px, 1fr) 175px; + gap: 11px; + flex: 1; + min-height: 0; + padding: 10px 0; +} +.control-card { + display: flex; + flex-direction: column; + padding: 13px 15px 10px; + border: 1px solid #e1e4e0; + border-radius: 22px; + background: var(--card); + box-shadow: var(--shadow); + min-height: 0; +} +.control-card header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} +.control-card header div { + display: flex; + flex-direction: column; + gap: 1px; +} +.control-card small { + font-size: 8.5px; + color: #828a88; + font-weight: 400; +} +.control-card header strong { + font-size: 13px; + font-weight: 400; +} +.key-hint { + padding: 3px 6px; + border-radius: 6px; + background: #f0f2ef; + color: #7e8583; + font-size: 8.5px; + font-weight: 400; +} + +/* ── Joystick Card ── */ +.joystick { + --size: var(--joy-size); + position: relative; + width: var(--size); + height: var(--size); + min-width: 165px; + min-height: 165px; + margin: auto; + border: 0; + background: transparent; + user-select: none; + touch-action: none; +} +.joystick:before, .joystick:after { display: none !important; } +.axis, .axis-label, .axis-copy, .stick, .stick-shadow { display: none !important; } + +/* ── Vertical Card & Slider (Matched in Height to Joystick Sphere) ── */ +.vertical-control { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-height: var(--joy-size); + gap: 3px; + font-size: 8.5px; + font-weight: 400; + color: #828a87; + padding: 2px 0; +} +.heave-track { + position: relative; + width: 36px; + height: var(--joy-circle-d); + border-radius: 18px; + background: #eaeee8; + border: 1px solid #d6dbd4; + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.05); + touch-action: none; +} +.heave-track::before { display: none !important; } +.heave-center { + position: absolute; + left: 6px; + right: 6px; + top: 50%; + height: 1px; + background: #b2b9b4; +} +.heave-fill { + position: absolute; + left: 12px; + right: 12px; + top: 50%; + height: 0; + background: rgba(188, 159, 235, 0.4); + border-radius: 3px; +} +.heave-knob { + position: absolute; + left: 50%; + top: 50%; + translate: -50% -50%; + width: 48px; + height: 27px; + border-radius: 14px; + background: linear-gradient(145deg, #687271, #3c4443); + border: 1px solid #d7dedc; + box-shadow: 0 3px 10px rgba(20, 30, 25, 0.18), inset 0 1px rgba(255, 255, 255, 0.55); + cursor: grab; + touch-action: none; + transition: scale 0.12s, filter 0.12s; + will-change: top; +} +.heave-knob::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 12px; + height: 1.5px; + background: #8e9694; + border-radius: 1px; + transform: translate(-50%, -50%); +} +.heave-knob:active, .heave-knob.pressed { + scale: 0.93; +} + +/* ── Aligned Footers ── */ +.control-card footer { + display: flex; + align-items: center; + height: 14px; + margin-top: auto; + color: #7e8583; + font-size: 9.5px; + font-weight: 300; + line-height: 1; +} +.control-card footer b { font-weight: 400; color: #28302c; } +.steer-card footer { + justify-content: space-between; + width: 100%; + padding: 0 2px; +} +.steer-readout-left, .steer-readout-right { + display: inline-flex; + align-items: center; + gap: 3px; +} +.dot { display: none !important; } + +/* ── Command Bar (Bottom Pill Tray) ── */ +.bottom-actions { + display: flex; + flex-direction: column; + gap: 5px; +} +.command-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 42px; + padding: 4px 8px; + border: 1px solid #dde1dc; + border-radius: 999px; + background: #fff; + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.03); +} +.commands { + display: flex; + align-items: center; + gap: 5px; +} +.commands .button { + height: 30px; + padding: 0 12px; + font-size: 10px; +} +.subtle { background: #edf0ec; color: #262d29; } +.arm { + background: #eef3e5; + color: #24351a; + min-width: 66px; + position: relative; + isolation: isolate; + overflow: hidden; + user-select: none; + touch-action: none; + cursor: pointer; +} +.arm::before { + content: ""; + position: absolute; + z-index: 1; + inset: 0 auto 0 0; + width: var(--arm-progress, 0%); + border-radius: inherit; + background: linear-gradient(90deg, #c9f277, #9de848); + pointer-events: none; + transition: width 0.04s linear; +} +.arm-copy { + display: grid; + place-items: center; + width: 100%; + position: relative; + z-index: 2; +} +.arm-copy > span { + grid-area: 1 / 1; + transition: opacity 0.15s, transform 0.15s; +} +.arm-holding-label { opacity: 0; transform: translateY(3px); } +.arm.charging .arm-label { opacity: 0; transform: translateY(-3px); } +.arm.charging .arm-holding-label { opacity: 1; transform: translateY(0); } +.arm.charged { + background: #c9f277 !important; + color: #14200d !important; + transform: scale(1.04); +} +.danger { background: #f8deda; color: #7a2822; } + +/* ── Output Limit Slider ── */ +.output-limit { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: #656d6a; + font-weight: 300; + flex: 1; + max-width: 240px; + margin: 0 4px; +} +.output-limit span { + white-space: nowrap; +} +.output-limit b { + font-weight: 400; + color: #232926; +} +.range-pill { + display: grid; + grid-template-columns: 22px 1fr 22px; + align-items: center; + width: 100%; + height: 25px; + border-radius: 999px; + background: #eaede8; + overflow: hidden; +} +.range-pill button { + width: 22px; + height: 25px; + border: 0; + background: transparent; + color: #626966; + font-size: 13px; + font-weight: 400; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.range-pill button:hover { background: #dee2dc; color: #161a18; } +.range-pill input[type="range"] { + width: 100%; + height: 14px; + margin: 0; + padding: 0 3px; + border: 0; + background: transparent; + accent-color: #3b4341; + -webkit-appearance: none; +} +.range-pill input::-webkit-slider-runnable-track { + height: 4.5px; + border-radius: 3px; + background: #d6dbd4; +} +.range-pill input::-webkit-slider-thumb { + width: 12px; + height: 16px; + margin-top: -5.75px; + border: 1px solid #aeb4ae; + border-radius: 999px; + background: #bcc0bb; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + -webkit-appearance: none; +} + +/* ── Status Line & Logs ── */ +.status-line { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 9.5px; + color: #7c8481; + padding: 1px 3px 0; +} +.status-line p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.logs-link { + all: unset; + cursor: pointer; + color: #9aa29f; + font-size: 9.5px; + letter-spacing: 0.02em; + margin-left: auto; +} +.logs-link:hover { color: #3c4240; } + +/* ── Modals & Number Steppers (Floaty, Feathery Transitions) ── */ +.modal { + width: min(540px, calc(100vw - 40px)); + padding: 22px 24px; + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 24px; + background: rgba(247, 248, 245, 0.96); + color: #1a1e1f; + box-shadow: + 0 30px 80px -12px rgba(0, 0, 0, 0.42), + 0 12px 28px -4px rgba(0, 0, 0, 0.20), + 0 0 0 1px rgba(255, 255, 255, 0.35) inset; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + outline: none; + transform-origin: center center; + will-change: transform, opacity, filter; +} + +.modal::backdrop { + background: rgba(8, 17, 19, 0.65); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + will-change: opacity, backdrop-filter; +} + +/* Floaty feathery entrance */ +.modal[open] { + animation: featheryModalIn 0.42s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.modal[open]::backdrop { + animation: featheryBackdropIn 0.38s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* Floaty feathery exit */ +.modal.closing { + animation: featheryModalOut 0.28s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.modal.closing::backdrop { + animation: featheryBackdropOut 0.26s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes featheryModalIn { + 0% { + opacity: 0; + transform: translateY(26px) scale(0.92); + filter: blur(6px); + } + 65% { + transform: translateY(-2.5px) scale(1.008); + filter: blur(0px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } +} + +@keyframes featheryModalOut { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } + 100% { + opacity: 0; + transform: translateY(18px) scale(0.94); + filter: blur(4px); + } +} + +@keyframes featheryBackdropIn { + 0% { + opacity: 0; + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px); + } + 100% { + opacity: 1; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } +} + +@keyframes featheryBackdropOut { + 0% { + opacity: 1; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + 100% { + opacity: 0; + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px); + } +} + +.modal h2 { + margin: 3px 0 13px; + font-size: 21px; + font-weight: 300; +} +.modal-close { + position: absolute; + top: 15px; + right: 15px; + width: 28px; + height: 28px; + border: 0; + border-radius: 50%; + background: #e6eae4; + color: #555; + cursor: pointer; + font-size: 16px; + display: grid; + place-items: center; + transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), background 0.15s, color 0.15s; +} +.modal-close:hover { + background: #dde2db; + color: #1a1e1d; + transform: scale(1.1) rotate(90deg); +} +.modal-close:active { + transform: scale(0.9) rotate(90deg); +} +.form-grid { + display: grid; + gap: 10px; +} +.form-grid.two { grid-template-columns: 1fr 1fr; } +.form-grid.three { grid-template-columns: repeat(3, 1fr); } +.form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + color: #6f7572; + font-size: 9.5px; +} +.number-pill { + display: grid; + grid-template-columns: 24px 1fr 24px; + align-items: center; + height: 32px; + border: 1px solid #dce0db; + border-radius: 12px; + background: #fff; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; +} +.number-pill:focus-within { + border-color: #9cb0a8; + box-shadow: 0 0 0 3px rgba(114, 216, 232, 0.15); +} +.number-pill input { + width: 100% !important; + height: 100% !important; + border: 0 !important; + background: transparent !important; + border-radius: 0 !important; + text-align: center; + font-size: 11.5px; + font-weight: 400; + color: #202523; + padding: 0 !important; + box-shadow: none !important; + -moz-appearance: textfield; +} +.number-pill input::-webkit-inner-spin-button, +.number-pill input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.num-step { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 32px; + padding: 0; + margin: 0; + border: 0; + background: transparent; + color: #6e7572; + font-size: 13px; + cursor: pointer; + user-select: none; + transition: background 0.12s, color 0.12s; +} +.num-step:hover { background: #eef1ed; color: #161a18; } +.num-step:active { background: #dee2dd; } + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 7px; + margin-top: 16px; +} +.modal-actions.wide { grid-column: 1 / -1; } +.note { color: #767d7a; font-size: 10px; line-height: 1.4; } + +.motor-modal { width: min(680px, calc(100vw - 40px)); } +.motor-head, .motor-row { + display: grid; + grid-template-columns: 65px 1fr 100px 80px; + align-items: center; + gap: 7px; +} +.motor-head { padding: 5px 8px; color: #8e9592; font-size: 8.5px; } +.motor-list { max-height: 320px; overflow: auto; } +.motor-row { min-height: 38px; padding: 3px 8px; border-top: 1px solid #e1e4df; } +.motor-row select { height: 28px; padding: 0 6px; font-size: 10px; } +.motor-row .button { height: 26px; padding: 0 8px; font-size: 10px; } + +/* ── Toast (Floaty Feathery Popup) ── */ +.toast { + position: fixed; + z-index: 20; + left: 50%; + bottom: 24px; + padding: 9px 18px; + border-radius: 14px; + background: rgba(27, 33, 31, 0.94); + color: #fff; + font-size: 11px; + font-weight: 400; + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: + 0 16px 42px -6px rgba(0, 0, 0, 0.5), + 0 4px 14px rgba(0, 0, 0, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transform: translate(-50%, 22px) scale(0.92); + opacity: 0; + filter: blur(4px); + pointer-events: none; + transition: + transform 0.42s cubic-bezier(0.16, 1, 0.3, 1), + opacity 0.32s cubic-bezier(0.16, 1, 0.3, 1), + filter 0.32s cubic-bezier(0.16, 1, 0.3, 1); + will-change: transform, opacity, filter; +} +.toast.show { + transform: translate(-50%, 0) scale(1); + opacity: 1; + filter: blur(0px); + animation: toastFeatherFloat 3.2s ease-in-out infinite alternate; +} +.toast.error { + background: rgba(115, 48, 42, 0.95); + border-color: rgba(240, 149, 139, 0.35); +} + +@keyframes toastFeatherFloat { + 0% { + transform: translate(-50%, 0) scale(1); + } + 100% { + transform: translate(-50%, -3px) scale(1); + } +} + +/* ── Window Exit Animation ── */ +body.window-exiting .app-shell { + opacity: 0; + transform: scale(0.96) translateY(14px); + filter: blur(5px); + transition: + transform 0.34s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.34s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.34s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +/* ── Cursor-Tracked Themed Button Light Glow ── */ +button { + --glow-x: 50%; + --glow-y: 50%; + --glow-c1: rgba(255, 255, 255, 0.20); + --glow-c2: rgba(240, 248, 245, 0.12); + --glow-c3: rgba(180, 210, 205, 0.06); + position: relative; + isolation: isolate; + overflow: hidden; +} +button::after { + content: ""; + position: absolute; + z-index: 3; + inset: -1px; + border-radius: inherit; + pointer-events: none; + opacity: 0; + background: radial-gradient(circle 55px at var(--glow-x) var(--glow-y), var(--glow-c1) 0%, var(--glow-c2) 30%, var(--glow-c3) 65%, transparent 100%); + transition: opacity 0.25s ease; +} +button:hover::after { opacity: 1; } +button:active::after { opacity: 0.7; } +.arm { + --glow-c1: rgba(255, 255, 255, 0.30); + --glow-c2: rgba(235, 252, 205, 0.20); + --glow-c3: rgba(201, 242, 119, 0.12); +} +.danger { + --glow-c1: rgba(255, 255, 255, 0.28); + --glow-c2: rgba(254, 228, 224, 0.18); + --glow-c3: rgba(240, 149, 139, 0.10); +} +.connect-button { + --glow-c1: rgba(255, 255, 255, 0.22); + --glow-c2: rgba(215, 245, 255, 0.14); + --glow-c3: rgba(114, 216, 232, 0.08); +} +.subtle, .icon-button { + --glow-c1: rgba(255, 255, 255, 0.22); + --glow-c2: rgba(242, 246, 244, 0.14); + --glow-c3: rgba(195, 210, 205, 0.07); +} +button > svg, button > span, button > kbd { position: relative; z-index: 4; } + +@media (max-width: 950px) { + .app-shell { grid-template-columns: 210px minmax(0, 1fr); } + .drive-grid { grid-template-columns: minmax(260px, 1fr) 160px; } + .workspace { padding: 14px 16px 10px; } +} diff --git a/SUB Control/web_ui.py b/SUB Control/web_ui.py index f27c29e..e66cef1 100644 --- a/SUB Control/web_ui.py +++ b/SUB Control/web_ui.py @@ -6,6 +6,10 @@ import queue import threading import time import base64 +import shutil +import http.server +import socketserver +import urllib.parse from pathlib import Path import webview @@ -56,6 +60,7 @@ class ControlApi: "ports": [p.device for p in list_ports.comports()], "battery_config": self.battery_config, "state": self._snapshot(), + "model_info": self.get_saved_model_info(), } def refresh_ports(self): @@ -81,6 +86,56 @@ class ControlApi: self._window.destroy() 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): safe_name = Path(str(name)).name if not safe_name.lower().endswith(".stl"): @@ -96,24 +151,55 @@ class ControlApi: self.model_cache_name.write_text(safe_name, encoding="utf-8") return True - def saved_model(self): + def get_saved_model_info(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 "vehicle_model.stl" - return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")} + name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl" + return {"name": name, "size": self.model_cache.stat().st_size} except (OSError, ValueError): 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 - @staticmethod - def _model_payload(path): - return {"name": path.name, "data": base64.b64encode(path.read_bytes()).decode("ascii")} + def read_saved_model_chunk(self, offset, size=262144): + offset, size = int(offset), min(int(size), 524288) + 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): if not port: @@ -146,6 +232,14 @@ class ControlApi: return value 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": self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.set_manual_mode() @@ -156,7 +250,7 @@ class ControlApi: self.worker.log("OPERATOR", "clicked DISARM") self.worker.command_arm(False) else: - raise ValueError("Unknown command") + raise ValueError(f"Unknown command: {name}") return True def set_servo_function(self, servo, motor): @@ -279,23 +373,79 @@ class ControlApi: 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(): 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( - "SUB Control", url=str(ui_path), js_api=api, - width=1120, height=760, min_size=(900, 650), + "SUB Control", url=app_url, js_api=api, + width=1400, height=808, min_size=(1125, 690), background_color="#111719", frameless=True, easy_drag=False, shadow=True, ) api._window = window + window.events.shown += lambda: _apply_dwm_styling(window) window.events.maximized += lambda: setattr(api, "maximized", True) window.events.restored += lambda: setattr(api, "maximized", False) try: - webview.start(debug=False) + webview.start(debug=True) finally: api.close() if __name__ == "__main__": main() +