// ===========================================================================
// LEON RENDER — photoreal stills and 360 panoramas
// ===========================================================================
// Modelled on 2020 Visual Impression, whose two outputs are a photoreal still
// and a 360 panorama you can look round. 2020 renders with Cycles, a path
// tracer, on the desktop.
//
// BE CLEAR ABOUT WHAT THIS IS. A browser tab cannot run a path tracer over a
// kitchen in any time anyone would wait, so this is not Cycles. It is
// physically-based real-time rendering, which is what every architectural
// visualiser uses for anything interactive, plus PROGRESSIVE ACCUMULATION:
// the same frame is rendered many times with the camera jittered inside a pixel
// and the sun jittered across its own disc, and the results are averaged. That
// converges to genuinely soft shadows and clean edges — the longer it runs the
// better it gets, which is how a real renderer behaves and why the sample
// counter is on screen.
//
// What it does NOT do, and says so: no path-traced global illumination, no
// caustics, no refraction through glass. Those need a tracer.
//
// The 360 panorama IS the real thing: a cube camera at eye height renders the
// six faces, and they are resolved to an equirectangular image — the same
// format every panorama viewer and every phone expects.

const RND_QUALITY = [
  { key: 'draft', label: 'Draft', samples: 16, shadow: 1024, note: 'A look, in a second or two.' },
  { key: 'good', label: 'Good', samples: 64, shadow: 2048, note: 'For a client email.' },
  { key: 'high', label: 'High', samples: 192, shadow: 2048, note: 'For a presentation.' },
  { key: 'final', label: 'Final', samples: 512, shadow: 4096, note: 'Leave it running.' },
];
const RND_SIZES = [
  { key: '720', label: '1280 × 720', w: 1280, h: 720 },
  { key: '1080', label: '1920 × 1080', w: 1920, h: 1080 },
  { key: '1440', label: '2560 × 1440', w: 2560, h: 1440 },
  { key: 'sq', label: '1200 × 1200 (square)', w: 1200, h: 1200 },
];
const RND_TIMES = [
  { key: 'morning', label: 'Morning', elev: 22, azim: 95, warm: 0xffd9b3, sky: 0x9fc4e8, intensity: 2.6 },
  { key: 'midday', label: 'Midday', elev: 62, azim: 150, warm: 0xfff4e6, sky: 0xaecdf0, intensity: 3.6 },
  { key: 'afternoon', label: 'Afternoon', elev: 30, azim: 235, warm: 0xffc98f, sky: 0x9dbfe4, intensity: 2.8 },
  { key: 'evening', label: 'Evening', elev: 8, azim: 265, warm: 0xff9d5c, sky: 0x6f86ab, intensity: 1.5 },
  { key: 'overcast', label: 'Overcast', elev: 55, azim: 180, warm: 0xf2f4f7, sky: 0xc9d3de, intensity: 1.6 },
  { key: 'night', label: 'Night — interior light only', elev: -20, azim: 180, warm: 0x2a3550, sky: 0x121826, intensity: 0.05 },
];
const RND_VIEWS = [
  { key: 'wide', label: 'Wide', fov: 55, dist: 1.0, height: 1550 },
  { key: 'eye', label: 'Eye level', fov: 42, dist: 0.72, height: 1600 },
  { key: 'close', label: 'Detail', fov: 32, dist: 0.42, height: 1350 },
  { key: 'high', label: 'High corner', fov: 48, dist: 0.85, height: 2100 },
];

function rndMm(v) { return (Number(v) || 0) / 1000; }        // the app is mm; three is metres

// A room environment for image-based lighting. three's own RoomEnvironment is
// in examples/, which the UMD build does not carry — so this is the same idea
// written out: a box of emissive panels that PMREM turns into the soft ambient
// a real room has. Without it every surface takes its colour from one hard
// light and the result looks like a video game.
function rndEnvironmentScene(T, tint) {
  const scene = new T.Scene();
  const geo = new T.BoxGeometry(1, 1, 1);
  geo.deleteAttribute('uv');
  const room = new T.Mesh(geo, new T.MeshStandardMaterial({ side: T.BackSide, roughness: 1 }));
  room.scale.setScalar(24);
  scene.add(room);
  const panel = (w, h, d, x, y, z, colour, power) => {
    const m = new T.Mesh(new T.BoxGeometry(w, h, d),
      new T.MeshStandardMaterial({ color: colour, emissive: colour, emissiveIntensity: power, roughness: 1 }));
    m.position.set(x, y, z);
    scene.add(m);
    return m;
  };
  // A bright ceiling, a cooler sky-side wall and two soft bounces — which is
  // what a room actually looks like to a surface in it.
  panel(20, 0.5, 20, 0, 9.5, 0, 0xffffff, 1.5);
  panel(0.5, 12, 20, -11, 3, 0, tint || 0xbcd4f0, 1.1);
  panel(0.5, 12, 20, 11, 3, 0, 0xfff0dd, 0.55);
  panel(20, 12, 0.5, 0, 3, -11, 0xffffff, 0.4);
  return scene;
}

// ── Building the scene from the job's own records ──────────────────────────
// One derivation, from the same rooms, walls and runs the planner and the cut
// list read. Nothing here is a second model of the kitchen.
function rndBuildScene(T, ctx, project, room, opts) {
  const o = opts || {};
  const scene = new T.Scene();
  const ct = room ? cwCaseworkTypes(project).find(t => t.id === room.caseworkTypeId) : null;
  const walls = (ct && ct.walls) || [];
  const runs = (ct && ct.runs) || [];
  const wallH = rndMm(cwNum((walls[0] && walls[0].height)) || cwIn(96));

  const mat = (colour, rough, metal) => new T.MeshStandardMaterial({
    color: colour, roughness: rough === undefined ? 0.85 : rough,
    metalness: metal === undefined ? 0 : metal,
  });
  const floorMat = new T.MeshStandardMaterial({ color: 0xb59a7d, roughness: 0.6, metalness: 0.02 });
  const wallMat = mat(0xf1ece4, 0.95, 0);
  const ceilMat = mat(0xfbfaf7, 1, 0);

  // The walls are laid end to end round the room, which is how the record holds
  // them: a list of lengths, in order.
  let cursorX = 0;
  const totalLen = walls.reduce((a, w) => a + rndMm(cwWallLength(room, w)), 0);
  const depth = Math.max(3, totalLen / 3);

  // THE ROOM IS A CLOSED BOX and the cabinets stand IN it. The first version
  // put the wall at z=0 with the cabinets behind it and the camera in front, so
  // the render was a wall with sky either side — geometry floating in daylight
  // rather than an interior. The wall the run is fixed to is at z=0 facing into
  // the room; the room runs from there toward the camera.
  const W = Math.max(totalLen, 6);
  const floor = new T.Mesh(new T.PlaneGeometry(W, depth), floorMat);
  floor.rotation.x = -Math.PI / 2;
  floor.position.set(totalLen / 2, 0, depth / 2);
  floor.receiveShadow = true;
  scene.add(floor);

  const ceil = new T.Mesh(new T.PlaneGeometry(W, depth), ceilMat);
  ceil.rotation.x = Math.PI / 2;
  ceil.position.set(totalLen / 2, wallH, depth / 2);
  scene.add(ceil);

  // the wall the cabinets are on, facing the room
  const back = new T.Mesh(new T.PlaneGeometry(W, wallH), wallMat);
  back.position.set(totalLen / 2, wallH / 2, 0);
  back.receiveShadow = true;
  scene.add(back);
  // and the two returns, so light bounces and nothing looks out to sky
  const left = new T.Mesh(new T.PlaneGeometry(depth, wallH), wallMat);
  left.position.set(0, wallH / 2, depth / 2);
  left.rotation.y = Math.PI / 2;
  left.receiveShadow = true;
  scene.add(left);
  const right = new T.Mesh(new T.PlaneGeometry(depth, wallH), wallMat);
  right.position.set(totalLen, wallH / 2, depth / 2);
  right.rotation.y = -Math.PI / 2;
  right.receiveShadow = true;
  scene.add(right);
  // A window on the return, which is where the daylight in the render comes
  // from and what stops an interior reading as a lightbox.
  const glass = new T.Mesh(new T.PlaneGeometry(Math.min(depth * 0.5, 1.6), wallH * 0.45),
    new T.MeshBasicMaterial({ color: 0xdfeaf7 }));
  glass.position.set(totalLen - 0.01, wallH * 0.58, depth * 0.55);
  glass.rotation.y = -Math.PI / 2;
  scene.add(glass);

  // A cabinet is a box with a face; the face carries the finish, which is where
  // a render earns its keep — the client is looking at the door, not the box.
  const loader = new T.TextureLoader();
  const cache = {};
  function faceMaterial(finishRef) {
    const key = finishRef && finishRef.img ? finishRef.img : '__plain';
    if (cache[key]) return cache[key];
    let m;
    if (finishRef && finishRef.img) {
      // The texture must ALREADY be decoded. TextureLoader is async, and the
      // accumulation starts on the next frame — so a texture that arrives
      // late is averaged in behind sixty grey ones and the doors come out
      // grey. `o.finishImage` is the decoded HTMLImageElement, loaded before
      // the render is allowed to start.
      const tex = o.finishImage ? new T.Texture(o.finishImage) : loader.load(finishRef.img);
      if (o.finishImage) tex.needsUpdate = true;
      tex.wrapS = tex.wrapT = T.RepeatWrapping;
      tex.repeat.set(1, 1);
      if (T.sRGBEncoding !== undefined) tex.encoding = T.sRGBEncoding;
      m = new T.MeshPhysicalMaterial({ map: tex, roughness: 0.42, metalness: 0.02,
        clearcoat: 0.35, clearcoatRoughness: 0.4 });
    } else {
      m = new T.MeshPhysicalMaterial({ color: o.frontColour || 0xe8e2d6, roughness: 0.4,
        metalness: 0.02, clearcoat: 0.35, clearcoatRoughness: 0.4 });
    }
    cache[key] = m;
    return m;
  }
  const boxMat = mat(0xd8d2c6, 0.9, 0);
  const topMat = new T.MeshPhysicalMaterial({ color: o.topColour || 0xdedad2, roughness: 0.18,
    metalness: 0.02, clearcoat: 0.7, clearcoatRoughness: 0.15 });
  const kickMat = mat(0x3a3a3a, 0.95, 0);
  const pullMat = new T.MeshStandardMaterial({ color: 0x2e2e2e, roughness: 0.35, metalness: 0.85 });

  let placed = 0;
  walls.forEach((w, wi) => {
    const wLen = rndMm(cwWallLength(room, w));
    const wallRuns = runs.filter(r => r.wallId === w.id);
    wallRuns.forEach(r => {
      const layout = cwRunLayout(project, ctx, room, w, r);
      layout.members.forEach(m => {
        const width = rndMm(m.w), h = rndMm(m.h), z0 = rndMm(m.z0);
        if (!(width > 0) || !(h > 0)) return;
        const d = rndMm(cwNum(m.type && m.type.depth) || cwIn(24));
        const x = cursorX + rndMm(m.x) + width / 2;
        const g = new T.Group();
        g.position.set(x, 0, d / 2);       // standing in the room, against the wall
        // the carcass
        const box = new T.Mesh(new T.BoxGeometry(width * 0.98, h, d), boxMat);
        box.position.set(0, z0 + h / 2, 0);
        box.castShadow = true; box.receiveShadow = true;
        g.add(box);
        // the face, proud of the box, in the real finish
        const face = new T.Mesh(new T.BoxGeometry(width * 0.97, h * 0.985, 0.019),
          faceMaterial(o.finishRef));
        face.position.set(0, z0 + h / 2, d / 2 + 0.010);
        face.castShadow = true;
        g.add(face);
        // a pull, because a door without one reads as a panel
        const pull = new T.Mesh(new T.BoxGeometry(Math.min(width * 0.5, 0.16), 0.014, 0.02), pullMat);
        pull.position.set(0, z0 + h - 0.09, d / 2 + 0.028);
        pull.castShadow = true;
        g.add(pull);
        if (m.type && m.type.toeKick !== false && z0 < 0.05) {
          const kick = new T.Mesh(new T.BoxGeometry(width * 0.98, 0.1, 0.02), kickMat);
          kick.position.set(0, 0.05, d / 2 - 0.08);
          g.add(kick);
        }
        scene.add(g);
        placed++;
      });
      // a worktop over a base run
      if (rndMm(cwNum(r.zBottom)) < 0.05 && layout.members.length) {
        const used = rndMm(layout.used);
        const d = rndMm(cwIn(25));
        const top = new T.Mesh(new T.BoxGeometry(used, 0.038, d), topMat);
        top.position.set(cursorX + rndMm(cwNum(r.startOffset)) + used / 2, rndMm(cwIn(34.5)) + 0.019, d / 2 - 0.01);
        top.castShadow = true; top.receiveShadow = true;
        scene.add(top);
      }
    });
    cursorX += wLen;
  });

  return { scene, width: totalLen, depth, height: wallH, cabinets: placed };
}

// ── The renderer ───────────────────────────────────────────────────────────
// Progressive accumulation, which is the honest way to get soft shadows and
// clean edges out of a real-time renderer: render the frame many times with the
// camera jittered inside one pixel and the sun jittered across its own angular
// size, and average them. Sun jitter is what turns a hard shadow edge into the
// penumbra a real one has — it is not a blur filter, it is the same geometry
// sampled from different points on the light.
function rndRender(opts) {
  const T = window.THREE;
  const { scene, camera, width, height, samples, shadowSize, sun, onProgress, signal } = opts;
  const canvas = document.createElement('canvas');
  canvas.width = width; canvas.height = height;

  const renderer = new T.WebGLRenderer({ antialias: false, alpha: false, preserveDrawingBuffer: true });
  renderer.setSize(width, height, false);
  renderer.setPixelRatio(1);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = T.PCFSoftShadowMap;
  if (T.ACESFilmicToneMapping !== undefined) renderer.toneMapping = T.ACESFilmicToneMapping;
  renderer.toneMappingExposure = opts.exposure === undefined ? 1 : opts.exposure;
  if (T.sRGBEncoding !== undefined) renderer.outputEncoding = T.sRGBEncoding;

  sun.shadow.mapSize.set(shadowSize, shadowSize);
  sun.shadow.bias = -0.0006;
  sun.shadow.normalBias = 0.02;

  const acc = new Float32Array(width * height * 4);
  const buf = new Uint8Array(width * height * 4);
  const out = canvas.getContext('2d');
  const img = out.createImageData(width, height);
  const base = { x: sun.position.x, y: sun.position.y, z: sun.position.z };
  const sunRadius = Math.max(0.02, (opts.softness === undefined ? 0.06 : opts.softness)) * sun.position.length();

  let n = 0;
  let stopped = false;
  function step() {
    if (stopped || (signal && signal.stopped)) return;
    // Sub-pixel jitter — the camera's own anti-aliasing, done properly.
    const jx = (Math.random() - 0.5) / width * 2;
    const jy = (Math.random() - 0.5) / height * 2;
    camera.setViewOffset(width, height, jx * width, jy * height, width, height);
    // A point on the sun's disc, not its centre.
    if (n > 0) {
      sun.position.set(
        base.x + (Math.random() - 0.5) * sunRadius,
        base.y + (Math.random() - 0.5) * sunRadius * 0.5,
        base.z + (Math.random() - 0.5) * sunRadius);
    } else sun.position.set(base.x, base.y, base.z);
    renderer.render(scene, camera);
    const gl = renderer.getContext();
    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
    for (let i = 0; i < acc.length; i++) acc[i] += buf[i];
    n++;
    // WebGL reads bottom-up; the canvas is top-down.
    const d = img.data;
    for (let y = 0; y < height; y++) {
      const src = (height - 1 - y) * width * 4, dst = y * width * 4;
      for (let x = 0; x < width * 4; x += 4) {
        d[dst + x] = acc[src + x] / n;
        d[dst + x + 1] = acc[src + x + 1] / n;
        d[dst + x + 2] = acc[src + x + 2] / n;
        d[dst + x + 3] = 255;
      }
    }
    out.putImageData(img, 0, 0);
    if (onProgress) onProgress(n, samples, canvas);
    camera.clearViewOffset();
    if (n < samples) requestAnimationFrame(step);
    else { renderer.dispose(); }
  }
  step();
  return { canvas, stop() { stopped = true; renderer.dispose(); } };
}

// ── The 360 panorama ───────────────────────────────────────────────────────
// The real thing: a cube camera renders the six faces from a point in the room,
// and they are resolved to an equirectangular image — which is what every
// panorama viewer, every phone and every social platform expects. Nothing about
// this is faked with a wide-angle lens.
function rndPanorama(opts) {
  const T = window.THREE;
  const { scene, position, size, exposure } = opts;
  const W = size || 4096, H = W / 2;
  const renderer = new T.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
  renderer.setSize(64, 64, false);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = T.PCFSoftShadowMap;
  if (T.ACESFilmicToneMapping !== undefined) renderer.toneMapping = T.ACESFilmicToneMapping;
  renderer.toneMappingExposure = exposure === undefined ? 1 : exposure;
  if (T.sRGBEncoding !== undefined) renderer.outputEncoding = T.sRGBEncoding;

  const target = new T.WebGLCubeRenderTarget(Math.min(2048, W / 2));
  const cube = new T.CubeCamera(0.05, 200, target);
  cube.position.copy(position);
  cube.update(renderer, scene);

  // Resolve the cube to equirectangular with a shader — the projection written
  // out rather than approximated by sampling six images into a canvas by hand.
  const quadScene = new T.Scene();
  const quadCam = new T.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const mat = new T.ShaderMaterial({
    uniforms: { tCube: { value: target.texture } },
    vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }',
    fragmentShader: [
      'uniform samplerCube tCube; varying vec2 vUv;',
      '#define PI 3.14159265359',
      'void main(){',
      '  float lon = (vUv.x - 0.5) * 2.0 * PI;',
      '  float lat = (vUv.y - 0.5) * PI;',
      '  vec3 dir = vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon));',
      '  gl_FragColor = textureCube(tCube, dir);',
      '}',
    ].join('\n'),
  });
  quadScene.add(new T.Mesh(new T.PlaneGeometry(2, 2), mat));

  const rt = new T.WebGLRenderTarget(W, H);
  renderer.setRenderTarget(rt);
  renderer.render(quadScene, quadCam);
  const buf = new Uint8Array(W * H * 4);
  renderer.readRenderTargetPixels(rt, 0, 0, W, H, buf);
  renderer.setRenderTarget(null);

  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const g = canvas.getContext('2d');
  const img = g.createImageData(W, H);
  for (let y = 0; y < H; y++) {
    const src = (H - 1 - y) * W * 4, dst = y * W * 4;
    for (let x = 0; x < W * 4; x++) img.data[dst + x] = buf[src + x];
  }
  g.putImageData(img, 0, 0);
  target.dispose(); rt.dispose(); renderer.dispose();
  return canvas;
}

// ── The software ───────────────────────────────────────────────────────────
const RND_SECTIONS = [
  { key: 'scene', label: 'Scene', icon: '🏠', group: 'Render' },
  { key: 'still', label: 'Render', icon: '📷', group: 'Render' },
  { key: 'pano', label: '360 Panorama', icon: '🔄', group: 'Render' },
  { key: 'gallery', label: 'Gallery', icon: '🖼️', group: 'Render' },
  { key: 'about', label: 'What this does', icon: 'ℹ️', group: 'Render' },
];

// Mounted INSIDE LEON Casework & Millwork rather than as a tool of its own —
// it renders the room that module already models, so it belongs with it.
// `mode` is which of the two outputs the rail asked for.
function LeonRenderPanel({ ctx, project: fixedProject, mode }) {
  const [section, setSection] = useState(mode === 'pano' ? 'pano' : 'still');
  useEffect(() => { setSection(mode === 'pano' ? 'pano' : 'still'); }, [mode]);
  const [projectId, setProjectId] = useState('');
  const [roomId, setRoomId] = useState('');
  const [quality, setQuality] = useState('good');
  const [sizeKey, setSizeKey] = useState('1080');
  const [timeKey, setTimeKey] = useState('midday');
  const [viewKey, setViewKey] = useState('wide');
  const [exposure, setExposure] = useState(1);
  const [softness, setSoftness] = useState(0.06);
  const [finishRef, setFinishRef] = useState(null);
  const [busy, setBusy] = useState(false);
  const [prog, setProg] = useState({ n: 0, of: 0 });
  const [shot, setShot] = useState(null);
  const [pano, setPano] = useState(null);
  const [gallery, setGallery] = useState([]);
  const [err, setErr] = useState('');
  const [yaw, setYaw] = useState(0);
  const [pitch, setPitch] = useState(0);
  const holder = useRef(null);
  const job = useRef(null);

  const projects = ctx.deptProjects ? ctx.deptProjects(ctx.projects || []) : (ctx.projects || []);
  const project = fixedProject || projects.find(p => p.id === projectId) || projects[0] || null;
  const rooms = project ? cwRooms(project) : [];
  const room = rooms.find(r => r.id === roomId) || rooms[0] || null;
  const Q = RND_QUALITY.find(q => q.key === quality) || RND_QUALITY[1];
  const S = RND_SIZES.find(x => x.key === sizeKey) || RND_SIZES[1];
  const TM = RND_TIMES.find(x => x.key === timeKey) || RND_TIMES[1];
  const V = RND_VIEWS.find(x => x.key === viewKey) || RND_VIEWS[0];

  function makeScene(finishImage) {
    const T = window.THREE;
    if (!T) throw new Error('The 3D library did not load.');
    const built = rndBuildScene(T, ctx, project, room, { finishRef, finishImage });
    const { scene } = built;

    // Image-based lighting. Without it a render is one hard light and a black
    // ambient, which is exactly what makes a real-time image look unreal.
    const pmrem = new T.PMREMGenerator(new T.WebGLRenderer());
    const envScene = rndEnvironmentScene(T, TM.sky);
    const env = pmrem.fromScene(envScene, 0.04);
    scene.environment = env.texture;
    // An interior's background is the room, not the sky — you are inside it.
    scene.background = new T.Color(TM.key === 'night' ? 0x0d1017 : 0xe9e4db);

    // The sun, at the elevation and bearing the time of day gives it.
    const el = TM.elev * Math.PI / 180, az = TM.azim * Math.PI / 180;
    const R = Math.max(built.width, 6) * 2.2;
    const sun = new T.DirectionalLight(TM.warm, TM.intensity);
    sun.position.set(Math.cos(el) * Math.sin(az) * R, Math.max(0.4, Math.sin(el)) * R, Math.cos(el) * Math.cos(az) * R);
    sun.castShadow = true;
    const span = Math.max(built.width, built.depth) * 0.8;
    sun.shadow.camera.left = -span; sun.shadow.camera.right = span;
    sun.shadow.camera.top = span; sun.shadow.camera.bottom = -span;
    sun.shadow.camera.far = R * 3;
    scene.add(sun);
    scene.add(new T.HemisphereLight(TM.sky, 0x6b5f52, TM.key === 'night' ? 0.15 : 0.35));
    // Under-cabinet light, which is what a kitchen render is sold on.
    const strip = new T.PointLight(0xfff0d8, TM.key === 'night' ? 2.2 : 0.9, 6, 2);
    strip.position.set(built.width / 2, rndMm(cwIn(50)), -0.35);
    scene.add(strip);

    const camera = new T.PerspectiveCamera(V.fov, S.w / S.h, 0.05, 200);
    const cx = built.width / 2;
    // INSIDE the room, at eye height, looking at the run. Standing outside a
    // box and pointing a camera at it is how the first attempt produced a wall
    // against sky.
    camera.position.set(cx * 0.72, rndMm(V.height), Math.min(built.depth * 0.92, built.depth * V.dist + 1.6));
    camera.lookAt(cx, rndMm(cwIn(46)), 0.15);
    return { T, scene, camera, sun, built };
  }

  // Decode the finish before anything renders. One await is the difference
  // between a walnut kitchen and a grey one.
  function loadFinish() {
    return new Promise(resolve => {
      if (!finishRef || !finishRef.img) { resolve(null); return; }
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => resolve(img);
      img.onerror = () => resolve(null);
      img.src = finishRef.img;
    });
  }
  async function renderStill() {
    if (busy) return;
    setErr(''); setShot(null); setBusy(true); setProg({ n: 0, of: Q.samples });
    try {
      const finishImage = await loadFinish();
      const { scene, camera, sun } = makeScene(finishImage);
      const signal = { stopped: false };
      job.current = signal;
      rndRender({
        scene, camera, sun, width: S.w, height: S.h, samples: Q.samples,
        shadowSize: Q.shadow, exposure, softness, signal,
        onProgress: (n, of, canvas) => {
          setProg({ n, of });
          if (n === 1 || n % 4 === 0 || n === of) setShot(canvas.toDataURL('image/jpeg', 0.92));
          if (n >= of) setBusy(false);
        },
      });
    } catch (e) { setErr(e.message || String(e)); setBusy(false); }
  }
  function stop() { if (job.current) job.current.stopped = true; setBusy(false); }

  async function renderPano() {
    if (busy) return;
    setErr(''); setPano(null); setBusy(true);
    const finishImage = await loadFinish();
    setTimeout(() => {
      try {
        const { T, scene, built } = makeScene(finishImage);
        const canvas = rndPanorama({ scene,
          position: new T.Vector3(built.width / 2, rndMm(1600), built.depth * 0.35),
          size: 4096, exposure });
        setPano(canvas.toDataURL('image/jpeg', 0.9));
      } catch (e) { setErr(e.message || String(e)); }
      setBusy(false);
    }, 30);
  }

  function keep(kind, data) {
    if (!data) return;
    setGallery(g => [{ id: uid('rnd'), kind, data, at: new Date().toLocaleString(),
      label: `${room ? room.name : 'Room'} — ${TM.label}, ${V.label}` }, ...g].slice(0, 12));
  }
  function download(data, name) {
    try {
      const a = document.createElement('a'); a.href = data; a.download = name;
      document.body.appendChild(a); a.click(); a.remove();
    } catch (e) { setErr('The browser would not save the file.'); }
  }

  const noRoom = !project || !room;

  return (
    <div className="space-y-3">
      <div className="text-[11px] text-[var(--leon-black)]/45">
        {Q.label} · {Q.samples} samples · {S.label}{busy ? ` · rendering ${prog.n}/${prog.of}` : ''}
      </div>

      <div className="flex items-end gap-2 flex-wrap mb-3">
        {!fixedProject && <Field label="Project">
          <Select className="!w-56" value={project ? project.id : ''}
            onChange={e => { setProjectId(e.target.value); setRoomId(''); }}>
            {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>}
        <Field label="Room">
          <Select className="!w-44" value={room ? room.id : ''} onChange={e => setRoomId(e.target.value)}>
            {rooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
          </Select>
        </Field>
      </div>

      {noRoom && <EmptyState text="Pick a job with a casework room on it — a render is built from the same rooms, walls and runs the planner reads." />}

      {!noRoom && (section === 'still' || section === 'scene') && (
        <div className="grid lg:grid-cols-[280px_1fr] gap-3">
          <div className="space-y-3">
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
              <div className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--leon-black)]/55">Light</div>
              <div className="grid grid-cols-2 gap-1">
                {RND_TIMES.map(t => (
                  <button key={t.key} onClick={() => setTimeKey(t.key)}
                    className={`px-1.5 py-1 rounded border text-[11px] ${timeKey === t.key
                      ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]'
                      : 'border-[var(--leon-line)] hover:border-[var(--leon-brown)]'}`}>{t.label}</button>
                ))}
              </div>
              <Field label={`Exposure ${exposure.toFixed(2)}`}>
                <input type="range" min="0.3" max="2.2" step="0.05" value={exposure}
                  onChange={e => setExposure(Number(e.target.value))} className="w-full" />
              </Field>
              <Field label={`Shadow softness ${(softness * 100).toFixed(0)}%`}
                hint="How big the sun is. Bigger sun, softer shadow — the same as life.">
                <input type="range" min="0.01" max="0.3" step="0.01" value={softness}
                  onChange={e => setSoftness(Number(e.target.value))} className="w-full" />
              </Field>
            </div>
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
              <div className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--leon-black)]/55">Camera</div>
              <div className="grid grid-cols-2 gap-1">
                {RND_VIEWS.map(v => (
                  <button key={v.key} onClick={() => setViewKey(v.key)}
                    className={`px-1.5 py-1 rounded border text-[11px] ${viewKey === v.key
                      ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]'
                      : 'border-[var(--leon-line)] hover:border-[var(--leon-brown)]'}`}>{v.label}</button>
                ))}
              </div>
            </div>
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
              <div className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--leon-black)]/55">Door finish</div>
              <DoorFinishPicker label="" hint="From Supplier Finishes — the real material, on the doors."
                value={finishRef} editable onChange={setFinishRef} />
            </div>
            <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
              <div className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--leon-black)]/55">Quality</div>
              {RND_QUALITY.map(q => (
                <label key={q.key} className="flex items-baseline gap-2 text-xs">
                  <input type="radio" checked={quality === q.key} onChange={() => setQuality(q.key)} />
                  <span className="font-semibold">{q.label}</span>
                  <span className="text-[var(--leon-black)]/45">{q.samples} samples — {q.note}</span>
                </label>
              ))}
              <Field label="Size">
                <Select value={sizeKey} onChange={e => setSizeKey(e.target.value)}>
                  {RND_SIZES.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
                </Select>
              </Field>
            </div>
          </div>

          <div className="space-y-2">
            <div className="flex items-center gap-2 flex-wrap">
              <Button onClick={renderStill} disabled={busy}>📷 Render</Button>
              {busy && <Button variant="outline" onClick={stop}>Stop</Button>}
              <Button variant="outline" disabled={!shot} onClick={() => keep('still', shot)}>Keep</Button>
              <Button variant="outline" disabled={!shot}
                onClick={() => download(shot, `${project.name} — ${room.name}.jpg`)}>⬇ Save</Button>
              {busy && (
                <span className="text-xs text-[var(--leon-black)]/55">
                  {prog.n} / {prog.of} samples — it cleans up as it goes
                </span>
              )}
            </div>
            <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-black)]/5 grid place-items-center overflow-hidden"
              style={{ minHeight: 320 }}>
              {shot ? <img src={shot} alt="Render" className="w-full h-auto" />
                : <span className="text-sm text-[var(--leon-black)]/45 py-16">
                    Press Render. The image appears immediately and cleans up as the samples accumulate.
                  </span>}
            </div>
            {err && <div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">{err}</div>}
          </div>
        </div>
      )}

      {!noRoom && section === 'pano' && (
        <div className="space-y-3">
          <div className="flex items-center gap-2 flex-wrap">
            <Button onClick={renderPano} disabled={busy}>🔄 Render the panorama</Button>
            <Button variant="outline" disabled={!pano} onClick={() => keep('pano', pano)}>Keep</Button>
            <Button variant="outline" disabled={!pano}
              onClick={() => download(pano, `${project.name} — ${room.name} — 360.jpg`)}>⬇ Save equirectangular</Button>
            {busy && <span className="text-xs text-[var(--leon-black)]/55">Rendering six faces…</span>}
          </div>
          {pano ? (
            <>
              {/* Drag to look round. The image is a true equirectangular, so the
                  window into it is a straight pan — and the same file opens in
                  any panorama viewer or goes straight onto a phone. */}
              <div className="rounded-lg border border-[var(--leon-line)] overflow-hidden bg-black"
                style={{ height: 420, position: 'relative', cursor: 'grab' }}
                onMouseDown={e => {
                  const sx = e.clientX, sy = e.clientY, y0 = yaw, p0 = pitch;
                  const move = ev => {
                    setYaw(y0 + (ev.clientX - sx) * 0.12);
                    setPitch(Math.max(-45, Math.min(45, p0 + (ev.clientY - sy) * 0.1)));
                  };
                  const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
                  window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
                }}>
                <img src={pano} alt="360 panorama" draggable="false"
                  style={{ position: 'absolute', height: '260%', maxWidth: 'none',
                    left: `${-((yaw % 360) + 360) % 360 / 360 * 300}%`, top: `${-80 + pitch}%`,
                    width: '300%', objectFit: 'cover', userSelect: 'none' }} />
                <div className="absolute bottom-2 left-2 text-[11px] text-white/70 bg-black/40 px-2 py-1 rounded">
                  Drag to look round · {Math.round(((yaw % 360) + 360) % 360)}°
                </div>
              </div>
              <details className="text-xs">
                <summary className="cursor-pointer text-[var(--leon-black)]/55">The equirectangular image</summary>
                <img src={pano} alt="" className="w-full mt-2 border border-[var(--leon-line)] rounded" />
              </details>
            </>
          ) : (
            <EmptyState text="Render a panorama and it appears here to look round — and saves as a standard equirectangular image that any 360 viewer opens." />
          )}
          {err && <div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">{err}</div>}
        </div>
      )}

      {section === 'gallery' && (
        <div className="space-y-3">
          {!gallery.length ? <EmptyState text="Nothing kept yet. Press Keep after a render." /> : (
            <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
              {gallery.map(g => (
                <div key={g.id} className="rounded-lg border border-[var(--leon-line)] bg-white overflow-hidden">
                  <img src={g.data} alt="" className="w-full" />
                  <div className="p-2">
                    <div className="text-xs font-semibold">{g.label}</div>
                    <div className="text-[11px] text-[var(--leon-black)]/45">
                      {g.kind === 'pano' ? '360 panorama' : 'Still'} · {g.at}
                    </div>
                    <button onClick={() => download(g.data, `${g.label}.jpg`)}
                      className="text-[11px] font-semibold text-[var(--leon-brown)] mt-1">⬇ Save</button>
                  </div>
                </div>
              ))}
            </div>
          )}
          <p className="text-[11px] text-[var(--leon-black)]/45">
            The gallery is for this session. Save what is worth keeping, or attach it to the job through
            the Renders tab, which is where a render belongs once it has been chosen.
          </p>
        </div>
      )}

      {section === 'about' && (
        <div className="space-y-3 max-w-3xl text-sm text-[var(--leon-black)]/70">
          <h3 className="font-bold text-[var(--leon-black)]">What this does, and what it does not</h3>
          <p>
            2020 Visual Impression renders with <b>Cycles</b>, a path tracer, on a desktop. A browser tab
            cannot run a path tracer over a kitchen in any time anyone would wait, so this is not that.
          </p>
          <p>
            It is <b>physically-based real-time rendering</b> — the same approach every interactive
            architectural visualiser uses — with <b>progressive accumulation</b> on top: the frame is
            rendered many times with the camera jittered inside a pixel and the sun jittered across its own
            disc, and the results averaged. That produces genuinely soft shadows and clean edges, and it is
            why the image cleans up the longer you leave it. Lighting is image-based, from a room
            environment, and the image is tone-mapped with ACES, which is what film uses.
          </p>
          <p>
            <b>It does not do path-traced global illumination, caustics, or refraction through glass.</b>
            Those need a tracer. If a job needs that, the honest route is to export and render elsewhere.
          </p>
          <p>
            <b>The 360 panorama is the real thing.</b> A cube camera renders six faces from a point at eye
            height and they are resolved to an equirectangular image — the format every panorama viewer and
            every phone expects. It is not a wide-angle photograph pretending.
          </p>
          <p>
            Every surface comes from the job's own records: the same rooms, walls and runs the planner and
            the cut list read, with the doors in the finish chosen from Supplier Finishes.
          </p>
        </div>
      )}
      <div ref={holder} style={{ display: 'none' }} />
    </div>
  );
}
