map/mapTransport.js
2026-05-28 00:30:09 +09:00

2557 lines
119 KiB
JavaScript

import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities, xyOf } from "./mapUtils.js";
import { distanceToNearest, influenceFromPaths, samplePath } from "./mapGeneratorHelpers.js";
import {
assessExpresswayRoute,
assessMountainRoute,
countReason,
fieldBackbonePolicy,
makeSpatialIndex,
makeUnionFind,
packDebugField,
pathAverageField,
pathLengthCells,
squaredDistance,
TRANSPORT_ROUTE_POLICIES,
} from "./mapTransportUtils.js";
export {
createPathInfluenceCache,
packDebugField,
pathAverageField,
pathLengthCells,
pathSetSignature,
routeQualityAcceptable,
routeQualityStats,
TRANSPORT_ROUTE_POLICIES,
} from "./mapTransportUtils.js";
export function buildDensityFlowRoadTransportSystem(ctx) {
const {
seed,
sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore,
agriculture, basinField, plain, passSuitability, crossingSuitability,
settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence,
logisticsPreSuitability, urbanEdge,
transportFields, cachedInfluenceFromPaths,
nationalRoads, minorRoads, railways, externalRoads, externalRailways,
expressways, externalExpressways, icAccessRoads, interchanges, externalGateways,
modernCities, markets, villages, ports, commercialPorts, passes, regionStats,
regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates,
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
} = ctx;
// --- OD-corridor road generation ---------------------------------------
// Roads are generated as corridors first; hierarchy-specific labels are
// assigned by corridor purpose. This replaces the older field-corridor
// national/expressway lines and avoids repeated endpoint correction passes.
const majorCitiesForExpressway = modernCities.filter((c) => (c.population || 0) >= 45000);
const villageCentersForExpressway = villages.filter((v) => (v.population || 0) >= 400);
const cityCoreProtectionIndex = makeSpatialIndex(
majorCitiesForExpressway.map((c) => ({ ...c, protectedRadius: Math.max(5.5, (c.coreRadius || 4) + 3.2) })),
18
);
const urbanCoreProtectionIndex = makeSpatialIndex(
modernCities.map((c) => ({ ...c, protectedRadius: Math.max(6.2, (c.coreRadius || 4) + 4.2) })),
18
);
const villageCoreIndex = makeSpatialIndex(villageCentersForExpressway, 8);
const lineCostCache = new Map();
function approximateLineCost(a, b, costField) {
const fieldLabel =
costField === expresswayCorridorCost ? "expressway" :
costField === transportFields.national ? "national" :
costField === transportFields.local ? "local" :
null;
const orderedEndpoints = a.x < b.x || (a.x === b.x && a.y <= b.y)
? `${a.x},${a.y}:${b.x},${b.y}`
: `${b.x},${b.y}:${a.x},${a.y}`;
const cacheKey = fieldLabel ? `${fieldLabel}:${orderedEndpoints}` : null;
if (cacheKey && lineCostCache.has(cacheKey)) return lineCostCache.get(cacheKey);
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let sum = 0;
let n = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) {
if (cacheKey) lineCostCache.set(cacheKey, INF);
return INF;
}
sum += costField[i];
n++;
}
const result = n ? sum / n : INF;
if (cacheKey) lineCostCache.set(cacheKey, result);
return result;
}
function pathTerrainRisk(path) {
if (!path?.length) return 1;
let mountain = 0;
let boundary = 0;
let dense = 0;
let villageCore = 0;
let cityCore = 0;
let highAltitude = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
mountain += clamp((elevation[i] - 0.56) * 2.7 + slope[i] * 0.95 + ridgeField[i] * 0.85);
if (elevation[i] >= 0.70) highAltitude++;
const nb = naturalBarrierScore?.[i] || 0;
boundary += nb * nb;
dense += settlementDemand[i] > 0.70 ? 1 : 0;
villageCore += preliminaryVillageInfluence[i] > 0.46 ? 1 : 0;
cityCore += preliminaryTownInfluence[i] > 0.52 || settlementDemand[i] > 0.68 ? 1 : 0;
n++;
}
return n ? {
mountain: mountain / n,
boundary: boundary / n,
denseShare: dense / n,
villageCoreShare: villageCore / n,
cityCoreShare: cityCore / n,
highAltitudeShare: highAltitude / n,
} : { mountain: 1, boundary: 1, denseShare: 1, villageCoreShare: 1, cityCoreShare: 1, highAltitudeShare: 1 };
}
function highAltitudeRoadClosed(i) {
return elevation[i] >= 0.70;
}
const expresswayCorridorCost = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.expressway[i] >= INF) {
expresswayCorridorCost[i] = INF;
} else {
const urbanCore = clamp(settlementDemand[i] * 0.80 + preliminaryTownInfluence[i] * 0.92 + preliminaryVillageInfluence[i] * 0.58);
const ruralSettlementCore = clamp(preliminaryVillageInfluence[i] * 1.15 + agriculture[i] * 0.20 - plain[i] * 0.16);
const boundary = naturalBarrierScore?.[i] || 0;
// Highways are through-corridors here, not urban expressways. Penalize
// CBD proxies and village cores strongly, while still allowing suburban
// edge cells selected by majorCitySuburbanAnchor().
expresswayCorridorCost[i] = transportFields.expressway[i]
+ urbanCore * 8.2
+ ruralSettlementCore * 6.3
+ boundary * boundary * 2.6
+ Math.max(0, elevation[i] - 0.58) * 5.4
+ ridgeField[i] * 2.1;
}
}
const routePolicies = TRANSPORT_ROUTE_POLICIES;
function mountainRouteAssessment(path, mode = "road") {
return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies);
}
function routeTooStraightAcrossMountains(path, mode = "road") {
return !mountainRouteAssessment(path, mode).ok;
}
function routeTooStraightMountainOnly(path) {
return !mountainRouteAssessment(path, "expresswayMountainOnly").ok;
}
function expresswayProximityRisk(path) {
const result = { cityCoreHits: 0, villageHits: 0, minMajorCityDistance: Infinity, minVillageDistance: Infinity };
if (!path?.length) return { ...result, cityCoreHits: 999, villageHits: 999, minMajorCityDistance: 0, minVillageDistance: 0 };
for (const [x, y] of path) {
for (const c of cityCoreProtectionIndex.near(x, y, 42)) {
const d2 = squaredDistance(x, y, c.x, c.y);
const d = Math.sqrt(d2);
result.minMajorCityDistance = Math.min(result.minMajorCityDistance, d);
if (d2 < c.protectedRadius * c.protectedRadius) result.cityCoreHits++;
}
for (const v of villageCoreIndex.near(x, y, 4)) {
const d2 = squaredDistance(x, y, v.x, v.y);
const d = Math.sqrt(d2);
result.minVillageDistance = Math.min(result.minVillageDistance, d);
if (d2 < 1.65 * 1.65) result.villageHits++;
}
}
return result;
}
function expresswayRouteAssessment(path, options = {}) {
return assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, routePolicies);
}
function expresswayRouteAcceptable(path, options = {}) {
return expresswayRouteAssessment(path, options).ok;
}
function routeAcceptableForMode(path, mode, potentialField, penalty, limits = {}, expresswayOptions = {}) {
if (mode === "expressway") {
return expresswayRouteAssessment(path, expresswayOptions);
}
const ok = transportRouteAcceptable(path, mode, potentialField, penalty, limits);
return { ok, reason: ok ? "ok" : "transportQuality" };
}
function majorCitySuburbanAnchor(city) {
if (!city || !inside(city.x, city.y)) return null;
let best = null;
const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7));
const outer = Math.round(Math.max(inner + 7, Math.min(32, (city.urbanRadius || 12) * 1.85)));
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = city.x + dx;
const 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] || transportFields.expressway[i] >= INF) continue;
let protectedUrbanCore = false;
for (const other of urbanCoreProtectionIndex.near(x, y, 18)) {
if (squaredDistance(x, y, other.x, other.y) < other.protectedRadius * other.protectedRadius) {
protectedUrbanCore = true;
break;
}
}
if (protectedUrbanCore) continue;
let onVillageCore = false;
for (const v of villageCoreIndex.near(x, y, 4)) {
if (squaredDistance(x, y, v.x, v.y) < 2.3 * 2.3) {
onVillageCore = true;
break;
}
}
if (onVillageCore) continue;
const nb = naturalBarrierScore?.[i] || 0;
const suburbanBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(3, (outer - inner) * 0.52));
const score =
transportFields.expresswayPotential[i] * 1.18 +
logisticsPreSuitability[i] * 0.68 +
urbanEdge[i] * 0.52 +
plain[i] * 0.22 +
basinField[i] * 0.14 +
suburbanBand * 0.38 -
settlementDemand[i] * 1.10 -
preliminaryTownInfluence[i] * 0.72 -
preliminaryVillageInfluence[i] * 0.96 -
slope[i] * 0.86 -
ridgeField[i] * 0.72 -
nb * nb * 1.05 +
hash2(x, y, seed + 18103 + city.x * 7 + city.y * 13) * 0.06;
if (!best || score > best.score) {
best = { x, y, score, city, regionId: regionIdAt(x, y), role: "major-city-suburb", population: city.population || 0 };
}
}
}
return best;
}
function dedupeAnchors(points, minDistance = 5) {
return dedupePointCandidates(points.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), minDistance);
}
function buildExpresswayODCorridors(debug) {
const majorSuburbs = dedupeAnchors(
modernCities
.filter((c) => (c.population || 0) >= 100000)
.map(majorCitySuburbanAnchor),
10
);
debug.majorCitySuburbanAnchors = majorSuburbs.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
const majorCityRefs = majorSuburbs.filter(Boolean);
const externalRefs = externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 90000 }));
const portRefs = commercialPorts
.filter((p) => p.portClass === "major" || (p.population || 0) >= 18000)
.map((p) => ({ ...p, score: 0.78 + (p.portClass === "major" ? 0.30 : 0), role: "port-logistics", population: p.population || 45000 }));
const remoteRefs = modernCities
.filter((c) => (c.population || 0) >= 65000 && !majorSuburbs.some((m) => m.city === c))
.map((c) => {
const nearestMajor = majorSuburbs.reduce((best, m) => {
const d = Math.hypot(m.x - c.x, m.y - c.y);
return !best || d < best.d ? { m, d } : best;
}, null);
const anchor = majorCitySuburbanAnchor(c) || { x: c.x, y: c.y, score: 0.3, city: c, population: c.population };
return { ...anchor, role: "remote-city", score: (anchor.score || 0.3) + Math.min(1.0, (nearestMajor?.d || 0) / 95) * 0.55, remoteDistance: nearestMajor?.d || 0, population: c.population || 0 };
})
.filter((p) => p.remoteDistance >= 52)
.sort((a, b) => b.score - a.score)
.slice(0, 8);
const nodes = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 9);
const pairs = [];
for (let a = 0; a < nodes.length; a++) {
for (let b = a + 1; b < nodes.length; b++) {
const A = nodes[a];
const B = nodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 48 || d > 176) continue;
const lineCost = approximateLineCost(A, B, expresswayCorridorCost);
if (!Number.isFinite(lineCost) || lineCost >= INF) continue;
const demand = Math.sqrt(Math.max(25000, A.population || 50000) * Math.max(25000, B.population || 50000)) / 100000;
const longDistanceNeed = clamp((d - 48) / 70);
const externalNeed = A.role === "external-gateway" || B.role === "external-gateway" ? 0.55 : 0;
const logisticsNeed = A.role === "port-logistics" || B.role === "port-logistics" ? 0.38 : 0;
const remoteNeed = A.role === "remote-city" || B.role === "remote-city" ? 0.42 : 0;
const score = (demand * 0.70 + longDistanceNeed * 0.90 + externalNeed + logisticsNeed + remoteNeed) / Math.max(0.9, lineCost) + hash2(A.x + B.x, A.y + B.y, seed + 18131) * 0.025;
pairs.push({ a: A, b: B, d, score });
}
}
pairs.sort((x, y) => y.score - x.score);
const penalty = new Float32Array(SIZE);
const degree = new Map();
const maxCorridors = Math.min(6, Math.max(3, Math.ceil(majorSuburbs.length / 2.4)));
for (const pair of pairs) {
if (expressways.length >= maxCorridors) break;
const aid = `${pair.a.x},${pair.a.y}`;
const bid = `${pair.b.x},${pair.b.y}`;
if ((degree.get(aid) || 0) >= 2 || (degree.get(bid) || 0) >= 2) continue;
const path = routeBetweenTrafficCandidates(pair.a, pair.b, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.11,
penaltyStrength: 2.2,
terrainFlowBias: 0.10,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.24,
maxPathLength: pair.d * 2.25 + 42,
});
const len = pathLengthCells(path);
if (len < 34 || len > pair.d * 2.25 + 48) continue;
if (routeTooStraightMountainOnly(path)) continue;
if (!expresswayRouteAcceptable(path)) continue;
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 24, 1.80);
degree.set(aid, (degree.get(aid) || 0) + 1);
degree.set(bid, (degree.get(bid) || 0) + 1);
debug.expresswayCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
}
// Guarantee at least one expressway approach for every very large city. The
// path still uses suburban anchors and expresswayCorridorCost, so it should
// bypass the CBD and village cores instead of cutting through them.
const expressInfluence = cachedInfluenceFromPaths(expressways, 18, "expressway:major-city-coverage");
const allCandidateTargets = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 8);
for (const anchor of majorCityRefs.sort((a, b) => (b.population || 0) - (a.population || 0))) {
if ((anchor.population || 0) < 100000) continue;
const ai = indexOf(anchor.x, anchor.y);
if ((expressInfluence[ai] || 0) > 0.20) continue;
const options = allCandidateTargets
.filter((q) => q !== anchor)
.map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
.filter((e) => e.d >= 38 && e.d <= 170 && Number.isFinite(e.c) && e.c < INF)
.sort((a, b) => (a.d * a.c) - (b.d * b.c));
for (const opt of options.slice(0, 8)) {
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.11,
penaltyStrength: 2.6,
terrainFlowBias: 0.10,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.22,
maxPathLength: opt.d * 2.75 + 78,
});
const len = pathLengthCells(path);
if (len >= 24 && len <= opt.d * 2.95 + 96 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 58, 8.80);
debug.expresswayCorridors.push({ from: "major-city-guarantee", city: anchor.city?.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
break;
}
}
const refreshed = cachedInfluenceFromPaths(expressways, 9, `expressway:coverage:${anchor.x},${anchor.y}`);
if ((refreshed[ai] || 0) <= 0.20) {
// Last resort: create a short suburban approach to the nearest low-cost
// through corridor cell, still outside the urban core. This avoids the
// pathological case where a large isolated city receives no motorway at all.
const fallbackTargets = [];
const searchR = 56;
for (let dy = -searchR; dy <= searchR; dy += 3) {
for (let dx = -searchR; dx <= searchR; dx += 3) {
const x = anchor.x + dx;
const y = anchor.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const d = Math.hypot(dx, dy);
if (d < 20 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
if (settlementDemand[i] > 0.44 || preliminaryTownInfluence[i] > 0.34 || preliminaryVillageInfluence[i] > 0.30) continue;
fallbackTargets.push({ x, y, d, role: "suburban-fallback", population: anchor.population, score: expresswayCorridorCost[i] + d * 0.018 });
}
}
fallbackTargets.sort((a, b) => a.score - b.score);
const target = fallbackTargets[0];
if (target) {
const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.11,
penaltyStrength: 2.2,
terrainFlowBias: 0.10,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.16,
maxPathLength: target.d * 2.8 + 34,
});
if (pathLengthCells(path) >= 12 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 10, 0.74);
debug.expresswayCorridors.push({ from: "major-city-fallback-approach", city: anchor.city?.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
}
}
}
}
// Final city-level coverage pass. The anchor-level influence check can miss
// paired urban centers whose suburban anchors were deduplicated into the
// neighboring city. Check distance from each major city center to the
// motorway layer, then connect its own suburban anchor to the nearest
// existing motorway cell or create a short outward suburban approach.
function expresswayCells() {
const cells = [];
for (const path of expressways) {
for (const [x, y] of path) if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, role: "existing-expressway", population: 0 });
}
return cells;
}
function minDistanceToExpressways(x, y) {
let best = Infinity;
for (const c of expresswayCells()) best = Math.min(best, Math.hypot(c.x - x, c.y - y));
return best;
}
for (const city of modernCities.filter((c) => (c.population || 0) >= 100000).sort((a, b) => (b.population || 0) - (a.population || 0))) {
const coverLimit = Math.max(25, (city.urbanRadius || 13) * 1.75);
if (minDistanceToExpressways(city.x, city.y) <= coverLimit) continue;
const anchor = majorCitySuburbanAnchor(city);
if (!anchor) continue;
let addedForCity = false;
const cells = expresswayCells()
.map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
.filter((e) => e.d >= 8 && e.d <= 105 && Number.isFinite(e.c) && e.c < INF)
.sort((a, b) => (a.d * a.c) - (b.d * b.c));
for (const opt of cells.slice(0, 8)) {
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.11,
penaltyStrength: 2.6,
terrainFlowBias: 0.10,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.18,
maxPathLength: opt.d * 2.85 + 42,
});
const len = pathLengthCells(path);
if (len >= 8 && len <= opt.d * 3.0 + 58 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 10, 0.72);
debug.expresswayCorridors.push({ from: "major-city-center-coverage", city: city.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
addedForCity = true;
break;
}
}
if (!addedForCity) {
const searchR = 48;
const fallbackTargets = [];
for (let dy = -searchR; dy <= searchR; dy += 3) {
for (let dx = -searchR; dx <= searchR; dx += 3) {
const x = anchor.x + dx;
const y = anchor.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const d = Math.hypot(dx, dy);
if (d < 16 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
if (settlementDemand[i] > 0.46 || preliminaryTownInfluence[i] > 0.36 || preliminaryVillageInfluence[i] > 0.31) continue;
fallbackTargets.push({ x, y, d, role: "city-coverage-fallback", population: city.population, score: expresswayCorridorCost[i] + d * 0.016 });
}
}
fallbackTargets.sort((a, b) => a.score - b.score);
for (const target of fallbackTargets.slice(0, 4)) {
const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.11,
penaltyStrength: 2.2,
terrainFlowBias: 0.10,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.16,
maxPathLength: target.d * 2.9 + 36,
});
if (pathLengthCells(path) >= 10 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 10, 0.64);
debug.expresswayCorridors.push({ from: "major-city-center-fallback", city: city.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
break;
}
}
}
}
// Inter-city backbone pass. The previous city-coverage fallback produced
// short suburban motorway approaches, but did not necessarily connect those
// approaches into a through network. Treat high-capacity roads as OD
// corridors: connect major-city suburb anchors, ports and external gates by
// a small Kruskal-style backbone over low-cost terrain.
const backboneAnchors = dedupeAnchors([...majorSuburbs, ...portRefs, ...externalRefs], 10)
.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]);
const keyOf = (p) => `${p.x},${p.y}`;
const { find, unite } = makeUnionFind(backboneAnchors, keyOf);
const backbonePairs = [];
for (let a = 0; a < backboneAnchors.length; a++) {
for (let b = a + 1; b < backboneAnchors.length; b++) {
const A = backboneAnchors[a];
const B = backboneAnchors[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 44 || d > 190) continue;
const c = approximateLineCost(A, B, expresswayCorridorCost);
if (!Number.isFinite(c) || c >= INF) continue;
const demand = Math.sqrt(Math.max(50000, A.population || 70000) * Math.max(50000, B.population || 70000)) / 120000;
const gatewayBonus = A.role === "external-gateway" || B.role === "external-gateway" ? 0.28 : 0;
const portBonus = A.role === "port-logistics" || B.role === "port-logistics" ? 0.18 : 0;
backbonePairs.push({ A, B, d, score: d * c / Math.max(0.55, demand + gatewayBonus + portBonus) });
}
}
backbonePairs.sort((a, b) => a.score - b.score);
let backboneAdded = 0;
for (const pair of backbonePairs) {
const ak = keyOf(pair.A);
const bk = keyOf(pair.B);
if (find(ak) === find(bk)) continue;
if (backboneAdded >= Math.min(9, Math.max(3, backboneAnchors.length - 1))) break;
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "expressway", expresswayCorridorCost, penalty, {
curvePenalty: 0.105,
penaltyStrength: 2.10,
terrainFlowBias: 0.12,
surfaceGrain: 0.007,
relaxRadius: 1,
relaxLineWeight: 0.20,
maxPathLength: pair.d * 3.05 + 96,
snapRadius: 5,
});
const len = pathLengthCells(path);
if (len < 34 || len > pair.d * 3.15 + 116) continue;
if (routeTooStraightMountainOnly(path)) continue;
if (!expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) continue;
expressways.push(path);
addCorridorInfluencePenalty(penalty, path, 24, 1.70);
unite(ak, bk);
backboneAdded++;
debug.expresswayCorridors.push({ from: "expressway-backbone", to: `${pair.A.role}-${pair.B.role}`, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
}
}
function buildNationalCorridorNetwork(debug) {
const baseNodes = [
...modernCities.map((c) => ({ ...c, score: 1.2 + Math.sqrt(c.population || 50000) / 430 + ((c.population || 0) >= 100000 ? 0.55 : 0) + ((c.population || 0) >= 500000 ? 1.10 : 0), role: "city", population: c.population || 0 })),
...markets.filter((m) => (m.population || 0) >= 3000).map((m) => ({ ...m, score: 0.72 + (m.population || 6000) / 42000, role: "market", population: m.population || 0 })),
...ports.map((p) => ({ ...p, score: 0.76 + (p.portClass === "major" ? 0.45 : 0), role: "port", population: p.population || 12000 })),
...externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 42000 })),
...villages.filter((v) => (v.population || 0) >= 2200).map((v) => ({ ...v, score: 0.38 + (v.population || 0) / 18000, role: "large-village", population: v.population || 0 })),
];
const nodes = dedupeAnchors(baseNodes.sort((a, b) => b.score - a.score), 6).slice(0, 48);
if (nodes.length < 2) return;
const penalty = cachedInfluenceFromPaths([...expressways, ...externalRoads], 8, "national-corridor:base");
const connected = [nodes[0]];
const remaining = nodes.slice(1);
const maxMain = Math.min(24, Math.max(14, Math.ceil(nodes.length * 0.42)));
while (remaining.length && nationalRoads.length < maxMain) {
let best = null;
for (const node of remaining) {
const candidates = connected
.map((q) => {
const d = Math.hypot(node.x - q.x, node.y - q.y);
if (d < 10 || d > 112) return null;
const lineCost = approximateLineCost(node, q, transportFields.national);
if (!Number.isFinite(lineCost) || lineCost >= INF) return null;
const demand = Math.sqrt(Math.max(3000, node.population || 6000) * Math.max(3000, q.population || 6000)) / 65000;
const score = d * lineCost / Math.max(0.35, demand + node.score * 0.25 + q.score * 0.25);
return { q, d, score };
})
.filter(Boolean)
.sort((a, b) => a.score - b.score);
if (!candidates.length) continue;
const cand = candidates[0];
if (!best || cand.score < best.score) best = { node, target: cand.q, d: cand.d, score: cand.score };
}
if (!best) break;
const path = routeBetweenTrafficCandidates(best.node, best.target, "national", transportFields.national, penalty, {
curvePenalty: 0.050,
penaltyStrength: 0.92,
terrainFlowBias: 0.26,
surfaceGrain: 0.034,
relaxRadius: 2,
relaxLineWeight: 0.32,
maxPathLength: best.d * 2.55 + 38,
});
const len = pathLengthCells(path);
if (len >= 6 && len <= best.d * 2.65 + 42 && !routeTooStraightAcrossMountains(path, "national") && transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 6, maxLength: best.d * 2.65 + 42, maxHighElevationShare: 0.34, maxSteepShare: 0.46 })) {
nationalRoads.push(path);
debug.nationalCorridors.push({ from: best.node.role, to: best.target.role, length: Math.round(len), path });
addCorridorInfluencePenalty(penalty, path, 6, 0.30);
}
connected.push(best.node);
remaining.splice(remaining.indexOf(best.node), 1);
}
const extraPairs = [];
for (let a = 0; a < Math.min(nodes.length, 32); a++) {
for (let b = a + 1; b < Math.min(nodes.length, 32); b++) {
const A = nodes[a];
const B = nodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 22 || d > 86) continue;
const regional = A.regionId !== B.regionId ? 0.22 : 0;
const need = (A.score + B.score) * 0.5 + regional;
extraPairs.push({ A, B, d, score: d / Math.max(0.5, need) + hash2(A.x + B.x, A.y + B.y, seed + 18161) * 0.06 });
}
}
extraPairs.sort((a, b) => a.score - b.score);
let addedExtra = 0;
for (const pair of extraPairs) {
if (addedExtra >= 5 || nationalRoads.length >= 29) break;
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
curvePenalty: 0.050,
penaltyStrength: 1.05,
terrainFlowBias: 0.25,
surfaceGrain: 0.034,
relaxRadius: 2,
relaxLineWeight: 0.32,
maxPathLength: pair.d * 2.35 + 32,
});
const len = pathLengthCells(path);
if (len < 8 || len > pair.d * 2.35 + 32 || routeTooStraightAcrossMountains(path, "national")) continue;
if (pathAverageField(path, penalty) > 0.42 && pathAverageField(path, transportFields.nationalPotential) < 0.37) continue;
nationalRoads.push(path);
addedExtra++;
debug.nationalCorridors.push({ from: `${pair.A.role}-extra`, to: `${pair.B.role}-extra`, length: Math.round(len), path });
addCorridorInfluencePenalty(penalty, path, 6, 0.32);
}
const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 7, "national:major-city-coverage");
const importantCities = modernCities
.filter((c) => (c.population || 0) >= 100000)
.sort((a, b) => (b.population || 0) - (a.population || 0));
const nationalTargets = dedupeAnchors([...nodes, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
for (const city of importantCities) {
const ci = indexOf(city.x, city.y);
if ((nationalInfluence[ci] || 0) > 0.20) continue;
const target = nationalTargets
.filter((q) => Math.hypot(q.x - city.x, q.y - city.y) > 4)
.map((q) => ({ q, d: Math.hypot(q.x - city.x, q.y - city.y), c: approximateLineCost(city, q, transportFields.national) }))
.filter((e) => e.d <= 86 && Number.isFinite(e.c) && e.c < INF)
.sort((a, b) => (a.d * a.c) - (b.d * b.c))[0];
if (!target) continue;
const path = routeBetweenTrafficCandidates(city, target.q, "national", transportFields.national, penalty, {
curvePenalty: 0.052,
penaltyStrength: 0.86,
terrainFlowBias: 0.27,
surfaceGrain: 0.034,
relaxRadius: 2,
relaxLineWeight: 0.28,
maxPathLength: target.d * 2.5 + 34,
});
const len = pathLengthCells(path);
if (len >= 4 && len <= target.d * 2.55 + 38 && !routeTooStraightAcrossMountains(path, "national")) {
nationalRoads.push(path);
debug.nationalCorridors.push({ from: "major-city-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
addCorridorInfluencePenalty(penalty, path, 6, 0.30);
}
}
// National roads should form regional corridors, not a set of short roads
// terminating around each town. Add a sparse backbone over cities, ports
// and external gates after the initial MST/extra pass, using the same
// terrain cost field but a larger distance envelope.
const backboneNodes = dedupeAnchors([
...modernCities.filter((c) => (c.population || 0) >= 65000).map((c) => ({ ...c, role: "city-backbone", score: 1.0 + Math.sqrt(c.population || 70000) / 420, population: c.population || 0 })),
...ports.filter((p) => p.portClass === "major" || (p.population || 0) >= 9000).map((p) => ({ ...p, role: "port-backbone", score: 1.05, population: p.population || 20000 })),
...externalGateways.map((g) => ({ ...g, role: "external-backbone", score: 1.0, population: 42000 })),
].sort((a, b) => b.score - a.score), 7).slice(0, 34);
const keyOf = (p) => `${p.x},${p.y}`;
const { find, unite } = makeUnionFind(backboneNodes, keyOf);
const pairs = [];
for (let a = 0; a < backboneNodes.length; a++) {
for (let b = a + 1; b < backboneNodes.length; b++) {
const A = backboneNodes[a];
const B = backboneNodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 16 || d > 132) continue;
const c = approximateLineCost(A, B, transportFields.national);
if (!Number.isFinite(c) || c >= INF) continue;
const demand = Math.sqrt(Math.max(9000, A.population || 12000) * Math.max(9000, B.population || 12000)) / 82000;
const regional = A.regionId !== B.regionId ? 0.28 : 0;
pairs.push({ A, B, d, score: d * c / Math.max(0.42, demand + regional + (A.score + B.score) * 0.18) });
}
}
pairs.sort((a, b) => a.score - b.score);
let backboneAdded = 0;
for (const pair of pairs) {
if (backboneAdded >= Math.min(22, Math.max(8, backboneNodes.length - 1))) break;
const ak = keyOf(pair.A);
const bk = keyOf(pair.B);
if (find(ak) === find(bk)) continue;
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
curvePenalty: 0.052,
penaltyStrength: 0.78,
terrainFlowBias: 0.28,
surfaceGrain: 0.036,
relaxRadius: 2,
relaxLineWeight: 0.28,
maxPathLength: pair.d * 3.05 + 62,
snapRadius: 4,
});
const len = pathLengthCells(path);
if (len < 6 || len > pair.d * 3.15 + 78) continue;
if (routeTooStraightAcrossMountains(path, "national")) continue;
nationalRoads.push(path);
addCorridorInfluencePenalty(penalty, path, 6, 0.27);
unite(ak, bk);
backboneAdded++;
debug.nationalCorridors.push({ from: "national-backbone", to: `${pair.A.role}-${pair.B.role}`, length: Math.round(len), path });
}
}
// -------------------------------------------------------------------------
// Density-flow transport system
// -------------------------------------------------------------------------
// The road hierarchy below deliberately avoids treating a city as one node.
// Cities contribute several edge portals and the field samplers add population
// density anchors, so trunks are drawn to preferred density bands instead of
// collapsing into CBD points.
function densityBand(i, target = 0.46, width = 0.26) {
const d = (settlementDemand[i] || 0) - target;
return Math.exp(-(d * d) / Math.max(0.0001, 2 * width * width));
}
function denseCorePenaltyAt(i) {
return clamp(
(settlementDemand[i] - 0.62) / 0.30 +
preliminaryTownInfluence[i] * 0.44 +
preliminaryVillageInfluence[i] * 0.30
);
}
function terrainCorridorBonusAt(i, mode = "national") {
return clamp(
valleyField[i] * (mode === "expressway" ? 0.26 : 0.46) +
coastalLowland[i] * (mode === "expressway" ? 0.24 : 0.34) +
plain[i] * 0.24 +
basinField[i] * 0.18 +
agriculture[i] * (mode === "expressway" ? 0.08 : 0.18) +
(passSuitability?.[i] || 0) * (mode === "expressway" ? 0.18 : 0.32) +
(crossingSuitability?.[i] || 0) * (mode === "expressway" ? 0.10 : 0.24) -
ridgeField[i] * (mode === "expressway" ? 0.42 : 0.24) -
slope[i] * (mode === "expressway" ? 0.36 : 0.22)
);
}
function markPathInfluence(field, path, radius = 5, strength = 1) {
for (const [px, py] of path || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = px + dx;
const y = py + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
const d = Math.hypot(dx, dy);
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
if (v > field[i]) field[i] = v;
}
}
}
}
function fieldAdjustedCost(baseCost, mode, flowField = null) {
const out = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i] || baseCost[i] >= INF) {
out[i] = INF;
continue;
}
const core = denseCorePenaltyAt(i);
const flow = flowField?.[i] || 0;
if (mode === "expressway") {
// Motorways prefer the urban fringe / logistics band and through-flow,
// but strongly avoid CBD and village cores.
const preferred = densityBand(i, 0.38, 0.22) * 0.42 + urbanEdge[i] * 0.42 + logisticsPreSuitability[i] * 0.34 + terrainCorridorBonusAt(i, mode) * 0.24;
out[i] = Math.max(0.12,
baseCost[i]
- preferred
- flow * 0.58
+ core * 2.55
+ preliminaryVillageInfluence[i] * 1.38
+ Math.max(0, elevation[i] - 0.60) * 2.9
+ ridgeField[i] * 0.92
+ slope[i] * 0.92
);
} else if (mode === "national") {
// National roads should follow town chains and valleys without diving
// into every exact population maximum.
const preferred = densityBand(i, 0.52, 0.32) * 0.38 + preliminaryTownInfluence[i] * 0.24 + preliminaryVillageInfluence[i] * 0.20 + terrainCorridorBonusAt(i, mode) * 0.34;
out[i] = Math.max(0.10,
baseCost[i]
- preferred
- flow * 0.74
+ Math.max(0, core - 0.46) * 0.52
+ Math.max(0, elevation[i] - 0.66) * 1.4
+ ridgeField[i] * 0.28
);
} else {
out[i] = baseCost[i];
}
}
return out;
}
function pointPopulationProxy(p, mode = "national") {
if (!p) return 1000;
if (p.population) return p.population;
if (p.portClass === "major") return 85000;
if (p.portClass === "regional") return 42000;
const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1;
const fieldPop = i >= 0 ? Math.round((settlementDemand[i] * 110000 + preliminaryTownInfluence[i] * 65000 + preliminaryVillageInfluence[i] * 18000)) : 0;
return Math.max(mode === "expressway" ? 42000 : 7000, fieldPop);
}
function portalSearchAroundPoint(point, mode = "national", role = "portal", options = {}) {
if (!point || !inside(point.x, point.y)) return null;
const inner = options.inner ?? (mode === "expressway" ? Math.max(9, Math.round((point.coreRadius || 3) + 6)) : Math.max(3, Math.round((point.coreRadius || 2) + 2)));
const outer = options.outer ?? (mode === "expressway" ? Math.max(inner + 7, Math.round((point.urbanRadius || 12) * 1.85)) : Math.max(inner + 5, Math.round((point.urbanRadius || 9) * 1.05)));
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
const targetDensity = mode === "expressway" ? 0.38 : 0.52;
const width = mode === "expressway" ? 0.22 : 0.32;
let best = null;
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = point.x + dx;
const y = point.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 ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.55));
const core = denseCorePenaltyAt(i);
const score =
potentialField[i] * 1.15 +
densityBand(i, targetDensity, width) * 0.58 +
(mode === "expressway" ? urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.46 - core * 1.12 : preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.18 - Math.max(0, core - 0.70) * 0.28) +
terrainCorridorBonusAt(i, mode) * 0.38 +
radialBand * 0.26 -
slope[i] * (mode === "expressway" ? 0.90 : 0.42) -
ridgeField[i] * (mode === "expressway" ? 0.68 : 0.26) +
hash2(x, y, seed + 18610 + point.x * 7 + point.y * 13 + (mode === "expressway" ? 37 : 0)) * 0.055;
if (!best || score > best.score) best = { x, y, score, role, regionId: regionIdAt(x, y), population: pointPopulationProxy(point, mode), source: point };
}
}
return best;
}
function cityPortalAnchors(city, mode = "national") {
if (!city || !inside(city.x, city.y)) return [];
const sectors = mode === "expressway" ? 8 : 10;
const inner = mode === "expressway" ? Math.max(10, Math.round((city.coreRadius || 4) + 7)) : Math.max(4, Math.round((city.coreRadius || 3) + 2));
const outer = mode === "expressway" ? Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9)) : Math.max(inner + 5, Math.round((city.urbanRadius || 11) * 1.15));
const bySector = Array.from({ length: sectors }, () => null);
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
for (let dy = -outer; dy <= outer; dy += 1) {
for (let dx = -outer; dx <= outer; dx += 1) {
const x = city.x + dx;
const 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 ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
const sector = Math.floor((((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * sectors)) % sectors;
const tangentNoise = hash2(x, y, seed + 18630 + city.x * 5 + city.y * 11) * 0.055;
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.54));
const core = denseCorePenaltyAt(i);
const score =
potentialField[i] * 1.10 +
(mode === "expressway" ? densityBand(i, 0.38, 0.22) * 0.58 + urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.48 - core * 1.22 : densityBand(i, 0.52, 0.32) * 0.48 + preliminaryTownInfluence[i] * 0.26 + preliminaryVillageInfluence[i] * 0.16 - Math.max(0, core - 0.70) * 0.24) +
terrainCorridorBonusAt(i, mode) * 0.36 +
radialBand * 0.30 -
slope[i] * (mode === "expressway" ? 0.92 : 0.42) -
ridgeField[i] * (mode === "expressway" ? 0.74 : 0.30) +
tangentNoise;
const current = bySector[sector];
if (!current || score > current.score) bySector[sector] = { x, y, score, role: mode === "expressway" ? "urban-fringe-ic" : "urban-portal", regionId: regionIdAt(x, y), population: city.population || 0, city };
}
}
const count = mode === "expressway"
? ((city.population || 0) >= 420000 ? 2 : 1)
: ((city.population || 0) >= 420000 ? 4 : (city.population || 0) >= 140000 ? 3 : 2);
return dedupeAnchors(bySector.filter(Boolean).sort((a, b) => b.score - a.score), mode === "expressway" ? 9 : 5).slice(0, count);
}
function densityFieldAnchors(mode = "national", max = 48) {
const candidates = [];
const step = mode === "expressway" ? 5 : 4;
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
for (let y = 3; y < MAP_H - 3; y += step) {
for (let x = 3; x < MAP_W - 3; x += step) {
const i = indexOf(x, y);
if (sea[i] || regionIdAt(x, y) < 0) continue;
if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
const core = denseCorePenaltyAt(i);
const score = mode === "expressway"
? potentialField[i] * 1.18 + densityBand(i, 0.38, 0.22) * 0.60 + urbanEdge[i] * 0.48 + logisticsPreSuitability[i] * 0.52 + terrainCorridorBonusAt(i, mode) * 0.32 - core * 1.18 - preliminaryVillageInfluence[i] * 0.82 - slope[i] * 0.78 - ridgeField[i] * 0.62 + hash2(x, y, seed + 18670) * 0.06
: potentialField[i] * 1.26 + densityBand(i, 0.52, 0.32) * 0.46 + preliminaryTownInfluence[i] * 0.32 + preliminaryVillageInfluence[i] * 0.24 + terrainCorridorBonusAt(i, mode) * 0.38 - Math.max(0, core - 0.82) * 0.36 - slope[i] * 0.36 - ridgeField[i] * 0.22 + hash2(x, y, seed + 18671) * 0.06;
const threshold = mode === "expressway" ? 0.78 : 0.66;
if (score >= threshold) candidates.push({ x, y, score, role: mode === "expressway" ? "density-fringe" : "density-town-chain", regionId: regionIdAt(x, y), population: pointPopulationProxy({ x, y }, mode) });
}
}
return pickEntities(candidates, { max, minDistance: mode === "expressway" ? 18 : 9, threshold: 0, seed: seed + 18680 + (mode === "expressway" ? 41 : 0), jitter: 0.035 });
}
function roadAnchorsForMode(mode = "national") {
if (mode === "expressway") {
const urbanPortals = modernCities
// Expressways are intercity corridors. Do not give every medium city an
// urban-expressway-like fringe anchor; medium cities are handled by the
// national-road layer unless they are a capital.
.filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
.flatMap((c) => cityPortalAnchors(c, "expressway"));
const portPortals = commercialPorts
.filter((p) => p.portClass === "major" || p.portClass === "regional")
.map((p) => portalSearchAroundPoint(p, "expressway", "port-fringe", { inner: 4, outer: 14 }) || { ...p, role: "port-fringe", population: p.population || 55000, score: 0.9 });
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 90000, score: 0.92 }));
const fieldPortals = densityFieldAnchors("expressway", 16);
return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 38);
}
const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national"));
const marketPortals = markets
.filter((m) => (m.population || 0) >= 6000)
.map((m) => portalSearchAroundPoint(m, "national", "market-portal", { inner: 2, outer: 8 }) || { ...m, role: "market-portal", population: m.population || 9000, score: 0.65 });
const villagePortals = villages
.filter((v) => (v.population || 0) >= 3500)
.map((v) => ({ ...v, role: "large-village", score: 0.34 + (v.population || 0) / 17000, population: v.population || 0 }));
const portPortals = ports.map((p) => ({ ...p, role: "port", score: 0.72 + (p.portClass === "major" ? 0.48 : p.portClass === "regional" ? 0.28 : 0), population: p.population || 18000 }));
const passPortals = passes.map((p) => ({ ...p, role: "pass", score: 0.46 + (passSuitability?.[indexOf(p.x, p.y)] || 0), population: 8000 }));
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }));
const fieldPortals = densityFieldAnchors("national", 54);
return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 88);
}
function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) {
const flow = new Float32Array(SIZE);
const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56);
const pairs = [];
for (let a = 0; a < nodes.length; a++) {
for (let b = a + 1; b < nodes.length; b++) {
const A = nodes[a];
const B = nodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < (mode === "expressway" ? 36 : 14) || d > (mode === "expressway" ? 178 : 118)) continue;
const c = approximateLineCost(A, B, baseCost);
if (!Number.isFinite(c) || c >= INF) continue;
const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 120000 : 52000);
const crossRegion = A.regionId !== B.regionId ? (mode === "expressway" ? 0.30 : 0.18) : 0;
const gateway = A.role === "external-gateway" || B.role === "external-gateway" ? (mode === "expressway" ? 0.38 : 0.18) : 0;
const score = d * c / Math.max(0.28, demand + crossRegion + gateway + (A.score + B.score) * 0.16);
pairs.push({ A, B, d, score, demand });
}
}
pairs.sort((a, b) => a.score - b.score);
let added = 0;
const virtualPenalty = new Float32Array(SIZE);
for (const pair of pairs) {
if (added >= maxRoutes) break;
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, baseCost, virtualPenalty, {
curvePenalty: mode === "expressway" ? 0.12 : 0.065,
penaltyStrength: mode === "expressway" ? 1.05 : 0.72,
terrainFlowBias: mode === "expressway" ? 0.10 : 0.25,
surfaceGrain: mode === "expressway" ? 0.006 : 0.026,
relaxRadius: mode === "expressway" ? 1 : 2,
relaxLineWeight: mode === "expressway" ? 0.20 : 0.28,
maxPathLength: pair.d * (mode === "expressway" ? 2.65 : 2.45) + (mode === "expressway" ? 70 : 34),
snapRadius: mode === "expressway" ? 4 : 3,
heuristicWeight: mode === "expressway" ? 0.70 : 0.48,
});
if (path.length < 4) continue;
const len = pathLengthCells(path);
if (len > pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 78 : 38)) continue;
markPathInfluence(flow, path, mode === "expressway" ? 8 : 5, Math.min(1.2, 0.42 + pair.demand * 0.36));
addCorridorInfluencePenalty(virtualPenalty, path, mode === "expressway" ? 42 : 6, mode === "expressway" ? 4.20 : 0.22);
added++;
}
return flow;
}
function maxSegmentLength(path) {
let max = 0;
for (let k = 1; k < (path?.length || 0); k++) max = Math.max(max, Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
return max;
}
function existingParallelShare(path, influenceField, threshold = 0.16) {
if (!path?.length || !influenceField) return 0;
let hit = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
n++;
if ((influenceField[indexOf(x, y)] || 0) > threshold) hit++;
}
return n ? hit / n : 0;
}
function localRouteTerrainStats(path) {
const len = pathLengthCells(path);
const first = path?.[0];
const last = path?.[path.length - 1];
const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0;
let mountain = 0, ridge = 0, steep = 0, high = 0, valleyPass = 0, coast = 0, seaNear = 0, n = 0;
for (const [x, y] of path || []) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) return { len, direct, straightness: 1, mountain: 1, ridge: 1, steepShare: 1, highShare: 1, valleyPass: 0, coast: 0, seaNear: 1 };
mountain += clamp((elevation[i] - 0.56) * 2.6 + slope[i] * 0.95 + ridgeField[i] * 0.85 - valleyField[i] * 0.26 - (passSuitability?.[i] || 0) * 0.32);
ridge += ridgeField[i];
if (slope[i] > 0.44) steep++;
if (elevation[i] >= 0.70) high++;
valleyPass += clamp(valleyField[i] * 0.62 + (passSuitability?.[i] || 0) * 0.58 + basinField[i] * 0.18 + plain[i] * 0.12);
coast += coastalLowland[i];
let near = 0;
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) && sea[indexOf(nx, ny)]) near = 1;
}
seaNear += near;
n++;
}
return {
len,
direct,
straightness: direct / Math.max(1, len),
mountain: mountain / Math.max(1, n),
ridge: ridge / Math.max(1, n),
steepShare: steep / Math.max(1, n),
highShare: high / Math.max(1, n),
valleyPass: valleyPass / Math.max(1, n),
coast: coast / Math.max(1, n),
seaNear: seaNear / Math.max(1, n),
};
}
function localRouteAcceptableStrict(path, options = {}) {
if (!path?.length || path.length < 2) return false;
const st = localRouteTerrainStats(path);
const maxLength = options.maxLength ?? 74;
if (st.len > maxLength) return false;
if (st.highShare > 0 && !(options.allowPassCrossing && st.valleyPass > 0.62 && st.len <= 18)) return false;
// Coastal land roads are allowed; actual sea cells are already rejected by localRouteTerrainStats().
if (st.len > 26 && st.highShare > 0.34 && st.valleyPass < 0.34) return false;
if (st.len > 30 && st.steepShare > 0.42 && st.valleyPass < 0.36) return false;
if (st.len > 34 && st.mountain > 0.46 && st.valleyPass < 0.36) return false;
if (st.len > 26 && st.straightness > 0.82 && st.mountain > 0.38 && st.valleyPass < 0.40) return false;
if (st.len > 44 && st.ridge > 0.42 && st.valleyPass < 0.42) return false;
return true;
}
function sanitizeLocalRoads() {
const before = minorRoads.length;
const kept = [];
let pruned = 0;
for (const path of minorRoads) {
if (!path || path.length < 2) { pruned++; continue; }
const len = pathLengthCells(path);
const maxLength = len > 58 ? 68 : 78;
if (localRouteAcceptableStrict(path, { maxLength })) kept.push(path);
else pruned++;
}
minorRoads.length = 0;
minorRoads.push(...kept);
return { before, after: minorRoads.length, pruned };
}
function removePathSelfLoops(path) {
if (!path || path.length < 2) return path || [];
const out = [];
let pos = new Map();
function rebuildIndex() {
pos = new Map();
for (let k = 0; k < out.length; k++) pos.set(`${out[k][0]},${out[k][1]}`, k);
}
for (const p of path) {
if (!p || !inside(p[0], p[1]) || sea[indexOf(p[0], p[1])]) continue;
const key = `${p[0]},${p[1]}`;
if (out.length && out[out.length - 1][0] === p[0] && out[out.length - 1][1] === p[1]) continue;
if (pos.has(key)) {
const keep = pos.get(key) + 1;
if (out.length - keep > 2) {
out.length = keep;
rebuildIndex();
}
continue;
}
pos.set(key, out.length);
out.push([p[0], p[1]]);
}
return out;
}
function expresswayUrbanTangleScore(path) {
if (!path?.length) return { share: 0, maxCity: null, coreHits: 0 };
let bestShare = 0;
let bestCity = null;
let bestCoreHits = 0;
for (const city of modernCities) {
if ((city.population || 0) < 28000) continue;
const envelope = Math.max(18, (city.urbanRadius || 10) * ((city.population || 0) >= 220000 ? 2.15 : 2.55));
const core = Math.max(5.0, (city.coreRadius || 4) + 4.2);
let inEnvelope = 0;
let coreHits = 0;
for (const [x, y] of path) {
const d = Math.hypot(x - city.x, y - city.y);
if (d <= envelope) inEnvelope++;
if (d <= core) coreHits++;
}
const share = inEnvelope / Math.max(1, path.length);
if (share > bestShare) { bestShare = share; bestCity = city; bestCoreHits = coreHits; }
}
return { share: bestShare, maxCity: bestCity, coreHits: bestCoreHits };
}
function sanitizeExpresswayNetwork(debug = null) {
const before = expressways.length;
const cleaned = [];
let loopTrimmed = 0;
let urbanPruned = 0;
let shortPruned = 0;
const seen = new Set();
for (const path of expressways) {
const cleanedPath = removePathSelfLoops(path);
if (cleanedPath.length < (path?.length || 0)) loopTrimmed++;
const len = pathLengthCells(cleanedPath);
if (len < 12 || cleanedPath.length < 2) { shortPruned++; continue; }
const sig = cleanedPath.map((p, k) => k % 3 === 0 ? `${p[0]},${p[1]}` : '').filter(Boolean).join('|');
if (seen.has(sig)) continue;
seen.add(sig);
const tangle = expresswayUrbanTangleScore(cleanedPath);
// A through expressway may graze a city's fringe, but a path mostly inside
// one middle-sized urban envelope is rendered like an accidental city
// expressway. Drop those short/looping urban motorway fragments.
if (expressways.length > 1 && tangle.maxCity && (tangle.maxCity.population || 0) < 260000 && tangle.share > 0.46 && len < 105) {
urbanPruned++;
continue;
}
if (tangle.coreHits > (tangle.maxCity && (tangle.maxCity.population || 0) >= 260000 ? 8 : 3)) {
urbanPruned++;
continue;
}
cleaned.push(cleanedPath);
}
expressways.length = 0;
expressways.push(...cleaned);
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
if (debug) debug.expresswaySanitization = { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
}
function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) {
const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76));
if (nodes.length < 2) return;
const policy = fieldBackbonePolicy(mode);
const label = mode === "expressway" ? "expresswayCorridors" : "nationalCorridors";
const penalty = new Float32Array(SIZE);
const accepted = new Float32Array(SIZE);
for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
if (mode === "national") {
for (const path of expressways) markPathInfluence(penalty, path, 8, 0.16);
for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
}
const degree = new Map();
const keyOf = (p) => `${p.x},${p.y}`;
const { find, unite } = makeUnionFind(nodes, keyOf);
const pairs = [];
for (let a = 0; a < nodes.length; a++) {
for (let b = a + 1; b < nodes.length; b++) {
const A = nodes[a];
const B = nodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < (options.minDistance ?? (mode === "expressway" ? 42 : 13)) || d > (options.maxDistance ?? (mode === "expressway" ? 186 : 126))) continue;
const c = approximateLineCost(A, B, costField);
if (!Number.isFinite(c) || c >= INF) continue;
const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 115000 : 48000);
const roleNeed =
((A.role || "").includes("gateway") || (B.role || "").includes("gateway") ? (mode === "expressway" ? 0.42 : 0.22) : 0) +
((A.role || "").includes("port") || (B.role || "").includes("port") ? (mode === "expressway" ? 0.24 : 0.18) : 0) +
(A.regionId !== B.regionId ? (mode === "expressway" ? 0.26 : 0.16) : 0);
const localScore = (A.score + B.score) * 0.16;
const jitter = hash2(A.x + B.x * 3, A.y + B.y * 5, seed + (mode === "expressway" ? 18720 : 18721)) * 0.05;
pairs.push({ A, B, d, score: d * c / Math.max(0.35, demand + roleNeed + localScore) + jitter });
}
}
pairs.sort((a, b) => a.score - b.score);
const skip = { pairs: pairs.length, degree: 0, noPath: 0, length: 0, mountain: 0, mountainReasons: {}, acceptable: 0, acceptableReasons: {}, parallel: 0, added: 0 };
let connectedAdds = 0;
let extraAdds = 0;
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil(nodes.length / 4))) : Math.min(34, Math.max(16, Math.ceil(nodes.length * 0.46))));
const maxExtra = options.maxExtra ?? (mode === "expressway" ? 2 : 7);
const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 4);
for (const pair of pairs) {
if (outPaths.length >= maxAdded) break;
const ak = keyOf(pair.A);
const bk = keyOf(pair.B);
const connects = find(ak) !== find(bk);
if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; }
if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; }
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, {
curvePenalty: mode === "expressway" ? 0.125 : 0.065,
penaltyStrength: mode === "expressway" ? 7.20 : 1.05,
terrainFlowBias: mode === "expressway" ? 0.08 : 0.24,
surfaceGrain: mode === "expressway" ? 0.006 : 0.030,
relaxRadius: mode === "expressway" ? 1 : 2,
relaxLineWeight: mode === "expressway" ? 0.19 : 0.28,
maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42),
snapRadius: mode === "expressway" ? 3.5 : 2.5,
heuristicWeight: mode === "expressway" ? 0.72 : 0.50,
});
const len = pathLengthCells(path);
if (!path.length) { skip.noPath++; continue; }
if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; }
if (mode === "expressway") {
const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly");
if (!mountainCheck.ok) {
skip.mountain++;
countReason(skip, "mountainReasons", mountainCheck.reason);
continue;
}
const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true });
if (!acceptCheck.ok) {
skip.acceptable++;
countReason(skip, "acceptableReasons", acceptCheck.reason);
continue;
}
} else {
const mountainCheck = mountainRouteAssessment(path, "national");
if (!mountainCheck.ok) {
skip.mountain++;
countReason(skip, "mountainReasons", mountainCheck.reason);
continue;
}
const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, {
minLength: policy.minLength,
maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd,
maxHighElevationShare: policy.maxHighElevationShare,
maxSteepShare: policy.maxSteepShare,
});
if (!acceptCheck.ok) {
skip.acceptable++;
countReason(skip, "acceptableReasons", acceptCheck.reason);
continue;
}
}
const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18);
if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; }
outPaths.push(path);
markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), options.penaltyStrengthMark ?? (mode === "expressway" ? 2.10 : 0.30));
degree.set(ak, (degree.get(ak) || 0) + 1);
degree.set(bk, (degree.get(bk) || 0) + 1);
if (connects) {
unite(ak, bk);
connectedAdds++;
} else {
extraAdds++;
}
skip.added++;
debug[label].push({ from: pair.A.role, to: pair.B.role, length: Math.round(len), distance: Math.round(pair.d), score: Math.round(pair.score * 100) / 100, path });
}
debug[`${mode}AnchorCount`] = nodes.length;
debug[`${mode}ConnectedAdds`] = connectedAdds;
debug[`${mode}ExtraAdds`] = extraAdds;
debug[`${mode}SkipStats`] = skip;
}
function pathComesOutOfCity(path, city, anchor, mode = "national") {
if (!path?.length || !city || !anchor) return false;
const nearAnchorRadius = mode === "expressway" ? 5.5 : 4.5;
const exitRadius = mode === "expressway"
? Math.max(28, (city.urbanRadius || 12) * 1.75)
: Math.max(16, (city.urbanRadius || 9) * 1.18);
let touchesAnchor = false;
let leavesUrbanEnvelope = false;
for (const [x, y] of path) {
if (Math.hypot(x - anchor.x, y - anchor.y) <= nearAnchorRadius) touchesAnchor = true;
if (Math.hypot(x - city.x, y - city.y) >= exitRadius) leavesUrbanEnvelope = true;
if (touchesAnchor && leavesUrbanEnvelope) return true;
}
return false;
}
function pathServesCityCenter(path, city, mode = "national") {
if (!path?.length || !city) return false;
const nearRadius = mode === "expressway"
? Math.max(20, (city.urbanRadius || 12) * 1.55)
: Math.max(10, (city.urbanRadius || 9) * 0.95);
const exitRadius = mode === "expressway"
? Math.max(28, (city.urbanRadius || 12) * 1.75)
: Math.max(16, (city.urbanRadius || 9) * 1.18);
let near = false;
let far = false;
for (const [x, y] of path) {
const d = Math.hypot(x - city.x, y - city.y);
if (d <= nearRadius) near = true;
if (d >= exitRadius) far = true;
if (near && far) return true;
}
return false;
}
function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) {
const forceCost = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) { forceCost[i] = INF; continue; }
if (Number.isFinite(expressCost[i]) && expressCost[i] < INF) {
forceCost[i] = expressCost[i];
} else {
const nationalFallback = Number.isFinite(transportFields.national[i]) && transportFields.national[i] < INF ? transportFields.national[i] + 2.2 : 5.8;
forceCost[i] = nationalFallback + denseCorePenaltyAt(i) * 3.4 + preliminaryVillageInfluence[i] * 1.3 + Math.max(0, elevation[i] - 0.58) * 3.1 + slope[i] * 1.6 + ridgeField[i] * 1.4;
}
}
function forcedExpresswayAnchorForCity(city) {
const primary = cityPortalAnchors(city, "expressway")[0] || majorCitySuburbanAnchor(city) || portalSearchAroundPoint(city, "expressway", "urban-fringe-ic");
if (primary) return primary;
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 5));
const outer = Math.max(inner + 10, Math.round((city.urbanRadius || 13) * 2.3));
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] || forceCost[i] >= INF) continue;
const target = Math.max(inner + 3, Math.min(outer - 2, (city.urbanRadius || 13) * 1.65));
const score = -forceCost[i] - Math.abs(d - target) * 0.045 - denseCorePenaltyAt(i) * 1.5 - preliminaryVillageInfluence[i] * 0.8 - ridgeField[i] * 0.4 + urbanEdge[i] * 0.28 + hash2(x, y, seed + 18891 + city.x * 11 + city.y * 17) * 0.04;
if (!best || score > best.score) best = { x, y, score, role: "urban-fringe-ic", regionId: regionIdAt(x, y), population: city.population || 0, city };
}
}
return best;
}
const eligibleCities = modernCities
.filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
.sort((a, b) => (b.population || 0) - (a.population || 0));
const cityAnchors = eligibleCities
.map((city) => {
const anchor = forcedExpresswayAnchorForCity(city);
return anchor ? { ...anchor, city, role: "urban-fringe-ic", population: city.population || anchor.population || 0, score: (anchor.score || 0) + Math.sqrt(city.population || 80000) / 900 } : null;
})
.filter(Boolean);
if (cityAnchors.length < 1) return;
const logisticsTargets = expressAnchors
.filter((p) => p && (p.role === "port-fringe" || p.role === "external-gateway"))
.sort((a, b) => (b.score || 0) - (a.score || 0));
const otherTargets = [...cityAnchors, ...logisticsTargets]
.sort((a, b) => (b.score || 0) - (a.score || 0));
const penalty = new Float32Array(SIZE);
const accepted = new Float32Array(SIZE);
for (const path of expressways) {
markPathInfluence(penalty, path, 64, 9.20);
markPathInfluence(accepted, path, 64, 1.0);
}
let added = 0;
const stats = { cities: cityAnchors.length, covered: 0, noCandidates: 0, tried: 0, noPath: 0, length: 0, notOutbound: 0, mountain: 0, mountainReasons: {}, unacceptable: 0, unacceptableReasons: {}, parallel: 0 };
for (const anchor of cityAnchors) {
const city = anchor.city;
if (expressways.some((path) => pathServesCityCenter(path, city, "expressway"))) { stats.covered++; continue; }
const candidates = otherTargets
.filter((q) => q !== anchor && q.city !== city)
.map((q) => {
const d = Math.hypot(q.x - anchor.x, q.y - anchor.y);
const c0 = approximateLineCost(anchor, q, forceCost);
const c = Number.isFinite(c0) && c0 < INF ? c0 : 2.8;
return { q, d, c };
})
.filter((e) => e.d >= 32 && e.d <= 260)
.sort((a, b) => {
const aCity = a.q.role === "urban-fringe-ic" ? -22 : 0;
const bCity = b.q.role === "urban-fringe-ic" ? -22 : 0;
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
});
if (!candidates.length) stats.noCandidates++;
for (const opt of candidates.slice(0, 10)) {
stats.tried++;
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, {
curvePenalty: 0.13,
penaltyStrength: 13.80,
terrainFlowBias: 0.08,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.18,
maxPathLength: opt.d * 3.35 + 150,
snapRadius: 2.2,
heuristicWeight: 0.80,
searchPad: Math.ceil(Math.max(64, Math.min(132, opt.d * 0.72))),
});
const len = pathLengthCells(path);
if (!path.length) { stats.noPath++; continue; }
if (len < 20 || len > opt.d * 3.55 + 168) { stats.length++; continue; }
if (!pathComesOutOfCity(path, city, anchor, "expressway")) { stats.notOutbound++; continue; }
const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly");
if (!mountainCheck.ok) {
stats.mountain++;
countReason(stats, "mountainReasons", mountainCheck.reason);
continue;
}
const acceptCheck = expresswayRouteAssessment(path, { allowFallback: true, allowApproach: true });
if (!acceptCheck.ok) {
const risk = acceptCheck.risk;
if (risk.cityCoreShare > 0.36 || risk.villageCoreShare > 0.38 || risk.denseShare > 0.42) {
stats.unacceptable++;
countReason(stats, "unacceptableReasons", acceptCheck.reason);
continue;
}
}
const parallel = existingParallelShare(path, accepted, 0.010);
if (parallel > 0.105) { stats.parallel++; continue; }
expressways.push(path);
markPathInfluence(accepted, path, 86, 1.0);
addCorridorInfluencePenalty(penalty, path, 82, 12.40);
debug.expresswayCorridors.push({ from: "forced-city-intercity", city: city.name, to: opt.q.city?.name || opt.q.role, distance: Math.round(opt.d), length: Math.round(len), path });
added++;
break;
}
}
debug.forcedExpresswayCityConnections = (debug.forcedExpresswayCityConnections || 0) + added;
debug.forcedExpresswayCityStats = stats;
}
function ensureNationalCityOutbound(debug, nationalAnchors, nationalCost) {
const targetAnchors = nationalAnchors
.filter((p) => p && ["urban-portal", "market-portal", "port", "external-gateway", "large-village"].includes(p.role))
.sort((a, b) => (b.score || 0) - (a.score || 0));
const penalty = new Float32Array(SIZE);
const accepted = new Float32Array(SIZE);
for (const path of nationalRoads) {
markPathInfluence(penalty, path, 6, 0.42);
markPathInfluence(accepted, path, 6, 1.0);
}
for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
let added = 0;
const cities = modernCities
.filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital)
.sort((a, b) => (b.population || 0) - (a.population || 0));
for (const city of cities) {
const start = cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal");
if (!start) continue;
if (nationalRoads.some((path) => pathServesCityCenter(path, city, "national"))) continue;
const candidates = targetAnchors
.filter((q) => q !== start && q.city !== city && q.source !== city)
.map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) }))
.filter((e) => e.d >= 14 && e.d <= 126 && Number.isFinite(e.c) && e.c < INF)
.sort((a, b) => {
const aCity = a.q.role === "urban-portal" ? -10 : 0;
const bCity = b.q.role === "urban-portal" ? -10 : 0;
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
});
for (const opt of candidates.slice(0, 8)) {
const path = routeBetweenTrafficCandidates(start, opt.q, "national", nationalCost, penalty, {
curvePenalty: 0.060,
penaltyStrength: 1.04,
terrainFlowBias: 0.26,
surfaceGrain: 0.030,
relaxRadius: 2,
relaxLineWeight: 0.28,
maxPathLength: opt.d * 3.05 + 74,
snapRadius: 2.0,
heuristicWeight: 0.60,
searchPad: Math.ceil(Math.max(42, Math.min(110, opt.d * 0.62))),
});
const len = pathLengthCells(path);
if (len < 5 || len > opt.d * 3.15 + 82) continue;
if (!pathComesOutOfCity(path, city, start, "national")) continue;
if (routeTooStraightAcrossMountains(path, "national")) continue;
if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) continue;
const parallel = existingParallelShare(path, accepted, 0.18);
if (parallel > 0.58) continue;
nationalRoads.push(path);
markPathInfluence(accepted, path, 6, 1.0);
addCorridorInfluencePenalty(penalty, path, 6, 0.38);
debug.nationalCorridors.push({ from: "forced-city-outbound", city: city.name, to: opt.q.city?.name || opt.q.source?.name || opt.q.role, length: Math.round(len), path });
added++;
break;
}
}
debug.forcedNationalCityConnections = (debug.forcedNationalCityConnections || 0) + added;
}
function buildDensityFlowRoadSystem(debug) {
const expressAnchors = roadAnchorsForMode("expressway");
const nationalAnchors = roadAnchorsForMode("national");
debug.majorCitySuburbanAnchors = expressAnchors
.filter((p) => p.role === "urban-fringe-ic")
.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14);
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
maxNodes: 48,
maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))),
maxExtra: 0,
minDistance: 38,
maxDistance: 190,
maxDegree: 2,
parallelRadius: 84,
penaltyStrengthMark: 16.50,
});
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
// Recompute national flow with expressways already present. National roads
// are allowed to cross/approach motorways but are discouraged from becoming
// a duplicate motorway frontage road for long distances.
const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50);
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
maxNodes: 78,
maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))),
maxExtra: 8,
minDistance: 12,
maxDistance: 130,
maxDegree: 4,
parallelRadius: 6,
penaltyStrengthMark: 0.32,
});
// Guarantee light national access to large urban areas whose portals were
// deduped away. The target remains a portal/field anchor, not the city point.
const nationalInfluence = influenceFromPaths([...nationalRoads, ...externalRoads], 7);
const nationalPenalty = influenceFromPaths(nationalRoads, 6);
const nationalTargets = dedupeAnchors([...nationalAnchors, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
for (const city of modernCities.filter((c) => (c.population || 0) >= 90000).sort((a, b) => (b.population || 0) - (a.population || 0))) {
if ((nationalInfluence[indexOf(city.x, city.y)] || 0) > 0.18) continue;
const portals = cityPortalAnchors(city, "national");
const start = portals[0] || portalSearchAroundPoint(city, "national", "urban-portal");
if (!start) continue;
const target = nationalTargets
.filter((q) => Math.hypot(q.x - start.x, q.y - start.y) > 8)
.map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) }))
.filter((e) => e.d <= 82 && Number.isFinite(e.c) && e.c < INF)
.sort((a, b) => (a.d * a.c) - (b.d * b.c))[0];
if (!target) continue;
const path = routeBetweenTrafficCandidates(start, target.q, "national", nationalCost, nationalPenalty, {
curvePenalty: 0.060,
penaltyStrength: 0.82,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
relaxRadius: 2,
relaxLineWeight: 0.28,
maxPathLength: target.d * 2.6 + 38,
snapRadius: 2.5,
});
const len = pathLengthCells(path);
if (len >= 5 && len <= target.d * 2.7 + 44 && !routeTooStraightAcrossMountains(path, "national")) {
nationalRoads.push(path);
debug.nationalCorridors.push({ from: "city-portal-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28);
}
}
ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.62, radius: 2, minKeep: 10, shortLength: 18 });
// Pruning may remove short stubs that were serving as the only outward
// connection for a city. Run the hard city-outbound guarantees after pruning
// as the final road graph invariant.
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
sanitizeExpresswayNetwork(debug);
}
function rebuildRoadTransportByCorridors() {
const debug = {
strategy: "density-field-portals-plus-flow-backbone",
routePolicySummary: {
expresswayAcceptance: routePolicies.expresswayAcceptance,
fieldBackbone: routePolicies.fieldBackbone,
},
clearedGeneratedNational: nationalRoads.length,
clearedGeneratedExpressways: expressways.length,
majorCitySuburbanAnchors: [],
expresswayCorridors: [],
nationalCorridors: [],
};
nationalRoads.length = 0;
expressways.length = 0;
minorRoads.length = 0;
buildDensityFlowRoadSystem(debug);
return debug;
}
function addShortConnector(outPaths, a, b, mode = "local") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const costField = mode === "expressway" ? expresswayCorridorCost : mode === "national" ? transportFields.national : transportFields.local;
let path = [];
if (d > 2.2) {
path = routeBetweenTrafficCandidates(a, b, mode === "expressway" ? "expressway" : mode === "national" ? "national" : "local", costField, null, {
curvePenalty: mode === "expressway" ? 0.095 : mode === "national" ? 0.045 : 0.035,
penaltyStrength: 0.0,
terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28,
surfaceGrain: 0.020,
relaxRadius: 1,
relaxLineWeight: 0.22,
maxPathLength: d * 2.6 + 8,
snapRadius: 0.5,
searchPad: Math.max(6, Math.ceil(d + 4)),
});
} else {
const steps = Math.max(1, Math.ceil(d));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) return false;
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
}
if (path.length >= 2 && maxSegmentLength(path) <= 1.6 && pathLengthCells(path) <= d * 2.25 + 10) {
if (mode === "local" && !localRouteAcceptableStrict(path, { maxLength: Math.max(10, d * 2.45 + 12) })) return false;
outPaths.push(path);
return true;
}
return false;
}
function stitchRasterNearContacts() {
const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 };
function sampledCells(paths, step = 1) {
const cells = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const path = paths[pathId];
for (let k = 0; k < path.length; k += step) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
}
}
return cells;
}
function endpointListWithIds(paths) {
const out = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const p = paths[pathId];
if (!p || p.length < 2) continue;
out.push({ x: p[0][0], y: p[0][1], pathId });
const q = p[p.length - 1];
out.push({ x: q[0], y: q[1], pathId });
}
return out;
}
function nearestWithin(source, targets, radius, excludeSamePath = true) {
let best = null;
let bestD = radius + 1;
for (const t of targets) {
if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue;
const d = Math.hypot(source.x - t.x, source.y - t.y);
if (d > 0.01 && d < bestD) {
bestD = d;
best = t;
}
}
return best ? { target: best, d: bestD } : null;
}
function stitchEndpoints(paths, mode, targets, maxAdds, radius) {
let added = 0;
const endpoints = endpointListWithIds(paths);
for (const ep of endpoints) {
if (added >= maxAdds) break;
const near = nearestWithin(ep, targets, radius, true);
if (near && addShortConnector(paths, ep, near.target, mode)) added++;
}
return added;
}
const expresswayCells = sampledCells(expressways, 1);
const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1);
const localCells = sampledCells(minorRoads, 1);
debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 8, 34.0);
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0);
debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0);
debug.local += stitchEndpoints(minorRoads, "local", localCells, 260, 16.0);
// Also stitch near-miss interiors: visually crossing/adjacent roads that do
// not share a raster cell. This addresses line simplification and diagonal
// near-contact cases not caught by endpoint-only repair.
const allLocalTargets = [...nationalCells, ...localCells];
const candidates = sampledCells([...nationalRoads, ...minorRoads], 3)
.sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377));
for (const c of candidates) {
if (debug.broadNearMisses >= 260) break;
const near = nearestWithin(c, allLocalTargets, 4.75, true);
if (!near) continue;
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
const mode = out === nationalRoads ? "national" : "local";
if (addShortConnector(out, c, near.target, mode)) debug.broadNearMisses++;
}
// Expressway stitching can add new motorway cells; remove loopbacks before
// regenerating the IC/access layer so final spacing is applied to the
// simplified motorway graph.
sanitizeExpresswayNetwork();
generateInterchangesForExpressways();
return debug;
}
function stitchLongLocalBranches() {
const debug = { localToTrunk: 0, localToLocal: 0 };
function cells(paths, step = 2) {
const out = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const path = paths[pathId];
for (let k = 0; k < path.length; k += step) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId });
}
}
return out;
}
function endpoints(paths) {
const out = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const p = paths[pathId];
if (!p || p.length < 2) continue;
out.push({ x: p[0][0], y: p[0][1], pathId });
const q = p[p.length - 1];
out.push({ x: q[0], y: q[1], pathId });
}
return out;
}
function nearest(source, targets, radius, excludePath = null) {
let best = null;
let bestD = radius + 1;
for (const t of targets) {
if (excludePath != null && t.pathId === excludePath) continue;
const d = Math.hypot(source.x - t.x, source.y - t.y);
if (d > 0.01 && d < bestD) {
bestD = d;
best = t;
}
}
return best ? { ...best, d: bestD } : null;
}
const trunkCells = cells([...nationalRoads, ...externalRoads], 2);
const localCells = cells(minorRoads, 2);
const eps = endpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
for (const ep of eps) {
if (debug.localToTrunk >= 160) break;
const hit = nearest(ep, trunkCells, 20, null);
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++;
}
const eps2 = endpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493));
for (const ep of eps2) {
if (debug.localToLocal >= 150) break;
const hit = nearest(ep, localCells, 17, ep.pathId);
if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
}
return debug;
}
const corridorRoadNetworkDebug = rebuildRoadTransportByCorridors();
const transportDebugLayers = {
packedHeatmaps: true,
expresswayPotential: packDebugField(transportFields.expresswayPotential),
railPotential: packDebugField(transportFields.railPotential),
nationalRoadPotential: packDebugField(transportFields.nationalPotential),
slopeSeaPenalty: (() => {
const src = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42);
return packDebugField(src);
})(),
components: [],
repairedSegments: [],
unservedSettlements: [],
corridorRoadNetwork: {
strategy: corridorRoadNetworkDebug.strategy,
routePolicySummary: corridorRoadNetworkDebug.routePolicySummary,
clearedGeneratedNational: corridorRoadNetworkDebug.clearedGeneratedNational,
clearedGeneratedExpressways: corridorRoadNetworkDebug.clearedGeneratedExpressways,
majorCitySuburbanAnchors: corridorRoadNetworkDebug.majorCitySuburbanAnchors,
expresswayCorridorCount: corridorRoadNetworkDebug.expresswayCorridors.length,
nationalCorridorCount: corridorRoadNetworkDebug.nationalCorridors.length,
expresswayAnchorCount: corridorRoadNetworkDebug.expresswayAnchorCount,
nationalAnchorCount: corridorRoadNetworkDebug.nationalAnchorCount,
expresswaySkipStats: corridorRoadNetworkDebug.expresswaySkipStats,
nationalSkipStats: corridorRoadNetworkDebug.nationalSkipStats,
forcedExpresswayCityConnections: corridorRoadNetworkDebug.forcedExpresswayCityConnections || 0,
forcedNationalCityConnections: corridorRoadNetworkDebug.forcedNationalCityConnections || 0,
forcedExpresswayCityStats: corridorRoadNetworkDebug.forcedExpresswayCityStats,
},
};
// Corridor selection gives the road hierarchy, but a small topology pass is
// still needed to remove isolated fragments and near-miss components. Keep
// the repair budget bounded so it connects existing backbones rather than
// recreating the removed field-generated spaghetti layer.
for (const repair of [
repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, {
minImportance: 5.5,
minComponentCells: 10,
maxComponents: 8,
maxRepairs: 3,
maxRepairDistance: 145,
minRepairDistance: 18,
searchPad: 48,
penaltyRadius: 18,
penaltyStrength: 4.2,
curvePenalty: 0.13,
terrainFlowBias: 0.09,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.20,
highPotentialThreshold: 0.24,
}),
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 4.0,
minComponentCells: 7,
maxComponents: 14,
maxRepairs: 12,
maxRepairDistance: 96,
minRepairDistance: 8,
searchPad: 36,
penaltyRadius: 6,
penaltyStrength: 1.15,
curvePenalty: 0.060,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
relaxRadius: 2,
relaxLineWeight: 0.28,
highPotentialThreshold: 0.28,
}),
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 10.5,
minComponentCells: 18,
maxComponents: 6,
maxRepairs: 3,
maxRepairDistance: 105,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.16,
terrainFlowBias: 0.13,
surfaceGrain: 0.008,
relaxRadius: 1,
relaxLineWeight: 0.50,
highPotentialThreshold: 0.36,
}),
]) {
transportDebugLayers.components.push(...repair.components);
transportDebugLayers.repairedSegments.push(...repair.repairs);
}
function pruneShortTransportSegments(paths, minLength, minKeep = 1) {
const kept = [];
let pruned = 0;
const sorted = (paths || []).map((path, index) => ({ path, index, len: pathLengthCells(path) }));
for (const row of sorted) {
if ((row.len >= minLength || kept.length < minKeep) && (row.path?.length || 0) >= 2) kept.push(row.path);
else pruned++;
}
paths.length = 0;
paths.push(...kept);
return { pruned, kept: kept.length };
}
const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, {
maxAdded: 18,
maxTargetDistance: 58,
maxPathLength: 86,
curvePenalty: 0.060,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
relaxRadius: 2,
relaxLineWeight: 0.28,
targetRadius: 6,
penaltyRadius: 6,
addedPenalty: 0.24,
});
transportDebugLayers.endpointRepairs = [nationalEndpointRepair];
transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added);
const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, {
maxAdded: 3,
maxTargetDistance: 78,
maxPathLength: 128,
curvePenalty: 0.13,
terrainFlowBias: 0.09,
surfaceGrain: 0.006,
relaxRadius: 1,
relaxLineWeight: 0.20,
targetRadius: 9,
penaltyRadius: 20,
penaltyStrength: 4.4,
addedPenalty: 1.10,
});
transportDebugLayers.endpointRepairs.push(expressEndpointRepair);
transportDebugLayers.repairedSegments.push(...expressEndpointRepair.added);
transportDebugLayers.expresswaySanitizationAfterRepair = sanitizeExpresswayNetwork();
function downgradeShortNationalRoads(minLength = 18, protectedKeep = 8) {
const kept = [];
const downgraded = [];
for (const path of nationalRoads) {
if (!path || path.length < 2) continue;
const len = pathLengthCells(path);
if (len < minLength && nationalRoads.length - downgraded.length > protectedKeep) downgraded.push(path);
else kept.push(path);
}
nationalRoads.length = 0;
nationalRoads.push(...kept);
minorRoads.push(...downgraded);
return { threshold: minLength, downgraded: downgraded.length, kept: kept.length };
}
transportDebugLayers.shortNationalDowngrade = downgradeShortNationalRoads(18, 10);
transportDebugLayers.shortSegmentPruning = {
expressway: pruneShortTransportSegments(expressways, 12, 1),
national: pruneShortTransportSegments(nationalRoads, 9, 8),
};
const expressTerminalPrune = pruneDanglingTerminalSegments(expressways, "expressway", [...externalExpressways, ...nationalRoads], {
oneInvalidMax: 26,
bothInvalidMax: 42,
minKeep: 1,
targetRadius: 9,
});
const nationalTerminalPrune = pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...expressways, ...railways], {
oneInvalidMax: 14,
bothInvalidMax: 26,
minKeep: 12,
targetRadius: 6,
});
transportDebugLayers.graphCandidateNetworks = [];
transportDebugLayers.parallelPruning = [];
transportDebugLayers.prunedDanglingSegments = [expressTerminalPrune, nationalTerminalPrune];
transportDebugLayers.expresswaySanitizationFinal = sanitizeExpresswayNetwork();
function generateLocalRoadsForUnservedSettlements() {
const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk");
const candidates = [...villages, ...markets, ...ports]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
})
.filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34)
.sort((a, b) => b.score - a.score)
.slice(0, 150);
const localPenalty = new Float32Array(SIZE);
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 115) break;
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
let path = traceCorridorByCost(
start,
(x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045),
transportFields.local,
localPenalty,
{ curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
);
if (path.length < 4 || path.length > 86) continue;
if (!localRouteAcceptableStrict(path, { maxLength: 72 })) continue;
if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86, maxHighElevationShare: 0.34, maxSteepShare: 0.50 })) continue;
paths.push(path);
served.push(start);
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
}
return paths;
}
function runLocalAccessPass({
candidates,
accessInfluence,
localPenalty,
maxAdded = 80,
minSpacing = 3.5,
maxLength = 82,
debugMode = "local-access",
from = "unserved",
to = "network",
targetPredicate = null,
}) {
const served = [];
let added = 0;
for (const start of candidates) {
if (added >= maxAdded) break;
if (distanceToNearest(served, start.x, start.y) < minSpacing) continue;
let path = traceCorridorByCost(
start,
targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075),
transportFields.local,
localPenalty,
{ curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
);
path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
const strictMaxLength = Math.min(maxLength, 74);
const ok = path.length >= 4 && path.length <= maxLength && localRouteAcceptableStrict(path, { maxLength: strictMaxLength }) && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: strictMaxLength, maxHighElevationShare: 0.34, maxSteepShare: 0.50 });
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok });
if (!ok) continue;
minorRoads.push(path);
served.push(start);
added++;
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22);
}
return added;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, {
maxAdded: 36,
maxTargetDistance: 34,
curvePenalty: 0.055,
terrainFlowBias: 0.26,
surfaceGrain: 0.048,
relaxRadius: 2,
relaxLineWeight: 0.30,
targetRadius: 5,
});
transportDebugLayers.endpointRepairs.push(localEndpointRepair);
transportDebugLayers.repairedSegments.push(...localEndpointRepair.added);
const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], {
oneInvalidMax: 11,
bothInvalidMax: 18,
minKeep: 24,
});
transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune);
const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, {
threshold: 0.70,
radius: 1,
minKeep: 28,
shortLength: 13,
});
transportDebugLayers.parallelPruning.push(localParallelPruning);
transportDebugLayers.localSanitizationInitial = sanitizeLocalRoads();
function sampledNetworkCells(paths, step = 2) {
const cells = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const path = paths[pathId];
for (let k = 0; k < (path?.length || 0); k += step) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
}
}
return cells;
}
const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" }));
const generalRoadForIc = sampledNetworkCells([...nationalRoads, ...externalRoads, ...minorRoads], 2).map((q, idx) => ({ ...q, roadClass: idx < nationalForIc.length ? "national" : "local" }));
const nationalRoadIcIndex = makeSpatialIndex(nationalForIc, 16);
const generalRoadIcIndex = makeSpatialIndex(generalRoadForIc, 16);
function nearestRoadForIc(p, maxDistance = 24.0, preferNational = true) {
let best = null;
let bestD = maxDistance + 1;
const pool = preferNational ? nationalRoadIcIndex.near(p.x, p.y, maxDistance) : generalRoadIcIndex.near(p.x, p.y, maxDistance);
for (const q of pool) {
const d2 = squaredDistance(q.x, q.y, p.x, p.y);
if (d2 <= 1.2 * 1.2 || d2 >= bestD * bestD) continue;
const d = Math.sqrt(d2);
if (d < bestD) {
bestD = d;
best = q;
}
}
if (!best && preferNational) return nearestRoadForIc(p, maxDistance + 8, false);
return best ? { ...best, d: bestD } : null;
}
function pathCumulativeLengths(path) {
const cum = [0];
for (let k = 1; k < (path?.length || 0); k++) cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
return cum;
}
function pointAtPathDistance(path, cum, dist) {
if (!path?.length) return null;
if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 };
const total = cum[cum.length - 1] || 0;
if (dist >= total) {
const p = path[path.length - 1];
return { x: p[0], y: p[1], s: total };
}
let k = 1;
while (k < cum.length && cum[k] < dist) k++;
const a = path[k - 1];
const b = path[k];
const seg = Math.max(0.0001, cum[k] - cum[k - 1]);
const t = clamp((dist - cum[k - 1]) / seg);
return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist };
}
function meanFieldAround(field, x, y, radius = 8) {
let sum = 0, n = 0;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const i = indexOf(nx, ny);
if (sea[i]) continue;
const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius);
sum += (field?.[i] || 0) * (0.35 + w);
n += 0.35 + w;
}
}
return n ? sum / n : 0;
}
const icDemandCache = new Map();
function icDemandAt(p) {
if (!p || !inside(p.x, p.y)) return 0;
const key = `${p.x},${p.y}`;
const cached = icDemandCache.get(key);
if (cached !== undefined) return cached;
const demand = clamp(
meanFieldAround(settlementDemand, p.x, p.y, 11) * 0.64 +
meanFieldAround(preliminaryTownInfluence, p.x, p.y, 13) * 0.42 +
meanFieldAround(preliminaryVillageInfluence, p.x, p.y, 9) * 0.18 +
meanFieldAround(urbanEdge, p.x, p.y, 10) * 0.28 +
meanFieldAround(logisticsPreSuitability, p.x, p.y, 10) * 0.24
);
icDemandCache.set(key, demand);
return demand;
}
function icSpacingForPoint(p) {
const demand = icDemandAt(p);
// Sparse rural sections: 20-25 km. Dense urban fringe: 6-12 km.
return {
demand,
minGap: clamp(8.0 - demand * 3.0, 5.0, 8.0),
idealGap: clamp(22.0 - demand * 15.0, 7.0, 22.0),
maxGap: clamp(25.0 - demand * 10.5, 12.0, 25.0),
};
}
function icCoveragePenalty(p) {
const demand = icDemandAt(p);
if (demand <= 0.001 || !interchanges.length) return 0;
let nearest = Infinity;
for (const q of interchanges) nearest = Math.min(nearest, Math.hypot(q.x - p.x, q.y - p.y));
const desired = clamp(22.0 - demand * 14.0, 7.0, 22.0);
return nearest < desired * 0.72 ? (desired * 0.72 - nearest) * 0.040 : 0;
}
function interchangeScore(p, hit) {
if (!p || !inside(p.x, p.y)) return -INF;
const i = indexOf(p.x, p.y);
if (sea[i] || denseCorePenaltyAt(i) > 0.92) return -INF;
const demand = icDemandAt(p);
return (hit ? 0.72 - hit.d * 0.018 : -0.20) +
transportFields.expresswayPotential[i] * 0.46 +
urbanEdge[i] * 0.34 +
logisticsPreSuitability[i] * 0.28 +
demand * 0.42 +
settlementDemand[i] * 0.10 -
icCoveragePenalty(p) -
slope[i] * 0.34 -
ridgeField[i] * 0.28;
}
function directLandConnector(a, b) {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const steps = Math.max(1, Math.ceil(d));
const path = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) return [];
const i = indexOf(x, y);
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.local[i] >= INF) return [];
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
return path;
}
function addInterchange(p, hit) {
if (!p || !inside(p.x, p.y)) return false;
if (interchanges.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 5.0)) return false;
const i = indexOf(p.x, p.y);
if (sea[i]) return false;
const roadHit = hit || nearestRoadForIc(p, 55.0, false);
if (!roadHit) return false;
const connector = routeBetweenTrafficCandidates(p, roadHit, "local", transportFields.local, null, {
curvePenalty: 0.030,
penaltyStrength: 0,
terrainFlowBias: 0.24,
surfaceGrain: 0.025,
relaxRadius: 1,
relaxLineWeight: 0.20,
maxPathLength: roadHit.d * 3.20 + 18,
snapRadius: 0.5,
searchPad: Math.max(10, Math.ceil(roadHit.d + 10)),
});
const finalConnector = connector.length >= 2 ? connector : directLandConnector(p, roadHit);
if (finalConnector.length < 2) return false;
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i] + (roadHit ? 0.24 : 0), regionId: regionIdAt(p.x, p.y) });
icAccessRoads.push(finalConnector);
minorRoads.push(finalConnector);
return true;
}
function generateInterchangesForExpressways() {
const absoluteMinGap = 5.0;
const absoluteMaxGap = 25.0;
if (icAccessRoads.length) {
const oldAccess = new Set(icAccessRoads);
for (let k = minorRoads.length - 1; k >= 0; k--) if (oldAccess.has(minorRoads[k])) minorRoads.splice(k, 1);
}
interchanges.length = 0;
icAccessRoads.length = 0;
for (const path of expressways) {
if (!path || path.length < 3) continue;
const cum = pathCumulativeLengths(path);
const total = cum[cum.length - 1] || 0;
if (total < absoluteMinGap * 1.6) continue;
let lastS = Math.min(8, Math.max(4, total * 0.10));
while (lastS < total - absoluteMinGap) {
const current = pointAtPathDistance(path, cum, lastS) || { x: path[0][0], y: path[0][1] };
const spacing = icSpacingForPoint(current);
const minGap = spacing.minGap;
const maxGap = spacing.maxGap;
const idealGap = spacing.idealGap;
const windowStart = Math.min(total - absoluteMinGap, lastS + minGap);
const windowEnd = Math.min(total - absoluteMinGap, lastS + Math.min(absoluteMaxGap, maxGap));
if (windowEnd < windowStart) break;
let best = null;
for (let s = windowStart; s <= windowEnd; s += 1.5) {
const p = pointAtPathDistance(path, cum, s);
if (!p || !inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) continue;
const hit = nearestRoadForIc(p, 30.0 + icDemandAt(p) * 14.0, true);
const localSpacing = icSpacingForPoint(p);
const score = interchangeScore(p, hit) - Math.abs((s - lastS) - localSpacing.idealGap) * 0.020;
if (!best || score > best.score) best = { ...p, hit, score };
}
if (!best || best.score < -0.26) {
best = pointAtPathDistance(path, cum, Math.min(windowEnd, lastS + idealGap));
if (best) best.hit = nearestRoadForIc(best, 36.0 + icDemandAt(best) * 12.0, true);
}
if (!best) break;
const added = addInterchange(best, best.hit);
// Keep advancing even if the candidate could not be connected; otherwise
// a no-road window can trap the loop. The next window may find a road.
lastS = best.s || Math.min(windowEnd, lastS + idealGap);
if (!added && lastS < windowEnd) lastS = windowEnd;
}
}
}
generateInterchangesForExpressways();
function connectAllRoadNetworksFinal(maxAdds = 72) {
const debug = { beforeComponents: 0, afterComponents: 0, added: 0, failed: 0 };
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
const roadGroups = () => [minorRoads, nationalRoads, externalRoads, expressways, externalExpressways];
function groupCostField(groupIndex) {
if (groupIndex === 3 || groupIndex === 4) return expresswayCorridorCost;
if (groupIndex === 1 || groupIndex === 2) return transportFields.national;
return transportFields.local;
}
function splitPathToValidCells(path, costField, minCells = 2) {
const chunks = [];
let cur = [];
function valid(x, y) {
if (!inside(x, y)) return false;
const i = indexOf(x, y);
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
}
function pushPoint(x, y) {
if (!valid(x, y)) {
if (cur.length >= minCells) chunks.push(cur);
cur = [];
return;
}
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
}
for (let k = 0; k < (path?.length || 0); k++) {
const a = path[k];
const b = path[Math.min(k + 1, path.length - 1)];
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 > 0 && s === 0) continue;
const t = s / steps;
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
}
}
if (cur.length >= minCells) chunks.push(cur);
return chunks;
}
function normalizeRoadGroups() {
const groups = roadGroups();
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const costField = groupCostField(gi);
const out = [];
for (const path of group || []) out.push(...splitPathToValidCells(path, costField, 2));
group.length = 0;
group.push(...out);
}
}
normalizeRoadGroups();
const keepAliveSettlements = dedupeAnchors([
...modernCities,
...markets,
...villages.filter((p) => (p.population || 0) >= 600),
...ports,
], 4);
function pathNearKeptSettlement(path, radius = 7.5) {
if (!path?.length) return false;
for (const [x, y] of path) {
for (const p of keepAliveSettlements) {
if (Math.hypot(p.x - x, p.y - y) <= radius) return true;
}
}
return false;
}
function roadComponents() {
const occ = new Uint8Array(SIZE);
function markSegmented(path, fn) {
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
fn(x, y);
}
}
}
for (const group of roadGroups()) {
for (const path of group || []) {
markSegmented(path, (x, y) => {
if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1;
});
}
}
const seen = new Uint8Array(SIZE);
const comps = [];
for (let i = 0; i < SIZE; i++) {
if (!occ[i] || seen[i]) continue;
const queue = [i];
const cells = [];
seen[i] = 1;
let sx = 0, sy = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
sx += x; sy += y;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (!dx && !dy) continue;
if (dx * dx + dy * dy > 5) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
comps.push({ id: comps.length, cells, size: cells.length, cx: sx / Math.max(1, cells.length), cy: sy / Math.max(1, cells.length) });
}
comps.sort((a, b) => b.size - a.size);
return comps;
}
function sampleComponent(comp, limit = 96) {
const step = Math.max(1, Math.floor(comp.cells.length / limit));
const out = [];
for (let k = 0; k < comp.cells.length; k += step) {
const [x, y] = xyOf(comp.cells[k]);
out.push({ x, y, regionId: regionIdAt(x, y) });
if (out.length >= limit) break;
}
return out;
}
function bestAnchorPair(a, b) {
const as = sampleComponent(a, 72);
const bs = sampleComponent(b, 72);
let best = null;
for (const pa of as) for (const pb of bs) {
const d = Math.hypot(pa.x - pb.x, pa.y - pb.y);
if (d < 1.5) continue;
if (!best || d < best.d) best = { a: pa, b: pb, d };
}
return best;
}
function pathIsLand(path) {
return path?.length >= 2 && path.every(([x, y]) => inside(x, y) && !sea[indexOf(x, y)] && !highAltitudeRoadClosed(indexOf(x, y)) && transportFields.local[indexOf(x, y)] < INF);
}
let comps = roadComponents();
debug.beforeComponents = comps.length;
for (let pass = 0; pass < maxAdds && comps.length > 1; pass++) {
const main = comps[0];
let best = null;
const candidates = comps.slice(1, Math.min(comps.length, 18));
for (const comp of candidates) {
const pair = bestAnchorPair(main, comp);
if (!pair) continue;
const coastBridgeRisk = pair.d > 18 && approximateLineCost(pair.a, pair.b, transportFields.local) >= INF;
if (coastBridgeRisk) continue;
const score = pair.d / Math.sqrt(Math.max(4, comp.size));
if (!best || score < best.score) best = { comp, pair, score };
}
if (!best) break;
const { a, b, d } = best.pair;
const penalty = cachedInfluenceFromPaths(minorRoads, 3, `all-road-connect:${pass}`);
if (d > 72) {
debug.failed++;
comps.splice(comps.indexOf(best.comp), 1);
comps.push(best.comp);
continue;
}
const routed = routeBetweenTrafficCandidates(a, b, "local", transportFields.local, penalty, {
curvePenalty: 0.055,
penaltyStrength: 0.55,
terrainFlowBias: 0.34,
surfaceGrain: 0.040,
relaxRadius: 1,
relaxLineWeight: 0.18,
maxPathLength: d * 2.05 + 24,
snapRadius: 0.5,
heuristicWeight: 0.50,
searchPad: Math.ceil(Math.max(12, Math.min(72, d * 0.45 + 10))),
});
const rawPath = routed?.length ? routed : (d <= 12 ? directLandConnector(a, b) : []);
const connectedPath = rawPath?.length ? [[a.x, a.y], ...rawPath, [b.x, b.y]] : [];
const dedupedPath = [];
for (const pt of connectedPath) {
if (!dedupedPath.length || dedupedPath[dedupedPath.length - 1][0] !== pt[0] || dedupedPath[dedupedPath.length - 1][1] !== pt[1]) dedupedPath.push(pt);
}
const routeLen = pathLengthCells(dedupedPath);
const terrainOk = localRouteAcceptableStrict(dedupedPath, { maxLength: Math.min(76, Math.max(26, d * 2.10 + 24)) })
&& routeLen <= d * 2.15 + 24
&& maxSegmentLength(dedupedPath) <= 1.6
&& !routeTooStraightMountainOnly(dedupedPath);
if (!pathIsLand(dedupedPath) || !terrainOk) {
debug.failed++;
// Drop this candidate by marking it tiny relative to the main component for this pass.
comps.splice(comps.indexOf(best.comp), 1);
comps.push(best.comp);
continue;
}
const before = comps.length;
minorRoads.push(dedupedPath);
normalizeRoadGroups();
comps = roadComponents();
if (comps.length < before) debug.added++;
else debug.failed++;
}
for (let prunePass = 0; prunePass < 4; prunePass++) {
comps = roadComponents();
if (comps.length <= 1) break;
const mainMask = new Uint8Array(SIZE);
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy > 5) continue;
const nx = cx + dx, ny = cy + dy;
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
}
}
function touchesMain(path) {
let hit = 0, n = 0;
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
n++;
if (mainMask[indexOf(x, y)]) hit++;
}
}
return n > 0 && hit / n >= (prunePass === 0 ? 0.12 : 0.01);
}
function pruneGroup(group, key) {
const kept = [];
let pruned = 0;
for (const path of group || []) {
if (touchesMain(path) || pathNearKeptSettlement(path)) kept.push(path);
else pruned++;
}
group.length = 0;
group.push(...kept);
debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned;
}
if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
pruneGroup(minorRoads, "minor");
pruneGroup(nationalRoads, "national");
pruneGroup(externalRoads, "external");
pruneGroup(expressways, "expressway");
pruneGroup(externalExpressways, "externalExpressway");
}
normalizeRoadGroups();
// Absolute invariant for the rendered modern road graph: retain the largest
// terrain-valid component. Unconnectable orphan fragments are more harmful
// than being absent, because they force implausible long repairs.
let finalComps = roadComponents();
if (finalComps.length > 1) {
const main = new Uint8Array(SIZE);
for (const ci of finalComps[0].cells) main[ci] = 1;
function pruneToMain(group, key) {
const kept = [];
let pruned = 0;
for (const path of group || []) {
let hit = 0;
let n = 0;
for (const [x, y] of path || []) {
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
n++;
if (main[indexOf(x, y)]) hit++;
}
if (hit > 0 || n === 0 || pathNearKeptSettlement(path)) kept.push(path);
else pruned++;
}
group.length = 0;
group.push(...kept);
debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned;
}
if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
pruneToMain(minorRoads, "minor");
pruneToMain(nationalRoads, "national");
pruneToMain(externalRoads, "external");
pruneToMain(expressways, "expressway");
pruneToMain(externalExpressways, "externalExpressway");
normalizeRoadGroups();
finalComps = roadComponents();
}
debug.afterComponents = finalComps.length;
return debug;
}
return { transportDebugLayers, corridorRoadNetworkDebug, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal };
}