Render STL as fixed white top-view silhouette

This commit is contained in:
2026-08-25 22:40:48 +02:00
parent 411eb6397c
commit 8fed9dc61e
5 changed files with 34 additions and 33 deletions
+3 -4
View File
@@ -12,10 +12,9 @@ arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection. 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** to show a
orbit, use the mouse wheel to zoom, or switch between shaded and edge-focused fixed orthographic top-view silhouette. A cached STL copy is stored locally
rendering. A cached STL copy is stored locally under the ignored `runtime/` under the ignored `runtime/` directory so the model can be restored on the next launch.
directory so the model can be restored on the next launch.
STEP/STP CAD solids must first be tessellated/exported as STL because the STEP/STP CAD solids must first be tessellated/exported as STL because the
embedded renderer intentionally does not include a full CAD kernel. embedded renderer intentionally does not include a full CAD kernel.
-1
View File
@@ -28,7 +28,6 @@ $('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=
const modelInput=$('#model-input'),modelButton=$('#load-model'); const modelInput=$('#model-input'),modelButton=$('#load-model');
modelButton.onclick=()=>{modelInput.value='';modelInput.click()}; 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}}; 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
@@ -45,7 +45,6 @@
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<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>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden> <input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
</div> </div>
</div> </div>
+30 -26
View File
@@ -2,8 +2,8 @@
const canvas = document.querySelector('#model-canvas'); const canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage'); const stage = document.querySelector('.rov-stage');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let vertices = null, yaw = -.55, pitch = -.42, zoom = 1, edgesOnly = false; let vertices = null, frame = 0;
let dragging = false, lastX = 0, lastY = 0, frame = 0; const renderScale = .62;
function parseStl(buffer) { function parseStl(buffer) {
const view = new DataView(buffer); const view = new DataView(buffer);
@@ -37,39 +37,44 @@
return new Float32Array(raw); return new Float32Array(raw);
} }
function rotated(x,y,z) {
const cy=Math.cos(yaw),sy=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch);
const x1=x*cy+z*sy,z1=-x*sy+z*cy;
return [x1,y*cp-z1*sp,y*sp+z1*cp];
}
function draw() { function draw() {
frame=0;if(!vertices)return; frame=0;if(!vertices)return;
const rect=canvas.getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2); const rect=canvas.getBoundingClientRect();
const width=Math.max(1,Math.round(rect.width*dpr)),height=Math.max(1,Math.round(rect.height*dpr)); const width=Math.max(1,Math.round(rect.width*renderScale)),height=Math.max(1,Math.round(rect.height*renderScale));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height} if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,rect.width,rect.height); ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height);
const size=Math.min(rect.width,rect.height)*.39*zoom,cx=rect.width/2,cy=rect.height/2; const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d');
const tris=[]; mask.width=width;mask.height=height;
for(let i=0;i<vertices.length;i+=9){const a=rotated(vertices[i],vertices[i+1],vertices[i+2]),b=rotated(vertices[i+3],vertices[i+4],vertices[i+5]),c=rotated(vertices[i+6],vertices[i+7],vertices[i+8]);tris.push({a,b,c,z:(a[2]+b[2]+c[2])/3})} const size=Math.min(width,height)*.43,cx=width/2,cy=height/2;
tris.sort((a,b)=>a.z-b.z); maskCtx.fillStyle='#fff';
ctx.lineJoin='round';ctx.lineWidth=.65; for(let i=0;i<vertices.length;i+=9){
for(const t of tris){const ax=cx+t.a[0]*size,ay=cy-t.a[1]*size,bx=cx+t.b[0]*size,by=cy-t.b[1]*size,cx2=cx+t.c[0]*size,cy2=cy-t.c[1]*size;const nz=(bx-ax)*(cy2-ay)-(by-ay)*(cx2-ax),light=Math.max(0,Math.min(1,.25+Math.abs(nz)/(size*size)*5));ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.lineTo(cx2,cy2);ctx.closePath();ctx.fillStyle=edgesOnly?'rgba(79,96,102,.035)':`rgba(${55+light*80},${67+light*82},${72+light*84},.88)`;ctx.strokeStyle=edgesOnly?'rgba(183,210,216,.72)':'rgba(171,193,198,.25)';ctx.fill();ctx.stroke()} maskCtx.beginPath();
maskCtx.moveTo(cx+vertices[i]*size,cy-vertices[i+1]*size);
maskCtx.lineTo(cx+vertices[i+3]*size,cy-vertices[i+4]*size);
maskCtx.lineTo(cx+vertices[i+6]*size,cy-vertices[i+7]*size);
maskCtx.closePath();maskCtx.fill();
}
// A filtered copy creates only the exterior contour; triangle boundaries
// never enter the final image because the source is a single solid mask.
ctx.save();
ctx.filter='drop-shadow(0 0 1.2px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.28))';
ctx.drawImage(mask,0,0);ctx.restore();
const surface=document.createElement('canvas'),surfaceCtx=surface.getContext('2d');
surface.width=width;surface.height=height;
const gradient=surfaceCtx.createLinearGradient(0,0,width,height);
gradient.addColorStop(0,'#ffffff');gradient.addColorStop(.58,'#f4f5f2');gradient.addColorStop(1,'#dfe2de');
surfaceCtx.fillStyle=gradient;surfaceCtx.fillRect(0,0,width,height);
surfaceCtx.globalCompositeOperation='destination-in';surfaceCtx.drawImage(mask,0,0);
ctx.drawImage(surface,0,0);
} }
function requestDraw(){if(!frame)frame=requestAnimationFrame(draw)} function requestDraw(){if(!frame)frame=requestAnimationFrame(draw)}
canvas.addEventListener('pointerdown',e=>{dragging=true;lastX=e.clientX;lastY=e.clientY;canvas.setPointerCapture(e.pointerId)});
canvas.addEventListener('pointermove',e=>{if(!dragging)return;yaw+=(e.clientX-lastX)*.012;pitch=Math.max(-1.45,Math.min(1.45,pitch+(e.clientY-lastY)*.012));lastX=e.clientX;lastY=e.clientY;requestDraw()});
canvas.addEventListener('pointerup',()=>dragging=false);
canvas.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.55,Math.min(2.2,zoom*(e.deltaY>0?.9:1.1)));requestDraw()},{passive:false});
addEventListener('resize',requestDraw); addEventListener('resize',requestDraw);
window.rovModel = { window.rovModel = {
loadBuffer(buffer, name) { loadBuffer(buffer, name) {
vertices=parseStl(buffer);stage.classList.add('model-loaded'); vertices=parseStl(buffer);stage.classList.add('model-loaded');
document.querySelector('#model-style').disabled=false;
document.querySelector('#model-file-name').textContent=name; document.querySelector('#model-file-name').textContent=name;
yaw=-.55;pitch=-.42;zoom=1;requestDraw(); requestDraw();
return {name,triangles:vertices.length/9}; return {name,triangles:vertices.length/9};
}, },
load(payload) { load(payload) {
@@ -77,7 +82,6 @@
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);
return this.loadBuffer(bytes.buffer,payload.name); return this.loadBuffer(bytes.buffer,payload.name);
}, }
toggleStyle() { edgesOnly=!edgesOnly;requestDraw();return edgesOnly; }
}; };
})(); })();
+1 -1
View File
@@ -9,4 +9,4 @@
.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} .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}} @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} .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;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}.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} .rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block}.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}