diff --git a/app.js b/app.js index 6eab3ad..0749340 100644 --- a/app.js +++ b/app.js @@ -70,6 +70,10 @@ const dragState = { startClientY: 0, startCameraX: 0, startCameraY: 0, + lastClientX: 0, + lastClientY: 0, + panRemainderX: 0, + panRemainderY: 0, selectStart: null, selectEnd: null, selectPath: null, @@ -241,15 +245,34 @@ function selectionPathToShape(points) { }; } +function syncSelectionSvgToCanvas() { + if (!selectionSvgEl || !canvas) return null; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, rect.width || canvas.clientWidth || canvas.width || 1); + const height = Math.max(1, rect.height || canvas.clientHeight || canvas.height || 1); + selectionSvgEl.style.left = `${canvas.offsetLeft}px`; + selectionSvgEl.style.top = `${canvas.offsetTop}px`; + selectionSvgEl.style.width = `${width}px`; + selectionSvgEl.style.height = `${height}px`; + selectionSvgEl.setAttribute("width", String(width)); + selectionSvgEl.setAttribute("height", String(height)); + selectionSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); + return { width, height }; +} + function drawSelectionSvg(points, invalid = false) { if (!selectionSvgEl) return; - if (!points || points.length < 3) { + const bounds = syncSelectionSvgToCanvas(); + if (!bounds || !points || points.length < 3) { selectionSvgEl.style.display = "none"; selectionSvgEl.innerHTML = ""; return; } - const pts = points.map((p) => `${p.x},${p.y}`).join(" "); - selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`); + const pts = points.map((p) => { + const x = Math.min(Math.max(p.x, 0), bounds.width); + const y = Math.min(Math.max(p.y, 0), bounds.height); + return `${x},${y}`; + }).join(" "); selectionSvgEl.innerHTML = ``; selectionSvgEl.style.display = "block"; selectionSvgEl.classList.toggle("invalid", !!invalid); @@ -375,6 +398,8 @@ function clearDragMode() { dragState.mode = null; dragState.pointerId = null; dragState.pendingCamera = null; + dragState.panRemainderX = 0; + dragState.panRemainderY = 0; if (dragState.panRaf != null) { cancelAnimationFrame(dragState.panRaf); dragState.panRaf = null; @@ -392,7 +417,10 @@ function schedulePanRedraw(camera) { dragState.pendingCamera = null; if (next.x === state.camera.x && next.y === state.camera.y) return; state.camera = next; - redraw({ fastTerrain: true }); + // Do not auto-expand the backing world while a pointer drag is active. + // Expansion shifts world coordinates; doing it mid-drag invalidates the + // pointer-to-camera baseline and can make the viewport appear to jump. + redraw({ fastTerrain: true, allowWorldExpand: false }); }); } @@ -432,6 +460,10 @@ function handleMapPointerDown(event) { dragState.startClientY = event.clientY; dragState.startCameraX = state.camera.x; dragState.startCameraY = state.camera.y; + dragState.lastClientX = event.clientX; + dragState.lastClientY = event.clientY; + dragState.panRemainderX = 0; + dragState.panRemainderY = 0; tooltipEl?.classList.remove("visible"); if (event.button === 0) { @@ -455,14 +487,34 @@ function handleMapPointerMove(event) { tooltipEl?.classList.remove("visible"); if (dragState.mode === "pan") { - const cellSize = Math.max(1, displayedCellSize()); - const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize); - const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize); - const nextCamera = clampCameraForView({ - x: dragState.startCameraX - dxCells, - y: dragState.startCameraY - dyCells, - }, viewportSizeForZoom(state.zoom)); - schedulePanRedraw(nextCamera); + const rect = canvas.getBoundingClientRect(); + const maxDeltaX = Math.max(320, rect.width * 0.72); + const maxDeltaY = Math.max(240, rect.height * 0.72); + const rawDx = event.clientX - dragState.lastClientX; + const rawDy = event.clientY - dragState.lastClientY; + dragState.lastClientX = event.clientX; + dragState.lastClientY = event.clientY; + + // Pointer capture can occasionally deliver a stale/outlier coordinate after + // a tab switch, resize, context-menu gesture, or OS-level event hiccup. A + // single implausibly large delta would otherwise become a large camera jump. + if (Math.abs(rawDx) <= maxDeltaX && Math.abs(rawDy) <= maxDeltaY) { + const cellSize = Math.max(1, displayedCellSize()); + const totalX = dragState.panRemainderX + rawDx / cellSize; + const totalY = dragState.panRemainderY + rawDy / cellSize; + const dxCells = totalX < 0 ? Math.ceil(totalX) : Math.floor(totalX); + const dyCells = totalY < 0 ? Math.ceil(totalY) : Math.floor(totalY); + dragState.panRemainderX = totalX - dxCells; + dragState.panRemainderY = totalY - dyCells; + if (dxCells || dyCells) { + const base = dragState.pendingCamera || state.camera; + const nextCamera = clampCameraForView({ + x: base.x - dxCells, + y: base.y - dyCells, + }, viewportSizeForZoom(state.zoom)); + schedulePanRedraw(nextCamera); + } + } } else if (dragState.mode === "select") { dragState.selectEnd = clampCanvasPoint(event); if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) { @@ -954,17 +1006,22 @@ function redraw(options = {}) { const viewSize = syncViewportSize(); const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height); if (expansion?.expanded) { - state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) }; + const ex = expansion.dx || 0; + const ey = expansion.dy || 0; + state.camera = { x: (state.camera?.x || 0) + ex, y: (state.camera?.y || 0) + ey }; + if (dragState.mode === "pan") { + dragState.startCameraX += ex; + dragState.startCameraY += ey; + if (dragState.pendingCamera) dragState.pendingCamera = { x: dragState.pendingCamera.x + ex, y: dragState.pendingCamera.y + ey }; + } if (state.selectionRect) { - const dx = expansion.dx || 0; - const dy = expansion.dy || 0; state.selectionRect = { ...state.selectionRect, - x0: state.selectionRect.x0 + dx, - y0: state.selectionRect.y0 + dy, - x1: state.selectionRect.x1 + dx, - y1: state.selectionRect.y1 + dy, - polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon, + x0: state.selectionRect.x0 + ex, + y0: state.selectionRect.y0 + ey, + x1: state.selectionRect.x1 + ex, + y1: state.selectionRect.y1 + ey, + polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + ex, y: p.y + ey })) : state.selectionRect.polygon, }; } } diff --git a/index.html b/index.html index a0b500d..638d91b 100644 --- a/index.html +++ b/index.html @@ -40,7 +40,7 @@ - + @@ -56,7 +56,7 @@ - + diff --git a/mapPatch.js b/mapPatch.js index f8457d2..ae52648 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -158,7 +158,9 @@ function pointInPolygon(px, py, polygon) { const yi = polygon[i].y + 0.5; const xj = polygon[j].x + 0.5; const yj = polygon[j].y + 0.5; - const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi); + const denomRaw = yj - yi; + const denom = Math.abs(denomRaw) < 1e-6 ? (denomRaw < 0 ? -1e-6 : 1e-6) : denomRaw; + const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / denom + xi); if (intersect) inside = !inside; } return inside; @@ -220,6 +222,58 @@ function ensureWorldField(world, name, source) { return world.fields[name]; } +function isWorldCellField(world, value) { + return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === (world?.width || 0) * (world?.height || 0); +} + +function captureStrictSelectionFieldSnapshot(world, rects, seed = 0) { + if (!rects?.strictSelectionMask || !world?.fields || !rects.writeRect) return null; + const rect = rects.writeRect; + const width = rectWidth(rect); + const height = rectHeight(rect); + const fields = new Map(); + for (const [name, field] of Object.entries(world.fields)) { + if (!isWorldCellField(world, field)) continue; + const data = new field.constructor(width * height); + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi >= 0) data[(y - rect.y0) * width + (x - rect.x0)] = field[wi]; + } + } + fields.set(name, data); + } + return { rect: { ...rect }, width, height, fields, seed }; +} + +function restoreOutsideStrictSelectionFields(world, rects, snapshot, seed = 0) { + if (!snapshot || !rects?.strictSelectionMask) return { strictMaskCellsRestored: 0, strictMaskValuesRestored: 0 }; + const rect = snapshot.rect; + let strictMaskCellsRestored = 0; + let strictMaskValuesRestored = 0; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + if (patchAlpha(x, y, rects, seed) > 0.005) continue; + let cellChanged = false; + const li = (y - rect.y0) * snapshot.width + (x - rect.x0); + const wi = worldIndex(world, x, y); + if (wi < 0) continue; + for (const [name, oldData] of snapshot.fields) { + const field = world.fields?.[name]; + if (!isWorldCellField(world, field)) continue; + const oldValue = oldData[li]; + if (field[wi] !== oldValue) { + field[wi] = oldValue; + strictMaskValuesRestored++; + cellChanged = true; + } + } + if (cellChanged) strictMaskCellsRestored++; + } + } + return { strictMaskCellsRestored, strictMaskValuesRestored }; +} + export function clipPatchRect(rect, world) { const normalized = normalizeSelectionShape(rect, world); if (!normalized || !world) return null; @@ -269,12 +323,20 @@ export function buildPatchRects(userRect, world = null) { const width = rectWidth(coreRect); const height = rectHeight(coreRect); const shortSide = Math.max(1, Math.min(width, height)); + const polygonSelection = isPolygonSelection(coreRect); const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42))); const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2))); - const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource)); - const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12))); - const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource)); - const writeRect = expandRect(coreRect, writeMargin, world); + const rawWriteMargin = Math.max(0, Math.min(desiredWrite, maxBySource)); + const desiredRepair = Math.min(120, rawWriteMargin + Math.max(8, Math.floor(shortSide * 0.12))); + const repairMargin = Math.max(rawWriteMargin, Math.min(desiredRepair, maxBySource)); + + // A lasso/freeform selection is now treated as a strict write mask. Earlier + // versions expanded the lasso's bounding box and let alpha feather outside the + // drawn polygon; that made terrain/features appear beyond the user's blue + // selection outline. We still keep repair/transport context outside the mask, + // but only cells inside the lasso can receive generated field/feature data. + const writeMargin = polygonSelection ? Math.max(4, Math.min(rawWriteMargin, Math.floor(shortSide * 0.20))) : rawWriteMargin; + const writeRect = polygonSelection ? { x0: coreRect.x0, y0: coreRect.y0, x1: coreRect.x1, y1: coreRect.y1 } : expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); const transportReachMargin = Math.max( repairMargin + 48, @@ -290,7 +352,8 @@ export function buildPatchRects(userRect, world = null) { blendRect: coreRect, userRect: writeRect, selectedRect: coreRect, - selectionShape: isPolygonSelection(coreRect) ? coreRect : null, + selectionShape: polygonSelection ? coreRect : null, + strictSelectionMask: polygonSelection, writeMargin, repairMargin, transportReachMargin, @@ -310,10 +373,16 @@ function computePatchAlpha(x, y, rects, seed = 0) { const px = x + 0.5; const py = y + 0.5; const inside = pointInPolygon(px, py, shape.polygon); + if (!inside) return 0; + + // Strict lasso semantics: never write outside the user's polygon. The seam + // feather is applied inward only, so a large interior is fully regenerated + // while the edge remains just soft enough to avoid a visible cut line. const dist = distanceToPolygonEdge(px, py, shape.polygon); - const signedDist = inside ? dist : -dist; - const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10; - return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2))); + const feather = Math.max(2, Math.min(margin, 18)); + const noisyInsideDist = dist + low * Math.min(3.0, feather * 0.18) + mid * Math.min(1.4, feather * 0.08); + const edge = smoothstep(clamp(noisyInsideDist / Math.max(1e-6, feather))); + return clamp(0.64 + edge * 0.36); } const edge = distanceToRectEdge(x, y, writeRect); const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; @@ -398,6 +467,42 @@ function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) { return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha); } +function segmentTouchesRect(world, seg, rect) { + if (!Array.isArray(seg) || seg.length < 2 || !rect) return false; + const ax = tupleWorldX(world, seg[0]); + const ay = tupleWorldY(world, seg[0]); + const bx = tupleWorldX(world, seg[1]); + const by = tupleWorldY(world, seg[1]); + const mx = (ax + bx) * 0.5; + const my = (ay + by) * 0.5; + if (insideRect(ax, ay, rect) || insideRect(bx, by, rect) || insideRect(mx, my, rect)) return true; + const minX = Math.min(ax, bx); + const maxX = Math.max(ax, bx); + const minY = Math.min(ay, by); + const maxY = Math.max(ay, by); + return maxX >= rect.x0 && minX < rect.x1 && maxY >= rect.y0 && minY < rect.y1; +} + +function quantizedSegmentKey(seg) { + if (!Array.isArray(seg) || seg.length < 2) return ""; + const p = seg.map(([x, y]) => [Math.round(x * 4) / 4, Math.round(y * 4) / 4]); + const a = `${p[0][0]},${p[0][1]}`; + const b = `${p[1][0]},${p[1][1]}`; + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +function dedupeSegments(segments) { + const seen = new Set(); + const out = []; + for (const seg of segments || []) { + const key = quantizedSegmentKey(seg); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(seg); + } + return out; +} + function sourceWindowForRects(rects) { const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; @@ -738,6 +843,130 @@ function cloneContinuityFields(world) { return out; } +function cloneHumanLandContinuityFields(world) { + const out = new Map(); + for (const name of [ + "elevation", + "roadInfluence", + "railInfluence2", + "stationInfluence", + "populationDensity", + "settlementScore", + "villageInfluence", + "plain", + "agriculture", + ]) { + const field = world?.fields?.[name]; + if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); + } + return out; +} + +function restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed = 0, seaLevel = 0.30) { + const sea = world.fields?.sea; + const ocean = world.fields?.ocean; + const lake = world.fields?.lake; + const elevation = world.fields?.elevation; + const landuse = world.fields?.landuse; + const rect = rects?.writeRect; + if (!sea || !rect || !oldSea) { + return { humanLandCellsRestored: 0, humanLandFeatureMaskCells: 0, humanLandCandidatesChecked: 0 }; + } + + const width = rectWidth(rect); + const height = rectHeight(rect); + const mask = new Float32Array(width * height); + const localIndex = (x, y) => (y - rect.y0) * width + (x - rect.x0); + const active = (x, y, minAlpha = 0.18) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= minAlpha; + + const markDisk = (cx, cy, radius, weight) => { + cx = Math.round(cx); cy = Math.round(cy); + const r = Math.max(1, Math.floor(radius)); + for (let y = cy - r; y <= cy + r; y++) { + for (let x = cx - r; x <= cx + r; x++) { + if (!active(x, y, 0.14)) continue; + const wi = worldIndex(world, x, y); + if (wi < 0 || oldSea[wi]) continue; + const d = Math.hypot(x - cx, y - cy); + if (d > r + 0.35) continue; + const li = localIndex(x, y); + const falloff = 1 - d / Math.max(1, r + 0.35); + mask[li] = Math.max(mask[li], weight * (0.35 + falloff * 0.65)); + } + } + }; + + const markSegment = (ax, ay, bx, by, radius, weight) => { + const len = Math.max(1, Math.hypot(bx - ax, by - ay)); + const steps = Math.max(1, Math.ceil(len / 2.0)); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + markDisk(ax + (bx - ax) * t, ay + (by - ay) * t, radius, weight); + } + }; + + let humanLandFeatureMaskCells = 0; + const markPathLayer = (key, radius, weight) => { + for (const path of sourceMap?.[key] || []) { + let prev = null; + for (const tuple of path || []) { + const x = tupleWorldX(world, tuple); + const y = tupleWorldY(world, tuple); + if (prev) markSegment(prev.x, prev.y, x, y, radius, weight); + prev = { x, y }; + } + } + }; + + // Only the actual pre-existing transport centerlines should bias land + // restoration. Settlements, admin centers, ports, industrial sites, etc. are + // deliberately ignored here; otherwise patching a coastal/archipelago area + // over-preserves old human geography and resists legitimate water generation. + // The weights are intentionally weak: this pass is a continuity hint, not a + // hard constraint. + for (const key of ["expressways", "externalExpressways", "nationalRoads"]) markPathLayer(key, 3, 0.62); + for (const key of ["minorRoads", "premodernRoads", "ringRoads", "externalRoads", "icAccessRoads"]) markPathLayer(key, 2, 0.42); + for (const key of ["railways", "branchRailways", "ringRailways", "externalRailways"]) markPathLayer(key, 3, 0.66); + + for (let i = 0; i < mask.length; i++) if (mask[i] > 0) humanLandFeatureMaskCells++; + + const oldElevation = oldHumanFields?.get("elevation"); + const scoreField = (name, i, weight) => (oldHumanFields?.get(name)?.[i] || 0) * weight; + let humanLandCellsRestored = 0; + let humanLandCandidatesChecked = 0; + + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + if (!active(x, y, 0.20)) continue; + const i = worldIndex(world, x, y); + if (i < 0 || oldSea[i] || !sea[i]) continue; + humanLandCandidatesChecked++; + const li = localIndex(x, y); + const score = mask[li] + + scoreField("roadInfluence", i, 0.82) + + scoreField("railInfluence2", i, 0.98) + + scoreField("stationInfluence", i, 0.28); + if (score < 0.78) continue; + + sea[i] = 0; + if (ocean) ocean[i] = 0; + if (lake) lake[i] = 0; + if (elevation) { + const oldElev = oldElevation?.[i]; + const target = Number.isFinite(oldElev) ? Math.max(oldElev, seaLevel + 0.012) : seaLevel + 0.018; + elevation[i] = Math.max(elevation[i] || 0, target); + } + if (landuse) { + const oldUse = oldLanduse?.[i]; + landuse[i] = oldUse && oldUse !== LANDUSE.WATER ? oldUse : (LANDUSE.RURAL || 0); + } + humanLandCellsRestored++; + } + } + + return { humanLandCellsRestored, humanLandFeatureMaskCells, humanLandCandidatesChecked }; +} + function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { let restored = 0; let remapped = 0; @@ -870,9 +1099,11 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { return { adminSeamCellsResolved, prefectureSeamCellsResolved }; } -function copyFullPipelineFields(world, candidate, rects, seed) { +function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) { const window = sourceWindowForRects(rects); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; + const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null; + const oldHumanFields = cloneHumanLandContinuityFields(world); const oldContinuityFields = cloneContinuityFields(world); const adminIdMapping = buildAdminIdMapping({ candidateMap: candidate, @@ -977,6 +1208,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) { } } + const humanLandDebug = restoreHumanLandContinuity(world, sourceMap, rects, oldSea, oldLanduse, oldHumanFields, seed, seaLevel); const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed); if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { @@ -998,6 +1230,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) { landUseCellsUpdated, adminIdMapping, adminIdMappingDebug: summarizeIdMapping(adminIdMapping), + ...humanLandDebug, ...continuityDebug, ...seamOwnershipDebug, }; @@ -1199,6 +1432,167 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, see return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; } +function protectedAdministrativeCells(world, fieldName, rect) { + const protectedCells = new Set(); + const sourceMap = world?.sourceMap || {}; + const pointKeys = fieldName === "prefectureRegionId" ? ["prefectureRegions"] : ["adminCenters"]; + const field = world?.fields?.[fieldName]; + if (!field) return protectedCells; + for (const key of pointKeys) { + for (const p of sourceMap[key] || []) { + const x = Math.round(pointWorldX(world, p)); + const y = Math.round(pointWorldY(world, p)); + if (!insideRect(x, y, rect)) continue; + const i = worldIndex(world, x, y); + if (i >= 0 && field[i] >= 0) protectedCells.add(i); + } + } + return protectedCells; +} + +function modeFromCounts(counts) { + let best = -1; + let bestCount = 0; + for (const [id, count] of counts || []) { + if (count > bestCount || (count === bestCount && id < best)) { + best = id; + bestCount = count; + } + } + return best; +} + +function addNestedVote(map, key, value, weight = 1) { + if (!Number.isFinite(key) || key < 0 || !Number.isFinite(value) || value < 0) return; + const id = Math.floor(key); + const bucket = map.get(id) || new Map(); + bucket.set(Math.floor(value), (bucket.get(Math.floor(value)) || 0) + weight); + map.set(id, bucket); +} + +function cleanupDiscreteFieldComponents(world, fieldName, rect, options = {}) { + const field = world?.fields?.[fieldName]; + const sea = world?.fields?.sea; + if (!field || !rect) return { componentsMerged: 0, cellsMerged: 0 }; + const minCells = Math.max(1, Math.floor(options.minCells || 80)); + const passes = Math.max(1, Math.floor(options.passes || 2)); + const respectPrefecture = !!options.respectPrefecture; + const pref = world.fields?.prefectureRegionId; + const protectedCells = protectedAdministrativeCells(world, fieldName, rect); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + let componentsMerged = 0; + let cellsMerged = 0; + + for (let pass = 0; pass < passes; pass++) { + const seen = new Uint8Array(world.width * world.height); + const reassignments = []; + + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const start = worldIndex(world, x, y); + if (start < 0 || seen[start] || sea?.[start] || field[start] < 0) continue; + + const id = field[start]; + const stack = [start]; + seen[start] = 1; + const cells = []; + const neighborVotes = new Map(); + const neighborPrefVotes = new Map(); + const ownPrefVotes = new Map(); + let touchesRectEdge = false; + let hasProtectedPoint = false; + + while (stack.length) { + const ci = stack.pop(); + const cx = ci % world.width; + const cy = Math.floor(ci / world.width); + cells.push(ci); + if (protectedCells.has(ci)) hasProtectedPoint = true; + if (cx <= rect.x0 || cy <= rect.y0 || cx >= rect.x1 - 1 || cy >= rect.y1 - 1) touchesRectEdge = true; + if (pref?.[ci] >= 0) ownPrefVotes.set(pref[ci], (ownPrefVotes.get(pref[ci]) || 0) + 1); + + for (const [dx, dy] of dirs) { + const nx = cx + dx; + const ny = cy + dy; + const ni = worldIndex(world, nx, ny); + if (ni < 0 || sea?.[ni]) continue; + const nid = field[ni]; + if (insideRect(nx, ny, rect) && nid === id && !seen[ni]) { + seen[ni] = 1; + stack.push(ni); + } else if (nid >= 0 && nid !== id) { + neighborVotes.set(nid, (neighborVotes.get(nid) || 0) + 1); + if (pref?.[ni] >= 0) addNestedVote(neighborPrefVotes, nid, pref[ni], 1); + } + } + } + + // Components touching the cleanup rectangle boundary may be only the + // visible slice of a large outside municipality/prefecture. Preserve + // those unless they are extremely small; otherwise patched seams can + // erase legitimate existing regions. + const clippedLargeOutsideRegion = touchesRectEdge && cells.length >= Math.floor(minCells * 0.55); + if (hasProtectedPoint || clippedLargeOutsideRegion || cells.length >= minCells || !neighborVotes.size) continue; + + const componentPref = respectPrefecture ? modeFromCounts(ownPrefVotes) : -1; + let bestTarget = -1; + let bestScore = -Infinity; + for (const [target, count] of neighborVotes) { + let score = count; + if (respectPrefecture && componentPref >= 0) { + const targetPref = modeFromCounts(neighborPrefVotes.get(target)); + if (targetPref === componentPref) score += count * 0.85; + else score -= count * 0.45; + } + if (score > bestScore || (score === bestScore && target < bestTarget)) { + bestTarget = target; + bestScore = score; + } + } + if (bestTarget < 0) continue; + for (const ci of cells) reassignments.push([ci, bestTarget]); + componentsMerged++; + cellsMerged += cells.length; + } + } + + if (!reassignments.length) break; + for (const [i, target] of reassignments) field[i] = target; + } + + return { componentsMerged, cellsMerged }; +} + +function repairPatchAdministrativeTopology(world, rects) { + const rect = rects.repairRect || rects.writeRect; + const prefecture = cleanupDiscreteFieldComponents(world, "prefectureRegionId", rect, { minCells: 420, passes: 3 }); + const admin = cleanupDiscreteFieldComponents(world, "adminId", rect, { minCells: 96, passes: 4, respectPrefecture: true }); + let municipalityCellsSynced = 0; + if (world.fields?.adminId && world.fields?.municipalityId) { + const adminId = world.fields.adminId; + const municipalityId = world.fields.municipalityId; + const sea = world.fields.sea; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + const next = sea?.[i] ? -1 : adminId[i]; + if (municipalityId[i] !== next) { + municipalityId[i] = next; + municipalityCellsSynced++; + } + } + } + } + return { + prefectureTinyComponentsMerged: prefecture.componentsMerged, + prefectureTinyCellsMerged: prefecture.cellsMerged, + adminTinyComponentsMerged: admin.componentsMerged, + adminTinyCellsMerged: admin.cellsMerged, + municipalityCellsSynced, + }; +} + function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { const sea = world.fields.sea; const ocean = world.fields.ocean; @@ -1242,6 +1636,158 @@ function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = return { coastCellsChanged: changed }; } +function repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) { + const sea = world.fields.sea; + const ocean = world.fields.ocean; + const lake = world.fields.lake; + const elevation = world.fields.elevation; + const landuse = world.fields.landuse; + const rect = rects?.writeRect; + if (!sea || !rect) return { waterComponentsScanned: 0, tinyWaterComponentsRemoved: 0, tinyLandIslandsRemoved: 0, waterTopologyCellsFlipped: 0 }; + + const expected = world.width * world.height; + const visited = new Uint8Array(expected); + const activeMinAlpha = 0.22; + const preserveAlpha = 0.40; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + const active = (x, y) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= activeMinAlpha; + const tinyWaterLimit = Math.max(14, Math.min(36, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.20))); + const tinyLandLimit = Math.max(10, Math.min(28, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.16))); + + let waterComponentsScanned = 0; + let tinyWaterComponentsRemoved = 0; + let tinyLandIslandsRemoved = 0; + let waterTopologyCellsFlipped = 0; + + const flipCell = (i, nextSea) => { + if (sea[i] === nextSea) return; + sea[i] = nextSea; + if (ocean) ocean[i] = nextSea; + if (lake) lake[i] = 0; + if (elevation) { + if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); + else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); + } + if (landuse) landuse[i] = nextSea ? (LANDUSE.WATER || LANDUSE.RURAL || 0) : (LANDUSE.RURAL || 0); + waterTopologyCellsFlipped++; + }; + + for (let y0 = rect.y0; y0 < rect.y1; y0++) { + for (let x0 = rect.x0; x0 < rect.x1; x0++) { + if (!active(x0, y0)) continue; + const start = worldIndex(world, x0, y0); + if (start < 0 || visited[start]) continue; + const value = sea[start] ? 1 : 0; + const stack = [[x0, y0]]; + const cells = []; + let sumElevation = 0; + let elevationCount = 0; + let touchesWeakPatchEdge = false; + let touchesSameOutsideActive = false; + let oppositeBorder = 0; + let sameBorder = 0; + + visited[start] = 1; + while (stack.length) { + const [x, y] = stack.pop(); + const i = worldIndex(world, x, y); + if (i < 0) continue; + cells.push(i); + if (elevation) { sumElevation += elevation[i] || 0; elevationCount++; } + if (patchAlpha(x, y, rects, seed) < preserveAlpha || x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) { + touchesWeakPatchEdge = true; + } + for (const [dx, dy] of dirs) { + const nx = x + dx; + const ny = y + dy; + const ni = worldIndex(world, nx, ny); + if (ni < 0) continue; + const nv = sea[ni] ? 1 : 0; + if (nv !== value) { + oppositeBorder++; + continue; + } + sameBorder++; + if (!active(nx, ny)) { + touchesSameOutsideActive = true; + continue; + } + if (!visited[ni]) { + visited[ni] = 1; + stack.push([nx, ny]); + } + } + } + + waterComponentsScanned++; + const area = cells.length; + const avgElevation = elevationCount ? sumElevation / elevationCount : seaLevel; + const isolatedInsidePatch = !touchesWeakPatchEdge && !touchesSameOutsideActive; + if (value === 1) { + if (isolatedInsidePatch && area <= tinyWaterLimit && avgElevation > seaLevel - 0.055) { + for (const i of cells) flipCell(i, 0); + tinyWaterComponentsRemoved++; + } + } else { + const mostlySurroundedBySea = oppositeBorder > sameBorder * 0.72; + if (isolatedInsidePatch && mostlySurroundedBySea && area <= tinyLandLimit && avgElevation < seaLevel + 0.045) { + for (const i of cells) flipCell(i, 1); + tinyLandIslandsRemoved++; + } + } + } + } + + return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped }; +} + +function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) { + const elevation = world.fields?.elevation; + const sea = world.fields?.sea; + if (!elevation || !sea || !rects?.writeRect) return { waterElevationCellsSmoothed: 0 }; + const rect = rects.writeRect; + let waterElevationCellsSmoothed = 0; + + // Do not diffuse water elevation row-by-row. Diffusion made broad patched + // ocean/sea areas inherit candidate raster bands, which appeared as horizontal + // stripes. Instead assign a stable world-coordinate bathymetry target and + // blend toward it by patch alpha. The renderer also avoids DEM hillshade for + // water, but keeping the underlying water DEM coherent prevents dependent + // fields from reintroducing stripe artefacts later. + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || !sea[i]) continue; + const a = patchAlpha(x, y, rects, seed); + if (a < 0.08) continue; + + let seaNear = 0; + let totalNear = 0; + for (let dy = -3; dy <= 3; dy++) { + for (let dx = -3; dx <= 3; dx++) { + if (!dx && !dy) continue; + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + totalNear++; + if (sea[ni]) seaNear++; + } + } + const offshore = totalNear ? seaNear / totalNear : 1; + const broad = valueNoise(x, y, seed ^ 0x6d2b79f5, 86); + const mid = valueNoise(x, y, seed ^ 0x2f31c9a7, 31); + const texture = broad * 0.75 + mid * 0.25; + const depth = clamp(0.032 + offshore * 0.060 + (texture - 0.5) * 0.018, 0.018, 0.125); + const target = seaLevel - depth; + const before = elevation[i]; + const strength = clamp(0.46 + a * 0.42, 0.42, 0.86); + elevation[i] = clamp(lerp(Math.min(before, seaLevel - 0.004), target, strength), seaLevel - 0.16, seaLevel - 0.004); + if (Math.abs(elevation[i] - before) > 1e-6) waterElevationCellsSmoothed++; + } + } + + return { waterElevationCellsSmoothed }; +} + function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) { const fields = world.fields; const { elevation, sea } = fields; @@ -1501,10 +2047,13 @@ function pathCost(world, x, y, mode) { return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18; } -function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) { +function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000, options = {}) { const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y); if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null; + const allowCell = typeof options.allowCell === "function" ? options.allowCell : null; + const extraCost = typeof options.extraCost === "function" ? options.extraCost : null; if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null; + if (allowCell && (!allowCell(sx, sy, true) || !allowCell(gx, gy, true))) return null; const w = rectWidth(rect); const h = rectHeight(rect); const n = w * h; @@ -1526,9 +2075,11 @@ function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24 for (const [dx, dy] of dirs) { const nx = cur.x + dx, ny = cur.y + dy; if (!insideRect(nx, ny, rect)) continue; + if (allowCell && !allowCell(nx, ny, false)) continue; const nid = local(nx, ny); - const c = pathCost(world, nx, ny, mode); - if (!Number.isFinite(c)) continue; + const baseCost = pathCost(world, nx, ny, mode); + if (!Number.isFinite(baseCost)) continue; + const c = baseCost + (extraCost ? Math.max(0, extraCost(nx, ny) || 0) : 0); const step = (dx && dy ? 1.42 : 1) * c; const nd = dist[cur.id] + step; if (nd >= dist[nid]) continue; @@ -1575,13 +2126,13 @@ function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road } } } - const featureKeys = mode === "rail" - ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"] - : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"]; - for (const key of featureKeys) { - for (const p of sourceMap[key] || []) { - add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25); - } + // Do not use non-network human geography as graph reconnection targets. + // Reconnection should repair severed transport graphs, not force every city, + // admin center, village, port, or industrial site to remain tied to the old + // road topology after a terrain patch. Stations are retained as rail graph + // targets because they are part of the transport network. + if (mode === "rail") { + for (const p of sourceMap.stations || []) add(pointWorldX(world, p), pointWorldY(world, p), "stations", 1.15); } return points; } @@ -1616,7 +2167,7 @@ function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachR return candidates.slice(0, mode === "rail" ? 18 : 28); } -function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) { +function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null, patchOptions = null) { const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; @@ -1655,7 +2206,16 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000); if (!path || path.length < 2) continue; seen.add(sig); - sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); + const outputChunks = patchOptions?.rects + ? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34) + : [path]; + let wroteChunk = false; + for (const chunk of outputChunks) { + if (!chunk || chunk.length < 2) continue; + sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2))); + wroteChunk = true; + } + if (!wroteChunk) continue; connectors++; made = true; break; @@ -1682,7 +2242,7 @@ function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = return best; } -function ensureSettlementRoadCoverage(world, sourceMap, rect) { +function ensureSettlementRoadCoverage(world, sourceMap, rect, patchOptions = null) { const keys = ["nationalRoads", "minorRoads", "premodernRoads"]; const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; sourceMap.minorRoads ||= []; @@ -1713,13 +2273,349 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) { seen.add(sig); const path = localPathfind(world, start, target, rect, "road", 36000); if (!path || path.length < 2) continue; - sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); + const outputChunks = patchOptions?.rects + ? splitWorldPathByPatch(path, patchOptions.rects, patchOptions.seed || 0, true, patchOptions.minAlpha ?? 0.34) + : [path]; + let wroteChunk = false; + for (const chunk of outputChunks) { + if (!chunk || chunk.length < 2) continue; + sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(chunk, 2))); + wroteChunk = true; + } + if (!wroteChunk) continue; connectors++; } } return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked }; } + +function transportLayerKeys(mode) { + return mode === "rail" + ? ["railways", "branchRailways", "ringRailways", "externalRailways"] + : ["nationalRoads", "minorRoads", "premodernRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]; +} + +function densifyWorldPath(path, visit) { + if (!Array.isArray(path) || path.length < 2) return; + for (let k = 0; k < path.length - 1; k++) { + const a = path[k]; + const b = path[k + 1]; + const ax = Math.round(a?.[0] ?? 0); + const ay = Math.round(a?.[1] ?? 0); + const bx = Math.round(b?.[0] ?? ax); + const by = Math.round(b?.[1] ?? ay); + const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay))); + for (let s = k === 0 ? 0 : 1; s <= steps; s++) { + const t = s / steps; + visit(Math.round(ax + (bx - ax) * t), Math.round(ay + (by - ay) * t)); + } + } +} + +function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed = 0) { + const keys = transportLayerKeys(mode); + const w = rectWidth(graphRect); + const h = rectHeight(graphRect); + const occ = new Uint8Array(Math.max(0, w * h)); + const weight = new Float32Array(Math.max(0, w * h)); + const local = (x, y) => (y - graphRect.y0) * w + (x - graphRect.x0); + const mark = (x, y, v = 1) => { + x = Math.round(x); y = Math.round(y); + if (!insideRect(x, y, graphRect)) return; + // Graph connectivity is based on rendered transport centerlines. Existing + // short bridges are allowed to connect components, but arbitrary candidate + // roads far out at sea are not allowed to become graph anchors. + const i = worldIndex(world, x, y); + if (i < 0) return; + const nearLand = isLand(world, x, y) || seaNeighbors(world, x, y, 2) >= 3; + if (!nearLand) return; + const li = local(x, y); + occ[li] = 1; + weight[li] = Math.max(weight[li] || 0, v); + }; + + for (const key of keys) { + const layerWeight = key.includes("express") ? 2.4 : key.includes("national") ? 2.0 : key.includes("rail") ? 2.1 : 1.0; + for (const path of sourceMap?.[key] || []) { + const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); + densifyWorldPath(worldPath, (x, y) => mark(x, y, layerWeight)); + } + } + + const seen = new Uint8Array(occ.length); + const comps = []; + const dirs = []; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (!dx && !dy) continue; + if (dx * dx + dy * dy <= 5) dirs.push([dx, dy]); + } + } + + for (let li = 0; li < occ.length; li++) { + if (!occ[li] || seen[li]) continue; + const queue = [li]; + const cells = []; + seen[li] = 1; + let sx = 0, sy = 0, score = 0, patchCells = 0, nearWriteCells = 0, exteriorCells = 0; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const lx = cur % w; + const ly = Math.floor(cur / w); + const x = graphRect.x0 + lx; + const y = graphRect.y0 + ly; + sx += x; sy += y; score += Math.max(1, weight[cur] || 1); + minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + if (patchAffected(x, y, rects, seed, 0.24)) patchCells++; + if (rectDistance(x, y, rects.writeRect) <= 8) nearWriteCells++; + if (!insideRect(x, y, rects.writeRect)) exteriorCells++; + for (const [dx, dy] of dirs) { + const nx = lx + dx; + const ny = ly + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const ni = ny * w + nx; + if (!occ[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + const size = cells.length; + const cx = sx / Math.max(1, size); + const cy = sy / Math.max(1, size); + const boundaryBonus = exteriorCells > 0 ? Math.min(180, exteriorCells) : 0; + comps.push({ + id: comps.length, + cells, + size, + cx, + cy, + minX, + minY, + maxX, + maxY, + score: score + boundaryBonus * 1.5 + patchCells * 1.2 + nearWriteCells * 0.8, + patchCells, + nearWriteCells, + exteriorCells, + }); + } + comps.sort((a, b) => b.score - a.score); + return { graphRect, width: w, height: h, occ, comps, local }; +} + +function sampleTransportComponent(snapshot, comp, limit = 96) { + const cells = comp?.cells || []; + if (!cells.length) return []; + const out = []; + const step = Math.max(1, Math.floor(cells.length / Math.max(1, limit))); + for (let i = 0; i < cells.length; i += step) { + const li = cells[i]; + const x = snapshot.graphRect.x0 + (li % snapshot.width); + const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width); + out.push({ x, y }); + if (out.length >= limit) break; + } + // Bias the sample toward the component centroid and bounding-box edges; this + // improves reconnection when a long road was clipped by the patch edge. + const specials = [ + { x: Math.round(comp.cx), y: Math.round(comp.cy) }, + { x: comp.minX, y: Math.round(comp.cy) }, + { x: comp.maxX, y: Math.round(comp.cy) }, + { x: Math.round(comp.cx), y: comp.minY }, + { x: Math.round(comp.cx), y: comp.maxY }, + ]; + for (const p of specials) { + if (insideRect(p.x, p.y, snapshot.graphRect)) out.push(p); + } + return out; +} + +function bestTransportComponentPair(snapshot, main, other, maxDistance) { + const mainSamples = sampleTransportComponent(snapshot, main, 48); + const otherSamples = sampleTransportComponent(snapshot, other, 48); + let best = null; + for (const a of mainSamples) { + for (const b of otherSamples) { + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 3 || d > maxDistance) continue; + const score = d / Math.sqrt(Math.max(4, other.size)); + if (!best || score < best.score) best = { a, b, d, score }; + } + } + return best; +} + +function pathLength(path) { + let len = 0; + for (let i = 1; i < (path?.length || 0); i++) len += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + return len; +} + +function dedupeWorldPath(path) { + const out = []; + let last = ""; + for (const p of path || []) { + const x = Math.round(p?.[0] ?? 0); + const y = Math.round(p?.[1] ?? 0); + const key = `${x},${y}`; + if (key === last) continue; + out.push([x, y]); + last = key; + } + return out; +} + +function makeTransportConnectorAllowCell(world, snapshot, rects, seed, strictMask) { + const graphRect = snapshot.graphRect; + return (x, y, endpoint = false) => { + if (!insideRect(x, y, graphRect)) return false; + // Field writes are strict-masked elsewhere. For transport repair, allow the + // pathfinder to use the broader graph neighborhood so it can reconnect to + // realistic regional targets beyond the selected patch. Water remains + // blocked by pathCost(), preserving the existing island/strait behavior. + return true; + }; +} + +function makeTransportConnectorExtraCost(world, rects, seed, mode) { + return (x, y) => { + const i = worldIndex(world, x, y); + if (i < 0) return 999; + const alpha = patchAlpha(x, y, rects, seed); + const edgePenalty = alpha > 0 ? Math.max(0, 0.35 - alpha) * 1.1 : 0; + const roadInf = world.fields.roadInfluence?.[i] || 0; + const railInf = world.fields.railInfluence2?.[i] || 0; + const transportBonus = mode === "rail" ? railInf * 0.45 : roadInf * 0.35; + return Math.max(0, edgePenalty - transportBonus); + }; +} + +function writeConnectorPath(world, sourceMap, mode, path, rects, seed, strictMask) { + const layer = mode === "rail" ? "branchRailways" : "minorRoads"; + sourceMap[layer] ||= []; + const startLength = sourceMap[layer].length; + const clean = dedupeWorldPath(path); + if (clean.length < 2) return { wrote: 0, layer, startLength }; + const chunks = [clean]; + let wrote = 0; + for (const chunk of chunks) { + const out = dedupeWorldPath(chunk); + if (out.length < 2) continue; + // Reject tiny one-cell remnants produced by strict polygon clipping. They + // look like specks rather than graph repairs. + if (pathLength(out) < (mode === "rail" ? 4 : 3)) continue; + sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(out, mode === "rail" ? 3 : 2))); + wrote++; + } + return { wrote, layer, startLength }; +} + +function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, options = {}) { + const strictMask = !!rects.strictSelectionMask; + const maxAdds = options.maxAdds ?? (mode === "rail" ? 5 : 10); + const maxDistance = options.maxDistance ?? (mode === "rail" ? 120 : 140); + const minComponentSize = options.minComponentSize ?? (mode === "rail" ? 3 : 4); + const debug = { + [`${mode}GraphBeforeComponents`]: 0, + [`${mode}GraphAfterComponents`]: 0, + [`${mode}GraphConnectorsAdded`]: 0, + [`${mode}GraphConnectorsFailed`]: 0, + [`${mode}GraphCandidatesConsidered`]: 0, + [`${mode}GraphComponentsIgnored`]: 0, + }; + + const eligible = (comp) => { + if (!comp || comp.size < minComponentSize) return false; + // Work only on the dirty neighborhood. External components are included + // when they touch the dirty region so regenerated internal roads can attach + // back to the pre-existing graph. + return comp.patchCells > 0 || comp.nearWriteCells > 0; + }; + + let snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); + debug[`${mode}GraphBeforeComponents`] = snapshot.comps.filter(eligible).length; + const rejectedPairs = new Set(); + const rejectedComponents = new Set(); + const componentSignature = (comp) => `${Math.round((comp?.cx || 0) / 5)},${Math.round((comp?.cy || 0) / 5)},${Math.round((comp?.size || 0) / 6)}`; + for (let pass = 0; pass < maxAdds; pass++) { + snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); + const comps = snapshot.comps.filter(eligible); + if (comps.length <= 1) break; + comps.sort((a, b) => b.score - a.score); + const main = comps[0]; + let best = null; + const others = comps.slice(1, Math.min(comps.length, mode === "rail" ? 8 : 12)); + for (const comp of others) { + if (rejectedComponents.has(componentSignature(comp))) continue; + const pair = bestTransportComponentPair(snapshot, main, comp, maxDistance); + if (!pair) { debug[`${mode}GraphComponentsIgnored`]++; continue; } + debug[`${mode}GraphCandidatesConsidered`]++; + const pairSig = `${Math.round(pair.a.x)},${Math.round(pair.a.y)}:${Math.round(pair.b.x)},${Math.round(pair.b.y)}`; + const pairSigRev = `${Math.round(pair.b.x)},${Math.round(pair.b.y)}:${Math.round(pair.a.x)},${Math.round(pair.a.y)}`; + if (rejectedPairs.has(pairSig) || rejectedPairs.has(pairSigRev)) continue; + const patchBias = comp.patchCells > 0 ? 0.76 : 1.0; + const exteriorBias = comp.exteriorCells > 0 && main.exteriorCells > 0 ? 1.18 : 1.0; + const score = pair.score * patchBias * exteriorBias; + if (!best || score < best.score) best = { comp, pair, score }; + } + if (!best) break; + + const { a, b, d } = best.pair; + const pad = Math.ceil(Math.max(18, Math.min(72, d * 0.45 + 12))); + const searchRect = expandRect({ + x0: Math.floor(Math.min(a.x, b.x)), + y0: Math.floor(Math.min(a.y, b.y)), + x1: Math.ceil(Math.max(a.x, b.x) + 1), + y1: Math.ceil(Math.max(a.y, b.y) + 1), + }, pad, world); + const boundedSearchRect = { + x0: Math.max(searchRect.x0, graphRect.x0), + y0: Math.max(searchRect.y0, graphRect.y0), + x1: Math.min(searchRect.x1, graphRect.x1), + y1: Math.min(searchRect.y1, graphRect.y1), + }; + const allowCell = makeTransportConnectorAllowCell(world, snapshot, rects, seed, strictMask); + const extraCost = makeTransportConnectorExtraCost(world, rects, seed, mode); + const path = localPathfind(world, a, b, boundedSearchRect, mode, mode === "rail" ? 15000 : 18000, { allowCell, extraCost }); + const clean = dedupeWorldPath(path || []); + const routeLen = pathLength(clean); + const tooLong = !clean.length || routeLen > d * (mode === "rail" ? 2.25 : 2.65) + (mode === "rail" ? 30 : 42); + if (tooLong) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`); + rejectedComponents.add(componentSignature(best.comp)); + continue; + } + const beforeEligibleComponents = comps.length; + const writeResult = writeConnectorPath(world, sourceMap, mode, clean, rects, seed, strictMask); + if (!writeResult.wrote) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`); + rejectedComponents.add(componentSignature(best.comp)); + continue; + } + const checkSnapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); + const afterEligibleComponents = checkSnapshot.comps.filter(eligible).length; + if (afterEligibleComponents >= beforeEligibleComponents) { + // Strict-mask clipping can produce a visible fragment that does not + // actually merge graph components. Roll it back; otherwise repeated + // attempts accumulate harmless-looking but noisy connector shards. + sourceMap[writeResult.layer].splice(writeResult.startLength); + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(`${Math.round(a.x)},${Math.round(a.y)}:${Math.round(b.x)},${Math.round(b.y)}`); + rejectedComponents.add(componentSignature(best.comp)); + continue; + } + debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote; + } + snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); + debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length; + return debug; +} + function dedupeAdminCentersByWorldId(kept, generated) { const out = [...kept]; const seen = new Set(); @@ -1818,20 +2714,34 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { } sourceMap[key] = next; } - const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; + const strictMask = !!rects.strictSelectionMask; + const broadTransportRect = rects.transportReachRect || expandRect(rects.writeRect, 220, world); + const transportRect = strictMask + ? broadTransportRect + : (rects.transportReachRect || rects.repairRect || rects.writeRect); + // Terrain/field generation still obeys the strict lasso mask. Transport + // repair is intentionally allowed to operate in a much broader neighborhood, + // because clipping graph repairs to the selected polygon leaves implausible + // dangling regional networks just outside the patch. + const connectorPatchOptions = null; const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); roadAnchors = roadAnchors.concat(externalRoadAnchors); railAnchors = railAnchors.concat(externalRailAnchors); - const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect); - const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect); - const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); + const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect, connectorPatchOptions); + const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect, connectorPatchOptions); + const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 }; + const graphRect = strictMask + ? transportRect + : transportRect; + const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 16 : 10, maxDistance: strictMask ? 260 : 180 }); + const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 8 : 5, maxDistance: strictMask ? 210 : 140 }); return { roadsClipped, railsClipped, regeneratedPaths, - roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors, - railwayConnectorsCreated: railConn.connectors, + roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors + (roadGraph.roadGraphConnectorsAdded || 0), + railwayConnectorsCreated: railConn.connectors + (railGraph.railGraphConnectorsAdded || 0), disconnectedRoadComponents: roadConn.disconnected, disconnectedRailComponents: railConn.disconnected, skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0), @@ -1840,6 +2750,8 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0, externalRoadAnchors: externalRoadAnchors.length, externalRailAnchors: externalRailAnchors.length, + ...roadGraph, + ...railGraph, }; } @@ -1888,15 +2800,45 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec return added; } -function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { +function transformCandidateBoundarySegments(world, candidate, key, rects, window, seed = 0, minAlpha = 0.74) { + const out = []; + if (!candidate || !window || !Array.isArray(candidate[key])) return out; + const sea = world.fields?.sea; + for (const seg of candidate[key]) { + if (!Array.isArray(seg) || seg.length < 2) continue; + const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); + const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); + const mx = Math.round((a.x + b.x) * 0.5); + const my = Math.round((a.y + b.y) * 0.5); + if (!insideRect(mx, my, rects.writeRect)) continue; + if (patchAlpha(mx, my, rects, seed) < minAlpha) continue; + const mi = worldIndex(world, mx, my); + if (mi >= 0 && sea?.[mi]) continue; + out.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); + } + return out; +} + +function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) { + const segmentRect = rects.writeRect || rects.repairRect; + const strongAlpha = 0.72; + + // Do not wipe every prepared boundary inside the repair rectangle. That made + // the old/new seam itself visible as a prefecture border and left broad holes + // when candidate boundaries were weak. Replace only strong patch-interior + // segments; keep the weak seam band owned by the existing world. for (const key of SEGMENT_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; - sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); + sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); } - sourceMap.adminBorders ||= []; - sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 })); - sourceMap.regionalPrefectureBorders ||= []; - sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect, { rects, seed, minAlpha: 0.72 })); + + const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76); + const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78); + const rebuiltAdmin = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 }); + const rebuiltPrefecture = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 }); + + sourceMap.adminBorders = dedupeSegments([...(sourceMap.adminBorders || []), ...candidateAdmin, ...rebuiltAdmin]); + sourceMap.regionalPrefectureBorders = dedupeSegments([...(sourceMap.regionalPrefectureBorders || []), ...candidatePref, ...rebuiltPrefecture]); sourceMap.prefectureBorder ||= []; const debug = sourceMap.adminDebug || {}; @@ -1909,6 +2851,8 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { return { adminBordersRebuilt: sourceMap.adminBorders.length, prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, + candidateAdminBordersMerged: candidateAdmin.length, + candidatePrefectureBordersMerged: candidatePref.length, compartmentBordersRebuilt: debug.compartmentBorders.length, }; } @@ -2082,6 +3026,7 @@ export function generatePatch(world, userRectInput, options = {}) { const rects = buildPatchRects(validation.rect, world); const terrainType = options.terrainType || "auto"; const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0; + const strictFieldSnapshot = captureStrictSelectionFieldSnapshot(world, rects, seed); const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; const candidateWindow = sourceWindowForRects(rects); const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); @@ -2116,12 +3061,15 @@ export function generatePatch(world, userRectInput, options = {}) { const sourceMap = world.sourceMap || (world.sourceMap = {}); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); - const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed); + const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; + const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel); patchTimer.mark("fields", "Field copy and alpha blend"); const terrainSeamDebug = featherTerrainSeam(world, rects, seed); - const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed); + const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); + const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); + const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); const maskDebug = repairDisplayMasks(world, rects, seed); - recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); + recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); patchTimer.mark("points", "Point merge"); @@ -2131,6 +3079,8 @@ export function generatePatch(world, userRectInput, options = {}) { patchTimer.mark("influence", "Influence refresh"); const landDebug = repairLanduseAndPopulation(world, rects); const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed); + const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); + const strictMaskDebug = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); const municipalCoherence = reconcileMunicipalMetadata({ adminId: world.fields.adminId, @@ -2164,7 +3114,7 @@ export function generatePatch(world, userRectInput, options = {}) { municipalCoherence: municipalCoherence.debug, prefectureMetadataCoherence: prefectureCoherence.debug, }; - const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed); + const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window); const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); patchTimer.mark("segments", "Boundary and debug segment merge"); sanitizeExistingLogistics(sourceMap); @@ -2186,6 +3136,10 @@ export function generatePatch(world, userRectInput, options = {}) { adminIdMapping: fieldDebug.adminIdMappingDebug, sourceAdminMetadataUpdated, ...finalAdminCoverageDebug, + ...adminTopologyDebug, + ...strictMaskDebug, + humanLandCellsRestored: fieldDebug.humanLandCellsRestored || 0, + humanLandFeatureMaskCells: fieldDebug.humanLandFeatureMaskCells || 0, finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, @@ -2197,6 +3151,8 @@ export function generatePatch(world, userRectInput, options = {}) { adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, ...terrainSeamDebug, + ...waterComponentDebug, + ...waterElevationDebug, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, displayMaskUpdated: maskDebug.displayMaskUpdated || 0, logisticsLabelsMigrated, @@ -2228,7 +3184,7 @@ export function generatePatch(world, userRectInput, options = {}) { patchTimings, updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, - coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged, + coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, adminIdMapping: fieldDebug.adminIdMappingDebug, diff --git a/mapPatch.js.bak b/mapPatch.js.bak new file mode 100644 index 0000000..da1dcd2 --- /dev/null +++ b/mapPatch.js.bak @@ -0,0 +1,2660 @@ +import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js"; +import { generateMap } from "./mapPipeline.js"; +import { LANDUSE } from "./landuseCodes.js"; +import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js"; + +export const PATCH_MIN_WIDTH = 48; +export const PATCH_MIN_HEIGHT = 48; +export const PATCH_MIN_AREA = 3000; + +const POINT_LAYER_KEYS = [ + "villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins", + "ports", "crossings", "passes", "modernCities", "satelliteCities", "stations", + "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters", + "externalGateways", "prefectureRegions", +]; + +const PATH_LAYER_KEYS = [ + "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", + "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways", + "icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths", +]; + +const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]); +const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]); +const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]); + +const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"]; +const PATCH_CANDIDATE_CACHE_LIMIT = 3; + +const ID_FIELD_OFFSETS = new Map([ + ["adminId", 100000], + ["municipalityId", 100000], + ["prefectureRegionId", 200000], + ["regionId", 300000], + ["naturalCompartmentId", 400000], + ["watershedId", 500000], +]); + +const DISCRETE_FIELD_NAMES = new Set([ + "sea", "ocean", "lake", "prefectureMask", "landMask", "landuse", + "adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId", +]); + + +const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]); +const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]); +const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]); + +const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]); + +function worldIndex(world, x, y) { + if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1; + return y * world.width + x; +} + +function sourceIndex(x, y) { + if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1; + return y * MAP_W + x; +} + +function isCellField(value) { + return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE; +} + +function rectWidth(rect) { + return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0)); +} + +function rectHeight(rect) { + return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0)); +} + +function rectArea(rect) { + return rectWidth(rect) * rectHeight(rect); +} + +function nowMs() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); +} + +function createPatchTimer() { + const timings = []; + let mark = nowMs(); + return { + timings, + mark(key, label = key) { + const t = nowMs(); + timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 }); + mark = t; + }, + }; +} + +function normalizeRect(rect) { + if (!rect) return null; + const x0 = Math.floor(Math.min(rect.x0, rect.x1)); + const y0 = Math.floor(Math.min(rect.y0, rect.y1)); + const x1 = Math.ceil(Math.max(rect.x0, rect.x1)); + const y1 = Math.ceil(Math.max(rect.y0, rect.y1)); + return { x0, y0, x1, y1 }; +} + +function isPolygonSelection(input) { + return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3; +} + +function clampPointToWorld(point, world) { + return { + x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)), + y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)), + }; +} + +function polygonBounds(polygon) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of polygon || []) { + if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue; + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + if (!Number.isFinite(minX)) return null; + return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) }; +} + +function polygonAreaCells(polygon) { + if (!polygon || polygon.length < 3) return 0; + let area = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area) * 0.5; +} + +function normalizeSelectionShape(input, world = null) { + if (!isPolygonSelection(input)) return normalizeRect(input); + const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) }); + const bounds = polygonBounds(polygon); + if (!bounds) return null; + return { + kind: input.kind || 'lasso', + polygon, + areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))), + x0: bounds.x0, + y0: bounds.y0, + x1: bounds.x1, + y1: bounds.y1, + }; +} + +function pointInPolygon(px, py, polygon) { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].x + 0.5; + const yi = polygon[i].y + 0.5; + const xj = polygon[j].x + 0.5; + const yj = polygon[j].y + 0.5; + const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi); + if (intersect) inside = !inside; + } + return inside; +} + +function pointSegmentDistance(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay); + const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); + return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); +} + +function distanceToPolygonEdge(px, py, polygon) { + let best = Infinity; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5)); + } + return best; +} + +function insideRect(x, y, rect) { + return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1; +} + +function expandRect(rect, margin, world = null) { + return { + x0: Math.max(0, rect.x0 - margin), + y0: Math.max(0, rect.y0 - margin), + x1: Math.min(world?.width ?? Infinity, rect.x1 + margin), + y1: Math.min(world?.height ?? Infinity, rect.y1 + margin), + }; +} + +function distanceToRectEdge(x, y, rect) { + return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y); +} + +function defaultForField(name, Constructor) { + if (name === "sea" || name === "ocean") return 1; + if (name === "elevation") return 0.08; + if (ID_FIELD_OFFSETS.has(name)) return -1; + if (Constructor === Float32Array || Constructor === Float64Array) return 0; + return 0; +} + +function ensureWorldField(world, name, source) { + if (!source || !isCellField(source)) return null; + const Constructor = source.constructor; + const expected = world.width * world.height; + if (!world.fields[name] || world.fields[name].length !== expected) { + world.fields[name] = new Constructor(expected); + const fallback = defaultForField(name, Constructor); + if (fallback !== 0) world.fields[name].fill(fallback); + } + return world.fields[name]; +} + +export function clipPatchRect(rect, world) { + const normalized = normalizeSelectionShape(rect, world); + if (!normalized || !world) return null; + if (isPolygonSelection(normalized)) return normalized; + return { + x0: Math.min(Math.max(normalized.x0, 0), world.width), + y0: Math.min(Math.max(normalized.y0, 0), world.height), + x1: Math.min(Math.max(normalized.x1, 0), world.width), + y1: Math.min(Math.max(normalized.y1, 0), world.height), + }; +} + +export function validatePatchRect(rect, world) { + const clipped = clipPatchRect(rect, world); + if (!clipped) return { ok: false, rect: null, reason: "No selected area." }; + const width = rectWidth(clipped); + const height = rectHeight(clipped); + const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height; + if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) { + const parts = []; + if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`); + if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`); + return { + ok: false, + rect: clipped, + width, + height, + area, + reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`, + }; + } + if (area < PATCH_MIN_AREA) { + return { + ok: false, + rect: clipped, + width, + height, + area, + reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`, + }; + } + return { ok: true, rect: clipped, width, height, area, reason: "" }; +} + +export function buildPatchRects(userRect, world = null) { + const coreRect = normalizeSelectionShape(userRect, world); + const width = rectWidth(coreRect); + const height = rectHeight(coreRect); + const shortSide = Math.max(1, Math.min(width, height)); + const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42))); + const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2))); + const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource)); + const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12))); + const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource)); + const writeRect = expandRect(coreRect, writeMargin, world); + const repairRect = expandRect(coreRect, repairMargin, world); + const transportReachMargin = Math.max( + repairMargin + 48, + Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15))) + ); + const transportReachRect = expandRect(coreRect, transportReachMargin, world); + return { + coreRect, + writeRect, + repairRect, + contextRect: repairRect, + transportReachRect, + blendRect: coreRect, + userRect: writeRect, + selectedRect: coreRect, + selectionShape: isPolygonSelection(coreRect) ? coreRect : null, + writeMargin, + repairMargin, + transportReachMargin, + outerMargin: writeMargin, + innerMargin: 0, + }; +} + +function computePatchAlpha(x, y, rects, seed = 0) { + const writeRect = rects.writeRect || rects.userRect; + if (!insideRect(x, y, writeRect)) return 0; + const margin = Math.max(1, rects.writeMargin || 1); + const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5; + const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5; + const shape = rects.selectionShape; + if (shape?.polygon?.length >= 3) { + const px = x + 0.5; + const py = y + 0.5; + const inside = pointInPolygon(px, py, shape.polygon); + const dist = distanceToPolygonEdge(px, py, shape.polygon); + const signedDist = inside ? dist : -dist; + const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10; + return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2))); + } + const edge = distanceToRectEdge(x, y, writeRect); + const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; + const base = smoothstep(clamp(noisyEdge / margin)); + // Keep the expanded repair band as the actual seam. The user's selected core + // is still dominant, but the write edge is irregular, so coastlines and land-use + // no longer inherit the rectangular user selection as a hard boundary. + return clamp(base); +} + +function getPatchAlphaCache(rects, seed = 0) { + const writeRect = rects?.writeRect || rects?.userRect; + if (!writeRect) return null; + const width = rectWidth(writeRect); + const height = rectHeight(writeRect); + const existing = rects.patchAlphaCache; + if ( + existing + && existing.seed === seed + && existing.width === width + && existing.height === height + && existing.x0 === writeRect.x0 + && existing.y0 === writeRect.y0 + ) return existing; + + const data = new Float32Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed); + } + } + rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data }; + return rects.patchAlphaCache; +} + +function patchAlpha(x, y, rects, seed = 0) { + const writeRect = rects?.writeRect || rects?.userRect; + if (!writeRect || !insideRect(x, y, writeRect)) return 0; + const cache = rects.patchAlphaCache; + if ( + cache + && cache.seed === seed + && x >= cache.x0 + && y >= cache.y0 + && x < cache.x0 + cache.width + && y < cache.y0 + cache.height + ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0; + return computePatchAlpha(x, y, rects, seed); +} + +function patchBand(x, y, rects, seed = 0) { + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.18) return "preserve"; + if (a >= 0.82) return "core"; + return "feather"; +} + +function continuityReplaceThreshold(name, x, y, rects, seed = 0) { + const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5; + if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92); + if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82); + return clamp(0.46 + n * 0.20, 0.28, 0.68); +} + +function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) { + if (!rects) return true; + return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha; +} + +function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) { + return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha; +} + +function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) { + if (!Array.isArray(seg) || seg.length < 2) return false; + const ax = tupleWorldX(world, seg[0]); + const ay = tupleWorldY(world, seg[0]); + const bx = tupleWorldX(world, seg[1]); + const by = tupleWorldY(world, seg[1]); + const mx = (ax + bx) * 0.5; + const my = (ay + by) * 0.5; + return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha); +} + +function segmentTouchesRect(world, seg, rect) { + if (!Array.isArray(seg) || seg.length < 2 || !rect) return false; + const ax = tupleWorldX(world, seg[0]); + const ay = tupleWorldY(world, seg[0]); + const bx = tupleWorldX(world, seg[1]); + const by = tupleWorldY(world, seg[1]); + const mx = (ax + bx) * 0.5; + const my = (ay + by) * 0.5; + if (insideRect(ax, ay, rect) || insideRect(bx, by, rect) || insideRect(mx, my, rect)) return true; + const minX = Math.min(ax, bx); + const maxX = Math.max(ax, bx); + const minY = Math.min(ay, by); + const maxY = Math.max(ay, by); + return maxX >= rect.x0 && minX < rect.x1 && maxY >= rect.y0 && minY < rect.y1; +} + +function quantizedSegmentKey(seg) { + if (!Array.isArray(seg) || seg.length < 2) return ""; + const p = seg.map(([x, y]) => [Math.round(x * 4) / 4, Math.round(y * 4) / 4]); + const a = `${p[0][0]},${p[0][1]}`; + const b = `${p[1][0]},${p[1][1]}`; + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +function dedupeSegments(segments) { + const seen = new Set(); + const out = []; + for (const seg of segments || []) { + const key = quantizedSegmentKey(seg); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(seg); + } + return out; +} + + +function sourceWindowForRects(rects) { + const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; + const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2; + return { + worldCenterX: cx, + worldCenterY: cy, + sourceCenterX: (MAP_W - 1) / 2, + sourceCenterY: (MAP_H - 1) / 2, + }; +} + +function sourceCoordForWorld(window, x, y) { + return { + x: Math.round(x - window.worldCenterX + window.sourceCenterX), + y: Math.round(y - window.worldCenterY + window.sourceCenterY), + }; +} + +function getPatchSourceIndexCache(rects, window) { + const writeRect = rects?.writeRect; + if (!writeRect || !window) return null; + const width = rectWidth(writeRect); + const height = rectHeight(writeRect); + const existing = rects.patchSourceIndexCache; + if ( + existing + && existing.width === width + && existing.height === height + && existing.x0 === writeRect.x0 + && existing.y0 === writeRect.y0 + && existing.worldCenterX === window.worldCenterX + && existing.worldCenterY === window.worldCenterY + && existing.sourceCenterX === window.sourceCenterX + && existing.sourceCenterY === window.sourceCenterY + ) return existing; + + const data = new Int32Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX); + const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY); + data[y * width + x] = sourceIndex(sx, sy); + } + } + rects.patchSourceIndexCache = { + width, + height, + x0: writeRect.x0, + y0: writeRect.y0, + worldCenterX: window.worldCenterX, + worldCenterY: window.worldCenterY, + sourceCenterX: window.sourceCenterX, + sourceCenterY: window.sourceCenterY, + data, + }; + return rects.patchSourceIndexCache; +} + +function sourceIndexForWorld(rects, window, x, y) { + const cache = rects?.patchSourceIndexCache; + if ( + cache + && x >= cache.x0 + && y >= cache.y0 + && x < cache.x0 + cache.width + && y < cache.y0 + cache.height + ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)]; + const s = sourceCoordForWorld(window, x, y); + return sourceIndex(s.x, s.y); +} + +function worldCoordForSource(window, sx, sy) { + return { + x: Math.round(sx - window.sourceCenterX + window.worldCenterX), + y: Math.round(sy - window.sourceCenterY + window.worldCenterY), + }; +} + +function fieldIdOffset(name, seed) { + const base = ID_FIELD_OFFSETS.get(name) || 0; + if (!base) return 0; + return base + ((seed >>> 0) % 997) * 10000; +} + +function maxFieldId(field) { + if (!field) return -1; + let max = -1; + for (let i = 0; i < field.length; i++) { + const id = field[i]; + if (Number.isFinite(id) && id > max) max = id; + } + return max; +} + +function addMappingVote(votes, from, to, weight = 1) { + if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return; + const key = Math.floor(from); + const target = Math.floor(to); + const bucket = votes.get(key) || new Map(); + bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight)); + votes.set(key, bucket); +} + +function chooseVotedTarget(bucket, minVotes = 1) { + let best = -1; + let bestVotes = 0; + for (const [target, count] of bucket || []) { + if (count > bestVotes || (count === bestVotes && target < best)) { + best = target; + bestVotes = count; + } + } + return best >= 0 && bestVotes >= minVotes ? best : -1; +} + +function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) { + const ids = new Set(); + if (!candidateField) return ids; + const rect = rects.writeRect; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + if (patchAlpha(x, y, rects, seed) < minAlpha) continue; + const si = sourceIndexForWorld(rects, window, x, y); + if (si < 0) continue; + const id = candidateField[si]; + if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id)); + } + } + return ids; +} + +function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) { + const out = new Map(); + const direct = candidateMap?.municipalityToPrefectureId; + for (const id of candidateAdminIds || []) { + const pref = direct?.[id]; + if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref)); + } + const adminField = candidateMap?.adminId || candidateMap?.municipalityId; + const prefField = candidateMap?.prefectureRegionId; + if (!adminField || !prefField) return out; + const votes = new Map(); + for (let i = 0; i < adminField.length; i++) { + const admin = adminField[i]; + const pref = prefField[i]; + if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue; + if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue; + addMappingVote(votes, admin, pref, 1); + } + for (const [admin, bucket] of votes) { + if (!out.has(admin)) { + const pref = chooseVotedTarget(bucket, 1); + if (pref >= 0) out.set(admin, pref); + } + } + return out; +} + +export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) { + const actualWindow = window || (rects ? sourceWindowForRects(rects) : null); + const actualRects = rects || { writeRect, writeMargin: seamBand || 1 }; + if (!candidateMap || !world || !writeRect || !actualWindow) { + return { + prefecture: new Map(), + municipality: new Map(), + admin: new Map(), + candidateAdminToPrefecture: new Map(), + debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 }, + }; + } + + const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId; + const candidateMunicipality = candidateMap.municipalityId || candidateAdmin; + const candidatePrefecture = candidateMap.prefectureRegionId; + const worldAdmin = world.fields?.adminId || world.fields?.municipalityId; + const worldMunicipality = world.fields?.municipalityId || worldAdmin; + const worldPrefecture = world.fields?.prefectureRegionId; + + const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed); + const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed); + const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed); + const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds); + + const adminVotes = new Map(); + const municipalityVotes = new Map(); + const prefectureVotes = new Map(); + const band = Math.max(2, Math.floor(seamBand)); + const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + + for (let y = writeRect.y0; y < writeRect.y1; y++) { + for (let x = writeRect.x0; x < writeRect.x1; x++) { + const edge = distanceToRectEdge(x, y, writeRect); + if (edge > band) continue; + const s = sourceCoordForWorld(actualWindow, x, y); + const si = sourceIndex(s.x, s.y); + const wi = worldIndex(world, x, y); + if (si < 0 || wi < 0) continue; + const cAdmin = candidateAdmin?.[si] ?? -1; + const cMunicipality = candidateMunicipality?.[si] ?? cAdmin; + const cPrefecture = candidatePrefecture?.[si] ?? -1; + const sameCellWeight = Math.max(1, band + 1 - edge); + addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight); + addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight); + addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight); + + for (const [dx, dy] of dirs) { + for (let step = 1; step <= 6; step++) { + const nx = x + dx * step; + const ny = y + dy * step; + if (insideRect(nx, ny, writeRect)) continue; + const ni = worldIndex(world, nx, ny); + if (ni < 0) break; + const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25; + addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w); + addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w); + addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w); + break; + } + } + } + } + + const prefecture = new Map(); + let nextPrefectureId = maxFieldId(worldPrefecture) + 1; + let prefecturesMappedToExisting = 0; + let prefecturesAllocated = 0; + for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) { + const voted = chooseVotedTarget(prefectureVotes.get(id), 3); + if (voted >= 0) { + prefecture.set(id, voted); + prefecturesMappedToExisting++; + } else { + prefecture.set(id, nextPrefectureId++); + prefecturesAllocated++; + } + } + + const usedAdminIds = new Set(); + if (worldAdmin) { + for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]); + } + const municipality = new Map(); + const admin = new Map(); + let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1; + let municipalitiesMappedToExisting = 0; + let municipalitiesAllocated = 0; + const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]); + for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) { + const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4); + if (voted >= 0) { + municipality.set(id, voted); + admin.set(id, voted); + municipalitiesMappedToExisting++; + } else { + while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++; + municipality.set(id, nextMunicipalityId); + admin.set(id, nextMunicipalityId); + usedAdminIds.add(nextMunicipalityId); + nextMunicipalityId++; + municipalitiesAllocated++; + } + } + + const municipalityToPrefecture = new Map(); + for (const [candidateAdminId, worldAdminId] of admin) { + const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId); + const worldPrefId = prefecture.get(candidatePrefId); + if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId); + } + + return { + prefecture, + municipality, + admin, + candidateAdminToPrefecture, + municipalityToPrefecture, + debug: { + candidatePrefectureIds: candidatePrefectureIds.size, + candidateMunicipalityIds: allMunicipalityIds.size, + prefecturesMappedToExisting, + prefecturesAllocated, + municipalitiesMappedToExisting, + municipalitiesAllocated, + }, + }; +} + +function remapAdminCandidateValue(name, raw, adminIdMapping) { + if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw; + const id = Math.floor(raw); + if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw; + if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw; + if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw; + return raw; +} + +function numericFeatureId(point, keys) { + for (const key of keys) { + const value = point?.[key]; + if (Number.isFinite(value) && value >= 0) return Math.floor(value); + } + return -1; +} + +function summarizeIdMapping(mapping) { + return { ...(mapping?.debug || {}) }; +} + +function updateSourceAdminMetadata(sourceMap, adminIdMapping) { + if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0; + let maxId = -1; + const current = sourceMap.municipalityToPrefectureId; + if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1); + for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId); + const next = new Int32Array(Math.max(0, maxId + 1)); + next.fill(-1); + if (current && typeof current.length === "number") { + for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; + } + let updated = 0; + for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) { + if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue; + if (next[adminId] !== prefId) updated++; + next[adminId] = prefId; + } + sourceMap.municipalityToPrefectureId = next; + sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping); + return updated; +} + +function cloneContinuityFields(world) { + const out = new Map(); + for (const name of CONTINUITY_FIELD_NAMES) { + const field = world?.fields?.[name]; + if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); + } + return out; +} + +function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { + let restored = 0; + let remapped = 0; + const margin = Math.max(2, rects.writeMargin || 1); + for (const name of CONTINUITY_FIELD_NAMES) { + const field = world.fields?.[name]; + const old = oldFields?.get(name); + if (!field || !old) continue; + const isPrefecture = name === "prefectureRegionId"; + const isAdmin = name === "adminId" || name === "municipalityId"; + const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74; + const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72; + + // First preserve the old IDs in the transition band. This prevents the + // writeRect edge from becoming a prefecture/municipal border. + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || old[i] < 0) continue; + const edge = distanceToRectEdge(x, y, rects.writeRect); + const a = patchAlpha(x, y, rects, seed); + if (edge <= preserveEdge || a < preserveAlpha) { + if (field[i] !== old[i]) { field[i] = old[i]; restored++; } + } + } + } + + // Then map candidate IDs that contact an outside ID back to that outside ID. + // This lets prefectures/municipalities cross the generated-area seam instead + // of creating a new border exactly on the seam. + const contacts = new Map(); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) { + for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || field[i] < 0 || old[i] === field[i]) continue; + const a = patchAlpha(x, y, rects, seed); + if (a < 0.98 && !isPrefecture) continue; + for (const [dx, dy] of dirs) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue; + if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) { + const key = field[i]; + const bucket = contacts.get(key) || new Map(); + bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1); + contacts.set(key, bucket); + } + } + } + } + const mapping = new Map(); + for (const [from, bucket] of contacts) { + let best = -1, bestCount = 0; + for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; } + if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best); + } + if (mapping.size) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; } + } + } + } + } + return { continuityCellsRestored: restored, continuityCellsRemapped: remapped }; +} + +function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) { + const a = patchAlpha(x, y, rects, seed); + const i = worldIndex(world, x, y); + const oldValue = oldField?.[i] ?? -1; + if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue; + if (a <= 0.24) return oldValue; + if (a >= 0.82) return candidateValue; + + const field = world.fields?.[fieldName]; + const pref = world.fields?.prefectureRegionId; + const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField; + let oldScore = (1 - a) * 3.0; + let candidateScore = a * 3.0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + const neighbor = field?.[ni] ?? -1; + if (neighbor === oldValue) oldScore += 1.2; + if (neighbor === candidateValue) candidateScore += 1.2; + if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) { + if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18; + } + } + const barrierBonus = naturalBarrier?.[i] || 0; + if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) { + if (a < 0.5) oldScore += barrierBonus * 0.9; + else candidateScore += barrierBonus * 0.9; + } + return candidateScore > oldScore ? candidateValue : oldValue; +} + +function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { + let adminSeamCellsResolved = 0; + let prefectureSeamCellsResolved = 0; + for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) { + const field = world.fields?.[name]; + const old = oldFields?.get(name); + if (!field || !old) continue; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || world.fields.sea?.[i]) continue; + if (patchBand(x, y, rects, seed) !== "feather") continue; + const before = field[i]; + const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed); + if (next !== before) { + field[i] = next; + if (name === "prefectureRegionId") prefectureSeamCellsResolved++; + else adminSeamCellsResolved++; + } + } + } + } + if (world.fields.adminId && world.fields.municipalityId) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i]; + } + } + } + return { adminSeamCellsResolved, prefectureSeamCellsResolved }; +} + +function copyFullPipelineFields(world, candidate, rects, seed) { + const window = sourceWindowForRects(rects); + const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; + const oldContinuityFields = cloneContinuityFields(world); + const adminIdMapping = buildAdminIdMapping({ + candidateMap: candidate, + world, + writeRect: rects.writeRect, + seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)), + window, + rects, + seed, + }); + let updatedCells = 0; + let coastCellsChanged = 0; + let terrainCellsFullyReplaced = 0; + let naturalRegionsUpdated = 0; + let adminCellsReassigned = 0; + let landUseCellsUpdated = 0; + + for (const [name, source] of Object.entries(candidate || {})) { + if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue; + const dest = ensureWorldField(world, name, source); + if (!dest) continue; + const isFloat = source.constructor === Float32Array || source.constructor === Float64Array; + const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat; + const idOffset = fieldIdOffset(name, seed); + + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi < 0) continue; + const si = sourceIndexForWorld(rects, window, x, y); + if (si < 0) continue; + const alpha = patchAlpha(x, y, rects, seed); + if (alpha <= 0.005) continue; + + if (isDiscrete) { + const threshold = continuityReplaceThreshold(name, x, y, rects, seed); + if (alpha >= threshold) { + const raw = source[si]; + const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); + const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") + ? mapped + : idOffset && raw >= 0 ? raw + idOffset : raw; + if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++; + if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++; + if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++; + if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++; + dest[wi] = value; + } + } else { + const before = dest[wi] || 0; + dest[wi] = lerp(before, source[si] || 0, alpha); + } + + if (name === "elevation") { + updatedCells++; + if (alpha > 0.94) terrainCellsFullyReplaced++; + } + } + } + } + + // The legacy full pipeline uses `adminId` as the municipality raster and + // assigns municipality metadata on `adminCenters`; it does not expose a + // separate municipalityId cell field. If an old experimental field exists, + // keep it synchronized with the canonical legacy adminId instead of leaving + // stale numeric/one-municipality data in regenerated patches. + if (world.fields.adminId && !candidate?.municipalityId) { + const expected = world.width * world.height; + if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { + world.fields.municipalityId = new Int32Array(expected); + world.fields.municipalityId.fill(-1); + } + const municipalityId = world.fields.municipalityId; + const adminId = world.fields.adminId; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi >= 0) municipalityId[wi] = adminId[wi]; + } + } + } + + // Keep water fields coherent after all continuous fields have been blended. + const sea = world.fields.sea; + const ocean = world.fields.ocean; + const lake = world.fields.lake; + const landuse = world.fields.landuse; + if (sea) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + if (sea[i]) { + if (ocean) ocean[i] = 1; + if (lake) lake[i] = 0; + if (landuse) landuse[i] = LANDUSE.WATER || 0; + } else { + if (ocean) ocean[i] = 0; + if (lake) lake[i] = 0; + } + } + } + } + + const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); + const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed); + if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi]; + } + } + } + + return { + window, + updatedCells, + terrainCellsFullyReplaced, + coastCellsChanged, + naturalRegionsUpdated, + adminCellsReassigned, + landUseCellsUpdated, + adminIdMapping, + adminIdMappingDebug: summarizeIdMapping(adminIdMapping), + ...continuityDebug, + ...seamOwnershipDebug, + }; +} + + +function repairDisplayMasks(world, rects, seed = 0) { + const expected = world.width * world.height; + if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected); + if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected); + if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected); + const coverage = world.fields.prefectureMask; + const landMask = world.fields.landMask; + const humanMask = world.fields.humanRegionMask; + const sea = world.fields.sea; + let displayMaskUpdated = 0; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.08) continue; + if (!coverage[i]) displayMaskUpdated++; + coverage[i] = 1; + const isSea = Boolean(sea?.[i]); + humanMask[i] = isSea ? 0 : 1; + landMask[i] = isSea ? 0 : 1; + } + } + return { displayMaskUpdated }; +} + +function featherTerrainSeam(world, rects, seed = 0) { + const fields = world.fields || {}; + const smoothKeys = [ + "elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField", + "basinField", "coastalLowland", "plain", "agriculture", "erosionField", + "depositionField", "depositionalLowland", "alluvialFanField", "deltaField", + "naturalBarrierScore", "settlementScore", "populationDensity", + ]; + let terrainFeatherCells = 0; + let terrainFeatherValues = 0; + for (const key of smoothKeys) { + const field = fields[key]; + if (!field || !ArrayBuffer.isView(field)) continue; + const old = new field.constructor(field); + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + if (patchBand(x, y, rects, seed) !== "feather") continue; + const i = worldIndex(world, x, y); + if (i < 0 || fields.sea?.[i]) continue; + let sum = 0; + let count = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni >= 0 && !fields.sea?.[ni]) { + sum += old[ni] || 0; + count++; + } + } + if (!count) continue; + const a = patchAlpha(x, y, rects, seed); + const neighborMean = sum / count; + const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2); + field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34)); + terrainFeatherValues++; + if (key === "elevation") terrainFeatherCells++; + } + } + } + return { terrainFeatherCells, terrainFeatherValues }; +} + + +function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { + const field = world.fields?.[fieldName]; + const sea = world.fields?.sea; + if (!field) return -1; + const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18)); + const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null; + const prefField = world.fields?.prefectureRegionId; + for (let r = 1; r <= maxRadius; r++) { + let best = -1; + let bestD = Infinity; + const y0 = Math.max(0, y - r); + const y1 = Math.min(world.height - 1, y + r); + const x0 = Math.max(0, x - r); + const x1 = Math.min(world.width - 1, x + r); + for (let yy = y0; yy <= y1; yy++) { + for (let xx = x0; xx <= x1; xx++) { + if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; + if (rect && !insideRect(xx, yy, rect)) continue; + const i = worldIndex(world, xx, yy); + if (i < 0 || sea?.[i]) continue; + if (requiredPref !== null && prefField?.[i] !== requiredPref) continue; + const id = field[i]; + if (!Number.isFinite(id) || id < 0) continue; + const d = Math.hypot(xx - x, yy - y); + if (d < bestD) { best = Math.floor(id); bestD = d; } + } + } + if (best >= 0) return best; + } + return -1; +} + +function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) { + if (!Number.isFinite(adminId) || adminId < 0) return -1; + const id = Math.floor(adminId); + const mapped = adminIdMapping?.municipalityToPrefecture?.get(id); + if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped); + const table = sourceMap?.municipalityToPrefectureId; + if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]); + return -1; +} + +function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) { + const admin = world.fields?.adminId; + if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 }; + const expected = world.width * world.height; + if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { + world.fields.municipalityId = new Int32Array(expected); + world.fields.municipalityId.fill(-1); + } + if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) { + world.fields.prefectureRegionId = new Int32Array(expected); + world.fields.prefectureRegionId.fill(-1); + } + const municipality = world.fields.municipalityId; + const prefecture = world.fields.prefectureRegionId; + const sea = world.fields.sea; + const coverage = world.fields.prefectureMask; + let seaAdminCellsCleared = 0; + let landAdminCellsFilled = 0; + let prefectureCellsFilled = 0; + let adminPrefectureCellsAligned = 0; + + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + if (sea?.[i]) { + if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++; + admin[i] = -1; + municipality[i] = -1; + prefecture[i] = -1; + continue; + } + const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect)); + if (!generated) continue; + + if (admin[i] < 0) { + const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null; + let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 }); + if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 }); + if (nearest >= 0) { + admin[i] = nearest; + municipality[i] = nearest; + landAdminCellsFilled++; + } + } + if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i]; + if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i]; + + let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]); + if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 }); + if (targetPref >= 0 && prefecture[i] !== targetPref) { + if (prefecture[i] < 0) prefectureCellsFilled++; + else adminPrefectureCellsAligned++; + prefecture[i] = targetPref; + } else if (prefecture[i] < 0) { + const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 }); + if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; } + } + } + } + + const current = sourceMap?.municipalityToPrefectureId; + let maxId = current?.length ? current.length - 1 : -1; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]); + } + } + if (sourceMap && maxId >= 0) { + const next = new Int32Array(maxId + 1); + next.fill(-1); + if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i]; + } + } + sourceMap.municipalityToPrefectureId = next; + } + + return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; +} + +function protectedAdministrativeCells(world, fieldName, rect) { + const protectedCells = new Set(); + const sourceMap = world?.sourceMap || {}; + const pointKeys = fieldName === "prefectureRegionId" ? ["prefectureRegions"] : ["adminCenters"]; + const field = world?.fields?.[fieldName]; + if (!field) return protectedCells; + for (const key of pointKeys) { + for (const p of sourceMap[key] || []) { + const x = Math.round(pointWorldX(world, p)); + const y = Math.round(pointWorldY(world, p)); + if (!insideRect(x, y, rect)) continue; + const i = worldIndex(world, x, y); + if (i >= 0 && field[i] >= 0) protectedCells.add(i); + } + } + return protectedCells; +} + +function modeFromCounts(counts) { + let best = -1; + let bestCount = 0; + for (const [id, count] of counts || []) { + if (count > bestCount || (count === bestCount && id < best)) { + best = id; + bestCount = count; + } + } + return best; +} + +function addNestedVote(map, key, value, weight = 1) { + if (!Number.isFinite(key) || key < 0 || !Number.isFinite(value) || value < 0) return; + const id = Math.floor(key); + const bucket = map.get(id) || new Map(); + bucket.set(Math.floor(value), (bucket.get(Math.floor(value)) || 0) + weight); + map.set(id, bucket); +} + +function cleanupDiscreteFieldComponents(world, fieldName, rect, options = {}) { + const field = world?.fields?.[fieldName]; + const sea = world?.fields?.sea; + if (!field || !rect) return { componentsMerged: 0, cellsMerged: 0 }; + const minCells = Math.max(1, Math.floor(options.minCells || 80)); + const passes = Math.max(1, Math.floor(options.passes || 2)); + const respectPrefecture = !!options.respectPrefecture; + const pref = world.fields?.prefectureRegionId; + const protectedCells = protectedAdministrativeCells(world, fieldName, rect); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + let componentsMerged = 0; + let cellsMerged = 0; + + for (let pass = 0; pass < passes; pass++) { + const seen = new Uint8Array(world.width * world.height); + const reassignments = []; + + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const start = worldIndex(world, x, y); + if (start < 0 || seen[start] || sea?.[start] || field[start] < 0) continue; + + const id = field[start]; + const stack = [start]; + seen[start] = 1; + const cells = []; + const neighborVotes = new Map(); + const neighborPrefVotes = new Map(); + const ownPrefVotes = new Map(); + let touchesRectEdge = false; + let hasProtectedPoint = false; + + while (stack.length) { + const ci = stack.pop(); + const cx = ci % world.width; + const cy = Math.floor(ci / world.width); + cells.push(ci); + if (protectedCells.has(ci)) hasProtectedPoint = true; + if (cx <= rect.x0 || cy <= rect.y0 || cx >= rect.x1 - 1 || cy >= rect.y1 - 1) touchesRectEdge = true; + if (pref?.[ci] >= 0) ownPrefVotes.set(pref[ci], (ownPrefVotes.get(pref[ci]) || 0) + 1); + + for (const [dx, dy] of dirs) { + const nx = cx + dx; + const ny = cy + dy; + const ni = worldIndex(world, nx, ny); + if (ni < 0 || sea?.[ni]) continue; + const nid = field[ni]; + if (insideRect(nx, ny, rect) && nid === id && !seen[ni]) { + seen[ni] = 1; + stack.push(ni); + } else if (nid >= 0 && nid !== id) { + neighborVotes.set(nid, (neighborVotes.get(nid) || 0) + 1); + if (pref?.[ni] >= 0) addNestedVote(neighborPrefVotes, nid, pref[ni], 1); + } + } + } + + // Components touching the cleanup rectangle boundary may be only the + // visible slice of a large outside municipality/prefecture. Preserve + // those unless they are extremely small; otherwise patched seams can + // erase legitimate existing regions. + const clippedLargeOutsideRegion = touchesRectEdge && cells.length >= Math.floor(minCells * 0.55); + if (hasProtectedPoint || clippedLargeOutsideRegion || cells.length >= minCells || !neighborVotes.size) continue; + + const componentPref = respectPrefecture ? modeFromCounts(ownPrefVotes) : -1; + let bestTarget = -1; + let bestScore = -Infinity; + for (const [target, count] of neighborVotes) { + let score = count; + if (respectPrefecture && componentPref >= 0) { + const targetPref = modeFromCounts(neighborPrefVotes.get(target)); + if (targetPref === componentPref) score += count * 0.85; + else score -= count * 0.45; + } + if (score > bestScore || (score === bestScore && target < bestTarget)) { + bestTarget = target; + bestScore = score; + } + } + if (bestTarget < 0) continue; + for (const ci of cells) reassignments.push([ci, bestTarget]); + componentsMerged++; + cellsMerged += cells.length; + } + } + + if (!reassignments.length) break; + for (const [i, target] of reassignments) field[i] = target; + } + + return { componentsMerged, cellsMerged }; +} + +function repairPatchAdministrativeTopology(world, rects) { + const rect = rects.repairRect || rects.writeRect; + const prefecture = cleanupDiscreteFieldComponents(world, "prefectureRegionId", rect, { minCells: 420, passes: 3 }); + const admin = cleanupDiscreteFieldComponents(world, "adminId", rect, { minCells: 96, passes: 4, respectPrefecture: true }); + let municipalityCellsSynced = 0; + if (world.fields?.adminId && world.fields?.municipalityId) { + const adminId = world.fields.adminId; + const municipalityId = world.fields.municipalityId; + const sea = world.fields.sea; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + const next = sea?.[i] ? -1 : adminId[i]; + if (municipalityId[i] !== next) { + municipalityId[i] = next; + municipalityCellsSynced++; + } + } + } + } + return { + prefectureTinyComponentsMerged: prefecture.componentsMerged, + prefectureTinyCellsMerged: prefecture.cellsMerged, + adminTinyComponentsMerged: admin.componentsMerged, + adminTinyCellsMerged: admin.cellsMerged, + municipalityCellsSynced, + }; +} + +function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { + const sea = world.fields.sea; + const ocean = world.fields.ocean; + const lake = world.fields.lake; + const elevation = world.fields.elevation; + if (!sea || !elevation) return { coastCellsChanged: 0 }; + let changed = 0; + for (let pass = 0; pass < 3; pass++) { + const flips = []; + for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) { + for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + let seaN = 0; + let landN = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + if (sea[ni]) seaN++; else landN++; + } + } + const a = rects ? patchAlpha(x, y, rects, seed) : 1; + if (a < 0.24) continue; + const strongOnly = a < 0.42; + if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]); + else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]); + } + } + for (const [i, nextSea] of flips) { + if (sea[i] === nextSea) continue; + sea[i] = nextSea; + if (ocean) ocean[i] = nextSea; + if (lake) lake[i] = 0; + if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); + else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); + changed++; + } + } + return { coastCellsChanged: changed }; +} + +function repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) { + const sea = world.fields.sea; + const ocean = world.fields.ocean; + const lake = world.fields.lake; + const elevation = world.fields.elevation; + const landuse = world.fields.landuse; + const rect = rects?.writeRect; + if (!sea || !rect) return { waterComponentsScanned: 0, tinyWaterComponentsRemoved: 0, tinyLandIslandsRemoved: 0, waterTopologyCellsFlipped: 0 }; + + const expected = world.width * world.height; + const visited = new Uint8Array(expected); + const activeMinAlpha = 0.22; + const preserveAlpha = 0.40; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + const active = (x, y) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= activeMinAlpha; + const tinyWaterLimit = Math.max(14, Math.min(36, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.20))); + const tinyLandLimit = Math.max(10, Math.min(28, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.16))); + + let waterComponentsScanned = 0; + let tinyWaterComponentsRemoved = 0; + let tinyLandIslandsRemoved = 0; + let waterTopologyCellsFlipped = 0; + + const flipCell = (i, nextSea) => { + if (sea[i] === nextSea) return; + sea[i] = nextSea; + if (ocean) ocean[i] = nextSea; + if (lake) lake[i] = 0; + if (elevation) { + if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); + else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); + } + if (landuse) landuse[i] = nextSea ? (LANDUSE.WATER || LANDUSE.RURAL || 0) : (LANDUSE.RURAL || 0); + waterTopologyCellsFlipped++; + }; + + for (let y0 = rect.y0; y0 < rect.y1; y0++) { + for (let x0 = rect.x0; x0 < rect.x1; x0++) { + if (!active(x0, y0)) continue; + const start = worldIndex(world, x0, y0); + if (start < 0 || visited[start]) continue; + const value = sea[start] ? 1 : 0; + const stack = [[x0, y0]]; + const cells = []; + let sumElevation = 0; + let elevationCount = 0; + let touchesWeakPatchEdge = false; + let touchesSameOutsideActive = false; + let oppositeBorder = 0; + let sameBorder = 0; + + visited[start] = 1; + while (stack.length) { + const [x, y] = stack.pop(); + const i = worldIndex(world, x, y); + if (i < 0) continue; + cells.push(i); + if (elevation) { sumElevation += elevation[i] || 0; elevationCount++; } + if (patchAlpha(x, y, rects, seed) < preserveAlpha || x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) { + touchesWeakPatchEdge = true; + } + for (const [dx, dy] of dirs) { + const nx = x + dx; + const ny = y + dy; + const ni = worldIndex(world, nx, ny); + if (ni < 0) continue; + const nv = sea[ni] ? 1 : 0; + if (nv !== value) { + oppositeBorder++; + continue; + } + sameBorder++; + if (!active(nx, ny)) { + touchesSameOutsideActive = true; + continue; + } + if (!visited[ni]) { + visited[ni] = 1; + stack.push([nx, ny]); + } + } + } + + waterComponentsScanned++; + const area = cells.length; + const avgElevation = elevationCount ? sumElevation / elevationCount : seaLevel; + const isolatedInsidePatch = !touchesWeakPatchEdge && !touchesSameOutsideActive; + if (value === 1) { + if (isolatedInsidePatch && area <= tinyWaterLimit && avgElevation > seaLevel - 0.055) { + for (const i of cells) flipCell(i, 0); + tinyWaterComponentsRemoved++; + } + } else { + const mostlySurroundedBySea = oppositeBorder > sameBorder * 0.72; + if (isolatedInsidePatch && mostlySurroundedBySea && area <= tinyLandLimit && avgElevation < seaLevel + 0.045) { + for (const i of cells) flipCell(i, 1); + tinyLandIslandsRemoved++; + } + } + } + } + + return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped }; +} + +function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) { + const elevation = world.fields?.elevation; + const sea = world.fields?.sea; + if (!elevation || !sea || !rects?.writeRect) return { waterElevationCellsSmoothed: 0 }; + const rect = rects.writeRect; + let waterElevationCellsSmoothed = 0; + + // Do not diffuse water elevation row-by-row. Diffusion made broad patched + // ocean/sea areas inherit candidate raster bands, which appeared as horizontal + // stripes. Instead assign a stable world-coordinate bathymetry target and + // blend toward it by patch alpha. The renderer also avoids DEM hillshade for + // water, but keeping the underlying water DEM coherent prevents dependent + // fields from reintroducing stripe artefacts later. + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || !sea[i]) continue; + const a = patchAlpha(x, y, rects, seed); + if (a < 0.08) continue; + + let seaNear = 0; + let totalNear = 0; + for (let dy = -3; dy <= 3; dy++) { + for (let dx = -3; dx <= 3; dx++) { + if (!dx && !dy) continue; + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + totalNear++; + if (sea[ni]) seaNear++; + } + } + const offshore = totalNear ? seaNear / totalNear : 1; + const broad = valueNoise(x, y, seed ^ 0x6d2b79f5, 86); + const mid = valueNoise(x, y, seed ^ 0x2f31c9a7, 31); + const texture = broad * 0.75 + mid * 0.25; + const depth = clamp(0.032 + offshore * 0.060 + (texture - 0.5) * 0.018, 0.018, 0.125); + const target = seaLevel - depth; + const before = elevation[i]; + const strength = clamp(0.46 + a * 0.42, 0.42, 0.86); + elevation[i] = clamp(lerp(Math.min(before, seaLevel - 0.004), target, strength), seaLevel - 0.16, seaLevel - 0.004); + if (Math.abs(elevation[i] - before) > 1e-6) waterElevationCellsSmoothed++; + } + } + + return { waterElevationCellsSmoothed }; +} + +function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) { + const fields = world.fields; + const { elevation, sea } = fields; + if (!elevation || !sea) return; + if (!fields.slope) fields.slope = new Float32Array(world.width * world.height); + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + if (sea[i]) { + for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) { + if (fields[key]) fields[key][i] = 0; + } + continue; + } + if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) { + const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)]; + const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)]; + fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2); + } + if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25); + if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28); + } + } +} + +function seaNeighbors(world, x, y, radius = 1) { + let count = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!dx && !dy) continue; + const i = worldIndex(world, x + dx, y + dy); + if (i >= 0 && world.fields.sea?.[i]) count++; + } + } + return count; +} + +function isLand(world, x, y) { + const i = worldIndex(world, x, y); + return i >= 0 && !world.fields.sea?.[i]; +} + +function nearestLand(world, x, y, rect, radius = 10) { + if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y }; + for (let r = 1; r <= radius; r++) { + let best = null; + let bestScore = Infinity; + for (let yy = y - r; yy <= y + r; yy++) { + for (let xx = x - r; xx <= x + r; xx++) { + if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue; + if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue; + const i = worldIndex(world, xx, yy); + const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3; + if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; } + } + } + if (best) return best; + } + return null; +} + +function pointWorldX(world, p) { + if (Number.isFinite(p?.worldX)) return p.worldX; + return (p?.x || 0) + (world?.originX || 0); +} + +function pointWorldY(world, p) { + if (Number.isFinite(p?.worldY)) return p.worldY; + return (p?.y || 0) + (world?.originY || 0); +} + +function tupleWorldX(world, tuple) { + return (tuple?.[0] || 0) + (world?.originX || 0); +} + +function tupleWorldY(world, tuple) { + return (tuple?.[1] || 0) + (world?.originY || 0); +} + +function sourcePointFromWorld(world, point) { + return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true }; +} + +function sourcePathFromWorld(world, path) { + return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); +} + +function offsetPointNumericFields(point, fields, offset) { + for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset; +} + +function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) { + const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); + if (rawAdminId >= 0) { + const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId); + if (Number.isFinite(mappedAdminId)) { + point.sourceAdminId = rawAdminId; + point.adminId = mappedAdminId; + point.adminNumericId = mappedAdminId; + point.municipalityId = mappedAdminId; + } else { + offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed)); + if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId; + if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId; + } + } + + const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]); + if (rawPrefectureId >= 0) { + const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId); + if (Number.isFinite(mappedPrefectureId)) { + point.sourcePrefectureRegionId = rawPrefectureId; + point.prefectureRegionId = mappedPrefectureId; + if (key === "prefectureRegions") point.id = mappedPrefectureId; + } else { + const offset = fieldIdOffset("prefectureRegionId", seed); + if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset; + if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset; + } + } + + if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId; + if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId; + return point; +} + +function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) { + if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; + const w = worldCoordForSource(window, p.x, p.y); + if (key === "ports") { + const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8); + if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null; + w.x = land.x; w.y = land.y; + } else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) { + const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5); + if (!land) return null; + w.x = land.x; w.y = land.y; + } + const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y }); + normalizeGeneratedPointIds(out, key, seed, adminIdMapping); + if (key === "adminCenters") { + if (Number.isFinite(out.sourceAdminId)) { + const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId); + const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId); + if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId; + } + } + if (key === "logisticsParks") sanitizeLogisticsPark(out); + return out; +} + +function sanitizeLogisticsPark(p) { + if (!p) return p; + p.name = null; + p.labelName = null; + p.facilityLabel = p.facilityLabel || "Logistics Park"; + p.labelStyle = "facility"; + p.suppressSettlementLabel = true; + p.kind = "Logistics Park"; + p.population = 0; + return p; +} + +function sanitizeExistingLogistics(sourceMap) { + let migrated = 0; + if (!Array.isArray(sourceMap.logisticsParks)) return 0; + for (const p of sourceMap.logisticsParks) { + if (!p) continue; + if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++; + sanitizeLogisticsPark(p); + } + for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) { + const arr = sourceMap[key]; + if (!Array.isArray(arr)) continue; + for (const p of arr) { + if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue; + p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null; + p.labelName = p.name; + migrated++; + } + } + return migrated; +} + +function transformCandidatePath(window, path) { + const out = []; + for (const tuple of path || []) { + if (!Array.isArray(tuple) || tuple.length < 2) continue; + const p = worldCoordForSource(window, tuple[0], tuple[1]); + out.push([p.x, p.y]); + } + return out; +} + +function splitWorldPathByPredicate(path, predicate, keepWhenTrue) { + const chunks = []; + let current = []; + for (const p of path || []) { + const matches = predicate(Math.round(p[0]), Math.round(p[1])); + if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]); + else { + if (current.length >= 2) chunks.push(current); + current = []; + } + } + if (current.length >= 2) chunks.push(current); + return chunks; +} + +function splitWorldPathByRect(path, rect, keepInside) { + return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside); +} + +function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) { + return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected); +} + +function pruneOldPathLayer(world, paths, rects, seed, mode) { + const kept = []; + const anchors = []; + let clipped = 0; + for (const path of paths || []) { + const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); + const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34)); + if (!touches) { + kept.push(path); + continue; + } + clipped++; + let lastOutside = null; + let wasInside = false; + for (const [x, y] of worldPath) { + const inside = patchAffected(x, y, rects, seed, 0.34); + if (!inside) { + if (wasInside) anchors.push({ x, y, mode }); + lastOutside = { x, y, mode }; + } else if (lastOutside && !wasInside) { + anchors.push(lastOutside); + } + wasInside = inside; + } + for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk)); + } + return { kept, anchors, clipped }; +} + +function pathCost(world, x, y, mode) { + const i = worldIndex(world, x, y); + if (i < 0 || world.fields.sea?.[i]) return Infinity; + const slope = world.fields.slope?.[i] || 0; + const river = world.fields.river?.[i] || 0; + const plain = world.fields.plain?.[i] || 0; + const roadInfluence = world.fields.roadInfluence?.[i] || 0; + const pop = world.fields.populationDensity?.[i] || 0; + const slopeMult = mode === "rail" ? 15 : 7; + return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18; +} + +function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) { + const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y); + if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null; + if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null; + const w = rectWidth(rect); + const h = rectHeight(rect); + const n = w * h; + const dist = new Float64Array(n); dist.fill(Infinity); + const prev = new Int32Array(n); prev.fill(-1); + const local = (x, y) => (y - rect.y0) * w + (x - rect.x0); + const heap = new MinHeap(); + const startId = local(sx, sy); + dist[startId] = 0; + heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId }); + let expanded = 0; + let found = -1; + const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + while (heap.items.length && expanded < maxExpanded) { + const cur = heap.pop(); + if (!cur) break; + if (cur.x === gx && cur.y === gy) { found = cur.id; break; } + expanded++; + for (const [dx, dy] of dirs) { + const nx = cur.x + dx, ny = cur.y + dy; + if (!insideRect(nx, ny, rect)) continue; + const nid = local(nx, ny); + const c = pathCost(world, nx, ny, mode); + if (!Number.isFinite(c)) continue; + const step = (dx && dy ? 1.42 : 1) * c; + const nd = dist[cur.id] + step; + if (nd >= dist[nid]) continue; + dist[nid] = nd; + prev[nid] = cur.id; + heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 }); + } + } + if (found < 0) return null; + const rev = []; + let at = found; + while (at >= 0) { + const x = rect.x0 + (at % w); + const y = rect.y0 + Math.floor(at / w); + rev.push([x, y]); + at = prev[at]; + } + return rev.reverse(); +} + +function simplifyPath(path, keepEvery = 2) { + if (!path || path.length <= 2) return path || []; + const out = [path[0]]; + for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]); + out.push(path[path.length - 1]); + return out; +} + +function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") { + const points = []; + const seen = new Set(); + const add = (x, y, key, weight = 1) => { + x = Math.round(x); y = Math.round(y); + if (!insideRect(x, y, rect) || !isLand(world, x, y)) return; + const sig = `${x},${y},${key}`; + if (seen.has(sig)) return; + seen.add(sig); + points.push({ x, y, key, weight }); + }; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (let i = 0; i < path.length; i += 4) { + add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1); + } + } + } + const featureKeys = mode === "rail" + ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"] + : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"]; + for (const key of featureKeys) { + for (const p of sourceMap[key] || []) { + add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25); + } + } + return points; +} + +function rectDistance(x, y, rect) { + if (insideRect(x, y, rect)) return 0; + const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0; + const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0; + return Math.hypot(dx, dy); +} + +function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") { + const candidates = []; + const seen = new Set(); + const step = mode === "rail" ? 6 : 4; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (let i = 0; i < path.length; i += step) { + const x = Math.round(tupleWorldX(world, path[i])); + const y = Math.round(tupleWorldY(world, path[i])); + if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue; + const d = rectDistance(x, y, writeRect); + if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue; + const sig = `${x},${y},${mode}`; + if (seen.has(sig)) continue; + seen.add(sig); + candidates.push({ x, y, mode, external: true, d }); + } + } + } + candidates.sort((a, b) => a.d - b.d); + return candidates.slice(0, mode === "rail" ? 18 : 28); +} + +function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) { + const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; + const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); + const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; + const targets = preferredTargets.length ? preferredTargets : allTargets; + if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; + let connectors = 0; + let disconnected = 0; + let skippedConnectorAnchors = 0; + let connectorAttempts = 0; + const layer = mode === "rail" ? "branchRailways" : "minorRoads"; + sourceMap[layer] ||= []; + const seen = new Set(); + const maxRange = mode === "rail" ? 220 : 260; + const maxAnchors = mode === "rail" ? 10 : 18; + const maxTargets = mode === "rail" ? 3 : 3; + const searchRect = expandRect(rect, 16, world); + const orderedAnchors = (anchors || []) + .map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) })) + .sort((a, b) => a.patchDistance - b.patchDistance) + .slice(0, maxAnchors); + skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length); + for (const raw of orderedAnchors) { + const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); + if (!anchorLand) { disconnected++; continue; } + const targetList = targets + .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) + .filter((p) => p.d <= maxRange && p.d >= 6) + .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1))) + .slice(0, maxTargets); + if (!targetList.length) { disconnected++; continue; } + let made = false; + for (const target of targetList) { + const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; + if (seen.has(sig)) continue; + connectorAttempts++; + const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000); + if (!path || path.length < 2) continue; + seen.add(sig); + sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); + connectors++; + made = true; + break; + } + if (!made) disconnected++; + } + return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts }; +} + +function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) { + let best = null; + let bestD = maxDistance; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (let i = 0; i < path.length; i += 5) { + const x = Math.round(tupleWorldX(world, path[i])); + const y = Math.round(tupleWorldY(world, path[i])); + if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue; + const d = Math.hypot(point.x - x, point.y - y); + if (d < bestD) { bestD = d; best = { x, y, key }; } + } + } + } + return best; +} + +function ensureSettlementRoadCoverage(world, sourceMap, rect) { + const keys = ["nationalRoads", "minorRoads", "premodernRoads"]; + const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; + sourceMap.minorRoads ||= []; + let connectors = 0; + let skippedServedSettlements = 0; + let checked = 0; + const seen = new Set(); + for (const key of featureKeys) { + const limit = key === "villages" ? 30 : 18; + const items = (sourceMap[key] || []) + .map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) })) + .filter((row) => row.d <= (key === "villages" ? 80 : 150)) + .sort((a, b) => a.d - b.d) + .slice(0, limit); + for (const { p } of items) { + checked++; + const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); + if (!start || !insideRect(start.x, start.y, rect)) continue; + const si = worldIndex(world, start.x, start.y); + if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) { + skippedServedSettlements++; + continue; + } + const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132); + if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; + const sig = `${start.x},${start.y}:${target.x},${target.y}`; + if (seen.has(sig)) continue; + seen.add(sig); + const path = localPathfind(world, start, target, rect, "road", 36000); + if (!path || path.length < 2) continue; + sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); + connectors++; + } + } + return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked }; +} + +function dedupeAdminCentersByWorldId(kept, generated) { + const out = [...kept]; + const seen = new Set(); + for (const p of kept) { + const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); + if (id >= 0) seen.add(id); + } + for (const p of generated) { + const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); + if (id >= 0 && seen.has(id)) continue; + if (id >= 0) seen.add(id); + out.push(p); + } + return out; +} + +function dedupePrefectureRegionsByWorldId(kept, generated) { + const out = [...kept]; + const seen = new Set(); + for (const p of kept) { + const id = numericFeatureId(p, ["prefectureRegionId", "id"]); + if (id >= 0) seen.add(id); + } + for (const p of generated) { + const id = numericFeatureId(p, ["prefectureRegionId", "id"]); + if (id >= 0 && seen.has(id)) continue; + if (id >= 0) seen.add(id); + out.push(p); + } + return out; +} + +function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) { + let preservedExternalEntities = 0; + let regeneratedInternalEntities = 0; + let invalidPortsRemoved = 0; + for (const key of POINT_LAYER_KEYS) { + const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; + const kept = []; + for (const p of oldArr) { + if (!p) continue; + const wx = Math.round(pointWorldX(world, p)); + const wy = Math.round(pointWorldY(world, p)); + const inWrite = insideRect(wx, wy, rects.writeRect); + const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0; + if (!inWrite || alpha < 0.34) { + kept.push(p); + if (!inWrite) preservedExternalEntities++; + } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { + invalidPortsRemoved++; + continue; + } + } + const generated = []; + for (const p of candidate[key] || []) { + const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping); + if (!q) continue; + const wx = Math.round(pointWorldX(world, q)); + const wy = Math.round(pointWorldY(world, q)); + if (!insideRect(wx, wy, rects.writeRect)) continue; + if (patchAlpha(wx, wy, rects, seed) < 0.42) continue; + generated.push(q); + } + if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated); + else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated); + else sourceMap[key] = [...kept, ...generated]; + regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length); + } + return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved }; +} + +function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { + let roadAnchors = []; + let railAnchors = []; + let roadsClipped = 0; + let railsClipped = 0; + let regeneratedPaths = 0; + for (const key of PATH_LAYER_KEYS) { + const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; + const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path"; + const pruned = pruneOldPathLayer(world, oldArr, rects, seed, mode); + if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; } + else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; } + const next = [...pruned.kept]; + for (const path of candidate[key] || []) { + const worldPath = transformCandidatePath(window, path); + const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40) + .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90)) + .filter((chunk) => chunk.length >= 2); + for (const chunk of chunks) { + if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) { + next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2))); + regeneratedPaths++; + } + } + } + sourceMap[key] = next; + } + const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; + const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); + const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); + roadAnchors = roadAnchors.concat(externalRoadAnchors); + railAnchors = railAnchors.concat(externalRailAnchors); + const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect); + const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect); + const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); + return { + roadsClipped, + railsClipped, + regeneratedPaths, + roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors, + railwayConnectorsCreated: railConn.connectors, + disconnectedRoadComponents: roadConn.disconnected, + disconnectedRailComponents: railConn.disconnected, + skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0), + connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0), + skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0, + checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0, + externalRoadAnchors: externalRoadAnchors.length, + externalRailAnchors: externalRailAnchors.length, + }; +} + +function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) { + const field = world.fields[fieldName]; + const sea = world.fields.sea; + const rects = options.rects || null; + const seed = options.seed || 0; + const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0; + if (!field) return []; + const out = []; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || sea?.[i]) continue; + const id = field[i]; + if (id < 0) continue; + const right = worldIndex(world, x + 1, y); + if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) { + out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]); + } + const down = worldIndex(world, x, y + 1); + if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) { + out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]); + } + } + } + return out; +} + +function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) { + const debug = sourceMap.adminDebug || {}; + debug.compartmentBorders ||= []; + let added = 0; + for (const seg of candidate?.adminDebug?.compartmentBorders || []) { + if (!Array.isArray(seg) || seg.length < 2) continue; + const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); + const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); + const mx = (a.x + b.x) * 0.5; + const my = (a.y + b.y) * 0.5; + if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue; + debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); + added++; + } + sourceMap.adminDebug = debug; + return added; +} + +function transformCandidateBoundarySegments(world, candidate, key, rects, window, seed = 0, minAlpha = 0.74) { + const out = []; + if (!candidate || !window || !Array.isArray(candidate[key])) return out; + const sea = world.fields?.sea; + for (const seg of candidate[key]) { + if (!Array.isArray(seg) || seg.length < 2) continue; + const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); + const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); + const mx = Math.round((a.x + b.x) * 0.5); + const my = Math.round((a.y + b.y) * 0.5); + if (!insideRect(mx, my, rects.writeRect)) continue; + if (patchAlpha(mx, my, rects, seed) < minAlpha) continue; + const mi = worldIndex(world, mx, my); + if (mi >= 0 && sea?.[mi]) continue; + out.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); + } + return out; +} + +function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) { + const segmentRect = rects.writeRect || rects.repairRect; + const strongAlpha = 0.72; + + // Do not wipe every prepared boundary inside the repair rectangle. That made + // the old/new seam itself visible as a prefecture border and left broad holes + // when candidate boundaries were weak. Replace only strong patch-interior + // segments; keep the weak seam band owned by the existing world. + for (const key of SEGMENT_LAYER_KEYS) { + const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; + sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); + } + + const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76); + const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78); + const rebuiltAdmin = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 }); + const rebuiltPrefecture = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 }); + + sourceMap.adminBorders = dedupeSegments([...(sourceMap.adminBorders || []), ...candidateAdmin, ...rebuiltAdmin]); + sourceMap.regionalPrefectureBorders = dedupeSegments([...(sourceMap.regionalPrefectureBorders || []), ...candidatePref, ...rebuiltPrefecture]); + sourceMap.prefectureBorder ||= []; + + const debug = sourceMap.adminDebug || {}; + // Use the legacy full-pipeline compartment debug segments for regenerated + // areas. Rebuilding directly from the raster field made patch compartments + // look denser/smaller than the initial map. Candidate segments are merged + // just after this function. + debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); + sourceMap.adminDebug = debug; + return { + adminBordersRebuilt: sourceMap.adminBorders.length, + prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, + candidateAdminBordersMerged: candidateAdmin.length, + candidatePrefectureBordersMerged: candidatePref.length, + compartmentBordersRebuilt: debug.compartmentBorders.length, + }; +} + +function repairLanduseAndPopulation(world, rects) { + const landuse = world.fields.landuse; + if (!landuse) return { landUseCellsUpdated: 0 }; + let updated = 0; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + if (world.fields.sea?.[i]) { + if (landuse[i] !== (LANDUSE.WATER || 0)) updated++; + landuse[i] = LANDUSE.WATER || 0; + continue; + } + if (landuse[i] === (LANDUSE.WATER || 0)) { + const slope = world.fields.slope?.[i] || 0; + const ag = world.fields.agriculture?.[i] || 0; + landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL; + updated++; + } + } + } + return { landUseCellsUpdated: updated }; +} + +function ensureWorldFloatField(world, name) { + const expected = world.width * world.height; + if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected); + return world.fields[name]; +} + +function clearFieldRect(world, field, rect) { + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0) field[i] = 0; + } + } +} + +function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) { + const sea = world.fields.sea; + const r = Math.max(1, Math.ceil(radius)); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = Math.round(cx + dx); + const y = Math.round(cy + dy); + if (!insideRect(x, y, rect)) continue; + const i = worldIndex(world, x, y); + if (i < 0 || sea?.[i]) continue; + const d = Math.hypot(dx, dy); + const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); + if (value > field[i]) field[i] = clamp(value); + } + } +} + +function refreshPatchInfluenceFields(world, sourceMap, rects) { + const rect = rects.repairRect || rects.writeRect; + const roadInfluence = ensureWorldFloatField(world, "roadInfluence"); + const railInfluence2 = ensureWorldFloatField(world, "railInfluence2"); + const stationInfluence = ensureWorldFloatField(world, "stationInfluence"); + const villageInfluence = ensureWorldFloatField(world, "villageInfluence"); + for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect); + + let roadCellsPainted = 0; + let railCellsPainted = 0; + let stationCellsPainted = 0; + let villageCellsPainted = 0; + const paintPathLayer = (keys, field, radius, strength, counterName) => { + let painted = 0; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (const tuple of path || []) { + const x = tupleWorldX(world, tuple); + const y = tupleWorldY(world, tuple); + if (rectDistance(x, y, rect) > radius + 1) continue; + paintInfluenceDisk(world, field, x, y, radius, strength, rect); + painted++; + } + } + } + if (counterName === "road") roadCellsPainted += painted; + if (counterName === "rail") railCellsPainted += painted; + }; + + paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road"); + paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail"); + + for (const p of sourceMap.stations || []) { + const x = pointWorldX(world, p); + const y = pointWorldY(world, p); + if (rectDistance(x, y, rect) > 8) continue; + paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect); + stationCellsPainted++; + } + for (const p of sourceMap.villages || []) { + const x = pointWorldX(world, p); + const y = pointWorldY(world, p); + if (rectDistance(x, y, rect) > 10) continue; + paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect); + villageCellsPainted++; + } + + return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted }; +} + +function countSea(world, rect) { + let seaCount = 0; + let total = 0; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + total++; + if (world.fields.sea?.[i]) seaCount++; + } + } + return { seaCount, total, seaRatio: total ? seaCount / total : 0 }; +} + +function terrainLabel(candidate, fallback) { + return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback; +} + +function terrainId(candidate, fallback) { + return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback; +} + +function rectKey(rect) { + return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-"; +} + +function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) { + return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|"); +} + +function getPatchCandidateCache(world) { + if (!world.patchCandidateCache) world.patchCandidateCache = new Map(); + return world.patchCandidateCache; +} + +function rememberPatchCandidate(world, key, candidate) { + const cache = getPatchCandidateCache(world); + if (cache.has(key)) cache.delete(key); + cache.set(key, candidate); + while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value); +} + +function getOrGeneratePatchCandidate(world, key, create) { + const cache = getPatchCandidateCache(world); + if (cache.has(key)) { + const candidate = cache.get(key); + cache.delete(key); + cache.set(key, candidate); + return { candidate, cacheHit: true, cacheSize: cache.size }; + } + const candidate = create(); + rememberPatchCandidate(world, key, candidate); + return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; +} + +export function generatePatch(world, userRectInput, options = {}) { + const validation = validatePatchRect(userRectInput, world); + if (!validation.ok) return { ok: false, ...validation }; + + const rects = buildPatchRects(validation.rect, world); + const terrainType = options.terrainType || "auto"; + const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0; + const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; + const candidateWindow = sourceWindowForRects(rects); + const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); + const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY); + const patchTimer = createPatchTimer(); + const patchGenerationMode = "legacy-full-pipeline"; + const cacheKey = patchCandidateCacheKey({ + seed, + terrainType, + variant, + candidateOriginX, + candidateOriginY, + contextRect: rects.contextRect, + serial: world.patchGenerationSerial || 0, + }); + const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, { + terrainType, + legacyTerrain: true, + worldNative: true, + variant, + originX: candidateOriginX, + originY: candidateOriginY, + width: MAP_W, + height: MAP_H, + contextRect: rects.contextRect, + boundaryWorld: world, + onProgress: () => {}, + })); + patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation"); + getPatchAlphaCache(rects, seed); + getPatchSourceIndexCache(rects, candidateWindow); + const sourceMap = world.sourceMap || (world.sourceMap = {}); + const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); + + const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed); + patchTimer.mark("fields", "Field copy and alpha blend"); + const terrainSeamDebug = featherTerrainSeam(world, rects, seed); + const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; + const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); + const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); + const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); + const maskDebug = repairDisplayMasks(world, rects, seed); + recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); + patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); + const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); + patchTimer.mark("points", "Point merge"); + const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); + patchTimer.mark("paths", "Path merge and connector repair"); + const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects); + patchTimer.mark("influence", "Influence refresh"); + const landDebug = repairLanduseAndPopulation(world, rects); + const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed); + const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); + const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); + const municipalCoherence = reconcileMunicipalMetadata({ + adminId: world.fields.adminId, + municipalityId: world.fields.municipalityId, + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + adminCenters: sourceMap.adminCenters || [], + municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + seed, + }); + sourceMap.adminCenters = municipalCoherence.adminCenters; + sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; + const prefectureCoherence = refreshPrefectureRegionsMetadata({ + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + existing: sourceMap.prefectureRegions || [], + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + }); + sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; + sourceMap.adminDebug = { + ...(sourceMap.adminDebug || {}), + municipalCoherence: municipalCoherence.debug, + prefectureMetadataCoherence: prefectureCoherence.debug, + }; + const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window); + const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); + patchTimer.mark("segments", "Boundary and debug segment merge"); + sanitizeExistingLogistics(sourceMap); + patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); + const patchTimings = patchTimer.timings; + + const seaStats = countSea(world, rects.coreRect); + const label = terrainLabel(candidate, terrainType); + const id = terrainId(candidate, terrainType); + const humanGeography = { + ok: true, + modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, + ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, + villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, + ...pointDebug, + ...pathDebug, + ...influenceDebug, + adminCellsReassigned: fieldDebug.adminCellsReassigned, + adminIdMapping: fieldDebug.adminIdMappingDebug, + sourceAdminMetadataUpdated, + ...finalAdminCoverageDebug, + ...adminTopologyDebug, + finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, + finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, + finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, + finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0, + municipalCoherence: municipalCoherence.debug, + prefectureMetadataCoherence: prefectureCoherence.debug, + continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, + continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, + adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, + prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, + ...terrainSeamDebug, + ...waterComponentDebug, + ...waterElevationDebug, + landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, + displayMaskUpdated: maskDebug.displayMaskUpdated || 0, + logisticsLabelsMigrated, + candidateCacheHit: cacheHit, + candidateCacheSize: cacheSize, + ...segmentDebug, + candidateCompartmentSegmentsAdded, + }; + + const record = { + ...rects.coreRect, + coreRect: { ...rects.coreRect }, + selectionShape: rects.selectionShape ? { + kind: rects.selectionShape.kind || 'lasso', + areaCells: rects.selectionShape.areaCells || 0, + polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), + } : null, + writeRect: { ...rects.writeRect }, + repairRect: { ...rects.repairRect }, + contextRect: { ...rects.contextRect }, + blendRect: { ...rects.blendRect }, + terrainType: id, + label, + seed, + variant, + candidateOriginX, + candidateOriginY, + patchGenerationMode, + patchTimings, + updatedCells: fieldDebug.updatedCells, + terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, + coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped, + naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, + naturalRegionFragmentsMerged: 0, + adminIdMapping: fieldDebug.adminIdMappingDebug, + seaRatio: seaStats.seaRatio, + humanGeography, + createdAt: Date.now(), + }; + world.generatedRects = [...(world.generatedRects || []), record]; + world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }]; + world.lastPatchResult = record; + world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1; + + return { + ok: true, + validation, + rects: { + ...rects, + selectionShape: rects.selectionShape ? { + kind: rects.selectionShape.kind || 'lasso', + areaCells: rects.selectionShape.areaCells || 0, + polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), + } : null, + }, + terrainType: id, + label, + seed, + variant, + candidateOriginX, + candidateOriginY, + patchGenerationMode, + patchTimings, + updatedCells: record.updatedCells, + terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, + coastCellsChanged: record.coastCellsChanged, + naturalRegionsUpdated: record.naturalRegionsUpdated, + naturalRegionFragmentsMerged: 0, + adminIdMapping: fieldDebug.adminIdMappingDebug, + seaRatio: seaStats.seaRatio, + humanGeography, + }; +} diff --git a/mapTerrain.js b/mapTerrain.js index 497084e..ece1655 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -209,15 +209,16 @@ const TERRAIN_TYPES = [ }, { id: "oceanic_archipelago", - label: "海洋型・多島海", - weight: 0.16, + label: "Ocean", + weight: 0.00, + autoSelectable: false, coastStyle: "oceanic_archipelago", mountainMode: "mixed", massifnessRange: [0.04, 0.28], - seaRatioRange: [0.72, 0.90], + seaRatioRange: [0.955, 0.985], twoSidedChance: 1.0, mountainOffsetRange: [0.25, 0.55], - baseHeightRange: [0.30, 0.62], + baseHeightRange: [0.20, 0.46], primaryLengthRange: [0.20, 0.52], primaryWidthRange: [0.08, 0.24], systemCountRange: [7, 14], @@ -227,7 +228,7 @@ const TERRAIN_TYPES = [ lengthScale: 0.78, widthScale: 0.82, heightScale: 0.58, - coastStrength: 1.55, + coastStrength: 2.10, plainBiasRange: [0.12, 0.34], riverRichnessRange: [0.18, 0.52], bigRiverChanceRange: [0.02, 0.12], @@ -315,11 +316,11 @@ function pickTerrainType(seed, requestedType = "auto") { const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType); if (selected) return selected; } - // Terrain type selection is intentionally uniform. Individual terrain - // templates still contain their own parameter ranges, but there is no - // terrain-type appearance weighting. - const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length; - return TERRAIN_TYPES[index]; + // Auto excludes explicitly manual/special-purpose templates such as Ocean. + // The selection remains uniform over ordinary land-bearing templates. + const autoTypes = TERRAIN_TYPES.filter((type) => type.autoSelectable !== false); + const index = Math.floor(rand(seed, 10001) * autoTypes.length) % autoTypes.length; + return autoTypes[index]; } function rangeValue(seed, salt, [lo, hi]) { @@ -1174,8 +1175,8 @@ function enforceLandGradient(elevation, sea, seaLevel) { function rectTerrainProfile(template) { const id = String(template?.terrainType || "auto"); if (id.includes("oceanic")) return { - base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28, - seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86, + base: 0.28, relief: 0.11, ridge: 0.20, ridgeWidth: 18, ridgeSpacing: 70, coast: 0.52, archipelago: 0.18, + seaQuantile: Math.max(0.95, template.seaRatio ?? 0.965), plain: 0.12, moisture: 0.66, capStart: 0.56, capMax: 0.74, }; if (id.includes("setouchi") || id.includes("archipelago")) return { base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20, @@ -1697,7 +1698,11 @@ export function generateTerrainRect(options = {}) { } } - const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50); + const seaLevel = clamp( + Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), + 0.13, + terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.50 + ); const oceanCells = classifyRectWater(ctx, fields, seaLevel); recomputeRectSlope(ctx, fields); const filled = priorityFloodRect(ctx, fields); @@ -1922,7 +1927,7 @@ export function generateTerrainAndRivers(seed, options = {}) { } let seaLevel = quantile(elevation, terrainTemplate.seaRatio); - seaLevel = clamp(seaLevel, 0.14, 0.47); + seaLevel = clamp(seaLevel, 0.14, terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.47); classifyWater(elevation, seaLevel, sea, ocean, lake); recomputeSlope(elevation, sea, slope); diff --git a/renderer.js b/renderer.js index e387f7c..1de25e1 100644 --- a/renderer.js +++ b/renderer.js @@ -1,4 +1,4 @@ -import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js"; +import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); @@ -296,6 +296,38 @@ function isWaterSample(map, fx, fy) { return seaCoverageSample(map, fx, fy) >= 0.50; } +function terrainWorldPoint(map, fx, fy) { + return { + x: fx + (map?.worldCamera?.x || 0), + y: fy + (map?.worldCamera?.y || 0), + }; +} + +function waterVisualTone(map, fx, fy) { + const p = terrainWorldPoint(map, fx, fy); + const seed = (map?.seed || 0) >>> 0; + const broad = valueNoise(p.x, p.y, seed ^ 0x5d2a9b31, 74); + const mid = valueNoise(p.x, p.y, seed ^ 0x8f4c2d19, 29); + return clamp(broad * 0.72 + mid * 0.28); +} + +function waterVisualColor(map, fx, fy, waterCoverage = null) { + const coverage = waterCoverage ?? seaCoverageSample(map, fx, fy); + const tone = waterVisualTone(map, fx, fy) - 0.5; + const offshore = clamp((coverage - 0.42) / 0.58); + const depth = clamp(0.48 + offshore * 0.22 + tone * 0.10); + return [ + Math.round(174 - depth * 6), + Math.round(203 + depth * 2), + Math.round(224 + depth * 4), + ]; +} + +function waterVisualShade(map, fx, fy) { + const tone = waterVisualTone(map, fx * 0.85 + 11.7, fy * 0.85 - 3.1) - 0.5; + return clamp(0.992 + tone * 0.018, 0.976, 1.012); +} + function mixRgb(a, b, t) { return [ Math.round(a[0] + (b[0] - a[0]) * t), @@ -363,8 +395,10 @@ function terrainColorContinuous(map, fx, fy, mode) { let color; const waterCoverage = seaCoverageSample(map, fx, fy); - const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4); - const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; + // Water must not inherit terrain DEM hill bands. Use a stable world-coordinate + // visual water tone instead of elevation-derived depth; otherwise patched sea + // areas expose rectangular/horizontal generation artefacts. + const waterColor = waterVisualColor(map, fx, fy, waterCoverage); let landColor; if (mode === "development") { @@ -405,6 +439,9 @@ function terrainColorContinuous(map, fx, fy, mode) { } function terrainShadeContinuous(map, fx, fy) { + const waterCoverage = seaCoverageSample(map, fx, fy); + if (waterCoverage >= 0.50) return waterVisualShade(map, fx, fy); + const step = 0.50; const eC = fieldSample(map, map.elevation, fx, fy); const eL = fieldSample(map, map.elevation, fx - step, fy); @@ -589,6 +626,14 @@ function getRasterBorderSegments(map, fieldName) { return segments; } +function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) { + // Raw raster borders expose every one-cell ID fragment and also reveal patch + // ownership seams as long rectangular prefecture/municipality lines. Keep + // that extractor debug-only; normal modes must use prepared vector layers. + if (mode === "borders-debug") return getRasterBorderSegments(map, fieldName); + return Array.isArray(precomputedSegments) ? precomputedSegments : []; +} + function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { if (!path || path.length < 2) return; ctx.save(); @@ -1067,8 +1112,8 @@ export function drawMap(canvas, map, options) { const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode); if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode); - const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders; - const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders; + const adminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode); + const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode); if (showAdmin && adminBorderSegments?.length) { drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); diff --git a/styles.css b/styles.css index 7ffa8ee..5b353a2 100644 --- a/styles.css +++ b/styles.css @@ -72,7 +72,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} .canvas-shell.panning{cursor:grabbing} -.map-selection-svg{position:absolute;inset:12px;z-index:18;display:none;pointer-events:none;overflow:visible} +.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible} .map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round} .map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)} .canvas-shell.selecting{cursor:crosshair}