opentakeoff-mcp 0.9.2 → 0.9.3
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 +345 -29
- package/package.json +1 -1
package/dist/server-core.js
CHANGED
|
@@ -28,6 +28,19 @@ import * as pdfjs from "pdfjs-dist";
|
|
|
28
28
|
|
|
29
29
|
// ../web/src/lib/sheets.ts
|
|
30
30
|
import * as pdfjsLib from "pdfjs-dist";
|
|
31
|
+
|
|
32
|
+
// ../web/src/lib/sheetKey.ts
|
|
33
|
+
function parseSheetKey(key) {
|
|
34
|
+
const i = key.lastIndexOf("#");
|
|
35
|
+
if (i > 0 && /^\d+$/.test(key.slice(i + 1))) return { file: key.slice(0, i), page: parseInt(key.slice(i + 1), 10) };
|
|
36
|
+
return { file: key, page: 1 };
|
|
37
|
+
}
|
|
38
|
+
function compareSheetKeys(ka, kb) {
|
|
39
|
+
const a = parseSheetKey(ka), b = parseSheetKey(kb);
|
|
40
|
+
return a.file === b.file ? a.page - b.page : a.file.localeCompare(b.file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ../web/src/lib/sheets.ts
|
|
31
44
|
var RENDER_SCALE = 2;
|
|
32
45
|
var PX_PER_IN = 72 * RENDER_SCALE;
|
|
33
46
|
var arch = (inPerFt) => 1 / inPerFt / PX_PER_IN;
|
|
@@ -256,6 +269,7 @@ var LEAK_FRACTION = 0.3;
|
|
|
256
269
|
var TINY_PX = 30;
|
|
257
270
|
var MIN_THICK = 4;
|
|
258
271
|
var CURVE_STEPS = 8;
|
|
272
|
+
var GAP_BRIDGE_MAX = 2;
|
|
259
273
|
var SEG_CURVE = 1;
|
|
260
274
|
var SEG_CLIP = 2;
|
|
261
275
|
var SEG_FILLONLY = 4;
|
|
@@ -718,24 +732,60 @@ function floodPass(maskObj, ix, iy, barrier) {
|
|
|
718
732
|
if (count < TINY_PX || bx1 - bx0 + 1 < MIN_THICK || by1 - by0 + 1 < MIN_THICK) return { status: "tiny", count };
|
|
719
733
|
return { status: "ok", region, count, mw, mh, ws, hardHits, softHits };
|
|
720
734
|
}
|
|
735
|
+
function dilateHard(maskObj, r) {
|
|
736
|
+
const { mask, mw, mh, ws, softCount } = maskObj;
|
|
737
|
+
const horiz = new Uint8Array(mask);
|
|
738
|
+
for (let y = 0; y < mh; y++) {
|
|
739
|
+
const row = y * mw;
|
|
740
|
+
for (let x = 0; x < mw; x++) {
|
|
741
|
+
if (mask[row + x] & 1) {
|
|
742
|
+
const x1 = Math.min(mw - 1, x + r);
|
|
743
|
+
for (let i = Math.max(0, x - r); i <= x1; i++) horiz[row + i] |= 1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const out = new Uint8Array(horiz);
|
|
748
|
+
for (let y = 0; y < mh; y++) {
|
|
749
|
+
const row = y * mw;
|
|
750
|
+
for (let x = 0; x < mw; x++) {
|
|
751
|
+
if (horiz[row + x] & 1) {
|
|
752
|
+
const y1 = Math.min(mh - 1, y + r);
|
|
753
|
+
for (let j = Math.max(0, y - r); j <= y1; j++) out[j * mw + x] |= 1;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { mask: out, mw, mh, ws, softCount };
|
|
758
|
+
}
|
|
721
759
|
function floodRegion(maskObj, ix, iy, sensitivity = SENS_BALANCED) {
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
r2.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
760
|
+
const attempt = (m) => {
|
|
761
|
+
const r1 = floodPass(m, ix, iy, 3);
|
|
762
|
+
if (!m.softCount) return r1;
|
|
763
|
+
if (r1.status === "leak") return r1;
|
|
764
|
+
const { escalateFrac, growthMax } = escalationParams(sensitivity);
|
|
765
|
+
let growthCap = Infinity;
|
|
766
|
+
if (r1.status === "ok") {
|
|
767
|
+
const blocks = (r1.hardHits || 0) + (r1.softHits || 0);
|
|
768
|
+
const softFrac = blocks ? (r1.softHits || 0) / blocks : 0;
|
|
769
|
+
if (softFrac < escalateFrac) return r1;
|
|
770
|
+
if (softFrac < HATCH_BOUND_FRAC) growthCap = growthMax;
|
|
771
|
+
}
|
|
772
|
+
const r2 = floodPass(m, ix, iy, 1);
|
|
773
|
+
if (r2.status === "ok" && (r1.status !== "ok" || r2.count <= r1.count * growthCap)) {
|
|
774
|
+
r2.hatchFiltered = true;
|
|
775
|
+
return r2;
|
|
776
|
+
}
|
|
777
|
+
return r1;
|
|
778
|
+
};
|
|
779
|
+
const r = attempt(maskObj);
|
|
780
|
+
if (r.status !== "leak") return r;
|
|
781
|
+
for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
|
|
782
|
+
const rb = attempt(dilateHard(maskObj, br));
|
|
783
|
+
if (rb.status === "ok") {
|
|
784
|
+
rb.gapBridged = br;
|
|
785
|
+
return rb;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return r;
|
|
739
789
|
}
|
|
740
790
|
function traceRegion(reg, epsMaskPx = 1.5) {
|
|
741
791
|
const { region, mw, mh, ws } = reg;
|
|
@@ -905,6 +955,12 @@ function openLen(pts) {
|
|
|
905
955
|
// ../web/src/lib/num.js
|
|
906
956
|
var round22 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
907
957
|
|
|
958
|
+
// ../web/src/lib/conditionColumns.js
|
|
959
|
+
var visible = (v) => typeof v === "string" && v.trim() ? v : "";
|
|
960
|
+
function attrValue(attrs, colId) {
|
|
961
|
+
return visible(attrs?.[colId]);
|
|
962
|
+
}
|
|
963
|
+
|
|
908
964
|
// ../web/src/lib/totals.js
|
|
909
965
|
function accumulateRole(acc, s) {
|
|
910
966
|
const cp = s.computed || {};
|
|
@@ -977,6 +1033,63 @@ function conditionTotals(conditions, shapes) {
|
|
|
977
1033
|
};
|
|
978
1034
|
});
|
|
979
1035
|
}
|
|
1036
|
+
function sheetTotals(conditions, shapes) {
|
|
1037
|
+
const bySheet = /* @__PURE__ */ new Map();
|
|
1038
|
+
for (const s of shapes) {
|
|
1039
|
+
let conds = bySheet.get(s.sheet_id);
|
|
1040
|
+
if (!conds) {
|
|
1041
|
+
conds = /* @__PURE__ */ new Map();
|
|
1042
|
+
bySheet.set(s.sheet_id, conds);
|
|
1043
|
+
}
|
|
1044
|
+
let a = conds.get(s.condition_id);
|
|
1045
|
+
if (!a) {
|
|
1046
|
+
a = { n: 0, floor: 0, wall: 0, border: 0, lf: 0, ea: 0 };
|
|
1047
|
+
conds.set(s.condition_id, a);
|
|
1048
|
+
}
|
|
1049
|
+
a.n += 1;
|
|
1050
|
+
accumulateRole(a, s);
|
|
1051
|
+
}
|
|
1052
|
+
const order = [...bySheet.keys()].sort((ka, kb) => compareSheetKeys(String(ka), String(kb)));
|
|
1053
|
+
return order.map((sheet_id) => {
|
|
1054
|
+
const conds = bySheet.get(sheet_id);
|
|
1055
|
+
const rows = conditions.filter((c) => conds.has(c.id)).map((c) => {
|
|
1056
|
+
const a = conds.get(c.id);
|
|
1057
|
+
return {
|
|
1058
|
+
id: c.id,
|
|
1059
|
+
finish_tag: c.finish_tag,
|
|
1060
|
+
color: c.color,
|
|
1061
|
+
multiplier: c.multiplier || 1,
|
|
1062
|
+
shape_count: a.n,
|
|
1063
|
+
floor_sf: a.floor,
|
|
1064
|
+
wall_sf: a.wall,
|
|
1065
|
+
border_sf: a.border,
|
|
1066
|
+
lf: a.lf,
|
|
1067
|
+
ea: a.ea
|
|
1068
|
+
};
|
|
1069
|
+
});
|
|
1070
|
+
return { sheet_id, rows };
|
|
1071
|
+
}).filter((g) => g.rows.length);
|
|
1072
|
+
}
|
|
1073
|
+
function roundSheetRow(r) {
|
|
1074
|
+
return {
|
|
1075
|
+
...r,
|
|
1076
|
+
floor_sf: round22(r.floor_sf),
|
|
1077
|
+
wall_sf: round22(r.wall_sf),
|
|
1078
|
+
border_sf: round22(r.border_sf),
|
|
1079
|
+
lf: round22(r.lf),
|
|
1080
|
+
ea: round22(r.ea)
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
function materialsSummary(rows) {
|
|
1084
|
+
const map = /* @__PURE__ */ new Map();
|
|
1085
|
+
for (const r of rows) for (const m of r.materials || []) {
|
|
1086
|
+
const key = `${m.name}\0${m.unit}`;
|
|
1087
|
+
const cur = map.get(key) || { name: m.name, unit: m.unit, qty: 0 };
|
|
1088
|
+
cur.qty += m.qty;
|
|
1089
|
+
map.set(key, cur);
|
|
1090
|
+
}
|
|
1091
|
+
return [...map.values()].map((x) => ({ ...x, qty: round22(x.qty) }));
|
|
1092
|
+
}
|
|
980
1093
|
function grandTotals(rows) {
|
|
981
1094
|
const sum = (k) => rows.reduce((n, r) => n + (r[k] || 0), 0);
|
|
982
1095
|
return {
|
|
@@ -988,6 +1101,90 @@ function grandTotals(rows) {
|
|
|
988
1101
|
sy_net: round22(sum("sy_net"))
|
|
989
1102
|
};
|
|
990
1103
|
}
|
|
1104
|
+
function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [], markups = [], rfis = [], sheetLabel = null, conditionColumns = [], attrsByCond = null, shapeLabels = [], byLabel = [], displayUnits = "imperial" }) {
|
|
1105
|
+
const label = (id) => sheetLabel ? sheetLabel(id) : id;
|
|
1106
|
+
const colDefs = (Array.isArray(conditionColumns) ? conditionColumns : []).filter((cc) => cc && typeof cc === "object" && typeof cc.id === "string");
|
|
1107
|
+
const attrs = attrsByCond instanceof Map ? attrsByCond : /* @__PURE__ */ new Map();
|
|
1108
|
+
return {
|
|
1109
|
+
schema: "opentakeoff.report.v1",
|
|
1110
|
+
project_name: projectName || null,
|
|
1111
|
+
generated_with: "OpenTakeoff",
|
|
1112
|
+
sheets: scaleInfo.map((si) => ({ sheet_id: si.sheet_id, sheet: label(si.sheet_id), scale_source: si.scale_source ?? si.source ?? "unknown" })),
|
|
1113
|
+
// custom-column values APPEND after materials (row key order otherwise
|
|
1114
|
+
// untouched). Iterating the DEFINED columns — never raw attrs — naturally
|
|
1115
|
+
// drops orphaned colIds; attrValue (the shared assigned-value rule) keeps
|
|
1116
|
+
// corrupted and empty values out of the export.
|
|
1117
|
+
conditions: rows.map((r) => ({
|
|
1118
|
+
...r,
|
|
1119
|
+
columns: colDefs.flatMap((cc) => {
|
|
1120
|
+
const v = attrValue(attrs.get(r.id), cc.id);
|
|
1121
|
+
return v ? [{ id: cc.id, name: cc.name, value: v }] : [];
|
|
1122
|
+
})
|
|
1123
|
+
})),
|
|
1124
|
+
by_sheet: bySheet.map((gp) => ({
|
|
1125
|
+
sheet_id: gp.sheet_id,
|
|
1126
|
+
sheet: label(gp.sheet_id),
|
|
1127
|
+
rows: gp.rows.map(roundSheetRow)
|
|
1128
|
+
})),
|
|
1129
|
+
totals: grandTotals(rows),
|
|
1130
|
+
materials: materialsSummary(rows),
|
|
1131
|
+
// id + rfi_id APPEND after the original four keys (the additive-only v1
|
|
1132
|
+
// convention — see scale_source above): a cloud with empty text was fully
|
|
1133
|
+
// anonymous in the export. Legacy markups: id → null, rfi_id → "".
|
|
1134
|
+
// condition_id + condition APPEND again, same rule. condition is the
|
|
1135
|
+
// resolved finish_tag rather than only the id, so a reader of the export
|
|
1136
|
+
// can see WHICH scope an annotation is about without joining two arrays;
|
|
1137
|
+
// the id stays authoritative. Unattached markups: "" for both.
|
|
1138
|
+
markups: markups.map((m) => {
|
|
1139
|
+
const c = m.condition_id ? (rows || []).find((r) => r.id === m.condition_id) : null;
|
|
1140
|
+
return { type: m.type, sheet_id: m.sheet_id, sheet: label(m.sheet_id), text: m.text || "", id: m.id ?? null, rfi_id: m.rfi_id || "", condition_id: m.condition_id || "", condition: c?.finish_tag || "" };
|
|
1141
|
+
}),
|
|
1142
|
+
// rfis APPENDS after markups (additive-only v1 — old exports had no RFI
|
|
1143
|
+
// register). linked_markups/linked_sheets are DERIVED from markup.rfi_id,
|
|
1144
|
+
// never a second store of the link.
|
|
1145
|
+
rfis: (rfis || []).map((r) => {
|
|
1146
|
+
const linked = (markups || []).filter((m) => m.rfi_id === r.id);
|
|
1147
|
+
return {
|
|
1148
|
+
id: r.id ?? null,
|
|
1149
|
+
number: r.number || "",
|
|
1150
|
+
subject: r.subject || "",
|
|
1151
|
+
question: r.question || "",
|
|
1152
|
+
status: r.status || "open",
|
|
1153
|
+
to: r.to || "",
|
|
1154
|
+
priority: r.priority || "",
|
|
1155
|
+
cost_impact: !!r.cost_impact,
|
|
1156
|
+
schedule_impact: !!r.schedule_impact,
|
|
1157
|
+
date: r.date || "",
|
|
1158
|
+
response: r.response || "",
|
|
1159
|
+
response_date: r.response_date || "",
|
|
1160
|
+
sheet_id: r.sheet_id ?? null,
|
|
1161
|
+
sheet: r.sheet_id != null ? label(r.sheet_id) : null,
|
|
1162
|
+
linked_markups: linked.length,
|
|
1163
|
+
linked_sheets: [...new Set(linked.map((m) => label(m.sheet_id)))]
|
|
1164
|
+
};
|
|
1165
|
+
}),
|
|
1166
|
+
// the custom-column definitions themselves, so row `columns` values can be
|
|
1167
|
+
// read against the project vocabulary
|
|
1168
|
+
condition_columns: colDefs.map(({ id, name, values }) => ({ id, name, values: Array.isArray(values) ? values : [] })),
|
|
1169
|
+
// shape-level phase/area labels (#112) APPEND after condition_columns and
|
|
1170
|
+
// are always emitted (empty when unused) per the additive-only v1 rule:
|
|
1171
|
+
// shape_labels is the project vocabulary; by_label is the group-by-label
|
|
1172
|
+
// breakdown — ORDERED per-bucket quantities (waste/×N applied), matching the
|
|
1173
|
+
// report's interactive view, unlike the BASE by_sheet reference above.
|
|
1174
|
+
shape_labels: (Array.isArray(shapeLabels) ? shapeLabels : []).filter((v) => typeof v === "string" && v.trim()),
|
|
1175
|
+
by_label: (Array.isArray(byLabel) ? byLabel : []).map((gp) => ({
|
|
1176
|
+
label: gp.value ?? null,
|
|
1177
|
+
// null = Unlabeled
|
|
1178
|
+
rows: (gp.rows || []).map((r) => ({ id: r.id, finish_tag: r.finish_tag, floor_sf: r.floor_sf, wall_sf: r.wall_sf, border_sf: r.border_sf, lf: r.lf, ea: r.ea, total_sf: r.total_sf, total_sf_net: r.total_sf_net }))
|
|
1179
|
+
})),
|
|
1180
|
+
// units metadata APPENDS last (additive-only v1): every quantity above is
|
|
1181
|
+
// RAW internal feet (SF/LF/SY keys, uninterpretable otherwise); this is
|
|
1182
|
+
// the display system the exporting user was reading — the units port's
|
|
1183
|
+
// "JSON stays raw, but says so" contract.
|
|
1184
|
+
units: "imperial (SF/LF \u2014 raw internal values)",
|
|
1185
|
+
display_units: displayUnits === "metric" ? "metric" : "imperial"
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
991
1188
|
|
|
992
1189
|
// src/view.ts
|
|
993
1190
|
var INK = "#d91a1a";
|
|
@@ -1292,13 +1489,13 @@ var Session = class {
|
|
|
1292
1489
|
const x1 = geo.segs[i * 4], y1 = geo.segs[i * 4 + 1], x2 = geo.segs[i * 4 + 2], y2 = geo.segs[i * 4 + 3];
|
|
1293
1490
|
if (segIntersectsRect(x1, y1, x2, y2, r)) inRegion.push({ i, len: Math.hypot(x2 - x1, y2 - y1) });
|
|
1294
1491
|
}
|
|
1295
|
-
const
|
|
1296
|
-
const droppedShort = inRegion.length -
|
|
1297
|
-
let kept =
|
|
1492
|
+
const visible2 = inRegion.filter((e) => e.len >= minLen);
|
|
1493
|
+
const droppedShort = inRegion.length - visible2.length;
|
|
1494
|
+
let kept = visible2;
|
|
1298
1495
|
let droppedCap = 0;
|
|
1299
|
-
if (
|
|
1300
|
-
kept =
|
|
1301
|
-
droppedCap =
|
|
1496
|
+
if (visible2.length > cap) {
|
|
1497
|
+
kept = visible2.slice().sort((a, b) => b.len - a.len).slice(0, cap);
|
|
1498
|
+
droppedCap = visible2.length - cap;
|
|
1302
1499
|
}
|
|
1303
1500
|
const segments = [], metaOut = [], family = [];
|
|
1304
1501
|
for (const { i } of kept) {
|
|
@@ -1402,6 +1599,7 @@ var Session = class {
|
|
|
1402
1599
|
throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
|
|
1403
1600
|
}
|
|
1404
1601
|
s.upp = upp;
|
|
1602
|
+
s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
|
|
1405
1603
|
return { sheet: s.key, upp, ...label ? { label } : {}, source };
|
|
1406
1604
|
}
|
|
1407
1605
|
conditionFor(tag) {
|
|
@@ -1452,6 +1650,7 @@ var Session = class {
|
|
|
1452
1650
|
status: "ok",
|
|
1453
1651
|
nverts: ring.length,
|
|
1454
1652
|
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
1653
|
+
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
1455
1654
|
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
1456
1655
|
};
|
|
1457
1656
|
if (s.upp == null) {
|
|
@@ -1473,6 +1672,7 @@ var Session = class {
|
|
|
1473
1672
|
seed_norm: [x / s.widthPx, y / s.heightPx],
|
|
1474
1673
|
reviewed: false,
|
|
1475
1674
|
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
1675
|
+
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
1476
1676
|
// canvas-parity provenance: a non-default fill sensitivity is part of
|
|
1477
1677
|
// how the shape was made (ShapeOrigin.fill_sensitivity)
|
|
1478
1678
|
...opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
|
|
@@ -1526,7 +1726,7 @@ var Session = class {
|
|
|
1526
1726
|
const byRing = /* @__PURE__ */ new Map();
|
|
1527
1727
|
const order = [];
|
|
1528
1728
|
for (const lb of labels) {
|
|
1529
|
-
let ring = null, hatch = false, seed = null;
|
|
1729
|
+
let ring = null, hatch = false, gap = 0, seed = null;
|
|
1530
1730
|
let sawBubble = false, sawDegenerate = false;
|
|
1531
1731
|
for (const probe of seedLadderPx(lb.bbox)) {
|
|
1532
1732
|
const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
|
|
@@ -1542,6 +1742,7 @@ var Session = class {
|
|
|
1542
1742
|
}
|
|
1543
1743
|
ring = r;
|
|
1544
1744
|
hatch = !!f.hatchFiltered;
|
|
1745
|
+
gap = f.gapBridged || 0;
|
|
1545
1746
|
seed = probe;
|
|
1546
1747
|
break;
|
|
1547
1748
|
}
|
|
@@ -1564,6 +1765,7 @@ var Session = class {
|
|
|
1564
1765
|
perimPx: closedMetrics(ring).perim,
|
|
1565
1766
|
seed,
|
|
1566
1767
|
hatch,
|
|
1768
|
+
gap,
|
|
1567
1769
|
merged: []
|
|
1568
1770
|
};
|
|
1569
1771
|
byRing.set(key, cand);
|
|
@@ -1576,6 +1778,7 @@ var Session = class {
|
|
|
1576
1778
|
nverts: c.ring.length,
|
|
1577
1779
|
...c.merged.length ? { merged_labels: c.merged } : {},
|
|
1578
1780
|
...c.hatch ? { hatch_filtered: true } : {},
|
|
1781
|
+
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
1579
1782
|
...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
1580
1783
|
};
|
|
1581
1784
|
if (upp == null) {
|
|
@@ -1594,7 +1797,8 @@ var Session = class {
|
|
|
1594
1797
|
actor: "agent",
|
|
1595
1798
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
1596
1799
|
reviewed: false,
|
|
1597
|
-
...c.hatch ? { hatch_filtered: true } : {}
|
|
1800
|
+
...c.hatch ? { hatch_filtered: true } : {},
|
|
1801
|
+
...c.gap ? { gap_bridged_px: c.gap } : {}
|
|
1598
1802
|
}).id;
|
|
1599
1803
|
}
|
|
1600
1804
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
@@ -1771,6 +1975,30 @@ var Session = class {
|
|
|
1771
1975
|
materials: c.materials
|
|
1772
1976
|
};
|
|
1773
1977
|
}
|
|
1978
|
+
/** Set a condition's quantity knobs — waste % and multiplier. Both are
|
|
1979
|
+
* emitted by takeoff_summary (`waste_pct`, the `*_net` order quantities) and
|
|
1980
|
+
* carried by every export, but nothing in the tool surface could set them,
|
|
1981
|
+
* so an agent's takeoff always shipped net === gross (#131). Same class as
|
|
1982
|
+
* editMaterials — quantity config, not traced geometry, so no review gate —
|
|
1983
|
+
* but resolve-or-error rather than mint-on-first-touch: these knobs only
|
|
1984
|
+
* mean anything on a condition that exists, and a typo'd tag must error,
|
|
1985
|
+
* not create an empty condition as a side effect. One journal entry
|
|
1986
|
+
* snapshots both knobs; undo restores them verbatim. */
|
|
1987
|
+
editCondition(tag, opts) {
|
|
1988
|
+
if (opts.waste_pct === void 0 && opts.multiplier === void 0) {
|
|
1989
|
+
throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier.");
|
|
1990
|
+
}
|
|
1991
|
+
const c = this.conditions.find((x) => x.finish_tag === tag);
|
|
1992
|
+
if (!c) {
|
|
1993
|
+
const known = this.conditions.map((x) => x.finish_tag);
|
|
1994
|
+
throw new UserError(`No condition ${JSON.stringify(tag)}.${known.length ? ` Known tags: ${known.join(", ")}.` : " Nothing has minted a condition yet \u2014 commit a measurement or add materials first."}`);
|
|
1995
|
+
}
|
|
1996
|
+
const before = { waste_pct: c.waste_pct, multiplier: c.multiplier };
|
|
1997
|
+
if (opts.waste_pct !== void 0) c.waste_pct = opts.waste_pct;
|
|
1998
|
+
if (opts.multiplier !== void 0) c.multiplier = opts.multiplier;
|
|
1999
|
+
this.record({ op: "condition", tool: "edit_condition", condition_id: c.id, before });
|
|
2000
|
+
return { condition: tag, condition_id: c.id, waste_pct: c.waste_pct, multiplier: c.multiplier };
|
|
2001
|
+
}
|
|
1774
2002
|
/** Step back over this session's own last n mutations, newest first. Each
|
|
1775
2003
|
* entry's inverse is exact (see JournalEntry), so this restores state rather
|
|
1776
2004
|
* than approximating it. Reads are not journaled, so undo never has to step
|
|
@@ -1792,6 +2020,13 @@ var Session = class {
|
|
|
1792
2020
|
const c = this.conditions.find((x) => x.id === e.condition_id);
|
|
1793
2021
|
if (c) c.materials = e.before;
|
|
1794
2022
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
2023
|
+
} else if (e.op === "condition") {
|
|
2024
|
+
const c = this.conditions.find((x) => x.id === e.condition_id);
|
|
2025
|
+
if (c) {
|
|
2026
|
+
c.waste_pct = e.before.waste_pct;
|
|
2027
|
+
c.multiplier = e.before.multiplier;
|
|
2028
|
+
}
|
|
2029
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
1795
2030
|
} else {
|
|
1796
2031
|
for (const { shape, index } of e.removed) {
|
|
1797
2032
|
this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
|
|
@@ -1914,6 +2149,25 @@ var Session = class {
|
|
|
1914
2149
|
sheet_levels: {}
|
|
1915
2150
|
};
|
|
1916
2151
|
}
|
|
2152
|
+
/** The computed Report document — "opentakeoff.report.v1", the SAME schema
|
|
2153
|
+
* and math as the canvas Report's JSON export (web reportJson, totals.js):
|
|
2154
|
+
* per-condition quantities with waste and multiplier applied, the computed
|
|
2155
|
+
* materials buy list, per-sheet BASE subtotals, scale provenance. This is
|
|
2156
|
+
* the contract a pricing consumer reads (#130) — export_takeoff carries
|
|
2157
|
+
* materials as CONFIG rows and takeoff_summary strips them; only this
|
|
2158
|
+
* document carries the computed order quantities. */
|
|
2159
|
+
exportReport() {
|
|
2160
|
+
if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
2161
|
+
const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
|
|
2162
|
+
return reportJson({
|
|
2163
|
+
projectName: "",
|
|
2164
|
+
rows,
|
|
2165
|
+
bySheet: sheetTotals(this.conditions, this.shapes),
|
|
2166
|
+
scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown" })),
|
|
2167
|
+
markups: this.markups,
|
|
2168
|
+
rfis: []
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
1917
2171
|
readSheetText(name, region) {
|
|
1918
2172
|
const s = this.sheet(name);
|
|
1919
2173
|
const items = region ? s.text.filter((t) => t.x >= region.x0 && t.x <= region.x1 && t.y >= region.y0 && t.y <= region.y1) : s.text;
|
|
@@ -2004,6 +2258,7 @@ var oneClickOutput = {
|
|
|
2004
2258
|
status: z.literal("ok"),
|
|
2005
2259
|
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
2006
2260
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
2261
|
+
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region \u2014 the rescue rides provenance (origin.gap_bridged_px) rather than passing as a clean fill"),
|
|
2007
2262
|
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
2008
2263
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
2009
2264
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
@@ -2017,6 +2272,7 @@ var detectedRoom = z.object({
|
|
|
2017
2272
|
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
2018
2273
|
merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
|
|
2019
2274
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
2275
|
+
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region"),
|
|
2020
2276
|
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
2021
2277
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
2022
2278
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
@@ -2126,9 +2382,9 @@ var undoLastOutput = {
|
|
|
2126
2382
|
undone: z.number().int().describe("Steps actually reversed"),
|
|
2127
2383
|
steps: z.array(z.object({
|
|
2128
2384
|
seq: z.number().int(),
|
|
2129
|
-
op: z.enum(["commit", "edit", "delete", "materials"]),
|
|
2385
|
+
op: z.enum(["commit", "edit", "delete", "materials", "condition"]),
|
|
2130
2386
|
tool: z.string().describe("The tool call this step came from"),
|
|
2131
|
-
shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes)")
|
|
2387
|
+
shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes) and for a condition step (it restores the waste/multiplier pair)")
|
|
2132
2388
|
})).describe("Newest first"),
|
|
2133
2389
|
shape_count: z.number().int().describe("Committed shapes after the undo"),
|
|
2134
2390
|
remaining: z.number().int().describe("Steps still available to undo"),
|
|
@@ -2164,6 +2420,45 @@ var editMaterialsOutput = {
|
|
|
2164
2420
|
}),
|
|
2165
2421
|
materials: z.array(materialRow).describe("The condition's full materials array after this write")
|
|
2166
2422
|
};
|
|
2423
|
+
var reportMaterialLine = z.object({
|
|
2424
|
+
name: z.string(),
|
|
2425
|
+
unit: z.string().describe("Purchase unit, e.g. 'gal', 'bag'"),
|
|
2426
|
+
per: z.number().describe("Coverage rate \u2014 basis units per purchase unit"),
|
|
2427
|
+
basis: z.enum(["area", "linear", "count"]),
|
|
2428
|
+
round: z.boolean(),
|
|
2429
|
+
basis_qty: z.number().describe("The condition total this row divides (SF, LF, or EA \u2014 multiplier applied, waste not)"),
|
|
2430
|
+
qty: z.number().describe("Computed order quantity")
|
|
2431
|
+
}).passthrough();
|
|
2432
|
+
var exportReportOutput = {
|
|
2433
|
+
schema: z.literal("opentakeoff.report.v1"),
|
|
2434
|
+
project_name: z.string().nullable(),
|
|
2435
|
+
generated_with: z.string(),
|
|
2436
|
+
sheets: z.array(z.object({ sheet_id: z.string(), sheet: z.string(), scale_source: z.string() }).passthrough()).describe("Scale provenance per sheet \u2014 how each scale was set"),
|
|
2437
|
+
conditions: z.array(summaryRow.extend({ materials: z.array(reportMaterialLine) }).passthrough()).describe("conditionTotals rows: gross + *_net quantities AND the computed materials buy list"),
|
|
2438
|
+
by_sheet: z.array(z.object({ sheet_id: z.string(), sheet: z.string(), rows: z.array(z.record(z.unknown())) }).passthrough()).describe("BASE per-sheet subtotals \u2014 multiplier NOT applied, no waste, no materials"),
|
|
2439
|
+
totals: z.object({
|
|
2440
|
+
total_sf: z.number(),
|
|
2441
|
+
total_sf_net: z.number(),
|
|
2442
|
+
lf: z.number(),
|
|
2443
|
+
lf_net: z.number(),
|
|
2444
|
+
ea: z.number(),
|
|
2445
|
+
sy_net: z.number()
|
|
2446
|
+
}).passthrough(),
|
|
2447
|
+
materials: z.array(z.object({ name: z.string(), unit: z.string(), qty: z.number() }).passthrough()).describe("Project-wide buy list \u2014 condition rows summed by (name, unit)"),
|
|
2448
|
+
markups: z.array(z.record(z.unknown())),
|
|
2449
|
+
rfis: z.array(z.record(z.unknown())),
|
|
2450
|
+
condition_columns: z.array(z.record(z.unknown())),
|
|
2451
|
+
shape_labels: z.array(z.string()),
|
|
2452
|
+
by_label: z.array(z.record(z.unknown())),
|
|
2453
|
+
units: z.string(),
|
|
2454
|
+
display_units: z.string()
|
|
2455
|
+
};
|
|
2456
|
+
var editConditionOutput = {
|
|
2457
|
+
condition: z.string().describe("The finish tag passed in"),
|
|
2458
|
+
condition_id: z.string(),
|
|
2459
|
+
waste_pct: z.number().describe("The condition's waste % after this write"),
|
|
2460
|
+
multiplier: z.number().describe("The condition's quantity multiplier after this write")
|
|
2461
|
+
};
|
|
2167
2462
|
var readSheetTextOutput = {
|
|
2168
2463
|
sheet: z.string(),
|
|
2169
2464
|
items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
|
|
@@ -2342,6 +2637,18 @@ function registerTools(server, session) {
|
|
|
2342
2637
|
}
|
|
2343
2638
|
return payload;
|
|
2344
2639
|
}));
|
|
2640
|
+
server.registerTool("export_report", {
|
|
2641
|
+
description: `The computed Report document \u2014 "opentakeoff.report.v1", the same schema the canvas Report's JSON export writes. Everything a pricing consumer needs without re-implementing the app's math: per-condition quantities with waste and multiplier applied (gross and *_net), the computed materials BUY LIST per condition (order quantity = basis \xF7 coverage rate, rounded up to whole purchase units) plus the project-wide roll-up summed by (name, unit), per-sheet BASE subtotals, scale provenance per sheet, and annotations. Contrast: export_takeoff is the raw canvas payload (materials as CONFIG rows, no computed quantities) and takeoff_summary strips materials for a compact reply \u2014 when the numbers are leaving for pricing, consume this. Returned inline; pass path to also write it to disk as JSON.`,
|
|
2642
|
+
inputSchema: { path: z2.string().optional().describe("File path to write the document to") },
|
|
2643
|
+
outputSchema: exportReportOutput
|
|
2644
|
+
}, run("export_report", async ({ path: outPath }) => {
|
|
2645
|
+
const doc = session.exportReport();
|
|
2646
|
+
if (outPath) {
|
|
2647
|
+
const { writeFile } = await import("node:fs/promises");
|
|
2648
|
+
await writeFile(outPath, JSON.stringify(doc));
|
|
2649
|
+
}
|
|
2650
|
+
return doc;
|
|
2651
|
+
}));
|
|
2345
2652
|
server.registerTool("delete_shape", {
|
|
2346
2653
|
description: `Remove a committed shape by the id returned when it was committed. ${COORDS}`,
|
|
2347
2654
|
inputSchema: { shape_id: z2.string() },
|
|
@@ -2387,8 +2694,17 @@ function registerTools(server, session) {
|
|
|
2387
2694
|
},
|
|
2388
2695
|
outputSchema: editMaterialsOutput
|
|
2389
2696
|
}, run("edit_materials", (a) => session.editMaterials(a.condition, { add: a.add, remove: a.remove, patch: a.patch })));
|
|
2697
|
+
server.registerTool("edit_condition", {
|
|
2698
|
+
description: `Set a condition's quantity knobs \u2014 waste % and/or multiplier. takeoff_summary emits waste-adjusted *_net order quantities and a per-condition multiplier, and every export carries both, but conditions minted through the measure tools start at waste 0 / multiplier 1 \u2014 without this tool an agent's takeoff always ships net === gross (#131). waste_pct is the estimator's cut-waste percentage (carpet commonly 5\u201310); multiplier scales every quantity on the condition (\xD7N identical floors \u2014 takeoff_summary applies it before waste). condition must resolve to an EXISTING finish tag \u2014 a typo'd tag errors rather than minting an empty condition (the edit_materials remove/patch rule, not its add rule: these knobs mean nothing on a condition that doesn't exist yet). No review gate \u2014 quantity config, not traced geometry; undo_last reverses a call in one step (both knobs snapshotted together, restored verbatim).`,
|
|
2699
|
+
inputSchema: {
|
|
2700
|
+
condition: z2.string().describe("Finish tag of an existing condition, e.g. 'CPT-1'"),
|
|
2701
|
+
waste_pct: z2.number().min(0).optional().describe("Waste percentage applied to net order quantities, e.g. 10 for 10%"),
|
|
2702
|
+
multiplier: z2.number().positive().optional().describe("Quantity multiplier (\xD7N identical areas). Note: the canvas treats 0 as 1, so 0 is rejected here rather than silently meaning 'off'")
|
|
2703
|
+
},
|
|
2704
|
+
outputSchema: editConditionOutput
|
|
2705
|
+
}, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier })));
|
|
2390
2706
|
server.registerTool("undo_last", {
|
|
2391
|
-
description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, or an
|
|
2707
|
+
description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, an edit_materials call, or an edit_condition call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
|
|
2392
2708
|
inputSchema: {
|
|
2393
2709
|
n: z2.number().int().min(1).max(UNDO_CAP).default(1).describe(`How many steps to reverse (1\u2013${UNDO_CAP})`)
|
|
2394
2710
|
},
|
|
@@ -2547,7 +2863,7 @@ function registerResources(server, session) {
|
|
|
2547
2863
|
// package.json
|
|
2548
2864
|
var package_default = {
|
|
2549
2865
|
name: "opentakeoff-mcp",
|
|
2550
|
-
version: "0.9.
|
|
2866
|
+
version: "0.9.3",
|
|
2551
2867
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
2552
2868
|
type: "module",
|
|
2553
2869
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED