// ============================================================================
// LEON Softwares — Surface Layout & Finish Designer
// ----------------------------------------------------------------------------
// Two ideas hold this module up. Everything else is detail.
//
//  1. CONNECTED GEOMETRY. A bathroom is one room, not five unrelated
//     elevations. The floor and the four walls are created together and the
//     EDGES THAT MEET ARE STORED as records (surfCorner), so "continue the
//     course around the corner" is a lookup, not a guess a drafter makes twice.
//
//  2. INHERITANCE. A Room Type is designed once; the physical Rooms that use
//     it read straight through to it. A Room stores ONLY what it disagrees
//     with. That is why an override is a single flat path/value pair — see
//     surfEffectiveRoomType.
//
// NOT BUILT, and the UI says so where it would be expected: no 3D view, no
// DWG/DXF export, no drag-and-drop canvas editing, and no AI layout. The
// drawings here are computed SVG, and the set-out is parametric.
// ============================================================================

// ---- vocabularies ---------------------------------------------------------

const SURF_WALL_KEYS = ['north', 'east', 'south', 'west'];
const SURF_WALL_LABELS = { north: 'North', east: 'East', south: 'South', west: 'West' };
const SURF_SURFACE_KINDS = ['Floor', 'Wall', 'Ceiling', 'Shower Wall', 'Shower Floor', 'Curb', 'Bench', 'Tub Surround'];
const SURF_ROOM_STATUSES = ['Not Started', 'In Design', 'Released for Construction', 'Installed'];
const SURF_CONTINUITY = ['Continuous', 'Independent', 'Custom'];

// 2" is the cut most tilers refuse to leave at a corner. Editable per room and
// per surface; this is only where the number starts.
const SURF_DEFAULT_MIN_CUT_MM = 50.8;
// THE GROUT JOINT, on screen. It was drawn as `--leon-line` (#e5ded4) at 0.55 px
// over a tile filled `--leon-cream` (#f7f3ee) — two colours a shade apart, at
// half a pixel. The set-out was right and completely unreadable: adjacent tiles
// merged into one solid block, which is what "the tiles are not coming
// correctly" actually looks like.
//
// A real joint is 2-3 mm, which at any sensible zoom is a fraction of a pixel,
// so drawing it to scale is not an option either. It is drawn at a READABLE
// minimum instead, in a colour that reads against the tile — the joint on the
// screen shows WHERE the joints are; the joint WIDTH is the number in the
// set-out panel, and the drawing says so rather than implying it is to scale.
const SURF_JOINT_COLOR = '#a89684';
const SURF_JOINT_PX = 0.9;
// TCNA/ANSI: a tile with any side over 15" should not be offset more than a
// third, because a half offset lands the neighbouring tile on the crown of a
// slightly bowed large-format tile and lippages.
const SURF_LARGE_FORMAT_MM = 381;

const SURF_ORIGIN_X = [
  // `solved` is where the set-out solver leaves the field: an explicit phase
  // rather than one of the named positions. It is a real answer — "start 66 mm
  // in, because that is what keeps every cut off the minimum" — so it is stored
  // and shown like the others rather than pretending to be 'centre'.
  { key: 'solved', label: 'Solved — an explicit offset' },
  { key: 'left', label: 'Full tile at the left' },
  { key: 'center', label: 'Tile centred on the surface' },
  { key: 'centerJoint', label: 'Joint centred on the surface' },
  { key: 'right', label: 'Full tile at the right' },
];
const SURF_ORIGIN_Y = [
  { key: 'elevation', label: 'From a set starting elevation' },
  { key: 'bottom', label: 'Full course at the bottom' },
  { key: 'center', label: 'Course centred' },
  { key: 'top', label: 'Full course at the top' },
];

// A pattern is an offset rule plus what it is honest about. `offset` is the
// fraction of one horizontal module each course steps by.
const SURF_PATTERNS = [
  { key: 'straight', label: 'Straight Lay', offset: 0,
    note: 'A continuous grid both ways. Every joint lines through.' },
  { key: 'stack', label: 'Stack Bond', offset: 0,
    note: 'Sets out identically to straight lay — the name on the drawing differs, the set-out does not.' },
  { key: 'running_half', label: 'Running Bond ½', offset: 0.5,
    note: 'Each course steps half a tile. The classic brick bond.' },
  // RUNNING vs STAGGERED is a real distinction and the module only had one of
  // them. A running bond steps a further fraction on EVERY course, so the bond
  // repeats every 1/offset courses; a staggered bond alternates between two
  // positions only. At a half they are identical, which is why it took adding
  // the thirds and quarters for the difference to matter.
  { key: 'offset_third', label: '⅓ Running Bond', offset: 1 / 3,
    note: 'Each course steps a further third, repeating every three. Required where a half offset would lippage.' },
  { key: 'stagger_third', label: '⅓ Staggered', offset: 1 / 3, stagger: true,
    note: 'Alternates between two positions a third apart, rather than walking on. Two courses repeat.' },
  { key: 'offset_quarter', label: '¼ Running Bond', offset: 0.25,
    note: 'Each course steps a further quarter, repeating every four. How a plank floor is normally set out.' },
  { key: 'stagger_quarter', label: '¼ Staggered', offset: 0.25, stagger: true,
    note: 'Alternates between two positions a quarter apart. The gentlest stagger of all.' },
  { key: 'diagonal', label: 'Diagonal (45°)', offset: 0, diagonal: true,
    note: 'The grid turned 45° to the room. Every perimeter tile is a triangle, so this wastes more than any other square-tile layout — the count below is the real one, not an allowance.' },
  { key: 'largeformat', label: 'Large Format (⅓ max)', offset: 1 / 3, largeFormat: true,
    note: 'Large-format rule: any tile with a side over 15" (381 mm) is held to a ⅓ offset or less.' },
  { key: 'herringbone', label: 'Herringbone (90°)', offset: 0, herringbone: true,
    note: '90° herringbone closes only on a 2:1 tile (long side = twice the short side plus one grout joint). Tiles stay axis-aligned, so edge cuts are still rectangular and are listed — but there are no continuous horizontal courses, so no course elevation schedule is produced.' },
];
function surfPatternDef(key) {
  return SURF_PATTERNS.find(p => p.key === key) || SURF_PATTERNS[0];
}

const SURF_NICHE_ALIGN_V = [
  { key: 'none', label: 'As entered' },
  { key: 'course', label: 'Align to tile course (whole courses above and below)' },
  { key: 'joint', label: 'Align to grout joint (sill on the joint centreline)' },
];
const SURF_NICHE_ALIGN_H = [
  { key: 'none', label: 'As entered' },
  { key: 'centre', label: 'Centred on the surface' },
  { key: 'joint', label: 'Sides on vertical grout joints' },
];

// ---- library shape --------------------------------------------------------

// surfaceLibrary is created empty by App(); every read goes through here so a
// state that predates a new collection can never throw.
function surfLib(ctx) {
  const l = ctx.surfaceLibrary || {};
  return {
    unitTypes: l.unitTypes || [],
    roomTypes: l.roomTypes || [],
    surfaceTypes: l.surfaceTypes || [],
    patterns: l.patterns || [],
  };
}
function surfSetLib(ctx, mutator) {
  ctx.setSurfaceLibrary(prev => {
    const base = {
      unitTypes: (prev && prev.unitTypes) || [],
      roomTypes: (prev && prev.roomTypes) || [],
      surfaceTypes: (prev && prev.surfaceTypes) || [],
      patterns: (prev && prev.patterns) || [],
    };
    const next = cloneDeep(base);
    mutator(next);
    return next;
  });
}

// Per-project instance collections. Same guard, same reason.
function surfBuildings(project) { return (project && project.buildings) || []; }
function surfProjectUnits(project) { return (project && project.units) || []; }
function surfProjectRooms(project) { return (project && project.rooms) || []; }

// logAction is declared inside App(), so it is only reachable through ctx —
// calling it bare from this file throws inside the updateProject callback and
// the whole mutation is silently dropped. Everything goes through this wrapper
// so there is one place that can be wrong.
function surfLog(draft, ctx, action) {
  if (ctx && typeof ctx.logAction === 'function') { ctx.logAction(draft, action); return; }
  draft.changeLog = draft.changeLog || [];
  draft.changeLog.unshift({ id: uid('log'), date: todayISO(), role: ctx.currentRole, user: ctx.currentUserName, action });
}

// ---- factories ------------------------------------------------------------

function surfMakeLayout(o) {
  return {
    tileWmm: 304.8, tileHmm: 609.6, groutMm: 3,
    startElevMm: 0, originX: 'center', originY: 'elevation',
    pattern: 'running_half', minCutMm: SURF_DEFAULT_MIN_CUT_MM,
    // What the merchant sells it in, and the breakage on top. `tilesPerBox: 0`
    // means nobody has told us, so no box count is claimed rather than a
    // plausible-looking guess.
    tilesPerBox: SURF_DEFAULT_TILES_PER_BOX, breakagePct: SURF_DEFAULT_BREAKAGE_PCT,
    angleDeg: 45,
    // Where a SOLVED set-out puts the grid. null until the solver is used, and
    // only read when originX is 'solved'.
    phaseXMm: null,
    ...(o || {}),
  };
}
function surfMakeSurface(o) {
  return {
    id: uid('surf'), key: o.key || 'wall', name: o.name || 'Surface', kind: o.kind || 'Wall',
    widthMm: o.widthMm || 0, heightMm: o.heightMm || 0,
    surfaceTypeId: null, finish: null, notes: '',
    layout: surfMakeLayout(o.layout),
    // The solver's own controls. Piece size and joint stay on `layout`, which is
    // where the elevation drawing already reads them - two records holding one
    // tile size is how a drawing and a purchase order end up disagreeing.
    setout: null,
    niches: [],
    ...(o.extra || {}),
  };
}
// The connection itself, as data. aEdge/bEdge name which physical edge of each
// surface is in the joint, so the engine never has to infer adjacency from the
// order surfaces happen to sit in an array.
// ── How two tiled surfaces MEET at a corner ────────────────────────────────
// A corner already carried CONTINUITY — whether the grid runs through it. That
// is the set-out question. This is the other one, and the module had no answer
// for it: what the tiler physically does where the two planes meet.
//
// A 45° MITRE is the detail LEON works to: both tiles cut at 45° through their
// thickness so the glazed faces meet in a clean arris and no cut edge shows.
// It is what a large-format or stone job is specified with, and it is now the
// default on every corner of a new room.
//
// It is also a CHARGEABLE operation, and on the same rule as a countertop
// mitre: the cut is made on BOTH pieces, so a 2.4 m corner is 4.8 m of mitre.
// Getting that wrong halves the cut on every corner in the job.
const SURF_CORNER_JOINTS = [
  { key: 'mitre45', label: 'Mitred 45°', mitre: true,
    note: 'Both tiles cut at 45° through the thickness. The faces meet in an arris and no cut edge shows — the detail a large-format or stone job is specified with. Charged on both pieces.' },
  { key: 'butt', label: 'Butt joint', mitre: false,
    note: 'One plane runs past and the other butts into it, closed with a silicone movement joint. The ordinary internal-corner detail, and what a thin or soft-bodied tile has to use.' },
  { key: 'trim', label: 'Trim profile', mitre: false,
    note: 'A metal or PVC edge profile takes the corner. No mitre to cut, and the profile is a separate line to order.' },
  { key: 'overlap', label: 'Overlap', mitre: false,
    note: 'One plane laps the other and its cut edge is visible. Only acceptable where the edge is glazed or the corner is not seen.' },
];
function surfCornerJoint(key) {
  return SURF_CORNER_JOINTS.find(j => j.key === key) || SURF_CORNER_JOINTS[0];
}
// A mitre needs enough body to cut through. Under this a 45° cut breaks out at
// the arris, which is why a mosaic or a thin porcelain is butted instead.
const SURF_MIN_MITRE_THICKNESS_MM = 8;

function surfMakeCorner(o) {
  return {
    id: uid('scorn'), name: o.name, kind: o.kind || 'corner',
    aSurfaceId: o.aSurfaceId, aEdge: o.aEdge,
    bSurfaceId: o.bSurfaceId, bEdge: o.bEdge,
    continuity: o.continuity || 'Continuous',
    customLeadMm: null,
    alignCourses: o.alignCourses !== undefined ? o.alignCourses : true,
    // How the two planes MEET. A 45° mitre by default — the detail LEON works
    // to — and `angleDeg` is carried so a room that is not square still says
    // what the cut actually is rather than claiming 45.
    joint: o.joint || 'mitre45',
    angleDeg: o.angleDeg !== undefined ? o.angleDeg : 90,
    // Whether the tile faces turn AWAY from the room. An internal corner is
    // the ordinary case in a rectangular room and can be mitred or butted; an
    // external one — a bulkhead return, a niche reveal, a curb — is where a
    // visible cut edge makes the mitre the only clean answer.
    external: !!o.external,
    notes: '',
  };
}
// The mitre a corner cuts, in DEGREES of the tile — half the turn, because both
// tiles take half of it. A square corner is two 45° cuts; a 135° splayed corner
// is two 22.5° cuts.
function surfMitreAngle(corner) {
  const turn = Math.max(1, Math.min(179, Number((corner && corner.angleDeg) || 90)));
  return turn / 2;
}
function surfMakeNiche(o) {
  return {
    id: uid('snich'), name: (o && o.name) || 'Niche',
    widthMm: (o && o.widthMm) || 355.6, heightMm: (o && o.heightMm) || 304.8,
    depthMm: (o && o.depthMm) || 88.9,
    bottomElevMm: (o && o.bottomElevMm) || 1067,
    centerMm: (o && o.centerMm) || 0,
    interiorFinish: null, alignV: 'none', alignH: 'none', notes: '',
  };
}
function surfMakeSurfaceType(o) {
  return { id: uid('stype'), name: (o && o.name) || 'New surface type', kind: (o && o.kind) || 'Wall',
    active: true, notes: '', defaultLayout: surfMakeLayout(o && o.layout) };
}
function surfMakePatternPreset(o) {
  return { id: uid('spat'), name: (o && o.name) || 'New preset', base: (o && o.base) || 'running_half',
    active: true, notes: '', layout: surfMakeLayout(o && o.layout) };
}
function surfMakeUnitType(o) {
  return { id: uid('sutype'), code: (o && o.code) || 'UT-01', name: (o && o.name) || 'Unit type',
    active: true, notes: '', roomTypeIds: [], createdDate: todayISO() };
}

// CREATE ROOM ONCE — width, length and height in, floor + four walls + every
// joint between them out. The four vertical corners run clockwise in plan
// (N→E→S→W), which is what makes "north wall right edge meets east wall left
// edge" the correct pairing rather than an arbitrary one.
function surfBuildRoomShell(o) {
  const W = o.widthMm, L = o.lengthMm, H = o.heightMm;
  const floor = surfMakeSurface({ key: 'floor', name: 'Floor', kind: 'Floor', widthMm: W, heightMm: L,
    layout: { pattern: 'straight', originX: 'center', originY: 'center', startElevMm: 0, tileWmm: 609.6, tileHmm: 609.6 } });
  const walls = {
    north: surfMakeSurface({ key: 'north', name: 'North Wall', kind: 'Wall', widthMm: W, heightMm: H }),
    east: surfMakeSurface({ key: 'east', name: 'East Wall', kind: 'Wall', widthMm: L, heightMm: H }),
    south: surfMakeSurface({ key: 'south', name: 'South Wall', kind: 'Wall', widthMm: W, heightMm: H }),
    west: surfMakeSurface({ key: 'west', name: 'West Wall', kind: 'Wall', widthMm: L, heightMm: H }),
  };
  const surfaces = [floor, walls.north, walls.east, walls.south, walls.west];
  const corners = [];
  const ring = ['north', 'east', 'south', 'west'];
  ring.forEach((k, i) => {
    const nk = ring[(i + 1) % 4];
    corners.push(surfMakeCorner({
      name: `${SURF_WALL_LABELS[k][0]}${SURF_WALL_LABELS[nk][0]} corner`,
      aSurfaceId: walls[k].id, aEdge: 'right', bSurfaceId: walls[nk].id, bEdge: 'left',
    }));
  });
  // Floor-to-wall joints are recorded too, but default to Independent: a floor
  // grid and a wall grid rarely run through each other, and pretending they do
  // by default would silently move every wall's set-out.
  ring.forEach(k => {
    corners.push(surfMakeCorner({
      name: `Floor / ${SURF_WALL_LABELS[k]} base`, kind: 'base',
      aSurfaceId: floor.id, aEdge: k, bSurfaceId: walls[k].id, bEdge: 'bottom',
      continuity: 'Independent', alignCourses: false,
      // A floor-to-wall joint is NOT mitred — it is a movement joint, closed
      // with silicone or taken by a cove. Inheriting the wall corners' mitre
      // here would have put four mitres on the schedule that nobody cuts.
      joint: 'butt',
    }));
  });
  return { surfaces, corners, masterSurfaceId: walls.north.id };
}

function surfMakeRoomType(o) {
  const W = (o && o.widthMm) || 2438.4, L = (o && o.lengthMm) || 3048, H = (o && o.heightMm) || 2438.4;
  const shell = surfBuildRoomShell({ widthMm: W, lengthMm: L, heightMm: H });
  return {
    id: uid('srtype'), code: (o && o.code) || 'RT-01', name: (o && o.name) || 'New room type',
    active: true, department: (o && o.department) || 'Interiors', notes: '',
    widthMm: W, lengthMm: L, heightMm: H,
    minCutMm: SURF_DEFAULT_MIN_CUT_MM,
    surfaces: shell.surfaces, corners: shell.corners, masterSurfaceId: shell.masterSurfaceId,
    // The set-out half. Both are null until someone reshapes the room or edits
    // an accessory: surfPlanOf/surfAccessoriesOf build the default on demand, so
    // every room type that predates the solver reads correctly without migration.
    plan: null, accessories: null,
    createdDate: todayISO(), createdBy: (o && o.createdBy) || null,
  };
}

// A shower is three walls, a floor and a curb, joined the same way the room is.
function surfBuildShower(widthMm, depthMm, heightMm, curbMm) {
  const back = surfMakeSurface({ key: 'shower_back', name: 'Shower — Back', kind: 'Shower Wall', widthMm, heightMm });
  const left = surfMakeSurface({ key: 'shower_left', name: 'Shower — Left', kind: 'Shower Wall', widthMm: depthMm, heightMm });
  const right = surfMakeSurface({ key: 'shower_right', name: 'Shower — Right', kind: 'Shower Wall', widthMm: depthMm, heightMm });
  const floor = surfMakeSurface({ key: 'shower_floor', name: 'Shower — Floor', kind: 'Shower Floor', widthMm, heightMm: depthMm,
    layout: { pattern: 'straight', originX: 'center', originY: 'center', tileWmm: 50.8, tileHmm: 50.8, groutMm: 3 } });
  const curb = surfMakeSurface({ key: 'shower_curb', name: 'Shower — Curb', kind: 'Curb', widthMm, heightMm: curbMm || 152.4,
    layout: { pattern: 'straight', originX: 'center', originY: 'bottom' } });
  const corners = [
    surfMakeCorner({ name: 'Shower left / back', aSurfaceId: left.id, aEdge: 'right', bSurfaceId: back.id, bEdge: 'left' }),
    surfMakeCorner({ name: 'Shower back / right', aSurfaceId: back.id, aEdge: 'right', bSurfaceId: right.id, bEdge: 'left' }),
  ];
  return { surfaces: [back, left, right, floor, curb], corners };
}
function surfBuildTubSurround(lengthMm, depthMm, heightMm) {
  const back = surfMakeSurface({ key: 'tub_back', name: 'Tub — Back', kind: 'Tub Surround', widthMm: lengthMm, heightMm });
  const left = surfMakeSurface({ key: 'tub_left', name: 'Tub — Left Return', kind: 'Tub Surround', widthMm: depthMm, heightMm });
  const right = surfMakeSurface({ key: 'tub_right', name: 'Tub — Right Return', kind: 'Tub Surround', widthMm: depthMm, heightMm });
  const deck = surfMakeSurface({ key: 'tub_deck', name: 'Tub — Deck', kind: 'Tub Surround', widthMm: lengthMm, heightMm: depthMm,
    layout: { pattern: 'straight', originY: 'center' } });
  const corners = [
    surfMakeCorner({ name: 'Tub left / back', aSurfaceId: left.id, aEdge: 'right', bSurfaceId: back.id, bEdge: 'left' }),
    surfMakeCorner({ name: 'Tub back / right', aSurfaceId: back.id, aEdge: 'right', bSurfaceId: right.id, bEdge: 'left' }),
  ];
  return { surfaces: [back, left, right, deck], corners };
}

// ---- the tile layout engine ----------------------------------------------
// Everything below is pure. Given a field and a layout it returns the same
// answer anywhere — the SVG, the schedule table and the quantity roll-up all
// read one computation rather than three that can disagree.

function surfMod(v, m) { return m > 0 ? ((v % m) + m) % m : 0; }
// `phase` is how far the ideal grid sits to the LEFT of the field's origin, so
// tile k spans [-phase + k*module, ...]. Anchoring by a desired left edge keeps
// every origin option one line long.
function surfPhase(anchor, m) { return surfMod(-anchor, m); }

function surfAnchorX(originX, W, tw, g) {
  if (originX === 'left') return 0;
  if (originX === 'right') return W - tw;
  if (originX === 'centerJoint') return W / 2 + g / 2;
  return (W - tw) / 2;
}
function surfAnchorY(layout, H, th) {
  if (layout.originY === 'bottom') return 0;
  if (layout.originY === 'top') return H - th;
  if (layout.originY === 'center') return (H - th) / 2;
  return layout.startElevMm || 0;
}

function surfRunAcross(W, tw, g, phaseX) {
  const mx = tw + g;
  const out = [];
  if (mx <= 0) return out;
  const kMin = Math.floor(phaseX / mx) - 1;
  const kMax = Math.ceil((W + phaseX) / mx) + 1;
  for (let k = kMin; k <= kMax; k++) {
    const left = -phaseX + k * mx, right = left + tw;
    const vl = Math.max(0, left), vr = Math.min(W, right);
    if (vr - vl < 0.5) continue;
    out.push({ k, x: vl, w: vr - vl, cutLeft: vl > left + 0.01, cutRight: vr < right - 0.01 });
  }
  return out;
}
function surfCourseBands(H, th, g, anchor) {
  const my = th + g;
  const out = [];
  if (my <= 0) return out;
  const phase = surfPhase(anchor, my);
  const nMin = Math.floor(phase / my) - 1;
  const nMax = Math.ceil((H + phase) / my) + 1;
  for (let n = nMin; n <= nMax; n++) {
    const bottom = -phase + n * my, top = bottom + th;
    const vb = Math.max(0, bottom), vt = Math.min(H, top);
    if (vt - vb < 0.5) continue;
    out.push({ n, y: vb, h: vt - vb, bottom, top, cutBottom: vb > bottom + 0.01, cutTop: vt < top - 0.01 });
  }
  return out;
}

// DIAGONAL — the grid turned 45° to the room. It cannot go through the
// axis-aligned band engine at all, so like herringbone it is its own branch:
// lay a straight grid in the ROTATED frame, then keep whatever falls in the
// field. A tile whose four corners are all inside is whole; anything else is
// cut, and at 45° those cuts are triangles, which is why this pattern wastes
// more than any other square-tile layout.
function surfDiagonalPieces(W, H, tw, th, g, angleDeg) {
  const a = ((angleDeg === undefined ? 45 : angleDeg) * Math.PI) / 180;
  const ca = Math.cos(a), sa = Math.sin(a);
  const mx = tw + g, my = th + g;
  const pieces = [];
  if (mx <= 0 || my <= 0) return { pieces, capped: false };
  // The rotated frame has to cover the field's diagonal in both directions.
  const R = Math.hypot(W, H) / 2 + Math.max(tw, th) * 2;
  const cx = W / 2, cy = H / 2;
  const iMin = Math.floor(-R / mx), iMax = Math.ceil(R / mx);
  const jMin = Math.floor(-R / my), jMax = Math.ceil(R / my);
  const CAP = 6000;
  let capped = false;
  // Rotate a point of the tile lattice into the field.
  const put = (u, v) => ({ x: cx + u * ca - v * sa, y: cy + u * sa + v * ca });
  const inside = p => p.x >= -0.01 && p.x <= W + 0.01 && p.y >= -0.01 && p.y <= H + 0.01;
  for (let i = iMin; i <= iMax; i++) {
    for (let j = jMin; j <= jMax; j++) {
      const u0 = i * mx, v0 = j * my;
      const q = [put(u0, v0), put(u0 + tw, v0), put(u0 + tw, v0 + th), put(u0, v0 + th)];
      // Cheap reject: the whole quad off one side of the field.
      if (q.every(p => p.x < 0) || q.every(p => p.x > W)) continue;
      if (q.every(p => p.y < 0) || q.every(p => p.y > H)) continue;
      const whole = q.every(inside);
      // A quad entirely outside but not rejected above cannot contribute.
      if (!whole && !q.some(inside)
          && !(Math.min.apply(null, q.map(p => p.x)) < 0 && Math.max.apply(null, q.map(p => p.x)) > W)
          && !(Math.min.apply(null, q.map(p => p.y)) < 0 && Math.max.apply(null, q.map(p => p.y)) > H)) continue;
      if (pieces.length >= CAP) { capped = true; break; }
      pieces.push({ quad: q, cut: !whole });
    }
    if (capped) break;
  }
  return { pieces, capped };
}

// 90° herringbone. The pairs sit on a real lattice — a = (3u, u), b = (u, −u)
// with u = short side + grout — which closes exactly when the long side is
// twice the short plus one joint. Anything else is drawn as laid but flagged,
// because it will not repeat and the tiler will discover that at the far wall.
function surfHerringbonePieces(W, H, tw, th, g) {
  const short = Math.min(tw, th), long = Math.max(tw, th);
  const u = short + g;
  const closes = Math.abs(long - (2 * short + g)) < 1.5;
  const pieces = [];
  if (u <= 0) return { pieces, closes, u };
  const pad = long + u;
  const mMin = Math.floor((-2 * pad) / (4 * u)) - 1;
  const mMax = Math.ceil((W + H + 2 * pad) / (4 * u)) + 1;
  const nSpan = Math.ceil((H + 2 * pad) / u) + 2;
  let guard = 0;
  for (let m = mMin; m <= mMax; m++) {
    for (let n = m - nSpan; n <= m + 2; n++) {
      const ox = 3 * u * m + u * n, oy = u * m - u * n;
      if (ox > W + pad || ox < -2 * pad || oy > H + pad || oy < -2 * pad) continue;
      const rects = [
        { x: ox, y: oy, w: long, h: short },
        { x: ox + long + g, y: oy, w: short, h: long },
      ];
      rects.forEach(r => {
        const vl = Math.max(0, r.x), vr = Math.min(W, r.x + r.w);
        const vb = Math.max(0, r.y), vt = Math.min(H, r.y + r.h);
        if (vr - vl < 0.5 || vt - vb < 0.5) return;
        pieces.push({
          x: vl, y: vb, w: vr - vl, h: vt - vb,
          cut: (vr - vl < r.w - 0.01) || (vt - vb < r.h - 0.01),
        });
        guard++;
      });
      if (guard > 6000) break;
    }
    if (guard > 6000) break;
  }
  return { pieces, closes, u, capped: guard > 6000 };
}

// The one computation. `opt.phaseXMm` is supplied by the continuity walk when
// this surface continues a neighbour's grid; absent, the surface's own origin
// rule decides.
function surfComputeSurface(surface, layout, opt) {
  const o = opt || {};
  const L = layout || surface.layout;
  const W = Math.max(1, surface.widthMm || 0);
  const H = Math.max(1, surface.heightMm || 0);
  const tw = Math.max(1, L.tileWmm || 1), th = Math.max(1, L.tileHmm || 1), g = Math.max(0, L.groutMm || 0);
  const mx = tw + g, my = th + g;
  const pat = surfPatternDef(L.pattern);
  const minCut = (o.minCutMm || L.minCutMm || SURF_DEFAULT_MIN_CUT_MM);
  const warnings = [];
  const res = { mode: 'grid', W, H, tileW: tw, tileH: th, grout: g, moduleX: mx, moduleY: my,
    pattern: pat, courses: [], pieces: [], warnings, phaseX: 0,
    totals: { full: 0, cut: 0, total: 0, fieldAreaMm2: W * H, minCutX: null, minCutY: null } };

  if (pat.offset > 1 / 3 + 0.001 && (tw > SURF_LARGE_FORMAT_MM || th > SURF_LARGE_FORMAT_MM)) {
    warnings.push({ level: 'warn', text: `This tile is ${fmtDim(Math.max(tw, th), 'Imperial')} on its long side. A ${Math.round(pat.offset * 100)}% offset on large format risks lippage — the trade rule is ⅓ or less.` });
  }

  if (pat.diagonal) {
    const dg = surfDiagonalPieces(W, H, tw, th, g, L.angleDeg === undefined ? 45 : L.angleDeg);
    res.mode = 'diagonal';
    res.pieces = dg.pieces;
    let full = 0, cut = 0;
    dg.pieces.forEach(p => { if (p.cut) cut++; else full++; });
    res.totals = { full, cut, total: full + cut, fieldAreaMm2: W * H, minCutX: null, minCutY: null };
    if (dg.capped) warnings.push({ level: 'note', text: 'Preview capped at 6,000 pieces; the counts are the pieces drawn, not the full field.' });
    warnings.push({ level: 'note', text: 'Every perimeter piece on a diagonal is a triangle, so there is no course elevation schedule and no rectangular cut list — the cuts are read off the drawing.' });
    if (full + cut > 0) {
      warnings.push({ level: 'warn', text: `${cut} of ${full + cut} pieces meet a wall at 45° and need cutting — ${Math.round((cut / (full + cut)) * 100)}% of the field. A diagonal wastes more than any other square-tile layout.` });
    }
    return res;
  }

  if (pat.herringbone) {
    const hb = surfHerringbonePieces(W, H, tw, th, g);
    res.mode = 'herringbone';
    res.pieces = hb.pieces;
    if (!hb.closes) {
      warnings.push({ level: 'warn', text: `90° herringbone closes only on a 2:1 tile. At ${fmtDim(tw, 'Imperial')} × ${fmtDim(th, 'Imperial')} with a ${g} mm joint the pattern will not repeat — the drawing shows it laid, the wall will drift.` });
    }
    if (hb.capped) warnings.push({ level: 'note', text: 'Preview capped at 6,000 pieces; the counts below are the pieces drawn, not the full field.' });
    let full = 0, cut = 0, minPiece = null;
    hb.pieces.forEach(p => {
      if (p.cut) { cut++; const m = Math.min(p.w, p.h); if (minPiece === null || m < minPiece) minPiece = m; }
      else full++;
    });
    res.totals = { full, cut, total: full + cut, fieldAreaMm2: W * H, minCutX: minPiece, minCutY: minPiece };
    if (minPiece !== null && minPiece < minCut) {
      warnings.push({ level: 'bad', text: `Smallest perimeter cut is ${fmtDim(minPiece, 'Imperial')}, under the ${fmtDim(minCut, 'Imperial')} minimum.` });
    }
    warnings.push({ level: 'note', text: 'Herringbone has no continuous horizontal courses, so no course elevation schedule is produced. Cuts are listed because the tiles stay axis-aligned.' });
    return res;
  }

  const anchorY = surfAnchorY(L, H, th);
  const bands = surfCourseBands(H, th, g, anchorY);
  // An explicit phase wins wherever it comes from: the caller's option (which is
  // how the solver probes candidates) or the layout's own stored offset (which
  // is how a solved set-out survives being saved). Reading only the option was
  // a real bug — applying a solved set-out wrote a phase nothing read, and the
  // field silently fell back to centred.
  const phaseX0 = (o.phaseXMm !== null && o.phaseXMm !== undefined)
    ? surfMod(o.phaseXMm, mx)
    : (L.originX === 'solved' && L.phaseXMm !== null && L.phaseXMm !== undefined)
      ? surfMod(L.phaseXMm, mx)
      : surfPhase(surfAnchorX(L.originX, W, tw, g), mx);
  res.phaseX = phaseX0;

  // The counts are always exact; the DRAWING is not. A 5 mm mosaic on a 3 m
  // floor is a hundred thousand rectangles, and putting those in the DOM would
  // hang the browser — so the piece list stops at a cap and the view says so.
  const PIECE_DRAW_CAP = 4000;
  let full = 0, cut = 0, minCutX = null, minCutY = null;
  bands.forEach(b => {
    // The row step is taken off the course NUMBER, not off the array index, so
    // two walls sharing a starting elevation share their course numbering and
    // therefore step together. That is what makes "align courses" true rather
    // than coincidental.
    // A RUNNING bond walks on every course; a STAGGERED one alternates between
    // two positions. The step is taken off the course NUMBER either way, so
    // two walls sharing a starting elevation still step together.
    const step = pat.stagger ? (surfMod(b.n, 2) ? 1 : 0) : b.n;
    const off = surfMod(step * pat.offset * mx, mx);
    const phaseX = surfMod(phaseX0 - off, mx);
    const tiles = surfRunAcross(W, tw, g, phaseX);
    let rowFull = 0, rowCut = 0;
    // A TILE IS CUT IF IT IS CUT IN EITHER DIRECTION. This counted X cuts only,
    // so every tile in a partial top or bottom course — full width, but cut to
    // height — was counted as a whole tile. Almost every layout has a partial
    // course, so the cut count and anything built on it were low. Caught by
    // cross-checking the totals against the grouped cut list, which reads the
    // pieces and got a different answer.
    const courseCut = b.cutBottom || b.cutTop;
    tiles.forEach(t => {
      const cutX = t.cutLeft || t.cutRight;
      const isCut = cutX || courseCut;
      if (isCut) rowCut++; else rowFull++;
      // minCutX is the sliver-at-the-wall test, so it stays an X measurement —
      // a full-width tile in a short course is a cut, not a sliver.
      if (cutX && (minCutX === null || t.w < minCutX)) minCutX = t.w;
      if (res.pieces.length < PIECE_DRAW_CAP) res.pieces.push({ x: t.x, y: b.y, w: t.w, h: b.h, cut: isCut });
    });
    if (b.cutBottom || b.cutTop) { if (minCutY === null || b.h < minCutY) minCutY = b.h; }
    full += rowFull; cut += rowCut;
    const first = tiles[0], last = tiles[tiles.length - 1];
    res.courses.push({
      n: b.n, bottomMm: b.y, topMm: b.y + b.h, heightMm: b.h,
      isPartial: b.cutBottom || b.cutTop, cutAt: b.cutBottom ? 'bottom' : b.cutTop ? 'top' : null,
      offsetMm: off, tiles, fullCount: rowFull, cutCount: rowCut,
      leftCutMm: first && first.cutLeft ? first.w : null,
      rightCutMm: last && last.cutRight ? last.w : null,
    });
  });
  res.courses.sort((a, b) => a.bottomMm - b.bottomMm);
  res.totals = { full, cut, total: full + cut, fieldAreaMm2: W * H, minCutX, minCutY };
  if (full + cut > PIECE_DRAW_CAP) {
    res.drawingCapped = PIECE_DRAW_CAP;
    warnings.push({ level: 'note', text: `This surface takes ${full + cut} tiles. The counts and the course schedule are complete; the drawing shows the first ${PIECE_DRAW_CAP} pieces only, because a browser cannot hold that many rectangles on screen.` });
  }

  // A sliver at a corner is the classic mistake, so it is named with the place
  // it happens rather than reported as one aggregate number.
  const badEnds = res.courses.filter(c =>
    (c.leftCutMm !== null && c.leftCutMm < minCut) || (c.rightCutMm !== null && c.rightCutMm < minCut));
  if (badEnds.length) {
    const worst = Math.min(...badEnds.map(c => Math.min(
      c.leftCutMm === null ? Infinity : c.leftCutMm, c.rightCutMm === null ? Infinity : c.rightCutMm)));
    warnings.push({ level: 'bad', text: `${badEnds.length} course${badEnds.length === 1 ? '' : 's'} end on a cut under the ${fmtDim(minCut, 'Imperial')} minimum — the worst is ${fmtDim(worst, 'Imperial')}. Centre the set-out or start on a joint instead of a tile.` });
  }
  const topBand = res.courses[res.courses.length - 1];
  if (topBand && topBand.isPartial && topBand.heightMm < minCut) {
    warnings.push({ level: 'bad', text: `The top course is ${fmtDim(topBand.heightMm, 'Imperial')} — a sliver at the ceiling line. Move the starting elevation.` });
  }
  const baseBand = res.courses[0];
  if (baseBand && baseBand.isPartial && baseBand.heightMm < minCut) {
    warnings.push({ level: 'bad', text: `The bottom course is ${fmtDim(baseBand.heightMm, 'Imperial')}. A base cut this small will not sit down on the floor.` });
  }
  return res;
}

// ---- continuity: walking the stored joints --------------------------------
// The grid continues around a corner when B's phase picks up exactly where A's
// field ended: phaseB = phaseA + widthA (mod the module). Because the per-course
// step is derived from the course number, both walls step identically and the
// bond carries through the corner rather than restarting.
function surfContinuityPhases(roomT) {
  const byId = {};
  (roomT.surfaces || []).forEach(s => { byId[s.id] = s; });
  const phases = {};
  const notes = {};
  const master = roomT.masterSurfaceId && byId[roomT.masterSurfaceId] ? roomT.masterSurfaceId : (roomT.surfaces[0] || {}).id;
  if (!master) return { phases, notes, master };

  const seen = { [master]: true };
  const queue = [master];
  let guard = 0;
  while (queue.length && guard++ < 200) {
    const curId = queue.shift();
    const cur = byId[curId];
    if (!cur) continue;
    const curLayout = surfLayoutOf(roomT, cur);
    const curComputed = surfComputeSurface(cur, curLayout, { phaseXMm: phases[curId] });
    (roomT.corners || []).forEach(c => {
      if (c.continuity === 'Independent') return;
      let fromId = null, toId = null, fromEdge = null, toEdge = null;
      if (c.aSurfaceId === curId) { fromId = c.aSurfaceId; toId = c.bSurfaceId; fromEdge = c.aEdge; toEdge = c.bEdge; }
      else if (c.bSurfaceId === curId) { fromId = c.bSurfaceId; toId = c.aSurfaceId; fromEdge = c.bEdge; toEdge = c.aEdge; }
      else return;
      if (seen[toId]) return;
      const to = byId[toId];
      if (!to) return;
      const toLayout = surfLayoutOf(roomT, to);
      const mx = Math.max(1, (toLayout.tileWmm || 1) + (toLayout.groutMm || 0));
      const fromMx = curComputed.moduleX;
      if (Math.abs(mx - fromMx) > 1) {
        notes[toId] = `Cannot continue from ${cur.name}: the modules differ (${fmtDim(fromMx, 'Imperial')} vs ${fmtDim(mx, 'Imperial')}). Set out independently.`;
        seen[toId] = true;
        queue.push(toId);
        return;
      }
      let phase;
      if (c.continuity === 'Custom') {
        // A custom lead states the width of the first piece on the receiving
        // surface, which is how a set-out drawing states it.
        const lead = Math.max(0, Math.min(toLayout.tileWmm, c.customLeadMm || toLayout.tileWmm));
        phase = surfMod(toLayout.tileWmm - lead, mx);
        notes[toId] = `Custom start from ${cur.name} — first piece ${fmtDim(lead, 'Imperial')}.`;
      } else if (fromEdge === 'right' || fromEdge === 'left') {
        phase = fromEdge === 'right'
          ? surfMod(curComputed.phaseX + curComputed.W, mx)
          : surfMod(curComputed.phaseX - to.widthMm, mx);
        notes[toId] = `Continuous from ${cur.name} (${fromEdge} edge).`;
      } else {
        // A floor-to-wall joint has no shared horizontal axis to continue, only
        // shared courses; leave the phase alone and say so.
        notes[toId] = `Courses referenced to ${cur.name}; horizontal set-out is its own.`;
        phase = undefined;
      }
      if (phase !== undefined) phases[toId] = phase;
      seen[toId] = true;
      queue.push(toId);
    });
  }
  return { phases, notes, master };
}

// Align Courses: sharing a course elevation means sharing the vertical module
// and the starting elevation. There is no other way for two grids to line
// through, so aligned surfaces take all three from the master and the UI says
// which fields stopped being their own.
function surfLayoutOf(roomT, surface) {
  const base = surface.layout || surfMakeLayout();
  const masterId = roomT.masterSurfaceId;
  if (!masterId || surface.id === masterId) return base;
  const corner = (roomT.corners || []).find(c =>
    c.alignCourses && (c.aSurfaceId === surface.id || c.bSurfaceId === surface.id));
  if (!corner) return base;
  const master = (roomT.surfaces || []).find(s => s.id === masterId);
  if (!master || !master.layout) return base;
  if (surface.kind === 'Floor' || surface.kind === 'Shower Floor') return base;
  return { ...base, tileHmm: master.layout.tileHmm, groutMm: master.layout.groutMm,
    startElevMm: master.layout.startElevMm, originY: master.layout.originY, __aligned: true };
}

// One call gives every surface in a room its final layout and its computation,
// with continuity already resolved. Every view reads this.
function surfComputeRoom(roomT) {
  const cont = surfContinuityPhases(roomT);
  const out = { master: cont.master, notes: cont.notes, surfaces: {} };
  (roomT.surfaces || []).forEach(s => {
    const layout = surfLayoutOf(roomT, s);
    out.surfaces[s.id] = {
      surface: s, layout,
      computed: surfComputeSurface(s, layout, {
        phaseXMm: cont.phases[s.id],
        minCutMm: layout.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM,
      }),
      continuedFrom: cont.notes[s.id] || null,
    };
  });
  return out;
}

// ---- niches ---------------------------------------------------------------

function surfNicheRect(niche, surfaceW) {
  const cx = niche.centerMm || surfaceW / 2;
  return { x: cx - (niche.widthMm || 0) / 2, y: niche.bottomElevMm || 0, w: niche.widthMm || 0, h: niche.heightMm || 0 };
}
// What the niche does to the tiles it lands in. Reported per edge, because a
// 12 mm strip above a niche head is a different problem from a 12 mm strip
// beside it and gets fixed differently.
function surfAnalyzeNiche(niche, computed, minCut) {
  const r = surfNicheRect(niche, computed.W);
  const out = { rect: r, sillCut: null, headCut: null, leftCut: null, rightCut: null, warnings: [] };
  if (computed.mode !== 'grid') {
    out.warnings.push({ level: 'note', text: 'Niche cut analysis is only produced for course-based patterns.' });
    return out;
  }
  const sillCourse = computed.courses.find(c => r.y > c.bottomMm + 0.01 && r.y < c.topMm - 0.01);
  const headY = r.y + r.h;
  const headCourse = computed.courses.find(c => headY > c.bottomMm + 0.01 && headY < c.topMm - 0.01);
  if (sillCourse) out.sillCut = r.y - sillCourse.bottomMm;
  if (headCourse) out.headCut = headCourse.topMm - headY;
  const midCourse = computed.courses.find(c => c.bottomMm >= r.y && c.topMm <= headY) || sillCourse;
  if (midCourse) {
    const leftTile = midCourse.tiles.find(t => r.x > t.x + 0.01 && r.x < t.x + t.w - 0.01);
    const rightTile = midCourse.tiles.find(t => (r.x + r.w) > t.x + 0.01 && (r.x + r.w) < t.x + t.w - 0.01);
    if (leftTile) out.leftCut = r.x - leftTile.x;
    if (rightTile) out.rightCut = (rightTile.x + rightTile.w) - (r.x + r.w);
  }
  [['sillCut', 'below the sill'], ['headCut', 'above the head'], ['leftCut', 'beside the left jamb'], ['rightCut', 'beside the right jamb']]
    .forEach(([k, where]) => {
      const v = out[k];
      if (v !== null && v > 0.5 && v < minCut) {
        out.warnings.push({ level: 'bad', text: `${fmtDim(v, 'Imperial')} strip ${where} — under the ${fmtDim(minCut, 'Imperial')} minimum.` });
      }
    });
  if (r.x < 0 || r.x + r.w > computed.W || r.y < 0 || r.y + r.h > computed.H) {
    out.warnings.push({ level: 'bad', text: 'The niche falls outside the surface.' });
  }
  return out;
}
// Snapping WRITES the value. An "aligned" flag that only changed how the
// drawing looked would leave the real dimension wrong on site.
function surfSnapNiche(niche, computed, mode, modeH) {
  const next = {};
  const r = surfNicheRect(niche, computed.W);
  if (computed.mode === 'grid') {
    const lines = [];
    computed.courses.forEach(c => { lines.push(c.bottomMm); lines.push(c.topMm); });
    const joints = computed.courses.map(c => c.topMm + (computed.grout || 0) / 2);
    const pick = (v, arr) => arr.reduce((best, x) => Math.abs(x - v) < Math.abs(best - v) ? x : best, arr[0]);
    if (mode === 'course' && lines.length) {
      const b = pick(r.y, lines), t = pick(r.y + r.h, lines);
      next.bottomElevMm = Math.round(b * 10) / 10;
      next.heightMm = Math.round(Math.max(computed.tileH / 2, t - b) * 10) / 10;
    } else if (mode === 'joint' && joints.length) {
      const b = pick(r.y, joints), t = pick(r.y + r.h, joints);
      next.bottomElevMm = Math.round(b * 10) / 10;
      next.heightMm = Math.round(Math.max(computed.tileH / 2, t - b) * 10) / 10;
    }
    if (modeH === 'centre') next.centerMm = Math.round(computed.W / 2 * 10) / 10;
    else if (modeH === 'joint') {
      const course = computed.courses.find(c => r.y >= c.bottomMm && r.y <= c.topMm) || computed.courses[0];
      if (course) {
        const vlines = [];
        course.tiles.forEach(t => { vlines.push(t.x); vlines.push(t.x + t.w); });
        const pickV = v => vlines.reduce((best, x) => Math.abs(x - v) < Math.abs(best - v) ? x : best, vlines[0]);
        const l = pickV(r.x), rr = pickV(r.x + r.w);
        if (rr - l > 25) {
          next.widthMm = Math.round((rr - l) * 10) / 10;
          next.centerMm = Math.round((l + (rr - l) / 2) * 10) / 10;
        }
      }
    }
  }
  return next;
}

// ---- inheritance ----------------------------------------------------------
// A Room stores its disagreements as a FLAT path -> value map. Flat because
// every question the module asks of an override is a lookup on one key: is this
// field overridden, reset this field, which fields did a type change touch.
// A nested override tree makes all three of those a walk.

function surfPathParts(path) { return String(path).split('.'); }
function surfTypeValueAt(roomT, path) {
  const p = surfPathParts(path);
  if (p[0] === 'room') return roomT[p[1]];
  if (p[0] === 'surface') {
    const s = (roomT.surfaces || []).find(x => x.id === p[1]);
    if (!s) return undefined;
    return p.length === 3 ? s[p[2]] : (s[p[2]] || {})[p[3]];
  }
  if (p[0] === 'corner') {
    const c = (roomT.corners || []).find(x => x.id === p[1]);
    return c ? c[p[2]] : undefined;
  }
  if (p[0] === 'niche') {
    const s = (roomT.surfaces || []).find(x => x.id === p[1]);
    const n = s && (s.niches || []).find(x => x.id === p[2]);
    return n ? n[p[3]] : undefined;
  }
  return undefined;
}
function surfApplyPath(target, path, value) {
  const p = surfPathParts(path);
  if (p[0] === 'room') { target[p[1]] = value; return; }
  if (p[0] === 'surface') {
    const s = (target.surfaces || []).find(x => x.id === p[1]);
    if (!s) return;
    if (p.length === 3) s[p[2]] = value;
    else { s[p[2]] = { ...(s[p[2]] || {}) }; s[p[2]][p[3]] = value; }
    return;
  }
  if (p[0] === 'corner') {
    const c = (target.corners || []).find(x => x.id === p[1]);
    if (c) c[p[2]] = value;
    return;
  }
  if (p[0] === 'niche') {
    const s = (target.surfaces || []).find(x => x.id === p[1]);
    const n = s && (s.niches || []).find(x => x.id === p[2]);
    if (n) n[p[3]] = value;
  }
}
function surfPathLabel(roomT, path) {
  const p = surfPathParts(path);
  const nice = k => String(k)
    .replace(/mm$/, '').replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).trim();
  if (p[0] === 'room') return `Room · ${nice(p[1])}`;
  if (p[0] === 'surface') {
    const s = (roomT.surfaces || []).find(x => x.id === p[1]);
    return `${s ? s.name : 'Surface'} · ${nice(p[p.length - 1])}`;
  }
  if (p[0] === 'corner') {
    const c = (roomT.corners || []).find(x => x.id === p[1]);
    return `${c ? c.name : 'Corner'} · ${nice(p[2])}`;
  }
  if (p[0] === 'niche') {
    const s = (roomT.surfaces || []).find(x => x.id === p[1]);
    const n = s && (s.niches || []).find(x => x.id === p[2]);
    return `${s ? s.name : 'Surface'} · ${n ? n.name : 'Niche'} · ${nice(p[3])}`;
  }
  return path;
}
function surfOverrides(room) { return (room && room.overrides) || {}; }
function surfHasOverride(room, path) { return Object.prototype.hasOwnProperty.call(surfOverrides(room), path); }
function surfEffectiveRoomType(roomT, room) {
  const ov = surfOverrides(room);
  const keys = Object.keys(ov);
  if (!keys.length) return roomT;
  const eff = cloneDeep(roomT);
  keys.forEach(k => surfApplyPath(eff, k, ov[k]));
  return eff;
}

// ---- quantities -----------------------------------------------------------

function surfM2(mm2) { return mm2 / 1000000; }
function surfSurfaceNetAreaMm2(entry) {
  const s = entry.surface;
  let a = (s.widthMm || 0) * (s.heightMm || 0);
  (s.niches || []).forEach(n => { a -= (n.widthMm || 0) * (n.heightMm || 0); });
  return Math.max(0, a);
}
function surfWasteFor(patternKey) {
  const p = surfPatternDef(patternKey);
  if (p.herringbone) return 0.15;
  if (p.offset > 0) return 0.10;
  return 0.08;
}
// Per room type: one computation, multiplied by the instance count. Rooms that
// hold a geometry or finish override contribute only their DELTA against that
// same base — the project is never costed twice.
// Every mitred corner in the room, as linear metres of 45° cut. On the SAME
// rule a countertop mitre uses: the cut is made on BOTH pieces, so a 2.4 m
// corner is 4.8 m of mitre. The two surfaces are looked up so the length is the
// height they actually share rather than a nominal ceiling height.
function surfRoomMitres(roomT) {
  const byId = {};
  (roomT.surfaces || []).forEach(x => { byId[x.id] = x; });
  const out = [];
  (roomT.corners || []).forEach(c => {
    if (c.kind !== 'corner') return;                 // a floor/wall base joint is not a mitre
    const j = surfCornerJoint(c.joint);
    if (!j.mitre) return;
    const a = byId[c.aSurfaceId], b = byId[c.bSurfaceId];
    if (!a || !b) return;
    // The shared edge is the shorter of the two heights — a mitre cannot run
    // past where one of the planes stops.
    const lenMm = Math.min(a.heightMm || 0, b.heightMm || 0);
    if (!(lenMm > 0)) return;
    const thick = Math.max(surfTileThickness(roomT, a), surfTileThickness(roomT, b));
    out.push({
      cornerId: c.id, name: c.name, lenMm,
      // BOTH PIECES. This is the line that halves every corner in the job if
      // it is written as `cutMm: lenMm`.
      cutMm: lenMm * 2,
      angle: surfMitreAngle(c),
      external: !!c.external,
      thicknessMm: thick,
      tooThin: thick > 0 && thick < SURF_MIN_MITRE_THICKNESS_MM,
    });
  });
  return out;
}
// A tile's body thickness. It is a property of the finish where the supplier
// publishes one; 0 means nobody has said, and a check that needs it says so
// rather than assuming a number.
function surfTileThickness(roomT, surface) {
  const f = surface && surface.finish;
  const v = f && (f.thicknessMm || f.thickness);
  return Number(v) || 0;
}

function surfRoomQuantities(roomT) {
  const computedRoom = surfComputeRoom(roomT);
  // A SOLVED SET-OUT SUPERSEDES THE PERCENTAGE. Where a surface has one, its
  // waste is the figure the simulated layout produced and the piece count is
  // real pieces off real boards. Where it does not, the rough allowance stands
  // in - and every line says which of the two it is carrying.
  const roomSetout = surfRoomSetout(roomT);
  const lines = [];
  (roomT.surfaces || []).forEach(s => {
    const entry = computedRoom.surfaces[s.id];
    if (!entry) return;
    const areaMm2 = surfSurfaceNetAreaMm2(entry);
    const layout = entry.layout;
    const tileAreaMm2 = Math.max(1, (layout.tileWmm || 1) * (layout.tileHmm || 1));
    const solved = roomSetout.surfaces[s.id];
    const usable = solved && !solved.capped;
    const waste = usable ? solved.tally.totalWastePct / 100 : surfAllowanceFor(s.kind, layout.pattern);
    lines.push({
      surfaceId: s.id, surfaceName: s.name, kind: s.kind,
      finish: s.finish || null,
      finishKey: s.finish ? `${s.finish.source}:${s.finish.id}` : '__unassigned',
      finishName: s.finish ? s.finish.name : 'No finish selected',
      areaMm2, waste, tileAreaMm2,
      piecesNominal: usable ? solved.tally.totalPieces : areaMm2 / tileAreaMm2,
      fullCount: entry.computed.totals.full, cutCount: entry.computed.totals.cut,
      wasteSource: usable ? 'computed' : 'allowance',
      perPack: usable ? solved.tally.perPack : null,
      surplusPieces: usable ? solved.tally.surplus : 0,
      offcutLossM2: usable ? solved.tally.offcutLossM2 : 0,
      purchasedM2: usable ? solved.tally.purchasedM2 : 0,
    });
    // A niche is a hole in the field AND five more tiled faces. The field area
    // above already has the opening deducted, so the returns have to be added
    // back or the order is short by exactly the part that gets noticed on site.
    (s.niches || []).forEach(n => {
      const back = (n.widthMm || 0) * (n.heightMm || 0);
      const returns = 2 * (n.heightMm || 0) * (n.depthMm || 0) + 2 * (n.widthMm || 0) * (n.depthMm || 0);
      const fin = n.interiorFinish || s.finish || null;
      lines.push({
        surfaceId: `${s.id}:${n.id}`, surfaceName: `${s.name} — ${n.name} (interior)`, kind: 'Niche',
        finish: fin, finishKey: fin ? `${fin.source}:${fin.id}` : '__unassigned',
        finishName: fin ? fin.name : 'No finish selected',
        areaMm2: back + returns, waste: surfAllowanceFor(s.kind, layout.pattern), tileAreaMm2,
        piecesNominal: (back + returns) / tileAreaMm2, fullCount: 0, cutCount: 0,
        // A niche return is hand-cut off the field material; it is not solved,
        // so it stays on the allowance even when the field around it is not.
        wasteSource: 'allowance', perPack: null, surplusPieces: 0, offcutLossM2: 0, purchasedM2: 0,
      });
    });
  });
  return lines;
}
function surfScaleLines(lines, n) {
  // Pieces and offcut loss scale with the room count; BOXES do not, because
  // ceil() is applied once to the whole order and not once per room. That is
  // why packs are computed at the point of display and never carried here.
  return lines.map(l => ({ ...l, areaMm2: l.areaMm2 * n, piecesNominal: l.piecesNominal * n,
    fullCount: l.fullCount * n, cutCount: l.cutCount * n,
    surplusPieces: (l.surplusPieces || 0) * n, offcutLossM2: (l.offcutLossM2 || 0) * n,
    purchasedM2: (l.purchasedM2 || 0) * n }));
}
function surfSumByFinish(lines) {
  const map = {};
  lines.forEach(l => {
    if (!map[l.finishKey]) {
      map[l.finishKey] = { key: l.finishKey, name: l.finishName, finish: l.finish, areaMm2: 0, pieces: 0,
        waste: l.waste, computedPieces: 0, allowancePieces: 0, offcutLossM2: 0, perPack: null, anyComputed: false, anyAllowance: false };
    }
    const m = map[l.finishKey];
    m.areaMm2 += l.areaMm2;
    m.pieces += l.piecesNominal;
    m.waste = Math.max(m.waste, l.waste);
    m.offcutLossM2 += l.offcutLossM2 || 0;
    if (l.wasteSource === 'computed') { m.anyComputed = true; m.computedPieces += l.piecesNominal; if (l.perPack) m.perPack = l.perPack; }
    else { m.anyAllowance = true; m.allowancePieces += l.piecesNominal; }
  });
  // ceil() ONCE, over the whole order for that finish - boxing is a purchase
  // decision, not a per-room one, and rounding per room buys boxes nobody needs.
  return Object.keys(map).map(k => {
    const m = map[k];
    m.boxes = m.anyComputed && m.perPack ? Math.ceil(m.computedPieces / m.perPack) : null;
    m.orderPieces = m.anyComputed && !m.anyAllowance
      ? Math.ceil(m.computedPieces)
      : Math.ceil(m.computedPieces + m.allowancePieces * (1 + m.waste));
    return m;
  }).sort((a, b) => b.areaMm2 - a.areaMm2);
}

// ============================================================================
// Shared inputs
// ============================================================================

// Dimensions are stored in millimetres (contract §3). This input lets someone
// type 3'-0", 36", 914 or 914mm and keeps the stored value canonical, showing
// it back formatted in whichever system they are working in.
function SurfDimInput({ valueMm, onChange, sys, disabled, placeholder, className }) {
  const show = () => (valueMm === null || valueMm === undefined ? '' : fmtDim(valueMm, sys, { bare: sys === 'Metric' }));
  const [text, setText] = useState(show);
  const [dirty, setDirty] = useState(false);
  useEffect(() => { if (!dirty) setText(show()); }, [valueMm, sys, dirty]);
  function commit() {
    setDirty(false);
    const mm = parseDim(text, sys);
    if (mm === null) { setText(show()); return; }
    onChange(Math.round(mm * 10) / 10);
  }
  return (
    <TextInput className={className} disabled={disabled} placeholder={placeholder || (sys === 'Metric' ? 'mm' : 'e.g. 3-0"')}
      value={text} onChange={e => { setDirty(true); setText(e.target.value); }}
      onBlur={commit} onKeyDown={e => { if (e.key === 'Enter') e.target.blur(); }} />
  );
}

// The finish MUST be a supplier catalog record - there is no second materials
// list in this module. The app's own SupplierFinishPicker writes straight onto
// a scope's selections, which is not where a surface layout stores it, so this
// is the same catalog behind a callback.
function SurfFinishPicker({ ctx, value, onChange, disabled, compact }) {
  const [open, setOpen] = useState(false);
  const [pick, setPick] = useState(() => (value ? `${value.source || ''}||${value.cat || ''}` : ''));
  const [q, setQ] = useState('');
  const groups = useMemo(() => supplierGroups(), []);
  const parts = pick ? pick.split('||') : ['', ''];
  const sup = parts[0], cat = parts[1];
  const results = useMemo(() => (cat ? searchSupplierFinishes(sup, cat, q, 40) : []), [sup, cat, q]);

  if (value && !open) {
    return (
      <div className="flex items-center gap-1.5">
        {value.img && <img src={value.img} alt="" className="w-8 h-8 object-cover rounded border border-[var(--leon-line)]" />}
        <div className="min-w-0 leading-tight">
          <div className="text-xs font-semibold truncate max-w-[160px]" title={`${value.name} - ${value.code}`}>{value.name}</div>
          <div className="text-[10px] text-[var(--leon-black)]/45 truncate max-w-[160px]">
            {value.code}{` · ${supplierDisplayName(value.source, ctx.vendors)}`}
          </div>
        </div>
        {!disabled && (
          <>
            <button onClick={() => { setPick(`${value.source || ''}||${value.cat || ''}`); setOpen(true); }}
              className="text-[11px] text-[var(--leon-brown)] font-semibold">Change</button>
            <IconBtn title="Clear finish" onClick={() => onChange(null)}>✕</IconBtn>
          </>
        )}
      </div>
    );
  }
  if (disabled) return <span className="text-xs text-[var(--leon-black)]/40 italic">No finish selected</span>;
  if (!open) return <button onClick={() => setOpen(true)} className="text-xs text-[var(--leon-brown)] font-semibold whitespace-nowrap">+ Supplier finish</button>;
  return (
    <div className={`border border-[var(--leon-brown)] rounded-lg p-2 bg-white space-y-2 ${compact ? 'w-full' : 'w-full max-w-lg'}`}>
      <div className="flex items-center gap-2">
        <Select value={pick} onChange={e => { setPick(e.target.value); setQ(''); }} className="!py-1 !text-xs !w-56">
          <option value="">Vendor &amp; construction…</option>
          {groups.map(g => (
            <optgroup key={g.key} label={supplierDisplayName(g.key, ctx.vendors)}>
              {g.cats.map(c => <option key={c.sup + c.cat} value={`${c.sup}||${c.cat}`}>{c.cat} ({c.count})</option>)}
            </optgroup>
          ))}
        </Select>
        <TextInput value={q} onChange={e => setQ(e.target.value)} disabled={!cat}
          placeholder={cat ? 'Search name or supplier code…' : 'Pick a vendor & construction first'}
          className="!py-1 !text-xs flex-1" />
        <IconBtn title="Close" onClick={() => { setOpen(false); setQ(''); }}>✕</IconBtn>
      </div>
      {cat && (results.length === 0
        ? <p className="text-xs text-[var(--leon-black)]/45 px-1 py-2">Nothing in {cat} matches that.</p>
        : (
          <div className="max-h-56 overflow-y-auto divide-y divide-[var(--leon-line)]">
            {results.map(r => (
              <button key={r.id} onClick={() => { onChange(makeSupplierFinishRef(r)); setOpen(false); setQ(''); }}
                className="w-full flex items-center gap-2 px-1 py-1.5 text-left hover:bg-[var(--leon-cream)]">
                <img src={r.img} alt="" loading="lazy" className="w-9 h-9 object-cover rounded border border-[var(--leon-line)] shrink-0" />
                <span className="min-w-0">
                  <span className="block text-xs font-semibold truncate">{r.name}</span>
                  <span className="block text-[10px] text-[var(--leon-black)]/45 truncate">{r.code}{r.color ? ` · ${r.color}` : ''}</span>
                </span>
              </button>
            ))}
          </div>
        ))}
    </div>
  );
}

function SurfWarnings({ list, className }) {
  if (!list || !list.length) return null;
  const tone = l => l === 'bad' ? 'bg-[#fbe7e7] text-[#8f2f2f] border-[#f0c9c9]'
    : l === 'warn' ? 'bg-[#fbf1dd] text-[#8a6417] border-[#eddcb8]'
    : 'bg-[var(--leon-cream)] text-[var(--leon-black)]/65 border-[var(--leon-line)]';
  return (
    <div className={`space-y-1.5 ${className || ''}`}>
      {list.map((w, i) => (
        <div key={i} className={`text-xs border rounded-md px-2.5 py-1.5 flex gap-2 ${tone(w.level)}`}>
          <span aria-hidden="true">{w.level === 'bad' ? '⛔' : w.level === 'warn' ? '⚠️' : 'ℹ️'}</span>
          <span>{w.text}</span>
        </div>
      ))}
    </div>
  );
}

// "Inherited from PB-01" / "Override - Unit 503", next to the field itself.
// Nothing in this module edits a Room without saying which of the two it did.
function SurfInheritTag({ roomT, room, path, onReset, canEdit }) {
  if (!room) return null;
  const over = surfHasOverride(room, path);
  if (!over) {
    return <span className="text-[10px] text-[var(--leon-black)]/40 whitespace-nowrap">Inherited from {roomT.code}</span>;
  }
  return (
    <span className="inline-flex items-center gap-1 whitespace-nowrap">
      <span className="text-[10px] font-semibold text-[var(--leon-brown)]">Override &mdash; {room.__label || 'this room'}</span>
      {canEdit && <button onClick={() => onReset(path)} className="text-[10px] underline text-[var(--leon-black)]/50 hover:text-[var(--leon-black)]">Reset to Type</button>}
    </span>
  );
}

// ============================================================================
// Drawings - computed SVG. There is no drag-and-drop canvas editing here: you
// change a parameter and the drawing follows, which is the point of a
// parametric set-out. There is also no 3D view and no DWG/DXF export.
// ============================================================================

// A LITERAL, not `var(--leon-cream)`. A CSS custom property resolves only while
// the drawing is inside the page: serialise this SVG for an export, a PDF or an
// email and the fill is invalid, which paints every full tile BLACK. The door
// and countertop sheets use literal hex for exactly this reason. The value is
// --leon-cream itself, so nothing changes on screen.
const SURF_TILE_FULL = '#f7f3ee';
const SURF_TILE_CUT = '#e8dccd';
const SURF_TILE_SLIVER = '#f2cdcd';

// ONE piece renderer for every mode. A grid or herringbone piece is a rect; a
// diagonal piece is a rotated quad that has to be clipped to the field, since
// the lattice is laid past the walls and then cut. Two copies of this drifted
// once already (the grout joint was fixed in one and not the other), so both
// call sites read this.
function SurfPiece({ p, fill, img, useImage, flipY, H }) {
  const y = v => (flipY === undefined ? v : flipY - v);
  if (p.quad) {
    const pts = p.quad.map(q => `${q.x},${y(q.y) - (flipY === undefined ? 0 : 0)}`).join(' ');
    return (
      <polygon points={p.quad.map(q => `${q.x},${flipY === undefined ? q.y : flipY - q.y}`).join(' ')}
        fill={fill} fillOpacity={fill === 'none' ? 0 : 1}
        stroke={SURF_JOINT_COLOR} strokeWidth={SURF_JOINT_PX} vectorEffect="non-scaling-stroke" />
    );
  }
  const py = flipY === undefined ? p.y : flipY - p.y - p.h;
  return (
    <g>
      {useImage && !p.cut && img && (
        <image href={img} x={p.x} y={py} width={p.w} height={p.h} preserveAspectRatio="none" />
      )}
      <rect x={p.x} y={py} width={p.w} height={p.h} fill={fill} fillOpacity={fill === 'none' ? 0 : 1}
        stroke={SURF_JOINT_COLOR} strokeWidth={SURF_JOINT_PX} vectorEffect="non-scaling-stroke" />
    </g>
  );
}

// ── What to actually order ─────────────────────────────────────────────────
// The module counted tiles and cuts and stopped there, which is the number a
// drawing needs and NOT the number anybody buys. Tile is sold by the box, and
// the count that matters at the merchant is boxes — with breakage on top.
//
// The waste here is a BREAKAGE AND SPARES allowance, not a layout allowance:
// the cuts are already counted exactly, piece by piece, so adding a
// pattern-based waste percentage on top would charge the same waste twice.
// That distinction is the whole reason this is worth computing rather than
// multiplying an area by 1.1, and it is said on the screen.
const SURF_DEFAULT_BREAKAGE_PCT = 10;
const SURF_DEFAULT_TILES_PER_BOX = 0;    // 0 = not known, so no box count is claimed
function surfOrderQty(computed, opt) {
  const o = opt || {};
  const laid = computed.totals.total;
  const breakage = o.breakagePct === undefined || o.breakagePct === null
    ? SURF_DEFAULT_BREAKAGE_PCT : Math.max(0, Number(o.breakagePct) || 0);
  const perBox = Math.max(0, Math.round(Number(o.tilesPerBox) || 0));
  const spare = Math.ceil(laid * (breakage / 100));
  const buy = laid + spare;
  const boxes = perBox > 0 ? Math.ceil(buy / perBox) : null;
  const tileArea = (computed.tileW * computed.tileH) / 1e6;   // m2
  return {
    laid, cut: computed.totals.cut, full: computed.totals.full,
    breakagePct: breakage, spare, buy, perBox, boxes,
    boxedTiles: boxes === null ? null : boxes * perBox,
    leftover: boxes === null ? null : boxes * perBox - buy,
    fieldM2: computed.totals.fieldAreaMm2 / 1e6,
    tileM2: tileArea,
    // What the cuts actually cost, which is the honest version of a "waste
    // factor": the stone bought less the stone laid, as a share.
    cutSharePct: laid > 0 ? (computed.totals.cut / laid) * 100 : 0,
  };
}

// The cut list, grouped the way a tiler reads it — "7 tiles cut to 1.9 in",
// not a course-by-course schedule. Grid and herringbone pieces stay
// rectangular so this is exact; a diagonal is triangles and says so instead.
function surfCutList(computed, tol) {
  if (computed.mode === 'diagonal') return null;
  const t = tol || 1;                                   // mm, to fold near-identical cuts together
  const tw = computed.tileW, th = computed.tileH;
  const groups = {};
  computed.pieces.forEach(p => {
    if (!p.cut || p.quad) return;
    const w = Math.round(p.w / t) * t, h = Math.round(p.h / t) * t;
    const cutW = w < tw - 0.5, cutH = h < th - 0.5;
    const kind = cutW && cutH ? 'both' : cutW ? 'width' : 'length';
    const key = `${kind}|${w}|${h}`;
    if (!groups[key]) groups[key] = { kind, wMm: w, hMm: h, count: 0 };
    groups[key].count += 1;
  });
  return Object.keys(groups).map(k => groups[k]).sort((a, b) => b.count - a.count);
}
function surfCutLabel(g, system) {
  const f = mm => fmtDim(mm, system, { inchesOnly: true });
  if (g.kind === 'both') return `Cut both ways — ${f(g.wMm)} × ${f(g.hMm)}`;
  if (g.kind === 'width') return `Cut the width to ${f(g.wMm)}, full ${f(g.hMm)} height`;
  return `Cut the height to ${f(g.hMm)}, full ${f(g.wMm)} width`;
}

// ── Solving the set-out ─────────────────────────────────────────────────────
// The module already DETECTED a sliver and told you to move the set-out. It
// never searched for where to move it to, which leaves the one useful thing
// undone: a sliver is not fixed by knowing about it.
//
// What is being solved: WHERE THE GRID STARTS. Sliding the field horizontally
// by a fraction of a module, and the first course vertically by a fraction of a
// course, changes nothing about the tile, the pattern or the joint — it changes
// only which offcut lands at which wall. That is exactly the decision a
// setter-out makes with a chalk line, and it is the only thing here worth
// searching over.
//
// THE OBJECTIVE IS THE BIGGEST SMALLEST CUT, not the most full tiles.
// A 2" sliver and a 10" cut both count as one cut, so counting cuts says they
// are equally good — and they are not: the 2" one chips on the saw, will not
// bed properly and is what the client sees at the door. Maximising the WORST
// cut is what a tiler actually wants, and it satisfies the minimum by
// construction rather than as a separate test. "Most full tiles" is offered as
// a second objective because it is what TilePro optimises and it is the right
// answer when the tile is expensive.
const SURF_SOLVE_STEPS = 96;          // per axis; 96 x 96 would be 9,216 layouts
const SURF_SOLVE_OBJECTIVES = [
  { key: 'safest', label: 'Biggest smallest cut',
    note: 'Pushes the worst cut on the surface as large as it will go. What a tiler wants: no sliver to chip, nothing thin at a door.' },
  { key: 'fewest', label: 'Most full tiles',
    note: 'Fewest cuts to make. The right answer when the tile is expensive or the cutting is slow, provided nothing falls under the minimum.' },
];

// One candidate, scored. `minEnd` is the smallest END cut on any course — the
// sliver-at-the-wall figure — and `minBand` the smallest partial COURSE height.
function surfScoreSetOut(res, minCut) {
  let minEnd = Infinity, cuts = 0, full = 0;
  (res.courses || []).forEach(c => {
    if (c.leftCutMm !== null) minEnd = Math.min(minEnd, c.leftCutMm);
    if (c.rightCutMm !== null) minEnd = Math.min(minEnd, c.rightCutMm);
    cuts += c.cutCount; full += c.fullCount;
  });
  const bands = (res.courses || []).filter(c => c.isPartial);
  const minBand = bands.length ? Math.min.apply(null, bands.map(c => c.heightMm)) : Infinity;
  // The worst thing on the surface, whichever axis it is on.
  const worst = Math.min(minEnd, minBand);
  return {
    minEnd: minEnd === Infinity ? null : minEnd,
    minBand: minBand === Infinity ? null : minBand,
    worst: worst === Infinity ? null : worst,
    cuts, full, total: cuts + full,
    ok: worst === Infinity || worst >= minCut,
  };
}

// The search. X and Y are swept INDEPENDENTLY and the winner is then evaluated
// jointly — which is honest rather than exhaustive, and worth saying why: the
// horizontal phase and the starting elevation are very nearly independent in a
// grid (a course's own step comes from its NUMBER, so the set of horizontal
// offsets is the same whichever courses exist). A full 2D sweep is 9,216
// layouts for an answer that differs on contrived inputs; two 96-step sweeps
// are 192 and the joint check catches any coupling that survives.
function surfSolveSetOut(surface, layout, opt) {
  const o = opt || {};
  const L = layout || surface.layout;
  const objective = o.objective || 'safest';
  const minCut = o.minCutMm || L.minCutMm || SURF_DEFAULT_MIN_CUT_MM;
  const probe = (phaseXMm, startElevMm) => {
    const lay = startElevMm === null || startElevMm === undefined
      ? L : Object.assign({}, L, { originY: 'elevation', startElevMm });
    const res = surfComputeSurface(surface, lay, { phaseXMm, minCutMm: minCut });
    return { res, score: surfScoreSetOut(res, minCut), phaseXMm, startElevMm };
  };

  const base = surfComputeSurface(surface, L, { minCutMm: minCut });
  if (base.mode !== 'grid') {
    return { solvable: false,
      reason: base.mode === 'herringbone'
        ? 'Herringbone has no continuous courses to slide, so there is no set-out origin to solve for.'
        : 'A diagonal grid meets every wall at 45°, so moving the origin trades one triangle for another rather than removing a sliver.' };
  }
  const current = { res: base, score: surfScoreSetOut(base, minCut),
    phaseXMm: base.phaseX, startElevMm: surfStartElevOf(L) };

  // Better of two candidates, under the chosen objective. A candidate that
  // clears the minimum always beats one that does not, whichever objective is
  // running — the minimum is a rule, not a preference.
  const better = (a, b) => {
    if (!b) return true;
    if (a.score.ok !== b.score.ok) return a.score.ok;
    if (objective === 'fewest') {
      if (a.score.cuts !== b.score.cuts) return a.score.cuts < b.score.cuts;
      return (a.score.worst || 0) > (b.score.worst || 0);
    }
    if ((a.score.worst || 0) !== (b.score.worst || 0)) return (a.score.worst || 0) > (b.score.worst || 0);
    return a.score.cuts < b.score.cuts;
  };

  const mx = base.moduleX, my = base.moduleY;
  let bestX = null;
  for (let i = 0; i < SURF_SOLVE_STEPS; i++) {
    const c = probe((i / SURF_SOLVE_STEPS) * mx, current.startElevMm);
    if (better(c, bestX)) bestX = c;
  }
  let bestY = bestX;
  // Only worth sweeping the vertical where the courses actually start from an
  // elevation. A floor centred on the room has no starting course to move.
  if (L.originY === 'elevation' || L.originY === 'bottom') {
    for (let j = 0; j < SURF_SOLVE_STEPS; j++) {
      const c = probe(bestX.phaseXMm, (j / SURF_SOLVE_STEPS) * my);
      if (better(c, bestY)) bestY = c;
    }
  }
  const best = bestY;
  const gain = {
    worst: (best.score.worst || 0) - (current.score.worst || 0),
    cuts: current.score.cuts - best.score.cuts,
    full: best.score.full - current.score.full,
  };
  return {
    solvable: true, objective, minCut, current, best, gain,
    tried: SURF_SOLVE_STEPS * ((L.originY === 'elevation' || L.originY === 'bottom') ? 2 : 1),
    // Nothing to do when the set-out already clears the minimum and the search
    // cannot improve the worst cut — saying so is more useful than offering a
    // move that changes nothing.
    worthwhile: !current.score.ok || gain.worst > 1 || gain.cuts > 0,
  };
}
// What the layout's current starting elevation IS, in the terms the solver
// writes back — `surfAnchorY` answers in field coordinates, this answers in the
// field the solver sets.
function surfStartElevOf(L) {
  if (L.originY === 'elevation') return surfNum(L.startElevMm) || 0;
  return null;
}
function surfNum(v) { const n = Number(v); return isFinite(n) ? n : 0; }

function surfPieceFill(p, minCut, hasImage) {
  if (!p.cut) return hasImage ? 'none' : SURF_TILE_FULL;
  // A diagonal piece is a triangle with no width or height to test, so it is
  // simply a cut — the sliver rule is a rectangular one.
  if (p.quad) return SURF_TILE_CUT;
  return Math.min(p.w, p.h) < minCut ? SURF_TILE_SLIVER : SURF_TILE_CUT;
}

// One surface, drawn in millimetres. The viewBox IS the field, so every stroke
// uses non-scaling-stroke and every label is sized off the field - the drawing
// then reads the same at any container size without a resize observer.
function SurfSurfaceDrawing({ entry, minCutMm, height, showNiches }) {
  const c = entry.computed;
  const s = entry.surface;
  const W = c.W, H = c.H;
  const pad = Math.max(W, H) * 0.04;
  const fs = Math.max(W, H) / 45;
  const img = s.finish && s.finish.img;
  const useImages = !!img && c.pieces.length <= 420;
  return (
    <svg viewBox={`${-pad} ${-pad} ${W + pad * 2} ${H + pad * 2}`} style={{ width: '100%', height: height || 300 }}
      preserveAspectRatio="xMidYMid meet" role="img" aria-label={`${s.name} tile set-out`}>
      <rect x={0} y={0} width={W} height={H} fill="#fff" stroke="var(--leon-black)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
      <defs>
        <clipPath id={`surfclip-${s.id}`}><rect x={0} y={0} width={W} height={H} /></clipPath>
      </defs>
      <g clipPath={`url(#surfclip-${s.id})`}>
        {c.pieces.map((p, i) => (
          <SurfPiece key={i} p={p} fill={surfPieceFill(p, minCutMm, useImages)}
            img={img} useImage={useImages} flipY={H} />
        ))}
      </g>
      {showNiches !== false && (s.niches || []).map(n => {
        const r = surfNicheRect(n, W);
        return (
          <g key={n.id}>
            <rect x={r.x} y={H - r.y - r.h} width={r.w} height={r.h} fill="#fff" stroke="var(--leon-black)" strokeWidth="1.6" vectorEffect="non-scaling-stroke" />
            <text x={r.x + r.w / 2} y={H - r.y - r.h / 2 + fs / 3} fontSize={fs} textAnchor="middle" fill="var(--leon-black)">{n.name}</text>
          </g>
        );
      })}
      <rect x={0} y={0} width={W} height={H} fill="none" stroke="var(--leon-black)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

// UNFOLDED ELEVATION - the walls laid out side by side as if the room were cut
// at one corner and opened flat. The dashed rules are the MASTER surface's
// course lines carried right across, which is the only way to see at a glance
// whether courses, grout lines and accent bands line through the corners.
function SurfUnfoldedElevation({ roomT, computedRoom, sys, keys }) {
  const order = keys || SURF_WALL_KEYS;
  const walls = order
    .map(k => (roomT.surfaces || []).find(s => s.key === k))
    .filter(Boolean)
    .map(s => computedRoom.surfaces[s.id])
    .filter(Boolean);
  if (!walls.length) return <EmptyState text="No walls on this room type yet." />;

  const gap = Math.max.apply(null, walls.map(w => w.computed.W)) * 0.05;
  const totalW = walls.reduce((a, w) => a + w.computed.W, 0) + gap * (walls.length - 1);
  const maxH = Math.max.apply(null, walls.map(w => w.computed.H));
  const pad = totalW * 0.03;
  const fs = totalW / 90;

  const master = computedRoom.surfaces[computedRoom.master];
  const guides = master && master.computed.mode === 'grid'
    ? master.computed.courses.map(c => c.topMm).filter(v => v < maxH - 1)
    : [];

  let x = 0;
  const placed = walls.map(w => { const at = x; x += w.computed.W + gap; return { w, at }; });

  return (
    <div className="overflow-x-auto">
      <svg viewBox={`${-pad} ${-pad} ${totalW + pad * 2} ${maxH + pad * 2 + fs * 3}`}
        style={{ width: '100%', minWidth: 720, height: 340 }} preserveAspectRatio="xMidYMid meet"
        role="img" aria-label="Unfolded elevation of the room">
        {guides.map((g, i) => (
          <line key={i} x1={0} y1={maxH - g} x2={totalW} y2={maxH - g}
            stroke="var(--leon-brown-light)" strokeWidth="0.7" strokeDasharray="6 5" vectorEffect="non-scaling-stroke" opacity="0.75" />
        ))}
        {placed.map(({ w, at }) => {
          const c = w.computed, s = w.surface;
          const y0 = maxH - c.H;
          const img = s.finish && s.finish.img;
          const useImages = !!img && c.pieces.length <= 260;
          const minCut = w.layout.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM;
          return (
            <g key={s.id} transform={`translate(${at},0)`}>
              <rect x={0} y={y0} width={c.W} height={c.H} fill="#fff" />
              <defs>
                <clipPath id={`surfuclip-${s.id}`}><rect x={0} y={y0} width={c.W} height={c.H} /></clipPath>
              </defs>
              <g clipPath={`url(#surfuclip-${s.id})`}>
                {c.pieces.map((p, i) => (
                  <SurfPiece key={i} p={p} fill={surfPieceFill(p, minCut, useImages)}
                    img={img} useImage={useImages} flipY={maxH} />
                ))}
              </g>
              {(s.niches || []).map(n => {
                const r = surfNicheRect(n, c.W);
                return <rect key={n.id} x={r.x} y={maxH - r.y - r.h} width={r.w} height={r.h}
                  fill="#fff" stroke="var(--leon-black)" strokeWidth="1.4" vectorEffect="non-scaling-stroke" />;
              })}
              <rect x={0} y={y0} width={c.W} height={c.H} fill="none" stroke="var(--leon-black)" strokeWidth="1.4" vectorEffect="non-scaling-stroke" />
              <text x={c.W / 2} y={maxH + fs * 1.8} fontSize={fs} textAnchor="middle" fill="var(--leon-black)" fontWeight="600">{s.name}</text>
              <text x={c.W / 2} y={maxH + fs * 3} fontSize={fs * 0.85} textAnchor="middle" fill="var(--leon-black)" opacity="0.55">
                {fmtDim(c.W, sys)} &times; {fmtDim(c.H, sys)}
              </text>
              {s.id === computedRoom.master && (
                <text x={c.W / 2} y={y0 - fs * 0.6} fontSize={fs * 0.9} textAnchor="middle" fill="var(--leon-brown)" fontWeight="700">MASTER</text>
              )}
            </g>
          );
        })}
        {placed.slice(0, -1).map(({ w, at }, i) => {
          const nextW = placed[i + 1].w;
          const corner = (roomT.corners || []).find(c => c.kind !== 'base' &&
            ((c.aSurfaceId === w.surface.id && c.bSurfaceId === nextW.surface.id) ||
             (c.bSurfaceId === w.surface.id && c.aSurfaceId === nextW.surface.id)));
          const cx = at + w.computed.W + gap / 2;
          return (
            <g key={`j${i}`}>
              <line x1={cx} y1={0} x2={cx} y2={maxH} stroke="var(--leon-black)" strokeWidth="0.8"
                strokeDasharray="4 4" vectorEffect="non-scaling-stroke" opacity="0.5" />
              <text x={cx} y={maxH + fs * 1.8} fontSize={fs * 0.8} textAnchor="middle"
                fill={corner && corner.continuity === 'Continuous' ? 'var(--leon-brown)' : 'var(--leon-black)'} opacity="0.75">
                {corner ? corner.continuity : 'not joined'}
              </text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}

// Plan view - which wall is which, and which one the set-out is driven from.
// Clicking a wall selects it, the same as clicking its nav chip.
function SurfPlanView({ roomT, computedRoom, selectedId, onSelect, sys, height }) {
  const W = Math.max(1, roomT.widthMm), L = Math.max(1, roomT.lengthMm);
  const t = Math.max(W, L) * 0.055;
  const pad = Math.max(W, L) * 0.12;
  const fs = Math.max(W, L) / 26;
  const byKey = k => (roomT.surfaces || []).find(s => s.key === k);
  const bands = [
    { k: 'north', x: 0, y: -t, w: W, h: t },
    { k: 'south', x: 0, y: L, w: W, h: t },
    { k: 'west', x: -t, y: 0, w: t, h: L },
    { k: 'east', x: W, y: 0, w: t, h: L },
  ];
  const floor = byKey('floor');
  return (
    <svg viewBox={`${-t - pad} ${-t - pad} ${W + t * 2 + pad * 2} ${L + t * 2 + pad * 2}`}
      style={{ width: '100%', height: height || 220 }} preserveAspectRatio="xMidYMid meet" role="img" aria-label="Room plan">
      {floor && (
        <rect x={0} y={0} width={W} height={L} onClick={() => onSelect && onSelect(floor.id)}
          style={{ cursor: onSelect ? 'pointer' : 'default' }}
          fill={selectedId === floor.id ? 'var(--leon-brown-light)' : 'var(--leon-cream)'}
          fillOpacity={selectedId === floor.id ? 0.45 : 1}
          stroke="var(--leon-line)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
      )}
      <text x={W / 2} y={L / 2 + fs / 3} fontSize={fs} textAnchor="middle" fill="var(--leon-black)" opacity="0.5">
        {fmtDim(W, sys)} &times; {fmtDim(L, sys)}
      </text>
      {bands.map(b => {
        const s = byKey(b.k);
        if (!s) return null;
        const sel = selectedId === s.id;
        const isMaster = computedRoom && s.id === computedRoom.master;
        return (
          <g key={b.k} onClick={() => onSelect && onSelect(s.id)} style={{ cursor: onSelect ? 'pointer' : 'default' }}>
            <rect x={b.x} y={b.y} width={b.w} height={b.h}
              fill={sel ? 'var(--leon-brown)' : isMaster ? 'var(--leon-brown-light)' : 'var(--leon-black)'}
              fillOpacity={sel ? 1 : isMaster ? 0.55 : 0.15}
              stroke="var(--leon-black)" strokeWidth="0.8" vectorEffect="non-scaling-stroke" />
            <text x={b.x + b.w / 2} y={b.y + b.h / 2 + fs / 3} fontSize={fs * 0.8} textAnchor="middle"
              fill={sel ? '#fff' : 'var(--leon-black)'} fontWeight="700">{SURF_WALL_LABELS[b.k].toUpperCase()}</text>
          </g>
        );
      })}
      {[[0, 0], [W, 0], [W, L], [0, L]].map((p, i) => (
        <circle key={i} cx={p[0]} cy={p[1]} r={Math.max(W, L) / 90} fill="var(--leon-brown)" />
      ))}
    </svg>
  );
}

// ============================================================================
// Type -> instance diffing and impact
// ============================================================================

const SURF_ROOM_FIELDS = ['code', 'name', 'widthMm', 'lengthMm', 'heightMm', 'minCutMm', 'masterSurfaceId', 'plan', 'accessories'];
const SURF_SURFACE_FIELDS = ['name', 'kind', 'widthMm', 'heightMm', 'surfaceTypeId'];
const SURF_LAYOUT_FIELDS = ['tileWmm', 'tileHmm', 'groutMm', 'startElevMm', 'originX', 'originY', 'pattern', 'minCutMm'];
const SURF_CORNER_FIELDS = ['continuity', 'alignCourses', 'customLeadMm'];
const SURF_NICHE_FIELDS = ['name', 'widthMm', 'heightMm', 'depthMm', 'bottomElevMm', 'centerMm', 'alignV', 'alignH'];

function surfSame(a, b) {
  if (a === b) return true;
  if (a === null || a === undefined || b === null || b === undefined) return (a === null || a === undefined) && (b === null || b === undefined);
  if (typeof a === 'object' || typeof b === 'object') return JSON.stringify(a) === JSON.stringify(b);
  return false;
}
// Which fields a pending Room Type edit actually moves. The impact review is
// built on this list, not on "the type changed" — a Room only has to be told
// about the fields it might disagree with.
function surfDiffPaths(saved, draft) {
  const out = [];
  const structural = [];
  if (!saved || !draft) return { paths: out, structural };
  SURF_ROOM_FIELDS.forEach(f => {
    if (!surfSame(saved[f], draft[f])) out.push({ path: `room.${f}`, from: saved[f], to: draft[f] });
  });
  const savedS = {}; (saved.surfaces || []).forEach(s => { savedS[s.id] = s; });
  const draftS = {}; (draft.surfaces || []).forEach(s => { draftS[s.id] = s; });
  Object.keys(draftS).forEach(id => {
    const b = draftS[id], a = savedS[id];
    if (!a) { structural.push(`Adds the surface "${b.name}"`); return; }
    SURF_SURFACE_FIELDS.forEach(f => {
      if (!surfSame(a[f], b[f])) out.push({ path: `surface.${id}.${f}`, from: a[f], to: b[f] });
    });
    if (!surfSame(a.finish, b.finish)) out.push({ path: `surface.${id}.finish`, from: a.finish, to: b.finish });
    // The set-out is compared whole, the way the finish is: it is one decision,
    // and a room either holds its own or reads the type's.
    if (!surfSame(a.setout, b.setout)) out.push({ path: `surface.${id}.setout`, from: a.setout, to: b.setout });
    SURF_LAYOUT_FIELDS.forEach(f => {
      if (!surfSame((a.layout || {})[f], (b.layout || {})[f])) {
        out.push({ path: `surface.${id}.layout.${f}`, from: (a.layout || {})[f], to: (b.layout || {})[f] });
      }
    });
    const savedN = {}; (a.niches || []).forEach(n => { savedN[n.id] = n; });
    (b.niches || []).forEach(n => {
      const an = savedN[n.id];
      if (!an) { structural.push(`Adds "${n.name}" to ${b.name}`); return; }
      SURF_NICHE_FIELDS.forEach(f => {
        if (!surfSame(an[f], n[f])) out.push({ path: `niche.${id}.${n.id}.${f}`, from: an[f], to: n[f] });
      });
      if (!surfSame(an.interiorFinish, n.interiorFinish)) out.push({ path: `niche.${id}.${n.id}.interiorFinish`, from: an.interiorFinish, to: n.interiorFinish });
    });
    (a.niches || []).forEach(n => { if (!(b.niches || []).some(x => x.id === n.id)) structural.push(`Removes "${n.name}" from ${b.name}`); });
  });
  Object.keys(savedS).forEach(id => { if (!draftS[id]) structural.push(`Removes the surface "${savedS[id].name}"`); });
  const savedC = {}; (saved.corners || []).forEach(c => { savedC[c.id] = c; });
  (draft.corners || []).forEach(c => {
    const a = savedC[c.id];
    if (!a) { structural.push(`Adds the joint "${c.name}"`); return; }
    SURF_CORNER_FIELDS.forEach(f => {
      if (!surfSame(a[f], c[f])) out.push({ path: `corner.${c.id}.${f}`, from: a[f], to: c[f] });
    });
  });
  return { paths: out, structural };
}

function surfInstancesOfType(projects, typeId) {
  const out = [];
  (projects || []).forEach(p => surfProjectRooms(p).forEach(r => {
    if (r.roomTypeId === typeId) out.push({ project: p, room: r });
  }));
  return out;
}
// The four numbers a change review has to answer before anything is written:
// how many use it, how many will actually move, how many are holding their own
// value, and how many are already on the wall.
function surfImpact(projects, typeId, changedPaths) {
  const inst = surfInstancesOfType(projects, typeId);
  const willUpdate = [], holding = [], installed = [];
  inst.forEach(row => {
    if (row.room.status === 'Installed') { installed.push(row); return; }
    const holds = changedPaths.filter(cp => surfHasOverride(row.room, cp.path));
    if (holds.length) holding.push({ ...row, holds });
    else willUpdate.push(row);
  });
  return { total: inst.length, willUpdate, holding, installed };
}
// An installed room is protected by writing the CURRENT type values in as its
// own overrides. Nothing else would work: a Room reads live through to its
// Type, so the only way to stop a change reaching an as-built room is to give
// that room the old value to hold.
function surfSaveTypeWithFreeze(ctx, saved, draft, changedPaths) {
  surfSetLib(ctx, l => { l.roomTypes = l.roomTypes.map(t => (t.id === draft.id ? cloneDeep(draft) : t)); });
  if (!changedPaths.length) return;
  (ctx.projects || []).forEach(p => {
    const hits = surfProjectRooms(p).filter(r => r.roomTypeId === draft.id && r.status === 'Installed');
    if (!hits.length) return;
    ctx.updateProject(p.id, d => {
      d.rooms = surfProjectRooms(d).map(r => {
        if (r.roomTypeId !== draft.id || r.status !== 'Installed') return r;
        const ov = { ...(r.overrides || {}) };
        changedPaths.forEach(cp => {
          if (Object.prototype.hasOwnProperty.call(ov, cp.path)) return;
          const v = surfTypeValueAt(saved, cp.path);
          if (v !== undefined) ov[cp.path] = cloneDeep(v);
        });
        return { ...r, overrides: ov };
      });
      surfLog(d, ctx, `Surfaces: Room Type ${draft.code} changed — ${hits.length} installed room(s) frozen at their as-built values`);
    });
  });
}

// ============================================================================
// Entry component
// ============================================================================

function SurfaceSoftware({ ctx }) {
  const [tab, setTab] = useState('roomTypes');
  const [sys, setSys] = useState('Imperial');
  const [openTypeId, setOpenTypeId] = useState(null);
  // deptProjects is a FILTER — it takes the list and returns the ones in the
  // active department. Treating it as an array reads its arity as a length,
  // which is truthy, and hands a function to .find().
  const projectPool = (typeof ctx.deptProjects === 'function'
    ? ctx.deptProjects(ctx.projects || []) : (ctx.projects || []));
  const [projectId, setProjectId] = useState(() => (projectPool[0] ? projectPool[0].id : null));
  const project = projectPool.find(p => p.id === projectId) || null;

  // Room Types are a company library, so they follow the LEON Collection right;
  // the instances hang off a project and follow that project's Selection Hub.
  const canEditLib = !!(ctx.canManageCollection || ctx.canEdit('selections'));
  const canEditProject = !!ctx.canEdit('selections');

  const lib = surfLib(ctx);

  // Grouped the way every other drawing tool is: the work you do on a job,
  // then the standards the job is built to. A settings screen sitting between
  // two working screens is how someone opens it by accident.
  const tabs = [
    { key: 'roomTypes', label: 'Room Types', icon: '🧱', group: 'This job' },
    { key: 'unitTypes', label: 'Unit Types', icon: '🏢', group: 'This job' },
    { key: 'project', label: 'Buildings & Rooms', icon: '🗂️', group: 'This job' },
    { key: 'sheet', label: 'Shop Drawing', icon: '📄', group: 'This job' },
    { key: 'setout', label: 'Set-Out & Waste', icon: '🧩', group: 'This job' },
    { key: 'quantities', label: 'Quantities', icon: '📐', group: 'This job' },
    { key: 'setup', label: 'Surface Types & Presets', icon: '⚙️', group: 'Tile Settings' },
    { key: 'about', label: 'About this module', icon: 'ℹ️', group: 'Tile Settings' },
  ];

  return (
    <div className="space-y-4" data-print-region="Surface Layout & Finish Designer">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <h2 className="text-xl font-bold">Surface Layout &amp; Finish Designer</h2>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-2xl mt-1">
            A room is designed once as a <b>Room Type</b> — floor, four walls and the joints between
            them — and every physical room that uses it inherits the set-out. Finishes come from the
            supplier catalog the rest of the app already uses.
          </p>
        </div>
        <div className="flex items-end gap-2">
          <Field label="Units">
            <Select value={sys} onChange={e => setSys(e.target.value)} className="!w-auto">
              <option>Imperial</option>
              <option>Metric</option>
            </Select>
          </Field>
          <HubTools title="Surface Layout & Finish Designer" heading="Surface Layout & Finish Designer" />
        </div>
      </div>


      <SoftwareRail swKey="surfaces" sections={tabs} active={tab}
        onSelect={setTab}
        status={<><span>{sys}</span></>}>
      {tab === 'roomTypes' && (openTypeId
        ? <SurfRoomTypeEditor ctx={ctx} typeId={openTypeId} sys={sys} canEditLib={canEditLib} onBack={() => setOpenTypeId(null)} />
        : <SurfRoomTypesList ctx={ctx} lib={lib} sys={sys} canEditLib={canEditLib} onOpen={setOpenTypeId} />)}

      {tab === 'unitTypes' && <SurfUnitTypesTab ctx={ctx} lib={lib} canEditLib={canEditLib} onOpenType={id => { setOpenTypeId(id); setTab('roomTypes'); }} />}

      {tab === 'setout' && (
        <SurfSetOutTab ctx={ctx} lib={lib} sys={sys} canEditLib={canEditLib}
          projectPool={projectPool} project={project} onPickProject={setProjectId} />
      )}

      {tab === 'project' && (
        <SurfProjectTab ctx={ctx} lib={lib} sys={sys} canEdit={canEditProject}
          projectPool={projectPool} project={project} onPickProject={setProjectId}
          onOpenType={id => { setOpenTypeId(id); setTab('roomTypes'); }} />
      )}

      {tab === 'quantities' && (
        <SurfQuantitiesTab ctx={ctx} lib={lib} sys={sys}
          projectPool={projectPool} project={project} onPickProject={setProjectId} />
      )}

      {tab === 'sheet' && (
        <SurfSheetTab ctx={ctx} lib={lib} sys={sys} project={project} />
      )}

      {tab === 'setup' && <SurfSetupTab ctx={ctx} lib={lib} sys={sys} canEditLib={canEditLib} />}

      {tab === 'about' && <SurfAboutTab />}
      </SoftwareRail>
    </div>
  );
}

// One sheet per surface: pick the room type and the surface and it draws.
// A tile sheet is read surface by surface — nobody sets out four walls at once
// off one sheet — so this paginates rather than cramming.
function SurfSheetTab({ ctx, lib, sys, project }) {
  const types = (lib && lib.roomTypes) || [];
  const [typeId, setTypeId] = useState(types[0] ? types[0].id : '');
  const [surfId, setSurfId] = useState('');
  const [sizeKey, setSizeKey] = useState('A2');
  const [scaleKey, setScaleKey] = useState('fit');
  const ref = useRef(null);
  const roomT = types.find(t => t.id === typeId) || types[0] || null;
  const computed = roomT ? surfComputeRoom(roomT) : null;
  const surfaces = (roomT && roomT.surfaces) || [];
  const surface = surfaces.find(x => x.id === surfId) || surfaces[0] || null;
  const entry = surface && computed ? computed.surfaces[surface.id] : null;
  const scales = (typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [];
  const sizes = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [];

  if (!types.length) return <EmptyState text="No room types yet. A sheet is drawn from a room type's surfaces." />;
  if (!entry) return <EmptyState text="This room type has no surfaces yet." />;

  return (
    <div className="space-y-3">
      <div>
        <h3 className="font-bold">📄 Shop Drawing</h3>
        <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
          One surface at a stated scale, with LEON's own lockup, the set-out down the side, and the tile and
          the cut list along the bottom &mdash; the same sheet model the door and countertop drawings use, so a
          reviewer learns one sheet and not one per trade. Drawn from the same set-out the designer screen
          shows, so the sheet and the design cannot disagree.
        </p>
      </div>

      <div className="flex items-end gap-3 flex-wrap">
        <Field label="Room type">
          <Select className="!w-52" value={roomT ? roomT.id : ''}
            onChange={e => { setTypeId(e.target.value); setSurfId(''); }}>
            {types.map(t => <option key={t.id} value={t.id}>{t.code} · {t.name}</option>)}
          </Select>
        </Field>
        <Field label="Surface">
          <Select className="!w-44" value={surface ? surface.id : ''} onChange={e => setSurfId(e.target.value)}>
            {surfaces.map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
          </Select>
        </Field>
        <Field label="Sheet size">
          <Select className="!w-48" value={sizeKey} onChange={e => setSizeKey(e.target.value)}>
            {sizes.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <Field label="Scale">
          <Select className="!w-44" value={scaleKey} onChange={e => setScaleKey(e.target.value)}>
            <option value="fit">Fit to the sheet</option>
            {scales.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <div className="ml-auto flex items-end gap-1.5">
          <IconAction icon="🖨" title="Print this sheet"
            onClick={() => printRegion(ref.current, {
              title: `${roomT.code} — ${surface.name}`, heading: 'Tile shop drawing' })} />
        </div>
      </div>

      <div ref={ref} data-print-region="Tile shop drawing"
        className="overflow-auto rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/40 p-3">
        <SurfShopDrawingPage project={project} ctx={ctx} roomT={roomT} entry={entry}
          size={sizeKey} system={sys} autoFit={scaleKey === 'fit'}
          denom={(scales.find(z => z.key === scaleKey) || {}).denom || 20}
          sheetNo={`TL-${String(surfaces.indexOf(surface) + 1).padStart(2, '0')}`} />
      </div>

      <p className="text-[11px] text-[var(--leon-black)]/45 max-w-3xl">
        A browser prints through the page, so the sheet size here sets the DRAWING size &mdash; choose matching
        paper in the print dialog and turn scaling off, or the scale in the header stops being true.
      </p>
    </div>
  );
}

function SurfAboutTab() {
  return (
    <div className="space-y-3">
      <Collapsible title="How this module is built" defaultOpen id="surf-about-built">
        <div className="text-sm space-y-3 text-[var(--leon-black)]/75">
          <p>
            <b>Connected geometry.</b> Entering a room&rsquo;s width, length and height creates the floor and
            the four walls in one action and records every joint between them — the north wall&rsquo;s right
            edge meets the east wall&rsquo;s left edge, and that pairing is a stored record, not something
            re-derived each time a drawing is opened. Continuity around a corner is then a lookup.
          </p>
          <p>
            <b>Inheritance.</b> A <b>Room Type</b> is the standard. A <b>Room</b> is the physical one in a unit
            on a floor of a building. A Room reads straight through to its Type and stores only the fields
            it disagrees with, so correcting the Type corrects every room that has not been given its own answer.
          </p>
          <p>
            <b>Finishes.</b> Every finish is a reference into the existing supplier catalog — the same records
            behind LEON Collection, the vendor pages and the Selection Hub. There is no second materials list here.
          </p>
          <p>
            <b>Waste is computed, never entered.</b> Under <b>Set-Out &amp; Waste</b> a room is a real polygon and the
            material is laid on it piece by piece: every row walked, every cut taken from the smallest offcut
            already on the shelf that is big enough, and whatever is left charged as loss. What comes out is a
            piece count, a box count, surplus pieces and a waste percentage — not <i>area &times; 1.10</i>. The two
            waste percentages in this software&rsquo;s settings are still there, but they are the figure to use
            <i>before</i> a set-out exists and nowhere else; the moment one exists the computed figure supersedes
            them and every screen says which of the two it is showing.
          </p>
          <p>
            <b>Slivers are prevented, not reported.</b> The solver searches start positions and rejects any that
            lands a row on a cut under the minimum start or end length. A warning would leave the problem for
            whoever reads it; this moves the grid.
          </p>
          <p>
            <b>A set-out belongs to the room TYPE.</b> Solve it once and every physical room using that type
            inherits it, through the same override map every other field here uses. That is the thing a
            stand-alone layout tool cannot do at all.
          </p>
        </div>
      </Collapsible>
      <Collapsible title="What this module does not do" defaultOpen id="surf-about-not">
        <ul className="text-sm space-y-2 text-[var(--leon-black)]/75 list-disc pl-5">
          <li><b>No 3D view.</b> The drawings are computed 2D SVG — a plan, an unfolded elevation and a per-surface set-out.</li>
          <li><b>No DWG / DXF export.</b> Nothing here writes a CAD file. Use the 🖨 and 📄 buttons for a printed or PDF set-out.</li>
          <li><b>No drag-and-drop canvas editing.</b> Layouts are parametric: change tile size, joint, origin, pattern or starting elevation and the drawing follows. You cannot nudge an individual tile, and a set-out that only exists as a nudge could not be reproduced on site.</li>
          <li><b>No AI layout.</b> Nothing in this module calls a model. The warnings are arithmetic against the minimum cut you set.</li>
          <li><b>Herringbone</b> is drawn and counted, but produces no course elevation schedule, because it has no continuous horizontal courses. It sets out exactly on a 2:1 piece; on any other ratio the drift is named rather than silently corrected.</li>
          <li>
            <b>No carpet and no sheet goods.</b> Broadloom and sheet vinyl are a different problem — roll width,
            cut lengths, pile direction and a seam plan — and a piece solver answers none of it. This module will
            not give you a carpet quantity and does not pretend to.
          </li>
          <li><b>No curves.</b> A room outline is straight segments only. Approximating a curved wall with a polyline would put a dimension on a drawing nobody could build to.</li>
          <li><b>Auto-detecting a room outline only works on a vector PDF.</b> A flattened scan carries no path geometry at all, and the tracing panel says so and asks for a manual trace rather than producing an outline that looks authoritative and is wrong.</li>
        </ul>
      </Collapsible>
    </div>
  );
}

// ---- Room Types: the list -------------------------------------------------

function SurfRoomTypesList({ ctx, lib, sys, canEditLib, onOpen }) {
  const [q, setQ] = useState('');
  const [adding, setAdding] = useState(false);
  const [form, setForm] = useState(() => ({ code: '', name: '', department: (ctx.activeDepartment && ctx.activeDepartment !== 'All') ? ctx.activeDepartment : 'Interiors', widthMm: 2438.4, lengthMm: 3048, heightMm: 2438.4 }));

  const rows = lib.roomTypes.filter(t => {
    if (!q) return true;
    const s = q.toLowerCase();
    return (t.code || '').toLowerCase().includes(s) || (t.name || '').toLowerCase().includes(s);
  });
  const counts = useMemo(() => {
    const m = {};
    (ctx.projects || []).forEach(p => surfProjectRooms(p).forEach(r => { m[r.roomTypeId] = (m[r.roomTypeId] || 0) + 1; }));
    return m;
  }, [ctx.projects]);

  function create() {
    if (!form.code.trim() || !form.name.trim()) return;
    const t = surfMakeRoomType({ ...form, createdBy: ctx.currentUserName });
    surfSetLib(ctx, l => { l.roomTypes = [...l.roomTypes, t]; });
    setAdding(false);
    setForm({ ...form, code: '', name: '' });
    onOpen(t.id);
  }

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-2 flex-wrap">
        <TextInput className="!w-64" placeholder="Search room types…" value={q} onChange={e => setQ(e.target.value)} />
        <div className="flex-1" />
        {canEditLib && <Button onClick={() => setAdding(true)}>+ New Room Type</Button>}
      </div>

      {rows.length === 0
        ? <EmptyState text={lib.roomTypes.length ? 'No room type matches that.' : 'No room types yet. Create one — it makes the floor, the four walls and every joint between them in one step.'} />
        : (
          <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
            {rows.map(t => {
              const computedRoom = surfComputeRoom(t);
              const n = counts[t.id] || 0;
              return (
                <button key={t.id} onClick={() => onOpen(t.id)}
                  className="text-left border border-[var(--leon-line)] rounded-lg bg-white p-3 hover:border-[var(--leon-brown-light)] transition-colors">
                  <div className="flex items-start justify-between gap-2 mb-1">
                    <div>
                      <div className="font-bold text-sm">{t.code} &middot; {t.name}</div>
                      <div className="text-[11px] text-[var(--leon-black)]/50">
                        {fmtDim(t.widthMm, sys)} &times; {fmtDim(t.lengthMm, sys)} &times; {fmtDim(t.heightMm, sys)} high
                      </div>
                    </div>
                    <Badge tone={n ? 'brown' : 'neutral'}>{n} room{n === 1 ? '' : 's'}</Badge>
                  </div>
                  <SurfPlanView roomT={t} computedRoom={computedRoom} sys={sys} height={150} />
                  <div className="text-[11px] text-[var(--leon-black)]/50 mt-1">
                    {(t.surfaces || []).length} surfaces &middot; {(t.corners || []).length} stored joints &middot; {t.department}
                  </div>
                </button>
              );
            })}
          </div>
        )}

      <Modal open={adding} onClose={() => setAdding(false)} title="New Room Type" wide footer={
        <>
          <Button variant="ghost" onClick={() => setAdding(false)}>Cancel</Button>
          <Button onClick={create} disabled={!form.code.trim() || !form.name.trim()}>Create room &amp; its surfaces</Button>
        </>
      }>
        <div className="space-y-3">
          <p className="text-xs text-[var(--leon-black)]/55">
            Enter the room once. The floor and the four walls are created from these dimensions, and the
            eight joints between them (four corners, four floor-to-wall) are recorded so layouts can be
            carried around a corner later.
          </p>
          <div className="grid grid-cols-2 gap-3">
            <Field label="Code"><TextInput value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="PB-01" /></Field>
            <Field label="Name"><TextInput value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Primary Bath" /></Field>
          </div>
          <div className="grid grid-cols-3 gap-3">
            <Field label="Width" hint="North / South walls">
              <SurfDimInput sys={sys} valueMm={form.widthMm} onChange={v => setForm({ ...form, widthMm: v })} />
            </Field>
            <Field label="Length" hint="East / West walls">
              <SurfDimInput sys={sys} valueMm={form.lengthMm} onChange={v => setForm({ ...form, lengthMm: v })} />
            </Field>
            <Field label="Ceiling height">
              <SurfDimInput sys={sys} valueMm={form.heightMm} onChange={v => setForm({ ...form, heightMm: v })} />
            </Field>
          </div>
          <Field label="Department">
            <Select value={form.department} onChange={e => setForm({ ...form, department: e.target.value })}>
              {DEPARTMENTS.map(d => <option key={d}>{d}</option>)}
            </Select>
          </Field>
        </div>
      </Modal>
    </div>
  );
}

// ============================================================================
// Room Type editor
// ============================================================================

// Connected geometry, the other way round: the room's dimensions are the source
// and the surfaces follow. Resizing the room here is one action, not five.
function surfResizeRoom(roomT, dims) {
  if (dims.widthMm !== undefined) roomT.widthMm = dims.widthMm;
  if (dims.lengthMm !== undefined) roomT.lengthMm = dims.lengthMm;
  if (dims.heightMm !== undefined) roomT.heightMm = dims.heightMm;
  (roomT.surfaces || []).forEach(s => {
    if (s.key === 'floor') { s.widthMm = roomT.widthMm; s.heightMm = roomT.lengthMm; }
    else if (s.key === 'north' || s.key === 'south') { s.widthMm = roomT.widthMm; s.heightMm = roomT.heightMm; }
    else if (s.key === 'east' || s.key === 'west') { s.widthMm = roomT.lengthMm; s.heightMm = roomT.heightMm; }
  });
}

function SurfRoomTypeEditor({ ctx, typeId, sys, canEditLib, onBack }) {
  const lib = surfLib(ctx);
  const saved = lib.roomTypes.find(t => t.id === typeId) || null;
  const [draft, setDraft] = useState(() => (saved ? cloneDeep(saved) : null));
  const [selectedId, setSelectedId] = useState(null);
  const [impactOpen, setImpactOpen] = useState(false);
  const [addOpen, setAddOpen] = useState(null); // 'shower' | 'tub' | 'custom'

  useEffect(() => { setDraft(saved ? cloneDeep(saved) : null); setSelectedId(null); }, [typeId]);

  // Every hook runs before any early return — the guards below are renders, not
  // branches around hooks.
  const computedRoom = useMemo(() => (draft ? surfComputeRoom(draft) : null), [draft]);
  const diff = useMemo(() => surfDiffPaths(saved, draft), [saved, draft]);

  if (!saved) return <EmptyState text="That room type no longer exists." />;
  if (!draft || !computedRoom) return null;

  const edit = fn => setDraft(d => { const c = cloneDeep(d); fn(c); return c; });
  const dirty = diff.paths.length > 0 || diff.structural.length > 0;
  const sel = (draft.surfaces || []).find(s => s.id === selectedId)
    || (draft.surfaces || []).find(s => s.id === draft.masterSurfaceId)
    || (draft.surfaces || [])[0];
  const selEntry = sel ? computedRoom.surfaces[sel.id] : null;
  const instanceCount = surfInstancesOfType(ctx.projects, draft.id).length;

  function saveNow() {
    surfSaveTypeWithFreeze(ctx, saved, draft, diff.paths);
    setImpactOpen(false);
  }

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-3 flex-wrap sticky top-0 z-10 bg-[var(--leon-cream)] py-2">
        <Button variant="ghost" onClick={onBack}>&larr; All room types</Button>
        <div className="font-bold">{draft.code} &middot; {draft.name}</div>
        <Badge tone={instanceCount ? 'brown' : 'neutral'}>{instanceCount} room{instanceCount === 1 ? '' : 's'} use this</Badge>
        <div className="flex-1" />
        {dirty && (
          <>
            <span className="text-xs text-[var(--leon-brown)] font-semibold">
              {diff.paths.length + diff.structural.length} unsaved change{diff.paths.length + diff.structural.length === 1 ? '' : 's'}
            </span>
            <Button size="sm" variant="ghost" onClick={() => setDraft(cloneDeep(saved))}>Discard</Button>
            <Button size="sm" onClick={() => setImpactOpen(true)}>Review impact &amp; save</Button>
          </>
        )}
        {!dirty && <span className="text-xs text-[var(--leon-black)]/40">Saved</span>}
      </div>

      {!canEditLib && <LockedNotice label="You can read this room type but not change it. Room types are part of the company library." />}

      <Collapsible title="The room" defaultOpen id={`surf-room-${draft.id}`} count={(draft.surfaces || []).length}>
        <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
          <div className="space-y-3">
            <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
              <Field label="Code"><TextInput disabled={!canEditLib} value={draft.code} onChange={e => edit(d => { d.code = e.target.value; })} /></Field>
              <Field label="Name"><TextInput disabled={!canEditLib} value={draft.name} onChange={e => edit(d => { d.name = e.target.value; })} /></Field>
              <Field label="Department">
                <Select disabled={!canEditLib} value={draft.department} onChange={e => edit(d => { d.department = e.target.value; })}>
                  {DEPARTMENTS.map(x => <option key={x}>{x}</option>)}
                </Select>
              </Field>
              <Field label="Minimum cut" hint="Anything smaller is flagged">
                <SurfDimInput sys={sys} disabled={!canEditLib} valueMm={draft.minCutMm} onChange={v => edit(d => { d.minCutMm = v; })} />
              </Field>
            </div>
            <div className="grid grid-cols-3 gap-3">
              <Field label="Width" hint="Resizes floor + N/S walls">
                <SurfDimInput sys={sys} disabled={!canEditLib} valueMm={draft.widthMm} onChange={v => edit(d => surfResizeRoom(d, { widthMm: v }))} />
              </Field>
              <Field label="Length" hint="Resizes floor + E/W walls">
                <SurfDimInput sys={sys} disabled={!canEditLib} valueMm={draft.lengthMm} onChange={v => edit(d => surfResizeRoom(d, { lengthMm: v }))} />
              </Field>
              <Field label="Ceiling height" hint="Resizes all four walls">
                <SurfDimInput sys={sys} disabled={!canEditLib} valueMm={draft.heightMm} onChange={v => edit(d => surfResizeRoom(d, { heightMm: v }))} />
              </Field>
            </div>
            <Field label="Master surface" hint="Course elevations on every aligned surface are taken from this one">
              <Select disabled={!canEditLib} value={draft.masterSurfaceId || ''} onChange={e => edit(d => { d.masterSurfaceId = e.target.value; })}>
                {(draft.surfaces || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
              </Select>
            </Field>
            {canEditLib && (
              <div className="flex gap-2 flex-wrap pt-1">
                <Button size="sm" variant="outline" onClick={() => setAddOpen('shower')}>+ Add shower</Button>
                <Button size="sm" variant="outline" onClick={() => setAddOpen('tub')}>+ Add tub surround</Button>
                <Button size="sm" variant="outline" onClick={() => setAddOpen('custom')}>+ Add a surface</Button>
              </div>
            )}
          </div>
          <div>
            <SurfPlanView roomT={draft} computedRoom={computedRoom} sys={sys} selectedId={sel && sel.id} onSelect={setSelectedId} height={230} />
            <p className="text-[11px] text-[var(--leon-black)]/45 text-center">Plan &mdash; click a wall or the floor to edit it. Brown dots are the four stored corners.</p>
          </div>
        </div>
      </Collapsible>

      <Collapsible title="Unfolded elevation" defaultOpen id={`surf-unfold-${draft.id}`}
        right={<span className="text-[11px] text-[var(--leon-black)]/45">dashed rules = master course lines</span>}>
        <SurfUnfoldedElevation roomT={draft} computedRoom={computedRoom} sys={sys} />
        <p className="text-xs text-[var(--leon-black)]/55 mt-2">
          The four walls opened flat, in the order you would walk them. The dashed rules carry the master
          surface&rsquo;s course lines across every wall, so a course that does not line through the corner
          shows here rather than on site. The label under each break is what that stored joint is set to do.
        </p>
        {(draft.surfaces || []).some(s => s.key && s.key.indexOf('shower_') === 0) && (
          <div className="mt-4">
            <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">Shower, unfolded</div>
            <SurfUnfoldedElevation roomT={draft} computedRoom={computedRoom} sys={sys} keys={['shower_left', 'shower_back', 'shower_right']} />
          </div>
        )}
        {(draft.surfaces || []).some(s => s.key && s.key.indexOf('tub_') === 0) && (
          <div className="mt-4">
            <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">Tub surround, unfolded</div>
            <SurfUnfoldedElevation roomT={draft} computedRoom={computedRoom} sys={sys} keys={['tub_left', 'tub_back', 'tub_right']} />
          </div>
        )}
      </Collapsible>

      <SurfSurfaceNav surfaces={draft.surfaces || []} computedRoom={computedRoom} selectedId={sel && sel.id} onSelect={setSelectedId} />

      {sel && selEntry && (
        <>
          <SurfSurfacePanel ctx={ctx} roomT={draft} entry={selEntry} sys={sys} canEdit={canEditLib}
            onSurface={(f, v) => edit(d => { const s = d.surfaces.find(x => x.id === sel.id); if (s) s[f] = v; })}
            onLayout={(f, v) => edit(d => { const s = d.surfaces.find(x => x.id === sel.id); if (s) s.layout = { ...s.layout, [f]: v }; })}
            onLayoutMany={m => edit(d => { const s = d.surfaces.find(x => x.id === sel.id); if (s) s.layout = { ...s.layout, ...m }; })}
            onRemove={sel.key === 'floor' || SURF_WALL_KEYS.indexOf(sel.key) >= 0 ? null : () => {
              edit(d => {
                d.surfaces = d.surfaces.filter(x => x.id !== sel.id);
                d.corners = d.corners.filter(c => c.aSurfaceId !== sel.id && c.bSurfaceId !== sel.id);
                if (d.masterSurfaceId === sel.id) d.masterSurfaceId = (d.surfaces[0] || {}).id || null;
              });
              setSelectedId(null);
            }} />

          <SurfNichesPanel ctx={ctx} roomT={draft} entry={selEntry} sys={sys} canEdit={canEditLib}
            onNicheField={(nid, f, v) => edit(d => {
              const s = d.surfaces.find(x => x.id === sel.id);
              const n = s && (s.niches || []).find(x => x.id === nid);
              if (n) n[f] = v;
            })}
            onNicheAdd={() => edit(d => {
              const s = d.surfaces.find(x => x.id === sel.id);
              if (!s) return;
              s.niches = s.niches || [];
              s.niches.push(surfMakeNiche({ centerMm: (s.widthMm || 0) / 2, name: `Niche ${s.niches.length + 1}` }));
            })}
            onNicheRemove={nid => edit(d => {
              const s = d.surfaces.find(x => x.id === sel.id);
              if (s) s.niches = (s.niches || []).filter(x => x.id !== nid);
            })} />
        </>
      )}

      <SurfCornersPanel roomT={draft} computedRoom={computedRoom} sys={sys} canEdit={canEditLib}
        onCorner={(id, f, v) => edit(d => { const c = d.corners.find(x => x.id === id); if (c) c[f] = v; })}
        onSelectSurface={setSelectedId} />

      <SurfAddSurfaceModal open={!!addOpen} kind={addOpen} sys={sys} roomT={draft}
        onClose={() => setAddOpen(null)}
        onAdd={parts => { edit(d => { d.surfaces = [...d.surfaces, ...parts.surfaces]; d.corners = [...d.corners, ...parts.corners]; }); setAddOpen(null); if (parts.surfaces[0]) setSelectedId(parts.surfaces[0].id); }} />

      <SurfImpactModal open={impactOpen} onClose={() => setImpactOpen(false)} ctx={ctx}
        roomT={saved} draft={draft} diff={diff} onConfirm={saveNow} />
    </div>
  );
}

// SURFACE NAVIGATION — Floor / North / East / South / West / Shower / Tub, all
// in one row, so you never leave the room to edit one of its surfaces.
function SurfSurfaceNav({ surfaces, computedRoom, selectedId, onSelect }) {
  const order = ['floor', 'north', 'east', 'south', 'west'];
  const sorted = surfaces.slice().sort((a, b) => {
    const ia = order.indexOf(a.key), ib = order.indexOf(b.key);
    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
  });
  return (
    <div className="flex items-center gap-1.5 flex-wrap border border-[var(--leon-line)] rounded-lg bg-white p-2 no-print">
      <span className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mr-1">Surface</span>
      {sorted.map(s => {
        const e = computedRoom.surfaces[s.id];
        const bad = e ? e.computed.warnings.some(w => w.level === 'bad') : false;
        const on = s.id === selectedId;
        return (
          <button key={s.id} onClick={() => onSelect(s.id)}
            className={`px-2.5 py-1 rounded-md text-xs font-semibold border transition-colors ${on
              ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]'
              : 'bg-white text-[var(--leon-black)]/70 border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
            {s.name}
            {s.id === computedRoom.master && <span className="ml-1 opacity-70">★</span>}
            {bad && <span className="ml-1" title="This surface has a cut warning">⛔</span>}
          </button>
        );
      })}
    </div>
  );
}

// ---- one surface: parameters, drawing, course schedule --------------------

function SurfSurfacePanel({ ctx, roomT, entry, sys, canEdit, onSurface, onLayout, onRemove, room, onReset }) {
  const s = entry.surface;
  const L = entry.layout;
  const c = entry.computed;
  const minCut = L.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM;
  const pat = surfPatternDef(L.pattern);
  const p = f => `surface.${s.id}.${f}`;

  return (
    <Collapsible title={`${s.name} — set-out`} defaultOpen id={`surf-panel-${s.id}`}
      count={c.totals.total}
      right={
        <span className="flex items-center gap-2">
          {c.warnings.some(w => w.level === 'bad') && <Badge tone="red">cut warning</Badge>}
          {L.__aligned && <Badge tone="blue">courses aligned to master</Badge>}
        </span>
      }>
      <div className="grid gap-4 lg:grid-cols-[340px_minmax(0,1fr)]">
        <div className="space-y-3">
          <div className="grid grid-cols-2 gap-3">
            <Field label="Surface name">
              <TextInput disabled={!canEdit} value={s.name} onChange={e => onSurface('name', e.target.value)} />
            </Field>
            <Field label="Kind">
              <Select disabled={!canEdit} value={s.kind} onChange={e => onSurface('kind', e.target.value)}>
                {SURF_SURFACE_KINDS.map(k => <option key={k}>{k}</option>)}
              </Select>
            </Field>
          </div>
          <div className="grid grid-cols-2 gap-3">
            <Field label={s.kind === 'Floor' || s.kind === 'Shower Floor' ? 'Width' : 'Wall width'}>
              <SurfDimInput sys={sys} disabled={!canEdit} valueMm={s.widthMm} onChange={v => onSurface('widthMm', v)} />
            </Field>
            <Field label={s.kind === 'Floor' || s.kind === 'Shower Floor' ? 'Depth' : 'Wall height'}>
              <SurfDimInput sys={sys} disabled={!canEdit} valueMm={s.heightMm} onChange={v => onSurface('heightMm', v)} />
            </Field>
          </div>

          <div className="border-t border-[var(--leon-line)] pt-3 space-y-3">
            <div className="flex items-center justify-between">
              <span className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50">Finish</span>
              {room && <SurfInheritTag roomT={roomT} room={room} path={p('finish')} onReset={onReset} canEdit={canEdit} />}
            </div>
            <SurfFinishPicker ctx={ctx} value={s.finish} disabled={!canEdit} onChange={v => onSurface('finish', v)} compact />

            <div className="grid grid-cols-2 gap-3">
              <Field label="Tile width">
                <SurfDimInput sys={sys} disabled={!canEdit} valueMm={L.tileWmm} onChange={v => onLayout('tileWmm', v)} />
              </Field>
              <Field label="Tile height">
                <SurfDimInput sys={sys} disabled={!canEdit || L.__aligned} valueMm={L.tileHmm} onChange={v => onLayout('tileHmm', v)} />
              </Field>
              <Field label="Grout joint">
                <SurfDimInput sys={sys} disabled={!canEdit || L.__aligned} valueMm={L.groutMm} onChange={v => onLayout('groutMm', v)} />
              </Field>
              <Field label="Minimum cut">
                <SurfDimInput sys={sys} disabled={!canEdit} valueMm={L.minCutMm} onChange={v => onLayout('minCutMm', v)} />
              </Field>
            </div>
            <Field label="Pattern">
              <Select disabled={!canEdit} value={L.pattern} onChange={e => onLayout('pattern', e.target.value)}>
                {SURF_PATTERNS.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
              </Select>
            </Field>
            <p className="text-[11px] text-[var(--leon-black)]/50 -mt-1">{pat.note}</p>
            <Field label="Horizontal origin">
              <Select disabled={!canEdit} value={L.originX} onChange={e => onLayout('originX', e.target.value)}>
                {SURF_ORIGIN_X.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
              </Select>
            </Field>
            <Field label="Vertical origin">
              <Select disabled={!canEdit || L.__aligned} value={L.originY} onChange={e => onLayout('originY', e.target.value)}>
                {SURF_ORIGIN_Y.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
              </Select>
            </Field>
            {L.originY === 'elevation' && (
              <Field label="Starting elevation" hint="Where the first full course sits">
                <SurfDimInput sys={sys} disabled={!canEdit || L.__aligned} valueMm={L.startElevMm} onChange={v => onLayout('startElevMm', v)} />
              </Field>
            )}
            {L.__aligned && (
              <p className="text-[11px] text-[var(--leon-brown)]">
                Tile height, joint, vertical origin and starting elevation are taken from the master surface,
                because two grids cannot line through unless they share all four. Turn off &ldquo;align courses&rdquo;
                on this surface&rsquo;s joint to set them here.
              </p>
            )}
            {entry.continuedFrom && (
              <p className="text-[11px] text-[var(--leon-brown)]">{entry.continuedFrom}</p>
            )}
          </div>
          {onRemove && canEdit && (
            <Button size="sm" variant="danger" onClick={onRemove}>Remove this surface</Button>
          )}
        </div>

        <div className="space-y-3 min-w-0">
          <div className="border border-[var(--leon-line)] rounded-lg bg-white p-2">
            <SurfSurfaceDrawing entry={entry} minCutMm={minCut} height={320} />
            <div className="flex items-center gap-3 flex-wrap justify-center text-[11px] text-[var(--leon-black)]/55 pt-1">
              <span className="flex items-center gap-1"><span className="inline-block w-3 h-3 rounded-sm border border-[var(--leon-line)]" style={{ background: SURF_TILE_FULL }} /> full tile</span>
              <span className="flex items-center gap-1"><span className="inline-block w-3 h-3 rounded-sm border border-[var(--leon-line)]" style={{ background: SURF_TILE_CUT }} /> cut</span>
              <span className="flex items-center gap-1"><span className="inline-block w-3 h-3 rounded-sm border border-[var(--leon-line)]" style={{ background: SURF_TILE_SLIVER }} /> under minimum</span>
            </div>
          </div>
          <SurfWarnings list={c.warnings} />
          <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
            <SurfStat label="Full tiles" value={c.totals.full} />
            <SurfStat label="Cut tiles" value={c.totals.cut} />
            <SurfStat label="Field area" value={`${surfM2(c.totals.fieldAreaMm2).toFixed(2)} m²`} />
            <SurfStat label="Smallest cut" value={c.totals.minCutX === null ? '—' : fmtDim(c.totals.minCutX, sys)} />
          </div>
          <SurfCoursesTable computed={c} sys={sys} minCut={minCut} />
          <SurfSolvePanel surface={s} layout={L} computed={c} minCut={minCut} sys={sys}
            canEdit={canEdit} onLayout={onLayout} />
          <SurfOrderPanel computed={c} layout={L} sys={sys} canEdit={canEdit} onLayout={onLayout} />
        </div>
      </div>
    </Collapsible>
  );
}

function SurfStat({ label, value }) {
  return (
    <div className="border border-[var(--leon-line)] rounded-md bg-white px-2.5 py-1.5">
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">{label}</div>
      <div className="text-sm font-bold">{value}</div>
    </div>
  );
}

// The set-out solver's screen. It PROPOSES; it never moves the set-out by
// itself. Where a grid starts is a design decision — a centred set-out is
// routinely specified, and a tool that silently slid one because it found a
// bigger offcut would be overruling the person who chose it.
function SurfSolvePanel({ surface, layout, computed, minCut, sys, canEdit, onLayout, onLayoutMany }) {
  const [objective, setObjective] = useState('safest');
  const [sol, setSol] = useState(null);
  const [busy, setBusy] = useState(false);
  const here = surfScoreSetOut(computed, minCut);
  const fmt = mm => fmtDim(mm, sys, { inchesOnly: true });

  function solve() {
    setBusy(true);
    // Let the button paint before a few hundred layouts are computed.
    setTimeout(() => {
      try { setSol(surfSolveSetOut(surface, layout, { objective, minCutMm: minCut })); }
      finally { setBusy(false); }
    }, 0);
  }
  function apply() {
    if (!sol || !sol.solvable || !canEdit) return;
    // The solver answers in a PHASE; the layout stores an ORIGIN. Writing the
    // phase back means the origin is no longer any of the named ones, so it is
    // recorded as an explicit offset and the origin says so.
    const w = { originX: 'solved', phaseXMm: sol.best.phaseXMm };
    if (sol.best.startElevMm !== null && sol.best.startElevMm !== undefined) {
      w.originY = 'elevation';
      w.startElevMm = sol.best.startElevMm;
    }
    const note = `set-out solved on ${surface.name} — smallest cut ${fmt(sol.current.score.worst)} → ${fmt(sol.best.score.worst)}`;
    if (typeof onLayoutMany === 'function') onLayoutMany(w, note);
    else Object.keys(w).forEach(k => onLayout(k, w[k]));
    setSol(null);
  }

  return (
    <div className="mt-4 rounded-lg border border-[var(--leon-line)] bg-white p-3">
      <div className="flex items-end gap-3 flex-wrap">
        <div className="grow">
          <div className="text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
            Where the grid starts
          </div>
          <div className="text-sm mt-0.5">
            {here.worst === null ? (
              <span className="text-[var(--leon-black)]/55">Nothing is cut on this surface.</span>
            ) : here.ok ? (
              <>Smallest cut <b className="tabular-nums">{fmt(here.worst)}</b>, over the
                {' '}{fmt(minCut)} minimum. <span className="text-[var(--leon-black)]/55">
                {here.cuts} cut of {here.total}.</span></>
            ) : (
              <span className="text-[#b83b3b]">
                Smallest cut <b className="tabular-nums">{fmt(here.worst)}</b> &mdash; under the
                {' '}{fmt(minCut)} minimum.
              </span>
            )}
          </div>
        </div>
        <Field label="Solve for" className="w-52">
          <Select className="!py-1" value={objective} onChange={e => { setObjective(e.target.value); setSol(null); }}>
            {SURF_SOLVE_OBJECTIVES.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
          </Select>
        </Field>
        <Button size="sm" variant="outline" onClick={solve} disabled={busy}>
          {busy ? 'Searching…' : 'Find a better set-out'}
        </Button>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/50 mt-1">
        {(SURF_SOLVE_OBJECTIVES.find(o => o.key === objective) || {}).note}
      </p>

      {sol && !sol.solvable && (
        <div className="mt-2 rounded-md border border-[var(--leon-line)] bg-[var(--leon-cream)]/50 px-3 py-2 text-xs">
          {sol.reason}
        </div>
      )}

      {sol && sol.solvable && !sol.worthwhile && (
        <div className="mt-2 rounded-md border border-[#8fbf8f] bg-[#f2f9f2] px-3 py-2 text-xs">
          <b>This set-out is already the best one found.</b> {sol.tried} starting positions were tried and
          none improved on {fmt(sol.current.score.worst)}. Leave it where it is.
        </div>
      )}

      {sol && sol.solvable && sol.worthwhile && (
        <div className="mt-2 rounded-lg border border-[var(--leon-brown)]/40 bg-[var(--leon-cream)]/60 p-3">
          <div className="grid sm:grid-cols-2 gap-3">
            <div>
              <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">As set out now</div>
              <div className="text-sm mt-0.5 tabular-nums">
                smallest cut <b className={sol.current.score.ok ? '' : 'text-[#b83b3b]'}>
                  {fmt(sol.current.score.worst)}</b>
                {' · '}{sol.current.score.cuts} cut{' · '}{sol.current.score.full} whole
              </div>
            </div>
            <div>
              <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">If it moves</div>
              <div className="text-sm mt-0.5 tabular-nums">
                smallest cut <b className="text-[#3a7d44]">{fmt(sol.best.score.worst)}</b>
                {' · '}{sol.best.score.cuts} cut{' · '}{sol.best.score.full} whole
              </div>
            </div>
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/60 mt-2">
            Slide the field <b>{fmt(Math.abs(sol.best.phaseXMm - sol.current.phaseXMm))}</b> across
            {sol.best.startElevMm !== null && sol.best.startElevMm !== undefined
              && sol.best.startElevMm !== sol.current.startElevMm
              && <> and start the first course at <b>{fmt(sol.best.startElevMm)}</b></>}.
            {' '}Same tile, same pattern, same joint &mdash; only which offcut lands at which wall.
            {sol.gain.cuts < 0 && (
              <> It costs <b>{-sol.gain.cuts} more cut{-sol.gain.cuts === 1 ? '' : 's'}</b>, which is the
                trade for losing the sliver.</>
            )}
          </p>
          <div className="flex gap-2 mt-2">
            <Button size="sm" disabled={!canEdit} onClick={apply}>Move the set-out</Button>
            <Button size="sm" variant="ghost" onClick={() => setSol(null)}>Leave it</Button>
          </div>
        </div>
      )}
    </div>
  );
}

// WHAT TO BUY, and WHAT TO CUT. The module counted tiles and stopped, which is
// the number a drawing needs and not the number anyone orders — tile is sold by
// the box. And a course schedule is how the setter-out reads a wall; a grouped
// cut list is how the tiler reads it, and they are different documents.
function SurfOrderPanel({ computed, layout, sys, canEdit, onLayout }) {
  const ord = surfOrderQty(computed, layout);
  const cuts = surfCutList(computed);
  return (
    <div className="mt-4 rounded-lg border border-[var(--leon-line)] bg-white p-3">
      <div className="text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50 mb-2">
        What to order
      </div>
      <div className="flex items-end gap-3 flex-wrap mb-2">
        <Field label="Tiles per box" hint="Leave at 0 and no box count is claimed.">
          <TextInput className="!w-24" disabled={!canEdit} defaultValue={layout.tilesPerBox || 0}
            onBlur={e => onLayout('tilesPerBox', Math.max(0, Math.round(Number(e.target.value) || 0)))} />
        </Field>
        {/* A layout stored before these fields existed has neither, and
            surfOrderQty falls back to the standard — so the FIELD has to show
            the same fallback. A blank box beside a figure that is being used is
            how a screen reads as broken. */}
        <Field label="Breakage &amp; spares" hint="On top of the cuts, which are already counted exactly.">
          <TextInput className="!w-24" disabled={!canEdit} defaultValue={ord.breakagePct}
            onBlur={e => onLayout('breakagePct', Math.max(0, Number(e.target.value) || 0))} />
        </Field>
        <div className="pb-2 text-xs text-[var(--leon-black)]/55">%</div>
      </div>
      <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-2">
        <SurfStat label="Tiles laid" value={ord.laid} />
        <SurfStat label={`+ ${ord.breakagePct}% spares`} value={ord.spare} />
        <SurfStat label="Order" value={ord.buy} />
        <SurfStat label="Boxes"
          value={ord.boxes === null ? '—' : `${ord.boxes}${ord.leftover ? ` (${ord.leftover} spare)` : ''}`} />
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/55">
        <b>{Math.round(ord.cutSharePct)}% of the field is a cut tile.</b> That figure is counted piece by
        piece off this set-out, not a pattern waste allowance &mdash; so the {ord.breakagePct}% above is
        breakage and spares only. Adding a pattern percentage on top would charge the same waste twice.
        {ord.boxes === null && ' Set the tiles per box to get a box count.'}
      </p>

      {cuts === null ? (
        <p className="text-[11px] text-[var(--leon-black)]/55 mt-3">
          Every perimeter piece on a diagonal is a triangle, so there is no rectangular cut list &mdash; the
          cuts are read off the drawing.
        </p>
      ) : cuts.length === 0 ? (
        <p className="text-[11px] text-[#3a7d44] mt-3">No cuts on this surface &mdash; the field lands whole.</p>
      ) : (
        <div className="mt-3">
          <div className="text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">
            Cut list ({cuts.reduce((n, g) => n + g.count, 0)} tiles, {cuts.length} kind{cuts.length === 1 ? '' : 's'})
          </div>
          <div className="space-y-0.5">
            {cuts.map((g, i) => (
              <div key={i} className="flex items-center gap-2 text-sm">
                <span className="w-14 text-right font-bold tabular-nums">{g.count}</span>
                <span className="text-[var(--leon-black)]/70">{surfCutLabel(g, sys)}</span>
              </div>
            ))}
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
            Grouped by the cut itself, which is how a tiler works: one setting on the saw does a whole row of
            this list. The course schedule above is the other half &mdash; where each of them goes.
          </p>
        </div>
      )}
    </div>
  );
}

// ============================================================================
// THE TILE SHOP DRAWING
// ============================================================================
// Same model as the door and countertop sheets, deliberately: a real paper
// size, a real scale, LEON's own lockup, a side panel of information, and the
// finishes and selections along the bottom. A set that reads differently from
// one trade to the next is several sets, and the point of issuing them together
// is that a reviewer learns one sheet.
//
// The SVG is sized in MILLIMETRES so paper mm = model mm / denominator, which
// is what makes a printed elevation measurable.
const SURF_INK = '#2B2118', SURF_BROWN = '#8B5E34', SURF_LINE = '#D9D2C7', SURF_CREAM = '#F3EFE9';
const SURF_FONT = "'Century Gothic Leon', 'Century Gothic', Questrial, sans-serif";
const SURF_SW = { cut: 0.62, outline: 0.42, detail: 0.26, thin: 0.16 };
const SURF_SHEET_DIM_MM = 140;          // room outside the elevation for the dimension bands

function surfSheetSize(key) {
  const list = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [{ key: 'A2', w: 594, h: 420 }];
  return list.find(x => x.key === key) || list.find(x => x.key === 'A2') || list[0];
}
function surfFitDenom(mm, paper, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [{ denom: 5 }])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [{ denom: 20 }]);
  const ladder = src.map(x => x.denom).sort((a, b) => a - b);
  for (const dn of ladder) if (mm / dn <= paper) return dn;
  return ladder[ladder.length - 1];
}
function surfScaleLabel(dn, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || []);
  const f = src.find(x => x.denom === dn);
  return f ? f.label : `1:${dn}`;
}
function SurfSheetDim({ x1, y1, x2, y2, text, tone }) {
  const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
  const nx = -dy / len, ny = dx / len, t = 1.3;
  const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
  const ang = Math.atan2(dy, dx) * 180 / Math.PI;
  const flip = ang > 90 || ang < -90;
  const col = tone || SURF_BROWN;
  return (
    <g stroke={col} strokeWidth={SURF_SW.thin} fill="none">
      <line x1={x1} y1={y1} x2={x2} y2={y2} />
      <line x1={x1 - nx * t} y1={y1 - ny * t} x2={x1 + nx * t} y2={y1 + ny * t} />
      <line x1={x2 - nx * t} y1={y2 - ny * t} x2={x2 + nx * t} y2={y2 + ny * t} />
      <text x={mx} y={my - 1.3} fontSize="2.5" fill={col} stroke="none" fontFamily={SURF_FONT}
        textAnchor="middle" transform={`rotate(${flip ? ang + 180 : ang} ${mx} ${my})`}>{text}</text>
    </g>
  );
}
function SurfSheetBox({ x, y, w, h, title, scaleNote, children }) {
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="none" stroke={SURF_INK}
        strokeWidth={SURF_SW.thin} opacity="0.45" />
      <text x={x + 1.5} y={y + 3.6} fontSize="2.6" fontWeight="bold" fill={SURF_BROWN}
        fontFamily={SURF_FONT} letterSpacing="0.9">{title}</text>
      {scaleNote && (
        <text x={x + w - 1.5} y={y + 3.6} fontSize="2" fill={SURF_INK} opacity="0.5"
          fontFamily={SURF_FONT} textAnchor="end">{scaleNote}</text>
      )}
      <line x1={x} y1={y + 5} x2={x + w} y2={y + 5} stroke={SURF_INK} strokeWidth={SURF_SW.thin} opacity="0.35" />
      {children}
    </g>
  );
}

// One surface, at scale, with its course lines dimensioned. Nothing is
// re-derived — it draws the SAME `computed` the set-out screen draws.
function SurfSheetElevation({ entry, x, y, w, h, denom, system, minCut }) {
  const c = entry.computed, s = entry.surface;
  const sp = mm => mm / denom;
  const ox = x + (w - sp(c.W)) / 2, oy = y + 5 + (h - 5 - sp(c.H)) / 2;
  const fmt = mm => fmtDim(mm, system, { inchesOnly: true });
  const flip = v => oy + sp(c.H) - sp(v);
  return (
    <g>
      <defs>
        <clipPath id={`sheetclip-${s.id}`}>
          <rect x={ox} y={oy} width={sp(c.W)} height={sp(c.H)} />
        </clipPath>
      </defs>
      <rect x={ox} y={oy} width={sp(c.W)} height={sp(c.H)} fill="#fdfcfa" />
      <g clipPath={`url(#sheetclip-${s.id})`}>
        {c.pieces.map((p, i) => {
          const fill = surfPieceFill(p, minCut, false);
          if (p.quad) {
            return <polygon key={i} points={p.quad.map(q => `${ox + sp(q.x)},${flip(q.y)}`).join(' ')}
              fill={fill} stroke={SURF_JOINT_COLOR} strokeWidth={SURF_SW.thin} />;
          }
          return <rect key={i} x={ox + sp(p.x)} y={flip(p.y + p.h)} width={sp(p.w)} height={sp(p.h)}
            fill={fill} stroke={SURF_JOINT_COLOR} strokeWidth={SURF_SW.thin} />;
        })}
      </g>
      <rect x={ox} y={oy} width={sp(c.W)} height={sp(c.H)} fill="none"
        stroke={SURF_INK} strokeWidth={SURF_SW.cut} />
      {/* Niches, drawn heavy — they are holes in the field. */}
      {(s.niches || []).map(n => {
        const r = surfNicheRect(n, c.W);
        return <rect key={n.id} x={ox + sp(r.x)} y={flip(r.y + r.h)} width={sp(r.w)} height={sp(r.h)}
          fill="#fff" stroke={SURF_INK} strokeWidth={SURF_SW.cut} />;
      })}
      {/* Overall, both ways. */}
      <SurfSheetDim x1={ox} y1={oy + sp(c.H) + 9} x2={ox + sp(c.W)} y2={oy + sp(c.H) + 9} text={fmt(c.W)} />
      <SurfSheetDim x1={ox - 9} y1={oy} x2={ox - 9} y2={oy + sp(c.H)} text={fmt(c.H)} />
      {/* COURSE ELEVATIONS up the right — the figure the setter-out works to,
          and the reason a tile elevation exists at all. */}
      {c.courses.map(cr => (
        <g key={cr.n}>
          <line x1={ox + sp(c.W)} y1={flip(cr.topMm)} x2={ox + sp(c.W) + 5} y2={flip(cr.topMm)}
            stroke={SURF_BROWN} strokeWidth={SURF_SW.thin} opacity="0.7" />
          <text x={ox + sp(c.W) + 6} y={flip(cr.topMm) + 0.8} fontSize="1.9" fill={SURF_BROWN}
            fontFamily={SURF_FONT}>{fmt(cr.topMm)}</text>
        </g>
      ))}
    </g>
  );
}

function SurfSheetPanel({ project, ctx, roomT, entry, x, y, w, h, system, ord }) {
  const s = entry.surface, L = entry.layout, c = entry.computed;
  const pat = surfPatternDef(L.pattern);
  const fin = s.finish || {};
  const fmt = mm => fmtDim(mm, system, { inchesOnly: true });
  const co = (ctx && ctx.companyProfile) || {};
  const rows = [
    ['ROOM TYPE', `${roomT.code} · ${roomT.name}`],
    ['SURFACE', s.name],
    ['TILE', `${fmt(L.tileWmm)} × ${fmt(L.tileHmm)}`],
    ['PATTERN', pat.label],
    ['GROUT', fmt(L.groutMm)],
    ['MIN CUT', fmt(L.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM)],
    ['TILES', `${c.totals.full} full · ${c.totals.cut} cut`],
    ['ORDER', ord.boxes === null ? `${ord.buy} tiles` : `${ord.boxes} box${ord.boxes === 1 ? '' : 'es'}`],
  ];
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={SURF_INK}
        strokeWidth={SURF_SW.thin} opacity="0.9" />
      <rect x={x} y={y} width={w} height={40} fill={SURF_CREAM} />
      <image href="logo/leon-official.svg" x={x + w / 2 - 15} y={y + 2} width="30" height="36"
        preserveAspectRatio="xMidYMid meet" />
      <line x1={x} y1={y + 40} x2={x + w} y2={y + 40} stroke={SURF_BROWN} strokeWidth="0.5" />
      <text x={x + 2} y={y + 45} fontSize="2.6" fill={SURF_INK} fontFamily={SURF_FONT} fontWeight="bold">
        {String((project && project.name) || 'Room type library').slice(0, 28)}
      </text>
      <text x={x + 2} y={y + 48.6} fontSize="1.9" fill={SURF_INK} fontFamily={SURF_FONT} opacity="0.55">
        {(project && project.projectNumber) || ''}
      </text>
      <line x1={x} y1={y + 50.5} x2={x + w} y2={y + 50.5} stroke={SURF_LINE} strokeWidth={SURF_SW.thin} />
      {rows.map(([k, v], i) => (
        <g key={k}>
          <text x={x + 2} y={y + 56 + i * 7} fontSize="1.8" fill={SURF_INK} fontFamily={SURF_FONT}
            opacity="0.45" letterSpacing="0.5">{k}</text>
          <text x={x + 2} y={y + 59.4 + i * 7} fontSize="2.4" fill={SURF_INK} fontFamily={SURF_FONT}>
            {String(v == null ? '' : v).slice(0, 24)}
          </text>
          <line x1={x + 2} y1={y + 61 + i * 7} x2={x + w - 2} y2={y + 61 + i * 7}
            stroke={SURF_LINE} strokeWidth={SURF_SW.thin} opacity="0.6" />
        </g>
      ))}
      <text x={x + 2} y={y + h - 7} fontSize="1.8" fill={SURF_INK} fontFamily={SURF_FONT} opacity="0.45">
        {String(co.name || 'LEON INTEGRA').toUpperCase()}
      </text>
      <text x={x + 2} y={y + h - 3.6} fontSize="1.7" fill={SURF_INK} fontFamily={SURF_FONT} opacity="0.4">
        {[co.addressLine1, co.phone].filter(Boolean).join(' · ').slice(0, 34)}
      </text>
    </g>
  );
}

// FINISHES & SELECTIONS — the tile itself from the supplier library, then the
// cut list, which is what this trade's bottom strip is actually for.
function SurfSheetFinishes({ entry, ctx, x, y, w, h, system, ord }) {
  const s = entry.surface, L = entry.layout, c = entry.computed;
  const fin = s.finish || null;
  const cuts = surfCutList(c) || [];
  const fmt = mm => fmtDim(mm, system, { inchesOnly: true });
  const imgW = Math.min(70, w * 0.16);
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={SURF_INK}
        strokeWidth={SURF_SW.thin} opacity="0.9" />
      <text x={x + 2} y={y + 4} fontSize="2.4" fontWeight="bold" fill={SURF_BROWN}
        fontFamily={SURF_FONT} letterSpacing="1">FINISHES &amp; SELECTIONS</text>
      <line x1={x} y1={y + 5.6} x2={x + w} y2={y + 5.6} stroke={SURF_INK} strokeWidth={SURF_SW.thin} opacity="0.35" />

      {/* the tile */}
      {fin && fin.img
        ? <image href={fin.img} x={x + 2} y={y + 7} width={imgW} height={h - 20}
            preserveAspectRatio="xMidYMid slice" />
        : <rect x={x + 2} y={y + 7} width={imgW} height={h - 20} fill={SURF_CREAM}
            stroke={SURF_LINE} strokeWidth={SURF_SW.thin} />}
      <rect x={x + 2} y={y + 7} width={imgW} height={h - 20} fill="none"
        stroke={SURF_INK} strokeWidth={SURF_SW.thin} opacity="0.4" />
      <text x={x + 3.5} y={y + 10.5} fontSize="1.8" fill={SURF_BROWN} fontFamily={SURF_FONT}
        fontWeight="bold" letterSpacing="0.6">TILE</text>
      <text x={x + 2} y={y + h - 9} fontSize="2.2" fill={SURF_INK} fontFamily={SURF_FONT}>
        {fin ? String(fin.name || '').slice(0, 26) : 'Not selected'}
      </text>
      <text x={x + 2} y={y + h - 5.6} fontSize="1.8" fill={SURF_INK} fontFamily={SURF_FONT} opacity="0.55">
        {fin ? [fin.supplier, fin.code].filter(Boolean).join(' · ').slice(0, 30)
             : 'Link a finish from the supplier library'}
      </text>
      {!fin && (
        <text x={x + w - 2} y={y + 4} fontSize="1.8" fill="#b83b3b" fontFamily={SURF_FONT} textAnchor="end">
          Tile not linked to the supplier library
        </text>
      )}

      {/* the cut list, which is the thing a tiler takes to the saw */}
      <text x={x + imgW + 8} y={y + 10.5} fontSize="1.9" fill={SURF_BROWN} fontFamily={SURF_FONT}
        fontWeight="bold" letterSpacing="0.6">
        CUT LIST — {cuts.reduce((n, g) => n + g.count, 0)} TILES
      </text>
      {cuts.slice(0, 8).map((g, i) => (
        <g key={i}>
          <text x={x + imgW + 8} y={y + 15 + i * 3.4} fontSize="2.1" fill={SURF_INK}
            fontFamily={SURF_FONT} fontWeight="bold">{g.count}</text>
          <text x={x + imgW + 15} y={y + 15 + i * 3.4} fontSize="2.1" fill={SURF_INK} fontFamily={SURF_FONT}>
            {surfCutLabel(g, system)}
          </text>
        </g>
      ))}
      {!cuts.length && (
        <text x={x + imgW + 8} y={y + 15} fontSize="2.1" fill={SURF_INK} fontFamily={SURF_FONT} opacity="0.6">
          {c.mode === 'diagonal'
            ? 'Diagonal — every perimeter piece is a triangle, read off the drawing.'
            : 'No cuts — the field lands whole.'}
        </text>
      )}

      {/* what to order, stated where the shop reads it */}
      <text x={x + w - 2} y={y + h - 9} fontSize="2.2" fill={SURF_INK} fontFamily={SURF_FONT} textAnchor="end">
        ORDER {ord.buy} tiles{ord.boxes === null ? '' : ` · ${ord.boxes} box${ord.boxes === 1 ? '' : 'es'}`}
      </text>
      <text x={x + w - 2} y={y + h - 5.6} fontSize="1.7" fill={SURF_INK} fontFamily={SURF_FONT}
        textAnchor="end" opacity="0.55">
        {ord.laid} laid + {ord.breakagePct}% breakage · cuts counted, not allowed for
      </text>
    </g>
  );
}

function SurfShopDrawingPage({ project, ctx, roomT, entry, size, denom, system, autoFit, sheetNo }) {
  const S = surfSheetSize(size);
  const m = 7, panelW = 74, gap = 2.5;
  const drawW = S.w - m * 2 - panelW - gap;
  const top = m + 2;
  const footH = Math.max(42, Math.round(S.h * 0.135));
  const bodyH = S.h - m * 2 - 4 - footH - gap;
  const c = entry.computed;
  const fit = Math.max(surfFitDenom(c.W + SURF_SHEET_DIM_MM, drawW - 6),
                       surfFitDenom(c.H + SURF_SHEET_DIM_MM, bodyH - 12));
  const dn = autoFit ? fit : denom;
  const minCut = entry.layout.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM;
  const ord = surfOrderQty(c, entry.layout);

  return (
    <svg width={`${S.w}mm`} height={`${S.h}mm`} viewBox={`0 0 ${S.w} ${S.h}`}
      style={{ background: '#fff', maxWidth: '100%', height: 'auto' }}
      role="img" aria-label={`Tile shop drawing — ${entry.surface.name}`}>
      <rect x="0" y="0" width={S.w} height={S.h} fill="#ffffff" />
      <rect x={m / 2} y={m / 2} width={S.w - m} height={S.h - m}
        fill="none" stroke={SURF_INK} strokeWidth="0.5" />

      <SurfSheetBox x={m} y={top} w={drawW} h={bodyH}
        title={`ELEVATION — ${String(entry.surface.name).toUpperCase()}`}
        scaleNote={`SCALE ${surfScaleLabel(dn)}`}>
        <SurfSheetElevation entry={entry} x={m} y={top} w={drawW} h={bodyH}
          denom={dn} system={system} minCut={minCut} />
      </SurfSheetBox>

      <SurfSheetPanel project={project} ctx={ctx} roomT={roomT} entry={entry}
        x={m + drawW + gap} y={top} w={panelW} h={bodyH} system={system} ord={ord} />

      <SurfSheetFinishes entry={entry} ctx={ctx} x={m} y={top + bodyH + gap}
        w={S.w - m * 2} h={footH} system={system} ord={ord} />

      <text x={S.w - m} y={S.h - m / 2 - 1.5} fontSize="2" fill={SURF_INK} fontFamily={SURF_FONT}
        textAnchor="end" opacity="0.5">{sheetNo || ''}</text>
    </svg>
  );
}

// The course schedule — real elevations, not a count. This is the table the
// setter-out works from, so the top course is listed first, the way a wall is read.
function SurfCoursesTable({ computed, sys, minCut }) {
  const [all, setAll] = useState(false);
  if (computed.mode !== 'grid') {
    return (
      <div className="text-xs text-[var(--leon-black)]/55 border border-dashed border-[var(--leon-line)] rounded-md px-3 py-3">
        No course schedule for this pattern — herringbone has no continuous horizontal courses.
        The cut count above is still real: the tiles stay axis-aligned, so every perimeter cut is rectangular.
      </div>
    );
  }
  const rows = computed.courses.slice().reverse();
  const shown = all ? rows : rows.slice(0, 14);
  return (
    <div>
      <div className="overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
        <table className="w-full text-xs">
          <thead className="bg-[var(--leon-cream)]">
            <tr className="text-left">
              <th className="px-2.5 py-1.5 font-semibold">Course</th>
              <th className="px-2.5 py-1.5 font-semibold">Bottom</th>
              <th className="px-2.5 py-1.5 font-semibold">Top</th>
              <th className="px-2.5 py-1.5 font-semibold">Height</th>
              <th className="px-2.5 py-1.5 font-semibold">Left cut</th>
              <th className="px-2.5 py-1.5 font-semibold">Right cut</th>
              <th className="px-2.5 py-1.5 font-semibold">Full</th>
              <th className="px-2.5 py-1.5 font-semibold">Cut</th>
            </tr>
          </thead>
          <tbody className="divide-y divide-[var(--leon-line)]">
            {shown.map((c, i) => {
              const badL = c.leftCutMm !== null && c.leftCutMm < minCut;
              const badR = c.rightCutMm !== null && c.rightCutMm < minCut;
              return (
                <tr key={c.n} className={c.isPartial ? 'bg-[var(--leon-cream)]/60' : ''}>
                  <td className="px-2.5 py-1.5 font-semibold">
                    {rows.length - i}{c.isPartial && <span className="ml-1 text-[10px] text-[var(--leon-black)]/45">cut {c.cutAt}</span>}
                  </td>
                  <td className="px-2.5 py-1.5">{fmtDim(c.bottomMm, sys)}</td>
                  <td className="px-2.5 py-1.5">{fmtDim(c.topMm, sys)}</td>
                  <td className="px-2.5 py-1.5">{fmtDim(c.heightMm, sys)}</td>
                  <td className={`px-2.5 py-1.5 ${badL ? 'text-[#b83b3b] font-semibold' : ''}`}>{c.leftCutMm === null ? 'full' : fmtDim(c.leftCutMm, sys)}</td>
                  <td className={`px-2.5 py-1.5 ${badR ? 'text-[#b83b3b] font-semibold' : ''}`}>{c.rightCutMm === null ? 'full' : fmtDim(c.rightCutMm, sys)}</td>
                  <td className="px-2.5 py-1.5">{c.fullCount}</td>
                  <td className="px-2.5 py-1.5">{c.cutCount}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {rows.length > 14 && (
        <button onClick={() => setAll(a => !a)} className="text-xs text-[var(--leon-brown)] font-semibold mt-1">
          {all ? 'Show the top 14 courses' : `Show all ${rows.length} courses`}
        </button>
      )}
    </div>
  );
}

// ---- niches ---------------------------------------------------------------
// A niche is an object IN the wall, held against the wall's own set-out — which
// is why it is stored on the surface and analysed against that surface's
// computed courses rather than dimensioned off the floor on a separate drawing.

function SurfNichesPanel({ ctx, roomT, entry, sys, canEdit, onNicheField, onNicheAdd, onNicheRemove, room, onReset }) {
  const s = entry.surface;
  const c = entry.computed;
  const minCut = entry.layout.minCutMm || roomT.minCutMm || SURF_DEFAULT_MIN_CUT_MM;
  const niches = s.niches || [];
  const isFloor = s.kind === 'Floor' || s.kind === 'Shower Floor';

  return (
    <Collapsible title={`${s.name} — niches & recesses`} count={niches.length} id={`surf-niches-${s.id}`}>
      {isFloor && <p className="text-xs text-[var(--leon-black)]/50 mb-2">Niches are wall objects; on a floor this is where a recess or drain surround would be recorded.</p>}
      {niches.length === 0 && <EmptyState text="No niches on this surface." />}
      <div className="space-y-3">
        {niches.map(n => {
          const a = surfAnalyzeNiche(n, c, minCut);
          const snap = surfSnapNiche(n, c, n.alignV, n.alignH);
          const snapDiffers = Object.keys(snap).some(k => Math.abs((snap[k] || 0) - (n[k] || 0)) > 0.5);
          const setN = (f, v) => onNicheField(n.id, f, v);
          return (
            <div key={n.id} className="border border-[var(--leon-line)] rounded-lg p-3 bg-white space-y-3">
              <div className="flex items-center gap-2 flex-wrap">
                <TextInput className="!w-48" disabled={!canEdit} value={n.name} onChange={e => setN('name', e.target.value)} />
                {room && <SurfInheritTag roomT={roomT} room={room} path={`niche.${s.id}.${n.id}.bottomElevMm`} onReset={onReset} canEdit={canEdit} />}
                <div className="flex-1" />
                {canEdit && onNicheRemove && <IconBtn title="Remove this niche" onClick={() => onNicheRemove(n.id)}>✕</IconBtn>}
              </div>
              <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
                <Field label="Width"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={n.widthMm} onChange={v => setN('widthMm', v)} /></Field>
                <Field label="Height"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={n.heightMm} onChange={v => setN('heightMm', v)} /></Field>
                <Field label="Depth"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={n.depthMm} onChange={v => setN('depthMm', v)} /></Field>
                <Field label="Sill elevation"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={n.bottomElevMm} onChange={v => setN('bottomElevMm', v)} /></Field>
                <Field label="Centreline" hint="From the left edge"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={n.centerMm} onChange={v => setN('centerMm', v)} /></Field>
              </div>
              <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-end">
                <Field label="Vertical alignment">
                  <Select disabled={!canEdit} value={n.alignV} onChange={e => setN('alignV', e.target.value)}>
                    {SURF_NICHE_ALIGN_V.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
                  </Select>
                </Field>
                <Field label="Horizontal alignment">
                  <Select disabled={!canEdit} value={n.alignH} onChange={e => setN('alignH', e.target.value)}>
                    {SURF_NICHE_ALIGN_H.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
                  </Select>
                </Field>
                {canEdit && snapDiffers && (
                  <Button size="sm" variant="outline" onClick={() => Object.keys(snap).forEach(k => onNicheField(n.id, k, snap[k]))}>
                    Snap to that alignment
                  </Button>
                )}
              </div>
              {snapDiffers && (
                <p className="text-[11px] text-[var(--leon-brown)]">
                  Snapping writes the real dimension ({Object.keys(snap).map(k => `${k.replace('Mm', '')} ${fmtDim(snap[k], sys)}`).join(', ')}).
                  An alignment that only changed the drawing would leave the wrong number on the drawing the tiler builds from.
                </p>
              )}
              <div className="flex items-center justify-between gap-3 flex-wrap">
                <div className="text-[11px] text-[var(--leon-black)]/60 flex gap-3 flex-wrap">
                  <span>Below sill: <b>{a.sillCut === null ? 'lands on a joint' : fmtDim(a.sillCut, sys)}</b></span>
                  <span>Above head: <b>{a.headCut === null ? 'lands on a joint' : fmtDim(a.headCut, sys)}</b></span>
                  <span>Left jamb: <b>{a.leftCut === null ? 'lands on a joint' : fmtDim(a.leftCut, sys)}</b></span>
                  <span>Right jamb: <b>{a.rightCut === null ? 'lands on a joint' : fmtDim(a.rightCut, sys)}</b></span>
                </div>
                <div className="min-w-[220px]">
                  <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">Interior finish</div>
                  <SurfFinishPicker ctx={ctx} value={n.interiorFinish} disabled={!canEdit} onChange={v => setN('interiorFinish', v)} compact />
                </div>
              </div>
              <SurfWarnings list={a.warnings} />
            </div>
          );
        })}
      </div>
      {canEdit && onNicheAdd && (
        <Button size="sm" variant="outline" className="mt-3" onClick={onNicheAdd}>+ Add a niche</Button>
      )}
      {canEdit && !onNicheAdd && (
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-3">
          A niche can be added or removed on the Room Type only. A Room overrides the values of the niches its
          type defines; adding one to a single room would be a different room, and should be a different type.
        </p>
      )}
    </Collapsible>
  );
}

// ---- corners & continuity -------------------------------------------------
// The joints created with the room, listed as records. This panel is the whole
// argument for storing them: "continue the layout around this corner" is a
// setting on a thing that exists, not an instruction repeated on two drawings.

function SurfCornersPanel({ roomT, computedRoom, sys, canEdit, onCorner, onSelectSurface }) {
  const byId = {};
  (roomT.surfaces || []).forEach(s => { byId[s.id] = s; });
  const corners = (roomT.corners || []);
  const verticals = corners.filter(c => c.kind !== 'base');
  const bases = corners.filter(c => c.kind === 'base');

  function row(c) {
    const a = byId[c.aSurfaceId], b = byId[c.bSurfaceId];
    if (!a || !b) return null;
    const entryB = computedRoom.surfaces[b.id];
    const mismatch = (() => {
      const ea = computedRoom.surfaces[a.id];
      if (!ea || !entryB) return false;
      return Math.abs(ea.computed.moduleX - entryB.computed.moduleX) > 1;
    })();
    return (
      <div key={c.id} className="border border-[var(--leon-line)] rounded-lg p-3 bg-white">
        <div className="flex items-center gap-2 flex-wrap mb-2">
          <span className="font-semibold text-sm">{c.name}</span>
          <span className="text-[11px] text-[var(--leon-black)]/50">
            <button className="underline" onClick={() => onSelectSurface && onSelectSurface(a.id)}>{a.name}</button>
            <span className="mx-1">{c.aEdge} edge &rarr; {c.bEdge} edge</span>
            <button className="underline" onClick={() => onSelectSurface && onSelectSurface(b.id)}>{b.name}</button>
          </span>
        </div>
        <div className="grid gap-3 md:grid-cols-3 items-end">
          <Field label="Continue layout around corner">
            <Select disabled={!canEdit} value={c.continuity} onChange={e => onCorner(c.id, 'continuity', e.target.value)}>
              {SURF_CONTINUITY.map(x => <option key={x}>{x}</option>)}
            </Select>
          </Field>
          {c.continuity === 'Custom' && (
            <Field label="First piece on the receiving surface">
              <SurfDimInput sys={sys} disabled={!canEdit} valueMm={c.customLeadMm} onChange={v => onCorner(c.id, 'customLeadMm', v)} />
            </Field>
          )}
          <label className="flex items-center gap-2 text-sm pb-2">
            <input type="checkbox" disabled={!canEdit} checked={!!c.alignCourses}
              onChange={e => onCorner(c.id, 'alignCourses', e.target.checked)} />
            Align courses to the master surface
          </label>
        </div>
        {/* HOW THE TWO PLANES MEET. Continuity above is the set-out question —
            whether the grid runs through. This is the other one: what the tiler
            physically cuts where the planes meet, and it was missing. */}
        {c.kind === 'corner' && (
          <div className="grid gap-3 md:grid-cols-3 items-end mt-3 pt-3 border-t border-[var(--leon-line)]">
            <Field label="Corner joint">
              <Select disabled={!canEdit} value={c.joint || 'mitre45'}
                onChange={e => onCorner(c.id, 'joint', e.target.value)}>
                {SURF_CORNER_JOINTS.map(j => <option key={j.key} value={j.key}>{j.label}</option>)}
              </Select>
            </Field>
            <Field label="Turn at this corner" hint="90° is a square room. The mitre is half of it, on each tile.">
              <TextInput className="!w-24" disabled={!canEdit} defaultValue={c.angleDeg === undefined ? 90 : c.angleDeg}
                onBlur={e => onCorner(c.id, 'angleDeg', Math.max(1, Math.min(179, Number(e.target.value) || 90)))} />
            </Field>
            <label className="flex items-center gap-2 text-sm pb-2">
              <input type="checkbox" disabled={!canEdit} checked={!!c.external}
                onChange={e => onCorner(c.id, 'external', e.target.checked)} />
              External corner (the faces turn away)
            </label>
          </div>
        )}
        {c.kind === 'corner' && (() => {
          const j = surfCornerJoint(c.joint);
          const half = surfMitreAngle(c);
          const shared = Math.min(a.heightMm || 0, b.heightMm || 0);
          const thick = Math.max(surfTileThickness(roomT, a), surfTileThickness(roomT, b));
          const warns = [];
          if (j.mitre && thick > 0 && thick < SURF_MIN_MITRE_THICKNESS_MM) {
            warns.push({ level: 'bad', text: `This tile is ${fmtDim(thick, sys)} thick. Under ${fmtDim(SURF_MIN_MITRE_THICKNESS_MM, sys)} a 45° cut breaks out at the arris — butt this corner or take a trim profile instead.` });
          }
          if (j.mitre && !c.external) {
            warns.push({ level: 'note', text: 'This is an internal corner, where a butt joint with silicone is the ordinary detail — a mitre here is a finish decision, not a necessity. On an external corner it is the only way to hide the cut edge.' });
          }
          if (j.key === 'overlap') {
            warns.push({ level: 'warn', text: 'An overlap leaves a cut tile edge in view. Only specify it where that edge is glazed or the corner is not seen.' });
          }
          return (
            <div className="mt-2">
              <p className="text-[11px] text-[var(--leon-black)]/55">
                <b>{j.label}</b> &mdash; {j.note}
                {j.mitre && shared > 0 && (
                  <> Over {fmtDim(shared, sys)} of corner that is <b>{fmtDim(shared * 2, sys)}</b> of cut,
                    because both tiles take {half.toFixed(half % 1 ? 1 : 0)}&deg;.</>
                )}
              </p>
              {warns.length > 0 && <SurfWarnings className="mt-2" list={warns} />}
            </div>
          );
        })()}
        <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
          {c.continuity === 'Continuous'
            ? `${b.name} starts where ${a.name} left off — it picks up the remainder of the tile that turned the corner instead of restarting with a full one.`
            : c.continuity === 'Custom'
              ? `${b.name} starts with a piece of the width set above, whatever ${a.name} ended on.`
              : `${b.name} is set out on its own; nothing carries through this corner.`}
        </p>
        {mismatch && c.continuity === 'Continuous' && (
          <SurfWarnings className="mt-2" list={[{ level: 'warn', text: `${a.name} and ${b.name} use different modules (tile width + joint), so nothing can continue through this corner. Match them or set this joint to Independent.` }]} />
        )}
      </div>
    );
  }

  return (
    <Collapsible title="Joints & continuity" count={corners.length} id={`surf-corners-${roomT.id}`}>
      <p className="text-xs text-[var(--leon-black)]/55 mb-3">
        These joints were created with the room. Each one names the two edges that physically meet, which is
        what lets a course carry around a corner rather than being drawn twice and hoped about.
      </p>
      <div className="space-y-3">{verticals.map(row)}</div>
      {bases.length > 0 && (
        <>
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mt-4 mb-2">Floor-to-wall joints</div>
          <p className="text-[11px] text-[var(--leon-black)]/50 mb-2">
            Recorded, but set to Independent by default: a floor grid and a wall grid rarely run through each
            other, and defaulting them to continuous would silently move every wall&rsquo;s set-out.
          </p>
          <div className="space-y-3">{bases.map(row)}</div>
        </>
      )}
    </Collapsible>
  );
}

// ---- adding a shower / tub / a one-off surface -----------------------------

function SurfAddSurfaceModal({ open, kind, sys, roomT, onClose, onAdd }) {
  const [f, setF] = useState({ widthMm: 1524, depthMm: 914.4, heightMm: 2032, curbMm: 152.4, name: 'New surface', surfKind: 'Wall' });
  useEffect(() => {
    if (!open) return;
    if (kind === 'shower') setF(x => ({ ...x, widthMm: 1524, depthMm: 914.4, heightMm: 2032, curbMm: 152.4 }));
    if (kind === 'tub') setF(x => ({ ...x, widthMm: 1524, depthMm: 762, heightMm: 1219.2 }));
    if (kind === 'custom') setF(x => ({ ...x, widthMm: 1219.2, heightMm: 914.4, name: 'New surface', surfKind: 'Wall' }));
  }, [open, kind]);
  if (!open) return null;
  const title = kind === 'shower' ? 'Add a shower' : kind === 'tub' ? 'Add a tub surround' : 'Add a surface';

  function add() {
    if (kind === 'shower') onAdd(surfBuildShower(f.widthMm, f.depthMm, f.heightMm, f.curbMm));
    else if (kind === 'tub') onAdd(surfBuildTubSurround(f.widthMm, f.depthMm, f.heightMm));
    else onAdd({ surfaces: [surfMakeSurface({ key: 'custom', name: f.name, kind: f.surfKind, widthMm: f.widthMm, heightMm: f.heightMm })], corners: [] });
  }

  return (
    <Modal open={open} onClose={onClose} title={title} footer={
      <>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button onClick={add}>Add</Button>
      </>
    }>
      <div className="space-y-3">
        {kind === 'shower' && <p className="text-xs text-[var(--leon-black)]/55">Creates the back and two side walls, the shower floor and the curb, plus the two joints between the walls — the same connected geometry as the room itself.</p>}
        {kind === 'tub' && <p className="text-xs text-[var(--leon-black)]/55">Creates the back panel, both returns and the deck, plus the two joints between them.</p>}
        {kind === 'custom' && (
          <div className="grid grid-cols-2 gap-3">
            <Field label="Name"><TextInput value={f.name} onChange={e => setF({ ...f, name: e.target.value })} /></Field>
            <Field label="Kind">
              <Select value={f.surfKind} onChange={e => setF({ ...f, surfKind: e.target.value })}>
                {SURF_SURFACE_KINDS.map(k => <option key={k}>{k}</option>)}
              </Select>
            </Field>
          </div>
        )}
        <div className="grid grid-cols-2 gap-3">
          <Field label={kind === 'custom' ? 'Width' : kind === 'tub' ? 'Tub length' : 'Shower width'}>
            <SurfDimInput sys={sys} valueMm={f.widthMm} onChange={v => setF({ ...f, widthMm: v })} />
          </Field>
          {kind !== 'custom' && (
            <Field label={kind === 'tub' ? 'Return depth' : 'Shower depth'}>
              <SurfDimInput sys={sys} valueMm={f.depthMm} onChange={v => setF({ ...f, depthMm: v })} />
            </Field>
          )}
          <Field label="Height">
            <SurfDimInput sys={sys} valueMm={f.heightMm} onChange={v => setF({ ...f, heightMm: v })} />
          </Field>
          {kind === 'shower' && (
            <Field label="Curb height"><SurfDimInput sys={sys} valueMm={f.curbMm} onChange={v => setF({ ...f, curbMm: v })} /></Field>
          )}
        </div>
      </div>
    </Modal>
  );
}

// ---- change propagation with impact ---------------------------------------
// Nothing is written until this has been read. A Room reads live through to its
// Type, so the counts here are the difference between a correction and an
// accident: the rooms that will move, the rooms holding their own answer, and
// the rooms already on the wall.

function SurfImpactModal({ open, onClose, ctx, roomT, draft, diff, onConfirm }) {
  if (!open || !roomT || !draft) return null;
  const impact = surfImpact(ctx.projects, draft.id, diff.paths);
  const nothingUses = impact.total === 0;

  return (
    <Modal open={open} onClose={onClose} wide title={`Apply changes to ${draft.code}`} footer={
      <>
        <Button variant="ghost" onClick={onClose}>Back to editing</Button>
        <Button onClick={onConfirm}>
          {impact.installed.length ? `Apply — update ${impact.willUpdate.length}, protect ${impact.installed.length}` : `Apply to ${impact.willUpdate.length} room${impact.willUpdate.length === 1 ? '' : 's'}`}
        </Button>
      </>
    }>
      <div className="space-y-4">
        <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
          <SurfStat label="Rooms using this type" value={impact.total} />
          <SurfStat label="Will update" value={impact.willUpdate.length} />
          <SurfStat label="Hold an override" value={impact.holding.length} />
          <SurfStat label="Already installed" value={impact.installed.length} />
        </div>

        {nothingUses && <p className="text-sm text-[var(--leon-black)]/55">No physical room uses this type yet, so this change affects nothing but the standard itself.</p>}

        <div>
          <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">
            What changes ({diff.paths.length + diff.structural.length})
          </div>
          <div className="max-h-40 overflow-y-auto border border-[var(--leon-line)] rounded-md divide-y divide-[var(--leon-line)]">
            {diff.structural.map((s, i) => (
              <div key={`s${i}`} className="px-2.5 py-1.5 text-xs">{s}</div>
            ))}
            {diff.paths.map(p => (
              <div key={p.path} className="px-2.5 py-1.5 text-xs flex justify-between gap-3">
                <span className="font-semibold">{surfPathLabel(draft, p.path)}</span>
                <span className="text-[var(--leon-black)]/55 text-right">
                  {surfShowValue(p.from)} &rarr; <b className="text-[var(--leon-black)]">{surfShowValue(p.to)}</b>
                </span>
              </div>
            ))}
          </div>
        </div>

        {impact.holding.length > 0 && (
          <div>
            <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">Holding their own value — not touched</div>
            <div className="max-h-32 overflow-y-auto border border-[var(--leon-line)] rounded-md divide-y divide-[var(--leon-line)]">
              {impact.holding.map(r => (
                <div key={r.room.id} className="px-2.5 py-1.5 text-xs">
                  <b>{r.room.name}</b> &middot; {r.project.name}
                  <span className="text-[var(--leon-black)]/50"> — overrides {r.holds.map(h => surfPathLabel(roomT, h.path)).join(', ')}</span>
                </div>
              ))}
            </div>
            <p className="text-[11px] text-[var(--leon-black)]/55 mt-1">An override is somebody&rsquo;s decision. It is never silently overwritten from here — reset it on the room itself if it should follow the type again.</p>
          </div>
        )}

        {impact.installed.length > 0 && (
          <div>
            <div className="text-xs font-bold uppercase tracking-wide text-[#8f2f2f] mb-1">Already installed — will be frozen as-built</div>
            <div className="max-h-32 overflow-y-auto border border-[#f0c9c9] rounded-md divide-y divide-[#f0c9c9] bg-[#fdf4f4]">
              {impact.installed.map(r => (
                <div key={r.room.id} className="px-2.5 py-1.5 text-xs"><b>{r.room.name}</b> &middot; {r.project.name}</div>
              ))}
            </div>
            <p className="text-[11px] text-[var(--leon-black)]/55 mt-1">
              A Room reads live through to its Type, so the only way to stop this change reaching tile that is
              already on the wall is to write the current values onto those rooms as overrides. That is what
              &ldquo;frozen&rdquo; means here, and it is recorded in each project&rsquo;s change log.
            </p>
          </div>
        )}
      </div>
    </Modal>
  );
}

function surfShowValue(v) {
  if (v === null || v === undefined || v === '') return '—';
  if (typeof v === 'boolean') return v ? 'yes' : 'no';
  if (typeof v === 'number') return Math.round(v * 10) / 10;
  if (typeof v === 'object') return v.name || v.code || 'a record';
  return String(v);
}

// ============================================================================
// Unit Types — the set of room types a typical unit is made of
// ============================================================================

function SurfUnitTypesTab({ ctx, lib, canEditLib, onOpenType }) {
  const [adding, setAdding] = useState(false);
  const [form, setForm] = useState({ code: '', name: '' });

  const counts = useMemo(() => {
    const m = {};
    (ctx.projects || []).forEach(p => surfProjectUnits(p).forEach(u => { m[u.unitTypeId] = (m[u.unitTypeId] || 0) + 1; }));
    return m;
  }, [ctx.projects]);

  function toggleRoomType(ut, rtId) {
    surfSetLib(ctx, l => {
      const t = l.unitTypes.find(x => x.id === ut.id);
      if (!t) return;
      t.roomTypeIds = t.roomTypeIds || [];
      const i = t.roomTypeIds.indexOf(rtId);
      if (i >= 0) t.roomTypeIds.splice(i, 1); else t.roomTypeIds.push(rtId);
    });
  }

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-2">
        <p className="text-sm text-[var(--leon-black)]/55 flex-1">
          A Unit Type is the standard apartment or suite — which room types it contains. Adding a unit of this
          type to a floor creates one room per room type listed here, each inheriting its type.
        </p>
        {canEditLib && <Button onClick={() => setAdding(true)}>+ New Unit Type</Button>}
      </div>

      {lib.unitTypes.length === 0
        ? <EmptyState text="No unit types yet." />
        : lib.unitTypes.map(ut => (
          <Collapsible key={ut.id} title={`${ut.code} · ${ut.name}`} id={`surf-ut-${ut.id}`}
            count={(ut.roomTypeIds || []).length}
            right={<Badge tone={counts[ut.id] ? 'brown' : 'neutral'}>{counts[ut.id] || 0} units</Badge>}>
            <div className="grid gap-3 md:grid-cols-2">
              <div className="space-y-3">
                <div className="grid grid-cols-2 gap-3">
                  <Field label="Code"><TextInput disabled={!canEditLib} value={ut.code}
                    onChange={e => surfSetLib(ctx, l => { const t = l.unitTypes.find(x => x.id === ut.id); if (t) t.code = e.target.value; })} /></Field>
                  <Field label="Name"><TextInput disabled={!canEditLib} value={ut.name}
                    onChange={e => surfSetLib(ctx, l => { const t = l.unitTypes.find(x => x.id === ut.id); if (t) t.name = e.target.value; })} /></Field>
                </div>
                <Field label="Notes"><TextArea rows={3} disabled={!canEditLib} value={ut.notes || ''}
                  onChange={e => surfSetLib(ctx, l => { const t = l.unitTypes.find(x => x.id === ut.id); if (t) t.notes = e.target.value; })} /></Field>
              </div>
              <div>
                <div className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50 mb-1">Room types in this unit</div>
                {lib.roomTypes.length === 0
                  ? <EmptyState text="Create a room type first." />
                  : (
                    <div className="border border-[var(--leon-line)] rounded-md divide-y divide-[var(--leon-line)] max-h-64 overflow-y-auto">
                      {lib.roomTypes.map(rt => (
                        <label key={rt.id} className="flex items-center gap-2 px-2.5 py-1.5 text-sm">
                          <input type="checkbox" disabled={!canEditLib}
                            checked={(ut.roomTypeIds || []).indexOf(rt.id) >= 0}
                            onChange={() => toggleRoomType(ut, rt.id)} />
                          <span className="flex-1">{rt.code} &middot; {rt.name}</span>
                          <button className="text-[11px] text-[var(--leon-brown)] font-semibold" onClick={e => { e.preventDefault(); onOpenType(rt.id); }}>open</button>
                        </label>
                      ))}
                    </div>
                  )}
              </div>
            </div>
          </Collapsible>
        ))}

      <Modal open={adding} onClose={() => setAdding(false)} title="New Unit Type" footer={
        <>
          <Button variant="ghost" onClick={() => setAdding(false)}>Cancel</Button>
          <Button disabled={!form.code.trim()} onClick={() => {
            surfSetLib(ctx, l => { l.unitTypes = [...l.unitTypes, surfMakeUnitType(form)]; });
            setAdding(false); setForm({ code: '', name: '' });
          }}>Create</Button>
        </>
      }>
        <div className="grid grid-cols-2 gap-3">
          <Field label="Code"><TextInput value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="A1" /></Field>
          <Field label="Name"><TextInput value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="2-bed corner" /></Field>
        </div>
      </Modal>
    </div>
  );
}

// ============================================================================
// The project side: Building > Floor > Unit > Room
// ============================================================================

function SurfProjectPicker({ projectPool, project, onPick }) {
  return (
    <Field label="Project">
      <Select value={project ? project.id : ''} onChange={e => onPick(e.target.value)} className="!w-auto">
        {projectPool.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
      </Select>
    </Field>
  );
}

function SurfProjectTab({ ctx, lib, sys, canEdit, projectPool, project, onPickProject, onOpenType }) {
  const [openRoomId, setOpenRoomId] = useState(null);
  const [addBuilding, setAddBuilding] = useState(false);
  const [addUnitFor, setAddUnitFor] = useState(null); // {buildingId, floorId}
  const [bForm, setBForm] = useState({ name: '', code: '', floors: 3 });
  const [uForm, setUForm] = useState({ number: '', unitTypeId: '' });

  if (!projectPool.length) return <EmptyState text="No projects available in this department." />;
  if (!project) return <EmptyState text="Pick a project." />;

  const buildings = surfBuildings(project);
  const units = surfProjectUnits(project);
  const rooms = surfProjectRooms(project);
  const roomTypeById = {}; lib.roomTypes.forEach(t => { roomTypeById[t.id] = t; });
  const unitTypeById = {}; lib.unitTypes.forEach(t => { unitTypeById[t.id] = t; });

  function createBuilding() {
    const n = Math.max(1, Math.min(60, parseInt(bForm.floors, 10) || 1));
    ctx.updateProject(project.id, d => {
      d.buildings = surfBuildings(d);
      const b = {
        id: uid('sbld'), name: bForm.name || 'Building', code: bForm.code || '',
        floors: Array.from({ length: n }, (_, i) => ({ id: uid('sflr'), name: `Level ${i + 1}`, level: i + 1 })),
      };
      d.buildings.push(b);
      surfLog(d, ctx, `Surfaces: added building ${b.name} with ${n} floor${n === 1 ? '' : 's'}`);
    });
    setAddBuilding(false);
    setBForm({ name: '', code: '', floors: 3 });
  }

  // Adding a unit instantiates its type's room list. The rooms hold nothing but
  // a pointer to their room type — the design is not copied, which is the whole
  // point of the inheritance model.
  function createUnit() {
    if (!addUnitFor || !uForm.unitTypeId) return;
    const ut = unitTypeById[uForm.unitTypeId];
    ctx.updateProject(project.id, d => {
      d.units = surfProjectUnits(d);
      d.rooms = surfProjectRooms(d);
      const u = { id: uid('sunit'), buildingId: addUnitFor.buildingId, floorId: addUnitFor.floorId,
        unitTypeId: uForm.unitTypeId, number: uForm.number || 'Unit', createdDate: todayISO() };
      d.units.push(u);
      ((ut && ut.roomTypeIds) || []).forEach(rtId => {
        const rt = roomTypeById[rtId];
        d.rooms.push({
          id: uid('sroom'), unitId: u.id, roomTypeId: rtId,
          name: `${u.number} — ${rt ? rt.name : 'Room'}`,
          status: 'Not Started', overrides: {}, installedDate: null, notes: '',
        });
      });
      surfLog(d, ctx, `Surfaces: added unit ${u.number} (${ut ? ut.code : '—'}) with ${((ut && ut.roomTypeIds) || []).length} room(s)`);
    });
    setAddUnitFor(null);
    setUForm({ number: '', unitTypeId: '' });
  }

  const openRoom = rooms.find(r => r.id === openRoomId) || null;

  if (openRoom) {
    const rt = roomTypeById[openRoom.roomTypeId];
    const unit = units.find(u => u.id === openRoom.unitId);
    return (
      <SurfRoomInstance ctx={ctx} project={project} room={openRoom} roomT={rt} unit={unit} sys={sys}
        canEdit={canEdit} onBack={() => setOpenRoomId(null)} onOpenType={onOpenType} />
    );
  }

  return (
    <div className="space-y-3">
      <div className="flex items-end gap-3 flex-wrap">
        <SurfProjectPicker projectPool={projectPool} project={project} onPick={onPickProject} />
        <div className="flex-1" />
        {canEdit && <Button onClick={() => setAddBuilding(true)}>+ Building</Button>}
      </div>

      {buildings.length === 0 && <EmptyState text="No buildings on this project yet. A building holds floors; a floor holds units; a unit holds rooms." />}

      {buildings.map(b => (
        <Collapsible key={b.id} title={`${b.name}${b.code ? ` (${b.code})` : ''}`} id={`surf-bld-${b.id}`}
          count={(b.floors || []).length} defaultOpen>
          <div className="space-y-2">
            {(b.floors || []).map(f => {
              const fUnits = units.filter(u => u.floorId === f.id);
              return (
                <div key={f.id} className="border border-[var(--leon-line)] rounded-lg p-2.5 bg-white">
                  <div className="flex items-center gap-2 mb-2">
                    <span className="font-semibold text-sm">{f.name}</span>
                    <span className="text-[11px] text-[var(--leon-black)]/45">{fUnits.length} unit{fUnits.length === 1 ? '' : 's'}</span>
                    <div className="flex-1" />
                    {canEdit && <Button size="sm" variant="ghost" onClick={() => setAddUnitFor({ buildingId: b.id, floorId: f.id })}>+ Unit</Button>}
                  </div>
                  {fUnits.length === 0
                    ? <p className="text-xs text-[var(--leon-black)]/40 italic px-1">No units on this floor.</p>
                    : (
                      <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
                        {fUnits.map(u => {
                          const uRooms = rooms.filter(r => r.unitId === u.id);
                          const ut = unitTypeById[u.unitTypeId];
                          return (
                            <div key={u.id} className="border border-[var(--leon-line)] rounded-md p-2">
                              <div className="flex items-center gap-2 mb-1">
                                <span className="font-semibold text-sm">{u.number}</span>
                                <Badge tone="neutral">{ut ? ut.code : 'no type'}</Badge>
                              </div>
                              <div className="divide-y divide-[var(--leon-line)]">
                                {uRooms.map(r => {
                                  const rt = roomTypeById[r.roomTypeId];
                                  const nOver = Object.keys(surfOverrides(r)).length;
                                  return (
                                    <button key={r.id} onClick={() => setOpenRoomId(r.id)}
                                      className="w-full flex items-center gap-2 py-1.5 text-left hover:bg-[var(--leon-cream)] px-1 rounded">
                                      <span className="flex-1 min-w-0">
                                        <span className="block text-xs font-semibold truncate">{rt ? `${rt.code} · ${rt.name}` : 'Room type missing'}</span>
                                        <span className="block text-[10px] text-[var(--leon-black)]/45">
                                          {nOver ? `${nOver} override${nOver === 1 ? '' : 's'}` : 'fully inherited'}
                                        </span>
                                      </span>
                                      <Badge tone={r.status === 'Installed' ? 'green' : r.status === 'Released for Construction' ? 'brown' : 'neutral'}>{r.status}</Badge>
                                    </button>
                                  );
                                })}
                                {uRooms.length === 0 && <p className="text-[11px] text-[var(--leon-black)]/40 italic py-1">Its unit type lists no room types.</p>}
                              </div>
                            </div>
                          );
                        })}
                      </div>
                    )}
                </div>
              );
            })}
          </div>
        </Collapsible>
      ))}

      <Modal open={addBuilding} onClose={() => setAddBuilding(false)} title="Add a building" footer={
        <>
          <Button variant="ghost" onClick={() => setAddBuilding(false)}>Cancel</Button>
          <Button onClick={createBuilding} disabled={!bForm.name.trim()}>Create</Button>
        </>
      }>
        <div className="grid grid-cols-3 gap-3">
          <Field label="Name" className="col-span-2"><TextInput value={bForm.name} onChange={e => setBForm({ ...bForm, name: e.target.value })} placeholder="Tower A" /></Field>
          <Field label="Floors"><TextInput type="number" min="1" max="60" value={bForm.floors} onChange={e => setBForm({ ...bForm, floors: e.target.value })} /></Field>
        </div>
      </Modal>

      <Modal open={!!addUnitFor} onClose={() => setAddUnitFor(null)} title="Add a unit" footer={
        <>
          <Button variant="ghost" onClick={() => setAddUnitFor(null)}>Cancel</Button>
          <Button onClick={createUnit} disabled={!uForm.unitTypeId}>Create unit &amp; its rooms</Button>
        </>
      }>
        <div className="space-y-3">
          <Field label="Unit number"><TextInput value={uForm.number} onChange={e => setUForm({ ...uForm, number: e.target.value })} placeholder="503" /></Field>
          <Field label="Unit type" hint="Its room types are created as rooms, each inheriting its type">
            <Select value={uForm.unitTypeId} onChange={e => setUForm({ ...uForm, unitTypeId: e.target.value })}>
              <option value="">Select…</option>
              {lib.unitTypes.map(t => <option key={t.id} value={t.id}>{t.code} — {t.name} ({(t.roomTypeIds || []).length} rooms)</option>)}
            </Select>
          </Field>
        </div>
      </Modal>
    </div>
  );
}

// ---- one physical room: inherited, with its own overrides -----------------

function SurfRoomInstance({ ctx, project, room, roomT, unit, sys, canEdit, onBack, onOpenType }) {
  const [selectedId, setSelectedId] = useState(null);
  const effective = useMemo(() => (roomT ? surfEffectiveRoomType(roomT, room) : null), [roomT, room]);
  const computedRoom = useMemo(() => (effective ? surfComputeRoom(effective) : null), [effective]);

  if (!roomT) return <EmptyState text="This room points at a room type that no longer exists." />;
  if (!effective || !computedRoom) return null;

  const labelled = { ...room, __label: unit ? unit.number : room.name };
  const installed = room.status === 'Installed';
  const editable = canEdit && !installed;
  const overrides = surfOverrides(room);
  const overrideKeys = Object.keys(overrides);

  function setOverride(path, value) {
    ctx.updateProject(project.id, d => {
      const r = surfProjectRooms(d).find(x => x.id === room.id);
      if (!r) return;
      r.overrides = { ...(r.overrides || {}) };
      r.overrides[path] = value;
      surfLog(d, ctx, `Surfaces: ${room.name} — override on ${surfPathLabel(roomT, path)}`);
    });
  }
  // Several fields that are ONE decision — moving a set-out writes an origin, a
  // phase and possibly a starting elevation, and four change-log lines for one
  // button press makes the log harder to read rather than more complete.
  function setOverrides(map, note) {
    ctx.updateProject(project.id, d => {
      const r = surfProjectRooms(d).find(x => x.id === room.id);
      if (!r) return;
      r.overrides = { ...(r.overrides || {}) };
      Object.keys(map).forEach(k => { r.overrides[k] = map[k]; });
      surfLog(d, ctx, `Surfaces: ${room.name} — ${note || 'several fields changed'}`);
    });
  }
  function resetOverride(path) {
    ctx.updateProject(project.id, d => {
      const r = surfProjectRooms(d).find(x => x.id === room.id);
      if (!r || !r.overrides) return;
      const next = { ...r.overrides };
      delete next[path];
      r.overrides = next;
      surfLog(d, ctx, `Surfaces: ${room.name} — reset ${surfPathLabel(roomT, path)} to ${roomT.code}`);
    });
  }
  function setStatus(status) {
    ctx.updateProject(project.id, d => {
      const r = surfProjectRooms(d).find(x => x.id === room.id);
      if (!r) return;
      r.status = status;
      if (status === 'Installed' && !r.installedDate) r.installedDate = todayISO();
      surfLog(d, ctx, `Surfaces: ${room.name} — ${status}`);
    });
  }

  const sel = (effective.surfaces || []).find(s => s.id === selectedId)
    || (effective.surfaces || []).find(s => s.id === effective.masterSurfaceId)
    || (effective.surfaces || [])[0];
  const selEntry = sel ? computedRoom.surfaces[sel.id] : null;

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-3 flex-wrap">
        <Button variant="ghost" onClick={onBack}>&larr; Buildings &amp; rooms</Button>
        <div className="font-bold">{room.name}</div>
        <Badge tone="neutral">Type {roomT.code}</Badge>
        <button className="text-xs text-[var(--leon-brown)] font-semibold" onClick={() => onOpenType(roomT.id)}>open the type</button>
        <div className="flex-1" />
        <Field label="Status">
          <Select disabled={!canEdit} value={room.status} onChange={e => setStatus(e.target.value)} className="!w-auto">
            {SURF_ROOM_STATUSES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
      </div>

      {installed && (
        <SurfWarnings list={[{ level: 'note', text: `This room is recorded as installed${room.installedDate ? ` on ${fmtDate(room.installedDate)}` : ''}. Its set-out is read-only here — change the status first if the as-built really did change.` }]} />
      )}

      <div className="border border-[var(--leon-line)] rounded-lg bg-white p-3">
        <div className="flex items-center justify-between gap-2 mb-2">
          <span className="text-xs font-bold uppercase tracking-wide text-[var(--leon-black)]/50">
            Inheritance — {overrideKeys.length ? `${overrideKeys.length} override${overrideKeys.length === 1 ? '' : 's'}` : 'everything inherited'}
          </span>
          {overrideKeys.length > 0 && canEdit && (
            <Button size="sm" variant="ghost" onClick={() => {
              if (!confirm(`Reset every override on ${room.name} back to ${roomT.code}?`)) return;
              ctx.updateProject(project.id, d => {
                const r = surfProjectRooms(d).find(x => x.id === room.id);
                if (!r) return;
                r.overrides = {};
                surfLog(d, ctx, `Surfaces: ${room.name} — all overrides reset to ${roomT.code}`);
              });
            }}>Reset everything to {roomT.code}</Button>
          )}
        </div>
        {overrideKeys.length === 0
          ? <p className="text-xs text-[var(--leon-black)]/50">This room reads straight through to {roomT.code}. Correcting the type corrects this room.</p>
          : (
            <div className="divide-y divide-[var(--leon-line)]">
              {overrideKeys.map(k => (
                <div key={k} className="py-1.5 flex items-center gap-3 text-xs">
                  <span className="flex-1">{surfPathLabel(roomT, k)}</span>
                  <span className="text-[var(--leon-black)]/50">{surfShowValue(surfTypeValueAt(roomT, k))} &rarr; <b className="text-[var(--leon-black)]">{surfShowValue(overrides[k])}</b></span>
                  {canEdit && <button className="underline text-[var(--leon-black)]/50" onClick={() => resetOverride(k)}>Reset to Type</button>}
                </div>
              ))}
            </div>
          )}
      </div>

      <Collapsible title="Unfolded elevation" defaultOpen id={`surf-room-unfold-${room.id}`}>
        <SurfUnfoldedElevation roomT={effective} computedRoom={computedRoom} sys={sys} />
      </Collapsible>

      <SurfSurfaceNav surfaces={effective.surfaces || []} computedRoom={computedRoom} selectedId={sel && sel.id} onSelect={setSelectedId} />

      {sel && selEntry && (
        <>
          <SurfSurfacePanel ctx={ctx} roomT={roomT} entry={selEntry} sys={sys} canEdit={editable}
            room={labelled} onReset={resetOverride}
            onSurface={(f, v) => setOverride(`surface.${sel.id}.${f}`, v)}
            onLayout={(f, v) => setOverride(`surface.${sel.id}.layout.${f}`, v)}
            onLayoutMany={(m, note) => setOverrides(
              Object.keys(m).reduce((a, k) => { a[`surface.${sel.id}.layout.${k}`] = m[k]; return a; }, {}), note)} />
          <SurfNichesPanel ctx={ctx} roomT={roomT} entry={selEntry} sys={sys} canEdit={editable}
            room={labelled} onReset={resetOverride}
            onNicheField={(nid, f, v) => setOverride(`niche.${sel.id}.${nid}.${f}`, v)} />
        </>
      )}

      <SurfCornersPanel roomT={effective} computedRoom={computedRoom} sys={sys} canEdit={editable}
        onCorner={(id, f, v) => setOverride(`corner.${id}.${f}`, v)} onSelectSurface={setSelectedId} />
    </div>
  );
}

// ============================================================================
// Quantity roll-up
// ============================================================================

function SurfQuantitiesTab({ ctx, lib, sys, projectPool, project, onPickProject }) {
  const [scope, setScope] = useState('project'); // 'project' | 'all'

  const result = useMemo(() => {
    const pool = scope === 'all' ? (ctx.projects || []) : (project ? [project] : []);
    const rows = [];
    lib.roomTypes.forEach(rt => {
      const inst = [];
      pool.forEach(p => surfProjectRooms(p).forEach(r => { if (r.roomTypeId === rt.id) inst.push({ project: p, room: r }); }));
      if (!inst.length) return;
      // The type is costed ONCE and multiplied by its instance count. Rooms that
      // hold an override contribute only the DIFFERENCE against that same base,
      // so nothing in the project is measured twice.
      const base = surfRoomQuantities(rt);
      let lines = surfScaleLines(base, inst.length);
      const deltas = [];
      inst.forEach(({ project: p, room }) => {
        if (!Object.keys(surfOverrides(room)).length) return;
        const eff = surfEffectiveRoomType(rt, room);
        const own = surfRoomQuantities(eff);
        const byId = {}; base.forEach(l => { byId[l.surfaceId] = l; });
        own.forEach(l => {
          const b = byId[l.surfaceId];
          const dArea = l.areaMm2 - (b ? b.areaMm2 : 0);
          const dPieces = l.piecesNominal - (b ? b.piecesNominal : 0);
          const finishChanged = !b || b.finishKey !== l.finishKey;
          if (!finishChanged && Math.abs(dArea) < 1000) return;
          deltas.push({ room, project: p, line: l, base: b, dArea, dPieces, finishChanged });
        });
      });
      // Apply the deltas onto the scaled totals.
      deltas.forEach(d => {
        if (d.finishChanged && d.base) {
          const oldLine = lines.find(l => l.surfaceId === d.base.surfaceId && l.finishKey === d.base.finishKey);
          if (oldLine) { oldLine.areaMm2 -= d.base.areaMm2; oldLine.piecesNominal -= d.base.piecesNominal; }
          lines = lines.concat([{ ...d.line, areaMm2: d.line.areaMm2, piecesNominal: d.line.piecesNominal }]);
        } else {
          const l = lines.find(x => x.surfaceId === d.line.surfaceId);
          if (l) { l.areaMm2 += d.dArea; l.piecesNominal += d.dPieces; }
        }
      });
      // MITRED CORNERS are labour, not material — they add no tile and they are
      // not in `lines`, so they roll up separately. Multiplied by the instance
      // count for the same reason everything else is.
      const mitres = surfRoomMitres(rt);
      rows.push({ roomType: rt, instances: inst.length, overriddenRooms: new Set(deltas.map(d => d.room.id)).size,
        lines, deltas, mitres,
        mitreCutMm: mitres.reduce((n, m) => n + m.cutMm, 0) * inst.length,
        mitreCount: mitres.length * inst.length,
        mitreTooThin: mitres.some(m => m.tooThin) });
    });
    return rows;
  }, [ctx.projects, lib.roomTypes, project, scope]);

  const allLines = result.reduce((a, r) => a.concat(r.lines), []);
  const materials = surfSumByFinish(allLines);

  return (
    <div className="space-y-3">
      <div className="flex items-end gap-3 flex-wrap">
        <Field label="Count">
          <Select value={scope} onChange={e => setScope(e.target.value)} className="!w-auto">
            <option value="project">This project</option>
            <option value="all">Every project</option>
          </Select>
        </Field>
        {scope === 'project' && <SurfProjectPicker projectPool={projectPool} project={project} onPick={onPickProject} />}
      </div>

      {result.length === 0 && <EmptyState text="No rooms have been created from a room type yet." />}

      {result.map(r => (
        <Collapsible key={r.roomType.id} title={`${r.roomType.code} · ${r.roomType.name}`} id={`surf-qty-${r.roomType.id}`}
          count={r.instances}
          right={<span className="text-[11px] text-[var(--leon-black)]/50">
            {r.overriddenRooms ? `${r.overriddenRooms} room${r.overriddenRooms === 1 ? '' : 's'} differ` : 'all identical'}
          </span>}>
          {r.mitreCount > 0 && (
            <div className="mb-2 rounded-lg border border-[var(--leon-line)] bg-white p-2.5">
              <div className="flex items-center gap-3 flex-wrap text-sm">
                <span className="text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">Mitred corners</span>
                <span><b>{r.mitreCount}</b> corner{r.mitreCount === 1 ? '' : 's'}</span>
                <span className="tabular-nums"><b>{fmtDim(r.mitreCutMm, sys)}</b> of 45&deg; cut</span>
                {r.mitreTooThin && <Badge tone="red">tile too thin to mitre</Badge>}
              </div>
              <p className="text-[11px] text-[var(--leon-black)]/55 mt-1">
                Labour, not material &mdash; a mitre adds no tile. The cut is made on <b>BOTH</b> pieces, so a
                corner is twice its own height, the same rule a countertop mitre bills on.
              </p>
            </div>
          )}
          <div className="overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
            <table className="w-full text-xs">
              <thead className="bg-[var(--leon-cream)]">
                <tr className="text-left">
                  <th className="px-2.5 py-1.5 font-semibold">Surface</th>
                  <th className="px-2.5 py-1.5 font-semibold">Finish</th>
                  <th className="px-2.5 py-1.5 font-semibold">Area × {r.instances}</th>
                  <th className="px-2.5 py-1.5 font-semibold">Waste</th>
                  <th className="px-2.5 py-1.5 font-semibold">From</th>
                  <th className="px-2.5 py-1.5 font-semibold">With waste</th>
                  <th className="px-2.5 py-1.5 font-semibold">Pieces</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-[var(--leon-line)]">
                {r.lines.map((l, i) => (
                  <tr key={`${l.surfaceId}-${l.finishKey}-${i}`}>
                    <td className="px-2.5 py-1.5">{l.surfaceName}</td>
                    <td className="px-2.5 py-1.5">{l.finishName}</td>
                    <td className="px-2.5 py-1.5">{surfM2(l.areaMm2).toFixed(2)} m²</td>
                    <td className="px-2.5 py-1.5">{(l.waste * 100).toFixed(1)}%</td>
                    <td className="px-2.5 py-1.5">
                      {l.wasteSource === 'computed'
                        ? <Badge tone="brown">set-out</Badge>
                        : <Badge tone="neutral">allowance</Badge>}
                    </td>
                    <td className="px-2.5 py-1.5">{(surfM2(l.areaMm2) * (1 + l.waste)).toFixed(2)} m²</td>
                    <td className="px-2.5 py-1.5">
                      {l.wasteSource === 'computed'
                        ? <span><b>{Math.ceil(l.piecesNominal)}</b>{l.surplusPieces ? <span className="text-[var(--leon-black)]/45"> · {Math.round(l.surplusPieces)} surplus</span> : null}</span>
                        : Math.ceil(l.piecesNominal * (1 + l.waste))}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {r.deltas.length > 0 && (
            <div className="mt-2">
              <div className="text-[11px] font-bold uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">Override deltas applied</div>
              <div className="text-[11px] text-[var(--leon-black)]/60 space-y-0.5">
                {r.deltas.map((d, i) => (
                  <div key={i}>
                    {d.room.name} &middot; {d.line.surfaceName} &mdash;
                    {d.finishChanged
                      ? ` finish changed to ${d.line.finishName}`
                      : ` ${d.dArea > 0 ? '+' : ''}${surfM2(d.dArea).toFixed(2)} m²`}
                  </div>
                ))}
              </div>
              <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
                The type is measured once and multiplied by {r.instances}. Only the difference a room actually
                holds is added on top — nothing here is measured twice.
              </p>
            </div>
          )}
        </Collapsible>
      ))}

      {materials.length > 0 && (
        <Collapsible title="Material requirement" defaultOpen id="surf-qty-materials" count={materials.length}>
          <div className="overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
            <table className="w-full text-xs">
              <thead className="bg-[var(--leon-cream)]">
                <tr className="text-left">
                  <th className="px-2.5 py-1.5 font-semibold">Finish</th>
                  <th className="px-2.5 py-1.5 font-semibold">Supplier</th>
                  <th className="px-2.5 py-1.5 font-semibold">Net area</th>
                  <th className="px-2.5 py-1.5 font-semibold">Order with waste</th>
                  <th className="px-2.5 py-1.5 font-semibold">Pieces</th>
                  <th className="px-2.5 py-1.5 font-semibold">Boxes</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-[var(--leon-line)]">
                {materials.map(m => (
                  <tr key={m.key}>
                    <td className="px-2.5 py-1.5">
                      <span className="flex items-center gap-2">
                        {m.finish && m.finish.img && <img src={m.finish.img} alt="" className="w-7 h-7 rounded object-cover border border-[var(--leon-line)]" />}
                        <span>
                          <b>{m.name}</b>
                          {m.finish && m.finish.code ? <span className="block text-[10px] text-[var(--leon-black)]/45">{m.finish.code}</span> : null}
                        </span>
                      </span>
                    </td>
                    <td className="px-2.5 py-1.5">{m.finish ? supplierDisplayName(m.finish.source, ctx.vendors) : '—'}</td>
                    <td className="px-2.5 py-1.5">{surfM2(m.areaMm2).toFixed(2)} m²</td>
                    <td className="px-2.5 py-1.5"><b>{(surfM2(m.areaMm2) * (1 + m.waste)).toFixed(2)} m²</b></td>
                    <td className="px-2.5 py-1.5">{m.orderPieces.toLocaleString()}</td>
                    <td className="px-2.5 py-1.5">
                      {m.boxes === null
                        ? <span className="text-[var(--leon-black)]/40" title="No set-out on these surfaces, so there is no box count">—</span>
                        : <b>{m.boxes.toLocaleString()}</b>}
                      {m.anyComputed && m.anyAllowance && <span className="block text-[10px] text-[var(--leon-black)]/45">part set-out, part allowance</span>}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <p className="text-xs text-[var(--leon-black)]/55 mt-2">
            <b>A line marked &ldquo;set-out&rdquo; carries a computed figure</b> — its room was simulated piece by piece,
            each cut taken from the smallest offcut big enough, and whatever was left charged as loss. A line
            marked &ldquo;allowance&rdquo; has no set-out yet and is still on the rough percentage (the company figure for
            that kind of surface, or the pattern&rsquo;s own if it is higher). Solve a set-out under
            <b> Set-Out &amp; Waste</b> and the computed figure supersedes it here automatically.
            Boxes are counted once over the whole order, not once per room. Prices are not held in the supplier
            catalog, so the buy itself still belongs in the Procurement Hub.
          </p>
        </Collapsible>
      )}
    </div>
  );
}

// ============================================================================
// Surface types & pattern presets
// ============================================================================

function SurfSetupTab({ ctx, lib, sys, canEditLib }) {
  return (
    <div className="space-y-3">
      <Collapsible title="Surface types" count={lib.surfaceTypes.length} defaultOpen id="surf-setup-types">
        <p className="text-xs text-[var(--leon-black)]/55 mb-2">
          A named default set-out — &ldquo;Bathroom wall tile&rdquo;, &ldquo;Shower floor mosaic&rdquo; — that a
          surface can be created from so the same standard is not retyped on every room type.
        </p>
        {lib.surfaceTypes.length === 0 && <EmptyState text="No surface types yet." />}
        <div className="space-y-2">
          {lib.surfaceTypes.map(st => (
            <div key={st.id} className="border border-[var(--leon-line)] rounded-lg p-3 bg-white grid gap-3 md:grid-cols-4">
              <Field label="Name"><TextInput disabled={!canEditLib} value={st.name}
                onChange={e => surfSetLib(ctx, l => { const t = l.surfaceTypes.find(x => x.id === st.id); if (t) t.name = e.target.value; })} /></Field>
              <Field label="Kind">
                <Select disabled={!canEditLib} value={st.kind}
                  onChange={e => surfSetLib(ctx, l => { const t = l.surfaceTypes.find(x => x.id === st.id); if (t) t.kind = e.target.value; })}>
                  {SURF_SURFACE_KINDS.map(k => <option key={k}>{k}</option>)}
                </Select>
              </Field>
              <Field label="Tile"><span className="text-sm">{fmtDim(st.defaultLayout.tileWmm, sys)} &times; {fmtDim(st.defaultLayout.tileHmm, sys)}</span></Field>
              <Field label="Pattern">
                <Select disabled={!canEditLib} value={st.defaultLayout.pattern}
                  onChange={e => surfSetLib(ctx, l => { const t = l.surfaceTypes.find(x => x.id === st.id); if (t) t.defaultLayout = { ...t.defaultLayout, pattern: e.target.value }; })}>
                  {SURF_PATTERNS.map(p => <option key={p.key} value={p.key}>{p.label}</option>)}
                </Select>
              </Field>
            </div>
          ))}
        </div>
        {canEditLib && <Button size="sm" variant="outline" className="mt-3"
          onClick={() => surfSetLib(ctx, l => { l.surfaceTypes = [...l.surfaceTypes, surfMakeSurfaceType({})]; })}>+ Add a surface type</Button>}
      </Collapsible>

      <Collapsible title="Pattern presets" count={lib.patterns.length} id="surf-setup-patterns">
        <p className="text-xs text-[var(--leon-black)]/55 mb-2">
          The six patterns themselves are fixed vocabulary. A preset saves a whole set-out — tile size, joint,
          origin and starting elevation — under a name a specification can refer to.
        </p>
        {lib.patterns.length === 0 && <EmptyState text="No presets saved." />}
        <div className="space-y-2">
          {lib.patterns.map(p => (
            <div key={p.id} className="border border-[var(--leon-line)] rounded-lg p-3 bg-white grid gap-3 md:grid-cols-4">
              <Field label="Name"><TextInput disabled={!canEditLib} value={p.name}
                onChange={e => surfSetLib(ctx, l => { const t = l.patterns.find(x => x.id === p.id); if (t) t.name = e.target.value; })} /></Field>
              <Field label="Pattern">
                <Select disabled={!canEditLib} value={p.base}
                  onChange={e => surfSetLib(ctx, l => { const t = l.patterns.find(x => x.id === p.id); if (t) { t.base = e.target.value; t.layout = { ...t.layout, pattern: e.target.value }; } })}>
                  {SURF_PATTERNS.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
                </Select>
              </Field>
              <Field label="Tile"><span className="text-sm">{fmtDim(p.layout.tileWmm, sys)} &times; {fmtDim(p.layout.tileHmm, sys)}</span></Field>
              <Field label="Joint"><span className="text-sm">{fmtDim(p.layout.groutMm, sys)}</span></Field>
            </div>
          ))}
        </div>
        {canEditLib && <Button size="sm" variant="outline" className="mt-3"
          onClick={() => surfSetLib(ctx, l => { l.patterns = [...l.patterns, surfMakePatternPreset({})]; })}>+ Add a preset</Button>}
      </Collapsible>

      <Collapsible title="The pattern vocabulary" id="surf-setup-vocab">
        <div className="space-y-2">
          {SURF_PATTERNS.map(p => (
            <div key={p.key} className="text-sm">
              <b>{p.label}</b>
              <span className="text-[var(--leon-black)]/60"> — {p.note}</span>
            </div>
          ))}
        </div>
      </Collapsible>
    </div>
  );
}

// ############################################################################
// THE SET-OUT SOLVER
// ----------------------------------------------------------------------------
// Everything above this line answers "what does the surface look like". This
// half answers the question the trade actually buys against: HOW MANY PIECES,
// HOW MANY BOXES, AND HOW MUCH IS WASTED.
//
// The one idea it is built on: WASTE IS COMPUTED FROM A SIMULATED LAYOUT AND
// IS NEVER ENTERED. A spreadsheet says area x 1.10. This says: this room, this
// plank, this stagger -> 247 pieces, 36 boxes, 5 surplus, 2.3 m2 offcut loss,
// 6.4% waste. The two waste percentages in SOFTWARE_SETTINGS are kept, but
// they are demoted to what they always were - the rough figure to use BEFORE a
// set-out exists. Once a surface has one, the computed figure supersedes them,
// and every screen that shows a number says which of the two it is showing.
//
// The second idea is the OFFCUT BIN. The end cut off row 3 starts row 4. That
// single fact is the whole difference between a set-out and area / plank area,
// and it is why the answer changes when the stagger changes.
//
// NOTHING HERE IS STORED. The inputs persist; every piece, every cut and every
// box count is regenerated on read - the same discipline as buildDueAlerts.
// ############################################################################

// ---- tunables -------------------------------------------------------------
// Read through softwareSetting so an admin can move them, with a local fallback
// so the module works today for keys that are not in the schema yet.
const SURF_SETOUT_FALLBACKS = {
  perPack: 8,
  minReusableMm: 300,
  minStartMm: 200,
  minEndMm: 200,
  minStaggerMm: 300,
  expansionMm: 10,
  sawKerfMm: 3,
  skirtingStockMm: 2440,
  skirtingHeightMm: 100,
  openingFrameMm: 40,
  openingWidthMm: 800,
  wallThicknessMm: 150,
  underlayRollLengthMm: 15000,
  underlayRollWidthMm: 1000,
  underlayPanelLengthMm: 1200,
  underlayPanelWidthMm: 600,
  adhesiveBagKg: 25,
  groutBagKg: 5,
  groutDensityKgPerL: 1.9,
  groutDepthMm: 8,
  maxSimPieces: 20000,
  // These four ARE in the schema already. They are listed so surfCfg answers
  // even if setActiveSoftwareSettings has not been pointed yet.
  groutMm: 3,
  wasteFloorPct: 10,
  wasteWallPct: 12,
  minCutPct: 33,
};
function surfCfg(key) {
  const v = (typeof softwareSetting === 'function') ? softwareSetting('surfaces', key) : undefined;
  if (v === undefined || v === null || v === '') return SURF_SETOUT_FALLBACKS[key];
  const n = Number(v);
  return Number.isFinite(n) && typeof SURF_SETOUT_FALLBACKS[key] === 'number' ? n : v;
}

// ---- vocabularies ---------------------------------------------------------

const SURF_PIECE_SHAPES = [
  { key: 'rect', label: 'Rectangular' },
  { key: 'square', label: 'Square' },
  { key: 'hex', label: 'Hexagon' },
];

// Every one of these changes GEOMETRY, which is the point. A pattern that only
// changed a percentage would not be worth choosing.
const SURF_SETOUT_PATTERNS = [
  { key: 'grid', label: 'Grid / straight lay', family: 'row', frac: 0, tiles: true, planks: false,
    note: 'Every joint lines through both ways.' },
  { key: 'stagger_half', label: 'Fixed 1/2 stagger', family: 'row', frac: 1 / 2, tiles: true, planks: true,
    note: 'Each row steps half a module. The classic brick bond.' },
  { key: 'stagger_third', label: 'Fixed 1/3 stagger', family: 'row', frac: 1 / 3, tiles: true, planks: true,
    note: 'Required on large format, where a half offset lippages.' },
  { key: 'stagger_quarter', label: 'Fixed 1/4 stagger', family: 'row', frac: 1 / 4, tiles: true, planks: true,
    note: 'A quarter step. Four rows before a joint repeats.' },
  { key: 'stagger_free', label: 'Free (random) stagger', family: 'row', frac: null, tiles: false, planks: true,
    note: 'Each row starts on whatever offcut the last one left, subject to the minimum stagger. This is the pattern the offcut bin was built for.' },
  { key: 'diamond', label: 'Diamond (45 degrees)', family: 'row', frac: 0, rotate: 45, tiles: true, planks: false,
    note: 'The same grid, turned 45 degrees. Every perimeter piece is a diagonal cut, so almost nothing comes back as a reusable offcut.' },
  { key: 'herringbone', label: 'Herringbone', family: 'lattice', tiles: true, planks: true,
    note: 'End-to-side. Sets out exactly when the long side is twice the short plus one joint.' },
  { key: 'double_herringbone', label: 'Double herringbone', family: 'lattice', double: true, tiles: true, planks: true,
    note: 'Two pieces per limb. Sets out exactly when the long side is twice the limb width plus one joint.' },
  { key: 'hex', label: 'Hexagon', family: 'hex', tiles: true, planks: false,
    note: 'Its own placement and its own grout formula. A cut hexagon is not a rectangle, so hexagon offcuts are not binned.' },
];
function surfSetoutPatternDef(key) {
  return SURF_SETOUT_PATTERNS.find(p => p.key === key) || SURF_SETOUT_PATTERNS[0];
}
function surfPatternsFor(material, shape) {
  if (shape === 'hex') return SURF_SETOUT_PATTERNS.filter(p => p.family === 'hex');
  return SURF_SETOUT_PATTERNS.filter(p => (material === 'plank' ? p.planks : p.tiles));
}

const SURF_OPENING_KINDS = ['door', 'window', 'passage'];
const SURF_OPENING_VARIANTS = ['single', 'double', 'sliding'];

// The four states a piece can be in, and the legend the drawing uses.
const SURF_PIECE_LEGEND = [
  { key: 'full', label: 'Full piece', fill: '#f4ede1', stroke: '#c9b899' },
  { key: 'cut_bin', label: 'Has reusable offcut', fill: '#dcecd8', stroke: '#8fb487' },
  { key: 'cut_loss', label: 'No reusable offcut', fill: '#f6d9d9', stroke: '#d09a9a' },
  { key: 'bin', label: 'Reused from offcut', fill: '#d8e4f2', stroke: '#8ba6c9' },
];
function surfPieceStyle(kind) {
  return SURF_PIECE_LEGEND.find(x => x.key === kind) || SURF_PIECE_LEGEND[0];
}

// ---- polygon geometry -----------------------------------------------------
// Plain arrays of {x,y}. Every ring is held counter-clockwise, which is what
// lets one half-plane test serve for clipping, insetting and containment.

function surfPolySigned(pts) {
  let a = 0;
  for (let i = 0; i < pts.length; i++) {
    const p = pts[i], q = pts[(i + 1) % pts.length];
    a += p.x * q.y - q.x * p.y;
  }
  return a / 2;
}
function surfPolyArea(pts) { return Math.abs(surfPolySigned(pts)); }
function surfPolyCCW(pts) { return surfPolySigned(pts) >= 0 ? pts.slice() : pts.slice().reverse(); }
function surfPolyBBox(pts) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  pts.forEach(p => {
    if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
    if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
  });
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
function surfDist(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
function surfPolyPerimeter(pts) {
  let t = 0;
  for (let i = 0; i < pts.length; i++) t += surfDist(pts[i], pts[(i + 1) % pts.length]);
  return t;
}
function surfLineIsect(p1, p2, p3, p4) {
  const d = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
  if (Math.abs(d) < 1e-9) return { x: p2.x, y: p2.y };
  const t = ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / d;
  return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) };
}
// Sutherland-Hodgman. The CLIP window must be convex; the SUBJECT may be as
// concave as it likes. That is exactly the shape of this problem - the clip is
// always one piece (a rectangle, a rotated rectangle or a hexagon) and the
// subject is the room, which is regularly an L. The known artifact of a
// concave subject is a degenerate edge running along the boundary, which does
// not affect the area and is invisible at drawing scale.
function surfClipHalf(pts, a, b) {
  if (!pts.length) return pts;
  const side = p => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
  const out = [];
  for (let i = 0; i < pts.length; i++) {
    const cur = pts[i], prv = pts[(i + pts.length - 1) % pts.length];
    const sc = side(cur), sp = side(prv);
    if (sc >= -1e-9) {
      if (sp < -1e-9) out.push(surfLineIsect(prv, cur, a, b));
      out.push(cur);
    } else if (sp >= -1e-9) {
      out.push(surfLineIsect(prv, cur, a, b));
    }
  }
  return out;
}
function surfClipToConvex(subject, clipCCW) {
  let out = subject;
  for (let i = 0; i < clipCCW.length && out.length; i++) {
    out = surfClipHalf(out, clipCCW[i], clipCCW[(i + 1) % clipCCW.length]);
  }
  return out;
}
function surfPointInPoly(pt, pts) {
  let inside = false;
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
    const a = pts[i], b = pts[j];
    if ((a.y > pt.y) !== (b.y > pt.y) &&
        pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
  }
  return inside;
}
// Offsetting every edge inward and re-intersecting the corners. Correct for the
// distances this is used with (an 8-10 mm expansion gap against a room measured
// in metres). If the result folds through itself - which it will if someone
// asks for a 300 mm gap in a 400 mm alcove - the fold is detected and the
// original returned, because a silently self-intersecting field would produce
// piece counts nobody could explain.
function surfInsetPolygon(pts, d) {
  if (!d) return pts;
  const p = surfPolyCCW(pts);
  const n = p.length;
  if (n < 3) return pts;
  const lines = [];
  for (let i = 0; i < n; i++) {
    const a = p[i], b = p[(i + 1) % n];
    const len = surfDist(a, b) || 1;
    const nx = -(b.y - a.y) / len, ny = (b.x - a.x) / len; // inward for CCW
    lines.push([{ x: a.x + nx * d, y: a.y + ny * d }, { x: b.x + nx * d, y: b.y + ny * d }]);
  }
  const out = [];
  for (let i = 0; i < n; i++) {
    const prev = lines[(i + n - 1) % n], cur = lines[i];
    out.push(surfLineIsect(prev[0], prev[1], cur[0], cur[1]));
  }
  const a0 = surfPolySigned(p), a1 = surfPolySigned(out);
  if (a1 <= 0 || Math.sign(a1) !== Math.sign(a0) || Math.abs(a1) > Math.abs(a0)) return pts;
  return out;
}

// ---- the u/v frame --------------------------------------------------------
// The direction the material runs is chosen by naming an EDGE, not an angle:
// "runs off the window wall" is what a set-out drawing says, and it survives
// the room being re-measured. Everything downstream then works in a local
// frame where the run is +u, so one row walker serves every direction.

function surfFrameFor(poly, edgeIndex, extraDeg) {
  const n = poly.length;
  const i = ((Math.round(edgeIndex || 0) % n) + n) % n;
  const a = poly[i], b = poly[(i + 1) % n];
  const ang = Math.atan2(b.y - a.y, b.x - a.x) + ((extraDeg || 0) * Math.PI / 180);
  return { ox: a.x, oy: a.y, ux: Math.cos(ang), uy: Math.sin(ang), vx: -Math.sin(ang), vy: Math.cos(ang), angle: ang };
}
function surfToUV(f, p) {
  const dx = p.x - f.ox, dy = p.y - f.oy;
  return { x: dx * f.ux + dy * f.uy, y: dx * f.vx + dy * f.vy };
}
function surfFromUV(f, p) {
  return { x: f.ox + p.x * f.ux + p.y * f.vx, y: f.oy + p.x * f.uy + p.y * f.vy };
}
function surfRingToUV(f, ring) { return ring.map(p => surfToUV(f, p)); }
function surfRingFromUV(f, ring) { return ring.map(p => surfFromUV(f, p)); }

// ---- scanline helpers -----------------------------------------------------
// Used only to bound the slot walk and to score a candidate start position
// cheaply. The AREA of every piece still comes from a real clip.
function surfScanline(rings, v) {
  const xs = [];
  rings.forEach(r => {
    for (let i = 0; i < r.length; i++) {
      const a = r[i], b = r[(i + 1) % r.length];
      if ((a.y <= v && b.y > v) || (b.y <= v && a.y > v)) {
        xs.push(a.x + (v - a.y) / (b.y - a.y) * (b.x - a.x));
      }
    }
  });
  xs.sort((p, q) => p - q);
  const out = [];
  for (let i = 0; i + 1 < xs.length; i += 2) if (xs[i + 1] - xs[i] > 0.5) out.push([xs[i], xs[i + 1]]);
  return out;
}
function surfUnionIntervals(list) {
  const s = list.slice().sort((a, b) => a[0] - b[0]);
  const out = [];
  s.forEach(iv => {
    const last = out[out.length - 1];
    if (last && iv[0] <= last[1] + 0.5) last[1] = Math.max(last[1], iv[1]);
    else out.push([iv[0], iv[1]]);
  });
  return out;
}
// The u-extent a row band actually covers. Sampled at the two band edges and
// the middle, because a splayed wall covers more at one edge than the other and
// a row is bounded by whichever is wider.
function surfBandIntervals(field, v0, v1) {
  const rings = [field.poly].concat(field.holes || []);
  const all = [];
  [v0 + 0.01, (v0 + v1) / 2, v1 - 0.01].forEach(v => { surfScanline(rings, v).forEach(iv => all.push(iv)); });
  return surfUnionIntervals(all);
}

// ---- the offcut bin -------------------------------------------------------
// Best fit is the SMALLEST offcut big enough. Largest-first would burn a long
// board on a short cut and then have nothing left for the long one, which is
// exactly the mistake a person makes on site and the reason the bin is worth
// simulating at all.
function surfMakeBin(spec) {
  const shelf = [];
  const state = { newPieces: 0, reused: 0, pushed: 0, seq: 0 };
  const minKeep = Math.max(0, spec.minReusableMm || 0);
  // An offcut a saw-kerf short of the slot it is wanted for IS usable: every cut
  // piece in a set-out is a perimeter piece, and a shortfall that small
  // disappears into the expansion gap it is already sitting against. Demanding
  // an exact fit looks rigorous and is wrong - it makes a fixed half stagger
  // reuse nothing at all, because cutting a plank in half leaves one half
  // exactly one kerf short of the other.
  const tol = Math.max(1, spec.fitToleranceMm || 0);
  return {
    state, shelf, tol,
    fresh() { state.seq += 1; state.newPieces += 1; return `b${state.seq}`; },
    best(len) {
      let bi = -1;
      for (let i = 0; i < shelf.length; i++) {
        if (shelf[i].len >= len - tol && (bi < 0 || shelf[i].len < shelf[bi].len)) bi = i;
      }
      if (bi < 0) return null;
      const found = shelf[bi];
      shelf.splice(bi, 1);
      state.reused += 1;
      return found;
    },
    put(len, src) {
      if (len < minKeep) return false;
      shelf.push({ len, src });
      state.pushed += 1;
      return true;
    },
    lengths() { return shelf.map(o => o.len).sort((a, b) => a - b); },
  };
}

// A tiny deterministic PRNG. Free stagger has to be reproducible or the layout
// stops being derived - reopening the room would give a different piece count
// and the report would stop matching the drawing.
function surfRandom(seed) {
  let s = (Math.floor(seed) || 1) >>> 0;
  return function () {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 4294967296;
  };
}
function surfCircDist(a, b, m) {
  const d = Math.abs(surfMod(a - b, m));
  return Math.min(d, m - d);
}

// ---- placing one piece ----------------------------------------------------
// A piece is a convex polygon in uv. The room is clipped against it, the holes
// are clipped against it and subtracted, and what is left is what lands on the
// floor. That single routine serves rectangles, rotated rectangles and
// hexagons, which is why there is one solver and not three.
function surfPlacePiece(field, pieceCCW, spec, bin, meta) {
  const kept = surfClipToConvex(field.poly, pieceCCW);
  if (kept.length < 3) return null;
  let area = surfPolyArea(kept);
  const holeCuts = [];
  (field.holes || []).forEach(h => {
    const hc = surfClipToConvex(h, pieceCCW);
    if (hc.length >= 3) { area -= surfPolyArea(hc); holeCuts.push(hc); }
  });
  if (area < 400) return null;                       // under 4 cm2 is not a piece
  const pieceArea = surfPolyArea(pieceCCW);
  const full = area >= pieceArea - Math.max(50, pieceArea * 0.001);

  // The length axis of THIS piece, taken from its own first edge, so a rotated
  // herringbone piece measures its cut along its own length rather than along
  // the room.
  const ax = pieceCCW[1].x - pieceCCW[0].x, ay = pieceCCW[1].y - pieceCCW[0].y;
  const alen = Math.hypot(ax, ay) || 1;
  const ux = ax / alen, uy = ay / alen;
  let lo = Infinity, hi = -Infinity;
  kept.forEach(p => { const t = p.x * ux + p.y * uy; if (t < lo) lo = t; if (t > hi) hi = t; });
  const keptLen = Math.max(0, hi - lo);
  const pieceLen = spec.pieceLenMm;
  const pieceWid = spec.pieceWidMm;

  const out = {
    id: `${meta.row}-${meta.col}`, poly: pieceCCW, kept, holeCuts,
    row: meta.row, col: meta.col, keptAreaMm2: area, pieceAreaMm2: pieceArea,
    neededLenMm: full ? pieceLen : keptLen, remainderMm: 0, kind: 'full', sourceId: null,
    straight: true, angled: false, lenAxis: { x: ux, y: uy },
  };

  if (full) { out.sourceId = bin.fresh(); return out; }

  // A cut is only a CROSS-CUT - the kind that leaves a rectangle worth keeping -
  // when what is left is the piece's full width. A diagonal or a notch leaves a
  // shape, and a shape is loss.
  const straight = Math.abs(area - keptLen * pieceWid) < Math.max(2000, pieceArea * 0.02);
  out.straight = straight;
  out.angled = !straight;
  const need = keptLen;
  const kerf = spec.sawKerfMm || 0;

  if (spec.binnable) {
    const from = bin.best(need);
    if (from) {
      out.kind = 'bin';
      out.sourceId = from.src;
      const rem = from.len - need - kerf;
      out.remainderMm = Math.max(0, rem);
      if (straight && rem > 0) bin.put(rem, from.src);
      return out;
    }
  }
  out.sourceId = bin.fresh();
  const rem = pieceLen - need - kerf;
  out.remainderMm = Math.max(0, rem);
  if (spec.binnable && straight && rem > 0 && bin.put(rem, out.sourceId)) out.kind = 'cut_bin';
  else out.kind = 'cut_loss';
  return out;
}

// ---- rows: grid, fixed stagger, free stagger, diamond ---------------------

function surfRowPhase(spec, r, prior, bin, iv, mU, rnd) {
  const pat = surfSetoutPatternDef(spec.pattern);
  const uLo = iv[0][0], uHi = iv[iv.length - 1][1];
  if (r === 0 && spec.starterLenMm > 0) {
    // A stated starter length is a decision, not a suggestion: the first row
    // begins on a piece of exactly that length and everything follows from it.
    const X = Math.min(spec.pieceLenMm, Math.max(1, spec.starterLenMm));
    return { phase: surfMod(spec.pieceLenMm - X - uLo + spec.uAnchor, mU), ok: true, from: 'starter' };
  }
  if (pat.frac !== null && pat.frac !== undefined) {
    return { phase: surfMod(r * pat.frac * mU, mU), ok: true, from: 'fixed' };
  }

  // FREE STAGGER. The first candidates are the offcuts actually on the shelf,
  // because "start the next row with the end of the last one" is the whole
  // practice and it is what makes the bin change the answer.
  const minS = spec.minStaggerMm || 0;
  const p1 = prior.length ? prior[prior.length - 1] : undefined;
  const p2 = prior.length > 1 ? prior[prior.length - 2] : undefined;
  const gap = ph => Math.min(
    p1 === undefined ? Infinity : surfCircDist(ph, p1, mU),
    p2 === undefined ? Infinity : surfCircDist(ph, p2, mU));
  const cands = [];
  bin.lengths().forEach(len => {
    if (len < spec.minStartMm) return;
    cands.push({ phase: surfMod(spec.pieceLenMm - len - uLo + spec.uAnchor, mU), from: 'offcut' });
  });
  // Then a DENSE deterministic sweep of the whole phase circle. Ten random
  // positions look like the right shape and quietly fail: the two rows above
  // each exclude an arc of 2 x minStagger, and when those arcs nearly meet, ten
  // tries will miss the gap that is left - which showed up as four rows in
  // twenty landing inside the minimum they were supposed to respect.
  const N = 48;
  const jitter = rnd() * (mU / N);
  for (let i = 0; i < N; i++) cands.push({ phase: surfMod(i * mU / N + jitter, mU), from: 'cut' });

  let best = null;
  for (let i = 0; i < cands.length; i++) {
    const c = cands[i];
    const d = gap(c.phase);
    const startLen = surfRowStartLen(spec, c.phase, uLo, mU);
    const endLen = surfRowEndLen(spec, c.phase, uHi, mU);
    const okEnds = (startLen <= 0.5 || startLen >= spec.minStartMm) && (endLen <= 0.5 || endLen >= spec.minEndMm);
    const ok = d >= minS && okEnds;
    // Among the rows that are legal, one that starts on an offcut already cut
    // beats one that opens a new board; below that, more stagger is better.
    const score = (ok ? 0 : 100000) + (okEnds ? 0 : 1000)
      + (c.from === 'offcut' ? -50 : 0) - Math.min(d, mU / 2);
    if (!best || score < best.score) best = { score, phase: c.phase, ok, from: c.from };
  }
  return best || { phase: 0, ok: false, from: 'cut' };
}
// The visible length of the last piece in a row: how far past the last full
// module boundary the field runs.
function surfRowEndLen(spec, phase, uHi, mU) {
  const t = surfMod(uHi - spec.uAnchor + phase, mU);
  return t >= spec.pieceLenMm ? spec.pieceLenMm : t;
}
function surfRowStartLen(spec, phase, uLo, mU) {
  const s = surfMod(uLo - spec.uAnchor + phase, mU);
  return s >= spec.pieceLenMm ? spec.pieceLenMm : spec.pieceLenMm - s;
}

// The cheap scorer the start-position SEARCH runs on. It only needs the first
// and last piece of each row, which is all a sliver is, so it can be run over a
// hundred candidate positions without clipping a single piece.
function surfScoreStart(field, spec, offU, offV) {
  const mU = spec.moduleUMm, mV = spec.moduleVMm;
  const bb = field.bbox;
  const uAnchor = bb.minX - offU * mU;
  const vAnchor = bb.minY - offV * mV;
  const s2 = { ...spec, uAnchor };
  let violations = 0, rows = 0, worst = Infinity;
  const pat = surfSetoutPatternDef(spec.pattern);
  const frac = pat.frac === null || pat.frac === undefined ? 0.5 : pat.frac;
  const nRows = Math.ceil((bb.maxY - vAnchor) / mV) + 1;
  for (let r = 0; r < nRows && r < 500; r++) {
    const v0 = vAnchor + r * mV, v1 = v0 + spec.pieceWidMm;
    if (v0 > bb.maxY || v1 < bb.minY) continue;
    const iv = surfBandIntervals(field, Math.max(v0, bb.minY), Math.min(v1, bb.maxY));
    if (!iv.length) continue;
    rows++;
    const phase = surfMod(r * frac * mU, mU);
    iv.forEach(seg => {
      const st = surfRowStartLen(s2, phase, seg[0], mU);
      const en = surfRowEndLen(s2, phase, seg[1], mU);
      if (st > 0.5 && st < spec.minStartMm) { violations++; worst = Math.min(worst, st); }
      if (en > 0.5 && en < spec.minEndMm) { violations++; worst = Math.min(worst, en); }
    });
  }
  return { violations, rows, worst: worst === Infinity ? null : worst, offU, offV };
}
// SLIVERS ARE PREVENTED, NOT REPORTED. The solver walks candidate start
// positions and keeps the first that lands no row on a sliver at either end.
// A warning would leave the problem for whoever reads it; this moves the grid.
function surfSearchStart(field, spec) {
  const tried = [];
  const first = surfScoreStart(field, spec, spec.gridOffsetU || 0, spec.gridOffsetV || 0);
  tried.push(first);
  if (first.violations === 0) return { ...first, searched: 0, moved: false };
  // 12 x 6. Finer than that buys start positions a tiler cannot set out anyway
  // — a module is rarely more than a metre, so a twelfth of one is under 90 mm.
  const stepsU = 12, stepsV = 6;
  let best = first;
  for (let a = 0; a < stepsU; a++) {
    for (let b = 0; b < stepsV; b++) {
      const r = surfScoreStart(field, spec, a / stepsU, b / stepsV);
      tried.push(r);
      if (r.violations === 0) return { ...r, searched: tried.length, moved: true };
      if (r.violations < best.violations) best = r;
    }
  }
  return { ...best, searched: tried.length, moved: best !== first, unsolved: true };
}

function surfRunRows(field, spec) {
  const mU = spec.moduleUMm, mV = spec.moduleVMm;
  const bb = field.bbox;
  const uAnchor = bb.minX - (spec.gridOffsetU || 0) * mU;
  const vAnchor = bb.minY - (spec.gridOffsetV || 0) * mV;
  const s = { ...spec, uAnchor };
  const bin = surfMakeBin(spec);
  const pieces = [];
  const rowPhases = [];
  const rnd = surfRandom(spec.seed || 7);
  const cap = spec.maxPieces;
  let capped = false;
  let staggerMisses = 0, offcutStarts = 0;
  const nRows = Math.ceil((bb.maxY - vAnchor) / mV) + 1;
  for (let r = 0; r < nRows && !capped; r++) {
    const v0 = vAnchor + r * mV, v1 = v0 + spec.pieceWidMm;
    if (v0 > bb.maxY + 1 || v1 < bb.minY - 1) continue;
    const iv = surfBandIntervals(field, Math.max(v0, bb.minY + 0.01), Math.min(v1, bb.maxY - 0.01));
    if (!iv.length) continue;
    const chosen = surfRowPhase(s, rowPhases.length, rowPhases, bin, iv, mU, rnd);
    const phase = chosen.phase;
    if (!chosen.ok) staggerMisses++;
    if (chosen.from === 'offcut') offcutStarts++;
    rowPhases.push(phase);
    const uLo = iv[0][0], uHi = iv[iv.length - 1][1];
    const base = uAnchor - phase;
    const kStart = Math.floor((uLo - base) / mU) - 1;
    const kEnd = Math.ceil((uHi - base) / mU) + 1;
    for (let k = kStart; k <= kEnd; k++) {
      const u0 = base + k * mU, u1 = u0 + spec.pieceLenMm;
      if (u1 < uLo - 1 || u0 > uHi + 1) continue;
      const rect = [{ x: u0, y: v0 }, { x: u1, y: v0 }, { x: u1, y: v1 }, { x: u0, y: v1 }];
      const p = surfPlacePiece(field, rect, s, bin, { row: r, col: k });
      if (p) pieces.push(p);
      if (pieces.length > cap) { capped = true; break; }
    }
  }
  return { pieces, bin, rows: rowPhases.length, rowPhases, capped, uAnchor, vAnchor,
    moduleU: mU, moduleV: mV, staggerMisses, offcutStarts };
}

// ---- lattice: herringbone and double herringbone --------------------------
// The lattice is the one this module already ships and has been verified
// against: pairs on a = (3u, u), b = (u, -u). It closes exactly when the long
// side is twice the limb width plus one joint. On any other ratio the pattern
// is drawn and counted and the drift is NAMED, which is what the tiler needs to
// know before the first piece goes down - not after the far wall.
function surfRunLattice(field, spec) {
  const pat = surfSetoutPatternDef(spec.pattern);
  const j = spec.jointMm;
  const short = spec.pieceWidMm, long = spec.pieceLenMm;
  const limbW = pat.double ? (2 * short + j) : short;
  const u = limbW + j;
  const closes = Math.abs(long - (2 * limbW + j)) < 1.5;
  const bin = surfMakeBin(spec);
  const pieces = [];
  const bb = field.bbox;
  const cx = (bb.minX + bb.maxX) / 2, cy = (bb.minY + bb.maxY) / 2;
  const th = (spec.patternAngleDeg || 0) * Math.PI / 180;
  const ca = Math.cos(th), sa = Math.sin(th);
  const rot = p => ({ x: cx + p.x * ca - p.y * sa, y: cy + p.x * sa + p.y * ca });
  const A = Math.hypot(bb.w, bb.h) / 2 + long + u;
  const cap = spec.maxPieces;
  let capped = false;
  // The lattice is a = (3u, u), b = (u, -u), so a pair sits at
  //   ox = 3u*m + u*n,  oy = u*m - u*n
  // Inverting those gives m = (ox+oy)/4u and n = (ox-3oy)/4u, which is what
  // bounds the walk to the pairs that can actually touch the field. Iterating a
  // generous box and rejecting inside the loop looked equivalent and was
  // roughly fifteen times the work on a small mosaic.
  const K = A / u;
  const mMin = -Math.ceil(K / 2) - 1, mMax = Math.ceil(K / 2) + 1;
  let idx = 0;
  for (let m = mMin; m <= mMax && !capped; m++) {
    const nLo = Math.floor(Math.max(-K - 3 * m, m - K)) - 1;
    const nHi = Math.ceil(Math.min(K - 3 * m, m + K)) + 1;
    for (let n = nLo; n <= nHi && !capped; n++) {
      const ox = 3 * u * m + u * n, oy = u * m - u * n;
      if (ox > A || ox < -A - long || oy > A || oy < -A - long) continue;
      const limbs = [];
      // The horizontal limb, then the vertical one it butts into.
      const nPer = pat.double ? 2 : 1;
      for (let q = 0; q < nPer; q++) {
        const yy = oy + q * (short + j);
        limbs.push([{ x: ox, y: yy }, { x: ox + long, y: yy }, { x: ox + long, y: yy + short }, { x: ox, y: yy + short }]);
      }
      for (let q = 0; q < nPer; q++) {
        const xx = ox + long + j + q * (short + j);
        limbs.push([{ x: xx, y: oy }, { x: xx, y: oy + long }, { x: xx + short, y: oy + long }, { x: xx + short, y: oy }]);
      }
      limbs.forEach(l => {
        if (capped) return;
        const poly = surfPolyCCW(l.map(rot));
        const p = surfPlacePiece(field, poly, spec, bin, { row: m, col: idx++ });
        if (p) pieces.push(p);
        if (pieces.length > cap) capped = true;
      });
    }
  }
  return { pieces, bin, rows: 0, rowPhases: [], capped, closes, limbW };
}

// ---- hexagons -------------------------------------------------------------
// Its own placement, because a hexagon is not a rectangle on a coarser grid.
// Pointy-top, so the across-flats dimension is the one a supplier quotes.
function surfRunHex(field, spec) {
  const fw = spec.pieceLenMm;                 // across flats
  const j = spec.jointMm;
  const s = fw / Math.sqrt(3);                // circumradius / side
  const stepX = fw + j;
  const stepY = 1.5 * s + j * (Math.sqrt(3) / 2);
  const bin = surfMakeBin(spec);
  const pieces = [];
  const bb = field.bbox;
  const cap = spec.maxPieces;
  let capped = false;
  const r0 = Math.floor((bb.minY - s) / stepY) - 1, r1 = Math.ceil((bb.maxY + s) / stepY) + 1;
  for (let r = r0; r <= r1 && !capped; r++) {
    const cy = r * stepY;
    const shift = (Math.abs(r) % 2) * stepX / 2;
    const c0 = Math.floor((bb.minX - fw - shift) / stepX) - 1, c1 = Math.ceil((bb.maxX + fw - shift) / stepX) + 1;
    for (let c = c0; c <= c1 && !capped; c++) {
      const cxp = c * stepX + shift;
      const poly = [];
      for (let k = 0; k < 6; k++) {
        const a = (Math.PI / 180) * (30 + k * 60);
        poly.push({ x: cxp + s * Math.cos(a), y: cy + s * Math.sin(a) });
      }
      const p = surfPlacePiece(field, surfPolyCCW(poly), spec, bin, { row: r, col: c });
      if (p) pieces.push(p);
      if (pieces.length > cap) capped = true;
    }
  }
  return { pieces, bin, rows: r1 - r0, rowPhases: [], capped };
}

// ---- the arithmetic -------------------------------------------------------
// In this order, and it closes: purchased = laid + offcut loss + surplus. If
// those three do not add back up to what was bought, one of them is wrong, and
// this is the check that says so.
function surfTally(run, spec) {
  const pieceAreaM2 = (spec.pieceAreaMm2 || (spec.pieceLenMm * spec.pieceWidMm)) / 1e6;
  const totalPieces = run.bin.state.newPieces;
  const perPack = Math.max(1, Math.round(spec.perPack || 1));
  const packs = Math.ceil(totalPieces / perPack);
  const surplus = packs * perPack - totalPieces;                    // PIECES, not area
  const areaPerBoxM2 = pieceAreaM2 * perPack;
  const purchasedM2 = areaPerBoxM2 * packs;
  const laidM2 = run.pieces.reduce((a, p) => a + p.keptAreaMm2, 0) / 1e6;
  const surplusM2 = surplus * pieceAreaM2;
  const offcutLossM2 = Math.max(0, totalPieces * pieceAreaM2 - laidM2);
  const totalWastePct = purchasedM2 > 0 ? ((surplusM2 + offcutLossM2) / purchasedM2) * 100 : 0;

  const counts = { full: 0, cut_bin: 0, cut_loss: 0, bin: 0 };
  run.pieces.forEach(p => { counts[p.kind] = (counts[p.kind] || 0) + 1; });
  const shelfLeft = run.bin.lengths();

  const flags = [];
  if (surplus <= 3) {
    flags.push({ level: 'warn', text: `Low margin — only ${surplus} spare piece${surplus === 1 ? '' : 's'} in the whole order. One breakage or one re-cut and the job stops for a delivery.` });
  }
  if (surplus > perPack / 2) {
    flags.push({ level: 'note', text: `Surplus ${surplus} of ${perPack} — most of a box left over. Changing the start position or the stagger is usually worth a box.` });
  }
  return {
    totalPieces, placed: run.pieces.length, perPack, packs, surplus,
    areaPerBoxM2, purchasedM2, laidM2, surplusM2, offcutLossM2, totalWastePct,
    counts, reused: run.bin.state.reused, shelfLeft,
    shelfLeftM2: shelfLeft.reduce((a, l) => a + l * spec.pieceWidMm, 0) / 1e6,
    flags,
  };
}

// ---- one call: the whole set-out ------------------------------------------
// DERIVED, NEVER STORED. Everything this returns is thrown away and rebuilt on
// the next read, which is what makes it impossible for the saved answer and the
// saved inputs to drift apart.
function surfSolveSetout(roomT, surface, opts) {
  const o = opts || {};
  const spec = surfEffectiveSpec(roomT, surface);
  const src = surfSurfaceField(roomT, surface);
  if (!src || src.poly.length < 3) return null;
  const warnings = [];

  // The expansion gap and any perimeter joint come off the field before a
  // single piece is placed, because nothing is laid in them.
  const inset = (spec.expansionMm || 0) + (spec.perimeterJointMm || 0);
  let ring = surfPolyCCW(src.poly);
  if (inset > 0) {
    const r2 = surfInsetPolygon(ring, inset);
    if (r2 === ring) warnings.push({ level: 'warn', text: `A ${fmtDim(inset, 'Imperial')} perimeter gap does not fit this shape without the field folding through itself, so it has been ignored. Reduce the gap or check the outline.` });
    ring = r2;
  }
  const holes = (src.holes || []).map(h => {
    const hh = surfPolyCCW(h);
    return inset > 0 ? surfInsetPolygon(hh, -inset) : hh;
  });

  const frame = surfFrameFor(ring, spec.directionEdge, spec.rotateDeg);
  const polyUV = surfRingToUV(frame, ring);
  const holesUV = holes.map(h => surfRingToUV(frame, h));
  const field = { poly: polyUV, holes: holesUV, bbox: surfPolyBBox(polyUV) };

  const pat = surfSetoutPatternDef(spec.pattern);
  const run2 = { ...spec };
  run2.maxPieces = surfCfg('maxSimPieces');
  run2.uAnchor = 0;

  let search = null;
  if (pat.family === 'row') {
    if (spec.autoStart) {
      search = surfSearchStart(field, run2);
      run2.gridOffsetU = search.offU;
      run2.gridOffsetV = search.offV;
    } else {
      search = surfScoreStart(field, run2, spec.gridOffsetU || 0, spec.gridOffsetV || 0);
      search.searched = 1; search.moved = false;
    }
    // The wall offset shifts the GRID off the reference edge. It is NOT an
    // inset: the field is unchanged and the set-out moves. Confusing the two is
    // how a "10 mm gap" quietly becomes a 10 mm narrower room.
    if (spec.wallOffsetMm) {
      run2.gridOffsetV = surfMod(run2.gridOffsetV * run2.moduleVMm + spec.wallOffsetMm, run2.moduleVMm) / run2.moduleVMm;
    }
  }

  const run = pat.family === 'lattice' ? surfRunLattice(field, run2)
    : pat.family === 'hex' ? surfRunHex(field, run2)
      : surfRunRows(field, run2);

  const tally = surfTally(run, run2);
  const netAreaM2 = (surfPolyArea(polyUV) - holesUV.reduce((a, h) => a + surfPolyArea(h), 0)) / 1e6;
  const grossAreaM2 = (surfPolyArea(surfPolyCCW(src.poly)) - (src.holes || []).reduce((a, h) => a + surfPolyArea(h), 0)) / 1e6;

  if (run.capped) {
    warnings.push({ level: 'bad', text: `This set-out runs past ${run2.maxPieces.toLocaleString()} pieces, which is more than a browser will simulate honestly. No piece count is given — the rough percentage allowance is what is in play until the piece size or the area comes down.` });
  }
  if (pat.family === 'lattice' && run.closes === false) {
    warnings.push({ level: 'warn', text: `${pat.label} closes only when the long side is twice the limb width plus one joint. At ${fmtDim(spec.pieceLenMm, 'Imperial')} x ${fmtDim(spec.pieceWidMm, 'Imperial')} with a ${spec.jointMm} mm joint the limb is ${fmtDim(run.limbW, 'Imperial')} and the pattern will drift — it is drawn and counted as laid, and the drift shows at the far wall.` });
  }
  if (pat.family === 'hex') {
    warnings.push({ level: 'note', text: 'A cut hexagon is not a rectangle, so hexagon offcuts are not put back in the bin. Every cut here is counted as loss, which is what actually happens on site.' });
  }
  if (search && search.violations > 0) {
    warnings.push({
      level: 'bad',
      text: search.unsolved
        ? `No start position was found that keeps every row off a sliver — the best of ${search.searched} tried still leaves ${search.violations} row end${search.violations === 1 ? '' : 's'} under the minimum, the worst at ${fmtDim(search.worst || 0, 'Imperial')}. The piece size or the minimum itself has to move.`
        : `${search.violations} row end${search.violations === 1 ? '' : 's'} land under the minimum start/end length at the position you have set. Turn the start-position search back on, or move the offset yourself.`,
    });
  } else if (search && search.moved) {
    warnings.push({ level: 'note', text: `Start position moved to ${Math.round(search.offU * 100)}% across and ${Math.round(search.offV * 100)}% up, found on try ${search.searched} of the search. Every row now starts and ends on a piece at or over the minimum.` });
  }
  if (run.staggerMisses) {
    warnings.push({
      level: 'warn',
      text: `${run.staggerMisses} row${run.staggerMisses === 1 ? '' : 's'} could not find a start that clears the ${fmtDim(spec.minStaggerMm, 'Imperial')} minimum stagger from both rows below it. On a piece this long there is not always a legal position left, and the solver takes the best one rather than pretending. Shorten the minimum, or change the piece length.`,
    });
  }
  if (run.offcutStarts) {
    warnings.push({ level: 'note', text: `${run.offcutStarts} row${run.offcutStarts === 1 ? '' : 's'} started on an offcut already cut from an earlier row — that is where the saving in this set-out comes from.` });
  }
  if (spec.binnable && tally.reused === 0 && tally.counts.cut_loss > 0 && pat.family === 'row') {
    warnings.push({ level: 'note', text: 'No offcut was reusable in this set-out — every cut piece came off a fresh board. That is usually the minimum reusable length being set longer than the cuts this room produces.' });
  }

  return {
    spec, frame, field, run, tally, warnings, search, pattern: pat,
    netAreaM2, grossAreaM2,
    perimeterMm: surfPolyPerimeter(src.poly),
    sourcePoly: src.poly, sourceHoles: src.holes || [],
    openings: src.openings || [], dividers: src.dividers || [],
    capped: run.capped,
  };
}

// ############################################################################
// THE ROOM AS A SHAPE
// ----------------------------------------------------------------------------
// A room is a POLYGON, not a width and a length. Any number of corners, any
// angle, holes cut out of it. No curves - a curved wall is not something this
// module would set out honestly, and the editor says so rather than
// approximating one with a polyline nobody could build to.
//
// AN OPENING IS A PARAMETER ON AN EDGE, never a coordinate. It carries the
// index of the edge it sits in and its position along that edge as a fraction,
// so moving the wall moves the door with it. That one decision is what makes
// "do not run skirting across the doorway" a lookup instead of a geometry
// problem someone has to redo every time the room is re-measured.
// ############################################################################

const SURF_OPENING_DEFAULTS = {
  door: { widthMm: 800, heightMm: 2100, sillMm: 0 },
  passage: { widthMm: 900, heightMm: 2100, sillMm: 0 },
  window: { widthMm: 1200, heightMm: 1200, sillMm: 900 },
};

function surfMakeOpening(o) {
  const kind = (o && o.kind) || 'door';
  const d = SURF_OPENING_DEFAULTS[kind] || SURF_OPENING_DEFAULTS.door;
  return {
    id: uid('sopen'),
    edgeIndex: (o && o.edgeIndex) || 0,
    tCenter: (o && o.tCenter !== undefined) ? o.tCenter : 0.5,
    widthMm: (o && o.widthMm) || surfCfg('openingWidthMm') || d.widthMm,
    heightMm: (o && o.heightMm) || d.heightMm,
    sillMm: (o && o.sillMm !== undefined) ? o.sillMm : d.sillMm,
    depthMm: (o && o.depthMm) || surfCfg('wallThicknessMm'),
    frameMm: (o && o.frameMm !== undefined) ? o.frameMm : surfCfg('openingFrameMm'),
    kind,
    variant: (o && o.variant) || 'single',
    // 'total' means the width entered is the whole opening; 'split' means it is
    // per leaf, so a pair of 800s is a 1600 opening. Getting that wrong halves
    // or doubles the doorway, which is why it is a stored decision.
    widthMode: (o && o.widthMode) || 'total',
    dir: (o && o.dir) || 'left',
    name: (o && o.name) || '',
  };
}
function surfOpeningTotalWidth(op) {
  const leaves = op.variant === 'double' ? 2 : 1;
  return op.widthMode === 'split' ? (op.widthMm || 0) * leaves : (op.widthMm || 0);
}
// What skirting has to stop for: the opening plus its frame both sides.
function surfOpeningBlockWidth(op) {
  return surfOpeningTotalWidth(op) + 2 * (op.frameMm || 0);
}

// A divider is a partial wall standing on an edge - a pony wall, a return, a
// column. It takes floor away and it adds skirting, which is why it is a record
// and not a note.
function surfMakeDivider(o) {
  return {
    id: uid('sdiv'),
    edgeIndex: (o && o.edgeIndex) || 0,
    tCenter: (o && o.tCenter !== undefined) ? o.tCenter : 0.5,
    widthMm: (o && o.widthMm) || 1200,     // along the edge
    spanMm: (o && o.spanMm) || 1067,       // how tall it stands
    openMm: (o && o.openMm) || 0,          // a pass-through centred in it
    depth0Mm: (o && o.depth0Mm) || 150,    // projection into the room at one end
    depth1Mm: (o && o.depth1Mm) || 150,    // and at the other
    name: (o && o.name) || 'Divider',
  };
}

function surfMakePlan(o) {
  const w = (o && o.widthMm) || 2438.4, l = (o && o.lengthMm) || 3048;
  return {
    points: (o && o.points) || [{ xMm: 0, yMm: 0 }, { xMm: w, yMm: 0 }, { xMm: w, yMm: l }, { xMm: 0, yMm: l }],
    holes: (o && o.holes) || [],
    openings: (o && o.openings) || [],
    dividers: (o && o.dividers) || [],
    trace: (o && o.trace) || null,
    fromRect: !(o && o.points),
  };
}
function surfPtsIn(points) { return (points || []).map(p => ({ x: p.xMm, y: p.yMm })); }
function surfPtsOut(pts) { return (pts || []).map(p => ({ xMm: Math.round(p.x * 10) / 10, yMm: Math.round(p.y * 10) / 10 })); }

// The plan a room type is actually using. A room that has never been traced or
// reshaped is its rectangle, built on demand - so no existing room type has to
// be migrated and none of them grew a field they do not use.
function surfPlanOf(roomT) {
  const p = roomT && roomT.plan;
  if (p && p.points && p.points.length >= 3) return p;
  return surfMakePlan({ widthMm: roomT ? roomT.widthMm : 0, lengthMm: roomT ? roomT.lengthMm : 0 });
}
const SURF_WALL_EDGE = { north: 0, east: 1, south: 2, west: 3 };
function surfEdgeLengths(plan) {
  const pts = surfPtsIn(plan.points);
  return pts.map((p, i) => surfDist(p, pts[(i + 1) % pts.length]));
}
// Which plan edge a recorded wall surface is. Only answerable while the plan is
// the original four-sided one; once someone traces an L the walls and the edges
// stop corresponding and the module says so rather than guessing.
function surfWallEdgeIndex(roomT, surface) {
  const plan = surfPlanOf(roomT);
  if ((plan.points || []).length !== 4) return null;
  const i = SURF_WALL_EDGE[surface.key];
  return i === undefined ? null : i;
}

// The divider's footprint on the floor: one rectangle, or two when it has a
// pass-through cut in it.
function surfDividerFootprints(plan, div) {
  const pts = surfPtsIn(plan.points);
  const n = pts.length;
  const i = ((div.edgeIndex % n) + n) % n;
  const a = pts[i], b = pts[(i + 1) % n];
  const len = surfDist(a, b) || 1;
  const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
  const nx = -uy, ny = ux;   // into the room for a CCW ring
  const c = (div.tCenter || 0.5) * len;
  const half = (div.widthMm || 0) / 2;
  const openHalf = Math.max(0, (div.openMm || 0) / 2);
  const spans = openHalf > 0
    ? [[c - half, c - openHalf], [c + openHalf, c + half]]
    : [[c - half, c + half]];
  return spans.filter(s => s[1] - s[0] > 1).map(s => {
    const d0 = div.depth0Mm || 0, d1 = div.depth1Mm || 0;
    const t0 = (s[0] - (c - half)) / Math.max(1, div.widthMm), t1 = (s[1] - (c - half)) / Math.max(1, div.widthMm);
    const dA = d0 + (d1 - d0) * t0, dB = d0 + (d1 - d0) * t1;
    const p0 = { x: a.x + ux * s[0], y: a.y + uy * s[0] };
    const p1 = { x: a.x + ux * s[1], y: a.y + uy * s[1] };
    return surfPolyCCW([
      p0, p1,
      { x: p1.x + nx * dB, y: p1.y + ny * dB },
      { x: p0.x + nx * dA, y: p0.y + ny * dA },
    ]);
  });
}

// The FIELD a set-out is solved on. One function for every surface: the floor
// is the plan polygon, a wall is its own rectangle, and in both cases the holes
// are the things that are genuinely not tiled.
function surfSurfaceField(roomT, surface) {
  const isFloor = surface.kind === 'Floor';
  const plan = surfPlanOf(roomT);
  if (isFloor) {
    const poly = surfPolyCCW(surfPtsIn(plan.points));
    const holes = (plan.holes || []).map(h => surfPolyCCW(surfPtsIn(h.points || h)));
    (plan.dividers || []).forEach(d => { surfDividerFootprints(plan, d).forEach(f => holes.push(f)); });
    return { poly, holes, openings: plan.openings || [], dividers: plan.dividers || [], kind: 'floor' };
  }
  const W = Math.max(1, surface.widthMm || 0), H = Math.max(1, surface.heightMm || 0);
  const poly = [{ x: 0, y: 0 }, { x: W, y: 0 }, { x: W, y: H }, { x: 0, y: H }];
  const holes = [];
  (surface.niches || []).forEach(nch => {
    const r = surfNicheRect(nch, W);
    holes.push(surfPolyCCW([{ x: r.x, y: r.y }, { x: r.x + r.w, y: r.y }, { x: r.x + r.w, y: r.y + r.h }, { x: r.x, y: r.y + r.h }]));
  });
  const ei = surfWallEdgeIndex(roomT, surface);
  const mine = [];
  if (ei !== null) {
    const lens = surfEdgeLengths(plan);
    (plan.openings || []).forEach(op => {
      if (op.edgeIndex !== ei) return;
      mine.push(op);
      const len = lens[ei] || W;
      const cx = (op.tCenter || 0.5) * len;
      const ow = surfOpeningTotalWidth(op);
      const x0 = cx - ow / 2, x1 = cx + ow / 2;
      const y0 = op.sillMm || 0, y1 = y0 + (op.heightMm || 0);
      holes.push(surfPolyCCW([{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }]));
    });
  }
  return { poly, holes, openings: mine, dividers: [], kind: 'wall', wallEdgeIndex: ei };
}

// ---- the set-out spec -----------------------------------------------------
// Deliberately SMALL. Piece size and joint are NOT stored here: they live on
// surface.layout, which is where the elevation drawing and the course schedule
// already read them from. Two records holding the same tile size is how a
// drawing and a purchase order end up disagreeing.
function surfMakeSetout(o) {
  return {
    enabled: false,
    material: 'tile',            // 'tile' | 'plank'
    shape: 'rect',               // 'rect' | 'square' | 'hex'
    thicknessMm: 10,
    pattern: 'stagger_half',
    directionEdge: 0,
    patternAngleDeg: 45,
    gridOffsetU: 0, gridOffsetV: 0, autoStart: true,
    starterLenMm: 0,
    minStaggerMm: null, minStartMm: null, minEndMm: null,
    expansionMm: null, perimeterJointMm: 0, wallOffsetMm: 0,
    perPack: null, minReusableMm: null,
    seed: 7,
    ...(o || {}),
  };
}
function surfSetoutOf(surface) {
  return surfMakeSetout(surface && surface.setout ? surface.setout : {});
}
// One flat object the solver reads. Everything that can fall back to a company
// setting does, so a blank field means "the company answer", never zero.
function surfEffectiveSpec(roomT, surface) {
  const s = surfSetoutOf(surface);
  const L = surface.layout || surfMakeLayout();
  const a = Math.max(1, L.tileWmm || 1), b = Math.max(1, L.tileHmm || 1);
  const shape = s.shape || 'rect';
  const pieceLen = shape === 'hex' ? a : Math.max(a, b);
  const pieceWid = shape === 'hex' ? a : Math.min(a, b);
  const joint = Math.max(0, L.groutMm === undefined ? surfCfg('groutMm') || 3 : L.groutMm);
  const pat = surfSetoutPatternDef(s.pattern);
  const hexArea = shape === 'hex' ? (Math.sqrt(3) / 2) * pieceLen * pieceLen : 0;
  return {
    ...s,
    pieceLenMm: pieceLen, pieceWidMm: pieceWid, jointMm: joint,
    pieceAreaMm2: shape === 'hex' ? hexArea : pieceLen * pieceWid,
    moduleUMm: pieceLen + joint, moduleVMm: pieceWid + joint,
    rotateDeg: pat.rotate || 0,
    minStaggerMm: s.minStaggerMm === null || s.minStaggerMm === undefined ? surfCfg('minStaggerMm') : s.minStaggerMm,
    minStartMm: s.minStartMm === null || s.minStartMm === undefined ? surfCfg('minStartMm') : s.minStartMm,
    minEndMm: s.minEndMm === null || s.minEndMm === undefined ? surfCfg('minEndMm') : s.minEndMm,
    expansionMm: s.expansionMm === null || s.expansionMm === undefined ? surfCfg('expansionMm') : s.expansionMm,
    perPack: s.perPack === null || s.perPack === undefined ? surfCfg('perPack') : s.perPack,
    minReusableMm: s.minReusableMm === null || s.minReusableMm === undefined ? surfCfg('minReusableMm') : s.minReusableMm,
    sawKerfMm: surfCfg('sawKerfMm'),
    fitToleranceMm: Math.max(2, s.expansionMm === null || s.expansionMm === undefined ? surfCfg('expansionMm') : s.expansionMm),
    // A hexagon offcut is not a hexagon. Nothing goes on the shelf.
    binnable: shape !== 'hex',
  };
}
// THE ROUGH ALLOWANCE, DEMOTED.
// SOFTWARE_SETTINGS.surfaces.wasteFloorPct / wasteWallPct are kept, but this is
// now the ONLY thing they are for: the figure to use on a surface that has no
// set-out yet. The moment one exists, the computed percentage supersedes this
// and every screen that shows a number says which of the two it is showing.
// The pattern's own allowance wins where it is higher, because a herringbone
// does not stop wasting more just because a company setting says 10%.
function surfAllowanceFor(kind, patternKey) {
  const floor = kind === 'Floor' || kind === 'Shower Floor';
  const company = (surfCfg(floor ? 'wasteFloorPct' : 'wasteWallPct') || 0) / 100;
  return Math.max(company, surfWasteFor(patternKey));
}

function surfHasSetout(surface) {
  return !!(surface && surface.setout && surface.setout.enabled);
}

// ############################################################################
// ACCESSORIES — laid out, never divided
// ----------------------------------------------------------------------------
// perimeter / stock length is always wrong and always LOW, because it silently
// assumes one continuous run and no offcut. Skirting restarts at every corner
// and every door, so the piece count comes from laying physical lengths into
// each allowed interval. Windows are skipped: skirting runs UNDER a window and
// stops at a door.
// ############################################################################

function surfMakeAccessories(o) {
  return {
    skirting: { enabled: true, stockMm: null, heightMm: null, thicknessMm: 15, finish: null,
      piecesOverride: null, lengthOverride: null, ...((o || {}).skirting || {}) },
    underlay: { enabled: false, mode: 'roll', lenMm: null, widMm: null, countOverride: null,
      ...((o || {}).underlay || {}) },
    adhesive: { enabled: true, notchMm: null, consumptionOverride: null, bagKg: null, bagsOverride: null,
      ...((o || {}).adhesive || {}) },
    grout: { enabled: true, depthMm: null, bagKg: null, litersOverride: null, bagsOverride: null,
      ...((o || {}).grout || {}) },
  };
}
function surfAccessoriesOf(roomT) { return surfMakeAccessories(roomT && roomT.accessories); }

// The shared linear allocator. Skirting and underlay both run through it, so a
// remainder cut off one wall genuinely starts the next one, exactly as the
// tile solver reuses the end of row 3 to start row 4.
function surfAllocateRuns(runs, stockMm, minReusableMm, kerfMm) {
  const bin = surfMakeBin({ minReusableMm });
  const items = [];
  const stock = Math.max(1, stockMm);
  runs.forEach(r => {
    let remaining = r.lenMm;
    let seq = 0;
    while (remaining > 0.5) {
      if (remaining >= stock - 0.5) {
        const id = bin.fresh();
        items.push({ ...r, seq: seq++, lenMm: stock, kind: 'full', sourceId: id });
        remaining -= stock;
        continue;
      }
      const from = bin.best(remaining);
      if (from) {
        const rem = from.len - remaining - (kerfMm || 0);
        items.push({ ...r, seq: seq++, lenMm: remaining, kind: 'bin', sourceId: from.src });
        if (rem > 0) bin.put(rem, from.src);
      } else {
        const id = bin.fresh();
        const rem = stock - remaining - (kerfMm || 0);
        const kept = rem > 0 && bin.put(rem, id);
        items.push({ ...r, seq: seq++, lenMm: remaining, kind: kept ? 'cut_bin' : 'cut_loss', sourceId: id });
      }
      remaining = 0;
    }
  });
  const installedMm = items.reduce((a, i) => a + i.lenMm, 0);
  const boughtMm = bin.state.newPieces * stock;
  return {
    items, pieces: bin.state.newPieces, reused: bin.state.reused,
    installedMm, boughtMm, lossMm: Math.max(0, boughtMm - installedMm),
    shelf: bin.lengths(), stockMm: stock,
  };
}

// Skirting. One run per uninterrupted stretch of wall: a corner ends a run and
// so does a door, because a physical length cannot turn a corner or cross a
// doorway. A window does not end anything.
function surfSkirtingRuns(roomT) {
  const plan = surfPlanOf(roomT);
  const pts = surfPtsIn(plan.points);
  const n = pts.length;
  const runs = [];
  for (let i = 0; i < n; i++) {
    const len = surfDist(pts[i], pts[(i + 1) % n]);
    const blocks = [];
    (plan.openings || []).forEach(op => {
      if (op.edgeIndex !== i) return;
      if (op.kind === 'window') return;     // skirting runs under a window
      const c = (op.tCenter || 0.5) * len;
      const w = surfOpeningBlockWidth(op);
      blocks.push([Math.max(0, c - w / 2), Math.min(len, c + w / 2)]);
    });
    blocks.sort((a, b) => a[0] - b[0]);
    let cursor = 0;
    blocks.forEach(b => {
      if (b[0] - cursor > 1) runs.push({ edge: i, lenMm: b[0] - cursor, label: `Wall ${i + 1}` });
      cursor = Math.max(cursor, b[1]);
    });
    if (len - cursor > 1) runs.push({ edge: i, lenMm: len - cursor, label: `Wall ${i + 1}` });
  }
  // A divider is skirted on both faces and both returns.
  (plan.dividers || []).forEach(d => {
    surfDividerFootprints(plan, d).forEach((f, k) => {
      for (let i = 1; i < f.length; i++) {
        const L = surfDist(f[i], f[(i + 1) % f.length]);
        if (L > 1) runs.push({ edge: -1, lenMm: L, label: `${d.name}${k ? ` (${k + 1})` : ''}` });
      }
    });
  });
  return runs;
}
function surfSkirting(roomT) {
  const acc = surfAccessoriesOf(roomT).skirting;
  const stock = acc.stockMm || surfCfg('skirtingStockMm');
  const runs = surfSkirtingRuns(roomT);
  // A skirting length short of its run leaves a visible gap at a scribed joint,
  // so the tolerance here is 1 mm, not the floor's expansion gap.
  const alloc = surfAllocateRuns(runs, stock, Math.max(150, stock * 0.1), surfCfg('sawKerfMm'));
  const derivedPieces = alloc.pieces;
  const derivedLenM = alloc.installedMm / 1000;
  const plan = surfPlanOf(roomT);
  const blocked = (plan.openings || []).filter(o => o.kind !== 'window').length;
  return {
    enabled: acc.enabled !== false,
    heightMm: acc.heightMm || surfCfg('skirtingHeightMm'),
    thicknessMm: acc.thicknessMm || 15,
    stockMm: stock, runs, alloc,
    pieces: acc.piecesOverride === null || acc.piecesOverride === undefined ? derivedPieces : acc.piecesOverride,
    piecesCustom: !(acc.piecesOverride === null || acc.piecesOverride === undefined),
    derivedPieces,
    lengthM: acc.lengthOverride === null || acc.lengthOverride === undefined ? derivedLenM : acc.lengthOverride,
    lengthCustom: !(acc.lengthOverride === null || acc.lengthOverride === undefined),
    derivedLengthM: derivedLenM,
    lossM: alloc.lossMm / 1000,
    openingsSkipped: blocked,
    windowsIgnored: (plan.openings || []).filter(o => o.kind === 'window').length,
    naive: surfPolyPerimeter(surfPtsIn(plan.points)) / stock,
  };
}

// Underlay. Bands across the room, offcuts through the same allocator, because
// the end of one band starts the next exactly the way a plank offcut does.
function surfUnderlay(roomT, floorSurface) {
  const acc = surfAccessoriesOf(roomT).underlay;
  const roll = acc.mode !== 'panel';
  const lenMm = acc.lenMm || (roll ? surfCfg('underlayRollLengthMm') : surfCfg('underlayPanelLengthMm'));
  const widMm = acc.widMm || (roll ? surfCfg('underlayRollWidthMm') : surfCfg('underlayPanelWidthMm'));
  const src = floorSurface ? surfSurfaceField(roomT, floorSurface) : null;
  if (!src) return null;
  const poly = surfPolyCCW(src.poly);
  const holes = (src.holes || []).map(surfPolyCCW);
  const frame = surfFrameFor(poly, 0, 0);
  const field = { poly: surfRingToUV(frame, poly), holes: holes.map(h => surfRingToUV(frame, h)) };
  field.bbox = surfPolyBBox(field.poly);
  const bands = Math.max(1, Math.ceil(field.bbox.h / widMm));
  const runs = [];
  for (let b = 0; b < bands; b++) {
    const v0 = field.bbox.minY + b * widMm, v1 = Math.min(field.bbox.maxY, v0 + widMm);
    const iv = surfBandIntervals(field, v0 + 0.01, Math.max(v0 + 0.02, v1 - 0.01));
    iv.forEach(seg => { if (seg[1] - seg[0] > 1) runs.push({ edge: b, lenMm: seg[1] - seg[0], label: `Band ${b + 1}` }); });
  }
  const alloc = surfAllocateRuns(runs, lenMm, Math.max(200, lenMm * 0.05), 0);
  const netM2 = (surfPolyArea(field.poly) - field.holes.reduce((a, h) => a + surfPolyArea(h), 0)) / 1e6;
  const derived = alloc.pieces;
  return {
    enabled: acc.enabled === true,
    mode: roll ? 'roll' : 'panel', lenMm, widMm, bands, runs, alloc,
    count: acc.countOverride === null || acc.countOverride === undefined ? derived : acc.countOverride,
    custom: !(acc.countOverride === null || acc.countOverride === undefined),
    derived,
    coverageM2: (derived * lenMm * widMm) / 1e6,
    netM2,
  };
}

// Adhesive. The notch is SUGGESTED from the tile's longest edge and can be
// overridden, because the substrate has a say the tile size cannot express.
const SURF_ADHESIVE_KG_PER_M2 = { 4: 2.5, 6: 3.5, 8: 4.5, 10: 5.5, 12: 6.5, 15: 8.0 };
function surfSuggestNotch(maxEdgeMm) {
  if (maxEdgeMm < 50) return 4;
  if (maxEdgeMm < 200) return 6;
  if (maxEdgeMm < 400) return 8;
  if (maxEdgeMm < 600) return 10;
  if (maxEdgeMm < 900) return 12;
  return 15;
}
function surfAdhesive(roomT, netAreaM2, maxEdgeMm) {
  const acc = surfAccessoriesOf(roomT).adhesive;
  const suggested = surfSuggestNotch(maxEdgeMm || 0);
  const notch = acc.notchMm || suggested;
  const derivedRate = SURF_ADHESIVE_KG_PER_M2[notch] || SURF_ADHESIVE_KG_PER_M2[8];
  const rate = acc.consumptionOverride === null || acc.consumptionOverride === undefined ? derivedRate : acc.consumptionOverride;
  const bagKg = acc.bagKg || surfCfg('adhesiveBagKg');
  const kg = netAreaM2 * rate;
  const derivedBags = Math.ceil(kg / Math.max(1, bagKg));
  return {
    enabled: acc.enabled !== false,
    notchMm: notch, notchCustom: !!acc.notchMm && acc.notchMm !== suggested, suggestedNotch: suggested,
    ratePerM2: rate, rateCustom: !(acc.consumptionOverride === null || acc.consumptionOverride === undefined),
    derivedRate, kg, bagKg,
    bags: acc.bagsOverride === null || acc.bagsOverride === undefined ? derivedBags : acc.bagsOverride,
    bagsCustom: !(acc.bagsOverride === null || acc.bagsOverride === undefined),
    derivedBags,
  };
}

// Grout. Two formulas, because a hexagon does not have a length and a width.
function surfGrout(roomT, netAreaM2, spec) {
  const acc = surfAccessoriesOf(roomT).grout;
  const depth = acc.depthMm || spec.thicknessMm || surfCfg('groutDepthMm');
  const j = spec.jointMm || 0;
  let liters;
  let formula;
  if (spec.shape === 'hex') {
    const f = spec.pieceLenMm;
    const frac = 1 - Math.pow(f / (f + j), 2);
    liters = netAreaM2 * frac * depth;
    formula = `area x [1 - (${Math.round(f)} / ${Math.round(f + j)})^2] x ${depth} mm`;
  } else {
    const L = spec.pieceLenMm, W = spec.pieceWidMm;
    liters = netAreaM2 * ((L + W) / (L * W)) * j * depth;
    formula = `area x (${Math.round(L)} + ${Math.round(W)}) / (${Math.round(L)} x ${Math.round(W)}) x ${j} mm x ${depth} mm`;
  }
  const density = surfCfg('groutDensityKgPerL');
  const bagKg = acc.bagKg || surfCfg('groutBagKg');
  const derivedBags = Math.ceil((liters * density) / Math.max(0.5, bagKg));
  return {
    enabled: acc.enabled !== false,
    depthMm: depth, jointMm: j, formula, density, bagKg,
    liters: acc.litersOverride === null || acc.litersOverride === undefined ? liters : acc.litersOverride,
    litersCustom: !(acc.litersOverride === null || acc.litersOverride === undefined),
    derivedLiters: liters,
    bags: acc.bagsOverride === null || acc.bagsOverride === undefined ? derivedBags : acc.bagsOverride,
    bagsCustom: !(acc.bagsOverride === null || acc.bagsOverride === undefined),
    derivedBags,
  };
}

// Everything a room needs quantified in one call, so the report, the quantities
// roll-up and the screen cannot disagree.
function surfRoomSetout(roomT) {
  const out = { surfaces: {}, anySetout: false };
  (roomT.surfaces || []).forEach(s => {
    if (!surfHasSetout(s)) return;
    const solved = surfSolveSetout(roomT, s);
    if (!solved) return;
    out.surfaces[s.id] = solved;
    out.anySetout = true;
  });
  const floor = (roomT.surfaces || []).find(s => s.kind === 'Floor');
  const floorSolved = floor ? out.surfaces[floor.id] : null;
  out.skirting = surfSkirting(roomT);
  out.underlay = surfUnderlay(roomT, floor);
  if (floorSolved) {
    out.adhesive = surfAdhesive(roomT, floorSolved.netAreaM2, Math.max(floorSolved.spec.pieceLenMm, floorSolved.spec.pieceWidMm));
    out.grout = surfGrout(roomT, floorSolved.netAreaM2, floorSolved.spec);
  }
  return out;
}

// ############################################################################
// TRACING A REAL PLAN
// ----------------------------------------------------------------------------
// office-pdf-core.jsx loads AFTER this file, so nothing here may touch it at
// load time - every call is guarded and made from an event handler, by which
// point the whole page has run.
//
// Auto-detect is only offered where there is really something to detect.
// getOperatorList() hands back the page's own path geometry on a vector PDF, so
// closed outlines can be found and offered as candidates. A flattened scan has
// no path operators at all, and the panel says so and offers manual tracing
// rather than producing an outline that looks authoritative and is wrong.
// ############################################################################

function surfPdfReady() {
  return typeof officePdfImportFile === 'function'
    && typeof officePdfRenderPage === 'function'
    && typeof officePdfDocument === 'function';
}
function surfMat(m1, m2) {
  return [
    m1[0] * m2[0] + m1[2] * m2[1], m1[1] * m2[0] + m1[3] * m2[1],
    m1[0] * m2[2] + m1[2] * m2[3], m1[1] * m2[2] + m1[3] * m2[3],
    m1[0] * m2[4] + m1[2] * m2[5] + m1[4], m1[1] * m2[4] + m1[3] * m2[5] + m1[5],
  ];
}
function surfMatApply(m, x, y) { return { x: m[0] * x + m[2] * y + m[4], y: m[1] * x + m[3] * y + m[5] }; }

// Closed vector outlines on one page, in page-fraction coordinates. Only closed
// rings with real area are kept - a page is full of leader lines and hatching,
// and offering those as a room outline would be noise dressed as help.
function surfPdfClosedPaths(assetId, pageIndex) {
  if (!surfPdfReady() || typeof officePdfLibs !== 'function') return Promise.resolve(null);
  return officePdfLibs().then(({ pdfjsLib }) => officePdfDocument(assetId).then(pdf =>
    pdf.getPage(pageIndex + 1).then(page => {
      const rotation = page.rotate || 0;
      const viewport = page.getViewport({ scale: 1, rotation });
      return page.getOperatorList().then(list => {
        const OPS = pdfjsLib.OPS;
        const fns = list.fnArray || [], args = list.argsArray || [];
        let ctm = viewport.transform.slice();
        const stack = [];
        const rings = [];
        let pathOps = 0;
        for (let i = 0; i < fns.length; i++) {
          const fn = fns[i];
          if (fn === OPS.save) { stack.push(ctm.slice()); continue; }
          if (fn === OPS.restore) { const m = stack.pop(); if (m) ctm = m; continue; }
          if (fn === OPS.transform) { const a = args[i]; if (a && a.length >= 6) ctm = surfMat(ctm, a); continue; }
          if (fn === OPS.paintFormXObjectBegin) {
            stack.push(ctm.slice());
            const a = args[i];
            if (a && a[0] && a[0].length >= 6) ctm = surfMat(ctm, a[0]);
            continue;
          }
          if (fn === OPS.paintFormXObjectEnd) { const m = stack.pop(); if (m) ctm = m; continue; }
          if (fn !== OPS.constructPath) continue;
          pathOps++;
          const a = args[i];
          if (!a || !a[0] || !a[1]) continue;
          const ops = a[0], co = a[1];
          let j = 0, cur = [], cx = 0, cy = 0, sx = 0, sy = 0;
          const flush = closed => {
            if (closed && cur.length >= 3) rings.push(cur);
            cur = [];
          };
          for (let k = 0; k < ops.length; k++) {
            const op = ops[k] | 0;
            if (op === OPS.moveTo) {
              flush(false);
              cx = co[j++]; cy = co[j++]; sx = cx; sy = cy;
              cur.push(surfMatApply(ctm, cx, cy));
            } else if (op === OPS.lineTo) {
              cx = co[j++]; cy = co[j++];
              cur.push(surfMatApply(ctm, cx, cy));
            } else if (op === OPS.curveTo) {
              j += 4; cx = co[j++]; cy = co[j++];
              cur.push(surfMatApply(ctm, cx, cy));
            } else if (op === OPS.curveTo2 || op === OPS.curveTo3) {
              j += 2; cx = co[j++]; cy = co[j++];
              cur.push(surfMatApply(ctm, cx, cy));
            } else if (op === OPS.closePath) {
              cx = sx; cy = sy;
              flush(true);
            } else if (op === OPS.rectangle) {
              const rx = co[j++], ry = co[j++], rw = co[j++], rh = co[j++];
              flush(false);
              rings.push([[rx, ry], [rx + rw, ry], [rx + rw, ry + rh], [rx, ry + rh]].map(p => surfMatApply(ctm, p[0], p[1])));
            } else { break; }
          }
          // A path left open but ending where it started is closed in every
          // sense that matters to an outline.
          if (cur.length >= 3 && Math.hypot(cur[0].x - cur[cur.length - 1].x, cur[0].y - cur[cur.length - 1].y) < 1.5) rings.push(cur);
        }
        const W = viewport.width, H = viewport.height;
        const cands = rings.map(r => {
          const pts = r.map(p => ({ x: p.x / W, y: p.y / H }));
          return { pts, area: surfPolyArea(pts) };
        }).filter(c => c.area > 0.01 && c.pts.length >= 3 && c.pts.length <= 400);
        cands.sort((a, b) => b.area - a.area);
        return { candidates: cands.slice(0, 12), pathOps, vector: pathOps > 0, width: W, height: H };
      });
    })));
}

// Douglas-Peucker. A traced outline off a vector path routinely carries a
// hundred points describing four walls; simplifying is what makes it editable.
function surfSimplify(pts, tol) {
  if (pts.length < 3) return pts;
  const d2 = (p, a, b) => {
    const dx = b.x - a.x, dy = b.y - a.y;
    const L = dx * dx + dy * dy;
    let t = L ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / L : 0;
    t = Math.max(0, Math.min(1, t));
    return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
  };
  const walk = (a, b) => {
    let best = -1, bi = -1;
    for (let i = a + 1; i < b; i++) {
      const d = d2(pts[i], pts[a], pts[b]);
      if (d > best) { best = d; bi = i; }
    }
    if (best <= tol) return [pts[a]];
    return walk(a, bi).concat(walk(bi, b));
  };
  const out = walk(0, pts.length - 1).concat([pts[pts.length - 1]]);
  return out.length >= 3 ? out : pts;
}

// ############################################################################
// THE PLAN EDITOR
// ----------------------------------------------------------------------------
// Drag a corner, or TAP A DIMENSION AND TYPE IT. Both, because a plan is
// dragged into shape and then made exact, and a tool that only offers one of
// those forces a drawing to be either sloppy or slow.
// ############################################################################

// Icons from the app's existing tool vocabulary rather than a new set: ➤ is
// Select in the PDF toolbar, and ⊖ is already Cutout in the take-off's own
// palette — the same action in two tools should not need learning twice.
const SURF_PLAN_TOOLS = [
  { key: 'select', label: 'Select & drag', icon: '➤', hint: 'Drag a corner. Drag an edge to move the whole wall.' },
  { key: 'corner', label: 'Add corner', icon: '⬦', hint: 'Click an edge to put a new corner on it.' },
  { key: 'opening', label: 'Add opening', icon: '🚪', hint: 'Click an edge to put a door, window or passage in it.' },
  { key: 'divider', label: 'Add divider', icon: '▥', hint: 'Click an edge to stand a partial wall on it.' },
  { key: 'cutout', label: 'Add cutout', icon: '⊖', hint: 'Click inside the room to drop a rectangular hole - a column, a stair, a hearth.' },
];

function surfShapePreset(kind, w, l) {
  if (kind === 'notch') {
    return [{ xMm: 0, yMm: 0 }, { xMm: w, yMm: 0 }, { xMm: w, yMm: l * 0.6 }, { xMm: w * 0.6, yMm: l * 0.6 },
      { xMm: w * 0.6, yMm: l }, { xMm: 0, yMm: l }];
  }
  if (kind === 'recess') {
    return [{ xMm: 0, yMm: 0 }, { xMm: w, yMm: 0 }, { xMm: w, yMm: l }, { xMm: w * 0.65, yMm: l },
      { xMm: w * 0.65, yMm: l * 0.75 }, { xMm: w * 0.35, yMm: l * 0.75 }, { xMm: w * 0.35, yMm: l }, { xMm: 0, yMm: l }];
  }
  if (kind === 'bump') {
    return [{ xMm: 0, yMm: 0 }, { xMm: w, yMm: 0 }, { xMm: w, yMm: l * 0.35 }, { xMm: w * 1.3, yMm: l * 0.35 },
      { xMm: w * 1.3, yMm: l * 0.7 }, { xMm: w, yMm: l * 0.7 }, { xMm: w, yMm: l }, { xMm: 0, yMm: l }];
  }
  return [{ xMm: 0, yMm: 0 }, { xMm: w, yMm: 0 }, { xMm: w, yMm: l }, { xMm: 0, yMm: l }];
}

function SurfPlanEditor({ plan, onPlan, sys, canEdit, height, roomT }) {
  const [tool, setTool] = useState('select');
  const [sel, setSel] = useState(null);          // { type:'pt'|'edge'|'opening'|'divider'|'hole', i, k }
  // A drag is held LOCALLY and committed on pointer-up. Writing through on every
  // pointermove would be a surfaceLibrary state write - and therefore a
  // localStorage save - per mouse movement, which is the same mistake the
  // Presentation editor had to be corrected for.
  const [drag, setDrag] = useState(null);
  const [live, setLive] = useState(null);        // points while a drag is in flight
  const [dimEdit, setDimEdit] = useState(null);  // edge index whose dimension is being typed
  const [dimText, setDimText] = useState('');
  const svgRef = useRef(null);

  const pts = live || surfPtsIn(plan.points);
  const bb = surfPolyBBox(pts.length ? pts : [{ x: 0, y: 0 }, { x: 1000, y: 1000 }]);
  const pad = Math.max(400, Math.max(bb.w, bb.h) * 0.14);
  const vb = { x: bb.minX - pad, y: bb.minY - pad, w: bb.w + pad * 2, h: bb.h + pad * 2 };
  const S = Math.max(vb.w, vb.h) / 100;           // one "unit" for stroke and text

  function toMm(evt) {
    const svg = svgRef.current;
    if (!svg) return { x: 0, y: 0 };
    const r = svg.getBoundingClientRect();
    const cx = (evt.clientX - r.left) / r.width, cy = (evt.clientY - r.top) / r.height;
    return { x: vb.x + cx * vb.w, y: vb.y + cy * vb.h };
  }
  function mutate(fn) {
    if (!canEdit) return;
    const next = cloneDeep(plan);
    next.fromRect = false;
    fn(next);
    onPlan(next);
  }
  function nearestEdge(p) {
    let best = null;
    for (let i = 0; i < pts.length; i++) {
      const a = pts[i], b = pts[(i + 1) % pts.length];
      const dx = b.x - a.x, dy = b.y - a.y;
      const L = dx * dx + dy * dy;
      let t = L ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / L : 0;
      t = Math.max(0, Math.min(1, t));
      const q = { x: a.x + t * dx, y: a.y + t * dy };
      const d = surfDist(p, q);
      if (!best || d < best.d) best = { i, t, d, q };
    }
    return best;
  }

  function onDown(e) {
    if (!canEdit) return;
    const p = toMm(e);
    if (tool === 'corner') {
      const ne = nearestEdge(p);
      if (ne) mutate(n => { n.points.splice(ne.i + 1, 0, { xMm: Math.round(ne.q.x), yMm: Math.round(ne.q.y) }); });
      setTool('select');
      return;
    }
    if (tool === 'opening' || tool === 'divider') {
      const ne = nearestEdge(p);
      if (ne) {
        mutate(n => {
          if (tool === 'opening') n.openings = (n.openings || []).concat([surfMakeOpening({ edgeIndex: ne.i, tCenter: ne.t })]);
          else n.dividers = (n.dividers || []).concat([surfMakeDivider({ edgeIndex: ne.i, tCenter: ne.t })]);
        });
      }
      setTool('select');
      return;
    }
    if (tool === 'cutout') {
      const s = Math.max(300, Math.min(bb.w, bb.h) * 0.2);
      mutate(n => {
        n.holes = (n.holes || []).concat([{
          id: uid('shole'), name: `Cutout ${(n.holes || []).length + 1}`,
          points: [{ xMm: p.x - s / 2, yMm: p.y - s / 2 }, { xMm: p.x + s / 2, yMm: p.y - s / 2 },
            { xMm: p.x + s / 2, yMm: p.y + s / 2 }, { xMm: p.x - s / 2, yMm: p.y + s / 2 }],
        }]);
      });
      setTool('select');
      return;
    }
    // select: grab the nearest corner if it is close, otherwise the edge
    let bestPt = null;
    pts.forEach((q, i) => { const d = surfDist(p, q); if (!bestPt || d < bestPt.d) bestPt = { i, d }; });
    if (bestPt && bestPt.d < S * 3) {
      setSel({ type: 'pt', i: bestPt.i });
      setDrag({ type: 'pt', i: bestPt.i });
      return;
    }
    const ne = nearestEdge(p);
    if (ne && ne.d < S * 2.5) {
      setSel({ type: 'edge', i: ne.i });
      setDrag({ type: 'edge', i: ne.i, from: p, a: pts[ne.i], b: pts[(ne.i + 1) % pts.length] });
    } else setSel(null);
  }
  function onMove(e) {
    if (!drag || !canEdit) return;
    const p = toMm(e);
    const base = surfPtsIn(plan.points);
    if (drag.type === 'pt') {
      const next = base.slice();
      next[drag.i] = { x: Math.round(p.x), y: Math.round(p.y) };
      setLive(next);
    } else if (drag.type === 'edge') {
      // An edge moves along its own normal only - dragging a wall sideways
      // along itself would just slide the corners and change nothing.
      const a = drag.a, b = drag.b;
      const len = surfDist(a, b) || 1;
      const nx = -(b.y - a.y) / len, ny = (b.x - a.x) / len;
      const d = (p.x - drag.from.x) * nx + (p.y - drag.from.y) * ny;
      const next = base.slice();
      next[drag.i] = { x: Math.round(a.x + nx * d), y: Math.round(a.y + ny * d) };
      next[(drag.i + 1) % next.length] = { x: Math.round(b.x + nx * d), y: Math.round(b.y + ny * d) };
      setLive(next);
    }
  }
  function onUp() {
    if (drag && live) { const committed = live; mutate(n => { n.points = surfPtsOut(committed); }); }
    setDrag(null);
    setLive(null);
  }

  // Typing a dimension: the edge is set to that length by moving its SECOND
  // corner along the edge's own direction, so the wall you did not touch stays
  // where it is. Anything else would move the room every time a wall is stated.
  function commitDim() {
    const i = dimEdit;
    if (i === null || i === undefined) { setDimEdit(null); return; }
    const v = parseDim(dimText, sys);
    if (!v || v < 1) { setDimEdit(null); return; }
    const a = pts[i], b = pts[(i + 1) % pts.length];
    const len = surfDist(a, b) || 1;
    const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
    mutate(n => {
      const j = (i + 1) % n.points.length;
      n.points[j] = { xMm: Math.round(a.x + ux * v), yMm: Math.round(a.y + uy * v) };
    });
    setDimEdit(null);
  }

  const edges = pts.map((p, i) => {
    const q = pts[(i + 1) % pts.length];
    return { i, a: p, b: q, mid: { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }, len: surfDist(p, q) };
  });

  return (
    <div className="space-y-2">
      {canEdit && (
        <div className="flex items-center gap-1.5 flex-wrap no-print">
          {SURF_PLAN_TOOLS.map(t => (
            <button key={t.key} onClick={() => setTool(t.key)} title={t.hint}
              className={`text-[11px] px-2 py-1 rounded border ${tool === t.key ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]' : 'bg-white border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
              {t.icon && <span aria-hidden="true" className="mr-1 opacity-80">{t.icon}</span>}
              {t.label}
            </button>
          ))}
          <span className="w-px h-4 bg-[var(--leon-line)] mx-1" />
          {['notch', 'recess', 'bump'].map(k => (
            <button key={k} title={`Replace the outline with a standard ${k}`}
              onClick={() => mutate(n => { n.points = surfShapePreset(k, roomT.widthMm, roomT.lengthMm); })}
              className="text-[11px] px-2 py-1 rounded border bg-white border-[var(--leon-line)] hover:border-[var(--leon-brown-light)] capitalize">{k}</button>
          ))}
          <button onClick={() => mutate(n => { n.points = surfShapePreset('rect', roomT.widthMm, roomT.lengthMm); n.fromRect = true; })}
            className="text-[11px] px-2 py-1 rounded border bg-white border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]">Back to the rectangle</button>
        </div>
      )}
      <div className="border border-[var(--leon-line)] rounded-lg bg-white overflow-hidden relative">
        <svg ref={svgRef} viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`} style={{ width: '100%', height: height || 380, touchAction: 'none' }}
          onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={onUp}>
          <polygon points={pts.map(p => `${p.x},${p.y}`).join(' ')} fill="var(--leon-cream)" fillOpacity="0.55"
            stroke="var(--leon-black)" strokeWidth={S * 0.16} />
          {(plan.holes || []).map((h, k) => (
            <polygon key={h.id || k} points={surfPtsIn(h.points).map(p => `${p.x},${p.y}`).join(' ')}
              fill="#fff" stroke="#b06a6a" strokeWidth={S * 0.12} strokeDasharray={`${S * 0.5} ${S * 0.35}`} />
          ))}
          {(plan.dividers || []).map(d => surfDividerFootprints(plan, d).map((f, k) => (
            <polygon key={`${d.id}-${k}`} points={f.map(p => `${p.x},${p.y}`).join(' ')}
              fill="#e3d8c6" stroke="var(--leon-brown)" strokeWidth={S * 0.12} />
          )))}
          {edges.map(e => {
            const len = e.len || 1;
            const nx = -(e.b.y - e.a.y) / len, ny = (e.b.x - e.a.x) / len;
            return (
              <g key={`e${e.i}`}>
                <line x1={e.a.x} y1={e.a.y} x2={e.b.x} y2={e.b.y}
                  stroke={sel && sel.type === 'edge' && sel.i === e.i ? 'var(--leon-brown)' : 'transparent'} strokeWidth={S * 0.5} />
                <text x={e.mid.x - nx * S * 1.6} y={e.mid.y - ny * S * 1.6} fontSize={S * 1.5}
                  textAnchor="middle" dominantBaseline="middle" fill="var(--leon-black)"
                  style={{ cursor: canEdit ? 'text' : 'default' }}
                  onPointerDown={ev => { if (!canEdit) return; ev.stopPropagation(); setDimEdit(e.i); setDimText(fmtDim(e.len, sys, { bare: sys === 'Metric' })); }}>
                  {fmtDim(e.len, sys)}
                </text>
              </g>
            );
          })}
          {(plan.openings || []).map(op => {
            const n = pts.length;
            const i = ((op.edgeIndex % n) + n) % n;
            const a = pts[i], b = pts[(i + 1) % n];
            const len = surfDist(a, b) || 1;
            const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
            const c = (op.tCenter || 0.5) * len;
            const w = surfOpeningTotalWidth(op) / 2;
            const p0 = { x: a.x + ux * (c - w), y: a.y + uy * (c - w) };
            const p1 = { x: a.x + ux * (c + w), y: a.y + uy * (c + w) };
            const col = op.kind === 'window' ? '#4d7fb3' : op.kind === 'passage' ? '#8a7a55' : '#8f5b2f';
            return (
              <g key={op.id} onPointerDown={ev => { ev.stopPropagation(); setSel({ type: 'opening', id: op.id }); }} style={{ cursor: 'pointer' }}>
                <line x1={p0.x} y1={p0.y} x2={p1.x} y2={p1.y} stroke="#fff" strokeWidth={S * 0.5} />
                <line x1={p0.x} y1={p0.y} x2={p1.x} y2={p1.y} stroke={col}
                  strokeWidth={sel && sel.id === op.id ? S * 0.4 : S * 0.28}
                  strokeDasharray={op.kind === 'window' ? `${S * 0.6} ${S * 0.4}` : undefined} />
              </g>
            );
          })}
          {pts.map((p, i) => (
            <circle key={`p${i}`} cx={p.x} cy={p.y} r={S * (sel && sel.type === 'pt' && sel.i === i ? 1.1 : 0.8)}
              fill={sel && sel.type === 'pt' && sel.i === i ? 'var(--leon-brown)' : '#fff'}
              stroke="var(--leon-brown)" strokeWidth={S * 0.16} style={{ cursor: canEdit ? 'move' : 'default' }} />
          ))}
        </svg>
        {dimEdit !== null && dimEdit !== undefined && (
          <div className="absolute inset-x-0 bottom-0 bg-white border-t border-[var(--leon-line)] p-2 flex items-center gap-2 no-print">
            <span className="text-xs">Wall {dimEdit + 1} length</span>
            <TextInput autoFocus className="!w-32 !py-1 !text-xs" value={dimText}
              onChange={e => setDimText(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') commitDim(); if (e.key === 'Escape') setDimEdit(null); }} />
            <Button size="sm" onClick={commitDim}>Set</Button>
            <Button size="sm" variant="ghost" onClick={() => setDimEdit(null)}>Cancel</Button>
          </div>
        )}
      </div>

      {sel && sel.type === 'pt' && canEdit && (
        <div className="flex items-end gap-2 flex-wrap border border-[var(--leon-line)] rounded-lg bg-white p-2">
          <Field label="Corner X"><SurfDimInput sys={sys} valueMm={pts[sel.i].x} onChange={v => mutate(n => { n.points[sel.i].xMm = v; })} /></Field>
          <Field label="Corner Y"><SurfDimInput sys={sys} valueMm={pts[sel.i].y} onChange={v => mutate(n => { n.points[sel.i].yMm = v; })} /></Field>
          <Button size="sm" variant="ghost" disabled={pts.length <= 3}
            onClick={() => { mutate(n => { n.points.splice(sel.i, 1); }); setSel(null); }}>Remove this corner</Button>
        </div>
      )}
      {sel && sel.type === 'opening' && canEdit && (() => {
        const op = (plan.openings || []).find(o => o.id === sel.id);
        if (!op) return null;
        const set = (f, v) => mutate(n => { const t = (n.openings || []).find(o => o.id === op.id); if (t) t[f] = v; });
        return (
          <div className="border border-[var(--leon-line)] rounded-lg bg-white p-2 grid gap-2 md:grid-cols-4">
            <Field label="Kind">
              <Select value={op.kind} onChange={e => set('kind', e.target.value)}>
                {SURF_OPENING_KINDS.map(k => <option key={k} value={k}>{k}</option>)}
              </Select>
            </Field>
            <Field label="Type">
              <Select value={op.variant} onChange={e => set('variant', e.target.value)}>
                {SURF_OPENING_VARIANTS.map(k => <option key={k} value={k}>{k}</option>)}
              </Select>
            </Field>
            <Field label="Width" hint={op.widthMode === 'split' ? 'per leaf' : 'whole opening'}>
              <SurfDimInput sys={sys} valueMm={op.widthMm} onChange={v => set('widthMm', v)} />
            </Field>
            <Field label="Width is">
              <Select value={op.widthMode} onChange={e => set('widthMode', e.target.value)}>
                <option value="total">the whole opening</option>
                <option value="split">per leaf</option>
              </Select>
            </Field>
            <Field label="Head height"><SurfDimInput sys={sys} valueMm={op.heightMm} onChange={v => set('heightMm', v)} /></Field>
            <Field label="Sill"><SurfDimInput sys={sys} valueMm={op.sillMm} onChange={v => set('sillMm', v)} /></Field>
            <Field label="Frame each side"><SurfDimInput sys={sys} valueMm={op.frameMm} onChange={v => set('frameMm', v)} /></Field>
            <Field label="Wall depth"><SurfDimInput sys={sys} valueMm={op.depthMm} onChange={v => set('depthMm', v)} /></Field>
            <Field label="Position along wall" hint="0 = start of the wall, 1 = its end">
              <TextInput type="number" step="0.01" min="0" max="1" value={op.tCenter}
                onChange={e => set('tCenter', Math.max(0, Math.min(1, Number(e.target.value) || 0)))} />
            </Field>
            <Field label="Opens">
              <Select value={op.dir} onChange={e => set('dir', e.target.value)}>
                <option value="left">left</option><option value="right">right</option>
              </Select>
            </Field>
            <div className="flex items-end">
              <Button size="sm" variant="ghost" onClick={() => { mutate(n => { n.openings = (n.openings || []).filter(o => o.id !== op.id); }); setSel(null); }}>Remove</Button>
            </div>
            <p className="md:col-span-4 text-[11px] text-[var(--leon-black)]/50">
              Skirting stops at a door and at a passage and runs straight under a window. That is decided by
              this <b>kind</b> field and nothing else.
            </p>
          </div>
        );
      })()}
      {(plan.dividers || []).length > 0 && canEdit && (
        <div className="space-y-2">
          {(plan.dividers || []).map(d => {
            const set = (f, v) => mutate(n => { const t = (n.dividers || []).find(x => x.id === d.id); if (t) t[f] = v; });
            return (
              <div key={d.id} className="border border-[var(--leon-line)] rounded-lg bg-white p-2 grid gap-2 md:grid-cols-6">
                <Field label="Name"><TextInput value={d.name} onChange={e => set('name', e.target.value)} /></Field>
                <Field label="Along the wall"><SurfDimInput sys={sys} valueMm={d.widthMm} onChange={v => set('widthMm', v)} /></Field>
                <Field label="Pass-through"><SurfDimInput sys={sys} valueMm={d.openMm} onChange={v => set('openMm', v)} /></Field>
                <Field label="Depth at start"><SurfDimInput sys={sys} valueMm={d.depth0Mm} onChange={v => set('depth0Mm', v)} /></Field>
                <Field label="Depth at end"><SurfDimInput sys={sys} valueMm={d.depth1Mm} onChange={v => set('depth1Mm', v)} /></Field>
                <div className="flex items-end"><Button size="sm" variant="ghost"
                  onClick={() => mutate(n => { n.dividers = (n.dividers || []).filter(x => x.id !== d.id); })}>Remove</Button></div>
              </div>
            );
          })}
        </div>
      )}
      {(plan.holes || []).length > 0 && canEdit && (
        <div className="text-[11px] text-[var(--leon-black)]/60 flex items-center gap-2 flex-wrap">
          <span>{(plan.holes || []).length} cutout{(plan.holes || []).length === 1 ? '' : 's'} in the floor:</span>
          {(plan.holes || []).map(h => (
            <button key={h.id} onClick={() => mutate(n => { n.holes = (n.holes || []).filter(x => x.id !== h.id); })}
              className="underline">remove {h.name}</button>
          ))}
        </div>
      )}
      <p className="text-[11px] text-[var(--leon-black)]/45">
        No curves. A curved wall is not something this module can set out honestly, so it is not offered —
        approximating one with a polyline would put a dimension on a drawing that nobody could build to.
      </p>
    </div>
  );
}

// ---- tracing panel --------------------------------------------------------

function SurfTracePanel({ plan, onPlan, sys, canEdit }) {
  const [state, setState] = useState({ phase: 'idle', msg: '' });
  const [page, setPage] = useState(0);
  const [pageCount, setPageCount] = useState(1);
  const [img, setImg] = useState(null);           // { dataUrl, w, h }
  const [asset, setAsset] = useState(null);
  const [calib, setCalib] = useState({ a: null, b: null, mm: '' });
  const [mmPerPx, setMmPerPx] = useState(null);
  const [trace, setTrace] = useState([]);
  const [cands, setCands] = useState(null);
  const [opacity, setOpacity] = useState(0.45);
  const boxRef = useRef(null);

  function load(file) {
    if (!file) return;
    if (!surfPdfReady()) { setState({ phase: 'error', msg: 'The PDF engine has not loaded on this page.' }); return; }
    setState({ phase: 'busy', msg: 'Reading the drawing…' });
    officePdfImportFile(file).then(info => {
      setAsset(info.assetId);
      setPageCount(info.pageCount);
      setPage(0);
      return render(info.assetId, 0);
    }).catch(e => setState({ phase: 'error', msg: String((e && e.message) || e) }));
  }
  function render(assetId, idx) {
    setState({ phase: 'busy', msg: `Rendering page ${idx + 1}…` });
    return officePdfRenderPage(assetId, idx, { width: 1400, dpr: 1 }).then(r => {
      setImg({ dataUrl: r.canvas.toDataURL('image/jpeg', 0.82), w: r.width, h: r.height });
      setState({ phase: 'ready', msg: '' });
      setCalib({ a: null, b: null, mm: '' });
      setMmPerPx(null);
      setTrace([]);
      setCands(null);
      return surfPdfClosedPaths(assetId, idx).then(res => setCands(res)).catch(() => setCands(null));
    });
  }
  function pick(e) {
    const box = boxRef.current;
    if (!box || !img) return null;
    const r = box.getBoundingClientRect();
    return { x: (e.clientX - r.left) / r.width * img.w, y: (e.clientY - r.top) / r.height * img.h };
  }
  function onClick(e) {
    const p = pick(e);
    if (!p) return;
    if (!mmPerPx) {
      if (!calib.a) setCalib({ ...calib, a: p });
      else if (!calib.b) setCalib({ ...calib, b: p });
      return;
    }
    setTrace(t => t.concat([p]));
  }
  function applyCalib() {
    const v = parseDim(calib.mm, sys);
    if (!calib.a || !calib.b || !v) return;
    const px = Math.hypot(calib.b.x - calib.a.x, calib.b.y - calib.a.y);
    if (px < 2) return;
    setMmPerPx(v / px);
  }
  function commit(pointsPx) {
    if (!mmPerPx || pointsPx.length < 3) return;
    const mm = surfPtsOut(pointsPx.map(p => ({ x: p.x * mmPerPx, y: p.y * mmPerPx })));
    const next = cloneDeep(plan);
    next.points = mm;
    next.fromRect = false;
    next.trace = asset ? { assetId: asset, pageIndex: page, mmPerPx, capturedAt: todayISO() } : null;
    onPlan(next);
    setState({ phase: 'done', msg: `Outline traced — ${mm.length} corners.` });
  }

  return (
    <div className="space-y-3">
      <p className="text-xs text-[var(--leon-black)]/60">
        Put the architect&rsquo;s plan underneath, tell it one dimension it already knows, then click the outline.
        Nothing is scaled from the paper size — <b>a PDF printed to fit is the normal case</b>, and it is exactly how a
        traced room ends up 4% wrong.
      </p>
      {canEdit && (
        <div className="flex items-center gap-2 flex-wrap">
          <label className="text-xs">
            <input type="file" accept="application/pdf,.pdf" className="text-xs"
              onChange={e => load(e.target.files && e.target.files[0])} />
          </label>
          {pageCount > 1 && asset && (
            <Select className="!w-auto !py-1 !text-xs" value={page}
              onChange={e => { const i = Number(e.target.value); setPage(i); render(asset, i); }}>
              {Array.from({ length: pageCount }).map((x, i) => <option key={i} value={i}>Page {i + 1}</option>)}
            </Select>
          )}
          {img && (
            <label className="text-[11px] flex items-center gap-1">
              Underlay
              <input type="range" min="0.1" max="1" step="0.05" value={opacity} onChange={e => setOpacity(Number(e.target.value))} />
            </label>
          )}
        </div>
      )}
      {state.phase === 'busy' && <p className="text-xs text-[var(--leon-brown)]">{state.msg}</p>}
      {state.phase === 'error' && <SurfWarnings list={[{ level: 'bad', text: state.msg }]} />}
      {state.phase === 'done' && <SurfWarnings list={[{ level: 'note', text: state.msg }]} />}

      {img && (
        <>
          <div className="text-xs border border-[var(--leon-line)] rounded-lg bg-white p-2 space-y-1">
            {!mmPerPx ? (
              <div className="flex items-center gap-2 flex-wrap">
                <b>Step 1 — scale.</b>
                <span className="text-[var(--leon-black)]/60">
                  Click the two ends of something you know the length of {calib.a ? (calib.b ? '(both picked)' : '(one picked)') : ''}
                </span>
                <TextInput className="!w-32 !py-1 !text-xs" placeholder={sys === 'Metric' ? 'e.g. 3600' : `e.g. 12'-0"`}
                  value={calib.mm} onChange={e => setCalib({ ...calib, mm: e.target.value })} />
                <Button size="sm" disabled={!calib.a || !calib.b || !calib.mm} onClick={applyCalib}>Set the scale</Button>
                <Button size="sm" variant="ghost" onClick={() => setCalib({ a: null, b: null, mm: '' })}>Restart</Button>
              </div>
            ) : (
              <div className="flex items-center gap-2 flex-wrap">
                <b>Step 2 — the outline.</b>
                <span className="text-[var(--leon-black)]/60">
                  Click each corner in order. {trace.length} picked. Scale: 1 px = {(mmPerPx).toFixed(2)} mm.
                </span>
                <Button size="sm" disabled={trace.length < 3} onClick={() => commit(trace)}>Use this outline</Button>
                <Button size="sm" variant="ghost" onClick={() => setTrace([])}>Clear</Button>
                <Button size="sm" variant="ghost" onClick={() => setMmPerPx(null)}>Re-scale</Button>
              </div>
            )}
          </div>
          <div ref={boxRef} className="relative border border-[var(--leon-line)] rounded-lg overflow-auto bg-white"
            style={{ maxHeight: 460 }} onClick={onClick}>
            <img src={img.dataUrl} alt="" style={{ width: '100%', opacity, display: 'block' }} />
            <svg viewBox={`0 0 ${img.w} ${img.h}`} className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}>
              {calib.a && <circle cx={calib.a.x} cy={calib.a.y} r={img.w / 160} fill="#c0392b" />}
              {calib.b && <circle cx={calib.b.x} cy={calib.b.y} r={img.w / 160} fill="#c0392b" />}
              {calib.a && calib.b && <line x1={calib.a.x} y1={calib.a.y} x2={calib.b.x} y2={calib.b.y} stroke="#c0392b" strokeWidth={img.w / 400} />}
              {trace.length > 1 && (
                <polyline points={trace.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke="var(--leon-brown)" strokeWidth={img.w / 350} />
              )}
              {trace.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={img.w / 200} fill="var(--leon-brown)" />)}
            </svg>
          </div>

          <div className="border border-[var(--leon-line)] rounded-lg bg-white p-2 text-xs">
            {cands === null && <span className="text-[var(--leon-black)]/50">Looking for vector outlines on this page…</span>}
            {cands && !cands.vector && (
              <span>
                <b>No auto-detect on this page.</b> It carries no path geometry at all — it is a flattened scan or an
                image. Trace it by hand above; guessing an outline off a picture would produce something that looks
                authoritative and is wrong.
              </span>
            )}
            {cands && cands.vector && cands.candidates.length === 0 && (
              <span><b>Vector page, no closed outline found.</b> {cands.pathOps} paths were read but none of them closes on itself with enough area to be a room. Trace it by hand.</span>
            )}
            {cands && cands.vector && cands.candidates.length > 0 && (
              <div className="space-y-1">
                <b>{cands.candidates.length} closed outline{cands.candidates.length === 1 ? '' : 's'} found in the page&rsquo;s own linework</b>
                {!mmPerPx && <span className="text-[var(--leon-black)]/55"> — set the scale first and they become usable.</span>}
                <div className="flex gap-1.5 flex-wrap pt-1">
                  {cands.candidates.map((c, i) => (
                    <Button key={i} size="sm" variant="outline" disabled={!mmPerPx || !canEdit}
                      onClick={() => {
                        const px = c.pts.map(p => ({ x: p.x * img.w, y: p.y * img.h }));
                        const simp = surfSimplify(px, Math.max(2, img.w / 400));
                        setTrace(simp);
                      }}>
                      Outline {i + 1} · {(c.area * 100).toFixed(1)}% of the sheet
                    </Button>
                  ))}
                </div>
                <p className="text-[11px] text-[var(--leon-black)]/50">
                  Picking one loads it as the trace so it can be checked and edited before it becomes the room.
                  Nothing is written to the room type until <b>Use this outline</b>.
                </p>
              </div>
            )}
          </div>
        </>
      )}
    </div>
  );
}

// ############################################################################
// THE SET-OUT, DRAWN
// ----------------------------------------------------------------------------
// Every piece here came out of the solver a moment ago and will be thrown away
// when this component unmounts. Nothing on screen is read from storage, which
// is why the drawing and the box count can never disagree.
// ############################################################################

const SURF_SETOUT_DRAW_CAP = 3500;

function surfDisp(solved, flip, uv) {
  const p = surfFromUV(solved.frame, uv);
  if (!flip) return p;
  return { x: p.x, y: solved.flipAxis - p.y };
}
function surfPathOf(pts) {
  if (!pts || !pts.length) return '';
  return `M ${pts.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ')} Z`;
}

function SurfSetoutDrawing({ solved, sys, flip, height, selectedId, onSelect, showCentrelines, showLegend, showOpenings }) {
  if (!solved) return null;
  const src = solved.sourcePoly;
  const bbAll = surfPolyBBox(src);
  const flipAxis = bbAll.minY + bbAll.maxY;
  const s2 = { ...solved, flipAxis };
  const D = uv => surfDisp(s2, flip, uv);
  const R = p => (flip ? { x: p.x, y: flipAxis - p.y } : p);
  const pad = Math.max(200, Math.max(bbAll.w, bbAll.h) * 0.06);
  const vb = { x: bbAll.minX - pad, y: bbAll.minY - pad, w: bbAll.w + pad * 2, h: bbAll.h + pad * 2 };
  const S = Math.max(vb.w, vb.h) / 100;
  const pieces = solved.run.pieces;
  const shown = pieces.slice(0, SURF_SETOUT_DRAW_CAP);

  // Centrelines: the row guides the set-out actually runs on, plus the two
  // offset dimensions from the reference edge. Those two numbers are what a
  // tiler transfers to the floor with a chalk line - without them a drawing is
  // a picture, not an instruction.
  const guides = [];
  const run = solved.run;
  if (showCentrelines && run.moduleV) {
    const bbUV = solved.field.bbox;
    const nR = Math.ceil((bbUV.maxY - run.vAnchor) / run.moduleV) + 1;
    for (let r = 0; r <= nR && r < 400; r++) {
      const v = run.vAnchor + r * run.moduleV;
      if (v < bbUV.minY - run.moduleV || v > bbUV.maxY + run.moduleV) continue;
      guides.push([D({ x: bbUV.minX - S * 2, y: v }), D({ x: bbUV.maxX + S * 2, y: v })]);
    }
  }

  return (
    <div>
      <svg viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`} style={{ width: '100%', height: height || 460 }}
        role="img" aria-label="Set-out layout">
        <path d={surfPathOf(src.map(R))} fill="#fff" stroke="none" />
        {shown.map((p, i) => {
          const st = surfPieceStyle(p.kind);
          const outer = p.kept.map(D);
          const holes = (p.holeCuts || []).map(h => h.map(D));
          const d = surfPathOf(outer) + holes.map(h => ' ' + surfPathOf(h)).join('');
          const on = selectedId === p.id;
          return (
            <path key={`${p.id}-${i}`} d={d} fillRule="evenodd"
              fill={on ? 'var(--leon-brown-light)' : st.fill} stroke={on ? 'var(--leon-brown)' : st.stroke}
              strokeWidth={on ? S * 0.2 : S * 0.06}
              style={{ cursor: onSelect ? 'pointer' : 'default' }}
              onClick={onSelect ? () => onSelect(p) : undefined} />
          );
        })}
        {guides.map((g, i) => (
          <line key={`g${i}`} x1={g[0].x} y1={g[0].y} x2={g[1].x} y2={g[1].y}
            stroke="var(--leon-brown)" strokeOpacity="0.35" strokeWidth={S * 0.05} strokeDasharray={`${S * 0.8} ${S * 0.6}`} />
        ))}
        {showCentrelines && run.uAnchor !== undefined && (() => {
          const bbUV = solved.field.bbox;
          const a = D({ x: bbUV.minX, y: bbUV.minY });
          const b = D({ x: run.uAnchor, y: bbUV.minY });
          const c = D({ x: bbUV.minX, y: run.vAnchor });
          return (
            <g>
              <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#c0392b" strokeWidth={S * 0.12} />
              <line x1={a.x} y1={a.y} x2={c.x} y2={c.y} stroke="#c0392b" strokeWidth={S * 0.12} />
              <text x={(a.x + b.x) / 2} y={(a.y + b.y) / 2 - S} fontSize={S * 1.6} fill="#c0392b" textAnchor="middle">
                {fmtDim(Math.abs(run.uAnchor - bbUV.minX), sys)} along
              </text>
              <text x={(a.x + c.x) / 2 - S} y={(a.y + c.y) / 2} fontSize={S * 1.6} fill="#c0392b" textAnchor="end">
                {fmtDim(Math.abs(run.vAnchor - bbUV.minY), sys)} off
              </text>
            </g>
          );
        })()}
        <path d={surfPathOf(src.map(R))} fill="none" stroke="var(--leon-black)" strokeWidth={S * 0.16} />
        {(solved.sourceHoles || []).map((h, i) => (
          <path key={`h${i}`} d={surfPathOf(h.map(R))} fill="#fff" stroke="#b06a6a" strokeWidth={S * 0.12} />
        ))}
        {showOpenings && (solved.openings || []).map(op => {
          const n = src.length;
          const i = ((op.edgeIndex % n) + n) % n;
          const a = src[i], b = src[(i + 1) % n];
          const len = surfDist(a, b) || 1;
          const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
          const c = (op.tCenter || 0.5) * len, w = surfOpeningTotalWidth(op) / 2;
          const p0 = R({ x: a.x + ux * (c - w), y: a.y + uy * (c - w) });
          const p1 = R({ x: a.x + ux * (c + w), y: a.y + uy * (c + w) });
          return <line key={op.id} x1={p0.x} y1={p0.y} x2={p1.x} y2={p1.y} stroke="#fff" strokeWidth={S * 0.36} />;
        })}
      </svg>
      {pieces.length > SURF_SETOUT_DRAW_CAP && (
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-1">
          The counts are complete. The drawing shows the first {SURF_SETOUT_DRAW_CAP.toLocaleString()} of {pieces.length.toLocaleString()} pieces —
          a browser will not hold more rectangles on screen than that.
        </p>
      )}
      {showLegend !== false && (
        <div className="flex items-center gap-3 flex-wrap mt-2 text-[11px]">
          {SURF_PIECE_LEGEND.map(l => (
            <span key={l.key} className="inline-flex items-center gap-1.5">
              <span className="inline-block w-3.5 h-3.5 rounded-sm border" style={{ background: l.fill, borderColor: l.stroke }} />
              {l.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// ---- one piece, and exactly what happens to it ----------------------------
// The board it came off, what is kept, what is removed, the cut line and every
// side dimensioned. A piece count nobody can check piece by piece is a piece
// count nobody will act on.
function SurfPieceDetail({ solved, piece, sys, onClose }) {
  if (!piece) return null;
  const spec = solved.spec;
  const poly = piece.poly;
  const o = poly[0];
  const ax = { x: piece.lenAxis.x, y: piece.lenAxis.y };
  const av = { x: -ax.y, y: ax.x };
  const toLocal = p => ({ x: (p.x - o.x) * ax.x + (p.y - o.y) * ax.y, y: (p.x - o.x) * av.x + (p.y - o.y) * av.y });
  const boardPts = poly.map(toLocal);
  const keptPts = piece.kept.map(toLocal);
  const bb = surfPolyBBox(boardPts.concat(keptPts));
  const pad = Math.max(bb.w, bb.h) * 0.16;
  const vb = { x: bb.minX - pad, y: bb.minY - pad, w: bb.w + pad * 2, h: bb.h + pad * 2 };
  const S = Math.max(vb.w, vb.h) / 100;
  const kb = surfPolyBBox(keptPts);
  const st = surfPieceStyle(piece.kind);
  const angled = piece.angled;
  const cutAngle = (() => {
    if (!angled) return null;
    // The steepest edge of the kept shape that is not one of the board's own —
    // that is the cut, and its angle off the piece's length is what gets set on
    // the saw.
    let best = null;
    for (let i = 0; i < keptPts.length; i++) {
      const a = keptPts[i], b = keptPts[(i + 1) % keptPts.length];
      const L = surfDist(a, b);
      if (L < 5) continue;
      const ang = Math.abs(Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI) % 180;
      const off = Math.min(Math.abs(ang), Math.abs(180 - ang), Math.abs(90 - ang));
      if (off < 1) continue;
      if (!best || L > best.L) best = { L, ang };
    }
    return best ? Math.round(best.ang * 10) / 10 : null;
  })();

  return (
    <div className="border border-[var(--leon-brown)] rounded-lg bg-white p-3 space-y-2">
      <div className="flex items-center justify-between gap-2">
        <div className="font-bold text-sm">
          Piece {piece.id} &middot; <span style={{ color: st.stroke }}>{st.label}</span>
        </div>
        <IconBtn title="Close" onClick={onClose}>&times;</IconBtn>
      </div>
      <svg viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`} style={{ width: '100%', height: 190 }}>
        <path d={surfPathOf(boardPts)} fill="#faf6ef" stroke="#b9a98c" strokeWidth={S * 0.25} strokeDasharray={`${S} ${S * 0.7}`} />
        <path d={surfPathOf(keptPts) + (piece.holeCuts || []).map(h => ' ' + surfPathOf(h.map(toLocal))).join('')}
          fillRule="evenodd" fill={st.fill} stroke={st.stroke} strokeWidth={S * 0.3} />
        {piece.kind !== 'full' && kb.maxX < bb.maxX - 1 && (
          <line x1={kb.maxX} y1={bb.minY} x2={kb.maxX} y2={bb.maxY} stroke="#c0392b" strokeWidth={S * 0.35} />
        )}
        <text x={(kb.minX + kb.maxX) / 2} y={kb.minY - S * 0.8} fontSize={S * 3.2} textAnchor="middle" fill="var(--leon-black)">
          {fmtDim(kb.w, sys)}
        </text>
        <text x={kb.maxX + S * 1.2} y={(kb.minY + kb.maxY) / 2} fontSize={S * 3.2} fill="var(--leon-black)">
          {fmtDim(kb.h, sys)}
        </text>
      </svg>
      <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
        <div><span className="text-[var(--leon-black)]/50">Board</span> {fmtDim(spec.pieceLenMm, sys)} &times; {fmtDim(spec.pieceWidMm, sys)}</div>
        <div><span className="text-[var(--leon-black)]/50">Kept</span> {fmtDim(kb.w, sys)} &times; {fmtDim(kb.h, sys)}</div>
        <div><span className="text-[var(--leon-black)]/50">Cut off</span> {piece.kind === 'full' ? '—' : fmtDim(Math.max(0, spec.pieceLenMm - piece.neededLenMm), sys)}</div>
        <div><span className="text-[var(--leon-black)]/50">Remainder</span> {piece.remainderMm > 0 ? fmtDim(piece.remainderMm, sys) : '—'}</div>
        <div><span className="text-[var(--leon-black)]/50">Laid area</span> {(piece.keptAreaMm2 / 1e6).toFixed(3)} m&sup2;</div>
        <div><span className="text-[var(--leon-black)]/50">Cut angle</span> {cutAngle === null ? 'square' : `${cutAngle}°`}</div>
        <div className="col-span-2 pt-1 text-[var(--leon-black)]/70">
          {piece.kind === 'full' && 'A full piece off a new board. Nothing removed.'}
          {piece.kind === 'bin' && `Cut from an offcut already on the shelf (board ${piece.sourceId}) — no new board was opened for it.`}
          {piece.kind === 'cut_bin' && `A new board was opened and cut; the ${fmtDim(piece.remainderMm, sys)} remainder went back on the shelf and is available to start a later row.`}
          {piece.kind === 'cut_loss' && (angled
            ? 'The cut is not square, so what comes off is a shape, not a length. It cannot go back on the shelf and is counted as loss.'
            : `A new board was opened; the ${fmtDim(piece.remainderMm, sys)} remainder is under the minimum reusable length and is counted as loss.`)}
        </div>
      </div>
    </div>
  );
}

// ---- the arithmetic, stated ------------------------------------------------
function SurfWasteCard({ solved, sys, roughPct }) {
  if (!solved) return null;
  const t = solved.tally;
  const spec = solved.spec;
  const cell = (label, value, sub) => (
    <div className="border border-[var(--leon-line)] rounded-md bg-white px-2.5 py-1.5">
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">{label}</div>
      <div className="text-sm font-bold">{value}</div>
      {sub && <div className="text-[10px] text-[var(--leon-black)]/45">{sub}</div>}
    </div>
  );
  return (
    <div className="space-y-2">
      <div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-2">
        {cell('Pieces', t.totalPieces.toLocaleString(), `${t.placed.toLocaleString()} laid`)}
        {cell('Boxes', t.packs.toLocaleString(), `${t.perPack} per box`)}
        {cell('Surplus', `${t.surplus} pc`, `${t.surplusM2.toFixed(2)} m²`)}
        {cell('Offcut loss', `${t.offcutLossM2.toFixed(2)} m²`, `${t.reused} reused from the shelf`)}
        {cell('Purchased', `${t.purchasedM2.toFixed(2)} m²`, `${t.areaPerBoxM2.toFixed(3)} m² per box`)}
        {cell('Total waste', `${t.totalWastePct.toFixed(1)}%`, 'computed, not entered')}
      </div>
      <SurfWarnings list={t.flags} />
      <details className="text-[11px] text-[var(--leon-black)]/60">
        <summary className="cursor-pointer">The arithmetic, in the order it runs</summary>
        <div className="pt-1 font-mono leading-5">
          <div>pieces = {t.totalPieces} &nbsp;(full {t.counts.full || 0} &middot; cut {(t.counts.cut_bin || 0) + (t.counts.cut_loss || 0)} &middot; from the shelf {t.counts.bin || 0})</div>
          <div>boxes = ceil({t.totalPieces} / {t.perPack}) = {t.packs}</div>
          <div>surplus = {t.packs} &times; {t.perPack} &minus; {t.totalPieces} = {t.surplus} pieces</div>
          <div>area per box = {Math.round(spec.pieceLenMm)} &times; {Math.round(spec.pieceWidMm)} &times; {t.perPack} / 1e6 = {t.areaPerBoxM2.toFixed(3)} m&sup2;</div>
          <div>purchased = {t.areaPerBoxM2.toFixed(3)} &times; {t.packs} = {t.purchasedM2.toFixed(2)} m&sup2;</div>
          <div>laid = {t.laidM2.toFixed(2)} m&sup2; &nbsp; offcut loss = {t.offcutLossM2.toFixed(2)} m&sup2; &nbsp; surplus = {t.surplusM2.toFixed(2)} m&sup2;</div>
          <div>waste % = ({t.surplusM2.toFixed(2)} + {t.offcutLossM2.toFixed(2)}) / {t.purchasedM2.toFixed(2)} = {t.totalWastePct.toFixed(1)}%</div>
        </div>
        <p className="pt-1">
          Those three add back to what was bought: {(t.laidM2 + t.offcutLossM2 + t.surplusM2).toFixed(2)} m&sup2; against {t.purchasedM2.toFixed(2)} m&sup2; purchased.
          If they ever stop agreeing, one of them is wrong and this line is what says so.
        </p>
      </details>
      {roughPct !== null && roughPct !== undefined && (
        <p className="text-[11px] text-[var(--leon-black)]/55">
          The company rough allowance for this kind of surface is <b>{roughPct}%</b>. It is <b>not</b> in play here —
          this surface has a set-out, so the computed <b>{t.totalWastePct.toFixed(1)}%</b> supersedes it.
          The percentage is the figure to use before a set-out exists, and nowhere else.
        </p>
      )}
    </div>
  );
}

// ---- the controls ----------------------------------------------------------
function SurfSetoutControls({ roomT, surface, sys, canEdit, onSetout, onLayout, solved }) {
  const s = surfSetoutOf(surface);
  const spec = surfEffectiveSpec(roomT, surface);
  const L = surface.layout || surfMakeLayout();
  const plan = surfPlanOf(roomT);
  const edges = surface.kind === 'Floor'
    ? surfEdgeLengths(plan).map((len, i) => ({ i, label: `Wall ${i + 1} — ${fmtDim(len, sys)}` }))
    : [{ i: 0, label: 'The bottom edge' }, { i: 1, label: 'The right edge' }, { i: 2, label: 'The top edge' }, { i: 3, label: 'The left edge' }];
  const pats = surfPatternsFor(s.material, s.shape);
  const patDef = surfSetoutPatternDef(s.pattern);

  return (
    <div className="space-y-3">
      <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
        <Field label="Material">
          <Select disabled={!canEdit} value={s.material} onChange={e => onSetout({ material: e.target.value, pattern: surfPatternsFor(e.target.value, s.shape)[0].key })}>
            <option value="tile">Tile</option>
            <option value="plank">Plank / board</option>
          </Select>
        </Field>
        <Field label="Shape">
          <Select disabled={!canEdit} value={s.shape} onChange={e => {
            const shape = e.target.value;
            onSetout({ shape, pattern: surfPatternsFor(s.material, shape)[0].key });
          }}>
            {SURF_PIECE_SHAPES.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
          </Select>
        </Field>
        <Field label={s.shape === 'hex' ? 'Across the flats' : 'Piece length'} hint="Shared with the elevation drawing">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={Math.max(L.tileWmm, L.tileHmm)}
            onChange={v => onLayout({ tileWmm: v, tileHmm: s.shape === 'square' || s.shape === 'hex' ? v : Math.min(L.tileWmm, L.tileHmm) })} />
        </Field>
        <Field label="Piece width">
          <SurfDimInput sys={sys} disabled={!canEdit || s.shape !== 'rect'} valueMm={s.shape === 'hex' ? Math.max(L.tileWmm, L.tileHmm) : Math.min(L.tileWmm, L.tileHmm)}
            onChange={v => onLayout({ tileHmm: v })} />
        </Field>
        <Field label="Joint width" hint="Real geometry — 3 mm and 5 mm move every cut">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={L.groutMm} onChange={v => onLayout({ groutMm: v })} />
        </Field>
        <Field label="Thickness">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={s.thicknessMm} onChange={v => onSetout({ thicknessMm: v })} />
        </Field>
        <Field label="Pieces per box">
          <TextInput type="number" min="1" disabled={!canEdit} value={s.perPack === null || s.perPack === undefined ? '' : s.perPack}
            placeholder={String(surfCfg('perPack'))}
            onChange={e => onSetout({ perPack: e.target.value === '' ? null : Math.max(1, Number(e.target.value) || 1) })} />
        </Field>
        <Field label="Pattern">
          <Select disabled={!canEdit} value={s.pattern} onChange={e => onSetout({ pattern: e.target.value })}>
            {pats.map(p => <option key={p.key} value={p.key}>{p.label}</option>)}
          </Select>
        </Field>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/55">{patDef.note}</p>

      <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
        <Field label="Runs off" hint="Which edge the material runs parallel to">
          <Select disabled={!canEdit} value={s.directionEdge} onChange={e => onSetout({ directionEdge: Number(e.target.value) })}>
            {edges.map(e => <option key={e.i} value={e.i}>{e.label}</option>)}
          </Select>
        </Field>
        {patDef.family === 'lattice' && (
          <Field label="Pattern angle">
            <Select disabled={!canEdit} value={s.patternAngleDeg} onChange={e => onSetout({ patternAngleDeg: Number(e.target.value) })}>
              <option value={0}>0&deg; — square to the room</option>
              <option value={45}>45&deg; — the classic</option>
            </Select>
          </Field>
        )}
        <Field label="Starter length" hint="0 = let the pattern decide">
          <SurfDimInput sys={sys} disabled={!canEdit || patDef.family !== 'row'} valueMm={s.starterLenMm} onChange={v => onSetout({ starterLenMm: v })} />
        </Field>
        <Field label="Minimum stagger" hint="Between one row and the two before it">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={spec.minStaggerMm} onChange={v => onSetout({ minStaggerMm: v })} />
        </Field>
        <Field label="Minimum start length">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={spec.minStartMm} onChange={v => onSetout({ minStartMm: v })} />
        </Field>
        <Field label="Minimum end length" hint="The sliver the solver refuses to leave">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={spec.minEndMm} onChange={v => onSetout({ minEndMm: v })} />
        </Field>
        <Field label="Expansion gap" hint="Taken off the field before anything is laid">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={spec.expansionMm} onChange={v => onSetout({ expansionMm: v })} />
        </Field>
        <Field label="Perimeter joint" hint="Extra joint against the wall, on top of the gap">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={s.perimeterJointMm} onChange={v => onSetout({ perimeterJointMm: v })} />
        </Field>
        <Field label="Wall offset" hint="Moves the GRID off the reference edge — it does not shrink the room">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={s.wallOffsetMm} onChange={v => onSetout({ wallOffsetMm: v })} />
        </Field>
        <Field label="Smallest offcut worth keeping" hint="Shorter than this and the remainder is loss">
          <SurfDimInput sys={sys} disabled={!canEdit} valueMm={spec.minReusableMm} onChange={v => onSetout({ minReusableMm: v })} />
        </Field>
      </div>

      <div className="border border-[var(--leon-line)] rounded-lg bg-white p-2.5 space-y-2">
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" disabled={!canEdit} checked={s.autoStart !== false}
            onChange={e => onSetout({ autoStart: e.target.checked })} />
          <span><b>Search for a start position that leaves no slivers</b> — reject any offset where a row starts or ends under the minimum</span>
        </label>
        {s.autoStart === false && (
          <div className="grid grid-cols-2 gap-3">
            <Field label={`Offset across (${Math.round((s.gridOffsetU || 0) * 100)}% of a module)`}>
              <input type="range" min="0" max="1" step="0.02" disabled={!canEdit} value={s.gridOffsetU || 0}
                onChange={e => onSetout({ gridOffsetU: Number(e.target.value) })} className="w-full" />
            </Field>
            <Field label={`Offset up (${Math.round((s.gridOffsetV || 0) * 100)}% of a row)`}>
              <input type="range" min="0" max="1" step="0.02" disabled={!canEdit} value={s.gridOffsetV || 0}
                onChange={e => onSetout({ gridOffsetV: Number(e.target.value) })} className="w-full" />
            </Field>
          </div>
        )}
        {s.pattern === 'stagger_free' && (
          <div className="flex items-end gap-2">
            <Field label="Random seed" hint="Free stagger has to be reproducible, or the drawing and the count could differ on the next read">
              <TextInput type="number" className="!w-28" disabled={!canEdit} value={s.seed}
                onChange={e => onSetout({ seed: Number(e.target.value) || 1 })} />
            </Field>
            <Button size="sm" variant="outline" disabled={!canEdit} onClick={() => onSetout({ seed: Math.floor(Math.random() * 100000) + 1 })}>Try another</Button>
          </div>
        )}
        {solved && solved.search && (
          <p className="text-[11px] text-[var(--leon-black)]/55">
            {solved.search.violations === 0
              ? `No row starts or ends on a sliver. ${solved.search.searched > 1 ? `${solved.search.searched} start positions were tried before this one held.` : 'The position you set already held; nothing was searched.'}`
              : `${solved.search.violations} row end${solved.search.violations === 1 ? '' : 's'} still under the minimum.`}
          </p>
        )}
      </div>
    </div>
  );
}

// ---- accessories -----------------------------------------------------------
// Estimated / Custom, the same badge the rest of the app uses: the derived
// value is shown, marked Estimated; an override marks it Custom and offers a
// reset. Nothing here silently keeps a number a person typed once.
function SurfEstBadge({ custom, derived, onReset, canEdit }) {
  if (!custom) return <Badge tone="neutral">Estimated</Badge>;
  return (
    <span className="inline-flex items-center gap-1.5">
      <Badge tone="brown">Custom</Badge>
      {canEdit && <button onClick={onReset} className="text-[10px] underline text-[var(--leon-black)]/50">reset to {derived}</button>}
    </span>
  );
}

function SurfAccessoriesPanel({ roomT, roomSetout, sys, canEdit, onAcc }) {
  const acc = surfAccessoriesOf(roomT);
  const sk = roomSetout.skirting, un = roomSetout.underlay, ad = roomSetout.adhesive, gr = roomSetout.grout;
  const set = (group, patch) => onAcc({ ...acc, [group]: { ...acc[group], ...patch } });

  return (
    <div className="space-y-3">
      <Collapsible title="Skirting" defaultOpen id={`surf-acc-skirt-${roomT.id}`}
        right={<span className="text-[11px] text-[var(--leon-black)]/50">{sk.pieces} pieces &middot; {sk.lengthM.toFixed(2)} m</span>}>
        <div className="grid gap-3 md:grid-cols-4">
          <Field label="Stock length"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={sk.stockMm} onChange={v => set('skirting', { stockMm: v })} /></Field>
          <Field label="Height"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={sk.heightMm} onChange={v => set('skirting', { heightMm: v })} /></Field>
          <Field label="Thickness"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={sk.thicknessMm} onChange={v => set('skirting', { thicknessMm: v })} /></Field>
          <Field label="Pieces">
            <div className="flex items-center gap-2">
              <TextInput type="number" className="!w-24" disabled={!canEdit}
                value={sk.piecesCustom ? sk.pieces : ''} placeholder={String(sk.derivedPieces)}
                onChange={e => set('skirting', { piecesOverride: e.target.value === '' ? null : Number(e.target.value) })} />
              <SurfEstBadge custom={sk.piecesCustom} derived={sk.derivedPieces} canEdit={canEdit}
                onReset={() => set('skirting', { piecesOverride: null })} />
            </div>
          </Field>
        </div>
        <div className="mt-2 overflow-x-auto border border-[var(--leon-line)] rounded-lg bg-white">
          <table className="w-full text-xs">
            <thead className="bg-[var(--leon-cream)]"><tr className="text-left">
              <th className="px-2.5 py-1.5 font-semibold">Run</th>
              <th className="px-2.5 py-1.5 font-semibold">Length</th>
              <th className="px-2.5 py-1.5 font-semibold">Pieces laid into it</th>
            </tr></thead>
            <tbody className="divide-y divide-[var(--leon-line)]">
              {sk.runs.map((r, i) => {
                const items = sk.alloc.items.filter(x => x.label === r.label && x.edge === r.edge);
                return (
                  <tr key={i}>
                    <td className="px-2.5 py-1.5">{r.label}</td>
                    <td className="px-2.5 py-1.5">{fmtDim(r.lenMm, sys)}</td>
                    <td className="px-2.5 py-1.5">{Math.max(1, Math.ceil(r.lenMm / sk.stockMm))} length{Math.ceil(r.lenMm / sk.stockMm) === 1 ? '' : 's'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
          {sk.runs.length} separate runs, because a physical length cannot turn a corner or cross a doorway.
          {sk.openingsSkipped > 0 && ` ${sk.openingsSkipped} door/passage opening${sk.openingsSkipped === 1 ? '' : 's'} broke a run.`}
          {sk.windowsIgnored > 0 && ` ${sk.windowsIgnored} window${sk.windowsIgnored === 1 ? '' : 's'} did not — skirting runs under a window.`}
          {' '}Offcuts carry between runs: {sk.alloc.reused} run{sk.alloc.reused === 1 ? '' : 's'} started on one, and {(sk.lossM).toFixed(2)} m is loss.
          {' '}Perimeter &divide; stock length says <b>{Math.ceil(sk.naive)}</b>
          {Math.ceil(sk.naive) === sk.derivedPieces
            ? ' — the same answer here, which it will not be as soon as a run is longer than a stock length or another door breaks one.'
            : Math.ceil(sk.naive) < sk.derivedPieces
              ? ` — ${sk.derivedPieces - Math.ceil(sk.naive)} short, because it assumes one continuous run that can turn corners.`
              : ' — higher here, because it cannot see that offcuts carry between runs.'}
        </p>
      </Collapsible>

      {un && (
        <Collapsible title="Underlay" id={`surf-acc-under-${roomT.id}`}
          right={<span className="text-[11px] text-[var(--leon-black)]/50">{acc.underlay.enabled ? `${un.count} ${un.mode}${un.count === 1 ? '' : 's'}` : 'not used'}</span>}>
          <label className="flex items-center gap-2 text-sm mb-2">
            <input type="checkbox" disabled={!canEdit} checked={acc.underlay.enabled === true}
              onChange={e => set('underlay', { enabled: e.target.checked })} />
            This floor has an underlay
          </label>
          {acc.underlay.enabled && (
            <>
              <div className="grid gap-3 md:grid-cols-4">
                <Field label="Form">
                  <Select disabled={!canEdit} value={un.mode} onChange={e => set('underlay', { mode: e.target.value, lenMm: null, widMm: null })}>
                    <option value="roll">Roll</option><option value="panel">Panel</option>
                  </Select>
                </Field>
                <Field label="Length"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={un.lenMm} onChange={v => set('underlay', { lenMm: v })} /></Field>
                <Field label="Width"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={un.widMm} onChange={v => set('underlay', { widMm: v })} /></Field>
                <Field label={un.mode === 'roll' ? 'Rolls' : 'Panels'}>
                  <div className="flex items-center gap-2">
                    <TextInput type="number" className="!w-20" disabled={!canEdit}
                      value={un.custom ? un.count : ''} placeholder={String(un.derived)}
                      onChange={e => set('underlay', { countOverride: e.target.value === '' ? null : Number(e.target.value) })} />
                    <SurfEstBadge custom={un.custom} derived={un.derived} canEdit={canEdit}
                      onReset={() => set('underlay', { countOverride: null })} />
                  </div>
                </Field>
              </div>
              <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
                Laid in {un.bands} band{un.bands === 1 ? '' : 's'} across the room, {un.runs.length} run{un.runs.length === 1 ? '' : 's'} in total, with
                offcuts carried through the same shelf the planks use ({un.alloc.reused} reused).
                {un.netM2.toFixed(2)} m&sup2; to cover, {un.coverageM2.toFixed(2)} m&sup2; bought.
              </p>
            </>
          )}
        </Collapsible>
      )}

      {ad && (
        <Collapsible title="Adhesive" id={`surf-acc-adh-${roomT.id}`}
          right={<span className="text-[11px] text-[var(--leon-black)]/50">{acc.adhesive.enabled === false ? 'not used' : `${ad.bags} bag${ad.bags === 1 ? '' : 's'}`}</span>}>
          <label className="flex items-center gap-2 text-sm mb-2">
            <input type="checkbox" disabled={!canEdit} checked={acc.adhesive.enabled !== false}
              onChange={e => set('adhesive', { enabled: e.target.checked })} />
            This surface is set in adhesive
            <span className="text-[11px] text-[var(--leon-black)]/45">— a click-lock floor floats and uses none</span>
          </label>
          <div className={`grid gap-3 md:grid-cols-4 ${acc.adhesive.enabled === false ? 'opacity-40 pointer-events-none' : ''}`}>
            <Field label="Notch" hint={`Suggested ${ad.suggestedNotch} mm from the longest tile edge`}>
              <Select disabled={!canEdit} value={ad.notchMm} onChange={e => set('adhesive', { notchMm: Number(e.target.value), consumptionOverride: null })}>
                {[4, 6, 8, 10, 12, 15].map(n => <option key={n} value={n}>{n} mm &mdash; {SURF_ADHESIVE_KG_PER_M2[n]} kg/m&sup2;</option>)}
              </Select>
            </Field>
            <Field label="Consumption">
              <div className="flex items-center gap-2">
                <TextInput type="number" step="0.1" className="!w-24" disabled={!canEdit}
                  value={ad.rateCustom ? ad.ratePerM2 : ''} placeholder={String(ad.derivedRate)}
                  onChange={e => set('adhesive', { consumptionOverride: e.target.value === '' ? null : Number(e.target.value) })} />
                <SurfEstBadge custom={ad.rateCustom} derived={`${ad.derivedRate} kg/m²`} canEdit={canEdit}
                  onReset={() => set('adhesive', { consumptionOverride: null })} />
              </div>
            </Field>
            <Field label="Bag size (kg)">
              <TextInput type="number" className="!w-20" disabled={!canEdit} value={ad.bagKg}
                onChange={e => set('adhesive', { bagKg: Number(e.target.value) || null })} />
            </Field>
            <Field label="Bags">
              <div className="flex items-center gap-2">
                <TextInput type="number" className="!w-20" disabled={!canEdit}
                  value={ad.bagsCustom ? ad.bags : ''} placeholder={String(ad.derivedBags)}
                  onChange={e => set('adhesive', { bagsOverride: e.target.value === '' ? null : Number(e.target.value) })} />
                <SurfEstBadge custom={ad.bagsCustom} derived={ad.derivedBags} canEdit={canEdit}
                  onReset={() => set('adhesive', { bagsOverride: null })} />
              </div>
            </Field>
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/55 mt-2 font-mono">
            bags = ceil({ad.kg.toFixed(1)} kg / {ad.bagKg} kg) = {ad.derivedBags}
          </p>
        </Collapsible>
      )}

      {gr && (
        <Collapsible title="Grout" id={`surf-acc-grout-${roomT.id}`}
          right={<span className="text-[11px] text-[var(--leon-black)]/50">{acc.grout.enabled === false ? 'not used' : `${gr.liters.toFixed(1)} L · ${gr.bags} bag${gr.bags === 1 ? '' : 's'}`}</span>}>
          <label className="flex items-center gap-2 text-sm mb-2">
            <input type="checkbox" disabled={!canEdit} checked={acc.grout.enabled !== false}
              onChange={e => set('grout', { enabled: e.target.checked })} />
            This surface is grouted
          </label>
          {gr.jointMm === 0 && acc.grout.enabled !== false && (
            <SurfWarnings className="mb-2" list={[{ level: 'note', text: 'The joint on this surface is 0 mm, so the formula returns nothing. A butted plank floor has no grout — either that is right and this section belongs switched off, or the joint width is missing from the set-out.' }]} />
          )}
          <div className={`grid gap-3 md:grid-cols-4 ${acc.grout.enabled === false ? 'opacity-40 pointer-events-none' : ''}`}>
            <Field label="Joint depth"><SurfDimInput sys={sys} disabled={!canEdit} valueMm={gr.depthMm} onChange={v => set('grout', { depthMm: v })} /></Field>
            <Field label="Bag size (kg)">
              <TextInput type="number" className="!w-20" disabled={!canEdit} value={gr.bagKg}
                onChange={e => set('grout', { bagKg: Number(e.target.value) || null })} />
            </Field>
            <Field label="Litres">
              <div className="flex items-center gap-2">
                <TextInput type="number" step="0.1" className="!w-24" disabled={!canEdit}
                  value={gr.litersCustom ? gr.liters : ''} placeholder={gr.derivedLiters.toFixed(1)}
                  onChange={e => set('grout', { litersOverride: e.target.value === '' ? null : Number(e.target.value) })} />
                <SurfEstBadge custom={gr.litersCustom} derived={`${gr.derivedLiters.toFixed(1)} L`} canEdit={canEdit}
                  onReset={() => set('grout', { litersOverride: null })} />
              </div>
            </Field>
            <Field label="Bags">
              <div className="flex items-center gap-2">
                <TextInput type="number" className="!w-20" disabled={!canEdit}
                  value={gr.bagsCustom ? gr.bags : ''} placeholder={String(gr.derivedBags)}
                  onChange={e => set('grout', { bagsOverride: e.target.value === '' ? null : Number(e.target.value) })} />
                <SurfEstBadge custom={gr.bagsCustom} derived={gr.derivedBags} canEdit={canEdit}
                  onReset={() => set('grout', { bagsOverride: null })} />
              </div>
            </Field>
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/55 mt-2 font-mono">
            litres = {gr.formula} = {gr.derivedLiters.toFixed(2)} L &nbsp;&middot;&nbsp;
            bags = ceil({gr.derivedLiters.toFixed(2)} &times; {gr.density} kg/L / {gr.bagKg} kg) = {gr.derivedBags}
          </p>
        </Collapsible>
      )}
    </div>
  );
}

// ############################################################################
// THE LAYOUT REPORT
// ----------------------------------------------------------------------------
// Through the app's OWN print path: a data-print-region, the shared DocActions
// pair, and the letterhead that every other printed page in the Hub carries.
// Nothing here rasterises the screen.
//
// Technical and Client are two audiences, not two documents. The technical
// sheet carries the cut detail and the offcut accounting because that is what
// the person laying it needs; the client sheet carries the areas, the pattern
// and the drawing, because a client asked for a floor and not for a bin of
// offcuts.
// ############################################################################

function surfReportRows(roomT, roomSetout, sys, instances) {
  const rows = [];
  const n = Math.max(1, instances || 1);
  (roomT.surfaces || []).forEach(s => {
    const solved = roomSetout.surfaces[s.id];
    if (!solved || solved.capped) return;
    const t = solved.tally, sp = solved.spec;
    rows.push({
      surface: s.name,
      finish: s.finish ? s.finish.name : 'No finish selected',
      pattern: solved.pattern.label,
      shape: sp.shape === 'hex' ? 'Hexagon' : sp.shape === 'square' ? 'Square' : 'Rectangular',
      piece: `${fmtDim(sp.pieceLenMm, sys)} x ${fmtDim(sp.pieceWidMm, sys)}`,
      thickness: fmtDim(sp.thicknessMm, sys),
      joint: `${sp.jointMm} mm`,
      netArea: (solved.netAreaM2 * n).toFixed(2),
      perimeter: (solved.perimeterMm / 1000).toFixed(2),
      perPack: t.perPack,
      areaPerBox: t.areaPerBoxM2.toFixed(3),
      pieces: t.totalPieces * n,
      boxes: t.packs * n,
      surplus: t.surplus * n,
      offcutLoss: (t.offcutLossM2 * n).toFixed(2),
      purchased: (t.purchasedM2 * n).toFixed(2),
      waste: `${t.totalWastePct.toFixed(1)}%`,
    });
  });
  return rows;
}
const SURF_REPORT_COLUMNS = [
  { key: 'surface', label: 'Surface' }, { key: 'finish', label: 'Finish' },
  { key: 'pattern', label: 'Pattern' }, { key: 'shape', label: 'Shape' },
  { key: 'piece', label: 'Piece size' }, { key: 'thickness', label: 'Thickness' },
  { key: 'joint', label: 'Joint' }, { key: 'netArea', label: 'Net area (m2)' },
  { key: 'perimeter', label: 'Perimeter (m)' }, { key: 'perPack', label: 'Pieces per box' },
  { key: 'areaPerBox', label: 'Area per box (m2)' }, { key: 'pieces', label: 'Pieces' },
  { key: 'boxes', label: 'Boxes' }, { key: 'surplus', label: 'Surplus pieces' },
  { key: 'offcutLoss', label: 'Offcut loss (m2)' }, { key: 'purchased', label: 'Purchased (m2)' },
  { key: 'waste', label: 'Total waste' },
];

function SurfLayoutReport({ ctx, roomT, roomSetout, sys, project, instances }) {
  const [mode, setMode] = useState('technical');
  const [orient, setOrient] = useState('portrait');
  const [centres, setCentres] = useState(true);
  const rows = surfReportRows(roomT, roomSetout, sys, instances);
  // A capped surface is deliberately excluded: an incomplete simulation must not
  // put a box count on a document somebody orders against.
  const surfaces = (roomT.surfaces || []).filter(s => roomSetout.surfaces[s.id] && !roomSetout.surfaces[s.id].capped);
  const tech = mode === 'technical';
  const sk = roomSetout.skirting, un = roomSetout.underlay, gr = roomSetout.grout, ad = roomSetout.adhesive;
  const heading = `Layout Report — ${roomT.code} ${roomT.name}`;
  const lines = [
    project ? `Project: ${project.name}` : 'Room type library',
    `Room type: ${roomT.code} · ${roomT.name}`,
    `Units: ${sys}`,
    `Date: ${fmtDate(todayISO())}`,
  ];

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-2 flex-wrap no-print">
        <Field label="Report">
          <Select className="!w-auto" value={mode} onChange={e => setMode(e.target.value)}>
            <option value="technical">Technical — for the person laying it</option>
            <option value="client">Client — areas, pattern and the drawing</option>
          </Select>
        </Field>
        <Field label="Page">
          <Select className="!w-auto" value={orient} onChange={e => setOrient(e.target.value)}>
            <option value="portrait">Portrait</option><option value="landscape">Landscape</option>
          </Select>
        </Field>
        <label className="text-xs flex items-center gap-1.5 pt-4">
          <input type="checkbox" checked={centres} onChange={e => setCentres(e.target.checked)} /> Centrelines
        </label>
        <div className="flex-1" />
        <Button size="sm" variant="outline"
          onClick={() => downloadCsv(`layout-${roomT.code}`, SURF_REPORT_COLUMNS, rows)}>Quantities as CSV</Button>
        <DocActions title={heading} heading={heading} lines={lines} />
      </div>

      <div data-print-region={heading}
        className={`border border-[var(--leon-line)] rounded-lg bg-white p-4 space-y-4 ${orient === 'landscape' ? 'lp-landscape' : ''}`}>
        <div className="lp-section-title text-base font-bold">{heading}</div>
        <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
          <div><span className="text-[var(--leon-black)]/45 block">Project</span>{project ? project.name : '—'}</div>
          <div><span className="text-[var(--leon-black)]/45 block">Area / room type</span>{roomT.code} · {roomT.name}</div>
          <div><span className="text-[var(--leon-black)]/45 block">Date</span>{fmtDate(todayISO())}</div>
          <div><span className="text-[var(--leon-black)]/45 block">Units</span>{sys}</div>
          {instances > 1 && <div><span className="text-[var(--leon-black)]/45 block">Rooms of this type</span>{instances}</div>}
        </div>

        {surfaces.map(s => {
          const solved = roomSetout.surfaces[s.id];
          const t = solved.tally, sp = solved.spec;
          return (
            <div key={s.id} className="space-y-2">
              <div className="lp-section-title text-sm font-bold border-t border-[var(--leon-line)] pt-3">{s.name}</div>
              <div className={`grid gap-3 ${orient === 'landscape' ? 'md:grid-cols-2' : ''}`}>
                <div>
                  <SurfSetoutDrawing solved={solved} sys={sys} flip={s.kind !== 'Floor'} height={orient === 'landscape' ? 300 : 340}
                    showCentrelines={centres} showLegend={tech} showOpenings />
                </div>
                <div>
                  <table className="w-full text-xs">
                    <tbody className="divide-y divide-[var(--leon-line)]">
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Pattern</td><td className="py-1">{solved.pattern.label}</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Piece</td><td className="py-1">{fmtDim(sp.pieceLenMm, sys)} &times; {fmtDim(sp.pieceWidMm, sys)} &middot; {sp.shape === 'hex' ? 'hexagon' : sp.shape} &middot; {fmtDim(sp.thicknessMm, sys)} thick</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Joint</td><td className="py-1">{sp.jointMm} mm</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Net area</td><td className="py-1">{(solved.netAreaM2 * Math.max(1, instances || 1)).toFixed(2)} m&sup2;</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Perimeter</td><td className="py-1">{(solved.perimeterMm / 1000).toFixed(2)} m</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Pieces per box</td><td className="py-1">{t.perPack}</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Area per box</td><td className="py-1">{t.areaPerBoxM2.toFixed(3)} m&sup2;</td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Pieces</td><td className="py-1"><b>{(t.totalPieces * Math.max(1, instances || 1)).toLocaleString()}</b></td></tr>
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Boxes</td><td className="py-1"><b>{(t.packs * Math.max(1, instances || 1)).toLocaleString()}</b></td></tr>
                      {tech && <tr><td className="py-1 text-[var(--leon-black)]/50">Surplus</td><td className="py-1">{t.surplus * Math.max(1, instances || 1)} pieces</td></tr>}
                      {tech && <tr><td className="py-1 text-[var(--leon-black)]/50">Offcut loss</td><td className="py-1">{(t.offcutLossM2 * Math.max(1, instances || 1)).toFixed(2)} m&sup2;</td></tr>}
                      {tech && <tr><td className="py-1 text-[var(--leon-black)]/50">Reused offcuts</td><td className="py-1">{t.reused}</td></tr>}
                      <tr><td className="py-1 text-[var(--leon-black)]/50">Total waste</td><td className="py-1"><b>{t.totalWastePct.toFixed(1)}%</b> — computed from this layout, not a percentage allowance</td></tr>
                    </tbody>
                  </table>
                </div>
              </div>
              {tech && solved.warnings.length > 0 && <SurfWarnings list={solved.warnings} />}
            </div>
          );
        })}

        <div className="lp-section-title text-sm font-bold border-t border-[var(--leon-line)] pt-3">Accessories</div>
        <table className="w-full text-xs">
          <tbody className="divide-y divide-[var(--leon-line)]">
            <tr>
              <td className="py-1 text-[var(--leon-black)]/50">Skirting</td>
              <td className="py-1">
                {fmtDim(sk.heightMm, sys)} high &times; {fmtDim(sk.thicknessMm, sys)}, {fmtDim(sk.stockMm, sys)} lengths —
                <b> {sk.pieces} pieces</b>, {sk.lengthM.toFixed(2)} m installed over {sk.runs.length} runs
                {tech && `, ${sk.lossM.toFixed(2)} m loss`}
              </td>
            </tr>
            {un && un.enabled && (
              <tr><td className="py-1 text-[var(--leon-black)]/50">Underlay</td>
                <td className="py-1">{fmtDim(un.lenMm, sys)} &times; {fmtDim(un.widMm, sys)} {un.mode}s — <b>{un.count}</b>, covering {un.netM2.toFixed(2)} m&sup2;</td></tr>
            )}
            {ad && ad.enabled && (
              <tr><td className="py-1 text-[var(--leon-black)]/50">Adhesive</td>
                <td className="py-1">{ad.notchMm} mm notch at {ad.ratePerM2} kg/m&sup2; — <b>{ad.bags} bag{ad.bags === 1 ? '' : 's'}</b> of {ad.bagKg} kg</td></tr>
            )}
            {gr && gr.enabled && (
              <tr><td className="py-1 text-[var(--leon-black)]/50">Grout</td>
                <td className="py-1"><b>{gr.liters.toFixed(1)} litres</b> at {gr.jointMm} mm &times; {fmtDim(gr.depthMm, sys)} deep — {gr.bags} bag{gr.bags === 1 ? '' : 's'} of {gr.bagKg} kg</td></tr>
            )}
          </tbody>
        </table>

        <div className="lp-section-title text-sm font-bold border-t border-[var(--leon-line)] pt-3">Notes</div>
        <p className="text-xs text-[var(--leon-black)]/70">
          Every figure above comes from simulating this layout piece by piece: each row walked, each cut taken
          from the smallest offcut big enough, and whatever was left charged as loss. <b>No waste percentage was
          applied anywhere.</b> Change the pattern, the joint width or the start position and these numbers change,
          which is the point of producing them this way.
          {tech && ' Surplus is whole pieces left in an opened box; offcut loss is material cut off and never re-laid. They are different things and are reported separately.'}
        </p>
      </div>
    </div>
  );
}

// ############################################################################
// THE SET-OUT TAB
// ----------------------------------------------------------------------------
// A set-out lives on the ROOM TYPE, so it carries to every room that uses that
// type - and a room that disagrees holds its own answer through the same flat
// override map the rest of this module already uses. Designing one bathroom and
// having ninety of them inherit it is the thing this module has that a
// stand-alone layout tool cannot do at all.
// ############################################################################

function SurfSetOutTab({ ctx, lib, sys, canEditLib, projectPool, project, onPickProject }) {
  const [typeId, setTypeId] = useState(() => (lib.roomTypes[0] ? lib.roomTypes[0].id : null));
  const [surfaceId, setSurfaceId] = useState(null);
  const [piece, setPiece] = useState(null);
  const [centres, setCentres] = useState(false);
  const [showTrace, setShowTrace] = useState(false);

  const roomT = lib.roomTypes.find(t => t.id === typeId) || lib.roomTypes[0] || null;
  const plan = roomT ? surfPlanOf(roomT) : null;

  // LIVE. There is no Calculate button: the solver runs on every change, which
  // is only possible because it is cheap enough to and because nothing it
  // produces is written anywhere.
  const roomSetout = useMemo(() => (roomT ? surfRoomSetout(roomT) : null), [roomT]);

  const surfaces = roomT ? (roomT.surfaces || []) : [];
  const sel = surfaces.find(s => s.id === surfaceId) || surfaces.find(s => s.kind === 'Floor') || surfaces[0] || null;
  const solved = sel && roomSetout ? roomSetout.surfaces[sel.id] : null;
  const instances = roomT ? surfInstancesOfType(ctx.projects, roomT.id).length : 0;

  function editType(fn) {
    if (!canEditLib || !roomT) return;
    surfSetLib(ctx, l => {
      const t = l.roomTypes.find(x => x.id === roomT.id);
      if (t) fn(t);
    });
  }
  const setSetout = patch => editType(t => {
    const s = (t.surfaces || []).find(x => x.id === sel.id);
    if (s) s.setout = { ...surfSetoutOf(s), ...patch, enabled: true };
  });
  const setLayout = patch => editType(t => {
    const s = (t.surfaces || []).find(x => x.id === sel.id);
    if (s) s.layout = { ...(s.layout || surfMakeLayout()), ...patch };
  });

  if (!lib.roomTypes.length) {
    return <EmptyState text="Create a room type first — a set-out is solved on a room, and the room type is what the physical rooms inherit it from." />;
  }

  const roughPct = sel ? Math.round(surfAllowanceFor(sel.kind, (sel.layout || {}).pattern) * 100) : null;

  return (
    <div className="space-y-3">
      <div className="flex items-end gap-3 flex-wrap">
        <Field label="Room type">
          <Select className="!w-auto" value={roomT ? roomT.id : ''} onChange={e => { setTypeId(e.target.value); setSurfaceId(null); setPiece(null); }}>
            {lib.roomTypes.map(t => <option key={t.id} value={t.id}>{t.code} · {t.name}</option>)}
          </Select>
        </Field>
        <Field label="Report against">
          <Select className="!w-auto" value={project ? project.id : ''} onChange={e => onPickProject(e.target.value)}>
            <option value="">No project</option>
            {projectPool.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>
        <Badge tone={instances ? 'brown' : 'neutral'}>{instances} room{instances === 1 ? '' : 's'} inherit this</Badge>
      </div>

      {!canEditLib && <LockedNotice label="You can read this set-out but not change it. Room types are part of the company library." />}
      {canEditLib && instances > 0 && (
        <SurfWarnings list={[{ level: 'note', text: `Everything on this screen is stored on the room type, so it reaches all ${instances} room${instances === 1 ? '' : 's'} using it the moment it changes — except a room that already holds its own override for that field. An installed room is frozen at its as-built values by the room type editor's impact review.` }]} />
      )}

      <Collapsible title="The room, as a shape" defaultOpen id={`surf-plan-${roomT.id}`}
        right={<span className="text-[11px] text-[var(--leon-black)]/50">
          {(plan.points || []).length} corners &middot; {(plan.openings || []).length} openings &middot; {(plan.holes || []).length} cutouts
        </span>}>
        <div className="flex items-center gap-2 mb-2 no-print">
          <Button size="sm" variant={showTrace ? 'primary' : 'outline'} onClick={() => setShowTrace(v => !v)}>
            {showTrace ? 'Back to the editor' : 'Trace a real plan'}
          </Button>
          <span className="text-[11px] text-[var(--leon-black)]/50">
            Drag a corner, drag a wall, or tap a dimension and type it.
          </span>
        </div>
        {showTrace
          ? <SurfTracePanel plan={plan} sys={sys} canEdit={canEditLib} onPlan={p => editType(t => { t.plan = p; })} />
          : <SurfPlanEditor plan={plan} roomT={roomT} sys={sys} canEdit={canEditLib} height={400}
              onPlan={p => editType(t => { t.plan = p; })} />}
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-2">
          Floor area {(surfPolyArea(surfPtsIn(plan.points)) / 1e6).toFixed(2)} m&sup2; &middot;
          perimeter {(surfPolyPerimeter(surfPtsIn(plan.points)) / 1000).toFixed(2)} m.
          {(plan.points || []).length !== 4 && ' This outline is no longer four-sided, so the four recorded wall surfaces no longer correspond one-to-one with its edges — skirting reads the outline, the unfolded elevation still reads the wall records.'}
        </p>
      </Collapsible>

      <div className="flex items-center gap-1.5 flex-wrap border border-[var(--leon-line)] rounded-lg bg-white p-2 no-print">
        {surfaces.map(s => {
          const has = surfHasSetout(s);
          const on = sel && sel.id === s.id;
          return (
            <button key={s.id} onClick={() => { setSurfaceId(s.id); setPiece(null); }}
              className={`text-[11px] px-2 py-1 rounded border ${on ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]' : 'bg-white border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
              {s.name}{has ? ' ·' : ''}
            </button>
          );
        })}
        <span className="text-[11px] text-[var(--leon-black)]/40 ml-1">a dot marks a surface that has a set-out</span>
      </div>

      {sel && !surfHasSetout(sel) && (
        <div className="border border-[var(--leon-line)] rounded-lg bg-white p-4 text-center space-y-2">
          <p className="text-sm">
            <b>{sel.name}</b> has no set-out yet, so its quantity is still the rough allowance —
            area &times; {roughPct}%.
          </p>
          <Button disabled={!canEditLib} onClick={() => setSetout({ enabled: true })}>Solve a set-out for this surface</Button>
        </div>
      )}

      {sel && surfHasSetout(sel) && solved && (
        <>
          <Collapsible title={`${sel.name} — the set-out`} defaultOpen id={`surf-setout-${sel.id}`}>
            <SurfSetoutControls roomT={roomT} surface={sel} sys={sys} canEdit={canEditLib}
              onSetout={setSetout} onLayout={setLayout} solved={solved} />
            <div className="pt-3">
              <Button size="sm" variant="ghost" disabled={!canEditLib}
                onClick={() => editType(t => { const s = (t.surfaces || []).find(x => x.id === sel.id); if (s) s.setout = { ...surfSetoutOf(s), enabled: false }; })}>
                Turn the set-out off for this surface
              </Button>
            </div>
          </Collapsible>

          <Collapsible title={`${sel.name} — the layout`} defaultOpen id={`surf-draw-${sel.id}`}
            right={<label className="text-[11px] flex items-center gap-1 no-print">
              <input type="checkbox" checked={centres} onChange={e => setCentres(e.target.checked)} /> centrelines
            </label>}>
            <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_320px]">
              <SurfSetoutDrawing solved={solved} sys={sys} flip={sel.kind !== 'Floor'} height={460}
                selectedId={piece && piece.id} onSelect={setPiece} showCentrelines={centres} showOpenings />
              <div className="space-y-2">
                {piece
                  ? <SurfPieceDetail solved={solved} piece={piece} sys={sys} onClose={() => setPiece(null)} />
                  : <p className="text-xs text-[var(--leon-black)]/50 border border-dashed border-[var(--leon-line)] rounded-lg p-3">
                      Click any piece for its cut detail — the board it came off, what is kept, what is removed,
                      the cut line and whether it came out of the offcut bin.
                    </p>}
                <SurfWarnings list={solved.warnings} />
              </div>
            </div>
          </Collapsible>

          <Collapsible title={`${sel.name} — pieces, boxes and waste`} defaultOpen id={`surf-waste-${sel.id}`}
            right={<span className="text-[11px] font-bold text-[var(--leon-brown)]">
              {solved.capped ? 'not simulated' : `${solved.tally.totalWastePct.toFixed(1)}% waste`}
            </span>}>
            {solved.capped
              ? <SurfWarnings list={[{ level: 'bad', text: `The layout was cut short at the simulation limit, so the pieces placed are not the whole floor and no box count can be given from them. This surface stays on the ${roughPct}% rough allowance until the piece size or the area brings it back under the limit.` }]} />
              : <SurfWasteCard solved={solved} sys={sys} roughPct={roughPct} />}
          </Collapsible>
        </>
      )}

      <Collapsible title="Accessories" id={`surf-acc-${roomT.id}`}>
        {roomSetout && (
          <SurfAccessoriesPanel roomT={roomT} roomSetout={roomSetout} sys={sys} canEdit={canEditLib}
            onAcc={a => editType(t => { t.accessories = a; })} />
        )}
      </Collapsible>

      <Collapsible title="Layout Report" id={`surf-report-${roomT.id}`}>
        {roomSetout && roomSetout.anySetout && Object.keys(roomSetout.surfaces).some(k => !roomSetout.surfaces[k].capped)
          ? <SurfLayoutReport ctx={ctx} roomT={roomT} roomSetout={roomSetout} sys={sys} project={project} instances={instances || 1} />
          : <EmptyState text="No surface in this room type has a set-out yet, so there is nothing to report." />}
      </Collapsible>

      <Collapsible title="What the set-out engine does not do" id="surf-setout-limits">
        <ul className="text-sm space-y-2 text-[var(--leon-black)]/75 list-disc pl-5">
          <li>
            <b>Carpet and sheet goods are not built.</b> Broadloom is a different problem — roll width, cut
            lengths, pile direction and a seam plan — and none of it is answered by a piece solver. Nothing in
            this module will give you a carpet quantity, and it will not pretend to.
          </li>
          <li><b>No curves.</b> A curved wall cannot be described here, so it is refused rather than approximated with a polyline.</li>
          <li>
            <b>Herringbone closes on a 2:1 piece.</b> On any other ratio the pattern is drawn and counted as laid
            and the drift is named. It is not silently corrected, because correcting it would mean drawing
            something the tiler is not going to lay.
          </li>
          <li><b>A hexagon offcut is not binned.</b> A cut hexagon is a shape, not a length, so every hexagon cut is loss.</li>
          <li>
            <b>Nothing here is stored.</b> The pieces, the cuts and the box counts are regenerated on every read.
            That is deliberate: a saved answer and saved inputs would eventually disagree, and the saved answer
            would win.
          </li>
        </ul>
      </Collapsible>
    </div>
  );
}
