opentakeoff-mcp 0.9.18 → 0.9.20

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
@@ -109,12 +109,13 @@ includes document text, shape vertices, or result payload content.
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
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. |
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), never dropped silently. Pass `condition` to commit every detected room. |
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. |
@@ -123,10 +124,10 @@ includes document text, shape vertices, or result payload content.
123
124
  | `edit_condition` | Set a condition's **waste %**, **×N multiplier**, **height_ft** (the H knob `measure_surface` quantifies against), and **roll_setup** (the roll-goods opt-in: seams figured, cuts packed, the reply echoes the order — cuts, `order_lf`, rolls, `order_qty` — and `export_report`'s `roll_goods` block carries the same rows; `null` opts out) — the knobs that turn measured quantities into order quantities. Resolves an **existing** finish tag or errors — a typo must not mint an empty condition. No review gate; one `undo_last` step restores the knobs verbatim. |
124
125
  | `export_report` | The **computed Report document** — `opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers — `export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path`. |
125
126
  | `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import — finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. |
126
- | `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. Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
127
- | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, and review state 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. |
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`. |
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. |
@@ -1316,6 +1316,235 @@ var fail = (err) => ({
1316
1316
  var round2 = (n) => +n.toFixed(2);
1317
1317
  var round1 = (n) => +n.toFixed(1);
1318
1318
 
1319
+ // ../web/src/lib/symbolsweep.ts
1320
+ var SWEEP_TOL_PX = 2;
1321
+ var SWEEP_SCORE_HIGH = 0.92;
1322
+ var SWEEP_SCORE_LOW = 0.75;
1323
+ var SWEEP_MAX_CANDIDATES = 2e4;
1324
+ var ANCHOR_COUNT = 3;
1325
+ var MIN_SEG_LEN = 0.5;
1326
+ var MAX_SEED_SEGS = 2e3;
1327
+ function transformsFor(rotations, mirror) {
1328
+ const rots = [
1329
+ [0, [1, 0, 0, 1]],
1330
+ [90, [0, -1, 1, 0]],
1331
+ [180, [-1, 0, 0, -1]],
1332
+ [270, [0, 1, -1, 0]]
1333
+ ];
1334
+ const use = rotations ? rots : rots.slice(0, 1);
1335
+ const out = use.map(([rotation, m]) => ({ rotation, mirrored: false, m }));
1336
+ if (mirror) {
1337
+ for (const [rotation, m] of use) out.push({ rotation, mirrored: true, m: [-m[0], m[1], -m[2], m[3]] });
1338
+ }
1339
+ return out;
1340
+ }
1341
+ var apply = (m, x, y) => [m[0] * x + m[1] * y, m[2] * x + m[3] * y];
1342
+ var EndpointGrid = class {
1343
+ constructor(segs, tol) {
1344
+ this.segs = segs;
1345
+ this.cell = Math.max(2 * tol, 4);
1346
+ const n = segs.length >> 2;
1347
+ for (let i = 0; i < n; i++) {
1348
+ this.add(segs[i * 4], segs[i * 4 + 1], i);
1349
+ this.add(segs[i * 4 + 2], segs[i * 4 + 3], i);
1350
+ }
1351
+ }
1352
+ cells = /* @__PURE__ */ new Map();
1353
+ cell;
1354
+ key(cx, cy) {
1355
+ return cx * 73856093 ^ cy * 19349663;
1356
+ }
1357
+ add(x, y, i) {
1358
+ const k = this.key(Math.floor(x / this.cell), Math.floor(y / this.cell));
1359
+ const a = this.cells.get(k);
1360
+ if (a) {
1361
+ if (a[a.length - 1] !== i) a.push(i);
1362
+ } else this.cells.set(k, [i]);
1363
+ }
1364
+ /** Indices of segments with an endpoint within one cell of (x, y). */
1365
+ near(x, y, out) {
1366
+ out.length = 0;
1367
+ const cx = Math.floor(x / this.cell), cy = Math.floor(y / this.cell);
1368
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
1369
+ const a = this.cells.get(this.key(cx + dx, cy + dy));
1370
+ if (a) for (const i of a) out.push(i);
1371
+ }
1372
+ return out;
1373
+ }
1374
+ };
1375
+ var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
1376
+ function sweepSymbols(segs, seedRect, opts = {}) {
1377
+ const tol = opts.tolPx ?? SWEEP_TOL_PX;
1378
+ const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
1379
+ const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
1380
+ const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
1381
+ const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
1382
+ const n = segs.length >> 2;
1383
+ const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
1384
+ const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
1385
+ const inside = (x, y) => x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1;
1386
+ const seedIdx = [];
1387
+ for (let i = 0; i < n; i++) {
1388
+ if (inside(segs[i * 4], segs[i * 4 + 1]) && inside(segs[i * 4 + 2], segs[i * 4 + 3]) && segLen(segs, i) >= MIN_SEG_LEN) {
1389
+ seedIdx.push(i);
1390
+ }
1391
+ }
1392
+ if (!seedIdx.length) {
1393
+ 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).");
1394
+ }
1395
+ if (seedIdx.length > MAX_SEED_SEGS) {
1396
+ throw new Error(`The seed rect holds ${seedIdx.length} segments \u2014 that is a region, not one symbol instance. Marquee a single symbol.`);
1397
+ }
1398
+ let totalLen = 0, cxw = 0, cyw = 0;
1399
+ for (const i of seedIdx) {
1400
+ const L = segLen(segs, i);
1401
+ totalLen += L;
1402
+ cxw += (segs[i * 4] + segs[i * 4 + 2]) / 2 * L;
1403
+ cyw += (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2 * L;
1404
+ }
1405
+ const seedCx = cxw / totalLen, seedCy = cyw / totalLen;
1406
+ const rel = seedIdx.map((i) => [
1407
+ segs[i * 4] - seedCx,
1408
+ segs[i * 4 + 1] - seedCy,
1409
+ segs[i * 4 + 2] - seedCx,
1410
+ segs[i * 4 + 3] - seedCy,
1411
+ segLen(segs, i)
1412
+ ]);
1413
+ let sbx0 = Infinity, sby0 = Infinity, sbx1 = -Infinity, sby1 = -Infinity;
1414
+ for (const i of seedIdx) {
1415
+ sbx0 = Math.min(sbx0, segs[i * 4], segs[i * 4 + 2]);
1416
+ sby0 = Math.min(sby0, segs[i * 4 + 1], segs[i * 4 + 3]);
1417
+ sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
1418
+ sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
1419
+ }
1420
+ const lenBucket = /* @__PURE__ */ new Map();
1421
+ for (let i = 0; i < n; i++) {
1422
+ const b = Math.round(segLen(segs, i));
1423
+ const a = lenBucket.get(b);
1424
+ if (a) a.push(i);
1425
+ else lenBucket.set(b, [i]);
1426
+ }
1427
+ const bucketBand = (L) => {
1428
+ const out = [];
1429
+ for (let b = Math.floor(L - 2 * tol); b <= Math.ceil(L + 2 * tol); b++) {
1430
+ const a = lenBucket.get(b);
1431
+ if (a) {
1432
+ for (const i of a) if (Math.abs(segLen(segs, i) - L) <= 2 * tol) out.push(i);
1433
+ }
1434
+ }
1435
+ return out;
1436
+ };
1437
+ const byLenQ = /* @__PURE__ */ new Map();
1438
+ for (let k = 0; k < rel.length; k++) {
1439
+ const L = rel[k][4];
1440
+ const q = Math.round(L);
1441
+ if (!byLenQ.has(q)) byLenQ.set(q, { relIdx: k, len: L, rarity: bucketBand(L).length });
1442
+ }
1443
+ const anchors = [...byLenQ.values()].sort((a, b) => a.rarity - b.rarity || b.len - a.len || a.relIdx - b.relIdx).slice(0, ANCHOR_COUNT);
1444
+ const proposals = [];
1445
+ const seen = /* @__PURE__ */ new Set();
1446
+ const quant = Math.max(tol, 1);
1447
+ for (let xi = 0; xi < xforms.length; xi++) {
1448
+ const { m } = xforms[xi];
1449
+ for (const anc of anchors) {
1450
+ const r = rel[anc.relIdx];
1451
+ const A = apply(m, r[0], r[1]);
1452
+ const B = apply(m, r[2], r[3]);
1453
+ for (const j of bucketBand(anc.len)) {
1454
+ const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
1455
+ for (const [ax, ay, bx, by] of [[A[0], A[1], B[0], B[1]], [B[0], B[1], A[0], A[1]]]) {
1456
+ const c1x = px - ax, c1y = py - ay;
1457
+ const c2x = qx - bx, c2y = qy - by;
1458
+ if (Math.abs(c1x - c2x) > 2 * tol || Math.abs(c1y - c2y) > 2 * tol) continue;
1459
+ const tx = (c1x + c2x) / 2, ty = (c1y + c2y) / 2;
1460
+ const key = `${xi}:${Math.round(tx / quant)}:${Math.round(ty / quant)}`;
1461
+ if (seen.has(key)) continue;
1462
+ seen.add(key);
1463
+ proposals.push({ tx, ty, xf: xi });
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+ proposals.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.xf - b.xf);
1469
+ const considered = Math.min(proposals.length, maxCandidates);
1470
+ const dropped = proposals.length - considered;
1471
+ const grid = new EndpointGrid(segs, tol);
1472
+ const scratch = [];
1473
+ const tol2 = tol * tol;
1474
+ const near = (x1, y1, x2, y2) => (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) <= tol2;
1475
+ const scoreAt = (m, tx, ty) => {
1476
+ let matched = 0;
1477
+ for (const r of rel) {
1478
+ const a = apply(m, r[0], r[1]);
1479
+ const b = apply(m, r[2], r[3]);
1480
+ const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
1481
+ let hit = false;
1482
+ for (const j of grid.near(ax, ay, scratch)) {
1483
+ const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
1484
+ if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
1485
+ hit = true;
1486
+ break;
1487
+ }
1488
+ }
1489
+ if (hit) matched += r[4];
1490
+ }
1491
+ return matched / totalLen;
1492
+ };
1493
+ const scored = [];
1494
+ for (let k = 0; k < considered; k++) {
1495
+ const c = proposals[k];
1496
+ const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
1497
+ if (score < scoreLow) continue;
1498
+ scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
1499
+ }
1500
+ const mergeR = Math.max(2 * tol, 4);
1501
+ const kept = [];
1502
+ for (const s of scored) {
1503
+ const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
1504
+ if (!twin) {
1505
+ kept.push(s);
1506
+ continue;
1507
+ }
1508
+ if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
1509
+ twin.at = s.at;
1510
+ twin.score = s.score;
1511
+ twin.rotation = s.rotation;
1512
+ twin.mirrored = s.mirrored;
1513
+ twin.xf = s.xf;
1514
+ }
1515
+ }
1516
+ const suppressR = Math.max(mergeR, Math.hypot(sbx1 - sbx0, sby1 - sby0) / 2);
1517
+ const away = kept.filter((s) => Math.hypot(s.at[0] - seedCx, s.at[1] - seedCy) > suppressR);
1518
+ const matches = [];
1519
+ const withheld = [];
1520
+ const pct = (v) => Math.round(v * 1e3) / 1e3;
1521
+ const row = (s) => ({
1522
+ at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
1523
+ score: pct(s.score),
1524
+ rotation: s.rotation,
1525
+ mirrored: s.mirrored
1526
+ });
1527
+ for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
1528
+ for (const s of away) {
1529
+ if (s.score >= scoreHigh) continue;
1530
+ if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
1531
+ 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` });
1532
+ }
1533
+ 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);
1534
+ matches.sort(order);
1535
+ withheld.sort(order);
1536
+ return {
1537
+ seed: {
1538
+ segments: seedIdx.length,
1539
+ center: [Math.round(seedCx * 10) / 10, Math.round(seedCy * 10) / 10],
1540
+ length_px: Math.round(totalLen * 10) / 10
1541
+ },
1542
+ matches,
1543
+ withheld,
1544
+ candidates: { considered, dropped }
1545
+ };
1546
+ }
1547
+
1319
1548
  // ../web/src/lib/geometry.js
1320
1549
  function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
1321
1550
  const r = R * innerRatio;
@@ -1463,6 +1692,21 @@ function attrValue(attrs, colId) {
1463
1692
  return visible(attrs?.[colId]);
1464
1693
  }
1465
1694
 
1695
+ // ../web/src/lib/units.ts
1696
+ var M_PER_FT = 0.3048;
1697
+ function dimLabel(feet, units = "imperial") {
1698
+ if (!Number.isFinite(feet)) return "";
1699
+ if (units === "metric") return `${(feet * M_PER_FT).toFixed(2)} m`;
1700
+ const sign = feet < 0 ? "-" : "";
1701
+ let ft = Math.floor(Math.abs(feet) + 1e-9);
1702
+ let inch = Math.round((Math.abs(feet) - ft) * 12);
1703
+ if (inch === 12) {
1704
+ ft += 1;
1705
+ inch = 0;
1706
+ }
1707
+ return `${sign}${ft}'-${inch}"`;
1708
+ }
1709
+
1466
1710
  // ../web/src/lib/totals.js
1467
1711
  function accumulateRole(acc, s) {
1468
1712
  const cp = s.computed || {};
@@ -2292,6 +2536,12 @@ var Session = class _Session {
2292
2536
  conditions = [];
2293
2537
  markups = [];
2294
2538
  shapes = [];
2539
+ /** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
2540
+ * marked-set cover discloses as withheld. Replaced per assign run, cleared
2541
+ * with the rest of the session on a non-merge load_plan. Seeds ride
2542
+ * normalized so the export-time staleness drop can test them against
2543
+ * committed rings in verts_norm space. */
2544
+ scheduleWithheld = [];
2295
2545
  /** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
2296
2546
  journal = [];
2297
2547
  seq = 0;
@@ -2331,6 +2581,7 @@ var Session = class _Session {
2331
2581
  this.file = null;
2332
2582
  this.filePath = null;
2333
2583
  this.nextOrd = 1;
2584
+ this.scheduleWithheld = [];
2334
2585
  this.journal = [];
2335
2586
  this.pendingCommits = [];
2336
2587
  } else if (this.docs.has(base)) {
@@ -2734,6 +2985,7 @@ var Session = class _Session {
2734
2985
  return c;
2735
2986
  }
2736
2987
  commit(s, tag, role, vertsPx, computed, origin) {
2988
+ if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
2737
2989
  const c = this.conditionFor(tag);
2738
2990
  const shape = {
2739
2991
  id: uid("shp"),
@@ -2830,6 +3082,16 @@ var Session = class _Session {
2830
3082
  * judge and nothing commits anyway. */
2831
3083
  async detectRooms(name, opts) {
2832
3084
  const s = this.sheet(name);
3085
+ const assign = !!opts.assignFromSchedule;
3086
+ let graph = null;
3087
+ if (assign) {
3088
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
3089
+ graph = await this.ensureGraph();
3090
+ if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
3091
+ if (!graph.tables.some((t) => t.kind === "room-finish")) {
3092
+ 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
+ }
3094
+ }
2833
3095
  const mask = await this.maskWithLayers(name, opts.layers);
2834
3096
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
2835
3097
  const minAreaSf = opts.minAreaSf ?? 5;
@@ -2839,7 +3101,8 @@ var Session = class _Session {
2839
3101
  const num2 = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
2840
3102
  if (num2) labels.push({ str: num2, bbox: sp });
2841
3103
  }
2842
- const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0 };
3104
+ const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0, unresolved: 0 };
3105
+ const unresolved = [];
2843
3106
  const byRing = /* @__PURE__ */ new Map();
2844
3107
  const order = [];
2845
3108
  for (const lb of labels) {
@@ -2907,21 +3170,42 @@ var Session = class _Session {
2907
3170
  return null;
2908
3171
  }
2909
3172
  const perimeter_lf = round2(c.perimPx * upp);
3173
+ let tag = opts.condition;
3174
+ let assignment;
3175
+ if (assign) {
3176
+ const hit = this.floorTagFor(graph, c.label);
3177
+ if ("reason" in hit) {
3178
+ withheld.unresolved++;
3179
+ unresolved.push({ label: c.label, reason: hit.reason, area_sf, perimeter_lf, seed: [round1(c.seed[0]), round1(c.seed[1])] });
3180
+ return null;
3181
+ }
3182
+ tag = hit.tag;
3183
+ assignment = { source: "schedule", room_tag: c.label, surface: "FLOOR", schedule_sheet: hit.sheet };
3184
+ }
2910
3185
  let shape_id;
2911
- if (opts.condition) {
2912
- shape_id = this.commit(s, opts.condition, opts.role, c.ring, { area_sf, perimeter_lf }, {
3186
+ if (tag) {
3187
+ shape_id = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
2913
3188
  method: "one_click_v1",
2914
3189
  actor: "agent",
2915
3190
  seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
2916
3191
  reviewed: false,
2917
3192
  ...c.hatch ? { hatch_filtered: true } : {},
2918
- ...c.gap ? { gap_bridged_px: c.gap } : {}
3193
+ ...c.gap ? { gap_bridged_px: c.gap } : {},
3194
+ ...assignment ? { assignment } : {}
2919
3195
  }).id;
2920
3196
  }
2921
- return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
3197
+ return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
2922
3198
  }).filter((r) => r !== null);
2923
3199
  this.flushCommits("detect_rooms");
2924
- const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible;
3200
+ if (assign) {
3201
+ this.scheduleWithheld = unresolved.map((u) => ({
3202
+ sheet_id: s.key,
3203
+ label: u.label,
3204
+ reason: u.reason,
3205
+ seed_norm: [u.seed[0] / s.widthPx, u.seed[1] / s.heightPx]
3206
+ }));
3207
+ }
3208
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible + withheld.unresolved;
2925
3209
  return {
2926
3210
  detected: rooms.length,
2927
3211
  rooms,
@@ -2930,8 +3214,11 @@ var Session = class _Session {
2930
3214
  ...withheld,
2931
3215
  ...upp != null ? { min_area_sf: minAreaSf } : {}
2932
3216
  },
3217
+ // assign mode always states the answer, empty array included: [] is the
3218
+ // positive claim "every detected room resolved against its own row"
3219
+ ...assign ? { unresolved } : {},
2933
3220
  ...s.detected?.multi ? { multiple_scales: true } : {},
2934
- ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable.` } : {},
3221
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
2935
3222
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
2936
3223
  };
2937
3224
  }
@@ -3047,15 +3334,99 @@ var Session = class _Session {
3047
3334
  * computed {count: 1}, NO scale required (EA is scale-free; the canvas's
3048
3335
  * recompute skips count shapes for the same reason). One shape per point,
3049
3336
  * the whole call one journal gesture — undoing a placement sweep is one step,
3050
- * matching detect_rooms. */
3337
+ * matching detect_rooms.
3338
+ *
3339
+ * `origins` (optional, aligned with points) lets a derived tool commit
3340
+ * through this same path while telling the truth about the method —
3341
+ * symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
3342
+ * marker; a bare place_count stays exactly the manual agent gesture it is. */
3051
3343
  placeCount(name, points, opts) {
3052
3344
  const s = this.sheet(name);
3053
- const ids = points.map(([x, y]) => this.commit(s, opts.condition, "count", [[x, y]], { count: 1 }, { method: "manual", actor: "agent" }).id);
3054
- this.flushCommits("place_count");
3345
+ const ids = points.map(([x, y], i) => this.commit(
3346
+ s,
3347
+ opts.condition,
3348
+ "count",
3349
+ [[x, y]],
3350
+ { count: 1 },
3351
+ opts.origins?.[i] ? { ...opts.origins[i] } : { method: "manual", actor: "agent" }
3352
+ ).id);
3353
+ this.flushCommits(opts.tool ?? "place_count");
3055
3354
  const c = this.conditions.find((x) => x.finish_tag === opts.condition);
3056
3355
  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);
3057
3356
  return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
3058
3357
  }
3358
+ /** symbol_sweep — every placement of ONE example symbol, from the linework.
3359
+ * The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
3360
+ * rect's segments, propose placements by constellation anchoring under the
3361
+ * square symmetry group, score each as the length-weighted fraction of seed
3362
+ * segments reproduced within tolerance. This method is the plumbing plus
3363
+ * the wire shapes: rect clamping, the scan refusal, and the commit path —
3364
+ * match centers through placeCount (ONE undo step, EA scale-free), origins
3365
+ * telling the truth (`method: "symbol_sweep"`, per-match score/transform),
3366
+ * withheld placements NEVER committed. */
3367
+ async symbolSweep(name, opts) {
3368
+ const s = this.sheet(name);
3369
+ if (opts.commit && !opts.condition) {
3370
+ throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
3371
+ }
3372
+ const geo = await this.ensureGeometry(s);
3373
+ if (!geo.segs.length) {
3374
+ 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.");
3375
+ }
3376
+ const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
3377
+ const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
3378
+ const rect = [
3379
+ [clampX(opts.seedRect[0][0]), clampY(opts.seedRect[0][1])],
3380
+ [clampX(opts.seedRect[1][0]), clampY(opts.seedRect[1][1])]
3381
+ ];
3382
+ if (!(Math.abs(rect[1][0] - rect[0][0]) >= 1 && Math.abs(rect[1][1] - rect[0][1]) >= 1)) {
3383
+ throw new UserError(`Empty seed rect \u2014 need two distinct corners in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
3384
+ }
3385
+ const sweepOpts = {
3386
+ rotations: opts.rotations ?? true,
3387
+ mirror: opts.mirror ?? true,
3388
+ tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
3389
+ };
3390
+ let res;
3391
+ try {
3392
+ res = sweepSymbols(geo.segs, rect, sweepOpts);
3393
+ } catch (e) {
3394
+ throw new UserError(e instanceof Error ? e.message : String(e));
3395
+ }
3396
+ let committed;
3397
+ if (opts.commit && res.matches.length) {
3398
+ committed = this.placeCount(name, res.matches.map((m) => m.at), {
3399
+ condition: opts.condition,
3400
+ tool: "symbol_sweep",
3401
+ origins: res.matches.map((m) => ({
3402
+ method: "symbol_sweep",
3403
+ actor: "agent",
3404
+ reviewed: false,
3405
+ symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored }
3406
+ }))
3407
+ });
3408
+ }
3409
+ return {
3410
+ found: res.matches.length,
3411
+ matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
3412
+ 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 })),
3413
+ seed: {
3414
+ segments: res.seed.segments,
3415
+ center: [round1(res.seed.center[0]), round1(res.seed.center[1])],
3416
+ rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
3417
+ length_px: res.seed.length_px
3418
+ },
3419
+ candidates: res.candidates,
3420
+ ...committed ? {
3421
+ committed: committed.committed,
3422
+ shape_ids: committed.shape_ids,
3423
+ condition: committed.condition,
3424
+ ea_total: committed.ea_total
3425
+ } : {},
3426
+ ...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
3427
+ ...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.` } : {}
3428
+ };
3429
+ }
3059
3430
  /** The mid-session shape inventory (#149): every committed shape's id,
3060
3431
  * home, role, and quantities in one compact read — the ids edit_shape /
3061
3432
  * delete_shape assume you have, without pulling the whole export_takeoff
@@ -3085,6 +3456,7 @@ var Session = class _Session {
3085
3456
  ...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
3086
3457
  nverts: x.verts_norm.length,
3087
3458
  reviewed: x.origin?.reviewed === true,
3459
+ ...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
3088
3460
  ...x.origin?.agent_edits ? { agent_edits: x.origin.agent_edits } : {}
3089
3461
  })),
3090
3462
  count: rows.length
@@ -3164,7 +3536,13 @@ var Session = class _Session {
3164
3536
  verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
3165
3537
  computed,
3166
3538
  ...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
3167
- ...cur.origin ? { origin: { ...cur.origin, agent_edits: (cur.origin.agent_edits ?? 0) + 1 } } : {}
3539
+ ...cur.origin ? { origin: {
3540
+ ...cur.origin,
3541
+ agent_edits: (cur.origin.agent_edits ?? 0) + 1,
3542
+ // a reassign onto a different tag is the agent choosing the finish —
3543
+ // keeping "schedule" (and its citation) past that point would be a lie
3544
+ ...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
3545
+ } } : {}
3168
3546
  };
3169
3547
  this.record({ op: "edit", tool: "edit_shape", before });
3170
3548
  const changed = [
@@ -3368,7 +3746,14 @@ var Session = class _Session {
3368
3746
  if ((a.type === "cloud" || a.type === "highlight") && !a.rect) throw new UserError(`a ${a.type} needs rect: [[x0,y0],[x1,y1]] in image px`);
3369
3747
  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`);
3370
3748
  if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
3371
- 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");
3749
+ if ((a.type === "arrow" || a.type === "dimension") && (!a.from || !a.to)) {
3750
+ 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");
3751
+ }
3752
+ let len_ft;
3753
+ if (a.type === "dimension") {
3754
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
3755
+ len_ft = round2(Math.hypot(a.to[0] - a.from[0], a.to[1] - a.from[1]) * s.upp);
3756
+ }
3372
3757
  const cond = a.condition ? this.conditionFor(a.condition) : null;
3373
3758
  const m = {
3374
3759
  id: uid("mk"),
@@ -3385,7 +3770,10 @@ var Session = class _Session {
3385
3770
  ...a.to ? { to: n(a.to) } : {},
3386
3771
  // bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
3387
3772
  // canvas default is 0.02 when unset — stored explicitly so exports agree
3388
- ...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {}
3773
+ ...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {},
3774
+ // dimension: the measured length rides the markup so the renderers
3775
+ // (canvas, marked set) draw the label without scale plumbing
3776
+ ...len_ft !== void 0 ? { len_ft } : {}
3389
3777
  };
3390
3778
  this.markups.push(m);
3391
3779
  return {
@@ -3395,6 +3783,7 @@ var Session = class _Session {
3395
3783
  text: m.text,
3396
3784
  condition: cond?.finish_tag ?? "",
3397
3785
  condition_id: m.condition_id,
3786
+ ...len_ft !== void 0 ? { length_lf: len_ft } : {},
3398
3787
  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."
3399
3788
  };
3400
3789
  }
@@ -3432,7 +3821,8 @@ var Session = class _Session {
3432
3821
  ...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
3433
3822
  ...m.from ? { from: px(m, m.from) } : {},
3434
3823
  ...m.to ? { to: px(m, m.to) } : {},
3435
- ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {}
3824
+ ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {},
3825
+ ...m.len_ft != null ? { length_lf: m.len_ft } : {}
3436
3826
  })),
