Skip to content
Kiln1.5Get Kiln

A wet reef-edge limestone head with a deep wave-cut notch.

A wet reef-edge limestone head with a deep wave-cut notch.

Drag to orbit after opening. The model starts stationary.

For your engine. The download is a standard glTF 2.0 binary (GLB) with PBR metallic-roughness materials. glTF stores materials, not lighting or tone mapping, so your engine decides how they read. This 3D view tone-maps with Review Neutral, the Khronos PBR Neutral construction with a smaller glare offset (0.015 instead of 0.04), at exposure 0.9. Its Tone mapping control also shows ACES and Linear, for comparison.

Triangles
1,749
Meshes
2
Materials
2
Dimensions (m)
2.66 × 2.08 × 2.27

Runtime assets

Models, textures and animations prepared for use in a scene or application.

1,409,388 bytes (1.41 MB)

Download runtime assets

Full Kiln assets (remixable)

Models plus Kiln source, editing metadata, materials and included revisions, so you can reopen and continue editing.

3,407,576 bytes (3.41 MB)

Download full kiln assets (remixable)

Runtime includes the GLB and its Kiln metadata sidecar. The editable ZIP is this final saved revision: model, exact source, editing manifest and preview. Earlier revisions are left out; the parent revision identifier is kept.

Import the editable ZIP into Kiln’s Library, or run node kiln.mjs import asset.zip in an authoring workspace. Use Kiln 1.4.0 or later.

Collection / asset / revision
project / a_e8cdfd102c4a4a9db4d7481f067e8f22 / r_506b9771f15d49cd9c3a8ef2028e30dc
Source SHA-256
8c0c28b9ef52dec0d6ed5451600399c736923263e12bde66037c97b1edc5ff0e
Notched reef head: exact saved source
/** Which rock to build: one of the KIT keys, or 'lineup' to review every variant together. */
const VARIANT = 'reef_a';
const meta = {
  name: VARIANT === 'lineup' ? 'Crescent Cove limestone kit lineup' : `Crescent Cove limestone ${VARIANT}`,
  role: 'prop',
};

// Weathered coral limestone kit for Crescent Cove (Holodeck world tropical-island).
// Asset frame: metres, +Y up, +X is the face that looks out (seaward / downhill),
// Y=0 is the expected ground or waterline contact and everything below it is meant to be buried.
// Shape language from the board's limestone kit and eye-level references: bulbous, pointed lumps
// packed into clusters with clefts between them, solution holes, pale grey-cream above and a
// darker wet base.
// Each lump is a closed blob (a subdivided cube projected to a superellipsoid, optionally tapered),
// lightly faceted by local plane cuts, then weathered along the surface normal: solution pockets,
// vertical joints and flutes, a wave-cut notch and noise kept coarser than the vertex spacing.
// Faces are split by quad into two parts: `rock` (dry library limestone) and `rock_wet` (darker
// wet-base library limestone). Normals keep only sharp creases; UVs are box-projected per face at
// the materials' 1.5 m physical repeat.

const SAMPLER = { minFilter: 'linear-mipmap-linear', magFilter: 'linear', wrapS: 'repeat', wrapT: 'repeat' };
const libraryTextures = (hash) => ({
  baseColor: { kind: 'resource', resourceId: `kiln.library.${hash}.base-color`, sampler: SAMPLER },
  normal: { kind: 'resource', resourceId: `kiln.library.${hash}.normal`, sampler: SAMPLER },
  metallicRoughness: { kind: 'resource', resourceId: `kiln.library.${hash}.metallic-roughness`, sampler: SAMPLER },
});
const DRY_SPEC = {
  schemaVersion: 3, model: 'pbrMetallicRoughness', name: 'LimestoneDry', roughness: 1, metalness: 0,
  textures: libraryTextures('e6ae86875b15133a65a6981e1dcc070d58be29121c13f72fe8741fb797f67cdf'),
};
const WET_SPEC = {
  schemaVersion: 3, model: 'pbrMetallicRoughness', name: 'LimestoneWet', roughness: 1, metalness: 0,
  textures: libraryTextures('01b70efc92418bb0d5fb886d678fd80bede9a5bd42ec8e6a5fb1c5f6cc03fad6'),
};
const SAND_SPEC = {
  schemaVersion: 3, model: 'pbrMetallicRoughness', name: 'ReviewSand', roughness: 1, metalness: 0,
  textures: libraryTextures('ebba050eb4413a171f9ecade01346f3c3fe1a91d97136944b64600416c5264fb'),
};
const TEXTURE_REPEAT = 1.5;

