import * as THREE from 'three';
import { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
import type { Bread } from './bread';
import type { SpreadDef } from './spreads';

export const FIELD_N = 128;

/** Past this, the surface has stopped being toast and started being charcoal. */
export const CHAR_THRESHOLD = 0.85;

/** Hard cap on rind bits — also the instanced mesh's allocation. */
export const RIND_MAX = 96;

/**
 * One slice of bread: the mesh, the four scalar fields the whole game reads and
 * writes, and the shader that turns them into something appetising (or not).
 *
 * Fields are packed into a single RGBA8 texture — one small upload per frame
 * instead of four, and the shader gets free bilinear smoothing over the 128x128
 * sim grid, which is what makes knife strokes look continuous.
 */
export class Slice {
  readonly bread: Bread;
  readonly mesh: THREE.Mesh;
  readonly material: THREE.ShaderMaterial;

  /** 0 = raw, 0.85 = char threshold, 1 = carbon. */
  readonly browning: Field;
  /** Water that must boil off before browning gets going. */
  readonly dryness: Field;
  /** Spread thickness. ~0.12 thin, ~0.4 normal, ~0.8 thick. */
  readonly spread: Field;
  /** Torn/gouged surface. Permanent. */
  readonly damage: Field;
  /** 1 inside the bread silhouette. Every judged statistic is masked by this. */
  readonly mask: Field;
  /** Per-run heat bias — why no two slices toast identically. */
  readonly heatBias: Float32Array;

  /** 1 right out of the toaster, decays to 0. Softens butter. */
  warmth = 0;
  /** Which spread is currently on the slice (for judging + shading). */
  spreadDef: SpreadDef | null = null;
  /** Extent of the UV projection, so world hits can be turned back into texels. */
  readonly sizeX: number;
  readonly sizeZ: number;
  readonly halfThickness: number;

  /**
   * Solid bits sitting on the spread (marmalade rind), in UV space. Discrete
   * points rather than a fifth field: the judge scores their *distribution*, and
   * nearest-neighbour statistics want points, not a raster.
   */
  readonly rind: { u: number; v: number }[] = [];
  private rindMesh: THREE.InstancedMesh | null = null;
  private rindDirty = false;

  /**
   * Consistency bookkeeping for spreads that separate: every deposit remembers
   * the oil level it landed at, and the judge reads the mass-weighted mean.
   * 0.5 is right; 1 is a slick; 0 is grout.
   */
  private spreadMassSum = 0;
  private oilMassSum = 0;
  private oilSqSum = 0;

  private tex: THREE.DataTexture;
  private texData: Uint8Array;
  private dirty = true;

  constructor(bread: Bread, rng: Rng) {
    this.bread = bread;
    this.browning = new Field(FIELD_N);
    this.dryness = new Field(FIELD_N);
    this.spread = new Field(FIELD_N);
    this.damage = new Field(FIELD_N);
    this.mask = new Field(FIELD_N);

    const shape = breadShape(bread);
    buildMask(this.mask, shape, bread);
    this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, 3);

    this.texData = new Uint8Array(FIELD_N * FIELD_N * 4);
    this.tex = new THREE.DataTexture(this.texData, FIELD_N, FIELD_N, THREE.RGBAFormat);
    this.tex.minFilter = THREE.LinearFilter;
    this.tex.magFilter = THREE.LinearFilter;
    this.tex.wrapS = THREE.ClampToEdgeWrapping;
    this.tex.wrapT = THREE.ClampToEdgeWrapping;
    this.tex.needsUpdate = true;

    const geo = buildGeometry(shape, bread);
    const bb = geo.boundingBox!;
    this.sizeX = bb.max.x - bb.min.x;
    this.sizeZ = bb.max.z - bb.min.z;
    this.halfThickness = bb.max.y;
    this.material = buildMaterial(bread, this.tex);
    this.mesh = new THREE.Mesh(geo, this.material);
    this.mesh.castShadow = true;
    this.mesh.receiveShadow = true;
    this.sync();
  }

  /**
   * World point -> field UV. The UVs were planar-projected from the shape's own
   * XY before the slice was laid flat, which makes shape +y become world -z —
   * hence the flip on v.
   */
  uvAt(worldPoint: THREE.Vector3, out: THREE.Vector2): THREE.Vector2 {
    const p = this.mesh.worldToLocal(worldPoint.clone());
    return out.set(p.x / this.sizeX + 0.5, 0.5 - p.z / this.sizeZ);
  }

  /** Height of the top face in world space (the slice lies flat when spreading). */
  get topY(): number {
    return this.mesh.position.y + this.halfThickness;
  }

  recordDeposit(mass: number, oil: number): void {
    this.spreadMassSum += mass;
    this.oilMassSum += mass * oil;
    this.oilSqSum += mass * oil * oil;
  }

  /** Scraping takes spread off at whatever the current blend is. */
  recordRemoval(mass: number): void {
    const mean = this.oilMean;
    this.spreadMassSum = Math.max(0, this.spreadMassSum - mass);
    this.oilMassSum = Math.max(0, this.oilMassSum - mass * mean);
    this.oilSqSum = Math.max(0, this.oilSqSum - mass * mean * mean);
  }

  /** Mass-weighted consistency of everything on the toast. 0.5 until proven otherwise. */
  get oilMean(): number {
    return this.spreadMassSum < 1 ? 0.5 : this.oilMassSum / this.spreadMassSum;
  }

  /**
   * Mass-weighted spread of consistencies. This is what catches the lazy play:
   * a slick strip and a grout strip AVERAGE to "just right" — but nobody wants
   * to eat the average, they eat one bite at a time.
   */
  get oilStdev(): number {
    if (this.spreadMassSum < 1) return 0;
    const mean = this.oilMean;
    return Math.sqrt(Math.max(0, this.oilSqSum / this.spreadMassSum - mean * mean));
  }

  /** Drop a bit of rind at (u,v). It rides the slice from then on. */
  addRind(u: number, v: number): void {
    if (this.rind.length >= RIND_MAX) return;
    this.rind.push({ u, v });
    this.rindDirty = true;
  }

  /** Scrape rind off within `radius` (UV units) of (u,v). Returns how many went. */
  removeRindNear(u: number, v: number, radius: number): number {
    const r2 = radius * radius;
    let removed = 0;
    for (let i = this.rind.length - 1; i >= 0; i--) {
      const p = this.rind[i];
      const du = p.u - u;
      const dv = p.v - v;
      if (du * du + dv * dv <= r2) {
        this.rind.splice(i, 1);
        removed++;
      }
    }
    if (removed) this.rindDirty = true;
    return removed;
  }

  clearRind(): void {
    if (this.rind.length) this.rindDirty = true;
    this.rind.length = 0;
  }

  /**
   * Rebuild the rind instances. The instanced mesh is a child of the slice mesh
   * and positioned in its local space, so rind rides along when the toast flies,
   * lands, and turns on the judge's pedestal.
   */
  private syncRind(): void {
    if (!this.rindDirty) return;
    this.rindDirty = false;
    if (!this.rindMesh) {
      if (this.rind.length === 0) return;
      const def = this.spreadDef?.particles;
      const s = def?.size ?? 0.05;
      const geo = new THREE.BoxGeometry(s * 1.6, s * 0.55, s * 0.9);
      // The palette is authored in sRGB; passing the floats raw would have the
      // material read them as linear and render candied orange as pale butter —
      // the same trap as the slice shader's albedo.
      const col = new THREE.Color().setRGB(
        ...(def?.color ?? ([0.6, 0.25, 0.05] as [number, number, number])),
        THREE.SRGBColorSpace,
      );
      const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.38 });
      this.rindMesh = new THREE.InstancedMesh(geo, mat, RIND_MAX);
      this.rindMesh.castShadow = true;
      this.mesh.add(this.rindMesh);
    }
    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const pos = new THREE.Vector3();
    const scl = new THREE.Vector3();
    const axis = new THREE.Vector3(0, 1, 0);
    for (let i = 0; i < this.rind.length; i++) {
      const p = this.rind[i];
      pos.set(
        (p.u - 0.5) * this.sizeX,
        this.halfThickness + 0.008,
        (0.5 - p.v) * this.sizeZ,
      );
      // Deterministic per-index jitter so the pieces read as strewn, not stamped.
      const h = Math.sin(i * 127.1) * 43758.5453;
      const r = h - Math.floor(h);
      q.setFromAxisAngle(axis, r * Math.PI * 2);
      scl.setScalar(0.75 + r * 0.6);
      m.compose(pos, q, scl);
      this.rindMesh.setMatrixAt(i, m);
    }
    this.rindMesh.count = this.rind.length;
    this.rindMesh.instanceMatrix.needsUpdate = true;
  }

  get uniforms() {
    return this.material.uniforms;
  }

  /** Push the sim fields into the GPU texture. Cheap: 64KB. */
  sync(): void {
    this.syncRind();
    if (!this.dirty) return;
    const d = this.texData;
    const b = this.browning.data;
    const s = this.spread.data;
    const g = this.damage.data;
    const m = this.mask.data;
    for (let i = 0, j = 0; i < b.length; i++, j += 4) {
      d[j] = clamp255(b[i] * 255);
      d[j + 1] = clamp255(s[i] * 255);
      d[j + 2] = clamp255(g[i] * 255);
      d[j + 3] = clamp255(m[i] * 255);
    }
    this.tex.needsUpdate = true;
    this.dirty = false;
  }

  touch(): void {
    this.dirty = true;
  }

  setSpread(def: SpreadDef | null): void {
    this.spreadDef = def;
    const u = this.material.uniforms;
    if (def) {
      u.uSpreadColor.value.setRGB(def.color[0], def.color[1], def.color[2]);
      u.uSpreadGloss.value = def.gloss;
      u.uSpreadOpaqueAt.value = def.opaqueAt;
      u.uSpreadBump.value = def.bump;
    } else {
      u.uSpreadOpaqueAt.value = 1e9;
    }
  }

  setHeatGlow(v: number): void {
    this.material.uniforms.uHeatGlow.value = v;
  }

  setHeatmap(mode: 0 | 1 | 2): void {
    this.material.uniforms.uHeatmap.value = mode;
  }

  /**
   * The slice rolls its own lighting, so scene lights don't touch it — which
   * means the judge's spotlight would do nothing at all. Swap the shader's own
   * rig instead: hard key, near-black ambient.
   */
  setPresentation(on: boolean): void {
    const u = this.material.uniforms;
    if (on) {
      u.uLightDir.value.set(0.4, 0.86, 0.52).normalize();
      u.uLightColor.value.setRGB(1.35, 1.24, 1.05);
      u.uAmbientSky.value.setRGB(0.1, 0.11, 0.15);
      u.uAmbientGround.value.setRGB(0.035, 0.03, 0.03);
    } else {
      u.uLightDir.value.set(0.5, 0.9, 0.42).normalize();
      u.uLightColor.value.setRGB(1.0, 0.95, 0.86);
      u.uAmbientSky.value.setRGB(0.26, 0.27, 0.31);
      u.uAmbientGround.value.setRGB(0.14, 0.11, 0.09);
    }
  }

  dispose(): void {
    if (this.rindMesh) {
      this.rindMesh.geometry.dispose();
      (this.rindMesh.material as THREE.Material).dispose();
    }
    this.mesh.geometry.dispose();
    this.material.dispose();
    this.tex.dispose();
  }
}