3437
3827
  count: rows.length,
3438
3828
  unattached: rows.filter((m) => !m.condition_id).length
@@ -3534,6 +3924,24 @@ var Session = class _Session {
3534
3924
  counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
3535
3925
  };
3536
3926
  }
3927
+ /** The FLOOR finish a room's own schedule row states — assign-from-schedule's
3928
+ * per-room resolver (0.9.18): resolveTag's chain narrowed to the one surface
3929
+ * a floor takeoff commits. Refusal over guessing, per room: an unresolved
3930
+ * tag returns resolveTag's own reason; a resolved row with no FLOOR cell
3931
+ * says so; a compound cell ("CPT-1/VCT-1") is ambiguous — committing the
3932
+ * whole room's SF under a two-finish literal would assert an area split the
3933
+ * schedule never stated. Compound detection is narrow ("/", ",", " OR "),
3934
+ * so a hyphenated code never trips it. BASE/WALL are deliberately ignored:
3935
+ * those are derive_base's and measure_surface's measures. */
3936
+ floorTagFor(g, tag) {
3937
+ const res = resolveTag(g, tag);
3938
+ if (res.status !== "resolved") return { reason: res.reason };
3939
+ const floor = res.finishes.find((f) => f.surface === "FLOOR");
3940
+ const code = floor?.code.trim();
3941
+ if (!floor || !code) return { reason: `schedule row ${res.tag} states no FLOOR finish` };
3942
+ if (/[/,]|\bOR\b/i.test(code)) return { reason: `ambiguous: floor cell "${code}" names more than one finish with no stated split` };
3943
+ return { tag: code, sheet: floor.source.sheet };
3944
+ }
3537
3945
  async resolveRoomTag(tag) {
3538
3946
  if (!tag || !tag.trim()) throw new UserError('Pass a room tag, e.g. resolve_tag { tag: "134" }.');
3539
3947
  const g = await this.ensureGraph();
@@ -3695,6 +4103,7 @@ var detectedRoom = z.object({
3695
4103
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
3696
4104
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
3697
4105
  shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
4106
+ condition: z.string().optional().describe("The finish tag this room committed under \u2014 the passed condition, or in assign mode the FLOOR finish its own schedule row states. Present exactly when shape_id is"),
3698
4107
  area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
3699
4108
  perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
3700
4109
  });
@@ -3707,8 +4116,16 @@ var detectRoomsOutput = {
3707
4116
  duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
3708
4117
  bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
3709
4118
  implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
4119
+ unresolved: z.number().int().describe("Assign mode: rooms the schedule could not answer for (no row, no FLOOR cell, or a compound cell) \u2014 withheld into unresolved[], never committed under a guess. Always present; 0 outside assign mode"),
3710
4120
  min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
3711
4121
  }).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
