hm
This commit is contained in:
parent
f2d0306d96
commit
27ceb6568a
7 changed files with 940 additions and 132 deletions
7
app.js
7
app.js
|
|
@ -732,7 +732,6 @@ function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = nul
|
|||
const d = Math.hypot(center.x - x, center.y - y);
|
||||
if (d < bestD) { best = center; bestD = d; }
|
||||
}
|
||||
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
|
||||
return best;
|
||||
}
|
||||
|
||||
|
|
@ -764,7 +763,9 @@ function prefectureNameForCell(map, i) {
|
|||
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
|
||||
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
|
||||
if (regionName) return regionName;
|
||||
const center = nearestNamedAdminCenter(map, i, 90);
|
||||
const adminId = map.adminId?.[i] ?? -1;
|
||||
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
|
||||
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
|
||||
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
|
||||
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
|
||||
}
|
||||
|
|
@ -930,7 +931,7 @@ async function generateSelectedPatch() {
|
|||
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}`;
|
||||
renderTimingRows([]);
|
||||
renderTimingRows(result.patchTimings || []);
|
||||
window.setTimeout(() => setProgressVisible(false), 900);
|
||||
} catch (error) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
|
||||
|
|
|
|||
265
mapMunicipalCoherence.js
Normal file
265
mapMunicipalCoherence.js
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
|
||||
|
||||
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
|
||||
|
||||
function coordIndex(width, height, x, y) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
|
||||
return y * width + x;
|
||||
}
|
||||
|
||||
function numericAdminId(point) {
|
||||
for (const key of ADMIN_ID_KEYS) {
|
||||
const value = point?.[key];
|
||||
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function fieldValue(fields, name, i) {
|
||||
return fields?.[name]?.[i] || 0;
|
||||
}
|
||||
|
||||
function bestCellScore(fields, i) {
|
||||
return fieldValue(fields, "populationDensity", i) * 3.0
|
||||
+ fieldValue(fields, "plain", i) * 0.32
|
||||
+ fieldValue(fields, "agriculture", i) * 0.16
|
||||
- fieldValue(fields, "slope", i) * 0.30
|
||||
- fieldValue(fields, "ridgeField", i) * 0.18;
|
||||
}
|
||||
|
||||
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
|
||||
const stats = new Map();
|
||||
for (let i = 0; i < adminId.length; i++) {
|
||||
const id = adminId[i];
|
||||
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
|
||||
const x = i % width;
|
||||
const y = Math.floor(i / width);
|
||||
row.area++;
|
||||
row.sx += x;
|
||||
row.sy += y;
|
||||
const pref = prefectureRegionId?.[i] ?? -1;
|
||||
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
|
||||
const score = bestCellScore(fields, i);
|
||||
if (score > row.bestScore) {
|
||||
row.bestScore = score;
|
||||
row.bestI = i;
|
||||
}
|
||||
stats.set(id, row);
|
||||
}
|
||||
for (const row of stats.values()) {
|
||||
let bestPref = -1;
|
||||
let bestVotes = -1;
|
||||
for (const [pref, count] of row.prefVotes) {
|
||||
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
|
||||
bestPref = pref;
|
||||
bestVotes = count;
|
||||
}
|
||||
}
|
||||
row.prefectureRegionId = bestPref;
|
||||
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
|
||||
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
|
||||
return {
|
||||
x: Math.round((point?.x || 0) + pointOffsetX),
|
||||
y: Math.round((point?.y || 0) + pointOffsetY),
|
||||
};
|
||||
}
|
||||
|
||||
function centerQuality(center, id, stat, context) {
|
||||
if (!center) return -INF;
|
||||
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
|
||||
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
|
||||
const i = coordIndex(width, height, p.x, p.y);
|
||||
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
|
||||
return (ownsCell ? 100000 : 0)
|
||||
+ (center.name ? 5000 : 0)
|
||||
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
|
||||
+ (center.generatedOfficePoint ? -200 : 0)
|
||||
- Math.hypot(p.x - stat.x, p.y - stat.y);
|
||||
}
|
||||
|
||||
function normalizeCenter(center, id, stat, context, generated = false) {
|
||||
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
|
||||
const out = {
|
||||
...(center || {}),
|
||||
x: stat.x - pointOffsetX,
|
||||
y: stat.y - pointOffsetY,
|
||||
adminId: id,
|
||||
adminNumericId: id,
|
||||
municipalityId: id,
|
||||
prefectureRegionId: stat.prefectureRegionId,
|
||||
municipalArea: stat.area,
|
||||
insidePrefecture: true,
|
||||
};
|
||||
if (generated) {
|
||||
out.generatedOfficePoint = true;
|
||||
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
|
||||
out.kind ||= "Municipal Center";
|
||||
out.generatedMunicipalityName ||= `自治${id + 1}`;
|
||||
out.name ||= out.generatedMunicipalityName;
|
||||
out.labelName ||= out.generatedMunicipalityName;
|
||||
out.municipalityName ||= out.generatedMunicipalityName;
|
||||
}
|
||||
const i = coordIndex(context.width, context.height, stat.x, stat.y);
|
||||
if (i >= 0) {
|
||||
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
|
||||
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function reconcileMunicipalMetadata({
|
||||
adminId,
|
||||
municipalityId = null,
|
||||
prefectureRegionId = null,
|
||||
sea = null,
|
||||
adminCenters = [],
|
||||
municipalityToPrefectureId = null,
|
||||
fields = {},
|
||||
width = MAP_W,
|
||||
height = MAP_H,
|
||||
pointOffsetX = 0,
|
||||
pointOffsetY = 0,
|
||||
seed = 0,
|
||||
} = {}) {
|
||||
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
|
||||
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
|
||||
if (municipalityId) {
|
||||
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
|
||||
}
|
||||
|
||||
const byId = new Map();
|
||||
let ghostCentersRemoved = 0;
|
||||
let centersMovedToOwnedCells = 0;
|
||||
for (const [index, center] of (adminCenters || []).entries()) {
|
||||
if (!center) continue;
|
||||
const explicitId = numericAdminId(center);
|
||||
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
|
||||
const stat = stats.get(id);
|
||||
if (!stat) {
|
||||
ghostCentersRemoved++;
|
||||
continue;
|
||||
}
|
||||
const current = byId.get(id);
|
||||
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
|
||||
byId.set(id, center);
|
||||
}
|
||||
}
|
||||
|
||||
let fallbackCentersAdded = 0;
|
||||
const nextCenters = [];
|
||||
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const existing = byId.get(id);
|
||||
if (!existing) fallbackCentersAdded++;
|
||||
else {
|
||||
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
|
||||
const pi = coordIndex(width, height, p.x, p.y);
|
||||
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
|
||||
}
|
||||
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
|
||||
}
|
||||
|
||||
let maxId = Math.max(-1, ...stats.keys());
|
||||
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
|
||||
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
|
||||
nextMapping.fill(-1);
|
||||
if (municipalityToPrefectureId) {
|
||||
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
|
||||
}
|
||||
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
|
||||
|
||||
return {
|
||||
adminCenters: nextCenters,
|
||||
municipalityToPrefectureId: nextMapping,
|
||||
stats,
|
||||
debug: {
|
||||
activeMunicipalities: stats.size,
|
||||
ghostCentersRemoved,
|
||||
fallbackCentersAdded,
|
||||
centersMovedToOwnedCells,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshPrefectureRegionsMetadata({
|
||||
prefectureRegionId,
|
||||
sea,
|
||||
existing = [],
|
||||
fields = {},
|
||||
width = MAP_W,
|
||||
height = MAP_H,
|
||||
pointOffsetX = 0,
|
||||
pointOffsetY = 0,
|
||||
} = {}) {
|
||||
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
const id = prefectureRegionId[i];
|
||||
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
|
||||
const x = i % width;
|
||||
const y = Math.floor(i / width);
|
||||
row.area++;
|
||||
row.sx += x;
|
||||
row.sy += y;
|
||||
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
|
||||
if (score > row.bestScore) {
|
||||
row.bestScore = score;
|
||||
row.bestI = i;
|
||||
}
|
||||
byId.set(id, row);
|
||||
}
|
||||
const existingById = new Map();
|
||||
for (const region of existing || []) {
|
||||
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);
|
||||
}
|
||||
let fallbackRegionsAdded = 0;
|
||||
const prefectureRegions = [];
|
||||
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const base = existingById.get(id);
|
||||
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));
|
||||
prefectureRegions.push({
|
||||
...(base || {}),
|
||||
id,
|
||||
prefectureRegionId: id,
|
||||
featureId: id,
|
||||
x: x - pointOffsetX,
|
||||
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}`,
|
||||
forceLabel: true,
|
||||
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
|
||||
});
|
||||
}
|
||||
return {
|
||||
prefectureRegions,
|
||||
debug: {
|
||||
activePrefectureRegions: byId.size,
|
||||
fallbackRegionsAdded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function municipalCoherenceForMap(map) {
|
||||
return reconcileMunicipalMetadata({
|
||||
adminId: map?.adminId,
|
||||
municipalityId: map?.municipalityId,
|
||||
prefectureRegionId: map?.prefectureRegionId,
|
||||
sea: map?.sea,
|
||||
adminCenters: map?.adminCenters,
|
||||
municipalityToPrefectureId: map?.municipalityToPrefectureId,
|
||||
fields: map,
|
||||
width: map?.width || MAP_W,
|
||||
height: map?.height || MAP_H,
|
||||
});
|
||||
}
|
||||
80
mapOutput.js
80
mapOutput.js
|
|
@ -1,6 +1,7 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
|
||||
import { routeQualityAcceptable } from "./mapTransport.js";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
|
@ -30,15 +31,33 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
|||
}
|
||||
|
||||
|
||||
function centerMunicipalityId(center, fallback = -1) {
|
||||
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
|
||||
const value = center?.[key];
|
||||
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
|
||||
if (!adminCenters?.length || !adminId) return;
|
||||
const totals = new Float64Array(adminCenters.length);
|
||||
const settlementTotals = new Float64Array(adminCenters.length);
|
||||
const landCells = new Uint32Array(adminCenters.length);
|
||||
const inhabitedCells = new Uint32Array(adminCenters.length);
|
||||
const centerById = new Map();
|
||||
let maxId = -1;
|
||||
for (const center of adminCenters) {
|
||||
const id = centerMunicipalityId(center);
|
||||
if (id >= 0 && !centerById.has(id)) {
|
||||
centerById.set(id, center);
|
||||
maxId = Math.max(maxId, id);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
|
||||
const totals = new Float64Array(maxId + 1);
|
||||
const settlementTotals = new Float64Array(maxId + 1);
|
||||
const landCells = new Uint32Array(maxId + 1);
|
||||
const inhabitedCells = new Uint32Array(maxId + 1);
|
||||
for (let i = 0; i < adminId.length; i++) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
|
||||
if (id < 0 || fields.sea?.[i]) continue;
|
||||
landCells[id]++;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const lu = fields.landuse?.[i] ?? 0;
|
||||
|
|
@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
let skippedDuplicateSettlementPopulation = 0;
|
||||
for (const { feature, i } of uniqueSettlementByCell.values()) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length) continue;
|
||||
if (id < 0 || id >= settlementTotals.length) continue;
|
||||
settlementTotals[id] += feature.population;
|
||||
}
|
||||
for (const feature of settlementFeatures || []) {
|
||||
|
|
@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
const kept = uniqueSettlementByCell.get(key)?.feature;
|
||||
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
|
||||
}
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
for (const [id, center] of centerById) {
|
||||
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
|
||||
const minimumResidentPopulation = landCells[id] > 0
|
||||
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
|
||||
|
|
@ -85,13 +104,13 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
|
||||
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
|
||||
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
|
||||
adminCenters[id].municipalityPopulation = safePopulation;
|
||||
center.municipalityPopulation = safePopulation;
|
||||
// Some consumers still read the generic `population` field from municipal
|
||||
// centers. Mirror the municipality total there so no municipality is shown
|
||||
// as 0人 merely because it is not a canonical city/market entity.
|
||||
adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
|
||||
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||
center.population = Math.max(center.population || 0, safePopulation);
|
||||
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +486,20 @@ export function finishMapOutput({
|
|||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||
outputProgress("municipality naming");
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const municipalCoherence = reconcileMunicipalMetadata({
|
||||
adminId,
|
||||
prefectureRegionId,
|
||||
sea,
|
||||
adminCenters: adminCentersRaw,
|
||||
municipalityToPrefectureId,
|
||||
fields: nameFields,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
seed,
|
||||
});
|
||||
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
|
||||
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const representativeFeatures = [
|
||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||
|
|
@ -515,25 +547,27 @@ export function finishMapOutput({
|
|||
}
|
||||
const usedAdminNames = new Set();
|
||||
for (const [index, center] of adminCenters.entries()) {
|
||||
center.adminNumericId = index;
|
||||
center.municipalityId = index;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
const municipalId = centerMunicipalityId(center, index);
|
||||
center.adminId = municipalId;
|
||||
center.adminNumericId = municipalId;
|
||||
center.municipalityId = municipalId;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
|
||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const chars = Array.from(rootSource || "里郷");
|
||||
const alternates = [
|
||||
chars.slice(0, 2).join(""),
|
||||
chars.slice(-2).join(""),
|
||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
|
||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
|
||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
|
||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
|
||||
].filter((v) => Array.from(v).length >= 2);
|
||||
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
|
||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`;
|
||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(municipalId + attempt) % 10}`;
|
||||
candidate = `${root}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -584,7 +618,7 @@ export function finishMapOutput({
|
|||
return bs - as;
|
||||
})
|
||||
.filter((p) => {
|
||||
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
||||
const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
||||
const used = perPrefectureQuota.get(prefId) || 0;
|
||||
if (used >= 18) return false;
|
||||
perPrefectureQuota.set(prefId, used + 1);
|
||||
|
|
@ -1233,7 +1267,7 @@ export function finishMapOutput({
|
|||
humanRegionMask,
|
||||
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
|
||||
prefectureRegionId,
|
||||
municipalityToPrefectureId,
|
||||
municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
|
||||
prefectureRegions,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
|
|
|
|||
652
mapPatch.js
652
mapPatch.js
|
|
@ -1,6 +1,7 @@
|
|||
import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js";
|
||||
import { generateMap } from "./mapPipeline.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
|
||||
|
||||
export const PATCH_MIN_WIDTH = 48;
|
||||
export const PATCH_MIN_HEIGHT = 48;
|
||||
|
|
@ -24,6 +25,7 @@ const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "
|
|||
const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
|
||||
|
||||
const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
|
||||
const PATCH_CANDIDATE_CACHE_LIMIT = 3;
|
||||
|
||||
const ID_FIELD_OFFSETS = new Map([
|
||||
["adminId", 100000],
|
||||
|
|
@ -72,6 +74,23 @@ function rectArea(rect) {
|
|||
return rectWidth(rect) * rectHeight(rect);
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function createPatchTimer() {
|
||||
const timings = [];
|
||||
let mark = nowMs();
|
||||
return {
|
||||
timings,
|
||||
mark(key, label = key) {
|
||||
const t = nowMs();
|
||||
timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 });
|
||||
mark = t;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRect(rect) {
|
||||
if (!rect) return null;
|
||||
const x0 = Math.floor(Math.min(rect.x0, rect.x1));
|
||||
|
|
@ -257,7 +276,10 @@ export function buildPatchRects(userRect, world = null) {
|
|||
const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
|
||||
const writeRect = expandRect(coreRect, writeMargin, world);
|
||||
const repairRect = expandRect(coreRect, repairMargin, world);
|
||||
const transportReachMargin = Math.max(repairMargin + 160, Math.min(420, Math.max(220, repairMargin + Math.floor(Math.max(MAP_W, MAP_H) * 1.35))));
|
||||
const transportReachMargin = Math.max(
|
||||
repairMargin + 48,
|
||||
Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15)))
|
||||
);
|
||||
const transportReachRect = expandRect(coreRect, transportReachMargin, world);
|
||||
return {
|
||||
coreRect,
|
||||
|
|
@ -277,7 +299,7 @@ export function buildPatchRects(userRect, world = null) {
|
|||
};
|
||||
}
|
||||
|
||||
function patchAlpha(x, y, rects, seed = 0) {
|
||||
function computePatchAlpha(x, y, rects, seed = 0) {
|
||||
const writeRect = rects.writeRect || rects.userRect;
|
||||
if (!insideRect(x, y, writeRect)) return 0;
|
||||
const margin = Math.max(1, rects.writeMargin || 1);
|
||||
|
|
@ -289,10 +311,9 @@ function patchAlpha(x, y, rects, seed = 0) {
|
|||
const py = y + 0.5;
|
||||
const inside = pointInPolygon(px, py, shape.polygon);
|
||||
const dist = distanceToPolygonEdge(px, py, shape.polygon);
|
||||
const noisyDist = dist + low * margin * 0.28 + mid * margin * 0.10;
|
||||
if (inside) return 1;
|
||||
if (noisyDist >= margin * 1.08) return 0;
|
||||
return clamp(smoothstep(1 - noisyDist / Math.max(1e-6, margin)));
|
||||
const signedDist = inside ? dist : -dist;
|
||||
const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10;
|
||||
return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2)));
|
||||
}
|
||||
const edge = distanceToRectEdge(x, y, writeRect);
|
||||
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
|
||||
|
|
@ -303,6 +324,53 @@ function patchAlpha(x, y, rects, seed = 0) {
|
|||
return clamp(base);
|
||||
}
|
||||
|
||||
function getPatchAlphaCache(rects, seed = 0) {
|
||||
const writeRect = rects?.writeRect || rects?.userRect;
|
||||
if (!writeRect) return null;
|
||||
const width = rectWidth(writeRect);
|
||||
const height = rectHeight(writeRect);
|
||||
const existing = rects.patchAlphaCache;
|
||||
if (
|
||||
existing
|
||||
&& existing.seed === seed
|
||||
&& existing.width === width
|
||||
&& existing.height === height
|
||||
&& existing.x0 === writeRect.x0
|
||||
&& existing.y0 === writeRect.y0
|
||||
) return existing;
|
||||
|
||||
const data = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed);
|
||||
}
|
||||
}
|
||||
rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data };
|
||||
return rects.patchAlphaCache;
|
||||
}
|
||||
|
||||
function patchAlpha(x, y, rects, seed = 0) {
|
||||
const writeRect = rects?.writeRect || rects?.userRect;
|
||||
if (!writeRect || !insideRect(x, y, writeRect)) return 0;
|
||||
const cache = rects.patchAlphaCache;
|
||||
if (
|
||||
cache
|
||||
&& cache.seed === seed
|
||||
&& x >= cache.x0
|
||||
&& y >= cache.y0
|
||||
&& x < cache.x0 + cache.width
|
||||
&& y < cache.y0 + cache.height
|
||||
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0;
|
||||
return computePatchAlpha(x, y, rects, seed);
|
||||
}
|
||||
|
||||
function patchBand(x, y, rects, seed = 0) {
|
||||
const a = patchAlpha(x, y, rects, seed);
|
||||
if (a <= 0.18) return "preserve";
|
||||
if (a >= 0.82) return "core";
|
||||
return "feather";
|
||||
}
|
||||
|
||||
function continuityReplaceThreshold(name, x, y, rects, seed = 0) {
|
||||
const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5;
|
||||
if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92);
|
||||
|
|
@ -315,6 +383,21 @@ function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) {
|
|||
return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha;
|
||||
}
|
||||
|
||||
function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) {
|
||||
return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha;
|
||||
}
|
||||
|
||||
function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) {
|
||||
if (!Array.isArray(seg) || seg.length < 2) return false;
|
||||
const ax = tupleWorldX(world, seg[0]);
|
||||
const ay = tupleWorldY(world, seg[0]);
|
||||
const bx = tupleWorldX(world, seg[1]);
|
||||
const by = tupleWorldY(world, seg[1]);
|
||||
const mx = (ax + bx) * 0.5;
|
||||
const my = (ay + by) * 0.5;
|
||||
return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha);
|
||||
}
|
||||
|
||||
|
||||
function sourceWindowForRects(rects) {
|
||||
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
||||
|
|
@ -334,6 +417,59 @@ function sourceCoordForWorld(window, x, y) {
|
|||
};
|
||||
}
|
||||
|
||||
function getPatchSourceIndexCache(rects, window) {
|
||||
const writeRect = rects?.writeRect;
|
||||
if (!writeRect || !window) return null;
|
||||
const width = rectWidth(writeRect);
|
||||
const height = rectHeight(writeRect);
|
||||
const existing = rects.patchSourceIndexCache;
|
||||
if (
|
||||
existing
|
||||
&& existing.width === width
|
||||
&& existing.height === height
|
||||
&& existing.x0 === writeRect.x0
|
||||
&& existing.y0 === writeRect.y0
|
||||
&& existing.worldCenterX === window.worldCenterX
|
||||
&& existing.worldCenterY === window.worldCenterY
|
||||
&& existing.sourceCenterX === window.sourceCenterX
|
||||
&& existing.sourceCenterY === window.sourceCenterY
|
||||
) return existing;
|
||||
|
||||
const data = new Int32Array(width * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX);
|
||||
const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY);
|
||||
data[y * width + x] = sourceIndex(sx, sy);
|
||||
}
|
||||
}
|
||||
rects.patchSourceIndexCache = {
|
||||
width,
|
||||
height,
|
||||
x0: writeRect.x0,
|
||||
y0: writeRect.y0,
|
||||
worldCenterX: window.worldCenterX,
|
||||
worldCenterY: window.worldCenterY,
|
||||
sourceCenterX: window.sourceCenterX,
|
||||
sourceCenterY: window.sourceCenterY,
|
||||
data,
|
||||
};
|
||||
return rects.patchSourceIndexCache;
|
||||
}
|
||||
|
||||
function sourceIndexForWorld(rects, window, x, y) {
|
||||
const cache = rects?.patchSourceIndexCache;
|
||||
if (
|
||||
cache
|
||||
&& x >= cache.x0
|
||||
&& y >= cache.y0
|
||||
&& x < cache.x0 + cache.width
|
||||
&& y < cache.y0 + cache.height
|
||||
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)];
|
||||
const s = sourceCoordForWorld(window, x, y);
|
||||
return sourceIndex(s.x, s.y);
|
||||
}
|
||||
|
||||
function worldCoordForSource(window, sx, sy) {
|
||||
return {
|
||||
x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
|
||||
|
|
@ -385,8 +521,7 @@ function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.2
|
|||
for (let y = rect.y0; y < rect.y1; y++) {
|
||||
for (let x = rect.x0; x < rect.x1; x++) {
|
||||
if (patchAlpha(x, y, rects, seed) < minAlpha) continue;
|
||||
const s = sourceCoordForWorld(window, x, y);
|
||||
const si = sourceIndex(s.x, s.y);
|
||||
const si = sourceIndexForWorld(rects, window, x, y);
|
||||
if (si < 0) continue;
|
||||
const id = candidateField[si];
|
||||
if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
|
||||
|
|
@ -671,6 +806,70 @@ function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) {
|
|||
return { continuityCellsRestored: restored, continuityCellsRemapped: remapped };
|
||||
}
|
||||
|
||||
function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) {
|
||||
const a = patchAlpha(x, y, rects, seed);
|
||||
const i = worldIndex(world, x, y);
|
||||
const oldValue = oldField?.[i] ?? -1;
|
||||
if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue;
|
||||
if (a <= 0.24) return oldValue;
|
||||
if (a >= 0.82) return candidateValue;
|
||||
|
||||
const field = world.fields?.[fieldName];
|
||||
const pref = world.fields?.prefectureRegionId;
|
||||
const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField;
|
||||
let oldScore = (1 - a) * 3.0;
|
||||
let candidateScore = a * 3.0;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const ni = worldIndex(world, x + dx, y + dy);
|
||||
if (ni < 0) continue;
|
||||
const neighbor = field?.[ni] ?? -1;
|
||||
if (neighbor === oldValue) oldScore += 1.2;
|
||||
if (neighbor === candidateValue) candidateScore += 1.2;
|
||||
if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) {
|
||||
if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18;
|
||||
}
|
||||
}
|
||||
const barrierBonus = naturalBarrier?.[i] || 0;
|
||||
if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) {
|
||||
if (a < 0.5) oldScore += barrierBonus * 0.9;
|
||||
else candidateScore += barrierBonus * 0.9;
|
||||
}
|
||||
return candidateScore > oldScore ? candidateValue : oldValue;
|
||||
}
|
||||
|
||||
function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
|
||||
let adminSeamCellsResolved = 0;
|
||||
let prefectureSeamCellsResolved = 0;
|
||||
for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) {
|
||||
const field = world.fields?.[name];
|
||||
const old = oldFields?.get(name);
|
||||
if (!field || !old) continue;
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0 || world.fields.sea?.[i]) continue;
|
||||
if (patchBand(x, y, rects, seed) !== "feather") continue;
|
||||
const before = field[i];
|
||||
const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed);
|
||||
if (next !== before) {
|
||||
field[i] = next;
|
||||
if (name === "prefectureRegionId") prefectureSeamCellsResolved++;
|
||||
else adminSeamCellsResolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (world.fields.adminId && world.fields.municipalityId) {
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
|
||||
}
|
||||
|
||||
function copyFullPipelineFields(world, candidate, rects, seed) {
|
||||
const window = sourceWindowForRects(rects);
|
||||
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
||||
|
|
@ -703,8 +902,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
|
|||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const wi = worldIndex(world, x, y);
|
||||
if (wi < 0) continue;
|
||||
const s = sourceCoordForWorld(window, x, y);
|
||||
const si = sourceIndex(s.x, s.y);
|
||||
const si = sourceIndexForWorld(rects, window, x, y);
|
||||
if (si < 0) continue;
|
||||
const alpha = patchAlpha(x, y, rects, seed);
|
||||
if (alpha <= 0.005) continue;
|
||||
|
|
@ -780,6 +978,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
|
|||
}
|
||||
|
||||
const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed);
|
||||
const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed);
|
||||
if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) {
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
|
|
@ -800,6 +999,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
|
|||
adminIdMapping,
|
||||
adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
|
||||
...continuityDebug,
|
||||
...seamOwnershipDebug,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +1030,47 @@ function repairDisplayMasks(world, rects, seed = 0) {
|
|||
return { displayMaskUpdated };
|
||||
}
|
||||
|
||||
function featherTerrainSeam(world, rects, seed = 0) {
|
||||
const fields = world.fields || {};
|
||||
const smoothKeys = [
|
||||
"elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField",
|
||||
"basinField", "coastalLowland", "plain", "agriculture", "erosionField",
|
||||
"depositionField", "depositionalLowland", "alluvialFanField", "deltaField",
|
||||
"naturalBarrierScore", "settlementScore", "populationDensity",
|
||||
];
|
||||
let terrainFeatherCells = 0;
|
||||
let terrainFeatherValues = 0;
|
||||
for (const key of smoothKeys) {
|
||||
const field = fields[key];
|
||||
if (!field || !ArrayBuffer.isView(field)) continue;
|
||||
const old = new field.constructor(field);
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
if (patchBand(x, y, rects, seed) !== "feather") continue;
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0 || fields.sea?.[i]) continue;
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const ni = worldIndex(world, x + dx, y + dy);
|
||||
if (ni >= 0 && !fields.sea?.[ni]) {
|
||||
sum += old[ni] || 0;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (!count) continue;
|
||||
const a = patchAlpha(x, y, rects, seed);
|
||||
const neighborMean = sum / count;
|
||||
const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2);
|
||||
field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34));
|
||||
terrainFeatherValues++;
|
||||
if (key === "elevation") terrainFeatherCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { terrainFeatherCells, terrainFeatherValues };
|
||||
}
|
||||
|
||||
|
||||
function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
|
||||
const field = world.fields?.[fieldName];
|
||||
|
|
@ -873,7 +1114,7 @@ function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
|
|||
return -1;
|
||||
}
|
||||
|
||||
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
|
||||
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) {
|
||||
const admin = world.fields?.adminId;
|
||||
if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
|
||||
const expected = world.width * world.height;
|
||||
|
|
@ -905,7 +1146,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
|
|||
prefecture[i] = -1;
|
||||
continue;
|
||||
}
|
||||
const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08;
|
||||
const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect));
|
||||
if (!generated) continue;
|
||||
|
||||
if (admin[i] < 0) {
|
||||
|
|
@ -958,7 +1199,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
|
|||
return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
|
||||
}
|
||||
|
||||
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
|
||||
function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) {
|
||||
const sea = world.fields.sea;
|
||||
const ocean = world.fields.ocean;
|
||||
const lake = world.fields.lake;
|
||||
|
|
@ -981,8 +1222,11 @@ function smoothWaterTopology(world, rect, seaLevel = 0.30) {
|
|||
if (sea[ni]) seaN++; else landN++;
|
||||
}
|
||||
}
|
||||
if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
|
||||
else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
|
||||
const a = rects ? patchAlpha(x, y, rects, seed) : 1;
|
||||
if (a < 0.24) continue;
|
||||
const strongOnly = a < 0.42;
|
||||
if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
|
||||
else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
|
||||
}
|
||||
}
|
||||
for (const [i, nextSea] of flips) {
|
||||
|
|
@ -1086,6 +1330,45 @@ function sourcePathFromWorld(world, path) {
|
|||
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
|
||||
}
|
||||
|
||||
function offsetPointNumericFields(point, fields, offset) {
|
||||
for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset;
|
||||
}
|
||||
|
||||
function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) {
|
||||
const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]);
|
||||
if (rawAdminId >= 0) {
|
||||
const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId);
|
||||
if (Number.isFinite(mappedAdminId)) {
|
||||
point.sourceAdminId = rawAdminId;
|
||||
point.adminId = mappedAdminId;
|
||||
point.adminNumericId = mappedAdminId;
|
||||
point.municipalityId = mappedAdminId;
|
||||
} else {
|
||||
offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed));
|
||||
if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId;
|
||||
if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId;
|
||||
}
|
||||
}
|
||||
|
||||
const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]);
|
||||
if (rawPrefectureId >= 0) {
|
||||
const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId);
|
||||
if (Number.isFinite(mappedPrefectureId)) {
|
||||
point.sourcePrefectureRegionId = rawPrefectureId;
|
||||
point.prefectureRegionId = mappedPrefectureId;
|
||||
if (key === "prefectureRegions") point.id = mappedPrefectureId;
|
||||
} else {
|
||||
const offset = fieldIdOffset("prefectureRegionId", seed);
|
||||
if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset;
|
||||
if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset;
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId;
|
||||
if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId;
|
||||
return point;
|
||||
}
|
||||
|
||||
function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) {
|
||||
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
|
||||
const w = worldCoordForSource(window, p.x, p.y);
|
||||
|
|
@ -1099,39 +1382,12 @@ function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping
|
|||
w.x = land.x; w.y = land.y;
|
||||
}
|
||||
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
|
||||
normalizeGeneratedPointIds(out, key, seed, adminIdMapping);
|
||||
if (key === "adminCenters") {
|
||||
const rawAdminId = numericFeatureId(out, ["adminId", "municipalityId", "adminNumericId"]);
|
||||
const mappedAdminId = rawAdminId >= 0
|
||||
? adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId)
|
||||
: undefined;
|
||||
if (Number.isFinite(mappedAdminId)) {
|
||||
out.sourceAdminId = rawAdminId;
|
||||
out.adminId = mappedAdminId;
|
||||
out.adminNumericId = mappedAdminId;
|
||||
out.municipalityId = mappedAdminId;
|
||||
const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(rawAdminId);
|
||||
if (Number.isFinite(out.sourceAdminId)) {
|
||||
const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId);
|
||||
const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId);
|
||||
if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId;
|
||||
} else {
|
||||
const offset = fieldIdOffset("adminId", seed);
|
||||
if (Number.isFinite(out.adminId)) out.adminId += offset;
|
||||
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
|
||||
if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
|
||||
if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId;
|
||||
if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId;
|
||||
}
|
||||
}
|
||||
if (key === "prefectureRegions") {
|
||||
const rawPrefectureId = numericFeatureId(out, ["prefectureRegionId", "id"]);
|
||||
const mappedPrefectureId = rawPrefectureId >= 0 ? adminIdMapping?.prefecture?.get(rawPrefectureId) : undefined;
|
||||
if (Number.isFinite(mappedPrefectureId)) {
|
||||
out.sourcePrefectureRegionId = rawPrefectureId;
|
||||
out.id = mappedPrefectureId;
|
||||
out.prefectureRegionId = mappedPrefectureId;
|
||||
} else {
|
||||
const offset = fieldIdOffset("prefectureRegionId", seed);
|
||||
if (Number.isFinite(out.id)) out.id += offset;
|
||||
if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
|
||||
}
|
||||
}
|
||||
if (key === "logisticsParks") sanitizeLogisticsPark(out);
|
||||
|
|
@ -1181,12 +1437,12 @@ function transformCandidatePath(window, path) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function splitWorldPathByRect(path, rect, keepInside) {
|
||||
function splitWorldPathByPredicate(path, predicate, keepWhenTrue) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (const p of path || []) {
|
||||
const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect);
|
||||
if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
const matches = predicate(Math.round(p[0]), Math.round(p[1]));
|
||||
if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
else {
|
||||
if (current.length >= 2) chunks.push(current);
|
||||
current = [];
|
||||
|
|
@ -1196,13 +1452,21 @@ function splitWorldPathByRect(path, rect, keepInside) {
|
|||
return chunks;
|
||||
}
|
||||
|
||||
function pruneOldPathLayer(world, paths, rect, mode) {
|
||||
function splitWorldPathByRect(path, rect, keepInside) {
|
||||
return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside);
|
||||
}
|
||||
|
||||
function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) {
|
||||
return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected);
|
||||
}
|
||||
|
||||
function pruneOldPathLayer(world, paths, rects, seed, mode) {
|
||||
const kept = [];
|
||||
const anchors = [];
|
||||
let clipped = 0;
|
||||
for (const path of paths || []) {
|
||||
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
|
||||
const touches = worldPath.some(([x, y]) => insideRect(x, y, rect));
|
||||
const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34));
|
||||
if (!touches) {
|
||||
kept.push(path);
|
||||
continue;
|
||||
|
|
@ -1211,7 +1475,7 @@ function pruneOldPathLayer(world, paths, rect, mode) {
|
|||
let lastOutside = null;
|
||||
let wasInside = false;
|
||||
for (const [x, y] of worldPath) {
|
||||
const inside = insideRect(x, y, rect);
|
||||
const inside = patchAffected(x, y, rects, seed, 0.34);
|
||||
if (!inside) {
|
||||
if (wasInside) anchors.push({ x, y, mode });
|
||||
lastOutside = { x, y, mode };
|
||||
|
|
@ -1220,7 +1484,7 @@ function pruneOldPathLayer(world, paths, rect, mode) {
|
|||
}
|
||||
wasInside = inside;
|
||||
}
|
||||
for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
|
||||
for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk));
|
||||
}
|
||||
return { kept, anchors, clipped };
|
||||
}
|
||||
|
|
@ -1357,28 +1621,38 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe
|
|||
const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
|
||||
const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : [];
|
||||
const targets = preferredTargets.length ? preferredTargets : allTargets;
|
||||
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
|
||||
if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
||||
let connectors = 0;
|
||||
let disconnected = 0;
|
||||
let skippedConnectorAnchors = 0;
|
||||
let connectorAttempts = 0;
|
||||
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
||||
sourceMap[layer] ||= [];
|
||||
const seen = new Set();
|
||||
const maxRange = mode === "rail" ? 320 : 360;
|
||||
for (const raw of anchors) {
|
||||
const maxRange = mode === "rail" ? 220 : 260;
|
||||
const maxAnchors = mode === "rail" ? 10 : 18;
|
||||
const maxTargets = mode === "rail" ? 3 : 3;
|
||||
const searchRect = expandRect(rect, 16, world);
|
||||
const orderedAnchors = (anchors || [])
|
||||
.map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) }))
|
||||
.sort((a, b) => a.patchDistance - b.patchDistance)
|
||||
.slice(0, maxAnchors);
|
||||
skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length);
|
||||
for (const raw of orderedAnchors) {
|
||||
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18);
|
||||
if (!anchorLand) { disconnected++; continue; }
|
||||
const targetList = targets
|
||||
.map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
|
||||
.filter((p) => p.d <= maxRange && p.d >= 6)
|
||||
.sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))
|
||||
.slice(0, 5);
|
||||
.slice(0, maxTargets);
|
||||
if (!targetList.length) { disconnected++; continue; }
|
||||
let made = false;
|
||||
for (const target of targetList) {
|
||||
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
||||
if (seen.has(sig)) continue;
|
||||
const searchRect = expandRect(rect, 24, world);
|
||||
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 62000 : 76000);
|
||||
connectorAttempts++;
|
||||
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000);
|
||||
if (!path || path.length < 2) continue;
|
||||
seen.add(sig);
|
||||
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
|
||||
|
|
@ -1388,7 +1662,7 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe
|
|||
}
|
||||
if (!made) disconnected++;
|
||||
}
|
||||
return { connectors, disconnected };
|
||||
return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts };
|
||||
}
|
||||
|
||||
function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) {
|
||||
|
|
@ -1413,23 +1687,37 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) {
|
|||
const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
|
||||
sourceMap.minorRoads ||= [];
|
||||
let connectors = 0;
|
||||
let skippedServedSettlements = 0;
|
||||
let checked = 0;
|
||||
const seen = new Set();
|
||||
for (const key of featureKeys) {
|
||||
for (const p of sourceMap[key] || []) {
|
||||
const limit = key === "villages" ? 30 : 18;
|
||||
const items = (sourceMap[key] || [])
|
||||
.map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) }))
|
||||
.filter((row) => row.d <= (key === "villages" ? 80 : 150))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, limit);
|
||||
for (const { p } of items) {
|
||||
checked++;
|
||||
const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
|
||||
if (!start || !insideRect(start.x, start.y, rect)) continue;
|
||||
const si = worldIndex(world, start.x, start.y);
|
||||
if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) {
|
||||
skippedServedSettlements++;
|
||||
continue;
|
||||
}
|
||||
const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132);
|
||||
if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue;
|
||||
const sig = `${start.x},${start.y}:${target.x},${target.y}`;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
const path = localPathfind(world, start, target, rect, "road", 64000);
|
||||
const path = localPathfind(world, start, target, rect, "road", 36000);
|
||||
if (!path || path.length < 2) continue;
|
||||
sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
|
||||
connectors++;
|
||||
}
|
||||
}
|
||||
return connectors;
|
||||
return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked };
|
||||
}
|
||||
|
||||
function dedupeAdminCentersByWorldId(kept, generated) {
|
||||
|
|
@ -1478,9 +1766,11 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, admi
|
|||
const inWrite = insideRect(wx, wy, rects.writeRect);
|
||||
const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
|
||||
if (!inWrite || alpha < 0.34) {
|
||||
if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; }
|
||||
kept.push(p);
|
||||
if (!inWrite) preservedExternalEntities++;
|
||||
} else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) {
|
||||
invalidPortsRemoved++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const generated = [];
|
||||
|
|
@ -1510,13 +1800,13 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
|||
for (const key of PATH_LAYER_KEYS) {
|
||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||
const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
|
||||
const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode);
|
||||
const pruned = pruneOldPathLayer(world, oldArr, rects, seed, mode);
|
||||
if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
|
||||
else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
|
||||
const next = [...pruned.kept];
|
||||
for (const path of candidate[key] || []) {
|
||||
const worldPath = transformCandidatePath(window, path);
|
||||
const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true)
|
||||
const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40)
|
||||
.map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
|
||||
.filter((chunk) => chunk.length >= 2);
|
||||
for (const chunk of chunks) {
|
||||
|
|
@ -1540,21 +1830,19 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
|||
roadsClipped,
|
||||
railsClipped,
|
||||
regeneratedPaths,
|
||||
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors,
|
||||
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors,
|
||||
railwayConnectorsCreated: railConn.connectors,
|
||||
disconnectedRoadComponents: roadConn.disconnected,
|
||||
disconnectedRailComponents: railConn.disconnected,
|
||||
skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
|
||||
connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
|
||||
skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
|
||||
checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
|
||||
externalRoadAnchors: externalRoadAnchors.length,
|
||||
externalRailAnchors: externalRailAnchors.length,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentTouchesRect(world, seg, rect) {
|
||||
if (!Array.isArray(seg) || seg.length < 2) return false;
|
||||
return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
|
||||
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
|
||||
}
|
||||
|
||||
function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) {
|
||||
const field = world.fields[fieldName];
|
||||
const sea = world.fields.sea;
|
||||
|
|
@ -1603,7 +1891,7 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec
|
|||
function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
|
||||
for (const key of SEGMENT_LAYER_KEYS) {
|
||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
|
||||
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
|
||||
}
|
||||
sourceMap.adminBorders ||= [];
|
||||
sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 }));
|
||||
|
|
@ -1616,7 +1904,7 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
|
|||
// 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) => !segmentTouchesRect(world, seg, rects.writeRect));
|
||||
debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
|
||||
sourceMap.adminDebug = debug;
|
||||
return {
|
||||
adminBordersRebuilt: sourceMap.adminBorders.length,
|
||||
|
|
@ -1649,6 +1937,89 @@ function repairLanduseAndPopulation(world, rects) {
|
|||
return { landUseCellsUpdated: updated };
|
||||
}
|
||||
|
||||
function ensureWorldFloatField(world, name) {
|
||||
const expected = world.width * world.height;
|
||||
if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected);
|
||||
return world.fields[name];
|
||||
}
|
||||
|
||||
function clearFieldRect(world, field, rect) {
|
||||
for (let y = rect.y0; y < rect.y1; y++) {
|
||||
for (let x = rect.x0; x < rect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i >= 0) field[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) {
|
||||
const sea = world.fields.sea;
|
||||
const r = Math.max(1, Math.ceil(radius));
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
const x = Math.round(cx + dx);
|
||||
const y = Math.round(cy + dy);
|
||||
if (!insideRect(x, y, rect)) continue;
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0 || sea?.[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
||||
if (value > field[i]) field[i] = clamp(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPatchInfluenceFields(world, sourceMap, rects) {
|
||||
const rect = rects.repairRect || rects.writeRect;
|
||||
const roadInfluence = ensureWorldFloatField(world, "roadInfluence");
|
||||
const railInfluence2 = ensureWorldFloatField(world, "railInfluence2");
|
||||
const stationInfluence = ensureWorldFloatField(world, "stationInfluence");
|
||||
const villageInfluence = ensureWorldFloatField(world, "villageInfluence");
|
||||
for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect);
|
||||
|
||||
let roadCellsPainted = 0;
|
||||
let railCellsPainted = 0;
|
||||
let stationCellsPainted = 0;
|
||||
let villageCellsPainted = 0;
|
||||
const paintPathLayer = (keys, field, radius, strength, counterName) => {
|
||||
let painted = 0;
|
||||
for (const key of keys) {
|
||||
for (const path of sourceMap[key] || []) {
|
||||
for (const tuple of path || []) {
|
||||
const x = tupleWorldX(world, tuple);
|
||||
const y = tupleWorldY(world, tuple);
|
||||
if (rectDistance(x, y, rect) > radius + 1) continue;
|
||||
paintInfluenceDisk(world, field, x, y, radius, strength, rect);
|
||||
painted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (counterName === "road") roadCellsPainted += painted;
|
||||
if (counterName === "rail") railCellsPainted += painted;
|
||||
};
|
||||
|
||||
paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road");
|
||||
paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail");
|
||||
|
||||
for (const p of sourceMap.stations || []) {
|
||||
const x = pointWorldX(world, p);
|
||||
const y = pointWorldY(world, p);
|
||||
if (rectDistance(x, y, rect) > 8) continue;
|
||||
paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect);
|
||||
stationCellsPainted++;
|
||||
}
|
||||
for (const p of sourceMap.villages || []) {
|
||||
const x = pointWorldX(world, p);
|
||||
const y = pointWorldY(world, p);
|
||||
if (rectDistance(x, y, rect) > 10) continue;
|
||||
paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect);
|
||||
villageCellsPainted++;
|
||||
}
|
||||
|
||||
return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted };
|
||||
}
|
||||
|
||||
function countSea(world, rect) {
|
||||
let seaCount = 0;
|
||||
let total = 0;
|
||||
|
|
@ -1671,6 +2042,39 @@ function terrainId(candidate, fallback) {
|
|||
return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
|
||||
}
|
||||
|
||||
function rectKey(rect) {
|
||||
return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-";
|
||||
}
|
||||
|
||||
function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) {
|
||||
return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|");
|
||||
}
|
||||
|
||||
function getPatchCandidateCache(world) {
|
||||
if (!world.patchCandidateCache) world.patchCandidateCache = new Map();
|
||||
return world.patchCandidateCache;
|
||||
}
|
||||
|
||||
function rememberPatchCandidate(world, key, candidate) {
|
||||
const cache = getPatchCandidateCache(world);
|
||||
if (cache.has(key)) cache.delete(key);
|
||||
cache.set(key, candidate);
|
||||
while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
||||
}
|
||||
|
||||
function getOrGeneratePatchCandidate(world, key, create) {
|
||||
const cache = getPatchCandidateCache(world);
|
||||
if (cache.has(key)) {
|
||||
const candidate = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, candidate);
|
||||
return { candidate, cacheHit: true, cacheSize: cache.size };
|
||||
}
|
||||
const candidate = create();
|
||||
rememberPatchCandidate(world, key, candidate);
|
||||
return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size };
|
||||
}
|
||||
|
||||
export function generatePatch(world, userRectInput, options = {}) {
|
||||
const validation = validatePatchRect(userRectInput, world);
|
||||
if (!validation.ok) return { ok: false, ...validation };
|
||||
|
|
@ -1682,35 +2086,90 @@ export function generatePatch(world, userRectInput, options = {}) {
|
|||
const candidateWindow = sourceWindowForRects(rects);
|
||||
const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
|
||||
const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
|
||||
const candidate = generateMap(seed, {
|
||||
const patchTimer = createPatchTimer();
|
||||
const patchGenerationMode = "legacy-full-pipeline";
|
||||
const cacheKey = patchCandidateCacheKey({
|
||||
seed,
|
||||
terrainType,
|
||||
legacyTerrain: true,
|
||||
worldNative: true,
|
||||
variant,
|
||||
originX: candidateOriginX,
|
||||
originY: candidateOriginY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
candidateOriginX,
|
||||
candidateOriginY,
|
||||
contextRect: rects.contextRect,
|
||||
boundaryWorld: world,
|
||||
onProgress: () => {},
|
||||
serial: world.patchGenerationSerial || 0,
|
||||
});
|
||||
const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, {
|
||||
terrainType,
|
||||
legacyTerrain: true,
|
||||
worldNative: true,
|
||||
variant,
|
||||
originX: candidateOriginX,
|
||||
originY: candidateOriginY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
contextRect: rects.contextRect,
|
||||
boundaryWorld: world,
|
||||
onProgress: () => {},
|
||||
}));
|
||||
patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation");
|
||||
getPatchAlphaCache(rects, seed);
|
||||
getPatchSourceIndexCache(rects, candidateWindow);
|
||||
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
||||
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
||||
|
||||
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
|
||||
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
||||
patchTimer.mark("fields", "Field copy and alpha blend");
|
||||
const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
|
||||
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed);
|
||||
const maskDebug = repairDisplayMasks(world, rects, seed);
|
||||
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
||||
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
|
||||
const adminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
|
||||
patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
|
||||
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
|
||||
patchTimer.mark("points", "Point merge");
|
||||
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||
patchTimer.mark("paths", "Path merge and connector repair");
|
||||
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
|
||||
patchTimer.mark("influence", "Influence refresh");
|
||||
const landDebug = repairLanduseAndPopulation(world, rects);
|
||||
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
|
||||
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
|
||||
const municipalCoherence = reconcileMunicipalMetadata({
|
||||
adminId: world.fields.adminId,
|
||||
municipalityId: world.fields.municipalityId,
|
||||
prefectureRegionId: world.fields.prefectureRegionId,
|
||||
sea: world.fields.sea,
|
||||
adminCenters: sourceMap.adminCenters || [],
|
||||
municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
|
||||
fields: world.fields,
|
||||
width: world.width,
|
||||
height: world.height,
|
||||
pointOffsetX: world.originX || 0,
|
||||
pointOffsetY: world.originY || 0,
|
||||
seed,
|
||||
});
|
||||
sourceMap.adminCenters = municipalCoherence.adminCenters;
|
||||
sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
|
||||
const prefectureCoherence = refreshPrefectureRegionsMetadata({
|
||||
prefectureRegionId: world.fields.prefectureRegionId,
|
||||
sea: world.fields.sea,
|
||||
existing: sourceMap.prefectureRegions || [],
|
||||
fields: world.fields,
|
||||
width: world.width,
|
||||
height: world.height,
|
||||
pointOffsetX: world.originX || 0,
|
||||
pointOffsetY: world.originY || 0,
|
||||
});
|
||||
sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
|
||||
sourceMap.adminDebug = {
|
||||
...(sourceMap.adminDebug || {}),
|
||||
municipalCoherence: municipalCoherence.debug,
|
||||
prefectureMetadataCoherence: prefectureCoherence.debug,
|
||||
};
|
||||
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed);
|
||||
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||
const landDebug = repairLanduseAndPopulation(world, rects);
|
||||
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
|
||||
patchTimer.mark("segments", "Boundary and debug segment merge");
|
||||
sanitizeExistingLogistics(sourceMap);
|
||||
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
|
||||
const patchTimings = patchTimer.timings;
|
||||
|
||||
const seaStats = countSea(world, rects.coreRect);
|
||||
const label = terrainLabel(candidate, terrainType);
|
||||
|
|
@ -1722,19 +2181,27 @@ export function generatePatch(world, userRectInput, options = {}) {
|
|||
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||
...pointDebug,
|
||||
...pathDebug,
|
||||
...influenceDebug,
|
||||
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
||||
adminIdMapping: fieldDebug.adminIdMappingDebug,
|
||||
sourceAdminMetadataUpdated,
|
||||
...adminCoverageDebug,
|
||||
...finalAdminCoverageDebug,
|
||||
finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
|
||||
finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
|
||||
finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
|
||||
finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
|
||||
municipalCoherence: municipalCoherence.debug,
|
||||
prefectureMetadataCoherence: prefectureCoherence.debug,
|
||||
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
|
||||
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
|
||||
adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
|
||||
prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
|
||||
...terrainSeamDebug,
|
||||
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
||||
displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
|
||||
logisticsLabelsMigrated,
|
||||
candidateCacheHit: cacheHit,
|
||||
candidateCacheSize: cacheSize,
|
||||
...segmentDebug,
|
||||
candidateCompartmentSegmentsAdded,
|
||||
};
|
||||
|
|
@ -1757,7 +2224,8 @@ export function generatePatch(world, userRectInput, options = {}) {
|
|||
variant,
|
||||
candidateOriginX,
|
||||
candidateOriginY,
|
||||
patchGenerationMode: "legacy-full-pipeline",
|
||||
patchGenerationMode,
|
||||
patchTimings,
|
||||
updatedCells: fieldDebug.updatedCells,
|
||||
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
||||
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
|
||||
|
|
@ -1771,6 +2239,7 @@ export function generatePatch(world, userRectInput, options = {}) {
|
|||
world.generatedRects = [...(world.generatedRects || []), record];
|
||||
world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
|
||||
world.lastPatchResult = record;
|
||||
world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -1789,7 +2258,8 @@ export function generatePatch(world, userRectInput, options = {}) {
|
|||
variant,
|
||||
candidateOriginX,
|
||||
candidateOriginY,
|
||||
patchGenerationMode: "legacy-full-pipeline",
|
||||
patchGenerationMode,
|
||||
patchTimings,
|
||||
updatedCells: record.updatedCells,
|
||||
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
||||
coastCellsChanged: record.coastCellsChanged,
|
||||
|
|
|
|||
|
|
@ -1190,7 +1190,7 @@ export function drawMap(canvas, map, options) {
|
|||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
||||
if (mode === "admin") {
|
||||
const municipalLabels = (map.adminCenters || [])
|
||||
.filter((p) => p && p.name)
|
||||
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
|
||||
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||
finish();
|
||||
|
|
|
|||
30
test.js
30
test.js
|
|
@ -15,7 +15,7 @@ const result = document.getElementById("result");
|
|||
const logLines = [];
|
||||
let failed = 0;
|
||||
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
|
||||
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()),
|
||||
|
|
@ -24,6 +24,9 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende
|
|||
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()),
|
||||
]);
|
||||
|
||||
|
|
@ -675,6 +678,23 @@ try {
|
|||
const removedContextSuffixKey = "context" + "Suffixes";
|
||||
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
|
||||
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
|
||||
assert(mapPatchSource.includes("splitWorldPathByPatch") && mapPatchSource.includes("patchAffected"), "patch path merging is alpha-aware for lasso selections");
|
||||
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
|
||||
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
|
||||
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
|
||||
assert(mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes('patchGenerationMode = "legacy-full-pipeline"'), "patch generation remains full-pipeline simulation");
|
||||
assert(mapPatchSource.includes("patchTimings") && appSource.includes("result.patchTimings"), "patch generation returns and renders timing rows");
|
||||
assert(mapPatchSource.includes("PATCH_CANDIDATE_CACHE_LIMIT") && mapPatchSource.includes("patchCandidateCacheKey") && mapPatchSource.includes("cache.size > PATCH_CANDIDATE_CACHE_LIMIT"), "patch candidate cache is bounded and keyed");
|
||||
assert(mapPatchSource.includes("getPatchAlphaCache") && mapPatchSource.includes("getPatchSourceIndexCache"), "patch generation caches alpha and source-index grids for merge work");
|
||||
assert(mapPatchSource.includes("const searchRect = expandRect(rect, 16, world)") && mapPatchSource.includes("connectorAttempts"), "patch connector pathfinding uses bounded attempts and a shared search rect");
|
||||
assert(worldMapSource.includes("shiftSelectionShape") && worldMapSource.includes("selectionShape = shiftSelectionShape"), "world expansion shifts stored lasso patch polygons");
|
||||
assert(municipalSource.includes("reconcileMunicipalMetadata") && mapOutputSource.includes("reconcileMunicipalMetadata") && mapPatchSource.includes("reconcileMunicipalMetadata"), "municipal metadata is reconciled in output and patch repair");
|
||||
assert(appSource.includes("mappedPref === id") && !appSource.includes("return nearestNamedAdminCenter(map, cellIndex, maxDistance, null)"), "tooltip municipal fallback requires exact coherent ids");
|
||||
assert(mapPatchSource.includes("signedDist") && mapPatchSource.includes("patchBand"), "lasso patch alpha uses a feathered signed seam band");
|
||||
assert(mapPatchSource.includes("repairDiscreteSeamOwnership") && mapPatchSource.includes("chooseSeamOwnerValue"), "patch admin and prefecture seams use ownership repair");
|
||||
assert(mapPatchSource.includes("featherTerrainSeam") && mapPatchSource.includes("terrainFeatherCells"), "patch terrain transition bands are feather-smoothed");
|
||||
assert(mapPatchSource.includes("strongOnly") && mapPatchSource.includes("patchAlpha(x, y, rects, seed)"), "patch water topology avoids weak low-alpha seam flips");
|
||||
assert(!municipalSource.includes("Municipality ${id + 1}") && !municipalSource.includes("Prefecture ${id + 1}") && municipalSource.includes("自治${id + 1}") && municipalSource.includes("県域${id + 1}"), "fallback municipal and prefecture metadata avoids generic English labels");
|
||||
|
||||
assert(map.elevation.length === size, "elevation length matches map size");
|
||||
assert(map.sea.length === size, "sea length matches map size");
|
||||
|
|
@ -777,6 +797,14 @@ try {
|
|||
assert(map.externalGateways.length > 0, "external gateways exist");
|
||||
assert(map.minorRoads.length > 0, "minor roads exist");
|
||||
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
|
||||
const activeMunicipalityIds = new Set([...map.adminId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const centerIds = new Set(map.adminCenters.map((center) => center.adminId ?? center.municipalityId ?? center.adminNumericId).filter((id) => Number.isFinite(id)));
|
||||
assert(activeMunicipalityIds.size === map.adminCenters.length && [...activeMunicipalityIds].every((id) => centerIds.has(id)), "every active municipality has exactly one municipal center");
|
||||
assert(map.adminCenters.every((center) => activeMunicipalityIds.has(center.adminId ?? center.municipalityId ?? center.adminNumericId)), "municipal centers do not point to inactive municipalities");
|
||||
assert([...activeMunicipalityIds].every((id) => map.municipalityToPrefectureId?.[id] >= 0), "every active municipality maps to a prefecture");
|
||||
const activePrefectureIds = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const prefMetadataIds = new Set((map.prefectureRegions || []).map((region) => region.id));
|
||||
assert([...activePrefectureIds].every((id) => prefMetadataIds.has(id)), "every active prefecture id has metadata");
|
||||
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
|
||||
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
|
||||
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
||||
|
|
|
|||
36
worldMap.js
36
worldMap.js
|
|
@ -125,24 +125,34 @@ function expandRectByOffset(rect, dx, dy) {
|
|||
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
|
||||
}
|
||||
|
||||
function shiftSelectionShape(shape, dx, dy) {
|
||||
if (!shape?.polygon) return shape;
|
||||
return {
|
||||
...shape,
|
||||
x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0,
|
||||
y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0,
|
||||
x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1,
|
||||
y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1,
|
||||
polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
|
||||
};
|
||||
}
|
||||
|
||||
function shiftPatchMetadata(item, dx, dy) {
|
||||
const out = expandRectByOffset(item, dx, dy);
|
||||
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
|
||||
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
|
||||
}
|
||||
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shiftRectCollections(world, dx, dy) {
|
||||
if (!dx && !dy) return;
|
||||
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
|
||||
if (!Array.isArray(world[key])) continue;
|
||||
world[key] = world[key].map((item) => {
|
||||
const out = expandRectByOffset(item, dx, dy);
|
||||
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
|
||||
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
if (world.lastPatchResult) {
|
||||
world.lastPatchResult = { ...world.lastPatchResult };
|
||||
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
|
||||
if (world.lastPatchResult[sub]) world.lastPatchResult[sub] = expandRectByOffset(world.lastPatchResult[sub], dx, dy);
|
||||
}
|
||||
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
|
||||
}
|
||||
if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy);
|
||||
}
|
||||
|
||||
export function expandWorldMap(world, margins = {}) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue