opentakeoff-mcp 0.9.8 → 0.9.12

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
@@ -112,14 +112,17 @@ includes document text, shape vertices, or result payload content.
112
112
  | `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible), never dropped silently. Pass `condition` to commit every detected room. |
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
+ | `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob — pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
116
+ | `place_count` | **EA markers**: one point, one each — thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
115
117
  | `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
116
118
  | `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
117
119
  | `delete_shape` | Remove a committed shape by id. |
118
120
  | `edit_shape` | **Revise** a committed shape instead of redoing it: new `verts`, a different `condition`, a different `role`, or any combination — quantities recomputed from the result. Refuses shapes a human affirmed. |
119
121
  | `edit_materials` | Add/remove/patch supporting-materials rows on a condition — the coverage-rate lines (adhesive at N sf/gal, grout at N lf/bag, …) that turn a measured quantity into an order quantity, matching the canvas's Supporting Materials panel. `condition` mints on first touch, like `one_click`/`measure_polygon`. No review gate (materials rows are quantity config, not traced geometry) — edits directly, reversible with `undo_last`. |
120
- | `edit_condition` | Set a condition's **waste %** and **×N multiplier** the knobs that turn measured quantities into order quantities (`takeoff_summary`'s `*_net`); without them an agent takeoff always ships net === gross. Resolves an **existing** finish tag or errors — a typo must not mint an empty condition. No review gate; one `undo_last` step restores both knobs verbatim. |
122
+ | `edit_condition` | Set a condition's **waste %**, **×N multiplier**, **height_ft** (the H knob `measure_surface` quantifies against), and **roll_setup** (the roll-goods opt-in: seams figured, cuts packed, the reply echoes the order cuts, `order_lf`, rolls, `order_qty` — and `export_report`'s `roll_goods` block carries the same rows; `null` opts out) the knobs that turn measured quantities into order quantities. Resolves an **existing** finish tag or errors — a typo must not mint an empty condition. No review gate; one `undo_last` step restores the knobs verbatim. |
121
123
  | `export_report` | The **computed Report document** — `opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers — `export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path`. |
122
124
  | `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself. Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
125
+ | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, and review state in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
123
126
  | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
124
127
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
125
128
  | `find_text` | **Locate** a known string — the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
@@ -1675,6 +1675,440 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
1675
1675
  };
1676
1676
  }
1677
1677
 
