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 { 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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user