Render surface maps directly and preserve their quality
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "./block-theme.css";
|
||||
import "./account.css";
|
||||
import "./map-theme.css";
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "MineGeo — Get lost in a familiar world",
|
||||
|
||||
119
app/map-theme.css
Normal file
119
app/map-theme.css
Normal file
@@ -0,0 +1,119 @@
|
||||
/* The same parchment surrounds the editor preview and interactive player map. */
|
||||
.detail-map,
|
||||
.map-surface {
|
||||
border: 22px solid transparent;
|
||||
border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch;
|
||||
background: #ddc79f;
|
||||
box-shadow: 4px 6px 0 #0005;
|
||||
}
|
||||
.detail-map {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.detail-map img,
|
||||
.map-surface .leaflet-image-layer {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.map-surface {
|
||||
border-width: 14px;
|
||||
border-image-width: 14px;
|
||||
height: 290px;
|
||||
}
|
||||
.map-surface .leaflet-container {
|
||||
background: #ddc79f;
|
||||
outline: 1px solid #9d815c;
|
||||
}
|
||||
.map-surface .leaflet-bar {
|
||||
border: 2px solid #806543;
|
||||
border-radius: 0;
|
||||
box-shadow: 2px 2px 0 #0004;
|
||||
}
|
||||
.map-surface .leaflet-bar a {
|
||||
background: #e5c99a;
|
||||
color: #493a28;
|
||||
border-color: #b39970;
|
||||
border-radius: 0;
|
||||
}
|
||||
.map-surface .leaflet-bar a:hover,
|
||||
.map-surface .leaflet-bar a:focus-visible {
|
||||
background: #f3ddb1;
|
||||
}
|
||||
.map-surface .leaflet-bar a.leaflet-disabled {
|
||||
background: #c9b18a;
|
||||
color: #847256;
|
||||
}
|
||||
.pin-marker {
|
||||
background: #e45e66;
|
||||
border-color: #fff1d0;
|
||||
border-radius: 50%;
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 0 0 1px #763d36,
|
||||
1px 2px 0 #0005;
|
||||
}
|
||||
.actual-marker {
|
||||
background: #90c55b;
|
||||
box-shadow:
|
||||
0 0 0 1px #426132,
|
||||
1px 2px 0 #0005;
|
||||
}
|
||||
.game-map {
|
||||
width: 340px;
|
||||
border: 2px solid #806543;
|
||||
background: #241e18;
|
||||
}
|
||||
.game-map.expanded {
|
||||
width: min(650px, calc(100vw - 48px));
|
||||
}
|
||||
.map-topline,
|
||||
.map-topline button,
|
||||
.map-actions small {
|
||||
color: #e5c99a;
|
||||
}
|
||||
.map-topline {
|
||||
padding: 9px 12px;
|
||||
}
|
||||
.map-actions {
|
||||
padding: 12px;
|
||||
}
|
||||
.map-actions .button.primary {
|
||||
background: #715034;
|
||||
color: #fff1d0;
|
||||
border-color: #af8956;
|
||||
box-shadow:
|
||||
inset 3px 3px 0 #a67c4e,
|
||||
inset -3px -3px 0 #49331f,
|
||||
2px 2px 0 #0005;
|
||||
}
|
||||
.map-actions .button.primary:hover:not(:disabled) {
|
||||
background: #87613e;
|
||||
}
|
||||
.expanded .map-surface {
|
||||
height: min(540px, 58vh);
|
||||
border-width: 22px;
|
||||
border-image-width: 22px;
|
||||
}
|
||||
.result-map {
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.detail-map {
|
||||
border-width: 16px;
|
||||
border-image-width: 16px;
|
||||
}
|
||||
.game-map {
|
||||
width: 250px;
|
||||
}
|
||||
.game-map .map-surface {
|
||||
height: 210px;
|
||||
border-width: 12px;
|
||||
border-image-width: 12px;
|
||||
}
|
||||
.game-map.expanded {
|
||||
width: calc(100vw - 28px);
|
||||
}
|
||||
.game-map.expanded .map-surface {
|
||||
height: min(440px, 50vh);
|
||||
border-width: 16px;
|
||||
border-image-width: 16px;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function GuessMap({
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -5,
|
||||
maxZoom: 3,
|
||||
zoomSnap: 0,
|
||||
maxBounds: b.pad(0.15),
|
||||
attributionControl: false,
|
||||
});
|
||||
@@ -55,7 +56,10 @@ export function GuessMap({
|
||||
),
|
||||
);
|
||||
overlay.addTo(m);
|
||||
m.fitBounds(b, { padding: [5, 5] });
|
||||
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
|
||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||
const fitZoom = m.getZoom();
|
||||
m.setMinZoom(fitZoom);
|
||||
markers.current = L.layerGroup().addTo(m);
|
||||
setGeneration((v) => v + 1);
|
||||
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
|
||||
@@ -70,7 +74,19 @@ export function GuessMap({
|
||||
)
|
||||
callbacks.current.onPin?.({ x, z });
|
||||
});
|
||||
const observer = new ResizeObserver(() => m.invalidateSize());
|
||||
let fittedZoom = fitZoom;
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Expanding the map should reveal the whole survey at its new size.
|
||||
// Preserve a player's chosen zoom and center while inspecting terrain.
|
||||
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
||||
m.invalidateSize();
|
||||
m.setMinZoom(-5);
|
||||
fittedZoom = m.getBoundsZoom(b);
|
||||
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
||||
if (wasFitted || m.getZoom() < fittedZoom)
|
||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||
m.setMinZoom(fittedZoom);
|
||||
});
|
||||
observer.observe(root.current);
|
||||
cleanup = () => {
|
||||
observer.disconnect();
|
||||
@@ -85,7 +101,7 @@ export function GuessMap({
|
||||
disposed = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [bounds, image]);
|
||||
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
|
||||
useEffect(() => {
|
||||
const L = lib.current,
|
||||
group = markers.current;
|
||||
|
||||
27
docs/surface-maps.md
Normal file
27
docs/surface-maps.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Surface maps
|
||||
|
||||
Plugin exports now produce a flat, north-up map rather than a textured aerial
|
||||
render. The map uses up to 128 cells along its longest side, chooses the dominant
|
||||
surface material in each cell, shades slopes and water depth, and retains the
|
||||
original world bounds. Transparent glass and small vegetation do not hide the
|
||||
ground. Unknown blocks use stone gray; material colors are approximations.
|
||||
|
||||
Uncaptured terrain appears as parchment. The saved WebP uses lossless compression
|
||||
and nearest-neighbor scaling, with a subtle grid. Fabric maps retain their
|
||||
client-captured colors and are also compressed losslessly. The web preview,
|
||||
player guessing map and results share a parchment frame.
|
||||
|
||||
New plugin uploads automatically use the new renderer. Existing processed images
|
||||
are not regenerated merely by restarting the web server. For an unpublished
|
||||
original capture, use the existing `npm run reprocess -- <dataset UUID>` workflow
|
||||
with the worker stopped, then restart the worker. Published revisions retain
|
||||
their approved media.
|
||||
|
||||
To create just a local map image from a retained export, without rerendering
|
||||
panoramas or changing stored data:
|
||||
|
||||
```sh
|
||||
npm run render:map -- path/to/terrain.json.gz path/to/map.webp
|
||||
```
|
||||
|
||||
The parchment is a local SVG UI asset; no remote textures are required.
|
||||
@@ -15,6 +15,7 @@
|
||||
"worker": "tsx worker/main.ts",
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"test:renderer": "node scripts/test-renderer.mjs",
|
||||
"render:map": "node scripts/render-map.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"renderer:proof": "node scripts/proof.mjs",
|
||||
|
||||
7
public/images/map-parchment.svg
Normal file
7
public/images/map-parchment.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" shape-rendering="crispEdges">
|
||||
<path fill="#806543" d="M2 0h26v2h3V0h31v1h4V0h36v2h4V0h20v2h2v26h-2v4h2v32h-1v4h1v30h-2v4h2v24h-2v2h-24v-2h-4v2H67v-1h-4v1H32v-2h-4v2H2v-2H0v-23h2v-4H0V68h1v-4H0V33h2v-5H0V2h2z"/>
|
||||
<path fill="#d8bc8a" d="M3 2h23v2h7V2h27v1h8V2h32v2h8V2h17v25h-2v7h3v28h-2v8h2v27h-2v7h2v20h-25v-2h-6v3H69v-2h-8v3H34v-2h-8v2H3v-24h2v-5H2V70h2v-8H2V35h3v-9H2V3h1z"/>
|
||||
<path fill="#ead1a2" d="M6 5h20v2h8V5h26v1h9V5h29v2h10V5h14v22h-2v9h3v24h-2v11h2v25h-2v10h2v15h-24v-2h-8v3H70v-2H59v3H36v-2H24v2H6v-22h2v-7H5V72h2V60H5V37h3V24H5V6h1z"/>
|
||||
<path fill="#b39970" d="M10 8h4v2h-2v4h-2zm14-3h2v5h-2zm35-1h2v4h4v2h-6zm42 0h2v4h-2zm16 6h4v2h-2v4h-2zM4 27h3v2h4v2H4zm118 1h3v3h-5v-2h2zM4 63h5v2H6v4H4zm117 1h4v2h-4zM5 96h4v2H7v6H5zm116 3h4v2h-2v4h-2zM9 114h2v5h5v2H9zm18 7h4v3h-2v-1h-2zm35-1h2v4h-2zm35 0h2v3h5v2h-7zm18-6h3v3h4v2h-7z"/>
|
||||
<path fill="#f3ddb1" d="M16 5h7v1h-7zM37 4h14v1H37zm40 1h14v1H77zM5 44h1v9H5zm117 0h1v10h-1zM5 78h1v9H5zm118 1h1v8h-1zM40 123h10v1H40zm39 0h10v1H79z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -5,6 +5,7 @@ import { resolve, sep } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
|
||||
import { assertTerrainImage } from "./image-validation.mjs";
|
||||
import { renderSurfaceMap } from "./surface-map.mjs";
|
||||
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot
|
||||
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
|
||||
* @param {string} output
|
||||
@@ -202,13 +203,7 @@ export async function renderSnapshot(
|
||||
}
|
||||
}
|
||||
await report({ percent: 85, message: "Rendering the surface map" });
|
||||
const map = await page.evaluate(() => window.renderMap(1024));
|
||||
const mapBytes = Buffer.from(map.split(",")[1], "base64");
|
||||
await assertTerrainImage(mapBytes, "Surface map");
|
||||
if (map.startsWith("data:image/webp;"))
|
||||
await writeFile(`${output}/map.webp`, mapBytes);
|
||||
else
|
||||
await sharp(mapBytes).webp({ quality: 90 }).toFile(`${output}/map.webp`);
|
||||
stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`);
|
||||
await report({
|
||||
percent: 90,
|
||||
message: "Rendered all panoramas and the surface map",
|
||||
|
||||
213
renderer/surface-map.mjs
Normal file
213
renderer/surface-map.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
// A map is a survey of the surface, not a photograph of block textures.
|
||||
// Keep the palette and relief separate from the lighting used by panoramas.
|
||||
const palette = {
|
||||
grass: 0x7fb238,
|
||||
foliage: 0x007c00,
|
||||
sand: 0xf7e9a3,
|
||||
stone: 0x707070,
|
||||
dirt: 0x976d4d,
|
||||
wood: 0x8f7748,
|
||||
water: 0x356baf,
|
||||
snow: 0xffffff,
|
||||
ice: 0xa0a0ff,
|
||||
clay: 0xa4a8b8,
|
||||
lava: 0xff0000,
|
||||
black: 0x191919,
|
||||
white: 0xfffcf5,
|
||||
orange: 0xd87f33,
|
||||
magenta: 0xb24cd8,
|
||||
light_blue: 0x6699d8,
|
||||
yellow: 0xe5e533,
|
||||
lime: 0x7fcc19,
|
||||
pink: 0xf27fa5,
|
||||
gray: 0x4c4c4c,
|
||||
light_gray: 0x999999,
|
||||
cyan: 0x4c7f99,
|
||||
purple: 0x7f3fb2,
|
||||
blue: 0x334cb2,
|
||||
brown: 0x664c33,
|
||||
green: 0x667f33,
|
||||
red: 0x993333,
|
||||
gold: 0xfaee4d,
|
||||
iron: 0xa7a7a7,
|
||||
diamond: 0x5cdbd5,
|
||||
emerald: 0x00d93a,
|
||||
terracotta: 0x985e43,
|
||||
netherrack: 0x700200,
|
||||
deepslate: 0x646464,
|
||||
copper: 0xc16b4f,
|
||||
oxidized: 0x52a384,
|
||||
crimson: 0x943f61,
|
||||
warped: 0x3a8e8c,
|
||||
};
|
||||
const entries = Object.entries(palette);
|
||||
const ids = Object.fromEntries(entries.map(([name], id) => [name, id]));
|
||||
const invisible =
|
||||
/^(?:air|cave_air|void_air|barrier|structure_void|light|.*glass(?:_pane)?|(?:wall_)?torch|.*_torch|.*_flower|.*_sapling|short_grass|tall_grass|fern|large_fern|dead_bush|vine|glow_lichen|seagrass|tall_seagrass|kelp|kelp_plant|.*_rail|rail|redstone_wire|tripwire|tripwire_hook)$/;
|
||||
|
||||
function material(state) {
|
||||
const name = state.split("[")[0].replace(/^minecraft:/, "");
|
||||
if (invisible.test(name)) return -1;
|
||||
if (name === "water" || name === "bubble_column") return ids.water;
|
||||
if (/snow|quartz|calcite/.test(name)) return ids.snow;
|
||||
if (name.includes("ice")) return ids.ice;
|
||||
if (name === "lava" || name.includes("magma")) return ids.lava;
|
||||
if (name === "grass_block" || name === "moss_block" || name === "moss_carpet")
|
||||
return ids.grass;
|
||||
if (/leaves|lily_pad/.test(name)) return ids.foliage;
|
||||
if (/^(?:red_sand|red_sandstone)/.test(name)) return ids.orange;
|
||||
if (/sand|end_stone|birch/.test(name)) return ids.sand;
|
||||
if (/dirt|mud|farmland|rooted|granite|jungle/.test(name)) return ids.dirt;
|
||||
if (name === "clay") return ids.clay;
|
||||
if (/obsidian|coal_block|blackstone/.test(name)) return ids.black;
|
||||
for (const [color] of entries) {
|
||||
if (
|
||||
name.startsWith(color + "_") &&
|
||||
/wool|carpet|concrete|terracotta|bed|shulker/.test(name)
|
||||
)
|
||||
return ids[color];
|
||||
}
|
||||
if (/terracotta|brick/.test(name) && !/stone|nether|end/.test(name))
|
||||
return ids.terracotta;
|
||||
if (/gold_block|raw_gold/.test(name)) return ids.gold;
|
||||
if (/iron_block|raw_iron/.test(name)) return ids.iron;
|
||||
if (/diamond_block|prismarine|sea_lantern/.test(name)) return ids.diamond;
|
||||
if (name === "emerald_block") return ids.emerald;
|
||||
if (/copper/.test(name))
|
||||
return /oxidized|weathered/.test(name) ? ids.oxidized : ids.copper;
|
||||
if (/crimson|nether_wart/.test(name)) return ids.crimson;
|
||||
if (/warped/.test(name)) return ids.warped;
|
||||
if (/netherrack|nether_brick/.test(name)) return ids.netherrack;
|
||||
if (/deepslate|basalt/.test(name)) return ids.deepslate;
|
||||
if (/spruce|dark_oak/.test(name)) return ids.brown;
|
||||
if (
|
||||
/acacia|mangrove|cherry|pale_oak|oak|bamboo|planks|log|wood|chest|crafting|barrel|bookshelf/.test(
|
||||
name,
|
||||
)
|
||||
)
|
||||
return ids.wood;
|
||||
return ids.stone;
|
||||
}
|
||||
|
||||
/** Rasterize north (-Z) at the top, preserving half-open world bounds.
|
||||
* @param {import('../shared/protocol').TerrainSnapshot} snapshot
|
||||
* @param {{resolution?:number}} [options]
|
||||
*/
|
||||
export function surfacePixels(snapshot, { resolution = 128 } = {}) {
|
||||
const { minX, maxX, minZ, maxZ } = snapshot.bounds;
|
||||
const spanX = maxX - minX,
|
||||
spanZ = maxZ - minZ;
|
||||
if (!Number.isInteger(resolution) || resolution < 1 || resolution > 512)
|
||||
throw new Error("Map resolution must be an integer from 1 to 512");
|
||||
const scale = Math.min(1, resolution / Math.max(spanX, spanZ));
|
||||
const width = Math.ceil(spanX * scale),
|
||||
height = Math.ceil(spanZ * scale);
|
||||
const top = new Int16Array(spanX * spanZ).fill(-32768);
|
||||
const bed = new Int16Array(top.length).fill(-32768);
|
||||
const colors = new Int16Array(top.length).fill(-1);
|
||||
const stateCache = new Map();
|
||||
let columns = 0;
|
||||
for (const [x, y, z, state] of snapshot.blocks) {
|
||||
if (x < minX || x >= maxX || z < minZ || z >= maxZ) continue;
|
||||
let color = stateCache.get(state);
|
||||
if (color === undefined) {
|
||||
color = material(state);
|
||||
stateCache.set(state, color);
|
||||
}
|
||||
if (color < 0) continue;
|
||||
const i = (z - minZ) * spanX + x - minX;
|
||||
if (color !== ids.water && y > bed[i]) bed[i] = y;
|
||||
if (y > top[i]) {
|
||||
if (colors[i] < 0) columns++;
|
||||
top[i] = y;
|
||||
colors[i] = color;
|
||||
}
|
||||
}
|
||||
if (!columns) throw new Error("Surface map contains no mapped terrain");
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
const heights = new Float32Array(width * height).fill(NaN);
|
||||
const votes = new Uint32Array(entries.length);
|
||||
for (let pz = 0; pz < height; pz++) {
|
||||
for (let px = 0; px < width; px++) {
|
||||
votes.fill(0);
|
||||
let count = 0,
|
||||
totalHeight = 0,
|
||||
totalDepth = 0,
|
||||
waterCount = 0;
|
||||
const x0 = Math.floor((px * spanX) / width),
|
||||
x1 = Math.floor(((px + 1) * spanX) / width);
|
||||
const z0 = Math.floor((pz * spanZ) / height),
|
||||
z1 = Math.floor(((pz + 1) * spanZ) / height);
|
||||
for (let z = z0; z < z1; z++)
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = z * spanX + x;
|
||||
if (colors[i] < 0) continue;
|
||||
votes[colors[i]]++;
|
||||
count++;
|
||||
totalHeight += top[i];
|
||||
if (colors[i] === ids.water) {
|
||||
// Exports retain only the upper terrain layers. A missing bed means
|
||||
// deep water; it must not turn into a bright shallow coastline.
|
||||
totalDepth += bed[i] === -32768 ? 24 : Math.max(1, top[i] - bed[i]);
|
||||
waterCount++;
|
||||
}
|
||||
}
|
||||
const pixel = pz * width + px,
|
||||
offset = pixel * 4;
|
||||
if (!count) {
|
||||
const paper =
|
||||
(Math.floor(px / 4) + Math.floor(pz / 4)) % 2 ? 0xd4bb8c : 0xddc79f;
|
||||
data[offset] = paper >> 16;
|
||||
data[offset + 1] = (paper >> 8) & 255;
|
||||
data[offset + 2] = paper & 255;
|
||||
data[offset + 3] = 255;
|
||||
continue;
|
||||
}
|
||||
let dominant = 0;
|
||||
for (let c = 1; c < votes.length; c++)
|
||||
if (votes[c] > votes[dominant]) dominant = c;
|
||||
heights[pixel] = totalHeight / count;
|
||||
const north = heights[pixel - width];
|
||||
const slope = Number.isFinite(north)
|
||||
? (heights[pixel] - north) / (spanZ / height)
|
||||
: 0;
|
||||
const shade =
|
||||
dominant === ids.water
|
||||
? totalDepth / waterCount > 8
|
||||
? 0.61
|
||||
: totalDepth / waterCount > 3
|
||||
? 0.76
|
||||
: 0.9
|
||||
: slope > 0.25
|
||||
? 1
|
||||
: slope < -0.25
|
||||
? 0.71
|
||||
: 0.86;
|
||||
const rgb = entries[dominant][1];
|
||||
data[offset] = Math.floor((rgb >> 16) * shade);
|
||||
data[offset + 1] = Math.floor(((rgb >> 8) & 255) * shade);
|
||||
data[offset + 2] = Math.floor((rgb & 255) * shade);
|
||||
data[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { data, width, height, channels: 4, columns };
|
||||
}
|
||||
|
||||
/** Save without lossy ringing around the map's square pixels. */
|
||||
export async function renderSurfaceMap(snapshot, output) {
|
||||
const { data, width, height, columns } = surfacePixels(snapshot);
|
||||
await sharp(data, { raw: { width, height, channels: 4 } })
|
||||
.resize(width * 8, height * 8, { kernel: "nearest" })
|
||||
.composite([
|
||||
{
|
||||
input: Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width * 8}" height="${height * 8}"><defs><pattern id="grid" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M8 0H0V8" fill="none" stroke="#f3edd8" stroke-opacity=".07" stroke-width="1"/></pattern></defs><rect width="100%" height="100%" fill="url(#grid)"/></svg>`,
|
||||
),
|
||||
},
|
||||
])
|
||||
.webp({ lossless: true })
|
||||
.toFile(output);
|
||||
return { width, height, columns };
|
||||
}
|
||||
20
scripts/render-map.mjs
Normal file
20
scripts/render-map.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { renderSurfaceMap } from "../renderer/surface-map.mjs";
|
||||
|
||||
const [input, output] = process.argv.slice(2);
|
||||
if (!input || !output)
|
||||
throw new Error(
|
||||
"Usage: npm run render:map -- <terrain.json.gz> <output.webp>",
|
||||
);
|
||||
const terrain = JSON.parse(
|
||||
gunzipSync(await readFile(input), {
|
||||
maxOutputLength: 256 * 1024 * 1024,
|
||||
}).toString(),
|
||||
);
|
||||
await mkdir(dirname(resolve(output)), { recursive: true });
|
||||
const report = await renderSurfaceMap(terrain, output);
|
||||
console.log(
|
||||
`Rendered ${report.width} × ${report.height} map cells from ${report.columns.toLocaleString()} surface columns: ${output}`,
|
||||
);
|
||||
123
tests/surface-map.test.ts
Normal file
123
tests/surface-map.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { surfacePixels } from "../renderer/surface-map.mjs";
|
||||
import type { TerrainSnapshot } from "../shared/protocol";
|
||||
|
||||
function pixel(map: ReturnType<typeof surfacePixels>, x: number, z: number) {
|
||||
return [
|
||||
...map.data.subarray((z * map.width + x) * 4, (z * map.width + x + 1) * 4),
|
||||
];
|
||||
}
|
||||
|
||||
test("surface map keeps north up, negative coordinates, rectangular bounds and uncovered parchment", () => {
|
||||
const map = surfacePixels({
|
||||
bounds: { minX: -2, maxX: 2, minZ: -3, maxZ: -1 },
|
||||
coverage: [],
|
||||
blocks: [
|
||||
[-2, 64, -3, "minecraft:snow_block"],
|
||||
[1, 64, -2, "minecraft:grass_block"],
|
||||
[2, 90, -2, "minecraft:snow_block"], // half-open boundary
|
||||
[-2, 90, -1, "minecraft:snow_block"],
|
||||
],
|
||||
});
|
||||
assert.equal(map.width, 4);
|
||||
assert.equal(map.height, 2);
|
||||
assert.equal(map.columns, 2);
|
||||
const north = pixel(map, 0, 0);
|
||||
assert.equal(north[0], north[1]);
|
||||
assert.equal(north[1], north[2]);
|
||||
const south = pixel(map, 3, 1);
|
||||
assert.ok(south[1] > south[0] && south[0] > south[2]);
|
||||
const blank = pixel(map, 3, 0);
|
||||
assert.ok(blank[0] > blank[1] && blank[1] > blank[2]);
|
||||
assert.equal(blank[3], 255);
|
||||
});
|
||||
|
||||
test("the highest mapped block wins regardless of export order and glass does not obscure terrain", () => {
|
||||
const terrain: TerrainSnapshot = {
|
||||
bounds: { minX: 0, maxX: 1, minZ: 0, maxZ: 1 },
|
||||
coverage: [],
|
||||
blocks: [
|
||||
[0, 63, 0, "minecraft:dirt"],
|
||||
[0, 64, 0, "minecraft:grass_block[snowy=false]"],
|
||||
[0, 70, 0, "minecraft:glass"],
|
||||
[0, 71, 0, "minecraft:short_grass"],
|
||||
],
|
||||
};
|
||||
const first = surfacePixels(terrain);
|
||||
const reversed = surfacePixels({
|
||||
...terrain,
|
||||
blocks: [...terrain.blocks].reverse(),
|
||||
});
|
||||
assert.deepEqual(first.data, reversed.data);
|
||||
const rgb = pixel(first, 0, 0);
|
||||
assert.ok(rgb[1] > rgb[0] && rgb[0] > rgb[2]);
|
||||
});
|
||||
|
||||
test("depth shading separates shallow, deep and unknown-bed water without texture speckle", () => {
|
||||
const map = surfacePixels({
|
||||
bounds: { minX: 0, maxX: 3, minZ: 0, maxZ: 1 },
|
||||
coverage: [],
|
||||
blocks: [
|
||||
[0, 64, 0, "minecraft:water[level=0]"],
|
||||
[0, 63, 0, "minecraft:sand"],
|
||||
[1, 64, 0, "minecraft:water[level=0]"],
|
||||
[1, 52, 0, "minecraft:stone"],
|
||||
[2, 64, 0, "minecraft:water[level=0]"],
|
||||
],
|
||||
});
|
||||
const shallow = pixel(map, 0, 0),
|
||||
deep = pixel(map, 1, 0);
|
||||
assert.ok(shallow[2] > shallow[1] && shallow[1] > shallow[0]);
|
||||
assert.ok(shallow[2] > deep[2]);
|
||||
assert.deepEqual(deep, pixel(map, 2, 0));
|
||||
});
|
||||
|
||||
test("height relief shades slopes while flat ground stays uniform", () => {
|
||||
const blocks: TerrainSnapshot["blocks"] = [64, 64, 66, 64].map((y, z) => [
|
||||
0,
|
||||
y,
|
||||
z,
|
||||
"minecraft:stone",
|
||||
]);
|
||||
const map = surfacePixels({
|
||||
bounds: { minX: 0, maxX: 1, minZ: 0, maxZ: 4 },
|
||||
coverage: [],
|
||||
blocks,
|
||||
});
|
||||
assert.deepEqual(pixel(map, 0, 0), pixel(map, 0, 1));
|
||||
assert.ok(pixel(map, 0, 2)[0] > pixel(map, 0, 1)[0]);
|
||||
assert.ok(pixel(map, 0, 3)[0] < pixel(map, 0, 1)[0]);
|
||||
});
|
||||
|
||||
test("map cells use the dominant surface material instead of a textured average", () => {
|
||||
const blocks: TerrainSnapshot["blocks"] = [];
|
||||
for (let x = 0; x < 4; x++)
|
||||
for (let z = 0; z < 4; z++)
|
||||
blocks.push([
|
||||
x,
|
||||
64,
|
||||
z,
|
||||
x === 0 ? "minecraft:stone" : "minecraft:grass_block",
|
||||
]);
|
||||
const map = surfacePixels(
|
||||
{ bounds: { minX: 0, maxX: 4, minZ: 0, maxZ: 4 }, coverage: [], blocks },
|
||||
{ resolution: 1 },
|
||||
);
|
||||
assert.equal(map.width, 1);
|
||||
assert.equal(map.height, 1);
|
||||
const rgb = pixel(map, 0, 0);
|
||||
assert.ok(rgb[1] > rgb[0] && rgb[0] > rgb[2]);
|
||||
});
|
||||
|
||||
test("empty exports fail instead of producing a playable blank map", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
surfacePixels({
|
||||
bounds: { minX: 0, maxX: 16, minZ: 0, maxZ: 16 },
|
||||
coverage: [],
|
||||
blocks: [],
|
||||
}),
|
||||
/no mapped terrain/,
|
||||
);
|
||||
});
|
||||
@@ -193,7 +193,7 @@ async function processJob(job: {
|
||||
await sharp(await readFile(`${directory}/raw/map.png`), {
|
||||
limitInputPixels: 16777216,
|
||||
})
|
||||
.webp({ quality: 90 })
|
||||
.webp({ lossless: true })
|
||||
.toFile(`${output}/map.webp`);
|
||||
let processedFaces = 0;
|
||||
for (const point of manifest.availableViewpoints ?? manifest.viewpoints) {
|
||||
|
||||
Reference in New Issue
Block a user