Add panorama previews and repair beta export rendering

This commit is contained in:
2026-10-04 23:12:34 +05:00
parent 763cf8713c
commit aec0641f87
21 changed files with 802 additions and 60 deletions

View File

@@ -24,7 +24,7 @@ npm start
Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- <dataset UUID>` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).
`GET /api/v1/health` checks database connectivity. An empty catalog means no map has passed moderator review yet. Moderator access is controlled by `profiles.role` in Supabase; the project owner can set their profile's role to `moderator` using the database dashboard, then use `/moderation` on the website.
`GET /api/v1/health` checks database connectivity. An empty catalog means no map has passed moderator review yet. Moderator access is controlled by `profiles.role` in Supabase; follow [moderator setup and the first beta map](docs/moderation.md). Contributors and moderators can inspect all six panorama faces before submission or approval, and saved draft selections retain excluded locations.
## Install the capture tools

View File

@@ -1455,3 +1455,48 @@ pre {
font-size: 42px;
}
}
/* Contributor and moderator previews use the same projection as the game. */
.panorama-preview {
width: min(1100px, 96vw);
max-width: 96vw;
padding: 16px;
background: #0b1119;
color: #edf2ef;
border: 1px solid #58684b;
}
.panorama-preview::backdrop {
background: #000b;
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.preview-toolbar select {
background: #141e27;
color: inherit;
padding: 8px;
}
.preview-viewport {
position: relative;
height: min(65vh, 650px);
overflow: hidden;
}
.preview-status {
position: absolute;
inset: 0;
display: grid;
place-content: center;
background: #0b1119e8;
z-index: 5;
padding: 20px;
}
.preview-tile {
min-width: 0;
}
.preview-tile > label {
display: block;
position: relative;
}

View File

@@ -5,6 +5,7 @@ import { api } from "./StartGame";
import type { DatasetManifest } from "@/shared/protocol";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { PanoramaPreview } from "./PanoramaPreview";
type Draft = {
id: string;
name: string;
@@ -25,6 +26,7 @@ export function DraftEditor({ id }: { id: string }) {
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
@@ -55,6 +57,7 @@ export function DraftEditor({ id }: { id: string }) {
async function save(submit = false) {
setBusy(true);
setError("");
setMessage("");
try {
await api(
"datasets/" + id,
@@ -147,32 +150,47 @@ export function DraftEditor({ id }: { id: string }) {
</p>
<div className="preview-grid">
{draft.previews.map((p) => (
<label key={p.id}>
<input
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, p.id]
: s.filter((id) => id !== p.id),
)
}
/>
{p.image && (
<Image
src={p.image}
width={512}
height={512}
alt={`Viewpoint ${p.id}`}
unoptimized
<div className="preview-tile" key={p.id}>
<label>
<input
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable || busy}
aria-label={`Keep location ${p.id}`}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, p.id]
: s.filter((id) => id !== p.id),
)
}
/>
{p.image && (
<Image
src={p.image}
width={512}
height={512}
alt={`Viewpoint ${p.id}`}
unoptimized
/>
)}
<span>{p.id}</span>
</label>
{p.image && (
<button className="button full" onClick={() => setPreview(p.id)}>
Look around
</button>
)}
<span>{p.id}</span>
</label>
</div>
))}
</div>
{preview && (
<PanoramaPreview
endpoint={`datasets/${id}/panoramas`}
initialId={preview}
onClose={() => setPreview(null)}
/>
)}
{editable && (
<div className="inline-actions">
<button

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
type ReviewMap = {
id: string;
name: string;
@@ -20,6 +21,8 @@ export function Moderation() {
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
async function load() {
try {
const d = await api<{ maps: ReviewMap[]; reports: typeof reports }>(
@@ -27,8 +30,23 @@ export function Moderation() {
);
setMaps(d.maps);
setReports(d.reports);
setError("");
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
async function resolveReport(id: string) {
setBusy(true);
setError("");
try {
await api(`reports/${id}`, { state: "resolved" }, "PATCH");
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
useEffect(() => {
@@ -57,7 +75,8 @@ export function Moderation() {
{error}
</p>
)}
{!maps.length && !error && (
{loading && <p role="status">Loading review queue…</p>}
{!loading && !maps.length && !error && (
<p className="muted">The review queue is clear.</p>
)}
{maps.map((m) => (
@@ -87,6 +106,9 @@ export function Moderation() {
/>
</label>
<div className="inline-actions">
<button className="button" onClick={() => setPreview(m.id)}>
Inspect 360° locations
</button>
{m.state === "review" ? (
<>
<button
@@ -125,10 +147,23 @@ export function Moderation() {
<code>{r.dataset_id}</code>
<p>{r.reason}</p>
</div>
<button
className="button"
disabled={busy}
onClick={() => resolveReport(r.id)}
>
Resolve report
</button>
</article>
))}
</section>
)}
{preview && (
<PanoramaPreview
endpoint={`moderation/${preview}/panoramas`}
onClose={() => setPreview(null)}
/>
)}
</>
);
}

View File

@@ -82,6 +82,12 @@ export function Panorama({
);
};
let drag: { x: number; y: number } | null = null;
if (urls.length !== 6) {
callbacks.current.onError(
"This panorama is missing one or more of its six faces.",
);
return;
}
preloadFaces(urls)
.then((textures) => {
if (disposed) return;
@@ -134,11 +140,12 @@ export function Panorama({
element.addEventListener("wheel", wheel, { passive: false });
callbacks.current.onReady();
})
.catch((e) =>
.catch((e) => {
if (disposed) return;
callbacks.current.onError(
"Panorama could not load. " + (e instanceof Error ? e.message : ""),
),
);
);
});
return () => {
disposed = true;
cancelAnimationFrame(frame);
@@ -170,13 +177,13 @@ export function Panorama({
<>
<button
aria-label="Zoom in"
onClick={() => setFov((v) => Math.max(35, v - 10))}
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
>
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
onClick={() => setFov((v) => Math.min(100, v + 10))}
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
>
<Minus size={17} />
</button>

View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Panorama } from "./Panorama";
import { api } from "./StartGame";
export function PanoramaPreview({
endpoint,
initialId,
onClose,
}: {
endpoint: string;
initialId?: string;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [locations, setLocations] = useState<string[]>([]),
[selected, setSelected] = useState(initialId ?? ""),
[urls, setUrls] = useState<string[] | null>(null),
[ready, setReady] = useState(false),
[error, setError] = useState("");
useEffect(() => {
dialog.current?.showModal();
let disposed = false;
api<{ locations: string[] }>(endpoint)
.then((d) => {
if (disposed) return;
setLocations(d.locations);
setSelected((s) =>
d.locations.includes(s) ? s : (d.locations[0] ?? ""),
);
})
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [endpoint]);
useEffect(() => {
if (!selected) return;
let disposed = false;
setUrls(null);
setReady(false);
setError("");
api<{ faces: string[] }>(`${endpoint}/${selected}`)
.then((d) => {
if (!disposed) setUrls(d.faces);
})
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [endpoint, selected]);
const index = locations.indexOf(selected);
return (
<dialog
ref={dialog}
className="panorama-preview"
aria-label="Inspect panorama"
onCancel={onClose}
>
<div className="preview-toolbar">
<label>
Location{" "}
<select
aria-label="Preview location"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
{locations.map((p) => (
<option key={p}>{p}</option>
))}
</select>
</label>
<button
className="button"
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
Previous
</button>
<button
className="button"
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
Next
</button>
<button className="button" onClick={onClose}>
Close preview
</button>
</div>
<div className="preview-viewport">
{urls && (
<Panorama
urls={urls}
zoom
onReady={() => setReady(true)}
onError={setError}
/>
)}
{error ? (
<p role="alert" className="preview-status">
{error}
</p>
) : (
!ready && (
<p role="status" className="preview-status">
Loading panorama…
</p>
)
)}
</div>
<p className="muted">
Drag to look around · Scroll to zoom · Check for blocked views and
missing terrain.
</p>
</dialog>
);
}

View File

@@ -9,6 +9,7 @@ export async function api<T = Record<string, unknown>>(
method = body ? "POST" : "GET",
): Promise<T> {
const res = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000),
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,

42
docs/moderation.md Normal file
View File

@@ -0,0 +1,42 @@
# Moderator access
MineGeo checks `public.profiles.role` on the server for every moderation request. A contributor cannot change their own role through the website or Supabase's public API. No Discord role, JWT metadata, or additional plugin is required.
1. Have the moderator sign in to MineGeo and open their dashboard once.
2. In the **MineGeo** Supabase project, open **Authentication → Users** and copy that person's user UUID.
3. Open **Table Editor → public.profiles**, find the matching `id`, and set `role` to `moderator`. Keep the existing ID and display name.
4. The moderator can open `/moderation`, inspect each location in 360°, approve a submitted map, return it with feedback, unpublish a map, and resolve community reports. The role takes effect on the next request.
Alternatively, the project owner can run this in Supabase's SQL Editor, replacing the email with the intended account:
```sql
insert into public.profiles (id, role)
select id, 'moderator'
from auth.users
where lower(email) = lower('moderator@example.com')
on conflict (id) do update set role = excluded.role
returning id, role;
```
The query must return the intended user's ID. An empty result means the person has not signed in with that email yet. To remove moderator access, change that profile's role back to `contributor`.
Treat the website role separately from Supabase organization membership: moderators do not need access to your Supabase project or its secret key. The existing test moderator account is a verification fixture; it does not grant access to your personal account.
# First beta map
After deploying the updated website and worker:
1. Open the private draft in `/dashboard`. Use **Look around** to inspect its complete panorama, including terrain near the edge of the export.
2. Keep at least five usable locations. Saved exclusions can now be restored after a reload. Locations removed by older versions cannot be recovered unless the original capture manifest is available.
3. Save and submit the map for review.
4. A moderator opens `/moderation`, checks **Inspect 360° locations**, and approves the map. It then appears in **Play & maps** and visitors can play without signing in.
The rendering fix must run on existing images as well as new uploads. This session repaired the private draft `00ff0076-cae1-4a0d-ab81-9014833309f4` in Supabase while preserving its ten selected locations, metadata and original terrain upload. Refresh its preview to see the corrected textures.
For another completed private plugin draft with the same rendering issue:
```sh
npm run repair:panoramas -- <dataset UUID>
```
This command renders and verifies the original terrain first, then replaces the draft's private media. It refuses published revisions, maps under review, and active/failed processing jobs. For failed processing, use the existing `npm run reprocess -- <dataset UUID>` recovery workflow instead. Server-generated views retain simplified lighting and biome color approximations; entities, custom packs and dynamic sign text remain outside the capture scope.

View File

@@ -1,5 +1,24 @@
# Verification of the export/upload fixes
## First beta texture and usability fixes (October 4, 2026)
The renderer previously treated `waterlogged=false` on leaf blocks as water and tinted trees blue. Tint selection now uses the block identifier and exported biome. Animated vanilla sprite sheets use a single frame, grass overlays avoid depth flickering, partial block models use their derived face UVs, and element rotations also rotate lighting normals.
Verification completed against the configured MineGeo project:
- All 31 unit tests and TypeScript checking passed, including blue-leaf tint, sprite frame selection, restoring excluded locations after reload, and invalid selections.
- The Next.js 16.3.8 production build passed.
- Both the small renderer fixture and cached real Paper export passed, with no unsupported models.
- The user's 2,651,888-block private export rendered 8,838,392 faces and all ten selected panoramas with no unsupported models; its corrected map/previews were uploaded to Supabase. Windows/AMD geometry construction took about 8.8 seconds; the whole render took about 23.7 seconds, excluding download/upload. These measurements do not establish macOS performance.
- A temporary fixture verified private-draft denial, draft selection persistence/restoration, interactive 360 previews, moderator review and approval, report resolution, and a guest game through all five rounds to results with real Supabase timers/scores. A different browser could not read the guest session. No uncaught browser errors were observed. The fixture was unpublished, media removed, and database rows cleaned up afterward.
- After the user's browser preference was updated, remaining UI checks used Codex's built-in Browser. The earlier standalone test browser and its terminal window were closed.
Local screenshots and the regression report are retained in `.cache/beta-verification`. The repaired export's report and images are retained in `.cache/panorama-repair/00ff0076-cae1-4a0d-ab81-9014833309f4`. The user's map remains a private draft pending their moderator workflow. The website UI changes need deployment; no new database migration is required.
The Supabase security advisor reported no application schema warnings; tables without client policies are intentionally server-only. Its separate leaked-password-protection warning concerns the project's authentication setting; see [Supabase's password protection guidance](https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection). The site uses magic-link sign-in; the beta test used an existing password-based fixture account.
---
The following checks passed on Windows with Java 21 and Minecraft/Paper 1.21.11:
- A temporary Paper server with no connected player exported 16 chunks / 10,112 retained blocks and five valid surface viewpoints.

View File

@@ -6,6 +6,7 @@ import { admin, required, user } from "./supabase";
import { datasetSchema, faces, difficulties } from "../shared/protocol";
import { mediaUrl } from "./catalog";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { selectDraftLocations } from "../shared/draft-selection";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid();
@@ -355,6 +356,21 @@ export async function handle(request: NextRequest, path: string[]) {
});
}
const d = await ownedDataset(request, path[1]);
if (method === "GET" && path[2] === "panoramas") {
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
);
const manifest = datasetSchema.parse(input.manifest);
return panoramaResponse(
d.id,
manifest.availableViewpoints ?? manifest.viewpoints,
path[3],
);
}
if (method === "GET") {
const input = required(
await client
@@ -376,12 +392,14 @@ export async function handle(request: NextRequest, path: string[]) {
manifest,
map: await mediaUrl(d.map_path),
previews: await Promise.all(
manifest.viewpoints.map(async (p: { id: string }) => ({
id: p.id,
image: d.map_path
? await mediaUrl(`${d.id}/${p.id}/preview.webp`)
: null,
})),
(manifest.availableViewpoints ?? manifest.viewpoints).map(
async (p: { id: string }) => ({
id: p.id,
image: d.map_path
? await mediaUrl(`${d.id}/${p.id}/preview.webp`)
: null,
}),
),
),
});
}
@@ -408,9 +426,7 @@ export async function handle(request: NextRequest, path: string[]) {
description: body.description,
};
if (body.selectedIds)
manifest.viewpoints = manifest.viewpoints.filter(
(p: { id: string }) => body.selectedIds!.includes(p.id),
);
manifest = selectDraftLocations(manifest, body.selectedIds);
manifest = datasetSchema.parse(manifest);
required(
await client
@@ -427,8 +443,10 @@ export async function handle(request: NextRequest, path: string[]) {
name: body.name,
description: body.description,
panorama_count: manifest.viewpoints.length,
cover_path: `${d.id}/${manifest.viewpoints[0].id}/preview.webp`,
state: "draft",
rejection_reason: null,
updated_at: new Date().toISOString(),
})
.eq("id", d.id)
.select("id")
@@ -596,6 +614,29 @@ export async function handle(request: NextRequest, path: string[]) {
}
if (path[0] === "moderation") {
const reviewer = await moderator(request);
if (method === "GET" && path[2] === "panoramas") {
const d = required(
await client
.from("datasets")
.select("id,state")
.eq("id", uuid(path[1]))
.single(),
);
if (!["review", "published"].includes(d.state))
throw new Error("Map is not awaiting review or published");
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
);
return panoramaResponse(
d.id,
datasetSchema.parse(input.manifest).viewpoints,
path[3],
);
}
if (method === "GET") {
const maps = required(
await client
@@ -657,6 +698,21 @@ export async function handle(request: NextRequest, path: string[]) {
);
return NextResponse.json({ ok: true });
}
if (path[0] === "reports" && method === "PATCH") {
await moderator(request);
const b = z
.object({ state: z.literal("resolved") })
.parse(await jsonBody(request));
required(
await client
.from("reports")
.update({ state: b.state })
.eq("id", uuid(path[1]))
.select("id")
.single(),
);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Not found" }, { status: 404 });
} catch (error) {
const message =
@@ -675,3 +731,22 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ error: message }, { status });
}
}
async function panoramaResponse(
datasetId: string,
locations: { id: string }[],
pointId?: string,
) {
if (!pointId)
return NextResponse.json({ locations: locations.map((p) => p.id) });
if (!locations.some((p) => p.id === pointId))
throw new Error("Location not found");
return NextResponse.json(
{
faces: await Promise.all(
faces.map((f) => mediaUrl(`${datasetId}/${pointId}/${f}.webp`)),
),
},
{ headers: { "Cache-Control": "no-store" } },
);
}

View File

@@ -11,6 +11,7 @@
"start": "node scripts/supervise.mjs production",
"setup": "node scripts/setup.mjs",
"reprocess": "node scripts/reprocess.mjs",
"repair:panoramas": "node scripts/repair-panoramas.mjs",
"worker": "tsx worker/main.ts",
"test": "tsx --test tests/*.test.ts",
"test:renderer": "node scripts/test-renderer.mjs",

35
renderer/block-colors.mjs Normal file
View File

@@ -0,0 +1,35 @@
// Properties such as waterlogged=false must never determine the block's tint.
export function blockTint(state, biome = "minecraft:plains") {
const name = state.split("[")[0].replace(/^minecraft:/, "");
if (name === "water") return biome.includes("swamp") ? "#617b64" : "#3f76e4";
if (name === "spruce_leaves") return "#619961";
if (name === "birch_leaves") return "#80a755";
if (name === "redstone_wire") {
const power = Number(state.match(/power=(\d+)/)?.[1] || 0);
return power ? "#ff3300" : "#4c0000";
}
const leaves = name.endsWith("_leaves") || name === "vine";
if (biome.includes("swamp")) return "#6a7039";
if (/savanna|badlands|desert/.test(biome))
return leaves ? "#aea42a" : "#bfb755";
if (/snow|frozen|taiga/.test(biome)) return leaves ? "#60a17b" : "#80b497";
if (/jungle/.test(biome)) return leaves ? "#30bb0b" : "#59c93c";
return leaves ? "#77ab2f" : "#91bd59";
}
export function firstTextureFrame(width, height, animation) {
if (!animation) return { x: 0, y: 0, width, height };
const w =
animation.width || (animation.height ? width : Math.min(width, height));
const h =
animation.height || (animation.width ? height : Math.min(width, height));
const first = animation.frames?.[0] ?? 0;
const index = typeof first === "number" ? first : first.index;
const columns = Math.floor(width / w);
return {
x: (index % columns) * w,
y: Math.floor(index / columns) * h,
width: w,
height: h,
};
}

View File

@@ -1,5 +1,6 @@
import * as THREE from "/three.js";
import { buildTerrain } from "./terrain-mesh.js";
import { firstTextureFrame } from "./block-colors.mjs";
// This renderer consumes snapshots, never connects to a Minecraft server.
const renderer = new THREE.WebGLRenderer({
antialias: true,
@@ -70,7 +71,39 @@ async function texture(ref) {
ref,
loader
.loadAsync(resourcePath(ref, "textures") + ".png")
.then((t) => {
.then(async (t) => {
// Vanilla animated textures are sprite sheets, not stretched faces.
const metadata =
t.image.width !== t.image.height
? await fetch(resourcePath(ref, "textures") + ".png.mcmeta").then(
(r) => (r.ok ? r.json() : null),
)
: null;
if (metadata?.animation) {
const frame = firstTextureFrame(
t.image.width,
t.image.height,
metadata.animation,
);
const canvas = document.createElement("canvas");
canvas.width = frame.width;
canvas.height = frame.height;
canvas
.getContext("2d")
.drawImage(
t.image,
frame.x,
frame.y,
frame.width,
frame.height,
0,
0,
frame.width,
frame.height,
);
t.image = canvas;
t.needsUpdate = true;
}
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;

View File

@@ -1,5 +1,6 @@
import * as THREE from "/three.js";
import { OpaqueIndex, QuadBuffer } from "./mesh-buffers.mjs";
import { blockTint } from "./block-colors.mjs";
const nonOpaque =
/air|water|lava|leaves|glass|grass(?!_block)|fern|flower|sapling|torch|fence|wall|slab|stairs|door|pane|rail|vine|carpet/;
@@ -16,6 +17,7 @@ async function compileState(
texture,
materials,
unsupported,
biome,
) {
const faces = [];
for (const { m, v = {} } of variants) {
@@ -40,13 +42,7 @@ async function compileState(
continue;
}
const tint =
f.tintindex !== undefined
? state.includes("water")
? "#5b9eb5"
: state.includes("leaves")
? "#7ba54c"
: "#90b650"
: "#ffffff";
f.tintindex !== undefined ? blockTint(state, biome) : "#ffffff";
const key = ref + "|" + tint;
let material = materials.get(key);
if (!material) {
@@ -60,14 +56,30 @@ async function compileState(
alphaTest: 0.3,
transparent: water,
opacity: water ? 0.8 : 1,
// Grass side overlays share a plane with the dirt face.
polygonOffset: ref?.includes("grass_block_side_overlay"),
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
}),
};
materials.set(key, material);
}
const normal = new THREE.Vector3(...fd.normal).applyMatrix4(rotation);
if (e.rotation) {
const axis = new THREE.Vector3(
e.rotation.axis === "x" ? 1 : 0,
e.rotation.axis === "y" ? 1 : 0,
e.rotation.axis === "z" ? 1 : 0,
);
normal.copy(
new THREE.Vector3(...fd.normal)
.applyAxisAngle(axis, (e.rotation.angle * Math.PI) / 180)
.applyMatrix4(rotation),
);
}
const positions = new Float64Array(12),
uvs = new Float32Array(8);
const uv = f.uv || [0, 0, 16, 16];
const uv = f.uv || defaultUV(dir, e.from, e.to);
const coords = [
[uv[0] / 16, 1 - uv[3] / 16],
[uv[2] / 16, 1 - uv[3] / 16],
@@ -143,6 +155,17 @@ function fluidModel(state, faceData) {
};
}
function defaultUV(dir, from, to) {
const [x, y, z] = from,
[X, Y, Z] = to;
if (dir === "up") return [x, z, X, Z];
if (dir === "down") return [x, 16 - Z, X, 16 - z];
if (dir === "north") return [16 - X, 16 - Y, 16 - x, 16 - y];
if (dir === "south") return [x, 16 - Y, X, 16 - y];
if (dir === "west") return [z, 16 - Y, Z, 16 - y];
return [16 - Z, 16 - Y, 16 - z, 16 - y];
}
export async function buildTerrain(
snapshot,
scene,
@@ -176,8 +199,14 @@ export async function buildTerrain(
const b = snapshot.blocks[i];
if (b[1] < minY) minY = b[1];
if (b[1] > maxY) maxY = b[1];
if (!states.has(b[3]))
states.set(b[3], { opaque: !nonOpaque.test(b[3]), faces: null });
const key = b[3] + "|" + (b[4] || "minecraft:plains");
if (!states.has(key))
states.set(key, {
state: b[3],
biome: b[4],
opaque: !nonOpaque.test(b[3].split("[")[0]),
faces: null,
});
if (i % 16384 === 0)
await progress(
25 + (2 * i) / snapshot.blocks.length,
@@ -187,7 +216,8 @@ export async function buildTerrain(
const occupancy = new OpaqueIndex(snapshot.bounds, minY, maxY);
for (let i = 0; i < snapshot.blocks.length; i++) {
const b = snapshot.blocks[i];
if (states.get(b[3]).opaque) occupancy.add(b[0], b[1], b[2]);
if (states.get(b[3] + "|" + (b[4] || "minecraft:plains")).opaque)
occupancy.add(b[0], b[1], b[2]);
if (i % 16384 === 0)
await progress(
27 + (3 * i) / snapshot.blocks.length,
@@ -203,7 +233,8 @@ export async function buildTerrain(
for (let i = 0; i < entries.length; i += 8) {
const end = Math.min(i + 8, entries.length);
await Promise.all(
entries.slice(i, end).map(async ([state, entry]) => {
entries.slice(i, end).map(async ([, entry]) => {
const state = entry.state;
const variants = fluid.test(state)
? [fluidModel(state, faceData)]
: await stateModels(state);
@@ -215,6 +246,7 @@ export async function buildTerrain(
texture,
materials,
unsupported,
entry.biome,
);
completedModels++;
}),
@@ -261,7 +293,7 @@ export async function buildTerrain(
x = b[0],
y = b[1],
z = b[2];
const entry = states.get(b[3]);
const entry = states.get(b[3] + "|" + (b[4] || "minecraft:plains"));
const region =
Math.floor(x / 64) -
originX +

View File

@@ -18,7 +18,7 @@ async function assetsReady() {
try {
const marker = JSON.parse(await readFile(completionMarker, "utf8"));
if (
marker.format !== "minegeo-assets/v1" ||
marker.format !== "minegeo-assets/v2" ||
marker.minecraftVersion !== "1.21.11" ||
!marker.files
)
@@ -89,7 +89,7 @@ export async function prepareAssets() {
filter: (f) =>
/^assets\/minecraft\/(models|blockstates|textures\/block)\//.test(
f.name,
) && /\.(json|png)$/.test(f.name),
) && /\.(json|png|mcmeta)$/.test(f.name),
});
for (const [name, bytes] of Object.entries(entries)) {
const path = `${assetsRoot}/${name}`;
@@ -100,7 +100,7 @@ export async function prepareAssets() {
await writeFile(
completionMarker,
JSON.stringify({
format: "minegeo-assets/v1",
format: "minegeo-assets/v2",
minecraftVersion: "1.21.11",
clientSha1: version.downloads.client.sha1,
files: Object.keys(entries).length,

View File

@@ -0,0 +1,147 @@
import { createClient } from "@supabase/supabase-js";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
import { renderSnapshot } from "../renderer/render.mjs";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const id = process.argv[2];
if (
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
id || "",
)
)
throw new Error(
"Usage: npm run repair:panoramas -- <private plugin draft UUID>",
);
const client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
function check(r) {
if (r.error) throw new Error(r.error.message);
return r.data;
}
const dataset = check(
await client.from("datasets").select("*").eq("id", id).single(),
);
if (
dataset.source !== "plugin" ||
dataset.revision_id ||
!["draft", "rejected"].includes(dataset.state)
)
throw new Error(
"Only a private, unpublished plugin draft can be repaired. Withdraw review or recover failed processing first.",
);
const job = check(
await client
.from("upload_jobs")
.select("state")
.eq("dataset_id", id)
.single(),
);
if (job.state !== "complete")
throw new Error("Wait for processing to finish first.");
const input = check(
await client
.from("dataset_inputs")
.select("manifest,validated")
.eq("dataset_id", id)
.single(),
);
if (!input.validated) throw new Error("The export must be validated first.");
const asset = input.manifest.assets.find((a) => a.path === "terrain.json.gz");
if (!asset) throw new Error("Missing original terrain export");
const raw = check(
await client.storage
.from("minegeo-raw")
.download(`${dataset.owner_id}/${id}/${asset.path}`),
);
const bytes = Buffer.from(await raw.arrayBuffer());
if (
bytes.length !== asset.bytes ||
createHash("sha256").update(bytes).digest("hex") !== asset.sha256
)
throw new Error("Original terrain checksum mismatch");
const output = `.cache/panorama-repair/${id}`;
await mkdir(output, { recursive: true });
const terrain = JSON.parse(
gunzipSync(bytes, { maxOutputLength: 256 * 1024 * 1024 }).toString(),
);
await renderSnapshot(
terrain,
input.manifest.availableViewpoints ?? input.manifest.viewpoints,
output,
{
size: Number(process.env.MINEGEO_FACE_SIZE || 1024),
onProgress: async ({ percent, message }) =>
console.log(`${Math.floor(percent)}% ${message}`),
},
);
// Render first, then briefly lock the draft while replacing its private images.
// Its complete job stays complete, so another worker cannot pick it up.
const locked = check(
await client
.from("datasets")
.update({ state: "processing", updated_at: new Date().toISOString() })
.eq("id", id)
.eq("state", dataset.state)
.eq("updated_at", dataset.updated_at)
.is("revision_id", null)
.select("id"),
);
if (locked.length !== 1)
throw new Error(
"Draft changed during rendering; images were retained locally but nothing was replaced.",
);
try {
for (const entry of (await readdir(output, { recursive: true })).filter((p) =>
p.endsWith(".webp"),
)) {
check(
await client.storage
.from("minegeo-media")
.upload(
`${id}/${entry.replaceAll("\\", "/")}`,
await readFile(`${output}/${entry}`),
{ upsert: true, contentType: "image/webp", cacheControl: "60" },
),
);
}
check(
await client
.from("datasets")
.update({ state: dataset.state, updated_at: new Date().toISOString() })
.eq("id", id)
.eq("state", "processing")
.is("revision_id", null)
.select("id")
.single(),
);
await writeFile(
`${output}/repair.json`,
JSON.stringify({
datasetId: id,
repaired: true,
at: new Date().toISOString(),
}),
);
console.log(
`Repaired draft ${id}. Reload its dashboard preview; no Minecraft export or new upload is needed.`,
);
} catch (error) {
await client
.from("datasets")
.update({
state: "failed",
rejection_reason:
"Image repair interrupted. Reprocess the retained raw upload.",
updated_at: new Date().toISOString(),
})
.eq("id", id)
.eq("state", "processing")
.is("revision_id", null);
throw error;
}

16
shared/draft-selection.ts Normal file
View File

@@ -0,0 +1,16 @@
import { datasetSchema, type DatasetManifest } from "./protocol";
export function selectDraftLocations(manifest: DatasetManifest, ids: string[]) {
const available = manifest.availableViewpoints ?? manifest.viewpoints;
const selected = new Set(ids);
if (
selected.size !== ids.length ||
ids.some((id) => !available.some((p) => p.id === id))
)
throw new Error("Choose unique locations from this capture");
return datasetSchema.parse({
...manifest,
availableViewpoints: available,
viewpoints: available.filter((p) => selected.has(p.id)),
});
}

View File

@@ -38,6 +38,14 @@ export const datasetSchema = z
)
.min(5)
.max(100),
// Retain the capture's locations while viewpoints holds the saved selection.
availableViewpoints: z
.array(
pointSchema.extend({ id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }),
)
.min(5)
.max(100)
.optional(),
assets: z
.array(
z.object({
@@ -62,7 +70,7 @@ export const datasetSchema = z
? ["terrain.json.gz"]
: [
"map.png",
...d.viewpoints.flatMap((point) =>
...(d.availableViewpoints ?? d.viewpoints).flatMap((point) =>
faces.map((face) => `${point.id}/${face}.png`),
),
];
@@ -75,9 +83,20 @@ export const datasetSchema = z
}
if (new Set(d.viewpoints.map((p) => p.id)).size !== d.viewpoints.length)
ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" });
if (d.availableViewpoints) {
const ids = new Set(d.availableViewpoints.map((p) => p.id));
if (
ids.size !== d.availableViewpoints.length ||
d.viewpoints.some((p) => !ids.has(p.id))
)
ctx.addIssue({
code: "custom",
message: "Selection must use unique captured locations",
});
}
if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length)
ctx.addIssue({ code: "custom", message: "Asset paths must be unique" });
for (const p of d.viewpoints)
for (const p of [...d.viewpoints, ...(d.availableViewpoints ?? [])])
if (
p.x < d.bounds.minX ||
p.x > d.bounds.maxX ||

View File

@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { blockTint, firstTextureFrame } from "../renderer/block-colors.mjs";
import { datasetSchema } from "../shared/protocol";
import { selectDraftLocations } from "../shared/draft-selection";
test("waterlogged properties do not turn leaves, grass or vines blue", () => {
for (const name of ["acacia_leaves", "oak_leaves", "vine", "short_grass"]) {
assert.equal(
blockTint(`minecraft:${name}[waterlogged=false]`),
blockTint(`minecraft:${name}`),
);
assert.notEqual(
blockTint(`minecraft:${name}[waterlogged=true]`),
blockTint("minecraft:water"),
);
}
assert.equal(
blockTint("minecraft:birch_leaves[waterlogged=false]"),
"#80a755",
);
assert.notEqual(
blockTint("minecraft:acacia_leaves", "minecraft:savanna"),
blockTint("minecraft:acacia_leaves", "minecraft:jungle"),
);
});
test("animated textures use one frame, including explicit sprite frame order", () => {
assert.deepEqual(firstTextureFrame(16, 512, {}), {
x: 0,
y: 0,
width: 16,
height: 16,
});
assert.deepEqual(
firstTextureFrame(32, 64, {
width: 16,
height: 16,
frames: [{ index: 3, time: 4 }],
}),
{ x: 16, y: 16, width: 16, height: 16 },
);
assert.deepEqual(firstTextureFrame(32, 16, null), {
x: 0,
y: 0,
width: 32,
height: 16,
});
});
const capture = datasetSchema.parse({
format: "minegeo-dataset/v1",
minecraftVersion: "1.21.11",
dimension: "minecraft:overworld",
source: "plugin",
name: "Selection regression",
serverName: "Test",
north: "-z",
bounds: { minX: 0, minZ: 0, maxX: 16, maxZ: 16 },
viewpoints: Array.from({ length: 6 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 66,
z: i,
})),
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
});
test("saving and reloading a selection retains deselected capture locations", () => {
const saved = selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "p4"]);
const reloaded = datasetSchema.parse(JSON.parse(JSON.stringify(saved)));
assert.equal(reloaded.viewpoints.length, 5);
assert.equal(reloaded.availableViewpoints?.length, 6);
const restored = selectDraftLocations(reloaded, [
"p0",
"p1",
"p2",
"p3",
"p5",
]);
assert.equal(restored.viewpoints[4].id, "p5");
assert.equal(restored.availableViewpoints?.length, 6);
});
test("unknown, duplicate or too few locations cannot enter a published selection", () => {
assert.throws(() =>
selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "missing"]),
);
assert.throws(() =>
selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "p3"]),
);
assert.throws(() => selectDraftLocations(capture, ["p0", "p1", "p2", "p3"]));
});

View File

@@ -23,6 +23,9 @@ const files = {
"textures/block/dirt.png": png,
"textures/block/stone.png": png,
"textures/block/water_still.png": png,
"textures/block/water_still.png.mcmeta": Buffer.from(
'{"animation":{"frametime":2}}',
),
};
const jar = zipSync(
Object.fromEntries(

View File

@@ -179,7 +179,7 @@ async function processJob(job: {
);
const report = await renderSnapshot(
terrain,
manifest.viewpoints,
manifest.availableViewpoints ?? manifest.viewpoints,
output,
{
size: Number(process.env.MINEGEO_FACE_SIZE || 1024),
@@ -196,7 +196,7 @@ async function processJob(job: {
.webp({ quality: 90 })
.toFile(`${output}/map.webp`);
let processedFaces = 0;
for (const point of manifest.viewpoints) {
for (const point of manifest.availableViewpoints ?? manifest.viewpoints) {
await mkdir(`${output}/${point.id}`, { recursive: true });
for (const face of faces) {
const bytes = await readFile(
@@ -257,7 +257,8 @@ async function processJob(job: {
{
contentType: "image/webp",
upsert: true,
cacheControl: "31536000",
// Drafts can be repaired before publication.
cacheControl: "60",
},
),
);