opentakeoff-mcp 0.9.56 → 0.9.60
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 +277 -30
- package/package.json +1 -1
package/dist/server-core.js
CHANGED
|
@@ -77,18 +77,49 @@ var STANDARD_SCALES = [
|
|
|
77
77
|
var SHEET_NO_RE = /^[A-Z]{1,3}[-. ]?\d{1,3}(\.\d{1,2})?[A-Z]?$/;
|
|
78
78
|
function extractSheetNumber(textContent, viewport) {
|
|
79
79
|
const W = viewport.width, H = viewport.height;
|
|
80
|
-
|
|
80
|
+
const placed = [];
|
|
81
|
+
const vscale = Math.hypot(viewport.transform?.[0] ?? 1, viewport.transform?.[1] ?? 0) || 1;
|
|
81
82
|
for (const it of textContent.items || []) {
|
|
82
83
|
const raw = (it.str || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
83
|
-
if (
|
|
84
|
+
if (!raw) continue;
|
|
84
85
|
const t = pdfjsLib.Util.transform(viewport.transform, it.transform);
|
|
85
86
|
const x = t[4], y = t[5], h = Math.hypot(t[2], t[3]) || it.height || 0;
|
|
86
87
|
if (x < W * 0.6 || y < H * 0.55) continue;
|
|
88
|
+
const w = it.width != null ? it.width * vscale : raw.length * 0.62 * h;
|
|
89
|
+
placed.push({ raw, x, y, h, w });
|
|
90
|
+
}
|
|
91
|
+
let best = null, bestScore = 0;
|
|
92
|
+
const consider = (raw, x, y, h) => {
|
|
93
|
+
if (raw.length < 2 || raw.length > 8 || !SHEET_NO_RE.test(raw)) return;
|
|
87
94
|
const score = h + x / W * 4 + y / H * 4;
|
|
88
|
-
if (score >
|
|
89
|
-
|
|
95
|
+
if (score > bestScore) {
|
|
96
|
+
bestScore = score;
|
|
90
97
|
best = raw;
|
|
91
98
|
}
|
|
99
|
+
};
|
|
100
|
+
for (const p of placed) consider(p.raw, p.x, p.y, p.h);
|
|
101
|
+
const rows = [];
|
|
102
|
+
for (const p of [...placed].sort((a, b) => a.y - b.y || a.x - b.x)) {
|
|
103
|
+
const row = rows[rows.length - 1];
|
|
104
|
+
if (row && Math.abs(p.y - row[0].y) <= Math.max(2, row[0].h * 0.35)) row.push(p);
|
|
105
|
+
else rows.push([p]);
|
|
106
|
+
}
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
row.sort((a, b) => a.x - b.x);
|
|
109
|
+
let run2 = [];
|
|
110
|
+
const flush = () => {
|
|
111
|
+
if (run2.length > 1) {
|
|
112
|
+
const h = Math.max(...run2.map((r) => r.h));
|
|
113
|
+
consider(run2.map((r) => r.raw).join(""), run2[0].x, run2[0].y, h);
|
|
114
|
+
}
|
|
115
|
+
run2 = [];
|
|
116
|
+
};
|
|
117
|
+
for (const p of row) {
|
|
118
|
+
const prev = run2[run2.length - 1];
|
|
119
|
+
if (prev && p.x - (prev.x + prev.w) > Math.max(...run2.map((r) => r.h)) * 1.2) flush();
|
|
120
|
+
run2.push(p);
|
|
121
|
+
}
|
|
122
|
+
flush();
|
|
92
123
|
}
|
|
93
124
|
return best;
|
|
94
125
|
}
|
|
@@ -2580,7 +2611,9 @@ var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str ||
|
|
|
2580
2611
|
var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
|
|
2581
2612
|
var ROLE_SIGNALS = [
|
|
2582
2613
|
{ re: /DEMOLITION\s+PLAN|DEMO\s+PLAN/, role: "demolition", conf: 0.9 },
|
|
2583
|
-
|
|
2614
|
+
// every discipline draws plans, not just finishes — an M-sheet's "SECOND
|
|
2615
|
+
// FLOOR DUCTWORK PLAN" is as much a plan title as an A-sheet's finish plan
|
|
2616
|
+
{ re: /(?:FINISH|FLOOR|FURNITURE|CEILING|DUCTWORK|PIPING|MECHANICAL|ELECTRICAL|LIGHTING|POWER|PLUMBING|SPRINKLER|HVAC|FRAMING|FOUNDATION|ROOF|SITE|EQUIPMENT)\s+PLAN\b/, role: "plan", conf: 0.85 },
|
|
2584
2617
|
{ re: SCHEDULE_TITLE_RE, role: "schedule", conf: 0.85 },
|
|
2585
2618
|
{ re: /SCHEDULE/, role: "schedule", conf: 0.5 },
|
|
2586
2619
|
{ re: /LEGEND/, role: "legend", conf: 0.5 },
|
|
@@ -2600,7 +2633,7 @@ function classifySheetRole(sheet) {
|
|
|
2600
2633
|
}
|
|
2601
2634
|
if (!hits.length) {
|
|
2602
2635
|
const n = norm(sheet.sheet_number || "");
|
|
2603
|
-
if (/^A-?1\d\d/.test(n)) return { role: "plan", confidence: 0.4, evidence: null };
|
|
2636
|
+
if (/^(A|M|E|P|S|FP)-?1\d\d/.test(n)) return { role: "plan", confidence: 0.4, evidence: null };
|
|
2604
2637
|
return { role: "unknown", confidence: 0, evidence: null };
|
|
2605
2638
|
}
|
|
2606
2639
|
hits.sort((a, b) => b.conf - a.conf);
|
|
@@ -2916,13 +2949,23 @@ var isNonFinishSchedule = (title) => {
|
|
|
2916
2949
|
return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
|
|
2917
2950
|
};
|
|
2918
2951
|
function rowKeyOf(raw, kind, buildings) {
|
|
2919
|
-
const
|
|
2920
|
-
|
|
2952
|
+
const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
|
|
2953
|
+
const key = kept.replace(/\//g, "");
|
|
2954
|
+
if (kind === "finish") {
|
|
2955
|
+
const parts = kept.split("/").filter(Boolean);
|
|
2956
|
+
if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
|
|
2957
|
+
return CODE_RE.test(key) ? { key } : null;
|
|
2958
|
+
}
|
|
2921
2959
|
if (ROW_KEY_RE.test(key)) return { key };
|
|
2922
2960
|
const q = key.match(QUALIFIED_KEY_RE);
|
|
2923
2961
|
if (q && buildings?.has(q[1])) return { key, building: q[1] };
|
|
2924
2962
|
return null;
|
|
2925
2963
|
}
|
|
2964
|
+
var rowKeyAnswersFor = (key, want) => {
|
|
2965
|
+
const c = norm(key).replace(/\s+/g, "");
|
|
2966
|
+
const w = norm(want).replace(/\s+/g, "");
|
|
2967
|
+
return c === w || c.split("/").filter(Boolean).includes(w);
|
|
2968
|
+
};
|
|
2926
2969
|
var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
|
|
2927
2970
|
var centerX = (t) => t.x + (t.w || 0) / 2;
|
|
2928
2971
|
function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
|
|
@@ -3470,7 +3513,7 @@ function resolveTag(graph, tag) {
|
|
|
3470
3513
|
const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
|
|
3471
3514
|
const fin = { surface, code: cell.text.trim(), source: { sheet: r.sheet, text: cell.text.trim(), bbox: cell.bbox } };
|
|
3472
3515
|
for (const ft of finTables) {
|
|
3473
|
-
const def = ft.rows.find((fr) =>
|
|
3516
|
+
const def = ft.rows.find((fr) => rowKeyAnswersFor(fr.key, code));
|
|
3474
3517
|
if (def) {
|
|
3475
3518
|
const cells = {};
|
|
3476
3519
|
for (const [k, v] of Object.entries(def.cells)) cells[k] = v.text;
|
|
@@ -4111,6 +4154,30 @@ function buildNegative(fp, segs, rect, opts = {}) {
|
|
|
4111
4154
|
center: [Math.round(best.at[0] * 10) / 10, Math.round(best.at[1] * 10) / 10]
|
|
4112
4155
|
};
|
|
4113
4156
|
}
|
|
4157
|
+
function mergeProposals(scored, mergeR) {
|
|
4158
|
+
const kept = [];
|
|
4159
|
+
for (const s of scored) {
|
|
4160
|
+
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
4161
|
+
if (!twin) {
|
|
4162
|
+
kept.push({ ...s });
|
|
4163
|
+
continue;
|
|
4164
|
+
}
|
|
4165
|
+
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
4166
|
+
twin.at = s.at;
|
|
4167
|
+
twin.score = s.score;
|
|
4168
|
+
twin.rotation = s.rotation;
|
|
4169
|
+
twin.mirrored = s.mirrored;
|
|
4170
|
+
twin.xf = s.xf;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
const byBest = [...kept].sort((a, b) => b.score - a.score || a.xf - b.xf || a.at[1] - b.at[1] || a.at[0] - b.at[0]);
|
|
4174
|
+
const out = [];
|
|
4175
|
+
for (const s of byBest) {
|
|
4176
|
+
if (out.some((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR)) continue;
|
|
4177
|
+
out.push(s);
|
|
4178
|
+
}
|
|
4179
|
+
return out;
|
|
4180
|
+
}
|
|
4114
4181
|
function matchSymbol(fp, segs, opts = {}) {
|
|
4115
4182
|
const scale = opts.scale ?? 1;
|
|
4116
4183
|
const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
|
|
@@ -4221,21 +4288,7 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
4221
4288
|
scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
|
|
4222
4289
|
}
|
|
4223
4290
|
const mergeR = Math.max(2 * tol, 4);
|
|
4224
|
-
const kept =
|
|
4225
|
-
for (const s of scored) {
|
|
4226
|
-
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
4227
|
-
if (!twin) {
|
|
4228
|
-
kept.push(s);
|
|
4229
|
-
continue;
|
|
4230
|
-
}
|
|
4231
|
-
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
4232
|
-
twin.at = s.at;
|
|
4233
|
-
twin.score = s.score;
|
|
4234
|
-
twin.rotation = s.rotation;
|
|
4235
|
-
twin.mirrored = s.mirrored;
|
|
4236
|
-
twin.xf = s.xf;
|
|
4237
|
-
}
|
|
4238
|
-
}
|
|
4291
|
+
const kept = mergeProposals(scored, mergeR);
|
|
4239
4292
|
const suppressR = Math.max(mergeR, fpS.footprint / 2);
|
|
4240
4293
|
const ex = opts.excludeCenter;
|
|
4241
4294
|
const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
|
|
@@ -6994,6 +7047,158 @@ var Session = class _Session {
|
|
|
6994
7047
|
* through this same path while telling the truth about the method —
|
|
6995
7048
|
* symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
|
|
6996
7049
|
* marker; a bare place_count stays exactly the manual agent gesture it is. */
|
|
7050
|
+
/** count_marks — the deterministic census, the whole count takeoff in ONE
|
|
7051
|
+
* call: every VALUE-ANNOTATED mark tag on the plan sheets, counted per
|
|
7052
|
+
* schedule mark, committed as EA markers, residue disclosed.
|
|
7053
|
+
*
|
|
7054
|
+
* The identity rule is the annotated-device drafting pattern: a device is
|
|
7055
|
+
* drawn with its mark tag and a value under it ("S1" over "200" — CFM, GPM,
|
|
7056
|
+
* a fixture count). Tag text WITH a paired value counts; a tag inside a
|
|
7057
|
+
* schedule table's own region is a row label and is excluded; everything
|
|
7058
|
+
* else is WITHHELD with a reason and coordinates — a tag amid linework but
|
|
7059
|
+
* unvalued may be a real device (look), a bare tag is probably a note. A
|
|
7060
|
+
* mark drawn ON its marker with no value is sweep_schedule_row's family,
|
|
7061
|
+
* not this one. Refusal-honest throughout: scans refuse (no text layer),
|
|
7062
|
+
* a set with no mark-shaped schedule rows refuses unless the marks are
|
|
7063
|
+
* stated, and non-plan sheets are skipped with the role that excused them. */
|
|
7064
|
+
async countMarks(opts = {}) {
|
|
7065
|
+
const graph = await this.ensureGraph();
|
|
7066
|
+
if (!graph.available) {
|
|
7067
|
+
throw new UserError("This set has no text layer (a scan) \u2014 the census reads drawn tag text, so it cannot run. Marquee one device with symbol_sweep instead.");
|
|
7068
|
+
}
|
|
7069
|
+
const canon = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
7070
|
+
const MARK_RE = /^[A-Z]{1,3}-?\d{1,3}[A-Z]?$/;
|
|
7071
|
+
const rowCite = /* @__PURE__ */ new Map();
|
|
7072
|
+
for (const tb of graph.tables) {
|
|
7073
|
+
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
7074
|
+
for (const row of tb.rows) {
|
|
7075
|
+
for (const part of canon(row.key).split("/").filter(Boolean)) {
|
|
7076
|
+
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
|
|
7077
|
+
}
|
|
7078
|
+
}
|
|
7079
|
+
}
|
|
7080
|
+
let marks;
|
|
7081
|
+
if (opts.marks?.length) {
|
|
7082
|
+
marks = [...new Set(opts.marks.map(canon).filter(Boolean))];
|
|
7083
|
+
} else {
|
|
7084
|
+
marks = [...rowCite.keys()].filter((k) => MARK_RE.test(k)).sort();
|
|
7085
|
+
if (!marks.length) {
|
|
7086
|
+
throw new UserError('No mark-shaped schedule row keys in the set to census \u2014 state the marks yourself: count_marks { marks: ["S1", "R1"] }.');
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
7090
|
+
const skipped = [];
|
|
7091
|
+
const planSheets = [];
|
|
7092
|
+
for (const sh of this.sheetList()) {
|
|
7093
|
+
const role = roleOf.get(sh.key) ?? "unknown";
|
|
7094
|
+
if (role === "plan") planSheets.push(sh);
|
|
7095
|
+
else {
|
|
7096
|
+
skipped.push({
|
|
7097
|
+
sheet: sh.key,
|
|
7098
|
+
role,
|
|
7099
|
+
reason: role === "unknown" ? "role unknown (no classifiable title text) \u2014 tags here are not censused" : `a ${role} sheet \u2014 tags here are reference text, never installed work`
|
|
7100
|
+
});
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
if (!planSheets.length) {
|
|
7104
|
+
throw new UserError("No plan-role sheet in the set \u2014 the census counts installed work, and every sheet classified as schedule/legend/detail/unknown. sheet_graph shows each sheet's role and evidence.");
|
|
7105
|
+
}
|
|
7106
|
+
const tableRegions = /* @__PURE__ */ new Map();
|
|
7107
|
+
for (const tb of graph.tables) {
|
|
7108
|
+
const arr2 = tableRegions.get(tb.sheet) ?? [];
|
|
7109
|
+
arr2.push(tb.region);
|
|
7110
|
+
tableRegions.set(tb.sheet, arr2);
|
|
7111
|
+
}
|
|
7112
|
+
const VAL_RE = /^[0-9][0-9,]{0,6}$/;
|
|
7113
|
+
const perMark = /* @__PURE__ */ new Map();
|
|
7114
|
+
for (const m of marks) perMark.set(m, { counted: [], withheld: [] });
|
|
7115
|
+
let excludedInTables = 0;
|
|
7116
|
+
const perSheetRows = [];
|
|
7117
|
+
for (const sh of planSheets) {
|
|
7118
|
+
if (!sh.spans) sh.spans = textSpans(sh.page);
|
|
7119
|
+
const regions = tableRegions.get(sh.key) ?? [];
|
|
7120
|
+
const values = sh.spans.filter((sp) => VAL_RE.test(sp.str.trim()));
|
|
7121
|
+
const counts = {};
|
|
7122
|
+
let segs = null;
|
|
7123
|
+
for (const m of marks) {
|
|
7124
|
+
const rec = perMark.get(m);
|
|
7125
|
+
for (const sp of sh.spans) {
|
|
7126
|
+
if (canon(sp.str) !== m) continue;
|
|
7127
|
+
const cx = (sp.x0 + sp.x1) / 2, cy = (sp.y0 + sp.y1) / 2;
|
|
7128
|
+
const h = Math.max(sp.y1 - sp.y0, 6);
|
|
7129
|
+
if (regions.some((r) => cx >= r[0] && cx <= r[2] && cy >= r[1] && cy <= r[3])) {
|
|
7130
|
+
excludedInTables++;
|
|
7131
|
+
continue;
|
|
7132
|
+
}
|
|
7133
|
+
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);
|
|
7134
|
+
if (paired) {
|
|
7135
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
|
|
7136
|
+
counts[m] = (counts[m] || 0) + 1;
|
|
7137
|
+
} else {
|
|
7138
|
+
if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
|
|
7139
|
+
const pad = 2.5 * h;
|
|
7140
|
+
const bx0 = sp.x0 - pad, by0 = sp.y0 - pad, bx1 = sp.x1 + pad, by1 = sp.y1 + pad;
|
|
7141
|
+
let n = 0;
|
|
7142
|
+
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
7143
|
+
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++;
|
|
7144
|
+
}
|
|
7145
|
+
rec.withheld.push({
|
|
7146
|
+
at: [round1(cx), round1(cy)],
|
|
7147
|
+
sheet: sh.key,
|
|
7148
|
+
reason: n >= 3 ? "tag amid linework but no paired value \u2014 may be a device labeled without one, or a legend/detail reference; look before counting it" : "bare tag text \u2014 no paired value, no adjacent linework; likely a note mention, not an instance"
|
|
7149
|
+
});
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
7152
|
+
}
|
|
7153
|
+
perSheetRows.push({ sheet: sh.key, counts });
|
|
7154
|
+
}
|
|
7155
|
+
const committedByMark = {};
|
|
7156
|
+
if (opts.commit) {
|
|
7157
|
+
for (const m of marks) {
|
|
7158
|
+
const rec = perMark.get(m);
|
|
7159
|
+
if (!rec.counted.length) continue;
|
|
7160
|
+
const cite = rowCite.get(m);
|
|
7161
|
+
let n = 0;
|
|
7162
|
+
for (const occ of rec.counted) {
|
|
7163
|
+
this.commit(this.sheet(occ.sheet), m, "count", [occ.at], { count: 1 }, {
|
|
7164
|
+
method: "manual",
|
|
7165
|
+
actor: "agent",
|
|
7166
|
+
reviewed: false,
|
|
7167
|
+
...cite ? { assignment: { source: "schedule", schedule_sheet: cite.sheet } } : {}
|
|
7168
|
+
});
|
|
7169
|
+
n++;
|
|
7170
|
+
}
|
|
7171
|
+
const c = this.conditions.find((x) => x.finish_tag === m);
|
|
7172
|
+
const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((t2, x) => t2 + (x.computed.count || 1), 0);
|
|
7173
|
+
committedByMark[m] = { committed: n, ea_total };
|
|
7174
|
+
}
|
|
7175
|
+
if (Object.keys(committedByMark).length) this.flushCommits("count_marks");
|
|
7176
|
+
}
|
|
7177
|
+
const cap = (a, n) => a.length > n ? { list: a.slice(0, n), elided: a.length - n } : { list: a, elided: 0 };
|
|
7178
|
+
return {
|
|
7179
|
+
marks: marks.map((m) => {
|
|
7180
|
+
const rec = perMark.get(m);
|
|
7181
|
+
const cite = rowCite.get(m);
|
|
7182
|
+
const c = cap(rec.counted, 150);
|
|
7183
|
+
const w = cap(rec.withheld, 60);
|
|
7184
|
+
return {
|
|
7185
|
+
mark: m,
|
|
7186
|
+
count: rec.counted.length,
|
|
7187
|
+
...cite ? { row: cite } : { unscheduled: true },
|
|
7188
|
+
occurrences: c.list,
|
|
7189
|
+
...c.elided ? { occurrences_elided: c.elided } : {},
|
|
7190
|
+
withheld: w.list,
|
|
7191
|
+
...w.elided ? { withheld_elided: w.elided } : {},
|
|
7192
|
+
...committedByMark[m] ? { committed: committedByMark[m] } : {}
|
|
7193
|
+
};
|
|
7194
|
+
}),
|
|
7195
|
+
total: [...perMark.values()].reduce((n, r) => n + r.counted.length, 0),
|
|
7196
|
+
per_sheet: perSheetRows,
|
|
7197
|
+
...excludedInTables ? { excluded_in_tables: excludedInTables } : {},
|
|
7198
|
+
skipped,
|
|
7199
|
+
complete: true
|
|
7200
|
+
};
|
|
7201
|
+
}
|
|
6997
7202
|
placeCount(name, points, opts) {
|
|
6998
7203
|
const s = this.sheet(name);
|
|
6999
7204
|
const ids = points.map(([x, y], i) => this.commit(
|
|
@@ -7297,16 +7502,20 @@ var Session = class _Session {
|
|
|
7297
7502
|
if (!t) throw new UserError('Pass a schedule-row tag as drawn, e.g. sweep_schedule_row { tag: "T1" }.');
|
|
7298
7503
|
const graph = await this.ensureGraph();
|
|
7299
7504
|
if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, so schedule rows cannot be read.");
|
|
7300
|
-
const
|
|
7505
|
+
const canonKey = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
7506
|
+
const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => rowKeyAnswersFor(r2.key, t)).map((r2) => ({ tb: tb2, r: r2 })));
|
|
7301
7507
|
if (!rowHits.length) {
|
|
7302
|
-
const found2 = graph.tables.map((x) =>
|
|
7508
|
+
const found2 = graph.tables.map((x) => {
|
|
7509
|
+
const keys = x.rows.map((row) => row.key).slice(0, 12).join(", ");
|
|
7510
|
+
return `${x.kind} on ${x.sheet} (${x.rows.length} rows: ${keys}${x.rows.length > 12 ? ", \u2026" : ""})`;
|
|
7511
|
+
}).join(" | ");
|
|
7303
7512
|
throw new UserError(`No schedule row "${t}" in the set \u2014 tables found: ${found2 || "none"}. Check the tag as drawn (find_schedule shows each table's region), or merge the schedule sheet in with load_plan.`);
|
|
7304
7513
|
}
|
|
7305
7514
|
if (rowHits.length > 1) {
|
|
7306
7515
|
throw new UserError(`Ambiguous: ${rowHits.length} schedule rows carry the key "${t}" \u2014 the same mark defined twice cannot seed one sweep. Marquee the marker yourself with symbol_sweep.`);
|
|
7307
7516
|
}
|
|
7308
7517
|
const { tb, r } = rowHits[0];
|
|
7309
|
-
const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.
|
|
7518
|
+
const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.flatMap((row) => canonKey(row.key).split("/").filter(Boolean))))].filter((k) => k !== t).sort();
|
|
7310
7519
|
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
7311
7520
|
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
7312
7521
|
const skipped = [];
|
|
@@ -7364,6 +7573,7 @@ var Session = class _Session {
|
|
|
7364
7573
|
if (e instanceof Error && /region, not one symbol/.test(e.message)) break;
|
|
7365
7574
|
continue;
|
|
7366
7575
|
}
|
|
7576
|
+
if (cand.segments < 3) continue;
|
|
7367
7577
|
if (!corro) {
|
|
7368
7578
|
fp = cand;
|
|
7369
7579
|
anchorRect = rect;
|
|
@@ -9370,6 +9580,34 @@ var linkAnnotationOutput = {
|
|
|
9370
9580
|
condition_id: z.string().optional(),
|
|
9371
9581
|
note: z.string()
|
|
9372
9582
|
};
|
|
9583
|
+
var censusOccurrence = {
|
|
9584
|
+
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
|
|
9585
|
+
value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
|
|
9586
|
+
sheet: z.string()
|
|
9587
|
+
};
|
|
9588
|
+
var censusWithheld = {
|
|
9589
|
+
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
|
|
9590
|
+
sheet: z.string(),
|
|
9591
|
+
reason: z.string()
|
|
9592
|
+
};
|
|
9593
|
+
var countMarksOutput = {
|
|
9594
|
+
marks: z.array(z.object({
|
|
9595
|
+
mark: z.string(),
|
|
9596
|
+
count: z.number().int().describe("Value-paired instances counted on plan-role sheets"),
|
|
9597
|
+
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)"),
|
|
9598
|
+
unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
|
|
9599
|
+
occurrences: z.array(z.object(censusOccurrence)),
|
|
9600
|
+
occurrences_elided: z.number().int().optional(),
|
|
9601
|
+
withheld: z.array(z.object(censusWithheld)).describe("Tag occurrences that did NOT count, each with the reason \u2014 read them, look, resolve or report"),
|
|
9602
|
+
withheld_elided: z.number().int().optional(),
|
|
9603
|
+
committed: z.object({ committed: z.number().int(), ea_total: z.number() }).optional()
|
|
9604
|
+
})),
|
|
9605
|
+
total: z.number().int().describe("All counted instances across every mark"),
|
|
9606
|
+
per_sheet: z.array(z.object({ sheet: z.string(), counts: z.record(z.number().int()) })),
|
|
9607
|
+
excluded_in_tables: z.number().int().optional().describe("Tag occurrences inside a schedule table's own region \u2014 row labels, never instances"),
|
|
9608
|
+
skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })),
|
|
9609
|
+
complete: z.boolean()
|
|
9610
|
+
};
|
|
9373
9611
|
|
|
9374
9612
|
// src/marked.ts
|
|
9375
9613
|
import path3 from "node:path";
|
|
@@ -11135,6 +11373,14 @@ function registerTools(realServer, session) {
|
|
|
11135
11373
|
mirror: a.mirror,
|
|
11136
11374
|
tolerancePx: a.tolerance_px
|
|
11137
11375
|
})));
|
|
11376
|
+
server.registerTool("count_marks", {
|
|
11377
|
+
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}`,
|
|
11378
|
+
inputSchema: {
|
|
11379
|
+
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`),
|
|
11380
|
+
commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
|
|
11381
|
+
},
|
|
11382
|
+
outputSchema: countMarksOutput
|
|
11383
|
+
}, run("count_marks", (a) => session.countMarks({ marks: a.marks, commit: a.commit })));
|
|
11138
11384
|
server.registerTool("derive_base", {
|
|
11139
11385
|
description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
|
|
11140
11386
|
inputSchema: {
|
|
@@ -11544,6 +11790,7 @@ var TOOL_STAGES = {
|
|
|
11544
11790
|
"measure_line",
|
|
11545
11791
|
"measure_surface",
|
|
11546
11792
|
"place_count",
|
|
11793
|
+
"count_marks",
|
|
11547
11794
|
"symbol_sweep",
|
|
11548
11795
|
"sweep_schedule_row",
|
|
11549
11796
|
"derive_base",
|
|
@@ -11659,7 +11906,7 @@ function nameTheStageInRefusals(server) {
|
|
|
11659
11906
|
// package.json
|
|
11660
11907
|
var package_default = {
|
|
11661
11908
|
name: "opentakeoff-mcp",
|
|
11662
|
-
version: "0.9.
|
|
11909
|
+
version: "0.9.60",
|
|
11663
11910
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
11664
11911
|
type: "module",
|
|
11665
11912
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -11741,12 +11988,12 @@ function buildServer(session = new Session(), opts = {}) {
|
|
|
11741
11988
|
"OpenTakeoff: quantity takeoff on construction plan PDFs.",
|
|
11742
11989
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
11743
11990
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
11744
|
-
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
|
|
11991
|
+
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds.",
|
|
11745
11992
|
"3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
|
|
11746
11993
|
"4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
11747
11994
|
"5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
|
|
11748
11995
|
"A floor split across sheets at a MATCH LINE: there is no stitch verb, deliberately \u2014 joining and aligning a match line is human judgment in the canvas (a sloppy join silently skews every seam-crossing quantity). Measure each member sheet as its own surface and tell the user a seam-crossing room needs their stitch in the app; never approximate one by combining sheets yourself.",
|
|
11749
|
-
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
|
|
11996
|
+
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row, count_marks and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
|
|
11750
11997
|
...staged ? [STAGED_INSTRUCTIONS] : []
|
|
11751
11998
|
].join("\n")
|
|
11752
11999
|
});
|
package/package.json
CHANGED