4122
+ unresolved: z.array(z.object({
4123
+ label: z.string().describe("The room tag as drawn"),
4124
+ reason: z.string().describe(`WHY the schedule could not answer \u2014 resolveTag's own reason, "states no FLOOR finish", or "ambiguous: \u2026" for a compound cell`),
4125
+ area_sf: z.number().describe("The room's real traced area \u2014 withheld from committing, not from reporting"),
4126
+ perimeter_lf: z.number(),
4127
+ seed: z.tuple([z.number(), z.number()]).describe("The flood seed (image px) \u2014 once the estimator answers, one_click here with the stated condition commits it")
4128
+ })).optional().describe("Assign mode only, empty array included: [] is the positive claim that every detected room resolved against its own schedule row"),
3712
4129
  note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
3713
4130
  multiple_scales: z.literal(true).optional().describe("Several DISTINCT scale notes on this sheet (#153) \u2014 rooms inside an enlarged viewport may be figured at the wrong scale"),
3714
4131
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
@@ -3734,6 +4151,33 @@ var placeCountOutput = {
3734
4151
  condition: z.string(),
3735
4152
  ea_total: z.number().describe("The condition's total EA after this call")
3736
4153
  };
4154
+ var sweepPlacement = {
4155
+ 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"),
4156
+ score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
4157
+ rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
4158
+ mirrored: z.boolean()
4159
+ };
4160
+ var symbolSweepOutput = {
4161
+ found: z.number().int().describe("Placements that cleared the commit bar \u2014 matches.length"),
4162
+ matches: z.array(z.object(sweepPlacement)).describe("Deterministic reading order (y, then x). The seed's own location is never listed here"),
4163
+ 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"),
4164
+ seed: z.object({
4165
+ segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
4166
+ 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"),
4167
+ rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
4168
+ length_px: z.number().describe("Total seed linework length, image px")
4169
+ }),
4170
+ candidates: z.object({
4171
+ considered: z.number().int(),
4172
+ dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
4173
+ }),
4174
+ committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
4175
+ shape_ids: z.array(z.string()).optional(),
4176
+ condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
4177
+ ea_total: z.number().optional().describe("commit mode: the condition's total EA after this call"),
4178
+ note: z.string().optional(),
4179
+ warning: z.string().optional().describe("Present when the work cap dropped candidates \u2014 what a tighter seed rect would recover")
4180
+ };
3737
4181
  var measureLineOutput = {
3738
4182
  length_lf: z.number(),
3739
4183
  npts: z.number().int(),
@@ -3791,7 +4235,7 @@ var exportTakeoffOutput = {
3791
4235
  measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
3792
4236
  verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
3793
4237
  computed: z.object({ area_sf: z.number().optional(), perimeter_lf: z.number().optional(), count: z.number().optional() }).passthrough().describe("count shapes carry {count} alone; every other role carries area_sf + perimeter_lf"),
3794
- origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
4238
+ origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), assignment (where the finish tag came from \u2014 {source: 'schedule', room_tag, surface, schedule_sheet} when the room's own schedule row decided it, {source: 'asserted'} when the agent chose; stamped on every agent commit), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
3795
4239
  }).passthrough()),
3796
4240
  markups: z.array(z.unknown()),
3797
4241
  sheet_group: z.array(z.unknown()),
@@ -3838,6 +4282,7 @@ var listShapesOutput = {
3838
4282
  height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
3839
4283
  nverts: z.number().int(),
3840
4284
  reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
4285
+ assignment: z.enum(["schedule", "asserted"]).optional().describe(`Where the finish tag came from: "schedule" = resolved from the room's own schedule row, "asserted" = the agent chose it. origin.assignment in export_takeoff carries the citation. Absent on human canvas shapes`),
3841
4286
  agent_edits: z.number().int().optional().describe("Present when the agent has revised this shape")
3842
4287
  })),
3843
4288
  count: z.number().int()
@@ -4051,9 +4496,10 @@ var annotationRow = z.object({
4051
4496
  at: z.tuple([z.number(), z.number()]).optional(),
4052
4497
  target: z.tuple([z.number(), z.number()]).optional(),
4053
4498
  rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional(),
4054
- from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail (image px)"),
4055
- to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head (image px)"),
4056
- r: z.number().optional().describe("Bubble radius (image px)")
4499
+ from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail / dimension start (image px)"),
4500
+ to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head / dimension end (image px)"),
4501
+ r: z.number().optional().describe("Bubble radius (image px)"),
4502
+ length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
4057
4503
  });
