898 lines
34 KiB
JavaScript
898 lines
34 KiB
JavaScript
import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep } from "./mapUtils.js";
|
|
import { generateMap } from "./mapPipeline.js";
|
|
import { LANDUSE } from "./landuseCodes.js";
|
|
|
|
export const PATCH_MIN_WIDTH = 48;
|
|
export const PATCH_MIN_HEIGHT = 48;
|
|
export const PATCH_MIN_AREA = 3000;
|
|
|
|
const POINT_LAYER_KEYS = [
|
|
"villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
|
|
"ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
|
|
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
|
"externalGateways", "prefectureRegions",
|
|
];
|
|
|
|
const PATH_LAYER_KEYS = [
|
|
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
|
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
|
"icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
|
|
];
|
|
|
|
const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]);
|
|
const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]);
|
|
const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
|
|
|
|
const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
|
|
|
|
const ID_FIELD_OFFSETS = new Map([
|
|
["adminId", 100000],
|
|
["municipalityId", 100000],
|
|
["prefectureRegionId", 200000],
|
|
["regionId", 300000],
|
|
["naturalCompartmentId", 400000],
|
|
["watershedId", 500000],
|
|
]);
|
|
|
|
const DISCRETE_FIELD_NAMES = new Set([
|
|
"sea", "ocean", "lake", "prefectureMask", "landMask", "landuse",
|
|
"adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
|
|
]);
|
|
|
|
const SKIP_CELL_FIELDS = new Set(["flowTo"]);
|
|
|
|
function worldIndex(world, x, y) {
|
|
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
|
return y * world.width + x;
|
|
}
|
|
|
|
function sourceIndex(x, y) {
|
|
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1;
|
|
return y * MAP_W + x;
|
|
}
|
|
|
|
function isCellField(value) {
|
|
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
|
}
|
|
|
|
function rectWidth(rect) {
|
|
return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
|
|
}
|
|
|
|
function rectHeight(rect) {
|
|
return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
|
|
}
|
|
|
|
function rectArea(rect) {
|
|
return rectWidth(rect) * rectHeight(rect);
|
|
}
|
|
|
|
function normalizeRect(rect) {
|
|
if (!rect) return null;
|
|
const x0 = Math.floor(Math.min(rect.x0, rect.x1));
|
|
const y0 = Math.floor(Math.min(rect.y0, rect.y1));
|
|
const x1 = Math.ceil(Math.max(rect.x0, rect.x1));
|
|
const y1 = Math.ceil(Math.max(rect.y0, rect.y1));
|
|
return { x0, y0, x1, y1 };
|
|
}
|
|
|
|
function insideRect(x, y, rect) {
|
|
return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
|
|
}
|
|
|
|
function expandRect(rect, margin, world = null) {
|
|
return {
|
|
x0: Math.max(0, rect.x0 - margin),
|
|
y0: Math.max(0, rect.y0 - margin),
|
|
x1: Math.min(world?.width ?? Infinity, rect.x1 + margin),
|
|
y1: Math.min(world?.height ?? Infinity, rect.y1 + margin),
|
|
};
|
|
}
|
|
|
|
function distanceToRectEdge(x, y, rect) {
|
|
return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
|
|
}
|
|
|
|
function defaultForField(name, Constructor) {
|
|
if (name === "sea" || name === "ocean") return 1;
|
|
if (name === "elevation") return 0.08;
|
|
if (ID_FIELD_OFFSETS.has(name)) return -1;
|
|
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
|
return 0;
|
|
}
|
|
|
|
function ensureWorldField(world, name, source) {
|
|
if (!source || !isCellField(source)) return null;
|
|
const Constructor = source.constructor;
|
|
const expected = world.width * world.height;
|
|
if (!world.fields[name] || world.fields[name].length !== expected) {
|
|
world.fields[name] = new Constructor(expected);
|
|
const fallback = defaultForField(name, Constructor);
|
|
if (fallback !== 0) world.fields[name].fill(fallback);
|
|
}
|
|
return world.fields[name];
|
|
}
|
|
|
|
export function clipPatchRect(rect, world) {
|
|
const normalized = normalizeRect(rect);
|
|
if (!normalized || !world) return null;
|
|
return {
|
|
x0: Math.min(Math.max(normalized.x0, 0), world.width),
|
|
y0: Math.min(Math.max(normalized.y0, 0), world.height),
|
|
x1: Math.min(Math.max(normalized.x1, 0), world.width),
|
|
y1: Math.min(Math.max(normalized.y1, 0), world.height),
|
|
};
|
|
}
|
|
|
|
export function validatePatchRect(rect, world) {
|
|
const clipped = clipPatchRect(rect, world);
|
|
if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
|
|
const width = rectWidth(clipped);
|
|
const height = rectHeight(clipped);
|
|
const area = width * height;
|
|
if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
|
|
const parts = [];
|
|
if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
|
|
if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`);
|
|
return {
|
|
ok: false,
|
|
rect: clipped,
|
|
width,
|
|
height,
|
|
area,
|
|
reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`,
|
|
};
|
|
}
|
|
if (area < PATCH_MIN_AREA) {
|
|
return {
|
|
ok: false,
|
|
rect: clipped,
|
|
width,
|
|
height,
|
|
area,
|
|
reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`,
|
|
};
|
|
}
|
|
return { ok: true, rect: clipped, width, height, area, reason: "" };
|
|
}
|
|
|
|
export function buildPatchRects(userRect, world = null) {
|
|
const coreRect = normalizeRect(userRect);
|
|
const width = rectWidth(coreRect);
|
|
const height = rectHeight(coreRect);
|
|
const shortSide = Math.max(1, Math.min(width, height));
|
|
const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
|
|
const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
|
|
const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
|
|
const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
|
|
const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
|
|
const writeRect = expandRect(coreRect, writeMargin, world);
|
|
const repairRect = expandRect(coreRect, repairMargin, world);
|
|
return {
|
|
coreRect,
|
|
writeRect,
|
|
repairRect,
|
|
contextRect: repairRect,
|
|
blendRect: coreRect,
|
|
userRect: writeRect,
|
|
selectedRect: coreRect,
|
|
writeMargin,
|
|
repairMargin,
|
|
outerMargin: writeMargin,
|
|
innerMargin: 0,
|
|
};
|
|
}
|
|
|
|
function patchAlpha(x, y, rects, seed = 0) {
|
|
const writeRect = rects.writeRect || rects.userRect;
|
|
if (!insideRect(x, y, writeRect)) return 0;
|
|
const edge = distanceToRectEdge(x, y, writeRect);
|
|
const margin = Math.max(1, rects.writeMargin || 1);
|
|
const low = hash2(Math.floor(x / 18), Math.floor(y / 18), seed ^ 0x7153a9d1) - 0.5;
|
|
const mid = hash2(Math.floor(x / 7), Math.floor(y / 7), seed ^ 0x9e3779b9) - 0.5;
|
|
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
|
|
const base = smoothstep(clamp(noisyEdge / margin));
|
|
// Keep the expanded repair band as the actual seam. The user's selected core
|
|
// is still dominant, but the write edge is irregular, so coastlines and land-use
|
|
// no longer inherit the rectangular user selection as a hard boundary.
|
|
return clamp(base);
|
|
}
|
|
|
|
function sourceWindowForRects(rects) {
|
|
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
|
const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
|
|
return {
|
|
worldCenterX: cx,
|
|
worldCenterY: cy,
|
|
sourceCenterX: (MAP_W - 1) / 2,
|
|
sourceCenterY: (MAP_H - 1) / 2,
|
|
};
|
|
}
|
|
|
|
function sourceCoordForWorld(window, x, y) {
|
|
return {
|
|
x: Math.round(x - window.worldCenterX + window.sourceCenterX),
|
|
y: Math.round(y - window.worldCenterY + window.sourceCenterY),
|
|
};
|
|
}
|
|
|
|
function worldCoordForSource(window, sx, sy) {
|
|
return {
|
|
x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
|
|
y: Math.round(sy - window.sourceCenterY + window.worldCenterY),
|
|
};
|
|
}
|
|
|
|
function fieldIdOffset(name, seed) {
|
|
const base = ID_FIELD_OFFSETS.get(name) || 0;
|
|
if (!base) return 0;
|
|
return base + ((seed >>> 0) % 997) * 10000;
|
|
}
|
|
|
|
function copyFullPipelineFields(world, candidate, rects, seed) {
|
|
const window = sourceWindowForRects(rects);
|
|
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
|
let updatedCells = 0;
|
|
let coastCellsChanged = 0;
|
|
let terrainCellsFullyReplaced = 0;
|
|
let naturalRegionsUpdated = 0;
|
|
let adminCellsReassigned = 0;
|
|
let landUseCellsUpdated = 0;
|
|
|
|
for (const [name, source] of Object.entries(candidate || {})) {
|
|
if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue;
|
|
const dest = ensureWorldField(world, name, source);
|
|
if (!dest) continue;
|
|
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
|
|
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
|
|
const idOffset = fieldIdOffset(name, seed);
|
|
|
|
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
|
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
|
const wi = worldIndex(world, x, y);
|
|
if (wi < 0) continue;
|
|
const s = sourceCoordForWorld(window, x, y);
|
|
const si = sourceIndex(s.x, s.y);
|
|
if (si < 0) continue;
|
|
const alpha = patchAlpha(x, y, rects, seed);
|
|
if (alpha <= 0.005) continue;
|
|
|
|
if (isDiscrete) {
|
|
const thresholdNoise = hash2(Math.floor(x / 6), Math.floor(y / 6), seed ^ 0x21f0aaad) - 0.5;
|
|
const threshold = clamp(0.46 + thresholdNoise * 0.20, 0.28, 0.68);
|
|
if (alpha >= threshold) {
|
|
const raw = source[si];
|
|
const value = idOffset && raw >= 0 ? raw + idOffset : raw;
|
|
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
|
|
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
|
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
|
|
if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
|
|
dest[wi] = value;
|
|
}
|
|
} else {
|
|
const before = dest[wi] || 0;
|
|
dest[wi] = lerp(before, source[si] || 0, alpha);
|
|
}
|
|
|
|
if (name === "elevation") {
|
|
updatedCells++;
|
|
if (alpha > 0.94) terrainCellsFullyReplaced++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep water fields coherent after all continuous fields have been blended.
|
|
const sea = world.fields.sea;
|
|
const ocean = world.fields.ocean;
|
|
const lake = world.fields.lake;
|
|
const landuse = world.fields.landuse;
|
|
if (sea) {
|
|
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
|
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
if (sea[i]) {
|
|
if (ocean) ocean[i] = 1;
|
|
if (lake) lake[i] = 0;
|
|
if (landuse) landuse[i] = LANDUSE.WATER || 0;
|
|
} else {
|
|
if (ocean) ocean[i] = 0;
|
|
if (lake) lake[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { window, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated };
|
|
}
|
|
|
|
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
|
|
const sea = world.fields.sea;
|
|
const ocean = world.fields.ocean;
|
|
const lake = world.fields.lake;
|
|
const elevation = world.fields.elevation;
|
|
if (!sea || !elevation) return { coastCellsChanged: 0 };
|
|
let changed = 0;
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
const flips = [];
|
|
for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) {
|
|
for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
let seaN = 0;
|
|
let landN = 0;
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
if (ni < 0) continue;
|
|
if (sea[ni]) seaN++; else landN++;
|
|
}
|
|
}
|
|
if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
|
|
else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
|
|
}
|
|
}
|
|
for (const [i, nextSea] of flips) {
|
|
if (sea[i] === nextSea) continue;
|
|
sea[i] = nextSea;
|
|
if (ocean) ocean[i] = nextSea;
|
|
if (lake) lake[i] = 0;
|
|
if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
|
|
else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
|
|
changed++;
|
|
}
|
|
}
|
|
return { coastCellsChanged: changed };
|
|
}
|
|
|
|
function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) {
|
|
const fields = world.fields;
|
|
const { elevation, sea } = fields;
|
|
if (!elevation || !sea) return;
|
|
if (!fields.slope) fields.slope = new Float32Array(world.width * world.height);
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
if (sea[i]) {
|
|
for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) {
|
|
if (fields[key]) fields[key][i] = 0;
|
|
}
|
|
continue;
|
|
}
|
|
if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) {
|
|
const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)];
|
|
const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)];
|
|
fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
|
|
}
|
|
if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25);
|
|
if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28);
|
|
}
|
|
}
|
|
}
|
|
|
|
function seaNeighbors(world, x, y, radius = 1) {
|
|
let count = 0;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const i = worldIndex(world, x + dx, y + dy);
|
|
if (i >= 0 && world.fields.sea?.[i]) count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function isLand(world, x, y) {
|
|
const i = worldIndex(world, x, y);
|
|
return i >= 0 && !world.fields.sea?.[i];
|
|
}
|
|
|
|
function nearestLand(world, x, y, rect, radius = 10) {
|
|
if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
|
|
for (let r = 1; r <= radius; r++) {
|
|
let best = null;
|
|
let bestScore = Infinity;
|
|
for (let yy = y - r; yy <= y + r; yy++) {
|
|
for (let xx = x - r; xx <= x + r; xx++) {
|
|
if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
|
|
if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
|
|
const i = worldIndex(world, xx, yy);
|
|
const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
|
|
if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
|
|
}
|
|
}
|
|
if (best) return best;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pointWorldX(world, p) {
|
|
if (Number.isFinite(p?.worldX)) return p.worldX;
|
|
return (p?.x || 0) + (world?.originX || 0);
|
|
}
|
|
|
|
function pointWorldY(world, p) {
|
|
if (Number.isFinite(p?.worldY)) return p.worldY;
|
|
return (p?.y || 0) + (world?.originY || 0);
|
|
}
|
|
|
|
function tupleWorldX(world, tuple) {
|
|
return (tuple?.[0] || 0) + (world?.originX || 0);
|
|
}
|
|
|
|
function tupleWorldY(world, tuple) {
|
|
return (tuple?.[1] || 0) + (world?.originY || 0);
|
|
}
|
|
|
|
function sourcePointFromWorld(world, point) {
|
|
return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
|
|
}
|
|
|
|
function sourcePathFromWorld(world, path) {
|
|
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
|
|
}
|
|
|
|
function transformCandidatePoint(world, window, p, key, seed = 0) {
|
|
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
|
|
const w = worldCoordForSource(window, p.x, p.y);
|
|
if (key === "ports") {
|
|
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8);
|
|
if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null;
|
|
w.x = land.x; w.y = land.y;
|
|
} else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) {
|
|
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5);
|
|
if (!land) return null;
|
|
w.x = land.x; w.y = land.y;
|
|
}
|
|
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
|
|
if (key === "adminCenters") {
|
|
const offset = fieldIdOffset("adminId", seed);
|
|
if (Number.isFinite(out.id)) out.id += offset;
|
|
if (Number.isFinite(out.adminId)) out.adminId += offset;
|
|
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
|
|
if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
|
|
}
|
|
if (key === "prefectureRegions") {
|
|
const offset = fieldIdOffset("prefectureRegionId", seed);
|
|
if (Number.isFinite(out.id)) out.id += offset;
|
|
if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
|
|
}
|
|
if (key === "logisticsParks") sanitizeLogisticsPark(out);
|
|
return out;
|
|
}
|
|
|
|
function sanitizeLogisticsPark(p) {
|
|
if (!p) return p;
|
|
p.name = null;
|
|
p.labelName = null;
|
|
p.facilityLabel = p.facilityLabel || "Logistics Park";
|
|
p.labelStyle = "facility";
|
|
p.suppressSettlementLabel = true;
|
|
p.kind = "Logistics Park";
|
|
p.population = 0;
|
|
return p;
|
|
}
|
|
|
|
function sanitizeExistingLogistics(sourceMap) {
|
|
let migrated = 0;
|
|
if (!Array.isArray(sourceMap.logisticsParks)) return 0;
|
|
for (const p of sourceMap.logisticsParks) {
|
|
if (!p) continue;
|
|
if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++;
|
|
sanitizeLogisticsPark(p);
|
|
}
|
|
for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
for (const p of arr) {
|
|
if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue;
|
|
p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null;
|
|
p.labelName = p.name;
|
|
migrated++;
|
|
}
|
|
}
|
|
return migrated;
|
|
}
|
|
|
|
function transformCandidatePath(window, path) {
|
|
const out = [];
|
|
for (const tuple of path || []) {
|
|
if (!Array.isArray(tuple) || tuple.length < 2) continue;
|
|
const p = worldCoordForSource(window, tuple[0], tuple[1]);
|
|
out.push([p.x, p.y]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function splitWorldPathByRect(path, rect, keepInside) {
|
|
const chunks = [];
|
|
let current = [];
|
|
for (const p of path || []) {
|
|
const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect);
|
|
if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]);
|
|
else {
|
|
if (current.length >= 2) chunks.push(current);
|
|
current = [];
|
|
}
|
|
}
|
|
if (current.length >= 2) chunks.push(current);
|
|
return chunks;
|
|
}
|
|
|
|
function pruneOldPathLayer(world, paths, rect, mode) {
|
|
const kept = [];
|
|
const anchors = [];
|
|
let clipped = 0;
|
|
for (const path of paths || []) {
|
|
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
|
|
const touches = worldPath.some(([x, y]) => insideRect(x, y, rect));
|
|
if (!touches) {
|
|
kept.push(path);
|
|
continue;
|
|
}
|
|
clipped++;
|
|
let lastOutside = null;
|
|
let wasInside = false;
|
|
for (const [x, y] of worldPath) {
|
|
const inside = insideRect(x, y, rect);
|
|
if (!inside) {
|
|
if (wasInside) anchors.push({ x, y, mode });
|
|
lastOutside = { x, y, mode };
|
|
} else if (lastOutside && !wasInside) {
|
|
anchors.push(lastOutside);
|
|
}
|
|
wasInside = inside;
|
|
}
|
|
for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
|
|
}
|
|
return { kept, anchors, clipped };
|
|
}
|
|
|
|
function pathCost(world, x, y, mode) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) return Infinity;
|
|
const slope = world.fields.slope?.[i] || 0;
|
|
const river = world.fields.river?.[i] || 0;
|
|
const plain = world.fields.plain?.[i] || 0;
|
|
const roadInfluence = world.fields.roadInfluence?.[i] || 0;
|
|
const pop = world.fields.populationDensity?.[i] || 0;
|
|
const slopeMult = mode === "rail" ? 15 : 7;
|
|
return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
|
|
}
|
|
|
|
function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
|
|
const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
|
|
if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
|
|
if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
|
|
const w = rectWidth(rect);
|
|
const h = rectHeight(rect);
|
|
const n = w * h;
|
|
const dist = new Float64Array(n); dist.fill(Infinity);
|
|
const prev = new Int32Array(n); prev.fill(-1);
|
|
const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
|
|
const heap = new MinHeap();
|
|
const startId = local(sx, sy);
|
|
dist[startId] = 0;
|
|
heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
|
|
let expanded = 0;
|
|
let found = -1;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
while (heap.items.length && expanded < maxExpanded) {
|
|
const cur = heap.pop();
|
|
if (!cur) break;
|
|
if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
|
|
expanded++;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = cur.x + dx, ny = cur.y + dy;
|
|
if (!insideRect(nx, ny, rect)) continue;
|
|
const nid = local(nx, ny);
|
|
const c = pathCost(world, nx, ny, mode);
|
|
if (!Number.isFinite(c)) continue;
|
|
const step = (dx && dy ? 1.42 : 1) * c;
|
|
const nd = dist[cur.id] + step;
|
|
if (nd >= dist[nid]) continue;
|
|
dist[nid] = nd;
|
|
prev[nid] = cur.id;
|
|
heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
|
|
}
|
|
}
|
|
if (found < 0) return null;
|
|
const rev = [];
|
|
let at = found;
|
|
while (at >= 0) {
|
|
const x = rect.x0 + (at % w);
|
|
const y = rect.y0 + Math.floor(at / w);
|
|
rev.push([x, y]);
|
|
at = prev[at];
|
|
}
|
|
return rev.reverse();
|
|
}
|
|
|
|
function simplifyPath(path, keepEvery = 2) {
|
|
if (!path || path.length <= 2) return path || [];
|
|
const out = [path[0]];
|
|
for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
|
|
out.push(path[path.length - 1]);
|
|
return out;
|
|
}
|
|
|
|
function collectInternalNetworkPoints(world, sourceMap, keys, rect) {
|
|
const points = [];
|
|
for (const key of keys) {
|
|
for (const path of sourceMap[key] || []) {
|
|
for (let i = 0; i < path.length; i += 6) {
|
|
const x = Math.round(tupleWorldX(world, path[i]));
|
|
const y = Math.round(tupleWorldY(world, path[i]));
|
|
if (insideRect(x, y, rect) && isLand(world, x, y)) points.push({ x, y, key });
|
|
}
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function connectAnchors(world, sourceMap, anchors, mode, rect) {
|
|
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
|
|
const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect);
|
|
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
|
|
let connectors = 0;
|
|
let disconnected = 0;
|
|
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
|
sourceMap[layer] ||= [];
|
|
const seen = new Set();
|
|
for (const raw of anchors) {
|
|
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 12);
|
|
if (!anchorLand) { disconnected++; continue; }
|
|
const target = targets
|
|
.filter((p) => Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) <= (mode === "rail" ? 80 : 64))
|
|
.sort((a, b) => Math.hypot(a.x - anchorLand.x, a.y - anchorLand.y) - Math.hypot(b.x - anchorLand.x, b.y - anchorLand.y))[0];
|
|
if (!target) { disconnected++; continue; }
|
|
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
|
if (seen.has(sig)) continue;
|
|
seen.add(sig);
|
|
const path = localPathfind(world, anchorLand, target, rect, mode);
|
|
if (!path || path.length < 2) { disconnected++; continue; }
|
|
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
|
|
connectors++;
|
|
}
|
|
return { connectors, disconnected };
|
|
}
|
|
|
|
function mergePointLayers(world, sourceMap, candidate, rects, window, seed) {
|
|
let preservedExternalEntities = 0;
|
|
let regeneratedInternalEntities = 0;
|
|
let invalidPortsRemoved = 0;
|
|
for (const key of POINT_LAYER_KEYS) {
|
|
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
const kept = [];
|
|
for (const p of oldArr) {
|
|
if (!p) continue;
|
|
const wx = Math.round(pointWorldX(world, p));
|
|
const wy = Math.round(pointWorldY(world, p));
|
|
const inWrite = insideRect(wx, wy, rects.writeRect);
|
|
const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
|
|
if (!inWrite || alpha < 0.34) {
|
|
if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; }
|
|
kept.push(p);
|
|
if (!inWrite) preservedExternalEntities++;
|
|
}
|
|
}
|
|
const generated = [];
|
|
for (const p of candidate[key] || []) {
|
|
const q = transformCandidatePoint(world, window, p, key, seed);
|
|
if (!q) continue;
|
|
const wx = Math.round(pointWorldX(world, q));
|
|
const wy = Math.round(pointWorldY(world, q));
|
|
if (!insideRect(wx, wy, rects.writeRect)) continue;
|
|
if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
|
|
generated.push(q);
|
|
}
|
|
sourceMap[key] = [...kept, ...generated];
|
|
regeneratedInternalEntities += generated.length;
|
|
}
|
|
return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
|
|
}
|
|
|
|
function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
|
let roadAnchors = [];
|
|
let railAnchors = [];
|
|
let roadsClipped = 0;
|
|
let railsClipped = 0;
|
|
let regeneratedPaths = 0;
|
|
for (const key of PATH_LAYER_KEYS) {
|
|
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
|
|
const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode);
|
|
if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
|
|
else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
|
|
const next = [...pruned.kept];
|
|
for (const path of candidate[key] || []) {
|
|
const worldPath = transformCandidatePath(window, path);
|
|
const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true)
|
|
.map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
|
|
.filter((chunk) => chunk.length >= 2);
|
|
for (const chunk of chunks) {
|
|
if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
|
|
next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
|
|
regeneratedPaths++;
|
|
}
|
|
}
|
|
}
|
|
sourceMap[key] = next;
|
|
}
|
|
const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", rects.writeRect);
|
|
const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", rects.writeRect);
|
|
return {
|
|
roadsClipped,
|
|
railsClipped,
|
|
regeneratedPaths,
|
|
roadConnectorsCreated: roadConn.connectors,
|
|
railwayConnectorsCreated: railConn.connectors,
|
|
disconnectedRoadComponents: roadConn.disconnected,
|
|
disconnectedRailComponents: railConn.disconnected,
|
|
};
|
|
}
|
|
|
|
function segmentTouchesRect(world, seg, rect) {
|
|
if (!Array.isArray(seg) || seg.length < 2) return false;
|
|
return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
|
|
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
|
|
}
|
|
|
|
function buildBoundarySegmentsFromField(world, fieldName, rect) {
|
|
const field = world.fields[fieldName];
|
|
const sea = world.fields.sea;
|
|
if (!field) return [];
|
|
const out = [];
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || sea?.[i]) continue;
|
|
const id = field[i];
|
|
if (id < 0) continue;
|
|
const right = worldIndex(world, x + 1, y);
|
|
if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id) {
|
|
out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]);
|
|
}
|
|
const down = worldIndex(world, x, y + 1);
|
|
if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id) {
|
|
out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergeSegmentLayers(world, sourceMap, rects) {
|
|
for (const key of SEGMENT_LAYER_KEYS) {
|
|
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
|
|
}
|
|
sourceMap.adminBorders ||= [];
|
|
sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect));
|
|
sourceMap.regionalPrefectureBorders ||= [];
|
|
sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect));
|
|
}
|
|
|
|
function repairLanduseAndPopulation(world, rects) {
|
|
const landuse = world.fields.landuse;
|
|
if (!landuse) return { landUseCellsUpdated: 0 };
|
|
let updated = 0;
|
|
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
|
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
if (world.fields.sea?.[i]) {
|
|
if (landuse[i] !== (LANDUSE.WATER || 0)) updated++;
|
|
landuse[i] = LANDUSE.WATER || 0;
|
|
continue;
|
|
}
|
|
if (landuse[i] === (LANDUSE.WATER || 0)) {
|
|
const slope = world.fields.slope?.[i] || 0;
|
|
const ag = world.fields.agriculture?.[i] || 0;
|
|
landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
|
|
updated++;
|
|
}
|
|
}
|
|
}
|
|
return { landUseCellsUpdated: updated };
|
|
}
|
|
|
|
function countSea(world, rect) {
|
|
let seaCount = 0;
|
|
let total = 0;
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
total++;
|
|
if (world.fields.sea?.[i]) seaCount++;
|
|
}
|
|
}
|
|
return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
|
|
}
|
|
|
|
function terrainLabel(candidate, fallback) {
|
|
return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
|
|
}
|
|
|
|
function terrainId(candidate, fallback) {
|
|
return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
|
|
}
|
|
|
|
export function generatePatch(world, userRectInput, options = {}) {
|
|
const validation = validatePatchRect(userRectInput, world);
|
|
if (!validation.ok) return { ok: false, ...validation };
|
|
|
|
const rects = buildPatchRects(validation.rect, world);
|
|
const terrainType = options.terrainType || "auto";
|
|
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
|
|
const candidate = generateMap(seed, { terrainType, onProgress: () => {} });
|
|
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
|
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
|
|
|
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
|
|
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
|
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
|
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
|
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
|
mergeSegmentLayers(world, sourceMap, rects);
|
|
const landDebug = repairLanduseAndPopulation(world, rects);
|
|
sanitizeExistingLogistics(sourceMap);
|
|
|
|
const seaStats = countSea(world, rects.coreRect);
|
|
const label = terrainLabel(candidate, terrainType);
|
|
const id = terrainId(candidate, terrainType);
|
|
const humanGeography = {
|
|
ok: true,
|
|
modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
|
ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
|
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
|
...pointDebug,
|
|
...pathDebug,
|
|
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
|
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
|
logisticsLabelsMigrated,
|
|
};
|
|
|
|
const record = {
|
|
...rects.coreRect,
|
|
coreRect: { ...rects.coreRect },
|
|
writeRect: { ...rects.writeRect },
|
|
repairRect: { ...rects.repairRect },
|
|
contextRect: { ...rects.contextRect },
|
|
blendRect: { ...rects.blendRect },
|
|
terrainType: id,
|
|
label,
|
|
seed,
|
|
updatedCells: fieldDebug.updatedCells,
|
|
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
|
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
|
|
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
|
|
naturalRegionFragmentsMerged: 0,
|
|
seaRatio: seaStats.seaRatio,
|
|
humanGeography,
|
|
createdAt: Date.now(),
|
|
};
|
|
world.generatedRects = [...(world.generatedRects || []), record];
|
|
world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
|
|
world.lastPatchResult = record;
|
|
|
|
return {
|
|
ok: true,
|
|
validation,
|
|
rects,
|
|
terrainType: id,
|
|
label,
|
|
seed,
|
|
updatedCells: record.updatedCells,
|
|
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
|
coastCellsChanged: record.coastCellsChanged,
|
|
naturalRegionsUpdated: record.naturalRegionsUpdated,
|
|
naturalRegionFragmentsMerged: 0,
|
|
seaRatio: seaStats.seaRatio,
|
|
humanGeography,
|
|
};
|
|
}
|