function clamp255(v: number): number {
  return v < 0 ? 0 : v > 255 ? 255 : v | 0;
}

/** The classic sandwich-loaf silhouette: square-ish body, domed top. */
function breadShape(bread: Bread): THREE.Shape {
  const hw = bread.width / 2;
  const hh = bread.height / 2;
  const dome = bread.domeH;
  const r = 0.09;
  const bodyTop = hh - dome;
  const s = new THREE.Shape();
  s.moveTo(-hw + r, -hh);
  s.lineTo(hw - r, -hh);
  s.quadraticCurveTo(hw, -hh, hw, -hh + r);
  s.lineTo(hw, bodyTop);
  s.bezierCurveTo(hw, bodyTop + dome * 0.86, hw * 0.62, hh, 0, hh);
  s.bezierCurveTo(-hw * 0.62, hh, -hw, bodyTop + dome * 0.86, -hw, bodyTop);
  s.lineTo(-hw, -hh + r);
  s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
  s.closePath();
  return s;
}

/**
 * Extrude the silhouette, then planar-project UVs from the shape's own XY before
 * we lay the slice flat — so the field grid lines up with the bread exactly, and
 * both faces share it.
 */
function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
  const bevel = Math.min(0.03, bread.thickness * 0.28);
  const geo = new THREE.ExtrudeGeometry(shape, {
    depth: bread.thickness - bevel * 2,
    bevelEnabled: true,
    bevelThickness: bevel,
    bevelSize: bevel,
    bevelSegments: 3,
    curveSegments: 32,
  });

  geo.computeBoundingBox();
  const bb = geo.boundingBox!;
  const w = bb.max.x - bb.min.x;
  const h = bb.max.y - bb.min.y;
  const pos = geo.attributes.position;
  const uv = new Float32Array(pos.count * 2);
  for (let i = 0; i < pos.count; i++) {
    uv[i * 2] = (pos.getX(i) - bb.min.x) / w;
    uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / h;
  }
  geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));

  // Lay it flat: the far extrude cap becomes the top face (+Y).
  geo.rotateX(-Math.PI / 2);
  geo.center();
  geo.computeVertexNormals();
  geo.computeBoundingBox();
  return geo;
}