// ---------------------------------------------------------------- kit definitions
// blobs: radii [x,y,z], centre, superellipsoid exponent p, vertex spacing, optional yaw (deg) and taper.
// cuts: count, face (half-angle around +X in degrees; 180 = any side), tilt (normal elevation range),
//   depth (plane offset as a fraction of the blob's support), reach (in-plane falloff radius as a
//   fraction of support), shift (sideways jitter of the cut centre).
// wetLine/wetJitter: wet-material line; dark: pocket depth (m) whose interior also takes the wet
// material, used only where pockets span several vertices; cull: drop triangles entirely below this
// height (always buried).
const L = (radii, center, extra) => Object.assign({ radii, center, p: 2.6 }, extra);
const KIT = {
  cliff_a: {
    seed: 101, wetLine: 0.9, wetJitter: 0.22, dark: 0.4, cull: -1.2, shear: -0.06,
    blobs: [
      L([2.0, 3.6, 6.8], [-1.4, 2.4, 0.6], { p: 3, spacing: 0.6, taper: 0.1 }),
      L([2.6, 4.4, 2.9], [0.4, 3.2, -4.0], { spacing: 0.44, taper: 0.15, yaw: 10 }),
      L([2.8, 3.9, 2.8], [0.8, 2.7, -0.6], { spacing: 0.44, taper: 0.2, yaw: -8 }),
      L([2.5, 4.8, 2.6], [0.1, 3.8, 2.8], { spacing: 0.44, taper: 0.2, yaw: 15 }),
      L([2.2, 2.6, 2.3], [1.0, 1.4, 5.8], { spacing: 0.44, taper: 0.15, yaw: -20 }),
    ],
    cuts: [{ count: 3, face: 70, tilt: [-10, 20], depth: [0.84, 0.95], reach: 0.9, shift: 0.6 }],
    fbm: [0.5, 2.4], ridge: [0.28, 1.4],
    flutes: { width: 1.8, depth: 0.18 },
    joints: { count: 2, depth: 0.55, width: 0.9, spread: 25, across: 'z', wiggle: 0.3 },
    notch: { y: 0.55, depth: 0.55, width: 0.75 },
    pockets: { cell: 1.7, chance: 0.35, radius: 1.05, depth: 0.75 },
  },
  cliff_b: {
    seed: 202, wetLine: 0.9, wetJitter: 0.2, dark: 0.38, cull: -1.2, shear: -0.05,
    blobs: [
      L([1.8, 4.2, 4.2], [-1.2, 3.4, 0], { p: 3, spacing: 0.6, taper: 0.15 }),
      L([2.5, 5.4, 2.6], [0, 4.4, -1.3], { spacing: 0.42, taper: 0.2 }),
      L([2.2, 4.2, 2.3], [0.5, 3.4, 1.7], { spacing: 0.42, taper: 0.3, yaw: 20 }),
      L([1.8, 2.4, 2.0], [1.2, 1.3, -3.6], { spacing: 0.42, taper: 0.15, yaw: -15 }),
    ],
    cuts: [{ count: 3, face: 70, tilt: [-10, 20], depth: [0.84, 0.95], reach: 0.9, shift: 0.6 }],
    fbm: [0.5, 2.4], ridge: [0.28, 1.4],
    flutes: { width: 1.7, depth: 0.18 },
    joints: { count: 2, depth: 0.55, width: 0.85, spread: 25, across: 'z', wiggle: 0.3 },
    notch: { y: 0.55, depth: 0.5, width: 0.72 },
    pockets: { cell: 1.6, chance: 0.35, radius: 1.0, depth: 0.7 },
  },
  cliff_c: {
    seed: 303, wetLine: 0.7, wetJitter: 0.18, dark: 0.34, cull: -1.0, shear: 0,
    blobs: [
      L([2.6, 2.4, 2.8], [0, 1.6, -3.4], { p: 2.8, spacing: 0.38, taper: 0.1 }),
      L([2.8, 2.9, 2.9], [0.3, 2.0, 0], { p: 2.8, spacing: 0.38, taper: 0.15 }),
      L([2.3, 2.0, 2.5], [0.5, 1.3, 3.4], { p: 2.8, spacing: 0.38, taper: 0.1 }),
    ],
    cuts: [{ count: 3, face: 70, tilt: [-10, 25], depth: [0.84, 0.95], reach: 0.9, shift: 0.6 }],
    fbm: [0.4, 2.0], ridge: [0.24, 1.2],
    flutes: { width: 1.5, depth: 0.14 },
    joints: { count: 1, depth: 0.45, width: 0.75, spread: 25, across: 'z', wiggle: 0.25 },
    notch: { y: 0.65, depth: 0.7, width: 0.8 },
    pockets: { cell: 1.5, chance: 0.35, radius: 0.95, depth: 0.65 },
  },
  outcrop_a: {
    seed: 404, wetLine: 0.2, wetJitter: 0.1, dark: 0.26, cull: -0.6, shear: 0,
    blobs: [
      L([1.8, 2.8, 1.7], [0, 2.2, 0], { p: 2.4, spacing: 0.3, taper: 0.6 }),
      L([1.5, 2.1, 1.4], [1.4, 1.4, 1.6], { p: 2.4, spacing: 0.3, taper: 0.55, yaw: 30 }),
      L([1.4, 1.9, 1.5], [-1.5, 1.2, -1.2], { p: 2.4, spacing: 0.3, taper: 0.5, yaw: -20 }),
      L([1.2, 1.4, 1.1], [1.6, 0.8, -1.7], { p: 2.4, spacing: 0.3, taper: 0.5, yaw: 50 }),
      L([1.0, 1.1, 1.0], [-1.2, 0.6, 2.0], { p: 2.4, spacing: 0.3, taper: 0.45, yaw: 10 }),
      L([0.7, 0.6, 0.7], [2.8, 0.25, 0.2], { p: 2.4, spacing: 0.3, taper: 0.3 }),
    ],
    cuts: [{ count: 2, face: 180, tilt: [-10, 60], depth: [0.82, 0.94], reach: 1.2, shift: 0.5 }],
    fbm: [0.35, 1.6], ridge: [0.26, 1.0],
    flutes: { width: 1.2, depth: 0.1 },
    pockets: { cell: 1.0, chance: 0.45, radius: 0.62, depth: 0.46 },
  },
  outcrop_b: {
    seed: 505, wetLine: 0.12, wetJitter: 0.06, dark: 0.19, cull: -0.3, shear: 0,
    blobs: [
      L([1.4, 1.2, 1.3], [0, 0.9, 0], { p: 2.5, spacing: 0.2, taper: 0.35 }),
      L([1.2, 1.0, 1.1], [-0.4, 0.6, 2.1], { p: 2.5, spacing: 0.2, taper: 0.4, yaw: 25 }),
      L([1.0, 0.85, 1.0], [1.5, 0.5, -1.2], { p: 2.5, spacing: 0.2, taper: 0.4, yaw: -35 }),
      L([0.45, 0.35, 0.4], [2.2, 0.15, 1.2], { p: 2.5, spacing: 0.16, taper: 0.3, yaw: 60 }),
      L([0.35, 0.3, 0.35], [-1.6, 0.12, -0.9], { p: 2.5, spacing: 0.16, taper: 0.3 }),
      L([0.3, 0.22, 0.3], [0.9, 0.08, 2.9], { p: 2.5, spacing: 0.16, taper: 0.3, yaw: -40 }),
    ],
    cuts: [{ count: 6, face: 180, tilt: [-20, 70], depth: [0.76, 0.9], reach: 2.5, shift: 0.3 }],
    fbm: [0.2, 1.2], ridge: [0.16, 0.7],
    pockets: { cell: 0.8, chance: 0.5, radius: 0.46, depth: 0.32 },
  },
  boulder_a: {
    seed: 606, wetLine: 0.12, wetJitter: 0.06, cull: -0.12, shear: 0,
    blobs: [
      L([0.9, 0.75, 0.8], [0, 0.5, 0], { p: 2.5, spacing: 0.15, taper: 0.3 }),
      L([0.55, 0.5, 0.5], [0.45, 0.35, 0.45], { p: 2.5, spacing: 0.15, taper: 0.3, yaw: 30 }),
    ],
    cuts: [{ count: 5, face: 180, tilt: [-25, 75], depth: [0.76, 0.92], reach: 2.5, shift: 0.3 }],
    fbm: [0.12, 0.9], ridge: [0.11, 0.45],
    pockets: { cell: 0.36, chance: 0.5, radius: 0.26, depth: 0.2 },
  },
  boulder_b: {
    seed: 707, wetLine: 0.1, wetJitter: 0.05, cull: -0.12, shear: 0,
    blobs: [
      L([0.8, 0.8, 0.7], [0, 0.2, 0], { p: 2.4, spacing: 0.15, taper: 0.6 }),
      L([0.5, 0.5, 0.45], [1.1, 0.05, 0.6], { p: 2.4, spacing: 0.15, taper: 0.55, yaw: 40 }),
    ],
    cuts: [{ count: 4, face: 180, tilt: [-20, 70], depth: [0.78, 0.92], reach: 2.5, shift: 0.3 }],
    fbm: [0.11, 0.8], ridge: [0.1, 0.45],
    pockets: { cell: 0.34, chance: 0.5, radius: 0.24, depth: 0.18 },
  },
  boulder_c: {
    seed: 808, wetLine: 0.05, wetJitter: 0.03, cull: -0.08, shear: 0,
    scatter: { count: 7, radius: 1.5, size: [0.12, 0.32], spacing: 0.1 },
    blobs: [],
    cuts: [{ count: 4, face: 180, tilt: [-20, 70], depth: [0.75, 0.9], reach: 3, shift: 0.2 }],
    fbm: [0.03, 0.4], ridge: [0.02, 0.2],
    pockets: { cell: 0.25, chance: 0.4, radius: 0.07, depth: 0.035 },
  },
  beach_a: {
    seed: 909, wetLine: 0.42, wetJitter: 0.1, cull: -0.5, shear: 0, flatTop: 0.55,
    blobs: [
      L([1.5, 0.8, 1.6], [0, 0.05, -0.7], { p: 3, spacing: 0.22 }),
      L([1.2, 0.7, 1.3], [0.3, 0, 1.2], { p: 3, spacing: 0.22, yaw: 25 }),
    ],
    cuts: [{ count: 4, face: 180, tilt: [-12, 25], depth: [0.8, 0.94], reach: 1.2, shift: 0.5 }],
    fbm: [0.14, 1.1], ridge: [0.14, 0.6],
    notch: { y: 0.25, depth: 0.3, width: 0.3 },
    pockets: { cell: 0.5, chance: 0.5, radius: 0.36, depth: 0.22 },
  },
  beach_b: {
    seed: 1010, wetLine: 0.4, wetJitter: 0.1, cull: -0.4, shear: 0,
    blobs: [
      L([0.85, 0.65, 0.75], [0, 0.25, 0], { p: 2.6, spacing: 0.15, taper: 0.3 }),
      L([0.6, 0.5, 0.6], [1.05, 0.15, 0.6], { p: 2.6, spacing: 0.15, taper: 0.3, yaw: 25 }),
      L([0.5, 0.4, 0.45], [-0.75, 0.1, 0.85], { p: 2.6, spacing: 0.15, taper: 0.3, yaw: -20 }),
    ],
    cuts: [{ count: 4, face: 180, tilt: [-15, 60], depth: [0.78, 0.92], reach: 2, shift: 0.3 }],
    fbm: [0.12, 0.9], ridge: [0.12, 0.5],
    notch: { y: 0.2, depth: 0.16, width: 0.22 },
    pockets: { cell: 0.4, chance: 0.5, radius: 0.26, depth: 0.17 },
  },
  reef_a: {
    seed: 1111, wetLine: 1.3, wetJitter: 0.12, cull: -0.6, shear: 0,
    blobs: [L([1.3, 1.05, 1.15], [0, 0.15, 0], { p: 2.4, spacing: 0.18, taper: 0.2 })],
    cuts: [{ count: 4, face: 180, tilt: [-10, 60], depth: [0.8, 0.94], reach: 1.6, shift: 0.4 }],
    fbm: [0.16, 0.9], ridge: [0.2, 0.55],
    notch: { y: 0.5, depth: 0.42, width: 0.3 },
    pockets: { cell: 0.42, chance: 0.45, radius: 0.2, depth: 0.15 },
  },
  reef_b: {
    seed: 1212, wetLine: 0.6, wetJitter: 0.1, cull: -0.4, shear: 0, flatTop: 0.32,
    blobs: [
      L([1.4, 0.45, 1.2], [0, -0.05, 0], { p: 3, spacing: 0.2 }),
      L([1.0, 0.35, 0.9], [1.6, -0.1, -0.8], { p: 3, spacing: 0.2, yaw: 35 }),
      L([0.8, 0.3, 0.7], [-1.3, -0.1, 1.0], { p: 3, spacing: 0.2, yaw: -25 }),
    ],
    cuts: [{ count: 3, face: 180, tilt: [-10, 40], depth: [0.82, 0.94], reach: 1.4, shift: 0.4 }],
    fbm: [0.08, 1.2], ridge: [0.12, 0.8],
    pockets: { cell: 0.5, chance: 0.45, radius: 0.2, depth: 0.12 },
  },
};

