// ═════════════════════════════════════════════ LEON Casework — 3D Studio
//
// THE RULE THIS WHOLE FILE IS BUILT AROUND, stated once so it is never in
// doubt further down:
//
//   THE 3D MODEL IS NOT A SECOND MODEL. It is a VIEW of the casework records
//   that already exist.
//
// Concretely, and enforced by construction rather than by discipline:
//
//  1. Every mesh in this file is generated by reading a record. A cabinet's
//     geometry comes out of cwComputeParts(project, cab) — the same function
//     the elevation, the panel list, the edge-band total and the nest read —
//     plus the resolved width/height/depth from cwResolve. There is no
//     geometry authored here that is not derived from a number someone can
//     find in the Cabinet Schedule.
//
//  2. Resizing in 3D writes a PARAMETER and regenerates. Typing 33" in the
//     properties panel sets cab.width through ctx.updateProject and the mesh
//     is rebuilt from the new parts. Nothing in this file ever scales, skews
//     or stretches a mesh to represent a size change — if a drag or a typed
//     value cannot be expressed as a change to a field on a record, the tool
//     refuses it and says which field is missing.
//
//  3. There is no "3D cabinet" record. A cabinet is project.caseworkItems[i];
//     a run member is a member on a Casework Type's run; a wall is a wall on
//     that type or on the room. This file adds exactly two project fields,
//     and neither of them is geometry: casework3dViews (saved cameras) and
//     nothing else. One record, many views.
//
// The honest limits are collected at the bottom of the screen under "What
// this does not do", where somebody looking for them will actually look.

// Everything here is prefixed Cw3D / cw3d / CW3D_. casework.jsx owns Cw / cw
// and is CALLED, never copied — the guard below fails loudly if the load
// order in index.html ever changes, rather than quietly drawing nothing.
function cw3dReady() {
  return typeof THREE !== 'undefined'
    && typeof cwComputeParts === 'function'
    && typeof cwResolve === 'function'
    && typeof cwRunLayout === 'function'
    && typeof cwRunHost === 'function'
    && typeof cwEffectiveType === 'function'
    && typeof cwMemberKey === 'function'
    && typeof cwAllTypes === 'function'
    && typeof cwMakeCabinet === 'function'
    && typeof cwMakeMember === 'function'
    && typeof cwMakeRun === 'function'
    && typeof cwMutateRuns === 'function'
    && typeof cwUpdate === 'function';
}
function cw3dMissing() {
  const need = [['THREE', typeof THREE !== 'undefined'],
    ['cwComputeParts', typeof cwComputeParts === 'function'],
    ['cwResolve', typeof cwResolve === 'function'],
    ['cwRunLayout', typeof cwRunLayout === 'function'],
    ['cwRunHost', typeof cwRunHost === 'function'],
    ['cwEffectiveType', typeof cwEffectiveType === 'function'],
    ['cwMemberKey', typeof cwMemberKey === 'function'],
    ['cwAllTypes', typeof cwAllTypes === 'function'],
    ['cwMakeCabinet', typeof cwMakeCabinet === 'function'],
    ['cwMakeMember', typeof cwMakeMember === 'function'],
    ['cwMakeRun', typeof cwMakeRun === 'function'],
    ['cwMutateRuns', typeof cwMutateRuns === 'function'],
    ['cwUpdate', typeof cwUpdate === 'function']];
  return need.filter(n => !n[1]).map(n => n[0]);
}

// ---- units ----------------------------------------------------------------
// The app stores millimetres. Three.js is happiest around 1.0, and a camera
// with a 0.1 near plane and a 6000-unit scene z-fights badly, so the scene
// works in METRES and this is the only place the conversion lives.
const CW3D_MM = 0.001;
const CW3D_FOV = 45;
const CW3D_IN = 25.4;

function cw3dNum(v) {
  if (v === null || v === undefined || v === '') return 0;
  const n = typeof v === 'number' ? v : parseFloat(String(v).replace(/[^0-9.\-]/g, ''));
  return isFinite(n) ? n : 0;
}
function cw3dRound(v) { return Math.round(v * 10) / 10; }

// ---- palette --------------------------------------------------------------
// Deliberately muted and close in value. A model where every part group is a
// different saturated colour reads as a diagram, not as casework; the point of
// the shaded view is to see whether the elevation looks right.
const CW3D_COLORS = {
  carcass: 0xd8c8b2,
  side: 0xd2c0a8,
  deck: 0xdfd1bc,
  back: 0xc3b39c,
  shelf: 0xe4d8c6,
  stretcher: 0xcbbaa2,
  nailer: 0xc3b39c,
  front: 0xc6ae92,
  falseFront: 0xc6ae92,
  toe: 0x8d7a64,
  frame: 0xbfa88c,
  drawer: 0xd6c7b1,
  counter: 0x9aa0a6,
  appliance: 0xb3b8bd,
  // A planned member with no cabinet record yet is drawn cool and flat, so it
  // can never be mistaken at a glance for released work.
  ghost: 0x9c9186,
  floor: 0xece5db,
  ceiling: 0xf4efe8,
  wall: 0xf0eae1,
  opening: 0x8fa9c4,      // a door in a wall
  window: 0xa9c6dd,
};

// ---- geometry: a cabinet's parts, in cabinet-local millimetres -------------
// Local frame, and it is worth being exact because everything downstream
// depends on it:
//   x  0 -> W   left to right, seen from the front
//   y  0 -> H   finished floor upward
//   z  0 -> D   the wall face forward, so z = 0 is the back of the box
//
// Every number below is read off cwComputeParts / cwResolve / the construction
// standard. Where a value genuinely is not in any record — the position of a
// drawer box on its runner, for instance — the box is centred on its front and
// the comment says so, rather than a fake field being invented to hold it.
function cw3dBox(o) {
  return {
    key: o.key, name: o.name, group: o.group || 'Carcass', kind: o.kind || 'part',
    x: o.x, y: o.y, z: o.z, w: o.w, h: o.h, d: o.d,
    color: o.color, open: o.open || null, cell: o.cell || null,
    note: o.note || '',
  };
}

function cw3dCabinetBoxes(project, cab, opts) {
  const o = opts || {};
  let built = null;
  try { built = cwComputeParts(project, cab); } catch (err) { return { error: String(err && err.message || err), boxes: [] }; }
  const res = built.res, con = built.con, L = built.layout;
  const W = cw3dNum(res.width), H = cw3dNum(res.height), D = cw3dNum(res.depth);
  if (!(W > 0 && H > 0 && D > 0)) return { error: 'This cabinet has no usable width, height or depth.', boxes: [], built: built };

  const t = cw3dNum(con.panelThickness);
  const ffT = con.kind === 'Face Frame' ? cw3dNum(con.ffThickness) : 0;
  const carcassD = cw3dNum(built.carcassD);
  const toeH = cw3dNum(built.toeH);
  const iw = cw3dNum(built.interiorW);
  const ih = cw3dNum(built.interiorH);
  const deckD = cw3dNum(built.deckDepth);
  const backT = cw3dNum(con.backThickness);
  const backZ = cw3dNum(con.backInset);
  const deckZ0 = backZ + backT;              // rear edge of every deck and shelf
  const kickBack = cw3dNum(con.toeKickSetback);
  const doorT = cw3dNum(con.doorThickness);
  const fullTop = res.category !== 'Base' && res.category !== 'Vanity';
  const boxes = [];

  // ---- sides. A notched side is not a rectangle, and drawing it as one puts
  // material where the toe kick has to be. Two boxes describe the notch
  // exactly, out of the same two construction numbers the cut list uses.
  [['side.L', 'Side — left', 0], ['side.R', 'Side — right', W - t]].forEach(s => {
    if (toeH > 0 && kickBack > 0) {
      boxes.push(cw3dBox({ key: s[0] + '.leg', name: s[1] + ' (below the notch)', group: 'Carcass',
        x: s[2], y: 0, z: 0, w: t, h: toeH, d: Math.max(1, carcassD - kickBack), color: CW3D_COLORS.side,
        note: 'Notched ' + Math.round(toeH) + ' high x ' + Math.round(kickBack) + ' deep for the toe kick.' }));
      boxes.push(cw3dBox({ key: s[0], name: s[1], group: 'Carcass',
        x: s[2], y: toeH, z: 0, w: t, h: H - toeH, d: carcassD, color: CW3D_COLORS.side }));
    } else {
      boxes.push(cw3dBox({ key: s[0], name: s[1], group: 'Carcass',
        x: s[2], y: 0, z: 0, w: t, h: H, d: carcassD, color: CW3D_COLORS.side }));
    }
  });

  // ---- bottom deck
  boxes.push(cw3dBox({ key: 'bottom', name: 'Bottom deck', group: 'Carcass',
    x: t, y: toeH, z: deckZ0, w: iw, h: t, d: deckD, color: CW3D_COLORS.deck }));

  // ---- top: a full deck on a wall or tall box, stretchers on a base box, the
  // same branch cwComputeParts takes so the model and the cut list agree.
  if (fullTop) {
    boxes.push(cw3dBox({ key: 'top', name: 'Top deck', group: 'Carcass',
      x: t, y: H - t, z: deckZ0, w: iw, h: t, d: deckD, color: CW3D_COLORS.deck }));
  } else {
    const sw = cw3dNum(con.stretcherWidth);
    boxes.push(cw3dBox({ key: 'stretcher.back', name: 'Top stretcher — back', group: 'Carcass',
      x: t, y: H - t, z: deckZ0, w: iw, h: t, d: sw, color: CW3D_COLORS.stretcher }));
    boxes.push(cw3dBox({ key: 'stretcher.front', name: 'Top stretcher — front', group: 'Carcass',
      x: t, y: H - t, z: Math.max(deckZ0, carcassD - sw), w: iw, h: t, d: sw, color: CW3D_COLORS.stretcher }));
  }

  // ---- back
  const g = con.backCaptured ? cw3dNum(con.backGrooveDepth) : 0;
  boxes.push(cw3dBox({ key: 'back', name: 'Back', group: 'Carcass',
    x: t - g, y: toeH + t - g, z: backZ, w: iw + 2 * g, h: ih + 2 * g, d: backT, color: CW3D_COLORS.back }));

  // ---- shelves, spread evenly through the clear interior. Their COUNT and
  // their SIZE are records; where they sit vertically is not — a shelf on
  // System 32 pins moves — so they are drawn evenly and the panel says so.
  const shelves = Math.max(0, Math.round(cw3dNum(res.shelfCount)));
  if (shelves > 0) {
    const sl = iw - 2 * cw3dNum(con.shelfSideClearance);
    const sd = deckD - cw3dNum(con.shelfDepthSetback);
    const y0 = toeH + t, y1 = y0 + ih;
    for (let i = 0; i < shelves; i++) {
      const y = y0 + ((i + 1) * (y1 - y0)) / (shelves + 1) - t / 2;
      boxes.push(cw3dBox({ key: 'shelf.' + (i + 1), name: 'Adjustable shelf ' + (i + 1), group: 'Carcass',
        x: t + cw3dNum(con.shelfSideClearance), y: y, z: deckZ0, w: sl, h: t, d: sd, color: CW3D_COLORS.shelf,
        note: 'Adjustable — drawn evenly spaced. The pin line, not this drawing, sets the real height.' }));
    }
  }

  // ---- toe kick board, set back by the construction standard
  if (toeH > 0) {
    boxes.push(cw3dBox({ key: 'toekick', name: 'Toe kick board', group: 'Trim',
      x: t, y: 0, z: Math.max(0, carcassD - kickBack - doorT), w: iw, h: toeH, d: doorT, color: CW3D_COLORS.toe }));
  }

  // ---- nailers: what actually carries a hung cabinet
  if (res.category === 'Wall' || res.category === 'Tall') {
    const nw = cw3dNum(con.nailerWidth);
    boxes.push(cw3dBox({ key: 'nailer.top', name: 'Nailer — top', group: 'Carcass',
      x: t, y: H - t - nw, z: deckZ0, w: iw, h: nw, d: t, color: CW3D_COLORS.nailer }));
    boxes.push(cw3dBox({ key: 'nailer.bottom', name: 'Nailer — bottom', group: 'Carcass',
      x: t, y: toeH + t, z: deckZ0, w: iw, h: nw, d: t, color: CW3D_COLORS.nailer }));
  }

  // ---- face frame. The carcass was already set back by its thickness in
  // cwComputeParts (carcassD = D - ffT), so the frame lands flush at D.
  if (con.kind === 'Face Frame') {
    const st = cw3dNum(con.ffStile), rl = cw3dNum(con.ffRail);
    const faceW = cw3dNum(L.faceW);
    boxes.push(cw3dBox({ key: 'ff.stile.L', name: 'Face frame stile — left', group: 'Face frame',
      x: 0, y: toeH, z: carcassD, w: st, h: H - toeH, d: ffT, color: CW3D_COLORS.frame }));
    boxes.push(cw3dBox({ key: 'ff.stile.R', name: 'Face frame stile — right', group: 'Face frame',
      x: faceW - st, y: toeH, z: carcassD, w: st, h: H - toeH, d: ffT, color: CW3D_COLORS.frame }));
    boxes.push(cw3dBox({ key: 'ff.rail.top', name: 'Face frame rail — top', group: 'Face frame',
      x: st, y: H - rl, z: carcassD, w: faceW - 2 * st, h: rl, d: ffT, color: CW3D_COLORS.frame }));
    boxes.push(cw3dBox({ key: 'ff.rail.bottom', name: 'Face frame rail — bottom', group: 'Face frame',
      x: st, y: toeH, z: carcassD, w: faceW - 2 * st, h: rl, d: ffT, color: CW3D_COLORS.frame }));
  }

  // ---- fronts, straight off cwFrontLayout's cells.
  // Cell x runs from the left edge of the FACE; cell y runs DOWNWARD from the
  // top of the front area. The face itself starts above the toe kick, which is
  // why local y is H - cell.y - cell.h and not cell.y.
  const frontZ = carcassD + ffT;
  const doorsByRow = {};
  (L.cells || []).forEach(c => {
    if (c.kind !== 'door') return;
    doorsByRow[c.row] = (doorsByRow[c.row] || 0) + 1;
  });
  (L.cells || []).forEach(cell => {
    if (cell.kind === 'opening') return;    // an opening is a void; drawing it as a panel would be a lie
    const y = H - cw3dNum(cell.y) - cw3dNum(cell.h);
    let open = null;
    if (cell.kind === 'door') {
      // Hinge side. A pair opens outward from the middle; a single door takes
      // the cabinet's own handing, and Auto hinges left, which is the drawing
      // convention this module already uses in its elevations.
      const n = doorsByRow[cell.row] || 1;
      let hingeLeft;
      if (n > 1) hingeLeft = cell.index % 2 === 0;
      else hingeLeft = (cab.handing || 'Auto') !== 'R';
      open = { type: 'door', hinge: hingeLeft ? 'L' : 'R' };
    } else if (cell.kind === 'drawer') {
      open = { type: 'drawer' };
    } else if (cell.kind === 'falseFront') {
      open = { type: 'tilt' };               // a tip-out hinges at the bottom, it does not slide
    }
    boxes.push(cw3dBox({
      key: cell.key,
      name: cell.kind === 'door' ? 'Door' : cell.kind === 'drawer' ? 'Drawer front' : 'False front',
      group: 'Front', kind: 'front',
      x: cw3dNum(cell.x), y: y, z: frontZ, w: cw3dNum(cell.w), h: cw3dNum(cell.h), d: doorT,
      color: cell.kind === 'falseFront' ? CW3D_COLORS.falseFront : CW3D_COLORS.front,
      open: open, cell: cell.key, note: cell.math || '',
    }));
  });

  // ---- drawer boxes, sized from the panels cwComputeParts already produced
  // rather than from a second copy of the arithmetic.
  if (o.interiors) {
    const bt = (function () {
      const p = built.panels.find(x => /drawer\.\d+\.side$/.test(x.key));
      return p ? cw3dNum(p.thickness) : cw3dNum(con.drawerBoxThickness);
    })();
    const drawerCells = (L.cells || []).filter(c => c.kind === 'drawer');
    drawerCells.forEach((cell, i) => {
      const side = built.panels.find(p => p.key === 'drawer.' + (i + 1) + '.side');
      const fb = built.panels.find(p => p.key === 'drawer.' + (i + 1) + '.fb');
      if (!side || !fb) return;              // the engine refused this drawer as unbuildable; do not draw one
      const boxDepth = cw3dNum(side.length), boxH = cw3dNum(side.width);
      const boxW = cw3dNum(fb.length) + 2 * bt;
      const bx = (W - boxW) / 2;
      const bz = Math.max(0, carcassD - boxDepth);
      const fy = H - cw3dNum(cell.y) - cw3dNum(cell.h);
      // The runner height is a hardware fact and is not on any record here, so
      // the box is centred on its front. The panel sizes are exact; this one
      // placement is a drawing convention and is labelled as one.
      const by = fy + (cw3dNum(cell.h) - boxH) / 2;
      const key = 'drawer.' + (i + 1);
      const oc = { type: 'drawer', depth: boxDepth };
      boxes.push(cw3dBox({ key: key + '.side.L', name: 'Drawer ' + (i + 1) + ' — box side', group: 'Drawer box',
        x: bx, y: by, z: bz, w: bt, h: boxH, d: boxDepth, color: CW3D_COLORS.drawer, open: oc }));
      boxes.push(cw3dBox({ key: key + '.side.R', name: 'Drawer ' + (i + 1) + ' — box side', group: 'Drawer box',
        x: bx + boxW - bt, y: by, z: bz, w: bt, h: boxH, d: boxDepth, color: CW3D_COLORS.drawer, open: oc }));
      boxes.push(cw3dBox({ key: key + '.fb.F', name: 'Drawer ' + (i + 1) + ' — box front', group: 'Drawer box',
        x: bx + bt, y: by, z: bz + boxDepth - bt, w: boxW - 2 * bt, h: boxH, d: bt, color: CW3D_COLORS.drawer, open: oc }));
      boxes.push(cw3dBox({ key: key + '.fb.B', name: 'Drawer ' + (i + 1) + ' — box back', group: 'Drawer box',
        x: bx + bt, y: by, z: bz, w: boxW - 2 * bt, h: boxH, d: bt, color: CW3D_COLORS.drawer, open: oc }));
      boxes.push(cw3dBox({ key: key + '.bottom', name: 'Drawer ' + (i + 1) + ' — bottom', group: 'Drawer box',
        x: bx + bt, y: by + bt, z: bz + bt, w: boxW - 2 * bt, h: bt, d: boxDepth - 2 * bt, color: CW3D_COLORS.drawer, open: oc }));
    });
  }

  // ---- fillers, scribes and finished ends: real parts on the cut list, so
  // real geometry here. A filler sits OUTSIDE the cabinet's run allocation,
  // which is exactly why seeing it in 3D is worth something.
  const fL = cw3dNum(cab.fillerLeft) + cw3dNum(cab.scribeLeft);
  const fR = cw3dNum(cab.fillerRight) + cw3dNum(cab.scribeRight);
  if (fL > 0) {
    boxes.push(cw3dBox({ key: 'filler.left', name: 'Filler — left', group: 'Trim',
      x: -fL, y: toeH, z: frontZ, w: fL, h: H - toeH, d: doorT, color: CW3D_COLORS.toe }));
  }
  if (fR > 0) {
    boxes.push(cw3dBox({ key: 'filler.right', name: 'Filler — right', group: 'Trim',
      x: W, y: toeH, z: frontZ, w: fR, h: H - toeH, d: doorT, color: CW3D_COLORS.toe }));
  }
  if (cab.finishedEndLeft) {
    boxes.push(cw3dBox({ key: 'endpanel.left', name: 'Finished end panel — left', group: 'Trim',
      x: -doorT, y: toeH, z: 0, w: doorT, h: H - toeH, d: carcassD + doorT, color: CW3D_COLORS.front }));
  }
  if (cab.finishedEndRight) {
    boxes.push(cw3dBox({ key: 'endpanel.right', name: 'Finished end panel — right', group: 'Trim',
      x: W, y: toeH, z: 0, w: doorT, h: H - toeH, d: carcassD + doorT, color: CW3D_COLORS.front }));
  }

  return { boxes: boxes, built: built, res: res, con: con, layout: L,
           W: W, H: H, D: D, frontZ: frontZ + doorT, error: null };
}

// A block stand-in: the same overall envelope, one box, for the detail level
// that has to draw a two-hundred-cabinet job without melting a laptop.
function cw3dCabinetBlocks(parts) {
  if (!parts || parts.error) return [];
  return [cw3dBox({ key: 'block', name: 'Cabinet envelope', group: 'Carcass', kind: 'block',
    x: 0, y: 0, z: 0, w: parts.W, h: parts.H, d: parts.D, color: CW3D_COLORS.carcass })];
}

// ---- the room, laid out ---------------------------------------------------
// A wall record carries a LENGTH, a HEIGHT and its obstructions. It does not
// carry a plan angle or a corner point — there is no plan in this module, and
// inventing one would be inventing data. So the walls are laid end to end in
// their listed order, turning ninety degrees at each corner, which is what the
// list already implies, and the viewport says so on screen rather than letting
// somebody assume the corners were surveyed.
function cw3dWallFrames(walls) {
  const out = [];
  let x = 0, z = 0;
  walls.forEach((w, i) => {
    const th = (i * Math.PI) / 2;
    const dir = { x: Math.cos(th), z: Math.sin(th) };
    const normal = { x: -dir.z, z: dir.x };           // into the room
    const len = cw3dNum(w.length) || 0;
    out.push({ wall: w, index: i, angle: th, origin: { x: x, z: z }, dir: dir, normal: normal,
               length: len, height: cw3dNum(w.height) || 0 });
    x += dir.x * len; z += dir.z * len;
  });
  return out;
}

const CW3D_WALL_THICKNESS = 114;      // 4 1/2" — a DISPLAY value; no record carries wall thickness
const CW3D_COUNTER_T = 30;            // 30 mm stone; the real slab is a LEON Stone record
const CW3D_COUNTER_OVERHANG = 25;

// One pass that produces everything the viewport, the outliner, the warnings
// panel and the properties panel read, so those four can never disagree.
function cw3dBuildModel(project, ctx, opts) {
  const o = opts || {};
  const rooms = (project && project.caseworkRooms) || [];
  const items = (project && project.caseworkItems) || [];
  const wantRooms = o.roomId && o.roomId !== 'all' ? rooms.filter(r => r.id === o.roomId) : rooms;
  const model = { rooms: [], nodes: [], warnings: [], unplaced: [], counted: 0 };
  const claimed = {};

  wantRooms.forEach(room => {
    let host = null, eff = null;
    try { host = cwRunHost(project, room); eff = cwEffectiveType(project, room); } catch (e) { host = null; }
    const walls = eff ? (eff.walls || []) : (host ? host.walls : []);
    const runs = eff ? (eff.runs || []) : (host ? host.runs : []);
    const frames = cw3dWallFrames(walls);
    const ceiling = cw3dNum(room.ceilingHeight) > 0 ? cw3dNum(room.ceilingHeight)
      : (eff && cw3dNum(eff.ceilingHeight) > 0 ? cw3dNum(eff.ceilingHeight) : 96 * CW3D_IN);
    const rm = { room: room, frames: frames, runs: [], ceiling: ceiling, mirrored: !!room.mirrored,
                 fromType: !!(host && host.kind === 'type'), typeCode: eff ? eff.code : '' };

    frames.forEach(frame => {
      runs.filter(r => r.wallId === frame.wall.id).forEach(run => {
        let layout = null;
        try { layout = cwRunLayout(project, ctx, room, frame.wall, run); } catch (e) { layout = null; }
        if (!layout) return;
        const entry = { run: run, frame: frame, layout: layout, nodes: [] };
        (layout.issues || []).forEach(iss => {
          model.warnings.push({ id: 'run:' + run.id + ':' + model.warnings.length, level: iss.level,
            msg: iss.msg, source: 'Run arithmetic', roomId: room.id, runId: run.id, wallId: frame.wall.id });
        });

        layout.members.forEach(mm => {
          const m = mm.member;
          const depth = (function () {
            if (m.depth != null && m.depth !== '') return cw3dNum(m.depth);
            if (mm.type) return cw3dNum(mm.type.depth);
            if (m.kind === 'appliance') return 24 * CW3D_IN;
            if (m.kind === 'filler' || m.kind === 'panel') return 24 * CW3D_IN;
            return 24 * CW3D_IN;
          })();
          const memberKey = cwMemberKey(run, m);
          const node = {
            id: room.id + '|' + run.id + '|' + m.id,
            kind: m.kind, memberKey: memberKey,
            roomId: room.id, runId: run.id, wallId: frame.wall.id, memberId: m.id,
            member: m, type: mm.type || null, frame: frame, run: run, room: room,
            t: mm.x, w: mm.w, h: mm.h, d: depth, z0: mm.z0,
            label: mm.label, cab: null, parts: null, boxes: [],
          };

          if (m.kind === 'cabinet') {
            const cab = items.find(c => c.roomId === room.id && c.memberKey === memberKey) || null;
            if (cab) {
              claimed[cab.id] = true;
              node.cab = cab;
              const parts = cw3dCabinetBoxes(project, cab, { interiors: o.interiors });
              node.parts = parts;
              node.boxes = o.detail === 'blocks' ? cw3dCabinetBlocks(parts) : parts.boxes;
              node.w = parts.error ? mm.w : parts.W;
              node.h = parts.error ? mm.h : parts.H;
              node.d = parts.error ? depth : parts.D;
              node.label = cab.mark + (mm.type ? ' · ' + mm.type.code : '');
              if (parts.error) {
                model.warnings.push({ id: 'cab:' + cab.id + ':err', level: 'error',
                  msg: cab.mark + ': ' + parts.error, source: 'Part engine',
                  roomId: room.id, runId: run.id, nodeId: node.id });
              } else {
                (parts.built.issues || []).forEach((iss, k) => {
                  model.warnings.push({ id: 'cab:' + cab.id + ':' + k, level: iss.level, msg: cab.mark + ' — ' + iss.msg,
                    source: 'Part engine', roomId: room.id, runId: run.id, nodeId: node.id });
                });
              }
            } else {
              // A member with no cabinet record yet: the design exists, the
              // cabinet does not. Drawn as a ghost so nobody mistakes an
              // unissued layout for released work.
              node.kind = 'ghost';
              node.boxes = [cw3dBox({ key: 'ghost', name: 'Planned cabinet', group: 'Planned', kind: 'ghost',
                x: 0, y: 0, z: 0, w: mm.w, h: mm.h, d: depth, color: CW3D_COLORS.ghost })];
              node.label = (mm.type ? mm.type.code : 'Cabinet') + ' (planned)';
            }
          } else if (m.kind === 'appliance') {
            node.boxes = [cw3dBox({ key: 'appliance', name: m.label || 'Appliance', group: 'Appliance', kind: 'appliance',
              x: cw3dNum(m.clearanceEach), y: 0, z: 0,
              w: Math.max(1, mm.w - 2 * cw3dNum(m.clearanceEach)), h: mm.h, d: depth, color: CW3D_COLORS.appliance })];
          } else if (m.kind === 'filler' || m.kind === 'panel') {
            node.boxes = [cw3dBox({ key: m.kind, name: m.label || (m.kind === 'filler' ? 'Filler' : 'Finished end panel'),
              group: 'Trim', kind: m.kind, x: 0, y: 0, z: 0, w: mm.w, h: mm.h, d: depth,
              color: m.kind === 'filler' ? CW3D_COLORS.toe : CW3D_COLORS.front })];
          } else {
            node.boxes = [];      // a gap is a gap; it gets an outliner row and no geometry
          }
          entry.nodes.push(node);
          model.nodes.push(node);
          if (node.kind === 'cabinet' || node.kind === 'ghost') model.counted++;
        });

        // ---- countertop. A base or vanity run gets a slab drawn over it.
        // Thickness and overhang are DRAWING values set in the viewport, not
        // records — the real piece is a LEON Stone record referenced by
        // cab.countertopRef, and the panel says so.
        const solid = entry.nodes.filter(n => n.kind === 'cabinet' || n.kind === 'ghost');
        const tier = run.tier || 'Base';
        if (o.countertops && solid.length && (tier === 'Base' || tier === 'Vanity')) {
          const t0 = Math.min.apply(null, solid.map(n => n.t));
          const t1 = Math.max.apply(null, solid.map(n => n.t + n.w));
          const top = Math.max.apply(null, solid.map(n => n.z0 + n.h));
          const dep = Math.max.apply(null, solid.map(n => n.d));
          entry.counter = { t: t0, w: t1 - t0, y: top, d: dep + CW3D_COUNTER_OVERHANG, h: CW3D_COUNTER_T };
        }
        rm.runs.push(entry);
      });
    });
    model.rooms.push(rm);
  });

  // ---- cabinets that belong to no run. They are real records with real
  // parts, so they are drawn — parked in front of the room and labelled,
  // never silently dropped.
  items.forEach(cab => {
    if (claimed[cab.id]) return;
    if (o.roomId && o.roomId !== 'all' && cab.roomId && cab.roomId !== o.roomId) return;
    if (o.roomId && o.roomId !== 'all' && !cab.roomId) { /* no room: still show */ }
    const parts = cw3dCabinetBoxes(project, cab, { interiors: o.interiors });
    model.unplaced.push({ cab: cab, parts: parts,
      boxes: o.detail === 'blocks' ? cw3dCabinetBlocks(parts) : parts.boxes });
  });

  return model;
}

// ---- clearance and collision ----------------------------------------------
// Everything that can be answered by cwRunLayout already has been, above —
// obstruction clashes, appliance openings, a run over the end of its wall.
// What is added here is what only exists once the runs are in one space:
// the walkway between two facing runs, a door that cannot open, and a cabinet
// through the ceiling. Warn, never block. Only a production release blocks.
const CW3D_WALK_MIN = 30 * CW3D_IN;      // 30" — below this two people cannot pass
const CW3D_WALK_GOOD = 42 * CW3D_IN;     // 42" — NKBA one-cook working aisle