4058
4504
  var annotateOutput = {
4059
4505
  id: z.string(),
@@ -4062,6 +4508,7 @@ var annotateOutput = {
4062
4508
  text: z.string(),
4063
4509
  condition: z.string(),
4064
4510
  condition_id: z.string(),
4511
+ length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
4065
4512
  note: z.string()
4066
4513
  };
4067
4514
  var listAnnotationsOutput = {
@@ -4080,6 +4527,24 @@ var linkAnnotationOutput = {
4080
4527
  import path3 from "node:path";
4081
4528
  import { readFile as readFile2, writeFile } from "node:fs/promises";
4082
4529
 
4530
+ // ../web/src/lib/approvals.js
4531
+ var APPROVAL_R = 0.022;
4532
+ var APPROVAL_INK = {
4533
+ estimator: { light: "#1f6b4a", dark: "#55b083" },
4534
+ agent: { light: "#6c6a5e", dark: "#9d9a8c" }
4535
+ };
4536
+ function approvalInk(actor, dark = false) {
4537
+ const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
4538
+ return dark ? c.dark : c.light;
4539
+ }
4540
+ function approvalTally(approvals) {
4541
+ const t = { estimator: 0, agent: 0 };
4542
+ for (const a of Array.isArray(approvals) ? approvals : []) {
4543
+ if (a && a.actor in t) t[a.actor] += 1;
4544
+ }
4545
+ return t;
4546
+ }
4547
+
4083
4548
  // ../web/src/lib/svgpath.js
4084
4549
  function isSep(c) {
4085
4550
  return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
@@ -4735,7 +5200,7 @@ function invertPixels(cv) {
4735
5200
  ctx.fillRect(0, 0, cv.width, cv.height);
4736
5201
  ctx.restore();
4737
5202
  }
4738
- async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, coverTitle = "Marked Set", units = "imperial" }) {
5203
+ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, approvals = [], rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
4739
5204
  const M = units === "metric";
4740
5205
  const uA = (sf) => M ? sf * 0.09290304 : sf;
4741
5206
  const uL = (lf) => M ? lf * 0.3048 : lf;
@@ -4752,8 +5217,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4752
5217
  }
4753
5218
  return m;
4754
5219
  };
4755
- const shapesBy = byKey(shapes), marksBy = byKey(markups);
4756
- const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length);
5220
+ const shapesBy = byKey(shapes), marksBy = byKey(markups), apBy = byKey(approvals);
5221
+ const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length || (apBy.get(sh.key) || []).length);
4757
5222
  if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
