diff --git a/app.js b/app.js
index 79292b3..5893a6b 100644
--- a/app.js
+++ b/app.js
@@ -957,6 +957,7 @@ function updateRenderDiagnostics(options = {}, timings = {}) {
hoverMs: timings.hoverMs || 0,
drawMs: timings.drawMs || 0,
totalRenderMs: timings.totalRenderMs || 0,
+ renderBreakdown: timings.renderBreakdown || null,
};
state.diagnostics.lastFeatureCounts = collectFeatureCounts(map);
}
@@ -981,6 +982,15 @@ function selectionWriteDiagnostics() {
function renderViewportDiagnostics() {
const viewport = state.diagnostics.lastViewport;
+ const renderRows = viewport?.renderBreakdown ? [
+ { label: "Draw / base", value: formatMs(viewport.renderBreakdown.baseTerrain || 0), sub: "terrain raster" },
+ { label: "Draw / urban", value: formatMs(viewport.renderBreakdown.urbanFill || 0), sub: "land-use overlay" },
+ { label: "Draw / coast", value: formatMs(viewport.renderBreakdown.coastline || 0), sub: "coastline vectors" },
+ { label: "Draw / rivers", value: formatMs(viewport.renderBreakdown.rivers || 0), sub: "river paths" },
+ { label: "Draw / admin", value: formatMs(viewport.renderBreakdown.adminBorders || 0), sub: "fills and borders" },
+ { label: "Draw / transport", value: formatMs(viewport.renderBreakdown.transport || 0), sub: "roads and rail" },
+ { label: "Draw / labels", value: formatMs((viewport.renderBreakdown.icons || 0) + (viewport.renderBreakdown.labels || 0)), sub: "icons and text" },
+ ] : [];
renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [
{ label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` },
{ label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" },
@@ -990,6 +1000,7 @@ function renderViewportDiagnostics() {
{ label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" },
{ label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" },
{ label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` },
+ ...renderRows,
] : [], "No viewport render recorded yet.");
const counts = state.diagnostics.lastFeatureCounts;
@@ -1056,13 +1067,15 @@ function performanceMetricsForRuns(runs) {
const totals = summarizeValues(numericValues(runs, (run) => run.totalMs));
const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand));
if (!totals) return [];
+ const hasSmallAreaRuns = (runs || []).some((run) => Number(run?.areaCells) > 0 && Number(run.areaCells) < 1000);
+ const perAreaSub = hasSmallAreaRuns ? "area-normalized; small runs include fixed overhead" : "seconds / 1k cells";
return [
{ label: "Samples", value: String(totals.count), sub: "last 10" },
{ label: "Avg total", value: formatMs(totals.avg), sub: "generation time" },
{ label: "Max total", value: formatMs(totals.max), sub: "slowest run" },
{ label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" },
- perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: "seconds / 1k cells" } : null,
- perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: "area-normalized" } : null,
+ perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: perAreaSub } : null,
+ perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: hasSmallAreaRuns ? "fixed overhead dominated below 1k cells" : "area-normalized" } : null,
];
}
@@ -1072,6 +1085,10 @@ function recordGenerationRun(map, terrainType) {
const totalMs = Number.isFinite(map.generationTotalMs)
? map.generationTotalMs
: (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0);
+ const featureTimings = (map.transportDebug?.featureTimings || []).map((row) => ({
+ label: `Settlements / ${row.key || "substage"}`,
+ ms: Number(row.ms) || 0,
+ }));
pushCapped(state.generationRuns, {
id: Date.now(),
createdAt: new Date(),
@@ -1082,7 +1099,7 @@ function recordGenerationRun(map, terrainType) {
timings: (map.generationTimings || []).map((row) => ({
label: row.label || row.key || "Stage",
ms: Number(row.ms) || 0,
- })),
+ })).concat(featureTimings),
});
renderAdvancedData();
}
@@ -1187,7 +1204,7 @@ function appendRunHistory(container, runs, options = {}) {
${name}
${formatMs(run.totalMs)}
${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""}
- ${formatSeconds(run.secondsPerThousand)} / 1k cells
+ ${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""}
`;
const body = document.createElement("div");
@@ -2235,7 +2252,7 @@ function redraw(options = {}) {
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
const hoverMs = performance.now() - hoverStartedAt;
const drawStartedAt = performance.now();
- drawMap(canvas, state.viewportMap, {
+ const renderBreakdown = drawMap(canvas, state.viewportMap, {
mode: state.mode,
showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain,
@@ -2251,6 +2268,7 @@ function redraw(options = {}) {
hoverMs,
drawMs,
totalRenderMs: performance.now() - redrawStartedAt,
+ renderBreakdown,
});
renderAdvancedData();
}
diff --git a/mapPatch.js b/mapPatch.js
index 3406180..86c0bba 100644
--- a/mapPatch.js
+++ b/mapPatch.js
@@ -3,6 +3,7 @@ import { generateMap } from "./mapPipeline.js";
import { generateTerrainRect } from "./mapTerrain.js";
import { LANDUSE } from "./landuseCodes.js";
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
+import { createPatchContext } from "./mapPatchContext.js";
export const PATCH_MIN_WIDTH = 48;
export const PATCH_MIN_HEIGHT = 48;
@@ -1358,8 +1359,8 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
}
-function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) {
- const window = sourceWindowForRects(rects);
+function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30, patchContext = null) {
+ const window = patchContext?.candidateWindow || sourceWindowForRects(rects);
const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed);
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null;
@@ -1381,6 +1382,7 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
let adminCellsReassigned = 0;
let landUseCellsUpdated = 0;
+ const fieldEntries = [];
for (const [name, source] of Object.entries(candidate || {})) {
if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue;
const dest = ensureWorldField(world, name, source);
@@ -1388,7 +1390,11 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
const idOffset = fieldIdOffset(name, seed);
+ fieldEntries.push({ name, source, dest, isDiscrete, idOffset });
+ }
+ const cells = patchContext?.writeCells?.length ? patchContext.writeCells : (() => {
+ const out = [];
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);
@@ -1396,33 +1402,39 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
const si = sourceIndexForWorld(rects, window, x, y);
if (si < 0) continue;
const alpha = patchAlpha(x, y, rects, seed);
- if (alpha <= 0.005) continue;
+ if (alpha > 0.005) out.push({ x, y, wi, si, alpha });
+ }
+ }
+ return out;
+ })();
- if (isDiscrete) {
- const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
- if (alpha >= threshold) {
- const raw = source[si];
- const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
- const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
- ? mapped
- : idOffset && raw >= 0 ? raw + idOffset : raw;
- if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
- if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
- if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
- if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
- dest[wi] = value;
- }
- } else {
- const before = dest[wi] || 0;
- let candidateValue = source[si] || 0;
- if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment);
- dest[wi] = lerp(before, candidateValue, alpha);
- }
+ for (const cell of cells) {
+ const { x, y, wi, si, alpha } = cell;
+ for (const entry of fieldEntries) {
+ const { name, source, dest, isDiscrete, idOffset } = entry;
+ if (isDiscrete) {
+ const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
+ if (alpha < threshold) continue;
+ const raw = source[si];
+ const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
+ const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
+ ? mapped
+ : idOffset && raw >= 0 ? raw + idOffset : raw;
+ if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
+ if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
+ if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
+ if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
+ dest[wi] = value;
+ } else {
+ const before = dest[wi] || 0;
+ let candidateValue = source[si] || 0;
+ if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment);
+ dest[wi] = lerp(before, candidateValue, alpha);
+ }
- if (name === "elevation") {
- updatedCells++;
- if (alpha > 0.94) terrainCellsFullyReplaced++;
- }
+ if (name === "elevation") {
+ updatedCells++;
+ if (alpha > 0.94) terrainCellsFullyReplaced++;
}
}
}
@@ -3212,7 +3224,6 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect,
const rejectedPairs = new Set();
let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8);
for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) {
- snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
const dirtyCount = snapshot.comps.filter(eligible).length;
if (dirtyCount <= 1) break;
const comps = snapshot.comps.filter(contextual);
@@ -3285,13 +3296,13 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect,
rejectedPairs.add(transportPairSignature(b, a, mode));
continue;
}
+ snapshot = checkSnapshot;
debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote;
accepted = true;
break;
}
if (!accepted) break;
}
- snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length;
return debug;
}
@@ -3438,7 +3449,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
// 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);
@@ -3447,9 +3457,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
// visible fragments that did not reduce disconnected components. Keep the
// anchor collection for diagnostics, but route all actual repair through the
// graph reconnection pass below, which rolls back failed candidates.
- const roadConn = { connectors: 0, disconnected: roadAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
- const railConn = { connectors: 0, disconnected: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
- const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 };
const graphRect = strictMask
? transportRect
: transportRect;
@@ -3459,14 +3466,14 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
roadsClipped,
railsClipped,
regeneratedPaths,
- roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors + (roadGraph.roadGraphConnectorsAdded || 0),
- railwayConnectorsCreated: railConn.connectors + (railGraph.railGraphConnectorsAdded || 0),
- disconnectedRoadComponents: roadConn.disconnected,
- disconnectedRailComponents: railConn.disconnected,
- skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
- connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
- skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
- checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
+ roadConnectorsCreated: roadGraph.roadGraphConnectorsAdded || 0,
+ railwayConnectorsCreated: railGraph.railGraphConnectorsAdded || 0,
+ disconnectedRoadComponents: roadAnchors.length,
+ disconnectedRailComponents: railAnchors.length,
+ skippedConnectorAnchors: 0,
+ connectorAttempts: (roadGraph.roadGraphCandidatesConsidered || 0) + (railGraph.railGraphCandidatesConsidered || 0),
+ skippedServedSettlements: 0,
+ checkedSettlementCoverage: 0,
externalRoadAnchors: externalRoadAnchors.length,
externalRailAnchors: externalRailAnchors.length,
...roadGraph,
@@ -4208,6 +4215,82 @@ function addInvalidatedRect(world, rect) {
if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized });
}
+function repairPatchTerrain(world, rects, seed, seaLevel) {
+ const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
+ const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel);
+ const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
+ const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
+ const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true });
+ const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
+ const maskDebug = repairDisplayMasks(world, rects, seed);
+ recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
+ return {
+ terrainSeamDebug,
+ elevationCliffDebug,
+ waterDebug,
+ waterComponentDebug,
+ residualSeaDebug,
+ waterElevationDebug,
+ maskDebug,
+ };
+}
+
+function repairPatchAdministration(world, sourceMap, rects, seed, adminIdMapping, strictFieldSnapshot) {
+ const landDebug = repairLanduseAndPopulation(world, rects);
+ const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, adminIdMapping, seed);
+ const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects);
+ const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
+ const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, adminIdMapping);
+ const municipalCoherence = reconcileMunicipalMetadata({
+ adminId: world.fields.adminId,
+ municipalityId: world.fields.municipalityId,
+ prefectureRegionId: world.fields.prefectureRegionId,
+ sea: world.fields.sea,
+ adminCenters: sourceMap.adminCenters || [],
+ municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
+ fields: world.fields,
+ width: world.width,
+ height: world.height,
+ pointOffsetX: world.originX || 0,
+ pointOffsetY: world.originY || 0,
+ seed,
+ });
+ sourceMap.adminCenters = municipalCoherence.adminCenters;
+ sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
+ const prefectureCoherence = refreshPrefectureRegionsMetadata({
+ prefectureRegionId: world.fields.prefectureRegionId,
+ sea: world.fields.sea,
+ existing: sourceMap.prefectureRegions || [],
+ adminCenters: sourceMap.adminCenters || [],
+ fields: world.fields,
+ width: world.width,
+ height: world.height,
+ pointOffsetX: world.originX || 0,
+ pointOffsetY: world.originY || 0,
+ });
+ sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
+ const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
+ sourceMap.adminDebug = {
+ ...(sourceMap.adminDebug || {}),
+ municipalCoherence: municipalCoherence.debug,
+ prefectureMetadataCoherence: prefectureCoherence.debug,
+ };
+ return {
+ landDebug,
+ finalAdminCoverageDebug,
+ adminTopologyDebug,
+ strictMaskDebugPreCoherence,
+ strictMaskDebugPostCoherence,
+ sourceAdminMetadataUpdated,
+ municipalCoherence,
+ prefectureCoherence,
+ };
+}
+
+function repairPatchTransport(world, sourceMap, candidate, rects, window, seed) {
+ return mergePathLayers(world, sourceMap, candidate, rects, window, seed);
+}
+
export function generatePatch(world, userRectInput, options = {}) {
const validation = validatePatchRect(userRectInput, world);
if (!validation.ok) return { ok: false, ...validation };
@@ -4258,85 +4341,80 @@ export function generatePatch(world, userRectInput, options = {}) {
: (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation"));
getPatchAlphaCache(rects, seed);
getPatchSourceIndexCache(rects, candidateWindow);
+ const patchContext = createPatchContext({
+ world,
+ rects,
+ candidateWindow,
+ seed,
+ patchAlpha,
+ sourceIndexForWorld,
+ worldIndex,
+ });
const sourceMap = world.sourceMap || (world.sourceMap = {});
const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed);
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30;
- const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel);
+ const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel, patchContext);
patchTimer.mark("fields", "Field copy and alpha blend");
- const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
- const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel);
- const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
- const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
- const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true });
- const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
- const maskDebug = repairDisplayMasks(world, rects, seed);
- recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
+ const terrainDebug = repairPatchTerrain(world, rects, seed, 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);
+ const pathDebug = repairPatchTransport(world, sourceMap, candidate, rects, fieldDebug.window, seed);
patchTimer.mark("paths", "Path merge and connector repair");
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
patchTimer.mark("influence", "Influence refresh");
- const landDebug = repairLanduseAndPopulation(world, rects);
- const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
- const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects);
- const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
- const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
- const municipalCoherence = reconcileMunicipalMetadata({
- adminId: world.fields.adminId,
- municipalityId: world.fields.municipalityId,
- prefectureRegionId: world.fields.prefectureRegionId,
- sea: world.fields.sea,
- adminCenters: sourceMap.adminCenters || [],
- municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
- fields: world.fields,
- width: world.width,
- height: world.height,
- pointOffsetX: world.originX || 0,
- pointOffsetY: world.originY || 0,
- seed,
- });
- sourceMap.adminCenters = municipalCoherence.adminCenters;
- sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
- const prefectureCoherence = refreshPrefectureRegionsMetadata({
- prefectureRegionId: world.fields.prefectureRegionId,
- sea: world.fields.sea,
- existing: sourceMap.prefectureRegions || [],
- adminCenters: sourceMap.adminCenters || [],
- fields: world.fields,
- width: world.width,
- height: world.height,
- pointOffsetX: world.originX || 0,
- pointOffsetY: world.originY || 0,
- });
- sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
- const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
+ const adminDebug = repairPatchAdministration(world, sourceMap, rects, seed, fieldDebug.adminIdMapping, strictFieldSnapshot);
const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true });
if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) {
repairDisplayMasks(world, rects, seed);
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
}
const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed);
- sourceMap.adminDebug = {
- ...(sourceMap.adminDebug || {}),
- municipalCoherence: municipalCoherence.debug,
- prefectureMetadataCoherence: prefectureCoherence.debug,
- };
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window);
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
patchTimer.mark("segments", "Boundary and debug segment merge");
sanitizeExistingLogistics(sourceMap);
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
const patchTimings = patchTimer.timings;
+ const {
+ terrainSeamDebug,
+ elevationCliffDebug,
+ waterDebug,
+ waterComponentDebug,
+ residualSeaDebug,
+ waterElevationDebug,
+ maskDebug,
+ } = terrainDebug;
+ const {
+ landDebug,
+ finalAdminCoverageDebug,
+ adminTopologyDebug,
+ strictMaskDebugPreCoherence,
+ strictMaskDebugPostCoherence,
+ sourceAdminMetadataUpdated,
+ municipalCoherence,
+ prefectureCoherence,
+ } = adminDebug;
const seaStats = countSea(world, rects.coreRect, rects, seed);
const label = terrainLabel(candidate, terrainType);
const id = terrainId(candidate, terrainType);
+ const patchStageDebug = {
+ candidate: { cacheHit, cacheSize, patchGenerationMode, candidateAreaRatio: candidateWindow.areaRatio ?? 1 },
+ fields: fieldDebug,
+ terrain: terrainDebug,
+ points: pointDebug,
+ transport: pathDebug,
+ influence: influenceDebug,
+ admin: adminDebug,
+ segments: { ...segmentDebug, candidateCompartmentSegmentsAdded },
+ cleanup: { logisticsLabelsMigrated, strictMetadataDebug, residualSeaStrictDebug },
+ };
const humanGeography = {
ok: true,
+ patchStages: patchStageDebug,
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,
diff --git a/mapPatch.js.bak b/mapPatch.js.bak
deleted file mode 100644
index da1dcd2..0000000
--- a/mapPatch.js.bak
+++ /dev/null
@@ -1,2660 +0,0 @@
-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/mapPatchContext.js b/mapPatchContext.js
new file mode 100644
index 0000000..cddb1c8
--- /dev/null
+++ b/mapPatchContext.js
@@ -0,0 +1,24 @@
+export function createPatchContext({ world, rects, candidateWindow, seed, patchAlpha, sourceIndexForWorld, worldIndex }) {
+ const writeRect = rects?.writeRect;
+ const writeCells = [];
+ if (world && writeRect && typeof patchAlpha === "function" && typeof sourceIndexForWorld === "function" && typeof worldIndex === "function") {
+ for (let y = writeRect.y0; y < writeRect.y1; y++) {
+ for (let x = writeRect.x0; x < writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi < 0) continue;
+ const si = sourceIndexForWorld(rects, candidateWindow, x, y);
+ if (si < 0) continue;
+ const alpha = patchAlpha(x, y, rects, seed);
+ if (alpha <= 0.005) continue;
+ writeCells.push({ x, y, wi, si, alpha });
+ }
+ }
+ }
+ return {
+ world,
+ rects,
+ candidateWindow,
+ seed,
+ writeCells,
+ };
+}
diff --git a/renderer.js b/renderer.js
index 8433891..e9b9d65 100644
--- a/renderer.js
+++ b/renderer.js
@@ -2,11 +2,74 @@ import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
+const segmentVectorStableCache = new Map();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
const rasterBorderCache = new WeakMap();
-const baseImageCache = new WeakMap();
-const MAX_BASE_CACHE_IMAGES = 4;
+const baseImageCache = new Map();
+const urbanOverlayCache = new Map();
+const prefectureFillCache = new Map();
+const MAX_BASE_CACHE_IMAGES = 18;
+const MAX_OVERLAY_CACHE_IMAGES = 18;
+const MAX_SEGMENT_VECTOR_CACHE = 48;
+const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"];
+
+function nowMs() {
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+}
+
+function fieldRefSignature(map) {
+ const refs = [
+ map?.elevation, map?.slope, map?.sea, map?.prefectureMask, map?.humanRegionMask,
+ map?.landuse, map?.populationDensity, map?.prefectureRegionId, map?.adminId,
+ ];
+ return refs.map((field) => ArrayBuffer.isView(field) ? `${field.constructor.name}:${field.length}` : "-").join("|");
+}
+
+function generatedRectSignature(map) {
+ const rects = Array.isArray(map?.generatedRects) ? map.generatedRects : [];
+ const last = rects[rects.length - 1] || null;
+ if (!last) return "0";
+ return `${rects.length}:${last.x0 || 0},${last.y0 || 0},${last.x1 || 0},${last.y1 || 0}:${last.terrainType || ""}:${last.variant || ""}`;
+}
+
+function stableViewportCachePrefix(map) {
+ const camera = map?.worldCamera || {};
+ const origin = map?.worldOrigin || {};
+ return [
+ map?.baseSeed ?? map?.seed ?? 0,
+ map?.effectiveSeed ?? "",
+ map?.generationContext?.variant ?? "",
+ mapWidth(map),
+ mapHeight(map),
+ Math.round(camera.x || 0),
+ Math.round(camera.y || 0),
+ Math.round(origin.x || 0),
+ Math.round(origin.y || 0),
+ generatedRectSignature(map),
+ fieldRefSignature(map),
+ ].join(":");
+}
+
+function cappedSet(cache, key, value, maxSize) {
+ cache.set(key, value);
+ while (cache.size > maxSize) cache.delete(cache.keys().next().value);
+ return value;
+}
+
+function segmentContentSignature(segments) {
+ const count = segments?.length || 0;
+ if (!count) return "0";
+ const step = Math.max(1, Math.floor(count / 24));
+ const parts = [String(count)];
+ for (let i = 0; i < count; i += step) {
+ const seg = segments[i];
+ parts.push(`${seg?.[0]?.[0] || 0},${seg?.[0]?.[1] || 0},${seg?.[1]?.[0] || 0},${seg?.[1]?.[1] || 0}`);
+ }
+ const last = segments[count - 1];
+ parts.push(`${last?.[0]?.[0] || 0},${last?.[0]?.[1] || 0},${last?.[1]?.[0] || 0},${last?.[1]?.[1] || 0}`);
+ return parts.join("|");
+}
function mapWidth(map) {
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
@@ -189,6 +252,12 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {})
segmentVectorCache.set(segments, cachedByOption);
}
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
+ const stableKey = `${cacheKey}:${segmentContentSignature(segments)}`;
+ const stableCached = segmentVectorStableCache.get(stableKey);
+ if (stableCached) {
+ cachedByOption.set(cacheKey, stableCached);
+ return stableCached;
+ }
const polylines = segmentChains(segments).map((line) => {
const cleaned = removeCollinear(line);
@@ -199,6 +268,7 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {})
}).filter((line) => line.length >= 2);
cachedByOption.set(cacheKey, polylines);
+ cappedSet(segmentVectorStableCache, stableKey, polylines, MAX_SEGMENT_VECTOR_CACHE);
return polylines;
}
@@ -527,35 +597,29 @@ function discreteColor(map, x, y, mode) {
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
-function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
- const continuousModes = ["terrain", "development", "all"];
- const scaleKey = Math.round((renderScale || 1) * 20) / 20;
- if (continuousTerrain && continuousModes.includes(mode)) {
- return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
+function baseCacheKey(map, mode, continuousTerrain, renderScale = 1) {
+ const prefix = stableViewportCachePrefix(map);
+ if (continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode)) {
+ const scaleKey = Math.round((renderScale || 1) * 20) / 20;
+ return `${prefix}:continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
}
- return `discrete:${mode}:${scaleKey}`;
+ return `${prefix}:discrete:${mode}:1`;
}
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
- let cache = baseImageCache.get(map);
- if (!cache) {
- cache = new Map();
- baseImageCache.set(map, cache);
- }
-
- const key = baseCacheKey(mode, continuousTerrain, renderScale);
- let canvas = cache.get(key);
+ const key = baseCacheKey(map, mode, continuousTerrain, renderScale);
+ let canvas = baseImageCache.get(key);
if (canvas) return canvas;
const sourceWidth = mapPixelWidth(map);
const sourceHeight = mapPixelHeight(map);
- const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
+ const continuous = continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode);
+ const targetScale = continuous ? Math.max(0.35, Math.min(1, renderScale || 1)) : 1;
const width = Math.max(1, Math.round(sourceWidth * targetScale));
const height = Math.max(1, Math.round(sourceHeight * targetScale));
const img = ctx.createImageData(width, height);
- const continuousModes = ["terrain", "development", "all"];
- if (continuousTerrain && continuousModes.includes(mode)) {
+ if (continuous) {
for (let py = 0; py < height; py++) {
const fy = (py / Math.max(1, height)) * mapHeight(map);
for (let px = 0; px < width; px++) {
@@ -594,9 +658,7 @@ function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1)
canvas.height = height;
const bctx = canvas.getContext("2d");
bctx.putImageData(img, 0, 0);
- cache.set(key, canvas);
- if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
- return canvas;
+ return cappedSet(baseImageCache, key, canvas, MAX_BASE_CACHE_IMAGES);
}
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
@@ -842,6 +904,13 @@ function drawUrbanAreas(ctx, map, mode) {
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
if (!visibleModes.includes(mode)) return;
+ const key = `${stableViewportCachePrefix(map)}:urban:${mode}`;
+ let canvas = urbanOverlayCache.get(key);
+ if (canvas) {
+ ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
+ return;
+ }
+
const detailedColors = {
2: "rgba(223, 214, 206, 0.72)",
3: "rgba(215, 175, 172, 0.88)",
@@ -854,7 +923,11 @@ function drawUrbanAreas(ctx, map, mode) {
const cityColor = "rgba(232, 222, 228, 0.60)";
const cbdColor = "rgba(215, 175, 172, 0.84)";
- ctx.save();
+ canvas = document.createElement("canvas");
+ canvas.width = mapPixelWidth(map);
+ canvas.height = mapPixelHeight(map);
+ const bctx = canvas.getContext("2d");
+ if (!bctx) return;
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
@@ -871,11 +944,12 @@ function drawUrbanAreas(ctx, map, mode) {
const px = x * CELL_SIZE;
const py = y * CELL_SIZE;
- ctx.fillStyle = fill;
- ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
+ bctx.fillStyle = fill;
+ bctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
}
}
- ctx.restore();
+ cappedSet(urbanOverlayCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
+ ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
}
function drawDebugCells(ctx, map, field, color) {
@@ -940,9 +1014,19 @@ function drawPrefectureRegionFill(ctx, map, mode) {
if (!["all", "admin", "borders-debug"].includes(mode)) return;
const ids = map.prefectureRegionId;
if (!ids) return;
+ const key = `${stableViewportCachePrefix(map)}:prefecture-fill:${mode}`;
+ let canvas = prefectureFillCache.get(key);
+ if (canvas) {
+ ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
+ return;
+ }
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
- ctx.save();
- ctx.globalAlpha = alpha;
+ canvas = document.createElement("canvas");
+ canvas.width = mapPixelWidth(map);
+ canvas.height = mapPixelHeight(map);
+ const bctx = canvas.getContext("2d");
+ if (!bctx) return;
+ bctx.globalAlpha = alpha;
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
@@ -951,11 +1035,12 @@ function drawPrefectureRegionFill(ctx, map, mode) {
const id = ids[i];
if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id);
- ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
- ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
+ bctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
+ bctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
- ctx.restore();
+ cappedSet(prefectureFillCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
+ ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
}
function dot(ctx, p, radius, fill, stroke = "white") {
@@ -1090,6 +1175,14 @@ export function drawMap(canvas, map, options) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
+ const timings = {};
+ let timingMark = nowMs();
+ const markTiming = (key) => {
+ const t = nowMs();
+ timings[key] = Math.round((t - timingMark) * 10) / 10;
+ timingMark = t;
+ };
+
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
@@ -1102,7 +1195,8 @@ export function drawMap(canvas, map, options) {
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
const cellScreenSize = CELL_SIZE * drawScale;
- const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
+ const terrainQualityScale = options.fastTerrain ? 0.48 : 0.62;
+ const terrainRenderScale = continuousTerrain ? clamp(drawScale * terrainQualityScale, 0.30, options.fastTerrain ? 0.55 : 0.78) : 1;
if (canvas.width !== outputWidth) canvas.width = outputWidth;
if (canvas.height !== outputHeight) canvas.height = outputHeight;
@@ -1117,14 +1211,19 @@ export function drawMap(canvas, map, options) {
drawScaleBar(ctx, cellScreenSize);
delete ctx.__mapPixelWidth;
delete ctx.__mapPixelHeight;
+ timings.scale = Math.round((nowMs() - timingMark) * 10) / 10;
+ return timings;
};
// 1. Base Terrain & Urban
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
+ markTiming("baseTerrain");
drawUrbanAreas(ctx, map, mode);
+ markTiming("urbanFill");
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
+ markTiming("coastline");
// 2. Rivers
const waterBlue = "rgba(116, 165, 202, 0.92)";
@@ -1175,6 +1274,7 @@ export function drawMap(canvas, map, options) {
return 1.55 * downstreamBoost;
}, 1.0);
}
+ markTiming("rivers");
const showHistory = mode === "history";
const showTransportDebug = mode === "transport-debug";
@@ -1214,10 +1314,10 @@ export function drawMap(canvas, map, options) {
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
+ markTiming("adminBorders");
if (!showFeatures) {
- finish();
- return;
+ return finish();
}
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
@@ -1267,6 +1367,7 @@ export function drawMap(canvas, map, options) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
}
+ markTiming("transport");
// 6. Icons & Labels
if (["admin", "borders-debug"].includes(mode)) {
@@ -1307,6 +1408,7 @@ export function drawMap(canvas, map, options) {
}
}
}
+ markTiming("icons");
if (showLabels) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
@@ -1315,13 +1417,13 @@ export function drawMap(canvas, map, options) {
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
- finish();
- return;
+ markTiming("labels");
+ return finish();
}
if (mode === "borders-debug") {
drawLabels(ctx, prefectureLabels, Infinity);
- finish();
- return;
+ markTiming("labels");
+ return finish();
}
const important = [
...prefectureLabels,
@@ -1332,5 +1434,6 @@ export function drawMap(canvas, map, options) {
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
}
- finish();
+ markTiming("labels");
+ return finish();
}