This commit is contained in:
33333-33333 2026-05-28 15:48:42 +09:00
commit 860471f805
13 changed files with 1643 additions and 356 deletions

View file

@ -1,4 +1,4 @@
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js";
import { pathLengthCells } from "./mapTransport.js";
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
@ -19,15 +19,38 @@ function pathTerrainRuns(path, terrain = null) {
const ridgeField = terrain?.ridgeField;
const naturalBarrierScore = terrain?.naturalBarrierScore;
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
for (const [x, y] of path || []) {
if (!inside(x, y)) continue;
let sampled = 0;
const visit = (x, y) => {
if (!inside(x, y)) {
seaRun++;
maxSeaRun = Math.max(maxSeaRun, seaRun);
tunnelRun = 0;
sampled++;
return;
}
const i = indexOf(x, y);
const isSea = Boolean(sea?.[i]);
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
// Use the same sensitive tunnel proxy as the main transport validator.
// Sampling every raster cell along each segment prevents smoothed or direct
// paths from hiding over-limit tunnel runs between sparse vertices.
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72);
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
sampled++;
};
for (let k = 1; k < (path?.length || 0); k++) {
const a = path[k - 1];
const b = path[k];
if (!a || !b) continue;
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
for (let s = 0; s <= steps; s++) {
if (k > 1 && s === 0) continue;
const t = s / steps;
visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
}
}
return { maxSeaRun, maxTunnelRun };
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
return { maxSeaRun, maxTunnelRun, sampled };
}
function directPath(a, b, options = {}) {
@ -50,6 +73,70 @@ function directPath(a, b, options = {}) {
return out;
}
function routeTerrainPath(a, b, terrain = null, options = {}) {
if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return [];
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const slope = terrain?.slope;
const ridgeField = terrain?.ridgeField;
const start = indexOf(Math.round(a.x), Math.round(a.y));
const goal = indexOf(Math.round(b.x), Math.round(b.y));
if (sea?.[start] || sea?.[goal]) return [];
const straight = Math.hypot(a.x - b.x, a.y - b.y);
const maxLength = options.maxLength ?? straight * 2.8 + 60;
const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5)));
const dist = new Float64Array(SIZE);
dist.fill(INF);
const prev = new Int32Array(SIZE);
prev.fill(-1);
const closed = new Uint8Array(SIZE);
const heap = new MinHeap();
dist[start] = 0;
prev[start] = start;
heap.push({ i: start, f: straight * 0.42 });
let hit = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
const cur = current.i;
closed[cur] = 1;
const [x, y] = xyOf(cur);
if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; }
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea?.[ni]) continue;
const step = Math.hypot(dx, dy);
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05;
const nd = dist[cur] + step * Math.max(0.42, terrainCost);
if (nd >= dist[ni]) continue;
dist[ni] = nd;
prev[ni] = cur;
const h = Math.hypot(nx - b.x, ny - b.y) * 0.42;
heap.push({ i: ni, f: nd + h });
}
}
if (hit < 0) return [];
const path = [];
let cur = hit;
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
const [x, y] = xyOf(cur);
path.push([x, y]);
if (prev[cur] === cur) break;
cur = prev[cur];
}
path.reverse();
if (path.length < 2 || pathLengthCells(path) > maxLength) return [];
const runs = pathTerrainRuns(path, terrain);
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
return path;
}
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
let best = null;
for (const path of paths || []) {
@ -194,6 +281,44 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
function townWeight(p) {
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
}
function relayGeometryAcceptable(points, options = {}) {
const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
if (pts.length < 3) return true;
const first = pts[0];
const last = pts[pts.length - 1];
const vx = last.x - first.x;
const vy = last.y - first.y;
const direct = Math.hypot(vx, vy);
if (direct < 0.001) return false;
let via = 0;
for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
const maxDetour = options.maxDetour ?? 1.68;
if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false;
const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32)));
for (let i = 1; i < pts.length - 1; i++) {
const p = pts[i];
const wx = p.x - first.x;
const wy = p.y - first.y;
const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct);
const projX = first.x + vx * t;
const projY = first.y + vy * t;
const offset = Math.hypot(p.x - projX, p.y - projY);
if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false;
if (offset > maxOffset) return false;
}
return true;
}
function pathGeometryAcceptable(path, options = {}) {
if (!path || path.length < 3) return true;
const step = Math.max(1, Math.floor(path.length / 10));
const pts = [];
for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] });
const last = path[path.length - 1];
pts.push({ x: last[0], y: last[1] });
return relayGeometryAcceptable(pts, options);
}
function nearestTrunkOrHub(p, maxDistance = 85) {
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
if (trunk) return trunk;
@ -225,20 +350,31 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
let townsCovered = 0;
while (uncovered.length) {
const start = uncovered.shift();
const chain = buildTownChain(start, uncovered, 7);
let chain = buildTownChain(start, uncovered, 7);
uncovered = uncovered.filter((town) => !chain.includes(town));
const parts = [];
const before = nearestTrunkOrHub(chain[0], 80);
let before = nearestTrunkOrHub(chain[0], 80);
let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]];
if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) {
// The town-chain pass is a coverage fallback, not a mandate to drag a
// road through a remote off-axis waypoint. Collapse to a single spur
// when the waypoint chain would create a hooked or S-shaped route.
chain = [chain[0]];
before = nearestTrunkOrHub(chain[0], 80);
after = null;
}
if (before) {
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
for (let i = 1; i < chain.length; i++) {
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
const segmentPoints = [chain[i - 1], chain[i]];
if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue;
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
if (after) {
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
@ -248,7 +384,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const target = nearestTrunkOrHub(chain[0], 90);
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
}
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
nationalRoads.push(path);
chainsAdded++;
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
@ -286,30 +422,123 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
}
function suburbanExpresswayAnchorForCity(city, target = null) {
if (!city || !inside(city.x, city.y)) return null;
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const slope = terrain?.slope;
const ridgeField = terrain?.ridgeField;
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6));
const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9));
let best = null;
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = city.x + dx, y = city.y + dy;
if (!inside(x, y)) continue;
const d = Math.hypot(dx, dy);
if (d < inner || d > outer) continue;
const i = indexOf(x, y);
if (sea?.[i]) continue;
const radial = Math.abs(d - (inner + outer) * 0.52);
const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0;
const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75;
if (!best || score > best.score) best = { x, y, score };
}
}
return best;
}
function suburbanExpresswayStubForCity(city, preferredAnchor = null) {
if (!city || !inside(city.x, city.y)) return [];
const angles = [];
if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x));
for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0));
const seenAngles = new Set();
for (const angle of angles) {
const bucket = Math.round(angle * 100) / 100;
if (seenAngles.has(bucket)) continue;
seenAngles.add(bucket);
const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) };
const anchor = suburbanExpresswayAnchorForCity(city, hint);
if (!anchor) continue;
const minD = Math.max(20, (city.urbanRadius || 12) * 1.35);
const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65);
let bestEnd = null;
for (let d = minD; d <= maxD; d += 2) {
const x = Math.round(city.x + Math.cos(angle) * d);
const y = Math.round(city.y + Math.sin(angle) * d);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (terrain?.sea?.[i]) continue;
bestEnd = { x, y };
}
if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue;
const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y);
let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 });
if (path.length >= 4) return path;
}
return [];
}
function expresswayServesCityFringe(city) {
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0);
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
let inBand = false;
let exits = false;
for (const [x, y] of path || []) {
const d = Math.hypot(x - city.x, y - city.y);
if (d >= inner && d <= outer) inBand = true;
if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true;
if (inBand && exits) return true;
}
}
return false;
}
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
const cities = (features.modernCities || [])
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
.sort((a, b) => (b.population || 0) - (a.population || 0));
const result = { minPopulation, checked: cities.length, added: 0 };
if (cities.length < 2) return result;
for (let iter = 0; iter < cities.length * 2; iter++) {
const comps = expresswayCityComponents(cities);
if (new Set(comps.values()).size <= 1) break;
let best = null;
for (const a of cities) {
for (const b of cities) {
if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1))));
if (!best || score < best.score) best = { a, b, d, score };
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
features.expressways ||= [];
if (!cities.length) return result;
for (const city of cities) {
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
let target = nearestPointOnPaths(existing, city, 145);
if (!target) {
const other = cities.find((c) => c !== city && expresswayServesCityFringe(c));
target = other ? suburbanExpresswayAnchorForCity(other, city) : null;
}
if (!target) { result.noTarget++; continue; }
const anchor = suburbanExpresswayAnchorForCity(city, target);
if (!anchor) { result.noTarget++; continue; }
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
if (d < 4) { result.covered++; continue; }
let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
if (!path.length) {
const cityTargets = cities
.filter((other) => other !== city)
.map((other) => {
const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor);
return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null;
})
.filter(Boolean)
.filter((row) => row.d >= 16 && row.d <= 185)
.sort((a, b) => a.d - b.d);
for (const row of cityTargets.slice(0, 6)) {
let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
if (candidate.length >= 4) { path = candidate; break; }
}
}
if (!best) break;
let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 });
if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain });
if (!path.length) break;
path = smoothPath(path, 2);
expressways.push(path);
if (!path.length) path = suburbanExpresswayStubForCity(city, anchor);
if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; }
features.expressways.push(smoothPath(path, 1));
result.added++;
}
return result;
@ -341,7 +570,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const a = chain[i - 1], b = chain[i];
const d = Math.hypot(a.x - b.x, a.y - b.y);
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain });
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
const path = concatPaths(parts);
@ -360,22 +589,185 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
const expressDebug = ensureMajorCityExpresswayLinks(100000);
debug.expresswayMajorCityLinksAdded = expressDebug.added;
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
for (let i = 0; i < expressways.length; i++) {
const smoothed = smoothPath(expressways[i], 2);
if (smoothed.length >= 2) {
expressways[i] = smoothed;
debug.expresswaysSmoothed++;
const runs = pathTerrainRuns(smoothed, terrain);
if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
expressways[i] = smoothed;
debug.expresswaysSmoothed++;
}
}
}
for (const path of [...expressways, ...externalExpressways]) {
if (!path || path.length < 2) continue;
const a = path[0];
const b = path[path.length - 1];
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
const expresswayBeforeTerrainPrune = expressways.length;
for (let i = expressways.length - 1; i >= 0; i--) {
const runs = pathTerrainRuns(expressways[i], terrain);
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
}
debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length;
function pointInsideCityNodeBuffer(x, y) {
for (const city of features.modernCities || []) {
if (!city || (city.population || 0) < 25000) continue;
const r = Math.max(4.2, (city.coreRadius || 3) + 1.6);
if (Math.hypot(x - city.x, y - city.y) <= r) return true;
}
return false;
}
function splitExpresswayAwayFromCityNodes(path) {
const chunks = [];
let cur = [];
for (const [x, y] of path || []) {
if (pointInsideCityNodeBuffer(x, y)) {
if (cur.length >= 2) chunks.push(cur);
cur = [];
continue;
}
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
}
if (cur.length >= 2) chunks.push(cur);
return chunks.filter((chunk) => pathLengthCells(chunk) >= 12);
}
const expresswayBeforeCityNodePrune = expressways.length;
const separatedExpressways = [];
for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path));
expressways.length = 0;
expressways.push(...dedupePaths(separatedExpressways, 2));
debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length;
function connectNearbyExpresswayTermini() {
const result = { candidates: 0, added: 0, failed: 0 };
const expressGroups = [
{ key: "expressway", paths: expressways },
{ key: "externalExpressway", paths: externalExpressways },
];
const endpoints = [];
for (const group of expressGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
const path = group.paths[pathIdx];
if (!path || path.length < 2) continue;
for (const end of [0, 1]) {
const raw = end === 0 ? path[0] : path[path.length - 1];
const x = Math.round(raw[0]), y = Math.round(raw[1]);
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue;
endpoints.push({ group: group.key, pathIdx, end, x, y });
}
}
}
const pairs = [];
for (let i = 0; i < endpoints.length; i++) {
const a = endpoints[i];
for (let j = i + 1; j < endpoints.length; j++) {
const b = endpoints[j];
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < 3.0 || d > 24.0) continue;
pairs.push({ a, b, d, kind: "terminus-terminus" });
}
}
// Also snap a dead-end to the side of a nearby expressway if no terminal is
// close enough. This removes visible half-built expressway stubs without
// requiring every segment to be merged into a single polyline.
for (const a of endpoints) {
let best = null;
for (const group of expressGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
if (a.group === group.key && a.pathIdx === pathIdx) continue;
const path = group.paths[pathIdx];
for (let k = 1; k < (path?.length || 0) - 1; k += 2) {
const [x, y] = path[k];
const d = Math.hypot(a.x - x, a.y - y);
if (d < 3.0 || d > 14.0) continue;
if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" };
}
}
}
if (best) pairs.push(best);
}
pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1));
const used = new Set();
for (const pair of pairs) {
if (result.added >= 10) break;
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue;
result.candidates++;
let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 });
if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; }
const runs = pathTerrainRuns(path, terrain);
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; }
expressways.push(smoothPath(path, 1));
used.add(ak);
if (pair.b.end >= 0) used.add(bk);
result.added++;
}
return result;
}
const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini();
debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added;
debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates;
debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed;
function pointOnExpressway(p, radius = 1.5) {
return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
}
const icBeforePrune = interchanges.length;
const pairedInterchanges = [];
const pairedAccessRoads = [];
for (let i = 0; i < interchanges.length; i++) {
const ic = interchanges[i];
const access = (features.icAccessRoads || [])[i];
if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) {
pairedInterchanges.push(ic);
pairedAccessRoads.push(access);
}
}
interchanges.length = 0;
interchanges.push(...pairedInterchanges);
features.icAccessRoads = pairedAccessRoads;
debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length;
function ensureTerminalInterchangesWithAccess() {
const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 };
features.icAccessRoads ||= [];
const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
if (!path || path.length < 2) continue;
for (const raw of [path[0], path[path.length - 1]]) {
const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) };
result.endpointsChecked++;
if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue;
if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue;
const hit = nearestPointOnPaths(ordinaryRoads, p, 58);
let access = [];
if (hit) {
const d = Math.hypot(p.x - hit.x, p.y - hit.y);
access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 });
if (access.length >= 2) {
features.icAccessRoads.push(access);
features.minorRoads ||= [];
features.minorRoads.push(access);
result.accessAdded++;
}
}
addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access");
result.added++;
if (access.length < 2) result.withoutAccess++;
}
}
return result;
}
const terminalIcDebug = ensureTerminalInterchangesWithAccess();
debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added;
debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded;
debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess;
features.minorRoads = dedupePaths(minorRoads, 2);
features.nationalRoads = dedupePaths(nationalRoads, 1);