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 +7 -4
- package/dist/server-core.js +214 -21
- package/package.json +2 -2
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
|
-
- **
|
|
248
|
-
|
|
249
|
-
(
|
|
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
|
package/dist/server-core.js
CHANGED
|
@@ -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
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
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
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
if (
|
|
3009
|
-
|
|
3010
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3096
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|