diff --git a/web/app.js b/web/app.js index 6b51ec9..a86ac7f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,5 @@ -import { LandscapeRenderer } from './renderer.js'; +import { LandscapeRenderer, KIND } from './renderer.js'; +import { layoutCity, squarifiedLayout, spatialIndex, pickRay } from './city.js'; import { apiFetch, progressEvents } from './api.mjs'; const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||''; @@ -44,6 +45,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; +let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false; let currentSnapshot=null; let currentName=''; let currentMetric='lines'; @@ -85,36 +87,6 @@ function makeTree(list){ weigh(root); return root; } -function squarifiedLayout(entries,x,y,w,h,depth,output){ - if(!entries.length||w<=0||h<=0)return; - const total=entries.reduce((sum,entry)=>sum+Math.max(.0001,entry.weight),0),scale=w*h/total; - const remaining=entries.map(entry=>({entry,area:Math.max(.0001,entry.weight)*scale})); - const worst=(row,side)=>{ - if(!row.length)return Infinity; - const sum=row.reduce((value,item)=>value+item.area,0),largest=Math.max(...row.map(item=>item.area)),smallest=Math.min(...row.map(item=>item.area)),sideSquared=side*side; - return Math.max(sideSquared*largest/(sum*sum),(sum*sum)/(sideSquared*smallest)); - }; - const place=row=>{ - const area=row.reduce((value,item)=>value+item.area,0); - if(w>=h){ - const rowWidth=Math.min(w,area/Math.max(.0001,h));let rowY=y; - for(const item of row){const itemHeight=item.area/Math.max(.0001,rowWidth);output(item.entry,x,rowY,rowWidth,itemHeight,depth);rowY+=itemHeight;} - x+=rowWidth;w=Math.max(0,w-rowWidth); - }else{ - const rowHeight=Math.min(h,area/Math.max(.0001,w));let rowX=x; - for(const item of row){const itemWidth=item.area/Math.max(.0001,rowHeight);output(item.entry,rowX,y,itemWidth,rowHeight,depth);rowX+=itemWidth;} - y+=rowHeight;h=Math.max(0,h-rowHeight); - } - }; - let row=[]; - while(remaining.length){ - const next=remaining[0],side=Math.max(.0001,Math.min(w,h)); - if(!row.length||worst([...row,next],side)<=worst(row,side)){row.push(remaining.shift());} - else{place(row);row=[];} - } - if(row.length)place(row); -} - function computeLayout(){ const root=makeTree(files); layoutItems=[];directoryRects=[]; function visit(node,x,y,w,h,depth){ @@ -137,7 +109,29 @@ function computeLayout(){ } visit(root,0,0,1000,680,0); layoutById=new Map(layoutItems.map(item=>[item.id,item]));buildSpatialGrid();resetCodeCaches(); - renderer.setData(layoutItems);dirty=true; + renderer.setData(layoutItems);computeCity();renderer.setSelected(selected?.id||0);dirty=true; +} + +// The city shares files, colours and the code overview atlas with the landscape but has its own +// street layout in metres. Each layout item keeps its building as item.city. +function computeCity(){ + cityModel=layoutCity(files,currentName); + const instances=[{x:-80,y:-80,w:cityModel.width+160,h:cityModel.height+160,height:.02,color:[0,0,0],kind:KIND.ground}]; + for(const block of cityModel.blocks)if(block.depth>0)instances.push({x:block.x,y:block.y,w:block.w,h:block.h,height:.1+.06*Math.min(block.depth,4),color:[.55,.6,.9],kind:KIND.block}); + cityEntries=[]; + for(const item of layoutItems){ + const building=cityModel.buildings.get(item.id);if(!building)continue; + item.city={...building,file:item};cityEntries.push(item.city); + instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id}); + } + cityIndex=spatialIndex(cityEntries); + renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); +} + +function fitCity(){ + if(!cityModel)return; + Object.assign(renderer.city,{view:'heli',x:cityModel.width/2,y:cityModel.height/2,yaw:-.35,pitch:.72,distance:Math.max(cityModel.width,cityModel.height)*1.05}); + cityFitted=true;dirty=true; } function resetCodeCaches(){ @@ -169,12 +163,13 @@ function applySnapshot(snapshot){ files=snapshot.files||[];for(const file of files)file.symbols||=[];edges=snapshot.edges||[];currentName=snapshot.name||'codebase';currentSnapshot=snapshot.id||null;selected=null;history=[];searchHits=[];expandedCoverageLayer=null;coverageActiveIndex=-1;cameraAnimation=null; fileById=new Map(files.map(file=>[file.id,file]));referenceDegree=new Map();edgesByFile=new Map();let known=0,inferred=0;for(const edge of edges){referenceDegree.set(edge.from,(referenceDegree.get(edge.from)||0)+1);referenceDegree.set(edge.to,(referenceDegree.get(edge.to)||0)+1);for(const id of [edge.from,edge.to]){const list=edgesByFile.get(id);if(list)list.push(edge);else edgesByFile.set(id,[edge]);}if(edge.confidence==='known')known++;else inferred++;} const layers=new Map();for(const file of files){const layer=file.layer in layerLabels?file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+1);}sceneStats={definitions:snapshot.definitions??files.reduce((n,file)=>n+(file.symbolCount||0),0),totalLines:snapshot.totalLines??files.reduce((n,file)=>n+file.lines,0),known,inferred,layers}; - computeLayout();fitScene();updateStats(snapshot);renderInspector();renderResults([]);renderHistory(); + computeLayout();fitScene();fitCity();updateStats(snapshot);renderInspector();renderResults([]);renderHistory(); $('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;$('#welcome').hidden=true; } function fitScene(){ cameraAnimation=null; + if(renderer.mode==='city'){fitCity();return;} renderer.camera.x=500;renderer.camera.y=340; renderer.camera.zoom=Math.min((viewport.clientWidth-24)/1000,(viewport.clientHeight-24)/680); renderer.camera.distance=3.5;renderer.camera.yaw=-.12;renderer.camera.pitch=.76;dirty=true; @@ -189,6 +184,16 @@ function pan3d(dx,dy){ renderer.camera.y+=( dx*sy-dy*cy)*scale; } +function dragCity(dx,dy,action){ + const c=renderer.city;if(c.view!=='heli')return; + if(action==='pan'){const scale=c.distance*.0016,cy=Math.cos(c.yaw),sy=Math.sin(c.yaw);c.x+=(-dx*cy-dy*sy)*scale;c.y+=(dx*sy-dy*cy)*scale;} + else{c.yaw-=dx*.007;c.pitch=Math.max(.08,Math.min(1.5,c.pitch+dy*.006));} +} +function dollyCity(amount){ + const c=renderer.city,size=Math.max(cityModel?.width||1000,cityModel?.height||1000); + if(c.view==='heli')c.distance=Math.max(12,Math.min(size*3,c.distance*Math.exp(amount))); +} + function dolly3d(amount){ renderer.camera.distance=Math.max(MIN_3D_DISTANCE,Math.min(14,renderer.camera.distance*Math.exp(amount))); } @@ -314,7 +319,7 @@ function selectFile(file,addHistory=true){ if(!file)coverageActiveIndex=-1; if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();} $('#breadcrumbText').textContent=file?`${currentName} › ${file.path}`:currentName; - renderInspector();if(file)loadFileDetails(file);dirty=true; + renderer.setSelected(file?.id||0);renderInspector();if(file)loadFileDetails(file);dirty=true; } function renderInspector(){ @@ -388,19 +393,21 @@ function renderHistory(){ function focusFile(file){ const item=layoutById.get(file.id);if(!item)return; + if(renderer.mode==='city'){const b=item.city;if(b){renderer.city.view='heli';animateCameraTo({x:b.x+b.w/2,y:b.y+b.h/2,distance:Math.max(90,Math.max(b.w,b.h,b.height)*3.2)},renderer.city);}return;} const target={x:item.x+item.w/2,y:item.y+item.h/2}; if(renderer.mode==='2d'){const fit=Math.min(viewport.clientWidth/Math.max(.2,item.w*1.35),viewport.clientHeight/Math.max(.2,item.h*1.35),MAX_2D_ZOOM);target.zoom=Math.min(MAX_2D_ZOOM,Math.max(fit,9/codeWorldFont(item)));} else target.distance=2.25; animateCameraTo(target); } -function animateCameraTo(target){ - const from={},to={};for(const [key,value] of Object.entries(target)){from[key]=renderer.camera[key];to[key]=value;} - if(matchMedia('(prefers-reduced-motion: reduce)').matches){Object.assign(renderer.camera,to);cameraAnimation=null;dirty=true;return;} - cameraAnimation={from,to,start:performance.now(),duration:620};dirty=true; +function animateCameraTo(target,camera=activeCamera(),duration=620){ + const from={},to={};for(const [key,value] of Object.entries(target)){from[key]=camera[key];to[key]=value;} + if(matchMedia('(prefers-reduced-motion: reduce)').matches){Object.assign(camera,to);cameraAnimation=null;dirty=true;return;} + cameraAnimation={camera,from,to,start:performance.now(),duration};dirty=true; } function cancelCameraAnimation(){cameraAnimation=null;} +function activeCamera(){return renderer.mode==='city'?renderer.city:renderer.camera;} function switchTab(name){$$('.tab').forEach(tab=>{const active=tab.dataset.tab===name;tab.classList.toggle('active',active);tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;});$$('.panel').forEach(panel=>panel.classList.toggle('active',panel.id===`panel-${name}`));} $$('.tab').forEach(tab=>tab.addEventListener('click',()=>switchTab(tab.dataset.tab))); @@ -431,6 +438,7 @@ function drawOverlay(){ resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textBaseline='top'; codeTexturesPending=false; codeTextureDeadline=performance.now()+5; + if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay(ctx);ctx.restore();return;} const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible; let labelCount=0; if(showCode&&renderer.mode==='2d'){ @@ -463,6 +471,18 @@ function drawOverlay(){ ctx.restore(); } +function drawCityOverlay(ctx){ + if(showCode){fillCodeOverview();uploadCodeOverview();} + if(!cityModel)return; + const occupied=[],w=renderer.width,h=renderer.height;let count=0; + if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}} + for(const block of cityModel.blocks){ + if(block.depth!==1||count>=40)continue; + const p=renderer.project([block.x+block.w/2,block.y+block.h/2,0]);if(!(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h))continue; + if(drawChip(ctx,`${block.node.name}/`,p.x,p.y-8,180,'#8d7dff',occupied,false,'center'))count++; + } +} + function tileCorners(item){return [[item.x,item.y],[item.x+item.w,item.y],[item.x+item.w,item.y+item.h],[item.x,item.y+item.h]].map(([x,y])=>renderer.project([x,y,item.height||0]));} function drawSelectedConnections(ctx){ if(!selected)return; @@ -588,7 +608,7 @@ function drawCodeTexture(ctx,item,rect){ function animate(now){ if(cameraAnimation){ const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4; - for(const key of Object.keys(cameraAnimation.to))renderer.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased; + for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased; if(progress>=1)cameraAnimation=null;dirty=true; } if(dirty){ @@ -601,6 +621,7 @@ function animate(now){ function pickAt(x,y){ if(renderer.mode==='2d'){const world=renderer.worldAt(x,y),cell=spatialGrid.get(`${Math.floor(world.x/spatialCell)}:${Math.floor(world.y/spatialCell)}`)||[];let match=null,area=Infinity;for(const item of cell){if(world.x>=item.x&&world.x<=item.x+item.w&&world.y>=item.y&&world.y<=item.y+item.h){const next=item.w*item.h;if(nextrect.x+rect.w||yrect.y+rect.h)continue; @@ -617,7 +638,7 @@ function pickAt(x,y){ viewport.addEventListener('pointerdown',event=>{ // Only drags that start on the map; pointer capture would otherwise swallow clicks on floating controls. if(event.button>2||event.target!==glCanvas)return; - const pan=renderer.mode==='3d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey); + const pan=renderer.mode!=='2d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey); if(renderer.mode==='2d'&&event.button!==0)return; event.preventDefault(); cancelCameraAnimation(); @@ -629,6 +650,7 @@ viewport.addEventListener('pointermove',event=>{ if(pointer.down&&event.pointerId===pointer.id){ const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY; if(renderer.mode==='2d'){renderer.camera.x-=dx/renderer.camera.zoom;renderer.camera.y-=dy/renderer.camera.zoom;} + else if(renderer.mode==='city')dragCity(dx,dy,pointer.action); else if(pointer.action==='pan')pan3d(dx,dy); else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));} dirty=true;$('#tooltip').hidden=true;return; @@ -644,11 +666,11 @@ function finishPointer(event){ viewport.addEventListener('pointerup',finishPointer); viewport.addEventListener('pointercancel',finishPointer); viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;}); -viewport.addEventListener('contextmenu',event=>{if(renderer.mode==='3d')event.preventDefault();}); -viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false}); +viewport.addEventListener('contextmenu',event=>{if(renderer.mode!=='2d')event.preventDefault();}); +viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false}); viewport.addEventListener('dblclick',event=>{const rect=viewport.getBoundingClientRect(),file=pickAt(event.clientX-rect.left,event.clientY-rect.top);if(file){selectFile(file);focusFile(file);}}); -$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;})); +$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;if(renderer.mode==='city'&&!cityFitted)fitCity();$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;})); $$('[data-metric]').forEach(button=>button.addEventListener('click',()=>{$$('[data-metric]').forEach(v=>v.classList.toggle('active',v===button));currentMetric=button.dataset.metric;computeLayout();})); $('#codeButton').classList.toggle('active',showCode);$('#codeButton').addEventListener('click',event=>{showCode=!showCode;event.currentTarget.classList.toggle('active',showCode);dirty=true;}); $('#homeButton').addEventListener('click',()=>{selectFile(null);fitScene();}); @@ -795,7 +817,7 @@ $('#fpsToggle').addEventListener('change',event=>{$('#fps').hidden=!event.target $('#paletteButton').addEventListener('click',savePreferences);$('#codeButton').addEventListener('click',savePreferences); $('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;}); $('#fitButton').addEventListener('click',fitScene); -function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;} +function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='city')dollyCity(-direction*.22);else if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;} $('#zoomInButton').addEventListener('click',()=>zoomStep(1));$('#zoomOutButton').addEventListener('click',()=>zoomStep(-1)); $$('.canvas-controls, .breadcrumb, .progress-card, .legend, .camera-help').forEach(control=>{['pointerdown','dblclick','wheel'].forEach(type=>control.addEventListener(type,event=>event.stopPropagation()));}); $$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{ @@ -803,7 +825,7 @@ $$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{ const from={...renderer.camera},to={...from,...poses[button.dataset.cameraPreset]}; cancelCameraAnimation(); if(matchMedia('(prefers-reduced-motion: reduce)').matches)Object.assign(renderer.camera,to); - else cameraAnimation={from,to,start:performance.now(),duration:450}; + else cameraAnimation={camera:renderer.camera,from,to,start:performance.now(),duration:450}; dirty=true; })); dialog.addEventListener('click',event=>{if(event.target===dialog)dialog.close();}); diff --git a/web/camera.js b/web/camera.js index f420deb..d04717f 100644 --- a/web/camera.js +++ b/web/camera.js @@ -55,3 +55,20 @@ export function orbitPose({ x, y, yaw, pitch, distance }, scale = 420, lift = .3 } export const ORBIT_FOV = 2 * Math.atan(1 / 2.7); + +// General 4x4 inverse (null when singular), for turning screen points back into world rays. +export function invert(m) { + const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m; + const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11; + const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30; + const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32; + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (!det) return null; + det = 1 / det; + return new Float32Array([ + (a11 * b11 - a12 * b10 + a13 * b09) * det, (a02 * b10 - a01 * b11 - a03 * b09) * det, (a31 * b05 - a32 * b04 + a33 * b03) * det, (a22 * b04 - a21 * b05 - a23 * b03) * det, + (a12 * b08 - a10 * b11 - a13 * b07) * det, (a00 * b11 - a02 * b08 + a03 * b07) * det, (a32 * b02 - a30 * b05 - a33 * b01) * det, (a20 * b05 - a22 * b02 + a23 * b01) * det, + (a10 * b10 - a11 * b08 + a13 * b06) * det, (a01 * b08 - a00 * b10 - a03 * b06) * det, (a30 * b04 - a31 * b02 + a33 * b00) * det, (a21 * b02 - a20 * b04 - a23 * b00) * det, + (a11 * b07 - a10 * b09 - a12 * b06) * det, (a00 * b09 - a01 * b07 + a02 * b06) * det, (a31 * b01 - a30 * b03 - a32 * b00) * det, (a20 * b03 - a21 * b01 + a22 * b00) * det, + ]); +} diff --git a/web/city.js b/web/city.js new file mode 100644 index 0000000..ae02e62 --- /dev/null +++ b/web/city.js @@ -0,0 +1,142 @@ +// Code as a city: folders become districts and blocks separated by roads, files become buildings. +// Everything here is plain data and maths in metres, so it can be tested without a browser. + +// Squarified treemap: calls output(entry, x, y, w, h, depth) for each entry, largest first. +export function squarifiedLayout(entries, x, y, w, h, depth, output) { + if (!entries.length || w <= 0 || h <= 0) return; + const total = entries.reduce((sum, entry) => sum + Math.max(.0001, entry.weight), 0), scale = w * h / total; + const remaining = entries.map(entry => ({ entry, area: Math.max(.0001, entry.weight) * scale })); + const worst = (row, side) => { + if (!row.length) return Infinity; + let sum = 0, largest = 0, smallest = Infinity; + for (const item of row) { sum += item.area; largest = Math.max(largest, item.area); smallest = Math.min(smallest, item.area); } + return Math.max(side * side * largest / (sum * sum), (sum * sum) / (side * side * smallest)); + }; + const place = row => { + const area = row.reduce((value, item) => value + item.area, 0); + if (w >= h) { + const rowWidth = Math.min(w, area / Math.max(.0001, h)); let rowY = y; + for (const item of row) { const itemHeight = item.area / Math.max(.0001, rowWidth); output(item.entry, x, rowY, rowWidth, itemHeight, depth); rowY += itemHeight; } + x += rowWidth; w = Math.max(0, w - rowWidth); + } else { + const rowHeight = Math.min(h, area / Math.max(.0001, w)); let rowX = x; + for (const item of row) { const itemWidth = item.area / Math.max(.0001, rowHeight); output(item.entry, rowX, y, itemWidth, rowHeight, depth); rowX += itemWidth; } + y += rowHeight; h = Math.max(0, h - rowHeight); + } + }; + let row = []; + while (remaining.length) { + const next = remaining[0], side = Math.max(.0001, Math.min(w, h)); + if (!row.length || worst([...row, next], side) <= worst(row, side)) row.push(remaining.shift()); + else { place(row); row = []; } + } + if (row.length) place(row); +} + +export const CITY = { + squareMetresPerLine: 1.1, // building footprint per source line + minLines: 40, // small files still get a walkable kiosk + roadWidths: [18, 12, 8, 6], // avenues between districts, then narrower streets per folder depth + sidewalk: 2.5, // kerb inside each block before buildings start + setback: 1.2, // gap between neighbouring buildings + cellSize: 32, // spatial grid cell for collision and picking +}; + +// Storeys scale with complexity: a flat data file is a low warehouse, dense logic is a tower. +export function buildingHeight(file) { + return 6 + 10 * Math.log2(1 + Math.max(0, file.complexity || 0)); +} + +function buildTree(files, name) { + const root = { name, path: '', children: new Map(), files: [], weight: 0 }; + for (const file of files) { + const parts = file.path.split('/'); parts.pop(); let node = root; + for (const part of parts) { + if (!node.children.has(part)) node.children.set(part, { name: part, path: node.path ? `${node.path}/${part}` : part, children: new Map(), files: [], weight: 0 }); + node = node.children.get(part); + } + node.files.push(file); + } + const weigh = node => { + node.weight = node.files.reduce((sum, file) => sum + Math.max(CITY.minLines, file.lines || 0), 0); + for (const child of node.children.values()) node.weight += weigh(child); + return node.weight; + }; + weigh(root); + return root; +} + +// Returns { width, height, buildings: Map(fileId -> {x,y,w,h,height}), blocks: [{node,depth,x,y,w,h}] }. +export function layoutCity(files, name = '') { + const root = buildTree(files, name); + const area = root.weight * CITY.squareMetresPerLine * 1.9; // roads, sidewalks and setbacks + const width = Math.sqrt(area * 1000 / 680), height = area / width; + const buildings = new Map(), blocks = []; + const visit = (node, x, y, w, h, depth) => { + if (depth > 0) { + // Half a road on every side, so neighbouring blocks are a full road apart. + const half = Math.min(CITY.roadWidths[Math.min(depth - 1, CITY.roadWidths.length - 1)] / 2, w * .2, h * .2); + x += half; y += half; w -= half * 2; h -= half * 2; + } + blocks.push({ node, depth, x, y, w, h }); + const kerb = Math.min(CITY.sidewalk, w * .15, h * .15); + const values = [...node.children.values(), ...node.files.map(file => ({ file, weight: Math.max(CITY.minLines, file.lines || 0) }))]; + const total = values.reduce((sum, value) => sum + value.weight, 0), floor = total / Math.max(1, values.length * 8); + const entries = values.map(value => ({ value, weight: Math.max(value.weight, floor) })).sort((a, b) => b.weight - a.weight); + squarifiedLayout(entries, x + kerb, y + kerb, w - kerb * 2, h - kerb * 2, depth, (entry, rx, ry, rw, rh) => { + const value = entry.value; + if (!value.file) return visit(value, rx, ry, rw, rh, depth + 1); + const gap = Math.min(CITY.setback / 2, rw * .12, rh * .12); + buildings.set(value.file.id, { x: rx + gap, y: ry + gap, w: rw - gap * 2, h: rh - gap * 2, height: buildingHeight(value.file) }); + }); + }; + visit(root, 0, 0, width, height, 0); + return { width, height, buildings, blocks }; +} + +// Uniform grid over building footprints, for collision and picking near a point or along a ray. +export function spatialIndex(entries, cellSize = CITY.cellSize) { + const cells = new Map(); + for (const entry of entries) { + const x0 = Math.floor(entry.x / cellSize), x1 = Math.floor((entry.x + entry.w) / cellSize); + const y0 = Math.floor(entry.y / cellSize), y1 = Math.floor((entry.y + entry.h) / cellSize); + for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) { + const key = cx * 65536 + cy, cell = cells.get(key); + if (cell) cell.push(entry); else cells.set(key, [entry]); + } + } + return { + cellSize, + near(x, y, radius) { + const found = new Set(); + for (let cy = Math.floor((y - radius) / cellSize); cy <= Math.floor((y + radius) / cellSize); cy++) + for (let cx = Math.floor((x - radius) / cellSize); cx <= Math.floor((x + radius) / cellSize); cx++) + for (const entry of cells.get(cx * 65536 + cy) || []) found.add(entry); + return found; + }, + }; +} + +// Distance along the ray to an axis-aligned box from z=0 to z=height, or Infinity. +export function rayBox([ox, oy, oz], [dx, dy, dz], box) { + let near = 0, far = Infinity; + for (const [o, d, min, max] of [[ox, dx, box.x, box.x + box.w], [oy, dy, box.y, box.y + box.h], [oz, dz, 0, box.height]]) { + if (Math.abs(d) < 1e-9) { if (o < min || o > max) return Infinity; continue; } + let t0 = (min - o) / d, t1 = (max - o) / d; + if (t0 > t1) [t0, t1] = [t1, t0]; + near = Math.max(near, t0); far = Math.min(far, t1); + if (near > far) return Infinity; + } + return near; +} + +// Nearest building hit by a ray: { entry, distance } or null. +// ponytail: tests every building; walk the spatial grid along the ray if repos pass ~100k files. +export function pickRay(origin, dir, entries, maxDistance = Infinity) { + let best = null, distance = maxDistance; + for (const entry of entries) { + const t = rayBox(origin, dir, entry); + if (t < distance) { distance = t; best = entry; } + } + return best && { entry: best, distance }; +} diff --git a/web/index.html b/web/index.html index e1da0d4..4575833 100644 --- a/web/index.html +++ b/web/index.html @@ -23,6 +23,7 @@
+
diff --git a/web/renderer.js b/web/renderer.js index 29c57fe..c828ad2 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -1,3 +1,5 @@ +import { perspective, lookAt, multiply, transform, invert, orbitPose, direction, ORBIT_FOV } from './camera.js'; + const VERTICES = new Float32Array([ // top 0,0,1, 1,0,1, 1,1,1, 0,0,1, 1,1,1, 0,1,1, @@ -13,6 +15,15 @@ const VERTICES = new Float32Array([ 0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0, ]); +// Instance kinds in the box program. +export const KIND = { building: 0, block: 1, ground: 2 }; +const FLOATS_PER_INSTANCE = 16; + +// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. +const LANDSCAPE_HEIGHT_SCALE = 420 / 170; +const CITY_FOV = 55 * Math.PI / 180; +export const EYE_HEIGHT = 1.7; + // Shared by every program. 2D is an orthographic map; 3D goes through the same view-projection // matrix that project() uses in JS, so WebGL, the overlay and picking agree exactly. const PROJECT = ` @@ -35,18 +46,25 @@ precision highp float; layout(location=0) in vec3 aUnit; layout(location=1) in vec4 aRect; layout(location=2) in vec4 aColorHeight; +layout(location=3) in vec4 aAtlas; +layout(location=4) in vec4 aMeta; ${PROJECT} out vec4 vColor; out vec3 vUnit; -out vec2 vWorld; +out vec2 vAtlas; +out vec3 vSize; flat out int vFace; +flat out ivec2 vKind; void main(){ - float height = max(1.0, aColorHeight.a); + int kind = int(aMeta.x + .5); + float height = kind == 0 && uMode < 1.5 ? max(1.0, aColorHeight.a) : aColorHeight.a; vec3 world = vec3(aRect.xy + aUnit.xy * aRect.zw, aUnit.z * height); vColor = vec4(aColorHeight.rgb, 1.0); vUnit = aUnit; - vWorld = world.xy; + vAtlas = aAtlas.xy + aUnit.xy * aAtlas.zw; + vSize = vec3(aRect.zw, height); vFace = gl_VertexID / 6; + vKind = ivec2(kind, int(aMeta.w + .5)); gl_Position = projectWorld(world); }`; @@ -54,8 +72,10 @@ const FS = `#version 300 es precision highp float; in vec4 vColor; in vec3 vUnit; -in vec2 vWorld; +in vec2 vAtlas; +in vec3 vSize; flat in int vFace; +flat in ivec2 vKind; uniform float uMode; uniform float uAlpha; uniform sampler2D uCode; @@ -70,16 +90,25 @@ void main(){ vec3 top = vec3(.055, .067, .094); vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top; vec3 stroke = mix(vColor.rgb, vec3(1.0), .12); - if(vFace > 0 && vFace < 5){ + if(vKind.x == 2){ + outColor = vec4(.03, .036, .052, uAlpha); // asphalt + return; + } + if(vKind.x == 1){ + // Blocks are raised pavements tinted by their dominant layer. + base = mix(vec3(.075, .085, .11), vColor.rgb, .07); + stroke = mix(vColor.rgb, base, .55); + } else if(vFace > 0 && vFace < 5){ // Walls: a fixed key light per side and a darker foot make buildings read as solids. float light = vFace == 1 ? .34 : vFace == 2 ? .2 : vFace == 3 ? .26 : .42; base = mix(top, vColor.rgb, light) * (.55 + .45 * vUnit.z); stroke = mix(stroke, base, .45); } else if(vFace == 0 && uCodeOn > .5){ - // Roofs sample the code overview, which is laid out exactly like the 1000x680 world. - vec4 code = texture(uCode, vWorld / vec2(1000.0, 680.0)); + // Roofs sample the code overview canvas at this file's rectangle. + vec4 code = texture(uCode, vAtlas); base = mix(base, code.rgb / max(code.a, .001), code.a * .92); } + if(vKind.y == 1) stroke = vec3(1.0, .94, .54); // selected outColor = vec4(mix(stroke, base, border), uAlpha); }`; @@ -109,10 +138,11 @@ function shader(gl, type, source) { return value; } -import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from './camera.js'; - -// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. -const LANDSCAPE_HEIGHT_SCALE = 420 / 170; +function uniformsOf(gl, prog) { + const out = {}, count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < count; i++) { const name = gl.getActiveUniform(prog, i).name; out[name] = gl.getUniformLocation(prog, name); } + return out; +} export class LandscapeRenderer { constructor(canvas) { @@ -120,8 +150,8 @@ export class LandscapeRenderer { const gl = canvas.getContext('webgl2', { antialias: true, alpha: true, depth: true, powerPreference: 'high-performance' }); if (!gl) throw new Error('WebGL2 is required'); this.gl = gl; - this.program = program(gl, VS, FS); - this.gridProgram = program(gl, GRID_VS, GRID_FS); + this.program = program(gl, VS, FS); this.uniforms = uniformsOf(gl, this.program); + this.gridProgram = program(gl, GRID_VS, GRID_FS); this.gridUniforms = uniformsOf(gl, this.gridProgram); const grid = []; for (let x = -200; x <= 1200; x += 100) grid.push(x, -200, x, 900); for (let y = -200; y <= 900; y += 100) grid.push(-200, y, 1200, y); @@ -131,106 +161,166 @@ export class LandscapeRenderer { this.gridCount = grid.length / 2; this.codeTexture = gl.createTexture(); this.codeOn = false; this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic'); - this.vao = gl.createVertexArray(); gl.bindVertexArray(this.vao); - const vertices = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertices); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW); - gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); - this.instances = gl.createBuffer(); + this.cube = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW); + this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer(); this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 }; - this.mode = '2d'; this.count = 0; + // City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position. + this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, eye: [0, 0, EYE_HEIGHT], lookYaw: 0, lookPitch: 0 }; + this.mode = '2d'; this.resize(); } + boxLayer() { + const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; + gl.bindVertexArray(layer.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); + for (let i = 0; i < 4; i++) { + gl.enableVertexAttribArray(i + 1); gl.vertexAttribPointer(i + 1, 4, gl.FLOAT, false, FLOATS_PER_INSTANCE * 4, i * 16); gl.vertexAttribDivisor(i + 1, 1); + } + return layer; + } + + upload(layer, packed) { + const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW); + layer.count = packed.length / FLOATS_PER_INSTANCE; layer.packed = packed; + } + + // Landscape tiles: items carry x,y,w,h (map units), color and height. setData(items) { this.items = items; - const packed = new Float32Array(items.length * 8); - for (let i=0;i packed.set([item.x, item.y, item.w, item.h, ...item.color, item.height, item.x / 1000, item.y / 680, item.w / 1000, item.h / 680, KIND.building, 0, item.id, 0], i * FLOATS_PER_INSTANCE)); + this.upload(this.landscape, packed); + } + + // City instances: [{x,y,w,h,height,color,atlas:[u,v,du,dv],kind,id}] in metres. + setCity(instances, bounds) { + const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE); + instances.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, ...(b.atlas || [0, 0, 0, 0]), b.kind, 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE)); + this.cityInstances = instances; this.cityBounds = bounds; + this.upload(this.cityLayer, packed); + } + + // Highlights one landscape tile and one city building by file id (0 clears). + setSelected(id) { + for (const layer of [this.landscape, this.cityLayer]) { + const packed = layer.packed; if (!packed) continue; + for (let o = 0; o < packed.length; o += FLOATS_PER_INSTANCE) packed[o + 15] = id && packed[o + 12] === KIND.building && packed[o + 14] === id ? 1 : 0; + const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, packed); } - const gl=this.gl; gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER,this.instances); gl.bufferData(gl.ARRAY_BUFFER,packed,gl.STATIC_DRAW); - gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,32,0); gl.vertexAttribDivisor(1,1); - gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,32,16); gl.vertexAttribDivisor(2,1); - this.count=items.length; } resize() { // Cache the CSS size once per frame: project() runs tens of thousands of times per frame, // and reading clientWidth there forces layout queries that dominated 3D frame time. - this.width=Math.max(1,this.canvas.clientWidth); this.height=Math.max(1,this.canvas.clientHeight); - const dpr=Math.min(devicePixelRatio||1,2), w=Math.max(1,Math.floor(this.width*dpr)), h=Math.max(1,Math.floor(this.height*dpr)); - if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;} - this.dpr=dpr; this.gl.viewport(0,0,w,h); this.updateView(); + this.width = Math.max(1, this.canvas.clientWidth); this.height = Math.max(1, this.canvas.clientHeight); + const dpr = Math.min(devicePixelRatio || 1, 2), w = Math.max(1, Math.floor(this.width * dpr)), h = Math.max(1, Math.floor(this.height * dpr)); + if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; } + this.dpr = dpr; this.gl.viewport(0, 0, w, h); this.updateView(); } // Uploads the code overview canvas as the roof texture; call again after it changes. setCodeTexture(source) { - const gl=this.gl; gl.bindTexture(gl.TEXTURE_2D,this.codeTexture); - gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,true); // mipmaps of straight alpha get dark fringes around glyphs - gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,source); + const gl = this.gl; gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); // mipmaps of straight alpha get dark fringes around glyphs + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); gl.generateMipmap(gl.TEXTURE_2D); - gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR); - gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); - if(this.anisotropy)gl.texParameterf(gl.TEXTURE_2D,this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(8,gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT))); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + if (this.anisotropy) gl.texParameterf(gl.TEXTURE_2D, this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT))); } setCameraUniforms(u) { - const gl=this.gl; - gl.uniform2f(u.uCenter,this.camera.x,this.camera.y); - gl.uniform2f(u.uViewport,this.width,this.height); - gl.uniform1f(u.uZoom,this.camera.zoom); - gl.uniform1f(u.uMode,this.mode==='3d'?1:0); - gl.uniformMatrix4fv(u.uViewProj,false,this.viewProj); - gl.uniform1f(u.uHeightScale,this.heightScale); + const gl = this.gl; + gl.uniform2f(u.uCenter, this.camera.x, this.camera.y); + gl.uniform2f(u.uViewport, this.width, this.height); + gl.uniform1f(u.uZoom, this.camera.zoom); + gl.uniform1f(u.uMode, this.mode === '2d' ? 0 : this.mode === '3d' ? 1 : 2); + if (u.uViewProj) gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); + if (u.uHeightScale) gl.uniform1f(u.uHeightScale, this.heightScale); } - // Recomputes the 3D view-projection from the camera; called once per frame and before picking. + // Eye and look direction of the current city camera. + cityPose() { + const c = this.city; + if (c.view === 'heli') { + const cy = Math.cos(c.yaw), sy = Math.sin(c.yaw), cp = Math.cos(c.pitch), sp = Math.sin(c.pitch); + const eye = [c.x + sy * cp * c.distance, c.y + cy * cp * c.distance, sp * c.distance]; + return { eye, forward: [-sy * cp, -cy * cp, -sp] }; + } + return { eye: c.eye, forward: direction(c.lookYaw, c.lookPitch) }; + } + + // Recomputes the 3D view-projection from the active camera; runs on every resize check. updateView() { - this.heightScale=LANDSCAPE_HEIGHT_SCALE; - const pose=orbitPose(this.camera); - this.eye=pose.eye; - this.viewProj=multiply(perspective(ORBIT_FOV,this.width/this.height,4,60000),lookAt(pose.eye,pose.target,pose.up)); + const aspect = this.width / this.height; + if (this.mode === 'city') { + this.heightScale = 1; + const { eye, forward } = this.cityPose(), c = this.city; + const size = Math.max(this.cityBounds?.width || 1000, this.cityBounds?.height || 1000); + const near = c.view === 'heli' ? Math.max(.5, c.distance * .002) : .1, far = c.view === 'heli' ? c.distance * 3 + size * 2 : size * 2.5 + 2000; + this.eye = eye; this.forward = forward; + this.viewProj = multiply(perspective(CITY_FOV, aspect, near, far), lookAt(eye, [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]], Math.abs(forward[2]) > .999 ? [0, -1, 0] : [0, 0, 1])); + } else { + this.heightScale = LANDSCAPE_HEIGHT_SCALE; + const pose = orbitPose(this.camera); + this.eye = pose.eye; + this.viewProj = multiply(perspective(ORBIT_FOV, aspect, 4, 60000), lookAt(pose.eye, pose.target, pose.up)); + } + this.inverseViewProj = null; } - render(alpha=1) { - const gl=this.gl; this.resize(); - gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); - gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); - const names=['uCenter','uViewport','uZoom','uMode','uViewProj','uHeightScale','uAlpha','uCode','uCodeOn']; - this.uniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.program,n)])); - this.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)])); - if(this.mode==='3d'){ + render(alpha = 1) { + const gl = this.gl; this.resize(); + gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + if (this.mode === '3d') { // Ground grid first without writing depth; buildings then cover it where they stand. gl.useProgram(this.gridProgram); gl.bindVertexArray(this.gridVao); this.setCameraUniforms(this.gridUniforms); - gl.depthMask(false); gl.drawArrays(gl.LINES,0,this.gridCount); gl.depthMask(true); + gl.depthMask(false); gl.drawArrays(gl.LINES, 0, this.gridCount); gl.depthMask(true); } - const u=this.uniforms; - gl.useProgram(this.program); gl.bindVertexArray(this.vao); this.setCameraUniforms(u); - gl.uniform1f(u.uAlpha,alpha); - gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,this.codeTexture); gl.uniform1i(u.uCode,0); - gl.uniform1f(u.uCodeOn,this.codeOn&&this.mode==='3d'?1:0); - gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count); + const u = this.uniforms, layer = this.mode === 'city' ? this.cityLayer : this.landscape; + gl.useProgram(this.program); gl.bindVertexArray(layer.vao); this.setCameraUniforms(u); + gl.uniform1f(u.uAlpha, alpha); + gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); gl.uniform1i(u.uCode, 0); + gl.uniform1f(u.uCodeOn, this.codeOn && this.mode !== '2d' ? 1 : 0); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count); } + // Geometry of an item in the active world: map units for 2D/3D, metres in the city. + geom(item) { return this.mode === 'city' ? item.city || item : item; } + screenRect(item) { - if(this.mode==='2d') return { x:(item.x-this.camera.x)*this.camera.zoom+this.width/2, y:(item.y-this.camera.y)*this.camera.zoom+this.height/2, w:item.w*this.camera.zoom, h:item.h*this.camera.zoom }; - let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity; - for(const [x,y] of [[item.x,item.y],[item.x+item.w,item.y],[item.x,item.y+item.h],[item.x+item.w,item.y+item.h]]){ - const p=this.project([x,y,item.height]); - if(p.xx1)x1=p.x; if(p.yy1)y1=p.y; + const g = this.geom(item); + if (this.mode === '2d') return { x: (g.x - this.camera.x) * this.camera.zoom + this.width / 2, y: (g.y - this.camera.y) * this.camera.zoom + this.height / 2, w: g.w * this.camera.zoom, h: g.h * this.camera.zoom }; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const [x, y] of [[g.x, g.y], [g.x + g.w, g.y], [g.x, g.y + g.h], [g.x + g.w, g.y + g.h]]) { + const p = this.project([x, y, g.height]); + if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; } - return {x:x0,y:y0,w:x1-x0,h:y1-y0}; + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; } // Screen position of a world point; depth is the view distance, and points behind the camera are NaN. - project([x,y,z=0]) { - const [cx,cy,,cw]=transform(this.viewProj,x,y,z*this.heightScale); - if(cw<=1e-3)return {x:NaN,y:NaN,depth:Infinity}; - return {x:(cx/cw+1)/2*this.width,y:(1-cy/cw)/2*this.height,depth:cw}; + project([x, y, z = 0]) { + const [cx, cy, , cw] = transform(this.viewProj, x, y, z * this.heightScale); + if (cw <= 1e-3) return { x: NaN, y: NaN, depth: Infinity }; + return { x: (cx / cw + 1) / 2 * this.width, y: (1 - cy / cw) / 2 * this.height, depth: cw }; } - worldAt(clientX,clientY) { - return {x:this.camera.x+(clientX-this.width/2)/this.camera.zoom,y:this.camera.y+(clientY-this.height/2)/this.camera.zoom}; + // World-space ray through a screen point (3D modes), with z in the active height units. + ray(screenX, screenY) { + this.inverseViewProj ||= invert(this.viewProj); + const m = this.inverseViewProj, nx = screenX / this.width * 2 - 1, ny = 1 - screenY / this.height * 2; + const point = z => { const [x, y, w, q] = transform(m, nx, ny, z); return [x / q, y / q, w / q]; }; + const a = point(-1), b = point(1), len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + return { origin: a, dir: [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len] }; + } + + worldAt(clientX, clientY) { + return { x: this.camera.x + (clientX - this.width / 2) / this.camera.zoom, y: this.camera.y + (clientY - this.height / 2) / this.camera.zoom }; } } diff --git a/webtest/camera.test.mjs b/webtest/camera.test.mjs index 83c1d66..69c8553 100644 --- a/webtest/camera.test.mjs +++ b/webtest/camera.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from '../web/camera.js'; +import { perspective, lookAt, multiply, transform, invert, orbitPose, ORBIT_FOV } from '../web/camera.js'; // The landscape projection before the real camera, kept as the reference. function legacyProject(camera, [x, y, z], width, height) { @@ -26,3 +26,9 @@ test('orbit camera reproduces the legacy landscape projection', () => { } } }); + +test('invert undoes a view-projection', () => { + const m = multiply(perspective(1, 1.5, .1, 1000), lookAt([3, 4, 5], [0, 0, 0])); + const inv = invert(m), id = multiply(m, inv); + for (let i = 0; i < 16; i++) assert.ok(Math.abs(id[i] - (i % 5 === 0 ? 1 : 0)) < 1e-4, `${i}: ${id[i]}`); +}); diff --git a/webtest/city.test.mjs b/webtest/city.test.mjs new file mode 100644 index 0000000..0d160db --- /dev/null +++ b/webtest/city.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layoutCity, buildingHeight, spatialIndex, rayBox, pickRay, CITY } from '../web/city.js'; + +function sampleFiles() { + const files = []; let id = 1; + for (const dir of ['src/core', 'src/ui/widgets', 'tests', 'docs', 'vendor/lib', '']) { + for (let i = 0; i < 12; i++) files.push({ id: id++, path: `${dir ? dir + '/' : ''}file${i}.js`, lines: 20 + (i * 137) % 900, complexity: i * 3 }); + } + return files; +} + +const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + +test('every file gets a building inside the city, and buildings never overlap', () => { + const files = sampleFiles(), city = layoutCity(files, 'demo'); + assert.equal(city.buildings.size, files.length); + const list = [...city.buildings.values()]; + for (const b of list) { + assert.ok(b.w > 0 && b.h > 0 && b.x >= 0 && b.y >= 0 && b.x + b.w <= city.width + 1e-6 && b.y + b.h <= city.height + 1e-6, JSON.stringify(b)); + } + for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) assert.ok(!overlaps(list[i], list[j]), `${i} overlaps ${j}`); +}); + +test('top-level districts are separated by an avenue', () => { + const city = layoutCity(sampleFiles(), 'demo'); + const districts = city.blocks.filter(block => block.depth === 1); + assert.ok(districts.length >= 4); + for (let i = 0; i < districts.length; i++) for (let j = i + 1; j < districts.length; j++) { + const a = districts[i], b = districts[j]; + const gap = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w), b.y - (a.y + a.h), a.y - (b.y + b.h)); + assert.ok(gap >= CITY.roadWidths[0] * .99 || gap >= Math.min(a.w, a.h, b.w, b.h) * .39, `gap ${gap}`); + } +}); + +test('the city grows with the codebase and is deterministic', () => { + const small = layoutCity(sampleFiles().slice(0, 10)), large = layoutCity(sampleFiles()); + assert.ok(large.width * large.height > small.width * small.height * 3); + assert.deepEqual([...layoutCity(sampleFiles()).buildings], [...large.buildings]); +}); + +test('complex files are taller', () => { + assert.ok(buildingHeight({ complexity: 200 }) > buildingHeight({ complexity: 3 })); + assert.equal(buildingHeight({}), 6); +}); + +test('rays hit the nearest box and miss empty space', () => { + const near = { x: 10, y: -1, w: 2, h: 2, height: 5 }, far = { x: 30, y: -1, w: 2, h: 2, height: 50 }; + assert.equal(rayBox([0, 0, 1], [1, 0, 0], near), 10); + assert.equal(rayBox([0, 0, 6], [1, 0, 0], near), Infinity); + assert.equal(pickRay([0, 0, 1], [1, 0, 0], [far, near]).entry, near); + assert.equal(pickRay([0, 0, 1], [-1, 0, 0], [far, near]), null); + assert.equal(pickRay([0, 0, 1], [1, 0, 0], [far, near], 5), null); +}); + +test('spatial index finds boxes near a point', () => { + const a = { x: 0, y: 0, w: 5, h: 5 }, b = { x: 200, y: 200, w: 5, h: 5 }; + const index = spatialIndex([a, b]); + assert.deepEqual([...index.near(2, 2, 3)], [a]); + assert.deepEqual([...index.near(100, 100, 3)], []); +});