3860 lines
158 KiB
JavaScript
3860 lines
158 KiB
JavaScript
import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js";
|
|
import { generateMap } from "./mapPipeline.js";
|
|
import { LANDUSE } from "./landuseCodes.js";
|
|
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.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 PATCH_CANDIDATE_CACHE_LIMIT = 3;
|
|
|
|
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 ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]);
|
|
const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]);
|
|
const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]);
|
|
|
|
const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]);
|
|
|
|
const STRICT_RESTORE_FIELD_EXEMPTIONS = new Set([
|
|
// Transport repair is allowed to operate over a wider neighborhood than the
|
|
// lasso itself. Keep its derived influence fields in sync with repaired
|
|
// paths instead of restoring them to the pre-patch values outside the lasso.
|
|
"roadInfluence", "railInfluence2", "stationInfluence",
|
|
]);
|
|
|
|
function shouldStrictRestoreField(name) {
|
|
return !STRICT_RESTORE_FIELD_EXEMPTIONS.has(name);
|
|
}
|
|
|
|
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 nowMs() {
|
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
}
|
|
|
|
function createPatchTimer() {
|
|
const timings = [];
|
|
let mark = nowMs();
|
|
return {
|
|
timings,
|
|
mark(key, label = key) {
|
|
const t = nowMs();
|
|
timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 });
|
|
mark = t;
|
|
},
|
|
};
|
|
}
|
|
|
|
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 isPolygonSelection(input) {
|
|
return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3;
|
|
}
|
|
|
|
function clampPointToWorld(point, world) {
|
|
return {
|
|
x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)),
|
|
y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)),
|
|
};
|
|
}
|
|
|
|
function polygonBounds(polygon) {
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const p of polygon || []) {
|
|
if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
|
|
minX = Math.min(minX, p.x);
|
|
minY = Math.min(minY, p.y);
|
|
maxX = Math.max(maxX, p.x);
|
|
maxY = Math.max(maxY, p.y);
|
|
}
|
|
if (!Number.isFinite(minX)) return null;
|
|
return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) };
|
|
}
|
|
|
|
function polygonAreaCells(polygon) {
|
|
if (!polygon || polygon.length < 3) return 0;
|
|
let area = 0;
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
const a = polygon[i];
|
|
const b = polygon[(i + 1) % polygon.length];
|
|
area += a.x * b.y - b.x * a.y;
|
|
}
|
|
return Math.abs(area) * 0.5;
|
|
}
|
|
|
|
function normalizeSelectionShape(input, world = null) {
|
|
if (!isPolygonSelection(input)) return normalizeRect(input);
|
|
const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) });
|
|
const bounds = polygonBounds(polygon);
|
|
if (!bounds) return null;
|
|
return {
|
|
kind: input.kind || 'lasso',
|
|
polygon,
|
|
areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))),
|
|
x0: bounds.x0,
|
|
y0: bounds.y0,
|
|
x1: bounds.x1,
|
|
y1: bounds.y1,
|
|
};
|
|
}
|
|
|
|
function pointInPolygon(px, py, polygon) {
|
|
let inside = false;
|
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
const xi = polygon[i].x + 0.5;
|
|
const yi = polygon[i].y + 0.5;
|
|
const xj = polygon[j].x + 0.5;
|
|
const yj = polygon[j].y + 0.5;
|
|
const denomRaw = yj - yi;
|
|
const denom = Math.abs(denomRaw) < 1e-6 ? (denomRaw < 0 ? -1e-6 : 1e-6) : denomRaw;
|
|
const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / denom + xi);
|
|
if (intersect) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
function pointSegmentDistance(px, py, ax, ay, bx, by) {
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay);
|
|
const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);
|
|
return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
|
|
}
|
|
|
|
function distanceToPolygonEdge(px, py, polygon) {
|
|
let best = Infinity;
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
const a = polygon[i];
|
|
const b = polygon[(i + 1) % polygon.length];
|
|
best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5));
|
|
}
|
|
return best;
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
function isWorldCellField(world, value) {
|
|
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === (world?.width || 0) * (world?.height || 0);
|
|
}
|
|
|
|
function captureStrictSelectionFieldSnapshot(world, rects, seed = 0) {
|
|
if (!rects?.strictSelectionMask || !world?.fields || !rects.writeRect) return null;
|
|
// Snapshot the entire field-repair neighborhood. Several post-processors
|
|
// intentionally work on repairRect to keep seams smooth; for lasso patches,
|
|
// cells outside the polygon must still be put back after those repairs.
|
|
const rect = rects.repairRect || rects.writeRect;
|
|
const width = rectWidth(rect);
|
|
const height = rectHeight(rect);
|
|
const fields = new Map();
|
|
for (const [name, field] of Object.entries(world.fields)) {
|
|
if (!shouldStrictRestoreField(name) || !isWorldCellField(world, field)) continue;
|
|
const data = new field.constructor(width * height);
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const wi = worldIndex(world, x, y);
|
|
if (wi >= 0) data[(y - rect.y0) * width + (x - rect.x0)] = field[wi];
|
|
}
|
|
}
|
|
fields.set(name, data);
|
|
}
|
|
return { rect: { ...rect }, width, height, fields, seed };
|
|
}
|
|
|
|
function restoreOutsideStrictSelectionFields(world, rects, snapshot, seed = 0) {
|
|
if (!snapshot || !rects?.strictSelectionMask) return { strictMaskCellsRestored: 0, strictMaskValuesRestored: 0 };
|
|
const rect = snapshot.rect;
|
|
let strictMaskCellsRestored = 0;
|
|
let strictMaskValuesRestored = 0;
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
if (patchAlpha(x, y, rects, seed) > 0.005) continue;
|
|
let cellChanged = false;
|
|
const li = (y - rect.y0) * snapshot.width + (x - rect.x0);
|
|
const wi = worldIndex(world, x, y);
|
|
if (wi < 0) continue;
|
|
for (const [name, oldData] of snapshot.fields) {
|
|
const field = world.fields?.[name];
|
|
if (!isWorldCellField(world, field)) continue;
|
|
const oldValue = oldData[li];
|
|
if (field[wi] !== oldValue) {
|
|
field[wi] = oldValue;
|
|
strictMaskValuesRestored++;
|
|
cellChanged = true;
|
|
}
|
|
}
|
|
if (cellChanged) strictMaskCellsRestored++;
|
|
}
|
|
}
|
|
return { strictMaskCellsRestored, strictMaskValuesRestored };
|
|
}
|
|
|
|
export function clipPatchRect(rect, world) {
|
|
const normalized = normalizeSelectionShape(rect, world);
|
|
if (!normalized || !world) return null;
|
|
if (isPolygonSelection(normalized)) return normalized;
|
|
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 = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : 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 = normalizeSelectionShape(userRect, world);
|
|
const width = rectWidth(coreRect);
|
|
const height = rectHeight(coreRect);
|
|
const shortSide = Math.max(1, Math.min(width, height));
|
|
const polygonSelection = isPolygonSelection(coreRect);
|
|
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 rawWriteMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
|
|
const desiredRepair = Math.min(120, rawWriteMargin + Math.max(8, Math.floor(shortSide * 0.12)));
|
|
const repairMargin = Math.max(rawWriteMargin, Math.min(desiredRepair, maxBySource));
|
|
|
|
// A lasso/freeform selection is now treated as a strict write mask. Earlier
|
|
// versions expanded the lasso's bounding box and let alpha feather outside the
|
|
// drawn polygon; that made terrain/features appear beyond the user's blue
|
|
// selection outline. We still keep repair/transport context outside the mask,
|
|
// but only cells inside the lasso can receive generated field/feature data.
|
|
const writeMargin = polygonSelection ? Math.max(4, Math.min(rawWriteMargin, Math.floor(shortSide * 0.20))) : rawWriteMargin;
|
|
const writeRect = polygonSelection ? { x0: coreRect.x0, y0: coreRect.y0, x1: coreRect.x1, y1: coreRect.y1 } : expandRect(coreRect, writeMargin, world);
|
|
const repairRect = expandRect(coreRect, repairMargin, world);
|
|
// Transport graph repair needs substantially more regional context than field
|
|
// generation. Terrain/admin/water still obey writeRect/strict masks, but
|
|
// severed roads and rails often need to reconnect to the next real trunk line
|
|
// outside the edited patch. Keep this capped so very large worlds do not make
|
|
// every patch repair global.
|
|
const longSide = Math.max(width, height);
|
|
const diagonal = Math.hypot(width, height);
|
|
const transportReachMargin = Math.max(
|
|
repairMargin + 72,
|
|
Math.min(420, Math.max(160, repairMargin + Math.floor(diagonal * 0.45), Math.floor(longSide * 0.68)))
|
|
);
|
|
const transportReachRect = expandRect(coreRect, transportReachMargin, world);
|
|
return {
|
|
coreRect,
|
|
writeRect,
|
|
repairRect,
|
|
contextRect: repairRect,
|
|
transportReachRect,
|
|
blendRect: coreRect,
|
|
userRect: writeRect,
|
|
selectedRect: coreRect,
|
|
selectionShape: polygonSelection ? coreRect : null,
|
|
strictSelectionMask: polygonSelection,
|
|
writeMargin,
|
|
repairMargin,
|
|
transportReachMargin,
|
|
outerMargin: writeMargin,
|
|
innerMargin: 0,
|
|
};
|
|
}
|
|
|
|
function computePatchAlpha(x, y, rects, seed = 0) {
|
|
const writeRect = rects.writeRect || rects.userRect;
|
|
if (!insideRect(x, y, writeRect)) return 0;
|
|
const margin = Math.max(1, rects.writeMargin || 1);
|
|
const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5;
|
|
const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5;
|
|
const shape = rects.selectionShape;
|
|
if (shape?.polygon?.length >= 3) {
|
|
const px = x + 0.5;
|
|
const py = y + 0.5;
|
|
const inside = pointInPolygon(px, py, shape.polygon);
|
|
if (!inside) return 0;
|
|
|
|
// Strict lasso semantics: never write outside the user's polygon. The seam
|
|
// feather is inward-only. Earlier builds started the lasso edge at ~0.64,
|
|
// which made a hard terrain switch visible immediately inside the blue line.
|
|
// Start at zero and let the candidate terrain take over only after an
|
|
// interior transition band.
|
|
const dist = distanceToPolygonEdge(px, py, shape.polygon);
|
|
const feather = Math.max(6, Math.min(margin, 24));
|
|
const noisyInsideDist = dist + low * Math.min(2.2, feather * 0.12) + mid * Math.min(1.1, feather * 0.06);
|
|
const t = clamp((noisyInsideDist - 0.35) / Math.max(1e-6, feather));
|
|
const edge = smoothstep(t);
|
|
return clamp(edge);
|
|
}
|
|
const edge = distanceToRectEdge(x, y, writeRect);
|
|
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 getPatchAlphaCache(rects, seed = 0) {
|
|
const writeRect = rects?.writeRect || rects?.userRect;
|
|
if (!writeRect) return null;
|
|
const width = rectWidth(writeRect);
|
|
const height = rectHeight(writeRect);
|
|
const existing = rects.patchAlphaCache;
|
|
if (
|
|
existing
|
|
&& existing.seed === seed
|
|
&& existing.width === width
|
|
&& existing.height === height
|
|
&& existing.x0 === writeRect.x0
|
|
&& existing.y0 === writeRect.y0
|
|
) return existing;
|
|
|
|
const data = new Float32Array(width * height);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed);
|
|
}
|
|
}
|
|
rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data };
|
|
return rects.patchAlphaCache;
|
|
}
|
|
|
|
function patchAlpha(x, y, rects, seed = 0) {
|
|
const writeRect = rects?.writeRect || rects?.userRect;
|
|
if (!writeRect || !insideRect(x, y, writeRect)) return 0;
|
|
const cache = rects.patchAlphaCache;
|
|
if (
|
|
cache
|
|
&& cache.seed === seed
|
|
&& x >= cache.x0
|
|
&& y >= cache.y0
|
|
&& x < cache.x0 + cache.width
|
|
&& y < cache.y0 + cache.height
|
|
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0;
|
|
return computePatchAlpha(x, y, rects, seed);
|
|
}
|
|
|
|
function patchBand(x, y, rects, seed = 0) {
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (a <= 0.18) return "preserve";
|
|
if (a >= 0.82) return "core";
|
|
return "feather";
|
|
}
|
|
|
|
function continuityReplaceThreshold(name, x, y, rects, seed = 0) {
|
|
const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5;
|
|
if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92);
|
|
if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82);
|
|
return clamp(0.46 + n * 0.20, 0.28, 0.68);
|
|
}
|
|
|
|
function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) {
|
|
if (!rects) return true;
|
|
return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha;
|
|
}
|
|
|
|
function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) {
|
|
return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha;
|
|
}
|
|
|
|
function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) {
|
|
if (!Array.isArray(seg) || seg.length < 2) return false;
|
|
const ax = tupleWorldX(world, seg[0]);
|
|
const ay = tupleWorldY(world, seg[0]);
|
|
const bx = tupleWorldX(world, seg[1]);
|
|
const by = tupleWorldY(world, seg[1]);
|
|
const mx = (ax + bx) * 0.5;
|
|
const my = (ay + by) * 0.5;
|
|
return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha);
|
|
}
|
|
|
|
function segmentTouchesRect(world, seg, rect) {
|
|
if (!Array.isArray(seg) || seg.length < 2 || !rect) return false;
|
|
const ax = tupleWorldX(world, seg[0]);
|
|
const ay = tupleWorldY(world, seg[0]);
|
|
const bx = tupleWorldX(world, seg[1]);
|
|
const by = tupleWorldY(world, seg[1]);
|
|
const mx = (ax + bx) * 0.5;
|
|
const my = (ay + by) * 0.5;
|
|
if (insideRect(ax, ay, rect) || insideRect(bx, by, rect) || insideRect(mx, my, rect)) return true;
|
|
const minX = Math.min(ax, bx);
|
|
const maxX = Math.max(ax, bx);
|
|
const minY = Math.min(ay, by);
|
|
const maxY = Math.max(ay, by);
|
|
return maxX >= rect.x0 && minX < rect.x1 && maxY >= rect.y0 && minY < rect.y1;
|
|
}
|
|
|
|
function quantizedSegmentKey(seg) {
|
|
if (!Array.isArray(seg) || seg.length < 2) return "";
|
|
const p = seg.map(([x, y]) => [Math.round(x * 4) / 4, Math.round(y * 4) / 4]);
|
|
const a = `${p[0][0]},${p[0][1]}`;
|
|
const b = `${p[1][0]},${p[1][1]}`;
|
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
}
|
|
|
|
function dedupeSegments(segments) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const seg of segments || []) {
|
|
const key = quantizedSegmentKey(seg);
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(seg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
|
|
function segmentMidpoint(seg) {
|
|
return {
|
|
x: ((seg?.[0]?.[0] || 0) + (seg?.[1]?.[0] || 0)) * 0.5,
|
|
y: ((seg?.[0]?.[1] || 0) + (seg?.[1]?.[1] || 0)) * 0.5,
|
|
};
|
|
}
|
|
|
|
function segmentOrientation(seg) {
|
|
const dx = (seg?.[1]?.[0] || 0) - (seg?.[0]?.[0] || 0);
|
|
const dy = (seg?.[1]?.[1] || 0) - (seg?.[0]?.[1] || 0);
|
|
return Math.atan2(dy, dx);
|
|
}
|
|
|
|
function angleDistance(a, b) {
|
|
let d = Math.abs(a - b) % Math.PI;
|
|
if (d > Math.PI / 2) d = Math.PI - d;
|
|
return d;
|
|
}
|
|
|
|
function segmentNear(a, b, tolerance = 0.60) {
|
|
if (!a || !b) return false;
|
|
const am = segmentMidpoint(a);
|
|
const bm = segmentMidpoint(b);
|
|
if (Math.hypot(am.x - bm.x, am.y - bm.y) > tolerance) return false;
|
|
return angleDistance(segmentOrientation(a), segmentOrientation(b)) < 0.55;
|
|
}
|
|
|
|
function filterSupplementalSegments(primary, supplemental, tolerance = 0.60) {
|
|
if (!supplemental?.length) return [];
|
|
if (!primary?.length) return supplemental || [];
|
|
const grid = new Map();
|
|
const cell = (v) => Math.floor(v / Math.max(0.1, tolerance));
|
|
for (const seg of primary) {
|
|
const m = segmentMidpoint(seg);
|
|
const key = `${cell(m.x)},${cell(m.y)}`;
|
|
const bucket = grid.get(key) || [];
|
|
bucket.push(seg);
|
|
grid.set(key, bucket);
|
|
}
|
|
const out = [];
|
|
for (const seg of supplemental || []) {
|
|
const m = segmentMidpoint(seg);
|
|
let near = false;
|
|
const gx = cell(m.x), gy = cell(m.y);
|
|
for (let yy = gy - 1; yy <= gy + 1 && !near; yy++) {
|
|
for (let xx = gx - 1; xx <= gx + 1 && !near; xx++) {
|
|
for (const other of grid.get(`${xx},${yy}`) || []) {
|
|
if (segmentNear(seg, other, tolerance)) { near = true; break; }
|
|
}
|
|
}
|
|
}
|
|
if (!near) out.push(seg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function removeSegmentsNearSegments(segments, blockers, tolerance = 0.68) {
|
|
if (!segments?.length || !blockers?.length) return segments || [];
|
|
return filterSupplementalSegments(blockers, segments, tolerance);
|
|
}
|
|
|
|
|
|
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 getPatchSourceIndexCache(rects, window) {
|
|
const writeRect = rects?.writeRect;
|
|
if (!writeRect || !window) return null;
|
|
const width = rectWidth(writeRect);
|
|
const height = rectHeight(writeRect);
|
|
const existing = rects.patchSourceIndexCache;
|
|
if (
|
|
existing
|
|
&& existing.width === width
|
|
&& existing.height === height
|
|
&& existing.x0 === writeRect.x0
|
|
&& existing.y0 === writeRect.y0
|
|
&& existing.worldCenterX === window.worldCenterX
|
|
&& existing.worldCenterY === window.worldCenterY
|
|
&& existing.sourceCenterX === window.sourceCenterX
|
|
&& existing.sourceCenterY === window.sourceCenterY
|
|
) return existing;
|
|
|
|
const data = new Int32Array(width * height);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX);
|
|
const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY);
|
|
data[y * width + x] = sourceIndex(sx, sy);
|
|
}
|
|
}
|
|
rects.patchSourceIndexCache = {
|
|
width,
|
|
height,
|
|
x0: writeRect.x0,
|
|
y0: writeRect.y0,
|
|
worldCenterX: window.worldCenterX,
|
|
worldCenterY: window.worldCenterY,
|
|
sourceCenterX: window.sourceCenterX,
|
|
sourceCenterY: window.sourceCenterY,
|
|
data,
|
|
};
|
|
return rects.patchSourceIndexCache;
|
|
}
|
|
|
|
function sourceIndexForWorld(rects, window, x, y) {
|
|
const cache = rects?.patchSourceIndexCache;
|
|
if (
|
|
cache
|
|
&& x >= cache.x0
|
|
&& y >= cache.y0
|
|
&& x < cache.x0 + cache.width
|
|
&& y < cache.y0 + cache.height
|
|
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)];
|
|
const s = sourceCoordForWorld(window, x, y);
|
|
return sourceIndex(s.x, s.y);
|
|
}
|
|
|
|
|
|
function solveLinear3(a00, a01, a02, a11, a12, a22, b0, b1, b2) {
|
|
const m = [
|
|
[a00, a01, a02, b0],
|
|
[a01, a11, a12, b1],
|
|
[a02, a12, a22, b2],
|
|
];
|
|
for (let col = 0; col < 3; col++) {
|
|
let pivot = col;
|
|
for (let row = col + 1; row < 3; row++) if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) pivot = row;
|
|
if (Math.abs(m[pivot][col]) < 1e-8) return null;
|
|
if (pivot !== col) [m[col], m[pivot]] = [m[pivot], m[col]];
|
|
const div = m[col][col];
|
|
for (let k = col; k < 4; k++) m[col][k] /= div;
|
|
for (let row = 0; row < 3; row++) {
|
|
if (row === col) continue;
|
|
const f = m[row][col];
|
|
for (let k = col; k < 4; k++) m[row][k] -= f * m[col][k];
|
|
}
|
|
}
|
|
return [m[0][3], m[1][3], m[2][3]];
|
|
}
|
|
|
|
function computeElevationCandidateAdjustment(world, candidate, rects, window, seed = 0) {
|
|
const oldElevation = world?.fields?.elevation;
|
|
const candidateElevation = candidate?.elevation;
|
|
if (!oldElevation || !candidateElevation || !rects?.writeRect || !window) return null;
|
|
const rect = rects.writeRect;
|
|
const cx = (rect.x0 + rect.x1 - 1) / 2;
|
|
const cy = (rect.y0 + rect.y1 - 1) / 2;
|
|
const scale = Math.max(1, Math.max(rectWidth(rect), rectHeight(rect)) / 2);
|
|
let n = 0;
|
|
let sX = 0, sY = 0, sXX = 0, sXY = 0, sYY = 0;
|
|
let sZ = 0, sXZ = 0, sYZ = 0;
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (a <= 0.01 || a > 0.42) continue;
|
|
const wi = worldIndex(world, x, y);
|
|
const si = sourceIndexForWorld(rects, window, x, y);
|
|
if (wi < 0 || si < 0) continue;
|
|
const oldSea = world.fields?.sea?.[wi];
|
|
const newSea = candidate.sea?.[si];
|
|
if (oldSea || newSea) continue;
|
|
const oldValue = oldElevation[wi];
|
|
const newValue = candidateElevation[si];
|
|
if (!Number.isFinite(oldValue) || !Number.isFinite(newValue)) continue;
|
|
const lx = (x - cx) / scale;
|
|
const ly = (y - cy) / scale;
|
|
const z = oldValue - newValue;
|
|
n++;
|
|
sX += lx; sY += ly; sXX += lx * lx; sXY += lx * ly; sYY += ly * ly;
|
|
sZ += z; sXZ += lx * z; sYZ += ly * z;
|
|
}
|
|
}
|
|
if (n < 24) return { offset: 0, tiltX: 0, tiltY: 0, cx, cy, scale, samples: n };
|
|
const solved = solveLinear3(n, sX, sY, sXX, sXY, sYY, sZ, sXZ, sYZ);
|
|
if (!solved) return { offset: clamp(sZ / n, -0.18, 0.18), tiltX: 0, tiltY: 0, cx, cy, scale, samples: n };
|
|
return {
|
|
offset: clamp(solved[0], -0.22, 0.22),
|
|
tiltX: clamp(solved[1], -0.16, 0.16),
|
|
tiltY: clamp(solved[2], -0.16, 0.16),
|
|
cx, cy, scale,
|
|
samples: n,
|
|
};
|
|
}
|
|
|
|
function adjustedCandidateElevation(value, x, y, adjustment) {
|
|
if (!adjustment || !Number.isFinite(value)) return value;
|
|
const lx = (x - adjustment.cx) / Math.max(1, adjustment.scale || 1);
|
|
const ly = (y - adjustment.cy) / Math.max(1, adjustment.scale || 1);
|
|
return clamp(value + adjustment.offset + adjustment.tiltX * lx + adjustment.tiltY * ly, 0, 1);
|
|
}
|
|
|
|
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 maxFieldId(field) {
|
|
if (!field) return -1;
|
|
let max = -1;
|
|
for (let i = 0; i < field.length; i++) {
|
|
const id = field[i];
|
|
if (Number.isFinite(id) && id > max) max = id;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function addMappingVote(votes, from, to, weight = 1) {
|
|
if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return;
|
|
const key = Math.floor(from);
|
|
const target = Math.floor(to);
|
|
const bucket = votes.get(key) || new Map();
|
|
bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight));
|
|
votes.set(key, bucket);
|
|
}
|
|
|
|
function chooseVotedTarget(bucket, minVotes = 1) {
|
|
let best = -1;
|
|
let bestVotes = 0;
|
|
for (const [target, count] of bucket || []) {
|
|
if (count > bestVotes || (count === bestVotes && target < best)) {
|
|
best = target;
|
|
bestVotes = count;
|
|
}
|
|
}
|
|
return best >= 0 && bestVotes >= minVotes ? best : -1;
|
|
}
|
|
|
|
function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) {
|
|
const ids = new Set();
|
|
if (!candidateField) return ids;
|
|
const rect = rects.writeRect;
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
if (patchAlpha(x, y, rects, seed) < minAlpha) continue;
|
|
const si = sourceIndexForWorld(rects, window, x, y);
|
|
if (si < 0) continue;
|
|
const id = candidateField[si];
|
|
if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) {
|
|
const out = new Map();
|
|
const direct = candidateMap?.municipalityToPrefectureId;
|
|
for (const id of candidateAdminIds || []) {
|
|
const pref = direct?.[id];
|
|
if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref));
|
|
}
|
|
const adminField = candidateMap?.adminId || candidateMap?.municipalityId;
|
|
const prefField = candidateMap?.prefectureRegionId;
|
|
if (!adminField || !prefField) return out;
|
|
const votes = new Map();
|
|
for (let i = 0; i < adminField.length; i++) {
|
|
const admin = adminField[i];
|
|
const pref = prefField[i];
|
|
if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue;
|
|
if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue;
|
|
addMappingVote(votes, admin, pref, 1);
|
|
}
|
|
for (const [admin, bucket] of votes) {
|
|
if (!out.has(admin)) {
|
|
const pref = chooseVotedTarget(bucket, 1);
|
|
if (pref >= 0) out.set(admin, pref);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) {
|
|
const actualWindow = window || (rects ? sourceWindowForRects(rects) : null);
|
|
const actualRects = rects || { writeRect, writeMargin: seamBand || 1 };
|
|
if (!candidateMap || !world || !writeRect || !actualWindow) {
|
|
return {
|
|
prefecture: new Map(),
|
|
municipality: new Map(),
|
|
admin: new Map(),
|
|
candidateAdminToPrefecture: new Map(),
|
|
debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 },
|
|
};
|
|
}
|
|
|
|
const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId;
|
|
const candidateMunicipality = candidateMap.municipalityId || candidateAdmin;
|
|
const candidatePrefecture = candidateMap.prefectureRegionId;
|
|
const worldAdmin = world.fields?.adminId || world.fields?.municipalityId;
|
|
const worldMunicipality = world.fields?.municipalityId || worldAdmin;
|
|
const worldPrefecture = world.fields?.prefectureRegionId;
|
|
|
|
const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed);
|
|
const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed);
|
|
const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed);
|
|
const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds);
|
|
|
|
const adminVotes = new Map();
|
|
const municipalityVotes = new Map();
|
|
const prefectureVotes = new Map();
|
|
const band = Math.max(2, Math.floor(seamBand));
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
|
|
for (let y = writeRect.y0; y < writeRect.y1; y++) {
|
|
for (let x = writeRect.x0; x < writeRect.x1; x++) {
|
|
const edge = distanceToRectEdge(x, y, writeRect);
|
|
if (edge > band) continue;
|
|
const s = sourceCoordForWorld(actualWindow, x, y);
|
|
const si = sourceIndex(s.x, s.y);
|
|
const wi = worldIndex(world, x, y);
|
|
if (si < 0 || wi < 0) continue;
|
|
const cAdmin = candidateAdmin?.[si] ?? -1;
|
|
const cMunicipality = candidateMunicipality?.[si] ?? cAdmin;
|
|
const cPrefecture = candidatePrefecture?.[si] ?? -1;
|
|
const sameCellWeight = Math.max(1, band + 1 - edge);
|
|
addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight);
|
|
addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight);
|
|
addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight);
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
for (let step = 1; step <= 6; step++) {
|
|
const nx = x + dx * step;
|
|
const ny = y + dy * step;
|
|
if (insideRect(nx, ny, writeRect)) continue;
|
|
const ni = worldIndex(world, nx, ny);
|
|
if (ni < 0) break;
|
|
const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25;
|
|
addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w);
|
|
addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w);
|
|
addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const prefecture = new Map();
|
|
let nextPrefectureId = maxFieldId(worldPrefecture) + 1;
|
|
let prefecturesMappedToExisting = 0;
|
|
let prefecturesAllocated = 0;
|
|
for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) {
|
|
const voted = chooseVotedTarget(prefectureVotes.get(id), 3);
|
|
if (voted >= 0) {
|
|
prefecture.set(id, voted);
|
|
prefecturesMappedToExisting++;
|
|
} else {
|
|
prefecture.set(id, nextPrefectureId++);
|
|
prefecturesAllocated++;
|
|
}
|
|
}
|
|
|
|
const usedAdminIds = new Set();
|
|
if (worldAdmin) {
|
|
for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]);
|
|
}
|
|
const municipality = new Map();
|
|
const admin = new Map();
|
|
let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1;
|
|
let municipalitiesMappedToExisting = 0;
|
|
let municipalitiesAllocated = 0;
|
|
const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]);
|
|
for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) {
|
|
const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4);
|
|
if (voted >= 0) {
|
|
municipality.set(id, voted);
|
|
admin.set(id, voted);
|
|
municipalitiesMappedToExisting++;
|
|
} else {
|
|
while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++;
|
|
municipality.set(id, nextMunicipalityId);
|
|
admin.set(id, nextMunicipalityId);
|
|
usedAdminIds.add(nextMunicipalityId);
|
|
nextMunicipalityId++;
|
|
municipalitiesAllocated++;
|
|
}
|
|
}
|
|
|
|
const municipalityToPrefecture = new Map();
|
|
for (const [candidateAdminId, worldAdminId] of admin) {
|
|
const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId);
|
|
const worldPrefId = prefecture.get(candidatePrefId);
|
|
if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId);
|
|
}
|
|
|
|
return {
|
|
prefecture,
|
|
municipality,
|
|
admin,
|
|
candidateAdminToPrefecture,
|
|
municipalityToPrefecture,
|
|
debug: {
|
|
candidatePrefectureIds: candidatePrefectureIds.size,
|
|
candidateMunicipalityIds: allMunicipalityIds.size,
|
|
prefecturesMappedToExisting,
|
|
prefecturesAllocated,
|
|
municipalitiesMappedToExisting,
|
|
municipalitiesAllocated,
|
|
},
|
|
};
|
|
}
|
|
|
|
function remapAdminCandidateValue(name, raw, adminIdMapping) {
|
|
if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw;
|
|
const id = Math.floor(raw);
|
|
if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw;
|
|
if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw;
|
|
if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw;
|
|
return raw;
|
|
}
|
|
|
|
function numericFeatureId(point, keys) {
|
|
for (const key of keys) {
|
|
const value = point?.[key];
|
|
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function summarizeIdMapping(mapping) {
|
|
return { ...(mapping?.debug || {}) };
|
|
}
|
|
|
|
function updateSourceAdminMetadata(sourceMap, adminIdMapping) {
|
|
if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0;
|
|
let maxId = -1;
|
|
const current = sourceMap.municipalityToPrefectureId;
|
|
if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1);
|
|
for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId);
|
|
const next = new Int32Array(Math.max(0, maxId + 1));
|
|
next.fill(-1);
|
|
if (current && typeof current.length === "number") {
|
|
for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
|
|
}
|
|
let updated = 0;
|
|
for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) {
|
|
if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue;
|
|
if (next[adminId] !== prefId) updated++;
|
|
next[adminId] = prefId;
|
|
}
|
|
sourceMap.municipalityToPrefectureId = next;
|
|
sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping);
|
|
return updated;
|
|
}
|
|
|
|
function cloneContinuityFields(world) {
|
|
const out = new Map();
|
|
for (const name of CONTINUITY_FIELD_NAMES) {
|
|
const field = world?.fields?.[name];
|
|
if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function cloneHumanLandContinuityFields(world) {
|
|
const out = new Map();
|
|
for (const name of [
|
|
"elevation",
|
|
"roadInfluence",
|
|
"railInfluence2",
|
|
"stationInfluence",
|
|
"populationDensity",
|
|
"settlementScore",
|
|
"villageInfluence",
|
|
"plain",
|
|
"agriculture",
|
|
]) {
|
|
const field = world?.fields?.[name];
|
|
if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed = 0, seaLevel = 0.30) {
|
|
const sea = world.fields?.sea;
|
|
const ocean = world.fields?.ocean;
|
|
const lake = world.fields?.lake;
|
|
const elevation = world.fields?.elevation;
|
|
const landuse = world.fields?.landuse;
|
|
const rect = rects?.writeRect;
|
|
if (!sea || !rect || !oldSea) {
|
|
return { humanLandCellsRestored: 0, humanLandFeatureMaskCells: 0, humanLandCandidatesChecked: 0 };
|
|
}
|
|
|
|
const width = rectWidth(rect);
|
|
const height = rectHeight(rect);
|
|
const mask = new Float32Array(width * height);
|
|
const localIndex = (x, y) => (y - rect.y0) * width + (x - rect.x0);
|
|
const active = (x, y, minAlpha = 0.18) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= minAlpha;
|
|
|
|
const markDisk = (cx, cy, radius, weight) => {
|
|
cx = Math.round(cx); cy = Math.round(cy);
|
|
const r = Math.max(1, Math.floor(radius));
|
|
for (let y = cy - r; y <= cy + r; y++) {
|
|
for (let x = cx - r; x <= cx + r; x++) {
|
|
if (!active(x, y, 0.14)) continue;
|
|
const wi = worldIndex(world, x, y);
|
|
if (wi < 0 || oldSea[wi]) continue;
|
|
const d = Math.hypot(x - cx, y - cy);
|
|
if (d > r + 0.35) continue;
|
|
const li = localIndex(x, y);
|
|
const falloff = 1 - d / Math.max(1, r + 0.35);
|
|
mask[li] = Math.max(mask[li], weight * (0.35 + falloff * 0.65));
|
|
}
|
|
}
|
|
};
|
|
|
|
const markSegment = (ax, ay, bx, by, radius, weight) => {
|
|
const len = Math.max(1, Math.hypot(bx - ax, by - ay));
|
|
const steps = Math.max(1, Math.ceil(len / 2.0));
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = i / steps;
|
|
markDisk(ax + (bx - ax) * t, ay + (by - ay) * t, radius, weight);
|
|
}
|
|
};
|
|
|
|
let humanLandFeatureMaskCells = 0;
|
|
const markPathLayer = (key, radius, weight) => {
|
|
for (const path of sourceMap?.[key] || []) {
|
|
let prev = null;
|
|
for (const tuple of path || []) {
|
|
const x = tupleWorldX(world, tuple);
|
|
const y = tupleWorldY(world, tuple);
|
|
if (prev) markSegment(prev.x, prev.y, x, y, radius, weight);
|
|
prev = { x, y };
|
|
}
|
|
}
|
|
};
|
|
|
|
// Only the actual pre-existing transport centerlines should bias land
|
|
// restoration. Settlements, admin centers, ports, industrial sites, etc. are
|
|
// deliberately ignored here; otherwise patching a coastal/archipelago area
|
|
// over-preserves old human geography and resists legitimate water generation.
|
|
// The weights are intentionally weak: this pass is a continuity hint, not a
|
|
// hard constraint.
|
|
for (const key of ["expressways", "externalExpressways", "nationalRoads"]) markPathLayer(key, 3, 0.62);
|
|
for (const key of ["minorRoads", "premodernRoads", "ringRoads", "externalRoads", "icAccessRoads"]) markPathLayer(key, 2, 0.42);
|
|
for (const key of ["railways", "branchRailways", "ringRailways", "externalRailways"]) markPathLayer(key, 3, 0.66);
|
|
|
|
for (let i = 0; i < mask.length; i++) if (mask[i] > 0) humanLandFeatureMaskCells++;
|
|
|
|
const oldElevation = oldHumanFields?.get("elevation");
|
|
const scoreField = (name, i, weight) => (oldHumanFields?.get(name)?.[i] || 0) * weight;
|
|
let humanLandCellsRestored = 0;
|
|
let humanLandCandidatesChecked = 0;
|
|
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
if (!active(x, y, 0.20)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || oldSea[i] || !sea[i]) continue;
|
|
humanLandCandidatesChecked++;
|
|
const li = localIndex(x, y);
|
|
const score = mask[li]
|
|
+ scoreField("roadInfluence", i, 0.82)
|
|
+ scoreField("railInfluence2", i, 0.98)
|
|
+ scoreField("stationInfluence", i, 0.28);
|
|
if (score < 0.78) continue;
|
|
|
|
sea[i] = 0;
|
|
if (ocean) ocean[i] = 0;
|
|
if (lake) lake[i] = 0;
|
|
if (elevation) {
|
|
const oldElev = oldElevation?.[i];
|
|
const target = Number.isFinite(oldElev) ? Math.max(oldElev, seaLevel + 0.012) : seaLevel + 0.018;
|
|
elevation[i] = Math.max(elevation[i] || 0, target);
|
|
}
|
|
if (landuse) {
|
|
const oldUse = oldLanduse?.[i];
|
|
landuse[i] = oldUse && oldUse !== LANDUSE.WATER ? oldUse : (LANDUSE.RURAL || 0);
|
|
}
|
|
humanLandCellsRestored++;
|
|
}
|
|
}
|
|
|
|
return { humanLandCellsRestored, humanLandFeatureMaskCells, humanLandCandidatesChecked };
|
|
}
|
|
|
|
function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) {
|
|
let restored = 0;
|
|
let remapped = 0;
|
|
const margin = Math.max(2, rects.writeMargin || 1);
|
|
for (const name of CONTINUITY_FIELD_NAMES) {
|
|
const field = world.fields?.[name];
|
|
const old = oldFields?.get(name);
|
|
if (!field || !old) continue;
|
|
const isPrefecture = name === "prefectureRegionId";
|
|
const isAdmin = name === "adminId" || name === "municipalityId";
|
|
const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74;
|
|
const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72;
|
|
|
|
// First preserve the old IDs in the transition band. This prevents the
|
|
// writeRect edge from becoming a prefecture/municipal border.
|
|
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 || old[i] < 0) continue;
|
|
const edge = distanceToRectEdge(x, y, rects.writeRect);
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (edge <= preserveEdge || a < preserveAlpha) {
|
|
if (field[i] !== old[i]) { field[i] = old[i]; restored++; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then map candidate IDs that contact an outside ID back to that outside ID.
|
|
// This lets prefectures/municipalities cross the generated-area seam instead
|
|
// of creating a new border exactly on the seam.
|
|
const contacts = new Map();
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) {
|
|
for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || field[i] < 0 || old[i] === field[i]) continue;
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (a < 0.98 && !isPrefecture) continue;
|
|
for (const [dx, dy] of dirs) {
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue;
|
|
if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) {
|
|
const key = field[i];
|
|
const bucket = contacts.get(key) || new Map();
|
|
bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1);
|
|
contacts.set(key, bucket);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const mapping = new Map();
|
|
for (const [from, bucket] of contacts) {
|
|
let best = -1, bestCount = 0;
|
|
for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; }
|
|
if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best);
|
|
}
|
|
if (mapping.size) {
|
|
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 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { continuityCellsRestored: restored, continuityCellsRemapped: remapped };
|
|
}
|
|
|
|
function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) {
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
const i = worldIndex(world, x, y);
|
|
const oldValue = oldField?.[i] ?? -1;
|
|
if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue;
|
|
if (a <= 0.24) return oldValue;
|
|
if (a >= 0.82) return candidateValue;
|
|
|
|
const field = world.fields?.[fieldName];
|
|
const pref = world.fields?.prefectureRegionId;
|
|
const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField;
|
|
let oldScore = (1 - a) * 3.0;
|
|
let candidateScore = a * 3.0;
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
if (ni < 0) continue;
|
|
const neighbor = field?.[ni] ?? -1;
|
|
if (neighbor === oldValue) oldScore += 1.2;
|
|
if (neighbor === candidateValue) candidateScore += 1.2;
|
|
if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) {
|
|
if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18;
|
|
}
|
|
}
|
|
const barrierBonus = naturalBarrier?.[i] || 0;
|
|
if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) {
|
|
if (a < 0.5) oldScore += barrierBonus * 0.9;
|
|
else candidateScore += barrierBonus * 0.9;
|
|
}
|
|
return candidateScore > oldScore ? candidateValue : oldValue;
|
|
}
|
|
|
|
function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
|
|
let adminSeamCellsResolved = 0;
|
|
let prefectureSeamCellsResolved = 0;
|
|
for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) {
|
|
const field = world.fields?.[name];
|
|
const old = oldFields?.get(name);
|
|
if (!field || !old) continue;
|
|
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 || world.fields.sea?.[i]) continue;
|
|
if (patchBand(x, y, rects, seed) !== "feather") continue;
|
|
const before = field[i];
|
|
const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed);
|
|
if (next !== before) {
|
|
field[i] = next;
|
|
if (name === "prefectureRegionId") prefectureSeamCellsResolved++;
|
|
else adminSeamCellsResolved++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (world.fields.adminId && world.fields.municipalityId) {
|
|
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 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i];
|
|
}
|
|
}
|
|
}
|
|
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
|
|
}
|
|
|
|
function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) {
|
|
const window = sourceWindowForRects(rects);
|
|
const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed);
|
|
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
|
const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null;
|
|
const oldHumanFields = cloneHumanLandContinuityFields(world);
|
|
const oldContinuityFields = cloneContinuityFields(world);
|
|
const adminIdMapping = buildAdminIdMapping({
|
|
candidateMap: candidate,
|
|
world,
|
|
writeRect: rects.writeRect,
|
|
seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)),
|
|
window,
|
|
rects,
|
|
seed,
|
|
});
|
|
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 si = sourceIndexForWorld(rects, window, x, y);
|
|
if (si < 0) continue;
|
|
const alpha = patchAlpha(x, y, rects, seed);
|
|
if (alpha <= 0.005) continue;
|
|
|
|
if (isDiscrete) {
|
|
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
|
|
if (alpha >= threshold) {
|
|
const raw = source[si];
|
|
const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
|
|
const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
|
|
? mapped
|
|
: 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;
|
|
let candidateValue = source[si] || 0;
|
|
if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment);
|
|
dest[wi] = lerp(before, candidateValue, alpha);
|
|
}
|
|
|
|
if (name === "elevation") {
|
|
updatedCells++;
|
|
if (alpha > 0.94) terrainCellsFullyReplaced++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The legacy full pipeline uses `adminId` as the municipality raster and
|
|
// assigns municipality metadata on `adminCenters`; it does not expose a
|
|
// separate municipalityId cell field. If an old experimental field exists,
|
|
// keep it synchronized with the canonical legacy adminId instead of leaving
|
|
// stale numeric/one-municipality data in regenerated patches.
|
|
if (world.fields.adminId && !candidate?.municipalityId) {
|
|
const expected = world.width * world.height;
|
|
if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
|
|
world.fields.municipalityId = new Int32Array(expected);
|
|
world.fields.municipalityId.fill(-1);
|
|
}
|
|
const municipalityId = world.fields.municipalityId;
|
|
const adminId = world.fields.adminId;
|
|
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) municipalityId[wi] = adminId[wi];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const humanLandDebug = restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed, seaLevel);
|
|
const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed);
|
|
const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed);
|
|
if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) {
|
|
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) world.fields.municipalityId[wi] = world.fields.adminId[wi];
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
window,
|
|
updatedCells,
|
|
terrainCellsFullyReplaced,
|
|
coastCellsChanged,
|
|
naturalRegionsUpdated,
|
|
adminCellsReassigned,
|
|
landUseCellsUpdated,
|
|
adminIdMapping,
|
|
adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
|
|
terrainSeamAdjustmentSamples: elevationAdjustment?.samples || 0,
|
|
terrainSeamElevationOffset: elevationAdjustment ? Math.round((elevationAdjustment.offset || 0) * 10000) / 10000 : 0,
|
|
terrainSeamElevationTiltX: elevationAdjustment ? Math.round((elevationAdjustment.tiltX || 0) * 10000) / 10000 : 0,
|
|
terrainSeamElevationTiltY: elevationAdjustment ? Math.round((elevationAdjustment.tiltY || 0) * 10000) / 10000 : 0,
|
|
...humanLandDebug,
|
|
...continuityDebug,
|
|
...seamOwnershipDebug,
|
|
};
|
|
}
|
|
|
|
|
|
function repairDisplayMasks(world, rects, seed = 0) {
|
|
const expected = world.width * world.height;
|
|
if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected);
|
|
if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected);
|
|
if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected);
|
|
const coverage = world.fields.prefectureMask;
|
|
const landMask = world.fields.landMask;
|
|
const humanMask = world.fields.humanRegionMask;
|
|
const sea = world.fields.sea;
|
|
let displayMaskUpdated = 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;
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (a <= 0.08) continue;
|
|
if (!coverage[i]) displayMaskUpdated++;
|
|
coverage[i] = 1;
|
|
const isSea = Boolean(sea?.[i]);
|
|
humanMask[i] = isSea ? 0 : 1;
|
|
landMask[i] = isSea ? 0 : 1;
|
|
}
|
|
}
|
|
return { displayMaskUpdated };
|
|
}
|
|
|
|
function featherTerrainSeam(world, rects, seed = 0) {
|
|
const fields = world.fields || {};
|
|
const smoothKeys = [
|
|
"elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField",
|
|
"basinField", "coastalLowland", "plain", "agriculture", "erosionField",
|
|
"depositionField", "depositionalLowland", "alluvialFanField", "deltaField",
|
|
"naturalBarrierScore", "settlementScore", "populationDensity",
|
|
];
|
|
let terrainFeatherCells = 0;
|
|
let terrainFeatherValues = 0;
|
|
|
|
// Earlier versions cloned each whole typed array here. After the world grows,
|
|
// that made every patch pay for global memory copies. The seam smoother only
|
|
// samples the write rectangle and its one-cell neighborhood, so snapshot just
|
|
// that local window.
|
|
const sampleRect = expandRect(rects.writeRect, 1, world);
|
|
const sw = rectWidth(sampleRect);
|
|
const localOffset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0);
|
|
|
|
for (const key of smoothKeys) {
|
|
const field = fields[key];
|
|
if (!field || !ArrayBuffer.isView(field)) continue;
|
|
const old = new field.constructor(sw * rectHeight(sampleRect));
|
|
for (let y = sampleRect.y0; y < sampleRect.y1; y++) {
|
|
for (let x = sampleRect.x0; x < sampleRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i >= 0) old[localOffset(x, y)] = field[i] || 0;
|
|
}
|
|
}
|
|
const oldValue = (x, y) => {
|
|
if (insideRect(x, y, sampleRect)) return old[localOffset(x, y)] || 0;
|
|
const i = worldIndex(world, x, y);
|
|
return i >= 0 ? (field[i] || 0) : 0;
|
|
};
|
|
|
|
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
|
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
|
if (patchBand(x, y, rects, seed) !== "feather") continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || fields.sea?.[i]) continue;
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
if (ni >= 0 && !fields.sea?.[ni]) {
|
|
sum += oldValue(x + dx, y + dy);
|
|
count++;
|
|
}
|
|
}
|
|
if (!count) continue;
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
const neighborMean = sum / count;
|
|
const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2);
|
|
field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34));
|
|
terrainFeatherValues++;
|
|
if (key === "elevation") terrainFeatherCells++;
|
|
}
|
|
}
|
|
}
|
|
return { terrainFeatherCells, terrainFeatherValues };
|
|
}
|
|
|
|
function smoothExtremeElevationSeams(world, rects, seed = 0) {
|
|
const elevation = world.fields?.elevation;
|
|
const sea = world.fields?.sea;
|
|
if (!elevation || !rects?.writeRect) return { elevationCliffCellsSmoothed: 0, elevationCliffMaxDelta: 0 };
|
|
const rect = rects.writeRect;
|
|
const sampleRect = expandRect(rect, 1, world);
|
|
const sw = rectWidth(sampleRect);
|
|
const sh = rectHeight(sampleRect);
|
|
const offset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0);
|
|
let cells = 0;
|
|
let maxDelta = 0;
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
const old = new Float32Array(sw * sh);
|
|
for (let y = sampleRect.y0; y < sampleRect.y1; y++) {
|
|
for (let x = sampleRect.x0; x < sampleRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i >= 0) old[offset(x, y)] = elevation[i] || 0;
|
|
}
|
|
}
|
|
let passCells = 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 || sea?.[i]) continue;
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
if (a <= 0.005 || a >= 0.82) continue;
|
|
const here = old[offset(x, y)] || 0;
|
|
let sum = 0;
|
|
let count = 0;
|
|
let strongest = 0;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
const ni = worldIndex(world, nx, ny);
|
|
if (ni < 0 || sea?.[ni]) continue;
|
|
const na = patchAlpha(nx, ny, rects, seed);
|
|
// Only pull the generated seam toward less-generated or preserved
|
|
// neighbors. This smooths cliffs at the patch boundary without
|
|
// blurring the interior terrain that the candidate intentionally made.
|
|
if (na > a + 0.08 && insideRect(nx, ny, rect)) continue;
|
|
const nv = insideRect(nx, ny, sampleRect) ? old[offset(nx, ny)] : (elevation[ni] || 0);
|
|
const d = Math.abs(here - nv);
|
|
strongest = Math.max(strongest, d);
|
|
sum += nv;
|
|
count++;
|
|
}
|
|
if (!count || strongest < 0.075) continue;
|
|
const mean = sum / count;
|
|
const diff = Math.abs(here - mean);
|
|
if (diff < 0.055) continue;
|
|
const severity = clamp((diff - 0.045) / 0.20);
|
|
const seamBias = clamp(1 - a * 0.72, 0.14, 0.88);
|
|
const weight = clamp(0.18 + severity * 0.48, 0.18, 0.62) * seamBias;
|
|
elevation[i] = clamp(lerp(elevation[i], mean, weight), 0, 1);
|
|
maxDelta = Math.max(maxDelta, diff);
|
|
passCells++;
|
|
}
|
|
}
|
|
cells += passCells;
|
|
if (!passCells) break;
|
|
}
|
|
return { elevationCliffCellsSmoothed: cells, elevationCliffMaxDelta: Math.round(maxDelta * 10000) / 10000 };
|
|
}
|
|
|
|
function averageNearbyLandElevation(world, x, y, maxRadius = 5) {
|
|
const elevation = world.fields?.elevation;
|
|
const sea = world.fields?.sea;
|
|
if (!elevation) return null;
|
|
for (let r = 1; r <= maxRadius; r++) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let yy = y - r; yy <= y + r; yy++) {
|
|
for (let xx = x - r; xx <= x + r; xx++) {
|
|
if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue;
|
|
const i = worldIndex(world, xx, yy);
|
|
if (i >= 0 && !sea?.[i] && Number.isFinite(elevation[i])) { sum += elevation[i]; count++; }
|
|
}
|
|
}
|
|
if (count) return sum / count;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) {
|
|
const sea = world.fields?.sea;
|
|
if (!sea || !rects?.writeRect) return { residualSeaPatchesFilled: 0, residualSeaCellsFilled: 0 };
|
|
const rect = rects.writeRect;
|
|
const visited = new Uint8Array(rectWidth(rect) * rectHeight(rect));
|
|
const local = (x, y) => (y - rect.y0) * rectWidth(rect) + (x - rect.x0);
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
const maxComponent = Math.max(8, Math.min(72, Math.floor((rectWidth(rect) * rectHeight(rect)) * 0.0025)));
|
|
let patches = 0;
|
|
let cellsFilled = 0;
|
|
|
|
for (let sy = rect.y0; sy < rect.y1; sy++) {
|
|
for (let sx = rect.x0; sx < rect.x1; sx++) {
|
|
const startLocal = local(sx, sy);
|
|
if (visited[startLocal]) continue;
|
|
const startIndex = worldIndex(world, sx, sy);
|
|
if (startIndex < 0 || !sea[startIndex]) { visited[startLocal] = 1; continue; }
|
|
|
|
const queue = [[sx, sy]];
|
|
const cells = [];
|
|
visited[startLocal] = 1;
|
|
let touchesOutside = false;
|
|
let landContacts = 0;
|
|
let waterContacts = 0;
|
|
let maxAlpha = 0;
|
|
let seamTouches = 0;
|
|
for (let qi = 0; qi < queue.length; qi++) {
|
|
const [x, y] = queue[qi];
|
|
cells.push([x, y]);
|
|
const a = patchAlpha(x, y, rects, seed);
|
|
maxAlpha = Math.max(maxAlpha, a);
|
|
if (a < 0.34) seamTouches++;
|
|
if (x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) touchesOutside = true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!insideRect(nx, ny, rect)) { touchesOutside = true; continue; }
|
|
const ni = worldIndex(world, nx, ny);
|
|
if (ni < 0) { touchesOutside = true; continue; }
|
|
if (sea[ni]) {
|
|
const li = local(nx, ny);
|
|
if (!visited[li]) { visited[li] = 1; queue.push([nx, ny]); }
|
|
waterContacts++;
|
|
} else {
|
|
landContacts++;
|
|
}
|
|
}
|
|
if (cells.length > maxComponent * 3) break;
|
|
}
|
|
|
|
// Fill only tiny enclosed sea remnants near the inward feather band. This
|
|
// is meant to remove lasso hand-jitter pinholes, not real bays, lakes, or
|
|
// island channels created by the candidate terrain.
|
|
if (touchesOutside || cells.length > maxComponent || maxAlpha > 0.70 || seamTouches < Math.max(1, Math.floor(cells.length * 0.30)) || landContacts < waterContacts * 2.2) continue;
|
|
|
|
for (const [x, y] of cells) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
sea[i] = 0;
|
|
if (world.fields.ocean) world.fields.ocean[i] = 0;
|
|
if (world.fields.lake) world.fields.lake[i] = 0;
|
|
const landElevation = averageNearbyLandElevation(world, x, y, 6);
|
|
if (world.fields.elevation) world.fields.elevation[i] = clamp(Math.max(seaLevel + 0.012, landElevation ?? (seaLevel + 0.018)), 0, 1);
|
|
if (world.fields.landuse) {
|
|
const lu = nearestLandFieldValue(world, x, y, "landuse", expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 8, world), { maxRadius: 8 });
|
|
world.fields.landuse[i] = lu >= 0 ? lu : (LANDUSE.FOREST || 1);
|
|
}
|
|
for (const key of ["adminId", "municipalityId", "prefectureRegionId"]) {
|
|
if (!world.fields[key]) continue;
|
|
const value = nearestLandFieldValue(world, x, y, key, expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 12, world), { maxRadius: 12 });
|
|
if (value >= 0) world.fields[key][i] = value;
|
|
}
|
|
}
|
|
patches++;
|
|
cellsFilled += cells.length;
|
|
}
|
|
}
|
|
return { residualSeaPatchesFilled: patches, residualSeaCellsFilled: cellsFilled };
|
|
}
|
|
|
|
function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
|
|
const field = world.fields?.[fieldName];
|
|
const sea = world.fields?.sea;
|
|
if (!field) return -1;
|
|
const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18));
|
|
const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null;
|
|
const prefField = world.fields?.prefectureRegionId;
|
|
for (let r = 1; r <= maxRadius; r++) {
|
|
let best = -1;
|
|
let bestD = Infinity;
|
|
const y0 = Math.max(0, y - r);
|
|
const y1 = Math.min(world.height - 1, y + r);
|
|
const x0 = Math.max(0, x - r);
|
|
const x1 = Math.min(world.width - 1, x + r);
|
|
for (let yy = y0; yy <= y1; yy++) {
|
|
for (let xx = x0; xx <= x1; xx++) {
|
|
if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue;
|
|
if (rect && !insideRect(xx, yy, rect)) continue;
|
|
const i = worldIndex(world, xx, yy);
|
|
if (i < 0 || sea?.[i]) continue;
|
|
if (requiredPref !== null && prefField?.[i] !== requiredPref) continue;
|
|
const id = field[i];
|
|
if (!Number.isFinite(id) || id < 0) continue;
|
|
const d = Math.hypot(xx - x, yy - y);
|
|
if (d < bestD) { best = Math.floor(id); bestD = d; }
|
|
}
|
|
}
|
|
if (best >= 0) return best;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
|
|
if (!Number.isFinite(adminId) || adminId < 0) return -1;
|
|
const id = Math.floor(adminId);
|
|
const mapped = adminIdMapping?.municipalityToPrefecture?.get(id);
|
|
if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped);
|
|
const table = sourceMap?.municipalityToPrefectureId;
|
|
if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]);
|
|
return -1;
|
|
}
|
|
|
|
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) {
|
|
const admin = world.fields?.adminId;
|
|
if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
|
|
const expected = world.width * world.height;
|
|
if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
|
|
world.fields.municipalityId = new Int32Array(expected);
|
|
world.fields.municipalityId.fill(-1);
|
|
}
|
|
if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) {
|
|
world.fields.prefectureRegionId = new Int32Array(expected);
|
|
world.fields.prefectureRegionId.fill(-1);
|
|
}
|
|
const municipality = world.fields.municipalityId;
|
|
const prefecture = world.fields.prefectureRegionId;
|
|
const sea = world.fields.sea;
|
|
const coverage = world.fields.prefectureMask;
|
|
let seaAdminCellsCleared = 0;
|
|
let landAdminCellsFilled = 0;
|
|
let prefectureCellsFilled = 0;
|
|
let adminPrefectureCellsAligned = 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 (sea?.[i]) {
|
|
if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++;
|
|
admin[i] = -1;
|
|
municipality[i] = -1;
|
|
prefecture[i] = -1;
|
|
continue;
|
|
}
|
|
const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect));
|
|
if (!generated) continue;
|
|
|
|
if (admin[i] < 0) {
|
|
const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null;
|
|
let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 });
|
|
if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 });
|
|
if (nearest >= 0) {
|
|
admin[i] = nearest;
|
|
municipality[i] = nearest;
|
|
landAdminCellsFilled++;
|
|
}
|
|
}
|
|
if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i];
|
|
if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i];
|
|
|
|
let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]);
|
|
if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 });
|
|
if (targetPref >= 0 && prefecture[i] !== targetPref) {
|
|
if (prefecture[i] < 0) prefectureCellsFilled++;
|
|
else adminPrefectureCellsAligned++;
|
|
prefecture[i] = targetPref;
|
|
} else if (prefecture[i] < 0) {
|
|
const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 });
|
|
if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; }
|
|
}
|
|
}
|
|
}
|
|
|
|
const current = sourceMap?.municipalityToPrefectureId;
|
|
let maxId = current?.length ? current.length - 1 : -1;
|
|
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 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]);
|
|
}
|
|
}
|
|
if (sourceMap && maxId >= 0) {
|
|
const next = new Int32Array(maxId + 1);
|
|
next.fill(-1);
|
|
if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
|
|
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 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i];
|
|
}
|
|
}
|
|
sourceMap.municipalityToPrefectureId = next;
|
|
}
|
|
|
|
return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
|
|
}
|
|
|
|
function protectedAdministrativeCells(world, fieldName, rect) {
|
|
const protectedCells = new Set();
|
|
const sourceMap = world?.sourceMap || {};
|
|
const pointKeys = fieldName === "prefectureRegionId" ? ["prefectureRegions"] : ["adminCenters"];
|
|
const field = world?.fields?.[fieldName];
|
|
if (!field) return protectedCells;
|
|
for (const key of pointKeys) {
|
|
for (const p of sourceMap[key] || []) {
|
|
const x = Math.round(pointWorldX(world, p));
|
|
const y = Math.round(pointWorldY(world, p));
|
|
if (!insideRect(x, y, rect)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i >= 0 && field[i] >= 0) protectedCells.add(i);
|
|
}
|
|
}
|
|
return protectedCells;
|
|
}
|
|
|
|
function modeFromCounts(counts) {
|
|
let best = -1;
|
|
let bestCount = 0;
|
|
for (const [id, count] of counts || []) {
|
|
if (count > bestCount || (count === bestCount && id < best)) {
|
|
best = id;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function addNestedVote(map, key, value, weight = 1) {
|
|
if (!Number.isFinite(key) || key < 0 || !Number.isFinite(value) || value < 0) return;
|
|
const id = Math.floor(key);
|
|
const bucket = map.get(id) || new Map();
|
|
bucket.set(Math.floor(value), (bucket.get(Math.floor(value)) || 0) + weight);
|
|
map.set(id, bucket);
|
|
}
|
|
|
|
function cleanupDiscreteFieldComponents(world, fieldName, rect, options = {}) {
|
|
const field = world?.fields?.[fieldName];
|
|
const sea = world?.fields?.sea;
|
|
if (!field || !rect) return { componentsMerged: 0, cellsMerged: 0 };
|
|
const minCells = Math.max(1, Math.floor(options.minCells || 80));
|
|
const passes = Math.max(1, Math.floor(options.passes || 2));
|
|
const respectPrefecture = !!options.respectPrefecture;
|
|
const pref = world.fields?.prefectureRegionId;
|
|
const protectedCells = protectedAdministrativeCells(world, fieldName, rect);
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
|
let componentsMerged = 0;
|
|
let cellsMerged = 0;
|
|
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
const seen = new Uint8Array(world.width * world.height);
|
|
const reassignments = [];
|
|
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const start = worldIndex(world, x, y);
|
|
if (start < 0 || seen[start] || sea?.[start] || field[start] < 0) continue;
|
|
|
|
const id = field[start];
|
|
const stack = [start];
|
|
seen[start] = 1;
|
|
const cells = [];
|
|
const neighborVotes = new Map();
|
|
const neighborPrefVotes = new Map();
|
|
const ownPrefVotes = new Map();
|
|
let touchesRectEdge = false;
|
|
let hasProtectedPoint = false;
|
|
|
|
while (stack.length) {
|
|
const ci = stack.pop();
|
|
const cx = ci % world.width;
|
|
const cy = Math.floor(ci / world.width);
|
|
cells.push(ci);
|
|
if (protectedCells.has(ci)) hasProtectedPoint = true;
|
|
if (cx <= rect.x0 || cy <= rect.y0 || cx >= rect.x1 - 1 || cy >= rect.y1 - 1) touchesRectEdge = true;
|
|
if (pref?.[ci] >= 0) ownPrefVotes.set(pref[ci], (ownPrefVotes.get(pref[ci]) || 0) + 1);
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = cx + dx;
|
|
const ny = cy + dy;
|
|
const ni = worldIndex(world, nx, ny);
|
|
if (ni < 0 || sea?.[ni]) continue;
|
|
const nid = field[ni];
|
|
if (insideRect(nx, ny, rect) && nid === id && !seen[ni]) {
|
|
seen[ni] = 1;
|
|
stack.push(ni);
|
|
} else if (nid >= 0 && nid !== id) {
|
|
neighborVotes.set(nid, (neighborVotes.get(nid) || 0) + 1);
|
|
if (pref?.[ni] >= 0) addNestedVote(neighborPrefVotes, nid, pref[ni], 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Components touching the cleanup rectangle boundary may be only the
|
|
// visible slice of a large outside municipality/prefecture. Preserve
|
|
// those unless they are extremely small; otherwise patched seams can
|
|
// erase legitimate existing regions.
|
|
const clippedLargeOutsideRegion = touchesRectEdge && cells.length >= Math.floor(minCells * 0.55);
|
|
if (hasProtectedPoint || clippedLargeOutsideRegion || cells.length >= minCells || !neighborVotes.size) continue;
|
|
|
|
const componentPref = respectPrefecture ? modeFromCounts(ownPrefVotes) : -1;
|
|
let bestTarget = -1;
|
|
let bestScore = -Infinity;
|
|
for (const [target, count] of neighborVotes) {
|
|
let score = count;
|
|
if (respectPrefecture && componentPref >= 0) {
|
|
const targetPref = modeFromCounts(neighborPrefVotes.get(target));
|
|
if (targetPref === componentPref) score += count * 0.85;
|
|
else score -= count * 0.45;
|
|
}
|
|
if (score > bestScore || (score === bestScore && target < bestTarget)) {
|
|
bestTarget = target;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
if (bestTarget < 0) continue;
|
|
for (const ci of cells) reassignments.push([ci, bestTarget]);
|
|
componentsMerged++;
|
|
cellsMerged += cells.length;
|
|
}
|
|
}
|
|
|
|
if (!reassignments.length) break;
|
|
for (const [i, target] of reassignments) field[i] = target;
|
|
}
|
|
|
|
return { componentsMerged, cellsMerged };
|
|
}
|
|
|
|
function repairPatchAdministrativeTopology(world, rects) {
|
|
const rect = rects.repairRect || rects.writeRect;
|
|
const prefecture = cleanupDiscreteFieldComponents(world, "prefectureRegionId", rect, { minCells: 420, passes: 3 });
|
|
const admin = cleanupDiscreteFieldComponents(world, "adminId", rect, { minCells: 96, passes: 4, respectPrefecture: true });
|
|
let municipalityCellsSynced = 0;
|
|
if (world.fields?.adminId && world.fields?.municipalityId) {
|
|
const adminId = world.fields.adminId;
|
|
const municipalityId = world.fields.municipalityId;
|
|
const sea = world.fields.sea;
|
|
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;
|
|
const next = sea?.[i] ? -1 : adminId[i];
|
|
if (municipalityId[i] !== next) {
|
|
municipalityId[i] = next;
|
|
municipalityCellsSynced++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
prefectureTinyComponentsMerged: prefecture.componentsMerged,
|
|
prefectureTinyCellsMerged: prefecture.cellsMerged,
|
|
adminTinyComponentsMerged: admin.componentsMerged,
|
|
adminTinyCellsMerged: admin.cellsMerged,
|
|
municipalityCellsSynced,
|
|
};
|
|
}
|
|
|
|
function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) {
|
|
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++;
|
|
}
|
|
}
|
|
const a = rects ? patchAlpha(x, y, rects, seed) : 1;
|
|
if (a < 0.24) continue;
|
|
const strongOnly = a < 0.42;
|
|
if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
|
|
else if (!sea[i] && seaN >= (strongOnly ? 8 : 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 repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) {
|
|
const sea = world.fields.sea;
|
|
const ocean = world.fields.ocean;
|
|
const lake = world.fields.lake;
|
|
const elevation = world.fields.elevation;
|
|
const landuse = world.fields.landuse;
|
|
const rect = rects?.writeRect;
|
|
if (!sea || !rect) return { waterComponentsScanned: 0, tinyWaterComponentsRemoved: 0, tinyLandIslandsRemoved: 0, waterTopologyCellsFlipped: 0 };
|
|
|
|
const expected = world.width * world.height;
|
|
const visited = new Uint8Array(expected);
|
|
const activeMinAlpha = 0.22;
|
|
const preserveAlpha = 0.40;
|
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
const active = (x, y) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= activeMinAlpha;
|
|
const tinyWaterLimit = Math.max(14, Math.min(36, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.20)));
|
|
const tinyLandLimit = Math.max(10, Math.min(28, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.16)));
|
|
|
|
let waterComponentsScanned = 0;
|
|
let tinyWaterComponentsRemoved = 0;
|
|
let tinyLandIslandsRemoved = 0;
|
|
let waterTopologyCellsFlipped = 0;
|
|
|
|
const flipCell = (i, nextSea) => {
|
|
if (sea[i] === nextSea) return;
|
|
sea[i] = nextSea;
|
|
if (ocean) ocean[i] = nextSea;
|
|
if (lake) lake[i] = 0;
|
|
if (elevation) {
|
|
if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
|
|
else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
|
|
}
|
|
if (landuse) landuse[i] = nextSea ? (LANDUSE.WATER || LANDUSE.RURAL || 0) : (LANDUSE.RURAL || 0);
|
|
waterTopologyCellsFlipped++;
|
|
};
|
|
|
|
for (let y0 = rect.y0; y0 < rect.y1; y0++) {
|
|
for (let x0 = rect.x0; x0 < rect.x1; x0++) {
|
|
if (!active(x0, y0)) continue;
|
|
const start = worldIndex(world, x0, y0);
|
|
if (start < 0 || visited[start]) continue;
|
|
const value = sea[start] ? 1 : 0;
|
|
const stack = [[x0, y0]];
|
|
const cells = [];
|
|
let sumElevation = 0;
|
|
let elevationCount = 0;
|
|
let touchesWeakPatchEdge = false;
|
|
let touchesSameOutsideActive = false;
|
|
let oppositeBorder = 0;
|
|
let sameBorder = 0;
|
|
|
|
visited[start] = 1;
|
|
while (stack.length) {
|
|
const [x, y] = stack.pop();
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
cells.push(i);
|
|
if (elevation) { sumElevation += elevation[i] || 0; elevationCount++; }
|
|
if (patchAlpha(x, y, rects, seed) < preserveAlpha || x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) {
|
|
touchesWeakPatchEdge = true;
|
|
}
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
const ni = worldIndex(world, nx, ny);
|
|
if (ni < 0) continue;
|
|
const nv = sea[ni] ? 1 : 0;
|
|
if (nv !== value) {
|
|
oppositeBorder++;
|
|
continue;
|
|
}
|
|
sameBorder++;
|
|
if (!active(nx, ny)) {
|
|
touchesSameOutsideActive = true;
|
|
continue;
|
|
}
|
|
if (!visited[ni]) {
|
|
visited[ni] = 1;
|
|
stack.push([nx, ny]);
|
|
}
|
|
}
|
|
}
|
|
|
|
waterComponentsScanned++;
|
|
const area = cells.length;
|
|
const avgElevation = elevationCount ? sumElevation / elevationCount : seaLevel;
|
|
const isolatedInsidePatch = !touchesWeakPatchEdge && !touchesSameOutsideActive;
|
|
if (value === 1) {
|
|
if (isolatedInsidePatch && area <= tinyWaterLimit && avgElevation > seaLevel - 0.055) {
|
|
for (const i of cells) flipCell(i, 0);
|
|
tinyWaterComponentsRemoved++;
|
|
}
|
|
} else {
|
|
const mostlySurroundedBySea = oppositeBorder > sameBorder * 0.72;
|
|
if (isolatedInsidePatch && mostlySurroundedBySea && area <= tinyLandLimit && avgElevation < seaLevel + 0.045) {
|
|
for (const i of cells) flipCell(i, 1);
|
|
tinyLandIslandsRemoved++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped };
|
|
}
|
|
|
|
function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) {
|
|
const elevation = world.fields?.elevation;
|
|
const sea = world.fields?.sea;
|
|
if (!elevation || !sea || !rects?.writeRect) return { waterElevationCellsSmoothed: 0 };
|
|
const rect = rects.writeRect;
|
|
let waterElevationCellsSmoothed = 0;
|
|
|
|
// Do not diffuse water elevation row-by-row. Diffusion made broad patched
|
|
// ocean/sea areas inherit candidate raster bands, which appeared as horizontal
|
|
// stripes. Instead assign a stable world-coordinate bathymetry target and
|
|
// blend toward it by patch alpha. The renderer also avoids DEM hillshade for
|
|
// water, but keeping the underlying water DEM coherent prevents dependent
|
|
// fields from reintroducing stripe artefacts later.
|
|
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 a = patchAlpha(x, y, rects, seed);
|
|
if (a < 0.08) continue;
|
|
|
|
let seaNear = 0;
|
|
let totalNear = 0;
|
|
for (let dy = -3; dy <= 3; dy++) {
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
if (ni < 0) continue;
|
|
totalNear++;
|
|
if (sea[ni]) seaNear++;
|
|
}
|
|
}
|
|
const offshore = totalNear ? seaNear / totalNear : 1;
|
|
const broad = valueNoise(x, y, seed ^ 0x6d2b79f5, 86);
|
|
const mid = valueNoise(x, y, seed ^ 0x2f31c9a7, 31);
|
|
const texture = broad * 0.75 + mid * 0.25;
|
|
const depth = clamp(0.032 + offshore * 0.060 + (texture - 0.5) * 0.018, 0.018, 0.125);
|
|
const target = seaLevel - depth;
|
|
const before = elevation[i];
|
|
const strength = clamp(0.46 + a * 0.42, 0.42, 0.86);
|
|
elevation[i] = clamp(lerp(Math.min(before, seaLevel - 0.004), target, strength), seaLevel - 0.16, seaLevel - 0.004);
|
|
if (Math.abs(elevation[i] - before) > 1e-6) waterElevationCellsSmoothed++;
|
|
}
|
|
}
|
|
|
|
return { waterElevationCellsSmoothed };
|
|
}
|
|
|
|
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 landNeighbors(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) {
|
|
const out = path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
|
|
// Arrays can carry lightweight metadata in JS. Marking generated paths lets
|
|
// a later Alternative generation remove the prior variant completely instead
|
|
// of leaving low-alpha edge fragments behind.
|
|
out.patchGenerated = true;
|
|
return out;
|
|
}
|
|
|
|
function offsetPointNumericFields(point, fields, offset) {
|
|
for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset;
|
|
}
|
|
|
|
function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) {
|
|
const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]);
|
|
if (rawAdminId >= 0) {
|
|
const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId);
|
|
if (Number.isFinite(mappedAdminId)) {
|
|
point.sourceAdminId = rawAdminId;
|
|
point.adminId = mappedAdminId;
|
|
point.adminNumericId = mappedAdminId;
|
|
point.municipalityId = mappedAdminId;
|
|
} else {
|
|
offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed));
|
|
if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId;
|
|
if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId;
|
|
}
|
|
}
|
|
|
|
const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]);
|
|
if (rawPrefectureId >= 0) {
|
|
const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId);
|
|
if (Number.isFinite(mappedPrefectureId)) {
|
|
point.sourcePrefectureRegionId = rawPrefectureId;
|
|
point.prefectureRegionId = mappedPrefectureId;
|
|
if (key === "prefectureRegions") point.id = mappedPrefectureId;
|
|
} else {
|
|
const offset = fieldIdOffset("prefectureRegionId", seed);
|
|
if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset;
|
|
if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset;
|
|
}
|
|
}
|
|
|
|
if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId;
|
|
if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId;
|
|
return point;
|
|
}
|
|
|
|
function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) {
|
|
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 });
|
|
normalizeGeneratedPointIds(out, key, seed, adminIdMapping);
|
|
if (key === "adminCenters") {
|
|
if (Number.isFinite(out.sourceAdminId)) {
|
|
const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId);
|
|
const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId);
|
|
if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId;
|
|
}
|
|
}
|
|
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 splitWorldPathByPredicate(path, predicate, keepWhenTrue) {
|
|
const chunks = [];
|
|
let current = [];
|
|
for (const p of path || []) {
|
|
const matches = predicate(Math.round(p[0]), Math.round(p[1]));
|
|
if (matches === keepWhenTrue) 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 splitWorldPathByRect(path, rect, keepInside) {
|
|
return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside);
|
|
}
|
|
|
|
function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) {
|
|
return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected);
|
|
}
|
|
|
|
function pruneOldPathLayer(world, paths, rects, seed, 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 removalAlpha = path?.patchGenerated ? 0.005 : 0.34;
|
|
const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, removalAlpha));
|
|
if (!touches) {
|
|
kept.push(path);
|
|
continue;
|
|
}
|
|
clipped++;
|
|
let lastOutside = null;
|
|
let wasInside = false;
|
|
for (const [x, y] of worldPath) {
|
|
const inside = patchAffected(x, y, rects, seed, removalAlpha);
|
|
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 splitWorldPathByPatch(worldPath, rects, seed, false, removalAlpha)) 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, options = {}) {
|
|
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;
|
|
const allowCell = typeof options.allowCell === "function" ? options.allowCell : null;
|
|
const extraCost = typeof options.extraCost === "function" ? options.extraCost : null;
|
|
if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
|
|
if (allowCell && (!allowCell(sx, sy, true) || !allowCell(gx, gy, true))) 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;
|
|
if (allowCell && !allowCell(nx, ny, false)) continue;
|
|
const nid = local(nx, ny);
|
|
const baseCost = pathCost(world, nx, ny, mode);
|
|
if (!Number.isFinite(baseCost)) continue;
|
|
const c = baseCost + (extraCost ? Math.max(0, extraCost(nx, ny) || 0) : 0);
|
|
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, mode = "road") {
|
|
const points = [];
|
|
const seen = new Set();
|
|
const add = (x, y, key, weight = 1) => {
|
|
x = Math.round(x); y = Math.round(y);
|
|
if (!insideRect(x, y, rect) || !isLand(world, x, y)) return;
|
|
const sig = `${x},${y},${key}`;
|
|
if (seen.has(sig)) return;
|
|
seen.add(sig);
|
|
points.push({ x, y, key, weight });
|
|
};
|
|
for (const key of keys) {
|
|
for (const path of sourceMap[key] || []) {
|
|
for (let i = 0; i < path.length; i += 4) {
|
|
add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1);
|
|
}
|
|
}
|
|
}
|
|
// Do not use non-network human geography as graph reconnection targets.
|
|
// Reconnection should repair severed transport graphs, not force every city,
|
|
// admin center, village, port, or industrial site to remain tied to the old
|
|
// road topology after a terrain patch. Stations are retained as rail graph
|
|
// targets because they are part of the transport network.
|
|
if (mode === "rail") {
|
|
for (const p of sourceMap.stations || []) add(pointWorldX(world, p), pointWorldY(world, p), "stations", 1.15);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function rectDistance(x, y, rect) {
|
|
if (insideRect(x, y, rect)) return 0;
|
|
const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0;
|
|
const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0;
|
|
return Math.hypot(dx, dy);
|
|
}
|
|
|
|
function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const step = mode === "rail" ? 6 : 4;
|
|
for (const key of keys) {
|
|
for (const path of sourceMap[key] || []) {
|
|
for (let i = 0; i < path.length; i += step) {
|
|
const x = Math.round(tupleWorldX(world, path[i]));
|
|
const y = Math.round(tupleWorldY(world, path[i]));
|
|
if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue;
|
|
const d = rectDistance(x, y, writeRect);
|
|
if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue;
|
|
const sig = `${x},${y},${mode}`;
|
|
if (seen.has(sig)) continue;
|
|
seen.add(sig);
|
|
candidates.push({ x, y, mode, external: true, d });
|
|
}
|
|
}
|
|
}
|
|
candidates.sort((a, b) => a.d - b.d);
|
|
return candidates.slice(0, mode === "rail" ? 18 : 28);
|
|
}
|
|
|
|
function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null, patchOptions = null) {
|
|
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
|
|
const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
|
|
const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : [];
|
|
const targets = preferredTargets.length ? preferredTargets : allTargets;
|
|
if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
|
let connectors = 0;
|
|
let disconnected = 0;
|
|
let skippedConnectorAnchors = 0;
|
|
let connectorAttempts = 0;
|
|
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
|
sourceMap[layer] ||= [];
|
|
const seen = new Set();
|
|
const maxRange = mode === "rail" ? 220 : 260;
|
|
const maxAnchors = mode === "rail" ? 10 : 18;
|
|
const maxTargets = mode === "rail" ? 3 : 3;
|
|
const searchRect = expandRect(rect, 16, world);
|
|
const orderedAnchors = (anchors || [])
|
|
.map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) }))
|
|
.sort((a, b) => a.patchDistance - b.patchDistance)
|
|
.slice(0, maxAnchors);
|
|
skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length);
|
|
for (const raw of orderedAnchors) {
|
|
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18);
|
|
if (!anchorLand) { disconnected++; continue; }
|
|
const targetList = targets
|
|
.map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
|
|
.filter((p) => p.d <= maxRange && p.d >= 6)
|
|
.sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))
|
|
.slice(0, maxTargets);
|
|
if (!targetList.length) { disconnected++; continue; }
|
|
let made = false;
|
|
for (const target of targetList) {
|
|
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
|
if (seen.has(sig)) continue;
|
|
connectorAttempts++;
|
|
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000);
|
|
if (!path || path.length < 2) continue;
|
|
seen.add(sig);
|
|
const outputChunks = patchOptions?.rects
|
|
? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34)
|
|
: [path];
|
|
let wroteChunk = false;
|
|
for (const chunk of outputChunks) {
|
|
if (!chunk || chunk.length < 2) continue;
|
|
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
|
|
wroteChunk = true;
|
|
}
|
|
if (!wroteChunk) continue;
|
|
connectors++;
|
|
made = true;
|
|
break;
|
|
}
|
|
if (!made) disconnected++;
|
|
}
|
|
return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts };
|
|
}
|
|
|
|
function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) {
|
|
let best = null;
|
|
let bestD = maxDistance;
|
|
for (const key of keys) {
|
|
for (const path of sourceMap[key] || []) {
|
|
for (let i = 0; i < path.length; i += 5) {
|
|
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)) continue;
|
|
const d = Math.hypot(point.x - x, point.y - y);
|
|
if (d < bestD) { bestD = d; best = { x, y, key }; }
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function ensureSettlementRoadCoverage(world, sourceMap, rect, patchOptions = null) {
|
|
const keys = ["nationalRoads", "minorRoads", "premodernRoads"];
|
|
const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
|
|
sourceMap.minorRoads ||= [];
|
|
let connectors = 0;
|
|
let skippedServedSettlements = 0;
|
|
let checked = 0;
|
|
const seen = new Set();
|
|
for (const key of featureKeys) {
|
|
const limit = key === "villages" ? 30 : 18;
|
|
const items = (sourceMap[key] || [])
|
|
.map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) }))
|
|
.filter((row) => row.d <= (key === "villages" ? 80 : 150))
|
|
.sort((a, b) => a.d - b.d)
|
|
.slice(0, limit);
|
|
for (const { p } of items) {
|
|
checked++;
|
|
const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
|
|
if (!start || !insideRect(start.x, start.y, rect)) continue;
|
|
const si = worldIndex(world, start.x, start.y);
|
|
if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) {
|
|
skippedServedSettlements++;
|
|
continue;
|
|
}
|
|
const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132);
|
|
if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue;
|
|
const sig = `${start.x},${start.y}:${target.x},${target.y}`;
|
|
if (seen.has(sig)) continue;
|
|
seen.add(sig);
|
|
const path = localPathfind(world, start, target, rect, "road", 36000);
|
|
if (!path || path.length < 2) continue;
|
|
const outputChunks = patchOptions?.rects
|
|
? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34)
|
|
: [path];
|
|
let wroteChunk = false;
|
|
for (const chunk of outputChunks) {
|
|
if (!chunk || chunk.length < 2) continue;
|
|
sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(chunk, 2)));
|
|
wroteChunk = true;
|
|
}
|
|
if (!wroteChunk) continue;
|
|
connectors++;
|
|
}
|
|
}
|
|
return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked };
|
|
}
|
|
|
|
|
|
function transportLayerKeys(mode) {
|
|
return mode === "rail"
|
|
? ["railways", "branchRailways", "ringRailways", "externalRailways"]
|
|
: ["nationalRoads", "minorRoads", "premodernRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"];
|
|
}
|
|
|
|
function densifyWorldPath(path, visit) {
|
|
if (!Array.isArray(path) || path.length < 2) return;
|
|
for (let k = 0; k < path.length - 1; k++) {
|
|
const a = path[k];
|
|
const b = path[k + 1];
|
|
const ax = Math.round(a?.[0] ?? 0);
|
|
const ay = Math.round(a?.[1] ?? 0);
|
|
const bx = Math.round(b?.[0] ?? ax);
|
|
const by = Math.round(b?.[1] ?? ay);
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay)));
|
|
for (let s = k === 0 ? 0 : 1; s <= steps; s++) {
|
|
const t = s / steps;
|
|
visit(Math.round(ax + (bx - ax) * t), Math.round(ay + (by - ay) * t));
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed = 0) {
|
|
const keys = transportLayerKeys(mode);
|
|
const w = rectWidth(graphRect);
|
|
const h = rectHeight(graphRect);
|
|
const occ = new Uint8Array(Math.max(0, w * h));
|
|
const weight = new Float32Array(Math.max(0, w * h));
|
|
const local = (x, y) => (y - graphRect.y0) * w + (x - graphRect.x0);
|
|
const mark = (x, y, v = 1) => {
|
|
x = Math.round(x); y = Math.round(y);
|
|
if (!insideRect(x, y, graphRect)) return;
|
|
// Graph connectivity is based on rendered transport centerlines. Existing
|
|
// short bridges are allowed to connect components, but arbitrary candidate
|
|
// roads far out at sea are not allowed to become graph anchors.
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) return;
|
|
// Accept land cells and only very near-shore bridge-like sea cells.
|
|
// The previous sea-neighbor test accidentally made open water more likely
|
|
// to become a graph anchor.
|
|
const nearTransportSurface = isLand(world, x, y) || landNeighbors(world, x, y, 2) >= 8;
|
|
if (!nearTransportSurface) return;
|
|
const li = local(x, y);
|
|
occ[li] = 1;
|
|
weight[li] = Math.max(weight[li] || 0, v);
|
|
};
|
|
|
|
for (const key of keys) {
|
|
const layerWeight = key.includes("express") ? 2.4 : key.includes("national") ? 2.0 : key.includes("rail") ? 2.1 : 1.0;
|
|
for (const path of sourceMap?.[key] || []) {
|
|
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
|
|
densifyWorldPath(worldPath, (x, y) => mark(x, y, layerWeight));
|
|
}
|
|
}
|
|
|
|
const seen = new Uint8Array(occ.length);
|
|
const comps = [];
|
|
const dirs = [];
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
if (!dx && !dy) continue;
|
|
if (dx * dx + dy * dy <= 5) dirs.push([dx, dy]);
|
|
}
|
|
}
|
|
|
|
for (let li = 0; li < occ.length; li++) {
|
|
if (!occ[li] || seen[li]) continue;
|
|
const queue = [li];
|
|
const cells = [];
|
|
seen[li] = 1;
|
|
let sx = 0, sy = 0, score = 0, patchCells = 0, nearWriteCells = 0, exteriorCells = 0;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
cells.push(cur);
|
|
const lx = cur % w;
|
|
const ly = Math.floor(cur / w);
|
|
const x = graphRect.x0 + lx;
|
|
const y = graphRect.y0 + ly;
|
|
sx += x; sy += y; score += Math.max(1, weight[cur] || 1);
|
|
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
|
if (patchAffected(x, y, rects, seed, 0.24)) patchCells++;
|
|
if (rectDistance(x, y, rects.writeRect) <= 8) nearWriteCells++;
|
|
if (!insideRect(x, y, rects.writeRect)) exteriorCells++;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = lx + dx;
|
|
const ny = ly + dy;
|
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
|
const ni = ny * w + nx;
|
|
if (!occ[ni] || seen[ni]) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
const size = cells.length;
|
|
const cx = sx / Math.max(1, size);
|
|
const cy = sy / Math.max(1, size);
|
|
const boundaryBonus = exteriorCells > 0 ? Math.min(180, exteriorCells) : 0;
|
|
comps.push({
|
|
id: comps.length,
|
|
cells,
|
|
size,
|
|
cx,
|
|
cy,
|
|
minX,
|
|
minY,
|
|
maxX,
|
|
maxY,
|
|
score: score + boundaryBonus * 1.5 + patchCells * 1.2 + nearWriteCells * 0.8,
|
|
patchCells,
|
|
nearWriteCells,
|
|
exteriorCells,
|
|
});
|
|
}
|
|
comps.sort((a, b) => b.score - a.score);
|
|
return { graphRect, width: w, height: h, occ, comps, local };
|
|
}
|
|
|
|
function nearestComponentCell(snapshot, comp, target) {
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
for (const li of comp?.cells || []) {
|
|
const x = snapshot.graphRect.x0 + (li % snapshot.width);
|
|
const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width);
|
|
const d = Math.hypot(x - target.x, y - target.y);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = { x, y };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function sampleTransportComponent(snapshot, comp, limit = 96) {
|
|
const cells = comp?.cells || [];
|
|
if (!cells.length) return [];
|
|
const out = [];
|
|
const seen = new Set();
|
|
const addCell = (li) => {
|
|
if (!Number.isFinite(li) || li < 0) return;
|
|
const x = snapshot.graphRect.x0 + (li % snapshot.width);
|
|
const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width);
|
|
const key = `${x},${y}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
out.push({ x, y });
|
|
};
|
|
const step = Math.max(1, Math.floor(cells.length / Math.max(1, limit)));
|
|
for (let i = 0; i < cells.length; i += step) {
|
|
addCell(cells[i]);
|
|
if (out.length >= limit) break;
|
|
}
|
|
// Add actual occupied cells nearest to the centroid and bbox edge targets.
|
|
// Do not use the raw centroid/bbox coordinates as endpoints: they are often
|
|
// off the centerline, which made the checker roll back otherwise valid graph
|
|
// repairs because the new path never actually touched the component.
|
|
const specials = [
|
|
{ x: Math.round(comp.cx), y: Math.round(comp.cy) },
|
|
{ x: comp.minX, y: Math.round(comp.cy) },
|
|
{ x: comp.maxX, y: Math.round(comp.cy) },
|
|
{ x: Math.round(comp.cx), y: comp.minY },
|
|
{ x: Math.round(comp.cx), y: comp.maxY },
|
|
];
|
|
for (const target of specials) {
|
|
const nearest = nearestComponentCell(snapshot, comp, target);
|
|
if (!nearest) continue;
|
|
const key = `${nearest.x},${nearest.y}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(nearest);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function transportComponentSamples(snapshot, comp, mode = "road") {
|
|
snapshot.sampleCache ||= new Map();
|
|
const key = `${mode}:${comp?.id ?? -1}`;
|
|
const cached = snapshot.sampleCache.get(key);
|
|
if (cached) return cached;
|
|
const samples = sampleTransportComponent(snapshot, comp, mode === "rail" ? 48 : 72);
|
|
snapshot.sampleCache.set(key, samples);
|
|
return samples;
|
|
}
|
|
|
|
function bestTransportComponentPair(snapshot, compA, compB, maxDistance, mode = "road") {
|
|
const samplesA = transportComponentSamples(snapshot, compA, mode);
|
|
const samplesB = transportComponentSamples(snapshot, compB, mode);
|
|
let best = null;
|
|
for (const a of samplesA) {
|
|
for (const b of samplesB) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 3 || d > maxDistance) continue;
|
|
// Prefer connecting components that actually touch the changed area, but
|
|
// do not force every component into the single largest component. A local
|
|
// chain of nearby component merges usually looks more like a natural
|
|
// regional repair than a star-shaped set of shortcuts to the trunk road.
|
|
const dirtyBonus = (compA.patchCells || compA.nearWriteCells || compB.patchCells || compB.nearWriteCells) ? 0.78 : 1.0;
|
|
const exteriorPenalty = compA.exteriorCells > 0 && compB.exteriorCells > 0 && !compA.patchCells && !compB.patchCells ? 1.32 : 1.0;
|
|
const sizeBonus = 1 / Math.sqrt(Math.max(4, Math.min(compA.size, compB.size)));
|
|
const score = d * sizeBonus * dirtyBonus * exteriorPenalty;
|
|
if (!best || score < best.score) best = { a, b, d, score };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs) {
|
|
const limit = mode === "rail" ? 12 : 18;
|
|
const pool = comps.slice(0, Math.min(comps.length, limit));
|
|
const candidates = [];
|
|
for (let i = 0; i < pool.length; i++) {
|
|
for (let j = i + 1; j < pool.length; j++) {
|
|
const aComp = pool[i];
|
|
const bComp = pool[j];
|
|
// At least one side must be part of the dirty neighborhood. This prevents
|
|
// broad transport context from welding unrelated external networks while
|
|
// still allowing internal severed pieces to attach to outside trunks.
|
|
const aDirty = (aComp.patchCells || aComp.nearWriteCells) > 0;
|
|
const bDirty = (bComp.patchCells || bComp.nearWriteCells) > 0;
|
|
if (!aDirty && !bDirty) continue;
|
|
const pair = bestTransportComponentPair(snapshot, aComp, bComp, maxDistance, mode);
|
|
if (!pair) continue;
|
|
const sig = transportPairSignature(pair.a, pair.b, mode);
|
|
const rev = transportPairSignature(pair.b, pair.a, mode);
|
|
if (rejectedPairs.has(sig) || rejectedPairs.has(rev)) continue;
|
|
const bothPatch = aComp.patchCells > 0 && bComp.patchCells > 0 ? 0.74 : 1.0;
|
|
const oneExternal = (aComp.exteriorCells > 0 || bComp.exteriorCells > 0) ? 1.03 : 1.0;
|
|
const score = pair.score * bothPatch * oneExternal;
|
|
candidates.push({ compA: aComp, compB: bComp, pair, score });
|
|
}
|
|
}
|
|
candidates.sort((a, b) => a.score - b.score);
|
|
return candidates;
|
|
}
|
|
|
|
function transportPairSignature(a, b, mode = "road") {
|
|
return `${mode}:${Math.round((a?.x || 0) / 3)},${Math.round((a?.y || 0) / 3)}:${Math.round((b?.x || 0) / 3)},${Math.round((b?.y || 0) / 3)}`;
|
|
}
|
|
|
|
function pathLength(path) {
|
|
let len = 0;
|
|
for (let i = 1; i < (path?.length || 0); i++) len += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
|
return len;
|
|
}
|
|
|
|
function dedupeWorldPath(path) {
|
|
const out = [];
|
|
let last = "";
|
|
for (const p of path || []) {
|
|
const x = Math.round(p?.[0] ?? 0);
|
|
const y = Math.round(p?.[1] ?? 0);
|
|
const key = `${x},${y}`;
|
|
if (key === last) continue;
|
|
out.push([x, y]);
|
|
last = key;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function makeTransportConnectorAllowCell(world, snapshot, rects, seed, strictMask) {
|
|
const graphRect = snapshot.graphRect;
|
|
return (x, y, endpoint = false) => {
|
|
if (!insideRect(x, y, graphRect)) return false;
|
|
// Field writes are strict-masked elsewhere. For transport repair, allow the
|
|
// pathfinder to use the broader graph neighborhood so it can reconnect to
|
|
// realistic regional targets beyond the selected patch. Water remains
|
|
// blocked by pathCost(), preserving the existing island/strait behavior.
|
|
return true;
|
|
};
|
|
}
|
|
|
|
function makeTransportConnectorExtraCost(world, rects, seed, mode) {
|
|
return (x, y) => {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) return 999;
|
|
const alpha = patchAlpha(x, y, rects, seed);
|
|
const edgePenalty = alpha > 0 ? Math.max(0, 0.35 - alpha) * 1.1 : 0;
|
|
const roadInf = world.fields.roadInfluence?.[i] || 0;
|
|
const railInf = world.fields.railInfluence2?.[i] || 0;
|
|
const transportBonus = mode === "rail" ? railInf * 0.45 : roadInf * 0.35;
|
|
return Math.max(0, edgePenalty - transportBonus);
|
|
};
|
|
}
|
|
|
|
function transportLineSeaBarrier(world, a, b, mode = "road") {
|
|
const ax = Math.round(a?.x || 0), ay = Math.round(a?.y || 0);
|
|
const bx = Math.round(b?.x || 0), by = Math.round(b?.y || 0);
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay)));
|
|
let seaHits = 0;
|
|
let longestRun = 0;
|
|
let run = 0;
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(ax + (bx - ax) * t);
|
|
const y = Math.round(ay + (by - ay) * t);
|
|
const water = !isLand(world, x, y);
|
|
if (water) {
|
|
seaHits++;
|
|
run++;
|
|
longestRun = Math.max(longestRun, run);
|
|
} else {
|
|
run = 0;
|
|
}
|
|
}
|
|
// Keep the existing island/strait behavior: do not spend expensive A* attempts
|
|
// on pairs that are probably separated by open water. Small coastal gaps are
|
|
// still allowed, especially for roads, so pre-existing bridge-like contexts
|
|
// can be repaired without turning islands into a road mesh.
|
|
const seaRatio = seaHits / Math.max(1, steps + 1);
|
|
const maxRun = mode === "rail" ? 4 : 6;
|
|
return longestRun > maxRun || seaRatio > (mode === "rail" ? 0.10 : 0.14);
|
|
}
|
|
|
|
function writeConnectorPath(world, sourceMap, mode, path, rects, seed, strictMask) {
|
|
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
|
sourceMap[layer] ||= [];
|
|
const startLength = sourceMap[layer].length;
|
|
const clean = dedupeWorldPath(path);
|
|
if (clean.length < 2) return { wrote: 0, layer, startLength };
|
|
const chunks = [clean];
|
|
let wrote = 0;
|
|
for (const chunk of chunks) {
|
|
const out = dedupeWorldPath(chunk);
|
|
if (out.length < 2) continue;
|
|
// Reject tiny one-cell remnants produced by strict polygon clipping. They
|
|
// look like specks rather than graph repairs.
|
|
if (pathLength(out) < (mode === "rail" ? 4 : 3)) continue;
|
|
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(out, mode === "rail" ? 3 : 2)));
|
|
wrote++;
|
|
}
|
|
return { wrote, layer, startLength };
|
|
}
|
|
|
|
function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, options = {}) {
|
|
const maxAdds = options.maxAdds ?? (mode === "rail" ? 8 : 16);
|
|
const maxDistance = options.maxDistance ?? (mode === "rail" ? 280 : 380);
|
|
const minComponentSize = options.minComponentSize ?? (mode === "rail" ? 3 : 4);
|
|
const debug = {
|
|
[`${mode}GraphBeforeComponents`]: 0,
|
|
[`${mode}GraphAfterComponents`]: 0,
|
|
[`${mode}GraphConnectorsAdded`]: 0,
|
|
[`${mode}GraphConnectorsFailed`]: 0,
|
|
[`${mode}GraphCandidatesConsidered`]: 0,
|
|
[`${mode}GraphComponentsIgnored`]: 0,
|
|
};
|
|
|
|
const eligible = (comp) => {
|
|
if (!comp || comp.size < minComponentSize) return false;
|
|
// Broad transport context is intentional, but the worklist is limited to
|
|
// components that touch the edited neighborhood. Purely external networks
|
|
// remain as context/targets, not as things to rewire together.
|
|
return comp.patchCells > 0 || comp.nearWriteCells > 0;
|
|
};
|
|
const contextual = (comp) => {
|
|
if (!comp || comp.size < minComponentSize) return false;
|
|
return comp.patchCells > 0 || comp.nearWriteCells > 0 || comp.exteriorCells > 0;
|
|
};
|
|
|
|
let snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
debug[`${mode}GraphBeforeComponents`] = snapshot.comps.filter(eligible).length;
|
|
const rejectedPairs = new Set();
|
|
let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8);
|
|
for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) {
|
|
snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
const dirtyCount = snapshot.comps.filter(eligible).length;
|
|
if (dirtyCount <= 1) break;
|
|
const comps = snapshot.comps.filter(contextual);
|
|
if (comps.length <= 1) break;
|
|
|
|
const candidates = buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs);
|
|
debug[`${mode}GraphCandidatesConsidered`] += candidates.length;
|
|
if (!candidates.length) {
|
|
debug[`${mode}GraphComponentsIgnored`] += dirtyCount;
|
|
break;
|
|
}
|
|
|
|
let accepted = false;
|
|
for (const candidate of candidates.slice(0, 1)) {
|
|
const { a, b, d } = candidate.pair;
|
|
if (transportLineSeaBarrier(world, a, b, mode)) {
|
|
debug[`${mode}GraphConnectorsFailed`]++;
|
|
rejectedPairs.add(transportPairSignature(a, b, mode));
|
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
|
continue;
|
|
}
|
|
const pad = Math.ceil(Math.max(28, Math.min(mode === "rail" ? 72 : 84, d * 0.38 + 16)));
|
|
const searchRect = expandRect({
|
|
x0: Math.floor(Math.min(a.x, b.x)),
|
|
y0: Math.floor(Math.min(a.y, b.y)),
|
|
x1: Math.ceil(Math.max(a.x, b.x) + 1),
|
|
y1: Math.ceil(Math.max(a.y, b.y) + 1),
|
|
}, pad, world);
|
|
const boundedSearchRect = {
|
|
x0: Math.max(searchRect.x0, graphRect.x0),
|
|
y0: Math.max(searchRect.y0, graphRect.y0),
|
|
x1: Math.min(searchRect.x1, graphRect.x1),
|
|
y1: Math.min(searchRect.y1, graphRect.y1),
|
|
};
|
|
const allowCell = makeTransportConnectorAllowCell(world, snapshot, rects, seed, !!rects.strictSelectionMask);
|
|
const extraCost = makeTransportConnectorExtraCost(world, rects, seed, mode);
|
|
const searchArea = Math.max(0, rectWidth(boundedSearchRect) * rectHeight(boundedSearchRect));
|
|
if (searchArea > (mode === "rail" ? 72000 : 90000)) {
|
|
debug[`${mode}GraphConnectorsFailed`]++;
|
|
rejectedPairs.add(transportPairSignature(a, b, mode));
|
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
|
continue;
|
|
}
|
|
attemptsRemaining--;
|
|
const path = localPathfind(world, a, b, boundedSearchRect, mode, mode === "rail" ? 5500 : 7000, { allowCell, extraCost });
|
|
const clean = dedupeWorldPath(path || []);
|
|
const routeLen = pathLength(clean);
|
|
const tooLong = !clean.length || routeLen > d * (mode === "rail" ? 2.65 : 3.05) + (mode === "rail" ? 48 : 70);
|
|
if (tooLong) {
|
|
debug[`${mode}GraphConnectorsFailed`]++;
|
|
rejectedPairs.add(transportPairSignature(a, b, mode));
|
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
|
continue;
|
|
}
|
|
|
|
const beforeEligibleComponents = dirtyCount;
|
|
const writeResult = writeConnectorPath(world, sourceMap, mode, clean, rects, seed, !!rects.strictSelectionMask);
|
|
if (!writeResult.wrote) {
|
|
debug[`${mode}GraphConnectorsFailed`]++;
|
|
rejectedPairs.add(transportPairSignature(a, b, mode));
|
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
|
continue;
|
|
}
|
|
const checkSnapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
const afterEligibleComponents = checkSnapshot.comps.filter(eligible).length;
|
|
if (afterEligibleComponents >= beforeEligibleComponents) {
|
|
sourceMap[writeResult.layer].splice(writeResult.startLength);
|
|
debug[`${mode}GraphConnectorsFailed`]++;
|
|
rejectedPairs.add(transportPairSignature(a, b, mode));
|
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
|
continue;
|
|
}
|
|
debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote;
|
|
accepted = true;
|
|
break;
|
|
}
|
|
if (!accepted) break;
|
|
}
|
|
snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length;
|
|
return debug;
|
|
}
|
|
|
|
function dedupeAdminCentersByWorldId(kept, generated) {
|
|
const out = [...kept];
|
|
const seen = new Set();
|
|
for (const p of kept) {
|
|
const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
|
|
if (id >= 0) seen.add(id);
|
|
}
|
|
for (const p of generated) {
|
|
const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
|
|
if (id >= 0 && seen.has(id)) continue;
|
|
if (id >= 0) seen.add(id);
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function dedupePrefectureRegionsByWorldId(kept, generated) {
|
|
const out = [...kept];
|
|
const seen = new Set();
|
|
for (const p of kept) {
|
|
const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
|
|
if (id >= 0) seen.add(id);
|
|
}
|
|
for (const p of generated) {
|
|
const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
|
|
if (id >= 0 && seen.has(id)) continue;
|
|
if (id >= 0) seen.add(id);
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) {
|
|
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;
|
|
// Prior patch-generated point layers must be fully replaced when the user
|
|
// presses Alternative. The new variant has a different alpha noise field,
|
|
// so using the normal 0.34 removal threshold can leave previous towns,
|
|
// stations, labels, etc. in the seam band.
|
|
const removalThreshold = p.patchGenerated ? 0.005 : 0.34;
|
|
if (!inWrite || alpha < removalThreshold) {
|
|
kept.push(p);
|
|
if (!inWrite) preservedExternalEntities++;
|
|
} else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) {
|
|
invalidPortsRemoved++;
|
|
continue;
|
|
}
|
|
}
|
|
const generated = [];
|
|
for (const p of candidate[key] || []) {
|
|
const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping);
|
|
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);
|
|
}
|
|
if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated);
|
|
else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated);
|
|
else sourceMap[key] = [...kept, ...generated];
|
|
regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.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, seed, 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 = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40)
|
|
.map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y)))
|
|
.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 strictMask = !!rects.strictSelectionMask;
|
|
const broadTransportRect = rects.transportReachRect || expandRect(rects.writeRect, 220, world);
|
|
const transportRect = strictMask
|
|
? broadTransportRect
|
|
: (rects.transportReachRect || rects.repairRect || rects.writeRect);
|
|
// Terrain/field generation still obeys the strict lasso mask. Transport
|
|
// repair is intentionally allowed to operate in a much broader neighborhood,
|
|
// because clipping graph repairs to the selected polygon leaves implausible
|
|
// dangling regional networks just outside the patch.
|
|
const connectorPatchOptions = null;
|
|
const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
|
|
const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
|
|
roadAnchors = roadAnchors.concat(externalRoadAnchors);
|
|
railAnchors = railAnchors.concat(externalRailAnchors);
|
|
// Legacy anchor-to-target connectors were not graph-validated and could leave
|
|
// visible fragments that did not reduce disconnected components. Keep the
|
|
// anchor collection for diagnostics, but route all actual repair through the
|
|
// graph reconnection pass below, which rolls back failed candidates.
|
|
const roadConn = { connectors: 0, disconnected: roadAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
|
const railConn = { connectors: 0, disconnected: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
|
const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 };
|
|
const graphRect = strictMask
|
|
? transportRect
|
|
: transportRect;
|
|
const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 12 : 9, maxDistance: strictMask ? 340 : 290, maxAttempts: strictMask ? 8 : 6 });
|
|
const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 6 : 4, maxDistance: strictMask ? 245 : 210, maxAttempts: strictMask ? 4 : 3 });
|
|
return {
|
|
roadsClipped,
|
|
railsClipped,
|
|
regeneratedPaths,
|
|
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors + (roadGraph.roadGraphConnectorsAdded || 0),
|
|
railwayConnectorsCreated: railConn.connectors + (railGraph.railGraphConnectorsAdded || 0),
|
|
disconnectedRoadComponents: roadConn.disconnected,
|
|
disconnectedRailComponents: railConn.disconnected,
|
|
skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
|
|
connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
|
|
skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
|
|
checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
|
|
externalRoadAnchors: externalRoadAnchors.length,
|
|
externalRailAnchors: externalRailAnchors.length,
|
|
...roadGraph,
|
|
...railGraph,
|
|
};
|
|
}
|
|
|
|
function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) {
|
|
const field = world.fields[fieldName];
|
|
const sea = world.fields.sea;
|
|
const rects = options.rects || null;
|
|
const seed = options.seed || 0;
|
|
const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0;
|
|
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 && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) {
|
|
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 && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) {
|
|
out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) {
|
|
const debug = sourceMap.adminDebug || {};
|
|
debug.compartmentBorders ||= [];
|
|
let added = 0;
|
|
for (const seg of candidate?.adminDebug?.compartmentBorders || []) {
|
|
if (!Array.isArray(seg) || seg.length < 2) continue;
|
|
const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]);
|
|
const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]);
|
|
const mx = (a.x + b.x) * 0.5;
|
|
const my = (a.y + b.y) * 0.5;
|
|
if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue;
|
|
debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]]));
|
|
added++;
|
|
}
|
|
sourceMap.adminDebug = debug;
|
|
return added;
|
|
}
|
|
|
|
function transformCandidateBoundarySegments(world, candidate, key, rects, window, seed = 0, minAlpha = 0.74) {
|
|
const out = [];
|
|
if (!candidate || !window || !Array.isArray(candidate[key])) return out;
|
|
const sea = world.fields?.sea;
|
|
for (const seg of candidate[key]) {
|
|
if (!Array.isArray(seg) || seg.length < 2) continue;
|
|
const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]);
|
|
const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]);
|
|
const mx = Math.round((a.x + b.x) * 0.5);
|
|
const my = Math.round((a.y + b.y) * 0.5);
|
|
if (!insideRect(mx, my, rects.writeRect)) continue;
|
|
if (patchAlpha(mx, my, rects, seed) < minAlpha) continue;
|
|
const mi = worldIndex(world, mx, my);
|
|
if (mi >= 0 && sea?.[mi]) continue;
|
|
out.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]]));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) {
|
|
const segmentRect = rects.writeRect || rects.repairRect;
|
|
const strongAlpha = 0.72;
|
|
|
|
// Keep outside prepared boundaries, but treat the patch interior as a single
|
|
// replacement zone. Mixing candidate vector boundaries with raster-rebuilt
|
|
// boundaries without filtering produced double prefecture/municipal lines:
|
|
// one properly dashed line plus a nearby white/solid-looking twin.
|
|
const keptByKey = new Map();
|
|
for (const key of SEGMENT_LAYER_KEYS) {
|
|
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
const kept = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha));
|
|
keptByKey.set(key, kept);
|
|
}
|
|
|
|
const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76);
|
|
const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78);
|
|
const rebuiltAdminRaw = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 });
|
|
const rebuiltPrefRaw = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 });
|
|
|
|
// Candidate boundaries come from the full generator and usually have the same
|
|
// visual semantics as the initial map. Raster-rebuilt segments are fallback
|
|
// only, and are rejected when they are near an existing candidate segment.
|
|
const rebuiltPref = filterSupplementalSegments(candidatePref, rebuiltPrefRaw, 0.72);
|
|
const patchPref = dedupeSegments([...candidatePref, ...rebuiltPref]);
|
|
const rebuiltAdmin = filterSupplementalSegments(candidateAdmin, rebuiltAdminRaw, 0.64);
|
|
let patchAdmin = dedupeSegments([...candidateAdmin, ...rebuiltAdmin]);
|
|
|
|
// A prefecture border is also a municipal border in the raw rasters. Do not
|
|
// draw both visual layers on the same line; the thicker prefecture styling wins.
|
|
patchAdmin = removeSegmentsNearSegments(patchAdmin, patchPref, 0.76);
|
|
|
|
sourceMap.adminBorders = dedupeSegments([...(keptByKey.get("adminBorders") || []), ...patchAdmin]);
|
|
sourceMap.regionalPrefectureBorders = dedupeSegments([...(keptByKey.get("regionalPrefectureBorders") || []), ...patchPref]);
|
|
sourceMap.prefectureBorder = dedupeSegments([...(keptByKey.get("prefectureBorder") || []), ...(sourceMap.prefectureBorder || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha))]);
|
|
|
|
const debug = sourceMap.adminDebug || {};
|
|
debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
|
|
sourceMap.adminDebug = debug;
|
|
return {
|
|
adminBordersRebuilt: sourceMap.adminBorders.length,
|
|
prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length,
|
|
candidateAdminBordersMerged: candidateAdmin.length,
|
|
candidatePrefectureBordersMerged: candidatePref.length,
|
|
rasterAdminBordersSuppressed: Math.max(0, rebuiltAdminRaw.length - rebuiltAdmin.length),
|
|
rasterPrefectureBordersSuppressed: Math.max(0, rebuiltPrefRaw.length - rebuiltPref.length),
|
|
compartmentBordersRebuilt: debug.compartmentBorders.length,
|
|
};
|
|
}
|
|
|
|
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 ensureWorldFloatField(world, name) {
|
|
const expected = world.width * world.height;
|
|
if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected);
|
|
return world.fields[name];
|
|
}
|
|
|
|
function clearFieldRect(world, field, rect) {
|
|
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) field[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) {
|
|
const sea = world.fields.sea;
|
|
const r = Math.max(1, Math.ceil(radius));
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
if (dx * dx + dy * dy > radius * radius) continue;
|
|
const x = Math.round(cx + dx);
|
|
const y = Math.round(cy + dy);
|
|
if (!insideRect(x, y, rect)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || sea?.[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
|
if (value > field[i]) field[i] = clamp(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function pathWorldBounds(world, path) {
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
for (const tuple of path || []) {
|
|
const x = tupleWorldX(world, tuple);
|
|
const y = tupleWorldY(world, tuple);
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
x0 = Math.min(x0, x); y0 = Math.min(y0, y);
|
|
x1 = Math.max(x1, x); y1 = Math.max(y1, y);
|
|
}
|
|
if (!Number.isFinite(x0)) return null;
|
|
return { x0, y0, x1: x1 + 1, y1: y1 + 1 };
|
|
}
|
|
|
|
function rectsSeparatedByMoreThan(a, b, margin = 0) {
|
|
return a.x1 + margin < b.x0 || a.x0 - margin > b.x1 || a.y1 + margin < b.y0 || a.y0 - margin > b.y1;
|
|
}
|
|
|
|
function refreshPatchInfluenceFields(world, sourceMap, rects) {
|
|
const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
|
|
const localRect = rects.repairRect || rects.writeRect;
|
|
const roadInfluence = ensureWorldFloatField(world, "roadInfluence");
|
|
const railInfluence2 = ensureWorldFloatField(world, "railInfluence2");
|
|
const stationInfluence = ensureWorldFloatField(world, "stationInfluence");
|
|
const villageInfluence = ensureWorldFloatField(world, "villageInfluence");
|
|
clearFieldRect(world, roadInfluence, transportRect);
|
|
clearFieldRect(world, railInfluence2, transportRect);
|
|
clearFieldRect(world, stationInfluence, transportRect);
|
|
clearFieldRect(world, villageInfluence, localRect);
|
|
|
|
let roadCellsPainted = 0;
|
|
let railCellsPainted = 0;
|
|
let stationCellsPainted = 0;
|
|
let villageCellsPainted = 0;
|
|
const paintPathLayer = (keys, field, radius, strength, counterName, rect) => {
|
|
let painted = 0;
|
|
for (const key of keys) {
|
|
for (const path of sourceMap[key] || []) {
|
|
const bounds = pathWorldBounds(world, path);
|
|
if (!bounds || rectsSeparatedByMoreThan(bounds, rect, radius + 2)) continue;
|
|
for (const tuple of path || []) {
|
|
const x = tupleWorldX(world, tuple);
|
|
const y = tupleWorldY(world, tuple);
|
|
if (rectDistance(x, y, rect) > radius + 1) continue;
|
|
paintInfluenceDisk(world, field, x, y, radius, strength, rect);
|
|
painted++;
|
|
}
|
|
}
|
|
}
|
|
if (counterName === "road") roadCellsPainted += painted;
|
|
if (counterName === "rail") railCellsPainted += painted;
|
|
};
|
|
|
|
paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road", transportRect);
|
|
paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail", transportRect);
|
|
|
|
for (const p of sourceMap.stations || []) {
|
|
const x = pointWorldX(world, p);
|
|
const y = pointWorldY(world, p);
|
|
if (rectDistance(x, y, transportRect) > 8) continue;
|
|
paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), transportRect);
|
|
stationCellsPainted++;
|
|
}
|
|
for (const p of sourceMap.villages || []) {
|
|
const x = pointWorldX(world, p);
|
|
const y = pointWorldY(world, p);
|
|
if (rectDistance(x, y, localRect) > 10) continue;
|
|
paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), localRect);
|
|
villageCellsPainted++;
|
|
}
|
|
|
|
return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted };
|
|
}
|
|
|
|
function countSea(world, rect, rects = null, seed = 0) {
|
|
let seaCount = 0;
|
|
let total = 0;
|
|
const useStrictMask = !!rects?.strictSelectionMask;
|
|
const scanRect = useStrictMask ? (rects.writeRect || rect) : rect;
|
|
for (let y = scanRect.y0; y < scanRect.y1; y++) {
|
|
for (let x = scanRect.x0; x < scanRect.x1; x++) {
|
|
if (useStrictMask && patchAlpha(x, y, rects, seed) <= 0.005) continue;
|
|
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;
|
|
}
|
|
|
|
function rectKey(rect) {
|
|
return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-";
|
|
}
|
|
|
|
function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) {
|
|
return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|");
|
|
}
|
|
|
|
function getPatchCandidateCache(world) {
|
|
if (!world.patchCandidateCache) world.patchCandidateCache = new Map();
|
|
return world.patchCandidateCache;
|
|
}
|
|
|
|
function rememberPatchCandidate(world, key, candidate) {
|
|
const cache = getPatchCandidateCache(world);
|
|
if (cache.has(key)) cache.delete(key);
|
|
cache.set(key, candidate);
|
|
while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
}
|
|
|
|
function getOrGeneratePatchCandidate(world, key, create) {
|
|
const cache = getPatchCandidateCache(world);
|
|
if (cache.has(key)) {
|
|
const candidate = cache.get(key);
|
|
cache.delete(key);
|
|
cache.set(key, candidate);
|
|
return { candidate, cacheHit: true, cacheSize: cache.size };
|
|
}
|
|
const candidate = create();
|
|
rememberPatchCandidate(world, key, candidate);
|
|
return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size };
|
|
}
|
|
|
|
function clonePointForPatch(point) {
|
|
return point ? { ...point } : point;
|
|
}
|
|
|
|
function captureStrictMetadataSnapshot(world, sourceMap, rects, seed = 0) {
|
|
if (!rects?.strictSelectionMask || !sourceMap) return null;
|
|
const byLayer = new Map();
|
|
for (const key of ["adminCenters", "prefectureRegions"]) {
|
|
const arr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
const outside = [];
|
|
for (const p of arr) {
|
|
const x = Math.round(pointWorldX(world, p));
|
|
const y = Math.round(pointWorldY(world, p));
|
|
if (patchAlpha(x, y, rects, seed) <= 0.005) outside.push(clonePointForPatch(p));
|
|
}
|
|
byLayer.set(key, outside);
|
|
}
|
|
return { byLayer };
|
|
}
|
|
|
|
function metadataIdForLayer(point, key) {
|
|
if (key === "prefectureRegions") return numericFeatureId(point, ["prefectureRegionId", "id", "featureId"]);
|
|
return numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]);
|
|
}
|
|
|
|
function restoreOutsideStrictMetadata(world, sourceMap, rects, snapshot, seed = 0) {
|
|
if (!snapshot || !rects?.strictSelectionMask || !sourceMap) return { strictMetadataPointsRestored: 0 };
|
|
let strictMetadataPointsRestored = 0;
|
|
for (const key of ["adminCenters", "prefectureRegions"]) {
|
|
const oldOutside = snapshot.byLayer.get(key) || [];
|
|
if (!oldOutside.length) continue;
|
|
const oldById = new Map();
|
|
for (const p of oldOutside) {
|
|
const id = metadataIdForLayer(p, key);
|
|
if (id >= 0 && !oldById.has(id)) oldById.set(id, clonePointForPatch(p));
|
|
}
|
|
const existing = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
|
const usedOldIds = new Set();
|
|
const next = [];
|
|
for (const p of existing) {
|
|
const id = metadataIdForLayer(p, key);
|
|
const old = id >= 0 ? oldById.get(id) : null;
|
|
if (old) {
|
|
// If an existing administrative label/center was outside the lasso
|
|
// before the patch, keep it anchored there by ID. The global coherence
|
|
// pass may otherwise move it into the selected area even though the
|
|
// user did not select the old center itself.
|
|
next.push(clonePointForPatch(old));
|
|
usedOldIds.add(id);
|
|
strictMetadataPointsRestored++;
|
|
continue;
|
|
}
|
|
next.push(p);
|
|
}
|
|
for (const [id, old] of oldById) {
|
|
if (usedOldIds.has(id)) continue;
|
|
const present = next.some((p) => metadataIdForLayer(p, key) === id);
|
|
if (!present) {
|
|
next.push(clonePointForPatch(old));
|
|
strictMetadataPointsRestored++;
|
|
}
|
|
}
|
|
sourceMap[key] = next;
|
|
}
|
|
return { strictMetadataPointsRestored };
|
|
}
|
|
|
|
function addInvalidatedRect(world, rect) {
|
|
if (!rect) return;
|
|
const normalized = normalizeRect(rect);
|
|
if (!normalized || rectArea(normalized) <= 0) return;
|
|
const key = rectKey(normalized);
|
|
const list = world.invalidatedRects || (world.invalidatedRects = []);
|
|
if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized });
|
|
}
|
|
|
|
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 strictFieldSnapshot = captureStrictSelectionFieldSnapshot(world, rects, seed);
|
|
const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
|
|
const candidateWindow = sourceWindowForRects(rects);
|
|
const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
|
|
const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
|
|
const patchTimer = createPatchTimer();
|
|
const patchGenerationMode = "legacy-full-pipeline";
|
|
const cacheKey = patchCandidateCacheKey({
|
|
seed,
|
|
terrainType,
|
|
variant,
|
|
candidateOriginX,
|
|
candidateOriginY,
|
|
contextRect: rects.contextRect,
|
|
serial: world.patchGenerationSerial || 0,
|
|
});
|
|
const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, {
|
|
terrainType,
|
|
legacyTerrain: true,
|
|
worldNative: true,
|
|
variant,
|
|
originX: candidateOriginX,
|
|
originY: candidateOriginY,
|
|
width: MAP_W,
|
|
height: MAP_H,
|
|
contextRect: rects.contextRect,
|
|
boundaryWorld: world,
|
|
patchMode: true,
|
|
topCenterSuppression: 0.72,
|
|
onProgress: () => {},
|
|
}));
|
|
patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation");
|
|
getPatchAlphaCache(rects, seed);
|
|
getPatchSourceIndexCache(rects, candidateWindow);
|
|
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
|
const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed);
|
|
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
|
|
|
const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30;
|
|
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel);
|
|
patchTimer.mark("fields", "Field copy and alpha blend");
|
|
const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
|
|
const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed);
|
|
const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
|
|
const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
|
|
const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed);
|
|
const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
|
|
const maskDebug = repairDisplayMasks(world, rects, seed);
|
|
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
|
|
patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
|
|
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
|
|
patchTimer.mark("points", "Point merge");
|
|
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
|
patchTimer.mark("paths", "Path merge and connector repair");
|
|
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
|
|
patchTimer.mark("influence", "Influence refresh");
|
|
const landDebug = repairLanduseAndPopulation(world, rects);
|
|
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
|
|
const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects);
|
|
const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
|
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
|
|
const municipalCoherence = reconcileMunicipalMetadata({
|
|
adminId: world.fields.adminId,
|
|
municipalityId: world.fields.municipalityId,
|
|
prefectureRegionId: world.fields.prefectureRegionId,
|
|
sea: world.fields.sea,
|
|
adminCenters: sourceMap.adminCenters || [],
|
|
municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
|
|
fields: world.fields,
|
|
width: world.width,
|
|
height: world.height,
|
|
pointOffsetX: world.originX || 0,
|
|
pointOffsetY: world.originY || 0,
|
|
seed,
|
|
});
|
|
sourceMap.adminCenters = municipalCoherence.adminCenters;
|
|
sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
|
|
const prefectureCoherence = refreshPrefectureRegionsMetadata({
|
|
prefectureRegionId: world.fields.prefectureRegionId,
|
|
sea: world.fields.sea,
|
|
existing: sourceMap.prefectureRegions || [],
|
|
adminCenters: sourceMap.adminCenters || [],
|
|
fields: world.fields,
|
|
width: world.width,
|
|
height: world.height,
|
|
pointOffsetX: world.originX || 0,
|
|
pointOffsetY: world.originY || 0,
|
|
});
|
|
sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
|
|
const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
|
const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed);
|
|
sourceMap.adminDebug = {
|
|
...(sourceMap.adminDebug || {}),
|
|
municipalCoherence: municipalCoherence.debug,
|
|
prefectureMetadataCoherence: prefectureCoherence.debug,
|
|
};
|
|
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window);
|
|
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
|
patchTimer.mark("segments", "Boundary and debug segment merge");
|
|
sanitizeExistingLogistics(sourceMap);
|
|
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
|
|
const patchTimings = patchTimer.timings;
|
|
|
|
const seaStats = countSea(world, rects.coreRect, rects, seed);
|
|
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,
|
|
...influenceDebug,
|
|
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
|
adminIdMapping: fieldDebug.adminIdMappingDebug,
|
|
sourceAdminMetadataUpdated,
|
|
...finalAdminCoverageDebug,
|
|
...adminTopologyDebug,
|
|
strictMaskCellsRestored: (strictMaskDebugPreCoherence.strictMaskCellsRestored || 0) + (strictMaskDebugPostCoherence.strictMaskCellsRestored || 0),
|
|
strictMaskValuesRestored: (strictMaskDebugPreCoherence.strictMaskValuesRestored || 0) + (strictMaskDebugPostCoherence.strictMaskValuesRestored || 0),
|
|
...strictMetadataDebug,
|
|
humanLandCellsRestored: fieldDebug.humanLandCellsRestored || 0,
|
|
humanLandFeatureMaskCells: fieldDebug.humanLandFeatureMaskCells || 0,
|
|
finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
|
|
finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
|
|
finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
|
|
finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
|
|
municipalCoherence: municipalCoherence.debug,
|
|
prefectureMetadataCoherence: prefectureCoherence.debug,
|
|
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
|
|
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
|
|
terrainSeamAdjustmentSamples: fieldDebug.terrainSeamAdjustmentSamples || 0,
|
|
terrainSeamElevationOffset: fieldDebug.terrainSeamElevationOffset || 0,
|
|
terrainSeamElevationTiltX: fieldDebug.terrainSeamElevationTiltX || 0,
|
|
terrainSeamElevationTiltY: fieldDebug.terrainSeamElevationTiltY || 0,
|
|
adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
|
|
prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
|
|
...terrainSeamDebug,
|
|
...elevationCliffDebug,
|
|
...waterComponentDebug,
|
|
...residualSeaDebug,
|
|
...waterElevationDebug,
|
|
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
|
displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
|
|
logisticsLabelsMigrated,
|
|
candidateCacheHit: cacheHit,
|
|
candidateCacheSize: cacheSize,
|
|
...segmentDebug,
|
|
candidateCompartmentSegmentsAdded,
|
|
};
|
|
|
|
const record = {
|
|
...rects.coreRect,
|
|
coreRect: { ...rects.coreRect },
|
|
selectionShape: rects.selectionShape ? {
|
|
kind: rects.selectionShape.kind || 'lasso',
|
|
areaCells: rects.selectionShape.areaCells || 0,
|
|
polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
|
|
} : null,
|
|
writeRect: { ...rects.writeRect },
|
|
repairRect: { ...rects.repairRect },
|
|
contextRect: { ...rects.contextRect },
|
|
transportReachRect: { ...rects.transportReachRect },
|
|
transportReachMargin: rects.transportReachMargin,
|
|
blendRect: { ...rects.blendRect },
|
|
terrainType: id,
|
|
label,
|
|
seed,
|
|
variant,
|
|
candidateOriginX,
|
|
candidateOriginY,
|
|
patchGenerationMode,
|
|
patchTimings,
|
|
updatedCells: fieldDebug.updatedCells,
|
|
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
|
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped + residualSeaDebug.residualSeaCellsFilled,
|
|
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
|
|
naturalRegionFragmentsMerged: 0,
|
|
adminIdMapping: fieldDebug.adminIdMappingDebug,
|
|
seaRatio: seaStats.seaRatio,
|
|
humanGeography,
|
|
createdAt: Date.now(),
|
|
};
|
|
world.generatedRects = [...(world.generatedRects || []), record];
|
|
addInvalidatedRect(world, rects.writeRect);
|
|
addInvalidatedRect(world, rects.transportReachRect);
|
|
world.lastPatchResult = record;
|
|
world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1;
|
|
|
|
return {
|
|
ok: true,
|
|
validation,
|
|
rects: {
|
|
...rects,
|
|
selectionShape: rects.selectionShape ? {
|
|
kind: rects.selectionShape.kind || 'lasso',
|
|
areaCells: rects.selectionShape.areaCells || 0,
|
|
polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
|
|
} : null,
|
|
},
|
|
terrainType: id,
|
|
label,
|
|
seed,
|
|
variant,
|
|
candidateOriginX,
|
|
candidateOriginY,
|
|
patchGenerationMode,
|
|
patchTimings,
|
|
updatedCells: record.updatedCells,
|
|
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
|
coastCellsChanged: record.coastCellsChanged,
|
|
naturalRegionsUpdated: record.naturalRegionsUpdated,
|
|
naturalRegionFragmentsMerged: 0,
|
|
adminIdMapping: fieldDebug.adminIdMappingDebug,
|
|
seaRatio: seaStats.seaRatio,
|
|
humanGeography,
|
|
};
|
|
}
|