opentakeoff-mcp 0.9.20 → 0.9.21

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,7 +108,7 @@ includes document text, shape vertices, or result payload content.
108
108
  | `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152) — plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
109
109
  | `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
110
110
  | `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
111
- | `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
+ | `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels — same engine as the canvas — with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit; `role: "deduct"` subtracts. |
112
112
  | `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; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
113
113
  | `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
114
114
  | `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
@@ -244,9 +244,12 @@ sheet number (`A-101`) wherever a sheet is named.
244
244
 
245
245
  ## Limits (v1)
246
246
 
247
- - **Vector + text sheets only.** A scanned sheet has no vector linework, so
248
- `one_click` reports it plainly; a raster fallback is a planned seam
249
- (`src/session.ts`, `ensureMask`), not yet built.
247
+ - **Scanned sheets flood, but don't index.** `one_click` and `detect_rooms`
248
+ fall back to the sheet's rendered pixels where vectors can't bound the room
249
+ (#154) — disclosed as `raster_traced` — but a scan with no text layer still
250
+ has nothing for `detect_rooms`/`sheet_graph`/`resolve_tag` to read: seeds
251
+ come from you (`view_sheet`, then `one_click`). The raster path needs the
252
+ same optional `@napi-rs/canvas` as `view_sheet`.
250
253
  - `load_plan` replaces the session by default; `merge: true` builds a multi-document working set (#152). Reloading a merged file is refused — reload = replace, deliberately.
251
254
  - The takeoff lives in memory. `export_takeoff` (the app's exact save payload,
252
255
  nothing lost in translation) and `export_marked_pdf` (the reviewable marked
@@ -193,6 +193,19 @@ async function openPdf(filePath) {
193
193
  factory.destroy(target);
194
194
  }
195
195
  },
196
+ async renderRgba(scale, width, height) {
197
+ await ensureCanvasGlobals();
198
+ const rvp = page.getViewport({ scale });
199
+ const factory = doc.canvasFactory;
200
+ const target = factory.create(width, height);
201
+ try {
202
+ await page.render({ canvasContext: target.context, viewport: rvp, background: "#ffffff" }).promise;
203
+ const ctx = target.context;
204
+ return ctx.getImageData(0, 0, width, height).data;
205
+ } finally {
206
+ factory.destroy(target);
207
+ }
208
+ },
196
209
  async renderRegionPng(region, longEdge, draw) {
197
210
  await ensureCanvasGlobals();
198
211
  const w = region.x1 - region.x0;
@@ -1316,6 +1329,105 @@ var fail = (err) => ({
1316
1329
  var round2 = (n) => +n.toFixed(2);
1317
1330
  var round1 = (n) => +n.toFixed(1);
1318
1331
 
1332
+ // ../web/src/lib/rastermask.ts
1333
+ var RASTER_MIN_IMG_FRAC = 0.1;
1334
+ var RASTER_MIN_SEGS = 500;
1335
+ var RASTER_T = 0.15;
1336
+ var RASTER_ABS_INK = 100;
1337
+ var RASTER_WIN_MIN = 15;
1338
+ var RASTER_WIN_DIV = 32;
1339
+ var INVERT_MEAN = 128;
1340
+ var RASTER_RDP_EPS = 2.5;
1341
+ function toGray(rgba, n) {
1342
+ const gray = new Uint8Array(n);
1343
+ let sum = 0;
1344
+ for (let i = 0, j = 0; i < n; i++, j += 4) {
1345
+ const g = 77 * rgba[j] + 151 * rgba[j + 1] + 28 * rgba[j + 2] >> 8;
1346
+ gray[i] = g;
1347
+ sum += g;
1348
+ }
1349
+ return { gray, mean: n ? sum / n : 255 };
1350
+ }
1351
+ function interiorMean(gray, mw, mh, marginFrac = 0.12) {
1352
+ const mx = Math.floor(mw * marginFrac), my = Math.floor(mh * marginFrac);
1353
+ const x0 = mx, x1 = mw - mx, y0 = my, y1 = mh - my;
1354
+ if (x1 <= x0 || y1 <= y0) {
1355
+ let sum2 = 0;
1356
+ for (let i = 0; i < gray.length; i++) sum2 += gray[i];
1357
+ return gray.length ? sum2 / gray.length : 255;
1358
+ }
1359
+ let sum = 0, count = 0;
1360
+ for (let y = y0; y < y1; y++) {
1361
+ const row = y * mw;
1362
+ for (let x = x0; x < x1; x++) {
1363
+ sum += gray[row + x];
1364
+ count++;
1365
+ }
1366
+ }
1367
+ return count ? sum / count : 255;
1368
+ }
1369
+ function adaptiveThreshold(gray, mw, mh, t = RASTER_T, absInk = RASTER_ABS_INK) {
1370
+ const iw = mw + 1;
1371
+ const integral = new Uint32Array(iw * (mh + 1));
1372
+ for (let y = 0; y < mh; y++) {
1373
+ let rowSum = 0;
1374
+ for (let x = 0; x < mw; x++) {
1375
+ rowSum += gray[y * mw + x];
1376
+ integral[(y + 1) * iw + (x + 1)] = integral[y * iw + (x + 1)] + rowSum;
1377
+ }
1378
+ }
1379
+ const win = Math.max(RASTER_WIN_MIN, Math.round(Math.min(mw, mh) / RASTER_WIN_DIV)) | 1;
1380
+ const half = win >> 1;
1381
+ const out = new Uint8Array(mw * mh);
1382
+ for (let y = 0; y < mh; y++) {
1383
+ const y0 = Math.max(0, y - half), y1 = Math.min(mh - 1, y + half);
1384
+ for (let x = 0; x < mw; x++) {
1385
+ const x0 = Math.max(0, x - half), x1 = Math.min(mw - 1, x + half);
1386
+ const area = (x1 - x0 + 1) * (y1 - y0 + 1);
1387
+ const sum = integral[(y1 + 1) * iw + (x1 + 1)] - integral[y0 * iw + (x1 + 1)] - integral[(y1 + 1) * iw + x0] + integral[y0 * iw + x0];
1388
+ const g = gray[y * mw + x];
1389
+ if (g < absInk || g * area < sum * (1 - t)) out[y * mw + x] = 1;
1390
+ }
1391
+ }
1392
+ return out;
1393
+ }
1394
+ function closeMask(mask, mw, mh) {
1395
+ const pass = (src, horizontal, dilate) => {
1396
+ const dst = new Uint8Array(mw * mh);
1397
+ for (let y = 0; y < mh; y++) {
1398
+ for (let x = 0; x < mw; x++) {
1399
+ const i = y * mw + x;
1400
+ let a, b, c;
1401
+ if (horizontal) {
1402
+ a = x > 0 ? src[i - 1] : src[i];
1403
+ b = src[i];
1404
+ c = x < mw - 1 ? src[i + 1] : src[i];
1405
+ } else {
1406
+ a = y > 0 ? src[i - mw] : src[i];
1407
+ b = src[i];
1408
+ c = y < mh - 1 ? src[i + mw] : src[i];
1409
+ }
1410
+ dst[i] = dilate ? a | b | c : a & b & c;
1411
+ }
1412
+ }
1413
+ return dst;
1414
+ };
1415
+ let m = pass(mask, true, true);
1416
+ m = pass(m, false, true);
1417
+ m = pass(m, true, false);
1418
+ m = pass(m, false, false);
1419
+ return m;
1420
+ }
1421
+ function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
1422
+ const n = mw * mh;
1423
+ const { gray } = toGray(rgba, n);
1424
+ const polarityMean = interiorMean(gray, mw, mh);
1425
+ if (polarityMean < INVERT_MEAN) for (let i = 0; i < n; i++) gray[i] = 255 - gray[i];
1426
+ let mask = adaptiveThreshold(gray, mw, mh, opts.t, opts.absInk);
1427
+ if (opts.bridge !== false) mask = closeMask(mask, mw, mh);
1428
+ return { mask, mw, mh, ws, softCount: 0 };
1429
+ }
1430
+
1319
1431
  // ../web/src/lib/symbolsweep.ts
1320
1432
  var SWEEP_TOL_PX = 2;
1321
1433
  var SWEEP_SCORE_HIGH = 0.92;
@@ -2865,9 +2977,9 @@ var Session = class _Session {
2865
2977
  }
2866
2978
  return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
2867
2979
  }
2868
- /** v1 masks come from the sheet's vector linework only. Raster seam: a scanned
2869
- * sheet would render via a node canvas into a future rastermask module that
2870
- * returns this same MaskObj shape. Layer roles (#85) ride in as the stated
2980
+ /** v1 masks come from the sheet's vector linework only; a scanned sheet
2981
+ * (zero segments) is null here and the measuring tools fall back to
2982
+ * ensureRasterMask (#154). Layer roles (#85) ride in as the stated
2871
2983
  * short-circuit; an unlayered sheet builds the identical pre-#85 mask. */
2872
2984
  async ensureMask(name) {
2873
2985
  const s = this.sheet(name);
@@ -2887,6 +2999,46 @@ var Session = class _Session {
2887
2999
  if (!geo.segs.length) return null;
2888
3000
  return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
2889
3001
  }
3002
+ /** The raster-fallback mask (#154): a dedicated render of the sheet at mask
3003
+ * scale (the view_sheet machinery — pdf.ts + @napi-rs/canvas), thresholded
3004
+ * by the canvas's own rastermask engine into the same MaskObj shape
3005
+ * buildMask emits, so floodRegion/traceRegion run unchanged on scans.
3006
+ * Cached per sheet like geo/mask; where the optional native canvas never
3007
+ * installed, renderRgba throws its plain install-hint Error and the tool
3008
+ * reply carries it — a stated inability, never a guessed polygon. */
3009
+ async ensureRasterMask(s) {
3010
+ if (!s.rmask) {
3011
+ const ws = Math.min(1, MASK_MAX_DIM / Math.max(s.widthPx, s.heightPx, 1));
3012
+ const mw = Math.max(2, Math.ceil(s.widthPx * ws));
3013
+ const mh = Math.max(2, Math.ceil(s.heightPx * ws));
3014
+ const rgba = await s.page.renderRgba(RENDER_SCALE * ws, mw, mh);
3015
+ s.rmask = buildRasterMask(rgba, mw, mh, ws);
3016
+ }
3017
+ return s.rmask;
3018
+ }
3019
+ /** The canvas's trigger policy (#154), verbatim (TakeoffCanvas.jsx /
3020
+ * rastermask.ts constants): raster-ELIGIBLE = placed-image area covers
3021
+ * ≥ RASTER_MIN_IMG_FRAC of the sheet (a scan wrapper or photo underlay);
3022
+ * vector-VIABLE = enough segments that the vector mask can bound rooms.
3023
+ * Vector is exact and always wins where it works — a pure-vector sheet
3024
+ * never touches pixels; a scan wrapper with near-zero linework runs raster
3025
+ * primary; a mixed sheet retries on pixels only after the vector flood
3026
+ * fails. */
3027
+ rasterPolicy(s, geo) {
3028
+ const sheetArea = s.widthPx * s.heightPx;
3029
+ return {
3030
+ rasterEligible: sheetArea > 0 && geo.imageArea / sheetArea >= RASTER_MIN_IMG_FRAC,
3031
+ vectorViable: geo.segs.length >> 2 >= RASTER_MIN_SEGS
3032
+ };
3033
+ }
3034
+ /** Layer overrides (#85) name PDF Optional Content — scan pixels carry
3035
+ * none, so a raster-path call that stated them is refused rather than
3036
+ * silently no-opped (the rolesFor resolve-or-error doctrine). */
3037
+ refuseLayersOnRaster(layersOpt) {
3038
+ if (layersOpt && (layersOpt.include?.length || layersOpt.exclude?.length)) {
3039
+ throw new UserError("Layer overrides can't apply here \u2014 this flood runs on the sheet's rendered pixels (raster fallback), and scan ink carries no PDF layers. Retry without layers.");
3040
+ }
3041
+ }
2890
3042
  async sheetInfo(name) {
2891
3043
  const s = this.sheet(name);
2892
3044
  const geo = await this.ensureGeometry(s);
@@ -3002,12 +3154,31 @@ var Session = class _Session {
3002
3154
  }
3003
3155
  async oneClick(name, x, y, opts) {
3004
3156
  const s = this.sheet(name);
3005
- const mask = await this.maskWithLayers(name, opts.layers);
3006
- if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
3007
- const f = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
3008
- if (f.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
3009
- if (f.status !== "ok") throw new UserError("Landed in dense linework (hatching or text).");
3010
- const ring = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
3157
+ const { rasterEligible, vectorViable } = this.rasterPolicy(s, await this.ensureGeometry(s));
3158
+ let f = null;
3159
+ let raster = false;
3160
+ if (!rasterEligible || vectorViable) {
3161
+ const mask = await this.maskWithLayers(name, opts.layers);
3162
+ if (!mask && !rasterEligible) throw new UserError("This sheet has no vector linework and no scan image to flood \u2014 nothing here bounds a region. Trace the space with measure_polygon instead.");
3163
+ if (mask) {
3164
+ const r = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
3165
+ if (r.status === "ok") f = r;
3166
+ else if (!rasterEligible) {
3167
+ if (r.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
3168
+ throw new UserError("Landed in dense linework (hatching or text).");
3169
+ }
3170
+ }
3171
+ }
3172
+ if (!f) {
3173
+ this.refuseLayersOnRaster(opts.layers);
3174
+ const rmask = await this.ensureRasterMask(s);
3175
+ const r = floodRegion(rmask, x, y);
3176
+ if (r.status === "leak") throw new UserError("That space isn't enclosed on the scan \u2014 the fill escaped through a gap (faded line or open doorway). Seed a more enclosed spot, or trace it with measure_polygon.");
3177
+ if (r.status !== "ok") throw new UserError("Landed on dense scan ink (text or hatching). Seed an open spot inside the room.");
3178
+ f = r;
3179
+ raster = true;
3180
+ }
3181
+ const ring = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
3011
3182
  if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
3012
3183
  const areaPx2 = ringArea(ring);
3013
3184
  const perimPx = closedMetrics(ring).perim;
@@ -3016,6 +3187,9 @@ var Session = class _Session {
3016
3187
  nverts: ring.length,
3017
3188
  ...f.hatchFiltered ? { hatch_filtered: true } : {},
3018
3189
  ...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
3190
+ // which path ran, disclosed both ways (#154): present = pixels bounded
3191
+ // this trace, absent = the vector linework did
3192
+ ...raster ? { raster_traced: true } : {},
3019
3193
  ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
3020
3194
  };
3021
3195
  if (s.upp == null) {
@@ -3038,12 +3212,18 @@ var Session = class _Session {
3038
3212
  reviewed: false,
3039
3213
  ...f.hatchFiltered ? { hatch_filtered: true } : {},
3040
3214
  ...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
3215
+ // #154 — a trace whose boundary was scan PIXELS is a different claim
3216
+ // than one bounded by vector linework; the record says which (the
3217
+ // canvas's raster_traced vocabulary, contribution.v2)
3218
+ ...raster ? { raster_traced: true } : {},
3041
3219
  // #85 — a trace bounded by DECLARED boundary layers is categorically
3042
- // stronger evidence than one bounded by a pitch heuristic
3043
- ...(s.layers || []).some((l) => l.visible && (l.role === "boundary" || l.role === "structure")) ? { layer_bounded: true } : {},
3220
+ // stronger evidence than one bounded by a pitch heuristic (vector
3221
+ // path only: the raster mask never saw the layer table)
3222
+ ...!raster && (s.layers || []).some((l) => l.visible && (l.role === "boundary" || l.role === "structure")) ? { layer_bounded: true } : {},
3044
3223
  // canvas-parity provenance: a non-default fill sensitivity is part of
3045
- // how the shape was made (ShapeOrigin.fill_sensitivity)
3046
- ...opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
3224
+ // how the shape was made (ShapeOrigin.fill_sensitivity) — vector path
3225
+ // only; the knob is inert on a single-tier raster mask
3226
+ ...!raster && opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
3047
3227
  }).id;
3048
3228
  }
3049
3229
  this.flushCommits("one_click");
@@ -3092,8 +3272,17 @@ var Session = class _Session {
3092
3272
  throw new UserError("No room-finish schedule in the working set \u2014 load_plan the schedule sheet with merge: true, or pass condition to commit every room under one tag.");
3093
3273
  }
3094
3274
  }
3095
- const mask = await this.maskWithLayers(name, opts.layers);
3096
- if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
3275
+ const geo = await this.ensureGeometry(s);
3276
+ const { rasterEligible, vectorViable } = this.rasterPolicy(s, geo);
3277
+ let mask = null;
3278
+ let raster = false;
3279
+ if (!rasterEligible || vectorViable) mask = await this.maskWithLayers(name, opts.layers);
3280
+ if (!mask) {
3281
+ if (!rasterEligible) throw new UserError("This sheet has no vector linework and no scan image to flood \u2014 nothing here bounds a region. Trace rooms with measure_polygon instead.");
3282
+ this.refuseLayersOnRaster(opts.layers);
3283
+ mask = await this.ensureRasterMask(s);
3284
+ raster = true;
3285
+ }
3097
3286
  const minAreaSf = opts.minAreaSf ?? 5;
3098
3287
  if (!s.spans) s.spans = textSpans(s.page);
3099
3288
  const labels = [];
@@ -3111,7 +3300,7 @@ var Session = class _Session {
3111
3300
  for (const probe of seedLadderPx(lb.bbox)) {
3112
3301
  const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
3113
3302
  if (f.status !== "ok") continue;
3114
- const r = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
3303
+ const r = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
3115
3304
  if (r.length < 3) {
3116
3305
  sawDegenerate = true;
3117
3306
  continue;
@@ -3159,6 +3348,7 @@ var Session = class _Session {
3159
3348
  ...c.merged.length ? { merged_labels: c.merged } : {},
3160
3349
  ...c.hatch ? { hatch_filtered: true } : {},
3161
3350
  ...c.gap ? { gap_bridged_px: c.gap } : {},
3351
+ ...raster ? { raster_traced: true } : {},
3162
3352
  ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
3163
3353
  };
3164
3354
  if (upp == null) {
@@ -3191,6 +3381,7 @@ var Session = class _Session {
3191
3381
  reviewed: false,
3192
3382
  ...c.hatch ? { hatch_filtered: true } : {},
3193
3383
  ...c.gap ? { gap_bridged_px: c.gap } : {},
3384
+ ...raster ? { raster_traced: true } : {},
3194
3385
  ...assignment ? { assignment } : {}
3195
3386
  }).id;
3196
3387
  }
@@ -4085,6 +4276,7 @@ var oneClickOutput = {
4085
4276
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
4086
4277
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
4087
4278
  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"),
4279
+ raster_traced: z.literal(true).optional().describe("Present when the region was bounded by the sheet's RENDERED PIXELS (the scanned-sheet raster fallback, #154) rather than vector linework \u2014 absent means the vector path ran. Rides origin.raster_traced on the committed shape; a raster ring's corners are unsnapped (a scan has no true endpoints), so audit it with view_sheet overlay before trusting the total"),
4088
4280
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
4089
4281
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
4090
4282
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
@@ -4099,6 +4291,7 @@ var detectedRoom = z.object({
4099
4291
  merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
4100
4292
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
4101
4293
  gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region"),
4294
+ raster_traced: z.literal(true).optional().describe("Present when the room was bounded by rendered pixels (scanned-sheet raster fallback, #154) rather than vector linework \u2014 sheet-wide per sweep, and it rides origin.raster_traced on the committed shape"),
4102
4295
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
4103
4296
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
4104
4297
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
@@ -5925,7 +6118,7 @@ function registerTools(server, session) {
5925
6118
  return loaded;
5926
6119
  }));
5927
6120
  server.registerTool("sheet_info", {
5928
- description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click needs it), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
6121
+ description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click floods it when present; a scanned sheet falls back to rendered pixels, disclosed as raster_traced), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
5929
6122
  inputSchema: { sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') },
5930
6123
  outputSchema: sheetInfoOutput
5931
6124
  }, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
@@ -5945,7 +6138,7 @@ function registerTools(server, session) {
5945
6138
  return session.setScale(a.sheet, a);
5946
6139
  }));
5947
6140
  server.registerTool("one_click", {
5948
- description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
6141
+ description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. SCANNED sheets work too (#154): where vectors can't bound the room (an image-only scan, or a scan wrapper whose only linework is the title block), the flood falls back automatically to the sheet's rendered pixels \u2014 same engine the canvas uses \u2014 and the reply plus the committed shape's origin carry raster_traced: true so a pixel-bounded ring is never mistaken for a vector-snapped one. Vector always wins where it works; a raster ring's corners are unsnapped, so audit it with view_sheet {overlay: true} before trusting the total. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
5949
6142
  inputSchema: {
5950
6143
  sheet: z2.string(),
5951
6144
  x: z2.number(),
@@ -5959,7 +6152,7 @@ function registerTools(server, session) {
5959
6152
  outputSchema: oneClickOutput
5960
6153
  }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
5961
6154
  server.registerTool("detect_rooms", {
5962
- 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. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; 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. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
6155
+ 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. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. 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. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; 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. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
5963
6156
  inputSchema: {
5964
6157
  sheet: z2.string(),
5965
6158
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
@@ -6348,7 +6541,7 @@ function registerResources(server, session) {
6348
6541
  // package.json
6349
6542
  var package_default = {
6350
6543
  name: "opentakeoff-mcp",
6351
- version: "0.9.20",
6544
+ version: "0.9.21",
6352
6545
  mcpName: "io.github.Kentucky-ai/opentakeoff",
6353
6546
  type: "module",
6354
6547
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -6364,7 +6557,7 @@ var package_default = {
6364
6557
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
6365
6558
  prepublishOnly: "npm run typecheck && npm test && npm run build",
6366
6559
  typecheck: "tsc --noEmit",
6367
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
6560
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
6368
6561
  },
6369
6562
  dependencies: {
6370
6563
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.20",
3
+ "version": "0.9.21",
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/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
19
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",