Tooling
This commit is contained in:
@@ -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(); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user