// ═══════════════════════════════════════════════════ LEON Take-off
// Drawing intelligence and quantity take-off.
//
// The thing this module is for is TRACEABILITY. A quantity is never a number
// somebody typed into a summary — it is the sum of takeoff records, each one
// pinned to a sheet, a sheet revision, a scope, a bid version and a person. No
// aggregate exists here without its sources, which is why every total on the
// Quantity Summary opens into the rows behind it.
//
// There are TWO ways to create that record and they produce the same record:
// typed on the Takeoff tab, or drawn on the sheet itself on the On-Sheet
// Measure tab, which renders the drawing through office-pdf-core.jsx (pdf.js)
// and measures against a scale calibrated on it.
//
// THE STRUCTURAL IDEA, borrowed from how estimators actually work: a take-off
// is not a separate structure, it IS the markup set viewed through a list.
// Quantities are properties of markups, and every report — the roll-up, the
// Markups List, the CSV — is a projection of that list. So the three grouping
// axes (SUBJECT, LAYER, SPACE) are stamped onto a markup at the moment it is
// drawn, from the tool that drew it, and subtotals, legends and export come
// free rather than being built three times.
//
// AND THE RULE THAT MAKES THE WHOLE THING RE-SCALABLE: store geometry, never a
// computed quantity. Length, area, volume and wall area are all
// f(geometry, resolved scale, depth/slope) and are computed on read. That is
// why a bulk re-scale is a re-render here and not a data migration.
//
// A CORRECTION TO WHAT THIS FILE USED TO SAY. It claimed snapping to the
// drawing's own linework was impossible because pdf.js gives only a raster and
// a text layer. That was wrong. `page.getOperatorList()` exposes the real
// vector path geometry — constructPath ops with moveTo/lineTo/curveTo/rect plus
// the CTM stack — so the linework CAN be walked, indexed and snapped to. It is
// done, in dwgBuildVectorIndex below. The honest limit is narrower and is the
// same one Bluebeam has: it works on VECTOR pages only. A scanned sheet has no
// path operators, and there the app says snapping is unavailable rather than
// snapping to nothing.
//
// WHAT IS HERE, in the order it was built, because it is also the order of
// value: depth / rise-drop / slope as markup properties with Recalculate as the
// recovery path; viewports carrying their own scale; the Markups List with
// grouping, subtotals, saved views and CSV; custom columns including a formula
// type; cutouts, and promoting a cutout into its own measurement; nesting
// Spaces; Dynamic Fill; a tool chest that stamps a whole property set including
// Snap to Content; legends and the four shape questions; and Visual Search.
//
// WHAT IS STILL NOT HERE, and why. None of these is a gap waiting to be filled
// quietly — each is something a browser with no server genuinely cannot do, and
// every surface that touches one says so in words rather than implying it works:
//   • No live Excel link. That needs a process running on this machine. What
//     leaves here is a CSV file somebody opens, and the screen says exactly that.
//   • No writing markups back into a real Bluebeam file. pdf.js cannot write
//     PDFs at all, and Revu's measurement annotations carry private keys that
//     are not a published format — so what can leave is a flattened PDF plus the
//     data, never a .bex. The PDF a sheet points at is NEVER modified by
//     anything in this module: boundaries, spaces and viewports live in LEON's
//     own state and are drawn over the render.
//   • No OCR, so a scanned sheet has no text to search and no linework to snap
//     to. It is detected and named, not worked around.
//   • No multi-user session — two people cannot mark up one sheet at once.
//   • No batch operation across a whole set from one tab: everything here acts
//     on the sheet and page in front of you.
//   • Visual Search is a REVIEWABLE LIST, never a finished count.

const DWG_SECTIONS = [
  { key: 'dashboard', label: 'Dashboard', icon: '📊' },
  { key: 'log', label: 'Drawing Log', icon: '📚' },
  { key: 'sheets', label: 'Sheets', icon: '📐' },
  { key: 'measure', label: 'On-Sheet Measure', icon: '🖊️' },
  { key: 'markups', label: 'Markups List', icon: '🗂️' },
  { key: 'takeoff', label: 'Takeoff', icon: '📏' },
  { key: 'summary', label: 'Quantity Summary', icon: '🧮' },
  { key: 'matrix', label: 'Scope Matrix', icon: '🔲' },
  { key: 'revisions', label: 'Revisions', icon: '🕘' },
  { key: 'handoff', label: 'Handoff', icon: '📤' },
];

// A set's issue type is what tells you how to read it. "Addendum 3" and "IFC"
// are not the same document even when they carry the same sheets.
const DWG_ISSUE_TYPES = ['Bid Set', 'Addendum', 'IFC', 'Bulletin', 'ASI', 'Revision', 'Shop Drawing', 'As Built'];
const DWG_DISCIPLINES = ['Architectural', 'Interiors', 'Structural', 'Mechanical', 'Electrical', 'Plumbing',
  'Millwork', 'Kitchen / Bath', 'Landscape', 'Civil', 'Details', 'Schedules', 'Other'];
const DWG_SHEET_STATUSES = ['Current', 'Superseded', 'Void'];
// Three different claims, deliberately not collapsed into one. 'Manual' is a
// scale a person read off the title block; 'Calibrated' is one measured on the
// rendered sheet against a known dimension; 'Detected' would be one a pipeline
// reported on its own, which nothing in this app does — a scale found in the
// PDF's text layer is offered as a suggestion and lands as Manual, because a
// person still accepted it.
const DWG_SCALE_SOURCES = ['Not calibrated', 'Detected', 'Manual', 'Calibrated'];
const DWG_SCALE_CONFIDENCE = ['Unverified', 'Low', 'Medium', 'High'];
const DWG_SCALE_PRESETS = ['1/8" = 1\'-0"', '3/16" = 1\'-0"', '1/4" = 1\'-0"', '3/8" = 1\'-0"',
  '1/2" = 1\'-0"', '3/4" = 1\'-0"', '1" = 1\'-0"', '1:50', '1:100', 'NTS'];
const DWG_TAKEOFF_STATUSES = ['Not Started', 'In Progress', 'Needs Review', 'Complete'];
const DWG_ITEM_STATUSES = ['Not Started', 'In Progress', 'Ready for Review', 'Reviewed', 'Approved',
  'Revision Review Required', 'Superseded'];
// A takeoff row is not only "is it measured" — it is also "does it count".
// An allowance and an alternate both exist on the drawings and neither belongs
// in the base bid total, so inclusion is its own axis, never folded into status.
const DWG_INCLUSIONS = ['Included', 'Excluded', 'Alternate', 'Allowance', 'By Others', 'Existing'];
const DWG_MEASURE_TYPES = ['Length', 'Area', 'Count', 'Volume'];
const DWG_UNITS_BY_TYPE = {
  Length: ['LF', 'LM', 'IN', 'MM'],
  Area: ['SF', 'SM', 'SY'],
  Count: ['EA', 'SET', 'PR', 'LEAF'],
  Volume: ['CF', 'CY', 'CM'],
};
const DWG_TOOL_SCOPES = ['Casework', 'Doors', 'Countertops', 'Tile', 'Wood / SPC', 'Carpet',
  'Baseboard', 'Mirrors', 'Hardware', 'Other'];

// Which LEON scope family a tool's work normally lands in. Used to suggest a
// scope when a takeoff is entered and to bucket the Scope Matrix — a suggestion,
// never a constraint: the person entering the row picks the real scope.
const DWG_FAMILY_FOR_TOOL_SCOPE = {
  'Casework': 'Casework', 'Doors': 'Doors', 'Countertops': 'Countertop', 'Tile': 'Tile',
  'Wood / SPC': 'Engineered Wood Flooring', 'Carpet': 'Carpet', 'Baseboard': 'Baseboards / Trims',
  'Mirrors': 'Other / Custom', 'Hardware': 'Other / Custom', 'Other': 'Other / Custom',
};

// The shared tool chest. These live in code rather than in state because they
// are the company's standard way of measuring, not a per-job decision — a job
// that needs its own tool adds a PROJECT tool below, which does persist.
const DWG_GLOBAL_TOOLS = [
  { id: 'gt-case-lf', name: 'Casework — run', scope: 'Casework', measurementType: 'Length', unit: 'LF', wastePct: 5, color: '#8a6a4f' },
  { id: 'gt-case-ea', name: 'Casework — cabinet count', scope: 'Casework', measurementType: 'Count', unit: 'EA', wastePct: 0, color: '#a98663' },
  { id: 'gt-case-panel', name: 'Casework — panel area', scope: 'Casework', measurementType: 'Area', unit: 'SF', wastePct: 10, color: '#c0a184' },
  { id: 'gt-door-leaf', name: 'Door leaf count', scope: 'Doors', measurementType: 'Count', unit: 'LEAF', wastePct: 0, color: '#6b4f3a' },
  { id: 'gt-door-frame', name: 'Door frame count', scope: 'Doors', measurementType: 'Count', unit: 'EA', wastePct: 0, color: '#7d6047' },
  { id: 'gt-ctop-sf', name: 'Countertop area', scope: 'Countertops', measurementType: 'Area', unit: 'SF', wastePct: 15, color: '#4f5d6b' },
  { id: 'gt-ctop-edge', name: 'Countertop edge', scope: 'Countertops', measurementType: 'Length', unit: 'LF', wastePct: 5, color: '#63748a' },
  { id: 'gt-ctop-splash', name: 'Backsplash area', scope: 'Countertops', measurementType: 'Area', unit: 'SF', wastePct: 12, color: '#7c8ea3' },
  { id: 'gt-tile-floor', name: 'Tile — floor', scope: 'Tile', measurementType: 'Area', unit: 'SF', wastePct: 10, color: '#3f7d78' },
  { id: 'gt-tile-wall', name: 'Tile — wall', scope: 'Tile', measurementType: 'Area', unit: 'SF', wastePct: 12, color: '#529a94' },
  { id: 'gt-tile-trim', name: 'Tile — trim / schluter', scope: 'Tile', measurementType: 'Length', unit: 'LF', wastePct: 8, color: '#6fb3ac' },
  { id: 'gt-wood-sf', name: 'Wood / SPC flooring', scope: 'Wood / SPC', measurementType: 'Area', unit: 'SF', wastePct: 8, color: '#9a7b45' },
  { id: 'gt-wood-tran', name: 'Flooring transitions', scope: 'Wood / SPC', measurementType: 'Length', unit: 'LF', wastePct: 5, color: '#b39560' },
  { id: 'gt-carpet-sf', name: 'Carpet — broadloom', scope: 'Carpet', measurementType: 'Area', unit: 'SY', wastePct: 12, color: '#7a5f7d' },
  { id: 'gt-carpet-tile', name: 'Carpet tile', scope: 'Carpet', measurementType: 'Area', unit: 'SF', wastePct: 8, color: '#96789a' },
  { id: 'gt-base-lf', name: 'Baseboard run', scope: 'Baseboard', measurementType: 'Length', unit: 'LF', wastePct: 10, color: '#5d7a4f' },
  { id: 'gt-base-corner', name: 'Baseboard corners', scope: 'Baseboard', measurementType: 'Count', unit: 'EA', wastePct: 0, color: '#7a9668' },
  { id: 'gt-mirror-sf', name: 'Mirror area', scope: 'Mirrors', measurementType: 'Area', unit: 'SF', wastePct: 5, color: '#4a7f96' },
  { id: 'gt-mirror-ea', name: 'Mirror count', scope: 'Mirrors', measurementType: 'Count', unit: 'EA', wastePct: 0, color: '#66a0b8' },
  { id: 'gt-hw-ea', name: 'Hardware count', scope: 'Hardware', measurementType: 'Count', unit: 'EA', wastePct: 2, color: '#8d8d8d' },
  { id: 'gt-hw-set', name: 'Hardware sets', scope: 'Hardware', measurementType: 'Count', unit: 'SET', wastePct: 0, color: '#a5a5a5' },
  { id: 'gt-other-ea', name: 'Miscellaneous count', scope: 'Other', measurementType: 'Count', unit: 'EA', wastePct: 0, color: '#7b6b5c' },
  { id: 'gt-other-sf', name: 'Miscellaneous area', scope: 'Other', measurementType: 'Area', unit: 'SF', wastePct: 5, color: '#96887a' },
];

// Where a finished takeoff row goes next. Each target writes into the OTHER
// MODULE'S OWN collection — project.doors is what LEON Doors reads,
// project.stoneCutList is what LEON Stone reads — never a parallel copy staged
// here. `mode` is the honest part: two targets can create a record from a
// take-off, and one genuinely cannot, for a reason the screen states.
const DWG_HANDOFF_TARGETS = [
  {
    key: 'doors', label: 'LEON Doors', mode: 'create', collection: 'doors', matchField: 'mark',
    toolScopes: ['Doors'],
    note: 'Creates a real door on this project — the same record the Door Schedule, the Door Designer, production and the submittal all read. Only the mark, location, scope and count come from the take-off; sizes, type and hardware are decided in LEON Doors.',
  },
  {
    key: 'stone', label: 'LEON Stone', mode: 'create', collection: 'stoneCutList', matchField: 'label',
    toolScopes: ['Countertops'],
    note: 'Creates a piece on the Stone Cut List. A take-off measures AREA, and a cut piece needs a length and a width — those are not invented here. Each piece lands Planned with its measured area in the note, for someone to dimension against the drawing before it is nested onto a slab.',
  },
  {
    // Deliberately link-only. A LEON Surfaces room is geometry — width, length,
    // height, a tile module and a set-out per surface. A take-off is a quantity
    // with none of that. Generating rooms from square footage would fabricate
    // dimensions and every set-out drawn from them would be fiction.
    key: 'surfaces', label: 'LEON Surfaces', mode: 'link', toolScopes: ['Tile', 'Wood / SPC', 'Carpet', 'Baseboard'],
    note: 'Links only — it creates nothing, on purpose. A room in LEON Surfaces is real geometry (width, length, height and a set-out per surface); a take-off is a quantity with no dimensions, so building rooms from it would invent them. What this does instead is check coverage: which unit types and rooms in the take-off already have a type modelled in LEON Surfaces, and which do not.',
  },
];

const DWG_STATUS_TONE = {
  'Not Started': 'neutral', 'In Progress': 'yellow', 'Ready for Review': 'blue', 'Needs Review': 'blue',
  'Reviewed': 'blue', 'Approved': 'green', 'Complete': 'green',
  'Revision Review Required': 'red', 'Superseded': 'neutral', 'Current': 'green', 'Void': 'red',
};

// ── Plain honesty line ────────────────────────────────────────────────────
// Used wherever the workflow expects a capability this app genuinely does not
// have. It says what is missing AND what it would take, so nobody has to guess
// whether it is broken or simply not built.
function DwgNote({ children }) {
  return (
    <p className="text-xs text-[var(--leon-black)]/55 bg-[var(--leon-cream)] border border-dashed border-[var(--leon-line)] rounded-lg px-3 py-2">
      {children}
    </p>
  );
}

// ── Accessors ─────────────────────────────────────────────────────────────
// Every collection is read through one of these, so a project persisted before
// this module existed reads as empty instead of throwing.
function dwgSets(project) { return (project && project.drawingSets) || []; }
function dwgSheetList(project) { return (project && project.drawingSheets) || []; }
function dwgItemList(project) { return (project && project.takeoffItems) || []; }
function dwgProjectTools(project) { return (project && project.takeoffTools) || []; }
function dwgVersions(project) { return (project && project.bidVersions) || []; }
function dwgMaps(project) { return (project && project.revisionMaps) || []; }
function dwgChecklists(project) { return (project && project.takeoffChecklists) || {}; }
function dwgAllTools(project) { return DWG_GLOBAL_TOOLS.concat(dwgProjectTools(project).filter(t => t.active !== false)); }
function dwgToolById(project, id) { return dwgAllTools(project).find(t => t.id === id) || null; }
function dwgSheetById(project, id) { return dwgSheetList(project).find(s => s.id === id) || null; }
function dwgSetById(project, id) { return dwgSets(project).find(s => s.id === id) || null; }
function dwgScopeById(project, id) { return ((project && project.scopes) || []).find(s => s.id === id) || null; }

function dwgNum(v) { const n = Number(v); return isFinite(n) ? n : 0; }
function dwgFmtQty(n, unit) {
  const v = dwgNum(n);
  const s = Math.abs(v) >= 100 || v === Math.round(v)
    ? Math.round(v).toLocaleString()
    : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
  return unit ? `${s} ${unit}` : s;
}
function dwgSigned(n, unit) { return `${dwgNum(n) > 0 ? '+' : ''}${dwgFmtQty(n, unit)}`; }

// A sheet with no calibration record is not "probably 1/4 inch" — it is
// unknown, and anything measured off it inherits that. This predicate is the
// single definition, so the warning cannot appear in one screen and not another.
function dwgSheetCalibrated(sheet) {
  return !!(sheet && sheet.scale && sheet.scaleSource && sheet.scaleSource !== 'Not calibrated');
}

// ── Record factories ──────────────────────────────────────────────────────
// These live here rather than in data.jsx because this module owns the shapes;
// data.jsx is not edited by a software module.
function dwgMakeSheet(data, by) {
  return {
    id: uid('dsheet'), drawingSetId: data.drawingSetId || null,
    number: data.number || '', title: data.title || '',
    discipline: data.discipline || 'Architectural',
    revision: data.revision === undefined || data.revision === null ? '' : String(data.revision),
    scale: data.scale || '', scaleSource: data.scaleSource || 'Not calibrated',
    scaleConfidence: data.scaleConfidence || 'Unverified',
    scaleNote: data.scaleNote || '', calibratedBy: data.calibratedBy || null, calibratedDate: data.calibratedDate || null,
    status: data.status || 'Current',
    // The rendered source, and what was drawn on it. `pdf` holds only an ASSET
    // ID into the office-pdf-core IndexedDB store — never bytes, which would
    // take the app's localStorage down on the first real drawing set.
    pdf: data.pdf || null,                // { assetId, name, size, pageCount, pageIndex, sourceSetId, attachedBy, attachedDate }
    pageCalibrations: data.pageCalibrations || {},   // pageIndex -> dwgMakeCalibration
    measurements: Array.isArray(data.measurements) ? data.measurements : [],
    tags: Array.isArray(data.tags) ? data.tags : [],
    reviewedBy: [],                       // [{id, userId, name, scopeId, date}]
    scopeStatus: {},                      // scopeId -> Scope Matrix cell
    takeoffStatus: data.takeoffStatus || 'Not Started',
    supersedesSheetId: data.supersedesSheetId || null,
    note: data.note || '',
    addedBy: by || '', addedDate: todayISO(),
  };
}

function dwgMakeItem(data, by) {
  return {
    id: uid('toitem'),
    projectId: data.projectId || null, scopeId: data.scopeId || null,
    drawingSetId: data.drawingSetId || null, sheetId: data.sheetId || null,
    // The revision the measurement was taken FROM, copied at entry. Comparing
    // it to the sheet's current revision is the whole revision-impact check.
    sheetRevision: data.sheetRevision || '',
    toolId: data.toolId || null, toolName: data.toolName || '',
    measurementType: data.measurementType || 'Area',
    rawMeasure: data.rawMeasure === undefined || data.rawMeasure === null ? null : Number(data.rawMeasure),
    unit: data.unit || 'SF',
    wastePct: data.wastePct === undefined || data.wastePct === null ? 0 : Number(data.wastePct),
    // netQty and procurementQty are DERIVED (dwgItemQty), never stored. A total
    // kept beside its own inputs is exactly the thing that goes stale and puts
    // a wrong number on a bid — which is what this module exists to prevent.
    finishRef: data.finishRef || null, materialId: data.materialId || null,
    code: data.code || '',                // mark / code — the handoff match key
    building: data.building || '', floor: data.floor || '',
    unitType: data.unitType || '',
    unitNo: data.unitNo || '',            // the apartment/unit; `unit` above is the unit of measure
    room: data.room || '', surface: data.surface || '',
    status: data.status || 'Not Started',
    included: data.included || 'Included',
    // Type multiplier. `typical` means "this measurement stands for every unit
    // of unitType"; typicalCount is how many. An override row points at the
    // typical it replaces and contributes only its DIFFERENCE.
    typical: !!data.typical, typicalCount: dwgNum(data.typicalCount) || 1,
    overrideOfId: data.overrideOfId || null,
    bidVersionId: data.bidVersionId || null,
    notes: data.notes || '',
    active: true,
    preRevisionStatus: null, revisionMapId: null,
    handoff: null,
    createdBy: by || '', createdDate: todayISO(),
    updatedBy: by || '', updatedDate: todayISO(),
    history: [],
  };
}

// A tool carries its WHOLE property set, and stamps all of it onto the markup it
// places. That is the mechanism by which two estimators on one job produce
// comparable take-offs without having to coordinate: they pick the same tool and
// the subject, layer, colour, depth and custom values arrive with it.
function dwgMakeTool(data, by) {
  return {
    id: uid('totool'), name: data.name || '', scope: data.scope || 'Other',
    measurementType: data.measurementType || 'Area', unit: data.unit || 'SF',
    wastePct: dwgNum(data.wastePct), color: data.color || '#7b6b5c',
    finishRef: data.finishRef || null, materialId: data.materialId || null,
    sfPerBox: data.sfPerBox === undefined || data.sfPerBox === null ? null : Number(data.sfPerBox),
    // ── what it stamps on a markup ───────────────────────────────────────
    subject: data.subject || data.name || '',
    layer: data.layer || '',
    labelTemplate: data.labelTemplate || '',
    lineWeight: dwgNum(data.lineWeight) || 2,
    // How it draws. 'count' turns any tool into a count tool — that is what
    // "Create Count" does, and the symbol is what the pin is drawn as.
    drawKind: data.drawKind || (data.measurementType === 'Count' ? 'count'
      : data.measurementType === 'Length' ? 'distance' : 'area'),
    countSymbol: data.countSymbol || '',
    depthValue: data.depthValue === undefined || data.depthValue === null ? null : Number(data.depthValue),
    depthUnit: data.depthUnit || 'IN',
    slopeMode: data.slopeMode || '', slopeValue: data.slopeValue === undefined || data.slopeValue === null ? null : Number(data.slopeValue),
    custom: data.custom || {},           // customColumnId -> default value
    project: true, active: true, createdBy: by || '', createdDate: todayISO(),
  };
}
// The shared tools live in code and predate every field above, so they are read
// through this rather than being rewritten — a global tool gets sensible stamps
// without DWG_GLOBAL_TOOLS having to repeat them all.
function dwgToolProps(t) {
  if (!t) return null;
  return {
    id: t.id, name: t.name, color: t.color, scope: t.scope,
    measurementType: t.measurementType, unit: t.unit, wastePct: dwgNum(t.wastePct),
    subject: t.subject || t.name || '', layer: t.layer || '',
    labelTemplate: t.labelTemplate || '',
    lineWeight: dwgNum(t.lineWeight) || 2,
    drawKind: t.drawKind || (t.measurementType === 'Count' ? 'count'
      : t.measurementType === 'Length' ? 'distance' : 'area'),
    countSymbol: t.countSymbol || '',
    depthValue: t.depthValue === undefined ? null : t.depthValue,
    depthUnit: t.depthUnit || 'IN',
    slopeMode: t.slopeMode || '', slopeValue: t.slopeValue === undefined ? null : t.slopeValue,
    custom: t.custom || {},
  };
}

function dwgMakeVersion(data, by) {
  return {
    id: uid('bidver'), name: data.name || 'Bid', createdDate: todayISO(), createdBy: by || '',
    locked: false, lockedDate: null, lockedBy: null, note: data.note || '',
  };
}

// ── Quantity arithmetic ───────────────────────────────────────────────────
// One function, used by the roll-up, the drill-down, the material requirement
// and the change report — so the arithmetic shown on screen is literally the
// arithmetic that produced the number.
function dwgItemLive(item) { return !!item && item.active !== false && item.status !== 'Superseded'; }

// A snapshot key is the label, a pipe, then the unit. A tool name someone
// typed could itself contain a pipe, so the split is from the RIGHT — the unit
// is the last field, never the first.
function dwgSplitKey(k) {
  const i = String(k).lastIndexOf('|');
  return i < 0 ? [String(k), ''] : [String(k).slice(0, i), String(k).slice(i + 1)];
}

function dwgItemQty(item, allItems) {
  const raw = dwgNum(item.rawMeasure);
  const occ = item.typical ? Math.max(1, Math.round(dwgNum(item.typicalCount)) || 1) : 1;
  const base = raw * occ;
  // An override replaces ONE occurrence that `base` has already counted, so only
  // the difference is added. Adding the override's own measure would count that
  // unit twice — the exact double-count the spec calls out.
  const overrides = (allItems || []).filter(o => o.overrideOfId === item.id && dwgItemLive(o));
  const delta = overrides.reduce((a, o) => a + (dwgNum(o.rawMeasure) - raw), 0);
  const net = base + delta;
  const waste = dwgNum(item.wastePct);
  return {
    raw, occ, base, delta, overrides, overrideCount: overrides.length, net, waste,
    procurement: net * (1 + waste / 100),
  };
}

// The sentence the roll-up shows under a typical row. Written out in full
// because "3,655 SF" on its own is not auditable and "×42" in a tooltip is not
// either — the arithmetic is the point.
function dwgMultiplierSentence(item, q) {
  const label = [item.unitType, item.room, item.surface].filter(Boolean).join(' ') || (item.toolName || 'Item');
  if (!item.typical) return `${label} ${dwgFmtQty(q.raw, item.unit)}`;
  let s = `${label} ${dwgFmtQty(q.raw, item.unit)} × ${q.occ} unit${q.occ === 1 ? '' : 's'} = ${dwgFmtQty(q.base, item.unit)}`;
  if (q.overrideCount) s += `, overrides ${dwgSigned(q.delta, item.unit)} (${q.overrideCount})`;
  s += `, final ${dwgFmtQty(q.net, item.unit)}`;
  return s;
}

// Roll-up. `keyOf` decides the grouping; every group keeps its member rows, so
// no total can exist without the records behind it.
function dwgRollUp(items, keyOf, labelOf) {
  const groups = {};
  items.filter(i => dwgItemLive(i) && !i.overrideOfId).forEach(i => {
    const k = `${keyOf(i)}\u0000${i.unit}`;
    if (!groups[k]) groups[k] = { key: k, label: labelOf(i), unit: i.unit, net: 0, procurement: 0, items: [], uncalibrated: 0 };
    const q = dwgItemQty(i, items);
    groups[k].net += q.net;
    groups[k].procurement += q.procurement;
    groups[k].items.push({ item: i, q });
  });
  return Object.keys(groups).map(k => groups[k]).sort((a, b) => a.label.localeCompare(b.label) || a.unit.localeCompare(b.unit));
}

// ── Mutation ──────────────────────────────────────────────────────────────
// Backfills every collection this module owns before the mutator runs, so a
// project saved long before LEON Take-off existed never needs a migration pass.
// The change-log line goes through ctx.logAction — app.jsx's logAction is
// declared inside App() and is NOT a global, so calling it bare from a module
// file throws inside the updateProject callback and the whole mutation is lost
// silently. Anything declared in App() has to arrive via ctx.
function dwgUpdate(ctx, projectId, mutator, logText) {
  ctx.updateProject(projectId, draft => {
    if (!Array.isArray(draft.drawingSheets)) draft.drawingSheets = [];
    if (!Array.isArray(draft.takeoffItems)) draft.takeoffItems = [];
    if (!Array.isArray(draft.takeoffTools)) draft.takeoffTools = [];
    if (!Array.isArray(draft.bidVersions)) draft.bidVersions = [];
    if (!Array.isArray(draft.revisionMaps)) draft.revisionMaps = [];
    if (!Array.isArray(draft.drawingSets)) draft.drawingSets = [];
    if (!Array.isArray(draft.takeoffColumns)) draft.takeoffColumns = [];
    if (!Array.isArray(draft.takeoffColumnSets)) draft.takeoffColumnSets = [];
    if (!Array.isArray(draft.takeoffFilterSets)) draft.takeoffFilterSets = [];
    if (!Array.isArray(draft.doors)) draft.doors = [];
    if (!draft.takeoffChecklists || typeof draft.takeoffChecklists !== 'object') draft.takeoffChecklists = {};
    mutator(draft);
    if (logText && ctx.logAction) ctx.logAction(draft, logText);
  });
}

// A locked bid version is history. Editing a row that belongs to one has to
// produce a comparison, not a silent rewrite — so every write path asks here
// first and the caller offers to branch instead.
function dwgVersionLocked(project, versionId) {
  const v = dwgVersions(project).find(x => x.id === versionId);
  return !!(v && v.locked);
}

// ── Shared bits ───────────────────────────────────────────────────────────
function DwgStat({ label, value, sub, tone }) {
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40">{label}</div>
      <div className={`text-2xl font-bold ${tone === 'warn' ? 'text-[var(--leon-red)]' : 'text-[var(--leon-brown)]'}`}>{value}</div>
      {sub && <div className="text-[11px] text-[var(--leon-black)]/45 mt-0.5">{sub}</div>}
    </div>
  );
}

function DwgChip({ active, onClick, children, count }) {
  return (
    <button onClick={onClick}
      className={`px-2.5 py-1 rounded-lg text-xs font-semibold border transition whitespace-nowrap ${active
        ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)] text-[var(--leon-brown)]'
        : 'border-[var(--leon-line)] text-[var(--leon-black)]/60 hover:text-[var(--leon-black)]'}`}>
      {children}{count === undefined ? '' : <span className="opacity-50 ml-1">{count}</span>}
    </button>
  );
}

// The uncalibrated marker. It follows the quantity, not the sheet, because by
// the time a number reaches a summary the sheet it came from is off screen.
function DwgScaleFlag({ sheet, short }) {
  if (dwgSheetCalibrated(sheet)) {
    return <span className="text-[10px] text-[var(--leon-black)]/45" title={`Scale ${sheet.scale} · ${sheet.scaleSource} · ${sheet.scaleConfidence} confidence`}>
      {short ? '📐' : `📐 ${sheet.scale}`}
    </span>;
  }
  return <span className="text-[10px] font-bold text-[var(--leon-red)]" title="This sheet has no scale on record — anything measured from it is unverified.">
    {short ? '⚠' : '⚠ no scale'}
  </span>;
}

// ── The module ────────────────────────────────────────────────────────────
function DrawingSoftware({ ctx }) {
  const [section, setSection] = useState('dashboard');
  const [projectId, setProjectId] = useState('');
  const [versionId, setVersionId] = useState('');
  const [focusSheetId, setFocusSheetId] = useState(null);
  // Set when a measurement is asked to show the record it produced, so the
  // Takeoff table can ring that row rather than leaving someone to find it.
  const [highlightItemId, setHighlightItemId] = useState(null);

  // Real jobs, plus this person's own unassigned workspace. A drawing often
  // arrives before there is a job to file it against — and sometimes never
  // becomes one — so the tool must not demand a project before it will open a
  // file. `toolProjects` is the same list every software should offer.
  const allForTools = typeof ctx.toolProjects === 'function' ? ctx.toolProjects() : (ctx.projects || []);
  const projects = ctx.deptProjects(allForTools);
  const scratchIds = new Set((ctx.scratchProjects || []).map(p => p.id));
  const project = projects.find(p => p.id === projectId) || null;
  const editable = ctx.canEdit('takeOffs');
  const editSets = ctx.canEdit('drawingSets');

  // Sections that are meaningless without a job. The Dashboard reads across
  // every project, so it never asks for one.
  const needsProject = section !== 'dashboard';

  function openSheet(pid, sheetId) {
    setProjectId(pid); setFocusSheetId(sheetId); setSection('sheets');
  }
  function openMeasure(pid, sheetId) {
    setProjectId(pid); setFocusSheetId(sheetId); setSection('measure');
  }

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <h2 className="text-xl font-bold">📏 LEON Take-off</h2>
          <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
            Drawing intelligence and quantity take-off. Every quantity is a record pinned to a sheet, a
            sheet revision, a scope, a bid version and a person — so a total can always be opened into
            the measurements behind it.
          </p>
        </div>
        <div className="flex items-end gap-2 flex-wrap">
          <Field label="Project">
            {/* The scratch project's real id is not one of the listed options,
                so the select has to be told to show the placeholder value for
                it — otherwise choosing the workspace silently snaps the box
                back to "select a project". */}
            <Select className="!w-60" value={scratchIds.has(projectId) ? '__scratch' : projectId} onChange={e => {
              const v = e.target.value;
              // Created only when it is actually wanted, so nobody carries an
              // empty workspace they never asked for.
              const id = v === '__scratch' && typeof ctx.myScratchProject === 'function'
                ? ctx.myScratchProject().id : v;
              setProjectId(id); setVersionId(''); setFocusSheetId(null);
            }}>
              <option value="">— select a project —</option>
              {projects.filter(p => !scratchIds.has(p.id)).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
              <option value="__scratch">— Not linked to a job (my workspace) —</option>
            </Select>
          </Field>
          {project && (
            <Field label="Bid version">
              <Select className="!w-44" value={versionId} onChange={e => setVersionId(e.target.value)}>
                <option value="">All versions</option>
                {dwgVersions(project).map(v => (
                  <option key={v.id} value={v.id}>{v.name}{v.locked ? ' 🔒' : ''}</option>
                ))}
              </Select>
            </Field>
          )}
        </div>
      </div>


      <SoftwareRail swKey="drawings" sections={DWG_SECTIONS} active={section}
        onSelect={setSection}>
      {needsProject && !project ? (
        <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-8 text-center">
          <div className="text-3xl mb-2">📐</div>
          <div className="font-semibold mb-1">Pick a project</div>
          <div className="text-sm text-[var(--leon-black)]/55">
            Sheets and takeoffs belong to a job. The Dashboard reads across all of them.
          </div>
        </div>
      ) : (
        <>
          {section === 'dashboard' && <DwgDashboard ctx={ctx} projects={projects} onOpen={(pid, s) => { setProjectId(pid); setSection(s); }} />}
          {section === 'log' && <DwgDrawingLog ctx={ctx} project={project} editable={editSets} onOpenSheet={id => openSheet(project.id, id)} />}
          {section === 'sheets' && <DwgSheetWorkspace ctx={ctx} project={project} editable={editSets} takeoffEditable={editable}
            focusSheetId={focusSheetId} setFocusSheetId={setFocusSheetId} versionId={versionId}
            onMeasure={id => openMeasure(project.id, id)} />}
          {section === 'measure' && <DwgMeasureSection ctx={ctx} project={project} editable={editable} versionId={versionId}
            focusSheetId={focusSheetId} setFocusSheetId={setFocusSheetId}
            onOpenRecord={id => { setHighlightItemId(id); setSection('takeoff'); }} />}
          {section === 'markups' && <DwgMarkupsSection ctx={ctx} project={project} editable={editable}
            onOpenSheet={id => openSheet(project.id, id)} />}
          {section === 'takeoff' && <DwgTakeoffSection ctx={ctx} project={project} editable={editable} versionId={versionId}
            setVersionId={setVersionId} onOpenSheet={id => openSheet(project.id, id)}
            onMeasure={id => openMeasure(project.id, id)}
            highlightItemId={highlightItemId} clearHighlight={() => setHighlightItemId(null)} />}
          {section === 'summary' && <DwgQuantitySummary ctx={ctx} project={project} versionId={versionId} editable={editable}
            onOpenSheet={id => openSheet(project.id, id)} />}
          {section === 'matrix' && <DwgScopeMatrix ctx={ctx} project={project} editable={editable} onOpenSheet={id => openSheet(project.id, id)} />}
          {section === 'revisions' && <DwgRevisions ctx={ctx} project={project} editable={editable} onOpenSheet={id => openSheet(project.id, id)} />}
          {section === 'handoff' && <DwgHandoff ctx={ctx} project={project} editable={editable} versionId={versionId} />}
        </>
      )}
      </SoftwareRail>
    </div>
  );
}

// ── Dashboard ─────────────────────────────────────────────────────────────
// Reads across every project in the active department. The two numbers that
// earn their place here are the ones that cost money when ignored: quantities
// taken off sheets with no scale on record, and quantities sitting in the
// revision-review queue.
function DwgDashboard({ ctx, projects, onOpen }) {
  const rows = projects.map(p => {
    const sheets = dwgSheetList(p);
    const items = dwgItemList(p).filter(dwgItemLive);
    const uncal = items.filter(i => !dwgSheetCalibrated(dwgSheetById(p, i.sheetId)));
    const flagged = dwgItemList(p).filter(i => i.status === 'Revision Review Required');
    const complete = sheets.filter(s => s.takeoffStatus === 'Complete').length;
    return {
      p, sets: dwgSets(p).length, sheets, items, uncal, flagged, complete,
      calibrated: sheets.filter(dwgSheetCalibrated).length,
    };
  }).filter(r => r.sets || r.sheets.length || r.items.length);

  const totals = rows.reduce((a, r) => ({
    sheets: a.sheets + r.sheets.length, calibrated: a.calibrated + r.calibrated,
    items: a.items + r.items.length, uncal: a.uncal + r.uncal.length, flagged: a.flagged + r.flagged.length,
  }), { sheets: 0, calibrated: 0, items: 0, uncal: 0, flagged: 0 });

  return (
    <div className="space-y-4">
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
        <DwgStat label="Jobs with drawings" value={rows.length} />
        <DwgStat label="Sheets indexed" value={totals.sheets} sub={`${totals.calibrated} with a scale on record`} />
        <DwgStat label="Takeoff records" value={totals.items} />
        <DwgStat label="From uncalibrated sheets" value={totals.uncal} tone={totals.uncal ? 'warn' : undefined}
          sub="Measured off a sheet with no scale recorded" />
        <DwgStat label="Awaiting revision review" value={totals.flagged} tone={totals.flagged ? 'warn' : undefined}
          sub="Taken from a sheet that has since been superseded" />
      </div>

      <DwgNote>
        A quantity can be typed in on the Takeoff tab or measured on the drawing itself on
        <b> On-Sheet Measure</b>, which renders the sheet with pdf.js — both produce the same record, held
        against its sheet, revision, scale and author. What is <b>not</b> here: no symbol recognition, no
        auto-count of a legend symbol, no snapping to the drawing's own linework and no AI plan reading.
        A rendered page and its text layer are all a browser is given; the CAD geometry is not in it.
      </DwgNote>

      {!rows.length && <EmptyState text="No drawing sheets indexed on any job yet. Pick a project and start in the Drawing Log." />}

      <div className="grid gap-3 md:grid-cols-2">
        {rows.map(r => (
          <div key={r.p.id} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
            <div className="flex items-start justify-between gap-2">
              <div className="min-w-0">
                <div className="font-bold truncate">{r.p.name}</div>
                <div className="text-xs text-[var(--leon-black)]/50">
                  {r.sets} set{r.sets === 1 ? '' : 's'} · {r.sheets.length} sheet{r.sheets.length === 1 ? '' : 's'} · {r.items.length} takeoff record{r.items.length === 1 ? '' : 's'}
                </div>
              </div>
              <div className="flex gap-1 shrink-0">
                <Button size="sm" variant="ghost" onClick={() => onOpen(r.p.id, 'log')}>Log</Button>
                <Button size="sm" variant="ghost" onClick={() => onOpen(r.p.id, 'takeoff')}>Takeoff</Button>
              </div>
            </div>
            <div className="flex flex-wrap gap-1 mt-2">
              <Badge tone={r.calibrated === r.sheets.length && r.sheets.length ? 'green' : 'yellow'}>
                {r.calibrated}/{r.sheets.length} calibrated
              </Badge>
              <Badge tone="neutral">{r.complete}/{r.sheets.length} sheets done</Badge>
              {!!r.uncal.length && <Badge tone="red">{r.uncal.length} unverified qty</Badge>}
              {!!r.flagged.length && <Badge tone="red">{r.flagged.length} revision review</Badge>}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Drawing Log ───────────────────────────────────────────────────────────
// The sheet index. A drawing SET is the delivery (a bid set, an addendum, an
// IFC issue); a SHEET is what a measurement is actually taken from. Sets reuse
// the project's existing `drawingSets` records — the ones the Drawing Sets tab
// already manages — and this module only ADDS fields to them (issueType,
// issueDate, issueState). It never keeps a second copy of a set.
//
// Note on status vocabularies: a set already has Active/Void, which is about
// whether the record stands. Current/Superseded is a different question — has a
// newer issue replaced it — so it is a separate field rather than a value
// crammed into the existing one.
function DwgDrawingLog({ ctx, project, editable, onOpenSheet }) {
  const [q, setQ] = useState('');
  const [setFilter, setSetFilter] = useState('');
  const [discFilter, setDiscFilter] = useState('');
  const [statusFilter, setStatusFilter] = useState('');
  const [adding, setAdding] = useState(false);
  const [bulk, setBulk] = useState(false);
  const [reviewFor, setReviewFor] = useState(null);

  const sets = dwgSets(project);
  const sheets = dwgSheetList(project);
  const items = dwgItemList(project);

  const rows = sheets.filter(s => {
    if (setFilter && s.drawingSetId !== setFilter) return false;
    if (discFilter && s.discipline !== discFilter) return false;
    if (statusFilter && s.status !== statusFilter) return false;
    if (!q.trim()) return true;
    const hay = `${s.number} ${s.title} ${s.discipline} ${(s.tags || []).join(' ')}`.toLowerCase();
    return hay.includes(q.trim().toLowerCase());
  }).sort((a, b) => (a.number || '').localeCompare(b.number || '', undefined, { numeric: true }));

  function setSheet(id, fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === id);
      if (!s) return;
      Object.assign(s, fields);
    }, why);
  }
  function removeSheet(id) {
    const s = dwgSheetById(project, id);
    const used = items.filter(i => i.sheetId === id && dwgItemLive(i)).length;
    if (used) { alert(`${used} takeoff record${used === 1 ? ' is' : 's are'} measured from ${s.number}. Mark the sheet Void instead — removing it would orphan them.`); return; }
    if (!confirm(`Remove sheet ${s.number} from the index?`)) return;
    dwgUpdate(ctx, project.id, draft => {
      draft.drawingSheets = draft.drawingSheets.filter(x => x.id !== id);
    }, `Take-off: removed sheet ${s.number} from the drawing log.`);
  }

  return (
    <div className="space-y-4">
      <Collapsible id={`dwg-sets-${project.id}`} title="Drawing sets received" count={sets.length} defaultOpen>
        <DwgNote>
          These are the project's own drawing set records — the same ones the Drawing Sets tab manages.
          Issue type, issue date and whether a set has been superseded are added here; nothing is copied.
        </DwgNote>
        {!sets.length ? <EmptyState text="No drawing sets on this job yet. They are added in the project's Drawing Sets tab." /> : (
          <div className="overflow-x-auto mt-2">
            <table className="w-full text-xs">
              <thead>
                <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                  <th className="px-2 py-2">Set</th><th className="px-2 py-2">Issue type</th><th className="px-2 py-2">Rev</th>
                  <th className="px-2 py-2">Issued</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Source</th>
                  <th className="px-2 py-2">State</th><th className="px-2 py-2">Sheets</th>
                </tr>
              </thead>
              <tbody>
                {sets.map(d => {
                  const mine = sheets.filter(s => s.drawingSetId === d.id);
                  return (
                    <tr key={d.id} className="border-b border-[var(--leon-line)]/60">
                      <td className="px-2 py-1.5 font-semibold">
                        {d.name || 'Drawing set'}
                        {d.status === 'Void' && <Badge tone="red">Void</Badge>}
                      </td>
                      <td className="px-2 py-1.5">
                        <select value={d.issueType || ''} disabled={!editable}
                          onChange={e => dwgUpdate(ctx, project.id, draft => {
                            const t = draft.drawingSets.find(x => x.id === d.id); if (t) t.issueType = e.target.value || null;
                          }, `Take-off: "${d.name}" classified as ${e.target.value || 'unclassified'}.`)}
                          className="px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          <option value="">— not classified —</option>
                          {DWG_ISSUE_TYPES.map(t => <option key={t}>{t}</option>)}
                        </select>
                      </td>
                      <td className="px-2 py-1.5">{d.revision}</td>
                      <td className="px-2 py-1.5">
                        <input type="date" value={d.issueDate || ''} disabled={!editable}
                          onChange={e => dwgUpdate(ctx, project.id, draft => {
                            const t = draft.drawingSets.find(x => x.id === d.id); if (t) t.issueDate = e.target.value || null;
                          })}
                          className="px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white" />
                      </td>
                      <td className="px-2 py-1.5">{fmtDate(d.dateReceived)}</td>
                      <td className="px-2 py-1.5 text-[var(--leon-black)]/60">{d.source || '—'}</td>
                      <td className="px-2 py-1.5">
                        <Badge tone={DWG_STATUS_TONE[d.issueState || 'Current']}>{d.issueState || 'Current'}</Badge>
                      </td>
                      <td className="px-2 py-1.5">{mine.length}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        )}
      </Collapsible>

      <div className="flex items-end gap-2 flex-wrap">
        <TextInput className="!w-56" value={q} onChange={e => setQ(e.target.value)} placeholder="Search number, title, tag…" />
        <Select className="!w-44" value={setFilter} onChange={e => setSetFilter(e.target.value)}>
          <option value="">All sets</option>
          {sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
        </Select>
        <Select className="!w-40" value={discFilter} onChange={e => setDiscFilter(e.target.value)}>
          <option value="">All disciplines</option>
          {DWG_DISCIPLINES.map(d => <option key={d}>{d}</option>)}
        </Select>
        <Select className="!w-36" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
          <option value="">All statuses</option>
          {DWG_SHEET_STATUSES.map(s => <option key={s}>{s}</option>)}
        </Select>
        <div className="ml-auto flex gap-2">
          {editable && <Button size="sm" variant="outline" onClick={() => setBulk(true)}>Paste a sheet list</Button>}
          {editable && <Button size="sm" onClick={() => setAdding(true)}>+ Add sheet</Button>}
        </div>
      </div>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[1080px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <th className="px-2 py-2">Number</th><th className="px-2 py-2">Title</th><th className="px-2 py-2">Discipline</th>
              <th className="px-2 py-2">Rev</th><th className="px-2 py-2">Scale</th><th className="px-2 py-2">Status</th>
              <th className="px-2 py-2">Tags</th><th className="px-2 py-2">Takeoff</th><th className="px-2 py-2">Reviewed for</th>
              <th className="px-2 py-2">Qty</th><th className="px-2 py-2 w-8"></th>
            </tr>
          </thead>
          <tbody>
            {rows.map(s => {
              const mine = items.filter(i => i.sheetId === s.id && dwgItemLive(i));
              return (
                <tr key={s.id} className={`border-b border-[var(--leon-line)]/60 ${s.status === 'Superseded' ? 'opacity-55' : ''}`}>
                  <td className="px-2 py-1.5 font-bold whitespace-nowrap">
                    <button className="text-[var(--leon-brown)] hover:underline" onClick={() => onOpenSheet(s.id)}>{s.number || '—'}</button>
                  </td>
                  <td className="px-2 py-1.5">
                    <input value={s.title} disabled={!editable} onChange={e => setSheet(s.id, { title: e.target.value })}
                      className="w-52 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1.5">
                    <select value={s.discipline} disabled={!editable} onChange={e => setSheet(s.id, { discipline: e.target.value })}
                      className="px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                      {DWG_DISCIPLINES.map(d => <option key={d}>{d}</option>)}
                    </select>
                  </td>
                  <td className="px-2 py-1.5">
                    <input value={s.revision || ''} disabled={!editable} onChange={e => setSheet(s.id, { revision: e.target.value })}
                      className="w-12 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1.5 whitespace-nowrap"><DwgScaleFlag sheet={s} /></td>
                  <td className="px-2 py-1.5">
                    <Badge tone={DWG_STATUS_TONE[s.status]}>{s.status}</Badge>
                  </td>
                  <td className="px-2 py-1.5">
                    <input value={(s.tags || []).join(', ')} disabled={!editable}
                      onChange={e => setSheet(s.id, { tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean) })}
                      placeholder="tag, tag" className="w-32 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1.5">
                    <select value={s.takeoffStatus} disabled={!editable} onChange={e => setSheet(s.id, { takeoffStatus: e.target.value },
                      `Take-off: sheet ${s.number} takeoff status set to ${e.target.value}.`)}
                      className="px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                      {DWG_TAKEOFF_STATUSES.map(t => <option key={t}>{t}</option>)}
                    </select>
                  </td>
                  <td className="px-2 py-1.5">
                    <div className="flex flex-wrap gap-1 items-center">
                      {(s.reviewedBy || []).map(r => {
                        const sc = dwgScopeById(project, r.scopeId);
                        return <span key={r.id} title={`${r.name} on ${fmtDate(r.date)}`}
                          className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--leon-cream)] border border-[var(--leon-line)]">
                          {sc ? sc.name : 'Whole sheet'} · {(r.name || '').split(' ')[0]}
                        </span>;
                      })}
                      {editable && <button onClick={() => setReviewFor(s)} className="text-[10px] text-[var(--leon-brown)] font-semibold">+ mark reviewed</button>}
                    </div>
                  </td>
                  <td className="px-2 py-1.5 text-[var(--leon-black)]/60">{mine.length}</td>
                  <td className="px-2 py-1.5">
                    {editable && <IconBtn title="Remove from the index" onClick={() => removeSheet(s.id)}>✕</IconBtn>}
                  </td>
                </tr>
              );
            })}
            {!rows.length && <tr><td colSpan={11} className="px-3 py-6 text-center text-[var(--leon-black)]/40">No sheets match. Add them one at a time, or paste a sheet list.</td></tr>}
          </tbody>
        </table>
      </div>

      <DwgAddSheetModal open={adding} onClose={() => setAdding(false)} ctx={ctx} project={project} />
      <DwgBulkSheetsModal open={bulk} onClose={() => setBulk(false)} ctx={ctx} project={project} />
      <DwgReviewModal sheet={reviewFor} onClose={() => setReviewFor(null)} ctx={ctx} project={project} />
    </div>
  );
}

function DwgAddSheetModal({ open, onClose, ctx, project }) {
  const sets = dwgSets(project);
  const blank = { drawingSetId: sets.length ? sets[0].id : '', number: '', title: '', discipline: 'Architectural', revision: '', scale: '', note: '' };
  const [form, setForm] = useState(blank);
  useEffect(() => { if (open) setForm(blank); }, [open]);
  function submit() {
    if (!form.number.trim()) return;
    dwgUpdate(ctx, project.id, draft => {
      draft.drawingSheets.push(dwgMakeSheet({
        ...form, drawingSetId: form.drawingSetId || null,
        // A scale typed in at entry is a person's reading of the title block, so
        // it is recorded as Manual — never as Detected, which this app cannot do.
        scale: form.scale, scaleSource: form.scale ? 'Manual' : 'Not calibrated',
        scaleConfidence: form.scale ? 'Medium' : 'Unverified',
      }, ctx.currentUserName));
    }, `Take-off: added sheet ${form.number} to the drawing log.`);
    onClose();
  }
  return (
    <Modal open={open} onClose={onClose} wide title="Add a sheet" footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={submit} disabled={!form.number.trim()}>Add sheet</Button>
    </>}>
      <div className="space-y-3">
        <Field label="Drawing set" hint="Which delivery this sheet came in with.">
          <Select value={form.drawingSetId} onChange={e => setForm({ ...form, drawingSetId: e.target.value })}>
            <option value="">— not filed to a set —</option>
            {sets.map(s => <option key={s.id} value={s.id}>{s.name} (Rev {s.revision})</option>)}
          </Select>
        </Field>
        <div className="grid grid-cols-2 gap-3">
          <Field label="Sheet number"><TextInput value={form.number} onChange={e => setForm({ ...form, number: e.target.value })} placeholder="A-101" autoFocus /></Field>
          <Field label="Revision"><TextInput value={form.revision} onChange={e => setForm({ ...form, revision: e.target.value })} placeholder="3" /></Field>
        </div>
        <Field label="Title"><TextInput value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="Level 2 Floor Plan" /></Field>
        <div className="grid grid-cols-2 gap-3">
          <Field label="Discipline">
            <Select value={form.discipline} onChange={e => setForm({ ...form, discipline: e.target.value })}>
              {DWG_DISCIPLINES.map(d => <option key={d}>{d}</option>)}
            </Select>
          </Field>
          <Field label="Scale" hint="Read off the title block. Leave blank if you have not checked it.">
            <Select value={form.scale} onChange={e => setForm({ ...form, scale: e.target.value })}>
              <option value="">— not calibrated —</option>
              {DWG_SCALE_PRESETS.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
        </div>
        <Field label="Note"><TextArea rows={2} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></Field>
      </div>
    </Modal>
  );
}

// Bulk add. A sheet index is nearly always copied out of a PDF's index page or
// a consultant's transmittal, so the parser is deliberately forgiving: tabs,
// commas or a run of spaces all separate columns, and only the first is required.
function dwgParseSheetLines(text) {
  return String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean).map(line => {
    const parts = line.split(/\t|\s{2,}|,(?=\s)|,/).map(p => p.trim()).filter(p => p !== '');
    const [number, title, third, fourth] = parts;
    const disc = DWG_DISCIPLINES.find(d => d.toLowerCase() === String(third || '').toLowerCase());
    return {
      number: number || '', title: title || '',
      discipline: disc || '',
      revision: disc ? (fourth || '') : (third || ''),
      raw: line,
    };
  }).filter(r => r.number);
}

function DwgBulkSheetsModal({ open, onClose, ctx, project }) {
  const sets = dwgSets(project);
  const [text, setText] = useState('');
  const [setId, setSetId] = useState(sets.length ? sets[0].id : '');
  const [disc, setDisc] = useState('Architectural');
  useEffect(() => { if (open) { setText(''); setSetId(sets.length ? sets[0].id : ''); } }, [open]);
  const parsed = dwgParseSheetLines(text);
  const existing = dwgSheetList(project).map(s => (s.number || '').toLowerCase());
  const fresh = parsed.filter(p => !existing.includes(p.number.toLowerCase()));
  const dupes = parsed.length - fresh.length;

  function submit() {
    if (!fresh.length) return;
    dwgUpdate(ctx, project.id, draft => {
      fresh.forEach(p => draft.drawingSheets.push(dwgMakeSheet({
        drawingSetId: setId || null, number: p.number, title: p.title,
        discipline: p.discipline || disc, revision: p.revision,
      }, ctx.currentUserName)));
    }, `Take-off: added ${fresh.length} sheet${fresh.length === 1 ? '' : 's'} to the drawing log by paste.`);
    onClose();
  }

  return (
    <Modal open={open} onClose={onClose} wide title="Paste a sheet list" footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={submit} disabled={!fresh.length}>
        {fresh.length ? `Add ${fresh.length} sheet${fresh.length === 1 ? '' : 's'}` : 'Nothing to add'}
      </Button>
    </>}>
      <div className="space-y-3">
        <DwgNote>
          One sheet per line. Columns can be separated by a tab, a comma or two or more spaces:
          <b> number, title, discipline, revision</b> — only the number is required. The index is not lifted
          out of the PDF for you: reading a title block means recognising a drawing, which is not something
          a rendered page and a text layer can do. Once the sheets are listed, On-Sheet Measure will search
          the PDF's text layer for a sheet number and offer the pages it appears on — a match to check, not
          an index it built.
        </DwgNote>
        <div className="grid grid-cols-2 gap-3">
          <Field label="File into set">
            <Select value={setId} onChange={e => setSetId(e.target.value)}>
              <option value="">— not filed to a set —</option>
              {sets.map(s => <option key={s.id} value={s.id}>{s.name} (Rev {s.revision})</option>)}
            </Select>
          </Field>
          <Field label="Default discipline" hint="Used for any line that does not name one.">
            <Select value={disc} onChange={e => setDisc(e.target.value)}>
              {DWG_DISCIPLINES.map(d => <option key={d}>{d}</option>)}
            </Select>
          </Field>
        </div>
        <Field label="Sheet list">
          <TextArea rows={8} value={text} onChange={e => setText(e.target.value)}
            placeholder={'A-101\tLevel 1 Floor Plan\tArchitectural\t3\nA-102\tLevel 2 Floor Plan\nID-201, Enlarged Bath Plans'} />
        </Field>
        {!!parsed.length && (
          <div className="rounded-lg border border-[var(--leon-line)] overflow-hidden">
            <div className="px-3 py-1.5 text-xs bg-[var(--leon-cream)] border-b border-[var(--leon-line)]">
              <b>{parsed.length}</b> line{parsed.length === 1 ? '' : 's'} read · <b>{fresh.length}</b> new
              {dupes ? <span className="text-[var(--leon-red)]"> · {dupes} already in the index and skipped</span> : ''}
            </div>
            <div className="max-h-48 overflow-y-auto">
              <table className="w-full text-xs">
                <tbody>
                  {parsed.map((p, i) => {
                    const dupe = existing.includes(p.number.toLowerCase());
                    return (
                      <tr key={i} className={`border-b border-[var(--leon-line)]/60 ${dupe ? 'opacity-45' : ''}`}>
                        <td className="px-2 py-1 font-bold w-24">{p.number}</td>
                        <td className="px-2 py-1">{p.title || <span className="text-[var(--leon-black)]/35">no title</span>}</td>
                        <td className="px-2 py-1 text-[var(--leon-black)]/50 w-28">{p.discipline || disc}</td>
                        <td className="px-2 py-1 w-12">{p.revision}</td>
                        <td className="px-2 py-1 w-20 text-right">{dupe ? <span className="text-[var(--leon-red)]">skip</span> : 'new'}</td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        )}
      </div>
    </Modal>
  );
}

// "Reviewed" is per SCOPE, not per sheet — a plan sheet gets read once for tile
// and again for baseboard, by different people, and the log has to say which.
function DwgReviewModal({ sheet, onClose, ctx, project }) {
  const [scopeId, setScopeId] = useState('');
  useEffect(() => { if (sheet) setScopeId(''); }, [sheet]);
  if (!sheet) return null;
  function submit() {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!Array.isArray(s.reviewedBy)) s.reviewedBy = [];
      s.reviewedBy.push({
        id: uid('shrev'), userId: ctx.currentUserId, name: ctx.currentUserName,
        scopeId: scopeId || null, date: todayISO(),
      });
    }, `Take-off: ${ctx.currentUserName} marked sheet ${sheet.number} reviewed${scopeId ? ` for ${(dwgScopeById(project, scopeId) || {}).name}` : ''}.`);
    onClose();
  }
  return (
    <Modal open={!!sheet} onClose={onClose} title={`Mark ${sheet.number} reviewed`} footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={submit}>Record review</Button>
    </>}>
      <div className="space-y-3">
        <p className="text-sm text-[var(--leon-black)]/60">
          Records that <b>{ctx.currentUserName}</b> read this sheet today, for the scope below. A sheet is
          reviewed once per scope, not once overall.
        </p>
        <Field label="For which scope">
          <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
            <option value="">Whole sheet (no particular scope)</option>
            {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
          </Select>
        </Field>
      </div>
    </Modal>
  );
}

// ── Sheets — the per-sheet workspace, and scale calibration ───────────────
// Calibration is a RECORD, not a setting: a value, where it came from, how
// confident whoever set it was, who set it and when. Every quantity measured
// off the sheet inherits that record, which is why an uncalibrated sheet is
// shouted about here and again on every total downstream.
function DwgSheetWorkspace({ ctx, project, editable, takeoffEditable, focusSheetId, setFocusSheetId, versionId, onMeasure }) {
  const sheets = dwgSheetList(project).slice()
    .sort((a, b) => (a.number || '').localeCompare(b.number || '', undefined, { numeric: true }));
  const [q, setQ] = useState('');
  const current = sheets.find(s => s.id === focusSheetId) || sheets[0] || null;
  const items = dwgItemList(project);
  const shown = sheets.filter(s => !q.trim() || `${s.number} ${s.title}`.toLowerCase().includes(q.trim().toLowerCase()));

  if (!sheets.length) return <EmptyState text="No sheets indexed yet — add them in the Drawing Log first." />;

  return (
    <div className="grid gap-4 lg:grid-cols-[260px_1fr]">
      <div className="space-y-2">
        <TextInput value={q} onChange={e => setQ(e.target.value)} placeholder="Find a sheet…" />
        <div className="rounded-lg border border-[var(--leon-line)] bg-white max-h-[70vh] overflow-y-auto">
          {shown.map(s => {
            const n = items.filter(i => i.sheetId === s.id && dwgItemLive(i)).length;
            const on = current && current.id === s.id;
            return (
              <button key={s.id} onClick={() => setFocusSheetId(s.id)}
                className={`w-full text-left px-3 py-2 border-b border-[var(--leon-line)]/60 last:border-b-0 ${on ? 'bg-[var(--leon-cream)]' : 'hover:bg-[var(--leon-cream)]/50'}`}>
                <div className="flex items-center justify-between gap-2">
                  <span className="font-bold text-xs">{s.number}</span>
                  <span className="flex items-center gap-1">
                    <DwgScaleFlag sheet={s} short />
                    {!!n && <span className="text-[10px] text-[var(--leon-black)]/45">{n}</span>}
                  </span>
                </div>
                <div className="text-[11px] text-[var(--leon-black)]/50 truncate">{s.title || 'Untitled'}</div>
              </button>
            );
          })}
          {!shown.length && <div className="px-3 py-4 text-xs text-[var(--leon-black)]/40">Nothing matches.</div>}
        </div>
      </div>

      {current && <DwgSheetDetail ctx={ctx} project={project} sheet={current} editable={editable}
        takeoffEditable={takeoffEditable} versionId={versionId} onMeasure={onMeasure} />}
    </div>
  );
}

function DwgSheetDetail({ ctx, project, sheet, editable, takeoffEditable, versionId, onMeasure }) {
  const [calOpen, setCalOpen] = useState(false);
  const items = dwgItemList(project).filter(i => i.sheetId === sheet.id
    && (!versionId || i.bidVersionId === versionId));
  const set = dwgSetById(project, sheet.drawingSetId);
  const scopes = project.scopes || [];

  function setSheet(fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (s) Object.assign(s, fields);
    }, why);
  }
  function setCell(scopeId, value) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!s.scopeStatus) s.scopeStatus = {};
      s.scopeStatus[scopeId] = value;
    }, `Take-off: ${sheet.number} × ${(dwgScopeById(project, scopeId) || {}).name} set to ${value}.`);
  }

  return (
    <div className="space-y-4">
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4">
        <div className="flex items-start justify-between gap-3 flex-wrap">
          <div>
            <div className="text-lg font-bold">{sheet.number} <span className="font-normal text-[var(--leon-black)]/60">{sheet.title}</span></div>
            <div className="text-xs text-[var(--leon-black)]/50">
              {sheet.discipline} · Rev {sheet.revision || '—'} · {set ? `${set.name}${set.issueType ? ` (${set.issueType})` : ''}` : 'no set'}
              {' · '}added by {sheet.addedBy || '—'} {fmtDate(sheet.addedDate)}
            </div>
          </div>
          <div className="flex items-center gap-2">
            {onMeasure && <Button size="sm" variant="outline" onClick={() => onMeasure(sheet.id)}>🖊️ Measure on this sheet</Button>}
            <Badge tone={DWG_STATUS_TONE[sheet.status]}>{sheet.status}</Badge>
            {editable && (
              <Select className="!w-36 !py-1 !text-xs" value={sheet.takeoffStatus}
                onChange={e => setSheet({ takeoffStatus: e.target.value }, `Take-off: sheet ${sheet.number} takeoff status set to ${e.target.value}.`)}>
                {DWG_TAKEOFF_STATUSES.map(t => <option key={t}>{t}</option>)}
              </Select>
            )}
          </div>
        </div>
      </div>

      {/* Scale calibration — a first-class record on the sheet. */}
      <div className={`rounded-lg border p-4 ${dwgSheetCalibrated(sheet) ? 'border-[var(--leon-line)] bg-white' : 'border-[var(--leon-red)]/40 bg-[#fbe7e7]/40'}`}>
        <div className="flex items-start justify-between gap-3 flex-wrap">
          <div>
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Scale calibration</div>
            {dwgSheetCalibrated(sheet) ? (
              <>
                <div className="text-xl font-bold text-[var(--leon-brown)]">{sheet.scale}</div>
                <div className="text-xs text-[var(--leon-black)]/55">
                  Source: <b>{sheet.scaleSource}</b> · confidence <b>{sheet.scaleConfidence}</b>
                  {sheet.calibratedBy ? ` · set by ${sheet.calibratedBy} ${fmtDate(sheet.calibratedDate)}` : ''}
                </div>
                {sheet.scaleNote && <div className="text-xs text-[var(--leon-black)]/50 mt-1">{sheet.scaleNote}</div>}
              </>
            ) : (
              <>
                <div className="text-xl font-bold text-[var(--leon-red)]">Not calibrated</div>
                <div className="text-xs text-[var(--leon-black)]/60">
                  No scale is on record for this sheet. Anything measured from it is carried through the
                  whole module marked unverified, including on the Quantity Summary.
                </div>
              </>
            )}
          </div>
          {editable && <Button size="sm" variant="outline" onClick={() => setCalOpen(true)}>
            {dwgSheetCalibrated(sheet) ? 'Change calibration' : 'Record the scale'}
          </Button>}
        </div>
        <div className="mt-3">
          <DwgNote>
            A scale typed in here is read off the title block by a person, so its source is <b>Manual</b>.
            Drawing a line over a known dimension on <b>On-Sheet Measure</b> records it as
            <b> Calibrated</b> — a measured fact rather than a reading, and the two are not the same claim,
            because a set plotted to fit the paper prints a scale that is simply wrong. <b>Detected</b> is
            kept for a scale a pipeline reports on its own; nothing sets it, and a scale found in the PDF's
            text layer is offered as a suggestion and lands as Manual, because a person still accepted it.
          </DwgNote>
        </div>
      </div>

      <Collapsible id={`dwg-sheet-scopes-${sheet.id}`} title="Scope review on this sheet" count={scopes.length} defaultOpen>
        {!scopes.length ? <EmptyState text="This job has no scopes yet." /> : (
          <div className="space-y-1.5">
            {scopes.map(sc => {
              const reviews = (sheet.reviewedBy || []).filter(r => r.scopeId === sc.id);
              const cell = (sheet.scopeStatus || {})[sc.id] || 'Not Started';
              const n = items.filter(i => i.scopeId === sc.id && dwgItemLive(i)).length;
              return (
                <div key={sc.id} className="flex items-center gap-2 flex-wrap border border-[var(--leon-line)] rounded-lg px-3 py-2">
                  <span className="font-semibold text-sm flex-1 min-w-[10rem]">{sc.name}</span>
                  <span className="text-xs text-[var(--leon-black)]/50">{n} record{n === 1 ? '' : 's'}</span>
                  {reviews.map(r => <span key={r.id} className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--leon-cream)] border border-[var(--leon-line)]">{r.name} · {fmtDate(r.date)}</span>)}
                  {editable ? (
                    <Select className="!w-36 !py-1 !text-xs" value={cell} onChange={e => setCell(sc.id, e.target.value)}>
                      {DWG_TAKEOFF_STATUSES.map(t => <option key={t}>{t}</option>)}
                    </Select>
                  ) : <Badge tone={DWG_STATUS_TONE[cell]}>{cell}</Badge>}
                </div>
              );
            })}
          </div>
        )}
      </Collapsible>

      <Collapsible id={`dwg-sheet-items-${sheet.id}`} title="Takeoff records measured from this sheet" count={items.length} defaultOpen>
        {!items.length ? <EmptyState text="Nothing measured from this sheet yet." /> : (
          <table className="w-full text-xs">
            <thead>
              <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                <th className="px-2 py-1.5">Scope</th><th className="px-2 py-1.5">Tool</th><th className="px-2 py-1.5">Location</th>
                <th className="px-2 py-1.5 text-right">Measure</th><th className="px-2 py-1.5 text-right">Net</th>
                <th className="px-2 py-1.5">Rev taken</th><th className="px-2 py-1.5">Status</th>
              </tr>
            </thead>
            <tbody>
              {items.map(i => {
                const qy = dwgItemQty(i, dwgItemList(project));
                const sc = dwgScopeById(project, i.scopeId);
                const stale = i.sheetRevision && sheet.revision && i.sheetRevision !== sheet.revision;
                return (
                  <tr key={i.id} className="border-b border-[var(--leon-line)]/60">
                    <td className="px-2 py-1.5">{sc ? sc.name : '—'}</td>
                    <td className="px-2 py-1.5">{i.toolName}</td>
                    <td className="px-2 py-1.5 text-[var(--leon-black)]/60">
                      {[i.building, i.floor, i.unitType, i.unitNo, i.room, i.surface].filter(Boolean).join(' · ') || '—'}
                    </td>
                    <td className="px-2 py-1.5 text-right whitespace-nowrap">{dwgFmtQty(qy.raw, i.unit)}{i.typical ? ` ×${qy.occ}` : ''}</td>
                    <td className="px-2 py-1.5 text-right font-semibold whitespace-nowrap">{dwgFmtQty(qy.net, i.unit)}</td>
                    <td className={`px-2 py-1.5 ${stale ? 'text-[var(--leon-red)] font-bold' : ''}`} title={stale ? `Sheet is now Rev ${sheet.revision}` : ''}>
                      {i.sheetRevision || '—'}{stale ? ' ⚠' : ''}
                    </td>
                    <td className="px-2 py-1.5"><Badge tone={DWG_STATUS_TONE[i.status]}>{i.status}</Badge></td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </Collapsible>

      <DwgCalibrateModal open={calOpen} onClose={() => setCalOpen(false)} ctx={ctx} project={project} sheet={sheet} />
    </div>
  );
}

function DwgCalibrateModal({ open, onClose, ctx, project, sheet }) {
  const [form, setForm] = useState({ scale: '', custom: '', confidence: 'Medium', note: '' });
  useEffect(() => {
    if (!open || !sheet) return;
    const preset = DWG_SCALE_PRESETS.includes(sheet.scale);
    setForm({
      scale: preset ? sheet.scale : (sheet.scale ? '__custom' : ''),
      custom: preset ? '' : (sheet.scale || ''),
      confidence: sheet.scaleConfidence || 'Medium', note: sheet.scaleNote || '',
    });
  }, [open, sheet]);
  if (!sheet) return null;
  const value = form.scale === '__custom' ? form.custom.trim() : form.scale;
  const affected = dwgItemList(project).filter(i => i.sheetId === sheet.id && dwgItemLive(i)).length;

  function save() {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      s.scale = value;
      s.scaleSource = value ? 'Manual' : 'Not calibrated';
      s.scaleConfidence = value ? form.confidence : 'Unverified';
      s.scaleNote = form.note;
      s.calibratedBy = value ? ctx.currentUserName : null;
      s.calibratedDate = value ? todayISO() : null;
    }, `Take-off: sheet ${sheet.number} scale ${value ? `recorded as ${value} (${form.confidence} confidence)` : 'cleared'}.`);
    onClose();
  }

  return (
    <Modal open={open} onClose={onClose} wide title={`Scale — ${sheet.number}`} footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={save}>{value ? 'Record scale' : 'Clear calibration'}</Button>
    </>}>
      <div className="space-y-3">
        <Field label="Scale">
          <Select value={form.scale} onChange={e => setForm({ ...form, scale: e.target.value })}>
            <option value="">— not calibrated —</option>
            {DWG_SCALE_PRESETS.map(s => <option key={s} value={s}>{s}</option>)}
            <option value="__custom">Something else…</option>
          </Select>
        </Field>
        {form.scale === '__custom' && (
          <Field label="Scale as printed"><TextInput value={form.custom} onChange={e => setForm({ ...form, custom: e.target.value })} placeholder={'3/32" = 1\'-0"'} /></Field>
        )}
        <Field label="Confidence" hint="Low is a legitimate answer — a plotted sheet that may not be to size is worth saying so.">
          <Select value={form.confidence} onChange={e => setForm({ ...form, confidence: e.target.value })}>
            {DWG_SCALE_CONFIDENCE.filter(c => c !== 'Unverified').map(c => <option key={c}>{c}</option>)}
          </Select>
        </Field>
        <Field label="Note" hint="e.g. read off the title block; plan says NTS but the graphic bar checks at 1/4."><TextArea rows={2} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></Field>
        {!!affected && (
          <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm">
            <b>{affected} takeoff record{affected === 1 ? '' : 's'}</b> already reference this sheet. Typing a
            scale here does not re-scale them — it changes whether they are reported as verified. Records
            that were <i>measured</i> on the sheet are recomputed from the calibration instead, and that is
            done deliberately from the measurement list on the On-Sheet Measure tab.
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── On-sheet measuring ────────────────────────────────────────────────────
// This is the second way to create the takeoff record above — not a second kind
// of record. Everything drawn here ends in dwgMakeItem, on the same sheet, at
// the same revision, in the same bid version, and is then read, rolled up and
// revision-checked by the code that was already here.
//
// What is real, and what is not, is drawn sharply on purpose:
//   • REAL — the sheet is rendered by pdf.js through office-pdf-core.jsx, a
//     scale is calibrated by drawing over a known dimension, and distance,
//     area, perimeter, count and deduction are measured against it.
//   • NOT REAL — there is no symbol recognition, no auto-count of a legend
//     symbol, and no snapping to the drawing's own linework. pdf.js hands this
//     app a RASTER of the page plus a text layer; it does not hand it the CAD
//     geometry, so there is nothing for a cursor to snap to. Snapping here is
//     to points a person placed and to a grid a person set.
//
// Bytes never touch project state. The PDF goes into the core's IndexedDB asset
// store and the sheet keeps an asset id — the same split LEON PDF uses, and the
// reason a 60 MB architectural set does not take the app's localStorage down.

// A tool here is a way of DRAWING. DWG_GLOBAL_TOOLS is a way of COUNTING. They
// meet exactly once: when a measurement is turned into a takeoff record.
const DWG_DRAW_TOOLS = [
  { key: 'distance', label: 'Distance', icon: '📏', type: 'Length', poly: true,
    hint: 'Click each point of the run; double-click, or press Enter, to finish.' },
  { key: 'area', label: 'Area', icon: '⬟', type: 'Area', poly: true,
    hint: 'Click the corners; double-click to close the shape. Gives area and perimeter.' },
  { key: 'rect', label: 'Rectangle', icon: '▭', type: 'Area', poly: false,
    hint: 'Drag from one corner to the other.' },
  { key: 'count', label: 'Count', icon: '⦿', type: 'Count', poly: true,
    hint: 'Click to drop a numbered pin; finish when the run is done.' },
  { key: 'deduct', label: 'Cutout', icon: '⊖', type: 'Area', poly: true,
    hint: 'Click the corners of an opening INSIDE an area already measured — it is subtracted from it. Many per area.' },
  { key: 'deductEllipse', label: 'Round cutout', icon: '⊘', type: 'Area', poly: false,
    hint: 'Drag a box round the opening; the cutout is the ellipse inside it.' },
  { key: 'fill', label: 'Dynamic Fill', icon: '🪣', type: 'Area', poly: false, special: true,
    hint: 'Click inside a room and it floods to the walls — area, perimeter and (optionally) a Space in one click. Drag a closed loop instead to lasso a region and ignore the linework.' },
  // The four that answer a question about a shape rather than about a quantity.
  // Cheap to add next to everything else, and an estimator who uses the whole
  // toolset reaches for them constantly — a mitre angle, a curved wall's radius.
  { key: 'angle', label: 'Angle', icon: '∠', type: 'Angle', poly: true, pts: 3,
    hint: 'Click along one leg, then the corner, then along the other leg. Three points, and the angle is at the middle one.' },
  { key: 'radius', label: 'Radius', icon: '◜', type: 'Length', poly: true, pts: 2,
    hint: 'Click the centre, then a point on the curve.' },
  { key: 'diameter', label: 'Diameter', icon: '⌀', type: 'Length', poly: true, pts: 2,
    hint: 'Click straight across the circle, edge to edge.' },
  { key: 'radius3', label: '3-point radius', icon: '◟', type: 'Length', poly: true, pts: 3,
    hint: 'Click three points anywhere on the arc — the centre and radius are solved from them, so no centre mark is needed.' },
];
// Not measuring tools: these draw the two structures a measurement is resolved
// against. Kept out of DWG_DRAW_TOOLS so nothing iterating measuring tools has
// to filter them out.
const DWG_STRUCTURE_TOOLS = [
  { key: 'viewport', label: 'Viewport', icon: '🔍', hint: 'Drag a box round a detail drawn at its own scale, then give it a name and a scale. Anything measured inside it is measured at that scale.' },
  { key: 'space', label: 'Space', icon: '🏠', hint: 'Drag a box, or click the corners, round a room / unit / level. Markups drawn inside it file themselves to it.' },
  { key: 'boundary', label: 'Fill boundary', icon: '✂️', hint: 'Draw a line to close a gap the drawing leaves open — a door threshold, say. It is used by Dynamic Fill only and is never drawn on the sheet or measured.' },
];
const DWG_DRAW_COLORS = {
  distance: '#2563a8', area: '#3a7d44', rect: '#3a7d44',
  count: '#a67b1f', deduct: '#b83b3b', deductEllipse: '#b83b3b',
  fill: '#3a7d44', calibrate: '#7b4fa8', viewport: '#7b4fa8', space: '#1f7a8c', boundary: '#d06a1f',
  // The four shape tools share one colour: they are all answering a question
  // about the geometry rather than adding to a quantity, and reading as one
  // family on the sheet is the point.
  angle: '#8a5a1f', radius: '#8a5a1f', diameter: '#8a5a1f', radius3: '#8a5a1f',
};
const DWG_DRAW_TOOL_BY_KEY = {};
DWG_DRAW_TOOLS.forEach(t => { DWG_DRAW_TOOL_BY_KEY[t.key] = t; });

const DWG_SNAP_PX = 9;            // how close the cursor must be to grab a point
const DWG_ZOOMS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 6];
const DWG_GRID_PRESETS = [
  { label: 'no grid', mm: 0 },
  { label: '1"', mm: 25.4 }, { label: '3"', mm: 76.2 }, { label: '6"', mm: 152.4 },
  { label: "1'-0\"", mm: 304.8 }, { label: "2'-0\"", mm: 609.6 },
  { label: '100 mm', mm: 100 }, { label: '500 mm', mm: 500 }, { label: '1 m', mm: 1000 },
];

// office-pdf-core.jsx loads AFTER this file, so nothing here may call it at
// module level. Every call site checks first and this names what is missing
// rather than throwing a TypeError out of a render loop.
function dwgPdfCoreMissing() {
  const out = [];
  if (typeof officePdfLibs !== 'function') out.push('officePdfLibs');
  if (typeof officePdfReadFile !== 'function') out.push('officePdfReadFile');
  if (typeof officePdfPutAsset !== 'function') out.push('officePdfPutAsset');
  if (typeof officePdfGetBytes !== 'function') out.push('officePdfGetBytes');
  if (typeof officePdfDocument !== 'function') out.push('officePdfDocument');
  if (typeof officePdfRenderPage !== 'function') out.push('officePdfRenderPage');
  if (typeof officePdfPageSize !== 'function') out.push('officePdfPageSize');
  if (typeof officePdfPageText !== 'function') out.push('officePdfPageText');
  if (typeof officePdfExtractText !== 'function') out.push('officePdfExtractText');
  if (typeof officePdfSearch !== 'function') out.push('officePdfSearch');
  if (typeof officePdfBytesLabel !== 'function') out.push('officePdfBytesLabel');
  return out;
}
function dwgBytesLabel(n) {
  return typeof officePdfBytesLabel === 'function' ? officePdfBytesLabel(n) : `${dwgNum(n)} B`;
}

// A drawing set's attachment is a data URL in project state (that is how
// FileField stores it). Reading it out is fine; what must never happen is
// writing bytes back into state, which is why the decoded array goes straight
// into the core's asset store and only the id is kept.
function dwgDataUrlToBytes(url) {
  const s = String(url || '');
  const i = s.indexOf(',');
  if (i < 0 || s.slice(0, i).indexOf('base64') < 0) return null;
  let bin;
  try { bin = atob(s.slice(i + 1)); } catch (e) { return null; }
  const out = new Uint8Array(bin.length);
  for (let k = 0; k < bin.length; k++) out[k] = bin.charCodeAt(k);
  return out;
}
// Check the BYTES, not the file name and not an HTTP status. A WAF challenge
// page saved as .pdf is still a web page, and it fails much later and much
// less clearly than it fails here.
function dwgLooksLikePdf(bytes) {
  return !!(bytes && bytes.length > 4 && bytes[0] === 0x25 && bytes[1] === 0x50
            && bytes[2] === 0x44 && bytes[3] === 0x46);
}

// ── Sheet-level accessors (a sheet saved before this existed reads as empty) ──
function dwgSheetPdf(sheet) { return (sheet && sheet.pdf) || null; }
function dwgSheetMeasures(sheet) { return ((sheet && sheet.measurements) || []).filter(m => m.active !== false); }
function dwgSheetCalibrations(sheet) { return (sheet && sheet.pageCalibrations) || {}; }
function dwgCalibrationFor(sheet, pageIndex) { return dwgSheetCalibrations(sheet)[String(dwgNum(pageIndex))] || null; }

// ── Geometry ──────────────────────────────────────────────────────────────
// Points are stored as fractions of the page: x of its width, y of its height.
// Those are different lengths, so y is converted into width-units by the page's
// aspect ratio before anything is added up — skip that and every diagonal is
// wrong on any page that is not square. One unit = one page width.
function dwgWidthUnits(points, aspect) { return (points || []).map(p => ({ x: p.x, y: p.y * aspect })); }
function dwgPolyLength(points, aspect, closed) {
  const p = dwgWidthUnits(points, aspect);
  if (p.length < 2) return 0;
  let d = 0;
  for (let i = 1; i < p.length; i++) d += Math.hypot(p[i].x - p[i - 1].x, p[i].y - p[i - 1].y);
  if (closed && p.length > 2) d += Math.hypot(p[0].x - p[p.length - 1].x, p[0].y - p[p.length - 1].y);
  return d;
}
function dwgPolyArea(points, aspect) {
  const p = dwgWidthUnits(points, aspect);
  if (p.length < 3) return 0;
  let s = 0;
  for (let i = 0; i < p.length; i++) {
    const a = p[i], b = p[(i + 1) % p.length];
    s += a.x * b.y - b.x * a.y;
  }
  return Math.abs(s / 2);
}
function dwgPointInPoly(pt, points) {
  const pts = points || [];
  let inside = false;
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
    const a = pts[i], b = pts[j];
    const denom = (b.y - a.y) || 1e-12;
    if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / denom + a.x)) inside = !inside;
  }
  return inside;
}
function dwgCentroid(points) {
  const pts = points || [];
  if (!pts.length) return { x: 0, y: 0 };
  const s = pts.reduce((a, p) => ({ x: a.x + p.x, y: a.y + p.y }), { x: 0, y: 0 });
  return { x: s.x / pts.length, y: s.y / pts.length };
}
function dwgRectPoints(a, b) {
  return [{ x: a.x, y: a.y }, { x: b.x, y: a.y }, { x: b.x, y: b.y }, { x: a.x, y: b.y }];
}

// Millimetres are the canonical unit here as everywhere else in the Hub; the
// takeoff units are produced on the way out.
const DWG_LENGTH_PER_MM = { LF: 1 / 304.8, LM: 1 / 1000, IN: 1 / 25.4, MM: 1 };
const DWG_AREA_PER_MM2 = { SF: 1 / 92903.04, SM: 1 / 1000000, SY: 1 / 836127.36 };
// Cubic yards of concrete, square yards of carpet and feet-and-inches of trim
// all belong on one sheet at one scale, which is why the unit is a property of
// the MARKUP and not of the page.
const DWG_VOLUME_PER_MM3 = { CF: 1 / 28316846.592, CY: 1 / 764554857.984, CM: 1e-9 };
// Depth, rise and drop are typed in whatever the trade writes them in; they are
// converted straight to millimetres, the canonical unit everywhere in the Hub.
const DWG_DEPTH_UNITS = ['IN', 'FT', 'MM', 'CM', 'M'];
const DWG_DEPTH_UNIT_MM = { IN: 25.4, FT: 304.8, MM: 1, CM: 10, M: 1000 };
function dwgLengthIn(mm, unit) { const f = DWG_LENGTH_PER_MM[unit]; return dwgNum(mm) * (f || DWG_LENGTH_PER_MM.LF); }
function dwgAreaIn(mm2, unit) { const f = DWG_AREA_PER_MM2[unit]; return dwgNum(mm2) * (f || DWG_AREA_PER_MM2.SF); }
function dwgVolumeIn(mm3, unit) { const f = DWG_VOLUME_PER_MM3[unit]; return dwgNum(mm3) * (f || DWG_VOLUME_PER_MM3.CF); }
function dwgDepthMm(value, unit) { return dwgNum(value) * (DWG_DEPTH_UNIT_MM[unit] || DWG_DEPTH_UNIT_MM.IN); }
function dwgFmtMm(mm) { return `${fmtDim(mm, 'Imperial')} · ${dwgFmtQty(dwgLengthIn(mm, 'LF'), 'LF')}`; }
function dwgFmtArea(mm2) { return `${dwgFmtQty(dwgAreaIn(mm2, 'SF'), 'SF')} · ${dwgFmtQty(dwgAreaIn(mm2, 'SM'), 'SM')}`; }

// "1/4\" = 1'-0\"" and "1:50" both say the same thing — how many real
// millimetres one paper millimetre stands for. parseDim already reads every
// dimension form the trade writes, so neither half needs its own parser.
function dwgScaleRatioFromLabel(label) {
  const s = String(label || '').trim().toLowerCase();
  if (!s || s === 'nts') return null;
  const colon = s.match(/^1\s*:\s*([\d.]+)$/);
  if (colon) { const n = parseFloat(colon[1]); return n > 0 ? n : null; }
  const halves = s.split('=');
  if (halves.length !== 2) return null;
  const paper = parseDim(halves[0].trim(), 'Imperial');
  const real = parseDim(halves[1].trim(), 'Imperial');
  if (!paper || !real || paper <= 0) return null;
  return real / paper;
}
function dwgScaleLabelFromRatio(ratio) {
  if (!ratio || !isFinite(ratio) || ratio <= 0) return '';
  let best = null;
  DWG_SCALE_PRESETS.forEach(p => {
    const r = dwgScaleRatioFromLabel(p);
    if (!r) return;
    const err = Math.abs(r - ratio) / ratio;
    if (err < 0.02 && (!best || err < best.err)) best = { p, err };
  });
  if (best) return best.p;
  return `1:${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)}`;
}

// ── Records this surface writes ───────────────────────────────────────────
// A calibration is a RECORD, exactly like the typed one: a value, where it came
// from, how confident, who and when — plus the line that was drawn, so it can
// be redrawn and checked rather than taken on trust.
function dwgMakeCalibration(data, by) {
  return {
    id: uid('dcal'), pageIndex: dwgNum(data.pageIndex),
    mmPerPageWidth: dwgNum(data.mmPerPageWidth),
    knownText: data.knownText || '', knownMm: dwgNum(data.knownMm),
    rawFraction: dwgNum(data.rawFraction),
    points: (data.points || []).map(p => ({ x: p.x, y: p.y })),
    // Geometry is stored as page FRACTIONS, so the page's own height-over-width
    // is needed to turn any of it back into a length. It is recorded here
    // because a calibration is the one record that is always made while the page
    // is open and its size is known — the Markups List, which reads across sheets
    // it never renders, would otherwise have to guess.
    pageAspect: dwgNum(data.pageAspect) || 0,
    scaleLabel: data.scaleLabel || '',
    printedScale: data.printedScale || '',
    printedAgrees: data.printedAgrees === undefined ? null : data.printedAgrees,
    printedErrorPct: data.printedErrorPct === undefined || data.printedErrorPct === null ? null : dwgNum(data.printedErrorPct),
    confidence: data.confidence || 'High',
    note: data.note || '',
    by: by || '', date: todayISO(),
  };
}
// A measurement keeps GEOMETRY, not a number. The number is recomputed from the
// geometry and whatever calibration is in force, which is what lets one
// re-calibration correct a whole sheet instead of leaving stale figures behind.
function dwgMakeMeasurement(data, by) {
  return {
    id: uid('dmeas'), kind: data.kind || 'distance',
    // 'poly' for everything drawn point by point; 'ellipse' stores two bbox
    // corners instead, and dwgRingPoints turns either into one ring so no other
    // piece of geometry code has to know which it is.
    shape: data.shape === 'ellipse' ? 'ellipse' : 'poly',
    pageIndex: dwgNum(data.pageIndex),
    points: (data.points || []).map(p => ({ x: p.x, y: p.y })),
    parentId: data.parentId || null,
    label: data.label || '',
    scopeId: data.scopeId || null,
    // ── the three grouping axes, stamped at creation from the tool ─────────
    // Subject is WHAT it is, Layer is which overlay it belongs to, Space is
    // WHERE it is (filled in positionally, see dwgSpaceAt). Get these on at
    // creation and every subtotal, legend and export is already grouped.
    subject: data.subject || '', layer: data.layer || '', spaceId: data.spaceId || null,
    // Which viewport resolved this markup's scale, decided POSITIONALLY at the
    // moment it was drawn. Recorded rather than re-derived so the number stays
    // explainable, and so a markup can be flagged when its viewport is deleted.
    viewportId: data.viewportId || null,
    // ── secondary dimensions — properties, never geometry ──────────────────
    // Depth turns an Area into a Volume and a Length into a Wall Area.
    // Rise/Drop is a total ADDED to a length (a pipe run between floors).
    // Depth and Rise/Drop are mutually exclusive; dwgSetSecondary enforces it.
    depthValue: data.depthValue === undefined || data.depthValue === null ? null : Number(data.depthValue),
    depthUnit: data.depthUnit || 'IN',
    riseValue: data.riseValue === undefined || data.riseValue === null ? null : Number(data.riseValue),
    riseUnit: data.riseUnit || 'FT',
    slopeMode: data.slopeMode || '',            // '' | 'Pitch' | 'Degree' | 'Grade'
    slopeValue: data.slopeValue === undefined || data.slopeValue === null ? null : Number(data.slopeValue),
    // Per-markup units and precision, independent of the page scale.
    units: data.units || null,                  // { Length, Area, Volume }
    precision: data.precision || null,          // { mode:'decimal'|'fraction', decimals, denom }
    color: data.color || '',
    // What a count pin is drawn as. Stamped from the tool, because a symbol is a
    // property of HOW something is counted, not of each individual pin.
    countSymbol: data.countSymbol || '',
    status: data.status || '',
    comments: data.comments || '',
    custom: data.custom || {},                  // customColumnId -> raw value
    // The revision the sheet carried when this was drawn — the whole revision
    // check is comparing it to the sheet's revision now.
    sheetRevision: data.sheetRevision || '',
    calibrationId: data.calibrationId || null,
    // What one page width was worth when this was drawn. Not a stored quantity —
    // a record of the scale in force, and the only way to tell afterwards that a
    // page has been re-calibrated under a markup. This is what Recalculate reads.
    calMmPerPageWidth: data.calMmPerPageWidth === undefined || data.calMmPerPageWidth === null
      ? null : Number(data.calMmPerPageWidth),
    // Which figure of this shape became the record, and in what unit, so the
    // record can be recomputed exactly when the calibration changes.
    itemId: data.itemId || null, figure: data.figure || null, unit: data.unit || '',
    note: data.note || '',
    origin: data.origin || 'drawn',             // drawn | fill | lasso | search | split
    // What Visual Search was actually looking for, so the same search can be
    // re-run later. The RECT is stored, never the rendered template — a packed
    // bitmap is derived pixels and has no business in persisted state.
    searchTemplate: data.searchTemplate || null,
    active: true,
    createdBy: by || '', createdDate: todayISO(),
  };
}

// One ring for every shape. An ellipse is stored as two bbox corners because
// that is what was dragged; everything downstream — area, perimeter, hit test,
// containment, SVG — asks for its ring instead of asking what shape it is.
const DWG_ELLIPSE_SEGMENTS = 64;
function dwgRingPoints(m) {
  const pts = (m && m.points) || [];
  if (!m || m.shape !== 'ellipse' || pts.length < 2) return pts;
  const cx = (pts[0].x + pts[1].x) / 2, cy = (pts[0].y + pts[1].y) / 2;
  const rx = Math.abs(pts[1].x - pts[0].x) / 2, ry = Math.abs(pts[1].y - pts[0].y) / 2;
  const out = [];
  for (let i = 0; i < DWG_ELLIPSE_SEGMENTS; i++) {
    const t = (i / DWG_ELLIPSE_SEGMENTS) * Math.PI * 2;
    out.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) });
  }
  return out;
}

// Signed shoelace, deliberately NOT absolute. Cutouts are subtracted by signing
// the ring and summing, which is the whole trick that lets many cutouts (and
// cutouts inside cutouts) work without a polygon boolean.
function dwgSignedArea(points, aspect) {
  const p = dwgWidthUnits(points, aspect);
  if (p.length < 3) return 0;
  let s = 0;
  for (let i = 0; i < p.length; i++) {
    const a = p[i], b = p[(i + 1) % p.length];
    s += a.x * b.y - b.x * a.y;
  }
  return s / 2;
}
// Outer ring forced positive, every hole forced negative, then summed. Same
// number as gross-minus-holes for the simple case, and still right when a hole
// was drawn the other way round the clock.
function dwgRingsArea(outerRing, holeRings, aspect) {
  let total = Math.abs(dwgSignedArea(outerRing, aspect));
  (holeRings || []).forEach(h => { total -= Math.abs(dwgSignedArea(h, aspect)); });
  return Math.max(0, total);
}

// Slope is entered the way the trade writes it. All three say the same thing:
// how much the surface rises per unit of the run that was measured on the plan.
const DWG_SLOPE_MODES = ['Pitch', 'Degree', 'Grade'];
function dwgSlopeRatio(m) {
  if (!m || !m.slopeMode || m.slopeValue === null || m.slopeValue === undefined) return 0;
  const v = dwgNum(m.slopeValue);
  if (m.slopeMode === 'Pitch') return v / 12;                       // 4 means 4-in-12
  if (m.slopeMode === 'Degree') return Math.tan(v * Math.PI / 180);
  if (m.slopeMode === 'Grade') return v / 100;                      // a percentage
  return 0;
}
// A plan measures the RUN. The real surface is longer by sqrt(1 + m²) — the same
// factor for a sloped length and for a sloped area, because the area is only
// stretched along the direction of the fall.
function dwgSlopeFactor(m) {
  const r = dwgSlopeRatio(m);
  return r ? Math.sqrt(1 + r * r) : 1;
}
function dwgMeasureDepthMm(m) {
  return m && m.depthValue !== null && m.depthValue !== undefined && dwgNum(m.depthValue) !== 0
    ? dwgDepthMm(m.depthValue, m.depthUnit) : 0;
}
function dwgMeasureRiseMm(m) {
  return m && m.riseValue !== null && m.riseValue !== undefined && dwgNum(m.riseValue) !== 0
    ? dwgDepthMm(m.riseValue, m.riseUnit) : 0;
}

// ── The four shape questions: angle, radius, diameter, 3-point radius ─────
// These answer a question ABOUT a shape rather than producing a quantity, so
// they carry no perimeter and no polygon area. They live in the same
// measurement record as everything else deliberately: a mitre angle belongs in
// the list beside the run it was taken off, not in a separate scratchpad.
const DWG_SHAPE_KINDS = { angle: 1, radius: 1, diameter: 1, radius3: 1 };
function dwgIsShapeKind(kind) { return !!DWG_SHAPE_KINDS[kind]; }

// The angle at the MIDDLE point, in degrees, computed in width-units so it is
// the angle on the paper and not the angle on a stretched pixel grid.
function dwgAngleAt(points, aspect) {
  const p = dwgWidthUnits(points, aspect);
  if (p.length < 3) return 0;
  const a = { x: p[0].x - p[1].x, y: p[0].y - p[1].y };
  const b = { x: p[2].x - p[1].x, y: p[2].y - p[1].y };
  const la = Math.hypot(a.x, a.y), lb = Math.hypot(b.x, b.y);
  if (!la || !lb) return 0;
  const cos = Math.max(-1, Math.min(1, (a.x * b.x + a.y * b.y) / (la * lb)));
  return Math.acos(cos) * 180 / Math.PI;
}
// Circumcentre of three points. Three points in a straight line have no circle,
// and that is reported as such rather than returned as an enormous radius that
// reads like a real answer.
function dwgCircleFrom3(points, aspect) {
  const p = dwgWidthUnits(points, aspect);
  if (p.length < 3) return null;
  const A = p[0], B = p[1], C = p[2];
  const d = 2 * (A.x * (B.y - C.y) + B.x * (C.y - A.y) + C.x * (A.y - B.y));
  if (Math.abs(d) < 1e-12) return null;
  const a2 = A.x * A.x + A.y * A.y, b2 = B.x * B.x + B.y * B.y, c2 = C.x * C.x + C.y * C.y;
  const ux = (a2 * (B.y - C.y) + b2 * (C.y - A.y) + c2 * (A.y - B.y)) / d;
  const uy = (a2 * (C.x - B.x) + b2 * (A.x - C.x) + c2 * (B.x - A.x)) / d;
  return { cx: ux, cy: uy, r: Math.hypot(A.x - ux, A.y - uy) };
}
// One radius, in page-width units, whichever of the three ways it was asked for.
function dwgShapeRadiusUnits(m, aspect) {
  const pts = (m && m.points) || [];
  if (m.kind === 'radius') return pts.length >= 2 ? dwgPolyLength([pts[0], pts[1]], aspect, false) : 0;
  if (m.kind === 'diameter') return pts.length >= 2 ? dwgPolyLength([pts[0], pts[1]], aspect, false) / 2 : 0;
  if (m.kind === 'radius3') { const c = dwgCircleFrom3(pts, aspect); return c ? c.r : 0; }
  return 0;
}

// ── Scale resolution ──────────────────────────────────────────────────────
// A markup drawn inside a viewport is measured at the VIEWPORT's scale; outside
// every viewport the page calibration applies. The decision is positional and
// is taken once, at creation — after that the viewport id on the markup is what
// is read, so the number stays explainable and a deleted viewport is a flag
// rather than a silent re-measure.
function dwgSheetViewports(sheet, pageIndex) {
  const list = ((sheet && sheet.viewports) || []).filter(v => v && v.active !== false);
  return pageIndex === undefined || pageIndex === null ? list
    : list.filter(v => dwgNum(v.pageIndex) === dwgNum(pageIndex));
}
function dwgViewportById(sheet, id) {
  return id ? (((sheet && sheet.viewports) || []).find(v => v.id === id) || null) : null;
}
function dwgViewportBox(v) {
  const p = (v && v.points) || [];
  if (p.length < 2) return null;
  const xs = p.map(q => q.x), ys = p.map(q => q.y);
  return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
function dwgViewportArea(v) {
  const b = dwgViewportBox(v);
  return b ? (b.x1 - b.x0) * (b.y1 - b.y0) : Infinity;
}
// PRECEDENCE, chosen here because Revu does not document its own: where two
// viewports overlap, the SMALLEST one wins. A detail bubble sitting on top of a
// plan region is the more specific statement about that spot, and the specific
// answer is the one an estimator means. The screen says this in words.
function dwgViewportAt(sheet, pageIndex, pt) {
  const hits = dwgSheetViewports(sheet, pageIndex).filter(v => {
    const b = dwgViewportBox(v);
    return b && pt.x >= b.x0 && pt.x <= b.x1 && pt.y >= b.y0 && pt.y <= b.y1;
  });
  if (!hits.length) return null;
  return hits.slice().sort((a, b) => dwgViewportArea(a) - dwgViewportArea(b))[0];
}
// Everything that needs a number asks this, so page scale, viewport scale and
// "no scale at all" are decided in exactly one place.
function dwgResolveScale(sheet, m, cal) {
  const pageMm = cal ? dwgNum(cal.mmPerPageWidth) : 0;
  if (m && m.viewportId) {
    const v = dwgViewportById(sheet, m.viewportId);
    if (!v || v.active === false) {
      return { mmPerPageWidth: pageMm, source: 'page', label: cal ? cal.scaleLabel : '',
               viewport: null, viewportMissing: true };
    }
    return { mmPerPageWidth: dwgNum(v.mmPerPageWidth), source: 'viewport', label: v.scaleLabel || '',
             viewport: v, viewportMissing: false };
  }
  return { mmPerPageWidth: pageMm, source: cal ? 'page' : 'none', label: cal ? cal.scaleLabel : '',
           viewport: null, viewportMissing: false };
}

function dwgMeasureValue(m, cal, aspect, siblings, sheet) {
  const res = dwgResolveScale(sheet, m, cal);
  const mmPer = dwgNum(res.mmPerPageWidth);
  const out = { kind: m.kind, calibrated: mmPer > 0, scale: res,
                mm: 0, runMm: 0, riseMm: 0, mm2: 0, perimeterMm: 0,
                count: 0, grossMm2: 0, deductedMm2: 0, netMm2: 0, deductions: [],
                depthMm: dwgMeasureDepthMm(m), slopeFactor: dwgSlopeFactor(m),
                volumeMm3: 0, wallAreaMm2: 0,
                angleDeg: 0, radiusMm: 0, diameterMm: 0, circumferenceMm: 0,
                circleAreaMm2: 0, collinear: false };
  if (m.kind === 'count') { out.count = (m.points || []).length; return out; }
  if (dwgIsShapeKind(m.kind)) {
    // An angle needs no scale at all — it is the same angle at any calibration,
    // which is why it is the one measuring tool an uncalibrated page still allows.
    if (m.kind === 'angle') { out.angleDeg = dwgAngleAt(m.points, aspect); return out; }
    const rU = dwgShapeRadiusUnits(m, aspect);
    if (!rU) { out.collinear = m.kind === 'radius3'; return out; }
    out.radiusMm = rU * mmPer;
    out.diameterMm = out.radiusMm * 2;
    out.circumferenceMm = 2 * Math.PI * out.radiusMm;
    out.circleAreaMm2 = Math.PI * out.radiusMm * out.radiusMm;
    return out;
  }
  const ring = dwgRingPoints(m);
  const closed = m.kind !== 'distance';
  out.perimeterMm = dwgPolyLength(ring, aspect, closed) * mmPer * out.slopeFactor;
  if (m.kind === 'distance') {
    out.runMm = dwgPolyLength(ring, aspect, false) * mmPer;
    out.riseMm = dwgMeasureRiseMm(m);
    // Slope stretches the run; rise/drop is a flat addition on the end of it.
    out.mm = out.runMm * out.slopeFactor + out.riseMm;
    out.wallAreaMm2 = out.depthMm ? out.mm * out.depthMm : 0;
    return out;
  }
  const kids = (siblings || []).filter(x => x.kind === 'deduct' && x.parentId === m.id && x.active !== false);
  out.deductions = kids;
  const holes = kids.map(dwgRingPoints);
  const grossUnits = Math.abs(dwgSignedArea(ring, aspect));
  const netUnits = dwgRingsArea(ring, holes, aspect);
  const f = mmPer * mmPer * out.slopeFactor;
  out.grossMm2 = grossUnits * f;
  out.deductedMm2 = (grossUnits - netUnits) * f;
  out.netMm2 = netUnits * f;
  out.mm2 = out.grossMm2;
  out.volumeMm3 = out.depthMm ? out.netMm2 * out.depthMm : 0;
  out.wallAreaMm2 = out.depthMm ? out.perimeterMm * out.depthMm : 0;
  return out;
}
// The figures a shape can hand to a takeoff record. An area gives three from
// one polygon — net (what is installed), gross (what was drawn) and perimeter
// (the trim round it) — and a fourth and fifth once a depth is set.
function dwgFiguresFor(kind, m) {
  if (kind === 'count') return [{ key: 'count', label: 'Count', type: 'Count' }];
  // An angle produces no quantity anybody buys, so it offers nothing to a
  // takeoff record rather than offering a number in degrees pretending to be one.
  if (kind === 'angle') return [];
  if (dwgIsShapeKind(kind)) return [
    { key: 'radius', label: 'Radius', type: 'Length' },
    { key: 'diameter', label: 'Diameter', type: 'Length' },
    { key: 'circumference', label: 'Circumference of the full circle', type: 'Length' },
    { key: 'circleArea', label: 'Area of the full circle', type: 'Area' },
  ];
  const depth = dwgMeasureDepthMm(m);
  if (kind === 'distance') {
    const out = [{ key: 'length', label: 'Length of the run', type: 'Length' }];
    if (depth) out.push({ key: 'wallArea', label: 'Wall area (length × depth)', type: 'Area' });
    return out;
  }
  if (kind === 'deduct') return [];
  const out = [
    { key: 'net', label: 'Area, less cutouts', type: 'Area' },
    { key: 'gross', label: 'Area as drawn', type: 'Area' },
    { key: 'perimeter', label: 'Perimeter', type: 'Length' },
  ];
  if (depth) {
    out.push({ key: 'volume', label: 'Volume (net area × depth)', type: 'Volume' });
    out.push({ key: 'wallArea', label: 'Wall area (perimeter × depth)', type: 'Area' });
  }
  return out;
}
function dwgFigureIn(v, figure, unit) {
  if (figure === 'count') return v.count;
  if (figure === 'radius') return dwgLengthIn(v.radiusMm, unit);
  if (figure === 'diameter') return dwgLengthIn(v.diameterMm, unit);
  if (figure === 'circumference') return dwgLengthIn(v.circumferenceMm, unit);
  if (figure === 'circleArea') return dwgAreaIn(v.circleAreaMm2, unit);
  if (figure === 'length') return dwgLengthIn(v.mm, unit);
  if (figure === 'perimeter') return dwgLengthIn(v.perimeterMm, unit);
  if (figure === 'gross') return dwgAreaIn(v.grossMm2, unit);
  if (figure === 'volume') return dwgVolumeIn(v.volumeMm3, unit);
  if (figure === 'wallArea') return dwgAreaIn(v.wallAreaMm2, unit);
  return dwgAreaIn(v.netMm2, unit);
}

// A measurement is pinned to the sheet revision it was drawn on. When the sheet
// moves on the geometry is still there but the drawing under it is not the one
// it was drawn over — so it is flagged, in the module's OWN words
// ('Revision Review Required'), and lands in the queue that already exists.
function dwgMeasureStale(sheet, m) {
  if (!sheet || !m) return false;
  if (sheet.status === 'Superseded') return true;
  return !!(m.sheetRevision && sheet.revision && m.sheetRevision !== sheet.revision);
}
function dwgMeasureName(m, i) {
  if (m.label) return m.label;
  const t = DWG_DRAW_TOOL_BY_KEY[m.kind];
  return `${t ? t.label : m.kind} ${i + 1}`;
}

// ── The measuring section ─────────────────────────────────────────────────
function DwgMeasureSection({ ctx, project, editable, versionId, focusSheetId, setFocusSheetId, onOpenRecord }) {
  const [q, setQ] = useState('');
  const sheets = dwgSheetList(project).slice()
    .sort((a, b) => (a.number || '').localeCompare(b.number || '', undefined, { numeric: true }));
  const current = sheets.find(s => s.id === focusSheetId) || sheets[0] || null;
  const shown = sheets.filter(s => !q.trim() || `${s.number} ${s.title}`.toLowerCase().includes(q.trim().toLowerCase()));
  const missing = dwgPdfCoreMissing();

  if (!sheets.length) return <EmptyState text="No sheets indexed yet — add them in the Drawing Log first, then measure on one here." />;
  if (missing.length) {
    return (
      <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 p-4 text-sm">
        <div className="font-bold mb-1">The PDF engine did not load, so nothing can be measured.</div>
        <div className="text-[var(--leon-black)]/65">
          LEON Take-off renders a sheet through the same core LEON PDF uses. These are missing from this
          build: <b>{missing.join(', ')}</b>. Check that <code>softwares/office-pdf-core.jsx</code> and
          {' '}<code>vendor/pdf.min.js</code> are loaded in index.html.
        </div>
      </div>
    );
  }

  return (
    <div className="grid gap-4 lg:grid-cols-[230px_1fr]">
      <div className="space-y-2">
        <TextInput value={q} onChange={e => setQ(e.target.value)} placeholder="Find a sheet…" />
        <div className="rounded-lg border border-[var(--leon-line)] bg-white max-h-[70vh] overflow-y-auto">
          {shown.map(s => {
            const on = current && current.id === s.id;
            const n = dwgSheetMeasures(s).length;
            return (
              <button key={s.id} onClick={() => setFocusSheetId(s.id)}
                className={`w-full text-left px-3 py-2 border-b border-[var(--leon-line)]/60 last:border-b-0 ${on ? 'bg-[var(--leon-cream)]' : 'hover:bg-[var(--leon-cream)]/50'}`}>
                <div className="flex items-center justify-between gap-2">
                  <span className="font-bold text-xs">{s.number}</span>
                  <span className="flex items-center gap-1">
                    {dwgSheetPdf(s) ? <span className="text-[10px]" title="A PDF is attached to this sheet">📄</span>
                      : <span className="text-[10px] text-[var(--leon-black)]/25" title="No PDF attached yet">—</span>}
                    <DwgScaleFlag sheet={s} short />
                    {!!n && <span className="text-[10px] text-[var(--leon-black)]/45">{n}</span>}
                  </span>
                </div>
                <div className="text-[11px] text-[var(--leon-black)]/50 truncate">{s.title || 'Untitled'}</div>
              </button>
            );
          })}
          {!shown.length && <div className="px-3 py-4 text-xs text-[var(--leon-black)]/40">Nothing matches.</div>}
        </div>
      </div>
      {current && <DwgMeasureWorkspace key={current.id} ctx={ctx} project={project} sheet={current}
        editable={editable} versionId={versionId} onOpenRecord={onOpenRecord} />}
    </div>
  );
}

// ── Where the sheet's PDF comes from ──────────────────────────────────────
function DwgSheetSourcePanel({ ctx, project, sheet, editable, pageIndex, setPageIndex, pageCount, onChanged }) {
  const set = dwgSetById(project, sheet.drawingSetId);
  const pdf = dwgSheetPdf(sheet);
  const [busy, setBusy] = useState('');
  const [err, setErr] = useState('');
  const [found, setFound] = useState(null);
  const fileRef = useRef(null);

  function attach(assetId, name, size, count, sourceSetId) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      s.pdf = {
        assetId, name: name || '', size: dwgNum(size), pageCount: dwgNum(count) || 1,
        // Keep the page already chosen where the new file is at least that long;
        // a shorter one would leave the sheet pointing past the end of its PDF.
        pageIndex: Math.min(dwgNum(pdf && pdf.pageIndex), Math.max(0, (dwgNum(count) || 1) - 1)),
        sourceSetId: sourceSetId || null,
        attachedBy: ctx.currentUserName, attachedDate: todayISO(),
      };
      if (sourceSetId) {
        // One asset per SET, not per sheet: forty sheets out of one issue are
        // forty pages of one file, and storing it forty times would be forty
        // copies of the same 60 MB.
        const d = draft.drawingSets.find(x => x.id === sourceSetId);
        if (d) d.takeoffAssetId = assetId;
      }
    }, `Take-off: attached ${name || 'a PDF'} to sheet ${sheet.number} for on-sheet measuring.`);
    if (onChanged) onChanged();
  }

  function useSetFile() {
    if (!set) return;
    setErr(''); setBusy('Opening the set…');
    const existing = set.takeoffAssetId;
    const start = existing && typeof officePdfGetBytes === 'function'
      ? officePdfGetBytes(existing).then(b => (b ? { assetId: existing, size: b.length } : null))
      : Promise.resolve(null);
    start.then(hit => {
      if (hit) return officePdfDocument(hit.assetId).then(doc => ({ ...hit, name: set.name || '', count: doc.numPages }));
      const bytes = dwgDataUrlToBytes(set.fileUrl);
      if (!bytes) throw new Error('That set has a file name but no file bytes on record — attach the PDF below.');
      if (!dwgLooksLikePdf(bytes)) throw new Error(`"${set.name}" is not a PDF — its first bytes are not %PDF, so it is some other file saved under that name.`);
      return officePdfPutAsset(bytes.buffer, { name: set.name || 'drawing-set.pdf', kind: 'pdf' })
        .then(id => officePdfDocument(id).then(doc => ({ assetId: id, size: bytes.length, name: set.name || '', count: doc.numPages })));
    }).then(r => {
      setBusy('');
      attach(r.assetId, r.name, r.size, r.count, set.id);
    }).catch(e => { setBusy(''); setErr(e && e.message ? e.message : String(e)); });
  }

  function onPick(e) {
    const file = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!file) return;
    setErr(''); setBusy('Reading the file…');
    // Deliberately officePdfReadFile + a magic-byte check + officePdfPutAsset
    // rather than officePdfImportFile: importFile stores the bytes first and
    // only then discovers they are not a PDF, which leaves rubbish in the
    // asset store. Checking first costs nothing and never writes it.
    officePdfReadFile(file)
      .then(bytes => {
        if (!dwgLooksLikePdf(bytes)) throw new Error(`${file.name} does not start with %PDF, so it is not a PDF. Nothing was stored.`);
        return officePdfPutAsset(bytes.buffer, { name: file.name, kind: 'pdf', mime: file.type || 'application/pdf' })
          .then(id => officePdfDocument(id).then(doc => ({ assetId: id, count: doc.numPages, size: bytes.length })));
      })
      .then(r => { setBusy(''); attach(r.assetId, file.name, r.size, r.count, null); })
      .catch(x => { setBusy(''); setErr(x && x.message ? x.message : String(x)); });
  }

  // Which page of a 200-page set is this sheet? Read out of the page's TEXT
  // LAYER — the sheet number as it is written in the title block. A scanned set
  // has no text layer at all, and this says so rather than guessing.
  function findPage() {
    if (!pdf || !sheet.number) return;
    setErr(''); setFound(null); setBusy('Reading the text layer…');
    officePdfExtractText(pdf.assetId, (n, total) => setBusy(`Reading the text layer… page ${n} of ${total}`))
      .then(pages => {
        setBusy('');
        const withText = pages.filter(p => (p.text || '').trim()).length;
        const hits = officePdfSearch(pages, sheet.number, {});
        const byPage = {};
        hits.forEach(h => { byPage[h.page] = (byPage[h.page] || 0) + 1; });
        const ranked = Object.keys(byPage).map(k => ({ page: Number(k), n: byPage[k] })).sort((a, b) => b.n - a.n);
        setFound({ ranked: ranked.slice(0, 6), withText, total: pages.length });
      })
      .catch(e => { setBusy(''); setErr(e && e.message ? e.message : String(e)); });
  }

  function detach() {
    if (!confirm('Detach this PDF from the sheet? Measurements already recorded stay on the sheet — only the drawing behind them is removed.')) return;
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (s) s.pdf = null;
    }, `Take-off: detached the PDF from sheet ${sheet.number}.`);
    if (onChanged) onChanged();
  }

  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
      <div className="flex items-start justify-between gap-3 flex-wrap">
        <div className="min-w-0">
          <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Sheet source</div>
          {pdf ? (
            <div className="text-xs text-[var(--leon-black)]/65">
              <b>{pdf.name || 'PDF'}</b> · {dwgBytesLabel(pdf.size)} · {dwgNum(pdf.pageCount) || 1} page{dwgNum(pdf.pageCount) === 1 ? '' : 's'}
              {' · '}this sheet is page <b>{dwgNum(pdf.pageIndex) + 1}</b>
              {pdf.attachedBy ? ` · attached by ${pdf.attachedBy} ${fmtDate(pdf.attachedDate)}` : ''}
            </div>
          ) : (
            <div className="text-xs text-[var(--leon-black)]/65">
              No PDF is behind this sheet yet, so there is nothing to measure on.
              {set ? (set.fileUrl
                ? <> Its set, <b>{set.name}</b>, has one attached.</>
                : <> Its set, <b>{set.name}</b>, is <b>logged but not uploaded</b> — the record has the file
                    name and no file. Attach the PDF here, or on the project's Drawing Sets tab.</>)
                : ' This sheet is not filed to a drawing set.'}
            </div>
          )}
        </div>
        {editable && (
          <div className="flex gap-1.5 flex-wrap shrink-0">
            {set && set.fileUrl && <Button size="sm" variant="outline" onClick={useSetFile} disabled={!!busy}>Use the set&rsquo;s PDF</Button>}
            <Button size="sm" variant="outline" onClick={() => fileRef.current && fileRef.current.click()} disabled={!!busy}>
              {pdf ? 'Attach a different PDF' : 'Attach a PDF'}
            </Button>
            <input ref={fileRef} type="file" accept="application/pdf,.pdf" className="hidden" onChange={onPick} />
            {pdf && <IconBtn title="Detach this PDF" onClick={detach}>✕</IconBtn>}
          </div>
        )}
      </div>

      {!!busy && <div className="text-xs text-[var(--leon-black)]/55">{busy}</div>}
      {!!err && <div className="text-xs text-[var(--leon-red)] font-semibold">{err}</div>}

      {pdf && (
        <div className="flex items-end gap-2 flex-wrap border-t border-[var(--leon-line)] pt-2">
          <Field label="Viewing page">
            <div className="flex items-center gap-1">
              <IconBtn title="Previous page" onClick={() => setPageIndex(Math.max(0, pageIndex - 1))}>‹</IconBtn>
              <input type="number" min="1" max={Math.max(1, pageCount || dwgNum(pdf.pageCount) || 1)} value={pageIndex + 1}
                onChange={e => setPageIndex(Math.max(0, Math.min((pageCount || dwgNum(pdf.pageCount) || 1) - 1, (parseInt(e.target.value, 10) || 1) - 1)))}
                className="w-16 px-2 py-1 text-xs border border-[var(--leon-line)] rounded bg-white text-right" />
              <span className="text-xs text-[var(--leon-black)]/45">/ {pageCount || dwgNum(pdf.pageCount) || 1}</span>
              <IconBtn title="Next page" onClick={() => setPageIndex(Math.min((pageCount || dwgNum(pdf.pageCount) || 1) - 1, pageIndex + 1))}>›</IconBtn>
            </div>
          </Field>
          {editable && pageIndex !== dwgNum(pdf.pageIndex) && (
            <Button size="sm" onClick={() => {
              dwgUpdate(ctx, project.id, draft => {
                const s = draft.drawingSheets.find(x => x.id === sheet.id);
                if (s && s.pdf) s.pdf.pageIndex = pageIndex;
              }, `Take-off: sheet ${sheet.number} set to page ${pageIndex + 1} of ${pdf.name || 'its PDF'}.`);
            }}>This page is {sheet.number}</Button>
          )}
          {editable && <Button size="sm" variant="ghost" onClick={findPage} disabled={!!busy}>Find “{sheet.number}” in the text layer</Button>}
        </div>
      )}

      {found && (
        <div className="text-xs text-[var(--leon-black)]/65 border-t border-[var(--leon-line)] pt-2">
          {!found.withText ? (
            <span><b>No page in this PDF has a text layer.</b> It is a scan — an image of the drawing, not
              words — so the sheet number cannot be read out of it. Find the page by eye.</span>
          ) : !found.ranked.length ? (
            <span>“{sheet.number}” does not appear in the text of any of the {found.total} pages.</span>
          ) : (
            <span>
              “{sheet.number}” appears on:{' '}
              {found.ranked.map(r => (
                <button key={r.page} onClick={() => setPageIndex(r.page)}
                  className="mr-1.5 px-1.5 py-0.5 rounded border border-[var(--leon-line)] hover:border-[var(--leon-brown)] text-[var(--leon-brown)] font-semibold">
                  page {r.page + 1} <span className="opacity-50">×{r.n}</span>
                </button>
              ))}
              <span className="text-[var(--leon-black)]/45"> — a text match, not a decision: check the page before you set it.</span>
            </span>
          )}
        </div>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════
// The take-off engine — everything below is pure, and nothing below draws.
//
// It is separated from the surface on purpose: a quantity has to be arrived at
// the same way whether it was drawn, flood-filled, imported or listed, and the
// only way to guarantee that is for all four to call the same functions.
//
// Nothing here is ever persisted except the small records at the top. Rendered
// rasters, ink masks, vector indexes and search templates are held in memory
// for as long as a page is open and are then dropped — the app's whole
// localStorage budget is about 13 MB and one 24×36 sheet at 150 DPI is 19
// million pixels on its own.
// ══════════════════════════════════════════════════════════════════════════

// ── Units and precision, per markup ───────────────────────────────────────
// Cubic yards of concrete, square yards of carpet and feet-and-inches of trim
// on one sheet at one scale. The unit belongs to the MARKUP; the scale belongs
// to the page or the viewport; they are different questions.
const DWG_PRECISION_MODES = ['Decimal', 'Fractional'];
const DWG_FRACTION_DENOMS = [2, 4, 8, 16, 32];

function dwgUnitFor(m, type) {
  const u = (m && m.units) || null;
  if (u && u[type]) return u[type];
  const list = DWG_UNITS_BY_TYPE[type] || ['SF'];
  return list[0];
}
function dwgPrecisionFor(m) {
  const p = (m && m.precision) || null;
  return { mode: (p && p.mode) || 'Decimal',
           decimals: p && p.decimals !== undefined && p.decimals !== null ? dwgNum(p.decimals) : 2,
           denom: (p && p.denom) || 16 };
}
// Nearest 1/denom, reduced. A trim length that reads 12.34 LF is not a length
// anybody cuts to; 12'-4 1/8" is.
function dwgFracString(v, denom) {
  const sign = v < 0 ? '-' : '';
  const a = Math.abs(v);
  const whole = Math.floor(a);
  let num = Math.round((a - whole) * denom);
  let w = whole;
  if (num >= denom) { w += 1; num = 0; }
  if (!num) return `${sign}${w}`;
  let d = denom;
  while (num % 2 === 0 && d % 2 === 0) { num /= 2; d /= 2; }
  return `${sign}${w ? w + ' ' : ''}${num}/${d}`;
}
function dwgFmtValue(n, unit, prec) {
  const v = dwgNum(n);
  const p = prec || { mode: 'Decimal', decimals: 2, denom: 16 };
  const body = p.mode === 'Fractional'
    ? dwgFracString(v, p.denom || 16)
    : v.toLocaleString(undefined, { minimumFractionDigits: Math.max(0, Math.min(6, p.decimals)),
                                    maximumFractionDigits: Math.max(0, Math.min(6, p.decimals)) });
  return unit ? `${body} ${unit}` : body;
}

// The five figures a markup can carry, in the markup's own units. This is the
// row that the Markups List, the subtotals, the CSV and every formula all read,
// so a number can never mean one thing in a column and another in an export.
function dwgMarkupFigures(m, v) {
  const lu = dwgUnitFor(m, 'Length'), au = dwgUnitFor(m, 'Area'), vu = dwgUnitFor(m, 'Volume');
  // The shape tools sit in the same list, so they have to answer the same five
  // questions. A radius IS a length and the circle it implies IS an area; an
  // angle is neither, and its Measurement is the number of degrees — which is
  // exactly the figure a formula over a mitre would want.
  if (dwgIsShapeKind(m.kind)) {
    const isAngle = m.kind === 'angle';
    const length = isAngle ? 0 : dwgLengthIn(v.radiusMm, lu);
    const area = isAngle ? 0 : dwgAreaIn(v.circleAreaMm2, au);
    return { length, area, volume: 0, wallArea: 0, count: 0,
             measurement: isAngle ? dwgNum(v.angleDeg) : length,
             lengthUnit: lu, areaUnit: au, volumeUnit: vu };
  }
  const isCount = m.kind === 'count';
  const isLine = m.kind === 'distance';
  const length = isCount ? 0 : isLine ? dwgLengthIn(v.mm, lu) : dwgLengthIn(v.perimeterMm, lu);
  const area = isCount ? 0 : isLine ? dwgAreaIn(v.wallAreaMm2, au) : dwgAreaIn(v.netMm2, au);
  const volume = isCount || isLine ? 0 : dwgVolumeIn(v.volumeMm3, vu);
  const wallArea = dwgAreaIn(v.wallAreaMm2, au);
  const count = isCount ? v.count : 0;
  // "Measurement" is the markup's PRIMARY figure with no unit attached — that
  // is precisely what makes it usable on the left of a formula.
  const measurement = isCount ? count : isLine ? length : area;
  return { length, area, volume, wallArea, count, measurement,
           lengthUnit: lu, areaUnit: au, volumeUnit: vu };
}

// ── Spaces ────────────────────────────────────────────────────────────────
// A Space is a named region on a page: a room, a unit, a level. They nest, and
// the invariant that makes nesting mean anything is that they may CONTAIN one
// another or be disjoint, and may never partially overlap or share a border.
// A drag that would break that is refused and snapped back rather than saved.
const DWG_SPACE_GAP = 0.0015;      // page widths — the clearance borders must keep

function dwgMakeSpace(data, by) {
  return {
    id: uid('dspace'), pageIndex: dwgNum(data.pageIndex),
    name: data.name || 'Space', parentId: data.parentId || null,
    points: (data.points || []).map(p => ({ x: p.x, y: p.y })),
    color: data.color || '#1f7a8c',
    note: data.note || '', active: true,
    createdBy: by || '', createdDate: todayISO(),
  };
}
function dwgSheetSpaces(sheet, pageIndex) {
  const list = ((sheet && sheet.spaces) || []).filter(s => s && s.active !== false);
  return pageIndex === undefined || pageIndex === null ? list
    : list.filter(s => dwgNum(s.pageIndex) === dwgNum(pageIndex));
}
function dwgSpaceById(sheet, id) {
  return id ? (((sheet && sheet.spaces) || []).find(s => s.id === id) || null) : null;
}
function dwgSpaceDepth(sheet, space) {
  let d = 0, cur = space, guard = 0;
  while (cur && cur.parentId && guard++ < 24) { cur = dwgSpaceById(sheet, cur.parentId); if (cur) d++; }
  return d;
}
// "Level 2 → Unit 204 → Bathroom" — the whole point of nesting is that the path
// reads as an address, so it is what the list and the CSV show.
function dwgSpacePath(sheet, space) {
  const parts = [];
  let cur = space, guard = 0;
  while (cur && guard++ < 24) { parts.unshift(cur.name); cur = cur.parentId ? dwgSpaceById(sheet, cur.parentId) : null; }
  return parts.join(' → ');
}
function dwgSpaceBBox(pts) {
  if (!pts || !pts.length) return null;
  const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
  return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
// Distance between two segments, in page-width units — the measure that decides
// "their borders touch". Cheap, and the rings involved are small.
function dwgSegDist(a1, a2, b1, b2) {
  function pd(p, q1, q2) {
    const dx = q2.x - q1.x, dy = q2.y - q1.y;
    const L = dx * dx + dy * dy;
    let t = L ? ((p.x - q1.x) * dx + (p.y - q1.y) * dy) / L : 0;
    t = Math.max(0, Math.min(1, t));
    return Math.hypot(p.x - (q1.x + t * dx), p.y - (q1.y + t * dy));
  }
  return Math.min(pd(a1, b1, b2), pd(a2, b1, b2), pd(b1, a1, a2), pd(b2, a1, a2));
}
function dwgSegsCross(a1, a2, b1, b2) {
  function o(p, q, r) { const v = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y); return v > 1e-12 ? 1 : v < -1e-12 ? -1 : 0; }
  const o1 = o(a1, a2, b1), o2 = o(a1, a2, b2), o3 = o(b1, b2, a1), o4 = o(b1, b2, a2);
  return o1 !== o2 && o3 !== o4;
}
// contains | contained | disjoint | overlap. 'overlap' is the illegal answer and
// covers a touching border as well, because a shared border is exactly as
// ambiguous as a crossing one when a markup lands on it.
function dwgSpaceRelation(ptsA, ptsB, aspect) {
  const A = dwgWidthUnits(ptsA, aspect), B = dwgWidthUnits(ptsB, aspect);
  if (A.length < 3 || B.length < 3) return 'disjoint';
  const bA = dwgSpaceBBox(A), bB = dwgSpaceBBox(B);
  if (bA.x1 + DWG_SPACE_GAP < bB.x0 || bB.x1 + DWG_SPACE_GAP < bA.x0
      || bA.y1 + DWG_SPACE_GAP < bB.y0 || bB.y1 + DWG_SPACE_GAP < bA.y0) return 'disjoint';
  for (let i = 0; i < A.length; i++) {
    const a1 = A[i], a2 = A[(i + 1) % A.length];
    for (let j = 0; j < B.length; j++) {
      const b1 = B[j], b2 = B[(j + 1) % B.length];
      if (dwgSegsCross(a1, a2, b1, b2)) return 'overlap';
      if (dwgSegDist(a1, a2, b1, b2) < DWG_SPACE_GAP) return 'overlap';
    }
  }
  if (dwgPointInPoly(A[0], B)) return 'contained';
  if (dwgPointInPoly(B[0], A)) return 'contains';
  return 'disjoint';
}
// A Space fully inside another nests automatically — nobody should have to say
// so, and a person picking the parent by hand is a person who will pick wrong.
function dwgValidateSpace(sheet, pageIndex, points, ignoreId, aspect) {
  const others = dwgSheetSpaces(sheet, pageIndex).filter(s => s.id !== ignoreId);
  let parent = null, parentArea = Infinity;
  for (let i = 0; i < others.length; i++) {
    const rel = dwgSpaceRelation(points, others[i].points, aspect);
    if (rel === 'overlap') {
      return { ok: false, parentId: null,
               message: `That would partly overlap “${others[i].name}”, or sit right on its border. Spaces may nest or be separate — never half in and half out — so the drag was snapped back.` };
    }
    if (rel === 'contained') {
      const a = Math.abs(dwgSignedArea(others[i].points, aspect));
      if (a < parentArea) { parent = others[i]; parentArea = a; }
    }
  }
  return { ok: true, parentId: parent ? parent.id : null, parent };
}
// Attribution is POSITIONAL. The deepest space that contains the point wins,
// because "Bathroom" is a more useful answer than "Level 2".
function dwgSpaceAt(sheet, pageIndex, pt) {
  const hits = dwgSheetSpaces(sheet, pageIndex).filter(s => dwgPointInPoly(pt, s.points));
  if (!hits.length) return null;
  return hits.slice().sort((a, b) => dwgSpaceDepth(sheet, b) - dwgSpaceDepth(sheet, a)
    || Math.abs(dwgSignedArea(a.points, 1)) - Math.abs(dwgSignedArea(b.points, 1)))[0];
}
// Where a markup sits, for attribution: a count is at its first pin, everything
// else at its centroid.
function dwgMarkupAnchor(m) {
  const pts = dwgRingPoints(m);
  if (!pts.length) return { x: 0, y: 0 };
  if (m.kind === 'count') return { x: pts[0].x, y: pts[0].y };
  return dwgCentroid(pts);
}

// ── Viewports ─────────────────────────────────────────────────────────────
function dwgMakeViewport(data, by) {
  return {
    id: uid('dvp'), pageIndex: dwgNum(data.pageIndex),
    name: data.name || '',
    points: (data.points || []).map(p => ({ x: p.x, y: p.y })),
    scaleLabel: data.scaleLabel || '',
    mmPerPageWidth: dwgNum(data.mmPerPageWidth),
    knownText: data.knownText || '', source: data.source || 'Typed scale',
    note: data.note || '', active: true,
    createdBy: by || '', createdDate: todayISO(),
  };
}

// ── Custom columns ────────────────────────────────────────────────────────
// This is where a take-off stops being a list of quantities and becomes an
// estimate. Six types, and one of them is a formula over the others.
const DWG_COLUMN_TYPES = ['Text', 'Number', 'Choice', 'Formula', 'Date', 'Checkmark'];
const DWG_NUMBER_FORMATS = ['Normal', 'Currency', 'Percentage'];

function dwgMakeColumn(data, by) {
  return {
    id: uid('tocol'), name: (data.name || '').trim(), type: data.type || 'Text',
    // Text
    multiline: data.multiline !== false,
    // Number
    numberFormat: data.numberFormat || 'Normal',
    decimals: data.decimals === undefined || data.decimals === null ? 2 : dwgNum(data.decimals),
    min: data.min === undefined || data.min === null || data.min === '' ? null : Number(data.min),
    max: data.max === undefined || data.max === null || data.max === '' ? null : Number(data.max),
    defaultValue: data.defaultValue === undefined ? null : data.defaultValue,
    includeInTotals: !!data.includeInTotals,
    // Choice — an item may carry a NUMBER, which is what makes
    // `Measurement * Material` work: the choice is a name to a person and a
    // unit cost to the arithmetic.
    items: (data.items || []).map(i => ({ id: i.id || uid('tochoice'), label: i.label || '',
      value: i.value === undefined || i.value === null || i.value === '' ? null : Number(i.value),
      subjects: Array.isArray(i.subjects) ? i.subjects : [] })),
    allowCustomText: !!data.allowCustomText,
    // Formula
    formula: data.formula || '',
    active: true, order: dwgNum(data.order),
    createdBy: by || '', createdDate: todayISO(),
  };
}
function dwgColumns(project) { return ((project && project.takeoffColumns) || []).filter(c => c.active !== false); }
function dwgColumnSets(project) { return ((project && project.takeoffColumnSets) || []).filter(s => s.active !== false); }
function dwgFilterSets(project) { return ((project && project.takeoffFilterSets) || []).filter(s => s.active !== false); }

// ── The formula language ──────────────────────────────────────────────────
// Deliberately NOT extended beyond what Revu documents, because the constraint
// is the feature: a formula is a per-row scalar expression, so a column can
// always be explained by pointing at one row.
//
//   expression := term (('+' | '-') term)*
//   term       := power (('*' | '/' | '%') power)*
//   power      := unary ('^' power)?                 -- right associative
//   unary      := ('-' | '+') unary | primary
//   primary    := NUMBER | CONSTANT | VARIABLE
//               | FUNCTION '(' expression ')'
//               | '(' expression ')'
//
// There is no `if`, no text function, no aggregate and no reference to another
// ROW. Variables are the five built-ins and any custom column by name.
const DWG_FORMULA_VARS = ['Area', 'Count', 'Length', 'Measurement', 'Volume'];
const DWG_FORMULA_CONSTS = { e: Math.E, pi: Math.PI };
const DWG_FORMULA_FUNCS = {
  acos: Math.acos, asin: Math.asin, atan: Math.atan,
  ceiling: Math.ceil, cos: Math.cos, floor: Math.floor,
  ln: Math.log, log: x => Math.log(x) / Math.LN10,
  round: Math.round, sin: Math.sin, sqrt: Math.sqrt, tan: Math.tan,
};
const DWG_FORMULA_FUNC_NAMES = Object.keys(DWG_FORMULA_FUNCS);
const DWG_OP_PREC = { '+': 1, '-': 1, '*': 2, '/': 2, '%': 2, '^': 4 };

// Column names contain spaces — "Unit Cost" is what a person writes — so the
// tokenizer matches the LONGEST known variable name at each position rather
// than splitting on whitespace. Square brackets are accepted as an escape for a
// name that would otherwise be ambiguous; that is lexical, not a new operator.
function dwgTokenizeFormula(src, names) {
  const s = String(src || '');
  const sorted = (names || []).slice().sort((a, b) => b.length - a.length);
  const out = [];
  let i = 0;
  while (i < s.length) {
    const c = s[i];
    if (/\s/.test(c)) { i++; continue; }
    if (c === '[') {
      const end = s.indexOf(']', i + 1);
      if (end < 0) return { error: 'A “[” was opened and never closed.' };
      const nm = s.slice(i + 1, end).trim();
      const hit = sorted.find(n => n.toLowerCase() === nm.toLowerCase());
      if (!hit) return { error: `There is no column called “${nm}”.` };
      out.push({ t: 'var', name: hit }); i = end + 1; continue;
    }
    if (/[0-9.]/.test(c)) {
      let j = i;
      while (j < s.length && /[0-9.]/.test(s[j])) j++;
      const n = Number(s.slice(i, j));
      if (!isFinite(n)) return { error: `“${s.slice(i, j)}” is not a number.` };
      out.push({ t: 'num', v: n }); i = j; continue;
    }
    if (c === '(') { out.push({ t: 'lp' }); i++; continue; }
    if (c === ')') { out.push({ t: 'rp' }); i++; continue; }
    if ('+-*/^%'.indexOf(c) >= 0) { out.push({ t: 'op', op: c }); i++; continue; }
    if (/[A-Za-z_]/.test(c)) {
      // longest known variable first, and only when the character after it is
      // not itself part of a name — otherwise "Cost" would match inside "Costs".
      const rest = s.slice(i);
      const hit = sorted.find(n => rest.toLowerCase().startsWith(n.toLowerCase())
        && !/[A-Za-z0-9_]/.test(rest.charAt(n.length) || ''));
      if (hit) { out.push({ t: 'var', name: hit }); i += hit.length; continue; }
      let j = i;
      while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;
      const word = s.slice(i, j);
      const lower = word.toLowerCase();
      if (Object.prototype.hasOwnProperty.call(DWG_FORMULA_FUNCS, lower)) { out.push({ t: 'func', name: lower }); i = j; continue; }
      if (Object.prototype.hasOwnProperty.call(DWG_FORMULA_CONSTS, lower)) { out.push({ t: 'num', v: DWG_FORMULA_CONSTS[lower] }); i = j; continue; }
      return { error: `“${word}” is not a column, a constant or one of the functions (${DWG_FORMULA_FUNC_NAMES.join(', ')}).` };
    }
    return { error: `“${c}” means nothing in a formula.` };
  }
  return { tokens: out };
}

// Shunting-yard, straight to an AST rather than to RPN — the two stacks are the
// same, the operand stack just holds nodes instead of numbers.
function dwgParseFormula(src, names) {
  const tk = dwgTokenizeFormula(src, names);
  if (tk.error) return { error: tk.error, refs: [] };
  const toks = tk.tokens;
  if (!toks.length) return { error: 'The formula is empty.', refs: [] };
  const out = [], ops = [];
  const refs = [];
  let expectOperand = true;
  let bad = null;

  function reduce() {
    const op = ops.pop();
    if (!op) { bad = bad || 'The brackets do not balance.'; return false; }
    if (op.t === 'lp') { bad = bad || 'The brackets do not balance.'; return false; }
    if (op.t === 'func' || op.unary) {
      const a = out.pop();
      if (!a) { bad = bad || 'A function or a minus sign has nothing to work on.'; return false; }
      out.push({ t: 'un', op: op.t === 'func' ? op.name : 'neg', a });
      return true;
    }
    const b = out.pop(), a = out.pop();
    if (!a || !b) { bad = bad || `“${op.op}” is missing one of its two sides.`; return false; }
    out.push({ t: 'bin', op: op.op, a, b });
    return true;
  }

  for (let i = 0; i < toks.length && !bad; i++) {
    const t = toks[i];
    if (t.t === 'num') { out.push({ t: 'num', v: t.v }); expectOperand = false; continue; }
    if (t.t === 'var') { out.push({ t: 'var', name: t.name }); if (refs.indexOf(t.name) < 0) refs.push(t.name); expectOperand = false; continue; }
    if (t.t === 'func') {
      if (toks[i + 1] && toks[i + 1].t === 'lp') { ops.push({ t: 'func', name: t.name }); expectOperand = true; continue; }
      bad = `${t.name} needs brackets round what it works on — ${t.name}(x).`; break;
    }
    if (t.t === 'lp') { ops.push({ t: 'lp' }); expectOperand = true; continue; }
    if (t.t === 'rp') {
      let guard = 0;
      while (ops.length && ops[ops.length - 1].t !== 'lp' && guard++ < 999) { if (!reduce()) break; }
      if (bad) break;
      if (!ops.length) { bad = 'There is a “)” with no “(” to match it.'; break; }
      ops.pop();
      if (ops.length && ops[ops.length - 1].t === 'func') reduce();
      expectOperand = false;
      continue;
    }
    if (t.t === 'op') {
      if (expectOperand) {
        if (t.op === '-') { ops.push({ t: 'op', op: 'neg', unary: true, prec: 3 }); continue; }
        if (t.op === '+') { continue; }                       // unary plus is a no-op
        bad = `“${t.op}” has nothing on its left.`; break;
      }
      const prec = DWG_OP_PREC[t.op];
      const rightAssoc = t.op === '^';
      let guard = 0;
      while (ops.length && guard++ < 999) {
        const top = ops[ops.length - 1];
        if (top.t === 'lp') break;
        const tp = top.t === 'func' ? 9 : (top.prec || DWG_OP_PREC[top.op] || 0);
        if (tp > prec || (tp === prec && !rightAssoc)) { if (!reduce()) break; } else break;
      }
      if (bad) break;
      ops.push({ t: 'op', op: t.op, prec });
      expectOperand = true;
      continue;
    }
  }
  let guard = 0;
  while (!bad && ops.length && guard++ < 999) { if (!reduce()) break; }
  if (bad) return { error: bad, refs };
  if (out.length !== 1) return { error: 'That is not a complete expression.', refs };
  return { ast: out[0], refs };
}

function dwgEvalFormulaAst(node, vars) {
  if (!node) return NaN;
  if (node.t === 'num') return node.v;
  if (node.t === 'var') {
    const v = vars[node.name];
    return v === undefined || v === null || v === '' ? 0 : Number(v);
  }
  if (node.t === 'un') {
    const a = dwgEvalFormulaAst(node.a, vars);
    if (node.op === 'neg') return -a;
    const fn = DWG_FORMULA_FUNCS[node.op];
    return fn ? fn(a) : NaN;
  }
  const a = dwgEvalFormulaAst(node.a, vars), b = dwgEvalFormulaAst(node.b, vars);
  switch (node.op) {
    case '+': return a + b;
    case '-': return a - b;
    case '*': return a * b;
    case '/': return b === 0 ? NaN : a / b;
    case '%': return b === 0 ? NaN : a % b;
    case '^': return Math.pow(a, b);
    default: return NaN;
  }
}

// A column may depend on another column. That makes a graph, and a graph can
// have a cycle — Cost = Measurement * Rate while Rate = Cost / Measurement is
// not a mistake anybody makes on purpose, but it is one that hangs a browser.
// So the graph is built once, cycles are named, and a column in one evaluates
// to an error rather than to a number.
function dwgBuildColumnPlan(columns) {
  const cols = (columns || []).filter(c => c.active !== false);
  const names = DWG_FORMULA_VARS.concat(cols.map(c => c.name).filter(Boolean));
  const byName = {};
  cols.forEach(c => { if (c.name) byName[c.name.toLowerCase()] = c; });
  const parsed = {}, errors = {}, deps = {};
  cols.forEach(c => {
    if (c.type !== 'Formula') { deps[c.id] = []; return; }
    const r = dwgParseFormula(c.formula, names);
    if (r.error) { errors[c.id] = r.error; deps[c.id] = []; return; }
    parsed[c.id] = r.ast;
    deps[c.id] = (r.refs || []).map(n => byName[n.toLowerCase()]).filter(Boolean).map(x => x.id);
  });
  // Depth-first with three colours. Grey means "on the current path", so a grey
  // hit IS the cycle and the path back to it is the message.
  const colour = {}, order = [], cycles = [];
  const path = [];
  function visit(id) {
    if (colour[id] === 2) return;
    if (colour[id] === 1) {
      const at = path.indexOf(id);
      const names2 = path.slice(at < 0 ? 0 : at).concat([id])
        .map(x => (cols.find(c => c.id === x) || {}).name || '?');
      cycles.push(names2);
      return;
    }
    colour[id] = 1; path.push(id);
    (deps[id] || []).forEach(visit);
    path.pop(); colour[id] = 2; order.push(id);
  }
  cols.forEach(c => visit(c.id));
  const inCycle = {};
  cycles.forEach(cy => cy.forEach(n => {
    const c = cols.find(x => (x.name || '') === n);
    if (c) inCycle[c.id] = true;
  }));
  Object.keys(inCycle).forEach(id => {
    errors[id] = 'This column refers to itself, through other columns. Nothing is calculated until the loop is broken.';
  });
  return { cols, names, byName, parsed, errors, deps, order, cycles, inCycle };
}

// One row's values, resolved in dependency order. `stored` is the markup's own
// custom map; a Choice contributes its NUMBER to the arithmetic and its LABEL
// to the eye.
function dwgResolveRowColumns(plan, baseVars, stored) {
  const vars = { ...baseVars };
  const display = {};
  const raw = stored || {};
  plan.cols.forEach(c => {
    if (c.type === 'Formula') return;
    let v = raw[c.id];
    if ((v === undefined || v === null || v === '') && c.defaultValue !== null && c.defaultValue !== undefined) v = c.defaultValue;
    display[c.id] = v === undefined || v === null ? '' : v;
    if (c.type === 'Number') vars[c.name] = v === '' || v === undefined || v === null ? 0 : Number(v);
    else if (c.type === 'Checkmark') vars[c.name] = v ? 1 : 0;
    else if (c.type === 'Choice') {
      const item = (c.items || []).find(x => x.label === v);
      vars[c.name] = item && item.value !== null && item.value !== undefined ? Number(item.value) : 0;
    } else vars[c.name] = 0;
  });
  plan.order.forEach(id => {
    const c = plan.cols.find(x => x.id === id);
    if (!c || c.type !== 'Formula') return;
    if (plan.errors[id]) { display[id] = '—'; vars[c.name] = 0; return; }
    const n = dwgEvalFormulaAst(plan.parsed[id], vars);
    vars[c.name] = isFinite(n) ? n : 0;
    display[id] = isFinite(n) ? n : NaN;
  });
  return { vars, display };
}
function dwgFmtColumnValue(col, v) {
  if (v === undefined || v === null || v === '') return '';
  if (col.type === 'Checkmark') return v ? '✓' : '';
  if (col.type === 'Number' || col.type === 'Formula') {
    const n = Number(v);
    if (!isFinite(n)) return '—';
    const d = Math.max(0, Math.min(6, dwgNum(col.decimals)));
    const body = n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
    if (col.numberFormat === 'Currency') return `$${body}`;
    if (col.numberFormat === 'Percentage') return `${body}%`;
    return body;
  }
  return String(v);
}

// ── Raster engine: the ink mask, and Dynamic Fill ─────────────────────────
// This is ordinary image processing. No model, no server, nothing that reads
// the drawing: it thresholds the rendered page into ink and paper, closes the
// gaps the drawing leaves, floods from where you clicked, and traces what it
// filled. Every step below is one a person can check by eye.
const DWG_FILL_DEFAULT_DPI = 150;
const DWG_FILL_MAX_PIXELS = 12000000;     // a 24×36 at 150 DPI is 19M; this caps it

function dwgRenderInkMask(assetId, pageIndex, opts) {
  const o = opts || {};
  const dpi = dwgNum(o.dpi) || DWG_FILL_DEFAULT_DPI;
  const maxPixels = dwgNum(o.maxPixels) || DWG_FILL_MAX_PIXELS;
  return officePdfPageSize(assetId, pageIndex).then(sz => {
    let scale = dpi / 72;
    const px = sz.width * scale * sz.height * scale;
    // Downscaling is not a compromise here — the polygon is simplified to about
    // a pixel and a half anyway, so beyond a point the extra pixels buy nothing
    // but memory.
    if (px > maxPixels) scale = Math.sqrt(maxPixels / (sz.width * sz.height));
    return officePdfRenderPage(assetId, pageIndex, { scale, dpr: 1 }).then(r => {
      const w = r.canvas.width, h = r.canvas.height;
      const data = r.canvas.getContext('2d').getImageData(0, 0, w, h).data;
      const ink = new Uint8Array(w * h);
      const thr = o.threshold === undefined || o.threshold === null ? 190 : dwgNum(o.threshold);
      for (let i = 0, p = 0; i < ink.length; i++, p += 4) {
        // Transparent is PAPER, not ink. pdf.js leaves the page background
        // unpainted, and treating alpha 0 as black fills nothing at all.
        if (data[p + 3] < 16) { ink[i] = 0; continue; }
        const lum = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
        ink[i] = lum < thr ? 1 : 0;
      }
      return { w, h, ink, canvas: r.canvas, scale };
    });
  });
}

// Separable 1-D window filters over a 0/1 image. Because the values are binary,
// a sliding max is "is there a 1 in the window" and a sliding min is "are they
// all 1" — a running count, O(1) a pixel, no deque needed.
function dwgWindowFilter(src, dst, w, h, k, vertical, dilate) {
  const span = 2 * k + 1;
  if (vertical) {
    for (let x = 0; x < w; x++) {
      let sum = 0;
      for (let y = -k; y <= k; y++) if (y >= 0 && y < h) sum += src[y * w + x];
      for (let y = 0; y < h; y++) {
        // Outside the page is paper for a dilation and ink for an erosion, so
        // closing does not eat the sheet's own border.
        const outside = span - Math.min(h - 1, y + k) + Math.max(0, y - k) - 1;
        const eff = dilate ? sum : sum + outside;
        dst[y * w + x] = dilate ? (sum > 0 ? 1 : 0) : (eff >= span ? 1 : 0);
        const drop = y - k, add = y + k + 1;
        if (drop >= 0) sum -= src[drop * w + x];
        if (add < h) sum += src[add * w + x];
      }
    }
  } else {
    for (let y = 0; y < h; y++) {
      const row = y * w;
      let sum = 0;
      for (let x = -k; x <= k; x++) if (x >= 0 && x < w) sum += src[row + x];
      for (let x = 0; x < w; x++) {
        const outside = span - Math.min(w - 1, x + k) + Math.max(0, x - k) - 1;
        const eff = dilate ? sum : sum + outside;
        dst[row + x] = dilate ? (sum > 0 ? 1 : 0) : (eff >= span ? 1 : 0);
        const drop = x - k, add = x + k + 1;
        if (drop >= 0) sum -= src[row + drop];
        if (add < w) sum += src[row + add];
      }
    }
  }
}
// Morphological CLOSING — dilate by k, then erode by k. It is what handles the
// ubiquitous wall that does not quite meet a door jamb, and k is exactly what
// the Edge Sensitivity slider sets. Closing puts the ink back where it was and
// leaves the bridged gaps behind, so nothing on the drawing moves.
function dwgCloseMask(ink, w, h, k) {
  if (!k) return ink;
  const a = new Uint8Array(w * h), b = new Uint8Array(w * h);
  dwgWindowFilter(ink, a, w, h, k, false, true);
  dwgWindowFilter(a, b, w, h, k, true, true);
  dwgWindowFilter(b, a, w, h, k, false, false);
  dwgWindowFilter(a, b, w, h, k, true, false);
  return b;
}
// A user-drawn boundary is painted into the MASK and nowhere else. It closes a
// threshold the drawing leaves open without putting a line on the sheet.
function dwgPaintBoundary(ink, w, h, pts, widthPx) {
  const r = Math.max(1, Math.floor(dwgNum(widthPx) || 2));
  for (let i = 1; i < pts.length; i++) {
    const x0 = Math.round(pts[i - 1].x * w), y0 = Math.round(pts[i - 1].y * h);
    const x1 = Math.round(pts[i].x * w), y1 = Math.round(pts[i].y * h);
    const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
    for (let s = 0; s <= steps; s++) {
      const x = Math.round(x0 + (x1 - x0) * s / steps), y = Math.round(y0 + (y1 - y0) * s / steps);
      for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
        const nx = x + dx, ny = y + dy;
        if (nx >= 0 && nx < w && ny >= 0 && ny < h) ink[ny * w + nx] = 1;
      }
    }
  }
}

// Span-based (scanline) flood fill with an EXPLICIT stack in a typed array.
// Never recursion: a room on a 5400×3600 sheet is millions of pixels and a
// recursive fill blows the JS stack long before it finishes.
function dwgFillBegin(ink, w, h, sx, sy) {
  if (sx < 0 || sy < 0 || sx >= w || sy >= h) return null;
  if (ink[sy * w + sx]) return null;                     // clicked on a line
  const st = { ink, w, h, region: new Uint8Array(w * h), stack: new Int32Array(4096), sp: 0,
               count: 0, x0: sx, x1: sx, y0: sy, y1: sy, done: false, overflow: false,
               limit: w * h };
  dwgFillPush(st, sx, sy);
  return st;
}
function dwgFillPush(st, x, y) {
  if (st.sp + 2 > st.stack.length) {
    const n = new Int32Array(st.stack.length * 2);
    n.set(st.stack); st.stack = n;
  }
  st.stack[st.sp++] = x; st.stack[st.sp++] = y;
}
function dwgFillScanRow(st, xa, xb, y) {
  if (y < 0 || y >= st.h) return;
  const row = y * st.w;
  let x = xa;
  while (x <= xb) {
    while (x <= xb && (st.ink[row + x] || st.region[row + x])) x++;
    if (x > xb) return;
    const start = x;
    while (x <= xb && !st.ink[row + x] && !st.region[row + x]) x++;
    dwgFillPush(st, start, y);
  }
}
// `budget` spans per call. That is what makes hold-to-grow feel controllable:
// the fill advances a bounded amount each animation frame and is painted as it
// goes, rather than freezing and then appearing.
function dwgFillStep(st, budget) {
  let work = 0;
  while (st.sp > 0 && work < budget) {
    const y = st.stack[--st.sp], x = st.stack[--st.sp];
    const row = y * st.w;
    if (st.ink[row + x] || st.region[row + x]) { work++; continue; }
    let a = x; while (a >= 0 && !st.ink[row + a] && !st.region[row + a]) a--;
    a++;
    let b = x; while (b < st.w && !st.ink[row + b] && !st.region[row + b]) b++;
    b--;
    for (let i = a; i <= b; i++) st.region[row + i] = 1;
    st.count += b - a + 1;
    if (a < st.x0) st.x0 = a;
    if (b > st.x1) st.x1 = b;
    if (y < st.y0) st.y0 = y;
    if (y > st.y1) st.y1 = y;
    dwgFillScanRow(st, a, b, y - 1);
    dwgFillScanRow(st, a, b, y + 1);
    work++;
    if (st.count > st.limit) { st.overflow = true; break; }
  }
  st.done = st.sp === 0 || st.overflow;
  return st;
}

// Moore-neighbour boundary tracing with Jacob's stopping criterion. Marching
// squares would do as well; this is fewer moving parts and gives the ring in
// order, which is what the shoelace needs.
const DWG_MOORE = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]];
function dwgTraceRing(get, sx, sy, maxSteps) {
  const ring = [[sx, sy]];
  let cur = [sx, sy];
  let prev = [sx - 1, sy];                 // we start at a leftmost pixel
  const startPrev = [prev[0], prev[1]];
  let steps = 0;
  const cap = maxSteps || 2000000;
  for (;;) {
    let d = 0;
    for (let i = 0; i < 8; i++) {
      if (cur[0] + DWG_MOORE[i][0] === prev[0] && cur[1] + DWG_MOORE[i][1] === prev[1]) { d = i; break; }
    }
    let found = null, backtrack = null;
    for (let i = 1; i <= 8; i++) {
      const j = (d + i) % 8;
      const nx = cur[0] + DWG_MOORE[j][0], ny = cur[1] + DWG_MOORE[j][1];
      if (get(nx, ny)) {
        found = [nx, ny];
        const p = (j + 7) % 8;
        backtrack = [cur[0] + DWG_MOORE[p][0], cur[1] + DWG_MOORE[p][1]];
        break;
      }
    }
    if (!found) break;                                   // a single isolated pixel
    prev = backtrack; cur = found;
    ring.push([cur[0], cur[1]]);
    steps++;
    if (cur[0] === sx && cur[1] === sy
        && prev[0] === startPrev[0] && prev[1] === startPrev[1]) break;
    if (cur[0] === sx && cur[1] === sy && steps > 3) break;
    if (steps > cap) break;
  }
  return ring;
}

// Ramer–Douglas–Peucker. Not optional: a traced room boundary is one point per
// boundary PIXEL — forty thousand of them for an ordinary room — and neither
// the SVG nor localStorage nor a person editing it wants that.
function dwgRdp(points, eps) {
  const n = points.length;
  if (n < 3) return points.slice();
  const keep = new Uint8Array(n);
  keep[0] = 1; keep[n - 1] = 1;
  const stack = [[0, n - 1]];
  while (stack.length) {
    const [a, b] = stack.pop();
    if (b <= a + 1) continue;
    const ax = points[a][0], ay = points[a][1];
    const bx = points[b][0], by = points[b][1];
    const dx = bx - ax, dy = by - ay;
    const len = Math.hypot(dx, dy) || 1;
    let far = -1, best = eps;
    for (let i = a + 1; i < b; i++) {
      const d = Math.abs((points[i][0] - ax) * dy - (points[i][1] - ay) * dx) / len;
      if (d > best) { best = d; far = i; }
    }
    if (far > 0) { keep[far] = 1; stack.push([a, far]); stack.push([far, b]); }
  }
  const out = [];
  for (let i = 0; i < n; i++) if (keep[i]) out.push(points[i]);
  return out;
}

// The filled region turned into rings: one outer, and one per enclosed hole.
// Holes are found by flooding the BACKGROUND in from the padded border — what
// the background cannot reach is enclosed, which is exactly the definition.
function dwgFillContours(st, eps) {
  const w = st.w, h = st.h;
  const px0 = Math.max(0, st.x0 - 1), py0 = Math.max(0, st.y0 - 1);
  const px1 = Math.min(w - 1, st.x1 + 1), py1 = Math.min(h - 1, st.y1 + 1);
  const bw = px1 - px0 + 1, bh = py1 - py0 + 1;
  const inRegion = (x, y) => (x >= 0 && y >= 0 && x < bw && y < bh)
    ? st.region[(y + py0) * w + (x + px0)] : 0;

  // outer ring — start at the topmost-then-leftmost region pixel
  let sx = -1, sy = -1;
  for (let y = 0; y < bh && sy < 0; y++) for (let x = 0; x < bw; x++) if (inRegion(x, y)) { sx = x; sy = y; break; }
  if (sy < 0) return { outer: [], holes: [] };
  const outerPx = dwgTraceRing(inRegion, sx, sy);

  // holes — background reachable from the padded border is "outside"
  const outside = new Uint8Array(bw * bh);
  const stack = [];
  for (let x = 0; x < bw; x++) { stack.push(x, 0); stack.push(x, bh - 1); }
  for (let y = 0; y < bh; y++) { stack.push(0, y); stack.push(bw - 1, y); }
  while (stack.length) {
    const y = stack.pop(), x = stack.pop();
    if (x < 0 || y < 0 || x >= bw || y >= bh) continue;
    const i = y * bw + x;
    if (outside[i] || inRegion(x, y)) continue;
    outside[i] = 1;
    stack.push(x + 1, y); stack.push(x - 1, y); stack.push(x, y + 1); stack.push(x, y - 1);
  }
  const holes = [];
  const seen = new Uint8Array(bw * bh);
  for (let y = 0; y < bh; y++) for (let x = 0; x < bw; x++) {
    const i = y * bw + x;
    if (inRegion(x, y) || outside[i] || seen[i]) continue;
    // a fresh enclosed component: trace it, then mark it so it is traced once
    const hole = [];
    const s2 = [x, y];
    const st2 = [s2];
    let minX = x, minY = y;
    while (st2.length) {
      const p = st2.pop();
      const px = p[0], py = p[1];
      if (px < 0 || py < 0 || px >= bw || py >= bh) continue;
      const j = py * bw + px;
      if (seen[j] || inRegion(px, py) || outside[j]) continue;
      seen[j] = 1; hole.push(j);
      if (py < minY || (py === minY && px < minX)) { minX = px; minY = py; }
      st2.push([px + 1, py]); st2.push([px - 1, py]); st2.push([px, py + 1]); st2.push([px, py - 1]);
    }
    if (hole.length < 12) continue;                     // a speck is not an opening
    const inHole = (hx, hy) => (hx >= 0 && hy >= 0 && hx < bw && hy < bh
      && seen[hy * bw + hx] && !inRegion(hx, hy) && !outside[hy * bw + hx]) ? 1 : 0;
    holes.push(dwgTraceRing(inHole, minX, minY));
  }

  const e = eps === undefined || eps === null ? 1.5 : eps;
  const toFrac = ring => dwgRdp(ring, e).map(p => ({ x: (p[0] + px0) / w, y: (p[1] + py0) / h }));
  return { outer: toFrac(outerPx), holes: holes.map(toFrac).filter(r => r.length >= 3) };
}

// ── The vector index, and snapping to the drawing's own linework ──────────
// getOperatorList() hands back the page's real path geometry — the same ops the
// canvas renderer draws with — so the linework IS reachable. Every vertex is
// pushed through the CTM stack into viewport space and then stored as page
// fractions, which is the one coordinate system that survives zoom and rotation.
//
// The honest limit is the same one Revu has: this works on VECTOR pages. A scan
// has no path operators at all, and the surface says snapping is unavailable
// there rather than snapping to nothing.
const DWG_VECTOR_MAX_SEGMENTS = 200000;
const DWG_VECTOR_GRID = 160;

function dwgMatMul(m1, m2) {
  // m1 ∘ m2 — apply m2 first, then m1. Same convention as pdf.js's Util.transform,
  // which is what its own canvas renderer uses, so the numbers agree with what
  // is drawn on screen.
  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 dwgApplyMat(m, x, y) { return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; }

function dwgWalkOperatorList(OPS, opList, viewport, maxSegments) {
  const W = viewport.width, H = viewport.height;
  const segs = [];       // flat x1,y1,x2,y2 in page fractions
  const verts = [];      // flat x,y
  let ctm = viewport.transform.slice();
  const stack = [];
  let truncated = false;
  let paths = 0;

  function push(x0, y0, x1, y1) {
    if (segs.length / 4 >= (maxSegments || DWG_VECTOR_MAX_SEGMENTS)) { truncated = true; return; }
    segs.push(x0 / W, y0 / H, x1 / W, y1 / H);
  }
  function vert(x, y) { verts.push(x / W, y / H); }

  const fns = opList.fnArray || [], args = opList.argsArray || [];
  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 = dwgMatMul(ctm, a); continue; }
    // A form XObject is the PDF's "block reference". pdf.js inlines its content
    // between Begin/End, so honouring the transform expands it one level — the
    // same depth the fenestration DXF reader expands INSERT to.
    if (fn === OPS.paintFormXObjectBegin) {
      stack.push(ctm.slice());
      const a = args[i];
      if (a && a[0] && a[0].length >= 6) ctm = dwgMatMul(ctm, a[0]);
      continue;
    }
    if (fn === OPS.paintFormXObjectEnd) { const m = stack.pop(); if (m) ctm = m; continue; }
    if (fn !== OPS.constructPath) continue;
    const a = args[i];
    if (!a || !a[0] || !a[1]) continue;
    const ops = a[0], co = a[1];
    let j = 0, cx = 0, cy = 0, sxp = 0, syp = 0, has = false;
    paths++;
    for (let k = 0; k < ops.length; k++) {
      const op = ops[k] | 0;
      if (op === OPS.moveTo) {
        cx = co[j++]; cy = co[j++];
        sxp = cx; syp = cy; has = true;
        const p = dwgApplyMat(ctm, cx, cy); vert(p[0], p[1]);
      } else if (op === OPS.lineTo) {
        const nx = co[j++], ny = co[j++];
        if (has) { const p0 = dwgApplyMat(ctm, cx, cy), p1 = dwgApplyMat(ctm, nx, ny); push(p0[0], p0[1], p1[0], p1[1]); }
        cx = nx; cy = ny; has = true;
        const p = dwgApplyMat(ctm, cx, cy); vert(p[0], p[1]);
      } else if (op === OPS.curveTo || op === OPS.curveTo2 || op === OPS.curveTo3) {
        let c1x, c1y, c2x, c2y, ex, ey;
        if (op === OPS.curveTo) { c1x = co[j++]; c1y = co[j++]; c2x = co[j++]; c2y = co[j++]; ex = co[j++]; ey = co[j++]; }
        else if (op === OPS.curveTo2) { c1x = cx; c1y = cy; c2x = co[j++]; c2y = co[j++]; ex = co[j++]; ey = co[j++]; }
        else { c1x = co[j++]; c1y = co[j++]; ex = co[j++]; ey = co[j++]; c2x = ex; c2y = ey; }
        // Flattened to 8 chords. A snap target does not need the curve, it needs
        // points on it, and 8 is well under a pixel at any sane zoom.
        let px = cx, py = cy;
        for (let s = 1; s <= 8; s++) {
          const t = s / 8, u = 1 - t;
          const qx = u * u * u * cx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex;
          const qy = u * u * u * cy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey;
          const p0 = dwgApplyMat(ctm, px, py), p1 = dwgApplyMat(ctm, qx, qy);
          push(p0[0], p0[1], p1[0], p1[1]);
          px = qx; py = qy;
        }
        cx = ex; cy = ey; has = true;
        const p = dwgApplyMat(ctm, cx, cy); vert(p[0], p[1]);
      } else if (op === OPS.closePath) {
        if (has) { const p0 = dwgApplyMat(ctm, cx, cy), p1 = dwgApplyMat(ctm, sxp, syp); push(p0[0], p0[1], p1[0], p1[1]); }
        cx = sxp; cy = syp;
      } else if (op === OPS.rectangle) {
        const rx = co[j++], ry = co[j++], rw = co[j++], rh = co[j++];
        const c = [[rx, ry], [rx + rw, ry], [rx + rw, ry + rh], [rx, ry + rh]];
        for (let s = 0; s < 4; s++) {
          const p0 = dwgApplyMat(ctm, c[s][0], c[s][1]), p1 = dwgApplyMat(ctm, c[(s + 1) % 4][0], c[(s + 1) % 4][1]);
          push(p0[0], p0[1], p1[0], p1[1]);
          vert(p0[0], p0[1]);
        }
        cx = rx; cy = ry; sxp = rx; syp = ry; has = true;
      } else {
        // an op this walk does not model — stop reading THIS path rather than
        // consuming its coordinates out of step and inventing geometry
        break;
      }
    }
  }
  return { segs: new Float32Array(segs), verts: new Float32Array(verts), truncated, paths };
}

// A uniform grid, because the query is always "what is within N screen pixels
// of the cursor" and a uniform grid answers that in constant time without a
// tree to build or balance.
function dwgIndexVectors(raw) {
  const g = DWG_VECTOR_GRID;
  const vcell = [], scell = [];
  for (let i = 0; i < g * g; i++) { vcell.push(null); scell.push(null); }
  const cellOf = (x, y) => {
    const cx = Math.max(0, Math.min(g - 1, Math.floor(x * g)));
    const cy = Math.max(0, Math.min(g - 1, Math.floor(y * g)));
    return cy * g + cx;
  };
  const verts = raw.verts;
  for (let i = 0; i < verts.length; i += 2) {
    const c = cellOf(verts[i], verts[i + 1]);
    if (!vcell[c]) vcell[c] = [];
    vcell[c].push(i);
  }
  const segs = raw.segs;
  for (let i = 0; i < segs.length; i += 4) {
    const x0 = Math.min(segs[i], segs[i + 2]), x1 = Math.max(segs[i], segs[i + 2]);
    const y0 = Math.min(segs[i + 1], segs[i + 3]), y1 = Math.max(segs[i + 1], segs[i + 3]);
    const cx0 = Math.max(0, Math.min(g - 1, Math.floor(x0 * g))), cx1 = Math.max(0, Math.min(g - 1, Math.floor(x1 * g)));
    const cy0 = Math.max(0, Math.min(g - 1, Math.floor(y0 * g))), cy1 = Math.max(0, Math.min(g - 1, Math.floor(y1 * g)));
    // A long diagonal would land in a lot of cells; a segment spanning more than
    // a quarter of the sheet is a border or a hatch and is not a useful snap.
    if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > 900) continue;
    for (let cy = cy0; cy <= cy1; cy++) for (let cx = cx0; cx <= cx1; cx++) {
      const c = cy * g + cx;
      if (!scell[c]) scell[c] = [];
      scell[c].push(i);
    }
  }
  return { ...raw, g, vcell, scell, empty: raw.segs.length === 0 };
}

function dwgBuildVectorIndex(assetId, pageIndex, maxSegments) {
  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 =>
        dwgIndexVectors(dwgWalkOperatorList(pdfjsLib.OPS, list, viewport, maxSegments)));
    })));
}

// Nearest vertex first, then nearest point on a segment — an endpoint or a
// corner is almost always what an estimator meant, and only if there is none
// nearby does the middle of a wall become the answer.
function dwgSnapContent(idx, pt, rx, ry) {
  if (!idx || idx.empty) return null;
  const g = idx.g;
  const cx0 = Math.max(0, Math.floor((pt.x - rx) * g)), cx1 = Math.min(g - 1, Math.floor((pt.x + rx) * g));
  const cy0 = Math.max(0, Math.floor((pt.y - ry) * g)), cy1 = Math.min(g - 1, Math.floor((pt.y + ry) * g));
  const nx = v => (v - pt.x) / rx, ny = v => (v - pt.y) / ry;    // normalised: 1 = on the radius
  let best = null;
  for (let cy = cy0; cy <= cy1; cy++) for (let cx = cx0; cx <= cx1; cx++) {
    const list = idx.vcell[cy * g + cx];
    if (!list) continue;
    for (let k = 0; k < list.length; k++) {
      const i = list[k];
      const d = Math.hypot(nx(idx.verts[i]), ny(idx.verts[i + 1]));
      if (d <= 1 && (!best || d < best.d)) best = { d, x: idx.verts[i], y: idx.verts[i + 1], kind: 'vertex' };
    }
  }
  if (best) return best;
  for (let cy = cy0; cy <= cy1; cy++) for (let cx = cx0; cx <= cx1; cx++) {
    const list = idx.scell[cy * g + cx];
    if (!list) continue;
    for (let k = 0; k < list.length; k++) {
      const i = list[k];
      const ax = nx(idx.segs[i]), ay = ny(idx.segs[i + 1]);
      const bx = nx(idx.segs[i + 2]), by = ny(idx.segs[i + 3]);
      const dx = bx - ax, dy = by - ay;
      const L = dx * dx + dy * dy;
      let t = L ? (-(ax * dx + ay * dy)) / L : 0;
      t = Math.max(0, Math.min(1, t));
      const d = Math.hypot(ax + t * dx, ay + t * dy);
      if (d <= 1 && (!best || d < best.d)) {
        best = { d, kind: 'edge',
                 x: idx.segs[i] + t * (idx.segs[i + 2] - idx.segs[i]),
                 y: idx.segs[i + 1] + t * (idx.segs[i + 3] - idx.segs[i + 1]) };
      }
    }
  }
  return best;
}

// ── Visual search ─────────────────────────────────────────────────────────
// Line art is effectively 1-bit, so matching is a bit comparison: pack both the
// page and the template into 32-bit words and score by Hamming distance over
// XOR. Coarse-to-fine because an exhaustive full-resolution scan of a 19-megapixel
// sheet against a 60×60 symbol is billions of comparisons; a 4× downsample is
// sixteen times cheaper and only has to be right enough to nominate candidates.
//
// It is presented as a REVIEWABLE RESULT LIST, never as a finished count. Even
// Bluebeam's own documentation calls this slow and imperfect, and estimators
// check it by hand.
const DWG_SEARCH_ROTATIONS = [0, 45, 90, 135, 180, 225, 270, 315];

function dwgPopcount(n) {
  n = n - ((n >> 1) & 0x55555555);
  n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
  n = (n + (n >> 4)) & 0x0f0f0f0f;
  return (n * 0x01010101) >> 24;
}
function dwgPackBits(ink, w, h) {
  const words = (w + 31) >> 5;
  const bits = new Uint32Array(words * h);
  for (let y = 0; y < h; y++) {
    const row = y * w, wrow = y * words;
    for (let x = 0; x < w; x++) if (ink[row + x]) bits[wrow + (x >> 5)] |= (1 << (x & 31));
  }
  return { bits, words, w, h };
}
// Box-average downsample of a binary mask. A cell counts as ink when a quarter
// of it is ink — low enough to keep a thin symbol alive at 4×, high enough that
// a hatch does not turn the page solid.
function dwgDownsampleMask(ink, w, h, f) {
  const nw = Math.max(1, Math.floor(w / f)), nh = Math.max(1, Math.floor(h / f));
  const out = new Uint8Array(nw * nh);
  const need = Math.max(1, Math.round(f * f * 0.25));
  for (let y = 0; y < nh; y++) for (let x = 0; x < nw; x++) {
    let c = 0;
    for (let dy = 0; dy < f; dy++) {
      const sy = y * f + dy;
      if (sy >= h) break;
      const row = sy * w;
      for (let dx = 0; dx < f; dx++) {
        const sx = x * f + dx;
        if (sx >= w) break;
        c += ink[row + sx];
      }
    }
    out[y * nw + x] = c >= need ? 1 : 0;
  }
  return { ink: out, w: nw, h: nh };
}
function dwgCropMask(ink, w, h, x0, y0, cw, ch) {
  const out = new Uint8Array(cw * ch);
  for (let y = 0; y < ch; y++) {
    const sy = y0 + y;
    if (sy < 0 || sy >= h) continue;
    for (let x = 0; x < cw; x++) {
      const sx = x0 + x;
      if (sx < 0 || sx >= w) continue;
      out[y * cw + x] = ink[sy * w + sx];
    }
  }
  return out;
}
// Rotating the TEMPLATE, not the page — one small rotation instead of one huge
// one, per angle.
function dwgRotateMask(ink, w, h, deg) {
  const rad = deg * Math.PI / 180;
  const c = Math.cos(rad), s = Math.sin(rad);
  const nw = Math.ceil(Math.abs(w * c) + Math.abs(h * s));
  const nh = Math.ceil(Math.abs(w * s) + Math.abs(h * c));
  const out = new Uint8Array(nw * nh);
  const cx = w / 2, cy = h / 2, ncx = nw / 2, ncy = nh / 2;
  for (let y = 0; y < nh; y++) for (let x = 0; x < nw; x++) {
    const dx = x - ncx, dy = y - ncy;
    const sx = Math.round(cx + dx * c + dy * s);
    const sy = Math.round(cy - dx * s + dy * c);
    if (sx >= 0 && sy >= 0 && sx < w && sy < h) out[y * nw + x] = ink[sy * w + sx];
  }
  return { ink: out, w: nw, h: nh };
}
// Two numbers from one pass over the words, because both are needed at every
// offset and reading the page bits twice would double the cost of the scan:
//   sim — 1 − (differing bits ÷ compared bits), the Hamming similarity;
//   ink — how many of the template's INK bits are actually present.
// The second is what stops a blank patch of paper scoring beautifully against a
// sparse symbol: almost every bit agrees at zero, so similarity alone would
// nominate every empty corner on the sheet.
function dwgMatchAt(page, tmpl, ox, oy) {
  let diff = 0, ink = 0;
  const total = tmpl.w * tmpl.h;
  for (let y = 0; y < tmpl.h; y++) {
    const py = oy + y;
    const trow = y * tmpl.words;
    if (py < 0 || py >= page.h) { diff += tmpl.w; continue; }
    const prow = py * page.words;
    for (let wI = 0; wI < tmpl.words; wI++) {
      const shift = ox & 31, base = (ox >> 5) + wI;
      let pw = 0;
      if (base >= 0 && base < page.words) pw = page.bits[prow + base] >>> shift;
      if (shift && base + 1 >= 0 && base + 1 < page.words) pw |= page.bits[prow + base + 1] << (32 - shift);
      let mask = 0xffffffff;
      const rem = tmpl.w - wI * 32;
      if (rem < 32) mask = rem <= 0 ? 0 : (rem === 32 ? 0xffffffff : ((1 << rem) - 1));
      const tb = tmpl.bits[trow + wI];
      diff += dwgPopcount(((pw ^ tb) & mask) >>> 0);
      ink += dwgPopcount(((pw & tb) & mask) >>> 0);
    }
  }
  return { sim: 1 - diff / Math.max(1, total), ink };
}
// Non-maximum suppression at ~50% IoU. Without it one symbol yields nine hits,
// because every offset within a pixel or two of the real one also scores well.
function dwgSuppress(hits, iou) {
  const out = [];
  hits.slice().sort((a, b) => b.score - a.score).forEach(h => {
    const clash = out.some(o => {
      const ix = Math.max(0, Math.min(o.x + o.w, h.x + h.w) - Math.max(o.x, h.x));
      const iy = Math.max(0, Math.min(o.y + o.h, h.y + h.h) - Math.max(o.y, h.y));
      const inter = ix * iy;
      const uni = o.w * o.h + h.w * h.h - inter;
      return uni > 0 && inter / uni > iou;
    });
    if (!clash) out.push(h);
  });
  return out;
}

// ── The measuring surface ─────────────────────────────────────────────────
function DwgMeasureWorkspace({ ctx, project, sheet, editable, versionId, onOpenRecord }) {
  const pdf = dwgSheetPdf(sheet);
  const [pageIndex, setPageIndex] = useState(dwgNum(pdf && pdf.pageIndex));
  const [zoom, setZoom] = useState(1);
  const [tool, setTool] = useState('pan');
  const [poly, setPoly] = useState(null);
  const [hover, setHover] = useState(null);
  const [selectedId, setSelectedId] = useState(null);
  const [gridMm, setGridMm] = useState(0);
  const [ortho, setOrtho] = useState(false);
  const [render, setRender] = useState(null);
  const [pageMeta, setPageMeta] = useState(null);
  const [status, setStatus] = useState('');
  const [calDraft, setCalDraft] = useState(null);
  const [recordFor, setRecordFor] = useState(null);
  const [boxW, setBoxW] = useState(880);
  const [find, setFind] = useState('');
  const [hits, setHits] = useState(null);
  // The take-off tool in the estimator's hand. A markup placed while one is
  // active is stamped with its whole property set — subject, layer, colour,
  // depth, custom values — which is the mechanism that makes two people's
  // take-offs on one job comparable without them agreeing anything first.
  const [activeToolId, setActiveToolId] = useState('');
  const [snapContent, setSnapContent] = useState(true);
  const [vecIdx, setVecIdx] = useState(null);       // in memory only, never persisted
  const [vecState, setVecState] = useState('');
  const [fillCfg, setFillCfg] = useState({
    sensitivity: dwgNum(softwareSetting('takeoff', 'fillEdgeSensitivity')) || 2,
    threshold: dwgNum(softwareSetting('takeoff', 'fillInkThreshold')) || 190,
    dpi: dwgNum(softwareSetting('takeoff', 'fillDpi')) || DWG_FILL_DEFAULT_DPI,
    makeSpace: false,
  });
  const [fillState, setFillState] = useState('');
  const [fillResult, setFillResult] = useState(null);
  const [vpDraft, setVpDraft] = useState(null);
  const [spaceDraft, setSpaceDraft] = useState(null);
  const [searchOpen, setSearchOpen] = useState(false);
  const [searchSeed, setSearchSeed] = useState(null);
  const [propsFor, setPropsFor] = useState(null);
  const [bulkOpen, setBulkOpen] = useState(false);
  const [recalcOpen, setRecalcOpen] = useState(false);
  const hostRef = useRef(null);
  const scrollRef = useRef(null);
  const surfaceRef = useRef(null);
  const panRef = useRef(null);
  const finishRef = useRef(null);
  const maskRef = useRef(null);         // { key, w, h, ink } — memory only
  const fillRunRef = useRef(null);
  const fillCanvasRef = useRef(null);
  const lassoRef = useRef(null);

  const cal = dwgCalibrationFor(sheet, pageIndex);
  const allMeasures = dwgSheetMeasures(sheet);
  const measures = allMeasures.filter(m => dwgNum(m.pageIndex) === pageIndex);
  const aspect = render && render.w ? render.h / render.w : 1;
  const width = Math.round(boxW * zoom);
  const pageWidthMm = pageMeta ? pageMeta.width * 25.4 / 72 : 0;
  const items = dwgItemList(project);
  const viewports = dwgSheetViewports(sheet, pageIndex);
  const spaces = dwgSheetSpaces(sheet, pageIndex);
  const boundaries = ((sheet.fillBoundaries || []).filter(b => b.active !== false && dwgNum(b.pageIndex) === pageIndex));
  const tools = dwgAllTools(project);
  const activeTool = dwgToolProps(tools.find(t => t.id === activeToolId));
  // A markup whose calibration id is not the one now in force was measured
  // under a different scale. It is not wrong — it recomputes on read — but the
  // number a person wrote down from it has silently changed, which is exactly
  // what Recalculate exists to surface.
  const mixedScale = measures.filter(m => m.kind !== 'deduct' && !m.viewportId && cal
    && m.calibrationId && m.calibrationId !== cal.id);

  useEffect(() => {
    function sync() { if (scrollRef.current) setBoxW(Math.max(320, scrollRef.current.clientWidth - 4)); }
    sync();
    window.addEventListener('resize', sync);
    return () => window.removeEventListener('resize', sync);
  }, []);

  // A shorter PDF can leave the view pointing past the end of the new file.
  useEffect(() => {
    const pc = pdf ? dwgNum(pdf.pageCount) : 0;
    if (pc && pageIndex > pc - 1) setPageIndex(0);
  }, [pdf && pdf.pageCount]);

  // Render. Guarded here, not at module level, because office-pdf-core.jsx is
  // loaded after this file and none of it exists while this file is evaluated.
  useEffect(() => {
    const host = hostRef.current;
    if (!pdf || !pdf.assetId || !host) { setRender(null); return undefined; }
    if (typeof officePdfLibs !== 'function' || typeof officePdfRenderPage !== 'function'
        || typeof officePdfGetBytes !== 'function' || typeof officePdfPageSize !== 'function') return undefined;
    let dead = false;
    setStatus('Rendering the sheet…');
    officePdfLibs()
      .then(() => officePdfGetBytes(pdf.assetId))
      .then(bytes => {
        // The asset store is a different store from the app's state, so a sheet
        // can point at bytes that are not in THIS browser. Say so plainly.
        if (!bytes) throw new Error('This sheet points at a PDF that is not in this browser’s document store — it was attached somewhere else. Attach it again above.');
        return officePdfPageSize(pdf.assetId, pageIndex);
      })
      .then(sz => { if (!dead) setPageMeta(sz); return officePdfRenderPage(pdf.assetId, pageIndex, { width }); })
      .then(r => {
        if (dead) return;
        host.innerHTML = '';
        r.canvas.style.display = 'block';
        host.appendChild(r.canvas);
        setRender({ w: r.width, h: r.height, pageWidth: r.pageWidth, pageHeight: r.pageHeight, rotation: r.rotation });
        setStatus('');
      })
      .catch(e => { if (!dead) { setRender(null); setStatus(e && e.message ? e.message : String(e)); } });
    return () => { dead = true; };
  }, [pdf && pdf.assetId, pageIndex, width]);

  // The vector index. Built once per page, held in memory, and dropped when the
  // page changes — it is derived from the file and must never be persisted.
  useEffect(() => {
    setVecIdx(null); setVecState('');
    maskRef.current = null;
    if (!pdf || !pdf.assetId || !snapContent) return undefined;
    if (typeof officePdfLibs !== 'function' || typeof officePdfDocument !== 'function') return undefined;
    let dead = false;
    setVecState('Reading the page’s linework…');
    dwgBuildVectorIndex(pdf.assetId, pageIndex,
      dwgNum(softwareSetting('takeoff', 'snapMaxSegments')) || DWG_VECTOR_MAX_SEGMENTS)
      .then(idx => {
        if (dead) return;
        setVecIdx(idx);
        setVecState(idx.empty
          ? 'This page has no vector linework — it is a scan, so there is nothing to snap to. Snapping falls back to your own points and the grid.'
          : `${(idx.segs.length / 4).toLocaleString()} line segments indexed${idx.truncated ? ' (capped — the rest are not snap targets)' : ''}.`);
      })
      .catch(e => { if (!dead) setVecState(e && e.message ? e.message : String(e)); });
    return () => { dead = true; };
  }, [pdf && pdf.assetId, pageIndex, snapContent]);

  useEffect(() => {
    function kd(e) {
      const t = e.target;
      if (t && /^(input|textarea|select)$/i.test(t.tagName)) return;
      if (e.key === 'Shift') setOrtho(true);
      if (e.key === 'Escape') { setPoly(null); setStatus(''); cancelFill(); }
      if (e.key === 'Enter' && finishRef.current) { e.preventDefault(); finishRef.current(); }
    }
    function ku(e) { if (e.key === 'Shift') setOrtho(false); }
    window.addEventListener('keydown', kd);
    window.addEventListener('keyup', ku);
    return () => { window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); };
  }, []);

  // ── snapping ──────────────────────────────────────────────────────────
  // FOUR snaps now, and the first of them is new: the drawing's OWN linework,
  // read out of the page's path operators (see dwgBuildVectorIndex). The other
  // three are to things a person put there — a point already placed, a grid
  // someone set, an orthogonal constraint someone is holding.
  function snapPt(raw, soFar) {
    const w = render ? render.w : 1, h = render ? render.h : 1;
    let best = null;
    const consider = p => {
      const d = Math.hypot((p.x - raw.x) * w, (p.y - raw.y) * h);
      if (d <= DWG_SNAP_PX && (!best || d < best.d)) best = { p, d };
    };
    measures.forEach(m => (m.points || []).forEach(consider));
    (soFar || []).forEach(consider);
    if (cal && (cal.points || []).length) (cal.points || []).forEach(consider);
    if (best) return { x: best.p.x, y: best.p.y, snapped: 'point' };
    if (snapContent && vecIdx && !vecIdx.empty && w && h) {
      const hit = dwgSnapContent(vecIdx, raw, DWG_SNAP_PX / w, DWG_SNAP_PX / h);
      if (hit) return { x: hit.x, y: hit.y, snapped: hit.kind === 'vertex' ? 'a drawing vertex' : 'a drawing line' };
    }
    const last = soFar && soFar.length ? soFar[soFar.length - 1] : null;
    if (ortho && last) {
      // Ortho is judged in page-width units, so "square" means square on the
      // paper rather than square on a stretched pixel grid.
      const p = { x: raw.x, y: raw.y };
      if (Math.abs(p.x - last.x) >= Math.abs((p.y - last.y) * aspect)) p.y = last.y; else p.x = last.x;
      return { x: p.x, y: p.y, snapped: 'ortho' };
    }
    if (gridMm && cal && dwgNum(cal.mmPerPageWidth) > 0) {
      const step = gridMm / dwgNum(cal.mmPerPageWidth);
      if (step > 0.0004) {
        return { x: Math.round(raw.x / step) * step,
                 y: (Math.round((raw.y * aspect) / step) * step) / (aspect || 1), snapped: 'grid' };
      }
    }
    return { x: raw.x, y: raw.y, snapped: null };
  }

  function ptFromEvent(e) {
    const r = surfaceRef.current.getBoundingClientRect();
    return { x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
             y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) };
  }

  const drawTool = DWG_DRAW_TOOL_BY_KEY[tool] || null;
  const structureTool = DWG_STRUCTURE_TOOLS.find(t => t.key === tool) || null;
  const drawing = !!drawTool || !!structureTool || tool === 'calibrate' || tool === 'symbol';
  const dragTool = tool === 'rect' || tool === 'calibrate' || tool === 'deductEllipse'
    || tool === 'viewport' || tool === 'symbol';
  const requireCal = softwareSetting('takeoff', 'requireCalibration') !== false;
  // Spaces, viewports and fill boundaries describe the page, not a quantity, so
  // they can be drawn before a scale exists. A measurement cannot — except an
  // ANGLE, which is the same angle at every scale and so needs none.
  const blocked = !!drawTool && !cal && requireCal && tool !== 'angle';

  // ── Dynamic Fill ──────────────────────────────────────────────────────
  // Step 1-3 of the pipeline: render the page to an offscreen canvas, binarise
  // it into an ink mask, and CLOSE the gaps. The mask is cached per page and per
  // setting in a ref — never in state (it would re-render the sheet on every
  // frame) and never in storage (it is 19 MB of derived bytes).
  function ensureMask() {
    const key = `${pdf && pdf.assetId}|${pageIndex}|${fillCfg.threshold}|${fillCfg.sensitivity}|${fillCfg.dpi}|${boundaries.map(b => b.id).join(',')}`;
    if (maskRef.current && maskRef.current.key === key) return Promise.resolve(maskRef.current);
    setFillState('Preparing the page…');
    return dwgRenderInkMask(pdf.assetId, pageIndex, { dpi: fillCfg.dpi, threshold: fillCfg.threshold })
      .then(m => {
        // Boundaries are painted into the MASK before the closing runs, so a
        // hand-drawn threshold behaves exactly like a wall — and the drawing
        // itself is untouched.
        const ink = new Uint8Array(m.ink);
        boundaries.forEach(b => dwgPaintBoundary(ink, m.w, m.h, b.points, 2));
        const closed = dwgCloseMask(ink, m.w, m.h, Math.max(0, Math.round(fillCfg.sensitivity)));
        const rec = { key, w: m.w, h: m.h, ink: closed };
        maskRef.current = rec;
        setFillState('');
        return rec;
      });
  }

  function paintFillPreview(st) {
    const c = fillCanvasRef.current;
    if (!c || !st) return;
    if (c.width !== st.w || c.height !== st.h) { c.width = st.w; c.height = st.h; }
    const cx = c.getContext('2d');
    const img = cx.createImageData(st.w, st.h);
    const d = img.data;
    for (let i = 0, p = 0; i < st.region.length; i++, p += 4) {
      if (st.region[i]) { d[p] = 58; d[p + 1] = 125; d[p + 2] = 68; d[p + 3] = 110; }
    }
    cx.putImageData(img, 0, 0);
  }
  function clearFillPreview() {
    const c = fillCanvasRef.current;
    if (c && c.width) c.getContext('2d').clearRect(0, 0, c.width, c.height);
  }
  function cancelFill() {
    if (fillRunRef.current) { cancelAnimationFrame(fillRunRef.current.raf); fillRunRef.current = null; }
    clearFillPreview();
    setFillState('');
  }
  // Hold-to-grow: a bounded number of spans per animation frame, painting the
  // growing region as it goes. Release commits whatever it had reached.
  //
  // A Web Worker with OffscreenCanvas would keep the main thread completely
  // free, but a worker in this no-build app has to be assembled from a Blob URL
  // and the page still has to be rasterised on the main thread first, so the
  // work is chunked across animation frames instead. The frame budget below is
  // what keeps the sheet responsive while it grows.
  function startFill(pt) {
    if (!pdf) return;
    cancelFill();
    ensureMask().then(mask => {
      const sx = Math.round(pt.x * mask.w), sy = Math.round(pt.y * mask.h);
      const st = dwgFillBegin(mask.ink, mask.w, mask.h, sx, sy);
      if (!st) {
        setFillState('That click landed on a line, not inside a region. Click in the open space of the room.');
        return;
      }
      const run = { st, raf: 0, seed: pt };
      fillRunRef.current = run;
      const budget = dwgNum(softwareSetting('takeoff', 'fillSpansPerFrame')) || 3000;
      const tick = () => {
        if (fillRunRef.current !== run) return;
        dwgFillStep(st, budget);
        paintFillPreview(st);
        setFillState(st.done
          ? `${st.count.toLocaleString()} pixels — release to keep it.`
          : `Growing… ${st.count.toLocaleString()} pixels. Hold to let it finish, release to stop it here.`);
        if (!st.done) run.raf = requestAnimationFrame(tick);
      };
      run.raf = requestAnimationFrame(tick);
    }).catch(e => setFillState(e && e.message ? e.message : String(e)));
  }
  function commitFill() {
    const run = fillRunRef.current;
    if (!run) return;
    cancelAnimationFrame(run.raf);
    const st = run.st;
    fillRunRef.current = null;
    if (!st.count) { clearFillPreview(); setFillState('Nothing was filled.'); return; }
    setFillState('Tracing the boundary…');
    const eps = dwgNum(softwareSetting('takeoff', 'fillSimplifyPx')) || 1.5;
    const rings = dwgFillContours(st, eps);
    clearFillPreview();
    setFillState('');
    if (rings.outer.length < 3) { setFillState('The filled region was too small or too thin to trace.'); return; }
    setFillResult({ outer: rings.outer, holes: rings.holes, pixels: st.count,
                    overflow: st.overflow, origin: 'fill' });
  }
  // The lasso override. If the drag closes a loop, what is inside it IS the
  // answer and the ink mask is not consulted at all — which is how you take a
  // region the drawing simply does not enclose.
  function commitLasso(path) {
    const eps = dwgNum(softwareSetting('takeoff', 'fillSimplifyPx')) || 1.5;
    const w = render ? render.w : 1, h = render ? render.h : 1;
    const px = path.map(p => [p.x * w, p.y * h]);
    const simple = dwgRdp(px, eps).map(p => ({ x: p[0] / w, y: p[1] / h }));
    if (simple.length < 3) { setFillState('That loop was too small to use.'); return; }
    setFillResult({ outer: simple, holes: [], pixels: 0, overflow: false, origin: 'lasso' });
  }

  function onDown(e) {
    if (tool === 'pan') {
      panRef.current = { x: e.clientX, y: e.clientY, l: scrollRef.current.scrollLeft, t: scrollRef.current.scrollTop };
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ }
      return;
    }
    if (!render || !editable || !drawing) return;
    if (blocked) return;
    if (e.button !== 0) return;
    const raw = ptFromEvent(e);
    if (tool === 'fill') {
      e.preventDefault();
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ }
      lassoRef.current = { path: [raw], moved: 0 };
      startFill(raw);
      return;
    }
    if (dragTool) {
      e.preventDefault();
      const p = snapPt(raw, []);
      setPoly({ kind: tool, drag: true, points: [{ x: p.x, y: p.y }], cur: { x: p.x, y: p.y } });
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ }
      return;
    }
    e.preventDefault();
    const prevPts = poly && poly.kind === tool ? poly.points : [];
    const p = snapPt(raw, prevPts);
    const next = prevPts.concat([{ x: p.x, y: p.y }]);
    // A tool with a FIXED number of points finishes itself. An angle is three
    // clicks and nothing else, so asking for Enter as well is a click wasted —
    // and it is the difference between the shape tools feeling like tools and
    // feeling like a polygon with extra steps.
    const fixed = (DWG_DRAW_TOOL_BY_KEY[tool] || {}).pts;
    if (fixed && next.length >= fixed) { setPoly(null); commit(tool, next); return; }
    setPoly({ kind: tool, drag: false, points: next, cur: { x: p.x, y: p.y },
              resumeId: poly && poly.kind === tool ? poly.resumeId : null });
  }
  function onMove(e) {
    if (panRef.current) {
      const d = panRef.current;
      scrollRef.current.scrollLeft = d.l - (e.clientX - d.x);
      scrollRef.current.scrollTop = d.t - (e.clientY - d.y);
      return;
    }
    if (!render) return;
    const raw = ptFromEvent(e);
    if (lassoRef.current) {
      const L = lassoRef.current;
      const last = L.path[L.path.length - 1];
      const d = Math.hypot((raw.x - last.x) * render.w, (raw.y - last.y) * render.h);
      if (d > 2) { L.path.push(raw); L.moved += d; }
      // Past a few pixels of travel this is a lasso, not a click, so the fill
      // that was started on pointer-down is abandoned.
      if (L.moved > 12 && fillRunRef.current) cancelFill();
      setPoly({ kind: 'fill', drag: true, lasso: true, points: L.path.slice(), cur: raw });
      return;
    }
    if (!poly) { setHover(drawing && !blocked ? snapPt(raw, []) : null); return; }
    const p = snapPt(raw, poly.drag ? [poly.points[0]] : poly.points);
    setHover(p);
    setPoly(d => d && ({ ...d, cur: { x: p.x, y: p.y } }));
  }
  function onUp() {
    if (panRef.current) { panRef.current = null; return; }
    if (lassoRef.current) {
      const L = lassoRef.current;
      lassoRef.current = null;
      setPoly(null);
      if (L.moved > 12) {
        const a = L.path[0], b = L.path[L.path.length - 1];
        const gap = Math.hypot((a.x - b.x) * (render ? render.w : 1), (a.y - b.y) * (render ? render.h : 1));
        if (gap < 40) commitLasso(L.path);
        else setFillState('That drag did not close back on itself, so there was nothing to take the inside of. Click once to flood-fill instead, or drag a closed loop.');
      } else {
        commitFill();
      }
      return;
    }
    if (!poly || !poly.drag) return;
    const d = poly;
    setPoly(null);
    const a = d.points[0], b = d.cur;
    if (Math.abs(a.x - b.x) < 0.002 && Math.abs(a.y - b.y) < 0.002) return;
    if (d.kind === 'calibrate') { setCalDraft({ points: [a, b], raw: dwgPolyLength([a, b], aspect, false) }); return; }
    if (d.kind === 'viewport') { setVpDraft({ points: dwgRectPoints(a, b) }); return; }
    if (d.kind === 'symbol') {
      setSearchSeed({ pageIndex, rect: { x0: Math.min(a.x, b.x), y0: Math.min(a.y, b.y),
                                          x1: Math.max(a.x, b.x), y1: Math.max(a.y, b.y) } });
      setSearchOpen(true); setTool('pan');
      return;
    }
    if (d.kind === 'deductEllipse') { commit('deduct', [a, b], { shape: 'ellipse' }); return; }
    if (d.kind === 'space') { openSpaceDraft(dwgRectPoints(a, b)); return; }
    commit('rect', dwgRectPoints(a, b));
  }
  function finishPoly() {
    const d = poly;
    if (!d || d.drag) return;
    const pts = (d.points || []).slice();
    // A double-click fires pointerdown first, so the last vertex is usually a
    // duplicate of the one before it.
    if (pts.length > 1) {
      const a = pts[pts.length - 1], b = pts[pts.length - 2];
      if (Math.abs(a.x - b.x) < 0.0015 && Math.abs(a.y - b.y) < 0.0015) pts.pop();
    }
    setPoly(null);
    if (d.kind === 'space') { if (pts.length >= 3) openSpaceDraft(pts); return; }
    if (d.kind === 'boundary') { if (pts.length >= 2) addBoundary(pts); return; }
    const def = DWG_DRAW_TOOL_BY_KEY[d.kind] || {};
    const need = def.pts ? def.pts : d.kind === 'count' ? 1 : d.kind === 'distance' ? 2 : 3;
    if (pts.length < need) return;
    // Resume Count adds to the tally that was already there rather than starting
    // a second one beside it — otherwise "resume" would just be "draw another",
    // and the whole point is that it is ONE row with a running total.
    if (d.kind === 'count' && d.resumeId) {
      setMeasure(d.resumeId, { points: pts.map(p => ({ x: p.x, y: p.y })) },
        `Take-off: resumed a count on sheet ${sheet.number} — the tally now stands at ${pts.length}.`);
      setStatus('');
      return;
    }
    commit(d.kind, pts);
  }
  finishRef.current = finishPoly;

  function openSpaceDraft(points) {
    const check = dwgValidateSpace(sheet, pageIndex, points, null, aspect);
    if (!check.ok) { setStatus(check.message); return; }
    setSpaceDraft({ points, parentId: check.parentId, parent: check.parent || null });
  }
  function addBoundary(points) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!Array.isArray(s.fillBoundaries)) s.fillBoundaries = [];
      s.fillBoundaries.push({ id: uid('dbnd'), pageIndex, points: points.map(p => ({ x: p.x, y: p.y })),
                              active: true, createdBy: ctx.currentUserName, createdDate: todayISO() });
    }, `Take-off: added a fill boundary on sheet ${sheet.number} page ${pageIndex + 1}.`);
    maskRef.current = null;
  }

  // The one place a markup is created, whatever drew it. Everything positional —
  // which viewport, which space — is decided here, once, and recorded.
  function commit(kind, points, extra) {
    const opts = extra || {};
    const rec0 = { kind, points, pageIndex, shape: opts.shape || 'poly' };
    const anchor = dwgMarkupAnchor(rec0);
    let parentId = null;
    if (kind === 'deduct') {
      const host = measures.filter(m => m.kind === 'area' || m.kind === 'rect')
        .find(m => dwgPointInPoly(anchor, dwgRingPoints(m)));
      if (!host) {
        setStatus('A cutout has to sit inside an area already measured on this page — this one does not, so nothing was recorded.');
        return;
      }
      parentId = host.id;
    }
    // Positional scale resolution, taken ONCE at creation. Later moving a
    // viewport does not silently re-measure work that was already signed off.
    const vp = kind === 'deduct' ? dwgViewportById(sheet, (measures.find(m => m.id === parentId) || {}).viewportId)
      : dwgViewportAt(sheet, pageIndex, anchor);
    const space = dwgSpaceAt(sheet, pageIndex, anchor);
    const t = activeTool;
    const rec = dwgMakeMeasurement({
      kind, points, pageIndex, parentId, shape: opts.shape || 'poly',
      sheetRevision: sheet.revision || '',
      calibrationId: cal ? cal.id : null,
      calMmPerPageWidth: cal ? dwgNum(cal.mmPerPageWidth) : null,
      viewportId: vp ? vp.id : null,
      spaceId: space ? space.id : null,
      subject: t ? t.subject : (opts.subject || ''),
      layer: t ? t.layer : '',
      label: t && t.labelTemplate ? t.labelTemplate : '',
      color: t ? t.color : '',
      countSymbol: t ? t.countSymbol : '',
      depthValue: t ? t.depthValue : null, depthUnit: t ? t.depthUnit : 'IN',
      slopeMode: t ? t.slopeMode : '', slopeValue: t ? t.slopeValue : null,
      units: t && t.unit ? { [t.measurementType]: t.unit } : null,
      custom: t && t.custom ? { ...t.custom } : {},
      origin: opts.origin || 'drawn',
      note: opts.note || '',
      searchTemplate: opts.searchTemplate || null,
    }, ctx.currentUserName);
    const v = dwgMeasureValue(rec, cal, aspect, measures, sheet);
    const said = kind === 'count' ? `${v.count} pin${v.count === 1 ? '' : 's'}`
      : kind === 'angle' ? `${v.angleDeg.toFixed(1)}°`
      : dwgIsShapeKind(kind) ? `radius ${dwgFmtMm(v.radiusMm)}`
      : kind === 'distance' ? dwgFmtMm(v.mm)
      : kind === 'deduct' ? `${dwgFmtArea(v.grossMm2)} cut out`
      : `${dwgFmtArea(v.netMm2)}, perimeter ${dwgFmtMm(v.perimeterMm)}`;
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!Array.isArray(s.measurements)) s.measurements = [];
      s.measurements.push(rec);
      if (Array.isArray(opts.holes)) {
        opts.holes.forEach(h => {
          s.measurements.push(dwgMakeMeasurement({
            kind: 'deduct', points: h, pageIndex, parentId: rec.id,
            sheetRevision: sheet.revision || '', calibrationId: cal ? cal.id : null,
            calMmPerPageWidth: cal ? dwgNum(cal.mmPerPageWidth) : null,
            viewportId: rec.viewportId, spaceId: rec.spaceId, origin: opts.origin || 'fill',
            label: 'Opening', subject: rec.subject, layer: rec.layer,
          }, ctx.currentUserName));
        });
      }
    }, `Take-off: measured ${(DWG_DRAW_TOOL_BY_KEY[kind] || {}).label || kind} on sheet ${sheet.number} Rev ${sheet.revision || '—'} — ${said}${vp ? ` (viewport “${vp.name}”)` : ''}.`);
    setSelectedId(rec.id);
    setStatus('');
    return rec;
  }

  function setMeasure(id, fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      const m = s.measurements.find(x => x.id === id);
      if (m) Object.assign(m, fields);
    }, why);
  }
  function removeMeasure(m) {
    const kids = measures.filter(x => x.parentId === m.id);
    if (!confirm(kids.length
      ? `Remove this measurement and its ${kids.length} cutout${kids.length === 1 ? '' : 's'}?${m.itemId ? ' The takeoff record it produced stays — a record is never deleted from here.' : ''}`
      : `Remove this measurement?${m.itemId ? ' The takeoff record it produced stays — a record is never deleted from here.' : ''}`)) return;
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      // Soft, like everything else in this app: the geometry stops being drawn
      // but the record of it having existed does not disappear.
      s.measurements.forEach(x => { if (x.id === m.id || x.parentId === m.id) x.active = false; });
    }, `Take-off: removed a ${m.kind} measurement from sheet ${sheet.number}.`);
    if (selectedId === m.id) setSelectedId(null);
  }
  // The trick estimators want: cut the window out of the wall, then make the
  // window its own quantity. The cutout stops being a cutout and becomes an area
  // in its own right, so the wall grows back by exactly what the window takes.
  function promoteCutout(m, keepCut) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      const cut = s.measurements.find(x => x.id === m.id);
      if (!cut) return;
      if (keepCut) {
        // A copy: the opening stays deducted from the wall AND is measured on
        // its own. That is two different quantities about the same hole, which
        // is usually exactly what is wanted (wall area, and window area).
        const copy = dwgMakeMeasurement({
          ...cut, kind: cut.shape === 'ellipse' ? 'area' : 'area', parentId: null,
          points: cut.points, shape: cut.shape, pageIndex: cut.pageIndex,
          sheetRevision: cut.sheetRevision, calibrationId: cut.calibrationId,
          calMmPerPageWidth: cut.calMmPerPageWidth,
          viewportId: cut.viewportId, spaceId: cut.spaceId,
          label: cut.label || 'Opening', subject: cut.subject, layer: cut.layer,
          origin: 'split',
        }, ctx.currentUserName);
        s.measurements.push(copy);
      } else {
        cut.kind = 'area';
        cut.parentId = null;
        cut.origin = 'split';
      }
    }, keepCut
      ? `Take-off: an opening on sheet ${sheet.number} is now measured on its own as well as deducted.`
      : `Take-off: a cutout on sheet ${sheet.number} became a measurement in its own right.`);
  }

  // ── Recalculate ───────────────────────────────────────────────────────
  // Every estimator has found a wrong scale two hundred markups in. This is the
  // way back: re-stamp every markup on the page to the calibration now in force
  // and recompute the takeoff records they produced, with the before and after
  // shown first. A locked bid version is never touched.
  function recalcRows() {
    const rows = [];
    measures.filter(m => m.kind !== 'deduct').forEach(m => {
      const v = dwgMeasureValue(m, cal, aspect, measures, sheet);
      const item = m.itemId ? items.find(i => i.id === m.itemId) : null;
      const next = item && m.figure ? dwgFigureIn(v, m.figure, m.unit) : null;
      const wasCal = m.calMmPerPageWidth;
      const nowCal = v.scale.mmPerPageWidth;
      const scaleChanged = !!(cal && m.calibrationId && m.calibrationId !== cal.id && !m.viewportId);
      const recordStale = item && next !== null && Math.abs(next - dwgNum(item.rawMeasure)) > 0.0005;
      if (!scaleChanged && !recordStale && !v.scale.viewportMissing) return;
      rows.push({ m, v, item, next, wasCal, nowCal, scaleChanged, recordStale,
                  viewportMissing: v.scale.viewportMissing,
                  locked: item ? dwgVersionLocked(project, item.bidVersionId) : false });
    });
    return rows;
  }
  function applyRecalc(rows) {
    const doable = rows.filter(r => !r.locked);
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      doable.forEach(r => {
        if (s && Array.isArray(s.measurements)) {
          const mm = s.measurements.find(x => x.id === r.m.id);
          if (mm && cal && !mm.viewportId) { mm.calibrationId = cal.id; mm.calMmPerPageWidth = dwgNum(cal.mmPerPageWidth); }
          // A markup whose viewport was deleted falls back to the page scale and
          // says so, rather than keeping a pointer to nothing.
          if (mm && r.viewportMissing) mm.viewportId = null;
        }
        if (!r.item || r.next === null || !r.recordStale) return;
        const t = draft.takeoffItems.find(x => x.id === r.item.id);
        if (!t) return;
        t.history = t.history || [];
        t.history.push({ id: uid('tohist'), date: todayISO(), by: ctx.currentUserName,
          was: { rawMeasure: t.rawMeasure, unit: t.unit, wastePct: t.wastePct, typicalCount: t.typicalCount, status: t.status } });
        t.rawMeasure = r.next;
        t.updatedBy = ctx.currentUserName; t.updatedDate = todayISO();
        t.notes = `${t.notes || ''}${t.notes ? ' — ' : ''}Recalculated against the current page scale on ${todayISO()}.`;
      });
    }, `Take-off: recalculated ${doable.length} markup${doable.length === 1 ? '' : 's'} on sheet ${sheet.number} page ${pageIndex + 1} against the current scale.`);
    setRecalcOpen(false);
  }

  // Re-calibrating changes every figure on the page. The MEASUREMENTS follow on
  // their own because nothing is stored but geometry; the takeoff RECORDS they
  // produced are separate records and are only updated when someone says so.
  function reapplyCalibration() {
    const linked = measures.filter(m => m.itemId && m.figure && m.unit);
    if (!linked.length) return;
    const changes = [];
    linked.forEach(m => {
      const item = items.find(i => i.id === m.itemId);
      if (!item) return;
      const v = dwgMeasureValue(m, cal, aspect, measures, sheet);
      const next = dwgFigureIn(v, m.figure, m.unit);
      if (Math.abs(next - dwgNum(item.rawMeasure)) > 0.0005) changes.push({ m, item, next });
    });
    if (!changes.length) { setStatus('Every linked takeoff record already matches the current calibration.'); return; }
    const locked = changes.filter(c => dwgVersionLocked(project, c.item.bidVersionId));
    if (!confirm(`Recompute ${changes.length} takeoff record${changes.length === 1 ? '' : 's'} from the current calibration?`
      + (locked.length ? ` ${locked.length} belong${locked.length === 1 ? 's' : ''} to a locked bid version and will be left alone.` : ''))) return;
    dwgUpdate(ctx, project.id, draft => {
      changes.forEach(c => {
        if (dwgVersionLocked(project, c.item.bidVersionId)) return;
        const t = draft.takeoffItems.find(x => x.id === c.item.id);
        if (!t) return;
        t.history = t.history || [];
        t.history.push({ id: uid('tohist'), date: todayISO(), by: ctx.currentUserName,
          was: { rawMeasure: t.rawMeasure, unit: t.unit, wastePct: t.wastePct, typicalCount: t.typicalCount, status: t.status } });
        t.rawMeasure = c.next;
        t.updatedBy = ctx.currentUserName; t.updatedDate = todayISO();
        t.notes = `${t.notes || ''}${t.notes ? ' — ' : ''}Recomputed from the sheet calibration on ${todayISO()}.`;
      });
    }, `Take-off: recomputed ${changes.length - locked.length} record${changes.length - locked.length === 1 ? '' : 's'} on sheet ${sheet.number} from the current calibration.`);
    setStatus('');
  }

  // Every count pin becomes its own row, or an existing count is re-opened and
  // added to. Both are what an estimator does when a tally turns out to need
  // splitting by room, or when the run was interrupted.
  function splitAllCounts(m) {
    const pts = (m.points || []).slice();
    if (pts.length < 2) return;
    if (!confirm(`Split this tally into ${pts.length} separate count markups?`)) return;
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      const src = s.measurements.find(x => x.id === m.id);
      if (!src) return;
      src.points = [pts[0]];
      pts.slice(1).forEach((p, i) => {
        s.measurements.push(dwgMakeMeasurement({
          ...src, points: [p], kind: 'count', pageIndex: src.pageIndex,
          label: src.label ? `${src.label} ${i + 2}` : '', parentId: null, itemId: null, figure: null,
          origin: 'split',
        }, ctx.currentUserName));
      });
    }, `Take-off: split a tally of ${pts.length} on sheet ${sheet.number} into separate counts.`);
  }
  function resumeCount(m) {
    setTool('count');
    setSelectedId(m.id);
    setPoly({ kind: 'count', drag: false, points: (m.points || []).slice(), cur: (m.points || [])[0] || { x: 0, y: 0 }, resumeId: m.id });
    setStatus(`Adding to “${dwgMeasureName(m, 0)}”. Drop the rest of the pins and press Enter — they join this tally rather than starting a new one.`);
  }

  // Text search on the rendered page. This is the pdf.js text layer — the words
  // the PDF carries — not reading the drawing. A scanned sheet has none.
  function runFind() {
    if (!pdf || !find.trim()) { setHits(null); return; }
    setStatus('Searching this page’s text…');
    officePdfPageText(pdf.assetId, pageIndex)
      .then(pageText => {
        const found = officePdfSearch([pageText], find.trim(), {});
        setHits({ list: found, hasText: !!(pageText.text || '').trim() });
        setStatus('');
      })
      .catch(e => { setStatus(e && e.message ? e.message : String(e)); });
  }

  const live = (() => {
    if (!poly || !render) return null;
    if (poly.lasso) return { pts: poly.points, segMm: 0, runMm: 0, areaMm2: 0, closed: true, count: poly.points.length, lasso: true };
    const mmPer = cal ? dwgNum(cal.mmPerPageWidth) : 0;
    // A calibration is drawn by dragging too, but it is a LINE, not a box —
    // only the box tools turn a drag into four corners.
    const boxKind = poly.kind === 'rect' || poly.kind === 'viewport' || poly.kind === 'space'
      || poly.kind === 'symbol' || poly.kind === 'deductEllipse';
    const pts = boxKind ? dwgRectPoints(poly.points[0], poly.cur) : poly.points.concat([poly.cur]);
    const last = poly.points.length ? poly.points[poly.points.length - 1] : null;
    const segMm = last ? dwgPolyLength([last, poly.cur], aspect, false) * mmPer : 0;
    const runMm = dwgPolyLength(pts, aspect, false) * mmPer;
    const closed = boxKind || poly.kind === 'area' || poly.kind === 'deduct';
    const areaMm2 = closed && pts.length >= 3 ? dwgPolyArea(pts, aspect) * mmPer * mmPer : 0;
    return { pts, segMm, runMm, areaMm2, closed, count: poly.points.length, ellipse: poly.kind === 'deductEllipse' };
  })();

  const X = p => p.x * (render ? render.w : 0);
  const Y = p => p.y * (render ? render.h : 0);
  const path = pts => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p).toFixed(1)},${Y(p).toFixed(1)}`).join(' ');

  const toolHint = drawTool ? drawTool.hint : structureTool ? structureTool.hint
    : tool === 'calibrate' ? 'Drag a line along a dimension you can read off the drawing, then type what it really is.'
    : tool === 'symbol' ? 'Drag a box tightly round one instance of the symbol you want to find everywhere else.'
    : 'Drag to pan. Pick a tool to measure.';

  return (
    <div className="space-y-3">
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
        <div className="flex items-start justify-between gap-3 flex-wrap">
          <div>
            <div className="text-lg font-bold">{sheet.number} <span className="font-normal text-[var(--leon-black)]/60">{sheet.title}</span></div>
            <div className="text-xs text-[var(--leon-black)]/50">
              {sheet.discipline} · Rev {sheet.revision || '—'} · <Badge tone={DWG_STATUS_TONE[sheet.status]}>{sheet.status}</Badge>
            </div>
          </div>
          <div className="text-right">
            <DwgScaleFlag sheet={sheet} />
            {cal && (
              <div className="text-[10px] text-[var(--leon-black)]/50">
                page {pageIndex + 1} calibrated {cal.scaleLabel ? `at ${cal.scaleLabel}` : ''} by {cal.by} {fmtDate(cal.date)}
              </div>
            )}
            {!!viewports.length && (
              <div className="text-[10px] text-[var(--leon-black)]/50">
                {viewports.length} viewport{viewports.length === 1 ? '' : 's'} on this page — a markup inside one is measured at ITS scale
              </div>
            )}
          </div>
        </div>
      </div>

      <DwgSheetSourcePanel ctx={ctx} project={project} sheet={sheet} editable={editable}
        pageIndex={pageIndex} setPageIndex={p => { setPageIndex(p); setPoly(null); setHits(null); cancelFill(); }}
        pageCount={pdf ? dwgNum(pdf.pageCount) : 0} onChanged={() => { setPoly(null); setStatus(''); maskRef.current = null; }} />

      {!pdf ? (
        <DwgNote>
          Attach the sheet&rsquo;s PDF above and the drawing is rendered here to measure on. Until then the
          takeoff records for this sheet are entered by hand on the Takeoff tab, which is still a perfectly
          good way to work — the record is identical either way.
        </DwgNote>
      ) : (
        <>
          {/* ── toolbar ── */}
          <div className="rounded-lg border border-[var(--leon-line)] bg-white p-2 space-y-2">
            <div className="flex items-center gap-1.5 flex-wrap">
              <DwgChip active={tool === 'pan'} onClick={() => { setTool('pan'); setPoly(null); cancelFill(); }}>✋ Pan</DwgChip>
              <DwgChip active={tool === 'calibrate'} onClick={() => { setTool(tool === 'calibrate' ? 'pan' : 'calibrate'); setPoly(null); }}>
                📐 {cal ? 'Re-calibrate' : 'Calibrate'}
              </DwgChip>
              <span className="w-px h-5 bg-[var(--leon-line)] mx-1" />
              {DWG_DRAW_TOOLS.map(t => (
                <DwgChip key={t.key} active={tool === t.key}
                  onClick={() => { if (!cal && requireCal && t.key !== 'angle') { setStatus('Calibrate this page first — a measurement with no scale behind it is not a measurement. (An angle is the exception: it reads the same at any scale.)'); return; } setTool(tool === t.key ? 'pan' : t.key); setPoly(null); cancelFill(); }}>
                  {t.icon} {t.label}
                </DwgChip>
              ))}
              <span className="w-px h-5 bg-[var(--leon-line)] mx-1" />
              {DWG_STRUCTURE_TOOLS.map(t => (
                <DwgChip key={t.key} active={tool === t.key}
                  onClick={() => { setTool(tool === t.key ? 'pan' : t.key); setPoly(null); cancelFill(); }}>
                  {t.icon} {t.label}
                </DwgChip>
              ))}
              <DwgChip active={tool === 'symbol'} onClick={() => { setTool(tool === 'symbol' ? 'pan' : 'symbol'); setPoly(null); }}>
                🔎 Visual Search
              </DwgChip>
              <span className="ml-auto flex items-center gap-1.5">
                <label className="flex items-center gap-1 text-[11px] text-[var(--leon-black)]/60">
                  Grid
                  <select value={gridMm} onChange={e => setGridMm(Number(e.target.value))} disabled={!cal}
                    className="px-1 py-0.5 text-[11px] border border-[var(--leon-line)] rounded bg-white">
                    {DWG_GRID_PRESETS.map(g => <option key={g.label} value={g.mm}>{g.label}</option>)}
                  </select>
                </label>
                <span className={`text-[11px] px-1.5 py-0.5 rounded border ${ortho ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] font-bold' : 'border-[var(--leon-line)] text-[var(--leon-black)]/45'}`}
                  title="Hold Shift to constrain the current segment horizontally or vertically">⇧ ortho</span>
                <IconBtn title="Zoom out" onClick={() => setZoom(z => DWG_ZOOMS[Math.max(0, DWG_ZOOMS.indexOf(z) - 1)] || 0.5)}>−</IconBtn>
                <span className="text-[11px] w-10 text-center">{Math.round(zoom * 100)}%</span>
                <IconBtn title="Zoom in" onClick={() => setZoom(z => DWG_ZOOMS[Math.min(DWG_ZOOMS.length - 1, DWG_ZOOMS.indexOf(z) + 1)] || 1)}>+</IconBtn>
                <IconBtn title="Fit the width" onClick={() => setZoom(1)}>⤢</IconBtn>
              </span>
            </div>

            {/* the tool in your hand, and what it stamps */}
            <div className="flex items-center gap-2 flex-wrap text-xs border-t border-[var(--leon-line)] pt-2">
              <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Take-off tool</span>
              <select value={activeToolId} onChange={e => {
                const id = e.target.value;
                setActiveToolId(id);
                const t = dwgToolProps(tools.find(x => x.id === id));
                // "Create Count" is not a separate concept: a tool that draws as
                // a count IS a count tool, so picking one picks the pin.
                if (t && t.drawKind && DWG_DRAW_TOOL_BY_KEY[t.drawKind] && cal) setTool(t.drawKind);
              }} className="px-2 py-1 text-xs border border-[var(--leon-line)] rounded bg-white max-w-[18rem]">
                <option value="">— none: markups are placed plain —</option>
                {DWG_TOOL_SCOPES.map(sc => {
                  const list = tools.filter(t => t.scope === sc);
                  if (!list.length) return null;
                  return <optgroup key={sc} label={sc}>
                    {list.map(t => <option key={t.id} value={t.id}>{t.name} ({t.unit})</option>)}
                  </optgroup>;
                })}
              </select>
              {activeTool ? (
                <span className="flex items-center gap-1.5 flex-wrap">
                  <span className="w-2.5 h-2.5 rounded-full" style={{ background: activeTool.color }} />
                  <Badge tone="neutral">Subject: {activeTool.subject || '—'}</Badge>
                  {activeTool.layer ? <Badge tone="neutral">Layer: {activeTool.layer}</Badge> : null}
                  {activeTool.depthValue ? <Badge tone="neutral">Depth {activeTool.depthValue} {activeTool.depthUnit}</Badge> : null}
                  <span className="text-[var(--leon-black)]/45">stamped onto every markup you place</span>
                </span>
              ) : (
                <span className="text-[var(--leon-black)]/45">Pick one and its subject, layer, colour, depth and column defaults land on whatever you draw next.</span>
              )}
              <span className="ml-auto flex items-center gap-2">
                <label className="flex items-center gap-1 text-[11px]" title="Snap the cursor to the drawing's own lines and corners, read out of the page's path geometry">
                  <input type="checkbox" checked={snapContent} onChange={e => setSnapContent(e.target.checked)} />
                  Snap to content
                </label>
                {!!mixedScale.length && (
                  <Button size="sm" variant="danger" onClick={() => setRecalcOpen(true)}>
                    ⚠ {mixedScale.length} measured at a different scale — Recalculate
                  </Button>
                )}
                {!mixedScale.length && editable && <Button size="sm" variant="ghost" onClick={() => setRecalcOpen(true)}>Recalculate…</Button>}
                {editable && <Button size="sm" variant="ghost" onClick={() => setBulkOpen(true)}>Units &amp; precision…</Button>}
              </span>
            </div>

            {snapContent && !!vecState && (
              <div className="text-[11px] text-[var(--leon-black)]/50 border-t border-[var(--leon-line)] pt-1.5">{vecState}</div>
            )}

            {tool === 'fill' && (
              <div className="flex items-center gap-3 flex-wrap text-xs border-t border-[var(--leon-line)] pt-2">
                <label className="flex items-center gap-1.5">
                  Edge sensitivity
                  <input type="range" min="0" max="8" step="1" value={fillCfg.sensitivity}
                    onChange={e => { setFillCfg({ ...fillCfg, sensitivity: Number(e.target.value) }); maskRef.current = null; }} />
                  <b>{fillCfg.sensitivity}px</b>
                </label>
                <label className="flex items-center gap-1.5">
                  Ink threshold
                  <input type="range" min="60" max="245" step="5" value={fillCfg.threshold}
                    onChange={e => { setFillCfg({ ...fillCfg, threshold: Number(e.target.value) }); maskRef.current = null; }} />
                  <b>{fillCfg.threshold}</b>
                </label>
                <label className="flex items-center gap-1">
                  <input type="checkbox" checked={fillCfg.makeSpace} onChange={e => setFillCfg({ ...fillCfg, makeSpace: e.target.checked })} />
                  Also create a Space
                </label>
                <span className="text-[var(--leon-black)]/50">
                  Sensitivity is a morphological closing: the ink is grown by that many pixels and shrunk back,
                  which bridges a wall that stops short of a jamb without moving anything on the drawing.
                </span>
              </div>
            )}
          </div>

          {!!status && (
            <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)] px-3 py-2 text-xs">{status}</div>
          )}
          {!!fillState && (
            <div className="rounded-lg border border-[#3a7d44]/40 bg-[#eef6ef] px-3 py-2 text-xs">{fillState}</div>
          )}

          {/* ── the sheet ── */}
          <div ref={scrollRef}
            className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/40 overflow-auto"
            style={{ maxHeight: '72vh' }}>
            <div ref={surfaceRef}
              className="relative mx-auto select-none"
              style={{ width: render ? render.w : width, height: render ? render.h : 200,
                       cursor: tool === 'pan' ? 'grab' : blocked ? 'not-allowed' : drawing ? 'crosshair' : 'default' }}
              onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp}
              onDoubleClick={e => { e.preventDefault(); finishPoly(); }}>
              <div ref={hostRef} className="absolute inset-0" />
              {/* the growing fill, painted at mask resolution and stretched — a
                  canvas rather than SVG because it is a bitmap, not a shape */}
              <canvas ref={fillCanvasRef} className="absolute inset-0 pointer-events-none"
                style={{ width: render ? render.w : 0, height: render ? render.h : 0 }} />
              {render && (
                <svg width={render.w} height={render.h} className="absolute inset-0" style={{ pointerEvents: 'none' }}>
                  {/* Spaces first — they are the ground everything else sits on */}
                  {spaces.map(sp => {
                    const c = dwgCentroid(sp.points || []);
                    return (
                      <g key={sp.id}>
                        <path d={path(sp.points || []) + ' Z'} fill={sp.color} fillOpacity="0.06"
                          stroke={sp.color} strokeWidth="1.5" strokeDasharray="2 4" />
                        <text x={X(c)} y={Y(c) - 4} textAnchor="middle" fontSize="10" fontWeight="bold"
                          fill={sp.color} stroke="#fff" strokeWidth="3" paintOrder="stroke" opacity="0.85">
                          {dwgSpacePath(sheet, sp)}
                        </text>
                      </g>
                    );
                  })}
                  {viewports.map(vp => {
                    const b = dwgViewportBox(vp);
                    if (!b) return null;
                    return (
                      <g key={vp.id}>
                        <rect x={b.x0 * render.w} y={b.y0 * render.h}
                          width={(b.x1 - b.x0) * render.w} height={(b.y1 - b.y0) * render.h}
                          fill="none" stroke={DWG_DRAW_COLORS.viewport} strokeWidth="2" strokeDasharray="8 4" />
                        <text x={b.x0 * render.w + 4} y={b.y0 * render.h + 12} fontSize="10" fontWeight="bold"
                          fill={DWG_DRAW_COLORS.viewport} stroke="#fff" strokeWidth="3" paintOrder="stroke">
                          🔍 {vp.name} · {vp.scaleLabel}
                        </text>
                      </g>
                    );
                  })}
                  {boundaries.map(b => (
                    <path key={b.id} d={path(b.points || [])} fill="none"
                      stroke={DWG_DRAW_COLORS.boundary} strokeWidth="2" strokeDasharray="3 3" opacity="0.8" />
                  ))}

                  {/* text-layer search hits, when the page is not rotated in the file */}
                  {hits && !render.rotation && hits.list.map((h, i) => (
                    <g key={`hit-${i}`}>
                      {(h.rects || []).map((r, j) => (
                        <rect key={j} x={r.x * render.w} y={r.y * render.h} width={r.w * render.w} height={r.h * render.h}
                          fill="#a67b1f" opacity="0.28" />
                      ))}
                    </g>
                  ))}

                  {measures.map((m, i) => {
                    const v = dwgMeasureValue(m, cal, aspect, measures, sheet);
                    const col = m.color || DWG_DRAW_COLORS[m.kind] || '#333';
                    const on = selectedId === m.id;
                    const stale = dwgMeasureStale(sheet, m);
                    const stroke = stale ? '#b83b3b' : col;
                    if (m.kind === 'count') {
                      const sym = m.countSymbol || '';
                      return (
                        <g key={m.id}>
                          {(m.points || []).map((p, n) => (
                            <g key={n}>
                              <circle cx={X(p)} cy={Y(p)} r={on ? 9 : 7} fill={stroke} opacity={on ? 0.95 : 0.75} stroke="#fff" strokeWidth="1.5" />
                              <text x={X(p)} y={Y(p) + 3} textAnchor="middle" fontSize="9" fill="#fff" fontWeight="bold">{sym || (n + 1)}</text>
                            </g>
                          ))}
                        </g>
                      );
                    }
                    if (dwgIsShapeKind(m.kind)) {
                      const pts = m.points || [];
                      const lu = dwgUnitFor(m, 'Length'), prec = dwgPrecisionFor(m);
                      const c3 = m.kind === 'radius3' ? dwgCircleFrom3(pts, aspect) : null;
                      const label = m.kind === 'angle'
                        ? `${v.angleDeg.toFixed(1)}°`
                        : v.collinear ? 'in a straight line — no radius'
                        : `R ${dwgFmtValue(dwgLengthIn(v.radiusMm, lu), lu, prec)}`;
                      const anchor = pts[m.kind === 'angle' ? 1 : 0] || { x: 0, y: 0 };
                      return (
                        <g key={m.id}>
                          {/* the solved circle, drawn faintly, so a 3-point radius
                              can be checked against the arc it was read off */}
                          {c3 && <circle cx={c3.cx * render.w} cy={(c3.cy / (aspect || 1)) * render.h}
                            r={c3.r * render.w} fill="none" stroke={stroke} strokeWidth="1"
                            strokeDasharray="4 4" opacity="0.55" />}
                          <path d={path(pts)} fill="none" stroke={stroke} strokeWidth={on ? 3 : 2} />
                          {pts.map((p, n) => <circle key={n} cx={X(p)} cy={Y(p)} r={on ? 3.5 : 2.5} fill={stroke} />)}
                          <text x={X(anchor)} y={Y(anchor) - 9} textAnchor="middle" fontSize="11" fontWeight="bold"
                            fill={stroke} stroke="#fff" strokeWidth="3" paintOrder="stroke">
                            {dwgMeasureName(m, i)} · {label}
                          </text>
                        </g>
                      );
                    }
                    const ring = dwgRingPoints(m);
                    const closed = m.kind !== 'distance';
                    const d = path(ring) + (closed ? ' Z' : '');
                    const c = dwgCentroid(ring);
                    const lu = dwgUnitFor(m, 'Length'), au = dwgUnitFor(m, 'Area'), vu = dwgUnitFor(m, 'Volume');
                    const prec = dwgPrecisionFor(m);
                    const label = m.kind === 'distance' ? dwgFmtValue(dwgLengthIn(v.mm, lu), lu, prec)
                      : m.kind === 'deduct' ? `−${dwgFmtValue(dwgAreaIn(v.grossMm2, au), au, prec)}`
                      : v.volumeMm3 ? dwgFmtValue(dwgVolumeIn(v.volumeMm3, vu), vu, prec)
                      : dwgFmtValue(dwgAreaIn(v.netMm2, au), au, prec);
                    return (
                      <g key={m.id}>
                        <path d={d} fill={closed ? stroke : 'none'} fillOpacity={m.kind === 'deduct' ? 0.28 : on ? 0.22 : 0.12}
                          stroke={stroke} strokeWidth={on ? 3 : 2} strokeDasharray={m.kind === 'deduct' ? '5 3' : undefined} />
                        {m.shape !== 'ellipse' && (m.points || []).map((p, n) => <circle key={n} cx={X(p)} cy={Y(p)} r={on ? 3.5 : 2.5} fill={stroke} />)}
                        <text x={X(c)} y={Y(c)} textAnchor="middle" fontSize="11" fontWeight="bold" fill={stroke}
                          stroke="#fff" strokeWidth="3" paintOrder="stroke">{dwgMeasureName(m, i)} · {label}</text>
                      </g>
                    );
                  })}

                  {cal && (cal.points || []).length === 2 && (
                    <g opacity="0.5">
                      <path d={path(cal.points)} stroke={DWG_DRAW_COLORS.calibrate} strokeWidth="2" strokeDasharray="6 4" fill="none" />
                      <text x={X(cal.points[0])} y={Y(cal.points[0]) - 6} fontSize="10" fontWeight="bold"
                        fill={DWG_DRAW_COLORS.calibrate} stroke="#fff" strokeWidth="3" paintOrder="stroke">
                        calibration · {cal.knownText}
                      </text>
                    </g>
                  )}

                  {live && (
                    <g>
                      <path d={path(live.pts) + (live.closed && live.pts.length > 2 ? ' Z' : '')}
                        fill={live.closed ? (DWG_DRAW_COLORS[poly.kind] || '#333') : 'none'} fillOpacity="0.15"
                        stroke={DWG_DRAW_COLORS[poly.kind] || '#333'} strokeWidth="2" strokeDasharray="4 3" />
                      {!live.lasso && poly.points.map((p, n) => <circle key={n} cx={X(p)} cy={Y(p)} r="3" fill={DWG_DRAW_COLORS[poly.kind] || '#333'} />)}
                    </g>
                  )}
                  {hover && drawing && !blocked && (
                    <circle cx={X(hover)} cy={Y(hover)} r={hover.snapped ? 6 : 3}
                      fill="none" stroke={hover.snapped === 'point' ? '#2563a8' : hover.snapped && String(hover.snapped).indexOf('drawing') >= 0 ? '#b3521f' : '#7b6b5c'} strokeWidth="1.5" />
                  )}
                </svg>
              )}
              {!render && <div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--leon-black)]/45">{status || 'Nothing rendered.'}</div>}
            </div>
          </div>

          {/* live readout — the number as it is being drawn, not only after */}
          <div className="flex items-center gap-3 flex-wrap text-xs rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2">
            {!cal && requireCal && !structureTool ? (
              <span className="text-[var(--leon-red)] font-bold">
                ⚠ This page has no scale. Draw a line along a dimension you know — nothing can be measured until you have.
                Spaces, viewports and fill boundaries can still be drawn, because they describe the page rather than a quantity.
              </span>
            ) : live ? (
              <>
                <span className="text-[var(--leon-black)]/45 uppercase tracking-wide text-[10px]">
                  {live.lasso ? 'Lasso' : (DWG_DRAW_TOOL_BY_KEY[poly.kind] || DWG_STRUCTURE_TOOLS.find(t => t.key === poly.kind) || { label: poly.kind === 'symbol' ? 'Symbol box' : 'Calibration line' }).label}
                </span>
                {poly.kind === 'count'
                  ? <span><b>{live.count}</b> pin{live.count === 1 ? '' : 's'} dropped{poly.resumeId ? ' — joining an existing tally' : ''}</span>
                  : <>
                      <span>segment <b>{fmtDim(live.segMm, 'Imperial')}</b></span>
                      <span>run <b>{fmtDim(live.runMm, 'Imperial')}</b> ({dwgFmtQty(dwgLengthIn(live.runMm, 'LF'), 'LF')})</span>
                      {live.closed && !!live.areaMm2 && <span>area <b>{dwgFmtQty(dwgAreaIn(live.areaMm2, 'SF'), 'SF')}</b></span>}
                    </>}
                {hover && hover.snapped && <Badge tone="blue">snapped to {hover.snapped === 'point' ? 'a point you placed' : hover.snapped}</Badge>}
                <span className="ml-auto flex gap-1.5">
                  {(DWG_DRAW_TOOL_BY_KEY[poly.kind] && DWG_DRAW_TOOL_BY_KEY[poly.kind].poly) || poly.kind === 'space' || poly.kind === 'boundary'
                    ? <Button size="sm" onClick={finishPoly}>Finish (Enter)</Button> : null}
                  <Button size="sm" variant="ghost" onClick={() => { setPoly(null); cancelFill(); }}>Cancel (Esc)</Button>
                </span>
              </>
            ) : (
              <span className="text-[var(--leon-black)]/55">{toolHint}</span>
            )}
          </div>

          {/* text-layer search — the words in the PDF, not the drawing */}
          <div className="flex items-end gap-2 flex-wrap">
            <TextInput className="!w-56" value={find} onChange={e => setFind(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter') runFind(); }} placeholder="Find text on this page…" />
            <Button size="sm" variant="outline" onClick={runFind} disabled={!find.trim()}>Find</Button>
            {hits && <span className="text-xs text-[var(--leon-black)]/55 pb-1">
              {!hits.hasText ? 'This page carries no text at all — it is a scan, so there is nothing to search.'
                : `${hits.list.length} match${hits.list.length === 1 ? '' : 'es'}${hits.list.length && render && render.rotation ? ' — the page is rotated in the file, so they are listed but not boxed' : ''}.`}
            </span>}
            {hits && !!hits.list.length && <Button size="sm" variant="ghost" onClick={() => setHits(null)}>Clear</Button>}
          </div>

          <DwgNote>
            <b>What this surface does:</b> it renders the sheet with pdf.js and measures against a scale you
            calibrate on it — distance, area, perimeter, counts and cutouts, plus volume and wall area once a
            depth is set. It snaps to the drawing&rsquo;s <b>own lines and corners</b>, read out of the page&rsquo;s
            path geometry, as well as to your points and your grid. <b>Dynamic Fill</b> floods a room to its
            walls in one click, and <b>Visual Search</b> finds other instances of a symbol.
            <b> What it does not do:</b> snapping and Visual Search need a <b>vector</b> page — a scan has no
            linework to read, and the toolbar says so when it finds one. There is no OCR, no AI plan reading,
            and Visual Search is a list of candidates to check, never a finished count.
          </DwgNote>

          <DwgSpacesPanel ctx={ctx} project={project} sheet={sheet} pageIndex={pageIndex} spaces={spaces}
            measures={measures} aspect={aspect} editable={editable} />
          <DwgViewportsPanel ctx={ctx} project={project} sheet={sheet} pageIndex={pageIndex}
            viewports={viewports} measures={measures} cal={cal} pageWidthMm={pageWidthMm} editable={editable} />
          {!!boundaries.length && (
            <Collapsible id={`dwg-bounds-${sheet.id}`} title="Fill boundaries" count={boundaries.length}>
              <DwgNote>
                These close gaps for <b>Dynamic Fill</b> only. They are not measured, not exported and not drawn
                on the sheet — the PDF is never modified by anything in this module.
              </DwgNote>
              <div className="space-y-1 mt-2">
                {boundaries.map((b, i) => (
                  <div key={b.id} className="flex items-center gap-2 text-xs border border-[var(--leon-line)] rounded px-2 py-1">
                    <span className="w-2.5 h-2.5 rounded-full" style={{ background: DWG_DRAW_COLORS.boundary }} />
                    <span className="flex-1">Boundary {i + 1} · {(b.points || []).length} points · {b.createdBy} {fmtDate(b.createdDate)}</span>
                    {editable && <IconBtn title="Remove this boundary" onClick={() => {
                      dwgUpdate(ctx, project.id, draft => {
                        const s = draft.drawingSheets.find(x => x.id === sheet.id);
                        if (s && Array.isArray(s.fillBoundaries)) s.fillBoundaries.forEach(x => { if (x.id === b.id) x.active = false; });
                      }, `Take-off: removed a fill boundary from sheet ${sheet.number}.`);
                      maskRef.current = null;
                    }}>✕</IconBtn>}
                  </div>
                ))}
              </div>
            </Collapsible>
          )}

          <DwgMeasureList ctx={ctx} project={project} sheet={sheet} measures={measures} cal={cal} aspect={aspect}
            editable={editable} selectedId={selectedId} setSelectedId={setSelectedId}
            onRemove={removeMeasure} onEdit={setMeasure} onRecord={m => setRecordFor(m)}
            onOpenRecord={onOpenRecord} onReapply={reapplyCalibration} items={items}
            onProps={m => setPropsFor(m)} onPromote={promoteCutout}
            onSplitAll={splitAllCounts} onResume={resumeCount} />
        </>
      )}

      <DwgCalibrateOnSheetModal draft={calDraft} onClose={() => setCalDraft(null)} ctx={ctx} project={project}
        sheet={sheet} pageIndex={pageIndex} pageWidthMm={pageWidthMm} existing={cal}
        pageAspect={pageMeta && pageMeta.width ? pageMeta.height / pageMeta.width : 0}
        isSheetPage={pdf ? dwgNum(pdf.pageIndex) === pageIndex : true}
        onDone={() => { setCalDraft(null); setTool('pan'); }} />
      <DwgMeasureToRecordModal measurement={recordFor} onClose={() => setRecordFor(null)} ctx={ctx} project={project}
        sheet={sheet} cal={cal} aspect={aspect} measures={measures} versionId={versionId} />
      <DwgViewportModal draft={vpDraft} onClose={() => setVpDraft(null)} ctx={ctx} project={project} sheet={sheet}
        pageIndex={pageIndex} pageWidthMm={pageWidthMm} aspect={aspect}
        onDone={() => { setVpDraft(null); setTool('pan'); }} />
      <DwgSpaceModal draft={spaceDraft} onClose={() => setSpaceDraft(null)} ctx={ctx} project={project} sheet={sheet}
        pageIndex={pageIndex} aspect={aspect} measures={allMeasures}
        onDone={() => { setSpaceDraft(null); setTool('pan'); }} />
      <DwgFillResultModal result={fillResult} onClose={() => setFillResult(null)} ctx={ctx} project={project}
        sheet={sheet} pageIndex={pageIndex} cal={cal} aspect={aspect} measures={measures}
        makeSpace={fillCfg.makeSpace} activeTool={activeTool}
        onCommit={(points, holes, opts) => {
          const rec = commit('area', points, { holes, origin: opts.origin, note: opts.note });
          if (opts.space && rec) {
            const check = dwgValidateSpace(sheet, pageIndex, points, null, aspect);
            if (!check.ok) {
              setStatus(`The area was recorded, but no Space was created: ${check.message}`);
            } else {
              const sp = dwgMakeSpace({ pageIndex, name: opts.spaceName || 'Room', parentId: check.parentId, points }, ctx.currentUserName);
              dwgUpdate(ctx, project.id, draft => {
                const s = draft.drawingSheets.find(x => x.id === sheet.id);
                if (!s) return;
                if (!Array.isArray(s.spaces)) s.spaces = [];
                s.spaces.push(sp);
                const mm = (s.measurements || []).find(x => x.id === rec.id);
                if (mm) mm.spaceId = sp.id;
              }, `Take-off: Dynamic Fill created the Space “${sp.name}” on sheet ${sheet.number}.`);
            }
          }
          setFillResult(null);
        }} />
      <DwgMarkupPropsModal measurement={propsFor} onClose={() => setPropsFor(null)} ctx={ctx} project={project}
        sheet={sheet} cal={cal} aspect={aspect} measures={measures} />
      <DwgBulkUnitsModal open={bulkOpen} onClose={() => setBulkOpen(false)} ctx={ctx} project={project}
        sheet={sheet} pageIndex={pageIndex} measures={measures} />
      <DwgRecalculateModal open={recalcOpen} onClose={() => setRecalcOpen(false)} rows={recalcOpen ? recalcRows() : []}
        sheet={sheet} pageIndex={pageIndex} cal={cal} onApply={applyRecalc} editable={editable} />
      <DwgVisualSearchModal open={searchOpen} onClose={() => { setSearchOpen(false); setSearchSeed(null); }}
        ctx={ctx} project={project} sheet={sheet} seed={searchSeed} pdf={pdf} cal={cal} aspect={aspect}
        activeTool={activeTool} fillCfg={fillCfg}
        onApply={(pins, note, template) => { commit('count', pins, { origin: 'search', note, searchTemplate: template }); setSearchOpen(false); setSearchSeed(null); }} />
    </div>
  );
}

// ── Depth, rise/drop and slope, as properties ─────────────────────────────
// Depth and Rise/Drop are MUTUALLY EXCLUSIVE and this is where that is
// enforced, in the one place both are written. They are not alternative ways of
// saying the same thing: depth is a third dimension (an area becomes a volume, a
// length becomes a wall area), rise/drop is a total added onto the end of a run.
// A markup carrying both would be answering two different questions with one
// number, so setting either clears the other.
function dwgSetSecondary(fields) {
  const out = { ...fields };
  if (out.depthValue !== undefined && out.depthValue !== null && dwgNum(out.depthValue) !== 0) {
    out.riseValue = null;
  }
  if (out.riseValue !== undefined && out.riseValue !== null && dwgNum(out.riseValue) !== 0) {
    out.depthValue = null;
  }
  return out;
}

// Re-runs positional Space attribution across one page of a sheet DRAFT. Used
// when a Space is created, renamed away, or removed, and by "Recalculate Space"
// for markups drawn before the Space existed. Attribution is positional every
// time it is asked, never a value someone typed.
function dwgRecalcSpaces(sheetDraft, pageIndex) {
  let changed = 0;
  (sheetDraft.measurements || []).forEach(m => {
    if (m.active === false || dwgNum(m.pageIndex) !== dwgNum(pageIndex)) return;
    const sp = dwgSpaceAt(sheetDraft, pageIndex, dwgMarkupAnchor(m));
    const next = sp ? sp.id : null;
    if ((m.spaceId || null) !== next) { m.spaceId = next; changed++; }
  });
  return changed;
}

// ── Spaces on this page ───────────────────────────────────────────────────
function DwgSpacesPanel({ ctx, project, sheet, pageIndex, spaces, measures, aspect, editable }) {
  const [msg, setMsg] = useState('');
  const counts = {};
  measures.forEach(m => { if (m.spaceId) counts[m.spaceId] = (counts[m.spaceId] || 0) + 1; });
  const unfiled = measures.filter(m => m.kind !== 'deduct' && !m.spaceId).length;
  // Depth-first so the list reads as the address it is: Level 2, then Unit 204
  // indented under it, then Bathroom under that.
  const tree = [];
  (function walk(parentId, depth) {
    spaces.filter(s => (s.parentId || null) === parentId)
      .sort((a, b) => (a.name || '').localeCompare(b.name || ''))
      .forEach(s => { tree.push({ s, depth }); walk(s.id, depth + 1); });
  })(null, 0);
  // A space whose parent is on another page (or gone) would never be reached by
  // the walk, so it is listed at the root rather than silently disappearing.
  spaces.forEach(s => { if (!tree.find(t => t.s.id === s.id)) tree.push({ s, depth: 0 }); });

  function edit(sp, fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const sh = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!sh || !Array.isArray(sh.spaces)) return;
      const t = sh.spaces.find(x => x.id === sp.id);
      if (t) Object.assign(t, fields);
    }, why);
  }
  function remove(sp) {
    const kids = spaces.filter(x => x.parentId === sp.id);
    if (!confirm(`Remove the Space "${sp.name}"?`
      + (kids.length ? ` Its ${kids.length} nested Space${kids.length === 1 ? '' : 's'} move up to its parent.` : '')
      + ` ${counts[sp.id] || 0} markup${(counts[sp.id] || 0) === 1 ? '' : 's'} filed to it will be re-filed to whatever Space they actually sit in — nothing is deleted.`)) return;
    dwgUpdate(ctx, project.id, draft => {
      const sh = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!sh || !Array.isArray(sh.spaces)) return;
      const dead = sh.spaces.find(x => x.id === sp.id);
      if (!dead) return;
      dead.active = false;
      sh.spaces.forEach(x => { if (x.parentId === sp.id) x.parentId = dead.parentId || null; });
      dwgRecalcSpaces(sh, pageIndex);
    }, `Take-off: removed the Space "${sp.name}" from sheet ${sheet.number}.`);
  }
  function recalc() {
    let n = 0;
    dwgUpdate(ctx, project.id, draft => {
      const sh = draft.drawingSheets.find(x => x.id === sheet.id);
      if (sh) n = dwgRecalcSpaces(sh, pageIndex);
    }, `Take-off: re-filed markups to Spaces on sheet ${sheet.number} page ${pageIndex + 1}.`);
    setMsg(n ? `${n} markup${n === 1 ? ' was' : 's were'} re-filed.` : 'Every markup was already filed to the right Space.');
  }

  return (
    <Collapsible id={`dwg-spaces-${sheet.id}-${pageIndex}`} title="Spaces on this page" count={spaces.length}
      right={editable && spaces.length ? <Button size="sm" variant="ghost" onClick={e => { e.stopPropagation(); recalc(); }}>Recalculate Space</Button> : null}>
      <DwgNote>
        A Space is a named region — a level, a unit, a room. They <b>nest</b>, and a markup files itself to the
        deepest one it sits inside, positionally, at the moment it is drawn. Two Spaces may contain one another
        or be entirely separate; they may never half overlap, and their borders may not even touch, because a
        markup landing on a shared border would have no answer. A drag that would break that is refused and
        snapped back. <b>Recalculate Space</b> re-files markups drawn before a Space existed.
      </DwgNote>
      {!!msg && <div className="mt-2 text-xs rounded border border-[var(--leon-line)] bg-[var(--leon-cream)] px-2 py-1">{msg}</div>}
      {!spaces.length ? <div className="mt-2"><EmptyState text="No Spaces on this page. Draw one with the 🏠 Space tool." /></div> : (
        <div className="space-y-1 mt-2">
          {tree.map(({ s, depth }) => (
            <div key={s.id} className="flex items-center gap-2 text-xs border border-[var(--leon-line)] rounded px-2 py-1"
              style={{ marginLeft: depth * 16 }}>
              <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: s.color }} />
              {editable ? (
                <input value={s.name} onChange={e => edit(s, { name: e.target.value })}
                  className="px-1.5 py-0.5 border border-[var(--leon-line)] rounded bg-white w-40" />
              ) : <span className="font-semibold w-40 truncate">{s.name}</span>}
              <span className="text-[var(--leon-black)]/45 flex-1 truncate">{dwgSpacePath(sheet, s)}</span>
              <Badge tone={counts[s.id] ? 'blue' : 'neutral'}>{counts[s.id] || 0} markup{(counts[s.id] || 0) === 1 ? '' : 's'}</Badge>
              {editable && <input type="color" value={s.color || '#1f7a8c'} onChange={e => edit(s, { color: e.target.value })}
                className="w-7 h-6 rounded border border-[var(--leon-line)]" />}
              {editable && <IconBtn title="Remove this Space" onClick={() => remove(s)}>✕</IconBtn>}
            </div>
          ))}
          {!!unfiled && (
            <div className="text-[11px] text-[var(--leon-black)]/50 pt-1">
              {unfiled} markup{unfiled === 1 ? '' : 's'} on this page {unfiled === 1 ? 'is' : 'are'} in no Space at all — that is
              perfectly legitimate, it just means nothing was drawn round {unfiled === 1 ? 'it' : 'them'}.
            </div>
          )}
        </div>
      )}
    </Collapsible>
  );
}

// ── Viewports on this page ────────────────────────────────────────────────
function DwgViewportsPanel({ ctx, project, sheet, pageIndex, viewports, measures, cal, pageWidthMm, editable }) {
  const counts = {};
  measures.forEach(m => { if (m.viewportId) counts[m.viewportId] = (counts[m.viewportId] || 0) + 1; });
  const orphaned = measures.filter(m => m.viewportId && !dwgViewportById(sheet, m.viewportId));
  const detached = measures.filter(m => m.viewportId && (dwgViewportById(sheet, m.viewportId) || {}).active === false);

  function edit(vp, fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const sh = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!sh || !Array.isArray(sh.viewports)) return;
      const t = sh.viewports.find(x => x.id === vp.id);
      if (t) Object.assign(t, fields);
    }, why);
  }
  function remove(vp) {
    const n = counts[vp.id] || 0;
    if (!confirm(`Remove the viewport "${vp.name}"?`
      + (n ? ` ${n} markup${n === 1 ? '' : 's'} were measured through it. They are NOT re-measured behind your back — each one is flagged, falls back to the page scale, and appears in Recalculate for you to accept.` : ''))) return;
    dwgUpdate(ctx, project.id, draft => {
      const sh = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!sh || !Array.isArray(sh.viewports)) return;
      sh.viewports.forEach(x => { if (x.id === vp.id) x.active = false; });
    }, `Take-off: removed the viewport "${vp.name}" from sheet ${sheet.number} — ${n} markup${n === 1 ? '' : 's'} flagged for Recalculate.`);
  }

  return (
    <Collapsible id={`dwg-vps-${sheet.id}-${pageIndex}`} title="Viewports on this page" count={viewports.length}>
      <DwgNote>
        A viewport is a labelled box carrying <b>its own scale</b> — a detail bubble drawn at 1 1/2&quot; = 1&apos;-0&quot; on a
        sheet plotted at 1/4&quot;. A markup drawn inside one is measured at the viewport&apos;s scale; outside every
        viewport the page calibration applies. The decision is <b>positional and taken once, at the moment the
        markup is drawn</b>, and the viewport it used is recorded on it — so the number stays explainable, and
        moving a viewport later never silently re-measures work already signed off.
        <br />
        <b>Where two viewports overlap, the smaller one wins.</b> Bluebeam does not document its own rule; this is
        ours, and the reason is that a detail bubble on top of a plan region is the more specific statement about
        that spot, which is what an estimator means.
      </DwgNote>
      {!viewports.length ? <div className="mt-2"><EmptyState text="No viewports on this page — everything here is measured at the page scale." /></div> : (
        <div className="space-y-1.5 mt-2">
          {viewports.map(vp => {
            const ratio = pageWidthMm ? dwgNum(vp.mmPerPageWidth) / pageWidthMm : 0;
            const pageRatio = cal && pageWidthMm ? dwgNum(cal.mmPerPageWidth) / pageWidthMm : 0;
            return (
              <div key={vp.id} className="border border-[var(--leon-line)] rounded px-2 py-1.5 text-xs">
                <div className="flex items-center gap-2 flex-wrap">
                  <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: DWG_DRAW_COLORS.viewport }} />
                  {editable ? (
                    <input value={vp.name} onChange={e => edit(vp, { name: e.target.value })}
                      placeholder="name it…" className="px-1.5 py-0.5 border border-[var(--leon-line)] rounded bg-white w-44" />
                  ) : <span className="font-semibold">{vp.name}</span>}
                  <Badge tone="neutral">{vp.scaleLabel || dwgScaleLabelFromRatio(ratio) || 'no scale'}</Badge>
                  <span className="text-[var(--leon-black)]/45">
                    {ratio && pageRatio ? `${(ratio / pageRatio).toFixed(2)}× the page scale` : ''}
                  </span>
                  <Badge tone={counts[vp.id] ? 'blue' : 'neutral'}>{counts[vp.id] || 0} markup{(counts[vp.id] || 0) === 1 ? '' : 's'}</Badge>
                  <span className="ml-auto flex items-center gap-1">
                    {editable && <IconBtn title="Remove this viewport" onClick={() => remove(vp)}>✕</IconBtn>}
                  </span>
                </div>
                <div className="text-[10px] text-[var(--leon-black)]/45 mt-0.5">
                  {vp.source}{vp.knownText ? ` · against ${vp.knownText}` : ''} · {vp.createdBy} {fmtDate(vp.createdDate)}
                  {vp.note ? ` · ${vp.note}` : ''}
                </div>
              </div>
            );
          })}
        </div>
      )}
      {!!(orphaned.length + detached.length) && (
        <div className="mt-2 text-xs rounded border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-2 py-1.5">
          {orphaned.length + detached.length} markup{orphaned.length + detached.length === 1 ? '' : 's'} on this page point at a
          viewport that no longer exists. They are being measured at the page scale and are listed in
          <b> Recalculate</b>, which is where the pointer is cleared once you have looked at them.
        </div>
      )}
    </Collapsible>
  );
}

// ── Naming a viewport ─────────────────────────────────────────────────────
// A LABEL IS REQUIRED. An unnamed viewport is a scale nobody can account for
// afterwards, and the whole reason the viewport is recorded on the markup is so
// the number can be explained six weeks later.
function DwgViewportModal({ draft, onClose, ctx, project, sheet, pageIndex, pageWidthMm, aspect, onDone }) {
  const [name, setName] = useState('');
  const [mode, setMode] = useState('scale');
  const [scaleLabel, setScaleLabel] = useState('1/2" = 1\'-0"');
  const [known, setKnown] = useState('');
  const [note, setNote] = useState('');
  useEffect(() => { if (draft) { setName(''); setMode('scale'); setScaleLabel('1/2" = 1\'-0"'); setKnown(''); setNote(''); } }, [draft]);
  if (!draft) return null;

  const box = dwgViewportBox({ points: draft.points });
  const boxWidthFraction = box ? (box.x1 - box.x0) : 0;
  const ratio = dwgScaleRatioFromLabel(scaleLabel);
  const knownMm = parseDim(known, 'Imperial');
  // Two honest ways to state the scale, and no third. Either the detail says
  // what it is drawn at, or something in it is a dimension you can read.
  const mmPerPageWidth = mode === 'scale'
    ? (ratio && pageWidthMm ? ratio * pageWidthMm : 0)
    : (knownMm && boxWidthFraction > 0 ? knownMm / boxWidthFraction : 0);
  const derivedLabel = mode === 'scale' ? scaleLabel
    : (mmPerPageWidth && pageWidthMm ? dwgScaleLabelFromRatio(mmPerPageWidth / pageWidthMm) : '');
  const ok = !!name.trim() && mmPerPageWidth > 0;

  function save() {
    if (!ok) return;
    const rec = dwgMakeViewport({
      pageIndex, name: name.trim(), points: draft.points,
      scaleLabel: derivedLabel, mmPerPageWidth,
      knownText: mode === 'known' ? known.trim() : '',
      source: mode === 'scale' ? 'Typed scale' : 'Measured across the box',
      note: note.trim(),
    }, ctx.currentUserName);
    dwgUpdate(ctx, project.id, d => {
      const s = d.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!Array.isArray(s.viewports)) s.viewports = [];
      s.viewports.push(rec);
    }, `Take-off: added the viewport "${rec.name}" at ${rec.scaleLabel} on sheet ${sheet.number} page ${pageIndex + 1}.`);
    if (onDone) onDone();
  }

  return (
    <Modal open wide onClose={onClose} title="Name this viewport and give it a scale" footer={
      <>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button onClick={save} disabled={!ok}>Add the viewport</Button>
      </>
    }>
      <div className="space-y-3">
        <DwgNote>
          Everything you draw <b>inside this box</b> from now on is measured at the scale you set here, not at the
          page scale. Where viewports overlap the <b>smallest</b> one wins, because it is the more specific
          statement about that spot.
        </DwgNote>
        <Field label="Label" hint="Required — an unnamed viewport is a scale nobody can account for later.">
          <TextInput value={name} onChange={e => setName(e.target.value)} placeholder="Detail 3 / A-501 — typical head" />
        </Field>
        <div className="flex items-center gap-3 text-xs">
          <label className="flex items-center gap-1">
            <input type="radio" checked={mode === 'scale'} onChange={() => setMode('scale')} /> It says what it is drawn at
          </label>
          <label className="flex items-center gap-1">
            <input type="radio" checked={mode === 'known'} onChange={() => setMode('known')} /> Measure across the box
          </label>
        </div>
        {mode === 'scale' ? (
          <Field label="Scale">
            <div className="flex gap-2">
              <Select className="!w-44" value={DWG_SCALE_PRESETS.indexOf(scaleLabel) >= 0 ? scaleLabel : ''}
                onChange={e => { if (e.target.value) setScaleLabel(e.target.value); }}>
                <option value="">— type it instead —</option>
                {DWG_SCALE_PRESETS.filter(p => p !== 'NTS').map(p => <option key={p} value={p}>{p}</option>)}
              </Select>
              <TextInput value={scaleLabel} onChange={e => setScaleLabel(e.target.value)} placeholder={'1 1/2" = 1\'-0"'} />
            </div>
          </Field>
        ) : (
          <Field label="How wide is this box in reality?"
            hint="Read a dimension that spans the box you just drew — a grid bay, an opening width. Feet-and-inches or millimetres.">
            <TextInput value={known} onChange={e => setKnown(e.target.value)} placeholder={`3'-6"  ·  1067mm`} />
          </Field>
        )}
        <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/50 px-3 py-2 text-xs">
          {mmPerPageWidth > 0 ? (
            <>
              This viewport reads as <b>{derivedLabel || '—'}</b>. One page width across it is{' '}
              <b>{fmtDim(mmPerPageWidth, 'Imperial')}</b>, against{' '}
              {pageWidthMm ? <>a sheet {fmtDim(pageWidthMm, 'Imperial')} wide on paper</> : <>an unknown paper size</>}.
            </>
          ) : (
            <span className="text-[var(--leon-red)]">
              {mode === 'scale'
                ? 'That scale cannot be read arithmetically — write it as 1/4" = 1\'-0" or as 1:50.'
                : 'Type a dimension you can read off the drawing.'}
            </span>
          )}
        </div>
        <Field label="Note"><TextInput value={note} onChange={e => setNote(e.target.value)} placeholder="Optional — where the scale was read from." /></Field>
      </div>
    </Modal>
  );
}

// ── Naming a Space ────────────────────────────────────────────────────────
function DwgSpaceModal({ draft, onClose, ctx, project, sheet, pageIndex, aspect, measures, onDone }) {
  const [name, setName] = useState('');
  const [color, setColor] = useState('#1f7a8c');
  const [note, setNote] = useState('');
  useEffect(() => { if (draft) { setName(''); setColor('#1f7a8c'); setNote(''); } }, [draft]);
  if (!draft) return null;

  const parent = draft.parent || null;
  // How many markups already on the page will file themselves to this Space the
  // moment it exists — worth saying before it is created, not after.
  const willTake = (measures || []).filter(m => m.active !== false
    && dwgNum(m.pageIndex) === dwgNum(pageIndex)
    && dwgPointInPoly(dwgMarkupAnchor(m), draft.points)).length;

  function save() {
    if (!name.trim()) return;
    const rec = dwgMakeSpace({ pageIndex, name: name.trim(), parentId: draft.parentId || null,
                               points: draft.points, color, note: note.trim() }, ctx.currentUserName);
    dwgUpdate(ctx, project.id, d => {
      const s = d.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!Array.isArray(s.spaces)) s.spaces = [];
      s.spaces.push(rec);
      dwgRecalcSpaces(s, pageIndex);
    }, `Take-off: added the Space "${rec.name}"${parent ? ` inside "${parent.name}"` : ''} on sheet ${sheet.number}.`);
    if (onDone) onDone();
  }

  return (
    <Modal open onClose={onClose} title="Name this Space" footer={
      <>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button onClick={save} disabled={!name.trim()}>Add the Space</Button>
      </>
    }>
      <div className="space-y-3">
        <Field label="Name"><TextInput value={name} onChange={e => setName(e.target.value)} placeholder="Unit 204 · Bathroom · Level 2" /></Field>
        <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/50 px-3 py-2 text-xs space-y-1">
          {parent
            ? <div>It sits entirely inside <b>{parent.name}</b>, so it nests under it automatically — the address will read <b>{dwgSpacePath(sheet, parent)} → {name.trim() || '…'}</b>. Nobody picks the parent by hand, because a person picking one is a person who will eventually pick wrong.</div>
            : <div>It is not inside any other Space, so it sits at the top level.</div>}
          <div>{willTake} markup{willTake === 1 ? '' : 's'} already on this page fall inside it and will be filed to it.</div>
        </div>
        <div className="flex items-center gap-2">
          <input type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 rounded border border-[var(--leon-line)]" />
          <TextInput className="flex-1" value={note} onChange={e => setNote(e.target.value)} placeholder="Note (optional)" />
        </div>
      </div>
    </Modal>
  );
}

// ── What Dynamic Fill found ───────────────────────────────────────────────
// The fill is shown as a NUMBER before it becomes a markup. A flood fill that
// leaked through a doorway looks plausible on screen and is obvious the moment
// its area is stated, so the area is stated first.
function DwgFillResultModal({ result, onClose, ctx, project, sheet, pageIndex, cal, aspect, measures, makeSpace, activeTool, onCommit }) {
  const [label, setLabel] = useState('');
  const [spaceName, setSpaceName] = useState('');
  const [alsoSpace, setAlsoSpace] = useState(false);
  useEffect(() => {
    if (result) { setLabel(''); setSpaceName(''); setAlsoSpace(!!makeSpace); }
  }, [result]);
  if (!result) return null;

  const mmPer = cal ? dwgNum(cal.mmPerPageWidth) : 0;
  const holes = (result.holes || []);
  const netUnits = dwgRingsArea(result.outer, holes, aspect);
  const grossUnits = Math.abs(dwgSignedArea(result.outer, aspect));
  const netMm2 = netUnits * mmPer * mmPer;
  const grossMm2 = grossUnits * mmPer * mmPer;
  const perimeterMm = dwgPolyLength(result.outer, aspect, true) * mmPer;
  const vertices = result.outer.length;

  return (
    <Modal open wide onClose={onClose} title={result.origin === 'lasso' ? 'Lassoed region' : 'Dynamic Fill'} footer={
      <>
        <Button variant="ghost" onClick={onClose}>Throw it away</Button>
        <Button onClick={() => onCommit(result.outer, holes, {
          origin: result.origin, space: alsoSpace, spaceName: spaceName.trim() || label.trim() || 'Room',
          note: result.origin === 'lasso'
            ? 'Taken with the lasso — the region was drawn by hand and the drawing’s own linework was ignored.'
            : `Flood-filled from a click; ${result.pixels.toLocaleString()} pixels, traced and simplified to ${vertices} points.`,
        })}>Keep it as an area</Button>
      </>
    }>
      <div className="space-y-3">
        <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
          <DwgStat label="Area" value={mmPer ? dwgFmtQty(dwgAreaIn(netMm2, 'SF'), 'SF') : '—'}
            sub={mmPer ? dwgFmtQty(dwgAreaIn(netMm2, 'SM'), 'SM') : 'no scale on this page'} />
          <DwgStat label="Perimeter" value={mmPer ? dwgFmtQty(dwgLengthIn(perimeterMm, 'LF'), 'LF') : '—'}
            sub={mmPer ? fmtDim(perimeterMm, 'Imperial') : ''} />
          <DwgStat label="Openings found" value={holes.length}
            sub={holes.length ? 'each becomes a cutout' : 'nothing enclosed inside it'} />
          <DwgStat label="Outline" value={`${vertices} pts`} sub="simplified from the traced pixels" />
        </div>
        {!!holes.length && grossMm2 > netMm2 && (
          <div className="text-xs text-[var(--leon-black)]/60">
            Gross {dwgFmtQty(dwgAreaIn(grossMm2, 'SF'), 'SF')}, less {dwgFmtQty(dwgAreaIn(grossMm2 - netMm2, 'SF'), 'SF')} of
            enclosed openings — columns, shafts and cores the fill went round. Each one is recorded as a real
            cutout, so it can be promoted into a measurement of its own later.
          </div>
        )}
        {result.overflow && (
          <div className="text-xs rounded border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-2 py-1.5">
            The fill reached the limit and stopped. That almost always means it <b>leaked</b> — a wall stops short
            of a jamb somewhere and the whole floor plate came with it. Raise the edge sensitivity, or draw a
            ✂️ Fill boundary across the gap and try again.
          </div>
        )}
        <Field label="Name it"><TextInput value={label} onChange={e => setLabel(e.target.value)} placeholder="Corridor 2 floor" /></Field>
        <label className="flex items-center gap-2 text-xs">
          <input type="checkbox" checked={alsoSpace} onChange={e => setAlsoSpace(e.target.checked)} />
          Also make this outline a <b>Space</b>, so everything drawn in it from now on files itself here
        </label>
        {alsoSpace && (
          <Field label="Space name"><TextInput value={spaceName} onChange={e => setSpaceName(e.target.value)} placeholder={label.trim() || 'Room'} /></Field>
        )}
        {activeTool && (
          <div className="text-[11px] text-[var(--leon-black)]/50">
            The tool in your hand — <b>{activeTool.name}</b> — stamps its subject, layer, colour and column
            defaults onto this the moment you keep it.
          </div>
        )}
        <DwgNote>
          This is ordinary image processing and nothing more: the page is rendered off screen, thresholded into
          ink and paper, the gaps are closed by growing the ink and shrinking it back, the region is flood-filled
          from where you clicked, traced, and simplified. No model, no server, and the PDF is never modified.
        </DwgNote>
      </div>
    </Modal>
  );
}

// ── One markup's properties ───────────────────────────────────────────────
// A markup's review state. Deliberately its own short vocabulary rather than
// the takeoff record's — a markup is checked, queried or accepted on the
// drawing; a takeoff RECORD is approved into a bid. Collapsing the two would
// make "Approved" mean two different things in one export.
const DWG_MARKUP_STATUSES = ['', 'For review', 'Checked', 'Queried', 'Accepted', 'Rejected'];

// One input per custom-column type. A Choice contributes its LABEL to the eye
// and its NUMBER to the arithmetic, which is the whole reason a Choice can carry
// a value at all.
function DwgColumnInput({ col, value, onChange, subject }) {
  const v = value === undefined || value === null ? '' : value;
  if (col.type === 'Checkmark') {
    return <input type="checkbox" checked={!!v} onChange={e => onChange(e.target.checked)} />;
  }
  if (col.type === 'Date') {
    return <TextInput type="date" value={v} onChange={e => onChange(e.target.value)} />;
  }
  if (col.type === 'Number') {
    return <TextInput type="number" step="any" value={v} onChange={e => onChange(e.target.value)}
      min={col.min === null ? undefined : col.min} max={col.max === null ? undefined : col.max} />;
  }
  if (col.type === 'Choice') {
    // An item scoped to a Subject is offered only on markups carrying it —
    // "Oak veneer" has no business in the list on a floor tile markup.
    const items = (col.items || []).filter(i => !i.subjects || !i.subjects.length
      || i.subjects.indexOf(subject || '') >= 0);
    const known = items.find(i => i.label === v);
    return (
      <div className="flex gap-1">
        <Select value={known ? v : ''} onChange={e => onChange(e.target.value)}>
          <option value="">—</option>
          {items.map(i => <option key={i.id} value={i.label}>{i.label}{i.value === null || i.value === undefined ? '' : ` (${i.value})`}</option>)}
        </Select>
        {col.allowCustomText && (
          <TextInput className="!w-32" value={known ? '' : v} onChange={e => onChange(e.target.value)} placeholder="or type it" />
        )}
      </div>
    );
  }
  if (col.type === 'Formula') {
    return <span className="text-xs text-[var(--leon-black)]/45">calculated — {col.formula || 'no formula set'}</span>;
  }
  return col.multiline
    ? <TextArea rows={2} value={v} onChange={e => onChange(e.target.value)} />
    : <TextInput value={v} onChange={e => onChange(e.target.value)} />;
}

function DwgMarkupPropsModal({ measurement, onClose, ctx, project, sheet, cal, aspect, measures }) {
  const m = measurement;
  const [tab, setTab] = useState('dims');
  if (!m) return null;
  const v = dwgMeasureValue(m, cal, aspect, measures, sheet);
  const fig = dwgMarkupFigures(m, v);
  const cols = dwgColumns(project);
  const plan = dwgBuildColumnPlan(cols);
  const resolved = dwgResolveRowColumns(plan, {
    Area: fig.area, Count: fig.count, Length: fig.length, Measurement: fig.measurement, Volume: fig.volume,
  }, m.custom || {});
  const spaces = dwgSheetSpaces(sheet, m.pageIndex);
  const prec = dwgPrecisionFor(m);
  const hasDepth = m.depthValue !== null && m.depthValue !== undefined && dwgNum(m.depthValue) !== 0;
  const hasRise = m.riseValue !== null && m.riseValue !== undefined && dwgNum(m.riseValue) !== 0;
  const isLine = m.kind === 'distance';
  const isArea = m.kind === 'area' || m.kind === 'rect' || m.kind === 'deduct';

  function set(fields, why) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      const t = s.measurements.find(x => x.id === m.id);
      if (t) Object.assign(t, fields);
    }, why);
  }
  function setUnit(type, unit) { set({ units: { ...(m.units || {}), [type]: unit } }); }
  function setPrec(fields) { set({ precision: { ...prec, ...fields } }); }
  function setCustom(colId, value) { set({ custom: { ...(m.custom || {}), [colId]: value } }); }

  return (
    <Modal open wide onClose={onClose} title={`${dwgMeasureName(m, 0)} — properties`} footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
      <div className="space-y-3">
        <div className="flex gap-1.5 flex-wrap">
          <DwgChip active={tab === 'dims'} onClick={() => setTab('dims')}>Depth · rise · slope</DwgChip>
          <DwgChip active={tab === 'units'} onClick={() => setTab('units')}>Units &amp; precision</DwgChip>
          <DwgChip active={tab === 'group'} onClick={() => setTab('group')}>Subject · layer · space</DwgChip>
          <DwgChip active={tab === 'cols'} onClick={() => setTab('cols')} count={cols.length}>Columns</DwgChip>
        </div>

        {tab === 'dims' && (
          <div className="space-y-3">
            <DwgNote>
              <b>Depth</b> is a third dimension: it turns an Area into a <b>Volume</b> and a Length into a
              <b> Wall Area</b>. <b>Rise / Drop</b> is a flat total <b>added</b> to a length — a run that climbs
              between floors. They answer different questions, so a markup may carry one or the other and never
              both; setting either clears the other. <b>Slope</b> is different again: it stretches what was
              measured on the plan by √(1 + m²), because a plan measures the run and the surface is longer.
            </DwgNote>
            {m.kind === 'count' || dwgIsShapeKind(m.kind) ? (
              <div className="text-xs text-[var(--leon-black)]/55">
                A {m.kind === 'count' ? 'count' : 'shape question'} has no depth, rise or slope — there is nothing for them to act on.
              </div>
            ) : (
              <>
                <div className="grid gap-3 md:grid-cols-2">
                  <Field label="Depth" hint={isArea ? 'Area × depth = volume; perimeter × depth = wall area.' : 'Length × depth = wall area.'}>
                    <div className="flex gap-1">
                      <TextInput type="number" step="any" value={m.depthValue === null || m.depthValue === undefined ? '' : m.depthValue}
                        onChange={e => set(dwgSetSecondary({ depthValue: e.target.value === '' ? null : Number(e.target.value) }),
                          `Take-off: set a depth on a markup on sheet ${sheet.number}.`)} />
                      <Select className="!w-24" value={m.depthUnit || 'IN'} onChange={e => set({ depthUnit: e.target.value })}>
                        {DWG_DEPTH_UNITS.map(u => <option key={u}>{u}</option>)}
                      </Select>
                    </div>
                  </Field>
                  <Field label="Rise / Drop" hint="A total added on the end of the run, not per segment.">
                    <div className="flex gap-1">
                      <TextInput type="number" step="any" value={m.riseValue === null || m.riseValue === undefined ? '' : m.riseValue}
                        onChange={e => set(dwgSetSecondary({ riseValue: e.target.value === '' ? null : Number(e.target.value) }),
                          `Take-off: set a rise/drop on a markup on sheet ${sheet.number}.`)}
                        disabled={!isLine} />
                      <Select className="!w-24" value={m.riseUnit || 'FT'} onChange={e => set({ riseUnit: e.target.value })} disabled={!isLine}>
                        {DWG_DEPTH_UNITS.map(u => <option key={u}>{u}</option>)}
                      </Select>
                    </div>
                  </Field>
                </div>
                {hasDepth && hasRise && (
                  <div className="text-xs text-[var(--leon-red)]">Both are set — that should not be possible; clear one.</div>
                )}
                {!isLine && (
                  <div className="text-[11px] text-[var(--leon-black)]/45">Rise / Drop only applies to a length, so it is off here.</div>
                )}
                <div className="grid gap-3 md:grid-cols-2">
                  <Field label="Slope">
                    <div className="flex gap-1">
                      <Select className="!w-32" value={m.slopeMode || ''} onChange={e => set({ slopeMode: e.target.value })}>
                        <option value="">— flat —</option>
                        {DWG_SLOPE_MODES.map(s => <option key={s}>{s}</option>)}
                      </Select>
                      <TextInput type="number" step="any" disabled={!m.slopeMode}
                        value={m.slopeValue === null || m.slopeValue === undefined ? '' : m.slopeValue}
                        onChange={e => set({ slopeValue: e.target.value === '' ? null : Number(e.target.value) })} />
                    </div>
                  </Field>
                  <div className="text-xs text-[var(--leon-black)]/55 self-end pb-2">
                    {m.slopeMode === 'Pitch' ? 'Pitch: the rise in twelve — 4 means 4-in-12.'
                      : m.slopeMode === 'Degree' ? 'Degrees from horizontal.'
                      : m.slopeMode === 'Grade' ? 'Grade: a percentage — 2 means 2%.'
                      : 'Three ways of writing the same thing; pick whichever the trade wrote it in.'}
                    {v.slopeFactor > 1 && <> Currently multiplying by <b>{v.slopeFactor.toFixed(4)}</b>.</>}
                  </div>
                </div>
              </>
            )}
            <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
              <DwgStat label="Length" value={fig.length ? dwgFmtValue(fig.length, fig.lengthUnit, prec) : '—'} />
              <DwgStat label="Area" value={fig.area ? dwgFmtValue(fig.area, fig.areaUnit, prec) : '—'} />
              <DwgStat label="Volume" value={fig.volume ? dwgFmtValue(fig.volume, fig.volumeUnit, prec) : '—'} />
              <DwgStat label="Wall area" value={fig.wallArea ? dwgFmtValue(fig.wallArea, fig.areaUnit, prec) : '—'} />
            </div>
          </div>
        )}

        {tab === 'units' && (
          <div className="space-y-3">
            <DwgNote>
              Units and precision belong to the <b>markup</b>, not to the page. Cubic yards of concrete, square
              yards of carpet and feet-and-inches of trim all belong on one sheet at one scale — a unit is a
              property of what was measured, and the scale is a property of where it was measured.
            </DwgNote>
            <div className="grid gap-3 md:grid-cols-3">
              {['Length', 'Area', 'Volume'].map(t => (
                <Field key={t} label={`${t} unit`}>
                  <Select value={dwgUnitFor(m, t)} onChange={e => setUnit(t, e.target.value)}>
                    {(DWG_UNITS_BY_TYPE[t] || []).map(u => <option key={u}>{u}</option>)}
                  </Select>
                </Field>
              ))}
            </div>
            <div className="grid gap-3 md:grid-cols-3">
              <Field label="Precision">
                <Select value={prec.mode} onChange={e => setPrec({ mode: e.target.value })}>
                  {DWG_PRECISION_MODES.map(p => <option key={p}>{p}</option>)}
                </Select>
              </Field>
              {prec.mode === 'Decimal' ? (
                <Field label="Decimal places">
                  <Select value={prec.decimals} onChange={e => setPrec({ decimals: Number(e.target.value) })}>
                    {[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{d}</option>)}
                  </Select>
                </Field>
              ) : (
                <Field label="Nearest" hint="A trim length that reads 12.34 LF is not a length anybody cuts to.">
                  <Select value={prec.denom} onChange={e => setPrec({ denom: Number(e.target.value) })}>
                    {DWG_FRACTION_DENOMS.map(d => <option key={d} value={d}>1/{d}</option>)}
                  </Select>
                </Field>
              )}
              <div className="self-end pb-2 text-xs text-[var(--leon-black)]/55">
                reads as <b>{dwgFmtValue(fig.measurement, '', prec)}</b>
              </div>
            </div>
          </div>
        )}

        {tab === 'group' && (
          <div className="space-y-3">
            <DwgNote>
              <b>Subject</b>, <b>Layer</b> and <b>Space</b> are the three axes everything downstream groups on —
              the list, the subtotals, the legend and the export. Get them right here and none of those has to
              be configured separately.
            </DwgNote>
            <div className="grid gap-3 md:grid-cols-2">
              <Field label="Subject" hint="What it is. The main grouping axis.">
                <TextInput value={m.subject || ''} onChange={e => set({ subject: e.target.value })} placeholder="Tile — floor" />
              </Field>
              <Field label="Layer" hint="Which overlay it belongs to — base bid, alternate, an addendum.">
                <TextInput value={m.layer || ''} onChange={e => set({ layer: e.target.value })} placeholder="Base bid" />
              </Field>
              <Field label="Label"><TextInput value={m.label || ''} onChange={e => set({ label: e.target.value })} /></Field>
              <Field label="Space" hint="Normally decided positionally; this is the override.">
                <Select value={m.spaceId || ''} onChange={e => set({ spaceId: e.target.value || null },
                  `Take-off: a markup on sheet ${sheet.number} was filed to a Space by hand.`)}>
                  <option value="">— none —</option>
                  {spaces.map(s => <option key={s.id} value={s.id}>{dwgSpacePath(sheet, s)}</option>)}
                </Select>
              </Field>
              <Field label="Status">
                <Select value={m.status || ''} onChange={e => set({ status: e.target.value })}>
                  {DWG_MARKUP_STATUSES.map(s => <option key={s || 'none'} value={s}>{s || '— none —'}</option>)}
                </Select>
              </Field>
              <Field label="Colour">
                <input type="color" value={m.color || DWG_DRAW_COLORS[m.kind] || '#333333'}
                  onChange={e => set({ color: e.target.value })} className="w-full h-9 rounded border border-[var(--leon-line)]" />
              </Field>
            </div>
            <Field label="Comments"><TextArea rows={3} value={m.comments || ''} onChange={e => set({ comments: e.target.value })} /></Field>
            <div className="text-[11px] text-[var(--leon-black)]/45">
              Drawn by {m.createdBy} {fmtDate(m.createdDate)} · sheet Rev {m.sheetRevision || '—'} ·
              {v.scale.source === 'viewport' && v.scale.viewport ? ` measured through the viewport “${v.scale.viewport.name}” at ${v.scale.label}`
                : v.scale.viewportMissing ? ' its viewport has been deleted — now on the page scale'
                : ` measured at the page scale${v.scale.label ? ` (${v.scale.label})` : ''}`}
              {m.origin && m.origin !== 'drawn' ? ` · created by ${m.origin === 'fill' ? 'Dynamic Fill' : m.origin === 'lasso' ? 'the lasso' : m.origin === 'search' ? 'Visual Search' : 'a split'}` : ''}
            </div>
          </div>
        )}

        {tab === 'cols' && (
          <div className="space-y-3">
            {!cols.length ? <EmptyState text="No custom columns on this job yet — add them on the Markups List tab." /> : (
              <div className="space-y-2">
                {cols.map(c => (
                  <div key={c.id} className="grid gap-2 md:grid-cols-[10rem_1fr] items-center border-b border-[var(--leon-line)]/60 pb-2">
                    <div className="text-xs">
                      <div className="font-semibold">{c.name}</div>
                      <div className="text-[10px] text-[var(--leon-black)]/45">{c.type}{c.includeInTotals ? ' · totalled' : ''}</div>
                    </div>
                    {c.type === 'Formula' ? (
                      <div className="text-sm">
                        {plan.errors[c.id]
                          ? <span className="text-[var(--leon-red)] text-xs">{plan.errors[c.id]}</span>
                          : <b>{dwgFmtColumnValue(c, resolved.display[c.id])}</b>}
                        <span className="text-[10px] text-[var(--leon-black)]/40 ml-2">{c.formula}</span>
                      </div>
                    ) : (
                      <DwgColumnInput col={c} subject={m.subject} value={(m.custom || {})[c.id]}
                        onChange={val => setCustom(c.id, val)} />
                    )}
                  </div>
                ))}
              </div>
            )}
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── Change units, precision and the secondary dimensions over a selection ──
function DwgBulkUnitsModal({ open, onClose, ctx, project, sheet, pageIndex, measures }) {
  const [kind, setKind] = useState('');
  const [subject, setSubject] = useState('');
  const [layer, setLayer] = useState('');
  const [f, setF] = useState({});
  useEffect(() => { if (open) { setKind(''); setSubject(''); setLayer(''); setF({}); } }, [open]);

  const subjects = Array.from(new Set(measures.map(m => m.subject).filter(Boolean))).sort();
  const layers = Array.from(new Set(measures.map(m => m.layer).filter(Boolean))).sort();
  const hit = measures.filter(m => m.kind !== 'deduct'
    && (!kind || m.kind === kind)
    && (!subject || m.subject === subject)
    && (!layer || m.layer === layer));
  const anything = Object.keys(f).some(k => f[k] !== '' && f[k] !== undefined && f[k] !== null);

  function apply() {
    if (!hit.length || !anything) return;
    const ids = {};
    hit.forEach(m => { ids[m.id] = true; });
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheet.id);
      if (!s || !Array.isArray(s.measurements)) return;
      s.measurements.forEach(m => {
        if (!ids[m.id]) return;
        const next = {};
        if (f.lengthUnit) next.units = { ...(m.units || {}), Length: f.lengthUnit };
        if (f.areaUnit) next.units = { ...(next.units || m.units || {}), Area: f.areaUnit };
        if (f.volumeUnit) next.units = { ...(next.units || m.units || {}), Volume: f.volumeUnit };
        if (f.precMode) next.precision = { ...dwgPrecisionFor(m), mode: f.precMode };
        if (f.decimals !== undefined && f.decimals !== '') next.precision = { ...(next.precision || dwgPrecisionFor(m)), decimals: Number(f.decimals) };
        if (f.denom !== undefined && f.denom !== '') next.precision = { ...(next.precision || dwgPrecisionFor(m)), denom: Number(f.denom) };
        if (f.subject) next.subject = f.subject;
        if (f.layer) next.layer = f.layer;
        if (f.status) next.status = f.status === '(clear)' ? '' : f.status;
        if (f.depthValue !== undefined && f.depthValue !== '') {
          Object.assign(next, dwgSetSecondary({ depthValue: Number(f.depthValue) }));
          if (f.depthUnit) next.depthUnit = f.depthUnit;
        }
        if (f.slopeMode) { next.slopeMode = f.slopeMode === '(clear)' ? '' : f.slopeMode; if (f.slopeValue !== '') next.slopeValue = Number(f.slopeValue); }
        Object.assign(m, next);
      });
    }, `Take-off: changed units, precision or dimensions on ${hit.length} markup${hit.length === 1 ? '' : 's'} on sheet ${sheet.number} page ${pageIndex + 1}.`);
    onClose();
  }

  return (
    <Modal open={open} wide onClose={onClose} title="Units, precision and dimensions over a selection" footer={
      <>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button onClick={apply} disabled={!hit.length || !anything}>
          {hit.length} markup{hit.length === 1 ? '' : 's'} will be updated
        </Button>
      </>
    }>
      <div className="space-y-3">
        <DwgNote>
          Filter first, then set only what you want to change — anything left blank is left alone. The count on
          the button is what will actually be written, so a filter that catches nothing cannot be applied by
          accident.
        </DwgNote>
        <div className="grid gap-3 md:grid-cols-3">
          <Field label="Only this tool">
            <Select value={kind} onChange={e => setKind(e.target.value)}>
              <option value="">every tool</option>
              {DWG_DRAW_TOOLS.filter(t => t.key !== 'deduct' && t.key !== 'deductEllipse' && t.key !== 'fill')
                .map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
            </Select>
          </Field>
          <Field label="Only this subject">
            <Select value={subject} onChange={e => setSubject(e.target.value)}>
              <option value="">every subject</option>
              {subjects.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
          <Field label="Only this layer">
            <Select value={layer} onChange={e => setLayer(e.target.value)}>
              <option value="">every layer</option>
              {layers.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
        </div>
        <div className="border-t border-[var(--leon-line)] pt-3 grid gap-3 md:grid-cols-3">
          {['Length', 'Area', 'Volume'].map(t => (
            <Field key={t} label={`${t} unit`}>
              <Select value={f[`${t.toLowerCase()}Unit`] || ''} onChange={e => setF({ ...f, [`${t.toLowerCase()}Unit`]: e.target.value })}>
                <option value="">leave alone</option>
                {(DWG_UNITS_BY_TYPE[t] || []).map(u => <option key={u}>{u}</option>)}
              </Select>
            </Field>
          ))}
          <Field label="Precision">
            <Select value={f.precMode || ''} onChange={e => setF({ ...f, precMode: e.target.value })}>
              <option value="">leave alone</option>
              {DWG_PRECISION_MODES.map(p => <option key={p}>{p}</option>)}
            </Select>
          </Field>
          <Field label="Decimal places">
            <Select value={f.decimals === undefined ? '' : f.decimals} onChange={e => setF({ ...f, decimals: e.target.value })}>
              <option value="">leave alone</option>
              {[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{d}</option>)}
            </Select>
          </Field>
          <Field label="Fraction">
            <Select value={f.denom === undefined ? '' : f.denom} onChange={e => setF({ ...f, denom: e.target.value })}>
              <option value="">leave alone</option>
              {DWG_FRACTION_DENOMS.map(d => <option key={d} value={d}>1/{d}</option>)}
            </Select>
          </Field>
          <Field label="Depth" hint="Clears rise/drop on anything it is set on.">
            <div className="flex gap-1">
              <TextInput type="number" step="any" value={f.depthValue === undefined ? '' : f.depthValue}
                onChange={e => setF({ ...f, depthValue: e.target.value })} placeholder="leave alone" />
              <Select className="!w-20" value={f.depthUnit || 'IN'} onChange={e => setF({ ...f, depthUnit: e.target.value })}>
                {DWG_DEPTH_UNITS.map(u => <option key={u}>{u}</option>)}
              </Select>
            </div>
          </Field>
          <Field label="Slope">
            <div className="flex gap-1">
              <Select className="!w-28" value={f.slopeMode || ''} onChange={e => setF({ ...f, slopeMode: e.target.value })}>
                <option value="">leave alone</option>
                <option value="(clear)">— flat —</option>
                {DWG_SLOPE_MODES.map(s => <option key={s}>{s}</option>)}
              </Select>
              <TextInput type="number" step="any" value={f.slopeValue === undefined ? '' : f.slopeValue}
                onChange={e => setF({ ...f, slopeValue: e.target.value })} />
            </div>
          </Field>
          <Field label="Subject"><TextInput value={f.subject || ''} onChange={e => setF({ ...f, subject: e.target.value })} placeholder="leave alone" /></Field>
          <Field label="Layer"><TextInput value={f.layer || ''} onChange={e => setF({ ...f, layer: e.target.value })} placeholder="leave alone" /></Field>
          <Field label="Status">
            <Select value={f.status || ''} onChange={e => setF({ ...f, status: e.target.value })}>
              <option value="">leave alone</option>
              <option value="(clear)">— none —</option>
              {DWG_MARKUP_STATUSES.filter(Boolean).map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
        </div>
      </div>
    </Modal>
  );
}

// ── Recalculate ───────────────────────────────────────────────────────────
// The recovery path. Changing a page's scale does NOT retro-update markups —
// deliberately, because silently rewriting two hundred numbers somebody has
// already quoted from is worse than leaving them wrong and saying so. This is
// where they are shown, before and after, and accepted.
function DwgRecalculateModal({ open, onClose, rows, sheet, pageIndex, cal, onApply, editable }) {
  const doable = (rows || []).filter(r => !r.locked);
  const locked = (rows || []).filter(r => r.locked);
  return (
    <Modal open={open} wide onClose={onClose} title="Recalculate this page against the current scale" footer={
      <>
        <Button variant="ghost" onClick={onClose}>Close</Button>
        <Button onClick={() => onApply(rows)} disabled={!editable || !doable.length}>
          Apply to {doable.length} markup{doable.length === 1 ? '' : 's'}
        </Button>
      </>
    }>
      <div className="space-y-3">
        <DwgNote>
          A markup keeps <b>geometry</b>, so its own figure already follows the scale in force — nothing here is
          stale on screen. What can be stale is the <b>takeoff record</b> it produced, which is a separate record
          with its own history, and the note somebody wrote down from the old number. This restamps each markup
          to the calibration now in force and recomputes the records behind them.
          <br />
          A markup belonging to a <b>locked bid version</b> is never touched — that is history, and correcting it
          in place would rewrite what was submitted.
        </DwgNote>
        {!cal && <div className="text-xs text-[var(--leon-red)]">This page has no calibration at all, so there is nothing to recalculate to.</div>}
        {!rows.length ? <EmptyState text="Everything on this page already agrees with the current scale." /> : (
          <div className="overflow-x-auto">
            <table className="w-full text-xs">
              <thead className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                <tr>
                  <th className="text-left py-1">Markup</th>
                  <th className="text-left">Why</th>
                  <th className="text-right">Record now</th>
                  <th className="text-right">Becomes</th>
                </tr>
              </thead>
              <tbody>
                {rows.map((r, i) => (
                  <tr key={r.m.id} className={`border-b border-[var(--leon-line)]/50 ${r.locked ? 'opacity-45' : ''}`}>
                    <td className="py-1">{dwgMeasureName(r.m, i)}</td>
                    <td className="text-[var(--leon-black)]/60">
                      {r.viewportMissing ? 'its viewport was deleted'
                        : r.scaleChanged ? `measured at ${r.wasCal ? fmtDim(r.wasCal, 'Imperial') : '—'} per page width, the page is now ${fmtDim(r.nowCal, 'Imperial')}`
                        : 'its takeoff record no longer matches the geometry'}
                      {r.locked ? ' — locked bid version, left alone' : ''}
                    </td>
                    <td className="text-right">{r.item ? dwgFmtQty(r.item.rawMeasure, r.item.unit) : '—'}</td>
                    <td className="text-right font-bold">{r.item && r.next !== null ? dwgFmtQty(r.next, r.item.unit) : '—'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
        {!!locked.length && (
          <div className="text-xs text-[var(--leon-black)]/55">
            {locked.length} row{locked.length === 1 ? '' : 's'} belong to a locked bid version and are shown greyed
            out. To correct those, branch the version rather than editing history.
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── Visual Search → count ─────────────────────────────────────────────────
// Coarse-to-fine template matching over the ink mask. Every step is ordinary
// image processing: pack both page and template to 32-bit words, score by
// Hamming distance with a popcount, nominate candidates at a quarter resolution,
// refine the survivors at full resolution, then suppress the near-duplicates.
//
// It is presented as a REVIEWABLE LIST and never as a finished count. Bluebeam's
// own documentation calls this slow and imperfect; estimators check it by hand,
// and so does this — nothing is written until the boxes are ticked.
const DWG_SEARCH_COARSE = 4;
const DWG_SEARCH_MAX_CANDIDATES = 8000;
const DWG_SEARCH_MAX_REFINE = 400;
const DWG_SEARCH_MAX_HITS = 250;

// A crop of the ink mask as a small PNG, so a result can be LOOKED at rather
// than trusted. Built on demand and never persisted — it is derived pixels.
function dwgMaskThumb(ink, w, h, x0, y0, cw, ch) {
  const c = document.createElement('canvas');
  c.width = Math.max(1, cw); c.height = Math.max(1, ch);
  const cx = c.getContext('2d');
  const img = cx.createImageData(c.width, c.height);
  const d = img.data;
  for (let y = 0; y < c.height; y++) {
    for (let x = 0; x < c.width; x++) {
      const sx = x0 + x, sy = y0 + y;
      const on = (sx >= 0 && sy >= 0 && sx < w && sy < h) ? ink[sy * w + sx] : 0;
      const p = (y * c.width + x) * 4;
      d[p] = d[p + 1] = d[p + 2] = on ? 26 : 255;
      d[p + 3] = 255;
    }
  }
  cx.putImageData(img, 0, 0);
  return c.toDataURL('image/png');
}
function dwgCountInk(ink) {
  let n = 0;
  for (let i = 0; i < ink.length; i++) n += ink[i];
  return n;
}

function DwgVisualSearchModal({ open, onClose, ctx, project, sheet, seed, pdf, cal, aspect, activeTool, fillCfg, onApply }) {
  const [sens, setSens] = useState(dwgNum(softwareSetting('takeoff', 'searchSensitivity')) || 84);
  const [useRot, setUseRot] = useState(false);
  const [stage, setStage] = useState('');
  const [pct, setPct] = useState(0);
  const [hits, setHits] = useState(null);
  const [chosen, setChosen] = useState({});
  const [err, setErr] = useState('');
  const [tmplUrl, setTmplUrl] = useState('');
  const [tmplSize, setTmplSize] = useState(null);
  const [running, setRunning] = useState(false);
  const runRef = useRef(null);
  const prepRef = useRef(null);      // { mask, tmpl } — memory only, never stored

  // The RUNNING flag has to be state, not the ref: a ref changing does not
  // re-render, so a Cancel button driven off the ref would never appear.
  function stop() {
    if (runRef.current) { cancelAnimationFrame(runRef.current.raf); runRef.current.cancelled = true; runRef.current = null; }
    setRunning(false);
  }
  useEffect(() => () => stop(), []);
  useEffect(() => {
    if (!open) { stop(); return undefined; }
    setHits(null); setChosen({}); setErr(''); setPct(0); setTmplUrl(''); setTmplSize(null);
    prepRef.current = null;
    if (!seed || !pdf || typeof officePdfPageSize !== 'function' || typeof officePdfRenderPage !== 'function') return undefined;
    let dead = false;
    setStage('Rendering the page and cutting the template out of it…');
    dwgRenderInkMask(pdf.assetId, seed.pageIndex, { dpi: fillCfg.dpi, threshold: fillCfg.threshold })
      .then(mask => {
        if (dead) return;
        const r = seed.rect;
        const x0 = Math.max(0, Math.round(r.x0 * mask.w)), y0 = Math.max(0, Math.round(r.y0 * mask.h));
        const tw = Math.max(3, Math.round((r.x1 - r.x0) * mask.w));
        const th = Math.max(3, Math.round((r.y1 - r.y0) * mask.h));
        const ink = dwgCropMask(mask.ink, mask.w, mask.h, x0, y0, tw, th);
        prepRef.current = { mask, tmpl: { ink, w: tw, h: th, x0, y0, count: dwgCountInk(ink) } };
        setTmplSize({ w: tw, h: th, ink: prepRef.current.tmpl.count });
        setTmplUrl(dwgMaskThumb(ink, tw, th, 0, 0, tw, th));
        setStage(prepRef.current.tmpl.count < 12
          ? 'That box is almost empty — there is barely any linework in it to match against. Draw it tightly round the symbol itself.'
          : '');
      })
      .catch(e => { if (!dead) { setErr(e && e.message ? e.message : String(e)); setStage(''); } });
    return () => { dead = true; };
  }, [open, seed && seed.pageIndex, seed && JSON.stringify(seed && seed.rect), fillCfg.dpi, fillCfg.threshold]);

  const thr = Math.max(0.5, Math.min(0.995, sens / 100));
  const coarseThr = Math.max(0.45, thr - 0.08);

  function run() {
    const prep = prepRef.current;
    if (!prep) return;
    stop();
    setHits(null); setChosen({}); setErr(''); setPct(0);
    const F = dwgNum(softwareSetting('takeoff', 'searchCoarseFactor')) || DWG_SEARCH_COARSE;
    const mask = prep.mask, T = prep.tmpl;
    if (T.w >= mask.w || T.h >= mask.h) { setErr('The template is as big as the page — there is nothing to search.'); return; }

    setStage('Packing the page…');
    const pageFull = dwgPackBits(mask.ink, mask.w, mask.h);
    const pageCo = dwgDownsampleMask(mask.ink, mask.w, mask.h, F);
    const pageCoP = dwgPackBits(pageCo.ink, pageCo.w, pageCo.h);

    const angles = useRot ? DWG_SEARCH_ROTATIONS : [0];
    const prepared = angles.map(a => {
      // The TEMPLATE is rotated, never the page: one small rotation per angle
      // instead of one enormous one.
      const rot = a ? dwgRotateMask(T.ink, T.w, T.h, a) : { ink: T.ink, w: T.w, h: T.h };
      const co = dwgDownsampleMask(rot.ink, rot.w, rot.h, F);
      return { a, full: rot, fullP: dwgPackBits(rot.ink, rot.w, rot.h), fullInk: dwgCountInk(rot.ink),
               co, coP: dwgPackBits(co.ink, co.w, co.h), coInk: dwgCountInk(co.ink) };
    }).filter(p => p.co.w > 0 && p.co.h > 0 && p.co.w < pageCo.w && p.co.h < pageCo.h);
    if (!prepared.length) { setErr('At this resolution the template collapses to nothing. Raise the fill DPI, or draw a larger box.'); return; }

    const totalRows = prepared.reduce((s, p) => s + Math.max(0, pageCo.h - p.co.h), 0);
    const run0 = { cancelled: false, raf: 0, ai: 0, y: 0, done: 0, cands: [] };
    runRef.current = run0;
    setRunning(true);

    const tick = () => {
      if (run0.cancelled) return;
      const t0 = Date.now();
      // Bounded work per animation frame rather than a Web Worker: the page has
      // to be rasterised on the main thread first anyway, and a worker in a
      // no-build app has to be assembled out of a Blob URL. Chunking keeps the
      // modal responsive and, unlike a worker, keeps Cancel instant.
      while (Date.now() - t0 < 26) {
        if (run0.ai >= prepared.length) { finish(); return; }
        const P = prepared[run0.ai];
        if (run0.y > pageCo.h - P.co.h) { run0.ai++; run0.y = 0; continue; }
        const need = Math.max(1, Math.round(P.coInk * 0.55));
        for (let ox = 0; ox + P.co.w <= pageCo.w; ox++) {
          const r = dwgMatchAt(pageCoP, P.coP, ox, run0.y);
          // Similarity ALONE is not enough: a blank patch of paper scores
          // beautifully against a sparse symbol, because almost every bit
          // agrees at zero. Requiring most of the template's INK to be present
          // is what stops a search returning three hundred empty corners.
          if (r.sim >= coarseThr && r.ink >= need) {
            run0.cands.push({ ox, oy: run0.y, ai: run0.ai, score: r.sim, w: P.co.w, h: P.co.h });
          }
        }
        run0.y++; run0.done++;
        if (run0.cands.length > DWG_SEARCH_MAX_CANDIDATES) { run0.ai = prepared.length; break; }
      }
      setPct(totalRows ? Math.min(99, Math.round(run0.done / totalRows * 100)) : 0);
      setStage(`Scanning… ${run0.cands.length.toLocaleString()} candidate${run0.cands.length === 1 ? '' : 's'} so far.`);
      run0.raf = requestAnimationFrame(tick);
    };

    function finish() {
      setStage('Refining and removing duplicates…');
      // Nominate: suppress hard in coarse space so the refinement pass is over
      // distinct places rather than over the same symbol nine times.
      const nominated = dwgSuppress(run0.cands.map(c => ({ ...c, x: c.ox, y: c.oy })), 0.3)
        .slice(0, DWG_SEARCH_MAX_REFINE);
      const refined = [];
      nominated.forEach(c => {
        const P = prepared[c.ai];
        const bx = c.ox * F, by = c.oy * F;
        let best = null;
        for (let oy = by - F; oy <= by + F; oy++) {
          for (let ox = bx - F; ox <= bx + F; ox++) {
            const r = dwgMatchAt(pageFull, P.fullP, ox, oy);
            if (!best || r.sim > best.sim) best = { sim: r.sim, ink: r.ink, ox, oy };
          }
        }
        if (!best) return;
        const need = Math.max(1, Math.round(P.fullInk * 0.55));
        if (best.sim < thr || best.ink < need) return;
        refined.push({ x: best.ox, y: best.oy, w: P.full.w, h: P.full.h, score: best.sim, angle: P.a });
      });
      const kept = dwgSuppress(refined, 0.5).slice(0, DWG_SEARCH_MAX_HITS);
      const withThumbs = kept.map((h, i) => ({
        ...h, id: `vs-${i}`,
        thumb: dwgMaskThumb(mask.ink, mask.w, mask.h, h.x, h.y, h.w, h.h),
        cx: (h.x + h.w / 2) / mask.w, cy: (h.y + h.h / 2) / mask.h,
      }));
      const pick = {};
      withThumbs.forEach(h => { pick[h.id] = true; });
      setChosen(pick);
      setHits({ list: withThumbs, scanned: run0.done, candidates: run0.cands.length,
                capped: run0.cands.length > DWG_SEARCH_MAX_CANDIDATES });
      setPct(100);
      setStage('');
      runRef.current = null;
      setRunning(false);
    }

    run0.raf = requestAnimationFrame(tick);
  }

  const list = hits ? hits.list : [];
  const picked = list.filter(h => chosen[h.id]);

  function apply() {
    if (!picked.length) return;
    onApply(picked.map(h => ({ x: h.cx, y: h.cy })),
      `Placed from Visual Search: ${picked.length} of ${list.length} candidate${list.length === 1 ? '' : 's'} accepted at ${Math.round(thr * 100)}% similarity${useRot ? ', rotations on' : ''}. Every pin was reviewed by ${ctx.currentUserName}.`,
      seed ? { pageIndex: seed.pageIndex, rect: seed.rect, sensitivity: sens, rotations: useRot } : null);
  }

  return (
    <Modal open={open} wide onClose={() => { stop(); onClose(); }} title="Visual Search → count" footer={
      <>
        <Button variant="ghost" onClick={() => { stop(); onClose(); }}>Close</Button>
        {running
          ? <Button variant="danger" onClick={() => { stop(); setStage('Stopped — the candidates found so far were discarded.'); }}>Cancel the search</Button>
          : <Button variant="outline" onClick={run} disabled={!prepRef.current}>{hits ? 'Search again' : 'Search this page'}</Button>}
        <Button onClick={apply} disabled={!picked.length}>Place {picked.length} count pin{picked.length === 1 ? '' : 's'}</Button>
      </>
    }>
      <div className="space-y-3">
        <DwgNote>
          This finds other places on <b>this page</b> whose linework looks like the box you drew. It is not
          reading the drawing and there is no model involved — the page and the symbol are both reduced to
          black-and-white bits and compared. It needs a <b>vector or a clean scan</b>, it is confused by symbols
          that overlap other linework, and <b>it is a list of candidates, never a finished count</b>: untick
          anything wrong before you place it.
        </DwgNote>
        {!!err && <div className="text-xs rounded border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-2 py-1.5">{err}</div>}
        <div className="flex items-start gap-4 flex-wrap">
          <div>
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">What you are looking for</div>
            {tmplUrl
              ? <img src={tmplUrl} alt="" className="border border-[var(--leon-line)] bg-white"
                  style={{ width: Math.min(140, (tmplSize ? tmplSize.w : 60)), imageRendering: 'pixelated' }} />
              : <div className="w-24 h-16 border border-dashed border-[var(--leon-line)]" />}
            {tmplSize && <div className="text-[10px] text-[var(--leon-black)]/45 mt-1">{tmplSize.w}×{tmplSize.h} px · {tmplSize.ink} ink pixels</div>}
          </div>
          <div className="flex-1 min-w-[16rem] space-y-2">
            <label className="flex items-center gap-2 text-xs">
              Sensitivity
              <input type="range" min="50" max="99" step="1" value={sens} onChange={e => setSens(Number(e.target.value))} className="flex-1" />
              <b className="w-10 text-right">{sens}%</b>
            </label>
            <div className="text-[11px] text-[var(--leon-black)]/50">
              The sensitivity <b>is</b> the threshold: how many of the compared bits must agree. Lower it and you
              get more candidates and more wrong ones; raise it and a symbol drawn slightly differently is missed.
            </div>
            <label className="flex items-center gap-2 text-xs">
              <input type="checkbox" checked={useRot} onChange={e => setUseRot(e.target.checked)} />
              Also look for it rotated ({DWG_SEARCH_ROTATIONS.length} angles — roughly {DWG_SEARCH_ROTATIONS.length}× slower)
            </label>
          </div>
        </div>
        {(!!stage || pct > 0) && (
          <div className="space-y-1">
            {!!stage && <div className="text-xs">{stage}</div>}
            {pct > 0 && pct < 100 && (
              <div className="h-1.5 rounded bg-[var(--leon-line)] overflow-hidden">
                <div className="h-full bg-[var(--leon-brown)]" style={{ width: `${pct}%` }} />
              </div>
            )}
          </div>
        )}
        {hits && (
          <div className="space-y-2">
            <div className="flex items-center gap-2 flex-wrap text-xs">
              <b>{list.length}</b> candidate{list.length === 1 ? '' : 's'} kept
              <span className="text-[var(--leon-black)]/45">from {hits.candidates.toLocaleString()} coarse matches</span>
              <Button size="sm" variant="ghost" onClick={() => { const p = {}; list.forEach(h => { p[h.id] = true; }); setChosen(p); }}>Tick all</Button>
              <Button size="sm" variant="ghost" onClick={() => setChosen({})}>Untick all</Button>
              {hits.capped && <Badge tone="yellow">the candidate cap was reached — raise the sensitivity</Badge>}
            </div>
            {!list.length ? <EmptyState text="Nothing on this page matched closely enough. Lower the sensitivity, or draw the box more tightly round the symbol." /> : (
              <div className="flex flex-wrap gap-1.5 max-h-[38vh] overflow-y-auto p-1">
                {list.map(h => (
                  <button key={h.id} onClick={() => setChosen({ ...chosen, [h.id]: !chosen[h.id] })}
                    className={`relative border rounded p-0.5 bg-white ${chosen[h.id] ? 'border-[var(--leon-brown)] ring-1 ring-[var(--leon-brown)]' : 'border-[var(--leon-line)] opacity-55'}`}
                    title={`${Math.round(h.score * 100)}% match${h.angle ? ` · rotated ${h.angle}°` : ''}`}>
                    <img src={h.thumb} alt="" style={{ width: 54, height: 54 * (h.h / Math.max(1, h.w)), imageRendering: 'pixelated' }} />
                    <span className="absolute bottom-0 right-0 text-[9px] px-0.5 bg-white/85 font-bold">{Math.round(h.score * 100)}</span>
                  </button>
                ))}
              </div>
            )}
            {activeTool
              ? <div className="text-[11px] text-[var(--leon-black)]/55">
                  The pins are placed with <b>{activeTool.name}</b> in your hand, so they arrive stamped with its
                  subject, layer, colour and column defaults — the same as a pin dropped by hand.
                </div>
              : <div className="text-[11px] text-[var(--leon-black)]/55">
                  No take-off tool is selected, so the pins land plain. Pick one on the toolbar first if you want
                  them stamped with a subject and a unit.
                </div>}
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── Calibration, drawn ────────────────────────────────────────────────────
// The printed scale is offered as a starting assumption and nothing more. A set
// plotted to fit the paper is the normal case, not the exception, and it is
// exactly how a take-off goes wrong quietly — so the number that counts is the
// one measured against a dimension a person can read off the drawing.
function DwgCalibrateOnSheetModal({ draft, onClose, ctx, project, sheet, pageIndex, pageWidthMm, pageAspect, existing, isSheetPage, onDone }) {
  const [known, setKnown] = useState('');
  const [confidence, setConfidence] = useState('High');
  const [note, setNote] = useState('');
  useEffect(() => { if (draft) { setKnown(''); setConfidence('High'); setNote(''); } }, [draft]);
  if (!draft) return null;

  const raw = dwgNum(draft.raw);
  const printedRatio = dwgScaleRatioFromLabel(sheet.scale);
  const printedMm = printedRatio && pageWidthMm ? raw * printedRatio * pageWidthMm : 0;
  const knownMm = parseDim(known, 'Imperial');
  const mmPerPageWidth = knownMm && raw > 0 ? knownMm / raw : 0;
  const ratio = mmPerPageWidth && pageWidthMm ? mmPerPageWidth / pageWidthMm : 0;
  const scaleLabel = dwgScaleLabelFromRatio(ratio);
  const errPct = printedMm && knownMm ? ((knownMm - printedMm) / printedMm) * 100 : null;
  const agrees = errPct === null ? null : Math.abs(errPct) < 1;

  function save() {
    if (!mmPerPageWidth) return;
    const rec = dwgMakeCalibration({
      pageIndex, mmPerPageWidth, knownText: known.trim(), knownMm, rawFraction: raw,
      pageAspect,
      points: draft.points, scaleLabel, printedScale: sheet.scale || '',
      printedAgrees: agrees, printedErrorPct: errPct, confidence, note: note.trim(),
    }, ctx.currentUserName);
    dwgUpdate(ctx, project.id, d => {
      const s = d.drawingSheets.find(x => x.id === sheet.id);
      if (!s) return;
      if (!s.pageCalibrations || typeof s.pageCalibrations !== 'object') s.pageCalibrations = {};
      s.pageCalibrations[String(pageIndex)] = rec;
      // The sheet's own scale record is what every total downstream reads, so a
      // calibration has to land there too — as 'Calibrated', which is a
      // different claim from 'Manual' (a scale a person read off a title block)
      // and from 'Detected' (a scale a pipeline reported on its own).
      if (isSheetPage) {
        s.scale = scaleLabel || s.scale;
        s.scaleSource = 'Calibrated';
        s.scaleConfidence = confidence;
        s.calibratedBy = ctx.currentUserName;
        s.calibratedDate = todayISO();
        s.scaleNote = [`Measured on the sheet against ${known.trim()}.`,
          agrees === null ? (sheet.scale ? `The scale on record, ${sheet.scale}, cannot be compared arithmetically.` : '')
            : agrees ? `Agrees with the printed scale ${sheet.scale}.`
            : `The printed scale says ${sheet.scale}; the sheet actually plots ${errPct > 0 ? 'smaller' : 'larger'} by ${Math.abs(errPct).toFixed(1)}%.`,
          note.trim()].filter(Boolean).join(' ');
      }
    }, `Take-off: sheet ${sheet.number} page ${pageIndex + 1} calibrated against ${known.trim()} — ${scaleLabel}${agrees === false ? ` (printed scale ${sheet.scale} is off by ${Math.abs(errPct).toFixed(1)}%)` : ''}.`);
    if (onDone) onDone();
  }

  return (
    <Modal open={!!draft} onClose={onClose} wide title={`Calibrate ${sheet.number} — page ${pageIndex + 1}`} footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={save} disabled={!mmPerPageWidth}>Record this calibration</Button>
    </>}>
      <div className="space-y-3">
        <p className="text-sm text-[var(--leon-black)]/65">
          You drew a line across <b>{(raw * 100).toFixed(2)}%</b> of the page width. Type what that line
          really measures on the building and everything on this page is measured against it.
        </p>

        {sheet.scale ? (
          <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/60 p-3 text-sm">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-0.5">The scale on record</div>
            {printedRatio ? (
              <>At <b>{sheet.scale}</b> ({sheet.scaleSource}) that line would be <b>{fmtDim(printedMm, 'Imperial')}</b>.
                {' '}Confirm it against something you can read on the drawing — a PDF printed to fit the paper is
                the normal case, and the printed scale is then simply wrong.</>
            ) : (
              <>The sheet records <b>{sheet.scale}</b>, which is not a scale that can be worked with
                arithmetically. Measure a known dimension instead.</>
            )}
          </div>
        ) : (
          <DwgNote>This sheet has no scale on record at all, so there is nothing to compare against — the line you just drew is the only scale it will have.</DwgNote>
        )}

        <Field label="That line really measures" hint={'Accepts 12\'-6", 36 1/2", 3000mm, 914 — whatever the drawing calls it.'}>
          <TextInput value={known} onChange={e => setKnown(e.target.value)} placeholder={'12\'-6"'} autoFocus />
        </Field>

        {!!known.trim() && !knownMm && <p className="text-xs text-[var(--leon-red)]">That is not a dimension this app can read.</p>}

        {!!mmPerPageWidth && (
          <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm space-y-1">
            <div>This page works out to <b>{scaleLabel}</b> — one page width is {fmtDim(mmPerPageWidth, 'Imperial')}.</div>
            {errPct !== null && (
              agrees
                ? <div className="text-[#3a7d44] font-semibold">Agrees with the printed scale {sheet.scale} (within {Math.abs(errPct).toFixed(2)}%).</div>
                : <div className="text-[var(--leon-red)] font-semibold">
                    The printed scale {sheet.scale} is off by {Math.abs(errPct).toFixed(1)}% — this sheet plots
                    {errPct > 0 ? ' smaller' : ' larger'} than it says. The calibration wins.
                  </div>
            )}
            {!isSheetPage && <div className="text-xs text-[var(--leon-black)]/55">
              This is not the page filed as {sheet.number}, so the calibration is recorded for page {pageIndex + 1}
              {' '}only and the sheet&rsquo;s own scale record is left alone.
            </div>}
          </div>
        )}

        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Confidence" hint="Low is a legitimate answer — a line taken off a fuzzy scan is worth saying so.">
            <Select value={confidence} onChange={e => setConfidence(e.target.value)}>
              {DWG_SCALE_CONFIDENCE.filter(c => c !== 'Unverified').map(c => <option key={c}>{c}</option>)}
            </Select>
          </Field>
          <Field label="Note"><TextInput value={note} onChange={e => setNote(e.target.value)} placeholder="Measured across the corridor gridline dimension" /></Field>
        </div>

        {existing && (
          <div className="rounded-lg border border-[var(--leon-line)] p-3 text-xs text-[var(--leon-black)]/60">
            This replaces the calibration set by <b>{existing.by}</b> on {fmtDate(existing.date)} ({existing.scaleLabel} against {existing.knownText}).
            Every measurement on this page is recomputed from the new one straight away, because a measurement
            stores its geometry and not its number. The takeoff <b>records</b> already produced are separate
            records and are only changed if you ask for it, on the list below.
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── Measurements on this page ─────────────────────────────────────────────
function DwgMeasureList({ ctx, project, sheet, measures, cal, aspect, editable, selectedId, setSelectedId,
                         onRemove, onEdit, onRecord, onOpenRecord, onReapply, items,
                         onProps, onPromote, onSplitAll, onResume }) {
  const parents = measures.filter(m => m.kind !== 'deduct');
  const orphans = measures.filter(m => m.kind === 'deduct' && !parents.find(p => p.id === m.parentId));
  const linked = measures.filter(m => m.itemId).length;

  return (
    <Collapsible id={`dwg-measures-${sheet.id}`} title="Measurements on this sheet" count={measures.length} defaultOpen
      right={linked && editable ? <Button size="sm" variant="ghost" onClick={onReapply}>Recompute {linked} linked record{linked === 1 ? '' : 's'}</Button> : null}>
      {!measures.length ? <EmptyState text="Nothing measured on this page yet." /> : (
        <div className="space-y-1.5">
          {parents.concat(orphans).map((m, i) => {
            const v = dwgMeasureValue(m, cal, aspect, measures, sheet);
            const stale = dwgMeasureStale(sheet, m);
            const item = m.itemId ? items.find(x => x.id === m.itemId) : null;
            const on = selectedId === m.id;
            return (
              <div key={m.id}
                onClick={() => setSelectedId(on ? null : m.id)}
                className={`rounded-lg border px-3 py-2 cursor-pointer ${on ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]/60' : stale ? 'border-[var(--leon-red)]/40 bg-[#fbe7e7]/25' : 'border-[var(--leon-line)] bg-white'}`}>
                <div className="flex items-start justify-between gap-2 flex-wrap">
                  <div className="min-w-0">
                    <div className="text-sm font-semibold flex items-center gap-1.5 flex-wrap">
                      <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: DWG_DRAW_COLORS[m.kind] || '#999' }} />
                      {dwgMeasureName(m, i)}
                      <span className="font-normal text-[var(--leon-black)]/60">
                        {m.kind === 'count' ? `${v.count} EA`
                          : m.kind === 'angle' ? `${v.angleDeg.toFixed(1)}°`
                          : dwgIsShapeKind(m.kind) ? (v.collinear ? 'three points in a straight line — no radius'
                            : `R ${dwgFmtMm(v.radiusMm)} · ⌀ ${dwgFmtMm(v.diameterMm)}`)
                          : m.kind === 'distance' ? dwgFmtMm(v.mm)
                          : m.kind === 'deduct' ? `−${dwgFmtArea(v.grossMm2)}`
                          : `${dwgFmtArea(v.netMm2)} net · perimeter ${dwgFmtMm(v.perimeterMm)}`}
                      </span>
                      {!!v.depthMm && <Badge tone="blue">depth {m.depthValue} {m.depthUnit}</Badge>}
                      {!!dwgMeasureRiseMm(m) && <Badge tone="blue">rise/drop {m.riseValue} {m.riseUnit}</Badge>}
                      {!!m.slopeMode && m.slopeValue !== null && <Badge tone="blue">slope {m.slopeValue} {m.slopeMode}</Badge>}
                      {m.subject ? <Badge tone="neutral">{m.subject}</Badge> : null}
                      {v.scale.source === 'viewport' && v.scale.viewport
                        ? <Badge tone="neutral">🔍 {v.scale.viewport.name}</Badge> : null}
                      {v.scale.viewportMissing
                        ? <Badge tone="red">its viewport was deleted — now on the page scale</Badge> : null}
                      {!!v.deductions.length && <Badge tone="yellow">{v.deductions.length} deduction{v.deductions.length === 1 ? '' : 's'} · −{dwgFmtQty(dwgAreaIn(v.deductedMm2, 'SF'), 'SF')}</Badge>}
                      {stale && <Badge tone="red">Revision Review Required</Badge>}
                    </div>
                    <div className="text-[11px] text-[var(--leon-black)]/50">
                      drawn on Rev {m.sheetRevision || '—'}{stale ? ` · the sheet is now Rev ${sheet.revision || '—'}${sheet.status === 'Superseded' ? ' and superseded' : ''}` : ''}
                      {' · '}{m.createdBy} {fmtDate(m.createdDate)}
                      {m.kind === 'deduct' && !parents.find(p => p.id === m.parentId) ? ' · the area it was taken out of has been removed' : ''}
                    </div>
                    {item && (
                      <div className="text-[11px] mt-0.5">
                        <button onClick={e => { e.stopPropagation(); if (onOpenRecord) onOpenRecord(item.id); }}
                          className="text-[var(--leon-brown)] font-semibold hover:underline">
                          → takeoff record: {item.toolName} {dwgFmtQty(item.rawMeasure, item.unit)}
                        </button>
                        <span className="text-[var(--leon-black)]/45"> · {item.status} · {item.included}</span>
                      </div>
                    )}
                  </div>
                  <div className="flex items-center gap-1 shrink-0 flex-wrap" onClick={e => e.stopPropagation()}>
                    {editable && (
                      <input value={m.label} onChange={e => onEdit(m.id, { label: e.target.value })}
                        placeholder="name it…" className="w-32 px-1.5 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white" />
                    )}
                    {editable && (
                      <select value={m.scopeId || ''} onChange={e => {
                        const next = e.target.value || null;
                        onEdit(m.id, { scopeId: next }, `Take-off: a measurement on ${sheet.number} re-scoped to ${(dwgScopeById(project, next) || {}).name || 'no scope'}.`);
                        if (item && next) {
                          dwgUpdate(ctx, project.id, d => {
                            const t = d.takeoffItems.find(x => x.id === item.id);
                            if (t && !dwgVersionLocked(project, t.bidVersionId)) { t.scopeId = next; t.updatedBy = ctx.currentUserName; t.updatedDate = todayISO(); }
                          });
                        }
                      }} className="px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white max-w-[10rem]">
                        <option value="">— no scope —</option>
                        {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
                      </select>
                    )}
                    {editable && m.kind === 'count' && (m.points || []).length > 1 && onSplitAll && (
                      <Button size="sm" variant="ghost" title="Turn every pin in this tally into its own row"
                        onClick={() => onSplitAll(m)}>Split all</Button>
                    )}
                    {editable && m.kind === 'count' && onResume && (
                      <Button size="sm" variant="ghost" title="Carry on dropping pins into THIS tally"
                        onClick={() => onResume(m)}>Resume</Button>
                    )}
                    {editable && m.kind === 'deduct' && onPromote && (
                      <Button size="sm" variant="ghost"
                        title="Measure this opening on its own as well — cut the window out of the wall, then make the window its own quantity"
                        onClick={() => {
                          const keep = confirm('Keep it deducted from the area it sits in as well?\n\nOK — the opening stays cut out AND becomes its own measurement.\nCancel — it stops being a cutout and the area it was in grows back.');
                          onPromote(m, keep);
                        }}>Make it its own</Button>
                    )}
                    {editable && onProps && (
                      <Button size="sm" variant="outline" onClick={() => onProps(m)}>Properties…</Button>
                    )}
                    {editable && m.kind !== 'deduct' && m.kind !== 'angle' && !item && (
                      <Button size="sm" onClick={() => onRecord(m)} disabled={!v.calibrated}>+ Takeoff record</Button>
                    )}
                    {editable && <IconBtn title="Remove this measurement" onClick={() => onRemove(m)}>✕</IconBtn>}
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      )}
      <div className="mt-2">
        <DwgNote>
          A measurement keeps its <b>geometry</b>, in page percentages, and its figure is recomputed from the
          calibration every time it is shown — so it stays pinned to the drawing at any zoom and a
          re-calibration corrects the whole page at once. The takeoff <b>records</b> it produced are separate
          records with their own history; recomputing them is a deliberate act, and a locked bid version is
          never touched.
        </DwgNote>
      </div>
    </Collapsible>
  );
}

// ═══════════════════════════════════════════════ The Markups List
// The structural claim this whole module rests on: a take-off is not a separate
// structure, it IS the markup set viewed through a list. Estimators live here;
// the drawing is only where quantities are created. Everything below is a
// PROJECTION of the markups — nothing on this screen is stored, so nothing on
// it can go stale, and re-scaling a page is a re-render rather than a migration.

// The columns, in the order Revu presents them, because that order is what an
// estimator's eye already knows. `total` marks a column that can be subtotalled;
// `group` marks one that can be grouped on.
const DWG_MARKUP_COLUMNS = [
  { key: 'subject', label: 'Subject', group: true },
  { key: 'sheet', label: 'Sheet', group: true },
  { key: 'page', label: 'Page', num: true, group: true },
  { key: 'space', label: 'Space', group: true },
  { key: 'layer', label: 'Layer', group: true },
  { key: 'status', label: 'Status', group: true },
  { key: 'author', label: 'Author', group: true },
  { key: 'date', label: 'Date' },
  { key: 'color', label: 'Colour' },
  { key: 'length', label: 'Length', num: true, total: true },
  { key: 'area', label: 'Area', num: true, total: true },
  { key: 'volume', label: 'Volume', num: true, total: true },
  { key: 'wallArea', label: 'Wall Area', num: true, total: true },
  { key: 'count', label: 'Count', num: true, total: true },
  // The one column with NO unit on it. That is not an oversight — a raw number
  // is exactly what makes it usable on the left of a formula, and it is why
  // `Cost = Measurement * Material` reads the way it does.
  { key: 'measurement', label: 'Measurement', num: true, total: true, noUnit: true },
  { key: 'depth', label: 'Depth' },
  { key: 'rise', label: 'Rise/Drop' },
  { key: 'slope', label: 'Slope' },
  { key: 'label', label: 'Label' },
  { key: 'comments', label: 'Comments' },
];
const DWG_MARKUP_COLUMN_BY_KEY = {};
DWG_MARKUP_COLUMNS.forEach(c => { DWG_MARKUP_COLUMN_BY_KEY[c.key] = c; });
const DWG_DEFAULT_VISIBLE = ['subject', 'sheet', 'page', 'space', 'layer', 'measurement', 'length', 'area', 'count', 'label'];

function dwgAspectKey(sheetId, pageIndex) { return `${sheetId}|${dwgNum(pageIndex)}`; }

// One row per markup, across every sheet on the job. Cutouts are deliberately
// NOT rows: a cutout is a property of the area it was taken out of, and listing
// it separately would put a negative area into a subtotal that has already had
// it deducted.
function dwgBuildMarkupRows(project, aspects, plan) {
  const rows = [];
  dwgSheetList(project).forEach(sheet => {
    const all = dwgSheetMeasures(sheet);
    const byPage = {};
    all.forEach(m => { const k = String(dwgNum(m.pageIndex)); (byPage[k] = byPage[k] || []).push(m); });
    Object.keys(byPage).forEach(k => {
      const pageIndex = Number(k);
      const cal = dwgCalibrationFor(sheet, pageIndex);
      // The page's height-over-width. Geometry is stored as fractions of each,
      // so without it a diagonal on a non-square page is wrong. It comes from
      // the calibration where one was made after this field existed, and is
      // fetched from the PDF otherwise — never guessed.
      const aspect = dwgNum((aspects || {})[dwgAspectKey(sheet.id, pageIndex)])
        || dwgNum(cal && cal.pageAspect) || 0;
      byPage[k].forEach(m => {
        if (m.kind === 'deduct') return;
        const v = dwgMeasureValue(m, cal, aspect || 1, byPage[k], sheet);
        const fig = dwgMarkupFigures(m, v);
        const space = m.spaceId ? dwgSpaceById(sheet, m.spaceId) : null;
        const resolved = plan ? dwgResolveRowColumns(plan, {
          Area: fig.area, Count: fig.count, Length: fig.length,
          Measurement: fig.measurement, Volume: fig.volume,
        }, m.custom || {}) : { display: {}, vars: {} };
        rows.push({ m, sheet, pageIndex, cal, aspect, v, fig, resolved,
                    spacePath: space ? dwgSpacePath(sheet, space) : '',
                    stale: dwgMeasureStale(sheet, m),
                    aspectKnown: aspect > 0 });
      });
    });
  });
  return rows;
}

// The value AND the way it reads. Sorting, filtering and subtotalling all work
// on `v`; the screen and the CSV show `s`. Keeping them together is what stops a
// column sorting by one thing and displaying another.
function dwgMarkupCell(row, key) {
  const m = row.m, fig = row.fig, v = row.v;
  const prec = dwgPrecisionFor(m);
  switch (key) {
    case 'subject': return { v: m.subject || '', s: m.subject || '' };
    case 'sheet': return { v: row.sheet.number || '', s: row.sheet.number || '' };
    case 'page': return { v: row.pageIndex + 1, s: String(row.pageIndex + 1) };
    case 'space': return { v: row.spacePath, s: row.spacePath };
    case 'layer': return { v: m.layer || '', s: m.layer || '' };
    case 'status': return { v: m.status || '', s: m.status || '' };
    case 'author': return { v: m.createdBy || '', s: m.createdBy || '' };
    case 'date': return { v: m.createdDate || '', s: m.createdDate ? fmtDate(m.createdDate) : '' };
    case 'color': return { v: m.color || DWG_DRAW_COLORS[m.kind] || '', s: m.color || DWG_DRAW_COLORS[m.kind] || '' };
    case 'length': return { v: fig.length, s: fig.length ? dwgFmtValue(fig.length, fig.lengthUnit, prec) : '', unit: fig.lengthUnit };
    case 'area': return { v: fig.area, s: fig.area ? dwgFmtValue(fig.area, fig.areaUnit, prec) : '', unit: fig.areaUnit };
    case 'volume': return { v: fig.volume, s: fig.volume ? dwgFmtValue(fig.volume, fig.volumeUnit, prec) : '', unit: fig.volumeUnit };
    case 'wallArea': return { v: fig.wallArea, s: fig.wallArea ? dwgFmtValue(fig.wallArea, fig.areaUnit, prec) : '', unit: fig.areaUnit };
    case 'count': return { v: fig.count, s: fig.count ? String(fig.count) : '', unit: 'EA' };
    case 'measurement': return { v: fig.measurement, s: fig.measurement ? dwgFmtValue(fig.measurement, '', prec) : '', unit: '' };
    case 'depth': return { v: v.depthMm, s: v.depthMm ? `${m.depthValue} ${m.depthUnit}` : '' };
    case 'rise': return { v: dwgMeasureRiseMm(m), s: dwgMeasureRiseMm(m) ? `${m.riseValue} ${m.riseUnit}` : '' };
    case 'slope': return { v: dwgSlopeRatio(m), s: m.slopeMode && m.slopeValue !== null ? `${m.slopeValue} ${m.slopeMode}` : '' };
    case 'label': return { v: dwgMeasureName(m, 0), s: dwgMeasureName(m, 0) };
    case 'comments': return { v: m.comments || '', s: m.comments || '' };
    default: return { v: '', s: '' };
  }
}
function dwgCustomCell(row, col) {
  const raw = row.resolved.display[col.id];
  return { v: col.type === 'Number' || col.type === 'Formula' ? dwgNum(raw) : (raw === undefined ? '' : raw),
           s: dwgFmtColumnValue(col, raw) };
}

// A filter is written the way people already write one in a spreadsheet: plain
// text is a substring, and a number column also understands >, <, >=, <= and =.
// No expression language — this is a filter box, not a second formula engine.
function dwgFilterPass(cell, expr, numeric) {
  const q = String(expr || '').trim();
  if (!q) return true;
  if (numeric) {
    const m = q.match(/^(>=|<=|>|<|=)\s*(-?[\d.]+)$/);
    if (m) {
      const n = Number(m[2]);
      const val = dwgNum(cell.v);
      if (m[1] === '>') return val > n;
      if (m[1] === '<') return val < n;
      if (m[1] === '>=') return val >= n;
      if (m[1] === '<=') return val <= n;
      return Math.abs(val - n) < 1e-9;
    }
  }
  return String(cell.s).toLowerCase().indexOf(q.toLowerCase()) >= 0;
}

// Totals are kept PER UNIT. Adding square feet to square metres because they
// both live in a column called Area is the single most confident way to produce
// a wrong number, so a column that mixes units subtotals as two figures.
function dwgSumByUnit(rows, getter) {
  const out = {};
  rows.forEach(r => {
    const c = getter(r);
    if (!c || !dwgNum(c.v)) return;
    const u = c.unit === undefined ? '' : c.unit;
    out[u] = (out[u] || 0) + dwgNum(c.v);
  });
  return out;
}
function dwgFmtTotals(map, col) {
  const keys = Object.keys(map);
  if (!keys.length) return '';
  return keys.map(u => `${dwgFmtQty(map[u], u)}`).join(' · ');
}

function dwgDownloadCsvRows(filename, cols, rows) {
  const head = cols.map(c => csvEscape(c.label)).join(',');
  const body = rows.map(r => cols.map(c => csvEscape(r[c.key])).join(',')).join('\n');
  const blob = new Blob([head + '\n' + body], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
}

function DwgMarkupsSection({ ctx, project, editable, onOpenSheet }) {
  const [view, setView] = useState('list');
  // Page aspect ratios, fetched from the PDFs for pages calibrated before the
  // aspect was recorded. MEMORY ONLY — it is derived from the file and belongs
  // nowhere near persisted state.
  const [aspects, setAspects] = useState({});
  useEffect(() => {
    if (typeof officePdfPageSize !== 'function') return undefined;
    let dead = false;
    const want = [];
    dwgSheetList(project).forEach(sheet => {
      const pdf = dwgSheetPdf(sheet);
      if (!pdf || !pdf.assetId) return;
      const pages = {};
      dwgSheetMeasures(sheet).forEach(m => { pages[dwgNum(m.pageIndex)] = true; });
      Object.keys(pages).forEach(k => {
        const pageIndex = Number(k);
        const key = dwgAspectKey(sheet.id, pageIndex);
        const cal = dwgCalibrationFor(sheet, pageIndex);
        if (dwgNum(cal && cal.pageAspect) > 0) return;
        want.push({ key, assetId: pdf.assetId, pageIndex });
      });
    });
    if (!want.length) return undefined;
    let chain = Promise.resolve();
    want.forEach(w => {
      chain = chain.then(() => officePdfPageSize(w.assetId, w.pageIndex)
        .then(sz => { if (!dead && sz && sz.width) setAspects(a => ({ ...a, [w.key]: sz.height / sz.width })); })
        .catch(() => {}));
    });
    return () => { dead = true; };
  }, [project.id, dwgSheetList(project).length]);

  const cols = dwgColumns(project);
  const plan = dwgBuildColumnPlan(cols);
  const rows = dwgBuildMarkupRows(project, aspects, plan);

  return (
    <div className="space-y-4">
      <div className="flex gap-1.5 flex-wrap">
        <DwgChip active={view === 'list'} onClick={() => setView('list')} count={rows.length}>🗂️ Markups</DwgChip>
        <DwgChip active={view === 'columns'} onClick={() => setView('columns')} count={cols.length}>🧮 Custom columns</DwgChip>
        <DwgChip active={view === 'legend'} onClick={() => setView('legend')}>🔖 Legends</DwgChip>
      </div>
      {view === 'list' && <DwgMarkupsListView ctx={ctx} project={project} editable={editable}
        rows={rows} cols={cols} plan={plan} onOpenSheet={onOpenSheet} />}
      {view === 'columns' && <DwgColumnsView ctx={ctx} project={project} editable={editable} plan={plan} rows={rows} />}
      {view === 'legend' && <DwgLegendView project={project} rows={rows} cols={cols} />}
    </div>
  );
}

// ── The list itself ───────────────────────────────────────────────────────
function DwgMarkupsListView({ ctx, project, editable, rows, cols, plan, onOpenSheet }) {
  const [search, setSearch] = useState('');
  const [filters, setFilters] = useState({});
  const [showFilters, setShowFilters] = useState(false);
  const [sortKey, setSortKey] = useState('sheet');
  const [sortDir, setSortDir] = useState('asc');
  const [groupBy, setGroupBy] = useState('subject');
  const [visible, setVisible] = useState(() => {
    const o = {};
    DWG_MARKUP_COLUMNS.forEach(c => { o[c.key] = DWG_DEFAULT_VISIBLE.indexOf(c.key) >= 0; });
    return o;
  });
  const [visibleCustom, setVisibleCustom] = useState({});
  const [collapsed, setCollapsed] = useState({});
  const [style, setStyle] = useState('Table');
  const [setName, setSetName] = useState('');

  const customVisible = cols.filter(c => visibleCustom[c.id] !== false);
  const shownBuiltins = DWG_MARKUP_COLUMNS.filter(c => visible[c.key]);
  const allShown = shownBuiltins.map(c => ({ ...c, kind: 'builtin' }))
    .concat(customVisible.map(c => ({ key: c.id, label: c.name, kind: 'custom', col: c,
                                      num: c.type === 'Number' || c.type === 'Formula',
                                      total: !!c.includeInTotals })));

  function cellOf(row, c) { return c.kind === 'custom' ? dwgCustomCell(row, c.col) : dwgMarkupCell(row, c.key); }
  // A built-in quantity totals PER UNIT, because SF and SM must never be added
  // together. A custom Number or Formula column has one format instead, so it
  // totals in that format — a Cost column adding up to a bare number would read
  // as a quantity.
  function totalOf(rowsIn, c) {
    if (c.kind === 'custom') {
      return dwgFmtColumnValue(c.col, rowsIn.reduce((a, r) => a + dwgNum(dwgCustomCell(r, c.col).v), 0));
    }
    return dwgFmtTotals(dwgSumByUnit(rowsIn, r => cellOf(r, c)), c);
  }

  const q = search.trim().toLowerCase();
  const filtered = rows.filter(r => {
    if (q) {
      const hay = [r.m.subject, r.m.layer, r.m.label, r.m.comments, r.m.status, r.m.createdBy,
                   r.sheet.number, r.sheet.title, r.spacePath].join(' ').toLowerCase();
      if (hay.indexOf(q) < 0) return false;
    }
    // A filter on a column that has since been hidden is deliberately still
    // applied: hiding a column is a change to what you are looking at, not to
    // what you are looking for.
    const active = Object.keys(filters).filter(k => filters[k]);
    return active.every(k => {
      const c = allShown.find(x => x.key === k)
        || (DWG_MARKUP_COLUMN_BY_KEY[k] ? { ...DWG_MARKUP_COLUMN_BY_KEY[k], kind: 'builtin' } : null)
        || (function () { const cc = cols.find(x => x.id === k); return cc ? { key: cc.id, kind: 'custom', col: cc, num: cc.type === 'Number' || cc.type === 'Formula' } : null; })();
      return c ? dwgFilterPass(cellOf(r, c), filters[k], !!c.num) : true;
    });
  });

  const sorted = filtered.slice().sort((a, b) => {
    const sc = DWG_MARKUP_COLUMN_BY_KEY[sortKey] || allShown.find(c => c.key === sortKey);
    const ca = sc && sc.kind === 'custom' ? dwgCustomCell(a, sc.col) : dwgMarkupCell(a, sortKey);
    const cb = sc && sc.kind === 'custom' ? dwgCustomCell(b, sc.col) : dwgMarkupCell(b, sortKey);
    let r;
    if (typeof ca.v === 'number' && typeof cb.v === 'number') r = ca.v - cb.v;
    else r = String(ca.s).localeCompare(String(cb.s), undefined, { numeric: true });
    if (r) return sortDir === 'desc' ? -r : r;
    // SECONDARY SORT BY CREATION DATE on the axes where ties are the norm.
    // Sorting on Subject or Page produces long runs of the same value, and the
    // order an estimator remembers within a run is the order they drew it.
    if (sortKey === 'subject' || sortKey === 'page' || sortKey === 'sheet') {
      return String(a.m.createdDate).localeCompare(String(b.m.createdDate))
        || String(a.m.id).localeCompare(String(b.m.id), undefined, { numeric: true });
    }
    return 0;
  });

  const groups = [];
  if (groupBy) {
    const map = {};
    sorted.forEach(r => {
      const c = groupBy.indexOf('col:') === 0
        ? dwgCustomCell(r, cols.find(x => x.id === groupBy.slice(4)) || {})
        : dwgMarkupCell(r, groupBy);
      const k = c.s || '(none)';
      if (!map[k]) { map[k] = { key: k, rows: [] }; groups.push(map[k]); }
      map[k].rows.push(r);
    });
  } else groups.push({ key: '', rows: sorted });

  const totalCols = allShown.filter(c => c.total);
  const noAspect = sorted.filter(r => !r.aspectKnown).length;

  function toggleSort(key) {
    if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
    else { setSortKey(key); setSortDir('asc'); }
  }
  function exportCsv() {
    // Exactly what is on screen: the visible columns, the filtered rows, in the
    // order they are sorted, with the group each one falls in.
    const outCols = (groupBy ? [{ key: '__group', label: 'Group' }] : []).concat(allShown.map(c => ({ key: c.key, label: c.label })));
    const outRows = [];
    groups.forEach(g => g.rows.forEach(r => {
      const o = { __group: g.key };
      allShown.forEach(c => { o[c.key] = cellOf(r, c).s; });
      outRows.push(o);
    }));
    dwgDownloadCsvRows(`${project.name || 'project'}-markups`, outCols, outRows);
  }
  function saveFilterSet() {
    if (!setName.trim()) return;
    dwgUpdate(ctx, project.id, draft => {
      if (!Array.isArray(draft.takeoffFilterSets)) draft.takeoffFilterSets = [];
      draft.takeoffFilterSets.push({
        id: uid('tofilt'), name: setName.trim(), search, filters: { ...filters },
        sortKey, sortDir, groupBy, visible: { ...visible }, visibleCustom: { ...visibleCustom },
        style, active: true, createdBy: ctx.currentUserName, createdDate: todayISO(),
      });
    }, `Take-off: saved the markups view "${setName.trim()}".`);
    setSetName('');
  }
  function applySet(fs) {
    setSearch(fs.search || ''); setFilters(fs.filters || {});
    setSortKey(fs.sortKey || 'sheet'); setSortDir(fs.sortDir || 'asc');
    setGroupBy(fs.groupBy === undefined ? 'subject' : fs.groupBy);
    if (fs.visible) setVisible(fs.visible);
    if (fs.visibleCustom) setVisibleCustom(fs.visibleCustom);
    if (fs.style) setStyle(fs.style);
  }
  function removeSet(fs) {
    dwgUpdate(ctx, project.id, draft => {
      (draft.takeoffFilterSets || []).forEach(x => { if (x.id === fs.id) x.active = false; });
    }, `Take-off: removed the markups view "${fs.name}".`);
  }

  const savedSets = dwgFilterSets(project);

  return (
    <div className="space-y-3">
      <DwgNote>
        Every markup on this job, whichever sheet it is on. Nothing here is stored — each figure is recomputed
        from the geometry and the scale in force, so a re-calibration corrects this list the moment it is made.
        <b> Cutouts are not listed separately</b>: a cutout belongs to the area it was taken out of and has
        already been deducted from it, so listing it would subtract it twice.
      </DwgNote>

      <div className="flex items-end gap-2 flex-wrap">
        <TextInput className="!w-64" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search every markup…" />
        <Field label="Group by">
          <Select className="!w-44" value={groupBy} onChange={e => { setGroupBy(e.target.value); setCollapsed({}); }}>
            <option value="">— no grouping —</option>
            {DWG_MARKUP_COLUMNS.filter(c => c.group).map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
            {cols.filter(c => c.type === 'Choice' || c.type === 'Text').map(c => <option key={c.id} value={`col:${c.id}`}>{c.name}</option>)}
          </Select>
        </Field>
        <Field label="Summary style" hint="How it prints.">
          <Select className="!w-28" value={style} onChange={e => setStyle(e.target.value)}>
            <option>Table</option>
            <option>Flow</option>
          </Select>
        </Field>
        <DwgChip active={showFilters} onClick={() => setShowFilters(!showFilters)}
          count={Object.keys(filters).filter(k => filters[k]).length}>Filters</DwgChip>
        <Button size="sm" variant="outline" onClick={exportCsv} disabled={!sorted.length}>Export CSV</Button>
      </div>

      <Collapsible id={`dwg-markup-cols-${project.id}`} title="Columns" count={allShown.length}>
        <div className="flex flex-wrap gap-2 text-xs">
          {DWG_MARKUP_COLUMNS.map(c => (
            <label key={c.key} className="flex items-center gap-1 border border-[var(--leon-line)] rounded px-2 py-1">
              <input type="checkbox" checked={!!visible[c.key]} onChange={e => setVisible({ ...visible, [c.key]: e.target.checked })} />
              {c.label}
            </label>
          ))}
          {cols.map(c => (
            <label key={c.id} className="flex items-center gap-1 border border-[var(--leon-brown)]/40 bg-[var(--leon-cream)]/50 rounded px-2 py-1">
              <input type="checkbox" checked={visibleCustom[c.id] !== false} onChange={e => setVisibleCustom({ ...visibleCustom, [c.id]: e.target.checked })} />
              {c.name}
            </label>
          ))}
        </div>
      </Collapsible>

      <Collapsible id={`dwg-markup-views-${project.id}`} title="Saved views" count={savedSets.length}>
        <DwgNote>
          A saved view is the whole screen: the search, every column filter, the sort, the grouping and which
          columns are showing. It is the "how I look at a bid" that otherwise gets rebuilt from scratch every
          morning.
        </DwgNote>
        <div className="space-y-1 mt-2">
          {savedSets.map(fs => (
            <div key={fs.id} className="flex items-center gap-2 text-xs border border-[var(--leon-line)] rounded px-2 py-1">
              <span className="font-semibold flex-1">{fs.name}</span>
              <span className="text-[var(--leon-black)]/45">{fs.createdBy} {fmtDate(fs.createdDate)}</span>
              <Button size="sm" variant="ghost" onClick={() => applySet(fs)}>Apply</Button>
              {editable && <IconBtn title="Remove this view" onClick={() => removeSet(fs)}>✕</IconBtn>}
            </div>
          ))}
          {!savedSets.length && <EmptyState text="No saved views yet." />}
        </div>
        {editable && (
          <div className="flex items-end gap-2 mt-2">
            <TextInput className="!w-56" value={setName} onChange={e => setSetName(e.target.value)} placeholder="Name this view…" />
            <Button size="sm" onClick={saveFilterSet} disabled={!setName.trim()}>Save the current view</Button>
          </div>
        )}
      </Collapsible>

      {!!noAspect && (
        <div className="text-xs rounded border border-[var(--leon-line)] bg-[var(--leon-cream)] px-3 py-2">
          {noAspect} markup{noAspect === 1 ? '' : 's'} are on a page whose proportions have not been read back yet
          — their figures are being computed against a square page and will correct themselves in a moment.
          If the PDF is not in this browser they will not correct, and the sheet says so.
        </div>
      )}

      <div data-print-region className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <div className="flex items-center justify-between gap-2 px-3 pt-3">
          <div className="lp-section-title text-sm font-bold">
            Markups — {project.name} ({sorted.length} of {rows.length})
          </div>
          <DocActions title="Markups List" heading="Markups List"
            lines={[project.name, `${sorted.length} markups`,
                    `Grouped by ${groupBy ? (DWG_MARKUP_COLUMN_BY_KEY[groupBy] || {}).label || 'a column' : 'nothing'}`,
                    `${style} style`]} />
        </div>
        {style === 'Flow' ? (
          // Flow: one paragraph per markup. It prints as a document rather than
          // as a spreadsheet, which is what gets attached to a bid letter.
          <div className="p-3 space-y-2">
            {groups.map(g => (
              <div key={g.key || 'all'}>
                {!!g.key && <div className="lp-section-title text-xs font-bold uppercase tracking-wide mt-3">{g.key} ({g.rows.length})</div>}
                {g.rows.map((r, i) => (
                  <div key={r.m.id} className="text-xs border-b border-[var(--leon-line)]/50 py-1">
                    <b>{dwgMeasureName(r.m, i)}</b> — {r.sheet.number} p{r.pageIndex + 1}
                    {r.spacePath ? ` · ${r.spacePath}` : ''}
                    {allShown.filter(c => c.key !== 'label' && c.key !== 'sheet' && c.key !== 'page' && c.key !== 'space')
                      .map(c => { const cell = cellOf(r, c); return cell.s ? ` · ${c.label} ${cell.s}` : ''; }).join('')}
                  </div>
                ))}
                {!!totalCols.length && (
                  <div className="text-xs font-bold pt-1">
                    Subtotal — {totalCols
                      .map(c => ({ c, t: totalOf(g.rows, c) }))
                      .filter(x => x.t)
                      .map(x => `${x.c.label} ${x.t}`)
                      .join(' · ') || 'nothing to total'}
                  </div>
                )}
              </div>
            ))}
          </div>
        ) : (
          <table className="w-full text-xs">
            <thead className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <tr>
                {allShown.map(c => (
                  <th key={c.key} className={`px-2 py-1.5 whitespace-nowrap cursor-pointer ${c.num ? 'text-right' : 'text-left'}`}
                    onClick={() => toggleSort(c.key)}>
                    {c.label}{sortKey === c.key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
                  </th>
                ))}
                <th />
              </tr>
              {showFilters && (
                <tr className="no-print">
                  {allShown.map(c => (
                    <th key={c.key} className="px-1 pb-1.5">
                      <input value={filters[c.key] || ''} onChange={e => setFilters({ ...filters, [c.key]: e.target.value })}
                        placeholder={c.num ? '> 100' : 'contains…'}
                        className="w-full px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white font-normal normal-case" />
                    </th>
                  ))}
                  <th />
                </tr>
              )}
            </thead>
            <tbody>
              {groups.map(g => (
                <React.Fragment key={g.key || 'all'}>
                  {!!g.key && (
                    <tr className="bg-[var(--leon-cream)]/70 border-t border-[var(--leon-line)]">
                      <td colSpan={allShown.length + 1} className="px-2 py-1 font-bold cursor-pointer"
                        onClick={() => setCollapsed({ ...collapsed, [g.key]: !collapsed[g.key] })}>
                        <span className="no-print mr-1">{collapsed[g.key] ? '▸' : '▾'}</span>
                        {g.key} <span className="font-normal text-[var(--leon-black)]/50">({g.rows.length})</span>
                      </td>
                    </tr>
                  )}
                  {!collapsed[g.key] && g.rows.map((r, i) => (
                    <tr key={r.m.id} className={`border-b border-[var(--leon-line)]/40 ${r.stale ? 'bg-[#fbe7e7]/30' : ''}`}>
                      {allShown.map(c => {
                        const cell = cellOf(r, c);
                        if (c.key === 'color') {
                          return <td key={c.key} className="px-2 py-1">
                            <span className="inline-block w-3 h-3 rounded-full align-middle" style={{ background: cell.s || '#999' }} />
                          </td>;
                        }
                        return <td key={c.key} className={`px-2 py-1 ${c.num ? 'text-right tabular-nums' : ''}`}>
                          {c.key === 'label' && r.stale ? <span title="The sheet has moved on since this was drawn">⚠ </span> : null}
                          {cell.s}
                        </td>;
                      })}
                      <td className="px-1 no-print">
                        <button onClick={() => onOpenSheet(r.sheet.id)} title="Open this sheet"
                          className="text-[var(--leon-brown)] hover:underline">→</button>
                      </td>
                    </tr>
                  ))}
                  {!!totalCols.length && (
                    <tr className="border-t-2 border-[var(--leon-line)] font-bold">
                      {allShown.map(c => (
                        <td key={c.key} className={`px-2 py-1 ${c.num ? 'text-right' : ''}`}>
                          {c.total ? totalOf(g.rows, c)
                            : (allShown.length && c.key === allShown[0].key ? `${g.key ? g.key + ' — ' : ''}subtotal` : '')}
                        </td>
                      ))}
                      <td />
                    </tr>
                  )}
                </React.Fragment>
              ))}
              {!sorted.length && (
                <tr><td colSpan={allShown.length + 1} className="px-3 py-6 text-center text-[var(--leon-black)]/40">
                  Nothing matches. {rows.length ? 'Clear the filters to see all ' + rows.length + '.' : 'Nothing has been measured on this job yet.'}
                </td></tr>
              )}
            </tbody>
          </table>
        )}
      </div>

      <DwgNote>
        <b>Export is a file, not a link.</b> The CSV is exactly what is on screen — the visible columns, the
        filtered rows, in the order they are sorted. There is no live connection to Excel: that needs a process
        running on this machine and there is none, so what leaves here is a file somebody opens.
      </DwgNote>
    </div>
  );
}

// ── Custom columns, including formulas ────────────────────────────────────
// This is where a take-off stops being a list of quantities and becomes an
// estimate: a Choice column carrying unit costs, and a Formula column that
// multiplies by it. The language is TINY on purpose — no `if`, no text
// functions, no aggregates, no reference to another row — and it is not extended
// here, because that constraint is exactly why a column can always be explained
// by pointing at one row.
function DwgColumnsView({ ctx, project, editable, plan, rows }) {
  const cols = plan.cols;
  const [editing, setEditing] = useState(null);
  const [adding, setAdding] = useState(false);
  const [setName, setSetName] = useState('');
  const sets = dwgColumnSets(project);

  function retire(c) {
    if (!confirm(`Remove the column "${c.name}"? The values already entered on markups are kept, so putting it back restores them.`)) return;
    dwgUpdate(ctx, project.id, draft => {
      (draft.takeoffColumns || []).forEach(x => { if (x.id === c.id) x.active = false; });
    }, `Take-off: removed the custom column "${c.name}".`);
  }
  function saveSet() {
    if (!setName.trim()) return;
    dwgUpdate(ctx, project.id, draft => {
      if (!Array.isArray(draft.takeoffColumnSets)) draft.takeoffColumnSets = [];
      draft.takeoffColumnSets.push({ id: uid('tocolset'), name: setName.trim(),
        columnIds: cols.map(c => c.id), active: true,
        createdBy: ctx.currentUserName, createdDate: todayISO() });
    }, `Take-off: saved the column set "${setName.trim()}".`);
    setSetName('');
  }

  return (
    <div className="space-y-3">
      <DwgNote>
        Six types. <b>Text</b>, <b>Number</b>, <b>Date</b> and <b>Checkmark</b> are what you would expect. A
        <b> Choice</b> is a list where each item may also carry a <b>number</b> — that is the whole trick behind
        <code> Cost = Measurement * Material</code>: the choice is a material name to a person and a unit cost to
        the arithmetic. A <b>Formula</b> is a per-row expression over the built-in quantities and the other
        columns.
      </DwgNote>

      {!!plan.cycles.length && (
        <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-3 py-2 text-xs">
          <b>A formula refers to itself, through other columns.</b> Nothing in the loop is calculated until it is
          broken — the alternative is a browser that hangs.
          <ul className="list-disc ml-5 mt-1">
            {plan.cycles.map((cy, i) => <li key={i}>{cy.join(' → ')}</li>)}
          </ul>
        </div>
      )}

      <div className="rounded-lg border border-[var(--leon-line)] bg-white divide-y divide-[var(--leon-line)]">
        {!cols.length && <div className="p-6"><EmptyState text="No custom columns on this job yet." /></div>}
        {cols.map(c => {
          const err = plan.errors[c.id];
          const used = rows.filter(r => {
            const v = (r.m.custom || {})[c.id];
            return v !== undefined && v !== null && v !== '';
          }).length;
          return (
            <div key={c.id} className="px-3 py-2 text-xs">
              <div className="flex items-center gap-2 flex-wrap">
                <span className="font-bold text-sm">{c.name}</span>
                <Badge tone="neutral">{c.type}</Badge>
                {c.includeInTotals && <Badge tone="blue">totalled</Badge>}
                {c.type === 'Number' && c.numberFormat !== 'Normal' && <Badge tone="neutral">{c.numberFormat}</Badge>}
                {c.type === 'Choice' && <span className="text-[var(--leon-black)]/50">{(c.items || []).length} items</span>}
                {c.type === 'Formula' && <code className="text-[var(--leon-black)]/60">{c.formula || '(no formula)'}</code>}
                {c.type !== 'Formula' && <span className="text-[var(--leon-black)]/45">set on {used} markup{used === 1 ? '' : 's'}</span>}
                <span className="ml-auto flex gap-1">
                  {editable && <Button size="sm" variant="ghost" onClick={() => setEditing(c)}>Edit</Button>}
                  {editable && <IconBtn title="Remove this column" onClick={() => retire(c)}>✕</IconBtn>}
                </span>
              </div>
              {err && <div className="text-[var(--leon-red)] mt-1">{err}</div>}
              {c.type === 'Formula' && !err && !!(plan.deps[c.id] || []).length && (
                <div className="text-[10px] text-[var(--leon-black)]/45 mt-0.5">
                  depends on {(plan.deps[c.id] || []).map(id => (cols.find(x => x.id === id) || {}).name).filter(Boolean).join(', ')}
                </div>
              )}
            </div>
          );
        })}
      </div>

      {editable && <Button size="sm" onClick={() => setAdding(true)}>+ Add a column</Button>}

      <Collapsible id={`dwg-colsets-${project.id}`} title="Saved column sets" count={sets.length}>
        <div className="space-y-1">
          {sets.map(s => (
            <div key={s.id} className="flex items-center gap-2 text-xs border border-[var(--leon-line)] rounded px-2 py-1">
              <span className="font-semibold flex-1">{s.name}</span>
              <span className="text-[var(--leon-black)]/45">{(s.columnIds || []).length} columns · {s.createdBy} {fmtDate(s.createdDate)}</span>
              {editable && <IconBtn title="Remove this set" onClick={() => dwgUpdate(ctx, project.id, draft => {
                (draft.takeoffColumnSets || []).forEach(x => { if (x.id === s.id) x.active = false; });
              }, `Take-off: removed the column set "${s.name}".`)}>✕</IconBtn>}
            </div>
          ))}
          {!sets.length && <EmptyState text="No saved column sets." />}
        </div>
        {editable && (
          <div className="flex items-end gap-2 mt-2">
            <TextInput className="!w-56" value={setName} onChange={e => setSetName(e.target.value)} placeholder="Name this set…" />
            <Button size="sm" onClick={saveSet} disabled={!setName.trim() || !cols.length}>Save the current columns</Button>
          </div>
        )}
        <div className="mt-2">
          <DwgNote>
            A set is a record of which columns a job was estimated with — the set of questions asked about every
            markup. Columns themselves belong to the job, so a set names them rather than copying them.
          </DwgNote>
        </div>
      </Collapsible>

      <DwgColumnEditor open={adding || !!editing} column={editing} onClose={() => { setAdding(false); setEditing(null); }}
        ctx={ctx} project={project} plan={plan} />
    </div>
  );
}

function DwgColumnEditor({ open, column, onClose, ctx, project, plan }) {
  const blank = { name: '', type: 'Text', multiline: false, numberFormat: 'Normal', decimals: 2,
                  min: '', max: '', defaultValue: '', includeInTotals: false, items: [],
                  allowCustomText: false, formula: '' };
  const [f, setF] = useState(blank);
  useEffect(() => {
    if (!open) return;
    setF(column ? {
      ...blank, ...column,
      min: column.min === null || column.min === undefined ? '' : column.min,
      max: column.max === null || column.max === undefined ? '' : column.max,
      defaultValue: column.defaultValue === null || column.defaultValue === undefined ? '' : column.defaultValue,
      items: (column.items || []).map(i => ({ ...i })),
    } : blank);
  }, [open, column && column.id]);
  if (!open) return null;

  // Every name the formula may use: the five built-ins plus every other column
  // on the job. A column may not refer to itself directly, so its own name is
  // left out — a self-reference is a cycle, and naming it here would just make
  // the error message arrive one step later.
  const otherNames = plan.cols.filter(c => !column || c.id !== column.id).map(c => c.name).filter(Boolean);
  const names = DWG_FORMULA_VARS.concat(otherNames);
  const parsed = f.type === 'Formula' ? dwgParseFormula(f.formula, names) : null;
  // Would saving this create a loop? Check the graph the save WOULD produce,
  // rather than saving it and reporting the loop afterwards.
  const wouldCycle = (() => {
    if (f.type !== 'Formula' || !parsed || parsed.error) return null;
    const draftCols = plan.cols.filter(c => !column || c.id !== column.id)
      .concat([{ id: column ? column.id : '__new', name: f.name.trim(), type: 'Formula', formula: f.formula, active: true }]);
    const p2 = dwgBuildColumnPlan(draftCols);
    const mine = p2.cycles.filter(cy => cy.indexOf(f.name.trim()) >= 0);
    return mine.length ? mine[0].join(' → ') : null;
  })();
  const nameClash = plan.cols.some(c => (!column || c.id !== column.id)
    && (c.name || '').toLowerCase() === f.name.trim().toLowerCase());
  const reserved = DWG_FORMULA_VARS.some(v => v.toLowerCase() === f.name.trim().toLowerCase());
  const ok = !!f.name.trim() && !nameClash && !reserved
    && (f.type !== 'Formula' || (parsed && !parsed.error && !wouldCycle));

  function save() {
    if (!ok) return;
    const data = { ...f, min: f.min === '' ? null : f.min, max: f.max === '' ? null : f.max,
                   defaultValue: f.defaultValue === '' ? null : f.defaultValue };
    dwgUpdate(ctx, project.id, draft => {
      if (!Array.isArray(draft.takeoffColumns)) draft.takeoffColumns = [];
      if (column) {
        const t = draft.takeoffColumns.find(x => x.id === column.id);
        if (t) Object.assign(t, dwgMakeColumn({ ...data, order: t.order }, ctx.currentUserName), { id: t.id, createdBy: t.createdBy, createdDate: t.createdDate });
      } else {
        draft.takeoffColumns.push(dwgMakeColumn({ ...data, order: draft.takeoffColumns.length }, ctx.currentUserName));
      }
    }, `Take-off: ${column ? 'changed' : 'added'} the custom column "${f.name.trim()}".`);
    onClose();
  }

  return (
    <Modal open wide onClose={onClose} title={column ? `Edit “${column.name}”` : 'Add a column'} footer={
      <>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button onClick={save} disabled={!ok}>{column ? 'Save' : 'Add the column'}</Button>
      </>
    }>
      <div className="space-y-3">
        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Name" hint="This is what a formula refers to, so keep it short and unambiguous.">
            <TextInput value={f.name} onChange={e => setF({ ...f, name: e.target.value })} placeholder="Material" />
          </Field>
          <Field label="Type">
            <Select value={f.type} onChange={e => setF({ ...f, type: e.target.value })} disabled={!!column}>
              {DWG_COLUMN_TYPES.map(t => <option key={t}>{t}</option>)}
            </Select>
          </Field>
        </div>
        {nameClash && <div className="text-xs text-[var(--leon-red)]">There is already a column called that.</div>}
        {reserved && <div className="text-xs text-[var(--leon-red)]">
          That is one of the built-in quantities ({DWG_FORMULA_VARS.join(', ')}), so a formula could never tell
          which one you meant.
        </div>}
        {!!column && <div className="text-[11px] text-[var(--leon-black)]/45">
          The type cannot be changed once a column exists — the values already entered would stop meaning what
          they meant. Remove it and add a new one instead.
        </div>}

        {f.type === 'Text' && (
          <label className="flex items-center gap-2 text-xs">
            <input type="checkbox" checked={!!f.multiline} onChange={e => setF({ ...f, multiline: e.target.checked })} />
            Several lines
          </label>
        )}

        {f.type === 'Number' && (
          <div className="grid gap-3 md:grid-cols-4">
            <Field label="Format">
              <Select value={f.numberFormat} onChange={e => setF({ ...f, numberFormat: e.target.value })}>
                {DWG_NUMBER_FORMATS.map(n => <option key={n}>{n}</option>)}
              </Select>
            </Field>
            <Field label="Decimals">
              <Select value={f.decimals} onChange={e => setF({ ...f, decimals: Number(e.target.value) })}>
                {[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{d}</option>)}
              </Select>
            </Field>
            <Field label="Minimum"><TextInput type="number" step="any" value={f.min} onChange={e => setF({ ...f, min: e.target.value })} /></Field>
            <Field label="Maximum"><TextInput type="number" step="any" value={f.max} onChange={e => setF({ ...f, max: e.target.value })} /></Field>
          </div>
        )}
        {(f.type === 'Number' || f.type === 'Formula') && (
          <div className="grid gap-3 md:grid-cols-2">
            {f.type === 'Formula' && (
              <Field label="Decimals">
                <Select value={f.decimals} onChange={e => setF({ ...f, decimals: Number(e.target.value) })}>
                  {[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{d}</option>)}
                </Select>
              </Field>
            )}
            {f.type === 'Formula' && (
              <Field label="Format">
                <Select value={f.numberFormat} onChange={e => setF({ ...f, numberFormat: e.target.value })}>
                  {DWG_NUMBER_FORMATS.map(n => <option key={n}>{n}</option>)}
                </Select>
              </Field>
            )}
            <label className="flex items-center gap-2 text-xs self-end pb-2">
              <input type="checkbox" checked={!!f.includeInTotals} onChange={e => setF({ ...f, includeInTotals: e.target.checked })} />
              Include in totals — this column gets a subtotal on the Markups List
            </label>
          </div>
        )}
        {f.type !== 'Formula' && (
          <Field label="Default" hint="Used where a markup has nothing entered.">
            <TextInput value={f.defaultValue} onChange={e => setF({ ...f, defaultValue: e.target.value })} />
          </Field>
        )}

        {f.type === 'Choice' && (
          <div className="space-y-2">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Items</div>
            {(f.items || []).map((it, i) => (
              <div key={i} className="grid gap-2 md:grid-cols-[1fr_7rem_1fr_2rem] items-center">
                <TextInput value={it.label} placeholder="Porcelain 12×24"
                  onChange={e => setF({ ...f, items: f.items.map((x, j) => j === i ? { ...x, label: e.target.value } : x) })} />
                <TextInput type="number" step="any" value={it.value === null || it.value === undefined ? '' : it.value} placeholder="value"
                  onChange={e => setF({ ...f, items: f.items.map((x, j) => j === i ? { ...x, value: e.target.value === '' ? null : Number(e.target.value) } : x) })} />
                <TextInput value={(it.subjects || []).join(', ')} placeholder="only on these subjects (optional)"
                  onChange={e => setF({ ...f, items: f.items.map((x, j) => j === i ? { ...x, subjects: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } : x) })} />
                <IconBtn title="Remove this item" onClick={() => setF({ ...f, items: f.items.filter((x, j) => j !== i) })}>✕</IconBtn>
              </div>
            ))}
            <Button size="sm" variant="ghost" onClick={() => setF({ ...f, items: (f.items || []).concat([{ id: uid('tochoice'), label: '', value: null, subjects: [] }]) })}>+ Add an item</Button>
            <label className="flex items-center gap-2 text-xs">
              <input type="checkbox" checked={!!f.allowCustomText} onChange={e => setF({ ...f, allowCustomText: e.target.checked })} />
              Allow something typed in that is not on the list
            </label>
            <DwgNote>
              The <b>value</b> is what a formula sees. An item with no value counts as zero in the arithmetic and
              still reads as its label on screen — which is right for a choice that is a description rather than
              a rate, and is why the two are separate fields.
            </DwgNote>
          </div>
        )}

        {f.type === 'Formula' && (
          <div className="space-y-2">
            <Field label="Formula">
              <TextInput value={f.formula} onChange={e => setF({ ...f, formula: e.target.value })} placeholder="Measurement * Material" />
            </Field>
            {parsed && parsed.error && <div className="text-xs text-[var(--leon-red)]">{parsed.error}</div>}
            {wouldCycle && <div className="text-xs text-[var(--leon-red)]">
              That would make a loop: <b>{wouldCycle}</b>. A column cannot depend on itself, however far round it
              goes, so this is refused rather than saved and left to hang.
            </div>}
            {parsed && !parsed.error && !wouldCycle && (
              <div className="text-xs text-[#2e6b39]">
                Reads correctly{parsed.refs.length ? ` — uses ${parsed.refs.join(', ')}` : ' — a constant'}.
              </div>
            )}
            <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/50 px-3 py-2 text-[11px] space-y-1">
              <div><b>Quantities:</b> {DWG_FORMULA_VARS.join(', ')} — <b>Measurement</b> is the markup&rsquo;s own primary figure with no unit on it.</div>
              <div><b>Your columns:</b> {otherNames.length ? otherNames.join(', ') : 'none yet'}. A name with a space in it can be written in square brackets: <code>[Unit Cost]</code>.</div>
              <div><b>Constants:</b> e, pi. <b>Operators:</b> + − * / ^ % and a leading minus.</div>
              <div><b>Functions:</b> {DWG_FORMULA_FUNC_NAMES.join(', ')}.</div>
              <div className="text-[var(--leon-black)]/55">
                There is no <code>if</code>, no text function, no SUM over other rows and no way to reach another
                markup. That is deliberate and is not going to be added: a formula is a per-row scalar, which is
                what makes any number it produces explainable by pointing at one row.
              </div>
            </div>
          </div>
        )}
      </div>
    </Modal>
  );
}

// ── Legends ───────────────────────────────────────────────────────────────
// Generated from the markups, never stored, so it cannot fall out of step with
// them. Scoped to the whole job, one sheet, or one page — which is the
// difference between a legend for a bid letter and one for a plot.
function DwgLegendView({ project, rows, cols }) {
  const [scope, setScope] = useState('all');
  const [sheetId, setSheetId] = useState('');
  const [pageIndex, setPageIndex] = useState(0);
  const [by, setBy] = useState('subject');
  const [showCols, setShowCols] = useState({ length: true, area: true, count: true });

  const sheets = dwgSheetList(project);
  const scoped = rows.filter(r => {
    if (scope === 'sheet') return r.sheet.id === sheetId;
    if (scope === 'page') return r.sheet.id === sheetId && r.pageIndex === dwgNum(pageIndex);
    return true;
  });

  const groups = [];
  const map = {};
  scoped.forEach(r => {
    const k = by === 'layer' ? (r.m.layer || '(no layer)')
      : by === 'space' ? (r.spacePath || '(no space)')
      : (r.m.subject || '(no subject)');
    if (!map[k]) { map[k] = { key: k, rows: [], colors: {}, symbols: {} }; groups.push(map[k]); }
    map[k].rows.push(r);
    const col = r.m.color || DWG_DRAW_COLORS[r.m.kind] || '#999';
    map[k].colors[col] = (map[k].colors[col] || 0) + 1;
    if (r.m.countSymbol) map[k].symbols[r.m.countSymbol] = true;
  });
  groups.sort((a, b) => a.key.localeCompare(b.key));

  const quantityCols = [
    { key: 'length', label: 'Length' }, { key: 'area', label: 'Area' },
    { key: 'volume', label: 'Volume' }, { key: 'wallArea', label: 'Wall Area' },
    { key: 'count', label: 'Count' },
  ].filter(c => showCols[c.key]);
  const totalCustom = cols.filter(c => c.includeInTotals);

  return (
    <div className="space-y-3">
      <DwgNote>
        A legend is <b>generated</b>, not drawn: it reads the markups and changes the moment they do, so it can
        never claim a quantity the drawing no longer carries. It is also not written onto the PDF — nothing in
        this module modifies the file — so this is a legend to print or paste beside the sheet.
      </DwgNote>
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Covering">
          <Select className="!w-40" value={scope} onChange={e => setScope(e.target.value)}>
            <option value="all">the whole job</option>
            <option value="sheet">one sheet</option>
            <option value="page">one page</option>
          </Select>
        </Field>
        {scope !== 'all' && (
          <Field label="Sheet">
            <Select className="!w-52" value={sheetId} onChange={e => setSheetId(e.target.value)}>
              <option value="">— pick a sheet —</option>
              {sheets.map(s => <option key={s.id} value={s.id}>{s.number} {s.title}</option>)}
            </Select>
          </Field>
        )}
        {scope === 'page' && (
          <Field label="Page">
            <TextInput type="number" min="1" className="!w-20" value={dwgNum(pageIndex) + 1}
              onChange={e => setPageIndex(Math.max(0, Number(e.target.value) - 1))} />
          </Field>
        )}
        <Field label="Grouped by">
          <Select className="!w-36" value={by} onChange={e => setBy(e.target.value)}>
            <option value="subject">Subject</option>
            <option value="layer">Layer</option>
            <option value="space">Space</option>
          </Select>
        </Field>
        <div className="flex items-center gap-2 text-xs pb-1">
          {['length', 'area', 'volume', 'wallArea', 'count'].map(k => (
            <label key={k} className="flex items-center gap-1">
              <input type="checkbox" checked={!!showCols[k]} onChange={e => setShowCols({ ...showCols, [k]: e.target.checked })} />
              {(DWG_MARKUP_COLUMN_BY_KEY[k] || {}).label}
            </label>
          ))}
        </div>
      </div>

      <div data-print-region className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
        <div className="flex items-center justify-between gap-2 mb-2">
          <div className="lp-section-title text-sm font-bold">
            Legend — {project.name}
            {scope !== 'all' && sheetId ? ` · ${(sheets.find(s => s.id === sheetId) || {}).number}` : ''}
            {scope === 'page' ? ` page ${dwgNum(pageIndex) + 1}` : ''}
          </div>
          <DocActions title="Legend" heading="Legend" lines={[project.name,
            scope === 'all' ? 'The whole job'
              : `Sheet ${(sheets.find(s => s.id === sheetId) || {}).number || '—'}${scope === 'page' ? ` page ${dwgNum(pageIndex) + 1}` : ''}`,
            `Grouped by ${by}`]} />
        </div>
        {!groups.length ? <EmptyState text="Nothing measured in that scope yet." /> : (
          <table className="w-full text-xs">
            <thead className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <tr>
                <th className="text-left py-1 w-16">Symbol</th>
                <th className="text-left">Description</th>
                <th className="text-right">Markups</th>
                {quantityCols.map(c => <th key={c.key} className="text-right px-2">{c.label}</th>)}
                {totalCustom.map(c => <th key={c.id} className="text-right px-2">{c.name}</th>)}
              </tr>
            </thead>
            <tbody>
              {groups.map(g => {
                const col = Object.keys(g.colors).sort((a, b) => g.colors[b] - g.colors[a])[0] || '#999';
                const syms = Object.keys(g.symbols);
                return (
                  <tr key={g.key} className="border-b border-[var(--leon-line)]/40">
                    <td className="py-1.5">
                      <span className="inline-flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold text-white"
                        style={{ background: col }}>{syms[0] || ''}</span>
                    </td>
                    <td className="font-semibold">{g.key}</td>
                    <td className="text-right">{g.rows.length}</td>
                    {quantityCols.map(c => (
                      <td key={c.key} className="text-right px-2 tabular-nums">
                        {dwgFmtTotals(dwgSumByUnit(g.rows, r => dwgMarkupCell(r, c.key)), c)}
                      </td>
                    ))}
                    {totalCustom.map(c => (
                      <td key={c.id} className="text-right px-2 tabular-nums">
                        {dwgFmtColumnValue(c, g.rows.reduce((s, r) => s + dwgNum(dwgCustomCell(r, c).v), 0))}
                      </td>
                    ))}
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}

// ── Measurement → takeoff record ──────────────────────────────────────────
// This writes dwgMakeItem and nothing else. Every field the entry form sets is
// set here too, so a record made by drawing is indistinguishable from a record
// made by typing — which is the point: there is one kind of takeoff record.
function DwgMeasureToRecordModal({ measurement, onClose, ctx, project, sheet, cal, aspect, measures, versionId }) {
  const [f, setF] = useState({});
  const tools = dwgAllTools(project);
  const versions = dwgVersions(project);
  const openVersions = versions.filter(v => !v.locked);
  const figures = measurement ? dwgFiguresFor(measurement.kind, measurement) : [];

  useEffect(() => {
    if (!measurement) return;
    const first = figures[0] || { key: 'net', type: 'Area' };
    setF({
      figure: first.key, measurementType: first.type,
      unit: (DWG_UNITS_BY_TYPE[first.type] || ['SF'])[0],
      toolId: '', toolName: '', wastePct: 0,
      scopeId: measurement.scopeId || '', code: '', building: '', floor: '',
      unitType: '', unitNo: '', room: '', surface: '',
      included: 'Included', notes: '',
      bidVersionId: versionId || (openVersions[0] ? openVersions[0].id : ''),
    });
  }, [measurement]);

  if (!measurement) return null;
  const v = dwgMeasureValue(measurement, cal, aspect, measures, sheet);
  const fig = figures.find(x => x.key === f.figure) || figures[0];
  const units = DWG_UNITS_BY_TYPE[fig ? fig.type : 'Area'] || DWG_UNITS_BY_TYPE.Area;
  const rawMeasure = fig ? dwgFigureIn(v, fig.key, f.unit) : 0;
  const stale = dwgMeasureStale(sheet, measurement);
  const lockedTarget = dwgVersionLocked(project, f.bidVersionId);

  function pickTool(id) {
    const t = tools.find(x => x.id === id);
    if (!t) { setF({ ...f, toolId: '', toolName: '' }); return; }
    // A tool whose type matches keeps its own unit and standard waste, exactly
    // as the typed form does. A tool of a different type is offered but its
    // unit cannot be used, so the figure's own unit is kept.
    const sameType = fig && t.measurementType === fig.type;
    const family = DWG_FAMILY_FOR_TOOL_SCOPE[t.scope];
    const suggested = f.scopeId ? f.scopeId
      : ((project.scopes || []).find(s => s.familyName === family) || {}).id || '';
    setF({ ...f, toolId: t.id, toolName: t.name, wastePct: t.wastePct,
           unit: sameType ? t.unit : f.unit, scopeId: suggested });
  }

  function save() {
    const payload = {
      projectId: project.id,
      scopeId: f.scopeId || null,
      drawingSetId: sheet.drawingSetId || null,
      sheetId: sheet.id,
      // Stamped from the sheet as it is right now, exactly like the typed form.
      sheetRevision: sheet.revision || '',
      toolId: f.toolId || null, toolName: f.toolName || '',
      measurementType: fig ? fig.type : 'Area',
      rawMeasure, unit: f.unit, wastePct: dwgNum(f.wastePct),
      code: f.code, building: f.building, floor: f.floor,
      unitType: f.unitType, unitNo: f.unitNo, room: f.room, surface: f.surface,
      included: f.included,
      // A measurement drawn over a superseded sheet goes straight into the
      // revision queue that already exists rather than into a new one.
      status: stale ? 'Revision Review Required' : 'In Progress',
      bidVersionId: f.bidVersionId || null,
      notes: [`Measured on sheet ${sheet.number} Rev ${sheet.revision || '—'}`,
              cal ? `at ${cal.scaleLabel || 'the calibrated scale'} (calibrated by ${cal.by} ${fmtDate(cal.date)})` : '',
              f.notes].filter(Boolean).join(' · '),
    };
    const rec = dwgMakeItem(payload, ctx.currentUserName);
    if (stale) rec.preRevisionStatus = 'In Progress';
    dwgUpdate(ctx, project.id, d => {
      d.takeoffItems.push(rec);
      const s = d.drawingSheets.find(x => x.id === sheet.id);
      if (s && Array.isArray(s.measurements)) {
        const m = s.measurements.find(x => x.id === measurement.id);
        // The link is kept on the MEASUREMENT, not on the record: dwgMakeItem is
        // the shared shape and this surface does not get to add a field to it.
        if (m) { m.itemId = rec.id; m.figure = fig.key; m.unit = f.unit; }
      }
    }, `Take-off: recorded ${payload.toolName || 'takeoff'} ${dwgFmtQty(rawMeasure, f.unit)} measured on sheet ${sheet.number}.`);
    onClose();
  }

  const valid = !!f.toolId && dwgNum(rawMeasure) !== 0 && !lockedTarget;

  return (
    <Modal open={!!measurement} onClose={onClose} wide title="Turn this measurement into a takeoff record" footer={<>
      <Button variant="ghost" onClick={onClose}>Cancel</Button>
      <Button onClick={save} disabled={!valid}>Record takeoff</Button>
    </>}>
      <div className="space-y-3">
        <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm">
          <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">Measured on {sheet.number} Rev {sheet.revision || '—'}</div>
          {measurement.kind === 'count'
            ? <div><b>{v.count}</b> pins</div>
            : measurement.kind === 'distance'
              ? <div>{dwgFmtMm(v.mm)}</div>
              : <div>
                  {dwgFmtArea(v.grossMm2)} as drawn
                  {v.deductions.length ? <> less {dwgFmtQty(dwgAreaIn(v.deductedMm2, 'SF'), 'SF')} of deductions → <b>{dwgFmtArea(v.netMm2)}</b></> : null}
                  {' · perimeter '}{dwgFmtMm(v.perimeterMm)}
                </div>}
          <div className="text-xs text-[var(--leon-black)]/55 mt-1">
            {cal ? <>Against the calibration {cal.scaleLabel} set by {cal.by} on {fmtDate(cal.date)} against {cal.knownText}.</>
                 : 'No calibration on this page.'}
          </div>
        </div>

        {stale && (
          <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-3 py-2 text-xs">
            <b>This was drawn on Rev {measurement.sheetRevision || '—'} and the sheet has moved on.</b> The record
            will be created as <b>Revision Review Required</b> so it lands in the queue on the Revisions tab,
            rather than being carried forward as if the drawing had not changed.
          </div>
        )}

        <div className="grid gap-3 md:grid-cols-3">
          <Field label="Which figure">
            <Select value={f.figure} onChange={e => {
              const nf = figures.find(x => x.key === e.target.value) || figures[0];
              setF({ ...f, figure: nf.key, measurementType: nf.type, unit: (DWG_UNITS_BY_TYPE[nf.type] || ['SF'])[0] });
            }}>
              {figures.map(x => <option key={x.key} value={x.key}>{x.label}</option>)}
            </Select>
          </Field>
          <Field label="Unit">
            <Select value={f.unit} onChange={e => setF({ ...f, unit: e.target.value })}>
              {units.map(u => <option key={u}>{u}</option>)}
            </Select>
          </Field>
          <Field label="Waste %">
            <TextInput type="number" step="any" min="0" value={f.wastePct} onChange={e => setF({ ...f, wastePct: e.target.value })} />
          </Field>
        </div>

        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Tool" hint="The same tool chest the typed form uses — it decides the scope suggestion and the standard waste.">
            <Select value={f.toolId || ''} onChange={e => pickTool(e.target.value)}>
              <option value="">— pick a tool —</option>
              {DWG_TOOL_SCOPES.map(sc => {
                const list = tools.filter(t => t.scope === sc);
                if (!list.length) return null;
                return <optgroup key={sc} label={sc}>
                  {list.map(t => <option key={t.id} value={t.id}>
                    {t.name} ({t.unit}{t.wastePct ? ` · ${t.wastePct}% waste` : ''}){t.measurementType !== (fig ? fig.type : '') ? ' — different type' : ''}
                  </option>)}
                </optgroup>;
              })}
            </Select>
          </Field>
          <Field label="Scope">
            <Select value={f.scopeId || ''} onChange={e => setF({ ...f, scopeId: e.target.value })}>
              <option value="">— no scope yet —</option>
              {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </Select>
          </Field>
        </div>

        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Bid version">
            <Select value={f.bidVersionId || ''} onChange={e => setF({ ...f, bidVersionId: e.target.value })}>
              <option value="">— none —</option>
              {versions.map(v2 => <option key={v2.id} value={v2.id} disabled={v2.locked}>{v2.name}{v2.locked ? ' 🔒 locked' : ''}</option>)}
            </Select>
          </Field>
          <Field label="Counts toward">
            <Select value={f.included} onChange={e => setF({ ...f, included: e.target.value })}>
              {DWG_INCLUSIONS.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
        </div>

        <div className="grid gap-3 md:grid-cols-3">
          <Field label="Mark / code"><TextInput value={f.code} onChange={e => setF({ ...f, code: e.target.value })} placeholder="D-101 / T-3" /></Field>
          <Field label="Building"><TextInput value={f.building} onChange={e => setF({ ...f, building: e.target.value })} /></Field>
          <Field label="Floor"><TextInput value={f.floor} onChange={e => setF({ ...f, floor: e.target.value })} /></Field>
          <Field label="Unit type"><TextInput value={f.unitType} onChange={e => setF({ ...f, unitType: e.target.value })} /></Field>
          <Field label="Unit no."><TextInput value={f.unitNo} onChange={e => setF({ ...f, unitNo: e.target.value })} /></Field>
          <Field label="Room"><TextInput value={f.room} onChange={e => setF({ ...f, room: e.target.value })} /></Field>
        </div>
        <Field label="Surface"><TextInput value={f.surface} onChange={e => setF({ ...f, surface: e.target.value })} placeholder="Floor / Wall / Base" /></Field>
        <Field label="Notes" hint="The sheet, revision and calibration are written onto the record automatically.">
          <TextArea rows={2} value={f.notes} onChange={e => setF({ ...f, notes: e.target.value })} />
        </Field>

        <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm">
          <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">This record works out to</div>
          <div>
            {dwgFmtQty(rawMeasure, f.unit)}
            {dwgNum(f.wastePct) ? <> {' + '}{dwgNum(f.wastePct)}% waste → procure <b>{dwgFmtQty(rawMeasure * (1 + dwgNum(f.wastePct) / 100), f.unit)}</b></> : null}
          </div>
          {lockedTarget && <div className="text-xs text-[var(--leon-red)] mt-1">That bid version is locked — pick an open one.</div>}
          {!f.toolId && <div className="text-xs text-[var(--leon-black)]/55 mt-1">Pick a tool: it is what puts this quantity in the right column of every roll-up.</div>}
        </div>
      </div>
    </Modal>
  );
}



// ── Takeoff ───────────────────────────────────────────────────────────────
// The entry surface. This is the honest core of the module: a well-built form
// that captures WHAT was measured, OFF WHICH SHEET at WHICH REVISION, FOR WHICH
// SCOPE, in WHICH BID VERSION, BY WHOM. A drawn measurement would only replace
// the first of those six.
function DwgTakeoffSection({ ctx, project, editable, versionId, setVersionId, onOpenSheet, onMeasure, highlightItemId, clearHighlight }) {
  const [editing, setEditing] = useState(null);      // item being edited
  const [creating, setCreating] = useState(null);    // seed object for a new item
  const [tools, setTools] = useState(false);
  const [q, setQ] = useState('');
  const [scopeFilter, setScopeFilter] = useState('');
  const [statusFilter, setStatusFilter] = useState('');
  const [incFilter, setIncFilter] = useState('');
  const [showSuperseded, setShowSuperseded] = useState(false);

  const all = dwgItemList(project);
  const versions = dwgVersions(project);
  const activeVersion = versions.find(v => v.id === versionId) || null;

  const rows = all.filter(i => {
    if (!showSuperseded && !dwgItemLive(i)) return false;
    if (versionId && i.bidVersionId !== versionId) return false;
    if (scopeFilter && i.scopeId !== scopeFilter) return false;
    if (statusFilter && i.status !== statusFilter) return false;
    if (incFilter && i.included !== incFilter) return false;
    if (!q.trim()) return true;
    const sh = dwgSheetById(project, i.sheetId);
    const hay = `${i.toolName} ${i.code} ${i.building} ${i.floor} ${i.unitType} ${i.unitNo} ${i.room} ${i.surface} ${i.notes} ${sh ? sh.number : ''}`.toLowerCase();
    return hay.includes(q.trim().toLowerCase());
  });
  // Overrides are shown nested under the typical they modify, never as peers —
  // reading them as separate rows is how a double-count starts.
  const parents = rows.filter(i => !i.overrideOfId);
  const flagged = all.filter(i => i.status === 'Revision Review Required').length;

  function newItem(seed) {
    if (activeVersion && activeVersion.locked) {
      alert(`"${activeVersion.name}" is locked. Pick or create an unlocked bid version before adding takeoffs.`);
      return;
    }
    setCreating(seed || {});
  }

  return (
    <div className="space-y-4">
      <DwgBidVersionsPanel ctx={ctx} project={project} versionId={versionId} setVersionId={setVersionId} editable={editable} />

      {!!flagged && (
        <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-3 py-2 text-sm">
          <b>{flagged} record{flagged === 1 ? '' : 's'}</b> came off a sheet that has since been superseded and
          are waiting on revision review. Work through them on the <b>Revisions</b> tab.
        </div>
      )}

      <div className="flex items-end gap-2 flex-wrap">
        <TextInput className="!w-56" value={q} onChange={e => setQ(e.target.value)} placeholder="Search tool, code, room, sheet…" />
        <Select className="!w-44" value={scopeFilter} onChange={e => setScopeFilter(e.target.value)}>
          <option value="">All scopes</option>
          {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
        </Select>
        <Select className="!w-44" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
          <option value="">All statuses</option>
          {DWG_ITEM_STATUSES.map(s => <option key={s}>{s}</option>)}
        </Select>
        <Select className="!w-36" value={incFilter} onChange={e => setIncFilter(e.target.value)}>
          <option value="">All inclusions</option>
          {DWG_INCLUSIONS.map(s => <option key={s}>{s}</option>)}
        </Select>
        <DwgChip active={showSuperseded} onClick={() => setShowSuperseded(v => !v)}>Show superseded</DwgChip>
        <div className="ml-auto flex gap-2">
          <Button size="sm" variant="outline" onClick={() => setTools(true)}>🧰 Tool chest</Button>
          {editable && onMeasure && <Button size="sm" variant="outline" onClick={() => onMeasure(null)}>🖊️ Measure on a sheet</Button>}
          {editable && <Button size="sm" onClick={() => newItem()}>+ Record a takeoff</Button>}
        </div>
      </div>

      {!!highlightItemId && (
        <div className="rounded-lg border border-[var(--leon-brown)] bg-[var(--leon-cream)]/60 px-3 py-2 text-sm flex items-center justify-between gap-3 flex-wrap">
          <span>Showing the record produced by a measurement drawn on the sheet — the ringed row below.</span>
          <Button size="sm" variant="ghost" onClick={clearHighlight}>Clear</Button>
        </div>
      )}

      <DwgNote>
        A quantity gets here two ways and both produce this same record: typed in with
        <b> + Record a takeoff</b>, or measured on the drawing itself on <b>On-Sheet Measure</b>, which
        renders the sheet with pdf.js and measures distance, area, perimeter, counts and deductions against
        a scale calibrated on it. What is <b>not</b> real, here or there: no symbol recognition, no
        auto-count of a legend symbol, <b>no snapping to the drawing&rsquo;s own linework</b> (pdf.js gives
        this app a rendered page and a text layer, not the CAD lines — snapping is to your own points and to
        a grid you set), and no AI plan reading. Nothing reads the drawing for you.
      </DwgNote>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[1200px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <th className="px-2 py-2">Sheet</th><th className="px-2 py-2">Scope</th><th className="px-2 py-2">Tool</th>
              <th className="px-2 py-2">Code</th><th className="px-2 py-2">Location</th>
              <th className="px-2 py-2 text-right">Measured</th><th className="px-2 py-2 text-right">×</th>
              <th className="px-2 py-2 text-right">Net</th><th className="px-2 py-2 text-right">Waste</th>
              <th className="px-2 py-2 text-right">Procure</th>
              <th className="px-2 py-2">Incl.</th><th className="px-2 py-2">Status</th><th className="px-2 py-2">By</th>
              <th className="px-2 py-2 w-16"></th>
            </tr>
          </thead>
          <tbody>
            {parents.map(i => {
              const qy = dwgItemQty(i, all);
              const sh = dwgSheetById(project, i.sheetId);
              const sc = dwgScopeById(project, i.scopeId);
              const tool = dwgToolById(project, i.toolId);
              const stale = sh && i.sheetRevision && sh.revision && i.sheetRevision !== sh.revision;
              return (
                <React.Fragment key={i.id}>
                  <tr className={`border-b border-[var(--leon-line)]/60 ${!dwgItemLive(i) ? 'opacity-45' : ''} ${i.status === 'Revision Review Required' ? 'bg-[#fbe7e7]/40' : ''} ${highlightItemId === i.id ? 'ring-2 ring-[var(--leon-brown)] bg-[var(--leon-cream)]/70' : ''}`}>
                    <td className="px-2 py-1.5 whitespace-nowrap">
                      {sh ? <button className="font-bold text-[var(--leon-brown)] hover:underline" onClick={() => onOpenSheet(sh.id)}>{sh.number}</button>
                        : <span className="text-[var(--leon-black)]/35">no sheet</span>}
                      {' '}<DwgScaleFlag sheet={sh} short />
                      {stale && <span className="ml-1 text-[10px] text-[var(--leon-red)] font-bold" title={`Taken from Rev ${i.sheetRevision}; sheet is now Rev ${sh.revision}`}>R{i.sheetRevision}→{sh.revision}</span>}
                    </td>
                    <td className="px-2 py-1.5">{sc ? sc.name : <span className="text-[var(--leon-black)]/35">—</span>}</td>
                    <td className="px-2 py-1.5 whitespace-nowrap">
                      <span className="inline-block w-2 h-2 rounded-full mr-1.5 align-middle" style={{ background: tool ? tool.color : '#ccc' }} />
                      {i.toolName}
                    </td>
                    <td className="px-2 py-1.5 font-semibold">{i.code || '—'}</td>
                    <td className="px-2 py-1.5 text-[var(--leon-black)]/60">
                      {[i.building, i.floor, i.unitType, i.unitNo, i.room, i.surface].filter(Boolean).join(' · ') || '—'}
                    </td>
                    <td className="px-2 py-1.5 text-right whitespace-nowrap">{dwgFmtQty(qy.raw, i.unit)}</td>
                    <td className="px-2 py-1.5 text-right">{i.typical ? `×${qy.occ}` : ''}</td>
                    <td className="px-2 py-1.5 text-right font-bold whitespace-nowrap">{dwgFmtQty(qy.net, i.unit)}</td>
                    <td className="px-2 py-1.5 text-right">{qy.waste ? `${qy.waste}%` : '—'}</td>
                    <td className="px-2 py-1.5 text-right text-[var(--leon-brown)] font-semibold whitespace-nowrap">{dwgFmtQty(qy.procurement, i.unit)}</td>
                    <td className="px-2 py-1.5">{i.included === 'Included' ? <span className="text-[var(--leon-black)]/45">Incl.</span> : <Badge tone="yellow">{i.included}</Badge>}</td>
                    <td className="px-2 py-1.5"><Badge tone={DWG_STATUS_TONE[i.status]}>{i.status}</Badge></td>
                    <td className="px-2 py-1.5 text-[var(--leon-black)]/50 whitespace-nowrap">{(i.updatedBy || i.createdBy || '').split(' ')[0]} {fmtDate(i.updatedDate || i.createdDate)}</td>
                    <td className="px-2 py-1.5 whitespace-nowrap">
                      <button onClick={() => setEditing(i)} className="text-[var(--leon-brown)] font-semibold" title="Open">✎</button>
                      {editable && i.typical && (
                        <button onClick={() => newItem({ overrideOfId: i.id, parent: i })} className="ml-1.5 text-[var(--leon-brown)]" title="This unit differs from the typical">±</button>
                      )}
                    </td>
                  </tr>
                  {qy.overrides.map(o => (
                    <tr key={o.id} className="border-b border-[var(--leon-line)]/60 bg-[var(--leon-cream)]/40">
                      <td className="px-2 py-1 pl-6 text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40" colSpan={4}>
                        override · {o.unitNo || o.room || 'one unit'}
                      </td>
                      <td className="px-2 py-1 text-[var(--leon-black)]/60">{[o.building, o.floor, o.unitNo, o.room].filter(Boolean).join(' · ')}</td>
                      <td className="px-2 py-1 text-right">{dwgFmtQty(o.rawMeasure, o.unit)}</td>
                      <td className="px-2 py-1"></td>
                      <td className="px-2 py-1 text-right font-semibold">{dwgSigned(dwgNum(o.rawMeasure) - qy.raw, o.unit)}</td>
                      <td className="px-2 py-1" colSpan={4}></td>
                      <td className="px-2 py-1 text-[var(--leon-black)]/45">{(o.createdBy || '').split(' ')[0]}</td>
                      <td className="px-2 py-1">
                        <button onClick={() => setEditing(o)} className="text-[var(--leon-brown)] font-semibold" title="Open">✎</button>
                      </td>
                    </tr>
                  ))}
                </React.Fragment>
              );
            })}
            {!parents.length && <tr><td colSpan={14} className="px-3 py-6 text-center text-[var(--leon-black)]/40">
              No takeoff records match. {editable ? 'Use “Record a takeoff” to enter one.' : ''}
            </td></tr>}
          </tbody>
        </table>
      </div>

      <DwgItemModal open={!!creating || !!editing} seed={creating} item={editing}
        onClose={() => { setCreating(null); setEditing(null); }}
        ctx={ctx} project={project} versionId={versionId} editable={editable} />
      <DwgToolChestModal open={tools} onClose={() => setTools(false)} ctx={ctx} project={project} editable={editable} />
    </div>
  );
}

// ── Bid versions and the quantity lock ────────────────────────────────────
// A locked version is history. Nothing here edits a locked row in place — a
// change branches it into an open version, and the two are then compared on the
// Revisions tab. That is the difference between a record and a rewrite.
function DwgBidVersionsPanel({ ctx, project, versionId, setVersionId, editable }) {
  const [name, setName] = useState('');
  const versions = dwgVersions(project);
  const items = dwgItemList(project);

  function add() {
    if (!name.trim()) return;
    const v = dwgMakeVersion({ name: name.trim() }, ctx.currentUserName);
    dwgUpdate(ctx, project.id, draft => { draft.bidVersions.push(v); },
      `Take-off: created bid version "${v.name}".`);
    setName(''); setVersionId(v.id);
  }
  function toggleLock(v) {
    const n = items.filter(i => i.bidVersionId === v.id && dwgItemLive(i)).length;
    if (!v.locked && !confirm(`Lock "${v.name}"? Its ${n} takeoff record${n === 1 ? '' : 's'} become historical — later changes branch into a new version and are reported as a comparison instead of overwriting these.`)) return;
    dwgUpdate(ctx, project.id, draft => {
      const t = draft.bidVersions.find(x => x.id === v.id);
      if (!t) return;
      t.locked = !t.locked;
      t.lockedDate = t.locked ? todayISO() : null;
      t.lockedBy = t.locked ? ctx.currentUserName : null;
    }, `Take-off: bid version "${v.name}" ${v.locked ? 'unlocked' : `locked with ${n} record${n === 1 ? '' : 's'}`}.`);
  }

  return (
    <Collapsible id={`dwg-versions-${project.id}`} title="Bid versions" count={versions.length} defaultOpen={!versions.length}>
      <DwgNote>
        A takeoff belongs to a bid version. Locking one freezes it as the quantities that were bid; from
        then on an edit creates a copy in an open version and the two are compared, so the number that
        went out with the proposal can always be produced again.
      </DwgNote>
      <div className="space-y-1.5 mt-2">
        {versions.map(v => {
          const n = items.filter(i => i.bidVersionId === v.id && dwgItemLive(i)).length;
          return (
            <div key={v.id} className={`flex items-center gap-2 flex-wrap border rounded-lg px-3 py-2 ${versionId === v.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]/60' : 'border-[var(--leon-line)]'}`}>
              <button className="font-semibold text-sm flex-1 min-w-[8rem] text-left" onClick={() => setVersionId(v.id)}>
                {v.name}{v.locked && <span className="ml-1.5">🔒</span>}
              </button>
              <span className="text-xs text-[var(--leon-black)]/50">
                {n} record{n === 1 ? '' : 's'} · created {fmtDate(v.createdDate)} by {v.createdBy}
                {v.locked ? ` · locked ${fmtDate(v.lockedDate)} by ${v.lockedBy}` : ''}
              </span>
              {editable && <Button size="sm" variant={v.locked ? 'ghost' : 'outline'} onClick={() => toggleLock(v)}>
                {v.locked ? 'Unlock' : 'Lock'}
              </Button>}
            </div>
          );
        })}
        {!versions.length && <EmptyState text="No bid versions yet. Records entered without one still save; a version simply lets you freeze a bid." />}
      </div>
      {editable && (
        <div className="flex items-end gap-2 mt-2">
          <TextInput className="!w-56" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Bid — Addendum 2" />
          <Button size="sm" onClick={add} disabled={!name.trim()}>+ New version</Button>
        </div>
      )}
    </Collapsible>
  );
}

// ── Takeoff entry form ────────────────────────────────────────────────────
function DwgItemModal({ open, seed, item, onClose, ctx, project, versionId, editable }) {
  const [f, setF] = useState({});
  const [finishQuery, setFinishQuery] = useState('');
  const all = dwgItemList(project);
  const sheets = dwgSheetList(project);
  const tools = dwgAllTools(project);
  const versions = dwgVersions(project);
  const openVersions = versions.filter(v => !v.locked);
  const parent = seed && seed.overrideOfId ? all.find(x => x.id === seed.overrideOfId) : (item && item.overrideOfId ? all.find(x => x.id === item.overrideOfId) : null);

  useEffect(() => {
    if (!open) return;
    setFinishQuery('');
    if (item) { setF({ ...item }); return; }
    const base = parent ? {
      // An override inherits everything about the typical except the number and
      // the unit it applies to — that is the entire point of it.
      scopeId: parent.scopeId, drawingSetId: parent.drawingSetId, sheetId: parent.sheetId,
      toolId: parent.toolId, toolName: parent.toolName, measurementType: parent.measurementType,
      unit: parent.unit, wastePct: parent.wastePct, code: parent.code,
      building: parent.building, floor: parent.floor, unitType: parent.unitType,
      room: parent.room, surface: parent.surface, included: parent.included,
      finishRef: parent.finishRef, materialId: parent.materialId,
      overrideOfId: parent.id, bidVersionId: parent.bidVersionId,
    } : {};
    setF({
      scopeId: '', drawingSetId: '', sheetId: '', toolId: '', measurementType: 'Area', unit: 'SF',
      rawMeasure: '', wastePct: 0, code: '', building: '', floor: '', unitType: '', unitNo: '',
      room: '', surface: '', included: 'Included', status: 'In Progress', notes: '',
      typical: false, typicalCount: 1, finishRef: null, materialId: '',
      bidVersionId: versionId || (openVersions[0] ? openVersions[0].id : ''),
      ...base, ...(seed || {}),
    });
  }, [open, item, seed]);

  if (!open) return null;
  const sheet = sheets.find(s => s.id === f.sheetId) || null;
  const tool = tools.find(t => t.id === f.toolId) || null;
  const lockedTarget = dwgVersionLocked(project, f.bidVersionId);
  const editingLocked = !!item && dwgVersionLocked(project, item.bidVersionId);
  const units = DWG_UNITS_BY_TYPE[f.measurementType] || DWG_UNITS_BY_TYPE.Area;
  const preview = dwgItemQty({ ...f, rawMeasure: dwgNum(f.rawMeasure), typicalCount: dwgNum(f.typicalCount) }, all);
  const finishHits = finishQuery.trim().length > 1 ? searchSupplierFinishes(null, null, finishQuery, 12) : [];
  const materials = (ctx.warehouseMaterials || []).filter(m => m.active !== false);

  function pickTool(id) {
    const t = tools.find(x => x.id === id);
    if (!t) { setF({ ...f, toolId: '', toolName: '' }); return; }
    // A tool carries the measurement type, unit and standard waste — that is
    // what makes it a tool rather than a label. A scope is suggested only when
    // the row has none, so re-picking a tool never silently moves the work.
    const family = DWG_FAMILY_FOR_TOOL_SCOPE[t.scope];
    const suggested = f.scopeId ? f.scopeId
      : ((project.scopes || []).find(s => s.familyName === family) || {}).id || '';
    setF({
      ...f, toolId: t.id, toolName: t.name, measurementType: t.measurementType,
      unit: t.unit, wastePct: t.wastePct, scopeId: suggested,
      finishRef: f.finishRef || t.finishRef || null, materialId: f.materialId || t.materialId || '',
    });
  }

  function save(branchTo) {
    const payload = {
      ...f,
      projectId: project.id,
      scopeId: f.scopeId || null, drawingSetId: (sheet && sheet.drawingSetId) || f.drawingSetId || null,
      sheetId: f.sheetId || null,
      sheetRevision: sheet ? (sheet.revision || '') : (f.sheetRevision || ''),
      rawMeasure: dwgNum(f.rawMeasure), wastePct: dwgNum(f.wastePct),
      typicalCount: Math.max(1, Math.round(dwgNum(f.typicalCount)) || 1),
      materialId: f.materialId || null,
      bidVersionId: branchTo || f.bidVersionId || null,
    };
    const where = sheet ? `sheet ${sheet.number}` : 'no sheet';
    dwgUpdate(ctx, project.id, draft => {
      if (item && !branchTo) {
        const t = draft.takeoffItems.find(x => x.id === item.id);
        if (!t) return;
        t.history = t.history || [];
        t.history.push({ id: uid('tohist'), date: todayISO(), by: ctx.currentUserName, was: { rawMeasure: t.rawMeasure, unit: t.unit, wastePct: t.wastePct, typicalCount: t.typicalCount, status: t.status } });
        Object.assign(t, payload, { updatedBy: ctx.currentUserName, updatedDate: todayISO() });
      } else {
        const rec = dwgMakeItem(payload, ctx.currentUserName);
        if (item && branchTo) rec.notes = `${rec.notes}${rec.notes ? ' — ' : ''}Branched from a locked version on ${todayISO()}.`.trim();
        draft.takeoffItems.push(rec);
      }
    }, item && !branchTo
      ? `Take-off: updated ${payload.toolName || 'takeoff'} on ${where} (${dwgFmtQty(payload.rawMeasure, payload.unit)}).`
      : `Take-off: recorded ${payload.toolName || 'takeoff'} ${dwgFmtQty(payload.rawMeasure, payload.unit)} from ${where}.`);
    onClose();
  }

  const valid = !!f.toolId && dwgNum(f.rawMeasure) !== 0;

  return (
    <Modal open={open} onClose={onClose} wide
      title={item ? 'Takeoff record' : (parent ? `Unit override — ${parent.unitType || parent.toolName}` : 'Record a takeoff')}
      footer={<>
        <Button variant="ghost" onClick={onClose}>Close</Button>
        {editable && editingLocked && !!openVersions.length && (
          <Button variant="outline" onClick={() => save(openVersions[0].id)}>Branch into “{openVersions[0].name}”</Button>
        )}
        {editable && !editingLocked && !lockedTarget && (
          <Button onClick={() => save(null)} disabled={!valid}>{item ? 'Save' : 'Record takeoff'}</Button>
        )}
      </>}>
      <div className="space-y-3">
        {editingLocked && (
          <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)] p-3 text-sm">
            This record belongs to <b>a locked bid version</b>. It cannot be edited in place — that would
            rewrite what was bid. Branch it into an open version instead and the Revisions tab will report
            the difference.
          </div>
        )}
        {parent && (
          <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/60 p-3 text-xs">
            Overrides one unit of the typical <b>{parent.unitType || parent.toolName}</b> ({dwgFmtQty(parent.rawMeasure, parent.unit)} × {parent.typicalCount}).
            Only the <b>difference</b> is added to the roll-up — the unit is already counted once in the typical,
            so it is never counted twice.
          </div>
        )}

        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Tool" hint="Sets the measurement type, unit and standard waste.">
            <Select value={f.toolId || ''} onChange={e => pickTool(e.target.value)} disabled={editingLocked}>
              <option value="">— pick a tool —</option>
              {DWG_TOOL_SCOPES.map(sc => {
                const list = tools.filter(t => t.scope === sc);
                if (!list.length) return null;
                return <optgroup key={sc} label={sc}>
                  {list.map(t => <option key={t.id} value={t.id}>{t.name} ({t.unit}{t.wastePct ? ` · ${t.wastePct}% waste` : ''}){t.project ? ' ·  project' : ''}</option>)}
                </optgroup>;
              })}
            </Select>
          </Field>
          <Field label="Scope">
            <Select value={f.scopeId || ''} onChange={e => setF({ ...f, scopeId: e.target.value })} disabled={editingLocked}>
              <option value="">— no scope yet —</option>
              {(project.scopes || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </Select>
          </Field>
          <Field label="Sheet" hint="Where the measurement came from. The sheet's current revision is stamped on the record.">
            <Select value={f.sheetId || ''} onChange={e => setF({ ...f, sheetId: e.target.value })} disabled={editingLocked}>
              <option value="">— no sheet —</option>
              {sheets.map(s => <option key={s.id} value={s.id}>{s.number} — {s.title || 'untitled'}{s.revision ? ` (Rev ${s.revision})` : ''}{dwgSheetCalibrated(s) ? '' : ' ⚠ no scale'}</option>)}
            </Select>
          </Field>
          <Field label="Bid version">
            <Select value={f.bidVersionId || ''} onChange={e => setF({ ...f, bidVersionId: e.target.value })} disabled={editingLocked}>
              <option value="">— none —</option>
              {versions.map(v => <option key={v.id} value={v.id} disabled={v.locked}>{v.name}{v.locked ? ' 🔒 locked' : ''}</option>)}
            </Select>
          </Field>
        </div>

        {f.sheetId && !dwgSheetCalibrated(sheet) && (
          <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-3 py-2 text-xs">
            <b>{sheet.number} has no scale on record.</b> This measurement will be reported as unverified
            everywhere it is totalled. Record the scale on the Sheets tab when you have checked it.
          </div>
        )}

        <div className="grid gap-3 md:grid-cols-4">
          <Field label="Measurement type">
            <Select value={f.measurementType} onChange={e => {
              const t = e.target.value;
              setF({ ...f, measurementType: t, unit: (DWG_UNITS_BY_TYPE[t] || [])[0] || f.unit });
            }} disabled={editingLocked}>
              {DWG_MEASURE_TYPES.map(t => <option key={t}>{t}</option>)}
            </Select>
          </Field>
          <Field label="Measured">
            <TextInput type="number" step="any" value={f.rawMeasure} disabled={editingLocked}
              onChange={e => setF({ ...f, rawMeasure: e.target.value })} />
          </Field>
          <Field label="Unit">
            <Select value={f.unit} onChange={e => setF({ ...f, unit: e.target.value })} disabled={editingLocked}>
              {units.map(u => <option key={u}>{u}</option>)}
            </Select>
          </Field>
          <Field label="Waste %">
            <TextInput type="number" step="any" min="0" value={f.wastePct} disabled={editingLocked}
              onChange={e => setF({ ...f, wastePct: e.target.value })} />
          </Field>
        </div>

        {!parent && (
          <div className="rounded-lg border border-[var(--leon-line)] p-3">
            <label className="flex items-center gap-2 text-sm font-semibold">
              <input type="checkbox" checked={!!f.typical} disabled={editingLocked}
                onChange={e => setF({ ...f, typical: e.target.checked })} />
              This is a typical — it stands for every unit of a type
            </label>
            {f.typical && (
              <div className="grid gap-3 md:grid-cols-2 mt-2">
                <Field label="Unit type" hint="e.g. Type A bathroom">
                  <TextInput value={f.unitType} onChange={e => setF({ ...f, unitType: e.target.value })} disabled={editingLocked} />
                </Field>
                <Field label="How many units">
                  <TextInput type="number" min="1" step="1" value={f.typicalCount} disabled={editingLocked}
                    onChange={e => setF({ ...f, typicalCount: e.target.value })} />
                </Field>
              </div>
            )}
          </div>
        )}

        <div className="grid gap-3 md:grid-cols-3">
          <Field label="Mark / code" hint="Used to match against existing records on handoff.">
            <TextInput value={f.code} onChange={e => setF({ ...f, code: e.target.value })} disabled={editingLocked} placeholder="D-101 / T-3" />
          </Field>
          <Field label="Building"><TextInput value={f.building} onChange={e => setF({ ...f, building: e.target.value })} disabled={editingLocked} /></Field>
          <Field label="Floor"><TextInput value={f.floor} onChange={e => setF({ ...f, floor: e.target.value })} disabled={editingLocked} /></Field>
          {!f.typical && <Field label="Unit type"><TextInput value={f.unitType} onChange={e => setF({ ...f, unitType: e.target.value })} disabled={editingLocked} /></Field>}
          <Field label="Unit no."><TextInput value={f.unitNo} onChange={e => setF({ ...f, unitNo: e.target.value })} disabled={editingLocked} placeholder="1204" /></Field>
          <Field label="Room"><TextInput value={f.room} onChange={e => setF({ ...f, room: e.target.value })} disabled={editingLocked} /></Field>
          <Field label="Surface"><TextInput value={f.surface} onChange={e => setF({ ...f, surface: e.target.value })} disabled={editingLocked} placeholder="Floor / Wall / Base" /></Field>
        </div>

        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Counts toward">
            <Select value={f.included} onChange={e => setF({ ...f, included: e.target.value })} disabled={editingLocked}>
              {DWG_INCLUSIONS.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
          <Field label="Status">
            <Select value={f.status} onChange={e => setF({ ...f, status: e.target.value })} disabled={editingLocked}>
              {DWG_ITEM_STATUSES.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
        </div>

        {/* The finish comes from the real supplier catalogs — a takeoff row
            points at the same record the Selection Hub does, never a copy. */}
        <Field label="Material / finish" hint="Searches the supplier catalogs — the same records selections use.">
          {f.finishRef ? (
            <div className="flex items-center gap-2 border border-[var(--leon-line)] rounded-lg px-2 py-1.5">
              {f.finishRef.img && <img src={f.finishRef.img} alt="" className="w-8 h-8 rounded object-cover" />}
              <span className="text-sm flex-1 min-w-0 truncate">
                <b>{f.finishRef.name}</b>
                <span className="text-[var(--leon-black)]/50"> · {f.finishRef.supLabel}{f.finishRef.code ? ` · ${f.finishRef.code}` : ''}</span>
              </span>
              {!editingLocked && <IconBtn title="Clear" onClick={() => setF({ ...f, finishRef: null })}>✕</IconBtn>}
            </div>
          ) : (
            <>
              <TextInput value={finishQuery} onChange={e => setFinishQuery(e.target.value)} disabled={editingLocked}
                placeholder="Search a decor name or supplier code…" />
              {!!finishHits.length && (
                <div className="mt-1 border border-[var(--leon-line)] rounded-lg max-h-40 overflow-y-auto">
                  {finishHits.map(r => (
                    <button key={`${r.sup}:${r.id}`} onClick={() => { setF({ ...f, finishRef: makeSupplierFinishRef(r) }); setFinishQuery(''); }}
                      className="w-full text-left px-2 py-1.5 text-xs hover:bg-[var(--leon-cream)] flex items-center gap-2 border-b border-[var(--leon-line)]/60 last:border-b-0">
                      {r.img ? <img src={r.img} alt="" className="w-6 h-6 rounded object-cover" /> : <span className="w-6 h-6 rounded bg-[var(--leon-line)]" />}
                      <span className="truncate"><b>{r.name}</b> <span className="text-[var(--leon-black)]/45">· {r.supLabel} · {r.cat}{r.code ? ` · ${r.code}` : ''}</span></span>
                    </button>
                  ))}
                </div>
              )}
            </>
          )}
        </Field>

        <Field label="Warehouse item" hint="Links this quantity to real stock, so the material requirement can net it off.">
          <Select value={f.materialId || ''} onChange={e => setF({ ...f, materialId: e.target.value })} disabled={editingLocked}>
            <option value="">— not linked to stock —</option>
            {materials.map(m => <option key={m.id} value={m.id}>{m.name}{m.itemId ? ` (${m.itemId})` : ''} — {dwgFmtQty(m.currentStock, m.unitOfMeasure)}</option>)}
          </Select>
        </Field>

        <Field label="Notes"><TextArea rows={2} value={f.notes} onChange={e => setF({ ...f, notes: e.target.value })} disabled={editingLocked} /></Field>

        {/* The arithmetic, shown while it is being entered — not only in the
            summary. A wrong occurrence count is caught here or not at all. */}
        <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm">
          <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">This record works out to</div>
          <div>
            {dwgFmtQty(preview.raw, f.unit)}
            {f.typical ? ` × ${preview.occ} unit${preview.occ === 1 ? '' : 's'} = ${dwgFmtQty(preview.base, f.unit)}` : ''}
            {preview.overrideCount ? `, overrides ${dwgSigned(preview.delta, f.unit)}` : ''}
            {' → net '}<b>{dwgFmtQty(preview.net, f.unit)}</b>
            {preview.waste ? <> {' + '}{preview.waste}% waste → procure <b>{dwgFmtQty(preview.procurement, f.unit)}</b></> : null}
          </div>
          {f.included !== 'Included' && <div className="text-xs text-[var(--leon-black)]/55 mt-1">Marked <b>{f.included}</b> — reported separately, not in the base total.</div>}
        </div>

        {item && !!(item.history || []).length && (
          <Collapsible id={`dwg-item-hist-${item.id}`} title="Change history" count={item.history.length}>
            <div className="space-y-1 text-xs">
              {item.history.slice().reverse().map(h => (
                <div key={h.id} className="text-[var(--leon-black)]/60">
                  {fmtDate(h.date)} · {h.by} — was {dwgFmtQty(h.was.rawMeasure, h.was.unit)}
                  {h.was.typicalCount > 1 ? ` ×${h.was.typicalCount}` : ''}, {h.was.wastePct}% waste, {h.was.status}
                </div>
              ))}
            </div>
          </Collapsible>
        )}
      </div>
    </Modal>
  );
}

// ── Tool chest ────────────────────────────────────────────────────────────
function DwgToolChestModal({ open, onClose, ctx, project, editable }) {
  const blank = { name: '', scope: 'Tile', measurementType: 'Area', unit: 'SF', wastePct: 10, color: '#3f7d78',
                  materialId: '', sfPerBox: '', subject: '', layer: '', labelTemplate: '', lineWeight: 2,
                  drawKind: '', countSymbol: '', depthValue: '', depthUnit: 'IN',
                  slopeMode: '', slopeValue: '', custom: {} };
  const [f, setF] = useState(blank);
  useEffect(() => { if (open) setF(blank); }, [open]);
  const projTools = dwgProjectTools(project);
  const materials = (ctx.warehouseMaterials || []).filter(m => m.active !== false);
  const cols = dwgColumns(project).filter(c => c.type !== 'Formula');

  function add() {
    if (!f.name.trim()) return;
    dwgUpdate(ctx, project.id, draft => {
      draft.takeoffTools.push(dwgMakeTool({
        ...f,
        materialId: f.materialId || null,
        sfPerBox: f.sfPerBox === '' ? null : Number(f.sfPerBox),
        // A tool with no explicit subject IS its own subject — the name is what
        // the estimator already calls it, and forcing them to type it twice just
        // produces two spellings of the same thing.
        subject: (f.subject || f.name).trim(),
        drawKind: f.drawKind || undefined,
        depthValue: f.depthValue === '' ? null : Number(f.depthValue),
        slopeValue: f.slopeValue === '' ? null : Number(f.slopeValue),
      }, ctx.currentUserName));
    }, `Take-off: added project tool "${f.name.trim()}".`);
    setF(blank);
  }
  function retire(t) {
    dwgUpdate(ctx, project.id, draft => {
      const x = draft.takeoffTools.find(y => y.id === t.id);
      if (x) x.active = false;      // soft, so the records that used it still read
    }, `Take-off: retired project tool "${t.name}".`);
  }

  return (
    <Modal open={open} onClose={onClose} wide title="Takeoff tool chest" footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
      <div className="space-y-4">
        <DwgNote>
          A tool is a way of measuring: a unit, a standard waste and the LEON scope it belongs to. The
          shared tools below are company standards and live in code; a <b>project tool</b> is added here,
          persists on this job, and can point at real approved material so the requirement nets against
          actual stock.
        </DwgNote>

        <Collapsible id="dwg-tools-global" title="Shared tools" count={DWG_GLOBAL_TOOLS.length} defaultOpen>
          {DWG_TOOL_SCOPES.map(sc => {
            const list = DWG_GLOBAL_TOOLS.filter(t => t.scope === sc);
            if (!list.length) return null;
            return (
              <div key={sc} className="mb-2">
                <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">{sc}</div>
                <div className="flex flex-wrap gap-1.5">
                  {list.map(t => (
                    <span key={t.id} className="inline-flex items-center gap-1.5 text-xs border border-[var(--leon-line)] rounded-lg px-2 py-1">
                      <span className="w-2.5 h-2.5 rounded-full" style={{ background: t.color }} />
                      {t.name} <span className="text-[var(--leon-black)]/45">{t.unit}{t.wastePct ? ` · ${t.wastePct}%` : ''}</span>
                    </span>
                  ))}
                </div>
              </div>
            );
          })}
        </Collapsible>

        <Collapsible id={`dwg-tools-project-${project.id}`} title="Project tools" count={projTools.filter(t => t.active !== false).length} defaultOpen>
          {!projTools.filter(t => t.active !== false).length
            ? <EmptyState text="No project tools yet." />
            : (
              <div className="space-y-1.5">
                {projTools.filter(t => t.active !== false).map(t => {
                  const mat = materials.find(m => m.id === t.materialId);
                  return (
                    <div key={t.id} className="flex items-center gap-2 flex-wrap border border-[var(--leon-line)] rounded-lg px-3 py-2">
                      <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: t.color }} />
                      <span className="font-semibold text-sm flex-1 min-w-[8rem]">{t.name}</span>
                      <span className="text-xs text-[var(--leon-black)]/50">
                        {t.scope} · {t.measurementType} · {t.unit}{t.wastePct ? ` · ${t.wastePct}% waste` : ''}
                        {t.sfPerBox ? ` · ${t.sfPerBox} ${t.unit}/box` : ''}
                        {mat ? ` · ${mat.name}` : ''}
                      </span>
                      <span className="flex items-center gap-1 flex-wrap">
                        <Badge tone="neutral">Subject: {t.subject || t.name}</Badge>
                        {t.layer ? <Badge tone="neutral">Layer: {t.layer}</Badge> : null}
                        {t.countSymbol ? <Badge tone="neutral">pin “{t.countSymbol}”</Badge> : null}
                        {t.depthValue ? <Badge tone="neutral">depth {t.depthValue} {t.depthUnit}</Badge> : null}
                        {t.slopeMode && t.slopeValue !== null ? <Badge tone="neutral">slope {t.slopeValue} {t.slopeMode}</Badge> : null}
                        {Object.keys(t.custom || {}).length ? <Badge tone="neutral">{Object.keys(t.custom).length} column default{Object.keys(t.custom).length === 1 ? '' : 's'}</Badge> : null}
                      </span>
                      {editable && <IconBtn title="Retire this tool" onClick={() => retire(t)}>✕</IconBtn>}
                    </div>
                  );
                })}
              </div>
            )}
          {editable && (
            <div className="border-t border-[var(--leon-line)] mt-3 pt-3 space-y-3">
              <div className="grid gap-3 md:grid-cols-2">
                <Field label="Name"><TextInput value={f.name} onChange={e => setF({ ...f, name: e.target.value })} placeholder="Lobby feature wall tile" /></Field>
                <Field label="LEON scope">
                  <Select value={f.scope} onChange={e => setF({ ...f, scope: e.target.value })}>
                    {DWG_TOOL_SCOPES.map(s => <option key={s}>{s}</option>)}
                  </Select>
                </Field>
              </div>
              <div className="grid gap-3 md:grid-cols-4">
                <Field label="Type">
                  <Select value={f.measurementType} onChange={e => setF({ ...f, measurementType: e.target.value, unit: (DWG_UNITS_BY_TYPE[e.target.value] || [])[0] })}>
                    {DWG_MEASURE_TYPES.map(t => <option key={t}>{t}</option>)}
                  </Select>
                </Field>
                <Field label="Unit">
                  <Select value={f.unit} onChange={e => setF({ ...f, unit: e.target.value })}>
                    {(DWG_UNITS_BY_TYPE[f.measurementType] || []).map(u => <option key={u}>{u}</option>)}
                  </Select>
                </Field>
                <Field label="Waste %"><TextInput type="number" step="any" value={f.wastePct} onChange={e => setF({ ...f, wastePct: e.target.value })} /></Field>
                <Field label="Per box" hint="Blank if not boxed."><TextInput type="number" step="any" value={f.sfPerBox} onChange={e => setF({ ...f, sfPerBox: e.target.value })} /></Field>
              </div>
              <Field label="Approved material" hint="Points at a real warehouse item so the requirement can net off stock.">
                <Select value={f.materialId} onChange={e => setF({ ...f, materialId: e.target.value })}>
                  <option value="">— none —</option>
                  {materials.map(m => <option key={m.id} value={m.id}>{m.name}{m.itemId ? ` (${m.itemId})` : ''}</option>)}
                </Select>
              </Field>

              {/* ── what the tool STAMPS ──────────────────────────────────
                  This half is the reason a tool chest exists at all. Two
                  estimators who pick the same tool produce comparable take-offs
                  without having agreed anything first, because the subject, the
                  layer, the colour, the depth and the column defaults arrive
                  with the tool rather than being typed per markup. */}
              <div className="border-t border-[var(--leon-line)] pt-3 space-y-3">
                <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">
                  What it stamps on every markup it places
                </div>
                <div className="grid gap-3 md:grid-cols-3">
                  <Field label="Subject" hint="Blank uses the tool's name. This is the main grouping axis.">
                    <TextInput value={f.subject} onChange={e => setF({ ...f, subject: e.target.value })} placeholder={f.name || 'the tool’s name'} />
                  </Field>
                  <Field label="Layer" hint="Which overlay — base bid, an alternate, an addendum.">
                    <TextInput value={f.layer} onChange={e => setF({ ...f, layer: e.target.value })} placeholder="Base bid" />
                  </Field>
                  <Field label="Label" hint="A starting label on each markup; edit it per markup afterwards.">
                    <TextInput value={f.labelTemplate} onChange={e => setF({ ...f, labelTemplate: e.target.value })} />
                  </Field>
                </div>
                <div className="grid gap-3 md:grid-cols-4">
                  <Field label="Draws as" hint="Create Count is not a separate thing: a tool that draws as a count IS a count tool.">
                    <Select value={f.drawKind} onChange={e => setF({ ...f, drawKind: e.target.value })}>
                      <option value="">follow the type</option>
                      {DWG_DRAW_TOOLS.filter(t => ['distance', 'area', 'rect', 'count'].indexOf(t.key) >= 0)
                        .map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
                    </Select>
                  </Field>
                  <Field label="Count symbol" hint="One or two characters drawn in the pin instead of its number.">
                    <TextInput value={f.countSymbol} maxLength={2}
                      onChange={e => setF({ ...f, countSymbol: e.target.value })} placeholder="D · ◆ · 1" />
                  </Field>
                  <Field label="Default depth" hint="Turns an area into a volume and a length into a wall area.">
                    <div className="flex gap-1">
                      <TextInput type="number" step="any" value={f.depthValue} onChange={e => setF({ ...f, depthValue: e.target.value })} />
                      <Select className="!w-20" value={f.depthUnit} onChange={e => setF({ ...f, depthUnit: e.target.value })}>
                        {DWG_DEPTH_UNITS.map(u => <option key={u}>{u}</option>)}
                      </Select>
                    </div>
                  </Field>
                  <Field label="Default slope">
                    <div className="flex gap-1">
                      <Select className="!w-24" value={f.slopeMode} onChange={e => setF({ ...f, slopeMode: e.target.value })}>
                        <option value="">flat</option>
                        {DWG_SLOPE_MODES.map(s => <option key={s}>{s}</option>)}
                      </Select>
                      <TextInput type="number" step="any" disabled={!f.slopeMode} value={f.slopeValue}
                        onChange={e => setF({ ...f, slopeValue: e.target.value })} />
                    </div>
                  </Field>
                </div>
                {!!cols.length && (
                  <div className="space-y-2">
                    <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Custom column defaults</div>
                    {cols.map(c => (
                      <div key={c.id} className="grid gap-2 md:grid-cols-[10rem_1fr] items-center">
                        <span className="text-xs font-semibold">{c.name}</span>
                        <DwgColumnInput col={c} subject={f.subject || f.name} value={(f.custom || {})[c.id]}
                          onChange={val => setF({ ...f, custom: { ...(f.custom || {}), [c.id]: val } })} />
                      </div>
                    ))}
                  </div>
                )}
              </div>

              <div className="flex items-center gap-2">
                <input type="color" value={f.color} onChange={e => setF({ ...f, color: e.target.value })} className="w-10 h-8 rounded border border-[var(--leon-line)]" />
                <Button size="sm" onClick={add} disabled={!f.name.trim()}>+ Add project tool</Button>
              </div>
            </div>
          )}
        </Collapsible>
      </div>
    </Modal>
  );
}

// ── Quantity Summary ──────────────────────────────────────────────────────
// Every total on this screen opens. That is the requirement and it is also the
// design: dwgRollUp keeps each group's member rows attached to the number, so a
// total literally cannot be rendered without the records that produced it.
const DWG_GROUPINGS = [
  { key: 'tool', label: 'Tool' },
  { key: 'scope', label: 'Scope' },
  { key: 'finish', label: 'Material / finish' },
  { key: 'unitType', label: 'Unit type' },
  { key: 'room', label: 'Room' },
  { key: 'floor', label: 'Building / floor' },
];

function DwgQuantitySummary({ ctx, project, versionId, editable, onOpenSheet }) {
  const [groupBy, setGroupBy] = useState('tool');
  const [inclusion, setInclusion] = useState('Included');
  const [drill, setDrill] = useState(null);
  const all = dwgItemList(project);

  const pool = all.filter(i => dwgItemLive(i)
    && (!versionId || i.bidVersionId === versionId)
    && (inclusion === 'All' || i.included === inclusion));

  const keyOf = i => groupBy === 'tool' ? (i.toolId || i.toolName || 'none')
    : groupBy === 'scope' ? (i.scopeId || 'none')
    : groupBy === 'finish' ? (i.finishRef ? `${i.finishRef.source}:${i.finishRef.id}` : 'none')
    : groupBy === 'unitType' ? (i.unitType || 'none')
    : groupBy === 'room' ? (i.room || 'none')
    : `${i.building || '—'} / ${i.floor || '—'}`;
  const labelOf = i => groupBy === 'tool' ? (i.toolName || 'No tool')
    : groupBy === 'scope' ? ((dwgScopeById(project, i.scopeId) || {}).name || 'No scope')
    : groupBy === 'finish' ? (i.finishRef ? `${i.finishRef.name} (${i.finishRef.supLabel})` : 'No finish chosen')
    : groupBy === 'unitType' ? (i.unitType || 'No unit type')
    : groupBy === 'room' ? (i.room || 'No room')
    : `${i.building || '—'} / ${i.floor || '—'}`;

  const groups = dwgRollUp(pool, keyOf, labelOf).map(g => ({
    ...g,
    uncalibrated: g.items.filter(x => !dwgSheetCalibrated(dwgSheetById(project, x.item.sheetId))).length,
    unapproved: g.items.filter(x => x.item.status !== 'Approved').length,
  }));

  const buckets = DWG_INCLUSIONS.map(k => ({ k, n: all.filter(i => dwgItemLive(i) && (!versionId || i.bidVersionId === versionId) && i.included === k).length }))
    .filter(b => b.n);

  return (
    <div className="space-y-4">
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Group by">
          <Select className="!w-48" value={groupBy} onChange={e => { setGroupBy(e.target.value); setDrill(null); }}>
            {DWG_GROUPINGS.map(g => <option key={g.key} value={g.key}>{g.label}</option>)}
          </Select>
        </Field>
        <div className="flex gap-1 flex-wrap items-center pb-1">
          <DwgChip active={inclusion === 'All'} onClick={() => setInclusion('All')}>All</DwgChip>
          {buckets.map(b => <DwgChip key={b.k} active={inclusion === b.k} onClick={() => setInclusion(b.k)} count={b.n}>{b.k}</DwgChip>)}
        </div>
      </div>

      {inclusion !== 'All' && inclusion !== 'Included' && (
        <DwgNote>Showing <b>{inclusion}</b> only. These are deliberately kept out of the base total — an allowance and an alternate are on the drawings but are not what the bid carries.</DwgNote>
      )}

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[820px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <th className="px-3 py-2">{(DWG_GROUPINGS.find(g => g.key === groupBy) || {}).label}</th>
              <th className="px-3 py-2 text-right">Net</th>
              <th className="px-3 py-2 text-right">With waste</th>
              <th className="px-3 py-2 text-right">Records</th>
              <th className="px-3 py-2">Confidence</th>
              <th className="px-3 py-2 w-24"></th>
            </tr>
          </thead>
          <tbody>
            {groups.map(g => (
              <React.Fragment key={g.key}>
                <tr className={`border-b border-[var(--leon-line)]/60 ${drill === g.key ? 'bg-[var(--leon-cream)]/60' : ''}`}>
                  <td className="px-3 py-2 font-semibold">{g.label}</td>
                  <td className="px-3 py-2 text-right font-bold text-base">{dwgFmtQty(g.net, g.unit)}</td>
                  <td className="px-3 py-2 text-right text-[var(--leon-brown)] font-semibold">{dwgFmtQty(g.procurement, g.unit)}</td>
                  <td className="px-3 py-2 text-right">{g.items.length}</td>
                  <td className="px-3 py-2">
                    {g.uncalibrated
                      ? <Badge tone="red">{g.uncalibrated} from uncalibrated sheets</Badge>
                      : <Badge tone="green">all sheets calibrated</Badge>}
                    {!!g.unapproved && <span className="ml-1 text-[10px] text-[var(--leon-black)]/45">{g.unapproved} not yet approved</span>}
                  </td>
                  <td className="px-3 py-2 text-right">
                    <Button size="sm" variant="ghost" onClick={() => setDrill(drill === g.key ? null : g.key)}>
                      {drill === g.key ? 'Hide sources' : 'Show sources'}
                    </Button>
                  </td>
                </tr>
                {drill === g.key && (
                  <tr><td colSpan={6} className="px-3 py-3 bg-[var(--leon-cream)]/40 border-b border-[var(--leon-line)]">
                    <DwgSources ctx={ctx} project={project} group={g} onOpenSheet={onOpenSheet} />
                  </td></tr>
                )}
              </React.Fragment>
            ))}
            {!groups.length && <tr><td colSpan={6} className="px-3 py-6 text-center text-[var(--leon-black)]/40">No quantities for this filter.</td></tr>}
          </tbody>
        </table>
      </div>

      <DwgMaterialRequirement ctx={ctx} project={project} versionId={versionId} editable={editable} />
    </div>
  );
}

// The drill-down. Each line shows the multiplier arithmetic in full, the sheet
// it came off with its scale, who entered it and when.
function DwgSources({ ctx, project, group, onOpenSheet }) {
  return (
    <div className="space-y-1.5">
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">
        {group.items.length} record{group.items.length === 1 ? '' : 's'} behind {dwgFmtQty(group.net, group.unit)}
      </div>
      {group.items.map(({ item, q }) => {
        const sh = dwgSheetById(project, item.sheetId);
        const sc = dwgScopeById(project, item.scopeId);
        return (
          <div key={item.id} className="rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2">
            <div className="flex items-start justify-between gap-2 flex-wrap">
              <div className="min-w-0">
                <div className="text-xs font-semibold">{dwgMultiplierSentence(item, q)}</div>
                <div className="text-[11px] text-[var(--leon-black)]/50 mt-0.5">
                  {sh ? (
                    <button className="text-[var(--leon-brown)] font-semibold hover:underline" onClick={() => onOpenSheet(sh.id)}>
                      {sh.number}
                    </button>
                  ) : <span>no sheet</span>}
                  {sh ? <> · Rev taken {item.sheetRevision || '—'}{sh.revision && item.sheetRevision !== sh.revision ? ` (sheet is now ${sh.revision})` : ''} · </> : ' · '}
                  {sc ? sc.name : 'no scope'} · {item.included} · {item.status}
                  {' · '}{item.createdBy}{item.updatedDate !== item.createdDate ? ` (updated ${fmtDate(item.updatedDate)})` : ` ${fmtDate(item.createdDate)}`}
                </div>
                {item.notes && <div className="text-[11px] text-[var(--leon-black)]/45 mt-0.5">{item.notes}</div>}
              </div>
              <div className="shrink-0 text-right">
                <DwgScaleFlag sheet={sh} />
                {!dwgSheetCalibrated(sh) && <div className="text-[10px] text-[var(--leon-red)]">quantity unverified</div>}
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Material requirement ──────────────────────────────────────────────────
// net → waste → procurement → packaging → minus stock on hand → what would have
// to be bought. It stops there on purpose: this produces a REQUIREMENT. Turning
// one into a purchase order is a commitment of company money and belongs in the
// Procurement Hub, where the approval chain already lives.
function DwgMaterialRequirement({ ctx, project, versionId, editable }) {
  const all = dwgItemList(project);
  const pool = all.filter(i => dwgItemLive(i) && i.included === 'Included' && (!versionId || i.bidVersionId === versionId));
  const packaging = (project && project.takeoffPackaging) || {};
  const materials = (ctx.warehouseMaterials || []).filter(m => m.active !== false);

  const groups = dwgRollUp(pool, i => i.toolId || i.toolName || 'none', i => i.toolName || 'No tool');

  function setPack(toolId, fields) {
    dwgUpdate(ctx, project.id, draft => {
      if (!draft.takeoffPackaging) draft.takeoffPackaging = {};
      draft.takeoffPackaging[toolId] = { ...(draft.takeoffPackaging[toolId] || {}), ...fields };
    });
  }

  return (
    <Collapsible id={`dwg-req-${project.id}`} title="Material requirement" count={groups.length}>
      <DwgNote>
        This is a <b>requirement</b>, not a purchase order. It nets the take-off against stock actually on
        hand in the warehouse and stops there — committing the spend is the Procurement Hub's job, where
        the approval chain lives.
      </DwgNote>
      {!groups.length ? <EmptyState text="Nothing included in the base total yet." /> : (
        <div className="overflow-x-auto mt-2">
          <table className="w-full text-xs min-w-[980px]">
            <thead>
              <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                <th className="px-2 py-2">Tool</th><th className="px-2 py-2 text-right">Net</th>
                <th className="px-2 py-2 text-right">Waste</th><th className="px-2 py-2 text-right">Procure</th>
                <th className="px-2 py-2 text-right">Per box</th><th className="px-2 py-2 text-right">Boxes</th>
                <th className="px-2 py-2">Warehouse item</th><th className="px-2 py-2 text-right">On hand</th>
                <th className="px-2 py-2 text-right">To buy</th>
              </tr>
            </thead>
            <tbody>
              {groups.map(g => {
                const first = g.items[0].item;
                const toolId = first.toolId || first.toolName;
                const tool = dwgToolById(project, first.toolId);
                const pk = packaging[toolId] || {};
                const perBox = pk.perBox !== undefined && pk.perBox !== null && pk.perBox !== ''
                  ? Number(pk.perBox) : (tool && tool.sfPerBox ? Number(tool.sfPerBox) : null);
                // Stock link order: an explicit choice here, then the tool's
                // approved material, then whatever the rows themselves point at.
                const matId = pk.materialId || (tool && tool.materialId) || (g.items.find(x => x.item.materialId) || { item: {} }).item.materialId || '';
                const mat = materials.find(m => m.id === matId) || null;
                const onHand = mat ? dwgNum(mat.currentStock) : 0;
                const boxes = perBox ? Math.ceil(g.procurement / perBox) : null;
                const toBuy = Math.max(0, g.procurement - onHand);
                const wasteQty = g.procurement - g.net;
                return (
                  <tr key={g.key} className="border-b border-[var(--leon-line)]/60">
                    <td className="px-2 py-1.5 font-semibold">{g.label}</td>
                    <td className="px-2 py-1.5 text-right">{dwgFmtQty(g.net, g.unit)}</td>
                    <td className="px-2 py-1.5 text-right text-[var(--leon-black)]/55">+{dwgFmtQty(wasteQty, g.unit)}</td>
                    <td className="px-2 py-1.5 text-right font-bold">{dwgFmtQty(g.procurement, g.unit)}</td>
                    <td className="px-2 py-1.5 text-right">
                      <input type="number" step="any" min="0" value={perBox === null ? '' : perBox} disabled={!editable}
                        onChange={e => setPack(toolId, { perBox: e.target.value })} placeholder="—"
                        className="w-16 px-1 py-0.5 text-right border border-[var(--leon-line)] rounded bg-white" />
                    </td>
                    <td className="px-2 py-1.5 text-right font-semibold">{boxes === null ? <span className="text-[var(--leon-black)]/30">not boxed</span> : boxes}</td>
                    <td className="px-2 py-1.5">
                      <select value={matId} disabled={!editable} onChange={e => setPack(toolId, { materialId: e.target.value || null })}
                        className="w-44 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                        <option value="">— no stock link —</option>
                        {materials.map(m => <option key={m.id} value={m.id}>{m.name}{m.itemId ? ` (${m.itemId})` : ''}</option>)}
                      </select>
                    </td>
                    <td className="px-2 py-1.5 text-right">
                      {mat ? dwgFmtQty(onHand, mat.unitOfMeasure) : <span className="text-[var(--leon-black)]/30">—</span>}
                      {mat && mat.unitOfMeasure && mat.unitOfMeasure !== g.unit &&
                        <span className="block text-[9px] text-[var(--leon-red)]" title="Stock is counted in a different unit — the netting below assumes they are comparable.">unit differs</span>}
                    </td>
                    <td className="px-2 py-1.5 text-right font-bold text-[var(--leon-brown)]">{dwgFmtQty(toBuy, g.unit)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      )}
    </Collapsible>
  );
}

// ── Scope completion matrix ───────────────────────────────────────────────
// Sheet × scope. This is the QC tool: it answers "has anyone actually read
// A-204 for baseboard?", which no quantity total can tell you — a scope with
// zero takeoffs looks identical whether it was checked and had none, or was
// never opened. The cell is the record of that decision.
const DWG_CELL_CYCLE = ['Not Started', 'In Progress', 'Needs Review', 'Complete'];
const DWG_CELL_STYLE = {
  'Not Started': 'bg-white text-[var(--leon-black)]/30',
  'In Progress': 'bg-[#fbf1dd] text-[#a67b1f]',
  'Needs Review': 'bg-[#e3edfa] text-[#2563a8]',
  'Complete': 'bg-[#e7f3e9] text-[#3a7d44]',
};

const DWG_CHECKLIST_BASE = [
  'Every relevant sheet identified and filed to a set',
  'Scale recorded on every sheet used',
  'Quantities entered for all areas in scope',
  'Typicals confirmed against the real unit mix',
  'Unit overrides captured where a unit differs',
  'Alternates, allowances and by-others separated out',
  'Waste percentages agreed with production',
  'Second person reviewed the quantities',
  'Frozen into a bid version',
];
const DWG_CHECKLIST_EXTRA = {
  'Tile': ['Wall and floor separated', 'Trim, schluter and transitions measured'],
  'Countertop': ['Edge profile length measured separately from area', 'Splash measured separately'],
  'Doors': ['Leaf count reconciled against the door schedule', 'Frames counted separately from leaves'],
  'Casework': ['Linear run and cabinet count both taken', 'Panel and filler areas included'],
  'Carpet': ['Roll width and seam direction noted'],
  'Baseboards / Trims': ['Corner count taken', 'Door openings deducted'],
};

function DwgScopeMatrix({ ctx, project, editable, onOpenSheet }) {
  const sheets = dwgSheetList(project).slice()
    .filter(s => s.status !== 'Void')
    .sort((a, b) => (a.number || '').localeCompare(b.number || '', undefined, { numeric: true }));
  const scopes = project.scopes || [];
  const items = dwgItemList(project);

  function setCell(sheetId, scopeId, value) {
    dwgUpdate(ctx, project.id, draft => {
      const s = draft.drawingSheets.find(x => x.id === sheetId);
      if (!s) return;
      if (!s.scopeStatus) s.scopeStatus = {};
      s.scopeStatus[scopeId] = value;
    }, `Take-off: ${(dwgSheetById(project, sheetId) || {}).number} × ${(dwgScopeById(project, scopeId) || {}).name} → ${value}.`);
  }

  if (!sheets.length || !scopes.length) {
    return <EmptyState text={!scopes.length ? 'This job has no scopes yet.' : 'No sheets indexed yet.'} />;
  }

  const totals = scopes.map(sc => {
    const done = sheets.filter(s => ((s.scopeStatus || {})[sc.id] || 'Not Started') === 'Complete').length;
    return { sc, done, pct: Math.round(done / sheets.length * 100) };
  });

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2 flex-wrap text-xs">
        <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">Legend</span>
        {DWG_CELL_CYCLE.map(c => (
          <span key={c} className={`px-2 py-0.5 rounded border border-[var(--leon-line)] ${DWG_CELL_STYLE[c]}`}>{c}</span>
        ))}
        {editable && <span className="text-[var(--leon-black)]/45">— click a cell to advance it</span>}
      </div>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs">
          <thead>
            <tr className="border-b border-[var(--leon-line)]">
              <th className="px-2 py-2 text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 sticky left-0 bg-white">Sheet</th>
              {scopes.map(sc => (
                <th key={sc.id} className="px-2 py-2 text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 whitespace-nowrap">{sc.name}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {sheets.map(s => (
              <tr key={s.id} className="border-b border-[var(--leon-line)]/60">
                <td className="px-2 py-1 sticky left-0 bg-white whitespace-nowrap">
                  <button className="font-bold text-[var(--leon-brown)] hover:underline" onClick={() => onOpenSheet(s.id)}>{s.number}</button>
                  {' '}<DwgScaleFlag sheet={s} short />
                  <span className="block text-[10px] text-[var(--leon-black)]/40 max-w-[12rem] truncate">{s.title}</span>
                </td>
                {scopes.map(sc => {
                  const v = (s.scopeStatus || {})[sc.id] || 'Not Started';
                  const n = items.filter(i => i.sheetId === s.id && i.scopeId === sc.id && dwgItemLive(i)).length;
                  return (
                    <td key={sc.id} className="px-1 py-1">
                      <button disabled={!editable}
                        onClick={() => setCell(s.id, sc.id, DWG_CELL_CYCLE[(DWG_CELL_CYCLE.indexOf(v) + 1) % DWG_CELL_CYCLE.length])}
                        title={`${s.number} × ${sc.name} — ${v}${n ? ` · ${n} record${n === 1 ? '' : 's'}` : ' · no records'}`}
                        className={`w-full px-2 py-1 rounded border border-[var(--leon-line)] text-[10px] font-semibold ${DWG_CELL_STYLE[v]} ${editable ? 'hover:border-[var(--leon-brown)]' : ''}`}>
                        {v === 'Not Started' ? '—' : v}{n ? <span className="opacity-60"> · {n}</span> : ''}
                      </button>
                    </td>
                  );
                })}
              </tr>
            ))}
            <tr className="bg-[var(--leon-cream)]/60">
              <td className="px-2 py-2 font-bold sticky left-0 bg-[var(--leon-cream)]">Complete</td>
              {totals.map(t => (
                <td key={t.sc.id} className="px-2 py-2 text-[11px] font-semibold">{t.done}/{sheets.length} · {t.pct}%</td>
              ))}
            </tr>
          </tbody>
        </table>
      </div>

      <div className="grid gap-3 md:grid-cols-2">
        {scopes.map(sc => <DwgScopeChecklist key={sc.id} ctx={ctx} project={project} scope={sc} editable={editable} />)}
      </div>
    </div>
  );
}

function DwgScopeChecklist({ ctx, project, scope, editable }) {
  const [text, setText] = useState('');
  const lists = dwgChecklists(project);
  const list = lists[scope.id] || null;

  function start() {
    const labels = DWG_CHECKLIST_BASE.concat(DWG_CHECKLIST_EXTRA[scope.familyName] || []);
    dwgUpdate(ctx, project.id, draft => {
      draft.takeoffChecklists[scope.id] = labels.map(l => ({ id: uid('tochk'), label: l, done: false, doneBy: null, doneDate: null }));
    }, `Take-off: started the takeoff checklist for ${scope.name}.`);
  }
  function toggle(id) {
    dwgUpdate(ctx, project.id, draft => {
      const arr = draft.takeoffChecklists[scope.id] || [];
      const it = arr.find(x => x.id === id);
      if (!it) return;
      it.done = !it.done;
      it.doneBy = it.done ? ctx.currentUserName : null;
      it.doneDate = it.done ? todayISO() : null;
    });
  }
  function add() {
    if (!text.trim()) return;
    dwgUpdate(ctx, project.id, draft => {
      if (!draft.takeoffChecklists[scope.id]) draft.takeoffChecklists[scope.id] = [];
      draft.takeoffChecklists[scope.id].push({ id: uid('tochk'), label: text.trim(), done: false, doneBy: null, doneDate: null });
    });
    setText('');
  }

  const done = (list || []).filter(x => x.done).length;
  return (
    <Collapsible id={`dwg-chk-${scope.id}`} title={`${scope.name} — takeoff checklist`} count={list ? `${done}/${list.length}` : 0}>
      {!list ? (
        <div className="space-y-2">
          <p className="text-xs text-[var(--leon-black)]/55">
            The standard list for {scope.familyName || 'this scope'}, plus anything this job needs on top.
          </p>
          {editable && <Button size="sm" onClick={start}>Start the standard checklist</Button>}
        </div>
      ) : (
        <div className="space-y-1">
          {list.map(it => (
            <label key={it.id} className="flex items-start gap-2 text-xs">
              <input type="checkbox" checked={it.done} disabled={!editable} onChange={() => toggle(it.id)} className="mt-0.5" />
              <span className={it.done ? 'line-through text-[var(--leon-black)]/40' : ''}>
                {it.label}
                {it.done && <span className="ml-1 text-[10px] text-[var(--leon-black)]/35">{it.doneBy} · {fmtDate(it.doneDate)}</span>}
              </span>
            </label>
          ))}
          {editable && (
            <div className="flex items-end gap-2 pt-2">
              <TextInput className="!text-xs" value={text} onChange={e => setText(e.target.value)} placeholder="Add a check…" />
              <Button size="sm" variant="ghost" onClick={add} disabled={!text.trim()}>Add</Button>
            </div>
          )}
        </div>
      )}
    </Collapsible>
  );
}

// ── Revision impact ───────────────────────────────────────────────────────
// A new set does not invalidate a takeoff — it makes it a QUESTION. The map
// says which sheets changed; the queue makes someone answer, per record, what
// the change did to the number. Silently keeping the old quantity and silently
// deleting it are both wrong, which is why there are four explicit answers.
function dwgBuildRevisionRows(project, newSetId, oldSetId) {
  const sheets = dwgSheetList(project);
  const nw = sheets.filter(s => s.drawingSetId === newSetId);
  const od = sheets.filter(s => s.drawingSetId === oldSetId);
  const byNum = list => { const m = {}; list.forEach(s => { m[(s.number || '').toLowerCase()] = s; }); return m; };
  const nMap = byNum(nw), oMap = byNum(od);
  const rows = [];
  Object.keys(nMap).forEach(k => {
    const n = nMap[k], o = oMap[k];
    if (!o) rows.push({ state: 'New', number: n.number, title: n.title, newSheetId: n.id, oldSheetId: null, newRevision: n.revision, oldRevision: null });
    else if ((n.revision || '') !== (o.revision || '')) rows.push({ state: 'Revised', number: n.number, title: n.title, newSheetId: n.id, oldSheetId: o.id, newRevision: n.revision, oldRevision: o.revision });
    else rows.push({ state: 'Unchanged', number: n.number, title: n.title, newSheetId: n.id, oldSheetId: o.id, newRevision: n.revision, oldRevision: o.revision });
  });
  Object.keys(oMap).forEach(k => {
    if (!nMap[k]) { const o = oMap[k]; rows.push({ state: 'Removed', number: o.number, title: o.title, newSheetId: null, oldSheetId: o.id, newRevision: null, oldRevision: o.revision }); }
  });
  return rows.sort((a, b) => (a.number || '').localeCompare(b.number || '', undefined, { numeric: true }));
}

// The quantity snapshot a change report is measured against. Taken at the
// moment a map is applied, because "what did this revision cost us" is only
// answerable against what the numbers were immediately before it.
function dwgQuantitySnapshot(project) {
  const all = dwgItemList(project).filter(i => dwgItemLive(i) && i.included === 'Included');
  const out = {};
  all.filter(i => !i.overrideOfId).forEach(i => {
    const k = `${i.toolName || 'No tool'}|${i.unit}`;
    out[k] = (out[k] || 0) + dwgItemQty(i, all).net;
  });
  return out;
}

function DwgRevisions({ ctx, project, editable, onOpenSheet }) {
  const [newSetId, setNewSetId] = useState('');
  const [oldSetId, setOldSetId] = useState('');
  const [cmpA, setCmpA] = useState('');
  const [cmpB, setCmpB] = useState('');
  const sets = dwgSets(project);
  const maps = dwgMaps(project).slice().reverse();
  const all = dwgItemList(project);
  const queue = all.filter(i => i.status === 'Revision Review Required');
  const preview = newSetId && oldSetId && newSetId !== oldSetId ? dwgBuildRevisionRows(project, newSetId, oldSetId) : [];
  const affectedPreview = preview.filter(r => r.state === 'Revised' || r.state === 'Removed')
    .flatMap(r => all.filter(i => i.sheetId === r.oldSheetId && dwgItemLive(i)));

  function apply() {
    const rows = dwgBuildRevisionRows(project, newSetId, oldSetId);
    const mapId = uid('revmap');
    const snapshot = dwgQuantitySnapshot(project);
    const flaggedIds = [];
    dwgUpdate(ctx, project.id, draft => {
      rows.forEach(r => {
        if (r.state === 'Revised' || r.state === 'Removed') {
          const os = draft.drawingSheets.find(x => x.id === r.oldSheetId);
          if (os) os.status = 'Superseded';
          draft.takeoffItems.forEach(i => {
            if (i.sheetId === r.oldSheetId && i.active !== false && i.status !== 'Superseded') {
              i.preRevisionStatus = i.status;
              i.status = 'Revision Review Required';
              i.revisionMapId = mapId;
              flaggedIds.push(i.id);
            }
          });
        }
        // A revised sheet's replacement carries the takeoff forward, so it links
        // back to what it supersedes rather than starting with no lineage.
        if (r.state === 'Revised' && r.newSheetId) {
          const ns = draft.drawingSheets.find(x => x.id === r.newSheetId);
          if (ns) ns.supersedesSheetId = r.oldSheetId;
        }
      });
      const oset = draft.drawingSets.find(x => x.id === oldSetId);
      if (oset) oset.issueState = 'Superseded';
      const nset = draft.drawingSets.find(x => x.id === newSetId);
      if (nset) nset.issueState = 'Current';
      draft.revisionMaps.push({
        id: mapId, date: todayISO(), by: ctx.currentUserName,
        newSetId, oldSetId,
        newSetName: (nset || {}).name || '', oldSetName: (oset || {}).name || '',
        rows, affectedItemIds: flaggedIds, snapshot,
      });
    }, `Take-off: revision map from "${(dwgSetById(project, oldSetId) || {}).name}" to "${(dwgSetById(project, newSetId) || {}).name}" — ${rows.filter(r => r.state === 'Revised').length} revised, ${rows.filter(r => r.state === 'New').length} new, ${rows.filter(r => r.state === 'Removed').length} removed.`);
    setNewSetId(''); setOldSetId('');
  }

  return (
    <div className="space-y-4">
      <Collapsible id={`dwg-revmap-new-${project.id}`} title="Compare two drawing sets" defaultOpen={!maps.length}>
        <div className="grid gap-3 md:grid-cols-2">
          <Field label="Superseded set (the one you took off from)">
            <Select value={oldSetId} onChange={e => setOldSetId(e.target.value)}>
              <option value="">— pick a set —</option>
              {sets.map(s => <option key={s.id} value={s.id}>{s.name}{s.issueType ? ` · ${s.issueType}` : ''} (Rev {s.revision})</option>)}
            </Select>
          </Field>
          <Field label="New set">
            <Select value={newSetId} onChange={e => setNewSetId(e.target.value)}>
              <option value="">— pick a set —</option>
              {sets.map(s => <option key={s.id} value={s.id}>{s.name}{s.issueType ? ` · ${s.issueType}` : ''} (Rev {s.revision})</option>)}
            </Select>
          </Field>
        </div>
        {!!preview.length && (
          <div className="mt-3 space-y-2">
            <div className="flex flex-wrap gap-1.5">
              {['Unchanged', 'Revised', 'New', 'Removed'].map(st => (
                <Badge key={st} tone={st === 'Revised' ? 'yellow' : st === 'New' ? 'blue' : st === 'Removed' ? 'red' : 'neutral'}>
                  {preview.filter(r => r.state === st).length} {st.toLowerCase()}
                </Badge>
              ))}
              <Badge tone={affectedPreview.length ? 'red' : 'green'}>{affectedPreview.length} takeoff record{affectedPreview.length === 1 ? '' : 's'} would need review</Badge>
            </div>
            <div className="rounded-lg border border-[var(--leon-line)] max-h-60 overflow-y-auto">
              <table className="w-full text-xs">
                <tbody>
                  {preview.map((r, i) => (
                    <tr key={i} className="border-b border-[var(--leon-line)]/60">
                      <td className="px-2 py-1 font-bold w-24">{r.number}</td>
                      <td className="px-2 py-1">{r.title}</td>
                      <td className="px-2 py-1 w-28 text-[var(--leon-black)]/50">
                        {r.state === 'Revised' ? `Rev ${r.oldRevision || '—'} → ${r.newRevision || '—'}` : ''}
                      </td>
                      <td className="px-2 py-1 w-24 text-right">
                        <Badge tone={r.state === 'Revised' ? 'yellow' : r.state === 'New' ? 'blue' : r.state === 'Removed' ? 'red' : 'neutral'}>{r.state}</Badge>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            {editable && (
              <Button size="sm" onClick={apply}>
                Apply — supersede {preview.filter(r => r.state === 'Revised' || r.state === 'Removed').length} sheet{preview.filter(r => r.state === 'Revised' || r.state === 'Removed').length === 1 ? '' : 's'} and flag {affectedPreview.length} record{affectedPreview.length === 1 ? '' : 's'}
              </Button>
            )}
          </div>
        )}
        {!!newSetId && !!oldSetId && newSetId === oldSetId && <p className="text-xs text-[var(--leon-red)] mt-2">Pick two different sets.</p>}
        <div className="mt-3">
          <DwgNote>
            Sheets are matched by number, and a change is detected from the revision on record — not from
            the drawing content. A cloud-and-delta view of what actually moved <i>on</i> a revised sheet is
            not built here, so that is still a person's read. What this does guarantee is that no
            measurement quietly survives it: every takeoff record, and every measurement drawn on the sheet,
            is pinned to the revision it was taken on and is flagged the moment that revision is superseded.
          </DwgNote>
        </div>
      </Collapsible>

      {!!queue.length && (
        <Collapsible id={`dwg-revqueue-${project.id}`} title="Revision review queue" count={queue.length} defaultOpen>
          <p className="text-xs text-[var(--leon-black)]/55 mb-2">
            Each of these was measured off a sheet that has since been superseded. Answer each one — the
            quantity is not carried forward silently and it is not thrown away silently.
          </p>
          <div className="space-y-1.5">
            {queue.map(i => <DwgQueueRow key={i.id} ctx={ctx} project={project} item={i} editable={editable} onOpenSheet={onOpenSheet} />)}
          </div>
        </Collapsible>
      )}

      {maps.map(m => <DwgRevisionMapCard key={m.id} ctx={ctx} project={project} map={m} />)}
      {!maps.length && !queue.length && <EmptyState text="No revision maps yet. Compare two sets above once a new issue arrives." />}

      <Collapsible id={`dwg-verdiff-${project.id}`} title="Compare two bid versions">
        <div className="grid gap-3 md:grid-cols-2">
          <Field label="From">
            <Select value={cmpA} onChange={e => setCmpA(e.target.value)}>
              <option value="">— pick a version —</option>
              {dwgVersions(project).map(v => <option key={v.id} value={v.id}>{v.name}{v.locked ? ' 🔒' : ''}</option>)}
            </Select>
          </Field>
          <Field label="To">
            <Select value={cmpB} onChange={e => setCmpB(e.target.value)}>
              <option value="">— pick a version —</option>
              {dwgVersions(project).map(v => <option key={v.id} value={v.id}>{v.name}{v.locked ? ' 🔒' : ''}</option>)}
            </Select>
          </Field>
        </div>
        {cmpA && cmpB && cmpA !== cmpB && <DwgQuantityDelta project={project} fromVersionId={cmpA} toVersionId={cmpB} />}
      </Collapsible>
    </div>
  );
}

function DwgQueueRow({ ctx, project, item, editable, onOpenSheet }) {
  const [editing, setEditing] = useState(false);
  const sh = dwgSheetById(project, item.sheetId);
  const successor = sh && dwgSheetList(project).find(x => x.supersedesSheetId === sh.id);
  const q = dwgItemQty(item, dwgItemList(project));

  function resolve(kind) {
    dwgUpdate(ctx, project.id, draft => {
      const t = draft.takeoffItems.find(x => x.id === item.id);
      if (!t) return;
      if (kind === 'confirm') {
        // The measurement still stands — it is re-pointed at the current sheet
        // and revision so it stops reading as taken from a stale drawing.
        if (successor) { t.sheetId = successor.id; t.sheetRevision = successor.revision || ''; t.drawingSetId = successor.drawingSetId; }
        t.status = t.preRevisionStatus || 'Reviewed';
        t.notes = `${t.notes || ''}${t.notes ? ' — ' : ''}Confirmed unchanged at revision review ${todayISO()}.`;
      } else if (kind === 'remeasure') {
        if (successor) { t.sheetId = successor.id; t.sheetRevision = successor.revision || ''; t.drawingSetId = successor.drawingSetId; }
        t.status = 'Not Started';
        t.notes = `${t.notes || ''}${t.notes ? ' — ' : ''}Sent back to be re-measured at revision review ${todayISO()}.`;
      } else if (kind === 'delete') {
        // Soft, always: history has to keep the number that was once bid.
        t.status = 'Superseded';
        t.active = false;
        t.notes = `${t.notes || ''}${t.notes ? ' — ' : ''}Dropped at revision review ${todayISO()} — the work is no longer on the drawings.`;
      }
      t.preRevisionStatus = null;
      t.updatedBy = ctx.currentUserName; t.updatedDate = todayISO();
    }, `Take-off revision review: ${item.toolName} on ${sh ? sh.number : 'no sheet'} — ${kind === 'confirm' ? 'confirmed unchanged' : kind === 'remeasure' ? 'sent back to re-measure' : 'dropped'}.`);
  }

  return (
    <div className="rounded-lg border border-[var(--leon-red)]/30 bg-[#fbe7e7]/25 px-3 py-2">
      <div className="flex items-start justify-between gap-2 flex-wrap">
        <div className="min-w-0">
          <div className="text-sm font-semibold">
            {item.toolName} — {dwgFmtQty(q.net, item.unit)}
            {item.code ? <span className="text-[var(--leon-black)]/50"> · {item.code}</span> : null}
          </div>
          <div className="text-[11px] text-[var(--leon-black)]/55">
            {sh ? <button className="text-[var(--leon-brown)] font-semibold hover:underline" onClick={() => onOpenSheet(sh.id)}>{sh.number}</button> : 'no sheet'}
            {' '}Rev {item.sheetRevision || '—'}
            {successor ? <> → now <b>{successor.number} Rev {successor.revision || '—'}</b></> : ' · no replacement sheet indexed'}
            {' · '}{(dwgScopeById(project, item.scopeId) || {}).name || 'no scope'}
            {' · '}{[item.building, item.floor, item.unitType, item.room, item.surface].filter(Boolean).join(' · ')}
          </div>
        </div>
        {editable && (
          <div className="flex gap-1 shrink-0 flex-wrap">
            <Button size="sm" variant="ghost" onClick={() => resolve('confirm')}>Confirm unchanged</Button>
            <Button size="sm" variant="ghost" onClick={() => setEditing(true)}>Modify</Button>
            <Button size="sm" variant="ghost" onClick={() => resolve('remeasure')}>Re-measure</Button>
            <Button size="sm" variant="ghost" className="!text-[var(--leon-red)]"
              onClick={() => { if (confirm('Drop this take-off? It is kept as Superseded, never erased.')) resolve('delete'); }}>Delete</Button>
          </div>
        )}
      </div>
      <DwgItemModal open={editing} item={editing ? item : null} onClose={() => setEditing(false)}
        ctx={ctx} project={project} versionId={item.bidVersionId} editable={editable} />
    </div>
  );
}

function DwgRevisionMapCard({ ctx, project, map }) {
  const counts = ['Unchanged', 'Revised', 'New', 'Removed'].map(st => ({ st, n: map.rows.filter(r => r.state === st).length }));
  const outstanding = dwgItemList(project).filter(i => i.revisionMapId === map.id && i.status === 'Revision Review Required').length;
  return (
    <Collapsible id={`dwg-map-${map.id}`} title={`${map.oldSetName || 'old set'} → ${map.newSetName || 'new set'}`}
      count={map.rows.length}
      right={<span className="flex gap-1 items-center">
        {outstanding ? <Badge tone="red">{outstanding} outstanding</Badge> : <Badge tone="green">reviewed</Badge>}
      </span>}>
      <div className="text-xs text-[var(--leon-black)]/55 mb-2">
        Applied {fmtDate(map.date)} by {map.by} · {counts.map(c => `${c.n} ${c.st.toLowerCase()}`).join(' · ')} ·
        {' '}{(map.affectedItemIds || []).length} record{(map.affectedItemIds || []).length === 1 ? '' : 's'} flagged
      </div>
      <div className="mb-3">
        <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">Quantity change since this revision landed</div>
        <DwgSnapshotDelta project={project} snapshot={map.snapshot} />
      </div>
      <div className="rounded-lg border border-[var(--leon-line)] max-h-56 overflow-y-auto">
        <table className="w-full text-xs">
          <tbody>
            {map.rows.filter(r => r.state !== 'Unchanged').map((r, i) => (
              <tr key={i} className="border-b border-[var(--leon-line)]/60">
                <td className="px-2 py-1 font-bold w-24">{r.number}</td>
                <td className="px-2 py-1">{r.title}</td>
                <td className="px-2 py-1 w-28 text-[var(--leon-black)]/50">{r.state === 'Revised' ? `Rev ${r.oldRevision || '—'} → ${r.newRevision || '—'}` : ''}</td>
                <td className="px-2 py-1 w-24 text-right">
                  <Badge tone={r.state === 'Revised' ? 'yellow' : r.state === 'New' ? 'blue' : 'red'}>{r.state}</Badge>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </Collapsible>
  );
}

// "+8 EA doors, −421 SF tile" — the sentence a PM actually wants after a
// revision. Built from the snapshot taken when the map was applied.
function DwgSnapshotDelta({ project, snapshot }) {
  const now = dwgQuantitySnapshot(project);
  const keys = Array.from(new Set(Object.keys(snapshot || {}).concat(Object.keys(now))));
  const rows = keys.map(k => {
    const [label, unit] = dwgSplitKey(k);
    const was = dwgNum((snapshot || {})[k]);
    const is = dwgNum(now[k]);
    return { label, unit, was, is, delta: is - was };
  }).filter(r => Math.abs(r.delta) > 0.005).sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
  if (!rows.length) return <p className="text-xs text-[var(--leon-black)]/45">No quantity has moved since this revision was applied.</p>;
  return (
    <div className="flex flex-wrap gap-1.5">
      {rows.map(r => (
        <span key={`${r.label}|${r.unit}`}
          className={`text-xs px-2 py-1 rounded-lg border ${r.delta > 0 ? 'border-[#3a7d44]/40 bg-[#e7f3e9] text-[#3a7d44]' : 'border-[#b83b3b]/40 bg-[#fbe7e7] text-[#b83b3b]'}`}
          title={`was ${dwgFmtQty(r.was, r.unit)}, now ${dwgFmtQty(r.is, r.unit)}`}>
          <b>{dwgSigned(r.delta, r.unit)}</b> {r.label}
        </span>
      ))}
    </div>
  );
}

function DwgQuantityDelta({ project, fromVersionId, toVersionId }) {
  const all = dwgItemList(project);
  const snap = vid => {
    const pool = all.filter(i => dwgItemLive(i) && i.included === 'Included' && i.bidVersionId === vid && !i.overrideOfId);
    const out = {};
    pool.forEach(i => { const k = `${i.toolName || 'No tool'}|${i.unit}`; out[k] = (out[k] || 0) + dwgItemQty(i, all).net; });
    return out;
  };
  const a = snap(fromVersionId), b = snap(toVersionId);
  const keys = Array.from(new Set(Object.keys(a).concat(Object.keys(b))));
  const rows = keys.map(k => {
    const [label, unit] = dwgSplitKey(k);
    return { label, unit, was: dwgNum(a[k]), is: dwgNum(b[k]), delta: dwgNum(b[k]) - dwgNum(a[k]) };
  }).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
  if (!rows.length) return <p className="text-xs text-[var(--leon-black)]/45 mt-2">Neither version carries quantities.</p>;
  return (
    <table className="w-full text-xs mt-3">
      <thead>
        <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
          <th className="px-2 py-1.5">Tool</th><th className="px-2 py-1.5 text-right">From</th>
          <th className="px-2 py-1.5 text-right">To</th><th className="px-2 py-1.5 text-right">Change</th>
        </tr>
      </thead>
      <tbody>
        {rows.map(r => (
          <tr key={`${r.label}|${r.unit}`} className="border-b border-[var(--leon-line)]/60">
            <td className="px-2 py-1.5">{r.label}</td>
            <td className="px-2 py-1.5 text-right">{dwgFmtQty(r.was, r.unit)}</td>
            <td className="px-2 py-1.5 text-right">{dwgFmtQty(r.is, r.unit)}</td>
            <td className={`px-2 py-1.5 text-right font-bold ${r.delta > 0 ? 'text-[#3a7d44]' : r.delta < 0 ? 'text-[#b83b3b]' : 'text-[var(--leon-black)]/35'}`}>
              {r.delta === 0 ? '—' : dwgSigned(r.delta, r.unit)}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// ── Handoff ───────────────────────────────────────────────────────────────
// Takeoff rows become records in the specialised modules. The rule that matters
// is that a handoff must be re-runnable: matching on mark/code first means
// sending the same batch twice links to what is already there instead of
// creating a second copy of every door.
function dwgHandoffKey(v) { return String(v || '').trim().toLowerCase(); }

function DwgHandoff({ ctx, project, editable, versionId }) {
  const [targetKey, setTargetKey] = useState('doors');
  const [sel, setSel] = useState([]);
  const target = DWG_HANDOFF_TARGETS.find(t => t.key === targetKey);
  const all = dwgItemList(project);

  // Eligible rows: live, included, in this bid version, and measured with a
  // tool belonging to one of the target's scopes.
  const eligible = all.filter(i => {
    if (!dwgItemLive(i) || i.overrideOfId) return false;
    if (i.included !== 'Included') return false;
    if (versionId && i.bidVersionId !== versionId) return false;
    const tool = dwgToolById(project, i.toolId);
    return !!tool && target.toolScopes.includes(tool.scope);
  });

  if (target.mode === 'link') {
    return <DwgSurfaceCoverage ctx={ctx} project={project} target={target} rows={eligible}
      targets={DWG_HANDOFF_TARGETS} targetKey={targetKey} setTargetKey={k => { setTargetKey(k); setSel([]); }} />;
  }

  const existing = (project[target.collection] || []);
  const rows = eligible.map(i => {
    const key = dwgHandoffKey(i.code);
    const hit = key ? existing.find(r => dwgHandoffKey(r[target.matchField]) === key) : null;
    const sent = i.handoff && i.handoff.target === target.key;
    return { item: i, match: hit || null, sent: !!sent, q: dwgItemQty(i, all) };
  });
  const chosen = rows.filter(r => sel.includes(r.item.id));
  const willCreate = chosen.filter(r => !r.match).length;
  const willLink = chosen.filter(r => r.match).length;
  const noCode = chosen.filter(r => !dwgHandoffKey(r.item.code)).length;
  // Both create-targets come from sibling module files loaded before this one.
  // Checking rather than assuming means a load-order change fails loudly here
  // instead of writing a malformed record into another module's collection.
  const canBuild = target.key === 'doors' ? typeof makeDoor === 'function' : typeof stoneMakePiece === 'function';

  function send() {
    if (!chosen.length || !canBuild) return;
    dwgUpdate(ctx, project.id, draft => {
      if (!Array.isArray(draft[target.collection])) draft[target.collection] = [];
      chosen.forEach(({ item, match, q }) => {
        const t = draft.takeoffItems.find(x => x.id === item.id);
        let recordId = match ? match.id : null;
        if (!match) {
          if (target.key === 'doors') {
            // Only what the take-off genuinely knows: the mark, where it is,
            // whose scope it is and how many. Size, type and hardware are left
            // null so they read as "not decided yet" rather than as a decision.
            const d = makeDoor({
              mark: item.code || '', scopeId: item.scopeId || null,
              location: [item.building, item.floor, item.room].filter(Boolean).join(' / '),
              level: item.floor || '', unit: item.unitNo || '',
              qty: Math.max(1, Math.round(q.net)),
              finishRef: item.finishRef || null,
              notes: `From take-off — ${item.toolName}, ${dwgFmtQty(q.net, item.unit)}${item.notes ? ` · ${item.notes}` : ''}`,
            }, ctx.currentUserName);
            draft.doors.push(d);
            recordId = d.id;
          } else {
            // A cut piece with no length or width, on purpose — the take-off
            // measured area, and guessing a slab piece's dimensions from it is
            // exactly the kind of invented number this module refuses to make.
            const p = stoneMakePiece({
              scopeId: item.scopeId || null,
              label: item.code || [item.room, item.surface].filter(Boolean).join(' ') || item.toolName,
              material: item.finishRef ? item.finishRef.name : '',
              lengthMm: 0, widthMm: 0, thicknessMm: 0, qty: 1,
              notes: `From take-off — ${dwgFmtQty(q.net, item.unit)} measured${item.room ? ` at ${item.room}` : ''}. Length and width still to be taken off the drawing.`,
            }, ctx.currentUserName);
            draft.stoneCutList.push(p);
            recordId = p.id;
          }
        }
        if (t) t.handoff = { target: target.key, recordId, matched: !!match, date: todayISO(), by: ctx.currentUserName };
      });
    }, `Take-off handoff to ${target.label}: ${willCreate} record${willCreate === 1 ? '' : 's'} created, ${willLink} matched to existing.`);
    setSel([]);
  }

  return (
    <div className="space-y-4">
      <DwgHandoffTabs targets={DWG_HANDOFF_TARGETS} targetKey={targetKey} setTargetKey={k => { setTargetKey(k); setSel([]); }} />
      <DwgNote>{target.note}</DwgNote>
      <DwgNote>
        Rows are matched on <b>{target.matchField === 'mark' ? 'mark' : 'label'} / code</b> against what is
        already on the job. A match links the take-off to that existing record instead of creating a second
        one — so the same batch can be sent twice without duplicating anything. A row with no code cannot be
        matched at all, which is why those are called out before you send.
      </DwgNote>
      {!canBuild && (
        <div className="rounded-lg border border-[var(--leon-red)]/40 bg-[#fbe7e7]/40 px-3 py-2 text-sm">
          {target.label} is not loaded in this build, so nothing can be created against it. Sending is
          disabled rather than writing a record no module will read.
        </div>
      )}

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[900px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <th className="px-2 py-2 w-8">
                <input type="checkbox" checked={!!rows.length && sel.length === rows.length}
                  onChange={e => setSel(e.target.checked ? rows.map(r => r.item.id) : [])} />
              </th>
              <th className="px-2 py-2">Code</th><th className="px-2 py-2">Tool</th><th className="px-2 py-2">Scope</th>
              <th className="px-2 py-2">Location</th><th className="px-2 py-2 text-right">Qty</th>
              <th className="px-2 py-2">Takeoff status</th><th className="px-2 py-2">Against {target.label}</th>
            </tr>
          </thead>
          <tbody>
            {rows.map(r => (
              <tr key={r.item.id} className="border-b border-[var(--leon-line)]/60">
                <td className="px-2 py-1.5">
                  <input type="checkbox" checked={sel.includes(r.item.id)} disabled={!editable || !canBuild}
                    onChange={e => setSel(e.target.checked ? [...sel, r.item.id] : sel.filter(x => x !== r.item.id))} />
                </td>
                <td className="px-2 py-1.5 font-bold">{r.item.code || <span className="text-[var(--leon-black)]/30 font-normal">no code</span>}</td>
                <td className="px-2 py-1.5">{r.item.toolName}</td>
                <td className="px-2 py-1.5">{(dwgScopeById(project, r.item.scopeId) || {}).name || '—'}</td>
                <td className="px-2 py-1.5 text-[var(--leon-black)]/60">{[r.item.building, r.item.floor, r.item.unitNo, r.item.room].filter(Boolean).join(' · ') || '—'}</td>
                <td className="px-2 py-1.5 text-right font-semibold whitespace-nowrap">{dwgFmtQty(r.q.net, r.item.unit)}</td>
                <td className="px-2 py-1.5"><Badge tone={DWG_STATUS_TONE[r.item.status]}>{r.item.status}</Badge></td>
                <td className="px-2 py-1.5">
                  {r.match ? <Badge tone="blue">matches {r.match[target.matchField]}</Badge> : <Badge tone="green">new</Badge>}
                  {r.sent && <span className="ml-1 text-[10px] text-[var(--leon-black)]/45">sent {fmtDate(r.item.handoff.date)}</span>}
                </td>
              </tr>
            ))}
            {!rows.length && <tr><td colSpan={8} className="px-3 py-6 text-center text-[var(--leon-black)]/40">
              Nothing in this bid version was measured with a {target.toolScopes.join(' / ')} tool yet.
            </td></tr>}
          </tbody>
        </table>
      </div>

      {!!chosen.length && (
        <div className="rounded-lg bg-[var(--leon-cream)] border border-[var(--leon-line)] p-3 text-sm flex items-center justify-between gap-3 flex-wrap">
          <div>
            <b>{willCreate} new record{willCreate === 1 ? '' : 's'}</b> would be created in {target.label} and
            {' '}<b>{willLink}</b> linked to what already exists.
            {!!noCode && <span className="text-[var(--leon-red)]"> {noCode} of them have no code and cannot be matched — check they are not already on the job before sending.</span>}
          </div>
          {editable && <Button size="sm" onClick={send} disabled={!canBuild}>Send {chosen.length} row{chosen.length === 1 ? '' : 's'} to {target.label}</Button>}
        </div>
      )}
    </div>
  );
}

function DwgHandoffTabs({ targets, targetKey, setTargetKey }) {
  return (
    <div className="flex gap-1 flex-wrap">
      {targets.map(t => (
        <DwgChip key={t.key} active={targetKey === t.key} onClick={() => setTargetKey(t.key)}>
          {t.label}{t.mode === 'link' ? ' — link only' : ''}
        </DwgChip>
      ))}
    </div>
  );
}

// Coverage against LEON Surfaces. Nothing is created — this reports which unit
// types and rooms in the take-off already have a type modelled, and which do
// not, which is the genuinely useful question at this seam.
function DwgSurfaceCoverage({ ctx, project, target, rows, targets, targetKey, setTargetKey }) {
  const lib = ctx.surfaceLibrary || {};
  const unitTypes = lib.unitTypes || [];
  const roomTypes = lib.roomTypes || [];
  const norm = v => dwgHandoffKey(v);
  const matchType = (list, value) => {
    const k = norm(value);
    if (!k) return null;
    return list.find(t => norm(t.code) === k || norm(t.name) === k) || null;
  };

  const unitBuckets = {};
  const roomBuckets = {};
  rows.forEach(i => {
    if (i.unitType) {
      const k = norm(i.unitType);
      if (!unitBuckets[k]) unitBuckets[k] = { label: i.unitType, n: 0, match: matchType(unitTypes, i.unitType) };
      unitBuckets[k].n++;
    }
    if (i.room) {
      const k = norm(i.room);
      if (!roomBuckets[k]) roomBuckets[k] = { label: i.room, n: 0, match: matchType(roomTypes, i.room) };
      roomBuckets[k].n++;
    }
  });
  const units = Object.keys(unitBuckets).map(k => unitBuckets[k]).sort((a, b) => b.n - a.n);
  const rooms = Object.keys(roomBuckets).map(k => roomBuckets[k]).sort((a, b) => b.n - a.n);
  const noLoc = rows.filter(i => !i.unitType && !i.room).length;

  function Coverage({ title, list, emptyText }) {
    const hit = list.filter(x => x.match).length;
    return (
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
        <div className="flex items-center justify-between gap-2 mb-2">
          <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">{title}</div>
          <Badge tone={list.length && hit === list.length ? 'green' : hit ? 'yellow' : 'neutral'}>
            {hit}/{list.length} modelled
          </Badge>
        </div>
        {!list.length ? <EmptyState text={emptyText} /> : (
          <div className="flex flex-wrap gap-1.5">
            {list.map(x => (
              <span key={x.label} title={x.match ? `Modelled as ${x.match.code} — ${x.match.name}` : 'Not modelled in LEON Surfaces'}
                className={`text-xs px-2 py-1 rounded-lg border ${x.match
                  ? 'border-[#3a7d44]/40 bg-[#e7f3e9] text-[#3a7d44]'
                  : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'}`}>
                {x.label} <span className="opacity-60">· {x.n}</span>{x.match ? ' ✓' : ''}
              </span>
            ))}
          </div>
        )}
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <DwgHandoffTabs targets={targets} targetKey={targetKey} setTargetKey={setTargetKey} />
      <DwgNote>{target.note}</DwgNote>
      <div className="grid gap-3 md:grid-cols-2">
        <Coverage title="Unit types in the take-off" list={units}
          emptyText="No takeoff row names a unit type yet." />
        <Coverage title="Rooms in the take-off" list={rooms}
          emptyText="No takeoff row names a room yet." />
      </div>
      <div className="text-xs text-[var(--leon-black)]/55">
        {rows.length} surface-scope takeoff record{rows.length === 1 ? '' : 's'} in view
        {noLoc ? ` · ${noLoc} name neither a unit type nor a room, so they cannot be matched to anything modelled` : ''}.
        A green chip means LEON Surfaces already has a type of that name and its set-out can be used to
        check this quantity; a plain one means it does not, and the quantity stands on the take-off alone.
      </div>
    </div>
  );
}
