805 lines
36 KiB
JavaScript
805 lines
36 KiB
JavaScript
import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js";
|
|
import { pathLengthCells } from "./mapTransport.js";
|
|
|
|
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
|
|
|
|
function pathTouchesCell(path, x, y, radius = 0.65) {
|
|
if (!path || path.length < 1) return false;
|
|
for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true;
|
|
return false;
|
|
}
|
|
|
|
function anyPathTouches(paths, p, radius = 0.65) {
|
|
return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
|
|
}
|
|
|
|
function pathTerrainRuns(path, terrain = null) {
|
|
const sea = terrain?.sea;
|
|
const elevation = terrain?.elevation;
|
|
const ridgeField = terrain?.ridgeField;
|
|
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
|
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
|
let sampled = 0;
|
|
const visit = (x, y) => {
|
|
if (!inside(x, y)) {
|
|
seaRun++;
|
|
maxSeaRun = Math.max(maxSeaRun, seaRun);
|
|
tunnelRun = 0;
|
|
sampled++;
|
|
return;
|
|
}
|
|
const i = indexOf(x, y);
|
|
const isSea = Boolean(sea?.[i]);
|
|
// Use the same sensitive tunnel proxy as the main transport validator.
|
|
// Sampling every raster cell along each segment prevents smoothed or direct
|
|
// paths from hiding over-limit tunnel runs between sparse vertices.
|
|
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72);
|
|
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
|
|
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
|
|
sampled++;
|
|
};
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
if (!a || !b) continue;
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
for (let s = 0; s <= steps; s++) {
|
|
if (k > 1 && s === 0) continue;
|
|
const t = s / steps;
|
|
visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
|
}
|
|
}
|
|
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
|
|
return { maxSeaRun, maxTunnelRun, sampled };
|
|
}
|
|
|
|
function directPath(a, b, options = {}) {
|
|
if (!a || !b) return [];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
|
const out = [];
|
|
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 [];
|
|
if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]);
|
|
}
|
|
if (options.maxLength && pathLengthCells(out) > options.maxLength) return [];
|
|
if (options.terrain) {
|
|
const runs = pathTerrainRuns(out, options.terrain);
|
|
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
|
|
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function routeTerrainPath(a, b, terrain = null, options = {}) {
|
|
if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return [];
|
|
const sea = terrain?.sea;
|
|
const elevation = terrain?.elevation;
|
|
const slope = terrain?.slope;
|
|
const ridgeField = terrain?.ridgeField;
|
|
const start = indexOf(Math.round(a.x), Math.round(a.y));
|
|
const goal = indexOf(Math.round(b.x), Math.round(b.y));
|
|
if (sea?.[start] || sea?.[goal]) return [];
|
|
const straight = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const maxLength = options.maxLength ?? straight * 2.8 + 60;
|
|
const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5)));
|
|
const dist = new Float64Array(SIZE);
|
|
dist.fill(INF);
|
|
const prev = new Int32Array(SIZE);
|
|
prev.fill(-1);
|
|
const closed = new Uint8Array(SIZE);
|
|
const heap = new MinHeap();
|
|
dist[start] = 0;
|
|
prev[start] = start;
|
|
heap.push({ i: start, f: straight * 0.42 });
|
|
let hit = -1;
|
|
let expanded = 0;
|
|
while (heap.length && expanded++ < maxExpanded) {
|
|
const current = heap.pop();
|
|
if (!current || closed[current.i]) continue;
|
|
const cur = current.i;
|
|
closed[cur] = 1;
|
|
const [x, y] = xyOf(cur);
|
|
if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; }
|
|
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
|
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (closed[ni] || sea?.[ni]) continue;
|
|
const step = Math.hypot(dx, dy);
|
|
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05;
|
|
const nd = dist[cur] + step * Math.max(0.42, terrainCost);
|
|
if (nd >= dist[ni]) continue;
|
|
dist[ni] = nd;
|
|
prev[ni] = cur;
|
|
const h = Math.hypot(nx - b.x, ny - b.y) * 0.42;
|
|
heap.push({ i: ni, f: nd + h });
|
|
}
|
|
}
|
|
if (hit < 0) return [];
|
|
const path = [];
|
|
let cur = hit;
|
|
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
|
|
const [x, y] = xyOf(cur);
|
|
path.push([x, y]);
|
|
if (prev[cur] === cur) break;
|
|
cur = prev[cur];
|
|
}
|
|
path.reverse();
|
|
if (path.length < 2 || pathLengthCells(path) > maxLength) return [];
|
|
const runs = pathTerrainRuns(path, terrain);
|
|
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
|
|
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
|
|
return path;
|
|
}
|
|
|
|
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
|
let best = null;
|
|
for (const path of paths || []) {
|
|
for (const [x, y] of path || []) {
|
|
const d = Math.hypot(p.x - x, p.y - y);
|
|
if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function nearestEntity(entities, p, maxDistance = Infinity) {
|
|
let best = null;
|
|
for (const q of entities || []) {
|
|
if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue;
|
|
const d = Math.hypot(q.x - p.x, q.y - p.y);
|
|
if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function dedupePaths(paths, sampleStep = 2) {
|
|
const seen = new Set();
|
|
const kept = [];
|
|
for (const path of paths || []) {
|
|
if (!path || path.length < 2) continue;
|
|
const cleaned = [];
|
|
for (const pt of path) {
|
|
const x = Math.round(pt[0]);
|
|
const y = Math.round(pt[1]);
|
|
if (!inside(x, y)) continue;
|
|
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
|
|
}
|
|
if (cleaned.length < 2) continue;
|
|
const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|");
|
|
const f = sigFor(cleaned);
|
|
const r = sigFor([...cleaned].reverse());
|
|
const sig = f < r ? f : r;
|
|
if (seen.has(sig)) continue;
|
|
seen.add(sig);
|
|
kept.push(cleaned);
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") {
|
|
x = Math.round(x); y = Math.round(y);
|
|
if (!inside(x, y)) return false;
|
|
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
|
|
interchanges.push({ x, y, kind: "Interchange", score: 1, source });
|
|
return true;
|
|
}
|
|
|
|
function smoothPath(path, passes = 1) {
|
|
let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
if (cur.length < 3) break;
|
|
const next = [cur[0]];
|
|
for (let i = 1; i < cur.length - 1; i++) {
|
|
const [ax, ay] = cur[i - 1];
|
|
const [bx, by] = cur[i];
|
|
const [cx, cy] = cur[i + 1];
|
|
const x = Math.round((ax + bx * 2 + cx) / 4);
|
|
const y = Math.round((ay + by * 2 + cy) / 4);
|
|
if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]);
|
|
}
|
|
next.push(cur[cur.length - 1]);
|
|
cur = next;
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
function rebuildInfluence(paths, radius = 5) {
|
|
const field = new Float32Array(SIZE);
|
|
const r = Math.ceil(radius);
|
|
for (const path of paths || []) {
|
|
for (const [px, py] of path || []) {
|
|
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
|
const x = px + dx, y = py + dy;
|
|
if (!inside(x, y)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius) continue;
|
|
const i = indexOf(x, y);
|
|
field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius)));
|
|
}
|
|
}
|
|
}
|
|
return field;
|
|
}
|
|
|
|
export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) {
|
|
if (!features || !admin) return features;
|
|
const minorRoads = features.minorRoads || [];
|
|
const nationalRoads = features.nationalRoads || [];
|
|
const externalRoads = features.externalRoads || [];
|
|
const expressways = features.expressways || [];
|
|
const externalExpressways = features.externalExpressways || [];
|
|
const interchanges = features.interchanges || [];
|
|
const adminCenters = admin.adminCentersRaw || features.adminCenters || [];
|
|
const townsForNational = [
|
|
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
|
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
|
];
|
|
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 };
|
|
|
|
// Local roads after admin: every municipal office cell should lie on a road.
|
|
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
|
for (const center of adminCenters || []) {
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
debug.adminCentersChecked++;
|
|
const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads];
|
|
if (anyPathTouches(roadSet, center, 0.65)) continue;
|
|
const nearRoad = nearestPointOnPaths(roadSet, center, 22);
|
|
const nearSettlement = nearestEntity(settlementTargets, center, 18);
|
|
const target = nearRoad || nearSettlement;
|
|
let path = target ? directPath(center, target, { maxLength: 34 }) : [];
|
|
if (!path.length) {
|
|
const x = center.x, y = center.y;
|
|
const a = { x: Math.max(0, x - 2), y };
|
|
const b = { x: Math.min(MAP_W - 1, x + 2), y };
|
|
path = directPath(a, b, { maxLength: 8 });
|
|
}
|
|
if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) {
|
|
minorRoads.push(path);
|
|
debug.adminLocalRoadsAdded++;
|
|
}
|
|
}
|
|
|
|
// National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town.
|
|
function concatPaths(parts) {
|
|
const out = [];
|
|
for (const part of parts || []) {
|
|
if (!part || part.length < 2) continue;
|
|
for (const pt of part) {
|
|
if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function townWeight(p) {
|
|
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
|
|
}
|
|
|
|
function relayGeometryAcceptable(points, options = {}) {
|
|
const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
if (pts.length < 3) return true;
|
|
const first = pts[0];
|
|
const last = pts[pts.length - 1];
|
|
const vx = last.x - first.x;
|
|
const vy = last.y - first.y;
|
|
const direct = Math.hypot(vx, vy);
|
|
if (direct < 0.001) return false;
|
|
let via = 0;
|
|
for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
const maxDetour = options.maxDetour ?? 1.68;
|
|
if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false;
|
|
const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32)));
|
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
const p = pts[i];
|
|
const wx = p.x - first.x;
|
|
const wy = p.y - first.y;
|
|
const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct);
|
|
const projX = first.x + vx * t;
|
|
const projY = first.y + vy * t;
|
|
const offset = Math.hypot(p.x - projX, p.y - projY);
|
|
if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false;
|
|
if (offset > maxOffset) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function pathGeometryAcceptable(path, options = {}) {
|
|
if (!path || path.length < 3) return true;
|
|
const step = Math.max(1, Math.floor(path.length / 10));
|
|
const pts = [];
|
|
for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] });
|
|
const last = path[path.length - 1];
|
|
pts.push({ x: last[0], y: last[1] });
|
|
return relayGeometryAcceptable(pts, options);
|
|
}
|
|
function nearestTrunkOrHub(p, maxDistance = 85) {
|
|
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
|
|
if (trunk) return trunk;
|
|
return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance);
|
|
}
|
|
function buildTownChain(start, pool, maxHops = 7) {
|
|
const chain = [start];
|
|
let cur = start;
|
|
for (let hop = 1; hop < maxHops; hop++) {
|
|
let best = null;
|
|
for (const town of pool) {
|
|
if (chain.includes(town)) continue;
|
|
const d = Math.hypot(cur.x - town.x, cur.y - town.y);
|
|
if (d > 42) continue;
|
|
const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70);
|
|
if (!best || score < best.score) best = { town, d, score };
|
|
}
|
|
if (!best) break;
|
|
chain.push(best.town);
|
|
cur = best.town;
|
|
}
|
|
return chain;
|
|
}
|
|
function addNationalTownChains() {
|
|
let uncovered = townsForNational
|
|
.filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65))
|
|
.sort((a, b) => townWeight(b) - townWeight(a));
|
|
let chainsAdded = 0;
|
|
let townsCovered = 0;
|
|
while (uncovered.length) {
|
|
const start = uncovered.shift();
|
|
let chain = buildTownChain(start, uncovered, 7);
|
|
uncovered = uncovered.filter((town) => !chain.includes(town));
|
|
const parts = [];
|
|
let before = nearestTrunkOrHub(chain[0], 80);
|
|
let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
|
const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]];
|
|
if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) {
|
|
// The town-chain pass is a coverage fallback, not a mandate to drag a
|
|
// road through a remote off-axis waypoint. Collapse to a single spur
|
|
// when the waypoint chain would create a hooked or S-shaped route.
|
|
chain = [chain[0]];
|
|
before = nearestTrunkOrHub(chain[0], 80);
|
|
after = null;
|
|
}
|
|
if (before) {
|
|
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
for (let i = 1; i < chain.length; i++) {
|
|
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
|
|
const segmentPoints = [chain[i - 1], chain[i]];
|
|
if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue;
|
|
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
if (after) {
|
|
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
let path = concatPaths(parts);
|
|
if (path.length < 2) {
|
|
const target = nearestTrunkOrHub(chain[0], 90);
|
|
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
|
|
}
|
|
if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
|
nationalRoads.push(path);
|
|
chainsAdded++;
|
|
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
|
|
}
|
|
}
|
|
return { chainsAdded, townsCovered };
|
|
}
|
|
const chainDebug = addNationalTownChains();
|
|
debug.nationalTownChainsAdded = chainDebug.chainsAdded;
|
|
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
|
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
|
|
|
function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; }
|
|
function expresswayCityComponents(cities, radius = 8.0) {
|
|
const parent = new Map();
|
|
function find(k) {
|
|
const p = parent.get(k);
|
|
if (p === k) return k;
|
|
const r = find(p);
|
|
parent.set(k, r);
|
|
return r;
|
|
}
|
|
function unite(a, b) {
|
|
const ra = find(a), rb = find(b);
|
|
if (ra !== rb) parent.set(ra, rb);
|
|
}
|
|
for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city));
|
|
for (const path of [...expressways, ...externalExpressways]) {
|
|
const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius));
|
|
if (near.length >= 2) {
|
|
const first = majorCityKey(near[0]);
|
|
for (const city of near.slice(1)) unite(first, majorCityKey(city));
|
|
}
|
|
}
|
|
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
|
|
}
|
|
|
|
function suburbanExpresswayAnchorForCity(city, target = null) {
|
|
if (!city || !inside(city.x, city.y)) return null;
|
|
const sea = terrain?.sea;
|
|
const elevation = terrain?.elevation;
|
|
const slope = terrain?.slope;
|
|
const ridgeField = terrain?.ridgeField;
|
|
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6));
|
|
const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9));
|
|
let best = null;
|
|
for (let dy = -outer; dy <= outer; dy++) {
|
|
for (let dx = -outer; dx <= outer; dx++) {
|
|
const x = city.x + dx, y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < inner || d > outer) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea?.[i]) continue;
|
|
const radial = Math.abs(d - (inner + outer) * 0.52);
|
|
const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0;
|
|
const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75;
|
|
if (!best || score > best.score) best = { x, y, score };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function suburbanExpresswayStubForCity(city, preferredAnchor = null) {
|
|
if (!city || !inside(city.x, city.y)) return [];
|
|
const angles = [];
|
|
if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x));
|
|
for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0));
|
|
const seenAngles = new Set();
|
|
for (const angle of angles) {
|
|
const bucket = Math.round(angle * 100) / 100;
|
|
if (seenAngles.has(bucket)) continue;
|
|
seenAngles.add(bucket);
|
|
const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) };
|
|
const anchor = suburbanExpresswayAnchorForCity(city, hint);
|
|
if (!anchor) continue;
|
|
const minD = Math.max(20, (city.urbanRadius || 12) * 1.35);
|
|
const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65);
|
|
let bestEnd = null;
|
|
for (let d = minD; d <= maxD; d += 2) {
|
|
const x = Math.round(city.x + Math.cos(angle) * d);
|
|
const y = Math.round(city.y + Math.sin(angle) * d);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (terrain?.sea?.[i]) continue;
|
|
bestEnd = { x, y };
|
|
}
|
|
if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue;
|
|
const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y);
|
|
let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 });
|
|
if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 });
|
|
if (path.length >= 4) return path;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function expresswayServesCityFringe(city) {
|
|
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
|
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0);
|
|
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
|
let inBand = false;
|
|
let exits = false;
|
|
for (const [x, y] of path || []) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d >= inner && d <= outer) inBand = true;
|
|
if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true;
|
|
if (inBand && exits) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
|
|
features.expressways ||= [];
|
|
if (!cities.length) return result;
|
|
for (const city of cities) {
|
|
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
|
|
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
|
let target = nearestPointOnPaths(existing, city, 145);
|
|
if (!target) {
|
|
const other = cities.find((c) => c !== city && expresswayServesCityFringe(c));
|
|
target = other ? suburbanExpresswayAnchorForCity(other, city) : null;
|
|
}
|
|
if (!target) { result.noTarget++; continue; }
|
|
const anchor = suburbanExpresswayAnchorForCity(city, target);
|
|
if (!anchor) { result.noTarget++; continue; }
|
|
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
|
if (d < 4) { result.covered++; continue; }
|
|
let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
|
if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
|
if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
|
if (!path.length) {
|
|
const cityTargets = cities
|
|
.filter((other) => other !== city)
|
|
.map((other) => {
|
|
const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor);
|
|
return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null;
|
|
})
|
|
.filter(Boolean)
|
|
.filter((row) => row.d >= 16 && row.d <= 185)
|
|
.sort((a, b) => a.d - b.d);
|
|
for (const row of cityTargets.slice(0, 6)) {
|
|
let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
|
if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
|
if (candidate.length >= 4) { path = candidate; break; }
|
|
}
|
|
}
|
|
if (!path.length) path = suburbanExpresswayStubForCity(city, anchor);
|
|
if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; }
|
|
features.expressways.push(smoothPath(path, 1));
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function cityRailChain(minPopulation = 50000) {
|
|
const cities = (features.modernCities || [])
|
|
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
|
.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 };
|
|
if (cities.length < 2) return result;
|
|
const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
|
const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2));
|
|
if (!uncovered.length) return result;
|
|
const remaining = uncovered.slice();
|
|
let chain = [remaining.shift()];
|
|
while (remaining.length) {
|
|
const cur = chain[chain.length - 1];
|
|
let bestIndex = 0;
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y);
|
|
if (d < bestD) { bestD = d; bestIndex = i; }
|
|
}
|
|
chain.push(remaining.splice(bestIndex, 1)[0]);
|
|
}
|
|
const parts = [];
|
|
for (let i = 1; i < chain.length; i++) {
|
|
const a = chain[i - 1], b = chain[i];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
|
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
|
if (p.length) parts.push(p);
|
|
}
|
|
const path = concatPaths(parts);
|
|
if (path.length >= 2) {
|
|
features.railways = features.railways || [];
|
|
features.railways.push(smoothPath(path, 1));
|
|
result.chainsAdded = 1;
|
|
result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const railDebug = cityRailChain(50000);
|
|
debug.railCityChainsAdded = railDebug.chainsAdded;
|
|
debug.railCityChainCitiesCovered = railDebug.citiesCovered;
|
|
|
|
const expressDebug = ensureMajorCityExpresswayLinks(100000);
|
|
debug.expresswayMajorCityLinksAdded = expressDebug.added;
|
|
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
|
|
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
|
|
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
|
|
|
|
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
|
|
for (let i = 0; i < expressways.length; i++) {
|
|
const smoothed = smoothPath(expressways[i], 2);
|
|
if (smoothed.length >= 2) {
|
|
const runs = pathTerrainRuns(smoothed, terrain);
|
|
if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
|
|
expressways[i] = smoothed;
|
|
debug.expresswaysSmoothed++;
|
|
}
|
|
}
|
|
}
|
|
const expresswayBeforeTerrainPrune = expressways.length;
|
|
for (let i = expressways.length - 1; i >= 0; i--) {
|
|
const runs = pathTerrainRuns(expressways[i], terrain);
|
|
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
|
|
}
|
|
debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length;
|
|
|
|
function pointInsideCityNodeBuffer(x, y) {
|
|
for (const city of features.modernCities || []) {
|
|
if (!city || (city.population || 0) < 25000) continue;
|
|
const r = Math.max(4.2, (city.coreRadius || 3) + 1.6);
|
|
if (Math.hypot(x - city.x, y - city.y) <= r) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function splitExpresswayAwayFromCityNodes(path) {
|
|
const chunks = [];
|
|
let cur = [];
|
|
for (const [x, y] of path || []) {
|
|
if (pointInsideCityNodeBuffer(x, y)) {
|
|
if (cur.length >= 2) chunks.push(cur);
|
|
cur = [];
|
|
continue;
|
|
}
|
|
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
|
|
}
|
|
if (cur.length >= 2) chunks.push(cur);
|
|
return chunks.filter((chunk) => pathLengthCells(chunk) >= 12);
|
|
}
|
|
const expresswayBeforeCityNodePrune = expressways.length;
|
|
const separatedExpressways = [];
|
|
for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path));
|
|
expressways.length = 0;
|
|
expressways.push(...dedupePaths(separatedExpressways, 2));
|
|
debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length;
|
|
|
|
function connectNearbyExpresswayTermini() {
|
|
const result = { candidates: 0, added: 0, failed: 0 };
|
|
const expressGroups = [
|
|
{ key: "expressway", paths: expressways },
|
|
{ key: "externalExpressway", paths: externalExpressways },
|
|
];
|
|
const endpoints = [];
|
|
for (const group of expressGroups) {
|
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
|
const path = group.paths[pathIdx];
|
|
if (!path || path.length < 2) continue;
|
|
for (const end of [0, 1]) {
|
|
const raw = end === 0 ? path[0] : path[path.length - 1];
|
|
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
|
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue;
|
|
endpoints.push({ group: group.key, pathIdx, end, x, y });
|
|
}
|
|
}
|
|
}
|
|
const pairs = [];
|
|
for (let i = 0; i < endpoints.length; i++) {
|
|
const a = endpoints[i];
|
|
for (let j = i + 1; j < endpoints.length; j++) {
|
|
const b = endpoints[j];
|
|
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 3.0 || d > 24.0) continue;
|
|
pairs.push({ a, b, d, kind: "terminus-terminus" });
|
|
}
|
|
}
|
|
// Also snap a dead-end to the side of a nearby expressway if no terminal is
|
|
// close enough. This removes visible half-built expressway stubs without
|
|
// requiring every segment to be merged into a single polyline.
|
|
for (const a of endpoints) {
|
|
let best = null;
|
|
for (const group of expressGroups) {
|
|
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
|
if (a.group === group.key && a.pathIdx === pathIdx) continue;
|
|
const path = group.paths[pathIdx];
|
|
for (let k = 1; k < (path?.length || 0) - 1; k += 2) {
|
|
const [x, y] = path[k];
|
|
const d = Math.hypot(a.x - x, a.y - y);
|
|
if (d < 3.0 || d > 14.0) continue;
|
|
if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" };
|
|
}
|
|
}
|
|
}
|
|
if (best) pairs.push(best);
|
|
}
|
|
pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1));
|
|
const used = new Set();
|
|
for (const pair of pairs) {
|
|
if (result.added >= 10) break;
|
|
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
|
|
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
|
|
if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue;
|
|
result.candidates++;
|
|
let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
|
if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 });
|
|
if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; }
|
|
const runs = pathTerrainRuns(path, terrain);
|
|
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; }
|
|
expressways.push(smoothPath(path, 1));
|
|
used.add(ak);
|
|
if (pair.b.end >= 0) used.add(bk);
|
|
result.added++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini();
|
|
debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added;
|
|
debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates;
|
|
debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed;
|
|
|
|
function pointOnExpressway(p, radius = 1.5) {
|
|
return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
|
|
}
|
|
const icBeforePrune = interchanges.length;
|
|
const pairedInterchanges = [];
|
|
const pairedAccessRoads = [];
|
|
for (let i = 0; i < interchanges.length; i++) {
|
|
const ic = interchanges[i];
|
|
const access = (features.icAccessRoads || [])[i];
|
|
if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) {
|
|
pairedInterchanges.push(ic);
|
|
pairedAccessRoads.push(access);
|
|
}
|
|
}
|
|
interchanges.length = 0;
|
|
interchanges.push(...pairedInterchanges);
|
|
features.icAccessRoads = pairedAccessRoads;
|
|
debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length;
|
|
|
|
function ensureTerminalInterchangesWithAccess() {
|
|
const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 };
|
|
features.icAccessRoads ||= [];
|
|
const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
|
|
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
|
if (!path || path.length < 2) continue;
|
|
for (const raw of [path[0], path[path.length - 1]]) {
|
|
const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) };
|
|
result.endpointsChecked++;
|
|
if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue;
|
|
if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue;
|
|
const hit = nearestPointOnPaths(ordinaryRoads, p, 58);
|
|
let access = [];
|
|
if (hit) {
|
|
const d = Math.hypot(p.x - hit.x, p.y - hit.y);
|
|
access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 });
|
|
if (access.length >= 2) {
|
|
features.icAccessRoads.push(access);
|
|
features.minorRoads ||= [];
|
|
features.minorRoads.push(access);
|
|
result.accessAdded++;
|
|
}
|
|
}
|
|
addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access");
|
|
result.added++;
|
|
if (access.length < 2) result.withoutAccess++;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
const terminalIcDebug = ensureTerminalInterchangesWithAccess();
|
|
debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added;
|
|
debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded;
|
|
debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess;
|
|
|
|
features.minorRoads = dedupePaths(minorRoads, 2);
|
|
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
|
features.externalRoads = dedupePaths(externalRoads, 1);
|
|
features.expressways = dedupePaths(expressways, 2);
|
|
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
|
features.railways = dedupePaths(features.railways || [], 2);
|
|
features.interchanges = interchanges;
|
|
|
|
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
|
|
let finalAdminStubsAdded = 0;
|
|
for (const center of adminCenters || []) {
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue;
|
|
const x = Math.round(center.x), y = Math.round(center.y);
|
|
const candidates = [
|
|
[{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }],
|
|
[{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }],
|
|
];
|
|
const stub = candidates
|
|
.map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1]))
|
|
.find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]];
|
|
features.minorRoads.push(stub);
|
|
finalAdminStubsAdded++;
|
|
}
|
|
debug.finalAdminStubsAdded = finalAdminStubsAdded;
|
|
features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0);
|
|
features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0);
|
|
features.transportDebug = {
|
|
...(features.transportDebug || {}),
|
|
generationOrder: debug.order,
|
|
postAdminTransportFinalization: debug,
|
|
};
|
|
return features;
|
|
}
|