Files
UnrealPrototyping/Tools/Orogen/js/unreal-ui.js
T
2026-09-25 17:02:24 +03:00

288 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(); },
};
}