feat: WebGL 3D model viewer with smooth vertex normals, water caustics, soft cyan-slate material, floaty controls, and camera position mapping
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
logs/
|
||||||
|
model_settings.json
|
||||||
|
runtime/
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# SUB Control
|
||||||
|
|
||||||
|
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
|
||||||
|
ArduSub vehicle over a USB serial connection. It sends no parameter-list
|
||||||
|
request and no telemetry-stream request.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
|
||||||
|
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
|
||||||
|
arming, logging, output limiting, and parameter writes. It does not run a web
|
||||||
|
server, open a browser tab, or require an internet connection.
|
||||||
|
|
||||||
|
The WebView is frameless and uses the compact title bar drawn by the interface.
|
||||||
|
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
|
||||||
|
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
|
||||||
|
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
|
||||||
|
directory so the model can be restored on the next launch.
|
||||||
|
STEP/STP CAD solids must first be tessellated/exported as STL because the
|
||||||
|
embedded renderer intentionally does not include a full CAD kernel.
|
||||||
|
Binary and ASCII STL faces are preserved in full; large assemblies are not
|
||||||
|
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
|
||||||
|
and interactive orbiting responsive.
|
||||||
|
|
||||||
|
## Safety first
|
||||||
|
|
||||||
|
- Remove propellers for initial tests and restrain the vehicle.
|
||||||
|
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
|
||||||
|
leak/failsafe, and emergency-stop arrangements before arming.
|
||||||
|
- The application starts disarmed and limits pilot demand to 20% by default.
|
||||||
|
Once armed, control movement is sent directly with no additional gate.
|
||||||
|
- Closing or disconnecting sends neutral controls and a disarm request, but a
|
||||||
|
physical power cutoff is still required.
|
||||||
|
|
||||||
|
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
|
||||||
|
the frame mixing and generates the configured ESC outputs. For ArduSub,
|
||||||
|
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
|
||||||
|
|
||||||
|
## Run on Windows
|
||||||
|
|
||||||
|
1. Install Python 3.11 or newer. A virtual environment installs packages, but
|
||||||
|
it does not install Python itself.
|
||||||
|
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
|
||||||
|
choose **Run with PowerShell**).
|
||||||
|
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
|
||||||
|
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
|
||||||
|
4. Confirm `ArduSub` and a live heartbeat are shown.
|
||||||
|
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
|
||||||
|
6. Move the controls. Once armed, no additional enable button is needed.
|
||||||
|
|
||||||
|
`run.ps1` automatically creates a private `.venv` on the first run, installs
|
||||||
|
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
|
||||||
|
reuse the same environment and update missing dependencies when necessary.
|
||||||
|
The `.venv` directory is intentionally ignored by Git: virtual environments
|
||||||
|
contain machine-specific executables and can be recreated from
|
||||||
|
`requirements.txt`.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside
|
||||||
|
that repository containing this application. A Gitea **Project** is a planning
|
||||||
|
board for issues and tasks; source code is not attached to a Project board.
|
||||||
|
Git tracks the folder automatically because its files are committed on the
|
||||||
|
repository's `main` branch.
|
||||||
|
|
||||||
|
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
|
||||||
|
Q/E alternative yaw control, and R/F rise/dive.
|
||||||
|
Releasing movement keys eases their controls back to neutral.
|
||||||
|
|
||||||
|
## Three-thruster limitation
|
||||||
|
|
||||||
|
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
|
||||||
|
It cannot independently control lateral, roll, or pitch. Accordingly, the main
|
||||||
|
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
|
||||||
|
|
||||||
|
## Link behavior
|
||||||
|
|
||||||
|
- MAVLink GCS heartbeat: 1 Hz
|
||||||
|
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
|
||||||
|
intentionally excluded because they make short return ramps become abrupt
|
||||||
|
steps at the flight controller.
|
||||||
|
- No full parameter download
|
||||||
|
- On connection, inherited bulk telemetry streams are stopped. Only
|
||||||
|
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
|
||||||
|
requested, in addition to the normal heartbeat and event-driven status text.
|
||||||
|
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
|
||||||
|
- Link health is based on received autopilot heartbeats
|
||||||
|
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
|
||||||
|
estimated per-cell voltage, and a voltage-only percentage. Cell count,
|
||||||
|
capacity, C rating, full/empty cell voltages, and warning percentage are
|
||||||
|
configurable and saved locally in `battery_settings.json`. Capacity consumed
|
||||||
|
cannot be measured without a current sensor.
|
||||||
|
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
|
||||||
|
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
|
||||||
|
for each response and retrying missed replies. A selected output can be
|
||||||
|
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
|
||||||
|
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
|
||||||
|
intentionally not used for Motor functions. It never downloads the full
|
||||||
|
parameter list.
|
||||||
|
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
|
||||||
|
joystick gain values into the local session log; these values remain
|
||||||
|
read-only in the application.
|
||||||
|
- Every session creates a local text log under `logs/`, including operator
|
||||||
|
actions, outgoing manual-control values and commands, acknowledgements,
|
||||||
|
status text, and all received MAVLink messages. This does not use ArduPilot's
|
||||||
|
onboard logger or add any link traffic.
|
||||||
|
|
||||||
|
The car-style virtual joystick maps only to forward/reverse and continuous
|
||||||
|
differential turning; it never sends lateral motion. The joystick and vertical
|
||||||
|
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||||
|
be left commanding motion.
|
||||||
|
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
|
||||||
|
intentionally not eased.
|
||||||
|
|
||||||
|
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||||
|
0.8-second full-range slew limit. This makes both acceleration and deceleration
|
||||||
|
arrive at the flight controller as a sequence of stable intermediate values.
|
||||||
|
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
||||||
|
limit is applied, preventing their mixer contributions from adding beyond the
|
||||||
|
selected horizontal limit.
|
||||||
|
|
||||||
|
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
||||||
|
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
||||||
|
MAVLink input. Those parameters must already be correctly configured.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"cells": 4,
|
||||||
|
"capacity_mah": 5500,
|
||||||
|
"c_rating": 35.0,
|
||||||
|
"full_v_per_cell": 4.2,
|
||||||
|
"empty_v_per_cell": 3.3,
|
||||||
|
"warning_percent": 20
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
pymavlink>=2.4.49,<3
|
||||||
|
pyserial>=3.5,<4
|
||||||
|
customtkinter==5.2.2
|
||||||
|
pywebview==6.2.1
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location -LiteralPath $projectRoot
|
||||||
|
|
||||||
|
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
|
||||||
|
& py -3 -m venv .venv
|
||||||
|
}
|
||||||
|
|
||||||
|
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
& .venv\Scripts\python.exe app.py
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||||
|
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||||
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
|
window.showToast=toast;
|
||||||
|
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
||||||
|
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||||
|
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
||||||
|
|
||||||
|
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
|
||||||
|
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
||||||
|
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;
|
||||||
|
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();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
||||||
|
|
||||||
|
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()});
|
||||||
|
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||||
|
|
||||||
|
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
||||||
|
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
||||||
|
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
|
||||||
|
|
||||||
|
function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
|
||||||
|
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
|
||||||
|
const modelInput=$('#model-input'),modelButton=$('#load-model');
|
||||||
|
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
|
||||||
|
function bytesToBase64(bytes){let value='';for(let i=0;i<bytes.length;i+=0x8000)value+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(value)}
|
||||||
|
async function cacheModel(file){const token=await api('begin_model_save',file.name,file.size),chunk=196608;for(let offset=0;offset<file.size;offset+=chunk){const bytes=new Uint8Array(await file.slice(offset,offset+chunk).arrayBuffer());await api('append_model_chunk',token,bytesToBase64(bytes));if(offset% (chunk*4)===0)await new Promise(requestAnimationFrame)}await api('finish_model_save',token)}
|
||||||
|
async function loadCachedModel(info){const bytes=new Uint8Array(info.size),chunk=196608;for(let offset=0;offset<info.size;offset+=chunk){const response=await api('read_saved_model_chunk',offset,chunk),part=atob(response.data);for(let i=0;i<part.length;i++)bytes[offset+i]=part.charCodeAt(i);if(offset%(chunk*4)===0)await new Promise(requestAnimationFrame)}return window.rovModel.loadBuffer(bytes.buffer,info.name)}
|
||||||
|
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)cacheModel(file).then(()=>toast('STL saved for the next launch')).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}};
|
||||||
|
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
||||||
|
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
|
||||||
|
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
|
||||||
|
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||||
|
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||||
|
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
||||||
|
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
||||||
|
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||||
|
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('get_saved_model_info');if(saved){try{$('#load-model').textContent='Loading saved STL…';await loadCachedModel(saved);$('#load-model').textContent='Replace STL'}catch(error){$('#load-model').textContent='Load STL';toast(`Saved STL failed: ${error.message}`,true)}}poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}}
|
||||||
|
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
|
||||||
|
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>SUB Control</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="window-bar">
|
||||||
|
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
|
||||||
|
<div class="window-actions">
|
||||||
|
<button id="window-min" aria-label="Minimize">—</button>
|
||||||
|
<button id="window-max" aria-label="Maximize">□</button>
|
||||||
|
<button id="window-close" class="window-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-shell">
|
||||||
|
<aside class="vehicle-rail">
|
||||||
|
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
|
||||||
|
<div class="eyebrow">LIVE VEHICLE</div>
|
||||||
|
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||||
|
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||||
|
<svg id="default-model" viewBox="0 0 260 310" role="img">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
|
||||||
|
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
|
||||||
|
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
|
||||||
|
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
|
||||||
|
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
|
||||||
|
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
|
||||||
|
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#59656a" stroke-width="2">
|
||||||
|
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||||
|
<div class="model-tools">
|
||||||
|
<button id="load-model">Load STL</button>
|
||||||
|
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||||
|
<div class="rail-bottom">
|
||||||
|
<button class="battery-card ghost" data-open="battery-modal">
|
||||||
|
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i>
|
||||||
|
<b><em id="battery-fill"></em></b>
|
||||||
|
</button>
|
||||||
|
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
|
||||||
|
<div class="connection">
|
||||||
|
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||||
|
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
|
||||||
|
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
|
||||||
|
<button id="connect" class="button connect-button">Connect</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drive-grid">
|
||||||
|
<article class="control-card steer-card">
|
||||||
|
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
|
||||||
|
<div id="joystick" class="joystick" touch-action="none">
|
||||||
|
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
|
||||||
|
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
|
||||||
|
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
|
||||||
|
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
|
||||||
|
</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>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="control-card vertical-card">
|
||||||
|
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
|
||||||
|
<div class="vertical-control">
|
||||||
|
<span>RISE</span>
|
||||||
|
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
||||||
|
<span>DIVE</span>
|
||||||
|
</div>
|
||||||
|
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="command-bar">
|
||||||
|
<div class="commands">
|
||||||
|
<button id="manual" class="button subtle">Manual</button>
|
||||||
|
<button id="arm" class="button arm">Arm</button>
|
||||||
|
<button id="disarm" class="button danger">Disarm</button>
|
||||||
|
</div>
|
||||||
|
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
|
||||||
|
<button class="button subtle" data-open="motors-modal">Motor setup</button>
|
||||||
|
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
|
||||||
|
</section>
|
||||||
|
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
|
||||||
|
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||||
|
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
|
||||||
|
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||||
|
|
||||||
|
<script src="model-viewer.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
(() => {
|
||||||
|
const canvas = document.querySelector('#model-canvas');
|
||||||
|
const stage = document.querySelector('.rov-stage');
|
||||||
|
const renderScale = Math.max(2.0, window.devicePixelRatio || 2.0);
|
||||||
|
const gl = canvas.getContext('webgl', { alpha: true, antialias: true, preserveDrawingBuffer: false });
|
||||||
|
if (!gl) { window.rovModel = { loadBuffer() { throw new Error('WebGL is unavailable in this WebView'); } }; return; }
|
||||||
|
|
||||||
|
// Pass 1: Geometry shader - renders view-space normals and depth to FBO (Hero-sized 0.88 dynamic projection)
|
||||||
|
const vertexSceneSource = `
|
||||||
|
attribute vec3 aPosition;
|
||||||
|
attribute vec3 aNormal;
|
||||||
|
uniform float uYaw, uPitch, uZoom, uAspect;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vViewPos;
|
||||||
|
void main() {
|
||||||
|
float cy = cos(uYaw), sy = sin(uYaw);
|
||||||
|
vec3 p = vec3(aPosition.x * cy + aPosition.z * sy, aPosition.y, -aPosition.x * sy + aPosition.z * cy);
|
||||||
|
vec3 n = vec3(aNormal.x * cy + aNormal.z * sy, aNormal.y, -aNormal.x * sy + aNormal.z * cy);
|
||||||
|
float cp = cos(uPitch), sp = sin(uPitch);
|
||||||
|
p = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);
|
||||||
|
n = vec3(n.x, n.y * cp - n.z * sp, n.y * sp + n.z * cp);
|
||||||
|
|
||||||
|
// Base scale (1.15 fills 95-100% of canvas frame without over-zooming out)
|
||||||
|
float baseScale = 1.15 * uZoom;
|
||||||
|
|
||||||
|
// Dynamic pixel-perfect aspect projection: fills window canvas without distortion or shrinking
|
||||||
|
vec2 clipPos;
|
||||||
|
if (uAspect >= 1.0) {
|
||||||
|
clipPos = vec2(p.x * baseScale / uAspect, p.y * baseScale);
|
||||||
|
} else {
|
||||||
|
clipPos = vec2(p.x * baseScale, p.y * baseScale * uAspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_Position = vec4(clipPos.x, clipPos.y, p.z * baseScale * .4, 1.);
|
||||||
|
vViewPos = vec3(clipPos.x, clipPos.y, p.z * baseScale);
|
||||||
|
vNormal = normalize(n);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentSceneSource = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vViewPos;
|
||||||
|
void main() {
|
||||||
|
vec3 encNormal = normalize(vNormal) * .5 + .5;
|
||||||
|
float encDepth = clamp(vViewPos.z * .4 + .5, .01, .99);
|
||||||
|
gl_FragColor = vec4(encNormal, encDepth);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 2: Quad shader - Vibrant Soft Cyan-Slate Material + Water Caustics + Argo Glare (Non-grey)
|
||||||
|
const vertexQuadSource = `
|
||||||
|
attribute vec2 aQuadPos;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
void main() {
|
||||||
|
vTexCoord = aQuadPos * .5 + .5;
|
||||||
|
gl_Position = vec4(aQuadPos, 0., 1.);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentQuadSource = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uTex;
|
||||||
|
uniform vec2 uTexSize;
|
||||||
|
uniform float uTime;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
|
// Animated water caustics shader
|
||||||
|
float waterCaustics(vec2 uv, float t) {
|
||||||
|
vec2 p = uv * 8.0;
|
||||||
|
vec2 i = vec2(p);
|
||||||
|
float c = 1.0;
|
||||||
|
float intent = 0.14;
|
||||||
|
for (int n = 0; n < 4; n++) {
|
||||||
|
float t_ang = t * (0.8 - (2.2 / float(n + 1)));
|
||||||
|
vec2 coord = i + vec2(cos(t_ang - i.x) + sin(t_ang + i.y), sin(t_ang - i.y) + cos(t_ang + i.x));
|
||||||
|
c += 1.0 / length(vec2(i.x / (sin(coord.x + t_ang) / intent), i.y / (cos(coord.y + t_ang) / intent)));
|
||||||
|
i = coord;
|
||||||
|
}
|
||||||
|
c /= 4.0;
|
||||||
|
c = 1.15 - sqrt(c);
|
||||||
|
return clamp(pow(max(0.0, c), 2.2) * 1.4, 0.0, 1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 texel = 1. / uTexSize;
|
||||||
|
|
||||||
|
// Subpixel 4-sample Anti-Aliasing for smooth geometry boundaries
|
||||||
|
vec4 s0 = texture2D(uTex, vTexCoord + vec2(-0.25, -0.25) * texel);
|
||||||
|
vec4 s1 = texture2D(uTex, vTexCoord + vec2( 0.25, -0.25) * texel);
|
||||||
|
vec4 s2 = texture2D(uTex, vTexCoord + vec2(-0.25, 0.25) * texel);
|
||||||
|
vec4 s3 = texture2D(uTex, vTexCoord + vec2( 0.25, 0.25) * texel);
|
||||||
|
vec4 center = (s0 + s1 + s2 + s3) * 0.25;
|
||||||
|
|
||||||
|
float alphaCoverage = clamp((s0.a > .001 ? .25 : 0.) + (s1.a > .001 ? .25 : 0.) + (s2.a > .001 ? .25 : 0.) + (s3.a > .001 ? .25 : 0.), 0., 1.);
|
||||||
|
if (alphaCoverage <= .001) {
|
||||||
|
gl_FragColor = vec4(0., 0., 0., 0.);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 N = normalize(center.rgb * 2. - 1.);
|
||||||
|
float centerDepth = center.a;
|
||||||
|
|
||||||
|
// 1. Light Ambient Occlusion
|
||||||
|
float occlusion = 0.;
|
||||||
|
vec2 offsets[8];
|
||||||
|
offsets[0] = vec2(1.5, 0.); offsets[1] = vec2(-1.5, 0.);
|
||||||
|
offsets[2] = vec2(0., 1.5); offsets[3] = vec2(0., -1.5);
|
||||||
|
offsets[4] = vec2(1.1, 1.1); offsets[5] = vec2(-1.1, 1.1);
|
||||||
|
offsets[6] = vec2(1.1, -1.1); offsets[7] = vec2(-1.1, -1.1);
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
vec4 sampleVal = texture2D(uTex, vTexCoord + offsets[i] * texel);
|
||||||
|
if (sampleVal.a > .001) {
|
||||||
|
float dDiff = sampleVal.a - centerDepth;
|
||||||
|
if (dDiff < -.0005 && dDiff > -.12) {
|
||||||
|
occlusion += clamp(-dDiff * 14.0, 0., 1.);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float ao = 1. - clamp(occlusion / 8.0 * 0.36, 0., .20);
|
||||||
|
|
||||||
|
// 2. Soft Luminous Cyan-Slate Material Shading (Vibrant, non-grey)
|
||||||
|
vec3 lightDir = normalize(vec3(.35, .85, .45));
|
||||||
|
float diff = max(dot(N, lightDir), 0.0);
|
||||||
|
vec3 viewDir = vec3(0., 0., 1.);
|
||||||
|
vec3 halfDir = normalize(lightDir + viewDir);
|
||||||
|
float spec = pow(max(dot(N, halfDir), 0.0), 28.0);
|
||||||
|
|
||||||
|
// Argo crisp luminous glare
|
||||||
|
vec3 argoGlare = vec3(0.98, 0.96, 0.92) * spec * 0.95;
|
||||||
|
|
||||||
|
// Soft rim light
|
||||||
|
float fresnel = pow(1. - max(dot(N, viewDir), 0.), 2.0);
|
||||||
|
vec3 rimLight = vec3(0.65, 0.82, 0.95) * fresnel * 0.35;
|
||||||
|
|
||||||
|
// Soft vibrant cyan-slate gradient (non-grey, soft luminous depth)
|
||||||
|
vec3 baseShadow = vec3(.22, .44, .58);
|
||||||
|
vec3 baseBody = vec3(.38, .62, .78);
|
||||||
|
vec3 baseTop = vec3(.88, .92, .96);
|
||||||
|
|
||||||
|
float lFactor = clamp(diff * 0.7 + (N.y * 0.3 + 0.3), 0.0, 1.0);
|
||||||
|
vec3 bodyColor = mix(baseShadow, baseBody, smoothstep(0.0, 0.5, lFactor));
|
||||||
|
bodyColor = mix(bodyColor, baseTop, smoothstep(0.5, 1.0, lFactor));
|
||||||
|
|
||||||
|
// 3. Luminous Water Caustics on top/front facing surfaces
|
||||||
|
float topFacing = clamp(N.y * 0.7 + 0.45, 0.0, 1.0);
|
||||||
|
float causticsVal = waterCaustics(vTexCoord + vec2(sin(uTime * 0.2) * 0.04, uTime * 0.025), uTime * 0.75) * topFacing;
|
||||||
|
vec3 causticColor = vec3(0.30, 0.85, 1.0) * causticsVal * 1.1;
|
||||||
|
|
||||||
|
// Combine soft diffuse, vibrant cyan-slate body, rim light, glare, caustics, and AO
|
||||||
|
vec3 rawColor = (bodyColor + rimLight + argoGlare + causticColor) * ao;
|
||||||
|
|
||||||
|
// Soft filmic highlight compression (preserves vibrant colors without white crushing)
|
||||||
|
vec3 finalColor = rawColor / (rawColor + vec3(0.18)) * 1.08;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, alphaCoverage);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function compileShader(type, source) {
|
||||||
|
const s = gl.createShader(type);
|
||||||
|
gl.shaderSource(s, source);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(vSrc, fSrc) {
|
||||||
|
const p = gl.createProgram();
|
||||||
|
gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vSrc));
|
||||||
|
gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fSrc));
|
||||||
|
gl.linkProgram(p);
|
||||||
|
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const programScene = createProgram(vertexSceneSource, fragmentSceneSource);
|
||||||
|
const programQuad = createProgram(vertexQuadSource, fragmentQuadSource);
|
||||||
|
|
||||||
|
const locScene = {
|
||||||
|
aPosition: gl.getAttribLocation(programScene, 'aPosition'),
|
||||||
|
aNormal: gl.getAttribLocation(programScene, 'aNormal'),
|
||||||
|
uYaw: gl.getUniformLocation(programScene, 'uYaw'),
|
||||||
|
uPitch: gl.getUniformLocation(programScene, 'uPitch'),
|
||||||
|
uZoom: gl.getUniformLocation(programScene, 'uZoom'),
|
||||||
|
uAspect: gl.getUniformLocation(programScene, 'uAspect')
|
||||||
|
};
|
||||||
|
|
||||||
|
const locQuad = {
|
||||||
|
aQuadPos: gl.getAttribLocation(programQuad, 'aQuadPos'),
|
||||||
|
uTex: gl.getUniformLocation(programQuad, 'uTex'),
|
||||||
|
uTexSize: gl.getUniformLocation(programQuad, 'uTexSize'),
|
||||||
|
uTime: gl.getUniformLocation(programQuad, 'uTime')
|
||||||
|
};
|
||||||
|
|
||||||
|
const bufferScene = gl.createBuffer();
|
||||||
|
const bufferQuad = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
let fbo = null, fboTexture = null, fboDepth = null, fboWidth = 0, fboHeight = 0;
|
||||||
|
function updateFbo(w, h) {
|
||||||
|
if (fboWidth === w && fboHeight === h) return;
|
||||||
|
fboWidth = w; fboHeight = h;
|
||||||
|
if (fbo) { gl.deleteFramebuffer(fbo); gl.deleteTexture(fboTexture); gl.deleteRenderbuffer(fboDepth); }
|
||||||
|
fboTexture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
fboDepth = gl.createRenderbuffer();
|
||||||
|
gl.bindRenderbuffer(gl.RENDERBUFFER, fboDepth);
|
||||||
|
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h);
|
||||||
|
|
||||||
|
fbo = gl.createFramebuffer();
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTexture, 0);
|
||||||
|
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, fboDepth);
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertexCount = 0, frame = 0;
|
||||||
|
let yaw = 0, pitch = 0, zoom = 1;
|
||||||
|
let targetYaw = 0.68, targetPitch = 0.42, targetZoom = 1.0;
|
||||||
|
let yawVelocity = 0, pitchVelocity = 0;
|
||||||
|
let dragging = false, lastX = 0, lastY = 0;
|
||||||
|
let introAnimating = false, introStartTime = 0, startYaw = 0, startPitch = 0, startZoom = 1;
|
||||||
|
|
||||||
|
// Live joystick & vertical knob control inputs
|
||||||
|
let liveTurn = 0, liveForward = 0, liveVertical = 0;
|
||||||
|
let targetLiveTurn = 0, targetLiveForward = 0, targetLiveVertical = 0;
|
||||||
|
|
||||||
|
const timeStart = performance.now();
|
||||||
|
|
||||||
|
function normalize(data) {
|
||||||
|
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||||
|
for (let i = 0; i < data.length; i += 6) {
|
||||||
|
const x = data[i], y = data[i + 1], z = data[i + 2];
|
||||||
|
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
|
||||||
|
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
||||||
|
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
|
||||||
|
}
|
||||||
|
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
|
||||||
|
const scale = 2 / Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9);
|
||||||
|
for (let i = 0; i < data.length; i += 6) {
|
||||||
|
data[i] = (data[i] - cx) * scale;
|
||||||
|
data[i + 1] = (data[i + 1] - cy) * scale;
|
||||||
|
data[i + 2] = (data[i + 2] - cz) * scale;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth vertex normal computation (welds shared vertices to eliminate sharp polygon facet lines)
|
||||||
|
function parseStl(binary) {
|
||||||
|
const view = new DataView(binary), count = binary.byteLength >= 84 ? view.getUint32(80, true) : 0;
|
||||||
|
let rawTriangles = [];
|
||||||
|
|
||||||
|
if (count > 0 && 84 + count * 50 <= binary.byteLength) {
|
||||||
|
for (let t = 0; t < count; t++) {
|
||||||
|
const base = 84 + t * 50;
|
||||||
|
const v0x = view.getFloat32(base + 12, true), v0y = view.getFloat32(base + 16, true), v0z = view.getFloat32(base + 20, true);
|
||||||
|
const v1x = view.getFloat32(base + 24, true), v1y = view.getFloat32(base + 28, true), v1z = view.getFloat32(base + 32, true);
|
||||||
|
const v2x = view.getFloat32(base + 36, true), v2y = view.getFloat32(base + 40, true), v2z = view.getFloat32(base + 44, true);
|
||||||
|
rawTriangles.push(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = new TextDecoder().decode(binary), matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
|
||||||
|
const triCount = Math.floor(matches.length / 3);
|
||||||
|
if (triCount < 1) throw new Error('This file does not contain a readable STL mesh');
|
||||||
|
for (let i = 0; i + 2 < matches.length; i += 3) {
|
||||||
|
rawTriangles.push(
|
||||||
|
+matches[i][1], +matches[i][2], +matches[i][3],
|
||||||
|
+matches[i+1][1], +matches[i+1][2], +matches[i+1][3],
|
||||||
|
+matches[i+2][1], +matches[i+2][2], +matches[i+2][3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numTriangles = Math.floor(rawTriangles.length / 9);
|
||||||
|
const posMap = new Map();
|
||||||
|
const getVertexIndex = (x, y, z) => {
|
||||||
|
const key = `${Math.round(x * 10000)},${Math.round(y * 10000)},${Math.round(z * 10000)}`;
|
||||||
|
let idx = posMap.get(key);
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = posMap.size;
|
||||||
|
posMap.set(key, idx);
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
// First pass: Index vertices and accumulate smooth face normals
|
||||||
|
const triangleIndices = new Int32Array(numTriangles * 3);
|
||||||
|
for (let t = 0; t < numTriangles; t++) {
|
||||||
|
const b = t * 9;
|
||||||
|
triangleIndices[t * 3] = getVertexIndex(rawTriangles[b], rawTriangles[b+1], rawTriangles[b+2]);
|
||||||
|
triangleIndices[t * 3 + 1] = getVertexIndex(rawTriangles[b+3], rawTriangles[b+4], rawTriangles[b+5]);
|
||||||
|
triangleIndices[t * 3 + 2] = getVertexIndex(rawTriangles[b+6], rawTriangles[b+7], rawTriangles[b+8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numUnique = posMap.size;
|
||||||
|
const accumNormals = new Float32Array(numUnique * 3);
|
||||||
|
for (let t = 0; t < numTriangles; t++) {
|
||||||
|
const b = t * 9;
|
||||||
|
const v0x = rawTriangles[b], v0y = rawTriangles[b+1], v0z = rawTriangles[b+2];
|
||||||
|
const v1x = rawTriangles[b+3], v1y = rawTriangles[b+4], v1z = rawTriangles[b+5];
|
||||||
|
const v2x = rawTriangles[b+6], v2y = rawTriangles[b+7], v2z = rawTriangles[b+8];
|
||||||
|
|
||||||
|
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
|
||||||
|
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
|
||||||
|
const nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx;
|
||||||
|
|
||||||
|
const i0 = triangleIndices[t * 3], i1 = triangleIndices[t * 3 + 1], i2 = triangleIndices[t * 3 + 2];
|
||||||
|
accumNormals[i0 * 3] += nx; accumNormals[i0 * 3 + 1] += ny; accumNormals[i0 * 3 + 2] += nz;
|
||||||
|
accumNormals[i1 * 3] += nx; accumNormals[i1 * 3 + 1] += ny; accumNormals[i1 * 3 + 2] += nz;
|
||||||
|
accumNormals[i2 * 3] += nx; accumNormals[i2 * 3 + 1] += ny; accumNormals[i2 * 3 + 2] += nz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: Normalize smooth vertex normals
|
||||||
|
for (let i = 0; i < numUnique; i++) {
|
||||||
|
const b = i * 3;
|
||||||
|
const len = Math.hypot(accumNormals[b], accumNormals[b+1], accumNormals[b+2]) || 1;
|
||||||
|
accumNormals[b] /= len; accumNormals[b+1] /= len; accumNormals[b+2] /= len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Assemble final vertex array (6 floats per vertex)
|
||||||
|
const data = new Float32Array(numTriangles * 18);
|
||||||
|
let out = 0;
|
||||||
|
for (let t = 0; t < numTriangles; t++) {
|
||||||
|
const b = t * 9;
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
const vx = rawTriangles[b + v * 3], vy = rawTriangles[b + v * 3 + 1], vz = rawTriangles[b + v * 3 + 2];
|
||||||
|
const idx = triangleIndices[t * 3 + v];
|
||||||
|
data[out++] = vx; data[out++] = vy; data[out++] = vz;
|
||||||
|
data[out++] = accumNormals[idx * 3];
|
||||||
|
data[out++] = accumNormals[idx * 3 + 1];
|
||||||
|
data[out++] = accumNormals[idx * 3 + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalize(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
frame = 0;
|
||||||
|
if (!vertexCount) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(1, Math.round(rect.width * renderScale));
|
||||||
|
const height = Math.max(1, Math.round(rect.height * renderScale));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
updateFbo(width, height);
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const currentTime = (now - timeStart) * 0.001;
|
||||||
|
|
||||||
|
// Interpolate live control inputs (turn, forward, vertical)
|
||||||
|
liveTurn += (targetLiveTurn - liveTurn) * 0.15;
|
||||||
|
liveForward += (targetLiveForward - liveForward) * 0.15;
|
||||||
|
liveVertical += (targetLiveVertical - liveVertical) * 0.15;
|
||||||
|
|
||||||
|
// Handle auto-center 3/4 hero camera transition & floaty physics
|
||||||
|
if (introAnimating && !dragging) {
|
||||||
|
const elapsed = (now - introStartTime) / 1200;
|
||||||
|
if (elapsed >= 1.0) {
|
||||||
|
introAnimating = false;
|
||||||
|
yaw = targetYaw; pitch = targetPitch; zoom = targetZoom;
|
||||||
|
} else {
|
||||||
|
const t = 1 - Math.pow(1 - elapsed, 3);
|
||||||
|
yaw = startYaw + (targetYaw - startYaw) * t;
|
||||||
|
pitch = startPitch + (targetPitch - startPitch) * t;
|
||||||
|
zoom = startZoom + (targetZoom - startZoom) * t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yaw += yawVelocity;
|
||||||
|
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
|
||||||
|
zoom += (targetZoom - zoom) * 0.1;
|
||||||
|
yawVelocity *= 0.92;
|
||||||
|
pitchVelocity *= 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective yaw & pitch including live joystick/heave control offsets
|
||||||
|
const effectiveYaw = yaw + liveTurn * 0.7;
|
||||||
|
const effectivePitch = Math.max(-1.45, Math.min(1.45, pitch + (liveVertical * 0.65 + liveForward * 0.4)));
|
||||||
|
|
||||||
|
// Pass 1: Render Normals & Linear Depth to FBO
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.viewport(0, 0, width, height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.depthFunc(gl.LEQUAL);
|
||||||
|
|
||||||
|
gl.useProgram(programScene);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
|
||||||
|
gl.enableVertexAttribArray(locScene.aPosition);
|
||||||
|
gl.vertexAttribPointer(locScene.aPosition, 3, gl.FLOAT, false, 24, 0);
|
||||||
|
gl.enableVertexAttribArray(locScene.aNormal);
|
||||||
|
gl.vertexAttribPointer(locScene.aNormal, 3, gl.FLOAT, false, 24, 12);
|
||||||
|
|
||||||
|
gl.uniform1f(locScene.uYaw, effectiveYaw);
|
||||||
|
gl.uniform1f(locScene.uPitch, effectivePitch);
|
||||||
|
gl.uniform1f(locScene.uZoom, zoom);
|
||||||
|
gl.uniform1f(locScene.uAspect, width / height);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
|
||||||
|
|
||||||
|
// Pass 2: Soft "Eve"-style Material Pass to Canvas
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
gl.viewport(0, 0, width, height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
gl.disable(gl.DEPTH_TEST);
|
||||||
|
|
||||||
|
gl.useProgram(programQuad);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
|
||||||
|
gl.enableVertexAttribArray(locQuad.aQuadPos);
|
||||||
|
gl.vertexAttribPointer(locQuad.aQuadPos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
|
||||||
|
gl.uniform1i(locQuad.uTex, 0);
|
||||||
|
gl.uniform2f(locQuad.uTexSize, width, height);
|
||||||
|
gl.uniform1f(locQuad.uTime, currentTime);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
|
||||||
|
// Keep animation active if floaty velocity or control interpolation is active
|
||||||
|
if (Math.abs(yawVelocity) > 1e-4 || Math.abs(pitchVelocity) > 1e-4 ||
|
||||||
|
Math.abs(targetLiveTurn - liveTurn) > 1e-3 || Math.abs(targetLiveVertical - liveVertical) > 1e-3 ||
|
||||||
|
introAnimating) {
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDraw() {
|
||||||
|
if (!frame) frame = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', event => {
|
||||||
|
dragging = true;
|
||||||
|
introAnimating = false;
|
||||||
|
lastX = event.clientX;
|
||||||
|
lastY = event.clientY;
|
||||||
|
yawVelocity = 0;
|
||||||
|
pitchVelocity = 0;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', event => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = event.clientX - lastX;
|
||||||
|
const dy = event.clientY - lastY;
|
||||||
|
yawVelocity = dx * 0.0012;
|
||||||
|
pitchVelocity = dy * 0.0012;
|
||||||
|
yaw += yawVelocity;
|
||||||
|
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
|
||||||
|
lastX = event.clientX;
|
||||||
|
lastY = event.clientY;
|
||||||
|
requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerup', () => dragging = false);
|
||||||
|
canvas.addEventListener('pointercancel', () => dragging = false);
|
||||||
|
|
||||||
|
canvas.addEventListener('wheel', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
introAnimating = false;
|
||||||
|
targetZoom = Math.max(.55, Math.min(2.2, targetZoom * (event.deltaY > 0 ? .9 : 1.1)));
|
||||||
|
requestDraw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
addEventListener('resize', requestDraw);
|
||||||
|
|
||||||
|
window.rovModel = {
|
||||||
|
setLiveControl(turn, forward, vertical) {
|
||||||
|
targetLiveTurn = turn || 0;
|
||||||
|
targetLiveForward = forward || 0;
|
||||||
|
targetLiveVertical = vertical || 0;
|
||||||
|
requestDraw();
|
||||||
|
},
|
||||||
|
loadBuffer(data, name) {
|
||||||
|
const vertices = parseStl(data);
|
||||||
|
vertexCount = vertices.length / 6;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||||
|
stage.classList.add('model-loaded');
|
||||||
|
document.querySelector('#model-file-name').textContent = name;
|
||||||
|
|
||||||
|
// Auto-center camera transition to 3/4 corner hero shot upon load
|
||||||
|
yaw = 0;
|
||||||
|
pitch = 0;
|
||||||
|
zoom = 0.75;
|
||||||
|
targetYaw = 0.68;
|
||||||
|
targetPitch = 0.42;
|
||||||
|
targetZoom = 1.0;
|
||||||
|
yawVelocity = 0;
|
||||||
|
pitchVelocity = 0;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
introAnimating = true;
|
||||||
|
introStartTime = performance.now();
|
||||||
|
startYaw = yaw;
|
||||||
|
startPitch = pitch;
|
||||||
|
startZoom = zoom;
|
||||||
|
requestDraw();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
requestDraw();
|
||||||
|
return { name, triangles: vertexCount / 3 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
||||||
|
.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff}
|
||||||
|
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)}
|
||||||
|
.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718}
|
||||||
|
.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb}
|
||||||
|
.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544}
|
||||||
|
.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top}
|
||||||
|
.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f}
|
||||||
|
@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}}
|
||||||
|
.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px}
|
||||||
|
.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block;filter:drop-shadow(0 0 1px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.3))}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:.72;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import webview
|
||||||
|
from serial.tools import list_ports
|
||||||
|
|
||||||
|
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||||
|
|
||||||
|
|
||||||
|
class ControlApi:
|
||||||
|
"""Small, local-only bridge between the CSS UI and the MAVLink worker."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.events: queue.Queue = queue.Queue()
|
||||||
|
self.worker = MavlinkWorker(self.events)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.state = {
|
||||||
|
"connected": False,
|
||||||
|
"link": "DISCONNECTED",
|
||||||
|
"vehicle": "No heartbeat",
|
||||||
|
"armed": False,
|
||||||
|
"status": "Ready. Select a USB port to begin.",
|
||||||
|
"battery": None,
|
||||||
|
"log_path": None,
|
||||||
|
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||||
|
}
|
||||||
|
self.servo_functions: dict[int, int] = {}
|
||||||
|
self.motor_directions: dict[int, int] = {}
|
||||||
|
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||||
|
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
|
||||||
|
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||||
|
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
|
||||||
|
self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {}
|
||||||
|
self.model_lock = threading.Lock()
|
||||||
|
# Leading underscore keeps the native WebView object out of js_api
|
||||||
|
# serialization. Only the small public methods below are exposed.
|
||||||
|
self._window = None
|
||||||
|
self.maximized = False
|
||||||
|
self.battery_config = {
|
||||||
|
"cells": 4, "capacity_mah": 5500, "c_rating": 35,
|
||||||
|
"full_v_per_cell": 4.2, "empty_v_per_cell": 3.3,
|
||||||
|
"warning_percent": 20,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def initial_state(self):
|
||||||
|
return {
|
||||||
|
"ports": [p.device for p in list_ports.comports()],
|
||||||
|
"battery_config": self.battery_config,
|
||||||
|
"state": self._snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh_ports(self):
|
||||||
|
return [p.device for p in list_ports.comports()]
|
||||||
|
|
||||||
|
def minimize_window(self):
|
||||||
|
if self._window:
|
||||||
|
self._window.minimize()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_maximize_window(self):
|
||||||
|
if not self._window:
|
||||||
|
return False
|
||||||
|
if self.maximized:
|
||||||
|
self._window.restore()
|
||||||
|
else:
|
||||||
|
self._window.maximize()
|
||||||
|
self.maximized = not self.maximized
|
||||||
|
return self.maximized
|
||||||
|
|
||||||
|
def close_window(self):
|
||||||
|
if self._window:
|
||||||
|
self._window.destroy()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def begin_model_save(self, name, size):
|
||||||
|
safe_name = Path(str(name)).name
|
||||||
|
if not safe_name.lower().endswith(".stl"):
|
||||||
|
raise ValueError("Choose an STL mesh")
|
||||||
|
size = int(size)
|
||||||
|
if not 0 < size <= 30 * 1024 * 1024:
|
||||||
|
raise ValueError("STL must be between 1 byte and 30 MB")
|
||||||
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
temp = self.model_cache.parent / f".{token}.upload"
|
||||||
|
with self.model_lock:
|
||||||
|
self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def append_model_chunk(self, token, data):
|
||||||
|
try:
|
||||||
|
chunk = base64.b64decode(data, validate=True)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("Invalid STL data chunk") from exc
|
||||||
|
with self.model_lock:
|
||||||
|
upload = self.model_uploads.get(str(token))
|
||||||
|
if not upload:
|
||||||
|
raise ValueError("Model upload expired")
|
||||||
|
handle, temp, name, expected, received = upload
|
||||||
|
if received + len(chunk) > expected:
|
||||||
|
raise ValueError("Model upload is larger than expected")
|
||||||
|
handle.write(chunk)
|
||||||
|
self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def finish_model_save(self, token):
|
||||||
|
with self.model_lock:
|
||||||
|
upload = self.model_uploads.pop(str(token), None)
|
||||||
|
if not upload:
|
||||||
|
raise ValueError("Model upload expired")
|
||||||
|
handle, temp, name, expected, received = upload
|
||||||
|
handle.close()
|
||||||
|
if received != expected:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
raise ValueError("Model upload was incomplete")
|
||||||
|
os.replace(temp, self.model_cache)
|
||||||
|
self.model_cache_name.write_text(name, encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_saved_model_info(self):
|
||||||
|
self._migrate_legacy_model()
|
||||||
|
try:
|
||||||
|
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||||
|
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
|
||||||
|
return {"name": name, "size": self.model_cache.stat().st_size}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_saved_model_chunk(self, offset, size=196608):
|
||||||
|
offset, size = int(offset), min(int(size), 196608)
|
||||||
|
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 self.model_cache.is_file():
|
||||||
|
return
|
||||||
|
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:
|
||||||
|
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 (OSError, ValueError, TypeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self, port, baud=57600, rate=20):
|
||||||
|
if not port:
|
||||||
|
raise ValueError("Select a USB serial port")
|
||||||
|
self.worker.rate_hz = int(rate)
|
||||||
|
self.worker.connect(str(port), int(baud))
|
||||||
|
with self.lock:
|
||||||
|
self.state.update(
|
||||||
|
connected=True, link="CONNECTING",
|
||||||
|
status=f"Opening {port} at {baud} baud…",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.worker.disconnect()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_axes(self, forward, turn, vertical):
|
||||||
|
self.worker.set_axes(Axes(
|
||||||
|
max(-1.0, min(1.0, float(forward))),
|
||||||
|
0.0,
|
||||||
|
max(-1.0, min(1.0, float(vertical))),
|
||||||
|
max(-1.0, min(1.0, float(turn))),
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_gain(self, value):
|
||||||
|
value = max(5, min(50, int(value)))
|
||||||
|
self.worker.gain = value / 100.0
|
||||||
|
return value
|
||||||
|
|
||||||
|
def command(self, name):
|
||||||
|
if name == "manual":
|
||||||
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
|
self.worker.set_manual_mode()
|
||||||
|
elif name == "arm":
|
||||||
|
self.worker.log("OPERATOR", "clicked ARM")
|
||||||
|
self.worker.command_arm(True)
|
||||||
|
elif name == "disarm":
|
||||||
|
self.worker.log("OPERATOR", "clicked DISARM")
|
||||||
|
self.worker.command_arm(False)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown command")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_servo_function(self, servo, motor):
|
||||||
|
servo, motor = int(servo), int(motor)
|
||||||
|
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
|
||||||
|
raise ValueError("Invalid servo or motor")
|
||||||
|
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
|
||||||
|
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reverse_motor(self, servo):
|
||||||
|
servo = int(servo)
|
||||||
|
motor = motor_number_for_function(self.servo_functions.get(servo))
|
||||||
|
if motor is None:
|
||||||
|
raise ValueError(f"SERVO{servo} is not assigned to a motor")
|
||||||
|
current = self.motor_directions.get(motor)
|
||||||
|
if current is None:
|
||||||
|
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||||
|
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_battery(self, config):
|
||||||
|
clean = {
|
||||||
|
"cells": int(config["cells"]),
|
||||||
|
"capacity_mah": int(config["capacity_mah"]),
|
||||||
|
"c_rating": float(config["c_rating"]),
|
||||||
|
"full_v_per_cell": float(config["full_v_per_cell"]),
|
||||||
|
"empty_v_per_cell": float(config["empty_v_per_cell"]),
|
||||||
|
"warning_percent": int(config["warning_percent"]),
|
||||||
|
}
|
||||||
|
if clean["cells"] < 1 or clean["capacity_mah"] <= 0 or clean["c_rating"] <= 0:
|
||||||
|
raise ValueError("Battery cells, capacity and C rating must be positive")
|
||||||
|
if not clean["full_v_per_cell"] > clean["empty_v_per_cell"] > 0:
|
||||||
|
raise ValueError("Full voltage per cell must be greater than empty voltage")
|
||||||
|
if not 0 <= clean["warning_percent"] <= 100:
|
||||||
|
raise ValueError("Warning percentage must be between 0 and 100")
|
||||||
|
self.battery_config = clean
|
||||||
|
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def open_logs(self):
|
||||||
|
path = Path(__file__).resolve().parent / "logs"
|
||||||
|
path.mkdir(exist_ok=True)
|
||||||
|
os.startfile(path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = self.events.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
self._consume(event)
|
||||||
|
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
|
||||||
|
with self.lock:
|
||||||
|
self.state["link"] = (
|
||||||
|
"DATA OK / HEARTBEAT DELAYED"
|
||||||
|
if time.monotonic() - self.worker.last_rx < 2 else "ALL LINK DATA LOST"
|
||||||
|
)
|
||||||
|
return self._snapshot()
|
||||||
|
|
||||||
|
def _snapshot(self):
|
||||||
|
with self.lock:
|
||||||
|
return json.loads(json.dumps(self.state))
|
||||||
|
|
||||||
|
def _consume(self, event):
|
||||||
|
kind = event[0]
|
||||||
|
with self.lock:
|
||||||
|
if kind == "heartbeat":
|
||||||
|
_, system, autopilot, mode, armed = event
|
||||||
|
self.state.update(
|
||||||
|
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
||||||
|
armed=bool(armed),
|
||||||
|
)
|
||||||
|
elif kind in ("status", "statustext"):
|
||||||
|
self.state["status"] = event[1]
|
||||||
|
elif kind == "ack":
|
||||||
|
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||||
|
elif kind == "battery":
|
||||||
|
voltage = float(event[1])
|
||||||
|
cells = self.battery_config["cells"]
|
||||||
|
per_cell = voltage / cells
|
||||||
|
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
|
||||||
|
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
|
||||||
|
self.state["battery"] = {
|
||||||
|
"voltage": voltage, "per_cell": per_cell, "percent": percent,
|
||||||
|
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
|
||||||
|
}
|
||||||
|
elif kind == "servo_param":
|
||||||
|
_, param_id, value = event
|
||||||
|
servo = int(param_id[5:param_id.index("_")])
|
||||||
|
self.servo_functions[servo] = int(value)
|
||||||
|
self._update_servo(servo)
|
||||||
|
elif kind == "motor_direction":
|
||||||
|
_, motor, value = event
|
||||||
|
self.motor_directions[int(motor)] = int(value)
|
||||||
|
for servo in range(1, 17):
|
||||||
|
self._update_servo(servo)
|
||||||
|
elif kind == "param_saved":
|
||||||
|
self.state["status"] = f"Confirmed: {event[1]}={event[2]}"
|
||||||
|
elif kind == "log_path":
|
||||||
|
self.state["log_path"] = event[1]
|
||||||
|
elif kind == "error":
|
||||||
|
self.state["status"] = event[1]
|
||||||
|
elif kind == "disconnected":
|
||||||
|
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||||
|
armed=False, battery=None, status="Disconnected")
|
||||||
|
|
||||||
|
def _update_servo(self, servo):
|
||||||
|
function = self.servo_functions.get(servo)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
self.state["servos"][str(servo)] = {
|
||||||
|
"function": function,
|
||||||
|
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
|
||||||
|
"motor": motor,
|
||||||
|
"direction": self.motor_directions.get(motor) if motor else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.worker.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
api = ControlApi()
|
||||||
|
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
|
||||||
|
window = webview.create_window(
|
||||||
|
"SUB Control", url=str(ui_path), js_api=api,
|
||||||
|
width=1120, height=760, min_size=(900, 650),
|
||||||
|
background_color="#111719", frameless=True, easy_drag=False,
|
||||||
|
shadow=True,
|
||||||
|
)
|
||||||
|
api._window = window
|
||||||
|
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||||
|
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||||
|
try:
|
||||||
|
webview.start(debug=False)
|
||||||
|
finally:
|
||||||
|
api.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user