opentakeoff-mcp 0.9.35 → 0.9.38
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 +5 -1
- package/dist/server-core.js +504 -13
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
111
111
|
| `one_click` | One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped — the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …) — a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels — same engine as the canvas — with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit (the full account stamps `origin` centrally at the commit); `role: "deduct"` subtracts. |
|
|
112
112
|
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
|
+
| `cut_out` | **A real hole in a committed floor shape** (#206), reconciled the way the canvas cuts one (#137) — the same `lib/cutout.js` boolean subtract, one module, so a headless session and the app can never disagree about what a hole holds. The parent keeps its outer ring + `verts_norm_holes`, its `computed` nets for real (N cuts compose, overlap never double-deducts, a hole ADDS perimeter), and the deduct carries `cuts_shape_id` so report/legend read the reconciled number. Refuses a ring not FULLY inside the parent (the canvas's edge-clip is a canvas affordance; over the wire it's refusal-over-guessing) and a cut that would erase or split the parent. One undo step restores parent and hole together; `delete_shape` on the deduct reverts the cut too (multi-cut parents rebuild from the chain's pristine snapshot minus survivors — the canvas's own delete semantics, ported as the spec). |
|
|
114
115
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
116
|
| `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
|
|
116
117
|
| `derive_transitions` | **The transition where two finishes meet**: pass two finish tags and the tag to commit under, and every committed room of each is compared against every room of the other. The catch this is built around — flood-traced rooms **do not share edges**, a partition puts 4–8″ between them — so proximity comes in two flavours and they are never conflated. A **butt joint** (rings running together inside one open space, within an inch) *is* the transition and commits as a linear shape, `origin.derived` naming both parents, the tags, and the measured gap. A **wall-separated** run means the rooms are adjacent across a partition, where the transition is a threshold in a doorway that nothing in the trace record locates (the flood engine reports how *much* boundary it sealed, never where) — those return in `withheld` with length, gap in inches, and an `at` point to `view_sheet`, as questions rather than a confident wrong number. `max_gap_in` (default 12) only ever turns more of the plan into questions, never into committed LF. All-or-nothing; one undo step. |
|
|
@@ -124,8 +125,11 @@ includes document text, shape vertices, or result payload content.
|
|
|
124
125
|
| `edit_shape` | **Revise** a committed shape instead of redoing it: new `verts`, a different `condition`, a different `role`, a `label` (the room it belongs to — what per-room reporting groups by; `""` clears it), or any combination — quantities recomputed from the result. Refuses shapes a human affirmed. |
|
|
125
126
|
| `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. `basis` is `area` \| `linear` \| `count` \| **`seam_lf`** — the last is the *figured* roll-layout seam length a weld rod or seam tape is bought by (set `roll_setup` on the condition first; without one it reads 0, because nothing has decided how that floor gets cut). `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`. |
|
|
126
127
|
| `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. |
|
|
128
|
+
| `duplicate_condition` | **Twin a condition** — the same finish measured somewhere else, with its own supporting materials. One finish in two areas is neither two conditions nor one: the same sheet goods over a slab and over a raised deck take the same field material and different preparation underneath. The twin arrives carrying the original's whole materials list and keeps **following** it — fix a coverage rate on the original and every twin that hasn't touched that row gets it; edit a row on the twin and only THAT row stops following. `label` is required and becomes the tag suffix (`CPT-1` + `Level 2` → `CPT-1 – Level 2`). Reversible with `undo_last`. |
|
|
129
|
+
| `split_condition` | **Cut a twin loose** from its family: every following material row freezes at its current values and edits to the original stop reaching it. It keeps its finish tag and still groups with its siblings — only the inheritance ends. For when two variants have diverged far enough that following each other is wrong. A condition that already owns its materials returns `split: false` rather than erroring. Reversible with `undo_last`. |
|
|
127
130
|
| `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` (see **Writing to disk** below). |
|
|
128
|
-
| `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import — finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. |
|
|
131
|
+
| `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import — finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. Correction rules (#88) ride the file too — `apply_rules` re-runs them. |
|
|
132
|
+
| `apply_rules` | **Re-run the correction rules the takeoff arrived with** (#207) — the lessons an estimator TAUGHT the canvas (#88), e.g. "every room like this loses the mechanical chase". Same pure `rules.ts` engine the canvas Preview runs; committed as the ONE batch its Apply makes (`reviewed: false`, one undo step, `origin {method rule_v1, actor rule}` with the rule/seed/room citation). The reply's per-rule disclosure — produced, skipped, ids — IS the preview an agent gets. Idempotent by construction: anything an existing deduct covers is dropped by the engine, so re-running after new rooms commit is the intended workflow. Rules arrive ONLY via `import_takeoff`; minting one is an estimator's correction and stays behind the canvas's human Preview→Apply gate. |
|
|
129
133
|
| `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself, and the cover states where the finish tags came from (`Finish assignment: N schedule-resolved · N agent-asserted · …`, plus any rooms the last assign run withheld). Default path: `<plan> - marked set.pdf` next to the plan (see **Writing to disk** below). Works without `@napi-rs/canvas`. |
|
|
130
134
|
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, room `label`, review state, and assignment verdict (`schedule` \| `asserted` — where its finish tag came from) in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
|
|
131
135
|
| `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. |
|
package/dist/server-core.js
CHANGED
|
@@ -547,13 +547,23 @@ function openLen(pts) {
|
|
|
547
547
|
for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
|
548
548
|
return L;
|
|
549
549
|
}
|
|
550
|
+
function polyWithHolesMetrics(outerPts, holesPts = []) {
|
|
551
|
+
const outer = closedMetrics(outerPts);
|
|
552
|
+
let area = outer.area, perim = outer.perim;
|
|
553
|
+
for (const h of holesPts) {
|
|
554
|
+
const hm = closedMetrics(h);
|
|
555
|
+
area -= hm.area;
|
|
556
|
+
perim += hm.perim;
|
|
557
|
+
}
|
|
558
|
+
return { area: Math.max(0, area), perim };
|
|
559
|
+
}
|
|
550
560
|
function pointInPoly(x, y, pts) {
|
|
551
|
-
let
|
|
561
|
+
let inside2 = false;
|
|
552
562
|
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
553
563
|
const [xi, yi] = pts[i], [xj, yj] = pts[j];
|
|
554
|
-
if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
|
564
|
+
if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside2 = !inside2;
|
|
555
565
|
}
|
|
556
|
-
return
|
|
566
|
+
return inside2;
|
|
557
567
|
}
|
|
558
568
|
function chiselRibbon(pts, w, nibDeg = 45) {
|
|
559
569
|
const a = nibDeg * Math.PI / 180, vx = Math.cos(a) * w / 2, vy = -(Math.sin(a) * w) / 2;
|
|
@@ -3452,10 +3462,10 @@ function fingerprintSymbol(segs, seedRect) {
|
|
|
3452
3462
|
const n = segs.length >> 2;
|
|
3453
3463
|
const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
|
|
3454
3464
|
const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
|
|
3455
|
-
const
|
|
3465
|
+
const inside2 = (x, y) => x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1;
|
|
3456
3466
|
const seedIdx = [];
|
|
3457
3467
|
for (let i = 0; i < n; i++) {
|
|
3458
|
-
if (
|
|
3468
|
+
if (inside2(segs[i * 4], segs[i * 4 + 1]) && inside2(segs[i * 4 + 2], segs[i * 4 + 3]) && segLen(segs, i) >= MIN_SEG_LEN) {
|
|
3459
3469
|
seedIdx.push(i);
|
|
3460
3470
|
}
|
|
3461
3471
|
}
|
|
@@ -3744,6 +3754,180 @@ function sharedRuns(ringA, ringB, opts) {
|
|
|
3744
3754
|
return runs;
|
|
3745
3755
|
}
|
|
3746
3756
|
|
|
3757
|
+
// ../web/src/lib/cutout.js
|
|
3758
|
+
import { polygon as turfPolygon, featureCollection } from "@turf/helpers";
|
|
3759
|
+
import turfDifference from "@turf/difference";
|
|
3760
|
+
import booleanOverlap from "@turf/boolean-overlap";
|
|
3761
|
+
import booleanContains from "@turf/boolean-contains";
|
|
3762
|
+
var closeRing = (pts) => {
|
|
3763
|
+
if (!pts.length) return pts;
|
|
3764
|
+
const [x0, y0] = pts[0], [xn, yn] = pts[pts.length - 1];
|
|
3765
|
+
return x0 === xn && y0 === yn ? pts : [...pts, [x0, y0]];
|
|
3766
|
+
};
|
|
3767
|
+
var openRing = (pts) => {
|
|
3768
|
+
if (pts.length > 1) {
|
|
3769
|
+
const [x0, y0] = pts[0], [xn, yn] = pts[pts.length - 1];
|
|
3770
|
+
if (x0 === xn && y0 === yn) return pts.slice(0, -1);
|
|
3771
|
+
}
|
|
3772
|
+
return pts;
|
|
3773
|
+
};
|
|
3774
|
+
function ringFullyInside(outerRingPx, innerRingPx) {
|
|
3775
|
+
if (outerRingPx.length < 3 || innerRingPx.length < 3) return false;
|
|
3776
|
+
try {
|
|
3777
|
+
return booleanContains(turfPolygon([closeRing(outerRingPx)]), turfPolygon([closeRing(innerRingPx)]));
|
|
3778
|
+
} catch {
|
|
3779
|
+
return false;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
function subtractCutout(outerRingPx, holeRingsPx, deductRingPx) {
|
|
3783
|
+
let result;
|
|
3784
|
+
try {
|
|
3785
|
+
const parentPoly = turfPolygon([closeRing(outerRingPx), ...holeRingsPx.map(closeRing)]);
|
|
3786
|
+
const deductPoly = turfPolygon([closeRing(deductRingPx)]);
|
|
3787
|
+
result = turfDifference(featureCollection([parentPoly, deductPoly]));
|
|
3788
|
+
} catch {
|
|
3789
|
+
return null;
|
|
3790
|
+
}
|
|
3791
|
+
if (!result || result.geometry?.type !== "Polygon") return null;
|
|
3792
|
+
const [outer, ...holes] = result.geometry.coordinates.map(openRing);
|
|
3793
|
+
if (outer.length < 3) return null;
|
|
3794
|
+
const metrics = polyWithHolesMetrics(outer, holes);
|
|
3795
|
+
return { outer, holes, area: metrics.area, perim: metrics.perim };
|
|
3796
|
+
}
|
|
3797
|
+
function recomposeCutouts(baseOuterPx, baseHolesPx, deductRingsPx) {
|
|
3798
|
+
let outer = baseOuterPx, holes = baseHolesPx;
|
|
3799
|
+
for (const ring of deductRingsPx) {
|
|
3800
|
+
const r = subtractCutout(outer, holes, ring);
|
|
3801
|
+
if (!r) return null;
|
|
3802
|
+
outer = r.outer;
|
|
3803
|
+
holes = r.holes;
|
|
3804
|
+
}
|
|
3805
|
+
const metrics = polyWithHolesMetrics(outer, holes);
|
|
3806
|
+
return { outer, holes, area: metrics.area, perim: metrics.perim };
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
// ../web/src/lib/rules.ts
|
|
3810
|
+
var ringCentroid = (pts) => {
|
|
3811
|
+
let x = 0, y = 0;
|
|
3812
|
+
for (const p of pts) {
|
|
3813
|
+
x += p[0];
|
|
3814
|
+
y += p[1];
|
|
3815
|
+
}
|
|
3816
|
+
return [x / pts.length, y / pts.length];
|
|
3817
|
+
};
|
|
3818
|
+
var inside = (p, ring) => pointInPoly(p[0], p[1], ring);
|
|
3819
|
+
function findEnclosedRegions(mask, ringImgPx, maxCells, minCells = 4) {
|
|
3820
|
+
const { mask: m, mw, mh, ws } = mask;
|
|
3821
|
+
if (ringImgPx.length < 3 || maxCells < minCells) return [];
|
|
3822
|
+
const ringMask = ringImgPx.map(([x, y]) => [x * ws, y * ws]);
|
|
3823
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
3824
|
+
for (const [x, y] of ringMask) {
|
|
3825
|
+
if (x < bx0) bx0 = x;
|
|
3826
|
+
if (x > bx1) bx1 = x;
|
|
3827
|
+
if (y < by0) by0 = y;
|
|
3828
|
+
if (y > by1) by1 = y;
|
|
3829
|
+
}
|
|
3830
|
+
const x0 = Math.max(0, Math.floor(bx0)), y0 = Math.max(0, Math.floor(by0));
|
|
3831
|
+
const x1 = Math.min(mw - 1, Math.ceil(bx1)), y1 = Math.min(mh - 1, Math.ceil(by1));
|
|
3832
|
+
if (x1 <= x0 || y1 <= y0) return [];
|
|
3833
|
+
const bw = x1 - x0 + 1, bh = y1 - y0 + 1;
|
|
3834
|
+
const seen = new Uint8Array(bw * bh);
|
|
3835
|
+
const isFree = (gx, gy) => !(m[gy * mw + gx] & 1) && inside([gx + 0.5, gy + 0.5], ringMask);
|
|
3836
|
+
const out = [];
|
|
3837
|
+
for (let gy = y0; gy <= y1; gy++) {
|
|
3838
|
+
for (let gx = x0; gx <= x1; gx++) {
|
|
3839
|
+
const li = (gy - y0) * bw + (gx - x0);
|
|
3840
|
+
if (seen[li] || !isFree(gx, gy)) {
|
|
3841
|
+
seen[li] = 1;
|
|
3842
|
+
continue;
|
|
3843
|
+
}
|
|
3844
|
+
const cells = [];
|
|
3845
|
+
const stack = [gx, gy];
|
|
3846
|
+
seen[li] = 1;
|
|
3847
|
+
let over = false;
|
|
3848
|
+
let cx0 = gx, cx1 = gx, cy0 = gy, cy1 = gy;
|
|
3849
|
+
while (stack.length) {
|
|
3850
|
+
const cy = stack.pop(), cx = stack.pop();
|
|
3851
|
+
cells.push(cx, cy);
|
|
3852
|
+
if (cx < cx0) cx0 = cx;
|
|
3853
|
+
if (cx > cx1) cx1 = cx;
|
|
3854
|
+
if (cy < cy0) cy0 = cy;
|
|
3855
|
+
if (cy > cy1) cy1 = cy;
|
|
3856
|
+
if (!over && cells.length / 2 > maxCells) over = true;
|
|
3857
|
+
const nbrs = [cx - 1, cy, cx + 1, cy, cx, cy - 1, cx, cy + 1];
|
|
3858
|
+
for (let k = 0; k < 8; k += 2) {
|
|
3859
|
+
const nx = nbrs[k], ny = nbrs[k + 1];
|
|
3860
|
+
if (nx < x0 || ny < y0 || nx > x1 || ny > y1) continue;
|
|
3861
|
+
const nli = (ny - y0) * bw + (nx - x0);
|
|
3862
|
+
if (seen[nli]) continue;
|
|
3863
|
+
seen[nli] = 1;
|
|
3864
|
+
if (isFree(nx, ny)) {
|
|
3865
|
+
stack.push(nx, ny);
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
const n = cells.length / 2;
|
|
3870
|
+
if (over || n > maxCells || n < minCells) continue;
|
|
3871
|
+
if (cx1 - cx0 + 1 < 2 || cy1 - cy0 + 1 < 2) continue;
|
|
3872
|
+
const cw = cx1 - cx0 + 1, ch = cy1 - cy0 + 1;
|
|
3873
|
+
const region = new Uint8Array(cw * ch);
|
|
3874
|
+
for (let k = 0; k < cells.length; k += 2) region[(cells[k + 1] - cy0) * cw + (cells[k] - cx0)] = 1;
|
|
3875
|
+
const ring = traceRegion({ region, mw: cw, mh: ch, ws }).map(([px, py]) => [px + cx0 / ws, py + cy0 / ws]);
|
|
3876
|
+
if (ring.length >= 3) out.push(ring);
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
return out;
|
|
3880
|
+
}
|
|
3881
|
+
function applyRuleToProject(rule, shapes, sheetData) {
|
|
3882
|
+
if (!rule.active || rule.predicate.kind !== "enclosed_subpolygon_deduct") return [];
|
|
3883
|
+
const rooms = shapes.filter((s) => s.measure_role === "floor_area" && s.condition_id === rule.seed_condition_id && s.verts_norm.length >= 3 && sheetData.has(s.sheet_id));
|
|
3884
|
+
const deductsBySheet = /* @__PURE__ */ new Map();
|
|
3885
|
+
for (const s of shapes) {
|
|
3886
|
+
if (s.measure_role !== "deduct" || s.verts_norm.length < 3) continue;
|
|
3887
|
+
const d = sheetData.get(s.sheet_id);
|
|
3888
|
+
if (!d) continue;
|
|
3889
|
+
const ring = s.verts_norm.map(([nx, ny]) => [nx * d.imgW, ny * d.imgH]);
|
|
3890
|
+
let arr2 = deductsBySheet.get(s.sheet_id);
|
|
3891
|
+
if (!arr2) {
|
|
3892
|
+
arr2 = [];
|
|
3893
|
+
deductsBySheet.set(s.sheet_id, arr2);
|
|
3894
|
+
}
|
|
3895
|
+
arr2.push(ring);
|
|
3896
|
+
}
|
|
3897
|
+
const out = [];
|
|
3898
|
+
const acceptedBySheet = /* @__PURE__ */ new Map();
|
|
3899
|
+
for (const room of rooms) {
|
|
3900
|
+
const d = sheetData.get(room.sheet_id);
|
|
3901
|
+
if (!(d.upp > 0)) continue;
|
|
3902
|
+
const ringImg = room.verts_norm.map(([nx, ny]) => [nx * d.imgW, ny * d.imgH]);
|
|
3903
|
+
const cellSf = (d.upp / d.mask.ws) ** 2;
|
|
3904
|
+
const maxCells = Math.floor(rule.predicate.max_area_sf / cellSf);
|
|
3905
|
+
const found = findEnclosedRegions(d.mask, ringImg, maxCells);
|
|
3906
|
+
for (const ring of found) {
|
|
3907
|
+
const areaSf = ringArea(ring) * d.upp * d.upp;
|
|
3908
|
+
if (!(areaSf > 0) || areaSf > rule.predicate.max_area_sf) continue;
|
|
3909
|
+
const c = ringCentroid(ring);
|
|
3910
|
+
const existing = deductsBySheet.get(room.sheet_id) || [];
|
|
3911
|
+
const taken = acceptedBySheet.get(room.sheet_id) || [];
|
|
3912
|
+
const covered = (rings) => rings.some((r) => inside(c, r) || inside(ringCentroid(r), ring));
|
|
3913
|
+
if (covered(existing) || covered(taken)) continue;
|
|
3914
|
+
out.push({
|
|
3915
|
+
sheet_id: room.sheet_id,
|
|
3916
|
+
container_shape_id: room.id,
|
|
3917
|
+
verts_norm: ring.map(([x, y]) => [x / d.imgW, y / d.imgH]),
|
|
3918
|
+
area_sf: +areaSf.toFixed(2)
|
|
3919
|
+
});
|
|
3920
|
+
let arr2 = acceptedBySheet.get(room.sheet_id);
|
|
3921
|
+
if (!arr2) {
|
|
3922
|
+
arr2 = [];
|
|
3923
|
+
acceptedBySheet.set(room.sheet_id, arr2);
|
|
3924
|
+
}
|
|
3925
|
+
arr2.push(ring);
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
return out;
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3747
3931
|
// ../web/src/lib/provenance.js
|
|
3748
3932
|
var mintUuid = () => {
|
|
3749
3933
|
const c = globalThis.crypto;
|
|
@@ -4740,6 +4924,11 @@ var Session = class _Session {
|
|
|
4740
4924
|
* normalized so the export-time staleness drop can test them against
|
|
4741
4925
|
* committed rings in verts_norm space. */
|
|
4742
4926
|
scheduleWithheld = [];
|
|
4927
|
+
/** Correction rules (#207) — imported from a canvas takeoff file, never
|
|
4928
|
+
* minted here (a rule IS an estimator's correction by definition; minting
|
|
4929
|
+
* stays behind the canvas's human Preview→Apply gate). apply_rules re-runs
|
|
4930
|
+
* them; applied_to mirrors the canvas's audit-trail semantics. */
|
|
4931
|
+
rules = [];
|
|
4743
4932
|
/** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
|
|
4744
4933
|
journal = [];
|
|
4745
4934
|
seq = 0;
|
|
@@ -4781,6 +4970,7 @@ var Session = class _Session {
|
|
|
4781
4970
|
this.filePath = null;
|
|
4782
4971
|
this.nextOrd = 1;
|
|
4783
4972
|
this.scheduleWithheld = [];
|
|
4973
|
+
this.rules = [];
|
|
4784
4974
|
this.journal = [];
|
|
4785
4975
|
this.pendingCommits = [];
|
|
4786
4976
|
} else if (this.docs.has(base)) {
|
|
@@ -5674,6 +5864,188 @@ var Session = class _Session {
|
|
|
5674
5864
|
note: "Base runs trace each room's boundary; openings are your stated claim, recorded on origin.derived. Verify with view_sheet overlay:true."
|
|
5675
5865
|
};
|
|
5676
5866
|
}
|
|
5867
|
+
/** apply_rules (#207): re-run the correction rules an estimator taught the
|
|
5868
|
+
* canvas (#88). Rules arrive with import_takeoff — they are never minted
|
|
5869
|
+
* here, because a rule IS an estimator's correction by definition and the
|
|
5870
|
+
* v1 predicate's known label-box false-positive stays safe behind the
|
|
5871
|
+
* canvas's human Preview→Apply gate. Evaluation is the same pure rules.ts
|
|
5872
|
+
* engine the canvas Preview runs; the commit is the one batch the canvas's
|
|
5873
|
+
* Apply makes (ONE journal entry, undo_last takes it all back). Everything
|
|
5874
|
+
* lands reviewed: false — this server has no review gate — and the reply's
|
|
5875
|
+
* per-rule disclosure IS the preview an agent gets. Idempotent by
|
|
5876
|
+
* construction: the engine drops any candidate an existing deduct already
|
|
5877
|
+
* covers, and rule N's mints are rule N+1's dedup targets, exactly like
|
|
5878
|
+
* consecutive Applies on the canvas. */
|
|
5879
|
+
async applyRules(opts = {}) {
|
|
5880
|
+
if (!this.rules.length) {
|
|
5881
|
+
throw new UserError("No rules in this session \u2014 rules ride import_takeoff from a canvas file (an estimator's taught corrections, #88). Minting new rules is the canvas's job; this verb only re-runs what a human already taught.");
|
|
5882
|
+
}
|
|
5883
|
+
const activeCondIds = new Set(this.rules.filter((r) => r.active).map((r) => r.seed_condition_id));
|
|
5884
|
+
const sheetKeys = opts.sheet ? [this.sheet(opts.sheet).key] : [...new Set(this.shapes.filter((x) => x.measure_role === "floor_area" && activeCondIds.has(x.condition_id)).map((x) => x.sheet_id))];
|
|
5885
|
+
const skipped_sheets = [];
|
|
5886
|
+
const sheetData = /* @__PURE__ */ new Map();
|
|
5887
|
+
for (const key of sheetKeys) {
|
|
5888
|
+
const s = this.sheet(key);
|
|
5889
|
+
if (!(s.upp && s.upp > 0)) {
|
|
5890
|
+
skipped_sheets.push({ sheet_id: key, reason: "no_scale" });
|
|
5891
|
+
continue;
|
|
5892
|
+
}
|
|
5893
|
+
const mask = await this.ensureMask(key);
|
|
5894
|
+
if (!mask) {
|
|
5895
|
+
skipped_sheets.push({ sheet_id: key, reason: "no_vector_mask" });
|
|
5896
|
+
continue;
|
|
5897
|
+
}
|
|
5898
|
+
sheetData.set(key, { mask, upp: s.upp, imgW: s.widthPx, imgH: s.heightPx });
|
|
5899
|
+
}
|
|
5900
|
+
const rulesOut = [];
|
|
5901
|
+
const skipped_rules = [];
|
|
5902
|
+
for (const rule of this.rules) {
|
|
5903
|
+
if (!rule.active) {
|
|
5904
|
+
skipped_rules.push({ rule_id: rule.id, label: rule.label, reason: "inactive" });
|
|
5905
|
+
continue;
|
|
5906
|
+
}
|
|
5907
|
+
const cond = this.conditions.find((c) => c.id === rule.seed_condition_id);
|
|
5908
|
+
if (!cond) {
|
|
5909
|
+
skipped_rules.push({ rule_id: rule.id, label: rule.label, reason: "condition_not_in_session" });
|
|
5910
|
+
continue;
|
|
5911
|
+
}
|
|
5912
|
+
const candidates = applyRuleToProject(rule, this.shapes, sheetData);
|
|
5913
|
+
const ids = [];
|
|
5914
|
+
let sf = 0;
|
|
5915
|
+
for (const cand of candidates) {
|
|
5916
|
+
const d = sheetData.get(cand.sheet_id);
|
|
5917
|
+
const vertsPx = cand.verts_norm.map(([nx, ny]) => [nx * d.imgW, ny * d.imgH]);
|
|
5918
|
+
const shape = this.commit(
|
|
5919
|
+
this.sheet(cand.sheet_id),
|
|
5920
|
+
cond.finish_tag,
|
|
5921
|
+
"deduct",
|
|
5922
|
+
vertsPx,
|
|
5923
|
+
{ area_sf: cand.area_sf, perimeter_lf: round2(closedMetrics(vertsPx).perim * d.upp) },
|
|
5924
|
+
{
|
|
5925
|
+
method: "rule_v1",
|
|
5926
|
+
actor: "rule",
|
|
5927
|
+
reviewed: false,
|
|
5928
|
+
rule_id: rule.id,
|
|
5929
|
+
seed_shape_id: rule.seed_shape_id,
|
|
5930
|
+
container_shape_id: cand.container_shape_id
|
|
5931
|
+
}
|
|
5932
|
+
);
|
|
5933
|
+
ids.push(shape.id);
|
|
5934
|
+
sf += cand.area_sf;
|
|
5935
|
+
}
|
|
5936
|
+
if (ids.length) rule.applied_to = ids;
|
|
5937
|
+
rulesOut.push({ rule_id: rule.id, label: rule.label, condition: cond.finish_tag, produced: ids.length, shape_ids: ids, deduct_sf: round2(sf) });
|
|
5938
|
+
}
|
|
5939
|
+
this.flushCommits("apply_rules");
|
|
5940
|
+
const committed = rulesOut.reduce((n, r) => n + r.produced, 0);
|
|
5941
|
+
return {
|
|
5942
|
+
rules: rulesOut,
|
|
5943
|
+
committed,
|
|
5944
|
+
total_deduct_sf: round2(rulesOut.reduce((n, r) => n + r.deduct_sf, 0)),
|
|
5945
|
+
skipped_rules,
|
|
5946
|
+
skipped_sheets,
|
|
5947
|
+
note: committed ? "One batch, one undo step, everything reviewed: false \u2014 this disclosure is your preview; verify with view_sheet overlay:true. Re-running is idempotent: anything a deduct already covers is dropped by the engine." : "No new candidates \u2014 every match is already covered by an existing deduct (re-running is idempotent by construction), or the rules' rooms are not on the evaluated sheets."
|
|
5948
|
+
};
|
|
5949
|
+
}
|
|
5950
|
+
/** cut_out (#206): a REAL hole, reconciled the way the canvas cuts one
|
|
5951
|
+
* (#137) — the same lib/cutout.js boolean subtract, so a headless session
|
|
5952
|
+
* and the app can never disagree about what a hole holds. The parent's
|
|
5953
|
+
* outer ring + existing holes minus the new ring lands back on the parent
|
|
5954
|
+
* (verts_norm_holes + netted computed); the deduct commits carrying
|
|
5955
|
+
* cuts_shape_id, so totals.js skips it (set subtraction — N cuts compose,
|
|
5956
|
+
* overlap never double-deducts). One journal entry: undo_last restores
|
|
5957
|
+
* parent and hole together. Refusal over guessing: a cut not FULLY inside
|
|
5958
|
+
* the parent refuses (the canvas's edge-clip is a canvas affordance), and
|
|
5959
|
+
* a subtract that erases or splits the parent refuses whole. */
|
|
5960
|
+
cutOut(opts) {
|
|
5961
|
+
const parent = this.shapes.find((x) => x.id === opts.parent_shape_id);
|
|
5962
|
+
if (!parent) throw new UserError(`No shape with id ${JSON.stringify(opts.parent_shape_id)} \u2014 list_shapes for real ids.`);
|
|
5963
|
+
if (parent.measure_role !== "floor_area") {
|
|
5964
|
+
throw new UserError(`cut_out subtracts from a floor_area shape \u2014 ${parent.id} is ${parent.measure_role}. An opening in a linear run is derive_base's openings claim; a count marker has no area to cut.`);
|
|
5965
|
+
}
|
|
5966
|
+
if (parent.origin?.reviewed === true) {
|
|
5967
|
+
throw new UserError(`Shape ${parent.id} was affirmed by a human \u2014 reviewed work is ink, and cutting a hole in it would mutate what the estimator signed. Commit an independent deduct with measure_polygon instead.`);
|
|
5968
|
+
}
|
|
5969
|
+
const s = this.sheet(parent.sheet_id);
|
|
5970
|
+
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
5971
|
+
if (opts.verts.length < 3) throw new UserError(`A cut needs at least 3 vertices \u2014 got ${opts.verts.length}.`);
|
|
5972
|
+
const upp = s.upp;
|
|
5973
|
+
const toPx = (ring) => ring.map(([nx, ny]) => [nx * s.widthPx, ny * s.heightPx]);
|
|
5974
|
+
const toNorm = (ring) => ring.map(([x, y]) => [x / s.widthPx, y / s.heightPx]);
|
|
5975
|
+
const outerPx = toPx(parent.verts_norm);
|
|
5976
|
+
if (!ringFullyInside(outerPx, opts.verts)) {
|
|
5977
|
+
throw new UserError("The cut is not fully inside the parent's outer ring. The canvas resolves an edge-crossing cut as a boundary clip; over the wire the rule is refusal-over-guessing \u2014 reshape the parent with edit_shape, or commit an independent deduct with measure_polygon.");
|
|
5978
|
+
}
|
|
5979
|
+
const holesPx = (parent.verts_norm_holes ?? []).map(toPx);
|
|
5980
|
+
const r = subtractCutout(outerPx, holesPx, opts.verts);
|
|
5981
|
+
if (!r) {
|
|
5982
|
+
throw new UserError("The subtract degenerates \u2014 this cut would erase the parent or split it into disjoint pieces. That is a re-trace decision, not a hole: measure_polygon the pieces as their own rooms.");
|
|
5983
|
+
}
|
|
5984
|
+
const parentPrev = {
|
|
5985
|
+
verts_norm: parent.verts_norm.map((v) => [...v]),
|
|
5986
|
+
...parent.verts_norm_holes ? { verts_norm_holes: parent.verts_norm_holes.map((h) => h.map((v) => [...v])) } : {},
|
|
5987
|
+
...parent.computed ? { computed: { ...parent.computed } } : {}
|
|
5988
|
+
};
|
|
5989
|
+
const met = closedMetrics(opts.verts);
|
|
5990
|
+
const deduct = {
|
|
5991
|
+
id: uid("shp"),
|
|
5992
|
+
sheet_id: s.key,
|
|
5993
|
+
condition_id: parent.condition_id,
|
|
5994
|
+
measure_role: "deduct",
|
|
5995
|
+
verts_norm: opts.verts.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
|
|
5996
|
+
// face value for the hover label — totals skip it, the parent nets it
|
|
5997
|
+
computed: { area_sf: round2(met.area * upp * upp), perimeter_lf: round2(met.perim * upp) },
|
|
5998
|
+
cuts_shape_id: parent.id,
|
|
5999
|
+
origin: { method: "cutout_v1", actor: "agent", reviewed: false, cuts_shape_id: parent.id, parent_prev: parentPrev }
|
|
6000
|
+
};
|
|
6001
|
+
const prevNet = parentPrev.computed?.area_sf ?? 0;
|
|
6002
|
+
parent.verts_norm = toNorm(r.outer);
|
|
6003
|
+
parent.verts_norm_holes = r.holes.map(toNorm);
|
|
6004
|
+
parent.computed = { area_sf: round2(r.area * upp * upp), perimeter_lf: round2(r.perim * upp) };
|
|
6005
|
+
this.shapes.push(deduct);
|
|
6006
|
+
this.record({ op: "cutout", tool: "cut_out", deduct_id: deduct.id, parent_id: parent.id, parent_prev: parentPrev });
|
|
6007
|
+
return {
|
|
6008
|
+
deduct_shape_id: deduct.id,
|
|
6009
|
+
parent_shape_id: parent.id,
|
|
6010
|
+
hole_sf: round2(Math.max(0, prevNet - (parent.computed.area_sf ?? 0))),
|
|
6011
|
+
parent_net: { area_sf: parent.computed.area_sf ?? 0, perimeter_lf: parent.computed.perimeter_lf ?? 0 },
|
|
6012
|
+
holes: parent.verts_norm_holes.length,
|
|
6013
|
+
note: "The parent's computed nets the hole for real (boolean subtract, not arithmetic) \u2014 overlap with an earlier cut never double-deducts. One undo step restores parent and hole together; deleting the deduct later reverts the cut too."
|
|
6014
|
+
};
|
|
6015
|
+
}
|
|
6016
|
+
/** The canvas's cutoutParentPrevSans, ported verbatim in spirit (#206 —
|
|
6017
|
+
* "the canvas's answer is the spec"): rebuilding a multi-cut parent after
|
|
6018
|
+
* ONE cut is deleted starts from the chain's EARLIEST pristine snapshot and
|
|
6019
|
+
* re-subtracts every survivor in commit order. Null when the rebuild can't
|
|
6020
|
+
* be trusted — the caller falls back to a plain delete that leaves the cut
|
|
6021
|
+
* baked in, never reverts over survivors. */
|
|
6022
|
+
cutoutParentPrevSans(doomed) {
|
|
6023
|
+
const chain = this.shapes.filter((x) => x.cuts_shape_id === doomed.cuts_shape_id && x.origin?.parent_prev);
|
|
6024
|
+
const rest = chain.filter((x) => x.id !== doomed.id);
|
|
6025
|
+
if (!rest.length) return doomed.origin.parent_prev;
|
|
6026
|
+
const parent = this.shapes.find((x) => x.id === doomed.cuts_shape_id);
|
|
6027
|
+
const s = this.sheet(parent.sheet_id);
|
|
6028
|
+
if (s.upp == null) return null;
|
|
6029
|
+
const upp = s.upp;
|
|
6030
|
+
const px = (ring) => ring.map(([nx, ny]) => [nx * s.widthPx, ny * s.heightPx]);
|
|
6031
|
+
const base = chain[0].origin.parent_prev;
|
|
6032
|
+
const r = recomposeCutouts(px(base.verts_norm), (base.verts_norm_holes ?? []).map(px), rest.map((x) => px(x.verts_norm)));
|
|
6033
|
+
if (!r) return null;
|
|
6034
|
+
const norm2 = (ring) => ring.map(([x, y]) => [x / s.widthPx, y / s.heightPx]);
|
|
6035
|
+
return {
|
|
6036
|
+
verts_norm: norm2(r.outer),
|
|
6037
|
+
...r.holes.length ? { verts_norm_holes: r.holes.map(norm2) } : {},
|
|
6038
|
+
computed: { area_sf: round2(r.area * upp * upp), perimeter_lf: round2(r.perim * upp) }
|
|
6039
|
+
};
|
|
6040
|
+
}
|
|
6041
|
+
/** Write a cutout snapshot back onto a parent — the one place the restore
|
|
6042
|
+
* shape is defined, used by undo and by delete's revert. */
|
|
6043
|
+
static restoreCutoutSnapshot(p, snap) {
|
|
6044
|
+
p.verts_norm = snap.verts_norm.map((v) => [...v]);
|
|
6045
|
+
if (snap.verts_norm_holes) p.verts_norm_holes = snap.verts_norm_holes.map((h) => h.map((v) => [...v]));
|
|
6046
|
+
else delete p.verts_norm_holes;
|
|
6047
|
+
if (snap.computed) p.computed = { ...snap.computed };
|
|
6048
|
+
}
|
|
5677
6049
|
/** Mint the transition where two finishes meet (#202) — the derivation that
|
|
5678
6050
|
* follows derive_base, and the one an estimator draws by hand on every job.
|
|
5679
6051
|
*
|
|
@@ -6312,9 +6684,43 @@ var Session = class _Session {
|
|
|
6312
6684
|
deleteShape(id) {
|
|
6313
6685
|
const i = this.shapes.findIndex((x) => x.id === id);
|
|
6314
6686
|
if (i < 0) throw new UserError(`No shape with id ${JSON.stringify(id)}.`);
|
|
6315
|
-
const
|
|
6687
|
+
const shape = this.shapes[i];
|
|
6688
|
+
if (shape.cuts_shape_id && shape.origin?.parent_prev) {
|
|
6689
|
+
const parent = this.shapes.find((x) => x.id === shape.cuts_shape_id);
|
|
6690
|
+
if (parent) {
|
|
6691
|
+
const parentPrev = this.cutoutParentPrevSans(shape);
|
|
6692
|
+
if (parentPrev) {
|
|
6693
|
+
const parentAfter = {
|
|
6694
|
+
verts_norm: parent.verts_norm.map((v) => [...v]),
|
|
6695
|
+
...parent.verts_norm_holes ? { verts_norm_holes: parent.verts_norm_holes.map((h) => h.map((v) => [...v])) } : {},
|
|
6696
|
+
computed: { ...parent.computed }
|
|
6697
|
+
};
|
|
6698
|
+
this.shapes.splice(i, 1);
|
|
6699
|
+
_Session.restoreCutoutSnapshot(parent, parentPrev);
|
|
6700
|
+
this.record({ op: "cutout_restore", tool: "delete_shape", removed: { shape, index: i }, parent_id: parent.id, parent_after: parentAfter });
|
|
6701
|
+
return {
|
|
6702
|
+
deleted: id,
|
|
6703
|
+
shape_count: this.shapes.length,
|
|
6704
|
+
note: `Reconciled cutout: the cut was reverted out of parent ${parent.id} too (its net is back to ${parent.computed.area_sf} SF).`
|
|
6705
|
+
};
|
|
6706
|
+
}
|
|
6707
|
+
this.shapes.splice(i, 1);
|
|
6708
|
+
this.record({ op: "delete", tool: "delete_shape", removed: [{ shape, index: i }] });
|
|
6709
|
+
return {
|
|
6710
|
+
deleted: id,
|
|
6711
|
+
shape_count: this.shapes.length,
|
|
6712
|
+
note: `The cut could not be rebuilt out of parent ${parent.id} (a re-subtract degenerates) \u2014 the deduct is gone but its hole stays baked into the parent's geometry, the canvas's own fallback.`
|
|
6713
|
+
};
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
this.shapes.splice(i, 1);
|
|
6316
6717
|
this.record({ op: "delete", tool: "delete_shape", removed: [{ shape, index: i }] });
|
|
6317
|
-
|
|
6718
|
+
const orphans = this.shapes.filter((x) => x.cuts_shape_id === id).length;
|
|
6719
|
+
return {
|
|
6720
|
+
deleted: id,
|
|
6721
|
+
shape_count: this.shapes.length,
|
|
6722
|
+
...orphans ? { note: `${orphans} reconciled deduct(s) pointed at this shape and are now orphaned (their holes died with the parent; totals ignore them) \u2014 the canvas behaves the same. delete_shape them too, or undo_last.` } : {}
|
|
6723
|
+
};
|
|
6318
6724
|
}
|
|
6319
6725
|
/** Revise a committed shape in place: new geometry, a different condition, a
|
|
6320
6726
|
* different role, or any combination. This is the verb that turns the agent
|
|
@@ -6351,6 +6757,12 @@ var Session = class _Session {
|
|
|
6351
6757
|
if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0 && patch.label === void 0) {
|
|
6352
6758
|
throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role, label.");
|
|
6353
6759
|
}
|
|
6760
|
+
if (cur.cuts_shape_id && (patch.verts !== void 0 || patch.role !== void 0 || patch.condition !== void 0)) {
|
|
6761
|
+
throw new UserError(`Shape ${JSON.stringify(id)} is a reconciled cutout \u2014 its ring IS the hole in ${cur.cuts_shape_id}. delete_shape it (the parent's geometry is restored) and cut_out again where you mean it.`);
|
|
6762
|
+
}
|
|
6763
|
+
if (cur.verts_norm_holes?.length && (patch.verts !== void 0 || patch.role !== void 0)) {
|
|
6764
|
+
throw new UserError(`Shape ${JSON.stringify(id)} carries ${cur.verts_norm_holes.length} reconciled hole(s) \u2014 reshaping its outer ring would strand them. delete_shape the cutout deduct(s) first (each restores the parent), reshape, then cut_out again.`);
|
|
6765
|
+
}
|
|
6354
6766
|
const s = this.sheet(cur.sheet_id);
|
|
6355
6767
|
const role = patch.role ?? cur.measure_role;
|
|
6356
6768
|
if (role !== "count" && s.upp == null) throw new UserError(this.scaleGate(s));
|
|
@@ -6760,6 +7172,16 @@ var Session = class _Session {
|
|
|
6760
7172
|
else c.materials_dropped = structuredClone(e.before.materials_dropped);
|
|
6761
7173
|
}
|
|
6762
7174
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
7175
|
+
} else if (e.op === "cutout") {
|
|
7176
|
+
this.shapes = this.shapes.filter((x) => x.id !== e.deduct_id);
|
|
7177
|
+
const p = this.shapes.find((x) => x.id === e.parent_id);
|
|
7178
|
+
if (p) _Session.restoreCutoutSnapshot(p, e.parent_prev);
|
|
7179
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 1 });
|
|
7180
|
+
} else if (e.op === "cutout_restore") {
|
|
7181
|
+
this.shapes.splice(Math.min(e.removed.index, this.shapes.length), 0, e.removed.shape);
|
|
7182
|
+
const p = this.shapes.find((x) => x.id === e.parent_id);
|
|
7183
|
+
if (p) _Session.restoreCutoutSnapshot(p, e.parent_after);
|
|
7184
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 1 });
|
|
6763
7185
|
} else if (e.op === "approval") {
|
|
6764
7186
|
this.approvals = applyApprovalCommand2(this.approvals, e.inverse).approvals;
|
|
6765
7187
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
@@ -7508,9 +7930,43 @@ var importTakeoffOutput = {
|
|
|
7508
7930
|
conditions_added: z.number().int(),
|
|
7509
7931
|
scales_adopted: z.number().int().describe("Sheets whose calibration came from the file (this session's own always wins)"),
|
|
7510
7932
|
unknown_files: z.array(z.string()).describe("Files referenced by imported shapes that this document doesn't have \u2014 they count in totals but can't be viewed here"),
|
|
7933
|
+
rules_imported: z.number().int().describe("Correction rules (#88) that arrived with the file \u2014 apply_rules re-runs them"),
|
|
7511
7934
|
shapes_total: z.number().int(),
|
|
7512
7935
|
note: z.string()
|
|
7513
7936
|
};
|
|
7937
|
+
var cutOutOutput = {
|
|
7938
|
+
deduct_shape_id: z.string().describe("The reconciled deduct \u2014 carries cuts_shape_id; totals skip it (the parent nets the hole)"),
|
|
7939
|
+
parent_shape_id: z.string(),
|
|
7940
|
+
hole_sf: z.number().describe("What this cut actually removed from the parent's net \u2014 0 when the ring fell entirely inside an existing hole"),
|
|
7941
|
+
parent_net: z.object({
|
|
7942
|
+
area_sf: z.number().describe("The parent's recomputed net after the subtract"),
|
|
7943
|
+
perimeter_lf: z.number().describe("Outer ring + hole boundaries \u2014 a hole ADDS perimeter")
|
|
7944
|
+
}),
|
|
7945
|
+
holes: z.number().int().describe("Holes the parent now carries"),
|
|
7946
|
+
note: z.string()
|
|
7947
|
+
};
|
|
7948
|
+
var applyRulesOutput = {
|
|
7949
|
+
rules: z.array(z.object({
|
|
7950
|
+
rule_id: z.string(),
|
|
7951
|
+
label: z.string().describe("The rule's own plain-language statement, minted at creation"),
|
|
7952
|
+
condition: z.string().describe("The finish tag whose rooms were scanned"),
|
|
7953
|
+
produced: z.number().int(),
|
|
7954
|
+
shape_ids: z.array(z.string()).describe("The committed deducts \u2014 reviewed: false, one batch"),
|
|
7955
|
+
deduct_sf: z.number()
|
|
7956
|
+
})),
|
|
7957
|
+
committed: z.number().int().describe("Deducts committed across all rules \u2014 0 is a result (idempotence)"),
|
|
7958
|
+
total_deduct_sf: z.number(),
|
|
7959
|
+
skipped_rules: z.array(z.object({
|
|
7960
|
+
rule_id: z.string(),
|
|
7961
|
+
label: z.string(),
|
|
7962
|
+
reason: z.enum(["inactive", "condition_not_in_session"])
|
|
7963
|
+
})).describe("Rules not evaluated, named \u2014 never silently dropped"),
|
|
7964
|
+
skipped_sheets: z.array(z.object({
|
|
7965
|
+
sheet_id: z.string(),
|
|
7966
|
+
reason: z.enum(["no_scale", "no_vector_mask"])
|
|
7967
|
+
})).describe("Sheets that could not be scanned (uncalibrated, or scanned raster with no linework mask)"),
|
|
7968
|
+
note: z.string()
|
|
7969
|
+
};
|
|
7514
7970
|
var deriveBaseOutput = {
|
|
7515
7971
|
condition: z.string().describe("The tag the base committed under"),
|
|
7516
7972
|
source_condition: z.string(),
|
|
@@ -7567,7 +8023,8 @@ var listShapesOutput = {
|
|
|
7567
8023
|
};
|
|
7568
8024
|
var deleteShapeOutput = {
|
|
7569
8025
|
deleted: z.string().describe("The removed shape's id"),
|
|
7570
|
-
shape_count: z.number().int().describe("Committed shapes remaining")
|
|
8026
|
+
shape_count: z.number().int().describe("Committed shapes remaining"),
|
|
8027
|
+
note: z.string().optional().describe("Cutout interplay (#206), when it applies: the parent's cut was reverted, could not be rebuilt, or reconciled deducts were orphaned by a parent delete")
|
|
7571
8028
|
};
|
|
7572
8029
|
var editShapeOutput = {
|
|
7573
8030
|
shape_id: z.string(),
|
|
@@ -7584,7 +8041,7 @@ var undoLastOutput = {
|
|
|
7584
8041
|
undone: z.number().int().describe("Steps actually reversed"),
|
|
7585
8042
|
steps: z.array(z.object({
|
|
7586
8043
|
seq: z.number().int(),
|
|
7587
|
-
op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition"]),
|
|
8044
|
+
op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition", "cutout", "cutout_restore"]),
|
|
7588
8045
|
tool: z.string().describe("The tool call this step came from"),
|
|
7589
8046
|
shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes), for a condition step (it restores the waste/multiplier pair), and for an approval step (it re-seats or removes a verdict mark)")
|
|
7590
8047
|
})).describe("Newest first"),
|
|
@@ -9337,13 +9794,28 @@ async function importTakeoff(session, filePath) {
|
|
|
9337
9794
|
s.scaleSource = row.scale_source ?? "upp";
|
|
9338
9795
|
}
|
|
9339
9796
|
}
|
|
9797
|
+
const tagKey2 = (t) => String(t || "").trim().toUpperCase();
|
|
9798
|
+
const importedConds = Array.isArray(imported.conditions) ? imported.conditions : [];
|
|
9799
|
+
const tagById = new Map(importedConds.map((c) => [c.id, tagKey2(c.finish_tag)]));
|
|
9800
|
+
const sessionByTag = new Map(session.conditions.map((c) => [tagKey2(c.finish_tag), c.id]));
|
|
9801
|
+
const knownRuleIds = new Set(session.rules.map((r) => r.id));
|
|
9802
|
+
let rulesImported = 0;
|
|
9803
|
+
for (const raw of Array.isArray(imported.rules) ? imported.rules : []) {
|
|
9804
|
+
if (!raw || typeof raw !== "object" || !raw.id || knownRuleIds.has(raw.id)) continue;
|
|
9805
|
+
if (raw.predicate?.kind !== "enclosed_subpolygon_deduct" || !(raw.predicate.max_area_sf > 0)) continue;
|
|
9806
|
+
const mapped = sessionByTag.get(tagById.get(raw.seed_condition_id) ?? "");
|
|
9807
|
+
session.rules.push({ ...raw, applied_to: [...raw.applied_to ?? []], seed_condition_id: mapped ?? raw.seed_condition_id });
|
|
9808
|
+
knownRuleIds.add(raw.id);
|
|
9809
|
+
rulesImported++;
|
|
9810
|
+
}
|
|
9340
9811
|
const addedIds = session.shapes.filter((s) => !prevShapeIds.has(s.id)).map((s) => s.id);
|
|
9341
9812
|
if (addedIds.length) session.journalCommit("import_takeoff", addedIds);
|
|
9342
9813
|
return {
|
|
9343
9814
|
file: path4.basename(filePath),
|
|
9344
9815
|
...note,
|
|
9816
|
+
rules_imported: rulesImported,
|
|
9345
9817
|
shapes_total: session.shapes.length,
|
|
9346
|
-
note: note.replaced ? "Empty session \u2014 the import IS the takeoff now. Unreviewed machine shapes stay pencil; verify with view_sheet overlay:true." : "Merged: same finish tags joined your conditions, new ids appended, duplicates skipped (re-import is idempotent), this session's calibration won per sheet."
|
|
9818
|
+
note: (note.replaced ? "Empty session \u2014 the import IS the takeoff now. Unreviewed machine shapes stay pencil; verify with view_sheet overlay:true." : "Merged: same finish tags joined your conditions, new ids appended, duplicates skipped (re-import is idempotent), this session's calibration won per sheet.") + (rulesImported ? ` ${rulesImported} correction rule(s) arrived \u2014 apply_rules re-runs them.` : "")
|
|
9347
9819
|
};
|
|
9348
9820
|
}
|
|
9349
9821
|
|
|
@@ -9442,6 +9914,14 @@ function registerTools(server, session) {
|
|
|
9442
9914
|
},
|
|
9443
9915
|
outputSchema: measurePolygonOutput
|
|
9444
9916
|
}, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
|
|
9917
|
+
server.registerTool("cut_out", {
|
|
9918
|
+
description: `Cut a REAL hole in a committed floor_area shape (#206) \u2014 the way the canvas cuts one (#137): the same lib/cutout.js boolean subtract, so the two surfaces can never disagree about what a hole holds. The parent keeps its outer ring plus the reconciled hole(s) (verts_norm_holes), its computed nets for real \u2014 N cuts compose, overlap between cuts never double-deducts (set subtraction), a hole ADDS perimeter \u2014 and the deduct commits carrying cuts_shape_id so the report and legend read the reconciled number, never a second arithmetic pass. This is the verb for a column, a floor drain, an island of casework INSIDE a room; an independent measure_polygon role:"deduct" stays the tool for a deduction that isn't a hole in one parent. Refusal over guessing: the ring must sit FULLY inside the parent's outer ring (an edge-crossing cut is a boundary correction \u2014 edit_shape the parent instead), and a cut that would erase the parent or split it in two refuses whole (trace the pieces as rooms). One journal entry \u2014 undo_last restores parent and hole together; delete_shape on the deduct later reverts the cut too (a multi-cut parent rebuilds from the chain's pristine snapshot minus the survivors). ${COORDS}`,
|
|
9919
|
+
inputSchema: {
|
|
9920
|
+
parent_shape_id: z2.string().describe("A committed floor_area shape id (list_shapes)"),
|
|
9921
|
+
verts: z2.array(pointSchema).min(3).describe("The hole's ring, image px, fully inside the parent")
|
|
9922
|
+
},
|
|
9923
|
+
outputSchema: cutOutOutput
|
|
9924
|
+
}, run("cut_out", (a) => session.cutOut(a)));
|
|
9445
9925
|
server.registerTool("measure_line", {
|
|
9446
9926
|
description: `Measure an open polyline (min 2 points, image px): length_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it as a linear shape (base, transitions, feature strips). ${COORDS}`,
|
|
9447
9927
|
inputSchema: {
|
|
@@ -9586,6 +10066,13 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9586
10066
|
},
|
|
9587
10067
|
outputSchema: importTakeoffOutput
|
|
9588
10068
|
}, run("import_takeoff", (a) => importTakeoff(session, a.path)));
|
|
10069
|
+
server.registerTool("apply_rules", {
|
|
10070
|
+
description: `Re-run the correction rules the takeoff arrived with (#207) \u2014 the lessons an estimator TAUGHT the canvas (#88): "every room like this loses the mechanical chase." A rule is a deterministic predicate (enclosed linework islands under a size cap, inside the rule's condition's rooms), never a re-prompt. Evaluation is the same pure rules.ts engine the canvas Preview runs; the commit is the one batch the canvas's Apply makes \u2014 ONE journal entry, undo_last takes the whole batch back. Everything lands reviewed: false (this server has no review gate), and the reply's per-rule disclosure \u2014 what each rule produced, what was skipped, with ids \u2014 IS your preview: read it, then view_sheet overlay:true. Idempotent by construction: any candidate an existing deduct already covers is dropped by the engine, so re-running after new rooms commit is the intended workflow and never double-deducts. Rules arrive ONLY via import_takeoff (minting a new rule is an estimator's correction and stays behind the canvas's human Preview\u2192Apply gate); with none imported this refuses. Pass sheet to scan one sheet; omit it to scan every sheet holding the rules' rooms. Uncalibrated and scanned-raster sheets come back in skipped_sheets, named.`,
|
|
10071
|
+
inputSchema: {
|
|
10072
|
+
sheet: z2.string().optional().describe("Scan only this sheet (default: every sheet holding the rules' conditions' rooms)")
|
|
10073
|
+
},
|
|
10074
|
+
outputSchema: applyRulesOutput
|
|
10075
|
+
}, run("apply_rules", (a) => session.applyRules(a)));
|
|
9589
10076
|
server.registerTool("export_marked_pdf", {
|
|
9590
10077
|
description: `The MARKED-UP PLANSET \u2014 the deliverable of every takeoff. Writes a distribution-ready PDF to disk: a legend cover (per-condition totals, swatches, a by-sheet breakdown) followed by every sheet that carries takeoff shapes or annotations, vector-copied from the source plan with the work burned in as drawn \u2014 condition colors and hatches, a quantity chip on every shape, annotation clouds/callouts/highlights, and approval marks (the estimator's APPROVED rings, the agent's AGENT diamonds \u2014 the cover tallies the split). Built by the same module as the canvas's MARKED SET button, so agent output and app output are one implementation. A construction takeoff is no good without markup: finish EVERY takeoff by writing this file and giving the user its path (export_report carries the numbers for pricing; this carries the evidence). When the shapes were machine-traced and unreviewed, the document says so on its last page \u2014 the review path is importing the export_takeoff payload into the app, where agent shapes arrive as pencil proposals. Default path: next to the loaded plan as "<plan> - marked set.pdf". Needs no native canvas \u2014 pure vector copy, so it works even where view_sheet cannot render.`,
|
|
9591
10078
|
inputSchema: {
|
|
@@ -9888,7 +10375,7 @@ function registerResources(server, session) {
|
|
|
9888
10375
|
// package.json
|
|
9889
10376
|
var package_default = {
|
|
9890
10377
|
name: "opentakeoff-mcp",
|
|
9891
|
-
version: "0.9.
|
|
10378
|
+
version: "0.9.38",
|
|
9892
10379
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
9893
10380
|
type: "module",
|
|
9894
10381
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -9908,6 +10395,10 @@ var package_default = {
|
|
|
9908
10395
|
},
|
|
9909
10396
|
dependencies: {
|
|
9910
10397
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
10398
|
+
"@turf/boolean-contains": "^7.4.0",
|
|
10399
|
+
"@turf/boolean-overlap": "^7.4.0",
|
|
10400
|
+
"@turf/difference": "^7.4.0",
|
|
10401
|
+
"@turf/helpers": "^7.4.0",
|
|
9911
10402
|
"pdf-lib": "^1.17.1",
|
|
9912
10403
|
"pdfjs-dist": "^4.10.38",
|
|
9913
10404
|
zod: "^3.24.1"
|
|
@@ -9917,9 +10408,9 @@ var package_default = {
|
|
|
9917
10408
|
},
|
|
9918
10409
|
devDependencies: {
|
|
9919
10410
|
"@types/node": "^22.19.21",
|
|
9920
|
-
|
|
10411
|
+
esbuild: "^0.25.0",
|
|
9921
10412
|
tsx: "^4.19.2",
|
|
9922
|
-
|
|
10413
|
+
typescript: "^5.7.2"
|
|
9923
10414
|
},
|
|
9924
10415
|
bin: {
|
|
9925
10416
|
"opentakeoff-mcp": "dist/server.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.38",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
23
|
+
"@turf/boolean-contains": "^7.4.0",
|
|
24
|
+
"@turf/boolean-overlap": "^7.4.0",
|
|
25
|
+
"@turf/difference": "^7.4.0",
|
|
26
|
+
"@turf/helpers": "^7.4.0",
|
|
23
27
|
"pdf-lib": "^1.17.1",
|
|
24
28
|
"pdfjs-dist": "^4.10.38",
|
|
25
29
|
"zod": "^3.24.1"
|
|
@@ -29,9 +33,9 @@
|
|
|
29
33
|
},
|
|
30
34
|
"devDependencies": {
|
|
31
35
|
"@types/node": "^22.19.21",
|
|
32
|
-
"
|
|
36
|
+
"esbuild": "^0.25.0",
|
|
33
37
|
"tsx": "^4.19.2",
|
|
34
|
-
"
|
|
38
|
+
"typescript": "^5.7.2"
|
|
35
39
|
},
|
|
36
40
|
"bin": {
|
|
37
41
|
"opentakeoff-mcp": "dist/server.js"
|