{
 "genart": "1.2",
 "id": "outcrop-of-a-vale",
 "title": "Outcrop of a Vale",
 "created": "2026-09-11T00:00:00Z",
 "modified": "2026-09-11T19:09:06.412Z",
 "renderer": {
  "type": "canvas2d",
  "version": "1.x"
 },
 "canvas": {
  "width": 1400,
  "height": 1000
 },
 "parameters": [],
 "colors": [],
 "state": {
  "seed": 4,
  "params": {},
  "colorPalette": [
   "#1d2327",
   "#3b474f",
   "#6c7a82",
   "#b3bab8",
   "#ebe8e0"
  ]
 },
 "algorithm": "// Outcrop of a Vale. A geological map of ground that does not exist: the\n// sketch lays down a stack of beds, tilts them, breaks them with a fault,\n// erodes the ground across them, and engraves what is left at the surface.\n//\n// Each bed is told from the next only by how it is ruled or dotted, darkest\n// for the oldest, as the black-and-white geological sketch-maps told them. A\n// hard bed stands up as a scarp and is hachured; a soft one wears into a vale.\n// The outcrop is where each bed meets the eroded ground, so where a bed crosses\n// a valley its outcrop bends into a V by itself.\nfunction sketch(ctx, state) {\n  var W = state.canvas.width, H = state.canvas.height;\n  var seed = state.seed || 0;\n  var pal = state.colorPalette;\n  var K = W / 1400;\n\n  var COLS = Math.round(240 * W / 700), ROWS = Math.round(180 * H / 500);\n  var N = COLS * ROWS;\n  // The field maps onto the inset plate: the build script uses the same box.\n  var PX = W * 0.075, PY = H * 0.075, PW = W * 0.85, PH = H * 0.81;\n  var ASPECT = PW / PH;\n  var DX = ASPECT / (COLS - 1), DY = 1 / (ROWS - 1);   // cell size, plate heights\n  var CW = PW / (COLS - 1), CH = PH / (ROWS - 1);      // cell size, px\n\n  function rngFrom(a) {\n    return function () {\n      a |= 0; a = a + 0x6D2B79F5 | 0;\n      var t = Math.imul(a ^ a >>> 15, 1 | a);\n      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;\n      return ((t ^ t >>> 14) >>> 0) / 4294967296;\n    };\n  }\n  var rand = rngFrom(seed * 2654435761 + 104729);\n\n  /** A lattice of random values, bilinearly sampled over [0,1]. */\n  function lattice(nx, ny) {\n    var v = new Float32Array(nx * ny);\n    for (var k = 0; k < nx * ny; k++) v[k] = rand() * 2 - 1;\n    return function (u, t) {\n      // 🔴 Clamp: an out-of-range sample becomes a NaN in a published channel\n      // and takes the render down on a frame the CLI still exits 0 for.\n      if (u < 0) u = 0; else if (u > 1) u = 1;\n      if (t < 0) t = 0; else if (t > 1) t = 1;\n      var x = u * (nx - 1), y = t * (ny - 1);\n      var x0 = Math.floor(x), y0 = Math.floor(y);\n      var x1 = x0 + 1 > nx - 1 ? nx - 1 : x0 + 1, y1 = y0 + 1 > ny - 1 ? ny - 1 : y0 + 1;\n      var fx = x - x0, fy = y - y0;\n      fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy);\n      var a = v[y0 * nx + x0], b = v[y0 * nx + x1], c = v[y1 * nx + x0], d = v[y1 * nx + x1];\n      return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;\n    };\n  }\n  function smooth(e0, e1, x) {\n    var t = (x - e0) / (e1 - e0);\n    if (t < 0) t = 0; else if (t > 1) t = 1;\n    return t * t * (3 - 2 * t);\n  }\n  /** A fixed random value per cell, for marks that must not move between passes. */\n  function hash(i) {\n    var h = Math.imul(i ^ (seed * 374761393), 668265263);\n    h = Math.imul(h ^ h >>> 13, 1274126177);\n    return ((h ^ h >>> 16) >>> 0) / 4294967296;\n  }\n\n  var ph = [];\n  for (var i = 0; i < 8; i++) ph.push(rand() * Math.PI * 2);\n\n  // --- The lie of the ground --------------------------------------------------\n  // The ground falls toward one edge of the plate, where the water leaves it.\n  var th = Math.floor(rand() * 4) * Math.PI / 2 + (rand() * 2 - 1) * 0.25;\n  var nx0 = Math.cos(th), ny0 = Math.sin(th);          // up the fall of the ground\n  var cx = ASPECT / 2, cy = 0.5;\n  var halfExt = Math.abs(nx0) * ASPECT / 2 + Math.abs(ny0) * 0.5;\n  /** How far up the fall of the ground a point lies: -1 at the low edge, 1 at the high. */\n  function toS(x, y) { return ((x - cx) * nx0 + (y - cy) * ny0) / halfExt; }\n\n  // --- The beds -----------------------------------------------------------------\n  // Beds laid down flat and then tilted as one, so they dip toward `phi`, bent a\n  // little by a broad fold and broken by one fault. They dip across the fall of\n  // the ground, never straight up or down it, so the streams cut across them.\n  // Hard and soft beds alternate; a soft bed under a hard one makes a scarp.\n  var phi = th + (rand() < 0.5 ? 1 : -1) * (0.6 + rand() * 0.9);\n  var dpx = Math.cos(phi), dpy = Math.sin(phi);\n  var G = 0.16 + rand() * 0.10;                        // fall of a bed, per plate height\n  var NBED = 7, hard = [], BND = [0], par = rand() < 0.5 ? 0 : 1;\n  for (var kb = 0; kb < NBED; kb++) {\n    hard.push(kb % 2 === par);\n    BND.push(BND[kb] + (hard[kb] ? 0.03 + rand() * 0.025 : 0.05 + rand() * 0.04));\n  }\n  var fold = lattice(3, 3);\n  // The fault: a nearly straight line across the plate. The ground on one side\n  // was let down, so there younger beds come to the surface.\n  var fth = rand() * Math.PI, fnx = Math.cos(fth), fny = Math.sin(fth);\n  var fcx = cx + (rand() * 2 - 1) * 0.25 * ASPECT, fcy = cy + (rand() * 2 - 1) * 0.2;\n  var THROW = (rand() < 0.5 ? -1 : 1) * (0.035 + rand() * 0.035);\n  // 🔴 On every seed the fault ran edge to edge as one ruled line. It bends\n  // more now, and on half the sheets its throw dies away toward one end, so\n  // the fault stops inside the plate where it no longer moves the beds.\n  var fEnd = rand() < 0.5 ? (rand() * 2 - 1) * 0.3 : null, fDir = rand() < 0.5 ? 1 : -1;\n  /** Signed distance (plate heights) across the fault; its zero is the fault. */\n  function faultF(x, y) {\n    var a = (x - fcx) * fny - (y - fcy) * fnx;\n    return (x - fcx) * fnx + (y - fcy) * fny + 0.04 * Math.sin(2.3 * a + ph[0]) + 0.008 * Math.sin(7.1 * a + ph[1]);\n  }\n  /** How much of the throw the fault carries at a point: 0 past its end. */\n  function faultT(x, y) {\n    if (fEnd === null) return 1;\n    return smooth(0, 0.3, ((x - fcx) * fny - (y - fcy) * fnx - fEnd) * fDir);\n  }\n  /** The bed a height in the stack falls in, oldest 0. */\n  function bedOf(z) {\n    var b = 0;\n    while (b < NBED - 1 && z >= BND[b + 1]) b++;\n    return b;\n  }\n\n  // The first ground: a plain fall toward the low edge with some unevenness.\n  // Everything else on the sheet the erosion makes. 🔴 With only a faint\n  // unevenness, ground a hard bed kept from eroding stayed a smooth plane, and\n  // its streams ran across it as straight channels meeting at right angles.\n  var n1 = lattice(7, 5), n2 = lattice(15, 11), n3 = lattice(33, 25), w1x = lattice(6, 5), w1y = lattice(6, 5);\n  var hmap = new Float32Array(N), zb = new Float32Array(N);\n  var Z0 = BND[NBED] / 2 - 0.18;\n  for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n    var p = r * COLS + c, x = c * DX, y = r * DY, u = x / ASPECT, v = y;\n    var xw = x + 0.02 * w1x(u, v), yw = y + 0.02 * w1y(u, v);\n    var s = toS(xw, yw);\n    hmap[p] = Math.max(0.004, 0.05 + 0.13 * (s + 1) + 0.03 * n1(u, v) + 0.024 * n2(u, v) + 0.010 * n3(u, v));\n    zb[p] = Z0 + G * ((xw - cx) * dpx + (yw - cy) * dpy) + 0.035 * fold(u, v) + (faultF(x, y) > 0 ? THROW * faultT(x, y) : 0);\n  }\n\n  function at(a, c, r) {\n    if (c < 0) c = 0; else if (c > COLS - 1) c = COLS - 1;\n    if (r < 0) r = 0; else if (r > ROWS - 1) r = ROWS - 1;\n    return a[r * COLS + c];\n  }\n  /** Bilinear sample of a map at fractional cell coordinates. */\n  function bil(a, x, y) {\n    if (x < 0) x = 0; else if (x > COLS - 1.001) x = COLS - 1.001;\n    if (y < 0) y = 0; else if (y > ROWS - 1.001) y = ROWS - 1.001;\n    var x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, p = y0 * COLS + x0;\n    return (a[p] * (1 - fx) + a[p + 1] * fx) * (1 - fy) + (a[p + COLS] * (1 - fx) + a[p + COLS + 1] * fx) * fy;\n  }\n  var NB = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]];\n\n  /** Two-pass chamfer distance (in cells) from every source cell, with the nearest source. */\n  function chamfer(isSrc) {\n    var d = new Float32Array(N), src = new Int32Array(N);\n    for (var p = 0; p < N; p++) { d[p] = isSrc(p) ? 0 : 1e6; src[p] = d[p] === 0 ? p : -1; }\n    var D2 = Math.SQRT2;\n    function relax(p, q, w) { if (d[q] + w < d[p]) { d[p] = d[q] + w; src[p] = src[q]; } }\n    for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n      var p = r * COLS + c;\n      if (c > 0) relax(p, p - 1, 1);\n      if (r > 0) {\n        relax(p, p - COLS, 1);\n        if (c > 0) relax(p, p - COLS - 1, D2);\n        if (c < COLS - 1) relax(p, p - COLS + 1, D2);\n      }\n    }\n    for (var r2 = ROWS - 1; r2 >= 0; r2--) for (var c2 = COLS - 1; c2 >= 0; c2--) {\n      var p2 = r2 * COLS + c2;\n      if (c2 < COLS - 1) relax(p2, p2 + 1, 1);\n      if (r2 < ROWS - 1) {\n        relax(p2, p2 + COLS, 1);\n        if (c2 < COLS - 1) relax(p2, p2 + COLS + 1, D2);\n        if (c2 > 0) relax(p2, p2 + COLS - 1, D2);\n      }\n    }\n    return { d: d, src: src };\n  }\n  /** Box-blur a copy of a map, `passes` times, radius 2. */\n  function blur(a, passes, cap) {\n    var s = new Float32Array(N), tmp = new Float32Array(N);\n    for (var p = 0; p < N; p++) s[p] = cap !== undefined ? Math.min(a[p], cap) : a[p];\n    for (var pass = 0; pass < passes; pass++) {\n      for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n        var acc = 0, cnt = 0;\n        for (var oy = -2; oy <= 2; oy++) for (var ox = -2; ox <= 2; ox++) {\n          var cc = c + ox, rr = r + oy;\n          if (cc < 0 || rr < 0 || cc >= COLS || rr >= ROWS) continue;\n          acc += s[rr * COLS + cc]; cnt++;\n        }\n        tmp[r * COLS + c] = acc / cnt;\n      }\n      var sw = s; s = tmp; tmp = sw;\n    }\n    return s;\n  }\n  /**\n   * Exact Euclidean distance (in cells) from every source cell, carrying the\n   * index of the nearest source (Felzenszwalb and Huttenlocher's two-pass\n   * lower envelope). 🔴 A chamfer's distance steps in eighths of a turn, and\n   * water-lining laid on its bands came out octagonal round Relief of a Coast's stacks.\n   */\n  function edt(isSrc) {\n    var INF = 1e12, M = Math.max(COLS, ROWS);\n    var colD = new Float64Array(N), colS = new Int32Array(N);\n    var f = new Float64Array(M), out = new Float64Array(M), arg = new Int32Array(M);\n    var v = new Int32Array(M), z = new Float64Array(M + 1);\n    function pass1(n) {\n      var k = 0; v[0] = 0; z[0] = -INF; z[1] = INF;\n      for (var q = 1; q < n; q++) {\n        var s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n        while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }\n        k++; v[k] = q; z[k] = s; z[k + 1] = INF;\n      }\n      k = 0;\n      for (var q2 = 0; q2 < n; q2++) {\n        while (z[k + 1] < q2) k++;\n        out[q2] = (q2 - v[k]) * (q2 - v[k]) + f[v[k]]; arg[q2] = v[k];\n      }\n    }\n    for (var c = 0; c < COLS; c++) {\n      for (var r = 0; r < ROWS; r++) f[r] = isSrc(r * COLS + c) ? 0 : INF;\n      pass1(ROWS);\n      for (var r2 = 0; r2 < ROWS; r2++) { colD[r2 * COLS + c] = out[r2]; colS[r2 * COLS + c] = arg[r2]; }\n    }\n    var d = new Float32Array(N), src = new Int32Array(N);\n    for (var r3 = 0; r3 < ROWS; r3++) {\n      for (var c2 = 0; c2 < COLS; c2++) f[c2] = colD[r3 * COLS + c2];\n      pass1(COLS);\n      for (var c3 = 0; c3 < COLS; c3++) {\n        var p = r3 * COLS + c3;\n        d[p] = out[c3] >= INF * 0.5 ? 1e6 : Math.sqrt(out[c3]);\n        src[p] = out[c3] >= INF * 0.5 ? -1 : colS[r3 * COLS + arg[c3]] * COLS + arg[c3];\n      }\n    }\n    return { d: d, src: src };\n  }\n\n  var heapK = new Float64Array(N), heapV = new Int32Array(N), heapN = 0;\n  function hpush(key, val) {\n    var i = heapN++;\n    while (i > 0) {\n      var pa = (i - 1) >> 1;\n      if (heapK[pa] <= key) break;\n      heapK[i] = heapK[pa]; heapV[i] = heapV[pa]; i = pa;\n    }\n    heapK[i] = key; heapV[i] = val;\n  }\n  function hpop() {\n    var top = heapV[0], key = heapK[--heapN], val = heapV[heapN], i = 0;\n    for (;;) {\n      var l = 2 * i + 1;\n      if (l >= heapN) break;\n      if (l + 1 < heapN && heapK[l + 1] < heapK[l]) l++;\n      if (heapK[l] >= key) break;\n      heapK[i] = heapK[l]; heapV[i] = heapV[l]; i = l;\n    }\n    heapK[i] = key; heapV[i] = val;\n    return top;\n  }\n  var seen = new Uint8Array(N);\n  /** Fill every hollow on the land to its spill point, with a hair of fall. */\n  function fill() {\n    seen.fill(0); heapN = 0;\n    for (var p = 0; p < N; p++) {\n      var c = p % COLS, r = (p - c) / COLS;\n      if (hmap[p] <= 0 || c === 0 || r === 0 || c === COLS - 1 || r === ROWS - 1) { seen[p] = 1; hpush(hmap[p], p); }\n    }\n    while (heapN) {\n      var cur = hpop(), cc = cur % COLS, rc = (cur - cc) / COLS;\n      for (var nb = 0; nb < 8; nb++) {\n        var c2 = cc + NB[nb][0], r2 = rc + NB[nb][1];\n        if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n        var q = r2 * COLS + c2;\n        if (seen[q]) continue;\n        seen[q] = 1;\n        if (hmap[q] <= hmap[cur] + 1e-6) hmap[q] = hmap[cur] + 1e-6;\n        hpush(hmap[q], q);\n      }\n    }\n  }\n  var rcv = new Int32Array(N), acc = new Float32Array(N), landIdx = [];\n  /**\n   * Route water downhill and accumulate it. The diagonal fall is weighed by a\n   * fixed random per cell (Fairfield and Leymarie's rho-8), because a plain\n   * steepest-of-eight rule runs every stream on a plane slope as a ruled\n   * line at a multiple of 45 degrees.\n   */\n  function route() {\n    rcv.fill(-1); acc.fill(0); landIdx = [];\n    for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n      var p = r * COLS + c;\n      if (hmap[p] <= 0) continue;\n      landIdx.push(p);\n      var best = 0, diag = 2 - hash(p * 7 + 3);\n      for (var nb = 0; nb < 8; nb++) {\n        var c2 = c + NB[nb][0], r2 = r + NB[nb][1];\n        if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n        var q = r2 * COLS + c2;\n        var drop = (hmap[p] - hmap[q]) / (NB[nb][0] && NB[nb][1] ? diag : 1);\n        if (drop > best) { best = drop; rcv[p] = q; }\n      }\n    }\n    landIdx.sort(function (a, b) { return hmap[b] - hmap[a]; });\n    for (var li = 0; li < landIdx.length; li++) {\n      var pl = landIdx[li];\n      acc[pl] += 1;\n      if (rcv[pl] >= 0) acc[rcv[pl]] += acc[pl];\n    }\n  }\n  // --- The ground, eroded across the beds -------------------------------------\n  // As on the other sheets the valleys are not drawn on but cut: hollows filled,\n  // water routed downhill, each cell cut by the square root of the water through\n  // it. Here the cut and the creep of the slopes are both scaled by the bed at\n  // the surface, so a soft bed wears into a vale and a hard one is left\n  // standing as a scarp over it.\n  var erod = new Float32Array(N), lap = new Float32Array(N);\n  var KF = 0.05, ROUNDS = 24;\n  for (var round = 0; round < ROUNDS; round++) {\n    for (var pe0 = 0; pe0 < N; pe0++) erod[pe0] = hard[bedOf(zb[pe0] + hmap[pe0])] ? 0.3 : 1.6;\n    fill(); route();\n    for (var li2 = landIdx.length - 1; li2 >= 0; li2--) {\n      var pe = landIdx[li2], rq = rcv[pe];\n      if (rq < 0) continue;\n      var F = KF * erod[pe] * Math.sqrt(acc[pe]) * smooth(15, 70, acc[pe]);\n      var hn = (hmap[pe] + F * Math.max(hmap[rq], 0)) / (1 + F);\n      hmap[pe] = Math.max(0.001, Math.min(hmap[pe], hn));\n    }\n    for (var pd = 0; pd < N; pd++) {\n      var cd = pd % COLS, rd = (pd - cd) / COLS, sumL = 0, nL = 0;\n      if (cd > 0) { sumL += hmap[pd - 1]; nL++; }\n      if (cd < COLS - 1) { sumL += hmap[pd + 1]; nL++; }\n      if (rd > 0) { sumL += hmap[pd - COLS]; nL++; }\n      if (rd < ROWS - 1) { sumL += hmap[pd + COLS]; nL++; }\n      lap[pd] = sumL / nL - hmap[pd];\n    }\n    for (var pd2 = 0; pd2 < N; pd2++) hmap[pd2] = Math.max(0.001, hmap[pd2] + 0.16 * Math.min(1, erod[pd2]) * lap[pd2]);\n  }\n  fill(); route();\n  // Ground the last fill raised to its spill point: a flat a stream crosses as\n  // a ruled line. The pen lifts over it (Mouths of a River).\n  var levelled = new Uint8Array(N), pre = Float32Array.from(hmap);\n  fill(); route();\n  for (var pv = 0; pv < N; pv++) if (hmap[pv] - pre[pv] > 1e-5) levelled[pv] = 1;\n  // A gully that gathers this much water is drawn as a stream; below it the\n  // hachures would run together into each gully as a dark feather.\n  var A1 = Math.round(N * 0.0035);\n  var isStream = new Uint8Array(N);\n  for (var ps = 0; ps < N; ps++) if (hmap[ps] > 0 && acc[ps] >= A1) isStream[ps] = 1;\n  var toStream = chamfer(function (p) { return isStream[p] === 1; });\n\n  // --- Slope and light ------------------------------------------------------\n  var slope = new Float32Array(N), facing = new Float32Array(N);\n  var LX = -Math.SQRT1_2, LY = -Math.SQRT1_2;          // toward the light, north-west\n  var samples = [];\n  for (var r2 = 0; r2 < ROWS; r2++) {\n    for (var c2 = 0; c2 < COLS; c2++) {\n      var n = r2 * COLS + c2;\n      var gx = (at(hmap, c2 + 1, r2) - at(hmap, c2 - 1, r2)) / (2 * DX);\n      var gy = (at(hmap, c2, r2 + 1) - at(hmap, c2, r2 - 1)) / (2 * DY);\n      var g = Math.sqrt(gx * gx + gy * gy);\n      slope[n] = g;\n      facing[n] = g > 1e-6 ? (-gx * LX - gy * LY) / g : 0;   // +1 faces the light\n      // Inland of the shore only: the step down to the sea is not a slope.\n      if (hmap[n] > 0 && at(hmap, c2 - 2, r2) > 0 && at(hmap, c2 + 2, r2) > 0 &&\n        at(hmap, c2, r2 - 2) > 0 && at(hmap, c2, r2 + 2) > 0 && (r2 * 7 + c2) % 11 === 0) samples.push(g);\n    }\n  }\n  samples.sort(function (a, b) { return a - b; });\n  // Steepness is read against this coast's own steep ground, so every seed\n  // spends the whole tonal range whatever its relief happens to be.\n  var smax = samples.length ? samples[Math.floor(samples.length * 0.92)] : 1;\n  /** The weight of engraving a piece of ground takes: steepness, then the light. */\n  function toneOf(sl, f) {\n    var sn = Math.min(1.25, sl / smax);\n    var T = 0.04 + 0.46 * Math.pow(sn, 0.85) + 0.38 * sn * (f < 0 ? -f : 0) - 0.14 * sn * (f > 0 ? f : 0);\n    return T < 0 ? 0 : T > 1 ? 1 : T;\n  }\n  // The fall line a hachure follows is read off a lightly smoothed copy of the\n  // ground. Off the raw ground, neighbouring strokes ran together into every\n  // shallow gully and each course read as a row of arrowheads.\n  var hsm = blur(hmap, 1), gxT = new Float32Array(N), gyT = new Float32Array(N);\n  for (var r8 = 0; r8 < ROWS; r8++) for (var c8 = 0; c8 < COLS; c8++) {\n    gxT[r8 * COLS + c8] = at(hsm, c8 + 1, r8) - at(hsm, c8 - 1, r8);\n    gyT[r8 * COLS + c8] = at(hsm, c8, r8 + 1) - at(hsm, c8, r8 - 1);\n  }\n  var hmax = 0;\n  for (var ph0 = 0; ph0 < N; ph0++) if (hmap[ph0] > hmax) hmax = hmap[ph0];\n\n  // --- Tracing ------------------------------------------------------------\n  // Contour lines of a map at one level, by marching squares, chained into\n  // polylines (flat arrays of cell coordinates). Edges are numbered: the\n  // horizontal edge right of cell p is p, the vertical edge below it N + p.\n  var eA = new Int32Array(2 * N), eB = new Int32Array(2 * N), eStamp = new Int32Array(2 * N);\n  var ex = new Float32Array(2 * N), ey = new Float32Array(2 * N), eUsed = new Uint8Array(2 * N);\n  var stamp = 0;\n  function contour(a, L) {\n    stamp++;\n    var touched = [], e = [0, 0, 0, 0], ne;\n    function cross(va, vb, id, xa, ya, xb, yb) {\n      if ((va < L) === (vb < L)) return;\n      if (eStamp[id] !== stamp) {\n        eStamp[id] = stamp; eA[id] = -1; eB[id] = -1; eUsed[id] = 0;\n        var t = (L - va) / (vb - va);\n        ex[id] = xa + (xb - xa) * t; ey[id] = ya + (yb - ya) * t;\n        touched.push(id);\n      }\n      e[ne++] = id;\n    }\n    function link(i, j) {\n      if (eA[i] === -1) eA[i] = j; else eB[i] = j;\n      if (eA[j] === -1) eA[j] = i; else eB[j] = i;\n    }\n    for (var r = 0; r < ROWS - 1; r++) for (var c = 0; c < COLS - 1; c++) {\n      var p = r * COLS + c;\n      var v0 = a[p], v1 = a[p + 1], v2 = a[p + COLS + 1], v3 = a[p + COLS];\n      var b0 = v0 < L, b1 = v1 < L, b2 = v2 < L, b3 = v3 < L;\n      if (b0 === b1 && b1 === b2 && b2 === b3) continue;\n      ne = 0;\n      cross(v0, v1, p, c, r, c + 1, r);\n      cross(v1, v2, N + p + 1, c + 1, r, c + 1, r + 1);\n      cross(v3, v2, p + COLS, c, r + 1, c + 1, r + 1);\n      cross(v0, v3, N + p, c, r, c, r + 1);\n      if (ne === 2) link(e[0], e[1]);\n      else if (ne === 4) { link(e[0], e[1]); link(e[2], e[3]); }\n    }\n    var lines = [];\n    for (var ti = 0; ti < touched.length; ti++) {\n      var id0 = touched[ti];\n      if (eUsed[id0]) continue;\n      // Walk back to an end if the line has one, so it is traced in one piece.\n      var s0 = id0, prev = -1, guard = 0;\n      while (eB[s0] !== -1 && guard++ < touched.length) {\n        var nx = eA[s0] === prev ? eB[s0] : eA[s0];\n        if (nx === id0) break;\n        prev = s0; s0 = nx;\n      }\n      var pts = [], cur = s0, pv = -1;\n      while (cur !== -1 && !eUsed[cur]) {\n        eUsed[cur] = 1; pts.push(ex[cur], ey[cur]);\n        var l1 = eA[cur], l2 = eB[cur];\n        var nxt = (l1 !== -1 && l1 !== pv && !eUsed[l1]) ? l1 : (l2 !== -1 && l2 !== pv && !eUsed[l2]) ? l2 : -1;\n        pv = cur; cur = nxt;\n      }\n      if (pts.length >= 4) lines.push(pts);\n    }\n    return lines;\n  }\n  /** Corner-cutting, so a traced line does not show the grid it came from. */\n  function chaikin(p, rounds) {\n    for (var it = 0; it < rounds; it++) {\n      var q = [p[0], p[1]];\n      for (var j = 0; j < p.length - 2; j += 2) {\n        q.push(0.75 * p[j] + 0.25 * p[j + 2], 0.75 * p[j + 1] + 0.25 * p[j + 3],\n          0.25 * p[j] + 0.75 * p[j + 2], 0.25 * p[j + 1] + 0.75 * p[j + 3]);\n      }\n      q.push(p[p.length - 2], p[p.length - 1]);\n      p = q;\n    }\n    return p;\n  }\n  function X(c) { return PX + c * CW; }\n  function Y(r) { return PY + r * CH; }\n  function lineLen(p) {\n    var s = 0;\n    for (var j = 2; j < p.length; j += 2) s += Math.hypot((p[j] - p[j - 2]) * CW, (p[j + 1] - p[j - 1]) * CH);\n    return s;\n  }\n  function strokeLine(p) {\n    ctx.moveTo(X(p[0]), Y(p[1]));\n    for (var j = 2; j < p.length; j += 2) ctx.lineTo(X(p[j]), Y(p[j + 1]));\n  }\n  /** Cell coordinates of a point given in plate heights. */\n  function CX(x) { return PX + x / DX * CW; }\n  function CY(y) { return PY + y / DY * CH; }\n\n  // --- The rock at the surface ------------------------------------------------\n  var zeta = new Float32Array(N), Tm = new Float32Array(N), Fm = new Float32Array(N), gZ = new Float32Array(N);\n  var unitN = [];\n  for (var ku = 0; ku < NBED; ku++) unitN.push(0);\n  for (var pz = 0; pz < N; pz++) {\n    var cz = pz % COLS, rz = (pz - cz) / COLS;\n    // 🔴 Read off the lightly smoothed ground: off the raw ground every small\n    // lump that rose through a contact printed as a pinprick island of the\n    // bed above, a speckle across the sheet.\n    zeta[pz] = zb[pz] + hsm[pz];\n    unitN[bedOf(zeta[pz])]++;\n    Tm[pz] = toneOf(slope[pz], facing[pz]);\n    Fm[pz] = faultF(cz * DX, rz * DY);\n  }\n  for (var pg = 0; pg < N; pg++) {\n    var cg = pg % COLS, rg = (pg - cg) / COLS;\n    gZ[pg] = 0.5 * Math.hypot(at(zeta, cg + 1, rg) - at(zeta, cg - 1, rg), at(zeta, cg, rg + 1) - at(zeta, cg, rg - 1));\n  }\n  // 🔴 Hachures are kept to the scarps: steep ground on a hard bed, or just\n  // below one where the soft bed under it has worn back. Hachured wherever it\n  // was steep, the head of every gully printed as a scatter of dark flecks.\n  // The ruling gives way to them only there.\n  // The mask is blurred so a scarp is one continuous face; cut cell by cell it\n  // came out in patches, and the few strokes in each read as a frayed fringe.\n  var HT = 0.22;\n  var hardAt = new Uint8Array(N);\n  for (var ph3 = 0; ph3 < N; ph3++) hardAt[ph3] = hard[bedOf(zeta[ph3])] ? 1 : 0;\n  var fromHard = edt(function (p) { return hardAt[p] === 1; });\n  var hm0 = new Float32Array(N);\n  // Kept off the stream lines: where a gully cut back into a hard bed its\n  // fall lines converged, and the strokes bunched into a dark feather.\n  for (var ph4 = 0; ph4 < N; ph4++) hm0[ph4] = Tm[ph4] >= HT && fromHard.d[ph4] < 6 && toStream.d[ph4] > 2.5 ? 1 : 0;\n  var hachM = blur(hm0, 2);\n  /**\n   * A drawn stream within two cells of `p`, or -1. 🔴 Water splitting on a\n   * valley floor ran two streams a cell or two apart as a double line; a\n   * stream that comes this close to one already drawn joins it there.\n   */\n  function joinAt(p) {\n    var c = p % COLS, r = (p - c) / COLS, best = -1, bd = 9;\n    for (var dr = -2; dr <= 2; dr++) for (var dc = -2; dc <= 2; dc++) {\n      var c2 = c + dc, r2 = r + dr;\n      if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS || !drawn[r2 * COLS + c2]) continue;\n      if (dc * dc + dr * dr < bd) { bd = dc * dc + dr * dr; best = r2 * COLS + c2; }\n    }\n    return best;\n  }\n  // A stream on ground this flat was a ruled line across a plain; the pen lifts.\n  var FLAT = 0.12 * smax;\n  /**\n   * Which segments of a traced stream to draw: none over levelled or flat\n   * ground, and no run so short it would print as a fleck between the lifts.\n   */\n  function runsOf(sp, n) {\n    var k = new Uint8Array(n), s0 = -1, j;\n    for (j = 0; j < n - 1; j++) {\n      k[j] = levelled[Math.round(sp[j * 2 + 1]) * COLS + Math.round(sp[j * 2])] || bil(slope, sp[j * 2], sp[j * 2 + 1]) < FLAT ? 0 : 1;\n    }\n    for (j = 0; j < n; j++) {\n      if (j < n - 1 && k[j]) { if (s0 < 0) s0 = j; continue; }\n      if (s0 >= 0 && j - s0 < 40) for (var q = s0; q < j; q++) k[q] = 0;\n      s0 = -1;\n    }\n    return k;\n  }\n  var fromFault = edt(function (p) {\n    var c = p % COLS, r = (p - c) / COLS;\n    if (faultT(c * DX, r * DY) < 0.12) return false;\n    return (c < COLS - 1 && (Fm[p] > 0) !== (Fm[p + 1] > 0)) || (r < ROWS - 1 && (Fm[p] > 0) !== (Fm[p + COLS] > 0));\n  });\n  /** How far (in cells) a point lies inside its bed, measured to the nearer contact. */\n  function inside(z, u, gc, gr) {\n    var gz = Math.max(bil(gZ, gc, gr), 1e-4), d = 1e9;\n    if (u > 0) d = Math.min(d, (z - BND[u]) / gz);\n    if (u < NBED - 1) d = Math.min(d, (BND[u + 1] - z) / gz);\n    return d;\n  }\n\n  // Strike and dip, set on a few broad dip slopes well clear of any contact.\n  var dsr = rngFrom(seed * 4099 + 7), cand = [], syms = [];\n  for (var gy0 = 0.1; gy0 < 0.9; gy0 += 0.05) for (var gx0 = 0.08; gx0 < 0.92; gx0 += 0.04) {\n    var ccx = (gx0 + (dsr() - 0.5) * 0.03) * (COLS - 1), ccy = (gy0 + (dsr() - 0.5) * 0.04) * (ROWS - 1);\n    var zc = bil(zeta, ccx, ccy), dd = inside(zc, bedOf(zc), ccx, ccy);\n    if (dd < 10 || bil(fromFault.d, ccx, ccy) < 10 || bil(Tm, ccx, ccy) > 0.2 || bil(toStream.d, ccx, ccy) < 5) continue;\n    cand.push([ccx, ccy, Math.min(dd, 40) + 6 * dsr()]);\n  }\n  cand.sort(function (a, b) { return b[2] - a[2]; });\n  cand.forEach(function (cd0) {\n    if (syms.length >= 4) return;\n    for (var q = 0; q < syms.length; q++) if (Math.hypot((cd0[0] - syms[q][0]) * CW, (cd0[1] - syms[q][1]) * CH) < 220 * K) return;\n    syms.push(cd0);\n  });\n  var symGeo = syms.map(function (sm0) {\n    var c0 = Math.round(sm0[0]), r0 = Math.round(sm0[1]);\n    // The beds dip the way their height in the stack rises.\n    var gbx = (at(zb, c0 + 1, r0) - at(zb, c0 - 1, r0)) / (2 * DX), gby = (at(zb, c0, r0 + 1) - at(zb, c0, r0 - 1)) / (2 * DY);\n    var gm = Math.hypot(gbx, gby) || 1e-6;\n    return { x: X(sm0[0]), y: Y(sm0[1]), ux: gbx / gm, uy: gby / gm, deg: Math.max(2, Math.round(Math.atan(gm) * 180 / Math.PI)) };\n  });\n  /**\n   * Inside the small clearing the ruling leaves for a dip figure. 🔴 Cleared\n   * round the whole symbol, the darkest ruling carried a white hole at each.\n   */\n  function inSym(x, y) {\n    for (var q = 0; q < symGeo.length; q++) {\n      var sg = symGeo[q];\n      if (Math.hypot(x - sg.x - sg.ux * 9 * K, y - sg.y - sg.uy * 9 * K) < 7 * K) return true;\n    }\n    return false;\n  }\n\n  // --- The sheet ----------------------------------------------------------\n  // Nothing is left to a layer but the border and the grain, so the sheet is\n  // laid here: the paper colour with a faint, slow mottle.\n  ctx.fillStyle = pal[4];\n  ctx.fillRect(0, 0, W, H);\n  var m1 = lattice(44, 32), m2 = lattice(140, 100);\n  var MS = 3 * K;\n  for (var my = 0; my < H; my += MS) for (var mx = 0; mx < W; mx += MS) {\n    var mv = 0.5 + 0.35 * m1(mx / W, my / H) + 0.25 * m2(mx / W, my / H);\n    if (mv <= 0.45) continue;\n    ctx.fillStyle = 'rgba(60,70,76,' + (0.05 * (mv - 0.45)).toFixed(3) + ')';\n    ctx.fillRect(mx, my, MS + 0.5, MS + 0.5);\n  }\n  ctx.save();\n  ctx.beginPath();\n  ctx.rect(PX, PY, PW, PH);\n  ctx.clip();\n\n  // --- The rock, ruled ---------------------------------------------------------\n  // Oldest darkest. Dots alternate with ruling, no two ruled beds share an\n  // angle, and one bed is left as bare paper. A line stops just short of the\n  // contact, of the fault, of a stream and of ground steep enough to hachure.\n  var ra = (rand() * 2 - 1) * 0.2;\n  // 🔴 Spacing kept above the display's pixel pitch: at 2.2 the darkest bed\n  // beat into a woven moire once the 2x still was cut down to 1600 for the page.\n  var PAT = [\n    { rule: 0.35, sp: 2.8 },\n    { dots: 0.50 },\n    { rule: -0.75, sp: 3.5 },\n    { paper: true },\n    { rule: 1.15, sp: 4.4 },\n    { dots: 0.20 },\n    { rule: 0.05, sp: 5.4, dash: true },\n  ];\n  function clearAt(gc, gr, u) {\n    var z = bil(zeta, gc, gr);\n    if (bedOf(z) !== u || inside(z, u, gc, gr) < 0.45) return false;\n    // 🔴 No clearing along the streams: cleared round every stream cell, the\n    // ruling carried white gutters down courses the pen never drew (too short,\n    // in a comb, or on flat ground). The streams are drawn over it instead.\n    return bil(fromFault.d, gc, gr) > 1.1 && bil(hachM, gc, gr) < 0.5;\n  }\n  var wob = lattice(80, 58), DIAG = Math.hypot(PW, PH), CXp = PX + PW / 2, CYp = PY + PH / 2, ruled = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.fillStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.globalAlpha = 0.85;\n  ctx.lineWidth = 0.5 * K;\n  PAT.forEach(function (pt, u) {\n    if (!pt.rule) return;\n    var an = pt.rule + ra, ddx = Math.cos(an), ddy = Math.sin(an), nnx = -ddy, nny = ddx;\n    var SPp = pt.sp * K, ST = 1.3 * K, DP = 8.5 * K;\n    ctx.beginPath();\n    for (var o = -DIAG / 2; o <= DIAG / 2; o += SPp) {\n      var run = false;\n      for (var lam = -DIAG / 2; lam <= DIAG / 2; lam += ST) {\n        var bx = CXp + o * nnx + lam * ddx, by = CYp + o * nny + lam * ddy;\n        var ok = bx > PX && by > PY && bx < PX + PW && by < PY + PH;\n        if (ok) {\n          // The lines waver a little together, as a hand-ruled field does.\n          var wv = 0.4 * K * wob((bx - PX) / PW, (by - PY) / PH);\n          bx += wv * nnx; by += wv * nny;\n          ok = !inSym(bx, by) && clearAt((bx - PX) / CW, (by - PY) / CH, u);\n          if (ok && pt.dash) ok = (((lam + o * 2.7) % DP) + DP) % DP < 5.5 * K;\n        }\n        if (ok) { if (run) ctx.lineTo(bx, by); else { ctx.moveTo(bx, by); ruled++; } }\n        run = ok;\n      }\n    }\n    ctx.stroke();\n  });\n\n  // Dotted beds, dot by dot on a jittered grid.\n  var dr = rngFrom(seed * 7919 + 3), SGd = 2.5 * K, dotsN = 0;\n  ctx.beginPath();\n  for (var gyd = PY + SGd / 2; gyd < PY + PH; gyd += SGd) for (var gxd = PX + SGd / 2; gxd < PX + PW; gxd += SGd) {\n    var jx = gxd + (dr() - 0.5) * SGd, jy = gyd + (dr() - 0.5) * SGd, roll = dr(), rs = dr();\n    var gcd = (jx - PX) / CW, grd = (jy - PY) / CH, ud = bedOf(bil(zeta, gcd, grd));\n    if (!PAT[ud].dots || roll >= PAT[ud].dots || inSym(jx, jy) || !clearAt(gcd, grd, ud)) continue;\n    var rad = 0.6 * K * (0.8 + 0.4 * rs);\n    ctx.moveTo(jx + rad, jy);\n    ctx.arc(jx, jy, rad, 0, Math.PI * 2);\n    dotsN++;\n  }\n  ctx.fill();\n\n  // Contacts: a fine line where one bed gives way to the next, stopped at the fault.\n  var contactN = 0;\n  ctx.lineWidth = 0.5 * K;\n  ctx.globalAlpha = 0.8;\n  ctx.beginPath();\n  for (var kc = 1; kc < NBED; kc++) {\n    contour(zeta, BND[kc]).forEach(function (ln) {\n      if (lineLen(ln) < 16 * K) return;\n      var pts = chaikin(ln, 2), run = false;\n      for (var j = 0; j < pts.length; j += 2) {\n        var ok = bil(fromFault.d, pts[j], pts[j + 1]) > 1.2;\n        if (ok) { if (run) ctx.lineTo(X(pts[j]), Y(pts[j + 1])); else ctx.moveTo(X(pts[j]), Y(pts[j + 1])); }\n        run = ok;\n      }\n      contactN++;\n    });\n  }\n  ctx.stroke();\n  ctx.restore();\n\n  // --- Hachures on the land ---------------------------------------------------\n  // As on Relief of a Coast: along each contour of height the strokes are set\n  // at an even spacing, closer and heavier on steep ground and in shadow, and\n  // each is run down the fall line to just short of the next contour below,\n  // as a wedge that lifts to a point the way a burin stroke does.\n  var TIER = smax * (5.5 * K) / PH;\n  var hr = rngFrom(seed * 31337 + 11);\n  var STEP = 0.35, MAXLEN = 14 * K, strokes = 0;\n  ctx.save();\n  ctx.fillStyle = pal[0];\n  ctx.globalAlpha = 0.9;\n  for (var lv = 1; lv * TIER < hmax; lv++) {\n    var L = lv * TIER, floorH = L - 0.86 * TIER;\n    var lines = contour(hmap, L);\n    for (var li3 = 0; li3 < lines.length; li3++) {\n      var pl = lines[li3];\n      var next = hr() * 4 * K, run = 0;\n      for (var j4 = 0; j4 < pl.length - 2; j4 += 2) {\n        var ax = pl[j4], ay = pl[j4 + 1], bx2 = pl[j4 + 2], by2 = pl[j4 + 3];\n        var seg = Math.hypot((bx2 - ax) * CW, (by2 - ay) * CH);\n        while (run + seg >= next) {\n          var tt2 = (next - run) / seg;\n          var sx = ax + (bx2 - ax) * tt2, sy = ay + (by2 - ay) * tt2;\n          var T = toneOf(bil(slope, sx, sy), bil(facing, sx, sy)), hmS = bil(hachM, sx, sy);\n          if (hmS >= 0.5) T = Math.min(0.75, Math.max(T, 0.5 + 0.3 * T));\n          next += (2.1 + 5.2 * (1 - T)) * K * (0.9 + 0.2 * hr());\n          if (hmS < 0.5) continue;\n          var wd = (0.24 + 1.05 * Math.pow(T, 1.35)) * K * (0.9 + 0.2 * hr());\n          var path = [sx, sy], x = sx, y = sy, lenPx = 0;\n          for (var stp = 0; stp < 120; stp++) {\n            var gxs = bil(gxT, x, y), gys = bil(gyT, x, y), gm = Math.hypot(gxs, gys);\n            if (gm < 1e-5) break;\n            x -= gxs / gm * STEP; y -= gys / gm * STEP;\n            lenPx += STEP * CW;\n            var hh = bil(hmap, x, y);\n            if (hh < floorH || hh <= 0 || lenPx > MAXLEN || bil(toStream.d, x, y) < 1.0) break;\n            if (x < 1 || y < 1 || x > COLS - 2 || y > ROWS - 2) break;\n            path.push(x, y);\n          }\n          if (path.length < 6) continue;\n          var npt = path.length / 2, left = [], right = [];\n          for (var q3 = 0; q3 < npt; q3++) {\n            var qa = Math.max(0, q3 - 1), qb = Math.min(npt - 1, q3 + 1);\n            var tx2 = X(path[qb * 2]) - X(path[qa * 2]), ty2 = Y(path[qb * 2 + 1]) - Y(path[qa * 2 + 1]);\n            var tl = Math.hypot(tx2, ty2) || 1;\n            var hw = wd * 0.5 * (1 - 0.5 * q3 / (npt - 1));\n            left.push(X(path[q3 * 2]) - ty2 / tl * hw, Y(path[q3 * 2 + 1]) + tx2 / tl * hw);\n            right.push(X(path[q3 * 2]) + ty2 / tl * hw, Y(path[q3 * 2 + 1]) - tx2 / tl * hw);\n          }\n          ctx.beginPath();\n          ctx.moveTo(left[0], left[1]);\n          for (var q4 = 2; q4 < left.length; q4 += 2) ctx.lineTo(left[q4], left[q4 + 1]);\n          for (var q5 = right.length - 2; q5 >= 0; q5 -= 2) ctx.lineTo(right[q5], right[q5 + 1]);\n          ctx.closePath();\n          ctx.fill();\n          strokes++;\n        }\n        run += seg;\n      }\n    }\n  }\n  ctx.restore();\n\n  // --- Streams ----------------------------------------------------------------\n  // Each stream is traced from its head down to where it meets a larger one or\n  // the sea, a hairline at the head that swells a little with what it carries.\n  var hasUp = new Uint8Array(N), done = new Uint8Array(N), drawn = new Uint8Array(N), streamLines = 0;\n  for (var pu = 0; pu < N; pu++) if (isStream[pu] && rcv[pu] >= 0) hasUp[rcv[pu]] = 1;\n  var heads = [];\n  for (var ph2 = 0; ph2 < N; ph2++) if (isStream[ph2] && !hasUp[ph2]) heads.push(ph2);\n  // Largest first, so a tributary always stops against a stream already drawn.\n  heads.sort(function (a, b) { return hmap[a] - hmap[b]; });\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.globalAlpha = 0.9;\n  var wbx = lattice(60, 45), wby = lattice(60, 45);\n  heads.forEach(function (hd) {\n    var pts = [], ac = [], cur = hd, guard = 0, visited = [];\n    while (cur >= 0 && guard++ < 4000) {\n      var c9 = cur % COLS, r9 = Math.floor(cur / COLS), u9 = c9 / (COLS - 1), v9 = r9 / (ROWS - 1);\n      var wa = 0.8 + 2.6 * (1 - smooth(0, 0.3 * smax, slope[cur]));\n      pts.push(c9 + wa * wbx(u9, v9), r9 + wa * wby(u9, v9)); ac.push(acc[cur]);\n      if (hmap[cur] <= 0 || done[cur]) break;\n      var jn = joinAt(cur);\n      if (jn >= 0 && visited.length > 3) { pts.push(jn % COLS, Math.floor(jn / COLS)); ac.push(acc[cur]); break; }\n      // A stream that reaches the edge of the plate leaves it there: the edge\n      // cells are outlets, and one ran along the border as a ruled line.\n      if (c9 < 2 || r9 < 2 || c9 > COLS - 3 || r9 > ROWS - 3) break;\n      done[cur] = 1; visited.push(cur);\n      cur = rcv[cur];\n    }\n    // 🔴 Too short to draw, a stream head printed as a stray tick (Mouths of a\n    // River). Its cells are handed back, so a stream from higher up runs on.\n    if (visited.length < 12) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n    // 🔴 In a broad valley several streams ran side by side to the shore and\n    // read as a ruled comb. One that spends most of its course close beside a\n    // stream already drawn, without yet joining it, is left out the same way.\n    var RN = 8, near = 0, span = visited.length - RN - 1;\n    for (var vi = 0; vi < span; vi++) {\n      var vc0 = visited[vi] % COLS, vr0 = Math.floor(visited[vi] / COLS), hit = false;\n      for (var dr = -RN; dr <= RN && !hit; dr++) for (var dc = -RN; dc <= RN; dc++) {\n        var c0 = vc0 + dc, r0 = vr0 + dr;\n        if (c0 >= 0 && r0 >= 0 && c0 < COLS && r0 < ROWS && drawn[r0 * COLS + c0]) { hit = true; break; }\n      }\n      if (hit) near++;\n    }\n    if (near > 0.5 * span) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n    visited.forEach(function (vc) { drawn[vc] = 1; });\n    var sp2 = chaikin(pts, 2), n2 = sp2.length / 2, keepSeg = runsOf(sp2, n2);\n    for (var j5 = 0; j5 < n2 - 1; j5++) {\n      var a5 = ac[Math.min(ac.length - 1, Math.floor(j5 / (n2 - 1) * (ac.length - 1)))];\n      if (!keepSeg[j5]) continue;\n      // Lighter than on Relief of a Coast: here the hachures are the mass, and\n      // at full weight the streams outweighed them and ran across the plain\n      // as ruled lines.\n      ctx.lineWidth = Math.min(0.7, 0.22 + 0.12 * Math.log2(a5 / A1 + 1)) * K;\n      ctx.beginPath();\n      ctx.moveTo(X(sp2[j5 * 2]), Y(sp2[j5 * 2 + 1]));\n      ctx.lineTo(X(sp2[j5 * 2 + 2]), Y(sp2[j5 * 2 + 3]));\n      ctx.stroke();\n    }\n    streamLines++;\n  });\n  ctx.restore();\n\n  // --- The fault ----------------------------------------------------------------\n  // The heaviest line on the sheet.\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.fillStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.lineWidth = 2.2 * K;\n  ctx.globalAlpha = 0.92;\n  ctx.beginPath();\n  // Drawn only where it still moves the beds.\n  contour(Fm, 0).forEach(function (ln) {\n    var pts = chaikin(ln, 2), run = false;\n    for (var j = 0; j < pts.length; j += 2) {\n      var ok = faultT(pts[j] * DX, pts[j + 1] * DY) >= 0.12;\n      if (ok) { if (run) ctx.lineTo(X(pts[j]), Y(pts[j + 1])); else ctx.moveTo(X(pts[j]), Y(pts[j + 1])); }\n      run = ok;\n    }\n  });\n  ctx.stroke();\n\n  // --- Strike and dip -------------------------------------------------------------\n  // A strike line, a short tick toward the dip, and the dip in degrees beyond it.\n  ctx.lineWidth = 0.75 * K;\n  ctx.font = 'italic ' + (7.6 * K).toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'middle';\n  symGeo.forEach(function (sg0) {\n    ctx.beginPath();\n    ctx.moveTo(sg0.x + sg0.uy * 7 * K, sg0.y - sg0.ux * 7 * K);\n    ctx.lineTo(sg0.x - sg0.uy * 7 * K, sg0.y + sg0.ux * 7 * K);\n    ctx.moveTo(sg0.x, sg0.y);\n    ctx.lineTo(sg0.x + sg0.ux * 3.6 * K, sg0.y + sg0.uy * 3.6 * K);\n    ctx.stroke();\n    ctx.fillText(String(sg0.deg), sg0.x + sg0.ux * 9 * K, sg0.y + sg0.uy * 9 * K);\n  });\n  ctx.restore();\n\n  ctx.restore();   // the plate clip\n\n  var gl = (typeof globalThis !== 'undefined') ? globalThis : window;\n  gl.__genart_data = gl.__genart_data || {};\n  gl.__genart_data.debug = {\n    hard: hard.map(function (h) { return h ? 1 : 0; }).join(''), throw: +THROW.toFixed(3),\n    units: unitN.map(function (n) { return +(n / N).toFixed(3); }), ruled: ruled, dots: dotsN,\n    contacts: contactN, strokes: strokes, streams: streamLines, syms: symGeo.length,\n  };\n}\n",
 "layers": [
  {
   "id": "graduation",
   "type": "shapes:path",
   "name": "Neat Line — Graduated Border",
   "visible": true,
   "locked": false,
   "opacity": 0.8,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#1d2327",
    "fillEnabled": true,
    "strokeColor": "#000000",
    "strokeWidth": 0,
    "strokeEnabled": false,
    "d": "M 105.00 69.00 L 149.07 69.00 L 149.07 75.00 L 105.00 75.00 Z M 193.15 69.00 L 237.22 69.00 L 237.22 75.00 L 193.15 75.00 Z M 281.30 69.00 L 325.37 69.00 L 325.37 75.00 L 281.30 75.00 Z M 369.44 69.00 L 413.52 69.00 L 413.52 75.00 L 369.44 75.00 Z M 457.59 69.00 L 501.67 69.00 L 501.67 75.00 L 457.59 75.00 Z M 545.74 69.00 L 589.81 69.00 L 589.81 75.00 L 545.74 75.00 Z M 633.89 69.00 L 677.96 69.00 L 677.96 75.00 L 633.89 75.00 Z M 722.04 69.00 L 766.11 69.00 L 766.11 75.00 L 722.04 75.00 Z M 810.19 69.00 L 854.26 69.00 L 854.26 75.00 L 810.19 75.00 Z M 898.33 69.00 L 942.41 69.00 L 942.41 75.00 L 898.33 75.00 Z M 986.48 69.00 L 1030.56 69.00 L 1030.56 75.00 L 986.48 75.00 Z M 1074.63 69.00 L 1118.70 69.00 L 1118.70 75.00 L 1074.63 75.00 Z M 1162.78 69.00 L 1206.85 69.00 L 1206.85 75.00 L 1162.78 75.00 Z M 1250.93 69.00 L 1295.00 69.00 L 1295.00 75.00 L 1250.93 75.00 Z M 105.00 885.00 L 149.07 885.00 L 149.07 891.00 L 105.00 891.00 Z M 193.15 885.00 L 237.22 885.00 L 237.22 891.00 L 193.15 891.00 Z M 281.30 885.00 L 325.37 885.00 L 325.37 891.00 L 281.30 891.00 Z M 369.44 885.00 L 413.52 885.00 L 413.52 891.00 L 369.44 891.00 Z M 457.59 885.00 L 501.67 885.00 L 501.67 891.00 L 457.59 891.00 Z M 545.74 885.00 L 589.81 885.00 L 589.81 891.00 L 545.74 891.00 Z M 633.89 885.00 L 677.96 885.00 L 677.96 891.00 L 633.89 891.00 Z M 722.04 885.00 L 766.11 885.00 L 766.11 891.00 L 722.04 891.00 Z M 810.19 885.00 L 854.26 885.00 L 854.26 891.00 L 810.19 891.00 Z M 898.33 885.00 L 942.41 885.00 L 942.41 891.00 L 898.33 891.00 Z M 986.48 885.00 L 1030.56 885.00 L 1030.56 891.00 L 986.48 891.00 Z M 1074.63 885.00 L 1118.70 885.00 L 1118.70 891.00 L 1074.63 891.00 Z M 1162.78 885.00 L 1206.85 885.00 L 1206.85 891.00 L 1162.78 891.00 Z M 1250.93 885.00 L 1295.00 885.00 L 1295.00 891.00 L 1250.93 891.00 Z M 99.00 75.00 L 105.00 75.00 L 105.00 120.00 L 99.00 120.00 Z M 99.00 165.00 L 105.00 165.00 L 105.00 210.00 L 99.00 210.00 Z M 99.00 255.00 L 105.00 255.00 L 105.00 300.00 L 99.00 300.00 Z M 99.00 345.00 L 105.00 345.00 L 105.00 390.00 L 99.00 390.00 Z M 99.00 435.00 L 105.00 435.00 L 105.00 480.00 L 99.00 480.00 Z M 99.00 525.00 L 105.00 525.00 L 105.00 570.00 L 99.00 570.00 Z M 99.00 615.00 L 105.00 615.00 L 105.00 660.00 L 99.00 660.00 Z M 99.00 705.00 L 105.00 705.00 L 105.00 750.00 L 99.00 750.00 Z M 99.00 795.00 L 105.00 795.00 L 105.00 840.00 L 99.00 840.00 Z M 1295.00 75.00 L 1301.00 75.00 L 1301.00 120.00 L 1295.00 120.00 Z M 1295.00 165.00 L 1301.00 165.00 L 1301.00 210.00 L 1295.00 210.00 Z M 1295.00 255.00 L 1301.00 255.00 L 1301.00 300.00 L 1295.00 300.00 Z M 1295.00 345.00 L 1301.00 345.00 L 1301.00 390.00 L 1295.00 390.00 Z M 1295.00 435.00 L 1301.00 435.00 L 1301.00 480.00 L 1295.00 480.00 Z M 1295.00 525.00 L 1301.00 525.00 L 1301.00 570.00 L 1295.00 570.00 Z M 1295.00 615.00 L 1301.00 615.00 L 1301.00 660.00 L 1295.00 660.00 Z M 1295.00 705.00 L 1301.00 705.00 L 1301.00 750.00 L 1295.00 750.00 Z M 1295.00 795.00 L 1301.00 795.00 L 1301.00 840.00 L 1295.00 840.00 Z M 99.00 69.00 L 105.00 69.00 L 105.00 75.00 L 99.00 75.00 Z M 1295.00 69.00 L 1301.00 69.00 L 1301.00 75.00 L 1295.00 75.00 Z M 99.00 885.00 L 105.00 885.00 L 105.00 891.00 L 99.00 891.00 Z M 1295.00 885.00 L 1301.00 885.00 L 1301.00 891.00 L 1295.00 891.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "neat-outer",
   "type": "shapes:path",
   "name": "Neat Line — Outer Rule",
   "visible": true,
   "locked": false,
   "opacity": 0.85,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#ffffff",
    "fillEnabled": false,
    "strokeColor": "#1d2327",
    "strokeWidth": 1.5,
    "strokeEnabled": true,
    "d": "M 99.00 69.00 L 1301.00 69.00 L 1301.00 891.00 L 99.00 891.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "neat-inner",
   "type": "shapes:path",
   "name": "Neat Line — Inner Rule",
   "visible": true,
   "locked": false,
   "opacity": 0.8,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#ffffff",
    "fillEnabled": false,
    "strokeColor": "#1d2327",
    "strokeWidth": 0.7,
    "strokeEnabled": true,
    "d": "M 105.00 75.00 L 1295.00 75.00 L 1295.00 885.00 L 105.00 885.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "tone",
   "type": "filter:grain",
   "name": "Plate Tone",
   "visible": true,
   "locked": false,
   "opacity": 1,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "intensity": 0.1,
    "size": 2,
    "seed": 4,
    "monochrome": true
   }
  }
 ]
}