diff --git a/app.js b/app.js
index 6eab3ad..0749340 100644
--- a/app.js
+++ b/app.js
@@ -70,6 +70,10 @@ const dragState = {
startClientY: 0,
startCameraX: 0,
startCameraY: 0,
+ lastClientX: 0,
+ lastClientY: 0,
+ panRemainderX: 0,
+ panRemainderY: 0,
selectStart: null,
selectEnd: null,
selectPath: null,
@@ -241,15 +245,34 @@ function selectionPathToShape(points) {
};
}
+function syncSelectionSvgToCanvas() {
+ if (!selectionSvgEl || !canvas) return null;
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(1, rect.width || canvas.clientWidth || canvas.width || 1);
+ const height = Math.max(1, rect.height || canvas.clientHeight || canvas.height || 1);
+ selectionSvgEl.style.left = `${canvas.offsetLeft}px`;
+ selectionSvgEl.style.top = `${canvas.offsetTop}px`;
+ selectionSvgEl.style.width = `${width}px`;
+ selectionSvgEl.style.height = `${height}px`;
+ selectionSvgEl.setAttribute("width", String(width));
+ selectionSvgEl.setAttribute("height", String(height));
+ selectionSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ return { width, height };
+}
+
function drawSelectionSvg(points, invalid = false) {
if (!selectionSvgEl) return;
- if (!points || points.length < 3) {
+ const bounds = syncSelectionSvgToCanvas();
+ if (!bounds || !points || points.length < 3) {
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
return;
}
- const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
- selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
+ const pts = points.map((p) => {
+ const x = Math.min(Math.max(p.x, 0), bounds.width);
+ const y = Math.min(Math.max(p.y, 0), bounds.height);
+ return `${x},${y}`;
+ }).join(" ");
selectionSvgEl.innerHTML = ``;
selectionSvgEl.style.display = "block";
selectionSvgEl.classList.toggle("invalid", !!invalid);
@@ -375,6 +398,8 @@ function clearDragMode() {
dragState.mode = null;
dragState.pointerId = null;
dragState.pendingCamera = null;
+ dragState.panRemainderX = 0;
+ dragState.panRemainderY = 0;
if (dragState.panRaf != null) {
cancelAnimationFrame(dragState.panRaf);
dragState.panRaf = null;
@@ -392,7 +417,10 @@ function schedulePanRedraw(camera) {
dragState.pendingCamera = null;
if (next.x === state.camera.x && next.y === state.camera.y) return;
state.camera = next;
- redraw({ fastTerrain: true });
+ // Do not auto-expand the backing world while a pointer drag is active.
+ // Expansion shifts world coordinates; doing it mid-drag invalidates the
+ // pointer-to-camera baseline and can make the viewport appear to jump.
+ redraw({ fastTerrain: true, allowWorldExpand: false });
});
}
@@ -432,6 +460,10 @@ function handleMapPointerDown(event) {
dragState.startClientY = event.clientY;
dragState.startCameraX = state.camera.x;
dragState.startCameraY = state.camera.y;
+ dragState.lastClientX = event.clientX;
+ dragState.lastClientY = event.clientY;
+ dragState.panRemainderX = 0;
+ dragState.panRemainderY = 0;
tooltipEl?.classList.remove("visible");
if (event.button === 0) {
@@ -455,14 +487,34 @@ function handleMapPointerMove(event) {
tooltipEl?.classList.remove("visible");
if (dragState.mode === "pan") {
- const cellSize = Math.max(1, displayedCellSize());
- const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
- const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
- const nextCamera = clampCameraForView({
- x: dragState.startCameraX - dxCells,
- y: dragState.startCameraY - dyCells,
- }, viewportSizeForZoom(state.zoom));
- schedulePanRedraw(nextCamera);
+ const rect = canvas.getBoundingClientRect();
+ const maxDeltaX = Math.max(320, rect.width * 0.72);
+ const maxDeltaY = Math.max(240, rect.height * 0.72);
+ const rawDx = event.clientX - dragState.lastClientX;
+ const rawDy = event.clientY - dragState.lastClientY;
+ dragState.lastClientX = event.clientX;
+ dragState.lastClientY = event.clientY;
+
+ // Pointer capture can occasionally deliver a stale/outlier coordinate after
+ // a tab switch, resize, context-menu gesture, or OS-level event hiccup. A
+ // single implausibly large delta would otherwise become a large camera jump.
+ if (Math.abs(rawDx) <= maxDeltaX && Math.abs(rawDy) <= maxDeltaY) {
+ const cellSize = Math.max(1, displayedCellSize());
+ const totalX = dragState.panRemainderX + rawDx / cellSize;
+ const totalY = dragState.panRemainderY + rawDy / cellSize;
+ const dxCells = totalX < 0 ? Math.ceil(totalX) : Math.floor(totalX);
+ const dyCells = totalY < 0 ? Math.ceil(totalY) : Math.floor(totalY);
+ dragState.panRemainderX = totalX - dxCells;
+ dragState.panRemainderY = totalY - dyCells;
+ if (dxCells || dyCells) {
+ const base = dragState.pendingCamera || state.camera;
+ const nextCamera = clampCameraForView({
+ x: base.x - dxCells,
+ y: base.y - dyCells,
+ }, viewportSizeForZoom(state.zoom));
+ schedulePanRedraw(nextCamera);
+ }
+ }
} else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
@@ -954,17 +1006,22 @@ function redraw(options = {}) {
const viewSize = syncViewportSize();
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
if (expansion?.expanded) {
- state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
+ const ex = expansion.dx || 0;
+ const ey = expansion.dy || 0;
+ state.camera = { x: (state.camera?.x || 0) + ex, y: (state.camera?.y || 0) + ey };
+ if (dragState.mode === "pan") {
+ dragState.startCameraX += ex;
+ dragState.startCameraY += ey;
+ if (dragState.pendingCamera) dragState.pendingCamera = { x: dragState.pendingCamera.x + ex, y: dragState.pendingCamera.y + ey };
+ }
if (state.selectionRect) {
- const dx = expansion.dx || 0;
- const dy = expansion.dy || 0;
state.selectionRect = {
...state.selectionRect,
- x0: state.selectionRect.x0 + dx,
- y0: state.selectionRect.y0 + dy,
- x1: state.selectionRect.x1 + dx,
- y1: state.selectionRect.y1 + dy,
- polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
+ x0: state.selectionRect.x0 + ex,
+ y0: state.selectionRect.y0 + ey,
+ x1: state.selectionRect.x1 + ex,
+ y1: state.selectionRect.y1 + ey,
+ polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + ex, y: p.y + ey })) : state.selectionRect.polygon,
};
}
}
diff --git a/index.html b/index.html
index a0b500d..638d91b 100644
--- a/index.html
+++ b/index.html
@@ -40,7 +40,7 @@
-
+
@@ -56,7 +56,7 @@
-
+
diff --git a/mapPatch.js b/mapPatch.js
index f8457d2..ae52648 100644
--- a/mapPatch.js
+++ b/mapPatch.js
@@ -158,7 +158,9 @@ function pointInPolygon(px, py, polygon) {
const yi = polygon[i].y + 0.5;
const xj = polygon[j].x + 0.5;
const yj = polygon[j].y + 0.5;
- const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi);
+ 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;
@@ -220,6 +222,58 @@ function ensureWorldField(world, name, source) {
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;
+ const rect = rects.writeRect;
+ const width = rectWidth(rect);
+ const height = rectHeight(rect);
+ const fields = new Map();
+ for (const [name, field] of Object.entries(world.fields)) {
+ if (!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;
@@ -269,12 +323,20 @@ export function buildPatchRects(userRect, world = null) {
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 writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
- const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
- const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
- const writeRect = expandRect(coreRect, writeMargin, world);
+ const 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);
const transportReachMargin = Math.max(
repairMargin + 48,
@@ -290,7 +352,8 @@ export function buildPatchRects(userRect, world = null) {
blendRect: coreRect,
userRect: writeRect,
selectedRect: coreRect,
- selectionShape: isPolygonSelection(coreRect) ? coreRect : null,
+ selectionShape: polygonSelection ? coreRect : null,
+ strictSelectionMask: polygonSelection,
writeMargin,
repairMargin,
transportReachMargin,
@@ -310,10 +373,16 @@ function computePatchAlpha(x, y, rects, seed = 0) {
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 applied inward only, so a large interior is fully regenerated
+ // while the edge remains just soft enough to avoid a visible cut line.
const dist = distanceToPolygonEdge(px, py, shape.polygon);
- const signedDist = inside ? dist : -dist;
- const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10;
- return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2)));
+ const feather = Math.max(2, Math.min(margin, 18));
+ const noisyInsideDist = dist + low * Math.min(3.0, feather * 0.18) + mid * Math.min(1.4, feather * 0.08);
+ const edge = smoothstep(clamp(noisyInsideDist / Math.max(1e-6, feather)));
+ return clamp(0.64 + edge * 0.36);
}
const edge = distanceToRectEdge(x, y, writeRect);
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
@@ -398,6 +467,42 @@ function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) {
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 sourceWindowForRects(rects) {
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
@@ -738,6 +843,130 @@ function cloneContinuityFields(world) {
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;
@@ -870,9 +1099,11 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
}
-function copyFullPipelineFields(world, candidate, rects, seed) {
+function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) {
const window = sourceWindowForRects(rects);
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,
@@ -977,6 +1208,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
}
}
+ 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) {
@@ -998,6 +1230,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
landUseCellsUpdated,
adminIdMapping,
adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
+ ...humanLandDebug,
...continuityDebug,
...seamOwnershipDebug,
};
@@ -1199,6 +1432,167 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, see
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;
@@ -1242,6 +1636,158 @@ function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed =
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;
@@ -1501,10 +2047,13 @@ function pathCost(world, x, y, mode) {
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) {
+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;
@@ -1526,9 +2075,11 @@ function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24
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 c = pathCost(world, nx, ny, mode);
- if (!Number.isFinite(c)) continue;
+ 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;
@@ -1575,13 +2126,13 @@ function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road
}
}
}
- const featureKeys = mode === "rail"
- ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"]
- : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"];
- for (const key of featureKeys) {
- for (const p of sourceMap[key] || []) {
- add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25);
- }
+ // 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;
}
@@ -1616,7 +2167,7 @@ function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachR
return candidates.slice(0, mode === "rail" ? 18 : 28);
}
-function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) {
+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)) : [];
@@ -1655,7 +2206,16 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000);
if (!path || path.length < 2) continue;
seen.add(sig);
- sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
+ 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;
@@ -1682,7 +2242,7 @@ function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance =
return best;
}
-function ensureSettlementRoadCoverage(world, sourceMap, rect) {
+function ensureSettlementRoadCoverage(world, sourceMap, rect, patchOptions = null) {
const keys = ["nationalRoads", "minorRoads", "premodernRoads"];
const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
sourceMap.minorRoads ||= [];
@@ -1713,13 +2273,349 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) {
seen.add(sig);
const path = localPathfind(world, start, target, rect, "road", 36000);
if (!path || path.length < 2) continue;
- sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
+ 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;
+ const nearLand = isLand(world, x, y) || seaNeighbors(world, x, y, 2) >= 3;
+ if (!nearLand) 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 sampleTransportComponent(snapshot, comp, limit = 96) {
+ const cells = comp?.cells || [];
+ if (!cells.length) return [];
+ const out = [];
+ const step = Math.max(1, Math.floor(cells.length / Math.max(1, limit)));
+ for (let i = 0; i < cells.length; i += step) {
+ const li = cells[i];
+ const x = snapshot.graphRect.x0 + (li % snapshot.width);
+ const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width);
+ out.push({ x, y });
+ if (out.length >= limit) break;
+ }
+ // Bias the sample toward the component centroid and bounding-box edges; this
+ // improves reconnection when a long road was clipped by the patch edge.
+ 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 p of specials) {
+ if (insideRect(p.x, p.y, snapshot.graphRect)) out.push(p);
+ }
+ return out;
+}
+
+function bestTransportComponentPair(snapshot, main, other, maxDistance) {
+ const mainSamples = sampleTransportComponent(snapshot, main, 48);
+ const otherSamples = sampleTransportComponent(snapshot, other, 48);
+ let best = null;
+ for (const a of mainSamples) {
+ for (const b of otherSamples) {
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
+ if (d < 3 || d > maxDistance) continue;
+ const score = d / Math.sqrt(Math.max(4, other.size));
+ if (!best || score < best.score) best = { a, b, d, score };
+ }
+ }
+ return best;
+}
+
+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 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 strictMask = !!rects.strictSelectionMask;
+ const maxAdds = options.maxAdds ?? (mode === "rail" ? 5 : 10);
+ const maxDistance = options.maxDistance ?? (mode === "rail" ? 120 : 140);
+ 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;
+ // Work only on the dirty neighborhood. External components are included
+ // when they touch the dirty region so regenerated internal roads can attach
+ // back to the pre-existing graph.
+ return comp.patchCells > 0 || comp.nearWriteCells > 0;
+ };
+
+ let snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
+ debug[`${mode}GraphBeforeComponents`] = snapshot.comps.filter(eligible).length;
+ const rejectedPairs = new Set();
+ const rejectedComponents = new Set();
+ const componentSignature = (comp) => `${Math.round((comp?.cx || 0) / 5)},${Math.round((comp?.cy || 0) / 5)},${Math.round((comp?.size || 0) / 6)}`;
+ for (let pass = 0; pass < maxAdds; pass++) {
+ snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
+ const comps = snapshot.comps.filter(eligible);
+ if (comps.length <= 1) break;
+ comps.sort((a, b) => b.score - a.score);
+ const main = comps[0];
+ let best = null;
+ const others = comps.slice(1, Math.min(comps.length, mode === "rail" ? 8 : 12));
+ for (const comp of others) {
+ if (rejectedComponents.has(componentSignature(comp))) continue;
+ const pair = bestTransportComponentPair(snapshot, main, comp, maxDistance);
+ if (!pair) { debug[`${mode}GraphComponentsIgnored`]++; continue; }
+ debug[`${mode}GraphCandidatesConsidered`]++;
+ const pairSig = `${Math.round(pair.a.x)},${Math.round(pair.a.y)}:${Math.round(pair.b.x)},${Math.round(pair.b.y)}`;
+ const pairSigRev = `${Math.round(pair.b.x)},${Math.round(pair.b.y)}:${Math.round(pair.a.x)},${Math.round(pair.a.y)}`;
+ if (rejectedPairs.has(pairSig) || rejectedPairs.has(pairSigRev)) continue;
+ const patchBias = comp.patchCells > 0 ? 0.76 : 1.0;
+ const exteriorBias = comp.exteriorCells > 0 && main.exteriorCells > 0 ? 1.18 : 1.0;
+ const score = pair.score * patchBias * exteriorBias;
+ if (!best || score < best.score) best = { comp, pair, score };
+ }
+ if (!best) break;
+
+ const { a, b, d } = best.pair;
+ const pad = Math.ceil(Math.max(18, Math.min(72, d * 0.45 + 12)));
+ 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, strictMask);
+ const extraCost = makeTransportConnectorExtraCost(world, rects, seed, mode);
+ const path = localPathfind(world, a, b, boundedSearchRect, mode, mode === "rail" ? 15000 : 18000, { allowCell, extraCost });
+ const clean = dedupeWorldPath(path || []);
+ const routeLen = pathLength(clean);
+ const tooLong = !clean.length || routeLen > d * (mode === "rail" ? 2.25 : 2.65) + (mode === "rail" ? 30 : 42);
+ if (tooLong) {
+ debug[`${mode}GraphConnectorsFailed`]++;
+ rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`);
+ rejectedComponents.add(componentSignature(best.comp));
+ continue;
+ }
+ const beforeEligibleComponents = comps.length;
+ const writeResult = writeConnectorPath(world, sourceMap, mode, clean, rects, seed, strictMask);
+ if (!writeResult.wrote) {
+ debug[`${mode}GraphConnectorsFailed`]++;
+ rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`);
+ rejectedComponents.add(componentSignature(best.comp));
+ continue;
+ }
+ const checkSnapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
+ const afterEligibleComponents = checkSnapshot.comps.filter(eligible).length;
+ if (afterEligibleComponents >= beforeEligibleComponents) {
+ // Strict-mask clipping can produce a visible fragment that does not
+ // actually merge graph components. Roll it back; otherwise repeated
+ // attempts accumulate harmless-looking but noisy connector shards.
+ sourceMap[writeResult.layer].splice(writeResult.startLength);
+ debug[`${mode}GraphConnectorsFailed`]++;
+ rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`);
+ rejectedComponents.add(componentSignature(best.comp));
+ continue;
+ }
+ debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote;
+ }
+ 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();
@@ -1818,20 +2714,34 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
}
sourceMap[key] = next;
}
- const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
+ 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);
- const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect);
- const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect);
- const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect);
+ const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect, connectorPatchOptions);
+ const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect, connectorPatchOptions);
+ const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 };
+ const graphRect = strictMask
+ ? transportRect
+ : transportRect;
+ const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 16 : 10, maxDistance: strictMask ? 260 : 180 });
+ const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 8 : 5, maxDistance: strictMask ? 210 : 140 });
return {
roadsClipped,
railsClipped,
regeneratedPaths,
- roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors,
- railwayConnectorsCreated: railConn.connectors,
+ 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),
@@ -1840,6 +2750,8 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
externalRoadAnchors: externalRoadAnchors.length,
externalRailAnchors: externalRailAnchors.length,
+ ...roadGraph,
+ ...railGraph,
};
}
@@ -1888,15 +2800,45 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec
return added;
}
-function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
+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;
+
+ // Do not wipe every prepared boundary inside the repair rectangle. That made
+ // the old/new seam itself visible as a prefecture border and left broad holes
+ // when candidate boundaries were weak. Replace only strong patch-interior
+ // segments; keep the weak seam band owned by the existing world.
for (const key of SEGMENT_LAYER_KEYS) {
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
- sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
+ sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha));
}
- sourceMap.adminBorders ||= [];
- sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 }));
- sourceMap.regionalPrefectureBorders ||= [];
- sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect, { rects, seed, minAlpha: 0.72 }));
+
+ const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76);
+ const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78);
+ const rebuiltAdmin = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 });
+ const rebuiltPrefecture = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 });
+
+ sourceMap.adminBorders = dedupeSegments([...(sourceMap.adminBorders || []), ...candidateAdmin, ...rebuiltAdmin]);
+ sourceMap.regionalPrefectureBorders = dedupeSegments([...(sourceMap.regionalPrefectureBorders || []), ...candidatePref, ...rebuiltPrefecture]);
sourceMap.prefectureBorder ||= [];
const debug = sourceMap.adminDebug || {};
@@ -1909,6 +2851,8 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
return {
adminBordersRebuilt: sourceMap.adminBorders.length,
prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length,
+ candidateAdminBordersMerged: candidateAdmin.length,
+ candidatePrefectureBordersMerged: candidatePref.length,
compartmentBordersRebuilt: debug.compartmentBorders.length,
};
}
@@ -2082,6 +3026,7 @@ export function generatePatch(world, userRectInput, options = {}) {
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);
@@ -2116,12 +3061,15 @@ export function generatePatch(world, userRectInput, options = {}) {
const sourceMap = world.sourceMap || (world.sourceMap = {});
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
- const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
+ 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 waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed);
+ const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
+ const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
+ const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
const maskDebug = repairDisplayMasks(world, rects, seed);
- recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
+ 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");
@@ -2131,6 +3079,8 @@ export function generatePatch(world, userRectInput, options = {}) {
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 strictMaskDebug = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
const municipalCoherence = reconcileMunicipalMetadata({
adminId: world.fields.adminId,
@@ -2164,7 +3114,7 @@ export function generatePatch(world, userRectInput, options = {}) {
municipalCoherence: municipalCoherence.debug,
prefectureMetadataCoherence: prefectureCoherence.debug,
};
- const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed);
+ 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);
@@ -2186,6 +3136,10 @@ export function generatePatch(world, userRectInput, options = {}) {
adminIdMapping: fieldDebug.adminIdMappingDebug,
sourceAdminMetadataUpdated,
...finalAdminCoverageDebug,
+ ...adminTopologyDebug,
+ ...strictMaskDebug,
+ humanLandCellsRestored: fieldDebug.humanLandCellsRestored || 0,
+ humanLandFeatureMaskCells: fieldDebug.humanLandFeatureMaskCells || 0,
finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
@@ -2197,6 +3151,8 @@ export function generatePatch(world, userRectInput, options = {}) {
adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
...terrainSeamDebug,
+ ...waterComponentDebug,
+ ...waterElevationDebug,
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
logisticsLabelsMigrated,
@@ -2228,7 +3184,7 @@ export function generatePatch(world, userRectInput, options = {}) {
patchTimings,
updatedCells: fieldDebug.updatedCells,
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
- coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
+ coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped,
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
naturalRegionFragmentsMerged: 0,
adminIdMapping: fieldDebug.adminIdMappingDebug,
diff --git a/mapPatch.js.bak b/mapPatch.js.bak
new file mode 100644
index 0000000..da1dcd2
--- /dev/null
+++ b/mapPatch.js.bak
@@ -0,0 +1,2660 @@
+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"]);
+
+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 intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + 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];
+}
+
+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 desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
+ const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
+ const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
+ const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
+ const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
+ const writeRect = expandRect(coreRect, writeMargin, world);
+ const repairRect = expandRect(coreRect, repairMargin, world);
+ const transportReachMargin = Math.max(
+ repairMargin + 48,
+ Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15)))
+ );
+ const transportReachRect = expandRect(coreRect, transportReachMargin, world);
+ return {
+ coreRect,
+ writeRect,
+ repairRect,
+ contextRect: repairRect,
+ transportReachRect,
+ blendRect: coreRect,
+ userRect: writeRect,
+ selectedRect: coreRect,
+ selectionShape: isPolygonSelection(coreRect) ? coreRect : null,
+ 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);
+ const dist = distanceToPolygonEdge(px, py, shape.polygon);
+ const signedDist = inside ? dist : -dist;
+ const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10;
+ return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2)));
+ }
+ 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 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 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 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) {
+ const window = sourceWindowForRects(rects);
+ const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
+ 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;
+ dest[wi] = lerp(before, source[si] || 0, 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 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),
+ ...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;
+ for (const key of smoothKeys) {
+ const field = fields[key];
+ if (!field || !ArrayBuffer.isView(field)) continue;
+ const old = new field.constructor(field);
+ 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 += old[ni] || 0;
+ 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 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 isLand(world, x, y) {
+ const i = worldIndex(world, x, y);
+ return i >= 0 && !world.fields.sea?.[i];
+}
+
+function nearestLand(world, x, y, rect, radius = 10) {
+ if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
+ for (let r = 1; r <= radius; r++) {
+ let best = null;
+ let bestScore = Infinity;
+ for (let yy = y - r; yy <= y + r; yy++) {
+ for (let xx = x - r; xx <= x + r; xx++) {
+ if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
+ if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
+ const i = worldIndex(world, xx, yy);
+ const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
+ if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
+ }
+ }
+ if (best) return best;
+ }
+ return null;
+}
+
+function pointWorldX(world, p) {
+ if (Number.isFinite(p?.worldX)) return p.worldX;
+ return (p?.x || 0) + (world?.originX || 0);
+}
+
+function pointWorldY(world, p) {
+ if (Number.isFinite(p?.worldY)) return p.worldY;
+ return (p?.y || 0) + (world?.originY || 0);
+}
+
+function tupleWorldX(world, tuple) {
+ return (tuple?.[0] || 0) + (world?.originX || 0);
+}
+
+function tupleWorldY(world, tuple) {
+ return (tuple?.[1] || 0) + (world?.originY || 0);
+}
+
+function sourcePointFromWorld(world, point) {
+ return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
+}
+
+function sourcePathFromWorld(world, path) {
+ return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
+}
+
+function 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 touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34));
+ 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, 0.34);
+ 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, 0.34)) kept.push(sourcePathFromWorld(world, chunk));
+ }
+ return { kept, anchors, clipped };
+}
+
+function pathCost(world, x, y, mode) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) return Infinity;
+ const slope = world.fields.slope?.[i] || 0;
+ const river = world.fields.river?.[i] || 0;
+ const plain = world.fields.plain?.[i] || 0;
+ const roadInfluence = world.fields.roadInfluence?.[i] || 0;
+ const pop = world.fields.populationDensity?.[i] || 0;
+ const slopeMult = mode === "rail" ? 15 : 7;
+ return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
+}
+
+function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
+ const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
+ if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
+ if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
+ const w = rectWidth(rect);
+ const h = rectHeight(rect);
+ const n = w * h;
+ const dist = new Float64Array(n); dist.fill(Infinity);
+ const prev = new Int32Array(n); prev.fill(-1);
+ const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
+ const heap = new MinHeap();
+ const startId = local(sx, sy);
+ dist[startId] = 0;
+ heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
+ let expanded = 0;
+ let found = -1;
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+ while (heap.items.length && expanded < maxExpanded) {
+ const cur = heap.pop();
+ if (!cur) break;
+ if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
+ expanded++;
+ for (const [dx, dy] of dirs) {
+ const nx = cur.x + dx, ny = cur.y + dy;
+ if (!insideRect(nx, ny, rect)) continue;
+ const nid = local(nx, ny);
+ const c = pathCost(world, nx, ny, mode);
+ if (!Number.isFinite(c)) continue;
+ const step = (dx && dy ? 1.42 : 1) * c;
+ const nd = dist[cur.id] + step;
+ if (nd >= dist[nid]) continue;
+ dist[nid] = nd;
+ prev[nid] = cur.id;
+ heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
+ }
+ }
+ if (found < 0) return null;
+ const rev = [];
+ let at = found;
+ while (at >= 0) {
+ const x = rect.x0 + (at % w);
+ const y = rect.y0 + Math.floor(at / w);
+ rev.push([x, y]);
+ at = prev[at];
+ }
+ return rev.reverse();
+}
+
+function simplifyPath(path, keepEvery = 2) {
+ if (!path || path.length <= 2) return path || [];
+ const out = [path[0]];
+ for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
+ out.push(path[path.length - 1]);
+ return out;
+}
+
+function collectInternalNetworkPoints(world, sourceMap, keys, rect, 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);
+ }
+ }
+ }
+ const featureKeys = mode === "rail"
+ ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"]
+ : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"];
+ for (const key of featureKeys) {
+ for (const p of sourceMap[key] || []) {
+ add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25);
+ }
+ }
+ 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) {
+ 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);
+ sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
+ 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) {
+ 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;
+ sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
+ connectors++;
+ }
+ }
+ return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked };
+}
+
+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;
+ if (!inWrite || alpha < 0.34) {
+ 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) || patchAlpha(x, y, rects, seed) > 0.90))
+ .filter((chunk) => chunk.length >= 2);
+ for (const chunk of chunks) {
+ if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
+ next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
+ regeneratedPaths++;
+ }
+ }
+ }
+ sourceMap[key] = next;
+ }
+ const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
+ 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);
+ const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect);
+ const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect);
+ const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect);
+ return {
+ roadsClipped,
+ railsClipped,
+ regeneratedPaths,
+ roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors,
+ railwayConnectorsCreated: railConn.connectors,
+ 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,
+ };
+}
+
+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;
+
+ // Do not wipe every prepared boundary inside the repair rectangle. That made
+ // the old/new seam itself visible as a prefecture border and left broad holes
+ // when candidate boundaries were weak. Replace only strong patch-interior
+ // segments; keep the weak seam band owned by the existing world.
+ for (const key of SEGMENT_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha));
+ }
+
+ const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76);
+ const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78);
+ const rebuiltAdmin = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 });
+ const rebuiltPrefecture = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 });
+
+ sourceMap.adminBorders = dedupeSegments([...(sourceMap.adminBorders || []), ...candidateAdmin, ...rebuiltAdmin]);
+ sourceMap.regionalPrefectureBorders = dedupeSegments([...(sourceMap.regionalPrefectureBorders || []), ...candidatePref, ...rebuiltPrefecture]);
+ sourceMap.prefectureBorder ||= [];
+
+ const debug = sourceMap.adminDebug || {};
+ // Use the legacy full-pipeline compartment debug segments for regenerated
+ // areas. Rebuilding directly from the raster field made patch compartments
+ // look denser/smaller than the initial map. Candidate segments are merged
+ // just after this function.
+ 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,
+ 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 refreshPatchInfluenceFields(world, sourceMap, rects) {
+ const rect = rects.repairRect || rects.writeRect;
+ const roadInfluence = ensureWorldFloatField(world, "roadInfluence");
+ const railInfluence2 = ensureWorldFloatField(world, "railInfluence2");
+ const stationInfluence = ensureWorldFloatField(world, "stationInfluence");
+ const villageInfluence = ensureWorldFloatField(world, "villageInfluence");
+ for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect);
+
+ let roadCellsPainted = 0;
+ let railCellsPainted = 0;
+ let stationCellsPainted = 0;
+ let villageCellsPainted = 0;
+ const paintPathLayer = (keys, field, radius, strength, counterName) => {
+ let painted = 0;
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ 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");
+ paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail");
+
+ for (const p of sourceMap.stations || []) {
+ const x = pointWorldX(world, p);
+ const y = pointWorldY(world, p);
+ if (rectDistance(x, y, rect) > 8) continue;
+ paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect);
+ stationCellsPainted++;
+ }
+ for (const p of sourceMap.villages || []) {
+ const x = pointWorldX(world, p);
+ const y = pointWorldY(world, p);
+ if (rectDistance(x, y, rect) > 10) continue;
+ paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect);
+ villageCellsPainted++;
+ }
+
+ return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted };
+}
+
+function countSea(world, rect) {
+ let seaCount = 0;
+ let total = 0;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ total++;
+ if (world.fields.sea?.[i]) seaCount++;
+ }
+ }
+ return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
+}
+
+function terrainLabel(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
+}
+
+function terrainId(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
+}
+
+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 };
+}
+
+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 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,
+ 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 logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
+
+ const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
+ patchTimer.mark("fields", "Field copy and alpha blend");
+ const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
+ const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30;
+ const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
+ const waterComponentDebug = repairWaterComponentTopology(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 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 || [],
+ fields: world.fields,
+ width: world.width,
+ height: world.height,
+ pointOffsetX: world.originX || 0,
+ pointOffsetY: world.originY || 0,
+ });
+ sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
+ 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);
+ 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,
+ 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,
+ adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
+ prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
+ ...terrainSeamDebug,
+ ...waterComponentDebug,
+ ...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 },
+ 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,
+ naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
+ naturalRegionFragmentsMerged: 0,
+ adminIdMapping: fieldDebug.adminIdMappingDebug,
+ seaRatio: seaStats.seaRatio,
+ humanGeography,
+ createdAt: Date.now(),
+ };
+ world.generatedRects = [...(world.generatedRects || []), record];
+ world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
+ 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,
+ };
+}
diff --git a/mapTerrain.js b/mapTerrain.js
index 497084e..ece1655 100644
--- a/mapTerrain.js
+++ b/mapTerrain.js
@@ -209,15 +209,16 @@ const TERRAIN_TYPES = [
},
{
id: "oceanic_archipelago",
- label: "海洋型・多島海",
- weight: 0.16,
+ label: "Ocean",
+ weight: 0.00,
+ autoSelectable: false,
coastStyle: "oceanic_archipelago",
mountainMode: "mixed",
massifnessRange: [0.04, 0.28],
- seaRatioRange: [0.72, 0.90],
+ seaRatioRange: [0.955, 0.985],
twoSidedChance: 1.0,
mountainOffsetRange: [0.25, 0.55],
- baseHeightRange: [0.30, 0.62],
+ baseHeightRange: [0.20, 0.46],
primaryLengthRange: [0.20, 0.52],
primaryWidthRange: [0.08, 0.24],
systemCountRange: [7, 14],
@@ -227,7 +228,7 @@ const TERRAIN_TYPES = [
lengthScale: 0.78,
widthScale: 0.82,
heightScale: 0.58,
- coastStrength: 1.55,
+ coastStrength: 2.10,
plainBiasRange: [0.12, 0.34],
riverRichnessRange: [0.18, 0.52],
bigRiverChanceRange: [0.02, 0.12],
@@ -315,11 +316,11 @@ function pickTerrainType(seed, requestedType = "auto") {
const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType);
if (selected) return selected;
}
- // Terrain type selection is intentionally uniform. Individual terrain
- // templates still contain their own parameter ranges, but there is no
- // terrain-type appearance weighting.
- const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length;
- return TERRAIN_TYPES[index];
+ // Auto excludes explicitly manual/special-purpose templates such as Ocean.
+ // The selection remains uniform over ordinary land-bearing templates.
+ const autoTypes = TERRAIN_TYPES.filter((type) => type.autoSelectable !== false);
+ const index = Math.floor(rand(seed, 10001) * autoTypes.length) % autoTypes.length;
+ return autoTypes[index];
}
function rangeValue(seed, salt, [lo, hi]) {
@@ -1174,8 +1175,8 @@ function enforceLandGradient(elevation, sea, seaLevel) {
function rectTerrainProfile(template) {
const id = String(template?.terrainType || "auto");
if (id.includes("oceanic")) return {
- base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28,
- seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86,
+ base: 0.28, relief: 0.11, ridge: 0.20, ridgeWidth: 18, ridgeSpacing: 70, coast: 0.52, archipelago: 0.18,
+ seaQuantile: Math.max(0.95, template.seaRatio ?? 0.965), plain: 0.12, moisture: 0.66, capStart: 0.56, capMax: 0.74,
};
if (id.includes("setouchi") || id.includes("archipelago")) return {
base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20,
@@ -1697,7 +1698,11 @@ export function generateTerrainRect(options = {}) {
}
}
- const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50);
+ const seaLevel = clamp(
+ Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile),
+ 0.13,
+ terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.50
+ );
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
recomputeRectSlope(ctx, fields);
const filled = priorityFloodRect(ctx, fields);
@@ -1922,7 +1927,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
}
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
- seaLevel = clamp(seaLevel, 0.14, 0.47);
+ seaLevel = clamp(seaLevel, 0.14, terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.47);
classifyWater(elevation, seaLevel, sea, ocean, lake);
recomputeSlope(elevation, sea, slope);
diff --git a/renderer.js b/renderer.js
index e387f7c..1de25e1 100644
--- a/renderer.js
+++ b/renderer.js
@@ -1,4 +1,4 @@
-import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
+import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
@@ -296,6 +296,38 @@ function isWaterSample(map, fx, fy) {
return seaCoverageSample(map, fx, fy) >= 0.50;
}
+function terrainWorldPoint(map, fx, fy) {
+ return {
+ x: fx + (map?.worldCamera?.x || 0),
+ y: fy + (map?.worldCamera?.y || 0),
+ };
+}
+
+function waterVisualTone(map, fx, fy) {
+ const p = terrainWorldPoint(map, fx, fy);
+ const seed = (map?.seed || 0) >>> 0;
+ const broad = valueNoise(p.x, p.y, seed ^ 0x5d2a9b31, 74);
+ const mid = valueNoise(p.x, p.y, seed ^ 0x8f4c2d19, 29);
+ return clamp(broad * 0.72 + mid * 0.28);
+}
+
+function waterVisualColor(map, fx, fy, waterCoverage = null) {
+ const coverage = waterCoverage ?? seaCoverageSample(map, fx, fy);
+ const tone = waterVisualTone(map, fx, fy) - 0.5;
+ const offshore = clamp((coverage - 0.42) / 0.58);
+ const depth = clamp(0.48 + offshore * 0.22 + tone * 0.10);
+ return [
+ Math.round(174 - depth * 6),
+ Math.round(203 + depth * 2),
+ Math.round(224 + depth * 4),
+ ];
+}
+
+function waterVisualShade(map, fx, fy) {
+ const tone = waterVisualTone(map, fx * 0.85 + 11.7, fy * 0.85 - 3.1) - 0.5;
+ return clamp(0.992 + tone * 0.018, 0.976, 1.012);
+}
+
function mixRgb(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
@@ -363,8 +395,10 @@ function terrainColorContinuous(map, fx, fy, mode) {
let color;
const waterCoverage = seaCoverageSample(map, fx, fy);
- const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4);
- const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
+ // Water must not inherit terrain DEM hill bands. Use a stable world-coordinate
+ // visual water tone instead of elevation-derived depth; otherwise patched sea
+ // areas expose rectangular/horizontal generation artefacts.
+ const waterColor = waterVisualColor(map, fx, fy, waterCoverage);
let landColor;
if (mode === "development") {
@@ -405,6 +439,9 @@ function terrainColorContinuous(map, fx, fy, mode) {
}
function terrainShadeContinuous(map, fx, fy) {
+ const waterCoverage = seaCoverageSample(map, fx, fy);
+ if (waterCoverage >= 0.50) return waterVisualShade(map, fx, fy);
+
const step = 0.50;
const eC = fieldSample(map, map.elevation, fx, fy);
const eL = fieldSample(map, map.elevation, fx - step, fy);
@@ -589,6 +626,14 @@ function getRasterBorderSegments(map, fieldName) {
return segments;
}
+function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) {
+ // Raw raster borders expose every one-cell ID fragment and also reveal patch
+ // ownership seams as long rectangular prefecture/municipality lines. Keep
+ // that extractor debug-only; normal modes must use prepared vector layers.
+ if (mode === "borders-debug") return getRasterBorderSegments(map, fieldName);
+ return Array.isArray(precomputedSegments) ? precomputedSegments : [];
+}
+
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
if (!path || path.length < 2) return;
ctx.save();
@@ -1067,8 +1112,8 @@ export function drawMap(canvas, map, options) {
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
- const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
- const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
+ const adminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode);
+ const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode);
if (showAdmin && adminBorderSegments?.length) {
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
diff --git a/styles.css b/styles.css
index 7ffa8ee..5b353a2 100644
--- a/styles.css
+++ b/styles.css
@@ -72,7 +72,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
.canvas-shell.panning{cursor:grabbing}
-.map-selection-svg{position:absolute;inset:12px;z-index:18;display:none;pointer-events:none;overflow:visible}
+.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible}
.map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}
.map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)}
.canvas-shell.selecting{cursor:crosshair}