2213 lines
98 KiB
JavaScript
2213 lines
98 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,
|
|
createIncrementalPathInfluence,
|
|
fieldBackbonePolicy,
|
|
markPathInfluence as markPathInfluenceBase,
|
|
makeSpatialIndex,
|
|
makeUnionFind,
|
|
meanFieldAround as meanFieldAroundBase,
|
|
packDebugField,
|
|
pathCumulativeLengths,
|
|
pathAverageField,
|
|
pathLengthCells,
|
|
pointAtPathDistance,
|
|
sampledNetworkCells as sampledNetworkCellsBase,
|
|
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;
|
|
const ROAD_MODE = {
|
|
expressway: {
|
|
debugBucket: "expresswayCorridors",
|
|
costField: () => expresswayCorridorCost,
|
|
potentialField: () => transportFields.expresswayPotential,
|
|
routeOptions: {
|
|
curvePenalty: 0.125,
|
|
penaltyStrength: 7.20,
|
|
terrainFlowBias: 0.08,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.19,
|
|
snapRadius: 3.5,
|
|
heuristicWeight: 0.72,
|
|
},
|
|
backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 84, penaltyStrengthMark: 16.50 },
|
|
fieldPolicyMode: "expressway",
|
|
mountainMode: "expresswayMountainOnly",
|
|
connector: { curvePenalty: 0.095, terrainFlowBias: 0.12 },
|
|
},
|
|
national: {
|
|
debugBucket: "nationalCorridors",
|
|
costField: () => transportFields.national,
|
|
potentialField: () => transportFields.nationalPotential,
|
|
routeOptions: {
|
|
curvePenalty: 0.065,
|
|
penaltyStrength: 1.05,
|
|
terrainFlowBias: 0.24,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
snapRadius: 2.5,
|
|
heuristicWeight: 0.50,
|
|
},
|
|
backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 8, parallelRadius: 6, penaltyStrengthMark: 0.32 },
|
|
fieldPolicyMode: "national",
|
|
mountainMode: "national",
|
|
connector: { curvePenalty: 0.045, terrainFlowBias: 0.22 },
|
|
},
|
|
local: {
|
|
debugBucket: "localCorridors",
|
|
costField: () => transportFields.local,
|
|
potentialField: () => transportFields.localPotential,
|
|
routeOptions: {
|
|
curvePenalty: 0.035,
|
|
penaltyStrength: 0.90,
|
|
terrainFlowBias: 0.26,
|
|
surfaceGrain: 0.042,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.30,
|
|
snapRadius: 0.5,
|
|
heuristicWeight: 0.30,
|
|
},
|
|
connector: { curvePenalty: 0.035, terrainFlowBias: 0.28 },
|
|
},
|
|
};
|
|
|
|
function roadMode(mode) {
|
|
return ROAD_MODE[mode] || ROAD_MODE.national;
|
|
}
|
|
|
|
function recordSkipReason(stats, bucket, reason) {
|
|
stats[bucket]++;
|
|
countReason(stats, `${bucket}Reasons`, reason);
|
|
}
|
|
|
|
function recordCorridor(debug, mode, from, to, path, extra = {}) {
|
|
const bucket = roadMode(mode).debugBucket;
|
|
if (!debug[bucket]) debug[bucket] = [];
|
|
debug[bucket].push({
|
|
from,
|
|
to,
|
|
length: Math.round(pathLengthCells(path)),
|
|
...extra,
|
|
path,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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)
|
|
);
|
|
}
|
|
|
|
const markPathInfluence = (field, path, radius = 5, strength = 1) => markPathInfluenceBase(field, path, radius, strength, sea);
|
|
|
|
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, but cities over 100k should still
|
|
// have a suburban motorway contact point. The portal search stays outside
|
|
// the dense core, so the rendered line does not snap to the city dot.
|
|
.filter((c) => (c.population || 0) >= 100000 || 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", 11);
|
|
return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 30);
|
|
}
|
|
|
|
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", 40);
|
|
return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 68);
|
|
}
|
|
|
|
function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) {
|
|
const flow = new Float32Array(SIZE);
|
|
const nodes = anchors.slice(0, mode === "expressway" ? 18 : 42);
|
|
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 routeScoredPairs({
|
|
pairs,
|
|
mode,
|
|
outPaths,
|
|
costField,
|
|
potentialField,
|
|
penalty,
|
|
accepted,
|
|
debug,
|
|
options = {},
|
|
skip,
|
|
keyOf,
|
|
degree,
|
|
find,
|
|
unite,
|
|
}) {
|
|
const config = roadMode(mode);
|
|
const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode);
|
|
let connectedAdds = 0;
|
|
let extraAdds = 0;
|
|
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
|
|
const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
|
|
const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
|
|
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 routeOptions = {
|
|
...config.routeOptions,
|
|
maxPathLength: pair.d * (mode === "expressway" ? 2.45 : 2.28) + (mode === "expressway" ? 66 : 32),
|
|
...options.routeOptions,
|
|
};
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions);
|
|
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, config.mountainMode);
|
|
if (!mountainCheck.ok) {
|
|
recordSkipReason(skip, "mountain", mountainCheck.reason);
|
|
continue;
|
|
}
|
|
const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true });
|
|
if (!acceptCheck.ok) {
|
|
recordSkipReason(skip, "acceptable", acceptCheck.reason);
|
|
continue;
|
|
}
|
|
} else {
|
|
const mountainCheck = mountainRouteAssessment(path, config.mountainMode);
|
|
if (!mountainCheck.ok) {
|
|
recordSkipReason(skip, "mountain", 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) {
|
|
recordSkipReason(skip, "acceptable", 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 ?? config.backbone?.parallelRadius ?? 6, 1);
|
|
addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, options.penaltyStrengthMark ?? config.backbone?.penaltyStrengthMark ?? 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++;
|
|
recordCorridor(debug, mode, pair.A.role, pair.B.role, path, {
|
|
distance: Math.round(pair.d),
|
|
score: Math.round(pair.score * 100) / 100,
|
|
});
|
|
}
|
|
return { connectedAdds, extraAdds };
|
|
}
|
|
|
|
function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) {
|
|
const config = roadMode(mode);
|
|
const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 30 : 58));
|
|
if (nodes.length < 2) return;
|
|
const penalty = new Float32Array(SIZE);
|
|
const accepted = new Float32Array(SIZE);
|
|
for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 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 };
|
|
|
|
const { connectedAdds, extraAdds } = routeScoredPairs({
|
|
pairs,
|
|
mode,
|
|
outPaths,
|
|
costField,
|
|
potentialField,
|
|
penalty,
|
|
accepted,
|
|
debug,
|
|
options: { ...options, nodeCount: nodes.length },
|
|
skip,
|
|
keyOf,
|
|
degree,
|
|
find,
|
|
unite,
|
|
});
|
|
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 pathServesSuburbanAnchor(path, city, anchor, radius = 7.0) {
|
|
if (!path?.length || !city || !anchor) return false;
|
|
const coreAvoid = Math.max(4.5, (city.coreRadius || 3.5) + 1.5);
|
|
const fringeRadius = Math.max(radius, (city.urbanRadius || 12) * 0.42);
|
|
const exitRadius = Math.max(24, (city.urbanRadius || 12) * 1.55);
|
|
let nearAnchor = false;
|
|
let outsideCore = false;
|
|
let exitsEnvelope = false;
|
|
for (const [x, y] of path) {
|
|
const da = Math.hypot(x - anchor.x, y - anchor.y);
|
|
const dc = Math.hypot(x - city.x, y - city.y);
|
|
if (da <= fringeRadius) nearAnchor = true;
|
|
if (dc >= coreAvoid) outsideCore = true;
|
|
if (dc >= exitRadius) exitsEnvelope = true;
|
|
if (nearAnchor && outsideCore && exitsEnvelope) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureCityOutboundConnections({
|
|
debug,
|
|
mode,
|
|
cities,
|
|
startForCity,
|
|
targetAnchors,
|
|
costField,
|
|
penalty,
|
|
paths,
|
|
acceptedPaths = [],
|
|
maxTargets = 5,
|
|
minDistance = 14,
|
|
maxDistance = 126,
|
|
routeOptions = {},
|
|
acceptable,
|
|
recordFrom,
|
|
counterKey,
|
|
}) {
|
|
let added = 0;
|
|
const config = roadMode(mode);
|
|
const accepted = new Float32Array(SIZE);
|
|
for (const path of paths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0);
|
|
for (const path of acceptedPaths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, mode === "national" ? 0.55 : 1.0);
|
|
for (const city of cities) {
|
|
const start = startForCity(city);
|
|
if (!start) continue;
|
|
if (paths.some((path) => mode === "expressway" ? pathServesSuburbanAnchor(path, city, start, 7.0) : pathServesCityCenter(path, city, mode))) 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, costField) }))
|
|
.filter((e) => e.d >= minDistance && e.d <= maxDistance && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => {
|
|
const aCity = a.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0;
|
|
const bCity = b.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0;
|
|
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
|
|
});
|
|
for (const opt of candidates.slice(0, maxTargets)) {
|
|
const path = routeBetweenTrafficCandidates(start, opt.q, mode, costField, penalty, {
|
|
...config.routeOptions,
|
|
...routeOptions,
|
|
maxPathLength: typeof routeOptions.maxPathLength === "function" ? routeOptions.maxPathLength(opt.d) : routeOptions.maxPathLength ?? opt.d * 3.05 + 74,
|
|
searchPad: typeof routeOptions.searchPad === "function" ? routeOptions.searchPad(opt.d) : routeOptions.searchPad,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (!acceptable(path, len, opt, city, start, accepted)) continue;
|
|
paths.push(path);
|
|
markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0);
|
|
addCorridorInfluencePenalty(penalty, path, config.backbone?.parallelRadius ?? 6, mode === "expressway" ? 1.10 : 0.38);
|
|
recordCorridor(debug, mode, recordFrom, opt.q.city?.name || opt.q.source?.name || opt.q.role, path, { city: city.name });
|
|
added++;
|
|
break;
|
|
}
|
|
}
|
|
if (counterKey) debug[counterKey] = (debug[counterKey] || 0) + added;
|
|
return added;
|
|
}
|
|
|
|
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) >= 100000 || 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) => pathServesSuburbanAnchor(path, city, anchor, 7.0))) { 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, 6)) {
|
|
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);
|
|
recordCorridor(debug, "expressway", "forced-city-intercity", opt.q.city?.name || opt.q.role, path, { city: city.name, distance: Math.round(opt.d) });
|
|
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);
|
|
for (const path of nationalRoads) {
|
|
markPathInfluence(penalty, path, 6, 0.42);
|
|
}
|
|
const cities = modernCities
|
|
.filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
ensureCityOutboundConnections({
|
|
debug,
|
|
mode: "national",
|
|
cities,
|
|
startForCity: (city) => cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"),
|
|
targetAnchors,
|
|
costField: nationalCost,
|
|
penalty,
|
|
paths: nationalRoads,
|
|
acceptedPaths: externalRoads,
|
|
minDistance: 14,
|
|
maxDistance: 126,
|
|
maxTargets: 8,
|
|
routeOptions: {
|
|
curvePenalty: 0.060,
|
|
penaltyStrength: 1.04,
|
|
terrainFlowBias: 0.26,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
maxPathLength: (d) => d * 3.05 + 74,
|
|
snapRadius: 2.0,
|
|
heuristicWeight: 0.60,
|
|
searchPad: (d) => Math.ceil(Math.max(42, Math.min(110, d * 0.62))),
|
|
},
|
|
acceptable: (path, len, opt, city, start, accepted) => {
|
|
if (len < 5 || len > opt.d * 3.15 + 82) return false;
|
|
if (!pathComesOutOfCity(path, city, start, "national")) return false;
|
|
if (routeTooStraightAcrossMountains(path, "national")) return false;
|
|
if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) return false;
|
|
const parallel = existingParallelShare(path, accepted, 0.18);
|
|
if (parallel > 0.58) return false;
|
|
return true;
|
|
},
|
|
recordFrom: "forced-city-outbound",
|
|
counterKey: "forcedNationalCityConnections",
|
|
});
|
|
}
|
|
|
|
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, 10);
|
|
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
|
|
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
|
|
...roadMode("expressway").backbone,
|
|
maxNodes: 34,
|
|
maxAdded: Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))),
|
|
});
|
|
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, 34);
|
|
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
|
|
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
|
|
...roadMode("national").backbone,
|
|
maxNodes: 58,
|
|
maxAdded: Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))),
|
|
});
|
|
|
|
// 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);
|
|
recordCorridor(debug, "national", "city-portal-guarantee", target.q.role, path, { city: city.name });
|
|
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 config = roadMode(mode);
|
|
const costField = config.costField();
|
|
let path = [];
|
|
if (d > 2.2) {
|
|
path = routeBetweenTrafficCandidates(a, b, mode, costField, null, {
|
|
curvePenalty: config.connector.curvePenalty,
|
|
penaltyStrength: 0.0,
|
|
terrainFlowBias: config.connector.terrainFlowBias,
|
|
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, probability = 1) {
|
|
let added = 0;
|
|
const endpoints = endpointListWithIds(paths);
|
|
for (const ep of endpoints) {
|
|
if (added >= maxAdds) break;
|
|
if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue;
|
|
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, 14, 46.0, 0.62);
|
|
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: 6,
|
|
maxRepairs: 2,
|
|
maxRepairDistance: 125,
|
|
minRepairDistance: 18,
|
|
searchPad: 38,
|
|
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: 10,
|
|
maxRepairs: 7,
|
|
maxRepairDistance: 82,
|
|
minRepairDistance: 8,
|
|
searchPad: 28,
|
|
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: 5,
|
|
maxRepairs: 2,
|
|
maxRepairDistance: 92,
|
|
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: 11,
|
|
maxTargetDistance: 50,
|
|
maxPathLength: 74,
|
|
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: 2,
|
|
maxTargetDistance: 68,
|
|
maxPathLength: 110,
|
|
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, 92);
|
|
const localPenaltyAccumulator = createIncrementalPathInfluence([], 4, { sea });
|
|
const localPenalty = localPenaltyAccumulator.field;
|
|
const paths = [];
|
|
const served = [];
|
|
for (const start of candidates) {
|
|
if (paths.length >= 72) 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: Math.floor(SIZE * 0.56) }
|
|
);
|
|
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);
|
|
localPenaltyAccumulator.add(path, 0.22, 4);
|
|
}
|
|
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.50), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
|
|
);
|
|
if (path.length <= 48) 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) markPathInfluence(accessInfluence, path, 5, 0.22);
|
|
}
|
|
return added;
|
|
}
|
|
|
|
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
|
|
const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, {
|
|
maxAdded: 22,
|
|
maxTargetDistance: 30,
|
|
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();
|
|
const sampledNetworkCells = (paths, step = 2) => sampledNetworkCellsBase(paths, step, sea);
|
|
|
|
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;
|
|
}
|
|
|
|
const meanFieldAround = (field, x, y, radius = 8) => meanFieldAroundBase(field, x, y, radius, sea);
|
|
|
|
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;
|
|
const firstEndpoint = { x: path[0][0], y: path[0][1], s: 0, terminal: true };
|
|
const lastEndpoint = { x: path[path.length - 1][0], y: path[path.length - 1][1], s: total, terminal: true };
|
|
addInterchange(firstEndpoint, nearestRoadForIc(firstEndpoint, 38.0 + icDemandAt(firstEndpoint) * 14.0, true));
|
|
addInterchange(lastEndpoint, nearestRoadForIc(lastEndpoint, 38.0 + icDemandAt(lastEndpoint) * 14.0, true));
|
|
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 };
|
|
}
|