diff --git a/app.js b/app.js index 0749340..a11e37d 100644 --- a/app.js +++ b/app.js @@ -35,6 +35,7 @@ const state = { patchVariant: 0, zoom: 1, lastPatchResult: null, + pendingPatch: null, }; const canvas = document.getElementById("mapCanvas"); @@ -62,6 +63,9 @@ let generationCurrentStage = ""; let generationTimer = null; let zoomRedrawRaf = null; let zoomSettledTimer = null; +let zoomVisualState = null; +let patchWorker = null; +let patchJobSeq = 0; const dragState = { mode: null, @@ -81,8 +85,16 @@ const dragState = { panRaf: null, }; +function displayWorld() { + return state.pendingPatch?.world || state.world; +} + +function displaySourceMap() { + return displayWorld()?.sourceMap || state.map; +} + function activeMap() { - return state.viewportMap || state.map; + return state.viewportMap || displaySourceMap(); } function clampZoom(value) { @@ -99,8 +111,8 @@ function viewportSizeForZoom(zoom = state.zoom) { }; } -function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) { - return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H); +function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom), world = displayWorld()) { + return clampCameraToWorld(camera, world, size?.width || MAP_W, size?.height || MAP_H); } function syncViewportSize() { @@ -110,8 +122,12 @@ function syncViewportSize() { return size; } +function canvasInteractionRect() { + return zoomVisualState?.baseRect || canvas.getBoundingClientRect(); +} + function mapCellScreenSize(map = activeMap()) { - const rect = canvas.getBoundingClientRect(); + const rect = canvasInteractionRect(); const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1); } @@ -131,7 +147,7 @@ function displayedCellSize() { } function screenPointToMapPixel(clientX, clientY, sizeOverride = null) { - const rect = canvas.getBoundingClientRect(); + const rect = canvasInteractionRect(); if (!rect.width || !rect.height) return null; const map = activeMap(); const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W); @@ -144,7 +160,7 @@ function screenPointToMapPixel(clientX, clientY, sizeOverride = null) { } function mapPixelToScreenPoint(px, py) { - const rect = canvas.getBoundingClientRect(); + const rect = canvasInteractionRect(); const map = activeMap(); const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H); @@ -176,7 +192,7 @@ function viewportCellToWorldCell(cell) { } function clampCanvasPoint(event) { - const rect = canvas.getBoundingClientRect(); + const rect = canvasInteractionRect(); return { x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), @@ -186,7 +202,7 @@ function clampCanvasPoint(event) { function screenPointToWorldCell(point) { const map = activeMap(); if (!map || !point) return null; - const rect = canvas.getBoundingClientRect(); + const rect = canvasInteractionRect(); if (!rect.width || !rect.height) return null; const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H); @@ -387,8 +403,9 @@ function updatePatchControls() { return; } const rects = buildPatchRects(validation.rect, state.world); - const patchText = state.lastPatchResult - ? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` + const shownPatch = state.pendingPatch?.result || state.lastPatchResult; + const patchText = shownPatch + ? ` ${state.pendingPatch ? "Preview" : "Last applied"}: ${shownPatch.label}, variant ${shownPatch.variant ?? "-"}, mode ${shownPatch.patchGenerationMode || "legacy-full-pipeline"}, terrain ${shownPatch.updatedCells.toLocaleString()} cells, coast ${shownPatch.coastCellsChanged || 0}, natural ${shownPatch.naturalRegionsUpdated || 0}${shownPatch.humanGeography?.ok ? `, connectors ${(shownPatch.humanGeography.roadConnectorsCreated || 0) + (shownPatch.humanGeography.railwayConnectorsCreated || 0)}, admin ${shownPatch.humanGeography.adminCellsReassigned || 0}, invalid ports ${shownPatch.humanGeography.invalidPortsRemoved || 0}` : ""}.${state.pendingPatch ? " Click the map without dragging to apply; generate Alternative to replace the preview." : ""}` : ""; patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; patchStatusEl.classList.toggle("invalid", false); @@ -424,7 +441,36 @@ function schedulePanRedraw(camera) { }); } -function hideSelectionOverlay() { +function commitPendingPatch({ redrawAfter = true } = {}) { + if (!state.pendingPatch?.world) return false; + state.world = state.pendingPatch.world; + state.map = state.world.sourceMap || state.map; + state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult; + state.pendingPatch = null; + state.viewportMap = null; + if (redrawAfter) { + renderStats(displaySourceMap()); + redraw({ fastTerrain: true, allowWorldExpand: false }); + window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80); + } + return true; +} + +function discardPendingPatch({ redrawAfter = true } = {}) { + if (!state.pendingPatch) return false; + state.pendingPatch = null; + state.viewportMap = null; + if (redrawAfter) { + renderStats(displaySourceMap()); + redraw({ fastTerrain: false, allowWorldExpand: false }); + } + return true; +} + +function hideSelectionOverlay(options = {}) { + const commitPreview = options.commitPreview === true; + if (commitPreview) commitPendingPatch({ redrawAfter: false }); + else if (options.discardPreview === true) discardPendingPatch({ redrawAfter: false }); dragState.selectStart = null; dragState.selectEnd = null; dragState.selectPath = null; @@ -432,7 +478,10 @@ function hideSelectionOverlay() { resetPatchVariant({ update: false }); hideSelectionSvg(); if (selectionEl) selectionEl.style.display = "none"; + state.viewportMap = null; + renderStats(displaySourceMap()); updatePatchControls(); + if (commitPreview || options.discardPreview === true) redraw({ fastTerrain: false, allowWorldExpand: false }); } function selectionPixelsToCells(start, end) { @@ -470,6 +519,7 @@ function handleMapPointerDown(event) { dragState.mode = "pan"; canvasShell.classList.add("panning"); } else { + if (state.selectionRect) commitPendingPatch({ redrawAfter: false }); dragState.mode = "select"; dragState.selectStart = clampCanvasPoint(event); dragState.selectEnd = dragState.selectStart; @@ -551,8 +601,11 @@ function handleMapPointerUp(event) { state.camera = dragState.pendingCamera; dragState.pendingCamera = null; } + const clickDistance = Math.hypot(event.clientX - dragState.startClientX, event.clientY - dragState.startClientY); + const shouldClearSelectionByClick = wasPanning && clickDistance <= 5 && !!state.selectionRect; clearDragMode(); - if (wasPanning) redraw({ fastTerrain: false }); + if (shouldClearSelectionByClick) hideSelectionOverlay({ commitPreview: true }); + else if (wasPanning) redraw({ fastTerrain: false }); event.preventDefault(); } @@ -738,6 +791,11 @@ function hasNumericId(item, id, keys = ADMIN_ID_KEYS) { return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id)); } +function numericPrefectureId(item) { + const value = numericIdOf(item, PREFECTURE_ID_KEYS); + return value == null ? -1 : value; +} + function firstUsableText(item, keys) { for (const key of keys) { const value = item?.[key]; @@ -768,7 +826,7 @@ function adminCenterForId(map, adminId) { function looksNumericName(name) { if (!name) return true; const text = String(name).trim(); - return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text); + return !text || /^県域\d*$/u.test(text) || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text); } function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) { @@ -810,13 +868,46 @@ function adminPopulation(map, adminId, cellIndex = -1) { return found ? sum : null; } -function prefectureNameForCell(map, i) { - const id = map.prefectureRegionId?.[i] ?? -1; - const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS)); +function worldSourceMap() { + return displayWorld()?.sourceMap || null; +} + +function prefectureRegionById(id, maps = []) { + if (!Number.isFinite(id) || id < 0) return null; + for (const source of maps) { + const region = (source?.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS)); + if (region) return region; + } + return null; +} + +function prefectureNameForId(id, adminId = -1) { + if (!Number.isFinite(id) || id < 0) return ""; + const sources = [activeMap(), worldSourceMap()].filter(Boolean); + const region = prefectureRegionById(id, sources); const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS); if (regionName) return regionName; + + for (const source of sources) { + for (const center of source?.adminCenters || []) { + if (adminId >= 0 && !hasNumericId(center, adminId)) continue; + const centerPref = numericPrefectureId(center); + if (centerPref >= 0 && centerPref !== id) continue; + const name = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); + if (name) return name; + } + } + return ""; +} + +function prefectureNameForCell(map, i) { + const id = map.prefectureRegionId?.[i] ?? -1; + const direct = prefectureNameForId(id); + if (direct) return direct; const adminId = map.adminId?.[i] ?? -1; - const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1; + const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? state.world?.sourceMap?.municipalityToPrefectureId?.[adminId] ?? -1 : -1; + const mapped = prefectureNameForId(mappedPref, adminId); + if (mapped) return mapped; const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null; const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-"); @@ -893,8 +984,9 @@ async function regenerate() { state.world = createWorldMap(state.map); state.camera = createInitialCamera(state.world); state.lastPatchResult = null; + state.pendingPatch = null; resetPatchVariant({ update: false }); - hideSelectionOverlay(); + hideSelectionOverlay({ discardPreview: true }); renderStats(state.map); redraw(); if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`; @@ -919,6 +1011,47 @@ function derivePatchSeed(rect, terrainType, variant = 0) { } + +function beginZoomVisual(oldZoom, event) { + if (zoomVisualState) return; + const rect = canvas.getBoundingClientRect(); + zoomVisualState = { + baseRect: rect, + startZoom: clampZoom(oldZoom || state.zoom || 1), + originX: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), + originY: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), + }; + canvas.style.transformOrigin = `${zoomVisualState.originX}px ${zoomVisualState.originY}px`; + canvas.style.willChange = "transform"; + canvas.classList.add("is-zooming"); + if (selectionSvgEl) selectionSvgEl.style.visibility = "hidden"; +} + +function scheduleZoomVisualUpdate() { + if (!zoomVisualState || zoomRedrawRaf != null) return; + zoomRedrawRaf = requestAnimationFrame(() => { + zoomRedrawRaf = null; + if (!zoomVisualState) return; + const scale = clampZoom(state.zoom || 1) / Math.max(1e-6, zoomVisualState.startZoom || 1); + canvas.style.transform = `scale(${scale})`; + }); +} + +function finishZoomVisual() { + if (zoomRedrawRaf != null) { + cancelAnimationFrame(zoomRedrawRaf); + zoomRedrawRaf = null; + } + if (zoomVisualState) { + canvas.style.transform = ""; + canvas.style.transformOrigin = ""; + canvas.style.willChange = ""; + canvas.classList.remove("is-zooming"); + if (selectionSvgEl) selectionSvgEl.style.visibility = ""; + zoomVisualState = null; + } +} + function handleCanvasWheel(event) { if (!state.world || !activeMap()) return; event.preventDefault(); @@ -942,19 +1075,101 @@ function handleCanvasWheel(event) { } } - // Wheel events can fire dozens of times per second. Do one lightweight redraw - // per frame, then a full labeled/continuous redraw once zooming settles. - if (zoomRedrawRaf == null) { - zoomRedrawRaf = requestAnimationFrame(() => { - zoomRedrawRaf = null; - redraw({ fastTerrain: true, allowWorldExpand: false }); - }); - } + // Wheel events can fire dozens of times per second. During the gesture, keep + // the last rendered bitmap and only transform it on the GPU; rebuild the + // viewport and labels once the gesture settles. + beginZoomVisual(oldZoom, event); + scheduleZoomVisualUpdate(); if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer); zoomSettledTimer = window.setTimeout(() => { zoomSettledTimer = null; + finishZoomVisual(); redraw({ fastTerrain: false, allowWorldExpand: false }); - }, 140); + }, 170); +} + +function cloneForPatchPreview(value, seen = new Map()) { + if (value == null || typeof value !== "object") return value; + if (ArrayBuffer.isView(value)) return new value.constructor(value); + if (value instanceof ArrayBuffer) return value.slice(0); + if (seen.has(value)) return seen.get(value); + if (value instanceof Map) { + const out = new Map(); + seen.set(value, out); + for (const [k, v] of value.entries()) out.set(cloneForPatchPreview(k, seen), cloneForPatchPreview(v, seen)); + return out; + } + if (Array.isArray(value)) { + const out = []; + seen.set(value, out); + for (const item of value) out.push(cloneForPatchPreview(item, seen)); + return out; + } + const out = {}; + seen.set(value, out); + for (const [key, item] of Object.entries(value)) out[key] = cloneForPatchPreview(item, seen); + return out; +} + +function createPatchWorker() { + if (patchWorker || typeof Worker === "undefined") return patchWorker; + try { + patchWorker = new Worker(new URL("./mapPatchWorker.js", import.meta.url), { type: "module" }); + patchWorker.addEventListener("error", () => { + patchWorker?.terminate?.(); + patchWorker = null; + }); + } catch (_) { + patchWorker = null; + } + return patchWorker; +} + +function runPatchInWorker(world, rect, options) { + const worker = createPatchWorker(); + if (!worker) return null; + const id = ++patchJobSeq; + return new Promise((resolve, reject) => { + const cleanup = () => { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onMessageError); + }; + const onMessage = (event) => { + if (event.data?.id !== id) return; + cleanup(); + if (event.data.ok) resolve({ world: event.data.world, result: event.data.result, worker: true }); + else reject(new Error(event.data.error || "Patch worker failed")); + }; + const onError = (event) => { + cleanup(); + reject(new Error(event.message || "Patch worker error")); + }; + const onMessageError = () => { + cleanup(); + reject(new Error("Patch worker message clone failed")); + }; + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onMessageError); + worker.postMessage({ id, world, rect, options }); + }); +} + +async function generatePatchPreviewWorld(baseWorld, rect, options) { + const workerPromise = runPatchInWorker(baseWorld, rect, options); + if (workerPromise) { + try { + return await workerPromise; + } catch (error) { + console.warn("Patch worker unavailable; falling back to main-thread preview generation.", error); + patchWorker?.terminate?.(); + patchWorker = null; + } + } + const previewWorld = cloneForPatchPreview(baseWorld); + const result = generatePatch(previewWorld, rect, options); + return { world: previewWorld, result, worker: false }; } async function generateSelectedPatch() { @@ -966,23 +1181,29 @@ async function generateSelectedPatch() { const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; const variant = readPatchVariant(); const seed = derivePatchSeed(validation.rect, terrainType, variant); - setProgressVisible(true, "Generating selected patch..."); + setProgressVisible(true, "Generating preview patch..."); await nextFrame(); try { - const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant }); + const job = await generatePatchPreviewWorld(state.world, validation.rect, { terrainType, seed, variant }); + const result = job.result; if (!result.ok) { if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; updatePatchControls(); window.setTimeout(() => setProgressVisible(false), 1200); return; } - state.lastPatchResult = result; - redraw(); - renderStats(state.map); + state.pendingPatch = { world: job.world, result, rect: validation.rect, terrainType, seed, variant, worker: job.worker }; + // Keep the committed world untouched. The preview world is rendered until + // the user clicks once without dragging; Alternative replaces this preview + // from the same committed base, so old candidate artifacts cannot accumulate. + state.viewportMap = null; + redraw({ fastTerrain: true, allowWorldExpand: false }); + window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80); + renderStats(displaySourceMap()); updatePatchControls(); const human = result.humanGeography; const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : ""; - if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`; + if (progressStageEl) progressStageEl.textContent = `Preview generated${job.worker ? " in worker" : ""}: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}. Click the map without dragging to apply.`; renderTimingRows(result.patchTimings || []); window.setTimeout(() => setProgressVisible(false), 900); } catch (error) { @@ -1002,9 +1223,11 @@ async function generateAlternativePatch() { } function redraw(options = {}) { - if (!state.world) return; + const renderWorld = displayWorld(); + if (!renderWorld) return; const viewSize = syncViewportSize(); - const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height); + const mayExpand = !state.pendingPatch && options.allowWorldExpand !== false; + const expansion = mayExpand ? ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height) : null; if (expansion?.expanded) { const ex = expansion.dx || 0; const ey = expansion.dy || 0; @@ -1025,8 +1248,9 @@ function redraw(options = {}) { }; } } - state.camera = clampCameraForView(state.camera, viewSize); - state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain }); + const activeWorldForRender = displayWorld(); + state.camera = clampCameraForView(state.camera, viewSize, activeWorldForRender); + state.viewportMap = getViewportMap(activeWorldForRender, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain }); state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap); drawMap(canvas, state.viewportMap, { mode: state.mode, @@ -1050,6 +1274,7 @@ function init() { generationTypeInput?.addEventListener("change", regenerate); patchTerrainTypeInput?.addEventListener("change", () => { + discardPendingPatch({ redrawAfter: true }); state.lastPatchResult = null; resetPatchVariant({ update: false }); updatePatchControls(); diff --git a/mapFeatures.js b/mapFeatures.js index 9ffaada..73498c2 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -17,8 +17,10 @@ import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTr // 3. make sparse approximate transport paths without full-resolution A* // 4. synthesize population and land-use fields in one raster pass -export function generateMapFeatures(seed, terrain) { +export function generateMapFeatures(seed, terrain, options = {}) { const SPEED_TOLERANCE = 0.90; + const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true; + const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95); const featureTimings = []; const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); let timingMark = nowMs(); @@ -502,7 +504,11 @@ export function generateMapFeatures(seed, terrain) { // regional pass. modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); - const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2))); + const baseRegionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2))); + const regionalCapitalSlots = patchMode + ? Math.max(0, Math.min(1, Math.round(baseRegionalCapitalSlots * (1 - topCenterSuppression)))) + : baseRegionalCapitalSlots; + const topCenterGeoThreshold = 0.80 + topCenterSuppression * 0.16; for (const [rank, city] of modernCities.entries()) { const i = indexOf(city.x, city.y); const st = regionStats.get(city.regionId); @@ -514,8 +520,12 @@ export function generateMapFeatures(seed, terrain) { fieldValue(geoAccessibility, i, 0) * 0.18 + Math.log10((city.capacity || 26000) + 1) / 7 * 0.26 ); - const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8; - const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220); + const slotTopCenter = regionalCapitalSlots > 0 && rank < regionalCapitalSlots; + const exceptionalPatchCenter = patchMode && geoTierScore > topCenterGeoThreshold && (city.capacity || 0) > 360000; + const isTopCenter = (!patchMode && slotTopCenter) || exceptionalPatchCenter || (!patchMode && geoTierScore > topCenterGeoThreshold); + const regionalCapacityThreshold = patchMode ? 300000 : 210000; + const regionalCentralityThreshold = patchMode ? 340 : 220; + const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > regionalCapacityThreshold || (st?.highCentralityCells || 0) > regionalCentralityThreshold); const u = rand(st.seed, city.x, city.y, 9101); const v = rand(st.seed, city.x, city.y, 9102); const w = rand(st.seed, city.x, city.y, 9103); @@ -524,17 +534,20 @@ let rawPop; if (isRegionalCapital) { if (isTopCenter) { - // largest 3M - 11M + // largest 3M - 11M in full generation; patch candidates are suppressed + // unless they are exceptionally strong geographic centers. rawPop = 3000000 + Math.pow(u, 0.42) * 5200000 + Math.pow(v, 3.2) * 2800000; + if (patchMode) rawPop *= (0.42 + (1 - topCenterSuppression) * 0.28); } else { // larger 0.25M - 2.5M rawPop = 250000 + Math.pow(u, 0.55) * 1450000 + Math.pow(v, 2.4) * 900000; + if (patchMode) rawPop *= 0.72; } } else { // normal 5k - 0.75k @@ -543,9 +556,9 @@ if (isRegionalCapital) { Math.pow(u, 0.72) * 520000 + Math.pow(v, 3.0) * 320000; } - const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20; + const capMultiplier = isRegionalCapital ? (isTopCenter ? (patchMode ? 1.22 : 1.66) : (patchMode ? 1.08 : 1.42)) : 1.20; const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; - const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000; + const floor = isRegionalCapital ? (isTopCenter ? (patchMode ? 150000 : 210000) : (patchMode ? 90000 : 120000)) : 42000; city.population = Math.max(floor, population); city.isPrefecturalCapital = isPrefecturalCapital; city.isRegionalCapital = isRegionalCapital; diff --git a/mapMunicipalCoherence.js b/mapMunicipalCoherence.js index 88684ed..0bd3014 100644 --- a/mapMunicipalCoherence.js +++ b/mapMunicipalCoherence.js @@ -1,6 +1,24 @@ import { INF, MAP_H, MAP_W } from "./mapUtils.js"; const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"]; +const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "labelName"]; + +function usableName(value) { + const text = value == null ? "" : String(value).trim(); + if (!text) return ""; + if (/^県域\d*$/u.test(text)) return ""; + if (/^Unnamed prefecture$/i.test(text)) return ""; + if (/^Prefecture\s*-?\d+$/i.test(text)) return ""; + return text; +} + +function firstUsableName(obj, keys = PREFECTURE_NAME_KEYS) { + for (const key of keys) { + const text = usableName(obj?.[key]); + if (text) return text; + } + return ""; +} function coordIndex(width, height, x, y) { if (x < 0 || y < 0 || x >= width || y >= height) return -1; @@ -190,6 +208,7 @@ export function refreshPrefectureRegionsMetadata({ prefectureRegionId, sea, existing = [], + adminCenters = [], fields = {}, width = MAP_W, height = MAP_H, @@ -219,6 +238,13 @@ export function refreshPrefectureRegionsMetadata({ const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1; if (id >= 0 && !existingById.has(id)) existingById.set(id, region); } + const nameByPref = new Map(); + for (const center of adminCenters || []) { + const id = Number.isFinite(center?.prefectureRegionId) ? Math.floor(center.prefectureRegionId) : -1; + if (id < 0 || nameByPref.has(id)) continue; + const name = firstUsableName(center, ["prefectureName", "prefectureRegionName", "regionName"]); + if (name) nameByPref.set(id, name); + } let fallbackRegionsAdded = 0; const prefectureRegions = []; for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) { @@ -226,6 +252,7 @@ export function refreshPrefectureRegionsMetadata({ if (!base) fallbackRegionsAdded++; const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area)); const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area)); + const resolvedName = firstUsableName(base) || nameByPref.get(id) || `県域${id + 1}`; prefectureRegions.push({ ...(base || {}), id, @@ -235,8 +262,11 @@ export function refreshPrefectureRegionsMetadata({ y: y - pointOffsetY, area: row.area, kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"), - name: base?.name || `県域${id + 1}`, - labelName: base?.labelName || base?.name || `県域${id + 1}`, + name: resolvedName, + labelName: firstUsableName(base, ["labelName"]) || resolvedName, + prefectureName: firstUsableName(base, ["prefectureName"]) || resolvedName, + prefectureRegionName: firstUsableName(base, ["prefectureRegionName"]) || resolvedName, + regionName: firstUsableName(base, ["regionName"]) || resolvedName, forceLabel: true, labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area), }); diff --git a/mapOutput.js b/mapOutput.js index 20c7e23..6dfab7e 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -441,6 +441,7 @@ export function finishMapOutput({ let externalGateways = inputExternalGateways; const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step }); + const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true; outputProgress("final packaging"); // Use all generated prefecture regions for human-geography masks, not only // the focused prefecture. Population density itself is already generated in @@ -454,11 +455,19 @@ export function finishMapOutput({ // name features. for (const city of modernCities) { const cap = cityPopulationCap(city); - if (cap < INF && (city.population || 0) > cap) { - city.population = Math.round(cap / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); - city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); + let targetCap = cap; + if (patchMode) { + // Patch candidates should not regularly introduce a new top-center-scale + // metropolis. Existing cities in the world are preserved by mapPatch; this + // only affects newly generated candidate cities before they are merged. + const patchCap = city.isPrefecturalCapital ? 820000 : city.isRegionalCapital ? 680000 : 540000; + targetCap = Math.min(targetCap, patchCap); + } + if (targetCap < INF && (city.population || 0) > targetCap) { + city.population = Math.round(targetCap / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, patchMode ? 14 : 16); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, patchMode ? 4.8 : 5.2); + city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, patchMode ? 1.86 : 2.0); } } diff --git a/mapPatch.js b/mapPatch.js index ae52648..68baa85 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -48,6 +48,17 @@ const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATU const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]); +const STRICT_RESTORE_FIELD_EXEMPTIONS = new Set([ + // Transport repair is allowed to operate over a wider neighborhood than the + // lasso itself. Keep its derived influence fields in sync with repaired + // paths instead of restoring them to the pre-patch values outside the lasso. + "roadInfluence", "railInfluence2", "stationInfluence", +]); + +function shouldStrictRestoreField(name) { + return !STRICT_RESTORE_FIELD_EXEMPTIONS.has(name); +} + function worldIndex(world, x, y) { if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1; return y * world.width + x; @@ -228,12 +239,15 @@ function isWorldCellField(world, value) { function captureStrictSelectionFieldSnapshot(world, rects, seed = 0) { if (!rects?.strictSelectionMask || !world?.fields || !rects.writeRect) return null; - const rect = rects.writeRect; + // Snapshot the entire field-repair neighborhood. Several post-processors + // intentionally work on repairRect to keep seams smooth; for lasso patches, + // cells outside the polygon must still be put back after those repairs. + const rect = rects.repairRect || rects.writeRect; const width = rectWidth(rect); const height = rectHeight(rect); const fields = new Map(); for (const [name, field] of Object.entries(world.fields)) { - if (!isWorldCellField(world, field)) continue; + if (!shouldStrictRestoreField(name) || !isWorldCellField(world, field)) continue; const data = new field.constructor(width * height); for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { @@ -338,9 +352,16 @@ export function buildPatchRects(userRect, world = null) { const writeMargin = polygonSelection ? Math.max(4, Math.min(rawWriteMargin, Math.floor(shortSide * 0.20))) : rawWriteMargin; const writeRect = polygonSelection ? { x0: coreRect.x0, y0: coreRect.y0, x1: coreRect.x1, y1: coreRect.y1 } : expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); + // Transport graph repair needs substantially more regional context than field + // generation. Terrain/admin/water still obey writeRect/strict masks, but + // severed roads and rails often need to reconnect to the next real trunk line + // outside the edited patch. Keep this capped so very large worlds do not make + // every patch repair global. + const longSide = Math.max(width, height); + const diagonal = Math.hypot(width, height); const transportReachMargin = Math.max( - repairMargin + 48, - Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15))) + repairMargin + 72, + Math.min(420, Math.max(160, repairMargin + Math.floor(diagonal * 0.45), Math.floor(longSide * 0.68))) ); const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { @@ -376,13 +397,16 @@ function computePatchAlpha(x, y, rects, seed = 0) { 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. + // feather is inward-only. Earlier builds started the lasso edge at ~0.64, + // which made a hard terrain switch visible immediately inside the blue line. + // Start at zero and let the candidate terrain take over only after an + // interior transition band. const dist = distanceToPolygonEdge(px, py, shape.polygon); - const feather = Math.max(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 feather = Math.max(6, Math.min(margin, 24)); + const noisyInsideDist = dist + low * Math.min(2.2, feather * 0.12) + mid * Math.min(1.1, feather * 0.06); + const t = clamp((noisyInsideDist - 0.35) / Math.max(1e-6, feather)); + const edge = smoothstep(t); + return clamp(edge); } const edge = distanceToRectEdge(x, y, writeRect); const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; @@ -504,6 +528,68 @@ function dedupeSegments(segments) { } +function segmentMidpoint(seg) { + return { + x: ((seg?.[0]?.[0] || 0) + (seg?.[1]?.[0] || 0)) * 0.5, + y: ((seg?.[0]?.[1] || 0) + (seg?.[1]?.[1] || 0)) * 0.5, + }; +} + +function segmentOrientation(seg) { + const dx = (seg?.[1]?.[0] || 0) - (seg?.[0]?.[0] || 0); + const dy = (seg?.[1]?.[1] || 0) - (seg?.[0]?.[1] || 0); + return Math.atan2(dy, dx); +} + +function angleDistance(a, b) { + let d = Math.abs(a - b) % Math.PI; + if (d > Math.PI / 2) d = Math.PI - d; + return d; +} + +function segmentNear(a, b, tolerance = 0.60) { + if (!a || !b) return false; + const am = segmentMidpoint(a); + const bm = segmentMidpoint(b); + if (Math.hypot(am.x - bm.x, am.y - bm.y) > tolerance) return false; + return angleDistance(segmentOrientation(a), segmentOrientation(b)) < 0.55; +} + +function filterSupplementalSegments(primary, supplemental, tolerance = 0.60) { + if (!supplemental?.length) return []; + if (!primary?.length) return supplemental || []; + const grid = new Map(); + const cell = (v) => Math.floor(v / Math.max(0.1, tolerance)); + for (const seg of primary) { + const m = segmentMidpoint(seg); + const key = `${cell(m.x)},${cell(m.y)}`; + const bucket = grid.get(key) || []; + bucket.push(seg); + grid.set(key, bucket); + } + const out = []; + for (const seg of supplemental || []) { + const m = segmentMidpoint(seg); + let near = false; + const gx = cell(m.x), gy = cell(m.y); + for (let yy = gy - 1; yy <= gy + 1 && !near; yy++) { + for (let xx = gx - 1; xx <= gx + 1 && !near; xx++) { + for (const other of grid.get(`${xx},${yy}`) || []) { + if (segmentNear(seg, other, tolerance)) { near = true; break; } + } + } + } + if (!near) out.push(seg); + } + return out; +} + +function removeSegmentsNearSegments(segments, blockers, tolerance = 0.68) { + if (!segments?.length || !blockers?.length) return segments || []; + return filterSupplementalSegments(blockers, segments, tolerance); +} + + function sourceWindowForRects(rects) { const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2; @@ -575,6 +661,80 @@ function sourceIndexForWorld(rects, window, x, y) { return sourceIndex(s.x, s.y); } + +function solveLinear3(a00, a01, a02, a11, a12, a22, b0, b1, b2) { + const m = [ + [a00, a01, a02, b0], + [a01, a11, a12, b1], + [a02, a12, a22, b2], + ]; + for (let col = 0; col < 3; col++) { + let pivot = col; + for (let row = col + 1; row < 3; row++) if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) pivot = row; + if (Math.abs(m[pivot][col]) < 1e-8) return null; + if (pivot !== col) [m[col], m[pivot]] = [m[pivot], m[col]]; + const div = m[col][col]; + for (let k = col; k < 4; k++) m[col][k] /= div; + for (let row = 0; row < 3; row++) { + if (row === col) continue; + const f = m[row][col]; + for (let k = col; k < 4; k++) m[row][k] -= f * m[col][k]; + } + } + return [m[0][3], m[1][3], m[2][3]]; +} + +function computeElevationCandidateAdjustment(world, candidate, rects, window, seed = 0) { + const oldElevation = world?.fields?.elevation; + const candidateElevation = candidate?.elevation; + if (!oldElevation || !candidateElevation || !rects?.writeRect || !window) return null; + const rect = rects.writeRect; + const cx = (rect.x0 + rect.x1 - 1) / 2; + const cy = (rect.y0 + rect.y1 - 1) / 2; + const scale = Math.max(1, Math.max(rectWidth(rect), rectHeight(rect)) / 2); + let n = 0; + let sX = 0, sY = 0, sXX = 0, sXY = 0, sYY = 0; + let sZ = 0, sXZ = 0, sYZ = 0; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.01 || a > 0.42) continue; + const wi = worldIndex(world, x, y); + const si = sourceIndexForWorld(rects, window, x, y); + if (wi < 0 || si < 0) continue; + const oldSea = world.fields?.sea?.[wi]; + const newSea = candidate.sea?.[si]; + if (oldSea || newSea) continue; + const oldValue = oldElevation[wi]; + const newValue = candidateElevation[si]; + if (!Number.isFinite(oldValue) || !Number.isFinite(newValue)) continue; + const lx = (x - cx) / scale; + const ly = (y - cy) / scale; + const z = oldValue - newValue; + n++; + sX += lx; sY += ly; sXX += lx * lx; sXY += lx * ly; sYY += ly * ly; + sZ += z; sXZ += lx * z; sYZ += ly * z; + } + } + if (n < 24) return { offset: 0, tiltX: 0, tiltY: 0, cx, cy, scale, samples: n }; + const solved = solveLinear3(n, sX, sY, sXX, sXY, sYY, sZ, sXZ, sYZ); + if (!solved) return { offset: clamp(sZ / n, -0.18, 0.18), tiltX: 0, tiltY: 0, cx, cy, scale, samples: n }; + return { + offset: clamp(solved[0], -0.22, 0.22), + tiltX: clamp(solved[1], -0.16, 0.16), + tiltY: clamp(solved[2], -0.16, 0.16), + cx, cy, scale, + samples: n, + }; +} + +function adjustedCandidateElevation(value, x, y, adjustment) { + if (!adjustment || !Number.isFinite(value)) return value; + const lx = (x - adjustment.cx) / Math.max(1, adjustment.scale || 1); + const ly = (y - adjustment.cy) / Math.max(1, adjustment.scale || 1); + return clamp(value + adjustment.offset + adjustment.tiltX * lx + adjustment.tiltY * ly, 0, 1); +} + function worldCoordForSource(window, sx, sy) { return { x: Math.round(sx - window.sourceCenterX + window.worldCenterX), @@ -1101,6 +1261,7 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) { const window = sourceWindowForRects(rects); + const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null; const oldHumanFields = cloneHumanLandContinuityFields(world); @@ -1154,7 +1315,9 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, } } else { const before = dest[wi] || 0; - dest[wi] = lerp(before, source[si] || 0, alpha); + let candidateValue = source[si] || 0; + if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment); + dest[wi] = lerp(before, candidateValue, alpha); } if (name === "elevation") { @@ -1230,6 +1393,10 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, landUseCellsUpdated, adminIdMapping, adminIdMappingDebug: summarizeIdMapping(adminIdMapping), + terrainSeamAdjustmentSamples: elevationAdjustment?.samples || 0, + terrainSeamElevationOffset: elevationAdjustment ? Math.round((elevationAdjustment.offset || 0) * 10000) / 10000 : 0, + terrainSeamElevationTiltX: elevationAdjustment ? Math.round((elevationAdjustment.tiltX || 0) * 10000) / 10000 : 0, + terrainSeamElevationTiltY: elevationAdjustment ? Math.round((elevationAdjustment.tiltY || 0) * 10000) / 10000 : 0, ...humanLandDebug, ...continuityDebug, ...seamOwnershipDebug, @@ -1273,10 +1440,31 @@ function featherTerrainSeam(world, rects, seed = 0) { ]; let terrainFeatherCells = 0; let terrainFeatherValues = 0; + + // Earlier versions cloned each whole typed array here. After the world grows, + // that made every patch pay for global memory copies. The seam smoother only + // samples the write rectangle and its one-cell neighborhood, so snapshot just + // that local window. + const sampleRect = expandRect(rects.writeRect, 1, world); + const sw = rectWidth(sampleRect); + const localOffset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0); + for (const key of smoothKeys) { const field = fields[key]; if (!field || !ArrayBuffer.isView(field)) continue; - const old = new field.constructor(field); + const old = new field.constructor(sw * rectHeight(sampleRect)); + for (let y = sampleRect.y0; y < sampleRect.y1; y++) { + for (let x = sampleRect.x0; x < sampleRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0) old[localOffset(x, y)] = field[i] || 0; + } + } + const oldValue = (x, y) => { + if (insideRect(x, y, sampleRect)) return old[localOffset(x, y)] || 0; + const i = worldIndex(world, x, y); + return i >= 0 ? (field[i] || 0) : 0; + }; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { if (patchBand(x, y, rects, seed) !== "feather") continue; @@ -1287,7 +1475,7 @@ function featherTerrainSeam(world, rects, seed = 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; + sum += oldValue(x + dx, y + dy); count++; } } @@ -1304,6 +1492,168 @@ function featherTerrainSeam(world, rects, seed = 0) { return { terrainFeatherCells, terrainFeatherValues }; } +function smoothExtremeElevationSeams(world, rects, seed = 0) { + const elevation = world.fields?.elevation; + const sea = world.fields?.sea; + if (!elevation || !rects?.writeRect) return { elevationCliffCellsSmoothed: 0, elevationCliffMaxDelta: 0 }; + const rect = rects.writeRect; + const sampleRect = expandRect(rect, 1, world); + const sw = rectWidth(sampleRect); + const sh = rectHeight(sampleRect); + const offset = (x, y) => (y - sampleRect.y0) * sw + (x - sampleRect.x0); + let cells = 0; + let maxDelta = 0; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + + for (let pass = 0; pass < 3; pass++) { + const old = new Float32Array(sw * sh); + for (let y = sampleRect.y0; y < sampleRect.y1; y++) { + for (let x = sampleRect.x0; x < sampleRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0) old[offset(x, y)] = elevation[i] || 0; + } + } + let passCells = 0; + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || sea?.[i]) continue; + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.005 || a >= 0.82) continue; + const here = old[offset(x, y)] || 0; + let sum = 0; + let count = 0; + let strongest = 0; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + const ni = worldIndex(world, nx, ny); + if (ni < 0 || sea?.[ni]) continue; + const na = patchAlpha(nx, ny, rects, seed); + // Only pull the generated seam toward less-generated or preserved + // neighbors. This smooths cliffs at the patch boundary without + // blurring the interior terrain that the candidate intentionally made. + if (na > a + 0.08 && insideRect(nx, ny, rect)) continue; + const nv = insideRect(nx, ny, sampleRect) ? old[offset(nx, ny)] : (elevation[ni] || 0); + const d = Math.abs(here - nv); + strongest = Math.max(strongest, d); + sum += nv; + count++; + } + if (!count || strongest < 0.075) continue; + const mean = sum / count; + const diff = Math.abs(here - mean); + if (diff < 0.055) continue; + const severity = clamp((diff - 0.045) / 0.20); + const seamBias = clamp(1 - a * 0.72, 0.14, 0.88); + const weight = clamp(0.18 + severity * 0.48, 0.18, 0.62) * seamBias; + elevation[i] = clamp(lerp(elevation[i], mean, weight), 0, 1); + maxDelta = Math.max(maxDelta, diff); + passCells++; + } + } + cells += passCells; + if (!passCells) break; + } + return { elevationCliffCellsSmoothed: cells, elevationCliffMaxDelta: Math.round(maxDelta * 10000) / 10000 }; +} + +function averageNearbyLandElevation(world, x, y, maxRadius = 5) { + const elevation = world.fields?.elevation; + const sea = world.fields?.sea; + if (!elevation) return null; + for (let r = 1; r <= maxRadius; r++) { + let sum = 0; + let count = 0; + for (let yy = y - r; yy <= y + r; yy++) { + for (let xx = x - r; xx <= x + r; xx++) { + if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; + const i = worldIndex(world, xx, yy); + if (i >= 0 && !sea?.[i] && Number.isFinite(elevation[i])) { sum += elevation[i]; count++; } + } + } + if (count) return sum / count; + } + return null; +} + +function fillTinyResidualSeas(world, rects, seaLevel = 0.30, seed = 0) { + const sea = world.fields?.sea; + if (!sea || !rects?.writeRect) return { residualSeaPatchesFilled: 0, residualSeaCellsFilled: 0 }; + const rect = rects.writeRect; + const visited = new Uint8Array(rectWidth(rect) * rectHeight(rect)); + const local = (x, y) => (y - rect.y0) * rectWidth(rect) + (x - rect.x0); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const maxComponent = Math.max(8, Math.min(72, Math.floor((rectWidth(rect) * rectHeight(rect)) * 0.0025))); + let patches = 0; + let cellsFilled = 0; + + for (let sy = rect.y0; sy < rect.y1; sy++) { + for (let sx = rect.x0; sx < rect.x1; sx++) { + const startLocal = local(sx, sy); + if (visited[startLocal]) continue; + const startIndex = worldIndex(world, sx, sy); + if (startIndex < 0 || !sea[startIndex]) { visited[startLocal] = 1; continue; } + + const queue = [[sx, sy]]; + const cells = []; + visited[startLocal] = 1; + let touchesOutside = false; + let landContacts = 0; + let waterContacts = 0; + let maxAlpha = 0; + let seamTouches = 0; + for (let qi = 0; qi < queue.length; qi++) { + const [x, y] = queue[qi]; + cells.push([x, y]); + const a = patchAlpha(x, y, rects, seed); + maxAlpha = Math.max(maxAlpha, a); + if (a < 0.34) seamTouches++; + if (x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) touchesOutside = true; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!insideRect(nx, ny, rect)) { touchesOutside = true; continue; } + const ni = worldIndex(world, nx, ny); + if (ni < 0) { touchesOutside = true; continue; } + if (sea[ni]) { + const li = local(nx, ny); + if (!visited[li]) { visited[li] = 1; queue.push([nx, ny]); } + waterContacts++; + } else { + landContacts++; + } + } + if (cells.length > maxComponent * 3) break; + } + + // Fill only tiny enclosed sea remnants near the inward feather band. This + // is meant to remove lasso hand-jitter pinholes, not real bays, lakes, or + // island channels created by the candidate terrain. + if (touchesOutside || cells.length > maxComponent || maxAlpha > 0.70 || seamTouches < Math.max(1, Math.floor(cells.length * 0.30)) || landContacts < waterContacts * 2.2) continue; + + for (const [x, y] of cells) { + const i = worldIndex(world, x, y); + if (i < 0) continue; + sea[i] = 0; + if (world.fields.ocean) world.fields.ocean[i] = 0; + if (world.fields.lake) world.fields.lake[i] = 0; + const landElevation = averageNearbyLandElevation(world, x, y, 6); + if (world.fields.elevation) world.fields.elevation[i] = clamp(Math.max(seaLevel + 0.012, landElevation ?? (seaLevel + 0.018)), 0, 1); + if (world.fields.landuse) { + const lu = nearestLandFieldValue(world, x, y, "landuse", expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 8, world), { maxRadius: 8 }); + world.fields.landuse[i] = lu >= 0 ? lu : (LANDUSE.FOREST || 1); + } + for (const key of ["adminId", "municipalityId", "prefectureRegionId"]) { + if (!world.fields[key]) continue; + const value = nearestLandFieldValue(world, x, y, key, expandRect({ x0: x, y0: y, x1: x + 1, y1: y + 1 }, 12, world), { maxRadius: 12 }); + if (value >= 0) world.fields[key][i] = value; + } + } + patches++; + cellsFilled += cells.length; + } + } + return { residualSeaPatchesFilled: patches, residualSeaCellsFilled: cellsFilled }; +} function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { const field = world.fields?.[fieldName]; @@ -1826,6 +2176,18 @@ function seaNeighbors(world, x, y, radius = 1) { return count; } +function landNeighbors(world, x, y, radius = 1) { + let count = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!dx && !dy) continue; + const i = worldIndex(world, x + dx, y + dy); + if (i >= 0 && !world.fields.sea?.[i]) count++; + } + } + return count; +} + function isLand(world, x, y) { const i = worldIndex(world, x, y); return i >= 0 && !world.fields.sea?.[i]; @@ -1873,7 +2235,12 @@ function sourcePointFromWorld(world, point) { } function sourcePathFromWorld(world, path) { - return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); + const out = path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); + // Arrays can carry lightweight metadata in JS. Marking generated paths lets + // a later Alternative generation remove the prior variant completely instead + // of leaving low-alpha edge fragments behind. + out.patchGenerated = true; + return out; } function offsetPointNumericFields(point, fields, offset) { @@ -2012,7 +2379,8 @@ function pruneOldPathLayer(world, paths, rects, seed, mode) { 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)); + const removalAlpha = path?.patchGenerated ? 0.005 : 0.34; + const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, removalAlpha)); if (!touches) { kept.push(path); continue; @@ -2021,7 +2389,7 @@ function pruneOldPathLayer(world, paths, rects, seed, mode) { let lastOutside = null; let wasInside = false; for (const [x, y] of worldPath) { - const inside = patchAffected(x, y, rects, seed, 0.34); + const inside = patchAffected(x, y, rects, seed, removalAlpha); if (!inside) { if (wasInside) anchors.push({ x, y, mode }); lastOutside = { x, y, mode }; @@ -2030,7 +2398,7 @@ function pruneOldPathLayer(world, paths, rects, seed, mode) { } wasInside = inside; } - for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk)); + for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, removalAlpha)) kept.push(sourcePathFromWorld(world, chunk)); } return { kept, anchors, clipped }; } @@ -2328,8 +2696,11 @@ function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, s // 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; + // Accept land cells and only very near-shore bridge-like sea cells. + // The previous sea-neighbor test accidentally made open water more likely + // to become a graph anchor. + const nearTransportSurface = isLand(world, x, y) || landNeighbors(world, x, y, 2) >= 8; + if (!nearTransportSurface) return; const li = local(x, y); occ[li] = 1; weight[li] = Math.max(weight[li] || 0, v); @@ -2406,20 +2777,44 @@ function buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, s return { graphRect, width: w, height: h, occ, comps, local }; } +function nearestComponentCell(snapshot, comp, target) { + let best = null; + let bestD = Infinity; + for (const li of comp?.cells || []) { + const x = snapshot.graphRect.x0 + (li % snapshot.width); + const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width); + const d = Math.hypot(x - target.x, y - target.y); + if (d < bestD) { + bestD = d; + best = { x, y }; + } + } + return best; +} + function sampleTransportComponent(snapshot, comp, limit = 96) { const cells = comp?.cells || []; if (!cells.length) return []; const out = []; - const 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 seen = new Set(); + const addCell = (li) => { + if (!Number.isFinite(li) || li < 0) return; const x = snapshot.graphRect.x0 + (li % snapshot.width); const y = snapshot.graphRect.y0 + Math.floor(li / snapshot.width); + const key = `${x},${y}`; + if (seen.has(key)) return; + seen.add(key); out.push({ x, y }); + }; + const step = Math.max(1, Math.floor(cells.length / Math.max(1, limit))); + for (let i = 0; i < cells.length; i += step) { + addCell(cells[i]); if (out.length >= limit) break; } - // Bias the sample toward the component centroid and bounding-box edges; this - // improves reconnection when a long road was clipped by the patch edge. + // Add actual occupied cells nearest to the centroid and bbox edge targets. + // Do not use the raw centroid/bbox coordinates as endpoints: they are often + // off the centerline, which made the checker roll back otherwise valid graph + // repairs because the new path never actually touched the component. const specials = [ { x: Math.round(comp.cx), y: Math.round(comp.cy) }, { x: comp.minX, y: Math.round(comp.cy) }, @@ -2427,27 +2822,82 @@ function sampleTransportComponent(snapshot, comp, limit = 96) { { 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); + for (const target of specials) { + const nearest = nearestComponentCell(snapshot, comp, target); + if (!nearest) continue; + const key = `${nearest.x},${nearest.y}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(nearest); } return out; } -function bestTransportComponentPair(snapshot, main, other, maxDistance) { - const mainSamples = sampleTransportComponent(snapshot, main, 48); - const otherSamples = sampleTransportComponent(snapshot, other, 48); +function transportComponentSamples(snapshot, comp, mode = "road") { + snapshot.sampleCache ||= new Map(); + const key = `${mode}:${comp?.id ?? -1}`; + const cached = snapshot.sampleCache.get(key); + if (cached) return cached; + const samples = sampleTransportComponent(snapshot, comp, mode === "rail" ? 48 : 72); + snapshot.sampleCache.set(key, samples); + return samples; +} + +function bestTransportComponentPair(snapshot, compA, compB, maxDistance, mode = "road") { + const samplesA = transportComponentSamples(snapshot, compA, mode); + const samplesB = transportComponentSamples(snapshot, compB, mode); let best = null; - for (const a of mainSamples) { - for (const b of otherSamples) { + for (const a of samplesA) { + for (const b of samplesB) { const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < 3 || d > maxDistance) continue; - const score = d / Math.sqrt(Math.max(4, other.size)); + // Prefer connecting components that actually touch the changed area, but + // do not force every component into the single largest component. A local + // chain of nearby component merges usually looks more like a natural + // regional repair than a star-shaped set of shortcuts to the trunk road. + const dirtyBonus = (compA.patchCells || compA.nearWriteCells || compB.patchCells || compB.nearWriteCells) ? 0.78 : 1.0; + const exteriorPenalty = compA.exteriorCells > 0 && compB.exteriorCells > 0 && !compA.patchCells && !compB.patchCells ? 1.32 : 1.0; + const sizeBonus = 1 / Math.sqrt(Math.max(4, Math.min(compA.size, compB.size))); + const score = d * sizeBonus * dirtyBonus * exteriorPenalty; if (!best || score < best.score) best = { a, b, d, score }; } } return best; } +function buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs) { + const limit = mode === "rail" ? 12 : 18; + const pool = comps.slice(0, Math.min(comps.length, limit)); + const candidates = []; + for (let i = 0; i < pool.length; i++) { + for (let j = i + 1; j < pool.length; j++) { + const aComp = pool[i]; + const bComp = pool[j]; + // At least one side must be part of the dirty neighborhood. This prevents + // broad transport context from welding unrelated external networks while + // still allowing internal severed pieces to attach to outside trunks. + const aDirty = (aComp.patchCells || aComp.nearWriteCells) > 0; + const bDirty = (bComp.patchCells || bComp.nearWriteCells) > 0; + if (!aDirty && !bDirty) continue; + const pair = bestTransportComponentPair(snapshot, aComp, bComp, maxDistance, mode); + if (!pair) continue; + const sig = transportPairSignature(pair.a, pair.b, mode); + const rev = transportPairSignature(pair.b, pair.a, mode); + if (rejectedPairs.has(sig) || rejectedPairs.has(rev)) continue; + const bothPatch = aComp.patchCells > 0 && bComp.patchCells > 0 ? 0.74 : 1.0; + const oneExternal = (aComp.exteriorCells > 0 || bComp.exteriorCells > 0) ? 1.03 : 1.0; + const score = pair.score * bothPatch * oneExternal; + candidates.push({ compA: aComp, compB: bComp, pair, score }); + } + } + candidates.sort((a, b) => a.score - b.score); + return candidates; +} + +function transportPairSignature(a, b, mode = "road") { + return `${mode}:${Math.round((a?.x || 0) / 3)},${Math.round((a?.y || 0) / 3)}:${Math.round((b?.x || 0) / 3)},${Math.round((b?.y || 0) / 3)}`; +} + function pathLength(path) { let len = 0; for (let i = 1; i < (path?.length || 0); i++) len += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); @@ -2493,6 +2943,35 @@ function makeTransportConnectorExtraCost(world, rects, seed, mode) { }; } +function transportLineSeaBarrier(world, a, b, mode = "road") { + const ax = Math.round(a?.x || 0), ay = Math.round(a?.y || 0); + const bx = Math.round(b?.x || 0), by = Math.round(b?.y || 0); + const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay))); + let seaHits = 0; + let longestRun = 0; + let run = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(ax + (bx - ax) * t); + const y = Math.round(ay + (by - ay) * t); + const water = !isLand(world, x, y); + if (water) { + seaHits++; + run++; + longestRun = Math.max(longestRun, run); + } else { + run = 0; + } + } + // Keep the existing island/strait behavior: do not spend expensive A* attempts + // on pairs that are probably separated by open water. Small coastal gaps are + // still allowed, especially for roads, so pre-existing bridge-like contexts + // can be repaired without turning islands into a road mesh. + const seaRatio = seaHits / Math.max(1, steps + 1); + const maxRun = mode === "rail" ? 4 : 6; + return longestRun > maxRun || seaRatio > (mode === "rail" ? 0.10 : 0.14); +} + function writeConnectorPath(world, sourceMap, mode, path, rects, seed, strictMask) { const layer = mode === "rail" ? "branchRailways" : "minorRoads"; sourceMap[layer] ||= []; @@ -2514,9 +2993,8 @@ function writeConnectorPath(world, sourceMap, mode, path, rects, seed, strictMas } 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 maxAdds = options.maxAdds ?? (mode === "rail" ? 8 : 16); + const maxDistance = options.maxDistance ?? (mode === "rail" ? 280 : 380); const minComponentSize = options.minComponentSize ?? (mode === "rail" ? 3 : 4); const debug = { [`${mode}GraphBeforeComponents`]: 0, @@ -2529,87 +3007,99 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, 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. + // Broad transport context is intentional, but the worklist is limited to + // components that touch the edited neighborhood. Purely external networks + // remain as context/targets, not as things to rewire together. return comp.patchCells > 0 || comp.nearWriteCells > 0; }; + const contextual = (comp) => { + if (!comp || comp.size < minComponentSize) return false; + return comp.patchCells > 0 || comp.nearWriteCells > 0 || comp.exteriorCells > 0; + }; let snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); debug[`${mode}GraphBeforeComponents`] = snapshot.comps.filter(eligible).length; const rejectedPairs = new Set(); - 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++) { + let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8); + for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) { snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); - const comps = snapshot.comps.filter(eligible); + const dirtyCount = snapshot.comps.filter(eligible).length; + if (dirtyCount <= 1) break; + const comps = snapshot.comps.filter(contextual); 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 candidates = buildTransportPairCandidates(snapshot, comps, maxDistance, mode, rejectedPairs); + debug[`${mode}GraphCandidatesConsidered`] += candidates.length; + if (!candidates.length) { + debug[`${mode}GraphComponentsIgnored`] += dirtyCount; + break; } - 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; + + let accepted = false; + for (const candidate of candidates.slice(0, 1)) { + const { a, b, d } = candidate.pair; + if (transportLineSeaBarrier(world, a, b, mode)) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(transportPairSignature(a, b, mode)); + rejectedPairs.add(transportPairSignature(b, a, mode)); + continue; + } + const pad = Math.ceil(Math.max(28, Math.min(mode === "rail" ? 72 : 84, d * 0.38 + 16))); + const searchRect = expandRect({ + x0: Math.floor(Math.min(a.x, b.x)), + y0: Math.floor(Math.min(a.y, b.y)), + x1: Math.ceil(Math.max(a.x, b.x) + 1), + y1: Math.ceil(Math.max(a.y, b.y) + 1), + }, pad, world); + const boundedSearchRect = { + x0: Math.max(searchRect.x0, graphRect.x0), + y0: Math.max(searchRect.y0, graphRect.y0), + x1: Math.min(searchRect.x1, graphRect.x1), + y1: Math.min(searchRect.y1, graphRect.y1), + }; + const allowCell = makeTransportConnectorAllowCell(world, snapshot, rects, seed, !!rects.strictSelectionMask); + const extraCost = makeTransportConnectorExtraCost(world, rects, seed, mode); + const searchArea = Math.max(0, rectWidth(boundedSearchRect) * rectHeight(boundedSearchRect)); + if (searchArea > (mode === "rail" ? 72000 : 90000)) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(transportPairSignature(a, b, mode)); + rejectedPairs.add(transportPairSignature(b, a, mode)); + continue; + } + attemptsRemaining--; + const path = localPathfind(world, a, b, boundedSearchRect, mode, mode === "rail" ? 5500 : 7000, { allowCell, extraCost }); + const clean = dedupeWorldPath(path || []); + const routeLen = pathLength(clean); + const tooLong = !clean.length || routeLen > d * (mode === "rail" ? 2.65 : 3.05) + (mode === "rail" ? 48 : 70); + if (tooLong) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(transportPairSignature(a, b, mode)); + rejectedPairs.add(transportPairSignature(b, a, mode)); + continue; + } + + const beforeEligibleComponents = dirtyCount; + const writeResult = writeConnectorPath(world, sourceMap, mode, clean, rects, seed, !!rects.strictSelectionMask); + if (!writeResult.wrote) { + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(transportPairSignature(a, b, mode)); + rejectedPairs.add(transportPairSignature(b, a, mode)); + continue; + } + const checkSnapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); + const afterEligibleComponents = checkSnapshot.comps.filter(eligible).length; + if (afterEligibleComponents >= beforeEligibleComponents) { + sourceMap[writeResult.layer].splice(writeResult.startLength); + debug[`${mode}GraphConnectorsFailed`]++; + rejectedPairs.add(transportPairSignature(a, b, mode)); + rejectedPairs.add(transportPairSignature(b, a, mode)); + continue; + } + debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote; + accepted = true; + break; } - 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; + if (!accepted) break; } snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length; @@ -2661,7 +3151,12 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, admi 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) { + // Prior patch-generated point layers must be fully replaced when the user + // presses Alternative. The new variant has a different alpha noise field, + // so using the normal 0.34 removal threshold can leave previous towns, + // stations, labels, etc. in the seam band. + const removalThreshold = p.patchGenerated ? 0.005 : 0.34; + if (!inWrite || alpha < removalThreshold) { kept.push(p); if (!inWrite) preservedExternalEntities++; } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { @@ -2703,7 +3198,7 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { 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)) + .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y))) .filter((chunk) => chunk.length >= 2); for (const chunk of chunks) { if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) { @@ -2728,14 +3223,18 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { 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, connectorPatchOptions); - const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect, connectorPatchOptions); + // Legacy anchor-to-target connectors were not graph-validated and could leave + // visible fragments that did not reduce disconnected components. Keep the + // anchor collection for diagnostics, but route all actual repair through the + // graph reconnection pass below, which rolls back failed candidates. + const roadConn = { connectors: 0, disconnected: roadAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; + const railConn = { connectors: 0, disconnected: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 }; const graphRect = strictMask ? transportRect : transportRect; - 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 }); + const roadGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "road", graphRect, { maxAdds: strictMask ? 12 : 9, maxDistance: strictMask ? 340 : 290, maxAttempts: strictMask ? 8 : 6 }); + const railGraph = reconnectTransportGraph(world, sourceMap, rects, seed, "rail", graphRect, { maxAdds: strictMask ? 6 : 4, maxDistance: strictMask ? 245 : 210, maxAttempts: strictMask ? 4 : 3 }); return { roadsClipped, railsClipped, @@ -2823,29 +3322,39 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = 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. + // Keep outside prepared boundaries, but treat the patch interior as a single + // replacement zone. Mixing candidate vector boundaries with raster-rebuilt + // boundaries without filtering produced double prefecture/municipal lines: + // one properly dashed line plus a nearby white/solid-looking twin. + const keptByKey = new Map(); for (const key of SEGMENT_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; - sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); + const kept = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); + keptByKey.set(key, kept); } const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.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 }); + const rebuiltAdminRaw = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 }); + const rebuiltPrefRaw = 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 ||= []; + // Candidate boundaries come from the full generator and usually have the same + // visual semantics as the initial map. Raster-rebuilt segments are fallback + // only, and are rejected when they are near an existing candidate segment. + const rebuiltPref = filterSupplementalSegments(candidatePref, rebuiltPrefRaw, 0.72); + const patchPref = dedupeSegments([...candidatePref, ...rebuiltPref]); + const rebuiltAdmin = filterSupplementalSegments(candidateAdmin, rebuiltAdminRaw, 0.64); + let patchAdmin = dedupeSegments([...candidateAdmin, ...rebuiltAdmin]); + + // A prefecture border is also a municipal border in the raw rasters. Do not + // draw both visual layers on the same line; the thicker prefecture styling wins. + patchAdmin = removeSegmentsNearSegments(patchAdmin, patchPref, 0.76); + + sourceMap.adminBorders = dedupeSegments([...(keptByKey.get("adminBorders") || []), ...patchAdmin]); + sourceMap.regionalPrefectureBorders = dedupeSegments([...(keptByKey.get("regionalPrefectureBorders") || []), ...patchPref]); + sourceMap.prefectureBorder = dedupeSegments([...(keptByKey.get("prefectureBorder") || []), ...(sourceMap.prefectureBorder || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha))]); 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 { @@ -2853,6 +3362,8 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, candidateAdminBordersMerged: candidateAdmin.length, candidatePrefectureBordersMerged: candidatePref.length, + rasterAdminBordersSuppressed: Math.max(0, rebuiltAdminRaw.length - rebuiltAdmin.length), + rasterPrefectureBordersSuppressed: Math.max(0, rebuiltPrefRaw.length - rebuiltPref.length), compartmentBordersRebuilt: debug.compartmentBorders.length, }; } @@ -2914,22 +3425,45 @@ function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) { } } +function pathWorldBounds(world, path) { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const tuple of path || []) { + const x = tupleWorldX(world, tuple); + const y = tupleWorldY(world, tuple); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + x0 = Math.min(x0, x); y0 = Math.min(y0, y); + x1 = Math.max(x1, x); y1 = Math.max(y1, y); + } + if (!Number.isFinite(x0)) return null; + return { x0, y0, x1: x1 + 1, y1: y1 + 1 }; +} + +function rectsSeparatedByMoreThan(a, b, margin = 0) { + return a.x1 + margin < b.x0 || a.x0 - margin > b.x1 || a.y1 + margin < b.y0 || a.y0 - margin > b.y1; +} + function refreshPatchInfluenceFields(world, sourceMap, rects) { - const rect = rects.repairRect || rects.writeRect; + const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; + const localRect = rects.repairRect || rects.writeRect; const roadInfluence = ensureWorldFloatField(world, "roadInfluence"); const railInfluence2 = ensureWorldFloatField(world, "railInfluence2"); const stationInfluence = ensureWorldFloatField(world, "stationInfluence"); const villageInfluence = ensureWorldFloatField(world, "villageInfluence"); - for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect); + clearFieldRect(world, roadInfluence, transportRect); + clearFieldRect(world, railInfluence2, transportRect); + clearFieldRect(world, stationInfluence, transportRect); + clearFieldRect(world, villageInfluence, localRect); let roadCellsPainted = 0; let railCellsPainted = 0; let stationCellsPainted = 0; let villageCellsPainted = 0; - const paintPathLayer = (keys, field, radius, strength, counterName) => { + const paintPathLayer = (keys, field, radius, strength, counterName, rect) => { let painted = 0; for (const key of keys) { for (const path of sourceMap[key] || []) { + const bounds = pathWorldBounds(world, path); + if (!bounds || rectsSeparatedByMoreThan(bounds, rect, radius + 2)) continue; for (const tuple of path || []) { const x = tupleWorldX(world, tuple); const y = tupleWorldY(world, tuple); @@ -2943,32 +3477,35 @@ function refreshPatchInfluenceFields(world, sourceMap, rects) { 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"); + paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road", transportRect); + paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail", transportRect); for (const p of sourceMap.stations || []) { const x = pointWorldX(world, p); const y = pointWorldY(world, p); - if (rectDistance(x, y, rect) > 8) continue; - paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect); + if (rectDistance(x, y, transportRect) > 8) continue; + paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), transportRect); stationCellsPainted++; } for (const p of sourceMap.villages || []) { const x = pointWorldX(world, p); const y = pointWorldY(world, p); - if (rectDistance(x, y, rect) > 10) continue; - paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect); + if (rectDistance(x, y, localRect) > 10) continue; + paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), localRect); villageCellsPainted++; } return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted }; } -function countSea(world, rect) { +function countSea(world, rect, rects = null, seed = 0) { 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 useStrictMask = !!rects?.strictSelectionMask; + const scanRect = useStrictMask ? (rects.writeRect || rect) : rect; + for (let y = scanRect.y0; y < scanRect.y1; y++) { + for (let x = scanRect.x0; x < scanRect.x1; x++) { + if (useStrictMask && patchAlpha(x, y, rects, seed) <= 0.005) continue; const i = worldIndex(world, x, y); if (i < 0) continue; total++; @@ -3019,6 +3556,82 @@ function getOrGeneratePatchCandidate(world, key, create) { return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; } +function clonePointForPatch(point) { + return point ? { ...point } : point; +} + +function captureStrictMetadataSnapshot(world, sourceMap, rects, seed = 0) { + if (!rects?.strictSelectionMask || !sourceMap) return null; + const byLayer = new Map(); + for (const key of ["adminCenters", "prefectureRegions"]) { + const arr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; + const outside = []; + for (const p of arr) { + const x = Math.round(pointWorldX(world, p)); + const y = Math.round(pointWorldY(world, p)); + if (patchAlpha(x, y, rects, seed) <= 0.005) outside.push(clonePointForPatch(p)); + } + byLayer.set(key, outside); + } + return { byLayer }; +} + +function metadataIdForLayer(point, key) { + if (key === "prefectureRegions") return numericFeatureId(point, ["prefectureRegionId", "id", "featureId"]); + return numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); +} + +function restoreOutsideStrictMetadata(world, sourceMap, rects, snapshot, seed = 0) { + if (!snapshot || !rects?.strictSelectionMask || !sourceMap) return { strictMetadataPointsRestored: 0 }; + let strictMetadataPointsRestored = 0; + for (const key of ["adminCenters", "prefectureRegions"]) { + const oldOutside = snapshot.byLayer.get(key) || []; + if (!oldOutside.length) continue; + const oldById = new Map(); + for (const p of oldOutside) { + const id = metadataIdForLayer(p, key); + if (id >= 0 && !oldById.has(id)) oldById.set(id, clonePointForPatch(p)); + } + const existing = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; + const usedOldIds = new Set(); + const next = []; + for (const p of existing) { + const id = metadataIdForLayer(p, key); + const old = id >= 0 ? oldById.get(id) : null; + if (old) { + // If an existing administrative label/center was outside the lasso + // before the patch, keep it anchored there by ID. The global coherence + // pass may otherwise move it into the selected area even though the + // user did not select the old center itself. + next.push(clonePointForPatch(old)); + usedOldIds.add(id); + strictMetadataPointsRestored++; + continue; + } + next.push(p); + } + for (const [id, old] of oldById) { + if (usedOldIds.has(id)) continue; + const present = next.some((p) => metadataIdForLayer(p, key) === id); + if (!present) { + next.push(clonePointForPatch(old)); + strictMetadataPointsRestored++; + } + } + sourceMap[key] = next; + } + return { strictMetadataPointsRestored }; +} + +function addInvalidatedRect(world, rect) { + if (!rect) return; + const normalized = normalizeRect(rect); + if (!normalized || rectArea(normalized) <= 0) return; + const key = rectKey(normalized); + const list = world.invalidatedRects || (world.invalidatedRects = []); + if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized }); +} + export function generatePatch(world, userRectInput, options = {}) { const validation = validatePatchRect(userRectInput, world); if (!validation.ok) return { ok: false, ...validation }; @@ -3053,20 +3666,25 @@ export function generatePatch(world, userRectInput, options = {}) { height: MAP_H, contextRect: rects.contextRect, boundaryWorld: world, + patchMode: true, + topCenterSuppression: 0.72, 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 strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel); patchTimer.mark("fields", "Field copy and alpha blend"); const terrainSeamDebug = featherTerrainSeam(world, rects, seed); + const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed); const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); + const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed); const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); const maskDebug = repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); @@ -3080,7 +3698,7 @@ export function generatePatch(world, userRectInput, options = {}) { 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 strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); const municipalCoherence = reconcileMunicipalMetadata({ adminId: world.fields.adminId, @@ -3102,6 +3720,7 @@ export function generatePatch(world, userRectInput, options = {}) { prefectureRegionId: world.fields.prefectureRegionId, sea: world.fields.sea, existing: sourceMap.prefectureRegions || [], + adminCenters: sourceMap.adminCenters || [], fields: world.fields, width: world.width, height: world.height, @@ -3109,6 +3728,8 @@ export function generatePatch(world, userRectInput, options = {}) { pointOffsetY: world.originY || 0, }); sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; + const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); + const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed); sourceMap.adminDebug = { ...(sourceMap.adminDebug || {}), municipalCoherence: municipalCoherence.debug, @@ -3121,7 +3742,7 @@ export function generatePatch(world, userRectInput, options = {}) { patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); const patchTimings = patchTimer.timings; - const seaStats = countSea(world, rects.coreRect); + const seaStats = countSea(world, rects.coreRect, rects, seed); const label = terrainLabel(candidate, terrainType); const id = terrainId(candidate, terrainType); const humanGeography = { @@ -3137,7 +3758,9 @@ export function generatePatch(world, userRectInput, options = {}) { sourceAdminMetadataUpdated, ...finalAdminCoverageDebug, ...adminTopologyDebug, - ...strictMaskDebug, + strictMaskCellsRestored: (strictMaskDebugPreCoherence.strictMaskCellsRestored || 0) + (strictMaskDebugPostCoherence.strictMaskCellsRestored || 0), + strictMaskValuesRestored: (strictMaskDebugPreCoherence.strictMaskValuesRestored || 0) + (strictMaskDebugPostCoherence.strictMaskValuesRestored || 0), + ...strictMetadataDebug, humanLandCellsRestored: fieldDebug.humanLandCellsRestored || 0, humanLandFeatureMaskCells: fieldDebug.humanLandFeatureMaskCells || 0, finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, @@ -3148,10 +3771,16 @@ export function generatePatch(world, userRectInput, options = {}) { prefectureMetadataCoherence: prefectureCoherence.debug, continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, + terrainSeamAdjustmentSamples: fieldDebug.terrainSeamAdjustmentSamples || 0, + terrainSeamElevationOffset: fieldDebug.terrainSeamElevationOffset || 0, + terrainSeamElevationTiltX: fieldDebug.terrainSeamElevationTiltX || 0, + terrainSeamElevationTiltY: fieldDebug.terrainSeamElevationTiltY || 0, adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, ...terrainSeamDebug, + ...elevationCliffDebug, ...waterComponentDebug, + ...residualSeaDebug, ...waterElevationDebug, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, displayMaskUpdated: maskDebug.displayMaskUpdated || 0, @@ -3173,6 +3802,8 @@ export function generatePatch(world, userRectInput, options = {}) { writeRect: { ...rects.writeRect }, repairRect: { ...rects.repairRect }, contextRect: { ...rects.contextRect }, + transportReachRect: { ...rects.transportReachRect }, + transportReachMargin: rects.transportReachMargin, blendRect: { ...rects.blendRect }, terrainType: id, label, @@ -3184,7 +3815,7 @@ export function generatePatch(world, userRectInput, options = {}) { patchTimings, updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, - coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped, + coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped + residualSeaDebug.residualSeaCellsFilled, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, adminIdMapping: fieldDebug.adminIdMappingDebug, @@ -3193,7 +3824,8 @@ export function generatePatch(world, userRectInput, options = {}) { createdAt: Date.now(), }; world.generatedRects = [...(world.generatedRects || []), record]; - world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }]; + addInvalidatedRect(world, rects.writeRect); + addInvalidatedRect(world, rects.transportReachRect); world.lastPatchResult = record; world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1; diff --git a/mapPatchWorker.js b/mapPatchWorker.js new file mode 100644 index 0000000..b2d1918 --- /dev/null +++ b/mapPatchWorker.js @@ -0,0 +1,11 @@ +import { generatePatch } from "./mapPatch.js"; + +self.onmessage = (event) => { + const { id, world, rect, options } = event.data || {}; + try { + const result = generatePatch(world, rect, options || {}); + self.postMessage({ id, ok: true, world, result }); + } catch (error) { + self.postMessage({ id, ok: false, error: error?.message || String(error), stack: error?.stack || "" }); + } +}; diff --git a/mapTerrain.js b/mapTerrain.js index ece1655..0bc607d 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -160,7 +160,6 @@ const TERRAIN_TYPES = [ { id: "tohoku_spine", label: "東北型・長大脊梁", - weight: 0.24, coastStyle: "parallel_spine", mountainMode: "range", massifnessRange: [0.06, 0.26], @@ -185,7 +184,6 @@ const TERRAIN_TYPES = [ { id: "chubu_mountain", label: "中部型・交差高山地", - weight: 0.24, coastStyle: "outer_coast", mountainMode: "massif", massifnessRange: [0.42, 0.74], @@ -210,7 +208,6 @@ const TERRAIN_TYPES = [ { id: "oceanic_archipelago", label: "Ocean", - weight: 0.00, autoSelectable: false, coastStyle: "oceanic_archipelago", mountainMode: "mixed", @@ -236,24 +233,23 @@ const TERRAIN_TYPES = [ { id: "setouchi_inland_sea", label: "瀬戸内型・内海多島", - weight: 0.16, coastStyle: "inland_sea", mountainMode: "mixed", massifnessRange: [0.34, 0.62], - seaRatioRange: [0.28, 0.43], - twoSidedChance: 0.92, + seaRatioRange: [0.40, 0.56], + twoSidedChance: 0.96, mountainOffsetRange: [0.22, 0.34], baseHeightRange: [0.46, 0.78], primaryLengthRange: [0.52, 0.78], primaryWidthRange: [0.20, 0.38], - systemCountRange: [16, 22], - beltCountRange: [3, 4], + systemCountRange: [20, 28], + beltCountRange: [4, 5], angleSpread: 0.34, crossSpread: 0.86, - lengthScale: 1.00, - widthScale: 1.18, - heightScale: 0.82, - coastStrength: 1.34, + lengthScale: 0.92, + widthScale: 1.02, + heightScale: 0.78, + coastStrength: 1.52, plainBiasRange: [0.26, 0.50], riverRichnessRange: [0.58, 0.96], bigRiverChanceRange: [0.18, 0.42], @@ -261,7 +257,6 @@ const TERRAIN_TYPES = [ { id: "kanto_alluvial", label: "関東・濃尾型・大河川平野", - weight: 0.16, coastStyle: "open_bay", mountainMode: "range", massifnessRange: [0.10, 0.30], @@ -286,7 +281,6 @@ const TERRAIN_TYPES = [ { id: "mixed_archipelago", label: "混合型・列島変化", - weight: 0.20, coastStyle: "mixed_archipelago", mountainMode: "mixed", massifnessRange: [0.16, 0.72], @@ -1896,16 +1890,19 @@ export function generateTerrainAndRivers(seed, options = {}) { e -= high * clamp(0.18 + mountainMaskMax * 0.22, 0.18, 0.40); } if (terrainTemplate.terrainType === "setouchi_inland_sea") { - // Setouchi maps should have many low hills and island backbones rather - // than a few high alpine ridges. Add broad low relief, then cap peaks. - const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9); - const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12); - e += lowHillMask * 0.145; - // Do not add the previous fine speckle uplift here: it created too many - // tiny islets. Sea amount is controlled by seaRatio/coast pressure. - e -= clamp((coastPressure - 0.42) * 1.35) * 0.026; - const high = Math.max(0, e - 0.62); - e -= high * 0.42; + // Setouchi should read as sea-dominant, with many compact wooded island + // backbones rather than broad continental ridges. The small-massif term + // is band-limited so it forms believable islands, not one-cell speckle. + const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.39) * 2.7); + const islandMassif = clamp((fbm(wx * 1.85 - 31, wy * 1.85 + 19, seed + 573) - 0.50) * 3.4); + const islandBackbone = clamp((valueNoise(wx * 2.8 + 7, wy * 2.8 - 11, seed + 574, 9) - 0.54) * 3.2); + const coastalIslandBias = clamp(coastPressure * 0.64 + mountainMaskMax * 0.46 + lowHillNoise * 0.24); + e += lowHillNoise * 0.090; + e += islandMassif * coastalIslandBias * 0.105; + e += islandBackbone * coastalIslandBias * 0.045; + e -= clamp((coastPressure - 0.34) * 1.55) * 0.044; + const high = Math.max(0, e - 0.60); + e -= high * 0.48; } if (terrainTemplate.terrainType === "oceanic_archipelago") { // 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。 diff --git a/renderer.js b/renderer.js index 1de25e1..8704b6e 100644 --- a/renderer.js +++ b/renderer.js @@ -433,14 +433,25 @@ function terrainColorContinuous(map, fx, fy, mode) { ]); } - const coastBlend = clamp((waterCoverage - 0.36) / 0.28); - color = coastBlend > 0 ? mixRgb(landColor, waterColor, coastBlend) : landColor; + const centerWater = Boolean(map.sea?.[i]); + if (centerWater) { + // Keep the visible coastline and the filled water side derived from the same + // sea mask. Only a very narrow anti-aliased edge borrows land color; broad + // land/sea averaging made coast strokes disagree with the underlying fill. + const edgeLand = clamp((0.58 - waterCoverage) / 0.26); + color = edgeLand > 0 ? mixRgb(waterColor, landColor, edgeLand * 0.42) : waterColor; + } else { + const shore = clamp((waterCoverage - 0.10) / 0.42); + const shoreColor = [224, 229, 213]; + color = shore > 0 ? mixRgb(landColor, shoreColor, shore * 0.34) : landColor; + } return blendOutside(color, isInside); } function terrainShadeContinuous(map, fx, fy) { + const i = sampleCellIndex(map, fx, fy); const waterCoverage = seaCoverageSample(map, fx, fy); - if (waterCoverage >= 0.50) return waterVisualShade(map, fx, fy); + if (map.sea?.[i] || waterCoverage >= 0.82) return waterVisualShade(map, fx, fy); const step = 0.50; const eC = fieldSample(map, map.elevation, fx, fy); diff --git a/styles.css b/styles.css index 5b353a2..c2e3ac8 100644 --- a/styles.css +++ b/styles.css @@ -90,3 +90,5 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s} .secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)} .secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed} + +.map-canvas.is-zooming{image-rendering:auto;pointer-events:auto} diff --git a/test.js b/test.js index 2a54ed6..10ca0f9 100644 --- a/test.js +++ b/test.js @@ -11,23 +11,30 @@ import { validateGeneratedName, } from "./names.js"; -const result = document.getElementById("result"); +const IS_BROWSER = typeof document !== "undefined"; +const result = IS_BROWSER ? document.getElementById("result") : { className: "", textContent: "" }; const logLines = []; let failed = 0; +async function readLocalText(path) { + if (IS_BROWSER) return fetch(path).then((response) => response.text()); + const { readFile } = await import("node:fs/promises"); + return readFile(new URL(path, import.meta.url), "utf8"); +} + const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([ - fetch("./names.js").then((response) => response.text()), - fetch("./mapGenerator.js").then((response) => response.text()), - fetch("./mapOutput.js").then((response) => response.text()), - fetch("./mapTerrain.js").then((response) => response.text()), - fetch("./renderer.js").then((response) => response.text()), - fetch("./app.js").then((response) => response.text()), - fetch("./mapPipeline.js").then((response) => response.text()), - fetch("./mapAdminStage.js").then((response) => response.text()), - fetch("./mapPatch.js").then((response) => response.text()), - fetch("./worldMap.js").then((response) => response.text()), - fetch("./mapMunicipalCoherence.js").then((response) => response.text()), - fetch("./test.js").then((response) => response.text()), + readLocalText("./names.js"), + readLocalText("./mapGenerator.js"), + readLocalText("./mapOutput.js"), + readLocalText("./mapTerrain.js"), + readLocalText("./renderer.js"), + readLocalText("./app.js"), + readLocalText("./mapPipeline.js"), + readLocalText("./mapAdminStage.js"), + readLocalText("./mapPatch.js"), + readLocalText("./worldMap.js"), + readLocalText("./mapMunicipalCoherence.js"), + readLocalText("./test.js"), ]); function assert(condition, message) { @@ -1023,7 +1030,12 @@ try { result.className = failed === 0 ? "ok" : "ng"; result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`; + if (!IS_BROWSER) console.log(result.textContent); } catch (error) { result.className = "ng"; result.textContent = String(error?.stack || error); + if (!IS_BROWSER) { + console.error(result.textContent); + process.exitCode = 1; + } } diff --git a/worldViewport.js b/worldViewport.js index 448a1d9..309effb 100644 --- a/worldViewport.js +++ b/worldViewport.js @@ -218,7 +218,14 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight); } - if (options.light) return viewport; + if (options.light) { + // Light/fast redraws are used while panning and zooming. Do not leave + // source-space debug vectors on the viewport; in borders-debug this made + // natural compartment lines appear fixed on screen while the map moved. + viewport.adminDebug = null; + viewport.transportDebug = null; + return viewport; + } const originX = world?.originX || 0; const originY = world?.originY || 0;