opentakeoff-mcp 0.4.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 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. |
@@ -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();
@@ -1123,6 +1144,56 @@ var Session = class {
1123
1144
  }
1124
1145
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1125
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
+ }
1126
1197
  measurePolygon(name, verts, opts) {
1127
1198
  const s = this.sheet(name);
1128
1199
  if (s.upp == null) throw new UserError(this.scaleGate(s));
@@ -1244,6 +1315,22 @@ var oneClickOutput = {
1244
1315
  perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px"),
1245
1316
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
1246
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
+ };
1247
1334
  var measurePolygonOutput = {
1248
1335
  area_sf: z.number(),
1249
1336
  perimeter_lf: z.number(),
@@ -1362,7 +1449,7 @@ function registerTools(server, session) {
1362
1449
  label: z2.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
1363
1450
  upp: z2.number().optional().describe("Real feet per image px at render scale 2.0"),
1364
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"),
1365
- use_detected: z2.boolean().optional().describe("true = adopt the sheet's detected scale")
1452
+ use_detected: z2.literal(true).optional().describe("true = adopt the sheet's detected scale")
1366
1453
  },
1367
1454
  outputSchema: setScaleOutput
1368
1455
  }, run("set_scale", (a) => {
@@ -1382,6 +1469,16 @@ function registerTools(server, session) {
1382
1469
  },
1383
1470
  outputSchema: oneClickOutput
1384
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 })));
1385
1482
  server.registerTool("measure_polygon", {
1386
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}`,
1387
1484
  inputSchema: {
@@ -1512,7 +1609,7 @@ function registerResources(server, session) {
1512
1609
  // package.json
1513
1610
  var package_default = {
1514
1611
  name: "opentakeoff-mcp",
1515
- version: "0.4.0",
1612
+ version: "0.5.0",
1516
1613
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1517
1614
  type: "module",
1518
1615
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -1528,7 +1625,7 @@ var package_default = {
1528
1625
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
1529
1626
  prepublishOnly: "npm run typecheck && npm test && npm run build",
1530
1627
  typecheck: "tsc --noEmit",
1531
- 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"
1532
1629
  },
1533
1630
  dependencies: {
1534
1631
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -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",