opentakeoff-mcp 0.9.19 → 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,13 +108,14 @@ 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. |
115
115
  | `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
116
116
  | `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob — pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
117
117
  | `place_count` | **EA markers**: one point, one each — thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
118
+ | `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the sheet's vector linework is searched deterministically for every other placement — translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. `commit: true` + `condition` commits every match center as an EA count marker through the `place_count` path — one undo step, `origin.method "symbol_sweep"` with per-marker score and transform. No scale required. |
118
119
  | `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
119
120
  | `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
120
121
  | `delete_shape` | Remove a committed shape by id. |
@@ -126,7 +127,7 @@ includes document text, shape vertices, or result payload content.
126
127
  | `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself, and the cover states where the finish tags came from (`Finish assignment: N schedule-resolved · N agent-asserted · …`, plus any rooms the last assign run withheld). Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
127
128
  | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, review state, and assignment verdict (`schedule` \| `asserted` — where its finish tag came from) in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
128
129
  | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
129
- | `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
130
+ | `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text), **dimension** (`from` + `to` — a dimension line with end ticks, labelled with the measured length at the sheet's scale; the one annotation the scale gate applies to — an unscaled sheet refuses like the measure tools). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
130
131
  | `list_annotations` | Every annotation with its condition RESOLVED to a finish tag, coordinates back in image px; filter by sheet/condition. `unattached` counts the link_annotation candidates. |
131
132
  | `link_annotation` | Attach an existing annotation to a condition (or detach with an empty tag) — the canvas's Attach/Detach control, reachable by an agent. |
132
133
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
@@ -243,9 +244,12 @@ sheet number (`A-101`) wherever a sheet is named.
243
244
 
244
245
  ## Limits (v1)
245
246
 
246
- - **Vector + text sheets only.** A scanned sheet has no vector linework, so
247
- `one_click` reports it plainly; a raster fallback is a planned seam
248
- (`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`.
249
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.
250
254
  - The takeoff lives in memory. `export_takeoff` (the app's exact save payload,
251
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,334 @@ 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
+
1431
+ // ../web/src/lib/symbolsweep.ts
1432
+ var SWEEP_TOL_PX = 2;
1433
+ var SWEEP_SCORE_HIGH = 0.92;
1434
+ var SWEEP_SCORE_LOW = 0.75;
1435
+ var SWEEP_MAX_CANDIDATES = 2e4;
1436
+ var ANCHOR_COUNT = 3;
1437
+ var MIN_SEG_LEN = 0.5;
1438
+ var MAX_SEED_SEGS = 2e3;
1439
+ function transformsFor(rotations, mirror) {
1440
+ const rots = [
1441
+ [0, [1, 0, 0, 1]],
1442
+ [90, [0, -1, 1, 0]],
1443
+ [180, [-1, 0, 0, -1]],
1444
+ [270, [0, 1, -1, 0]]
1445
+ ];
1446
+ const use = rotations ? rots : rots.slice(0, 1);
1447
+ const out = use.map(([rotation, m]) => ({ rotation, mirrored: false, m }));
1448
+ if (mirror) {
1449
+ for (const [rotation, m] of use) out.push({ rotation, mirrored: true, m: [-m[0], m[1], -m[2], m[3]] });
1450
+ }
1451
+ return out;
1452
+ }
1453
+ var apply = (m, x, y) => [m[0] * x + m[1] * y, m[2] * x + m[3] * y];
1454
+ var EndpointGrid = class {
1455
+ constructor(segs, tol) {
1456
+ this.segs = segs;
1457
+ this.cell = Math.max(2 * tol, 4);
1458
+ const n = segs.length >> 2;
1459
+ for (let i = 0; i < n; i++) {
1460
+ this.add(segs[i * 4], segs[i * 4 + 1], i);
1461
+ this.add(segs[i * 4 + 2], segs[i * 4 + 3], i);
1462
+ }
1463
+ }
1464
+ cells = /* @__PURE__ */ new Map();
1465
+ cell;
1466
+ key(cx, cy) {
1467
+ return cx * 73856093 ^ cy * 19349663;
1468
+ }
1469
+ add(x, y, i) {
1470
+ const k = this.key(Math.floor(x / this.cell), Math.floor(y / this.cell));
1471
+ const a = this.cells.get(k);
1472
+ if (a) {
1473
+ if (a[a.length - 1] !== i) a.push(i);
1474
+ } else this.cells.set(k, [i]);
1475
+ }
1476
+ /** Indices of segments with an endpoint within one cell of (x, y). */
1477
+ near(x, y, out) {
1478
+ out.length = 0;
1479
+ const cx = Math.floor(x / this.cell), cy = Math.floor(y / this.cell);
1480
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
1481
+ const a = this.cells.get(this.key(cx + dx, cy + dy));
1482
+ if (a) for (const i of a) out.push(i);
1483
+ }
1484
+ return out;
1485
+ }
1486
+ };
1487
+ var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
1488
+ function sweepSymbols(segs, seedRect, opts = {}) {
1489
+ const tol = opts.tolPx ?? SWEEP_TOL_PX;
1490
+ const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
1491
+ const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
1492
+ const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
1493
+ const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
1494
+ const n = segs.length >> 2;
1495
+ const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
1496
+ const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
1497
+ const inside = (x, y) => x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1;
1498
+ const seedIdx = [];
1499
+ for (let i = 0; i < n; i++) {
1500
+ if (inside(segs[i * 4], segs[i * 4 + 1]) && inside(segs[i * 4 + 2], segs[i * 4 + 3]) && segLen(segs, i) >= MIN_SEG_LEN) {
1501
+ seedIdx.push(i);
1502
+ }
1503
+ }
1504
+ if (!seedIdx.length) {
1505
+ throw new Error("No vector segments sit fully inside the seed rect \u2014 marquee tightly around one whole symbol instance (segments crossing the rect edge don't count as the symbol).");
1506
+ }
1507
+ if (seedIdx.length > MAX_SEED_SEGS) {
1508
+ throw new Error(`The seed rect holds ${seedIdx.length} segments \u2014 that is a region, not one symbol instance. Marquee a single symbol.`);
1509
+ }
1510
+ let totalLen = 0, cxw = 0, cyw = 0;
1511
+ for (const i of seedIdx) {
1512
+ const L = segLen(segs, i);
1513
+ totalLen += L;
1514
+ cxw += (segs[i * 4] + segs[i * 4 + 2]) / 2 * L;
1515
+ cyw += (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2 * L;
1516
+ }
1517
+ const seedCx = cxw / totalLen, seedCy = cyw / totalLen;
1518
+ const rel = seedIdx.map((i) => [
1519
+ segs[i * 4] - seedCx,
1520
+ segs[i * 4 + 1] - seedCy,
1521
+ segs[i * 4 + 2] - seedCx,
1522
+ segs[i * 4 + 3] - seedCy,
1523
+ segLen(segs, i)
1524
+ ]);
1525
+ let sbx0 = Infinity, sby0 = Infinity, sbx1 = -Infinity, sby1 = -Infinity;
1526
+ for (const i of seedIdx) {
1527
+ sbx0 = Math.min(sbx0, segs[i * 4], segs[i * 4 + 2]);
1528
+ sby0 = Math.min(sby0, segs[i * 4 + 1], segs[i * 4 + 3]);
1529
+ sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
1530
+ sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
1531
+ }
1532
+ const lenBucket = /* @__PURE__ */ new Map();
1533
+ for (let i = 0; i < n; i++) {
1534
+ const b = Math.round(segLen(segs, i));
1535
+ const a = lenBucket.get(b);
1536
+ if (a) a.push(i);
1537
+ else lenBucket.set(b, [i]);
1538
+ }
1539
+ const bucketBand = (L) => {
1540
+ const out = [];
1541
+ for (let b = Math.floor(L - 2 * tol); b <= Math.ceil(L + 2 * tol); b++) {
1542
+ const a = lenBucket.get(b);
1543
+ if (a) {
1544
+ for (const i of a) if (Math.abs(segLen(segs, i) - L) <= 2 * tol) out.push(i);
1545
+ }
1546
+ }
1547
+ return out;
1548
+ };
1549
+ const byLenQ = /* @__PURE__ */ new Map();
1550
+ for (let k = 0; k < rel.length; k++) {
1551
+ const L = rel[k][4];
1552
+ const q = Math.round(L);
1553
+ if (!byLenQ.has(q)) byLenQ.set(q, { relIdx: k, len: L, rarity: bucketBand(L).length });
1554
+ }
1555
+ const anchors = [...byLenQ.values()].sort((a, b) => a.rarity - b.rarity || b.len - a.len || a.relIdx - b.relIdx).slice(0, ANCHOR_COUNT);
1556
+ const proposals = [];
1557
+ const seen = /* @__PURE__ */ new Set();
1558
+ const quant = Math.max(tol, 1);
1559
+ for (let xi = 0; xi < xforms.length; xi++) {
1560
+ const { m } = xforms[xi];
1561
+ for (const anc of anchors) {
1562
+ const r = rel[anc.relIdx];
1563
+ const A = apply(m, r[0], r[1]);
1564
+ const B = apply(m, r[2], r[3]);
1565
+ for (const j of bucketBand(anc.len)) {
1566
+ const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
1567
+ for (const [ax, ay, bx, by] of [[A[0], A[1], B[0], B[1]], [B[0], B[1], A[0], A[1]]]) {
1568
+ const c1x = px - ax, c1y = py - ay;
1569
+ const c2x = qx - bx, c2y = qy - by;
1570
+ if (Math.abs(c1x - c2x) > 2 * tol || Math.abs(c1y - c2y) > 2 * tol) continue;
1571
+ const tx = (c1x + c2x) / 2, ty = (c1y + c2y) / 2;
1572
+ const key = `${xi}:${Math.round(tx / quant)}:${Math.round(ty / quant)}`;
1573
+ if (seen.has(key)) continue;
1574
+ seen.add(key);
1575
+ proposals.push({ tx, ty, xf: xi });
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+ proposals.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.xf - b.xf);
1581
+ const considered = Math.min(proposals.length, maxCandidates);
1582
+ const dropped = proposals.length - considered;
1583
+ const grid = new EndpointGrid(segs, tol);
1584
+ const scratch = [];
1585
+ const tol2 = tol * tol;
1586
+ const near = (x1, y1, x2, y2) => (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) <= tol2;
1587
+ const scoreAt = (m, tx, ty) => {
1588
+ let matched = 0;
1589
+ for (const r of rel) {
1590
+ const a = apply(m, r[0], r[1]);
1591
+ const b = apply(m, r[2], r[3]);
1592
+ const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
1593
+ let hit = false;
1594
+ for (const j of grid.near(ax, ay, scratch)) {
1595
+ const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
1596
+ if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
1597
+ hit = true;
1598
+ break;
1599
+ }
1600
+ }
1601
+ if (hit) matched += r[4];
1602
+ }
1603
+ return matched / totalLen;
1604
+ };
1605
+ const scored = [];
1606
+ for (let k = 0; k < considered; k++) {
1607
+ const c = proposals[k];
1608
+ const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
1609
+ if (score < scoreLow) continue;
1610
+ scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
1611
+ }
1612
+ const mergeR = Math.max(2 * tol, 4);
1613
+ const kept = [];
1614
+ for (const s of scored) {
1615
+ const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
1616
+ if (!twin) {
1617
+ kept.push(s);
1618
+ continue;
1619
+ }
1620
+ if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
1621
+ twin.at = s.at;
1622
+ twin.score = s.score;
1623
+ twin.rotation = s.rotation;
1624
+ twin.mirrored = s.mirrored;
1625
+ twin.xf = s.xf;
1626
+ }
1627
+ }
1628
+ const suppressR = Math.max(mergeR, Math.hypot(sbx1 - sbx0, sby1 - sby0) / 2);
1629
+ const away = kept.filter((s) => Math.hypot(s.at[0] - seedCx, s.at[1] - seedCy) > suppressR);
1630
+ const matches = [];
1631
+ const withheld = [];
1632
+ const pct = (v) => Math.round(v * 1e3) / 1e3;
1633
+ const row = (s) => ({
1634
+ at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
1635
+ score: pct(s.score),
1636
+ rotation: s.rotation,
1637
+ mirrored: s.mirrored
1638
+ });
1639
+ for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
1640
+ for (const s of away) {
1641
+ if (s.score >= scoreHigh) continue;
1642
+ if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
1643
+ withheld.push({ ...row(s), reason: `matched ${Math.round(s.score * 100)}% of the seed's linework (commit bar ${Math.round(scoreHigh * 100)}%) \u2014 likely a variant or an overlapped instance; look before counting it` });
1644
+ }
1645
+ const order = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0] || a.rotation - b.rotation || Number(a.mirrored) - Number(b.mirrored);
1646
+ matches.sort(order);
1647
+ withheld.sort(order);
1648
+ return {
1649
+ seed: {
1650
+ segments: seedIdx.length,
1651
+ center: [Math.round(seedCx * 10) / 10, Math.round(seedCy * 10) / 10],
1652
+ length_px: Math.round(totalLen * 10) / 10
1653
+ },
1654
+ matches,
1655
+ withheld,
1656
+ candidates: { considered, dropped }
1657
+ };
1658
+ }
1659
+
1319
1660
  // ../web/src/lib/geometry.js
1320
1661
  function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
1321
1662
  const r = R * innerRatio;
@@ -1463,6 +1804,21 @@ function attrValue(attrs, colId) {
1463
1804
  return visible(attrs?.[colId]);
1464
1805
  }
1465
1806
 
1807
+ // ../web/src/lib/units.ts
1808
+ var M_PER_FT = 0.3048;
1809
+ function dimLabel(feet, units = "imperial") {
1810
+ if (!Number.isFinite(feet)) return "";
1811
+ if (units === "metric") return `${(feet * M_PER_FT).toFixed(2)} m`;
1812
+ const sign = feet < 0 ? "-" : "";
1813
+ let ft = Math.floor(Math.abs(feet) + 1e-9);
1814
+ let inch = Math.round((Math.abs(feet) - ft) * 12);
1815
+ if (inch === 12) {
1816
+ ft += 1;
1817
+ inch = 0;
1818
+ }
1819
+ return `${sign}${ft}'-${inch}"`;
1820
+ }
1821
+
1466
1822
  // ../web/src/lib/totals.js
1467
1823
  function accumulateRole(acc, s) {
1468
1824
  const cp = s.computed || {};
@@ -2621,9 +2977,9 @@ var Session = class _Session {
2621
2977
  }
2622
2978
  return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
2623
2979
  }
2624
- /** v1 masks come from the sheet's vector linework only. Raster seam: a scanned
2625
- * sheet would render via a node canvas into a future rastermask module that
2626
- * 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
2627
2983
  * short-circuit; an unlayered sheet builds the identical pre-#85 mask. */
2628
2984
  async ensureMask(name) {
2629
2985
  const s = this.sheet(name);
@@ -2643,6 +2999,46 @@ var Session = class _Session {
2643
2999
  if (!geo.segs.length) return null;
2644
3000
  return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
2645
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
+ }
2646
3042
  async sheetInfo(name) {
2647
3043
  const s = this.sheet(name);
2648
3044
  const geo = await this.ensureGeometry(s);
@@ -2758,12 +3154,31 @@ var Session = class _Session {
2758
3154
  }
2759
3155
  async oneClick(name, x, y, opts) {
2760
3156
  const s = this.sheet(name);
2761
- const mask = await this.maskWithLayers(name, opts.layers);
2762
- if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
2763
- const f = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
2764
- 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.");
2765
- if (f.status !== "ok") throw new UserError("Landed in dense linework (hatching or text).");
2766
- 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);
2767
3182
  if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
2768
3183
  const areaPx2 = ringArea(ring);
2769
3184
  const perimPx = closedMetrics(ring).perim;
@@ -2772,6 +3187,9 @@ var Session = class _Session {
2772
3187
  nverts: ring.length,
2773
3188
  ...f.hatchFiltered ? { hatch_filtered: true } : {},
2774
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 } : {},
2775
3193
  ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
2776
3194
  };
2777
3195
  if (s.upp == null) {
@@ -2794,12 +3212,18 @@ var Session = class _Session {
2794
3212
  reviewed: false,
2795
3213
  ...f.hatchFiltered ? { hatch_filtered: true } : {},
2796
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 } : {},
2797
3219
  // #85 — a trace bounded by DECLARED boundary layers is categorically
2798
- // stronger evidence than one bounded by a pitch heuristic
2799
- ...(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 } : {},
2800
3223
  // canvas-parity provenance: a non-default fill sensitivity is part of
2801
- // how the shape was made (ShapeOrigin.fill_sensitivity)
2802
- ...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 } : {}
2803
3227
  }).id;
2804
3228
  }
2805
3229
  this.flushCommits("one_click");
@@ -2848,8 +3272,17 @@ var Session = class _Session {
2848
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.");
2849
3273
  }
2850
3274
  }