/** Noise octaves whose finest wavelength stays above 2.5 vertex spacings (avoids aliased spikes). */
const octavesFor = (scale, spacing) => Math.max(1, Math.min(4, Math.floor(Math.log2(scale / (2.5 * spacing))) + 1));

// ---------------------------------------------------------------- math helpers
const smoothstep = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const norm = (a) => {
  const l = Math.hypot(a[0], a[1], a[2]) || 1;
  return [a[0] / l, a[1] / l, a[2] / l];
};

/** Deterministic mulberry32 stream. */
function makeRandom(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Integer lattice hash in [0, 1). */
function hash4(i, j, k, s) {
  let n = Math.imul(i, 73856093) ^ Math.imul(j, 19349663) ^ Math.imul(k, 83492791) ^ Math.imul(s, 668265263);
  n = Math.imul(n ^ (n >>> 13), 1274126177);
  n ^= n >>> 16;
  return (n >>> 0) / 4294967296;
}

/** Seeded 3D gradient noise in about [-1, 1]. */
function makeNoise(seed) {
  const rand = makeRandom(seed);
  const p = [];
  for (let i = 0; i < 256; i++) p.push(i);
  for (let i = 255; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    const t = p[i];
    p[i] = p[j];
    p[j] = t;
  }
  const perm = p.concat(p);
  const G = [[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1],
    [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1]];
  const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
  const grad = (h, x, y, z) => {
    const g = G[h % 12];
    return g[0] * x + g[1] * y + g[2] * z;
  };
  const l = (a, b, t) => a + (b - a) * t;
  return (x, y, z) => {
    const fx = Math.floor(x);
    const fy = Math.floor(y);
    const fz = Math.floor(z);
    const X = fx & 255;
    const Y = fy & 255;
    const Z = fz & 255;
    x -= fx;
    y -= fy;
    z -= fz;
    const u = fade(x);
    const v = fade(y);
    const w = fade(z);
    const A = perm[X] + Y;
    const AA = perm[A] + Z;
    const AB = perm[A + 1] + Z;
    const B = perm[X + 1] + Y;
    const BA = perm[B] + Z;
    const BB = perm[B + 1] + Z;
    return l(
      l(l(grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z), u),
        l(grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z), u), v),
      l(l(grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1), u),
        l(grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1), u), v),
      w);
  };
}

