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, nearestNetworkPoint, packDebugField, pathCumulativeLengths, pathAverageField, pathEndpoints, pathLengthCells, pointAtPathDistance, sampledNetworkCells as sampledNetworkCellsBase, splitPathToValidCells as splitPathToValidCellsBase, 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 stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) { let added = 0; const endpoints = pathEndpoints(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 = nearestNetworkPoint(ep, targets, radius); if (near && addShortConnector(paths, ep, near.target, mode)) added++; } return added; } const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea); const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea); const localCells = sampledNetworkCellsBase(minorRoads, 1, sea); 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 = sampledNetworkCellsBase([...nationalRoads, ...minorRoads], 3, sea) .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 = nearestNetworkPoint(c, allLocalTargets, 4.75); 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 }; const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea); const localCells = sampledNetworkCellsBase(minorRoads, 2, sea); const eps = pathEndpoints(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 = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false }); if (!hit) continue; if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++; } const eps2 = pathEndpoints(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 = nearestNetworkPoint(ep, localCells, 17, { excludePath: 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) { return splitPathToValidCellsBase(path, (x, y) => { if (!inside(x, y)) return false; const i = indexOf(x, y); return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF; }, minCells); } 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 }; }