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, }; }