opentakeoff-mcp 0.9.25 → 0.9.26

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,8 +108,8 @@ 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. 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
- | `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. |
111
+ | `one_click` | One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped — the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …) — a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. 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 (the full account stamps `origin` centrally at the 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, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). 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. |
@@ -580,6 +580,12 @@ var MIN_THICK_FT = MIN_THICK / CAL_MPPF;
580
580
  var MIN_THICK_FLOOR = 2;
581
581
  var NUDGE_PX = 3;
582
582
  var NUDGE_FT = NUDGE_PX / CAL_MPPF;
583
+ var MIN_PASS_FT = 0.5;
584
+ function minPassRadiusFor(maskPxPerFt) {
585
+ if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 0;
586
+ return Math.min(SEAL_R_MAX, Math.round(MIN_PASS_FT * maskPxPerFt / 2));
587
+ }
588
+ var DETERMINISM_MIN_MPPF = 8;
583
589
  var SEG_CURVE = 1;
584
590
  var SEG_CLIP = 2;
585
591
  var SEG_FILLONLY = 4;
@@ -594,9 +600,14 @@ var ARC_CUSP_MIN = 3;
594
600
  var ARC_CUSP_R_RATIO = 1.5;
595
601
  var ARC_CUSP_SPAN_MULT = 8;
596
602
  var MASK_NODOOR_BIT = 8;
603
+ var DOOR_R_MIN_FT = 1.5;
604
+ var DOOR_R_MAX_FT = 4.5;
605
+ var CLUSTER_FIT_TOL_FRAC = 0.05;
606
+ var CLUSTER_FIT_TOL_PX = 1.5;
597
607
  var HATCH_ANGLE_TOL = 2;
598
608
  var HATCH_MAX_PITCH = 24;
599
609
  var HATCH_MAX_PITCH_FT = HATCH_MAX_PITCH / 18;
610
+ var HATCH_TIER_RISK = { bounded: 0, trapped: 1, override: 2 };
600
611
  var HATCH_BOUND_FRAC = 0.7;
601
612
  var HATCH_ESCALATE_FRAC = 0.02;
602
613
  var HATCH_GROWTH_MAX = 2.5;
@@ -1550,18 +1561,6 @@ function bridgedMask(mo, r) {
1550
1561
  }
1551
1562
  return { mask: m, mw: mo.mw, mh: mo.mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf };
1552
1563
  }
1553
- function floodRegion(maskObj, ix, iy, sensitivity = SENS_BALANCED) {
1554
- const r = floodRegionLadder(maskObj, ix, iy, sensitivity);
1555
- if (r.status !== "leak") return r;
1556
- for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
1557
- const rb = floodRegionLadder(bridgedMask(maskObj, br), ix, iy, sensitivity);
1558
- if (rb.status === "ok") {
1559
- rb.gapBridged = br;
1560
- return rb;
1561
- }
1562
- }
1563
- return r;
1564
- }
1565
1564
  function floodRegionLadder(maskObj, ix, iy, sensitivity) {
1566
1565
  const r1 = floodPass(maskObj, ix, iy, 3);
1567
1566
  if (!maskObj.softCount) return r1;
@@ -1582,7 +1581,591 @@ function floodRegionLadder(maskObj, ix, iy, sensitivity) {
1582
1581
  }
1583
1582
  return r1;
1584
1583
  }
1584
+ var SEAL_RADII = [1, 2, 4];
1585
+ var DOOR_SEAL_MAX_FT = 5;
1586
+ var SEAL_R_MAX = 128;
1587
+ var SEAL_VIRTUAL_MAX = 0.25;
1588
+ var SEAL_MAX_SHEET_FRAC = 0.3;
1589
+ var VIRTUAL_HUG_PX = 3;
1590
+ function sealRadiiFor(maskPxPerFt) {
1591
+ if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return SEAL_RADII;
1592
+ const maxR = Math.min(SEAL_R_MAX, Math.ceil(DOOR_SEAL_MAX_FT * maskPxPerFt / 2));
1593
+ const radii = [];
1594
+ for (let r = 1; r < maxR; r *= 2) radii.push(r);
1595
+ radii.push(maxR);
1596
+ return radii;
1597
+ }
1598
+ var sealCache = /* @__PURE__ */ new WeakMap();
1599
+ function hardDT(mask, mw, mh, out) {
1600
+ const dt = out || new Uint8Array(mw * mh).fill(255);
1601
+ for (let y = 0; y < mh; y++) {
1602
+ const row = y * mw;
1603
+ for (let x = 0; x < mw; x++) {
1604
+ const i = row + x;
1605
+ if (mask[i] & 1) {
1606
+ dt[i] = 0;
1607
+ continue;
1608
+ }
1609
+ let d = 255;
1610
+ if (x > 0) d = Math.min(d, dt[i - 1] + 1);
1611
+ if (y > 0) d = Math.min(d, dt[i - mw] + 1);
1612
+ dt[i] = Math.min(255, d);
1613
+ }
1614
+ }
1615
+ for (let y = mh - 1; y >= 0; y--) {
1616
+ const row = y * mw;
1617
+ for (let x = mw - 1; x >= 0; x--) {
1618
+ const i = row + x;
1619
+ let d = dt[i];
1620
+ if (x < mw - 1) d = Math.min(d, dt[i + 1] + 1);
1621
+ if (y < mh - 1) d = Math.min(d, dt[i + mw] + 1);
1622
+ dt[i] = Math.min(255, d);
1623
+ }
1624
+ }
1625
+ return dt;
1626
+ }
1627
+ function l1ToBox(x, y, bx0, by0, bx1, by1) {
1628
+ const dx = x < bx0 ? bx0 - x : x > bx1 ? x - bx1 : 0;
1629
+ const dy = y < by0 ? by0 - y : y > by1 ? y - by1 : 0;
1630
+ return dx + dy;
1631
+ }
1632
+ function dtDirtyWindow(cl, mw, mh, dt) {
1633
+ let cx0 = mw, cy0 = mh, cx1 = -1, cy1 = -1;
1634
+ for (const i of cl) {
1635
+ const y = i / mw | 0, x = i - y * mw;
1636
+ if (x < cx0) cx0 = x;
1637
+ if (x > cx1) cx1 = x;
1638
+ if (y < cy0) cy0 = y;
1639
+ if (y > cy1) cy1 = y;
1640
+ }
1641
+ if (cx1 < 0) return null;
1642
+ for (let w = 16; ; w *= 2) {
1643
+ const x0 = cx0 - w, y0 = cy0 - w, x1 = cx1 + w, y1 = cy1 + w;
1644
+ if (x0 <= 0 && y0 <= 0 && x1 >= mw - 1 && y1 >= mh - 1) return null;
1645
+ const clear = (x, y) => x < 0 || y < 0 || x >= mw || y >= mh || dt[y * mw + x] < l1ToBox(x, y, cx0, cy0, cx1, cy1);
1646
+ let ok2 = true;
1647
+ for (let x = x0 - 1; x <= x1 + 1 && ok2; x++) ok2 = clear(x, y0 - 1) && clear(x, y1 + 1);
1648
+ for (let y = y0; y <= y1 && ok2; y++) ok2 = clear(x0 - 1, y) && clear(x1 + 1, y);
1649
+ if (ok2) return { x0: Math.max(0, x0), y0: Math.max(0, y0), x1: Math.min(mw - 1, x1), y1: Math.min(mh - 1, y1) };
1650
+ }
1651
+ }
1652
+ function hardDTWindow(mask, mw, mh, dt, b) {
1653
+ for (let y = b.y0; y <= b.y1; y++) {
1654
+ const row = y * mw;
1655
+ for (let x = b.x0; x <= b.x1; x++) {
1656
+ const i = row + x;
1657
+ if (mask[i] & 1) {
1658
+ dt[i] = 0;
1659
+ continue;
1660
+ }
1661
+ let d = 255;
1662
+ if (x > 0) d = Math.min(d, dt[i - 1] + 1);
1663
+ if (y > 0) d = Math.min(d, dt[i - mw] + 1);
1664
+ dt[i] = Math.min(255, d);
1665
+ }
1666
+ }
1667
+ for (let y = b.y1; y >= b.y0; y--) {
1668
+ const row = y * mw;
1669
+ for (let x = b.x1; x >= b.x0; x--) {
1670
+ const i = row + x;
1671
+ let d = dt[i];
1672
+ if (x < mw - 1) d = Math.min(d, dt[i + 1] + 1);
1673
+ if (y < mh - 1) d = Math.min(d, dt[i + mw] + 1);
1674
+ dt[i] = Math.min(255, d);
1675
+ }
1676
+ }
1677
+ }
1678
+ function dilatedView(mo, r, dt) {
1679
+ return { mask: mo.mask, mw: mo.mw, mh: mo.mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf, dilDT: dt, dilR: r };
1680
+ }
1681
+ function growRegionBack(f, orig, r, barrier, dt) {
1682
+ const { region, mw, mh } = f;
1683
+ const mask = orig.mask;
1684
+ const b = boxOf(region, mw, mh);
1685
+ regionBox.set(region, b);
1686
+ let frontier = [];
1687
+ for (let y = b.y0; y <= b.y1; y++) {
1688
+ const row = y * mw;
1689
+ for (let x = b.x0; x <= b.x1; x++) {
1690
+ const i = row + x;
1691
+ if (!region[i]) continue;
1692
+ if (x > 0 && !region[i - 1] || x < mw - 1 && !region[i + 1] || y > 0 && !region[i - mw] || y < mh - 1 && !region[i + mw]) frontier.push(i);
1693
+ }
1694
+ }
1695
+ const tryGrow = (from, to, next) => {
1696
+ if (!region[to] && !(mask[to] & barrier) && dt[to] <= r && dt[to] <= dt[from]) {
1697
+ region[to] = 1;
1698
+ f.count++;
1699
+ next.push(to);
1700
+ const y = to / mw | 0, x = to - y * mw;
1701
+ if (x < b.x0) b.x0 = x;
1702
+ if (x > b.x1) b.x1 = x;
1703
+ if (y < b.y0) b.y0 = y;
1704
+ if (y > b.y1) b.y1 = y;
1705
+ }
1706
+ };
1707
+ while (frontier.length) {
1708
+ const next = [];
1709
+ for (const i of frontier) {
1710
+ const x = i % mw, y = i / mw | 0;
1711
+ if (x > 0) tryGrow(i, i - 1, next);
1712
+ if (x < mw - 1) tryGrow(i, i + 1, next);
1713
+ if (y > 0) tryGrow(i, i - mw, next);
1714
+ if (y < mh - 1) tryGrow(i, i + mw, next);
1715
+ }
1716
+ frontier = next;
1717
+ }
1718
+ }
1719
+ var WEDGE_SLACK = 1.3;
1720
+ var WEDGE_GROWTH_FRAC = 0.3;
1721
+ var WEDGE_MAX_DOORS = 12;
1722
+ function doorWedgeCapPx(maskPxPerFt) {
1723
+ if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 0;
1724
+ return Math.round(Math.PI / 4 * (DOOR_SEAL_MAX_FT * maskPxPerFt) ** 2 * WEDGE_SLACK);
1725
+ }
1726
+ function boundaryCurveClusters(mo, region) {
1727
+ const { mw, mh } = mo;
1728
+ const src = mo.mask;
1729
+ const near = [];
1730
+ const b = boxOf(region, mw, mh);
1731
+ const y0 = Math.max(0, b.y0 - 3), y1 = Math.min(mh - 1, b.y1 + 3);
1732
+ const x0 = Math.max(0, b.x0 - 3), x1 = Math.min(mw - 1, b.x1 + 3);
1733
+ for (let y = y0; y <= y1; y++) {
1734
+ const row = y * mw;
1735
+ for (let x = x0; x <= x1; x++) {
1736
+ const i = row + x;
1737
+ if (!(src[i] & MASK_CURVE_BIT)) continue;
1738
+ let n = false;
1739
+ for (let dy = -3; dy <= 3 && !n; dy++) {
1740
+ const ny = y + dy;
1741
+ if (ny < 0 || ny >= mh) continue;
1742
+ for (let dx = -3; dx <= 3; dx++) {
1743
+ const nx = x + dx;
1744
+ if (nx >= 0 && nx < mw && region[ny * mw + nx]) {
1745
+ n = true;
1746
+ break;
1747
+ }
1748
+ }
1749
+ }
1750
+ if (n) near.push(i);
1751
+ }
1752
+ }
1753
+ const clusters = [];
1754
+ const seen = /* @__PURE__ */ new Set();
1755
+ for (const s of near) {
1756
+ if (seen.has(s)) continue;
1757
+ const cl = [];
1758
+ const stack = [s];
1759
+ seen.add(s);
1760
+ while (stack.length) {
1761
+ const i = stack.pop();
1762
+ cl.push(i);
1763
+ const x = i % mw, y = i / mw | 0;
1764
+ for (let dy = -3; dy <= 3; dy++) {
1765
+ const ny = y + dy;
1766
+ if (ny < 0 || ny >= mh) continue;
1767
+ for (let dx = -3; dx <= 3; dx++) {
1768
+ const nx = x + dx;
1769
+ if (nx < 0 || nx >= mw) continue;
1770
+ const j = ny * mw + nx;
1771
+ if (!seen.has(j) && src[j] & MASK_CURVE_BIT) {
1772
+ seen.add(j);
1773
+ stack.push(j);
1774
+ }
1775
+ }
1776
+ }
1777
+ }
1778
+ clusters.push(cl);
1779
+ }
1780
+ return clusters;
1781
+ }
1782
+ function arcClusterFit(cl, mw, mask) {
1783
+ const m = cl.length;
1784
+ const X = (i) => i % mw, Y = (i) => i / mw | 0;
1785
+ let mx = 0, my = 0, noDoor = 0;
1786
+ for (const i of cl) {
1787
+ mx += X(i);
1788
+ my += Y(i);
1789
+ if (mask[i] & MASK_NODOOR_BIT) noDoor++;
1790
+ }
1791
+ mx /= m;
1792
+ my /= m;
1793
+ let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
1794
+ for (const i of cl) {
1795
+ const x = X(i) - mx, y = Y(i) - my, z3 = x * x + y * y;
1796
+ sxx += x * x;
1797
+ sxy += x * y;
1798
+ syy += y * y;
1799
+ sxz += x * z3;
1800
+ syz += y * z3;
1801
+ }
1802
+ const tr = sxx + syy, dsc = Math.sqrt(Math.max(0, ((sxx - syy) / 2) ** 2 + sxy * sxy));
1803
+ const l1 = tr / 2 + dsc;
1804
+ let ux = sxy, uy = l1 - sxx;
1805
+ if (Math.hypot(ux, uy) < 1e-9) {
1806
+ ux = 1;
1807
+ uy = 0;
1808
+ } else {
1809
+ const L = Math.hypot(ux, uy);
1810
+ ux /= L;
1811
+ uy /= L;
1812
+ }
1813
+ let u0 = Infinity, u1 = -Infinity, n0 = Infinity, n1 = -Infinity;
1814
+ for (const i of cl) {
1815
+ const x = X(i) - mx, y = Y(i) - my;
1816
+ const a = x * ux + y * uy, b = -x * uy + y * ux;
1817
+ if (a < u0) u0 = a;
1818
+ if (a > u1) u1 = a;
1819
+ if (b < n0) n0 = b;
1820
+ if (b > n1) n1 = b;
1821
+ }
1822
+ const bu = u1 - u0 + 1, bn = n1 - n0 + 1;
1823
+ const base = { cx: mx, cy: my, r: 0, rms: Infinity, good: false, sweep: 0, noDoorFrac: noDoor / m, bu, bn, buH: bu, bnH: bn };
1824
+ const det = sxx * syy - sxy * sxy;
1825
+ if (m < ARC_MIN_CHORDS || Math.abs(det) < 1e-9) return base;
1826
+ const cx = (sxz * syy - syz * sxy) / (2 * det), cy = (syz * sxx - sxz * sxy) / (2 * det);
1827
+ let r = 0;
1828
+ for (const i of cl) r += Math.hypot(X(i) - mx - cx, Y(i) - my - cy);
1829
+ r /= m;
1830
+ if (!(r > 0)) return base;
1831
+ let s2 = 0;
1832
+ const angs = [];
1833
+ for (const i of cl) {
1834
+ const dx = X(i) - mx - cx, dy = Y(i) - my - cy;
1835
+ const d = Math.hypot(dx, dy) - r;
1836
+ s2 += d * d;
1837
+ angs.push(Math.atan2(dy, dx));
1838
+ }
1839
+ const rms = Math.sqrt(s2 / m);
1840
+ angs.sort((a, b) => a - b);
1841
+ let gap = angs[0] + 2 * Math.PI - angs[angs.length - 1];
1842
+ for (let k = 1; k < angs.length; k++) if (angs[k] - angs[k - 1] > gap) gap = angs[k] - angs[k - 1];
1843
+ const hu = cx * ux + cy * uy, hn = -cx * uy + cy * ux;
1844
+ return {
1845
+ ...base,
1846
+ cx: cx + mx,
1847
+ cy: cy + my,
1848
+ r,
1849
+ rms,
1850
+ good: rms <= Math.max(CLUSTER_FIT_TOL_PX, CLUSTER_FIT_TOL_FRAC * r),
1851
+ sweep: Math.max(0, 2 * Math.PI - gap),
1852
+ buH: Math.max(u1, hu) - Math.min(u0, hu) + 1,
1853
+ bnH: Math.max(n1, hn) - Math.min(n0, hn) + 1
1854
+ };
1855
+ }
1585
1856
  var WEDGE_RIM_FT = 3 / CAL_MPPF;
1857
+ function wedgeRimPx(maskPxPerFt) {
1858
+ if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 3;
1859
+ return Math.max(3, Math.round(WEDGE_RIM_FT * maskPxPerFt));
1860
+ }
1861
+ function wedgeAllowance(fit, mppf, wedgeCapPx) {
1862
+ if (fit.noDoorFrac > 0.5 && !fit.good) return 0;
1863
+ if (fit.good && mppf > 0 && fit.r / mppf > DOOR_R_MAX_FT) return 0;
1864
+ const rim = wedgeRimPx(mppf);
1865
+ const bu = fit.good ? fit.buH : fit.bu, bn = fit.good ? fit.bnH : fit.bn;
1866
+ let area = bu * bn;
1867
+ if (fit.good) area = Math.min(area, 0.5 * fit.sweep * fit.r * fit.r);
1868
+ area += 2 * rim * (bu + bn) + 4 * rim * rim;
1869
+ return Math.min(2 * wedgeCapPx, Math.round(area * WEDGE_SLACK));
1870
+ }
1871
+ function doorLikeness(fit, mppf) {
1872
+ let s = 1 - fit.noDoorFrac;
1873
+ const swDeg = fit.sweep * 180 / Math.PI;
1874
+ if (fit.good) {
1875
+ s += 1;
1876
+ if (mppf > 0 && fit.r / mppf >= DOOR_R_MIN_FT && fit.r / mppf <= DOOR_R_MAX_FT) s += 4;
1877
+ if (swDeg >= 45 && swDeg <= 190) s += 2;
1878
+ }
1879
+ return s;
1880
+ }
1881
+ function ascendSeed(dt, mw, mh, ws, ix, iy, r) {
1882
+ let cx = Math.max(0, Math.min(mw - 1, Math.round(ix * ws)));
1883
+ let cy = Math.max(0, Math.min(mh - 1, Math.round(iy * ws)));
1884
+ for (let step = 0; step < 2 * r && dt[cy * mw + cx] <= r; step++) {
1885
+ let bx = cx, by = cy, bd = dt[cy * mw + cx];
1886
+ if (cx > 0 && dt[cy * mw + cx - 1] > bd) {
1887
+ bd = dt[cy * mw + cx - 1];
1888
+ bx = cx - 1;
1889
+ by = cy;
1890
+ }
1891
+ if (cx < mw - 1 && dt[cy * mw + cx + 1] > bd) {
1892
+ bd = dt[cy * mw + cx + 1];
1893
+ bx = cx + 1;
1894
+ by = cy;
1895
+ }
1896
+ if (cy > 0 && dt[(cy - 1) * mw + cx] > bd) {
1897
+ bd = dt[(cy - 1) * mw + cx];
1898
+ bx = cx;
1899
+ by = cy - 1;
1900
+ }
1901
+ if (cy < mh - 1 && dt[(cy + 1) * mw + cx] > bd) {
1902
+ bd = dt[(cy + 1) * mw + cx];
1903
+ bx = cx;
1904
+ by = cy + 1;
1905
+ }
1906
+ if (bx === cx && by === cy) break;
1907
+ cx = bx;
1908
+ cy = by;
1909
+ }
1910
+ return [cx / ws, cy / ws];
1911
+ }
1912
+ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
1913
+ const scratch = () => {
1914
+ if (given) return given;
1915
+ let s = sealCache.get(mo.mask);
1916
+ if (!s) {
1917
+ s = { dt: hardDT(mo.mask, mo.mw, mo.mh) };
1918
+ sealCache.set(mo.mask, s);
1919
+ }
1920
+ return s;
1921
+ };
1922
+ let raw = null;
1923
+ const rawFlood = () => raw ??= floodRegionLadder(mo, ix, iy, sensitivity);
1924
+ let leakedR = 0;
1925
+ const cx = Math.max(0, Math.min(mo.mw - 1, Math.round(ix * mo.ws)));
1926
+ const cy = Math.max(0, Math.min(mo.mh - 1, Math.round(iy * mo.ws)));
1927
+ const cSoft = (mo.mask[cy * mo.mw + cx] & 2) !== 0;
1928
+ const cdt = (s) => cSoft ? 0 : s.dt[cy * mo.mw + cx];
1929
+ if (minPassPx > 0) {
1930
+ const s = scratch();
1931
+ const dm = dilatedView(mo, minPassPx, s.dt);
1932
+ const [ax, ay] = ascendSeed(s.dt, mo.mw, mo.mh, mo.ws, ix, iy, minPassPx);
1933
+ const f = floodRegionLadder(dm, ax, ay, sensitivity);
1934
+ if (f.status === "leak" && minPassPx > leakedR && cdt(s) > minPassPx) leakedR = minPassPx;
1935
+ if (f.status === "ok") {
1936
+ growRegionBack(f, mo, minPassPx, f.hatchFiltered ? 1 : 3, s.dt);
1937
+ const r0 = rawFlood();
1938
+ if (r0.status === "ok" && r0.count > 0) {
1939
+ const d = +(1 - f.count / r0.count).toFixed(4);
1940
+ if (d > 0) {
1941
+ f.minPassPx = minPassPx;
1942
+ f.minPassDelta = d;
1943
+ }
1944
+ return f;
1945
+ }
1946
+ const vf = f.count > f.mw * f.mh * SEAL_MAX_SHEET_FRAC ? 1 : virtualBoundaryFrac(f, s.dt);
1947
+ if (vf <= SEAL_VIRTUAL_MAX) {
1948
+ f.minPassPx = minPassPx;
1949
+ f.minPassDelta = 1;
1950
+ f.sealedPx = minPassPx;
1951
+ f.virtualFrac = +vf.toFixed(3);
1952
+ return f;
1953
+ }
1954
+ }
1955
+ }
1956
+ const base = rawFlood();
1957
+ if (base.status === "ok") return base;
1958
+ const sc = scratch();
1959
+ for (const r of radii) {
1960
+ if (r <= minPassPx) continue;
1961
+ const dm = dilatedView(mo, r, sc.dt);
1962
+ const [ax, ay] = ascendSeed(sc.dt, mo.mw, mo.mh, mo.ws, ix, iy, r);
1963
+ const f = floodRegionLadder(dm, ax, ay, sensitivity);
1964
+ if (f.status !== "ok") {
1965
+ if (f.status === "leak" && r > leakedR && cdt(sc) > r) leakedR = r;
1966
+ continue;
1967
+ }
1968
+ growRegionBack(f, mo, r, f.hatchFiltered ? 1 : 3, sc.dt);
1969
+ if (f.count > f.mw * f.mh * SEAL_MAX_SHEET_FRAC) continue;
1970
+ const vf = virtualBoundaryFrac(f, sc.dt);
1971
+ if (vf > SEAL_VIRTUAL_MAX) continue;
1972
+ f.sealedPx = r;
1973
+ f.virtualFrac = +vf.toFixed(3);
1974
+ return f;
1975
+ }
1976
+ if (base.status === "leak" && leakedR > 0) base.leakedDilationPx = leakedR;
1977
+ return base;
1978
+ }
1979
+ function floodRegionSealed(mo, ix, iy, sensitivity = SENS_BALANCED, radii = SEAL_RADII, wedgeCapPx = 0, minPassPx = 0) {
1980
+ const out = floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minPassPx);
1981
+ if (out.status === "ok") {
1982
+ const cf = curveBoundaryFrac(out, mo);
1983
+ if (cf > 0) out.curveFrac = +cf.toFixed(3);
1984
+ }
1985
+ return out;
1986
+ }
1987
+ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minPassPx) {
1988
+ const r1 = sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx);
1989
+ if (r1.status === "leak") {
1990
+ const futileBelowPx = (r1.leakedDilationPx ?? 0) >> 1;
1991
+ for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
1992
+ if (br <= futileBelowPx) continue;
1993
+ const rb = floodRegionLadder(bridgedMask(mo, br), ix, iy, sensitivity);
1994
+ if (rb.status === "ok") {
1995
+ rb.gapBridged = br;
1996
+ return rb;
1997
+ }
1998
+ }
1999
+ }
2000
+ if (!wedgeCapPx || r1.status !== "ok") return r1;
2001
+ const clusters = boundaryCurveClusters(mo, r1.region);
2002
+ if (!clusters.length) return r1;
2003
+ const globalAllowance = Math.max(wedgeCapPx, Math.min(Math.round(r1.count * WEDGE_GROWTH_FRAC), WEDGE_MAX_DOORS * wedgeCapPx));
2004
+ const { mw, mh } = mo;
2005
+ let region = null;
2006
+ let count = r1.count;
2007
+ let wedges = 0, ringWedges = 0;
2008
+ let hatchFiltered = !!r1.hatchFiltered;
2009
+ let hatchTier = r1.hatchTier;
2010
+ let sealedPx = r1.sealedPx, virtualFrac = r1.virtualFrac;
2011
+ let minPassPxOut = r1.minPassPx, minPassDelta = r1.minPassDelta;
2012
+ const ranked = clusters.map((cl) => {
2013
+ const fit = arcClusterFit(cl, mw, mo.mask);
2014
+ return { cl, fit, allow: wedgeAllowance(fit, mo.mppf || 0, wedgeCapPx), rank: doorLikeness(fit, mo.mppf || 0), at: cl[0] };
2015
+ }).filter((c) => c.allow >= 1).sort((a, b) => b.rank - a.rank || a.at - b.at);
2016
+ let m2mask = null, m2dt = null;
2017
+ let openedCl = null, dirty = null;
2018
+ const rb1 = boxOf(r1.region, mw, mh);
2019
+ let base = sealCache.get(mo.mask);
2020
+ if (!base) {
2021
+ base = { dt: hardDT(mo.mask, mw, mh) };
2022
+ sealCache.set(mo.mask, base);
2023
+ }
2024
+ const baseDT = base.dt;
2025
+ for (const { cl, fit, allow: clusterAllowance } of ranked.slice(0, WEDGE_MAX_DOORS)) {
2026
+ if (!m2mask) {
2027
+ m2mask = mo.mask.slice();
2028
+ m2dt = baseDT.slice();
2029
+ } else {
2030
+ if (openedCl) for (const i of openedCl) m2mask[i] = mo.mask[i];
2031
+ if (dirty) for (let y = dirty.y0; y <= dirty.y1; y++) {
2032
+ const r = y * mw;
2033
+ m2dt.set(baseDT.subarray(r + dirty.x0, r + dirty.x1 + 1), r + dirty.x0);
2034
+ }
2035
+ else m2dt.set(baseDT);
2036
+ }
2037
+ for (const i of cl) m2mask[i] = m2mask[i] & ~1;
2038
+ openedCl = cl;
2039
+ const m2 = { mask: m2mask, mw, mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf };
2040
+ dirty = dtDirtyWindow(cl, mw, mh, baseDT);
2041
+ if (dirty) hardDTWindow(m2mask, mw, mh, m2dt, dirty);
2042
+ else hardDT(m2mask, mw, mh, m2dt);
2043
+ const sc2 = { dt: m2dt };
2044
+ let bi = -1, bd = -1;
2045
+ for (let y = rb1.y0; y <= rb1.y1; y++) {
2046
+ const row = y * mw;
2047
+ for (let x = rb1.x0; x <= rb1.x1; x++) {
2048
+ const i = row + x;
2049
+ if (r1.region[i] && sc2.dt[i] > bd) {
2050
+ bd = sc2.dt[i];
2051
+ bi = i;
2052
+ }
2053
+ }
2054
+ }
2055
+ const sx = bi < 0 ? ix : bi % mw / mo.ws, sy = bi < 0 ? iy : Math.floor(bi / mw) / mo.ws;
2056
+ const r2 = sealAttempt(m2, sx, sy, sensitivity, radii, minPassPx, sc2);
2057
+ if (r2.status !== "ok" || r2.count <= r1.count) continue;
2058
+ const growth = r2.count - r1.count;
2059
+ if (growth > clusterAllowance) continue;
2060
+ if (count - r1.count + growth > globalAllowance) continue;
2061
+ if (!region) {
2062
+ region = r1.region.slice();
2063
+ regionBox.set(region, { ...rb1 });
2064
+ }
2065
+ const rb = regionBox.get(region), b2 = boxOf(r2.region, mw, mh);
2066
+ for (let y = b2.y0; y <= b2.y1; y++) {
2067
+ const row = y * mw;
2068
+ for (let x = b2.x0; x <= b2.x1; x++) {
2069
+ const i = row + x;
2070
+ if (r2.region[i] && !region[i]) {
2071
+ region[i] = 1;
2072
+ count++;
2073
+ if (x < rb.x0) rb.x0 = x;
2074
+ if (x > rb.x1) rb.x1 = x;
2075
+ if (y < rb.y0) rb.y0 = y;
2076
+ if (y > rb.y1) rb.y1 = y;
2077
+ }
2078
+ }
2079
+ }
2080
+ wedges++;
2081
+ if (fit.noDoorFrac > 0.5 && fit.good) ringWedges++;
2082
+ if (r2.hatchFiltered) hatchFiltered = true;
2083
+ if (r2.hatchTier && HATCH_TIER_RISK[r2.hatchTier] > (hatchTier ? HATCH_TIER_RISK[hatchTier] : -1)) hatchTier = r2.hatchTier;
2084
+ if (r2.sealedPx && (!sealedPx || r2.sealedPx > sealedPx)) sealedPx = r2.sealedPx;
2085
+ if (r2.virtualFrac != null && (virtualFrac == null || r2.virtualFrac > virtualFrac)) virtualFrac = r2.virtualFrac;
2086
+ if (r2.minPassPx && (!minPassPxOut || r2.minPassPx > minPassPxOut)) minPassPxOut = r2.minPassPx;
2087
+ if (r2.minPassDelta != null && (minPassDelta == null || r2.minPassDelta > minPassDelta)) minPassDelta = r2.minPassDelta;
2088
+ }
2089
+ if (!wedges || !region) return r1;
2090
+ const out = {
2091
+ status: "ok",
2092
+ region,
2093
+ count,
2094
+ mw,
2095
+ mh,
2096
+ ws: r1.ws,
2097
+ mppf: r1.mppf,
2098
+ hardHits: r1.hardHits,
2099
+ softHits: r1.softHits,
2100
+ hatchFiltered: hatchFiltered || void 0,
2101
+ hatchTier,
2102
+ sealedPx,
2103
+ virtualFrac,
2104
+ minPassPx: minPassPxOut,
2105
+ minPassDelta,
2106
+ ...ringWedges ? { ringWedges } : {}
2107
+ };
2108
+ const reg = region, reg1 = r1.region, mask = mo.mask;
2109
+ const isDelta = (i) => reg[i] && !reg1[i];
2110
+ const rbA = boxOf(reg, mw, mh);
2111
+ const ay0 = Math.max(1, rbA.y0), ay1 = Math.min(mh - 2, rbA.y1);
2112
+ const ax0 = Math.max(1, rbA.x0), ax1 = Math.min(mw - 2, rbA.x1);
2113
+ for (let pass = 0; pass < 2; pass++) {
2114
+ for (let y = ay0; y <= ay1; y++) {
2115
+ const row = y * mw;
2116
+ for (let x = ax0; x <= ax1; x++) {
2117
+ const i = row + x;
2118
+ if (reg[i] || !(mask[i] & 1)) continue;
2119
+ const pinchH = reg[i - 1] && reg[i + 1], pinchV = reg[i - mw] && reg[i + mw];
2120
+ if (pinchH && (isDelta(i - 1) || isDelta(i + 1)) || pinchV && (isDelta(i - mw) || isDelta(i + mw))) {
2121
+ reg[i] = 1;
2122
+ out.count++;
2123
+ }
2124
+ }
2125
+ }
2126
+ }
2127
+ out.wedges = wedges;
2128
+ out.wedgeGrowth = +(out.count / r1.count).toFixed(3);
2129
+ return out;
2130
+ }
2131
+ function virtualBoundaryFrac(f, dt) {
2132
+ const { region, mw, mh } = f;
2133
+ const b = boxOf(region, mw, mh);
2134
+ let boundary = 0, virtual = 0;
2135
+ for (let y = b.y0; y <= b.y1; y++) {
2136
+ const row = y * mw;
2137
+ for (let x = b.x0; x <= b.x1; x++) {
2138
+ const i = row + x;
2139
+ if (!region[i]) continue;
2140
+ if (x > 0 && !region[i - 1] || x < mw - 1 && !region[i + 1] || y > 0 && !region[i - mw] || y < mh - 1 && !region[i + mw]) {
2141
+ boundary++;
2142
+ if (dt[i] > VIRTUAL_HUG_PX) virtual++;
2143
+ }
2144
+ }
2145
+ }
2146
+ return boundary ? virtual / boundary : 1;
2147
+ }
2148
+ function curveBoundaryFrac(f, mo) {
2149
+ const { region, mw, mh } = f;
2150
+ const mask = mo.mask;
2151
+ const b = boxOf(region, mw, mh);
2152
+ let boundary = 0, curved = 0;
2153
+ for (let y = b.y0; y <= b.y1; y++) {
2154
+ const row = y * mw;
2155
+ for (let x = b.x0; x <= b.x1; x++) {
2156
+ const i = row + x;
2157
+ if (!region[i]) continue;
2158
+ const w = x > 0 ? i - 1 : -1, e = x < mw - 1 ? i + 1 : -1, n = y > 0 ? i - mw : -1, s = y < mh - 1 ? i + mw : -1;
2159
+ if (!(w >= 0 && !region[w] || e >= 0 && !region[e] || n >= 0 && !region[n] || s >= 0 && !region[s])) continue;
2160
+ boundary++;
2161
+ for (const j of [w, e, n, s]) if (j >= 0 && !region[j] && mask[j] & MASK_CURVE_BIT) {
2162
+ curved++;
2163
+ break;
2164
+ }
2165
+ }
2166
+ }
2167
+ return boundary ? curved / boundary : 0;
2168
+ }
1586
2169
  function traceRegion(reg, epsMaskPx = 1.5) {
1587
2170
  const { region, mw, mh, ws } = reg;
1588
2171
  let s = -1;
@@ -1682,6 +2265,20 @@ function ringArea(pts) {
1682
2265
 
1683
2266
  // ../web/src/lib/detectRooms.ts
1684
2267
  var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
2268
+ function oneClickArgs(maskPxPerFt) {
2269
+ const mppf = Number.isFinite(maskPxPerFt) && maskPxPerFt > 0 ? maskPxPerFt : 0;
2270
+ return {
2271
+ mppf,
2272
+ scaleBlind: mppf <= 0,
2273
+ radii: sealRadiiFor(mppf),
2274
+ wedgeCapPx: doorWedgeCapPx(mppf),
2275
+ minPassPx: minPassRadiusFor(mppf)
2276
+ };
2277
+ }
2278
+ function floodAtSeed(maskObj, ix, iy, sensitivity = SENS_BALANCED, maskPxPerFt = maskObj.mppf || 0) {
2279
+ const a = oneClickArgs(maskPxPerFt);
2280
+ return floodRegionSealed(maskObj, ix, iy, sensitivity, a.radii, a.wedgeCapPx, a.minPassPx);
2281
+ }
1685
2282
  var BUBBLE_RATIO = 2.5;
1686
2283
  function seedLadderPx(b, first = "anchor") {
1687
2284
  const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
@@ -2231,6 +2828,82 @@ var fail = (err) => ({
2231
2828
  var round2 = (n) => +n.toFixed(2);
2232
2829
  var round1 = (n) => +n.toFixed(1);
2233
2830
 
2831
+ // ../web/src/lib/confidence.ts
2832
+ var CONF_RASTER = 0.9;
2833
+ var CONF_HATCH = 0.95;
2834
+ var CONF_HATCH_TIER = { bounded: CONF_HATCH, trapped: 0.93, override: 0.85 };
2835
+ var CONF_WEDGE = 0.97;
2836
+ var WEDGE_ANNEX_REF = 0.1;
2837
+ var CONF_COARSE = 0.9;
2838
+ var CONF_MINPASS = 0.99;
2839
+ var CONF_MINPASS_SOLE = 0.85;
2840
+ var CONF_CURVE_K = 0.5;
2841
+ var ROOM_PLAUSIBLE_SF = 5e3;
2842
+ var ROOM_ABSURD_SF = 5e4;
2843
+ var CONF_OVERSIZE_MAX = 0.35;
2844
+ var SEAL_VIRTUAL_DEFAULT = 0.1;
2845
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
2846
+ function floodSignals(f, opts = {}) {
2847
+ return {
2848
+ raster: opts.raster,
2849
+ hatchFiltered: f.hatchFiltered,
2850
+ hatchTier: f.hatchTier,
2851
+ sealedPx: f.sealedPx,
2852
+ virtualFrac: f.virtualFrac,
2853
+ wedges: f.wedges,
2854
+ wedgeGrowth: f.wedgeGrowth,
2855
+ curveFrac: f.curveFrac,
2856
+ minPassPx: f.minPassPx,
2857
+ minPassDelta: f.minPassDelta,
2858
+ areaSF: opts.areaSF,
2859
+ mppf: f.mppf ?? opts.mppf
2860
+ };
2861
+ }
2862
+ function traceConfidence(s) {
2863
+ let score = 1;
2864
+ const factors = [];
2865
+ if (s.raster) {
2866
+ score *= CONF_RASTER;
2867
+ factors.push("raster-traced");
2868
+ }
2869
+ if (s.hatchFiltered) {
2870
+ const tier = s.hatchTier ?? "bounded";
2871
+ score *= CONF_HATCH_TIER[tier];
2872
+ factors.push(`hatch-filtered(${tier})`);
2873
+ }
2874
+ if (s.sealedPx) {
2875
+ const vf = typeof s.virtualFrac === "number" ? clamp(s.virtualFrac, 0, 0.25) : SEAL_VIRTUAL_DEFAULT;
2876
+ score *= 1 - vf;
2877
+ factors.push(`sealed-opening(${Math.round(vf * 100)}% synthetic boundary)`);
2878
+ }
2879
+ if (s.minPassDelta) {
2880
+ const sole = s.minPassDelta >= 1;
2881
+ score *= sole ? CONF_MINPASS_SOLE : CONF_MINPASS;
2882
+ factors.push(sole ? "undecidable-passage(the drawn linework does not enclose this space)" : `min-passage-rule(${(s.minPassDelta * 100).toFixed(1)}% of the verbatim flood removed)`);
2883
+ }
2884
+ if (s.wedges) {
2885
+ const annex = typeof s.wedgeGrowth === "number" && s.wedgeGrowth > 1 ? (s.wedgeGrowth - 1) / s.wedgeGrowth : void 0;
2886
+ const w = annex === void 0 ? 1 : clamp(annex / WEDGE_ANNEX_REF, 0, 1);
2887
+ score *= 1 - (1 - CONF_WEDGE) * w;
2888
+ factors.push(annex === void 0 ? "door-swing-crossed" : `door-swing-crossed(${(annex * 100).toFixed(1)}% annexed swing)`);
2889
+ }
2890
+ if (s.curveFrac) {
2891
+ const cf = clamp(s.curveFrac, 0, 1);
2892
+ score *= 1 - CONF_CURVE_K * cf;
2893
+ factors.push(`curve-bounded(${Math.round(cf * 100)}% of the boundary)`);
2894
+ }
2895
+ if (typeof s.areaSF === "number" && s.areaSF > ROOM_PLAUSIBLE_SF) {
2896
+ const over = clamp((s.areaSF - ROOM_PLAUSIBLE_SF) / (ROOM_ABSURD_SF - ROOM_PLAUSIBLE_SF), 0, 1);
2897
+ score *= 1 - CONF_OVERSIZE_MAX * over;
2898
+ factors.push(`oversize-for-one-room(${Math.round(s.areaSF).toLocaleString("en-US")} SF)`);
2899
+ }
2900
+ if (typeof s.mppf === "number" && s.mppf > 0 && s.mppf < DETERMINISM_MIN_MPPF) {
2901
+ score *= CONF_COARSE;
2902
+ factors.push("coarse-mask");
2903
+ }
2904
+ return { score: +score.toFixed(2), factors };
2905
+ }
2906
+
2234
2907
  // ../web/src/lib/rastermask.ts
2235
2908
  var RASTER_MIN_IMG_FRAC = 0.1;
2236
2909
  var RASTER_MIN_SEGS = 500;
@@ -3844,15 +4517,41 @@ var Session = class _Session {
3844
4517
  }
3845
4518
  return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
3846
4519
  }
4520
+ /** The vector mask, built through buildMask's FULL scale-pinned signature
4521
+ * (RFC #60 / PR #179 — the canvas's ensureMask, verbatim in intent):
4522
+ * `pxPerFt` (the sheet scale as image px per foot) rides INTO the mask, so
4523
+ * the hatch pitch cap, the seal radii, door-wedge caps and the minimum-
4524
+ * passage rule are all feet-true through `mask.mppf` instead of guessing in
4525
+ * raster px; the `page` pin (audit A1/F3) makes the working grid a property
4526
+ * of the SHEET in points, never of a render. This server renders at the
4527
+ * canvas BASELINE (RENDER_SCALE) always, so renderScale === baseScale and
4528
+ * basePxPerFt === pxPerFt — the one degenerate case where the pin and the
4529
+ * legacy reconstruction agree bit-for-bit. */
4530
+ buildVectorMask(s, geo, layersOpt) {
4531
+ if (!geo.segs.length) return null;
4532
+ const pxPerFt = s.upp ? 1 / s.upp : 0;
4533
+ return buildMask(
4534
+ geo.segs,
4535
+ s.widthPx,
4536
+ s.heightPx,
4537
+ MASK_MAX_DIM,
4538
+ geo.meta,
4539
+ pxPerFt,
4540
+ pxPerFt,
4541
+ { pageW: s.widthPt, pageH: s.heightPt, renderScale: RENDER_SCALE, baseScale: RENDER_SCALE },
4542
+ this.rolesFor(s, geo, layersOpt)
4543
+ );
4544
+ }
3847
4545
  /** v1 masks come from the sheet's vector linework only; a scanned sheet
3848
4546
  * (zero segments) is null here and the measuring tools fall back to
3849
4547
  * ensureRasterMask (#154). Layer roles (#85) ride in as the stated
3850
- * short-circuit; an unlayered sheet builds the identical pre-#85 mask. */
4548
+ * short-circuit; an unlayered sheet builds the identical pre-#85 mask.
4549
+ * The cached mask BAKES THE SCALE IN (mppf) — set_scale evicts it. */
3851
4550
  async ensureMask(name) {
3852
4551
  const s = this.sheet(name);
3853
4552
  if (s.mask === void 0) {
3854
4553
  const geo = await this.ensureGeometry(s);
3855
- s.mask = geo.segs.length ? buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo)) : null;
4554
+ s.mask = this.buildVectorMask(s, geo);
3856
4555
  }
3857
4556
  return s.mask;
3858
4557
  }
@@ -3863,16 +4562,18 @@ var Session = class _Session {
3863
4562
  if (!layersOpt || !layersOpt.include?.length && !layersOpt.exclude?.length) return this.ensureMask(name);
3864
4563
  const s = this.sheet(name);
3865
4564
  const geo = await this.ensureGeometry(s);
3866
- if (!geo.segs.length) return null;
3867
- return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
4565
+ return this.buildVectorMask(s, geo, layersOpt);
3868
4566
  }
3869
4567
  /** The raster-fallback mask (#154): a dedicated render of the sheet at mask
3870
4568
  * scale (the view_sheet machinery — pdf.ts + @napi-rs/canvas), thresholded
3871
4569
  * by the canvas's own rastermask engine into the same MaskObj shape
3872
- * buildMask emits, so floodRegion/traceRegion run unchanged on scans.
3873
- * Cached per sheet like geo/mask; where the optional native canvas never
3874
- * installed, renderRgba throws its plain install-hint Error and the tool
3875
- * reply carries it a stated inability, never a guessed polygon. */
4570
+ * buildMask emits, so the sealed flood (floodAtSeed) and traceRegion run
4571
+ * unchanged on scans. It carries NO mppf of its own (pixels cannot know
4572
+ * the sheet scale), so flood call sites pass mask px per foot explicitly —
4573
+ * ws / upp, the canvas's own raster-path convention and set_scale evicts
4574
+ * this cache alongside the vector mask. Where the optional native canvas
4575
+ * never installed, renderRgba throws its plain install-hint Error and the
4576
+ * tool reply carries it — a stated inability, never a guessed polygon. */
3876
4577
  async ensureRasterMask(s) {
3877
4578
  if (!s.rmask) {
3878
4579
  const ws = Math.min(1, MASK_MAX_DIM / Math.max(s.widthPx, s.heightPx, 1));
@@ -3955,6 +4656,10 @@ var Session = class _Session {
3955
4656
  } else {
3956
4657
  throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
3957
4658
  }
4659
+ if (s.upp !== upp) {
4660
+ s.mask = void 0;
4661
+ s.rmask = void 0;
4662
+ }
3958
4663
  s.upp = upp;
3959
4664
  s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
3960
4665
  return {
@@ -4003,7 +4708,43 @@ var Session = class _Session {
4003
4708
  }
4004
4709
  return c;
4005
4710
  }
4006
- commit(s, tag, role, vertsPx, computed, origin) {
4711
+ /** Slim flood evidence — the engine result's SCALAR signals, harvested the
4712
+ * moment the flood returns so a batch sweep never pins N mask-sized region
4713
+ * bitmaps just to score confidence at commit time. `signals` goes through
4714
+ * floodSignals, THE adapter (audit A2: hand-listed signal fields are how an
4715
+ * engine emission goes silently inert); gapBridged/ringWedges ride beside
4716
+ * it because they are provenance the adapter does not carry. */
4717
+ static floodEvidence(f, raster, mppf) {
4718
+ return {
4719
+ signals: floodSignals(f, { raster, mppf }),
4720
+ raster,
4721
+ ...f.gapBridged ? { gapBridged: f.gapBridged } : {},
4722
+ ...f.ringWedges ? { ringWedges: f.ringWedges } : {}
4723
+ };
4724
+ }
4725
+ /** THE flood → provenance mapping (the canvas Create gate's field set,
4726
+ * TakeoffCanvas commitOneClickRegions). One function, spread into the
4727
+ * committed origin by commit() and into the tool reply by the flood call
4728
+ * sites, so the two surfaces cannot drift and no site hand-lists fields. */
4729
+ static floodStamp(ev, areaSF) {
4730
+ const conf = traceConfidence({ ...ev.signals, areaSF });
4731
+ const sig = ev.signals;
4732
+ return {
4733
+ confidence: conf.score,
4734
+ ...conf.factors.length ? { confidence_factors: conf.factors } : {},
4735
+ ...sig.hatchFiltered ? { hatch_filtered: true } : {},
4736
+ ...sig.sealedPx ? { gap_sealed_px: sig.sealedPx } : {},
4737
+ ...ev.gapBridged ? { gap_bridged_px: ev.gapBridged } : {},
4738
+ // min-pass fields ride only when the rule CHANGED the answer — the
4739
+ // canvas convention (minPassDelta gates minPassPx)
4740
+ ...sig.minPassDelta ? { min_pass_px: sig.minPassPx || 0, min_pass_delta: sig.minPassDelta } : {},
4741
+ ...sig.wedges ? { door_wedges: sig.wedges } : {},
4742
+ ...ev.ringWedges ? { ring_interiors: ev.ringWedges } : {},
4743
+ ...ev.raster ? { raster_traced: true } : {}
4744
+ };
4745
+ }
4746
+ commit(s, tag, role, vertsPx, computed, origin, flood) {
4747
+ if (origin && flood) origin = { ...origin, ..._Session.floodStamp(flood, computed.area_sf) };
4007
4748
  if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
4008
4749
  const c = this.conditionFor(tag);
4009
4750
  const shape = {
@@ -4028,7 +4769,7 @@ var Session = class _Session {
4028
4769
  const mask = await this.maskWithLayers(name, opts.layers);
4029
4770
  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.");
4030
4771
  if (mask) {
4031
- const r = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
4772
+ const r = floodAtSeed(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
4032
4773
  if (r.status === "ok") f = r;
4033
4774
  else if (!rasterEligible) {
4034
4775
  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.");
@@ -4039,29 +4780,23 @@ var Session = class _Session {
4039
4780
  if (!f) {
4040
4781
  this.refuseLayersOnRaster(opts.layers);
4041
4782
  const rmask = await this.ensureRasterMask(s);
4042
- const r = floodRegion(rmask, x, y);
4783
+ const r = floodAtSeed(rmask, x, y, SENS_BALANCED, s.upp ? rmask.ws / s.upp : 0);
4043
4784
  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.");
4044
4785
  if (r.status !== "ok") throw new UserError("Landed on dense scan ink (text or hatching). Seed an open spot inside the room.");
4045
4786
  f = r;
4046
4787
  raster = true;
4047
4788
  }
4789
+ const ev = _Session.floodEvidence(f, raster, s.upp ? f.ws / s.upp : 0);
4048
4790
  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);
4049
4791
  if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
4050
4792
  const areaPx2 = ringArea(ring);
4051
4793
  const perimPx = closedMetrics(ring).perim;
4052
- const common = {
4053
- status: "ok",
4054
- nverts: ring.length,
4055
- ...f.hatchFiltered ? { hatch_filtered: true } : {},
4056
- ...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
4057
- // which path ran, disclosed both ways (#154): present = pixels bounded
4058
- // this trace, absent = the vector linework did
4059
- ...raster ? { raster_traced: true } : {},
4060
- ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
4061
- };
4062
4794
  if (s.upp == null) {
4063
4795
  return {
4064
- ...common,
4796
+ status: "ok",
4797
+ nverts: ring.length,
4798
+ ..._Session.floodStamp(ev),
4799
+ ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {},
4065
4800
  area_px2: round1(areaPx2),
4066
4801
  perimeter_px: round1(perimPx),
4067
4802
  warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.`
