Draw walkable street routes from a selected file to its imports
City mode builds a 3 m occupancy grid (buildings grown by half a metre blocked, pavements slightly dearer than roads). Selecting a file runs one Dijkstra from its building's street doors that stops once every imported file's doors are settled, then straightens each route to its necessary turns. Buildings boxed in by neighbours route to the nearest reachable street. Routes are drawn as glowing ground ribbons with chevrons flowing toward each dependency (cyan known, amber inferred) and on the minimap; walking from the helicopter starts at the first route's door facing along it. Distances are float64: float32 rounding made equal-cost paths look like improvements and flooded the queue (a hang on real cities). Tests cover detours, enclosed targets and a large-city time budget; real prometheus data routes 24 imports in about 60 ms.
This commit is contained in:
+33
-6
@@ -1,5 +1,5 @@
|
||||
import { LandscapeRenderer, KIND } from './renderer.js';
|
||||
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js';
|
||||
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, MOVE } from './city.js';
|
||||
import { LabelAtlas } from './labels.js';
|
||||
import { apiFetch, progressEvents } from './api.mjs';
|
||||
|
||||
@@ -46,7 +46,7 @@ let searchHits=[];
|
||||
let expandedCoverageLayer=null;
|
||||
let coverageActiveIndex=-1;
|
||||
let cameraAnimation=null;
|
||||
let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[];
|
||||
let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[];
|
||||
let currentSnapshot=null;
|
||||
let currentName='';
|
||||
let currentMetric='lines';
|
||||
@@ -128,6 +128,7 @@ function computeCity(){
|
||||
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,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)});
|
||||
}
|
||||
cityIndex=spatialIndex(cityEntries);
|
||||
navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel);
|
||||
// Each folder's own buildings (not its subfolders'), for street-corner signs.
|
||||
const byDirectory=new Map();
|
||||
for(const b of cityEntries){const list=byDirectory.get(b.file.directory||'');if(list)list.push(b);else byDirectory.set(b.file.directory||'',[b]);}
|
||||
@@ -135,7 +136,7 @@ function computeCity(){
|
||||
const tally=node=>{let files=node.files.length,lines=node.files.reduce((n,f)=>n+(f.lines||0),0);for(const child of node.children.values()){const t=tally(child);files+=t.files;lines+=t.lines;}return{files,lines};};
|
||||
cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,...tally(block.node),top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))}));
|
||||
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
|
||||
buildMinimap();updateCityHud();updateTrails();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
|
||||
buildMinimap();updateCityHud();updateTrails();updateRoutes();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
|
||||
}
|
||||
|
||||
// ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ----
|
||||
@@ -166,8 +167,11 @@ function setCityView(view,at){
|
||||
c.view=view;
|
||||
if(fromHeli||at){
|
||||
if(fromHeli)Object.assign(c,{ex:eye[0],ey:eye[1],ez:eye[2],lookYaw:Math.atan2(-forward[0],-forward[1]),lookPitch:Math.asin(Math.max(-1,Math.min(1,forward[2])))});
|
||||
const target=at||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,cityIndex);
|
||||
animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw,lookPitch:view==='walk'?0:-.3},c,1100);
|
||||
// With routes showing, walking starts at the selected building's door facing down the first route.
|
||||
const route=!at&&view==='walk'&&cityRoutes[0]?.points.length>1?cityRoutes[0].points:null;
|
||||
const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,cityIndex);
|
||||
const facing=route?Math.atan2(-(route[1][0]-route[0][0]),-(route[1][1]-route[0][1])):null;
|
||||
animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:facing??(view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw),lookPitch:view==='walk'?(route?-.18:0):-.3},c,1100);
|
||||
}else if(view==='walk'){
|
||||
const spot=collide(c.ex,c.ey,MOVE.radius*3,cityIndex);
|
||||
animateCameraTo({ex:spot.x,ey:spot.y,ez:MOVE.eyeHeight,lookPitch:0},c,800);
|
||||
@@ -371,6 +375,10 @@ function drawMinimap(){
|
||||
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(minimapBase,0,0);ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
const heli=c.view==='heli',x=ox+(heli?c.x:c.ex)*scale,y=oy+(heli?c.y:c.ey)*scale,yaw=heli?c.yaw:c.lookYaw;
|
||||
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){ctx.strokeStyle='#fff08a';ctx.lineWidth=1.5;ctx.strokeRect(ox+b.x*scale-1.5,oy+b.y*scale-1.5,b.w*scale+3,b.h*scale+3);}}
|
||||
for(const route of cityRoutes){
|
||||
ctx.strokeStyle=route.kind===0?'rgba(54,211,194,.9)':'rgba(247,189,77,.9)';ctx.lineWidth=1.4;ctx.beginPath();
|
||||
route.points.forEach(([px,py],i)=>i?ctx.lineTo(ox+px*scale,oy+py*scale):ctx.moveTo(ox+px*scale,oy+py*scale));ctx.stroke();
|
||||
}
|
||||
// Heading arrow: yaw 0 points north (up the map).
|
||||
ctx.save();ctx.translate(x,y);ctx.rotate(-yaw);
|
||||
ctx.fillStyle='rgba(54,211,194,.18)';ctx.beginPath();ctx.moveTo(0,0);ctx.arc(0,0,26,-Math.PI/2-.5,-Math.PI/2+.5);ctx.closePath();ctx.fill();
|
||||
@@ -578,7 +586,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;
|
||||
renderer.setSelected(file?.id||0);updateTrails();renderInspector();if(file)loadFileDetails(file);dirty=true;
|
||||
renderer.setSelected(file?.id||0);updateTrails();updateRoutes();renderInspector();if(file)loadFileDetails(file);dirty=true;
|
||||
}
|
||||
|
||||
function renderInspector(){
|
||||
@@ -751,6 +759,25 @@ function updateTrails(){
|
||||
renderer.setTrails(arcs);dirty=true;
|
||||
}
|
||||
|
||||
// Walkable routes along the streets from the selected file to each file it imports.
|
||||
// ponytail: one Dijkstra on the main thread (tens of ms on ~1,700 files); move to a worker if large repos stutter.
|
||||
const MAX_ROUTES=40;
|
||||
function updateRoutes(){
|
||||
const source=selected?.id?layoutById.get(selected.id)?.city:null;
|
||||
cityRoutes=[];
|
||||
if(source&&navGrid){
|
||||
const targets=[],kinds=[],seen=new Set();
|
||||
for(const edge of edgesByFile.get(selected.id)||[]){
|
||||
if(edge.from!==selected.id||seen.has(edge.to))continue;seen.add(edge.to);
|
||||
const target=layoutById.get(edge.to)?.city;if(!target||target===source)continue;
|
||||
targets.push(target);kinds.push(edge.confidence==='known'?0:1);
|
||||
if(targets.length>=MAX_ROUTES)break;
|
||||
}
|
||||
if(targets.length)routesFrom(navGrid,source,targets).forEach((points,i)=>{if(points)cityRoutes.push({points,kind:kinds[i],target:targets[i]});});
|
||||
}
|
||||
renderer.setRoutes(cityRoutes);dirty=true;
|
||||
}
|
||||
|
||||
// Search hits become light columns visible across the city.
|
||||
function updateBeacons(){
|
||||
if(!cityModel){renderer.setBeacons([]);return;}
|
||||
|
||||
+134
@@ -374,3 +374,137 @@ export function folderLabel(path, root, max = 34) {
|
||||
while (parts.length && label.length + parts[parts.length - 1].length + 3 <= max) label = `${parts.pop()}/${label}`;
|
||||
return `…/${label}`;
|
||||
}
|
||||
|
||||
// ---- Walkable grid, routes along streets and wandering residents ----
|
||||
|
||||
// Occupancy grid over the city: cells overlapping a building (grown by `margin`) are blocked, and
|
||||
// cells inside a block (pavements) cost a little more than roads so routes prefer the street.
|
||||
export function buildNavGrid(buildings, blocks, bounds, cell = 3, margin = .5) {
|
||||
const pad = 40, ox = -pad, oy = -pad;
|
||||
const cols = Math.ceil((bounds.width + pad * 2) / cell), rows = Math.ceil((bounds.height + pad * 2) / cell);
|
||||
const blocked = new Uint8Array(cols * rows), cost = new Float32Array(cols * rows).fill(1);
|
||||
const cellsOf = (x0, y0, x1, y1, fn) => {
|
||||
const c0 = Math.max(0, Math.floor((x0 - ox) / cell)), c1 = Math.min(cols - 1, Math.floor((x1 - ox) / cell));
|
||||
const r0 = Math.max(0, Math.floor((y0 - oy) / cell)), r1 = Math.min(rows - 1, Math.floor((y1 - oy) / cell));
|
||||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) fn(r * cols + c);
|
||||
};
|
||||
for (const block of blocks) if (block.depth > 0) cellsOf(block.x, block.y, block.x + block.w - 1e-6, block.y + block.h - 1e-6, i => { cost[i] = 1.35; });
|
||||
for (const b of buildings) cellsOf(b.x - margin, b.y - margin, b.x + b.w + margin - 1e-6, b.y + b.h + margin - 1e-6, i => { blocked[i] = 1; });
|
||||
return { cell, cols, rows, ox, oy, blocked, cost };
|
||||
}
|
||||
|
||||
export const cellCenter = (grid, index) => [grid.ox + (index % grid.cols + .5) * grid.cell, grid.oy + (Math.floor(index / grid.cols) + .5) * grid.cell];
|
||||
const cellAt = (grid, x, y) => {
|
||||
const c = Math.floor((x - grid.ox) / grid.cell), r = Math.floor((y - grid.oy) / grid.cell);
|
||||
return c < 0 || r < 0 || c >= grid.cols || r >= grid.rows ? -1 : r * grid.cols + c;
|
||||
};
|
||||
export const walkable = (grid, x, y) => { const i = cellAt(grid, x, y); return i >= 0 && !grid.blocked[i]; };
|
||||
|
||||
// Free cells in a ring just outside a building's blocked cells: its "doors" onto the street.
|
||||
function doors(grid, b) {
|
||||
const out = [], reach = grid.cell + 1;
|
||||
// Blocked cells that belong to this building (its grown footprint), not a neighbour's.
|
||||
const inside = (c, r) => {
|
||||
const x = grid.ox + (c + .5) * grid.cell, y = grid.oy + (r + .5) * grid.cell, half = grid.cell / 2 + .5;
|
||||
return x > b.x - half && x < b.x + b.w + half && y > b.y - half && y < b.y + b.h + half;
|
||||
};
|
||||
const c0 = Math.max(0, Math.floor((b.x - reach - grid.ox) / grid.cell)), c1 = Math.min(grid.cols - 1, Math.floor((b.x + b.w + reach - grid.ox) / grid.cell));
|
||||
const r0 = Math.max(0, Math.floor((b.y - reach - grid.oy) / grid.cell)), r1 = Math.min(grid.rows - 1, Math.floor((b.y + b.h + reach - grid.oy) / grid.cell));
|
||||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
|
||||
const i = r * grid.cols + c;
|
||||
if (grid.blocked[i]) continue;
|
||||
const touches = (cc, rr) => cc >= 0 && rr >= 0 && cc < grid.cols && rr < grid.rows && grid.blocked[rr * grid.cols + cc] && inside(cc, rr);
|
||||
if (touches(c - 1, r) || touches(c + 1, r) || touches(c, r - 1) || touches(c, r + 1)) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class MinHeap {
|
||||
constructor() { this.keys = []; this.items = []; }
|
||||
get size() { return this.items.length; }
|
||||
push(item, key) {
|
||||
const k = this.keys, it = this.items; let i = it.length; k.push(key); it.push(item);
|
||||
while (i > 0) { const p = (i - 1) >> 1; if (k[p] <= key) break; k[i] = k[p]; it[i] = it[p]; i = p; }
|
||||
k[i] = key; it[i] = item;
|
||||
}
|
||||
pop() {
|
||||
const k = this.keys, it = this.items, top = it[0], key = k.pop(), item = it.pop();
|
||||
if (it.length) {
|
||||
let i = 0;
|
||||
for (;;) { let c = 2 * i + 1; if (c >= it.length) break; if (c + 1 < it.length && k[c + 1] < k[c]) c++; if (k[c] >= key) break; k[i] = k[c]; it[i] = it[c]; i = c; }
|
||||
k[i] = key; it[i] = item;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
// Straight line between two points stays on walkable cells (sampled at half-cell steps).
|
||||
function clearLine(grid, a, b) {
|
||||
const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (grid.cell * .5));
|
||||
for (let s = 1; s < steps; s++) if (!walkable(grid, a[0] + (b[0] - a[0]) * s / steps, a[1] + (b[1] - a[1]) * s / steps)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keeps only the turns a walker needs: each point jumps as far ahead as it can see in a straight line.
|
||||
export function simplifyRoute(grid, points) {
|
||||
if (points.length < 3) return points;
|
||||
// Scans forward and stops at the first obstruction, so each turn costs about its street's length.
|
||||
const out = [points[0]];
|
||||
let i = 0;
|
||||
while (i < points.length - 1) {
|
||||
let j = i + 1;
|
||||
while (j + 1 < points.length && clearLine(grid, points[i], points[j + 1])) j++;
|
||||
out.push(points[j]); i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Street routes from one building to each target building: one Dijkstra pass from the source's
|
||||
// doors that stops once every target door is settled. Returns an array aligned with `targets`
|
||||
// holding point lists (metres), or null where a target cannot be reached.
|
||||
export function routesFrom(grid, source, targets) {
|
||||
const { cols, rows, blocked, cost } = grid, n = cols * rows;
|
||||
// Float64: float32 distances round up, so equal paths look like improvements and flood the queue.
|
||||
const distance = new Float64Array(n).fill(Infinity), parent = new Int32Array(n).fill(-1), heap = new MinHeap();
|
||||
for (const door of doors(grid, source)) { distance[door] = 0; heap.push(door, 0); }
|
||||
const settled = new Uint8Array(n);
|
||||
const goals = targets.map(t => new Set(doors(grid, t)));
|
||||
const reached = targets.map(() => -1);
|
||||
let remaining = targets.length;
|
||||
const neighbours = [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, Math.SQRT2], [1, -1, Math.SQRT2], [-1, 1, Math.SQRT2], [-1, -1, Math.SQRT2]];
|
||||
while (heap.size && remaining) {
|
||||
const i = heap.pop();
|
||||
if (settled[i]) continue; // a stale, longer queue entry
|
||||
settled[i] = 1;
|
||||
const d = distance[i];
|
||||
for (let t = 0; t < goals.length; t++) if (reached[t] < 0 && goals[t].has(i)) { reached[t] = i; remaining--; }
|
||||
const c = i % cols, r = (i - c) / cols;
|
||||
for (const [dc, dr, step] of neighbours) {
|
||||
const nc = c + dc, nr = r + dr;
|
||||
if (nc < 0 || nr < 0 || nc >= cols || nr >= rows) continue;
|
||||
const j = nr * cols + nc;
|
||||
// No corner cutting: a diagonal needs both orthogonal neighbours free.
|
||||
if (blocked[j] || (dc && dr && (blocked[r * cols + nc] || blocked[nr * cols + c]))) continue;
|
||||
const next = d + step * (cost[i] + cost[j]) / 2;
|
||||
if (next < distance[j]) { distance[j] = next; parent[j] = i; heap.push(j, next); }
|
||||
}
|
||||
}
|
||||
// Buildings boxed in by their neighbours have no door on a street: end at the reachable
|
||||
// street cell nearest the building instead.
|
||||
for (let t = 0; t < targets.length; t++) {
|
||||
if (reached[t] >= 0) continue;
|
||||
const b = targets[t], cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!settled[i]) continue;
|
||||
const [x, y] = cellCenter(grid, i), d = (x - cx) ** 2 + (y - cy) ** 2;
|
||||
if (d < best) { best = d; reached[t] = i; }
|
||||
}
|
||||
}
|
||||
return reached.map(end => {
|
||||
if (end < 0) return null;
|
||||
const cells = [];
|
||||
for (let i = end; i >= 0; i = parent[i]) cells.push(cellCenter(grid, i));
|
||||
return simplifyRoute(grid, cells.reverse());
|
||||
});
|
||||
}
|
||||
|
||||
+68
-1
@@ -219,6 +219,41 @@ void main(){
|
||||
}`;
|
||||
const TRAIL_FLOATS = 9;
|
||||
|
||||
// Street routes: flat ribbons on the road with chevrons flowing toward the destination.
|
||||
const ROUTE_VS = `#version 300 es
|
||||
precision highp float;
|
||||
layout(location=0) in vec3 aPos;
|
||||
layout(location=1) in vec3 aAcrossAlongKind;
|
||||
uniform mat4 uViewProj;
|
||||
out vec2 vAcrossAlong;
|
||||
out float vDepth;
|
||||
flat out int vKind;
|
||||
void main(){
|
||||
vAcrossAlong = aAcrossAlongKind.xy; vKind = int(aAcrossAlongKind.z + .5);
|
||||
gl_Position = uViewProj * vec4(aPos, 1.0);
|
||||
vDepth = gl_Position.w;
|
||||
}`;
|
||||
|
||||
const ROUTE_FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vAcrossAlong;
|
||||
in float vDepth;
|
||||
flat in int vKind;
|
||||
uniform float uTime;
|
||||
uniform float uFogDensity;
|
||||
out vec4 outColor;
|
||||
void main(){
|
||||
vec3 color = vKind == 0 ? vec3(.21, .83, .76) : vec3(.97, .74, .3);
|
||||
float across = abs(vAcrossAlong.x);
|
||||
// Chevron tips lead: the phase grows toward the centre line, and time moves it forward.
|
||||
float phase = fract(vAcrossAlong.y * .22 + across * .35 - uTime * 1.4);
|
||||
float chevron = smoothstep(0.0, .06, phase) * (1.0 - smoothstep(.22, .32, phase));
|
||||
float body = 1.0 - smoothstep(.7, 1.0, across);
|
||||
float glow = body * (.16 + .95 * chevron) * exp(-pow(vDepth * uFogDensity, 2.0));
|
||||
outColor = vec4(color * glow, 0.0);
|
||||
}`;
|
||||
const ROUTE_FLOATS = 6;
|
||||
|
||||
const SIGN_FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vUv;
|
||||
@@ -284,6 +319,10 @@ export class LandscapeRenderer {
|
||||
gl.bindVertexArray(this.trailLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.trailLayer.buffer);
|
||||
[[0, 0], [1, 12], [2, 24]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, TRAIL_FLOATS * 4, offset); });
|
||||
this.beaconLayer = this.boxLayer();
|
||||
this.routeProgram = program(gl, ROUTE_VS, ROUTE_FS); this.routeUniforms = uniformsOf(gl, this.routeProgram);
|
||||
this.routeLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
|
||||
gl.bindVertexArray(this.routeLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer);
|
||||
[[0, 0], [1, 12]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, ROUTE_FLOATS * 4, offset); });
|
||||
this.time = 0;
|
||||
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
|
||||
// City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position.
|
||||
@@ -366,6 +405,26 @@ export class LandscapeRenderer {
|
||||
this.trailLayer.count = vertices.length / TRAIL_FLOATS;
|
||||
}
|
||||
|
||||
// routes: [{ points: [[x,y], ...], kind }] drawn on the ground from first point to last.
|
||||
setRoutes(routes, width = 1.6, z = .45) {
|
||||
const vertices = [];
|
||||
for (const route of routes) {
|
||||
let along = 0;
|
||||
for (let i = 1; i < route.points.length; i++) {
|
||||
const [ax, ay] = route.points[i - 1], [bx, by] = route.points[i], length = Math.hypot(bx - ax, by - ay);
|
||||
if (length < 1e-3) continue;
|
||||
// Each segment runs half a width past both ends so corners overlap instead of leaving notches.
|
||||
const dx = (bx - ax) / length, dy = (by - ay) / length, nx = -dy * width / 2, ny = dx * width / 2, ex = dx * width / 2, ey = dy * width / 2;
|
||||
const p0 = [ax - ex, ay - ey], p1 = [bx + ex, by + ey], a0 = along - width / 2, a1 = along + length + width / 2;
|
||||
const v = (p, side, a) => vertices.push(p[0] + nx * side, p[1] + ny * side, z, side, a, route.kind);
|
||||
v(p0, -1, a0); v(p0, 1, a0); v(p1, 1, a1); v(p0, -1, a0); v(p1, 1, a1); v(p1, -1, a1);
|
||||
along += length;
|
||||
}
|
||||
}
|
||||
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
|
||||
this.routeLayer.count = vertices.length / ROUTE_FLOATS;
|
||||
}
|
||||
|
||||
// beams: [{ x, y, w, h, height, color }] rising from search hits.
|
||||
setBeacons(beams) {
|
||||
const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE);
|
||||
@@ -373,7 +432,7 @@ export class LandscapeRenderer {
|
||||
this.upload(this.beaconLayer, packed);
|
||||
}
|
||||
|
||||
get animating() { return this.mode === 'city' && this.trailLayer.count > 0; }
|
||||
get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0); }
|
||||
|
||||
syncAtlas() {
|
||||
const gl = this.gl, atlas = this.atlas; if (!atlas) return;
|
||||
@@ -522,6 +581,14 @@ export class LandscapeRenderer {
|
||||
// Light effects add colour and never occlude: additive blending without depth writes.
|
||||
gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE);
|
||||
if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); }
|
||||
if (this.routeLayer.count) {
|
||||
const r = this.routeUniforms;
|
||||
gl.useProgram(this.routeProgram); gl.bindVertexArray(this.routeLayer.vao);
|
||||
gl.uniformMatrix4fv(r.uViewProj, false, this.viewProj); gl.uniform1f(r.uTime, performance.now() / 1000); gl.uniform1f(r.uFogDensity, fog);
|
||||
gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-1, -4);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, this.routeLayer.count);
|
||||
gl.disable(gl.POLYGON_OFFSET_FILL);
|
||||
}
|
||||
if (this.trailLayer.count) {
|
||||
const t = this.trailUniforms;
|
||||
gl.useProgram(this.trailProgram); gl.bindVertexArray(this.trailLayer.vao);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildNavGrid, routesFrom, walkable } from '../web/city.js';
|
||||
|
||||
// Two buildings separated by a long wall-like building with a gap at the far end.
|
||||
const a = { x: 0, y: 0, w: 10, h: 10, height: 20 };
|
||||
const b = { x: 60, y: 0, w: 10, h: 10, height: 20 };
|
||||
const wall = { x: 30, y: -40, w: 6, h: 100, height: 20 };
|
||||
const bounds = { width: 100, height: 100 };
|
||||
|
||||
const onFreeGround = (grid, points) => {
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [p, q] = [points[i - 1], points[i]], steps = Math.ceil(Math.hypot(q[0] - p[0], q[1] - p[1]));
|
||||
for (let s = 0; s <= steps; s++) assert.ok(walkable(grid, p[0] + (q[0] - p[0]) * s / steps, p[1] + (q[1] - p[1]) * s / steps), `segment ${i} crosses a building`);
|
||||
}
|
||||
};
|
||||
|
||||
test('routes go around buildings and never through them', () => {
|
||||
const grid = buildNavGrid([a, b, wall], [], bounds);
|
||||
const [route] = routesFrom(grid, a, [b]);
|
||||
assert.ok(route && route.length >= 3, 'needs turns to get around the wall');
|
||||
onFreeGround(grid, route);
|
||||
assert.ok(route.some(([, y]) => y > 60), 'detours past the end of the wall');
|
||||
const toEdge = ([x, y], r) => Math.hypot(Math.max(r.x - x, 0, x - r.x - r.w), Math.max(r.y - y, 0, y - r.y - r.h));
|
||||
assert.ok(toEdge(route[0], a) < 8 && toEdge(route.at(-1), b) < 8, `starts and ends beside the buildings: ${route[0]} ${route.at(-1)}`);
|
||||
});
|
||||
|
||||
test('several targets from one search; enclosed ones end at the nearest street', () => {
|
||||
const sealed = { x: 80, y: 80, w: 6, h: 6, height: 5 };
|
||||
const box = [{ x: 74, y: 74, w: 18, h: 2, height: 5 }, { x: 74, y: 90, w: 18, h: 2, height: 5 }, { x: 74, y: 74, w: 2, h: 18, height: 5 }, { x: 90, y: 74, w: 2, h: 18, height: 5 }];
|
||||
const grid = buildNavGrid([a, b, sealed, ...box], [], { width: 120, height: 120 }, 3, .5);
|
||||
const routes = routesFrom(grid, a, [b, sealed]);
|
||||
assert.ok(routes[0]);
|
||||
onFreeGround(grid, routes[0]);
|
||||
// The sealed building can't be entered, so its route ends on the nearest reachable street outside the ring.
|
||||
onFreeGround(grid, routes[1]);
|
||||
const end = routes[1].at(-1);
|
||||
assert.ok(Math.hypot(end[0] - 83, end[1] - 83) < 20 && !(end[0] > 74 && end[0] < 92 && end[1] > 74 && end[1] < 92), `ends outside the ring: ${end}`);
|
||||
});
|
||||
|
||||
test('routes prefer roads over pavements when the detour is small', () => {
|
||||
const pavement = { depth: 1, x: 12, y: -2, w: 40, h: 14 };
|
||||
const grid = buildNavGrid([a, b], [pavement], bounds);
|
||||
const [route] = routesFrom(grid, a, [b]);
|
||||
onFreeGround(grid, route);
|
||||
assert.ok(route.length >= 2);
|
||||
});
|
||||
|
||||
import { layoutCity } from '../web/city.js';
|
||||
|
||||
test('routing a large generated city stays fast (guards against queue flooding)', () => {
|
||||
const files = []; let id = 1;
|
||||
for (let d = 0; d < 40; d++) for (let s = 0; s < 6; s++) for (let f = 0; f < 7; f++) files.push({ id: id++, path: `dir${d}/sub${s}/file${f}.go`, lines: 50 + (id * 7919) % 3000, complexity: (id * 31) % 200 });
|
||||
const city = layoutCity(files), entries = [...city.buildings.values()], grid = buildNavGrid(entries, city.blocks, city);
|
||||
const started = performance.now(), routes = routesFrom(grid, entries[0], entries.slice(100, 140));
|
||||
assert.ok(performance.now() - started < 3000, `took ${performance.now() - started} ms`);
|
||||
assert.ok(routes.every(Boolean), 'every building in a connected city is reachable');
|
||||
routes.forEach(route => { for (const [x, y] of route) assert.ok(walkable(grid, x, y)); });
|
||||
});
|
||||
Reference in New Issue
Block a user