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; }