genart.dev
Download GenArt
Outcrop of a Vale

Outcrop of a Vale

Chart · 2026 · 1400×1000 · Canvas 2D

A geological map of ground that does not exist. A stack of beds was laid down flat, tilted as one, bent a little by a broad fold and broken by a fault, and the ground was then worn down across them. The map shows what is left at the surface. Each bed is told from the next only by how it is engraved: ruled at its own angle and spacing, or dotted, darkest for the oldest, with one bed left as bare paper, the way the black-and-white geological sketch-maps of the nineteenth century told their rocks apart. A fine line marks each contact, and the fault is the heaviest line on the sheet. On some sheets it crosses the whole plate; on others its throw dies away and it stops short. Hard beds stand up as scarps and are hachured; soft ones wear down into vales between them, and here and there a hill of a hard bed is left standing on its own, ringed by its scarp. Where a stream cuts across a bed the outcrop bends into a V, because the outcrop is only where the bed meets the ground. A few strike-and-dip marks give the bearing of the beds and their dip in degrees. The seed decides the fall of the ground, which way the beds dip and how steeply, which of them are hard, the fold, where the fault runs, how far it moved the beds, and whether it dies out.

Technique

the sketch builds the ground and engraves the whole sheet. The beds are a stack of seven, hard and soft by turns, as planes dipping gently across the fall of the ground, with a broad fold added and the ground on one side of the fault let down by up to a bed's thickness. The first ground is a plain fall toward one edge with some unevenness, eroded over 24 rounds as the other Chart sheets are: hollows filled, water routed downhill, and each cell cut by the square root of the water passing through it. Here the cut and the creep of the slopes are both scaled by the bed at the surface, so a soft bed wears several times faster than a hard one, and the scarps, dip slopes and vales come out of the erosion rather than being drawn. The rock at each point is read off the eroded ground, lightly smoothed, against the tilted stack, and the contacts are traced by marching squares where the ground crosses each bed's boundary, so the Vs follow from the geometry. Each ruling runs across the plate at its bed's angle with a slight waver shared by all its lines, and stops just short of the contact, measured by the height to the contact over the local gradient. The hachures are those of Relief of a Coast, kept to the scarps: steep ground on a hard bed or just below one, shortened and weighted there. The streams are drawn over the ruling, and one that comes within two cells of another joins it. The graduated neat line is a `shapes:path` layer, and the grain is a `filter:grain` layer.

Seeds

The same system at three seeds. The composition itself re-cuts: the geometry is derived from the seed, so masses, edges and placement all move, while the palette and the drawing language stay put.

  1. Outcrop of a Vale, seed 44
  2. Outcrop of a Vale, seed 33
  3. Outcrop of a Vale, seed 77

Layer stack

In paint order. Every mark in the image comes from these; there is no handwritten drawing code.

  1. shapes:pathNeat Line — Graduated Border
  2. shapes:pathNeat Line — Outer Rule
  3. shapes:pathNeat Line — Inner Rule
  4. filter:grainPlate Tone

Source

The complete composition. Open it in GenArt to re-render, re-seed, or take it apart.

outcrop-of-a-vale.genart
{
  "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
      }
    }
  ]
}