1678
+ // ../web/src/lib/rollgoods.js
1679
+ var ROLL_FLOORING_TYPES = ["carpet", "sheet_vinyl", "rubber"];
1680
+ function isRollType(ft) {
1681
+ return ROLL_FLOORING_TYPES.includes(ft);
1682
+ }
1683
+ function defaultRollSetup(ft) {
1684
+ return { roll_width_ft: 12, roll_length_ft: 0, seam_allowance_in: 2, wall_overage_in: 3, doorway_overage_in: 1, direction: "auto", price_unit: ft === "carpet" ? "sy" : "sf" };
1685
+ }
1686
+ function rollQtyForUnit(orderFt, rollWidthFt, unit) {
1687
+ const w = rollWidthFt || 12;
1688
+ if (unit === "lf") return orderFt;
1689
+ if (unit === "sf") return orderFt * w;
1690
+ return orderFt * w / 9;
1691
+ }
1692
+ function laneCapacityIn(n, i, rollWidthIn, seamIn, wallIn) {
1693
+ if (n === 1) return rollWidthIn - 2 * wallIn;
1694
+ const isEdge = i === 0 || i === n - 1;
1695
+ return isEdge ? rollWidthIn - seamIn - wallIn : rollWidthIn - 2 * seamIn;
1696
+ }
1697
+ function totalCapacityIn(n, rollWidthIn, seamIn, wallIn) {
1698
+ let sum = 0;
1699
+ for (let i = 0; i < n; i++) sum += laneCapacityIn(n, i, rollWidthIn, seamIn, wallIn);
1700
+ return sum;
1701
+ }
1702
+ function computeLaneCount(widthIn, rollWidthIn, seamIn, wallIn) {
1703
+ for (let n = 1; n <= 40; n++) if (totalCapacityIn(n, rollWidthIn, seamIn, wallIn) >= widthIn - 1e-6) return n;
1704
+ return 40;
1705
+ }
1706
+ function scanlineCrossings(ring, laneAxis, runAxis, v) {
1707
+ const n = ring.length, vals = [];
1708
+ for (let i = 0; i < n; i++) {
1709
+ const a = ring[i], b = ring[(i + 1) % n];
1710
+ const av = a[laneAxis], bv = b[laneAxis];
1711
+ if (av <= v && bv > v || bv <= v && av > v) {
1712
+ const t = (v - av) / (bv - av);
1713
+ vals.push({ val: a[runAxis] + t * (b[runAxis] - a[runAxis]), edgeIndex: i });
1714
+ }
1715
+ }
1716
+ vals.sort((p, q) => p.val - q.val);
1717
+ return vals;
1718
+ }
1719
+ function laneSampleAxisValues(ring, laneAxis, lo, hi) {
1720
+ const eps = 1e-6;
1721
+ const vs = [Math.min(lo + eps, hi), Math.max(hi - eps, lo)];
1722
+ ring.forEach((p) => {
1723
+ if (p[laneAxis] > lo + eps && p[laneAxis] < hi - eps) vs.push(p[laneAxis]);
1724
+ });
1725
+ return vs;
1726
+ }
1727
+ function computeLaneRunExtent(ring, laneAxis, lo, hi) {
1728
+ const runAxis = laneAxis === "x" ? "y" : "x";
1729
+ const samples = laneSampleAxisValues(ring, laneAxis, lo, hi);
1730
+ let best = null, bestMax = null;
1731
+ samples.forEach((v) => {
1732
+ const crossings = scanlineCrossings(ring, laneAxis, runAxis, v);
1733
+ if (!crossings.length) return;
1734
+ const first = crossings[0], last = crossings[crossings.length - 1];
1735
+ if (!best || first.val < best.val) best = first;
1736
+ if (!bestMax || last.val > bestMax.val) bestMax = last;
1737
+ });
1738
+ if (!best) return null;
1739
+ return { min: best.val, minEdgeIndex: best.edgeIndex, max: bestMax.val, maxEdgeIndex: bestMax.edgeIndex };
1740
+ }
1741
+ function layoutRingStrips(item, direction, config) {
1742
+ const ring = item.ring;
1743
+ if (!ring || ring.length < 3) return [];
1744
+ const laneAxis = direction === "ns" ? "x" : "y";
1745
+ let lo = Infinity, hi = -Infinity;
1746
+ ring.forEach((p) => {
1747
+ lo = Math.min(lo, p[laneAxis]);
1748
+ hi = Math.max(hi, p[laneAxis]);
1749
+ });
1750
+ const widthFt = hi - lo;
1751
+ if (!(widthFt > 0)) return [];
1752
+ const rollWidthIn = config.rollWidthFt * 12;
1753
+ const n = computeLaneCount(widthFt * 12, rollWidthIn, config.seamAllowanceIn, config.wallOverageIn);
1754
+ const laneWidthsFt = [];
1755
+ let remaining = widthFt;
1756
+ for (let i = 0; i < n; i++) {
1757
+ const capFt = laneCapacityIn(n, i, rollWidthIn, config.seamAllowanceIn, config.wallOverageIn) / 12;
1758
+ const w = i === n - 1 ? Math.max(0, remaining) : Math.min(capFt, remaining);
1759
+ laneWidthsFt.push(w);
1760
+ remaining -= w;
1761
+ }
1762
+ const hasDoor = item.hasDoorAtEdge || (() => false);
1763
+ const wallOverageFt = config.wallOverageIn / 12;
1764
+ const seamFt = config.seamAllowanceIn / 12;
1765
+ const doorwayOverageFt = (config.doorwayOverageIn != null ? config.doorwayOverageIn : config.wallOverageIn) / 12;
1766
+ let lowSideDoor = false, highSideDoor = false;
1767
+ for (let e = 0; e < ring.length; e++) {
1768
+ const A = ring[e], B = ring[(e + 1) % ring.length];
1769
+ if (Math.abs(A[laneAxis] - lo) < 1e-6 && Math.abs(B[laneAxis] - lo) < 1e-6 && hasDoor(e)) lowSideDoor = true;
1770
+ if (Math.abs(A[laneAxis] - hi) < 1e-6 && Math.abs(B[laneAxis] - hi) < 1e-6 && hasDoor(e)) highSideDoor = true;
1771
+ }
1772
+ const strips = [];
1773
+ let cursor = lo;
1774
+ for (let i = 0; i < n; i++) {
1775
+ const laneLo = cursor, laneHi = cursor + laneWidthsFt[i];
1776
+ cursor = laneHi;
1777
+ const ext = computeLaneRunExtent(ring, laneAxis, laneLo, laneHi);
1778
+ if (!ext) continue;
1779
+ const minDoor = hasDoor(ext.minEdgeIndex), maxDoor = hasDoor(ext.maxEdgeIndex);
1780
+ const minOverageFt = wallOverageFt + (minDoor ? doorwayOverageFt : 0);
1781
+ const maxOverageFt = wallOverageFt + (maxDoor ? doorwayOverageFt : 0);
1782
+ const runMin = ext.min - minOverageFt, runMax = ext.max + maxOverageFt;
1783
+ const laneLowDoor = i === 0 && lowSideDoor;
1784
+ const laneHighDoor = i === n - 1 && highSideDoor;
1785
+ const lowExpand = (i === 0 ? wallOverageFt : seamFt) + (laneLowDoor ? doorwayOverageFt : 0);
1786
+ const highExpand = (i === n - 1 ? wallOverageFt : seamFt) + (laneHighDoor ? doorwayOverageFt : 0);
1787
+ strips.push({
1788
+ // STABLE id (srcId + lane) so manual per-cut overrides can be re-associated
1789
+ // across recomputes; unique within a layout since srcId is unique per item.
1790
+ id: `${item.id}:${i}`,
1791
+ srcId: item.id,
1792
+ label: item.name || "",
1793
+ laneIndex: i,
1794
+ laneCount: n,
1795
+ laneAxis,
1796
+ laneMin: laneLo - lowExpand,
1797
+ laneMax: laneHi + highExpand,
1798
+ // physical piece width (incl. seam/wall/door)
1799
+ coverMin: laneLo,
1800
+ coverMax: laneHi,
1801
+ // room-coverage slab (no allowances)
1802
+ runMin,
1803
+ runMax,
1804
+ autoRunMin: runMin,
1805
+ autoRunMax: runMax,
1806
+ minOverageFt,
1807
+ maxOverageFt,
1808
+ minDoor,
1809
+ maxDoor,
1810
+ laneLowDoor,
1811
+ laneHighDoor,
1812
+ doorwayOverageFt,
1813
+ seamFt,
1814
+ rollWidthFt: config.rollWidthFt
1815
+ });
1816
+ }
1817
+ return strips;
1818
+ }
1819
+ function sumStripLengthsFt(strips) {
1820
+ return strips.reduce((s, st) => s + Math.max(0, st.runMax - st.runMin), 0);
1821
+ }
1822
+ function assignDefaultCutPositions(strips, rollWidthFt, seqById, rollLenFt = 0) {
1823
+ const EPS = 1e-6;
1824
+ const L = rollLenFt > 0 ? rollLenFt : Infinity;
1825
+ const laneW = (s) => s.laneMax - s.laneMin;
1826
+ const lenX = (s) => Math.max(0, s.runMax - s.runMin);
1827
+ const makeRoll = () => ({ sky: [{ y: 0, width: rollWidthFt, x: 0 }] });
1828
+ function findSpot(roll, lw, cutLen) {
1829
+ const sky = roll.sky;
1830
+ let best = null;
1831
+ for (let i = 0; i < sky.length; i++) {
1832
+ const y = sky[i].y;
1833
+ if (y + lw > rollWidthFt + EPS) continue;
1834
+ let spanned = 0, x = 0, j = i;
1835
+ while (j < sky.length && spanned < lw - EPS) {
1836
+ x = Math.max(x, sky[j].x);
1837
+ spanned += sky[j].width;
1838
+ j++;
1839
+ }
1840
+ if (spanned < lw - EPS) continue;
1841
+ if (x + cutLen > L + EPS) continue;
1842
+ if (!best || x < best.x - EPS || Math.abs(x - best.x) < EPS && y < best.y) best = { x, y };
1843
+ }
1844
+ return best;
1845
+ }
1846
+ function raise(roll, y, lw, newX) {
1847
+ const spanEnd = y + lw, out = [];
1848
+ let placed = false;
1849
+ roll.sky.forEach((seg) => {
1850
+ const segEnd = seg.y + seg.width;
1851
+ if (segEnd <= y + EPS || seg.y >= spanEnd - EPS) {
1852
+ out.push(seg);
1853
+ return;
1854
+ }
1855
+ if (seg.y < y - EPS) out.push({ y: seg.y, width: y - seg.y, x: seg.x });
1856
+ if (!placed) {
1857
+ out.push({ y, width: lw, x: newX });
1858
+ placed = true;
1859
+ }
1860
+ if (segEnd > spanEnd + EPS) out.push({ y: spanEnd, width: segEnd - spanEnd, x: seg.x });
1861
+ });
1862
+ if (!placed) out.push({ y, width: lw, x: newX });
1863
+ const merged = [];
1864
+ out.sort((a, b) => a.y - b.y).forEach((nn) => {
1865
+ const last = merged[merged.length - 1];
1866
+ if (last && Math.abs(last.x - nn.x) < EPS) last.width += nn.width;
1867
+ else merged.push(nn);
1868
+ });
1869
+ roll.sky = merged;
1870
+ }
1871
+ const order = strips.slice().sort((a, b) => {
1872
+ if (seqById) {
1873
+ const sa = seqById[a.id], sb = seqById[b.id];
1874
+ const ha = Number.isFinite(sa), hb = Number.isFinite(sb);
1875
+ if (ha && hb && sa !== sb) return sa - sb;
1876
+ if (ha !== hb) return ha ? -1 : 1;
1877
+ }
1878
+ const dw = laneW(b) - laneW(a);
1879
+ if (Math.abs(dw) > EPS) return dw;
1880
+ return lenX(b) - lenX(a);
1881
+ });
1882
+ const rolls = [makeRoll()];
1883
+ order.forEach((s) => {
1884
+ const lw = laneW(s), Lc = lenX(s);
1885
+ let idx = -1, spot = null;
1886
+ for (let k = 0; k < rolls.length; k++) {
1887
+ const sp = findSpot(rolls[k], lw, Lc);
1888
+ if (sp) {
1889
+ idx = k;
1890
+ spot = sp;
1891
+ break;
1892
+ }
1893
+ }
1894
+ if (idx < 0) {
1895
+ idx = rolls.length;
1896
+ rolls.push(makeRoll());
1897
+ spot = findSpot(rolls[idx], lw, Lc);
1898
+ if (!spot) {
1899
+ spot = { x: 0, y: Math.max(0, (rollWidthFt - lw) / 2) };
1900
+ s.overRoll = Number.isFinite(L) && Lc > L + EPS;
1901
+ } else s.overRoll = false;
1902
+ } else s.overRoll = false;
1903
+ const baseX = Number.isFinite(L) ? idx * rollLenFt : 0;
1904
+ s.cutX = baseX + spot.x;
1905
+ s.cutY = spot.y;
1906
+ s.autoCutX = s.cutX;
1907
+ s.autoCutY = s.cutY;
1908
+ s.rollIndex = idx;
1909
+ raise(rolls[idx], spot.y, lw, spot.x + Lc);
1910
+ });
1911
+ return strips;
1912
+ }
1913
+ function rollLayoutRollCount(strips) {
1914
+ return (strips || []).reduce((m, s) => Math.max(m, (s.rollIndex || 0) + 1), 0);
1915
+ }
1916
+ function rollLayoutOrderLengthFt(strips) {
1917
+ return strips.reduce((m, s) => Math.max(m, (s.cutX || 0) + Math.max(0, s.runMax - s.runMin)), 0);
1918
+ }
1919
+ function roundUpToInch(ft) {
1920
+ return Math.ceil(ft * 12 - 1e-6) / 12;
1921
+ }
1922
+ function applyStripOverrides(strips, overrides) {
1923
+ if (!overrides) return;
1924
+ strips.forEach((s) => {
1925
+ const o = overrides[s.id];
1926
+ if (o && o.laneCount === s.laneCount && Number.isFinite(o.runMin) && Number.isFinite(o.runMax) && o.runMax > o.runMin) {
1927
+ s.runMin = o.runMin;
1928
+ s.runMax = o.runMax;
1929
+ }
1930
+ });
1931
+ }
1932
+ function seqFromOverrides(strips, overrides) {
1933
+ if (!overrides) return null;
1934
+ const m = {};
1935
+ let any = false;
1936
+ strips.forEach((s) => {
1937
+ const o = overrides[s.id];
1938
+ if (o && o.laneCount === s.laneCount && Number.isFinite(o.seq)) {
1939
+ m[s.id] = o.seq;
1940
+ any = true;
1941
+ }
1942
+ });
1943
+ return any ? m : null;
1944
+ }
1945
+ function computeRollLayout(items, config, overrides = {}, extraStrips = []) {
1946
+ const build = (d) => items.flatMap((it) => layoutRingStrips(it, d, config));
1947
+ let dir = config.direction, strips;
1948
+ if (dir === "ns" || dir === "ew") {
1949
+ strips = build(dir);
1950
+ } else {
1951
+ const ns = build("ns"), ew = build("ew");
1952
+ if (sumStripLengthsFt(ns) <= sumStripLengthsFt(ew)) {
1953
+ dir = "ns";
1954
+ strips = ns;
1955
+ } else {
1956
+ dir = "ew";
1957
+ strips = ew;
1958
+ }
1959
+ }
1960
+ if (extraStrips && extraStrips.length) strips = strips.concat(extraStrips);
1961
+ const maxW = config.rollWidthFt;
1962
+ strips.forEach((s) => {
1963
+ if (s.laneMax - s.laneMin > maxW + 1e-6) s.laneMax = s.laneMin + maxW;
1964
+ if (s.coverMax - s.coverMin > maxW + 1e-6) s.coverMax = s.coverMin + maxW;
1965
+ });
1966
+ applyStripOverrides(strips, overrides);
1967
+ assignDefaultCutPositions(strips, config.rollWidthFt, seqFromOverrides(strips, overrides), config.rollLengthFt || 0);
1968
+ return { direction: dir, strips, totalLinearFt: sumStripLengthsFt(strips) };
1969
+ }
1970
+ function rollCutNumbers(strips) {
1971
+ const m = /* @__PURE__ */ new Map();
1972
+ strips.slice().sort((a, b) => {
1973
+ const ax = a.cutX || 0, bx = b.cutX || 0;
1974
+ if (Math.abs(ax - bx) > 1e-6) return ax - bx;
1975
+ return (a.cutY || 0) - (b.cutY || 0);
1976
+ }).forEach((s, i) => m.set(s.id, i + 1));
1977
+ return m;
1978
+ }
1979
+
1980
+ // ../web/src/lib/rollTakeoff.js
1981
+ function hasRollSetup(c) {
1982
+ const rs = c?.roll_setup;
1983
+ return !!rs && typeof rs === "object" && !Array.isArray(rs) && Number(rs.roll_width_ft) > 0;
1984
+ }
1985
+ function mintRollSetup(material) {
1986
+ const ft = isRollType(material) ? material : "carpet";
1987
+ return { material: ft, ...defaultRollSetup(ft) };
1988
+ }
1989
+ function rollConfig(rs) {
1990
+ return {
1991
+ rollWidthFt: Math.max(0.5, Number(rs.roll_width_ft) || 12),
1992
+ rollLengthFt: Math.max(0, Number(rs.roll_length_ft) || 0),
1993
+ seamAllowanceIn: Math.max(0, Number(rs.seam_allowance_in) || 0),
1994
+ wallOverageIn: Math.max(0, Number(rs.wall_overage_in) || 0),
1995
+ doorwayOverageIn: Math.max(0, Number(rs.doorway_overage_in) || 0),
1996
+ direction: rs.direction === "ns" || rs.direction === "ew" ? rs.direction : "auto"
1997
+ };
1998
+ }
1999
+ function collectRollOverrides(shapes) {
2000
+ const out = {};
2001
+ for (const s of shapes) {
2002
+ const rl = s.roll_layout;
2003
+ if (!rl || typeof rl !== "object" || !rl.lanes || typeof rl.lanes !== "object") continue;
2004
+ for (const [li, o] of Object.entries(rl.lanes)) {
2005
+ if (!o || typeof o !== "object") continue;
2006
+ out[`${s.id}:${li}`] = { ...o, laneCount: rl.laneCount };
2007
+ }
2008
+ }
2009
+ return out;
2010
+ }
2011
+ var stripLenFt = (s) => Math.max(0, s.runMax - s.runMin);
2012
+ var stripWidthFt = (s) => Math.max(0, s.laneMax - s.laneMin);
2013
+ function stripSheetRect(strip, upp) {
2014
+ if (!(upp > 0)) return null;
2015
+ const lane0 = strip.laneMin / upp, lane1 = strip.laneMax / upp;
2016
+ const run0 = strip.runMin / upp, run1 = strip.runMax / upp;
2017
+ return strip.laneAxis === "x" ? { x: lane0, y: run0, w: lane1 - lane0, h: run1 - run0 } : { x: run0, y: lane0, w: run1 - run0, h: lane1 - lane0 };
2018
+ }
2019
+ function computeRollTakeoff(conditions, shapes, dimsFor, uppFor) {
2020
+ const byCond = /* @__PURE__ */ new Map();
2021
+ const cutsBySheet = /* @__PURE__ */ new Map();
2022
+ const rollConds = (conditions || []).filter(hasRollSetup);
2023
+ if (!rollConds.length) return { byCond, cutsBySheet };
2024
+ const shapeById = new Map(shapes.map((s) => [s.id, s]));
2025
+ for (const c of rollConds) {
2026
+ const rs = c.roll_setup;
2027
+ const config = rollConfig(rs);
2028
+ const items = [];
2029
+ for (const s of shapes) {
2030
+ if (s.condition_id !== c.id || s.measure_role !== "floor_area") continue;
2031
+ if (!Array.isArray(s.verts_norm) || s.verts_norm.length < 3) continue;
2032
+ const dims = dimsFor(s.sheet_id), upp = uppFor(s.sheet_id);
2033
+ if (!dims || !(dims.w > 0) || !(upp > 0)) continue;
2034
+ items.push({ id: s.id, name: s.label || "", ring: s.verts_norm.map(([nx, ny]) => ({ x: nx * dims.w * upp, y: ny * dims.h * upp })) });
2035
+ }
2036
+ if (!items.length) continue;
2037
+ const layout = computeRollLayout(items, config, collectRollOverrides(shapes));
2038
+ const nums = rollCutNumbers(layout.strips);
2039
+ const orderFt = roundUpToInch(rollLayoutOrderLengthFt(layout.strips));
2040
+ const material = isRollType(rs.material) ? rs.material : "carpet";
2041
+ const unit = rs.price_unit === "lf" || rs.price_unit === "sf" || rs.price_unit === "sy" ? rs.price_unit : "sf";
2042
+ byCond.set(c.id, {
2043
+ material,
2044
+ config,
2045
+ direction: layout.direction,
2046
+ strips: layout.strips,
2047
+ nums,
2048
+ orderFt,
2049
+ rollCount: rollLayoutRollCount(layout.strips),
2050
+ oversize: layout.strips.some((s) => s.overRoll),
2051
+ qty: round22(rollQtyForUnit(orderFt, config.rollWidthFt, unit)),
2052
+ unit,
2053
+ cutCount: layout.strips.length
2054
+ });
2055
+ for (const strip of layout.strips) {
2056
+ const src = shapeById.get(strip.srcId);
2057
+ if (!src) continue;
2058
+ const upp = uppFor(src.sheet_id);
2059
+ const rect = stripSheetRect(strip, upp);
2060
+ if (!rect) continue;
2061
+ if (!cutsBySheet.has(src.sheet_id)) cutsBySheet.set(src.sheet_id, []);
2062
+ cutsBySheet.get(src.sheet_id).push({
2063
+ id: strip.id,
2064
+ srcId: strip.srcId,
2065
+ condId: c.id,
2066
+ laneIndex: strip.laneIndex,
2067
+ laneCount: strip.laneCount,
2068
+ laneAxis: strip.laneAxis,
2069
+ x: rect.x,
2070
+ y: rect.y,
2071
+ w: rect.w,
2072
+ h: rect.h,
2073
+ runMin: strip.runMin,
2074
+ runMax: strip.runMax,
2075
+ upp,
2076
+ lenFt: stripLenFt(strip),
2077
+ widthFt: stripWidthFt(strip),
2078
+ num: nums.get(strip.id) || 0,
2079
+ overRoll: !!strip.overRoll,
2080
+ multi: strip.laneCount > 1,
2081
+ material
2082
+ });
2083
+ }
2084
+ }
2085
+ return { byCond, cutsBySheet };
2086
+ }
2087
+ function rollReportRows(rollByCond, rows) {
2088
+ if (!rollByCond || !rollByCond.size || !Array.isArray(rows)) return [];
2089
+ const out = [];
2090
+ for (const r of rows) {
2091
+ const ri = rollByCond.get(r.id);
2092
+ if (!ri) continue;
2093
+ const mult = r.multiplier || 1;
2094
+ out.push({
2095
+ condition_id: r.id,
2096
+ finish_tag: r.finish_tag,
2097
+ material: ri.material,
2098
+ roll_width_ft: ri.config.rollWidthFt,
2099
+ roll_length_ft: ri.config.rollLengthFt,
2100
+ direction: ri.direction,
2101
+ cuts: ri.cutCount,
2102
+ order_lf: round22(ri.orderFt * mult),
2103
+ rolls: ri.rollCount * mult,
2104
+ order_qty: round22(ri.qty * mult),
2105
+ order_unit: ri.unit,
2106
+ oversize: ri.oversize
2107
+ });
2108
+ }
2109
+ return out;
2110
+ }
2111
+
1678
2112
  // src/view.ts
