opentakeoff-mcp 0.9.56 → 0.9.59
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 +252 -15
- 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;
|
|
@@ -6994,6 +7037,158 @@ var Session = class _Session {
|
|
|
6994
7037
|
* through this same path while telling the truth about the method —
|
|
6995
7038
|
* symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
|
|
6996
7039
|
* marker; a bare place_count stays exactly the manual agent gesture it is. */
|
|
7040
|
+
/** count_marks — the deterministic census, the whole count takeoff in ONE
|
|
7041
|
+
* call: every VALUE-ANNOTATED mark tag on the plan sheets, counted per
|
|
7042
|
+
* schedule mark, committed as EA markers, residue disclosed.
|
|
7043
|
+
*
|
|
7044
|
+
* The identity rule is the annotated-device drafting pattern: a device is
|
|
7045
|
+
* drawn with its mark tag and a value under it ("S1" over "200" — CFM, GPM,
|
|
7046
|
+
* a fixture count). Tag text WITH a paired value counts; a tag inside a
|
|
7047
|
+
* schedule table's own region is a row label and is excluded; everything
|
|
7048
|
+
* else is WITHHELD with a reason and coordinates — a tag amid linework but
|
|
7049
|
+
* unvalued may be a real device (look), a bare tag is probably a note. A
|
|
7050
|
+
* mark drawn ON its marker with no value is sweep_schedule_row's family,
|
|
7051
|
+
* not this one. Refusal-honest throughout: scans refuse (no text layer),
|
|
7052
|
+
* a set with no mark-shaped schedule rows refuses unless the marks are
|
|
7053
|
+
* stated, and non-plan sheets are skipped with the role that excused them. */
|
|
7054
|
+
async countMarks(opts = {}) {
|
|
7055
|
+
const graph = await this.ensureGraph();
|
|
7056
|
+
if (!graph.available) {
|
|
7057
|
+
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.");
|
|
7058
|
+
}
|
|
7059
|
+
const canon = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
7060
|
+
const MARK_RE = /^[A-Z]{1,3}-?\d{1,3}[A-Z]?$/;
|
|
7061
|
+
const rowCite = /* @__PURE__ */ new Map();
|
|
7062
|
+
for (const tb of graph.tables) {
|
|
7063
|
+
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
7064
|
+
for (const row of tb.rows) {
|
|
7065
|
+
for (const part of canon(row.key).split("/").filter(Boolean)) {
|
|
7066
|
+
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
|
|
7067
|
+
}
|
|
7068
|
+
}
|
|
7069
|
+
}
|
|
7070
|
+
let marks;
|
|
7071
|
+
if (opts.marks?.length) {
|
|
7072
|
+
marks = [...new Set(opts.marks.map(canon).filter(Boolean))];
|
|
7073
|
+
} else {
|
|
7074
|
+
marks = [...rowCite.keys()].filter((k) => MARK_RE.test(k)).sort();
|
|
7075
|
+
if (!marks.length) {
|
|
7076
|
+
throw new UserError('No mark-shaped schedule row keys in the set to census \u2014 state the marks yourself: count_marks { marks: ["S1", "R1"] }.');
|
|
7077
|
+
}
|
|
7078
|
+
}
|
|
7079
|
+
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
7080
|
+
const skipped = [];
|
|
7081
|
+
const planSheets = [];
|
|
7082
|
+
for (const sh of this.sheetList()) {
|
|
7083
|
+
const role = roleOf.get(sh.key) ?? "unknown";
|
|
7084
|
+
if (role === "plan") planSheets.push(sh);
|
|
7085
|
+
else {
|
|
7086
|
+
skipped.push({
|
|
7087
|
+
sheet: sh.key,
|
|
7088
|
+
role,
|
|
7089
|
+
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`
|
|
7090
|
+
});
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
7093
|
+
if (!planSheets.length) {
|
|
7094
|
+
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.");
|
|
7095
|
+
}
|
|
7096
|
+
const tableRegions = /* @__PURE__ */ new Map();
|
|
7097
|
+
for (const tb of graph.tables) {
|
|
7098
|
+
const arr2 = tableRegions.get(tb.sheet) ?? [];
|
|
7099
|
+
arr2.push(tb.region);
|
|
7100
|
+
tableRegions.set(tb.sheet, arr2);
|
|
7101
|
+
}
|
|
7102
|
+
const VAL_RE = /^[0-9][0-9,]{0,6}$/;
|
|
7103
|
+
const perMark = /* @__PURE__ */ new Map();
|
|
7104
|
+
for (const m of marks) perMark.set(m, { counted: [], withheld: [] });
|
|
7105
|
+
let excludedInTables = 0;
|
|
7106
|
+
const perSheetRows = [];
|
|
7107
|
+
for (const sh of planSheets) {
|
|
7108
|
+
if (!sh.spans) sh.spans = textSpans(sh.page);
|
|
7109
|
+
const regions = tableRegions.get(sh.key) ?? [];
|
|
7110
|
+
const values = sh.spans.filter((sp) => VAL_RE.test(sp.str.trim()));
|
|
7111
|
+
const counts = {};
|
|
7112
|
+
let segs = null;
|
|
7113
|
+
for (const m of marks) {
|
|
7114
|
+
const rec = perMark.get(m);
|
|
7115
|
+
for (const sp of sh.spans) {
|
|
7116
|
+
if (canon(sp.str) !== m) continue;
|
|
7117
|
+
const cx = (sp.x0 + sp.x1) / 2, cy = (sp.y0 + sp.y1) / 2;
|
|
7118
|
+
const h = Math.max(sp.y1 - sp.y0, 6);
|
|
7119
|
+
if (regions.some((r) => cx >= r[0] && cx <= r[2] && cy >= r[1] && cy <= r[3])) {
|
|
7120
|
+
excludedInTables++;
|
|
7121
|
+
continue;
|
|
7122
|
+
}
|
|
7123
|
+
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);
|
|
7124
|
+
if (paired) {
|
|
7125
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
|
|
7126
|
+
counts[m] = (counts[m] || 0) + 1;
|
|
7127
|
+
} else {
|
|
7128
|
+
if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
|
|
7129
|
+
const pad = 2.5 * h;
|
|
7130
|
+
const bx0 = sp.x0 - pad, by0 = sp.y0 - pad, bx1 = sp.x1 + pad, by1 = sp.y1 + pad;
|
|
7131
|
+
let n = 0;
|
|
7132
|
+
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
7133
|
+
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++;
|
|
7134
|
+
}
|
|
7135
|
+
rec.withheld.push({
|
|
7136
|
+
at: [round1(cx), round1(cy)],
|
|
7137
|
+
sheet: sh.key,
|
|
7138
|
+
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"
|
|
7139
|
+
});
|
|
7140
|
+
}
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
perSheetRows.push({ sheet: sh.key, counts });
|
|
7144
|
+
}
|
|
7145
|
+
const committedByMark = {};
|
|
7146
|
+
if (opts.commit) {
|
|
7147
|
+
for (const m of marks) {
|
|
7148
|
+
const rec = perMark.get(m);
|
|
7149
|
+
if (!rec.counted.length) continue;
|
|
7150
|
+
const cite = rowCite.get(m);
|
|
7151
|
+
let n = 0;
|
|
7152
|
+
for (const occ of rec.counted) {
|
|
7153
|
+
this.commit(this.sheet(occ.sheet), m, "count", [occ.at], { count: 1 }, {
|
|
7154
|
+
method: "manual",
|
|
7155
|
+
actor: "agent",
|
|
7156
|
+
reviewed: false,
|
|
7157
|
+
...cite ? { assignment: { source: "schedule", schedule_sheet: cite.sheet } } : {}
|
|
7158
|
+
});
|
|
7159
|
+
n++;
|
|
7160
|
+
}
|
|
7161
|
+
const c = this.conditions.find((x) => x.finish_tag === m);
|
|
7162
|
+
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);
|
|
7163
|
+
committedByMark[m] = { committed: n, ea_total };
|
|
7164
|
+
}
|
|
7165
|
+
if (Object.keys(committedByMark).length) this.flushCommits("count_marks");
|
|
7166
|
+
}
|
|
7167
|
+
const cap = (a, n) => a.length > n ? { list: a.slice(0, n), elided: a.length - n } : { list: a, elided: 0 };
|
|
7168
|
+
return {
|
|
7169
|
+
marks: marks.map((m) => {
|
|
7170
|
+
const rec = perMark.get(m);
|
|
7171
|
+
const cite = rowCite.get(m);
|
|
7172
|
+
const c = cap(rec.counted, 150);
|
|
7173
|
+
const w = cap(rec.withheld, 60);
|
|
7174
|
+
return {
|
|
7175
|
+
mark: m,
|
|
7176
|
+
count: rec.counted.length,
|
|
7177
|
+
...cite ? { row: cite } : { unscheduled: true },
|
|
7178
|
+
occurrences: c.list,
|
|
7179
|
+
...c.elided ? { occurrences_elided: c.elided } : {},
|
|
7180
|
+
withheld: w.list,
|
|
7181
|
+
...w.elided ? { withheld_elided: w.elided } : {},
|
|
7182
|
+
...committedByMark[m] ? { committed: committedByMark[m] } : {}
|
|
7183
|
+
};
|
|
7184
|
+
}),
|
|
7185
|
+
total: [...perMark.values()].reduce((n, r) => n + r.counted.length, 0),
|
|
7186
|
+
per_sheet: perSheetRows,
|
|
7187
|
+
...excludedInTables ? { excluded_in_tables: excludedInTables } : {},
|
|
7188
|
+
skipped,
|
|
7189
|
+
complete: true
|
|
7190
|
+
};
|
|
7191
|
+
}
|
|
6997
7192
|
placeCount(name, points, opts) {
|
|
6998
7193
|
const s = this.sheet(name);
|
|
6999
7194
|
const ids = points.map(([x, y], i) => this.commit(
|
|
@@ -7297,16 +7492,20 @@ var Session = class _Session {
|
|
|
7297
7492
|
if (!t) throw new UserError('Pass a schedule-row tag as drawn, e.g. sweep_schedule_row { tag: "T1" }.');
|
|
7298
7493
|
const graph = await this.ensureGraph();
|
|
7299
7494
|
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
|
|
7495
|
+
const canonKey = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
7496
|
+
const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => rowKeyAnswersFor(r2.key, t)).map((r2) => ({ tb: tb2, r: r2 })));
|
|
7301
7497
|
if (!rowHits.length) {
|
|
7302
|
-
const found2 = graph.tables.map((x) =>
|
|
7498
|
+
const found2 = graph.tables.map((x) => {
|
|
7499
|
+
const keys = x.rows.map((row) => row.key).slice(0, 12).join(", ");
|
|
7500
|
+
return `${x.kind} on ${x.sheet} (${x.rows.length} rows: ${keys}${x.rows.length > 12 ? ", \u2026" : ""})`;
|
|
7501
|
+
}).join(" | ");
|
|
7303
7502
|
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
7503
|
}
|
|
7305
7504
|
if (rowHits.length > 1) {
|
|
7306
7505
|
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
7506
|
}
|
|
7308
7507
|
const { tb, r } = rowHits[0];
|
|
7309
|
-
const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.
|
|
7508
|
+
const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.flatMap((row) => canonKey(row.key).split("/").filter(Boolean))))].filter((k) => k !== t).sort();
|
|
7310
7509
|
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
7311
7510
|
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
7312
7511
|
const skipped = [];
|
|
@@ -7364,6 +7563,7 @@ var Session = class _Session {
|
|
|
7364
7563
|
if (e instanceof Error && /region, not one symbol/.test(e.message)) break;
|
|
7365
7564
|
continue;
|
|
7366
7565
|
}
|
|
7566
|
+
if (cand.segments < 3) continue;
|
|
7367
7567
|
if (!corro) {
|
|
7368
7568
|
fp = cand;
|
|
7369
7569
|
anchorRect = rect;
|
|
@@ -9370,6 +9570,34 @@ var linkAnnotationOutput = {
|
|
|
9370
9570
|
condition_id: z.string().optional(),
|
|
9371
9571
|
note: z.string()
|
|
9372
9572
|
};
|
|
9573
|
+
var censusOccurrence = {
|
|
9574
|
+
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
|
|
9575
|
+
value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
|
|
9576
|
+
sheet: z.string()
|
|
9577
|
+
};
|
|
9578
|
+
var censusWithheld = {
|
|
9579
|
+
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
|
|
9580
|
+
sheet: z.string(),
|
|
9581
|
+
reason: z.string()
|
|
9582
|
+
};
|
|
9583
|
+
var countMarksOutput = {
|
|
9584
|
+
marks: z.array(z.object({
|
|
9585
|
+
mark: z.string(),
|
|
9586
|
+
count: z.number().int().describe("Value-paired instances counted on plan-role sheets"),
|
|
9587
|
+
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)"),
|
|
9588
|
+
unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
|
|
9589
|
+
occurrences: z.array(z.object(censusOccurrence)),
|
|
9590
|
+
occurrences_elided: z.number().int().optional(),
|
|
9591
|
+
withheld: z.array(z.object(censusWithheld)).describe("Tag occurrences that did NOT count, each with the reason \u2014 read them, look, resolve or report"),
|
|
9592
|
+
withheld_elided: z.number().int().optional(),
|
|
9593
|
+
committed: z.object({ committed: z.number().int(), ea_total: z.number() }).optional()
|
|
9594
|
+
})),
|
|
9595
|
+
total: z.number().int().describe("All counted instances across every mark"),
|
|
9596
|
+
per_sheet: z.array(z.object({ sheet: z.string(), counts: z.record(z.number().int()) })),
|
|
9597
|
+
excluded_in_tables: z.number().int().optional().describe("Tag occurrences inside a schedule table's own region \u2014 row labels, never instances"),
|
|
9598
|
+
skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })),
|
|
9599
|
+
complete: z.boolean()
|
|
9600
|
+
};
|
|
9373
9601
|
|
|
9374
9602
|
// src/marked.ts
|
|
9375
9603
|
import path3 from "node:path";
|
|
@@ -11135,6 +11363,14 @@ function registerTools(realServer, session) {
|
|
|
11135
11363
|
mirror: a.mirror,
|
|
11136
11364
|
tolerancePx: a.tolerance_px
|
|
11137
11365
|
})));
|
|
11366
|
+
server.registerTool("count_marks", {
|
|
11367
|
+
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}`,
|
|
11368
|
+
inputSchema: {
|
|
11369
|
+
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`),
|
|
11370
|
+
commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
|
|
11371
|
+
},
|
|
11372
|
+
outputSchema: countMarksOutput
|
|
11373
|
+
}, run("count_marks", (a) => session.countMarks({ marks: a.marks, commit: a.commit })));
|
|
11138
11374
|
server.registerTool("derive_base", {
|
|
11139
11375
|
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
11376
|
inputSchema: {
|
|
@@ -11544,6 +11780,7 @@ var TOOL_STAGES = {
|
|
|
11544
11780
|
"measure_line",
|
|
11545
11781
|
"measure_surface",
|
|
11546
11782
|
"place_count",
|
|
11783
|
+
"count_marks",
|
|
11547
11784
|
"symbol_sweep",
|
|
11548
11785
|
"sweep_schedule_row",
|
|
11549
11786
|
"derive_base",
|
|
@@ -11659,7 +11896,7 @@ function nameTheStageInRefusals(server) {
|
|
|
11659
11896
|
// package.json
|
|
11660
11897
|
var package_default = {
|
|
11661
11898
|
name: "opentakeoff-mcp",
|
|
11662
|
-
version: "0.9.
|
|
11899
|
+
version: "0.9.59",
|
|
11663
11900
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
11664
11901
|
type: "module",
|
|
11665
11902
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -11741,12 +11978,12 @@ function buildServer(session = new Session(), opts = {}) {
|
|
|
11741
11978
|
"OpenTakeoff: quantity takeoff on construction plan PDFs.",
|
|
11742
11979
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
11743
11980
|
"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).",
|
|
11981
|
+
"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
11982
|
"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
11983
|
"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
11984
|
"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
11985
|
"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.",
|
|
11986
|
+
"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
11987
|
...staged ? [STAGED_INSTRUCTIONS] : []
|
|
11751
11988
|
].join("\n")
|
|
11752
11989
|
});
|
package/package.json
CHANGED