feat: enhance map renderer with catalog synchronization and reload functionality

- Added a "Reload Current Map" button to the left panel for refreshing the selected map after catalog edits.
- Updated catalog CSV handling to support non-authoritative `categorization` and `qualities` columns.
- Implemented automatic addition of newly observed shapes to the game catalog CSV during cache builds.
- Modified catalog entry handling to ensure proper boolean overrides for `roof` and `semitransparency`.
- Introduced `ensureShapeCatalogCoverage` function to maintain catalog integrity based on observed shapes.
- Enhanced the serialization of catalog entries to include new fields and proper formatting.
- Updated UI to reflect changes in reload state and button functionality.
This commit is contained in:
Marco 2026-03-27 17:27:51 +01:00
commit 90954fbf37
7 changed files with 2686 additions and 409 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,10 @@ Viewer behavior:
- inspect mode lets you pin a shape tooltip, hide a single instance, and restore hidden instances from the left panel
- PNG export is generated in the browser from the cached scene instead of being rasterized server-side
- hidden instances can be exported as JSON and each catalog CSV can be downloaded from the viewer
- catalog CSV rows support `roof` and `semitransparency` boolean overrides; leave them blank to use decoded defaults, or set `true`/`false` per shape
- the left panel includes a `Reload Current Map` button that forces a fresh rebuild/load of the currently selected map after catalog edits
- catalog CSV rows support `roof` and `semitransparency` boolean overrides; the catalog is authoritative for those properties, so blank means `false` and only explicit `true` turns them on
- cache builds automatically add any newly observed shapes into the matching game catalog CSV without overwriting existing rows, then rewrite the file sorted by `shape_code`
- catalog CSV rows also support non-authoritative `categorization` and `qualities` columns; cache builds auto-fill them when blank from the existing derived categorization and observed per-shape quality values
## Cache Warming

View file