/** Rasterise the silhouette into the mask field (point-in-polygon per texel). */
function buildMask(mask: Field, shape: THREE.Shape, bread: Bread): void {
  const pts = shape.getPoints(96);
  const hw = bread.width / 2;
  const hh = bread.height / 2;
  // Same normalisation the geometry uses (shape bbox ≈ the slice bbox).
  let minX = Infinity;
  let maxX = -Infinity;
  let minY = Infinity;
  let maxY = -Infinity;
  for (const p of pts) {
    minX = Math.min(minX, p.x);
    maxX = Math.max(maxX, p.x);
    minY = Math.min(minY, p.y);
    maxY = Math.max(maxY, p.y);
  }
  void hw;
  void hh;
  const n = mask.n;
  for (let y = 0; y < n; y++) {
    for (let x = 0; x < n; x++) {
      const px = minX + ((x + 0.5) / n) * (maxX - minX);
      const py = minY + ((y + 0.5) / n) * (maxY - minY);
      mask.data[y * n + x] = pointInPoly(px, py, pts) ? 1 : 0;
    }
  }
  // Pull the mask in by ~1 texel so brushes can't paint into the crust seam.
  erode(mask);
}

function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
  let inside = false;
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
    const xi = pts[i].x;
    const yi = pts[i].y;
    const xj = pts[j].x;
    const yj = pts[j].y;
    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
  }
  return inside;
}

