opentakeoff-mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/server-core.js +107 -9
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
108
108
|
| `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
|
|
109
109
|
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
|
|
110
110
|
| `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
|
|
111
|
+
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; a leaked/dense-linework label is silently withheld. Pass `condition` to commit every detected room. |
|
|
111
112
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
112
113
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
113
114
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
package/dist/server-core.js
CHANGED
|
@@ -740,6 +740,27 @@ function ringArea(pts) {
|
|
|
740
740
|
return Math.abs(a) / 2;
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
// ../web/src/lib/detectRooms.ts
|
|
744
|
+
var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
|
|
745
|
+
function roomLabelSeeds(items) {
|
|
746
|
+
const out = [];
|
|
747
|
+
for (const it of items) {
|
|
748
|
+
const num = (it.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
|
|
749
|
+
if (!num) continue;
|
|
750
|
+
out.push({ str: num, seed: [it.x, it.y] });
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
function detectRegions(maskObj, seeds, sensitivity = SENS_BALANCED) {
|
|
755
|
+
const out = [];
|
|
756
|
+
for (const s of seeds) {
|
|
757
|
+
const f = floodRegion(maskObj, s.seed[0], s.seed[1], sensitivity);
|
|
758
|
+
if (f.status !== "ok") continue;
|
|
759
|
+
out.push({ str: s.str, seed: s.seed, flood: f });
|
|
760
|
+
}
|
|
761
|
+
return out;
|
|
762
|
+
}
|
|
763
|
+
|
|
743
764
|
// ../web/src/lib/geometry.js
|
|
744
765
|
function buildSnapGrid(points, cell) {
|
|
745
766
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -884,8 +905,8 @@ var SNAP_CELL = 24;
|
|
|
884
905
|
var SNAP_TOL = 7;
|
|
885
906
|
var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
|
|
886
907
|
var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "fleur", "speckle"];
|
|
887
|
-
var
|
|
888
|
-
var uid = (p) => `${p}-${
|
|
908
|
+
var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
909
|
+
var uid = (p) => `${p}-${mintUuid()}`;
|
|
889
910
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
890
911
|
var IMAGE_MAX_EDGE = 1568;
|
|
891
912
|
var sheetSummary = (s) => ({
|
|
@@ -1115,13 +1136,64 @@ var Session = class {
|
|
|
1115
1136
|
if (opts.condition) {
|
|
1116
1137
|
shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
|
|
1117
1138
|
method: "one_click_v1",
|
|
1139
|
+
actor: "agent",
|
|
1118
1140
|
seed_norm: [x / s.widthPx, y / s.heightPx],
|
|
1119
|
-
reviewed:
|
|
1141
|
+
reviewed: false,
|
|
1120
1142
|
...f.hatchFiltered ? { hatch_filtered: true } : {}
|
|
1121
1143
|
}).id;
|
|
1122
1144
|
}
|
|
1123
1145
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
1124
1146
|
}
|
|
1147
|
+
/** Batch room detection: read every room-number label off the sheet's text
|
|
1148
|
+
* layer, seed the existing One-Click flood at each, and trace/commit
|
|
1149
|
+
* exactly like oneClick — just N of them from one call instead of N
|
|
1150
|
+
* reasoning-heavy round-trips. Same contract as oneClick: no scale → a
|
|
1151
|
+
* px-only preview per room; no condition → nothing commits (a review
|
|
1152
|
+
* pass, not a proposal-acceptance gate — this server has none). A region
|
|
1153
|
+
* that traces to a degenerate ring (<3 verts) is dropped from the batch
|
|
1154
|
+
* rather than failing the whole call — one bad label must not sink every
|
|
1155
|
+
* other clean detection on the sheet. */
|
|
1156
|
+
async detectRooms(name, opts) {
|
|
1157
|
+
const s = this.sheet(name);
|
|
1158
|
+
const mask = await this.ensureMask(name);
|
|
1159
|
+
if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
|
|
1160
|
+
const seeds = roomLabelSeeds(s.text);
|
|
1161
|
+
const regions = detectRegions(mask, seeds);
|
|
1162
|
+
const rooms = regions.map((r) => {
|
|
1163
|
+
const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
1164
|
+
if (ring.length < 3) return null;
|
|
1165
|
+
const areaPx2 = ringArea(ring);
|
|
1166
|
+
const perimPx = closedMetrics(ring).perim;
|
|
1167
|
+
const common = {
|
|
1168
|
+
label: r.str,
|
|
1169
|
+
nverts: ring.length,
|
|
1170
|
+
...r.flood.hatchFiltered ? { hatch_filtered: true } : {},
|
|
1171
|
+
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
1172
|
+
};
|
|
1173
|
+
if (s.upp == null) {
|
|
1174
|
+
return { ...common, area_px2: round1(areaPx2), perimeter_px: round1(perimPx) };
|
|
1175
|
+
}
|
|
1176
|
+
const upp = s.upp;
|
|
1177
|
+
const area_sf = round2(areaPx2 * upp * upp);
|
|
1178
|
+
const perimeter_lf = round2(perimPx * upp);
|
|
1179
|
+
let shape_id;
|
|
1180
|
+
if (opts.condition) {
|
|
1181
|
+
shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
|
|
1182
|
+
method: "one_click_v1",
|
|
1183
|
+
actor: "agent",
|
|
1184
|
+
seed_norm: [r.seed[0] / s.widthPx, r.seed[1] / s.heightPx],
|
|
1185
|
+
reviewed: false,
|
|
1186
|
+
...r.flood.hatchFiltered ? { hatch_filtered: true } : {}
|
|
1187
|
+
}).id;
|
|
1188
|
+
}
|
|
1189
|
+
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
1190
|
+
}).filter((r) => r !== null);
|
|
1191
|
+
return {
|
|
1192
|
+
detected: rooms.length,
|
|
1193
|
+
rooms,
|
|
1194
|
+
...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1125
1197
|
measurePolygon(name, verts, opts) {
|
|
1126
1198
|
const s = this.sheet(name);
|
|
1127
1199
|
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
@@ -1129,7 +1201,7 @@ var Session = class {
|
|
|
1129
1201
|
const area_sf = round2(met.area * s.upp * s.upp);
|
|
1130
1202
|
const perimeter_lf = round2(met.perim * s.upp);
|
|
1131
1203
|
let shape_id;
|
|
1132
|
-
if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }).id;
|
|
1204
|
+
if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }, { method: "manual", actor: "agent" }).id;
|
|
1133
1205
|
return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {} };
|
|
1134
1206
|
}
|
|
1135
1207
|
measureLine(name, pts, opts) {
|
|
@@ -1137,7 +1209,7 @@ var Session = class {
|
|
|
1137
1209
|
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
1138
1210
|
const length_lf = round2(openLen(pts) * s.upp);
|
|
1139
1211
|
let shape_id;
|
|
1140
|
-
if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", pts, { area_sf: 0, perimeter_lf: length_lf }).id;
|
|
1212
|
+
if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", pts, { area_sf: 0, perimeter_lf: length_lf }, { method: "manual", actor: "agent" }).id;
|
|
1141
1213
|
return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
|
|
1142
1214
|
}
|
|
1143
1215
|
summary() {
|
|
@@ -1243,6 +1315,22 @@ var oneClickOutput = {
|
|
|
1243
1315
|
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px"),
|
|
1244
1316
|
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
1245
1317
|
};
|
|
1318
|
+
var detectedRoom = z.object({
|
|
1319
|
+
label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
|
|
1320
|
+
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
1321
|
+
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
1322
|
+
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
1323
|
+
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
1324
|
+
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
1325
|
+
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
1326
|
+
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
1327
|
+
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
|
|
1328
|
+
});
|
|
1329
|
+
var detectRoomsOutput = {
|
|
1330
|
+
detected: z.number().int().describe("Count of cleanly-detected rooms \u2014 may be fewer than the labels found on the sheet"),
|
|
1331
|
+
rooms: z.array(detectedRoom),
|
|
1332
|
+
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
1333
|
+
};
|
|
1246
1334
|
var measurePolygonOutput = {
|
|
1247
1335
|
area_sf: z.number(),
|
|
1248
1336
|
perimeter_lf: z.number(),
|
|
@@ -1306,7 +1394,7 @@ var exportTakeoffOutput = {
|
|
|
1306
1394
|
measure_role: z.enum(["floor_area", "deduct", "linear"]),
|
|
1307
1395
|
verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
|
|
1308
1396
|
computed: z.object({ area_sf: z.number(), perimeter_lf: z.number() }).passthrough(),
|
|
1309
|
-
origin: z.object({}).passthrough().optional().describe("Provenance
|
|
1397
|
+
origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
|
|
1310
1398
|
}).passthrough()),
|
|
1311
1399
|
markups: z.array(z.unknown()),
|
|
1312
1400
|
sheet_group: z.array(z.unknown()),
|
|
@@ -1361,7 +1449,7 @@ function registerTools(server, session) {
|
|
|
1361
1449
|
label: z2.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
|
|
1362
1450
|
upp: z2.number().optional().describe("Real feet per image px at render scale 2.0"),
|
|
1363
1451
|
calibrate: z2.object({ p1: pointSchema, p2: pointSchema, feet: z2.number() }).optional().describe("Two points (image px) a known real distance apart, and that distance in feet"),
|
|
1364
|
-
use_detected: z2.
|
|
1452
|
+
use_detected: z2.literal(true).optional().describe("true = adopt the sheet's detected scale")
|
|
1365
1453
|
},
|
|
1366
1454
|
outputSchema: setScaleOutput
|
|
1367
1455
|
}, run("set_scale", (a) => {
|
|
@@ -1381,6 +1469,16 @@ function registerTools(server, session) {
|
|
|
1381
1469
|
},
|
|
1382
1470
|
outputSchema: oneClickOutput
|
|
1383
1471
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1472
|
+
server.registerTool("detect_rooms", {
|
|
1473
|
+
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. Only cleanly-traced rooms are returned; a label that leaked or landed in dense linework is silently withheld, never reported as a bad room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing. ${COORDS}`,
|
|
1474
|
+
inputSchema: {
|
|
1475
|
+
sheet: z2.string(),
|
|
1476
|
+
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
|
|
1477
|
+
role: roleSchema,
|
|
1478
|
+
return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)")
|
|
1479
|
+
},
|
|
1480
|
+
outputSchema: detectRoomsOutput
|
|
1481
|
+
}, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1384
1482
|
server.registerTool("measure_polygon", {
|
|
1385
1483
|
description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
|
|
1386
1484
|
inputSchema: {
|
|
@@ -1511,7 +1609,7 @@ function registerResources(server, session) {
|
|
|
1511
1609
|
// package.json
|
|
1512
1610
|
var package_default = {
|
|
1513
1611
|
name: "opentakeoff-mcp",
|
|
1514
|
-
version: "0.
|
|
1612
|
+
version: "0.5.0",
|
|
1515
1613
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
1516
1614
|
type: "module",
|
|
1517
1615
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -1527,7 +1625,7 @@ var package_default = {
|
|
|
1527
1625
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
1528
1626
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
1529
1627
|
typecheck: "tsc --noEmit",
|
|
1530
|
-
test: "node --import tsx --test test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
1628
|
+
test: "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
1531
1629
|
},
|
|
1532
1630
|
dependencies: {
|
|
1533
1631
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "OpenTakeoff MCP server
|
|
6
|
+
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "node --import tsx --test test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
19
|
+
"test": "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|