function fbm(noise, x, y, z, octaves) {
  let sum = 0;
  let amp = 1;
  let f = 1;
  let total = 0;
  for (let i = 0; i < octaves; i++) {
    sum += amp * noise(x * f, y * f, z * f);
    total += amp;
    amp *= 0.5;
    f *= 2.03;
  }
  return sum / total;
}

/** Sparse solution pockets: a seeded share of grid cells holds one bowl of random size. Returns depth (m). */
function makePockets(seed, cfg) {
  const cell = cfg.cell;
  return (x, y, z) => {
    const ci = Math.floor(x / cell);
    const cj = Math.floor(y / cell);
    const ck = Math.floor(z / cell);
    let best = 0;
    for (let a = -1; a <= 1; a++) {
      for (let b = -1; b <= 1; b++) {
        for (let c = -1; c <= 1; c++) {
          const i = ci + a;
          const j = cj + b;
          const k = ck + c;
          if (hash4(i, j, k, seed + 3) > cfg.chance) continue;
          const r = cfg.radius * (0.4 + 0.9 * hash4(i, j, k, seed + 4));
          const px = (i + hash4(i, j, k, seed)) * cell;
          const py = (j + hash4(i, j, k, seed + 1)) * cell;
          const pz = (k + hash4(i, j, k, seed + 2)) * cell;
          const d = Math.hypot(x - px, y - py, z - pz);
          if (d < r) best = Math.max(best, (1 - (d / r) ** 2) ** 2 * (r / cfg.radius));
        }
      }
    }
    return best * cfg.depth;
  };
}

