Mitre route corners and run routes into the buildings

Route ribbons were built from independent segments, each extended half
a width past both ends and offset into its lane separately, so every
turn drew overlapping, brighter corners. Each route is now one strip
with mitred vertices shared by neighbouring segments (sharp reversals
fall back to the outgoing normal), and its along-distance is exact.

Routes also used to stop at the street door; both ends now continue
to the nearest point of the building's footprint and just inside, so
the path visibly meets the wall of the file it leaves and reaches.
This commit is contained in:
Andy Dixon
2026-07-18 15:21:11 +01:00
parent c38a65556c
commit eed28b12cd
3 changed files with 65 additions and 16 deletions
+7 -4
View File
@@ -1,5 +1,5 @@
import { LandscapeRenderer, KIND } from './renderer.js'; 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, 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, MOVE } from './city.js';
import { LabelAtlas, PosterAtlas } from './labels.js'; import { LabelAtlas, PosterAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs'; import { apiFetch, progressEvents } from './api.mjs';
@@ -176,7 +176,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])))}); 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. // 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. // 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>1?(first.outgoing?first.points:[...first.points].reverse()):null; 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 target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex); 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; 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); 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);
@@ -790,8 +790,11 @@ function updateRoutes(){
meta.push({outgoing,kind:outgoing?(edge.confidence==='known'?ROUTE_KIND.knownImport:ROUTE_KIND.inferredImport):ROUTE_KIND.dependent}); meta.push({outgoing,kind:outgoing?(edge.confidence==='known'?ROUTE_KIND.knownImport:ROUTE_KIND.inferredImport):ROUTE_KIND.dependent});
} }
// Paths cost the same both ways, so one search from the selected building serves both directions. // Paths cost the same both ways, so one search from the selected building serves both directions.
if(targets.length)routesFrom(navGrid,source,targets).forEach((points,i)=>{ if(targets.length)routesFrom(navGrid,source,targets).forEach((street,i)=>{
if(points)cityRoutes.push({points:meta[i].outgoing?points:[...points].reverse(),kind:meta[i].kind,outgoing:meta[i].outgoing,target:targets[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]});
}); });
} }
renderer.setRoutes(cityRoutes);dirty=true; renderer.setRoutes(cityRoutes);dirty=true;
+35 -11
View File
@@ -648,19 +648,43 @@ export const ROUTE_KIND = { knownImport: 0, inferredImport: 1, dependent: 2 };
export function ribbonVertices(routes, width = 1.3, z = .45, lane = .8) { export function ribbonVertices(routes, width = 1.3, z = .45, lane = .8) {
const vertices = []; const vertices = [];
for (const route of routes) { 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);
if (points.length < 2) continue;
// Right-of-travel normal per segment: (-dy, dx) in this left-handed world (see heading()).
const normals = [];
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0], dy = points[i][1] - points[i - 1][1], length = Math.hypot(dx, dy);
normals.push([-dy / length, dx / length]);
}
// Mitred offsets at each vertex, so neighbouring segments share their corner edge exactly
// instead of overlapping. Very sharp turns fall back to the outgoing normal.
const mitre = i => {
const a = normals[Math.max(0, i - 1)], b = normals[Math.min(normals.length - 1, i)], dot = a[0] * b[0] + a[1] * b[1];
if (1 + dot < .25) return b;
return [(a[0] + b[0]) / (1 + dot), (a[1] + b[1]) / (1 + dot)];
};
let along = 0; let along = 0;
for (let i = 1; i < route.points.length; i++) { const edge = points.map((p, i) => {
const [ax, ay] = route.points[i - 1], [bx, by] = route.points[i], length = Math.hypot(bx - ax, by - ay); if (i) along += Math.hypot(p[0] - points[i - 1][0], p[1] - points[i - 1][1]);
if (length < 1e-3) continue; const m = mitre(i);
// Right of travel is (-dy, dx) in this left-handed world (see heading()). Each segment also runs 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 };
// half a width past both ends so corners overlap instead of leaving notches. });
const dx = (bx - ax) / length, dy = (by - ay) / length, rx = -dy, ry = dx; for (let i = 1; i < edge.length; i++) {
const ox = rx * lane, oy = ry * lane, ex = dx * width / 2, ey = dy * width / 2, hx = rx * width / 2, hy = ry * width / 2; const a = edge[i - 1], b = edge[i];
const p0 = [ax - ex + ox, ay - ey + oy], p1 = [bx + ex + ox, by + ey + oy], a0 = along - width / 2, a1 = along + length + width / 2; const v = (p, side, at) => vertices.push(p[0], p[1], z, side, at, route.kind);
const v = (p, side, a) => vertices.push(p[0] + hx * side, p[1] + hy * side, z, side, a, route.kind); v(a.left, -1, a.along); v(a.right, 1, a.along); v(b.right, 1, b.along);
v(p0, -1, a0); v(p0, 1, a0); v(p1, 1, a1); v(p0, -1, a0); v(p1, 1, a1); v(p1, -1, a1); v(a.left, -1, a.along); v(b.right, 1, b.along); v(b.left, -1, b.along);
along += length;
} }
} }
return vertices; return vertices;
} }
// Extends a route end from the street into a building: to the nearest point of its footprint, then
// `depth` metres inside, so the path visibly runs up to (and under) the wall.
export function enterBuilding(point, b, depth = .8) {
const nx = Math.max(b.x, Math.min(point[0], b.x + b.w)), ny = Math.max(b.y, Math.min(point[1], b.y + b.h));
const dx = nx - point[0], dy = ny - point[1], length = Math.hypot(dx, dy);
if (length < 1e-6) return [nx, ny];
return [nx + dx / length * depth, ny + dy / length * depth];
}
+23 -1
View File
@@ -125,5 +125,27 @@ test('route ribbons keep right, so opposite directions on one street use separat
assert.ok(ys(east).every(y => y > 0), `eastbound ribbon on +y: ${ys(east)}`); assert.ok(ys(east).every(y => y > 0), `eastbound ribbon on +y: ${ys(east)}`);
assert.ok(ys(west).every(y => y < 0), `westbound ribbon on -y: ${ys(west)}`); assert.ok(ys(west).every(y => y < 0), `westbound ribbon on -y: ${ys(west)}`);
const along = []; for (let i = 4; i < east.length; i += 6) along.push(east[i]); const along = []; for (let i = 4; i < east.length; i += 6) along.push(east[i]);
assert.ok(Math.max(...along) > 99 && Math.min(...along) < 0 && east[5] === 0 && west[5] === 2); assert.ok(Math.max(...along) === 100 && Math.min(...along) === 0 && east[5] === 0 && west[5] === 2);
});
import { enterBuilding } from '../web/city.js';
test('turns are mitred: consecutive segments share their corner edge instead of overlapping', () => {
const vertices = ribbonVertices([{ points: [[0, 0], [50, 0], [50, 40]], kind: 0 }], 1.3, .45, .8);
const at = i => [vertices[i * 6], vertices[i * 6 + 1]];
// Segment 1 ends with vertices 2 (right) and 5 (left); segment 2 starts with 6 (left) and 7 (right).
assert.deepEqual(at(2), at(7));
assert.deepEqual(at(5), at(6));
// The corner vertices sit on the diagonal offsets of the turn, not past the end of either segment.
const [rx, ry] = at(2);
assert.ok(Math.abs(rx - (50 - 1.45)) < 1e-9 && Math.abs(ry - 1.45) < 1e-9, `inner corner ${rx},${ry}`);
assert.equal(vertices.length / 6, 12, 'two quads, no extra caps');
});
test('route ends enter the building footprint', () => {
const b = { x: 10, y: 10, w: 20, h: 10 };
const [x, y] = enterBuilding([20, 4], b, .8);
assert.ok(x === 20 && Math.abs(y - 10.8) < 1e-9, `${x},${y}`);
const corner = enterBuilding([5, 5], b, 1);
assert.ok(corner[0] > 10 && corner[1] > 10 && corner[0] < 11 && corner[1] < 11);
}); });