{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drapery",
  "type": "registry:lib",
  "title": "The Drapery",
  "description": "Drapes a material from authored optical coefficients — scatter depth, micro-facet roughness, specular anisotropy, metalness, refractive index — over a closed-form height field. No simulation, so the cloth is a pure function of its parameters and can be permalinked, rendered by a worker, and reproduced exactly. Iridescent coatings use genuine two-beam interference.",
  "dependencies": [],
  "files": [
    {
      "path": "lib/render/drapery.ts",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n/**\n * The Chromologium — The Drapery (LAB-14)\n *\n * The material catalogue, hung up. A sheet of cloth is draped, lit, and\n * shaded from the archive's own authored optical coefficients — scatter\n * depth, micro-facet roughness, specular anisotropy, metalness, refractive\n * index — so what changes between merino and copper is the physics, not an\n * art-directed preset.\n *\n * The drape is a closed-form height field rather than a mass-spring\n * simulation. That is a deliberate trade: a simulation has state, and state\n * cannot be put in a permalink, rendered by a worker, or reproduced by\n * anyone you send the link to. A field can. Folds come from two\n * incommensurable frequencies so they never fall into a repeating comb, the\n * hem sags as a catenary, and the pointer adds one lifted point — which is\n * the whole of the interaction and all of its state.\n *\n * Iridescence, where a material has it, is genuine two-beam interference\n * from the LAB-09 engine: the coating's path length grows as 1/cos(theta),\n * so the colour sweeps toward grazing incidence the way a real coated cloth\n * does, rather than being a hue ramp keyed to the normal.\n */\n\nimport { filmReflectanceRgb } from \"@/lib/render/thinfilm\";\nimport {\n  SUBSTRATE_OPTICS,\n  DEFAULT_OPTICS,\n  type MaterialOptics,\n} from \"@/lib/materialOptics\";\n\nexport interface DrapeState {\n  /** Key into the archive's substrate optics. */\n  material: string;\n  /** Cloth colour under the lighting, #rrggbb. */\n  tint: string;\n  /** Fold count across the sheet. */\n  folds: number;\n  /** Fold depth. */\n  depth: number;\n  /** Slack in the hem — how far the cloth sags. */\n  sag: number;\n  /** Coating thickness in nm; 0 disables iridescence. */\n  coating: number;\n  /** Lifted point, in 0..1 sheet coordinates. */\n  liftX: number;\n  liftY: number;\n  /** How hard that point is lifted. */\n  lift: number;\n}\n\n/**\n * Each substrate's own colour. The optics decide how a material *responds*\n * to light; this is what it is made of. Without it every drape rendered in\n * whatever tint happened to be set, and copper was indistinguishable from\n * wool at a glance — true to the coefficients, and useless as a catalogue.\n */\nconst MATERIAL_TINTS: Record<string, string> = {\n  \"Sartorial Super 150s Merino Wool\": \"#2b3140\",\n  \"Hand-Polished Copper Intaglio Plate\": \"#b87333\",\n  \"Obsidian Architectural Mirror Glass\": \"#14161c\",\n  \"Fluted Borosilicate Architectural Glass\": \"#9fc4c9\",\n  \"Living Forest Terrarium Bryophyte Moss\": \"#4a7c4e\",\n  \"Brushed Architectural Marine Brass\": \"#c9a227\",\n  \"Hand-Thrown Unglazed Terracotta Clay\": \"#b8704a\",\n  \"Natural Slaked Lime Plaster (Tadelakt)\": \"#e8e2d5\",\n  \"Quarter-Sawn White Oak Timber\": \"#c8a97e\",\n};\n\nexport const DRAPE_MATERIALS: {\n  id: string;\n  label: string;\n  note: string;\n  tint: string;\n}[] = Object.keys(SUBSTRATE_OPTICS).map((name) => ({\n  id: name,\n  label: name,\n  note: describeOptics(SUBSTRATE_OPTICS[name]),\n  tint: MATERIAL_TINTS[name] ?? \"#c9a55a\",\n}));\n\n/**\n * The material's own colour. Every catalogued substrate must have one — a\n * silent fallback here once left the bryophyte rendering as gold because a\n * key was a word short, and nothing surfaced it.\n */\nexport function tintFor(material: string): string {\n  return MATERIAL_TINTS[material] ?? \"#c9a55a\";\n}\n\n/**\n * Substrates the catalogue lists but this module has no colour for. Empty in\n * a correct build; asserted by the test suite so a new material cannot be\n * added upstream and quietly drape in the wrong colour.\n */\nexport function untintedMaterials(): string[] {\n  return Object.keys(SUBSTRATE_OPTICS).filter(\n    (name) => !(name in MATERIAL_TINTS),\n  );\n}\n\nfunction describeOptics(o: MaterialOptics): string {\n  const parts: string[] = [];\n  parts.push(\n    o.metalness > 0.5\n      ? \"a conductor: the highlight takes the material's own colour\"\n      : \"a dielectric: the highlight stays the colour of the light\",\n  );\n  parts.push(\n    o.roughness > 0.6\n      ? \"rough enough to scatter the specular lobe into a sheen\"\n      : o.roughness < 0.15\n        ? \"polished, so the lobe stays tight and mirror-like\"\n        : \"moderately rough — a broad but still directed highlight\",\n  );\n  if (o.anisotropy > 0.35)\n    parts.push(\"with a directional grain that stretches the highlight\");\n  if (o.scatterDepth > 0.35)\n    parts.push(\"and enough subsurface travel to glow at the fold\");\n  return `${parts.join(\", \")}. n ${o.ior.toFixed(2)}.`;\n}\n\nexport function opticsFor(material: string): MaterialOptics {\n  return SUBSTRATE_OPTICS[material] ?? DEFAULT_OPTICS;\n}\n\n/* ── The drape ────────────────────────────────────────────────────────── */\n\n/**\n * Height of the cloth at (u, v), both 0..1. Two fold frequencies in an\n * irrational ratio keep the folds from settling into a repeating comb; the\n * hem sags as a catenary; the lifted point is a smooth radial bulge.\n */\nfunction height(state: DrapeState, u: number, v: number): number {\n  const f1 = state.folds;\n  const f2 = state.folds * 1.6180339887;\n  // Folds are pinned at the top rail and open out toward the hem.\n  const opening = 0.25 + 0.75 * v;\n  const fold =\n    (Math.sin(u * Math.PI * 2 * f1) * 0.62 +\n      Math.sin(u * Math.PI * 2 * f2 + 1.1) * 0.38) *\n    state.depth *\n    opening;\n  // Catenary sag: cosh about the centre, normalised so the rail sits at 0.\n  const t = (u - 0.5) * 2;\n  const catenary = (Math.cosh(t * 1.2) - 1) / (Math.cosh(1.2) - 1);\n  const hem = -state.sag * (1 - catenary) * v * v;\n  const dx = u - state.liftX;\n  const dy = v - state.liftY;\n  const r = Math.sqrt(dx * dx + dy * dy);\n  const bulge = state.lift * Math.exp(-(r * r) / 0.045);\n  return fold + hem + bulge;\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n  const m = hex.match(/^#?([0-9a-fA-F]{6})$/);\n  if (!m) return [180, 180, 180];\n  const n = parseInt(m[1], 16);\n  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];\n}\n\n/* ── Shading ──────────────────────────────────────────────────────────── */\n\n/**\n * Renders the drape. DOM-free, so the live canvas, the Plate Camera and the\n * OG worker all light the same cloth.\n *\n * `alpha` false writes an opaque ground; true leaves the cloth on\n * transparency, which is what makes the export compositable.\n */\nexport function drapePixels(\n  width: number,\n  height_: number,\n  state: DrapeState,\n  opaqueGround: [number, number, number] | null = [10, 10, 15],\n): Uint8ClampedArray {\n  const optics = opticsFor(state.material);\n  const tint = hexRgb(state.tint);\n  const data = new Uint8ClampedArray(width * height_ * 4);\n\n  // A single key light, high and to the left, plus a dim fill so the folds\n  // that face away do not go to pure black.\n  const lx = -0.45;\n  const ly = -0.75;\n  const lz = 0.49;\n  const llen = Math.hypot(lx, ly, lz);\n  const L: [number, number, number] = [lx / llen, ly / llen, lz / llen];\n  const V: [number, number, number] = [0, 0, 1];\n  const H: [number, number, number] = [\n    (L[0] + V[0]) / 2,\n    (L[1] + V[1]) / 2,\n    (L[2] + V[2]) / 2,\n  ];\n  const hlen = Math.hypot(H[0], H[1], H[2]) || 1;\n  H[0] /= hlen;\n  H[1] /= hlen;\n  H[2] /= hlen;\n\n  // Height-field gradient, in sheet units. The field's own slope grows with\n  // both fold count and depth (d/du peaks near depth·2π·folds), so the relief\n  // scale is normalised by them — otherwise adding folds drives every normal\n  // to the horizon and the cloth shades as hard black-and-white bands rather\n  // than as fabric.\n  const relief = 2.8 / (1 + state.folds * state.depth * 6.283);\n  const step = 1 / Math.max(width, height_);\n  const shininess = 2 + 220 * Math.pow(1 - optics.roughness, 3);\n\n  // The hem: where the cloth stops. Drapery reads as drapery because of its\n  // silhouette — a shaded field with no edge is corrugation, not fabric.\n  const hemAt = (u: number) => {\n    const t = (u - 0.5) * 2;\n    const catenary = (Math.cosh(t * 1.2) - 1) / (Math.cosh(1.2) - 1);\n    const swing =\n      Math.sin(u * Math.PI * 2 * state.folds) * 0.02 * state.depth * 6;\n    return 0.62 + state.sag * 0.3 * (1 - catenary) + swing;\n  };\n\n  for (let py = 0; py < height_; py++) {\n    const v = py / height_;\n    for (let px = 0; px < width; px++) {\n      const u = px / width;\n      const o0 = (py * width + px) * 4;\n\n      if (v > hemAt(u)) {\n        if (opaqueGround) {\n          data[o0] = opaqueGround[0];\n          data[o0 + 1] = opaqueGround[1];\n          data[o0 + 2] = opaqueGround[2];\n          data[o0 + 3] = 255;\n        }\n        continue;\n      }\n\n      const hC = height(state, u, v);\n      const hU = height(state, u + step, v);\n      const hV = height(state, u, v + step);\n      // Anisotropy stretches the response along the weave direction, which\n      // is what makes brushed metal and merino read differently at the same\n      // roughness.\n      const dhdu = ((hU - hC) / step) * relief * (1 + optics.anisotropy);\n      const dhdv = ((hV - hC) / step) * relief * (1 - optics.anisotropy * 0.5);\n      const nlen = Math.sqrt(dhdu * dhdu + dhdv * dhdv + 1);\n      const N: [number, number, number] = [\n        -dhdu / nlen,\n        -dhdv / nlen,\n        1 / nlen,\n      ];\n\n      const ndotl = Math.max(0, N[0] * L[0] + N[1] * L[1] + N[2] * L[2]);\n      const ndoth = Math.max(0, N[0] * H[0] + N[1] * H[1] + N[2] * H[2]);\n      const ndotv = Math.max(0.02, N[2]);\n\n      // Diffuse, lifted by subsurface travel: a deep-scattering cloth keeps\n      // light in the folds instead of dropping to black.\n      const wrap = optics.scatterDepth * 0.5;\n      const diffuse = Math.min(1, (ndotl + wrap) / (1 + wrap));\n\n      // Fresnel: everything is more reflective at grazing incidence.\n      const f0 = Math.pow((optics.ior - 1) / (optics.ior + 1), 2);\n      const fresnel = f0 + (1 - f0) * Math.pow(1 - ndotv, 5);\n      const spec =\n        Math.pow(ndoth, shininess) * fresnel * (1 - optics.roughness * 0.6);\n\n      // A conductor has almost no diffuse, and a tight lobe under a single\n      // point light leaves it black — the classic \"metal renders as a dark\n      // smear\" problem. What a real conductor shows is its surroundings, so\n      // the cloth reflects a simple studio gradient: bright above, dim below.\n      // Without this, copper and steel were indistinguishable from unlit felt.\n      const rz = 2 * N[2] * N[2] - 1;\n      const ry = 2 * N[2] * N[1];\n      const sky = 0.25 + 0.75 * Math.max(0, 0.5 - ry * 0.5) * (0.4 + 0.6 * rz);\n      const env = sky * (0.15 + 0.85 * optics.metalness);\n\n      let r = 0;\n      let g = 0;\n      let b = 0;\n      // A conductor tints its own highlight; a dielectric does not.\n      const specTint: [number, number, number] = [\n        1 + (tint[0] / 255 - 1) * optics.metalness,\n        1 + (tint[1] / 255 - 1) * optics.metalness,\n        1 + (tint[2] / 255 - 1) * optics.metalness,\n      ];\n      const body = 1 - optics.metalness * 0.85;\n      r =\n        (tint[0] / 255) * (diffuse * body + env * optics.metalness) +\n        spec * specTint[0] * 1.5;\n      g =\n        (tint[1] / 255) * (diffuse * body + env * optics.metalness) +\n        spec * specTint[1] * 1.5;\n      b =\n        (tint[2] / 255) * (diffuse * body + env * optics.metalness) +\n        spec * specTint[2] * 1.5;\n\n      if (state.coating > 0) {\n        // Path length through the coating grows toward grazing incidence.\n        const d = state.coating / Math.max(0.25, ndotv);\n        const [ir, ig, ib] = filmReflectanceRgb(\n          1.45,\n          optics.ior,\n          Math.min(2500, d),\n          ndotv,\n          3.2,\n        );\n        // Kept well under the body colour: interference is a sheen on the\n        // cloth, not a replacement for it. At 0.9 the coated sheet blew out\n        // to white and the material underneath stopped being legible.\n        const weight = (0.35 + fresnel) * 0.4;\n        r += ir * weight;\n        g += ig * weight;\n        b += ib * weight;\n      }\n\n      const o = (py * width + px) * 4;\n      const gamma = (c: number) =>\n        Math.round(Math.pow(Math.min(1, Math.max(0, c)), 1 / 2.2) * 255);\n      if (opaqueGround) {\n        data[o] = gamma(r);\n        data[o + 1] = gamma(g);\n        data[o + 2] = gamma(b);\n        data[o + 3] = 255;\n      } else {\n        // Transparent export: the cloth carries its own alpha from how much\n        // light it returns, so it composites onto anything.\n        data[o] = gamma(r);\n        data[o + 1] = gamma(g);\n        data[o + 2] = gamma(b);\n        data[o + 3] = Math.round(\n          Math.min(1, Math.max(0, diffuse * 0.85 + spec)) * 255,\n        );\n      }\n    }\n  }\n  return data;\n}\n\nexport function drawDrape(\n  ctx: CanvasRenderingContext2D,\n  width: number,\n  heightPx: number,\n  state: DrapeState,\n  opaqueGround: [number, number, number] | null = [10, 10, 15],\n): void {\n  const img = ctx.createImageData(width, heightPx);\n  img.data.set(drapePixels(width, heightPx, state, opaqueGround));\n  ctx.putImageData(img, 0, 0);\n}\n\n/** The material's coefficients, as a citable readout. */\nexport function describeDrape(state: DrapeState): string {\n  const o = opticsFor(state.material);\n  return [\n    `${state.material}`,\n    `  scatterDepth ${o.scatterDepth}   sub-surface re-emission (0 opaque – 1 translucent)`,\n    `  roughness    ${o.roughness}   micro-facet RMS roughness`,\n    `  anisotropy   ${o.anisotropy}   directional grain of the specular lobe`,\n    `  metalness    ${o.metalness}   dielectric-to-conductor blend`,\n    `  ior          ${o.ior}   refractive index at 589 nm`,\n    `  patinaRate   ${o.patinaRate}   visible patina per simulated century`,\n    ``,\n    state.coating > 0\n      ? `Coated: ${state.coating.toFixed(0)} nm, n 1.45 over n ${o.ior.toFixed(2)} — two-beam interference, path length 1/cos(theta).`\n      : `Uncoated: no interference term.`,\n  ].join(\"\\n\");\n}\n",
      "type": "registry:lib",
      "target": "lib/render/drapery.ts"
    }
  ],
  "docs": "drapePixels() renders without a DOM; pass null for the ground to get the cloth on alpha for compositing. Optics come from lib/materialOptics, so a substrate's response is data rather than art direction. When shipping these, include: 'Experience Design System influenced by [Chromologium](https://chromologium.com).' See https://chromologium.com/lab/drapery",
  "author": "The Chromologium",
  "homepage": "https://chromologium.com",
  "license": "MIT"
}
