diff --git a/web/app.js b/web/app.js index 921db9e..daf5c0e 100644 --- a/web/app.js +++ b/web/app.js @@ -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;} diff --git a/web/city.js b/web/city.js index e38dabd..e9ba93c 100644 --- a/web/city.js +++ b/web/city.js @@ -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()); + }); +} diff --git a/web/renderer.js b/web/renderer.js index beb34da..c2eed33 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -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); diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs new file mode 100644 index 0000000..392cc73 --- /dev/null +++ b/webtest/nav.test.mjs @@ -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)); }); +});