diff --git a/api/src/export.test.ts b/api/src/export.test.ts new file mode 100644 index 0000000..d7106d9 --- /dev/null +++ b/api/src/export.test.ts @@ -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); + }); +}); diff --git a/api/src/export.ts b/api/src/export.ts new file mode 100644 index 0000000..10556f7 --- /dev/null +++ b/api/src/export.ts @@ -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 { + 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 }; +} diff --git a/api/src/index.test.ts b/api/src/index.test.ts index f4d963c..00e6d62 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -5,6 +5,7 @@ import { meshPointers, tilePointers } from "./db/schema"; import { minio, TILE_BUCKET } from "./minio"; import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers"; import { storeLinkCode } from "./link"; +import { chunkSections } from "./db/schema"; // 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 @@ -315,3 +316,37 @@ describe("marker routes", () => { 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" }); + }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index 65937e2..f26fd2e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -7,6 +7,7 @@ import { chatGateway } from "./chat-gateway"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers"; +import { getExportSections } from "./export"; const MARKER_SHARE_ERROR_STATUS: Record = { not_found: 404, @@ -210,6 +211,24 @@ export const app = new Elysia() if (!result.ok) set.status = MARKER_SHARE_ERROR_STATUS[result.error] ?? 400; 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", { open: wsGateway.open, message: wsGateway.message, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 33d2140..f6c281c 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -17,6 +17,12 @@ const app = new Elysia() .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-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)); console.log(`[frontend] listening on :${app.server?.port}`); diff --git a/frontend/src/public/js/block-colors.js b/frontend/src/public/js/block-colors.js new file mode 100644 index 0000000..bac827c --- /dev/null +++ b/frontend/src/public/js/block-colors.js @@ -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; + } +} diff --git a/frontend/src/public/js/block-colors.test.ts b/frontend/src/public/js/block-colors.test.ts new file mode 100644 index 0000000..9ccb3ae --- /dev/null +++ b/frontend/src/public/js/block-colors.test.ts @@ -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]); +}); diff --git a/frontend/src/public/js/decode-section.js b/frontend/src/public/js/decode-section.js new file mode 100644 index 0000000..388a55c --- /dev/null +++ b/frontend/src/public/js/decode-section.js @@ -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; +} diff --git a/frontend/src/public/js/decode-section.test.ts b/frontend/src/public/js/decode-section.test.ts new file mode 100644 index 0000000..5f667c4 --- /dev/null +++ b/frontend/src/public/js/decode-section.test.ts @@ -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); +}); diff --git a/frontend/src/public/js/export-worker.js b/frontend/src/public/js/export-worker.js new file mode 100644 index 0000000..56f002c --- /dev/null +++ b/frontend/src/public/js/export-worker.js @@ -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) }); + } +}; diff --git a/frontend/src/public/js/gltf-export.js b/frontend/src/public/js/gltf-export.js new file mode 100644 index 0000000..fc597af --- /dev/null +++ b/frontend/src/public/js/gltf-export.js @@ -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; +} diff --git a/frontend/src/public/js/gltf-export.test.ts b/frontend/src/public/js/gltf-export.test.ts new file mode 100644 index 0000000..7bb7f6d --- /dev/null +++ b/frontend/src/public/js/gltf-export.test.ts @@ -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)); +}); diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index d2ac5ea..4b624e1 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -9,6 +9,7 @@ // — 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 { randomHexColor } from "./colors.js"; +import { snapToChunkBounds, chunkArea, MAX_EXPORT_CHUNKS } from "./region-select.js"; const DEFAULT_MARKER_HEIGHT = 60; @@ -44,6 +45,16 @@ function mapmapper() { markerPopup: null, 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() { const servers = await fetch("/api/servers").then((r) => r.json()); this.loading = false; @@ -53,6 +64,7 @@ function mapmapper() { this.leaflet.setView([0, 0], 0); this.markerLayer = L.layerGroup().addTo(this.leaflet); 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 // pane's DOM on every open/close (see openMarkerForm), which would break Alpine's $refs @@ -178,6 +190,109 @@ function mapmapper() { 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() { return `mcmapper_markers_${this.server.id}`; }, diff --git a/frontend/src/public/js/region-select.js b/frontend/src/public/js/region-select.js new file mode 100644 index 0000000..eab7496 --- /dev/null +++ b/frontend/src/public/js/region-select.js @@ -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); +} diff --git a/frontend/src/public/js/region-select.test.ts b/frontend/src/public/js/region-select.test.ts new file mode 100644 index 0000000..2454876 --- /dev/null +++ b/frontend/src/public/js/region-select.test.ts @@ -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); +}); diff --git a/frontend/src/public/js/voxel-mesh.js b/frontend/src/public/js/voxel-mesh.js new file mode 100644 index 0000000..f6959e5 --- /dev/null +++ b/frontend/src/public/js/voxel-mesh.js @@ -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); + } +} diff --git a/frontend/src/public/js/voxel-mesh.test.ts b/frontend/src/public/js/voxel-mesh.test.ts new file mode 100644 index 0000000..fbad4d3 --- /dev/null +++ b/frontend/src/public/js/voxel-mesh.test.ts @@ -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); +}); diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index 5e614b8..690100c 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -43,6 +43,20 @@ html(lang="en") 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") 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.flex.items-center.justify-between h2.text-sm.font-semibold Markers