function cw3dSpatialWarnings(model) {
  const out = [];
  model.rooms.forEach(rm => {
    // ceiling
    rm.runs.forEach(entry => {
      entry.nodes.forEach(n => {
        if (n.z0 + n.h > rm.ceiling + 1) {
          out.push({ id: 'ceil:' + n.id, level: 'error', source: 'Clearance',
            msg: n.label + ' finishes at ' + Math.round(n.z0 + n.h) + ' and the ceiling is at ' + Math.round(rm.ceiling) + '.',
            roomId: rm.room.id, nodeId: n.id });
        }
        if (n.t < -1) {
          out.push({ id: 'wallstart:' + n.id, level: 'warn', source: 'Clearance',
            msg: n.label + ' starts ' + Math.round(-n.t) + ' before the end of ' + entry.frame.wall.name + '.',
            roomId: rm.room.id, nodeId: n.id });
        }
      });
    });

    // walkways between facing runs, and the door swings across them
    for (let a = 0; a < rm.runs.length; a++) {
      for (let b = a + 1; b < rm.runs.length; b++) {
        const A = rm.runs[a], B = rm.runs[b];
        const nA = A.frame.normal, nB = B.frame.normal;
        const facing = nA.x * nB.x + nA.z * nB.z;
        if (facing > -0.7) continue;                        // not opposite walls
        const solidA = A.nodes.filter(n => n.d > 0 && (n.kind === 'cabinet' || n.kind === 'ghost' || n.kind === 'appliance'));
        const solidB = B.nodes.filter(n => n.d > 0 && (n.kind === 'cabinet' || n.kind === 'ghost' || n.kind === 'appliance'));
        if (!solidA.length || !solidB.length) continue;
        // vertical overlap: two runs at different heights do not share an aisle
        const aZ0 = Math.min.apply(null, solidA.map(n => n.z0)), aZ1 = Math.max.apply(null, solidA.map(n => n.z0 + n.h));
        const bZ0 = Math.min.apply(null, solidB.map(n => n.z0)), bZ1 = Math.max.apply(null, solidB.map(n => n.z0 + n.h));
        if (aZ1 <= bZ0 + 1 || bZ1 <= aZ0 + 1) continue;
        const dA = Math.max.apply(null, solidA.map(n => n.d));
        const dB = Math.max.apply(null, solidB.map(n => n.d));
        // front face of each run in world, projected onto A's inward normal
        const pA = { x: A.frame.origin.x + nA.x * dA, z: A.frame.origin.z + nA.z * dA };
        const pB = { x: B.frame.origin.x + nB.x * dB, z: B.frame.origin.z + nB.z * dB };
        const gap = (pB.x - pA.x) * nA.x + (pB.z - pA.z) * nA.z;
        if (gap <= 0) continue;
        if (gap < CW3D_WALK_MIN) {
          out.push({ id: 'walk:' + A.run.id + ':' + B.run.id, level: 'error', source: 'Clearance',
            msg: 'Walkway between ' + A.run.name + ' and ' + B.run.name + ' is ' + Math.round(gap) +
                 ' (' + (gap / CW3D_IN).toFixed(1) + '"). Under 30" two people cannot pass and an appliance door blocks the aisle.',
            roomId: rm.room.id, runId: A.run.id });
        } else if (gap < CW3D_WALK_GOOD) {
          out.push({ id: 'walk:' + A.run.id + ':' + B.run.id, level: 'warn', source: 'Clearance',
            msg: 'Walkway between ' + A.run.name + ' and ' + B.run.name + ' is ' + Math.round(gap) +
                 ' (' + (gap / CW3D_IN).toFixed(1) + '"). NKBA asks 42" for a one-cook kitchen.',
            roomId: rm.room.id, runId: A.run.id });
        }
        // door swing across that aisle
        [[A, B], [B, A]].forEach(pair => {
          const src = pair[0];
          src.nodes.forEach(n => {
            if (!n.parts || n.parts.error) return;
            (n.parts.layout.cells || []).filter(c => c.kind === 'door').forEach(c => {
              if (cw3dNum(c.w) > gap) {
                out.push({ id: 'swing:' + n.id + ':' + c.key, level: 'warn', source: 'Door swing',
                  msg: n.label + ': a ' + Math.round(cw3dNum(c.w)) + ' door cannot open fully across a ' +
                       Math.round(gap) + ' aisle.',
                  roomId: rm.room.id, nodeId: n.id });
              }
            });
          });
        });
      }
    }

    // a door at the end of a run, against the wall that returns there
    rm.runs.forEach(entry => {
      const wallLen = entry.layout.wallLen;
      entry.nodes.forEach(n => {
        if (!n.parts || n.parts.error) return;
        (n.parts.layout.cells || []).filter(c => c.kind === 'door').forEach(c => {
          const doorLeft = n.t + cw3dNum(c.x);
          const doorRight = doorLeft + cw3dNum(c.w);
          const clearLeft = doorLeft;
          const clearRight = wallLen - doorRight;
          if (clearLeft < 5 && cw3dNum(c.w) > 5) {
            out.push({ id: 'corner:' + n.id + ':' + c.key + ':L', level: 'warn', source: 'Door swing',
              msg: n.label + ': a door hard against the return at the start of ' + entry.frame.wall.name +
                   ' will bind on the adjoining wall. Add a filler.',
              roomId: rm.room.id, nodeId: n.id });
          }
          if (clearRight < 5 && cw3dNum(c.w) > 5) {
            out.push({ id: 'corner:' + n.id + ':' + c.key + ':R', level: 'warn', source: 'Door swing',
              msg: n.label + ': a door hard against the return at the end of ' + entry.frame.wall.name +
                   ' will bind on the adjoining wall. Add a filler.',
              roomId: rm.room.id, nodeId: n.id });
          }
        });
      });
    });
  });
  return out;
}

// ═══════════════════════════════════════════════ THREE.js resource handling
// A React tab mounts and unmounts. A WebGL context that is not released takes
// its buffers with it, and a browser only grants a handful of contexts before
// it starts killing the oldest — which is how a 3D tab "randomly goes black"
// after being opened a dozen times. Everything allocated goes in the pool and
// the pool is emptied on unmount, without exception.
function cw3dPool() {
  return {
    geos: new Map(), edges: new Map(), mats: new Map(), textures: new Map(), extra: [],
    boxGeo: function (w, h, d) {
      const k = Math.round(w * 10) + ':' + Math.round(h * 10) + ':' + Math.round(d * 10);
      let g = this.geos.get(k);
      // Two hundred identical cabinets are two hundred transforms over ONE set
      // of geometries. The mesh object is cheap; the buffer is not.
      if (!g) { g = new THREE.BoxGeometry(w * CW3D_MM, h * CW3D_MM, d * CW3D_MM); this.geos.set(k, g); }
      return g;
    },
    edgeGeo: function (w, h, d) {
      const k = Math.round(w * 10) + ':' + Math.round(h * 10) + ':' + Math.round(d * 10);
      let g = this.edges.get(k);
      if (!g) { g = new THREE.EdgesGeometry(this.boxGeo(w, h, d)); this.edges.set(k, g); }
      return g;
    },
    dispose: function () {
      this.geos.forEach(g => g.dispose()); this.geos.clear();
      this.edges.forEach(g => g.dispose()); this.edges.clear();
      this.mats.forEach(m => m.dispose()); this.mats.clear();
      this.textures.forEach(t => t.dispose()); this.textures.clear();
      this.extra.forEach(x => { if (x && x.dispose) x.dispose(); }); this.extra = [];
    },
  };
}

const CW3D_MODES = [
  { key: 'shaded', label: 'Shaded' },
  { key: 'material', label: 'Shaded + materials' },
  { key: 'hidden', label: 'Hidden line' },
  { key: 'xray', label: 'X-ray' },
  { key: 'wire', label: 'Wireframe' },
];

// One material per (colour, mode, texture). Swapping a mode reassigns from
// this registry instead of rebuilding the scene, so the camera does not jump
// and a 2,000-mesh model changes mode instantly.
function cw3dMaterial(pool, mode, color, texKey, planes) {
  const k = mode + ':' + color + ':' + (texKey || '');
  let m = pool.mats.get(k);
  if (m) { cw3dApplyClip(m, planes); return m; }
  const tex = texKey ? pool.textures.get(texKey) : null;
  if (mode === 'hidden') {
    m = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide,
      polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  } else if (mode === 'xray') {
    m = new THREE.MeshLambertMaterial({ color: color, side: THREE.DoubleSide,
      transparent: true, opacity: 0.22, depthWrite: false });
  } else if (mode === 'wire') {
    m = new THREE.MeshBasicMaterial({ color: color, wireframe: true });
  } else if (mode === 'material' && tex) {
    m = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.72, metalness: 0.02, side: THREE.DoubleSide });
  } else {
    m = new THREE.MeshLambertMaterial({ color: color, side: THREE.DoubleSide });
  }
  pool.mats.set(k, m);
  cw3dApplyClip(m, planes);
  return m;
}
function cw3dApplyClip(m, planes) {
  const want = planes && planes.length ? planes : [];
  const had = (m.clippingPlanes || []).length;
  m.clippingPlanes = want;
  if ((want.length ? 1 : 0) !== (had ? 1 : 0)) m.needsUpdate = true;
}

// ---- camera ---------------------------------------------------------------
// r149 ships no OrbitControls, and pulling an ES module into a Babel-in-the-
// browser page to get one would trade eighty lines of pointer maths for a
// loader problem. This is those eighty lines.
function cw3dApplyCamera(cam, view, aspect, perspective) {
  const phi = Math.max(0.02, Math.min(Math.PI - 0.02, view.phi));
  const sp = Math.sin(phi), cp = Math.cos(phi);
  const st = Math.sin(view.theta), ct = Math.cos(view.theta);
  cam.position.set(view.target.x + view.radius * sp * st,
                   view.target.y + view.radius * cp,
                   view.target.z + view.radius * sp * ct);
  cam.up.set(0, 1, 0);
  cam.lookAt(view.target.x, view.target.y, view.target.z);
  if (!perspective) {
    const h = 2 * view.radius * Math.tan((CW3D_FOV * Math.PI) / 360);
    cam.left = (-h * aspect) / 2; cam.right = (h * aspect) / 2;
    cam.top = h / 2; cam.bottom = -h / 2;
    cam.near = -view.radius * 10; cam.far = view.radius * 20;
  }
  cam.updateProjectionMatrix();
  cam.updateMatrixWorld();
}

const CW3D_STD_VIEWS = [
  { key: 'iso', label: 'Iso', theta: Math.PI * 0.25, phi: Math.PI * 0.33 },
  { key: 'top', label: 'Top', theta: 0, phi: 0.03 },
  { key: 'front', label: 'Front', theta: 0, phi: Math.PI / 2 },
  { key: 'back', label: 'Back', theta: Math.PI, phi: Math.PI / 2 },
  { key: 'left', label: 'Left', theta: -Math.PI / 2, phi: Math.PI / 2 },
  { key: 'right', label: 'Right', theta: Math.PI / 2, phi: Math.PI / 2 },
];

// ═══════════════════════════════════════════════════════ small UI helpers
function Cw3DDimInput({ value, system, onCommit, disabled, placeholder, title }) {
  const [draft, setDraft] = useState('');
  const [editing, setEditing] = useState(false);
  const shown = editing ? draft : (value === null || value === undefined || value === '' ? '' : fmtDim(cw3dNum(value), system, { inchesOnly: true }));
  function commit() {
    setEditing(false);
    const mm = parseDim(draft, system);
    if (mm === null) return;                 // unreadable input changes nothing, and says nothing it cannot back up
    onCommit(mm);
  }
  return (
    <input
      title={title}
      disabled={disabled}
      className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1.5 text-sm bg-white focus:border-[var(--leon-brown)] disabled:opacity-50"
      value={shown}
      placeholder={placeholder || ''}
      onFocus={() => { setEditing(true); setDraft(shown); }}
      onChange={e => setDraft(e.target.value)}
      onBlur={commit}
      onKeyDown={e => { if (e.key === 'Enter') { e.target.blur(); } if (e.key === 'Escape') { setEditing(false); } }}
    />
  );
}

function Cw3DBtn({ children, onClick, active, title, disabled }) {
  return (
    <button
      type="button" title={title} disabled={disabled} onClick={onClick}
      className={'px-2 py-1 rounded-md text-[11px] font-semibold border transition-colors disabled:opacity-40 ' +
        (active
          ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]'
          : 'bg-white/90 text-[var(--leon-black)] border-[var(--leon-line)] hover:border-[var(--leon-brown)]')}>
      {children}
    </button>
  );
}

function Cw3DLevelDot({ level }) {
  const c = level === 'error' ? 'bg-[var(--leon-red)]' : level === 'warn' ? 'bg-[var(--leon-yellow)]' : 'bg-[var(--leon-brown-light)]';
  return <span className={'inline-block w-2 h-2 rounded-full shrink-0 mt-1.5 ' + c} />;
}

