opentakeoff-mcp 0.9.42 → 0.9.44
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 -3
- package/dist/server-core.js +286 -77
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,9 +162,9 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
162
162
|
| `delete_verdict` | Lift an agent verdict mark by id. Agent marks only — the estimator's seal is human ink and is refused, the same line `edit_shape` holds on reviewed shapes. `undo_last` re-seats a lifted mark exactly where it was. |
|
|
163
163
|
| `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
|
|
164
164
|
| `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. |
|
|
165
|
-
| `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 — how an agent decides WHAT to measure without a human enumerating rooms. |
|
|
166
|
-
| `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. |
|
|
167
|
-
| `find_schedule` | Locate a schedule table by kind ("room finish", "material") — sheet, title, headers, row count,
|
|
165
|
+
| `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. |
|
|
166
|
+
| `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. |
|
|
167
|
+
| `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. |
|
|
168
168
|
| `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. |
|
|
169
169
|
| `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. |
|
|
170
170
|
|
package/dist/server-core.js
CHANGED
|
@@ -181,6 +181,12 @@ async function openPdf(filePath) {
|
|
|
181
181
|
viewport: { width: vp.width, height: vp.height, transform: vp.transform },
|
|
182
182
|
textContent,
|
|
183
183
|
operatorList: async () => await page.getOperatorList(),
|
|
184
|
+
cleanup: () => {
|
|
185
|
+
try {
|
|
186
|
+
page.cleanup();
|
|
187
|
+
} catch {
|
|
188
|
+
}
|
|
189
|
+
},
|
|
184
190
|
async renderPng(scale) {
|
|
185
191
|
await ensureCanvasGlobals();
|
|
186
192
|
const rvp = page.getViewport({ scale });
|
|
@@ -2580,6 +2586,79 @@ function sheetBuilding(sheet) {
|
|
|
2580
2586
|
const [building, span] = [...seen.entries()][0];
|
|
2581
2587
|
return { building, evidence: { sheet: sheet.key, text: span.str.trim(), bbox: bboxOf(span) } };
|
|
2582
2588
|
}
|
|
2589
|
+
var DELTA_MARK_RE = /^[Δ∆△▲]\s*(\d{1,2}[A-Z]?)$|^(\d{1,2}[A-Z]?)\s*[Δ∆△▲]$/;
|
|
2590
|
+
var REV_MARK_RE = /^REV(?:ISION)?\.?\s*#?\s*(\d{1,2}[A-Z]?)$/;
|
|
2591
|
+
var revisionOf = (s) => {
|
|
2592
|
+
const t = norm(s);
|
|
2593
|
+
if (!t || t.length > 12) return null;
|
|
2594
|
+
const d = t.match(DELTA_MARK_RE);
|
|
2595
|
+
if (d) return d[1] ?? d[2];
|
|
2596
|
+
const r = t.match(REV_MARK_RE);
|
|
2597
|
+
return r ? r[1] : null;
|
|
2598
|
+
};
|
|
2599
|
+
var BARE_DIGIT_RE = /^\d{1,2}$/;
|
|
2600
|
+
function drawnDeltaMarkers(spans, segs) {
|
|
2601
|
+
const cands = spans.filter((s) => BARE_DIGIT_RE.test((s.str || "").trim()));
|
|
2602
|
+
if (!cands.length || !segs.length) return [];
|
|
2603
|
+
const CELL = 64;
|
|
2604
|
+
const grid = /* @__PURE__ */ new Map();
|
|
2605
|
+
const nSeg = Math.floor(segs.length / 4);
|
|
2606
|
+
for (let i = 0; i < nSeg; i++) {
|
|
2607
|
+
const dx = segs[i * 4 + 2] - segs[i * 4], dy = segs[i * 4 + 3] - segs[i * 4 + 1];
|
|
2608
|
+
const len = Math.hypot(dx, dy);
|
|
2609
|
+
if (len < 4 || len > 400) continue;
|
|
2610
|
+
const mx = (segs[i * 4] + segs[i * 4 + 2]) / 2, my = (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2;
|
|
2611
|
+
const k = `${Math.floor(mx / CELL)},${Math.floor(my / CELL)}`;
|
|
2612
|
+
let cell = grid.get(k);
|
|
2613
|
+
if (!cell) grid.set(k, cell = []);
|
|
2614
|
+
cell.push(i);
|
|
2615
|
+
}
|
|
2616
|
+
const out = [];
|
|
2617
|
+
for (const sp of cands) {
|
|
2618
|
+
const h = Math.max(sp.h || 8, 6);
|
|
2619
|
+
const cx = sp.x + (sp.w || 0) / 2, cy = sp.y + h / 2;
|
|
2620
|
+
const R = h * 5;
|
|
2621
|
+
const near = [];
|
|
2622
|
+
for (let gx = Math.floor((cx - R) / CELL); gx <= Math.floor((cx + R) / CELL); gx++) {
|
|
2623
|
+
for (let gy = Math.floor((cy - R) / CELL); gy <= Math.floor((cy + R) / CELL); gy++) {
|
|
2624
|
+
for (const i of grid.get(`${gx},${gy}`) || []) {
|
|
2625
|
+
const mx = (segs[i * 4] + segs[i * 4 + 2]) / 2, my = (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2;
|
|
2626
|
+
const len = Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
2627
|
+
if (Math.hypot(mx - cx, my - cy) <= R && len >= h * 1.2 && len <= h * 8) near.push(i);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
if (near.length < 3 || near.length > 60) continue;
|
|
2632
|
+
const tol = Math.max(2, h * 0.35);
|
|
2633
|
+
let best = null;
|
|
2634
|
+
let bestArea = Infinity;
|
|
2635
|
+
const P = (i, end) => [segs[i * 4 + end * 2], segs[i * 4 + 1 + end * 2]];
|
|
2636
|
+
const close = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol;
|
|
2637
|
+
for (let a = 0; a < near.length; a++) for (let b = a + 1; b < near.length; b++) for (let c = b + 1; c < near.length; c++) {
|
|
2638
|
+
for (const fa of [0, 1]) for (const fb of [0, 1]) for (const fc of [0, 1]) {
|
|
2639
|
+
const [a0, a1] = [P(near[a], fa), P(near[a], 1 - fa)];
|
|
2640
|
+
const [b0, b1] = [P(near[b], fb), P(near[b], 1 - fb)];
|
|
2641
|
+
const [c0, c1] = [P(near[c], fc), P(near[c], 1 - fc)];
|
|
2642
|
+
if (!close(a1, b0) || !close(b1, c0) || !close(c1, a0)) continue;
|
|
2643
|
+
const v = [a0, b0, c0];
|
|
2644
|
+
const side = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]);
|
|
2645
|
+
const s01 = side(v[0], v[1]), s12 = side(v[1], v[2]), s20 = side(v[2], v[0]);
|
|
2646
|
+
const mx = Math.max(s01, s12, s20), mn = Math.min(s01, s12, s20);
|
|
2647
|
+
if (mn < h * 1.2 || mx > h * 8 || mx / mn > 2.5) continue;
|
|
2648
|
+
const cross = (p, q) => (q[0] - p[0]) * (cy - p[1]) - (q[1] - p[1]) * (cx - p[0]);
|
|
2649
|
+
const d0 = cross(v[0], v[1]), d1 = cross(v[1], v[2]), d2 = cross(v[2], v[0]);
|
|
2650
|
+
if (!(d0 > 0 && d1 > 0 && d2 > 0 || d0 < 0 && d1 < 0 && d2 < 0)) continue;
|
|
2651
|
+
const area = Math.abs((v[1][0] - v[0][0]) * (v[2][1] - v[0][1]) - (v[2][0] - v[0][0]) * (v[1][1] - v[0][1])) / 2;
|
|
2652
|
+
if (area < bestArea) {
|
|
2653
|
+
bestArea = area;
|
|
2654
|
+
best = [Math.min(v[0][0], v[1][0], v[2][0]), Math.min(v[0][1], v[1][1], v[2][1]), Math.max(v[0][0], v[1][0], v[2][0]), Math.max(v[0][1], v[1][1], v[2][1])];
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
if (best) out.push({ span: sp, tri: best });
|
|
2659
|
+
}
|
|
2660
|
+
return out;
|
|
2661
|
+
}
|
|
2583
2662
|
function clusterRows(spans) {
|
|
2584
2663
|
const toks = spans.filter((t) => t.str && t.str.trim()).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
2585
2664
|
const rows = [];
|
|
@@ -2612,7 +2691,7 @@ function findHeaderRow(rows, vocab, required, minHits) {
|
|
|
2612
2691
|
const w = headerLabel(t.str, vocab);
|
|
2613
2692
|
if (w && !seen.has(w)) {
|
|
2614
2693
|
seen.add(w);
|
|
2615
|
-
anchors.push({ label: w, x: t.x });
|
|
2694
|
+
anchors.push({ label: w, x: t.x + (t.w || 0) / 2 });
|
|
2616
2695
|
}
|
|
2617
2696
|
}
|
|
2618
2697
|
if (anchors.length < minHits || !required.some((r) => seen.has(r))) continue;
|
|
@@ -2675,6 +2754,80 @@ function rowKeyOf(raw, kind, buildings) {
|
|
|
2675
2754
|
return null;
|
|
2676
2755
|
}
|
|
2677
2756
|
var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
|
|
2757
|
+
var centerX = (t) => t.x + (t.w || 0) / 2;
|
|
2758
|
+
function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
2759
|
+
const { x0, x1, medGap } = bandLimits(anchors);
|
|
2760
|
+
const out = [];
|
|
2761
|
+
const outY = [];
|
|
2762
|
+
let region = null;
|
|
2763
|
+
const add = (row, toks) => {
|
|
2764
|
+
for (const t of toks) {
|
|
2765
|
+
const label = nearestAnchor(centerX(t), anchors);
|
|
2766
|
+
const text = t.str.trim();
|
|
2767
|
+
if (!row.cells[label]) row.cells[label] = { text, bbox: bboxOf(t) };
|
|
2768
|
+
else row.cells[label] = { text: `${row.cells[label].text} ${text}`, bbox: merge(row.cells[label].bbox, bboxOf(t)) };
|
|
2769
|
+
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
const orphans = [];
|
|
2773
|
+
const markers = [];
|
|
2774
|
+
for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
|
|
2775
|
+
if (rowY(rows[i]) <= cfg.belowY) continue;
|
|
2776
|
+
const banded = [];
|
|
2777
|
+
for (const t of rows[i]) {
|
|
2778
|
+
const tri = cfg.deltas?.get(t);
|
|
2779
|
+
const rev = tri ? norm(t.str) : revisionOf(t.str);
|
|
2780
|
+
if (rev != null) {
|
|
2781
|
+
if (centerX(t) >= x0 - 2.5 * medGap && centerX(t) <= x1 + medGap) markers.push({ rev, span: t, ...tri ? { drawn: true, tri } : {} });
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2784
|
+
if (t.x >= x0 && t.x <= x1) banded.push(t);
|
|
2785
|
+
}
|
|
2786
|
+
if (!banded.length) continue;
|
|
2787
|
+
const keyed = rowKeyOf(banded[0].str, kind, buildings);
|
|
2788
|
+
if (!keyed) {
|
|
2789
|
+
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
2790
|
+
continue;
|
|
2791
|
+
}
|
|
2792
|
+
if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
|
|
2793
|
+
const row = { key: keyed.key, sheet: sheetKey, cells: {} };
|
|
2794
|
+
if (keyed.building) row.building = keyed.building;
|
|
2795
|
+
add(row, banded);
|
|
2796
|
+
out.push(row);
|
|
2797
|
+
outY.push(rowY(rows[i]));
|
|
2798
|
+
}
|
|
2799
|
+
const gaps = outY.slice(1).map((y, i) => y - outY[i]).filter((d) => d > 0).sort((a, b) => a - b);
|
|
2800
|
+
const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
|
|
2801
|
+
const nearest = (y) => {
|
|
2802
|
+
let bi = -1, bd = Infinity;
|
|
2803
|
+
outY.forEach((ry, i) => {
|
|
2804
|
+
const d = Math.abs(y - ry);
|
|
2805
|
+
if (d < bd) {
|
|
2806
|
+
bd = d;
|
|
2807
|
+
bi = i;
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
return { i: bi, d: bd };
|
|
2811
|
+
};
|
|
2812
|
+
const radius = (h) => pitch ? pitch * 0.6 : Math.max(h, 8) * 1.6;
|
|
2813
|
+
for (const o of orphans) {
|
|
2814
|
+
const { i, d } = nearest(o.y);
|
|
2815
|
+
if (i < 0 || d > radius(Math.max(...o.toks.map((t) => t.h || 8)))) continue;
|
|
2816
|
+
add(out[i], o.toks);
|
|
2817
|
+
}
|
|
2818
|
+
for (const m of markers) {
|
|
2819
|
+
const { i, d } = nearest(m.span.y);
|
|
2820
|
+
if (i < 0 || d > radius(m.span.h || 8) || out[i].revision) continue;
|
|
2821
|
+
const ebox = m.tri ? merge(bboxOf(m.span), m.tri) : bboxOf(m.span);
|
|
2822
|
+
out[i].revision = { rev: m.rev, source: { sheet: sheetKey, text: m.span.str.trim(), bbox: ebox }, ...m.drawn ? { drawn: true } : {} };
|
|
2823
|
+
}
|
|
2824
|
+
for (const row of out) {
|
|
2825
|
+
if (row.building) continue;
|
|
2826
|
+
const cellB = norm(row.cells.BLDG?.text || row.cells.BUILDING?.text || "");
|
|
2827
|
+
if (DESIGNATOR_RE.test(cellB)) row.building = cellB;
|
|
2828
|
+
}
|
|
2829
|
+
return { out, region };
|
|
2830
|
+
}
|
|
2678
2831
|
function extractTable(sheet, kind, opts = {}) {
|
|
2679
2832
|
const horiz = sheet.spans.filter((s) => !isVertical(s));
|
|
2680
2833
|
const vert = sheet.spans.filter(isVertical);
|
|
@@ -2705,33 +2858,13 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
2705
2858
|
titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
|
|
2706
2859
|
if (titleFrom < -1) titleFrom = rows.length - 1;
|
|
2707
2860
|
}
|
|
2708
|
-
const out = [];
|
|
2709
2861
|
let region = null;
|
|
2710
2862
|
for (const t of headerSpans) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
2711
|
-
const {
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
const inBand = rows[i].filter((t) => t.x >= x0 && t.x <= x1);
|
|
2715
|
-
if (!inBand.length) continue;
|
|
2716
|
-
const keyed = rowKeyOf(inBand[0].str, kind, opts.buildings);
|
|
2717
|
-
if (!keyed) continue;
|
|
2718
|
-
const cells = {};
|
|
2719
|
-
for (const t of inBand) {
|
|
2720
|
-
const label = nearestAnchor(t.x, anchors);
|
|
2721
|
-
const text = t.str.trim();
|
|
2722
|
-
if (!cells[label]) cells[label] = { text, bbox: bboxOf(t) };
|
|
2723
|
-
else {
|
|
2724
|
-
cells[label] = { text: `${cells[label].text} ${text}`, bbox: merge(cells[label].bbox, bboxOf(t)) };
|
|
2725
|
-
}
|
|
2726
|
-
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
2727
|
-
}
|
|
2728
|
-
const row = { key: keyed.key, sheet: sheet.key, cells };
|
|
2729
|
-
const cellB = norm(cells.BLDG?.text || cells.BUILDING?.text || "");
|
|
2730
|
-
const b = keyed.building ?? (DESIGNATOR_RE.test(cellB) ? cellB : void 0);
|
|
2731
|
-
if (b) row.building = b;
|
|
2732
|
-
out.push(row);
|
|
2733
|
-
}
|
|
2863
|
+
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
|
|
2864
|
+
const out = banded.out;
|
|
2865
|
+
if (banded.region) region = region ? merge(region, banded.region) : banded.region;
|
|
2734
2866
|
if (!out.length) return null;
|
|
2867
|
+
const { x0, x1 } = bandLimits(anchors);
|
|
2735
2868
|
let title = null;
|
|
2736
2869
|
for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
|
|
2737
2870
|
const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
|
|
@@ -2763,40 +2896,25 @@ function mergeContinuation(base, frag) {
|
|
|
2763
2896
|
base.parts.push({ sheet: frag.sheet, title: frag.title?.text || "", rows: frag.rows.length, region: frag.region, ...frag.rotated_headers ? { rotated_headers: true } : {} });
|
|
2764
2897
|
base.rows.push(...frag.rows);
|
|
2765
2898
|
}
|
|
2766
|
-
function adoptContinuationRows(sheet, titleSpan, base, buildings) {
|
|
2899
|
+
function adoptContinuationRows(sheet, titleSpan, base, buildings, deltas) {
|
|
2767
2900
|
if (!base.anchors?.length || base.kind === "unknown") return null;
|
|
2768
2901
|
const rows = clusterRows(sheet.spans.filter((s) => !isVertical(s)));
|
|
2769
|
-
const {
|
|
2902
|
+
const { medGap } = bandLimits(base.anchors);
|
|
2770
2903
|
const keyTol = Math.max(40, medGap / 2);
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
const cells = {};
|
|
2780
|
-
for (const t of inBand) {
|
|
2781
|
-
const label = nearestAnchor(t.x, base.anchors);
|
|
2782
|
-
const text = t.str.trim();
|
|
2783
|
-
if (!cells[label]) cells[label] = { text, bbox: bboxOf(t) };
|
|
2784
|
-
else {
|
|
2785
|
-
cells[label] = { text: `${cells[label].text} ${text}`, bbox: merge(cells[label].bbox, bboxOf(t)) };
|
|
2786
|
-
}
|
|
2787
|
-
region = merge(region, bboxOf(t));
|
|
2788
|
-
}
|
|
2789
|
-
const r = { key: keyed.key, sheet: sheet.key, cells };
|
|
2790
|
-
if (keyed.building) r.building = keyed.building;
|
|
2791
|
-
out.push(r);
|
|
2792
|
-
}
|
|
2793
|
-
if (!out.length) return null;
|
|
2904
|
+
const banded = bandDataRows(rows, base.anchors, base.kind, sheet.key, buildings, {
|
|
2905
|
+
fromIdx: 0,
|
|
2906
|
+
belowY: titleSpan.y,
|
|
2907
|
+
keyAlign: { x: base.anchors[0].x, tol: keyTol },
|
|
2908
|
+
deltas
|
|
2909
|
+
});
|
|
2910
|
+
if (!banded.out.length) return null;
|
|
2911
|
+
const region = banded.region ? merge(bboxOf(titleSpan), banded.region) : bboxOf(titleSpan);
|
|
2794
2912
|
return {
|
|
2795
2913
|
kind: base.kind,
|
|
2796
2914
|
sheet: sheet.key,
|
|
2797
2915
|
title: { sheet: sheet.key, text: titleSpan.str.trim(), bbox: bboxOf(titleSpan) },
|
|
2798
2916
|
headers: base.headers,
|
|
2799
|
-
rows: out,
|
|
2917
|
+
rows: banded.out,
|
|
2800
2918
|
region
|
|
2801
2919
|
};
|
|
2802
2920
|
}
|
|
@@ -2811,6 +2929,7 @@ function roomTags(sheet, opts = {}) {
|
|
|
2811
2929
|
return { ok: false };
|
|
2812
2930
|
};
|
|
2813
2931
|
for (const sp of spans) {
|
|
2932
|
+
if (opts.deltas?.has(sp)) continue;
|
|
2814
2933
|
const t = sp.str.trim();
|
|
2815
2934
|
const a = accept(t);
|
|
2816
2935
|
if (!a.ok) continue;
|
|
@@ -2834,6 +2953,30 @@ function roomTags(sheet, opts = {}) {
|
|
|
2834
2953
|
if (a.building) tag.building = a.building;
|
|
2835
2954
|
out.push(tag);
|
|
2836
2955
|
}
|
|
2956
|
+
const markers = [];
|
|
2957
|
+
for (const c of spans) {
|
|
2958
|
+
const tri = opts.deltas?.get(c);
|
|
2959
|
+
if (tri) markers.push({ rev: norm(c.str), span: c, box: merge(bboxOf(c), tri), drawn: true });
|
|
2960
|
+
else {
|
|
2961
|
+
const rev = revisionOf(c.str);
|
|
2962
|
+
if (rev != null) markers.push({ rev, span: c, box: bboxOf(c) });
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
for (const tag of out) {
|
|
2966
|
+
const hgt = Math.max(tag.bbox[3] - tag.bbox[1], 6);
|
|
2967
|
+
let bestM = null;
|
|
2968
|
+
let bd = Infinity;
|
|
2969
|
+
for (const m of markers) {
|
|
2970
|
+
const dx = Math.max(tag.bbox[0] - m.box[2], m.box[0] - tag.bbox[2], 0);
|
|
2971
|
+
const dy = Math.max(tag.bbox[1] - m.box[3], m.box[1] - tag.bbox[3], 0);
|
|
2972
|
+
const d = Math.hypot(dx, dy);
|
|
2973
|
+
if (d <= hgt * 2.5 && d < bd) {
|
|
2974
|
+
bd = d;
|
|
2975
|
+
bestM = m;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
if (bestM) tag.revision = { rev: bestM.rev, source: { sheet: sheet.key, text: bestM.span.str.trim(), bbox: bestM.box }, ...bestM.drawn ? { drawn: true } : {} };
|
|
2979
|
+
}
|
|
2837
2980
|
return out;
|
|
2838
2981
|
}
|
|
2839
2982
|
var CALLOUT_RE = /^(\d{1,2})\s*\/\s*([A-Z]{1,2}-?\d{1,3}(?:\.\d+)?)$/;
|
|
@@ -2847,8 +2990,23 @@ function detailCallouts(sheet) {
|
|
|
2847
2990
|
}
|
|
2848
2991
|
function buildSheetGraph(sheets) {
|
|
2849
2992
|
const withText = sheets.filter((s) => s.spans.length > 0);
|
|
2850
|
-
if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], notes: [] };
|
|
2993
|
+
if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], revisions: [], notes: [] };
|
|
2851
2994
|
const notes = [];
|
|
2995
|
+
const deltasBySheet = /* @__PURE__ */ new Map();
|
|
2996
|
+
const revisions = [];
|
|
2997
|
+
for (const s of withText) {
|
|
2998
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
2999
|
+
if (s.segs?.length) for (const d of drawnDeltaMarkers(s.spans, s.segs)) deltas.set(d.span, d.tri);
|
|
3000
|
+
if (deltas.size) deltasBySheet.set(s.key, deltas);
|
|
3001
|
+
for (const sp of s.spans) {
|
|
3002
|
+
const tri = deltas.get(sp);
|
|
3003
|
+
if (tri) revisions.push({ rev: norm(sp.str), sheet: s.key, bbox: merge(bboxOf(sp), tri), drawn: true });
|
|
3004
|
+
else {
|
|
3005
|
+
const rev = revisionOf(sp.str);
|
|
3006
|
+
if (rev != null) revisions.push({ rev, sheet: s.key, bbox: bboxOf(sp) });
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
2852
3010
|
const ctxBySheet = /* @__PURE__ */ new Map();
|
|
2853
3011
|
const buildings = /* @__PURE__ */ new Set();
|
|
2854
3012
|
for (const s of withText) {
|
|
@@ -2862,7 +3020,7 @@ function buildSheetGraph(sheets) {
|
|
|
2862
3020
|
for (const s of withText) {
|
|
2863
3021
|
roles.set(s.key, classifySheetRole(s));
|
|
2864
3022
|
for (const kind of ["room-finish", "finish"]) {
|
|
2865
|
-
const t = extractTable(s, kind, { buildings });
|
|
3023
|
+
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key) });
|
|
2866
3024
|
if (!t) continue;
|
|
2867
3025
|
const titleB = t.title ? buildingMentions(t.title.text) : [];
|
|
2868
3026
|
const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
|
|
@@ -2891,7 +3049,7 @@ function buildSheetGraph(sheets) {
|
|
|
2891
3049
|
const fragBase = baseTitleOf(text);
|
|
2892
3050
|
const base = [...tables].reverse().find((t) => t.kind !== "unknown" && t.title && baseTitleOf(t.title.text) === fragBase && t.sheet !== s.key && !t.parts?.some((p) => p.sheet === s.key));
|
|
2893
3051
|
if (!base || fragmentKinds.get(s.key)?.has(base.kind)) continue;
|
|
2894
|
-
const adopted = adoptContinuationRows(s, sp, base, buildings);
|
|
3052
|
+
const adopted = adoptContinuationRows(s, sp, base, buildings, deltasBySheet.get(s.key));
|
|
2895
3053
|
if (adopted) {
|
|
2896
3054
|
if (adopted.building == null && ctxBySheet.get(s.key)) adopted.building = ctxBySheet.get(s.key);
|
|
2897
3055
|
mergeContinuation(base, adopted);
|
|
@@ -2912,7 +3070,7 @@ function buildSheetGraph(sheets) {
|
|
|
2912
3070
|
const role = roles.get(s.key);
|
|
2913
3071
|
if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") {
|
|
2914
3072
|
const ctxB = ctxBySheet.get(s.key);
|
|
2915
|
-
for (const r of roomTags(s, { buildings, exclude: sheetNumbers })) {
|
|
3073
|
+
for (const r of roomTags(s, { buildings, exclude: sheetNumbers, deltas: deltasBySheet.get(s.key) })) {
|
|
2916
3074
|
if (r.building == null && ctxB) r.building = ctxB;
|
|
2917
3075
|
rooms.push(r);
|
|
2918
3076
|
}
|
|
@@ -2942,7 +3100,7 @@ function buildSheetGraph(sheets) {
|
|
|
2942
3100
|
if (b) entry.building = b;
|
|
2943
3101
|
return entry;
|
|
2944
3102
|
});
|
|
2945
|
-
return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), notes };
|
|
3103
|
+
return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), revisions, notes };
|
|
2946
3104
|
}
|
|
2947
3105
|
var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
|
|
2948
3106
|
function resolveTag(graph, tag) {
|
|
@@ -3030,7 +3188,10 @@ function resolveTag(graph, tag) {
|
|
|
3030
3188
|
finishes.push(fin);
|
|
3031
3189
|
}
|
|
3032
3190
|
if (!finishes.length) return { status: "unresolved", tag: t, room, reason: `schedule row ${t} exists but carries no finish cells the extractor could band` };
|
|
3033
|
-
|
|
3191
|
+
const revs = [];
|
|
3192
|
+
if (r.revision) revs.push(r.revision);
|
|
3193
|
+
if (room?.revision && !revs.some((v) => v.rev === room.revision.rev)) revs.push(room.revision);
|
|
3194
|
+
return { status: "resolved", tag: t, room, ...chosen.building ? { building: chosen.building } : {}, finishes, sources, ...revs.length ? { revisions: revs } : {} };
|
|
3034
3195
|
}
|
|
3035
3196
|
|
|
3036
3197
|
// src/format.ts
|
|
@@ -3370,7 +3531,7 @@ function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
|
|
|
3370
3531
|
var SWEEP_TOL_PX = 2;
|
|
3371
3532
|
var SWEEP_SCORE_HIGH = 0.92;
|
|
3372
3533
|
var SWEEP_SCORE_LOW = 0.75;
|
|
3373
|
-
var
|
|
3534
|
+
var SWEEP_CANDIDATE_CEILING = 25e4;
|
|
3374
3535
|
var ANCHOR_COUNT = 3;
|
|
3375
3536
|
var MIN_SEG_LEN = 0.5;
|
|
3376
3537
|
var MAX_SEED_SEGS = 2e3;
|
|
@@ -3510,7 +3671,7 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
3510
3671
|
const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
|
|
3511
3672
|
const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
|
|
3512
3673
|
const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
|
|
3513
|
-
const maxCandidates = opts.maxCandidates ??
|
|
3674
|
+
const maxCandidates = opts.maxCandidates ?? SWEEP_CANDIDATE_CEILING;
|
|
3514
3675
|
const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
|
|
3515
3676
|
const n = segs.length >> 2;
|
|
3516
3677
|
if (scale !== 1 && opts.excludeCenter) {
|
|
@@ -3642,6 +3803,7 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
3642
3803
|
matches,
|
|
3643
3804
|
withheld,
|
|
3644
3805
|
candidates: { considered, dropped },
|
|
3806
|
+
complete: dropped === 0,
|
|
3645
3807
|
...scale === 1 ? {} : {
|
|
3646
3808
|
scaled: {
|
|
3647
3809
|
ratio: Math.round(scale * 1e6) / 1e6,
|
|
@@ -6282,6 +6444,7 @@ var Session = class _Session {
|
|
|
6282
6444
|
withheld: res.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
|
|
6283
6445
|
seed: seedOut,
|
|
6284
6446
|
candidates: res.candidates,
|
|
6447
|
+
complete: res.complete,
|
|
6285
6448
|
...committed2 ? {
|
|
6286
6449
|
committed: committed2.committed,
|
|
6287
6450
|
shape_ids: committed2.shape_ids,
|
|
@@ -6289,7 +6452,7 @@ var Session = class _Session {
|
|
|
6289
6452
|
ea_total: committed2.ea_total
|
|
6290
6453
|
} : {},
|
|
6291
6454
|
...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
|
|
6292
|
-
...res.candidates.dropped > 0 ? { warning: `Work
|
|
6455
|
+
...res.candidates.dropped > 0 ? { warning: `Work ceiling: ${res.candidates.dropped} candidate placement(s) were never scored \u2014 this count is a FLOOR, not a total. The seed's linework is too common on this sheet for an exhaustive sweep; tighten the seed rect around more distinctive geometry, or sweep a region at a time and reconcile the counts.` } : {}
|
|
6293
6456
|
};
|
|
6294
6457
|
}
|
|
6295
6458
|
const graph = await this.ensureGraph();
|
|
@@ -6381,14 +6544,16 @@ var Session = class _Session {
|
|
|
6381
6544
|
matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
6382
6545
|
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 })),
|
|
6383
6546
|
candidates: p.candidates,
|
|
6547
|
+
complete: p.complete,
|
|
6384
6548
|
elapsed_ms: p.elapsed_ms,
|
|
6385
6549
|
...p.scaled ? { scaled: p.scaled } : {},
|
|
6386
6550
|
...p.scale.known ? {} : { scale_assumed: "no scale set on the seed sheet or this one \u2014 swept at 1:1" }
|
|
6387
6551
|
})),
|
|
6552
|
+
complete: perSheet.every((p) => p.complete),
|
|
6388
6553
|
skipped,
|
|
6389
6554
|
...committed ?? {},
|
|
6390
6555
|
...notes.length ? { note: notes.join(" ") } : {},
|
|
6391
|
-
...capped.length ? { warning: `Work
|
|
6556
|
+
...capped.length ? { warning: `Work ceiling: candidate placements were dropped un-scored on ${capped.map((p) => p.state.key).join(", ")} \u2014 counts there are FLOORS, not totals. The seed's linework is too common there for an exhaustive sweep; tighten the seed rect around more distinctive geometry, or sweep those sheets singly and reconcile the counts.` } : {}
|
|
6392
6557
|
};
|
|
6393
6558
|
}
|
|
6394
6559
|
/** sweep_schedule_row (phase 2) — the estimator's story, honored: a
|
|
@@ -6567,7 +6732,7 @@ var Session = class _Session {
|
|
|
6567
6732
|
excluded.sort(byPos);
|
|
6568
6733
|
withheld.sort(byPos);
|
|
6569
6734
|
const text_only = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
|
|
6570
|
-
perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
|
|
6735
|
+
perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, complete: res.complete, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
|
|
6571
6736
|
}
|
|
6572
6737
|
const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
6573
6738
|
let committed;
|
|
@@ -6636,10 +6801,12 @@ var Session = class _Session {
|
|
|
6636
6801
|
excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
|
|
6637
6802
|
text_only: p.text_only,
|
|
6638
6803
|
candidates: p.candidates,
|
|
6804
|
+
complete: p.complete,
|
|
6639
6805
|
elapsed_ms: p.elapsed_ms,
|
|
6640
6806
|
...p.scaled ? { scaled: p.scaled } : {},
|
|
6641
6807
|
...p.scale.known ? {} : { scale_assumed: `no scale set on ${anchorSheet.key} or this sheet \u2014 swept at 1:1` }
|
|
6642
6808
|
})),
|
|
6809
|
+
complete: perSheet.every((p) => p.complete),
|
|
6643
6810
|
skipped,
|
|
6644
6811
|
...committed ?? {},
|
|
6645
6812
|
...notes.length ? { note: notes.join(" ") } : {},
|
|
@@ -7518,15 +7685,31 @@ var Session = class _Session {
|
|
|
7518
7685
|
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
7519
7686
|
if (!this.graph) {
|
|
7520
7687
|
const inputs = [];
|
|
7688
|
+
let vecBudget = 3e7;
|
|
7689
|
+
let skippedHeavy = 0;
|
|
7521
7690
|
for (const s of this.sheets.values()) {
|
|
7522
7691
|
if (!s.spans) s.spans = textSpans(s.page);
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7692
|
+
const spans = s.spans.map((t) => ({ str: t.str, x: t.x0, y: t.y0, w: t.x1 - t.x0, h: t.y1 - t.y0, ...t.rot ? { rot: t.rot } : {} }));
|
|
7693
|
+
let segs;
|
|
7694
|
+
if (spans.some((t) => /^\d{1,2}$/.test(t.str.trim()))) {
|
|
7695
|
+
const role = classifySheetRole({ key: s.key, sheet_number: s.sheetNumber, spans }).role;
|
|
7696
|
+
if (role === "plan" || role === "schedule" || role === "demolition" || role === "unknown") {
|
|
7697
|
+
if (vecBudget <= 0) skippedHeavy++;
|
|
7698
|
+
else if (s.geo) {
|
|
7699
|
+
segs = s.geo.segs;
|
|
7700
|
+
vecBudget -= segs.length / 4;
|
|
7701
|
+
} else {
|
|
7702
|
+
const opList = await s.page.operatorList();
|
|
7703
|
+
segs = extractVectorGeometry(opList, s.page.viewport.transform, OPS2).segs;
|
|
7704
|
+
vecBudget -= segs.length / 4;
|
|
7705
|
+
s.page.cleanup();
|
|
7706
|
+
}
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
inputs.push({ key: s.key, sheet_number: s.sheetNumber, spans, ...segs?.length ? { segs } : {} });
|
|
7528
7710
|
}
|
|
7529
7711
|
this.graph = buildSheetGraph(inputs);
|
|
7712
|
+
if (skippedHeavy) this.graph.notes.push(`drawn-delta hunt skipped on ${skippedHeavy} sheet(s) \u2014 the set's linework exceeded the vector budget; text revision markers (\u03942 / REV 2) were still read everywhere`);
|
|
7530
7713
|
}
|
|
7531
7714
|
return this.graph;
|
|
7532
7715
|
}
|
|
@@ -7536,6 +7719,16 @@ var Session = class _Session {
|
|
|
7536
7719
|
static wireEvidence(e) {
|
|
7537
7720
|
return { sheet: e.sheet, text: e.text, bbox: _Session.wireBox(e.bbox) };
|
|
7538
7721
|
}
|
|
7722
|
+
static wireRoom(r) {
|
|
7723
|
+
return {
|
|
7724
|
+
tag: r.tag,
|
|
7725
|
+
name: r.name,
|
|
7726
|
+
sheet: r.sheet,
|
|
7727
|
+
bbox: _Session.wireBox(r.bbox),
|
|
7728
|
+
...r.building ? { building: r.building } : {},
|
|
7729
|
+
...r.revision ? { revision: { rev: r.revision.rev, source: _Session.wireEvidence(r.revision.source), ...r.revision.drawn ? { drawn: true } : {} } } : {}
|
|
7730
|
+
};
|
|
7731
|
+
}
|
|
7539
7732
|
async sheetGraph() {
|
|
7540
7733
|
const g = await this.ensureGraph();
|
|
7541
7734
|
return {
|
|
@@ -7555,9 +7748,10 @@ var Session = class _Session {
|
|
|
7555
7748
|
...t.rotated_headers ? { rotated_headers: true } : {}
|
|
7556
7749
|
}))
|
|
7557
7750
|
})),
|
|
7558
|
-
rooms: g.rooms.map(
|
|
7751
|
+
rooms: g.rooms.map(_Session.wireRoom),
|
|
7559
7752
|
callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
|
|
7560
7753
|
...g.buildings.length ? { buildings: g.buildings } : {},
|
|
7754
|
+
...g.revisions.length ? { revisions: g.revisions.map((r) => ({ rev: r.rev, sheet: r.sheet, bbox: _Session.wireBox(r.bbox), ...r.drawn ? { drawn: true } : {} })) } : {},
|
|
7561
7755
|
...g.notes.length ? { notes: g.notes } : {},
|
|
7562
7756
|
counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
7563
7757
|
};
|
|
@@ -7585,7 +7779,7 @@ var Session = class _Session {
|
|
|
7585
7779
|
const g = await this.ensureGraph();
|
|
7586
7780
|
if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
|
|
7587
7781
|
const res = resolveTag(g, tag);
|
|
7588
|
-
const room = res.room ?
|
|
7782
|
+
const room = res.room ? _Session.wireRoom(res.room) : null;
|
|
7589
7783
|
if (res.status === "unresolved") {
|
|
7590
7784
|
return {
|
|
7591
7785
|
status: "unresolved",
|
|
@@ -7606,7 +7800,8 @@ var Session = class _Session {
|
|
|
7606
7800
|
source: _Session.wireEvidence(f.source),
|
|
7607
7801
|
...f.definition ? { definition: { cells: f.definition.cells, source: _Session.wireEvidence(f.definition.source) } } : {}
|
|
7608
7802
|
})),
|
|
7609
|
-
sources: res.sources.map(_Session.wireEvidence)
|
|
7803
|
+
sources: res.sources.map(_Session.wireEvidence),
|
|
7804
|
+
...res.revisions?.length ? { revisions: res.revisions.map((v) => ({ rev: v.rev, source: _Session.wireEvidence(v.source), ...v.drawn ? { drawn: true } : {} })) } : {}
|
|
7610
7805
|
};
|
|
7611
7806
|
}
|
|
7612
7807
|
async findSchedule(kind) {
|
|
@@ -7629,6 +7824,7 @@ var Session = class _Session {
|
|
|
7629
7824
|
region: _Session.wireBox(t.region),
|
|
7630
7825
|
...t.building ? { building: t.building } : {},
|
|
7631
7826
|
...t.rotated_headers ? { rotated_headers: true } : {},
|
|
7827
|
+
...t.rows.some((r) => r.revision) ? { revised_rows: t.rows.filter((r) => r.revision).length } : {},
|
|
7632
7828
|
...t.parts ? { parts: t.parts.map((p) => ({ sheet: p.sheet, title: p.title, rows: p.rows, region: _Session.wireBox(p.region) })) } : {}
|
|
7633
7829
|
}))
|
|
7634
7830
|
};
|
|
@@ -7838,7 +8034,8 @@ var sweepSheetBlock = z.object({
|
|
|
7838
8034
|
found: z.number().int(),
|
|
7839
8035
|
matches: z.array(z.object(sweepPlacement)),
|
|
7840
8036
|
withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })),
|
|
7841
|
-
candidates: sweepCandidates.describe("The work
|
|
8037
|
+
candidates: sweepCandidates.describe("The work ceiling applies PER SHEET; dropped > 0 here names exactly where the count is incomplete"),
|
|
8038
|
+
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)"),
|
|
7842
8039
|
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
|
|
7843
8040
|
scaled: sweepScaled.optional(),
|
|
7844
8041
|
scale_assumed: sweepScaleAssumed.optional()
|
|
@@ -7862,6 +8059,7 @@ var symbolSweepOutput = {
|
|
|
7862
8059
|
length_px: z.number().describe("Total seed linework length, image px")
|
|
7863
8060
|
}),
|
|
7864
8061
|
candidates: sweepCandidates.optional().describe("Sheet scope only \u2014 set scope accounts per sheet in sheets[]"),
|
|
8062
|
+
complete: z.boolean().describe("True when every proposed placement was scored (every swept sheet, in set scope) and the count is a total. FALSE MEANS THE COUNT IS A FLOOR \u2014 acknowledge it before trusting found (#261)"),
|
|
7865
8063
|
sheets: z.array(sweepSheetBlock).optional().describe("Set scope only: one entry per swept PLAN-role sheet, load order"),
|
|
7866
8064
|
skipped: sweepSkipped.optional().describe("Set scope only: every sheet excluded from counting, with role and reason \u2014 including the seed's own sheet when it is not a plan"),
|
|
7867
8065
|
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
|
|
@@ -8187,12 +8385,18 @@ var readSheetTextOutput = {
|
|
|
8187
8385
|
};
|
|
8188
8386
|
var wireBox = z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() });
|
|
8189
8387
|
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.");
|
|
8388
|
+
var wireRevision = z.object({
|
|
8389
|
+
rev: z.string(),
|
|
8390
|
+
source: wireEvidence,
|
|
8391
|
+
drawn: z.boolean().optional().describe("true = a DRAWN delta: a bare digit inside a triangle of linework (the common CAD convention \u2014 the text layer carries only the digit; the geometry proved the triangle). The evidence bbox spans digit and triangle")
|
|
8392
|
+
}).describe("A revision marker (delta triangle / 'REV 2' tag) attached to this item: the ink CHANGED under that revision. The value read is the post-revision answer \u2014 view_sheet the marker's bbox and check the addendum before pricing");
|
|
8190
8393
|
var graphRoom = z.object({
|
|
8191
8394
|
tag: z.string(),
|
|
8192
8395
|
name: z.string().describe("The name span stacked over the tag ('' when none)"),
|
|
8193
8396
|
sheet: z.string(),
|
|
8194
8397
|
bbox: wireBox,
|
|
8195
|
-
building: z.string().optional().describe("The building the room belongs to, when the set names one \u2014 its plan sheet's BUILDING/BLDG context, or the tag's own qualifier ('A-134')")
|
|
8398
|
+
building: z.string().optional().describe("The building the room belongs to, when the set names one \u2014 its plan sheet's BUILDING/BLDG context, or the tag's own qualifier ('A-134')"),
|
|
8399
|
+
revision: wireRevision.optional()
|
|
8196
8400
|
});
|
|
8197
8401
|
var sheetGraphOutput = {
|
|
8198
8402
|
available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
|
|
@@ -8214,6 +8418,7 @@ var sheetGraphOutput = {
|
|
|
8214
8418
|
rooms: z.array(graphRoom).describe("Room tags read off plan-role sheets \u2014 schedule sheets contribute rows, never phantom rooms"),
|
|
8215
8419
|
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"),
|
|
8216
8420
|
buildings: z.array(z.string()).optional().describe("Every building designator the set names (sorted) \u2014 present only on multi-building-aware sets. Room numbers reused across these need qualified tags ('A-134')"),
|
|
8421
|
+
revisions: z.array(z.object({ rev: z.string(), sheet: z.string(), bbox: wireBox, drawn: z.boolean().optional() })).optional().describe("Every delta-triangle / REV-tag marker the set carries \u2014 text markers ('\u03942', 'REV 2') and DRAWN deltas (a bare digit inside a triangle of linework, drawn: true) \u2014 where one sits, the ink changed under that revision. Markers on a schedule row or room bubble also attach there (and ride resolve_tag). A revision CLOUD is arc-chain linework these detectors do not read \u2014 absence here is not absence of revisions"),
|
|
8217
8422
|
notes: z.array(z.string()).optional().describe("Named gaps found while indexing (e.g. a continuation whose rows could not be aligned) \u2014 the graph refuses silently dropping anything"),
|
|
8218
8423
|
counts: z.object({ rooms: z.number().int(), schedules: z.number().int().describe("LOGICAL tables \u2014 a schedule continued across sheets counts once"), callouts: z.number().int() })
|
|
8219
8424
|
};
|
|
@@ -8229,6 +8434,7 @@ var resolveTagOutput = {
|
|
|
8229
8434
|
definition: z.object({ cells: z.record(z.string()), source: wireEvidence }).optional().describe("The finish/material-schedule row this code chains to, when one exists")
|
|
8230
8435
|
})).optional(),
|
|
8231
8436
|
sources: z.array(wireEvidence).optional().describe("The chain: plan tag \u2192 schedule row (the row cites the sheet that CARRIES it \u2014 under a continuation that is the CONT'D sheet)"),
|
|
8437
|
+
revisions: z.array(wireRevision).optional().describe("resolved only \u2014 delta/REV markers on the answering schedule row or the plan bubble. The finishes above are the POST-revision answer, but the ink changed: check the marker (view_sheet its bbox) and the addendum before pricing"),
|
|
8232
8438
|
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"),
|
|
8233
8439
|
candidates: z.array(z.object({
|
|
8234
8440
|
key: z.string(),
|
|
@@ -8247,6 +8453,7 @@ var findScheduleOutput = {
|
|
|
8247
8453
|
region: wireBox.describe("Pass to view_sheet to look at the table (the BASE fragment's region when the table continues)"),
|
|
8248
8454
|
building: z.string().optional().describe("The building this table answers for, when its title or sheet names one"),
|
|
8249
8455
|
rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn"),
|
|
8456
|
+
revised_rows: z.number().int().optional().describe("Rows carrying a delta/REV marker \u2014 the ink changed there; resolve those tags to see which"),
|
|
8250
8457
|
parts: z.array(z.object({ sheet: z.string(), title: z.string(), rows: z.number().int(), region: wireBox })).optional().describe("Present when the table CONTINUES across sheets ('\u2026 SCHEDULE \u2014 CONT'D'): every fragment, base first, each with its own viewable region")
|
|
8251
8458
|
}))
|
|
8252
8459
|
};
|
|
@@ -8283,10 +8490,12 @@ var sweepScheduleRowOutput = {
|
|
|
8283
8490
|
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"),
|
|
8284
8491
|
text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with NO matching marker geometry nearby \u2014 a note reference or a variant marker; a question, never a count"),
|
|
8285
8492
|
candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
|
|
8493
|
+
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)"),
|
|
8286
8494
|
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
|
|
8287
8495
|
scaled: sweepScaled.optional(),
|
|
8288
8496
|
scale_assumed: sweepScaleAssumed.optional()
|
|
8289
8497
|
})).describe("One entry per swept PLAN-role sheet, load order"),
|
|
8498
|
+
complete: z.boolean().describe("True when every proposed placement was scored on every swept sheet \u2014 false means at least one sheet's count is a FLOOR, not a total (#261)"),
|
|
8290
8499
|
skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })).describe("Sheets excluded from counting (schedule/detail/legend/unknown), each with its reason"),
|
|
8291
8500
|
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per counted match, the whole sweep ONE undo step"),
|
|
8292
8501
|
shape_ids: z.array(z.string()).optional(),
|
|
@@ -10117,7 +10326,7 @@ function registerTools(realServer, session) {
|
|
|
10117
10326
|
outputSchema: placeCountOutput
|
|
10118
10327
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
10119
10328
|
server.registerTool("symbol_sweep", {
|
|
10120
|
-
description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed.
|
|
10329
|
+
description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Every proposed placement is scored up to a hard work ceiling sized for pathological sheets, and the reply says which it was: complete true means the count is a total; complete false (with candidates.dropped > 0) means the count is a FLOOR \u2014 some placements were never scored \u2014 so tighten the seed rect around more distinctive geometry rather than trusting it as a total. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. scope "set" sweeps the WHOLE working set, counting on PLAN-role sheets only (the sheet graph decides): a symbol drawn in a detail, legend, or schedule is a reference drawing and never counts itself \u2014 which is also how you seed from one: marquee the assembly on the detail sheet and its plan-sheet occurrences are counted while the detail stays excluded (the exclusion disclosed in \`skipped\`, per-sheet results with per-sheet caps and wall-clock in \`sheets\`). Scale across sheets: the fingerprint is size-true and is never scale-SEARCHED, so a detail drawn at 1-1/2" = 1'-0" is 12\xD7 the size of the same mark on a 1/8" plan \u2014 when BOTH sheets have a scale set, the exact ratio is computed from them and the seed is resized before matching (reported per sheet as \`scaled\`); when a scale is missing, the sweep runs at 1:1 and SAYS so (\`scale_assumed\`), because an unknown ratio plus a zero count is not evidence of absence. Seeding from a detail/legend/schedule sheet REFUSES outright until both scales are set \u2014 that is the case where an unstated ratio silently finds nothing. commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep (set-wide included) is ONE undo step, each marker carries origin.method "symbol_sweep" with its score, transform, and seed source, and withheld placements are NEVER committed. The COUNT is scale-free (EA), but matching across sheets of different scales is not \u2014 set_scale on the sheets involved is what turns the ratio from an assumption into arithmetic. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
|
|
10121
10330
|
inputSchema: {
|
|
10122
10331
|
sheet: z2.string().describe("The sheet the seed rect sits on \u2014 in scope 'set' it may be ANY sheet (a detail/legend seed sheet is fingerprint source only, never counted)"),
|
|
10123
10332
|
seed_rect: z2.tuple([pointSchema, pointSchema]).describe("Marquee around ONE example instance, [[x0,y0],[x1,y1]] in image px \u2014 tight: segments fully inside define the symbol"),
|
|
@@ -10348,17 +10557,17 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
10348
10557
|
outputSchema: undoLastOutput
|
|
10349
10558
|
}, run("undo_last", ({ n }) => session.undoLast(n)));
|
|
10350
10559
|
server.registerTool("sheet_graph", {
|
|
10351
|
-
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 \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every room tag on the plan sheets (with the stacked room NAME when one exists, and the room's BUILDING on multi-building sets), the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, and named indexing gaps in "notes". 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}`,
|
|
10560
|
+
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 \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every room tag on the plan sheets (with the stacked room NAME when one exists, and the room's BUILDING on multi-building sets), the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, every REVISION marker the set carries (text markers "\u03942"/"REV 2" AND drawn deltas \u2014 a bare digit inside a triangle of linework, proven from vector geometry and flagged drawn \u2014 in "revisions", and attached to the schedule row / room tag they sit on), and named indexing gaps in "notes". 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}`,
|
|
10352
10561
|
inputSchema: {},
|
|
10353
10562
|
outputSchema: sheetGraphOutput
|
|
10354
10563
|
}, run("sheet_graph", () => session.sheetGraph()));
|
|
10355
10564
|
server.registerTool("resolve_tag", {
|
|
10356
|
-
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). Rows carried by a continuation sheet ("\u2026 SCHEDULE \u2014 CONT'D") resolve exactly like base-sheet rows, citing the sheet the ink is on. 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 return "ambiguous" rather than picking one \u2014 on a multi-building set the refusal LISTS the candidate rows per building, and a building-qualified tag ("A-134") picks the building the set names. ${COORDS}`,
|
|
10565
|
+
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). Rows carried by a continuation sheet ("\u2026 SCHEDULE \u2014 CONT'D") resolve exactly like base-sheet rows, citing the sheet the ink is on. 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 return "ambiguous" rather than picking one \u2014 on a multi-building set the refusal LISTS the candidate rows per building, and a building-qualified tag ("A-134") picks the building the set names. A delta triangle or REV tag on the answering row (or the plan bubble) rides the result as "revisions": the codes returned are the POST-revision answer, but the ink changed under that delta \u2014 view_sheet the marker's bbox and check the addendum before pricing. ${COORDS}`,
|
|
10357
10566
|
inputSchema: { tag: z2.string().describe('The room tag as drawn, e.g. "134" or "139A" \u2014 or building-qualified on a multi-building set, e.g. "A-134" (building A, room 134)') },
|
|
10358
10567
|
outputSchema: resolveTagOutput
|
|
10359
10568
|
}, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
|
|
10360
10569
|
server.registerTool("find_schedule", {
|
|
10361
|
-
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". Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
|
|
10570
|
+
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}`,
|
|
10362
10571
|
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes) or "finish"/"material" (codes \u2192 products)') },
|
|
10363
10572
|
outputSchema: findScheduleOutput
|
|
10364
10573
|
}, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
|
|
@@ -10639,7 +10848,7 @@ function applyStagedTools(server, registered) {
|
|
|
10639
10848
|
// package.json
|
|
10640
10849
|
var package_default = {
|
|
10641
10850
|
name: "opentakeoff-mcp",
|
|
10642
|
-
version: "0.9.
|
|
10851
|
+
version: "0.9.44",
|
|
10643
10852
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
10644
10853
|
type: "module",
|
|
10645
10854
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED