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 +3 -1
- package/dist/server-core.js +663 -42
- package/package.json +1 -1
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
|
-
| `
|
|
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. |
|
package/dist/server-core.js
CHANGED
|
@@ -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/
|
|
247
|
-
var
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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: {
|
|
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.
|
|
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