Files
argo/SUB Control/web/app.js
T

464 lines
26 KiB
JavaScript

const STATUS_LABELS = {
disconnected: 'Disconnected — select a port and connect',
connecting: 'Connecting…',
connected_disarmed: 'Connected · Disarmed',
armed: 'Connected · Armed',
link_degraded: 'Signal lost — check tether',
};
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args);
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
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');
if(!p)return;
const old=p.value||'';
p.innerHTML='';
const allList=[];
const seen=new Set();
const addPort=item=>{
let dev=item, lbl=item;
if(item&&typeof item==='object'){
dev=item.device||'';
lbl=item.label||dev;
}
if(dev&&!seen.has(dev)){
seen.add(dev);
allList.push({device:dev, label:lbl});
}
};
if(Array.isArray(ports)) ports.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false;
allList.forEach(item=>{
const opt=new Option(item.label, item.device);
p.add(opt);
if(item.device===old){
p.value=item.device;
matched=true;
}
});
if(!matched&&allList.length>0){
p.value=allList[0].device;
}
}
function sendAxes(){if(appState!=='armed')return;if(demoActive){demoState.status=`Demo controls • surge ${control.forward.toFixed(2)} • yaw ${control.turn.toFixed(2)} • heave ${control.vertical.toFixed(2)}`;$('#status-text').textContent=demoState.status;return}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(){ if(appState!=='armed'){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;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 heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;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&&appState==='armed')sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(appState!=='armed'||heavePointer!==null)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
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)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave();return;}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(heavePointer===null)easeHeave();}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&!e.repeat)startEngageHold();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave()});
function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
if(!el)return;
el.classList.remove('closing');
if(!el.open)el.showModal();
}
function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
dialog.classList.add('closing');
let done=false;
const finish=()=>{
if(done)return;
done=true;
dialog.removeEventListener('animationend',finish);
dialog.classList.remove('closing');
dialog.close();
};
dialog.addEventListener('animationend',finish,{once:true});
setTimeout(finish,300);
}
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{
const r=d.getBoundingClientRect();
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
});
});
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.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()=>{
const btn=$('#connect');
const label=btn.querySelector('.btn-label')||btn;
const p=$('#port');
const port=p?p.value:'';
if(port==='demo'||demoActive){
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
return;
}
if(appState==='connecting'){
label.textContent='Canceling…';
try{ await api('disconnect'); }catch(_){}
return;
}
if(appState!=='disconnected'){
label.textContent='Disconnecting…';
try{
await api('disconnect');
toast('Disconnected');
}catch(e){
toast(e.message,true);
}
}else{
if(!port){
toast('Select or enter a port to connect',true);
return;
}
btn.classList.add('is-connecting');
label.textContent='Connecting…';
const statusEl=$('#status-text');
if(statusEl)statusEl.textContent=`Opening ${port}…`;
toast(`Connecting to ${port}…`);
try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
if(confirmedState)render(confirmedState);
toast('MAVLink connected');
}catch(e){
btn.classList.remove('is-connecting');
label.textContent='Connect';
toast(e.message,true);
if(statusEl)statusEl.textContent=`Connection error: ${e.message}`;
}
}
};
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{
const val=customPortInput.value.trim();
if(!val)return;
const p=$('#port');
let exists=false;
for(const opt of p.options){
if(opt.value===val){opt.selected=true;exists=true;break;}
}
if(!exists){
p.add(new Option(`Custom (${val})`, val), 0);
p.value=val;
}
closeModal($('#link-modal'));
toast(`Selected port: ${val}`);
};
customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
}
const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
const ENGAGE_HOLD_MS=1400;
function isManualMode(s){
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
return m==='MANUAL';
}
function applyEngageVisual(next){
if(!engageButton||engageVisualState===next)return;
const previous=engageVisualState;engageVisualState=next;clearTimeout(engageTransitionTimer);
const commit=()=>{engageButton.classList.remove('arm','danger','subtle','charged');engageButton.classList.add(next==='disarm'?'danger':next==='engage'?'arm':'subtle');engageButton.disabled=next==='disabled';const label=next==='disarm'?'DISARM':'ENGAGE';$('#engage-label').textContent=label;if(engageProgressLabel)engageProgressLabel.textContent=label;engageButton.title=next==='disarm'?'Disarm immediately (F3)':next==='engage'?'Hold to Engage (F2)':'Engage';engageButton.setAttribute('aria-label',label)};
if((previous==='engage'&&next==='disarm')||(previous==='disarm'&&next==='engage')){engageButton.classList.add('state-transition');engageTransitionTimer=setTimeout(()=>{commit();engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),150)},120)}else commit();
}
async function completeEngage(){
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
try{if(demoActive){updateDemo({armed:true,app_state:'armed',vehicle:'DEMO • ARMED',status:'Demo: Arm confirmed'});toast('Demo vehicle armed')}else{await api('command','arm');toast('Engage command sent');setTimeout(()=>{if(appState!=='armed'){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%')}},2500)}}catch(error){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%');toast(error.message,true)}
}
function updateEngageProgress(now){
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return cancelEngageHold(false);
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function startEngageHold(e){
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return;
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function cancelEngageHold(showHint=true){
if(!engageStart)return;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.style.setProperty('--engage-progress','0%');if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Keep holding to engage');
}
if(engageButton){
engageButton.addEventListener('pointerdown',startEngageHold);
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=async()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(appState==='armed'){
try{
if(demoActive)updateDemo({armed:false,app_state:'connected_disarmed',vehicle:'DEMO • DISARMED',status:'Demo: Disarm confirmed'});
else await api('command','disarm');
toast('Disarm command sent');
}catch(e){
toast(e.message,true);
}
}
};
}
$('#logs').onclick=()=>api('open_logs');
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
function render(s){
appState=s.app_state||'disconnected';
document.body.dataset.appState=appState;
const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
if(connectBtn){
if(appState==='connecting'){
if(connectLabel)connectLabel.textContent='Connecting…';
else connectBtn.textContent='Connecting…';
connectBtn.classList.add('is-connecting');
connectBtn.classList.remove('is-connected');
}else if(appState!=='disconnected'){
if(connectLabel)connectLabel.textContent='Disconnect';
else connectBtn.textContent='Disconnect';
connectBtn.classList.remove('is-connecting');
connectBtn.classList.add('is-connected');
}else{
if(connectLabel)connectLabel.textContent='Connect';
else connectBtn.textContent='Connect';
connectBtn.classList.remove('is-connecting', 'is-connected');
}
}
const engageBtn=$('#engage');
const engageLabel=$('#engage-label');
if(engageBtn&&engageLabel){
applyEngageVisual(appState==='armed'?'disarm':appState==='connected_disarmed'?'engage':'disabled');
}
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
const statusEl=$('#status-text');
if(statusEl){
if(s.status){
statusEl.textContent=s.status;
}else{
statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected;
}
}
updateReadouts();
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;if(!demoActive)try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
let modelLoadPromise=null;
async function loadDefaultModel(){
if(modelLoadPromise)return modelLoadPromise;
modelLoadPromise=(async()=>{
const statusEl=$('#model-status');
// 1. Direct high-speed HTTP fetch (instant, ~20ms)
for(const url of [
'/model.stl',
'/model',
'/runtime/vehicle_model.stl',
'/assets/Main%20Assembly%20V1%20(2).stl',
'../runtime/vehicle_model.stl',
'../assets/Main%20Assembly%20V1%20(2).stl'
]){
try{
const res=await fetch(url);
if(res.ok){
if(statusEl)statusEl.textContent='Loading mesh…';
const buf=await res.arrayBuffer();
if(window.rovModel){
const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl');
$('#load-model').textContent='Replace STL';
console.log('[Direct Fetch] Model loaded:', result);
return true;
}
}
}catch(err){
console.warn(`[Direct Fetch] ${url} failed:`, err);
}
}
// 2. Python Bridge Chunked Stream (fallback)
if(window.pywebview?.api?.get_saved_model_info){
try{
const info=await api('get_saved_model_info');
if(info&&info.size){
const total=info.size, chunk=262144;
const bytes=new Uint8Array(total);
let loaded=0;
if(statusEl){
statusEl.className='model-status';
statusEl.textContent=`Streaming ${info.name} (0%)…`;
}
while(loaded<total){
const reqSize=Math.min(chunk,total-loaded);
const resp=await api('read_saved_model_chunk',loaded,reqSize);
const part=atob(resp.data);
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
loaded+=part.length;
const pct=Math.round((loaded/total)*100);
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(r,0));
}
if(window.rovModel){
const res=window.rovModel.loadBuffer(bytes.buffer,info.name);
$('#load-model').textContent='Replace STL';
return true;
}
}
}catch(e){
console.warn('[Bridge Stream] Failed:', e);
if(statusEl){
statusEl.className='model-status error';
statusEl.textContent=`Bridge Error: ${e.message}`;
}
}
}
return false;
})();
return modelLoadPromise;
}
let booted=false;
async function boot(){
if(booted||!window.pywebview?.api)return;
booted=true;
bridgeReady=true;
try{
const init=await api('initial_state');
batteryConfig=init.battery_config;
populatePorts(init.ports);
fillBatteryForm();
render(init.state);
poll();
// Populate the serial selector before starting the optional model load.
// A large cached mesh must never delay an operator seeing USB COM ports.
loadDefaultModel();
setInterval(async()=>{
if(appState==='disconnected'){
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();
}