// ═══════════════════════════════════════════════════════════ THE COMPONENT
function Cw3DStudio({ ctx, project, editable }) {
  const missing = cw3dReady() ? [] : cw3dMissing();

  const [system, setSystem] = useState('Imperial');
  const [roomId, setRoomId] = useState('all');
  const [mode, setMode] = useState('shaded');
  const [perspective, setPerspective] = useState(true);
  const [detail, setDetail] = useState('parts');
  const [interiors, setInteriors] = useState(false);
  const [countertops, setCountertops] = useState(true);
  const [showRoom, setShowRoom] = useState(true);
  const [openAmt, setOpenAmt] = useState(0);
  const [explode, setExplode] = useState(0);
  const [section, setSection] = useState({ on: false, axis: 'z', pos: 0.5, flip: false });
  const [hidden, setHidden] = useState({});
  const [sel, setSel] = useState(null);              // { nodeId } or { cabId } or { kind:'wall', ... }
  const [armed, setArmed] = useState('');            // cabinet type id waiting to be placed
  const [panel, setPanel] = useState('props');       // props | outline | warn | mats | views
  const [texMm, setTexMm] = useState(600);
  const [nudge, setNudge] = useState('6"');
  const [nudgeAxis, setNudgeAxis] = useState('along');
  const [arrayN, setArrayN] = useState(2);
  const [note, setNote] = useState('');

  const mountRef = useRef(null);
  const boxRef = useRef(null);
  const three = useRef(null);
  const viewRef = useRef({ theta: Math.PI * 0.25, phi: Math.PI * 0.33, radius: 6,
                           target: { x: 0, y: 1, z: 0 } });
  // The pointer listeners are attached once, for the life of the canvas, so
  // they cannot read `armed` out of the render closure — it would be frozen at
  // whatever it was on mount. This ref is the live copy they read instead.
  const armedRef = useRef('');

  const rooms = (project && project.caseworkRooms) || [];
  const items = (project && project.caseworkItems) || [];
  const types = project && typeof cwAllTypes === 'function' ? cwAllTypes(project) : [];

  // ---- the model. Rebuilt whenever a record changes, which is the whole
  // point: updateProject replaces the project object, this recomputes, and the
  // meshes below are regenerated from the new numbers.
  const model = useMemo(() => {
    if (missing.length || !project) return { rooms: [], nodes: [], warnings: [], unplaced: [], counted: 0 };
    const m = cw3dBuildModel(project, ctx, { roomId: roomId, detail: detail, interiors: interiors, countertops: countertops });
    m.warnings = m.warnings.concat(cw3dSpatialWarnings(m));
    return m;
  }, [project, roomId, detail, interiors, countertops, missing.length]);

  useEffect(() => { armedRef.current = armed; }, [armed]);

  const selNode = sel && sel.nodeId ? model.nodes.find(n => n.id === sel.nodeId) : null;
  const selUnplaced = sel && sel.cabId ? model.unplaced.find(u => u.cab.id === sel.cabId) : null;
  const selCab = selNode ? selNode.cab : (selUnplaced ? selUnplaced.cab : null);

  // ═══════════ scene lifecycle ═══════════
  useEffect(() => {
    if (missing.length) return undefined;
    const host = mountRef.current;
    if (!host) return undefined;

    const pool = cw3dPool();
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf2ede6);
    const persp = new THREE.PerspectiveCamera(CW3D_FOV, 1, 0.02, 400);
    const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, -100, 400);

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.localClippingEnabled = true;
    if (THREE.sRGBEncoding !== undefined) renderer.outputEncoding = THREE.sRGBEncoding;
    host.appendChild(renderer.domElement);
    renderer.domElement.style.display = 'block';
    renderer.domElement.style.width = '100%';
    renderer.domElement.style.height = '100%';
    renderer.domElement.style.touchAction = 'none';
    renderer.domElement.style.cursor = 'default';

    // No shadow maps. Shadows cost a second render pass per light and this is a
    // review tool, not a rendering — the module says plainly that it does not
    // do photoreal output, so it should not pay for half of one.
    const hemi = new THREE.HemisphereLight(0xffffff, 0x9a8d7d, 0.95);
    const key = new THREE.DirectionalLight(0xffffff, 0.55); key.position.set(3, 6, 4);
    const fill = new THREE.DirectionalLight(0xffffff, 0.28); fill.position.set(-4, 3, -3);
    scene.add(hemi, key, fill);

    const grid = new THREE.GridHelper(40, 40, 0xd8cfc2, 0xe8e1d7);
    grid.position.y = -0.002;
    scene.add(grid);
    pool.extra.push(grid.geometry, grid.material);

    const content = new THREE.Group(); scene.add(content);
    const overlay = new THREE.Group(); scene.add(overlay);

    const selBoxGeo = new THREE.BoxGeometry(1, 1, 1);
    const selEdges = new THREE.EdgesGeometry(selBoxGeo);
    const selMat = new THREE.LineBasicMaterial({ color: 0x6b4a34, depthTest: false, transparent: true });
    const selLine = new THREE.LineSegments(selEdges, selMat);
    selLine.renderOrder = 999; selLine.visible = false;
    overlay.add(selLine);
    pool.extra.push(selBoxGeo, selEdges, selMat);

    const plane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
    const raycaster = new THREE.Raycaster();

    three.current = { scene, persp, ortho, renderer, content, overlay, selLine, pool, plane,
                      raycaster, nodesById: {}, meshes: [], edgeSets: [], walls: [], bounds: null };

    // ---- pointer: orbit, pan, zoom, pick
    let dragging = null, lastX = 0, lastY = 0, moved = 0;
    const el = renderer.domElement;
    function cam() { return perspRef.current ? persp : ortho; }
    const perspRef = { current: true };
    three.current.perspRef = perspRef;

    function onDown(e) {
      el.setPointerCapture && el.setPointerCapture(e.pointerId);
      moved = 0; lastX = e.clientX; lastY = e.clientY;
      dragging = (e.button === 0 && !e.shiftKey) ? 'orbit' : 'pan';
      el.style.cursor = dragging === 'orbit' ? 'grabbing' : 'move';
    }
    function onMove(e) {
      if (!dragging) return;
      const dx = e.clientX - lastX, dy = e.clientY - lastY;
      lastX = e.clientX; lastY = e.clientY;
      moved += Math.abs(dx) + Math.abs(dy);
      const v = viewRef.current;
      if (dragging === 'orbit') {
        v.theta -= dx * 0.006;
        v.phi = Math.max(0.02, Math.min(Math.PI - 0.02, v.phi - dy * 0.006));
      } else {
        const c = cam();
        const r = el.clientHeight || 1;
        const scale = (2 * v.radius * Math.tan((CW3D_FOV * Math.PI) / 360)) / r;
        const right = new THREE.Vector3().setFromMatrixColumn(c.matrix, 0);
        const up = new THREE.Vector3().setFromMatrixColumn(c.matrix, 1);
        v.target.x += (-dx * scale) * right.x + (dy * scale) * up.x;
        v.target.y += (-dx * scale) * right.y + (dy * scale) * up.y;
        v.target.z += (-dx * scale) * right.z + (dy * scale) * up.z;
      }
    }
    function onUp(e) {
      const wasDragging = dragging;
      dragging = null;
      el.style.cursor = 'default';
      if (wasDragging && moved < 5) pick(e);
    }
    function onWheel(e) {
      e.preventDefault();
      const v = viewRef.current;
      v.radius = Math.max(0.15, Math.min(200, v.radius * Math.exp(e.deltaY * 0.0012)));
    }
    function ndc(e) {
      const r = el.getBoundingClientRect();
      return { x: ((e.clientX - r.left) / r.width) * 2 - 1, y: -((e.clientY - r.top) / r.height) * 2 + 1 };
    }
    function pick(e) {
      const p = ndc(e);
      raycaster.setFromCamera(new THREE.Vector2(p.x, p.y), cam());
      const hits = raycaster.intersectObjects(content.children, true);
      const armedNow = armedRef.current;
      for (let i = 0; i < hits.length; i++) {
        const h = hits[i];
        let o = h.object, tag = null;
        while (o && !tag) { if (o.userData && o.userData.cw3d) tag = o.userData.cw3d; o = o.parent; }
        if (!tag) continue;
        if (armedNow && tag.kind === 'wall') {
          const f = tag.frame;
          const t = (h.point.x / CW3D_MM - f.origin.x) * f.dir.x + (h.point.z / CW3D_MM - f.origin.z) * f.dir.z;
          three.current.onPlace && three.current.onPlace(tag, t);
          return;
        }
        if (armedNow && tag.kind !== 'wall') continue;   // placement only lands on a wall
        three.current.onPick && three.current.onPick(tag, h);
        return;
      }
      if (!armedNow) three.current.onPick && three.current.onPick(null, null);
    }
    el.addEventListener('pointerdown', onDown);
    el.addEventListener('pointermove', onMove);
    el.addEventListener('pointerup', onUp);
    el.addEventListener('pointercancel', onUp);
    el.addEventListener('wheel', onWheel, { passive: false });
    el.addEventListener('contextmenu', ev => ev.preventDefault());

    // ---- resize
    let w = 1, h = 1;
    function resize() {
      const b = boxRef.current;
      if (!b) return;
      w = Math.max(1, b.clientWidth); h = Math.max(1, b.clientHeight);
      renderer.setSize(w, h, false);
      persp.aspect = w / h; persp.updateProjectionMatrix();
    }
    const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
    if (ro && boxRef.current) ro.observe(boxRef.current);
    window.addEventListener('resize', resize);
    resize();

    let raf = 0;
    function loop() {
      raf = requestAnimationFrame(loop);
      const c = perspRef.current ? persp : ortho;
      cw3dApplyCamera(c, viewRef.current, w / h, perspRef.current);
      renderer.render(scene, c);
    }
    loop();

    return function () {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', resize);
      if (ro) ro.disconnect();
      el.removeEventListener('pointerdown', onDown);
      el.removeEventListener('pointermove', onMove);
      el.removeEventListener('pointerup', onUp);
      el.removeEventListener('pointercancel', onUp);
      el.removeEventListener('wheel', onWheel);
      pool.dispose();
      scene.traverse(obj => {
        if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
        if (obj.material) {
          const ms = Array.isArray(obj.material) ? obj.material : [obj.material];
          ms.forEach(m => m && m.dispose && m.dispose());
        }
      });
      renderer.dispose();
      if (renderer.forceContextLoss) renderer.forceContextLoss();
      if (el.parentNode) el.parentNode.removeChild(el);
      three.current = null;
    };
  }, [missing.length]);

  useEffect(() => {
    if (three.current && three.current.perspRef) three.current.perspRef.current = perspective;
  }, [perspective]);

  // ═══════════ build the meshes from the model ═══════════
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    const pool = T.pool, content = T.content;
    // Only the SCENE GRAPH is torn down here — the geometry cache survives, so
    // a rebuild after a width change reuses every box that did not move.
    while (content.children.length) content.remove(content.children[0]);
    T.nodesById = {}; T.meshes = []; T.edgeSets = []; T.walls = [];

    const bounds = new THREE.Box3();
    const planes = section.on ? [T.plane] : [];

    function addBox(parent, b, tag, colorOverride) {
      if (!(b.w > 0.05 && b.h > 0.05 && b.d > 0.05)) return null;
      const geo = pool.boxGeo(b.w, b.h, b.d);
      const color = colorOverride !== undefined ? colorOverride : b.color;
      const mesh = new THREE.Mesh(geo, cw3dMaterial(pool, mode, color, tag && tag.texKey, planes));
      mesh.position.set((b.x + b.w / 2) * CW3D_MM, (b.y + b.h / 2) * CW3D_MM, (b.z + b.d / 2) * CW3D_MM);
      mesh.userData.cw3dBox = b;
      mesh.userData.cw3dColor = color;
      mesh.userData.cw3dTex = (tag && tag.texKey) || '';
      if (tag) mesh.userData.cw3d = tag;
      const el = new THREE.LineSegments(pool.edgeGeo(b.w, b.h, b.d),
        cw3dEdgeMat(pool, mode));
      el.position.copy(mesh.position);
      el.userData.cw3dEdge = true;
      // A Line's raycast threshold is in WORLD units and defaults to 1 — a
      // metre — so every edge overlay would swallow the click before the solid
      // behind it, and selection would pick more or less at random. The edges
      // are decoration; they opt out of picking entirely.
      el.raycast = function () {};
      parent.add(mesh); parent.add(el);
      T.meshes.push(mesh); T.edgeSets.push(el);
      return mesh;
    }

    // ---- rooms
    model.rooms.forEach(rm => {
      const roomG = new THREE.Group();
      roomG.userData.cw3d = { kind: 'room', roomId: rm.room.id, label: rm.room.name };
      content.add(roomG);

      if (showRoom && !hidden['room:' + rm.room.id]) {
        // floor and ceiling from the polyline the walls describe
        const pts = [];
        rm.frames.forEach(f => pts.push([f.origin.x, f.origin.z]));
        if (pts.length >= 3) {
          const shape = new THREE.Shape();
          shape.moveTo(pts[0][0] * CW3D_MM, pts[0][1] * CW3D_MM);
          for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i][0] * CW3D_MM, pts[i][1] * CW3D_MM);
          shape.closePath();
          const fg = new THREE.ShapeGeometry(shape);
          pool.extra.push(fg);
          const floor = new THREE.Mesh(fg, cw3dMaterial(pool, mode === 'material' ? 'shaded' : mode, CW3D_COLORS.floor, null, planes));
          floor.rotation.x = Math.PI / 2;
          floor.userData.cw3dColor = CW3D_COLORS.floor;
          floor.userData.cw3d = { kind: 'floor', roomId: rm.room.id, label: rm.room.name + ' — floor' };
          roomG.add(floor); T.meshes.push(floor);
          if (!hidden['ceiling:' + rm.room.id]) {
            // Same rotation as the floor, only lifted. Flipping it instead
            // mirrors the polygon in Z and the ceiling stops matching the room.
            const ceil = new THREE.Mesh(fg, cw3dMaterial(pool, mode === 'material' ? 'shaded' : mode, CW3D_COLORS.ceiling, null, planes));
            ceil.rotation.x = Math.PI / 2;
            ceil.position.y = rm.ceiling * CW3D_MM;
            ceil.userData.cw3dColor = CW3D_COLORS.ceiling;
            ceil.userData.cw3d = { kind: 'ceiling', roomId: rm.room.id, label: rm.room.name + ' — ceiling' };
            roomG.add(ceil); T.meshes.push(ceil);
          }
        }

        rm.frames.forEach(f => {
          if (hidden['wall:' + rm.room.id + ':' + f.wall.id]) return;
          const g = new THREE.Group();
          g.position.set(f.origin.x * CW3D_MM, 0, f.origin.z * CW3D_MM);
          g.rotation.y = -f.angle;
          g.userData.cw3d = { kind: 'wall', roomId: rm.room.id, wallId: f.wall.id, frame: f,
                              label: rm.room.name + ' — ' + f.wall.name };
          roomG.add(g); T.walls.push(g);
          // The slab is drawn OUTWARD from the polyline so the inside face,
          // which is the face every run is dimensioned from, stays at zero.
          addBox(g, cw3dBox({ key: 'wall', name: f.wall.name, group: 'Room', kind: 'wallslab',
            x: 0, y: 0, z: -CW3D_WALL_THICKNESS, w: f.length, h: f.height || rm.ceiling,
            d: CW3D_WALL_THICKNESS, color: CW3D_COLORS.wall }), g.userData.cw3d);
          (f.wall.obstructions || []).forEach(ob => {
            const zb = ob.kind === 'Door' ? 0 : cw3dNum(ob.sillHeight);
            addBox(g, cw3dBox({ key: 'obs.' + ob.id, name: ob.label || ob.kind, group: 'Room', kind: 'obstruction',
              x: cw3dNum(ob.fromLeft), y: zb, z: -CW3D_WALL_THICKNESS - 2,
              w: cw3dNum(ob.width), h: cw3dNum(ob.height), d: CW3D_WALL_THICKNESS + 4,
              color: ob.kind === 'Door' ? CW3D_COLORS.opening : CW3D_COLORS.window }),
              { kind: 'obstruction', roomId: rm.room.id, wallId: f.wall.id, obsId: ob.id,
                label: (ob.label || ob.kind) + ' on ' + f.wall.name });
          });
        });
      }

      // ---- runs and their members
      rm.runs.forEach(entry => {
        const f = entry.frame;
        if (entry.counter && !hidden['counter:' + entry.run.id]) {
          const cg = new THREE.Group();
          cg.position.set(f.origin.x * CW3D_MM, 0, f.origin.z * CW3D_MM);
          cg.rotation.y = -f.angle;
          cg.userData.cw3d = { kind: 'countertop', roomId: rm.room.id, runId: entry.run.id,
                               label: entry.run.name + ' — countertop' };
          roomG.add(cg);
          addBox(cg, cw3dBox({ key: 'counter', name: 'Countertop', group: 'Countertop', kind: 'counter',
            x: entry.counter.t, y: entry.counter.y, z: 0,
            w: entry.counter.w, h: entry.counter.h, d: entry.counter.d, color: CW3D_COLORS.counter }),
            cg.userData.cw3d);
        }
        entry.nodes.forEach(node => {
          if (!node.boxes.length) return;
          if (hidden['node:' + node.id]) return;
          const g = new THREE.Group();
          g.position.set((f.origin.x + f.dir.x * node.t) * CW3D_MM, node.z0 * CW3D_MM,
                         (f.origin.z + f.dir.z * node.t) * CW3D_MM);
          g.rotation.y = -f.angle;
          const tag = { kind: node.kind === 'ghost' ? 'ghost' : node.kind === 'cabinet' ? 'cabinet' : node.kind,
                        nodeId: node.id, roomId: rm.room.id, runId: entry.run.id, cabId: node.cab ? node.cab.id : null,
                        label: node.label, texKey: cw3dTexKeyFor(node) };
          g.userData.cw3d = tag;
          roomG.add(g);
          T.nodesById[node.id] = g;
          cw3dAddParts(g, node, tag, addBox, mode);
          const bb = new THREE.Box3().setFromObject(g);
          if (isFinite(bb.min.x)) bounds.union(bb);
        });
      });
    });

    // ---- cabinets with no run. Parked in a row, clearly apart from the room.
    if (model.unplaced.length) {
      const parkG = new THREE.Group();
      parkG.position.set(0, 0, -1.6);
      parkG.userData.cw3d = { kind: 'parked', label: 'Not placed in a run' };
      content.add(parkG);
      let px = 0;
      model.unplaced.forEach(u => {
        if (hidden['cab:' + u.cab.id]) return;
        const g = new THREE.Group();
        g.position.set(px * CW3D_MM, 0, 0);
        const tag = { kind: 'cabinet', cabId: u.cab.id, label: u.cab.mark + ' (not in a run)',
                      texKey: u.cab.finish && u.cab.finish.img ? u.cab.finish.img : '' };
        g.userData.cw3d = tag;
        parkG.add(g);
        cw3dAddParts(g, { boxes: u.boxes, parts: u.parts, kind: 'cabinet' }, tag, addBox, mode);
        px += (u.parts && u.parts.W ? u.parts.W : 600) + 150;
      });
    }

    // A room with no cabinets in it yet still has to be findable, so the walls
    // count toward the extents even though they are not the subject.
    T.walls.forEach(g => {
      const bb = new THREE.Box3().setFromObject(g);
      if (isFinite(bb.min.x)) bounds.union(bb);
    });
    T.bounds = isFinite(bounds.min.x) ? bounds : null;
    // Frame on the first build, and again when the room being looked at
    // changes. NOT on an ordinary rebuild: a width edit that jumps the camera
    // makes the change impossible to read, which is the one thing this panel
    // exists to let somebody do.
    if (T.bounds && T.framedFor !== roomId) { cw3dFrame(viewRef.current, T.bounds); T.framedFor = roomId; }
    // `mode` and `section` are deliberately NOT dependencies. Changing either
    // reassigns materials from the registry a few effects below, which is
    // instant on a 2,000-mesh model; rebuilding the graph for a display change
    // would throw away the geometry cache for no reason.
  }, [model, showRoom, hidden, detail]);

  // ---- texture loading for the materials mode
  useEffect(() => {
    const T = three.current;
    if (!T || mode !== 'material') return;
    const pool = T.pool;
    const wanted = {};
    T.meshes.forEach(m => { const k = m.userData.cw3dTex; if (k) wanted[k] = true; });
    const keys = Object.keys(wanted).filter(k => !pool.textures.has(k));
    if (!keys.length) { cw3dRetexture(T, mode, texMm, section.on ? [T.plane] : []); return; }
    let cancelled = false;
    const loader = new THREE.TextureLoader();
    let left = keys.length;
    keys.forEach(k => {
      loader.load(k, tex => {
        if (cancelled) { tex.dispose(); return; }
        tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping;
        if (THREE.sRGBEncoding !== undefined) tex.encoding = THREE.sRGBEncoding;
        pool.textures.set(k, tex);
        if (--left <= 0 && three.current) cw3dRetexture(three.current, mode, texMm, section.on ? [three.current.plane] : []);
      }, undefined, () => {
        // A finish whose image is missing keeps its shaded colour rather than
        // rendering as a black panel that reads like a real dark finish.
        if (--left <= 0 && three.current) cw3dRetexture(three.current, mode, texMm, section.on ? [three.current.plane] : []);
      });
    });
    return function () { cancelled = true; };
  }, [model, mode, texMm]);

  // ---- mode swap without a rebuild
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    cw3dRetexture(T, mode, texMm, section.on ? [T.plane] : []);
    T.edgeSets.forEach(e => { e.visible = mode !== 'wire'; e.material = cw3dEdgeMat(T.pool, mode); });
  }, [mode, texMm, section.on]);

  // ---- section plane
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    const b = T.bounds;
    const n = section.axis === 'x' ? new THREE.Vector3(1, 0, 0)
      : section.axis === 'y' ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(0, 0, 1);
    if (section.flip) n.multiplyScalar(-1);
    let lo = -5, hi = 5;
    if (b) {
      lo = section.axis === 'x' ? b.min.x : section.axis === 'y' ? b.min.y : b.min.z;
      hi = section.axis === 'x' ? b.max.x : section.axis === 'y' ? b.max.y : b.max.z;
    }
    const at = lo + (hi - lo) * section.pos;
    T.plane.normal.copy(n);
    T.plane.constant = section.flip ? at : -at;
    T.pool.mats.forEach(m => cw3dApplyClip(m, section.on ? [T.plane] : []));
  }, [section, model]);

  // ---- open fronts and exploded view: transforms only, never a record
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    T.meshes.forEach(mesh => {
      const b = mesh.userData.cw3dBox;
      if (!b) return;
      const base = mesh.userData.cw3dBase || (mesh.userData.cw3dBase = mesh.position.clone());
      mesh.position.copy(base);
      mesh.rotation.set(0, 0, 0);
      const edge = mesh.userData.cw3dEdgeRef;
      if (b.open && openAmt > 0) {
        if (b.open.type === 'drawer') {
          const travel = (b.open.depth || b.d) * 0.7 * openAmt * CW3D_MM;
          mesh.position.z += travel;
        } else if (b.open.type === 'tilt') {
          mesh.rotation.x = openAmt * 0.5;
        } else if (b.open.type === 'door') {
          // Rotating about the hinge, not about the panel's own centre — which
          // is why the pivot is recomputed here rather than just spinning the
          // mesh in place.
          const ang = openAmt * (100 * Math.PI) / 180;
          const sgn = b.open.hinge === 'L' ? -1 : 1;
          const hx = (b.open.hinge === 'L' ? b.x : b.x + b.w) * CW3D_MM;
          const hz = b.z * CW3D_MM;
          const dx = base.x - hx, dz = base.z - hz;
          const ca = Math.cos(sgn * ang), sa = Math.sin(sgn * ang);
          mesh.position.x = hx + dx * ca + dz * sa;
          mesh.position.z = hz - dx * sa + dz * ca;
          mesh.rotation.y = sgn * ang;
        }
      }
      if (explode > 0 && mesh.userData.cw3dExplode) {
        const e = mesh.userData.cw3dExplode;
        mesh.position.x += e.x * explode; mesh.position.y += e.y * explode; mesh.position.z += e.z * explode;
      }
      if (edge) { edge.position.copy(mesh.position); edge.rotation.copy(mesh.rotation); }
    });
  }, [openAmt, explode, model, detail, hidden, interiors, showRoom]);

  // ---- selection outline
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    const line = T.selLine;
    let group = null;
    if (sel && sel.nodeId) group = T.nodesById[sel.nodeId];
    if (!group) { line.visible = false; return; }
    const bb = new THREE.Box3().setFromObject(group);
    if (!isFinite(bb.min.x)) { line.visible = false; return; }
    const size = new THREE.Vector3(); bb.getSize(size);
    const c = new THREE.Vector3(); bb.getCenter(c);
    line.scale.set(Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001));
    line.position.copy(c);
    line.visible = true;
  }, [sel, model, openAmt, explode, detail, hidden, showRoom]);

  // ---- pick / place callbacks (kept on the ref so the listener never goes stale)
  useEffect(() => {
    const T = three.current;
    if (!T) return;
    T.onPick = function (tag) {
      if (!tag) { setSel(null); return; }
      if (tag.nodeId) { setSel({ nodeId: tag.nodeId, cabId: tag.cabId || null }); setPanel('props'); return; }
      if (tag.cabId) { setSel({ cabId: tag.cabId }); setPanel('props'); return; }
      setSel({ kind: tag.kind, roomId: tag.roomId, wallId: tag.wallId, label: tag.label });
      setPanel('props');
    };
    T.onPlace = function (tag, t) {
      placeType(tag.roomId, tag.wallId, t);
    };
  });

  // ═══════════ writes — every one of these sets a FIELD ═══════════
  function writeCab(cabId, fields, action) {
    if (!editable) return;
    cwUpdate(ctx, project, draft => {
      const c = draft.caseworkItems.find(x => x.id === cabId);
      if (!c) return;
      Object.assign(c, fields);
    }, action || null);
  }

  function setDim(cabId, field, mm) {
    const cab = items.find(c => c.id === cabId);
    if (!cab) return;
    const before = cwResolve(project, cab);
    writeCab(cabId, { [field]: cw3dRound(mm) },
      'LEON Casework 3D — ' + cab.mark + ' ' + field + ' ' + Math.round(cw3dNum(before[field])) +
      ' -> ' + Math.round(mm) + ' mm. Parts, elevation and BOM recomputed.');
  }
  function clearDim(cabId, field) {
    const cab = items.find(c => c.id === cabId);
    if (!cab) return;
    writeCab(cabId, { [field]: null },
      'LEON Casework 3D — ' + cab.mark + ' ' + field + ' override cleared; it inherits its cabinet type again.');
  }

  // Placement. A run is an ORDERED LIST with no coordinates, so "where along
  // the wall" resolves to "at which index" — which means a placed cabinet is
  // exactly against its neighbour by construction. There is no float position
  // to snap, and therefore no way to leave a 0.4 mm gap behind.
  function placeType(rmId, wallId, t) {
    if (!editable || !armed) return;
    const rm = model.rooms.find(r => r.room.id === rmId);
    if (!rm) return;
    const type = types.find(x => x.id === armed);
    if (!type) return;
    const tier = type.category === 'Wall' ? 'Wall' : type.category === 'Tall' ? 'Tall' : type.category === 'Vanity' ? 'Vanity' : 'Base';
    const existing = rm.runs.filter(e => e.frame.wall.id === wallId);
    let target = existing.find(e => (e.run.tier || 'Base') === tier) || null;
    const room = rm.room;
    let index = 0;
    if (target) {
      let acc = cw3dNum(target.run.startOffset);
      (target.run.members || []).forEach((m, i) => {
        const w = target.nodes[i] ? target.nodes[i].w : 0;
        if (t > acc + w / 2) index = i + 1;
        acc += w;
      });
    }
    const wallName = rm.frames.find(f => f.wall.id === wallId);
    cwMutateRuns(ctx, project, room, list => {
      let run = target ? list.find(r => r.id === target.run.id) : null;
      if (!run) {
        run = cwMakeRun({ wallId: wallId, name: tier + ' run', tier: tier });
        list.push(run);
        index = 0;
      }
      if (!Array.isArray(run.members)) run.members = [];
      // width stays NULL so the cabinet keeps inheriting its type — the same
      // rule cwApplyCaseworkType follows, and the reason a later change to the
      // type still reaches everything placed from it.
      run.members.splice(index, 0, cwMakeMember({ kind: 'cabinet', cabTypeId: type.id, width: null, handing: 'Auto' }));
    }, 'LEON Casework 3D — ' + type.code + ' placed on ' + (wallName ? wallName.wall.name : 'a wall') +
       ' in ' + room.name + ' at position ' + (index + 1) + ' of the ' + tier.toLowerCase() + ' run.');
    setArmed('');
    setNote(type.code + ' placed. It joined the ' + tier.toLowerCase() + ' run against its neighbour — a run has no free coordinates, so there is no gap to snap.');
  }

  function memberMutate(node, fn, action) {
    if (!editable || !node) return;
    const room = rooms.find(r => r.id === node.roomId);
    if (!room) return;
    cwMutateRuns(ctx, project, room, list => {
      const run = list.find(r => r.id === node.runId);
      if (!run || !Array.isArray(run.members)) return;
      fn(run, run.members.findIndex(m => m.id === node.memberId));
    }, action);
  }

  function moveMember(node, dir) {
    memberMutate(node, (run, i) => {
      const j = i + dir;
      if (i < 0 || j < 0 || j >= run.members.length) return;
      const tmp = run.members[i]; run.members[i] = run.members[j]; run.members[j] = tmp;
    }, 'LEON Casework 3D — ' + node.label + ' moved ' + (dir < 0 ? 'left' : 'right') + ' in ' + node.run.name + '.');
  }

  function copyMember(node) {
    memberMutate(node, (run, i) => {
      if (i < 0) return;
      const src = run.members[i];
      const copy = cwMakeMember(Object.assign({}, src, { id: undefined }));
      run.members.splice(i + 1, 0, copy);
    }, 'LEON Casework 3D — ' + node.label + ' copied in ' + node.run.name + '.');
  }

  function arrayMember(node, n) {
    const count = Math.max(1, Math.min(40, Math.round(cw3dNum(n))));
    memberMutate(node, (run, i) => {
      if (i < 0) return;
      const src = run.members[i];
      const add = [];
      for (let k = 0; k < count; k++) add.push(cwMakeMember(Object.assign({}, src, { id: undefined })));
      run.members.splice.apply(run.members, [i + 1, 0].concat(add));
    }, 'LEON Casework 3D — ' + node.label + ' arrayed ' + count + ' more time(s) along ' + node.run.name + '.');
  }

  function removeMember(node) {
    memberMutate(node, (run, i) => { if (i >= 0) run.members.splice(i, 1); },
      'LEON Casework 3D — ' + node.label + ' removed from ' + node.run.name + '.');
    setSel(null);
  }

  // Typed exact movement. The only two positions a run actually stores are its
  // start offset along the wall and its height off the floor, so those are the
  // two axes offered. Moving one cabinet inside a run is not a coordinate — it
  // is a gap member — so that is what the third option writes.
  function applyNudge(node, sign) {
    const mm = parseDim(nudge, system);
    if (mm === null) { setNote('"' + nudge + '" could not be read as a dimension. Try 6", 150 mm, or 1\'-6".'); return; }
    if (!node) return;
    if (nudgeAxis === 'along') {
      memberMutate(node, run => { run.startOffset = Math.max(0, cw3dNum(run.startOffset) + sign * mm); },
        'LEON Casework 3D — ' + node.run.name + ' start offset moved ' + (sign > 0 ? '+' : '-') + Math.round(mm) + ' mm along the wall.');
    } else if (nudgeAxis === 'height') {
      memberMutate(node, run => { run.zBottom = Math.max(0, cw3dNum(run.zBottom) + sign * mm); },
        'LEON Casework 3D — ' + node.run.name + ' mounting height moved ' + (sign > 0 ? '+' : '-') + Math.round(mm) + ' mm.');
    } else {
      if (sign < 0) {
        // shrink or remove the gap immediately before this member
        memberMutate(node, (run, i) => {
          const prev = run.members[i - 1];
          if (!prev || prev.kind !== 'gap') return;
          const w = cw3dNum(prev.width) - mm;
          if (w <= 0.5) run.members.splice(i - 1, 1); else prev.width = cw3dRound(w);
        }, 'LEON Casework 3D — gap before ' + node.label + ' reduced by ' + Math.round(mm) + ' mm.');
      } else {
        memberMutate(node, (run, i) => {
          const prev = run.members[i - 1];
          if (prev && prev.kind === 'gap') prev.width = cw3dRound(cw3dNum(prev.width) + mm);
          else run.members.splice(i, 0, cwMakeMember({ kind: 'gap', label: 'Reserved', width: cw3dRound(mm) }));
        }, 'LEON Casework 3D — ' + node.label + ' moved ' + Math.round(mm) + ' mm along ' + node.run.name + ' by a reserved gap.');
      }
    }
  }

  // A one-off room's members never become cabinets, because cwApplyCaseworkType
  // only runs on rooms built to a Casework Type. This creates the record with
  // exactly the same rule that function uses — a dimension is stamped only when
  // the run member set one.
  function createCabinetFor(node) {
    if (!editable || !node || node.kind !== 'ghost') return;
    const room = rooms.find(r => r.id === node.roomId);
    const m = node.member;
    const t = node.type;
    cwUpdate(ctx, project, draft => {
      const mark = (t ? t.code : 'CAB') + '-' + String(draft.caseworkItems.length + 1).padStart(2, '0');
      draft.caseworkItems.push(cwMakeCabinet({
        mark: mark, typeId: m.cabTypeId, roomId: node.roomId, runId: node.runId, memberKey: node.memberKey,
        scopeId: room ? room.scopeId : null, room: room ? room.name : '', unit: room ? room.unit : '',
        level: room ? room.level : '',
        width: m.width != null && m.width !== '' ? cw3dNum(m.width) : null,
        height: m.height != null && m.height !== '' ? cw3dNum(m.height) : null,
        depth: m.depth != null && m.depth !== '' ? cw3dNum(m.depth) : null,
        handing: m.handing || 'Auto',
      }, ctx.currentUserName));
    }, 'LEON Casework 3D — cabinet record created for ' + node.label + ' in ' + (room ? room.name : 'the room') + '.');
  }

  // ---- finishes. There is no per-part finish record in this module, so the
  // scopes offered here are the scopes the data model actually has, and the
  // panel says which record each one writes.
  function applyFinish(rec, scope) {
    if (!editable || !rec) return;
    const ref = makeSupplierFinishRef(rec);
    if (scope === 'cabinet' && selCab) {
      writeCab(selCab.id, { finish: ref },
        'LEON Casework 3D — ' + selCab.mark + ' finish set to ' + (ref.name || ref.code) + '.');
    } else if (scope === 'run' && selNode) {
      const ids = model.nodes.filter(n => n.runId === selNode.runId && n.cab).map(n => n.cab.id);
      cwUpdate(ctx, project, draft => {
        ids.forEach(id => { const c = draft.caseworkItems.find(x => x.id === id); if (c) c.finish = ref; });
      }, 'LEON Casework 3D — finish ' + (ref.name || ref.code) + ' applied to ' + ids.length + ' cabinet(s) on ' + selNode.run.name + '.');
    } else if (scope === 'type' && selCab && selCab.typeId) {
      const own = (project.caseworkTypes || []).find(t => t.id === selCab.typeId);
      if (!own) { setNote('That is a standard library type, which is shared code and cannot be edited. Import it to the project first, under Cabinet Types.'); return; }
      cwUpdate(ctx, project, draft => {
        const t = draft.caseworkTypes.find(x => x.id === selCab.typeId);
        if (t) t.finish = ref;
      }, 'LEON Casework 3D — cabinet type ' + own.code + ' finish set to ' + (ref.name || ref.code) + '.');
    }
  }

  // ---- saved views
  function saveView(name) {
    if (!editable) return;
    const v = viewRef.current;
    cwUpdate(ctx, project, draft => {
      if (!Array.isArray(draft.casework3dViews)) draft.casework3dViews = [];
      draft.casework3dViews.push({
        id: uid('cw3dview'), name: name || ('View ' + (draft.casework3dViews.length + 1)),
        roomId: roomId, theta: v.theta, phi: v.phi, radius: v.radius,
        target: { x: v.target.x, y: v.target.y, z: v.target.z },
        perspective: perspective, mode: mode, detail: detail, interiors: interiors,
        countertops: countertops, showRoom: showRoom,
        section: { on: section.on, axis: section.axis, pos: section.pos, flip: section.flip },
        hidden: Object.keys(hidden).filter(k => hidden[k]),
        by: ctx.currentUserName, date: todayISO(),
      });
    }, 'LEON Casework 3D — view saved.');
  }
  function loadView(v) {
    const cur = viewRef.current;
    cur.theta = v.theta; cur.phi = v.phi; cur.radius = v.radius;
    cur.target.x = v.target.x; cur.target.y = v.target.y; cur.target.z = v.target.z;
    setRoomId(v.roomId || 'all');
    setPerspective(v.perspective !== false);
    setMode(v.mode || 'shaded');
    setDetail(v.detail || 'parts');
    setInteriors(!!v.interiors);
    setCountertops(v.countertops !== false);
    setShowRoom(v.showRoom !== false);
    setSection(Object.assign({ on: false, axis: 'z', pos: 0.5, flip: false }, v.section || {}));
    const h = {}; (v.hidden || []).forEach(k => { h[k] = true; });
    setHidden(h);
  }
  function removeView(id) {
    if (!editable) return;
    cwUpdate(ctx, project, draft => {
      draft.casework3dViews = (draft.casework3dViews || []).filter(v => v.id !== id);
    }, 'LEON Casework 3D — saved view removed.');
  }

  // ---- camera actions
  function stdView(v) {
    const cur = viewRef.current;
    cur.theta = v.theta; cur.phi = v.phi;
    if (v.key !== 'iso') setPerspective(false);
  }
  function zoomExtents() {
    const T = three.current;
    if (T && T.bounds) cw3dFrame(viewRef.current, T.bounds);
  }
  function fitSelected() {
    const T = three.current;
    if (!T || !sel || !sel.nodeId) return;
    const g = T.nodesById[sel.nodeId];
    if (!g) return;
    const bb = new THREE.Box3().setFromObject(g);
    if (isFinite(bb.min.x)) cw3dFrame(viewRef.current, bb, 2.1);
  }
  // Framing has to be done from the ids, not from `sel` — a setTimeout closure
  // captures the selection as it was BEFORE setSel, so calling fitSelected on a
  // delay would frame whatever had been selected a moment earlier.
  function frameNodes(ids) {
    const T = three.current;
    if (!T) return;
    const bb = new THREE.Box3();
    ids.forEach(id => { const g = T.nodesById[id]; if (g) bb.union(new THREE.Box3().setFromObject(g)); });
    if (isFinite(bb.min.x)) cw3dFrame(viewRef.current, bb, 2.0);
  }
  function focusWarning(w) {
    if (w.nodeId) { setSel({ nodeId: w.nodeId }); frameNodes([w.nodeId]); return; }
    if (w.runId) {
      const ids = model.nodes.filter(x => x.runId === w.runId).map(x => x.id);
      if (ids.length) { setSel({ nodeId: ids[0] }); frameNodes(ids); }
      return;
    }
    if (w.roomId) frameNodes(model.nodes.filter(x => x.roomId === w.roomId).map(x => x.id));
  }

  // ═══════════ render ═══════════
  if (missing.length) {
    return (
      <div className="rounded-lg border border-[var(--leon-red)]/40 bg-white p-4 text-sm space-y-2">
        <div className="font-bold">The 3D Studio cannot start.</div>
        <p className="text-[var(--leon-black)]/70">
          It is a view of the casework records and needs the casework engine and Three.js already loaded.
          Missing right now: <b>{missing.join(', ')}</b>. This file must load AFTER
          {' '}<code>softwares/casework.jsx</code> and after <code>vendor/three.min.js</code> in
          {' '}<code>index.html</code>.
        </p>
      </div>
    );
  }
  if (!project) {
    return <EmptyState text="Pick a project. The 3D Studio draws the rooms, runs and cabinets already on the job — it has nothing of its own to show." />;
  }

  const savedViews = project.casework3dViews || [];
  const errCount = model.warnings.filter(w => w.level === 'error').length;
  const warnCount = model.warnings.filter(w => w.level === 'warn').length;
  const meshHeavy = model.counted > 60 && detail === 'parts';

  return (
    <div className="space-y-3">
      {/* ---- header ------------------------------------------------------ */}
      <div className="flex items-start justify-between gap-3 flex-wrap">
        <div>
          <h3 className="font-bold">3D Studio</h3>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
            Not a second model — a <b>view of the casework records</b>. Every box below is generated from
            {' '}<code>cwComputeParts</code> and the cabinet's own width, height and depth. Type a new width in
            the properties panel and the run arithmetic, the elevation, the part sizes and this model all move
            together, because none of them was ever written down separately.
          </p>
        </div>
        <div className="flex items-end gap-2 flex-wrap">
          <Field label="Room">
            <Select className="!w-52" value={roomId} onChange={e => { setRoomId(e.target.value); setSel(null); }}>
              <option value="all">All rooms</option>
              {rooms.map(r => <option key={r.id} value={r.id}>{r.name}{r.unit ? ' — ' + r.unit : ''}</option>)}
            </Select>
          </Field>
          <Field label="Units">
            <Select className="!w-28" value={system} onChange={e => setSystem(e.target.value)}>
              <option>Imperial</option><option>Metric</option>
            </Select>
          </Field>
        </div>
      </div>

      {!rooms.length && (
        <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-4 text-sm text-[var(--leon-black)]/60">
          This job has no casework rooms yet. Add one under <b>Rooms &amp; Walls</b> and give its walls their
          lengths — the 3D view builds the room from those records and does not ask you to model a wall twice.
        </div>
      )}

      {/* ---- the three-column workspace ---------------------------------- */}
      <div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)_320px]">

        {/* ── left: the cabinet library, and placement ── */}
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-2.5 space-y-2 max-h-[640px] overflow-y-auto">
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Place a cabinet</div>
          {!editable && <p className="text-[11px] text-[var(--leon-black)]/50">Read-only — you can look, orbit and section, but not place or edit.</p>}
          {editable && (
            <p className="text-[11px] text-[var(--leon-black)]/50">
              Pick a type, then click a wall. It joins that wall's run at the position you clicked, hard against
              its neighbour — a run has no free coordinates, so there is nothing to snap.
            </p>
          )}
          {armed && (
            <div className="rounded-md bg-[var(--leon-cream)] border border-[var(--leon-brown)]/40 p-2 text-[11px]">
              <b>{(types.find(t => t.id === armed) || {}).code}</b> is armed. Click a wall in the viewport.
              {!showRoom && (
                <span className="block mt-1 text-[var(--leon-red)]">
                  The room is hidden, so there is no wall to click.{' '}
                  <button className="underline font-semibold" onClick={() => setShowRoom(true)}>Show it</button>.
                </span>
              )}
              <button className="block mt-1 font-semibold text-[var(--leon-red)]" onClick={() => setArmed('')}>Cancel</button>
            </div>
          )}
          {['Base', 'Wall', 'Tall', 'Vanity', 'Custom'].map(catg => {
            const list = types.filter(t => t.category === catg);
            if (!list.length) return null;
            return (
              <div key={catg}>
                <div className="text-[11px] font-bold text-[var(--leon-black)]/60 mt-1.5 mb-1">{catg}</div>
                <div className="space-y-1">
                  {list.map(t => (
                    <button key={t.id} disabled={!editable}
                      onClick={() => setArmed(armed === t.id ? '' : t.id)}
                      className={'w-full text-left px-2 py-1.5 rounded-md border text-[11px] disabled:opacity-40 ' +
                        (armed === t.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] hover:border-[var(--leon-brown)]')}>
                      <div className="font-bold">{t.code}</div>
                      <div className="text-[var(--leon-black)]/55">
                        {fmtDim(cw3dNum(t.width), system, { inchesOnly: true })} x {fmtDim(cw3dNum(t.height), system, { inchesOnly: true })} x {fmtDim(cw3dNum(t.depth), system, { inchesOnly: true })}
                      </div>
                    </button>
                  ))}
                </div>
              </div>
            );
          })}
        </div>

        {/* ── centre: the viewport ── */}
        <div className="space-y-2 min-w-0">
          <div ref={boxRef} className="relative rounded-lg border border-[var(--leon-line)] bg-[#f2ede6] overflow-hidden"
               style={{ height: '560px' }}>
            <div ref={mountRef} className="absolute inset-0" />

            {/* on-screen controls, because nobody should have to already know
                that shift-drag pans before they can use the tool */}
            <div className="absolute top-2 left-2 flex flex-col gap-1.5 pointer-events-none">
              <div className="flex gap-1 flex-wrap pointer-events-auto">
                {CW3D_STD_VIEWS.map(v => (
                  <Cw3DBtn key={v.key} onClick={() => stdView(v)} title={'Look from the ' + v.label.toLowerCase()}>{v.label}</Cw3DBtn>
                ))}
              </div>
              <div className="flex gap-1 flex-wrap pointer-events-auto">
                <Cw3DBtn onClick={zoomExtents} title="Frame everything in the scene">Zoom extents</Cw3DBtn>
                <Cw3DBtn onClick={fitSelected} disabled={!sel || !sel.nodeId} title="Frame the selected cabinet">Fit selected</Cw3DBtn>
                <Cw3DBtn onClick={() => setPerspective(!perspective)} active={!perspective}
                  title="Orthographic removes perspective, which is what you want to read an elevation off the model">
                  {perspective ? 'Perspective' : 'Orthographic'}
                </Cw3DBtn>
              </div>
              <div className="flex gap-1 flex-wrap pointer-events-auto">
                {CW3D_MODES.map(m => (
                  <Cw3DBtn key={m.key} active={mode === m.key} onClick={() => setMode(m.key)}>{m.label}</Cw3DBtn>
                ))}
              </div>
              <div className="flex gap-1 flex-wrap pointer-events-auto">
                <Cw3DBtn active={showRoom} onClick={() => setShowRoom(!showRoom)}>Room</Cw3DBtn>
                <Cw3DBtn active={countertops} onClick={() => setCountertops(!countertops)}>Countertops</Cw3DBtn>
                <Cw3DBtn active={interiors} onClick={() => setInteriors(!interiors)} title="Draw the drawer boxes as well as the fronts">Drawer boxes</Cw3DBtn>
                <Cw3DBtn active={detail === 'blocks'} onClick={() => setDetail(detail === 'blocks' ? 'parts' : 'blocks')}
                  title="One box per cabinet instead of every part — for a job with hundreds of cabinets">Blocks</Cw3DBtn>
              </div>
            </div>

            {/* a plain, readable view cube stand-in: the six faces, named */}
            <div className="absolute top-2 right-2 pointer-events-auto">
              <div className="grid grid-cols-3 gap-0.5 w-[92px]">
                <div />
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[1])} title="Top">Top</Cw3DBtn>
                <div />
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[4])} title="Left">L</Cw3DBtn>
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[0])} title="Isometric">Iso</Cw3DBtn>
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[5])} title="Right">R</Cw3DBtn>
                <div />
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[2])} title="Front">Fr</Cw3DBtn>
                <Cw3DBtn onClick={() => stdView(CW3D_STD_VIEWS[3])} title="Back">Bk</Cw3DBtn>
              </div>
            </div>

            <div className="absolute bottom-2 left-2 right-2 flex items-end justify-between gap-2 pointer-events-none">
              <div className="pointer-events-auto rounded-md bg-white/92 border border-[var(--leon-line)] px-2.5 py-1.5 text-[11px] space-y-1">
                <label className="flex items-center gap-2">
                  <span className="w-24 shrink-0">Open fronts</span>
                  <input type="range" min="0" max="1" step="0.02" value={openAmt}
                    onChange={e => setOpenAmt(parseFloat(e.target.value))} className="w-28" />
                  <span className="w-8 text-right">{Math.round(openAmt * 100)}%</span>
                </label>
                <label className="flex items-center gap-2">
                  <span className="w-24 shrink-0">Exploded</span>
                  <input type="range" min="0" max="1" step="0.02" value={explode}
                    onChange={e => setExplode(parseFloat(e.target.value))} className="w-28" />
                  <span className="w-8 text-right">{Math.round(explode * 100)}%</span>
                </label>
                <div className="text-[10px] text-[var(--leon-black)]/45">
                  Visualisation only. Neither of these writes anything.
                </div>
              </div>
              <div className="pointer-events-auto rounded-md bg-white/92 border border-[var(--leon-line)] px-2.5 py-1.5 text-[11px] space-y-1">
                <label className="flex items-center gap-2">
                  <input type="checkbox" checked={section.on} onChange={e => setSection(Object.assign({}, section, { on: e.target.checked }))} />
                  <span className="font-semibold">Section plane</span>
                </label>
                {section.on && (
                  <div className="space-y-1">
                    <div className="flex gap-1">
                      {['x', 'y', 'z'].map(a => (
                        <Cw3DBtn key={a} active={section.axis === a}
                          onClick={() => setSection(Object.assign({}, section, { axis: a }))}>{a.toUpperCase()}</Cw3DBtn>
                      ))}
                      <Cw3DBtn active={section.flip} onClick={() => setSection(Object.assign({}, section, { flip: !section.flip }))}>Flip</Cw3DBtn>
                    </div>
                    <input type="range" min="0" max="1" step="0.005" value={section.pos}
                      onChange={e => setSection(Object.assign({}, section, { pos: parseFloat(e.target.value) }))}
                      className="w-40" />
                  </div>
                )}
              </div>
            </div>

            <div className="absolute bottom-2 left-1/2 -translate-x-1/2 text-[10px] text-[var(--leon-black)]/45 pointer-events-none">
              drag to orbit · shift-drag or right-drag to pan · wheel to zoom · click to select
            </div>
          </div>

          <div className="flex items-center gap-3 flex-wrap text-[11px] text-[var(--leon-black)]/55">
            <span>{model.counted} cabinet{model.counted === 1 ? '' : 's'} drawn</span>
            <span>·</span>
            <span>{model.rooms.length} room{model.rooms.length === 1 ? '' : 's'}</span>
            {model.unplaced.length ? <><span>·</span><span className="text-[var(--leon-yellow)]">{model.unplaced.length} cabinet(s) not in a run, parked in front</span></> : null}
            {meshHeavy ? <><span>·</span><span>Large model — switch to <b>Blocks</b> if it feels slow.</span></> : null}
          </div>

          {note && (
            <div className="rounded-md border border-[var(--leon-brown)]/40 bg-[var(--leon-cream)] px-3 py-2 text-xs flex items-start gap-2">
              <span className="flex-1">{note}</span>
              <button className="font-bold" onClick={() => setNote('')}>x</button>
            </div>
          )}
        </div>

        {/* ── right: properties, outliner, warnings, materials, views ── */}
        <div className="rounded-lg border border-[var(--leon-line)] bg-white flex flex-col max-h-[640px]">
          <div className="flex border-b border-[var(--leon-line)] text-[11px] font-semibold shrink-0">
            {[['props', 'Properties'], ['outline', 'Outliner'],
              ['warn', 'Checks' + (errCount + warnCount ? ' (' + (errCount + warnCount) + ')' : '')],
              ['mats', 'Materials'], ['views', 'Views']].map(t => (
              <button key={t[0]} onClick={() => setPanel(t[0])}
                className={'flex-1 px-1.5 py-2 border-b-2 ' + (panel === t[0]
                  ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]'
                  : 'border-transparent text-[var(--leon-black)]/50 hover:text-[var(--leon-black)]')}>
                {t[1]}
              </button>
            ))}
          </div>
          <div className="p-3 overflow-y-auto text-sm flex-1">
            {panel === 'props' && (
              <Cw3DProperties
                ctx={ctx} project={project} editable={editable} system={system}
                node={selNode} cab={selCab} sel={sel} model={model}
                onDim={setDim} onClearDim={clearDim} onWrite={writeCab}
                onMove={moveMember} onCopy={copyMember} onArray={arrayMember} onRemove={removeMember}
                onCreate={createCabinetFor}
                nudge={nudge} setNudge={setNudge} nudgeAxis={nudgeAxis} setNudgeAxis={setNudgeAxis}
                arrayN={arrayN} setArrayN={setArrayN} onNudge={applyNudge} />
            )}
            {panel === 'outline' && (
              <Cw3DOutliner model={model} sel={sel} hidden={hidden} system={system}
                onSelect={s => { setSel(s); setPanel('props'); }}
                onToggle={k => setHidden(Object.assign({}, hidden, { [k]: !hidden[k] }))}
                onShowAll={() => setHidden({})} />
            )}
            {panel === 'warn' && <Cw3DWarnings model={model} onFocus={focusWarning} />}
            {panel === 'mats' && (
              <Cw3DMaterials ctx={ctx} editable={editable} cab={selCab} node={selNode} project={project}
                texMm={texMm} setTexMm={setTexMm} onApply={applyFinish} mode={mode} setMode={setMode} />
            )}
            {panel === 'views' && (
              <Cw3DViews views={savedViews} editable={editable} onSave={saveView} onLoad={loadView} onRemove={removeView} />
            )}
          </div>
        </div>
      </div>

      {/* ---- what this does not do -------------------------------------- */}
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 text-sm text-[var(--leon-black)]/65 space-y-2">
        <div className="font-semibold text-[var(--leon-black)]">What the 3D Studio does not do</div>
        <p>
          <b>No SKP, IFC, FBX or DWG import or export.</b> Those are large proprietary or heavyweight formats
          and a browser page with no backend cannot read or write them honestly. <b>No GLB export either</b> —
          Three.js r149 ships no exporter here and hand-writing a glTF binary would be a second geometry
          pipeline to keep in step with the first, which is exactly what this module is built to avoid.
        </p>
        <p>
          <b>No photoreal rendering.</b> There are no shadows, no reflections, no global illumination and no
          render queue. This is a review model — the shading exists to make the shape readable, not to sell it.
          For imagery, LEON Image &amp; Render Studio works on real renders.
        </p>
        <p>
          <b>No AI layout and no natural-language commands.</b> Nothing here calls a model, and the app has no
          backend that could.
        </p>
        <p>
          <b>No free-form push/pull.</b> A cabinet is a structured record — a width, a height, a depth, a row
          list, a construction standard. Dragging a face to an arbitrary shape would produce geometry no cut
          list could ever be derived from. Every edit here writes a field and the mesh is rebuilt from it; when
          a change cannot be written as a field, the tool says which field is missing rather than deforming
          the mesh.
        </p>
        <p>
          Two values on screen are <b>drawing conventions, not records</b>, and are labelled where they appear:
          wall thickness (no wall record carries one) and countertop thickness and overhang (the real piece is a
          LEON Stone record referenced by the cabinet). Walls are laid end to end in their listed order, turning
          ninety degrees at each corner, because a wall record carries a length and a height and not a plan angle.
        </p>
      </div>
    </div>
  );
}

// ---- helpers used by the scene builder ------------------------------------
function cw3dEdgeMat(pool, mode) {
  const k = 'edge:' + mode;
  let m = pool.mats.get(k);
  if (m) return m;
  m = new THREE.LineBasicMaterial({
    color: mode === 'hidden' ? 0x161311 : 0x6a5c4d,
    transparent: mode !== 'hidden', opacity: mode === 'hidden' ? 1 : 0.35,
  });
  pool.mats.set(k, m);
  return m;
}

function cw3dTexKeyFor(node) {
  if (node.cab && node.cab.finish && node.cab.finish.img) return node.cab.finish.img;
  if (node.type && node.type.finish && node.type.finish.img) return node.type.finish.img;
  return '';
}

// Parts of one cabinet into its group, with the explode vector precomputed so
// the slider is a transform update and never a rebuild.
function cw3dAddParts(group, node, tag, addBox, mode) {
  const boxes = node.boxes || [];
  const parts = node.parts;
  const cx = parts && parts.W ? parts.W / 2 : 0;
  const cy = parts && parts.H ? parts.H / 2 : 0;
  const cz = parts && parts.D ? parts.D / 2 : 0;
  const span = Math.max(parts && parts.W || 600, parts && parts.H || 700, parts && parts.D || 600);
  boxes.forEach(b => {
    const useTex = b.kind === 'front' || b.key === 'toekick' || String(b.key).indexOf('endpanel') === 0 || String(b.key).indexOf('filler') === 0;
    const mesh = addBox(group, b, Object.assign({}, tag, { texKey: useTex ? tag.texKey : '' }),
      node.kind === 'ghost' ? CW3D_COLORS.ghost : undefined);
    if (!mesh) return;
    const px = b.x + b.w / 2, py = b.y + b.h / 2, pz = b.z + b.d / 2;
    let dx = px - cx, dy = py - cy, dz = pz - cz;
    const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
    const amp = span * 0.55 * CW3D_MM;
    mesh.userData.cw3dExplode = { x: (dx / len) * amp, y: (dy / len) * amp, z: (dz / len) * amp };
    // The edge overlay is the mesh's shadow in every sense: it must follow the
    // same door swing and the same explode offset, so it is linked here.
    const edge = group.children[group.children.length - 1];
    if (edge && edge.userData && edge.userData.cw3dEdge) mesh.userData.cw3dEdgeRef = edge;
  });
}

function cw3dRetexture(T, mode, texMm, planes) {
  const pool = T.pool;
  T.meshes.forEach(mesh => {
    const color = mesh.userData.cw3dColor !== undefined ? mesh.userData.cw3dColor : 0xcccccc;
    const texKey = mesh.userData.cw3dTex;
    const useTex = mode === 'material' && texKey && pool.textures.has(texKey);
    if (useTex) {
      const b = mesh.userData.cw3dBox;
      const base = pool.textures.get(texKey);
      // Real-world scale: the swatch is laid at a stated physical size and the
      // repeat count follows the part. Stretching one image over a 2.4 m panel
      // would make a 40 mm grain read as a 400 mm one.
      const k = texKey + '@' + Math.round(texMm) + ':' + Math.round(b ? b.w : 0) + 'x' + Math.round(b ? b.h : 0);
      let tex = pool.textures.get(k);
      if (!tex) {
        tex = base.clone();
        tex.needsUpdate = true;
        tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping;
        tex.repeat.set(Math.max(0.05, (b ? b.w : texMm) / texMm), Math.max(0.05, (b ? b.h : texMm) / texMm));
        pool.textures.set(k, tex);
      }
      mesh.material = cw3dMaterial(pool, 'material', color, k, planes);
    } else {
      mesh.material = cw3dMaterial(pool, mode, color, null, planes);
    }
  });
}

function cw3dFrame(view, box, pad) {
  const size = new THREE.Vector3(); box.getSize(size);
  const c = new THREE.Vector3(); box.getCenter(c);
  const max = Math.max(size.x, size.y, size.z, 0.3);
  view.target.x = c.x; view.target.y = c.y; view.target.z = c.z;
  view.radius = (max * (pad || 1.5)) / (2 * Math.tan((CW3D_FOV * Math.PI) / 360));
  view.radius = Math.max(0.4, Math.min(150, view.radius));
}

// ═══════════════════════════════════════════════════════ side panels
function Cw3DProperties(props) {
  const { ctx, project, editable, system, node, cab, sel, model } = props;

  if (!node && !cab) {
    return (
      <div className="text-xs text-[var(--leon-black)]/55 space-y-2">
        <p><b>Nothing selected.</b> Click a cabinet, a wall or a countertop in the viewport.</p>
        <p>
          A cabinet's properties here are the record's own fields. Editing a width writes
          {' '}<code>cab.width</code> through <code>updateProject</code> and the mesh is rebuilt from the
          new part sizes — the model has no dimensions of its own to fall out of step.
        </p>
        {sel && sel.kind && <p className="pt-2 border-t border-[var(--leon-line)]"><b>{sel.label}</b> — this is room fabric, drawn from the wall record. Edit it under Rooms &amp; Walls.</p>}
      </div>
    );
  }

  if (node && node.kind === 'ghost') {
    return (
      <div className="space-y-3 text-xs">
        <div>
          <div className="font-bold text-sm">{node.label}</div>
          <div className="text-[var(--leon-black)]/55">{node.room.name} · {node.run.name} · {node.frame.wall.name}</div>
        </div>
        <div className="rounded-md border border-[var(--leon-yellow)]/50 bg-[var(--leon-cream)] p-2">
          <b>This is a planned cabinet, not a cabinet record.</b> The run member exists; no entry in
          {' '}<code>caseworkItems</code> does yet, so it has no mark, no status and no cut list. A room built
          to a Casework Type gets its records when the type is applied under <b>Casework Types</b>.
        </div>
        <dl className="grid grid-cols-2 gap-x-2 gap-y-1">
          <dt className="text-[var(--leon-black)]/50">Type</dt><dd>{node.type ? node.type.code : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Width</dt><dd>{fmtDim(node.w, system, { inchesOnly: true })}</dd>
          <dt className="text-[var(--leon-black)]/50">Height</dt><dd>{fmtDim(node.h, system, { inchesOnly: true })}</dd>
          <dt className="text-[var(--leon-black)]/50">Depth</dt><dd>{fmtDim(node.d, system, { inchesOnly: true })}</dd>
        </dl>
        {editable && (
          <Button size="sm" variant="outline" onClick={() => props.onCreate(node)}>Create the cabinet record</Button>
        )}
        <Cw3DMemberTools {...props} node={node} />
      </div>
    );
  }

  if (!cab) return <EmptyState text="Nothing selected." />;

  let res = null, built = null;
  try { res = cwResolve(project, cab); } catch (e) { res = null; }
  built = node && node.parts && !node.parts.error ? node.parts.built
    : (model.unplaced.find(u => u.cab.id === cab.id) || {}).parts;
  if (built && built.built) built = built.built;
  const own = f => res && res.ownFields.indexOf(f) >= 0;
  const typeName = res && res.type ? res.type.code + ' · ' + res.type.name : 'no cabinet type';

  const dims = [['width', 'Width'], ['height', 'Height'], ['depth', 'Depth']];

  return (
    <div className="space-y-3 text-xs">
      <div>
        <div className="font-bold text-sm">{cab.mark}</div>
        <div className="text-[var(--leon-black)]/55">
          {typeName}{node ? ' · ' + node.room.name + ' · ' + node.run.name : ' · not placed in a run'}
        </div>
      </div>

      <div className="space-y-2">
        <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">
          Parameters
        </div>
        {dims.map(d => (
          <div key={d[0]} className="flex items-center gap-2">
            <span className="w-14 shrink-0 text-[var(--leon-black)]/60">{d[1]}</span>
            <div className="flex-1">
              <Cw3DDimInput
                system={system} disabled={!editable}
                value={res ? res[d[0]] : null}
                title={'Type 33", 840 mm or 2\'-9". The mesh is regenerated from the new part sizes.'}
                onCommit={mm => props.onDim(cab.id, d[0], mm)} />
            </div>
            {own(d[0])
              ? <button disabled={!editable} title="Go back to inheriting this from the cabinet type"
                  className="text-[10px] font-semibold text-[var(--leon-brown)] disabled:opacity-40"
                  onClick={() => props.onClearDim(cab.id, d[0])}>set here</button>
              : <span className="text-[10px] text-[var(--leon-black)]/40 w-14">inherited</span>}
          </div>
        ))}
        <p className="text-[10px] text-[var(--leon-black)]/45">
          A value typed here becomes an override on this cabinet and is shown as one everywhere.
          {' '}<b>set here</b> clears it and the cabinet follows its type again.
        </p>
      </div>

      <div className="space-y-1.5 pt-2 border-t border-[var(--leon-line)]">
        <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Configuration</div>
        <dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-2 gap-y-1">
          <dt className="text-[var(--leon-black)]/50">Fronts</dt>
          <dd>{built ? built.doorCount + ' door(s), ' + built.drawerCount + ' drawer front(s)' : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Rows</dt>
          <dd>{res ? (res.rows || []).map(r => r.kind + (r.count > 1 ? ' x' + r.count : '')).join(' / ') : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Shelves</dt><dd>{res ? res.shelfCount : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Front style</dt><dd>{res ? res.frontStyle : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Construction</dt><dd>{built ? built.con.name : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Finish</dt>
          <dd className="flex items-center gap-1.5">
            {res && res.finish
              ? <>{res.finish.img && <img src={res.finish.img} alt="" className="w-5 h-5 rounded object-cover" />}
                  <span>{res.finish.name || res.finish.code}</span></>
              : <span className="text-[var(--leon-black)]/40">not set</span>}
          </dd>
          <dt className="text-[var(--leon-black)]/50">Parts</dt><dd>{built ? built.panels.length : '—'}</dd>
          <dt className="text-[var(--leon-black)]/50">Handing</dt><dd>{cab.handing || 'Auto'}</dd>
        </dl>
        <div className="flex items-center gap-2 pt-1">
          <span className="w-14 shrink-0 text-[var(--leon-black)]/60">Status</span>
          <Select className="!text-xs" value={cab.status} disabled={!editable}
            onChange={e => props.onWrite(cab.id, { status: e.target.value },
              'LEON Casework 3D — ' + cab.mark + ' status set to ' + e.target.value + '.')}>
            {(typeof CW_STATUSES !== 'undefined' ? CW_STATUSES : ['Draft']).map(s => <option key={s}>{s}</option>)}
          </Select>
        </div>
        <div className="flex items-center gap-2">
          <span className="w-14 shrink-0 text-[var(--leon-black)]/60">Handing</span>
          <Select className="!text-xs" value={cab.handing || 'Auto'} disabled={!editable}
            onChange={e => props.onWrite(cab.id, { handing: e.target.value },
              'LEON Casework 3D — ' + cab.mark + ' handing set to ' + e.target.value + '.')}>
            <option>Auto</option><option value="L">L — hinges left</option><option value="R">R — hinges right</option>
          </Select>
        </div>
      </div>

      {built && built.issues && built.issues.length > 0 && (
        <div className="pt-2 border-t border-[var(--leon-line)] space-y-1">
          <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">From the part engine</div>
          {built.issues.slice(0, 6).map((i, k) => (
            <div key={k} className="flex gap-1.5 items-start">
              <Cw3DLevelDot level={i.level} />
              <span className="text-[11px] text-[var(--leon-black)]/70">{i.msg}</span>
            </div>
          ))}
        </div>
      )}

      {node && <Cw3DMemberTools {...props} node={node} />}
    </div>
  );
}

// Move, copy, array and typed exact input — every one of them a change to a
// run member or a run field, because those are the only positions a run has.
function Cw3DMemberTools(props) {
  const { editable, node, system, nudge, setNudge, nudgeAxis, setNudgeAxis, arrayN, setArrayN } = props;
  if (!node || !node.runId) return null;
  return (
    <div className="pt-2 border-t border-[var(--leon-line)] space-y-2">
      <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Move, copy, array</div>
      <div className="flex gap-1 flex-wrap">
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onMove(node, -1)}>{'<- Left'}</Button>
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onMove(node, 1)}>{'Right ->'}</Button>
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onCopy(node)}>Copy</Button>
      </div>
      <div className="flex items-center gap-1.5">
        <span className="text-[var(--leon-black)]/60">Array</span>
        <input type="number" min="1" max="40" value={arrayN} disabled={!editable}
          onChange={e => setArrayN(e.target.value)}
          className="w-14 rounded-md border border-[var(--leon-line)] px-1.5 py-1 text-xs" />
        <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onArray(node, arrayN)}>more, along the run</Button>
      </div>

      <div className="space-y-1">
        <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Exact move</div>
        <div className="flex gap-1">
          {[['along', 'Along wall'], ['gap', 'By a gap'], ['height', 'Mounting height']].map(a => (
            <Cw3DBtn key={a[0]} active={nudgeAxis === a[0]} onClick={() => setNudgeAxis(a[0])}>{a[1]}</Cw3DBtn>
          ))}
        </div>
        <div className="flex items-center gap-1.5">
          <input value={nudge} onChange={e => setNudge(e.target.value)} disabled={!editable}
            placeholder={'6" or 150 mm'}
            className="flex-1 rounded-md border border-[var(--leon-line)] px-2 py-1 text-xs" />
          <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onNudge(node, -1)}>-</Button>
          <Button size="sm" variant="outline" disabled={!editable} onClick={() => props.onNudge(node, 1)}>+</Button>
        </div>
        <p className="text-[10px] text-[var(--leon-black)]/45">
          <b>Along wall</b> moves the whole run's start offset. <b>Mounting height</b> moves the run off the
          floor. <b>By a gap</b> inserts or grows a reserved gap before this cabinet — because a run stores an
          ORDER, not coordinates, and there is no per-cabinet position field to write. Anything the records
          cannot express is refused rather than faked.
        </p>
      </div>

      {editable && (
        <button className="text-[11px] font-semibold text-[var(--leon-red)]"
          onClick={() => { if (confirm('Remove ' + node.label + ' from ' + node.run.name + '?')) props.onRemove(node); }}>
          Remove from the run
        </button>
      )}
    </div>
  );
}

function Cw3DOutliner({ model, sel, hidden, onSelect, onToggle, onShowAll, system }) {
  return (
    <div className="space-y-2 text-xs">
      <div className="flex items-center justify-between">
        <span className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Outliner</span>
        <button className="text-[11px] font-semibold text-[var(--leon-brown)]" onClick={onShowAll}>Show all</button>
      </div>
      {!model.rooms.length && <EmptyState text="Nothing to list." />}
      {model.rooms.map(rm => (
        <div key={rm.room.id} className="space-y-1">
          <div className="flex items-center gap-1.5 font-bold">
            <button className="w-4 text-left" title="Show or hide this room's fabric"
              onClick={() => onToggle('room:' + rm.room.id)}>{hidden['room:' + rm.room.id] ? 'o' : '@'}</button>
            <span>{rm.room.name}</span>
            {rm.fromType && <Badge>{rm.typeCode}{rm.mirrored ? ' mirrored' : ''}</Badge>}
          </div>
          {rm.frames.map(f => (
            <div key={f.wall.id} className="pl-3">
              <div className="flex items-center gap-1.5 text-[var(--leon-black)]/60">
                <button className="w-4 text-left" onClick={() => onToggle('wall:' + rm.room.id + ':' + f.wall.id)}>
                  {hidden['wall:' + rm.room.id + ':' + f.wall.id] ? 'o' : '@'}
                </button>
                <span>{f.wall.name}</span>
                <span className="text-[10px]">{fmtDim(f.length, system, { inchesOnly: true })}</span>
              </div>
              {rm.runs.filter(e => e.frame.wall.id === f.wall.id).map(entry => (
                <div key={entry.run.id} className="pl-4">
                  <div className="text-[var(--leon-black)]/55 flex items-center gap-1.5">
                    <span>{entry.run.name}</span>
                    <span className={'text-[10px] ' + (entry.layout.over ? 'text-[var(--leon-red)]' : '')}>
                      {entry.layout.over ? 'over by ' + Math.round(-entry.layout.remaining) : Math.round(entry.layout.remaining) + ' left'}
                    </span>
                  </div>
                  {entry.nodes.map(n => (
                    <div key={n.id} className="pl-4 flex items-center gap-1.5">
                      <button className="w-4 text-left" onClick={() => onToggle('node:' + n.id)}>
                        {hidden['node:' + n.id] ? 'o' : '@'}
                      </button>
                      <button onClick={() => onSelect({ nodeId: n.id, cabId: n.cab ? n.cab.id : null })}
                        className={'text-left flex-1 truncate ' + (sel && sel.nodeId === n.id ? 'font-bold text-[var(--leon-brown)]' : '')}>
                        {n.label}
                      </button>
                      <span className="text-[10px] text-[var(--leon-black)]/40">{fmtDim(n.w, system, { inchesOnly: true })}</span>
                    </div>
                  ))}
                </div>
              ))}
            </div>
          ))}
        </div>
      ))}
      {model.unplaced.length > 0 && (
        <div className="pt-2 border-t border-[var(--leon-line)]">
          <div className="font-bold">Not placed in a run</div>
          {model.unplaced.map(u => (
            <div key={u.cab.id} className="pl-3 flex items-center gap-1.5">
              <button className="w-4 text-left" onClick={() => onToggle('cab:' + u.cab.id)}>
                {hidden['cab:' + u.cab.id] ? 'o' : '@'}
              </button>
              <button className="text-left flex-1 truncate" onClick={() => onSelect({ cabId: u.cab.id })}>{u.cab.mark}</button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function Cw3DWarnings({ model, onFocus }) {
  const list = model.warnings;
  const errs = list.filter(w => w.level === 'error');
  const warns = list.filter(w => w.level === 'warn');
  const infos = list.filter(w => w.level !== 'error' && w.level !== 'warn');
  function row(w, k) {
    return (
      <button key={w.id + ':' + k} onClick={() => onFocus(w)}
        className="w-full text-left flex gap-1.5 items-start px-1.5 py-1 rounded hover:bg-[var(--leon-cream)]">
        <Cw3DLevelDot level={w.level} />
        <span className="flex-1">
          <span className="text-[11px]">{w.msg}</span>
          <span className="block text-[10px] text-[var(--leon-black)]/40">{w.source}</span>
        </span>
      </button>
    );
  }
  return (
    <div className="space-y-2 text-xs">
      <p className="text-[11px] text-[var(--leon-black)]/55">
        Everything here <b>warns and never blocks</b> — only a production release should stop work. The run
        arithmetic, obstruction clashes and appliance openings are the casework engine's own checks, read
        straight from <code>cwRunLayout</code>; the walkway, door swing and ceiling checks are the ones that
        only exist once the runs share a space. Click any line to frame it.
      </p>
      {!list.length && <EmptyState text="Nothing flagged." />}
      {errs.length > 0 && <div><div className="text-[11px] font-bold text-[var(--leon-red)]">Errors ({errs.length})</div>{errs.map(row)}</div>}
      {warns.length > 0 && <div><div className="text-[11px] font-bold text-[var(--leon-yellow)]">Warnings ({warns.length})</div>{warns.map(row)}</div>}
      {infos.length > 0 && <div><div className="text-[11px] font-bold text-[var(--leon-black)]/50">Notes ({infos.length})</div>{infos.map(row)}</div>}
    </div>
  );
}

function Cw3DMaterials({ ctx, editable, cab, node, project, texMm, setTexMm, onApply, mode, setMode }) {
  const [q, setQ] = useState('');
  const [sup, setSup] = useState('');
  const [scope, setScope] = useState('cabinet');
  const groups = typeof supplierGroups === 'function' ? supplierGroups() : [];
  const results = useMemo(() => {
    if (typeof searchSupplierFinishes !== 'function') return [];
    return searchSupplierFinishes(sup || '', '', q, 24);
  }, [q, sup]);

  return (
    <div className="space-y-2 text-xs">
      <p className="text-[11px] text-[var(--leon-black)]/55">
        Finishes come from the real supplier catalogs — the same records the Selection Hub and the vendor
        pages read. Nothing is retyped here; a selection stores a reference.
      </p>
      {mode !== 'material' && (
        <button className="text-[11px] font-semibold text-[var(--leon-brown)]" onClick={() => setMode('material')}>
          Switch the viewport to Shaded + materials to see them
        </button>
      )}
      <div className="flex gap-1.5">
        <Select className="!text-xs !w-32" value={sup} onChange={e => setSup(e.target.value)}>
          <option value="">All suppliers</option>
          {groups.map(g => <option key={g.key} value={g.key}>{g.label}</option>)}
        </Select>
        <TextInput className="!text-xs" value={q} onChange={e => setQ(e.target.value)} placeholder="Search a finish..." />
      </div>

      <div>
        <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">Apply to</div>
        <div className="flex gap-1 flex-wrap">
          <Cw3DBtn active={scope === 'cabinet'} onClick={() => setScope('cabinet')}>This cabinet</Cw3DBtn>
          <Cw3DBtn active={scope === 'run'} onClick={() => setScope('run')} disabled={!node}>This run</Cw3DBtn>
          <Cw3DBtn active={scope === 'type'} onClick={() => setScope('type')} disabled={!cab || !cab.typeId}>This type</Cw3DBtn>
        </div>
        <p className="text-[10px] text-[var(--leon-black)]/45 mt-1">
          <b>There is no per-part finish record in this module</b>, so there is no per-part scope to offer.
          A part's look comes from its material, and a material is shared across every cabinet that uses it —
          which is a real decision, not a per-part one, and belongs under <b>Construction</b> and
          {' '}<b>Global Library</b>. These three scopes each write one field: <code>cab.finish</code>,
          the same field on every cabinet in the run, or <code>type.finish</code> on a project cabinet type.
        </p>
      </div>

      <label className="flex items-center gap-2">
        <span className="text-[var(--leon-black)]/60 shrink-0">Swatch size</span>
        <input type="number" min="50" max="3000" step="50" value={texMm}
          onChange={e => setTexMm(Math.max(50, parseInt(e.target.value, 10) || 600))}
          className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-1 text-xs" />
        <span className="text-[10px] text-[var(--leon-black)]/45">mm</span>
      </label>
      <p className="text-[10px] text-[var(--leon-black)]/45">
        The catalogs publish a photograph, not a physical swatch size, so the real-world scale is set here and
        the texture repeats to it. A finish is never stretched to fit a panel.
      </p>

      {!cab && <EmptyState text="Select a cabinet first." />}
      {cab && (
        <div className="grid grid-cols-3 gap-1.5">
          {results.map(r => (
            <button key={r.sup + ':' + r.id} disabled={!editable}
              onClick={() => onApply(r, scope)}
              title={(r.supLabel || '') + ' — ' + (r.name || r.code)}
              className="rounded-md border border-[var(--leon-line)] overflow-hidden hover:border-[var(--leon-brown)] disabled:opacity-40 text-left">
              {r.img
                ? <img src={r.img} alt="" className="w-full h-12 object-cover" />
                : <div className="w-full h-12 bg-[var(--leon-cream)]" />}
              <div className="px-1 py-0.5 text-[9px] leading-tight truncate">{r.name || r.code}</div>
            </button>
          ))}
        </div>
      )}
      {cab && !results.length && <EmptyState text="No finishes match that search." />}
    </div>
  );
}

function Cw3DViews({ views, editable, onSave, onLoad, onRemove }) {
  const [name, setName] = useState('');
  return (
    <div className="space-y-2 text-xs">
      <p className="text-[11px] text-[var(--leon-black)]/55">
        A saved view stores the camera, the projection, the display mode and what is hidden. It stores no
        geometry — reopening it re-reads the records, so a view saved last week shows this week's cabinets.
      </p>
      {editable && (
        <div className="flex gap-1.5">
          <TextInput className="!text-xs" value={name} onChange={e => setName(e.target.value)} placeholder="View name" />
          <Button size="sm" onClick={() => { onSave(name); setName(''); }}>Save</Button>
        </div>
      )}
      {!views.length && <EmptyState text="No saved views yet." />}
      {views.map(v => (
        <div key={v.id} className="flex items-center gap-1.5 px-1.5 py-1 rounded hover:bg-[var(--leon-cream)]">
          <button className="flex-1 text-left" onClick={() => onLoad(v)}>
            <div className="font-semibold">{v.name}</div>
            <div className="text-[10px] text-[var(--leon-black)]/45">
              {v.mode} · {v.perspective === false ? 'orthographic' : 'perspective'} · {v.by} · {fmtDate(v.date)}
            </div>
          </button>
          {editable && <button className="text-[10px] font-semibold text-[var(--leon-red)]" onClick={() => onRemove(v.id)}>Remove</button>}
        </div>
      ))}
    </div>
  );
}