@@ -4070,6 +4805,12 @@ var Session = class _Session {
4070
4805
  const upp = s.upp;
4071
4806
  const area_sf = round2(areaPx2 * upp * upp);
4072
4807
  const perimeter_lf = round2(perimPx * upp);
4808
+ const common = {
4809
+ status: "ok",
4810
+ nverts: ring.length,
4811
+ ..._Session.floodStamp(ev, area_sf),
4812
+ ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
4813
+ };
4073
4814
  let shape_id;
4074
4815
  if (opts.condition) {
4075
4816
  shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
@@ -4077,12 +4818,6 @@ var Session = class _Session {
4077
4818
  actor: "agent",
4078
4819
  seed_norm: [x / s.widthPx, y / s.heightPx],
4079
4820
  reviewed: false,
4080
- ...f.hatchFiltered ? { hatch_filtered: true } : {},
4081
- ...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
4082
- // #154 — a trace whose boundary was scan PIXELS is a different claim
4083
- // than one bounded by vector linework; the record says which (the
4084
- // canvas's raster_traced vocabulary, contribution.v2)
4085
- ...raster ? { raster_traced: true } : {},
4086
4821
  // #85 — a trace bounded by DECLARED boundary layers is categorically
4087
4822
  // stronger evidence than one bounded by a pitch heuristic (vector
4088
4823
  // path only: the raster mask never saw the layer table)
@@ -4091,7 +4826,7 @@ var Session = class _Session {
4091
4826
  // how the shape was made (ShapeOrigin.fill_sensitivity) — vector path
4092
4827
  // only; the knob is inert on a single-tier raster mask
4093
4828
  ...!raster && opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
4094
- }).id;
4829
+ }, ev).id;
4095
4830
  }
