Replace hanging STL dialog with local file picker
This commit is contained in:
@@ -3,3 +3,4 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
logs/
|
logs/
|
||||||
model_settings.json
|
model_settings.json
|
||||||
|
runtime/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user