288 lines
14 KiB
JavaScript
288 lines
14 KiB
JavaScript
// 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(); },
|
||
};
|
||
}
|