function erode(mask: Field): void {
  const n = mask.n;
  const src = mask.data.slice();
  // Anything off the grid counts as outside, so texels on the very border erode
  // away too — clamping the lookups instead would let them survive.
  const at = (x: number, y: number) => (x < 0 || y < 0 || x >= n || y >= n ? 0 : src[y * n + x]);
  for (let y = 0; y < n; y++) {
    for (let x = 0; x < n; x++) {
      if (src[y * n + x] < 0.5) continue;
      if (at(x - 1, y) < 0.5 || at(x + 1, y) < 0.5 || at(x, y - 1) < 0.5 || at(x, y + 1) < 0.5) {
        mask.data[y * n + x] = 0;
      }
    }
  }
}

const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;
void main() {
  vUv = uv;
  vPosL = position;
  vNormalW = normalize(mat3(modelMatrix) * normal);
  vec4 wp = modelMatrix * vec4(position, 1.0);
  vPosW = wp.xyz;
  gl_Position = projectionMatrix * viewMatrix * wp;
}
`;

const FRAG = /* glsl */ `
precision highp float;

uniform sampler2D uField;      // R browning, G spread, B damage, A mask
uniform float uFieldN;
uniform vec3 uCrumb;
uniform vec3 uCrust;
uniform float uCrumbScale;
uniform float uCrumbContrast;
uniform float uInclusions;
uniform vec3 uInclusionColor;
uniform vec3 uSpreadColor;
uniform float uSpreadGloss;
uniform float uSpreadOpaqueAt;   // spread thickness at which it fully hides the toast
uniform float uSpreadBump;
uniform float uOil;
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uAmbientSky;
uniform vec3 uAmbientGround;
uniform float uHeatGlow;
uniform float uWarmth;
uniform int uHeatmap;

varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;

float hash21(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * vnoise(p);
    p *= 2.03;
    a *= 0.5;
  }
  return v;
}

vec3 brownRamp(float b) {
  vec3 c0 = vec3(0.95, 0.91, 0.79);
  vec3 c1 = vec3(0.91, 0.77, 0.48);
  vec3 c2 = vec3(0.79, 0.54, 0.24);
  vec3 c3 = vec3(0.50, 0.28, 0.11);
  vec3 c4 = vec3(0.19, 0.11, 0.06);
  vec3 c5 = vec3(0.05, 0.04, 0.04);
  if (b < 0.20) return mix(c0, c1, b / 0.20);
  if (b < 0.45) return mix(c1, c2, (b - 0.20) / 0.25);
  if (b < 0.70) return mix(c2, c3, (b - 0.45) / 0.25);
  if (b < 0.88) return mix(c3, c4, (b - 0.70) / 0.18);
  return mix(c4, c5, clamp((b - 0.88) / 0.12, 0.0, 1.0));
}

vec3 heatRamp(float t) {
  vec3 a = vec3(0.10, 0.05, 0.35);
  vec3 b = vec3(0.15, 0.55, 0.75);
  vec3 c = vec3(0.95, 0.85, 0.25);
  vec3 d = vec3(0.85, 0.15, 0.10);
  if (t < 0.33) return mix(a, b, t / 0.33);
  if (t < 0.66) return mix(b, c, (t - 0.33) / 0.33);
  return mix(c, d, clamp((t - 0.66) / 0.34, 0.0, 1.0));
}

