opentakeoff-mcp 0.9.76 → 0.9.77
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 +1 -1
- package/dist/server-core.js +334 -36
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -194,7 +194,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
194
194
|
| `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. |
|
|
195
195
|
| `sheet_graph` | The plan-set INDEX (#87): every sheet's role with evidence, the schedule tables found, every room tag with its stacked name, the detail callouts, and every revision marker (text `Δ2`/`REV 2` tags AND drawn deltas—a bare digit inside a triangle of linework, proven from the sheet's vector geometry—in `revisions`)—how an agent decides WHAT to measure without a human enumerating rooms. |
|
|
196
196
|
| `resolve_tag` | ONE room tag → its room-finish schedule row → each code's finish/material definition, every edge cited (sheet + literal text + bbox). Refusal over guessing: `unresolved` comes back with a reason, never as silence. A delta/REV marker on the answering row rides the result as `revisions`—the codes are the post-revision answer, and you're told the ink changed. |
|
|
197
|
-
| `find_schedule` | Locate a schedule table by kind ("room finish", "material")—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. |
|
|
197
|
+
| `find_schedule` | Locate a schedule table by kind ("room finish", "material", or "equipment"—device schedules of any trade: fans, pumps, heaters, light fixtures, plumbing fixtures, diffusers, keyed by mark and proven by a device column such as CFM, WATTS, GPM, LAMPS, NECK)—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. Every stacked schedule on a sheet is read, top to bottom. |
|
|
198
198
|
| `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. |
|
|
199
199
|
| `get_sheet_vectors` | The STROKES (#367): the sheet's vector layer exactly as the engine is fed it—flat `[x1, y1, x2, y2, …]` points in image px, one meta byte per segment (curve / clip / fill-only / polyline-arc flags, pen width in the high nibble), per-segment stroke luminance, the drawn figure each segment belongs to, the placed-image area, and the PDF layer table with a per-segment layer index. Nothing classified, decimated, or merged—so a reader can run its own room finder, symbol matcher, or wall classifier against the same array and commit through the existing verbs. Paged (default 20,000 segments, ceiling 100,000) with `offset + returned + dropped === total` on every page; `next_cursor` recovers exactly what `dropped` counts. Read-only and stateless. Refuses on a scan and names `view_sheet` as the path. |
|
|
200
200
|
| `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed; `marks` (#297) burns disclosure layers in — `question` (withheld placements, orange ?-circle), `struck` (rejections, magenta struck ×), `ring` (the sweep's seed, violet double ring) — in colors off the common CAD pens, so what a reply names, the picture shows. |
|
package/dist/server-core.js
CHANGED
|
@@ -3257,6 +3257,12 @@ function isLabelBubblePx(ring, b) {
|
|
|
3257
3257
|
// ../web/src/lib/sheetgraph.ts
|
|
3258
3258
|
var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
|
|
3259
3259
|
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])];
|
|
3260
|
+
var overlapFrac = (a, b) => {
|
|
3261
|
+
const w = Math.min(a[2], b[2]) - Math.max(a[0], b[0]), h = Math.min(a[3], b[3]) - Math.max(a[1], b[1]);
|
|
3262
|
+
if (w <= 0 || h <= 0) return 0;
|
|
3263
|
+
const area = Math.max(1, (a[2] - a[0]) * (a[3] - a[1]));
|
|
3264
|
+
return w * h / area;
|
|
3265
|
+
};
|
|
3260
3266
|
var norm = (s) => (s || "").trim().toUpperCase();
|
|
3261
3267
|
var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str || "").trim().length >= 4 && (s.w || 0) > 0 && (s.h || 0) > 2 * s.w;
|
|
3262
3268
|
var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
|
|
@@ -3406,6 +3412,159 @@ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
|
|
|
3406
3412
|
var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
|
|
3407
3413
|
var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "MAT", "MATERIAL", "COMMENTS", "CASEWORK", "CABINET", "COUNTER", "COUNTERTOP", "BLDG", "BUILDING"];
|
|
3408
3414
|
var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
|
|
3415
|
+
var EQUIPMENT_HEADERS = [
|
|
3416
|
+
// identity + the shared columns
|
|
3417
|
+
"ID",
|
|
3418
|
+
"MARK",
|
|
3419
|
+
"TAG",
|
|
3420
|
+
"SYMBOL",
|
|
3421
|
+
"UNIT",
|
|
3422
|
+
"DESIGNATION",
|
|
3423
|
+
"TYPE",
|
|
3424
|
+
"FIXTURE",
|
|
3425
|
+
"DESCRIPTION",
|
|
3426
|
+
"MANUFACTURER",
|
|
3427
|
+
"MFR",
|
|
3428
|
+
"MODEL",
|
|
3429
|
+
"CATALOG",
|
|
3430
|
+
"SERVICE",
|
|
3431
|
+
"SERVES",
|
|
3432
|
+
"LOCATION",
|
|
3433
|
+
"AREA",
|
|
3434
|
+
"SIZE",
|
|
3435
|
+
"LENGTH",
|
|
3436
|
+
"WEIGHT",
|
|
3437
|
+
"QTY",
|
|
3438
|
+
"QUANTITY",
|
|
3439
|
+
"REMARKS",
|
|
3440
|
+
"NOTES",
|
|
3441
|
+
"COMMENTS",
|
|
3442
|
+
// mechanical
|
|
3443
|
+
"CFM",
|
|
3444
|
+
"GPM",
|
|
3445
|
+
"WATTS",
|
|
3446
|
+
"KW",
|
|
3447
|
+
"VOLTS",
|
|
3448
|
+
"VOLTAGE",
|
|
3449
|
+
"PHASE",
|
|
3450
|
+
"PH",
|
|
3451
|
+
"HZ",
|
|
3452
|
+
"HP",
|
|
3453
|
+
"MBH",
|
|
3454
|
+
"BTUH",
|
|
3455
|
+
"BTU",
|
|
3456
|
+
"TONS",
|
|
3457
|
+
"RPM",
|
|
3458
|
+
"ESP",
|
|
3459
|
+
"SP",
|
|
3460
|
+
"FLA",
|
|
3461
|
+
"MCA",
|
|
3462
|
+
"MOCP",
|
|
3463
|
+
"AMPS",
|
|
3464
|
+
"EAT",
|
|
3465
|
+
"LAT",
|
|
3466
|
+
"EWT",
|
|
3467
|
+
"LWT",
|
|
3468
|
+
"PSI",
|
|
3469
|
+
"FPM",
|
|
3470
|
+
"NECK",
|
|
3471
|
+
"THROW",
|
|
3472
|
+
"MOUNTING",
|
|
3473
|
+
"DAMPER",
|
|
3474
|
+
"FRAME",
|
|
3475
|
+
"AIRFLOW",
|
|
3476
|
+
// electrical
|
|
3477
|
+
"LAMP",
|
|
3478
|
+
"LAMPS",
|
|
3479
|
+
"LUMENS",
|
|
3480
|
+
"BALLAST",
|
|
3481
|
+
"DRIVER",
|
|
3482
|
+
"CIRCUIT",
|
|
3483
|
+
"BREAKER",
|
|
3484
|
+
"POLES",
|
|
3485
|
+
"KVA",
|
|
3486
|
+
"AIC",
|
|
3487
|
+
"WIRE",
|
|
3488
|
+
"CONDUIT",
|
|
3489
|
+
"FEEDER",
|
|
3490
|
+
"LOAD",
|
|
3491
|
+
"KVAR",
|
|
3492
|
+
// plumbing / fire
|
|
3493
|
+
"CW",
|
|
3494
|
+
"HW",
|
|
3495
|
+
"WASTE",
|
|
3496
|
+
"VENT",
|
|
3497
|
+
"TRAP",
|
|
3498
|
+
"DFU",
|
|
3499
|
+
"WSFU",
|
|
3500
|
+
"CONNECTION",
|
|
3501
|
+
"SUPPLY",
|
|
3502
|
+
"DRAIN",
|
|
3503
|
+
"SPRINKLER",
|
|
3504
|
+
"ORIFICE",
|
|
3505
|
+
"TEMPERATURE"
|
|
3506
|
+
];
|
|
3507
|
+
var EQUIPMENT_KEY_HEADERS = ["ID", "MARK", "TAG", "SYMBOL", "UNIT", "DESIGNATION", "TYPE", "FIXTURE"];
|
|
3508
|
+
var EQUIPMENT_ONLY = /* @__PURE__ */ new Set([
|
|
3509
|
+
"CFM",
|
|
3510
|
+
"GPM",
|
|
3511
|
+
"WATTS",
|
|
3512
|
+
"KW",
|
|
3513
|
+
"VOLTS",
|
|
3514
|
+
"VOLTAGE",
|
|
3515
|
+
"PHASE",
|
|
3516
|
+
"PH",
|
|
3517
|
+
"HZ",
|
|
3518
|
+
"HP",
|
|
3519
|
+
"MBH",
|
|
3520
|
+
"BTUH",
|
|
3521
|
+
"BTU",
|
|
3522
|
+
"TONS",
|
|
3523
|
+
"RPM",
|
|
3524
|
+
"ESP",
|
|
3525
|
+
"SP",
|
|
3526
|
+
"FLA",
|
|
3527
|
+
"MCA",
|
|
3528
|
+
"MOCP",
|
|
3529
|
+
"AMPS",
|
|
3530
|
+
"EAT",
|
|
3531
|
+
"LAT",
|
|
3532
|
+
"EWT",
|
|
3533
|
+
"LWT",
|
|
3534
|
+
"PSI",
|
|
3535
|
+
"FPM",
|
|
3536
|
+
"NECK",
|
|
3537
|
+
"THROW",
|
|
3538
|
+
"MOUNTING",
|
|
3539
|
+
"DAMPER",
|
|
3540
|
+
"AIRFLOW",
|
|
3541
|
+
"LAMP",
|
|
3542
|
+
"LAMPS",
|
|
3543
|
+
"LUMENS",
|
|
3544
|
+
"BALLAST",
|
|
3545
|
+
"DRIVER",
|
|
3546
|
+
"CIRCUIT",
|
|
3547
|
+
"BREAKER",
|
|
3548
|
+
"POLES",
|
|
3549
|
+
"KVA",
|
|
3550
|
+
"AIC",
|
|
3551
|
+
"WIRE",
|
|
3552
|
+
"CONDUIT",
|
|
3553
|
+
"FEEDER",
|
|
3554
|
+
"KVAR",
|
|
3555
|
+
"CW",
|
|
3556
|
+
"HW",
|
|
3557
|
+
"WASTE",
|
|
3558
|
+
"VENT",
|
|
3559
|
+
"TRAP",
|
|
3560
|
+
"DFU",
|
|
3561
|
+
"WSFU",
|
|
3562
|
+
"DRAIN",
|
|
3563
|
+
"SPRINKLER",
|
|
3564
|
+
"ORIFICE"
|
|
3565
|
+
]);
|
|
3566
|
+
var EQUIP_KEY_RE = /^[A-Z]{1,4}(?:-\d{1,3}|\d{1,2})[A-Z]?$/;
|
|
3567
|
+
var TYPE_KEY_RE = /^[A-Z]{1,2}\d{0,2}[A-Z]?$/;
|
|
3409
3568
|
var headerLabel = (s, vocab) => headerLabels(s, vocab)[0] ?? null;
|
|
3410
3569
|
var headerLabels = (s, vocab) => {
|
|
3411
3570
|
const out = [];
|
|
@@ -3623,9 +3782,15 @@ var isNonFinishSchedule = (title) => {
|
|
|
3623
3782
|
const u = norm(title);
|
|
3624
3783
|
return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
|
|
3625
3784
|
};
|
|
3626
|
-
function rowKeyOf(raw, kind, buildings) {
|
|
3785
|
+
function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
|
|
3627
3786
|
const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
|
|
3628
3787
|
const key = kept.replace(/\//g, "");
|
|
3788
|
+
if (kind === "equipment") {
|
|
3789
|
+
const parts = kept.split("/").filter(Boolean);
|
|
3790
|
+
const ok2 = (p) => EQUIP_KEY_RE.test(p) || typeKeyed && TYPE_KEY_RE.test(p);
|
|
3791
|
+
if (parts.length > 1 && parts.every(ok2)) return { key: parts.join("/") };
|
|
3792
|
+
return ok2(key) ? { key } : null;
|
|
3793
|
+
}
|
|
3629
3794
|
if (kind === "finish") {
|
|
3630
3795
|
const parts = kept.split("/").filter(Boolean);
|
|
3631
3796
|
if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
|
|
@@ -3722,6 +3887,7 @@ function columnStarts(rows, anchors, cfg, x0, x1) {
|
|
|
3722
3887
|
}
|
|
3723
3888
|
function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
3724
3889
|
const { x0, x1, medGap } = bandLimits(anchors);
|
|
3890
|
+
const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
|
|
3725
3891
|
const cols = columnStarts(rows, anchors, cfg, x0, x1);
|
|
3726
3892
|
const keyTol = cols && cols.cols.length > 1 ? Math.max(8, (cols.cols[1].start - cols.cols[0].start) * 0.5) : 40;
|
|
3727
3893
|
const out = [];
|
|
@@ -3774,11 +3940,20 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
3774
3940
|
if (t.x >= x0 && t.x <= x1) banded.push(t);
|
|
3775
3941
|
}
|
|
3776
3942
|
if (!banded.length) continue;
|
|
3777
|
-
|
|
3943
|
+
if (kind === "equipment" && out.length) {
|
|
3944
|
+
if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
|
|
3945
|
+
const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
|
|
3946
|
+
if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
|
|
3947
|
+
}
|
|
3948
|
+
const keyed = rowKeyOf(banded[0].str, kind, buildings, typeKeyed);
|
|
3778
3949
|
if (!keyed) {
|
|
3779
3950
|
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
3780
3951
|
continue;
|
|
3781
3952
|
}
|
|
3953
|
+
if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
|
|
3954
|
+
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
3955
|
+
continue;
|
|
3956
|
+
}
|
|
3782
3957
|
if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
|
|
3783
3958
|
if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
|
|
3784
3959
|
const row = { key: keyed.key, sheet: sheetKey, cells: {} };
|
|
@@ -3835,11 +4010,35 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
3835
4010
|
return { out, region };
|
|
3836
4011
|
}
|
|
3837
4012
|
function extractTable(sheet, kind, opts = {}) {
|
|
4013
|
+
const r = extractTableCore(sheet, kind, opts);
|
|
4014
|
+
return r && "table" in r ? r.table : null;
|
|
4015
|
+
}
|
|
4016
|
+
function extractTables(sheet, kind, opts = {}) {
|
|
4017
|
+
const out = [];
|
|
4018
|
+
let spans = sheet.spans;
|
|
4019
|
+
for (let guard = 0; guard < 12 && spans.length; guard++) {
|
|
4020
|
+
const r = extractTableCore({ ...sheet, spans }, kind, opts);
|
|
4021
|
+
if (!r) break;
|
|
4022
|
+
const mask = "table" in r ? r.table.region : r.skip;
|
|
4023
|
+
if ("table" in r) {
|
|
4024
|
+
out.push(r.table);
|
|
4025
|
+
if (r.table.title) mask[1] = Math.min(mask[1], r.table.title.bbox[1]);
|
|
4026
|
+
}
|
|
4027
|
+
const before = spans.length;
|
|
4028
|
+
spans = spans.filter((t) => {
|
|
4029
|
+
const cx = t.x + (t.w || 0) / 2, cy = t.y + (t.h || 0) / 2;
|
|
4030
|
+
return !(cx >= mask[0] - 1 && cx <= mask[2] + 1 && cy >= mask[1] - 1 && cy <= mask[3] + 1);
|
|
4031
|
+
});
|
|
4032
|
+
if (spans.length === before) break;
|
|
4033
|
+
}
|
|
4034
|
+
return out;
|
|
4035
|
+
}
|
|
4036
|
+
function extractTableCore(sheet, kind, opts = {}) {
|
|
3838
4037
|
const horiz = sheet.spans.filter((s) => !isVertical(s));
|
|
3839
4038
|
const vert = sheet.spans.filter(isVertical);
|
|
3840
4039
|
const rows = clusterRows(horiz);
|
|
3841
|
-
const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
|
|
3842
|
-
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK", "SYMBOL", "TAG"];
|
|
4040
|
+
const vocab = kind === "room-finish" ? ROOM_HEADERS : kind === "equipment" ? EQUIPMENT_HEADERS : FINISH_HEADERS;
|
|
4041
|
+
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : kind === "equipment" ? EQUIPMENT_KEY_HEADERS : ["CODE", "MARK", "SYMBOL", "TAG"];
|
|
3843
4042
|
const minHits = kind === "room-finish" ? 4 : 3;
|
|
3844
4043
|
let anchors;
|
|
3845
4044
|
let headerSpans;
|
|
@@ -3864,16 +4063,24 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
3864
4063
|
titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
|
|
3865
4064
|
if (titleFrom < -1) titleFrom = rows.length - 1;
|
|
3866
4065
|
}
|
|
4066
|
+
if (kind === "equipment" && !anchors.some((a) => EQUIPMENT_ONLY.has(a.label))) {
|
|
4067
|
+
let hb = null;
|
|
4068
|
+
for (const t of headerSpans) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
|
|
4069
|
+
return hb ? { skip: hb } : null;
|
|
4070
|
+
}
|
|
3867
4071
|
const hdrBand = bandLimits(anchors);
|
|
3868
4072
|
let region = null;
|
|
3869
4073
|
for (const t of headerSpans) {
|
|
3870
4074
|
if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
|
|
3871
4075
|
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
3872
4076
|
}
|
|
3873
|
-
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
|
|
4077
|
+
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers });
|
|
3874
4078
|
const out = banded.out;
|
|
3875
4079
|
if (banded.region) region = region ? merge(region, banded.region) : banded.region;
|
|
3876
|
-
if (!out.length)
|
|
4080
|
+
if (!out.length) {
|
|
4081
|
+
if (kind === "equipment" && region) return { skip: region };
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
3877
4084
|
const { x0, x1 } = bandLimits(anchors);
|
|
3878
4085
|
let title = null;
|
|
3879
4086
|
for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
|
|
@@ -3882,7 +4089,7 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
3882
4089
|
}
|
|
3883
4090
|
const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
|
|
3884
4091
|
if (rotated) table.rotated_headers = true;
|
|
3885
|
-
return table;
|
|
4092
|
+
return { table };
|
|
3886
4093
|
}
|
|
3887
4094
|
var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
|
|
3888
4095
|
var isContinuationTitle = (text) => {
|
|
@@ -4028,14 +4235,25 @@ function buildSheetGraph(sheets) {
|
|
|
4028
4235
|
const ctx = sheetBuilding(s);
|
|
4029
4236
|
if (ctx) ctxBySheet.set(s.key, ctx.building);
|
|
4030
4237
|
}
|
|
4238
|
+
const sheetNumberSet = /* @__PURE__ */ new Set();
|
|
4239
|
+
for (const s of sheets) {
|
|
4240
|
+
const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
|
|
4241
|
+
if (n) sheetNumberSet.add(n);
|
|
4242
|
+
}
|
|
4031
4243
|
const roles = /* @__PURE__ */ new Map();
|
|
4032
4244
|
const fragments = [];
|
|
4033
4245
|
const fragmentKinds = /* @__PURE__ */ new Map();
|
|
4034
4246
|
for (const s of withText) {
|
|
4035
4247
|
roles.set(s.key, classifySheetRole(s));
|
|
4248
|
+
const sheetFrags = [];
|
|
4249
|
+
const found2 = [];
|
|
4036
4250
|
for (const kind of ["room-finish", "finish"]) {
|
|
4037
|
-
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key) });
|
|
4038
|
-
if (
|
|
4251
|
+
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet });
|
|
4252
|
+
if (t) found2.push(t);
|
|
4253
|
+
}
|
|
4254
|
+
found2.push(...extractTables(s, "equipment", { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet }));
|
|
4255
|
+
for (const t of found2) {
|
|
4256
|
+
const kind = t.kind;
|
|
4039
4257
|
if (kind === "finish" && t.title && isNonFinishSchedule(t.title.text)) {
|
|
4040
4258
|
notes.push(`${s.key}: "${t.title.text}" names another schedule family, not a finish/material schedule \u2014 its ${t.rows.length} rows are NOT indexed as finish definitions`);
|
|
4041
4259
|
continue;
|
|
@@ -4044,9 +4262,18 @@ function buildSheetGraph(sheets) {
|
|
|
4044
4262
|
const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
|
|
4045
4263
|
if (b) t.building = b;
|
|
4046
4264
|
for (const r of t.rows) if (r.building) buildings.add(r.building);
|
|
4265
|
+
sheetFrags.push(t);
|
|
4266
|
+
}
|
|
4267
|
+
const equip = sheetFrags.filter((t) => t.kind === "equipment");
|
|
4268
|
+
for (const t of sheetFrags) {
|
|
4269
|
+
const shadow = t.kind === "finish" ? equip.find((e) => overlapFrac(t.region, e.region) >= 0.5) : void 0;
|
|
4270
|
+
if (shadow) {
|
|
4271
|
+
notes.push(`${s.key}: "${t.title?.text || "untitled finish table"}" overlaps the equipment schedule "${shadow.title?.text || "untitled"}" \u2014 indexed once, as equipment, not as a finish definition`);
|
|
4272
|
+
continue;
|
|
4273
|
+
}
|
|
4047
4274
|
fragments.push(t);
|
|
4048
4275
|
if (!fragmentKinds.has(s.key)) fragmentKinds.set(s.key, /* @__PURE__ */ new Set());
|
|
4049
|
-
fragmentKinds.get(s.key).add(kind);
|
|
4276
|
+
fragmentKinds.get(s.key).add(t.kind);
|
|
4050
4277
|
}
|
|
4051
4278
|
}
|
|
4052
4279
|
const tables = [];
|
|
@@ -9319,7 +9546,7 @@ var Session = class _Session {
|
|
|
9319
9546
|
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
9320
9547
|
for (const row of tb.rows) {
|
|
9321
9548
|
for (const part of canon(row.key).split("/").filter(Boolean)) {
|
|
9322
|
-
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
|
|
9549
|
+
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table, kind: tb.kind });
|
|
9323
9550
|
}
|
|
9324
9551
|
}
|
|
9325
9552
|
}
|
|
@@ -9378,7 +9605,7 @@ var Session = class _Session {
|
|
|
9378
9605
|
}
|
|
9379
9606
|
const paired = values.find((v) => Math.abs((v.x0 + v.x1) / 2 - cx) <= Math.max(sp.x1 - sp.x0, 1.5 * h) && v.y0 >= sp.y1 - 0.4 * h && v.y0 <= sp.y1 + 2.4 * h);
|
|
9380
9607
|
if (paired) {
|
|
9381
|
-
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
|
|
9608
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key, by: "value" });
|
|
9382
9609
|
counts[m] = (counts[m] || 0) + 1;
|
|
9383
9610
|
} else {
|
|
9384
9611
|
if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
|
|
@@ -9388,6 +9615,12 @@ var Session = class _Session {
|
|
|
9388
9615
|
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
9389
9616
|
if (segs[i] >= bx0 && segs[i] <= bx1 && segs[i + 1] >= by0 && segs[i + 1] <= by1 && segs[i + 2] >= bx0 && segs[i + 2] <= bx1 && segs[i + 3] >= by0 && segs[i + 3] <= by1) n++;
|
|
9390
9617
|
}
|
|
9618
|
+
const equipmentRow = rowCite.get(m)?.kind === "equipment";
|
|
9619
|
+
if (equipmentRow && _Session.lineworkNear(segs, [sp.x0, sp.y0, sp.x1, sp.y1], pad) >= 3) {
|
|
9620
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], sheet: sh.key, by: "label" });
|
|
9621
|
+
counts[m] = (counts[m] || 0) + 1;
|
|
9622
|
+
continue;
|
|
9623
|
+
}
|
|
9391
9624
|
rec.withheld.push({
|
|
9392
9625
|
at: [round1(cx), round1(cy)],
|
|
9393
9626
|
sheet: sh.key,
|
|
@@ -9427,10 +9660,12 @@ var Session = class _Session {
|
|
|
9427
9660
|
const cite = rowCite.get(m);
|
|
9428
9661
|
const c = cap(rec.counted, 150);
|
|
9429
9662
|
const w = cap(rec.withheld, 60);
|
|
9663
|
+
const byLabel = rec.counted.filter((h) => h.by === "label").length;
|
|
9430
9664
|
return {
|
|
9431
9665
|
mark: m,
|
|
9432
9666
|
count: rec.counted.length,
|
|
9433
|
-
...
|
|
9667
|
+
...byLabel ? { counted_by_label: byLabel } : {},
|
|
9668
|
+
...cite ? { row: { sheet: cite.sheet, key: cite.key, table: cite.table } } : { unscheduled: true },
|
|
9434
9669
|
occurrences: c.list,
|
|
9435
9670
|
...c.elided ? { occurrences_elided: c.elided } : {},
|
|
9436
9671
|
withheld: w.list,
|
|
@@ -9459,6 +9694,18 @@ var Session = class _Session {
|
|
|
9459
9694
|
withheld: named.slice(off + matches.length)
|
|
9460
9695
|
};
|
|
9461
9696
|
}
|
|
9697
|
+
/** Segments whose extent touches the box padded by `pad` — a leader, a device
|
|
9698
|
+
* outline crossing it, a tick — capped at 3 (the callers only ask "≥ 3?"). */
|
|
9699
|
+
static lineworkNear(segs, bbox, pad) {
|
|
9700
|
+
const bx0 = bbox[0] - pad, by0 = bbox[1] - pad, bx1 = bbox[2] + pad, by1 = bbox[3] + pad;
|
|
9701
|
+
let n = 0;
|
|
9702
|
+
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
9703
|
+
const sx0 = Math.min(segs[i], segs[i + 2]), sx1 = Math.max(segs[i], segs[i + 2]);
|
|
9704
|
+
const sy0 = Math.min(segs[i + 1], segs[i + 3]), sy1 = Math.max(segs[i + 1], segs[i + 3]);
|
|
9705
|
+
if (sx1 >= bx0 && sx0 <= bx1 && sy1 >= by0 && sy0 <= by1) n++;
|
|
9706
|
+
}
|
|
9707
|
+
return n;
|
|
9708
|
+
}
|
|
9462
9709
|
static labelFields(l) {
|
|
9463
9710
|
return l ? { label: l.label, label_via: l.via } : {};
|
|
9464
9711
|
}
|
|
@@ -9863,11 +10110,22 @@ var Session = class _Session {
|
|
|
9863
10110
|
return sh.spans.filter((sp) => sp.str.trim().toUpperCase() === key).map((sp) => ({ cx: (sp.x0 + sp.x1) / 2, cy: (sp.y0 + sp.y1) / 2, h: Math.max(sp.y1 - sp.y0, 6), bbox: [sp.x0, sp.y0, sp.x1, sp.y1] })).sort((a, b) => a.cy - b.cy || a.cx - b.cx);
|
|
9864
10111
|
};
|
|
9865
10112
|
const occBySheet = planSheets.map((sh) => ({ sh, occ: occOf(sh, t) }));
|
|
9866
|
-
const
|
|
10113
|
+
const tableRegionsOf = (key) => graph.tables.filter((x) => x.sheet === key).map((x) => x.region);
|
|
10114
|
+
const amidLinework = (segs, o) => _Session.lineworkNear(segs, o.bbox, 2.5 * o.h) >= 3;
|
|
10115
|
+
const inTable = (key, o) => tableRegionsOf(key).some((r2) => o.cx >= r2[0] && o.cx <= r2[2] && o.cy >= r2[1] && o.cy <= r2[3]);
|
|
10116
|
+
const drawnBySheet = [];
|
|
10117
|
+
for (const { sh, occ } of occBySheet) {
|
|
10118
|
+
const geo = await this.ensureGeometry(sh);
|
|
10119
|
+
const drawn = geo.segs.length ? occ.filter((o) => !inTable(sh.key, o) && amidLinework(geo.segs, o)) : [];
|
|
10120
|
+
drawnBySheet.push({ sh, occ: drawn });
|
|
10121
|
+
}
|
|
10122
|
+
const totalOcc = drawnBySheet.reduce((n, e) => n + e.occ.length, 0);
|
|
10123
|
+
const mentions = occBySheet.reduce((n, e) => n + e.occ.length, 0) - totalOcc;
|
|
9867
10124
|
if (!totalOcc) {
|
|
10125
|
+
if (mentions) throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) is mentioned ${mentions}\xD7 on plan sheets but never drawn \u2014 every occurrence is bare text with no linework near it (a note), so there is no instance to count. If the device is drawn without its tag, marquee one instance with symbol_sweep {scope: "set"}.`);
|
|
9868
10126
|
throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) cannot be geometrically anchored \u2014 its tag is not drawn on any plan sheet, and a fingerprint is never guessed from text alone. If the marker is drawn untagged, marquee one instance with symbol_sweep {scope: "set"}.`);
|
|
9869
10127
|
}
|
|
9870
|
-
const withOcc =
|
|
10128
|
+
const withOcc = drawnBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
|
|
9871
10129
|
const anchorSheet = withOcc[0].sh;
|
|
9872
10130
|
const anchor = withOcc[0].occ[0];
|
|
9873
10131
|
const anchorGeo = await this.ensureGeometry(anchorSheet);
|
|
@@ -9921,9 +10179,8 @@ var Session = class _Session {
|
|
|
9921
10179
|
break;
|
|
9922
10180
|
}
|
|
9923
10181
|
}
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
}
|
|
10182
|
+
const labelOnly = !fp || !anchorRect;
|
|
10183
|
+
const labelReason = labelOnly ? corro ? `the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry` : `no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}` : null;
|
|
9927
10184
|
const radiusFor = (sc) => (sc ? sc.footprint_px : fp.footprint) / 2 + anchor.h;
|
|
9928
10185
|
const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
|
|
9929
10186
|
const perSheet = [];
|
|
@@ -9933,6 +10190,17 @@ var Session = class _Session {
|
|
|
9933
10190
|
skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
|
|
9934
10191
|
continue;
|
|
9935
10192
|
}
|
|
10193
|
+
if (labelOnly) {
|
|
10194
|
+
const label_only2 = [];
|
|
10195
|
+
const text_only2 = [];
|
|
10196
|
+
for (const o of occ) {
|
|
10197
|
+
if (inTable(sh.key, o)) continue;
|
|
10198
|
+
if (amidLinework(g2.segs, o)) label_only2.push({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox });
|
|
10199
|
+
else text_only2.push({ at: [round1(o.cx), round1(o.cy)] });
|
|
10200
|
+
}
|
|
10201
|
+
perSheet.push({ state: sh, matches: [], withheld: [], excluded: [], text_only: text_only2, label_only: label_only2, candidates: { considered: 0, dropped: 0 }, complete: true, elapsed_ms: 0, scale: this.sweepRatio(anchorSheet, sh) });
|
|
10202
|
+
continue;
|
|
10203
|
+
}
|
|
9936
10204
|
const ratio = this.sweepRatio(anchorSheet, sh);
|
|
9937
10205
|
const t0 = process.hrtime.bigint();
|
|
9938
10206
|
let res;
|
|
@@ -9950,6 +10218,7 @@ var Session = class _Session {
|
|
|
9950
10218
|
const excluded = [];
|
|
9951
10219
|
const withheld = [];
|
|
9952
10220
|
const matchedOcc = /* @__PURE__ */ new Set();
|
|
10221
|
+
let duplicates = 0;
|
|
9953
10222
|
for (const m of res.matches) {
|
|
9954
10223
|
let oi = -1;
|
|
9955
10224
|
for (let k = 0; k < occ.length; k++) {
|
|
@@ -9958,6 +10227,10 @@ var Session = class _Session {
|
|
|
9958
10227
|
break;
|
|
9959
10228
|
}
|
|
9960
10229
|
}
|
|
10230
|
+
if (oi >= 0 && matchedOcc.has(oi)) {
|
|
10231
|
+
duplicates++;
|
|
10232
|
+
continue;
|
|
10233
|
+
}
|
|
9961
10234
|
if (oi >= 0) {
|
|
9962
10235
|
matchedOcc.add(oi);
|
|
9963
10236
|
matches.push({ ...m, tag_at: occ[oi].bbox });
|
|
@@ -9977,14 +10250,27 @@ var Session = class _Session {
|
|
|
9977
10250
|
matches.sort(byPos);
|
|
9978
10251
|
excluded.sort(byPos);
|
|
9979
10252
|
withheld.sort(byPos);
|
|
9980
|
-
const
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
10253
|
+
const unmatched = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R) && !inTable(sh.key, o));
|
|
10254
|
+
const label_only = unmatched.filter((o) => amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox }));
|
|
10255
|
+
const text_only = unmatched.filter((o) => !amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
|
|
10256
|
+
if (duplicates) skipped.push({ sheet: sh.key, role: "plan", reason: `${duplicates} extra fingerprint hit(s) around an already-counted tag were folded into their instance \u2014 one drawn tag is one unit` });
|
|
10257
|
+
perSheet.push({ state: sh, matches, withheld, excluded, text_only, label_only, candidates: res.candidates, complete: res.complete, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
|
|
10258
|
+
}
|
|
10259
|
+
const foundByGeometry = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
10260
|
+
const foundByLabel = perSheet.reduce((n, p) => n + p.label_only.length, 0);
|
|
10261
|
+
const found = foundByGeometry + foundByLabel;
|
|
9984
10262
|
let committed;
|
|
9985
10263
|
if (opts.commit && found) {
|
|
9986
10264
|
const ids = [];
|
|
9987
10265
|
for (const ps of perSheet) {
|
|
10266
|
+
for (const l of ps.label_only) {
|
|
10267
|
+
ids.push(this.commit(ps.state, t, "count", [l.at], { count: 1 }, {
|
|
10268
|
+
method: "manual",
|
|
10269
|
+
actor: "agent",
|
|
10270
|
+
reviewed: false,
|
|
10271
|
+
assignment: { source: "schedule", schedule_sheet: tb.sheet }
|
|
10272
|
+
}).id);
|
|
10273
|
+
}
|
|
9988
10274
|
for (const m of ps.matches) {
|
|
9989
10275
|
ids.push(this.commit(ps.state, t, "count", [m.at], { count: 1 }, {
|
|
9990
10276
|
method: "symbol_sweep",
|
|
@@ -10010,7 +10296,9 @@ var Session = class _Session {
|
|
|
10010
10296
|
const firstCell = r.cells[Object.keys(r.cells)[0]];
|
|
10011
10297
|
const capped = perSheet.filter((p) => p.candidates.dropped > 0);
|
|
10012
10298
|
const notes = [];
|
|
10013
|
-
if (
|
|
10299
|
+
if (labelOnly) notes.push(`Counted BY LABEL: ${labelReason} \u2014 so every drawn "${t}" tag amid linework on a plan sheet counts as one instance (${foundByLabel}), and a bare mention counts for nothing. Look at each before pricing.`);
|
|
10300
|
+
else if (foundByLabel) notes.push(`${foundByLabel} of ${found} counted BY LABEL: the tag is drawn there amid linework the marker fingerprint did not reach (a variant of the device \u2014 a different length or size \u2014 or a rotated placement outside the sweep's reach).`);
|
|
10301
|
+
if (!corroborated && !labelOnly) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
|
|
10014
10302
|
if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
|
|
10015
10303
|
const rowRescaled = perSheet.filter((p) => p.scaled);
|
|
10016
10304
|
if (rowRescaled.length) {
|
|
@@ -10029,7 +10317,7 @@ var Session = class _Session {
|
|
|
10029
10317
|
cells,
|
|
10030
10318
|
citation: { sheet: tb.sheet, text: `${table} row ${t}`, bbox: _Session.wireBox(firstCell?.bbox || tb.region) }
|
|
10031
10319
|
},
|
|
10032
|
-
anchor: {
|
|
10320
|
+
anchor: labelOnly ? null : {
|
|
10033
10321
|
sheet: anchorSheet.key,
|
|
10034
10322
|
at: [round1(anchor.cx), round1(anchor.cy)],
|
|
10035
10323
|
rect: [round1(anchorRect[0][0]), round1(anchorRect[0][1]), round1(anchorRect[1][0]), round1(anchorRect[1][1])],
|
|
@@ -10039,6 +10327,9 @@ var Session = class _Session {
|
|
|
10039
10327
|
occurrences: totalOcc
|
|
10040
10328
|
},
|
|
10041
10329
|
found,
|
|
10330
|
+
found_by_geometry: foundByGeometry,
|
|
10331
|
+
found_by_label: foundByLabel,
|
|
10332
|
+
counted_by: foundByLabel === 0 ? "geometry" : foundByGeometry === 0 ? "label" : "mixed",
|
|
10042
10333
|
sheets: perSheet.map((p) => ({
|
|
10043
10334
|
sheet: p.state.key,
|
|
10044
10335
|
found: p.matches.length,
|
|
@@ -10046,6 +10337,7 @@ var Session = class _Session {
|
|
|
10046
10337
|
withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
|
|
10047
10338
|
excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
|
|
10048
10339
|
text_only: p.text_only,
|
|
10340
|
+
label_only: p.label_only.map((l) => ({ at: l.at, tag_at: _Session.wireBox(l.tag_at) })),
|
|
10049
10341
|
candidates: p.candidates,
|
|
10050
10342
|
complete: p.complete,
|
|
10051
10343
|
elapsed_ms: p.elapsed_ms,
|
|
@@ -11344,7 +11636,7 @@ var Session = class _Session {
|
|
|
11344
11636
|
const g = await this.ensureGraph();
|
|
11345
11637
|
if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable.");
|
|
11346
11638
|
const k = (kind || "").toLowerCase();
|
|
11347
|
-
const want = /room/.test(k) ? "room-finish" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
|
|
11639
|
+
const want = /room/.test(k) ? "room-finish" : /equip|mechanical|mep|hvac|plumb|electr|fan|pump|heater|unit|valve|diffuser|grille|fixture|device/.test(k) ? "equipment" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
|
|
11348
11640
|
const hits = g.tables.filter((t) => t.kind === want);
|
|
11349
11641
|
if (!hits.length) {
|
|
11350
11642
|
const found = g.tables.map((t) => `${t.kind} on ${t.sheet}`).join(" | ");
|
|
@@ -12222,15 +12514,19 @@ var sweepScheduleRowOutput = {
|
|
|
12222
12514
|
length_px: z.number(),
|
|
12223
12515
|
corroborated: z.boolean().describe("true = the fingerprint recurred at a second tag occurrence before being trusted; false = the tag is drawn too sparsely to cross-check (see note)"),
|
|
12224
12516
|
occurrences: z.number().int().describe("Drawn occurrences of the tag across all plan sheets")
|
|
12225
|
-
}),
|
|
12226
|
-
found: z.number().int().describe("
|
|
12517
|
+
}).nullable().describe("null when the sweep counted BY LABEL: no repeatable marker geometry sits around the drawn tag (the equipment convention \u2014 a device drawn to its own size, tagged by a leader), so nothing was fingerprinted"),
|
|
12518
|
+
found: z.number().int().describe("Instances counted across every plan sheet: geometry matches carrying the row's own tag PLUS drawn tags counted by label \u2014 see counted_by"),
|
|
12519
|
+
found_by_geometry: z.number().int().describe("\u2026of which marker-fingerprint matches corroborated by the tag"),
|
|
12520
|
+
found_by_label: z.number().int().describe("\u2026of which drawn tags amid linework the fingerprint did not (or could not) reach \u2014 an installed instance by the equipment convention, disclosed as label-counted"),
|
|
12521
|
+
counted_by: z.enum(["geometry", "label", "mixed"]).describe('How the count was made \u2014 "label" means no marker geometry was matched at all; look at each label_only placement before pricing'),
|
|
12227
12522
|
sheets: z.array(z.object({
|
|
12228
12523
|
sheet: z.string(),
|
|
12229
12524
|
found: z.number().int(),
|
|
12230
12525
|
matches: z.array(z.object({ ...rowSweepPlacement, tag_at: wireBox.describe("The corroborating tag text's bbox \u2014 the evidence that this marker is THIS row's") })),
|
|
12231
12526
|
withheld: z.array(z.object({ ...rowSweepPlacement, reason: z.string() })).describe("Questions, never counts: markers matching the geometry but carrying no tag (an unlabeled instance or a shared bubble shape), and near-miss scores in the [0.75, 0.92) band"),
|
|
12232
12527
|
excluded: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag: z.string() })).describe("Markers matching the geometry but labeled with a SIBLING row's tag \u2014 the bubble shape is shared across marks, so these belong to that row, not this one"),
|
|
12233
|
-
text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with
|
|
12528
|
+
text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with no marker match AND no linework near it \u2014 a note mentioning the mark; a question, never a count"),
|
|
12529
|
+
label_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag_at: wireBox })).describe("Instances counted BY LABEL: the row's tag drawn amid linework that the marker fingerprint did not match (or none was anchored) \u2014 the equipment convention; each is counted and each deserves a look"),
|
|
12234
12530
|
candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
|
|
12235
12531
|
complete: z.boolean().describe("True when every proposed placement on this sheet was scored \u2014 false means this sheet's count is a FLOOR, not a total (#261)"),
|
|
12236
12532
|
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
|
|
@@ -12404,8 +12700,9 @@ var deleteRfiOutput = {
|
|
|
12404
12700
|
};
|
|
12405
12701
|
var censusOccurrence = {
|
|
12406
12702
|
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
|
|
12407
|
-
value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
|
|
12408
|
-
sheet: z.string()
|
|
12703
|
+
value: z.string().optional().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance); absent when counted by label"),
|
|
12704
|
+
sheet: z.string(),
|
|
12705
|
+
by: z.enum(["value", "label"]).describe('"value" = tag-over-value (air devices, fixtures); "label" = an EQUIPMENT-schedule mark drawn amid linework with no value \u2014 the leader-tag convention, counted as one instance and said so')
|
|
12409
12706
|
};
|
|
12410
12707
|
var censusWithheld = {
|
|
12411
12708
|
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
|
|
@@ -12415,7 +12712,8 @@ var censusWithheld = {
|
|
|
12415
12712
|
var countMarksOutput = {
|
|
12416
12713
|
marks: z.array(z.object({
|
|
12417
12714
|
mark: z.string(),
|
|
12418
|
-
count: z.number().int().describe("
|
|
12715
|
+
count: z.number().int().describe("Instances counted on plan-role sheets \u2014 value-paired tags, plus (for an equipment-schedule mark) tags drawn amid linework, see counted_by_label"),
|
|
12716
|
+
counted_by_label: z.number().int().optional().describe("\u2026of which counted by label (an equipment mark with a leader, no value under it)"),
|
|
12419
12717
|
row: z.object({ sheet: z.string(), key: z.string(), table: z.string() }).optional().describe("The schedule row that answers for this mark (a compound key answers for each part)"),
|
|
12420
12718
|
unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
|
|
12421
12719
|
occurrences: z.array(z.object(censusOccurrence)),
|
|
@@ -14251,7 +14549,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14251
14549
|
commitSeed: a.commit_seed
|
|
14252
14550
|
})));
|
|
14253
14551
|
server.registerTool("sweep_schedule_row", {
|
|
14254
|
-
description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}
|
|
14552
|
+
description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS} LABEL-FIRST for devices (any trade): when the marker cannot be fingerprinted or does not reach a drawn tag amid linework \u2014 a heater bar or fan drawn to its own size, tagged by a leader \u2014 every such tag on a plan sheet counts as ONE instance BY LABEL, disclosed in found_by_label / label_only / counted_by; a bare mention in a note (no linework near it) is text_only, never a count. Rows come from every schedule family the sheet graph reads: room-finish, finish/material, and equipment (mechanical, electrical, plumbing, fire).`,
|
|
14255
14553
|
inputSchema: {
|
|
14256
14554
|
tag: z2.string().min(1).describe("The schedule row's key exactly as drawn, e.g. 'T1', 'TR-2' \u2014 it becomes the condition tag on commit"),
|
|
14257
14555
|
commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
|
|
@@ -14267,7 +14565,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14267
14565
|
tolerancePx: a.tolerance_px
|
|
14268
14566
|
})));
|
|
14269
14567
|
server.registerTool("count_marks", {
|
|
14270
|
-
description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS}
|
|
14568
|
+
description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS} EQUIPMENT marks (a row in an equipment/device schedule \u2014 fans, pumps, heaters, fixtures, panels) follow the leader-tag convention instead: a scheduled mark drawn amid linework with no value under it is counted BY LABEL (occurrence by: "label", counted_by_label on the mark); a bare mention in a note still withholds.`,
|
|
14271
14569
|
inputSchema: {
|
|
14272
14570
|
marks: z2.array(z2.string().min(1)).optional().describe(`The marks to census, e.g. ["S1", "R1"] \u2014 omit to take them from the schedule tables' row keys`),
|
|
14273
14571
|
commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
|
|
@@ -14571,7 +14869,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
14571
14869
|
}, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
|
|
14572
14870
|
server.registerTool("find_schedule", {
|
|
14573
14871
|
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, TOTAL row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. A schedule continued across sheets is ONE match whose "parts" list every fragment (base first) with its own viewable region; tables read through rotated headers say so; a table answering for one building carries "building"; a table with delta/REV-marked rows says how many in "revised_rows". Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
|
|
14574
|
-
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes)
|
|
14872
|
+
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes), "finish"/"material" (codes \u2192 products), or "equipment" (MEP device schedules \u2014 fans, pumps, heaters, AHUs, VAVs, diffusers/grilles/registers \u2014 keyed by mark, proven by a powered or air-device column)') },
|
|
14575
14873
|
outputSchema: findScheduleOutput
|
|
14576
14874
|
}, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
|
|
14577
14875
|
server.registerTool("read_sheet_text", {
|
|
@@ -14953,7 +15251,7 @@ function nameTheStageInRefusals(server) {
|
|
|
14953
15251
|
// package.json
|
|
14954
15252
|
var package_default = {
|
|
14955
15253
|
name: "opentakeoff-mcp",
|
|
14956
|
-
version: "0.9.
|
|
15254
|
+
version: "0.9.77",
|
|
14957
15255
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
14958
15256
|
type: "module",
|
|
14959
15257
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -14970,7 +15268,7 @@ var package_default = {
|
|
|
14970
15268
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
14971
15269
|
typecheck: "tsc --noEmit",
|
|
14972
15270
|
"check:tool-count": "node scripts/check-tool-count.mjs",
|
|
14973
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/
|
|
15271
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
14974
15272
|
},
|
|
14975
15273
|
dependencies: {
|
|
14976
15274
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.77",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
19
|
"check:tool-count": "node scripts/check-tool-count.mjs",
|
|
20
|
-
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/
|
|
20
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@modelcontextprotocol/sdk": "^1.12.0",
|