// Exporting a planet as Unreal landscape tiles. // // Unreal's landscape importer will not take either of Orogen's two existing exports. The preview is a // picture. The heightmap is one flat 8192 x 4096 PNG with no scale attached to it - the ramp is absolute, // so the shades mean metres, but nothing in the file says how wide the planet is, and without that there is // no answer to "how many metres is a pixel". What the importer wants is per-tile 16-bit greyscale PNGs at // exactly 255*N+1 vertices, one Landscape actor each, plus a separate 8-bit weightmap per paint layer, at // the sample spacing the game actually uses. This module writes that, straight out of the planet. // // Three things about it are the whole design. // // **The scale is an input, not a guess.** A sphere mesh has no metres on it; `planet_circumference_km` is // what turns the window's degrees into ground. It is asked for rather than derived because it cannot be // derived, and because it is the number that decides how much land a window holds. The report prints what // the choice bought, including the east-west stretch at the window's edges, so a window that does not fit // on the planet says so instead of quietly producing 30 km of ground on a 31.8 km world. // // **The window is sampled once, then cut.** The planet is rendered into one float raster over the window, // and every tile is resampled out of that raster by its *global* vertex position. This is what closes the // seams: a vertex column shared by two neighbours is computed from the same source coordinates twice and // comes out bit-identical, so nothing has to blend or stitch. Rendering each tile under its own camera // would have been one step shorter and would have put a rasteriser's floating-point luck on every seam, // where one 16-bit step is 11 cm of crack. // // **The intermediate is float and it is the window.** The old path read a whole-planet 16-bit PNG and spent // its resolution on the whole planet; this spends all of it on the window, and carries kilometres as // float32 rather than quantised to a -5000..6000 m ramp. Both matter less than they sound, because the // sphere mesh only resolves a couple of hundred metres and no amount of sampling invents what is not there // - the detail below that is still the Go generator's job. What they do buy is that nothing downstream has // to know a magic number. // // The resampler is the Catmull-Rom from generate_region_tiles.py, clamped to its two central taps for the // same reason: a plain cubic overshoots at a step, the steps here are coastlines, and unclamped every shore // gets a raised lip on the land side and a trench on the sea side. import { renderHeightWindowKm } from './unreal-render.js'; import { encodeGray16, encodeGray8 } from './png-write.js'; // One vertex of the neighbours on every side, sampled before the paint layers are derived and thrown away // after. The layers read slope, a one-sided difference at an array edge is not what the neighbouring tile // computes for that same vertex, and without this every tile boundary is a one-vertex line of different // paint. One vertex is all a central difference needs. const LAYER_MARGIN = 1; // Pixels of the intermediate raster rendered beyond the window on each side, so the resampler's outer taps // and the layer margin read real ground instead of a clamped edge. Catmull-Rom reaches two pixels. const RASTER_PAD = 4; export const LAYER_NAMES = ['Base_Layer', 'Layer_02', 'Layer_03']; // rock, meadow, high rock // The defaults are a window that actually fits on this project's planet, which is a smaller one than it // looks. Planet.json is 100 km round, so the whole globe is 3183 km2 of surface; the 936 km2 square the // numpy region pipeline cuts is 29% of it, and that is why that window reads as 110 degrees on a side and // stretches by three quarters at its edge. Four tiles by two is 20.4 x 10.2 km, 208 km2, and about 5% // stretch at the edge - a window a sphere this size can actually hold flat. export const DEFAULTS = { level: '/Game/Maps/L_Region', planet_circumference_km: 100, centre: { lon_deg: 0, lat_deg: 0 }, tiles: { columns: 4, rows: 2, vertices: 2551 }, quad_cm: 200, sea_level_m: 0, spawn_pad_m: 150, streaming_grid_components: 5, elevation_m: { min: -1024, max: 6144 }, sea_scale: 0.17, source_metres_per_pixel: 8, layers: { rock_slope_start: 0.55, rock_slope_full: 1.05, high_altitude_start_m: 1400, high_altitude_full_m: 2000, breakup_m: 18, breakup_cells: 24, breakup_seed: 7, }, }; // ── Geometry ──────────────────────────────────────────────────────────────────────────────────────── /** * Everything that follows from the options, with nothing rendered yet. Cheap, so the UI can call it on * every keystroke to show what a setting buys - this is the export's equivalent of `--scout`. */ export function planRegion(opts) { const o = mergeDefaults(opts); const vertices = o.tiles.vertices; if ((vertices - 1) % 255 !== 0) { throw new Error(`tiles.vertices must be 255 * N + 1 (2551, 2041, 1021 ...), not ${vertices}`); } const quadsPerTile = vertices - 1; const quadM = o.quad_cm / 100; const quadsX = o.tiles.columns * quadsPerTile; const quadsY = o.tiles.rows * quadsPerTile; const widthM = quadsX * quadM; const heightM = quadsY * quadM; const radiusM = o.planet_circumference_km * 1000 / (2 * Math.PI); const centreLat = o.centre.lat_deg * Math.PI / 180; const centreLon = o.centre.lon_deg * Math.PI / 180; const latSpan = heightM / radiusM; // Cosine-corrected at the centre latitude, so ground metres are right there rather than only at the // equator. A flat reading of an equirectangular map stretches east-west by 1/cos(latitude); this puts // the error at zero in the middle of the window and splits it between the north and south edges. const lonSpan = widthM / (radiusM * Math.cos(centreLat)); const latMin = centreLat - latSpan / 2; const latMax = centreLat + latSpan / 2; const innerW = Math.max(2, Math.round(widthM / o.source_metres_per_pixel) + 1); const innerH = Math.max(2, Math.round(heightM / o.source_metres_per_pixel) + 1); // What the flat reading costs at the window's edges: 1 at the centre latitude by construction. const stretchAt = lat => Math.cos(centreLat) / Math.cos(Math.max(-1.55, Math.min(1.55, lat))); const warnings = []; if (latSpan >= Math.PI) { warnings.push(`the window is ${(latSpan * 180 / Math.PI).toFixed(0)} degrees of latitude tall, which ` + `is more than the planet has. Raise planet_circumference_km or use fewer tiles.`); } if (lonSpan >= 2 * Math.PI) { warnings.push(`the window wraps the planet more than once at this latitude. Raise ` + `planet_circumference_km or use fewer tiles.`); } if (Math.abs(latMax) > 1.4 || Math.abs(latMin) > 1.4) { warnings.push('the window reaches past 80 degrees of latitude, where an equirectangular reading ' + 'stretches without bound. Move the centre towards the equator.'); } const worstStretch = Math.max(stretchAt(latMin), stretchAt(latMax)); if (worstStretch > 1.1 && warnings.length === 0) { warnings.push(`the ground is stretched east-west by up to ${((worstStretch - 1) * 100).toFixed(1)}% ` + 'at the window\'s edge. A window this tall on a planet this small cannot avoid it; a bigger ' + 'planet_circumference_km or fewer rows would.'); } return { options: o, quadsPerTile, quadM, quadsX, quadsY, widthM, heightM, areaKm2: widthM * heightM / 1e6, tileSideM: quadsPerTile * quadM, tileCount: o.tiles.columns * o.tiles.rows, componentsPerTile: (quadsPerTile / 255) ** 2, radiusM, centreLat, centreLon, latSpan, lonSpan, latMin, latMax, lonMin: centreLon - lonSpan / 2, lonMax: centreLon + lonSpan / 2, innerW, innerH, rasterW: innerW + 2 * RASTER_PAD, rasterH: innerH + 2 * RASTER_PAD, metresPerPixel: widthM / (innerW - 1), stretchNorth: stretchAt(latMax), stretchSouth: stretchAt(latMin), warnings, }; } function mergeDefaults(opts) { const o = { ...DEFAULTS, ...(opts || {}) }; o.centre = { ...DEFAULTS.centre, ...(opts && opts.centre) }; o.tiles = { ...DEFAULTS.tiles, ...(opts && opts.tiles) }; o.elevation_m = { ...DEFAULTS.elevation_m, ...(opts && opts.elevation_m) }; o.layers = { ...DEFAULTS.layers, ...(opts && opts.layers) }; return o; } // ── Resampling ────────────────────────────────────────────────────────────────────────────────────── /** Catmull-Rom weights for taps at -1, 0, +1, +2. */ function cubicWeights(t) { const t2 = t * t, t3 = t2 * t; return [ -0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2, ]; } /** * One separable pass of clamped Catmull-Rom along x: `src` is srcW wide and `rows` tall, `coords` are * float source columns. Held between the two central taps, which is what stops it ringing at a coastline. */ function resampleX(src, srcW, rows, coords) { const outW = coords.length; const out = new Float32Array(rows * outW); const clampIdx = i => (i < 0 ? 0 : i >= srcW ? srcW - 1 : i); for (let o = 0; o < outW; o++) { const c = coords[o]; const i = Math.floor(c); const w = cubicWeights(c - i); const i0 = clampIdx(i - 1), i1 = clampIdx(i), i2 = clampIdx(i + 1), i3 = clampIdx(i + 2); for (let r = 0; r < rows; r++) { const base = r * srcW; const a = src[base + i0], b = src[base + i1], c2 = src[base + i2], d = src[base + i3]; let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3]; const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b; out[r * outW + o] = v < lo ? lo : v > hi ? hi : v; } } return out; } /** The same along y: `src` is width wide and srcH tall, `coords` are float source rows. */ function resampleY(src, width, srcH, coords) { const outH = coords.length; const out = new Float32Array(outH * width); const clampIdx = j => (j < 0 ? 0 : j >= srcH ? srcH - 1 : j); for (let o = 0; o < outH; o++) { const c = coords[o]; const j = Math.floor(c); const w = cubicWeights(c - j); const r0 = clampIdx(j - 1) * width, r1 = clampIdx(j) * width; const r2 = clampIdx(j + 1) * width, r3 = clampIdx(j + 2) * width; const dst = o * width; for (let x = 0; x < width; x++) { const a = src[r0 + x], b = src[r1 + x], c2 = src[r2 + x], d = src[r3 + x]; let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3]; const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b; out[dst + x] = v < lo ? lo : v > hi ? hi : v; } } return out; } // ── Break-up noise ────────────────────────────────────────────────────────────────────────────────── // // Value-noise fBm on a periodic lattice, sampled at *global* window coordinates so a tile boundary is not // a discontinuity in the paint. The lattice values come from a hash of (seed, octave, cell) rather than // from a stream of random numbers, which is what makes a single tile computable without generating the // ones before it. This is the same shape as heightmap_noise.fbm_at but not the same numbers: numpy's PCG64 // stream cannot be reproduced here, and it does not need to be - the two pipelines are alternatives, never // mixed, and this noise only decides where a paint boundary wobbles. function hash01(seed, octave, cells, i, j) { let h = (seed ^ Math.imul(octave + 1, 0x9E3779B1)) >>> 0; h = Math.imul(h ^ Math.imul(i, 0x27D4EB2D), 0x165667B1); h = Math.imul(h ^ Math.imul(j, 0x85EBCA77), 0xC2B2AE3D); h = Math.imul(h ^ cells, 0x27D4EB2F); h ^= h >>> 15; h = Math.imul(h, 0x2545F491); h ^= h >>> 13; return (h >>> 0) / 4294967296; } const smoothstep = t => t * t * (3 - 2 * t); function fbmAt(u, v, seed, baseCells, octaves = 4, gain = 0.5) { let total = 0, amplitude = 1, cells = baseCells, norm = 0; for (let o = 0; o < octaves; o++) { const su = u * cells, sv = v * cells; const i0 = Math.floor(su), j0 = Math.floor(sv); const tu = smoothstep(su - i0), tv = smoothstep(sv - j0); const ia = ((i0 % cells) + cells) % cells, ja = ((j0 % cells) + cells) % cells; const ib = (ia + 1) % cells, jb = (ja + 1) % cells; const top = hash01(seed, o, cells, ia, ja) * (1 - tu) + hash01(seed, o, cells, ib, ja) * tu; const bottom = hash01(seed, o, cells, ia, jb) * (1 - tu) + hash01(seed, o, cells, ib, jb) * tu; total += (top * (1 - tv) + bottom * tv) * amplitude; norm += amplitude; amplitude *= gain; cells *= 2; } return total / norm; } // ── One tile ──────────────────────────────────────────────────────────────────────────────────────── /** Global vertex indices along one axis for a tile, with `margin` extra on each side. */ function tileVertices(plan, tile, margin) { const n = plan.options.tiles.vertices + 2 * margin; const out = new Float64Array(n); for (let k = 0; k < n; k++) out[k] = tile * plan.quadsPerTile + (k - margin); return out; } /** Global vertex indices to raster pixel coordinates. RASTER_PAD is where the window's first vertex sits. */ function toRasterCoords(plan, vertices, axis) { const inner = axis === 0 ? plan.innerW : plan.innerH; const quads = axis === 0 ? plan.quadsX : plan.quadsY; const out = new Float64Array(vertices.length); for (let k = 0; k < vertices.length; k++) { out[k] = RASTER_PAD + vertices[k] * (inner - 1) / quads; } return out; } /** One tile's height in metres, sampled out of the window raster by global position. */ function tileMetres(plan, raster, tx, ty, margin) { const vx = tileVertices(plan, tx, margin); const vy = tileVertices(plan, ty, margin); const sx = toRasterCoords(plan, vx, 0); const sy = toRasterCoords(plan, vy, 1); // Only the raster rows this tile reaches, so a tile costs a band rather than the whole window. const row0 = Math.max(0, Math.floor(sy[0]) - 1); const row1 = Math.min(plan.rasterH, Math.floor(sy[sy.length - 1]) + 3); const bandRows = row1 - row0; const band = raster.subarray(row0 * plan.rasterW, row1 * plan.rasterW); const afterX = resampleX(band, plan.rasterW, bandRows, sx); const shifted = new Float64Array(sy.length); for (let k = 0; k < sy.length; k++) shifted[k] = sy[k] - row0; const km = resampleY(afterX, sx.length, bandRows, shifted); const out = new Float32Array(km.length); const seaScale = plan.options.sea_scale; for (let k = 0; k < km.length; k++) { let m = km[k] * 1000; if (m < 0) m *= seaScale; out[k] = m; } return { metres: out, vx, vy, width: sx.length, height: sy.length }; } /** * The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is * computed from global position, so where it crosses a tile boundary the two tiles agree on it. */ function applySpawnPad(plan, tile, padMetres) { const radius = plan.options.spawn_pad_m; if (radius <= 0) return; const { metres, vx, vy, width, height } = tile; const quadM = plan.quadM; for (let j = 0; j < height; j++) { const dy = (vy[j] - plan.quadsY / 2) * quadM; for (let i = 0; i < width; i++) { const dx = (vx[i] - plan.quadsX / 2) * quadM; const dist = Math.hypot(dx, dy); const t = Math.max(0, Math.min(1, 1 - (dist - radius) / radius)); if (t <= 0) continue; const w = smoothstep(t); const at = j * width + i; metres[at] = metres[at] * (1 - w) + padMetres * w; } } } /** * The pack's three paint layers from height and slope: meadow everywhere, rock by slope, high rock by * altitude, with an fBm break-up so neither boundary is a contour line. There is no erosion on this path, * so unlike L_World's version there is no wear, curvature or deposit term. `tile` carries LAYER_MARGIN * vertices of its neighbours on every side; the layers are computed over the lot and the margin cropped at * the end, so the slope at a tile's edge is the central difference its neighbour computes there too. */ function deriveLayers(plan, tile) { const rules = plan.options.layers; const { metres, vx, vy, width, height } = tile; const quadM = plan.quadM; const margin = LAYER_MARGIN; const outW = width - 2 * margin, outH = height - 2 * margin; // Both axes divided by the *longer* one, so the noise stays square on the ground and, because neither // coordinate then exceeds 1, it never repeats across the window. const span = Math.max(plan.quadsX, plan.quadsY); const breakupM = rules.breakup_m; const slopeBreakupScale = breakupM ? 0.12 / breakupM : 0; const layers = {}; for (const name of LAYER_NAMES) layers[name] = new Uint8Array(outW * outH); for (let j = margin; j < height - margin; j++) { for (let i = margin; i < width - margin; i++) { const at = j * width + i; // Central differences, which is why the margin is here. const gx = (metres[at + 1] - metres[at - 1]) / (2 * quadM); const gy = (metres[at + width] - metres[at - width]) / (2 * quadM); const slope = Math.hypot(gx, gy); const noise = fbmAt(vx[i] / span, vy[j] / span, rules.breakup_seed | 0, rules.breakup_cells | 0); const breakup = (noise - 0.5) * 2 * breakupM; let rock = smoothstep(Math.max(0, Math.min(1, (slope + breakup * slopeBreakupScale - rules.rock_slope_start) / (rules.rock_slope_full - rules.rock_slope_start)))); let high = smoothstep(Math.max(0, Math.min(1, (metres[at] + breakup - rules.high_altitude_start_m) / (rules.high_altitude_full_m - rules.high_altitude_start_m)))); high = high * (1 - rock * 0.5); const meadow = Math.max(0, Math.min(1, 1 - rock - high)); const total = Math.max(meadow + rock + high, 1e-6); const out = (j - margin) * outW + (i - margin); layers.Base_Layer[out] = Math.round(rock / total * 255); layers.Layer_02[out] = Math.round(meadow / total * 255); layers.Layer_03[out] = Math.round(high / total * 255); } } return { layers, width: outW, height: outH }; } /** Metres to the 16-bit code the manifest's elevation_m range defines. */ function encodeHeights(plan, tile) { const { metres, width, height } = tile; const margin = LAYER_MARGIN; const outW = width - 2 * margin, outH = height - 2 * margin; const lo = plan.options.elevation_m.min, hi = plan.options.elevation_m.max; const span = hi - lo; const out = new Uint16Array(outW * outH); let clipped = 0; // Measured over the cropped tile, not over `metres`, which still carries the margin ring. On an outside // tile that ring is sampled beyond the window, so a range taken across it can quote ground that is not // in the world - and this number is what the manifest prints as "the ground came out X..Y m". let minM = Infinity, maxM = -Infinity; for (let j = 0; j < outH; j++) { for (let i = 0; i < outW; i++) { const m = metres[(j + margin) * width + (i + margin)]; if (m < minM) minM = m; if (m > maxM) maxM = m; if (m < lo || m > hi) clipped++; const bounded = m < lo ? lo : m > hi ? hi : m; const v = Math.round((bounded - lo) / span * 65535); out[j * outW + i] = v < 0 ? 0 : v > 65535 ? 65535 : v; } } return { heights: out, width: outW, height: outH, clipped: clipped / (outW * outH), minM, maxM }; } // ── Writing ───────────────────────────────────────────────────────────────────────────────────────── async function writeBlob(dirHandle, name, blob) { const handle = await dirHandle.getFileHandle(name, { create: true }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); } async function fileExists(dirHandle, name) { try { await dirHandle.getFileHandle(name); return true; } catch { return false; // NotFoundError, and anything else here means we cannot claim it is there } } function tileName(plan, tx, ty) { const level = plan.options.level.split('/').pop(); return `${level}_x${tx}_y${ty}`; } /** * The manifest, in the shape RawContent/World/Region.json has - so the Unreal side reads this export with * region_manifest.py exactly as it reads a hand-written one, and nothing downstream needs to know which * tool cut the tiles. * * The `source` block is kept, and made honest. generate_region_tiles.py is what reads it, and it will not * run against these tiles because they are already there; what it records is where the ground came from * and at what scale, which used to be a number somebody chose and wrote in a comment. */ function regionManifest(plan, meta) { const o = plan.options; const deg = r => +(r * 180 / Math.PI).toFixed(6); return { _comment: 'Written by World Orogen\'s Unreal landscape export. The tiles in RegionTiles/ are a ' + 'product of this file and the planet named below; generate_region_tiles.py is not in this ' + 'path and does not need to run. Every key is explained in Scripts/Authoring/region_manifest.py.', level: o.level, _comment_tiles: `${o.tiles.columns} x ${o.tiles.rows} landscapes of ${o.tiles.vertices} vertices. ` + `${plan.quadsPerTile} quads is ${plan.quadsPerTile / 255} x 255, so the engine gives each tile ` + `${plan.componentsPerTile} components of 255 quads: ${plan.tileCount * plan.componentsPerTile} ` + `over the window. Neighbours share their edge vertices, so the grid is ${plan.quadsX + 1} x ` + `${plan.quadsY + 1} vertices, ${(plan.widthM / 1000).toFixed(2)} x ` + `${(plan.heightM / 1000).toFixed(2)} km, ${plan.areaKm2.toFixed(0)} km2 of map.`, tiles: { ...o.tiles }, quad_cm: o.quad_cm, sea_level_m: o.sea_level_m, spawn_pad_m: o.spawn_pad_m, streaming_grid_components: o.streaming_grid_components, elevation_m: { ...o.elevation_m }, _comment_elevation: `The ground came out ${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, which ` + `uses ${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp at ` + `${((o.elevation_m.max - o.elevation_m.min) / 65535 * 100).toFixed(1)} cm a step. ` + `${meta.clipped === 0 ? 'Nothing clips.' : (meta.clipped * 100).toFixed(3) + '% of vertices clip - widen elevation_m.'}`, source: { _comment: 'Rendered directly out of World Orogen rather than cut from a PNG, so the scale is ' + 'recorded rather than chosen after the fact. metres_per_pixel is what one pixel of the ' + 'intermediate float raster was worth; the tiles themselves are at quad_cm.', kind: 'orogen_render', planet: meta.planetCode || null, planet_circumference_km: o.planet_circumference_km, centre: { lon_deg: o.centre.lon_deg, lat_deg: o.centre.lat_deg }, window_deg: { lon_min: deg(plan.lonMin), lon_max: deg(plan.lonMax), lat_min: deg(plan.latMin), lat_max: deg(plan.latMax), }, projection: 'equirectangular, cosine-corrected at the centre latitude', east_west_stretch: { north: +plan.stretchNorth.toFixed(4), south: +plan.stretchSouth.toFixed(4) }, metres_per_pixel: +plan.metresPerPixel.toFixed(6), // The window in pixels of the intermediate raster, so region_manifest.py's metres_per_pixel() // has the same shape of answer here as it does for a manifest that names a PNG. The raster is // rendered `pad` pixels wider on every side than the window, for the resampler's outer taps. window: { x: RASTER_PAD, y: RASTER_PAD, width: plan.innerW, height: plan.innerH }, raster: { width: plan.rasterW, height: plan.rasterH, pad: RASTER_PAD }, elevation_m: { min: -5000, max: 6000 }, sea_scale: o.sea_scale, exported: new Date().toISOString(), }, layers: { ...o.layers }, }; } /** * Renders the window and writes the whole tile set, plus Region.json, into a directory the user picks. * * `dirHandle` should be the project's RawContent/World: the tiles go into RegionTiles/ beneath it and the * manifest beside it, which is the layout create_region_world.py already reads. One tile is held in memory * at a time; the window raster is the only large allocation and it is float32 over the window, not the * planet. */ export async function exportUnrealRegion(opts, dirHandle, onProgress = () => {}) { const plan = planRegion(opts); const o = plan.options; onProgress(0.02, 'Sampling the planet'); const raster = await renderHeightWindowKm({ lonMin: plan.lonMin - RASTER_PAD * plan.lonSpan / (plan.innerW - 1), lonMax: plan.lonMax + RASTER_PAD * plan.lonSpan / (plan.innerW - 1), latMin: plan.latMin - RASTER_PAD * plan.latSpan / (plan.innerH - 1), latMax: plan.latMax + RASTER_PAD * plan.latSpan / (plan.innerH - 1), width: plan.rasterW, height: plan.rasterH, onProgress: (f, label) => onProgress(0.02 + f * 0.18, label), }); // The pad's height is read at the window's exact centre, once, so every tile it touches lifts to the // same level. Never below the sea: a pad in the water is not a place to stand. const cx = RASTER_PAD + (plan.innerW - 1) / 2; const cy = RASTER_PAD + (plan.innerH - 1) / 2; const centreKm = resampleY( resampleX(raster, plan.rasterW, plan.rasterH, Float64Array.from([cx])), 1, plan.rasterH, Float64Array.from([cy]))[0]; let padMetres = centreKm * 1000; if (padMetres < 0) padMetres *= o.sea_scale; padMetres = Math.max(padMetres, o.sea_level_m + 30); const tilesDir = await dirHandle.getDirectoryHandle('RegionTiles', { create: true }); const meta = { minM: Infinity, maxM: -Infinity, clipped: 0, planetCode: opts && opts.planet_code }; const total = plan.tileCount; let done = 0; for (let ty = 0; ty < o.tiles.rows; ty++) { for (let tx = 0; tx < o.tiles.columns; tx++) { const name = tileName(plan, tx, ty); onProgress(0.2 + done / total * 0.8, `${name} (${done + 1}/${total})`); const tile = tileMetres(plan, raster, tx, ty, LAYER_MARGIN); applySpawnPad(plan, tile, padMetres); const { heights, width, height, clipped, minM, maxM } = encodeHeights(plan, tile); if (minM < meta.minM) meta.minM = minM; if (maxM > meta.maxM) meta.maxM = maxM; meta.clipped += clipped / total; await writeBlob(tilesDir, `${name}_Height.png`, await encodeGray16(width, height, heights)); const derived = deriveLayers(plan, tile); for (const layerName of LAYER_NAMES) { await writeBlob(tilesDir, `${name}_${layerName}.png`, await encodeGray8(derived.width, derived.height, derived.layers[layerName])); } done++; await new Promise(r => setTimeout(r, 0)); } } meta.rampUsed = (meta.maxM - meta.minM) / (o.elevation_m.max - o.elevation_m.min); const manifest = regionManifest(plan, meta); // An existing Region.json is never replaced. The one in this project is hand-written and most of it is // commentary explaining why each number is what it is; a generated file would throw all of that away, // and the same rule already governs the terrain studio, which saves by patching a legend's *text* so its // reasoning survives. The tiles are the product here - the manifest is a description of what was cut - // so the generated one lands beside it under a name of its own and the caller is told which it got. const manifestName = (await fileExists(dirHandle, 'Region.json')) ? 'Region.generated.json' : 'Region.json'; await writeBlob(dirHandle, manifestName, new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' })); onProgress(1, 'Done'); return { plan, meta, manifest, manifestName }; }