diff --git a/web/app.js b/web/app.js index 056873a..ea9bf1d 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, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, ROUTE_KIND, enterBuilding, 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, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, ROUTE_KIND, enterBuilding, assignDoors, routeThroughDoors, streetFurniture, furnitureBoxes, DOOR, MOVE } from './city.js'; import { LabelAtlas, PosterAtlas } from './labels.js'; import { personFor, residentSeed, drawPortrait } from './people.js'; import { apiFetch, progressEvents } from './api.mjs'; @@ -47,6 +47,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; +let cityFurniture=[],propState={key:''}; let cityModel=null,cityEntries=[],cityIndex=null,collisionIndex=null,cityWallBoxes=[],cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true; let currentSnapshot=null; let currentName=''; @@ -131,8 +132,26 @@ function computeCity(){ cityIndex=spatialIndex(cityEntries); // The wall keeps walkers, residents and routes inside; it's in the collision index but not the pick index. cityWallBoxes=cityWalls(cityModel); - collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes]); - navGrid=buildNavGrid([...cityEntries,...cityWallBoxes],cityModel.blocks,cityModel); + // Routes keep 2 m from walls, so a ribbon in its lane can never clip a building. + navGrid=buildNavGrid([...cityEntries,...cityWallBoxes],cityModel.blocks,cityModel,3,2); + // Back doors find their way out along the alleys on a half-metre grid. + assignDoors(cityEntries,navGrid,buildNavGrid([...cityEntries,...cityWallBoxes],[],cityModel,.5,.3)); + const doorBoxes=[]; + for(const b of cityEntries){ + const d=b.door;if(!d)continue; + const depth=.18,along=[d.n[1],-d.n[0]],cx=d.at[0]+d.n[0]*depth/2,cy=d.at[1]+d.n[1]*depth/2; + const w=Math.abs(along[0])*d.width+Math.abs(d.n[0])*depth,h=Math.abs(along[1])*d.width+Math.abs(d.n[1])*depth; + doorBoxes.push({x:cx-w/2,y:cy-h/2,w,h,height:Math.min(DOOR.height,b.height-.5),color:[1,1,1],kind:KIND.door,lit:0}); + } + // Street furniture on folder pavements, seeded by the repository so each city is furnished the same way. + let furnitureSeed=0;for(const char of currentName)furnitureSeed=(furnitureSeed*33+char.charCodeAt(0))|0; + const furniture=streetFurniture(cityModel.blocks,cityEntries,seededRandom(furnitureSeed)); + const obstacles=[]; + // Doors are culled with the furniture: from any distance where they'd matter they're tiny. + cityFurniture=[...doorBoxes.map(box=>({x:box.x+box.w/2,y:box.y+box.h/2,boxes:[box]})),...furniture.map(item=>({x:item.x,y:item.y,boxes:furnitureBoxes(item).map(part=>({...part,kind:part.part==='pool'?KIND.pool:part.part==='lamp'?KIND.lamp:KIND.prop,lit:part.z}))}))]; + for(const item of cityFurniture)for(const part of item.boxes)if(part.kind===KIND.prop&&part.z<1)obstacles.push({x:part.x,y:part.y,w:part.w,h:part.h,height:part.height}); + propState.key=''; + collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes,...obstacles]); updateFires(); spawnResidents(); // Each folder's own buildings (not its subfolders'), for street-corner signs. @@ -177,7 +196,7 @@ function setCityView(view,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])))}); // With routes showing, walking starts at the selected building's door facing down the first route. // Every route starts (imports) or ends (dependents) at the selected building; walk out from its door. - const first=cityRoutes[0],route=!at&&view==='walk'&&first?.points.length>2?(first.outgoing?first.points:[...first.points].reverse()).slice(1):null; // skip the point inside the building + const first=cityRoutes[0],route=!at&&view==='walk'&&first?.points.length>3?(first.outgoing?first.points:[...first.points].reverse()).slice(2):null; // start on the doorstep const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex); 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); @@ -806,8 +825,20 @@ function drawResidentTag(ctx){ if(!Number.isFinite(head.x)||head.x<0||head.y<0||head.x>renderer.width||head.y>renderer.height)return; drawChip(ctx,personOf(w).name,head.x,head.y-24,200,'#ff5ea8',null,true,'center'); } +// Street furniture is only uploaded near where you're looking: from high up it is sub-pixel, and +// thousands of small boxes are costly to draw everywhere at once. +function updateProps(){ + const c=renderer.city,heli=c.view==='heli'; + const radius=heli?(c.distance>600?0:Math.min(400,Math.max(150,c.distance*.9))):320; + const focus=heli?[c.x,c.y]:[c.ex,c.ey],key=radius?[Math.round(focus[0]/40),Math.round(focus[1]/40),Math.round(radius/100)].join():'none'; + if(key===propState.key)return; + propState.key=key; + const boxes=[]; + if(radius)for(const item of cityFurniture)if(Math.abs(item.x-focus[0]){ if(!street)return; - // Run from inside the selected building, along the streets, and into the other file's building. - const points=[enterBuilding(street[0],source),...street,enterBuilding(street.at(-1),targets[i])]; - cityRoutes.push({points:meta[i].outgoing?points:points.reverse(),kind:meta[i].kind,outgoing:meta[i].outgoing,target:targets[i]}); + // Out of the selected building's door, along the streets, and in through the other building's door. + const {points,narrow}=routeThroughDoors(street,source,targets[i]); + if(!meta[i].outgoing){points.reverse();narrow.reverse();} + cityRoutes.push({points,narrow,kind:meta[i].kind,outgoing:meta[i].outgoing,target:targets[i]}); }); } renderer.setRoutes(cityRoutes);dirty=true; diff --git a/web/city.js b/web/city.js index 24e148c..c5a2f20 100644 --- a/web/city.js +++ b/web/city.js @@ -38,7 +38,7 @@ export const CITY = { 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 + setback: 2.4, // alley between neighbouring buildings, wide enough for a path to a back door cellSize: 32, // spatial grid cell for collision and picking }; @@ -86,7 +86,7 @@ export function layoutCity(files, name = '') { 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); + const gap = Math.min(CITY.setback / 2, rw * .22, rh * .22); buildings.set(value.file.id, { x: rx + gap, y: ry + gap, w: rw - gap * 2, h: rh - gap * 2, height: buildingHeight(value.file) }); }); }; @@ -384,13 +384,13 @@ export function folderLabel(path, root, max = 34) { 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 blocked = new Uint8Array(cols * rows), cost = blocks.length ? new Float32Array(cols * rows).fill(1) : null; 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; }); + if (cost) 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 }; } @@ -440,9 +440,10 @@ class MinHeap { } } -// Straight line between two points stays on walkable cells (sampled at half-cell steps). +// Straight line between two points stays on walkable cells (sampled at quarter-cell steps, so a +// shortcut can't clip a blocked cell's corner by more than a fraction of the margin). export function clearLine(grid, a, b) { - const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (grid.cell * .5)); + const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (grid.cell * .25)); 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; } @@ -468,9 +469,11 @@ 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); } + // Buildings with a door start and finish on its doorstep; others use any cell beside them. + const entrances = b => { const step = b.door && cellAt(grid, ...b.door.step); return step >= 0 && step !== undefined && !blocked[step] ? [step] : doors(grid, b); }; + for (const door of entrances(source)) { distance[door] = 0; heap.push(door, 0); } const settled = new Uint8Array(n); - const goals = targets.map(t => new Set(doors(grid, t))); + const goals = targets.map(t => new Set(entrances(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]]; @@ -487,12 +490,13 @@ export function routesFrom(grid, source, targets) { 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; + const next = d + step * (cost ? (cost[i] + cost[j]) / 2 : 1); 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. + const found = reached.map(end => end >= 0); 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; @@ -503,11 +507,13 @@ export function routesFrom(grid, source, targets) { if (d < best) { best = d; reached[t] = i; } } } - return reached.map(end => { + return reached.map((end, t) => { 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()); + const route = simplifyRoute(grid, cells.reverse()); + route.reached = found[t]; // false when it stopped at the nearest street instead of the target + return route; }); } @@ -649,7 +655,8 @@ export function ribbonVertices(routes, width = 1.3, z = .45, lane = .8) { const vertices = []; for (const route of routes) { // Drop repeated points so every segment has a direction. - const points = route.points.filter((p, i, all) => i === 0 || Math.hypot(p[0] - all[i - 1][0], p[1] - all[i - 1][1]) > 1e-3); + const keep = route.points.map((p, i, all) => i === 0 || Math.hypot(p[0] - all[i - 1][0], p[1] - all[i - 1][1]) > 1e-3); + const points = route.points.filter((_, i) => keep[i]), narrow = (route.narrow || []).filter((_, i) => keep[i]); if (points.length < 2) continue; // Right-of-travel normal per segment: (-dy, dx) in this left-handed world (see heading()). const normals = []; @@ -667,8 +674,9 @@ export function ribbonVertices(routes, width = 1.3, z = .45, lane = .8) { let along = 0; const edge = points.map((p, i) => { if (i) along += Math.hypot(p[0] - points[i - 1][0], p[1] - points[i - 1][1]); - const m = mitre(i); - return { left: [p[0] + m[0] * (lane - width / 2), p[1] + m[1] * (lane - width / 2)], right: [p[0] + m[0] * (lane + width / 2), p[1] + m[1] * (lane + width / 2)], along }; + // Alley points are single-file: narrower and centred instead of in a lane. + const m = mitre(i), w = narrow[i] ? .5 : width, offset = narrow[i] ? 0 : lane; + return { left: [p[0] + m[0] * (offset - w / 2), p[1] + m[1] * (offset - w / 2)], right: [p[0] + m[0] * (offset + w / 2), p[1] + m[1] * (offset + w / 2)], along }; }); for (let i = 1; i < edge.length; i++) { const a = edge[i - 1], b = edge[i]; @@ -688,3 +696,237 @@ export function enterBuilding(point, b, depth = .8) { if (length < 1e-6) return [nx, ny]; return [nx + dx / length * depth, ny + dy / length * depth]; } + +// ---- Doors ---- + +export const DOOR = { width: 2.8, height: 3, cornerClearance: 1.2 }; + +// Gives each building one door, centred on a wall where possible and never within +// DOOR.cornerClearance of a corner. Buildings on a street get their door facing it; buildings +// in the middle of a folder get a back door with a path along the alleys (on `fine`, a +// half-metre grid) out to the nearest street. +// door: { at: [x, y] on the wall, n: outward normal, width, path: [at, ..., step] out to the +// street (step is walkable on `grid`), inside: a point in the building, access: 'street' | 'alley' }. +export function assignDoors(buildings, grid, fine = null) { + let distance = null; // built on first need + for (const b of buildings) { + const candidates = []; + for (const wall of walls(b)) { + const corner = Math.min(DOOR.cornerClearance, wall.width * .2), width = Math.min(DOOR.width, wall.width - corner * 2); + if (width < 1) continue; + const along = [wall.n[1], -wall.n[0]], slack = (wall.width - width) / 2 - corner; + for (const t of [0, -.4, .4, -.8, .8]) { + const offset = t * slack; + candidates.push({ wall, width, t, at: [wall.c[0] + along[0] * offset, wall.c[1] + along[1] * offset] }); + } + } + const door = (c, path, access) => ({ at: c.at, n: c.wall.n, width: c.width, path, step: path.at(-1), inside: [c.at[0] - c.wall.n[0] * 1.5, c.at[1] - c.wall.n[1] * 1.5], access }); + // Straight out onto a street. + let best = null; + for (const c of candidates) { + for (let d = .5; d <= grid.cell * 2; d += .5) { + const p = [c.at[0] + c.wall.n[0] * d, c.at[1] + c.wall.n[1] * d]; + if (!walkable(grid, p[0], p[1])) continue; + const score = d + Math.abs(c.t) * 4; // prefer a short step, then the middle of the wall + if (!best || score < best.score) best = { score, c, path: [c.at, p] }; + break; + } + } + if (best) { b.door = door(best.c, best.path, 'street'); continue; } + const alley = fine && alleyToStreet(candidates, grid, fine, distance ||= streetDistance(grid, fine)); + b.door = alley ? door(alley.c, alley.path, 'alley') : null; + } + return buildings; +} + +// Distance (in half-steps: 2 orthogonal, 3 diagonal) from every open cell of the fine grid to the +// nearest point that is walkable on the street grid, by repeated two-pass chamfer sweeps until +// nothing changes, so it follows winding alleys. Computed once per city. +function streetDistance(grid, fine) { + const { cols, rows, blocked } = fine, n = cols * rows, UNKNOWN = 65534, WALL = 65535; + const dist = new Uint16Array(n); + for (let r = 0, i = 0; r < rows; r++) { + const gr = Math.floor((fine.oy + (r + .5) * fine.cell - grid.oy) / grid.cell); + for (let c = 0; c < cols; c++, i++) { + if (blocked[i]) { dist[i] = WALL; continue; } + const gc = Math.floor((fine.ox + (c + .5) * fine.cell - grid.ox) / grid.cell); + dist[i] = gc >= 0 && gr >= 0 && gc < grid.cols && gr < grid.rows && !grid.blocked[gr * grid.cols + gc] ? 0 : UNKNOWN; + } + } + const relax = (i, j, cost) => { if (dist[j] < WALL && dist[j] + cost < dist[i]) { dist[i] = dist[j] + cost; return true; } return false; }; + for (let pass = 0, changed = true; changed && pass < 40; pass++) { + changed = false; + for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { + const i = r * cols + c; if (dist[i] >= WALL || dist[i] === 0) continue; + if (c > 0) changed = relax(i, i - 1, 2) || changed; + if (r > 0) { changed = relax(i, i - cols, 2) || changed; if (c > 0 && !blocked[i - 1] && !blocked[i - cols]) changed = relax(i, i - cols - 1, 3) || changed; if (c < cols - 1 && !blocked[i + 1] && !blocked[i - cols]) changed = relax(i, i - cols + 1, 3) || changed; } + } + for (let r = rows - 1; r >= 0; r--) for (let c = cols - 1; c >= 0; c--) { + const i = r * cols + c; if (dist[i] >= WALL || dist[i] === 0) continue; + if (c < cols - 1) changed = relax(i, i + 1, 2) || changed; + if (r < rows - 1) { changed = relax(i, i + cols, 2) || changed; if (c < cols - 1 && !blocked[i + 1] && !blocked[i + cols]) changed = relax(i, i + cols + 1, 3) || changed; if (c > 0 && !blocked[i - 1] && !blocked[i + cols]) changed = relax(i, i + cols - 1, 3) || changed; } + } + } + return dist; +} + +// The best candidate door with an alley out to a street: each candidate steps straight out to the +// first open fine cell, then follows the distance field downhill to the street. +function alleyToStreet(candidates, grid, fine, dist) { + const { cols, rows, blocked } = fine; + let best = null; + for (const c of candidates) { + for (let d = .3; d <= 1.6; d += fine.cell / 2) { + const exit = [c.at[0] + c.wall.n[0] * d, c.at[1] + c.wall.n[1] * d], i = cellAt(fine, ...exit); + if (i < 0 || blocked[i]) continue; + if (dist[i] < 65534) { + const score = dist[i] * fine.cell / 2 + Math.abs(c.t) * 4; + if (!best || score < best.score) best = { score, c, exit, start: i }; + } + break; + } + } + if (!best) return null; + const cells = []; + for (let i = best.start, guard = 0; guard < 100000; guard++) { + cells.push(cellCenter(fine, i)); + if (dist[i] === 0) break; + const c = i % cols, r = (i - c) / cols; + let next = -1; + for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { + const nc = c + dc, nr = r + dr; + if (nc < 0 || nr < 0 || nc >= cols || nr >= rows) continue; + const j = nr * cols + nc; + if (blocked[j] || (dc && dr && (blocked[r * cols + nc] || blocked[nr * cols + c]))) continue; + if (next < 0 || dist[j] < dist[next]) next = j; + } + if (next < 0 || dist[next] >= dist[i]) return null; // shouldn't happen once the field has settled + i = next; + } + // Straight out through the doorway, then along the alley. + return { c: best.c, path: [best.c.at, best.exit, ...simplifyRoute(fine, cells)] }; +} + +// The whole route for a street path: from inside one building, out through its door (and along +// its alley, if it has a back door) onto the street, along the street and in through the other +// building's door. Returns { points, narrow } where narrow marks alley points, drawn thinner so +// they fit between buildings. Falls back to the nearest wall when a building has no door or the +// street never reached it. +export function routeThroughDoors(street, from, to) { + const ends = (b, point, reached) => b.door && reached !== false + ? [b.door.inside, ...b.door.path].map(p => ({ p, narrow: b.door.access === 'alley' })) + : [{ p: enterBuilding(point, b), narrow: false }]; + const all = [...ends(from, street[0]), ...street.map(p => ({ p, narrow: false })), ...ends(to, street.at(-1), street.reached).reverse()]; + return { points: all.map(e => e.p), narrow: all.map(e => e.narrow) }; +} + +// ---- Street furniture ---- + +// Everything sits on a folder's pavement just inside its boundary, never on the dark roads: +// streetlights at every corner and every ~20 m along each edge, with bins, benches, trees, +// hydrants and post boxes between them. Nothing goes near a building, a doorstep or a nested folder. +export const FURNITURE = { lightSpacing: 20 }; +const FURNITURE_TYPES = [ + { type: 'bin', weight: 30, inset: .9, clearance: .8 }, + { type: 'bench', weight: 24, inset: 1.1, clearance: 1.2 }, + { type: 'tree', weight: 28, inset: 1.6, clearance: 1.9 }, + { type: 'hydrant', weight: 12, inset: .7, clearance: .6 }, + { type: 'postbox', weight: 6, inset: .9, clearance: .8 }, +]; + +export function streetFurniture(blocks, buildings, random = Math.random) { + const out = [], index = spatialIndex(buildings); + // Keep a clear approach to every door: nothing within 2.5 m of the straight path from the door + // out towards the road (12 m), which is where routes and people come and go. + const approaches = buildings.filter(b => b.door && b.door.access === 'street').map(({ door }) => ({ a: door.at, n: door.n })); + const alleyIndex = spatialIndex(buildings.filter(b => b.door && b.door.access === 'alley').flatMap(({ door }) => door.path).map(([x, y]) => ({ x, y, w: 0, h: 0 })), 16); + const approachIndex = spatialIndex(approaches.map(({ a, n }) => ({ x: Math.min(a[0], a[0] + n[0] * 12), y: Math.min(a[1], a[1] + n[1] * 12), w: Math.abs(n[0]) * 12, h: Math.abs(n[1]) * 12, a, n })), 16); + const onApproach = (x, y) => [...approachIndex.near(x, y, 3)].some(({ a, n }) => { + const along = (x - a[0]) * n[0] + (y - a[1]) * n[1], across = Math.abs((x - a[0]) * n[1] - (y - a[1]) * n[0]); + return along > -1 && along < 12 && across < 2.5; + }); + const nested = blocks.filter(block => block.depth > 1); + const distanceToBuildings = (x, y, radius) => { + let nearest = Infinity; + for (const b of index.near(x, y, radius + 1)) nearest = Math.min(nearest, Math.hypot(Math.max(b.x - x, 0, x - b.x - b.w), Math.max(b.y - y, 0, y - b.y - b.h))); + return nearest; + }; + const free = (block, x, y, clearance) => + distanceToBuildings(x, y, clearance) >= clearance && + !onApproach(x, y) && [...alleyIndex.near(x, y, 2.5)].every(p => Math.hypot(p.x - x, p.y - y) > 2.5) && + !nested.some(o => o !== block && o.depth > block.depth && x > o.x - clearance && x < o.x + o.w + clearance && y > o.y - clearance && y < o.y + o.h + clearance); + const pick = () => { + let roll = random() * FURNITURE_TYPES.reduce((sum, t) => sum + t.weight, 0); + for (const t of FURNITURE_TYPES) if ((roll -= t.weight) < 0) return t; + return FURNITURE_TYPES[0]; + }; + for (const block of blocks) { + if (block.depth < 1 || block.w < 6 || block.h < 6) continue; + // Edges clockwise; `out` points away from the block, towards the road. + const edges = [ + { a: [block.x, block.y], b: [block.x + block.w, block.y], out: [0, -1] }, + { a: [block.x + block.w, block.y], b: [block.x + block.w, block.y + block.h], out: [1, 0] }, + { a: [block.x + block.w, block.y + block.h], b: [block.x, block.y + block.h], out: [0, 1] }, + { a: [block.x, block.y + block.h], b: [block.x, block.y], out: [-1, 0] }, + ]; + for (const edge of edges) { + const length = Math.hypot(edge.b[0] - edge.a[0], edge.b[1] - edge.a[1]), dir = [(edge.b[0] - edge.a[0]) / length, (edge.b[1] - edge.a[1]) / length]; + const at = (distance, inset) => [edge.a[0] + dir[0] * distance - edge.out[0] * inset, edge.a[1] + dir[1] * distance - edge.out[1] * inset]; + // Lights include the edge's starting corner (each corner starts exactly one edge). + const lights = Math.max(1, Math.round(length / FURNITURE.lightSpacing)), spacing = length / lights; + for (let i = 0; i < lights; i++) { + const distance = i === 0 ? .6 : i * spacing, [x, y] = at(distance, .5); + if (free(block, x, y, .5)) out.push({ type: 'light', x, y, out: edge.out, along: dir, block }); + const between = distance + spacing / 2; + if (between > length - 1.5) continue; + const kind = pick(), [px, py] = at(between, kind.inset); + if (free(block, px, py, kind.clearance)) out.push({ type: kind.type, x: px, y: py, out: edge.out, along: dir, block }); + } + } + } + return out; +} + +// Boxes that draw a piece of furniture: { x, y, w, h, z, height, color, part } where part is +// 'solid', 'lamp' (emissive) or 'pool' (a pool of light on the pavement). +export function furnitureBoxes(item) { + const boxes = [], { x, y, out, along } = item; + const box = (ax, ay, sx, sy, z, height, color, part = 'solid') => { + // ax/ay are offsets along the edge and out towards the road; sx/sy sizes in those directions. + const cx = x + along[0] * ax + out[0] * ay, cy = y + along[1] * ax + out[1] * ay; + const w = Math.abs(along[0]) * sx + Math.abs(out[0]) * sy, h = Math.abs(along[1]) * sx + Math.abs(out[1]) * sy; + boxes.push({ x: cx - w / 2, y: cy - h / 2, w, h, z, height, color, part }); + }; + switch (item.type) { + case 'light': + box(0, 0, .18, .18, 0, 5.6, [.18, .18, .2]); + box(0, .55, .12, 1.2, 5.45, .12, [.18, .18, .2]); + box(0, 1.05, .45, .6, 5.2, .25, [1, .86, .55], 'lamp'); + box(0, .6, 9, 9, .38, .02, [1, .78, .42], 'pool'); + break; + case 'bin': + box(0, 0, .6, .6, 0, .95, [.12, .32, .2]); + box(0, 0, .7, .7, .95, .08, [.08, .2, .13]); + break; + case 'bench': + box(0, 0, 1.7, .45, 0, .42, [.2, .16, .12]); + box(0, 0, 1.8, .5, .42, .08, [.55, .36, .2]); + box(0, -.24, 1.8, .08, .5, .5, [.55, .36, .2]); + break; + case 'tree': + // Canopy starts above head height so walkers pass underneath. + box(0, 0, .32, .32, 0, 3.4, [.3, .2, .12]); + box(0, 0, 2.4, 2.4, 3, 1.5, [.16, .42, .2]); + box(0, 0, 1.6, 1.6, 4.5, 1, [.2, .5, .24]); + break; + case 'hydrant': + box(0, 0, .32, .32, 0, .6, [.85, .72, .1]); + box(0, 0, .42, .2, .38, .12, [.7, .58, .08]); + break; + case 'postbox': + box(0, 0, .55, .55, 0, 1.2, [.78, .08, .08]); + box(0, 0, .65, .65, 1.2, .14, [.6, .05, .05]); + break; + } + return boxes; +} diff --git a/web/renderer.js b/web/renderer.js index 5d2f6ca..784d4ee 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -17,7 +17,7 @@ const VERTICES = new Float32Array([ ]); // Instance kinds in the box program. -export const KIND = { building: 0, block: 1, ground: 2, beacon: 3, wall: 4 }; +export const KIND = { building: 0, block: 1, ground: 2, beacon: 3, wall: 4, door: 5, prop: 6, lamp: 7, pool: 8 }; const FLOATS_PER_INSTANCE = 16; // Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. @@ -61,7 +61,9 @@ flat out vec2 vLit; void main(){ 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); + // Doors and street furniture carry their base height in the otherwise unused lit slot. + float base = kind >= 5 ? aMeta.y : 0.0; + vec3 world = vec3(aRect.xy + aUnit.xy * aRect.zw, base + aUnit.z * height); vColor = vec4(aColorHeight.rgb, 1.0); vUnit = aUnit; vAtlas = aAtlas.xy + aUnit.xy * aAtlas.zw; @@ -75,9 +77,12 @@ void main(){ // Two variants from one source: the landscape build compiles none of the city's fog, window // and beacon code, which otherwise tripled fragment cost in software renderers. -const fragmentShader = city => `#version 300 es +// props: doors and street furniture get their own tiny variant, so every building pixel doesn't pay +// for their branches. +const fragmentShader = (city, props = false) => `#version 300 es precision highp float; ${city ? '#define CITY 1' : ''} +${props ? '#define PROPS 1' : ''} in vec4 vColor; in vec3 vUnit; in vec2 vAtlas; @@ -100,6 +105,35 @@ vec3 fog(vec3 color){ return mix(color, uFogColor, 1.0 - exp(-pow(vDepth * uFogD vec3 fog(vec3 color){ return color; } #endif void main(){ +#ifdef PROPS + if(vKind.x == 8){ + // Pool of lamp light on the pavement, drawn additively: a soft warm disc. + if(vFace != 0) discard; + float r = length(vUnit.xy * 2.0 - 1.0); + outColor = vec4(vColor.rgb * pow(max(0.0, 1.0 - r), 2.0) * .28 * (1.0 - smoothstep(.5, 1.0, 1.0 - exp(-pow(vDepth * uFogDensity, 2.0)))), 0.0); + return; + } + if(vKind.x == 7){ outColor = vec4(fog(vColor.rgb * 1.6), uAlpha); return; } // lamp head + if(vKind.x == 6){ + // Street furniture: flat colour with the same per-side light as buildings. + float light = vFace == 0 ? 1.0 : vFace == 1 ? .8 : vFace == 2 ? .55 : vFace == 3 ? .65 : vFace == 4 ? .9 : .4; + outColor = vec4(fog(vColor.rgb * light), uAlpha); + return; + } + if(vKind.x == 5){ + // Door: a dark recessed doorway with a warm lit frame, a glowing transom and a handle. + vec2 f = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; + float frame = 1.0 - step(.09, f.x) * step(f.x, .91) * step(f.y, .93); + float transom = step(.8, f.y) * step(f.y, .9) * step(.15, f.x) * step(f.x, .85); + float handle = step(.72, f.x) * step(f.x, .78) * step(.42, f.y) * step(f.y, .5); + vec3 warm = vec3(1.0, .76, .42); + vec3 c = mix(vec3(.05, .04, .035), warm * 1.3, max(frame, handle * .8)); + c = mix(c, warm * .9, transom); + outColor = vec4(fog(vFace == 0 ? warm * .6 : c), uAlpha); + return; + } + discard; +#endif #ifdef CITY if(vKind.x == 3){ // Search beacon: a light column fading upward, open at the top. @@ -435,6 +469,7 @@ export class LandscapeRenderer { this.gl = gl; this.program = program(gl, VS, fragmentShader(false)); this.uniforms = uniformsOf(gl, this.program); this.cityProgram = program(gl, VS, fragmentShader(true)); this.cityUniforms = uniformsOf(gl, this.cityProgram); + this.propProgram = program(gl, VS, fragmentShader(true, true)); this.propUniforms = uniformsOf(gl, this.propProgram); 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); @@ -446,7 +481,7 @@ export class LandscapeRenderer { this.codeTexture = gl.createTexture(); this.codeOn = false; this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic'); 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.cityFloor = this.boxLayer(); + this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer(); this.cityFloor = this.boxLayer(); this.glowLayer = this.boxLayer(); this.propLayer = this.boxLayer(); this.signProgram = program(gl, SIGN_VS, SIGN_FS); this.signUniforms = uniformsOf(gl, this.signProgram); this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.posterLayer = this.quadLayer(); this.posterTexture = this.texture(); @@ -655,16 +690,25 @@ export class LandscapeRenderer { // The floor (ground and folder pavements, in nesting order) is its own layer: its surfaces are // centimetres apart, far below depth-buffer precision from the air, so it is painted in order // without depth testing instead of z-fighting (see render()). + static packBoxes(list) { + const packed = new Float32Array(list.length * FLOATS_PER_INSTANCE); + list.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, b.lit || 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE)); + return packed; + } + + // Street furniture near the camera (props and lamp heads) plus their pools of light. + setProps(boxes) { + const pack = LandscapeRenderer.packBoxes; + this.upload(this.propLayer, pack(boxes.filter(b => b.kind !== KIND.pool))); + this.upload(this.glowLayer, pack(boxes.filter(b => b.kind === KIND.pool))); + } + setCity(instances, bounds) { - const pack = list => { - const packed = new Float32Array(list.length * FLOATS_PER_INSTANCE); - list.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, b.lit || 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE)); - return packed; - }; + const pack = LandscapeRenderer.packBoxes; const floor = instances.filter(b => b.kind === KIND.ground || b.kind === KIND.block); this.cityInstances = instances; this.cityBounds = bounds; this.upload(this.cityFloor, pack(floor)); - this.upload(this.cityLayer, pack(instances.filter(b => b.kind !== KIND.ground && b.kind !== KIND.block))); + this.upload(this.cityLayer, pack(instances.filter(b => b.kind !== KIND.ground && b.kind !== KIND.block && b.kind !== KIND.pool))); } // Highlights one landscape tile and one city building by file id (0 clears). @@ -771,9 +815,16 @@ export class LandscapeRenderer { gl.enable(gl.DEPTH_TEST); gl.bindVertexArray(layer.vao); } gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count); + const useProps = () => { + const p = this.propUniforms; + gl.useProgram(this.propProgram); this.setCameraUniforms(p); gl.uniform1f(p.uAlpha, alpha); + gl.uniform1f(p.uFogDensity, fog); gl.uniform3f(p.uFogColor, ...fogColor); + }; + if (city && this.propLayer.count) { useProps(); gl.bindVertexArray(this.propLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.propLayer.count); gl.useProgram(this.cityProgram); } if (this.mode === 'city') { // Light effects add colour and never occlude: additive blending without depth writes. gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE); + if (this.glowLayer.count) { useProps(); gl.bindVertexArray(this.glowLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.glowLayer.count); gl.useProgram(this.cityProgram); } if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); } if (this.smokeLayer.count) { // Camera-facing particles: the view matrix rows are the camera's right and up axes. diff --git a/webtest/infrastructure.test.mjs b/webtest/infrastructure.test.mjs new file mode 100644 index 0000000..48d51eb --- /dev/null +++ b/webtest/infrastructure.test.mjs @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layoutCity, buildNavGrid, routesFrom, assignDoors, routeThroughDoors, ribbonVertices, streetFurniture, furnitureBoxes, walkable, DOOR, seededRandom } from '../web/city.js'; + +const ROUTE_MARGIN = 2; +function city() { + const files = []; let id = 1; + for (let d = 0; d < 12; d++) for (let s = 0; s < 4; s++) for (let f = 0; f < (s === 0 ? 40 : 6); f++) files.push({ id: id++, path: `dir${d}/sub${s}/file${f}.go`, lines: 60 + (id * 7919) % 2500, complexity: (id * 31) % 150 }); + const model = layoutCity(files, 'x'), buildings = [...model.buildings.values()]; + const grid = buildNavGrid(buildings, model.blocks, model, 3, ROUTE_MARGIN); + assignDoors(buildings, grid, buildNavGrid(buildings, [], model, .5, .3)); + return { model, buildings, grid }; +} +const inside = (b, x, y, pad = 0) => x > b.x + pad && x < b.x + b.w - pad && y > b.y + pad && y < b.y + b.h - pad; +const distance = (b, x, y) => Math.hypot(Math.max(b.x - x, 0, x - b.x - b.w), Math.max(b.y - y, 0, y - b.y - b.h)); + +test('every door sits on its building wall, away from corners, with an open doorstep', () => { + const { buildings, grid } = city(); + const withDoors = buildings.filter(b => b.door); + assert.ok(withDoors.length / buildings.length > .97, `nearly every building has a door (${withDoors.length}/${buildings.length})`); + assert.ok(withDoors.some(b => b.door.access === 'alley') && withDoors.some(b => b.door.access === 'street')); + for (const b of withDoors) { + const { at, n, width, step, path } = b.door; + assert.deepEqual(path[0], at); + for (let i = 1; i < path.length; i++) for (const other of buildings) assert.ok(!inside(other, ...path[i], .05), 'door path stays out of buildings'); + assert.ok(distance(b, ...at) < 1e-6, 'on the wall'); + const cornerX = Math.min(at[0] - b.x, b.x + b.w - at[0]), cornerY = Math.min(at[1] - b.y, b.y + b.h - at[1]); + const alongCorner = n[0] ? cornerY : cornerX, wallWidth = n[0] ? b.h : b.w; + assert.ok(alongCorner >= width / 2 + Math.min(DOOR.cornerClearance, wallWidth * .2) - 1e-6, `door clear of the corner: ${alongCorner}`); + assert.ok(walkable(grid, ...step), 'doorstep is on the street'); + assert.ok((path[1][0] - at[0]) * n[0] + (path[1][1] - at[1]) * n[1] > 0, 'path leaves through the wall'); + assert.ok(inside(b, ...b.door.inside), 'inside point is inside'); + } +}); + +test('routes leave and enter through doors and never clip another building', () => { + const { buildings, grid } = city(); + const source = buildings.find(b => b.door?.access === 'alley'), targets = buildings.filter(b => b !== source && b.door).filter((_, i) => i % 5 === 0).slice(0, 30); + assert.ok(targets.some(t => t.door.access === 'alley')); + const streets = routesFrom(grid, source, targets); + const halfWidth = .8 + 1.3 / 2; // lane offset plus half the ribbon + let checked = 0; + streets.forEach((street, t) => { + assert.ok(street && street.reached, `target ${t} reached by street`); + const { points, narrow } = routeThroughDoors(street, source, targets[t]); + assert.deepEqual(points[0], source.door.inside); + assert.deepEqual(points[1], source.door.at); + assert.deepEqual(points.at(-1), targets[t].door.inside); + assert.equal(narrow.length, points.length); + // Everything between the two door frames: ribbon edges stay out of every building. + const verts = ribbonVertices([{ points: points.slice(1, -1), narrow: narrow.slice(1, -1), kind: 0 }]); + const segments = verts.length / 36; + for (let i = 0; i < verts.length; i += 6) { + const segment = Math.floor(i / 36); + if (segment === 0 || segment === segments - 1) continue; // the door segments touch their own walls + for (const b of buildings) assert.ok(!inside(b, verts[i], verts[i + 1], .05), `ribbon vertex ${verts[i]},${verts[i + 1]} inside a building`); + checked++; + } + // Door segments are perpendicular to the wall. + const [a, d] = [points[1], points[2]], n = source.door.n; + assert.ok(Math.abs((d[0] - a[0]) * n[1] - (d[1] - a[1]) * n[0]) < 1e-9); + }); + assert.ok(checked > 50); + assert.ok(halfWidth < ROUTE_MARGIN); +}); + +test('street furniture stays on pavements along folder edges, clear of buildings and doors', () => { + const { model, buildings } = city(); + const items = streetFurniture(model.blocks, buildings, seededRandom(4)); + const lights = items.filter(i => i.type === 'light'); + assert.ok(lights.length > 50, `plenty of lights (${lights.length})`); + assert.ok(new Set(items.map(i => i.type)).size >= 5, 'a mix of furniture'); + for (const item of items) { + const block = item.block; + assert.ok(block.depth >= 1 && inside(block, item.x, item.y), 'on a folder pavement, not the road'); + for (const b of buildings) assert.ok(distance(b, item.x, item.y) >= .5, `${item.type} clear of buildings`); + for (const b of buildings) if (b.door) { + const { at, n } = b.door, along = (item.x - at[0]) * n[0] + (item.y - at[1]) * n[1], across = Math.abs((item.x - at[0]) * n[1] - (item.y - at[1]) * n[0]); + assert.ok(!(along > -1 && along < 12 && across < 2.5), `${item.type} not on a door's approach`); + } + for (const box of furnitureBoxes(item)) if (box.part !== 'pool' && box.part !== 'lamp' && box.z < 2) { + assert.ok(inside(block, box.x, box.y, -1e-6) && inside(block, box.x + box.w, box.y + box.h, -1e-6), `${item.type} footprint within its pavement`); + } + } + for (const light of lights) { + const b = light.block, edge = Math.min(light.x - b.x, b.x + b.w - light.x, light.y - b.y, b.y + b.h - light.y); + assert.ok(Math.abs(edge - .5) < 1e-6, `light on the folder boundary line (${edge})`); + } +});