4758
5223
  const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
4759
5224
  const doc = await PDFDocument.create();
@@ -4825,6 +5290,15 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4825
5290
  }
4826
5291
  }
4827
5292
  draw(`${marked.length} marked sheet${marked.length === 1 ? "" : "s"} \xB7 ${markedShapes.length} takeoff item${markedShapes.length === 1 ? "" : "s"} \xB7 quantities net of deducts, waste-adjusted where noted`, { x: 52, y: metaY, size: 9.5, font, color: muted });
5293
+ if (provenance) {
5294
+ metaY -= 12;
5295
+ draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
5296
+ }
5297
+ const apCount = approvalTally(marked.flatMap((sh) => apBy.get(sh.key) || []));
5298
+ if (apCount.estimator || apCount.agent) {
5299
+ metaY -= 12;
5300
+ draw(`Approval stamps: ${apCount.estimator} estimator-approved \xB7 ${apCount.agent} agent-marked`, { x: 52, y: metaY, size: 9.5, font, color: muted });
5301
+ }
4828
5302
  let y = metaY - 34;
4829
5303
  const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
4830
5304
  draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
@@ -4853,7 +5327,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4853
5327
  for (const sh of marked) {
4854
5328
  if (y < 90) break;
4855
5329
  const items = shapesBy.get(sh.key) || [];
4856
- 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 });
5330
+ 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 });
4857
5331
  y -= 13;
4858
5332
  for (const r of bySheetId.get(sh.key)?.rows || []) {
4859
5333
  if (y < 92) break;
@@ -5095,6 +5569,21 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5095
5569
  pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
5096
5570
  const t = lbl(m.text);
5097
5571
  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);
5572
+ } else if (m.type === "dimension" && m.from && m.to) {
5573
+ const fx = m.from[0] * W, fy = m.from[1] * H, dxq = m.to[0] * W, dyq = m.to[1] * H;
5574
+ line(fx, fy, dxq, dyq, mcol, 1.1 * mw, 0.95, mdash);
5575
+ const dl = Math.hypot(dxq - fx, dyq - fy) || 1;
5576
+ const dnx = -(dyq - fy) / dl, dny = (dxq - fx) / dl;
5577
+ const tk = 5 / ptScale;
5578
+ line(fx - dnx * tk, fy - dny * tk, fx + dnx * tk, fy + dny * tk, mcol, 1.1 * mw, 0.95);
5579
+ line(dxq - dnx * tk, dyq - dny * tk, dxq + dnx * tk, dyq + dny * tk, mcol, 1.1 * mw, 0.95);
5580
+ const t = lbl([Number(m.len_ft) > 0 ? dimLabel(m.len_ft, M ? "metric" : "imperial") : "", m.text].filter(Boolean).join(" \xB7 "));
5581
+ if (t) {
5582
+ const size = 8;
5583
+ const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
5584
+ const [pmx, pmy] = toPage((fx + dxq) / 2 + dnx * (14 / ptScale), (fy + dyq) / 2 + dny * (14 / ptScale));
5585
+ pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
5586
+ }
5098
5587
  } else if (m.type === "bubble" && m.at) {
5099
5588
  const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
5100
5589
  const [pcx, pcy] = toPage(cxImg, cyImg);
@@ -5131,6 +5620,26 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5131
5620
  text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
5132
5621
  }
5133
5622
  }
5623
+ for (const a of apBy.get(sh.key) || []) {
5624
+ const isAgent = a.actor === "agent";
5625
+ const acol = rgb(...hex(approvalInk(a.actor, dark)));
5626
+ const cxImg = a.at[0] * W, cyImg = a.at[1] * H;
5627
+ const rImg = APPROVAL_R * W, rPt = rImg * ptScale;
5628
+ const [pcx, pcy] = toPage(cxImg, cyImg);
5629
+ const backing = dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1);
5630
+ if (isAgent) {
5631
+ const dia = (k) => [[cxImg, cyImg - rImg * k], [cxImg + rImg * k, cyImg], [cxImg, cyImg + rImg * k], [cxImg - rImg * k, cyImg]];
5632
+ pg.drawSvgPath(svgPath(dia(1)), { x: 0, y: 0, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
5633
+ pg.drawSvgPath(svgPath(dia(0.72)), { x: 0, y: 0, borderColor: acol, borderWidth: rPt * 0.035 });
5634
+ } else {
5635
+ pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt, yScale: rPt, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
5636
+ pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt * 0.78, yScale: rPt * 0.78, borderColor: acol, borderWidth: rPt * 0.035 });
5637
+ }
5638
+ const label = isAgent ? "AGENT" : "APPROVED";
5639
+ const size = rPt * (isAgent ? 0.3 : 0.26);
5640
+ const tw = bold.widthOfTextAtSize(label, size);
5641
+ pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
5642
+ }
5134
5643
  text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
5135
5644
  }
5136
5645
  const allPages = doc.getPages();
@@ -5146,6 +5655,20 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5146
5655
  }
5147
5656
 
5148
5657
  // src/marked.ts
5658
+ function assignmentDisclosure(shapes, withheld = []) {
5659
+ const agent = shapes.filter((s) => s.origin?.actor === "agent");
5660
+ const live = withheld.filter((w) => !shapes.some((s) => s.sheet_id === w.sheet_id && (s.measure_role === "floor_area" || s.measure_role === "deduct") && s.verts_norm.length >= 3 && pointInPoly(w.seed_norm[0], w.seed_norm[1], s.verts_norm)));
5661
+ if (!agent.length && !live.length) return null;
5662
+ const schedule = agent.filter((s) => s.origin?.assignment?.source === "schedule").length;
5663
+ const asserted = agent.filter((s) => s.origin?.assignment?.source === "asserted").length;
5664
+ const pending = agent.filter((s) => s.origin?.reviewed !== true).length;
5665
+ const parts = [];
5666
+ if (schedule) parts.push(`${schedule} schedule-resolved`);
5667
+ if (asserted) parts.push(`${asserted} agent-asserted`);
5668
+ if (pending) parts.push(`${pending} pending human review`);
5669
+ if (live.length) parts.push(`${live.length} room${live.length === 1 ? "" : "s"} withheld, unresolved against the schedule`);
5670
+ return parts.length ? `Finish assignment: ${parts.join(" \xB7 ")}` : null;
5671
+ }
5149
5672
  var buildMarkedSetPdf2 = buildMarkedSetPdf;
