1900 lines
90 KiB
HTML
1900 lines
90 KiB
HTML
<!doctype html>
|
||
<meta charset="utf-8">
|
||
<title>terrain studio</title>
|
||
<style>
|
||
:root {
|
||
/* The stage keeps its dark chrome: the toast and the cursor readout float over the world map,
|
||
not over the rail, and a pale plate there would hide what it is describing. */
|
||
--bg: #14181d; --line: #2b323b; --ink: #d8dee6; --dim: #8b95a1; --ok: #6fbf73;
|
||
/* The rail is a minihompy column. Pastel surfaces, one accent, and dark ink on every reading. */
|
||
--cream: #fff7ee; --paper: #ffffff; --mink: #3d2b34; --mink2: #6b5560; --mline: #dcc2cd;
|
||
/* --accd carries text, so it is the one pastel that is not pastel: 5.6:1 on white, 5.3:1 on cream. */
|
||
--acc: #ffa8bd; --accd: #b8365a; --accl: #fff2f5; --lemon: #ffe9a8;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
||
font: 13px/1.45 ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
|
||
#app { display: grid; grid-template-columns: 300px 1fr; height: 100%; }
|
||
/* The void is a CSS gradient rather than a fill on the canvas: a flat colour reads as a hole cut in the
|
||
page and a shallow vertical grade reads as space, and putting it here is what lets the fragment shader
|
||
simply drop every pixel that is not world. touch-action keeps a pen or a trackpad from turning a stroke
|
||
into a browser gesture. */
|
||
#stage { position: relative; overflow: hidden; touch-action: none;
|
||
background: linear-gradient(#0c1016, #070a0d); }
|
||
#stage canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
|
||
canvas#view { cursor: crosshair; }
|
||
/* The chrome sheet carries everything that is a line or a letter over the world - its top and bottom
|
||
edges, the region numbers, the brush ring. It never takes a pointer event: the brush is on the GL
|
||
canvas underneath it. */
|
||
canvas#chrome { pointer-events: none; }
|
||
/* Floating over the map, so they are blurred rather than opaque: a readout has to be legible over whatever
|
||
the world happens to put under it, and still let you see what it is describing. */
|
||
#hud { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; border-radius: 10px;
|
||
background: rgba(10,13,17,.72); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(216,222,230,.14); color: #c7cedb; pointer-events: none; white-space: pre;
|
||
font-size: 11.5px; box-shadow: 0 2px 10px rgba(0,0,0,.35); }
|
||
#msg { position: absolute; left: 12px; top: 12px; padding: 8px 11px; border-radius: 10px;
|
||
background: rgba(10,13,17,.82); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(216,222,230,.18); color: #dfe5ee; max-width: 70%; display: none;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||
#msg.err { border-color: #e0785c; color: #ffc9b8; }
|
||
#msg.ok { border-color: #7fd08a; color: #cdf0d2; }
|
||
|
||
/* ---- the rail --------------------------------------------------------------------------------- */
|
||
|
||
#side { background: var(--cream); color: var(--mink); border-right: 1.5px solid var(--mline);
|
||
overflow-y: auto; padding: 10px; line-height: 1.5; font-size: 12px;
|
||
font-family: "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", system-ui, sans-serif; }
|
||
#side::-webkit-scrollbar { width: 10px; }
|
||
#side::-webkit-scrollbar-track { background: #f6e6dc; }
|
||
#side::-webkit-scrollbar-thumb { background: var(--acc); border: 2px solid #f6e6dc; border-radius: 6px; }
|
||
#side::-webkit-scrollbar-thumb:hover { background: var(--accd); }
|
||
#side ::selection { background: var(--acc); color: #2a1b23; }
|
||
#side :focus-visible { outline: 2px solid var(--accd); outline-offset: 2px; border-radius: 4px; }
|
||
|
||
#hompy { background: var(--paper); border: 1.5px solid var(--mline); border-radius: 12px;
|
||
overflow: hidden; box-shadow: 0 3px 10px rgba(107,85,96,.14); }
|
||
#hompy .photo { height: 86px; background: #dff0f8 center / cover no-repeat;
|
||
border-bottom: 1.5px solid var(--mline); image-rendering: pixelated; }
|
||
#hompy .id { padding: 8px; }
|
||
#hompy .nm { font-weight: 800; font-size: 13px; line-height: 1.2; }
|
||
#hompy .mood { font-size: 11px; color: var(--mink2); line-height: 1.35; margin-top: 1px; }
|
||
#hompy .count { display: flex; border-top: 1.5px dashed var(--mline); }
|
||
#hompy .count > div { flex: 1; text-align: center; padding: 5px 2px; font-size: 11px; color: var(--mink2); }
|
||
#hompy .count > div + div { border-left: 1.5px dashed var(--mline); }
|
||
#hompy .count b { display: block; font-size: 12px; color: var(--accd); }
|
||
|
||
h2 { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 800; color: var(--accd);
|
||
margin: 12px 0 5px; }
|
||
h2::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--accd); flex: none; }
|
||
h2::after { content: ""; flex: 1; height: 1.5px; background: var(--mline); }
|
||
|
||
/* The menu sits on the list, so it keeps no gap under it. */
|
||
.tabs { display: flex; margin: 0; border-bottom: 2px solid var(--accd); }
|
||
.tabs button { flex: 1; font-size: 11px; padding: 4px 6px; margin-right: 3px; color: var(--mink2);
|
||
background: #f7e7dd; border: 1.5px solid var(--mline); border-bottom: none; border-radius: 8px 8px 0 0; }
|
||
.tabs button:last-child { margin-right: 0; }
|
||
.tabs button:hover { border-color: var(--accd); background: #fbf0e9; }
|
||
.tabs button.on, .tabs button.on:hover { background: var(--accd); border-color: var(--accd); color: #fff; }
|
||
|
||
#classes { background: var(--paper); border: 1.5px solid var(--mline); border-top: none;
|
||
border-radius: 0 0 10px 10px; overflow: hidden; }
|
||
#classes > .note { padding: 8px; margin: 0; }
|
||
/* One track in the playlist. The bars stand in every row so selecting one never moves the text. */
|
||
.sw { display: flex; align-items: center; gap: 7px; padding: 5px 8px; cursor: pointer;
|
||
border-bottom: 1px solid #f3e6ec; }
|
||
.sw:last-child { border-bottom: none; }
|
||
.sw::before { content: ""; width: 11px; height: 11px; flex: none;
|
||
background: linear-gradient(transparent, transparent) 0 100% / 2.5px 55% no-repeat,
|
||
linear-gradient(transparent, transparent) 50% 100% / 2.5px 100% no-repeat,
|
||
linear-gradient(transparent, transparent) 100% 100% / 2.5px 35% no-repeat; }
|
||
.sw:hover { background: var(--accl); }
|
||
/* The equalizer, the tinted row and the accent reading already say "this one". A coloured spine down the
|
||
edge would be a fourth signal and the most tired one. */
|
||
.sw.on { background: var(--accl); }
|
||
.sw.on::before { background-image: linear-gradient(var(--accd), var(--accd)),
|
||
linear-gradient(var(--accd), var(--accd)),
|
||
linear-gradient(var(--accd), var(--accd)); }
|
||
.chip { width: 14px; height: 14px; border-radius: 4px; border: 1.5px solid rgba(61,43,52,.45); flex: none; }
|
||
.chip.erase { background: repeating-linear-gradient(45deg,#c9b3bd 0 4px,#efe2e8 4px 8px); }
|
||
.sw .nm { flex: 1; font-size: 11.5px; font-weight: 700; }
|
||
.sw .ang, .sw .kind { color: var(--mink2); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||
.sw.on .ang, .sw.on .kind { color: var(--accd); font-weight: 700; }
|
||
|
||
.panel, #numbers, #planet, #bakeCtl { background: var(--paper); border: 1.5px solid var(--mline);
|
||
border-radius: 10px; padding: 8px; }
|
||
.row { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
||
.row label { flex: 1; color: var(--mink2); }
|
||
input[type=number], input[type=text] { width: 78px; background: var(--paper); color: var(--mink);
|
||
border: 1.5px solid var(--mline); border-radius: 8px; padding: 3px 7px; font: inherit; }
|
||
input[type=number]:hover, input[type=text]:hover { border-color: var(--accd); }
|
||
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
|
||
background: transparent; cursor: pointer; }
|
||
input[type=range]::-webkit-slider-runnable-track { height: 8px; border-radius: 6px; background: #fbe3ea;
|
||
border: 1.5px solid var(--mline); }
|
||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px;
|
||
height: 16px; margin-top: -6px; border-radius: 50%; background: linear-gradient(#ffd0dc,#ff9fb5);
|
||
border: 1.5px solid var(--accd); }
|
||
|
||
button { background: var(--paper); color: var(--mink); border: 1.5px solid var(--mline);
|
||
border-bottom-width: 3px; border-radius: 10px; padding: 6px 10px; font: inherit; font-weight: 700;
|
||
cursor: pointer; }
|
||
button:hover { border-color: var(--accd); background: #fffdfb; }
|
||
button:active { border-bottom-width: 1.5px; transform: translateY(1.5px); }
|
||
button:disabled { opacity: .45; cursor: default; border-bottom-width: 1.5px; }
|
||
button:disabled:hover { border-color: var(--mline); background: var(--paper); }
|
||
button.primary { background: linear-gradient(#ffc2d1, var(--acc)); border-color: var(--accd); color: #40202c; }
|
||
button.primary:hover { background: linear-gradient(#ffd0dc, #ffb3c6); }
|
||
button.warn { background: linear-gradient(#ffd9c2, #ffb894); border-color: #c9663a; color: #40241a; }
|
||
.bar { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||
.note { color: var(--mink2); font-size: 11px; margin: 5px 0 0; }
|
||
hr { border: 0; border-top: 1.5px dashed var(--mline); margin: 14px 0 0; }
|
||
|
||
.maps { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
|
||
.maps button { padding: 3px 9px; font-size: 11px; border-radius: 999px; border-bottom-width: 1.5px; }
|
||
.maps button.on { background: var(--accd); border-color: var(--accd); color: #fff; }
|
||
|
||
#key { margin-top: 8px; display: none; background: var(--paper); border: 1.5px solid var(--mline);
|
||
border-radius: 10px; padding: 8px; }
|
||
#key.on { display: block; }
|
||
#key .ramp { height: 12px; border-radius: 3px; border: 1px solid rgba(61,43,52,.35); }
|
||
#key .ends { display: flex; justify-content: space-between; color: var(--mink2); font-size: 11px; }
|
||
#key .item { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
|
||
#key .item .chip { width: 12px; height: 12px; }
|
||
#key .title { color: var(--accd); font-size: 11px; font-weight: 800; margin-bottom: 4px; }
|
||
#key .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 8px; }
|
||
|
||
/* Reports stay monospace: their columns are padded in the code that writes them. */
|
||
#report { white-space: pre; font-size: 11.5px; color: var(--mink2); margin-top: 6px;
|
||
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
|
||
#bakeStatus { white-space: pre-wrap; font-size: 11.5px; color: var(--mink2); margin-top: 6px;
|
||
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
|
||
#bakeStatus b { color: var(--mink); font-weight: 800; }
|
||
.prog { height: 12px; background: var(--accl); border: 1.5px solid var(--mline); border-radius: 7px;
|
||
overflow: hidden; margin: 7px 0 4px; }
|
||
.prog > div { height: 100%; background: linear-gradient(#ffc2d1, #ff8fa8); width: 0; transition: width .3s; }
|
||
</style>
|
||
|
||
<div id="app">
|
||
<div id="side">
|
||
<!-- The hompy card: the painting is its own profile photo, and the three costs are the counter. -->
|
||
<div id="hompy">
|
||
<div class="photo" id="hompyPhoto"></div>
|
||
<div class="id">
|
||
<div class="nm" id="hompyName">planet</div>
|
||
<div class="mood" id="hompyMood"></div>
|
||
</div>
|
||
<div class="count">
|
||
<div>plan <b>4 s</b></div>
|
||
<div>bake <b>~2 h</b></div>
|
||
<div>tile <b>12 s</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="brushHead">brush</h2>
|
||
<div class="tabs" id="tabs"></div>
|
||
<div id="classes"></div>
|
||
|
||
<div class="panel" style="margin-top:8px">
|
||
<div class="row">
|
||
<label>size <span id="sizeN">24</span> px</label>
|
||
</div>
|
||
<input type="range" id="size" min="1" max="400" value="24">
|
||
<p class="note" id="sizeM"></p>
|
||
<p class="note"><b>ctrl+z</b> takes back a stroke, <b>ctrl+shift+z</b> puts it back · [ and ] resize ·
|
||
shift-drag or space-drag pans · wheel zooms · <b>f</b> fits the world · <b>1</b> is one cell to one
|
||
pixel</p>
|
||
</div>
|
||
|
||
<h2 id="numbersHead">this class</h2>
|
||
<div id="numbers"></div>
|
||
|
||
<h2>planet</h2>
|
||
<div id="planet"></div>
|
||
|
||
<hr>
|
||
<div class="bar">
|
||
<button id="plan" class="primary">Plan (4 s)</button>
|
||
<button id="genOv">Generate marks</button>
|
||
<button id="savePaint">Save painting</button>
|
||
</div>
|
||
<p class="note" id="pending"></p>
|
||
<div class="maps" id="maps"></div>
|
||
<div id="key"></div>
|
||
<div id="report"></div>
|
||
|
||
<hr>
|
||
<h2>bake</h2>
|
||
<div id="bakeCtl"></div>
|
||
<div id="bakeStatus"></div>
|
||
</div>
|
||
|
||
<div id="stage">
|
||
<canvas id="view"></canvas>
|
||
<canvas id="chrome"></canvas>
|
||
<div id="msg"></div>
|
||
<div id="hud"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
const $ = s => document.querySelector(s);
|
||
const esc = s => String(s).replace(/&/g, "&").replace(/</g, "<");
|
||
const view = $("#view"), chromeCv = $("#chrome");
|
||
const cctx = chromeCv.getContext("2d");
|
||
|
||
let S = null; // server state
|
||
let full = null; // ImageData of the whole painting: the authority in the browser, and what the
|
||
// GPU texture is uploaded from. There is no second copy of it in a canvas.
|
||
let mapView = null; // {name, w, h} for a plan map drawn over the painting, or null
|
||
let mapLabels = null; // [{id, u, v}] to write over that map, or null. Only the region map has any
|
||
let layer = "class"; // which sheet the brush is on: "class" or "ov"
|
||
let sel = 0; // selected class index
|
||
let cam = { x: 0, y: 0, s: 1 }; // top-left in paint pixels, and scale
|
||
|
||
const readsAs = d => d < 3 ? "plain" : d < 8 ? "rolling" : d < 16 ? "hill country"
|
||
: d < 28 ? "mountain" : "alpine";
|
||
|
||
// tan(angle) = U / (K * cell^2m): the steady-state divide slope, which is the number an uplift rate
|
||
// actually chooses. Kept identical to internal/planet's divideAngle.
|
||
function divideDeg(rateMmYr, kMult) {
|
||
if (!S) return 0;
|
||
const k = S.k * (kMult || 1);
|
||
return Math.atan((rateMmYr / 1000) / (k * Math.pow(S.cell_m * S.cell_m, S.m))) * 180 / Math.PI;
|
||
}
|
||
|
||
// And what that rate makes of the ground, which is not the same thing. A divide is the *steepest* place in a
|
||
// catchment - A is smallest there - and almost none of a map is divide, so the median slope comes out at a
|
||
// third of the divide angle in tangent. Measured; internal/planet's typicalFromDivide holds the same number
|
||
// and the note explaining it. This is the one an author should be steering by.
|
||
const typicalDeg = deg => Math.atan(Math.tan(deg * Math.PI / 180) * 0.33) * 180 / Math.PI;
|
||
const groundDeg = (rateMmYr, kMult) => typicalDeg(divideDeg(rateMmYr, kMult));
|
||
|
||
function say(text, kind) {
|
||
const m = $("#msg");
|
||
m.textContent = text;
|
||
m.className = kind || "";
|
||
m.style.display = text ? "block" : "none";
|
||
if (kind === "ok") setTimeout(() => { if (m.textContent === text) m.style.display = "none"; }, 4000);
|
||
}
|
||
|
||
// ---- loading ------------------------------------------------------------------------------------
|
||
|
||
// A sheet decoded to pixels. The canvas is scratch - it exists for the length of this call, to turn a PNG
|
||
// into an array - and is thrown away as soon as the ImageData is out, because two of them held open would
|
||
// be another two hundred and thirty megabytes for nothing.
|
||
async function decodeSheet(url) {
|
||
const bmp = await createImageBitmap(await (await fetch(url)).blob());
|
||
const c = typeof OffscreenCanvas !== "undefined"
|
||
? new OffscreenCanvas(S.paint_w, S.paint_h)
|
||
: Object.assign(document.createElement("canvas"), { width: S.paint_w, height: S.paint_h });
|
||
const x = c.getContext("2d");
|
||
x.drawImage(bmp, 0, 0);
|
||
bmp.close();
|
||
return x.getImageData(0, 0, S.paint_w, S.paint_h);
|
||
}
|
||
|
||
async function boot() {
|
||
S = await (await fetch("/api/state")).json();
|
||
initGL();
|
||
const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
||
if (S.paint_w > max || S.paint_h > max) {
|
||
throw new Error(`the painting is ${S.paint_w} x ${S.paint_h} and this GPU will not hold a texture ` +
|
||
`larger than ${max} a side. Paint a smaller template.`);
|
||
}
|
||
|
||
full = await decodeSheet("/api/paint.png?" + Date.now());
|
||
// The annotation sheet, if this planet has one. Alpha is what says "nothing is marked here", so unlike
|
||
// the class painting it must not be flattened onto a background.
|
||
if (S.overlay) ovFull = await decodeSheet("/api/overlay.png?" + Date.now());
|
||
if (S.plates) plFull = await decodeSheet("/api/plates.png?" + Date.now());
|
||
|
||
resize();
|
||
uploadWorld();
|
||
fitWorld();
|
||
syncGenButton();
|
||
|
||
// Default to a land class an author would actually reach for rather than to whatever is first in the file,
|
||
// which on any legend written so far is the polar ice.
|
||
const pick = S.classes.findIndex(c => !c.sea && !c.derived && !c.stroke && c.has_massif);
|
||
sel = pick >= 0 ? pick : S.classes.findIndex(c => !c.sea && !c.derived);
|
||
|
||
buildHompy();
|
||
buildTabs(); buildClasses(); buildPlanet(); buildNumbers(); buildBake();
|
||
$("#size").oninput();
|
||
invalidate();
|
||
pollBake(); // a bake may already be running from before this page was reloaded
|
||
}
|
||
|
||
const paintable = () => S.classes.filter(c => !c.derived);
|
||
|
||
// ---- layers -------------------------------------------------------------------------------------
|
||
//
|
||
// Two sheets over one world. The class painting is the geology - every colour on it is an uplift rate the
|
||
// solve answers for - and the annotation layer is everything placed on the finished world that the
|
||
// simulation has no opinion about: a forest, a village, a road, and the one stretch of coast the author drew
|
||
// deliberately and wants left alone. They are separate images because a mark has to be able to sit on top of
|
||
// any class without changing it, and because there is no uplift rate for a town.
|
||
|
||
let ovFull = null; // ImageData of the annotation sheet, or null when the planet has no overlay
|
||
let ovSel = 0; // selected mark, or -1 for the eraser
|
||
|
||
// The tectonic sheet. No alpha and no eraser: every pixel of it is some plate, so there is nothing to rub
|
||
// out - painting one plate over another is the only edit there is.
|
||
let plFull = null; // ImageData of the tectonic sheet, or null when the planet has no plate layer
|
||
let plSel = 0; // selected plate
|
||
let plLegendDirty = false;
|
||
const onPlates = () => layer === "pl" && !!S.plates;
|
||
const platesOf = () => (S.plates ? S.plates.plates : []);
|
||
let ovLegendDirty = false;
|
||
|
||
const marks = () => (S.overlay ? S.overlay.marks : []);
|
||
const onOverlay = () => layer === "ov" && !!S.overlay;
|
||
|
||
// The card at the top of the rail. The profile photo is the painting itself, because the one picture that
|
||
// says which planet this is, is the one the author drew; the counter holds the three costs in the tool.
|
||
function buildHompy() {
|
||
$("#hompyPhoto").style.backgroundImage = `url(/api/paint.png?${Date.now()})`;
|
||
const stem = String(S.template || "planet").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||
$("#hompyName").textContent = stem;
|
||
const n = S.classes.filter(c => !c.derived).length;
|
||
$("#hompyMood").textContent =
|
||
`${n} classes · ${S.paint_w} × ${S.paint_h} · ${Math.round(S.circumference_km)} km round`;
|
||
}
|
||
|
||
// The generate button belongs to the annotation sheet and to nothing else, so it appears with it. It is
|
||
// also the one control here that can put marks on ground the author is not looking at, which is the other
|
||
// reason not to leave it visible while they are painting classes.
|
||
function syncGenButton() {
|
||
const b = $("#genOv");
|
||
if (!b) return;
|
||
b.style.display = onOverlay() ? "" : "none";
|
||
b.title = "Fill the blank parts of this sheet from the world - woodland, settlements and the roads "
|
||
+ "between them. Every press is a new seed, and it never touches a pixel you painted.";
|
||
}
|
||
|
||
function buildTabs() {
|
||
const el = $("#tabs"); el.innerHTML = "";
|
||
const mk = (id, label, enabled, hint) => {
|
||
const b = document.createElement("button");
|
||
b.textContent = label;
|
||
b.title = hint;
|
||
b.className = layer === id ? "on" : "";
|
||
b.disabled = !enabled;
|
||
b.onclick = () => {
|
||
layer = id;
|
||
buildTabs(); buildClasses(); buildNumbers();
|
||
$("#savePaint").textContent = saveLabel();
|
||
syncGenButton();
|
||
invalidate();
|
||
};
|
||
el.appendChild(b);
|
||
};
|
||
mk("class", "classes", true, "the geology: every colour is an uplift rate");
|
||
mk("ov", "overlay", !!S.overlay,
|
||
S.overlay ? "annotation: forests, settlements, roads, and coastlines to leave alone"
|
||
: "this planet has no overlay; set planet.overlay_legend in the manifest");
|
||
mk("pl", "plates", !!S.plates,
|
||
S.plates ? "tectonics: every colour is a plate, and where two meet is a margin"
|
||
: "this planet has no tectonic layer; set planet.plates.legend in the manifest");
|
||
}
|
||
|
||
function buildMarks() {
|
||
const el = $("#classes"); el.innerHTML = "";
|
||
$("#brushHead").textContent = "marks";
|
||
const row = (i, name, rgb, kind, erase) => {
|
||
const d = document.createElement("div");
|
||
d.className = "sw" + (i === ovSel ? " on" : "");
|
||
d.innerHTML = `<div class="chip${erase ? " erase" : ""}" style="${erase ? "" : `background:rgb(${rgb.join(",")})`}"></div>
|
||
<div class="nm">${name}</div><div class="kind">${kind}</div>`;
|
||
d.onclick = () => { ovSel = i; buildMarks(); buildNumbers(); invalidate(); };
|
||
el.appendChild(d);
|
||
};
|
||
marks().forEach((m, i) => {
|
||
let kind = m.kind;
|
||
if (m.has_coast_jitter) kind = m.coast_jitter === 0 ? "coast pinned" : `coast x${m.coast_jitter}`;
|
||
row(i, m.name, m.rgb, kind, false);
|
||
});
|
||
row(-1, "erase", [0, 0, 0], "", true);
|
||
if (!marks().length) {
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">The overlay legend has no marks yet. Add some to ${S.overlay.legend}.</p>`);
|
||
}
|
||
}
|
||
|
||
function buildMarkNumbers() {
|
||
const el = $("#numbers"); el.innerHTML = "";
|
||
$("#numbersHead").textContent = "this mark";
|
||
const m = marks()[ovSel];
|
||
if (!m) {
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">The eraser takes a mark off the sheet. It never touches the class painting.</p>`);
|
||
return;
|
||
}
|
||
const jr = document.createElement("div"); jr.className = "row";
|
||
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = m.has_coast_jitter;
|
||
cb.onchange = () => {
|
||
m.has_coast_jitter = cb.checked;
|
||
// Ticking the box means "I want to say something about the coast here", and what an author almost always
|
||
// wants to say is "leave this one alone". 1 is the planet's own amplitude, which is exactly what an
|
||
// unmarked pixel already gets, so starting there would be a box that did nothing.
|
||
if (m.has_coast_jitter && (m.coast_jitter === undefined || m.coast_jitter === 1)) m.coast_jitter = 0;
|
||
buildNumbers(); markOverlayDirty();
|
||
};
|
||
const jl = document.createElement("label"); jl.textContent = "controls the coastline";
|
||
jr.append(jl, cb); el.appendChild(jr);
|
||
|
||
if (m.has_coast_jitter) {
|
||
el.appendChild(numRow(" jitter x", m.coast_jitter, 0.25, v => {
|
||
m.coast_jitter = v; markOverlayDirty(); jitterNote();
|
||
}));
|
||
el.insertAdjacentHTML("beforeend", `<p class="note" id="jnote"></p>`);
|
||
}
|
||
if (m.kind === "path") {
|
||
el.appendChild(numRow("width m", m.width_m, 1, v => { m.width_m = v; markOverlayDirty(); }));
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">A path is thinned to its centreline and comes out of overlay.json as an ordered
|
||
polyline in world metres. The width travels with it; nothing here uses it.</p>`);
|
||
}
|
||
if (m.note) el.insertAdjacentHTML("beforeend", `<p class="note">${esc(m.note)}</p>`);
|
||
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const b = document.createElement("button"); b.textContent = "Save overlay legend";
|
||
b.onclick = () => saveOverlayLegend(false); bar.appendChild(b);
|
||
el.appendChild(bar);
|
||
jitterNote();
|
||
}
|
||
|
||
function jitterNote() {
|
||
const n = $("#jnote"); if (!n) return;
|
||
const m = marks()[ovSel]; if (!m || !m.has_coast_jitter) return;
|
||
const amp = S.coast_jitter_px * m.coast_jitter * S.metres_per_px;
|
||
n.textContent = m.coast_jitter === 0
|
||
? "The waterline under this mark stays exactly where you painted it, while the rest of the world is "
|
||
+ "still roughened. Paint over the shore; either side is enough."
|
||
: `The waterline under this mark moves up to ${amp.toFixed(0)} m, against `
|
||
+ `${(S.coast_jitter_px * S.metres_per_px).toFixed(0)} m elsewhere.`;
|
||
}
|
||
|
||
function markOverlayDirty() { ovLegendDirty = true; buildMarks(); refreshPending(); }
|
||
|
||
async function saveOverlayLegend(quiet) {
|
||
const edits = marks().map(m => ({
|
||
mark: m.name,
|
||
has_coast_jitter: m.has_coast_jitter,
|
||
coast_jitter: m.coast_jitter,
|
||
width_m: m.kind === "path" ? m.width_m : undefined,
|
||
}));
|
||
const r = await fetch("/api/overlay/legend", { method: "POST", body: JSON.stringify(edits) });
|
||
if (!r.ok) { say(await r.text(), "err"); return false; }
|
||
ovLegendDirty = false; refreshPending();
|
||
if (!quiet) say("overlay legend saved", "ok");
|
||
return true;
|
||
}
|
||
|
||
// ---- side panel ---------------------------------------------------------------------------------
|
||
|
||
// The tectonic brushes. A plate's chip carries its drift as a little arrow, because where a plate is going is
|
||
// the only thing that decides what happens where it meets the next one - a legend of seven colours with no
|
||
// directions on it tells you nothing about the world you are painting.
|
||
function buildPlateBrushes() {
|
||
const el = $("#classes"); el.innerHTML = "";
|
||
$("#brushHead").textContent = "plates";
|
||
platesOf().forEach((p, i) => {
|
||
const d = document.createElement("div");
|
||
d.className = "sw" + (i === plSel ? " on" : "");
|
||
// 0 is up the map and the arrow glyphs run clockwise from there, so the chip agrees with the heading.
|
||
const arrows = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"];
|
||
const a = p.speed_cm_yr > 0 ? arrows[Math.round(((p.heading_deg % 360) + 360) % 360 / 45) % 8] : "·";
|
||
const kind = p.continental ? "continental" : "oceanic";
|
||
d.innerHTML = `<div class="chip" style="background:rgb(${p.rgb.join(",")})"></div>
|
||
<div class="nm">${a} ${p.name}</div>
|
||
<div class="ang">${p.speed_cm_yr} cm/yr · ${kind}</div>`;
|
||
d.onclick = () => { plSel = i; buildPlateBrushes(); buildNumbers(); invalidate(); };
|
||
el.appendChild(d);
|
||
});
|
||
if (!platesOf().length) {
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">The tectonic legend has no plates yet. Add some to ${S.plates.legend}.</p>`);
|
||
}
|
||
}
|
||
|
||
function buildPlateNumbers() {
|
||
const el = $("#numbers"); el.innerHTML = "";
|
||
$("#numbersHead").textContent = "this plate";
|
||
const p = platesOf()[plSel];
|
||
if (!p) return;
|
||
el.appendChild(numRow("speed cm/yr", p.speed_cm_yr, 0.1, v => { p.speed_cm_yr = v; markPlatesDirty(); }));
|
||
el.appendChild(numRow("heading °", p.heading_deg, 1, v => { p.heading_deg = v; markPlatesDirty(); }));
|
||
el.appendChild(numRow("spin °/Myr", p.spin_deg_myr, 0.5, v => { p.spin_deg_myr = v; markPlatesDirty(); }));
|
||
const forced = p.forced ? " The legend says so rather than the painting." : "";
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">Heading is a compass bearing over the map: 0 is up, 90 is right.
|
||
What happens at a margin is the <i>difference</i> between the two plates either side, so two plates
|
||
drifting the same way are a boundary doing nothing. Spin turns the plate about its own centre, and it is
|
||
what makes one end of a margin collide while the other slides.<br><br>
|
||
This one is <b>${p.continental ? "continental" : "oceanic"}</b>.${forced}
|
||
Ocean against continent subducts; continent against continent collides.</p>`);
|
||
}
|
||
|
||
function markPlatesDirty() { plLegendDirty = true; buildPlateBrushes(); refreshPending(); }
|
||
|
||
function buildClasses() {
|
||
if (onPlates()) return buildPlateBrushes();
|
||
if (onOverlay()) return buildMarks();
|
||
const el = $("#classes"); el.innerHTML = "";
|
||
$("#brushHead").textContent = "brush";
|
||
S.classes.forEach((c, i) => {
|
||
if (c.derived) return;
|
||
const d = document.createElement("div");
|
||
d.className = "sw" + (i === sel ? " on" : "");
|
||
const g = c.sea ? 0 : groundDeg(c.uplift_mm_yr, c.k_mult);
|
||
const ang = c.sea ? `${c.depth_m|0} m deep` : `${g.toFixed(1)}° ${readsAs(g)}`;
|
||
d.innerHTML = `<div class="chip" style="background:rgb(${c.rgb.join(",")})"></div>
|
||
<div class="nm">${c.name}</div><div class="ang">${ang}</div>`;
|
||
d.onclick = () => { sel = i; buildClasses(); buildNumbers(); invalidate(); };
|
||
el.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function numRow(label, value, step, on) {
|
||
const r = document.createElement("div"); r.className = "row";
|
||
const l = document.createElement("label"); l.textContent = label;
|
||
const i = document.createElement("input");
|
||
i.type = "number"; i.step = step; i.value = value;
|
||
i.onchange = () => on(parseFloat(i.value));
|
||
r.append(l, i); return r;
|
||
}
|
||
|
||
function buildNumbers() {
|
||
if (onPlates()) return buildPlateNumbers();
|
||
if (onOverlay()) return buildMarkNumbers();
|
||
const el = $("#numbers"); el.innerHTML = "";
|
||
$("#numbersHead").textContent = "this class";
|
||
const c = S.classes[sel];
|
||
if (!c) return;
|
||
if (c.sea) {
|
||
el.appendChild(numRow("depth m", c.depth_m, 1, () => {}));
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">Sea. The solve holds every one of these cells at base level, so it carries no rate.</p>`);
|
||
return;
|
||
}
|
||
// Both numbers, because the author needs the second and the first is the one the physics is written in.
|
||
// The ground is what they are choosing; the divide is where it comes from and what the repose clamp
|
||
// eventually binds against.
|
||
const show = () => {
|
||
const d = divideDeg(c.uplift_mm_yr, c.k_mult), g = typicalDeg(d);
|
||
const fd = c.has_massif ? divideDeg(c.floor_mm_yr, c.k_mult) : null;
|
||
$("#angle").innerHTML =
|
||
`massif <b>${g.toFixed(1)}°</b> ${readsAs(g)} <span style="opacity:.6">(${d.toFixed(1)}° at a divide)</span>` +
|
||
(fd === null ? "" : `<br>plain <b>${typicalDeg(fd).toFixed(1)}°</b> ${readsAs(typicalDeg(fd))}` +
|
||
` <span style="opacity:.6">(${fd.toFixed(1)}° at a divide)</span>`) +
|
||
`<br><span style="opacity:.6">the bold number is the median over the class; a divide is the steepest` +
|
||
` place in a catchment and almost none of a map is divide.</span>`;
|
||
};
|
||
el.appendChild(numRow("uplift mm/yr", c.uplift_mm_yr, 0.005, v => { c.uplift_mm_yr = v; show(); markDirty(); }));
|
||
el.appendChild(numRow("k_mult", c.k_mult, 0.1, v => { c.k_mult = v; show(); markDirty(); }));
|
||
|
||
const mr = document.createElement("div"); mr.className = "row";
|
||
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = c.has_massif;
|
||
cb.onchange = () => {
|
||
c.has_massif = cb.checked;
|
||
if (c.has_massif && !c.floor_mm_yr) { c.floor_mm_yr = +(c.uplift_mm_yr / 7).toFixed(4); c.fraction = 0.16; }
|
||
buildNumbers(); markDirty();
|
||
};
|
||
const ml = document.createElement("label"); ml.textContent = "massif: plain + upland";
|
||
mr.append(ml, cb); el.appendChild(mr);
|
||
|
||
if (c.has_massif) {
|
||
el.appendChild(numRow(" floor mm/yr", c.floor_mm_yr, 0.002, v => { c.floor_mm_yr = v; show(); markDirty(); }));
|
||
el.appendChild(numRow(" fraction", c.fraction, 0.02, v => { c.fraction = v; markDirty(); }));
|
||
}
|
||
el.appendChild(numRow("coastal plain km", c.coastal_plain_km, 0.5, v => { c.coastal_plain_km = v; markDirty(); }));
|
||
if (c.coastal_plain_km > 0) {
|
||
el.appendChild(numRow(" shore mm/yr", c.coastal_floor_mm_yr, 0.005, v => { c.coastal_floor_mm_yr = v; markDirty(); }));
|
||
}
|
||
el.insertAdjacentHTML("beforeend", `<p class="note" id="angle"></p>`);
|
||
show();
|
||
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const b = document.createElement("button"); b.textContent = "Save legend";
|
||
b.onclick = () => saveLegend(false); bar.appendChild(b);
|
||
el.appendChild(bar);
|
||
}
|
||
|
||
// planetP is the planet block as the panel has it, which is not the same thing as what the manifest has until
|
||
// somebody saves. Hoisted out of buildPlanet because Plan and Bake both apply it first now: see flushPending.
|
||
let planetP = null;
|
||
let planetDirty = false;
|
||
function markPlanetDirty() { planetDirty = true; refreshPending(); }
|
||
|
||
function buildPlanet() {
|
||
const el = $("#planet"); el.innerHTML = "";
|
||
const P = planetP = {
|
||
seed: S.seed,
|
||
massif_wavelength_km: S.massif_wavelength_km,
|
||
lithology_wavelength_km: S.lithology_wavelength_km,
|
||
fault_grain_km: S.fault_grain_km,
|
||
coast_jitter_px: S.coast_jitter_px,
|
||
coast_jitter_wavelength_px: S.coast_jitter_wavelength_px,
|
||
coast_jitter_octaves: S.coast_jitter_octaves,
|
||
coast_jitter_gain: S.coast_jitter_gain,
|
||
};
|
||
const mpp = S.metres_per_px;
|
||
|
||
// The seed and the re-roll button. The painting fixes the composition - where the continents are, where the
|
||
// ranges run - and the seed decides everything inside it that nobody drew: where the massifs stand, which
|
||
// rock is where, where the faults are, and the detail of the coastline. Re-rolling is how one painting
|
||
// becomes many worlds, so it belongs next to a button rather than in a text editor.
|
||
const seedRow = numRow("seed", P.seed, 1, v => { P.seed = v | 0; markPlanetDirty(); });
|
||
el.appendChild(seedRow);
|
||
const rollBar = document.createElement("div"); rollBar.className = "bar";
|
||
const roll = document.createElement("button"); roll.textContent = "Re-roll";
|
||
roll.title = "a new seed: same painting, different massifs, rock, faults and coastline detail";
|
||
roll.onclick = () => {
|
||
P.seed = Math.floor(Math.random() * 100000);
|
||
seedRow.querySelector("input").value = P.seed;
|
||
markPlanetDirty();
|
||
};
|
||
rollBar.appendChild(roll);
|
||
el.appendChild(rollBar);
|
||
el.insertAdjacentHTML("beforeend",
|
||
`<p class="note">Re-roll, then Plan: the seed is applied for you. The painting stays exactly as drawn.</p>`);
|
||
|
||
el.appendChild(numRow("massif km", P.massif_wavelength_km, 0.5, v => { P.massif_wavelength_km = v; markPlanetDirty(); }));
|
||
el.appendChild(numRow("rock km", P.lithology_wavelength_km, 1, v => { P.lithology_wavelength_km = v; markPlanetDirty(); }));
|
||
el.appendChild(numRow("fault grain km", P.fault_grain_km, 1, v => { P.fault_grain_km = v; markPlanetDirty(); }));
|
||
el.appendChild(numRow("coast amp px", P.coast_jitter_px, 4, v => { P.coast_jitter_px = v; note(); markPlanetDirty(); }));
|
||
el.appendChild(numRow("coast bay px", P.coast_jitter_wavelength_px, 16, v => { P.coast_jitter_wavelength_px = v; note(); markPlanetDirty(); }));
|
||
el.appendChild(numRow("coast octaves", P.coast_jitter_octaves, 1, v => { P.coast_jitter_octaves = v; markPlanetDirty(); }));
|
||
el.appendChild(numRow("coast gain", P.coast_jitter_gain, 0.05, v => { P.coast_jitter_gain = v; markPlanetDirty(); }));
|
||
el.insertAdjacentHTML("beforeend", `<p class="note" id="coastNote"></p>`);
|
||
const note = () => $("#coastNote").textContent =
|
||
`bays about ${(P.coast_jitter_wavelength_px * mpp / 1000).toFixed(1)} km wide, ` +
|
||
`up to ${(P.coast_jitter_px * mpp).toFixed(0)} m deep`;
|
||
note();
|
||
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const b = document.createElement("button"); b.textContent = "Save planet";
|
||
b.onclick = () => savePlanet(false);
|
||
bar.appendChild(b); el.appendChild(bar);
|
||
}
|
||
|
||
async function savePlanet(quiet) {
|
||
if (!planetP) return true;
|
||
const r = await fetch("/api/planet", { method: "POST", body: JSON.stringify(planetP) });
|
||
if (!r.ok) { say(await r.text(), "err"); return false; }
|
||
Object.assign(S, planetP);
|
||
planetDirty = false; refreshPending();
|
||
if (!quiet) say("planet saved", "ok");
|
||
return true;
|
||
}
|
||
|
||
let legendDirty = false;
|
||
function markDirty() { legendDirty = true; buildClasses(); refreshPending(); }
|
||
|
||
async function saveLegend(quiet) {
|
||
const edits = S.classes.filter(c => !c.sea && !c.derived).map(c => ({
|
||
class: c.name,
|
||
uplift_mm_yr: c.uplift_mm_yr, k_mult: c.k_mult,
|
||
coastal_plain_km: c.coastal_plain_km, coastal_floor_mm_yr: c.coastal_floor_mm_yr,
|
||
has_massif: c.has_massif, floor_mm_yr: c.floor_mm_yr, fraction: c.fraction,
|
||
}));
|
||
const r = await fetch("/api/legend", { method: "POST", body: JSON.stringify(edits) });
|
||
if (!r.ok) { say(await r.text(), "err"); return false; }
|
||
legendDirty = false; refreshPending();
|
||
if (!quiet) say("legend saved", "ok");
|
||
return true;
|
||
}
|
||
|
||
// What is sitting in the panel and not yet in a file. The rail edits three separate things - the planet block
|
||
// in the manifest, the class legend, the overlay legend - and each has its own Save, so it is entirely possible
|
||
// to have changed all three and saved none.
|
||
function refreshPending() {
|
||
const n = $("#pending");
|
||
if (!n) return;
|
||
const parts = [];
|
||
if (planetDirty) parts.push("planet");
|
||
if (legendDirty) parts.push("legend");
|
||
if (ovLegendDirty) parts.push("overlay legend");
|
||
if (plLegendDirty) parts.push("plate legend");
|
||
n.textContent = parts.length
|
||
? `unsaved: ${parts.join(", ")}. Plan and Bake apply them first.`
|
||
: "";
|
||
}
|
||
|
||
// flushPending writes the panel's numbers before a run reads them off disk.
|
||
//
|
||
// This is the fix for the trap that made a re-rolled seed produce an identical map. Plan has always pushed both
|
||
// paintings before running, and not pushing the numbers beside them was the whole bug: the plan handler reads
|
||
// the legend and the manifest from *disk*, so a seed still sitting in the panel is a seed the prepare never
|
||
// sees, the plan key does not change, and the cached prepare comes back with the same uplift and erodibility
|
||
// maps - which looks exactly like a generator that ignores its seed. A bake reloads the manifest too, and gets
|
||
// two hours to be wrong about it, so it flushes the same way.
|
||
//
|
||
// Returns the list of what it applied, or null if a write was refused - in which case the caller stops, because
|
||
// planning against numbers the server rejected would answer a question nobody asked.
|
||
async function flushPending() {
|
||
const applied = [];
|
||
if (planetDirty) { if (!await savePlanet(true)) return null; applied.push("planet"); }
|
||
if (legendDirty) { if (!await saveLegend(true)) return null; applied.push("legend"); }
|
||
if (ovLegendDirty) { if (!await saveOverlayLegend(true)) return null; applied.push("overlay legend"); }
|
||
if (plLegendDirty) { if (!await savePlatesLegend(true)) return null; applied.push("plate legend"); }
|
||
return applied;
|
||
}
|
||
|
||
async function savePlatesLegend(quiet) {
|
||
const edits = platesOf().map(p => ({
|
||
plate: p.name,
|
||
speed_cm_yr: p.speed_cm_yr,
|
||
heading_deg: p.heading_deg,
|
||
spin_deg_myr: p.spin_deg_myr,
|
||
}));
|
||
const r = await fetch("/api/plates/legend", { method: "POST", body: JSON.stringify(edits) });
|
||
if (!r.ok) { say(await r.text(), "err"); return false; }
|
||
plLegendDirty = false; refreshPending();
|
||
if (!quiet) say("plate legend saved", "ok");
|
||
return true;
|
||
}
|
||
|
||
// ---- the canvas ---------------------------------------------------------------------------------
|
||
//
|
||
// The template that ships with the tool is 7738 x 3761: twenty-nine million cells, a hundred and sixteen
|
||
// megabytes of pixels. The first renderer put all of that through the 2D canvas on every pointer event - a
|
||
// full-width putImageData band to push the stroke back into an offscreen copy, then a high-quality
|
||
// downsample of the whole image, once per repetition across the seam. Both costs are linear in the size of
|
||
// the world and neither is a function of what the stroke actually touched, so a twenty-four pixel brush cost
|
||
// exactly what a four hundred pixel one did, and a mouse reporting a thousand times a second asked for it a
|
||
// thousand times a second.
|
||
//
|
||
// The world is a GPU texture now. A stroke uploads the rectangle it touched and nothing else, read straight
|
||
// out of the ImageData with no intermediate copy - that is what WebGL2's UNPACK_ROW_LENGTH is for - and
|
||
// drawing is one textured quad minified by the hardware off a mip chain. Neither cost is a function of the
|
||
// size of the painting any more. The seam comes free with REPEAT, which also fixes what the tiling loop
|
||
// could not: every repetition was a separate drawImage with texture coordinates of its own, so the
|
||
// derivative at the wrap was wrong and the seam blurred whenever the world was minified.
|
||
//
|
||
// Three layers over the stage. The void is a CSS gradient; the world is the GL canvas, transparent wherever
|
||
// there is no world; #chrome is a 2D canvas for everything that is a line or a letter - the world's top and
|
||
// bottom edges, the region numbers, the brush ring. Chrome is a viewport rather than a world, so redrawing
|
||
// the whole of it every frame costs nothing.
|
||
|
||
let gl = null, prog = null, quad = null;
|
||
const U = {};
|
||
let texPaint = null, texOv = null, texPl = null, texMap = null;
|
||
let paintBytes = null, ovBytes = null, plBytes = null; // Uint8Array views on the ImageData, which is what GL uploads take
|
||
let vw = 1, vh = 1, dpr = 1, vrect = { left: 0, top: 0 };
|
||
let mipDirty = false, mipAt = 0;
|
||
|
||
const VS = `#version 300 es
|
||
in vec2 aPos; // the unit square, one corner per vertex
|
||
uniform vec3 uCam; // top-left in paint pixels, and device pixels per paint pixel
|
||
uniform vec2 uView; // the viewport in device pixels
|
||
out vec2 vPaint;
|
||
void main() {
|
||
gl_Position = vec4(aPos.x * 2.0 - 1.0, 1.0 - aPos.y * 2.0, 0.0, 1.0);
|
||
vPaint = uCam.xy + aPos * uView / uCam.z;
|
||
}`;
|
||
|
||
// X is left to the sampler rather than wrapped here: REPEAT wraps the fetch *and* keeps the derivative
|
||
// continuous across the seam, which is what picks the right mip there. fract() would do neither.
|
||
const FS = `#version 300 es
|
||
precision highp float;
|
||
in vec2 vPaint;
|
||
uniform sampler2D uSrc;
|
||
uniform sampler2D uOv;
|
||
uniform vec2 uWorld; // the source's own size in paint pixels
|
||
uniform float uOvAmt; // 0 when no annotation sheet is being shown
|
||
out vec4 frag;
|
||
void main() {
|
||
vec2 uv = vPaint / uWorld;
|
||
// The world is a cylinder: it has no side edges, but it does begin and end in Y. Off either end is void,
|
||
// which the page shows through, rather than a stretched edge row.
|
||
if (uv.y < 0.0 || uv.y > 1.0) discard;
|
||
vec3 c = texture(uSrc, uv).rgb;
|
||
if (uOvAmt > 0.0) {
|
||
vec4 o = texture(uOv, uv);
|
||
c = mix(c, o.rgb, o.a * uOvAmt);
|
||
}
|
||
frag = vec4(c, 1.0);
|
||
}`;
|
||
|
||
function initGL() {
|
||
gl = view.getContext("webgl2", {
|
||
alpha: true, antialias: false, depth: false, stencil: false,
|
||
premultipliedAlpha: true, powerPreference: "high-performance",
|
||
});
|
||
if (!gl) throw new Error("this browser has no WebGL2, which the canvas is drawn with");
|
||
|
||
const sh = (type, src) => {
|
||
const s = gl.createShader(type);
|
||
gl.shaderSource(s, src); gl.compileShader(s);
|
||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||
return s;
|
||
};
|
||
prog = gl.createProgram();
|
||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
|
||
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
|
||
gl.linkProgram(prog);
|
||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
||
gl.useProgram(prog);
|
||
for (const n of ["uCam", "uView", "uWorld", "uOvAmt", "uSrc", "uOv"]) U[n] = gl.getUniformLocation(prog, n);
|
||
gl.uniform1i(U.uSrc, 0);
|
||
gl.uniform1i(U.uOv, 1);
|
||
|
||
quad = gl.createVertexArray();
|
||
gl.bindVertexArray(quad);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
|
||
const a = gl.getAttribLocation(prog, "aPos");
|
||
gl.enableVertexAttribArray(a);
|
||
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
|
||
}
|
||
|
||
// A texture the size of a sheet, with its mip chain. Non-power-of-two with REPEAT and mipmaps is a WebGL2
|
||
// thing; under WebGL1 a 7738-wide world would have had to be padded to 8192 first.
|
||
function makeSheet(data) {
|
||
const t = gl.createTexture();
|
||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, S.paint_w, S.paint_h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||
gl.generateMipmap(gl.TEXTURE_2D);
|
||
return t;
|
||
}
|
||
|
||
// uploadWorld builds both sheets from the ImageData the browser holds. Called at boot, and again if the
|
||
// driver takes the context away - which is a thing that happens on a machine that is also compiling shaders
|
||
// for an engine, and used to leave a black rectangle with no way back but a reload.
|
||
function uploadWorld() {
|
||
paintBytes = new Uint8Array(full.data.buffer);
|
||
texPaint = makeSheet(paintBytes);
|
||
if (ovFull) {
|
||
ovBytes = new Uint8Array(ovFull.data.buffer);
|
||
texOv = makeSheet(ovBytes);
|
||
}
|
||
if (plFull) {
|
||
plBytes = new Uint8Array(plFull.data.buffer);
|
||
texPl = makeSheet(plBytes);
|
||
}
|
||
mipDirty = false;
|
||
}
|
||
|
||
// pushRect sends one painted rectangle to the GPU, reading it in place out of the sheet's own bytes.
|
||
// UNPACK_ROW_LENGTH is the whole reason this is cheap: without it every upload would have to be a full-width
|
||
// band, or a copy into a scratch buffer first, which is what the 2D canvas made it.
|
||
function pushRect(tex, data, rect) {
|
||
const W = S.paint_w;
|
||
const y0 = Math.max(0, rect[1]), y1 = Math.min(S.paint_h - 1, rect[3]);
|
||
if (y1 < y0 || !tex) return;
|
||
const rows = y1 - y0 + 1;
|
||
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, W);
|
||
gl.pixelStorei(gl.UNPACK_SKIP_ROWS, y0);
|
||
const band = (x, w) => {
|
||
gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, x);
|
||
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y0, w, rows, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||
};
|
||
const span = Math.min(W, rect[2] - rect[0] + 1);
|
||
const a = ((rect[0] % W) + W) % W;
|
||
if (span >= W) band(0, W);
|
||
else if (a + span <= W) band(a, span);
|
||
else { band(a, W - a); band(0, a + span - W); } // a stroke over the seam is two rectangles, not one
|
||
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
|
||
gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0);
|
||
gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0);
|
||
mipDirty = true;
|
||
}
|
||
|
||
// The mip chain is what makes the zoomed-out view both correct and free, and regenerating it off a
|
||
// twenty-nine megapixel base is not something to do between two points of a stroke. So it is rebuilt at most
|
||
// ten times a second while the brush is down, never while the world is magnified past 1:1 because nothing
|
||
// reads it there, and once for certain on the frame after the brush comes up.
|
||
function refreshMips() {
|
||
const now = performance.now();
|
||
if (drawing && (cam.s >= 1 || now - mipAt < 100)) return;
|
||
gl.activeTexture(gl.TEXTURE0);
|
||
gl.bindTexture(gl.TEXTURE_2D, texPaint); gl.generateMipmap(gl.TEXTURE_2D);
|
||
if (texOv) { gl.bindTexture(gl.TEXTURE_2D, texOv); gl.generateMipmap(gl.TEXTURE_2D); }
|
||
mipDirty = false; mipAt = now;
|
||
}
|
||
|
||
// The size of whatever is on screen, in paint pixels. A plan map is narrower than the painting and covers
|
||
// the same cylinder, so its width is the painting's and its height follows from its own aspect.
|
||
const worldSize = () => mapView
|
||
? [S.paint_w, mapView.h * S.paint_w / mapView.w]
|
||
: [S.paint_w, S.paint_h];
|
||
|
||
function resize() {
|
||
const r = $("#stage").getBoundingClientRect();
|
||
vrect = r;
|
||
vw = Math.max(1, r.width); vh = Math.max(1, r.height);
|
||
// Rendered at the screen's own pixels rather than at CSS pixels. On a scaled display the old canvas was a
|
||
// smaller image stretched up, which is a soft coastline in a tool whose entire job is where the coast is.
|
||
dpr = Math.min(2, window.devicePixelRatio || 1);
|
||
view.width = chromeCv.width = Math.round(vw * dpr);
|
||
view.height = chromeCv.height = Math.round(vh * dpr);
|
||
}
|
||
|
||
// ---- the frame ----------------------------------------------------------------------------------
|
||
//
|
||
// One rAF loop owns everything that touches the screen. Pointer events do no work beyond writing down where
|
||
// the pointer was: a thousand-hertz mouse used to mean a thousand full repaints a second, all but sixty of
|
||
// which were thrown away before anybody saw them.
|
||
|
||
let raf = 0, lastT = 0;
|
||
function invalidate() { if (!raf) raf = requestAnimationFrame(frame); }
|
||
|
||
function frame(t) {
|
||
raf = 0;
|
||
if (!S || !gl || gl.isContextLost()) return;
|
||
const dt = Math.min(64, t - (lastT || t)); lastT = t;
|
||
const easing = easeZoom(dt);
|
||
drainStroke();
|
||
clampCam();
|
||
render();
|
||
drawChrome();
|
||
updateHud();
|
||
if (easing || (mipDirty && !drawing)) invalidate();
|
||
}
|
||
|
||
function render() {
|
||
if (mipDirty) refreshMips();
|
||
const [worldW, worldH] = worldSize();
|
||
const tex = mapView ? texMap : texPaint;
|
||
if (!tex) return;
|
||
|
||
gl.viewport(0, 0, view.width, view.height);
|
||
gl.clearColor(0, 0, 0, 0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
gl.useProgram(prog);
|
||
gl.bindVertexArray(quad);
|
||
|
||
// Nearest at or above 1:1, because the cells are the data and a blended edge describes a class that is not
|
||
// there. Below it, trilinear off the mips: a minified nearest sample throws four fifths of the painting
|
||
// away and crawls as you pan.
|
||
const mag = cam.s >= 1 ? gl.NEAREST : gl.LINEAR;
|
||
gl.activeTexture(gl.TEXTURE0);
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mag);
|
||
|
||
// One sheet rides on top of the base, and which one is whichever tab the brush is on.
|
||
//
|
||
// The annotation sheet is the usual passenger: a mark means nothing except against the coastline or the
|
||
// range it was drawn against, so it is dimmed rather than hidden while the brush is on the classes - a road
|
||
// cannot then be mistaken for something the solve reads, and a mark you cannot see is one you will paint
|
||
// over. The tectonic sheet takes the slot instead while the brush is on the plates, and it is *translucent*
|
||
// rather than opaque for the same reason one level up: a plate boundary is only worth anything relative to
|
||
// the coastlines under it, and painting plates over an opaque sheet would be painting them blind.
|
||
let over = null, ovAmt = 0;
|
||
if (!mapView) {
|
||
if (onPlates() && texPl) { over = texPl; ovAmt = 0.55; }
|
||
else if (texOv) { over = texOv; ovAmt = onOverlay() ? 1 : 0.45; }
|
||
}
|
||
if (over) {
|
||
gl.activeTexture(gl.TEXTURE1);
|
||
gl.bindTexture(gl.TEXTURE_2D, over);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mag);
|
||
}
|
||
|
||
gl.uniform3f(U.uCam, cam.x, cam.y, cam.s * dpr);
|
||
gl.uniform2f(U.uView, view.width, view.height);
|
||
gl.uniform2f(U.uWorld, worldW, worldH);
|
||
gl.uniform1f(U.uOvAmt, ovAmt);
|
||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||
}
|
||
|
||
function drawChrome() {
|
||
cctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
cctx.clearRect(0, 0, chromeCv.width, chromeCv.height);
|
||
cctx.setTransform(dpr, 0, 0, dpr, 0, 0); // everything below is written in CSS pixels
|
||
seatWorld();
|
||
if (mapView && mapLabels) drawMapLabels();
|
||
drawBrush();
|
||
setCursor();
|
||
}
|
||
|
||
// The world is a cylinder: X wraps, so it has no side edges, but it does begin and end in Y. Mark where, and
|
||
// let the void darken as it meets them, so the map reads as a surface lying in space rather than as the
|
||
// background showing through. Everything here is drawn outside the world except the boundary line itself,
|
||
// so no cell the author is trying to read is dimmed by it.
|
||
function seatWorld() {
|
||
const top = -cam.y * cam.s, bot = top + worldSize()[1] * cam.s;
|
||
const fade = Math.min(30, Math.max(10, vh * 0.03));
|
||
|
||
if (top > 0) {
|
||
const g = cctx.createLinearGradient(0, top - fade, 0, top);
|
||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,.5)");
|
||
cctx.fillStyle = g;
|
||
cctx.fillRect(0, top - fade, vw, fade);
|
||
}
|
||
if (bot < vh) {
|
||
const g = cctx.createLinearGradient(0, bot, 0, bot + fade);
|
||
g.addColorStop(0, "rgba(0,0,0,.5)"); g.addColorStop(1, "rgba(0,0,0,0)");
|
||
cctx.fillStyle = g;
|
||
cctx.fillRect(0, bot, vw, fade);
|
||
}
|
||
|
||
cctx.fillStyle = "rgba(216,222,230,.22)";
|
||
if (top >= 1 && top <= vh) cctx.fillRect(0, Math.round(top) - 1, vw, 1);
|
||
if (bot >= 0 && bot <= vh - 1) cctx.fillRect(0, Math.round(bot), vw, 1);
|
||
}
|
||
|
||
// The region ids, written over the region map rather than baked into the PNG.
|
||
//
|
||
// In the image they would be numbers at the image's resolution: illegible at the zoom that shows the whole
|
||
// world, a block of soft pixels at the zoom that shows one coastline. Drawn here they are one size at every
|
||
// zoom, and they tile across the seam with everything else, so a landmass that wraps carries its number on
|
||
// both sides of the window rather than losing it at the edge.
|
||
//
|
||
// Numbers and not only colour, because these ids are what `bake --only` takes. Forty regions are forty hues
|
||
// however carefully the hues are chosen, and reading one off a swatch list is work a two-digit number does
|
||
// for you.
|
||
function drawMapLabels() {
|
||
const [worldW, worldH] = worldSize();
|
||
const w = worldW * cam.s, h = worldH * cam.s;
|
||
const y = -cam.y * cam.s;
|
||
let x0 = -cam.x * cam.s;
|
||
x0 = x0 % w; if (x0 > 0) x0 -= w;
|
||
|
||
cctx.font = `600 13px ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace`;
|
||
cctx.textAlign = "center";
|
||
cctx.textBaseline = "middle";
|
||
cctx.lineJoin = "round";
|
||
cctx.lineWidth = 3;
|
||
cctx.strokeStyle = "#0b0e11"; // the void: darker than anything the hue walk can produce, so a number reads
|
||
cctx.fillStyle = "#d8dee6";
|
||
for (const r of mapLabels) {
|
||
const py = y + r.v * h;
|
||
if (py < -8 || py > vh + 8) continue;
|
||
const text = String(r.id);
|
||
for (let x = x0; x < vw; x += w) {
|
||
const px = x + r.u * w;
|
||
if (px < -16 || px > vw + 16) continue;
|
||
cctx.strokeText(text, px, py);
|
||
cctx.fillText(text, px, py);
|
||
}
|
||
}
|
||
cctx.lineWidth = 1;
|
||
cctx.textAlign = "start";
|
||
cctx.textBaseline = "alphabetic";
|
||
}
|
||
|
||
// The brush, drawn where it will land. Four hundred pixels is four hundred pixels at any zoom and the slider
|
||
// cannot say how much of *this* view that is; the ring can, and it is the one reading that has to be right
|
||
// before the stroke rather than after it. Two rings, dark under bright, so it reads over ice and over ocean
|
||
// alike; the bright one carries the colour that is about to be painted.
|
||
function drawBrush() {
|
||
if (mapView || !cursor || panning || spaceDown) return;
|
||
const rad = Math.max(1.5, (parseInt($("#size").value) / 2) * cam.s);
|
||
const c = onOverlay() ? (marks()[ovSel] || null) : S.classes[sel];
|
||
cctx.beginPath();
|
||
cctx.arc(cursor[0], cursor[1], rad, 0, Math.PI * 2);
|
||
cctx.lineWidth = 3; cctx.strokeStyle = "rgba(6,9,13,.6)"; cctx.stroke();
|
||
cctx.lineWidth = 1.5;
|
||
cctx.strokeStyle = c ? `rgba(${c.rgb.join(",")},.95)` : "rgba(255,255,255,.9)";
|
||
cctx.stroke();
|
||
if (rad > 9) { // the centre, for placing a big brush; inside a small one it would be the whole brush
|
||
cctx.fillStyle = "rgba(6,9,13,.6)";
|
||
cctx.fillRect(cursor[0] - 2.5, cursor[1] - 0.5, 5, 1);
|
||
cctx.fillRect(cursor[0] - 0.5, cursor[1] - 2.5, 1, 5);
|
||
}
|
||
}
|
||
|
||
let shownCursor = "";
|
||
function setCursor() {
|
||
const want = mapView ? "default"
|
||
: panning ? "grabbing"
|
||
: spaceDown ? "grab"
|
||
: cursor ? "none" // the ring is the cursor
|
||
: "crosshair";
|
||
if (want !== shownCursor) { view.style.cursor = shownCursor = want; }
|
||
}
|
||
|
||
function updateHud() {
|
||
const h = $("#hud");
|
||
if (!cursor) { h.textContent = `zoom ${(cam.s * 100).toFixed(0)}% ${brushName()}`; return; }
|
||
const px = Math.floor(cam.x + cursor[0] / cam.s), py = Math.floor(cam.y + cursor[1] / cam.s);
|
||
const wx = ((px % S.paint_w) + S.paint_w) % S.paint_w;
|
||
h.textContent =
|
||
`${wx}, ${py} px ${(wx * S.metres_per_px / 1000).toFixed(2)} km east` +
|
||
`\nzoom ${(cam.s * 100).toFixed(0)}% ${brushName()}`;
|
||
}
|
||
|
||
// ---- the camera ---------------------------------------------------------------------------------
|
||
|
||
// Zoom eases to where the wheel asked for instead of jumping there, and holds the point under the cursor
|
||
// under the cursor the whole way. The jump is the harder thing to read: at these scales one notch of the
|
||
// wheel can be a factor of two, and the eye has nothing to follow it with.
|
||
let zoom = null; // {s, ax, ay, wx, wy}: the target, the anchor on screen, the world point beneath it
|
||
|
||
function easeZoom(dt) {
|
||
if (!zoom) return false;
|
||
cam.s += (zoom.s - cam.s) * (1 - Math.exp(-dt / 55));
|
||
if (Math.abs(zoom.s - cam.s) < zoom.s * 0.002) cam.s = zoom.s;
|
||
cam.x = zoom.wx - zoom.ax / cam.s;
|
||
cam.y = zoom.wy - zoom.ay / cam.s;
|
||
if (cam.s !== zoom.s) return true;
|
||
zoom = null; return false;
|
||
}
|
||
|
||
const minScale = () => vw / S.paint_w / 4;
|
||
|
||
function zoomTo(s, ax, ay) {
|
||
s = Math.min(16, Math.max(minScale(), s));
|
||
zoom = { s, ax, ay, wx: cam.x + ax / cam.s, wy: cam.y + ay / cam.s };
|
||
invalidate();
|
||
}
|
||
|
||
// X wraps, so cam.x is kept inside one turn of the world rather than allowed to wander off into the range
|
||
// where a float stops having sub-pixel precision. Y does not wrap, and a pan that loses the world entirely
|
||
// leaves nothing on screen to pan back by, so half of it always stays.
|
||
function clampCam() {
|
||
const h = worldSize()[1] * cam.s;
|
||
const keep = Math.min(vh, h) * 0.5;
|
||
let top = -cam.y * cam.s;
|
||
top = Math.min(vh - keep, Math.max(keep - h, top));
|
||
cam.y = -top / cam.s;
|
||
cam.x = ((cam.x % S.paint_w) + S.paint_w) % S.paint_w;
|
||
}
|
||
|
||
function fitWorld() {
|
||
zoom = null;
|
||
cam.s = vw / S.paint_w;
|
||
cam.x = 0; cam.y = (S.paint_h - vh / cam.s) / 2;
|
||
invalidate();
|
||
}
|
||
|
||
// ---- painting -----------------------------------------------------------------------------------
|
||
|
||
function toPaint(ev) {
|
||
const px = Math.floor(cam.x + (ev.clientX - vrect.left) / cam.s);
|
||
const py = Math.floor(cam.y + (ev.clientY - vrect.top) / cam.s);
|
||
return [((px % S.paint_w) + S.paint_w) % S.paint_w, py];
|
||
}
|
||
|
||
// A hard-edged disc written straight into the ImageData. No canvas arc/fill: those antialias, and a
|
||
// blended pixel is not a colour between two classes - it is a pixel that classifies as whichever third
|
||
// class happens to sit near the midpoint, which is the artefact the despeckle pass exists to clean up.
|
||
function stamp(cx, cy, r, rgb, alpha, into) {
|
||
const W = S.paint_w, H = S.paint_h, d = into.data, r2 = r * r;
|
||
let x0 = Math.floor(cx - r), x1 = Math.ceil(cx + r);
|
||
let y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
|
||
keepTiles(x0, y0, x1, y1); // before the first write, because what it keeps is what undo puts back
|
||
for (let y = y0; y <= y1; y++) {
|
||
const dy = y - cy;
|
||
for (let x = x0; x <= x1; x++) {
|
||
const dx = x - cx;
|
||
if (dx * dx + dy * dy > r2) continue;
|
||
const wx = ((x % W) + W) % W; // the brush wraps round the seam, because the world does
|
||
const o = (y * W + wx) * 4;
|
||
d[o] = rgb[0]; d[o + 1] = rgb[1]; d[o + 2] = rgb[2]; d[o + 3] = alpha === undefined ? 255 : alpha;
|
||
}
|
||
}
|
||
return [x0, y0, x1, y1];
|
||
}
|
||
|
||
const grow = (a, b) => !a ? b : !b ? a
|
||
: [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
|
||
|
||
let drawing = false, panning = false, spaceDown = false;
|
||
let pan0 = null; // the last pan position, in client pixels
|
||
let cursor = null; // the pointer over the stage, in CSS pixels, or null when it is elsewhere
|
||
const queue = []; // paint-pixel positions the pointer has visited since the last frame
|
||
let strokeFrom = null; // where the stroke was when the last frame ended
|
||
|
||
// drainStroke lays down everything the pointer did since the last frame, in one pass, and sends the one
|
||
// rectangle that covers all of it. What used to be a stamp, a full-width band upload and a whole-world
|
||
// repaint per event is now all three per frame.
|
||
function drainStroke() {
|
||
if (!queue.length) return;
|
||
let rgb, alpha, into, tex, bytes;
|
||
if (onPlates()) {
|
||
// No eraser here: every pixel of a tectonic layer is some plate, so painting one over another is the
|
||
// only edit there is.
|
||
if (!plFull) { queue.length = 0; return; }
|
||
const p = platesOf()[plSel];
|
||
if (!p) { queue.length = 0; return; }
|
||
rgb = p.rgb; into = plFull; tex = texPl; bytes = plBytes;
|
||
} else if (onOverlay()) {
|
||
if (!ovFull) { queue.length = 0; return; }
|
||
// The eraser writes transparent black rather than a colour: alpha is what says "nothing is marked here",
|
||
// and a mark scrubbed out with a background colour would still classify as whatever that colour is
|
||
// nearest to.
|
||
const m = marks()[ovSel];
|
||
rgb = m ? m.rgb : [0, 0, 0];
|
||
alpha = m ? 255 : 0;
|
||
into = ovFull; tex = texOv; bytes = ovBytes;
|
||
} else {
|
||
const c = S.classes[sel];
|
||
if (!c) { queue.length = 0; return; }
|
||
rgb = c.rgb; into = full; tex = texPaint; bytes = paintBytes;
|
||
}
|
||
beginTake(into); // the step this stroke will become; stamp fills it in as it goes
|
||
|
||
const r = Math.max(0.5, parseInt($("#size").value) / 2);
|
||
let rect = null;
|
||
for (const p of queue) {
|
||
if (!strokeFrom) rect = grow(rect, stamp(p[0], p[1], r, rgb, alpha, into));
|
||
else {
|
||
// Interpolate along the drag so a fast stroke is a line and not a row of dots.
|
||
let dx = p[0] - strokeFrom[0];
|
||
if (dx > S.paint_w / 2) dx -= S.paint_w; else if (dx < -S.paint_w / 2) dx += S.paint_w;
|
||
const dy = p[1] - strokeFrom[1];
|
||
const n = Math.max(1, Math.ceil(Math.hypot(dx, dy) / Math.max(1, r * 0.6)));
|
||
for (let i = 1; i <= n; i++) {
|
||
rect = grow(rect, stamp(strokeFrom[0] + dx * i / n, strokeFrom[1] + dy * i / n, r, rgb, alpha, into));
|
||
}
|
||
}
|
||
strokeFrom = p;
|
||
}
|
||
queue.length = 0;
|
||
if (rect) pushRect(tex, bytes, rect);
|
||
}
|
||
|
||
function brushName() {
|
||
if (onPlates()) {
|
||
const p = platesOf()[plSel];
|
||
return "plate: " + (p ? p.name : "none");
|
||
}
|
||
if (onOverlay()) {
|
||
const m = marks()[ovSel];
|
||
return "overlay: " + (m ? m.name : "erase");
|
||
}
|
||
return S.classes[sel] ? S.classes[sel].name : "";
|
||
}
|
||
|
||
// ---- undo ---------------------------------------------------------------------------------------
|
||
//
|
||
// One stroke is one step, which is what a hand expects: brush down, drag, brush up, and ctrl+z takes all of
|
||
// it back at once rather than the last frame of it.
|
||
//
|
||
// What a step keeps is the pixels the stroke covered, never the sheet it covered them on - a sheet is a
|
||
// hundred and sixteen megabytes and a stack of those is not a stack. So the sheet is read as tiles and a tile
|
||
// is copied out the first time a stroke writes into it. Copy-on-write, with the grain coarse enough that the
|
||
// bookkeeping is a Map lookup per stamp rather than rectangle algebra, and fine enough that a dab costs a
|
||
// quarter of a megabyte instead of the world. A tile never straddles the seam, because the grid starts at
|
||
// column zero and the last column is the short one, so putting a tile back is one upload with no wrap in it.
|
||
|
||
const TILE = 256;
|
||
// What the undo stack may hold before its oldest step is dropped. It bounds both stacks: any new stroke
|
||
// empties redo, and a step *moves* between the two rather than being copied into it.
|
||
const UNDO_LIMIT = 192 << 20;
|
||
|
||
const undoStack = [], redoStack = [];
|
||
let taking = null; // the step being built while the brush is down
|
||
|
||
const tileCols = () => Math.ceil(S.paint_w / TILE);
|
||
const tileW = tx => Math.min(TILE, S.paint_w - tx * TILE);
|
||
const tileH = ty => Math.min(TILE, S.paint_h - ty * TILE);
|
||
|
||
function readTile(img, tx, ty) {
|
||
const w = tileW(tx), h = tileH(ty), W = S.paint_w, d = img.data;
|
||
const out = new Uint8Array(w * h * 4);
|
||
for (let y = 0; y < h; y++) {
|
||
const src = ((ty * TILE + y) * W + tx * TILE) * 4;
|
||
out.set(d.subarray(src, src + w * 4), y * w * 4);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function writeTile(img, tx, ty, px) {
|
||
const w = tileW(tx), h = tileH(ty), W = S.paint_w, d = img.data;
|
||
for (let y = 0; y < h; y++) {
|
||
const dst = ((ty * TILE + y) * W + tx * TILE) * 4;
|
||
d.set(px.subarray(y * w * 4, (y + 1) * w * 4), dst);
|
||
}
|
||
}
|
||
|
||
// keepTiles copies whatever a rectangle is about to overwrite, once per tile per stroke. It is called from
|
||
// stamp before stamp writes, so there is no path by which a pixel changes that undo has not already recorded.
|
||
//
|
||
// The rectangle arrives unwrapped - a brush at x=2 starts at -98 - and it is wrapped into runs *before* being
|
||
// cut into tiles, never after. Tile columns are only meaningful inside [0, W), because the world is 7738 wide
|
||
// and the tile is 256: the last column is the short one, so the grid does not line up with itself across the
|
||
// seam and one negative tile index covers parts of two real columns. Cutting first and wrapping afterwards
|
||
// kept 30 and 0 for a brush that had written into 29 as well, and the pixels it missed were unrecoverable.
|
||
function keepTiles(x0, y0, x1, y1) {
|
||
if (!taking || y1 < y0) return;
|
||
const W = S.paint_w;
|
||
const span = Math.min(W, x1 - x0 + 1);
|
||
const a = ((x0 % W) + W) % W;
|
||
if (a + span <= W) keepRun(a, a + span - 1, y0, y1);
|
||
else { keepRun(a, W - 1, y0, y1); keepRun(0, a + span - W - 1, y0, y1); }
|
||
}
|
||
|
||
function keepRun(xa, xb, y0, y1) {
|
||
const cols = tileCols();
|
||
for (let ty = Math.floor(y0 / TILE), lastY = Math.floor(y1 / TILE); ty <= lastY; ty++) {
|
||
for (let tx = Math.floor(xa / TILE), lastX = Math.floor(xb / TILE); tx <= lastX; tx++) {
|
||
const key = ty * cols + tx;
|
||
if (taking.tiles.has(key)) continue;
|
||
const px = readTile(taking.into, tx, ty);
|
||
taking.tiles.set(key, px);
|
||
taking.bytes += px.byteLength;
|
||
}
|
||
}
|
||
}
|
||
|
||
function beginTake(into) { if (!taking) taking = { into, tiles: new Map(), bytes: 0 }; }
|
||
|
||
function endTake() {
|
||
if (!taking) return;
|
||
if (taking.tiles.size) {
|
||
undoStack.push(taking);
|
||
redoStack.length = 0; // the future stops being reachable the moment the past is painted over
|
||
let total = 0;
|
||
for (const s of undoStack) total += s.bytes;
|
||
while (total > UNDO_LIMIT && undoStack.length > 1) total -= undoStack.shift().bytes;
|
||
}
|
||
taking = null;
|
||
}
|
||
|
||
// A step swaps what the record holds against what is on the sheet now, which both applies it and turns it
|
||
// into the record that puts it back: undo and redo are one move in opposite directions, and neither needs a
|
||
// copy the other does not already hold.
|
||
//
|
||
// Both sheets are on screen at all times - the annotation layer is only dimmed while the brush is on the
|
||
// classes, never hidden - so a step shows whichever tab it belongs to and no tab has to be switched under
|
||
// anybody. A map view does have to go, because it is the one thing covering what just changed.
|
||
function stepBack(from, to, nothing) {
|
||
if (!from.length) { say(nothing, "ok"); return; }
|
||
if (mapView) clearMapView();
|
||
const rec = from.pop();
|
||
const cols = tileCols();
|
||
const tex = rec.into === full ? texPaint : rec.into === plFull ? texPl : texOv;
|
||
const bytes = rec.into === full ? paintBytes : rec.into === plFull ? plBytes : ovBytes;
|
||
const now = new Map();
|
||
for (const [key, px] of rec.tiles) {
|
||
const tx = key % cols, ty = (key - tx) / cols;
|
||
now.set(key, readTile(rec.into, tx, ty));
|
||
writeTile(rec.into, tx, ty, px);
|
||
pushRect(tex, bytes, [tx * TILE, ty * TILE, tx * TILE + tileW(tx) - 1, ty * TILE + tileH(ty) - 1]);
|
||
}
|
||
rec.tiles = now;
|
||
to.push(rec);
|
||
invalidate();
|
||
}
|
||
|
||
// ---- input --------------------------------------------------------------------------------------
|
||
|
||
view.addEventListener("pointerdown", ev => {
|
||
if (mapView) {
|
||
say(mapView.name === "bake"
|
||
? "showing the bake preview. Escape, or “back to painting” under the key, to paint again"
|
||
: "showing a plan map; press its button again, or Escape, to paint");
|
||
return;
|
||
}
|
||
view.setPointerCapture(ev.pointerId);
|
||
if (ev.button === 1 || ev.button === 2 || ev.shiftKey || spaceDown) {
|
||
panning = true; zoom = null; pan0 = [ev.clientX, ev.clientY];
|
||
} else {
|
||
drawing = true; strokeFrom = null; queue.push(toPaint(ev));
|
||
}
|
||
invalidate();
|
||
});
|
||
|
||
view.addEventListener("pointermove", ev => {
|
||
cursor = [ev.clientX - vrect.left, ev.clientY - vrect.top];
|
||
if (panning) {
|
||
cam.x -= (ev.clientX - pan0[0]) / cam.s;
|
||
cam.y -= (ev.clientY - pan0[1]) / cam.s;
|
||
pan0 = [ev.clientX, ev.clientY];
|
||
} else if (drawing) {
|
||
// Every position the pointer actually visited, not only the one that survived to this event. A mouse
|
||
// polling at a kilohertz reports a dozen or more between two frames, and a stroke drawn through one of
|
||
// them is a polygon with its corners cut off.
|
||
const pts = ev.getCoalescedEvents ? ev.getCoalescedEvents() : [];
|
||
if (pts.length) for (const p of pts) queue.push(toPaint(p));
|
||
else queue.push(toPaint(ev));
|
||
}
|
||
invalidate();
|
||
});
|
||
|
||
view.addEventListener("pointerleave", () => { cursor = null; invalidate(); });
|
||
addEventListener("pointerup", () => {
|
||
drawing = panning = false; strokeFrom = null;
|
||
endTake(); // brush up is where one stroke becomes one step
|
||
invalidate(); // the frame that folds the last of the stroke into the mip chain
|
||
});
|
||
view.addEventListener("contextmenu", e => e.preventDefault());
|
||
|
||
view.addEventListener("wheel", ev => {
|
||
ev.preventDefault();
|
||
// Chained onto the target rather than onto wherever the ease has reached, so three quick notches travel
|
||
// three notches instead of stalling at the first.
|
||
zoomTo((zoom ? zoom.s : cam.s) * Math.exp(-ev.deltaY * 0.0015),
|
||
ev.clientX - vrect.left, ev.clientY - vrect.top);
|
||
}, { passive: false });
|
||
|
||
new ResizeObserver(() => { resize(); invalidate(); }).observe($("#stage"));
|
||
|
||
// The driver can take the context away - a shader compile in the engine next door is enough to do it - and
|
||
// before this that left a black rectangle with no way back but a page reload, and an unsaved painting behind
|
||
// it. Both sheets are still in JavaScript, so restoring them is just uploading them again.
|
||
view.addEventListener("webglcontextlost", ev => {
|
||
ev.preventDefault();
|
||
say("the browser dropped the GPU context. Rebuilding — the painting is safe.", "err");
|
||
});
|
||
view.addEventListener("webglcontextrestored", () => {
|
||
initGL();
|
||
uploadWorld();
|
||
clearMapView(); // the map textures went with the context, and the painting is the way back
|
||
say("");
|
||
invalidate();
|
||
});
|
||
|
||
$("#size").oninput = () => {
|
||
$("#sizeN").textContent = $("#size").value;
|
||
$("#sizeM").textContent =
|
||
`${(parseInt($("#size").value) * S.metres_per_px).toFixed(0)} m across on the ground`;
|
||
invalidate(); // the ring is the brush size, so it changes when the slider does
|
||
};
|
||
|
||
// ---- save and plan ------------------------------------------------------------------------------
|
||
|
||
// A sheet leaves the browser only when somebody saves, plans or bakes, so it is encoded here rather than
|
||
// kept as a second copy of the world in a canvas the whole time. The full putImageData that the old blit
|
||
// paid on every mouse move now happens once, on the way out, and the canvas is released straight after.
|
||
async function push(url, img, save) {
|
||
const c = document.createElement("canvas");
|
||
c.width = img.width; c.height = img.height;
|
||
c.getContext("2d").putImageData(img, 0, 0);
|
||
const blob = await new Promise(res => c.toBlob(res, "image/png"));
|
||
c.width = c.height = 0;
|
||
const r = await fetch(url + (save ? "?save=1" : ""), { method: "POST", body: blob });
|
||
if (!r.ok) { say(await r.text(), "err"); return null; }
|
||
return r.json();
|
||
}
|
||
|
||
// Every sheet goes up before a plan, because any of them can change the answer: the classes decide the
|
||
// geology, the overlay's coast_jitter marks decide where the waterline ends up, and the tectonic layer
|
||
// decides every margin and therefore the whole fault set.
|
||
async function pushPainting(save) {
|
||
const j = await push("/api/paint", full, save);
|
||
if (!j) return null;
|
||
if (ovFull && !await push("/api/overlay", ovFull, save)) return null;
|
||
if (plFull && !await push("/api/plates", plFull, save)) return null;
|
||
return j;
|
||
}
|
||
|
||
function saveLabel() {
|
||
return onPlates() ? "Save plates" : onOverlay() ? "Save overlay" : "Save painting";
|
||
}
|
||
|
||
$("#savePaint").onclick = async () => {
|
||
const b = $("#savePaint"); b.disabled = true;
|
||
const label = saveLabel();
|
||
b.textContent = "saving…";
|
||
const j = onPlates() ? await push("/api/plates", plFull, true)
|
||
: onOverlay() ? await push("/api/overlay", ovFull, true)
|
||
: await push("/api/paint", full, true);
|
||
b.disabled = false; b.textContent = label;
|
||
if (j) say("saved to " + j.saved + (j.repointed ? "\nthe manifest now points at it" : ""), "ok");
|
||
};
|
||
|
||
// Fill the annotation sheet in from the world. See internal/studio/overlaygen.go.
|
||
//
|
||
// The sheet is replaced wholesale rather than stroked, so the undo tiles no longer describe it and the
|
||
// history is dropped. That is said out loud in the status line: an author who has just spent ten minutes
|
||
// painting and then presses this deserves to know that ctrl+z will not walk back over it - though nothing
|
||
// they painted has been touched, because generation only ever fills blanks.
|
||
$("#genOv").onclick = async () => {
|
||
const b = $("#genOv");
|
||
b.disabled = true;
|
||
const was = b.textContent;
|
||
b.textContent = "generating…";
|
||
try {
|
||
// The sheet on the server has to be the sheet on screen, or the generator fills around a stale file.
|
||
if (!await pushPainting(false)) return;
|
||
const r = await fetch("/api/overlay/generate", { method: "POST", body: "{}" });
|
||
if (!r.ok) { say(await r.text(), "err"); return; }
|
||
const j = await r.json();
|
||
|
||
// Read the new sheet back and put it on the screen. One full-width rectangle is one upload; the
|
||
// ImageData is reused rather than replaced so that ovBytes, which the GPU path holds, stays valid.
|
||
const fresh = await decodeSheet("/api/overlay.png?" + Date.now());
|
||
ovFull.data.set(fresh.data);
|
||
pushRect(texOv, ovBytes, [0, 0, S.paint_w - 1, S.paint_h - 1]);
|
||
undoStack.length = 0; redoStack.length = 0;
|
||
invalidate();
|
||
|
||
let t = j.from_bake
|
||
? `generated from ${j.bake}, seed ${j.seed}\n`
|
||
: `generated from the painting alone, seed ${j.seed}\n` +
|
||
`no bake yet, so there are no rivers to sit on and no slope to bend a road around.\n` +
|
||
`Bake first and press again for a draft that reads the terrain.\n`;
|
||
t += "\n" + j.marks.join("\n") + "\n";
|
||
for (const line of j.lines) t += line + "\n";
|
||
t += "\nPress again for another. Undo history was cleared: the sheet was replaced, not stroked.\n" +
|
||
"Nothing you painted was touched - generation only fills blanks.\n";
|
||
$("#report").textContent = t;
|
||
say(j.from_bake ? "marks generated" : "marks generated (no bake: a sketch)", "ok");
|
||
} finally { b.disabled = false; b.textContent = was; }
|
||
};
|
||
|
||
$("#plan").onclick = async () => {
|
||
const b = $("#plan"); b.disabled = true; b.textContent = "planning…";
|
||
try {
|
||
const applied = await flushPending();
|
||
if (applied === null) return;
|
||
if (!await pushPainting(false)) return;
|
||
const r = await fetch("/api/plan", { method: "POST" });
|
||
if (!r.ok) { say(await r.text(), "err"); return; }
|
||
const j = await r.json();
|
||
showReport(j, applied);
|
||
} finally { b.disabled = false; b.textContent = "Plan (4 s)"; }
|
||
};
|
||
|
||
function showReport(j, applied) {
|
||
const rep = j.report;
|
||
let t = "";
|
||
// Said rather than done silently: applying these writes the author's files, and a run that quietly edited
|
||
// Planet.json is a run they cannot account for afterwards.
|
||
if (applied && applied.length) t += `applied ${applied.join(", ")} before planning\n`;
|
||
t += `planned in ${j.seconds.toFixed(1)} s` +
|
||
(j.cached ? ` (legend only: the prepare was reused)\n` : `\n`);
|
||
if (rep.wrap_land_against_sea > 0) {
|
||
t += `\nSEAM: the two edges disagree on ${rep.wrap_differ} of ${rep.wrap_rows} rows,\n` +
|
||
`${rep.wrap_land_against_sea} of them land against water. Paint round the edge.\n`;
|
||
}
|
||
t += `\nclass share at a divide\n`;
|
||
for (const c of rep.classes) {
|
||
if (c.sea) continue;
|
||
t += ` ${c.name.padEnd(11)} ${(100 * c.share).toFixed(1).padStart(5)}% ` +
|
||
`${c.divide_deg.toFixed(1).padStart(5)}° ${c.reads_as || ""}\n`;
|
||
if (c.massif_fraction) {
|
||
t += ` ${(100 * c.massif_fraction).toFixed(0)}% massif, rest ` +
|
||
`${c.floor_mm_yr} mm/yr ${c.floor_divide_deg.toFixed(1)}° ${c.floor_reads_as}\n`;
|
||
}
|
||
}
|
||
if (rep.faults || rep.lithology_wavelength_km) {
|
||
t += `
|
||
the seed re-rolls, the painting does not
|
||
`;
|
||
if (rep.lithology_wavelength_km) {
|
||
t += ` rock ${rep.lithology_types} types over ${rep.lithology_wavelength_km.toFixed(1)} km provinces
|
||
`;
|
||
}
|
||
if (rep.faults) {
|
||
t += ` faults ${rep.faults} traces on a ${rep.fault_grain_km.toFixed(0)} km grain
|
||
`;
|
||
for (const c of rep.classes) {
|
||
if (c.faults) t += ` ${c.name.padEnd(11)} ${String(c.faults).padStart(4)} throw ` +
|
||
`${c.fault_throw_m[0]}..${c.fault_throw_m[1]} m
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
if (j.overlay) {
|
||
t += `\noverlay painted pieces\n`;
|
||
for (const m of j.overlay.marks) {
|
||
t += ` ${m.name.padEnd(11)} ${m.area_km2.toFixed(2).padStart(7)} km2 ${String(m.pieces).padStart(5)}` +
|
||
(m.has_coast_jitter ? (m.coast_jitter === 0 ? " coast pinned" : ` coast x${m.coast_jitter}`) : "") + `\n`;
|
||
}
|
||
if (j.overlay.far_px) t += ` ${j.overlay.far_px} px match no mark and were dropped\n`;
|
||
}
|
||
t += `\n${rep.regions.length} regions, ${(rep.estimate_minutes).toFixed(0)} min of solve estimated\n`;
|
||
for (const r of rep.regions.slice(0, 6)) {
|
||
t += ` ${String(r.id).padStart(3)} ${r.width_km.toFixed(1)} x ${r.height_km.toFixed(1)} km` +
|
||
` ${r.estimate_minutes.toFixed(1)} min\n`;
|
||
}
|
||
$("#report").textContent = t;
|
||
|
||
const m = $("#maps"); m.innerHTML = "";
|
||
for (const name of j.maps) {
|
||
const b = document.createElement("button");
|
||
b.textContent = name.replace("map_", "");
|
||
b.onclick = async () => {
|
||
if (mapView && mapView.name === name) { clearMapView(); return; }
|
||
await showMap(name, `/api/map/${name}.png?${j.stamp}`,
|
||
name === "map_regions" ? j.scales.regions : null);
|
||
[...m.children].forEach(x => x.classList.remove("on"));
|
||
b.classList.add("on");
|
||
showKey(name, j.scales);
|
||
};
|
||
m.appendChild(b);
|
||
}
|
||
}
|
||
|
||
// showMap puts a plan map or a bake preview over the painting. One texture, reused: only one of them is ever
|
||
// on screen, and a plan writes six of these.
|
||
async function showMap(name, url, labels) {
|
||
const bmp = await createImageBitmap(await (await fetch(url)).blob());
|
||
if (!texMap) texMap = gl.createTexture();
|
||
gl.bindTexture(gl.TEXTURE_2D, texMap);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, bmp);
|
||
gl.generateMipmap(gl.TEXTURE_2D);
|
||
mapView = { name, w: bmp.width, h: bmp.height };
|
||
mapLabels = labels || null;
|
||
bmp.close();
|
||
invalidate();
|
||
}
|
||
|
||
function clearMapView() {
|
||
mapView = null;
|
||
mapLabels = null;
|
||
[...$("#maps").children].forEach(x => x.classList.remove("on"));
|
||
$("#key").className = "";
|
||
$("#key").innerHTML = "";
|
||
invalidate();
|
||
}
|
||
|
||
// Every map view needs a way out that can be seen. Escape has always worked, and a key panel that tells you to
|
||
// press Escape is a panel explaining a control it could simply be - which matters most for the bake preview,
|
||
// the one map view with no button of its own to press again.
|
||
function appendKeyExit() {
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const b = document.createElement("button");
|
||
b.textContent = "← back to painting";
|
||
b.title = "Escape does this too";
|
||
b.onclick = clearMapView;
|
||
bar.appendChild(b);
|
||
$("#key").appendChild(bar);
|
||
}
|
||
|
||
const rgbOf = c => `rgb(${c[0]},${c[1]},${c[2]})`;
|
||
|
||
// A map with no key is a picture. These come from the plan reply, which takes them from the code that drew
|
||
// the maps, so the key cannot drift away from the thing it describes.
|
||
function showKey(name, sc) {
|
||
const k = $("#key"); k.className = "on"; k.innerHTML = "";
|
||
showKeyBody(name, sc);
|
||
appendKeyExit();
|
||
}
|
||
|
||
function showKeyBody(name, sc) {
|
||
const k = $("#key");
|
||
const title = t => `<div class="title">${t}</div>`;
|
||
const ramp = (stops, lo, hi) =>
|
||
`<div class="ramp" style="background:linear-gradient(to right,${stops.map(rgbOf).join(",")})"></div>
|
||
<div class="ends"><span>${lo}</span><span>${hi}</span></div>`;
|
||
|
||
if (name === "map_class") {
|
||
const rows = S.classes.filter(c => !c.derived || c.name === "ice").map(c =>
|
||
`<div class="item"><span class="chip" style="background:${rgbOf(c.rgb)}"></span>${c.name}</div>`).join("");
|
||
k.innerHTML = title("class") + `<div class="grid">${rows}</div>` +
|
||
`<p class="note">The painting after the strokes are dissolved and the specks removed — what the
|
||
solve will actually read, which is not always what you drew.</p>`;
|
||
return;
|
||
}
|
||
if (name === "map_uplift") {
|
||
k.innerHTML = title("uplift, mm/yr") + ramp(sc.uplift.ramp, "0", sc.uplift.hi.toFixed(3)) +
|
||
`<p class="note">The field everything else is a consequence of, massifs included — so a class with a
|
||
massif shows as plain with upland in it, not one flat colour. The cyan lines are the fault traces:
|
||
a rate difference across a line, steep one side and gentle the other, which erosion carves into a
|
||
scarp. The line rather than what it contributes, because a scarp is two hundred metres wide and this
|
||
map is a hundred kilometres across.</p>`;
|
||
return;
|
||
}
|
||
if (name === "map_erodibility") {
|
||
k.innerHTML = title("k_mult") +
|
||
ramp(sc.erodibility.ramp, sc.erodibility.lo.toFixed(2), sc.erodibility.hi.toFixed(2)) +
|
||
`<p class="note">The multiplier on stream-power K. Below 1 is hard rock: less water doing less work,
|
||
so steeper and more angular ground. Each class's own <code>k_mult</code> times the planet's rock
|
||
field, which the seed re-rolls and <code>lithology_mix</code> lets through class by class.</p>`;
|
||
return;
|
||
}
|
||
if (name === "map_overlay") {
|
||
const rows = marks().map(m =>
|
||
`<div class="item"><span class="chip" style="background:${rgbOf(m.rgb)}"></span>${m.name}</div>`).join("");
|
||
k.innerHTML = title("overlay marks") + `<div class="grid">${rows}</div>` +
|
||
`<p class="note">The annotation layer over a dimmed class map. Only <b>coast_jitter</b> is read by the
|
||
generator; everything else comes out as a per-tile mask and as features in world metres in
|
||
<code>overlay.json</code>.</p>`;
|
||
return;
|
||
}
|
||
if (name === "map_regions") {
|
||
const rows = sc.regions.slice(0, 24).map(r =>
|
||
`<div class="item"><span class="chip" style="background:${rgbOf(r.rgb)}"></span>${r.id}</div>`).join("");
|
||
const hidden = sc.regions.length - Math.min(24, sc.regions.length);
|
||
k.innerHTML = title(`regions (${sc.regions.length})`) + `<div class="grid">${rows}</div>` +
|
||
(hidden > 0 ? `<p class="note">${hidden} more, numbered on the map.</p>` : "") +
|
||
`<p class="note">One colour per landmass, its ocean margin the same colour dimmed. A landmass across
|
||
the seam must be <b>one</b> colour — check the number, not the hue: the ids are written on the map at
|
||
the centre of each region's land, and a wrapped landmass carries its number on both sides of the seam.
|
||
These ids are what <code>bake --only</code> takes.</p>`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- bake ---------------------------------------------------------------------------------------
|
||
|
||
// A bake is hours, so it runs in the server and the page watches it. What makes watching worth anything is
|
||
// that the solve is decomposed per landmass: each one comes out whole, so the preview fills in a continent at
|
||
// a time and the first one answers "is this the world I meant" long before the last one finishes.
|
||
let bakeTimer = null, bakeStamp = 0, bakeSeen = false;
|
||
const bakeOpts = { only: "", steps: 0, jobs: 4 };
|
||
|
||
function buildBake() {
|
||
const el = $("#bakeCtl"); el.innerHTML = "";
|
||
|
||
const r1 = document.createElement("div"); r1.className = "row";
|
||
const l1 = document.createElement("label"); l1.textContent = "regions (blank = all)";
|
||
const i1 = document.createElement("input");
|
||
i1.type = "text"; i1.placeholder = "11,13"; i1.style.width = "78px";
|
||
i1.oninput = () => bakeOpts.only = i1.value;
|
||
r1.append(l1, i1); el.appendChild(r1);
|
||
|
||
el.appendChild(numRow("steps (0 = full)", 0, 50, v => bakeOpts.steps = v | 0));
|
||
el.appendChild(numRow("jobs", 4, 1, v => bakeOpts.jobs = v | 0));
|
||
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const go = document.createElement("button"); go.textContent = "Bake"; go.className = "primary";
|
||
const stop = document.createElement("button"); stop.textContent = "Cancel"; stop.className = "warn";
|
||
stop.style.display = "none";
|
||
const show = document.createElement("button"); show.textContent = "Preview";
|
||
show.title = "the last preview this bake produced";
|
||
show.disabled = true;
|
||
show.onclick = () => showBakePreview(bakeStamp, false);
|
||
bar.append(go, stop, show); el.appendChild(bar);
|
||
|
||
go.onclick = async () => {
|
||
const only = bakeOpts.only.split(",").map(x => parseInt(x.trim())).filter(x => !isNaN(x));
|
||
if (!only.length && !confirm([
|
||
"Bake every region? On the 100 km template that is about two hours.",
|
||
"",
|
||
"The preview fills in a landmass at a time, and Cancel stops it.",
|
||
"A few region ids in the box, or a low step count, is the quick version.",
|
||
].join("\n"))) return;
|
||
go.disabled = true;
|
||
// The flush Plan does, and here it is worth two hours rather than four seconds. A bake reloads the manifest
|
||
// from disk and solves the *server's* copy of the painting, so an unsaved seed, or a stroke the browser has
|
||
// not pushed since the last plan, is two hours spent answering a question nobody asked.
|
||
if (await flushPending() === null || !await pushPainting(false)) { go.disabled = false; return; }
|
||
const r = await fetch("/api/bake", {
|
||
method: "POST",
|
||
body: JSON.stringify({ only, steps: bakeOpts.steps, jobs: bakeOpts.jobs }),
|
||
});
|
||
if (!r.ok) { go.disabled = false; return say(await r.text(), "err"); }
|
||
say("baking. The preview updates as each landmass lands.", "ok");
|
||
pollBake(); // which owns go.disabled from here: it is the thing that knows whether a bake is running
|
||
};
|
||
stop.onclick = () => fetch("/api/bake/cancel", { method: "POST" });
|
||
|
||
buildBake.go = go; buildBake.stop = stop; buildBake.show = show;
|
||
}
|
||
|
||
async function pollBake() {
|
||
let j;
|
||
try { j = await (await fetch("/api/bake")).json(); } catch (e) { return; }
|
||
const el = $("#bakeStatus");
|
||
|
||
// A finished bake keeps its status and its stamp for as long as the studio process runs. Without this, the
|
||
// first poll of *every* page load would open the preview of a bake that ended hours ago - over the painting,
|
||
// blocking the brush, with no button of its own to press again - and reloading the page, the obvious way out
|
||
// of a stuck screen, would put it straight back. So the first poll of a load that finds nothing running
|
||
// adopts the stamp instead of drawing it. A bake that finishes while somebody is watching still lands its
|
||
// final preview, because that poll finds it running.
|
||
if (!bakeSeen) {
|
||
bakeSeen = true;
|
||
if (!j.running) bakeStamp = j.stamp;
|
||
}
|
||
|
||
buildBake.go.disabled = j.running;
|
||
buildBake.stop.style.display = j.running ? "" : "none";
|
||
buildBake.show.disabled = !j.stamp;
|
||
|
||
const rows = [];
|
||
if (j.running || j.finished) {
|
||
const state = j.running ? "baking" : (j.err ? "failed" : "done");
|
||
rows.push("<b>" + state + "</b> " + j.done.length + "/" + (j.total || "?") +
|
||
" regions " + (j.seconds / 60).toFixed(1) + " min");
|
||
}
|
||
if (j.err) rows.push(esc(j.err));
|
||
if (j.note) rows.push(esc(j.note));
|
||
for (const d of j.done.slice(-8)) {
|
||
rows.push(" " + String(d.ID).padStart(3) + " " +
|
||
d.MinM.toFixed(0) + ".." + d.MaxM.toFixed(0) + " m " +
|
||
(100 * d.ClipFrac).toFixed(2) + "% clipped " + d.Seconds.toFixed(0) + " s");
|
||
}
|
||
|
||
const frac = j.total ? j.done.length / j.total : 0;
|
||
let html = '<div class="prog"><div style="width:' + (100 * frac).toFixed(1) + '%"></div></div>';
|
||
html += rows.join("\n");
|
||
const tail = j.lines.slice(-4).map(esc).join("\n");
|
||
if (tail) html += '\n<span style="opacity:.65">' + tail + "</span>";
|
||
el.innerHTML = html;
|
||
|
||
// A new preview only arrives when a region lands, so the image is fetched on the stamp rather than on the
|
||
// poll: otherwise this would re-decode the same picture every two seconds for two hours.
|
||
if (j.stamp && j.stamp !== bakeStamp) {
|
||
bakeStamp = j.stamp;
|
||
await showBakePreview(j.stamp, j.running);
|
||
}
|
||
|
||
clearTimeout(bakeTimer);
|
||
if (j.running) bakeTimer = setTimeout(pollBake, 2000);
|
||
}
|
||
|
||
// showBakePreview draws the planet as the bake has it so far. Separate from the poll because the Preview button
|
||
// calls it too: a finished bake is a picture somebody will want to look at again after they have gone back to
|
||
// painting, and before this there was no way to ask for it.
|
||
async function showBakePreview(stamp, running) {
|
||
try {
|
||
await showMap("bake", "/api/bake/preview.png?" + stamp, null);
|
||
[...$("#maps").children].forEach(x => x.classList.remove("on"));
|
||
$("#key").className = "on";
|
||
$("#key").innerHTML = running
|
||
? '<div class="title">bake in progress</div>' +
|
||
'<p class="note">The planet as it stands, one landmass at a time. The preview refreshes as each ' +
|
||
'region lands.</p>'
|
||
: '<div class="title">bake finished</div>' +
|
||
'<p class="note">The planet as this bake left it. Nothing more is coming; go back to painting and ' +
|
||
'press Preview under <b>bake</b> to see it again.</p>';
|
||
appendKeyExit();
|
||
invalidate();
|
||
} catch (e) { /* the first poll can beat the first preview */ }
|
||
}
|
||
|
||
// A shortcut is only a shortcut when the hands are on the map. Typed into the region box or a number field
|
||
// these were characters the author meant to type, and `o` silently swapping the sheet under a half-written
|
||
// number is the worst of them.
|
||
const typing = t => t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
|
||
|
||
addEventListener("keydown", ev => {
|
||
if (typing(ev.target)) return;
|
||
// A step is a whole stroke, so there is no taking half of one back: while the brush is down these do
|
||
// nothing rather than committing a stroke the hand has not finished.
|
||
if ((ev.ctrlKey || ev.metaKey) && !drawing && "zZyY".includes(ev.key)) {
|
||
ev.preventDefault();
|
||
const back = (ev.key === "z" || ev.key === "Z") && !ev.shiftKey;
|
||
if (back) stepBack(undoStack, redoStack, "nothing to undo");
|
||
else stepBack(redoStack, undoStack, "nothing to redo");
|
||
return;
|
||
}
|
||
if (ev.code === "Space" && !spaceDown) { spaceDown = true; ev.preventDefault(); invalidate(); }
|
||
if (ev.key === "Escape" && mapView) clearMapView();
|
||
if (ev.key === "o" && S && S.overlay && !ev.ctrlKey && !ev.metaKey) {
|
||
layer = layer === "ov" ? "class" : "ov";
|
||
buildTabs(); buildClasses(); buildNumbers();
|
||
$("#savePaint").textContent = onOverlay() ? "Save overlay" : "Save painting";
|
||
syncGenButton();
|
||
invalidate();
|
||
}
|
||
if (ev.key === "[") { $("#size").value = Math.max(1, +$("#size").value - 4); $("#size").oninput(); }
|
||
if (ev.key === "]") { $("#size").value = Math.min(400, +$("#size").value + 4); $("#size").oninput(); }
|
||
// Back out of a deep zoom, or down to where a cell is a pixel and the brush is exact. Scrolling all the
|
||
// way back was the only way out of either.
|
||
if (ev.key === "f" && S) fitWorld();
|
||
if (ev.key === "1" && S) zoomTo(1, vw / 2, vh / 2);
|
||
});
|
||
|
||
addEventListener("keyup", ev => {
|
||
if (ev.code === "Space") { spaceDown = false; invalidate(); }
|
||
});
|
||
addEventListener("blur", () => { spaceDown = false; invalidate(); });
|
||
|
||
|
||
// Opening this file from disk is the one mistake worth spelling out. page.html is *served* by
|
||
// `terrain studio`, which holds the painting in memory and answers /api/*; from a file:// URL every one of
|
||
// those fetches resolves against the filesystem and fails as a bare NetworkError, which says nothing at all
|
||
// about what is actually wrong.
|
||
if (location.protocol === "file:") {
|
||
say([
|
||
"This page is served by the studio, not opened from disk.",
|
||
"",
|
||
" Tools/Terrain/bin/terrain.exe studio",
|
||
"",
|
||
"then open http://127.0.0.1:8099 in a browser.",
|
||
"Loaded from a file:// URL every /api/ request fails, which is the error you are looking at.",
|
||
].join("\n"), "err");
|
||
} else {
|
||
boot().catch(e => say([
|
||
"Could not reach the studio at " + location.origin + ".",
|
||
"",
|
||
"Is `terrain studio` still running in the terminal you started it from?",
|
||
"",
|
||
String(e),
|
||
].join("\n"), "err"));
|
||
}
|
||
</script>
|