@ -4,7 +4,7 @@ import path from "node:path";
import { SCENE_CACHE_ROOT, TILE_SIZE } from "../config.js";
import { packSprites } from "./atlas-packer.js";
import { getShapeCatalog } from "./catalog.js";
import { ensureShapeCatalogCoverage, getShapeCatalog } from "./catalog.js";
import {
EGG_FAMILIES,
FLAG_FLIPPED,
@ -21,7 +21,7 @@ import {
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
import { prepareSortedItems } from "./sorting.js";
const SCENE_CACHE_VERSION = "v3-atlas-scene";
const SCENE_CACHE_VERSION = "v3-atlas-scene-catalog-sync";
function nowIso() {
return new Date().toISOString();
@ -141,8 +141,8 @@ function applyCatalogOverrides(info, catalogEntry) {
}
return {
...info,
isRoof: catalogEntry.roof ?? info.isRoof,
isTranslucent: catalogEntry.semitransparency ?? info.isTranslucent
isRoof: catalogEntry.roof === true,
isTranslucent: catalogEntry.semitransparency === true
};
}
@ -353,6 +353,31 @@ function serializeSprite(sprite, placement) {
};
}
function collectObservedShapes(renderItems, shapeInfos) {
const observed = new Map();
for (const item of renderItems) {
const info = shapeInfos[item.shape] ?? {};
if (!observed.has(item.shape)) {
const kind = classifySceneKind({ flags: 0 }, info);
observed.set(item.shape, {
shapeCode: item.shape,
isEditor: Boolean(info.isEditor),
categorization: kind,
qualitySet: new Set()
});
}
observed.get(item.shape).qualitySet.add(item.quality);
}
return [...observed.values()]
.map((entry) => ({
shapeCode: entry.shapeCode,
isEditor: entry.isEditor,
categorization: entry.categorization,
qualities: [...entry.qualitySet].sort((left, right) => left - right).join(";")
}))
.sort((left, right) => left.shapeCode - right.shapeCode);
}
export class BuildManager {
constructor(catalog) {
this.catalog = catalog;
@ -390,8 +415,23 @@ export class BuildManager {
).slice(0, 16);
}
ensureCatalogCoverage(gameConfig, mapId) {
const assets = this.getAssets(gameConfig);
const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT");
const baseItems = loadMapItems(fixedDatPath, mapId);
const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, {
includeEditor: true,
expandGlobs: true,
worldRect: null,
includeRoofs: true,
includeHiddenMarkers: true
});
return ensureShapeCatalogCoverage(gameConfig.id, collectObservedShapes(renderItems, assets.shapeInfos));
}
async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) {
const options = normalizeBuildOptions(rawOptions);
this.ensureCatalogCoverage(gameConfig, mapId);
const catalogInfo = getShapeCatalog(gameConfig.id);
const fingerprint = this.computeBuildFingerprint(gameConfig, mapId, options, catalogInfo);
const key = `${gameConfig.id}:${mapId}:${fingerprint}`;

View file

@ -11,6 +11,7 @@ const CATALOG_FILE_BY_GAME = {
};
const shapeCatalogCache = new Map();
const CATALOG_HEADERS = ["shape_code", "human_readable_id", "description", "roof", "semitransparency", "categorization", "qualities"];
function sha1(value) {
return crypto.createHash("sha1").update(value).digest("hex");
@ -77,7 +78,9 @@ function normalizeCatalogEntry(row) {
humanReadableId: String(getRowValue(row, "human_readable_id", "humanReadableId", "HumanReadableId")).trim(),
description: String(getRowValue(row, "description", "Description")).trim(),
roof: parseOptionalBoolean(getRowValue(row, "roof", "Roof")),
semitransparency: parseOptionalBoolean(getRowValue(row, "semitransparency", "semi_transparency", "Semitransparency", "SemiTransparency"))
semitransparency: parseOptionalBoolean(getRowValue(row, "semitransparency", "semi_transparency", "Semitransparency", "SemiTransparency")),
categorization: String(getRowValue(row, "categorization", "category", "Categorization", "Category")).trim(),
qualities: String(getRowValue(row, "qualities", "quality_values", "Qualities", "QualityValues")).trim()
};
}
@ -106,6 +109,45 @@ function parseCatalogCsv(text) {
return entries;
}
function formatOptionalBoolean(value) {
if (value === true) {
return "true";
}
if (value === false) {
return "false";
}
return "";
}
function escapeCsvValue(value) {
const text = String(value ?? "");
if (!/[",\r\n]/u.test(text)) {
return text;
}
return `"${text.replaceAll('"', '""')}"`;
}
function serializeCatalog(entries) {
const lines = [CATALOG_HEADERS.join(",")];
const sortedEntries = [...entries.values()].sort((left, right) => left.shapeCode - right.shapeCode);
for (const entry of sortedEntries) {
lines.push(
[
entry.shapeCodeHex,
entry.humanReadableId,
entry.description,
formatOptionalBoolean(entry.roof),
formatOptionalBoolean(entry.semitransparency),
entry.categorization,
entry.qualities
]
.map(escapeCsvValue)
.join(",")
);
}
return `${lines.join("\n")}\n`;
}
function getCatalogPath(gameId) {
const fileName = CATALOG_FILE_BY_GAME[gameId];
if (!fileName) {
@ -145,6 +187,72 @@ export function getShapeCatalog(gameId) {
return value;
}
export function ensureShapeCatalogCoverage(gameId, observedShapes) {
const filePath = getCatalogPath(gameId);
if (!filePath) {
return {
changed: false,
added: 0,
filePath: null
};
}
const existing = getShapeCatalog(gameId);
const entries = new Map(existing.entries);
let added = 0;
let updated = 0;
for (const observed of observedShapes) {
if (entries.has(observed.shapeCode)) {
const entry = entries.get(observed.shapeCode);
let changed = false;
if (!entry.categorization && observed.categorization) {
entry.categorization = observed.categorization;
changed = true;
}
if (!entry.qualities && observed.qualities) {
entry.qualities = observed.qualities;
changed = true;
}
if (changed) {
updated += 1;
}
continue;
}
entries.set(observed.shapeCode, {
shapeCode: observed.shapeCode,
shapeCodeHex: `0x${observed.shapeCode.toString(16).padStart(4, "0")}`,
humanReadableId: "",
description: observed.isEditor ? "Editor Object" : "",
roof: null,
semitransparency: null,
categorization: observed.categorization,
qualities: observed.qualities
});
added += 1;
}
if (!added && !updated) {
return {
changed: false,
added: 0,
updated: 0,
filePath
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const serialized = serializeCatalog(entries);
fs.writeFileSync(filePath, serialized, "utf8");
shapeCatalogCache.delete(gameId);
return {
changed: true,
added,
updated,
filePath
};
}
export function detectCatalog() {
const games = [];
for (const game of GAMES) {

View file

@ -20,6 +20,7 @@ const inspectHighlight = document.querySelector("#inspect-highlight");
const overlayTooltip = document.querySelector("#overlay-tooltip");
const emptyState = document.querySelector("#empty-state");
const zoomLabel = document.querySelector("#zoom-label");
const reloadMapButton = document.querySelector("#reload-map-button");
const zoomInButton = document.querySelector("#zoom-in");
const zoomOutButton = document.querySelector("#zoom-out");
const zoomResetButton = document.querySelector("#zoom-reset");
@ -178,6 +179,12 @@ function setHiddenExportState(enabled) {
hiddenExportButton.disabled = !enabled;
}
function setReloadState(enabled) {
reloadMapButton.classList.toggle("is-disabled", !enabled);
reloadMapButton.setAttribute("aria-disabled", String(!enabled));
reloadMapButton.disabled = !enabled;
}
function updateZoomLabel() {
zoomLabel.textContent = `Zoom: ${Math.round(state.zoom * 100)}%`;
}
@ -305,6 +312,7 @@ function populateCatalog(catalog) {
mapSelect.disabled = catalog.games.length === 0;
setDownloadState(false);
setReloadState(false);
renderCatalogExportButtons(catalog.games);
if (catalog.games.length === 0) {
setStatus("No usable STATIC folders were detected under the app root.");
@ -814,6 +822,7 @@ async function startBuild(selected) {
setMeta(null);
setDownloadState(false);
setHiddenExportState(false);
setReloadState(false);
}
setLoadingState(true, { phase: "queued" });
@ -885,6 +894,7 @@ async function pollBuild(jobId, selected, token, preserveView) {
updateHiddenList();
setDownloadState(scene.items.length > 0);
setHiddenExportState(false);
setReloadState(true);
setEmptyStateVisible(false);
enableZoomControls(true);
@ -1026,6 +1036,22 @@ hiddenExportButton.addEventListener("click", async () => {
}
});
reloadMapButton.addEventListener("click", async () => {
if (reloadMapButton.classList.contains("is-disabled")) {
return;
}
const selected = state.current?.selected ?? getSelectedMap();
if (!selected) {
setStatus("Choose a map first.");
return;
}
try {
await startBuild(selected);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
}
});
overlayTooltip.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
@ -1160,6 +1186,7 @@ updateZoomLabel();
setMeta(null);
setDownloadState(false);
setHiddenExportState(false);
setReloadState(false);
setLoadingState(false);
setEmptyStateVisible(true);
hideOverlayTooltip();

View file

@ -33,6 +33,7 @@
<button id="zoom-fit" type="button" disabled>Fit</button>
</div>
<div id="zoom-label" class="muted">Zoom: --</div>
<button id="reload-map-button" class="action-link is-disabled" type="button" aria-disabled="true">Reload Current Map</button>
<button id="download-button" class="action-link is-disabled" type="button" aria-disabled="true">Download PNG</button>
<button id="hidden-export-button" class="action-link is-disabled" type="button" aria-disabled="true">Export Hidden JSON</button>
</div>