5150
5673
  async function exportMarkedPdf(session, opts) {
5151
5674
  const { file, filePath } = session;
@@ -5199,6 +5722,7 @@ async function exportMarkedPdf(session, opts) {
5199
5722
  getPage,
5200
5723
  loadPdfData,
5201
5724
  credit,
5725
+ provenance: assignmentDisclosure(session.shapes, session.scheduleWithheld),
5202
5726
  coverTitle: "OpenTakeoff \xB7 Marked Set"
5203
5727
  });
5204
5728
  const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
@@ -5421,7 +5945,7 @@ function registerTools(server, session) {
5421
5945
  return session.setScale(a.sheet, a);
5422
5946
  }));
5423
5947
  server.registerTool("one_click", {
5424
- 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. ${COORDS}`,
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}`,
5425
5949
  inputSchema: {
5426
5950
  sheet: z2.string(),
5427
5951
  x: z2.number(),
@@ -5435,10 +5959,11 @@ function registerTools(server, session) {
5435
5959
  outputSchema: oneClickOutput
5436
5960
  }, 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 })));
5437
5961
  server.registerTool("detect_rooms", {
5438
- description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. ${COORDS}`,
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}`,
5439
5963
  inputSchema: {
5440
5964
  sheet: z2.string(),
5441
- condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
5965
+ condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
5966
+ assign_from_schedule: z2.boolean().default(false).describe(`Commit each room under the FLOOR finish its OWN room-finish schedule row states (resolve_tag's chain, per room): the citation rides origin.assignment, and rooms the schedule cannot answer for \u2014 no row, no FLOOR cell, a compound cell like "CPT-1/VCT-1" \u2014 are returned in unresolved[] with reasons and seeds instead of committed under a guess. Needs the sheet's scale and a room-finish schedule in the working set (merge the schedule sheet in with load_plan first). Mutually exclusive with condition`),
5442
5967
  role: roleSchema,
5443
5968
  return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
5444
5969
  min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
@@ -5446,7 +5971,12 @@ function registerTools(server, session) {
5446
5971
  layers: layersFilterSchema
5447
5972
  },
5448
5973
  outputSchema: detectRoomsOutput
5449
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers })));
5974
+ }, run("detect_rooms", (a) => {
5975
+ if (a.assign_from_schedule && a.condition !== void 0) {
5976
+ throw new UserError("Provide at most one of: condition (every room under one stated tag) or assign_from_schedule (each room's own schedule row decides).");
5977
+ }
5978
+ return session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers, assignFromSchedule: a.assign_from_schedule });
5979
+ }));
5450
5980
  server.registerTool("measure_polygon", {
5451
5981
  description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
5452
5982
  inputSchema: {
@@ -5485,6 +6015,26 @@ function registerTools(server, session) {
5485
6015
  },
5486
6016
  outputSchema: placeCountOutput
5487
6017
  }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
6018
+ server.registerTool("symbol_sweep", {
6019
+ 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}`,
6020
+ inputSchema: {
6021
+ sheet: z2.string(),
6022
+ 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"),
6023
+ 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"),
6024
+ commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
6025
+ rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
6026
+ mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
6027
+ tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
6028
+ },
6029
+ outputSchema: symbolSweepOutput
6030
+ }, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
6031
+ seedRect: a.seed_rect,
6032
+ condition: a.condition,
6033
+ commit: a.commit,
6034
+ rotations: a.rotations,
6035
+ mirror: a.mirror,
6036
+ tolerancePx: a.tolerance_px
6037
+ })));
5488
6038
  server.registerTool("derive_base", {
5489
6039
  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.`,
5490
6040
  inputSchema: {
@@ -5661,7 +6211,7 @@ function registerTools(server, session) {
5661
6211
  outputSchema: findTextOutput
5662
6212
  }, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
5663
6213
  server.registerTool("view_sheet", {
5664
- description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet overview first, then tight crops at higher px until dimension strings and room labels read cleanly. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
6214
+ description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
5665
6215
  inputSchema: {
5666
6216
  sheet: z2.string(),
5667
6217
  region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
@@ -5682,21 +6232,21 @@ function registerTools(server, session) {
5682
6232
  return reply;
5683
6233
  });
5684
6234
  server.registerTool("annotate", {
5685
- 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).
6235
+ 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.
5686
6236
 
5687
6237
  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.
5688
6238
 
5689
6239
  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}`,
5690
6240
  inputSchema: {
5691
6241
  sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
5692
- 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"),
5693
- 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"),
6242
+ 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"),
6243
+ 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"),
5694
6244
  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"),
5695
6245
  at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
5696
6246
  target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
5697
6247
  rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight"),
5698
- from: pointSchema.optional().describe("Arrow tail (image px)"),
5699
- to: pointSchema.optional().describe("Arrow head \u2014 what it points at (image px)"),
6248
+ from: pointSchema.optional().describe("Arrow tail / dimension start (image px)"),
6249
+ to: pointSchema.optional().describe("Arrow head / dimension end (image px)"),
5700
6250
  r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
5701
6251
  },
5702
6252
  outputSchema: annotateOutput
@@ -5798,7 +6348,7 @@ function registerResources(server, session) {
5798
6348
  // package.json
5799
6349
  var package_default = {
5800
6350
  name: "opentakeoff-mcp",
5801
- version: "0.9.18",
6351
+ version: "0.9.20",
5802
6352
  mcpName: "io.github.Kentucky-ai/opentakeoff",
5803
6353
  type: "module",
5804
6354
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -5875,8 +6425,8 @@ function buildServer(session = new Session()) {
5875
6425
  "OpenTakeoff: quantity takeoff on construction plan PDFs.",
5876
6426
  "A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
5877
6427
  "1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
5878
- "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`).",
5879
- "3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals.",
6428
+ "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
6429
+ "3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
5880
6430
  "4. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone."
5881
6431
  ].join("\n")
5882
6432
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
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.",