diff --git a/web/app.js b/web/app.js index c98bcbd..93332c4 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, 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 { 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])))}); // 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>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 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); @@ -790,8 +790,11 @@ function updateRoutes(){ 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. - if(targets.length)routesFrom(navGrid,source,targets).forEach((points,i)=>{ - if(points)cityRoutes.push({points:meta[i].outgoing?points:[...points].reverse(),kind:meta[i].kind,outgoing:meta[i].outgoing,target:targets[i]}); + 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]}); }); } renderer.setRoutes(cityRoutes);dirty=true; diff --git a/web/city.js b/web/city.js index eba15eb..24e148c 100644 --- a/web/city.js +++ b/web/city.js @@ -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) { 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); + 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; - for (let i = 1; i < route.points.length; i++) { - const [ax, ay] = route.points[i - 1], [bx, by] = route.points[i], length = Math.hypot(bx - ax, by - ay); - if (length < 1e-3) continue; - // Right of travel is (-dy, dx) in this left-handed world (see heading()). Each segment also runs - // 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; - const ox = rx * lane, oy = ry * lane, ex = dx * width / 2, ey = dy * width / 2, hx = rx * width / 2, hy = ry * width / 2; - 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, a) => vertices.push(p[0] + hx * side, p[1] + hy * side, z, side, a, route.kind); - v(p0, -1, a0); v(p0, 1, a0); v(p1, 1, a1); v(p0, -1, a0); v(p1, 1, a1); v(p1, -1, a1); - along += length; + const 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 }; + }); + for (let i = 1; i < edge.length; i++) { + const a = edge[i - 1], b = edge[i]; + const v = (p, side, at) => vertices.push(p[0], p[1], z, side, at, route.kind); + v(a.left, -1, a.along); v(a.right, 1, a.along); v(b.right, 1, b.along); + v(a.left, -1, a.along); v(b.right, 1, b.along); v(b.left, -1, b.along); } } 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]; +} diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs index f79c122..9111f88 100644 --- a/webtest/nav.test.mjs +++ b/webtest/nav.test.mjs @@ -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(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]); - 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); });