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 +2 -2
- package/dist/server-core.js +814 -66
- package/package.json +2 -2
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
|
|
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. |
|
package/dist/server-core.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
3873
|
-
*
|
|
3874
|
-
*
|
|
3875
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
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.
|
|
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",
|