opentakeoff-mcp 0.6.0 → 0.6.1

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.
Files changed (2) hide show
  1. package/dist/server-core.js +87 -24
  2. package/package.json +1 -1
@@ -1293,48 +1293,101 @@ var Session = class {
1293
1293
  * exactly like oneClick — just N of them from one call instead of N
1294
1294
  * reasoning-heavy round-trips. Same contract as oneClick: no scale → a
1295
1295
  * px-only preview per room; no condition → nothing commits (a review
1296
- * pass, not a proposal-acceptance gate — this server has none). A region
1297
- * that traces to a degenerate ring (<3 verts) is dropped from the batch
1298
- * rather than failing the whole call one bad label must not sink every
1299
- * other clean detection on the sheet. */
1296
+ * pass, not a proposal-acceptance gate — this server has none).
1297
+ *
1298
+ * Withholding nothing is committed until it survives all three, and the
1299
+ * batch NEVER silently drops work: every withheld seed is counted and
1300
+ * reasoned in `withheld`, because a room the tool knows it skipped is a
1301
+ * question the caller can ask, while a room it skipped silently is a hole
1302
+ * in a bid.
1303
+ * 1. degenerate — traced to fewer than 3 vertices.
1304
+ * 2. duplicate — two labels flooding one region (a room tagged twice, or
1305
+ * a legend number landing in the same space) trace to an identical
1306
+ * ring. Committing both double-counts the area with no signal, which
1307
+ * is the worst failure mode an estimating tool has. One region commits
1308
+ * once; the collapsed labels ride along on `merged_labels`.
1309
+ * 3. implausible — a flood trapped inside a room-number bubble, a door
1310
+ * swing, or a wall cavity is fully enclosed, so it traces clean and
1311
+ * `detectRegions` passes it. Area is the only thing that separates it
1312
+ * from a room. Withheld below `minAreaSf` (default 5 SF — smaller than
1313
+ * any real finished space; a broom closet is ~10 SF). Only applied
1314
+ * once a scale exists, since without one there is no real area to
1315
+ * judge and nothing commits anyway. */
1300
1316
  async detectRooms(name, opts) {
1301
1317
  const s = this.sheet(name);
1302
1318
  const mask = await this.ensureMask(name);
1303
1319
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1320
+ const minAreaSf = opts.minAreaSf ?? 5;
1304
1321
  const seeds = roomLabelSeeds(s.text);
1305
1322
  const regions = detectRegions(mask, seeds);
1306
- const rooms = regions.map((r) => {
1323
+ const withheld = { degenerate: 0, duplicate: 0, implausible: 0 };
1324
+ const byRing = /* @__PURE__ */ new Map();
1325
+ const order = [];
1326
+ for (const r of regions) {
1307
1327
  const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1308
- if (ring.length < 3) return null;
1309
- const areaPx2 = ringArea(ring);
1310
- const perimPx = closedMetrics(ring).perim;
1311
- const common = {
1328
+ if (ring.length < 3) {
1329
+ withheld.degenerate++;
1330
+ continue;
1331
+ }
1332
+ const key = ring.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(";");
1333
+ const seen = byRing.get(key);
1334
+ if (seen) {
1335
+ seen.merged.push(r.str);
1336
+ withheld.duplicate++;
1337
+ continue;
1338
+ }
1339
+ const cand = {
1312
1340
  label: r.str,
1313
- nverts: ring.length,
1314
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {},
1315
- ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1341
+ ring,
1342
+ areaPx2: ringArea(ring),
1343
+ perimPx: closedMetrics(ring).perim,
1344
+ seed: r.seed,
1345
+ hatch: !!r.flood.hatchFiltered,
1346
+ merged: []
1316
1347
  };
1317
- if (s.upp == null) {
1318
- return { ...common, area_px2: round1(areaPx2), perimeter_px: round1(perimPx) };
1348
+ byRing.set(key, cand);
1349
+ order.push(cand);
1350
+ }
1351
+ const upp = s.upp;
1352
+ const rooms = order.map((c) => {
1353
+ const common = {
1354
+ label: c.label,
1355
+ nverts: c.ring.length,
1356
+ ...c.merged.length ? { merged_labels: c.merged } : {},
1357
+ ...c.hatch ? { hatch_filtered: true } : {},
1358
+ ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1359
+ };
1360
+ if (upp == null) {
1361
+ return { ...common, area_px2: round1(c.areaPx2), perimeter_px: round1(c.perimPx) };
1362
+ }
1363
+ const area_sf = round2(c.areaPx2 * upp * upp);
1364
+ if (area_sf < minAreaSf) {
1365
+ withheld.implausible++;
1366
+ return null;
1319
1367
  }
1320
- const upp = s.upp;
1321
- const area_sf = round2(areaPx2 * upp * upp);
1322
- const perimeter_lf = round2(perimPx * upp);
1368
+ const perimeter_lf = round2(c.perimPx * upp);
1323
1369
  let shape_id;
1324
1370
  if (opts.condition) {
1325
- shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
1371
+ shape_id = this.commit(s, opts.condition, opts.role, c.ring, { area_sf, perimeter_lf }, {
1326
1372
  method: "one_click_v1",
1327
1373
  actor: "agent",
1328
- seed_norm: [r.seed[0] / s.widthPx, r.seed[1] / s.heightPx],
1374
+ seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
1329
1375
  reviewed: false,
1330
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {}
1376
+ ...c.hatch ? { hatch_filtered: true } : {}
1331
1377
  }).id;
1332
1378
  }
1333
1379
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1334
1380
  }).filter((r) => r !== null);
1381
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.implausible;
1335
1382
  return {
1336
1383
  detected: rooms.length,
1337
1384
  rooms,
1385
+ withheld: {
1386
+ total: withheldTotal,
1387
+ ...withheld,
1388
+ ...upp != null ? { min_area_sf: minAreaSf } : {}
1389
+ },
1390
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable. Raise or lower min_area_sf to see more.` } : {},
1338
1391
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
1339
1392
  };
1340
1393
  }
@@ -1462,6 +1515,7 @@ var oneClickOutput = {
1462
1515
  var detectedRoom = z.object({
1463
1516
  label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
1464
1517
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
1518
+ merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
1465
1519
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
1466
1520
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
1467
1521
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
@@ -1473,6 +1527,14 @@ var detectedRoom = z.object({
1473
1527
  var detectRoomsOutput = {
1474
1528
  detected: z.number().int().describe("Count of cleanly-detected rooms \u2014 may be fewer than the labels found on the sheet"),
1475
1529
  rooms: z.array(detectedRoom),
1530
+ withheld: z.object({
1531
+ total: z.number().int().describe("Seeds found on the sheet but not reported as rooms"),
1532
+ degenerate: z.number().int().describe("Traced to fewer than 3 vertices"),
1533
+ duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
1534
+ implausible: z.number().int().describe("Enclosed and clean, but smaller than min_area_sf \u2014 a label bubble, door swing, or wall cavity rather than a room"),
1535
+ min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
1536
+ }).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
1537
+ note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
1476
1538
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
1477
1539
  };
1478
1540
  var measurePolygonOutput = {
@@ -1614,15 +1676,16 @@ function registerTools(server, session) {
1614
1676
  outputSchema: oneClickOutput
1615
1677
  }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
1616
1678
  server.registerTool("detect_rooms", {
1617
- 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}`,
1679
+ 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. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a 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 \u2014 the plausibility floor needs real units, so it only applies once a scale is set. ${COORDS}`,
1618
1680
  inputSchema: {
1619
1681
  sheet: z2.string(),
1620
1682
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
1621
1683
  role: roleSchema,
1622
- return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)")
1684
+ return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
1685
+ min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed regions smaller than this are withheld as label bubbles/cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped.")
1623
1686
  },
1624
1687
  outputSchema: detectRoomsOutput
1625
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
1688
+ }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf })));
1626
1689
  server.registerTool("measure_polygon", {
1627
1690
  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}`,
1628
1691
  inputSchema: {
@@ -1774,7 +1837,7 @@ function registerResources(server, session) {
1774
1837
  // package.json
1775
1838
  var package_default = {
1776
1839
  name: "opentakeoff-mcp",
1777
- version: "0.6.0",
1840
+ version: "0.6.1",
1778
1841
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1779
1842
  type: "module",
1780
1843
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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.",