opentakeoff-mcp 0.9.32 → 0.9.35
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 +26 -6
- package/dist/server-core.js +167 -38
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -119,15 +119,15 @@ includes document text, shape vertices, or result payload content.
|
|
|
119
119
|
| `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the vector linework is searched deterministically for every other placement — translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. **`scope: "set"` sweeps the whole working set, counting on PLAN-role sheets only** (the sheet graph decides; every excluded sheet disclosed in `skipped` with role and reason) — and the seed rect may sit on a detail or legend sheet, which then serves as the fingerprint SOURCE while staying excluded from counting: the estimator's "click the assembly in the detail, count it on the plans" gesture. Per-sheet results carry their own match/withheld lists, per-sheet cap accounting, and wall-clock `elapsed_ms`. `commit: true` + `condition` commits every match center as an EA count marker — the whole sweep (set-wide included) is one undo step, `origin.method "symbol_sweep"` with per-marker score, transform, and seed source (`origin.symbol.seed`). No scale required. |
|
|
120
120
|
| `sweep_schedule_row` | **Take off a schedule row's mark from the row itself**: pass the row's key (e.g. `T1`) and the tool reads the row from the set's schedule tables (the row is the condition's cited source), anchors a fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence — `anchor.corroborated` — before it is trusted), and sweeps every plan-role sheet. **The count is geometry AND text agreeing**: drafting reuses one bubble shape across many marks, so a match counts only when the row's own tag sits within the marker footprint (its bbox rides the match as `tag_at` evidence); a match labeled with a sibling key is `excluded` and says whose it is, an unlabeled match is `withheld` as a question, a tag drawn with no matching marker is `text_only`. Refusal over guessing, each with the reason and the fix: no such row, an ambiguous key, a tag drawn on no plan sheet, no repeatable marker linework — a fingerprint is never guessed from text alone. `commit: true` commits the counted matches under the row's own key — one undo step, `origin.assignment {source: "schedule"}` plus the anchor and row citation on `origin.symbol.seed`. No scale required. |
|
|
121
121
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
122
|
-
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path
|
|
122
|
+
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path` (see **Writing to disk** below). |
|
|
123
123
|
| `delete_shape` | Remove a committed shape by id. |
|
|
124
|
-
| `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. |
|
|
125
|
-
| `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`. |
|
|
124
|
+
| `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
|
+
| `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
126
|
| `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. |
|
|
127
|
-
| `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
|
|
127
|
+
| `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
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. |
|
|
129
|
-
| `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. Works without `@napi-rs/canvas`. |
|
|
130
|
-
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, 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. |
|
|
129
|
+
| `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
|
+
| `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
131
|
| `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. |
|
|
132
132
|
| `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text), **dimension** (`from` + `to` — a dimension line with end ticks, labelled with the measured length at the sheet's scale; the one annotation the scale gate applies to — an unscaled sheet refuses like the measure tools). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
|
|
133
133
|
| `list_annotations` | Every annotation with its condition RESOLVED to a finish tag, coordinates back in image px; filter by sheet/condition. `unattached` counts the link_annotation candidates. `verdicts[]` is the approval family's inventory — every mark with its actor stated, a condition filter reaching a verdict through its target shape. |
|
|
@@ -245,6 +245,26 @@ A takeoff finishes with **both** exports: `export_marked_pdf` is what a human
|
|
|
245
245
|
reviews (construction takeoffs are no good without markup), `export_report` is
|
|
246
246
|
what pricing consumes.
|
|
247
247
|
|
|
248
|
+
### Writing to disk
|
|
249
|
+
|
|
250
|
+
`path` is not confined to a working directory, and deliberately so — the marked
|
|
251
|
+
set belongs in the job folder, wherever that is. What the export tools will not
|
|
252
|
+
do is destroy a file they didn't write:
|
|
253
|
+
|
|
254
|
+
- **Nothing at the path** → written.
|
|
255
|
+
- **A previous export of ours at the path** → overwritten silently. Fix a
|
|
256
|
+
condition and export again to the same path as often as you like; that's the
|
|
257
|
+
normal loop, and it needs no flag.
|
|
258
|
+
- **Any other existing file** → refused, with the path named. Pass
|
|
259
|
+
`overwrite: true` to replace it anyway.
|
|
260
|
+
- **Corrupt, encrypted, or unreadable** → treated as *not* ours, so refused. An
|
|
261
|
+
unrecognizable file is exactly the kind worth not overwriting.
|
|
262
|
+
|
|
263
|
+
A marked set is recognized by its PDF `Producer`; the JSON exports by the
|
|
264
|
+
`schema` key they stamp. This is data-loss protection, not a sandbox — the
|
|
265
|
+
server runs as you, with your privileges. See [`SECURITY.md`](../SECURITY.md)
|
|
266
|
+
for the threat model.
|
|
267
|
+
|
|
248
268
|
Sheet keys follow the app's codec: page 1 is the bare file name
|
|
249
269
|
(`plan.pdf`), pages 2+ are `plan.pdf#2`. Tools also accept the title-block
|
|
250
270
|
sheet number (`A-101`) wherever a sheet is named.
|
package/dist/server-core.js
CHANGED
|
@@ -3884,7 +3884,8 @@ function accumulateRole(acc, s) {
|
|
|
3884
3884
|
break;
|
|
3885
3885
|
}
|
|
3886
3886
|
}
|
|
3887
|
-
function conditionTotals(conditions, shapes) {
|
|
3887
|
+
function conditionTotals(conditions, shapes, ctx = null) {
|
|
3888
|
+
const seamByShape = ctx?.seamByShape instanceof Map ? ctx.seamByShape : null;
|
|
3888
3889
|
return conditions.map((c) => {
|
|
3889
3890
|
const mult = c.multiplier || 1;
|
|
3890
3891
|
const waste = Math.max(0, Number(c.waste_pct) || 0);
|
|
@@ -3893,15 +3894,17 @@ function conditionTotals(conditions, shapes) {
|
|
|
3893
3894
|
const acc = { floor: 0, wall: 0, border: 0, lf: 0, ea: 0 };
|
|
3894
3895
|
for (const s of cs) accumulateRole(acc, s);
|
|
3895
3896
|
let { floor, wall, border, lf, ea } = acc;
|
|
3897
|
+
let seam = seamByShape ? cs.reduce((n, s) => n + (seamByShape.get(s.id) || 0), 0) : 0;
|
|
3896
3898
|
floor *= mult;
|
|
3897
3899
|
wall *= mult;
|
|
3898
3900
|
border *= mult;
|
|
3899
3901
|
lf *= mult;
|
|
3900
3902
|
ea *= mult;
|
|
3903
|
+
seam *= mult;
|
|
3901
3904
|
const total = floor + wall + border;
|
|
3902
3905
|
const materials = (c.materials || []).filter((m) => m && m.name).map((m) => {
|
|
3903
3906
|
const per = Math.max(0, Number(m.per) || 0);
|
|
3904
|
-
const basisVal = m.basis === "linear" ? lf : m.basis === "count" ? ea : total;
|
|
3907
|
+
const basisVal = m.basis === "linear" ? lf : m.basis === "count" ? ea : m.basis === "seam_lf" ? seam : total;
|
|
3905
3908
|
let qty = per > 0 ? basisVal / per : 0;
|
|
3906
3909
|
qty = m.round === false ? round22(qty) : Math.ceil(qty - 1e-9);
|
|
3907
3910
|
return { name: m.name, unit: m.unit || "", per, basis: m.basis || "area", round: m.round !== false, note: m.note || "", basis_qty: round22(basisVal), qty };
|
|
@@ -4240,6 +4243,32 @@ function layoutRingStrips(item, direction, config) {
|
|
|
4240
4243
|
function sumStripLengthsFt(strips) {
|
|
4241
4244
|
return strips.reduce((s, st) => s + Math.max(0, st.runMax - st.runMin), 0);
|
|
4242
4245
|
}
|
|
4246
|
+
function seamLfBySrc(strips) {
|
|
4247
|
+
const bySrc = /* @__PURE__ */ new Map();
|
|
4248
|
+
for (const st of strips || []) {
|
|
4249
|
+
if (!bySrc.has(st.srcId)) bySrc.set(st.srcId, []);
|
|
4250
|
+
bySrc.get(st.srcId).push(st);
|
|
4251
|
+
}
|
|
4252
|
+
const out = /* @__PURE__ */ new Map();
|
|
4253
|
+
for (const [srcId, lanes] of bySrc) {
|
|
4254
|
+
lanes.sort((a, b) => (a.laneIndex || 0) - (b.laneIndex || 0));
|
|
4255
|
+
let total = 0;
|
|
4256
|
+
for (let i = 0; i + 1 < lanes.length; i++) {
|
|
4257
|
+
const a = lanes[i], b = lanes[i + 1];
|
|
4258
|
+
if ((b.laneIndex || 0) !== (a.laneIndex || 0) + 1) continue;
|
|
4259
|
+
const aLo = a.runMin + (a.minOverageFt || 0), aHi = a.runMax - (a.maxOverageFt || 0);
|
|
4260
|
+
const bLo = b.runMin + (b.minOverageFt || 0), bHi = b.runMax - (b.maxOverageFt || 0);
|
|
4261
|
+
total += Math.max(0, Math.min(aHi, bHi) - Math.max(aLo, bLo));
|
|
4262
|
+
}
|
|
4263
|
+
if (total > 0) out.set(srcId, Math.round(total * 100) / 100);
|
|
4264
|
+
}
|
|
4265
|
+
return out;
|
|
4266
|
+
}
|
|
4267
|
+
function seamLfForStrips(strips) {
|
|
4268
|
+
let total = 0;
|
|
4269
|
+
for (const v of seamLfBySrc(strips).values()) total += v;
|
|
4270
|
+
return Math.round(total * 100) / 100;
|
|
4271
|
+
}
|
|
4243
4272
|
function assignDefaultCutPositions(strips, rollWidthFt, seqById, rollLenFt = 0) {
|
|
4244
4273
|
const EPS = 1e-6;
|
|
4245
4274
|
const L = rollLenFt > 0 ? rollLenFt : Infinity;
|
|
@@ -4471,7 +4500,9 @@ function computeRollTakeoff(conditions, shapes, dimsFor, uppFor) {
|
|
|
4471
4500
|
oversize: layout.strips.some((s) => s.overRoll),
|
|
4472
4501
|
qty: round22(rollQtyForUnit(orderFt, config.rollWidthFt, unit)),
|
|
4473
4502
|
unit,
|
|
4474
|
-
cutCount: layout.strips.length
|
|
4503
|
+
cutCount: layout.strips.length,
|
|
4504
|
+
seamLf: seamLfForStrips(layout.strips),
|
|
4505
|
+
seamByShape: seamLfBySrc(layout.strips)
|
|
4475
4506
|
});
|
|
4476
4507
|
for (const strip of layout.strips) {
|
|
4477
4508
|
const src = shapeById.get(strip.srcId);
|
|
@@ -4524,11 +4555,25 @@ function rollReportRows(rollByCond, rows) {
|
|
|
4524
4555
|
rolls: ri.rollCount * mult,
|
|
4525
4556
|
order_qty: round22(ri.qty * mult),
|
|
4526
4557
|
order_unit: ri.unit,
|
|
4527
|
-
oversize: ri.oversize
|
|
4558
|
+
oversize: ri.oversize,
|
|
4559
|
+
// seam_lf APPENDS last (the additive-only convention the roll_goods
|
|
4560
|
+
// block already follows): the figured weld-rod / seam-tape length, ×N
|
|
4561
|
+
// like every other reported quantity. 0 is a real answer — a layout
|
|
4562
|
+
// whose rooms each fit one strip has no seams to weld.
|
|
4563
|
+
seam_lf: round22((ri.seamLf || 0) * mult)
|
|
4528
4564
|
});
|
|
4529
4565
|
}
|
|
4530
4566
|
return out;
|
|
4531
4567
|
}
|
|
4568
|
+
function seamLfByShape(rollByCond) {
|
|
4569
|
+
const out = /* @__PURE__ */ new Map();
|
|
4570
|
+
if (!rollByCond || typeof rollByCond.forEach !== "function") return out;
|
|
4571
|
+
rollByCond.forEach((ri) => {
|
|
4572
|
+
if (!ri || !(ri.seamByShape instanceof Map)) return;
|
|
4573
|
+
ri.seamByShape.forEach((lf, shapeId) => out.set(shapeId, (out.get(shapeId) || 0) + lf));
|
|
4574
|
+
});
|
|
4575
|
+
return out;
|
|
4576
|
+
}
|
|
4532
4577
|
|
|
4533
4578
|
// src/view.ts
|
|
4534
4579
|
var INK = "#d91a1a";
|
|
@@ -5483,13 +5528,15 @@ var Session = class _Session {
|
|
|
5483
5528
|
}
|
|
5484
5529
|
let shape_id;
|
|
5485
5530
|
if (tag) {
|
|
5486
|
-
|
|
5531
|
+
const shape = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
|
|
5487
5532
|
method: "one_click_v1",
|
|
5488
5533
|
actor: "agent",
|
|
5489
5534
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
5490
5535
|
reviewed: false,
|
|
5491
5536
|
...assignment ? { assignment } : {}
|
|
5492
|
-
}, c.ev)
|
|
5537
|
+
}, c.ev);
|
|
5538
|
+
shape.label = c.label;
|
|
5539
|
+
shape_id = shape.id;
|
|
5493
5540
|
}
|
|
5494
5541
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
|
|
5495
5542
|
}).filter((r) => r !== null);
|
|
@@ -5597,25 +5644,25 @@ var Session = class _Session {
|
|
|
5597
5644
|
byShape.set(o.shape_id, (byShape.get(o.shape_id) ?? 0) + o.lf);
|
|
5598
5645
|
}
|
|
5599
5646
|
for (const f of floors) {
|
|
5600
|
-
const
|
|
5647
|
+
const open2 = byShape.get(f.id) ?? 0;
|
|
5601
5648
|
const gross = f.computed.perimeter_lf ?? 0;
|
|
5602
|
-
if (
|
|
5603
|
-
throw new UserError(`Shape ${f.id}: stated openings (${round2(
|
|
5649
|
+
if (open2 >= gross) {
|
|
5650
|
+
throw new UserError(`Shape ${f.id}: stated openings (${round2(open2)} LF) meet or exceed its perimeter (${round2(gross)} LF) \u2014 nothing would remain.`);
|
|
5604
5651
|
}
|
|
5605
5652
|
}
|
|
5606
5653
|
const rooms = floors.map((f) => {
|
|
5607
5654
|
const s = this.sheet(f.sheet_id);
|
|
5608
5655
|
const gross = f.computed.perimeter_lf ?? 0;
|
|
5609
|
-
const
|
|
5610
|
-
const net = round2(gross -
|
|
5656
|
+
const open2 = byShape.get(f.id) ?? 0;
|
|
5657
|
+
const net = round2(gross - open2);
|
|
5611
5658
|
const ringPx = f.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
|
|
5612
5659
|
const shape = this.commit(s, opts.condition, "linear", [...ringPx, ringPx[0]], { area_sf: 0, perimeter_lf: net }, {
|
|
5613
5660
|
method: "agent_v1",
|
|
5614
5661
|
actor: "agent",
|
|
5615
5662
|
reviewed: false,
|
|
5616
|
-
derived: { from_shape_id: f.id, gross_lf: round2(gross), openings_lf: round2(
|
|
5663
|
+
derived: { from_shape_id: f.id, gross_lf: round2(gross), openings_lf: round2(open2) }
|
|
5617
5664
|
});
|
|
5618
|
-
return { source_shape_id: f.id, base_shape_id: shape.id, sheet: f.sheet_id, gross_lf: round2(gross), openings_lf: round2(
|
|
5665
|
+
return { source_shape_id: f.id, base_shape_id: shape.id, sheet: f.sheet_id, gross_lf: round2(gross), openings_lf: round2(open2), net_lf: net };
|
|
5619
5666
|
});
|
|
5620
5667
|
this.flushCommits("derive_base");
|
|
5621
5668
|
return {
|
|
@@ -6248,6 +6295,7 @@ var Session = class _Session {
|
|
|
6248
6295
|
...x.computed.perimeter_lf !== void 0 ? { perimeter_lf: x.computed.perimeter_lf } : {},
|
|
6249
6296
|
...x.computed.count !== void 0 ? { count: x.computed.count } : {},
|
|
6250
6297
|
...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
|
|
6298
|
+
...x.label ? { label: x.label } : {},
|
|
6251
6299
|
nverts: x.verts_norm.length,
|
|
6252
6300
|
reviewed: x.origin?.reviewed === true,
|
|
6253
6301
|
...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
|
|
@@ -6257,7 +6305,7 @@ var Session = class _Session {
|
|
|
6257
6305
|
};
|
|
6258
6306
|
}
|
|
6259
6307
|
summary() {
|
|
6260
|
-
const rows = conditionTotals(this.conditions, this.shapes);
|
|
6308
|
+
const rows = conditionTotals(this.conditions, this.shapes, this.seamCtx());
|
|
6261
6309
|
const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
|
|
6262
6310
|
return { conditions: lean, totals: grandTotals(rows) };
|
|
6263
6311
|
}
|
|
@@ -6280,6 +6328,11 @@ var Session = class _Session {
|
|
|
6280
6328
|
* makes this surface portable to a host that DOES have a review gate, and it
|
|
6281
6329
|
* belongs in the code rather than in a host's good intentions.
|
|
6282
6330
|
*
|
|
6331
|
+
* `label` is the room (or phase, or area) the shape belongs to — the same
|
|
6332
|
+
* per-shape field the canvas groups the Report by. A visible string sets it,
|
|
6333
|
+
* "" clears it, and the whole shape is snapshotted before the write, so undo
|
|
6334
|
+
* restores a cleared label as exactly as it restores geometry.
|
|
6335
|
+
*
|
|
6283
6336
|
* Provenance: agent self-revision bumps origin.agent_edits and touches
|
|
6284
6337
|
* NOTHING in the human-correction vocabulary (edited / edits /
|
|
6285
6338
|
* proposed_verts_norm — see web/src/lib/provenance.js). Those fields grade a
|
|
@@ -6295,8 +6348,8 @@ var Session = class _Session {
|
|
|
6295
6348
|
if (cur.origin?.reviewed === true) {
|
|
6296
6349
|
throw new UserError(`Shape ${JSON.stringify(id)} was affirmed by a human \u2014 reviewed work is ink, not pencil, and cannot be edited by an agent.`);
|
|
6297
6350
|
}
|
|
6298
|
-
if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0) {
|
|
6299
|
-
throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role.");
|
|
6351
|
+
if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0 && patch.label === void 0) {
|
|
6352
|
+
throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role, label.");
|
|
6300
6353
|
}
|
|
6301
6354
|
const s = this.sheet(cur.sheet_id);
|
|
6302
6355
|
const role = patch.role ?? cur.measure_role;
|
|
@@ -6323,12 +6376,14 @@ var Session = class _Session {
|
|
|
6323
6376
|
})();
|
|
6324
6377
|
const before = structuredClone(cur);
|
|
6325
6378
|
const condition_id = patch.condition !== void 0 ? this.conditionFor(patch.condition).id : cur.condition_id;
|
|
6379
|
+
const nextLabel = patch.label !== void 0 ? patch.label.trim() : cur.label ?? "";
|
|
6326
6380
|
this.shapes[i] = {
|
|
6327
6381
|
...cur,
|
|
6328
6382
|
condition_id,
|
|
6329
6383
|
measure_role: role,
|
|
6330
6384
|
verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
|
|
6331
6385
|
computed,
|
|
6386
|
+
...nextLabel ? { label: nextLabel } : {},
|
|
6332
6387
|
...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
|
|
6333
6388
|
...cur.origin ? { origin: {
|
|
6334
6389
|
...cur.origin,
|
|
@@ -6338,11 +6393,13 @@ var Session = class _Session {
|
|
|
6338
6393
|
...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
|
|
6339
6394
|
} } : {}
|
|
6340
6395
|
};
|
|
6396
|
+
if (!nextLabel) delete this.shapes[i].label;
|
|
6341
6397
|
this.record({ op: "edit", tool: "edit_shape", before });
|
|
6342
6398
|
const changed = [
|
|
6343
6399
|
...patch.verts !== void 0 ? ["verts"] : [],
|
|
6344
6400
|
...patch.condition !== void 0 ? ["condition"] : [],
|
|
6345
|
-
...patch.role !== void 0 ? ["role"] : []
|
|
6401
|
+
...patch.role !== void 0 ? ["role"] : [],
|
|
6402
|
+
...patch.label !== void 0 ? ["label"] : []
|
|
6346
6403
|
];
|
|
6347
6404
|
return {
|
|
6348
6405
|
shape_id: id,
|
|
@@ -6350,6 +6407,7 @@ var Session = class _Session {
|
|
|
6350
6407
|
measure_role: role,
|
|
6351
6408
|
nverts: vertsPx.length,
|
|
6352
6409
|
...computed,
|
|
6410
|
+
...nextLabel ? { label: nextLabel } : {},
|
|
6353
6411
|
agent_edits: this.shapes[i].origin?.agent_edits ?? 0
|
|
6354
6412
|
};
|
|
6355
6413
|
}
|
|
@@ -6477,6 +6535,16 @@ var Session = class _Session {
|
|
|
6477
6535
|
uppFor: (sheetId) => this.sheets.get(sheetId)?.upp ?? null
|
|
6478
6536
|
};
|
|
6479
6537
|
}
|
|
6538
|
+
/** The figured seam length per SHAPE, as conditionTotals wants it — the
|
|
6539
|
+
* basis a materials row with basis "seam_lf" (weld rod, seam tape) divides
|
|
6540
|
+
* against. computeRollTakeoff returns immediately when no condition carries
|
|
6541
|
+
* a roll setup, so this costs nothing on a job that has none, and every
|
|
6542
|
+
* seam_lf row on such a job reads 0 rather than guessing. */
|
|
6543
|
+
seamCtx() {
|
|
6544
|
+
const { dimsFor, uppFor } = this.rollInputs();
|
|
6545
|
+
const { byCond } = computeRollTakeoff(this.conditions, this.shapes, dimsFor, uppFor);
|
|
6546
|
+
return { seamByShape: seamLfByShape(byCond) };
|
|
6547
|
+
}
|
|
6480
6548
|
editCondition(tag, opts) {
|
|
6481
6549
|
if (opts.waste_pct === void 0 && opts.multiplier === void 0 && opts.height_ft === void 0 && opts.roll_setup === void 0) {
|
|
6482
6550
|
throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier, height_ft, roll_setup.");
|
|
@@ -6991,9 +7059,9 @@ var Session = class _Session {
|
|
|
6991
7059
|
* document carries the computed order quantities. */
|
|
6992
7060
|
exportReport(projectName = "") {
|
|
6993
7061
|
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
6994
|
-
const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
|
|
6995
7062
|
const { dimsFor, uppFor } = this.rollInputs();
|
|
6996
7063
|
const { byCond } = computeRollTakeoff(this.conditions, this.shapes, dimsFor, uppFor);
|
|
7064
|
+
const rows = conditionTotals(this.conditions, this.shapes, { seamByShape: seamLfByShape(byCond) }).filter((r) => r.shape_count > 0);
|
|
6997
7065
|
return reportJson({
|
|
6998
7066
|
projectName,
|
|
6999
7067
|
rows,
|
|
@@ -7489,6 +7557,7 @@ var listShapesOutput = {
|
|
|
7489
7557
|
perimeter_lf: z.number().optional(),
|
|
7490
7558
|
count: z.number().optional(),
|
|
7491
7559
|
height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
|
|
7560
|
+
label: z.string().optional().describe("The room (or phase/area) this shape belongs to \u2014 detect_rooms stamps the room number it traced from; edit_shape sets or clears it. Absent when unlabeled"),
|
|
7492
7561
|
nverts: z.number().int(),
|
|
7493
7562
|
reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
|
|
7494
7563
|
assignment: z.enum(["schedule", "asserted"]).optional().describe(`Where the finish tag came from: "schedule" = resolved from the room's own schedule row, "asserted" = the agent chose it. origin.assignment in export_takeoff carries the citation. Absent on human canvas shapes`),
|
|
@@ -7502,12 +7571,13 @@ var deleteShapeOutput = {
|
|
|
7502
7571
|
};
|
|
7503
7572
|
var editShapeOutput = {
|
|
7504
7573
|
shape_id: z.string(),
|
|
7505
|
-
changed: z.array(z.enum(["verts", "condition", "role"])).describe("Which fields this call actually changed"),
|
|
7574
|
+
changed: z.array(z.enum(["verts", "condition", "role", "label"])).describe("Which fields this call actually changed"),
|
|
7506
7575
|
measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
|
|
7507
7576
|
nverts: z.number().int(),
|
|
7508
7577
|
area_sf: z.number().optional().describe("0 for linear shapes; LF \xD7 height for surface_area; absent for count"),
|
|
7509
7578
|
perimeter_lf: z.number().optional().describe("Length for linear/surface runs, perimeter for closed ones; absent for count"),
|
|
7510
7579
|
count: z.number().optional().describe("count shapes only \u2014 the marker's EA (preserved across the edit)"),
|
|
7580
|
+
label: z.string().optional().describe("The shape's room/phase label after this call \u2014 absent when it carries none (a cleared label reports as absent, not as an empty string)"),
|
|
7511
7581
|
agent_edits: z.number().int().describe("How many times the agent has revised this shape \u2014 separate from the human-correction tally")
|
|
7512
7582
|
};
|
|
7513
7583
|
var undoLastOutput = {
|
|
@@ -7537,7 +7607,7 @@ var materialRow = z.object({
|
|
|
7537
7607
|
id: z.string(),
|
|
7538
7608
|
name: z.string(),
|
|
7539
7609
|
per: z.number().describe("Coverage rate: basis \xF7 per = order quantity"),
|
|
7540
|
-
basis: z.enum(["area", "linear", "count"]).describe("Which of the condition's totals this row's quantity is computed against"),
|
|
7610
|
+
basis: z.enum(["area", "linear", "count", "seam_lf"]).describe("Which of the condition's totals this row's quantity is computed against \u2014 'seam_lf' is the FIGURED roll-layout seam length (weld rod, seam tape), 0 until the condition carries a roll_setup"),
|
|
7541
7611
|
unit: z.string(),
|
|
7542
7612
|
round: z.boolean().describe("true = round up to whole purchase units (the default \u2014 you buy whole bags/buckets)"),
|
|
7543
7613
|
note: z.string().optional(),
|
|
@@ -7558,9 +7628,9 @@ var reportMaterialLine = z.object({
|
|
|
7558
7628
|
name: z.string(),
|
|
7559
7629
|
unit: z.string().describe("Purchase unit, e.g. 'gal', 'bag'"),
|
|
7560
7630
|
per: z.number().describe("Coverage rate \u2014 basis units per purchase unit"),
|
|
7561
|
-
basis: z.enum(["area", "linear", "count"]),
|
|
7631
|
+
basis: z.enum(["area", "linear", "count", "seam_lf"]),
|
|
7562
7632
|
round: z.boolean(),
|
|
7563
|
-
basis_qty: z.number().describe("The condition total this row divides (SF, LF, or
|
|
7633
|
+
basis_qty: z.number().describe("The condition total this row divides (SF, LF, EA, or figured seam LF \u2014 multiplier applied, waste not)"),
|
|
7564
7634
|
qty: z.number().describe("Computed order quantity")
|
|
7565
7635
|
}).passthrough();
|
|
7566
7636
|
var exportReportOutput = {
|
|
@@ -8531,6 +8601,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
8531
8601
|
if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
|
|
8532
8602
|
const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
|
|
8533
8603
|
const doc = await PDFDocument.create();
|
|
8604
|
+
doc.setProducer("OpenTakeoff");
|
|
8605
|
+
doc.setCreator("OpenTakeoff");
|
|
8534
8606
|
const font = await doc.embedFont(StandardFonts.Helvetica);
|
|
8535
8607
|
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
|
|
8536
8608
|
const ink = dark ? rgb(0.93, 0.92, 0.89) : rgb(0.13, 0.12, 0.1);
|
|
@@ -8963,6 +9035,54 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
8963
9035
|
return { bytes, filename };
|
|
8964
9036
|
}
|
|
8965
9037
|
|
|
9038
|
+
// src/safewrite.ts
|
|
9039
|
+
import { open, stat } from "node:fs/promises";
|
|
9040
|
+
var PDF_PRODUCER = "OpenTakeoff";
|
|
9041
|
+
async function head(p, n) {
|
|
9042
|
+
const fh = await open(p, "r");
|
|
9043
|
+
try {
|
|
9044
|
+
const buf = Buffer.alloc(n);
|
|
9045
|
+
const { bytesRead } = await fh.read(buf, 0, n, 0);
|
|
9046
|
+
return buf.subarray(0, bytesRead);
|
|
9047
|
+
} finally {
|
|
9048
|
+
await fh.close();
|
|
9049
|
+
}
|
|
9050
|
+
}
|
|
9051
|
+
async function isOwnExport(outPath, kind) {
|
|
9052
|
+
try {
|
|
9053
|
+
if (kind === "json") {
|
|
9054
|
+
return /"schema"\s*:\s*"opentakeoff\./.test((await head(outPath, 4096)).toString("utf8"));
|
|
9055
|
+
}
|
|
9056
|
+
if ((await head(outPath, 5)).toString("latin1") !== "%PDF-") return false;
|
|
9057
|
+
const { readFile: readFile4 } = await import("node:fs/promises");
|
|
9058
|
+
const { PDFDocument } = await import("pdf-lib");
|
|
9059
|
+
const doc = await PDFDocument.load(await readFile4(outPath), {
|
|
9060
|
+
ignoreEncryption: true,
|
|
9061
|
+
updateMetadata: false
|
|
9062
|
+
});
|
|
9063
|
+
return (doc.getProducer() ?? "").includes(PDF_PRODUCER);
|
|
9064
|
+
} catch {
|
|
9065
|
+
return false;
|
|
9066
|
+
}
|
|
9067
|
+
}
|
|
9068
|
+
async function assertWritable(outPath, kind, overwrite) {
|
|
9069
|
+
if (overwrite === true) return;
|
|
9070
|
+
let st;
|
|
9071
|
+
try {
|
|
9072
|
+
st = await stat(outPath);
|
|
9073
|
+
} catch {
|
|
9074
|
+
return;
|
|
9075
|
+
}
|
|
9076
|
+
if (st.isDirectory()) {
|
|
9077
|
+
throw new UserError(`${outPath} is a directory \u2014 pass the full file path to write, not the folder.`);
|
|
9078
|
+
}
|
|
9079
|
+
if (await isOwnExport(outPath, kind)) return;
|
|
9080
|
+
throw new UserError(
|
|
9081
|
+
`${outPath} already exists and is not an OpenTakeoff export, so writing there would destroy it. Write somewhere else, or pass overwrite: true if replacing that exact file is what you mean to do.`
|
|
9082
|
+
);
|
|
9083
|
+
}
|
|
9084
|
+
var OVERWRITE_DESC = "Replace the file at path even when it is not an OpenTakeoff export. Off by default: re-exporting over a previous export of your own already overwrites without this, so you only need it to deliberately destroy an unrelated file.";
|
|
9085
|
+
|
|
8966
9086
|
// src/marked.ts
|
|
8967
9087
|
function assignmentDisclosure(shapes, withheld = []) {
|
|
8968
9088
|
const agent = shapes.filter((s) => s.origin?.actor === "agent");
|
|
@@ -8985,6 +9105,9 @@ async function exportMarkedPdf(session, opts) {
|
|
|
8985
9105
|
if (!session.shapes.length && !session.markups.length && !session.approvals.length) {
|
|
8986
9106
|
throw new UserError("Nothing to mark yet \u2014 commit shapes (one_click / detect_rooms / measure_polygon / measure_line with a condition) or annotate before exporting the marked set.");
|
|
8987
9107
|
}
|
|
9108
|
+
const base = file.replace(/\.pdf$/i, "");
|
|
9109
|
+
const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
|
|
9110
|
+
await assertWritable(outPath, "pdf", opts.overwrite);
|
|
8988
9111
|
const sheetStates = session.sheetList();
|
|
8989
9112
|
const byFilePage = new Map(sheetStates.map((s) => [`${session.fileFor(s.key)}#${s.pageNum}`, s.page]));
|
|
8990
9113
|
const sheets = sheetStates.map((s) => ({
|
|
@@ -9016,7 +9139,6 @@ async function exportMarkedPdf(session, opts) {
|
|
|
9016
9139
|
}
|
|
9017
9140
|
return bytes2;
|
|
9018
9141
|
};
|
|
9019
|
-
const base = file.replace(/\.pdf$/i, "");
|
|
9020
9142
|
const machine = session.shapes.filter((s) => s.origin?.reviewed !== true).length;
|
|
9021
9143
|
const credit = machine ? `Machine-traced via OpenTakeoff MCP \u2014 ${machine} shape${machine === 1 ? "" : "s"} pending human review` : null;
|
|
9022
9144
|
const { bytes } = await buildMarkedSetPdf2({
|
|
@@ -9038,7 +9160,6 @@ async function exportMarkedPdf(session, opts) {
|
|
|
9038
9160
|
provenance: assignmentDisclosure(session.shapes, session.scheduleWithheld),
|
|
9039
9161
|
coverTitle: "OpenTakeoff \xB7 Marked Set"
|
|
9040
9162
|
});
|
|
9041
|
-
const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
|
|
9042
9163
|
await writeFile(outPath, bytes);
|
|
9043
9164
|
const markedKeys = /* @__PURE__ */ new Set([...session.shapes.map((s) => s.sheet_id), ...session.markups.map((m) => m.sheet_id), ...session.approvals.map((a) => a.sheet_id)]);
|
|
9044
9165
|
return {
|
|
@@ -9293,7 +9414,7 @@ function registerTools(server, session) {
|
|
|
9293
9414
|
outputSchema: oneClickOutput
|
|
9294
9415
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
|
|
9295
9416
|
server.registerTool("detect_rooms", {
|
|
9296
|
-
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. With the sheet's scale set, returns area_sf/perimeter_lf per room. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
9417
|
+
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. With the sheet's scale set, returns area_sf/perimeter_lf per room. Every committed room carries the room number it was traced from as the shape's \`label\`, so a sweep arrives already sliced by room \u2014 that field is what the Report's per-room grouping and the workbook's floor \xD7 room tab read, and it is the one thing about a batch that cannot be recovered downstream if it is dropped. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
9297
9418
|
inputSchema: {
|
|
9298
9419
|
sheet: z2.string(),
|
|
9299
9420
|
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
@@ -9427,11 +9548,15 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9427
9548
|
}, run("takeoff_summary", () => session.summary()));
|
|
9428
9549
|
server.registerTool("export_takeoff", {
|
|
9429
9550
|
description: `The full "opentakeoff.takeoff_canvas.v1" annotations payload \u2014 exactly what the app autosaves, importable by it. Returned inline; pass path to also write it to disk as JSON. ${COORDS}`,
|
|
9430
|
-
inputSchema: {
|
|
9551
|
+
inputSchema: {
|
|
9552
|
+
path: z2.string().optional().describe("File path to write the payload to"),
|
|
9553
|
+
overwrite: z2.boolean().optional().describe(OVERWRITE_DESC)
|
|
9554
|
+
},
|
|
9431
9555
|
outputSchema: exportTakeoffOutput
|
|
9432
|
-
}, run("export_takeoff", async ({ path: outPath }) => {
|
|
9556
|
+
}, run("export_takeoff", async ({ path: outPath, overwrite }) => {
|
|
9433
9557
|
const payload = session.exportPayload();
|
|
9434
9558
|
if (outPath) {
|
|
9559
|
+
await assertWritable(outPath, "json", overwrite);
|
|
9435
9560
|
const { writeFile: writeFile2 } = await import("node:fs/promises");
|
|
9436
9561
|
await writeFile2(outPath, JSON.stringify(payload));
|
|
9437
9562
|
}
|
|
@@ -9441,12 +9566,14 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9441
9566
|
description: `The computed Report document \u2014 "opentakeoff.report.v1", the same schema the canvas Report's JSON export writes. Everything a pricing consumer needs without re-implementing the app's math: per-condition quantities with waste and multiplier applied (gross and *_net), the computed materials BUY LIST per condition (order quantity = basis \xF7 coverage rate, rounded up to whole purchase units) plus the project-wide roll-up summed by (name, unit), per-sheet BASE subtotals, scale provenance per sheet, and annotations. Contrast: export_takeoff is the raw canvas payload (materials as CONFIG rows, no computed quantities) and takeoff_summary strips materials for a compact reply \u2014 when the numbers are leaving for pricing, consume this. A report alone is HALF the deliverable: pair it with export_marked_pdf, because a takeoff is reviewed on marked drawings, not on numbers. Returned inline; pass path to also write it to disk as JSON.`,
|
|
9442
9567
|
inputSchema: {
|
|
9443
9568
|
path: z2.string().optional().describe("File path to write the document to"),
|
|
9444
|
-
project_name: z2.string().optional().describe("Label for the document's project_name field (a headless session has no project of its own; omitted \u2192 null)")
|
|
9569
|
+
project_name: z2.string().optional().describe("Label for the document's project_name field (a headless session has no project of its own; omitted \u2192 null)"),
|
|
9570
|
+
overwrite: z2.boolean().optional().describe(OVERWRITE_DESC)
|
|
9445
9571
|
},
|
|
9446
9572
|
outputSchema: exportReportOutput
|
|
9447
|
-
}, run("export_report", async ({ path: outPath, project_name: projectName }) => {
|
|
9573
|
+
}, run("export_report", async ({ path: outPath, project_name: projectName, overwrite }) => {
|
|
9448
9574
|
const doc = session.exportReport(projectName);
|
|
9449
9575
|
if (outPath) {
|
|
9576
|
+
await assertWritable(outPath, "json", overwrite);
|
|
9450
9577
|
const { writeFile: writeFile2 } = await import("node:fs/promises");
|
|
9451
9578
|
await writeFile2(outPath, JSON.stringify(doc));
|
|
9452
9579
|
}
|
|
@@ -9463,12 +9590,13 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9463
9590
|
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.`,
|
|
9464
9591
|
inputSchema: {
|
|
9465
9592
|
path: z2.string().optional().describe('Where to write the PDF (default: "<plan dir>/<plan> - marked set.pdf")'),
|
|
9466
|
-
project_name: z2.string().optional().describe("Cover-page project name (default: the plan file's name)")
|
|
9593
|
+
project_name: z2.string().optional().describe("Cover-page project name (default: the plan file's name)"),
|
|
9594
|
+
overwrite: z2.boolean().optional().describe(OVERWRITE_DESC)
|
|
9467
9595
|
},
|
|
9468
9596
|
outputSchema: exportMarkedPdfOutput
|
|
9469
9597
|
}, run("export_marked_pdf", (a) => exportMarkedPdf(session, a)));
|
|
9470
9598
|
server.registerTool("list_shapes", {
|
|
9471
|
-
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.`,
|
|
9599
|
+
description: `The mid-session shape inventory (#149): every committed shape's id, sheet, condition tag, role, quantities, room label, 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.`,
|
|
9472
9600
|
inputSchema: {
|
|
9473
9601
|
sheet: z2.string().optional().describe("Only shapes on this sheet"),
|
|
9474
9602
|
condition: z2.string().optional().describe("Only shapes under this finish tag (must exist)")
|
|
@@ -9491,23 +9619,24 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9491
9619
|
outputSchema: sheetContextOutput
|
|
9492
9620
|
}, run("sheet_context", (a) => session.sheetContext(a.sheet, { region: a.region, min_len_px: a.min_len_px, max_segments: a.max_segments })));
|
|
9493
9621
|
server.registerTool("edit_shape", {
|
|
9494
|
-
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}`,
|
|
9622
|
+
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, label to name the room it belongs to, 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. label is the per-room reporting seam: detect_rooms already stamps the room number it traced from, so this is how a shape traced by hand \u2014 or one whose room number the sweep read wrong \u2014 joins the same per-room breakdown the Report and the workbook's floor \xD7 room tab group by. 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}`,
|
|
9495
9623
|
inputSchema: {
|
|
9496
9624
|
shape_id: z2.string().describe("Id returned when the shape was committed"),
|
|
9497
9625
|
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"),
|
|
9498
9626
|
condition: z2.string().optional().describe("Reassign to this finish tag (minted on first use)"),
|
|
9499
|
-
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")
|
|
9627
|
+
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"),
|
|
9628
|
+
label: z2.string().optional().describe('The room (or phase/area) this shape belongs to, e.g. "134" or "OFFICE 101" \u2014 what per-room reporting groups by. Pass "" to clear it')
|
|
9500
9629
|
},
|
|
9501
9630
|
outputSchema: editShapeOutput
|
|
9502
|
-
}, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
|
|
9631
|
+
}, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role, label: a.label })));
|
|
9503
9632
|
server.registerTool("edit_materials", {
|
|
9504
|
-
description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count) \xF7 per, rounded up to whole purchase units unless round:false. condition names an existing OR NEW finish tag (minted on first touch, same as one_click/measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
|
|
9633
|
+
description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count/seam_lf) \xF7 per, rounded up to whole purchase units unless round:false. basis "seam_lf" is the one basis that is FIGURED rather than measured: it is the length where two cuts meet on the floor, read off the condition's roll layout (set roll_setup with edit_condition), which is what a heat-weld rod or a carpet seam tape is bought by. A 20-ft-wide room off a 12-ft roll seams once down its length; the same square footage as two 10-ft rooms seams not at all, and no percentage of the area or the perimeter can tell those two jobs apart. Without a roll_setup \u2014 or with no committed floor shapes to lay out \u2014 a seam_lf row reads 0, which is the honest state rather than a guess. condition names an existing OR NEW finish tag (minted on first touch, same as one_click/measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
|
|
9505
9634
|
inputSchema: {
|
|
9506
9635
|
condition: z2.string().describe("Finish tag, e.g. 'CPT-1'"),
|
|
9507
9636
|
add: z2.array(z2.object({
|
|
9508
9637
|
name: z2.string().min(1),
|
|
9509
9638
|
per: z2.number().min(0).optional().describe("Coverage rate \u2014 basis units per purchase unit, e.g. 250 for 1 gal / 250 sf. Default 0 (quantity 0 until set)"),
|
|
9510
|
-
basis: z2.enum(["area", "linear", "count"]).optional().describe("Which of the condition's totals this row divides against \u2014 default 'area' (total SF)"),
|
|
9639
|
+
basis: z2.enum(["area", "linear", "count", "seam_lf"]).optional().describe("Which of the condition's totals this row divides against \u2014 default 'area' (total SF). 'seam_lf' is the figured roll-layout seam length (weld rod, seam tape), 0 until the condition carries a roll_setup"),
|
|
9511
9640
|
unit: z2.string().optional().describe("Purchase unit, e.g. 'gal', 'bag', 'roll'"),
|
|
9512
9641
|
round: z2.boolean().optional().describe("Round up to whole purchase units \u2014 default true"),
|
|
9513
9642
|
note: z2.string().optional()
|
|
@@ -9759,7 +9888,7 @@ function registerResources(server, session) {
|
|
|
9759
9888
|
// package.json
|
|
9760
9889
|
var package_default = {
|
|
9761
9890
|
name: "opentakeoff-mcp",
|
|
9762
|
-
version: "0.9.
|
|
9891
|
+
version: "0.9.35",
|
|
9763
9892
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
9764
9893
|
type: "module",
|
|
9765
9894
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -9775,7 +9904,7 @@ var package_default = {
|
|
|
9775
9904
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
9776
9905
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
9777
9906
|
typecheck: "tsc --noEmit",
|
|
9778
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
9907
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
9779
9908
|
},
|
|
9780
9909
|
dependencies: {
|
|
9781
9910
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.35",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
19
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|