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)], []);
+});