Update UI layout, window controls, and vehicle styling
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
+269
-22
@@ -1,42 +1,289 @@
|
|||||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
|
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
|
||||||
|
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
window.showToast=toast;
|
window.showToast=toast;
|
||||||
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
||||||
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||||
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
||||||
|
|
||||||
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
|
window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()};
|
||||||
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes()}
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))}
|
|
||||||
joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()});
|
|
||||||
function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)}
|
|
||||||
|
|
||||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
||||||
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
|
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
|
||||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||||
heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()});
|
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
|
||||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||||
|
|
||||||
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
||||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
||||||
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
|
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()});
|
||||||
|
|
||||||
function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
|
function openModal(id){
|
||||||
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
|
const el=typeof id==='string'?document.getElementById(id):id;
|
||||||
const modelInput=$('#model-input'),modelButton=$('#load-model');
|
if(!el)return;
|
||||||
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
|
el.classList.remove('closing');
|
||||||
modelInput.onchange=async()=>{const file=modelInput.files?.[0],old=modelButton.textContent;if(!file)return;try{if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh');if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');modelButton.disabled=true;modelButton.textContent='Loading…';await new Promise(requestAnimationFrame);const buffer=await file.arrayBuffer(),result=window.rovModel.loadBuffer(buffer,file.name);modelButton.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);if(bridgeReady){const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=()=>reject(reader.error);reader.readAsDataURL(file)});api('save_model',file.name,String(dataUrl).split(',')[1]).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}};
|
if(!el.open)el.showModal();
|
||||||
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
}
|
||||||
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
|
function closeModal(dialog){
|
||||||
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
|
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
dialog.classList.add('closing');
|
||||||
|
let done=false;
|
||||||
|
const finish=()=>{
|
||||||
|
if(done)return;
|
||||||
|
done=true;
|
||||||
|
dialog.removeEventListener('animationend',finish);
|
||||||
|
dialog.classList.remove('closing');
|
||||||
|
dialog.close();
|
||||||
|
};
|
||||||
|
dialog.addEventListener('animationend',finish,{once:true});
|
||||||
|
setTimeout(finish,300);
|
||||||
|
}
|
||||||
|
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||||
|
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||||
|
$$('dialog').forEach(d=>{
|
||||||
|
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||||
|
d.addEventListener('click',e=>{
|
||||||
|
const r=d.getBoundingClientRect();
|
||||||
|
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
|
||||||
|
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
|
||||||
|
|
||||||
|
async function handleStlFile(file){
|
||||||
|
if(!file)return;
|
||||||
|
try{
|
||||||
|
if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh (*.stl)');
|
||||||
|
if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');
|
||||||
|
modelButton.disabled=true;
|
||||||
|
modelButton.textContent='Loading…';
|
||||||
|
const buffer=await file.arrayBuffer();
|
||||||
|
const result=window.rovModel.loadBuffer(buffer,file.name);
|
||||||
|
modelButton.textContent='Replace STL';
|
||||||
|
toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);
|
||||||
|
if(bridgeReady&&window.pywebview?.api?.save_model){
|
||||||
|
const dataUrl=await new Promise((resolve,reject)=>{
|
||||||
|
const reader=new FileReader();
|
||||||
|
reader.onload=()=>resolve(reader.result);
|
||||||
|
reader.onerror=()=>reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
api('save_model',file.name,String(dataUrl).split(',')[1]).catch(e=>console.warn(e));
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
toast(`STL import failed: ${error.message}`,true);
|
||||||
|
}finally{
|
||||||
|
modelButton.disabled=false;
|
||||||
|
if(modelButton.textContent==='Loading…')modelButton.textContent='Load STL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modelButton.onclick=async()=>{
|
||||||
|
let loaded=false;
|
||||||
|
if(window.pywebview?.api?.choose_model){
|
||||||
|
try{
|
||||||
|
const model=await api('choose_model');
|
||||||
|
if(model&&model.data){
|
||||||
|
const res=window.rovModel.load(model);
|
||||||
|
modelButton.textContent='Replace STL';
|
||||||
|
toast(`Loaded ${res.name} • ${res.triangles.toLocaleString()} triangles`);
|
||||||
|
loaded=true;
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
console.warn('choose_model error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!loaded&&modelInput){
|
||||||
|
modelInput.value='';
|
||||||
|
modelInput.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if(modelInput){
|
||||||
|
modelInput.onchange=()=>{
|
||||||
|
const file=modelInput.files?.[0];
|
||||||
|
if(file)handleStlFile(file);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(rovStage){
|
||||||
|
rovStage.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.add('drag-over')});
|
||||||
|
rovStage.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over')});
|
||||||
|
rovStage.addEventListener('drop',e=>{
|
||||||
|
e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over');
|
||||||
|
const file=e.dataTransfer?.files?.[0];
|
||||||
|
if(file)handleStlFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
||||||
|
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}};
|
||||||
|
const armButton=$('#arm');
|
||||||
|
let armHoldTimer=null, armStart=0, armCharged=false;
|
||||||
|
const ARM_HOLD_MS=1000;
|
||||||
|
|
||||||
|
function updateArmProgress(){
|
||||||
|
if(!armStart)return;
|
||||||
|
const elapsed=performance.now()-armStart;
|
||||||
|
const progress=Math.min(1,elapsed/ARM_HOLD_MS);
|
||||||
|
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
|
||||||
|
if(progress>=1){
|
||||||
|
armCharged=true;
|
||||||
|
stopArmHold(false);
|
||||||
|
armButton.classList.add('charged');
|
||||||
|
setTimeout(()=>armButton.classList.remove('charged'),600);
|
||||||
|
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startArmHold(e){
|
||||||
|
if(armStart||armCharged)return;
|
||||||
|
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||||
|
armStart=performance.now();
|
||||||
|
armCharged=false;
|
||||||
|
armButton.classList.add('charging');
|
||||||
|
armButton.style.setProperty('--arm-progress','0%');
|
||||||
|
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopArmHold(wasCancelled=true){
|
||||||
|
if(!armStart)return;
|
||||||
|
const elapsed=performance.now()-armStart;
|
||||||
|
cancelAnimationFrame(armHoldTimer);
|
||||||
|
armHoldTimer=null;
|
||||||
|
armStart=0;
|
||||||
|
armButton.classList.remove('charging');
|
||||||
|
armButton.style.setProperty('--arm-progress','0%');
|
||||||
|
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
|
||||||
|
toast('Hold for 1s to Arm vehicle');
|
||||||
|
}
|
||||||
|
armCharged=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
armButton.addEventListener('pointerdown',startArmHold);
|
||||||
|
armButton.addEventListener('pointerup',()=>stopArmHold(true));
|
||||||
|
armButton.addEventListener('pointerleave',()=>stopArmHold(true));
|
||||||
|
armButton.addEventListener('pointercancel',()=>stopArmHold(true));
|
||||||
|
armButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
|
|
||||||
|
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
|
||||||
|
$('#logs').onclick=()=>api('open_logs');
|
||||||
|
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||||
|
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||||
|
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
||||||
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
||||||
|
|
||||||
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||||
|
let modelLoadPromise=null;
|
||||||
|
async function loadDefaultModel(){
|
||||||
|
if(modelLoadPromise)return modelLoadPromise;
|
||||||
|
modelLoadPromise=(async()=>{
|
||||||
|
const statusEl=$('#model-status');
|
||||||
|
|
||||||
|
// 1. Direct high-speed HTTP fetch (instant, ~20ms)
|
||||||
|
for(const url of [
|
||||||
|
'/model.stl',
|
||||||
|
'/model',
|
||||||
|
'/runtime/vehicle_model.stl',
|
||||||
|
'/assets/Main%20Assembly%20V1%20(2).stl',
|
||||||
|
'../runtime/vehicle_model.stl',
|
||||||
|
'../assets/Main%20Assembly%20V1%20(2).stl'
|
||||||
|
]){
|
||||||
|
try{
|
||||||
|
const res=await fetch(url);
|
||||||
|
if(res.ok){
|
||||||
|
if(statusEl)statusEl.textContent='Loading mesh…';
|
||||||
|
const buf=await res.arrayBuffer();
|
||||||
|
if(window.rovModel){
|
||||||
|
const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl');
|
||||||
|
$('#load-model').textContent='Replace STL';
|
||||||
|
console.log('[Direct Fetch] Model loaded:', result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
console.warn(`[Direct Fetch] ${url} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Python Bridge Chunked Stream (fallback)
|
||||||
|
if(window.pywebview?.api?.get_saved_model_info){
|
||||||
|
try{
|
||||||
|
const info=await api('get_saved_model_info');
|
||||||
|
if(info&&info.size){
|
||||||
|
const total=info.size, chunk=262144;
|
||||||
|
const bytes=new Uint8Array(total);
|
||||||
|
let loaded=0;
|
||||||
|
if(statusEl){
|
||||||
|
statusEl.className='model-status';
|
||||||
|
statusEl.textContent=`Streaming ${info.name} (0%)…`;
|
||||||
|
}
|
||||||
|
while(loaded<total){
|
||||||
|
const reqSize=Math.min(chunk,total-loaded);
|
||||||
|
const resp=await api('read_saved_model_chunk',loaded,reqSize);
|
||||||
|
const part=atob(resp.data);
|
||||||
|
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
|
||||||
|
loaded+=part.length;
|
||||||
|
const pct=Math.round((loaded/total)*100);
|
||||||
|
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
|
||||||
|
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(r,0));
|
||||||
|
}
|
||||||
|
if(window.rovModel){
|
||||||
|
const res=window.rovModel.loadBuffer(bytes.buffer,info.name);
|
||||||
|
$('#load-model').textContent='Replace STL';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
console.warn('[Bridge Stream] Failed:', e);
|
||||||
|
if(statusEl){
|
||||||
|
statusEl.className='model-status error';
|
||||||
|
statusEl.textContent=`Bridge Error: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
return modelLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
let booted=false;
|
let booted=false;
|
||||||
async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=await api('saved_model');if(saved){try{window.rovModel.load(saved);$('#load-model').textContent='Replace STL'}catch(error){toast(`Saved STL failed: ${error.message}`,true)}}poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}}
|
async function boot(){
|
||||||
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
|
if(booted||!window.pywebview?.api)return;
|
||||||
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
|
booted=true;
|
||||||
|
bridgeReady=true;
|
||||||
|
loadDefaultModel();
|
||||||
|
try{
|
||||||
|
const init=await api('initial_state');
|
||||||
|
batteryConfig=init.battery_config;
|
||||||
|
populatePorts(init.ports);
|
||||||
|
fillBatteryForm();
|
||||||
|
render(init.state);
|
||||||
|
poll();
|
||||||
|
setInterval(async()=>{
|
||||||
|
if(!connected){
|
||||||
|
try{populatePorts(await api('refresh_ports'))}catch(_){}
|
||||||
|
}
|
||||||
|
},2000);
|
||||||
|
}catch(e){
|
||||||
|
toast(`Python bridge: ${e.message}`,true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function waitForBridge(){
|
||||||
|
if(window.pywebview?.api)boot();
|
||||||
|
else setTimeout(waitForBridge,50);
|
||||||
|
}
|
||||||
|
addEventListener('pywebviewready',boot);
|
||||||
|
if(document.readyState==='loading'){
|
||||||
|
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||||
|
}else{
|
||||||
|
loadDefaultModel();
|
||||||
|
waitForBridge();
|
||||||
|
}
|
||||||
|
|||||||
+37
-60
@@ -3,117 +3,94 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>SUB Control</title>
|
<title>Sub Control</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="window-bar">
|
|
||||||
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
|
|
||||||
<div class="window-actions">
|
|
||||||
<button id="window-min" aria-label="Minimize">—</button>
|
|
||||||
<button id="window-max" aria-label="Maximize">□</button>
|
|
||||||
<button id="window-close" class="window-close" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<aside class="vehicle-rail">
|
<aside class="vehicle-rail">
|
||||||
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
|
<header class="brand pywebview-drag-region">
|
||||||
<div class="eyebrow">LIVE VEHICLE</div>
|
<span>Sub Control</span>
|
||||||
|
</header>
|
||||||
|
<div class="eyebrow">live vehicle</div>
|
||||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||||
<svg id="default-model" viewBox="0 0 260 310" role="img">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
|
|
||||||
</defs>
|
|
||||||
<g filter="url(#shadow)">
|
|
||||||
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
|
|
||||||
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
|
|
||||||
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
|
|
||||||
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
|
|
||||||
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
|
|
||||||
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
|
|
||||||
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
|
|
||||||
</g>
|
|
||||||
<g stroke="#59656a" stroke-width="2">
|
|
||||||
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||||
|
<div id="model-status" class="model-status">Initializing…</div>
|
||||||
<div class="model-tools">
|
<div class="model-tools">
|
||||||
<button id="load-model">Load STL</button>
|
<button id="load-model">Load STL</button>
|
||||||
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
|
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||||
<div class="rail-bottom">
|
<div class="rail-bottom">
|
||||||
<button class="battery-card ghost" data-open="battery-modal">
|
<button class="battery-card ghost" data-open="battery-modal">
|
||||||
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i>
|
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
|
||||||
<b><em id="battery-fill"></em></b>
|
<b><em id="battery-fill"></em></b>
|
||||||
</button>
|
</button>
|
||||||
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
|
<div class="link-state"><span id="link-dot"></span><strong id="link">disconnected</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
|
|
||||||
<div class="connection">
|
<div class="connection">
|
||||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||||
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
|
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button>
|
||||||
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
|
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button>
|
||||||
<button id="connect" class="button connect-button">Connect</button>
|
<button id="connect" class="button connect-button">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="topbar-spacer pywebview-drag-region"></div>
|
||||||
|
<div class="window-actions">
|
||||||
|
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||||
|
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
|
||||||
|
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="drive-grid">
|
<section class="drive-grid">
|
||||||
<article class="control-card steer-card">
|
<article class="control-card steer-card">
|
||||||
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
|
<header><div><small>steer + throttle</small><strong>differential drive</strong></div><span class="key-hint">w a s d</span></header>
|
||||||
<div id="joystick" class="joystick" touch-action="none">
|
<div id="joystick" class="joystick" touch-action="none">
|
||||||
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
|
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
|
||||||
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
|
|
||||||
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
|
|
||||||
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer>
|
<footer><span class="steer-readout-left">surge <b id="surge-readout">0</b></span><span class="steer-readout-right"><b id="yaw-readout">0</b> turn</span></footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="control-card vertical-card">
|
<article class="control-card vertical-card">
|
||||||
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
|
<header><div><small>vertical</small><strong>center thruster</strong></div><span class="key-hint">r / f</span></header>
|
||||||
<div class="vertical-control">
|
<div class="vertical-control">
|
||||||
<span>RISE</span>
|
<span>rise</span>
|
||||||
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
||||||
<span>DIVE</span>
|
<span>dive</span>
|
||||||
</div>
|
</div>
|
||||||
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
|
<footer><span><b id="heave-readout">0</b> vertical</span></footer>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="bottom-actions">
|
||||||
<section class="command-bar">
|
<section class="command-bar">
|
||||||
<div class="commands">
|
<div class="commands">
|
||||||
<button id="manual" class="button subtle">Manual</button>
|
<button id="manual" class="button subtle"><span>Manual</span></button>
|
||||||
<button id="arm" class="button arm">Arm</button>
|
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
|
||||||
<button id="disarm" class="button danger">Disarm</button>
|
<button id="disarm" class="button danger"><span>Disarm</span></button>
|
||||||
</div>
|
</div>
|
||||||
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
|
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label>
|
||||||
<button class="button subtle" data-open="motors-modal">Motor setup</button>
|
<button class="button subtle" data-open="motors-modal">Motor Setup</button>
|
||||||
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
|
|
||||||
</section>
|
</section>
|
||||||
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
|
</section>
|
||||||
|
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
|
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
|
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
|
||||||
|
|
||||||
<script src="model-viewer.js"></script>
|
<script src="model-viewer.js"></script>
|
||||||
|
<script src="joystick-3d.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
+561
-82
@@ -1,104 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* ARGO 3D Vehicle Model Viewer
|
||||||
|
* Clean, lightweight, hardware-accelerated WebGL STL renderer with:
|
||||||
|
* - Dynamic ARGO subsea caustic light shader
|
||||||
|
* - Curvature & Depth Ambient Occlusion (AO)
|
||||||
|
* - Titanium sub hull metallic materials + Cyan Fresnel rim glow
|
||||||
|
* - 60 FPS Orbit Controls (drag, wheel zoom, double-click reset)
|
||||||
|
* - High-speed Binary & ASCII STL parser with auto-centering
|
||||||
|
*/
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
const canvas = document.querySelector('#model-canvas');
|
const canvas = document.querySelector('#model-canvas');
|
||||||
const stage = document.querySelector('.rov-stage');
|
const stage = document.querySelector('.rov-stage');
|
||||||
const ctx = canvas.getContext('2d');
|
if (!canvas || !stage) return;
|
||||||
let vertices = null, frame = 0, yaw = 0, pitch = 0, zoom = 1;
|
|
||||||
let dragging = false, lastX = 0, lastY = 0;
|
let gl = null;
|
||||||
// Still below native resolution, but high enough for clean assembly contours.
|
let shaderProgram = null;
|
||||||
const renderScale = .82;
|
let posBuffer = null;
|
||||||
|
let normBuffer = null;
|
||||||
|
let vertexCount = 0;
|
||||||
|
let modelLoaded = false;
|
||||||
|
let modelName = 'vehicle_model.stl';
|
||||||
|
|
||||||
|
// Uniform & Attribute Locations
|
||||||
|
let locPos = -1;
|
||||||
|
let locNorm = -1;
|
||||||
|
let locMVP = null;
|
||||||
|
let locNormalMat = null;
|
||||||
|
let locModelMat = null;
|
||||||
|
let locTime = null;
|
||||||
|
let locLight1 = null;
|
||||||
|
let locLight2 = null;
|
||||||
|
|
||||||
|
// Camera & Orbit State
|
||||||
|
let yaw = -0.45;
|
||||||
|
let pitch = 0.35;
|
||||||
|
let targetYaw = -0.45;
|
||||||
|
let targetPitch = 0.35;
|
||||||
|
let zoom = 1.0;
|
||||||
|
let targetZoom = 1.0;
|
||||||
|
let dragging = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
let startTime = performance.now();
|
||||||
|
let isRendering = false;
|
||||||
|
|
||||||
|
// Initialize WebGL
|
||||||
|
function initGL() {
|
||||||
|
if (gl) return true;
|
||||||
|
try {
|
||||||
|
gl = canvas.getContext('webgl', {
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
depth: true,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
|
}) || canvas.getContext('experimental-webgl', { antialias: true, alpha: true, depth: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WebGL initialization error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gl) return false;
|
||||||
|
|
||||||
|
const vsSource = `
|
||||||
|
attribute vec3 a_position;
|
||||||
|
attribute vec3 a_normal;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
uniform mat3 u_normalMat;
|
||||||
|
uniform mat4 u_modelMat;
|
||||||
|
varying vec3 v_normal;
|
||||||
|
varying vec3 v_modelPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
v_normal = normalize(u_normalMat * a_normal);
|
||||||
|
v_modelPos = a_position;
|
||||||
|
gl_Position = u_mvp * vec4(a_position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fsSource = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec3 v_normal;
|
||||||
|
varying vec3 v_modelPos;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform vec3 u_light1;
|
||||||
|
uniform vec3 u_light2;
|
||||||
|
|
||||||
|
// ARGO subsea caustic wave generator
|
||||||
|
float getCaustics(vec2 p, float t) {
|
||||||
|
vec2 p1 = p * 3.8 + vec2(t * 0.35, t * 0.25);
|
||||||
|
vec2 p2 = p * 6.2 - vec2(t * 0.28, -t * 0.38);
|
||||||
|
vec2 p3 = p * 8.5 + vec2(-t * 0.18, t * 0.42);
|
||||||
|
|
||||||
|
float c = sin(p1.x + sin(p1.y * 1.2 + t * 0.6)) * 0.5 + 0.5;
|
||||||
|
c += sin(p2.y + cos(p2.x * 1.1 - t * 0.5)) * 0.5 + 0.5;
|
||||||
|
c += sin(p3.x * 0.8 + p3.y * 0.9 + t * 0.9) * 0.5 + 0.5;
|
||||||
|
return pow(c / 3.0, 3.0) * 2.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 n = normalize(v_normal);
|
||||||
|
if (n.z < 0.0) n = -n; // Two-sided CAD shading
|
||||||
|
|
||||||
|
vec3 viewDir = vec3(0.0, 0.0, 1.0);
|
||||||
|
vec3 l1 = normalize(u_light1);
|
||||||
|
vec3 l2 = normalize(u_light2);
|
||||||
|
|
||||||
|
// Diffuse & Ambient Lighting (Soft, lifted shadows)
|
||||||
|
float diff1 = max(dot(n, l1), 0.0);
|
||||||
|
float diff2 = max(dot(n, l2), 0.0) * 0.30;
|
||||||
|
float ambient = 0.46;
|
||||||
|
|
||||||
|
// Matte Specular (Soft diffuse sheen, no harsh shine)
|
||||||
|
vec3 h1 = normalize(l1 + viewDir);
|
||||||
|
float spec = pow(max(dot(n, h1), 0.0), 10.0) * 0.07;
|
||||||
|
|
||||||
|
// Subtle Cyan Fresnel Accent
|
||||||
|
float fresnel = pow(1.0 - max(dot(n, viewDir), 0.0), 2.8);
|
||||||
|
|
||||||
|
// Soft Ambient Occlusion (Gentle curvature without heavy dark shadows)
|
||||||
|
float dist = length(v_modelPos);
|
||||||
|
float ao = clamp(0.76 + 0.24 * (dist * 0.9 + abs(n.y) * 0.15), 0.68, 1.0);
|
||||||
|
|
||||||
|
// Caustic ripple projection
|
||||||
|
float caust = getCaustics(v_modelPos.xz + v_modelPos.yy * 0.25, u_time) * max(n.y * 0.65 + 0.35, 0.0);
|
||||||
|
|
||||||
|
// Clean Matte White Palette
|
||||||
|
vec3 hullDark = vec3(0.66, 0.72, 0.76);
|
||||||
|
vec3 hullLit = vec3(0.96, 0.98, 1.00);
|
||||||
|
vec3 cyanGlow = vec3(0.35, 0.88, 0.98);
|
||||||
|
vec3 caustColor = vec3(0.55, 0.92, 1.00);
|
||||||
|
|
||||||
|
// Final Composite
|
||||||
|
vec3 color = mix(hullDark, hullLit, clamp((diff1 * 0.50 + diff2 + ambient) * ao, 0.0, 1.0));
|
||||||
|
color += caustColor * caust * 0.18;
|
||||||
|
color += vec3(1.0) * spec;
|
||||||
|
color += cyanGlow * fresnel * 0.18;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createShader(type, src) {
|
||||||
|
const s = gl.createShader(type);
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||||
|
console.error('Shader compile error:', gl.getShaderInfoLog(s));
|
||||||
|
gl.deleteShader(s);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vs = createShader(gl.VERTEX_SHADER, vsSource);
|
||||||
|
const fs = createShader(gl.FRAGMENT_SHADER, fsSource);
|
||||||
|
if (!vs || !fs) return false;
|
||||||
|
|
||||||
|
shaderProgram = gl.createProgram();
|
||||||
|
gl.attachShader(shaderProgram, vs);
|
||||||
|
gl.attachShader(shaderProgram, fs);
|
||||||
|
gl.linkProgram(shaderProgram);
|
||||||
|
|
||||||
|
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||||
|
console.error('Program link error:', gl.getProgramInfoLog(shaderProgram));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
locPos = gl.getAttribLocation(shaderProgram, 'a_position');
|
||||||
|
locNorm = gl.getAttribLocation(shaderProgram, 'a_normal');
|
||||||
|
locMVP = gl.getUniformLocation(shaderProgram, 'u_mvp');
|
||||||
|
locNormalMat = gl.getUniformLocation(shaderProgram, 'u_normalMat');
|
||||||
|
locModelMat = gl.getUniformLocation(shaderProgram, 'u_modelMat');
|
||||||
|
locTime = gl.getUniformLocation(shaderProgram, 'u_time');
|
||||||
|
locLight1 = gl.getUniformLocation(shaderProgram, 'u_light1');
|
||||||
|
locLight2 = gl.getUniformLocation(shaderProgram, 'u_light2');
|
||||||
|
|
||||||
|
posBuffer = gl.createBuffer();
|
||||||
|
normBuffer = gl.createBuffer();
|
||||||
|
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.depthFunc(gl.LEQUAL);
|
||||||
|
gl.clearDepth(1.0);
|
||||||
|
gl.disable(gl.CULL_FACE);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix math
|
||||||
|
function mat4Perspective(out, fovy, aspect, near, far) {
|
||||||
|
const f = 1.0 / Math.tan(fovy / 2);
|
||||||
|
const nf = 1 / (near - far);
|
||||||
|
out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0;
|
||||||
|
out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0;
|
||||||
|
out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1;
|
||||||
|
out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4Multiply(out, a, b) {
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const c4 = c * 4;
|
||||||
|
const b0 = b[c4], b1 = b[c4 + 1], b2 = b[c4 + 2], b3 = b[c4 + 3];
|
||||||
|
out[c4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
|
||||||
|
out[c4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
|
||||||
|
out[c4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
|
||||||
|
out[c4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast Binary & ASCII STL Parser
|
||||||
|
function parseSTL(buffer) {
|
||||||
|
const isBinary = buffer.byteLength >= 84 &&
|
||||||
|
(84 + new DataView(buffer).getUint32(80, true) * 50 <= buffer.byteLength);
|
||||||
|
|
||||||
|
let positions, normals;
|
||||||
|
|
||||||
function parseStl(buffer) {
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
let raw = [];
|
|
||||||
const binaryCount = buffer.byteLength >= 84 ? view.getUint32(80, true) : 0;
|
|
||||||
const isBinary = binaryCount > 0 && 84 + binaryCount * 50 <= buffer.byteLength;
|
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
for (let t = 0; t < binaryCount; t++) {
|
const view = new DataView(buffer);
|
||||||
const offset = 84 + t * 50 + 12;
|
const count = view.getUint32(80, true);
|
||||||
for (let v = 0; v < 3; v++) {
|
if (count === 0) throw new Error('Empty binary STL');
|
||||||
raw.push(view.getFloat32(offset + v * 12, true));
|
|
||||||
raw.push(view.getFloat32(offset + v * 12 + 4, true));
|
positions = new Float32Array(count * 9);
|
||||||
raw.push(view.getFloat32(offset + v * 12 + 8, true));
|
normals = new Float32Array(count * 9);
|
||||||
|
|
||||||
|
let pIdx = 0, nIdx = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const off = 84 + i * 50;
|
||||||
|
let nx = view.getFloat32(off, true);
|
||||||
|
let ny = view.getFloat32(off + 4, true);
|
||||||
|
let nz = view.getFloat32(off + 8, true);
|
||||||
|
|
||||||
|
const v0x = view.getFloat32(off + 12, true);
|
||||||
|
const v0y = view.getFloat32(off + 16, true);
|
||||||
|
const v0z = view.getFloat32(off + 20, true);
|
||||||
|
|
||||||
|
const v1x = view.getFloat32(off + 24, true);
|
||||||
|
const v1y = view.getFloat32(off + 28, true);
|
||||||
|
const v1z = view.getFloat32(off + 32, true);
|
||||||
|
|
||||||
|
const v2x = view.getFloat32(off + 36, true);
|
||||||
|
const v2y = view.getFloat32(off + 40, true);
|
||||||
|
const v2z = view.getFloat32(off + 44, true);
|
||||||
|
|
||||||
|
if (nx * nx + ny * ny + nz * nz < 1e-6) {
|
||||||
|
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
|
||||||
|
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
|
||||||
|
nx = ay * bz - az * by;
|
||||||
|
ny = az * bx - ax * bz;
|
||||||
|
nz = ax * by - ay * bx;
|
||||||
|
const len = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
nx /= len; ny /= len; nz /= len;
|
||||||
|
}
|
||||||
|
|
||||||
|
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
|
||||||
|
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
|
||||||
|
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
|
||||||
|
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
normals[nIdx++] = nx;
|
||||||
|
normals[nIdx++] = ny;
|
||||||
|
normals[nIdx++] = nz;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const text = new TextDecoder().decode(buffer);
|
const text = new TextDecoder().decode(buffer);
|
||||||
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
|
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
|
||||||
if (matches.length < 3) throw new Error('This file does not contain a readable STL mesh');
|
if (matches.length < 3) throw new Error('Invalid ASCII STL');
|
||||||
for (let i = 0; i + 2 < matches.length; i += 3) {
|
|
||||||
for (let j = 0; j < 3; j++) raw.push(+matches[i+j][1], +matches[i+j][2], +matches[i+j][3]);
|
const count = Math.floor(matches.length / 3);
|
||||||
|
positions = new Float32Array(count * 9);
|
||||||
|
normals = new Float32Array(count * 9);
|
||||||
|
|
||||||
|
let pIdx = 0, nIdx = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const i3 = i * 3;
|
||||||
|
const v0x = +matches[i3][1], v0y = +matches[i3][2], v0z = +matches[i3][3];
|
||||||
|
const v1x = +matches[i3 + 1][1], v1y = +matches[i3 + 1][2], v1z = +matches[i3 + 1][3];
|
||||||
|
const v2x = +matches[i3 + 2][1], v2y = +matches[i3 + 2][2], v2z = +matches[i3 + 2][3];
|
||||||
|
|
||||||
|
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
|
||||||
|
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
|
||||||
|
let nx = ay * bz - az * by;
|
||||||
|
let ny = az * bx - ax * bz;
|
||||||
|
let nz = ax * by - ay * bx;
|
||||||
|
const len = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
nx /= len; ny /= len; nz /= len;
|
||||||
|
|
||||||
|
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
|
||||||
|
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
|
||||||
|
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
|
||||||
|
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
normals[nIdx++] = nx;
|
||||||
|
normals[nIdx++] = ny;
|
||||||
|
normals[nIdx++] = nz;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (raw.length < 9) throw new Error('The STL mesh is empty');
|
|
||||||
let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity;
|
|
||||||
for(let i=0;i<raw.length;i+=3){minX=Math.min(minX,raw[i]);maxX=Math.max(maxX,raw[i]);minY=Math.min(minY,raw[i+1]);maxY=Math.max(maxY,raw[i+1]);minZ=Math.min(minZ,raw[i+2]);maxZ=Math.max(maxZ,raw[i+2])}
|
|
||||||
const cx=(minX+maxX)/2,cy=(minY+maxY)/2,cz=(minZ+maxZ)/2,scale=2/Math.max(maxX-minX,maxY-minY,maxZ-minZ,1e-9);
|
|
||||||
for(let i=0;i<raw.length;i+=3){raw[i]=(raw[i]-cx)*scale;raw[i+1]=(raw[i+1]-cy)*scale;raw[i+2]=(raw[i+2]-cz)*scale}
|
|
||||||
return new Float32Array(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
// Center to origin and scale to fit unit sphere
|
||||||
frame=0;if(!vertices)return;
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||||
|
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
|
||||||
|
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||||
|
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rect = canvas.getBoundingClientRect();
|
||||||
const width=Math.max(1,Math.round(rect.width*renderScale)),height=Math.max(1,Math.round(rect.height*renderScale));
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
|
const width = Math.max(10, Math.round(rect.width * dpr));
|
||||||
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height);
|
const height = Math.max(10, Math.round(rect.height * dpr));
|
||||||
const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d');
|
|
||||||
mask.width=width;mask.height=height;
|
|
||||||
const size=Math.min(width,height)*.43*zoom,cx=width/2,cy=height/2;
|
|
||||||
const cosY=Math.cos(yaw),sinY=Math.sin(yaw),cosP=Math.cos(pitch),sinP=Math.sin(pitch);
|
|
||||||
const project=(x,y,z)=>{
|
|
||||||
const rx=x*cosY+z*sinY,rz=-x*sinY+z*cosY;
|
|
||||||
return [rx,y*cosP-rz*sinP];
|
|
||||||
};
|
|
||||||
maskCtx.fillStyle='#fff';
|
|
||||||
maskCtx.beginPath();
|
|
||||||
for(let i=0;i<vertices.length;i+=9){
|
|
||||||
const a=project(vertices[i],vertices[i+1],vertices[i+2]);
|
|
||||||
const b=project(vertices[i+3],vertices[i+4],vertices[i+5]);
|
|
||||||
const c=project(vertices[i+6],vertices[i+7],vertices[i+8]);
|
|
||||||
maskCtx.moveTo(cx+a[0]*size,cy-a[1]*size);
|
|
||||||
maskCtx.lineTo(cx+b[0]*size,cy-b[1]*size);
|
|
||||||
maskCtx.lineTo(cx+c[0]*size,cy-c[1]*size);
|
|
||||||
maskCtx.closePath();
|
|
||||||
}
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.viewport(0, 0, width, height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.useProgram(shaderProgram);
|
||||||
|
|
||||||
|
// Matrix calculations
|
||||||
|
const aspect = width / height;
|
||||||
|
const proj = new Float32Array(16);
|
||||||
|
mat4Perspective(proj, (42 * Math.PI) / 180, aspect, 0.1, 50.0);
|
||||||
|
|
||||||
|
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
|
||||||
|
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
|
||||||
|
const dist = 3.2 / zoom;
|
||||||
|
|
||||||
|
const model = new Float32Array(16);
|
||||||
|
model[0] = cosY;
|
||||||
|
model[1] = sinP * sinY;
|
||||||
|
model[2] = -cosP * sinY;
|
||||||
|
model[3] = 0;
|
||||||
|
|
||||||
|
model[4] = 0;
|
||||||
|
model[5] = cosP;
|
||||||
|
model[6] = sinP;
|
||||||
|
model[7] = 0;
|
||||||
|
|
||||||
|
model[8] = sinY;
|
||||||
|
model[9] = -sinP * cosY;
|
||||||
|
model[10] = cosP * cosY;
|
||||||
|
model[11] = 0;
|
||||||
|
|
||||||
|
model[12] = 0;
|
||||||
|
model[13] = 0;
|
||||||
|
model[14] = -dist;
|
||||||
|
model[15] = 1;
|
||||||
|
|
||||||
|
const mvp = new Float32Array(16);
|
||||||
|
mat4Multiply(mvp, proj, model);
|
||||||
|
|
||||||
|
const normalMat = new Float32Array([
|
||||||
|
model[0], model[1], model[2],
|
||||||
|
model[4], model[5], model[6],
|
||||||
|
model[8], model[9], model[10],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const elapsedTime = (performance.now() - startTime) * 0.001;
|
||||||
|
|
||||||
|
gl.uniformMatrix4fv(locMVP, false, mvp);
|
||||||
|
gl.uniformMatrix3fv(locNormalMat, false, normalMat);
|
||||||
|
gl.uniformMatrix4fv(locModelMat, false, model);
|
||||||
|
gl.uniform1f(locTime, elapsedTime);
|
||||||
|
gl.uniform3f(locLight1, 0.65, 0.85, 0.60);
|
||||||
|
gl.uniform3f(locLight2, -0.60, -0.40, 0.30);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
|
||||||
|
gl.enableVertexAttribArray(locPos);
|
||||||
|
gl.vertexAttribPointer(locPos, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
|
||||||
|
gl.enableVertexAttribArray(locNorm);
|
||||||
|
gl.vertexAttribPointer(locNorm, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
|
||||||
|
|
||||||
|
// Continue animation loop for dynamic caustic wave shimmer
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (!isRendering && modelLoaded) {
|
||||||
|
isRendering = true;
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbit Pointer Interaction
|
||||||
|
canvas.addEventListener('pointerdown', e => {
|
||||||
|
dragging = true;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', e => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = e.clientX - lastX;
|
||||||
|
const dy = e.clientY - lastY;
|
||||||
|
targetYaw += dx * 0.012;
|
||||||
|
targetPitch = Math.max(-1.45, Math.min(1.45, targetPitch + dy * 0.012));
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
dragging = false;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerup', stopDrag);
|
||||||
|
canvas.addEventListener('pointercancel', stopDrag);
|
||||||
|
|
||||||
|
canvas.addEventListener('wheel', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.90 : 1.11;
|
||||||
|
targetZoom = Math.max(0.45, Math.min(3.5, targetZoom * factor));
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('dblclick', () => {
|
||||||
|
targetYaw = -0.45;
|
||||||
|
targetPitch = 0.35;
|
||||||
|
targetZoom = 1.0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Early context init
|
||||||
|
initGL();
|
||||||
|
|
||||||
|
// Public Interface
|
||||||
window.rovModel = {
|
window.rovModel = {
|
||||||
loadBuffer(buffer, name) {
|
loadBuffer(buffer, name = 'Main Assembly V1 (2).stl') {
|
||||||
vertices=parseStl(buffer);stage.classList.add('model-loaded');
|
console.log(`[ROV 3D] Loading STL buffer "${name}" (${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB)...`);
|
||||||
document.querySelector('#model-file-name').textContent=name;
|
const statusEl = document.querySelector('#model-status');
|
||||||
yaw=0;pitch=0;zoom=1;
|
if (statusEl) {
|
||||||
requestDraw();
|
statusEl.className = 'model-status';
|
||||||
return {name,triangles:vertices.length/9};
|
statusEl.textContent = `Parsing ${name}…`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mesh = parseSTL(buffer);
|
||||||
|
vertexCount = mesh.count;
|
||||||
|
modelLoaded = true;
|
||||||
|
modelName = name;
|
||||||
|
|
||||||
|
initGL();
|
||||||
|
|
||||||
|
if (gl) {
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
|
||||||
|
} else {
|
||||||
|
throw new Error('WebGL context unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.classList.add('model-loaded');
|
||||||
|
targetYaw = -0.45;
|
||||||
|
targetPitch = 0.35;
|
||||||
|
targetZoom = 1.0;
|
||||||
|
yaw = targetYaw;
|
||||||
|
pitch = targetPitch;
|
||||||
|
zoom = targetZoom;
|
||||||
|
|
||||||
|
startRenderLoop();
|
||||||
|
const tris = vertexCount / 3;
|
||||||
|
console.log(`[ROV 3D] Successfully parsed & uploaded ${tris.toLocaleString()} triangles to GPU.`);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = `${name} • ${tris.toLocaleString()} \u0394`;
|
||||||
|
statusEl.classList.remove('error');
|
||||||
|
setTimeout(() => statusEl.classList.add('fade-out'), 3500);
|
||||||
|
}
|
||||||
|
return { name, triangles: tris };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ROV 3D Error]', err);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.className = 'model-status error';
|
||||||
|
statusEl.textContent = `STL Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
load(payload) {
|
load(payload) {
|
||||||
if (!payload?.data) throw new Error('The selected STL returned no mesh data');
|
if (!payload) throw new Error('No model payload provided');
|
||||||
const binary=atob(payload.data),bytes=new Uint8Array(binary.length);
|
if (payload instanceof ArrayBuffer) {
|
||||||
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);
|
return this.loadBuffer(payload);
|
||||||
return this.loadBuffer(bytes.buffer,payload.name);
|
}
|
||||||
|
if (payload.buffer instanceof ArrayBuffer) {
|
||||||
|
return this.loadBuffer(payload.buffer, payload.name);
|
||||||
|
}
|
||||||
|
let rawBase64 = '';
|
||||||
|
let name = 'vehicle_model.stl';
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
rawBase64 = payload.includes(',') ? payload.split(',')[1] : payload;
|
||||||
|
} else if (payload.data) {
|
||||||
|
rawBase64 = payload.data.includes(',') ? payload.data.split(',')[1] : payload.data;
|
||||||
|
name = payload.name || name;
|
||||||
|
}
|
||||||
|
const binary = atob(rawBase64);
|
||||||
|
const len = binary.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return this.loadBuffer(bytes.buffer, name);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetView() {
|
||||||
|
targetYaw = -0.45;
|
||||||
|
targetPitch = 0.35;
|
||||||
|
targetZoom = 1.0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Immediate auto-fetch of runtime default model
|
||||||
|
for (const url of [
|
||||||
|
'/model.stl',
|
||||||
|
'/model',
|
||||||
|
'/runtime/vehicle_model.stl',
|
||||||
|
'/assets/Main%20Assembly%20V1%20(2).stl',
|
||||||
|
'../runtime/vehicle_model.stl',
|
||||||
|
'../assets/Main%20Assembly%20V1%20(2).stl'
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.ok ? r.arrayBuffer() : null)
|
||||||
|
.then(buf => {
|
||||||
|
if (buf && !modelLoaded) {
|
||||||
|
window.rovModel.loadBuffer(buf, 'Main Assembly V1 (2).stl');
|
||||||
|
const btn = document.querySelector('#load-model');
|
||||||
|
if (btn) btn.textContent = 'Replace STL';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1069
-12
File diff suppressed because it is too large
Load Diff
+167
-17
@@ -6,6 +6,10 @@ import queue
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
|
import shutil
|
||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
@@ -56,6 +60,7 @@ class ControlApi:
|
|||||||
"ports": [p.device for p in list_ports.comports()],
|
"ports": [p.device for p in list_ports.comports()],
|
||||||
"battery_config": self.battery_config,
|
"battery_config": self.battery_config,
|
||||||
"state": self._snapshot(),
|
"state": self._snapshot(),
|
||||||
|
"model_info": self.get_saved_model_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def refresh_ports(self):
|
def refresh_ports(self):
|
||||||
@@ -81,6 +86,56 @@ class ControlApi:
|
|||||||
self._window.destroy()
|
self._window.destroy()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def choose_model(self):
|
||||||
|
selected = None
|
||||||
|
# 1. Native Windows topmost open file dialog (instant, reliable)
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
file_path = filedialog.askopenfilename(
|
||||||
|
title="Select 3D STL Vehicle Model",
|
||||||
|
initialdir=str(Path.home() / "Downloads"),
|
||||||
|
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
|
||||||
|
)
|
||||||
|
root.destroy()
|
||||||
|
if file_path:
|
||||||
|
selected = (file_path,)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Fallback to pywebview create_file_dialog
|
||||||
|
if not selected and self._window:
|
||||||
|
try:
|
||||||
|
selected = self._window.create_file_dialog(
|
||||||
|
webview.FileDialog.OPEN,
|
||||||
|
directory=str(Path.home()),
|
||||||
|
allow_multiple=False,
|
||||||
|
file_types=("STL mesh (*.stl)", "All files (*.*)"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
selected = None
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(selected[0])
|
||||||
|
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
|
||||||
|
raise ValueError("Choose an STL mesh")
|
||||||
|
if path.stat().st_size > 30 * 1024 * 1024:
|
||||||
|
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
|
||||||
|
content = path.read_bytes()
|
||||||
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
|
self.model_cache.write_bytes(content)
|
||||||
|
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
|
||||||
|
|
||||||
def save_model(self, name, data):
|
def save_model(self, name, data):
|
||||||
safe_name = Path(str(name)).name
|
safe_name = Path(str(name)).name
|
||||||
if not safe_name.lower().endswith(".stl"):
|
if not safe_name.lower().endswith(".stl"):
|
||||||
@@ -96,24 +151,55 @@ class ControlApi:
|
|||||||
self.model_cache_name.write_text(safe_name, encoding="utf-8")
|
self.model_cache_name.write_text(safe_name, encoding="utf-8")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def saved_model(self):
|
def get_saved_model_info(self):
|
||||||
|
self._migrate_legacy_model()
|
||||||
try:
|
try:
|
||||||
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||||
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
|
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||||
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
|
return {"name": name, "size": self.model_cache.stat().st_size}
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
try:
|
|
||||||
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
|
||||||
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
|
||||||
return self._model_payload(path)
|
|
||||||
except (OSError, ValueError, TypeError, KeyError):
|
|
||||||
pass
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
def read_saved_model_chunk(self, offset, size=262144):
|
||||||
def _model_payload(path):
|
offset, size = int(offset), min(int(size), 524288)
|
||||||
return {"name": path.name, "data": base64.b64encode(path.read_bytes()).decode("ascii")}
|
if offset < 0 or size < 1 or not self.model_cache.is_file():
|
||||||
|
raise ValueError("Saved model is unavailable")
|
||||||
|
with self.model_cache.open("rb") as stream:
|
||||||
|
stream.seek(offset)
|
||||||
|
content = stream.read(size)
|
||||||
|
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
|
||||||
|
|
||||||
|
def _migrate_legacy_model(self):
|
||||||
|
if not self.model_cache.is_file():
|
||||||
|
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
||||||
|
if default_asset.is_file():
|
||||||
|
try:
|
||||||
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
|
shutil.copyfile(default_asset, self.model_cache)
|
||||||
|
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self.model_path.is_file():
|
||||||
|
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
||||||
|
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
||||||
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
|
shutil.copyfile(path, self.model_cache)
|
||||||
|
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def saved_model(self):
|
||||||
|
self._migrate_legacy_model()
|
||||||
|
try:
|
||||||
|
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||||
|
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||||
|
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def connect(self, port, baud=57600, rate=20):
|
def connect(self, port, baud=57600, rate=20):
|
||||||
if not port:
|
if not port:
|
||||||
@@ -146,6 +232,14 @@ class ControlApi:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def command(self, name):
|
def command(self, name):
|
||||||
|
if not self.worker.connection or not self.worker.target_system:
|
||||||
|
if name == "manual":
|
||||||
|
raise RuntimeError("Connect to vehicle to enable Manual mode")
|
||||||
|
elif name == "arm":
|
||||||
|
raise RuntimeError("Connect to vehicle before arming")
|
||||||
|
elif name == "disarm":
|
||||||
|
raise RuntimeError("Connect to vehicle before disarming")
|
||||||
|
raise RuntimeError("No autopilot heartbeat")
|
||||||
if name == "manual":
|
if name == "manual":
|
||||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
self.worker.set_manual_mode()
|
self.worker.set_manual_mode()
|
||||||
@@ -156,7 +250,7 @@ class ControlApi:
|
|||||||
self.worker.log("OPERATOR", "clicked DISARM")
|
self.worker.log("OPERATOR", "clicked DISARM")
|
||||||
self.worker.command_arm(False)
|
self.worker.command_arm(False)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown command")
|
raise ValueError(f"Unknown command: {name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_servo_function(self, servo, motor):
|
def set_servo_function(self, servo, motor):
|
||||||
@@ -279,23 +373,79 @@ class ControlApi:
|
|||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dwm_styling(window):
|
||||||
|
try:
|
||||||
|
hwnd = None
|
||||||
|
if hasattr(window, "native") and window.native:
|
||||||
|
hwnd = int(window.native.Handle.ToInt64())
|
||||||
|
if hwnd:
|
||||||
|
import ctypes
|
||||||
|
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||||
|
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||||
|
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
|
||||||
|
)
|
||||||
|
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||||
|
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # Suppress HTTP request logs in console
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
unquoted = urllib.parse.unquote(self.path)
|
||||||
|
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
|
||||||
|
project_root = Path(__file__).resolve().parent
|
||||||
|
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
|
||||||
|
if not model_file.is_file():
|
||||||
|
model_file = project_root / "runtime" / "vehicle_model.stl"
|
||||||
|
if model_file.is_file():
|
||||||
|
data = model_file.read_bytes()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "model/stl")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
|
||||||
|
def start_local_server(directory: Path) -> int:
|
||||||
|
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
||||||
|
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = server.server_address[1]
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
api = ControlApi()
|
api = ControlApi()
|
||||||
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
|
api._migrate_legacy_model()
|
||||||
|
project_root = Path(__file__).resolve().parent
|
||||||
|
port = start_local_server(project_root)
|
||||||
|
app_url = f"http://127.0.0.1:{port}/web/index.html"
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
"SUB Control", url=str(ui_path), js_api=api,
|
"SUB Control", url=app_url, js_api=api,
|
||||||
width=1120, height=760, min_size=(900, 650),
|
width=1400, height=808, min_size=(1125, 690),
|
||||||
background_color="#111719", frameless=True, easy_drag=False,
|
background_color="#111719", frameless=True, easy_drag=False,
|
||||||
shadow=True,
|
shadow=True,
|
||||||
)
|
)
|
||||||
api._window = window
|
api._window = window
|
||||||
|
window.events.shown += lambda: _apply_dwm_styling(window)
|
||||||
window.events.maximized += lambda: setattr(api, "maximized", True)
|
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||||
try:
|
try:
|
||||||
webview.start(debug=False)
|
webview.start(debug=True)
|
||||||
finally:
|
finally:
|
||||||
api.close()
|
api.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user