4096
4831
  this.flushCommits("one_click");
4097
4832
  const mixed = this.scaleWarningFor(s, ring);
@@ -4161,11 +4896,12 @@ var Session = class _Session {
4161
4896
  const unresolved = [];
4162
4897
  const byRing = /* @__PURE__ */ new Map();
4163
4898
  const order = [];
4899
+ const sweepMppf = raster ? s.upp ? mask.ws / s.upp : 0 : mask.mppf || 0;
4164
4900
  for (const lb of labels) {
4165
- let ring = null, hatch = false, gap = 0, seed = null;
4901
+ let ring = null, ev = null, seed = null;
4166
4902
  let sawBubble = false, sawDegenerate = false;
4167
4903
  for (const probe of seedLadderPx(lb.bbox)) {
4168
- const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
4904
+ const f = floodAtSeed(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED, sweepMppf);
4169
4905
  if (f.status !== "ok") continue;
4170
4906
  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);
4171
4907
  if (r.length < 3) {
@@ -4177,12 +4913,11 @@ var Session = class _Session {
4177
4913
  continue;
4178
4914
  }
4179
4915
  ring = r;
4180
- hatch = !!f.hatchFiltered;
4181
- gap = f.gapBridged || 0;
4916
+ ev = _Session.floodEvidence(f, raster, sweepMppf);
4182
4917
  seed = probe;
4183
4918
  break;
4184
4919
  }
4185
- if (!ring || !seed) {
4920
+ if (!ring || !ev || !seed) {
4186
4921
  if (sawBubble) withheld.bubble++;
4187
4922
  else if (sawDegenerate) withheld.degenerate++;
4188
4923
  continue;
@@ -4200,8 +4935,7 @@ var Session = class _Session {
4200
4935
  areaPx2: ringArea(ring),
4201
4936
  perimPx: closedMetrics(ring).perim,
4202
4937
  seed,
4203
- hatch,
4204
- gap,
4938
+ ev,
4205
4939
  merged: []
4206
4940
  };
4207
4941
  byRing.set(key, cand);
@@ -4209,17 +4943,16 @@ var Session = class _Session {
4209
4943
  }
4210
4944
  const upp = s.upp;
4211
4945
  const rooms = order.map((c) => {
4212
- const common = {
4213
- label: c.label,
4214
- nverts: c.ring.length,
4215
- ...c.merged.length ? { merged_labels: c.merged } : {},
4216
- ...c.hatch ? { hatch_filtered: true } : {},
4217
- ...c.gap ? { gap_bridged_px: c.gap } : {},
4218
- ...raster ? { raster_traced: true } : {},
4219
- ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
4220
- };
4221
4946
  if (upp == null) {
4222
- return { ...common, area_px2: round1(c.areaPx2), perimeter_px: round1(c.perimPx) };
4947
+ return {
4948
+ label: c.label,
4949
+ nverts: c.ring.length,
4950
+ ...c.merged.length ? { merged_labels: c.merged } : {},
4951
+ ..._Session.floodStamp(c.ev),
4952
+ ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {},
4953
+ area_px2: round1(c.areaPx2),
4954
+ perimeter_px: round1(c.perimPx)
4955
+ };
4223
4956
  }
4224
4957
  const area_sf = round2(c.areaPx2 * upp * upp);
4225
4958
  if (area_sf < minAreaSf) {
@@ -4227,6 +4960,13 @@ var Session = class _Session {
4227
4960
  return null;
4228
4961
  }
4229
4962
  const perimeter_lf = round2(c.perimPx * upp);
4963
+ const common = {
4964
+ label: c.label,
4965
+ nverts: c.ring.length,
4966
+ ...c.merged.length ? { merged_labels: c.merged } : {},
4967
+ ..._Session.floodStamp(c.ev, area_sf),
4968
+ ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
4969
+ };
4230
4970
  let tag = opts.condition;
4231
4971
  let assignment;
4232
4972
  if (assign) {
@@ -4246,11 +4986,8 @@ var Session = class _Session {
4246
4986
  actor: "agent",
4247
4987
  seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
4248
4988
  reviewed: false,
4249
- ...c.hatch ? { hatch_filtered: true } : {},
4250
- ...c.gap ? { gap_bridged_px: c.gap } : {},
4251
- ...raster ? { raster_traced: true } : {},
4252
4989
  ...assignment ? { assignment } : {}
4253
- }).id;
4990
+ }, c.ev).id;
4254
4991
  }
4255
4992
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
4256
4993
  }).filter((r) => r !== null);
@@ -5585,6 +6322,15 @@ function traceToolCall(tool, args, startedAt, reply) {
5585
6322
  // src/outputs.ts
5586
6323
  import { z } from "zod";
5587
6324
  var point = z.tuple([z.number(), z.number()]);
6325
+ var traceProvenance = {
6326
+ confidence: z.number().optional().describe("0..1 \u2014 the trace scored from the engine's own signals (sealed openings, door wedges, min-passage rule, hatch tier, raster boundary, mask coarseness, implausible size). A review PRIORITIZER, not a verification: 1.0 means every signal came back clean, never that the trace is right. A low score is a view_sheet {overlay:true} audit prompt, not a fact to bid from"),
6327
+ confidence_factors: z.array(z.string()).optional().describe('The named factors behind a sub-1.0 confidence (e.g. "sealed-opening(10% synthetic boundary)") \u2014 each names the edge worth putting eyes on; absent when every signal ran clean'),
6328
+ gap_sealed_px: z.number().optional().describe("Present when the seal ladder closed a genuine OPENING this many mask px wide (doorway-scale \u2014 scaled by the sheet's feet, distinct from gap_bridged_px's drafting-pinhole rescue). Part of the boundary is synthetic, and confidence deducts by that share; rides origin.gap_sealed_px on the committed shape"),
6329
+ min_pass_px: z.number().optional().describe("The feet-true minimum-passage rule (openings under ~0.5 ft never connect two spaces) ran at this dilation radius AND changed the answer \u2014 present only with min_pass_delta"),
6330
+ min_pass_delta: z.number().optional().describe("Fraction of the verbatim flood the minimum-passage rule removed; 1 means the drawn linework bounds nothing here and the rule is the only reason there is a measurement \u2014 audit before trusting"),
6331
+ door_wedges: z.number().int().optional().describe("Door-swing wedges annexed into the region under grow-but-verify \u2014 how many doorways' swings were included, the canvas's own door handling; rides origin.door_wedges"),
6332
+ ring_interiors: z.number().int().optional().describe("Of those wedges, how many were a CLOSED ring's interior (round column, callout bubble) rather than a door swing \u2014 annexed floor you may want as a deduct instead")
6333
+ };
5588
6334
  var sheetSummary2 = {
5589
6335
  sheet: z.string().describe('Sheet key: page 1 is the bare file name ("plan.pdf"), pages 2+ are "plan.pdf#2"'),
5590
6336
  page: z.number().int().describe("1-based page number"),
@@ -5629,6 +6375,7 @@ var setScaleOutput = {
5629
6375
  var oneClickOutput = {
5630
6376
  status: z.literal("ok"),
5631
6377
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
6378
+ ...traceProvenance,
5632
6379
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
5633
6380
  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"),
5634
6381
  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"),
@@ -5644,6 +6391,7 @@ var detectedRoom = z.object({
5644
6391
  label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
5645
6392
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
5646
6393
  merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
6394
+ ...traceProvenance,
5647
6395
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
5648
6396
  gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region"),
5649
6397
  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"),
@@ -7601,7 +8349,7 @@ function registerTools(server, session) {
7601
8349
  return session.setScale(a.sheet, a);
7602
8350
  }));
7603
8351
  server.registerTool("one_click", {
7604
- 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}`,
8352
+ description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 the sealed flood engine (RFC #60), contour trace, vertices snapped to true PDF endpoints. The engine's arguments are FEET-TRUE through the sheet's scale, exactly the canvas's: gap sealing bridges up to a door-width opening (disclosed as gap_sealed_px \u2014 that much boundary is synthetic), door-swing wedges annex the swing a doorway sweeps (door_wedges), and the minimum-passage rule keeps sub-half-foot slits from conjoining two rooms (min_pass_px/min_pass_delta). Every trace carries the engine's own account of itself: confidence (0..1, with confidence_factors naming what deducted) \u2014 a review PRIORITIZER, never a verification. 1.0 means every signal ran clean, not that the trace is right; a LOW confidence is a view_sheet {overlay: true} audit prompt, not a fact to bid from \u2014 put eyes on the flagged edge before the total means anything. 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 \u2014 the full engine account rides the committed shape's origin, so the export tells the truth about how each shape was made. Without a scale it returns px-only quantities with a warning and commits nothing (the engine also degrades to its scale-blind fallbacks \u2014 a weaker measurement, one more reason set_scale comes first). 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}`,
7605
8353
  inputSchema: {
7606
8354
  sheet: z2.string(),
7607
8355
  x: z2.number(),
@@ -7615,7 +8363,7 @@ function registerTools(server, session) {
7615
8363
  outputSchema: oneClickOutput
7616
8364
  }, 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 })));
7617
8365
  server.registerTool("detect_rooms", {
7618
- 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}`,
8366
+ 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. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. 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}`,
7619
8367
  inputSchema: {
7620
8368
  sheet: z2.string(),
7621
8369
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
@@ -8045,7 +8793,7 @@ function registerResources(server, session) {
8045
8793
  // package.json
8046
8794
  var package_default = {
8047
8795
  name: "opentakeoff-mcp",
8048
- version: "0.9.25",
8796
+ version: "0.9.26",
8049
8797
  mcpName: "io.github.Kentucky-ai/opentakeoff",
8050
8798
  type: "module",
8051
8799
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -8061,7 +8809,7 @@ var package_default = {
8061
8809
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
8062
8810
  prepublishOnly: "npm run typecheck && npm test && npm run build",
8063
8811
  typecheck: "tsc --noEmit",
8064
- 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"
8812
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.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"
8065
8813
  },
8066
8814
  dependencies: {
8067
8815
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.25",
3
+ "version": "0.9.26",
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/raster.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/parity.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",