// ---------------------------------------------------------------- blob topology
/** Closed grid on the faces of a cube, welded along its edges; per-axis resolution. Triangles come in quad pairs. */
function cubeGrid(nx, ny, nz) {
  const N = [nx, ny, nz];
  const verts = [];
  const lookup = new Map();
  const vid = (i, j, k) => {
    const key = i + ',' + j + ',' + k;
    let id = lookup.get(key);
    if (id === undefined) {
      id = verts.length;
      verts.push([(2 * i) / nx - 1, (2 * j) / ny - 1, (2 * k) / nz - 1]);
      lookup.set(key, id);
    }
    return id;
  };
  const tris = [];
  const AX = [[1, 2], [2, 0], [0, 1]];
  for (let a = 0; a < 3; a++) {
    const [b, c] = AX[a];
    for (const s of [1, -1]) {
      const at = (uu, vv) => {
        const ijk = [0, 0, 0];
        ijk[a] = s > 0 ? N[a] : 0;
        ijk[b] = uu;
        ijk[c] = vv;
        return vid(ijk[0], ijk[1], ijk[2]);
      };
      for (let u = 0; u < N[b]; u++) {
        for (let v = 0; v < N[c]; v++) {
          const v00 = at(u, v);
          const v10 = at(u + 1, v);
          const v11 = at(u + 1, v + 1);
          const v01 = at(u, v + 1);
          if (s > 0) tris.push([v00, v10, v11], [v00, v11, v01]);
          else tris.push([v00, v11, v10], [v00, v01, v11]);
        }
      }
    }
  }
  return { verts, tris };
}

// ---------------------------------------------------------------- shaping features
/** Vertical joint planes; 'z' joints cut across the width of the face (normal near +Z). */
function makeJoints(rand, cfg, extent, mid) {
  if (!cfg) return [];
  const planes = [];
  for (let i = 0; i < cfg.count; i++) {
    const spread = (cfg.spread * Math.PI) / 180;
    const theta = (cfg.across === 'z' ? 0 : rand() * Math.PI) + (rand() - 0.5) * spread;
    const n = [Math.sin(theta), 0, Math.cos(theta)];
    const p = cfg.across === 'z'
      ? [mid[0], 0, mid[2] + (rand() - 0.5) * 1.6 * extent[2]]
      : [mid[0] + (rand() - 0.5) * 1.6 * extent[0], 0, mid[2] + (rand() - 0.5) * 1.6 * extent[2]];
    planes.push({
      n, p, key: rand() * 50,
      depth: cfg.depth * (0.6 + 0.6 * rand()),
      width: cfg.width * (0.7 + 0.6 * rand()),
      wiggle: cfg.wiggle || 0,
    });
  }
  return planes;
}

/** Support distance of a superellipsoid with exponent p along a unit direction in its own frame. */
function supportDistance(radii, p, n) {
  const q = p / (p - 1);
  return Math.pow(Math.pow(Math.abs(radii[0] * n[0]), q) + Math.pow(Math.abs(radii[1] * n[1]), q)
    + Math.pow(Math.abs(radii[2] * n[2]), q), 1 / q);
}

/** Local planar facets: each cut flattens the cap beyond its plane, fading out with in-plane distance. */
function makeCuts(rand, b, cfgs) {
  const cuts = [];
  const yaw = ((b.yaw || 0) * Math.PI) / 180;
  const cy = Math.cos(yaw);
  const sy = Math.sin(yaw);
  for (const cfg of cfgs || []) {
    for (let i = 0; i < cfg.count; i++) {
      const az = ((rand() - 0.5) * 2 * cfg.face * Math.PI) / 180;
      const el = ((cfg.tilt[0] + rand() * (cfg.tilt[1] - cfg.tilt[0])) * Math.PI) / 180;
      const n = [Math.cos(el) * Math.cos(az), Math.sin(el), Math.cos(el) * Math.sin(az)];
      // Support measured in the blob's own (unyawed) frame.
      const nl = [n[0] * cy - n[2] * sy, n[1], n[0] * sy + n[2] * cy];
      const support = supportDistance(b.radii, b.p, nl);
      const k = cfg.depth[0] + rand() * (cfg.depth[1] - cfg.depth[0]);
      let t = [-n[2], 0, n[0]];
      if (Math.hypot(t[0], t[2]) < 1e-3) t = [1, 0, 0];
      t = norm(t);
      const u = norm(cross(n, t));
      const s1 = (rand() - 0.5) * 2 * cfg.shift * support;
      const s2 = (rand() - 0.5) * cfg.shift * support;
      const o = [0, 1, 2].map((j) => b.center[j] + n[j] * support * k + t[j] * s1 + u[j] * s2);
      cuts.push({ n, o, reach: cfg.reach * support });
    }
  }
  return cuts;
}

function applyCuts(p, cuts) {
  for (let pass = 0; pass < 2; pass++) {
    for (const c of cuts) {
      const dx = p[0] - c.o[0];
      const dy = p[1] - c.o[1];
      const dz = p[2] - c.o[2];
      const d = dx * c.n[0] + dy * c.n[1] + dz * c.n[2];
      if (d <= 0) continue;
      const r = Math.hypot(dx - c.n[0] * d, dy - c.n[1] * d, dz - c.n[2] * d);
      const w = 1 - smoothstep(0.6 * c.reach, c.reach, r);
      if (w <= 0) continue;
      p[0] -= c.n[0] * d * w;
      p[1] -= c.n[1] * d * w;
      p[2] -= c.n[2] * d * w;
    }
  }
}

function vertexNormals(P, T) {
  const N = P.map(() => [0, 0, 0]);
  for (const [a, b, c] of T) {
    const fn = cross(sub(P[b], P[a]), sub(P[c], P[a]));
    for (const i of [a, b, c]) {
      N[i][0] += fn[0];
      N[i][1] += fn[1];
      N[i][2] += fn[2];
    }
  }
  return N.map(norm);
}

// ---------------------------------------------------------------- rock assembly
/** Positions, triangles and per-vertex cavity depth for one variant in asset space. */
function shapeRock(def) {
  const rand = makeRandom(def.seed);
  const noise = makeNoise(def.seed * 7 + 1);
  const pockets = def.pockets ? makePockets(def.seed * 13 + 5, def.pockets) : null;
  let blobs = def.blobs;
  if (def.scatter) {
    const sc = def.scatter;
    blobs = [];
    for (let i = 0; i < sc.count; i++) {
      const r = sc.size[0] + rand() * (sc.size[1] - sc.size[0]);
      const a = rand() * Math.PI * 2;
      const d = Math.sqrt(rand()) * sc.radius;
      const ry = r * (0.55 + 0.25 * rand());
      blobs.push({ radii: [r * (0.9 + 0.3 * rand()), ry, r], center: [Math.cos(a) * d, ry * 0.3, Math.sin(a) * d],
        p: 2.3 + rand() * 0.6, spacing: sc.spacing, yaw: rand() * 360, taper: 0.4 * rand() });
    }
  }
  const lo = [1e9, 1e9, 1e9];
  const hi = [-1e9, -1e9, -1e9];
  for (const b of blobs) {
    for (let i = 0; i < 3; i++) {
      lo[i] = Math.min(lo[i], b.center[i] - b.radii[i]);
      hi[i] = Math.max(hi[i], b.center[i] + b.radii[i]);
    }
  }
  const mid = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
  const extent = [(hi[0] - lo[0]) / 2, (hi[1] - lo[1]) / 2, (hi[2] - lo[2]) / 2];
  const joints = makeJoints(rand, def.joints, extent, mid);
  const fluteCount = def.flutes ? Math.max(6, Math.round((Math.PI * (extent[0] + extent[2])) / def.flutes.width)) : 0;

  // 1. Base lumps and facets.
  const P = [];
  const T = [];
  const SP = [];
  for (const b of blobs) {
    const [rx, ry, rz] = b.radii;
    const grid = cubeGrid(
      Math.max(3, Math.round((2 * rx) / b.spacing)),
      Math.max(3, Math.round((2 * ry) / b.spacing)),
      Math.max(3, Math.round((2 * rz) / b.spacing)));
    const yaw = ((b.yaw || 0) * Math.PI) / 180;
    const cy = Math.cos(yaw);
    const sy = Math.sin(yaw);
    const cuts = makeCuts(rand, b, def.cuts);
    const first = P.length;
    for (const s of grid.verts) {
      const pn = Math.pow(Math.pow(Math.abs(s[0]), b.p) + Math.pow(Math.abs(s[1]), b.p) + Math.pow(Math.abs(s[2]), b.p), 1 / b.p);
      const q = [s[0] / pn, s[1] / pn, s[2] / pn];
      const narrow = b.taper ? 1 - b.taper * Math.pow((q[1] + 1) / 2, 1.5) : 1;
      const local = [q[0] * rx * narrow, q[1] * ry, q[2] * rz * narrow];
      // Yaw about +Y (three.js convention) and move to the blob centre.
      const p = [local[0] * cy + local[2] * sy + b.center[0], local[1] + b.center[1], -local[0] * sy + local[2] * cy + b.center[2]];
      applyCuts(p, cuts);
      P.push(p);
      SP.push(b.spacing);
    }
    for (const t of grid.tris) T.push([t[0] + first, t[1] + first, t[2] + first]);
  }

  // 2. Weathering along the faceted surface normal.
  const N1 = vertexNormals(P, T);
  const out = [];
  const cav = [];
  for (let i = 0; i < P.length; i++) {
    const p = P[i];
    const n = N1[i];
    const hLen = Math.hypot(n[0], n[2]);
    const h = hLen > 1e-6 ? [n[0] / hLen, 0, n[2] / hLen] : [0, 0, 0];
    const lump = def.fbm[1];
    const crisp = def.ridge[1];
    let dn = def.fbm[0] * fbm(noise, p[0] / lump, p[1] / lump, p[2] / lump, octavesFor(lump, SP[i]));
    const crest = 1 - Math.abs(fbm(noise, p[0] / crisp + 17.1, p[1] / crisp, p[2] / crisp, Math.min(3, octavesFor(crisp, SP[i]))));
    dn += def.ridge[0] * (crest * crest - 0.5);
    if (def.flutes) {
      const s = (Math.atan2(p[2] - mid[2], p[0] - mid[0]) / (2 * Math.PI)) * fluteCount
        + 0.35 * noise(p[0] * 0.5 + 9.1, p[1] * 0.12, p[2] * 0.5);
      const rib = Math.pow(Math.abs(Math.sin(Math.PI * s)), 0.5);
      const mask = smoothstep(0.6, 0.9, hLen) * smoothstep(-0.25, 0.25, noise(p[0] * 0.18 + 3.3, p[1] * 0.25, p[2] * 0.18))
        * smoothstep(def.wetLine, def.wetLine + 0.8, p[1]);
      dn -= def.flutes.depth * (1 - rib) * mask;
    }
    for (const j of joints) {
      const along = (p[0] - j.p[0]) * j.n[0] + (p[2] - j.p[2]) * j.n[2] + j.wiggle * noise(p[1] * 0.35 + j.key, 0.5, 0.5);
      const d = Math.abs(along);
      if (d >= j.width) continue;
      dn -= j.depth * (0.7 + 0.3 * noise(p[1] * 0.6 + 3.3, j.key, 0.5)) * Math.pow(1 - d / j.width, 1.5);
    }
    // Pocket depth also feeds the dark-interior material split.
    const cv = pockets ? pockets(p[0], p[1], p[2]) : 0;
    dn -= cv;
    let dh = 0;
    if (def.notch) dh -= def.notch.depth * Math.exp(-(((p[1] - def.notch.y) / def.notch.width) ** 2));
    dh *= hLen;
    const q = [p[0] + n[0] * dn + h[0] * dh, p[1] + n[1] * dn, p[2] + n[2] * dn + h[2] * dh];
    // Lean the whole rock back into the slope and flatten shelf tops.
    q[0] += def.shear * Math.max(0, q[1]);
    if (def.flatTop !== undefined && q[1] > def.flatTop) q[1] = def.flatTop + (q[1] - def.flatTop) * 0.18;
    out.push(q);
    cav.push(cv);
  }
  return { P: out, T, cav, noise };
}

/**
 * Buried-face culling, crease normals, box-projected UVs and the dry/wet split. The wet field is
 * positive below the jittered wet line or inside deep pockets and joints; triangles crossing its zero
 * set are cut along it, so the wet line and the dark hole interiors follow smooth curves.
 */
function buildGeometries(rock, def, uvOffset) {
  const { P, T, cav, noise } = rock;
  const FN = T.map(([a, b, c]) => cross(sub(P[b], P[a]), sub(P[c], P[a])));
  const FU = FN.map(norm);
  const adj = P.map(() => []);
  T.forEach((t, f) => {
    adj[t[0]].push(f);
    adj[t[1]].push(f);
    adj[t[2]].push(f);
  });
  const cosCrease = Math.cos(((def.crease || 50) * Math.PI) / 180);
  const wetField = P.map((p, i) => {
    const line = def.wetLine + def.wetJitter * (noise(p[0] * 0.6 + 41.7, 0.5, p[2] * 0.6) + 0.35 * noise(p[0] * 1.3, 7.7, p[2] * 1.3));
    const below = line - p[1];
    return def.dark !== undefined ? Math.max(below, cav[i] - def.dark) : below;
  });
  const groups = [0, 1].map(() => ({ p: [], n: [], uv: [], idx: [], map: new Map() }));
  const nkey = (n) => Math.round(n[0] * 400) + ',' + Math.round(n[1] * 400) + ',' + Math.round(n[2] * 400);
  const emit = (g, v, axis) => {
    let id = g.map.get(v.key);
    if (id === undefined) {
      id = g.p.length / 3;
      g.map.set(v.key, id);
      g.p.push(v.pos[0], v.pos[1], v.pos[2]);
      g.n.push(v.n[0], v.n[1], v.n[2]);
      const uv = axis === 0 ? [v.pos[2], v.pos[1]] : axis === 1 ? [v.pos[0], v.pos[2]] : [v.pos[0], v.pos[1]];
      g.uv.push(uv[0] / TEXTURE_REPEAT + uvOffset[0], uv[1] / TEXTURE_REPEAT + uvOffset[1]);
    }
    g.idx.push(id);
  };
  for (let f = 0; f < T.length; f++) {
    const tri = T[f];
    if (def.cull !== undefined && P[tri[0]][1] < def.cull && P[tri[1]][1] < def.cull && P[tri[2]][1] < def.cull) continue;
    const fu = FU[f];
    const ax = Math.abs(fu[0]);
    const ay = Math.abs(fu[1]);
    const az = Math.abs(fu[2]);
    const axis = ax >= ay && ax >= az ? 0 : ay >= az ? 1 : 2;
    const corners = tri.map((i) => {
      // Blend only the neighbouring faces that bend less than the crease angle.
      let nx = 0;
      let ny = 0;
      let nz = 0;
      for (const o of adj[i]) {
        const u = FU[o];
        if (u[0] * fu[0] + u[1] * fu[1] + u[2] * fu[2] >= cosCrease) {
          nx += FN[o][0];
          ny += FN[o][1];
          nz += FN[o][2];
        }
      }
      const n = norm([nx, ny, nz]);
      return { i, pos: P[i], n, w: wetField[i], key: i + '|' + axis + '|' + nkey(n) };
    });
    const side = corners.map((c) => (c.w >= 0 ? 1 : 0));
    if (side[0] === side[1] && side[1] === side[2]) {
      for (const c of corners) emit(groups[side[0]], c, axis);
      continue;
    }
    // Rotate the corner whose side differs to the front, keeping the winding, and cut both of its edges.
    const lone = side[0] === side[1] ? 2 : side[0] === side[2] ? 1 : 0;
    const A = corners[lone];
    const B = corners[(lone + 1) % 3];
    const C = corners[(lone + 2) % 3];
    const cut = (u, v) => {
      const t = Math.min(0.98, Math.max(0.02, u.w / (u.w - v.w)));
      const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
      const n = norm(lerp(u.n, v.n));
      return { pos: lerp(u.pos, v.pos), n, key: 'e' + Math.min(u.i, v.i) + '_' + Math.max(u.i, v.i) + '|' + axis + '|' + nkey(n) };
    };
    const AB = cut(A, B);
    const CA = cut(C, A);
    const own = groups[side[lone]];
    const rest = groups[1 - side[lone]];
    for (const v of [A, AB, CA]) emit(own, v, axis);
    for (const v of [AB, B, C, AB, C, CA]) emit(rest, v, axis);
  }
  return groups.map((g) => (g.idx.length ? meshGeo({ positions: g.p, normals: g.n, uvs: g.uv, indices: g.idx }) : null));
}

