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 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();
@@ -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 _idn = 0;
888
- var uid = (p) => `${p}-${Date.now().toString(36)}-${(_idn++).toString(36)}`;
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: true,
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 for one-click traces")
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.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")
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.3.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.0",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
- "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
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",