Render surface maps directly and preserve their quality

This commit is contained in:
2026-10-05 14:16:39 +05:00
parent c5368fa8cc
commit 8b31891776
11 changed files with 533 additions and 11 deletions

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import "./block-theme.css"; import "./block-theme.css";
import "./account.css"; import "./account.css";
import "./map-theme.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "MineGeo — Get lost in a familiar world", default: "MineGeo — Get lost in a familiar world",

119
app/map-theme.css Normal file
View 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;
}
}

View File

@@ -44,6 +44,7 @@ export function GuessMap({
crs: L.CRS.Simple, crs: L.CRS.Simple,
minZoom: -5, minZoom: -5,
maxZoom: 3, maxZoom: 3,
zoomSnap: 0,
maxBounds: b.pad(0.15), maxBounds: b.pad(0.15),
attributionControl: false, attributionControl: false,
}); });
@@ -55,7 +56,10 @@ export function GuessMap({
), ),
); );
overlay.addTo(m); 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); markers.current = L.layerGroup().addTo(m);
setGeneration((v) => v + 1); setGeneration((v) => v + 1);
m.on("click", (e: Leaflet.LeafletMouseEvent) => { m.on("click", (e: Leaflet.LeafletMouseEvent) => {
@@ -70,7 +74,19 @@ export function GuessMap({
) )
callbacks.current.onPin?.({ x, z }); 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); observer.observe(root.current);
cleanup = () => { cleanup = () => {
observer.disconnect(); observer.disconnect();
@@ -85,7 +101,7 @@ export function GuessMap({
disposed = true; disposed = true;
cleanup(); cleanup();
}; };
}, [bounds, image]); }, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
useEffect(() => { useEffect(() => {
const L = lib.current, const L = lib.current,
group = markers.current; group = markers.current;

27
docs/surface-maps.md Normal file
View 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.

View File

@@ -15,6 +15,7 @@
"worker": "tsx worker/main.ts", "worker": "tsx worker/main.ts",
"test": "tsx --test tests/*.test.ts", "test": "tsx --test tests/*.test.ts",
"test:renderer": "node scripts/test-renderer.mjs", "test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"renderer:proof": "node scripts/proof.mjs", "renderer:proof": "node scripts/proof.mjs",

View 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

View File

@@ -5,6 +5,7 @@ import { resolve, sep } from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs"; import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs"; import { assertTerrainImage } from "./image-validation.mjs";
import { renderSurfaceMap } from "./surface-map.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot /** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output * @param {string} output
@@ -202,13 +203,7 @@ export async function renderSnapshot(
} }
} }
await report({ percent: 85, message: "Rendering the surface map" }); await report({ percent: 85, message: "Rendering the surface map" });
const map = await page.evaluate(() => window.renderMap(1024)); stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`);
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`);
await report({ await report({
percent: 90, percent: 90,
message: "Rendered all panoramas and the surface map", message: "Rendered all panoramas and the surface map",

213
renderer/surface-map.mjs Normal file
View 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
View 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
View 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/,
);
});

View File

@@ -193,7 +193,7 @@ async function processJob(job: {
await sharp(await readFile(`${directory}/raw/map.png`), { await sharp(await readFile(`${directory}/raw/map.png`), {
limitInputPixels: 16777216, limitInputPixels: 16777216,
}) })
.webp({ quality: 90 }) .webp({ lossless: true })
.toFile(`${output}/map.webp`); .toFile(`${output}/map.webp`);
let processedFaces = 0; let processedFaces = 0;
for (const point of manifest.availableViewpoints ?? manifest.viewpoints) { for (const point of manifest.availableViewpoints ?? manifest.viewpoints) {