// fabric3d.jsx — Three.js fabric viewer.
//
// The fabric is a high-poly plane lying face-up like a water surface.
// A custom vertex-shader patch (multi-octave simplex fbm) displaces vertices
// vertically and re-derives the normal from analytic gradients of the noise
// field, so lighting actually responds to the ripples. The base map is a
// canvas where the satin tone + embroidery reveal layer are composited;
// the same logic from the 2D version is preserved — pointer hover paints
// embroidery via raycast UVs; drag rotates the scene.

(function () {
const THREE = window.THREE;
const { useEffect, useRef, useCallback } = React;

// ── Simplex 2D + fbm — Ashima Arts (MIT) ────────────────────────────────
const NOISE_GLSL = `
vec3 _m289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
vec2 _m289(vec2 x){return x-floor(x*(1.0/289.0))*289.0;}
vec3 _perm(vec3 x){return _m289(((x*34.0)+1.0)*x);}
float snoise(vec2 v){
  const vec4 C=vec4(0.211324865405187,0.366025403784439,-0.577350269189626,0.024390243902439);
  vec2 i=floor(v+dot(v,C.yy));
  vec2 x0=v-i+dot(i,C.xx);
  vec2 i1=(x0.x>x0.y)?vec2(1.0,0.0):vec2(0.0,1.0);
  vec4 x12=x0.xyxy+C.xxzz; x12.xy-=i1;
  i=_m289(i);
  vec3 p=_perm(_perm(i.y+vec3(0.0,i1.y,1.0))+i.x+vec3(0.0,i1.x,1.0));
  vec3 m=max(0.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.0);
  m=m*m; m=m*m;
  vec3 x=2.0*fract(p*C.www)-1.0;
  vec3 h=abs(x)-0.5;
  vec3 ox=floor(x+0.5);
  vec3 a0=x-ox;
  m*=1.79284291400159-0.85373472095314*(a0*a0+h*h);
  vec3 g; g.x=a0.x*x0.x+h.x*x0.y;
  g.yz=a0.yz*x12.xz+h.yz*x12.yw;
  return 130.0*dot(m,g);
}
float fbm(vec2 p){
  float v=0.0; float a=0.6; float f=1.0;
  // Fewer octaves + slower falloff → big rolling swells, not noisy detail
  for(int i=0;i<3;i++){ v+=a*snoise(p*f); f*=2.0; a*=0.55; }
  return v;
}
// Big sweeping swell with a gentle slow drift. No high-frequency ripple,
// no edge clamp — we want the fabric corners to lift naturally.
float surfaceH(vec2 uv, float t){
  vec2 p = (uv - 0.5) * vec2(1.6, 2.0);   // anisotropic — longer along length
  float swell = fbm(p * 0.8 + vec2(t*0.04, t*0.025));
  // A second, very slow, very large wave to drive corner curl
  float curl  = snoise(p * 0.9 + vec2(t*0.02, -t*0.015)) * 0.6;
  return swell + curl;
}
`;

// ── Tone defaults: satin base + thread colours ──────────────────────────
function paintSatin(ctx, W, H, palette) {
  const c = palette.satin || ['#d8bf94','#e9d2a3','#ddc095','#c9a87b','#b8956a'];
  // Diagonal base gradient
  const g = ctx.createLinearGradient(0, 0, W, H);
  g.addColorStop(0,   c[0]);
  g.addColorStop(0.25,c[1]);
  g.addColorStop(0.5, c[2]);
  g.addColorStop(0.75,c[3]);
  g.addColorStop(1,   c[4]);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);

  // Slanted satin sheen bands
  ctx.save();
  ctx.globalCompositeOperation = 'screen';
  const sh = ctx.createLinearGradient(-W*0.3, 0, W*1.3, H*0.6);
  sh.addColorStop(0.30, 'rgba(0,0,0,0)');
  sh.addColorStop(0.45, 'rgba(255,236,200,0.16)');
  sh.addColorStop(0.55, 'rgba(0,0,0,0)');
  ctx.fillStyle = sh;
  ctx.fillRect(0, 0, W, H);
  const sh2 = ctx.createLinearGradient(0, H*0.4, W, H*1.2);
  sh2.addColorStop(0.40, 'rgba(0,0,0,0)');
  sh2.addColorStop(0.55, 'rgba(255,232,194,0.08)');
  sh2.addColorStop(0.70, 'rgba(0,0,0,0)');
  ctx.fillStyle = sh2;
  ctx.fillRect(0, 0, W, H);
  ctx.restore();

  // Inner edge darkening for soft drape feel
  const r = ctx.createRadialGradient(W/2, H/2, Math.min(W,H)*0.25,
                                     W/2, H/2, Math.max(W,H)*0.62);
  r.addColorStop(0, 'rgba(0,0,0,0)');
  r.addColorStop(1, 'rgba(40,20,8,0.22)');
  ctx.fillStyle = r;
  ctx.fillRect(0, 0, W, H);

  // Fine weave grain — vertical warp threads + horizontal weft modulation
  ctx.save();
  ctx.globalAlpha = 0.10;
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 1;
  for (let x = 0; x < W; x += 2) {
    ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); ctx.stroke();
  }
  ctx.globalAlpha = 0.06;
  ctx.strokeStyle = '#000';
  for (let y = 0; y < H; y += 3) {
    ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); ctx.stroke();
  }
  ctx.restore();

  // Pixel-level noise grain for fibre tooth
  const grain = ctx.getImageData(0, 0, W, H);
  const d = grain.data;
  for (let i = 0; i < d.length; i += 4) {
    const n = (Math.random() - 0.5) * 10;
    d[i]   = Math.max(0, Math.min(255, d[i]   + n));
    d[i+1] = Math.max(0, Math.min(255, d[i+1] + n));
    d[i+2] = Math.max(0, Math.min(255, d[i+2] + n));
  }
  ctx.putImageData(grain, 0, 0);
}

// Paint a shape mask (white where fabric should show, transparent outside)
function paintShapeMask(ctx, W, H, pathD) {
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#fff';
  // soft inner shadow by drawing the path with a faint dark stroke first
  const p = new Path2D(pathD);
  ctx.fill(p);
}

// ── Component ───────────────────────────────────────────────────────────
function Fabric3D({
  palette, density, brushSize, motif, shape, shapePath,
  onActiveChange, clearKey,
  displacement = 0.12, ripple = 1.0,
  opacity = 1.0, transmission = 0.0,
  bumpScale = 0.05,
  fadeRate = 0.12,
  gloss = 0.5,
  autoSpin = true,
  paused = false,
  theme = 'dark',
}) {
  const mountRef = useRef(null);
  const stateRef = useRef({});
  const propsRef = useRef({});
  propsRef.current = { brushSize, onActiveChange, autoSpin, fadeRate };

  // ── one-time three.js scene setup ─────────────────────────────────────
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || !THREE) return;

    const getSize = () => ({
      W: Math.max(2, mount.clientWidth),
      H: Math.max(2, mount.clientHeight),
    });
    let { W, H } = getSize();

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true,
                                                preserveDrawingBuffer: false });
    renderer.setPixelRatio(Math.min(1.35, window.devicePixelRatio || 1));
    renderer.setSize(W, H);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.05;
    mount.appendChild(renderer.domElement);
    renderer.domElement.style.display = 'block';
    renderer.domElement.style.cursor = 'none';
    renderer.domElement.style.touchAction = 'none';

    const scene = new THREE.Scene();
    scene.background = null;

    const camera = new THREE.PerspectiveCamera(34, W / H, 0.1, 100);
    camera.position.set(0, 5.2, 5.8);
    camera.lookAt(0, 0, 0);

    // Fabric: 4 × 5 in scene units, densely tessellated so the displacement
    // doesn't reveal triangle edges along the silhouette
    const FW = 4.0, FH = 5.0;
    const SEG = 128;
    const geo = new THREE.PlaneGeometry(FW, FH, SEG, Math.round(SEG * FH / FW));

    // Texture canvas — base satin + embroidery composite
    const TEX_W = 1024, TEX_H = 1280;
    const texCanvas = document.createElement('canvas');
    texCanvas.width = TEX_W;
    texCanvas.height = TEX_H;
    const texCtx = texCanvas.getContext('2d');
    const texture = new THREE.CanvasTexture(texCanvas);
    texture.colorSpace = THREE.SRGBColorSpace;
    texture.anisotropy = renderer.capabilities.getMaxAnisotropy?.() || 8;
    texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping;

    // Shape alpha mask canvas
    const maskCanvas = document.createElement('canvas');
    maskCanvas.width = TEX_W;
    maskCanvas.height = TEX_H;
    const maskTex = new THREE.CanvasTexture(maskCanvas);
    maskTex.anisotropy = 4;

    const material = new THREE.MeshPhysicalMaterial({
      map: texture,
      alphaMap: maskTex,
      transparent: true,
      opacity: 1.0,
      roughness: 0.52,
      metalness: 0.0,
      sheen: 1.0,
      sheenRoughness: 0.38,
      sheenColor: new THREE.Color('#f4dca8'),
      clearcoat: 0.18,
      clearcoatRoughness: 0.42,
      transmission: 0.0,
      ior: 1.35,
      thickness: 0.4,
      side: THREE.DoubleSide,
      depthWrite: true,
      bumpScale: 0.05,
    });

    // Bump map for embroidery thread relief.
    const bumpCanvas = document.createElement('canvas');
    bumpCanvas.width = TEX_W; bumpCanvas.height = TEX_H;
    {
      const bx = bumpCanvas.getContext('2d');
      bx.fillStyle = '#181818';
      bx.fillRect(0, 0, TEX_W, TEX_H);
    }
    const bumpTex = new THREE.CanvasTexture(bumpCanvas);
    bumpTex.anisotropy = renderer.capabilities.getMaxAnisotropy?.() || 8;
    bumpTex.wrapS = bumpTex.wrapT = THREE.ClampToEdgeWrapping;
    material.bumpMap = bumpTex;

    // Inject noise displacement + analytic-gradient normal patch.
    const uniforms = {
      uTime: { value: 0 },
      uAmp:  { value: displacement },
      uRipple: { value: ripple },
    };
    material.onBeforeCompile = (shader) => {
      shader.uniforms.uTime   = uniforms.uTime;
      shader.uniforms.uAmp    = uniforms.uAmp;
      shader.uniforms.uRipple = uniforms.uRipple;
      shader.vertexShader = shader.vertexShader
        .replace('#include <common>',
          `#include <common>
           uniform float uTime; uniform float uAmp; uniform float uRipple;
           ${NOISE_GLSL}
           // Corner curl: a smooth bias that pushes the outer 25% upward.
           // We add it after the noise so big swells modulate it, but it
           // guarantees the edges lift instead of staying flat.
           float cornerLift(vec2 uv){
             vec2 c = abs(uv - 0.5) * 2.0;
             float r = max(c.x, c.y);
             return smoothstep(0.55, 1.0, r) * 0.45;
           }`)
        .replace('#include <beginnormal_vertex>',
          `vec3 objectNormal;
           {
             float t = uTime * uRipple;
             float e = 0.004;
             float h0 = (surfaceH(uv,             t) + cornerLift(uv));
             float hx = (surfaceH(uv + vec2(e,0), t) + cornerLift(uv + vec2(e,0)));
             float hy = (surfaceH(uv + vec2(0,e), t) + cornerLift(uv + vec2(0,e)));
             vec3 tx = vec3(e, 0.0, (hx - h0) * uAmp);
             vec3 ty = vec3(0.0, e, (hy - h0) * uAmp);
             objectNormal = normalize(cross(tx, ty));
           }
           #ifdef USE_TANGENT
             vec3 objectTangent = vec3(tangent.xyz);
           #endif`)
        .replace('#include <begin_vertex>',
          `vec3 transformed = vec3(position);
           {
             float t = uTime * uRipple;
             transformed.z += (surfaceH(uv, t) + cornerLift(uv)) * uAmp;
           }`);
    };

    const mesh = new THREE.Mesh(geo, material);
    mesh.rotation.x = -Math.PI / 2;     // lie flat (face up)
    mesh.rotation.z = 0;
    scene.add(mesh);

    // Soft contact shadow under the fabric: very subtle so it doesn't read
    // as a black base. Lighter alpha in light theme so the cream background
    // isn't muddied by a heavy shadow plate.
    const shadowCanvas = document.createElement('canvas');
    shadowCanvas.width = 256; shadowCanvas.height = 320;
    {
      const sctx = shadowCanvas.getContext('2d');
      const g = sctx.createRadialGradient(128, 160, 20, 128, 160, 130);
      const isLight = theme === 'light';
      const a0 = isLight ? 0.08 : 0.18;
      const a1 = isLight ? 0.03 : 0.06;
      g.addColorStop(0,   `rgba(60,40,24,${a0})`);
      g.addColorStop(0.6, `rgba(60,40,24,${a1})`);
      g.addColorStop(1,   'rgba(60,40,24,0)');
      sctx.fillStyle = g;
      sctx.fillRect(0, 0, 256, 320);
    }
    const shadowTex = new THREE.CanvasTexture(shadowCanvas);
    const shadowMat = new THREE.MeshBasicMaterial({
      map: shadowTex, transparent: true, depthWrite: false,
    });
    const shadowMesh = new THREE.Mesh(
      new THREE.PlaneGeometry(FW * 1.25, FH * 1.2),
      shadowMat
    );
    shadowMesh.rotation.x = -Math.PI / 2;
    shadowMesh.position.y = -0.25;
    scene.add(shadowMesh);

    // Lighting — warm key + cool-ish fill + rim. Light theme brightens
    // ambient and lifts the fill so cast shadows on cloth don't read muddy
    // against a cream/white background.
    const isLightTheme = theme === 'light';
    const ambient = new THREE.AmbientLight(0xffeacb,
      isLightTheme ? 0.62 : 0.28);
    scene.add(ambient);

    const key = new THREE.DirectionalLight(0xfff0d0,
      isLightTheme ? 1.55 : 1.85);
    key.position.set(3.5, 6.0, 3.0);
    scene.add(key);

    const fill = new THREE.DirectionalLight(
      isLightTheme ? 0xffe6c8 : 0xc28860,
      isLightTheme ? 0.85 : 0.55);
    fill.position.set(-4.0, 3.0, -2.0);
    scene.add(fill);

    const rim = new THREE.PointLight(0xffd596,
      isLightTheme ? 0.95 : 1.4, 22, 1.7);
    rim.position.set(-1.5, 3.0, -4.5);
    scene.add(rim);

    // Procedural environment for sheen reflection — warm vertical gradient
    const envCanvas = document.createElement('canvas');
    envCanvas.width = 512; envCanvas.height = 256;
    {
      const ec = envCanvas.getContext('2d');
      const g = ec.createLinearGradient(0, 0, 0, 256);
      g.addColorStop(0,    '#3a2418');
      g.addColorStop(0.45, '#80543a');
      g.addColorStop(0.55, '#a06d4a');
      g.addColorStop(0.70, '#5e3a26');
      g.addColorStop(1,    '#100a06');
      ec.fillStyle = g;
      ec.fillRect(0, 0, 512, 256);
      // Warm specular hotspot
      const sp = ec.createRadialGradient(380, 80, 5, 380, 80, 70);
      sp.addColorStop(0, 'rgba(255,238,210,0.85)');
      sp.addColorStop(1, 'rgba(255,238,210,0)');
      ec.fillStyle = sp;
      ec.fillRect(0, 0, 512, 256);
    }
    const envTex = new THREE.CanvasTexture(envCanvas);
    envTex.mapping = THREE.EquirectangularReflectionMapping;
    envTex.colorSpace = THREE.SRGBColorSpace;
    const pmrem = new THREE.PMREMGenerator(renderer);
    pmrem.compileEquirectangularShader();
    const envRT = pmrem.fromEquirectangular(envTex);
    scene.environment = envRT.texture;

    // ── State container ──────────────────────────────────────────────────
    stateRef.current = {
      scene, camera, renderer, mesh, material, uniforms,
      texCanvas, texCtx, texture,
      maskCanvas, maskTex,
      bumpCanvas, bumpTex,
      satinCanvas: null,
      embroideryCanvas: null,
      embroideryBumpCanvas: null,
      revealCanvas: null,
      composite: null,
      // orbit state — spherical coords (yaw, pitch) around origin
      yaw: 0.18,
      pitch: 1.05,            // radians from +y axis (0 = top-down). 0.0..1.45
      targetYaw: 0.18,
      targetPitch: 1.05,
      dist: 8.6,
      spin: 0,
      dragging: false,
      lastIdle: performance.now(),
    };

    // ── Resize ──
    const ro = new ResizeObserver(() => {
      const { W, H } = getSize();
      renderer.setSize(W, H);
      camera.aspect = W / H;
      camera.updateProjectionMatrix();
    });
    ro.observe(mount);

    // ── Render loop ──
    const clock = new THREE.Clock();
    let raf = 0;
    let loopLive = true;
    let loopPaused = false;
    const stopLoop = () => {
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
    };
    const syncLoopPause = () => {
      const blocked = loopPaused || document.hidden
        || document.documentElement.classList.contains('is-scrolling');
      if (blocked) stopLoop();
      else startLoop();
    };
    const setLoopPaused = (p) => {
      loopPaused = p;
      syncLoopPause();
    };
    stateRef.current.setLoopPaused = setLoopPaused;
    stateRef.current._viewVisible = true;

    const tick = () => {
      raf = 0;
      if (!loopLive || loopPaused || document.hidden) return;
      const t = clock.getElapsedTime();
      uniforms.uTime.value = t;

      const s = stateRef.current;

      // —— Per-frame fade so embroidery only lives near the cursor ——
      const fr = propsRef.current.fadeRate || 0;
      const scrolling = document.documentElement.classList.contains('is-scrolling');
      if (fr > 0 && !scrolling && s.revealCanvas && s.bumpCanvas) {
        const TEX_W = s.revealCanvas.width;
        const TEX_H = s.revealCanvas.height;
        // Reveal: erase alpha (destination-out) so pigment ghosts out cleanly.
        const rctx = s.revealCanvas.getContext('2d');
        rctx.save();
        rctx.globalCompositeOperation = 'destination-out';
        rctx.fillStyle = `rgba(0,0,0,${fr})`;
        rctx.fillRect(0, 0, TEX_W, TEX_H);
        rctx.restore();

        // Bump: pull luminance back to the base dark tone with a faint overlay.
        const bctx = s.bumpCanvas.getContext('2d');
        bctx.save();
        bctx.globalCompositeOperation = 'source-over';
        bctx.fillStyle = `rgba(24,24,24,${fr * 0.9})`;
        bctx.fillRect(0, 0, TEX_W, TEX_H);
        bctx.restore();

        s.bumpTex.needsUpdate = true;
        s.composite && s.composite();
      }

      // Auto-spin when idle and enabled
      const idle = performance.now() - s.lastIdle > 2400;
      if (propsRef.current.autoSpin && idle && !s.dragging) {
        s.targetYaw += 0.00385;
      }
      // Smooth toward targets
      s.yaw   += (s.targetYaw   - s.yaw)   * 0.10;
      s.pitch += (s.targetPitch - s.pitch) * 0.10;

      // Camera position from spherical (pitch from +Y axis)
      const r = s.dist;
      const cy = Math.cos(s.pitch) * r;
      const cr = Math.sin(s.pitch) * r;
      const cx = Math.sin(s.yaw) * cr;
      const cz = Math.cos(s.yaw) * cr;
      camera.position.set(cx, cy + 0.3, cz);
      camera.lookAt(0, 0, 0);

      renderer.render(scene, camera);
      raf = requestAnimationFrame(tick);
    };
    const startLoop = () => {
      if (!loopLive || loopPaused || raf || document.hidden) return;
      raf = requestAnimationFrame(tick);
    };
    const onVisibility = () => {
      syncLoopPause();
    };
    document.addEventListener('visibilitychange', onVisibility);
    startLoop();

    // ── Cleanup ──
    return () => {
      loopLive = false;
      document.removeEventListener('visibilitychange', onVisibility);
      cancelAnimationFrame(raf);
      ro.disconnect();
      pmrem.dispose();
      envRT.dispose();
      envTex.dispose();
      geo.dispose();
      material.dispose();
      texture.dispose();
      maskTex.dispose();
      shadowMat.dispose();
      shadowTex.dispose();
      shadowMesh.geometry.dispose();
      renderer.dispose();
      try { mount.removeChild(renderer.domElement); } catch (_) {}
    };
  }, []);

  // ── Live uniform updates ──────────────────────────────────────────────
  useEffect(() => {
    const s = stateRef.current;
    if (s.uniforms) {
      s.uniforms.uAmp.value = displacement;
      s.uniforms.uRipple.value = ripple;
    }
    if (s.material) {
      s.material.opacity = opacity;
      s.material.transmission = transmission;
      s.material.bumpScale = bumpScale;
      s.material.depthWrite = opacity > 0.95 && transmission < 0.05;
      // Gloss 0…1 maps to: low sheen + matte roughness → strong sheen + tight highlight.
      const g = Math.max(0, Math.min(1, gloss));
      s.material.sheen = 0.25 + g * 0.95;            // 0.25 … 1.20
      s.material.sheenRoughness = 0.85 - g * 0.55;   // 0.85 (matte) … 0.30 (sharp)
      s.material.roughness = 0.85 - g * 0.40;        // 0.85 (matte) … 0.45 (smooth)
      s.material.clearcoat = g * 0.30;               // 0 … 0.30
      s.material.clearcoatRoughness = 0.7 - g * 0.4;
    }
  }, [displacement, ripple, opacity, transmission, bumpScale, gloss]);

  useEffect(() => {
    stateRef.current._externalPaused = paused;
    const ctl = stateRef.current.setLoopPaused;
    if (!ctl) return;
    const vis = stateRef.current._viewVisible !== false;
    ctl(paused || !vis);
  }, [paused]);

  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || typeof IntersectionObserver === 'undefined') return;
    const syncPause = () => {
      const ctl = stateRef.current.setLoopPaused;
      if (!ctl) return;
      const vis = stateRef.current._viewVisible !== false;
      ctl(stateRef.current._externalPaused || !vis);
    };
    const io = new IntersectionObserver(([e]) => {
      stateRef.current._viewVisible = e.isIntersecting && e.intersectionRatio > 0.06;
      syncPause();
    }, { threshold: [0, 0.06, 0.12] });
    io.observe(mount);
    const onScrollIdle = () => syncPause();
    window.addEventListener('custex-scroll-idle', onScrollIdle);
    return () => {
      io.disconnect();
      window.removeEventListener('custex-scroll-idle', onScrollIdle);
    };
  }, []);

  // ── Rebuild textures when palette / motif / density / shape change ────
  useEffect(() => {
    const s = stateRef.current;
    if (!s.texCanvas) return;
    let cancelled = false;

    (async () => {
      const TEX_W = s.texCanvas.width;
      const TEX_H = s.texCanvas.height;

      // 1) Satin base canvas
      const satinCv = document.createElement('canvas');
      satinCv.width = TEX_W; satinCv.height = TEX_H;
      paintSatin(satinCv.getContext('2d'), TEX_W, TEX_H, palette);
      if (cancelled) return;
      s.satinCanvas = satinCv;

      // 2) Embroidery raster (full pattern, not yet revealed)
      const svg = window.generateEmbroiderySVG(TEX_W, TEX_H, palette, { density, motif });
      const ec = await window.rasteriseSVG(svg, TEX_W, TEX_H, 1);
      if (cancelled) return;
      s.embroideryCanvas = ec;

      // 2b) Bump version of the embroidery: every opaque stitch pixel
      //     becomes near-white (alpha preserved). Stamped into the bump
      //     canvas as the user reveals, so stitches catch light as relief.
      const ebc = document.createElement('canvas');
      ebc.width = TEX_W; ebc.height = TEX_H;
      const ebctx = ebc.getContext('2d');
      ebctx.drawImage(ec, 0, 0);
      const img = ebctx.getImageData(0, 0, TEX_W, TEX_H);
      const d = img.data;
      for (let i = 0; i < d.length; i += 4) {
        const a = d[i + 3];
        if (a > 0) {
          const lum = 0.30 * d[i] + 0.59 * d[i+1] + 0.11 * d[i+2];
          const h = 255 - (lum * 0.30);
          d[i] = d[i+1] = d[i+2] = h;
        }
      }
      ebctx.putImageData(img, 0, 0);
      s.embroideryBumpCanvas = ebc;

      // 3) Reveal canvas — accumulates user strokes (colour pigment)
      const revealCv = document.createElement('canvas');
      revealCv.width = TEX_W; revealCv.height = TEX_H;
      s.revealCanvas = revealCv;

      // 3b) Reset bump canvas to its dark base whenever motif rebuilds.
      {
        const bx = s.bumpCanvas.getContext('2d');
        bx.clearRect(0, 0, TEX_W, TEX_H);
        bx.fillStyle = '#181818';
        bx.fillRect(0, 0, TEX_W, TEX_H);
        s.bumpTex.needsUpdate = true;
      }

      // 4) Composite → main texture
      const composite = () => {
        const ctx = s.texCtx;
        ctx.clearRect(0, 0, TEX_W, TEX_H);
        ctx.drawImage(s.satinCanvas, 0, 0);
        if (s.revealCanvas) ctx.drawImage(s.revealCanvas, 0, 0);
        s.texture.needsUpdate = true;
      };
      s.composite = composite;
      composite();
    })();

    return () => { cancelled = true; };
  }, [palette, density, motif, clearKey]);

  // ── Shape mask rebuild ────────────────────────────────────────────────
  // shapePath is generated against the mask-canvas pixel dimensions
  // Shape path uses CustexHome.constants (see home/constants/shapes.js).
  useEffect(() => {
    const s = stateRef.current;
    if (!s.maskCanvas || !shapePath) return;
    const ctx = s.maskCanvas.getContext('2d');
    const TEX_W = s.maskCanvas.width;
    const TEX_H = s.maskCanvas.height;
    ctx.clearRect(0, 0, TEX_W, TEX_H);
    ctx.fillStyle = '#fff';
    ctx.fill(new Path2D(shapePath));
    s.maskTex.needsUpdate = true;
  }, [shapePath]);

  // ── Pointer interaction ───────────────────────────────────────────────
  const stamp = useCallback((uvx, uvy) => {
    const s = stateRef.current;
    if (!s.embroideryCanvas || !s.revealCanvas) return;
    const TEX_W = s.revealCanvas.width;
    const TEX_H = s.revealCanvas.height;
    const x = uvx * TEX_W;
    const y = (1 - uvy) * TEX_H;
    const bs = propsRef.current.brushSize || 90;
    const r = bs * 1.7;
    if (r < 4) return;
    const sz = Math.ceil(r * 2);

    const makeFeather = (ctx) => {
      const g = ctx.createRadialGradient(r, r, 0, r, r, r);
      g.addColorStop(0,    'rgba(0,0,0,1)');
      g.addColorStop(0.50, 'rgba(0,0,0,0.95)');
      g.addColorStop(0.75, 'rgba(0,0,0,0.55)');
      g.addColorStop(0.92, 'rgba(0,0,0,0.18)');
      g.addColorStop(1,    'rgba(0,0,0,0)');
      return g;
    };

    // —— Colour pigment reveal ——
    const tmp = document.createElement('canvas');
    tmp.width = tmp.height = sz;
    const tctx = tmp.getContext('2d');
    tctx.drawImage(s.embroideryCanvas, x - r, y - r, r * 2, r * 2, 0, 0, sz, sz);
    tctx.globalCompositeOperation = 'destination-in';
    tctx.fillStyle = makeFeather(tctx);
    tctx.fillRect(0, 0, sz, sz);
    s.revealCanvas.getContext('2d').drawImage(tmp, x - r, y - r);

    // —— Bump relief reveal (white stitches over dark base) ——
    if (s.embroideryBumpCanvas) {
      const btmp = document.createElement('canvas');
      btmp.width = btmp.height = sz;
      const btctx = btmp.getContext('2d');
      btctx.drawImage(s.embroideryBumpCanvas, x - r, y - r, r * 2, r * 2, 0, 0, sz, sz);
      btctx.globalCompositeOperation = 'destination-in';
      btctx.fillStyle = makeFeather(btctx);
      btctx.fillRect(0, 0, sz, sz);
      const bctx = s.bumpCanvas.getContext('2d');
      bctx.save();
      bctx.globalCompositeOperation = 'lighten';
      bctx.drawImage(btmp, x - r, y - r);
      bctx.restore();
      s.bumpTex.needsUpdate = true;
    }

    s.composite && s.composite();
  }, []);

  useEffect(() => {
    const s = stateRef.current;
    const mount = mountRef.current;
    if (!s.renderer || !mount) return;

    const raycaster = new THREE.Raycaster();
    const ptr = new THREE.Vector2();
    let dragging = false;
    let lastX = 0, lastY = 0;
    let lastUV = null;

    const screenToNDC = (e) => {
      const rect = mount.getBoundingClientRect();
      ptr.x =  ((e.clientX - rect.left) / rect.width)  * 2 - 1;
      ptr.y = -((e.clientY - rect.top)  / rect.height) * 2 + 1;
    };

    const onDown = (e) => {
      dragging = true;
      s.dragging = true;
      lastX = e.clientX; lastY = e.clientY;
      lastUV = null;
      s.lastIdle = performance.now() + 100000; // suspend auto-spin
      try { mount.setPointerCapture(e.pointerId); } catch (_) {}
    };
    const onUp = (e) => {
      dragging = false;
      s.dragging = false;
      s.lastIdle = performance.now();
      lastUV = null;
      try { mount.releasePointerCapture(e.pointerId); } catch (_) {}
    };
    const onMove = (e) => {
      if (dragging) {
        const dx = e.clientX - lastX;
        const dy = e.clientY - lastY;
        lastX = e.clientX; lastY = e.clientY;
        s.targetYaw   += dx * 0.0070;
        s.targetPitch -= dy * 0.0050;
        // Keep pitch within sensible above-fabric range
        s.targetPitch = Math.max(0.10, Math.min(1.40, s.targetPitch));
        return;
      }
      // Hover paint
      screenToNDC(e);
      raycaster.setFromCamera(ptr, s.camera);
      const hits = raycaster.intersectObject(s.mesh, false);
      if (!hits.length) {
        propsRef.current.onActiveChange && propsRef.current.onActiveChange(false);
        lastUV = null;
        return;
      }
      propsRef.current.onActiveChange && propsRef.current.onActiveChange(true);
      // NOTE: do NOT touch s.lastIdle on hover — hover only paints; the fabric
      // should keep auto-spinning while the user moves the cursor over it.
      // Only drag (onDown/onUp) suspends the spin.
      const uv = hits[0].uv;
      if (lastUV) {
        const dx = (uv.x - lastUV.x);
        const dy = (uv.y - lastUV.y);
        const dist = Math.sqrt(dx * dx + dy * dy);
        const stepU = (propsRef.current.brushSize * 0.6) / 1024;
        const n = Math.min(24, Math.max(1, Math.ceil(dist / stepU)));
        for (let i = 1; i <= n; i++) {
          const tx = lastUV.x + dx * (i / n);
          const ty = lastUV.y + dy * (i / n);
          stamp(tx, ty);
        }
      } else {
        stamp(uv.x, uv.y);
      }
      lastUV = { x: uv.x, y: uv.y };
    };
    const onLeave = () => {
      lastUV = null;
      propsRef.current.onActiveChange && propsRef.current.onActiveChange(false);
    };
    const onWheel = (e) => {
      // Don't preventDefault — let the page scroll the welcome stage.
      // Welcome flow drives sizing via CSS transform, not camera distance.
    };

    mount.addEventListener('pointerdown', onDown);
    mount.addEventListener('pointerup', onUp);
    mount.addEventListener('pointermove', onMove);
    mount.addEventListener('pointerleave', onLeave);
    mount.addEventListener('pointercancel', onUp);
    mount.addEventListener('wheel', onWheel, { passive: true });

    return () => {
      mount.removeEventListener('pointerdown', onDown);
      mount.removeEventListener('pointerup', onUp);
      mount.removeEventListener('pointermove', onMove);
      mount.removeEventListener('pointerleave', onLeave);
      mount.removeEventListener('pointercancel', onUp);
      mount.removeEventListener('wheel', onWheel);
    };
  }, [stamp]);

  return <div ref={mountRef} className="fabric3d" />;
}

window.Fabric3D = Fabric3D;
})();
