Render STL as fixed white top-view silhouette
This commit is contained in:
@@ -28,7 +28,6 @@ $('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=
|
||||
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)}};
|
||||
$('#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)};
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div class="model-tools">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
const canvas = document.querySelector('#model-canvas');
|
||||
const stage = document.querySelector('.rov-stage');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let vertices = null, yaw = -.55, pitch = -.42, zoom = 1, edgesOnly = false;
|
||||
let dragging = false, lastX = 0, lastY = 0, frame = 0;
|
||||
let vertices = null, frame = 0;
|
||||
const renderScale = .62;
|
||||
|
||||
function parseStl(buffer) {
|
||||
const view = new DataView(buffer);
|
||||
@@ -37,39 +37,44 @@
|
||||
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() {
|
||||
frame=0;if(!vertices)return;
|
||||
const rect=canvas.getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
|
||||
const width=Math.max(1,Math.round(rect.width*dpr)),height=Math.max(1,Math.round(rect.height*dpr));
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
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}
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,rect.width,rect.height);
|
||||
const size=Math.min(rect.width,rect.height)*.39*zoom,cx=rect.width/2,cy=rect.height/2;
|
||||
const tris=[];
|
||||
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})}
|
||||
tris.sort((a,b)=>a.z-b.z);
|
||||
ctx.lineJoin='round';ctx.lineWidth=.65;
|
||||
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()}
|
||||
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height);
|
||||
const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d');
|
||||
mask.width=width;mask.height=height;
|
||||
const size=Math.min(width,height)*.43,cx=width/2,cy=height/2;
|
||||
maskCtx.fillStyle='#fff';
|
||||
for(let i=0;i<vertices.length;i+=9){
|
||||
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)}
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
requestDraw();
|
||||
return {name,triangles:vertices.length/9};
|
||||
},
|
||||
load(payload) {
|
||||
@@ -77,7 +82,6 @@
|
||||
const binary=atob(payload.data),bytes=new Uint8Array(binary.length);
|
||||
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);
|
||||
return this.loadBuffer(bytes.buffer,payload.name);
|
||||
},
|
||||
toggleStyle() { edgesOnly=!edgesOnly;requestDraw();return edgesOnly; }
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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}
|
||||
@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;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}
|
||||
|
||||
Reference in New Issue
Block a user