4717 lines
264 KiB
JavaScript
4717 lines
264 KiB
JavaScript
import { INF, MAP_W, MAP_H, SIZE, indexOf, inside, rand, xyOf } from "./mapUtils.js";
|
|
import { pathLengthCells } from "./mapTransport.js";
|
|
import { createIncrementalPathInfluence, normalizeTransportPathSet, smoothRasterPath } from "./mapTransportUtils.js";
|
|
|
|
|
|
// Post-admin routing runs hundreds of short A* searches on the same raster.
|
|
// Reuse the large working buffers and keep the heap numeric so each search does
|
|
// not allocate/fill O(SIZE) typed arrays or thousands of {i,f} objects.
|
|
class ReusableIndexMinHeap {
|
|
constructor(capacity = 8192) {
|
|
const n = Math.max(64, Math.min(Math.max(64, SIZE), capacity));
|
|
this.indices = new Int32Array(n);
|
|
this.priorities = new Float64Array(n);
|
|
this.length = 0;
|
|
}
|
|
reset() { this.length = 0; }
|
|
ensureCapacity(required) {
|
|
if (required <= this.indices.length) return;
|
|
let n = this.indices.length;
|
|
while (n < required) n = Math.min(Math.max(required, n * 2), Math.max(required, SIZE * 4));
|
|
const nextIndices = new Int32Array(n); nextIndices.set(this.indices); this.indices = nextIndices;
|
|
const nextPriorities = new Float64Array(n); nextPriorities.set(this.priorities); this.priorities = nextPriorities;
|
|
}
|
|
push(index, priority) {
|
|
const end = this.length++;
|
|
this.ensureCapacity(this.length);
|
|
let i = end;
|
|
while (i > 0) {
|
|
const parent = (i - 1) >> 1;
|
|
if (this.priorities[parent] <= priority) break;
|
|
this.indices[i] = this.indices[parent];
|
|
this.priorities[i] = this.priorities[parent];
|
|
i = parent;
|
|
}
|
|
this.indices[i] = index;
|
|
this.priorities[i] = priority;
|
|
}
|
|
pop() {
|
|
if (this.length <= 0) return -1;
|
|
const rootIndex = this.indices[0];
|
|
const lastPos = --this.length;
|
|
if (lastPos > 0) {
|
|
const lastIndex = this.indices[lastPos];
|
|
const lastPriority = this.priorities[lastPos];
|
|
let i = 0;
|
|
while (true) {
|
|
const left = i * 2 + 1;
|
|
if (left >= lastPos) break;
|
|
const right = left + 1;
|
|
const child = right < lastPos && this.priorities[right] < this.priorities[left] ? right : left;
|
|
if (this.priorities[child] >= lastPriority) break;
|
|
this.indices[i] = this.indices[child];
|
|
this.priorities[i] = this.priorities[child];
|
|
i = child;
|
|
}
|
|
this.indices[i] = lastIndex;
|
|
this.priorities[i] = lastPriority;
|
|
}
|
|
return rootIndex;
|
|
}
|
|
}
|
|
|
|
function createRoutingWorkspace(DistanceArray) {
|
|
return {
|
|
dist: new DistanceArray(SIZE),
|
|
prev: new Int32Array(SIZE),
|
|
distStamp: new Uint32Array(SIZE),
|
|
closedStamp: new Uint32Array(SIZE),
|
|
metricStamp: new Uint32Array(SIZE),
|
|
startMetric: new Float64Array(SIZE),
|
|
goalMetric: new Float64Array(SIZE),
|
|
generation: 0,
|
|
heap: new ReusableIndexMinHeap(),
|
|
};
|
|
}
|
|
|
|
const primaryRouteWorkspace = createRoutingWorkspace(Float64Array);
|
|
const fallbackRouteWorkspace = createRoutingWorkspace(Float32Array);
|
|
let failedPrimaryRouteKeys = new Set();
|
|
let failedFallbackRouteKeys = new Set();
|
|
|
|
function beginRoutingSearch(workspace) {
|
|
workspace.generation = (workspace.generation + 1) >>> 0;
|
|
if (workspace.generation === 0) {
|
|
workspace.distStamp.fill(0);
|
|
workspace.closedStamp.fill(0);
|
|
workspace.metricStamp.fill(0);
|
|
workspace.generation = 1;
|
|
}
|
|
workspace.heap.reset();
|
|
return workspace.generation;
|
|
}
|
|
|
|
function routeSearchBounds(startX, startY, goalX, goalY, maxLength, snapRadius) {
|
|
const direct = Math.hypot(goalX - startX, goalY - startY);
|
|
const sumLimit = maxLength + snapRadius;
|
|
if (sumLimit + 1e-7 < direct) return null;
|
|
const semiMajor = Math.max(direct * 0.5, sumLimit * 0.5);
|
|
const focal = direct * 0.5;
|
|
const semiMinor = Math.sqrt(Math.max(0, semiMajor * semiMajor - focal * focal));
|
|
const ux = direct > 1e-9 ? (goalX - startX) / direct : 1;
|
|
const uy = direct > 1e-9 ? (goalY - startY) / direct : 0;
|
|
const extentX = Math.sqrt(semiMajor * semiMajor * ux * ux + semiMinor * semiMinor * uy * uy);
|
|
const extentY = Math.sqrt(semiMajor * semiMajor * uy * uy + semiMinor * semiMinor * ux * ux);
|
|
const cx = (startX + goalX) * 0.5;
|
|
const cy = (startY + goalY) * 0.5;
|
|
const minX = Math.max(0, Math.floor(cx - extentX) - 1);
|
|
const maxX = Math.min(MAP_W - 1, Math.ceil(cx + extentX) + 1);
|
|
const minY = Math.max(0, Math.floor(cy - extentY) - 1);
|
|
const maxY = Math.min(MAP_H - 1, Math.ceil(cy + extentY) + 1);
|
|
return { minX, maxX, minY, maxY, cellUpperBound: Math.max(1, (maxX - minX + 1) * (maxY - minY + 1)), sumLimit };
|
|
}
|
|
|
|
function routeFailureKey(start, goal, maxLength, maxExpanded, snapRadius, maxElevation, strictTerrain, maxSeaRun, maxTunnelRun) {
|
|
return `${start}:${goal}:${maxLength}:${maxExpanded}:${snapRadius}:${maxElevation}:${strictTerrain ? 1 : 0}:${maxSeaRun ?? ""}:${maxTunnelRun ?? ""}`;
|
|
}
|
|
|
|
|
|
function pathMaxVertexGap(path) {
|
|
let maxGap = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
if (!a || !b) continue;
|
|
maxGap = Math.max(maxGap, Math.hypot(b[0] - a[0], b[1] - a[1]));
|
|
}
|
|
return maxGap;
|
|
}
|
|
|
|
function pathTouchesCell(path, x, y, radius = 0.65) {
|
|
if (!path || path.length < 1) return false;
|
|
for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true;
|
|
return false;
|
|
}
|
|
|
|
function anyPathTouches(paths, p, radius = 0.65) {
|
|
return Boolean(nearestPointOnPaths(paths, p, radius));
|
|
}
|
|
|
|
function pathTerrainRuns(path, terrain = null) {
|
|
const sea = terrain?.sea;
|
|
const elevation = terrain?.elevation;
|
|
const ridgeField = terrain?.ridgeField;
|
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
|
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
|
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]);
|
|
// 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 elevationV = elevation?.[i] || 0;
|
|
const ridgeV = ridgeField?.[i] || 0;
|
|
const barrierV = naturalBarrierScore?.[i] || 0;
|
|
const isTunnel = !isSea && elevationV >= 0.58
|
|
&& ((elevationV >= 0.69 && ridgeV >= 0.34) || (ridgeV >= 0.62 && elevationV >= 0.60) || (barrierV >= 0.82 && elevationV >= 0.60));
|
|
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));
|
|
}
|
|
}
|
|
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
|
|
return { maxSeaRun, maxTunnelRun, sampled };
|
|
}
|
|
|
|
function pathTerrainBurden(path, terrain = null) {
|
|
const elevation = terrain?.elevation;
|
|
const slope = terrain?.slope;
|
|
const ridgeField = terrain?.ridgeField;
|
|
const valleyField = terrain?.valleyField;
|
|
const plain = terrain?.plain;
|
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
|
let samples = 0;
|
|
let slopeSum = 0, ridgeSum = 0, barrierSum = 0, elevationSum = 0, valleySum = 0, plainSum = 0, highBarrier = 0;
|
|
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;
|
|
const x = Math.round(a[0] + (b[0] - a[0]) * t);
|
|
const y = Math.round(a[1] + (b[1] - a[1]) * t);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
const slopeV = slope?.[i] || 0;
|
|
const ridgeV = ridgeField?.[i] || 0;
|
|
const barrierV = naturalBarrierScore?.[i] || 0;
|
|
const elevationV = elevation?.[i] || 0;
|
|
const valleyV = valleyField?.[i] || 0;
|
|
const plainV = plain?.[i] || 0;
|
|
samples++;
|
|
slopeSum += slopeV;
|
|
ridgeSum += ridgeV;
|
|
barrierSum += barrierV;
|
|
elevationSum += elevationV;
|
|
valleySum += valleyV;
|
|
plainSum += plainV;
|
|
if ((barrierV >= 0.78 && elevationV >= 0.55) || (elevationV >= 0.66 && ridgeV >= 0.32) || slopeV >= 0.52) highBarrier++;
|
|
}
|
|
}
|
|
if (!samples) return { samples: 0, penalty: 0, highBarrierShare: 0 };
|
|
const meanSlope = slopeSum / samples;
|
|
const meanRidge = ridgeSum / samples;
|
|
const meanBarrier = barrierSum / samples;
|
|
const meanElevation = elevationSum / samples;
|
|
const meanValley = valleySum / samples;
|
|
const meanPlain = plainSum / samples;
|
|
const highBarrierShare = highBarrier / samples;
|
|
const penalty = meanSlope * 0.95 + meanRidge * 0.72 + meanBarrier * 1.10 + Math.max(0, meanElevation - 0.66) * 1.25 + highBarrierShare * 1.45 - meanValley * 0.22 - meanPlain * 0.16;
|
|
return { samples, meanSlope, meanRidge, meanBarrier, meanElevation, meanValley, meanPlain, highBarrierShare, penalty };
|
|
}
|
|
|
|
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 valleyField = terrain?.valleyField;
|
|
const plain = terrain?.plain;
|
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
|
const passSuitability = terrain?.passSuitability;
|
|
const sx = Math.round(a.x), sy = Math.round(a.y);
|
|
const start = indexOf(sx, sy);
|
|
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 snapRadius = options.snapRadius ?? 2.0;
|
|
const bounds = routeSearchBounds(sx, sy, b.x, b.y, maxLength, snapRadius);
|
|
if (!bounds) return [];
|
|
const configuredMaxExpanded = options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5));
|
|
// maxExpanded now counts unique expanded cells, not stale duplicate heap pops.
|
|
// The ellipse bounding box is a mathematical upper bound for any path that
|
|
// could later pass the unchanged maxLength/snapRadius validator.
|
|
const maxExpanded = Math.min(configuredMaxExpanded, bounds.cellUpperBound);
|
|
// A low elevation ceiling is used by every trunk caller. Treat it as a
|
|
// request for strict terrain conformance even if an older call-site did not
|
|
// explicitly pass `strictTerrain`. This prevents the old subtly-curved
|
|
// Euclidean corridors that happened to remain just under the hard ceiling.
|
|
const requestedElevationCeiling = Number.isFinite(options.maxElevation) ? options.maxElevation : 0.695;
|
|
const strictTerrain = options.strictTerrain === true || requestedElevationCeiling <= 0.705;
|
|
const failureKey = routeFailureKey(start, goal, maxLength, maxExpanded, snapRadius, requestedElevationCeiling, strictTerrain, options.maxSeaRun, options.maxTunnelRun);
|
|
if (failedPrimaryRouteKeys.has(failureKey)) return [];
|
|
|
|
const workspace = primaryRouteWorkspace;
|
|
const generation = beginRoutingSearch(workspace);
|
|
const { dist, prev, distStamp, closedStamp, metricStamp, startMetric, goalMetric, heap } = workspace;
|
|
dist[start] = 0;
|
|
prev[start] = start;
|
|
distStamp[start] = generation;
|
|
metricStamp[start] = generation;
|
|
startMetric[start] = 0;
|
|
goalMetric[start] = Math.hypot(sx - b.x, sy - b.y);
|
|
// Every legal step costs at least 0.40 per Euclidean cell because the edge
|
|
// cost is step * max(0.40, terrainCost). Subtract the accepted goal snap
|
|
// radius, making this a strong admissible/consistent A* lower bound.
|
|
const heuristicFloor = 0.40;
|
|
heap.push(start, Math.max(0, goalMetric[start] - snapRadius) * heuristicFloor);
|
|
let hit = -1;
|
|
let expanded = 0;
|
|
while (heap.length && expanded < maxExpanded) {
|
|
const cur = heap.pop();
|
|
if (cur < 0 || closedStamp[cur] === generation) continue;
|
|
closedStamp[cur] = generation;
|
|
expanded++;
|
|
const x = cur % MAP_W;
|
|
const y = (cur / MAP_W) | 0;
|
|
const goalDistance = metricStamp[cur] === generation ? goalMetric[cur] : Math.hypot(x - b.x, y - b.y);
|
|
if (goalDistance <= snapRadius) { hit = cur; break; }
|
|
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
|
|
const currentElevation = elevation?.[cur] || 0;
|
|
const currentDist = dist[cur];
|
|
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 (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY) continue;
|
|
const ni = ny * MAP_W + nx;
|
|
if (closedStamp[ni] === generation || sea?.[ni]) continue;
|
|
let nextStartDistance, nextGoalDistance;
|
|
if (metricStamp[ni] === generation) {
|
|
nextStartDistance = startMetric[ni];
|
|
nextGoalDistance = goalMetric[ni];
|
|
} else {
|
|
nextStartDistance = Math.hypot(nx - sx, ny - sy);
|
|
nextGoalDistance = Math.hypot(nx - b.x, ny - b.y);
|
|
startMetric[ni] = nextStartDistance;
|
|
goalMetric[ni] = nextGoalDistance;
|
|
metricStamp[ni] = generation;
|
|
}
|
|
// Safe ROI: any finally accepted path must satisfy
|
|
// d(start,p)+d(p,goal) <= maxLength+snapRadius at every visited cell.
|
|
if (nextStartDistance + nextGoalDistance > bounds.sumLimit + 1e-7) continue;
|
|
const elev = elevation?.[ni] || 0;
|
|
const slopeV = slope?.[ni] || 0;
|
|
const ridgeV = ridgeField?.[ni] || 0;
|
|
const barrierV = naturalBarrierScore?.[ni] || 0;
|
|
const passV = passSuitability?.[ni] || 0;
|
|
const elevationCeiling = requestedElevationCeiling;
|
|
if (strictTerrain) {
|
|
if (elev >= elevationCeiling) continue;
|
|
if (elev >= 0.66 && passV < 0.50) continue;
|
|
} else if (elev >= elevationCeiling) continue;
|
|
const directionalGrade = Math.abs(elev - currentElevation);
|
|
const hardBarrier = strictTerrain
|
|
? (slopeV >= 0.52 || (ridgeV >= 0.62 && elev >= 0.58) || (barrierV >= 0.82 && elev >= 0.60) || directionalGrade >= 0.10)
|
|
: ((barrierV >= 0.90 && elev >= 0.62) || slopeV >= 0.60 || (ridgeV >= 0.72 && elev >= 0.64) || directionalGrade >= 0.145);
|
|
if (hardBarrier && passV < (strictTerrain ? 0.46 : 0.40)) continue;
|
|
const step = dx && dy ? Math.SQRT2 : 1;
|
|
const terrainCost = strictTerrain
|
|
? 1.0
|
|
+ slopeV * 12.2
|
|
+ ridgeV * 9.0
|
|
+ barrierV * 10.8
|
|
+ Math.max(0, elev - 0.46) * 14.5
|
|
+ directionalGrade * 31.0
|
|
- (valleyField?.[ni] || 0) * 2.45
|
|
- (plain?.[ni] || 0) * 0.92
|
|
- passV * 3.10
|
|
: 1.0
|
|
+ slopeV * 3.65
|
|
+ ridgeV * 2.65
|
|
+ barrierV * 3.10
|
|
+ Math.max(0, elev - 0.58) * 5.20
|
|
+ directionalGrade * 5.0
|
|
- (valleyField?.[ni] || 0) * 0.62
|
|
- (plain?.[ni] || 0) * 0.28
|
|
- passV * 0.72;
|
|
const nd = currentDist + step * Math.max(0.40, terrainCost);
|
|
const oldDist = distStamp[ni] === generation ? dist[ni] : INF;
|
|
if (nd >= oldDist) continue;
|
|
dist[ni] = nd;
|
|
prev[ni] = cur;
|
|
distStamp[ni] = generation;
|
|
const h = Math.max(0, nextGoalDistance - snapRadius) * heuristicFloor;
|
|
heap.push(ni, nd + h);
|
|
}
|
|
}
|
|
if (hit < 0) { failedPrimaryRouteKeys.add(failureKey); return []; }
|
|
const path = [];
|
|
let cur = hit;
|
|
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
|
|
const x = cur % MAP_W, y = Math.floor(cur / MAP_W);
|
|
path.push([x, y]);
|
|
if (prev[cur] === cur) break;
|
|
cur = prev[cur];
|
|
}
|
|
path.reverse();
|
|
if (path.length < 2 || pathLengthCells(path) > maxLength) { failedPrimaryRouteKeys.add(failureKey); return []; }
|
|
const runs = pathTerrainRuns(path, terrain);
|
|
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) { failedPrimaryRouteKeys.add(failureKey); return []; }
|
|
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) { failedPrimaryRouteKeys.add(failureKey); return []; }
|
|
return path;
|
|
}
|
|
|
|
function routeLandConnectedTerrainFallback(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 valleyField = terrain?.valleyField;
|
|
const plain = terrain?.plain;
|
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
|
const passSuitability = terrain?.passSuitability;
|
|
const sx = Math.round(a.x), sy = Math.round(a.y);
|
|
const start = indexOf(sx, sy);
|
|
const goal = indexOf(Math.round(b.x), Math.round(b.y));
|
|
if (sea?.[start] || sea?.[goal]) return [];
|
|
const direct = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const maxLength = options.maxLength ?? direct * 4.0 + 160;
|
|
const snapRadius = 1.5;
|
|
const bounds = routeSearchBounds(sx, sy, b.x, b.y, maxLength, snapRadius);
|
|
if (!bounds) return [];
|
|
const requestedElevationCeiling = Number.isFinite(options.maxElevation) ? options.maxElevation : 0.695;
|
|
const strictTerrain = options.strictTerrain === true || requestedElevationCeiling <= 0.705;
|
|
const failureKey = routeFailureKey(start, goal, maxLength, bounds.cellUpperBound, snapRadius, requestedElevationCeiling, strictTerrain, undefined, undefined);
|
|
if (failedFallbackRouteKeys.has(failureKey)) return [];
|
|
|
|
const workspace = fallbackRouteWorkspace;
|
|
const generation = beginRoutingSearch(workspace);
|
|
const { dist, prev, distStamp, closedStamp, metricStamp, startMetric, goalMetric, heap } = workspace;
|
|
dist[start] = 0; prev[start] = start; distStamp[start] = generation;
|
|
metricStamp[start] = generation; startMetric[start] = 0; goalMetric[start] = Math.hypot(sx - b.x, sy - b.y);
|
|
const heuristicFloor = 0.42;
|
|
heap.push(start, Math.max(0, goalMetric[start] - snapRadius) * heuristicFloor);
|
|
let hit = -1;
|
|
let expanded = 0;
|
|
while (heap.length && expanded < bounds.cellUpperBound) {
|
|
const cur = heap.pop();
|
|
if (cur < 0 || closedStamp[cur] === generation) continue;
|
|
closedStamp[cur] = generation;
|
|
expanded++;
|
|
const x = cur % MAP_W, y = (cur / MAP_W) | 0;
|
|
const goalDistance = metricStamp[cur] === generation ? goalMetric[cur] : Math.hypot(x - b.x, y - b.y);
|
|
if (goalDistance <= snapRadius) { hit = cur; break; }
|
|
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
|
|
const currentElevation = elevation?.[cur] || 0;
|
|
const currentDist = dist[cur];
|
|
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 (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY) continue;
|
|
const ni = ny * MAP_W + nx;
|
|
if (closedStamp[ni] === generation || sea?.[ni]) continue;
|
|
let nextStartDistance, nextGoalDistance;
|
|
if (metricStamp[ni] === generation) {
|
|
nextStartDistance = startMetric[ni];
|
|
nextGoalDistance = goalMetric[ni];
|
|
} else {
|
|
nextStartDistance = Math.hypot(nx - sx, ny - sy);
|
|
nextGoalDistance = Math.hypot(nx - b.x, ny - b.y);
|
|
startMetric[ni] = nextStartDistance;
|
|
goalMetric[ni] = nextGoalDistance;
|
|
metricStamp[ni] = generation;
|
|
}
|
|
if (nextStartDistance + nextGoalDistance > bounds.sumLimit + 1e-7) continue;
|
|
const elev = elevation?.[ni] || 0;
|
|
const slopeV = slope?.[ni] || 0;
|
|
const ridgeV = ridgeField?.[ni] || 0;
|
|
const barrierV = naturalBarrierScore?.[ni] || 0;
|
|
const passV = passSuitability?.[ni] || 0;
|
|
const elevationCeiling = requestedElevationCeiling;
|
|
if (strictTerrain) {
|
|
if (elev >= elevationCeiling) continue;
|
|
if (elev >= 0.66 && passV < 0.50) continue;
|
|
} else if (elev >= elevationCeiling) continue;
|
|
const directionalGrade = Math.abs(elev - currentElevation);
|
|
const hardBarrier = strictTerrain
|
|
? (slopeV >= 0.52 || (ridgeV >= 0.62 && elev >= 0.58) || (barrierV >= 0.82 && elev >= 0.60) || directionalGrade >= 0.10)
|
|
: ((barrierV >= 0.90 && elev >= 0.62) || slopeV >= 0.60 || (ridgeV >= 0.72 && elev >= 0.64) || directionalGrade >= 0.145);
|
|
if (hardBarrier && passV < (strictTerrain ? 0.46 : 0.40)) continue;
|
|
const step = dx && dy ? Math.SQRT2 : 1;
|
|
const terrainCost = strictTerrain
|
|
? 1 + slopeV * 12.8 + ridgeV * 9.4 + barrierV * 11.2 + Math.max(0, elev - 0.45) * 15.2 + directionalGrade * 33.0 - (valleyField?.[ni] || 0) * 2.60 - (plain?.[ni] || 0) * 0.96 - passV * 3.25
|
|
: 1 + slopeV * 4.10 + ridgeV * 3.05 + barrierV * 3.55 + Math.max(0, elev - 0.56) * 5.8 + directionalGrade * 5.5 - (valleyField?.[ni] || 0) * 0.72 - (plain?.[ni] || 0) * 0.34 - passV * 0.78;
|
|
const nd = currentDist + step * Math.max(0.42, terrainCost);
|
|
const oldDist = distStamp[ni] === generation ? dist[ni] : INF;
|
|
if (nd >= oldDist) continue;
|
|
dist[ni] = nd; prev[ni] = cur; distStamp[ni] = generation;
|
|
heap.push(ni, nd + Math.max(0, nextGoalDistance - snapRadius) * heuristicFloor);
|
|
}
|
|
}
|
|
if (hit < 0) { failedFallbackRouteKeys.add(failureKey); return []; }
|
|
const path = [];
|
|
let cur = hit;
|
|
for (let guard = 0; guard < SIZE && cur >= 0; guard++) {
|
|
const x = cur % MAP_W, y = Math.floor(cur / MAP_W); path.push([x, y]);
|
|
if (prev[cur] === cur) break;
|
|
cur = prev[cur];
|
|
}
|
|
path.reverse();
|
|
if (path.length < 2 || pathLengthCells(path) > maxLength) { failedFallbackRouteKeys.add(failureKey); return []; }
|
|
return path;
|
|
}
|
|
|
|
function terrainFirstConnector(a, b, terrain = null, options = {}) {
|
|
if (!a || !b) return [];
|
|
const direct = Math.hypot(a.x - b.x, a.y - b.y);
|
|
// r11.7: there is deliberately no straight-line fallback here. Every
|
|
// production transport connector, including short local/IC access, must be
|
|
// solved against the terrain raster. If the bounded route search fails, a
|
|
// second, broader land-connected terrain search is allowed; fabrication of a
|
|
// Euclidean segment is not.
|
|
if (options.skipPrimaryRoute !== true) {
|
|
const routed = routeTerrainPath(a, b, terrain, {
|
|
maxLength: options.maxLength ?? direct * 2.7 + 48,
|
|
maxSeaRun: options.maxSeaRun ?? 0,
|
|
maxTunnelRun: options.maxTunnelRun ?? 10,
|
|
maxExpanded: options.maxExpanded,
|
|
snapRadius: options.snapRadius ?? 1.8,
|
|
maxElevation: options.maxElevation,
|
|
strictTerrain: options.strictTerrain,
|
|
});
|
|
if (routed.length >= 2) return routed;
|
|
}
|
|
if (options.allowLandFallback === false) return [];
|
|
return routeLandConnectedTerrainFallback(a, b, terrain, {
|
|
maxLength: options.maxLength ?? direct * 3.4 + 72,
|
|
maxElevation: options.maxElevation,
|
|
strictTerrain: options.strictTerrain,
|
|
});
|
|
}
|
|
|
|
const nearestPathSpatialStates = [];
|
|
const PATH_SPATIAL_CELL = 8;
|
|
const PATH_SPATIAL_BINS_X = Math.ceil(MAP_W / PATH_SPATIAL_CELL);
|
|
const PATH_SPATIAL_BINS_Y = Math.ceil(MAP_H / PATH_SPATIAL_CELL);
|
|
|
|
function pathSpatialMetaMatches(state, paths, prefixOnly = false) {
|
|
const count = paths?.length || 0;
|
|
if (prefixOnly ? state.count > count : state.count !== count) return false;
|
|
for (let i = 0; i < state.count; i++) {
|
|
const path = paths[i];
|
|
if (state.refs[i] !== path) return false;
|
|
const len = path?.length || 0;
|
|
if (state.lengths[i] !== len) return false;
|
|
const first = path?.[0];
|
|
const mid = path?.[len ? Math.floor((len - 1) * 0.5) : 0];
|
|
const last = path?.[len - 1];
|
|
if ((state.firstX[i] !== (first?.[0] ?? NaN)) || (state.firstY[i] !== (first?.[1] ?? NaN))
|
|
|| (state.midX[i] !== (mid?.[0] ?? NaN)) || (state.midY[i] !== (mid?.[1] ?? NaN))
|
|
|| (state.lastX[i] !== (last?.[0] ?? NaN)) || (state.lastY[i] !== (last?.[1] ?? NaN))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function recordPathMeta(state, index, path) {
|
|
const len = path?.length || 0;
|
|
const first = path?.[0];
|
|
const mid = path?.[len ? Math.floor((len - 1) * 0.5) : 0];
|
|
const last = path?.[len - 1];
|
|
state.refs[index] = path;
|
|
state.lengths[index] = len;
|
|
state.firstX[index] = first?.[0] ?? NaN; state.firstY[index] = first?.[1] ?? NaN;
|
|
state.midX[index] = mid?.[0] ?? NaN; state.midY[index] = mid?.[1] ?? NaN;
|
|
state.lastX[index] = last?.[0] ?? NaN; state.lastY[index] = last?.[1] ?? NaN;
|
|
return len;
|
|
}
|
|
|
|
function appendPathsToSpatialState(state, paths, fromIndex) {
|
|
for (let pathIndex = fromIndex; pathIndex < (paths?.length || 0); pathIndex++) {
|
|
const path = paths[pathIndex];
|
|
recordPathMeta(state, pathIndex, path);
|
|
for (const point of path || []) {
|
|
if (!point) continue;
|
|
const x = point[0], y = point[1];
|
|
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
|
|
const bx = Math.max(0, Math.min(PATH_SPATIAL_BINS_X - 1, Math.floor(x / PATH_SPATIAL_CELL)));
|
|
const by = Math.max(0, Math.min(PATH_SPATIAL_BINS_Y - 1, Math.floor(y / PATH_SPATIAL_CELL)));
|
|
const binIndex = by * PATH_SPATIAL_BINS_X + bx;
|
|
let bucket = state.bins[binIndex];
|
|
if (!bucket) state.bins[binIndex] = bucket = [];
|
|
bucket.push(x, y);
|
|
}
|
|
}
|
|
state.count = paths?.length || 0;
|
|
}
|
|
|
|
function spatialStateForPaths(paths) {
|
|
const rows = paths || [];
|
|
// Exact/prefix matching by underlying path references means spread-created
|
|
// network arrays still share one index. Growing networks append only the new
|
|
// paths instead of rescanning every old road vertex.
|
|
let prefixState = null;
|
|
for (let i = 0; i < nearestPathSpatialStates.length; i++) {
|
|
const state = nearestPathSpatialStates[i];
|
|
if (pathSpatialMetaMatches(state, rows, false)) {
|
|
if (i > 0) { nearestPathSpatialStates.splice(i, 1); nearestPathSpatialStates.unshift(state); }
|
|
return state;
|
|
}
|
|
if ((!prefixState || state.count > prefixState.count) && pathSpatialMetaMatches(state, rows, true)) prefixState = state;
|
|
}
|
|
if (prefixState) {
|
|
appendPathsToSpatialState(prefixState, rows, prefixState.count);
|
|
const idx = nearestPathSpatialStates.indexOf(prefixState);
|
|
if (idx > 0) { nearestPathSpatialStates.splice(idx, 1); nearestPathSpatialStates.unshift(prefixState); }
|
|
return prefixState;
|
|
}
|
|
const state = {
|
|
bins: new Array(PATH_SPATIAL_BINS_X * PATH_SPATIAL_BINS_Y),
|
|
refs: [], lengths: [], firstX: [], firstY: [], midX: [], midY: [], lastX: [], lastY: [], count: 0,
|
|
};
|
|
appendPathsToSpatialState(state, rows, 0);
|
|
nearestPathSpatialStates.unshift(state);
|
|
if (nearestPathSpatialStates.length > 10) nearestPathSpatialStates.pop();
|
|
return state;
|
|
}
|
|
|
|
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
|
if (!paths?.length || !p) return null;
|
|
if (!Number.isFinite(maxDistance)) {
|
|
let best = null;
|
|
for (const path of paths || []) for (const [x, y] of path || []) {
|
|
const d = Math.hypot(p.x - x, p.y - y);
|
|
if (!best || d < best.d) best = { x, y, d };
|
|
}
|
|
return best;
|
|
}
|
|
const state = spatialStateForPaths(paths);
|
|
const minBx = Math.max(0, Math.floor((p.x - maxDistance) / PATH_SPATIAL_CELL));
|
|
const maxBx = Math.min(PATH_SPATIAL_BINS_X - 1, Math.floor((p.x + maxDistance) / PATH_SPATIAL_CELL));
|
|
const minBy = Math.max(0, Math.floor((p.y - maxDistance) / PATH_SPATIAL_CELL));
|
|
const maxBy = Math.min(PATH_SPATIAL_BINS_Y - 1, Math.floor((p.y + maxDistance) / PATH_SPATIAL_CELL));
|
|
let bestX = 0, bestY = 0;
|
|
let bestD2 = maxDistance * maxDistance;
|
|
let found = false;
|
|
for (let by = minBy; by <= maxBy; by++) {
|
|
for (let bx = minBx; bx <= maxBx; bx++) {
|
|
const bucket = state.bins[by * PATH_SPATIAL_BINS_X + bx];
|
|
if (!bucket) continue;
|
|
for (let k = 0; k < bucket.length; k += 2) {
|
|
const x = bucket[k], y = bucket[k + 1];
|
|
const dx = p.x - x, dy = p.y - y;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 <= bestD2) { bestD2 = d2; bestX = x; bestY = y; found = true; }
|
|
}
|
|
}
|
|
}
|
|
return found ? { x: bestX, y: bestY, d: Math.sqrt(bestD2) } : null;
|
|
}
|
|
|
|
function trimRouteAtExistingNetwork(path, network, radius = 2.6, minTravelCells = 4) {
|
|
if (!path?.length || !network?.length) return path || [];
|
|
const minIndex = Math.min(path.length - 1, Math.max(1, Math.floor(minTravelCells)));
|
|
for (let k = minIndex; k < path.length; k++) {
|
|
const [x, y] = path[k];
|
|
const hit = nearestPointOnPaths(network, { x, y }, radius);
|
|
if (!hit) continue;
|
|
const out = path.slice(0, k + 1);
|
|
const last = out[out.length - 1];
|
|
const snapGap = Math.hypot(last[0] - hit.x, last[1] - hit.y);
|
|
// Never manufacture a straight connector merely to make the endpoint touch
|
|
// the existing network. A sub-cell raster snap is harmless; any larger gap
|
|
// must remain a proximity connection or be routed explicitly elsewhere.
|
|
if (snapGap > 0.75 && snapGap <= 1.55) out.push([Math.round(hit.x), Math.round(hit.y)]);
|
|
return out;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function nearestPointsOnPaths(paths, p, maxDistance = Infinity, limit = 12, stride = 3) {
|
|
const rows = [];
|
|
for (const path of paths || []) {
|
|
for (let k = 0; k < (path?.length || 0); k += Math.max(1, stride)) {
|
|
const [x, y] = path[k];
|
|
const d = Math.hypot(p.x - x, p.y - y);
|
|
if (d <= maxDistance) rows.push({ x, y, d });
|
|
}
|
|
}
|
|
rows.sort((a, b) => a.d - b.d);
|
|
const out = [];
|
|
for (const row of rows) {
|
|
if (out.some((q) => Math.hypot(q.x - row.x, q.y - row.y) < 7)) continue;
|
|
out.push(row);
|
|
if (out.length >= limit) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function nearestEntity(entities, p, maxDistance = Infinity) {
|
|
let best = null;
|
|
for (const q of entities || []) {
|
|
if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue;
|
|
const d = Math.hypot(q.x - p.x, q.y - p.y);
|
|
if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function dedupePaths(paths, sampleStep = 2) {
|
|
const normalized = normalizeTransportPathSet(paths, { sampleStep, mutate: true });
|
|
return Array.isArray(paths) ? paths : normalized.paths;
|
|
}
|
|
|
|
function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") {
|
|
x = Math.round(x); y = Math.round(y);
|
|
if (!inside(x, y)) return false;
|
|
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
|
|
interchanges.push({ x, y, kind: "Interchange", score: 1, source });
|
|
return true;
|
|
}
|
|
|
|
function smoothPath(path, passes = 1) {
|
|
return smoothRasterPath(path, passes);
|
|
}
|
|
|
|
function pathTangent(path, k, span = 2) {
|
|
const a = path?.[Math.max(0, k - span)] || path?.[k] || [0, 0];
|
|
const b = path?.[Math.min((path?.length || 1) - 1, k + span)] || path?.[k] || [0, 0];
|
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const d = Math.hypot(dx, dy) || 1;
|
|
return [dx / d, dy / d];
|
|
}
|
|
|
|
function pathSharpTurnStats(path) {
|
|
let turns = 0, sharp = 0, extreme = 0, consecutiveExtreme = 0, run = 0, maxRun = 0;
|
|
for (let k = 2; k < (path?.length || 0) - 2; k += 2) {
|
|
const a = path[k - 2], b = path[k], c = path[k + 2];
|
|
if (!a || !b || !c) continue;
|
|
const ux = b[0] - a[0], uy = b[1] - a[1], vx = c[0] - b[0], vy = c[1] - b[1];
|
|
const ud = Math.hypot(ux, uy), vd = Math.hypot(vx, vy);
|
|
if (!ud || !vd) continue;
|
|
const dot = Math.max(-1, Math.min(1, (ux * vx + uy * vy) / (ud * vd)));
|
|
const angle = Math.acos(dot) * 180 / Math.PI;
|
|
turns++;
|
|
if (angle >= 50) sharp++;
|
|
if (angle >= 82) { extreme++; run++; maxRun = Math.max(maxRun, run); }
|
|
else run = 0;
|
|
}
|
|
consecutiveExtreme = maxRun;
|
|
return { turns, sharp, extreme, consecutiveExtreme, sharpShare: turns ? sharp / turns : 0 };
|
|
}
|
|
|
|
function trunkElevationSafe(path, terrain, ceiling = 0.72) {
|
|
if (!path?.length) return false;
|
|
for (const [x, y] of path) {
|
|
if (!inside(x, y)) return false;
|
|
if ((terrain?.elevation?.[indexOf(x, y)] || 0) > ceiling) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function terrainSafeSmooth(path, terrain, mode = "national", passes = 2) {
|
|
if (!path || path.length < 5) return path || [];
|
|
let best = path;
|
|
const maxTunnelRun = mode === "rail" ? 24 : mode === "expressway" ? 24 : mode === "national" ? 18 : 8;
|
|
const maxSeaRun = mode === "expressway" ? 3 : mode === "rail" ? 2 : 2;
|
|
const baseStats = pathSharpTurnStats(path);
|
|
for (let p = 1; p <= passes; p++) {
|
|
const candidate = smoothRasterPath(path, p);
|
|
if (!candidate?.length) continue;
|
|
const runs = pathTerrainRuns(candidate, terrain);
|
|
const burden = pathTerrainBurden(candidate, terrain);
|
|
if (runs.maxTunnelRun > maxTunnelRun || runs.maxSeaRun > maxSeaRun) continue;
|
|
// Smoothing must never be the stage that cuts a corner over water or a
|
|
// mountain. Trunk candidates are checked cell-for-cell against the same
|
|
// strict terrain contract as the final sanitizer.
|
|
if (["national", "expressway", "rail"].includes(mode)) {
|
|
let invalid = false;
|
|
for (const [x, y] of candidate) {
|
|
if (!inside(x, y)) { invalid = true; break; }
|
|
const i = indexOf(x, y);
|
|
const passV = terrain?.passSuitability?.[i] || 0;
|
|
const elevationV = terrain?.elevation?.[i] || 0;
|
|
const mountainObstacle = (terrain?.slope?.[i] || 0) >= 0.52
|
|
|| ((terrain?.ridgeField?.[i] || 0) >= 0.62 && elevationV >= 0.58)
|
|
|| ((terrain?.naturalBarrierScore?.[i] || 0) >= 0.82 && elevationV >= 0.60);
|
|
if (terrain?.sea?.[i] || elevationV >= 0.695 || (mountainObstacle && passV < 0.46)) { invalid = true; break; }
|
|
}
|
|
if (invalid) continue;
|
|
}
|
|
if (burden.highBarrierShare > (mode === "rail" ? 0.10 : mode === "expressway" ? 0.08 : mode === "national" ? 0.10 : 0.30)) continue;
|
|
const stats = pathSharpTurnStats(candidate);
|
|
if (stats.extreme <= pathSharpTurnStats(best).extreme && stats.sharpShare <= Math.max(0.34, baseStats.sharpShare + 0.04)) best = candidate;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function pathServesMajorCity(path, city, mode) {
|
|
if (!path || !city) return false;
|
|
if (mode !== "expressway") return pathTouchesCell(path, city.x, city.y, mode === "rail" ? 2.2 : 2.0);
|
|
const inner = Math.max(6.5, (city.coreRadius || 4) + 4.0);
|
|
const outer = Math.max(inner + 6, (city.urbanRadius || 12) * 2.1);
|
|
const largeCity = (city.population || 0) >= 180000;
|
|
const largeCityReach = Math.max(12, Math.min(24, (city.urbanRadius || 12) * 1.55));
|
|
let inBand = false, outside = false, minD = Infinity, maxD = 0;
|
|
for (const [x, y] of path) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
minD = Math.min(minD, d);
|
|
maxD = Math.max(maxD, d);
|
|
if (d >= inner && d <= outer) inBand = true;
|
|
if (d >= Math.max(20, outer * 0.85)) outside = true;
|
|
}
|
|
// Several-hundred-thousand-person cities require an actual suburban
|
|
// approach. A motorway only touching the remote metropolitan fringe must not
|
|
// count as unique service during final pruning/alignment decisions.
|
|
if (largeCity) return minD <= largeCityReach && pathLengthCells(path) >= 12 && maxD - minD >= 8;
|
|
return inBand && outside;
|
|
}
|
|
|
|
// Direction-aware final anti-parallel pass. The old raster-overlap test treated
|
|
// crossings and parallel roads alike and, more importantly, ran before the
|
|
// post-admin service links were appended. This pass executes on the completed
|
|
// hierarchy and specifically removes kilometre-scale side-by-side corridors.
|
|
function pruneFinalParallelPaths(paths, mode, majorCities = [], options = {}) {
|
|
if (!Array.isArray(paths) || paths.length < 2) return { before: paths?.length || 0, after: paths?.length || 0, pruned: 0 };
|
|
const before = paths.length;
|
|
const radius = options.radius ?? (mode === "expressway" ? 4 : mode === "national" ? 3 : 3);
|
|
const threshold = options.threshold ?? (mode === "expressway" ? 0.30 : mode === "national" ? 0.38 : 0.44);
|
|
const dotFloor = options.directionDot ?? 0.90;
|
|
const sampleStride = 2;
|
|
const rows = paths.map((path, index) => {
|
|
const served = new Set();
|
|
majorCities.forEach((city, ci) => { if (pathServesMajorCity(path, city, mode)) served.add(ci); });
|
|
return { path, index, served, len: pathLengthCells(path), score: served.size * 120 + pathLengthCells(path) };
|
|
}).sort((a, b) => b.score - a.score || b.len - a.len);
|
|
const accepted = [];
|
|
const acceptedSamples = [];
|
|
const servedByAccepted = new Set();
|
|
function samples(path) {
|
|
const out = [];
|
|
for (let k = 0; k < (path?.length || 0); k += sampleStride) {
|
|
const p = path[k];
|
|
if (!p) continue;
|
|
const [tx, ty] = pathTangent(path, k, 2);
|
|
out.push({ x: p[0], y: p[1], tx, ty });
|
|
}
|
|
return out;
|
|
}
|
|
function parallelStats(ss) {
|
|
let hit = 0;
|
|
let run = 0;
|
|
let longestRun = 0;
|
|
for (const p of ss) {
|
|
let parallel = false;
|
|
for (const q of acceptedSamples) {
|
|
const dx = p.x - q.x, dy = p.y - q.y;
|
|
if (Math.abs(dx) > radius || Math.abs(dy) > radius || dx * dx + dy * dy > radius * radius) continue;
|
|
if (Math.abs(p.tx * q.tx + p.ty * q.ty) < dotFloor) continue;
|
|
parallel = true; break;
|
|
}
|
|
if (parallel) { hit++; run++; longestRun = Math.max(longestRun, run); }
|
|
else run = 0;
|
|
}
|
|
return { share: ss.length ? hit / ss.length : 0, longestRun };
|
|
}
|
|
for (const row of rows) {
|
|
const ss = samples(row.path);
|
|
const { share, longestRun } = parallelStats(ss);
|
|
const uniqueService = [...row.served].some((id) => !servedByAccepted.has(id));
|
|
const longEnough = row.len >= (mode === "expressway" ? 16 : 12);
|
|
// Whole-path overlap misses a common failure mode: a route can parallel a
|
|
// trunk only for a 5-10 km segment, then diverge, yielding a modest global
|
|
// share. Treat sustained local parallelism as redundant as well. With the
|
|
// 2-cell sample stride, five samples are roughly five kilometres.
|
|
const maxParallelRunSamples = options.maxParallelRunSamples ?? (mode === "expressway" ? 5 : 6);
|
|
const sustainedParallel = longestRun >= maxParallelRunSamples;
|
|
if (accepted.length && longEnough && (share > threshold || sustainedParallel) && !uniqueService) continue;
|
|
accepted.push(row);
|
|
acceptedSamples.push(...ss);
|
|
for (const id of row.served) servedByAccepted.add(id);
|
|
}
|
|
accepted.sort((a, b) => a.index - b.index);
|
|
paths.length = 0;
|
|
paths.push(...accepted.map((row) => row.path));
|
|
return { before, after: paths.length, pruned: before - paths.length, radius, threshold, directionAware: true, maxParallelRunSamples: options.maxParallelRunSamples ?? (mode === "expressway" ? 5 : 6) };
|
|
}
|
|
|
|
|
|
|
|
// When two trunk routes are both semantically required, deleting the lower
|
|
// priority path can break city service. Instead, collapse a sustained
|
|
// near-parallel section onto the already accepted corridor. The routes then
|
|
// share one physical alignment and diverge only where their destinations do.
|
|
// This models multiplexed Japanese trunk corridors much better than drawing two
|
|
// highways one or two cells apart for kilometres.
|
|
function collapseParallelCorridorsOntoSharedAlignment(paths, mode, majorCities = [], terrain = null, options = {}) {
|
|
if (!Array.isArray(paths) || paths.length < 2) return { inspected: paths?.length || 0, collapsed: 0, cellsReused: 0 };
|
|
const radius = options.radius ?? 2;
|
|
const dotFloor = options.directionDot ?? 0.90;
|
|
const minRunPoints = options.minRunPoints ?? (mode === "expressway" ? 5 : 6);
|
|
const rows = paths.map((path, index) => {
|
|
let service = 0;
|
|
for (const city of majorCities || []) if (pathServesMajorCity(path, city, mode)) service++;
|
|
return { index, path, score: service * 200 + pathLengthCells(path), service };
|
|
}).sort((a, b) => b.score - a.score || b.path.length - a.path.length || a.index - b.index);
|
|
const accepted = [];
|
|
let collapsed = 0, cellsReused = 0;
|
|
|
|
function nearestParallel(path, k, ref) {
|
|
const p = path[k];
|
|
const [tx, ty] = pathTangent(path, k, 2);
|
|
let best = null;
|
|
for (let q = 0; q < ref.length; q++) {
|
|
const z = ref[q];
|
|
const dx = p[0] - z[0], dy = p[1] - z[1];
|
|
const d2 = dx * dx + dy * dy;
|
|
// Already-shared cells are the desired multiplexed state, not a
|
|
// distinct parallel corridor. Ignore them here so an existing shared
|
|
// section cannot mask a nearby side-by-side run that still needs to be
|
|
// collapsed. This matches the topology auditor's definition.
|
|
if (d2 < 0.75 || d2 > radius * radius) continue;
|
|
const [ux, uy] = pathTangent(ref, q, 2);
|
|
const dot = tx * ux + ty * uy;
|
|
if (Math.abs(dot) < dotFloor) continue;
|
|
if (!best || d2 < best.d2) best = { q, d2, sign: dot >= 0 ? 1 : -1 };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function bestRun(path, ref) {
|
|
let current = null, best = null;
|
|
for (let k = 0; k < path.length; k++) {
|
|
const hit = nearestParallel(path, k, ref);
|
|
if (!hit) { current = null; continue; }
|
|
const compatible = current
|
|
&& current.sign === hit.sign
|
|
&& (hit.sign > 0 ? hit.q >= current.lastQ - 2 : hit.q <= current.lastQ + 2)
|
|
&& Math.abs(hit.q - current.lastQ) <= 6;
|
|
if (!compatible) current = { start: k, end: k, startQ: hit.q, endQ: hit.q, lastQ: hit.q, sign: hit.sign, count: 1 };
|
|
else { current.end = k; current.endQ = hit.q; current.lastQ = hit.q; current.count++; }
|
|
if (!best || current.count > best.count) best = { ...current };
|
|
}
|
|
return best && best.count >= minRunPoints ? best : null;
|
|
}
|
|
|
|
function mergeOnReference(path, ref, run) {
|
|
let q0 = run.startQ, q1 = run.endQ;
|
|
let shared;
|
|
if (run.sign >= 0) {
|
|
if (q1 < q0) [q0, q1] = [q1, q0];
|
|
shared = ref.slice(q0, q1 + 1);
|
|
} else {
|
|
if (q0 < q1) [q0, q1] = [q1, q0];
|
|
shared = ref.slice(q1, q0 + 1).reverse();
|
|
}
|
|
if (shared.length < 2) return null;
|
|
const prefix = path.slice(0, run.start);
|
|
const suffix = path.slice(run.end + 1);
|
|
const pieces = [];
|
|
if (prefix.length) pieces.push(prefix);
|
|
const prefixEnd = prefix[prefix.length - 1];
|
|
if (prefixEnd) {
|
|
const d = Math.hypot(prefixEnd[0] - shared[0][0], prefixEnd[1] - shared[0][1]);
|
|
const connector = terrainFirstConnector(
|
|
{ x: prefixEnd[0], y: prefixEnd[1] }, { x: shared[0][0], y: shared[0][1] }, terrain,
|
|
{ maxLength: Math.max(8, d * 3.0 + 8), maxSeaRun: 0, maxTunnelRun: 0, maxElevation: 0.695, snapRadius: 0.8 }
|
|
);
|
|
if (d > 0.75 && connector.length < 2) return null;
|
|
if (connector.length >= 2) pieces.push(connector.slice(1));
|
|
}
|
|
pieces.push(shared);
|
|
const suffixStart = suffix[0];
|
|
if (suffixStart) {
|
|
const tail = shared[shared.length - 1];
|
|
const d = Math.hypot(tail[0] - suffixStart[0], tail[1] - suffixStart[1]);
|
|
const connector = terrainFirstConnector(
|
|
{ x: tail[0], y: tail[1] }, { x: suffixStart[0], y: suffixStart[1] }, terrain,
|
|
{ maxLength: Math.max(8, d * 3.0 + 8), maxSeaRun: 0, maxTunnelRun: 0, maxElevation: 0.695, snapRadius: 0.8 }
|
|
);
|
|
if (d > 0.75 && connector.length < 2) return null;
|
|
if (connector.length >= 2) pieces.push(connector.slice(1));
|
|
}
|
|
if (suffix.length) pieces.push(suffix.slice(1));
|
|
const merged = [];
|
|
for (const piece of pieces) for (const pt of piece || []) {
|
|
const last = merged[merged.length - 1];
|
|
if (!last || last[0] !== pt[0] || last[1] !== pt[1]) merged.push(pt);
|
|
}
|
|
if (merged.length < 3) return null;
|
|
const runs = pathTerrainRuns(merged, terrain);
|
|
const turnStats = pathSharpTurnStats(merged);
|
|
if (mode === "expressway" && (runs.maxSeaRun > 0 || runs.maxTunnelRun > 24 || turnStats.consecutiveExtreme > 1 || !trunkElevationSafe(merged, terrain, 0.72))) return null;
|
|
if (mode === "national" && (runs.maxSeaRun > 0 || runs.maxTunnelRun > 18 || turnStats.consecutiveExtreme > 1 || !trunkElevationSafe(merged, terrain, 0.74))) return null;
|
|
return merged;
|
|
}
|
|
|
|
for (const row of rows) {
|
|
let path = row.path;
|
|
let best = null;
|
|
for (const ref of accepted) {
|
|
const run = bestRun(path, ref);
|
|
if (!run) continue;
|
|
if (!best || run.count > best.run.count) best = { ref, run };
|
|
}
|
|
if (best) {
|
|
const merged = mergeOnReference(path, best.ref, best.run);
|
|
if (merged) {
|
|
paths[row.index] = path = merged;
|
|
collapsed++;
|
|
cellsReused += best.run.count;
|
|
}
|
|
}
|
|
accepted.push(path);
|
|
}
|
|
return { inspected: paths.length, collapsed, cellsReused, radius, minRunPoints };
|
|
}
|
|
|
|
// Remove path objects that remain as short duplicate loops/spurs after nearby
|
|
// corridors have been snapped onto one physical alignment. `dedupePaths()` only
|
|
// catches near-identical whole paths; a common failure was two trunk routes
|
|
// sharing 70-90% of their cells in reverse order with a tiny unique tail, which
|
|
// rendered as tangled double junctions. Keep the higher-value/longer route and
|
|
// discard the short redundant object unless it is the sole service for a major
|
|
// city.
|
|
function pruneNearDuplicateTrunkPaths(paths, mode, majorCities = [], options = {}) {
|
|
if (!Array.isArray(paths) || paths.length < 2) return { before: paths?.length || 0, after: paths?.length || 0, pruned: 0 };
|
|
const before = paths.length;
|
|
const overlapFloor = options.overlapFloor ?? (mode === "expressway" ? 0.48 : 0.52);
|
|
const maxUniqueCells = options.maxUniqueCells ?? (mode === "expressway" ? 12 : 10);
|
|
const rows = paths.map((path, index) => {
|
|
const served = new Set();
|
|
for (let ci = 0; ci < (majorCities || []).length; ci++) if (pathServesMajorCity(path, majorCities[ci], mode)) served.add(ci);
|
|
return { path, index, served, len: pathLengthCells(path) };
|
|
}).sort((a, b) => b.served.size - a.served.size || b.len - a.len || a.index - b.index);
|
|
const accepted = [];
|
|
const occupied = new Set();
|
|
const serviceOwners = new Set();
|
|
let pruned = 0;
|
|
for (const row of rows) {
|
|
let overlap = 0;
|
|
const uniqueKeys = new Set();
|
|
for (const point of row.path || []) {
|
|
if (!point) continue;
|
|
const key = `${point[0]},${point[1]}`;
|
|
if (occupied.has(key)) overlap++;
|
|
else uniqueKeys.add(key);
|
|
}
|
|
const share = row.path?.length ? overlap / row.path.length : 0;
|
|
const uniqueService = [...row.served].some((id) => !serviceOwners.has(id));
|
|
const redundant = accepted.length > 0
|
|
&& share >= overlapFloor
|
|
&& (uniqueKeys.size <= maxUniqueCells || share >= 0.78)
|
|
&& !uniqueService;
|
|
if (redundant) { pruned++; continue; }
|
|
accepted.push(row);
|
|
for (const point of row.path || []) if (point) occupied.add(`${point[0]},${point[1]}`);
|
|
for (const id of row.served) serviceOwners.add(id);
|
|
}
|
|
accepted.sort((a, b) => a.index - b.index);
|
|
paths.length = 0;
|
|
paths.push(...accepted.map((row) => row.path));
|
|
return { before, after: paths.length, pruned, overlapFloor, maxUniqueCells };
|
|
}
|
|
|
|
const incrementalInfluenceStates = [];
|
|
|
|
function appendPathsToInfluenceState(state, paths, fromIndex) {
|
|
for (let i = fromIndex; i < (paths?.length || 0); i++) {
|
|
const path = paths[i];
|
|
recordPathMeta(state, i, path);
|
|
state.accumulator.add(path, 1, state.radius);
|
|
}
|
|
state.count = paths?.length || 0;
|
|
}
|
|
|
|
function rebuildInfluence(paths, radius = 5) {
|
|
const rows = paths || [];
|
|
let prefixState = null;
|
|
for (let i = 0; i < incrementalInfluenceStates.length; i++) {
|
|
const state = incrementalInfluenceStates[i];
|
|
if (state.radius !== radius) continue;
|
|
if (pathSpatialMetaMatches(state, rows, false)) {
|
|
if (i > 0) { incrementalInfluenceStates.splice(i, 1); incrementalInfluenceStates.unshift(state); }
|
|
return state.accumulator.field;
|
|
}
|
|
if ((!prefixState || state.count > prefixState.count) && pathSpatialMetaMatches(state, rows, true)) prefixState = state;
|
|
}
|
|
if (prefixState) {
|
|
appendPathsToInfluenceState(prefixState, rows, prefixState.count);
|
|
const idx = incrementalInfluenceStates.indexOf(prefixState);
|
|
if (idx > 0) { incrementalInfluenceStates.splice(idx, 1); incrementalInfluenceStates.unshift(prefixState); }
|
|
return prefixState.accumulator.field;
|
|
}
|
|
const state = {
|
|
radius,
|
|
accumulator: createIncrementalPathInfluence([], radius, { exponent: 1.0 }),
|
|
refs: [], lengths: [], firstX: [], firstY: [], midX: [], midY: [], lastX: [], lastY: [], count: 0,
|
|
};
|
|
appendPathsToInfluenceState(state, rows, 0);
|
|
incrementalInfluenceStates.unshift(state);
|
|
if (incrementalInfluenceStates.length > 12) incrementalInfluenceStates.pop();
|
|
return state.accumulator.field;
|
|
}
|
|
|
|
export function finalizeAdminAwareTransport({ seed, terrain, features, admin, initialVisibleCrop = null }) {
|
|
if (!features || !admin) return features;
|
|
// Route failures depend on this terrain raster; never carry memoized failures
|
|
// into another generated map in the same JS realm.
|
|
failedPrimaryRouteKeys.clear();
|
|
failedFallbackRouteKeys.clear();
|
|
nearestPathSpatialStates.length = 0;
|
|
incrementalInfluenceStates.length = 0;
|
|
const minorRoads = features.minorRoads || [];
|
|
const nationalRoads = features.nationalRoads || [];
|
|
const externalRoads = features.externalRoads || [];
|
|
const expressways = features.expressways || [];
|
|
const externalExpressways = features.externalExpressways || [];
|
|
const interchanges = features.interchanges || [];
|
|
const adminCenters = admin.adminCentersRaw || features.adminCenters || [];
|
|
const townsForNational = [
|
|
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
|
];
|
|
const debug = { order: "settlements -> administration -> transport", terrainFirstLongDistanceRouting: true, narrowStraitBridgeAllowanceCells: { national: 2, expressway: 3, rail: 2 }, majorCityAllTrunkFloor: 50000, adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 };
|
|
|
|
// Cache 8-neighbour land components once. Major-city trunk guarantees use it
|
|
// to distinguish a genuine large-strait exception from a routing failure and
|
|
// to prevent a suburban anchor from accidentally landing on a nearby island.
|
|
const landComponentId = new Int32Array(SIZE);
|
|
landComponentId.fill(-1);
|
|
let landComponentCount = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (terrain?.sea?.[i] || landComponentId[i] >= 0) continue;
|
|
const id = landComponentCount++;
|
|
const queue = [i]; landComponentId[i] = id;
|
|
for (let qi = 0; qi < queue.length; qi++) {
|
|
const cur = queue[qi]; const [x, y] = xyOf(cur);
|
|
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 (terrain?.sea?.[ni] || landComponentId[ni] >= 0) continue;
|
|
landComponentId[ni] = id; queue.push(ni);
|
|
}
|
|
}
|
|
}
|
|
const landComponentArea = new Int32Array(Math.max(1, landComponentCount));
|
|
for (let i = 0; i < SIZE; i++) if (landComponentId[i] >= 0) landComponentArea[landComponentId[i]]++;
|
|
const componentAt = (p) => p && inside(Math.round(p.x), Math.round(p.y)) ? landComponentId[indexOf(Math.round(p.x), Math.round(p.y))] : -1;
|
|
const sameLandComponent = (a, b) => componentAt(a) >= 0 && componentAt(a) === componentAt(b);
|
|
const civicTransportTargets = [
|
|
...(features.modernCities || []), ...(features.markets || []), ...(features.ports || []),
|
|
...(features.villages || []), ...(adminCenters || []), ...(features.externalGateways || []),
|
|
].filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y) && inside(Math.round(p.x), Math.round(p.y)) && !terrain?.sea?.[indexOf(Math.round(p.x), Math.round(p.y))]);
|
|
function sameComponentCivicTargets(origin, options = {}) {
|
|
const component = componentAt(origin);
|
|
const minDistance = options.minDistance ?? 7;
|
|
const maxDistance = options.maxDistance ?? 220;
|
|
const preferFar = options.preferFar === true;
|
|
const rows = [];
|
|
const seen = new Set();
|
|
for (const target of civicTransportTargets) {
|
|
if (target === origin || componentAt(target) !== component) continue;
|
|
const key = `${Math.round(target.x)},${Math.round(target.y)}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const d = Math.hypot(target.x - origin.x, target.y - origin.y);
|
|
if (d < minDistance || d > maxDistance) continue;
|
|
const pop = Number(target.population || target.municipalityPopulation || 0);
|
|
const importance = Math.log1p(Math.max(0, pop)) * 2.1 + (target.isPrefecturalCapital ? 8 : 0) + (target.isRegionalCapital ? 6 : 0) + (target.portClass === "major" ? 4 : 0);
|
|
const score = preferFar ? d * 0.34 + importance : d - importance * 0.55;
|
|
rows.push({ ...target, d, score });
|
|
}
|
|
rows.sort((a, b) => a.score - b.score || b.d - a.d);
|
|
return rows.slice(0, options.limit ?? 16);
|
|
}
|
|
function remoteLandTargetOnComponent(origin, options = {}) {
|
|
const component = componentAt(origin);
|
|
if (component < 0) return null;
|
|
const minDistance = options.minDistance ?? 18;
|
|
const maxDistance = options.maxDistance ?? 170;
|
|
let best = null;
|
|
// Coarse scan is enough to choose an OD anchor; the emitted route is still
|
|
// solved at full raster resolution by the production terrain router.
|
|
for (let y = 1; y < MAP_H - 1; y += 3) for (let x = 1; x < MAP_W - 1; x += 3) {
|
|
const idx = indexOf(x, y);
|
|
if (landComponentId[idx] !== component || terrain?.sea?.[idx]) continue;
|
|
const d = Math.hypot(x - origin.x, y - origin.y);
|
|
if (d < minDistance || d > maxDistance) continue;
|
|
const slopePenalty = (terrain?.slope?.[idx] || 0) * 24;
|
|
const ridgePenalty = (terrain?.ridgeField?.[idx] || 0) * 18;
|
|
const elevationPenalty = Math.max(0, (terrain?.elevation?.[idx] || 0) - 0.68) * 30;
|
|
// Prefer a meaningful regional extension, but avoid selecting a mountain
|
|
// summit merely because it is the farthest point on the component.
|
|
const score = d - slopePenalty - ridgePenalty - elevationPenalty;
|
|
if (!best || score > best.score) best = { x, y, d, score, remoteLandAnchor: true };
|
|
}
|
|
return best;
|
|
}
|
|
const pathHasComponent = (paths, component) => {
|
|
if (component < 0) return false;
|
|
for (const path of paths || []) for (const [x, y] of path || []) if (inside(x, y) && landComponentId[indexOf(x, y)] === component) return true;
|
|
return false;
|
|
};
|
|
debug.landComponentCount = landComponentCount;
|
|
|
|
// Local roads after admin: every municipal office cell should lie on a road.
|
|
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
|
for (const center of adminCenters || []) {
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
debug.adminCentersChecked++;
|
|
const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads];
|
|
if (anyPathTouches(roadSet, center, 0.65)) continue;
|
|
const nearRoad = nearestPointOnPaths(roadSet, center, 22);
|
|
const nearSettlement = nearestEntity(settlementTargets, center, 18);
|
|
const target = nearRoad || nearSettlement;
|
|
let path = [];
|
|
if (target) {
|
|
const d = Math.hypot(target.x - center.x, target.y - center.y);
|
|
path = terrainFirstConnector(center, target, terrain, {
|
|
maxLength: Math.max(18, d * 2.8 + 20), maxSeaRun: 0, maxTunnelRun: 6,
|
|
maxExpanded: Math.min(SIZE, Math.max(9000, Math.floor(d * d * 8 + 5000))), maxElevation: 0.82,
|
|
});
|
|
}
|
|
if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) {
|
|
minorRoads.push(path);
|
|
debug.adminLocalRoadsAdded++;
|
|
}
|
|
}
|
|
|
|
// National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town.
|
|
function concatPaths(parts, maxJoinGap = 2.25) {
|
|
const valid = (parts || []).filter((part) => Array.isArray(part) && part.length >= 2);
|
|
if (!valid.length) return [];
|
|
const out = valid[0].map((pt) => [pt[0], pt[1]]);
|
|
for (let partIndex = 1; partIndex < valid.length; partIndex++) {
|
|
let part = valid[partIndex];
|
|
const tail = out[out.length - 1];
|
|
const dForward = Math.hypot(tail[0] - part[0][0], tail[1] - part[0][1]);
|
|
const pLast = part[part.length - 1];
|
|
const dReverse = Math.hypot(tail[0] - pLast[0], tail[1] - pLast[1]);
|
|
if (dReverse < dForward) part = [...part].reverse();
|
|
const head = part[0];
|
|
const gap = Math.hypot(tail[0] - head[0], tail[1] - head[1]);
|
|
// A failed intermediate A* leg used to be silently omitted and the next
|
|
// successful leg was appended anyway. Canvas then drew the missing tens
|
|
// of cells as one perfectly straight segment. A production trunk chain is
|
|
// atomic: if any leg is not contiguous, reject the whole chain so a later
|
|
// terrain-routed service pass can rebuild it correctly.
|
|
if (gap > maxJoinGap) return [];
|
|
for (const pt of part) {
|
|
if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push([pt[0], pt[1]]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
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;
|
|
return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance);
|
|
}
|
|
function buildTownChain(start, pool, maxHops = 7) {
|
|
const chain = [start];
|
|
let cur = start;
|
|
for (let hop = 1; hop < maxHops; hop++) {
|
|
let best = null;
|
|
for (const town of pool) {
|
|
if (chain.includes(town)) continue;
|
|
const d = Math.hypot(cur.x - town.x, cur.y - town.y);
|
|
if (d > 42) continue;
|
|
const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70);
|
|
if (!best || score < best.score) best = { town, d, score };
|
|
}
|
|
if (!best) break;
|
|
chain.push(best.town);
|
|
cur = best.town;
|
|
}
|
|
return chain;
|
|
}
|
|
function addNationalTownChains() {
|
|
let uncovered = townsForNational
|
|
.filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65))
|
|
.sort((a, b) => townWeight(b) - townWeight(a));
|
|
let chainsAdded = 0;
|
|
let townsCovered = 0;
|
|
while (uncovered.length) {
|
|
const start = uncovered.shift();
|
|
let chain = buildTownChain(start, uncovered, 7);
|
|
uncovered = uncovered.filter((town) => !chain.includes(town));
|
|
const parts = [];
|
|
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 = terrainFirstConnector(before, chain[0], terrain, { maxLength: 90, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 8 });
|
|
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 = terrainFirstConnector(chain[i - 1], chain[i], terrain, { maxLength: Math.max(32, d * 2.45 + 22), maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 8 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
if (after) {
|
|
const p = terrainFirstConnector(chain[chain.length - 1], after, terrain, { maxLength: 90, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 8 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
let path = concatPaths(parts);
|
|
if (path.length < 2) {
|
|
const target = nearestTrunkOrHub(chain[0], 90);
|
|
path = target ? terrainFirstConnector(chain[0], target, terrain, { maxLength: 110, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 8 }) : [];
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
return { chainsAdded, townsCovered };
|
|
}
|
|
const chainDebug = addNationalTownChains();
|
|
debug.nationalTownChainsAdded = chainDebug.chainsAdded;
|
|
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
|
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
|
|
|
function routeAlongTerrainGuide(start, guidePath, mode = "national", options = {}) {
|
|
if (!start || !guidePath?.length) return [];
|
|
let nearestIndex = -1, nearestDistance = Infinity;
|
|
for (let k = 0; k < guidePath.length; k++) {
|
|
const q = guidePath[k];
|
|
const d = Math.hypot(q[0] - start.x, q[1] - start.y);
|
|
if (d < nearestDistance) { nearestDistance = d; nearestIndex = k; }
|
|
}
|
|
if (nearestIndex < 0) return [];
|
|
const lengthToward = (dir) => {
|
|
let len = 0;
|
|
for (let k = nearestIndex + dir; k >= 0 && k < guidePath.length; k += dir) {
|
|
const prev = guidePath[k - dir];
|
|
len += Math.hypot(guidePath[k][0] - prev[0], guidePath[k][1] - prev[1]);
|
|
}
|
|
return len;
|
|
};
|
|
const dir = lengthToward(1) >= lengthToward(-1) ? 1 : -1;
|
|
const desired = options.desiredLength ?? 64;
|
|
const stepDistance = options.stepDistance ?? 10;
|
|
const guideTargets = [];
|
|
let walked = 0, nextSample = Math.max(6, stepDistance);
|
|
let last = guidePath[nearestIndex];
|
|
for (let k = nearestIndex + dir; k >= 0 && k < guidePath.length && walked <= desired + stepDistance; k += dir) {
|
|
const q = guidePath[k];
|
|
walked += Math.hypot(q[0] - last[0], q[1] - last[1]);
|
|
last = q;
|
|
if (walked >= nextSample) {
|
|
guideTargets.push({ x: q[0], y: q[1] });
|
|
nextSample += stepDistance;
|
|
}
|
|
}
|
|
if (!guideTargets.length) return [];
|
|
const parts = [];
|
|
let cur = { x: start.x, y: start.y };
|
|
for (const target of guideTargets) {
|
|
const d = Math.hypot(target.x - cur.x, target.y - cur.y);
|
|
if (d < 1.5) continue;
|
|
let leg = routeTerrainPath(cur, target, terrain, {
|
|
maxLength: d * 3.4 + 24, maxSeaRun: 0, maxTunnelRun: 0,
|
|
snapRadius: 0.8, maxExpanded: SIZE, maxElevation: 0.695, strictTerrain: true,
|
|
});
|
|
if (!leg.length) leg = routeLandConnectedTerrainFallback(cur, target, terrain, {
|
|
maxLength: Math.min(SIZE, d * 4.2 + 36), maxElevation: 0.695, strictTerrain: true,
|
|
});
|
|
if (leg.length < 2) return [];
|
|
parts.push(leg);
|
|
const tail = leg[leg.length - 1];
|
|
cur = { x: tail[0], y: tail[1] };
|
|
}
|
|
const joined = concatPaths(parts, 2.25);
|
|
if (joined.length < 4) return [];
|
|
const smoothed = terrainSafeSmooth(joined, terrain, mode, mode === "expressway" ? 3 : 2);
|
|
return hardTerrainPathValid(smoothed, mode) ? smoothed : [];
|
|
}
|
|
|
|
function sharedTerrainAlignmentFromGuide(start, guidePath, mode = "national", options = {}) {
|
|
if (!start || !guidePath?.length) return [];
|
|
let nearestIndex = -1, nearestDistance = Infinity;
|
|
for (let k = 0; k < guidePath.length; k++) {
|
|
const q = guidePath[k];
|
|
const d = Math.hypot(q[0] - start.x, q[1] - start.y);
|
|
if (d < nearestDistance) { nearestDistance = d; nearestIndex = k; }
|
|
}
|
|
if (nearestIndex < 0) return [];
|
|
const branchLength = (dir) => {
|
|
let len = 0, last = guidePath[nearestIndex];
|
|
for (let k = nearestIndex + dir; k >= 0 && k < guidePath.length; k += dir) {
|
|
const q = guidePath[k]; len += Math.hypot(q[0] - last[0], q[1] - last[1]); last = q;
|
|
}
|
|
return len;
|
|
};
|
|
const dir = branchLength(1) >= branchLength(-1) ? 1 : -1;
|
|
const join = { x: guidePath[nearestIndex][0], y: guidePath[nearestIndex][1] };
|
|
let connector = [];
|
|
if (nearestDistance > 0.75) {
|
|
connector = routeTerrainPath(start, join, terrain, {
|
|
maxLength: nearestDistance * 3.6 + 28, maxSeaRun: 0, maxTunnelRun: 0,
|
|
snapRadius: 0.7, maxExpanded: SIZE, maxElevation: 0.695, strictTerrain: true,
|
|
});
|
|
if (!connector.length) connector = routeLandConnectedTerrainFallback(start, join, terrain, {
|
|
maxLength: Math.min(SIZE, nearestDistance * 4.5 + 40), maxElevation: 0.695, strictTerrain: true,
|
|
});
|
|
if (connector.length < 2) return [];
|
|
} else connector = [[Math.round(start.x), Math.round(start.y)], [join.x, join.y]];
|
|
const desired = options.desiredLength ?? 58;
|
|
const slice = [[join.x, join.y]];
|
|
let walked = 0, last = guidePath[nearestIndex];
|
|
for (let k = nearestIndex + dir; k >= 0 && k < guidePath.length && walked < desired; k += dir) {
|
|
const q = guidePath[k];
|
|
walked += Math.hypot(q[0] - last[0], q[1] - last[1]);
|
|
slice.push([q[0], q[1]]); last = q;
|
|
}
|
|
if (walked < Math.min(14, desired * 0.35)) return [];
|
|
const joined = concatPaths([connector, slice], 2.25);
|
|
if (joined.length < 4) return [];
|
|
// Do not geometrically smooth the shared section: the guide already passed
|
|
// the production terrain invariant, and corner-cutting could reintroduce a
|
|
// mountain/sea shortcut. Exact shared alignment is preferable to a fake
|
|
// parallel motorway when no independent corridor exists.
|
|
return hardTerrainPathValid(joined, mode) ? joined : [];
|
|
}
|
|
|
|
function sharedSuburbanTerrainAlignment(city, guidePath, mode = "expressway", options = {}) {
|
|
if (!city || !guidePath?.length) return [];
|
|
const inner = Math.max(7, (city.coreRadius || 4) + 4.5);
|
|
const outer = Math.max(inner + 6, Math.min(34, (city.urbanRadius || 12) * 2.15));
|
|
const desired = options.desiredLength ?? 64;
|
|
const rect = options.focusRect || null;
|
|
const inRect = (q) => !rect || (q[0] >= rect.x0 + 1 && q[1] >= rect.y0 + 1 && q[0] < rect.x1 - 1 && q[1] < rect.y1 - 1);
|
|
let best = null;
|
|
for (let k = 0; k < guidePath.length; k++) {
|
|
const q = guidePath[k];
|
|
const cityD = Math.hypot(q[0] - city.x, q[1] - city.y);
|
|
if (cityD < inner || cityD > outer) continue;
|
|
for (const dir of [-1, 1]) {
|
|
const slice = [[q[0], q[1]]];
|
|
let len = 0, insideLen = 0, last = q;
|
|
for (let j = k + dir; j >= 0 && j < guidePath.length && len < desired; j += dir) {
|
|
const r = guidePath[j];
|
|
const step = Math.hypot(r[0] - last[0], r[1] - last[1]);
|
|
len += step;
|
|
if (inRect(r)) insideLen += step;
|
|
slice.push([r[0], r[1]]); last = r;
|
|
}
|
|
if (len < Math.min(14, desired * 0.30)) continue;
|
|
const score = insideLen * 3 + len - Math.abs(cityD - (inner + outer) * 0.5) * 0.2;
|
|
if (!best || score > best.score) best = { slice, score };
|
|
}
|
|
}
|
|
if (!best || !hardTerrainPathValid(best.slice, mode)) return [];
|
|
return best.slice;
|
|
}
|
|
|
|
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;
|
|
if (landComponentId[i] !== componentAt(city)) 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 = terrainFirstConnector(anchor, bestEnd, terrain, { maxLength: d * 2.7 + 30, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 7 });
|
|
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 nearOuter = false;
|
|
let minD = Infinity;
|
|
let maxD = 0;
|
|
for (const [x, y] of path || []) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
minD = Math.min(minD, d);
|
|
maxD = Math.max(maxD, d);
|
|
if (d >= inner && d <= outer) inBand = true;
|
|
if (d >= outer - 2) nearOuter = true;
|
|
}
|
|
const serviceRadius = Math.max(18, Math.min(34, (city.urbanRadius || 12) * 2.2));
|
|
const largeCity = (city.population || 0) >= 180000;
|
|
const largeCityReach = Math.max(12, Math.min(24, (city.urbanRadius || 12) * 1.55));
|
|
// For a several-hundred-thousand-person city, the broad annulus rule is
|
|
// deliberately disabled: remote fringe passage is not city service.
|
|
if (largeCity) {
|
|
if (minD <= largeCityReach && pathLengthCells(path) >= 12 && maxD - minD >= 8) return true;
|
|
} else {
|
|
// Smaller cities may be served by a regional bypass traversing their
|
|
// suburban annulus even if it stays farther from the urban core.
|
|
if (inBand && (nearOuter || maxD - minD >= 8)) return true;
|
|
if (minD <= serviceRadius && pathLengthCells(path) >= 10 && maxD - minD >= 7) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureMajorCityExpresswayLinks(minPopulation = 60000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => city && inside(city.x, city.y) && !terrain?.sea?.[indexOf(city.x, city.y)] && ((city.population || 0) >= minPopulation || city.isPrefecturalCapital || city.isRegionalCapital))
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0, seededBackbone: 0, seededComponentBackbones: 0 };
|
|
features.expressways ||= [];
|
|
if (!cities.length) return result;
|
|
|
|
if (!(features.expressways || []).length && !(features.externalExpressways || []).length && cities.length >= 2) {
|
|
const a = suburbanExpresswayAnchorForCity(cities[0], cities[1]);
|
|
const b = suburbanExpresswayAnchorForCity(cities[1], cities[0]);
|
|
if (a && b) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
let seedPath = routeTerrainPath(a, b, terrain, { maxLength: d * 2.8 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5, maxExpanded: SIZE });
|
|
if (!seedPath.length) seedPath = terrainFirstConnector(a, b, terrain, { skipPrimaryRoute: true, maxLength: d * 2.9 + 72, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (seedPath.length >= 4 && trunkElevationSafe(seedPath, terrain, 0.72)) {
|
|
seedPath = terrainSafeSmooth(seedPath, terrain, "expressway", 4);
|
|
if (hardTerrainPathValid(seedPath, "expressway")) {
|
|
features.expressways.push(seedPath);
|
|
result.seededBackbone++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A global expressway can exist on another island while a sizeable land
|
|
// component containing several major cities has no motorway at all. Seed a
|
|
// terrain-aware backbone independently for each such component. This is not
|
|
// a straight fallback: both suburban anchors and the route stay on land.
|
|
const citiesByComponent = new Map();
|
|
for (const city of cities) {
|
|
const component = componentAt(city);
|
|
if (component < 0) continue;
|
|
if (!citiesByComponent.has(component)) citiesByComponent.set(component, []);
|
|
citiesByComponent.get(component).push(city);
|
|
}
|
|
for (const [component, componentCities] of citiesByComponent) {
|
|
if (componentCities.length < 1) continue;
|
|
const existingNetwork = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
if (pathHasComponent(existingNetwork, component)) continue;
|
|
const ordered = componentCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
let seeded = false;
|
|
for (let aIndex = 0; aIndex < Math.min(3, ordered.length) && !seeded; aIndex++) {
|
|
const aCity = ordered[aIndex];
|
|
const targetPool = [
|
|
...ordered.filter((q) => q !== aCity),
|
|
...sameComponentCivicTargets(aCity, { minDistance: 16, maxDistance: 230, limit: 20, preferFar: componentCities.length < 2 }),
|
|
];
|
|
const targetSeen = new Set();
|
|
const targets = targetPool.filter((q) => {
|
|
if (!q || componentAt(q) !== component) return false;
|
|
const key = `${Math.round(q.x)},${Math.round(q.y)}`;
|
|
if (key === `${Math.round(aCity.x)},${Math.round(aCity.y)}` || targetSeen.has(key)) return false;
|
|
targetSeen.add(key);
|
|
return Math.hypot(q.x - aCity.x, q.y - aCity.y) >= 12;
|
|
}).sort((p, q) => {
|
|
const pd = Math.hypot(p.x - aCity.x, p.y - aCity.y);
|
|
const qd = Math.hypot(q.x - aCity.x, q.y - aCity.y);
|
|
// Single-major-city components need a meaningful regional corridor,
|
|
// not a tiny motorway stub. Prefer a farther populated/administrative
|
|
// anchor while staying on the same land component.
|
|
return componentCities.length < 2 ? qd - pd : pd - qd;
|
|
});
|
|
for (const bCity of targets.slice(0, 18)) {
|
|
const a = suburbanExpresswayAnchorForCity(aCity, bCity);
|
|
const b = suburbanExpresswayAnchorForCity(bCity, aCity);
|
|
let seedPath = [];
|
|
if (a && b && componentAt(a) === component && componentAt(b) === component) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d >= 7) {
|
|
seedPath = routeTerrainPath(a, b, terrain, { maxLength: d * 3.1 + 90, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.5, maxExpanded: SIZE });
|
|
if (!seedPath.length) seedPath = terrainFirstConnector(a, b, terrain, { skipPrimaryRoute: true, maxLength: d * 3.2 + 94, maxSeaRun: 0, maxTunnelRun: 28, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!seedPath.length) seedPath = routeLandConnectedTerrainFallback(a, b, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
}
|
|
}
|
|
// If ring anchors are unavailable on a narrow/irregular island, route
|
|
// city-to-city on the same land component and crop the core-city ends.
|
|
// The emitted motorway still starts outside each urban core and never
|
|
// becomes a terrain-ignoring straight connector.
|
|
if (!seedPath.length) {
|
|
let corePath = routeLandConnectedTerrainFallback(aCity, bCity, terrain, { maxLength: SIZE });
|
|
if (corePath.length >= 8) {
|
|
const aInner = Math.max(7.0, (aCity.coreRadius || 4) + 4.5);
|
|
const bInner = Math.max(7.0, (bCity.coreRadius || 4) + 4.5);
|
|
let first = corePath.findIndex(([x, y]) => Math.hypot(x - aCity.x, y - aCity.y) >= aInner);
|
|
let last = -1;
|
|
for (let k = corePath.length - 1; k >= 0; k--) {
|
|
const [x, y] = corePath[k];
|
|
if (Math.hypot(x - bCity.x, y - bCity.y) >= bInner) { last = k; break; }
|
|
}
|
|
if (first >= 0 && last > first + 3) corePath = corePath.slice(first, last + 1);
|
|
else corePath = [];
|
|
}
|
|
seedPath = corePath;
|
|
}
|
|
if (seedPath.length < 4 || !trunkElevationSafe(seedPath, terrain, 0.72)) continue;
|
|
seedPath = terrainSafeSmooth(seedPath, terrain, "expressway", 4);
|
|
const turns = pathSharpTurnStats(seedPath);
|
|
if (turns.consecutiveExtreme >= 2 || turns.sharpShare > 0.46) continue;
|
|
if (!hardTerrainPathValid(seedPath, "expressway")) continue;
|
|
features.expressways.push(seedPath);
|
|
result.seededComponentBackbones++;
|
|
seeded = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const city of cities) {
|
|
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
|
|
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
const cityComponent = componentAt(city);
|
|
let targets = nearestPointsOnPaths(existing, city, Infinity, 24, 3).filter((target) => componentAt(target) === cityComponent).slice(0, 14);
|
|
let connectingToExisting = targets.length > 0;
|
|
if (!targets.length) {
|
|
targets = sameComponentCivicTargets(city, { minDistance: 14, maxDistance: 230, limit: 16, preferFar: true });
|
|
connectingToExisting = false;
|
|
}
|
|
if (!targets.length) {
|
|
const remote = remoteLandTargetOnComponent(city, { minDistance: 16, maxDistance: 180 });
|
|
if (remote) targets = [remote];
|
|
}
|
|
if (!targets.length) result.noTarget++;
|
|
let path = [];
|
|
for (const target of targets) {
|
|
const anchor = suburbanExpresswayAnchorForCity(city, target);
|
|
if (!anchor) continue;
|
|
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
|
if (d < 4) continue;
|
|
let candidate = routeTerrainPath(anchor, target, terrain, { maxLength: d * 3.35 + 90, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.3, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = terrainFirstConnector(anchor, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.45 + 94, maxSeaRun: 0, maxTunnelRun: 28, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(anchor, target, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (candidate.length < 4 || !trunkElevationSafe(candidate, terrain, 0.72)) continue;
|
|
if (connectingToExisting) candidate = trimRouteAtExistingNetwork(candidate, existing, 3.0, 4);
|
|
candidate = terrainSafeSmooth(candidate, terrain, "expressway", 4);
|
|
const turns = pathSharpTurnStats(candidate);
|
|
if (turns.consecutiveExtreme >= 2 || turns.sharpShare > 0.46) continue;
|
|
path = candidate;
|
|
break;
|
|
}
|
|
// If the nearest existing motorway lies behind an awkward ridge/coast corridor, prefer a
|
|
// city-to-city backbone to a straight or terrain-ignoring feeder. One such corridor can
|
|
// serve two major cities and therefore also keeps motorway branching lower.
|
|
if (!path.length) {
|
|
const partnerCities = cities.filter((q) => q !== city && sameLandComponent(city, q))
|
|
.sort((a, b) => Math.hypot(a.x - city.x, a.y - city.y) - Math.hypot(b.x - city.x, b.y - city.y));
|
|
for (const partner of partnerCities.slice(0, 8)) {
|
|
let a = suburbanExpresswayAnchorForCity(city, partner);
|
|
let b = suburbanExpresswayAnchorForCity(partner, city);
|
|
let candidate = [];
|
|
if (a && b && componentAt(a) === cityComponent && componentAt(b) === cityComponent) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d >= 6) {
|
|
candidate = routeTerrainPath(a, b, terrain, { maxLength: d * 3.45 + 108, maxSeaRun: 0, maxTunnelRun: 32, snapRadius: 2.4, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = terrainFirstConnector(a, b, terrain, { skipPrimaryRoute: true, maxLength: d * 3.55 + 112, maxSeaRun: 0, maxTunnelRun: 32, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(a, b, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
}
|
|
}
|
|
if (!candidate.length) {
|
|
candidate = routeTerrainPath(city, partner, terrain, { maxLength: Math.hypot(partner.x-city.x, partner.y-city.y) * 3.6 + 120, maxSeaRun: 0, maxTunnelRun: 34, snapRadius: 2.2, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(city, partner, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (candidate.length >= 8) {
|
|
const cityInner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
|
const partnerInner = Math.max(7.0, (partner.coreRadius || 4) + 4.5);
|
|
let first = candidate.findIndex(([x, y]) => Math.hypot(x - city.x, y - city.y) >= cityInner);
|
|
let last = -1;
|
|
for (let k = candidate.length - 1; k >= 0; k--) {
|
|
const [x, y] = candidate[k];
|
|
if (Math.hypot(x - partner.x, y - partner.y) >= partnerInner) { last = k; break; }
|
|
}
|
|
candidate = first >= 0 && last > first + 3 ? candidate.slice(first, last + 1) : [];
|
|
}
|
|
}
|
|
if (candidate.length < 4 || !trunkElevationSafe(candidate, terrain, 0.72)) continue;
|
|
candidate = terrainSafeSmooth(candidate, terrain, "expressway", 4);
|
|
const turns = pathSharpTurnStats(candidate);
|
|
if (turns.consecutiveExtreme >= 2 || turns.sharpShare > 0.46) continue;
|
|
path = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!path.length) {
|
|
// If the city already has a terrain-valid national route, use that
|
|
// corridor only as a sequence of terrain waypoints. The motorway is
|
|
// still independently A*-routed at full resolution; no polyline is
|
|
// copied or straight-interpolated from the national road.
|
|
const nationalGuides = [...(features.nationalRoads || []), ...(features.externalRoads || [])]
|
|
.map((guide) => ({ guide, hit: nearestPointOnPaths([guide], city, 7) }))
|
|
.filter((row) => row.hit && componentAt(row.hit) === cityComponent)
|
|
.sort((a, b) => a.hit.d - b.hit.d);
|
|
for (const row of nationalGuides.slice(0, 4)) {
|
|
const hint = row.guide[Math.min(row.guide.length - 1, Math.max(0, Math.floor(row.guide.length * 0.7)))] || null;
|
|
const targetHint = hint ? { x: hint[0], y: hint[1] } : null;
|
|
const anchor = suburbanExpresswayAnchorForCity(city, targetHint);
|
|
let guided = sharedSuburbanTerrainAlignment(city, row.guide, "expressway", { desiredLength: 68 });
|
|
if (!guided.length && anchor) guided = routeAlongTerrainGuide(anchor, row.guide, "expressway", { desiredLength: 72, stepDistance: 9 });
|
|
if (!guided.length && anchor) guided = sharedTerrainAlignmentFromGuide(anchor, row.guide, "expressway", { desiredLength: 64 });
|
|
if (guided.length >= 4) { path = guided; break; }
|
|
}
|
|
}
|
|
if (!path.length) {
|
|
const regionalHint = sameComponentCivicTargets(city, { minDistance: 12, maxDistance: 230, limit: 1, preferFar: true })[0] || null;
|
|
const stub = suburbanExpresswayStubForCity(city, regionalHint);
|
|
if (stub.length >= 4 && trunkElevationSafe(stub, terrain, 0.72)) {
|
|
const turns = pathSharpTurnStats(stub);
|
|
if (turns.consecutiveExtreme < 2 && turns.sharpShare <= 0.46) path = terrainSafeSmooth(stub, terrain, "expressway", 3);
|
|
}
|
|
}
|
|
// A candidate can survive the local turn checks but still fail the final
|
|
// terrain invariant. Treat that exactly like a routing failure so the
|
|
// large-city guarantee below gets a chance to find a valid alternative.
|
|
if (path.length && (pathLengthCells(path) < 3 || !hardTerrainPathValid(path, "expressway"))) {
|
|
path = [];
|
|
result.invalidPrimaryCandidateRetried = (result.invalidPrimaryCandidateRetried || 0) + 1;
|
|
}
|
|
if (!path.length && (city.population || 0) >= 180000) {
|
|
// Hard guarantee for large cities on awkward coasts / narrow basins.
|
|
// First exploit an already proven terrain corridor (national road or
|
|
// railway) only as A* waypoints: the expressway is routed independently
|
|
// between suburban points instead of copying the guide geometry.
|
|
const largeReach = Math.max(12, Math.min(24, (city.urbanRadius || 12) * 1.55));
|
|
const approachInner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
|
const terrainGuides = [
|
|
...(features.nationalRoads || []), ...(features.externalRoads || []),
|
|
...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || []),
|
|
].map((guide) => ({ guide, hit: nearestPointOnPaths([guide], city, largeReach + 4) }))
|
|
.filter((row) => row.hit && componentAt(row.hit) === cityComponent)
|
|
.sort((a, b) => a.hit.d - b.hit.d || b.guide.length - a.guide.length);
|
|
result.largeCityGuideRows = (result.largeCityGuideRows || 0) + terrainGuides.length;
|
|
for (const { guide } of terrainGuides.slice(0, 12)) {
|
|
const candidates = [];
|
|
for (let k = 0; k < guide.length; k++) {
|
|
const d = Math.hypot(guide[k][0] - city.x, guide[k][1] - city.y);
|
|
if (d >= approachInner && d <= largeReach) candidates.push(k);
|
|
}
|
|
result.largeCityGuideCandidateStarts = (result.largeCityGuideCandidateStarts || 0) + candidates.length;
|
|
for (const startIndex of candidates.slice(0, 6)) {
|
|
const startPoint = { x: guide[startIndex][0], y: guide[startIndex][1] };
|
|
const ends = [0, guide.length - 1].sort((a, b) => Math.abs(b - startIndex) - Math.abs(a - startIndex));
|
|
for (const endIndex of ends) {
|
|
const endPoint = { x: guide[endIndex][0], y: guide[endIndex][1] };
|
|
const d = Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
|
|
if (d < 10) continue;
|
|
let candidate = routeTerrainPath(startPoint, endPoint, terrain, { maxLength: d * 3.7 + 72, maxSeaRun: 0, maxTunnelRun: 30, snapRadius: 1.2, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(startPoint, endPoint, terrain, { maxLength: Math.min(SIZE, d * 4.6 + 96), maxElevation: 0.695 });
|
|
candidate = terrainSafeSmooth(candidate, terrain, "expressway", 2);
|
|
if (candidate.length >= 10 && hardTerrainPathValid(candidate, "expressway")) {
|
|
const turns = pathSharpTurnStats(candidate);
|
|
if (turns.consecutiveExtreme < 2 && turns.sharpShare <= 0.48) {
|
|
path = candidate;
|
|
result.largeCityGuideRoutedFallbackAdded = (result.largeCityGuideRoutedFallbackAdded || 0) + 1;
|
|
break;
|
|
}
|
|
}
|
|
// At this raster resolution, a national road / railway corridor
|
|
// can be the only terrain-valid valley through a mountain block.
|
|
// Reuse that already terrain-validated *corridor geometry* rather
|
|
// than inventing a straight shortcut or leaving a 200k+ city
|
|
// without motorway access. The two modes may share a raster cell
|
|
// while remaining distinct transport layers.
|
|
let shared = startIndex <= endIndex
|
|
? guide.slice(startIndex, endIndex + 1)
|
|
: guide.slice(endIndex, startIndex + 1).reverse();
|
|
const sharedTerrainValid = shared.length >= 10 && hardTerrainPathValid(shared, "expressway");
|
|
if (sharedTerrainValid) {
|
|
const turns = pathSharpTurnStats(shared);
|
|
if (turns.consecutiveExtreme < 2 && turns.sharpShare <= 0.48) {
|
|
path = shared;
|
|
result.largeCitySharedTerrainCorridorFallbackAdded = (result.largeCitySharedTerrainCorridorFallbackAdded || 0) + 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (path.length) break;
|
|
}
|
|
if (path.length) break;
|
|
}
|
|
|
|
// If no guide has enough suburban extent, solve the complete route from
|
|
// the city at full terrain resolution, then remove only the inner-city
|
|
// portion so the motorway still behaves as a suburban approach.
|
|
const existingNow = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
const directTargets = [
|
|
...nearestPointsOnPaths(existingNow, city, Infinity, 32, 3).filter((target) => componentAt(target) === cityComponent),
|
|
...sameComponentCivicTargets(city, { minDistance: 22, maxDistance: 230, limit: 18, preferFar: true }),
|
|
];
|
|
const seenDirect = new Set();
|
|
for (const target of path.length ? [] : directTargets) {
|
|
if (!target) continue;
|
|
const key = `${Math.round(target.x)},${Math.round(target.y)}`;
|
|
if (seenDirect.has(key)) continue;
|
|
seenDirect.add(key);
|
|
const d = Math.hypot(target.x - city.x, target.y - city.y);
|
|
if (d < 14) continue;
|
|
let candidate = routeTerrainPath(city, target, terrain, { maxLength: d * 3.8 + 132, maxSeaRun: 0, maxTunnelRun: 38, snapRadius: 2.1, maxExpanded: SIZE, maxElevation: 0.72 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(city, target, terrain, { maxLength: SIZE, maxElevation: 0.72 });
|
|
if (candidate.length < 10) continue;
|
|
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
|
const first = candidate.findIndex(([x, y]) => Math.hypot(x - city.x, y - city.y) >= inner);
|
|
if (first < 0 || candidate.length - first < 8) continue;
|
|
candidate = candidate.slice(first);
|
|
if (existingNow.length) candidate = trimRouteAtExistingNetwork(candidate, existingNow, 3.0, 4);
|
|
candidate = terrainSafeSmooth(candidate, terrain, "expressway", 3);
|
|
if (candidate.length < 8 || !trunkElevationSafe(candidate, terrain, 0.72) || !hardTerrainPathValid(candidate, "expressway")) continue;
|
|
const turns = pathSharpTurnStats(candidate);
|
|
if (turns.consecutiveExtreme >= 2 || turns.sharpShare > 0.48) continue;
|
|
path = candidate;
|
|
result.largeCityDirectFallbackAdded = (result.largeCityDirectFallbackAdded || 0) + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (!path.length || pathLengthCells(path) < 3 || !hardTerrainPathValid(path, "expressway")) { result.noPath++; continue; }
|
|
features.expressways.push(path);
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function cityRailChain(minPopulation = 50000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
|
.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 };
|
|
if (cities.length < 2) return result;
|
|
const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2));
|
|
if (!uncovered.length) return result;
|
|
const remaining = uncovered.slice();
|
|
let chain = [remaining.shift()];
|
|
while (remaining.length) {
|
|
const cur = chain[chain.length - 1];
|
|
let bestIndex = 0;
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y);
|
|
if (d < bestD) { bestD = d; bestIndex = i; }
|
|
}
|
|
chain.push(remaining.splice(bestIndex, 1)[0]);
|
|
}
|
|
const parts = [];
|
|
for (let i = 1; i < chain.length; i++) {
|
|
const a = chain[i - 1], b = chain[i];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
let p = terrainFirstConnector(a, b, terrain, { maxLength: d * 2.55 + 30, maxSeaRun: 0, maxTunnelRun: 12, shortFallback: 7 });
|
|
if (!p.length) p = terrainFirstConnector(a, b, terrain, { maxLength: d * 2.95 + 48, maxSeaRun: 0, maxTunnelRun: 14, shortFallback: 7 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
const path = concatPaths(parts);
|
|
if (path.length >= 2) {
|
|
features.railways = features.railways || [];
|
|
features.railways.push(smoothPath(path, 1));
|
|
result.chainsAdded = 1;
|
|
result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const railDebug = cityRailChain(75000);
|
|
debug.railCityChainsAdded = railDebug.chainsAdded;
|
|
debug.railCityChainCitiesCovered = railDebug.citiesCovered;
|
|
|
|
const expressDebug = ensureMajorCityExpresswayLinks(60000);
|
|
debug.expresswayMajorCityLinksAdded = expressDebug.added;
|
|
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
|
|
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
|
|
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
|
|
|
|
// Expressway finalization after administration. Smooth only when the
|
|
// terrain-safe smoother actually improves curvature, and keep the same
|
|
// narrow-strait contract used by mandatory service routing. The former
|
|
// 20-cell sea allowance could turn a repair into an implausibly straight
|
|
// long bridge.
|
|
for (let i = 0; i < expressways.length; i++) {
|
|
const before = expressways[i];
|
|
const smoothed = terrainSafeSmooth(before, terrain, "expressway", 4);
|
|
if (smoothed.length >= 2) {
|
|
const runs = pathTerrainRuns(smoothed, terrain);
|
|
const turns = pathSharpTurnStats(smoothed);
|
|
if (runs.maxTunnelRun <= 24 && runs.maxSeaRun <= 3 && turns.consecutiveExtreme <= 1) {
|
|
expressways[i] = smoothed;
|
|
if (smoothed !== before) debug.expresswaysSmoothed++;
|
|
}
|
|
}
|
|
}
|
|
const expresswayBeforeTerrainPrune = expressways.length;
|
|
for (let i = expressways.length - 1; i >= 0; i--) {
|
|
const runs = pathTerrainRuns(expressways[i], terrain);
|
|
const turns = pathSharpTurnStats(expressways[i]);
|
|
if (runs.maxTunnelRun > 24 || runs.maxSeaRun > 3 || turns.consecutiveExtreme > 1 || turns.sharpShare > 0.42) 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 >= 2) 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 = terrainFirstConnector(pair.a, pair.b, terrain, { maxLength: pair.d * 2.7 + 42, maxSeaRun: 0, maxTunnelRun: 10, shortFallback: 7 });
|
|
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 || !trunkElevationSafe(path, terrain, 0.72)) { result.failed++; continue; }
|
|
const runs = pathTerrainRuns(path, terrain);
|
|
if (runs.maxTunnelRun > 18 || runs.maxSeaRun > 3 || pathSharpTurnStats(path).consecutiveExtreme > 1) { result.failed++; continue; }
|
|
expressways.push(terrainSafeSmooth(path, terrain, "expressway", 3));
|
|
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 = terrainFirstConnector(p, hit, terrain, { maxLength: d * 2.35 + 22, maxSeaRun: 0, maxTunnelRun: 8, maxExpanded: Math.min(SIZE, Math.max(7000, Math.floor(d * d * 8 + 4000))), maxElevation: 0.86 });
|
|
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;
|
|
|
|
function connectNearbyPathTermini(pathGroups, mode, options = {}) {
|
|
const result = { mode, candidates: 0, added: 0, failed: 0 };
|
|
const endpoints = [];
|
|
pathGroups.forEach((group) => {
|
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
|
const path = group.paths[pathIdx];
|
|
if (!path || path.length < 2) continue;
|
|
const ends = [path[0], path[path.length - 1]];
|
|
ends.forEach((raw, end) => {
|
|
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
|
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)]) return;
|
|
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 < (options.minDistance ?? 2.5) || d > (options.maxDistance ?? 16)) continue;
|
|
pairs.push({ a, b, d });
|
|
}
|
|
}
|
|
pairs.sort((a, b) => a.d - b.d);
|
|
const used = new Set();
|
|
for (const pair of pairs) {
|
|
if (result.added >= (options.maxAdded ?? 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) || used.has(bk)) continue;
|
|
result.candidates++;
|
|
let path = routeTerrainPath(pair.a, pair.b, terrain, {
|
|
maxLength: pair.d * (options.routeDetour ?? 2.4) + (options.routeSlack ?? 22),
|
|
maxSeaRun: options.maxSeaRun ?? 0,
|
|
maxTunnelRun: options.maxTunnelRun ?? (mode === 'rail' ? 12 : 8),
|
|
snapRadius: options.snapRadius ?? 1.6,
|
|
});
|
|
if (!path.length) path = terrainFirstConnector(pair.a, pair.b, terrain, {
|
|
skipPrimaryRoute: true,
|
|
maxLength: pair.d * (options.routeDetour ?? 2.8) + (options.routeSlack ?? 28),
|
|
maxSeaRun: options.maxSeaRun ?? 0,
|
|
maxTunnelRun: options.maxTunnelRun ?? (mode === 'rail' ? 12 : 8),
|
|
maxExpanded: Math.min(SIZE, Math.max(9000, Math.floor(pair.d * pair.d * 7 + 6000))),
|
|
maxElevation: mode === 'local' ? 0.86 : 0.72,
|
|
});
|
|
if (!path.length || pathLengthCells(path) < 3 || (mode !== 'local' && !trunkElevationSafe(path, terrain, 0.72))) { result.failed++; continue; }
|
|
pathGroups[0].paths.push(smoothPath(path, 1));
|
|
used.add(ak); used.add(bk);
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Urban local-road densification is handled only by mapTransport.js's existing terrain-aware local-access algorithm.
|
|
|
|
function ensureMajorCityNationalRoadLinks(minPopulation = 50000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => city && inside(city.x, city.y) && !terrain?.sea?.[indexOf(city.x, city.y)] && ((city.population || 0) >= minPopulation || city.isPrefecturalCapital || city.isRegionalCapital))
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
|
|
features.nationalRoads ||= [];
|
|
for (const city of cities) {
|
|
const network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
if (anyPathTouches(network, city, 3.0)) { result.covered++; continue; }
|
|
const cityComponent = componentAt(city);
|
|
let targets = nearestPointsOnPaths(network, city, Infinity, 28, 3).filter((target) => componentAt(target) === cityComponent).slice(0, 14);
|
|
if (!targets.length) {
|
|
targets = cities.filter((q) => q !== city)
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - city.x, q.y - city.y) }))
|
|
.filter((q) => q.d >= 10 && q.d <= 180 && sameLandComponent(city, q))
|
|
.sort((a, b) => a.d - b.d).slice(0, 8);
|
|
}
|
|
if (!targets.length) {
|
|
targets = sameComponentCivicTargets(city, { minDistance: 8, maxDistance: 220, limit: 16 });
|
|
}
|
|
if (!targets.length) { result.noTarget++; continue; }
|
|
let path = [];
|
|
for (const target of targets) {
|
|
const d = Math.hypot(target.x - city.x, target.y - city.y);
|
|
path = routeTerrainPath(city, target, terrain, { maxLength: d * 3.0 + 72, maxSeaRun: 0, maxTunnelRun: 20, snapRadius: 2.0, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(city, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.05 + 64, maxSeaRun: 0, maxTunnelRun: 20, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(city, target, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length >= (d < 4 ? 2 : 4) && trunkElevationSafe(path, terrain, 0.72)) break;
|
|
path = [];
|
|
}
|
|
if (!path.length || pathLengthCells(path) < 2) { result.noPath++; continue; }
|
|
path = trimRouteAtExistingNetwork(path, network, 2.6, 3);
|
|
path = terrainSafeSmooth(path, terrain, "national", 2);
|
|
if (!hardTerrainPathValid(path, "national")) { result.noPath++; continue; }
|
|
features.nationalRoads.push(path);
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
function ensureRuralNationalRoadCoverage() {
|
|
features.nationalRoads ||= [];
|
|
const result = {
|
|
checked: 0, eligible: 0, alreadyServed: 0, added: 0, noTarget: 0, noPath: 0,
|
|
forcedRemote: 0, alternateTargetsTried: 0,
|
|
};
|
|
const candidates = (adminCenters || [])
|
|
.filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)])
|
|
.map((p) => ({ ...p, population: Number(p.municipalityPopulation || p.population || 0) }))
|
|
.filter((p) => p.population >= 2500 && p.population < 42000);
|
|
const areaScale = Math.max(1, SIZE / (258 * 183));
|
|
// The old fixed cap (roughly a dozen roads on the overscan map) left whole
|
|
// rural districts without a national-road corridor. Scale the budget with
|
|
// the actual number of rural municipalities, while keeping it bounded so
|
|
// difficult mountain maps cannot turn this guarantee into an unbounded A* loop.
|
|
const maxAdded = Math.min(candidates.length, Math.max(18, Math.round(7 + candidates.length * 0.46 + 2 * Math.sqrt(areaScale))));
|
|
const profileFor = (pop) => pop >= 18000
|
|
? { serviceRadius: 8.0, probability: 1.00, remoteFloor: 22 }
|
|
: pop >= 10000 ? { serviceRadius: 9.0, probability: 0.96, remoteFloor: 25 }
|
|
: pop >= 6000 ? { serviceRadius: 10.0, probability: 0.84, remoteFloor: 29 }
|
|
: pop >= 3500 ? { serviceRadius: 11.0, probability: 0.68, remoteFloor: 34 }
|
|
: { serviceRadius: 12.0, probability: 0.50, remoteFloor: 40 };
|
|
|
|
// At this late stage the local-road network is already terrain-routed and
|
|
// connected. Reusing a continuous local-road corridor as a national-road
|
|
// alignment is both cheaper and more realistic than solving a second,
|
|
// parallel A* route beside it. Only corridors that also satisfy national
|
|
// terrain constraints are promoted.
|
|
const roadMask = new Uint8Array(SIZE);
|
|
const nationalMask = new Uint8Array(SIZE);
|
|
const rasterizeToMask = (path, mask) => {
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
for (let q = 0; q <= steps; q++) {
|
|
const t = q / steps;
|
|
const x = Math.round(a[0] + (b[0] - a[0]) * t), y = Math.round(a[1] + (b[1] - a[1]) * t);
|
|
if (inside(x, y)) mask[indexOf(x, y)] = 1;
|
|
}
|
|
}
|
|
};
|
|
for (const path of [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])]) rasterizeToMask(path, roadMask);
|
|
for (const path of [...(features.nationalRoads || []), ...(features.externalRoads || [])]) rasterizeToMask(path, nationalMask);
|
|
const roadSeen = new Uint32Array(SIZE);
|
|
const roadPrev = new Int32Array(SIZE);
|
|
const roadQueue = new Int32Array(SIZE);
|
|
let roadStamp = 0;
|
|
const roadDirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
function promoteExistingRoadCorridor(point, maxCells = 180) {
|
|
let start = -1, startD2 = Infinity;
|
|
const sx0 = Math.max(0, Math.floor(point.x - 6)), sx1 = Math.min(MAP_W - 1, Math.ceil(point.x + 6));
|
|
const sy0 = Math.max(0, Math.floor(point.y - 6)), sy1 = Math.min(MAP_H - 1, Math.ceil(point.y + 6));
|
|
for (let y = sy0; y <= sy1; y++) for (let x = sx0; x <= sx1; x++) {
|
|
const i = indexOf(x, y); if (!roadMask[i]) continue;
|
|
const d2 = (x - point.x) ** 2 + (y - point.y) ** 2;
|
|
if (d2 < startD2) { startD2 = d2; start = i; }
|
|
}
|
|
if (start < 0 || startD2 > 36) return [];
|
|
roadStamp = (roadStamp + 1) >>> 0;
|
|
if (!roadStamp) { roadSeen.fill(0); roadStamp = 1; }
|
|
let head = 0, tail = 0, found = -1;
|
|
roadQueue[tail++] = start; roadSeen[start] = roadStamp; roadPrev[start] = -1;
|
|
while (head < tail && head < maxCells * 28) {
|
|
const cur = roadQueue[head++];
|
|
if (cur !== start && nationalMask[cur]) { found = cur; break; }
|
|
const x = cur % MAP_W, y = (cur / MAP_W) | 0;
|
|
for (const [dx, dy] of roadDirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
|
const ni = ny * MAP_W + nx;
|
|
if (!roadMask[ni] || roadSeen[ni] === roadStamp) continue;
|
|
roadSeen[ni] = roadStamp; roadPrev[ni] = cur; roadQueue[tail++] = ni;
|
|
}
|
|
}
|
|
if (found < 0) return [];
|
|
const rev = [];
|
|
for (let cur = found; cur >= 0; cur = roadPrev[cur]) {
|
|
rev.push([cur % MAP_W, (cur / MAP_W) | 0]);
|
|
if (cur === start || rev.length > maxCells) break;
|
|
}
|
|
if (!rev.length || rev[rev.length - 1][0] !== start % MAP_W || rev[rev.length - 1][1] !== ((start / MAP_W) | 0)) return [];
|
|
const path = rev.reverse();
|
|
if (path.length < 4 || path.length > maxCells || !hardTerrainPathValid(path, "national")) return [];
|
|
return path;
|
|
}
|
|
|
|
// Rank genuinely underserved municipalities first. This avoids spending the
|
|
// route budget on already-near-trunk towns while leaving a 50-100 cell rural
|
|
// void untouched.
|
|
const initialNetwork = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const ranked = candidates.map((point) => {
|
|
const profile = profileFor(point.population || 0);
|
|
const hit = nearestPointOnPaths(initialNetwork, point, 150);
|
|
const distance = hit?.d ?? 180;
|
|
const deficit = distance / Math.max(1, profile.serviceRadius);
|
|
return { point, profile, initialDistance: distance, score: deficit * 10 + Math.log1p(point.population || 0) };
|
|
}).sort((a, b) => b.score - a.score || b.point.population - a.point.population || a.point.y - b.point.y || a.point.x - b.point.x);
|
|
|
|
for (const row of ranked) {
|
|
if (result.added >= maxAdded) break;
|
|
const point = row.point;
|
|
const pop = point.population || 0;
|
|
const profile = row.profile;
|
|
result.checked++;
|
|
let network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const currentHit = nearestPointOnPaths(network, point, 150);
|
|
if (currentHit && currentHit.d <= profile.serviceRadius) { result.alreadyServed++; continue; }
|
|
|
|
const remote = !currentHit || currentHit.d >= profile.remoteFloor;
|
|
const drawKey = Math.round(point.x) * 131 + Math.round(point.y) * 197 + Math.floor(pop / 500);
|
|
if (!remote && rand(seed + 84217, drawKey) > profile.probability) continue;
|
|
if (remote) result.forcedRemote++;
|
|
result.eligible++;
|
|
|
|
const promoted = promoteExistingRoadCorridor(point, remote ? 190 : 140);
|
|
if (promoted.length) {
|
|
features.nationalRoads.push(promoted);
|
|
rasterizeToMask(promoted, nationalMask);
|
|
rasterizeToMask(promoted, roadMask);
|
|
result.promotedLocalCorridors = (result.promotedLocalCorridors || 0) + 1;
|
|
result.added++;
|
|
continue;
|
|
}
|
|
|
|
// Try several physically distinct attachment points. The previous single-
|
|
// target policy was the main source of false failures: one ridge-blocked
|
|
// nearest point caused the municipality to be abandoned even when another
|
|
// nearby valley corridor existed on the same trunk network.
|
|
const targetLimit = pop >= 10000 || remote ? 4 : 3;
|
|
const maxTargetDistance = remote ? 145 : (pop >= 12000 ? 105 : 88);
|
|
const targets = nearestPointsOnPaths(network, point, maxTargetDistance, targetLimit, 5)
|
|
.filter((target) => sameLandComponent(point, target));
|
|
if (currentHit && currentHit.d <= maxTargetDistance && sameLandComponent(point, currentHit)
|
|
&& !targets.some((q) => Math.hypot(q.x - currentHit.x, q.y - currentHit.y) < 4)) targets.unshift(currentHit);
|
|
if (remote) {
|
|
// A remote municipality may sit behind a ridge from the geometrically
|
|
// nearest trunk. Also try nearby municipalities in the same land
|
|
// component so a valley-following regional trunk can grow toward the
|
|
// existing network over several municipalities.
|
|
const peers = candidates
|
|
.filter((q) => q !== point && sameLandComponent(point, q))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - point.x, q.y - point.y) }))
|
|
.filter((q) => q.d >= 7 && q.d <= 72)
|
|
.sort((a, b) => a.d - b.d || b.population - a.population)
|
|
.slice(0, 2);
|
|
for (const peer of peers) if (!targets.some((q) => Math.hypot(q.x - peer.x, q.y - peer.y) < 4)) targets.push(peer);
|
|
}
|
|
if (!targets.length) {
|
|
const civic = [...(features.modernCities || []), ...(features.markets || []), ...(adminCenters || [])]
|
|
.filter((q) => q && q !== point && inside(q.x, q.y) && sameLandComponent(point, q))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - point.x, q.y - point.y), p: Number(q.municipalityPopulation || q.population || 0) }))
|
|
.filter((q) => q.d >= 9 && q.d <= 105 && q.p >= Math.max(6000, pop * 1.05))
|
|
.sort((a, b) => (a.d - b.d) || (b.p - a.p)).slice(0, 3);
|
|
targets.push(...civic);
|
|
}
|
|
if (!targets.length) {
|
|
// Some islands / mountain basins have no pre-existing national-road
|
|
// object at all. Seed a trunk within that land component instead of
|
|
// declaring every municipality there permanently unserviceable.
|
|
const componentPeers = candidates
|
|
.filter((q) => q !== point && sameLandComponent(point, q))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - point.x, q.y - point.y) }))
|
|
.filter((q) => q.d >= 8 && q.d <= 115)
|
|
.sort((a, b) => (b.population - a.population) || (a.d - b.d));
|
|
const stronger = componentPeers.filter((q) => q.population >= Math.max(3500, pop * 0.9)).slice(0, 2);
|
|
targets.push(...(stronger.length ? stronger : componentPeers.slice(0, 2)));
|
|
}
|
|
if (!targets.length) { result.noTarget++; continue; }
|
|
// Bound alternative terrain searches so mountainous seeds cannot explode
|
|
// in runtime. Multiple candidates remain enough to fix the old
|
|
// single-target false-negative behavior.
|
|
if (targets.length > 4) {
|
|
targets.sort((a, b) => {
|
|
const ad = Math.hypot(a.x - point.x, a.y - point.y);
|
|
const bd = Math.hypot(b.x - point.x, b.y - point.y);
|
|
return ad - bd;
|
|
});
|
|
targets.length = 4;
|
|
}
|
|
|
|
let path = [];
|
|
for (let ti = 0; ti < targets.length; ti++) {
|
|
const target = targets[ti];
|
|
if (ti > 0) result.alternateTargetsTried++;
|
|
const d = Math.hypot(target.x - point.x, target.y - point.y);
|
|
let candidate = routeTerrainPath(point, target, terrain, {
|
|
maxLength: d * 3.0 + 72, maxSeaRun: 0, maxTunnelRun: 18,
|
|
snapRadius: 1.8, maxExpanded: SIZE, maxElevation: 0.695,
|
|
});
|
|
if (!candidate.length) candidate = terrainFirstConnector(point, target, terrain, {
|
|
skipPrimaryRoute: true, maxLength: d * 3.2 + 82, maxSeaRun: 0,
|
|
maxTunnelRun: 18, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695,
|
|
});
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(point, target, terrain, {
|
|
maxLength: Math.min(SIZE, d * 4.1 + 104), maxElevation: 0.695,
|
|
});
|
|
if (candidate.length < 4 || !trunkElevationSafe(candidate, terrain, 0.72)) continue;
|
|
network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
candidate = trimRouteAtExistingNetwork(candidate, network, 2.8, 3);
|
|
candidate = terrainSafeSmooth(candidate, terrain, "national", 2);
|
|
if (candidate.length < 3 || !hardTerrainPathValid(candidate, "national")) continue;
|
|
path = candidate;
|
|
break;
|
|
}
|
|
if (!path.length) { result.noPath++; continue; }
|
|
features.nationalRoads.push(path);
|
|
rasterizeToMask(path, nationalMask);
|
|
rasterizeToMask(path, roadMask);
|
|
result.added++;
|
|
}
|
|
// If strict new-trunk routing still cannot cross a local ridge, reuse an
|
|
// already-built terrain-valid local-road alignment through the municipal
|
|
// seat. This increases rural national-road presence without inventing a
|
|
// simplified straight road or a side-by-side duplicate corridor.
|
|
const promotionBudget = Math.min(10, Math.max(4, Math.round(candidates.length * 0.16)));
|
|
let promotedAfterRouting = 0;
|
|
for (const point of candidates) {
|
|
if (promotedAfterRouting >= promotionBudget || result.added >= maxAdded) break;
|
|
const profile = profileFor(point.population || 0);
|
|
const network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
if (anyPathTouches(network, point, profile.serviceRadius)) continue;
|
|
let best = null;
|
|
for (const path of features.minorRoads || []) {
|
|
if (!path || path.length < 8 || !pathTouchesCell(path, point.x, point.y, 3.2)) continue;
|
|
if (!hardTerrainPathValid(path, "national")) continue;
|
|
const a = path[0], b = path[path.length - 1];
|
|
const da = nearestPointOnPaths(network, { x: a[0], y: a[1] }, 80)?.d ?? 80;
|
|
const db = nearestPointOnPaths(network, { x: b[0], y: b[1] }, 80)?.d ?? 80;
|
|
const score = Math.min(da, db) - Math.min(24, pathLengthCells(path)) * 0.08;
|
|
if (!best || score < best.score) best = { path, score };
|
|
}
|
|
if (!best) continue;
|
|
const promotedPath = best.path.map((q) => [q[0], q[1]]);
|
|
features.nationalRoads.push(promotedPath);
|
|
rasterizeToMask(promotedPath, nationalMask);
|
|
rasterizeToMask(promotedPath, roadMask);
|
|
promotedAfterRouting++;
|
|
result.added++;
|
|
}
|
|
result.promotedRuralLocalAlignments = promotedAfterRouting;
|
|
result.maxAdded = maxAdded;
|
|
return result;
|
|
}
|
|
|
|
function repairUrbanNationalRoadGaps() {
|
|
features.nationalRoads ||= [];
|
|
const result = {
|
|
citiesChecked: 0, candidateGaps: 0, attemptedPairs: 0, alternatePairsTried: 0,
|
|
added: 0, noPath: 0, unresolvedCities: 0,
|
|
};
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => city && inside(city.x, city.y) && !terrain?.sea?.[indexOf(city.x, city.y)] && (city.population || 0) >= 45000)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const maxAdded = Math.max(12, Math.round(cities.length * 1.25));
|
|
|
|
for (const city of cities) {
|
|
if (result.added >= maxAdded) break;
|
|
result.citiesChecked++;
|
|
const radius = Math.max(13, Math.min(30, (city.urbanRadius || 10) * 2.0));
|
|
const rows = [];
|
|
for (let pi = 0; pi < features.nationalRoads.length; pi++) {
|
|
const path = features.nationalRoads[pi];
|
|
if (!path?.length) continue;
|
|
for (const tuple of [path[0], path[path.length - 1]]) {
|
|
const d = Math.hypot(tuple[0] - city.x, tuple[1] - city.y);
|
|
if (d <= radius) rows.push({ x: tuple[0], y: tuple[1], pathIndex: pi, d });
|
|
}
|
|
}
|
|
const pairs = [];
|
|
for (let a = 0; a < rows.length; a++) for (let b = a + 1; b < rows.length; b++) {
|
|
if (rows[a].pathIndex === rows[b].pathIndex) continue;
|
|
const gap = Math.hypot(rows[a].x - rows[b].x, rows[a].y - rows[b].y);
|
|
if (gap <= 1.0 || gap > 20 || !sameLandComponent(rows[a], rows[b])) continue;
|
|
pairs.push({ a: rows[a], b: rows[b], gap, score: gap + 0.12 * (rows[a].d + rows[b].d) });
|
|
}
|
|
pairs.sort((u, v) => u.score - v.score || u.gap - v.gap);
|
|
if (!pairs.length) continue;
|
|
result.candidateGaps += pairs.length;
|
|
let connector = [];
|
|
// One successful connector per city is enough here. If the shortest gap
|
|
// is terrain-blocked, try several other endpoint pairs instead of
|
|
// abandoning the city after one failed A* call.
|
|
for (let pi = 0; pi < Math.min(6, pairs.length); pi++) {
|
|
const pair = pairs[pi];
|
|
result.attemptedPairs++;
|
|
if (pi > 0) result.alternatePairsTried++;
|
|
let candidate = routeTerrainPath(pair.a, pair.b, terrain, {
|
|
maxLength: pair.gap * 3.0 + 26, maxSeaRun: 0, maxTunnelRun: 12,
|
|
snapRadius: 1.2, maxExpanded: Math.min(SIZE, 20000), maxElevation: 0.72,
|
|
});
|
|
if (!candidate.length) candidate = terrainFirstConnector(pair.a, pair.b, terrain, {
|
|
skipPrimaryRoute: true, maxLength: pair.gap * 3.35 + 30, maxSeaRun: 0,
|
|
maxTunnelRun: 12, maxExpanded: Math.min(SIZE, 24000), maxElevation: 0.72,
|
|
});
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(pair.a, pair.b, terrain, {
|
|
maxLength: Math.min(SIZE, pair.gap * 4.0 + 38), maxElevation: 0.695,
|
|
});
|
|
if (candidate.length < 3) continue;
|
|
candidate = terrainSafeSmooth(candidate, terrain, "national", 1);
|
|
if (!hardTerrainPathValid(candidate, "national")) continue;
|
|
connector = candidate;
|
|
break;
|
|
}
|
|
if (!connector.length) { result.noPath++; result.unresolvedCities++; continue; }
|
|
features.nationalRoads.push(connector);
|
|
result.added++;
|
|
}
|
|
result.maxAdded = maxAdded;
|
|
return result;
|
|
}
|
|
|
|
|
|
function ensureMajorCityRailLinks(minPopulation = 50000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => city && inside(city.x, city.y) && !terrain?.sea?.[indexOf(city.x, city.y)] && ((city.population || 0) >= minPopulation || city.isPrefecturalCapital || city.isRegionalCapital))
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
|
|
features.railways ||= [];
|
|
features.branchRailways ||= [];
|
|
for (const city of cities) {
|
|
const network = [...(features.railways || []), ...(features.externalRailways || []), ...(features.branchRailways || [])];
|
|
if (anyPathTouches(network, city, 3.0)) { result.covered++; continue; }
|
|
const cityComponent = componentAt(city);
|
|
let targets = nearestPointsOnPaths(network, city, Infinity, 28, 3).filter((target) => componentAt(target) === cityComponent).slice(0, 14);
|
|
if (!targets.length) {
|
|
targets = cities.filter((q) => q !== city)
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - city.x, q.y - city.y) }))
|
|
.filter((q) => q.d >= 10 && q.d <= 185 && sameLandComponent(city, q))
|
|
.sort((a, b) => a.d - b.d).slice(0, 8);
|
|
}
|
|
if (!targets.length) {
|
|
targets = sameComponentCivicTargets(city, { minDistance: 8, maxDistance: 220, limit: 16 });
|
|
}
|
|
if (!targets.length) { result.noTarget++; continue; }
|
|
let path = [];
|
|
for (const target of targets) {
|
|
const d = Math.hypot(target.x - city.x, target.y - city.y);
|
|
path = routeTerrainPath(city, target, terrain, { maxLength: d * 3.1 + 78, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.0, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(city, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.15 + 70, maxSeaRun: 0, maxTunnelRun: 28, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(city, target, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length >= (d < 4 ? 2 : 4) && trunkElevationSafe(path, terrain, 0.72)) break;
|
|
path = [];
|
|
}
|
|
// A major city must not be left rail-isolated merely because the nearest existing line is
|
|
// on the opposite side of a difficult local ridge. Try several same-land-component major
|
|
// cities as alternate OD targets; this still uses the full terrain router and never emits
|
|
// a direct straight fallback.
|
|
if (!path.length) {
|
|
const partnerCities = cities.filter((q) => q !== city && sameLandComponent(city, q))
|
|
.sort((a, b) => Math.hypot(a.x - city.x, a.y - city.y) - Math.hypot(b.x - city.x, b.y - city.y));
|
|
for (const partner of partnerCities.slice(0, 10)) {
|
|
const d = Math.hypot(partner.x - city.x, partner.y - city.y);
|
|
let candidate = routeTerrainPath(city, partner, terrain, { maxLength: d * 3.6 + 112, maxSeaRun: 0, maxTunnelRun: 36, snapRadius: 2.0, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = terrainFirstConnector(city, partner, terrain, { skipPrimaryRoute: true, maxLength: d * 3.7 + 116, maxSeaRun: 0, maxTunnelRun: 36, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(city, partner, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (candidate.length < 4 || !trunkElevationSafe(candidate, terrain, 0.72)) continue;
|
|
path = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!path.length) {
|
|
const nationalGuides = [...(features.nationalRoads || []), ...(features.externalRoads || [])]
|
|
.map((guide) => ({ guide, hit: nearestPointOnPaths([guide], city, 6) }))
|
|
.filter((row) => row.hit && componentAt(row.hit) === cityComponent)
|
|
.sort((a, b) => a.hit.d - b.hit.d);
|
|
for (const row of nationalGuides.slice(0, 5)) {
|
|
const guided = routeAlongTerrainGuide(city, row.guide, "rail", { desiredLength: 58, stepDistance: 8 });
|
|
if (guided.length >= 4) { path = guided; break; }
|
|
}
|
|
}
|
|
if (!path.length || pathLengthCells(path) < 2) { result.noPath++; continue; }
|
|
path = trimRouteAtExistingNetwork(path, network, 2.4, 3);
|
|
path = terrainSafeSmooth(path, terrain, "rail", 2);
|
|
if (!hardTerrainPathValid(path, "rail")) { result.noPath++; continue; }
|
|
features.branchRailways.push(path);
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
function densifyRailToNationalRatio(targetRatio = 0.75, focusRect = null) {
|
|
features.railways ||= [];
|
|
features.branchRailways ||= [];
|
|
const normalizedFocus = focusRect && [focusRect.x0, focusRect.y0, focusRect.x1, focusRect.y1].every(Number.isFinite)
|
|
? { x0: Math.floor(focusRect.x0), y0: Math.floor(focusRect.y0), x1: Math.ceil(focusRect.x1), y1: Math.ceil(focusRect.y1) }
|
|
: null;
|
|
const pointInFocus = (x, y, margin = 0) => !normalizedFocus
|
|
|| (x >= normalizedFocus.x0 - margin && y >= normalizedFocus.y0 - margin && x < normalizedFocus.x1 + margin && y < normalizedFocus.y1 + margin);
|
|
const lengthSum = (groups) => (groups || []).reduce((sum, path) => {
|
|
if (!normalizedFocus) return sum + pathLengthCells(path || []);
|
|
let subtotal = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
const mx = (a[0] + b[0]) * 0.5, my = (a[1] + b[1]) * 0.5;
|
|
if (pointInFocus(mx, my)) subtotal += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
}
|
|
return sum + subtotal;
|
|
}, 0);
|
|
const nationalLength = lengthSum([...(features.nationalRoads || [])]);
|
|
// Density is evaluated on the publishable centre when literal initial
|
|
// overscan is active. The pathfinder itself still sees the entire hidden
|
|
// raster, so this does not regress to edge-clipped planning.
|
|
const currentRailLength = () => lengthSum([...(features.railways || []), ...(features.branchRailways || [])]);
|
|
const targetLength = Math.max(0, nationalLength * targetRatio);
|
|
const candidates = [
|
|
...(features.modernCities || []).filter((p) => (p.population || 0) >= 12000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
|
|
].filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)] && pointInFocus(p.x, p.y, normalizedFocus ? 26 : 0))
|
|
.sort((a, b) => ((b.population || 0) + (b.isPrefecturalCapital ? 300000 : 0)) - ((a.population || 0) + (a.isPrefecturalCapital ? 300000 : 0)));
|
|
const result = { targetRatio, focusRect: normalizedFocus, nationalLength: Math.round(nationalLength), beforeRailLength: Math.round(currentRailLength()), targetRailLength: Math.round(targetLength), added: 0, noTarget: 0, noPath: 0 };
|
|
const visited = new Set();
|
|
for (const node of candidates) {
|
|
if (result.added >= 28 || currentRailLength() >= targetLength) break;
|
|
const key = `${Math.round(node.x)},${Math.round(node.y)}`;
|
|
if (visited.has(key)) continue;
|
|
visited.add(key);
|
|
const railSet = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
if (anyPathTouches(railSet, node, 3.0)) continue;
|
|
let target = nearestPointOnPaths(railSet, node, 85);
|
|
if (!target) {
|
|
target = candidates
|
|
.filter((q) => q !== node && !visited.has(`${Math.round(q.x)},${Math.round(q.y)}`))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - node.x, q.y - node.y) }))
|
|
.filter((q) => q.d >= 10 && q.d <= 95)
|
|
.sort((a, b) => a.d - b.d)[0] || null;
|
|
}
|
|
if (!target) { result.noTarget++; continue; }
|
|
const d = Math.hypot(target.x - node.x, target.y - node.y);
|
|
let path = routeTerrainPath(node, target, terrain, { maxLength: d * 2.55 + 42, maxSeaRun: 0, maxTunnelRun: 12, snapRadius: 2.0 });
|
|
if (!path.length) path = terrainFirstConnector(node, target, terrain, { skipPrimaryRoute: true, maxLength: d * 2.65 + 38, maxSeaRun: 0, maxTunnelRun: 12, shortFallback: 7 });
|
|
if (!path.length || pathLengthCells(path) < 4 || !trunkElevationSafe(path, terrain, 0.72)) { result.noPath++; continue; }
|
|
path = terrainSafeSmooth(path, terrain, "rail", 2);
|
|
if (!hardTerrainPathValid(path, "rail")) { result.noPath++; continue; }
|
|
features.branchRailways.push(path);
|
|
result.added++;
|
|
}
|
|
// If all important nodes are already within station-distance of a line, the
|
|
// simple "unserved node -> nearest rail" pass cannot add urban capacity even
|
|
// when the railway network is still much sparser than the national-road
|
|
// network. Add a small number of secondary OD corridors between populated
|
|
// hubs. These remain full terrain-routed railways and are rejected when the
|
|
// interior would merely shadow an existing line.
|
|
result.secondaryCorridorsAdded = 0;
|
|
if (nationalLength > 0 && currentRailLength() < targetLength * 0.96) {
|
|
const rawHubs = [
|
|
...(features.modernCities || []).filter((p) => (p.population || 0) >= 18000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) >= 4500),
|
|
...(features.newTowns || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.satelliteCities || []).filter((p) => (p.population || 0) >= 7000),
|
|
].filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)] && pointInFocus(p.x, p.y, normalizedFocus ? 30 : 0));
|
|
const hubs = [];
|
|
for (const p of rawHubs.sort((a, b) => (b.population || 0) - (a.population || 0))) {
|
|
if (hubs.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= 5.5)) hubs.push(p);
|
|
if (hubs.length >= 34) break;
|
|
}
|
|
const pairs = [];
|
|
for (let a = 0; a < hubs.length; a++) for (let b = a + 1; b < hubs.length; b++) {
|
|
const A = hubs[a], B = hubs[b];
|
|
if (!sameLandComponent(A, B)) continue;
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 10 || d > 82) continue;
|
|
const demand = Math.sqrt(Math.max(3000, A.population || 0) * Math.max(3000, B.population || 0));
|
|
const score = d / Math.max(0.35, demand / 90000 + (A.isPrefecturalCapital || B.isPrefecturalCapital ? 0.45 : 0));
|
|
pairs.push({ A, B, d, score });
|
|
}
|
|
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
|
|
for (const pair of pairs) {
|
|
if (result.secondaryCorridorsAdded >= 14 || currentRailLength() >= targetLength) break;
|
|
const railSet = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
const existingInfluence = rebuildInfluence(railSet, 2.6);
|
|
let path = routeTerrainPath(pair.A, pair.B, terrain, { maxLength: pair.d * 2.75 + 54, maxSeaRun: 0, maxTunnelRun: 26, snapRadius: 1.8, maxExpanded: SIZE });
|
|
if (!path.length) path = terrainFirstConnector(pair.A, pair.B, terrain, { skipPrimaryRoute: true, maxLength: pair.d * 2.85 + 58, maxSeaRun: 0, maxTunnelRun: 26, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (path.length < 8 || !trunkElevationSafe(path, terrain, 0.72)) continue;
|
|
path = terrainSafeSmooth(path, terrain, "rail", 3);
|
|
const from = Math.min(path.length - 1, Math.max(2, Math.floor(path.length * 0.16)));
|
|
const to = Math.max(from + 1, Math.ceil(path.length * 0.84));
|
|
let nearExisting = 0, checked = 0;
|
|
for (let k = from; k < to; k += 2) {
|
|
const [x, y] = path[k];
|
|
if (!inside(x, y)) continue;
|
|
checked++;
|
|
if ((existingInfluence[indexOf(x, y)] || 0) > 0.30) nearExisting++;
|
|
}
|
|
if (checked && nearExisting / checked > 0.46) continue;
|
|
const runs = pathTerrainRuns(path, terrain);
|
|
if (runs.maxSeaRun > 0 || runs.maxTunnelRun > 26 || !hardTerrainPathValid(path, "rail")) continue;
|
|
const majorPair = (pair.A.population || 0) >= 50000 && (pair.B.population || 0) >= 50000;
|
|
(majorPair ? features.railways : features.branchRailways).push(path);
|
|
result.secondaryCorridorsAdded++;
|
|
}
|
|
}
|
|
result.added += result.secondaryCorridorsAdded;
|
|
result.afterRailLength = Math.round(currentRailLength());
|
|
result.achievedRatio = nationalLength > 0 ? result.afterRailLength / nationalLength : 0;
|
|
return result;
|
|
}
|
|
|
|
// If a publishable crop has enough rail only because the national-road network
|
|
// is unusually sparse, deleting useful rail is the wrong correction. Grow the
|
|
// national network with the same terrain-routed production connectors used by
|
|
// the normal generator until rail is modestly below national-road density.
|
|
// This routine never emits a straight/simple fallback and rejects corridors
|
|
// that merely shadow an existing national road.
|
|
function densifyNationalToRailRatio(targetRailShare = 0.94, focusRect = null) {
|
|
features.nationalRoads ||= [];
|
|
const normalizedFocus = focusRect && [focusRect.x0, focusRect.y0, focusRect.x1, focusRect.y1].every(Number.isFinite)
|
|
? { x0: Math.floor(focusRect.x0), y0: Math.floor(focusRect.y0), x1: Math.ceil(focusRect.x1), y1: Math.ceil(focusRect.y1) }
|
|
: null;
|
|
const pointInFocus = (x, y, margin = 0) => !normalizedFocus
|
|
|| (x >= normalizedFocus.x0 - margin && y >= normalizedFocus.y0 - margin && x < normalizedFocus.x1 + margin && y < normalizedFocus.y1 + margin);
|
|
const measuredLength = (path) => {
|
|
if (!normalizedFocus) return pathLengthCells(path || []);
|
|
let len = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
const mx = (a[0] + b[0]) * 0.5, my = (a[1] + b[1]) * 0.5;
|
|
if (pointInFocus(mx, my)) len += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
}
|
|
return len;
|
|
};
|
|
const nationalLength = () => (features.nationalRoads || []).reduce((sum, path) => sum + measuredLength(path), 0);
|
|
const railLength = [...(features.railways || []), ...(features.branchRailways || [])].reduce((sum, path) => sum + measuredLength(path), 0);
|
|
const requiredNational = targetRailShare > 0 ? railLength / targetRailShare : railLength;
|
|
const result = { targetRailShare, focusRect: normalizedFocus, railLength: Math.round(railLength), beforeNationalLength: Math.round(nationalLength()), requiredNationalLength: Math.round(requiredNational), added: 0, accessAdded: 0, secondaryAdded: 0, noTarget: 0, noPath: 0, parallelRejected: 0 };
|
|
if (railLength <= 0 || nationalLength() >= requiredNational) {
|
|
result.afterNationalLength = result.beforeNationalLength;
|
|
result.achievedRailShare = result.afterNationalLength > 0 ? railLength / result.afterNationalLength : 0;
|
|
return result;
|
|
}
|
|
|
|
const rawNodes = [
|
|
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) >= 3000),
|
|
...(features.newTowns || []).filter((p) => (p.population || 0) >= 3500),
|
|
...(features.satelliteCities || []).filter((p) => (p.population || 0) >= 4500),
|
|
...(features.adminCenters || []).filter((p) => (p.municipalityPopulation || p.population || 0) >= 2000 || p.isPrefecturalCapital || p.isRegionalCapital),
|
|
...(features.ports || []).filter((p) => (p.population || 0) >= 3000),
|
|
].filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)] && pointInFocus(p.x, p.y, normalizedFocus ? 30 : 0));
|
|
const nodes = [];
|
|
for (const p of rawNodes.sort((a, b) => ((b.population || b.municipalityPopulation || 0) + (b.isPrefecturalCapital ? 350000 : 0)) - ((a.population || a.municipalityPopulation || 0) + (a.isPrefecturalCapital ? 350000 : 0)))) {
|
|
if (nodes.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= 4.0)) nodes.push(p);
|
|
if (nodes.length >= 48) break;
|
|
}
|
|
|
|
const routeNational = (a, b) => {
|
|
const d = Math.hypot(b.x - a.x, b.y - a.y);
|
|
if (d < 3 || d > 170) return [];
|
|
let path = routeTerrainPath(a, b, terrain, { maxLength: d * 2.9 + 68, maxSeaRun: 0, maxTunnelRun: 20, snapRadius: 1.9, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(a, b, terrain, { skipPrimaryRoute: true, maxLength: d * 3.05 + 72, maxSeaRun: 0, maxTunnelRun: 20, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(a, b, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length < 4 || !trunkElevationSafe(path, terrain, 0.72)) return [];
|
|
return terrainSafeSmooth(path, terrain, "national", 2);
|
|
};
|
|
const hasExcessiveOverlap = (path, network) => {
|
|
if (!path?.length || !network?.length) return false;
|
|
const influence = rebuildInfluence(network, 3.4);
|
|
const from = Math.min(path.length - 1, Math.max(2, Math.floor(path.length * 0.14)));
|
|
const to = Math.max(from + 1, Math.ceil(path.length * 0.86));
|
|
let checked = 0, near = 0;
|
|
for (let k = from; k < to; k += 2) {
|
|
const [x, y] = path[k];
|
|
if (!inside(x, y)) continue;
|
|
checked++;
|
|
if ((influence[indexOf(x, y)] || 0) > 0.30) near++;
|
|
}
|
|
return checked >= 3 && near / checked > 0.38;
|
|
};
|
|
|
|
// First give medium/small civic centres proper access to the national-road
|
|
// network. This adds useful spokes rather than arbitrary line mileage.
|
|
for (const node of nodes) {
|
|
if (result.added >= 18 || nationalLength() >= requiredNational) break;
|
|
const network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
if (anyPathTouches(network, node, 3.0)) continue;
|
|
const component = componentAt(node);
|
|
let targets = nearestPointsOnPaths(network, node, 120, 24, 3).filter((q) => componentAt(q) === component).slice(0, 10);
|
|
if (!targets.length) targets = nodes.filter((q) => q !== node && sameLandComponent(node, q))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - node.x, q.y - node.y) })).filter((q) => q.d >= 8 && q.d <= 100).sort((a, b) => a.d - b.d).slice(0, 8);
|
|
if (!targets.length) { result.noTarget++; continue; }
|
|
let accepted = [];
|
|
let acceptedScore = Infinity;
|
|
const remaining = Math.max(0, requiredNational - nationalLength());
|
|
for (const target of targets) {
|
|
const candidate = routeNational(node, target);
|
|
if (!candidate.length) continue;
|
|
if (hasExcessiveOverlap(candidate, network)) { result.parallelRejected++; continue; }
|
|
const trimmed = trimRouteAtExistingNetwork(candidate, network, 2.7, 3);
|
|
if (trimmed.length < 3) continue;
|
|
const contribution = measuredLength(trimmed);
|
|
if (contribution < 2) continue;
|
|
// Do not cure a modest rail/national imbalance by adding a huge trunk
|
|
// whose only virtue is that it happens to pass an unserved hamlet.
|
|
// Prefer a route whose visible contribution is close to the remaining
|
|
// production-density deficit.
|
|
const maxUseful = Math.max(14, remaining * 1.45);
|
|
if (contribution > maxUseful) continue;
|
|
const score = Math.abs(contribution - Math.max(8, remaining * 0.78));
|
|
if (score < acceptedScore) { accepted = trimmed; acceptedScore = score; }
|
|
}
|
|
if (!accepted.length) { result.noPath++; continue; }
|
|
features.nationalRoads.push(accepted);
|
|
result.added++; result.accessAdded++;
|
|
}
|
|
|
|
// If every settlement is already close to a national road but the published
|
|
// network is still too sparse, add demand-driven secondary OD corridors.
|
|
if (nationalLength() < requiredNational * 0.99) {
|
|
const pairs = [];
|
|
for (let a = 0; a < nodes.length; a++) for (let b = a + 1; b < nodes.length; b++) {
|
|
const A = nodes[a], B = nodes[b];
|
|
if (!sameLandComponent(A, B)) continue;
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 12 || d > 95) continue;
|
|
const popA = Math.max(1500, A.population || A.municipalityPopulation || 0);
|
|
const popB = Math.max(1500, B.population || B.municipalityPopulation || 0);
|
|
const demand = Math.sqrt(popA * popB);
|
|
const score = d / Math.max(0.35, demand / 65000 + (A.isPrefecturalCapital || B.isPrefecturalCapital ? 0.35 : 0));
|
|
pairs.push({ A, B, d, score });
|
|
}
|
|
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
|
|
for (const pair of pairs) {
|
|
if (result.added >= 26 || result.secondaryAdded >= 12 || nationalLength() >= requiredNational) break;
|
|
const network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const candidate = routeNational(pair.A, pair.B);
|
|
if (!candidate.length) continue;
|
|
if (hasExcessiveOverlap(candidate, network)) { result.parallelRejected++; continue; }
|
|
let path = trimRouteAtExistingNetwork(candidate, network, 2.7, 4);
|
|
const contribution = measuredLength(path);
|
|
const remaining = Math.max(0, requiredNational - nationalLength());
|
|
if (path.length < 6 || contribution < 4) continue;
|
|
if (contribution > Math.max(16, remaining * 1.40)) continue;
|
|
features.nationalRoads.push(path);
|
|
result.added++; result.secondaryAdded++;
|
|
}
|
|
}
|
|
result.afterNationalLength = Math.round(nationalLength());
|
|
result.achievedRailShare = result.afterNationalLength > 0 ? railLength / result.afterNationalLength : 0;
|
|
return result;
|
|
}
|
|
|
|
// Synthetic diameter/grid streets were visually dominant and ignored the
|
|
// morphology of the existing local-road generator. Urban street density is
|
|
// now increased earlier by the normal terrain-aware local-access algorithm.
|
|
debug.urbanStreetMeshAdded = 0;
|
|
debug.syntheticUrbanStreetMeshDisabled = true;
|
|
|
|
const nationalCityDebug = ensureMajorCityNationalRoadLinks(50000);
|
|
debug.nationalMajorCityLinksAdded = nationalCityDebug.added;
|
|
debug.nationalMajorCityLinksCovered = nationalCityDebug.covered;
|
|
debug.nationalMajorCityLinksNoTarget = nationalCityDebug.noTarget;
|
|
debug.nationalMajorCityLinksNoPath = nationalCityDebug.noPath;
|
|
|
|
const railCityGuaranteeDebug = ensureMajorCityRailLinks(50000);
|
|
debug.railMajorCityLinksAdded = railCityGuaranteeDebug.added;
|
|
debug.railMajorCityLinksCovered = railCityGuaranteeDebug.covered;
|
|
debug.railMajorCityLinksNoTarget = railCityGuaranteeDebug.noTarget;
|
|
debug.railMajorCityLinksNoPath = railCityGuaranteeDebug.noPath;
|
|
|
|
const railDensityDebug = densifyRailToNationalRatio(0.84);
|
|
debug.railDensityTarget = railDensityDebug;
|
|
|
|
const nationalTerminusDebug = connectNearbyPathTermini([
|
|
{ key: 'national', paths: nationalRoads },
|
|
{ key: 'external', paths: externalRoads },
|
|
], 'national', { maxDistance: 20, maxAdded: 16, maxTunnelRun: 8, straightTerrainPenaltyMax: 0.92 });
|
|
debug.nationalTerminusConnectionsAdded = nationalTerminusDebug.added;
|
|
|
|
const minorTerminusDebug = connectNearbyPathTermini([
|
|
{ key: 'minor', paths: minorRoads },
|
|
], 'local', { maxDistance: 14, maxAdded: 30, maxTunnelRun: 6, straightTerrainPenaltyMax: 1.04 });
|
|
debug.minorTerminusConnectionsAdded = minorTerminusDebug.added;
|
|
|
|
function pruneIsolatedRuralLocalRoads() {
|
|
const before = minorRoads.length;
|
|
const settlements = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || []), ...(adminCenters || [])];
|
|
const trunks = [...nationalRoads, ...externalRoads];
|
|
const kept = [];
|
|
function endpointConnected(point, selfIndex) {
|
|
if (nearestPointOnPaths(trunks, { x: point[0], y: point[1] }, 2.4)) return true;
|
|
for (let j = 0; j < minorRoads.length; j++) {
|
|
if (j === selfIndex) continue;
|
|
if (pathTouchesCell(minorRoads[j], point[0], point[1], 2.2)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function servesSettlement(path) {
|
|
for (const p of settlements) {
|
|
if (!p || !inside(p.x, p.y)) continue;
|
|
if (pathTouchesCell(path, p.x, p.y, 2.0)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
for (let idx = 0; idx < minorRoads.length; idx++) {
|
|
const path = minorRoads[idx];
|
|
const len = pathLengthCells(path || []);
|
|
if (!path?.length || len >= 18 || servesSettlement(path)) { kept.push(path); continue; }
|
|
let density = 0, n = 0;
|
|
for (let k = 0; k < path.length; k += 2) {
|
|
const [x, y] = path[k];
|
|
if (!inside(x, y)) continue;
|
|
density += features.populationDensity?.[indexOf(x, y)] || 0;
|
|
n++;
|
|
}
|
|
const avgDensity = n ? density / n : 0;
|
|
if (avgDensity >= 0.11) { kept.push(path); continue; }
|
|
const a = path[0], b = path[path.length - 1];
|
|
const connectedA = endpointConnected(a, idx), connectedB = endpointConnected(b, idx);
|
|
if (connectedA && connectedB) kept.push(path);
|
|
else if (len >= 12 && (connectedA || connectedB)) kept.push(path);
|
|
// else: short rural fragment with no destination -> remove.
|
|
}
|
|
minorRoads.length = 0;
|
|
minorRoads.push(...kept);
|
|
return { before, after: minorRoads.length, pruned: before - minorRoads.length };
|
|
}
|
|
debug.ruralLocalDanglingPrune = pruneIsolatedRuralLocalRoads();
|
|
|
|
// r11.7: countryside access is a first-class production requirement. The
|
|
// earlier generator can still leave a sparse rural map after pruning, so
|
|
// re-run the *terrain router* for municipal offices and small settlements
|
|
// that have no usable ordinary road. No synthetic crossbars or straight
|
|
// segments are emitted.
|
|
const ruralCoverageCandidates = [
|
|
...(adminCenters || []).map((p) => ({ ...p, _priority: 3 })),
|
|
...(features.villages || []).filter((p) => (p.population || 0) <= 14000).map((p) => ({ ...p, _priority: 2 })),
|
|
...(features.markets || []).filter((p) => (p.population || 0) <= 18000).map((p) => ({ ...p, _priority: 1 })),
|
|
].filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)])
|
|
.sort((a, b) => b._priority - a._priority || (b.population || b.municipalityPopulation || 0) - (a.population || a.municipalityPopulation || 0));
|
|
|
|
function ensureRuralMunicipalRoadCoverage(visibleOnly = false) {
|
|
const result = { checked: 0, alreadyServed: 0, added: 0, noTarget: 0, noPath: 0, visibleOnly };
|
|
const focus = visibleOnly && initialVisibleCrop ? {
|
|
x0: Math.max(0, Math.floor(initialVisibleCrop.x0)), y0: Math.max(0, Math.floor(initialVisibleCrop.y0)),
|
|
x1: Math.min(MAP_W, Math.ceil(initialVisibleCrop.x1)), y1: Math.min(MAP_H, Math.ceil(initialVisibleCrop.y1)),
|
|
} : null;
|
|
const inFocus = (x, y, margin = 0) => !focus || (x >= focus.x0 + margin && y >= focus.y0 + margin && x < focus.x1 - margin && y < focus.y1 - margin);
|
|
const visibleRoadTouch = (paths, point, radius = 2.4) => {
|
|
for (const path of paths || []) {
|
|
let near = false, inward = 0;
|
|
for (const q of path || []) {
|
|
if (!q || !inFocus(q[0], q[1], 0)) continue;
|
|
inward++;
|
|
if (Math.hypot(q[0] - point.x, q[1] - point.y) <= radius) near = true;
|
|
}
|
|
if (near && (!focus || inward >= 3)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const nearestFocusedRoadPoint = (paths, point, maxDistance = 64) => {
|
|
let best = null;
|
|
for (const path of paths || []) for (const q of path || []) {
|
|
if (!q || !inFocus(q[0], q[1], 2)) continue;
|
|
const d = Math.hypot(q[0] - point.x, q[1] - point.y);
|
|
if (d <= maxDistance && (!best || d < best.d)) best = { x: q[0], y: q[1], d };
|
|
}
|
|
return best;
|
|
};
|
|
const areaScale = Math.max(1, SIZE / (258 * 183));
|
|
// The candidate population/order is invariant across the full-map and
|
|
// visible-core audit stages. Reuse it and only apply the focus predicate.
|
|
const candidates = visibleOnly ? ruralCoverageCandidates.filter((p) => inFocus(p.x, p.y, 0)) : ruralCoverageCandidates;
|
|
const seen = new Set();
|
|
const maxChecks = Math.round(260 * Math.min(2.4, areaScale));
|
|
for (const point of candidates) {
|
|
if (result.checked >= maxChecks) break;
|
|
const key = `${Math.round(point.x)},${Math.round(point.y)}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key); result.checked++;
|
|
const roads = [...minorRoads, ...nationalRoads, ...externalRoads];
|
|
if ((visibleOnly ? visibleRoadTouch(roads, point, 2.4) : anyPathTouches(roads, point, 2.4))) { result.alreadyServed++; continue; }
|
|
let target = visibleOnly ? nearestFocusedRoadPoint(roads, point, 72) : nearestPointOnPaths(roads, point, 64);
|
|
if (!target) {
|
|
target = civicTransportTargets.filter((q) => q !== point && sameLandComponent(point, q) && (!visibleOnly || inFocus(q.x, q.y, 3)))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - point.x, q.y - point.y) }))
|
|
.filter((q) => q.d >= 4 && q.d <= 54)
|
|
.sort((a, b) => a.d - b.d)[0] || null;
|
|
}
|
|
if (!target) { result.noTarget++; continue; }
|
|
const d = Math.hypot(target.x - point.x, target.y - point.y);
|
|
let path = routeTerrainPath(point, target, terrain, { maxLength: d * 3.05 + 36, maxSeaRun: 0, maxTunnelRun: 8, snapRadius: 1.4, maxExpanded: Math.min(SIZE, Math.max(12000, Math.floor(d * d * 10 + 6000))), maxElevation: 0.82 });
|
|
if (!path.length) path = terrainFirstConnector(point, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.65 + 54, maxSeaRun: 0, maxTunnelRun: 9, maxExpanded: SIZE, maxElevation: 0.82 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(point, target, terrain, { maxLength: Math.min(SIZE, d * 4.2 + 72), maxElevation: 0.82 });
|
|
if (path.length < 4 || pathLengthCells(path) > d * 4.35 + 78) { result.noPath++; continue; }
|
|
minorRoads.push(terrainSafeSmooth(path, terrain, "local", 1));
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
debug.ruralMunicipalRoadCoverage = { deferredToFinalFullMapStage: true };
|
|
|
|
function runFinalRuralMunicipalCoverageStages() {
|
|
// One full hidden-raster pass is sufficient. A second visible-core pass was
|
|
// a crop-specific quality guarantee and repeated the same expensive routing
|
|
// work; the published crop now inherits the full-map result directly.
|
|
const full = ensureRuralMunicipalRoadCoverage(false);
|
|
return { full, visible: null };
|
|
}
|
|
|
|
// Build a sparse organic countryside network with the same terrain-aware local
|
|
// road solver used elsewhere. This is deliberately *not* a rectilinear mesh:
|
|
// each added road connects a real municipal/rural node to another nearby node
|
|
// or to an existing road, and every cell is produced by the terrain router.
|
|
const ruralDensifyCandidates = [
|
|
...(adminCenters || []).filter((p) => (p.municipalityPopulation || p.population || 0) < 30000),
|
|
...(features.villages || []).filter((p) => (p.population || 0) < 16000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) < 12000),
|
|
].filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)])
|
|
.sort((a, b) => (b.municipalityPopulation || b.population || 0) - (a.municipalityPopulation || a.population || 0));
|
|
|
|
function densifyRuralLocalRoadNetwork(visibleOnly = false) {
|
|
const focus = visibleOnly && initialVisibleCrop ? {
|
|
x0: Math.max(0, Math.floor(initialVisibleCrop.x0)), y0: Math.max(0, Math.floor(initialVisibleCrop.y0)),
|
|
x1: Math.min(MAP_W, Math.ceil(initialVisibleCrop.x1)), y1: Math.min(MAP_H, Math.ceil(initialVisibleCrop.y1)),
|
|
} : null;
|
|
const inFocus = (p, margin = 0) => !focus || (p.x >= focus.x0 + margin && p.y >= focus.y0 + margin && p.x < focus.x1 - margin && p.y < focus.y1 - margin);
|
|
const raw = visibleOnly ? ruralDensifyCandidates.filter((p) => inFocus(p, 1)) : ruralDensifyCandidates;
|
|
const nodes = [];
|
|
for (const p of raw) {
|
|
if (nodes.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= 3.2)) nodes.push(p);
|
|
if (nodes.length >= 260) break;
|
|
}
|
|
const areaScale = Math.max(1, SIZE / (258 * 183));
|
|
const targetAdded = Math.min(Math.round(185 * Math.min(2.2, areaScale)), Math.max(34, Math.round(nodes.length * 1.10)));
|
|
const result = { visibleOnly, nodes: nodes.length, targetAdded, added: 0, noPath: 0, skippedDense: 0 };
|
|
const localNetwork = () => [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const localDegree = (node) => {
|
|
let count = 0;
|
|
for (const path of localNetwork()) {
|
|
let touched = false;
|
|
for (const q of path || []) if (Math.hypot(q[0] - node.x, q[1] - node.y) <= 4.2) { touched = true; break; }
|
|
if (touched && ++count >= 2) break;
|
|
}
|
|
return count;
|
|
};
|
|
for (const node of nodes) {
|
|
if (result.added >= targetAdded) break;
|
|
// A rural seat with two independent nearby approaches is already well served.
|
|
if (localDegree(node) >= 4) { result.skippedDense++; continue; }
|
|
const sameLand = nodes
|
|
.filter((q) => q !== node && sameLandComponent(node, q))
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - node.x, q.y - node.y) }))
|
|
.filter((q) => q.d >= 5 && q.d <= 64 && inFocus(q, 1))
|
|
.sort((a, b) => a.d - b.d);
|
|
const roadTarget = nearestPointOnPaths(localNetwork(), node, 64);
|
|
const targets = [];
|
|
// Prefer a real nearby settlement so the countryside forms a connected
|
|
// dendritic network; use an existing road as a second option.
|
|
for (const q of sameLand.slice(0, 6)) targets.push(q);
|
|
if (roadTarget && roadTarget.d >= 4) targets.push(roadTarget);
|
|
let path = [];
|
|
for (const target of targets) {
|
|
const d = Math.hypot(target.x - node.x, target.y - node.y);
|
|
let candidate = routeTerrainPath(node, target, terrain, {
|
|
maxLength: d * 3.0 + 34, maxSeaRun: 0, maxTunnelRun: 5, snapRadius: 0.9,
|
|
maxExpanded: Math.min(SIZE, Math.max(9000, Math.floor(d * d * 9 + 4500))), maxElevation: 0.82,
|
|
});
|
|
if (!candidate.length) candidate = terrainFirstConnector(node, target, terrain, {
|
|
skipPrimaryRoute: true,
|
|
maxLength: d * 3.4 + 44, maxSeaRun: 0, maxTunnelRun: 5, snapRadius: 0.9, maxExpanded: SIZE, maxElevation: 0.82,
|
|
});
|
|
if (candidate.length < 4) continue;
|
|
// Do not add a nearly duplicate local road just to satisfy a count.
|
|
const influence = rebuildInfluence(localNetwork(), 2.0);
|
|
let near = 0, samples = 0;
|
|
for (let k = 2; k < candidate.length - 2; k += 2) {
|
|
const [x, y] = candidate[k]; if (!inside(x, y)) continue; samples++;
|
|
if ((influence[indexOf(x, y)] || 0) > 0.55) near++;
|
|
}
|
|
if (samples >= 4 && near / samples > 0.72) continue;
|
|
path = candidate; break;
|
|
}
|
|
if (!path.length) { result.noPath++; continue; }
|
|
features.minorRoads ||= [];
|
|
features.minorRoads.push(terrainSafeSmooth(path, terrain, "local", 1));
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function runFinalRuralDensificationStages() {
|
|
// Densify once on the complete hidden raster. Do not rerun the same node
|
|
// population solely for the future crop rectangle.
|
|
const full = densifyRuralLocalRoadNetwork(false);
|
|
return { full, visible: null };
|
|
}
|
|
|
|
const railTerminusDebug = connectNearbyPathTermini([
|
|
{ key: 'rail', paths: features.railways || [] },
|
|
{ key: 'externalRail', paths: features.externalRailways || [] },
|
|
], 'rail', { maxDistance: 20, maxAdded: 10, maxTunnelRun: 12, straightTerrainPenaltyMax: 0.94 });
|
|
debug.railTerminusConnectionsAdded = railTerminusDebug.added;
|
|
|
|
function ensureInitialOverscanGatewayContinuations() {
|
|
const gateways = (features.externalGateways || []).filter((g) => g?.initialOverscan && inside(g.x, g.y) && !terrain?.sea?.[indexOf(g.x, g.y)]);
|
|
const result = { enabled: gateways.length > 0, gateways: gateways.length, nationalAdded: 0, railAdded: 0, expresswayAdded: 0, sides: {} };
|
|
if (!gateways.length) return result;
|
|
const ranked = gateways.slice().sort((a, b) => ((b.score || 0) + Math.min(0.6, (b.virtualPopulation || 0) / 420000)) - ((a.score || 0) + Math.min(0.6, (a.virtualPopulation || 0) / 420000)));
|
|
for (const g of ranked) result.sides[g.edgeSide || 'unknown'] = (result.sides[g.edgeSide || 'unknown'] || 0) + 1;
|
|
|
|
function connectGateway(gateway, paths, mode) {
|
|
if (anyPathTouches(paths, gateway, mode === 'expressway' ? 2.5 : 1.8)) return [];
|
|
const component = componentAt(gateway);
|
|
const targets = nearestPointsOnPaths(paths, gateway, Infinity, 30, 3).filter((q) => componentAt(q) === component).slice(0, 12);
|
|
const civicFallback = [...(features.modernCities || []), ...(features.markets || [])]
|
|
.filter((q) => q && sameLandComponent(gateway, q))
|
|
.sort((a, b) => Math.hypot(a.x - gateway.x, a.y - gateway.y) - Math.hypot(b.x - gateway.x, b.y - gateway.y))
|
|
.slice(0, 5);
|
|
const allTargets = targets.length ? targets : civicFallback;
|
|
for (const target of allTargets) {
|
|
const d = Math.hypot(target.x - gateway.x, target.y - gateway.y);
|
|
if (d < 2) continue;
|
|
const modeOpts = mode === 'expressway'
|
|
? { maxSeaRun: 0, maxTunnelRun: 24, snapRadius: 2.4, factor: 3.2, slack: 82 }
|
|
: mode === 'rail'
|
|
? { maxSeaRun: 0, maxTunnelRun: 26, snapRadius: 2.0, factor: 3.0, slack: 70 }
|
|
: { maxSeaRun: 0, maxTunnelRun: 18, snapRadius: 1.8, factor: 2.8, slack: 58 };
|
|
let path = routeTerrainPath(gateway, target, terrain, { maxLength: d * modeOpts.factor + modeOpts.slack, maxSeaRun: modeOpts.maxSeaRun, maxTunnelRun: modeOpts.maxTunnelRun, snapRadius: modeOpts.snapRadius, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(gateway, target, terrain, { skipPrimaryRoute: true, maxLength: d * (modeOpts.factor + 0.2) + modeOpts.slack, maxSeaRun: 0, maxTunnelRun: modeOpts.maxTunnelRun, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(gateway, target, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length >= 4 && (mode === 'national' || mode === 'rail' || mode === 'expressway' ? trunkElevationSafe(path, terrain, 0.72) : true)) {
|
|
path = trimRouteAtExistingNetwork(path, paths, mode === 'expressway' ? 3.0 : 2.5, 3);
|
|
return terrainSafeSmooth(path, terrain, mode, mode === 'expressway' ? 4 : 2);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// National roads have the broadest outside demand, rail slightly less, and
|
|
// motorways the fewest exits. Select gateways by side first so the visible
|
|
// crop behaves like the middle of a larger network rather than a closed box.
|
|
const sideBest = [];
|
|
const usedSides = new Set();
|
|
for (const g of ranked) {
|
|
if (!usedSides.has(g.edgeSide)) { sideBest.push(g); usedSides.add(g.edgeSide); }
|
|
}
|
|
for (const g of [...sideBest, ...ranked].slice(0, Math.min(6, gateways.length))) {
|
|
const network = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const path = connectGateway(g, network, 'national');
|
|
if (path.length) { features.externalRoads ||= []; features.externalRoads.push(path); result.nationalAdded++; }
|
|
}
|
|
const railGateways = [...sideBest, ...ranked.filter((g) => !sideBest.includes(g))].slice(0, Math.min(4, gateways.length));
|
|
for (const g of railGateways) {
|
|
const network = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
if (!network.length) break;
|
|
const path = connectGateway(g, network, 'rail');
|
|
if (path.length) { features.externalRailways ||= []; features.externalRailways.push(path); result.railAdded++; }
|
|
}
|
|
const expressGateways = sideBest.slice(0, Math.min(3, sideBest.length));
|
|
for (const g of expressGateways) {
|
|
const network = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
if (!network.length) break;
|
|
const path = connectGateway(g, network, 'expressway');
|
|
if (path.length) { features.externalExpressways ||= []; features.externalExpressways.push(path); result.expresswayAdded++; }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
debug.initialOverscanGatewayContinuations = ensureInitialOverscanGatewayContinuations();
|
|
|
|
// Literal hidden-raster generation needs a second kind of continuation:
|
|
// routes must cross the *future published crop boundary*, not merely the true
|
|
// hidden-world edge. Otherwise the overscan can be fully generated yet still
|
|
// have no visible evidence that transport planning considered off-screen OD
|
|
// demand. These continuations are normal terrain-routed trunk paths whose
|
|
// outside portions are discarded by the final crop.
|
|
function ensureInitialVisibleCropContinuations() {
|
|
if (!initialVisibleCrop || ![initialVisibleCrop.x0, initialVisibleCrop.y0, initialVisibleCrop.x1, initialVisibleCrop.y1].every(Number.isFinite)) {
|
|
return { enabled: false, reason: "no-visible-crop" };
|
|
}
|
|
const rect = {
|
|
x0: Math.max(0, Math.floor(initialVisibleCrop.x0)),
|
|
y0: Math.max(0, Math.floor(initialVisibleCrop.y0)),
|
|
x1: Math.min(MAP_W, Math.ceil(initialVisibleCrop.x1)),
|
|
y1: Math.min(MAP_H, Math.ceil(initialVisibleCrop.y1)),
|
|
};
|
|
const inFocus = (x, y, margin = 0) => x >= rect.x0 - margin && y >= rect.y0 - margin && x < rect.x1 + margin && y < rect.y1 + margin;
|
|
const crossesFocus = (path) => {
|
|
let inCount = 0, outCount = 0;
|
|
for (const [x, y] of path || []) {
|
|
if (inFocus(x, y)) inCount++; else outCount++;
|
|
if (inCount && outCount) return true;
|
|
}
|
|
return false;
|
|
};
|
|
function nearestInsidePathPoint(paths, point, component) {
|
|
let best = null;
|
|
for (const path of paths || []) for (const [x, y] of path || []) {
|
|
if (!inFocus(x, y) || componentAt({ x, y }) !== component) continue;
|
|
const d = Math.hypot(x - point.x, y - point.y);
|
|
if (!best || d < best.d) best = { x, y, d };
|
|
}
|
|
return best;
|
|
}
|
|
const outsideLandByComponent = new Map();
|
|
for (let y = 0; y < MAP_H; y += 2) for (let x = 0; x < MAP_W; x += 2) {
|
|
if (inFocus(x, y)) continue;
|
|
const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - (rect.x1 - 1) : 0;
|
|
const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - (rect.y1 - 1) : 0;
|
|
if (Math.hypot(dx, dy) > 56) continue;
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i]) continue;
|
|
const component = landComponentId[i];
|
|
if (component < 0) continue;
|
|
let list = outsideLandByComponent.get(component);
|
|
if (!list) outsideLandByComponent.set(component, list = []);
|
|
if (list.length < 900) list.push({ x, y, borderDistance: Math.hypot(dx, dy) });
|
|
}
|
|
function syntheticOutsideGateway(paths) {
|
|
let best = null;
|
|
// Search any nearby halo land on the same component, not merely a
|
|
// straight normal projection from the crop edge. Real coastlines often
|
|
// turn sharply at the viewport edge, so the older straight-out scan could
|
|
// falsely conclude that rail/motorway had no off-screen continuation.
|
|
for (const path of paths || []) for (let k = 0; k < (path?.length || 0); k += 2) {
|
|
const [x, y] = path[k];
|
|
if (!inFocus(x, y)) continue;
|
|
const component = componentAt({ x, y });
|
|
if (component < 0) continue;
|
|
const candidates = outsideLandByComponent.get(component) || [];
|
|
for (const q of candidates) {
|
|
const d = Math.hypot(q.x - x, q.y - y);
|
|
if (d < 10 || d > 78) continue;
|
|
const qi = indexOf(q.x, q.y);
|
|
const score = d + q.borderDistance * 0.20 + (terrain?.slope?.[qi] || 0) * 6 + (terrain?.ridgeField?.[qi] || 0) * 5;
|
|
if (!best || score < best.score) best = { x: q.x, y: q.y, component, score, side: "halo-land" };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
function outsideCivicCandidates() {
|
|
return civicTransportTargets.filter((p) => !inFocus(p.x, p.y) && componentAt(p) >= 0)
|
|
.map((p) => {
|
|
const dx = p.x < rect.x0 ? rect.x0 - p.x : p.x >= rect.x1 ? p.x - (rect.x1 - 1) : 0;
|
|
const dy = p.y < rect.y0 ? rect.y0 - p.y : p.y >= rect.y1 ? p.y - (rect.y1 - 1) : 0;
|
|
const borderDistance = Math.hypot(dx, dy);
|
|
const pop = Number(p.population || p.municipalityPopulation || 0);
|
|
return { ...p, borderDistance, demandScore: borderDistance - Math.log1p(Math.max(0, pop)) * 1.8 };
|
|
}).filter((p) => p.borderDistance <= 64)
|
|
.sort((a, b) => a.demandScore - b.demandScore);
|
|
}
|
|
const outsideCivic = outsideCivicCandidates();
|
|
function standaloneVisibleToHaloPair(mode) {
|
|
const minPop = mode === "expressway" ? 10000 : mode === "rail" ? 8000 : 5000;
|
|
const insideNodes = civicTransportTargets.filter((p) => inFocus(p.x, p.y) && componentAt(p) >= 0
|
|
&& (Number(p.population || p.municipalityPopulation || 0) >= minPop || p.isPrefecturalCapital || p.isRegionalCapital))
|
|
.sort((a, b) => ((b.population || b.municipalityPopulation || 0) + (b.isPrefecturalCapital ? 500000 : 0)) - ((a.population || a.municipalityPopulation || 0) + (a.isPrefecturalCapital ? 500000 : 0)));
|
|
let best = null;
|
|
for (const node of insideNodes) {
|
|
const component = componentAt(node);
|
|
const outside = outsideLandByComponent.get(component) || [];
|
|
for (const q of outside) {
|
|
const d = Math.hypot(q.x - node.x, q.y - node.y);
|
|
if (d < 12 || d > 150) continue;
|
|
let anchor = node;
|
|
if (mode === "expressway") {
|
|
const suburban = suburbanExpresswayAnchorForCity(node, q);
|
|
if (suburban) anchor = suburban;
|
|
}
|
|
const score = d - Math.log1p(Math.max(0, Number(node.population || node.municipalityPopulation || 0))) * 2.0 + q.borderDistance * 0.2;
|
|
if (!best || score < best.score) best = { gateway: { x: q.x, y: q.y, component }, anchor: { x: anchor.x, y: anchor.y }, score };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
function addCrossings(paths, mode, desired) {
|
|
const result = { desired, beforeCrossings: (paths || []).filter(crossesFocus).length, added: 0, noGateway: 0, noPath: 0 };
|
|
if (result.beforeCrossings >= desired || !(paths || []).length) return result;
|
|
const used = new Set();
|
|
while (result.beforeCrossings + result.added < desired) {
|
|
let gateway = null, anchor = null;
|
|
for (const candidate of outsideCivic) {
|
|
const key = `${Math.round(candidate.x)},${Math.round(candidate.y)}`;
|
|
if (used.has(key)) continue;
|
|
const component = componentAt(candidate);
|
|
const insidePoint = nearestInsidePathPoint(paths, candidate, component);
|
|
if (!insidePoint) continue;
|
|
gateway = candidate; anchor = insidePoint; used.add(key); break;
|
|
}
|
|
if (!gateway) {
|
|
gateway = syntheticOutsideGateway(paths);
|
|
if (gateway) anchor = nearestInsidePathPoint(paths, gateway, gateway.component);
|
|
}
|
|
if (!gateway || !anchor) {
|
|
const standalone = standaloneVisibleToHaloPair(mode);
|
|
if (standalone) { gateway = standalone.gateway; anchor = standalone.anchor; }
|
|
}
|
|
if (!gateway || !anchor) { result.noGateway++; break; }
|
|
const d = Math.hypot(gateway.x - anchor.x, gateway.y - anchor.y);
|
|
const maxTunnelRun = mode === "expressway" ? 28 : mode === "rail" ? 32 : 22;
|
|
let path = routeTerrainPath(gateway, anchor, terrain, { maxLength: d * 3.5 + 96, maxSeaRun: 0, maxTunnelRun, snapRadius: mode === "expressway" ? 2.6 : 2.1, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(gateway, anchor, terrain, { skipPrimaryRoute: true, maxLength: d * 3.7 + 108, maxSeaRun: 0, maxTunnelRun, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(gateway, anchor, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length < 4 || !crossesFocus(path) || !trunkElevationSafe(path, terrain, 0.72)) { result.noPath++; break; }
|
|
path = terrainSafeSmooth(path, terrain, mode, mode === "expressway" ? 3 : 2);
|
|
if (!crossesFocus(path)) { result.noPath++; break; }
|
|
paths.push(path);
|
|
result.added++;
|
|
}
|
|
result.afterCrossings = (paths || []).filter(crossesFocus).length;
|
|
return result;
|
|
}
|
|
const national = addCrossings(features.nationalRoads || (features.nationalRoads = []), "national", 2);
|
|
const railPaths = [...(features.railways || []), ...(features.branchRailways || [])];
|
|
const rail = addCrossings(railPaths, "rail", 1);
|
|
// Preserve main/branch ownership for existing paths and append any new crop
|
|
// continuation to the branch layer; it is a regional continuation, not a
|
|
// reason to reclassify the whole mainline.
|
|
const existingRailObjects = new Set([...(features.railways || []), ...(features.branchRailways || [])]);
|
|
for (const path of railPaths) if (!existingRailObjects.has(path)) { features.branchRailways ||= []; features.branchRailways.push(path); }
|
|
const expressway = addCrossings(features.expressways || (features.expressways = []), "expressway", 1);
|
|
return { enabled: true, rect, national, rail, expressway, contract: "future published crop is treated as an interior window of a larger terrain-routed network" };
|
|
}
|
|
|
|
const finalMajorCities = (features.modernCities || [])
|
|
.filter((city) => city && inside(city.x, city.y) && !terrain?.sea?.[indexOf(city.x, city.y)] && ((city.population || 0) >= 50000 || city.isPrefecturalCapital || city.isRegionalCapital))
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
|
|
// A city whose centre survives the literal overscan crop is publishable and
|
|
// therefore must retain visible service inside that crop. Whole-hidden-map
|
|
// service is insufficient: an edge city can otherwise be "served" only by a
|
|
// line that immediately leaves the future viewport and is then discarded by
|
|
// cropping. Build a terrain-routed inward continuation for each missing
|
|
// hierarchy. The simple/draft pipeline is never used here.
|
|
function ensureVisibleCropMajorCityInternalService() {
|
|
if (!initialVisibleCrop) return { enabled: false, reason: "no-visible-crop" };
|
|
const rect = {
|
|
x0: Math.max(0, Math.floor(initialVisibleCrop.x0)), y0: Math.max(0, Math.floor(initialVisibleCrop.y0)),
|
|
x1: Math.min(MAP_W, Math.ceil(initialVisibleCrop.x1)), y1: Math.min(MAP_H, Math.ceil(initialVisibleCrop.y1)),
|
|
};
|
|
const inFocus = (x, y, margin = 0) => x >= rect.x0 + margin && y >= rect.y0 + margin && x < rect.x1 - margin && y < rect.y1 - margin;
|
|
const cityInFocus = (city) => city && inFocus(city.x, city.y, 0);
|
|
const visiblePointCount = (path, margin = 0) => (path || []).reduce((n, q) => n + (inFocus(q[0], q[1], margin) ? 1 : 0), 0);
|
|
// A line that merely kisses the future crop boundary can disappear when
|
|
// splitCroppedPath drops a one-cell fragment. Require a real inward run, not
|
|
// just hidden-halo service at the city coordinate.
|
|
const ordinaryServiceMatch = (paths, city, radius = 3.0) => {
|
|
for (let pathIndex = 0; pathIndex < (paths?.length || 0); pathIndex++) {
|
|
const path = paths[pathIndex];
|
|
for (let k = 0; k < (path?.length || 0); k++) {
|
|
const q = path[k];
|
|
if (!q || Math.hypot(q[0] - city.x, q[1] - city.y) > radius) continue;
|
|
// Service must continue inward *locally* from the city. A path that
|
|
// touches an edge city, leaves into the hidden halo, then re-enters the
|
|
// crop tens of cells away is not a usable visible rail/road approach.
|
|
for (const dir of [-1, 1]) {
|
|
let run = 0, interior = 0;
|
|
for (let step = 1; step <= 12; step++) {
|
|
const j = k + dir * step;
|
|
if (j < 0 || j >= path.length) break;
|
|
const p = path[j];
|
|
if (!p || !inFocus(p[0], p[1], 0)) break;
|
|
run++;
|
|
if (inFocus(p[0], p[1], 1)) interior++;
|
|
if (run >= 3 && interior >= 1) return { pathIndex, k, dir, local: path.slice(Math.max(0, k - 4), Math.min(path.length, k + 5)) };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const ordinaryServed = (paths, city, radius = 3.0) => !!ordinaryServiceMatch(paths, city, radius);
|
|
const expressServed = (city) => {
|
|
const serviceRadius = Math.max(18, Math.min(34, (city.urbanRadius || 12) * 2.2));
|
|
return [...(features.expressways || []), ...(features.externalExpressways || [])].some((path) => {
|
|
let insideCount = 0, minD = Infinity, maxD = 0;
|
|
for (const [x, y] of path || []) {
|
|
if (!inFocus(x, y)) continue;
|
|
insideCount++;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
minD = Math.min(minD, d); maxD = Math.max(maxD, d);
|
|
}
|
|
return insideCount >= 3 && minD <= serviceRadius && maxD - minD >= 2;
|
|
});
|
|
};
|
|
function internalNetworkTargets(paths, city, component, limit = 16) {
|
|
const rows = [];
|
|
for (const path of paths || []) for (let k = 0; k < (path?.length || 0); k += 2) {
|
|
const [x, y] = path[k];
|
|
if (!inFocus(x, y, 3) || componentAt({ x, y }) !== component) continue;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d < 7 || d > 190) continue;
|
|
rows.push({ x, y, d });
|
|
}
|
|
return rows.sort((a, b) => a.d - b.d).slice(0, limit);
|
|
}
|
|
function internalCivicTargets(city, component, minD = 10, maxD = 180, limit = 18) {
|
|
return civicTransportTargets.filter((q) => q && q !== city && inFocus(q.x, q.y, 4) && componentAt(q) === component)
|
|
.map((q) => ({ ...q, d: Math.hypot(q.x - city.x, q.y - city.y) }))
|
|
.filter((q) => q.d >= minD && q.d <= maxD)
|
|
.sort((a, b) => {
|
|
const ap = Number(a.population || a.municipalityPopulation || 0) + (a.isPrefecturalCapital ? 200000 : 0);
|
|
const bp = Number(b.population || b.municipalityPopulation || 0) + (b.isPrefecturalCapital ? 200000 : 0);
|
|
return a.d - b.d || bp - ap;
|
|
}).slice(0, limit);
|
|
}
|
|
function routeInward(city, targets, mode) {
|
|
for (const target of targets) {
|
|
const d = Math.hypot(target.x - city.x, target.y - city.y);
|
|
const maxTunnelRun = mode === "rail" ? 30 : 22;
|
|
let path = routeTerrainPath(city, target, terrain, { maxLength: d * 3.2 + 86, maxSeaRun: 0, maxTunnelRun, snapRadius: 2.0, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(city, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.3 + 90, maxSeaRun: 0, maxTunnelRun, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = routeLandConnectedTerrainFallback(city, target, terrain, { maxLength: SIZE, maxElevation: 0.695 });
|
|
if (path.length < 4 || visiblePointCount(path) < 3 || !trunkElevationSafe(path, terrain, 0.72)) continue;
|
|
path = terrainSafeSmooth(path, terrain, mode, 2);
|
|
if (!hardTerrainPathValid(path, mode)) continue;
|
|
return path;
|
|
}
|
|
return [];
|
|
}
|
|
function visibleSuburbanAnchor(city, hint = null) {
|
|
const component = componentAt(city);
|
|
const inner = Math.max(7, Math.round((city.coreRadius || 4) + 5));
|
|
const outer = Math.max(inner + 6, 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 (!inFocus(x, y, 2) || !inside(x, y)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < inner || d > outer) continue;
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i] || landComponentId[i] !== component) continue;
|
|
const centreBias = Math.hypot(x - (rect.x0 + rect.x1) * 0.5, y - (rect.y0 + rect.y1) * 0.5) * 0.012;
|
|
const hintBias = hint ? Math.hypot(x - hint.x, y - hint.y) * 0.025 : 0;
|
|
const score = centreBias + hintBias + (terrain?.slope?.[i] || 0) * 1.1 + (terrain?.ridgeField?.[i] || 0) * 0.8;
|
|
if (!best || score < best.score) best = { x, y, score };
|
|
}
|
|
return best;
|
|
}
|
|
function interiorLandTarget(city, component, minD = 18, maxD = 120) {
|
|
let best = null;
|
|
const cx = (rect.x0 + rect.x1) * 0.5, cy = (rect.y0 + rect.y1) * 0.5;
|
|
for (let y = rect.y0 + 4; y < rect.y1 - 4; y += 3) for (let x = rect.x0 + 4; x < rect.x1 - 4; x += 3) {
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i] || landComponentId[i] !== component) continue;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d < minD || d > maxD) continue;
|
|
const score = d * 0.16 + Math.hypot(x - cx, y - cy) * 0.025 + (terrain?.slope?.[i] || 0) * 5 + (terrain?.ridgeField?.[i] || 0) * 4;
|
|
if (!best || score < best.score) best = { x, y, score };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
const result = { enabled: true, checked: 0, nationalAdded: 0, railAdded: 0, expresswayAdded: 0, noPath: 0, unresolved: [] };
|
|
for (const city of finalMajorCities.filter(cityInFocus)) {
|
|
result.checked++;
|
|
const component = componentAt(city);
|
|
const nationalNetwork = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
if (!ordinaryServed(nationalNetwork, city, 3.0)) {
|
|
const targets = [...internalNetworkTargets(nationalNetwork, city, component), ...internalCivicTargets(city, component)];
|
|
const path = routeInward(city, targets, "national");
|
|
if (path.length) { features.nationalRoads ||= []; features.nationalRoads.push(path); result.nationalAdded++; }
|
|
else result.noPath++;
|
|
}
|
|
const railNetwork = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
const railServiceMatch = ordinaryServiceMatch(railNetwork, city, 3.0);
|
|
if (!railServiceMatch) {
|
|
const targets = [...internalNetworkTargets(railNetwork, city, component), ...internalCivicTargets(city, component)];
|
|
const path = routeInward(city, targets, "rail");
|
|
if (path.length) { features.branchRailways ||= []; features.branchRailways.push(path); result.railAdded++; }
|
|
else result.noPath++;
|
|
}
|
|
if (!expressServed(city)) {
|
|
const expressNetwork = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
let targets = internalNetworkTargets(expressNetwork, city, component, 12);
|
|
if (!targets.length) targets = internalCivicTargets(city, component, 16, 170, 12);
|
|
if (!targets.length) { const q = interiorLandTarget(city, component); if (q) targets = [q]; }
|
|
let path = [];
|
|
let pathIsExternal = false;
|
|
const validateExpressCandidate = (candidate) => {
|
|
if (candidate.length < 4 || visiblePointCount(candidate) < 3) return [];
|
|
const terrainRuns = pathTerrainRuns(candidate, terrain);
|
|
const terrainBurden = pathTerrainBurden(candidate, terrain);
|
|
if (terrainRuns.maxSeaRun > 0 || terrainRuns.maxTunnelRun > 28 || terrainBurden.highBarrierShare > 0.27) return [];
|
|
candidate = terrainSafeSmooth(candidate, terrain, "expressway", 3);
|
|
const turns = pathSharpTurnStats(candidate);
|
|
if (turns.consecutiveExtreme > 1 || turns.sharpShare > 0.46 || !hardTerrainPathValid(candidate, "expressway")) return [];
|
|
return candidate;
|
|
};
|
|
for (const target of targets) {
|
|
const anchor = visibleSuburbanAnchor(city, target);
|
|
if (!anchor) continue;
|
|
let endpoint = target;
|
|
if (!inFocus(endpoint.x, endpoint.y, 2)) { const q = interiorLandTarget(city, component); if (!q) continue; endpoint = q; }
|
|
const d = Math.hypot(anchor.x - endpoint.x, anchor.y - endpoint.y);
|
|
if (d < 7) continue;
|
|
let candidate = routeTerrainPath(anchor, endpoint, terrain, { maxLength: d * 3.35 + 96, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.3, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = terrainFirstConnector(anchor, endpoint, terrain, { skipPrimaryRoute: true, maxLength: d * 3.5 + 104, maxSeaRun: 0, maxTunnelRun: 28, shortFallback: 7, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(anchor, endpoint, terrain, { maxLength: SIZE });
|
|
candidate = validateExpressCandidate(candidate);
|
|
if (!candidate.length) continue;
|
|
path = candidate; break;
|
|
}
|
|
// If direct OD routing cannot find a legal corridor, follow the city's
|
|
// already terrain-valid national-road valley as *waypoints*. This does
|
|
// not copy the national geometry: each motorway leg is independently
|
|
// solved by the strict terrain router, avoiding both straight chords and
|
|
// mountain/sea clipping.
|
|
if (!path.length) {
|
|
const nationalGuides = [...(features.nationalRoads || []), ...(features.externalRoads || [])]
|
|
.map((guide) => ({ guide, hit: nearestPointOnPaths([guide], city, 7) }))
|
|
.filter((row) => row.hit && componentAt(row.hit) === component)
|
|
.sort((a, b) => a.hit.d - b.hit.d);
|
|
for (const row of nationalGuides.slice(0, 5)) {
|
|
const guideHintTuple = row.guide[Math.min(row.guide.length - 1, Math.max(0, Math.floor(row.guide.length * 0.7)))];
|
|
const guideHint = guideHintTuple ? { x: guideHintTuple[0], y: guideHintTuple[1] } : null;
|
|
const anchor = visibleSuburbanAnchor(city, guideHint);
|
|
let guided = sharedSuburbanTerrainAlignment(city, row.guide, "expressway", { desiredLength: 70, focusRect: rect });
|
|
if (!guided.length && anchor) guided = routeAlongTerrainGuide(anchor, row.guide, "expressway", { desiredLength: 76, stepDistance: 8 });
|
|
if (!guided.length && anchor) guided = sharedTerrainAlignmentFromGuide(anchor, row.guide, "expressway", { desiredLength: 66 });
|
|
const candidate = validateExpressCandidate(guided);
|
|
if (candidate.length >= 4 && visiblePointCount(candidate) >= 3) { path = candidate; break; }
|
|
}
|
|
}
|
|
|
|
// A publishable edge city can sit on a peninsula whose land connection
|
|
// to the rest of its component lies entirely in the hidden halo. In that
|
|
// case an inward motorway is geographically impossible, but the already
|
|
// generated off-screen motorway should visibly reach the crop boundary.
|
|
// Connect a visible suburban anchor to that hidden same-land network so
|
|
// cropping leaves a truthful outward motorway stub instead of erasing
|
|
// service altogether.
|
|
if (!path.length) {
|
|
const hiddenTargets = [];
|
|
for (const existingPath of expressNetwork) for (let k = 0; k < (existingPath?.length || 0); k += 2) {
|
|
const [x, y] = existingPath[k];
|
|
if (inFocus(x, y) || componentAt({ x, y }) !== component) continue;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d < 8 || d > 150) continue;
|
|
hiddenTargets.push({ x, y, d });
|
|
}
|
|
hiddenTargets.sort((a, b) => a.d - b.d);
|
|
for (const target of hiddenTargets.slice(0, 16)) {
|
|
const anchor = visibleSuburbanAnchor(city, target);
|
|
if (!anchor) continue;
|
|
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
|
let candidate = routeTerrainPath(anchor, target, terrain, { maxLength: d * 3.5 + 108, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.4, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(anchor, target, terrain, { maxLength: SIZE });
|
|
candidate = validateExpressCandidate(candidate);
|
|
if (!candidate.length || candidate.every(([x, y]) => inFocus(x, y))) continue;
|
|
path = candidate; pathIsExternal = true; break;
|
|
}
|
|
}
|
|
// Last production fallback: create a meaningful terrain-routed suburban
|
|
// motorway corridor toward the interior of the published landmass. This
|
|
// is deliberately long enough to be a real trunk segment (not a cosmetic
|
|
// two-cell stub) and is used only when neither the visible nor hidden
|
|
// existing motorway can be reached legally.
|
|
if (!path.length) {
|
|
const centreHint = { x: (rect.x0 + rect.x1) * 0.5, y: (rect.y0 + rect.y1) * 0.5 };
|
|
const anchor = visibleSuburbanAnchor(city, centreHint);
|
|
if (anchor) {
|
|
const endpoints = [];
|
|
for (let y = rect.y0 + 3; y < rect.y1 - 3; y += 3) for (let x = rect.x0 + 3; x < rect.x1 - 3; x += 3) {
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i] || landComponentId[i] !== component) continue;
|
|
const d = Math.hypot(x - anchor.x, y - anchor.y);
|
|
const cityD = Math.hypot(x - city.x, y - city.y);
|
|
if (d < 14 || d > 72 || cityD < Math.hypot(anchor.x - city.x, anchor.y - city.y) + 7) continue;
|
|
const score = d * 0.08 + Math.hypot(x - centreHint.x, y - centreHint.y) * 0.018
|
|
+ (terrain?.slope?.[i] || 0) * 4.5 + (terrain?.ridgeField?.[i] || 0) * 3.5 + (terrain?.naturalBarrierScore?.[i] || 0) * 4.0;
|
|
endpoints.push({ x, y, d, score });
|
|
}
|
|
endpoints.sort((a, b) => a.score - b.score || b.d - a.d);
|
|
for (const endpoint of endpoints.slice(0, 28)) {
|
|
let candidate = routeTerrainPath(anchor, endpoint, terrain, { maxLength: endpoint.d * 3.2 + 88, maxSeaRun: 0, maxTunnelRun: 30, snapRadius: 2.2, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!candidate.length) candidate = routeLandConnectedTerrainFallback(anchor, endpoint, terrain, { maxLength: SIZE });
|
|
candidate = validateExpressCandidate(candidate);
|
|
if (!candidate.length || pathLengthCells(candidate) < 10) continue;
|
|
path = candidate; break;
|
|
}
|
|
}
|
|
}
|
|
if (path.length) {
|
|
if (pathIsExternal) { features.externalExpressways ||= []; features.externalExpressways.push(path); }
|
|
else { features.expressways ||= []; features.expressways.push(path); }
|
|
result.expresswayAdded++;
|
|
} else result.noPath++;
|
|
}
|
|
}
|
|
// Re-audit with the exact visible contract before returning diagnostics.
|
|
for (const city of finalMajorCities.filter(cityInFocus)) {
|
|
const national = ordinaryServed([...(features.nationalRoads || []), ...(features.externalRoads || [])], city, 3.0);
|
|
const rail = ordinaryServed([...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])], city, 3.0);
|
|
const expressway = expressServed(city);
|
|
const area = componentAt(city) >= 0 ? (landComponentArea[componentAt(city)] || 0) : 0;
|
|
if (!(national && rail && expressway) && !(area < 96 && national && rail && !expressway)) result.unresolved.push({ x: city.x, y: city.y, name: city.name, population: city.population || 0, national, rail, expressway, landComponentArea: area });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function pruneRedundantExpresswayBranches(maxRemoved = 4) {
|
|
const result = { before: (features.expressways || []).length, after: (features.expressways || []).length, removed: 0, branchNodesBefore: 0, branchNodesAfter: 0 };
|
|
if ((features.expressways || []).length < 3) return result;
|
|
|
|
function topologyStats(paths) {
|
|
const neighbors = new Map();
|
|
const ensure = (key) => { let set = neighbors.get(key); if (!set) neighbors.set(key, set = new Set()); return set; };
|
|
for (const path of paths || []) {
|
|
for (let k = 0; k < (path?.length || 0); k++) ensure(`${path[k][0]},${path[k][1]}`);
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = `${path[k - 1][0]},${path[k - 1][1]}`;
|
|
const b = `${path[k][0]},${path[k][1]}`;
|
|
if (a === b) continue;
|
|
ensure(a).add(b); ensure(b).add(a);
|
|
}
|
|
}
|
|
let branchNodes = 0;
|
|
for (const set of neighbors.values()) if (set.size >= 3) branchNodes++;
|
|
let components = 0;
|
|
const seen = new Set();
|
|
for (const key of neighbors.keys()) {
|
|
if (seen.has(key)) continue;
|
|
components++;
|
|
const queue = [key]; seen.add(key);
|
|
for (let qi = 0; qi < queue.length; qi++) {
|
|
for (const n of neighbors.get(queue[qi]) || []) if (!seen.has(n)) { seen.add(n); queue.push(n); }
|
|
}
|
|
}
|
|
return { branchNodes, components, nodes: neighbors.size };
|
|
}
|
|
|
|
const external = features.externalExpressways || [];
|
|
let current = [...(features.expressways || [])];
|
|
let currentStats = topologyStats([...current, ...external]);
|
|
result.branchNodesBefore = currentStats.branchNodes;
|
|
if (currentStats.branchNodes <= 1) { result.branchNodesAfter = currentStats.branchNodes; return result; }
|
|
|
|
let guard = 0;
|
|
while (result.removed < maxRemoved && guard++ < 24 && current.length >= 3) {
|
|
const candidates = current.map((path, index) => {
|
|
let uniqueServiceRisk = 0;
|
|
let served = 0;
|
|
for (const city of finalMajorCities) {
|
|
if (!pathServesMajorCity(path, city, "expressway")) continue;
|
|
served++;
|
|
const elsewhere = [...current.filter((_, j) => j !== index), ...external].some((q) => pathServesMajorCity(q, city, "expressway"));
|
|
if (!elsewhere) uniqueServiceRisk++;
|
|
}
|
|
return { index, path, served, uniqueServiceRisk, len: pathLengthCells(path) };
|
|
}).filter((row) => row.uniqueServiceRisk === 0)
|
|
.sort((a, b) => a.served - b.served || a.len - b.len || a.index - b.index);
|
|
let removedOne = false;
|
|
for (const row of candidates) {
|
|
const remaining = current.filter((_, index) => index !== row.index);
|
|
const combined = [...remaining, ...external];
|
|
if (finalMajorCities.some((city) => !combined.some((path) => pathServesMajorCity(path, city, "expressway")))) continue;
|
|
const stats = topologyStats(combined);
|
|
// Never trade a branch for a disconnected motorway network. Only keep
|
|
// a removal when it materially reduces branching and does not increase
|
|
// the number of network components.
|
|
if (stats.components > currentStats.components || stats.branchNodes >= currentStats.branchNodes) continue;
|
|
current = remaining;
|
|
currentStats = stats;
|
|
result.removed++;
|
|
removedOne = true;
|
|
break;
|
|
}
|
|
if (!removedOne) break;
|
|
}
|
|
features.expressways = current;
|
|
result.after = current.length;
|
|
result.branchNodesAfter = currentStats.branchNodes;
|
|
return result;
|
|
}
|
|
|
|
function capRailDensityToNationalRatio(maxRatio = 1.08, focusRect = null) {
|
|
const normalizedFocus = focusRect && [focusRect.x0, focusRect.y0, focusRect.x1, focusRect.y1].every(Number.isFinite)
|
|
? { x0: Math.floor(focusRect.x0), y0: Math.floor(focusRect.y0), x1: Math.ceil(focusRect.x1), y1: Math.ceil(focusRect.y1) }
|
|
: null;
|
|
const inFocus = (x, y) => !normalizedFocus || (x >= normalizedFocus.x0 && y >= normalizedFocus.y0 && x < normalizedFocus.x1 && y < normalizedFocus.y1);
|
|
const measuredLength = (path) => {
|
|
if (!normalizedFocus) return pathLengthCells(path || []);
|
|
let len = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
const mx = (a[0] + b[0]) * 0.5, my = (a[1] + b[1]) * 0.5;
|
|
if (inFocus(mx, my)) len += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
}
|
|
return len;
|
|
};
|
|
const crossesFocus = (path) => {
|
|
if (!normalizedFocus) return false;
|
|
let insideSeen = false, outsideSeen = false;
|
|
for (const [x, y] of path || []) {
|
|
if (inFocus(x, y)) insideSeen = true; else outsideSeen = true;
|
|
if (insideSeen && outsideSeen) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const nationalLength = (features.nationalRoads || []).reduce((sum, path) => sum + measuredLength(path), 0);
|
|
let main = [...(features.railways || [])];
|
|
let branch = [...(features.branchRailways || [])];
|
|
const railLength = () => [...main, ...branch].reduce((sum, path) => sum + measuredLength(path), 0);
|
|
const target = nationalLength * maxRatio;
|
|
const minimumTarget = nationalLength * (normalizedFocus ? 0.84 : 0);
|
|
const result = { maxRatio, focusRect: normalizedFocus, nationalLength: Math.round(nationalLength), beforeRailLength: Math.round(railLength()), removed: 0, removedMain: 0, removedBranch: 0 };
|
|
if (nationalLength <= 0 || railLength() <= target || main.length + branch.length <= 2) { result.afterRailLength = result.beforeRailLength; result.ratio = nationalLength > 0 ? result.afterRailLength / nationalLength : 0; return result; }
|
|
|
|
let guard = 0;
|
|
while (railLength() > target && guard++ < 64 && main.length + branch.length > 2) {
|
|
const rows = [
|
|
...main.map((path, index) => ({ path, index, group: 'main' })),
|
|
...branch.map((path, index) => ({ path, index, group: 'branch' })),
|
|
];
|
|
const serviceCounts = new Int16Array(finalMajorCities.length);
|
|
for (const row of rows) {
|
|
for (let ci = 0; ci < finalMajorCities.length; ci++) if (pathTouchesCell(row.path, finalMajorCities[ci].x, finalMajorCities[ci].y, 3.0)) serviceCounts[ci]++;
|
|
}
|
|
const crossingCount = normalizedFocus ? rows.filter((row) => crossesFocus(row.path)).length : 0;
|
|
const removable = rows.filter((row) => {
|
|
if (row.group === 'main' && main.length <= 2) return false;
|
|
const contribution = measuredLength(row.path);
|
|
if (normalizedFocus && contribution <= 0.01) return false;
|
|
if (normalizedFocus && crossingCount <= 1 && crossesFocus(row.path)) return false;
|
|
if (normalizedFocus && railLength() - contribution < minimumTarget) return false;
|
|
for (let ci = 0; ci < finalMajorCities.length; ci++) {
|
|
if (pathTouchesCell(row.path, finalMajorCities[ci].x, finalMajorCities[ci].y, 3.0) && serviceCounts[ci] <= 1) return false;
|
|
}
|
|
return true;
|
|
}).map((row) => {
|
|
let served = 0;
|
|
for (const city of finalMajorCities) if (pathTouchesCell(row.path, city.x, city.y, 3.0)) served++;
|
|
const focusLen = measuredLength(row.path);
|
|
const totalLen = pathLengthCells(row.path);
|
|
// In a visible-focus cap, prefer removing routes that consume the most
|
|
// published density while serving no unique city. Whole-map mode keeps
|
|
// the previous long-branch preference.
|
|
const score = normalizedFocus
|
|
? focusLen * 3.0 + (served === 0 ? 160 : 0) + (row.group === 'branch' ? 45 : 0) + totalLen * 0.15
|
|
: totalLen * 2.0 + (served === 0 ? 120 : 0) + (row.group === 'branch' ? 35 : 0);
|
|
return { ...row, totalLen, focusLen, served, score };
|
|
}).sort((a, b) => b.score - a.score || b.focusLen - a.focusLen || b.totalLen - a.totalLen);
|
|
const victim = removable[0];
|
|
if (!victim) break;
|
|
if (victim.group === 'main') { main.splice(victim.index, 1); result.removedMain++; }
|
|
else { branch.splice(victim.index, 1); result.removedBranch++; }
|
|
result.removed++;
|
|
}
|
|
features.railways = main;
|
|
features.branchRailways = branch;
|
|
result.afterRailLength = Math.round(railLength());
|
|
result.ratio = nationalLength > 0 ? result.afterRailLength / nationalLength : 0;
|
|
return result;
|
|
}
|
|
|
|
// Run anti-parallel pruning only after every post-admin road has been added.
|
|
// Crossings are ignored by the direction test; only sustained side-by-side
|
|
// corridors are removed. Paths that uniquely service a major city are kept.
|
|
debug.finalExpresswayParallelPrune = pruneFinalParallelPaths(expressways, "expressway", finalMajorCities, { radius: 4, threshold: 0.20, directionDot: 0.91, maxParallelRunSamples: 2 });
|
|
debug.finalNationalParallelPrune = pruneFinalParallelPaths(nationalRoads, "national", finalMajorCities, { radius: 3, threshold: 0.26, directionDot: 0.90, maxParallelRunSamples: 2 });
|
|
const originalMainRailPaths = new Set(features.railways || []);
|
|
const combinedRailForParallelPrune = [...(features.railways || []), ...(features.branchRailways || [])];
|
|
debug.finalRailParallelPrune = pruneFinalParallelPaths(combinedRailForParallelPrune, "rail", finalMajorCities, { radius: 3, threshold: 0.36, directionDot: 0.90, maxParallelRunSamples: 6 });
|
|
features.railways = combinedRailForParallelPrune.filter((path) => originalMainRailPaths.has(path));
|
|
features.branchRailways = combinedRailForParallelPrune.filter((path) => !originalMainRailPaths.has(path));
|
|
debug.finalRailDensityCap = capRailDensityToNationalRatio(1.02);
|
|
|
|
for (let i = 0; i < expressways.length; i++) expressways[i] = terrainSafeSmooth(expressways[i], terrain, "expressway", 3);
|
|
for (let i = 0; i < nationalRoads.length; i++) nationalRoads[i] = terrainSafeSmooth(nationalRoads[i], terrain, "national", 2);
|
|
for (let i = 0; i < (features.railways || []).length; i++) features.railways[i] = terrainSafeSmooth(features.railways[i], terrain, "rail", 2);
|
|
for (let i = 0; i < (features.branchRailways || []).length; i++) features.branchRailways[i] = terrainSafeSmooth(features.branchRailways[i], terrain, "rail", 2);
|
|
|
|
function pruneShortFinalTrunkSegments() {
|
|
const result = { expresswayRemoved: 0, nationalDowngraded: 0 };
|
|
function uniquelyServes(path, allPaths, mode) {
|
|
return finalMajorCities.some((city) => {
|
|
const thisServes = mode === "expressway" ? pathServesMajorCity(path, city, "expressway") : anyPathTouches([path], city, 3.0);
|
|
if (!thisServes) return false;
|
|
return !allPaths.some((other) => other !== path && (mode === "expressway" ? pathServesMajorCity(other, city, "expressway") : anyPathTouches([other], city, 3.0)));
|
|
});
|
|
}
|
|
function bridges(path, others, radius = 2.8) {
|
|
if (!path?.length || !others?.length) return false;
|
|
const a = { x: path[0][0], y: path[0][1] };
|
|
const b0 = path[path.length - 1];
|
|
const b = { x: b0[0], y: b0[1] };
|
|
return !!nearestPointOnPaths(others, a, radius) && !!nearestPointOnPaths(others, b, radius);
|
|
}
|
|
const allExpress = [...expressways, ...externalExpressways];
|
|
for (let i = expressways.length - 1; i >= 0; i--) {
|
|
const path = expressways[i];
|
|
if (pathLengthCells(path) >= 10) continue;
|
|
const others = allExpress.filter((other) => other !== path);
|
|
if (uniquelyServes(path, allExpress, "expressway") || bridges(path, others, 3.0)) continue;
|
|
expressways.splice(i, 1);
|
|
result.expresswayRemoved++;
|
|
}
|
|
const allNational = [...nationalRoads, ...externalRoads];
|
|
for (let i = nationalRoads.length - 1; i >= 0; i--) {
|
|
const path = nationalRoads[i];
|
|
if (pathLengthCells(path) >= 12) continue;
|
|
const others = allNational.filter((other) => other !== path);
|
|
if (uniquelyServes(path, allNational, "national") || bridges(path, others, 2.6)) continue;
|
|
nationalRoads.splice(i, 1);
|
|
minorRoads.push(path);
|
|
result.nationalDowngraded++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Mandatory service audit after pruning. A 270k-class city must not be left
|
|
// without any one of the three trunk hierarchies just because a redundant
|
|
// route was removed. Narrow-strait allowances remain terrain constrained.
|
|
const finalNationalService = ensureMajorCityNationalRoadLinks(50000);
|
|
const finalRailService = ensureMajorCityRailLinks(50000);
|
|
const finalExpressService = ensureMajorCityExpresswayLinks(50000);
|
|
debug.finalMajorCityServiceAudit = {
|
|
national: finalNationalService,
|
|
rail: finalRailService,
|
|
expressway: finalExpressService,
|
|
};
|
|
debug.finalShortTrunkCleanup = pruneShortFinalTrunkSegments();
|
|
// One final direction-aware pass is safe because unique major-city service is
|
|
// protected. This prevents the audit itself from reintroducing a parallel pair.
|
|
debug.finalExpresswayParallelPruneAfterService = pruneFinalParallelPaths(expressways, "expressway", finalMajorCities, { radius: 4, threshold: 0.22, directionDot: 0.91, maxParallelRunSamples: 2 });
|
|
debug.finalNationalParallelPruneAfterService = pruneFinalParallelPaths(nationalRoads, "national", finalMajorCities, { radius: 3, threshold: 0.28, directionDot: 0.90, maxParallelRunSamples: 2 });
|
|
const mainRailAfterService = new Set(features.railways || []);
|
|
const combinedRailAfterService = [...(features.railways || []), ...(features.branchRailways || [])];
|
|
debug.finalRailParallelPruneAfterService = pruneFinalParallelPaths(combinedRailAfterService, "rail", finalMajorCities, { radius: 3, threshold: 0.38, directionDot: 0.90, maxParallelRunSamples: 6 });
|
|
features.railways = combinedRailAfterService.filter((path) => mainRailAfterService.has(path));
|
|
features.branchRailways = combinedRailAfterService.filter((path) => !mainRailAfterService.has(path));
|
|
|
|
features.minorRoads = dedupePaths(minorRoads, 2);
|
|
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
|
features.externalRoads = dedupePaths(externalRoads, 1);
|
|
features.expressways = dedupePaths(expressways, 2);
|
|
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
// National-road service additions happen late and can make an earlier rail
|
|
// density target stale. Top up only genuinely under-served maps; already
|
|
// dense rail networks are left untouched.
|
|
const visibleNationalLengthBeforeFinalRail = (features.nationalRoads || []).reduce((sum, path) => sum + pathLengthCells(path || []), 0);
|
|
const visibleRailLengthBeforeFinalRail = [...(features.railways || []), ...(features.branchRailways || [])].reduce((sum, path) => sum + pathLengthCells(path || []), 0);
|
|
debug.finalRailDensityBeforeAudit = {
|
|
nationalLength: Math.round(visibleNationalLengthBeforeFinalRail),
|
|
railLength: Math.round(visibleRailLengthBeforeFinalRail),
|
|
ratio: visibleNationalLengthBeforeFinalRail > 0 ? visibleRailLengthBeforeFinalRail / visibleNationalLengthBeforeFinalRail : 0,
|
|
};
|
|
if (visibleNationalLengthBeforeFinalRail > 0 && visibleRailLengthBeforeFinalRail / visibleNationalLengthBeforeFinalRail < 0.76) {
|
|
debug.finalRailDensityTopUp = densifyRailToNationalRatio(0.84);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
}
|
|
if (initialVisibleCrop) {
|
|
// The literal hidden-raster initial generator must not satisfy the railway
|
|
// quota mostly in the discarded halo. Top up the future published centre
|
|
// to a slightly-lower-than-national-road density while solving every new
|
|
// route against the complete hidden terrain.
|
|
debug.initialVisibleCropRailDensityTopUp = densifyRailToNationalRatio(0.82, initialVisibleCrop);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
}
|
|
|
|
// Dedupe is allowed to change path ownership, so audit the service contract
|
|
// one last time on the exact arrays that will be emitted.
|
|
const postDedupeNationalService = ensureMajorCityNationalRoadLinks(50000);
|
|
const postDedupeRailService = ensureMajorCityRailLinks(50000);
|
|
const postDedupeExpressService = ensureMajorCityExpresswayLinks(50000);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
if (features.minorRoads !== minorRoads
|
|
|| features.nationalRoads !== nationalRoads
|
|
|| features.externalRoads !== externalRoads
|
|
|| features.expressways !== expressways
|
|
|| features.externalExpressways !== externalExpressways) {
|
|
throw new Error("Post-admin transport path normalization must preserve published array identity.");
|
|
}
|
|
|
|
// The last service repair can create a very short hierarchy segment. Run the
|
|
// same short-fragment/parallel cleanup once more on the exact emitted trunk
|
|
// arrays. A segment that is the sole service for a major city is protected;
|
|
// all other tiny national pieces are downgraded to local roads and tiny
|
|
// motorway pieces are removed.
|
|
debug.finalShortTrunkCleanupAfterService = pruneShortFinalTrunkSegments();
|
|
// Collapse short and medium same-direction parallel runs onto one physical
|
|
// alignment before deciding whether a whole route is redundant. This is more
|
|
// robust than the old overlap-only pruning for 1-3 cell separated corridors
|
|
// and avoids the characteristic double expressway/national-road ribbons.
|
|
debug.finalExpresswaySharedAlignment = collapseParallelCorridorsOntoSharedAlignment(expressways, "expressway", finalMajorCities, terrain, { radius: 5, directionDot: 0.93, minRunPoints: 3 });
|
|
debug.finalNationalSharedAlignment = collapseParallelCorridorsOntoSharedAlignment(nationalRoads, "national", finalMajorCities, terrain, { radius: 4, directionDot: 0.93, minRunPoints: 3 });
|
|
debug.finalExpresswayParallelPruneExactOutput = pruneFinalParallelPaths(expressways, "expressway", finalMajorCities, { radius: 5, threshold: 0.16, directionDot: 0.93, maxParallelRunSamples: 2 });
|
|
debug.finalNationalParallelPruneExactOutput = pruneFinalParallelPaths(nationalRoads, "national", finalMajorCities, { radius: 4, threshold: 0.20, directionDot: 0.93, maxParallelRunSamples: 2 });
|
|
const exactOutputNationalService = ensureMajorCityNationalRoadLinks(50000);
|
|
const exactOutputExpressService = ensureMajorCityExpresswayLinks(50000);
|
|
debug.exactOutputTrunkServiceRepair = { national: exactOutputNationalService, expressway: exactOutputExpressService };
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
// A service repair can append a new approach beside an existing trunk. Snap
|
|
// that approach onto the established corridor once more; unlike pruning this
|
|
// preserves the mandatory-city service contract while eliminating visible
|
|
// side-by-side lanes represented as separate road paths.
|
|
debug.postServiceExpresswaySharedAlignment = collapseParallelCorridorsOntoSharedAlignment(features.expressways, "expressway", finalMajorCities, terrain, { radius: 5, directionDot: 0.93, minRunPoints: 3 });
|
|
debug.postServiceNationalSharedAlignment = collapseParallelCorridorsOntoSharedAlignment(features.nationalRoads, "national", finalMajorCities, terrain, { radius: 4, directionDot: 0.93, minRunPoints: 3 });
|
|
debug.finalExpresswayNearDuplicatePrune = pruneNearDuplicateTrunkPaths(features.expressways, "expressway", finalMajorCities, { overlapFloor: 0.46, maxUniqueCells: 12 });
|
|
debug.finalNationalNearDuplicatePrune = pruneNearDuplicateTrunkPaths(features.nationalRoads, "national", finalMajorCities, { overlapFloor: 0.50, maxUniqueCells: 10 });
|
|
debug.finalExpresswayBranchSimplification = pruneRedundantExpresswayBranches(4);
|
|
// Branch simplification preserves every major-city service route by
|
|
// construction, but dedupe again so shared alignments remain one emitted
|
|
// physical corridor where possible.
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
|
|
// Rail should be dense in urbanised regions but remain modestly below the
|
|
// national-road network overall. Redundant routes are removed only when all
|
|
// major-city rail service remains covered.
|
|
debug.finalRailDensityCapAfterTopUp = capRailDensityToNationalRatio(0.96);
|
|
const finalRailServiceAfterDensityCap = ensureMajorCityRailLinks(50000);
|
|
debug.finalRailServiceAfterDensityCap = finalRailServiceAfterDensityCap;
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
debug.finalRailDensityCapExactOutput = capRailDensityToNationalRatio(0.98);
|
|
const exactOutputRailService = ensureMajorCityRailLinks(50000);
|
|
debug.exactOutputRailServiceRepair = exactOutputRailService;
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
// Visible-core finalizer for literal overscan. The hidden map is only an
|
|
// implementation detail: quality contracts must still hold *after* the
|
|
// centre is cropped. Do the final service/density/continuation work here,
|
|
// while the router can still see the full hidden terrain and off-screen OD
|
|
// context. Nothing after this block may lower visible-core trunk quality.
|
|
if (initialVisibleCrop) {
|
|
const serviceBefore = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
const railDensityBeforeCrossing = densifyRailToNationalRatio(0.82, initialVisibleCrop);
|
|
const cropContinuations = ensureInitialVisibleCropContinuations();
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
// Shared physical alignment is preferred to deleting a semantically needed
|
|
// route. Use a generous radius matching the reported ~1 km duplication and
|
|
// then prune only paths that still duplicate an already-served corridor.
|
|
const nationalAlign1 = collapseParallelCorridorsOntoSharedAlignment(features.nationalRoads, "national", finalMajorCities, terrain, { radius: 5, directionDot: 0.90, minRunPoints: 2 });
|
|
const expressAlign1 = collapseParallelCorridorsOntoSharedAlignment(features.expressways, "expressway", finalMajorCities, terrain, { radius: 6, directionDot: 0.91, minRunPoints: 2 });
|
|
const nationalPrune = pruneFinalParallelPaths(features.nationalRoads, "national", finalMajorCities, { radius: 5, threshold: 0.16, directionDot: 0.90, maxParallelRunSamples: 2 });
|
|
const expressPrune = pruneFinalParallelPaths(features.expressways, "expressway", finalMajorCities, { radius: 6, threshold: 0.14, directionDot: 0.91, maxParallelRunSamples: 2 });
|
|
|
|
const serviceAfterPrune = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
// Mandatory repairs can append a short approach beside a trunk. Snap those
|
|
// approaches onto the established alignment, but do not delete the sole
|
|
// route serving a major city.
|
|
const nationalAlign2 = collapseParallelCorridorsOntoSharedAlignment(features.nationalRoads, "national", finalMajorCities, terrain, { radius: 5, directionDot: 0.90, minRunPoints: 2 });
|
|
const expressAlign2 = collapseParallelCorridorsOntoSharedAlignment(features.expressways, "expressway", finalMajorCities, terrain, { radius: 6, directionDot: 0.91, minRunPoints: 2 });
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
|
|
// Do this *after* all whole-map rail caps. The old order could achieve 0.9x
|
|
// density in the future crop and then remove those exact lines while
|
|
// optimizing the hidden halo, leaving a 0.57x published network.
|
|
const railDensityFinal = densifyRailToNationalRatio(0.82, initialVisibleCrop);
|
|
const railDensityVisibleCap = capRailDensityToNationalRatio(0.96, initialVisibleCrop);
|
|
const railServiceFinal = ensureMajorCityRailLinks(50000);
|
|
const railDensityVisibleCapAfterService = capRailDensityToNationalRatio(0.98, initialVisibleCrop);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
// A rail-heavy published core is corrected by adding useful, terrain-routed
|
|
// national-road OD corridors rather than deleting the rail service that the
|
|
// urban network actually needs. This also makes the intended ordering
|
|
// national > rail explicit after the hidden-halo crop.
|
|
const nationalDensityVisible = densifyNationalToRailRatio(0.86, initialVisibleCrop);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
const nationalAlign3 = collapseParallelCorridorsOntoSharedAlignment(features.nationalRoads, "national", finalMajorCities, terrain, { radius: 5, directionDot: 0.90, minRunPoints: 2 });
|
|
const nationalPruneAfterDensity = pruneFinalParallelPaths(features.nationalRoads, "national", finalMajorCities, { radius: 5, threshold: 0.16, directionDot: 0.90, maxParallelRunSamples: 2 });
|
|
const nationalServiceAfterDensity = ensureMajorCityNationalRoadLinks(50000);
|
|
const nationalDensityVisibleFinal = densifyNationalToRailRatio(0.90, initialVisibleCrop);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
const railDensityVisibleFloorAfterNational = densifyRailToNationalRatio(0.90, initialVisibleCrop);
|
|
const railDensityVisibleCapFinal = capRailDensityToNationalRatio(0.98, initialVisibleCrop);
|
|
const railServiceAfterNationalDensity = ensureMajorCityRailLinks(50000);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
|
|
// Final publishable-core service is stronger than the hidden-map audit: an
|
|
// edge city must have an inward visible national road, railway and motorway
|
|
// rather than being served only by a path that disappears into the halo.
|
|
const visibleMajorCityInternalService = ensureVisibleCropMajorCityInternalService();
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
const nationalAlignAfterVisibleService = collapseParallelCorridorsOntoSharedAlignment(features.nationalRoads, "national", finalMajorCities, terrain, { radius: 5, directionDot: 0.90, minRunPoints: 2 });
|
|
const expressAlignAfterVisibleService = collapseParallelCorridorsOntoSharedAlignment(features.expressways, "expressway", finalMajorCities, terrain, { radius: 6, directionDot: 0.91, minRunPoints: 2 });
|
|
const nationalDensityAfterVisibleService = densifyNationalToRailRatio(0.86, initialVisibleCrop);
|
|
const railDensityAfterVisibleService = densifyRailToNationalRatio(0.90, initialVisibleCrop);
|
|
const railCapAfterVisibleService = capRailDensityToNationalRatio(0.98, initialVisibleCrop);
|
|
|
|
debug.initialVisibleCropFinalizer = {
|
|
serviceBefore, railDensityBeforeCrossing, cropContinuations,
|
|
nationalAlign1, expressAlign1, nationalPrune, expressPrune,
|
|
serviceAfterPrune, nationalAlign2, expressAlign2, railDensityFinal, railDensityVisibleCap, railServiceFinal, railDensityVisibleCapAfterService,
|
|
nationalDensityVisible, nationalAlign3, nationalPruneAfterDensity, nationalServiceAfterDensity, nationalDensityVisibleFinal,
|
|
railDensityVisibleFloorAfterNational, railDensityVisibleCapFinal, railServiceAfterNationalDensity,
|
|
visibleMajorCityInternalService, nationalAlignAfterVisibleService, expressAlignAfterVisibleService, nationalDensityAfterVisibleService, railDensityAfterVisibleService, railCapAfterVisibleService,
|
|
productionOnly: true, simplifiedOutputForbidden: true,
|
|
};
|
|
}
|
|
|
|
// r11.7 final transport completion. These passes run after the literal
|
|
// overscan visible-core finalizer, so they see the exact geometry that will
|
|
// be cropped/published while still routing on the hidden production terrain.
|
|
function ensureUrbanRailMultiplicity() {
|
|
features.railways ||= []; features.branchRailways ||= [];
|
|
const result = { checked: 0, added: 0, noPath: 0 };
|
|
const cities = (features.modernCities || []).filter((c) => c && inside(c.x, c.y) && !terrain?.sea?.[indexOf(c.x, c.y)] && (c.population || 0) >= 50000)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const maxAdded = Math.min(24, Math.max(8, Math.ceil(cities.length * 0.9)));
|
|
for (const city of cities) {
|
|
if (result.added >= maxAdded) break;
|
|
result.checked++;
|
|
const required = (city.population || 0) >= 500000 ? 3 : (city.population || 0) >= 150000 ? 2 : 2;
|
|
const allRail = () => [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
let servedPaths = allRail().filter((path) => pathTouchesCell(path, city.x, city.y, 4.2)).length;
|
|
if (servedPaths >= required) continue;
|
|
const component = componentAt(city);
|
|
const targets = [
|
|
...finalMajorCities.filter((q) => q !== city && componentAt(q) === component),
|
|
...sameComponentCivicTargets(city, { minDistance: 12, maxDistance: 145, limit: 22 }),
|
|
].map((q) => ({ ...q, d: Math.hypot(q.x - city.x, q.y - city.y) }))
|
|
.filter((q) => q.d >= 12 && q.d <= 145)
|
|
.sort((a, b) => {
|
|
const ap = Number(a.population || a.municipalityPopulation || 0) + (a.isPrefecturalCapital ? 300000 : 0);
|
|
const bp = Number(b.population || b.municipalityPopulation || 0) + (b.isPrefecturalCapital ? 300000 : 0);
|
|
return (a.d / Math.max(1, Math.sqrt(ap + 4000))) - (b.d / Math.max(1, Math.sqrt(bp + 4000)));
|
|
});
|
|
const tried = new Set();
|
|
for (const target of targets) {
|
|
if (servedPaths >= required || result.added >= maxAdded) break;
|
|
const key = `${Math.round(target.x)},${Math.round(target.y)}`;
|
|
if (tried.has(key)) continue; tried.add(key);
|
|
const d = Math.hypot(target.x - city.x, target.y - city.y);
|
|
let path = routeTerrainPath(city, target, terrain, { maxLength: d * 3.0 + 66, maxSeaRun: 0, maxTunnelRun: 30, snapRadius: 1.8, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!path.length) path = terrainFirstConnector(city, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.25 + 76, maxSeaRun: 0, maxTunnelRun: 30, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (path.length < 8 || !trunkElevationSafe(path, terrain, 0.72)) { result.noPath++; continue; }
|
|
const influence = rebuildInfluence(allRail(), 3.2);
|
|
let near = 0, sampled = 0;
|
|
for (let k = Math.max(3, Math.floor(path.length * 0.18)); k < Math.ceil(path.length * 0.82); k += 2) {
|
|
const [x, y] = path[k]; if (!inside(x, y)) continue; sampled++;
|
|
if ((influence[indexOf(x, y)] || 0) > 0.40) near++;
|
|
}
|
|
if (sampled >= 4 && near / sampled > 0.58) continue;
|
|
path = terrainSafeSmooth(path, terrain, "rail", 2);
|
|
((city.population || 0) >= 150000 && (target.population || 0) >= 50000 ? features.railways : features.branchRailways).push(path);
|
|
result.added++; servedPaths++;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function ensureExpresswayTerminalContinuity() {
|
|
features.expressways ||= [];
|
|
const result = { checked: 0, justified: 0, connectorsAdded: 0, unresolved: 0 };
|
|
const external = features.externalExpressways || [];
|
|
const ordinary = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
|
|
const gateways = features.externalGateways || [];
|
|
const original = [...features.expressways];
|
|
const additions = [];
|
|
const allOtherPoints = (self) => [...original, ...external, ...additions].filter((p) => p !== self);
|
|
const nearEdge = (p) => p.x <= 3 || p.y <= 3 || p.x >= MAP_W - 4 || p.y >= MAP_H - 4;
|
|
for (const path of original) {
|
|
if (!path || path.length < 8) continue;
|
|
for (const raw of [path[0], path[path.length - 1]]) {
|
|
const endpoint = { x: raw[0], y: raw[1] }; result.checked++;
|
|
const connectedExpress = nearestPointOnPaths(allOtherPoints(path), endpoint, 3.0);
|
|
const ordinaryHit = nearestPointOnPaths(ordinary, endpoint, 10.0);
|
|
const gateway = nearestEntity(gateways, endpoint, 8.0);
|
|
const cityTerminal = finalMajorCities.some((c) => Math.hypot(c.x - endpoint.x, c.y - endpoint.y) <= Math.max(22, (c.urbanRadius || 12) * 2.1));
|
|
const explicitInterchange = (features.interchanges || []).some((ic) => Math.hypot(ic.x - endpoint.x, ic.y - endpoint.y) <= 5.0);
|
|
// Merely approaching an ordinary road used to make an expressway dead end
|
|
// "valid". Require a city/gateway or an actual interchange instead.
|
|
const legitimateRoadTerminal = !!ordinaryHit && explicitInterchange && cityTerminal;
|
|
if (nearEdge(endpoint) || connectedExpress || gateway || cityTerminal || legitimateRoadTerminal) { result.justified++; continue; }
|
|
let target = nearestPointOnPaths(allOtherPoints(path), endpoint, 96);
|
|
if (target && componentAt(target) !== componentAt(endpoint)) target = null;
|
|
if (!target) {
|
|
target = finalMajorCities.filter((c) => sameLandComponent(endpoint, c))
|
|
.map((c) => ({ ...c, d: Math.hypot(c.x - endpoint.x, c.y - endpoint.y) }))
|
|
.filter((c) => c.d >= 14 && c.d <= 110)
|
|
.sort((a, b) => a.d - b.d)[0] || null;
|
|
}
|
|
if (!target) { result.unresolved++; continue; }
|
|
const d = Math.hypot(target.x - endpoint.x, target.y - endpoint.y);
|
|
let connector = routeTerrainPath(endpoint, target, terrain, { maxLength: d * 3.1 + 80, maxSeaRun: 0, maxTunnelRun: 28, snapRadius: 2.2, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (!connector.length) connector = terrainFirstConnector(endpoint, target, terrain, { skipPrimaryRoute: true, maxLength: d * 3.3 + 88, maxSeaRun: 0, maxTunnelRun: 28, maxExpanded: SIZE, maxElevation: 0.695 });
|
|
if (connector.length < 8 || !trunkElevationSafe(connector, terrain, 0.72)) { result.unresolved++; continue; }
|
|
connector = terrainSafeSmooth(connector, terrain, "expressway", 3);
|
|
const turns = pathSharpTurnStats(connector);
|
|
if (turns.consecutiveExtreme > 1 || turns.sharpShare > 0.46) { result.unresolved++; continue; }
|
|
additions.push(connector); result.connectorsAdded++;
|
|
}
|
|
}
|
|
features.expressways.push(...additions);
|
|
return result;
|
|
}
|
|
|
|
function rebuildFinalInterchanges() {
|
|
const result = { expresswayLength: 0, target: 0, added: 0, accessAdded: 0, skippedNoRoad: 0 };
|
|
const ordinary = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
|
|
const newInterchanges = [];
|
|
const newAccess = [];
|
|
const addAt = (p, source) => {
|
|
if (!p || !inside(Math.round(p.x), Math.round(p.y)) || terrain?.sea?.[indexOf(Math.round(p.x), Math.round(p.y))]) return false;
|
|
if (newInterchanges.some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) < 9.0)) return false;
|
|
const hit = nearestPointOnPaths(ordinary, p, 38);
|
|
if (!hit) { result.skippedNoRoad++; return false; }
|
|
const d = Math.hypot(hit.x - p.x, hit.y - p.y);
|
|
let access = [];
|
|
if (d > 1.4) access = terrainFirstConnector(p, hit, terrain, { maxLength: d * 2.7 + 22, maxSeaRun: 0, maxTunnelRun: 7, maxExpanded: Math.min(SIZE, Math.max(8000, Math.floor(d * d * 8 + 4500))), maxElevation: 0.86 });
|
|
if (d > 1.4 && access.length < 2) { result.skippedNoRoad++; return false; }
|
|
newInterchanges.push({ x: Math.round(p.x), y: Math.round(p.y), kind: "Interchange", score: 1, source });
|
|
if (access.length >= 2) { newAccess.push(access); features.minorRoads ||= []; features.minorRoads.push(access); result.accessAdded++; }
|
|
result.added++; return true;
|
|
};
|
|
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
|
if (!path || path.length < 4) continue;
|
|
const cum = [0];
|
|
for (let k = 1; k < path.length; k++) cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
|
const total = cum[cum.length - 1] || 0; result.expresswayLength += total;
|
|
const first = { x: path[0][0], y: path[0][1] };
|
|
const lastTuple = path[path.length - 1];
|
|
const last = { x: lastTuple[0], y: lastTuple[1] };
|
|
// Even a retained short regional spur needs a real terminal IC. Previously
|
|
// the early `total < 8` return left such stubs visually ending in mid-road.
|
|
if (total < 8) {
|
|
addAt(first, "final-terminal-ic");
|
|
addAt(last, "final-terminal-ic");
|
|
continue;
|
|
}
|
|
let expectedSlots = 0;
|
|
for (let s = 5; s <= total - 4;) {
|
|
let probeK = 1; while (probeK < cum.length && cum[probeK] < s) probeK++;
|
|
const probeA = path[Math.max(0, probeK - 1)], probeB = path[Math.min(path.length - 1, probeK)];
|
|
const probeSpan = Math.max(0.001, cum[probeK] - cum[probeK - 1]);
|
|
const probeT = Math.max(0, Math.min(1, (s - cum[probeK - 1]) / probeSpan));
|
|
const probe = { x: Math.round(probeA[0] + (probeB[0] - probeA[0]) * probeT), y: Math.round(probeA[1] + (probeB[1] - probeA[1]) * probeT) };
|
|
const probeDensity = inside(probe.x, probe.y) ? Math.max(0, features.populationDensity?.[indexOf(probe.x, probe.y)] || 0) : 0;
|
|
let cityDemand = 0;
|
|
for (const city of features.modernCities || []) {
|
|
if (!city || (city.population || 0) < 18000) continue;
|
|
const radius = Math.max(16, Math.min(38, (city.urbanRadius || 10) * 2.4));
|
|
const dCity = Math.hypot(city.x - probe.x, city.y - probe.y);
|
|
if (dCity > radius) continue;
|
|
const popWeight = Math.min(1, Math.log10(Math.max(20000, city.population || 0) / 20000) / 1.25);
|
|
cityDemand = Math.max(cityDemand, (1 - dCity / radius) * (0.45 + popWeight * 0.55));
|
|
}
|
|
const icDemand = Math.max(Math.min(1, probeDensity * 1.7), cityDemand);
|
|
const dynamicGap = icDemand >= 0.72 ? 11.5 : icDemand >= 0.42 ? 14.5 : icDemand >= 0.18 ? 20.5 : 28.5;
|
|
let best = null;
|
|
for (let offset = -4; offset <= 4; offset += 1.5) {
|
|
const d0 = Math.max(2, Math.min(total - 2, s + offset));
|
|
let k = 1; while (k < cum.length && cum[k] < d0) k++;
|
|
const a = path[Math.max(0, k - 1)], b = path[Math.min(path.length - 1, k)];
|
|
const span = Math.max(0.001, cum[k] - cum[k - 1]); const t = Math.max(0, Math.min(1, (d0 - cum[k - 1]) / span));
|
|
const p = { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t) };
|
|
const hit = nearestPointOnPaths(ordinary, p, 34);
|
|
if (!hit) continue;
|
|
const roadD = Math.hypot(hit.x - p.x, hit.y - p.y);
|
|
const density = features.populationDensity?.[indexOf(p.x, p.y)] || 0;
|
|
const score = roadD - density * 12 + Math.abs(offset) * 0.12;
|
|
if (!best || score < best.score) best = { ...p, score };
|
|
}
|
|
if (best && addAt(best, "final-density-ic")) expectedSlots++;
|
|
s += dynamicGap;
|
|
}
|
|
result.target += expectedSlots;
|
|
addAt(first, "final-terminal-ic"); addAt(last, "final-terminal-ic");
|
|
}
|
|
result.legacyUniformTarget = Math.max(0, Math.round(result.expresswayLength / 19.5));
|
|
result.target = Math.max(result.target, newInterchanges.length);
|
|
interchanges.length = 0;
|
|
interchanges.push(...newInterchanges);
|
|
features.icAccessRoads = newAccess;
|
|
return result;
|
|
}
|
|
|
|
// Exact visual/topological junction repair. The graph auditors previously
|
|
// accepted endpoints that were merely within a few cells of another route,
|
|
// which looked like a torn road on the rendered map. Turn those near misses
|
|
// into real shared-raster junctions using the terrain router only.
|
|
function snapNearMissEndpoints(sourcePaths, targetPaths, mode, options = {}) {
|
|
const result = { checked: 0, added: 0, unresolved: 0 };
|
|
const maxGap = options.maxGap ?? 3.4;
|
|
const maxAdded = options.maxAdded ?? 48;
|
|
const minGap = options.minGap ?? 0.75;
|
|
const originals = [...(sourcePaths || [])];
|
|
const targets = [...(targetPaths || [])];
|
|
const used = new Set();
|
|
function nearestOtherPoint(endpoint, selfPath) {
|
|
let best = null;
|
|
for (const path of targets) {
|
|
if (!path || path === selfPath) continue;
|
|
for (let k = 0; k < path.length; k++) {
|
|
const q = path[k];
|
|
if (!q) continue;
|
|
const d = Math.hypot(endpoint.x - q[0], endpoint.y - q[1]);
|
|
if (d <= minGap) return { connected: true, x: q[0], y: q[1], d };
|
|
if (d > maxGap || (best && d >= best.d)) continue;
|
|
best = { connected: false, x: q[0], y: q[1], d };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
for (let pi = 0; pi < originals.length && result.added < maxAdded; pi++) {
|
|
const path = originals[pi];
|
|
if (!path || path.length < 2) continue;
|
|
const endpoints = [path[0], path[path.length - 1]];
|
|
for (let ei = 0; ei < endpoints.length && result.added < maxAdded; ei++) {
|
|
const raw = endpoints[ei];
|
|
const endpoint = { x: raw[0], y: raw[1] };
|
|
const id = `${Math.round(endpoint.x)},${Math.round(endpoint.y)}:${mode}`;
|
|
if (used.has(id)) continue;
|
|
used.add(id);
|
|
result.checked++;
|
|
const hit = nearestOtherPoint(endpoint, path);
|
|
if (!hit || hit.connected) continue;
|
|
const connector = terrainFirstConnector(endpoint, hit, terrain, {
|
|
maxLength: Math.max(8, hit.d * 3.2 + 8),
|
|
maxSeaRun: 0,
|
|
maxTunnelRun: mode === "local" ? 3 : 0,
|
|
maxElevation: mode === "local" ? 0.82 : 0.695,
|
|
snapRadius: 0.45,
|
|
maxExpanded: Math.min(SIZE, Math.max(4500, Math.floor(hit.d * hit.d * 120 + 2500))),
|
|
});
|
|
if (connector.length < 2) { result.unresolved++; continue; }
|
|
const burden = pathTerrainBurden(connector, terrain);
|
|
if (mode !== "local" && (burden.highBarrierShare > 0.02 || !hardTerrainPathValid(connector, mode))) { result.unresolved++; continue; }
|
|
// Weld into the original polyline so a visual/topological junction is a
|
|
// single exact raster chain instead of another two-ended fragment.
|
|
const merged = ei === 0
|
|
? [...connector.slice().reverse(), ...path.slice(1)]
|
|
: [...path, ...connector.slice(1)];
|
|
path.length = 0;
|
|
path.push(...merged);
|
|
if (!targets.includes(path)) targets.push(path);
|
|
result.added++;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function hardTerrainPathValid(path, mode) {
|
|
if (!path || path.length < 2) return false;
|
|
const maxElevation = mode === "local" ? 0.84 : 0.695;
|
|
const maxBarrier = mode === "local" ? 0.94 : 0.82;
|
|
const maxSlope = mode === "local" ? 0.68 : 0.52;
|
|
const maxRidge = mode === "local" ? 0.90 : 0.62;
|
|
const passField = terrain?.passSuitability;
|
|
let samples = 0;
|
|
let rugged = 0;
|
|
let slopeSum = 0;
|
|
let elevationSum = 0;
|
|
let valleyPassSum = 0;
|
|
let totalLength = 0;
|
|
for (let k = 1; k < path.length; k++) {
|
|
const a = path[k - 1], b = path[k];
|
|
const segLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
totalLength += segLen;
|
|
const steps = Math.max(1, Math.ceil(segLen));
|
|
for (let q = 0; q <= steps; q++) {
|
|
const t = q / steps;
|
|
const x = Math.round(a[0] + (b[0] - a[0]) * t), y = Math.round(a[1] + (b[1] - a[1]) * t);
|
|
if (!inside(x, y)) return false;
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i]) return false;
|
|
const elev = terrain?.elevation?.[i] || 0;
|
|
const barrier = terrain?.naturalBarrierScore?.[i] || 0;
|
|
const slopeV = terrain?.slope?.[i] || 0;
|
|
const ridgeV = terrain?.ridgeField?.[i] || 0;
|
|
const pass = passField?.[i] || 0;
|
|
const valley = terrain?.valleyField?.[i] || 0;
|
|
if (elev >= maxElevation) return false;
|
|
if (mode !== "local" && elev >= 0.66 && pass < 0.50) return false;
|
|
if ((slopeV > maxSlope || (ridgeV > maxRidge && elev >= 0.58) || (barrier > maxBarrier && elev >= 0.60)) && pass < (mode === "local" ? 0.40 : 0.46)) return false;
|
|
if (q > 0) {
|
|
const px = Math.round(a[0] + (b[0] - a[0]) * ((q - 1) / steps));
|
|
const py = Math.round(a[1] + (b[1] - a[1]) * ((q - 1) / steps));
|
|
if (inside(px, py)) {
|
|
const grade = Math.abs(elev - (terrain?.elevation?.[indexOf(px, py)] || 0));
|
|
if (mode !== "local" && grade >= 0.10 && pass < 0.46) return false;
|
|
}
|
|
}
|
|
samples++;
|
|
slopeSum += slopeV;
|
|
elevationSum += elev;
|
|
valleyPassSum += Math.max(valley, pass);
|
|
if (mode !== "local" && (slopeV >= 0.22 || elev >= 0.58 || (ridgeV >= 0.44 && elev >= 0.50) || (barrier >= 0.58 && elev >= 0.50))) rugged++;
|
|
}
|
|
}
|
|
// A route can have adjacent vertices and still be an implausible almost
|
|
// straight chord. Reject that geometry only when the traversed corridor is
|
|
// genuinely rugged; straight roads across plains remain perfectly valid.
|
|
if (mode !== "local" && samples > 0 && totalLength >= 18) {
|
|
const first = path[0], last = path[path.length - 1];
|
|
const direct = Math.hypot(last[0] - first[0], last[1] - first[1]);
|
|
const straightness = direct / Math.max(1, totalLength);
|
|
const ruggedShare = rugged / samples;
|
|
const meanSlope = slopeSum / samples;
|
|
const meanElevation = elevationSum / samples;
|
|
const meanValleyPass = valleyPassSum / samples;
|
|
if (straightness > 0.90 && ruggedShare > 0.18 && meanValleyPass < 0.46) return false;
|
|
if (totalLength >= 30 && straightness > 0.84 && ruggedShare > 0.30 && (meanSlope > 0.14 || meanElevation > 0.53) && meanValleyPass < 0.50) return false;
|
|
|
|
// Compare the emitted route with its geometric chord. A road can avoid
|
|
// every forbidden cell by one-cell wiggles and still look like a ruler
|
|
// line across a mountain range. If the direct chord is materially hostile,
|
|
// a production trunk must make a visible terrain-driven detour or achieve
|
|
// a substantially better terrain burden than that chord.
|
|
const chordSteps = Math.max(1, Math.ceil(direct));
|
|
let chordSamples = 0, chordHard = 0, chordBurden = 0;
|
|
let routeBurden = 0;
|
|
for (let k = 0; k < path.length; k++) {
|
|
const x = Math.round(path[k][0]), y = Math.round(path[k][1]);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
const e = terrain?.elevation?.[i] || 0, sV = terrain?.slope?.[i] || 0;
|
|
const rV = terrain?.ridgeField?.[i] || 0, bV = terrain?.naturalBarrierScore?.[i] || 0;
|
|
const vV = terrain?.valleyField?.[i] || 0, pV = passField?.[i] || 0;
|
|
routeBurden += Math.max(0, sV * 3.2 + rV * 2.0 + bV * 2.4 + Math.max(0, e - 0.48) * 6.0 - vV * 1.25 - pV * 1.55);
|
|
}
|
|
routeBurden /= Math.max(1, path.length);
|
|
for (let q = 0; q <= chordSteps; q++) {
|
|
const t = q / chordSteps;
|
|
const x = Math.round(first[0] + (last[0] - first[0]) * t);
|
|
const y = Math.round(first[1] + (last[1] - first[1]) * t);
|
|
if (!inside(x, y)) { chordHard++; chordSamples++; continue; }
|
|
const i = indexOf(x, y);
|
|
const e = terrain?.elevation?.[i] || 0, sV = terrain?.slope?.[i] || 0;
|
|
const rV = terrain?.ridgeField?.[i] || 0, bV = terrain?.naturalBarrierScore?.[i] || 0;
|
|
const vV = terrain?.valleyField?.[i] || 0, pV = passField?.[i] || 0;
|
|
const seaV = !!terrain?.sea?.[i];
|
|
const hostile = seaV || (e >= 0.66 && pV < 0.50) || ((sV >= 0.42 || (rV >= 0.58 && e >= 0.54) || (bV >= 0.78 && e >= 0.56)) && pV < 0.46);
|
|
if (hostile) chordHard++;
|
|
chordBurden += seaV ? 12 : Math.max(0, sV * 3.2 + rV * 2.0 + bV * 2.4 + Math.max(0, e - 0.48) * 6.0 - vV * 1.25 - pV * 1.55);
|
|
chordSamples++;
|
|
}
|
|
const chordHardShare = chordHard / Math.max(1, chordSamples);
|
|
const meanChordBurden = chordBurden / Math.max(1, chordSamples);
|
|
if (direct >= 18 && chordHardShare >= 0.08 && straightness > 0.90) return false;
|
|
if (direct >= 24 && meanChordBurden > routeBurden * 1.30 + 0.18 && straightness > 0.91) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function removeTerrainInvalidTransportPaths() {
|
|
const result = {};
|
|
const specs = [
|
|
["minorRoads", "local"], ["nationalRoads", "national"], ["ringRoads", "national"], ["externalRoads", "national"],
|
|
["expressways", "expressway"], ["ringExpressways", "expressway"], ["externalExpressways", "expressway"],
|
|
["railways", "rail"], ["branchRailways", "rail"], ["ringRailways", "rail"], ["externalRailways", "rail"],
|
|
];
|
|
for (const [key, mode] of specs) {
|
|
const before = (features[key] || []).length;
|
|
features[key] = (features[key] || []).filter((path) => hardTerrainPathValid(path, mode));
|
|
result[key] = { before, after: features[key].length, removed: before - features[key].length };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Hard production invariant: no emitted transport polyline may contain a
|
|
// sparse vertex jump that the renderer would turn into an artificial straight
|
|
// chord. This is deliberately destructive rather than interpolating the gap:
|
|
// interpolation would still ignore terrain. Mandatory service is rebuilt below
|
|
// with the full terrain router.
|
|
function removeDiscontinuousTransportPaths(maxGap = 2.25) {
|
|
const result = {};
|
|
const filter = (key) => {
|
|
const before = (features[key] || []).length;
|
|
features[key] = (features[key] || []).filter((path) => path?.length >= 2 && pathMaxVertexGap(path) <= maxGap);
|
|
result[key] = { before, after: features[key].length, removed: before - features[key].length };
|
|
};
|
|
for (const key of ["minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "ringExpressways", "externalExpressways", "railways", "branchRailways", "ringRailways", "externalRailways"]) filter(key);
|
|
return result;
|
|
}
|
|
debug.finalDiscontinuousTransportCleanup = removeDiscontinuousTransportPaths(2.25);
|
|
debug.finalTerrainInvalidTransportCleanup = removeTerrainInvalidTransportPaths();
|
|
debug.finalServiceAfterDiscontinuityCleanup = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
const railJunctionTarget = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
debug.finalNearMissJunctionRepair = {
|
|
national: snapNearMissEndpoints(features.nationalRoads || [], [...(features.nationalRoads || []), ...(features.externalRoads || [])], "national", { maxGap: 3.6, maxAdded: 56 }),
|
|
expressway: snapNearMissEndpoints(features.expressways || [], [...(features.expressways || []), ...(features.externalExpressways || [])], "expressway", { maxGap: 4.2, maxAdded: 28 }),
|
|
rail: snapNearMissEndpoints([...(features.railways || []), ...(features.branchRailways || [])], railJunctionTarget, "rail", { maxGap: 3.6, maxAdded: 48 }),
|
|
local: snapNearMissEndpoints(features.minorRoads || [], [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])], "local", { maxGap: 2.8, maxAdded: 80 }),
|
|
};
|
|
features.minorRoads = dedupePaths(features.minorRoads || [], 1);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
features.railways = dedupePaths(features.railways || [], 1);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 1);
|
|
debug.finalServiceAfterNearMissRepair = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
// Refill rural municipal access after the hard cleanup. This reuses the same
|
|
// terrain-aware local-road algorithm; no rectilinear/synthetic mesh is added.
|
|
const finalRuralCoverageStages = runFinalRuralMunicipalCoverageStages();
|
|
debug.finalRuralMunicipalRoadCoverage = finalRuralCoverageStages.full;
|
|
if (finalRuralCoverageStages.visible) debug.finalVisibleRuralMunicipalRoadCoverage = finalRuralCoverageStages.visible;
|
|
const finalRuralDensificationStages = runFinalRuralDensificationStages();
|
|
debug.finalRuralLocalDensification = finalRuralDensificationStages.full;
|
|
if (finalRuralDensificationStages.visible) debug.finalVisibleRuralLocalDensification = finalRuralDensificationStages.visible;
|
|
features.minorRoads = dedupePaths(features.minorRoads || [], 1);
|
|
|
|
debug.finalUrbanRailMultiplicity = ensureUrbanRailMultiplicity();
|
|
debug.finalRailDensityProduction = densifyRailToNationalRatio(initialVisibleCrop ? 1.00 : 0.98, initialVisibleCrop || null);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
debug.finalRailDensityProductionCap = capRailDensityToNationalRatio(1.04, initialVisibleCrop || null);
|
|
const finalRailServiceAfterProductionCap = ensureMajorCityRailLinks(50000);
|
|
debug.finalRailServiceAfterProductionCap = finalRailServiceAfterProductionCap;
|
|
// Density capping must not remove the sole *visible* inward rail approach of
|
|
// a crop-edge city. Re-run the exact visible-core service contract after the
|
|
// cap and never cap again after this point.
|
|
debug.finalVisibleServiceAfterRailCap = ensureVisibleCropMajorCityInternalService();
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 2);
|
|
debug.finalExpresswayTerminalContinuity = ensureExpresswayTerminalContinuity();
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
debug.finalExpresswayContinuityParallelPrune = pruneFinalParallelPaths(features.expressways, "expressway", finalMajorCities, { radius: 5, threshold: 0.18, directionDot: 0.92, maxParallelRunSamples: 2 });
|
|
debug.finalExpresswayTerminalContinuityAfterParallelPrune = ensureExpresswayTerminalContinuity();
|
|
features.expressways = dedupePaths(features.expressways || [], 2);
|
|
|
|
// Nothing after this point may be silently clipped by the renderer. Remove a
|
|
// whole route if it crosses forbidden water/mountain terrain, rebuild mandatory
|
|
// service with the strict terrain router, then turn near-misses into exact
|
|
// raster junctions. This prevents the old "route with the bad middle missing"
|
|
// appearance.
|
|
debug.finalTerrainInvalidTransportCleanupAfterAllDensity = removeTerrainInvalidTransportPaths();
|
|
debug.finalServiceAfterStrictTerrainCleanup = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
const finalRailJunctionTarget = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
debug.finalExactJunctionRepair = {
|
|
national: snapNearMissEndpoints(features.nationalRoads || [], [...(features.nationalRoads || []), ...(features.externalRoads || [])], "national", { maxGap: 4.2, maxAdded: 96 }),
|
|
expressway: snapNearMissEndpoints(features.expressways || [], [...(features.expressways || []), ...(features.externalExpressways || [])], "expressway", { maxGap: 4.8, maxAdded: 48 }),
|
|
rail: snapNearMissEndpoints([...(features.railways || []), ...(features.branchRailways || [])], finalRailJunctionTarget, "rail", { maxGap: 4.0, maxAdded: 84 }),
|
|
local: snapNearMissEndpoints(features.minorRoads || [], [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])], "local", { maxGap: 3.2, maxAdded: 140 }),
|
|
};
|
|
features.minorRoads = dedupePaths(features.minorRoads || [], 1);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
features.railways = dedupePaths(features.railways || [], 1);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 1);
|
|
debug.finalExpresswayTerminalContinuityAfterJunctionRepair = ensureExpresswayTerminalContinuity();
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
debug.finalTerrainInvariantAfterJunctionRepair = removeTerrainInvalidTransportPaths();
|
|
debug.finalServiceAfterFinalTerrainInvariant = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
const afterInvariantRailTarget = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
debug.finalExactJunctionRepairAfterInvariant = {
|
|
national: snapNearMissEndpoints(features.nationalRoads || [], [...(features.nationalRoads || []), ...(features.externalRoads || [])], "national", { maxGap: 4.6, maxAdded: 120 }),
|
|
expressway: snapNearMissEndpoints(features.expressways || [], [...(features.expressways || []), ...(features.externalExpressways || [])], "expressway", { maxGap: 5.0, maxAdded: 64 }),
|
|
rail: snapNearMissEndpoints([...(features.railways || []), ...(features.branchRailways || [])], afterInvariantRailTarget, "rail", { maxGap: 4.5, maxAdded: 110 }),
|
|
local: snapNearMissEndpoints(features.minorRoads || [], [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])], "local", { maxGap: 3.6, maxAdded: 220 }),
|
|
};
|
|
features.minorRoads = dedupePaths(features.minorRoads || [], 1);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
features.railways = dedupePaths(features.railways || [], 1);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 1);
|
|
debug.finalExpresswayTerminalContinuityAfterFinalTerrainInvariant = ensureExpresswayTerminalContinuity();
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
debug.finalInterchangeRebuild = rebuildFinalInterchanges();
|
|
|
|
const missingMajorCityTrunkService = [];
|
|
const majorCityTrunkServiceExceptions = [];
|
|
const nationalNetwork = [...(features.nationalRoads || []), ...(features.externalRoads || [])];
|
|
const railNetwork = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
const expressNetwork = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
for (const city of finalMajorCities) {
|
|
const national = anyPathTouches(nationalNetwork, city, 3.0);
|
|
const rail = anyPathTouches(railNetwork, city, 3.0);
|
|
const expressway = expresswayServesCityFringe(city);
|
|
if (national && rail && expressway) continue;
|
|
const component = componentAt(city);
|
|
const componentArea = component >= 0 ? (landComponentArea[component] || 0) : 0;
|
|
const alternateSameIslandTargets = sameComponentCivicTargets(city, { minDistance: 8, maxDistance: 240, limit: 8 });
|
|
const absentOnComponent = {
|
|
national: !national && component >= 0 && !pathHasComponent(nationalNetwork, component),
|
|
rail: !rail && component >= 0 && !pathHasComponent(railNetwork, component),
|
|
expressway: !expressway && component >= 0 && !pathHasComponent(expressNetwork, component),
|
|
};
|
|
// The hidden overscan is context, not publishable content. A city that lies
|
|
// wholly outside the future published crop and close to the true hidden
|
|
// raster edge must not fail the visible production contract merely because
|
|
// its outward continuation is itself beyond the hidden planning halo.
|
|
const outsidePublishedCore = !!initialVisibleCrop
|
|
&& !(city.x >= initialVisibleCrop.x0 && city.y >= initialVisibleCrop.y0 && city.x < initialVisibleCrop.x1 && city.y < initialVisibleCrop.y1);
|
|
const hiddenOuterEdgeDistance = Math.min(city.x, city.y, MAP_W - 1 - city.x, MAP_H - 1 - city.y);
|
|
const hiddenOverscanEdgeOnly = !!initialVisibleCrop && outsidePublishedCore && hiddenOuterEdgeDistance < 36;
|
|
|
|
// A genuinely tiny island may reasonably have no motorway, but national and
|
|
// rail access on that island are still required. This matches the visible
|
|
// crop audit and prevents the old broad "no same-component network" escape
|
|
// hatch from excusing ordinary mainland cities.
|
|
const tinyIsletMotorwayException = component >= 0
|
|
&& componentArea < 96
|
|
&& national && rail && !expressway;
|
|
const genuineLargeStraitIsolation = tinyIsletMotorwayException
|
|
|| (component >= 0 && componentArea < 72 && alternateSameIslandTargets.length === 0
|
|
&& (absentOnComponent.national || absentOnComponent.rail || absentOnComponent.expressway));
|
|
const unresolved = { national: !national, rail: !rail, expressway: !expressway };
|
|
const record = { x: city.x, y: city.y, name: city.name, population: city.population || 0, national, rail, expressway, landComponent: component, landComponentArea: componentArea };
|
|
if (hiddenOverscanEdgeOnly) {
|
|
majorCityTrunkServiceExceptions.push({ ...record, exceptionReason: "hidden-overscan-edge-only", hiddenOuterEdgeDistance, outsidePublishedCore: true, unresolved });
|
|
} else if (genuineLargeStraitIsolation) {
|
|
majorCityTrunkServiceExceptions.push({ ...record, exceptionReason: tinyIsletMotorwayException ? "tiny-isolated-islet-motorway-not-required" : "tiny-isolated-islet-no-same-land-civic-anchor", unavailableAcrossLargeStrait: absentOnComponent });
|
|
} else {
|
|
missingMajorCityTrunkService.push({ ...record, unresolved });
|
|
}
|
|
}
|
|
debug.postDedupeMajorCityService = {
|
|
national: postDedupeNationalService,
|
|
rail: postDedupeRailService,
|
|
expressway: postDedupeExpressService,
|
|
missing: missingMajorCityTrunkService,
|
|
exceptions: majorCityTrunkServiceExceptions,
|
|
};
|
|
if (!features.railways.length) {
|
|
const railNodes = [...(features.modernCities || []), ...(features.markets || [])]
|
|
.filter((p) => p && inside(p.x, p.y) && !terrain?.sea?.[indexOf(p.x, p.y)])
|
|
.sort((a, b) => ((b.population || 0) + (b.isPrefecturalCapital ? 800000 : 0)) - ((a.population || 0) + (a.isPrefecturalCapital ? 800000 : 0)));
|
|
let fallbackRail = [];
|
|
for (let a = 0; a < Math.min(8, railNodes.length) && !fallbackRail.length; a++) {
|
|
const candidates = railNodes.slice(a + 1, Math.min(14, railNodes.length))
|
|
.sort((p, q) => Math.hypot(p.x - railNodes[a].x, p.y - railNodes[a].y) - Math.hypot(q.x - railNodes[a].x, q.y - railNodes[a].y));
|
|
for (const target of candidates) {
|
|
const direct = Math.hypot(target.x - railNodes[a].x, target.y - railNodes[a].y);
|
|
if (direct < 10 || direct > 150) continue;
|
|
fallbackRail = routeTerrainPath(railNodes[a], target, terrain, { maxLength: direct * 3.4 + 82, maxSeaRun: 0, maxTunnelRun: 0, snapRadius: 1.2, maxElevation: 0.695, strictTerrain: true });
|
|
if (!fallbackRail.length) fallbackRail = terrainFirstConnector(railNodes[a], target, terrain, { skipPrimaryRoute: true, maxLength: direct * 3.6 + 88, maxSeaRun: 0, maxTunnelRun: 0, snapRadius: 1.2, maxElevation: 0.695, strictTerrain: true });
|
|
if (fallbackRail.length >= 4) break;
|
|
fallbackRail = [];
|
|
}
|
|
}
|
|
if (fallbackRail.length >= 4) {
|
|
fallbackRail = terrainSafeSmooth(fallbackRail, terrain, "rail", 1);
|
|
if (hardTerrainPathValid(fallbackRail, "rail")) {
|
|
features.railways.push(fallbackRail);
|
|
debug.guaranteedRailFallbackAdded = 1;
|
|
} else {
|
|
debug.guaranteedRailFallbackAdded = 0;
|
|
}
|
|
} else {
|
|
debug.guaranteedRailFallbackAdded = 0;
|
|
}
|
|
}
|
|
features.interchanges = interchanges;
|
|
|
|
// Final municipal access repair: connect an unserved office to the nearest
|
|
// existing road using the normal terrain-aware pathfinder. Do not create the
|
|
// former 2-3 cell isolated crossbar stub; those were the dominant source of
|
|
// meaningless rural dead-end roads.
|
|
let finalAdminStubsAdded = 0;
|
|
let finalAdminAccessUnresolved = 0;
|
|
for (const center of adminCenters || []) {
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
const network = [...features.minorRoads, ...features.nationalRoads, ...features.externalRoads];
|
|
if (anyPathTouches(network, center, 0.85)) continue;
|
|
const target = nearestPointOnPaths(network, center, 42);
|
|
if (!target) { finalAdminAccessUnresolved++; continue; }
|
|
const d = Math.hypot(target.x - center.x, target.y - center.y);
|
|
if (d < 2) continue;
|
|
let path = routeTerrainPath(center, target, terrain, { maxLength: d * 2.35 + 24, maxSeaRun: 0, maxTunnelRun: 6, snapRadius: 1.2 });
|
|
if (!path.length) path = terrainFirstConnector(center, target, terrain, { skipPrimaryRoute: true, maxLength: d * 2.45 + 26, maxSeaRun: 0, maxTunnelRun: 6, shortFallback: 6 });
|
|
if (!path.length || pathLengthCells(path) < 3) { finalAdminAccessUnresolved++; continue; }
|
|
features.minorRoads.push(terrainSafeSmooth(path, terrain, "local", 1));
|
|
finalAdminStubsAdded++;
|
|
}
|
|
debug.finalAdminStubsAdded = finalAdminStubsAdded;
|
|
debug.finalAdminAccessUnresolved = finalAdminAccessUnresolved;
|
|
debug.absoluteFinalTerrainInvariant = removeTerrainInvalidTransportPaths();
|
|
debug.absoluteFinalMajorCityService = {
|
|
national: ensureMajorCityNationalRoadLinks(50000),
|
|
rail: ensureMajorCityRailLinks(50000),
|
|
expressway: ensureMajorCityExpresswayLinks(50000),
|
|
};
|
|
// Service guarantees are intentionally late, but they must not reintroduce
|
|
// kilometre-scale side-by-side trunks. Collapse required parallel corridors
|
|
// onto one existing terrain-valid physical alignment rather than deleting the
|
|
// route that uniquely serves a city. This preserves topology while removing
|
|
// the visual double-road failure.
|
|
debug.absoluteFinalNationalSharedAlignment = collapseParallelCorridorsOntoSharedAlignment(
|
|
features.nationalRoads, "national", finalMajorCities, terrain,
|
|
{ radius: 4, directionDot: 0.90, minRunPoints: 2 },
|
|
);
|
|
debug.absoluteFinalExpresswaySharedAlignment = collapseParallelCorridorsOntoSharedAlignment(
|
|
features.expressways, "expressway", finalMajorCities, terrain,
|
|
{ radius: 5, directionDot: 0.91, minRunPoints: 2 },
|
|
);
|
|
debug.absoluteFinalRuralNationalCoverage = ensureRuralNationalRoadCoverage();
|
|
debug.absoluteFinalUrbanNationalGapRepair = repairUrbanNationalRoadGaps();
|
|
debug.absoluteFinalNationalMajorCityServiceAfterRuralRepair = ensureMajorCityNationalRoadLinks(50000);
|
|
debug.absoluteFinalNationalSharedAlignmentAfterRuralCoverage = collapseParallelCorridorsOntoSharedAlignment(
|
|
features.nationalRoads, "national", finalMajorCities, terrain,
|
|
{ radius: 4, directionDot: 0.90, minRunPoints: 2 },
|
|
);
|
|
// Rural service and urban gap repair can legitimately create a short
|
|
// connector whose endpoints sit on an existing trunk. If most of that
|
|
// connector is already represented by the trunk, keep the physical shared
|
|
// alignment instead of a second side-by-side national-road object. Major-city
|
|
// unique service remains protected by the pruning helper.
|
|
debug.absoluteFinalNationalNearDuplicatePruneAfterRuralCoverage = pruneNearDuplicateTrunkPaths(
|
|
features.nationalRoads, "national", finalMajorCities,
|
|
{ overlapFloor: 0.44, maxUniqueCells: 12 },
|
|
);
|
|
// Rural alignment promotion must not reintroduce the old several-kilometre
|
|
// side-by-side national-road artifact. Remove only redundant parallel trunks;
|
|
// exact shared alignments remain valid multiplexed corridors.
|
|
debug.absoluteFinalNationalParallelPruneAfterRuralCoverage = pruneFinalParallelPaths(
|
|
features.nationalRoads, "national", [],
|
|
{ radius: 3, threshold: 0.38, directionDot: 0.90, maxParallelRunSamples: 5 },
|
|
);
|
|
// This late anti-parallel pass intentionally ignores route-level major-city
|
|
// ownership. Rebuild any service it removed as a short terrain-routed access
|
|
// connector instead of preserving kilometres of duplicate trunk geometry.
|
|
debug.absoluteFinalNationalMajorCityServiceAfterParallelPrune = ensureMajorCityNationalRoadLinks(50000);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
debug.absoluteFinalRailDensity = densifyRailToNationalRatio(initialVisibleCrop ? 0.94 : 0.92, initialVisibleCrop || null);
|
|
debug.absoluteFinalRailDensityCap = capRailDensityToNationalRatio(0.96, initialVisibleCrop || null);
|
|
debug.absoluteFinalRailMajorCityService = ensureMajorCityRailLinks(50000);
|
|
// Rail densification can reveal the only terrain-valid valley corridor for a
|
|
// large-city motorway approach. Re-run the motorway service guarantee after
|
|
// the *complete* trunk network exists, then normalize any newly shared
|
|
// motorway geometry before the final IC reconstruction.
|
|
debug.absoluteFinalExpresswayMajorCityServiceAfterRailDensity = ensureMajorCityExpresswayLinks(50000);
|
|
debug.absoluteFinalExpresswaySharedAlignmentAfterRailDensity = collapseParallelCorridorsOntoSharedAlignment(
|
|
features.expressways, "expressway", finalMajorCities, terrain,
|
|
{ radius: 5, directionDot: 0.91, minRunPoints: 2 },
|
|
);
|
|
debug.absoluteFinalExpresswayNearDuplicatePruneAfterRailDensity = pruneNearDuplicateTrunkPaths(
|
|
features.expressways, "expressway", finalMajorCities,
|
|
{ overlapFloor: 0.44, maxUniqueCells: 12 },
|
|
);
|
|
function pruneAbsoluteShortExpresswayFragments() {
|
|
const current = features.expressways || [];
|
|
const external = features.externalExpressways || [];
|
|
const result = { before: current.length, removed: 0, after: current.length };
|
|
const keep = [];
|
|
for (let index = 0; index < current.length; index++) {
|
|
const path = current[index];
|
|
const len = pathLengthCells(path || []);
|
|
if (!path?.length) { result.removed++; continue; }
|
|
const a = path[0], b = path[path.length - 1];
|
|
const touchesEdge = a[0] <= 2 || a[1] <= 2 || a[0] >= MAP_W - 3 || a[1] >= MAP_H - 3
|
|
|| b[0] <= 2 || b[1] <= 2 || b[0] >= MAP_W - 3 || b[1] >= MAP_H - 3;
|
|
if (len >= 10 || touchesEdge || finalMajorCities.some((city) => pathServesMajorCity(path, city, "expressway"))) { keep.push(path); continue; }
|
|
const others = [...current.filter((_, j) => j !== index), ...external];
|
|
const aJoin = nearestPointOnPaths(others, { x: a[0], y: a[1] }, 3.0);
|
|
const bJoin = nearestPointOnPaths(others, { x: b[0], y: b[1] }, 3.0);
|
|
if (aJoin && bJoin) keep.push(path); else result.removed++;
|
|
}
|
|
features.expressways = keep;
|
|
result.after = keep.length;
|
|
return result;
|
|
}
|
|
debug.absoluteFinalShortExpresswayCleanup = pruneAbsoluteShortExpresswayFragments();
|
|
const absoluteRailTargets = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
debug.absoluteFinalNearMissJunctionRepair = {
|
|
national: snapNearMissEndpoints(features.nationalRoads || [], [...(features.nationalRoads || []), ...(features.externalRoads || [])], "national", { maxGap: 4.6, maxAdded: 120 }),
|
|
expressway: snapNearMissEndpoints(features.expressways || [], [...(features.expressways || []), ...(features.externalExpressways || [])], "expressway", { maxGap: 5.0, maxAdded: 64 }),
|
|
rail: snapNearMissEndpoints([...(features.railways || []), ...(features.branchRailways || [])], absoluteRailTargets, "rail", { maxGap: 4.5, maxAdded: 110 }),
|
|
local: snapNearMissEndpoints(features.minorRoads || [], [...(features.minorRoads || []), ...(features.nationalRoads || []), ...(features.externalRoads || [])], "local", { maxGap: 3.6, maxAdded: 220 }),
|
|
};
|
|
debug.absoluteFinalExpresswayTerminalContinuity = ensureExpresswayTerminalContinuity();
|
|
features.minorRoads = dedupePaths(features.minorRoads || [], 1);
|
|
features.nationalRoads = dedupePaths(features.nationalRoads || [], 1);
|
|
features.expressways = dedupePaths(features.expressways || [], 1);
|
|
features.railways = dedupePaths(features.railways || [], 1);
|
|
features.branchRailways = dedupePaths(features.branchRailways || [], 1);
|
|
debug.absoluteFinalInterchangeRebuild = rebuildFinalInterchanges();
|
|
features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0);
|
|
features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0);
|
|
features.transportDebug = {
|
|
...(features.transportDebug || {}),
|
|
generationOrder: debug.order,
|
|
postAdminTransportFinalization: debug,
|
|
};
|
|
return features;
|
|
}
|