diff --git a/map_renderer/README.md b/map_renderer/README.md
index d87773c..4d81676 100644
--- a/map_renderer/README.md
+++ b/map_renderer/README.md
@@ -26,6 +26,7 @@ Viewer behavior:
- use the scroll wheel to zoom directly at the pointer
- pinch to zoom on touch devices
- toggle roofs and editor-only elements independently before building
+- when editor-only elements are enabled, the base map stays server-rasterized while editor records render as interactive overlays with hover metadata
The app expects asset folders under the app root:
@@ -62,6 +63,7 @@ docker compose up --build
- `POST /api/builds` starts or reuses a build.
- `GET /api/builds/:id` returns build status.
- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings.
+- `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content.
- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles.
No raw Crusader asset files are exposed over HTTP.
diff --git a/map_renderer/src/lib/build-manager.js b/map_renderer/src/lib/build-manager.js
index de7794b..207e6f7 100644
--- a/map_renderer/src/lib/build-manager.js
+++ b/map_renderer/src/lib/build-manager.js
@@ -5,6 +5,8 @@ import sharp from "sharp";
import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js";
import {
+ EGG_FAMILIES,
+ FLAG_INVISIBLE,
FLAG_FLIPPED,
ShapeArchive,
collectRenderItems,
@@ -16,7 +18,7 @@ import {
summarizeRenderClasses
} from "./formats.js";
import { blitFrame, encodePng, rgbaBuffer } from "./png.js";
-import { prepareSortedItems } from "./sorting.js";
+import { prepareSortedItems, projectItemGeometry } from "./sorting.js";
function nowIso() {
return new Date().toISOString();
@@ -40,6 +42,231 @@ function buildOptionSuffix(options) {
return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`;
}
+function toHex(value, width = 4) {
+ return `0x${value.toString(16).padStart(width, "0")}`;
+}
+
+function createEmptyProjection() {
+ return {
+ projected: [],
+ invalidItemCount: 0,
+ invalidItems: []
+ };
+}
+
+function computeBoundsFromNodes(nodes) {
+ if (!nodes.length) {
+ return null;
+ }
+
+ let minLeft = Number.MAX_SAFE_INTEGER;
+ let minTop = Number.MAX_SAFE_INTEGER;
+ let maxRight = -Number.MAX_SAFE_INTEGER;
+ let maxBottom = -Number.MAX_SAFE_INTEGER;
+
+ for (const node of nodes) {
+ minLeft = Math.min(minLeft, node.left);
+ minTop = Math.min(minTop, node.top);
+ maxRight = Math.max(maxRight, node.right);
+ maxBottom = Math.max(maxBottom, node.bottom);
+ }
+
+ return {
+ minLeft,
+ minTop,
+ maxRight,
+ maxBottom,
+ width: maxRight - minLeft,
+ height: maxBottom - minTop
+ };
+}
+
+function mergeBounds(boundsList) {
+ const validBounds = boundsList.filter(Boolean);
+ if (!validBounds.length) {
+ return null;
+ }
+
+ const minLeft = Math.min(...validBounds.map((bounds) => bounds.minLeft));
+ const minTop = Math.min(...validBounds.map((bounds) => bounds.minTop));
+ const maxRight = Math.max(...validBounds.map((bounds) => bounds.maxRight));
+ const maxBottom = Math.max(...validBounds.map((bounds) => bounds.maxBottom));
+
+ return {
+ minLeft,
+ minTop,
+ maxRight,
+ maxBottom,
+ width: maxRight - minLeft,
+ height: maxBottom - minTop
+ };
+}
+
+function isOverlayItem(item, shapeInfos) {
+ return item.shape < shapeInfos.length && shapeInfos[item.shape].isEditor;
+}
+
+function splitRenderItems(renderItems, shapeInfos, includeEditor) {
+ if (!includeEditor) {
+ return {
+ baseItems: renderItems,
+ overlayItems: []
+ };
+ }
+
+ const baseItems = [];
+ const overlayItems = [];
+ for (const item of renderItems) {
+ if (isOverlayItem(item, shapeInfos)) {
+ overlayItems.push(item);
+ } else {
+ baseItems.push(item);
+ }
+ }
+
+ return {
+ baseItems,
+ overlayItems
+ };
+}
+
+function classifyOverlayKind(item, info) {
+ if ((item.flags & FLAG_INVISIBLE) || info.isOccl || info.isInvitem) {
+ return "helper";
+ }
+ if (EGG_FAMILIES.has(info.family)) {
+ return "egg";
+ }
+ if (info.isRoof) {
+ return "roof";
+ }
+ return "editor";
+}
+
+function overlayLabel(kind) {
+ if (kind === "helper") {
+ return "Helper Geometry";
+ }
+ if (kind === "egg") {
+ return "Egg Trigger";
+ }
+ if (kind === "roof") {
+ return "Roof Marker";
+ }
+ return "Editor Object";
+}
+
+function overlayNotes(item, info) {
+ const notes = [];
+ if (item.flags & FLAG_INVISIBLE) {
+ notes.push("invisible-flagged");
+ }
+ if (info.isOccl) {
+ notes.push("occluding-geometry");
+ }
+ if (info.isInvitem) {
+ notes.push("invitem-family");
+ }
+ if (EGG_FAMILIES.has(info.family)) {
+ notes.push("egg-family");
+ }
+ if (info.isRoof) {
+ notes.push("roof-flagged");
+ }
+ if (info.isTranslucent) {
+ notes.push("translucent");
+ }
+ return notes;
+}
+
+function serializeOverlayItem(node, minLeft, minTop, index) {
+ const { item, info, frame } = node;
+ const kind = classifyOverlayKind(item, info);
+ const sceneLeft = node.left - minLeft;
+ const sceneTop = node.top - minTop;
+ const screenWidth = node.right - node.left;
+ const screenHeight = node.bottom - node.top;
+
+ return {
+ id: `${index}:${item.source}:${item.shape}:${item.frame}:${item.x}:${item.y}:${item.z}`,
+ kind,
+ label: overlayLabel(kind),
+ family: info.family,
+ source: item.source,
+ world: {
+ x: item.x,
+ y: item.y,
+ z: item.z
+ },
+ mapNum: item.mapNum,
+ npcNum: item.npcNum,
+ nextItem: item.nextItem,
+ quality: item.quality,
+ shape: item.shape,
+ frame: item.frame,
+ frameSize: {
+ width: frame.width,
+ height: frame.height,
+ xoff: frame.xoff,
+ yoff: frame.yoff
+ },
+ screen: {
+ left: sceneLeft,
+ top: sceneTop,
+ right: node.right - minLeft,
+ bottom: node.bottom - minTop,
+ width: screenWidth,
+ height: screenHeight,
+ anchorX: Math.trunc(sceneLeft + screenWidth / 2),
+ anchorY: node.bottom - minTop
+ },
+ flags: {
+ raw: item.flags,
+ hex: toHex(item.flags),
+ invisible: Boolean(item.flags & FLAG_INVISIBLE),
+ flipped: Boolean(item.flags & FLAG_FLIPPED)
+ },
+ traits: {
+ editor: info.isEditor,
+ roof: info.isRoof,
+ occluding: info.isOccl,
+ translucent: info.isTranslucent,
+ solid: info.isSolid,
+ fixed: info.isFixed,
+ land: info.isLand,
+ draw: info.isDraw,
+ invitem: info.isInvitem,
+ animType: info.animType
+ },
+ notes: overlayNotes(item, info)
+ };
+}
+
+function summarizeOverlayItems(items) {
+ const kindCounts = {};
+ const familyCounts = {};
+ const sourceCounts = {};
+
+ for (const item of items) {
+ kindCounts[item.kind] = (kindCounts[item.kind] ?? 0) + 1;
+ familyCounts[item.family] = (familyCounts[item.family] ?? 0) + 1;
+ sourceCounts[item.source] = (sourceCounts[item.source] ?? 0) + 1;
+ }
+
+ const topFamilies = Object.entries(familyCounts)
+ .sort((left, right) => right[1] - left[1] || Number(left[0]) - Number(right[0]))
+ .slice(0, 6)
+ .map(([family, count]) => ({ family: Number(family), count }));
+
+ return {
+ itemCount: items.length,
+ kindCounts,
+ sourceCounts,
+ topFamilies,
+ helperCount: kindCounts.helper ?? 0
+ };
+}
+
function makeUsageInfo(gameId, mapId, baseItems, renderItems) {
const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right);
return {
@@ -63,10 +290,13 @@ function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) {
map: mapId,
rawItemCount: baseItems.length,
itemCount: 0,
+ baseRasterItemCount: 0,
+ overlayItemCount: 0,
paintedItemCount: 0,
occludedItemCount: 0,
invalidItemCount: 0,
invalidItems: [],
+ overlaySummary: summarizeOverlayItems([]),
usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []),
baseItemSummary: {
roofItems: 0,
@@ -179,6 +409,7 @@ export class BuildManager {
job.build = {
assets,
prepared: [],
+ overlays: [],
minLeft: 0,
minTop: 0,
width: TILE_SIZE,
@@ -195,16 +426,42 @@ export class BuildManager {
return;
}
+ const splitItems = splitRenderItems(renderItems, assets.shapeInfos, job.options.includeEditor);
+ this.touchJob(
+ job,
+ `Split ${splitItems.baseItems.length} base items and ${splitItems.overlayItems.length} overlay items`
+ );
+
job.phase = "sorting";
- const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, {
- checkpointEvery: 2000,
- maxInvalidDetails: 20,
- progress: (message) => this.touchJob(job, message)
- });
- if (!sorted.prepared.length) {
+ const sorted = splitItems.baseItems.length
+ ? prepareSortedItems(splitItems.baseItems, assets.shapeArchive, assets.shapeInfos, {
+ checkpointEvery: 2000,
+ maxInvalidDetails: 20,
+ progress: (message) => this.touchJob(job, message)
+ })
+ : {
+ minLeft: 0,
+ minTop: 0,
+ maxRight: TILE_SIZE,
+ maxBottom: TILE_SIZE,
+ prepared: [],
+ occludedCount: 0,
+ invalidItemCount: 0,
+ invalidItems: []
+ };
+ const overlayProjection = splitItems.overlayItems.length
+ ? projectItemGeometry(splitItems.overlayItems, assets.shapeArchive, assets.shapeInfos, {
+ checkpointEvery: 2000,
+ maxInvalidDetails: 20,
+ progress: (message) => this.touchJob(job, message)
+ })
+ : createEmptyProjection();
+
+ if (!sorted.prepared.length && !overlayProjection.projected.length) {
job.build = {
assets,
prepared: [],
+ overlays: [],
minLeft: 0,
minTop: 0,
width: TILE_SIZE,
@@ -226,22 +483,42 @@ export class BuildManager {
return;
}
- const width = sorted.maxRight - sorted.minLeft;
- const height = sorted.maxBottom - sorted.minTop;
- if (width <= 0 || height <= 0) {
+ const bounds = mergeBounds([
+ sorted.prepared.length
+ ? {
+ minLeft: sorted.minLeft,
+ minTop: sorted.minTop,
+ maxRight: sorted.maxRight,
+ maxBottom: sorted.maxBottom,
+ width: sorted.maxRight - sorted.minLeft,
+ height: sorted.maxBottom - sorted.minTop
+ }
+ : null,
+ computeBoundsFromNodes(overlayProjection.projected)
+ ]);
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
throw new Error("Computed image bounds are invalid");
}
+ const overlays = overlayProjection.projected.map((node, index) =>
+ serializeOverlayItem(node, bounds.minLeft, bounds.minTop, index)
+ );
+ const invalidItemCount = sorted.invalidItemCount + overlayProjection.invalidItemCount;
+ const invalidItems = [...sorted.invalidItems, ...overlayProjection.invalidItems].slice(0, 20);
+
const metadata = {
game: gameConfig.id,
gameLabel: gameConfig.label,
map: job.mapId,
rawItemCount: baseItems.length,
itemCount: renderItems.length,
+ baseRasterItemCount: splitItems.baseItems.length,
+ overlayItemCount: overlays.length,
paintedItemCount: sorted.prepared.length,
occludedItemCount: sorted.occludedCount,
- invalidItemCount: sorted.invalidItemCount,
- invalidItems: sorted.invalidItems,
+ invalidItemCount,
+ invalidItems,
+ overlaySummary: summarizeOverlayItems(overlays),
usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems),
baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos),
sorter: "scummvm_dependency_graph",
@@ -252,16 +529,16 @@ export class BuildManager {
includeRoofs: job.options.includeRoofs
},
bounds: {
- screenLeft: sorted.minLeft,
- screenTop: sorted.minTop,
- screenRight: sorted.maxRight,
- screenBottom: sorted.maxBottom,
- width,
- height
+ screenLeft: bounds.minLeft,
+ screenTop: bounds.minTop,
+ screenRight: bounds.maxRight,
+ screenBottom: bounds.maxBottom,
+ width: bounds.width,
+ height: bounds.height
},
tileSize: TILE_SIZE,
- tileCountX: Math.ceil(width / TILE_SIZE),
- tileCountY: Math.ceil(height / TILE_SIZE),
+ tileCountX: Math.ceil(bounds.width / TILE_SIZE),
+ tileCountY: Math.ceil(bounds.height / TILE_SIZE),
zoom: {
min: 0.01,
max: 8,
@@ -273,10 +550,11 @@ export class BuildManager {
job.build = {
assets,
prepared: sorted.prepared,
- minLeft: sorted.minLeft,
- minTop: sorted.minTop,
- width,
- height
+ overlays,
+ minLeft: bounds.minLeft,
+ minTop: bounds.minTop,
+ width: bounds.width,
+ height: bounds.height
};
job.metadata = metadata;
job.status = "ready";
@@ -337,6 +615,14 @@ export class BuildManager {
return job.metadata;
}
+ getOverlayData(jobId, gameId, mapId) {
+ const job = this.requireReadyJob(jobId, gameId, mapId);
+ return {
+ items: job.build.overlays,
+ summary: job.metadata.overlaySummary
+ };
+ }
+
async renderFullMap(jobId, gameId, mapId) {
const job = this.requireReadyJob(jobId, gameId, mapId);
const outputPath = path.join(
diff --git a/map_renderer/src/lib/sorting.js b/map_renderer/src/lib/sorting.js
index f8d5f17..689a0a6 100644
--- a/map_renderer/src/lib/sorting.js
+++ b/map_renderer/src/lib/sorting.js
@@ -294,6 +294,61 @@ function resolvePaintOrder(ordered, progress, checkpointEvery = 0) {
return painted;
}
+export function projectItemGeometry(items, archive, shapeInfos, options = {}) {
+ const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
+ const projected = [];
+ let invalidItemCount = 0;
+ const invalidItems = [];
+
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
+ const item = items[itemIndex];
+ try {
+ const frame = archive.getFrame(item.shape, item.frame);
+ projected.push(buildSortNode(item, shapeInfos[item.shape], frame, null));
+ } catch (error) {
+ invalidItemCount += 1;
+ if (invalidItems.length < maxInvalidDetails) {
+ invalidItems.push({
+ shape: item.shape,
+ frame: item.frame,
+ x: item.x,
+ y: item.y,
+ z: item.z,
+ source: item.source,
+ reason: error instanceof Error ? error.message : String(error)
+ });
+ }
+ }
+
+ if (progress && checkpointEvery > 0 && (itemIndex + 1) % checkpointEvery === 0) {
+ progress(`project processed=${itemIndex + 1} valid=${projected.length} invalid=${invalidItemCount}`);
+ }
+ }
+
+ projected.sort((left, right) => {
+ if (left.sy_bot !== right.sy_bot) {
+ return left.sy_bot - right.sy_bot;
+ }
+ if (left.sx_bot !== right.sx_bot) {
+ return left.sx_bot - right.sx_bot;
+ }
+ if (left.item.shape !== right.item.shape) {
+ return left.item.shape - right.item.shape;
+ }
+ return left.item.frame - right.item.frame;
+ });
+
+ if (progress) {
+ progress(`project complete processed=${items.length} valid=${projected.length} invalid=${invalidItemCount}`);
+ }
+
+ return {
+ projected,
+ invalidItemCount,
+ invalidItems
+ };
+}
+
export function prepareSortedItems(items, archive, shapeInfos, options = {}) {
const { progress, checkpointEvery = 0, maxInvalidDetails = 20 } = options;
const ordered = [];
diff --git a/map_renderer/src/public/app.css b/map_renderer/src/public/app.css
index 46514d3..47a5680 100644
--- a/map_renderer/src/public/app.css
+++ b/map_renderer/src/public/app.css
@@ -8,6 +8,11 @@
--muted: #6e5a37;
--accent: #0d6c7d;
--accent-strong: #114f59;
+ --overlay-editor: #18849a;
+ --overlay-egg: #c67129;
+ --overlay-roof: #627894;
+ --overlay-helper-fill: rgba(77, 169, 196, 0.16);
+ --overlay-helper-stroke: rgba(108, 201, 228, 0.72);
--viewport: #0e1218;
--tile-border: rgba(255, 255, 255, 0.04);
--shadow: 0 18px 45px rgba(59, 40, 8, 0.16);
@@ -268,6 +273,141 @@ select:disabled,
pointer-events: none;
}
+.overlay-root {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.overlay-item {
+ position: absolute;
+ pointer-events: auto;
+ border: 0;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ background: none;
+ cursor: pointer;
+}
+
+.overlay-marker {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+ color: white;
+ font-size: 0.68rem;
+ font-weight: 800;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
+ transform: translate(-50%, -100%);
+ transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
+}
+
+.overlay-marker:hover,
+.overlay-marker:focus-visible {
+ transform: translate(-50%, -100%) scale(1.08);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
+ filter: saturate(1.12);
+}
+
+.overlay-marker--editor {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-editor) 86%, white 14%) 0%, var(--overlay-editor) 100%);
+}
+
+.overlay-marker--egg {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-egg) 82%, white 18%) 0%, var(--overlay-egg) 100%);
+}
+
+.overlay-marker--roof {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--overlay-roof) 84%, white 16%) 0%, var(--overlay-roof) 100%);
+}
+
+.overlay-helper {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ min-width: 14px;
+ min-height: 14px;
+ padding: 4px;
+ border-radius: 8px;
+ border: 1px solid var(--overlay-helper-stroke);
+ background: var(--overlay-helper-fill);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
+ transition: transform 140ms ease, background 140ms ease;
+}
+
+.overlay-helper:hover,
+.overlay-helper:focus-visible {
+ transform: scale(1.02);
+ background: color-mix(in srgb, var(--overlay-helper-fill) 70%, white 30%);
+}
+
+.overlay-helper-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: rgba(7, 12, 18, 0.68);
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 0.68rem;
+ font-weight: 800;
+}
+
+.overlay-tooltip {
+ position: absolute;
+ z-index: 6;
+ max-width: 290px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: rgba(8, 12, 18, 0.9);
+ color: rgba(255, 255, 255, 0.9);
+ border: 1px solid rgba(124, 182, 214, 0.28);
+ box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34);
+ pointer-events: none;
+ backdrop-filter: blur(14px);
+}
+
+.tooltip-eyebrow {
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: rgba(138, 202, 221, 0.88);
+}
+
+.tooltip-title {
+ margin-top: 4px;
+ font-size: 0.98rem;
+ font-weight: 700;
+}
+
+.tooltip-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 6px 10px;
+ margin: 10px 0 0;
+ font-size: 0.84rem;
+}
+
+.tooltip-grid dt {
+ color: rgba(176, 197, 212, 0.76);
+}
+
+.tooltip-grid dd {
+ margin: 0;
+ text-align: right;
+}
+
+.tooltip-notes {
+ margin: 10px 0 0;
+ padding-left: 18px;
+ color: rgba(214, 227, 237, 0.82);
+ font-size: 0.8rem;
+}
+
@keyframes spin {
from {
transform: rotate(0deg);
diff --git a/map_renderer/src/public/app.js b/map_renderer/src/public/app.js
index 4af8465..51f9b0d 100644
--- a/map_renderer/src/public/app.js
+++ b/map_renderer/src/public/app.js
@@ -10,6 +10,7 @@ const statusBox = document.querySelector("#status");
const metaBox = document.querySelector("#meta");
const viewport = document.querySelector("#viewport");
const scene = document.querySelector("#scene");
+const overlayTooltip = document.querySelector("#overlay-tooltip");
const emptyState = document.querySelector("#empty-state");
const zoomLabel = document.querySelector("#zoom-label");
const zoomInButton = document.querySelector("#zoom-in");
@@ -62,6 +63,14 @@ function setStatus(message) {
statusBox.textContent = message;
}
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
function phaseProgress(build) {
const phaseToValue = {
queued: 5,
@@ -91,6 +100,14 @@ function setMeta(metadata) {
return;
}
+ const overlayKinds = Object.entries(metadata.overlaySummary?.kindCounts ?? {})
+ .sort((left, right) => left[0].localeCompare(right[0]))
+ .map(([kind, count]) => `${kind}: ${count}`)
+ .join(", ");
+ const overlayFamilies = (metadata.overlaySummary?.topFamilies ?? [])
+ .map((entry) => `family ${entry.family} (${entry.count})`)
+ .join(", ");
+
metaBox.innerHTML = `
+