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 +4 -1
- package/dist/server-core.js +716 -33
- package/package.json +1 -1
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
|
|
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. |
|
package/dist/server-core.js
CHANGED
|
@@ -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
|
|
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 === "
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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