Replace hanging STL dialog with local file picker

This commit is contained in:
2026-08-25 22:33:27 +02:00
parent 85ba44281e
commit 411eb6397c
6 changed files with 39 additions and 30 deletions
+1
View File
@@ -3,3 +3,4 @@ __pycache__/
*.py[cod] *.py[cod]
logs/ logs/
model_settings.json model_settings.json
runtime/
+4 -4
View File
@@ -14,10 +14,10 @@ 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 WebView is frameless and uses the compact title bar drawn by the interface.
The vehicle rail includes a local STL viewer: choose **Load STL**, then drag to The vehicle rail includes a local STL viewer: choose **Load STL**, then drag to
orbit, use the mouse wheel to zoom, or switch between shaded and edge-focused orbit, use the mouse wheel to zoom, or switch between shaded and edge-focused
rendering. The chosen file is remembered in `model_settings.json`; only its rendering. A cached STL copy is stored locally under the ignored `runtime/`
local path is stored. STEP/STP CAD solids must first be tessellated/exported as directory so the model can be restored on the next launch.
STL because the embedded renderer intentionally does not include a full CAD STEP/STP CAD solids must first be tessellated/exported as STL because the
kernel. embedded renderer intentionally does not include a full CAD kernel.
## Safety first ## Safety first
+4 -1
View File
@@ -25,7 +25,10 @@ addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)ea
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){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'); $('#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');
$('#load-model').onclick=async e=>{const button=e.currentTarget,old=button.textContent;try{button.disabled=true;button.textContent='Loading…';const model=await api('choose_model');if(!model)return;await new Promise(requestAnimationFrame);const result=window.rovModel.load(model);button.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`)}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{button.disabled=false;if(button.textContent==='Loading…')button.textContent=old}};$('#model-style').onclick=e=>{const edges=window.rovModel.toggleStyle();e.currentTarget.textContent=edges?'Shaded':'Edges'}; const modelInput=$('#model-input'),modelButton=$('#load-model');
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
modelInput.onchange=async()=>{const file=modelInput.files?.[0],old=modelButton.textContent;if(!file)return;try{if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh');if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');modelButton.disabled=true;modelButton.textContent='Loading…';await new Promise(requestAnimationFrame);const buffer=await file.arrayBuffer(),result=window.rovModel.loadBuffer(buffer,file.name);modelButton.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);if(bridgeReady){const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=()=>reject(reader.error);reader.readAsDataURL(file)});api('save_model',file.name,String(dataUrl).split(',')[1]).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}};
$('#model-style').onclick=e=>{const edges=window.rovModel.toggleStyle();e.currentTarget.textContent=edges?'Shaded':'Edges'};
$('#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)}}; $('#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'); $('#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)}; $('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
+1
View File
@@ -46,6 +46,7 @@
<div class="model-tools"> <div class="model-tools">
<button id="load-model">Load STL</button> <button id="load-model">Load STL</button>
<button id="model-style" disabled>Edges</button> <button id="model-style" disabled>Edges</button>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" 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>
+8 -5
View File
@@ -65,15 +65,18 @@
addEventListener('resize',requestDraw); addEventListener('resize',requestDraw);
window.rovModel = { window.rovModel = {
loadBuffer(buffer, name) {
vertices=parseStl(buffer);stage.classList.add('model-loaded');
document.querySelector('#model-style').disabled=false;
document.querySelector('#model-file-name').textContent=name;
yaw=-.55;pitch=-.42;zoom=1;requestDraw();
return {name,triangles:vertices.length/9};
},
load(payload) { load(payload) {
if (!payload?.data) throw new Error('The selected STL returned no mesh data'); if (!payload?.data) throw new Error('The selected STL returned no mesh data');
const binary=atob(payload.data),bytes=new Uint8Array(binary.length); const binary=atob(payload.data),bytes=new Uint8Array(binary.length);
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i); for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);
vertices=parseStl(bytes.buffer);stage.classList.add('model-loaded'); return this.loadBuffer(bytes.buffer,payload.name);
document.querySelector('#model-style').disabled=false;
document.querySelector('#model-file-name').textContent=payload.name;
yaw=-.55;pitch=-.42;zoom=1;requestDraw();
return {name:payload.name,triangles:vertices.length/9};
}, },
toggleStyle() { edgesOnly=!edgesOnly;requestDraw();return edgesOnly; } toggleStyle() { edgesOnly=!edgesOnly;requestDraw();return edgesOnly; }
}; };
+21 -20
View File
@@ -35,6 +35,8 @@ class ControlApi:
self.motor_directions: dict[int, int] = {} self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json" self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_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"
# Leading underscore keeps the native WebView object out of js_api # Leading underscore keeps the native WebView object out of js_api
# serialization. Only the small public methods below are exposed. # serialization. Only the small public methods below are exposed.
self._window = None self._window = None
@@ -79,29 +81,28 @@ class ControlApi:
self._window.destroy() self._window.destroy()
return True return True
def choose_model(self): def save_model(self, name, data):
if not self._window: safe_name = Path(str(name)).name
raise RuntimeError("Window is not ready") if not safe_name.lower().endswith(".stl"):
selected = self._window.create_file_dialog(
webview.OPEN_DIALOG,
allow_multiple=False,
file_types=("STL mesh (*.stl)", "STEP model (*.step;*.stp)"),
)
if not selected:
return None
path = Path(selected[0])
if path.suffix.lower() in (".step", ".stp"):
raise ValueError(
"STEP is a CAD solid and must be tessellated first. Export it as a binary STL, then load that file."
)
if path.suffix.lower() != ".stl":
raise ValueError("Choose an STL mesh") raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024: try:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh") content = base64.b64decode(data, validate=True)
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8") except (ValueError, TypeError) as exc:
return self._model_payload(path) raise ValueError("Invalid STL data") from exc
if not content or len(content) > 30 * 1024 * 1024:
raise ValueError("STL must be between 1 byte and 30 MB")
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(safe_name, encoding="utf-8")
return True
def saved_model(self): def saved_model(self):
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
except (OSError, ValueError):
pass
try: try:
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"]) 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: if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024: