opentakeoff-mcp 0.9.3 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,7 +117,9 @@ includes document text, shape vertices, or result payload content.
117
117
  | `delete_shape` | Remove a committed shape by id. |
118
118
  | `edit_shape` | **Revise** a committed shape instead of redoing it: new `verts`, a different `condition`, a different `role`, or any combination — quantities recomputed from the result. Refuses shapes a human affirmed. |
119
119
  | `edit_materials` | Add/remove/patch supporting-materials rows on a condition — the coverage-rate lines (adhesive at N sf/gal, grout at N lf/bag, …) that turn a measured quantity into an order quantity, matching the canvas's Supporting Materials panel. `condition` mints on first touch, like `one_click`/`measure_polygon`. No review gate (materials rows are quantity config, not traced geometry) — edits directly, reversible with `undo_last`. |
120
- | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored. A whole `detect_rooms` sweep is **one** step. |
120
+ | `edit_condition` | Set a condition's **waste %** and **×N multiplier** — the knobs that turn measured quantities into order quantities (`takeoff_summary`'s `*_net`); without them an agent takeoff always ships net === gross. Resolves an **existing** finish tag or errors a typo must not mint an empty condition. No review gate; one `undo_last` step restores both knobs verbatim. |
121
+ | `export_report` | The **computed Report document** — `opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers — `export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path`. |
122
+ | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
121
123
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
122
124
  | `find_text` | **Locate** a known string — the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
123
125
  | `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids — same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
@@ -216,6 +216,12 @@ async function openPdf(filePath) {
216
216
  }
217
217
  };
218
218
  },
219
+ async layers() {
220
+ const cfg = await doc.getOptionalContentConfig();
221
+ const groups = cfg ? cfg.getGroups() : null;
222
+ if (!groups) return [];
223
+ return Object.entries(groups).map(([id, g]) => ({ id, name: String(g?.name ?? ""), visible: g?.visible !== false }));
224
+ },
219
225
  destroy: () => doc.destroy().then(() => void 0)
220
226
  };
221
227
  }
@@ -243,25 +249,120 @@ function textSpans(ph) {
243
249
  return out;
244
250
  }
245
251
 
246
- // src/format.ts
247
- var UserError = class extends Error {
252
+ // ../web/src/lib/layers.ts
253
+ var ROLE_CODE = {
254
+ unknown: 0,
255
+ boundary: 1,
256
+ "finish-pattern": 2,
257
+ annotation: 3,
258
+ structure: 4,
259
+ demolition: 5
248
260
  };
249
- var ok = (payload) => ({
250
- structuredContent: payload,
251
- content: [{ type: "text", text: JSON.stringify(payload) }]
252
- });
253
- var okImage = (png, meta) => ({
254
- content: [
255
- { type: "image", data: Buffer.from(png.buffer, png.byteOffset, png.byteLength).toString("base64"), mimeType: "image/png" },
256
- { type: "text", text: JSON.stringify(meta) }
257
- ]
258
- });
259
- var fail = (err) => ({
260
- isError: true,
261
- content: [{ type: "text", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }]
262
- });
263
- var round2 = (n) => +n.toFixed(2);
264
- var round1 = (n) => +n.toFixed(1);
261
+ var ROLE_HIDDEN = 6;
262
+ var DEMOLITION = /* @__PURE__ */ new Set(["DEMO", "DEMOL", "DEMOLITION", "REMV", "REMOVE"]);
263
+ var PATTERN = /* @__PURE__ */ new Set(["PATT", "PATTERN", "PATTERNS", "HATCH", "HATCHING", "POCHE"]);
264
+ var ANNOTATION = /* @__PURE__ */ new Set([
265
+ "ANNO",
266
+ "TEXT",
267
+ "DIM",
268
+ "DIMS",
269
+ "NOTE",
270
+ "NOTES",
271
+ "SYMB",
272
+ "SYMBOL",
273
+ "IDEN",
274
+ "TAG",
275
+ "TAGS",
276
+ "TTLB",
277
+ "TITLEBLOCK",
278
+ "LEGN",
279
+ "LEGEND",
280
+ "SHBD",
281
+ "NPLT",
282
+ "PLOT",
283
+ "GRID",
284
+ "GRIDS",
285
+ "LEVL",
286
+ "REVS",
287
+ "REV"
288
+ ]);
289
+ var FIXTURES = /* @__PURE__ */ new Set(["FURN", "FURNITURE", "EQPM", "EQUIP", "EQUIPMENT", "CASE", "CASW", "CASEWORK", "MILL", "MILLWORK", "PLMB", "PFIX", "APPL", "SPCL", "SEAT", "SEATING"]);
290
+ var STRUCTURE = /* @__PURE__ */ new Set(["COLS", "COL", "COLUMN", "COLUMNS", "BEAM", "BEAMS", "BRACE", "FNDN", "FOUNDATION", "JOIS", "JOIST", "SLAB"]);
291
+ var BOUNDARY = /* @__PURE__ */ new Set([
292
+ "WALL",
293
+ "WALLS",
294
+ "PRHT",
295
+ "FULL",
296
+ "PART",
297
+ "PARTN",
298
+ "PARTITION",
299
+ "PARTITIONS",
300
+ "GLAZ",
301
+ "GLAZING",
302
+ "STOR",
303
+ "STOREFRONT",
304
+ "CWMG",
305
+ "OTLN",
306
+ "OUTLINE",
307
+ "OUTLINES",
308
+ "RM",
309
+ "ROOM",
310
+ "ROOMS",
311
+ "AREA",
312
+ "AREAS",
313
+ "DOOR",
314
+ "DOORS",
315
+ "DR",
316
+ "WNDW",
317
+ "WIND",
318
+ "WINDOW",
319
+ "WINDOWS"
320
+ ]);
321
+ var DISCIPLINES = /* @__PURE__ */ new Set(["A", "S", "M", "E", "P", "F", "C", "L", "T", "I", "Q", "G", "H", "V", "W", "X", "Z"]);
322
+ function layerNameTokens(raw) {
323
+ const base = String(raw || "").split("|").pop() || "";
324
+ return base.replace(/\$\d+\$/g, "-").toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
325
+ }
326
+ function classifyLayerName(raw) {
327
+ const s = String(raw || "").trim();
328
+ if (!s || /^0$/.test(s) || /^layer\s*\d*$/i.test(s)) return { role: "unknown", confidence: 0 };
329
+ const toks = layerNameTokens(s);
330
+ if (!toks.length) return { role: "unknown", confidence: 0 };
331
+ const conforming = DISCIPLINES.has(toks[0]) && toks.length > 1;
332
+ const grade = (base) => conforming ? base : Math.max(0.5, base - 0.2);
333
+ const has = (table) => toks.some((t) => table.has(t));
334
+ if (has(DEMOLITION)) return { role: "demolition", confidence: grade(0.95) };
335
+ if (has(PATTERN)) return { role: "finish-pattern", confidence: grade(0.9) };
336
+ if (has(ANNOTATION)) return { role: "annotation", confidence: grade(0.85) };
337
+ if (toks[0] === "S" && toks.length > 1) return { role: "structure", confidence: 0.85 };
338
+ if (has(STRUCTURE)) return { role: "structure", confidence: grade(0.8) };
339
+ if (has(BOUNDARY)) return { role: "boundary", confidence: grade(0.9) };
340
+ if (has(FIXTURES)) return { role: "annotation", confidence: grade(0.65) };
341
+ return { role: "unknown", confidence: 0.2 };
342
+ }
343
+ function layerRoleCodes(layerIds, infoById) {
344
+ const codes = new Uint8Array(layerIds.length);
345
+ layerIds.forEach((id, k) => {
346
+ const info = infoById.get(id);
347
+ if (!info) return;
348
+ codes[k] = info.visible === false ? ROLE_HIDDEN : ROLE_CODE[info.role];
349
+ });
350
+ return codes;
351
+ }
352
+ function segRoles(layerOf, codes) {
353
+ if (!layerOf || !codes.length) return null;
354
+ const n = layerOf.length;
355
+ const out = new Uint8Array(n);
356
+ let any = false;
357
+ for (let i = 0; i < n; i++) {
358
+ const li = layerOf[i];
359
+ if (li >= 0 && li < codes.length && codes[li]) {
360
+ out[i] = codes[li];
361
+ any = true;
362
+ }
363
+ }
364
+ return any ? out : null;
365
+ }
265
366
 
266
367
  // ../web/src/lib/oneclick.ts
267
368
  var MASK_MAX_DIM = 3e3;
@@ -317,6 +418,20 @@ function extractVectorGeometry(opList, transform, OPS3) {
317
418
  let m = transform.slice();
318
419
  let lw = 1;
319
420
  const stack = [];
421
+ const layerIds = [];
422
+ const layerIdxById = /* @__PURE__ */ new Map();
423
+ const layerOfArr = [];
424
+ const mcStack = [];
425
+ let curLayer = -1;
426
+ const layerIdxFor = (id) => {
427
+ let k = layerIdxById.get(id);
428
+ if (k === void 0) {
429
+ k = layerIds.length;
430
+ layerIds.push(id);
431
+ layerIdxById.set(id, k);
432
+ }
433
+ return k;
434
+ };
320
435
  const mul = (a, b) => [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]];
321
436
  const tx = (x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
322
437
  const fns = opList.fnArray, A = opList.argsArray;
@@ -352,6 +467,26 @@ function extractVectorGeometry(opList, transform, OPS3) {
352
467
  m = p[0];
353
468
  lw = p[1];
354
469
  }
470
+ } else if (fn === OPS3.beginMarkedContent) {
471
+ mcStack.push(-1);
472
+ } else if (fn === OPS3.beginMarkedContentProps) {
473
+ const data = args && args[0] === "OC" ? args[1] : null;
474
+ let li = -1;
475
+ if (data && typeof data === "object") {
476
+ if (typeof data.id === "string" && data.id) li = layerIdxFor(data.id);
477
+ else if (Array.isArray(data.ids) && data.ids.length === 1 && typeof data.ids[0] === "string" && data.ids[0]) li = layerIdxFor(data.ids[0]);
478
+ }
479
+ mcStack.push(li);
480
+ if (li >= 0) curLayer = li;
481
+ } else if (fn === OPS3.endMarkedContent) {
482
+ if (mcStack.length) {
483
+ mcStack.pop();
484
+ curLayer = -1;
485
+ for (let k = mcStack.length - 1; k >= 0; k--) if (mcStack[k] >= 0) {
486
+ curLayer = mcStack[k];
487
+ break;
488
+ }
489
+ }
355
490
  } else if (fn === OPS3.paintImageXObject || fn === OPS3.paintInlineImageXObject || fn === OPS3.paintImageMaskXObject) {
356
491
  imageArea += Math.abs(m[0] * m[3] - m[1] * m[2]);
357
492
  } else if (fn === OPS3.paintImageXObjectRepeat) {
@@ -379,6 +514,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
379
514
  const flags = paintFlags(i) | devW << 4;
380
515
  const ops = args[0], co = args[1];
381
516
  let c = 0, cur = null, start = null;
517
+ const pathLayer = curLayer;
382
518
  const visit = (p) => {
383
519
  points.push(p);
384
520
  };
@@ -386,6 +522,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
386
522
  if (cur) {
387
523
  segs.push(cur[0], cur[1], p[0], p[1]);
388
524
  metaArr.push(flags);
525
+ layerOfArr.push(pathLayer);
389
526
  }
390
527
  cur = p;
391
528
  visit(p);
@@ -426,6 +563,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
426
563
  if (cur) {
427
564
  segs.push(cur[0], cur[1], q[0], q[1]);
428
565
  metaArr.push(flags | SEG_CURVE);
566
+ layerOfArr.push(pathLayer);
429
567
  }
430
568
  cur = q;
431
569
  }
@@ -434,6 +572,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
434
572
  if (cur && start) {
435
573
  segs.push(cur[0], cur[1], start[0], start[1]);
436
574
  metaArr.push(flags);
575
+ layerOfArr.push(pathLayer);
437
576
  cur = start;
438
577
  }
439
578
  } else if (op === OPS3.rectangle) {
@@ -444,6 +583,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
444
583
  const a = q[k], b = q[(k + 1) % 4];
445
584
  segs.push(a[0], a[1], b[0], b[1]);
446
585
  metaArr.push(flags);
586
+ layerOfArr.push(pathLayer);
447
587
  visit(a);
448
588
  }
449
589
  cur = q[0];
@@ -452,7 +592,7 @@ function extractVectorGeometry(opList, transform, OPS3) {
452
592
  }
453
593
  }
454
594
  }
455
- return { points, segs, meta: Uint8Array.from(metaArr), imageArea };
595
+ return { points, segs, meta: Uint8Array.from(metaArr), imageArea, layerOf: Int32Array.from(layerOfArr), layerIds };
456
596
  }
457
597
  function sweepHatchRuns(segs, meta, ws) {
458
598
  const n = segs.length >> 2;
@@ -612,14 +752,16 @@ function hatchFamilies(segs, meta) {
612
752
  };
613
753
  });
614
754
  }
615
- function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null) {
755
+ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, roles = null) {
616
756
  const ws = Math.min(1, maxDim / Math.max(imgW, imgH, 1));
617
757
  const mw = Math.max(2, Math.ceil(imgW * ws)), mh = Math.max(2, Math.ceil(imgH * ws));
618
758
  const mask = new Uint8Array(mw * mh);
619
759
  const soft = meta ? classifyHatchSegs(segs, meta, ws) : null;
620
760
  let softCount = 0;
621
761
  for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
622
- const v = soft && soft[si] ? 2 : 1;
762
+ const role = roles ? roles[si] : 0;
763
+ if (role === 2 || role === 3 || role === 5 || role === 6) continue;
764
+ const v = role === 1 || role === 4 ? 1 : soft && soft[si] ? 2 : 1;
623
765
  if (v === 2) softCount++;
624
766
  let x0 = Math.round(segs[i] * ws), y0 = Math.round(segs[i + 1] * ws);
625
767
  const x1 = Math.round(segs[i + 2] * ws), y1 = Math.round(segs[i + 3] * ws);
@@ -900,6 +1042,249 @@ function isLabelBubblePx(ring, b) {
900
1042
  return x1 - x0 <= BUBBLE_RATIO * lw && y1 - y0 <= BUBBLE_RATIO * lh;
901
1043
  }
902
1044
 
1045
+ // ../web/src/lib/sheetgraph.ts
1046
+ var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
1047
+ var merge = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
1048
+ var norm = (s) => (s || "").trim().toUpperCase();
1049
+ var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.-]+)?$/;
1050
+ var ROLE_SIGNALS = [
1051
+ { re: /DEMOLITION\s+PLAN|DEMO\s+PLAN/, role: "demolition", conf: 0.9 },
1052
+ { re: /FINISH\s+PLAN|FLOOR\s+PLAN|FURNITURE\s+PLAN|CEILING\s+PLAN/, role: "plan", conf: 0.85 },
1053
+ { re: SCHEDULE_TITLE_RE, role: "schedule", conf: 0.85 },
1054
+ { re: /SCHEDULE/, role: "schedule", conf: 0.5 },
1055
+ { re: /LEGEND/, role: "legend", conf: 0.5 },
1056
+ { re: /ELEVATIONS?\b/, role: "elevation", conf: 0.7 },
1057
+ { re: /DETAILS?\b|SECTIONS?\b/, role: "detail", conf: 0.6 }
1058
+ ];
1059
+ var REFERENCE_RE = /^(SEE|REFER|PER|NOTED|AS SHOWN)\b|REFER TO/;
1060
+ function classifySheetRole(sheet) {
1061
+ const hits = [];
1062
+ for (const sp of sheet.spans) {
1063
+ const u = norm(sp.str);
1064
+ if (u.length < 4 || u.length > 60 || REFERENCE_RE.test(u)) continue;
1065
+ for (const sig of ROLE_SIGNALS) if (sig.re.test(u)) {
1066
+ hits.push({ role: sig.role, conf: sig.conf, span: sp });
1067
+ break;
1068
+ }
1069
+ }
1070
+ if (!hits.length) {
1071
+ const n = norm(sheet.sheet_number || "");
1072
+ if (/^A-?1\d\d/.test(n)) return { role: "plan", confidence: 0.4, evidence: null };
1073
+ return { role: "unknown", confidence: 0, evidence: null };
1074
+ }
1075
+ hits.sort((a, b) => b.conf - a.conf);
1076
+ const best = hits[0];
1077
+ const dissent = hits.some((h) => h.role !== best.role && h.conf >= best.conf - 0.1);
1078
+ return {
1079
+ role: best.role,
1080
+ confidence: dissent ? best.conf / 2 : best.conf,
1081
+ evidence: { sheet: sheet.key, text: best.span.str.trim(), bbox: bboxOf(best.span) }
1082
+ };
1083
+ }
1084
+ function clusterRows(spans) {
1085
+ const toks = spans.filter((t) => t.str && t.str.trim()).sort((a, b) => a.y - b.y || a.x - b.x);
1086
+ const rows = [];
1087
+ let cur = [];
1088
+ let cy = 0;
1089
+ for (const t of toks) {
1090
+ const tol = Math.max((t.h || 8) * 0.35, 3);
1091
+ if (cur.length && Math.abs(t.y - cy) > tol) {
1092
+ rows.push(cur);
1093
+ cur = [];
1094
+ }
1095
+ cur.push(t);
1096
+ cy = cur.reduce((s, w) => s + w.y, 0) / cur.length;
1097
+ }
1098
+ if (cur.length) rows.push(cur);
1099
+ return rows.map((r) => r.sort((a, b) => a.x - b.x));
1100
+ }
1101
+ var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT"];
1102
+ var FINISH_HEADERS = ["CODE", "MARK", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN"];
1103
+ var headerLabel = (s, vocab) => {
1104
+ for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w)) return w;
1105
+ return null;
1106
+ };
1107
+ function findHeaderRow(rows, vocab, required, minHits) {
1108
+ for (let i = 0; i < rows.length; i++) {
1109
+ const anchors = [];
1110
+ const seen = /* @__PURE__ */ new Set();
1111
+ for (const t of rows[i]) {
1112
+ const w = headerLabel(t.str, vocab);
1113
+ if (w && !seen.has(w)) {
1114
+ seen.add(w);
1115
+ anchors.push({ label: w, x: t.x });
1116
+ }
1117
+ }
1118
+ if (anchors.length < minHits || !required.some((r) => seen.has(r))) continue;
1119
+ return { anchors: anchors.sort((a, b) => a.x - b.x), rowIndex: i };
1120
+ }
1121
+ return null;
1122
+ }
1123
+ var nearestAnchor = (x, anchors) => {
1124
+ let best = anchors[0];
1125
+ for (const a of anchors) if (Math.abs(a.x - x) < Math.abs(best.x - x)) best = a;
1126
+ return best.label;
1127
+ };
1128
+ var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
1129
+ var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
1130
+ function extractTable(sheet, kind) {
1131
+ const rows = clusterRows(sheet.spans);
1132
+ const found = kind === "room-finish" ? findHeaderRow(rows, ROOM_HEADERS, ["FLOOR", "BASE"], 4) : findHeaderRow(rows, FINISH_HEADERS, ["CODE", "MARK"], 3);
1133
+ if (!found) return null;
1134
+ const { anchors, rowIndex } = found;
1135
+ const keyRe = kind === "room-finish" ? ROW_KEY_RE : CODE_RE;
1136
+ const out = [];
1137
+ let region = null;
1138
+ const headerRow = rows[rowIndex];
1139
+ for (const t of headerRow) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
1140
+ const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
1141
+ const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
1142
+ const x0 = anchors[0].x - Math.max(80, medGap / 2);
1143
+ const x1 = anchors[anchors.length - 1].x + Math.max(300, medGap * 3);
1144
+ for (let i = rowIndex + 1; i < rows.length; i++) {
1145
+ const inBand = rows[i].filter((t) => t.x >= x0 && t.x <= x1);
1146
+ if (!inBand.length) continue;
1147
+ const keyTok = inBand[0];
1148
+ const key = norm(keyTok.str).replace(/[^A-Z0-9-]/g, "");
1149
+ if (!keyRe.test(key)) continue;
1150
+ const cells = {};
1151
+ for (const t of inBand) {
1152
+ const label = nearestAnchor(t.x, anchors);
1153
+ const text = t.str.trim();
1154
+ if (!cells[label]) cells[label] = { text, bbox: bboxOf(t) };
1155
+ else {
1156
+ cells[label] = { text: `${cells[label].text} ${text}`, bbox: merge(cells[label].bbox, bboxOf(t)) };
1157
+ }
1158
+ region = region ? merge(region, bboxOf(t)) : bboxOf(t);
1159
+ }
1160
+ out.push({ key, cells });
1161
+ }
1162
+ if (!out.length) return null;
1163
+ let title = null;
1164
+ for (let i = rowIndex - 1; i >= 0 && i >= rowIndex - 6 && !title; i--) {
1165
+ const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
1166
+ if (hit) title = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
1167
+ }
1168
+ return { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region };
1169
+ }
1170
+ function roomTags(sheet) {
1171
+ const out = [];
1172
+ const spans = sheet.spans;
1173
+ for (const sp of spans) {
1174
+ const t = sp.str.trim();
1175
+ if (!ROOM_LABEL_RE.test(t)) continue;
1176
+ const b = bboxOf(sp);
1177
+ const hgt = Math.max(sp.h || 8, 6);
1178
+ let name = "";
1179
+ let best = Infinity;
1180
+ for (const cand of spans) {
1181
+ if (cand === sp || ROOM_LABEL_RE.test(cand.str.trim())) continue;
1182
+ const cb = bboxOf(cand);
1183
+ const dy = b[1] - cb[3];
1184
+ if (dy < -hgt * 0.2 || dy > hgt * 2.2) continue;
1185
+ if (cb[2] < b[0] - hgt || cb[0] > b[2] + hgt) continue;
1186
+ if (!/^[A-Z][A-Z .\/&-]{2,}$/.test(norm(cand.str))) continue;
1187
+ if (dy < best) {
1188
+ best = dy;
1189
+ name = cand.str.trim();
1190
+ }
1191
+ }
1192
+ out.push({ tag: t, name, sheet: sheet.key, bbox: b });
1193
+ }
1194
+ return out;
1195
+ }
1196
+ var CALLOUT_RE = /^(\d{1,2})\s*\/\s*([A-Z]{1,2}-?\d{1,3}(?:\.\d+)?)$/;
1197
+ function detailCallouts(sheet) {
1198
+ const out = [];
1199
+ for (const sp of sheet.spans) {
1200
+ const m = sp.str.trim().match(CALLOUT_RE);
1201
+ if (m) out.push({ detail: m[1], target_sheet: m[2], sheet: sheet.key, bbox: bboxOf(sp) });
1202
+ }
1203
+ return out;
1204
+ }
1205
+ function buildSheetGraph(sheets) {
1206
+ const withText = sheets.filter((s) => s.spans.length > 0);
1207
+ if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [] };
1208
+ const tables = [];
1209
+ const rooms = [];
1210
+ const callouts = [];
1211
+ const outSheets = [];
1212
+ for (const s of withText) {
1213
+ const role = classifySheetRole(s);
1214
+ const found = [];
1215
+ for (const kind of ["room-finish", "finish"]) {
1216
+ const t = extractTable(s, kind);
1217
+ if (t) {
1218
+ found.push(t);
1219
+ tables.push(t);
1220
+ }
1221
+ }
1222
+ if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") rooms.push(...roomTags(s));
1223
+ callouts.push(...detailCallouts(s));
1224
+ outSheets.push({
1225
+ key: s.key,
1226
+ role: role.role,
1227
+ confidence: role.confidence,
1228
+ evidence: role.evidence,
1229
+ schedules: found.map((t) => ({ kind: t.kind, title: t.title?.text || "", rows: t.rows.length, region: t.region }))
1230
+ });
1231
+ }
1232
+ return { available: true, sheets: outSheets, rooms, tables, callouts };
1233
+ }
1234
+ var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
1235
+ function resolveTag(graph, tag) {
1236
+ const t = norm(tag);
1237
+ const room = graph.rooms.find((r2) => norm(r2.tag) === t) || null;
1238
+ const roomTables = graph.tables.filter((x) => x.kind === "room-finish");
1239
+ if (!roomTables.length) return { status: "unresolved", tag: t, room, reason: "no room-finish schedule found in the set" };
1240
+ const matches = roomTables.flatMap((tab2) => tab2.rows.filter((r2) => norm(r2.key) === t).map((r2) => ({ tab: tab2, r: r2 })));
1241
+ if (!matches.length) return { status: "unresolved", tag: t, room, reason: `no schedule row for ${t} \u2014 the plan shows the room but no room-finish table lists it` };
1242
+ if (matches.length > 1) return { status: "unresolved", tag: t, room, reason: `ambiguous: ${matches.length} schedule rows match ${t} (room numbers reused across the set?)` };
1243
+ const { tab, r } = matches[0];
1244
+ const finTables = graph.tables.filter((x) => x.kind === "finish");
1245
+ const finishes = [];
1246
+ const sources = [{ sheet: tab.sheet, text: `${tab.title?.text || "room-finish schedule"} row ${r.key}`, bbox: r.cells[Object.keys(r.cells)[0]]?.bbox || tab.region }];
1247
+ if (room) sources.unshift({ sheet: room.sheet, text: `${room.name ? room.name + " " : ""}${room.tag}`.trim(), bbox: room.bbox });
1248
+ for (const surface of SURFACE_HEADERS) {
1249
+ const cell = r.cells[surface];
1250
+ if (!cell || !cell.text.trim()) continue;
1251
+ const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
1252
+ const fin = { surface, code: cell.text.trim(), source: { sheet: tab.sheet, text: cell.text.trim(), bbox: cell.bbox } };
1253
+ for (const ft of finTables) {
1254
+ const def = ft.rows.find((fr) => norm(fr.key) === code);
1255
+ if (def) {
1256
+ const cells = {};
1257
+ for (const [k, v] of Object.entries(def.cells)) cells[k] = v.text;
1258
+ fin.definition = { cells, source: { sheet: ft.sheet, text: `${ft.title?.text || "finish schedule"} row ${def.key}`, bbox: def.cells[Object.keys(def.cells)[0]]?.bbox || ft.region } };
1259
+ break;
1260
+ }
1261
+ }
1262
+ finishes.push(fin);
1263
+ }
1264
+ if (!finishes.length) return { status: "unresolved", tag: t, room, reason: `schedule row ${t} exists but carries no finish cells the extractor could band` };
1265
+ return { status: "resolved", tag: t, room, finishes, sources };
1266
+ }
1267
+
1268
+ // src/format.ts
1269
+ var UserError = class extends Error {
1270
+ };
1271
+ var ok = (payload) => ({
1272
+ structuredContent: payload,
1273
+ content: [{ type: "text", text: JSON.stringify(payload) }]
1274
+ });
1275
+ var okImage = (png, meta) => ({
1276
+ content: [
1277
+ { type: "image", data: Buffer.from(png.buffer, png.byteOffset, png.byteLength).toString("base64"), mimeType: "image/png" },
1278
+ { type: "text", text: JSON.stringify(meta) }
1279
+ ]
1280
+ });
1281
+ var fail = (err) => ({
1282
+ isError: true,
1283
+ content: [{ type: "text", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }]
1284
+ });
1285
+ var round2 = (n) => +n.toFixed(2);
1286
+ var round1 = (n) => +n.toFixed(1);
1287
+
903
1288
  // ../web/src/lib/geometry.js
904
1289
  function buildSnapGrid(points, cell) {
905
1290
  const map = /* @__PURE__ */ new Map();
@@ -965,8 +1350,14 @@ function attrValue(attrs, colId) {
965
1350
  function accumulateRole(acc, s) {
966
1351
  const cp = s.computed || {};
967
1352
  switch (s.measure_role) {
1353
+ // #137 — a deduct carrying cuts_shape_id was reconciled at commit time
1354
+ // into a REAL polygon boolean subtract against its parent (lib/cutout.js):
1355
+ // the parent's own computed.area_sf already nets the hole out, so
1356
+ // counting the deduct's own area again here would double-subtract the
1357
+ // same cut. Its area_sf stays at face value on the shape (hover label);
1358
+ // this is the ONE place that decides whether it counts toward aggregates.
968
1359
  case "deduct":
969
- acc.floor -= cp.area_sf || 0;
1360
+ if (!s.cuts_shape_id) acc.floor -= cp.area_sf || 0;
970
1361
  break;
971
1362
  case "floor_area":
972
1363
  acc.floor += cp.area_sf || 0;
@@ -1101,7 +1492,7 @@ function grandTotals(rows) {
1101
1492
  sy_net: round22(sum("sy_net"))
1102
1493
  };
1103
1494
  }
1104
- function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [], markups = [], rfis = [], sheetLabel = null, conditionColumns = [], attrsByCond = null, shapeLabels = [], byLabel = [], displayUnits = "imperial" }) {
1495
+ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [], markups = [], rfis = [], sheetLabel = null, conditionColumns = [], attrsByCond = null, shapeLabels = [], byLabel = [], displayUnits = "imperial", rollGoods = [] }) {
1105
1496
  const label = (id) => sheetLabel ? sheetLabel(id) : id;
1106
1497
  const colDefs = (Array.isArray(conditionColumns) ? conditionColumns : []).filter((cc) => cc && typeof cc === "object" && typeof cc.id === "string");
1107
1498
  const attrs = attrsByCond instanceof Map ? attrsByCond : /* @__PURE__ */ new Map();
@@ -1182,7 +1573,14 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
1182
1573
  // the display system the exporting user was reading — the units port's
1183
1574
  // "JSON stays raw, but says so" contract.
1184
1575
  units: "imperial (SF/LF \u2014 raw internal values)",
1185
- display_units: displayUnits === "metric" ? "metric" : "imperial"
1576
+ display_units: displayUnits === "metric" ? "metric" : "imperial",
1577
+ // roll_goods APPENDS last (additive-only v1, #136): one row per roll-goods
1578
+ // condition — the figured order (order_lf / rolls / order_qty in the
1579
+ // condition's sell unit, ×N applied like every reported quantity) beside
1580
+ // the measured quantities the conditions[] rows already carry. Always
1581
+ // emitted; empty for projects with no roll-goods conditions, so every
1582
+ // pre-#136 export round-trips byte-identically except this one key.
1583
+ roll_goods: Array.isArray(rollGoods) ? rollGoods : []
1186
1584
  };
1187
1585
  }
1188
1586
 
@@ -1313,7 +1711,7 @@ var sheetSummary = (s) => ({
1313
1711
  ...s.sheetNumber ? { sheet_number: s.sheetNumber } : {},
1314
1712
  ...s.detected ? { detected_scale: s.detected.label } : {}
1315
1713
  });
1316
- var Session = class {
1714
+ var Session = class _Session {
1317
1715
  file = null;
1318
1716
  doc = null;
1319
1717
  sheets = /* @__PURE__ */ new Map();
@@ -1350,6 +1748,7 @@ var Session = class {
1350
1748
  this.file = null;
1351
1749
  this.journal = [];
1352
1750
  this.pendingCommits = [];
1751
+ this.graph = null;
1353
1752
  const doc = await openPdf(filePath);
1354
1753
  this.doc = doc;
1355
1754
  this.file = path2.basename(filePath);
@@ -1539,20 +1938,80 @@ var Session = class {
1539
1938
  const opList = await s.page.operatorList();
1540
1939
  s.geo = extractVectorGeometry(opList, s.page.viewport.transform, OPS2);
1541
1940
  s.snap = buildSnapGrid(s.geo.points, SNAP_CELL);
1941
+ const layerIds = s.geo.layerIds || [];
1942
+ if (layerIds.length && this.doc) {
1943
+ const byId = new Map((await this.doc.layers()).map((g) => [g.id, g]));
1944
+ const counts = /* @__PURE__ */ new Map();
1945
+ const lo = s.geo.layerOf;
1946
+ if (lo) {
1947
+ for (let i = 0; i < lo.length; i++) if (lo[i] >= 0) counts.set(lo[i], (counts.get(lo[i]) || 0) + 1);
1948
+ }
1949
+ s.layers = layerIds.map((id, k) => {
1950
+ const g = byId.get(id);
1951
+ const name = g?.name || "";
1952
+ const { role, confidence } = classifyLayerName(name);
1953
+ return { id, name, role, confidence, visible: g ? g.visible : true, seg_count: counts.get(k) || 0 };
1954
+ });
1955
+ } else {
1956
+ s.layers = [];
1957
+ }
1542
1958
  }
1543
1959
  return s.geo;
1544
1960
  }
1961
+ /** Per-layer role codes for buildMask (#85), with optional include/exclude
1962
+ * OVERRIDES (by layer name or id, case-insensitive): include forces hard
1963
+ * boundary, exclude drops the layer outright — the agent's judgment beats
1964
+ * the table's. Unknown names error with the sheet's actual layer list
1965
+ * (resolve-or-error, never a silent no-op). */
1966
+ rolesFor(s, geo, layersOpt) {
1967
+ const infos = s.layers || [];
1968
+ if (!infos.length || !geo.layerIds?.length) {
1969
+ if (layersOpt && (layersOpt.include?.length || layersOpt.exclude?.length)) {
1970
+ throw new UserError(`${s.key} has no PDF layers (no Optional Content survived export) \u2014 layers.include/exclude can't apply here.`);
1971
+ }
1972
+ return null;
1973
+ }
1974
+ const infoById = new Map(infos.map((l) => [l.id, { role: l.role, visible: l.visible }]));
1975
+ if (layersOpt) {
1976
+ const resolve = (ref) => {
1977
+ const needle = ref.trim().toLowerCase();
1978
+ const hit = infos.find((l) => l.id.toLowerCase() === needle || l.name.toLowerCase() === needle);
1979
+ if (!hit) throw new UserError(`No layer ${JSON.stringify(ref)} on ${s.key}. Layers: ${infos.map((l) => l.name || l.id).join(" | ")}`);
1980
+ return hit;
1981
+ };
1982
+ for (const ref of layersOpt.include || []) {
1983
+ const l = resolve(ref);
1984
+ infoById.set(l.id, { role: "boundary", visible: true });
1985
+ }
1986
+ for (const ref of layersOpt.exclude || []) {
1987
+ const l = resolve(ref);
1988
+ infoById.set(l.id, { role: l.role, visible: false });
1989
+ }
1990
+ }
1991
+ return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
1992
+ }
1545
1993
  /** v1 masks come from the sheet's vector linework only. Raster seam: a scanned
1546
1994
  * sheet would render via a node canvas into a future rastermask module that
1547
- * returns this same MaskObj shape. */
1995
+ * returns this same MaskObj shape. Layer roles (#85) ride in as the stated
1996
+ * short-circuit; an unlayered sheet builds the identical pre-#85 mask. */
1548
1997
  async ensureMask(name) {
1549
1998
  const s = this.sheet(name);
1550
1999
  if (s.mask === void 0) {
1551
2000
  const geo = await this.ensureGeometry(s);
1552
- s.mask = geo.segs.length ? buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta) : null;
2001
+ s.mask = geo.segs.length ? buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo)) : null;
1553
2002
  }
1554
2003
  return s.mask;
1555
2004
  }
2005
+ /** The mask honoring per-call layer overrides — a fresh build when overrides
2006
+ * are given (never cached: the default mask stays authoritative), the cached
2007
+ * default otherwise. */
2008
+ async maskWithLayers(name, layersOpt) {
2009
+ if (!layersOpt || !layersOpt.include?.length && !layersOpt.exclude?.length) return this.ensureMask(name);
2010
+ const s = this.sheet(name);
2011
+ const geo = await this.ensureGeometry(s);
2012
+ if (!geo.segs.length) return null;
2013
+ return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
2014
+ }
1556
2015
  async sheetInfo(name) {
1557
2016
  const s = this.sheet(name);
1558
2017
  const geo = await this.ensureGeometry(s);
@@ -1562,7 +2021,10 @@ var Session = class {
1562
2021
  has_vector_linework: geo.segs.length > 0,
1563
2022
  scale_set: s.upp != null,
1564
2023
  ...s.upp != null ? { upp: s.upp } : {},
1565
- shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length
2024
+ shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length,
2025
+ // the sheet's PDF layer table (#85) — always emitted; [] = no Optional
2026
+ // Content survived export and every engine path runs the heuristics
2027
+ layers: (s.layers || []).map((l) => ({ id: l.id, name: l.name, role: l.role, confidence: l.confidence, visible: l.visible, seg_count: l.seg_count }))
1566
2028
  };
1567
2029
  }
1568
2030
  scaleGate(s) {
@@ -1637,7 +2099,7 @@ var Session = class {
1637
2099
  }
1638
2100
  async oneClick(name, x, y, opts) {
1639
2101
  const s = this.sheet(name);
1640
- const mask = await this.ensureMask(name);
2102
+ const mask = await this.maskWithLayers(name, opts.layers);
1641
2103
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1642
2104
  const f = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
1643
2105
  if (f.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
@@ -1673,6 +2135,9 @@ var Session = class {
1673
2135
  reviewed: false,
1674
2136
  ...f.hatchFiltered ? { hatch_filtered: true } : {},
1675
2137
  ...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
2138
+ // #85 — a trace bounded by DECLARED boundary layers is categorically
2139
+ // stronger evidence than one bounded by a pitch heuristic
2140
+ ...(s.layers || []).some((l) => l.visible && (l.role === "boundary" || l.role === "structure")) ? { layer_bounded: true } : {},
1676
2141
  // canvas-parity provenance: a non-default fill sensitivity is part of
1677
2142
  // how the shape was made (ShapeOrigin.fill_sensitivity)
1678
2143
  ...opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
@@ -1713,7 +2178,7 @@ var Session = class {
1713
2178
  * judge and nothing commits anyway. */
1714
2179
  async detectRooms(name, opts) {
1715
2180
  const s = this.sheet(name);
1716
- const mask = await this.ensureMask(name);
2181
+ const mask = await this.maskWithLayers(name, opts.layers);
1717
2182
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1718
2183
  const minAreaSf = opts.minAreaSf ?? 5;
1719
2184
  if (!s.spans) s.spans = textSpans(s.page);
@@ -2156,11 +2621,11 @@ var Session = class {
2156
2621
  * the contract a pricing consumer reads (#130) — export_takeoff carries
2157
2622
  * materials as CONFIG rows and takeoff_summary strips them; only this
2158
2623
  * document carries the computed order quantities. */
2159
- exportReport() {
2624
+ exportReport(projectName = "") {
2160
2625
  if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
2161
2626
  const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
2162
2627
  return reportJson({
2163
- projectName: "",
2628
+ projectName,
2164
2629
  rows,
2165
2630
  bySheet: sheetTotals(this.conditions, this.shapes),
2166
2631
  scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown" })),
@@ -2168,6 +2633,90 @@ var Session = class {
2168
2633
  rfis: []
2169
2634
  });
2170
2635
  }
2636
+ // ── the sheet graph (#87) ─────────────────────────────────────────────────
2637
+ // Built lazily from every sheet's text spans, cached per document (loadPlan
2638
+ // clears it). The engine is pure (web/src/lib/sheetgraph.ts); this is the
2639
+ // span plumbing plus the wire shapes.
2640
+ graph = null;
2641
+ async ensureGraph() {
2642
+ if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
2643
+ if (!this.graph) {
2644
+ const inputs = [];
2645
+ for (const s of this.sheets.values()) {
2646
+ if (!s.spans) s.spans = textSpans(s.page);
2647
+ inputs.push({
2648
+ key: s.key,
2649
+ sheet_number: s.sheetNumber,
2650
+ spans: s.spans.map((t) => ({ str: t.str, x: t.x0, y: t.y0, w: t.x1 - t.x0, h: t.y1 - t.y0 }))
2651
+ });
2652
+ }
2653
+ this.graph = buildSheetGraph(inputs);
2654
+ }
2655
+ return this.graph;
2656
+ }
2657
+ static wireBox(b) {
2658
+ return { x0: round1(b[0]), y0: round1(b[1]), x1: round1(b[2]), y1: round1(b[3]) };
2659
+ }
2660
+ static wireEvidence(e) {
2661
+ return { sheet: e.sheet, text: e.text, bbox: _Session.wireBox(e.bbox) };
2662
+ }
2663
+ async sheetGraph() {
2664
+ const g = await this.ensureGraph();
2665
+ return {
2666
+ available: g.available,
2667
+ sheets: g.sheets.map((s) => ({
2668
+ sheet: s.key,
2669
+ role: s.role,
2670
+ confidence: s.confidence,
2671
+ ...s.evidence ? { evidence: _Session.wireEvidence(s.evidence) } : {},
2672
+ schedules: s.schedules.map((t) => ({ kind: t.kind, title: t.title, rows: t.rows, region: _Session.wireBox(t.region) }))
2673
+ })),
2674
+ rooms: g.rooms.map((r) => ({ tag: r.tag, name: r.name, sheet: r.sheet, bbox: _Session.wireBox(r.bbox) })),
2675
+ callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
2676
+ counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
2677
+ };
2678
+ }
2679
+ async resolveRoomTag(tag) {
2680
+ if (!tag || !tag.trim()) throw new UserError('Pass a room tag, e.g. resolve_tag { tag: "134" }.');
2681
+ const g = await this.ensureGraph();
2682
+ if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
2683
+ const res = resolveTag(g, tag);
2684
+ const room = res.room ? { tag: res.room.tag, name: res.room.name, sheet: res.room.sheet, bbox: _Session.wireBox(res.room.bbox) } : null;
2685
+ if (res.status === "unresolved") return { status: "unresolved", tag: res.tag, room, reason: res.reason };
2686
+ return {
2687
+ status: "resolved",
2688
+ tag: res.tag,
2689
+ room,
2690
+ finishes: res.finishes.map((f) => ({
2691
+ surface: f.surface,
2692
+ code: f.code,
2693
+ source: _Session.wireEvidence(f.source),
2694
+ ...f.definition ? { definition: { cells: f.definition.cells, source: _Session.wireEvidence(f.definition.source) } } : {}
2695
+ })),
2696
+ sources: res.sources.map(_Session.wireEvidence)
2697
+ };
2698
+ }
2699
+ async findSchedule(kind) {
2700
+ const g = await this.ensureGraph();
2701
+ if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable.");
2702
+ const k = (kind || "").toLowerCase();
2703
+ const want = /room/.test(k) ? "room-finish" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
2704
+ const hits = g.tables.filter((t) => t.kind === want);
2705
+ if (!hits.length) {
2706
+ const found = g.tables.map((t) => `${t.kind} on ${t.sheet}`).join(" | ");
2707
+ throw new UserError(`No ${JSON.stringify(kind)} schedule found in the set. Found: ${found || "no schedules at all"}.`);
2708
+ }
2709
+ return {
2710
+ matches: hits.map((t) => ({
2711
+ sheet: t.sheet,
2712
+ kind: t.kind,
2713
+ title: t.title?.text || "",
2714
+ rows: t.rows.length,
2715
+ headers: t.headers,
2716
+ region: _Session.wireBox(t.region)
2717
+ }))
2718
+ };
2719
+ }
2171
2720
  readSheetText(name, region) {
2172
2721
  const s = this.sheet(name);
2173
2722
  const items = region ? s.text.filter((t) => t.x >= region.x0 && t.x <= region.x1 && t.y >= region.y0 && t.y <= region.y1) : s.text;
@@ -2246,7 +2795,15 @@ var sheetInfoOutput = {
2246
2795
  has_vector_linework: z.boolean().describe("one_click needs vector linework"),
2247
2796
  scale_set: z.boolean(),
2248
2797
  upp: z.number().optional().describe("Real feet per image px at render scale 2.0 \u2014 present once the scale is set"),
2249
- shape_count: z.number().int().describe("Committed shapes on this sheet")
2798
+ shape_count: z.number().int().describe("Committed shapes on this sheet"),
2799
+ layers: z.array(z.object({
2800
+ id: z.string().describe("Optional Content Group id \u2014 pass to one_click/detect_rooms layers.include/exclude"),
2801
+ name: z.string().describe("The CAD layer name as exported (e.g. A-WALL-FULL)"),
2802
+ role: z.enum(["boundary", "finish-pattern", "annotation", "structure", "demolition", "unknown"]).describe("What this layer's linework IS to a takeoff (lib/layers.ts) \u2014 boundary/structure plot hard, pattern/annotation/demolition are excluded, unknown falls back to the hatch heuristics"),
2803
+ confidence: z.number().describe("0..1 \u2014 how sure the name classifier is"),
2804
+ visible: z.boolean().describe("Default-config visibility \u2014 a hidden layer's ink is excluded outright (or you trace demolition)"),
2805
+ seg_count: z.number().int().describe("Segments this layer owns on this sheet")
2806
+ })).describe("The sheet's PDF layer table (#85) \u2014 [] when no Optional Content survived export (every engine path then runs the heuristics unchanged)")
2250
2807
  };
2251
2808
  var setScaleOutput = {
2252
2809
  sheet: z.string(),
@@ -2451,7 +3008,8 @@ var exportReportOutput = {
2451
3008
  shape_labels: z.array(z.string()),
2452
3009
  by_label: z.array(z.record(z.unknown())),
2453
3010
  units: z.string(),
2454
- display_units: z.string()
3011
+ display_units: z.string(),
3012
+ roll_goods: z.array(z.record(z.unknown())).describe("Roll-goods order rows (#136) \u2014 order_lf / rolls / order_qty per roll-goods condition, \xD7N applied; empty when no condition carries a roll_setup (always the case for a headless session today)")
2455
3013
  };
2456
3014
  var editConditionOutput = {
2457
3015
  condition: z.string().describe("The finish tag passed in"),
@@ -2464,6 +3022,45 @@ var readSheetTextOutput = {
2464
3022
  items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
2465
3023
  text: z.string().describe("The items joined with spaces")
2466
3024
  };
3025
+ var wireBox = z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() });
3026
+ var wireEvidence = z.object({ sheet: z.string(), text: z.string(), bbox: wireBox }).describe("An evidence pointer \u2014 the sheet, the literal text, and where it sits (image px). Every edge in the graph carries one; pass the bbox to view_sheet to LOOK at the source.");
3027
+ var graphRoom = z.object({ tag: z.string(), name: z.string().describe("The name span stacked over the tag ('' when none)"), sheet: z.string(), bbox: wireBox });
3028
+ var sheetGraphOutput = {
3029
+ available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
3030
+ sheets: z.array(z.object({
3031
+ sheet: z.string(),
3032
+ role: z.enum(["plan", "schedule", "legend", "detail", "elevation", "demolition", "unknown"]),
3033
+ confidence: z.number().describe("0..1; mixed title signals halve it, a bare sheet-number convention stays under 0.5"),
3034
+ evidence: wireEvidence.optional(),
3035
+ schedules: z.array(z.object({ kind: z.string(), title: z.string(), rows: z.number().int(), region: wireBox }))
3036
+ })),
3037
+ rooms: z.array(graphRoom).describe("Room tags read off plan-role sheets \u2014 schedule sheets contribute rows, never phantom rooms"),
3038
+ callouts: z.array(z.object({ detail: z.string(), target_sheet: z.string(), sheet: z.string(), bbox: wireBox })).describe("Detail callouts (3/A-601) \u2014 edges to their target sheets"),
3039
+ counts: z.object({ rooms: z.number().int(), schedules: z.number().int(), callouts: z.number().int() })
3040
+ };
3041
+ var resolveTagOutput = {
3042
+ status: z.enum(["resolved", "unresolved"]),
3043
+ tag: z.string(),
3044
+ room: graphRoom.nullable().describe("The plan tag, when the room appears on a plan sheet \u2014 cited even when resolution fails"),
3045
+ finishes: z.array(z.object({
3046
+ surface: z.string().describe("The schedule column: FLOOR / BASE / WALL / \u2026"),
3047
+ code: z.string(),
3048
+ source: wireEvidence,
3049
+ definition: z.object({ cells: z.record(z.string()), source: wireEvidence }).optional().describe("The finish/material-schedule row this code chains to, when one exists")
3050
+ })).optional(),
3051
+ sources: z.array(wireEvidence).optional().describe("The chain: plan tag \u2192 schedule row"),
3052
+ reason: z.string().optional().describe("unresolved only \u2014 WHY (no schedule row / ambiguous / no schedule found). A room that appears on the plan with no row comes back here, never as a silent omission")
3053
+ };
3054
+ var findScheduleOutput = {
3055
+ matches: z.array(z.object({
3056
+ sheet: z.string(),
3057
+ kind: z.string(),
3058
+ title: z.string(),
3059
+ rows: z.number().int(),
3060
+ headers: z.array(z.string()),
3061
+ region: wireBox.describe("Pass to view_sheet to look at the table")
3062
+ }))
3063
+ };
2467
3064
  var hatchFamilyRow = z.object({
2468
3065
  id: z.string().describe("Content hash of the quantized (angle, pitch, pen-width) signature \u2014 the SAME id for the same pattern spec anywhere on the sheet, so legend\u2194plan matching is id === id. Identifies a pattern, not a material; the legend maps pattern \u2192 material."),
2469
3066
  angle_deg: z.number().describe("Raw mean angle [0, 180) \u2014 rides beside the id for tolerance matching at bucket boundaries"),
@@ -2535,6 +3132,10 @@ var linkAnnotationOutput = {
2535
3132
  var COORDS = "Coordinates are image px at render scale 2.0: PDF pt \xD7 2, origin top-left, y down (the browser canvas's native space). Sheet payloads carry dims in both px and pt.";
2536
3133
  var pointSchema = z2.tuple([z2.number(), z2.number()]);
2537
3134
  var roleSchema = z2.enum(["floor_area", "deduct"]).default("floor_area");
3135
+ var layersFilterSchema = z2.object({
3136
+ include: z2.array(z2.string()).optional().describe("Layer names or ids whose ink must plot as HARD boundary"),
3137
+ exclude: z2.array(z2.string()).optional().describe("Layer names or ids whose ink must not block the flood at all")
3138
+ }).optional().describe("Override the sheet's classified layer roles for THIS call (see sheet_info.layers)");
2538
3139
  var run = (tool, fn) => async (args) => {
2539
3140
  const startedAt = process.hrtime.bigint();
2540
3141
  let reply;
@@ -2585,10 +3186,11 @@ function registerTools(server, session) {
2585
3186
  condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
2586
3187
  role: roleSchema,
2587
3188
  return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)"),
2588
- sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing")
3189
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
3190
+ layers: layersFilterSchema
2589
3191
  },
2590
3192
  outputSchema: oneClickOutput
2591
- }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity })));
3193
+ }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
2592
3194
  server.registerTool("detect_rooms", {
2593
3195
  description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. ${COORDS}`,
2594
3196
  inputSchema: {
@@ -2597,10 +3199,11 @@ function registerTools(server, session) {
2597
3199
  role: roleSchema,
2598
3200
  return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
2599
3201
  min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
2600
- sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing")
3202
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
3203
+ layers: layersFilterSchema
2601
3204
  },
2602
3205
  outputSchema: detectRoomsOutput
2603
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity })));
3206
+ }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers })));
2604
3207
  server.registerTool("measure_polygon", {
2605
3208
  description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
2606
3209
  inputSchema: {
@@ -2639,10 +3242,13 @@ function registerTools(server, session) {
2639
3242
  }));
2640
3243
  server.registerTool("export_report", {
2641
3244
  description: `The computed Report document \u2014 "opentakeoff.report.v1", the same schema the canvas Report's JSON export writes. Everything a pricing consumer needs without re-implementing the app's math: per-condition quantities with waste and multiplier applied (gross and *_net), the computed materials BUY LIST per condition (order quantity = basis \xF7 coverage rate, rounded up to whole purchase units) plus the project-wide roll-up summed by (name, unit), per-sheet BASE subtotals, scale provenance per sheet, and annotations. Contrast: export_takeoff is the raw canvas payload (materials as CONFIG rows, no computed quantities) and takeoff_summary strips materials for a compact reply \u2014 when the numbers are leaving for pricing, consume this. Returned inline; pass path to also write it to disk as JSON.`,
2642
- inputSchema: { path: z2.string().optional().describe("File path to write the document to") },
3245
+ inputSchema: {
3246
+ path: z2.string().optional().describe("File path to write the document to"),
3247
+ project_name: z2.string().optional().describe("Label for the document's project_name field (a headless session has no project of its own; omitted \u2192 null)")
3248
+ },
2643
3249
  outputSchema: exportReportOutput
2644
- }, run("export_report", async ({ path: outPath }) => {
2645
- const doc = session.exportReport();
3250
+ }, run("export_report", async ({ path: outPath, project_name: projectName }) => {
3251
+ const doc = session.exportReport(projectName);
2646
3252
  if (outPath) {
2647
3253
  const { writeFile } = await import("node:fs/promises");
2648
3254
  await writeFile(outPath, JSON.stringify(doc));
@@ -2710,6 +3316,21 @@ function registerTools(server, session) {
2710
3316
  },
2711
3317
  outputSchema: undoLastOutput
2712
3318
  }, run("undo_last", ({ n }) => session.undoLast(n)));
3319
+ server.registerTool("sheet_graph", {
3320
+ description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region), every room tag on the plan sheets (with the stacked room NAME when one exists), and the detail callouts (3/A-601 \u2192 sheet edges). Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
3321
+ inputSchema: {},
3322
+ outputSchema: sheetGraphOutput
3323
+ }, run("sheet_graph", () => session.sheetGraph()));
3324
+ server.registerTool("resolve_tag", {
3325
+ description: `Resolve ONE room tag across the set (#87): the plan tag \u2192 its room-finish schedule row \u2192 each finish code's definition in the finish/material schedule, EVERY edge carrying an evidence pointer (sheet + literal text + bbox \u2014 pass a bbox to view_sheet to look at the source). The doctrine is refusal over guessing: a room that appears on the plan with no schedule row returns status "unresolved" with the reason (and still cites the plan tag); reused room numbers across the set return "ambiguous" rather than picking one. This is the answer to "what finish is specified in room 134, and how do you know". ${COORDS}`,
3326
+ inputSchema: { tag: z2.string().describe('The room tag as drawn, e.g. "134" or "139A"') },
3327
+ outputSchema: resolveTagOutput
3328
+ }, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
3329
+ server.registerTool("find_schedule", {
3330
+ description: `Locate a schedule table in the set (#87): pass a kind ("room finish", "material"/"finish") and get every matching table's sheet, title, headers, row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
3331
+ inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes) or "finish"/"material" (codes \u2192 products)') },
3332
+ outputSchema: findScheduleOutput
3333
+ }, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
2713
3334
  server.registerTool("read_sheet_text", {
2714
3335
  description: `The sheet's text with positions \u2014 items [{str, x, y}] in image px plus the joined text. Optionally restrict to a region {x0, y0, x1, y1}. Use it to read title blocks, room labels, finish schedules, and scale notes. ${COORDS}`,
2715
3336
  inputSchema: {
@@ -2863,7 +3484,7 @@ function registerResources(server, session) {
2863
3484
  // package.json
2864
3485
  var package_default = {
2865
3486
  name: "opentakeoff-mcp",
2866
- version: "0.9.3",
3487
+ version: "0.9.7",
2867
3488
  mcpName: "io.github.Kentucky-ai/opentakeoff",
2868
3489
  type: "module",
2869
3490
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.9.7",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",