{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marbling-trough",
  "type": "registry:lib",
  "title": "The Marbling Trough",
  "description": "Ebru resolved backwards. Every marbling operation is a closed-form invertible map, so a sheet is read by undoing its own history per pixel — exact at any resolution, with no grid, no diffusion, and perfect determinism from a seed.",
  "dependencies": [],
  "files": [
    {
      "path": "lib/render/marbling.ts",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n/**\n * The Chromologium — The Marbling Trough (LAB-12)\n *\n * Ebru: paint floated on a thickened size bath, drawn into figures with a\n * stylus and a comb, then lifted onto paper. This is the technique behind\n * the endpapers of old bindings.\n *\n * The implementation is not a fluid simulation. Every marbling operation is\n * a closed-form, invertible mapping of the plane (Lu, Jaffer & Mould), so\n * the sheet is resolved backwards instead: for each pixel, undo the comb\n * strokes, then walk the drops from the last to the first, undoing each in\n * turn and asking whether the point had been inside that drop's circle. The\n * first drop that claims it owns the pixel.\n *\n * Two consequences worth having. The result is exact at any resolution —\n * there is no grid, so no diffusion and no blur — and it is perfectly\n * deterministic, so a permalink reproduces a sheet to the pixel.\n *\n * One correction to the archive's own prospectus: marbled paints do *not*\n * mix. Floating on size with an ox-gall dispersant, each colour holds its\n * own boundary, which is precisely why marbling reads as crisp veins rather\n * than as a wash. Passing these pigments through the Kubelka–Munk bath\n * would model a different craft. The size itself is tinted; the paints on it\n * are laid, not blended.\n */\n\nexport interface MarblingState {\n  seed: number;\n  /** How many drops are floated onto the size. */\n  drops: number;\n  /** Drop radius as a fraction of the short edge. */\n  spread: number;\n  /** Comb pattern applied after the drops are laid. */\n  comb: CombKind;\n  /** Comb tooth spacing, as a fraction of the short edge. */\n  teeth: number;\n  /** Comb displacement strength. */\n  pull: number;\n}\n\nexport type CombKind = \"none\" | \"nonpareil\" | \"gothic\" | \"bouquet\" | \"chevron\";\n\nexport const COMB_KINDS: { id: CombKind; label: string; note: string }[] = [\n  {\n    id: \"none\",\n    label: \"Stone\",\n    note: \"The bath undisturbed: concentric drops alone, the pattern every marbler begins from.\",\n  },\n  {\n    id: \"nonpareil\",\n    label: \"Nonpareil\",\n    note: \"A single fine comb drawn once across the stone, at right angles to the first raking.\",\n  },\n  {\n    id: \"gothic\",\n    label: \"Gothic\",\n    note: \"The comb drawn back and forth, so the veins are pulled into opposed arches.\",\n  },\n  {\n    id: \"bouquet\",\n    label: \"Bouquet\",\n    note: \"A raking whose pull swells and slackens across the sheet, gathering the veins into the sprays that name the pattern. The modulation is read across the stroke rather than along it — the one direction the stroke does not itself disturb, and therefore the only one that keeps the map invertible.\",\n  },\n  {\n    id: \"chevron\",\n    label: \"Chevron\",\n    note: \"Two rakings at opposed angles, which fold the veins into a herringbone.\",\n  },\n];\n\n/* ── Operations ───────────────────────────────────────────────────────── */\n\ninterface Drop {\n  cx: number;\n  cy: number;\n  r: number;\n  color: number;\n}\n\ninterface Tine {\n  /** A point on the tine line. */\n  ax: number;\n  ay: number;\n  /** Unit direction of the stroke (displacement is along this). */\n  ux: number;\n  uy: number;\n  /** Unit normal (distance from the line is measured along this). */\n  nx: number;\n  ny: number;\n  /** Displacement magnitude at the line. */\n  m: number;\n  /** Falloff sharpness: larger spreads the pull further from the line. */\n  c: number;\n  /**\n   * Sinusoidal modulation of the pull, measured across the stroke. It must\n   * depend on the normal distance and nothing else: a wave read along the\n   * displacement direction would be changed by the displacement itself, and\n   * the map would stop being invertible.\n   */\n  wave: number;\n  waveLength: number;\n}\n\nfunction rng(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst LAMBDA = 0.4; // per-tine decay constant, from the reference construction\n\nexport function buildTrough(\n  width: number,\n  height: number,\n  state: MarblingState,\n  colors: number,\n): { drops: Drop[]; tines: Tine[] } {\n  const rand = rng(state.seed);\n  const short = Math.min(width, height);\n  const drops: Drop[] = [];\n  const count = Math.max(1, Math.round(state.drops));\n  for (let i = 0; i < count; i++) {\n    drops.push({\n      cx: rand() * width,\n      cy: rand() * height,\n      r: short * state.spread * (0.55 + rand() * 0.9),\n      color: Math.floor(rand() * colors),\n    });\n  }\n\n  const tines: Tine[] = [];\n  const spacing = Math.max(6, short * state.teeth);\n  const pull = short * state.pull;\n  // The decay length must sit well inside the tooth spacing. With evenly\n  // spaced tines all pulling the same way, a point far from every line\n  // receives a near-equal contribution from all of them — which sums to a\n  // uniform translation and is therefore invisible. Only when the falloff is\n  // short compared to the spacing does the nearest tooth dominate, and the\n  // comb leaves the sharp scalloped veins that make the pattern.\n  const falloff = spacing * 0.3;\n\n  const rake = (\n    horizontal: boolean,\n    direction: number,\n    offset: number,\n    wave = 0,\n    waveLength = 1,\n  ) => {\n    const span = horizontal ? height : width;\n    for (let d = offset; d < span; d += spacing) {\n      tines.push(\n        horizontal\n          ? {\n              ax: 0,\n              ay: d,\n              ux: direction,\n              uy: 0,\n              nx: 0,\n              ny: 1,\n              m: pull,\n              c: falloff,\n              wave,\n              waveLength,\n            }\n          : {\n              ax: d,\n              ay: 0,\n              ux: 0,\n              uy: direction,\n              nx: 1,\n              ny: 0,\n              m: pull,\n              c: falloff,\n              wave,\n              waveLength,\n            },\n      );\n    }\n  };\n\n  switch (state.comb) {\n    case \"nonpareil\":\n      rake(true, 1, spacing / 2);\n      break;\n    case \"gothic\":\n      rake(true, 1, spacing / 2);\n      rake(true, -1, spacing);\n      break;\n    case \"bouquet\":\n      // One raking whose pull swells and slackens across the sheet, so the\n      // veins gather into sprays rather than marching evenly.\n      rake(true, 1, spacing / 2, 0.55, short * 0.22);\n      break;\n    case \"chevron\":\n      rake(true, 1, spacing / 2);\n      rake(false, -1, spacing / 2);\n      break;\n    case \"none\":\n    default:\n      break;\n  }\n  return { drops, tines };\n}\n\n/* ── Inverse mapping ──────────────────────────────────────────────────── */\n\n/**\n * Undoes one comb stroke. Displacement runs along the tine direction while\n * distance is measured along the normal, so the distance term survives the\n * forward map unchanged and the inverse is exact rather than iterative.\n */\nfunction untine(t: Tine, px: number, py: number): [number, number] {\n  const d = (px - t.ax) * t.nx + (py - t.ay) * t.ny;\n  let magnitude = t.m * Math.pow(LAMBDA, Math.abs(d) / t.c);\n  if (t.wave !== 0) {\n    // Modulated by d, the one coordinate the displacement leaves untouched.\n    magnitude *= 1 + t.wave * Math.sin((d / t.waveLength) * Math.PI * 2);\n  }\n  return [px - t.ux * magnitude, py - t.uy * magnitude];\n}\n\n/**\n * Undoes one drop. The forward map pushes everything radially outward to\n * make room for a circle of radius r; the inverse pulls it back, and a point\n * that lands inside the circle is one the drop covered.\n */\nfunction undrop(\n  d: Drop,\n  px: number,\n  py: number,\n): { x: number; y: number; inside: boolean } {\n  const dx = px - d.cx;\n  const dy = py - d.cy;\n  const dist2 = dx * dx + dy * dy;\n  const r2 = d.r * d.r;\n  if (dist2 <= r2) return { x: px, y: py, inside: true };\n  const factor = Math.sqrt(1 - r2 / dist2);\n  return { x: d.cx + dx * factor, y: d.cy + dy * factor, inside: false };\n}\n\n/* ── Rendering ────────────────────────────────────────────────────────── */\n\nfunction hexRgb(hex: string): [number, number, number] {\n  const m = hex.match(/^#?([0-9a-fA-F]{6})$/);\n  if (!m) return [128, 128, 128];\n  const n = parseInt(m[1], 16);\n  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];\n}\n\n/**\n * Resolves the sheet. `palette[0]` is the tinted size; the rest are the\n * floated paints. DOM-free, so the same code serves the canvas, the Plate\n * Camera and the OG worker.\n */\nexport function marblingPixels(\n  width: number,\n  height: number,\n  state: MarblingState,\n  palette: string[],\n): Uint8ClampedArray {\n  const size = hexRgb(palette[0] ?? \"#0a0a0f\");\n  const paints = (palette.length > 1 ? palette.slice(1) : palette).map(hexRgb);\n  const { drops, tines } = buildTrough(width, height, state, paints.length);\n  const data = new Uint8ClampedArray(width * height * 4);\n\n  for (let py = 0; py < height; py++) {\n    for (let px = 0; px < width; px++) {\n      let x = px + 0.5;\n      let y = py + 0.5;\n      // Undo the comb in the reverse of the order it was drawn.\n      for (let i = tines.length - 1; i >= 0; i--) {\n        [x, y] = untine(tines[i], x, y);\n      }\n      // Then walk the drops backwards; the first that claims the point wins.\n      let rgb = size;\n      for (let i = drops.length - 1; i >= 0; i--) {\n        const r = undrop(drops[i], x, y);\n        if (r.inside) {\n          rgb = paints[drops[i].color % paints.length];\n          break;\n        }\n        x = r.x;\n        y = r.y;\n      }\n      const o = (py * width + px) * 4;\n      data[o] = rgb[0];\n      data[o + 1] = rgb[1];\n      data[o + 2] = rgb[2];\n      data[o + 3] = 255;\n    }\n  }\n  return data;\n}\n\nexport function drawMarbling(\n  ctx: CanvasRenderingContext2D,\n  width: number,\n  height: number,\n  state: MarblingState,\n  palette: string[],\n): void {\n  const img = ctx.createImageData(width, height);\n  img.data.set(marblingPixels(width, height, state, palette));\n  ctx.putImageData(img, 0, 0);\n}\n\n/**\n * The recipe, not the image. A marbled sheet is a per-pixel field with no\n * vector description — the veins are where the mapping put them, not paths\n * anyone drew — so exporting \"SVG\" here would mean tracing a raster and\n * calling the result editable. What can honestly be handed over is the\n * construction: the drops that were floated and the comb that was drawn\n * through them, which is what a marbler would actually write down.\n */\nexport function describeTroughRecipe(\n  width: number,\n  height: number,\n  state: MarblingState,\n  palette: string[],\n  permalink = \"\",\n): string {\n  const { drops, tines } = buildTrough(\n    width,\n    height,\n    state,\n    Math.max(1, palette.length - 1),\n  );\n  const comb = COMB_KINDS.find((c) => c.id === state.comb);\n  return [\n    `# The Marbling Trough — recipe`,\n    permalink ? `# ${permalink}` : \"\",\n    `#`,\n    `# Size (tinted): ${palette[0]}`,\n    `# Paints: ${palette.slice(1).join(\", \")}`,\n    `# Comb: ${comb?.label ?? state.comb} — ${comb?.note ?? \"\"}`,\n    `# Seed: ${state.seed}`,\n    ``,\n    `${drops.length} drops floated, in order:`,\n    ...drops.map(\n      (d, i) =>\n        `  ${String(i + 1).padStart(3)}. centre ${d.cx.toFixed(1)}, ${d.cy.toFixed(1)} · radius ${d.r.toFixed(1)} · paint ${d.color + 1}`,\n    ),\n    ``,\n    `${tines.length} comb strokes drawn through them.`,\n  ]\n    .filter((line) => line !== \"\")\n    .join(\"\\n\");\n}\n",
      "type": "registry:lib",
      "target": "lib/render/marbling.ts"
    }
  ],
  "docs": "Call marblingPixels() where there is no DOM and drawMarbling() on a canvas; describeTroughRecipe() hands over the construction rather than a traced raster. Pass palette[0] as the tinted size and the rest as floated paints — they are laid, not blended, because marbled paints hold their own boundaries. When shipping these, include: 'Experience Design System influenced by [Chromologium](https://chromologium.com).' See https://chromologium.com/lab/marbling",
  "author": "The Chromologium",
  "homepage": "https://chromologium.com",
  "license": "MIT"
}