1679
2113
  var INK = "#d91a1a";
1680
2114
  var PENCIL = "#2659e6";
@@ -1741,15 +2175,28 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
1741
2175
  const w = Math.max(1.4, longEdge / 700);
1742
2176
  for (const s of shapes) {
1743
2177
  const pts = s.verts_norm.map(([nx, ny]) => toCanvas(nx * sheetW, ny * sheetH));
1744
- if (pts.length < 2) continue;
2178
+ if (!pts.length) continue;
1745
2179
  const pending = s.origin?.reviewed === false;
1746
2180
  ctx.strokeStyle = pending ? PENCIL : INK;
1747
2181
  ctx.lineWidth = w;
2182
+ if (s.measure_role === "count") {
2183
+ const m = Math.max(4, longEdge / 160);
2184
+ const [x, y] = pts[0];
2185
+ ctx.setLineDash([]);
2186
+ ctx.beginPath();
2187
+ ctx.moveTo(x - m, y - m);
2188
+ ctx.lineTo(x + m, y + m);
2189
+ ctx.moveTo(x - m, y + m);
2190
+ ctx.lineTo(x + m, y - m);
2191
+ ctx.stroke();
2192
+ continue;
2193
+ }
2194
+ if (pts.length < 2) continue;
1748
2195
  ctx.setLineDash(pending ? [w * 4, w * 3] : []);
1749
2196
  ctx.beginPath();
1750
2197
  ctx.moveTo(pts[0][0], pts[0][1]);
1751
2198
  for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
1752
- if (s.measure_role !== "linear") ctx.closePath();
2199
+ if (s.measure_role !== "linear" && s.measure_role !== "surface_area") ctx.closePath();
1753
2200
  ctx.stroke();
1754
2201
  }