void main() {
  vec4 f = texture2D(uField, vUv);
  float browning = f.r;
  float spread = f.g;
  float damage = f.b;

  vec3 N = normalize(vNormalW);
  // 1 on the two flat faces, 0 around the crust edge.
  float faceness = smoothstep(0.45, 0.85, abs(N.y));
  // Spread only ever lands on the top.
  float topFace = smoothstep(0.45, 0.85, N.y);

  // --- crumb ---
  float crumbN = fbm(vUv * uCrumbScale);
  vec3 crumb = uCrumb * (1.0 + (crumbN - 0.5) * uCrumbContrast * 2.0);
  // holes in the crumb
  float holes = smoothstep(0.62, 0.78, fbm(vUv * uCrumbScale * 0.55 + 11.0));
  crumb *= 1.0 - holes * 0.35;

  // seeds / raisins
  float inc = smoothstep(0.80, 0.92, vnoise(vUv * uCrumbScale * 0.9 + 37.0));
  crumb = mix(crumb, uInclusionColor, inc * uInclusions);

  // --- browning ---
  vec3 toastFace = brownRamp(browning) * (0.86 + crumbN * 0.28);
  // inclusions scorch first and stay dark
  toastFace = mix(toastFace, toastFace * 0.45, inc * uInclusions * smoothstep(0.15, 0.6, browning));
  toastFace = mix(crumb, toastFace, smoothstep(0.0, 0.06, browning));

  // char blotches, not a clean gradient
  float charN = fbm(vUv * 46.0 + 5.0);
  float char = smoothstep(0.82, 1.0, browning + (charN - 0.5) * 0.22);
  toastFace = mix(toastFace, vec3(0.035, 0.03, 0.03), char * 0.92);

  // crust: always browner than the face, and it browns too
  vec3 crust = uCrust * (0.75 + fbm(vUv * 26.0 + 3.0) * 0.5);
  crust = mix(crust, crust * 0.35, smoothstep(0.4, 1.0, browning));

  vec3 albedo = mix(crust, toastFace, faceness);

  // --- damage: torn/gouged surface exposes pale raw crumb in a dark crevice ---
  float dmgN = fbm(vUv * 60.0 + 21.0);
  float dmg = clamp(damage * (0.7 + dmgN * 0.6), 0.0, 1.0);
  albedo = mix(albedo, uCrumb * 0.88, dmg * 0.8);
  albedo *= 1.0 - dmg * 0.28; // crevice shading

  // --- spread ---
  float sTex = 1.0 / uFieldN;
  float sL = texture2D(uField, vUv - vec2(sTex, 0.0)).g;
  float sR = texture2D(uField, vUv + vec2(sTex, 0.0)).g;
  float sD = texture2D(uField, vUv - vec2(0.0, sTex)).g;
  float sU = texture2D(uField, vUv + vec2(0.0, sTex)).g;

  // Wetting. This is what actually makes a spread readable: long before a film is
  // thick enough to hide the toast, it soaks in and saturates it, the way oil
  // darkens paper. Without this, yellow butter on pale crumb is invisible.
  // Must be a gamma-style darkening — a multiply would *brighten* pale crumb.
  float wet = clamp(spread / max(uSpreadOpaqueAt * 0.35, 0.0001), 0.0, 1.0) * topFace;
  albedo = mix(albedo, pow(albedo, vec3(1.65)) * 0.88, wet * 0.8);

  float cover = clamp(spread / max(uSpreadOpaqueAt, 0.0001), 0.0, 1.0);
  cover *= topFace;
  // a little grain in the film so thin spread doesn't look like flat paint
  float filmN = fbm(vUv * 70.0 + 13.0);
  vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
  // Consistency tint: an oily slick is darker and saturated; dried-out paste
  // goes pale and grey. uOil sits at 0.5 for anything that doesn't separate.
  float oilT = (uOil - 0.5) * 2.0;
  spreadCol *= 1.0 - max(oilT, 0.0) * 0.28;
  spreadCol = mix(spreadCol, vec3(dot(spreadCol, vec3(0.333))) * 1.45, max(-oilT, 0.0) * 0.45);
  albedo = mix(albedo, spreadCol, cover);

  // Thickness relief. Central differences in *UV* units, not texels — a smooth
  // smear only changes ~0.01 per texel, so the raw difference is far too small
  // to bend a normal with.
  vec2 grad = vec2(sR - sL, sU - sD) / (2.0 * sTex);
  N = normalize(N + vec3(-grad.x, 0.0, grad.y) * uSpreadBump * topFace);
  // torn bread is rough
  N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);

  // Every colour above — the ramp, the crumb, the crust, the spread — is authored
  // the way a human picks colours: as sRGB. The lighting below is linear. Without
  // this line those values are read as if they were already linear, which lifts
  // everything: near-black MITEY renders as tan (linear 0.14 encodes back out to
  // sRGB 0.4) and saturated butter washes to pale cream. Mixing happens in sRGB
  // on purpose — that's the space the palette was chosen in.
  albedo = pow(albedo, vec3(2.2));

  // --- lighting ---
  vec3 L = normalize(uLightDir);
  vec3 V = normalize(cameraPosition - vPosW);
  vec3 H = normalize(L + V);
  float ndl = max(dot(N, L), 0.0);
  float wrap = (ndl + 0.35) / 1.35; // soft claymation falloff
  vec3 ambient = mix(uAmbientGround, uAmbientSky, N.y * 0.5 + 0.5);

  // Gloss: spread is shiny, bread is not; melting butter is shinier still.
  // A tight lobe puts one hotspot somewhere off the slice and reads as nothing,
  // so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
  float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
  gloss *= 1.0 + uWarmth * 0.6;
  // Oil is what shines. Dry paste barely does.
  gloss *= 0.55 + uOil * 0.9;
  // The lobe has to be TIGHT. The slice is flat and both the light and the camera
  // are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
  // blankets it in white and lifts near-black MITEY to tan. Tight, and the only
  // thing that catches is a ridge tilted into the light, which is the actual look
  // of a spread: dark film, bright knife marks.
  float shin = mix(40.0, 170.0, uSpreadGloss);
  float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.9;
  float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
  spec += fres * gloss * 0.22;
  spec *= 1.0 - char * 0.7;
  spec *= 1.0 - dmg * 0.6;

  vec3 color = albedo * (ambient + uLightColor * wrap * 0.68) + uLightColor * spec;

  // element glow while it's down in the toaster
  if (uHeatGlow > 0.001) {
    float coil = 0.5 + 0.5 * sin(vPosL.x * 34.0);
    color += vec3(1.0, 0.28, 0.06) * uHeatGlow * (0.16 + coil * 0.16) * faceness;
  }

  // a hot slice keeps a faint warmth in the crumb
  color += vec3(0.05, 0.015, 0.0) * uWarmth * faceness;

  // Judge-screen data views: flat, unlit, deliberately not appetising.
  if (uHeatmap == 1) {
    color = mix(vec3(0.10), heatRamp(clamp(browning, 0.0, 1.0)), faceness * 0.88 + 0.12);
  } else if (uHeatmap == 2) {
    color = mix(vec3(0.10), heatRamp(clamp(spread * 2.0, 0.0, 1.0)), topFace * 0.88 + 0.12);
  }

  gl_FragColor = vec4(color, 1.0);

  // Match the tone mapping + output colour space the standard materials get,
  // otherwise the toast reads as a different render from everything around it.
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
`;

function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMaterial {
  return new THREE.ShaderMaterial({
    vertexShader: VERT,
    fragmentShader: FRAG,
    uniforms: {
      uField: { value: tex },
      uFieldN: { value: FIELD_N },
      uCrumb: { value: new THREE.Color(...bread.crumb) },
      uCrust: { value: new THREE.Color(...bread.crust) },
      uCrumbScale: { value: bread.crumbScale },
      uCrumbContrast: { value: bread.crumbContrast },
      uInclusions: { value: bread.inclusions },
      uInclusionColor: { value: new THREE.Color(...bread.inclusionColor) },
      uSpreadColor: { value: new THREE.Color(1, 0.85, 0.3) },
      uSpreadGloss: { value: 0.5 },
      uSpreadOpaqueAt: { value: 1e9 },
      uSpreadBump: { value: 0.1 },
      uOil: { value: 0.5 },
      uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
      uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
      uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },
      uAmbientGround: { value: new THREE.Color(0.14, 0.11, 0.09) },
      uHeatGlow: { value: 0 },
      uWarmth: { value: 0 },
      uHeatmap: { value: 0 },
    },
  });
}
