opentakeoff-mcp 0.9.18 → 0.9.19
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 +121 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
109
109
|
| `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
|
|
110
110
|
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
|
|
111
111
|
| `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
|
|
112
|
-
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible), never dropped silently.
|
|
112
|
+
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
114
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
115
|
| `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
|
|
@@ -123,8 +123,8 @@ includes document text, shape vertices, or result payload content.
|
|
|
123
123
|
| `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. |
|
|
124
124
|
| `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`. |
|
|
125
125
|
| `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. |
|
|
126
|
-
| `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself. Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
|
|
127
|
-
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities,
|
|
126
|
+
| `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`. |
|
|
127
|
+
| `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. |
|
|
128
128
|
| `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. |
|
|
129
129
|
| `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). 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. |
|
|
130
130
|
| `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. |
|
package/dist/server-core.js
CHANGED
|
@@ -2292,6 +2292,12 @@ var Session = class _Session {
|
|
|
2292
2292
|
conditions = [];
|
|
2293
2293
|
markups = [];
|
|
2294
2294
|
shapes = [];
|
|
2295
|
+
/** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
|
|
2296
|
+
* marked-set cover discloses as withheld. Replaced per assign run, cleared
|
|
2297
|
+
* with the rest of the session on a non-merge load_plan. Seeds ride
|
|
2298
|
+
* normalized so the export-time staleness drop can test them against
|
|
2299
|
+
* committed rings in verts_norm space. */
|
|
2300
|
+
scheduleWithheld = [];
|
|
2295
2301
|
/** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
|
|
2296
2302
|
journal = [];
|
|
2297
2303
|
seq = 0;
|
|
@@ -2331,6 +2337,7 @@ var Session = class _Session {
|
|
|
2331
2337
|
this.file = null;
|
|
2332
2338
|
this.filePath = null;
|
|
2333
2339
|
this.nextOrd = 1;
|
|
2340
|
+
this.scheduleWithheld = [];
|
|
2334
2341
|
this.journal = [];
|
|
2335
2342
|
this.pendingCommits = [];
|
|
2336
2343
|
} else if (this.docs.has(base)) {
|
|
@@ -2734,6 +2741,7 @@ var Session = class _Session {
|
|
|
2734
2741
|
return c;
|
|
2735
2742
|
}
|
|
2736
2743
|
commit(s, tag, role, vertsPx, computed, origin) {
|
|
2744
|
+
if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
|
|
2737
2745
|
const c = this.conditionFor(tag);
|
|
2738
2746
|
const shape = {
|
|
2739
2747
|
id: uid("shp"),
|
|
@@ -2830,6 +2838,16 @@ var Session = class _Session {
|
|
|
2830
2838
|
* judge and nothing commits anyway. */
|
|
2831
2839
|
async detectRooms(name, opts) {
|
|
2832
2840
|
const s = this.sheet(name);
|
|
2841
|
+
const assign = !!opts.assignFromSchedule;
|
|
2842
|
+
let graph = null;
|
|
2843
|
+
if (assign) {
|
|
2844
|
+
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
2845
|
+
graph = await this.ensureGraph();
|
|
2846
|
+
if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
|
|
2847
|
+
if (!graph.tables.some((t) => t.kind === "room-finish")) {
|
|
2848
|
+
throw new UserError("No room-finish schedule in the working set \u2014 load_plan the schedule sheet with merge: true, or pass condition to commit every room under one tag.");
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2833
2851
|
const mask = await this.maskWithLayers(name, opts.layers);
|
|
2834
2852
|
if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
|
|
2835
2853
|
const minAreaSf = opts.minAreaSf ?? 5;
|
|
@@ -2839,7 +2857,8 @@ var Session = class _Session {
|
|
|
2839
2857
|
const num2 = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
|
|
2840
2858
|
if (num2) labels.push({ str: num2, bbox: sp });
|
|
2841
2859
|
}
|
|
2842
|
-
const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0 };
|
|
2860
|
+
const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0, unresolved: 0 };
|
|
2861
|
+
const unresolved = [];
|
|
2843
2862
|
const byRing = /* @__PURE__ */ new Map();
|
|
2844
2863
|
const order = [];
|
|
2845
2864
|
for (const lb of labels) {
|
|
@@ -2907,21 +2926,42 @@ var Session = class _Session {
|
|
|
2907
2926
|
return null;
|
|
2908
2927
|
}
|
|
2909
2928
|
const perimeter_lf = round2(c.perimPx * upp);
|
|
2929
|
+
let tag = opts.condition;
|
|
2930
|
+
let assignment;
|
|
2931
|
+
if (assign) {
|
|
2932
|
+
const hit = this.floorTagFor(graph, c.label);
|
|
2933
|
+
if ("reason" in hit) {
|
|
2934
|
+
withheld.unresolved++;
|
|
2935
|
+
unresolved.push({ label: c.label, reason: hit.reason, area_sf, perimeter_lf, seed: [round1(c.seed[0]), round1(c.seed[1])] });
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2938
|
+
tag = hit.tag;
|
|
2939
|
+
assignment = { source: "schedule", room_tag: c.label, surface: "FLOOR", schedule_sheet: hit.sheet };
|
|
2940
|
+
}
|
|
2910
2941
|
let shape_id;
|
|
2911
|
-
if (
|
|
2912
|
-
shape_id = this.commit(s,
|
|
2942
|
+
if (tag) {
|
|
2943
|
+
shape_id = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
|
|
2913
2944
|
method: "one_click_v1",
|
|
2914
2945
|
actor: "agent",
|
|
2915
2946
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
2916
2947
|
reviewed: false,
|
|
2917
2948
|
...c.hatch ? { hatch_filtered: true } : {},
|
|
2918
|
-
...c.gap ? { gap_bridged_px: c.gap } : {}
|
|
2949
|
+
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
2950
|
+
...assignment ? { assignment } : {}
|
|
2919
2951
|
}).id;
|
|
2920
2952
|
}
|
|
2921
|
-
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
2953
|
+
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
|
|
2922
2954
|
}).filter((r) => r !== null);
|
|
2923
2955
|
this.flushCommits("detect_rooms");
|
|
2924
|
-
|
|
2956
|
+
if (assign) {
|
|
2957
|
+
this.scheduleWithheld = unresolved.map((u) => ({
|
|
2958
|
+
sheet_id: s.key,
|
|
2959
|
+
label: u.label,
|
|
2960
|
+
reason: u.reason,
|
|
2961
|
+
seed_norm: [u.seed[0] / s.widthPx, u.seed[1] / s.heightPx]
|
|
2962
|
+
}));
|
|
2963
|
+
}
|
|
2964
|
+
const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible + withheld.unresolved;
|
|
2925
2965
|
return {
|
|
2926
2966
|
detected: rooms.length,
|
|
2927
2967
|
rooms,
|
|
@@ -2930,8 +2970,11 @@ var Session = class _Session {
|
|
|
2930
2970
|
...withheld,
|
|
2931
2971
|
...upp != null ? { min_area_sf: minAreaSf } : {}
|
|
2932
2972
|
},
|
|
2973
|
+
// assign mode always states the answer, empty array included: [] is the
|
|
2974
|
+
// positive claim "every detected room resolved against its own row"
|
|
2975
|
+
...assign ? { unresolved } : {},
|
|
2933
2976
|
...s.detected?.multi ? { multiple_scales: true } : {},
|
|
2934
|
-
...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable.` } : {},
|
|
2977
|
+
...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
|
|
2935
2978
|
...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
|
|
2936
2979
|
};
|
|
2937
2980
|
}
|
|
@@ -3085,6 +3128,7 @@ var Session = class _Session {
|
|
|
3085
3128
|
...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
|
|
3086
3129
|
nverts: x.verts_norm.length,
|
|
3087
3130
|
reviewed: x.origin?.reviewed === true,
|
|
3131
|
+
...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
|
|
3088
3132
|
...x.origin?.agent_edits ? { agent_edits: x.origin.agent_edits } : {}
|
|
3089
3133
|
})),
|
|
3090
3134
|
count: rows.length
|
|
@@ -3164,7 +3208,13 @@ var Session = class _Session {
|
|
|
3164
3208
|
verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
|
|
3165
3209
|
computed,
|
|
3166
3210
|
...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
|
|
3167
|
-
...cur.origin ? { origin: {
|
|
3211
|
+
...cur.origin ? { origin: {
|
|
3212
|
+
...cur.origin,
|
|
3213
|
+
agent_edits: (cur.origin.agent_edits ?? 0) + 1,
|
|
3214
|
+
// a reassign onto a different tag is the agent choosing the finish —
|
|
3215
|
+
// keeping "schedule" (and its citation) past that point would be a lie
|
|
3216
|
+
...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
|
|
3217
|
+
} } : {}
|
|
3168
3218
|
};
|
|
3169
3219
|
this.record({ op: "edit", tool: "edit_shape", before });
|
|
3170
3220
|
const changed = [
|
|
@@ -3534,6 +3584,24 @@ var Session = class _Session {
|
|
|
3534
3584
|
counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
3535
3585
|
};
|
|
3536
3586
|
}
|
|
3587
|
+
/** The FLOOR finish a room's own schedule row states — assign-from-schedule's
|
|
3588
|
+
* per-room resolver (0.9.18): resolveTag's chain narrowed to the one surface
|
|
3589
|
+
* a floor takeoff commits. Refusal over guessing, per room: an unresolved
|
|
3590
|
+
* tag returns resolveTag's own reason; a resolved row with no FLOOR cell
|
|
3591
|
+
* says so; a compound cell ("CPT-1/VCT-1") is ambiguous — committing the
|
|
3592
|
+
* whole room's SF under a two-finish literal would assert an area split the
|
|
3593
|
+
* schedule never stated. Compound detection is narrow ("/", ",", " OR "),
|
|
3594
|
+
* so a hyphenated code never trips it. BASE/WALL are deliberately ignored:
|
|
3595
|
+
* those are derive_base's and measure_surface's measures. */
|
|
3596
|
+
floorTagFor(g, tag) {
|
|
3597
|
+
const res = resolveTag(g, tag);
|
|
3598
|
+
if (res.status !== "resolved") return { reason: res.reason };
|
|
3599
|
+
const floor = res.finishes.find((f) => f.surface === "FLOOR");
|
|
3600
|
+
const code = floor?.code.trim();
|
|
3601
|
+
if (!floor || !code) return { reason: `schedule row ${res.tag} states no FLOOR finish` };
|
|
3602
|
+
if (/[/,]|\bOR\b/i.test(code)) return { reason: `ambiguous: floor cell "${code}" names more than one finish with no stated split` };
|
|
3603
|
+
return { tag: code, sheet: floor.source.sheet };
|
|
3604
|
+
}
|
|
3537
3605
|
async resolveRoomTag(tag) {
|
|
3538
3606
|
if (!tag || !tag.trim()) throw new UserError('Pass a room tag, e.g. resolve_tag { tag: "134" }.');
|
|
3539
3607
|
const g = await this.ensureGraph();
|
|
@@ -3695,6 +3763,7 @@ var detectedRoom = z.object({
|
|
|
3695
3763
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
3696
3764
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
3697
3765
|
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
3766
|
+
condition: z.string().optional().describe("The finish tag this room committed under \u2014 the passed condition, or in assign mode the FLOOR finish its own schedule row states. Present exactly when shape_id is"),
|
|
3698
3767
|
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
3699
3768
|
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
|
|
3700
3769
|
});
|
|
@@ -3707,8 +3776,16 @@ var detectRoomsOutput = {
|
|
|
3707
3776
|
duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
|
|
3708
3777
|
bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
|
|
3709
3778
|
implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
|
|
3779
|
+
unresolved: z.number().int().describe("Assign mode: rooms the schedule could not answer for (no row, no FLOOR cell, or a compound cell) \u2014 withheld into unresolved[], never committed under a guess. Always present; 0 outside assign mode"),
|
|
3710
3780
|
min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
|
|
3711
3781
|
}).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
|
|
3782
|
+
unresolved: z.array(z.object({
|
|
3783
|
+
label: z.string().describe("The room tag as drawn"),
|
|
3784
|
+
reason: z.string().describe(`WHY the schedule could not answer \u2014 resolveTag's own reason, "states no FLOOR finish", or "ambiguous: \u2026" for a compound cell`),
|
|
3785
|
+
area_sf: z.number().describe("The room's real traced area \u2014 withheld from committing, not from reporting"),
|
|
3786
|
+
perimeter_lf: z.number(),
|
|
3787
|
+
seed: z.tuple([z.number(), z.number()]).describe("The flood seed (image px) \u2014 once the estimator answers, one_click here with the stated condition commits it")
|
|
3788
|
+
})).optional().describe("Assign mode only, empty array included: [] is the positive claim that every detected room resolved against its own schedule row"),
|
|
3712
3789
|
note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
|
|
3713
3790
|
multiple_scales: z.literal(true).optional().describe("Several DISTINCT scale notes on this sheet (#153) \u2014 rooms inside an enlarged viewport may be figured at the wrong scale"),
|
|
3714
3791
|
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
@@ -3791,7 +3868,7 @@ var exportTakeoffOutput = {
|
|
|
3791
3868
|
measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
|
|
3792
3869
|
verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
|
|
3793
3870
|
computed: z.object({ area_sf: z.number().optional(), perimeter_lf: z.number().optional(), count: z.number().optional() }).passthrough().describe("count shapes carry {count} alone; every other role carries area_sf + perimeter_lf"),
|
|
3794
|
-
origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
|
|
3871
|
+
origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), assignment (where the finish tag came from \u2014 {source: 'schedule', room_tag, surface, schedule_sheet} when the room's own schedule row decided it, {source: 'asserted'} when the agent chose; stamped on every agent commit), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
|
|
3795
3872
|
}).passthrough()),
|
|
3796
3873
|
markups: z.array(z.unknown()),
|
|
3797
3874
|
sheet_group: z.array(z.unknown()),
|
|
@@ -3838,6 +3915,7 @@ var listShapesOutput = {
|
|
|
3838
3915
|
height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
|
|
3839
3916
|
nverts: z.number().int(),
|
|
3840
3917
|
reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
|
|
3918
|
+
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`),
|
|
3841
3919
|
agent_edits: z.number().int().optional().describe("Present when the agent has revised this shape")
|
|
3842
3920
|
})),
|
|
3843
3921
|
count: z.number().int()
|
|
@@ -4735,7 +4813,7 @@ function invertPixels(cv) {
|
|
|
4735
4813
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
4736
4814
|
ctx.restore();
|
|
4737
4815
|
}
|
|
4738
|
-
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, coverTitle = "Marked Set", units = "imperial" }) {
|
|
4816
|
+
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
|
|
4739
4817
|
const M = units === "metric";
|
|
4740
4818
|
const uA = (sf) => M ? sf * 0.09290304 : sf;
|
|
4741
4819
|
const uL = (lf) => M ? lf * 0.3048 : lf;
|
|
@@ -4825,6 +4903,10 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4825
4903
|
}
|
|
4826
4904
|
}
|
|
4827
4905
|
draw(`${marked.length} marked sheet${marked.length === 1 ? "" : "s"} \xB7 ${markedShapes.length} takeoff item${markedShapes.length === 1 ? "" : "s"} \xB7 quantities net of deducts, waste-adjusted where noted`, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
4906
|
+
if (provenance) {
|
|
4907
|
+
metaY -= 12;
|
|
4908
|
+
draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
4909
|
+
}
|
|
4828
4910
|
let y = metaY - 34;
|
|
4829
4911
|
const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
|
|
4830
4912
|
draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
|
|
@@ -5146,6 +5228,20 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5146
5228
|
}
|
|
5147
5229
|
|
|
5148
5230
|
// src/marked.ts
|
|
5231
|
+
function assignmentDisclosure(shapes, withheld = []) {
|
|
5232
|
+
const agent = shapes.filter((s) => s.origin?.actor === "agent");
|
|
5233
|
+
const live = withheld.filter((w) => !shapes.some((s) => s.sheet_id === w.sheet_id && (s.measure_role === "floor_area" || s.measure_role === "deduct") && s.verts_norm.length >= 3 && pointInPoly(w.seed_norm[0], w.seed_norm[1], s.verts_norm)));
|
|
5234
|
+
if (!agent.length && !live.length) return null;
|
|
5235
|
+
const schedule = agent.filter((s) => s.origin?.assignment?.source === "schedule").length;
|
|
5236
|
+
const asserted = agent.filter((s) => s.origin?.assignment?.source === "asserted").length;
|
|
5237
|
+
const pending = agent.filter((s) => s.origin?.reviewed !== true).length;
|
|
5238
|
+
const parts = [];
|
|
5239
|
+
if (schedule) parts.push(`${schedule} schedule-resolved`);
|
|
5240
|
+
if (asserted) parts.push(`${asserted} agent-asserted`);
|
|
5241
|
+
if (pending) parts.push(`${pending} pending human review`);
|
|
5242
|
+
if (live.length) parts.push(`${live.length} room${live.length === 1 ? "" : "s"} withheld, unresolved against the schedule`);
|
|
5243
|
+
return parts.length ? `Finish assignment: ${parts.join(" \xB7 ")}` : null;
|
|
5244
|
+
}
|
|
5149
5245
|
var buildMarkedSetPdf2 = buildMarkedSetPdf;
|
|
5150
5246
|
async function exportMarkedPdf(session, opts) {
|
|
5151
5247
|
const { file, filePath } = session;
|
|
@@ -5199,6 +5295,7 @@ async function exportMarkedPdf(session, opts) {
|
|
|
5199
5295
|
getPage,
|
|
5200
5296
|
loadPdfData,
|
|
5201
5297
|
credit,
|
|
5298
|
+
provenance: assignmentDisclosure(session.shapes, session.scheduleWithheld),
|
|
5202
5299
|
coverTitle: "OpenTakeoff \xB7 Marked Set"
|
|
5203
5300
|
});
|
|
5204
5301
|
const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
|
|
@@ -5421,7 +5518,7 @@ function registerTools(server, session) {
|
|
|
5421
5518
|
return session.setScale(a.sheet, a);
|
|
5422
5519
|
}));
|
|
5423
5520
|
server.registerTool("one_click", {
|
|
5424
|
-
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. ${COORDS}`,
|
|
5521
|
+
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
|
|
5425
5522
|
inputSchema: {
|
|
5426
5523
|
sheet: z2.string(),
|
|
5427
5524
|
x: z2.number(),
|
|
@@ -5435,10 +5532,11 @@ function registerTools(server, session) {
|
|
|
5435
5532
|
outputSchema: oneClickOutput
|
|
5436
5533
|
}, 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 })));
|
|
5437
5534
|
server.registerTool("detect_rooms", {
|
|
5438
|
-
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. 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. With the sheet's scale set, returns area_sf/perimeter_lf per room
|
|
5535
|
+
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. 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. 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}`,
|
|
5439
5536
|
inputSchema: {
|
|
5440
5537
|
sheet: z2.string(),
|
|
5441
|
-
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
|
|
5538
|
+
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
5539
|
+
assign_from_schedule: z2.boolean().default(false).describe(`Commit each room under the FLOOR finish its OWN room-finish schedule row states (resolve_tag's chain, per room): the citation rides origin.assignment, and rooms the schedule cannot answer for \u2014 no row, no FLOOR cell, a compound cell like "CPT-1/VCT-1" \u2014 are returned in unresolved[] with reasons and seeds instead of committed under a guess. Needs the sheet's scale and a room-finish schedule in the working set (merge the schedule sheet in with load_plan first). Mutually exclusive with condition`),
|
|
5442
5540
|
role: roleSchema,
|
|
5443
5541
|
return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
|
|
5444
5542
|
min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
|
|
@@ -5446,7 +5544,12 @@ function registerTools(server, session) {
|
|
|
5446
5544
|
layers: layersFilterSchema
|
|
5447
5545
|
},
|
|
5448
5546
|
outputSchema: detectRoomsOutput
|
|
5449
|
-
}, run("detect_rooms", (a) =>
|
|
5547
|
+
}, run("detect_rooms", (a) => {
|
|
5548
|
+
if (a.assign_from_schedule && a.condition !== void 0) {
|
|
5549
|
+
throw new UserError("Provide at most one of: condition (every room under one stated tag) or assign_from_schedule (each room's own schedule row decides).");
|
|
5550
|
+
}
|
|
5551
|
+
return session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers, assignFromSchedule: a.assign_from_schedule });
|
|
5552
|
+
}));
|
|
5450
5553
|
server.registerTool("measure_polygon", {
|
|
5451
5554
|
description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
|
|
5452
5555
|
inputSchema: {
|
|
@@ -5661,7 +5764,7 @@ function registerTools(server, session) {
|
|
|
5661
5764
|
outputSchema: findTextOutput
|
|
5662
5765
|
}, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
|
|
5663
5766
|
server.registerTool("view_sheet", {
|
|
5664
|
-
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet
|
|
5767
|
+
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
|
|
5665
5768
|
inputSchema: {
|
|
5666
5769
|
sheet: z2.string(),
|
|
5667
5770
|
region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
|
|
@@ -5798,7 +5901,7 @@ function registerResources(server, session) {
|
|
|
5798
5901
|
// package.json
|
|
5799
5902
|
var package_default = {
|
|
5800
5903
|
name: "opentakeoff-mcp",
|
|
5801
|
-
version: "0.9.
|
|
5904
|
+
version: "0.9.19",
|
|
5802
5905
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
5803
5906
|
type: "module",
|
|
5804
5907
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -5875,8 +5978,8 @@ function buildServer(session = new Session()) {
|
|
|
5875
5978
|
"OpenTakeoff: quantity takeoff on construction plan PDFs.",
|
|
5876
5979
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
5877
5980
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
5878
|
-
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition
|
|
5879
|
-
"3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals.",
|
|
5981
|
+
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
|
|
5982
|
+
"3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
5880
5983
|
"4. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone."
|
|
5881
5984
|
].join("\n")
|
|
5882
5985
|
});
|
package/package.json
CHANGED