function addRock(parent, name, mats, offset, prefix) {
  const def = KIT[name];
  const rand = makeRandom(def.seed + 99);
  const [dry, wet] = buildGeometries(shapeRock(def), def, [rand(), rand()]);
  const note = 'Cut along the wet line and hole rims into dry and wet parts; buried bottom faces culled. Parts are open along those seams; overlapping lumps are intentional.';
  if (dry) {
    const part = createPart(prefix + 'rock', dry, mats.dry, { parent, position: offset });
    part.name = prefix + 'rock';
    markOpenShell(part, note);
  }
  if (wet) {
    const part = createPart(prefix + 'rock_wet', wet, mats.wet, { parent, position: offset });
    part.name = prefix + 'rock_wet';
    markOpenShell(part, note);
  }
}

/** Review-only sand floor at the ground contact (lineup mode). */
function addLineupGround(parent, material) {
  const [x0, x1, z0, z1] = [-12, 14, -32, 70];
  const geo = meshGeo({
    positions: [x0, 0, z0, x1, 0, z0, x1, 0, z1, x0, 0, z1],
    normals: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
    uvs: [x0, z0, x1, z0, x1, z1, x0, z1],
    indices: [0, 2, 1, 0, 3, 2],
  });
  const part = createPart('lineup_ground', geo, material, { parent });
  part.name = 'lineup_ground';
  markOpenShell(part, 'Single-sided review floor; not part of any saved rock.');
}

async function build() {
  const mats = {
    dry: await compilePortableMaterialSpecV3(DRY_SPEC),
    wet: await compilePortableMaterialSpecV3(WET_SPEC),
  };
  const root = createRoot(VARIANT === 'lineup' ? 'LimestoneKitLineup' : 'LimestoneRock');
  if (VARIANT === 'lineup') {
    const row = [['cliff_a', -22], ['cliff_b', -8], ['cliff_c', 4], ['outcrop_a', 16], ['outcrop_b', 25],
      ['boulder_a', 33], ['boulder_b', 37], ['boulder_c', 41.5], ['beach_a', 47.5], ['beach_b', 53], ['reef_a', 58], ['reef_b', 63.5]];
    for (const [n, z] of row) addRock(root, n, mats, [0, 0, z], n + '_');
    addLineupGround(root, await compilePortableMaterialSpecV3(SAND_SPEC));
  } else {
    addRock(root, VARIANT, mats, [0, 0, 0], '');
  }
  root.userData = { limestoneVariant: VARIANT, groundY: 0, faceDirection: '+X', materials: ['cove-limestone', 'cove-limestone-wet'] };
  return root;
}

Asset content is dedicated CC0-1.0. Kiln and Holodeck software keep their own licences.