Phase 5: region select + client-side glTF export
Adds the Region Export API (GET /api/export/:serverId/:dimension/ :x1/:z1/:x2/:z2, chunk-coordinate range, capped at 64 chunks server- side) streaming raw chunkSections rows for a footprint, plus a fully client-side pipeline that turns that into a downloadable glTF (.glb): a JS port of the Rust worker's greedy mesher (voxel-mesh.js, mirrored test-for-test against worker/src/mesh.rs) and block-color palette (block-colors.js, mirrored against worker/src/palette.rs), a base64 section decoder, mesh merging with per-section world offsets, and a hand-rolled minimal GLB writer (gltf-export.js) — hand-rolled rather than Babylon's GLTF2Export since this project's Babylon usage never meshes client-side (only renders pre-built buffers) and a live Scene/Engine isn't needed or unit-testable. The whole mesh+export pipeline runs inside a Web Worker (export-worker.js) so a multi-chunk export doesn't block the UI thread. The map UI gets a drag-to-select region tool (chunk-snapped, live rectangle preview, cap enforced client-side too) and an Export glTF button. Built test-first per the project's TDD workflow; the exported .glb was independently validated with @gltf-transform/cli against real seeded terrain data (correct triangle count, vertex count, and bounding box for the selected area). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { chunkSections } from "./db/schema";
|
||||||
|
import { getExportSections, MAX_EXPORT_CHUNKS } from "./export";
|
||||||
|
import { createTestServer, deleteTestServer } from "./test-helpers";
|
||||||
|
|
||||||
|
describe("getExportSections", () => {
|
||||||
|
let server: { id: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("export-region");
|
||||||
|
await db.insert(chunkSections).values([
|
||||||
|
{ serverId: server.id, dimension: 0, x: 0, z: 0, sectionY: 4, blocks: "AAAA" },
|
||||||
|
{ serverId: server.id, dimension: 0, x: 0, z: 0, sectionY: 5, blocks: "BBBB" },
|
||||||
|
{ serverId: server.id, dimension: 0, x: 1, z: 0, sectionY: 4, blocks: "CCCC" },
|
||||||
|
// outside the queried range below — must not be returned.
|
||||||
|
{ serverId: server.id, dimension: 0, x: 50, z: 50, sectionY: 4, blocks: "ZZZZ" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns only sections within the requested chunk range", async () => {
|
||||||
|
const result = await getExportSections(server.id, 0, 0, 0, 1, 0);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.sections).toHaveLength(3);
|
||||||
|
expect(result.sections.map((s) => s.blocks).sort()).toEqual(["AAAA", "BBBB", "CCCC"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes swapped min/max chunk coordinates", async () => {
|
||||||
|
const result = await getExportSections(server.id, 0, 1, 0, 0, 0);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.sections).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an empty list, not an error, for a chunk-empty area", async () => {
|
||||||
|
const result = await getExportSections(server.id, 0, 900, 900, 901, 901);
|
||||||
|
expect(result).toEqual({ ok: true, sections: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a selection larger than the area cap", async () => {
|
||||||
|
const tooFar = Math.ceil(Math.sqrt(MAX_EXPORT_CHUNKS + 1)) - 1;
|
||||||
|
const result = await getExportSections(server.id, 0, 0, 0, tooFar + 1, tooFar + 1);
|
||||||
|
expect(result).toEqual({ ok: false, error: "selection_too_large" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a selection exactly at the area cap", async () => {
|
||||||
|
const side = Math.floor(Math.sqrt(MAX_EXPORT_CHUNKS));
|
||||||
|
const result = await getExportSections(server.id, 0, 2000, 2000, 2000 + side - 1, 2000 + side - 1);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { and, eq, gte, lte } from "drizzle-orm";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { chunkSections } from "./db/schema";
|
||||||
|
|
||||||
|
// "A handful of chunks square" per the plan's region-export guardrail — keeps client-side
|
||||||
|
// meshing/glTF assembly feasible on typical laptop hardware. 8x8 chunks = 128x128 blocks.
|
||||||
|
export const MAX_EXPORT_CHUNKS = 64;
|
||||||
|
|
||||||
|
export type ExportSection = { x: number; z: number; sectionY: number; blocks: string };
|
||||||
|
export type GetExportSectionsResult = { ok: true; sections: ExportSection[] } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the full-height raw block data (base64, same format the mod sends — see
|
||||||
|
* chunkSections' doc comment) for an X-Z chunk footprint, so the browser can mesh and export it
|
||||||
|
* itself (see the plan's "Feature: 2D region select..." section) — no server-side meshing here.
|
||||||
|
*/
|
||||||
|
export async function getExportSections(
|
||||||
|
serverId: string,
|
||||||
|
dimension: number,
|
||||||
|
chunkX1: number,
|
||||||
|
chunkZ1: number,
|
||||||
|
chunkX2: number,
|
||||||
|
chunkZ2: number,
|
||||||
|
): Promise<GetExportSectionsResult> {
|
||||||
|
const x1 = Math.min(chunkX1, chunkX2);
|
||||||
|
const x2 = Math.max(chunkX1, chunkX2);
|
||||||
|
const z1 = Math.min(chunkZ1, chunkZ2);
|
||||||
|
const z2 = Math.max(chunkZ1, chunkZ2);
|
||||||
|
|
||||||
|
const area = (x2 - x1 + 1) * (z2 - z1 + 1);
|
||||||
|
if (area > MAX_EXPORT_CHUNKS) {
|
||||||
|
return { ok: false, error: "selection_too_large" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ x: chunkSections.x, z: chunkSections.z, sectionY: chunkSections.sectionY, blocks: chunkSections.blocks })
|
||||||
|
.from(chunkSections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chunkSections.serverId, serverId),
|
||||||
|
eq(chunkSections.dimension, dimension),
|
||||||
|
gte(chunkSections.x, x1),
|
||||||
|
lte(chunkSections.x, x2),
|
||||||
|
gte(chunkSections.z, z1),
|
||||||
|
lte(chunkSections.z, z2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, sections: rows };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { meshPointers, tilePointers } from "./db/schema";
|
|||||||
import { minio, TILE_BUCKET } from "./minio";
|
import { minio, TILE_BUCKET } from "./minio";
|
||||||
import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers";
|
import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers";
|
||||||
import { storeLinkCode } from "./link";
|
import { storeLinkCode } from "./link";
|
||||||
|
import { chunkSections } from "./db/schema";
|
||||||
|
|
||||||
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without
|
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without
|
||||||
// binding a real port — avoids racing a real running instance for the port (see MCMapper's
|
// binding a real port — avoids racing a real running instance for the port (see MCMapper's
|
||||||
@@ -315,3 +316,37 @@ describe("marker routes", () => {
|
|||||||
expect(body.error).toBe("server_not_connected");
|
expect(body.error).toBe("server_not_connected");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/export/:serverId/:dimension/:x1/:z1/:x2/:z2", () => {
|
||||||
|
let server: { id: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("export-route");
|
||||||
|
await db.insert(chunkSections).values({
|
||||||
|
serverId: server.id,
|
||||||
|
dimension: 0,
|
||||||
|
x: 3,
|
||||||
|
z: 4,
|
||||||
|
sectionY: 4,
|
||||||
|
blocks: "AAAA",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("needs no session (region export is read-only public data)", async () => {
|
||||||
|
const res = await get(`/api/export/${server.id}/0/3/4/3/4`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.sections).toHaveLength(1);
|
||||||
|
expect(body.sections[0].blocks).toBe("AAAA");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("400s a too-large selection", async () => {
|
||||||
|
const res = await get(`/api/export/${server.id}/0/0/0/100/100`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()) as any).toEqual({ ok: false, error: "selection_too_large" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { chatGateway } from "./chat-gateway";
|
|||||||
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
|
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
|
||||||
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
|
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
|
||||||
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
|
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
|
||||||
|
import { getExportSections } from "./export";
|
||||||
|
|
||||||
const MARKER_SHARE_ERROR_STATUS: Record<string, number> = {
|
const MARKER_SHARE_ERROR_STATUS: Record<string, number> = {
|
||||||
not_found: 404,
|
not_found: 404,
|
||||||
@@ -210,6 +211,24 @@ export const app = new Elysia()
|
|||||||
if (!result.ok) set.status = MARKER_SHARE_ERROR_STATUS[result.error] ?? 400;
|
if (!result.ok) set.status = MARKER_SHARE_ERROR_STATUS[result.error] ?? 400;
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
|
// Unauthenticated on purpose, same as tile/mesh serving — this is read-only, scoped to
|
||||||
|
// already-public rendered-world data (see the plan's "Feature: 2D region select..." section).
|
||||||
|
// Coordinates are chunk coordinates, not block coordinates.
|
||||||
|
.get("/api/export/:serverId/:dimension/:x1/:z1/:x2/:z2", async ({ params, set }) => {
|
||||||
|
const result = await getExportSections(
|
||||||
|
params.serverId,
|
||||||
|
Number(params.dimension),
|
||||||
|
Number(params.x1),
|
||||||
|
Number(params.z1),
|
||||||
|
Number(params.x2),
|
||||||
|
Number(params.z2),
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
set.status = 400;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
.ws("/ws", {
|
.ws("/ws", {
|
||||||
open: wsGateway.open,
|
open: wsGateway.open,
|
||||||
message: wsGateway.message,
|
message: wsGateway.message,
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const app = new Elysia()
|
|||||||
.get("/js/colors.js", () => Bun.file(join(import.meta.dir, "public/js/colors.js")))
|
.get("/js/colors.js", () => Bun.file(join(import.meta.dir, "public/js/colors.js")))
|
||||||
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
|
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
|
||||||
.get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
|
.get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
|
||||||
|
.get("/js/block-colors.js", () => Bun.file(join(import.meta.dir, "public/js/block-colors.js")))
|
||||||
|
.get("/js/voxel-mesh.js", () => Bun.file(join(import.meta.dir, "public/js/voxel-mesh.js")))
|
||||||
|
.get("/js/gltf-export.js", () => Bun.file(join(import.meta.dir, "public/js/gltf-export.js")))
|
||||||
|
.get("/js/decode-section.js", () => Bun.file(join(import.meta.dir, "public/js/decode-section.js")))
|
||||||
|
.get("/js/region-select.js", () => Bun.file(join(import.meta.dir, "public/js/region-select.js")))
|
||||||
|
.get("/js/export-worker.js", () => Bun.file(join(import.meta.dir, "public/js/export-worker.js")))
|
||||||
.listen(Number(process.env.PORT ?? 3001));
|
.listen(Number(process.env.PORT ?? 3001));
|
||||||
|
|
||||||
console.log(`[frontend] listening on :${app.server?.port}`);
|
console.log(`[frontend] listening on :${app.server?.port}`);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Port of worker/src/palette.rs's `color_for` — kept in exact parity with the Rust tile/mesh
|
||||||
|
// renderer's palette so a client-side glTF export uses the same colors the 2D tiles/3D viewer
|
||||||
|
// already show. Ported by hand (no shared code between Rust and JS) — see mesh.rs's comment for
|
||||||
|
// why block identity is `(id, meta)` on these pre-Flattening leaves.
|
||||||
|
const UNKNOWN_COLOR = [204, 102, 204];
|
||||||
|
const AIR_COLOR = [30, 30, 40];
|
||||||
|
|
||||||
|
const WOOL_COLORS = [
|
||||||
|
[233, 236, 236],
|
||||||
|
[240, 118, 19],
|
||||||
|
[189, 68, 179],
|
||||||
|
[107, 138, 201],
|
||||||
|
[194, 173, 24],
|
||||||
|
[65, 174, 56],
|
||||||
|
[208, 132, 153],
|
||||||
|
[64, 64, 64],
|
||||||
|
[154, 161, 161],
|
||||||
|
[46, 110, 137],
|
||||||
|
[126, 61, 181],
|
||||||
|
[46, 56, 141],
|
||||||
|
[79, 50, 31],
|
||||||
|
[53, 70, 27],
|
||||||
|
[150, 52, 48],
|
||||||
|
[25, 22, 22],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLANK_COLORS = { 1: [166, 128, 78], 2: [196, 179, 123], 3: [170, 122, 79] };
|
||||||
|
const DEFAULT_PLANK_COLOR = [162, 130, 78];
|
||||||
|
|
||||||
|
export function colorFor(blockId, meta) {
|
||||||
|
switch (blockId) {
|
||||||
|
case 0:
|
||||||
|
return AIR_COLOR;
|
||||||
|
case 1:
|
||||||
|
return [125, 125, 125]; // stone
|
||||||
|
case 2:
|
||||||
|
return [95, 159, 53]; // grass block
|
||||||
|
case 3:
|
||||||
|
return [134, 96, 67]; // dirt
|
||||||
|
case 4:
|
||||||
|
return [122, 122, 122]; // cobblestone
|
||||||
|
case 5:
|
||||||
|
return PLANK_COLORS[meta] ?? DEFAULT_PLANK_COLOR;
|
||||||
|
case 7:
|
||||||
|
return [40, 40, 40]; // bedrock
|
||||||
|
case 8:
|
||||||
|
case 9:
|
||||||
|
return [63, 118, 228]; // water
|
||||||
|
case 10:
|
||||||
|
case 11:
|
||||||
|
return [207, 92, 32]; // lava
|
||||||
|
case 12:
|
||||||
|
return [219, 211, 160]; // sand
|
||||||
|
case 13:
|
||||||
|
return [136, 126, 126]; // gravel
|
||||||
|
case 14:
|
||||||
|
return [252, 238, 75]; // gold ore
|
||||||
|
case 15:
|
||||||
|
return [216, 175, 147]; // iron ore
|
||||||
|
case 16:
|
||||||
|
return [77, 77, 77]; // coal ore
|
||||||
|
case 17:
|
||||||
|
case 162:
|
||||||
|
return [92, 68, 41]; // logs
|
||||||
|
case 18:
|
||||||
|
case 161:
|
||||||
|
return [60, 100, 40]; // leaves
|
||||||
|
case 20:
|
||||||
|
return [220, 236, 240]; // glass
|
||||||
|
case 24:
|
||||||
|
return [219, 207, 163]; // sandstone
|
||||||
|
case 35:
|
||||||
|
return WOOL_COLORS[meta] ?? UNKNOWN_COLOR;
|
||||||
|
case 41:
|
||||||
|
return [246, 238, 92]; // gold block
|
||||||
|
case 42:
|
||||||
|
return [220, 220, 220]; // iron block
|
||||||
|
case 45:
|
||||||
|
return [151, 96, 90]; // bricks
|
||||||
|
case 48:
|
||||||
|
return [90, 108, 90]; // mossy cobblestone
|
||||||
|
case 49:
|
||||||
|
return [24, 20, 36]; // obsidian
|
||||||
|
case 56:
|
||||||
|
return [141, 209, 202]; // diamond ore
|
||||||
|
case 73:
|
||||||
|
case 74:
|
||||||
|
return [132, 32, 32]; // redstone ore
|
||||||
|
case 78:
|
||||||
|
return [240, 250, 255]; // snow layer
|
||||||
|
case 79:
|
||||||
|
return [140, 180, 230]; // ice
|
||||||
|
case 80:
|
||||||
|
return [248, 248, 248]; // snow block
|
||||||
|
case 82:
|
||||||
|
return [160, 164, 177]; // clay
|
||||||
|
case 86:
|
||||||
|
return [200, 128, 32]; // pumpkin
|
||||||
|
case 87:
|
||||||
|
return [110, 54, 48]; // netherrack
|
||||||
|
case 88:
|
||||||
|
return [84, 64, 51]; // soul sand
|
||||||
|
case 89:
|
||||||
|
return [186, 148, 92]; // glowstone
|
||||||
|
case 110:
|
||||||
|
return [92, 84, 108]; // mycelium
|
||||||
|
case 121:
|
||||||
|
return [221, 223, 165]; // end stone
|
||||||
|
case 123:
|
||||||
|
case 124:
|
||||||
|
return [171, 129, 85]; // nether wart block-ish glow (placeholder)
|
||||||
|
case 129:
|
||||||
|
return [79, 195, 161]; // emerald ore
|
||||||
|
case 133:
|
||||||
|
return [46, 190, 120]; // emerald block
|
||||||
|
case 159:
|
||||||
|
return [200, 130, 100]; // stained clay (approx, ignores meta)
|
||||||
|
default:
|
||||||
|
return UNKNOWN_COLOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { colorFor } from "./block-colors.js";
|
||||||
|
|
||||||
|
test("air is the dedicated air color", () => {
|
||||||
|
expect(colorFor(0, 0)).toEqual([30, 30, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("known block ignores meta unless the block uses it", () => {
|
||||||
|
expect(colorFor(1, 0)).toEqual(colorFor(1, 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oak log and planks differ", () => {
|
||||||
|
expect(colorFor(5, 0)).not.toEqual(colorFor(17, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planks vary by meta", () => {
|
||||||
|
const oak = colorFor(5, 0);
|
||||||
|
const spruce = colorFor(5, 1);
|
||||||
|
const birch = colorFor(5, 2);
|
||||||
|
expect(oak).not.toEqual(spruce);
|
||||||
|
expect(oak).not.toEqual(birch);
|
||||||
|
expect(spruce).not.toEqual(birch);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wool: all sixteen meta colors are distinct", () => {
|
||||||
|
const colors = Array.from({ length: 16 }, (_, meta) => colorFor(35, meta).join(","));
|
||||||
|
expect(new Set(colors).size).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmapped block id falls back to the unknown-color placeholder", () => {
|
||||||
|
expect(colorFor(9999, 0)).toEqual([204, 102, 204]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Decodes a chunkSections row's `blocks` field — base64 of 4096 little-endian u16
|
||||||
|
// blockStateIds, indexed by (ly*16 + lz)*16 + lx (see db/schema.ts's doc comment on the
|
||||||
|
// backend and voxel-mesh.js's blockAt, which expects exactly this layout).
|
||||||
|
export function decodeSectionBlocks(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
const blocks = new Uint16Array(bytes.length / 2);
|
||||||
|
for (let i = 0; i < blocks.length; i++) blocks[i] = view.getUint16(i * 2, true);
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { decodeSectionBlocks } from "./decode-section.js";
|
||||||
|
|
||||||
|
function base64FromU16LE(values: number[]) {
|
||||||
|
const bytes = new Uint8Array(values.length * 2);
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
values.forEach((v, i) => view.setUint16(i * 2, v, true));
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("decodes a base64 buffer of little-endian u16s into a Uint16Array", () => {
|
||||||
|
const values = [0, 1, 65535, 4096, ((2 << 4) | 0)];
|
||||||
|
const b64 = base64FromU16LE(values);
|
||||||
|
const decoded = decodeSectionBlocks(b64);
|
||||||
|
expect(Array.from(decoded)).toEqual(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes a full 4096-entry section buffer, preserving index order", () => {
|
||||||
|
const values = Array.from({ length: 4096 }, (_, i) => i % 65536);
|
||||||
|
const b64 = base64FromU16LE(values);
|
||||||
|
const decoded = decodeSectionBlocks(b64);
|
||||||
|
expect(decoded.length).toBe(4096);
|
||||||
|
expect(decoded[0]).toBe(0);
|
||||||
|
expect(decoded[4095]).toBe(4095);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Module Web Worker (see map.js's exportRegion) — keeps meshing + glTF assembly off the UI
|
||||||
|
// thread per the plan's "Web Worker to keep the UI thread free" note. Deliberately thin:
|
||||||
|
// fetch + orchestration only, all the actual logic lives in already-unit-tested pure modules
|
||||||
|
// (decode-section.js, voxel-mesh.js, gltf-export.js).
|
||||||
|
import { decodeSectionBlocks } from "./decode-section.js";
|
||||||
|
import { meshSection } from "./voxel-mesh.js";
|
||||||
|
import { mergeMeshes, buildGlb } from "./gltf-export.js";
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const { serverId, dimension, bounds } = event.data;
|
||||||
|
const { chunkX1, chunkZ1, chunkX2, chunkZ2 } = bounds;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/export/${serverId}/${dimension}/${chunkX1}/${chunkZ1}/${chunkX2}/${chunkZ2}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
self.postMessage({ ok: false, error: data.error ?? "export_failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = data.sections
|
||||||
|
.map((s) => ({
|
||||||
|
mesh: meshSection(decodeSectionBlocks(s.blocks)),
|
||||||
|
offset: [s.x * 16, s.sectionY * 16, s.z * 16],
|
||||||
|
}))
|
||||||
|
.filter((s) => s.mesh.indices.length > 0);
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
self.postMessage({ ok: false, error: "empty_selection" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeMeshes(sections);
|
||||||
|
const glb = buildGlb(merged);
|
||||||
|
self.postMessage({ ok: true, glb }, [glb]);
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Hand-rolled minimal glTF 2.0 binary (.glb) writer. The plan's marker feature section mentions
|
||||||
|
// "via Babylon's GLTF2Export serializer", but that class needs a live Babylon Scene/Engine (a
|
||||||
|
// real WebGL/DOM context) to run — this project's Babylon usage (mesh.js) only ever *renders*
|
||||||
|
// pre-built vertex buffers, it never meshes client-side, so there's no existing Babylon export
|
||||||
|
// path to reuse. A small hand-rolled writer instead keeps the whole export pipeline (this file)
|
||||||
|
// pure data transformation: fully unit-testable without a browser (see gltf-export.test.ts) and
|
||||||
|
// safe to run inside a Web Worker (see export-worker.js), which is the actual goal ("meshed
|
||||||
|
// client-side... Web Worker to keep the UI thread free" per the plan). Implements just enough of
|
||||||
|
// the spec (https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) for one indexed
|
||||||
|
// triangle mesh with POSITION/NORMAL/COLOR_0 — not a general-purpose glTF library.
|
||||||
|
|
||||||
|
const GLB_MAGIC = 0x46546c67; // 'glTF'
|
||||||
|
const GLB_VERSION = 2;
|
||||||
|
const CHUNK_TYPE_JSON = 0x4e4f534a; // 'JSON'
|
||||||
|
const CHUNK_TYPE_BIN = 0x004e4942; // 'BIN\0'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenates several section meshes (see voxel-mesh.js) into one combined mesh, shifting each
|
||||||
|
* section's vertex positions by its world offset (chunkX*16, sectionY*16, chunkZ*16 — matching
|
||||||
|
* how mesh.js positions live section meshes in the Babylon viewer) and rebasing its indices by
|
||||||
|
* the running vertex count so the merged index buffer stays valid.
|
||||||
|
*/
|
||||||
|
export function mergeMeshes(sections) {
|
||||||
|
let vertexCount = 0;
|
||||||
|
let indexCount = 0;
|
||||||
|
for (const { mesh } of sections) {
|
||||||
|
vertexCount += mesh.positions.length / 3;
|
||||||
|
indexCount += mesh.indices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Float32Array(vertexCount * 3);
|
||||||
|
const normals = new Float32Array(vertexCount * 3);
|
||||||
|
const colors = new Float32Array(vertexCount * 3);
|
||||||
|
const indices = new Uint32Array(indexCount);
|
||||||
|
|
||||||
|
let vertexOffset = 0;
|
||||||
|
let indexOffset = 0;
|
||||||
|
for (const { mesh, offset } of sections) {
|
||||||
|
const [ox, oy, oz] = offset;
|
||||||
|
const localVertexCount = mesh.positions.length / 3;
|
||||||
|
for (let i = 0; i < localVertexCount; i++) {
|
||||||
|
positions[(vertexOffset + i) * 3] = mesh.positions[i * 3] + ox;
|
||||||
|
positions[(vertexOffset + i) * 3 + 1] = mesh.positions[i * 3 + 1] + oy;
|
||||||
|
positions[(vertexOffset + i) * 3 + 2] = mesh.positions[i * 3 + 2] + oz;
|
||||||
|
}
|
||||||
|
normals.set(mesh.normals, vertexOffset * 3);
|
||||||
|
colors.set(mesh.colors, vertexOffset * 3);
|
||||||
|
for (let i = 0; i < mesh.indices.length; i++) {
|
||||||
|
indices[indexOffset + i] = mesh.indices[i] + vertexOffset;
|
||||||
|
}
|
||||||
|
vertexOffset += localVertexCount;
|
||||||
|
indexOffset += mesh.indices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, normals, colors, indices };
|
||||||
|
}
|
||||||
|
|
||||||
|
function minMaxVec3(positions) {
|
||||||
|
const min = [Infinity, Infinity, Infinity];
|
||||||
|
const max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const v = positions[i + c];
|
||||||
|
if (v < min[c]) min[c] = v;
|
||||||
|
if (v > max[c]) max[c] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function padTo4(buf, padByte) {
|
||||||
|
const remainder = buf.length % 4;
|
||||||
|
if (remainder === 0) return buf;
|
||||||
|
const padding = new Uint8Array(4 - remainder).fill(padByte);
|
||||||
|
const out = new Uint8Array(buf.length + padding.length);
|
||||||
|
out.set(buf, 0);
|
||||||
|
out.set(padding, buf.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single-mesh GLB from a combined {positions, normals, colors, indices} buffer set
|
||||||
|
* (see mergeMeshes) — assumes a non-empty mesh; callers should check for an empty selection
|
||||||
|
* before calling this (see export-worker.js), there's nothing meaningful to export otherwise.
|
||||||
|
*/
|
||||||
|
export function buildGlb(mesh) {
|
||||||
|
const { positions, normals, colors, indices } = mesh;
|
||||||
|
|
||||||
|
const positionsBytes = new Uint8Array(positions.buffer, positions.byteOffset, positions.byteLength);
|
||||||
|
const normalsBytes = new Uint8Array(normals.buffer, normals.byteOffset, normals.byteLength);
|
||||||
|
const colorsBytes = new Uint8Array(colors.buffer, colors.byteOffset, colors.byteLength);
|
||||||
|
const indicesBytes = new Uint8Array(indices.buffer, indices.byteOffset, indices.byteLength);
|
||||||
|
|
||||||
|
// Float32/Uint32 buffers are always already multiples of 4 bytes, so concatenating them keeps
|
||||||
|
// every bufferView's byteOffset naturally 4-byte aligned, as glTF's spec recommends.
|
||||||
|
let offset = 0;
|
||||||
|
const positionsView = { byteOffset: offset, byteLength: positionsBytes.length };
|
||||||
|
offset += positionsBytes.length;
|
||||||
|
const normalsView = { byteOffset: offset, byteLength: normalsBytes.length };
|
||||||
|
offset += normalsBytes.length;
|
||||||
|
const colorsView = { byteOffset: offset, byteLength: colorsBytes.length };
|
||||||
|
offset += colorsBytes.length;
|
||||||
|
const indicesView = { byteOffset: offset, byteLength: indicesBytes.length };
|
||||||
|
offset += indicesBytes.length;
|
||||||
|
|
||||||
|
const binBytes = new Uint8Array(offset);
|
||||||
|
binBytes.set(positionsBytes, positionsView.byteOffset);
|
||||||
|
binBytes.set(normalsBytes, normalsView.byteOffset);
|
||||||
|
binBytes.set(colorsBytes, colorsView.byteOffset);
|
||||||
|
binBytes.set(indicesBytes, indicesView.byteOffset);
|
||||||
|
|
||||||
|
const { min, max } = minMaxVec3(positions);
|
||||||
|
const vertexCount = positions.length / 3;
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
asset: { version: "2.0", generator: "MCMapper region export" },
|
||||||
|
scene: 0,
|
||||||
|
scenes: [{ nodes: [0] }],
|
||||||
|
nodes: [{ mesh: 0 }],
|
||||||
|
meshes: [
|
||||||
|
{
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
attributes: { POSITION: 0, NORMAL: 1, COLOR_0: 2 },
|
||||||
|
indices: 3,
|
||||||
|
mode: 4, // TRIANGLES
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
buffers: [{ byteLength: binBytes.length }],
|
||||||
|
bufferViews: [
|
||||||
|
{ buffer: 0, byteOffset: positionsView.byteOffset, byteLength: positionsView.byteLength, target: 34962 },
|
||||||
|
{ buffer: 0, byteOffset: normalsView.byteOffset, byteLength: normalsView.byteLength, target: 34962 },
|
||||||
|
{ buffer: 0, byteOffset: colorsView.byteOffset, byteLength: colorsView.byteLength, target: 34962 },
|
||||||
|
{ buffer: 0, byteOffset: indicesView.byteOffset, byteLength: indicesView.byteLength, target: 34963 },
|
||||||
|
],
|
||||||
|
accessors: [
|
||||||
|
{ bufferView: 0, componentType: 5126, count: vertexCount, type: "VEC3", min, max }, // FLOAT
|
||||||
|
{ bufferView: 1, componentType: 5126, count: vertexCount, type: "VEC3" },
|
||||||
|
{ bufferView: 2, componentType: 5126, count: vertexCount, type: "VEC3" },
|
||||||
|
{ bufferView: 3, componentType: 5125, count: indices.length, type: "SCALAR" }, // UNSIGNED_INT
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonBytes = padTo4(new TextEncoder().encode(JSON.stringify(json)), 0x20); // space-pad JSON
|
||||||
|
const binChunk = padTo4(binBytes, 0x00); // zero-pad BIN
|
||||||
|
|
||||||
|
const totalLength = 12 + (8 + jsonBytes.length) + (8 + binChunk.length);
|
||||||
|
const out = new ArrayBuffer(totalLength);
|
||||||
|
const view = new DataView(out);
|
||||||
|
let o = 0;
|
||||||
|
|
||||||
|
view.setUint32(o, GLB_MAGIC, true);
|
||||||
|
o += 4;
|
||||||
|
view.setUint32(o, GLB_VERSION, true);
|
||||||
|
o += 4;
|
||||||
|
view.setUint32(o, totalLength, true);
|
||||||
|
o += 4;
|
||||||
|
|
||||||
|
view.setUint32(o, jsonBytes.length, true);
|
||||||
|
o += 4;
|
||||||
|
view.setUint32(o, CHUNK_TYPE_JSON, true);
|
||||||
|
o += 4;
|
||||||
|
new Uint8Array(out, o, jsonBytes.length).set(jsonBytes);
|
||||||
|
o += jsonBytes.length;
|
||||||
|
|
||||||
|
view.setUint32(o, binChunk.length, true);
|
||||||
|
o += 4;
|
||||||
|
view.setUint32(o, CHUNK_TYPE_BIN, true);
|
||||||
|
o += 4;
|
||||||
|
new Uint8Array(out, o, binChunk.length).set(binChunk);
|
||||||
|
o += binChunk.length;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { mergeMeshes, buildGlb } from "./gltf-export.js";
|
||||||
|
import { meshSection } from "./voxel-mesh.js";
|
||||||
|
|
||||||
|
function oneVoxelMesh(blockIdMeta: number) {
|
||||||
|
const blocks = new Uint16Array(4096);
|
||||||
|
blocks[0] = blockIdMeta; // local (0,0,0)
|
||||||
|
return meshSection(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mergeMeshes offsets each section's positions by its world offset and concatenates buffers", () => {
|
||||||
|
const a = oneVoxelMesh((1 << 4) | 0);
|
||||||
|
const b = oneVoxelMesh((2 << 4) | 0);
|
||||||
|
|
||||||
|
const merged = mergeMeshes([
|
||||||
|
{ mesh: a, offset: [0, 0, 0] },
|
||||||
|
{ mesh: b, offset: [16, 0, 0] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged.positions.length).toBe(a.positions.length + b.positions.length);
|
||||||
|
expect(merged.indices.length).toBe(a.indices.length + b.indices.length);
|
||||||
|
|
||||||
|
// Every x coordinate contributed by mesh `b` must be shifted by +16.
|
||||||
|
const bXsShifted = [];
|
||||||
|
for (let i = 0; i < b.positions.length; i += 3) bXsShifted.push(b.positions[i] + 16);
|
||||||
|
const mergedXsForB = [];
|
||||||
|
for (let i = a.positions.length; i < merged.positions.length; i += 3) mergedXsForB.push(merged.positions[i]);
|
||||||
|
expect(mergedXsForB).toEqual(bXsShifted);
|
||||||
|
|
||||||
|
// Indices contributed by mesh `b` must be shifted by mesh `a`'s vertex count.
|
||||||
|
const aVertexCount = a.positions.length / 3;
|
||||||
|
const bIndicesShifted = Array.from(b.indices, (i) => i + aVertexCount);
|
||||||
|
const mergedIndicesForB = Array.from(merged.indices).slice(a.indices.length);
|
||||||
|
expect(mergedIndicesForB).toEqual(bIndicesShifted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildGlb produces a well-formed GLB container (magic, version, chunk structure)", () => {
|
||||||
|
const mesh = oneVoxelMesh((2 << 4) | 0);
|
||||||
|
const glb = buildGlb(mesh);
|
||||||
|
const view = new DataView(glb);
|
||||||
|
|
||||||
|
expect(view.getUint32(0, true)).toBe(0x46546c67); // 'glTF'
|
||||||
|
expect(view.getUint32(4, true)).toBe(2); // version
|
||||||
|
const totalLength = view.getUint32(8, true);
|
||||||
|
expect(totalLength).toBe(glb.byteLength);
|
||||||
|
|
||||||
|
const jsonChunkLength = view.getUint32(12, true);
|
||||||
|
const jsonChunkType = view.getUint32(16, true);
|
||||||
|
expect(jsonChunkType).toBe(0x4e4f534a); // 'JSON'
|
||||||
|
expect(jsonChunkLength % 4).toBe(0);
|
||||||
|
|
||||||
|
const jsonBytes = new Uint8Array(glb, 20, jsonChunkLength);
|
||||||
|
const json = JSON.parse(new TextDecoder().decode(jsonBytes)); // trailing pad spaces are valid JSON whitespace
|
||||||
|
expect(json.asset.version).toBe("2.0");
|
||||||
|
expect(json.meshes).toHaveLength(1);
|
||||||
|
expect(json.meshes[0].primitives[0].attributes.POSITION).toBeDefined();
|
||||||
|
expect(json.meshes[0].primitives[0].attributes.NORMAL).toBeDefined();
|
||||||
|
expect(json.meshes[0].primitives[0].attributes.COLOR_0).toBeDefined();
|
||||||
|
expect(json.meshes[0].primitives[0].indices).toBeDefined();
|
||||||
|
|
||||||
|
const positionAccessor = json.accessors[json.meshes[0].primitives[0].attributes.POSITION];
|
||||||
|
expect(positionAccessor.count).toBe(mesh.positions.length / 3);
|
||||||
|
expect(positionAccessor.min).toHaveLength(3);
|
||||||
|
expect(positionAccessor.max).toHaveLength(3);
|
||||||
|
|
||||||
|
const indexAccessor = json.accessors[json.meshes[0].primitives[0].indices];
|
||||||
|
expect(indexAccessor.count).toBe(mesh.indices.length);
|
||||||
|
|
||||||
|
const binChunkOffset = 20 + jsonChunkLength;
|
||||||
|
const binChunkLength = view.getUint32(binChunkOffset, true);
|
||||||
|
const binChunkType = view.getUint32(binChunkOffset + 4, true);
|
||||||
|
expect(binChunkType).toBe(0x004e4942); // 'BIN\0'
|
||||||
|
expect(binChunkLength).toBe(json.buffers[0].byteLength);
|
||||||
|
expect(20 + jsonChunkLength + 8 + binChunkLength).toBe(glb.byteLength);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildGlb's embedded BIN chunk round-trips the same position data", () => {
|
||||||
|
const mesh = oneVoxelMesh((1 << 4) | 0);
|
||||||
|
const glb = buildGlb(mesh);
|
||||||
|
const view = new DataView(glb);
|
||||||
|
const jsonChunkLength = view.getUint32(12, true);
|
||||||
|
const binStart = 20 + jsonChunkLength + 8;
|
||||||
|
|
||||||
|
const jsonBytes = new Uint8Array(glb, 20, jsonChunkLength);
|
||||||
|
const json = JSON.parse(new TextDecoder().decode(jsonBytes));
|
||||||
|
const posAccessorIdx = json.meshes[0].primitives[0].attributes.POSITION;
|
||||||
|
const posAccessor = json.accessors[posAccessorIdx];
|
||||||
|
const posBufferView = json.bufferViews[posAccessor.bufferView];
|
||||||
|
|
||||||
|
const positions = new Float32Array(glb, binStart + posBufferView.byteOffset, mesh.positions.length);
|
||||||
|
expect(Array.from(positions)).toEqual(Array.from(mesh.positions));
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
// — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts).
|
// — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts).
|
||||||
import { worldToLatLng, latLngToWorld } from "./coords.js";
|
import { worldToLatLng, latLngToWorld } from "./coords.js";
|
||||||
import { randomHexColor } from "./colors.js";
|
import { randomHexColor } from "./colors.js";
|
||||||
|
import { snapToChunkBounds, chunkArea, MAX_EXPORT_CHUNKS } from "./region-select.js";
|
||||||
|
|
||||||
const DEFAULT_MARKER_HEIGHT = 60;
|
const DEFAULT_MARKER_HEIGHT = 60;
|
||||||
|
|
||||||
@@ -44,6 +45,16 @@ function mapmapper() {
|
|||||||
markerPopup: null,
|
markerPopup: null,
|
||||||
markerStatus: "",
|
markerStatus: "",
|
||||||
|
|
||||||
|
// Region select + client-side glTF export (see the plan's "Feature: 2D region select..."
|
||||||
|
// section). 2D-only, independent of marker placement — see startRegionSelect's doc comment
|
||||||
|
// for why a drag gesture (not a click) is used here.
|
||||||
|
selectingRegion: false,
|
||||||
|
regionDragStart: null, // {x, z} world coords — set on mousedown, cleared on mouseup
|
||||||
|
regionBounds: null, // {chunkX1, chunkZ1, chunkX2, chunkZ2} — set once a drag completes
|
||||||
|
regionRectangle: null, // Leaflet layer, the live drag-preview rectangle
|
||||||
|
regionStatus: "",
|
||||||
|
exporting: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const servers = await fetch("/api/servers").then((r) => r.json());
|
const servers = await fetch("/api/servers").then((r) => r.json());
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -53,6 +64,7 @@ function mapmapper() {
|
|||||||
this.leaflet.setView([0, 0], 0);
|
this.leaflet.setView([0, 0], 0);
|
||||||
this.markerLayer = L.layerGroup().addTo(this.leaflet);
|
this.markerLayer = L.layerGroup().addTo(this.leaflet);
|
||||||
this.leaflet.on("click", (e) => this.onMapClick(e));
|
this.leaflet.on("click", (e) => this.onMapClick(e));
|
||||||
|
this.leaflet.on("mousedown", (e) => this.startRegionSelect(e));
|
||||||
|
|
||||||
// Captured once, up front: Leaflet physically moves this node in and out of its popup
|
// Captured once, up front: Leaflet physically moves this node in and out of its popup
|
||||||
// pane's DOM on every open/close (see openMarkerForm), which would break Alpine's $refs
|
// pane's DOM on every open/close (see openMarkerForm), which would break Alpine's $refs
|
||||||
@@ -178,6 +190,109 @@ function mapmapper() {
|
|||||||
this.renderMarkerLayer();
|
this.renderMarkerLayer();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleSelectingRegion() {
|
||||||
|
this.selectingRegion = !this.selectingRegion;
|
||||||
|
this.regionStatus = this.selectingRegion ? "drag on the map to select a region" : "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drag gesture (not a single click, unlike marker placement) since a region is an area,
|
||||||
|
* not a point — mousedown starts the drag, mousemove live-previews the rectangle,
|
||||||
|
* mouseup finalizes it (see updateRegionPreview/finishRegionSelect). Map panning is
|
||||||
|
* disabled for the duration so the drag doesn't fight Leaflet's own pan handling.
|
||||||
|
*/
|
||||||
|
startRegionSelect(e) {
|
||||||
|
if (!this.selectingRegion) return;
|
||||||
|
this.regionDragStart = latLngToWorld(e.latlng);
|
||||||
|
this.leaflet.dragging.disable();
|
||||||
|
this._onRegionMouseMove = (ev) => this.updateRegionPreview(ev);
|
||||||
|
this._onRegionMouseUp = (ev) => this.finishRegionSelect(ev);
|
||||||
|
this.leaflet.on("mousemove", this._onRegionMouseMove);
|
||||||
|
this.leaflet.on("mouseup", this._onRegionMouseUp);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRegionPreview(e) {
|
||||||
|
if (!this.regionDragStart) return;
|
||||||
|
const start = worldToLatLng(this.regionDragStart.x, this.regionDragStart.z);
|
||||||
|
const bounds = L.latLngBounds(start, e.latlng);
|
||||||
|
if (this.regionRectangle) {
|
||||||
|
this.regionRectangle.setBounds(bounds);
|
||||||
|
} else {
|
||||||
|
this.regionRectangle = L.rectangle(bounds, { color: "#f59e0b", weight: 1, fillOpacity: 0.1 }).addTo(this.leaflet);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finishRegionSelect(e) {
|
||||||
|
this.leaflet.off("mousemove", this._onRegionMouseMove);
|
||||||
|
this.leaflet.off("mouseup", this._onRegionMouseUp);
|
||||||
|
this.leaflet.dragging.enable();
|
||||||
|
this.selectingRegion = false;
|
||||||
|
|
||||||
|
const end = latLngToWorld(e.latlng);
|
||||||
|
const bounds = snapToChunkBounds(this.regionDragStart, end);
|
||||||
|
this.regionDragStart = null;
|
||||||
|
|
||||||
|
const area = chunkArea(bounds);
|
||||||
|
if (area > MAX_EXPORT_CHUNKS) {
|
||||||
|
this.regionStatus = `selection too large (${area} chunks, max ${MAX_EXPORT_CHUNKS}) — pick a smaller area`;
|
||||||
|
this.regionBounds = null;
|
||||||
|
this.clearRegionRectangle();
|
||||||
|
} else {
|
||||||
|
this.regionBounds = bounds;
|
||||||
|
this.regionStatus = `${area} chunk${area === 1 ? "" : "s"} selected`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearRegionRectangle() {
|
||||||
|
if (this.regionRectangle) {
|
||||||
|
this.leaflet.removeLayer(this.regionRectangle);
|
||||||
|
this.regionRectangle = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRegionSelection() {
|
||||||
|
this.selectingRegion = false;
|
||||||
|
this.regionBounds = null;
|
||||||
|
this.regionStatus = "";
|
||||||
|
this.clearRegionRectangle();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Runs the whole fetch -> decode -> greedy-mesh -> glTF-assemble pipeline in a Web Worker
|
||||||
|
// (export-worker.js) so a several-dozen-chunk selection doesn't freeze the map UI — see the
|
||||||
|
// plan's "Why client-side" note (this is a zero-cost feature for the backend/MC server).
|
||||||
|
exportRegion() {
|
||||||
|
if (!this.regionBounds || !this.server || this.exporting) return;
|
||||||
|
this.exporting = true;
|
||||||
|
this.regionStatus = "meshing…";
|
||||||
|
|
||||||
|
const worker = new Worker("/js/export-worker.js", { type: "module" });
|
||||||
|
worker.onmessage = (ev) => {
|
||||||
|
this.exporting = false;
|
||||||
|
worker.terminate();
|
||||||
|
const { ok, glb, error } = ev.data;
|
||||||
|
if (!ok) {
|
||||||
|
this.regionStatus = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([glb], { type: "model/gltf-binary" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `mcmapper-export-${Date.now()}.glb`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.regionStatus = "exported";
|
||||||
|
this.regionBounds = null;
|
||||||
|
this.clearRegionRectangle();
|
||||||
|
};
|
||||||
|
worker.onerror = (err) => {
|
||||||
|
this.exporting = false;
|
||||||
|
this.regionStatus = err.message ?? "export_failed";
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
worker.postMessage({ serverId: this.server.id, dimension: 0, bounds: this.regionBounds });
|
||||||
|
},
|
||||||
|
|
||||||
localMarkerStorageKey() {
|
localMarkerStorageKey() {
|
||||||
return `mcmapper_markers_${this.server.id}`;
|
return `mcmapper_markers_${this.server.id}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Chunk-snapping/area-cap helpers for the region-select-and-export tool (see the plan's
|
||||||
|
// "Feature: 2D region select..." section). Kept separate from coords.js since that module is
|
||||||
|
// about the Leaflet<->world mapping specifically, not chunk-grid math.
|
||||||
|
|
||||||
|
// Must match api/src/export.ts's MAX_EXPORT_CHUNKS — there's no shared-constants module between
|
||||||
|
// the two Bun processes in this repo layout, so this is a hand-kept duplicate (the "kept in sync
|
||||||
|
// by hand" test in region-select.test.ts is a tripwire if the backend value ever changes).
|
||||||
|
export const MAX_EXPORT_CHUNKS = 64;
|
||||||
|
|
||||||
|
function worldToChunk(block) {
|
||||||
|
return Math.floor(block / 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `a` and `b` are `{x, z}` world-block points (any drag order); returns inclusive chunk bounds. */
|
||||||
|
export function snapToChunkBounds(a, b) {
|
||||||
|
const chunkAX = worldToChunk(a.x);
|
||||||
|
const chunkAZ = worldToChunk(a.z);
|
||||||
|
const chunkBX = worldToChunk(b.x);
|
||||||
|
const chunkBZ = worldToChunk(b.z);
|
||||||
|
return {
|
||||||
|
chunkX1: Math.min(chunkAX, chunkBX),
|
||||||
|
chunkZ1: Math.min(chunkAZ, chunkBZ),
|
||||||
|
chunkX2: Math.max(chunkAX, chunkBX),
|
||||||
|
chunkZ2: Math.max(chunkAZ, chunkBZ),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkArea(bounds) {
|
||||||
|
return (bounds.chunkX2 - bounds.chunkX1 + 1) * (bounds.chunkZ2 - bounds.chunkZ1 + 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { snapToChunkBounds, chunkArea, MAX_EXPORT_CHUNKS } from "./region-select.js";
|
||||||
|
|
||||||
|
test("snaps two block-space points to their containing chunk bounds", () => {
|
||||||
|
// block (17, 33) is chunk (1, 2); block (5, 5) is chunk (0, 0).
|
||||||
|
const bounds = snapToChunkBounds({ x: 17, z: 33 }, { x: 5, z: 5 });
|
||||||
|
expect(bounds).toEqual({ chunkX1: 0, chunkZ1: 0, chunkX2: 1, chunkZ2: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes regardless of drag direction", () => {
|
||||||
|
const a = snapToChunkBounds({ x: 5, z: 5 }, { x: 17, z: 33 });
|
||||||
|
const b = snapToChunkBounds({ x: 17, z: 33 }, { x: 5, z: 5 });
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles negative world coordinates (floor, not truncate, toward chunk containment)", () => {
|
||||||
|
// block -1 is chunk -1 (floor(-1/16) = -1), not chunk 0.
|
||||||
|
const bounds = snapToChunkBounds({ x: -1, z: -1 }, { x: -1, z: -1 });
|
||||||
|
expect(bounds).toEqual({ chunkX1: -1, chunkZ1: -1, chunkX2: -1, chunkZ2: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chunkArea computes width*height in chunks, inclusive", () => {
|
||||||
|
expect(chunkArea({ chunkX1: 0, chunkZ1: 0, chunkX2: 1, chunkZ2: 2 })).toBe(2 * 3);
|
||||||
|
expect(chunkArea({ chunkX1: 5, chunkZ1: 5, chunkX2: 5, chunkZ2: 5 })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MAX_EXPORT_CHUNKS matches the backend's cap (kept in sync by hand — see api/src/export.ts)", () => {
|
||||||
|
expect(MAX_EXPORT_CHUNKS).toBe(64);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Port of worker/src/mesh.rs's `mesh_section` — the same "sweep each axis, build a 2D
|
||||||
|
// visibility mask per layer, greedily merge same-block runs into rectangles" greedy mesher,
|
||||||
|
// reimplemented in JS so region export can mesh client-side (see the plan's "Feature: 2D region
|
||||||
|
// select..." section — the whole point is keeping export a zero-cost operation for the
|
||||||
|
// backend/MC server, so this can't call back into the Rust worker). Deliberately not reusing
|
||||||
|
// the live 3D viewer's Babylon rendering path either — export needs plain vertex/index/color
|
||||||
|
// arrays to hand to gltf-export.js, not a live Babylon Scene/Mesh, and keeping this pure (no
|
||||||
|
// Babylon dependency) is what makes it unit-testable without a browser — see voxel-mesh.test.ts.
|
||||||
|
import { colorFor } from "./block-colors.js";
|
||||||
|
|
||||||
|
const SIZE = 16;
|
||||||
|
|
||||||
|
function blockAt(blocks, x, y, z) {
|
||||||
|
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE || z < 0 || z >= SIZE) return 0;
|
||||||
|
return blocks[(y * 16 + z) * 16 + x];
|
||||||
|
}
|
||||||
|
|
||||||
|
// axis 0 fixes x, 1 fixes y, 2 fixes z — mirrors mesh.rs's axis_pos.
|
||||||
|
function axisPos(axis, layer, u, v) {
|
||||||
|
if (axis === 0) return [layer, u, v];
|
||||||
|
if (axis === 1) return [u, layer, v];
|
||||||
|
return [u, v, layer];
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetAlongAxis(axis, dir) {
|
||||||
|
if (axis === 0) return [dir, 0, 0];
|
||||||
|
if (axis === 1) return [0, dir, 0];
|
||||||
|
return [0, 0, dir];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meshSection(blocks) {
|
||||||
|
const buf = { positions: [], normals: [], colors: [], indices: [] };
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
for (const dir of [-1, 1]) {
|
||||||
|
meshAxis(blocks, axis, dir, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
positions: Float32Array.from(buf.positions),
|
||||||
|
normals: Float32Array.from(buf.normals),
|
||||||
|
colors: Float32Array.from(buf.colors),
|
||||||
|
indices: Uint32Array.from(buf.indices),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshAxis(blocks, axis, dir, buf) {
|
||||||
|
const mask = Array.from({ length: SIZE }, () => new Uint16Array(SIZE));
|
||||||
|
|
||||||
|
for (let layer = 0; layer < SIZE; layer++) {
|
||||||
|
for (let u = 0; u < SIZE; u++) {
|
||||||
|
for (let v = 0; v < SIZE; v++) {
|
||||||
|
const [x, y, z] = axisPos(axis, layer, u, v);
|
||||||
|
const block = blockAt(blocks, x, y, z);
|
||||||
|
if (block === 0) {
|
||||||
|
mask[u][v] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [ox, oy, oz] = offsetAlongAxis(axis, dir);
|
||||||
|
const neighbor = blockAt(blocks, x + ox, y + oy, z + oz);
|
||||||
|
mask[u][v] = neighbor === 0 ? block : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const facePlane = dir === 1 ? layer + 1 : layer;
|
||||||
|
greedyMergeAndEmit(mask, axis, dir, facePlane, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function greedyMergeAndEmit(mask, axis, dir, facePlane, buf) {
|
||||||
|
const done = Array.from({ length: SIZE }, () => new Uint8Array(SIZE));
|
||||||
|
|
||||||
|
for (let u0 = 0; u0 < SIZE; u0++) {
|
||||||
|
for (let v0 = 0; v0 < SIZE; v0++) {
|
||||||
|
const block = mask[u0][v0];
|
||||||
|
if (block === 0 || done[u0][v0]) continue;
|
||||||
|
|
||||||
|
let v1 = v0 + 1;
|
||||||
|
while (v1 < SIZE && mask[u0][v1] === block && !done[u0][v1]) v1++;
|
||||||
|
|
||||||
|
let u1 = u0 + 1;
|
||||||
|
grow: while (u1 < SIZE) {
|
||||||
|
for (let v = v0; v < v1; v++) {
|
||||||
|
if (mask[u1][v] !== block || done[u1][v]) break grow;
|
||||||
|
}
|
||||||
|
u1++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let u = u0; u < u1; u++) {
|
||||||
|
for (let v = v0; v < v1; v++) done[u][v] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf) {
|
||||||
|
const cornersUv = [
|
||||||
|
[u0, v0],
|
||||||
|
[u1, v0],
|
||||||
|
[u1, v1],
|
||||||
|
[u0, v1],
|
||||||
|
];
|
||||||
|
const baseIndex = buf.positions.length / 3;
|
||||||
|
|
||||||
|
let normal;
|
||||||
|
if (axis === 0) normal = dir === 1 ? [1, 0, 0] : [-1, 0, 0];
|
||||||
|
else if (axis === 1) normal = dir === 1 ? [0, 1, 0] : [0, -1, 0];
|
||||||
|
else normal = dir === 1 ? [0, 0, 1] : [0, 0, -1];
|
||||||
|
|
||||||
|
const blockId = block >> 4;
|
||||||
|
const blockMeta = block & 0xf;
|
||||||
|
const [r, g, b] = colorFor(blockId, blockMeta);
|
||||||
|
const color = [r / 255, g / 255, b / 255];
|
||||||
|
|
||||||
|
for (const [u, v] of cornersUv) {
|
||||||
|
const [x, y, z] = axisPos(axis, facePlane, u, v);
|
||||||
|
buf.positions.push(x, y, z);
|
||||||
|
buf.normals.push(...normal);
|
||||||
|
buf.colors.push(...color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two triangles per quad; flip winding by direction, same as mesh.rs's emit_quad — backface
|
||||||
|
// culling is left off on the material side as the safety net (see mesh.js).
|
||||||
|
if (dir === 1) {
|
||||||
|
buf.indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3);
|
||||||
|
} else {
|
||||||
|
buf.indices.push(baseIndex, baseIndex + 2, baseIndex + 1, baseIndex, baseIndex + 3, baseIndex + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { meshSection } from "./voxel-mesh.js";
|
||||||
|
|
||||||
|
function emptyBlocks() {
|
||||||
|
return new Uint16Array(4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("empty section produces no geometry", () => {
|
||||||
|
const mesh = meshSection(emptyBlocks());
|
||||||
|
expect(mesh.indices.length).toBe(0);
|
||||||
|
expect(mesh.positions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single voxel produces six unmerged quads", () => {
|
||||||
|
const blocks = emptyBlocks();
|
||||||
|
blocks[(0 * 16 + 0) * 16 + 0] = (2 << 4) | 0; // grass at local (0,0,0)
|
||||||
|
const mesh = meshSection(blocks);
|
||||||
|
expect(mesh.positions.length).toBe(6 * 4 * 3); // 6 faces * 4 verts * xyz
|
||||||
|
expect(mesh.indices.length).toBe(6 * 6); // 6 faces * 2 tris * 3 indices
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full solid section collapses to six merged quads", () => {
|
||||||
|
const blocks = emptyBlocks();
|
||||||
|
blocks.fill((1 << 4) | 0); // stone everywhere
|
||||||
|
const mesh = meshSection(blocks);
|
||||||
|
expect(mesh.positions.length).toBe(6 * 4 * 3);
|
||||||
|
expect(mesh.indices.length).toBe(6 * 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkerboard layer does not merge across gaps (top face count matches solid cell count)", () => {
|
||||||
|
const blocks = emptyBlocks();
|
||||||
|
let solidCount = 0;
|
||||||
|
for (let x = 0; x < 16; x++) {
|
||||||
|
for (let z = 0; z < 16; z++) {
|
||||||
|
if ((x + z) % 2 === 0) {
|
||||||
|
blocks[z * 16 + x] = (1 << 4) | 0;
|
||||||
|
solidCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mesh = meshSection(blocks);
|
||||||
|
// Every solid cell is isolated on all 6 sides (checkerboard parity means x/z neighbors are
|
||||||
|
// always air, and there's nothing above/below) — nothing can merge, so every one of the 6
|
||||||
|
// faces of every solid voxel is emitted separately: solidCount * 6 quads, 4 verts each.
|
||||||
|
expect(mesh.positions.length / 3).toBe(solidCount * 6 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits plausible world-space positions for a corner voxel", () => {
|
||||||
|
const blocks = emptyBlocks();
|
||||||
|
blocks[0] = (1 << 4) | 0; // (0,0,0)
|
||||||
|
const mesh = meshSection(blocks);
|
||||||
|
const xs = [];
|
||||||
|
for (let i = 0; i < mesh.positions.length; i += 3) xs.push(mesh.positions[i]);
|
||||||
|
expect(Math.min(...xs)).toBe(0);
|
||||||
|
expect(Math.max(...xs)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("colors come from the block-color palette, normalized to 0..1", () => {
|
||||||
|
const blocks = emptyBlocks();
|
||||||
|
blocks[0] = (2 << 4) | 0; // grass -> [95, 159, 53]
|
||||||
|
const mesh = meshSection(blocks);
|
||||||
|
expect(mesh.colors[0]).toBeCloseTo(95 / 255, 5);
|
||||||
|
expect(mesh.colors[1]).toBeCloseTo(159 / 255, 5);
|
||||||
|
expect(mesh.colors[2]).toBeCloseTo(53 / 255, 5);
|
||||||
|
});
|
||||||
@@ -43,6 +43,20 @@ html(lang="en")
|
|||||||
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm") Cancel
|
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm") Cancel
|
||||||
p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus")
|
p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus")
|
||||||
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
|
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
|
||||||
|
div.border-b.border-neutral-700.p-2.space-y-2
|
||||||
|
div.flex.items-center.justify-between
|
||||||
|
h2.text-sm.font-semibold Region export
|
||||||
|
button.px-2.py-1.rounded.text-xs(
|
||||||
|
x-bind:class="selectingRegion ? 'bg-amber-600' : 'bg-neutral-700'"
|
||||||
|
x-on:click="toggleSelectingRegion"
|
||||||
|
x-text="selectingRegion ? 'drag on map…' : '+ select region'")
|
||||||
|
p.text-xs.text-neutral-400(x-show="regionStatus" x-text="regionStatus")
|
||||||
|
div.flex.gap-1(x-show="regionBounds")
|
||||||
|
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(
|
||||||
|
x-bind:disabled="exporting" x-on:click="exportRegion"
|
||||||
|
x-text="exporting ? 'exporting…' : 'Export glTF'")
|
||||||
|
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelRegionSelection") Cancel
|
||||||
|
|
||||||
div.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;")
|
div.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;")
|
||||||
div.flex.items-center.justify-between
|
div.flex.items-center.justify-between
|
||||||
h2.text-sm.font-semibold Markers
|
h2.text-sm.font-semibold Markers
|
||||||
|
|||||||
Reference in New Issue
Block a user