2851
- const mask = await this.maskWithLayers(name, opts.layers);
2852
- 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
+ }
2853
3286
  const minAreaSf = opts.minAreaSf ?? 5;
2854
3287
  if (!s.spans) s.spans = textSpans(s.page);
2855
3288
  const labels = [];
@@ -2867,7 +3300,7 @@ var Session = class _Session {
2867
3300
  for (const probe of seedLadderPx(lb.bbox)) {
2868
3301
  const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
2869
3302
  if (f.status !== "ok") continue;
2870
- 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);
2871
3304
  if (r.length < 3) {
2872
3305
  sawDegenerate = true;
2873
3306
  continue;
@@ -2915,6 +3348,7 @@ var Session = class _Session {
2915
3348
  ...c.merged.length ? { merged_labels: c.merged } : {},
2916
3349
  ...c.hatch ? { hatch_filtered: true } : {},
2917
3350
  ...c.gap ? { gap_bridged_px: c.gap } : {},
3351
+ ...raster ? { raster_traced: true } : {},
2918
3352
  ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
2919
3353
  };
2920
3354
  if (upp == null) {
@@ -2947,6 +3381,7 @@ var Session = class _Session {
2947
3381
  reviewed: false,
2948
3382
  ...c.hatch ? { hatch_filtered: true } : {},
2949
3383
  ...c.gap ? { gap_bridged_px: c.gap } : {},
3384
+ ...raster ? { raster_traced: true } : {},
2950
3385
  ...assignment ? { assignment } : {}
2951
3386
  }).id;
2952
3387
  }
@@ -3090,15 +3525,99 @@ var Session = class _Session {
3090
3525
  * computed {count: 1}, NO scale required (EA is scale-free; the canvas's
3091
3526
  * recompute skips count shapes for the same reason). One shape per point,
3092
3527
  * the whole call one journal gesture — undoing a placement sweep is one step,
3093
- * matching detect_rooms. */
3528
+ * matching detect_rooms.
3529
+ *
3530
+ * `origins` (optional, aligned with points) lets a derived tool commit
3531
+ * through this same path while telling the truth about the method —
3532
+ * symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
3533
+ * marker; a bare place_count stays exactly the manual agent gesture it is. */
3094
3534
  placeCount(name, points, opts) {
3095
3535
  const s = this.sheet(name);
3096
- const ids = points.map(([x, y]) => this.commit(s, opts.condition, "count", [[x, y]], { count: 1 }, { method: "manual", actor: "agent" }).id);
3097
- this.flushCommits("place_count");
3536
+ const ids = points.map(([x, y], i) => this.commit(
3537
+ s,
3538
+ opts.condition,
3539
+ "count",
3540
+ [[x, y]],
3541
+ { count: 1 },
3542
+ opts.origins?.[i] ? { ...opts.origins[i] } : { method: "manual", actor: "agent" }
3543
+ ).id);
3544
+ this.flushCommits(opts.tool ?? "place_count");
3098
3545
  const c = this.conditions.find((x) => x.finish_tag === opts.condition);
3099
3546
  const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((n, x) => n + (x.computed.count || 1), 0);
3100
3547
  return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
3101
3548
  }
3549
+ /** symbol_sweep — every placement of ONE example symbol, from the linework.
3550
+ * The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
3551
+ * rect's segments, propose placements by constellation anchoring under the
3552
+ * square symmetry group, score each as the length-weighted fraction of seed
3553
+ * segments reproduced within tolerance. This method is the plumbing plus
3554
+ * the wire shapes: rect clamping, the scan refusal, and the commit path —
3555
+ * match centers through placeCount (ONE undo step, EA scale-free), origins
3556
+ * telling the truth (`method: "symbol_sweep"`, per-match score/transform),
3557
+ * withheld placements NEVER committed. */
3558
+ async symbolSweep(name, opts) {
3559
+ const s = this.sheet(name);
3560
+ if (opts.commit && !opts.condition) {
3561
+ throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
3562
+ }
3563
+ const geo = await this.ensureGeometry(s);
3564
+ if (!geo.segs.length) {
3565
+ throw new UserError("This sheet has no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments; raster fallback not yet available in the MCP server.");
3566
+ }
3567
+ const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
3568
+ const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
3569
+ const rect = [
3570
+ [clampX(opts.seedRect[0][0]), clampY(opts.seedRect[0][1])],
3571
+ [clampX(opts.seedRect[1][0]), clampY(opts.seedRect[1][1])]
3572
+ ];
3573
+ if (!(Math.abs(rect[1][0] - rect[0][0]) >= 1 && Math.abs(rect[1][1] - rect[0][1]) >= 1)) {
3574
+ throw new UserError(`Empty seed rect \u2014 need two distinct corners in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
3575
+ }
3576
+ const sweepOpts = {
3577
+ rotations: opts.rotations ?? true,
3578
+ mirror: opts.mirror ?? true,
3579
+ tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
3580
+ };
3581
+ let res;
3582
+ try {
3583
+ res = sweepSymbols(geo.segs, rect, sweepOpts);
3584
+ } catch (e) {
3585
+ throw new UserError(e instanceof Error ? e.message : String(e));
3586
+ }
3587
+ let committed;
3588
+ if (opts.commit && res.matches.length) {
3589
+ committed = this.placeCount(name, res.matches.map((m) => m.at), {
3590
+ condition: opts.condition,
3591
+ tool: "symbol_sweep",
3592
+ origins: res.matches.map((m) => ({
3593
+ method: "symbol_sweep",
3594
+ actor: "agent",
3595
+ reviewed: false,
3596
+ symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored }
3597
+ }))
3598
+ });
3599
+ }
3600
+ return {
3601
+ found: res.matches.length,
3602
+ matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
3603
+ withheld: res.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
3604
+ seed: {
3605
+ segments: res.seed.segments,
3606
+ center: [round1(res.seed.center[0]), round1(res.seed.center[1])],
3607
+ rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
3608
+ length_px: res.seed.length_px
3609
+ },
3610
+ candidates: res.candidates,
3611
+ ...committed ? {
3612
+ committed: committed.committed,
3613
+ shape_ids: committed.shape_ids,
3614
+ condition: committed.condition,
3615
+ ea_total: committed.ea_total
3616
+ } : {},
3617
+ ...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
3618
+ ...res.candidates.dropped > 0 ? { warning: `Work cap: ${res.candidates.dropped} candidate placement(s) were never scored \u2014 the seed's linework is too common on this sheet for an exhaustive sweep. Tighten the seed rect around more distinctive geometry, or sweep a region at a time and reconcile the counts.` } : {}
3619
+ };
3620
+ }
3102
3621
  /** The mid-session shape inventory (#149): every committed shape's id,
3103
3622
  * home, role, and quantities in one compact read — the ids edit_shape /
3104
3623
  * delete_shape assume you have, without pulling the whole export_takeoff
@@ -3418,7 +3937,14 @@ var Session = class _Session {
3418
3937
  if ((a.type === "cloud" || a.type === "highlight") && !a.rect) throw new UserError(`a ${a.type} needs rect: [[x0,y0],[x1,y1]] in image px`);
3419
3938
  if ((a.type === "text" || a.type === "callout" || a.type === "bubble") && !a.at) throw new UserError(`a ${a.type} needs at: [x,y] in image px`);
3420
3939
  if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
3421
- if (a.type === "arrow" && (!a.from || !a.to)) throw new UserError("an arrow needs from: [x,y] and to: [x,y] \u2014 tail and head, in image px");
3940
+ if ((a.type === "arrow" || a.type === "dimension") && (!a.from || !a.to)) {
3941
+ throw new UserError(a.type === "arrow" ? "an arrow needs from: [x,y] and to: [x,y] \u2014 tail and head, in image px" : "a dimension needs from: [x,y] and to: [x,y] \u2014 its two measured endpoints, in image px");
3942
+ }
3943
+ let len_ft;
3944
+ if (a.type === "dimension") {
3945
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
3946
+ len_ft = round2(Math.hypot(a.to[0] - a.from[0], a.to[1] - a.from[1]) * s.upp);
3947
+ }
3422
3948
  const cond = a.condition ? this.conditionFor(a.condition) : null;
3423
3949
  const m = {
3424
3950
  id: uid("mk"),
@@ -3435,7 +3961,10 @@ var Session = class _Session {
3435
3961
  ...a.to ? { to: n(a.to) } : {},
3436
3962
  // bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
3437
3963
  // canvas default is 0.02 when unset — stored explicitly so exports agree
3438
- ...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {}
3964
+ ...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {},
3965
+ // dimension: the measured length rides the markup so the renderers
3966
+ // (canvas, marked set) draw the label without scale plumbing
3967
+ ...len_ft !== void 0 ? { len_ft } : {}
3439
3968
  };
3440
3969
  this.markups.push(m);
3441
3970
  return {
@@ -3445,6 +3974,7 @@ var Session = class _Session {
3445
3974
  text: m.text,
3446
3975
  condition: cond?.finish_tag ?? "",
3447
3976
  condition_id: m.condition_id,
3977
+ ...len_ft !== void 0 ? { length_lf: len_ft } : {},
3448
3978
  note: cond ? `Attached to ${cond.finish_tag} \u2014 it wears that condition's colour on the canvas and in the marked set.` : "Unattached \u2014 a note about the sheet. Pass condition to tie it to a scope."
3449
3979
  };
3450
3980
  }
@@ -3482,7 +4012,8 @@ var Session = class _Session {
3482
4012
  ...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
3483
4013
  ...m.from ? { from: px(m, m.from) } : {},
3484
4014
  ...m.to ? { to: px(m, m.to) } : {},
3485
- ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {}
4015
+ ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {},
4016
+ ...m.len_ft != null ? { length_lf: m.len_ft } : {}
3486
4017
  })),
3487
4018
  count: rows.length,
3488
4019
  unattached: rows.filter((m) => !m.condition_id).length
@@ -3745,6 +4276,7 @@ var oneClickOutput = {
3745
4276
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
3746
4277
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
3747
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"),
3748
4280
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
3749
4281
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
3750
4282
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
@@ -3759,6 +4291,7 @@ var detectedRoom = z.object({
3759
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`"),
3760
4292
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
3761
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"),
3762
4295
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
3763
4296
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
3764
4297
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
@@ -3811,6 +4344,33 @@ var placeCountOutput = {
3811
4344
  condition: z.string(),
3812
4345
  ea_total: z.number().describe("The condition's total EA after this call")
3813
4346
  };
4347
+ var sweepPlacement = {
4348
+ at: z.tuple([z.number(), z.number()]).describe("The placed symbol's centroid (image px) \u2014 the point a commit places its count marker at"),
4349
+ score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
4350
+ rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
4351
+ mirrored: z.boolean()
4352
+ };
4353
+ var symbolSweepOutput = {
4354
+ found: z.number().int().describe("Placements that cleared the commit bar \u2014 matches.length"),
4355
+ matches: z.array(z.object(sweepPlacement)).describe("Deterministic reading order (y, then x). The seed's own location is never listed here"),
4356
+ withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })).describe("Near-matches in the [0.75, 0.92) band \u2014 reported with a reason, NEVER committed. A withheld placement is a question you can answer with view_sheet; a hidden one is a miscount"),
4357
+ seed: z.object({
4358
+ segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
4359
+ center: z.tuple([z.number(), z.number()]).describe("The seed instance's own centroid (image px) \u2014 reported here, never double-committed as a match"),
4360
+ rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
4361
+ length_px: z.number().describe("Total seed linework length, image px")
4362
+ }),
4363
+ candidates: z.object({
4364
+ considered: z.number().int(),
4365
+ dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
4366
+ }),
4367
+ committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
4368
+ shape_ids: z.array(z.string()).optional(),
4369
+ condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
4370
+ ea_total: z.number().optional().describe("commit mode: the condition's total EA after this call"),
4371
+ note: z.string().optional(),
4372
+ warning: z.string().optional().describe("Present when the work cap dropped candidates \u2014 what a tighter seed rect would recover")
4373
+ };
3814
4374
  var measureLineOutput = {
3815
4375
  length_lf: z.number(),
3816
4376
  npts: z.number().int(),
@@ -4129,9 +4689,10 @@ var annotationRow = z.object({
4129
4689
  at: z.tuple([z.number(), z.number()]).optional(),
4130
4690
  target: z.tuple([z.number(), z.number()]).optional(),
4131
4691
  rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional(),
4132
- from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail (image px)"),
4133
- to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head (image px)"),
4134
- r: z.number().optional().describe("Bubble radius (image px)")
4692
+ from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail / dimension start (image px)"),
4693
+ to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head / dimension end (image px)"),
4694
+ r: z.number().optional().describe("Bubble radius (image px)"),
4695
+ length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
4135
4696
  });
4136
4697
  var annotateOutput = {
4137
4698
  id: z.string(),
@@ -4140,6 +4701,7 @@ var annotateOutput = {
4140
4701
  text: z.string(),
4141
4702
  condition: z.string(),
4142
4703
  condition_id: z.string(),
4704
+ length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
4143
4705
  note: z.string()
4144
4706
  };
4145
4707
  var listAnnotationsOutput = {
@@ -4158,6 +4720,24 @@ var linkAnnotationOutput = {
4158
4720
  import path3 from "node:path";
4159
4721
  import { readFile as readFile2, writeFile } from "node:fs/promises";
4160
4722
 
4723
+ // ../web/src/lib/approvals.js
4724
+ var APPROVAL_R = 0.022;
4725
+ var APPROVAL_INK = {
4726
+ estimator: { light: "#1f6b4a", dark: "#55b083" },
4727
+ agent: { light: "#6c6a5e", dark: "#9d9a8c" }
4728
+ };
4729
+ function approvalInk(actor, dark = false) {
4730
+ const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
4731
+ return dark ? c.dark : c.light;
4732
+ }
4733
+ function approvalTally(approvals) {
4734
+ const t = { estimator: 0, agent: 0 };
4735
+ for (const a of Array.isArray(approvals) ? approvals : []) {
4736
+ if (a && a.actor in t) t[a.actor] += 1;
4737
+ }
4738
+ return t;
4739
+ }
4740
+
4161
4741
  // ../web/src/lib/svgpath.js
4162
4742
  function isSep(c) {
4163
4743
  return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
@@ -4813,7 +5393,7 @@ function invertPixels(cv) {
4813
5393
  ctx.fillRect(0, 0, cv.width, cv.height);
4814
5394
  ctx.restore();
4815
5395
  }
4816
- async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
5396
+ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, approvals = [], rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
4817
5397
  const M = units === "metric";
4818
5398
  const uA = (sf) => M ? sf * 0.09290304 : sf;
4819
5399
  const uL = (lf) => M ? lf * 0.3048 : lf;
@@ -4830,8 +5410,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4830
5410
  }
4831
5411
  return m;
4832
5412
  };
4833
- const shapesBy = byKey(shapes), marksBy = byKey(markups);
4834
- const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length);
5413
+ const shapesBy = byKey(shapes), marksBy = byKey(markups), apBy = byKey(approvals);
5414
+ const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length || (apBy.get(sh.key) || []).length);
4835
5415
  if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
4836
5416
  const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
4837
5417
  const doc = await PDFDocument.create();
@@ -4907,6 +5487,11 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4907
5487
  metaY -= 12;
4908
5488
  draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
4909
5489
  }
5490
+ const apCount = approvalTally(marked.flatMap((sh) => apBy.get(sh.key) || []));
5491
+ if (apCount.estimator || apCount.agent) {
5492
+ metaY -= 12;
5493
+ draw(`Approval stamps: ${apCount.estimator} estimator-approved \xB7 ${apCount.agent} agent-marked`, { x: 52, y: metaY, size: 9.5, font, color: muted });
5494
+ }
4910
5495
  let y = metaY - 34;
4911
5496
  const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
4912
5497
  draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
@@ -4935,7 +5520,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4935
5520
  for (const sh of marked) {
4936
5521
  if (y < 90) break;
4937
5522
  const items = shapesBy.get(sh.key) || [];
4938
- draw(`${sh.label} \xB7 page ${sh.page} \xB7 ${items.length + (marksBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
5523
+ draw(`${sh.label} \xB7 page ${sh.page} \xB7 ${items.length + (marksBy.get(sh.key) || []).length + (apBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
4939
5524
  y -= 13;
4940
5525
  for (const r of bySheetId.get(sh.key)?.rows || []) {
4941
5526
  if (y < 92) break;
@@ -5177,6 +5762,21 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5177
5762
  pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
5178
5763
  const t = lbl(m.text);
5179
5764
  if (t) text(t, (m.from[0] + m.to[0]) / 2 * W, (m.from[1] + m.to[1]) / 2 * H - 6 / ptScale, 8, mcol, bold);
5765
+ } else if (m.type === "dimension" && m.from && m.to) {
5766
+ const fx = m.from[0] * W, fy = m.from[1] * H, dxq = m.to[0] * W, dyq = m.to[1] * H;
5767
+ line(fx, fy, dxq, dyq, mcol, 1.1 * mw, 0.95, mdash);
5768
+ const dl = Math.hypot(dxq - fx, dyq - fy) || 1;
5769
+ const dnx = -(dyq - fy) / dl, dny = (dxq - fx) / dl;
5770
+ const tk = 5 / ptScale;
5771
+ line(fx - dnx * tk, fy - dny * tk, fx + dnx * tk, fy + dny * tk, mcol, 1.1 * mw, 0.95);
5772
+ line(dxq - dnx * tk, dyq - dny * tk, dxq + dnx * tk, dyq + dny * tk, mcol, 1.1 * mw, 0.95);
5773
+ const t = lbl([Number(m.len_ft) > 0 ? dimLabel(m.len_ft, M ? "metric" : "imperial") : "", m.text].filter(Boolean).join(" \xB7 "));
5774
+ if (t) {
5775
+ const size = 8;
5776
+ const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
5777
+ const [pmx, pmy] = toPage((fx + dxq) / 2 + dnx * (14 / ptScale), (fy + dyq) / 2 + dny * (14 / ptScale));
5778
+ pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
5779
+ }
5180
5780
  } else if (m.type === "bubble" && m.at) {
5181
5781
  const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
5182
5782
  const [pcx, pcy] = toPage(cxImg, cyImg);
@@ -5213,6 +5813,26 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5213
5813
  text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
5214
5814
  }
5215
5815
  }
5816
+ for (const a of apBy.get(sh.key) || []) {
5817
+ const isAgent = a.actor === "agent";
5818
+ const acol = rgb(...hex(approvalInk(a.actor, dark)));
5819
+ const cxImg = a.at[0] * W, cyImg = a.at[1] * H;
5820
+ const rImg = APPROVAL_R * W, rPt = rImg * ptScale;
5821
+ const [pcx, pcy] = toPage(cxImg, cyImg);
5822
+ const backing = dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1);
5823
+ if (isAgent) {
5824
+ const dia = (k) => [[cxImg, cyImg - rImg * k], [cxImg + rImg * k, cyImg], [cxImg, cyImg + rImg * k], [cxImg - rImg * k, cyImg]];
5825
+ pg.drawSvgPath(svgPath(dia(1)), { x: 0, y: 0, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
5826
+ pg.drawSvgPath(svgPath(dia(0.72)), { x: 0, y: 0, borderColor: acol, borderWidth: rPt * 0.035 });
5827
+ } else {
5828
+ pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt, yScale: rPt, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
5829
+ pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt * 0.78, yScale: rPt * 0.78, borderColor: acol, borderWidth: rPt * 0.035 });
5830
+ }
5831
+ const label = isAgent ? "AGENT" : "APPROVED";
5832
+ const size = rPt * (isAgent ? 0.3 : 0.26);
5833
+ const tw = bold.widthOfTextAtSize(label, size);
5834
+ pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
5835
+ }
5216
5836
  text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
5217
5837
  }
5218
5838
  const allPages = doc.getPages();
@@ -5498,7 +6118,7 @@ function registerTools(server, session) {
5498
6118
  return loaded;
5499
6119
  }));
5500
6120
  server.registerTool("sheet_info", {
5501
- 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}`,
5502
6122
  inputSchema: { sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') },
5503
6123
  outputSchema: sheetInfoOutput
5504
6124
  }, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
@@ -5518,7 +6138,7 @@ function registerTools(server, session) {
5518
6138
  return session.setScale(a.sheet, a);
5519
6139
  }));
5520
6140
  server.registerTool("one_click", {
5521
- 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}`,
5522
6142
  inputSchema: {
5523
6143
  sheet: z2.string(),
5524
6144
  x: z2.number(),
@@ -5532,7 +6152,7 @@ function registerTools(server, session) {
5532
6152
  outputSchema: oneClickOutput
5533
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 })));
5534
6154
  server.registerTool("detect_rooms", {
5535
- 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}`,
5536
6156
  inputSchema: {
5537
6157
  sheet: z2.string(),
5538
6158
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
@@ -5588,6 +6208,26 @@ function registerTools(server, session) {
5588
6208
  },
5589
6209
  outputSchema: placeCountOutput
5590
6210
  }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
6211
+ server.registerTool("symbol_sweep", {
6212
+ description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the sheet's vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Work is capped and the cap is disclosed: a reply with candidates.dropped > 0 says exactly that some placements were never scored \u2014 tighten the seed rect around more distinctive geometry rather than trusting a truncated count. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep is ONE undo step, each marker carries origin.method "symbol_sweep" with its score and transform, and withheld placements are NEVER committed. No scale needed: EA is scale-free. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
6213
+ inputSchema: {
6214
+ sheet: z2.string(),
6215
+ seed_rect: z2.tuple([pointSchema, pointSchema]).describe("Marquee around ONE example instance, [[x0,y0],[x1,y1]] in image px \u2014 tight: segments fully inside define the symbol"),
6216
+ condition: z2.string().optional().describe("Finish tag to commit match markers under (minted on first use), e.g. 'FD-1'. Required when commit is true"),
6217
+ commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
6218
+ rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
6219
+ mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
6220
+ tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
6221
+ },
6222
+ outputSchema: symbolSweepOutput
6223
+ }, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
6224
+ seedRect: a.seed_rect,
6225
+ condition: a.condition,
6226
+ commit: a.commit,
6227
+ rotations: a.rotations,
6228
+ mirror: a.mirror,
6229
+ tolerancePx: a.tolerance_px
6230
+ })));
5591
6231
  server.registerTool("derive_base", {
5592
6232
  description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
5593
6233
  inputSchema: {
@@ -5785,21 +6425,21 @@ function registerTools(server, session) {
5785
6425
  return reply;
5786
6426
  });
5787
6427
  server.registerTool("annotate", {
5788
- description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text).
6428
+ description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text), dimension takes from:[x,y] and to:[x,y] (its two measured endpoints) and labels itself with the length between them at the sheet's scale \u2014 drawn as a dimension line with end ticks and the measurement centered. A dimension states a REAL length, so it is the one annotation the scale gate applies to: on an unscaled sheet it refuses exactly like the measure tools (set_scale first) rather than dressing a px figure up as feet. It still touches no quantity \u2014 a dimension is a note about a distance, not a takeoff line item.
5789
6429
 
5790
6430
  Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like one_click/measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
5791
6431
 
5792
6432
  No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
5793
6433
  inputSchema: {
5794
6434
  sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
5795
- type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow needs from + to"),
5796
- text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'; a bubble's text draws centered in the circle"),
6435
+ type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble", "dimension"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow and dimension need from + to"),
6436
+ text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'; a bubble's text draws centered in the circle; a dimension appends it after the measured length"),
5797
6437
  condition: z2.string().optional().describe("Finish tag to attach this note to, e.g. 'CPT-1' (minted on first use). Omit for an unattached sheet note"),
5798
6438
  at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
5799
6439
  target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
5800
6440
  rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight"),
5801
- from: pointSchema.optional().describe("Arrow tail (image px)"),
5802
- to: pointSchema.optional().describe("Arrow head \u2014 what it points at (image px)"),
6441
+ from: pointSchema.optional().describe("Arrow tail / dimension start (image px)"),
6442
+ to: pointSchema.optional().describe("Arrow head / dimension end (image px)"),
5803
6443
  r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
5804
6444
  },
5805
6445
  outputSchema: annotateOutput
@@ -5901,7 +6541,7 @@ function registerResources(server, session) {
5901
6541
  // package.json
5902
6542
  var package_default = {
5903
6543
  name: "opentakeoff-mcp",
5904
- version: "0.9.19",
6544
+ version: "0.9.21",
5905
6545
  mcpName: "io.github.Kentucky-ai/opentakeoff",
5906
6546
  type: "module",
5907
6547
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -5917,7 +6557,7 @@ var package_default = {
5917
6557
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
5918
6558
  prepublishOnly: "npm run typecheck && npm test && npm run build",
5919
6559
  typecheck: "tsc --noEmit",
5920
- 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"
5921
6561
  },
5922
6562
  dependencies: {
5923
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.19",
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",