// 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 . 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(); }, }; }