This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+287
View File
@@ -0,0 +1,287 @@
// The Unreal landscape export's panel.
//
// Built in JavaScript rather than written into index.html and import.html, because it is the same panel on
// both pages and two copies of a form with fourteen fields drift within a week.
//
// The panel's job is not to collect the numbers - it is to show what a number *buys* before two hours are
// spent on it. Every field re-plans on the keystroke and the readout underneath says how much ground the
// window holds, how many landscape components that is, how finely the planet is being sampled and what the
// flat reading costs at the window's edges. That is the same service `terrain plan` does for a legend and
// `--scout` does for a region: the expensive step should never be how you find out you set a number wrong.
import { planRegion, exportUnrealRegion, DEFAULTS } from './unreal-export.js';
const FIELDS = [
{ key: 'level', label: 'Level', type: 'text', width: 'wide',
hint: 'The tiles are named after its last segment: L_Region_x0_y0_Height.png' },
{ key: 'planet_circumference_km', label: 'Planet circumference', unit: 'km', type: 'number', step: 1,
hint: 'The sphere carries no metres. This is what turns the window\'s degrees into ground, and it is '
+ 'the number that decides how much land a window can hold. RawContent/World/Planet.json is where '
+ 'this project\'s own value lives; at 100 km round the whole globe is 3183 km2 of surface, so a '
+ 'window of a few hundred km2 is already a large piece of it.' },
{ key: 'centre.lon_deg', label: 'Centre longitude', unit: '°', type: 'number', step: 0.1 },
{ key: 'centre.lat_deg', label: 'Centre latitude', unit: '°', type: 'number', step: 0.1,
hint: 'Keep the window near the equator: an equirectangular reading stretches east-west by '
+ '1/cos(latitude), without bound at the poles.' },
{ key: 'tiles.columns', label: 'Tiles across', type: 'number', step: 1, min: 1 },
{ key: 'tiles.rows', label: 'Tiles down', type: 'number', step: 1, min: 1 },
{ key: 'tiles.vertices', label: 'Vertices a tile', type: 'select',
options: [[1021, '1021 (4 x 4 components)'], [2041, '2041 (8 x 8)'], [2551, '2551 (10 x 10)'],
[3061, '3061 (12 x 12)']],
hint: '255 * N + 1, so the engine gives each tile N x N components of 255 quads. The component count '
+ 'is what costs, not the vertex count.' },
{ key: 'quad_cm', label: 'Quad size', unit: 'cm', type: 'number', step: 1,
hint: 'Metres between vertices, in centimetres. 200 is a 2 m quad.' },
{ key: 'elevation_m.min', label: 'Elevation floor', unit: 'm', type: 'number', step: 1 },
{ key: 'elevation_m.max', label: 'Elevation ceiling', unit: 'm', type: 'number', step: 1,
hint: 'What 0 and 65535 mean. Too narrow clips; too wide only costs height precision, and the export '
+ 'reports both.' },
{ key: 'sea_scale', label: 'Sea scale', type: 'number', step: 0.01,
hint: 'Multiplies everything below sea level. Orogen\'s abyss is 5 km down on a whole-planet ramp, '
+ 'which over a small window is either a clipped plateau or an elevation range so wide the land '
+ 'loses its precision. Land is untouched.' },
{ key: 'source_metres_per_pixel', label: 'Sample spacing', unit: 'm', type: 'number', step: 0.5,
hint: 'How finely the planet is rendered before the tiles are cut from it. The mesh only resolves a '
+ 'couple of hundred metres, so anything below about 25 m here is already lossless.' },
];
const get = (obj, path) => path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
function set(obj, path, value) {
const parts = path.split('.');
const last = parts.pop();
const target = parts.reduce((o, k) => (o[k] = o[k] || {}), obj);
target[last] = value;
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
const STORE_KEY = 'orogen.unrealExport';
function loadSettings() {
const settings = clone(DEFAULTS);
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
for (const { key } of FIELDS) {
const v = get(saved, key);
if (v !== undefined && v !== null) set(settings, key, v);
}
} catch { /* a stale or blocked store is not a reason to refuse to open */ }
return settings;
}
function saveSettings(settings) {
try {
const out = {};
for (const { key } of FIELDS) set(out, key, get(settings, key));
localStorage.setItem(STORE_KEY, JSON.stringify(out));
} catch { /* private windows and blocked site data are fine; the panel just forgets */ }
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (v !== undefined && v !== null) node.setAttribute(k, v);
}
for (const child of children) if (child) node.appendChild(child);
return node;
}
let panel = null;
function build() {
const settings = loadSettings();
const overlay = el('div', { id: 'unrealOverlay', class: 'hidden' });
const card = el('div', { id: 'unrealCard' });
const close = el('button', { id: 'unrealClose', type: 'button', 'aria-label': 'Close', text: '×' });
card.appendChild(close);
card.appendChild(el('h3', { text: 'Export Unreal Landscape' }));
card.appendChild(el('p', { class: 'unreal-blurb', text:
'Renders a window of this planet straight into the tile set Unreal\'s landscape importer wants: a '
+ '16-bit height and three 8-bit weightmaps per tile, plus the Region.json that describes them.' }));
const grid = el('div', { class: 'unreal-grid' });
const inputs = {};
for (const field of FIELDS) {
const wrap = el('div', { class: 'cg' + (field.width === 'wide' ? ' unreal-wide' : '') });
const label = el('label', { text: field.label });
if (field.unit) label.appendChild(el('span', { class: 'v', text: field.unit }));
if (field.hint) label.setAttribute('title', field.hint);
wrap.appendChild(label);
let input;
if (field.type === 'select') {
input = el('select');
for (const [value, text] of field.options) input.appendChild(el('option', { value, text }));
} else if (field.type === 'number') {
// Deliberately not <input type="number">. That control formats and parses in the *browser's*
// locale, so on a machine whose decimal separator is a comma a sea scale of 0.17 is shown as
// "0,17" and `.value` comes back as the empty string - the setting silently becomes NaN and the
// export writes a whole tile set of nothing. A text box with inputmode="decimal" gets the same
// numeric keyboard on a phone and leaves the parsing here, where both separators are accepted.
input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', spellcheck: 'false' });
} else {
input = el('input', { type: field.type, autocomplete: 'off', spellcheck: 'false' });
}
input.value = get(settings, field.key);
input.addEventListener('input', () => {
const raw = input.value;
if (field.type === 'text') {
set(settings, field.key, raw);
} else if (field.type === 'select') {
set(settings, field.key, Number(raw));
} else {
const parsed = Number(String(raw).trim().replace(',', '.'));
input.classList.toggle('unreal-bad', raw.trim() !== '' && !Number.isFinite(parsed));
if (!Number.isFinite(parsed)) return; // keep the last good value while it is being typed
set(settings, field.key, parsed);
}
saveSettings(settings);
refresh();
});
inputs[field.key] = input;
wrap.appendChild(input);
grid.appendChild(wrap);
}
card.appendChild(grid);
const readout = el('div', { class: 'unreal-readout' });
card.appendChild(readout);
const status = el('div', { class: 'unreal-status' });
card.appendChild(status);
const cancel = el('button', { class: 'btn-ghost', type: 'button', text: 'Close' });
const go = el('button', { class: 'btn-primary', type: 'button', text: 'Choose folder & export' });
const actions = el('div', { class: 'export-actions' }, cancel, go);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
function refresh() {
let plan;
try {
plan = planRegion(settings);
} catch (err) {
readout.innerHTML = '';
readout.appendChild(el('div', { class: 'unreal-warn', text: err.message }));
go.disabled = true;
return null;
}
go.disabled = false;
const rows = [
['Ground', `${(plan.widthM / 1000).toFixed(2)} × ${(plan.heightM / 1000).toFixed(2)} km, `
+ `${plan.areaKm2.toFixed(0)} km²`],
['Tiles', `${plan.tileCount} of ${(plan.tileSideM / 1000).toFixed(2)} km, `
+ `${plan.tileCount * plan.componentsPerTile} components`],
['Files', `${plan.tileCount * 4} PNGs, about `
+ `${(plan.tileCount * (settings.tiles.vertices ** 2) * 5 / 1e9).toFixed(1)} GB uncompressed`],
['Window', `${(plan.lonSpan * 180 / Math.PI).toFixed(2)}° × `
+ `${(plan.latSpan * 180 / Math.PI).toFixed(2)}° of the planet`],
['Sampled at', `${plan.metresPerPixel.toFixed(2)} m a pixel `
+ `(${plan.rasterW} × ${plan.rasterH})`],
['E-W stretch', `${((plan.stretchNorth - 1) * 100).toFixed(1)}% north, `
+ `${((plan.stretchSouth - 1) * 100).toFixed(1)}% south`],
];
readout.innerHTML = '';
for (const [name, value] of rows) {
readout.appendChild(el('div', { class: 'unreal-row' },
el('span', { class: 'unreal-key', text: name }),
el('span', { class: 'unreal-val', text: value })));
}
for (const warning of plan.warnings) {
readout.appendChild(el('div', { class: 'unreal-warn', text: warning }));
}
return plan;
}
function setStatus(text, kind = '') {
status.textContent = text;
status.className = 'unreal-status' + (kind ? ' ' + kind : '');
}
go.addEventListener('click', async () => {
if (!window.showDirectoryPicker) {
setStatus('This browser cannot write to a folder. The export needs the File System Access API: '
+ 'use Chrome or Edge on a desktop.', 'unreal-warn');
return;
}
let dir;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite', id: 'orogen-unreal-region' });
} catch {
return; // the picker was dismissed, which is not an error
}
go.disabled = true;
cancel.disabled = true;
try {
const result = await exportUnrealRegion(settings, dir, (fraction, label) => {
setStatus(`${Math.round(fraction * 100)}% — ${label}`);
});
const { meta } = result;
const manifestNote = result.manifestName === 'Region.json'
? 'Region.json written beside RegionTiles/.'
: 'A Region.json was already there and was left alone — the new one is '
+ 'Region.generated.json. Rename it over the old one when you are ready.';
setStatus(`Done. ${result.plan.tileCount} tiles, ground `
+ `${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, `
+ `${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp used`
+ `${meta.clipped > 0 ? `, ${(meta.clipped * 100).toFixed(3)}% clipped — widen the elevation range` : ', nothing clipped'}`
+ `. ${manifestNote}`, meta.clipped > 0 ? 'unreal-warn' : 'unreal-ok');
} catch (err) {
setStatus(`Failed: ${err.message}`, 'unreal-warn');
throw err;
} finally {
go.disabled = false;
cancel.disabled = false;
}
});
const hide = () => overlay.classList.add('hidden');
close.addEventListener('click', hide);
cancel.addEventListener('click', hide);
overlay.addEventListener('click', e => { if (e.target === overlay) hide(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) hide();
});
panel = {
overlay,
open() { overlay.classList.remove('hidden'); refresh(); },
settings,
refresh,
};
refresh();
return panel;
}
export function openUnrealExport() {
(panel || build()).open();
}
/**
* Adds the button that opens the panel to whichever export card the page has. Called by both entry points;
* does nothing if the page has no export card or the button is already there.
*/
export function installUnrealExportButton() {
const actions = document.querySelector('#exportCard .export-actions');
if (!actions || document.getElementById('unrealExportBtn')) return;
const button = el('button', { id: 'unrealExportBtn', class: 'btn-ghost', type: 'button',
text: 'Unreal Landscape…' });
button.addEventListener('click', () => {
document.getElementById('exportOverlay').classList.add('hidden');
openUnrealExport();
});
actions.insertBefore(button, actions.firstChild);
// A handle for headless runs, the same way the painted import exposes window.orogenPainted.
window.orogenUnreal = {
plan: opts => planRegion({ ...(panel ? panel.settings : DEFAULTS), ...(opts || {}) }),
exportTo: (opts, dirHandle, onProgress) => exportUnrealRegion(opts, dirHandle, onProgress),
open: openUnrealExport,
get settings() { return panel ? panel.settings : loadSettings(); },
};
}