import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, pickEntities, smoothstep, valueNoise } from "./mapUtils.js"; import { generateMap } from "./mapPipeline.js"; import { generateTerrainRect } from "./mapTerrain.js"; import { LANDUSE } from "./landuseCodes.js"; import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js"; import { createPatchContext } from "./mapPatchContext.js"; export const PATCH_MIN_WIDTH = 48; export const PATCH_MIN_HEIGHT = 48; export const PATCH_MIN_AREA = 3000; const POINT_LAYER_KEYS = [ "villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins", "ports", "crossings", "passes", "modernCities", "satelliteCities", "stations", "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters", "externalGateways", "prefectureRegions", ]; const PATH_LAYER_KEYS = [ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways", "icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths", ]; const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]); const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]); const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]); const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"]; const PATCH_CANDIDATE_CACHE_LIMIT = 3; const ID_FIELD_OFFSETS = new Map([ ["adminId", 100000], ["municipalityId", 100000], ["prefectureRegionId", 200000], ["regionId", 300000], ["naturalCompartmentId", 400000], ["watershedId", 500000], ]); const DISCRETE_FIELD_NAMES = new Set([ "sea", "ocean", "lake", "prefectureMask", "landMask", "landuse", "adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId", ]); const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]); const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]); const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]); const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]); const STRICT_RESTORE_FIELD_EXEMPTIONS = new Set([ // Transport repair is allowed to operate over a wider neighborhood than the // lasso itself. Keep its derived influence fields in sync with repaired // paths instead of restoring them to the pre-patch values outside the lasso. "roadInfluence", "railInfluence2", "stationInfluence", ]); function shouldStrictRestoreField(name) { return !STRICT_RESTORE_FIELD_EXEMPTIONS.has(name); } function worldIndex(world, x, y) { if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1; return y * world.width + x; } function sourceIndex(x, y) { if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1; return y * MAP_W + x; } function isCellField(value) { return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE; } function rectWidth(rect) { return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0)); } function rectHeight(rect) { return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0)); } function rectArea(rect) { return rectWidth(rect) * rectHeight(rect); } function nowMs() { return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); } function createPatchTimer() { const timings = []; let mark = nowMs(); return { timings, mark(key, label = key) { const t = nowMs(); timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 }); mark = t; }, }; } function normalizeRect(rect) { if (!rect) return null; const x0 = Math.floor(Math.min(rect.x0, rect.x1)); const y0 = Math.floor(Math.min(rect.y0, rect.y1)); const x1 = Math.ceil(Math.max(rect.x0, rect.x1)); const y1 = Math.ceil(Math.max(rect.y0, rect.y1)); return { x0, y0, x1, y1 }; } function isPolygonSelection(input) { return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3; } function clampPointToWorld(point, world) { return { x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)), y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)), }; } function polygonBounds(polygon) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of polygon || []) { if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue; minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } if (!Number.isFinite(minX)) return null; return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) }; } function polygonAreaCells(polygon) { if (!polygon || polygon.length < 3) return 0; let area = 0; for (let i = 0; i < polygon.length; i++) { const a = polygon[i]; const b = polygon[(i + 1) % polygon.length]; area += a.x * b.y - b.x * a.y; } return Math.abs(area) * 0.5; } function normalizeSelectionShape(input, world = null) { if (!isPolygonSelection(input)) return normalizeRect(input); const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) }); const bounds = polygonBounds(polygon); if (!bounds) return null; return { kind: input.kind || 'lasso', polygon, areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))), x0: bounds.x0, y0: bounds.y0, x1: bounds.x1, y1: bounds.y1, }; } function pointInPolygon(px, py, polygon) { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x + 0.5; const yi = polygon[i].y + 0.5; const xj = polygon[j].x + 0.5; const yj = polygon[j].y + 0.5; const denomRaw = yj - yi; const denom = Math.abs(denomRaw) < 1e-6 ? (denomRaw < 0 ? -1e-6 : 1e-6) : denomRaw; const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / denom + xi); if (intersect) inside = !inside; } return inside; } function pointSegmentDistance(px, py, ax, ay, bx, by) { const dx = bx - ax; const dy = by - ay; const len2 = dx * dx + dy * dy; if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay); const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); } function distanceToPolygonEdge(px, py, polygon) { let best = Infinity; for (let i = 0; i < polygon.length; i++) { const a = polygon[i]; const b = polygon[(i + 1) % polygon.length]; best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5)); } return best; } function insideRect(x, y, rect) { return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1; } function expandRect(rect, margin, world = null) { return { x0: Math.max(0, rect.x0 - margin), y0: Math.max(0, rect.y0 - margin), x1: Math.min(world?.width ?? Infinity, rect.x1 + margin), y1: Math.min(world?.height ?? Infinity, rect.y1 + margin), }; } function distanceToRectEdge(x, y, rect) { return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y); } function defaultForField(name, Constructor) { if (name === "sea" || name === "ocean") return 1; if (name === "elevation") return 0.08; if (ID_FIELD_OFFSETS.has(name)) return -1; if (Constructor === Float32Array || Constructor === Float64Array) return 0; return 0; } function ensureWorldField(world, name, source) { if (!source || !ArrayBuffer.isView(source)) return null; const Constructor = source.constructor; const expected = world.width * world.height; if (!world.fields[name] || world.fields[name].length !== expected) { world.fields[name] = new Constructor(expected); const fallback = defaultForField(name, Constructor); if (fallback !== 0) world.fields[name].fill(fallback); } return world.fields[name]; } function isWorldCellField(world, value) { return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === (world?.width || 0) * (world?.height || 0); } function captureStrictSelectionFieldSnapshot(world, rects, seed = 0) { if (!rects?.strictSelectionMask || !world?.fields || !rects.writeRect) return null; // Snapshot the entire field-repair neighborhood. Several post-processors // intentionally work on repairRect to keep seams smooth; for lasso patches, // cells outside the polygon must still be put back after those repairs. const rect = rects.repairRect || rects.writeRect; const width = rectWidth(rect); const height = rectHeight(rect); const fields = new Map(); for (const [name, field] of Object.entries(world.fields)) { if (!shouldStrictRestoreField(name) || !isWorldCellField(world, field)) continue; const data = new field.constructor(width * height); for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const wi = worldIndex(world, x, y); if (wi >= 0) data[(y - rect.y0) * width + (x - rect.x0)] = field[wi]; } } fields.set(name, data); } return { rect: { ...rect }, width, height, fields, seed }; } function restoreOutsideStrictSelectionFields(world, rects, snapshot, seed = 0) { if (!snapshot || !rects?.strictSelectionMask) return { strictMaskCellsRestored: 0, strictMaskValuesRestored: 0 }; const rect = snapshot.rect; let strictMaskCellsRestored = 0; let strictMaskValuesRestored = 0; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { if (patchAlpha(x, y, rects, seed) > 0.005) continue; let cellChanged = false; const li = (y - rect.y0) * snapshot.width + (x - rect.x0); const wi = worldIndex(world, x, y); if (wi < 0) continue; for (const [name, oldData] of snapshot.fields) { const field = world.fields?.[name]; if (!isWorldCellField(world, field)) continue; const oldValue = oldData[li]; if (field[wi] !== oldValue) { field[wi] = oldValue; strictMaskValuesRestored++; cellChanged = true; } } if (cellChanged) strictMaskCellsRestored++; } } return { strictMaskCellsRestored, strictMaskValuesRestored }; } export function clipPatchRect(rect, world) { const normalized = normalizeSelectionShape(rect, world); if (!normalized || !world) return null; if (isPolygonSelection(normalized)) return normalized; return { x0: Math.min(Math.max(normalized.x0, 0), world.width), y0: Math.min(Math.max(normalized.y0, 0), world.height), x1: Math.min(Math.max(normalized.x1, 0), world.width), y1: Math.min(Math.max(normalized.y1, 0), world.height), }; } export function validatePatchRect(rect, world) { const clipped = clipPatchRect(rect, world); if (!clipped) return { ok: false, rect: null, reason: "No selected area." }; const width = rectWidth(clipped); const height = rectHeight(clipped); const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height; if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) { const parts = []; if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`); if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`); return { ok: false, rect: clipped, width, height, area, reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`, }; } if (area < PATCH_MIN_AREA) { return { ok: false, rect: clipped, width, height, area, reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`, }; } return { ok: true, rect: clipped, width, height, area, reason: "" }; } export function buildPatchRects(userRect, world = null) { const coreRect = normalizeSelectionShape(userRect, world); const width = rectWidth(coreRect); const height = rectHeight(coreRect); const shortSide = Math.max(1, Math.min(width, height)); const polygonSelection = isPolygonSelection(coreRect); const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42))); const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2))); const rawWriteMargin = Math.max(0, Math.min(desiredWrite, maxBySource)); const desiredRepair = Math.min(120, rawWriteMargin + Math.max(8, Math.floor(shortSide * 0.12))); const repairMargin = Math.max(rawWriteMargin, Math.min(desiredRepair, maxBySource)); // A lasso/freeform selection is now treated as a strict write mask. Earlier // versions expanded the lasso's bounding box and let alpha feather outside the // drawn polygon; that made terrain/features appear beyond the user's blue // selection outline. We still keep repair/transport context outside the mask, // but only cells inside the lasso can receive generated field/feature data. const writeMargin = polygonSelection ? Math.max(4, Math.min(rawWriteMargin, Math.floor(shortSide * 0.20))) : rawWriteMargin; const writeRect = polygonSelection ? { x0: coreRect.x0, y0: coreRect.y0, x1: coreRect.x1, y1: coreRect.y1 } : expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); // Transport graph repair needs substantially more regional context than field // generation. Terrain/admin/water still obey writeRect/strict masks, but // severed roads and rails often need to reconnect to the next real trunk line // outside the edited patch. Keep this capped so very large worlds do not make // every patch repair global. const longSide = Math.max(width, height); const diagonal = Math.hypot(width, height); const transportReachMargin = Math.max( repairMargin + 72, Math.min(420, Math.max(160, repairMargin + Math.floor(diagonal * 0.45), Math.floor(longSide * 0.68))) ); const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { coreRect, writeRect, repairRect, contextRect: repairRect, transportReachRect, blendRect: coreRect, userRect: writeRect, selectedRect: coreRect, selectionShape: polygonSelection ? coreRect : null, strictSelectionMask: polygonSelection, writeMargin, repairMargin, transportReachMargin, outerMargin: writeMargin, innerMargin: 0, }; } function computePatchAlpha(x, y, rects, seed = 0) { const writeRect = rects.writeRect || rects.userRect; if (!insideRect(x, y, writeRect)) return 0; const margin = Math.max(1, rects.writeMargin || 1); const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5; const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5; const shape = rects.selectionShape; if (shape?.polygon?.length >= 3) { const px = x + 0.5; const py = y + 0.5; const inside = pointInPolygon(px, py, shape.polygon); if (!inside) return 0; // Strict lasso semantics: never write outside the user's polygon. The seam // feather is inward-only. Earlier builds started the lasso edge at ~0.64, // which made a hard terrain switch visible immediately inside the blue line. // Start at zero and let the candidate terrain take over only after an // interior transition band. const dist = distanceToPolygonEdge(px, py, shape.polygon); const feather = Math.max(6, Math.min(margin, 24)); const noisyInsideDist = dist + low * Math.min(2.2, feather * 0.12) + mid * Math.min(1.1, feather * 0.06); const t = clamp((noisyInsideDist - 0.35) / Math.max(1e-6, feather)); const edge = smoothstep(t); return clamp(edge); } const edge = distanceToRectEdge(x, y, writeRect); const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; const base = smoothstep(clamp(noisyEdge / margin)); // Keep the expanded repair band as the actual seam. The user's selected core // is still dominant, but the write edge is irregular, so coastlines and land-use // no longer inherit the rectangular user selection as a hard boundary. return clamp(base); } function getPatchAlphaCache(rects, seed = 0) { const writeRect = rects?.writeRect || rects?.userRect; if (!writeRect) return null; const width = rectWidth(writeRect); const height = rectHeight(writeRect); const existing = rects.patchAlphaCache; if ( existing && existing.seed === seed && existing.width === width && existing.height === height && existing.x0 === writeRect.x0 && existing.y0 === writeRect.y0 ) return existing; const data = new Float32Array(width * height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed); } } rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data }; return rects.patchAlphaCache; } function patchAlpha(x, y, rects, seed = 0) { const writeRect = rects?.writeRect || rects?.userRect; if (!writeRect || !insideRect(x, y, writeRect)) return 0; const cache = rects.patchAlphaCache; if ( cache && cache.seed === seed && x >= cache.x0 && y >= cache.y0 && x < cache.x0 + cache.width && y < cache.y0 + cache.height ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0; return computePatchAlpha(x, y, rects, seed); } function patchBand(x, y, rects, seed = 0) { const a = patchAlpha(x, y, rects, seed); if (a <= 0.18) return "preserve"; if (a >= 0.82) return "core"; return "feather"; } function continuityReplaceThreshold(name, x, y, rects, seed = 0) { const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5; if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92); if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82); return clamp(0.46 + n * 0.20, 0.28, 0.68); } function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) { if (!rects) return true; return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha; } function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) { return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha; } function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) { if (!Array.isArray(seg) || seg.length < 2) return false; const ax = tupleWorldX(world, seg[0]); const ay = tupleWorldY(world, seg[0]); const bx = tupleWorldX(world, seg[1]); const by = tupleWorldY(world, seg[1]); const mx = (ax + bx) * 0.5; const my = (ay + by) * 0.5; return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha); } function segmentTouchesRect(world, seg, rect) { if (!Array.isArray(seg) || seg.length < 2 || !rect) return false; const ax = tupleWorldX(world, seg[0]); const ay = tupleWorldY(world, seg[0]); const bx = tupleWorldX(world, seg[1]); const by = tupleWorldY(world, seg[1]); const mx = (ax + bx) * 0.5; const my = (ay + by) * 0.5; if (insideRect(ax, ay, rect) || insideRect(bx, by, rect) || insideRect(mx, my, rect)) return true; const minX = Math.min(ax, bx); const maxX = Math.max(ax, bx); const minY = Math.min(ay, by); const maxY = Math.max(ay, by); return maxX >= rect.x0 && minX < rect.x1 && maxY >= rect.y0 && minY < rect.y1; } function quantizedSegmentKey(seg) { if (!Array.isArray(seg) || seg.length < 2) return ""; const p = seg.map(([x, y]) => [Math.round(x * 4) / 4, Math.round(y * 4) / 4]); const a = `${p[0][0]},${p[0][1]}`; const b = `${p[1][0]},${p[1][1]}`; return a < b ? `${a}|${b}` : `${b}|${a}`; } function dedupeSegments(segments) { const seen = new Set(); const out = []; for (const seg of segments || []) { const key = quantizedSegmentKey(seg); if (!key || seen.has(key)) continue; seen.add(key); out.push(seg); } return out; } function segmentMidpoint(seg) { return { x: ((seg?.[0]?.[0] || 0) + (seg?.[1]?.[0] || 0)) * 0.5, y: ((seg?.[0]?.[1] || 0) + (seg?.[1]?.[1] || 0)) * 0.5, }; } function segmentOrientation(seg) { const dx = (seg?.[1]?.[0] || 0) - (seg?.[0]?.[0] || 0); const dy = (seg?.[1]?.[1] || 0) - (seg?.[0]?.[1] || 0); return Math.atan2(dy, dx); } function angleDistance(a, b) { let d = Math.abs(a - b) % Math.PI; if (d > Math.PI / 2) d = Math.PI - d; return d; } function pointToSegmentDistance2(px, py, ax, ay, bx, by) { const dx = bx - ax; const dy = by - ay; const len2 = dx * dx + dy * dy; if (len2 <= 1e-9) 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 segmentNear(a, b, tolerance = 0.60) { if (!a || !b) return false; const am = segmentMidpoint(a); const bm = segmentMidpoint(b); const orientationClose = angleDistance(segmentOrientation(a), segmentOrientation(b)) < 0.68; if (!orientationClose) return false; const ax = a?.[0]?.[0] || 0, ay = a?.[0]?.[1] || 0; const bx = a?.[1]?.[0] || 0, by = a?.[1]?.[1] || 0; const cx = b?.[0]?.[0] || 0, cy = b?.[0]?.[1] || 0; const dx = b?.[1]?.[0] || 0, dy = b?.[1]?.[1] || 0; if (Math.hypot(am.x - bm.x, am.y - bm.y) <= tolerance) return true; // Adjacent raster/vector borders often have slightly different segment lengths, // so midpoint-only filtering misses them and both municipal/prefecture casings // are drawn. Test endpoints against the other segment as well. const best = Math.min( pointToSegmentDistance2(ax, ay, cx, cy, dx, dy), pointToSegmentDistance2(bx, by, cx, cy, dx, dy), pointToSegmentDistance2(cx, cy, ax, ay, bx, by), pointToSegmentDistance2(dx, dy, ax, ay, bx, by) ); return best <= tolerance; } function filterSupplementalSegments(primary, supplemental, tolerance = 0.60) { if (!supplemental?.length) return []; if (!primary?.length) return supplemental || []; const grid = new Map(); const cell = (v) => Math.floor(v / Math.max(0.1, tolerance)); for (const seg of primary) { const m = segmentMidpoint(seg); const key = `${cell(m.x)},${cell(m.y)}`; const bucket = grid.get(key) || []; bucket.push(seg); grid.set(key, bucket); } const out = []; for (const seg of supplemental || []) { const m = segmentMidpoint(seg); let near = false; const gx = cell(m.x), gy = cell(m.y); for (let yy = gy - 2; yy <= gy + 2 && !near; yy++) { for (let xx = gx - 2; xx <= gx + 2 && !near; xx++) { for (const other of grid.get(`${xx},${yy}`) || []) { if (segmentNear(seg, other, tolerance)) { near = true; break; } } } } if (!near) out.push(seg); } return out; } function removeSegmentsNearSegments(segments, blockers, tolerance = 0.68) { if (!segments?.length || !blockers?.length) return segments || []; return filterSupplementalSegments(blockers, segments, tolerance); } function sourceWindowForRects(rects) { if (rects?.candidateWindow) return rects.candidateWindow; 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, originX: Math.round(cx - (MAP_W - 1) / 2), originY: Math.round(cy - (MAP_H - 1) / 2), width: MAP_W, height: MAP_H, variable: false, }; } function sourceWindowIndex(window, sx, sy) { const width = Math.max(1, Math.floor(window?.width || MAP_W)); const height = Math.max(1, Math.floor(window?.height || MAP_H)); if (sx < 0 || sy < 0 || sx >= width || sy >= height) return -1; return sy * width + sx; } function sourceCoordForWorld(window, x, y) { return { x: Math.round(x - window.worldCenterX + window.sourceCenterX), y: Math.round(y - window.worldCenterY + window.sourceCenterY), }; } function candidateCellSize(candidate, window = null) { const width = Math.max(1, Math.floor(candidate?.width || window?.width || MAP_W)); const height = Math.max(1, Math.floor(candidate?.height || window?.height || MAP_H)); return { width, height, size: width * height }; } function isCandidateCellField(candidate, value, window = null) { if (!ArrayBuffer.isView(value) || typeof value.length !== "number") return false; const { size } = candidateCellSize(candidate, window); return value.length === size || value.length === SIZE; } function buildPatchCandidateWindow(rects, world, options = {}) { const forceLegacy = options.variableCandidate === false || options.legacyCandidate === true; const base = sourceWindowForRects({ ...rects, candidateWindow: null }); const write = rects.writeRect; if (forceLegacy || !write) return base; const writeW = rectWidth(write); const writeH = rectHeight(write); const coreW = rectWidth(rects.coreRect); const coreH = rectHeight(rects.coreRect); const margin = Math.max(18, Math.min(48, Math.floor(Math.min(coreW, coreH) * 0.22), rects.writeMargin || 24)); let bounds = expandRect(write, margin, world); let width = rectWidth(bounds); let height = rectHeight(bounds); const minW = Math.min(MAP_W, Math.max(96, writeW + 16)); const minH = Math.min(MAP_H, Math.max(96, writeH + 16)); const cx = (write.x0 + write.x1 - 1) / 2; const cy = (write.y0 + write.y1 - 1) / 2; const growTo = (targetW, targetH) => { const x0 = Math.floor(cx - targetW / 2); const y0 = Math.floor(cy - targetH / 2); return { x0, y0, x1: x0 + targetW, y1: y0 + targetH }; }; width = Math.max(width, minW); height = Math.max(height, minH); width = Math.min(MAP_W, Math.max(1, width)); height = Math.min(MAP_H, Math.max(1, height)); bounds = growTo(width, height); // Keep the candidate anchored to the world when possible, but allow negative // origins near the world edge. World-native terrain noise remains stable for // negative coordinates and the copied writeRect still maps inside the window. const originX = Math.floor(bounds.x0); const originY = Math.floor(bounds.y0); const areaRatio = (width * height) / SIZE; const useVariable = areaRatio < 0.82; if (!useVariable) return base; return { worldCenterX: originX + (width - 1) / 2, worldCenterY: originY + (height - 1) / 2, sourceCenterX: (width - 1) / 2, sourceCenterY: (height - 1) / 2, originX, originY, width, height, variable: true, areaRatio: Math.round(areaRatio * 1000) / 1000, }; } 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] = sourceWindowIndex(window, 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 sourceWindowIndex(window, s.x, s.y); } function solveLinear3(a00, a01, a02, a11, a12, a22, b0, b1, b2) { const m = [ [a00, a01, a02, b0], [a01, a11, a12, b1], [a02, a12, a22, b2], ]; for (let col = 0; col < 3; col++) { let pivot = col; for (let row = col + 1; row < 3; row++) if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) pivot = row; if (Math.abs(m[pivot][col]) < 1e-8) return null; if (pivot !== col) [m[col], m[pivot]] = [m[pivot], m[col]]; const div = m[col][col]; for (let k = col; k < 4; k++) m[col][k] /= div; for (let row = 0; row < 3; row++) { if (row === col) continue; const f = m[row][col]; for (let k = col; k < 4; k++) m[row][k] -= f * m[col][k]; } } return [m[0][3], m[1][3], m[2][3]]; } function computeElevationCandidateAdjustment(world, candidate, rects, window, seed = 0) { const oldElevation = world?.fields?.elevation; const candidateElevation = candidate?.elevation; if (!oldElevation || !candidateElevation || !rects?.writeRect || !window) return null; const rect = rects.writeRect; const cx = (rect.x0 + rect.x1 - 1) / 2; const cy = (rect.y0 + rect.y1 - 1) / 2; const scale = Math.max(1, Math.max(rectWidth(rect), rectHeight(rect)) / 2); let n = 0; let sX = 0, sY = 0, sXX = 0, sXY = 0, sYY = 0; let sZ = 0, sXZ = 0, sYZ = 0; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const a = patchAlpha(x, y, rects, seed); if (a <= 0.01 || a > 0.42) continue; const wi = worldIndex(world, x, y); const si = sourceIndexForWorld(rects, window, x, y); if (wi < 0 || si < 0) continue; const oldSea = world.fields?.sea?.[wi]; const newSea = candidate.sea?.[si]; if (oldSea || newSea) continue; const oldValue = oldElevation[wi]; const newValue = candidateElevation[si]; if (!Number.isFinite(oldValue) || !Number.isFinite(newValue)) continue; const lx = (x - cx) / scale; const ly = (y - cy) / scale; const z = oldValue - newValue; n++; sX += lx; sY += ly; sXX += lx * lx; sXY += lx * ly; sYY += ly * ly; sZ += z; sXZ += lx * z; sYZ += ly * z; } } if (n < 24) return { offset: 0, tiltX: 0, tiltY: 0, cx, cy, scale, samples: n }; const solved = solveLinear3(n, sX, sY, sXX, sXY, sYY, sZ, sXZ, sYZ); if (!solved) return { offset: clamp(sZ / n, -0.18, 0.18), tiltX: 0, tiltY: 0, cx, cy, scale, samples: n }; return { offset: clamp(solved[0], -0.22, 0.22), tiltX: clamp(solved[1], -0.16, 0.16), tiltY: clamp(solved[2], -0.16, 0.16), cx, cy, scale, samples: n, }; } function adjustedCandidateElevation(value, x, y, adjustment) { if (!adjustment || !Number.isFinite(value)) return value; const lx = (x - adjustment.cx) / Math.max(1, adjustment.scale || 1); const ly = (y - adjustment.cy) / Math.max(1, adjustment.scale || 1); return clamp(value + adjustment.offset + adjustment.tiltX * lx + adjustment.tiltY * ly, 0, 1); } function worldCoordForSource(window, sx, sy) { return { x: Math.round(sx - window.sourceCenterX + window.worldCenterX), y: Math.round(sy - window.sourceCenterY + window.worldCenterY), }; } function fieldIdOffset(name, seed) { const base = ID_FIELD_OFFSETS.get(name) || 0; if (!base) return 0; return base + ((seed >>> 0) % 997) * 10000; } function maxFieldId(field) { if (!field) return -1; let max = -1; for (let i = 0; i < field.length; i++) { const id = field[i]; if (Number.isFinite(id) && id > max) max = id; } return max; } function addMappingVote(votes, from, to, weight = 1) { if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return; const key = Math.floor(from); const target = Math.floor(to); const bucket = votes.get(key) || new Map(); bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight)); votes.set(key, bucket); } function chooseVotedTarget(bucket, minVotes = 1) { let best = -1; let bestVotes = 0; for (const [target, count] of bucket || []) { if (count > bestVotes || (count === bestVotes && target < best)) { best = target; bestVotes = count; } } return best >= 0 && bestVotes >= minVotes ? best : -1; } function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) { const ids = new Set(); if (!candidateField) return ids; const rect = rects.writeRect; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { if (patchAlpha(x, y, rects, seed) < minAlpha) continue; const si = sourceIndexForWorld(rects, window, x, y); if (si < 0) continue; const id = candidateField[si]; if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id)); } } return ids; } function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) { const out = new Map(); const direct = candidateMap?.municipalityToPrefectureId; for (const id of candidateAdminIds || []) { const pref = direct?.[id]; if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref)); } const adminField = candidateMap?.adminId || candidateMap?.municipalityId; const prefField = candidateMap?.prefectureRegionId; if (!adminField || !prefField) return out; const votes = new Map(); for (let i = 0; i < adminField.length; i++) { const admin = adminField[i]; const pref = prefField[i]; if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue; if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue; addMappingVote(votes, admin, pref, 1); } for (const [admin, bucket] of votes) { if (!out.has(admin)) { const pref = chooseVotedTarget(bucket, 1); if (pref >= 0) out.set(admin, pref); } } return out; } export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) { const actualWindow = window || (rects ? sourceWindowForRects(rects) : null); const actualRects = rects || { writeRect, writeMargin: seamBand || 1 }; if (!candidateMap || !world || !writeRect || !actualWindow) { return { prefecture: new Map(), municipality: new Map(), admin: new Map(), candidateAdminToPrefecture: new Map(), debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 }, }; } const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId; const candidateMunicipality = candidateMap.municipalityId || candidateAdmin; const candidatePrefecture = candidateMap.prefectureRegionId; const worldAdmin = world.fields?.adminId || world.fields?.municipalityId; const worldMunicipality = world.fields?.municipalityId || worldAdmin; const worldPrefecture = world.fields?.prefectureRegionId; const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed); const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed); const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed); const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds); const adminVotes = new Map(); const municipalityVotes = new Map(); const prefectureVotes = new Map(); const band = Math.max(2, Math.floor(seamBand)); const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; for (let y = writeRect.y0; y < writeRect.y1; y++) { for (let x = writeRect.x0; x < writeRect.x1; x++) { const edge = distanceToRectEdge(x, y, writeRect); if (edge > band) continue; const s = sourceCoordForWorld(actualWindow, x, y); const si = sourceWindowIndex(actualWindow, s.x, s.y); const wi = worldIndex(world, x, y); if (si < 0 || wi < 0) continue; const cAdmin = candidateAdmin?.[si] ?? -1; const cMunicipality = candidateMunicipality?.[si] ?? cAdmin; const cPrefecture = candidatePrefecture?.[si] ?? -1; const sameCellWeight = Math.max(1, band + 1 - edge); addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight); addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight); addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight); for (const [dx, dy] of dirs) { for (let step = 1; step <= 6; step++) { const nx = x + dx * step; const ny = y + dy * step; if (insideRect(nx, ny, writeRect)) continue; const ni = worldIndex(world, nx, ny); if (ni < 0) break; const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25; addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w); addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w); addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w); break; } } } } const prefecture = new Map(); let nextPrefectureId = maxFieldId(worldPrefecture) + 1; let prefecturesMappedToExisting = 0; let prefecturesAllocated = 0; for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) { const voted = chooseVotedTarget(prefectureVotes.get(id), 3); if (voted >= 0) { prefecture.set(id, voted); prefecturesMappedToExisting++; } else { prefecture.set(id, nextPrefectureId++); prefecturesAllocated++; } } const usedAdminIds = new Set(); if (worldAdmin) { for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]); } const municipality = new Map(); const admin = new Map(); let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1; let municipalitiesMappedToExisting = 0; let municipalitiesAllocated = 0; const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]); for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) { const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4); if (voted >= 0) { municipality.set(id, voted); admin.set(id, voted); municipalitiesMappedToExisting++; } else { while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++; municipality.set(id, nextMunicipalityId); admin.set(id, nextMunicipalityId); usedAdminIds.add(nextMunicipalityId); nextMunicipalityId++; municipalitiesAllocated++; } } const municipalityToPrefecture = new Map(); for (const [candidateAdminId, worldAdminId] of admin) { const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId); const worldPrefId = prefecture.get(candidatePrefId); if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId); } return { prefecture, municipality, admin, candidateAdminToPrefecture, municipalityToPrefecture, debug: { candidatePrefectureIds: candidatePrefectureIds.size, candidateMunicipalityIds: allMunicipalityIds.size, prefecturesMappedToExisting, prefecturesAllocated, municipalitiesMappedToExisting, municipalitiesAllocated, }, }; } function remapAdminCandidateValue(name, raw, adminIdMapping) { if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw; const id = Math.floor(raw); if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw; if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw; if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw; return raw; } function numericFeatureId(point, keys) { for (const key of keys) { const value = point?.[key]; if (Number.isFinite(value) && value >= 0) return Math.floor(value); } return -1; } function summarizeIdMapping(mapping) { return { ...(mapping?.debug || {}) }; } function updateSourceAdminMetadata(sourceMap, adminIdMapping) { if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0; let maxId = -1; const current = sourceMap.municipalityToPrefectureId; if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1); for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId); const next = new Int32Array(Math.max(0, maxId + 1)); next.fill(-1); if (current && typeof current.length === "number") { for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; } let updated = 0; for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) { if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue; if (next[adminId] !== prefId) updated++; next[adminId] = prefId; } sourceMap.municipalityToPrefectureId = next; sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping); return updated; } function cloneContinuityFields(world) { const out = new Map(); for (const name of CONTINUITY_FIELD_NAMES) { const field = world?.fields?.[name]; if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); } return out; } function cloneHumanLandContinuityFields(world) { const out = new Map(); for (const name of [ "elevation", "roadInfluence", "railInfluence2", "stationInfluence", "populationDensity", "settlementScore", "villageInfluence", "plain", "agriculture", ]) { const field = world?.fields?.[name]; if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); } return out; } function restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed = 0, seaLevel = 0.30) { const sea = world.fields?.sea; const ocean = world.fields?.ocean; const lake = world.fields?.lake; const elevation = world.fields?.elevation; const landuse = world.fields?.landuse; const rect = rects?.writeRect; if (!sea || !rect || !oldSea) { return { humanLandCellsRestored: 0, humanLandFeatureMaskCells: 0, humanLandCandidatesChecked: 0 }; } const width = rectWidth(rect); const height = rectHeight(rect); const mask = new Float32Array(width * height); const localIndex = (x, y) => (y - rect.y0) * width + (x - rect.x0); const active = (x, y, minAlpha = 0.18) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= minAlpha; const markDisk = (cx, cy, radius, weight) => { cx = Math.round(cx); cy = Math.round(cy); const r = Math.max(1, Math.floor(radius)); for (let y = cy - r; y <= cy + r; y++) { for (let x = cx - r; x <= cx + r; x++) { if (!active(x, y, 0.14)) continue; const wi = worldIndex(world, x, y); if (wi < 0 || oldSea[wi]) continue; const d = Math.hypot(x - cx, y - cy); if (d > r + 0.35) continue; const li = localIndex(x, y); const falloff = 1 - d / Math.max(1, r + 0.35); mask[li] = Math.max(mask[li], weight * (0.35 + falloff * 0.65)); } } }; const markSegment = (ax, ay, bx, by, radius, weight) => { const len = Math.max(1, Math.hypot(bx - ax, by - ay)); const steps = Math.max(1, Math.ceil(len / 2.0)); for (let i = 0; i <= steps; i++) { const t = i / steps; markDisk(ax + (bx - ax) * t, ay + (by - ay) * t, radius, weight); } }; let humanLandFeatureMaskCells = 0; const markPathLayer = (key, radius, weight) => { for (const path of sourceMap?.[key] || []) { let prev = null; for (const tuple of path || []) { const x = tupleWorldX(world, tuple); const y = tupleWorldY(world, tuple); if (prev) markSegment(prev.x, prev.y, x, y, radius, weight); prev = { x, y }; } } }; // Only the actual pre-existing transport centerlines should bias land // restoration. Settlements, admin centers, ports, industrial sites, etc. are // deliberately ignored here; otherwise patching a coastal/archipelago area // over-preserves old human geography and resists legitimate water generation. // The weights are intentionally weak: this pass is a continuity hint, not a // hard constraint. for (const key of ["expressways", "externalExpressways", "nationalRoads"]) markPathLayer(key, 3, 0.62); for (const key of ["minorRoads", "premodernRoads", "ringRoads", "externalRoads", "icAccessRoads"]) markPathLayer(key, 2, 0.42); for (const key of ["railways", "branchRailways", "ringRailways", "externalRailways"]) markPathLayer(key, 3, 0.66); for (let i = 0; i < mask.length; i++) if (mask[i] > 0) humanLandFeatureMaskCells++; const oldElevation = oldHumanFields?.get("elevation"); const scoreField = (name, i, weight) => (oldHumanFields?.get(name)?.[i] || 0) * weight; let humanLandCellsRestored = 0; let humanLandCandidatesChecked = 0; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { if (!active(x, y, 0.20)) continue; const i = worldIndex(world, x, y); if (i < 0 || oldSea[i] || !sea[i]) continue; humanLandCandidatesChecked++; const li = localIndex(x, y); const score = mask[li] + scoreField("roadInfluence", i, 0.82) + scoreField("railInfluence2", i, 0.98) + scoreField("stationInfluence", i, 0.28); if (score < 0.78) continue; sea[i] = 0; if (ocean) ocean[i] = 0; if (lake) lake[i] = 0; if (elevation) { const oldElev = oldElevation?.[i]; const target = Number.isFinite(oldElev) ? Math.max(oldElev, seaLevel + 0.012) : seaLevel + 0.018; elevation[i] = Math.max(elevation[i] || 0, target); } if (landuse) { const oldUse = oldLanduse?.[i]; landuse[i] = oldUse && oldUse !== LANDUSE.WATER ? oldUse : (LANDUSE.RURAL || 0); } humanLandCellsRestored++; } } return { humanLandCellsRestored, humanLandFeatureMaskCells, humanLandCandidatesChecked }; } function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { let restored = 0; let remapped = 0; const margin = Math.max(2, rects.writeMargin || 1); for (const name of CONTINUITY_FIELD_NAMES) { const field = world.fields?.[name]; const old = oldFields?.get(name); if (!field || !old) continue; const isPrefecture = name === "prefectureRegionId"; const isAdmin = name === "adminId" || name === "municipalityId"; const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74; const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72; // First preserve the old IDs in the transition band. This prevents the // writeRect edge from becoming a prefecture/municipal border. for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0 || old[i] < 0) continue; const edge = distanceToRectEdge(x, y, rects.writeRect); const a = patchAlpha(x, y, rects, seed); if (edge <= preserveEdge || a < preserveAlpha) { if (field[i] !== old[i]) { field[i] = old[i]; restored++; } } } } // Then map candidate IDs that contact an outside ID back to that outside ID. // This lets prefectures/municipalities cross the generated-area seam instead // of creating a new border exactly on the seam. const contacts = new Map(); const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) { for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) { const i = worldIndex(world, x, y); if (i < 0 || field[i] < 0 || old[i] === field[i]) continue; const a = patchAlpha(x, y, rects, seed); if (a < 0.98 && !isPrefecture) continue; for (const [dx, dy] of dirs) { const ni = worldIndex(world, x + dx, y + dy); if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue; if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) { const key = field[i]; const bucket = contacts.get(key) || new Map(); bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1); contacts.set(key, bucket); } } } } const mapping = new Map(); for (const [from, bucket] of contacts) { let best = -1, bestCount = 0; for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; } if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best); } if (mapping.size) { for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; } } } } } return { continuityCellsRestored: restored, continuityCellsRemapped: remapped }; } function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) { const a = patchAlpha(x, y, rects, seed); const i = worldIndex(world, x, y); const oldValue = oldField?.[i] ?? -1; if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue; if (a <= 0.24) return oldValue; if (a >= 0.82) return candidateValue; const field = world.fields?.[fieldName]; const pref = world.fields?.prefectureRegionId; const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField; let oldScore = (1 - a) * 3.0; let candidateScore = a * 3.0; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { const ni = worldIndex(world, x + dx, y + dy); if (ni < 0) continue; const neighbor = field?.[ni] ?? -1; if (neighbor === oldValue) oldScore += 1.2; if (neighbor === candidateValue) candidateScore += 1.2; if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) { if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18; } } const barrierBonus = naturalBarrier?.[i] || 0; if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) { if (a < 0.5) oldScore += barrierBonus * 0.9; else candidateScore += barrierBonus * 0.9; } return candidateScore > oldScore ? candidateValue : oldValue; } function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { let adminSeamCellsResolved = 0; let prefectureSeamCellsResolved = 0; for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) { const field = world.fields?.[name]; const old = oldFields?.get(name); if (!field || !old) continue; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0 || world.fields.sea?.[i]) continue; if (patchBand(x, y, rects, seed) !== "feather") continue; const before = field[i]; const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed); if (next !== before) { field[i] = next; if (name === "prefectureRegionId") prefectureSeamCellsResolved++; else adminSeamCellsResolved++; } } } } if (world.fields.adminId && world.fields.municipalityId) { for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i]; } } } return { adminSeamCellsResolved, prefectureSeamCellsResolved }; } function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30, patchContext = null) { const window = patchContext?.candidateWindow || sourceWindowForRects(rects); const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null; const oldHumanFields = cloneHumanLandContinuityFields(world); const oldContinuityFields = cloneContinuityFields(world); const adminIdMapping = buildAdminIdMapping({ candidateMap: candidate, world, writeRect: rects.writeRect, seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)), window, rects, seed, }); let updatedCells = 0; let coastCellsChanged = 0; let terrainCellsFullyReplaced = 0; let naturalRegionsUpdated = 0; let adminCellsReassigned = 0; let landUseCellsUpdated = 0; const fieldEntries = []; for (const [name, source] of Object.entries(candidate || {})) { if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue; const dest = ensureWorldField(world, name, source); if (!dest) continue; const isFloat = source.constructor === Float32Array || source.constructor === Float64Array; const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat; const idOffset = fieldIdOffset(name, seed); fieldEntries.push({ name, source, dest, isDiscrete, idOffset }); } const cells = patchContext?.writeCells?.length ? patchContext.writeCells : (() => { const out = []; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const wi = worldIndex(world, x, y); 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) out.push({ x, y, wi, si, alpha }); } } return out; })(); for (const cell of cells) { const { x, y, wi, si, alpha } = cell; for (const entry of fieldEntries) { const { name, source, dest, isDiscrete, idOffset } = entry; if (isDiscrete) { const threshold = continuityReplaceThreshold(name, x, y, rects, seed); if (alpha < threshold) continue; const raw = source[si]; const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") ? mapped : idOffset && raw >= 0 ? raw + idOffset : raw; if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++; if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++; if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++; if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++; dest[wi] = value; } else { const before = dest[wi] || 0; let candidateValue = source[si] || 0; if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment); dest[wi] = lerp(before, candidateValue, alpha); } if (name === "elevation") { updatedCells++; if (alpha > 0.94) terrainCellsFullyReplaced++; } } } // The legacy full pipeline uses `adminId` as the municipality raster and // assigns municipality metadata on `adminCenters`; it does not expose a // separate municipalityId cell field. If an old experimental field exists, // keep it synchronized with the canonical legacy adminId instead of leaving // stale numeric/one-municipality data in regenerated patches. if (world.fields.adminId && !candidate?.municipalityId) { const expected = world.width * world.height; if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { world.fields.municipalityId = new Int32Array(expected); world.fields.municipalityId.fill(-1); } const municipalityId = world.fields.municipalityId; const adminId = world.fields.adminId; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const wi = worldIndex(world, x, y); if (wi >= 0) municipalityId[wi] = adminId[wi]; } } } // Keep water fields coherent after all continuous fields have been blended. const sea = world.fields.sea; const ocean = world.fields.ocean; const lake = world.fields.lake; const landuse = world.fields.landuse; if (sea) { for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; if (sea[i]) { if (ocean) ocean[i] = 1; if (lake) lake[i] = 0; if (landuse) landuse[i] = LANDUSE.WATER || 0; } else { if (ocean) ocean[i] = 0; if (lake) lake[i] = 0; } } } } const humanLandDebug = restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed, seaLevel); const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed); if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const wi = worldIndex(world, x, y); if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi]; } } } return { window, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated, adminIdMapping, adminIdMappingDebug: summarizeIdMapping(adminIdMapping), terrainSeamAdjustmentSamples: elevationAdjustment?.samples || 0, terrainSeamElevationOffset: elevationAdjustment ? Math.round((elevationAdjustment.offset || 0) * 10000) / 10000 : 0, terrainSeamElevationTiltX: elevationAdjustment ? Math.round((elevationAdjustment.tiltX || 0) * 10000) / 10000 : 0, terrainSeamElevationTiltY: elevationAdjustment ? Math.round((elevationAdjustment.tiltY || 0) * 10000) / 10000 : 0, ...humanLandDebug, ...continuityDebug, ...seamOwnershipDebug, }; } function repairDisplayMasks(world, rects, seed = 0) { const expected = world.width * world.height; if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected); if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected); if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected); const coverage = world.fields.prefectureMask; const landMask = world.fields.landMask; const humanMask = world.fields.humanRegionMask; const sea = world.fields.sea; let displayMaskUpdated = 0; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; const a = patchAlpha(x, y, rects, seed); if (a <= 0.08) continue; if (!coverage[i]) displayMaskUpdated++; coverage[i] = 1; const isSea = Boolean(sea?.[i]); humanMask[i] = isSea ? 0 : 1; landMask[i] = isSea ? 0 : 1; } } return { displayMaskUpdated }; } function featherTerrainSeam(world, rects, seed = 0) { const fields = world.fields || {}; const smoothKeys = [ "elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField", "basinField", "coastalLowland", "plain", "agriculture", "erosionField", "depositionField", "depositionalLowland", "alluvialFanField", "deltaField", "naturalBarrierScore", "settlementScore", "populationDensity", ]; let terrainFeatherCells = 0; let terrainFeatherValues = 0; // Earlier versions cloned each whole typed array here. After the world grows, // that made every patch pay for global memory copies. The seam smoother only // samples the write rectangle and its one-cell neighborhood, so snapshot just // that local window. const sampleRect = expandRect(rects.writeRect, 1, world); const sw = rectWidth(sampleRect); const localOffset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0); for (const key of smoothKeys) { const field = fields[key]; if (!field || !ArrayBuffer.isView(field)) continue; const old = new field.constructor(sw * rectHeight(sampleRect)); for (let y = sampleRect.y0; y < sampleRect.y1; y++) { for (let x = sampleRect.x0; x < sampleRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0) old[localOffset(x, y)] = field[i] || 0; } } const oldValue = (x, y) => { if (insideRect(x, y, sampleRect)) return old[localOffset(x, y)] || 0; const i = worldIndex(world, x, y); return i >= 0 ? (field[i] || 0) : 0; }; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { if (patchBand(x, y, rects, seed) !== "feather") continue; const i = worldIndex(world, x, y); if (i < 0 || fields.sea?.[i]) continue; let sum = 0; let count = 0; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { const ni = worldIndex(world, x + dx, y + dy); if (ni >= 0 && !fields.sea?.[ni]) { sum += oldValue(x + dx, y + dy); count++; } } if (!count) continue; const a = patchAlpha(x, y, rects, seed); const neighborMean = sum / count; const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2); field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34)); terrainFeatherValues++; if (key === "elevation") terrainFeatherCells++; } } } return { terrainFeatherCells, terrainFeatherValues }; } function smoothExtremeElevationSeams(world, rects, seed = 0, seaLevel = 0.30) { const fields = world.fields || {}; const elevation = fields.elevation; const sea = fields.sea; if (!elevation || !rects?.writeRect) return { elevationCliffCellsSmoothed: 0, elevationCliffMaxDelta: 0, elevationBridgePasses: 0 }; const rect = rects.writeRect; const sampleRect = expandRect(rect, 1, world); const sw = rectWidth(sampleRect); const sh = rectHeight(sampleRect); const offset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0); const dirs4 = [[1,0],[-1,0],[0,1],[0,-1]]; const dirs8 = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; let cells = 0; let maxDelta = 0; let elevationBridgePasses = 0; // This pass is intentionally stronger than the previous cliff-only filter. // The seam can connect high mountains, low hills, plains, and sea in one patch; // a local threshold leaves visible walls. We smooth the generated side across // the whole inward feather band, with larger force near preserved cells and // near coastlines. The outside/preserved side is never written. for (let pass = 0; pass < 8; pass++) { const old = new Float32Array(sw * sh); for (let y = sampleRect.y0; y < sampleRect.y1; y++) { for (let x = sampleRect.x0; x < sampleRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0) old[offset(x, y)] = elevation[i] || 0; } } let passCells = 0; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; const a = patchAlpha(x, y, rects, seed); if (a <= 0.005 || a >= 0.94) continue; const here = old[offset(x, y)] || 0; const hereSea = !!sea?.[i]; let sum = 0; let wsum = 0; let lessGeneratedContacts = 0; let seaContacts = 0; let landContacts = 0; let strongest = 0; for (const [dx, dy] of dirs8) { const nx = x + dx, ny = y + dy; const ni = worldIndex(world, nx, ny); if (ni < 0) continue; const na = patchAlpha(nx, ny, rects, seed); // We can pull the generated seam toward preserved or less-generated // neighbors. Pulling toward deeper core cells would blur intentional // candidate landforms, so keep that side weak. const lessGenerated = na < a + 0.10 || !insideRect(nx, ny, rect); const nv = insideRect(nx, ny, sampleRect) ? old[offset(nx, ny)] : (elevation[ni] || 0); const nSea = !!sea?.[ni]; if (nSea) seaContacts++; else landContacts++; if (lessGenerated) lessGeneratedContacts++; const diag = Math.abs(dx) + Math.abs(dy) === 2; const w = (lessGenerated ? 1.35 : 0.32) * (diag ? 0.72 : 1.0) * (nSea === hereSea ? 1.0 : 0.82); sum += nv * w; wsum += w; strongest = Math.max(strongest, Math.abs(here - nv)); } if (!wsum || !lessGeneratedContacts) continue; let target = sum / wsum; const coastalMix = seaContacts > 0 && landContacts > 0; if (coastalMix) { // Avoid mountain/sea hard cuts. Land near a preserved sea seam becomes // low coastal ground; sea near land becomes a shallow shelf. const coastalLand = seaLevel + 0.030 + valueNoise(x, y, seed ^ 0x8ac3f51, 13) * 0.035; const coastalSea = seaLevel - 0.035 - valueNoise(x, y, seed ^ 0x1c69b3e, 17) * 0.030; target = hereSea ? lerp(target, coastalSea, 0.55) : lerp(target, coastalLand, 0.48); } const diff = Math.abs(here - target); const severity = clamp((Math.max(diff, strongest * 0.7) - 0.018) / 0.22); if (severity <= 0.01 && !coastalMix) continue; const edgeForce = clamp(1.08 - a * 0.82, 0.18, 1.0); const coastalForce = coastalMix ? 0.22 : 0; const weight = clamp(0.16 + severity * 0.58 + coastalForce, 0.16, 0.78) * edgeForce; elevation[i] = clamp(lerp(elevation[i], target, weight), 0, 1); maxDelta = Math.max(maxDelta, diff, strongest); passCells++; } } if (!passCells) break; elevationBridgePasses++; cells += passCells; } // Reconcile visible lowland fields after the elevation bridge. These fields // are continuous display/settlement aids; keeping the pre-bridge values is a // common cause of highland colors ending abruptly at the patch edge. 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 a = patchAlpha(x, y, rects, seed); if (a <= 0.005 || a >= 0.94) continue; const e = elevation[i] || 0; const lowland = clamp((seaLevel + 0.16 - e) * 2.2); const oldPlain = fields.plain?.[i] || 0; if (fields.coastalLowland) fields.coastalLowland[i] = clamp(lerp(fields.coastalLowland[i] || 0, lowland, 0.42 * (1 - a * 0.55))); if (fields.plain) fields.plain[i] = clamp(lerp(oldPlain, lowland * (1 - (fields.slope?.[i] || 0) * 0.55), 0.38 * (1 - a * 0.45))); if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp(lerp(fields.agriculture[i] || 0, fields.plain[i], 0.24 * (1 - a * 0.45))); } } return { elevationCliffCellsSmoothed: cells, elevationCliffMaxDelta: Math.round(maxDelta * 10000) / 10000, elevationBridgePasses }; } function averageNearbyLandElevation(world, x, y, maxRadius = 5) { const elevation = world.fields?.elevation; const sea = world.fields?.sea; if (!elevation) return null; for (let r = 1; r <= maxRadius; r++) { let sum = 0; let count = 0; for (let yy = y - r; yy <= y + r; yy++) { for (let xx = x - r; xx <= x + r; xx++) { if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; const i = worldIndex(world, xx, yy); if (i >= 0 && !sea?.[i] && Number.isFinite(elevation[i])) { sum += elevation[i]; count++; } } } if (count) return sum / count; } return null; } function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0, options = {}) { const sea = world.fields?.sea; if (!sea || !rects?.writeRect) return { residualSeaPatchesFilled: 0, residualSeaCellsFilled: 0 }; const rect = rects.writeRect; const rw = rectWidth(rect); const rh = rectHeight(rect); const visited = new Uint8Array(rw * rh); const local = (x, y) => (y - rect.y0) * rw + (x - rect.x0); const dirs4 = [[1,0],[-1,0],[0,1],[0,-1]]; const dirs8 = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; const aggressive = !!options.aggressive; const preservePockets = !!options.preservePockets; const baseMax = Math.floor((rw * rh) * (aggressive ? 0.0075 : 0.0025)); const maxComponent = Math.max(aggressive ? 36 : 8, Math.min(aggressive ? 260 : 72, baseMax)); let patches = 0; let cellsFilled = 0; for (let sy = rect.y0; sy < rect.y1; sy++) { for (let sx = rect.x0; sx < rect.x1; sx++) { const startLocal = local(sx, sy); if (visited[startLocal]) continue; const startIndex = worldIndex(world, sx, sy); if (startIndex < 0 || !sea[startIndex]) { visited[startLocal] = 1; continue; } const queue = [[sx, sy]]; const cells = []; visited[startLocal] = 1; let touchesOutside = false; let landContacts4 = 0; let waterContacts4 = 0; let landContacts8 = 0; let waterContacts8 = 0; let maxAlpha = 0; let minAlpha = 1; let seamTouches = 0; for (let qi = 0; qi < queue.length; qi++) { const [x, y] = queue[qi]; cells.push([x, y]); const a = patchAlpha(x, y, rects, seed); maxAlpha = Math.max(maxAlpha, a); minAlpha = Math.min(minAlpha, a); if (a < (aggressive ? 0.52 : 0.34)) seamTouches++; if (x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) touchesOutside = true; for (const [dx, dy] of dirs4) { const nx = x + dx, ny = y + dy; if (!insideRect(nx, ny, rect)) { touchesOutside = true; continue; } const ni = worldIndex(world, nx, ny); if (ni < 0) { touchesOutside = true; continue; } if (sea[ni]) { const li = local(nx, ny); if (!visited[li]) { visited[li] = 1; queue.push([nx, ny]); } waterContacts4++; } else { landContacts4++; } } for (const [dx, dy] of dirs8) { const ni = worldIndex(world, x + dx, y + dy); if (ni < 0) continue; if (sea[ni]) waterContacts8++; else landContacts8++; } if (cells.length > maxComponent * 4) break; } // Fill small enclosed water remnants at or near the inward seam. Stage 12 // was intentionally conservative and therefore missed the common hand-jitter // case: a tiny ungenerated sea pocket just inside the blue outline. This // pass is still topological: components touching the patch edge, long bays, // and real channels remain water. const surrounded4 = landContacts4 >= waterContacts4 * (aggressive ? 1.12 : 2.2); const surrounded8 = landContacts8 >= waterContacts8 * (aggressive ? 0.92 : 1.65); const seamish = seamTouches >= Math.max(1, Math.floor(cells.length * (aggressive ? 0.12 : 0.30))) || minAlpha < 0.18; const alphaOk = preservePockets ? maxAlpha <= 0.88 : maxAlpha <= (aggressive ? 0.84 : 0.70); if (touchesOutside || cells.length > maxComponent || !alphaOk || !seamish || !(surrounded4 || surrounded8)) continue; for (const [x, y] of cells) { const i = worldIndex(world, x, y); if (i < 0) continue; sea[i] = 0; if (world.fields.ocean) world.fields.ocean[i] = 0; if (world.fields.lake) world.fields.lake[i] = 0; if (world.fields.elevation) { const nearby = averageNearbyLandElevation(world, x, y, aggressive ? 9 : 5); const target = nearby == null ? seaLevel + 0.035 : Math.max(seaLevel + 0.025, nearby * 0.72 + (seaLevel + 0.05) * 0.28); world.fields.elevation[i] = clamp(target, seaLevel + 0.012, seaLevel + 0.28); } if (world.fields.plain) world.fields.plain[i] = Math.max(world.fields.plain[i] || 0, 0.38); if (world.fields.coastalLowland) world.fields.coastalLowland[i] = Math.max(world.fields.coastalLowland[i] || 0, 0.46); cellsFilled++; } patches++; } } return { residualSeaPatchesFilled: patches, residualSeaCellsFilled: cellsFilled }; } function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { const field = world.fields?.[fieldName]; const sea = world.fields?.sea; if (!field) return -1; const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18)); const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null; const prefField = world.fields?.prefectureRegionId; for (let r = 1; r <= maxRadius; r++) { let best = -1; let bestD = Infinity; const y0 = Math.max(0, y - r); const y1 = Math.min(world.height - 1, y + r); const x0 = Math.max(0, x - r); const x1 = Math.min(world.width - 1, x + r); for (let yy = y0; yy <= y1; yy++) { for (let xx = x0; xx <= x1; xx++) { if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; if (rect && !insideRect(xx, yy, rect)) continue; const i = worldIndex(world, xx, yy); if (i < 0 || sea?.[i]) continue; if (requiredPref !== null && prefField?.[i] !== requiredPref) continue; const id = field[i]; if (!Number.isFinite(id) || id < 0) continue; const d = Math.hypot(xx - x, yy - y); if (d < bestD) { best = Math.floor(id); bestD = d; } } } if (best >= 0) return best; } return -1; } function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) { if (!Number.isFinite(adminId) || adminId < 0) return -1; const id = Math.floor(adminId); const mapped = adminIdMapping?.municipalityToPrefecture?.get(id); if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped); const table = sourceMap?.municipalityToPrefectureId; if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]); return -1; } function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) { const admin = world.fields?.adminId; if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 }; const expected = world.width * world.height; if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { world.fields.municipalityId = new Int32Array(expected); world.fields.municipalityId.fill(-1); } if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) { world.fields.prefectureRegionId = new Int32Array(expected); world.fields.prefectureRegionId.fill(-1); } const municipality = world.fields.municipalityId; const prefecture = world.fields.prefectureRegionId; const sea = world.fields.sea; const coverage = world.fields.prefectureMask; let seaAdminCellsCleared = 0; let landAdminCellsFilled = 0; let prefectureCellsFilled = 0; let adminPrefectureCellsAligned = 0; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; if (sea?.[i]) { if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++; admin[i] = -1; municipality[i] = -1; prefecture[i] = -1; continue; } const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect)); if (!generated) continue; if (admin[i] < 0) { const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null; let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 }); if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 }); if (nearest >= 0) { admin[i] = nearest; municipality[i] = nearest; landAdminCellsFilled++; } } if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i]; if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i]; let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]); if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 }); if (targetPref >= 0 && prefecture[i] !== targetPref) { if (prefecture[i] < 0) prefectureCellsFilled++; else adminPrefectureCellsAligned++; prefecture[i] = targetPref; } else if (prefecture[i] < 0) { const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 }); if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; } } } } const current = sourceMap?.municipalityToPrefectureId; let maxId = current?.length ? current.length - 1 : -1; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]); } } if (sourceMap && maxId >= 0) { const next = new Int32Array(maxId + 1); next.fill(-1); if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i]; } } sourceMap.municipalityToPrefectureId = next; } return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; } function protectedAdministrativeCells(world, fieldName, rect) { const protectedCells = new Set(); const sourceMap = world?.sourceMap || {}; const pointKeys = fieldName === "prefectureRegionId" ? ["prefectureRegions"] : ["adminCenters"]; const field = world?.fields?.[fieldName]; if (!field) return protectedCells; for (const key of pointKeys) { for (const p of sourceMap[key] || []) { const x = Math.round(pointWorldX(world, p)); const y = Math.round(pointWorldY(world, p)); if (!insideRect(x, y, rect)) continue; const i = worldIndex(world, x, y); if (i >= 0 && field[i] >= 0) protectedCells.add(i); } } return protectedCells; } function modeFromCounts(counts) { let best = -1; let bestCount = 0; for (const [id, count] of counts || []) { if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } } return best; } function addNestedVote(map, key, value, weight = 1) { if (!Number.isFinite(key) || key < 0 || !Number.isFinite(value) || value < 0) return; const id = Math.floor(key); const bucket = map.get(id) || new Map(); bucket.set(Math.floor(value), (bucket.get(Math.floor(value)) || 0) + weight); map.set(id, bucket); } function cleanupDiscreteFieldComponents(world, fieldName, rect, options = {}) { const field = world?.fields?.[fieldName]; const sea = world?.fields?.sea; if (!field || !rect) return { componentsMerged: 0, cellsMerged: 0 }; const minCells = Math.max(1, Math.floor(options.minCells || 80)); const passes = Math.max(1, Math.floor(options.passes || 2)); const respectPrefecture = !!options.respectPrefecture; const pref = world.fields?.prefectureRegionId; const protectedCells = protectedAdministrativeCells(world, fieldName, rect); const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; let componentsMerged = 0; let cellsMerged = 0; for (let pass = 0; pass < passes; pass++) { const seen = new Uint8Array(world.width * world.height); const reassignments = []; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const start = worldIndex(world, x, y); if (start < 0 || seen[start] || sea?.[start] || field[start] < 0) continue; const id = field[start]; const stack = [start]; seen[start] = 1; const cells = []; const neighborVotes = new Map(); const neighborPrefVotes = new Map(); const ownPrefVotes = new Map(); let touchesRectEdge = false; let hasProtectedPoint = false; while (stack.length) { const ci = stack.pop(); const cx = ci % world.width; const cy = Math.floor(ci / world.width); cells.push(ci); if (protectedCells.has(ci)) hasProtectedPoint = true; if (cx <= rect.x0 || cy <= rect.y0 || cx >= rect.x1 - 1 || cy >= rect.y1 - 1) touchesRectEdge = true; if (pref?.[ci] >= 0) ownPrefVotes.set(pref[ci], (ownPrefVotes.get(pref[ci]) || 0) + 1); for (const [dx, dy] of dirs) { const nx = cx + dx; const ny = cy + dy; const ni = worldIndex(world, nx, ny); if (ni < 0 || sea?.[ni]) continue; const nid = field[ni]; if (insideRect(nx, ny, rect) && nid === id && !seen[ni]) { seen[ni] = 1; stack.push(ni); } else if (nid >= 0 && nid !== id) { neighborVotes.set(nid, (neighborVotes.get(nid) || 0) + 1); if (pref?.[ni] >= 0) addNestedVote(neighborPrefVotes, nid, pref[ni], 1); } } } // Components touching the cleanup rectangle boundary may be only the // visible slice of a large outside municipality/prefecture. Preserve // those unless they are extremely small; otherwise patched seams can // erase legitimate existing regions. const clippedLargeOutsideRegion = touchesRectEdge && cells.length >= Math.floor(minCells * 0.55); if (hasProtectedPoint || clippedLargeOutsideRegion || cells.length >= minCells || !neighborVotes.size) continue; const componentPref = respectPrefecture ? modeFromCounts(ownPrefVotes) : -1; let bestTarget = -1; let bestScore = -Infinity; for (const [target, count] of neighborVotes) { let score = count; if (respectPrefecture && componentPref >= 0) { const targetPref = modeFromCounts(neighborPrefVotes.get(target)); if (targetPref === componentPref) score += count * 0.85; else score -= count * 0.45; } if (score > bestScore || (score === bestScore && target < bestTarget)) { bestTarget = target; bestScore = score; } } if (bestTarget < 0) continue; for (const ci of cells) reassignments.push([ci, bestTarget]); componentsMerged++; cellsMerged += cells.length; } } if (!reassignments.length) break; for (const [i, target] of reassignments) field[i] = target; } return { componentsMerged, cellsMerged }; } function repairPatchAdministrativeTopology(world, rects) { const rect = rects.repairRect || rects.writeRect; const prefecture = cleanupDiscreteFieldComponents(world, "prefectureRegionId", rect, { minCells: 420, passes: 3 }); const admin = cleanupDiscreteFieldComponents(world, "adminId", rect, { minCells: 96, passes: 4, respectPrefecture: true }); let municipalityCellsSynced = 0; if (world.fields?.adminId && world.fields?.municipalityId) { const adminId = world.fields.adminId; const municipalityId = world.fields.municipalityId; const sea = world.fields.sea; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; const next = sea?.[i] ? -1 : adminId[i]; if (municipalityId[i] !== next) { municipalityId[i] = next; municipalityCellsSynced++; } } } } return { prefectureTinyComponentsMerged: prefecture.componentsMerged, prefectureTinyCellsMerged: prefecture.cellsMerged, adminTinyComponentsMerged: admin.componentsMerged, adminTinyCellsMerged: admin.cellsMerged, municipalityCellsSynced, }; } function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { const sea = world.fields.sea; const ocean = world.fields.ocean; const lake = world.fields.lake; const elevation = world.fields.elevation; if (!sea || !elevation) return { coastCellsChanged: 0 }; let changed = 0; for (let pass = 0; pass < 3; pass++) { const flips = []; for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) { for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; let seaN = 0; let landN = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const ni = worldIndex(world, x + dx, y + dy); if (ni < 0) continue; if (sea[ni]) seaN++; else landN++; } } const a = rects ? patchAlpha(x, y, rects, seed) : 1; if (a < 0.24) continue; const strongOnly = a < 0.42; if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]); else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]); } } for (const [i, nextSea] of flips) { if (sea[i] === nextSea) continue; sea[i] = nextSea; if (ocean) ocean[i] = nextSea; if (lake) lake[i] = 0; if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); changed++; } } return { coastCellsChanged: changed }; } function repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) { const sea = world.fields.sea; const ocean = world.fields.ocean; const lake = world.fields.lake; const elevation = world.fields.elevation; const landuse = world.fields.landuse; const rect = rects?.writeRect; if (!sea || !rect) return { waterComponentsScanned: 0, tinyWaterComponentsRemoved: 0, tinyLandIslandsRemoved: 0, waterTopologyCellsFlipped: 0 }; const expected = world.width * world.height; const visited = new Uint8Array(expected); const activeMinAlpha = 0.22; const preserveAlpha = 0.40; const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; const active = (x, y) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= activeMinAlpha; const tinyWaterLimit = Math.max(14, Math.min(36, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.20))); const tinyLandLimit = Math.max(10, Math.min(28, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.16))); let waterComponentsScanned = 0; let tinyWaterComponentsRemoved = 0; let tinyLandIslandsRemoved = 0; let waterTopologyCellsFlipped = 0; const flipCell = (i, nextSea) => { if (sea[i] === nextSea) return; sea[i] = nextSea; if (ocean) ocean[i] = nextSea; if (lake) lake[i] = 0; if (elevation) { if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); } if (landuse) landuse[i] = nextSea ? (LANDUSE.WATER || LANDUSE.RURAL || 0) : (LANDUSE.RURAL || 0); waterTopologyCellsFlipped++; }; for (let y0 = rect.y0; y0 < rect.y1; y0++) { for (let x0 = rect.x0; x0 < rect.x1; x0++) { if (!active(x0, y0)) continue; const start = worldIndex(world, x0, y0); if (start < 0 || visited[start]) continue; const value = sea[start] ? 1 : 0; const stack = [[x0, y0]]; const cells = []; let sumElevation = 0; let elevationCount = 0; let touchesWeakPatchEdge = false; let touchesSameOutsideActive = false; let oppositeBorder = 0; let sameBorder = 0; visited[start] = 1; while (stack.length) { const [x, y] = stack.pop(); const i = worldIndex(world, x, y); if (i < 0) continue; cells.push(i); if (elevation) { sumElevation += elevation[i] || 0; elevationCount++; } if (patchAlpha(x, y, rects, seed) < preserveAlpha || x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) { touchesWeakPatchEdge = true; } for (const [dx, dy] of dirs) { const nx = x + dx; const ny = y + dy; const ni = worldIndex(world, nx, ny); if (ni < 0) continue; const nv = sea[ni] ? 1 : 0; if (nv !== value) { oppositeBorder++; continue; } sameBorder++; if (!active(nx, ny)) { touchesSameOutsideActive = true; continue; } if (!visited[ni]) { visited[ni] = 1; stack.push([nx, ny]); } } } waterComponentsScanned++; const area = cells.length; const avgElevation = elevationCount ? sumElevation / elevationCount : seaLevel; const isolatedInsidePatch = !touchesWeakPatchEdge && !touchesSameOutsideActive; if (value === 1) { if (isolatedInsidePatch && area <= tinyWaterLimit && avgElevation > seaLevel - 0.055) { for (const i of cells) flipCell(i, 0); tinyWaterComponentsRemoved++; } } else { const mostlySurroundedBySea = oppositeBorder > sameBorder * 0.72; if (isolatedInsidePatch && mostlySurroundedBySea && area <= tinyLandLimit && avgElevation < seaLevel + 0.045) { for (const i of cells) flipCell(i, 1); tinyLandIslandsRemoved++; } } } } return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped }; } function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) { const elevation = world.fields?.elevation; const sea = world.fields?.sea; if (!elevation || !sea || !rects?.writeRect) return { waterElevationCellsSmoothed: 0 }; const rect = rects.writeRect; let waterElevationCellsSmoothed = 0; // Do not diffuse water elevation row-by-row. Diffusion made broad patched // ocean/sea areas inherit candidate raster bands, which appeared as horizontal // stripes. Instead assign a stable world-coordinate bathymetry target and // blend toward it by patch alpha. The renderer also avoids DEM hillshade for // water, but keeping the underlying water DEM coherent prevents dependent // fields from reintroducing stripe artefacts later. for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0 || !sea[i]) continue; const a = patchAlpha(x, y, rects, seed); if (a < 0.08) continue; let seaNear = 0; let totalNear = 0; for (let dy = -3; dy <= 3; dy++) { for (let dx = -3; dx <= 3; dx++) { if (!dx && !dy) continue; const ni = worldIndex(world, x + dx, y + dy); if (ni < 0) continue; totalNear++; if (sea[ni]) seaNear++; } } const offshore = totalNear ? seaNear / totalNear : 1; const broad = valueNoise(x, y, seed ^ 0x6d2b79f5, 86); const mid = valueNoise(x, y, seed ^ 0x2f31c9a7, 31); const texture = broad * 0.75 + mid * 0.25; const depth = clamp(0.032 + offshore * 0.060 + (texture - 0.5) * 0.018, 0.018, 0.125); const target = seaLevel - depth; const before = elevation[i]; const strength = clamp(0.46 + a * 0.42, 0.42, 0.86); elevation[i] = clamp(lerp(Math.min(before, seaLevel - 0.004), target, strength), seaLevel - 0.16, seaLevel - 0.004); if (Math.abs(elevation[i] - before) > 1e-6) waterElevationCellsSmoothed++; } } return { waterElevationCellsSmoothed }; } function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) { const fields = world.fields; const { elevation, sea } = fields; if (!elevation || !sea) return; if (!fields.slope) fields.slope = new Float32Array(world.width * world.height); for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; if (sea[i]) { for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) { if (fields[key]) fields[key][i] = 0; } continue; } if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) { const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)]; const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)]; fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2); } if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25); if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28); } } } function seaNeighbors(world, x, y, radius = 1) { let count = 0; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { if (!dx && !dy) continue; const i = worldIndex(world, x + dx, y + dy); if (i >= 0 && world.fields.sea?.[i]) count++; } } return count; } function landNeighbors(world, x, y, radius = 1) { let count = 0; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { if (!dx && !dy) continue; const i = worldIndex(world, x + dx, y + dy); if (i >= 0 && !world.fields.sea?.[i]) count++; } } return count; } function isLand(world, x, y) { const i = worldIndex(world, x, y); return i >= 0 && !world.fields.sea?.[i]; } function nearestLand(world, x, y, rect, radius = 10) { if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y }; for (let r = 1; r <= radius; r++) { let best = null; let bestScore = Infinity; for (let yy = y - r; yy <= y + r; yy++) { for (let xx = x - r; xx <= x + r; xx++) { if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue; if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue; const i = worldIndex(world, xx, yy); const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3; if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; } } } if (best) return best; } return null; } function pointWorldX(world, p) { if (Number.isFinite(p?.worldX)) return p.worldX; return (p?.x || 0) + (world?.originX || 0); } function pointWorldY(world, p) { if (Number.isFinite(p?.worldY)) return p.worldY; return (p?.y || 0) + (world?.originY || 0); } function tupleWorldX(world, tuple) { return (tuple?.[0] || 0) + (world?.originX || 0); } function tupleWorldY(world, tuple) { return (tuple?.[1] || 0) + (world?.originY || 0); } function sourcePointFromWorld(world, point) { return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true }; } function sourcePathFromWorld(world, path) { const out = path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); // Arrays can carry lightweight metadata in JS. Marking generated paths lets // a later Alternative generation remove the prior variant completely instead // of leaving low-alpha edge fragments behind. out.patchGenerated = true; return out; } function offsetPointNumericFields(point, fields, offset) { for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset; } function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) { const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); if (rawAdminId >= 0) { const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId); if (Number.isFinite(mappedAdminId)) { point.sourceAdminId = rawAdminId; point.adminId = mappedAdminId; point.adminNumericId = mappedAdminId; point.municipalityId = mappedAdminId; } else { offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed)); if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId; if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId; } } const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]); if (rawPrefectureId >= 0) { const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId); if (Number.isFinite(mappedPrefectureId)) { point.sourcePrefectureRegionId = rawPrefectureId; point.prefectureRegionId = mappedPrefectureId; if (key === "prefectureRegions") point.id = mappedPrefectureId; } else { const offset = fieldIdOffset("prefectureRegionId", seed); if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset; if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset; } } if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId; if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId; return point; } function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) { if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; const w = worldCoordForSource(window, p.x, p.y); if (key === "ports") { const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8); if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null; w.x = land.x; w.y = land.y; } else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) { const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5); if (!land) return null; w.x = land.x; w.y = land.y; } const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y }); normalizeGeneratedPointIds(out, key, seed, adminIdMapping); if (key === "adminCenters") { if (Number.isFinite(out.sourceAdminId)) { const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId); const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId); if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId; } } if (key === "logisticsParks") sanitizeLogisticsPark(out); return out; } function sanitizeLogisticsPark(p) { if (!p) return p; p.name = null; p.labelName = null; p.facilityLabel = p.facilityLabel || "Logistics Park"; p.labelStyle = "facility"; p.suppressSettlementLabel = true; p.kind = "Logistics Park"; p.population = 0; return p; } function sanitizeExistingLogistics(sourceMap) { let migrated = 0; if (!Array.isArray(sourceMap.logisticsParks)) return 0; for (const p of sourceMap.logisticsParks) { if (!p) continue; if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++; sanitizeLogisticsPark(p); } for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) { const arr = sourceMap[key]; if (!Array.isArray(arr)) continue; for (const p of arr) { if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue; p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null; p.labelName = p.name; migrated++; } } return migrated; } function transformCandidatePath(window, path) { const out = []; for (const tuple of path || []) { if (!Array.isArray(tuple) || tuple.length < 2) continue; const p = worldCoordForSource(window, tuple[0], tuple[1]); out.push([p.x, p.y]); } return out; } function splitWorldPathByPredicate(path, predicate, keepWhenTrue) { const chunks = []; let current = []; for (const p of path || []) { const matches = predicate(Math.round(p[0]), Math.round(p[1])); if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]); else { if (current.length >= 2) chunks.push(current); current = []; } } if (current.length >= 2) chunks.push(current); return chunks; } function splitWorldPathByRect(path, rect, keepInside) { return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside); } function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) { return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected); } function pruneOldPathLayer(world, paths, rects, seed, mode) { const kept = []; const anchors = []; let clipped = 0; for (const path of paths || []) { const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); const removalAlpha = path?.patchGenerated ? 0.005 : 0.34; const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, removalAlpha)); if (!touches) { kept.push(path); continue; } clipped++; let lastOutside = null; let wasInside = false; for (const [x, y] of worldPath) { const inside = patchAffected(x, y, rects, seed, removalAlpha); if (!inside) { if (wasInside) anchors.push({ x, y, mode }); lastOutside = { x, y, mode }; } else if (lastOutside && !wasInside) { anchors.push(lastOutside); } wasInside = inside; } for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, removalAlpha)) kept.push(sourcePathFromWorld(world, chunk)); } return { kept, anchors, clipped }; } function pathCost(world, x, y, mode) { const i = worldIndex(world, x, y); if (i < 0 || world.fields.sea?.[i]) return Infinity; const slope = world.fields.slope?.[i] || 0; const river = world.fields.river?.[i] || 0; const plain = world.fields.plain?.[i] || 0; const roadInfluence = world.fields.roadInfluence?.[i] || 0; const pop = world.fields.populationDensity?.[i] || 0; const slopeMult = mode === "rail" ? 15 : 7; return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18; } function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000, options = {}) { const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y); if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null; const allowCell = typeof options.allowCell === "function" ? options.allowCell : null; const extraCost = typeof options.extraCost === "function" ? options.extraCost : null; if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null; if (allowCell && (!allowCell(sx, sy, true) || !allowCell(gx, gy, true))) return null; const w = rectWidth(rect); const h = rectHeight(rect); const n = w * h; const dist = new Float64Array(n); dist.fill(Infinity); const prev = new Int32Array(n); prev.fill(-1); const local = (x, y) => (y - rect.y0) * w + (x - rect.x0); const heap = new MinHeap(); const startId = local(sx, sy); dist[startId] = 0; heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId }); let expanded = 0; let found = -1; const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; while (heap.items.length && expanded < maxExpanded) { const cur = heap.pop(); if (!cur) break; if (cur.x === gx && cur.y === gy) { found = cur.id; break; } expanded++; for (const [dx, dy] of dirs) { const nx = cur.x + dx, ny = cur.y + dy; if (!insideRect(nx, ny, rect)) continue; if (allowCell && !allowCell(nx, ny, false)) continue; const nid = local(nx, ny); const baseCost = pathCost(world, nx, ny, mode); if (!Number.isFinite(baseCost)) continue; const c = baseCost + (extraCost ? Math.max(0, extraCost(nx, ny) || 0) : 0); const step = (dx && dy ? 1.42 : 1) * c; const nd = dist[cur.id] + step; if (nd >= dist[nid]) continue; dist[nid] = nd; prev[nid] = cur.id; heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 }); } } if (found < 0) return null; const rev = []; let at = found; while (at >= 0) { const x = rect.x0 + (at % w); const y = rect.y0 + Math.floor(at / w); rev.push([x, y]); at = prev[at]; } return rev.reverse(); } function simplifyPath(path, keepEvery = 2) { if (!path || path.length <= 2) return path || []; const out = [path[0]]; for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]); out.push(path[path.length - 1]); return out; } function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") { const points = []; const seen = new Set(); const add = (x, y, key, weight = 1) => { x = Math.round(x); y = Math.round(y); if (!insideRect(x, y, rect) || !isLand(world, x, y)) return; const sig = `${x},${y},${key}`; if (seen.has(sig)) return; seen.add(sig); points.push({ x, y, key, weight }); }; for (const key of keys) { for (const path of sourceMap[key] || []) { for (let i = 0; i < path.length; i += 4) { add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1); } } } // Do not use non-network human geography as graph reconnection targets. // Reconnection should repair severed transport graphs, not force every city, // admin center, village, port, or industrial site to remain tied to the old // road topology after a terrain patch. Stations are retained as rail graph // targets because they are part of the transport network. if (mode === "rail") { for (const p of sourceMap.stations || []) add(pointWorldX(world, p), pointWorldY(world, p), "stations", 1.15); } return points; } function rectDistance(x, y, rect) { if (insideRect(x, y, rect)) return 0; const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0; const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0; return Math.hypot(dx, dy); } function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") { const candidates = []; const seen = new Set(); const step = mode === "rail" ? 6 : 4; for (const key of keys) { for (const path of sourceMap[key] || []) { for (let i = 0; i < path.length; i += step) { const x = Math.round(tupleWorldX(world, path[i])); const y = Math.round(tupleWorldY(world, path[i])); if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue; const d = rectDistance(x, y, writeRect); if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue; const sig = `${x},${y},${mode}`; if (seen.has(sig)) continue; seen.add(sig); candidates.push({ x, y, mode, external: true, d }); } } } candidates.sort((a, b) => a.d - b.d); return candidates.slice(0, mode === "rail" ? 18 : 28); } function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null, patchOptions = null) { const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; const targets = preferredTargets.length ? preferredTargets : allTargets; if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; let connectors = 0; let disconnected = 0; let skippedConnectorAnchors = 0; let connectorAttempts = 0; const layer = mode === "rail" ? "branchRailways" : "minorRoads"; sourceMap[layer] ||= []; const seen = new Set(); const maxRange = mode === "rail" ? 220 : 260; const maxAnchors = mode === "rail" ? 10 : 18; const maxTargets = mode === "rail" ? 3 : 3; const searchRect = expandRect(rect, 16, world); const orderedAnchors = (anchors || []) .map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) })) .sort((a, b) => a.patchDistance - b.patchDistance) .slice(0, maxAnchors); skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length); for (const raw of orderedAnchors) { const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); if (!anchorLand) { disconnected++; continue; } const targetList = targets .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) .filter((p) => p.d <= maxRange && p.d >= 6) .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1))) .slice(0, maxTargets); if (!targetList.length) { disconnected++; continue; } let made = false; for (const target of targetList) { const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; if (seen.has(sig)) continue; connectorAttempts++; const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000); if (!path || path.length < 2) continue; seen.add(sig); const outputChunks = patchOptions?.rects ? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34) : [path]; let wroteChunk = false; for (const chunk of outputChunks) { if (!chunk || chunk.length < 2) continue; sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2))); wroteChunk = true; } if (!wroteChunk) continue; connectors++; made = true; break; } if (!made) disconnected++; } return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts }; } function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) { let best = null; let bestD = maxDistance; for (const key of keys) { for (const path of sourceMap[key] || []) { for (let i = 0; i < path.length; i += 5) { const x = Math.round(tupleWorldX(world, path[i])); const y = Math.round(tupleWorldY(world, path[i])); if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue; const d = Math.hypot(point.x - x, point.y - y); if (d < bestD) { bestD = d; best = { x, y, key }; } } } } return best; } function ensureSettlementRoadCoverage(world, sourceMap, rect, patchOptions = null) { const keys = ["nationalRoads", "minorRoads", "premodernRoads"]; const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; sourceMap.minorRoads ||= []; let connectors = 0; let skippedServedSettlements = 0; let checked = 0; const seen = new Set(); for (const key of featureKeys) { const limit = key === "villages" ? 30 : 18; const items = (sourceMap[key] || []) .map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) })) .filter((row) => row.d <= (key === "villages" ? 80 : 150)) .sort((a, b) => a.d - b.d) .slice(0, limit); for (const { p } of items) { checked++; const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); if (!start || !insideRect(start.x, start.y, rect)) continue; const si = worldIndex(world, start.x, start.y); if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) { skippedServedSettlements++; continue; } const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132); if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; const sig = `${start.x},${start.y}:${target.x},${target.y}`; if (seen.has(sig)) continue; seen.add(sig); const path = localPathfind(world, start, target, rect, "road", 36000); if (!path || path.length < 2) continue; const outputChunks = patchOptions?.rects ? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34) : [path]; let wroteChunk = false; for (const chunk of outputChunks) { if (!chunk || chunk.length < 2) continue; sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(chunk, 2))); wroteChunk = true; } if (!wroteChunk) continue; connectors++; } } return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked }; } function transportLayerKeys(mode) { return mode === "rail" ? ["railways", "branchRailways", "ringRailways", "externalRailways"] : ["nationalRoads", "minorRoads", "premodernRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]; } function densifyWorldPath(path, visit) { if (!Array.isArray(path) || path.length < 2) return; for (let k = 0; k < path.length - 1; k++) { const a = path[k]; const b = path[k + 1]; const ax = Math.round(a?.[0] ?? 0); const ay = Math.round(a?.[1] ?? 0); const bx = Math.round(b?.[0] ?? ax); const by = Math.round(b?.[1] ?? ay); const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay))); for (let s = k === 0 ? 0 : 1; s <= steps; s++) { const t = s / steps; visit(Math.round(ax + (bx - ax) * t), Math.round(ay + (by - ay) * t)); } } } function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed = 0) { const keys = transportLayerKeys(mode); const w = rectWidth(graphRect); const h = rectHeight(graphRect); const occ = new Uint8Array(Math.max(0, w * h)); const weight = new Float32Array(Math.max(0, w * h)); const local = (x, y) => (y - graphRect.y0) * w + (x - graphRect.x0); const mark = (x, y, v = 1) => { x = Math.round(x); y = Math.round(y); if (!insideRect(x, y, graphRect)) return; // Graph connectivity is based on rendered transport centerlines. Existing // short bridges are allowed to connect components, but arbitrary candidate // roads far out at sea are not allowed to become graph anchors. const i = worldIndex(world, x, y); if (i < 0) return; // Accept land cells and only very near-shore bridge-like sea cells. // The previous sea-neighbor test accidentally made open water more likely // to become a graph anchor. const nearTransportSurface = isLand(world, x, y) || landNeighbors(world, x, y, 2) >= 8; if (!nearTransportSurface) return; const li = local(x, y); occ[li] = 1; weight[li] = Math.max(weight[li] || 0, v); }; for (const key of keys) { const layerWeight = key.includes("express") ? 2.4 : key.includes("national") ? 2.0 : key.includes("rail") ? 2.1 : 1.0; for (const path of sourceMap?.[key] || []) { const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); densifyWorldPath(worldPath, (x, y) => mark(x, y, layerWeight)); } } const seen = new Uint8Array(occ.length); const comps = []; const dirs = []; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { if (!dx && !dy) continue; if (dx * dx + dy * dy <= 5) dirs.push([dx, dy]); } } for (let li = 0; li < occ.length; li++) { if (!occ[li] || seen[li]) continue; const queue = [li]; const cells = []; seen[li] = 1; let sx = 0, sy = 0, score = 0, patchCells = 0, nearWriteCells = 0, exteriorCells = 0, trunkCells = 0; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const lx = cur % w; const ly = Math.floor(cur / w); const x = graphRect.x0 + lx; const y = graphRect.y0 + ly; sx += x; sy += y; score += Math.max(1, weight[cur] || 1); if ((weight[cur] || 0) >= 1.8) trunkCells++; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); if (patchAffected(x, y, rects, seed, 0.24)) patchCells++; if (rectDistance(x, y, rects.writeRect) <= 8) nearWriteCells++; if (!insideRect(x, y, rects.writeRect)) exteriorCells++; for (const [dx, dy] of dirs) { const nx = lx + dx; const ny = ly + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; const ni = ny * w + nx; if (!occ[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } const size = cells.length; const cx = sx / Math.max(1, size); const cy = sy / Math.max(1, size); const boundaryBonus = exteriorCells > 0 ? Math.min(180, exteriorCells) : 0; comps.push({ id: comps.length, cells, size, cx, cy, minX, minY, maxX, maxY, score: score + boundaryBonus * 1.5 + patchCells * 1.2 + nearWriteCells * 0.8, patchCells, nearWriteCells, exteriorCells, trunkCells, }); } comps.sort((a, b) => b.score - a.score); return { graphRect, width: w, height: h, occ, comps, local }; } function nearestComponentCell(snapshot, comp, target) { let best = null; let bestD = Infinity; for (const li of comp?.cells || []) { const x = snapshot.graphRect.x0 + (li % snapshot.width); const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width); const d = Math.hypot(x - target.x, y - target.y); if (d < bestD) { bestD = d; best = { x, y }; } } return best; } function sampleTransportComponent(snapshot, comp, limit = 96) { const cells = comp?.cells || []; if (!cells.length) return []; const out = []; const seen = new Set(); const addCell = (li) => { if (!Number.isFinite(li) || li < 0) return; const x = snapshot.graphRect.x0 + (li % snapshot.width); const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width); const key = `${x},${y}`; if (seen.has(key)) return; seen.add(key); out.push({ x, y }); }; const step = Math.max(1, Math.floor(cells.length / Math.max(1, limit))); for (let i = 0; i < cells.length; i += step) { addCell(cells[i]); if (out.length >= limit) break; } // Add actual occupied cells nearest to the centroid and bbox edge targets. // Do not use the raw centroid/bbox coordinates as endpoints: they are often // off the centerline, which made the checker roll back otherwise valid graph // repairs because the new path never actually touched the component. const specials = [ { x: Math.round(comp.cx), y: Math.round(comp.cy) }, { x: comp.minX, y: Math.round(comp.cy) }, { x: comp.maxX, y: Math.round(comp.cy) }, { x: Math.round(comp.cx), y: comp.minY }, { x: Math.round(comp.cx), y: comp.maxY }, ]; for (const target of specials) { const nearest = nearestComponentCell(snapshot, comp, target); if (!nearest) continue; const key = `${nearest.x},${nearest.y}`; if (seen.has(key)) continue; seen.add(key); out.push(nearest); } return out; } function transportComponentSamples(snapshot, comp, mode = "road") { snapshot.sampleCache ||= new Map(); const key = `${mode}:${comp?.id ?? -1}`; const cached = snapshot.sampleCache.get(key); if (cached) return cached; const samples = sampleTransportComponent(snapshot, comp, mode === "rail" ? 48 : 72); snapshot.sampleCache.set(key, samples); return samples; } function bestTransportComponentPair(snapshot, compA, compB, maxDistance, mode = "road") { const samplesA = transportComponentSamples(snapshot, compA, mode); const samplesB = transportComponentSamples(snapshot, compB, mode); let best = null; for (const a of samplesA) { for (const b of samplesB) { const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < 3 || d > maxDistance) continue; // Prefer connecting components that actually touch the changed area, but // do not force every component into the single largest component. A local // chain of nearby component merges usually looks more like a natural // regional repair than a star-shaped set of shortcuts to the trunk road. const dirtyBonus = (compA.patchCells || compA.nearWriteCells || compB.patchCells || compB.nearWriteCells) ? 0.78 : 1.0; const exteriorPenalty = compA.exteriorCells > 0 && compB.exteriorCells > 0 && !compA.patchCells && !compB.patchCells ? 1.32 : 1.0; const sizeBonus = 1 / Math.sqrt(Math.max(4, Math.min(compA.size, compB.size))); const score = d * sizeBonus * dirtyBonus * exteriorPenalty; if (!best || score < best.score) best = { a, b, d, score }; } } return best; } function isStrongExternalTransportContext(comp, mode = "road") { if (!comp) return false; // Pure external components are only context. They should be regional trunks or // meaningful nearby networks, not tiny roads in otherwise empty old terrain; // otherwise graph repair grows many spokes toward places with no transport // context. if ((comp.patchCells || comp.nearWriteCells) > 0) return true; const minSize = mode === "rail" ? 18 : 42; if (comp.size < minSize) return false; if ((comp.trunkCells || 0) >= (mode === "rail" ? 3 : 5)) return true; if (comp.exteriorCells >= minSize * 1.8 && comp.score >= minSize * (mode === "rail" ? 1.4 : 1.25)) return true; return false; } function transportComponentTouchesVoid(comp, mode = "road") { if (!comp) return true; return (comp.patchCells || comp.nearWriteCells) > 0 && comp.size < (mode === "rail" ? 8 : 13) && (comp.trunkCells || 0) <= 0; } function buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs) { const limit = mode === "rail" ? 12 : 18; const pool = comps.slice(0, Math.min(comps.length, limit)); const candidates = []; for (let i = 0; i < pool.length; i++) { for (let j = i + 1; j < pool.length; j++) { const aComp = pool[i]; const bComp = pool[j]; // At least one side must be part of the dirty neighborhood. This prevents // broad transport context from welding unrelated external networks while // still allowing internal severed pieces to attach to outside trunks. const aDirty = (aComp.patchCells || aComp.nearWriteCells) > 0; const bDirty = (bComp.patchCells || bComp.nearWriteCells) > 0; if (!aDirty && !bDirty) continue; if (transportComponentTouchesVoid(aComp, mode) && !bDirty) continue; if (transportComponentTouchesVoid(bComp, mode) && !aDirty) continue; if (!aDirty && !isStrongExternalTransportContext(aComp, mode)) continue; if (!bDirty && !isStrongExternalTransportContext(bComp, mode)) continue; const pair = bestTransportComponentPair(snapshot, aComp, bComp, maxDistance, mode); if (!pair) continue; const sig = transportPairSignature(pair.a, pair.b, mode); const rev = transportPairSignature(pair.b, pair.a, mode); if (rejectedPairs.has(sig) || rejectedPairs.has(rev)) continue; const bothPatch = aComp.patchCells > 0 && bComp.patchCells > 0 ? 0.74 : 1.0; const weakExternalPenalty = (!aDirty && (aComp.trunkCells || 0) <= 0) || (!bDirty && (bComp.trunkCells || 0) <= 0) ? 1.55 : 1.0; const oneExternal = (aComp.exteriorCells > 0 || bComp.exteriorCells > 0) ? 1.12 : 1.0; const score = pair.score * bothPatch * oneExternal * weakExternalPenalty; candidates.push({ compA: aComp, compB: bComp, pair, score }); } } candidates.sort((a, b) => a.score - b.score); return candidates; } function transportPairSignature(a, b, mode = "road") { return `${mode}:${Math.round((a?.x || 0) / 3)},${Math.round((a?.y || 0) / 3)}:${Math.round((b?.x || 0) / 3)},${Math.round((b?.y || 0) / 3)}`; } function pathLength(path) { let len = 0; for (let i = 1; i < (path?.length || 0); i++) len += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); return len; } function dedupeWorldPath(path) { const out = []; let last = ""; for (const p of path || []) { const x = Math.round(p?.[0] ?? 0); const y = Math.round(p?.[1] ?? 0); const key = `${x},${y}`; if (key === last) continue; out.push([x, y]); last = key; } return out; } function makeTransportConnectorAllowCell(world, snapshot, rects, seed, strictMask) { const graphRect = snapshot.graphRect; return (x, y, endpoint = false) => { if (!insideRect(x, y, graphRect)) return false; // Field writes are strict-masked elsewhere. For transport repair, allow the // pathfinder to use the broader graph neighborhood so it can reconnect to // realistic regional targets beyond the selected patch. Water remains // blocked by pathCost(), preserving the existing island/strait behavior. return true; }; } function makeTransportConnectorExtraCost(world, rects, seed, mode) { return (x, y) => { const i = worldIndex(world, x, y); if (i < 0) return 999; const alpha = patchAlpha(x, y, rects, seed); const edgePenalty = alpha > 0 ? Math.max(0, 0.35 - alpha) * 1.1 : 0; const roadInf = world.fields.roadInfluence?.[i] || 0; const railInf = world.fields.railInfluence2?.[i] || 0; const transportBonus = mode === "rail" ? railInf * 0.45 : roadInf * 0.35; return Math.max(0, edgePenalty - transportBonus); }; } function transportLineSeaBarrier(world, a, b, mode = "road") { const ax = Math.round(a?.x || 0), ay = Math.round(a?.y || 0); const bx = Math.round(b?.x || 0), by = Math.round(b?.y || 0); const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay))); let seaHits = 0; let longestRun = 0; let run = 0; for (let s = 0; s <= steps; s++) { const t = s / steps; const x = Math.round(ax + (bx - ax) * t); const y = Math.round(ay + (by - ay) * t); const water = !isLand(world, x, y); if (water) { seaHits++; run++; longestRun = Math.max(longestRun, run); } else { run = 0; } } // Keep the existing island/strait behavior: do not spend expensive A* attempts // on pairs that are probably separated by open water. Small coastal gaps are // still allowed, especially for roads, so pre-existing bridge-like contexts // can be repaired without turning islands into a road mesh. const seaRatio = seaHits / Math.max(1, steps + 1); const maxRun = mode === "rail" ? 4 : 6; return longestRun > maxRun || seaRatio > (mode === "rail" ? 0.10 : 0.14); } function writeConnectorPath(world, sourceMap, mode, path, rects, seed, strictMask) { const layer = mode === "rail" ? "branchRailways" : "minorRoads"; sourceMap[layer] ||= []; const startLength = sourceMap[layer].length; const clean = dedupeWorldPath(path); if (clean.length < 2) return { wrote: 0, layer, startLength }; const chunks = [clean]; let wrote = 0; for (const chunk of chunks) { const out = dedupeWorldPath(chunk); if (out.length < 2) continue; // Reject tiny one-cell remnants produced by strict polygon clipping. They // look like specks rather than graph repairs. if (pathLength(out) < (mode === "rail" ? 4 : 3)) continue; sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(out, mode === "rail" ? 3 : 2))); wrote++; } return { wrote, layer, startLength }; } function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, options = {}) { const maxAdds = options.maxAdds ?? (mode === "rail" ? 8 : 16); const maxDistance = options.maxDistance ?? (mode === "rail" ? 280 : 380); const minComponentSize = options.minComponentSize ?? (mode === "rail" ? 3 : 4); const debug = { [`${mode}GraphBeforeComponents`]: 0, [`${mode}GraphAfterComponents`]: 0, [`${mode}GraphConnectorsAdded`]: 0, [`${mode}GraphConnectorsFailed`]: 0, [`${mode}GraphCandidatesConsidered`]: 0, [`${mode}GraphComponentsIgnored`]: 0, }; const eligible = (comp) => { if (!comp || comp.size < minComponentSize) return false; // Broad transport context is intentional, but the worklist is limited to // components that touch the edited neighborhood. Purely external networks // remain as context/targets, not as things to rewire together. return comp.patchCells > 0 || comp.nearWriteCells > 0; }; const contextual = (comp) => { if (!comp || comp.size < minComponentSize) return false; return comp.patchCells > 0 || comp.nearWriteCells > 0 || comp.exteriorCells > 0; }; let snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); debug[`${mode}GraphBeforeComponents`] = snapshot.comps.filter(eligible).length; const rejectedPairs = new Set(); let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8); for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) { const dirtyCount = snapshot.comps.filter(eligible).length; if (dirtyCount <= 1) break; const comps = snapshot.comps.filter(contextual); if (comps.length <= 1) break; const candidates = buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs); debug[`${mode}GraphCandidatesConsidered`] += candidates.length; if (!candidates.length) { debug[`${mode}GraphComponentsIgnored`] += dirtyCount; break; } let accepted = false; for (const candidate of candidates.slice(0, 1)) { const { a, b, d } = candidate.pair; if (transportLineSeaBarrier(world, a, b, mode)) { debug[`${mode}GraphConnectorsFailed`]++; rejectedPairs.add(transportPairSignature(a, b, mode)); rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } const pad = Math.ceil(Math.max(28, Math.min(mode === "rail" ? 72 : 84, d * 0.38 + 16))); const searchRect = expandRect({ x0: Math.floor(Math.min(a.x, b.x)), y0: Math.floor(Math.min(a.y, b.y)), x1: Math.ceil(Math.max(a.x, b.x) + 1), y1: Math.ceil(Math.max(a.y, b.y) + 1), }, pad, world); const boundedSearchRect = { x0: Math.max(searchRect.x0, graphRect.x0), y0: Math.max(searchRect.y0, graphRect.y0), x1: Math.min(searchRect.x1, graphRect.x1), y1: Math.min(searchRect.y1, graphRect.y1), }; const allowCell = makeTransportConnectorAllowCell(world, snapshot, rects, seed, !!rects.strictSelectionMask); const extraCost = makeTransportConnectorExtraCost(world, rects, seed, mode); const searchArea = Math.max(0, rectWidth(boundedSearchRect) * rectHeight(boundedSearchRect)); if (searchArea > (mode === "rail" ? 72000 : 90000)) { debug[`${mode}GraphConnectorsFailed`]++; rejectedPairs.add(transportPairSignature(a, b, mode)); rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } attemptsRemaining--; const path = localPathfind(world, a, b, boundedSearchRect, mode, mode === "rail" ? 5500 : 7000, { allowCell, extraCost }); const clean = dedupeWorldPath(path || []); const routeLen = pathLength(clean); const tooLong = !clean.length || routeLen > d * (mode === "rail" ? 2.65 : 3.05) + (mode === "rail" ? 48 : 70); if (tooLong) { debug[`${mode}GraphConnectorsFailed`]++; rejectedPairs.add(transportPairSignature(a, b, mode)); rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } const beforeEligibleComponents = dirtyCount; const writeResult = writeConnectorPath(world, sourceMap, mode, clean, rects, seed, !!rects.strictSelectionMask); if (!writeResult.wrote) { debug[`${mode}GraphConnectorsFailed`]++; rejectedPairs.add(transportPairSignature(a, b, mode)); rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } const checkSnapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); const afterEligibleComponents = checkSnapshot.comps.filter(eligible).length; if (afterEligibleComponents >= beforeEligibleComponents) { sourceMap[writeResult.layer].splice(writeResult.startLength); debug[`${mode}GraphConnectorsFailed`]++; rejectedPairs.add(transportPairSignature(a, b, mode)); rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } snapshot = checkSnapshot; debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote; accepted = true; break; } if (!accepted) break; } debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length; return debug; } function dedupeAdminCentersByWorldId(kept, generated) { const out = [...kept]; const seen = new Set(); for (const p of kept) { const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); if (id >= 0) seen.add(id); } for (const p of generated) { const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); if (id >= 0 && seen.has(id)) continue; if (id >= 0) seen.add(id); out.push(p); } return out; } function dedupePrefectureRegionsByWorldId(kept, generated) { const out = [...kept]; const seen = new Set(); for (const p of kept) { const id = numericFeatureId(p, ["prefectureRegionId", "id"]); if (id >= 0) seen.add(id); } for (const p of generated) { const id = numericFeatureId(p, ["prefectureRegionId", "id"]); if (id >= 0 && seen.has(id)) continue; if (id >= 0) seen.add(id); out.push(p); } return out; } function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) { let preservedExternalEntities = 0; let regeneratedInternalEntities = 0; let invalidPortsRemoved = 0; for (const key of POINT_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; const kept = []; for (const p of oldArr) { if (!p) continue; const wx = Math.round(pointWorldX(world, p)); const wy = Math.round(pointWorldY(world, p)); const inWrite = insideRect(wx, wy, rects.writeRect); const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0; // Prior patch-generated point layers must be fully replaced when the user // presses Alternative. The new variant has a different alpha noise field, // so using the normal 0.34 removal threshold can leave previous towns, // stations, labels, etc. in the seam band. const removalThreshold = p.patchGenerated ? 0.005 : 0.34; if (!inWrite || alpha < removalThreshold) { kept.push(p); if (!inWrite) preservedExternalEntities++; } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; } } const generated = []; for (const p of candidate[key] || []) { const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping); if (!q) continue; const wx = Math.round(pointWorldX(world, q)); const wy = Math.round(pointWorldY(world, q)); if (!insideRect(wx, wy, rects.writeRect)) continue; if (patchAlpha(wx, wy, rects, seed) < 0.42) continue; generated.push(q); } if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated); else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated); else sourceMap[key] = [...kept, ...generated]; regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length); } return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved }; } function seamTransportInfluence(world, x, y, mode = "road") { const i = worldIndex(world, Math.round(x), Math.round(y)); if (i < 0) return 0; const road = world.fields.roadInfluence?.[i] || 0; const rail = world.fields.railInfluence2?.[i] || 0; return mode === "rail" ? rail : Math.max(road, rail * 0.35); } function trimCandidateTransportVoidEnds(world, chunk, rects, seed, mode = "road") { if (!Array.isArray(chunk) || chunk.length < 2 || (mode !== "road" && mode !== "rail")) return chunk || []; const minInf = mode === "rail" ? 0.038 : 0.052; const edgeAlpha = mode === "rail" ? 0.54 : 0.58; const maxTrim = Math.min(chunk.length - 2, mode === "rail" ? 10 : 14); const shouldTrim = ([x, y]) => { const a = patchAlpha(x, y, rects, seed); if (a <= 0.005 || a > edgeAlpha) return false; if (rectDistance(x, y, rects.writeRect) > 16) return false; return seamTransportInfluence(world, x, y, mode) < minInf; }; let start = 0; let end = chunk.length; let trimmed = 0; while (end - start > 2 && trimmed < maxTrim && shouldTrim(chunk[start])) { start++; trimmed++; } trimmed = 0; while (end - start > 2 && trimmed < maxTrim && shouldTrim(chunk[end - 1])) { end--; trimmed++; } const out = chunk.slice(start, end); return out.length >= 2 ? out : []; } 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))) .map((chunk) => trimCandidateTransportVoidEnds(world, chunk, rects, seed, mode)) .filter((chunk) => chunk.length >= 2); for (const chunk of chunks) { if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) { next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2))); regeneratedPaths++; } } } sourceMap[key] = next; } const strictMask = !!rects.strictSelectionMask; const broadTransportRect = rects.transportReachRect || expandRect(rects.writeRect, 220, world); const transportRect = strictMask ? broadTransportRect : (rects.transportReachRect || rects.repairRect || rects.writeRect); // Terrain/field generation still obeys the strict lasso mask. Transport // repair is intentionally allowed to operate in a much broader neighborhood, // because clipping graph repairs to the selected polygon leaves implausible // dangling regional networks just outside the patch. const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); roadAnchors = roadAnchors.concat(externalRoadAnchors); railAnchors = railAnchors.concat(externalRailAnchors); // Legacy anchor-to-target connectors were not graph-validated and could leave // visible fragments that did not reduce disconnected components. Keep the // anchor collection for diagnostics, but route all actual repair through the // graph reconnection pass below, which rolls back failed candidates. const graphRect = strictMask ? transportRect : transportRect; const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 7 : 6, maxDistance: strictMask ? 310 : 270, maxAttempts: strictMask ? 7 : 5 }); const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 4 : 3, maxDistance: strictMask ? 225 : 195, maxAttempts: strictMask ? 4 : 3 }); return { roadsClipped, railsClipped, regeneratedPaths, roadConnectorsCreated: roadGraph.roadGraphConnectorsAdded || 0, railwayConnectorsCreated: railGraph.railGraphConnectorsAdded || 0, disconnectedRoadComponents: roadAnchors.length, disconnectedRailComponents: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: (roadGraph.roadGraphCandidatesConsidered || 0) + (railGraph.railGraphCandidatesConsidered || 0), skippedServedSettlements: 0, checkedSettlementCoverage: 0, externalRoadAnchors: externalRoadAnchors.length, externalRailAnchors: externalRailAnchors.length, ...roadGraph, ...railGraph, }; } function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) { const field = world.fields[fieldName]; const sea = world.fields.sea; const rects = options.rects || null; const seed = options.seed || 0; const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0; if (!field) return []; const out = []; for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0 || sea?.[i]) continue; const id = field[i]; if (id < 0) continue; const right = worldIndex(world, x + 1, y); if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) { out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]); } const down = worldIndex(world, x, y + 1); if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) { out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]); } } } return out; } function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) { const debug = sourceMap.adminDebug || {}; debug.compartmentBorders ||= []; let added = 0; for (const seg of candidate?.adminDebug?.compartmentBorders || []) { if (!Array.isArray(seg) || seg.length < 2) continue; const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); const mx = (a.x + b.x) * 0.5; const my = (a.y + b.y) * 0.5; if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue; debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); added++; } sourceMap.adminDebug = debug; return added; } function transformCandidateBoundarySegments(world, candidate, key, rects, window, seed = 0, minAlpha = 0.74) { const out = []; if (!candidate || !window || !Array.isArray(candidate[key])) return out; const sea = world.fields?.sea; for (const seg of candidate[key]) { if (!Array.isArray(seg) || seg.length < 2) continue; const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); const mx = Math.round((a.x + b.x) * 0.5); const my = Math.round((a.y + b.y) * 0.5); if (!insideRect(mx, my, rects.writeRect)) continue; if (patchAlpha(mx, my, rects, seed) < minAlpha) continue; const mi = worldIndex(world, mx, my); if (mi >= 0 && sea?.[mi]) continue; out.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); } return out; } function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) { const segmentRect = rects.writeRect || rects.repairRect; const strongAlpha = 0.18; // Keep outside prepared boundaries, but treat the patch interior as a single // replacement zone. Mixing candidate vector boundaries with raster-rebuilt // boundaries without filtering produced double prefecture/municipal lines: // one properly dashed line plus a nearby white/solid-looking twin. const keptByKey = new Map(); for (const key of SEGMENT_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; const kept = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); keptByKey.set(key, kept); } const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.68); const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.70); const rebuiltAdminRaw = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.88 }); const rebuiltPrefRaw = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.90 }); // Candidate boundaries come from the full generator and usually have the same // visual semantics as the initial map. Raster-rebuilt segments are fallback // only, and are rejected when they are near an existing candidate segment. const rebuiltPref = filterSupplementalSegments(candidatePref, rebuiltPrefRaw, 1.18); const patchPref = dedupeSegments([...candidatePref, ...rebuiltPref]); const rebuiltAdmin = filterSupplementalSegments(candidateAdmin, rebuiltAdminRaw, 1.05); let patchAdmin = dedupeSegments([...candidateAdmin, ...rebuiltAdmin]); // A prefecture border is also a municipal border in the raw rasters. Do not // draw both visual layers on the same line; the thicker prefecture styling wins. patchAdmin = removeSegmentsNearSegments(patchAdmin, patchPref, 1.42); const mergedPrefectureBorders = dedupeSegments([...(keptByKey.get("regionalPrefectureBorders") || []), ...patchPref]); sourceMap.adminBorders = removeSegmentsNearSegments(dedupeSegments([...(keptByKey.get("adminBorders") || []), ...patchAdmin]), mergedPrefectureBorders, 1.35); sourceMap.regionalPrefectureBorders = mergedPrefectureBorders; sourceMap.prefectureBorder = dedupeSegments([...(keptByKey.get("prefectureBorder") || []), ...(sourceMap.prefectureBorder || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha))]); const debug = sourceMap.adminDebug || {}; debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); sourceMap.adminDebug = debug; return { adminBordersRebuilt: sourceMap.adminBorders.length, prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, candidateAdminBordersMerged: candidateAdmin.length, candidatePrefectureBordersMerged: candidatePref.length, rasterAdminBordersSuppressed: Math.max(0, rebuiltAdminRaw.length - rebuiltAdmin.length), rasterPrefectureBordersSuppressed: Math.max(0, rebuiltPrefRaw.length - rebuiltPref.length), compartmentBordersRebuilt: debug.compartmentBorders.length, }; } function repairLanduseAndPopulation(world, rects) { const landuse = world.fields.landuse; if (!landuse) return { landUseCellsUpdated: 0 }; let updated = 0; for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const i = worldIndex(world, x, y); if (i < 0) continue; if (world.fields.sea?.[i]) { if (landuse[i] !== (LANDUSE.WATER || 0)) updated++; landuse[i] = LANDUSE.WATER || 0; continue; } if (landuse[i] === (LANDUSE.WATER || 0)) { const slope = world.fields.slope?.[i] || 0; const ag = world.fields.agriculture?.[i] || 0; landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL; updated++; } } } return { landUseCellsUpdated: updated }; } function ensureWorldFloatField(world, name) { const expected = world.width * world.height; if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected); return world.fields[name]; } function clearFieldRect(world, field, rect) { for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { const i = worldIndex(world, x, y); if (i >= 0) field[i] = 0; } } } function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) { const sea = world.fields.sea; const r = Math.max(1, Math.ceil(radius)); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { if (dx * dx + dy * dy > radius * radius) continue; const x = Math.round(cx + dx); const y = Math.round(cy + dy); if (!insideRect(x, y, rect)) continue; const i = worldIndex(world, x, y); if (i < 0 || sea?.[i]) continue; const d = Math.hypot(dx, dy); const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); if (value > field[i]) field[i] = clamp(value); } } } function pathWorldBounds(world, path) { let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const tuple of path || []) { const x = tupleWorldX(world, tuple); const y = tupleWorldY(world, tuple); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); } if (!Number.isFinite(x0)) return null; return { x0, y0, x1: x1 + 1, y1: y1 + 1 }; } function rectsSeparatedByMoreThan(a, b, margin = 0) { return a.x1 + margin < b.x0 || a.x0 - margin > b.x1 || a.y1 + margin < b.y0 || a.y0 - margin > b.y1; } function refreshPatchInfluenceFields(world, sourceMap, rects) { const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; const localRect = rects.repairRect || rects.writeRect; const roadInfluence = ensureWorldFloatField(world, "roadInfluence"); const railInfluence2 = ensureWorldFloatField(world, "railInfluence2"); const stationInfluence = ensureWorldFloatField(world, "stationInfluence"); const villageInfluence = ensureWorldFloatField(world, "villageInfluence"); clearFieldRect(world, roadInfluence, transportRect); clearFieldRect(world, railInfluence2, transportRect); clearFieldRect(world, stationInfluence, transportRect); clearFieldRect(world, villageInfluence, localRect); let roadCellsPainted = 0; let railCellsPainted = 0; let stationCellsPainted = 0; let villageCellsPainted = 0; const paintPathLayer = (keys, field, radius, strength, counterName, rect) => { let painted = 0; for (const key of keys) { for (const path of sourceMap[key] || []) { const bounds = pathWorldBounds(world, path); if (!bounds || rectsSeparatedByMoreThan(bounds, rect, radius + 2)) continue; for (const tuple of path || []) { const x = tupleWorldX(world, tuple); const y = tupleWorldY(world, tuple); if (rectDistance(x, y, rect) > radius + 1) continue; paintInfluenceDisk(world, field, x, y, radius, strength, rect); painted++; } } } if (counterName === "road") roadCellsPainted += painted; if (counterName === "rail") railCellsPainted += painted; }; paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road", transportRect); paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail", transportRect); for (const p of sourceMap.stations || []) { const x = pointWorldX(world, p); const y = pointWorldY(world, p); if (rectDistance(x, y, transportRect) > 8) continue; paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), transportRect); stationCellsPainted++; } for (const p of sourceMap.villages || []) { const x = pointWorldX(world, p); const y = pointWorldY(world, p); if (rectDistance(x, y, localRect) > 10) continue; paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), localRect); villageCellsPainted++; } return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted }; } function countSea(world, rect, rects = null, seed = 0) { let seaCount = 0; let total = 0; const useStrictMask = !!rects?.strictSelectionMask; const scanRect = useStrictMask ? (rects.writeRect || rect) : rect; for (let y = scanRect.y0; y < scanRect.y1; y++) { for (let x = scanRect.x0; x < scanRect.x1; x++) { if (useStrictMask && patchAlpha(x, y, rects, seed) <= 0.005) continue; const i = worldIndex(world, x, y); if (i < 0) continue; total++; if (world.fields.sea?.[i]) seaCount++; } } return { seaCount, total, seaRatio: total ? seaCount / total : 0 }; } function terrainLabel(candidate, fallback) { return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback; } function terrainId(candidate, fallback) { return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback; } function rectKey(rect) { return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-"; } function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, candidateWidth = MAP_W, candidateHeight = MAP_H, mode = "legacy-full-pipeline", contextRect, serial = 0 }) { return [serial, mode, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, candidateWidth | 0, candidateHeight | 0, rectKey(contextRect)].join("|"); } function getPatchCandidateCache(world) { if (!world.patchCandidateCache) world.patchCandidateCache = new Map(); return world.patchCandidateCache; } function rememberPatchCandidate(world, key, candidate) { const cache = getPatchCandidateCache(world); if (cache.has(key)) cache.delete(key); cache.set(key, candidate); while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value); } function getOrGeneratePatchCandidate(world, key, create) { const cache = getPatchCandidateCache(world); if (cache.has(key)) { const candidate = cache.get(key); cache.delete(key); cache.set(key, candidate); return { candidate, cacheHit: true, cacheSize: cache.size }; } const candidate = create(); rememberPatchCandidate(world, key, candidate); return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; } function localCandidateIndex(candidate, x, y) { const width = Math.max(1, Math.floor(candidate?.width || MAP_W)); const height = Math.max(1, Math.floor(candidate?.height || MAP_H)); if (x < 0 || y < 0 || x >= width || y >= height) return -1; return y * width + x; } function localCandidateInside(candidate, x, y) { return localCandidateIndex(candidate, x, y) >= 0; } function terrainCellScore(candidate, x, y, seed = 0) { const i = localCandidateIndex(candidate, x, y); if (i < 0 || candidate.sea?.[i]) return -Infinity; const plain = candidate.plain?.[i] || 0; const agri = candidate.agriculture?.[i] || 0; const coast = candidate.coastalLowland?.[i] || 0; const slope = candidate.slope?.[i] || 0; const elev = candidate.elevation?.[i] || 0; const river = candidate.river?.[i] || 0; const n = valueNoise((candidate.originX || 0) + x, (candidate.originY || 0) + y, seed ^ 0x97d4a7c1, 13) - 0.5; return plain * 1.5 + agri * 1.2 + coast * 0.42 + river * 0.34 - slope * 1.15 - Math.max(0, elev - 0.58) * 0.8 + n * 0.12; } function chooseCandidateCenters(candidate, seed, count, minDistance, stride = 2) { const candidates = []; const width = candidate.width || MAP_W; const height = candidate.height || MAP_H; for (let y = 2; y < height - 2; y += stride) { for (let x = 2; x < width - 2; x += stride) { const score = terrainCellScore(candidate, x, y, seed); if (score > 0.12) candidates.push({ x, y, score }); } } let picked = pickEntities(candidates, { max: count, minDistance, threshold: 0.12, seed: seed ^ 0x5f356495, jitter: 0.08 }); if (!picked.length) { for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const i = localCandidateIndex(candidate, x, y); if (i >= 0 && !candidate.sea?.[i]) picked.push({ x, y, score: 0.1 }); if (picked.length >= Math.max(1, Math.min(4, count))) break; } if (picked.length) break; } } return picked; } function nearestCandidateSeedIndex(seeds, x, y, candidate, seed = 0, spacingPenalty = 1) { let best = -1; let bestScore = Infinity; for (let k = 0; k < seeds.length; k++) { const s = seeds[k]; const dx = x - s.x; const dy = y - s.y; const noise = valueNoise((candidate.originX || 0) + x + k * 11, (candidate.originY || 0) + y - k * 7, seed ^ 0xe2c1b3f5, 24) - 0.5; const d = dx * dx + dy * dy + noise * 42 * spacingPenalty - (s.score || 0) * 8; if (d < bestScore) { bestScore = d; best = k; } } return best; } function deriveVariableCandidateAdmin(candidate, seed = 0) { const width = candidate.width || MAP_W; const height = candidate.height || MAP_H; const size = width * height; let landCount = 0; for (let i = 0; i < size; i++) if (!candidate.sea?.[i]) landCount++; const adminCount = clamp(Math.round(landCount / 260), 4, 72); const prefCount = clamp(Math.round(landCount / 2100), 1, 8); const adminSeeds = chooseCandidateCenters(candidate, seed ^ 0x209f3d91, adminCount, Math.max(7, Math.round(Math.sqrt(size / Math.max(1, adminCount)) * 0.75)), 2); const prefSeeds = pickEntities( adminSeeds.map((p) => ({ ...p, score: (p.score || 0) + valueNoise((candidate.originX || 0) + p.x, (candidate.originY || 0) + p.y, seed ^ 0x06a09e66, 34) * 0.18 })), { max: prefCount, minDistance: Math.max(20, Math.round(Math.sqrt(size / Math.max(1, prefCount)) * 0.72)), threshold: -1, seed: seed ^ 0xb0f7c3d2, jitter: 0.04 } ); if (!prefSeeds.length && adminSeeds.length) prefSeeds.push(adminSeeds[0]); const adminId = new Int32Array(size); adminId.fill(-1); const municipalityId = new Int32Array(size); municipalityId.fill(-1); const prefectureRegionId = new Int32Array(size); prefectureRegionId.fill(-1); const populationDensity = new Float32Array(size); const landuse = new Uint8Array(size); const settlementScore = new Float32Array(size); const municipalityToPrefectureId = {}; const adminBest = new Map(); const prefBest = new Map(); const adminToPref = new Map(); for (let a = 0; a < adminSeeds.length; a++) { const p = adminSeeds[a]; const pref = nearestCandidateSeedIndex(prefSeeds, p.x, p.y, candidate, seed ^ 0xc314d1ba, 0.5); adminToPref.set(a, Math.max(0, pref)); municipalityToPrefectureId[a] = Math.max(0, pref); } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x; if (candidate.sea?.[i]) { landuse[i] = LANDUSE.RURAL; continue; } const a = nearestCandidateSeedIndex(adminSeeds, x, y, candidate, seed ^ 0x86ab1c2d, 1.0); const pref = adminToPref.get(a) ?? 0; adminId[i] = a; municipalityId[i] = a; prefectureRegionId[i] = pref; const score = terrainCellScore(candidate, x, y, seed); const urban = clamp((score - 0.20) * 0.95 + valueNoise((candidate.originX || 0) + x, (candidate.originY || 0) + y, seed ^ 0x4d765f31, 18) * 0.18); settlementScore[i] = clamp(score * 0.55 + urban * 0.45); populationDensity[i] = clamp(urban * 0.62 + (candidate.plain?.[i] || 0) * 0.16); landuse[i] = candidate.slope?.[i] > 0.34 || candidate.elevation?.[i] > 0.58 ? LANDUSE.FOREST : urban > 0.72 ? LANDUSE.OLD_URBAN : urban > 0.48 ? LANDUSE.SUBURB : (candidate.agriculture?.[i] || 0) > 0.38 ? LANDUSE.FARMLAND : LANDUSE.RURAL; const curAdmin = adminBest.get(a); if (!curAdmin || score > curAdmin.score) adminBest.set(a, { x, y, score, adminId: a, municipalityId: a, prefectureRegionId: pref }); const curPref = prefBest.get(pref); if (!curPref || score > curPref.score) prefBest.set(pref, { x, y, score, id: pref, prefectureRegionId: pref }); } } const adminCenters = [...adminBest.values()].map((p, idx) => ({ x: p.x, y: p.y, score: p.score, adminId: p.adminId, municipalityId: p.municipalityId, adminNumericId: p.adminId, prefectureRegionId: p.prefectureRegionId, population: Math.round(900 + clamp(p.score) * 19000 + hash2(p.x, p.y, seed ^ 0x1523) * 6000), kind: "Municipal Center", patchGenerated: true, })); const prefectureRegions = [...prefBest.values()].map((p) => ({ x: p.x, y: p.y, id: p.id, prefectureRegionId: p.prefectureRegionId, population: Math.round(60000 + clamp(p.score) * 260000), kind: "Prefecture Region", patchGenerated: true, })); const urbanCandidates = adminCenters .map((p) => ({ ...p, score: p.score + (p.population || 0) / 50000 })) .sort((a, b) => b.score - a.score); const topLimit = Math.max(1, Math.min(8, Math.round(landCount / 1400))); const modernCities = urbanCandidates.slice(0, topLimit).map((p, idx) => ({ ...p, kind: idx === 0 && landCount > 7000 ? "Regional Center" : "City", population: Math.min(idx === 0 ? 110000 : 68000, Math.round((p.population || 12000) * (idx === 0 ? 4.2 : 2.5))), })); const markets = urbanCandidates.slice(Math.max(1, topLimit - 1), topLimit + Math.max(2, Math.round(landCount / 1800))).map((p) => ({ ...p, kind: "Market Town", population: Math.round((p.population || 8000) * 0.65) })); const villages = chooseCandidateCenters(candidate, seed ^ 0x51b1, Math.max(8, Math.min(70, Math.round(landCount / 180))), 7, 3) .map((p) => ({ x: p.x, y: p.y, score: p.score, kind: "Village", population: Math.round(120 + clamp(p.score) * 1500), patchGenerated: true })); const ports = chooseCandidatePorts(candidate, seed, Math.max(2, Math.min(18, Math.round(landCount / 900)))); return { adminId, municipalityId, prefectureRegionId, populationDensity, landuse, settlementScore, adminCenters, prefectureRegions, modernCities, markets, villages, ports, municipalityToPrefectureId }; } function candidateSeaNeighbors(candidate, x, y, radius = 1) { let n = 0; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { if (!dx && !dy) continue; const i = localCandidateIndex(candidate, x + dx, y + dy); if (i >= 0 && candidate.sea?.[i]) n++; } } return n; } function chooseCandidatePorts(candidate, seed, maxPorts = 8) { const width = candidate.width || MAP_W; const height = candidate.height || MAP_H; const out = []; for (let y = 2; y < height - 2; y += 2) { for (let x = 2; x < width - 2; x += 2) { const i = localCandidateIndex(candidate, x, y); if (i < 0 || candidate.sea?.[i]) continue; const seas = candidateSeaNeighbors(candidate, x, y, 2); if (seas < 3) continue; const score = seas * 0.12 + (candidate.coastalLowland?.[i] || 0) * 0.7 + (candidate.plain?.[i] || 0) * 0.25 - (candidate.slope?.[i] || 0) * 0.8; if (score > 0.42) out.push({ x, y, score, kind: "Port", population: 0, patchGenerated: true }); } } return pickEntities(out, { max: maxPorts, minDistance: 12, threshold: 0.42, seed: seed ^ 0x7303, jitter: 0.03 }); } function candidateLineCrossesOpenWater(candidate, a, b, maxWater = 2) { const dx = b.x - a.x, dy = b.y - a.y; const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy))); let water = 0; for (let k = 0; k <= steps; k++) { const t = k / steps; const x = Math.round(a.x + dx * t); const y = Math.round(a.y + dy * t); const i = localCandidateIndex(candidate, x, y); if (i < 0 || candidate.sea?.[i]) water++; if (water > maxWater) return true; } return false; } function nearestCandidateLand(candidate, x, y, radius = 5) { if (localCandidateInside(candidate, x, y) && !candidate.sea?.[localCandidateIndex(candidate, x, y)]) return { x, y }; for (let r = 1; r <= radius; r++) { let best = null, 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; const i = localCandidateIndex(candidate, xx, yy); if (i < 0 || candidate.sea?.[i]) continue; const score = Math.hypot(xx - x, yy - y) + (candidate.slope?.[i] || 0) * 2; if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; } } } if (best) return best; } return null; } function buildCandidateRoute(candidate, a, b, seed = 0) { if (!a || !b) return []; if (candidateLineCrossesOpenWater(candidate, a, b, 2)) return []; const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.hypot(dx, dy); const steps = Math.max(2, Math.ceil(dist / 3)); const out = []; let prev = null; for (let k = 0; k <= steps; k++) { const t = k / steps; const curve = Math.sin(t * Math.PI) * (valueNoise((candidate.originX || 0) + a.x + b.x, (candidate.originY || 0) + a.y + b.y, seed ^ 0x8ac1, 19) - 0.5) * Math.min(9, dist * 0.10); const nx = -dy / Math.max(1, dist); const ny = dx / Math.max(1, dist); let x = Math.round(a.x + dx * t + nx * curve); let y = Math.round(a.y + dy * t + ny * curve); const land = nearestCandidateLand(candidate, x, y, 4); if (!land) return []; x = land.x; y = land.y; if (!prev || Math.hypot(prev[0] - x, prev[1] - y) >= 1.5) { out.push([x, y]); prev = [x, y]; } } return out.length >= 2 ? out : []; } function deriveVariableCandidateTransport(candidate, seed = 0) { const centers = [...(candidate.modernCities || []), ...(candidate.markets || [])] .filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)) .sort((a, b) => (b.population || 0) - (a.population || 0)); const nationalRoads = []; const minorRoads = []; const railways = []; const stations = []; const usedPairs = new Set(); const connect = (arr, a, b, salt) => { const key = a && b ? `${Math.min(a.x, b.x)},${Math.min(a.y, b.y)}:${Math.max(a.x, b.x)},${Math.max(a.y, b.y)}` : ""; if (!key || usedPairs.has(key)) return false; usedPairs.add(key); const path = buildCandidateRoute(candidate, a, b, seed ^ salt); if (path.length >= 2) { arr.push(path); return true; } return false; }; for (let i = 0; i < Math.min(centers.length - 1, 10); i++) connect(nationalRoads, centers[i], centers[i + 1], 0x1100 + i); for (let i = 0; i < Math.min(centers.length, 12); i++) { const a = centers[i]; const nearest = centers .filter((p) => p !== a) .map((p) => ({ p, d: Math.hypot(p.x - a.x, p.y - a.y) })) .filter((r) => r.d >= 9 && r.d < 62) .sort((x, y) => x.d - y.d) .slice(0, 2); for (const r of nearest) connect(minorRoads, a, r.p, 0x2200 + i); } if (centers.length >= 3) { for (let i = 0; i < Math.min(centers.length - 1, 4); i++) { if (Math.hypot(centers[i].x - centers[i + 1].x, centers[i].y - centers[i + 1].y) < 72 && connect(railways, centers[i], centers[i + 1], 0x3300 + i)) { stations.push({ x: centers[i].x, y: centers[i].y, kind: "Station", patchGenerated: true }); stations.push({ x: centers[i + 1].x, y: centers[i + 1].y, kind: "Station", patchGenerated: true }); } } } return { nationalRoads, minorRoads, railways, branchRailways: [], stations }; } function extractVariableBoundarySegments(candidate, fieldName, options = {}) { const field = candidate?.[fieldName]; const sea = candidate?.sea; if (!field) return []; const width = candidate.width || MAP_W; const height = candidate.height || MAP_H; const segments = []; const minBothLand = options.minBothLand !== false; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x; if (field[i] < 0 || (minBothLand && sea?.[i])) continue; if (x + 1 < width) { const ni = y * width + x + 1; if (field[ni] >= 0 && field[ni] !== field[i] && (!minBothLand || !sea?.[ni])) segments.push([[x + 1, y], [x + 1, y + 1]]); } if (y + 1 < height) { const ni = (y + 1) * width + x; if (field[ni] >= 0 && field[ni] !== field[i] && (!minBothLand || !sea?.[ni])) segments.push([[x, y + 1], [x + 1, y + 1]]); } } } return segments; } function generateVariablePatchCandidate(seed, options = {}) { const window = options.window; const width = Math.max(32, Math.floor(window?.width || MAP_W)); const height = Math.max(32, Math.floor(window?.height || MAP_H)); const originX = Math.floor(window?.originX ?? Math.round((window?.worldCenterX || 0) - (width - 1) / 2)); const originY = Math.floor(window?.originY ?? Math.round((window?.worldCenterY || 0) - (height - 1) / 2)); const terrain = generateTerrainRect({ ...options, seed, originX, originY, width, height, variant: options.variant || 0, worldNative: true, patchMode: true, name: "variable-patch-candidate", }); const candidate = { ...terrain, originX, originY, width, height, size: width * height }; const admin = deriveVariableCandidateAdmin(candidate, seed ^ 0x43a1b991); Object.assign(candidate, admin); const transport = deriveVariableCandidateTransport(candidate, seed ^ 0x7408d3a9); Object.assign(candidate, transport); candidate.prefectureMask = new Uint8Array(candidate.size); candidate.landMask = new Uint8Array(candidate.size); for (let i = 0; i < candidate.size; i++) { candidate.landMask[i] = candidate.sea?.[i] ? 0 : 1; candidate.prefectureMask[i] = candidate.landMask[i]; } candidate.adminBorders = extractVariableBoundarySegments(candidate, "adminId", { minBothLand: true }); candidate.regionalPrefectureBorders = extractVariableBoundarySegments(candidate, "prefectureRegionId", { minBothLand: true }); candidate.prefectureBorder = []; candidate.mainRivers ||= []; candidate.tributaryRivers ||= []; candidate.smallStreams ||= []; candidate.riverPaths ||= [...(candidate.mainRivers || []), ...(candidate.tributaryRivers || []), ...(candidate.smallStreams || [])]; candidate.generationTimings = [ { key: "terrainRect", label: "Variable rect terrain", ms: 0 }, { key: "localAdmin", label: "Variable local admin and settlements", ms: 0 }, { key: "localTransport", label: "Variable local transport", ms: 0 }, ]; candidate.generationContext = { ...(candidate.generationContext || {}), originX, originY, width, height, variant: options.variant || 0, worldNative: true, variableCandidate: true }; candidate.terrainDebug = { ...(candidate.terrainDebug || {}), variablePatchCandidate: true, width, height, originX, originY }; return candidate; } function generatePatchCandidate(seed, options = {}) { if (options?.window?.variable) return generateVariablePatchCandidate(seed, options); return generateMap(seed, options); } function clonePointForPatch(point) { return point ? { ...point } : point; } function captureStrictMetadataSnapshot(world, sourceMap, rects, seed = 0) { if (!rects?.strictSelectionMask || !sourceMap) return null; const byLayer = new Map(); for (const key of ["adminCenters", "prefectureRegions"]) { const arr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; const outside = []; for (const p of arr) { const x = Math.round(pointWorldX(world, p)); const y = Math.round(pointWorldY(world, p)); if (patchAlpha(x, y, rects, seed) <= 0.005) outside.push(clonePointForPatch(p)); } byLayer.set(key, outside); } return { byLayer }; } function metadataIdForLayer(point, key) { if (key === "prefectureRegions") return numericFeatureId(point, ["prefectureRegionId", "id", "featureId"]); return numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); } function restoreOutsideStrictMetadata(world, sourceMap, rects, snapshot, seed = 0) { if (!snapshot || !rects?.strictSelectionMask || !sourceMap) return { strictMetadataPointsRestored: 0 }; let strictMetadataPointsRestored = 0; for (const key of ["adminCenters", "prefectureRegions"]) { const oldOutside = snapshot.byLayer.get(key) || []; if (!oldOutside.length) continue; const oldById = new Map(); for (const p of oldOutside) { const id = metadataIdForLayer(p, key); if (id >= 0 && !oldById.has(id)) oldById.set(id, clonePointForPatch(p)); } const existing = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; const usedOldIds = new Set(); const next = []; for (const p of existing) { const id = metadataIdForLayer(p, key); const old = id >= 0 ? oldById.get(id) : null; if (old) { // If an existing administrative label/center was outside the lasso // before the patch, keep it anchored there by ID. The global coherence // pass may otherwise move it into the selected area even though the // user did not select the old center itself. next.push(clonePointForPatch(old)); usedOldIds.add(id); strictMetadataPointsRestored++; continue; } next.push(p); } for (const [id, old] of oldById) { if (usedOldIds.has(id)) continue; const present = next.some((p) => metadataIdForLayer(p, key) === id); if (!present) { next.push(clonePointForPatch(old)); strictMetadataPointsRestored++; } } sourceMap[key] = next; } return { strictMetadataPointsRestored }; } function addInvalidatedRect(world, rect) { if (!rect) return; const normalized = normalizeRect(rect); if (!normalized || rectArea(normalized) <= 0) return; const key = rectKey(normalized); const list = world.invalidatedRects || (world.invalidatedRects = []); if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized }); } function repairPatchTerrain(world, rects, seed, seaLevel) { const terrainSeamDebug = featherTerrainSeam(world, rects, seed); const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel); const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true }); const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); const maskDebug = repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); return { terrainSeamDebug, elevationCliffDebug, waterDebug, waterComponentDebug, residualSeaDebug, waterElevationDebug, maskDebug, }; } function repairPatchAdministration(world, sourceMap, rects, seed, adminIdMapping, strictFieldSnapshot) { const landDebug = repairLanduseAndPopulation(world, rects); const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, adminIdMapping, seed); const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, adminIdMapping); const municipalCoherence = reconcileMunicipalMetadata({ adminId: world.fields.adminId, municipalityId: world.fields.municipalityId, prefectureRegionId: world.fields.prefectureRegionId, sea: world.fields.sea, adminCenters: sourceMap.adminCenters || [], municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, fields: world.fields, width: world.width, height: world.height, pointOffsetX: world.originX || 0, pointOffsetY: world.originY || 0, seed, }); sourceMap.adminCenters = municipalCoherence.adminCenters; sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; const prefectureCoherence = refreshPrefectureRegionsMetadata({ prefectureRegionId: world.fields.prefectureRegionId, sea: world.fields.sea, existing: sourceMap.prefectureRegions || [], adminCenters: sourceMap.adminCenters || [], fields: world.fields, width: world.width, height: world.height, pointOffsetX: world.originX || 0, pointOffsetY: world.originY || 0, }); sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); sourceMap.adminDebug = { ...(sourceMap.adminDebug || {}), municipalCoherence: municipalCoherence.debug, prefectureMetadataCoherence: prefectureCoherence.debug, }; return { landDebug, finalAdminCoverageDebug, adminTopologyDebug, strictMaskDebugPreCoherence, strictMaskDebugPostCoherence, sourceAdminMetadataUpdated, municipalCoherence, prefectureCoherence, }; } function repairPatchTransport(world, sourceMap, candidate, rects, window, seed) { return mergePathLayers(world, sourceMap, candidate, rects, window, seed); } export function generatePatch(world, userRectInput, options = {}) { const validation = validatePatchRect(userRectInput, world); if (!validation.ok) return { ok: false, ...validation }; const rects = buildPatchRects(validation.rect, world); const terrainType = options.terrainType || "auto"; const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0; const strictFieldSnapshot = captureStrictSelectionFieldSnapshot(world, rects, seed); const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; const candidateWindow = buildPatchCandidateWindow(rects, world, options); rects.candidateWindow = candidateWindow; const candidateOriginX = Math.round(candidateWindow.originX ?? (candidateWindow.worldCenterX - candidateWindow.sourceCenterX)); const candidateOriginY = Math.round(candidateWindow.originY ?? (candidateWindow.worldCenterY - candidateWindow.sourceCenterY)); const candidateWidth = Math.max(1, Math.floor(candidateWindow.width || MAP_W)); const candidateHeight = Math.max(1, Math.floor(candidateWindow.height || MAP_H)); const patchTimer = createPatchTimer(); const patchGenerationMode = candidateWindow.variable ? "variable-rect-candidate" : "legacy-full-pipeline"; const cacheKey = patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, candidateWidth, candidateHeight, mode: patchGenerationMode, contextRect: rects.contextRect, serial: world.patchGenerationSerial || 0, }); const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generatePatchCandidate(seed, { terrainType, legacyTerrain: !candidateWindow.variable, worldNative: true, variant, originX: candidateOriginX, originY: candidateOriginY, width: candidateWidth, height: candidateHeight, window: candidateWindow, contextRect: rects.contextRect, boundaryWorld: world, patchMode: true, topCenterSuppression: 0.72, onProgress: () => {}, })); patchTimer.mark("candidate", cacheHit ? (candidateWindow.variable ? "Variable candidate generation (cached)" : "Full candidate generation (cached)") : (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation")); getPatchAlphaCache(rects, seed); getPatchSourceIndexCache(rects, candidateWindow); const patchContext = createPatchContext({ world, rects, candidateWindow, seed, patchAlpha, sourceIndexForWorld, worldIndex, }); const sourceMap = world.sourceMap || (world.sourceMap = {}); const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel, patchContext); patchTimer.mark("fields", "Field copy and alpha blend"); const terrainDebug = repairPatchTerrain(world, rects, seed, seaLevel); patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); patchTimer.mark("points", "Point merge"); const pathDebug = repairPatchTransport(world, sourceMap, candidate, rects, fieldDebug.window, seed); patchTimer.mark("paths", "Path merge and connector repair"); const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects); patchTimer.mark("influence", "Influence refresh"); const adminDebug = repairPatchAdministration(world, sourceMap, rects, seed, fieldDebug.adminIdMapping, strictFieldSnapshot); const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true }); if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) { repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); } const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed); const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window); const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); patchTimer.mark("segments", "Boundary and debug segment merge"); sanitizeExistingLogistics(sourceMap); patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); const patchTimings = patchTimer.timings; const { terrainSeamDebug, elevationCliffDebug, waterDebug, waterComponentDebug, residualSeaDebug, waterElevationDebug, maskDebug, } = terrainDebug; const { landDebug, finalAdminCoverageDebug, adminTopologyDebug, strictMaskDebugPreCoherence, strictMaskDebugPostCoherence, sourceAdminMetadataUpdated, municipalCoherence, prefectureCoherence, } = adminDebug; const seaStats = countSea(world, rects.coreRect, rects, seed); const label = terrainLabel(candidate, terrainType); const id = terrainId(candidate, terrainType); const patchStageDebug = { candidate: { cacheHit, cacheSize, patchGenerationMode, candidateAreaRatio: candidateWindow.areaRatio ?? 1 }, fields: fieldDebug, terrain: terrainDebug, points: pointDebug, transport: pathDebug, influence: influenceDebug, admin: adminDebug, segments: { ...segmentDebug, candidateCompartmentSegmentsAdded }, cleanup: { logisticsLabelsMigrated, strictMetadataDebug, residualSeaStrictDebug }, }; const humanGeography = { ok: true, patchStages: patchStageDebug, modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, ...pointDebug, ...pathDebug, ...influenceDebug, adminCellsReassigned: fieldDebug.adminCellsReassigned, adminIdMapping: fieldDebug.adminIdMappingDebug, sourceAdminMetadataUpdated, ...finalAdminCoverageDebug, ...adminTopologyDebug, strictMaskCellsRestored: (strictMaskDebugPreCoherence.strictMaskCellsRestored || 0) + (strictMaskDebugPostCoherence.strictMaskCellsRestored || 0), strictMaskValuesRestored: (strictMaskDebugPreCoherence.strictMaskValuesRestored || 0) + (strictMaskDebugPostCoherence.strictMaskValuesRestored || 0), ...strictMetadataDebug, humanLandCellsRestored: fieldDebug.humanLandCellsRestored || 0, humanLandFeatureMaskCells: fieldDebug.humanLandFeatureMaskCells || 0, finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0, municipalCoherence: municipalCoherence.debug, prefectureMetadataCoherence: prefectureCoherence.debug, continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, terrainSeamAdjustmentSamples: fieldDebug.terrainSeamAdjustmentSamples || 0, terrainSeamElevationOffset: fieldDebug.terrainSeamElevationOffset || 0, terrainSeamElevationTiltX: fieldDebug.terrainSeamElevationTiltX || 0, terrainSeamElevationTiltY: fieldDebug.terrainSeamElevationTiltY || 0, adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, ...terrainSeamDebug, ...elevationCliffDebug, ...waterComponentDebug, ...residualSeaDebug, residualSeaPostRestorePatchesFilled: residualSeaStrictDebug.residualSeaPatchesFilled, residualSeaPostRestoreCellsFilled: residualSeaStrictDebug.residualSeaCellsFilled, ...waterElevationDebug, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, displayMaskUpdated: maskDebug.displayMaskUpdated || 0, logisticsLabelsMigrated, candidateCacheHit: cacheHit, candidateCacheSize: cacheSize, ...segmentDebug, candidateCompartmentSegmentsAdded, }; const record = { ...rects.coreRect, coreRect: { ...rects.coreRect }, selectionShape: rects.selectionShape ? { kind: rects.selectionShape.kind || 'lasso', areaCells: rects.selectionShape.areaCells || 0, polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), } : null, writeRect: { ...rects.writeRect }, repairRect: { ...rects.repairRect }, contextRect: { ...rects.contextRect }, transportReachRect: { ...rects.transportReachRect }, transportReachMargin: rects.transportReachMargin, blendRect: { ...rects.blendRect }, terrainType: id, label, seed, variant, candidateOriginX, candidateOriginY, candidateWidth, candidateHeight, candidateAreaRatio: candidateWindow.areaRatio ?? 1, patchGenerationMode, patchTimings, updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped + residualSeaDebug.residualSeaCellsFilled + residualSeaStrictDebug.residualSeaCellsFilled, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, adminIdMapping: fieldDebug.adminIdMappingDebug, seaRatio: seaStats.seaRatio, humanGeography, createdAt: Date.now(), }; world.generatedRects = [...(world.generatedRects || []), record]; addInvalidatedRect(world, rects.writeRect); addInvalidatedRect(world, rects.transportReachRect); world.lastPatchResult = record; world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1; return { ok: true, validation, rects: { ...rects, selectionShape: rects.selectionShape ? { kind: rects.selectionShape.kind || 'lasso', areaCells: rects.selectionShape.areaCells || 0, polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), } : null, }, terrainType: id, label, seed, variant, candidateOriginX, candidateOriginY, candidateWidth, candidateHeight, candidateAreaRatio: candidateWindow.areaRatio ?? 1, patchGenerationMode, patchTimings, updatedCells: record.updatedCells, terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, coastCellsChanged: record.coastCellsChanged, naturalRegionsUpdated: record.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, adminIdMapping: fieldDebug.adminIdMappingDebug, seaRatio: seaStats.seaRatio, humanGeography, }; }