1755
2202
  ctx.setLineDash([]);
@@ -2398,6 +2845,78 @@ var Session = class _Session {
2398
2845
  this.flushCommits("measure_line");
2399
2846
  return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
2400
2847
  }
2848
+ /** Surface Area — the canvas's Surface tool (commitSurface): an OPEN run
2849
+ * traced along the wall in plan view, quantified as traced LF × height.
2850
+ * Height lives on the CONDITION (the canvas's H knob); an explicit height_ft
2851
+ * here writes that knob first, exactly like typing H before tracing — and
2852
+ * that write journals as its own condition step, so undo stays exact.
2853
+ * The refusal path mints nothing: no height, no condition side effects. */
2854
+ measureSurface(name, pts, opts) {
2855
+ const s = this.sheet(name);
2856
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
2857
+ const existing = this.conditions.find((x) => x.finish_tag === opts.condition);
2858
+ const h = opts.height_ft ?? (Number(existing?.height_ft) || 0);
2859
+ if (!(h > 0)) {
2860
+ throw new UserError(`Set a height for ${opts.condition} first \u2014 Surface Area = traced LF \xD7 height. Pass height_ft on this call, or set it with edit_condition.`);
2861
+ }
2862
+ const c = this.conditionFor(opts.condition);
2863
+ if (opts.height_ft !== void 0 && c.height_ft !== opts.height_ft) {
2864
+ this.record({ op: "condition", tool: "measure_surface", condition_id: c.id, before: { waste_pct: c.waste_pct, multiplier: c.multiplier, height_ft: c.height_ft } });
2865
+ c.height_ft = opts.height_ft;
2866
+ }
2867
+ const LF = openLen(pts) * s.upp;
2868
+ const shape = this.commit(s, opts.condition, "surface_area", pts, { area_sf: round2(LF * h), perimeter_lf: round2(LF) }, { method: "manual", actor: "agent" });
2869
+ shape.height_ft = h;
2870
+ this.flushCommits("measure_surface");
2871
+ return { condition: c.finish_tag, height_ft: h, length_lf: round2(LF), area_sf: round2(LF * h), npts: pts.length, shape_id: shape.id };
2872
+ }
2873
+ /** Count markers — the canvas's Count tool (commitCount): one point, one EA,
2874
+ * computed {count: 1}, NO scale required (EA is scale-free; the canvas's
2875
+ * recompute skips count shapes for the same reason). One shape per point,
2876
+ * the whole call one journal gesture — undoing a placement sweep is one step,
2877
+ * matching detect_rooms. */
2878
+ placeCount(name, points, opts) {
2879
+ const s = this.sheet(name);
2880
+ const ids = points.map(([x, y]) => this.commit(s, opts.condition, "count", [[x, y]], { count: 1 }, { method: "manual", actor: "agent" }).id);
2881
+ this.flushCommits("place_count");
2882
+ const c = this.conditions.find((x) => x.finish_tag === opts.condition);
2883
+ const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((n, x) => n + (x.computed.count || 1), 0);
2884
+ return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
2885
+ }
2886
+ /** The mid-session shape inventory (#149): every committed shape's id,
2887
+ * home, role, and quantities in one compact read — the ids edit_shape /
2888
+ * delete_shape assume you have, without pulling the whole export_takeoff
2889
+ * payload to find one shape. Filters narrow, they never 404 an empty list. */
2890
+ listShapes(f = {}) {
2891
+ if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
2892
+ let rows = this.shapes;
2893
+ if (f.sheet) {
2894
+ const s = this.sheet(f.sheet);
2895
+ rows = rows.filter((x) => x.sheet_id === s.key);
2896
+ }
2897
+ if (f.condition) {
2898
+ const c = this.conditions.find((x) => x.finish_tag === f.condition);
2899
+ if (!c) throw new UserError(`No condition ${JSON.stringify(f.condition)} \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}.`);
2900
+ rows = rows.filter((x) => x.condition_id === c.id);
2901
+ }
2902
+ const tagById = new Map(this.conditions.map((c) => [c.id, c.finish_tag]));
2903
+ return {
2904
+ shapes: rows.map((x) => ({
2905
+ id: x.id,
2906
+ sheet: x.sheet_id,
2907
+ condition: tagById.get(x.condition_id) ?? "",
2908
+ measure_role: x.measure_role,
2909
+ ...x.computed.area_sf !== void 0 ? { area_sf: x.computed.area_sf } : {},
2910
+ ...x.computed.perimeter_lf !== void 0 ? { perimeter_lf: x.computed.perimeter_lf } : {},
2911
+ ...x.computed.count !== void 0 ? { count: x.computed.count } : {},
2912
+ ...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
2913
+ nverts: x.verts_norm.length,
2914
+ reviewed: x.origin?.reviewed === true,
2915
+ ...x.origin?.agent_edits ? { agent_edits: x.origin.agent_edits } : {}
2916
+ })),
2917
+ count: rows.length
2918
+ };
2919
+ }
2401
2920
  summary() {
2402
2921
  const rows = conditionTotals(this.conditions, this.shapes);
2403
2922
  const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
@@ -2441,15 +2960,25 @@ var Session = class _Session {
2441
2960
  throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role.");
2442
2961
  }
2443
2962
  const s = this.sheet(cur.sheet_id);
2444
- if (s.upp == null) throw new UserError(this.scaleGate(s));
2445
- const upp = s.upp;
2446
2963
  const role = patch.role ?? cur.measure_role;
2964
+ if (role !== "count" && s.upp == null) throw new UserError(this.scaleGate(s));
2965
+ const upp = s.upp ?? 0;
2447
2966
  const vertsPx = patch.verts ?? cur.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
2448
- const minPts = role === "linear" ? 2 : 3;
2967
+ const minPts = role === "count" ? 1 : role === "linear" || role === "surface_area" ? 2 : 3;
2449
2968
  if (vertsPx.length < minPts) {
2450
- throw new UserError(`A ${role === "linear" ? "linear shape needs at least 2 points" : "closed shape needs at least 3 vertices"} \u2014 got ${vertsPx.length}.`);
2969
+ throw new UserError(`A ${role === "count" ? "count marker needs at least 1 point" : role === "linear" || role === "surface_area" ? `${role} shape needs at least 2 points` : "closed shape needs at least 3 vertices"} \u2014 got ${vertsPx.length}.`);
2451
2970
  }
2452
- const computed = role === "linear" ? { area_sf: 0, perimeter_lf: round2(openLen(vertsPx) * upp) } : (() => {
2971
+ const heightFor = () => {
2972
+ const condId = patch.condition !== void 0 ? this.conditionFor(patch.condition).id : cur.condition_id;
2973
+ const cond = this.conditions.find((x) => x.id === condId);
2974
+ const h = Number(cur.height_ft) || Number(cond?.height_ft) || 0;
2975
+ if (!(h > 0)) throw new UserError(`Surface Area needs a height \u2014 set height_ft on ${cond?.finish_tag ?? "the condition"} with edit_condition first.`);
2976
+ return h;
2977
+ };
2978
+ const computed = role === "count" ? { count: cur.computed.count ?? 1 } : role === "linear" ? { area_sf: 0, perimeter_lf: round2(openLen(vertsPx) * upp) } : role === "surface_area" ? (() => {
2979
+ const LF = openLen(vertsPx) * upp;
2980
+ return { area_sf: round2(LF * heightFor()), perimeter_lf: round2(LF) };
2981
+ })() : (() => {
2453
2982
  const met = closedMetrics(vertsPx);
2454
2983
  return { area_sf: round2(met.area * upp * upp), perimeter_lf: round2(met.perim * upp) };
2455
2984
  })();
@@ -2461,6 +2990,7 @@ var Session = class _Session {
2461
2990
  measure_role: role,
2462
2991
  verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
2463
2992
  computed,
2993
+ ...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
2464
2994
  ...cur.origin ? { origin: { ...cur.origin, agent_edits: (cur.origin.agent_edits ?? 0) + 1 } } : {}
2465
2995
  };
2466
2996
  this.record({ op: "edit", tool: "edit_shape", before });
@@ -2545,20 +3075,63 @@ var Session = class _Session {
2545
3075
  * mean anything on a condition that exists, and a typo'd tag must error,
2546
3076
  * not create an empty condition as a side effect. One journal entry
2547
3077
  * snapshots both knobs; undo restores them verbatim. */
3078
+ /** dimsFor / uppFor as computeRollTakeoff wants them — bitmap px and real
3079
+ * feet per px, null for sheets that can't participate. */
3080
+ rollInputs() {
3081
+ return {
3082
+ dimsFor: (sheetId) => {
3083
+ const s = this.sheets.get(sheetId);
3084
+ return s ? { w: s.widthPx, h: s.heightPx } : null;
3085
+ },
3086
+ uppFor: (sheetId) => this.sheets.get(sheetId)?.upp ?? null
3087
+ };
3088
+ }
2548
3089
  editCondition(tag, opts) {
2549
- if (opts.waste_pct === void 0 && opts.multiplier === void 0) {
2550
- throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier.");
3090
+ if (opts.waste_pct === void 0 && opts.multiplier === void 0 && opts.height_ft === void 0 && opts.roll_setup === void 0) {
3091
+ throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier, height_ft, roll_setup.");
2551
3092
  }
2552
3093
  const c = this.conditions.find((x) => x.finish_tag === tag);
2553
3094
  if (!c) {
2554
3095
  const known = this.conditions.map((x) => x.finish_tag);
2555
3096
  throw new UserError(`No condition ${JSON.stringify(tag)}.${known.length ? ` Known tags: ${known.join(", ")}.` : " Nothing has minted a condition yet \u2014 commit a measurement or add materials first."}`);
2556
3097
  }
2557
- const before = { waste_pct: c.waste_pct, multiplier: c.multiplier };
3098
+ const before = {
3099
+ waste_pct: c.waste_pct,
3100
+ multiplier: c.multiplier,
3101
+ height_ft: c.height_ft,
3102
+ roll_setup: c.roll_setup ? structuredClone(c.roll_setup) : void 0
3103
+ };
2558
3104
  if (opts.waste_pct !== void 0) c.waste_pct = opts.waste_pct;
2559
3105
  if (opts.multiplier !== void 0) c.multiplier = opts.multiplier;
3106
+ if (opts.height_ft !== void 0) c.height_ft = opts.height_ft;
3107
+ if (opts.roll_setup !== void 0) {
3108
+ if (opts.roll_setup === null) {
3109
+ delete c.roll_setup;
3110
+ } else {
3111
+ const given = Object.fromEntries(Object.entries(opts.roll_setup).filter(([, v]) => v !== void 0));
3112
+ const prevMaterial = c.roll_setup?.material;
3113
+ const material = given.material ?? prevMaterial ?? "carpet";
3114
+ const base = hasRollSetup(c) && material === prevMaterial ? c.roll_setup : mintRollSetup(material);
3115
+ c.roll_setup = { ...base, ...given, material };
3116
+ }
3117
+ }
2560
3118
  this.record({ op: "condition", tool: "edit_condition", condition_id: c.id, before });
2561
- return { condition: tag, condition_id: c.id, waste_pct: c.waste_pct, multiplier: c.multiplier };
3119
+ let roll;
3120
+ if (hasRollSetup(c)) {
3121
+ const { dimsFor, uppFor } = this.rollInputs();
3122
+ const { byCond } = computeRollTakeoff([c], this.shapes, dimsFor, uppFor);
3123
+ const rows = conditionTotals([c], this.shapes);
3124
+ roll = rollReportRows(byCond, rows)[0];
3125
+ }
3126
+ return {
3127
+ condition: tag,
3128
+ condition_id: c.id,
3129
+ waste_pct: c.waste_pct,
3130
+ multiplier: c.multiplier,
3131
+ ...c.height_ft !== void 0 ? { height_ft: c.height_ft } : {},
3132
+ ...c.roll_setup ? { roll_setup: c.roll_setup } : {},
3133
+ ...roll ? { roll } : {}
3134
+ };
2562
3135
  }
2563
3136
  /** Step back over this session's own last n mutations, newest first. Each
2564
3137
  * entry's inverse is exact (see JournalEntry), so this restores state rather
@@ -2586,6 +3159,10 @@ var Session = class _Session {
2586
3159
  if (c) {
2587
3160
  c.waste_pct = e.before.waste_pct;
2588
3161
  c.multiplier = e.before.multiplier;
3162
+ if (e.before.height_ft === void 0) delete c.height_ft;
3163
+ else c.height_ft = e.before.height_ft;
3164
+ if (e.before.roll_setup === void 0) delete c.roll_setup;
3165
+ else c.roll_setup = e.before.roll_setup;
2589
3166
  }
2590
3167
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
2591
3168
  } else {
@@ -2616,8 +3193,9 @@ var Session = class _Session {
2616
3193
  const s = this.sheet(a.sheet);
2617
3194
  const n = ([x, y]) => [x / s.widthPx, y / s.heightPx];
2618
3195
  if ((a.type === "cloud" || a.type === "highlight") && !a.rect) throw new UserError(`a ${a.type} needs rect: [[x0,y0],[x1,y1]] in image px`);
2619
- if ((a.type === "text" || a.type === "callout") && !a.at) throw new UserError(`a ${a.type} needs at: [x,y] in image px`);
3196
+ if ((a.type === "text" || a.type === "callout" || a.type === "bubble") && !a.at) throw new UserError(`a ${a.type} needs at: [x,y] in image px`);
2620
3197
  if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
3198
+ if (a.type === "arrow" && (!a.from || !a.to)) throw new UserError("an arrow needs from: [x,y] and to: [x,y] \u2014 tail and head, in image px");
2621
3199
  const cond = a.condition ? this.conditionFor(a.condition) : null;
2622
3200
  const m = {
2623
3201
  id: uid("mk"),
@@ -2629,7 +3207,12 @@ var Session = class _Session {
2629
3207
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
2630
3208
  ...a.at ? { at: n(a.at) } : {},
2631
3209
  ...a.target ? { target: n(a.target) } : {},
2632
- ...a.rect ? { rect: [n(a.rect[0]), n(a.rect[1])] } : {}
3210
+ ...a.rect ? { rect: [n(a.rect[0]), n(a.rect[1])] } : {},
3211
+ ...a.from ? { from: n(a.from) } : {},
3212
+ ...a.to ? { to: n(a.to) } : {},
3213
+ // bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
3214
+ // canvas default is 0.02 when unset — stored explicitly so exports agree
3215
+ ...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {}
2633
3216
  };
2634
3217
  this.markups.push(m);
2635
3218
  return {
@@ -2673,7 +3256,10 @@ var Session = class _Session {
2673
3256
  condition_id: m.condition_id,
2674
3257
  ...m.at ? { at: px(m, m.at) } : {},
2675
3258
  ...m.target ? { target: px(m, m.target) } : {},
2676
- ...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {}
3259
+ ...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
3260
+ ...m.from ? { from: px(m, m.from) } : {},
3261
+ ...m.to ? { to: px(m, m.to) } : {},
3262
+ ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {}
2677
3263
  })),
2678
3264
  count: rows.length,
2679
3265
  unattached: rows.filter((m) => !m.condition_id).length
@@ -2720,13 +3306,16 @@ var Session = class _Session {
2720
3306
  exportReport(projectName = "") {
2721
3307
  if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
2722
3308
  const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
3309
+ const { dimsFor, uppFor } = this.rollInputs();
3310
+ const { byCond } = computeRollTakeoff(this.conditions, this.shapes, dimsFor, uppFor);
2723
3311
  return reportJson({
2724
3312
  projectName,
2725
3313
  rows,
2726
3314
  bySheet: sheetTotals(this.conditions, this.shapes),
2727
3315
  scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown" })),
2728
3316
  markups: this.markups,
2729
- rfis: []
3317
+ rfis: [],
3318
+ rollGoods: rollReportRows(byCond, rows)
2730
3319
  });
2731
3320
  }
2732
3321
  // ── the sheet graph (#87) ─────────────────────────────────────────────────
@@ -2953,6 +3542,20 @@ var measurePolygonOutput = {
2953
3542
  nverts: z.number().int(),
2954
3543
  shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
2955
3544
  };
3545
+ var measureSurfaceOutput = {
3546
+ condition: z.string(),
3547
+ height_ft: z.number().describe("The height this shape was quantified at (snapshotted on the shape)"),
3548
+ length_lf: z.number().describe("The traced run's open length"),
3549
+ area_sf: z.number().describe("length_lf \xD7 height_ft \u2014 the wall SF committed"),
3550
+ npts: z.number().int(),
3551
+ shape_id: z.string()
3552
+ };
3553
+ var placeCountOutput = {
3554
+ committed: z.number().int().describe("Count shapes committed by this call \u2014 one per point"),
3555
+ shape_ids: z.array(z.string()),
3556
+ condition: z.string(),
3557
+ ea_total: z.number().describe("The condition's total EA after this call")
3558
+ };
2956
3559
  var measureLineOutput = {
2957
3560
  length_lf: z.number(),
2958
3561
  npts: z.number().int(),
@@ -3007,9 +3610,9 @@ var exportTakeoffOutput = {
3007
3610
  id: z.string(),
3008
3611
  sheet_id: z.string(),
3009
3612
  condition_id: z.string(),
3010
- measure_role: z.enum(["floor_area", "deduct", "linear"]),
3613
+ measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
3011
3614
  verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
3012
- computed: z.object({ area_sf: z.number(), perimeter_lf: z.number() }).passthrough(),
3615
+ computed: z.object({ area_sf: z.number().optional(), perimeter_lf: z.number().optional(), count: z.number().optional() }).passthrough().describe("count shapes carry {count} alone; every other role carries area_sf + perimeter_lf"),
3013
3616
  origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
3014
3617
  }).passthrough()),
3015
3618
  markups: z.array(z.unknown()),
@@ -3018,6 +3621,22 @@ var exportTakeoffOutput = {
3018
3621
  sheet_tabs: z.array(z.unknown()),
3019
3622
  sheet_levels: z.object({}).passthrough()
3020
3623
  };
3624
+ var listShapesOutput = {
3625
+ shapes: z.array(z.object({
3626
+ id: z.string(),
3627
+ sheet: z.string(),
3628
+ condition: z.string(),
3629
+ measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
3630
+ area_sf: z.number().optional(),
3631
+ perimeter_lf: z.number().optional(),
3632
+ count: z.number().optional(),
3633
+ height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
3634
+ nverts: z.number().int(),
3635
+ reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
3636
+ agent_edits: z.number().int().optional().describe("Present when the agent has revised this shape")
3637
+ })),
3638
+ count: z.number().int()
3639
+ };
3021
3640
  var deleteShapeOutput = {
3022
3641
  deleted: z.string().describe("The removed shape's id"),
3023
3642
  shape_count: z.number().int().describe("Committed shapes remaining")
@@ -3025,10 +3644,11 @@ var deleteShapeOutput = {
3025
3644
  var editShapeOutput = {
3026
3645
  shape_id: z.string(),
3027
3646
  changed: z.array(z.enum(["verts", "condition", "role"])).describe("Which fields this call actually changed"),
3028
- measure_role: z.enum(["floor_area", "deduct", "linear"]),
3647
+ measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
3029
3648
  nverts: z.number().int(),
3030
- area_sf: z.number().describe("0 for linear shapes"),
3031
- perimeter_lf: z.number().describe("Length for linear shapes, perimeter for closed ones"),
3649
+ area_sf: z.number().optional().describe("0 for linear shapes; LF \xD7 height for surface_area; absent for count"),
3650
+ perimeter_lf: z.number().optional().describe("Length for linear/surface runs, perimeter for closed ones; absent for count"),
3651
+ count: z.number().optional().describe("count shapes only \u2014 the marker's EA (preserved across the edit)"),
3032
3652
  agent_edits: z.number().int().describe("How many times the agent has revised this shape \u2014 separate from the human-correction tally")
3033
3653
  };
3034
3654
  var undoLastOutput = {
@@ -3119,7 +3739,23 @@ var editConditionOutput = {
3119
3739
  condition: z.string().describe("The finish tag passed in"),
3120
3740
  condition_id: z.string(),
3121
3741
  waste_pct: z.number().describe("The condition's waste % after this write"),
3122
- multiplier: z.number().describe("The condition's quantity multiplier after this write")
3742
+ multiplier: z.number().describe("The condition's quantity multiplier after this write"),
3743
+ height_ft: z.number().optional().describe("The condition's wall height after this write \u2014 present once set (measure_surface multiplies traced LF by it)"),
3744
+ roll_setup: z.object({}).passthrough().optional().describe("The condition's roll-goods setup after this write \u2014 present while opted in"),
3745
+ roll: z.object({
3746
+ condition_id: z.string(),
3747
+ finish_tag: z.string(),
3748
+ material: z.string(),
3749
+ roll_width_ft: z.number(),
3750
+ roll_length_ft: z.number(),
3751
+ direction: z.string(),
3752
+ cuts: z.number().int(),
3753
+ order_lf: z.number().describe("Full-width roll footage to order, \xD7N applied, rounded up to the inch"),
3754
+ rolls: z.number(),
3755
+ order_qty: z.number(),
3756
+ order_unit: z.string(),
3757
+ oversize: z.boolean().describe("true when a cut exceeds the physical roll length (roll_length_ft binds)")
3758
+ }).passthrough().optional().describe("The figured order (same row export_report's roll_goods carries) \u2014 present when the roll-goods condition has floor shapes on scaled sheets")
3123
3759
  };
3124
3760
  var readSheetTextOutput = {
3125
3761
  sheet: z.string(),
@@ -3209,7 +3845,10 @@ var annotationRow = z.object({
3209
3845
  condition_id: z.string(),
3210
3846
  at: z.tuple([z.number(), z.number()]).optional(),
3211
3847
  target: z.tuple([z.number(), z.number()]).optional(),
3212
- rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional()
3848
+ rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional(),
3849
+ from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail (image px)"),
3850
+ to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head (image px)"),
3851
+ r: z.number().optional().describe("Bubble radius (image px)")
3213
3852
  });
3214
3853
  var annotateOutput = {
3215
3854
  id: z.string(),
@@ -4456,6 +5095,25 @@ function registerTools(server, session) {
4456
5095
  },
4457
5096
  outputSchema: measureLineOutput
4458
5097
  }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
5098
+ server.registerTool("measure_surface", {
5099
+ description: `Surface Area \u2014 wall SF (#146): trace an OPEN run along the wall in plan view (min 2 points, image px) and the quantity is traced LF \xD7 height. This is how wall tile, wainscot, and wall systems are taken off \u2014 the quantity family one_click and measure_polygon cannot produce. Height lives on the CONDITION (the canvas's H knob): pass height_ft to set it on this call (journals as its own undo step, like typing H before tracing), or set it once with edit_condition; with neither, this refuses and mints nothing. The shape snapshots the height it was quantified at. Requires the sheet's scale. ${COORDS}`,
5100
+ inputSchema: {
5101
+ sheet: z2.string(),
5102
+ pts: z2.array(pointSchema).min(2).describe("The wall run, an open polyline (image px)"),
5103
+ condition: z2.string().describe("Finish tag to commit under (minted on first use), e.g. 'CT-W1'"),
5104
+ height_ft: z2.number().positive().optional().describe("Wall height in feet \u2014 written to the condition's H knob first, then used")
5105
+ },
5106
+ outputSchema: measureSurfaceOutput
5107
+ }, run("measure_surface", (a) => session.measureSurface(a.sheet, a.pts, { condition: a.condition, height_ft: a.height_ft })));
5108
+ server.registerTool("place_count", {
5109
+ description: `Count markers \u2014 EA (#146): one point, one each. Thresholds, stair nosings, floor boxes, entrance mats \u2014 the scale-free quantity family. Commits one count shape per point (computed {count: 1}, exactly the canvas's Count tool), NO scale required, and the whole call is ONE undo step like a detect_rooms sweep. takeoff_summary reports them as ea; the marked set draws each marker. ${COORDS}`,
5110
+ inputSchema: {
5111
+ sheet: z2.string(),
5112
+ points: z2.array(pointSchema).min(1).describe("Marker positions (image px), one committed count shape each"),
5113
+ condition: z2.string().describe("Finish tag to commit under (minted on first use), e.g. 'TR-1'")
5114
+ },
5115
+ outputSchema: placeCountOutput
5116
+ }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
4459
5117
  server.registerTool("takeoff_summary", {
4460
5118
  description: `Per-condition totals (floor/wall/border SF, LF, EA, SY, with and without waste) plus grand totals \u2014 the Report's numbers, computed by the same rules. Numbers only: the deliverable that SHOWS the work on the drawings is export_marked_pdf. ${COORDS}`,
4461
5119
  inputSchema: {},
@@ -4496,6 +5154,14 @@ function registerTools(server, session) {
4496
5154
  },
4497
5155
  outputSchema: exportMarkedPdfOutput
4498
5156
  }, run("export_marked_pdf", (a) => exportMarkedPdf(session, a)));
5157
+ server.registerTool("list_shapes", {
5158
+ description: `The mid-session shape inventory (#149): every committed shape's id, sheet, condition tag, role, quantities, vertex count, and review state in one compact read \u2014 the ids edit_shape and delete_shape assume you have, without pulling the whole export_takeoff payload to find one shape. Filter by sheet, by condition, or both; filters narrow, an empty list is a result, not an error.`,
5159
+ inputSchema: {
5160
+ sheet: z2.string().optional().describe("Only shapes on this sheet"),
5161
+ condition: z2.string().optional().describe("Only shapes under this finish tag (must exist)")
5162
+ },
5163
+ outputSchema: listShapesOutput
5164
+ }, run("list_shapes", (a) => session.listShapes(a)));
4499
5165
  server.registerTool("delete_shape", {
4500
5166
  description: `Remove a committed shape by the id returned when it was committed. ${COORDS}`,
4501
5167
  inputSchema: { shape_id: z2.string() },
@@ -4515,9 +5181,9 @@ function registerTools(server, session) {
4515
5181
  description: `REVISE a shape you already committed, instead of deleting it and starting over: pass new verts to move the geometry, condition to reassign it to a different finish tag, role to switch between floor_area / deduct / linear, or any combination. Quantities are recomputed from the result \u2014 a role flip alone re-measures (closed area vs open length). The loop this is for: one_click or measure_polygon to commit, view_sheet with overlay:true to LOOK at what landed, then edit_shape to fix the two vertices that overshot into the corridor. Shapes a human affirmed (origin.reviewed) are ink and are refused \u2014 an agent revises its own pencil and nothing else. Agent self-revision is tallied on origin.agent_edits, kept deliberately separate from the human-correction fields. ${COORDS}`,
4516
5182
  inputSchema: {
4517
5183
  shape_id: z2.string().describe("Id returned when the shape was committed"),
4518
- verts: z2.array(pointSchema).optional().describe("Replacement geometry (image px): \u22653 vertices for an area shape, \u22652 points for a linear one"),
5184
+ verts: z2.array(pointSchema).optional().describe("Replacement geometry (image px): \u22653 vertices for an area shape, \u22652 points for a linear/surface run, \u22651 for a count marker"),
4519
5185
  condition: z2.string().optional().describe("Reassign to this finish tag (minted on first use)"),
4520
- role: z2.enum(["floor_area", "deduct", "linear"]).optional().describe("Switch what the shape measures")
5186
+ role: z2.enum(["floor_area", "deduct", "linear", "surface_area", "count"]).optional().describe("Switch what the shape measures \u2014 flipping INTO surface_area needs a height on the shape or its condition")
4521
5187
  },
4522
5188
  outputSchema: editShapeOutput
4523
5189
  }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
@@ -4542,14 +5208,28 @@ function registerTools(server, session) {
4542
5208
  outputSchema: editMaterialsOutput
4543
5209
  }, run("edit_materials", (a) => session.editMaterials(a.condition, { add: a.add, remove: a.remove, patch: a.patch })));
4544
5210
  server.registerTool("edit_condition", {
4545
- description: `Set a condition's quantity knobs \u2014 waste % and/or multiplier. takeoff_summary emits waste-adjusted *_net order quantities and a per-condition multiplier, and every export carries both, but conditions minted through the measure tools start at waste 0 / multiplier 1 \u2014 without this tool an agent's takeoff always ships net === gross (#131). waste_pct is the estimator's cut-waste percentage (carpet commonly 5\u201310); multiplier scales every quantity on the condition (\xD7N identical floors \u2014 takeoff_summary applies it before waste). condition must resolve to an EXISTING finish tag \u2014 a typo'd tag errors rather than minting an empty condition (the edit_materials remove/patch rule, not its add rule: these knobs mean nothing on a condition that doesn't exist yet). No review gate \u2014 quantity config, not traced geometry; undo_last reverses a call in one step (both knobs snapshotted together, restored verbatim).`,
5211
+ description: `Set a condition's quantity knobs \u2014 waste %, multiplier, height_ft (the H knob measure_surface quantifies against), and/or roll_setup (the roll-goods opt-in: seams and order footage figured from the committed rooms, #147). takeoff_summary emits waste-adjusted *_net order quantities and a per-condition multiplier, and every export carries both, but conditions minted through the measure tools start at waste 0 / multiplier 1 \u2014 without this tool an agent's takeoff always ships net === gross (#131). waste_pct is the estimator's cut-waste percentage (carpet commonly 5\u201310); multiplier scales every quantity on the condition (\xD7N identical floors \u2014 takeoff_summary applies it before waste). condition must resolve to an EXISTING finish tag \u2014 a typo'd tag errors rather than minting an empty condition (the edit_materials remove/patch rule, not its add rule: these knobs mean nothing on a condition that doesn't exist yet). No review gate \u2014 quantity config, not traced geometry; undo_last reverses a call in one step (both knobs snapshotted together, restored verbatim).`,
4546
5212
  inputSchema: {
4547
5213
  condition: z2.string().describe("Finish tag of an existing condition, e.g. 'CPT-1'"),
4548
5214
  waste_pct: z2.number().min(0).optional().describe("Waste percentage applied to net order quantities, e.g. 10 for 10%"),
4549
- multiplier: z2.number().positive().optional().describe("Quantity multiplier (\xD7N identical areas). Note: the canvas treats 0 as 1, so 0 is rejected here rather than silently meaning 'off'")
5215
+ multiplier: z2.number().positive().optional().describe("Quantity multiplier (\xD7N identical areas). Note: the canvas treats 0 as 1, so 0 is rejected here rather than silently meaning 'off'"),
5216
+ height_ft: z2.number().positive().optional().describe("Wall height in feet \u2014 the canvas's H knob; measure_surface quantifies traced LF \xD7 this"),
5217
+ roll_setup: z2.union([
5218
+ z2.null().describe("Opt the condition OUT of roll goods"),
5219
+ z2.object({
5220
+ material: z2.enum(["carpet", "sheet_vinyl", "rubber"]).optional().describe("Material class \u2014 fresh opt-ins and material changes start from this class's engine defaults (carpet sells sy, others sf)"),
5221
+ roll_width_ft: z2.number().positive().optional(),
5222
+ roll_length_ft: z2.number().min(0).optional().describe("Physical roll length; 0 = unlimited"),
5223
+ seam_allowance_in: z2.number().min(0).optional(),
5224
+ wall_overage_in: z2.number().min(0).optional(),
5225
+ doorway_overage_in: z2.number().min(0).optional(),
5226
+ direction: z2.enum(["auto", "ns", "ew"]).optional().describe("Run direction; auto lets the engine pick per room"),
5227
+ price_unit: z2.enum(["sy", "sf", "lf"]).optional().describe("Sell unit the order quantity is figured in")
5228
+ })
5229
+ ]).optional().describe("Roll-goods opt-in (#147): presence of a setup is what makes the condition roll goods \u2014 seams figured, cuts packed, order footage beside the measured quantities. Same-material partial edits patch the existing setup; null opts out. The reply echoes the figured order (cuts, order_lf, rolls, order_qty) whenever floor shapes exist on scaled sheets, and export_report's roll_goods block carries the same rows")
4550
5230
  },
4551
5231
  outputSchema: editConditionOutput
4552
- }, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier })));
5232
+ }, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup })));
4553
5233
  server.registerTool("undo_last", {
4554
5234
  description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, an edit_materials call, or an edit_condition call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
4555
5235
  inputSchema: {
@@ -4612,19 +5292,22 @@ function registerTools(server, session) {
4612
5292
  return reply;
4613
5293
  });
4614
5294
  server.registerTool("annotate", {
4615
- description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at).
5295
+ description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text).
4616
5296
 
4617
5297
  Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like one_click/measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
4618
5298
 
4619
5299
  No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
4620
5300
  inputSchema: {
4621
5301
  sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
4622
- type: z2.enum(["cloud", "text", "callout", "highlight"]).describe("cloud/highlight need rect; text/callout need at; callout also needs target"),
4623
- text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'"),
5302
+ type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow needs from + to"),
5303
+ text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'; a bubble's text draws centered in the circle"),
4624
5304
  condition: z2.string().optional().describe("Finish tag to attach this note to, e.g. 'CPT-1' (minted on first use). Omit for an unattached sheet note"),
4625
- at: pointSchema.optional().describe("Anchor point (image px) \u2014 text and callout"),
5305
+ at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
4626
5306
  target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
4627
- rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight")
5307
+ rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight"),
5308
+ from: pointSchema.optional().describe("Arrow tail (image px)"),
5309
+ to: pointSchema.optional().describe("Arrow head \u2014 what it points at (image px)"),
5310
+ r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
4628
5311
  },
4629
5312
  outputSchema: annotateOutput
4630
5313
  }, run("annotate", (a) => session.annotate(a)));
@@ -4725,7 +5408,7 @@ function registerResources(server, session) {
4725
5408
  // package.json
4726
5409
  var package_default = {
4727
5410
  name: "opentakeoff-mcp",
4728
- version: "0.9.8",
5411
+ version: "0.9.12",
4729
5412
  mcpName: "io.github.Kentucky-ai/opentakeoff",
4730
5413
  type: "module",
4731
5414
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.8",
3
+ "version": "0.9.12",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",