opentakeoff-mcp 0.9.31 → 0.9.33
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 +3 -3
- package/dist/server-core.js +471 -37
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -121,13 +121,13 @@ includes document text, shape vertices, or result payload content.
|
|
|
121
121
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
122
122
|
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
|
|
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
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`. |
|
|
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
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. |
|
|
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. |
|
package/dist/server-core.js
CHANGED
|
@@ -3043,6 +3043,136 @@ var fail = (err) => ({
|
|
|
3043
3043
|
var round2 = (n) => +n.toFixed(2);
|
|
3044
3044
|
var round1 = (n) => +n.toFixed(1);
|
|
3045
3045
|
|
|
3046
|
+
// ../web/src/lib/variants.ts
|
|
3047
|
+
var SEP = " \u2013 ";
|
|
3048
|
+
var ROW_LINK_FIELDS = ["id", "origin_id", "inherited"];
|
|
3049
|
+
function baseTagOf(tag) {
|
|
3050
|
+
const s = String(tag ?? "");
|
|
3051
|
+
const i = s.indexOf(SEP);
|
|
3052
|
+
return (i === -1 ? s : s.slice(0, i)).trim();
|
|
3053
|
+
}
|
|
3054
|
+
function variantTag(baseOrTag, label) {
|
|
3055
|
+
const base = baseTagOf(baseOrTag);
|
|
3056
|
+
const l = String(label ?? "").trim();
|
|
3057
|
+
return l ? `${base}${SEP}${l}` : base;
|
|
3058
|
+
}
|
|
3059
|
+
function copyRow(row, id, originId) {
|
|
3060
|
+
return { ...row, id, origin_id: originId, inherited: true };
|
|
3061
|
+
}
|
|
3062
|
+
function childrenOf(conds, parentId) {
|
|
3063
|
+
return conds.filter((c) => c.variant_of === parentId);
|
|
3064
|
+
}
|
|
3065
|
+
function stripLinks(patch) {
|
|
3066
|
+
const out = { ...patch };
|
|
3067
|
+
for (const f of ROW_LINK_FIELDS) delete out[f];
|
|
3068
|
+
return out;
|
|
3069
|
+
}
|
|
3070
|
+
function mintTwin(parent, opts) {
|
|
3071
|
+
const family_id = parent.family_id || opts.mintId("fam");
|
|
3072
|
+
const materials = (parent.materials || []).map((r) => copyRow(r, opts.mintId("mat"), r.id));
|
|
3073
|
+
const twin = {
|
|
3074
|
+
...parent,
|
|
3075
|
+
id: opts.mintId("cnd"),
|
|
3076
|
+
created_at: opts.nowIso(),
|
|
3077
|
+
finish_tag: opts.tag || variantTag(parent.finish_tag, opts.label),
|
|
3078
|
+
family_id,
|
|
3079
|
+
variant_of: parent.id,
|
|
3080
|
+
variant_label: String(opts.label ?? "").trim() || void 0,
|
|
3081
|
+
materials,
|
|
3082
|
+
...opts.nextHatch ? { hatch: opts.nextHatch } : {}
|
|
3083
|
+
};
|
|
3084
|
+
delete twin.materials_dropped;
|
|
3085
|
+
delete twin.updated_at;
|
|
3086
|
+
if (twin.attrs && typeof twin.attrs === "object") twin.attrs = { ...twin.attrs };
|
|
3087
|
+
return { twin, parentPatch: parent.family_id ? null : { family_id } };
|
|
3088
|
+
}
|
|
3089
|
+
function propagateRowPatch(conds, parentId, originId, patch, seen = /* @__PURE__ */ new Set()) {
|
|
3090
|
+
if (seen.has(parentId)) return conds;
|
|
3091
|
+
seen.add(parentId);
|
|
3092
|
+
const clean = stripLinks(patch);
|
|
3093
|
+
let out = conds;
|
|
3094
|
+
for (const child of childrenOf(conds, parentId)) {
|
|
3095
|
+
if ((child.materials_dropped || []).includes(originId)) continue;
|
|
3096
|
+
const row = (child.materials || []).find((r) => r.origin_id === originId && r.inherited);
|
|
3097
|
+
if (!row) continue;
|
|
3098
|
+
out = out.map((c) => c.id !== child.id ? c : {
|
|
3099
|
+
...c,
|
|
3100
|
+
materials: (c.materials || []).map((r) => r.id !== row.id ? r : { ...r, ...clean, id: r.id, origin_id: r.origin_id, inherited: true })
|
|
3101
|
+
});
|
|
3102
|
+
out = propagateRowPatch(out, child.id, row.id, clean, seen);
|
|
3103
|
+
}
|
|
3104
|
+
return out;
|
|
3105
|
+
}
|
|
3106
|
+
function propagateRowAdd(conds, parentId, parentRow, mintId, seen = /* @__PURE__ */ new Set()) {
|
|
3107
|
+
if (seen.has(parentId)) return conds;
|
|
3108
|
+
seen.add(parentId);
|
|
3109
|
+
let out = conds;
|
|
3110
|
+
for (const child of childrenOf(conds, parentId)) {
|
|
3111
|
+
if ((child.materials_dropped || []).includes(parentRow.id)) continue;
|
|
3112
|
+
if ((child.materials || []).some((r) => r.origin_id === parentRow.id)) continue;
|
|
3113
|
+
const row = copyRow(parentRow, mintId("mat"), parentRow.id);
|
|
3114
|
+
out = out.map((c) => c.id !== child.id ? c : { ...c, materials: [...c.materials || [], row] });
|
|
3115
|
+
out = propagateRowAdd(out, child.id, row, mintId, seen);
|
|
3116
|
+
}
|
|
3117
|
+
return out;
|
|
3118
|
+
}
|
|
3119
|
+
function propagateRowRemove(conds, parentId, originId, seen = /* @__PURE__ */ new Set()) {
|
|
3120
|
+
if (seen.has(parentId)) return conds;
|
|
3121
|
+
seen.add(parentId);
|
|
3122
|
+
let out = conds;
|
|
3123
|
+
for (const child of childrenOf(conds, parentId)) {
|
|
3124
|
+
const row = (child.materials || []).find((r) => r.origin_id === originId);
|
|
3125
|
+
if (!row) continue;
|
|
3126
|
+
if (row.inherited) {
|
|
3127
|
+
out = out.map((c) => c.id !== child.id ? c : { ...c, materials: (c.materials || []).filter((r) => r.id !== row.id) });
|
|
3128
|
+
out = propagateRowRemove(out, child.id, row.id, seen);
|
|
3129
|
+
} else {
|
|
3130
|
+
out = out.map((c) => c.id !== child.id ? c : {
|
|
3131
|
+
...c,
|
|
3132
|
+
materials: (c.materials || []).map((r) => {
|
|
3133
|
+
if (r.id !== row.id) return r;
|
|
3134
|
+
const rest = { ...r };
|
|
3135
|
+
delete rest.origin_id;
|
|
3136
|
+
delete rest.inherited;
|
|
3137
|
+
return rest;
|
|
3138
|
+
})
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return out;
|
|
3143
|
+
}
|
|
3144
|
+
function markRowLocal(cond, id) {
|
|
3145
|
+
return {
|
|
3146
|
+
...cond,
|
|
3147
|
+
materials: (cond.materials || []).map((r) => r.id === id ? { ...r, inherited: false } : r)
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function dropRowLocal(cond, id) {
|
|
3151
|
+
const row = (cond.materials || []).find((r) => r.id === id);
|
|
3152
|
+
const materials = (cond.materials || []).filter((r) => r.id !== id);
|
|
3153
|
+
if (!row?.inherited || !row.origin_id) return { ...cond, materials };
|
|
3154
|
+
const dropped = [...cond.materials_dropped || []];
|
|
3155
|
+
if (!dropped.includes(row.origin_id)) dropped.push(row.origin_id);
|
|
3156
|
+
return { ...cond, materials, materials_dropped: dropped };
|
|
3157
|
+
}
|
|
3158
|
+
function splitFromFamily(conds, condId) {
|
|
3159
|
+
return conds.map((c) => {
|
|
3160
|
+
if (c.id !== condId) return c;
|
|
3161
|
+
const next = {
|
|
3162
|
+
...c,
|
|
3163
|
+
materials: (c.materials || []).map((r) => {
|
|
3164
|
+
const rest = { ...r };
|
|
3165
|
+
delete rest.origin_id;
|
|
3166
|
+
delete rest.inherited;
|
|
3167
|
+
return rest;
|
|
3168
|
+
})
|
|
3169
|
+
};
|
|
3170
|
+
delete next.variant_of;
|
|
3171
|
+
delete next.materials_dropped;
|
|
3172
|
+
return next;
|
|
3173
|
+
});
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3046
3176
|
// ../web/src/lib/confidence.ts
|
|
3047
3177
|
var CONF_RASTER = 0.9;
|
|
3048
3178
|
var CONF_HATCH = 0.95;
|
|
@@ -3754,7 +3884,8 @@ function accumulateRole(acc, s) {
|
|
|
3754
3884
|
break;
|
|
3755
3885
|
}
|
|
3756
3886
|
}
|
|
3757
|
-
function conditionTotals(conditions, shapes) {
|
|
3887
|
+
function conditionTotals(conditions, shapes, ctx = null) {
|
|
3888
|
+
const seamByShape = ctx?.seamByShape instanceof Map ? ctx.seamByShape : null;
|
|
3758
3889
|
return conditions.map((c) => {
|
|
3759
3890
|
const mult = c.multiplier || 1;
|
|
3760
3891
|
const waste = Math.max(0, Number(c.waste_pct) || 0);
|
|
@@ -3763,15 +3894,17 @@ function conditionTotals(conditions, shapes) {
|
|
|
3763
3894
|
const acc = { floor: 0, wall: 0, border: 0, lf: 0, ea: 0 };
|
|
3764
3895
|
for (const s of cs) accumulateRole(acc, s);
|
|
3765
3896
|
let { floor, wall, border, lf, ea } = acc;
|
|
3897
|
+
let seam = seamByShape ? cs.reduce((n, s) => n + (seamByShape.get(s.id) || 0), 0) : 0;
|
|
3766
3898
|
floor *= mult;
|
|
3767
3899
|
wall *= mult;
|
|
3768
3900
|
border *= mult;
|
|
3769
3901
|
lf *= mult;
|
|
3770
3902
|
ea *= mult;
|
|
3903
|
+
seam *= mult;
|
|
3771
3904
|
const total = floor + wall + border;
|
|
3772
3905
|
const materials = (c.materials || []).filter((m) => m && m.name).map((m) => {
|
|
3773
3906
|
const per = Math.max(0, Number(m.per) || 0);
|
|
3774
|
-
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;
|
|
3775
3908
|
let qty = per > 0 ? basisVal / per : 0;
|
|
3776
3909
|
qty = m.round === false ? round22(qty) : Math.ceil(qty - 1e-9);
|
|
3777
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 };
|
|
@@ -4110,6 +4243,32 @@ function layoutRingStrips(item, direction, config) {
|
|
|
4110
4243
|
function sumStripLengthsFt(strips) {
|
|
4111
4244
|
return strips.reduce((s, st) => s + Math.max(0, st.runMax - st.runMin), 0);
|
|
4112
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
|
+
}
|
|
4113
4272
|
function assignDefaultCutPositions(strips, rollWidthFt, seqById, rollLenFt = 0) {
|
|
4114
4273
|
const EPS = 1e-6;
|
|
4115
4274
|
const L = rollLenFt > 0 ? rollLenFt : Infinity;
|
|
@@ -4341,7 +4500,9 @@ function computeRollTakeoff(conditions, shapes, dimsFor, uppFor) {
|
|
|
4341
4500
|
oversize: layout.strips.some((s) => s.overRoll),
|
|
4342
4501
|
qty: round22(rollQtyForUnit(orderFt, config.rollWidthFt, unit)),
|
|
4343
4502
|
unit,
|
|
4344
|
-
cutCount: layout.strips.length
|
|
4503
|
+
cutCount: layout.strips.length,
|
|
4504
|
+
seamLf: seamLfForStrips(layout.strips),
|
|
4505
|
+
seamByShape: seamLfBySrc(layout.strips)
|
|
4345
4506
|
});
|
|
4346
4507
|
for (const strip of layout.strips) {
|
|
4347
4508
|
const src = shapeById.get(strip.srcId);
|
|
@@ -4394,11 +4555,25 @@ function rollReportRows(rollByCond, rows) {
|
|
|
4394
4555
|
rolls: ri.rollCount * mult,
|
|
4395
4556
|
order_qty: round22(ri.qty * mult),
|
|
4396
4557
|
order_unit: ri.unit,
|
|
4397
|
-
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)
|
|
4398
4564
|
});
|
|
4399
4565
|
}
|
|
4400
4566
|
return out;
|
|
4401
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
|
+
}
|
|
4402
4577
|
|
|
4403
4578
|
// src/view.ts
|
|
4404
4579
|
var INK = "#d91a1a";
|
|
@@ -4500,6 +4675,7 @@ var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488",
|
|
|
4500
4675
|
var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
|
|
4501
4676
|
var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
4502
4677
|
var uid = (p) => `${p}-${mintUuid2()}`;
|
|
4678
|
+
var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
4503
4679
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
4504
4680
|
var sanitizeApprovals2 = sanitizeApprovals;
|
|
4505
4681
|
var applyApprovalCommand2 = applyApprovalCommand;
|
|
@@ -5352,13 +5528,15 @@ var Session = class _Session {
|
|
|
5352
5528
|
}
|
|
5353
5529
|
let shape_id;
|
|
5354
5530
|
if (tag) {
|
|
5355
|
-
|
|
5531
|
+
const shape = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
|
|
5356
5532
|
method: "one_click_v1",
|
|
5357
5533
|
actor: "agent",
|
|
5358
5534
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
5359
5535
|
reviewed: false,
|
|
5360
5536
|
...assignment ? { assignment } : {}
|
|
5361
|
-
}, c.ev)
|
|
5537
|
+
}, c.ev);
|
|
5538
|
+
shape.label = c.label;
|
|
5539
|
+
shape_id = shape.id;
|
|
5362
5540
|
}
|
|
5363
5541
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
|
|
5364
5542
|
}).filter((r) => r !== null);
|
|
@@ -6117,6 +6295,7 @@ var Session = class _Session {
|
|
|
6117
6295
|
...x.computed.perimeter_lf !== void 0 ? { perimeter_lf: x.computed.perimeter_lf } : {},
|
|
6118
6296
|
...x.computed.count !== void 0 ? { count: x.computed.count } : {},
|
|
6119
6297
|
...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
|
|
6298
|
+
...x.label ? { label: x.label } : {},
|
|
6120
6299
|
nverts: x.verts_norm.length,
|
|
6121
6300
|
reviewed: x.origin?.reviewed === true,
|
|
6122
6301
|
...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
|
|
@@ -6126,7 +6305,7 @@ var Session = class _Session {
|
|
|
6126
6305
|
};
|
|
6127
6306
|
}
|
|
6128
6307
|
summary() {
|
|
6129
|
-
const rows = conditionTotals(this.conditions, this.shapes);
|
|
6308
|
+
const rows = conditionTotals(this.conditions, this.shapes, this.seamCtx());
|
|
6130
6309
|
const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
|
|
6131
6310
|
return { conditions: lean, totals: grandTotals(rows) };
|
|
6132
6311
|
}
|
|
@@ -6149,6 +6328,11 @@ var Session = class _Session {
|
|
|
6149
6328
|
* makes this surface portable to a host that DOES have a review gate, and it
|
|
6150
6329
|
* belongs in the code rather than in a host's good intentions.
|
|
6151
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
|
+
*
|
|
6152
6336
|
* Provenance: agent self-revision bumps origin.agent_edits and touches
|
|
6153
6337
|
* NOTHING in the human-correction vocabulary (edited / edits /
|
|
6154
6338
|
* proposed_verts_norm — see web/src/lib/provenance.js). Those fields grade a
|
|
@@ -6164,8 +6348,8 @@ var Session = class _Session {
|
|
|
6164
6348
|
if (cur.origin?.reviewed === true) {
|
|
6165
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.`);
|
|
6166
6350
|
}
|
|
6167
|
-
if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0) {
|
|
6168
|
-
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.");
|
|
6169
6353
|
}
|
|
6170
6354
|
const s = this.sheet(cur.sheet_id);
|
|
6171
6355
|
const role = patch.role ?? cur.measure_role;
|
|
@@ -6192,12 +6376,14 @@ var Session = class _Session {
|
|
|
6192
6376
|
})();
|
|
6193
6377
|
const before = structuredClone(cur);
|
|
6194
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 ?? "";
|
|
6195
6380
|
this.shapes[i] = {
|
|
6196
6381
|
...cur,
|
|
6197
6382
|
condition_id,
|
|
6198
6383
|
measure_role: role,
|
|
6199
6384
|
verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
|
|
6200
6385
|
computed,
|
|
6386
|
+
...nextLabel ? { label: nextLabel } : {},
|
|
6201
6387
|
...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
|
|
6202
6388
|
...cur.origin ? { origin: {
|
|
6203
6389
|
...cur.origin,
|
|
@@ -6207,11 +6393,13 @@ var Session = class _Session {
|
|
|
6207
6393
|
...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
|
|
6208
6394
|
} } : {}
|
|
6209
6395
|
};
|
|
6396
|
+
if (!nextLabel) delete this.shapes[i].label;
|
|
6210
6397
|
this.record({ op: "edit", tool: "edit_shape", before });
|
|
6211
6398
|
const changed = [
|
|
6212
6399
|
...patch.verts !== void 0 ? ["verts"] : [],
|
|
6213
6400
|
...patch.condition !== void 0 ? ["condition"] : [],
|
|
6214
|
-
...patch.role !== void 0 ? ["role"] : []
|
|
6401
|
+
...patch.role !== void 0 ? ["role"] : [],
|
|
6402
|
+
...patch.label !== void 0 ? ["label"] : []
|
|
6215
6403
|
];
|
|
6216
6404
|
return {
|
|
6217
6405
|
shape_id: id,
|
|
@@ -6219,6 +6407,7 @@ var Session = class _Session {
|
|
|
6219
6407
|
measure_role: role,
|
|
6220
6408
|
nverts: vertsPx.length,
|
|
6221
6409
|
...computed,
|
|
6410
|
+
...nextLabel ? { label: nextLabel } : {},
|
|
6222
6411
|
agent_edits: this.shapes[i].origin?.agent_edits ?? 0
|
|
6223
6412
|
};
|
|
6224
6413
|
}
|
|
@@ -6249,7 +6438,19 @@ var Session = class _Session {
|
|
|
6249
6438
|
if (!Object.keys(patch[i].fields).length) throw new UserError(`patch[${i}]: fields must be non-empty.`);
|
|
6250
6439
|
}
|
|
6251
6440
|
const c = this.conditionFor(tag);
|
|
6252
|
-
const
|
|
6441
|
+
const cid = c.id;
|
|
6442
|
+
const snap = [cid, ...this.descendantConditionIds(cid)].map((id) => {
|
|
6443
|
+
const x = this.conditions.find((q) => q.id === id);
|
|
6444
|
+
return {
|
|
6445
|
+
condition_id: id,
|
|
6446
|
+
before: structuredClone(x.materials),
|
|
6447
|
+
...x.materials_dropped ? { dropped_before: [...x.materials_dropped] } : {}
|
|
6448
|
+
};
|
|
6449
|
+
});
|
|
6450
|
+
let conds = this.conditions;
|
|
6451
|
+
const isParent = () => conds.some((x) => x.variant_of === cid);
|
|
6452
|
+
const target = () => conds.find((x) => x.id === cid);
|
|
6453
|
+
const mint = (p) => uid(p);
|
|
6253
6454
|
const added = [];
|
|
6254
6455
|
for (const a of add) {
|
|
6255
6456
|
const row = {
|
|
@@ -6261,23 +6462,57 @@ var Session = class _Session {
|
|
|
6261
6462
|
round: a.round ?? true,
|
|
6262
6463
|
...a.note ? { note: a.note } : {}
|
|
6263
6464
|
};
|
|
6264
|
-
|
|
6465
|
+
conds = conds.map((x) => x.id !== cid ? x : { ...x, materials: [...x.materials || [], row] });
|
|
6265
6466
|
added.push(row.id);
|
|
6467
|
+
if (isParent()) conds = propagateRowAdd(conds, cid, row, mint);
|
|
6266
6468
|
}
|
|
6267
6469
|
const removed = new Set(remove);
|
|
6268
|
-
|
|
6470
|
+
for (const id of remove) {
|
|
6471
|
+
if (target().variant_of) {
|
|
6472
|
+
conds = conds.map((x) => x.id !== cid ? x : dropRowLocal(x, id));
|
|
6473
|
+
} else {
|
|
6474
|
+
conds = conds.map((x) => x.id !== cid ? x : { ...x, materials: (x.materials || []).filter((r) => r.id !== id) });
|
|
6475
|
+
conds = propagateRowRemove(conds, cid, id);
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6269
6478
|
const patched = [];
|
|
6270
6479
|
for (const p of patch) {
|
|
6271
|
-
|
|
6272
|
-
|
|
6480
|
+
conds = conds.map((x) => x.id !== cid ? x : {
|
|
6481
|
+
...x,
|
|
6482
|
+
materials: (x.materials || []).map((r) => {
|
|
6483
|
+
if (r.id !== p.id) return r;
|
|
6484
|
+
const merged = { ...r, ...p.fields, id: r.id };
|
|
6485
|
+
if (r.origin_id === void 0) delete merged.origin_id;
|
|
6486
|
+
else merged.origin_id = r.origin_id;
|
|
6487
|
+
if (r.inherited === void 0) delete merged.inherited;
|
|
6488
|
+
else merged.inherited = r.inherited;
|
|
6489
|
+
return merged;
|
|
6490
|
+
})
|
|
6491
|
+
});
|
|
6273
6492
|
patched.push(p.id);
|
|
6274
|
-
|
|
6275
|
-
|
|
6493
|
+
const cur = target();
|
|
6494
|
+
if (cur.variant_of) {
|
|
6495
|
+
conds = conds.map((x) => x.id !== cid ? x : markRowLocal(x, p.id));
|
|
6496
|
+
} else if (isParent()) {
|
|
6497
|
+
const row = (cur.materials || []).find((r) => r.id === p.id);
|
|
6498
|
+
if (row) conds = propagateRowPatch(conds, cid, p.id, row);
|
|
6499
|
+
}
|
|
6500
|
+
}
|
|
6501
|
+
this.conditions = conds;
|
|
6502
|
+
const [primary, ...familySnap] = snap;
|
|
6503
|
+
this.record({
|
|
6504
|
+
op: "materials",
|
|
6505
|
+
tool: "edit_materials",
|
|
6506
|
+
condition_id: cid,
|
|
6507
|
+
before: primary.before,
|
|
6508
|
+
...primary.dropped_before ? { dropped_before: primary.dropped_before } : {},
|
|
6509
|
+
...familySnap.length ? { family: familySnap } : {}
|
|
6510
|
+
});
|
|
6276
6511
|
return {
|
|
6277
6512
|
condition: tag,
|
|
6278
|
-
condition_id:
|
|
6513
|
+
condition_id: cid,
|
|
6279
6514
|
changed: { added, removed: [...removed], patched },
|
|
6280
|
-
materials:
|
|
6515
|
+
materials: target().materials
|
|
6281
6516
|
};
|
|
6282
6517
|
}
|
|
6283
6518
|
/** Set a condition's quantity knobs — waste % and multiplier. Both are
|
|
@@ -6300,6 +6535,16 @@ var Session = class _Session {
|
|
|
6300
6535
|
uppFor: (sheetId) => this.sheets.get(sheetId)?.upp ?? null
|
|
6301
6536
|
};
|
|
6302
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
|
+
}
|
|
6303
6548
|
editCondition(tag, opts) {
|
|
6304
6549
|
if (opts.waste_pct === void 0 && opts.multiplier === void 0 && opts.height_ft === void 0 && opts.roll_setup === void 0) {
|
|
6305
6550
|
throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier, height_ft, roll_setup.");
|
|
@@ -6347,6 +6592,117 @@ var Session = class _Session {
|
|
|
6347
6592
|
...roll ? { roll } : {}
|
|
6348
6593
|
};
|
|
6349
6594
|
}
|
|
6595
|
+
/**
|
|
6596
|
+
* Twin a condition — the same finish measured somewhere else, with its own materials.
|
|
6597
|
+
*
|
|
6598
|
+
* The same sheet goods over a slab and over a raised deck take the same field material and
|
|
6599
|
+
* different preparation underneath. The twin carries the original's whole materials list and
|
|
6600
|
+
* keeps FOLLOWING it: change a coverage rate on the original and every twin that hasn't
|
|
6601
|
+
* touched that row gets it; edit a row on the twin and only that row goes local. The rule
|
|
6602
|
+
* lives in web/src/lib/variants.ts — one copy, shared with the canvas, so a headless session
|
|
6603
|
+
* and the app can never disagree about what a twin holds.
|
|
6604
|
+
*
|
|
6605
|
+
* A twin needs its OWN tag, and that is not cosmetic here: every tool in this server resolves
|
|
6606
|
+
* a condition by finish tag and takes the FIRST match, so two conditions sharing one would
|
|
6607
|
+
* make the second permanently unreachable — and a takeoff re-import collapses them last-wins.
|
|
6608
|
+
* So the label is required and a collision is refused rather than de-collided.
|
|
6609
|
+
*/
|
|
6610
|
+
/** Every condition below `rootId` in the family tree, children first. The
|
|
6611
|
+
* propagate functions walk this same tree; undo snapshots ride it so a
|
|
6612
|
+
* family edit's inverse restores exactly the set the write could touch.
|
|
6613
|
+
* The `seen` guard mirrors variants.ts — a hand-edited payload with a
|
|
6614
|
+
* cycle must not hang the session. */
|
|
6615
|
+
descendantConditionIds(rootId) {
|
|
6616
|
+
const out = [];
|
|
6617
|
+
const seen = /* @__PURE__ */ new Set([rootId]);
|
|
6618
|
+
const walk = (pid) => {
|
|
6619
|
+
for (const child of this.conditions) {
|
|
6620
|
+
if (child.variant_of === pid && !seen.has(child.id)) {
|
|
6621
|
+
seen.add(child.id);
|
|
6622
|
+
out.push(child.id);
|
|
6623
|
+
walk(child.id);
|
|
6624
|
+
}
|
|
6625
|
+
}
|
|
6626
|
+
};
|
|
6627
|
+
walk(rootId);
|
|
6628
|
+
return out;
|
|
6629
|
+
}
|
|
6630
|
+
duplicateCondition(tag, label) {
|
|
6631
|
+
const src = this.conditions.find((x) => x.finish_tag === tag);
|
|
6632
|
+
if (!src) {
|
|
6633
|
+
const known = this.conditions.map((x) => x.finish_tag);
|
|
6634
|
+
throw new UserError(`No condition ${JSON.stringify(tag)} to duplicate.${known.length ? ` Known tags: ${known.join(", ")}.` : ""}`);
|
|
6635
|
+
}
|
|
6636
|
+
const lab = String(label || "").trim();
|
|
6637
|
+
if (!lab) throw new UserError("label is required \u2014 it is what gives the twin its own finish tag, and a tag is how every tool here resolves a condition.");
|
|
6638
|
+
const newTag = variantTag(src.finish_tag, lab);
|
|
6639
|
+
if (this.conditions.some((x) => x.finish_tag.trim().toUpperCase() === newTag.trim().toUpperCase())) {
|
|
6640
|
+
throw new UserError(`A condition is already called ${JSON.stringify(newTag)} \u2014 pick a different label. Two conditions sharing a tag would make one of them unreachable to every tool.`);
|
|
6641
|
+
}
|
|
6642
|
+
const { twin, parentPatch } = mintTwin(src, {
|
|
6643
|
+
label: lab,
|
|
6644
|
+
tag: newTag,
|
|
6645
|
+
mintId: (p) => uid(p),
|
|
6646
|
+
nowIso: nowIso2,
|
|
6647
|
+
nextHatch: HATCH_IDS[1 + (this.conditions.length + 1) % (HATCH_IDS.length - 1)]
|
|
6648
|
+
});
|
|
6649
|
+
if (parentPatch) Object.assign(src, parentPatch);
|
|
6650
|
+
this.conditions.push(twin);
|
|
6651
|
+
this.record({
|
|
6652
|
+
op: "duplicate_condition",
|
|
6653
|
+
tool: "duplicate_condition",
|
|
6654
|
+
condition_id: twin.id,
|
|
6655
|
+
parent_id: src.id,
|
|
6656
|
+
parent_had_family: !parentPatch
|
|
6657
|
+
});
|
|
6658
|
+
return {
|
|
6659
|
+
condition: newTag,
|
|
6660
|
+
condition_id: twin.id,
|
|
6661
|
+
variant_of: src.id,
|
|
6662
|
+
variant_label: lab,
|
|
6663
|
+
family_id: twin.family_id,
|
|
6664
|
+
inherited_rows: (twin.materials || []).length,
|
|
6665
|
+
note: `Materials follow ${src.finish_tag} until you edit them on this condition. No takeoffs came along \u2014 measure into ${newTag}.`
|
|
6666
|
+
};
|
|
6667
|
+
}
|
|
6668
|
+
/** Cut a twin loose: every following row freezes where it stands. It KEEPS its family_id, so
|
|
6669
|
+
* it still groups with its siblings — only the inheritance ends. */
|
|
6670
|
+
splitCondition(tag) {
|
|
6671
|
+
const c = this.conditions.find((x) => x.finish_tag === tag);
|
|
6672
|
+
if (!c) {
|
|
6673
|
+
const known = this.conditions.map((x) => x.finish_tag);
|
|
6674
|
+
throw new UserError(`No condition ${JSON.stringify(tag)}.${known.length ? ` Known tags: ${known.join(", ")}.` : ""}`);
|
|
6675
|
+
}
|
|
6676
|
+
const cond = c;
|
|
6677
|
+
if (!cond.variant_of) {
|
|
6678
|
+
return {
|
|
6679
|
+
condition: tag,
|
|
6680
|
+
condition_id: c.id,
|
|
6681
|
+
split: false,
|
|
6682
|
+
frozen_rows: 0,
|
|
6683
|
+
note: "Already owns its materials \u2014 nothing was following."
|
|
6684
|
+
};
|
|
6685
|
+
}
|
|
6686
|
+
const frozen = (cond.materials || []).filter((r) => r.inherited).length;
|
|
6687
|
+
const before = structuredClone({
|
|
6688
|
+
variant_of: cond.variant_of,
|
|
6689
|
+
materials: cond.materials,
|
|
6690
|
+
materials_dropped: cond.materials_dropped
|
|
6691
|
+
});
|
|
6692
|
+
const [next] = splitFromFamily([cond], cond.id);
|
|
6693
|
+
Object.assign(c, next);
|
|
6694
|
+
delete c.variant_of;
|
|
6695
|
+
delete c.materials_dropped;
|
|
6696
|
+
this.record({ op: "split_condition", tool: "split_condition", condition_id: c.id, before });
|
|
6697
|
+
return {
|
|
6698
|
+
condition: tag,
|
|
6699
|
+
condition_id: c.id,
|
|
6700
|
+
split: true,
|
|
6701
|
+
frozen_rows: frozen,
|
|
6702
|
+
family_id: cond.family_id,
|
|
6703
|
+
note: "Frozen at its current values; edits to the original no longer reach it. It still groups with its family."
|
|
6704
|
+
};
|
|
6705
|
+
}
|
|
6350
6706
|
/** Step back over this session's own last n mutations, newest first. Each
|
|
6351
6707
|
* entry's inverse is exact (see JournalEntry), so this restores state rather
|
|
6352
6708
|
* than approximating it. Reads are not journaled, so undo never has to step
|
|
@@ -6365,8 +6721,15 @@ var Session = class _Session {
|
|
|
6365
6721
|
if (i >= 0) this.shapes[i] = e.before;
|
|
6366
6722
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
|
|
6367
6723
|
} else if (e.op === "materials") {
|
|
6368
|
-
const
|
|
6369
|
-
|
|
6724
|
+
const put = (condition_id, before, dropped) => {
|
|
6725
|
+
const cc = this.conditions.find((x) => x.id === condition_id);
|
|
6726
|
+
if (!cc) return;
|
|
6727
|
+
cc.materials = before;
|
|
6728
|
+
if (dropped === void 0) delete cc.materials_dropped;
|
|
6729
|
+
else cc.materials_dropped = dropped;
|
|
6730
|
+
};
|
|
6731
|
+
put(e.condition_id, e.before, e.dropped_before);
|
|
6732
|
+
for (const f of e.family ?? []) put(f.condition_id, f.before, f.dropped_before);
|
|
6370
6733
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
6371
6734
|
} else if (e.op === "condition") {
|
|
6372
6735
|
const c = this.conditions.find((x) => x.id === e.condition_id);
|
|
@@ -6379,6 +6742,24 @@ var Session = class _Session {
|
|
|
6379
6742
|
else c.roll_setup = e.before.roll_setup;
|
|
6380
6743
|
}
|
|
6381
6744
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
6745
|
+
} else if (e.op === "duplicate_condition") {
|
|
6746
|
+
const at = this.conditions.findIndex((x) => x.id === e.condition_id);
|
|
6747
|
+
if (at >= 0) this.conditions.splice(at, 1);
|
|
6748
|
+
if (!e.parent_had_family) {
|
|
6749
|
+
const parent = this.conditions.find((x) => x.id === e.parent_id);
|
|
6750
|
+
if (parent) delete parent.family_id;
|
|
6751
|
+
}
|
|
6752
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
6753
|
+
} else if (e.op === "split_condition") {
|
|
6754
|
+
const c = this.conditions.find((x) => x.id === e.condition_id);
|
|
6755
|
+
if (c) {
|
|
6756
|
+
if (e.before.variant_of === void 0) delete c.variant_of;
|
|
6757
|
+
else c.variant_of = e.before.variant_of;
|
|
6758
|
+
c.materials = structuredClone(e.before.materials);
|
|
6759
|
+
if (e.before.materials_dropped === void 0) delete c.materials_dropped;
|
|
6760
|
+
else c.materials_dropped = structuredClone(e.before.materials_dropped);
|
|
6761
|
+
}
|
|
6762
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
6382
6763
|
} else if (e.op === "approval") {
|
|
6383
6764
|
this.approvals = applyApprovalCommand2(this.approvals, e.inverse).approvals;
|
|
6384
6765
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
@@ -6678,9 +7059,9 @@ var Session = class _Session {
|
|
|
6678
7059
|
* document carries the computed order quantities. */
|
|
6679
7060
|
exportReport(projectName = "") {
|
|
6680
7061
|
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
6681
|
-
const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
|
|
6682
7062
|
const { dimsFor, uppFor } = this.rollInputs();
|
|
6683
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);
|
|
6684
7065
|
return reportJson({
|
|
6685
7066
|
projectName,
|
|
6686
7067
|
rows,
|
|
@@ -7176,6 +7557,7 @@ var listShapesOutput = {
|
|
|
7176
7557
|
perimeter_lf: z.number().optional(),
|
|
7177
7558
|
count: z.number().optional(),
|
|
7178
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"),
|
|
7179
7561
|
nverts: z.number().int(),
|
|
7180
7562
|
reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
|
|
7181
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`),
|
|
@@ -7189,19 +7571,20 @@ var deleteShapeOutput = {
|
|
|
7189
7571
|
};
|
|
7190
7572
|
var editShapeOutput = {
|
|
7191
7573
|
shape_id: z.string(),
|
|
7192
|
-
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"),
|
|
7193
7575
|
measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
|
|
7194
7576
|
nverts: z.number().int(),
|
|
7195
7577
|
area_sf: z.number().optional().describe("0 for linear shapes; LF \xD7 height for surface_area; absent for count"),
|
|
7196
7578
|
perimeter_lf: z.number().optional().describe("Length for linear/surface runs, perimeter for closed ones; absent for count"),
|
|
7197
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)"),
|
|
7198
7581
|
agent_edits: z.number().int().describe("How many times the agent has revised this shape \u2014 separate from the human-correction tally")
|
|
7199
7582
|
};
|
|
7200
7583
|
var undoLastOutput = {
|
|
7201
7584
|
undone: z.number().int().describe("Steps actually reversed"),
|
|
7202
7585
|
steps: z.array(z.object({
|
|
7203
7586
|
seq: z.number().int(),
|
|
7204
|
-
op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval"]),
|
|
7587
|
+
op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition"]),
|
|
7205
7588
|
tool: z.string().describe("The tool call this step came from"),
|
|
7206
7589
|
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)")
|
|
7207
7590
|
})).describe("Newest first"),
|
|
@@ -7224,10 +7607,12 @@ var materialRow = z.object({
|
|
|
7224
7607
|
id: z.string(),
|
|
7225
7608
|
name: z.string(),
|
|
7226
7609
|
per: z.number().describe("Coverage rate: basis \xF7 per = order quantity"),
|
|
7227
|
-
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"),
|
|
7228
7611
|
unit: z.string(),
|
|
7229
7612
|
round: z.boolean().describe("true = round up to whole purchase units (the default \u2014 you buy whole bags/buckets)"),
|
|
7230
|
-
note: z.string().optional()
|
|
7613
|
+
note: z.string().optional(),
|
|
7614
|
+
origin_id: z.string().optional().describe("On a twin: the parent row this one follows (the variants.ts family link)"),
|
|
7615
|
+
inherited: z.boolean().optional().describe("On a twin: true while the row still follows the family \u2014 a patch on it takes it local, split_condition freezes them all")
|
|
7231
7616
|
});
|
|
7232
7617
|
var editMaterialsOutput = {
|
|
7233
7618
|
condition: z.string().describe("The finish tag passed in"),
|
|
@@ -7243,9 +7628,9 @@ var reportMaterialLine = z.object({
|
|
|
7243
7628
|
name: z.string(),
|
|
7244
7629
|
unit: z.string().describe("Purchase unit, e.g. 'gal', 'bag'"),
|
|
7245
7630
|
per: z.number().describe("Coverage rate \u2014 basis units per purchase unit"),
|
|
7246
|
-
basis: z.enum(["area", "linear", "count"]),
|
|
7631
|
+
basis: z.enum(["area", "linear", "count", "seam_lf"]),
|
|
7247
7632
|
round: z.boolean(),
|
|
7248
|
-
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)"),
|
|
7249
7634
|
qty: z.number().describe("Computed order quantity")
|
|
7250
7635
|
}).passthrough();
|
|
7251
7636
|
var exportReportOutput = {
|
|
@@ -7282,6 +7667,23 @@ var exportMarkedPdfOutput = {
|
|
|
7282
7667
|
approvals_drawn: z.number().int().describe("Approval-family glyphs burned in (#176) \u2014 estimator APPROVED rings + agent AGENT diamonds; the cover tallies the split when any exist"),
|
|
7283
7668
|
note: z.string()
|
|
7284
7669
|
};
|
|
7670
|
+
var duplicateConditionOutput = {
|
|
7671
|
+
condition: z.string().describe("The twin's finish tag \u2014 base tag + the label, e.g. 'CPT-1 \u2013 Level 2'"),
|
|
7672
|
+
condition_id: z.string().describe("The TWIN \u2014 measure the new area against this"),
|
|
7673
|
+
variant_of: z.string().describe("The condition whose material rows this one follows"),
|
|
7674
|
+
variant_label: z.string(),
|
|
7675
|
+
family_id: z.string().describe("Shared by every variant of this finish \u2014 survives a split"),
|
|
7676
|
+
inherited_rows: z.number().int().describe("Material rows copied, all still following the original"),
|
|
7677
|
+
note: z.string()
|
|
7678
|
+
};
|
|
7679
|
+
var splitConditionOutput = {
|
|
7680
|
+
condition: z.string(),
|
|
7681
|
+
condition_id: z.string(),
|
|
7682
|
+
split: z.boolean().describe("false = it already owned its materials; nothing was following"),
|
|
7683
|
+
frozen_rows: z.number().int().describe("Following rows frozen at their current values"),
|
|
7684
|
+
family_id: z.string().optional().describe("Kept \u2014 it still groups with its siblings"),
|
|
7685
|
+
note: z.string()
|
|
7686
|
+
};
|
|
7285
7687
|
var editConditionOutput = {
|
|
7286
7688
|
condition: z.string().describe("The finish tag passed in"),
|
|
7287
7689
|
condition_id: z.string(),
|
|
@@ -8789,6 +9191,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
8789
9191
|
const byTag = new Map(conditions.map((c) => [tagKey(c.finish_tag), c.id]));
|
|
8790
9192
|
const condIds = new Set(conditions.map((c) => c.id));
|
|
8791
9193
|
const condMap = /* @__PURE__ */ new Map();
|
|
9194
|
+
const addedCondIds = /* @__PURE__ */ new Set();
|
|
8792
9195
|
let condMerged = 0, condAdded = 0;
|
|
8793
9196
|
for (const c of impConds) {
|
|
8794
9197
|
const hit = byTag.get(tagKey(c.finish_tag));
|
|
@@ -8803,8 +9206,23 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
8803
9206
|
condIds.add(id);
|
|
8804
9207
|
byTag.set(tagKey(c.finish_tag), id);
|
|
8805
9208
|
condMap.set(c.id, id);
|
|
9209
|
+
addedCondIds.add(id);
|
|
8806
9210
|
condAdded++;
|
|
8807
9211
|
}
|
|
9212
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
9213
|
+
const c = conditions[i];
|
|
9214
|
+
if (!c?.variant_of || !addedCondIds.has(c.id)) continue;
|
|
9215
|
+
const mapped = condMap.get(c.variant_of);
|
|
9216
|
+
const parentArrived = !!mapped && addedCondIds.has(mapped);
|
|
9217
|
+
if (parentArrived && mapped !== c.variant_of) {
|
|
9218
|
+
conditions[i] = { ...c, variant_of: mapped };
|
|
9219
|
+
} else if (!parentArrived) {
|
|
9220
|
+
const next = { ...c, materials: (c.materials || []).map(({ origin_id: _o, inherited: _i, ...m }) => m) };
|
|
9221
|
+
delete next.variant_of;
|
|
9222
|
+
delete next.materials_dropped;
|
|
9223
|
+
conditions[i] = next;
|
|
9224
|
+
}
|
|
9225
|
+
}
|
|
8808
9226
|
const shapeIds = new Set(arr(cur.shapes).map((s) => s.id));
|
|
8809
9227
|
const addedShapes = impShapes.filter((s) => !shapeIds.has(s.id)).map((s) => condMap.has(s.condition_id) && condMap.get(s.condition_id) !== s.condition_id ? { ...s, condition_id: condMap.get(s.condition_id) } : s);
|
|
8810
9228
|
const markupIds = new Set(arr(cur.markups).map((m) => m?.id).filter(Boolean));
|
|
@@ -8945,7 +9363,7 @@ function registerTools(server, session) {
|
|
|
8945
9363
|
outputSchema: oneClickOutput
|
|
8946
9364
|
}, 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 })));
|
|
8947
9365
|
server.registerTool("detect_rooms", {
|
|
8948
|
-
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}`,
|
|
9366
|
+
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}`,
|
|
8949
9367
|
inputSchema: {
|
|
8950
9368
|
sheet: z2.string(),
|
|
8951
9369
|
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
@@ -9120,7 +9538,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9120
9538
|
outputSchema: exportMarkedPdfOutput
|
|
9121
9539
|
}, run("export_marked_pdf", (a) => exportMarkedPdf(session, a)));
|
|
9122
9540
|
server.registerTool("list_shapes", {
|
|
9123
|
-
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.`,
|
|
9541
|
+
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.`,
|
|
9124
9542
|
inputSchema: {
|
|
9125
9543
|
sheet: z2.string().optional().describe("Only shapes on this sheet"),
|
|
9126
9544
|
condition: z2.string().optional().describe("Only shapes under this finish tag (must exist)")
|
|
@@ -9143,23 +9561,24 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9143
9561
|
outputSchema: sheetContextOutput
|
|
9144
9562
|
}, run("sheet_context", (a) => session.sheetContext(a.sheet, { region: a.region, min_len_px: a.min_len_px, max_segments: a.max_segments })));
|
|
9145
9563
|
server.registerTool("edit_shape", {
|
|
9146
|
-
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}`,
|
|
9564
|
+
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}`,
|
|
9147
9565
|
inputSchema: {
|
|
9148
9566
|
shape_id: z2.string().describe("Id returned when the shape was committed"),
|
|
9149
9567
|
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"),
|
|
9150
9568
|
condition: z2.string().optional().describe("Reassign to this finish tag (minted on first use)"),
|
|
9151
|
-
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")
|
|
9569
|
+
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"),
|
|
9570
|
+
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')
|
|
9152
9571
|
},
|
|
9153
9572
|
outputSchema: editShapeOutput
|
|
9154
|
-
}, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
|
|
9573
|
+
}, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role, label: a.label })));
|
|
9155
9574
|
server.registerTool("edit_materials", {
|
|
9156
|
-
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).`,
|
|
9575
|
+
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).`,
|
|
9157
9576
|
inputSchema: {
|
|
9158
9577
|
condition: z2.string().describe("Finish tag, e.g. 'CPT-1'"),
|
|
9159
9578
|
add: z2.array(z2.object({
|
|
9160
9579
|
name: z2.string().min(1),
|
|
9161
9580
|
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)"),
|
|
9162
|
-
basis: z2.enum(["area", "linear", "count"]).optional().describe("Which of the condition's totals this row divides against \u2014 default 'area' (total SF)"),
|
|
9581
|
+
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"),
|
|
9163
9582
|
unit: z2.string().optional().describe("Purchase unit, e.g. 'gal', 'bag', 'roll'"),
|
|
9164
9583
|
round: z2.boolean().optional().describe("Round up to whole purchase units \u2014 default true"),
|
|
9165
9584
|
note: z2.string().optional()
|
|
@@ -9195,6 +9614,21 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
9195
9614
|
},
|
|
9196
9615
|
outputSchema: editConditionOutput
|
|
9197
9616
|
}, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup })));
|
|
9617
|
+
server.registerTool("duplicate_condition", {
|
|
9618
|
+
description: `Twin a condition \u2014 the same finish measured somewhere else, with its own supporting materials. One finish in two areas is not two conditions and it is not one either: the same sheet goods over a slab and over a raised deck take the same field material and different preparation underneath (one wants a moisture barrier, the other a primer and a different adhesive). The twin arrives carrying the original's whole materials list and keeps FOLLOWING it \u2014 change a coverage rate on the original and every twin that has not 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' \u2192 'CPT-1 \u2013 Level 2'), because every tool in this server resolves a condition by finish tag and takes the FIRST match: two conditions sharing a tag would make one permanently unreachable, and a takeoff re-import collapses them last-wins. A label already in use is refused rather than de-collided. No takeoffs come along \u2014 measure the new area against the returned condition_id. Reversible with undo_last; use split_condition to end the inheritance permanently.`,
|
|
9619
|
+
inputSchema: {
|
|
9620
|
+
condition: z2.string().describe("Finish tag of the condition to twin, e.g. 'CPT-1'"),
|
|
9621
|
+
label: z2.string().describe("What makes this one different, usually the area: 'Level 2', 'Building B', 'Phase 2'")
|
|
9622
|
+
},
|
|
9623
|
+
outputSchema: duplicateConditionOutput
|
|
9624
|
+
}, run("duplicate_condition", (a) => session.duplicateCondition(a.condition, a.label)));
|
|
9625
|
+
server.registerTool("split_condition", {
|
|
9626
|
+
description: `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 \u2014 only the inheritance ends. Use when two variants have diverged far enough that following one another is wrong. A condition that already owns its materials returns split:false rather than erroring. Reversible with undo_last.`,
|
|
9627
|
+
inputSchema: {
|
|
9628
|
+
condition: z2.string().describe("Finish tag of the twin to split, e.g. 'CPT-1 \u2013 Level 2'")
|
|
9629
|
+
},
|
|
9630
|
+
outputSchema: splitConditionOutput
|
|
9631
|
+
}, run("split_condition", (a) => session.splitCondition(a.condition)));
|
|
9198
9632
|
server.registerTool("undo_last", {
|
|
9199
9633
|
description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, an edit_materials call, or an edit_condition call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
|
|
9200
9634
|
inputSchema: {
|
|
@@ -9396,7 +9830,7 @@ function registerResources(server, session) {
|
|
|
9396
9830
|
// package.json
|
|
9397
9831
|
var package_default = {
|
|
9398
9832
|
name: "opentakeoff-mcp",
|
|
9399
|
-
version: "0.9.
|
|
9833
|
+
version: "0.9.33",
|
|
9400
9834
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
9401
9835
|
type: "module",
|
|
9402
9836
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -9412,7 +9846,7 @@ var package_default = {
|
|
|
9412
9846
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
9413
9847
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
9414
9848
|
typecheck: "tsc --noEmit",
|
|
9415
|
-
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/view.test.ts"
|
|
9849
|
+
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/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
9416
9850
|
},
|
|
9417
9851
|
dependencies: {
|
|
9418
9852
|
"@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.33",
|
|
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/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/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",
|