opentakeoff-mcp 0.9.44 → 0.9.46
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/dist/server-core.js +283 -31
- package/package.json +1 -1
package/dist/server-core.js
CHANGED
|
@@ -2677,28 +2677,133 @@ function clusterRows(spans) {
|
|
|
2677
2677
|
return rows.map((r) => r.sort((a, b) => a.x - b.x));
|
|
2678
2678
|
}
|
|
2679
2679
|
var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
|
|
2680
|
-
var
|
|
2681
|
-
var
|
|
2680
|
+
var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
|
|
2681
|
+
var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "BLDG", "BUILDING"];
|
|
2682
|
+
var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
|
|
2682
2683
|
var headerLabel = (s, vocab) => {
|
|
2683
2684
|
for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w)) return w;
|
|
2684
2685
|
return null;
|
|
2685
2686
|
};
|
|
2687
|
+
function headerHits(row, vocab) {
|
|
2688
|
+
const out = [];
|
|
2689
|
+
for (const t of row) {
|
|
2690
|
+
const w = headerLabel(t.str, vocab);
|
|
2691
|
+
if (w) out.push({ label: w, span: t });
|
|
2692
|
+
}
|
|
2693
|
+
return out.sort((a, b) => a.span.x - b.span.x);
|
|
2694
|
+
}
|
|
2695
|
+
var qualifies = (hits, required, minHits) => {
|
|
2696
|
+
const seen = new Set(hits.map((h) => h.label));
|
|
2697
|
+
return seen.size >= minHits && required.some((r) => seen.has(r));
|
|
2698
|
+
};
|
|
2686
2699
|
function findHeaderRow(rows, vocab, required, minHits) {
|
|
2687
2700
|
for (let i = 0; i < rows.length; i++) {
|
|
2701
|
+
let hits = headerHits(rows[i], vocab);
|
|
2702
|
+
if (!qualifies(hits, required, minHits)) continue;
|
|
2703
|
+
let idx = i;
|
|
2704
|
+
for (; ; ) {
|
|
2705
|
+
let next = -1;
|
|
2706
|
+
for (let j = idx + 1; j < Math.min(idx + 4, rows.length); j++) {
|
|
2707
|
+
const h = headerHits(rows[j], vocab);
|
|
2708
|
+
const ratio = h.length / Math.max(1, rows[j].length);
|
|
2709
|
+
if (qualifies(h, required, minHits) && h.length > hits.length && ratio >= 0.6) {
|
|
2710
|
+
next = j;
|
|
2711
|
+
break;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
if (next < 0) break;
|
|
2715
|
+
idx = next;
|
|
2716
|
+
hits = headerHits(rows[idx], vocab);
|
|
2717
|
+
}
|
|
2718
|
+
const dup = /* @__PURE__ */ new Set();
|
|
2719
|
+
const once = /* @__PURE__ */ new Set();
|
|
2720
|
+
for (const h of hits) (once.has(h.label) ? dup : once).add(h.label);
|
|
2688
2721
|
const anchors = [];
|
|
2689
|
-
const
|
|
2690
|
-
for (
|
|
2691
|
-
const
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2722
|
+
const used = /* @__PURE__ */ new Set();
|
|
2723
|
+
for (let j = 0; j < hits.length; j++) {
|
|
2724
|
+
const h = hits[j];
|
|
2725
|
+
let label = h.label;
|
|
2726
|
+
if (dup.has(h.label) && !SURFACE_WORDS.has(h.label)) {
|
|
2727
|
+
const hi = j + 1 < hits.length ? hits[j + 1].span.x : Infinity;
|
|
2728
|
+
const parent = parentLabelOver(rows, idx, i, h.span.x, hi, vocab);
|
|
2729
|
+
if (parent && parent !== h.label) label = `${parent} ${h.label}`;
|
|
2730
|
+
}
|
|
2731
|
+
if (used.has(label)) continue;
|
|
2732
|
+
used.add(label);
|
|
2733
|
+
anchors.push({ label, x: h.span.x + (h.span.w || 0) / 2 });
|
|
2734
|
+
}
|
|
2735
|
+
if (anchors.length < minHits) continue;
|
|
2736
|
+
if (idx > i) {
|
|
2737
|
+
const lo = Math.min(...anchors.map((a) => a.x)), hi = Math.max(...anchors.map((a) => a.x));
|
|
2738
|
+
for (let j = i; j < idx; j++) {
|
|
2739
|
+
for (const h of headerHits(rows[j], vocab)) {
|
|
2740
|
+
const cx = h.span.x + (h.span.w || 0) / 2;
|
|
2741
|
+
if (cx >= lo && cx <= hi) continue;
|
|
2742
|
+
if (used.has(h.label)) continue;
|
|
2743
|
+
used.add(h.label);
|
|
2744
|
+
anchors.push({ label: h.label, x: cx });
|
|
2745
|
+
}
|
|
2695
2746
|
}
|
|
2696
2747
|
}
|
|
2697
|
-
|
|
2698
|
-
return { anchors: anchors.sort((a, b) => a.x - b.x), rowIndex: i };
|
|
2748
|
+
return { anchors: subTierAnchors(rows, idx, anchors.sort((a, b) => a.x - b.x), vocab), rowIndex: idx };
|
|
2699
2749
|
}
|
|
2700
2750
|
return null;
|
|
2701
2751
|
}
|
|
2752
|
+
var SUB_LABEL_RE = /^[A-Z0-9][A-Z0-9.\/-]{0,5}$/;
|
|
2753
|
+
function parentLabelOver(rows, hdrIdx, topIdx, gx0, gx1, vocab) {
|
|
2754
|
+
const width = Math.max(Math.min(gx1, gx0 + 4e3) - gx0, 1);
|
|
2755
|
+
const hs = rows[hdrIdx].map((t) => t.h || 8).sort((a, b) => a - b);
|
|
2756
|
+
const near = Math.max(24, (hs[hs.length >> 1] || 8) * 4);
|
|
2757
|
+
const hy = rowY(rows[hdrIdx]);
|
|
2758
|
+
const floorIdx = Math.max(0, Math.min(topIdx, hdrIdx - 8));
|
|
2759
|
+
for (let j = hdrIdx - 1; j >= floorIdx; j--) {
|
|
2760
|
+
if (hy - rowY(rows[j]) > near) break;
|
|
2761
|
+
for (const t of rows[j]) {
|
|
2762
|
+
const cx = t.x + (t.w || 0) / 2;
|
|
2763
|
+
const inInterval = cx >= gx0 && cx < gx1;
|
|
2764
|
+
const overlaps = Math.min(t.x + (t.w || 0), gx1) - Math.max(t.x, gx0) > width * 0.3;
|
|
2765
|
+
if (!inInterval && !overlaps) continue;
|
|
2766
|
+
const lbl = headerLabel(t.str, vocab);
|
|
2767
|
+
if (lbl) return lbl;
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
return null;
|
|
2771
|
+
}
|
|
2772
|
+
function subTierAnchors(rows, hdrIdx, anchors, vocab) {
|
|
2773
|
+
const lo = anchors[0].x, hi = anchors[anchors.length - 1].x;
|
|
2774
|
+
const loose = rows[hdrIdx].filter((t) => !headerLabel(t.str, vocab) && SUB_LABEL_RE.test(norm(t.str))).filter((t) => t.x + (t.w || 0) / 2 > lo && t.x + (t.w || 0) / 2 < hi).sort((a, b) => a.x - b.x);
|
|
2775
|
+
if (loose.length < 2) return anchors;
|
|
2776
|
+
const mid = (t) => t.x + (t.w || 0) / 2;
|
|
2777
|
+
const gaps = loose.slice(1).map((t, i) => mid(t) - mid(loose[i])).sort((a, b) => a - b);
|
|
2778
|
+
const med = gaps[gaps.length >> 1] || 1;
|
|
2779
|
+
const runs = [];
|
|
2780
|
+
let run2 = [loose[0]];
|
|
2781
|
+
for (let i = 1; i < loose.length; i++) {
|
|
2782
|
+
if (mid(loose[i]) - mid(loose[i - 1]) > med * 3) {
|
|
2783
|
+
runs.push(run2);
|
|
2784
|
+
run2 = [];
|
|
2785
|
+
}
|
|
2786
|
+
run2.push(loose[i]);
|
|
2787
|
+
}
|
|
2788
|
+
runs.push(run2);
|
|
2789
|
+
const out = anchors.slice();
|
|
2790
|
+
const used = new Set(anchors.map((a) => a.label));
|
|
2791
|
+
for (const r of runs) {
|
|
2792
|
+
if (r.length < 2) continue;
|
|
2793
|
+
const last = r[r.length - 1];
|
|
2794
|
+
const parent = parentLabelOver(rows, hdrIdx, hdrIdx - 2, r[0].x, last.x + (last.w || 0), vocab);
|
|
2795
|
+
if (!parent) continue;
|
|
2796
|
+
const pitch = r.length > 1 ? r.slice(1).map((t, i) => mid(t) - mid(r[i])).sort((a, b) => a - b)[r.length - 1 >> 1] : 0;
|
|
2797
|
+
for (const t of r) {
|
|
2798
|
+
const label = `${parent} ${norm(t.str)}`;
|
|
2799
|
+
if (used.has(label)) continue;
|
|
2800
|
+
used.add(label);
|
|
2801
|
+
const c = mid(t);
|
|
2802
|
+
out.push(pitch > 0 ? { label, x: c, x0: c - pitch / 2, x1: c + pitch / 2 } : { label, x: c });
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
return out.sort((a, b) => a.x - b.x);
|
|
2806
|
+
}
|
|
2702
2807
|
function findRotatedHeader(vert, vocab, required, minHits) {
|
|
2703
2808
|
const cands = vert.map((sp) => ({ sp, label: headerLabel(sp.str, vocab) })).filter((c) => !!c.label).sort((a, b) => a.sp.x - b.sp.x);
|
|
2704
2809
|
let band = [];
|
|
@@ -2733,18 +2838,35 @@ function findRotatedHeader(vert, vocab, required, minHits) {
|
|
|
2733
2838
|
return band.length ? flush() : null;
|
|
2734
2839
|
}
|
|
2735
2840
|
var nearestAnchor = (x, anchors) => {
|
|
2736
|
-
let
|
|
2737
|
-
for (const a of anchors)
|
|
2738
|
-
|
|
2841
|
+
let inside2 = null;
|
|
2842
|
+
for (const a of anchors) {
|
|
2843
|
+
if (a.x0 == null || a.x1 == null || x < a.x0 || x > a.x1) continue;
|
|
2844
|
+
if (!inside2 || Math.abs(a.x - x) < Math.abs(inside2.x - x)) inside2 = a;
|
|
2845
|
+
}
|
|
2846
|
+
if (inside2) return inside2.label;
|
|
2847
|
+
let best = null;
|
|
2848
|
+
for (const a of anchors) {
|
|
2849
|
+
if (a.x0 != null) continue;
|
|
2850
|
+
if (!best || Math.abs(a.x - x) < Math.abs(best.x - x)) best = a;
|
|
2851
|
+
}
|
|
2852
|
+
return (best ?? anchors[0]).label;
|
|
2739
2853
|
};
|
|
2854
|
+
var WIDE_LAST = /* @__PURE__ */ new Set(["REMARKS", "DESCRIPTION", "NOTES"]);
|
|
2740
2855
|
function bandLimits(anchors) {
|
|
2741
2856
|
const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
|
|
2742
2857
|
const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
|
|
2743
|
-
|
|
2858
|
+
const last = anchors[anchors.length - 1];
|
|
2859
|
+
const rightMargin = WIDE_LAST.has(last.label) ? Math.max(300, medGap * 3) : Math.max(120, medGap);
|
|
2860
|
+
return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: last.x + rightMargin, medGap };
|
|
2744
2861
|
}
|
|
2745
2862
|
var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
|
|
2746
2863
|
var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
|
|
2747
2864
|
var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
|
|
2865
|
+
var OTHER_FAMILY_RE = /\b(DOOR|WINDOW|PARTITION|EQUIPMENT|HARDWARE|LOUVER|SIGNAGE|LIGHTING|LUMINAIRE|PLUMBING|MECHANICAL|ELECTRICAL|STOREFRONT|GLAZING|CASEWORK|MILLWORK|APPLIANCE)S?\b/;
|
|
2866
|
+
var isNonFinishSchedule = (title) => {
|
|
2867
|
+
const u = norm(title);
|
|
2868
|
+
return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
|
|
2869
|
+
};
|
|
2748
2870
|
function rowKeyOf(raw, kind, buildings) {
|
|
2749
2871
|
const key = norm(raw).replace(/[^A-Z0-9-]/g, "");
|
|
2750
2872
|
if (kind === "finish") return CODE_RE.test(key) ? { key } : null;
|
|
@@ -2755,14 +2877,65 @@ function rowKeyOf(raw, kind, buildings) {
|
|
|
2755
2877
|
}
|
|
2756
2878
|
var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
|
|
2757
2879
|
var centerX = (t) => t.x + (t.w || 0) / 2;
|
|
2880
|
+
function columnStarts(rows, anchors, cfg, x0, x1) {
|
|
2881
|
+
const xs = [];
|
|
2882
|
+
const hs = [];
|
|
2883
|
+
for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
|
|
2884
|
+
if (rowY(rows[i]) <= cfg.belowY) continue;
|
|
2885
|
+
for (const t of rows[i]) {
|
|
2886
|
+
if (t.x < x0 || t.x > x1 || revisionOf(t.str) != null) continue;
|
|
2887
|
+
xs.push(t.x);
|
|
2888
|
+
hs.push(t.h || 8);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
if (xs.length < anchors.length * 2) return null;
|
|
2892
|
+
hs.sort((a, b) => a - b);
|
|
2893
|
+
const tol = Math.max(4, hs[hs.length >> 1] * 0.5);
|
|
2894
|
+
xs.sort((a, b) => a - b);
|
|
2895
|
+
const clusters = [];
|
|
2896
|
+
for (const x of xs) {
|
|
2897
|
+
const last = clusters[clusters.length - 1];
|
|
2898
|
+
if (last && x - last.start <= tol) {
|
|
2899
|
+
last.n++;
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
clusters.push({ start: x, n: 1 });
|
|
2903
|
+
}
|
|
2904
|
+
const maxN = Math.max(...clusters.map((c) => c.n));
|
|
2905
|
+
const kept = clusters.filter((c) => c.n >= Math.max(2, maxN * 0.25));
|
|
2906
|
+
if (kept.length < anchors.length) return null;
|
|
2907
|
+
const byLabel = /* @__PURE__ */ new Map();
|
|
2908
|
+
for (const c of kept) {
|
|
2909
|
+
const own = anchors.find((a) => a.x >= c.start);
|
|
2910
|
+
if (!own) continue;
|
|
2911
|
+
const cur = byLabel.get(own.label);
|
|
2912
|
+
if (cur == null || c.start < cur) byLabel.set(own.label, c.start);
|
|
2913
|
+
}
|
|
2914
|
+
if (byLabel.size !== anchors.length) return null;
|
|
2915
|
+
const named = [...byLabel.entries()].map(([label, start]) => ({ label, start })).sort((a, b) => a.start - b.start);
|
|
2916
|
+
const order = anchors.map((a) => a.label).join("|");
|
|
2917
|
+
if (named.map((n) => n.label).join("|") !== order) return null;
|
|
2918
|
+
return named;
|
|
2919
|
+
}
|
|
2758
2920
|
function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
2759
2921
|
const { x0, x1, medGap } = bandLimits(anchors);
|
|
2922
|
+
const cols = columnStarts(rows, anchors, cfg, x0, x1);
|
|
2923
|
+
const keyTol = cols && cols.length > 1 ? Math.max(8, (cols[1].start - cols[0].start) * 0.5) : 40;
|
|
2760
2924
|
const out = [];
|
|
2761
2925
|
const outY = [];
|
|
2762
2926
|
let region = null;
|
|
2927
|
+
const columnOf = (t) => {
|
|
2928
|
+
if (!cols) return nearestAnchor(centerX(t), anchors);
|
|
2929
|
+
let label = cols[0].label;
|
|
2930
|
+
for (const c of cols) {
|
|
2931
|
+
if (t.x + 1 >= c.start) label = c.label;
|
|
2932
|
+
else break;
|
|
2933
|
+
}
|
|
2934
|
+
return label;
|
|
2935
|
+
};
|
|
2763
2936
|
const add = (row, toks) => {
|
|
2764
2937
|
for (const t of toks) {
|
|
2765
|
-
const label =
|
|
2938
|
+
const label = columnOf(t);
|
|
2766
2939
|
const text = t.str.trim();
|
|
2767
2940
|
if (!row.cells[label]) row.cells[label] = { text, bbox: bboxOf(t) };
|
|
2768
2941
|
else row.cells[label] = { text: `${row.cells[label].text} ${text}`, bbox: merge(row.cells[label].bbox, bboxOf(t)) };
|
|
@@ -2789,6 +2962,7 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
2789
2962
|
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
2790
2963
|
continue;
|
|
2791
2964
|
}
|
|
2965
|
+
if (cols && Math.abs(banded[0].x - cols[0].start) > keyTol) continue;
|
|
2792
2966
|
if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
|
|
2793
2967
|
const row = { key: keyed.key, sheet: sheetKey, cells: {} };
|
|
2794
2968
|
if (keyed.building) row.building = keyed.building;
|
|
@@ -2796,6 +2970,21 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
2796
2970
|
out.push(row);
|
|
2797
2971
|
outY.push(rowY(rows[i]));
|
|
2798
2972
|
}
|
|
2973
|
+
if (out.length > 2) {
|
|
2974
|
+
const d = outY.slice(1).map((y, i) => y - outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
|
|
2975
|
+
const pitch0 = d.length ? d[d.length >> 1] : 0;
|
|
2976
|
+
if (pitch0 > 0) {
|
|
2977
|
+
let end = out.length;
|
|
2978
|
+
for (let i = 1; i < outY.length; i++) if (outY[i] - outY[i - 1] > pitch0 * 8) {
|
|
2979
|
+
end = i;
|
|
2980
|
+
break;
|
|
2981
|
+
}
|
|
2982
|
+
if (end < out.length) {
|
|
2983
|
+
out.length = end;
|
|
2984
|
+
outY.length = end;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2799
2988
|
const gaps = outY.slice(1).map((y, i) => y - outY[i]).filter((d) => d > 0).sort((a, b) => a - b);
|
|
2800
2989
|
const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
|
|
2801
2990
|
const nearest = (y) => {
|
|
@@ -2833,7 +3022,7 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
2833
3022
|
const vert = sheet.spans.filter(isVertical);
|
|
2834
3023
|
const rows = clusterRows(horiz);
|
|
2835
3024
|
const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
|
|
2836
|
-
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK"];
|
|
3025
|
+
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK", "SYMBOL"];
|
|
2837
3026
|
const minHits = kind === "room-finish" ? 4 : 3;
|
|
2838
3027
|
let anchors;
|
|
2839
3028
|
let headerSpans;
|
|
@@ -2858,8 +3047,12 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
2858
3047
|
titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
|
|
2859
3048
|
if (titleFrom < -1) titleFrom = rows.length - 1;
|
|
2860
3049
|
}
|
|
3050
|
+
const hdrBand = bandLimits(anchors);
|
|
2861
3051
|
let region = null;
|
|
2862
|
-
for (const t of headerSpans)
|
|
3052
|
+
for (const t of headerSpans) {
|
|
3053
|
+
if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
|
|
3054
|
+
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
3055
|
+
}
|
|
2863
3056
|
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
|
|
2864
3057
|
const out = banded.out;
|
|
2865
3058
|
if (banded.region) region = region ? merge(region, banded.region) : banded.region;
|
|
@@ -2919,6 +3112,7 @@ function adoptContinuationRows(sheet, titleSpan, base, buildings, deltas) {
|
|
|
2919
3112
|
};
|
|
2920
3113
|
}
|
|
2921
3114
|
var QUALIFIED_TAG_RE = /^([A-Z]{1,2})-(\d{2,3}[A-Z]?)$/;
|
|
3115
|
+
var NON_ROOM_NAME = /* @__PURE__ */ new Set(["NUMBER", "NO", "NAME", "MARK", "SYMBOL", "CODE", "TYPE", "QTY", "SIZE", "TOTAL", "SHEET", "DATE", "SCALE", "REV", "REVISION", "DESCRIPTION", "REMARKS", "COMMENTS", "DETAIL", "ROOM"]);
|
|
2922
3116
|
function roomTags(sheet, opts = {}) {
|
|
2923
3117
|
const out = [];
|
|
2924
3118
|
const spans = sheet.spans;
|
|
@@ -2943,7 +3137,10 @@ function roomTags(sheet, opts = {}) {
|
|
|
2943
3137
|
const dy = b[1] - cb[3];
|
|
2944
3138
|
if (dy < -hgt * 0.2 || dy > hgt * 2.2) continue;
|
|
2945
3139
|
if (cb[2] < b[0] - hgt || cb[0] > b[2] + hgt) continue;
|
|
2946
|
-
|
|
3140
|
+
const raw = cand.str.trim();
|
|
3141
|
+
if (/[a-z]/.test(raw)) continue;
|
|
3142
|
+
if (!/^[A-Z][A-Z .'’\/&-]{1,}$/.test(norm(raw))) continue;
|
|
3143
|
+
if (NON_ROOM_NAME.has(norm(raw))) continue;
|
|
2947
3144
|
if (dy < best) {
|
|
2948
3145
|
best = dy;
|
|
2949
3146
|
name = cand.str.trim();
|
|
@@ -2990,7 +3187,7 @@ function detailCallouts(sheet) {
|
|
|
2990
3187
|
}
|
|
2991
3188
|
function buildSheetGraph(sheets) {
|
|
2992
3189
|
const withText = sheets.filter((s) => s.spans.length > 0);
|
|
2993
|
-
if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], revisions: [], notes: [] };
|
|
3190
|
+
if (!withText.length) return { available: false, sheets: [], rooms: [], unmatched_tags: [], tables: [], callouts: [], buildings: [], revisions: [], notes: [] };
|
|
2994
3191
|
const notes = [];
|
|
2995
3192
|
const deltasBySheet = /* @__PURE__ */ new Map();
|
|
2996
3193
|
const revisions = [];
|
|
@@ -3022,6 +3219,10 @@ function buildSheetGraph(sheets) {
|
|
|
3022
3219
|
for (const kind of ["room-finish", "finish"]) {
|
|
3023
3220
|
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key) });
|
|
3024
3221
|
if (!t) continue;
|
|
3222
|
+
if (kind === "finish" && t.title && isNonFinishSchedule(t.title.text)) {
|
|
3223
|
+
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`);
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3025
3226
|
const titleB = t.title ? buildingMentions(t.title.text) : [];
|
|
3026
3227
|
const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
|
|
3027
3228
|
if (b) t.building = b;
|
|
@@ -3064,19 +3265,46 @@ function buildSheetGraph(sheets) {
|
|
|
3064
3265
|
const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
|
|
3065
3266
|
if (n) sheetNumbers.add(n);
|
|
3066
3267
|
}
|
|
3067
|
-
const
|
|
3268
|
+
const found = [];
|
|
3068
3269
|
const callouts = [];
|
|
3069
3270
|
for (const s of withText) {
|
|
3070
3271
|
const role = roles.get(s.key);
|
|
3071
|
-
|
|
3272
|
+
const suppresses = (role.role === "schedule" || role.role === "legend" || role.role === "elevation" || role.role === "detail") && role.confidence >= 0.6;
|
|
3273
|
+
if (!suppresses) {
|
|
3072
3274
|
const ctxB = ctxBySheet.get(s.key);
|
|
3073
3275
|
for (const r of roomTags(s, { buildings, exclude: sheetNumbers, deltas: deltasBySheet.get(s.key) })) {
|
|
3074
3276
|
if (r.building == null && ctxB) r.building = ctxB;
|
|
3075
|
-
|
|
3277
|
+
found.push(r);
|
|
3076
3278
|
}
|
|
3077
3279
|
}
|
|
3078
3280
|
callouts.push(...detailCallouts(s));
|
|
3079
3281
|
}
|
|
3282
|
+
const roomRows = tables.filter((t) => t.kind === "room-finish");
|
|
3283
|
+
const scheduleNums = /* @__PURE__ */ new Set();
|
|
3284
|
+
for (const t of roomRows) for (const r of t.rows) scheduleNums.add(numOf(norm(r.key)));
|
|
3285
|
+
const rooms = [];
|
|
3286
|
+
const unmatched = [];
|
|
3287
|
+
for (const r of found) {
|
|
3288
|
+
const num2 = numOf(norm(r.tag).replace(/\s+/g, ""));
|
|
3289
|
+
const byName = !!r.name.trim();
|
|
3290
|
+
const bySchedule = scheduleNums.has(num2);
|
|
3291
|
+
if (bySchedule || byName && !roomRows.length) {
|
|
3292
|
+
r.corroboration = bySchedule ? byName ? "name+schedule" : "schedule" : "name";
|
|
3293
|
+
rooms.push(r);
|
|
3294
|
+
} else {
|
|
3295
|
+
unmatched.push({
|
|
3296
|
+
tag: r.tag,
|
|
3297
|
+
sheet: r.sheet,
|
|
3298
|
+
bbox: r.bbox,
|
|
3299
|
+
...r.building ? { building: r.building } : {},
|
|
3300
|
+
...byName ? { name: r.name } : {},
|
|
3301
|
+
reason: !roomRows.length ? "no room name drawn with it, and the set carries no room-finish schedule to check it against" : byName ? `"${r.name}" is drawn with it but no room-finish row answers for it \u2014 either a room the schedule omits, or a keynote/legend row; LOOK before pricing it` : "no room name drawn with it and no room-finish row answers for it \u2014 reads as a keynote, detail marker or dimension fragment rather than a room"
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
if (unmatched.length) {
|
|
3306
|
+
notes.push(`${unmatched.length} numbered tag(s) on plan sheets are NOT counted as rooms \u2014 no name drawn with them and no schedule row answers for them; see unmatched_tags (they are listed, never dropped)`);
|
|
3307
|
+
}
|
|
3080
3308
|
const outSheets = withText.map((s) => {
|
|
3081
3309
|
const role = roles.get(s.key);
|
|
3082
3310
|
const schedules = [];
|
|
@@ -3100,15 +3328,18 @@ function buildSheetGraph(sheets) {
|
|
|
3100
3328
|
if (b) entry.building = b;
|
|
3101
3329
|
return entry;
|
|
3102
3330
|
});
|
|
3103
|
-
return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), revisions, notes };
|
|
3331
|
+
return { available: true, sheets: outSheets, rooms, unmatched_tags: unmatched, tables, callouts, buildings: [...buildings].sort(), revisions, notes };
|
|
3104
3332
|
}
|
|
3105
3333
|
var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
|
|
3334
|
+
var surfaceRank = (label) => SURFACE_HEADERS.indexOf(label.split(" ")[0]);
|
|
3106
3335
|
function resolveTag(graph, tag) {
|
|
3107
3336
|
const t = norm(tag).replace(/\s+/g, "");
|
|
3108
3337
|
const q = t.match(QUALIFIED_KEY_RE);
|
|
3109
3338
|
const wantB = q ? q[1] : null;
|
|
3110
3339
|
const num2 = q ? q[2] : t;
|
|
3111
|
-
const
|
|
3340
|
+
const asRoom = (u) => ({ tag: u.tag, name: u.name ?? "", sheet: u.sheet, bbox: u.bbox, ...u.building ? { building: u.building } : {} });
|
|
3341
|
+
const candidates = [...graph.rooms, ...graph.unmatched_tags.map(asRoom)];
|
|
3342
|
+
const rooms = candidates.filter((r2) => {
|
|
3112
3343
|
const rt = norm(r2.tag).replace(/\s+/g, "");
|
|
3113
3344
|
return rt === t || numOf(rt) === num2;
|
|
3114
3345
|
});
|
|
@@ -3171,7 +3402,8 @@ function resolveTag(graph, tag) {
|
|
|
3171
3402
|
const finishes = [];
|
|
3172
3403
|
const sources = [{ sheet: r.sheet, text: `${tab.title?.text || "room-finish schedule"} row ${r.key}`, bbox: r.cells[Object.keys(r.cells)[0]]?.bbox || tab.region }];
|
|
3173
3404
|
if (room) sources.unshift({ sheet: room.sheet, text: `${room.name ? room.name + " " : ""}${room.tag}`.trim(), bbox: room.bbox });
|
|
3174
|
-
|
|
3405
|
+
const surfaces = Object.keys(r.cells).filter((k) => surfaceRank(k) >= 0).sort((a, b) => surfaceRank(a) - surfaceRank(b) || a.localeCompare(b));
|
|
3406
|
+
for (const surface of surfaces) {
|
|
3175
3407
|
const cell = r.cells[surface];
|
|
3176
3408
|
if (!cell || !cell.text.trim()) continue;
|
|
3177
3409
|
const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
|
|
@@ -7726,6 +7958,7 @@ var Session = class _Session {
|
|
|
7726
7958
|
sheet: r.sheet,
|
|
7727
7959
|
bbox: _Session.wireBox(r.bbox),
|
|
7728
7960
|
...r.building ? { building: r.building } : {},
|
|
7961
|
+
...r.corroboration ? { corroboration: r.corroboration } : {},
|
|
7729
7962
|
...r.revision ? { revision: { rev: r.revision.rev, source: _Session.wireEvidence(r.revision.source), ...r.revision.drawn ? { drawn: true } : {} } } : {}
|
|
7730
7963
|
};
|
|
7731
7964
|
}
|
|
@@ -7749,11 +7982,21 @@ var Session = class _Session {
|
|
|
7749
7982
|
}))
|
|
7750
7983
|
})),
|
|
7751
7984
|
rooms: g.rooms.map(_Session.wireRoom),
|
|
7985
|
+
...g.unmatched_tags.length ? {
|
|
7986
|
+
unmatched_tags: g.unmatched_tags.map((u) => ({
|
|
7987
|
+
tag: u.tag,
|
|
7988
|
+
sheet: u.sheet,
|
|
7989
|
+
bbox: _Session.wireBox(u.bbox),
|
|
7990
|
+
...u.building ? { building: u.building } : {},
|
|
7991
|
+
...u.name ? { name: u.name } : {},
|
|
7992
|
+
reason: u.reason
|
|
7993
|
+
}))
|
|
7994
|
+
} : {},
|
|
7752
7995
|
callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
|
|
7753
7996
|
...g.buildings.length ? { buildings: g.buildings } : {},
|
|
7754
7997
|
...g.revisions.length ? { revisions: g.revisions.map((r) => ({ rev: r.rev, sheet: r.sheet, bbox: _Session.wireBox(r.bbox), ...r.drawn ? { drawn: true } : {} })) } : {},
|
|
7755
7998
|
...g.notes.length ? { notes: g.notes } : {},
|
|
7756
|
-
counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
7999
|
+
counts: { rooms: g.rooms.length, unmatched_tags: g.unmatched_tags.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
7757
8000
|
};
|
|
7758
8001
|
}
|
|
7759
8002
|
/** The FLOOR finish a room's own schedule row states — assign-from-schedule's
|
|
@@ -8396,7 +8639,8 @@ var graphRoom = z.object({
|
|
|
8396
8639
|
sheet: z.string(),
|
|
8397
8640
|
bbox: wireBox,
|
|
8398
8641
|
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()
|
|
8642
|
+
revision: wireRevision.optional(),
|
|
8643
|
+
corroboration: z.string().optional().describe('Why this number is believed to be a room: "schedule" (a room-finish row answers for it), "name" (a name is drawn with it and the set has no room-finish schedule), or "name+schedule"')
|
|
8400
8644
|
});
|
|
8401
8645
|
var sheetGraphOutput = {
|
|
8402
8646
|
available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
|
|
@@ -8415,12 +8659,20 @@ var sheetGraphOutput = {
|
|
|
8415
8659
|
rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn")
|
|
8416
8660
|
}))
|
|
8417
8661
|
})),
|
|
8418
|
-
rooms: z.array(graphRoom).describe("
|
|
8662
|
+
rooms: z.array(graphRoom).describe("Numbers CORROBORATED as rooms \u2014 a room-finish row answers for them, or (where the set carries no room-finish schedule) a room name is drawn with them. Each says which in `corroboration`. Schedule sheets contribute rows, never phantom rooms"),
|
|
8663
|
+
unmatched_tags: z.array(z.object({
|
|
8664
|
+
tag: z.string(),
|
|
8665
|
+
sheet: z.string(),
|
|
8666
|
+
bbox: wireBox,
|
|
8667
|
+
building: z.string().optional(),
|
|
8668
|
+
name: z.string().optional().describe("Text drawn with the number, when there is any \u2014 on a keynote legend this is the accessory description, not a room name"),
|
|
8669
|
+
reason: z.string().describe("WHY this number is not counted as a room. Read these: one of them may be a room the schedule left out, which is a hole in the bid")
|
|
8670
|
+
})).optional().describe('Numbered tags on plan sheets that are NOT counted as rooms \u2014 keynote hexagons, detail markers, dimension fragments, legend rows. Listed with a reason, never dropped. A real finish plan is covered in 2\u20133 digit numbers that are not rooms; counting them as rooms makes every one come back "no schedule row", which reads exactly like the lost-bid case and buries it'),
|
|
8419
8671
|
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"),
|
|
8420
8672
|
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
8673
|
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"),
|
|
8422
8674
|
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"),
|
|
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() })
|
|
8675
|
+
counts: z.object({ rooms: z.number().int(), unmatched_tags: z.number().int().optional(), schedules: z.number().int().describe("LOGICAL tables \u2014 a schedule continued across sheets counts once"), callouts: z.number().int() })
|
|
8424
8676
|
};
|
|
8425
8677
|
var resolveTagOutput = {
|
|
8426
8678
|
status: z.enum(["resolved", "unresolved"]),
|
|
@@ -10557,7 +10809,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
10557
10809
|
outputSchema: undoLastOutput
|
|
10558
10810
|
}, run("undo_last", ({ n }) => session.undoLast(n)));
|
|
10559
10811
|
server.registerTool("sheet_graph", {
|
|
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
|
|
10812
|
+
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 number CORROBORATED as a room (with the stacked room NAME when one exists, the room's BUILDING on multi-building sets, and "corroboration" saying why it counts as a room) plus "unmatched_tags" \u2014 the numbers that are NOT rooms (keynote hexagons, detail markers, dimension fragments, legend rows), each with a reason, listed and never dropped; READ those reasons, one of them may be a room the schedule left out, 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}`,
|
|
10561
10813
|
inputSchema: {},
|
|
10562
10814
|
outputSchema: sheetGraphOutput
|
|
10563
10815
|
}, run("sheet_graph", () => session.sheetGraph()));
|
|
@@ -10848,7 +11100,7 @@ function applyStagedTools(server, registered) {
|
|
|
10848
11100
|
// package.json
|
|
10849
11101
|
var package_default = {
|
|
10850
11102
|
name: "opentakeoff-mcp",
|
|
10851
|
-
version: "0.9.
|
|
11103
|
+
version: "0.9.46",
|
|
10852
11104
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
10853
11105
|
type: "module",
|
|
10854
11106
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED