Doors, door-to-door routes and street furniture in the city
Doors: every building gets one door, centred on a wall where possible and kept clear of the corners. Buildings with street frontage face the nearest open street; buildings in the middle of a folder get a back door with a path along the alleys, found by following a precomputed distance-to-street field on a half-metre grid (alleys widened from 1.2 m to 2.4 m so they are walkable). 1,608 of Prometheus's 1,676 buildings get a door; the rest are slivers too thin for one. Routes: dependency routes now start and end inside buildings, go straight out through the door and along the alley (drawn narrower and single-file) to the street, and the street grid keeps 2 m from walls so a ribbon in its lane can no longer clip a building. A test checks every ribbon vertex between the door frames stays outside all buildings. Street furniture: streetlights on every folder boundary, at corners and about every 20 m, with warm pools of light, and bins, benches, trees, fire hydrants and post boxes between them. Everything sits on a folder's pavement inside its edge, never on the dark road, and nothing blocks a building, a door's approach, an alley path or a nested folder. Furniture is seeded per repository, collides with walkers, and is only drawn within a few hundred metres of the camera, from a separate shader variant so buildings don't pay for its branches; frame cost in the software-GL profile matches the previous build from the default view.
This commit is contained in:
+40
-8
@@ -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])<radius&&Math.abs(item.y-focus[1])<radius)boxes.push(...item.boxes);
|
||||
renderer.setProps(boxes);
|
||||
}
|
||||
function drawCityOverlay(){
|
||||
if(cityModel){updateCityReadouts();updateCitySigns();if(showCode)updateFacade();else renderer.setFacade(null);}
|
||||
if(cityModel){updateCityReadouts();updateCitySigns();updateProps();if(showCode)updateFacade();else renderer.setFacade(null);}
|
||||
// The facade gets this frame's text budget before the background roof atlas.
|
||||
if(showCode){fillCodeOverview();uploadCodeOverview();}
|
||||
}
|
||||
@@ -846,9 +877,10 @@ function updateRoutes(){
|
||||
// Paths cost the same both ways, so one search from the selected building serves both directions.
|
||||
if(targets.length)routesFrom(navGrid,source,targets).forEach((street,i)=>{
|
||||
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;
|
||||
|
||||
+256
-14
@@ -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;
|
||||
}
|
||||
|
||||
+61
-10
@@ -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.
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user