117 lines
14 KiB
JavaScript
117 lines
14 KiB
JavaScript
"use strict";
|
|
const $=s=>document.querySelector(s), canvas=$("#viewport"), gl=canvas.getContext("webgl2",{antialias:true,alpha:false});
|
|
let bundle=null,etag=null,selected=null,isolated=false,showSolid=true,showWire=false,showGuides=true,section=false,clipZ=1e9,resources=[],grid=null;
|
|
let yaw=-.9,pitch=.52,distance=62,target=[0,0,5],radius=25,viewMode="orbit",dirty=true;
|
|
const add=(a,b)=>a.map((x,i)=>x+b[i]),sub=(a,b)=>a.map((x,i)=>x-b[i]),mul=(a,s)=>a.map(x=>x*s),dot=(a,b)=>a.reduce((s,x,i)=>s+x*b[i],0),cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],unit=a=>mul(a,1/(Math.hypot(...a)||1));
|
|
const identity=()=>[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1];
|
|
function multiply(a,b){let o=new Array(16).fill(0);for(let c=0;c<4;c++)for(let r=0;r<4;r++)for(let k=0;k<4;k++)o[c*4+r]+=a[k*4+r]*b[c*4+k];return o;}
|
|
function lookAt(eye,center,up){let z=unit(sub(eye,center)),x=unit(cross(up,z)),y=cross(z,x);return[x[0],y[0],z[0],0,x[1],y[1],z[1],0,x[2],y[2],z[2],0,-dot(x,eye),-dot(y,eye),-dot(z,eye),1];}
|
|
function perspective(fov,aspect,near,far){let f=1/Math.tan(fov/2),nf=1/(near-far);return[f/aspect,0,0,0,0,f,0,0,0,0,(far+near)*nf,-1,0,0,2*far*near*nf,0];}
|
|
function ortho(w,h,near,far){return[2/w,0,0,0,0,2/h,0,0,0,0,-2/(far-near),0,0,0,-(far+near)/(far-near),1];}
|
|
function colMajor(rows){return rows[0].map((_,i)=>rows.map(r=>r[i])).flat();}
|
|
function normalMatrix(m){const a=[m[0],m[1],m[2]],b=[m[4],m[5],m[6]],c=[m[8],m[9],m[10]],d=dot(a,cross(b,c));return[...mul(cross(b,c),1/d),...mul(cross(c,a),1/d),...mul(cross(a,b),1/d)];}
|
|
let program,loc={};
|
|
function shader(type,src){let s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s;}
|
|
function initializeGL(){
|
|
if(!gl)throw new Error("WebGL 2 is unavailable. JSON export and the Python kernel still work.");
|
|
program=gl.createProgram();gl.attachShader(program,shader(gl.VERTEX_SHADER,`#version 300 es
|
|
precision highp float;
|
|
layout(location=0) in vec3 position; layout(location=1) in vec3 normal;
|
|
uniform mat4 mvp; uniform mat4 model; uniform mat3 normals;
|
|
out vec3 world; out vec3 n;
|
|
void main(){world=(model*vec4(position,1.0)).xyz;n=normals*normal;gl_Position=mvp*vec4(position,1.0);}`));
|
|
gl.attachShader(program,shader(gl.FRAGMENT_SHADER,`#version 300 es
|
|
precision highp float;in vec3 world;in vec3 n;uniform vec3 color;uniform float unlit;uniform float clipHeight;out vec4 frag;
|
|
void main(){if(world.z>clipHeight)discard;vec3 nn=normalize(n+vec3(0.00001));float light=0.50+0.38*abs(dot(nn,normalize(vec3(-0.35,-0.55,0.78))))+0.12*max(nn.z,0.0);frag=vec4(color*mix(light,1.0,unlit),1.0);}`));
|
|
gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(program));
|
|
for(const name of ["mvp","model","normals","color","unlit","clipHeight"])loc[name]=gl.getUniformLocation(program,name);
|
|
gl.enable(gl.DEPTH_TEST);gl.clearColor(.914,.933,.953,1);
|
|
}
|
|
function makeGeometry(g,lineOnly=false){
|
|
const points=g.vertices,normal=points.map(()=>[0,0,0]),faces=g.faces||[];
|
|
for(const [a,b,c] of faces){const n=cross(sub(points[b],points[a]),sub(points[c],points[a]));for(const i of [a,b,c])normal[i]=add(normal[i],n);}
|
|
const edgeSet=new Set(),edges=[];
|
|
if(g.edges){for(const e of g.edges)edges.push(...e);}else for(const f of faces)for(let j=0;j<3;j++){let a=f[j],b=f[(j+1)%3],key=Math.min(a,b)+":"+Math.max(a,b);if(!edgeSet.has(key)){edgeSet.add(key);edges.push(a,b);}}
|
|
const vao=gl.createVertexArray();gl.bindVertexArray(vao);const buffers=[];
|
|
function attribute(index,data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(data),gl.STATIC_DRAW);gl.enableVertexAttribArray(index);gl.vertexAttribPointer(index,3,gl.FLOAT,false,0,0);}
|
|
attribute(0,points.flat());attribute(1,normal.map(unit).flat());
|
|
function indices(data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,b);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(data),gl.STATIC_DRAW);return b;}
|
|
const triangles=indices(faces.flat()),lines=indices(edges);gl.bindVertexArray(null);
|
|
return{vao,buffers,triangles,lines,triangleCount:faces.length*3,lineCount:edges.length,lineOnly};
|
|
}
|
|
function dispose(){for(const r of resources){gl.deleteVertexArray(r.vao);for(const b of r.buffers)gl.deleteBuffer(b);}resources=[];}
|
|
let geometries=new Map(),drawables=[];
|
|
function install(data){
|
|
const previous=!!bundle;bundle=data;dispose();geometries=new Map();drawables=[];
|
|
for(const [id,g] of Object.entries(data.geometries)){let r=makeGeometry(g);geometries.set(id,r);resources.push(r);}
|
|
for(const o of data.objects){let resource=geometries.get(o.geometry);
|
|
if(o.kind==="curve"){let edges=o.points.slice(1).map((_,i)=>[i,i+1]);if(o.closed)edges.push([o.points.length-1,0]);resource=makeGeometry({vertices:o.points,edges},true);resources.push(resource);}
|
|
if(o.kind==="frame"){resource=makeGeometry({vertices:[[0,0,0],[.65,0,0],[0,.65,0],[0,0,.65]],edges:[[0,1],[0,2],[0,3]]},true);resources.push(resource);}
|
|
drawables.push({object:o,resource,model:colMajor(o.matrix)});
|
|
}
|
|
const min=data.bounds.min,max=data.bounds.max;radius=Math.max(1,Math.hypot(...sub(max,min))/2);
|
|
if(!previous){target=mul(add(min,max),.5);distance=radius*2.7;}
|
|
const step=10**Math.floor(Math.log10(radius/5)),extent=Math.ceil(radius*1.3/step)*step,points=[],edges=[];const base=Math.floor(min[2]/step)*step;
|
|
for(let x=-extent;x<=extent+.001;x+=step){let n=points.length;points.push([x,-extent,base],[x,extent,base],[-extent,x,base],[extent,x,base]);edges.push([n,n+1],[n+2,n+3]);}
|
|
grid=makeGeometry({vertices:points,edges},true);resources.push(grid);
|
|
$("#project-name").textContent=data.name;document.title=data.name+" — Spatial Lab";
|
|
$("#node-count").textContent=data.stats.nodes+" nodes";
|
|
$("#statistics").textContent=`${data.stats.objects} objects / ${data.stats.unique_meshes} meshes / ${data.stats.triangles.toLocaleString()} triangles`;
|
|
const warning=data.validation.warnings.length;$("#validation").textContent=warning?`${warning} topology notes`:"Mesh topology checked";$("#validation").classList.toggle("warn",warning>0);
|
|
$("#validation").title="Checks finite coordinates, triangle indices, edge incidence and solid orientation. Does not test self-intersections or walkability.";
|
|
const slider=$("#section-height");slider.min=min[2]-.1;slider.max=max[2]+.1;if(!section){slider.value=max[2]+.1;clipZ=1e9;}
|
|
if(selected&&!data.nodes.some(n=>n.id===selected))selected=null;
|
|
outline();inspector();dirty=true;
|
|
}
|
|
function outline(){
|
|
const list=$("#nodes");list.replaceChildren();
|
|
for(const n of bundle.nodes){let b=document.createElement("button");b.className="node"+(n.id===selected?" selected":"")+(!n.visible?" computational":"");b.dataset.node=n.id;b.title=n.id;
|
|
const symbol=document.createElement("span");symbol.className="symbol";symbol.textContent=n.kind==="curve"?"∿":n.kind==="frames"?"⊥":"▱";
|
|
const label=document.createElement("span");label.className="label";label.append(document.createTextNode(n.id));let small=document.createElement("small");small.textContent=n.op;label.append(small);
|
|
const vis=document.createElement("span");vis.className="visibility";vis.textContent=n.visible?"●":"";
|
|
b.append(symbol,label,vis);b.onclick=()=>{selected=selected===n.id?null:n.id;if(!selected)isolated=false;outline();inspector();dirty=true;};list.append(b);
|
|
}
|
|
}
|
|
function inspector(){
|
|
const root=$("#inspector");root.replaceChildren();$("#clear-selection").hidden=!selected;$("#isolate").setAttribute("aria-pressed",String(isolated));
|
|
if(!selected){$("#selection-name").textContent="Project parameters";$("#selection-status").textContent="All geometry";for(const [k,v] of Object.entries(bundle.recipe.parameters||{})){let row=document.createElement("div");row.className="param-row";let a=document.createElement("span"),b=document.createElement("span");a.textContent=k;b.textContent=typeof v==="string"?v:JSON.stringify(v);row.append(a,b);root.append(row);}return;}
|
|
const n=bundle.nodes.find(n=>n.id===selected);$("#selection-name").textContent=n.id;$("#selection-status").textContent=n.visible?(isolated?"Isolated: ":"Selected: ")+n.id:"Construction node: "+n.id;
|
|
let dl=document.createElement("dl");for(const [k,v] of Object.entries({operation:n.op,role:n.role,...n.inputs,...n.params})){let dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=k;dd.textContent=typeof v==="string"?v:JSON.stringify(v);dl.append(dt,dd);}root.append(dl);
|
|
}
|
|
function draw(){
|
|
if(!gl||!bundle)return;const dpr=Math.min(devicePixelRatio,2),w=Math.round(canvas.clientWidth*dpr),h=Math.round(canvas.clientHeight*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;dirty=true;}if(!dirty)return;dirty=false;
|
|
gl.viewport(0,0,w,h);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.useProgram(program);
|
|
let offset=[Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)],eye=add(target,mul(offset,distance)),v=lookAt(eye,target,[0,0,1]);
|
|
let p=viewMode==="orbit"?perspective(.70,w/h,.05,Math.max(1000,distance+radius*10)):ortho(distance*.7*w/h,distance*.7,.05,Math.max(1000,distance+radius*10));let pv=multiply(p,v);
|
|
function render(r,m,color,lines=false){gl.bindVertexArray(r.vao);gl.uniformMatrix4fv(loc.model,false,m);gl.uniformMatrix4fv(loc.mvp,false,multiply(pv,m));gl.uniformMatrix3fv(loc.normals,false,normalMatrix(m));gl.uniform3fv(loc.color,color);gl.uniform1f(loc.unlit,lines?1:0);gl.uniform1f(loc.clipHeight,section?clipZ:1e9);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,lines?r.lines:r.triangles);gl.drawElements(lines?gl.LINES:gl.TRIANGLES,lines?r.lineCount:r.triangleCount,gl.UNSIGNED_INT,0);}
|
|
if(showGuides)render(grid,identity(),[.77,.82,.86],true);
|
|
let visible=0;
|
|
for(const d of drawables){const o=d.object;if(isolated&&o.node_id!==selected)continue;if(d.resource.lineOnly&&!showGuides)continue;visible++;
|
|
const selectedObject=selected===o.node_id,color=selectedObject?[.82,.56,.28]:o.color;
|
|
if(showSolid&&!d.resource.lineOnly){gl.enable(gl.POLYGON_OFFSET_FILL);gl.polygonOffset(1,1);render(d.resource,d.model,color);gl.disable(gl.POLYGON_OFFSET_FILL);}
|
|
if(showWire||d.resource.lineOnly||selectedObject)render(d.resource,d.model,selectedObject?[.38,.24,.12]:showSolid?mul(color,.54):mul(color,.68),true);
|
|
}
|
|
$("#empty").hidden=visible>0;gl.bindVertexArray(null);
|
|
}
|
|
function animate(){draw();requestAnimationFrame(animate);}
|
|
function setView(name){viewMode=name;if(name==="top"){yaw=-Math.PI/2;pitch=Math.PI/2-.0001;}if(name==="front"){yaw=-Math.PI/2;pitch=0;}if(name==="side"){yaw=0;pitch=0;}if(name==="orbit"){yaw=-.9;pitch=.52;}document.querySelectorAll("[data-view]").forEach(b=>b.classList.toggle("active",b.dataset.view===name));$("#view-label").textContent=name==="orbit"?"Perspective":name[0].toUpperCase()+name.slice(1)+" / orthographic";dirty=true;}
|
|
for(const b of document.querySelectorAll("[data-view]"))b.onclick=()=>setView(b.dataset.view);
|
|
$("#fit").onclick=()=>{if(bundle){target=mul(add(bundle.bounds.min,bundle.bounds.max),.5);distance=radius*(viewMode==="orbit"?2.7:2.4);dirty=true;}};
|
|
function toggle(id,get,set){$(id).onclick=()=>{set(!get());$(id).setAttribute("aria-pressed",String(get()));dirty=true;};}
|
|
toggle("#solid",()=>showSolid,v=>{showSolid=v;if(!showSolid&&!showWire){showWire=true;$("#wire").setAttribute("aria-pressed","true");}});
|
|
toggle("#wire",()=>showWire,v=>{showWire=v;if(!showWire&&!showSolid){showSolid=true;$("#solid").setAttribute("aria-pressed","true");}});
|
|
toggle("#guides",()=>showGuides,v=>showGuides=v);toggle("#isolate",()=>isolated,v=>{isolated=!!selected&&v;inspector();});
|
|
$("#clear-selection").onclick=()=>{selected=null;isolated=false;outline();inspector();dirty=true;};
|
|
$("#section-enabled").onchange=e=>{section=e.target.checked;$("#section-height").disabled=!section;updateClip();};
|
|
function updateClip(){clipZ=Number($("#section-height").value);$("#section-value").textContent=section?clipZ.toFixed(2)+" m":"—";dirty=true;}$("#section-height").oninput=updateClip;
|
|
let drag=null;
|
|
canvas.addEventListener("pointerdown",e=>{canvas.setPointerCapture(e.pointerId);drag={x:e.clientX,y:e.clientY,pan:e.shiftKey||e.button===1};});
|
|
canvas.addEventListener("pointerup",()=>drag=null);canvas.addEventListener("pointercancel",()=>drag=null);
|
|
canvas.addEventListener("pointermove",e=>{if(!drag)return;const dx=e.clientX-drag.x,dy=e.clientY-drag.y;drag.x=e.clientX;drag.y=e.clientY;if(drag.pan){const right=[-Math.sin(yaw),Math.cos(yaw),0],up=cross(unit([Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)]),right);target=add(target,add(mul(right,-dx*distance/canvas.clientHeight*.65),mul(up,dy*distance/canvas.clientHeight*.65)));}else{if(viewMode!=="orbit")setView("orbit");yaw-=dx*.006;pitch=Math.max(-1.5,Math.min(1.5,pitch+dy*.006));}dirty=true;});
|
|
canvas.addEventListener("wheel",e=>{e.preventDefault();distance=Math.max(.2,Math.min(radius*30,distance*Math.exp(e.deltaY*.001)));dirty=true;},{passive:false});
|
|
canvas.addEventListener("keydown",e=>{if(e.key==="Escape")$("#clear-selection").click();if(e.key.toLowerCase()==="f")$("#fit").click();});
|
|
new ResizeObserver(()=>dirty=true).observe(canvas);
|
|
function showError(message){$("#error").hidden=false;$("#error").textContent=(bundle?"Source cannot be built. Showing the last valid geometry.\n":"")+message;$("#live-state").textContent="Build error";$("#live-state").classList.add("error");}
|
|
async function refresh(){try{const r=await fetch("/api/bundle",{headers:etag?{"If-None-Match":etag}:{}});if(r.status!==304){const data=await r.json();if(!r.ok)throw new Error(data.error||r.statusText);etag=r.headers.get("ETag");install(data);}$("#error").hidden=true;$("#live-state").textContent="Live project";$("#live-state").classList.remove("error");}catch(e){showError(e.message);}finally{setTimeout(refresh,1800);}}
|
|
try{initializeGL();refresh();animate();}catch(e){showError(e.message);}
|