opentakeoff-mcp 0.9.12 → 0.9.18
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 +10 -2
- package/dist/server-core.js +461 -71
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -105,13 +105,14 @@ includes document text, shape vertices, or result payload content.
|
|
|
105
105
|
|
|
106
106
|
| Tool | What it does |
|
|
107
107
|
|---|---|
|
|
108
|
-
| `load_plan` | Open a plan PDF from disk.
|
|
108
|
+
| `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152) — plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
|
|
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
112
|
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible), never dropped silently. Pass `condition` to commit every detected room. |
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
114
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
|
+
| `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. |
|
|
115
116
|
| `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob — pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
|
|
116
117
|
| `place_count` | **EA markers**: one point, one each — thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
|
|
117
118
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
@@ -121,11 +122,18 @@ includes document text, shape vertices, or result payload content.
|
|
|
121
122
|
| `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`. |
|
|
122
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. |
|
|
123
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
|
+
| `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. |
|
|
124
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`. |
|
|
125
127
|
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, and review state in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
|
|
126
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
|
+
| `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
|
+
| `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. |
|
|
131
|
+
| `link_annotation` | Attach an existing annotation to a condition (or detach with an empty tag) — the canvas's Attach/Detach control, reachable by an agent. |
|
|
127
132
|
| `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
|
|
128
133
|
| `find_text` | **Locate** a known string — the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
|
|
134
|
+
| `sheet_graph` | The plan-set INDEX (#87): every sheet's role with evidence, the schedule tables found, every room tag with its stacked name, the detail callouts — how an agent decides WHAT to measure without a human enumerating rooms. |
|
|
135
|
+
| `resolve_tag` | ONE room tag → its room-finish schedule row → each code's finish/material definition, every edge cited (sheet + literal text + bbox). Refusal over guessing: `unresolved` comes back with a reason, never as silence. |
|
|
136
|
+
| `find_schedule` | Locate a schedule table by kind ("room finish", "material") — sheet, title, headers, row count, and a `view_sheet`-ready region. |
|
|
129
137
|
| `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids — same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
|
|
130
138
|
| `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed. |
|
|
131
139
|
|
|
@@ -238,7 +246,7 @@ sheet number (`A-101`) wherever a sheet is named.
|
|
|
238
246
|
- **Vector + text sheets only.** A scanned sheet has no vector linework, so
|
|
239
247
|
`one_click` reports it plainly; a raster fallback is a planned seam
|
|
240
248
|
(`src/session.ts`, `ensureMask`), not yet built.
|
|
241
|
-
-
|
|
249
|
+
- `load_plan` replaces the session by default; `merge: true` builds a multi-document working set (#152). Reloading a merged file is refused — reload = replace, deliberately.
|
|
242
250
|
- The takeoff lives in memory. `export_takeoff` (the app's exact save payload,
|
|
243
251
|
nothing lost in translation) and `export_marked_pdf` (the reviewable marked
|
|
244
252
|
planset) are the ways out.
|
package/dist/server-core.js
CHANGED
|
@@ -236,6 +236,15 @@ function positionedText(ph) {
|
|
|
236
236
|
}
|
|
237
237
|
return out;
|
|
238
238
|
}
|
|
239
|
+
function textItemsInRegion(ph, r) {
|
|
240
|
+
const items = [];
|
|
241
|
+
for (const it of ph.textContent.items || []) {
|
|
242
|
+
if (!(it.str || "").trim()) continue;
|
|
243
|
+
const t = pdfjs.Util.transform(ph.viewport.transform, it.transform);
|
|
244
|
+
if (t[4] >= r.x0 && t[4] <= r.x1 && t[5] >= r.y0 && t[5] <= r.y1) items.push(it);
|
|
245
|
+
}
|
|
246
|
+
return { items };
|
|
247
|
+
}
|
|
239
248
|
function textSpans(ph) {
|
|
240
249
|
const out = [];
|
|
241
250
|
for (const it of ph.textContent.items || []) {
|
|
@@ -250,6 +259,27 @@ function textSpans(ph) {
|
|
|
250
259
|
return out;
|
|
251
260
|
}
|
|
252
261
|
|
|
262
|
+
// src/scalewarn.ts
|
|
263
|
+
var SCALE_NOTE_MARGIN_XY = 0.1;
|
|
264
|
+
var SCALE_NOTE_MARGIN_BELOW = 0.35;
|
|
265
|
+
var UPP_TOLERANCE = 0.05;
|
|
266
|
+
function expandForScaleNotes(b) {
|
|
267
|
+
const w = b.x1 - b.x0, h = b.y1 - b.y0;
|
|
268
|
+
return {
|
|
269
|
+
x0: b.x0 - w * SCALE_NOTE_MARGIN_XY,
|
|
270
|
+
y0: b.y0 - h * SCALE_NOTE_MARGIN_XY,
|
|
271
|
+
x1: b.x1 + w * SCALE_NOTE_MARGIN_XY,
|
|
272
|
+
y1: b.y1 + h * SCALE_NOTE_MARGIN_BELOW
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function mixedScaleWarning(regionText, viewport, adoptedUpp, adoptedLabel) {
|
|
276
|
+
if (adoptedUpp == null) return void 0;
|
|
277
|
+
const hit = detectScale(regionText, viewport);
|
|
278
|
+
if (!hit) return void 0;
|
|
279
|
+
if (Math.abs(hit.upp - adoptedUpp) / adoptedUpp <= UPP_TOLERANCE) return void 0;
|
|
280
|
+
return `Scale note ${JSON.stringify(hit.label)} sits in or just below this region, but quantities were figured at ${adoptedLabel ? JSON.stringify(adoptedLabel) : `upp ${adoptedUpp}`} \u2014 if this is an enlarged plan or detail viewport, these numbers are wrong. Verify with view_sheet grid, or set_scale/calibrate for the right value before trusting them.`;
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
// ../web/src/lib/layers.ts
|
|
254
284
|
var ROLE_CODE = {
|
|
255
285
|
unknown: 0,
|
|
@@ -638,8 +668,8 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
638
668
|
clusters.pop();
|
|
639
669
|
}
|
|
640
670
|
}
|
|
641
|
-
const median = (
|
|
642
|
-
const a =
|
|
671
|
+
const median = (arr2) => {
|
|
672
|
+
const a = arr2.slice().sort((x, y) => x - y);
|
|
643
673
|
return a[a.length >> 1];
|
|
644
674
|
};
|
|
645
675
|
for (const members of clusters) {
|
|
@@ -2251,10 +2281,13 @@ var sheetSummary = (s) => ({
|
|
|
2251
2281
|
});
|
|
2252
2282
|
var Session = class _Session {
|
|
2253
2283
|
file = null;
|
|
2254
|
-
/** Absolute path the
|
|
2255
|
-
*
|
|
2284
|
+
/** Absolute path of the PRIMARY (first-loaded) plan — the marked set's
|
|
2285
|
+
* default output lands beside it; per-file paths live in `docs`. */
|
|
2256
2286
|
filePath = null;
|
|
2257
|
-
|
|
2287
|
+
/** The working document set (#152), by basename — one entry per load_plan,
|
|
2288
|
+
* several under merge: true. Source paths ride along for byte re-reads. */
|
|
2289
|
+
docs = /* @__PURE__ */ new Map();
|
|
2290
|
+
nextOrd = 1;
|
|
2258
2291
|
sheets = /* @__PURE__ */ new Map();
|
|
2259
2292
|
conditions = [];
|
|
2260
2293
|
markups = [];
|
|
@@ -2276,30 +2309,48 @@ var Session = class _Session {
|
|
|
2276
2309
|
this.record({ op: "commit", tool, ids: this.pendingCommits });
|
|
2277
2310
|
this.pendingCommits = [];
|
|
2278
2311
|
}
|
|
2279
|
-
/** load_plan
|
|
2280
|
-
* ALL state
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
this.
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2312
|
+
/** load_plan (#152): without merge, replaces the whole session — every doc
|
|
2313
|
+
* destroyed, ALL state (scales, caches, conditions, shapes) cleared. With
|
|
2314
|
+
* merge: true, ADDS a document to the working set — a bid set is plans +
|
|
2315
|
+
* schedule + addenda, not one PDF — keeping every scale, condition, and
|
|
2316
|
+
* shape already in the session. Sheet keys already carry file names
|
|
2317
|
+
* (plan.pdf, plan.pdf#2), so two documents never collide; loading the SAME
|
|
2318
|
+
* file again under merge is refused (an addendum is a new file — reloading
|
|
2319
|
+
* one in place is a replace-the-session decision, not a merge). */
|
|
2320
|
+
async loadPlan(filePath, opts = {}) {
|
|
2321
|
+
const base = path2.basename(filePath);
|
|
2322
|
+
const merging = !!opts.merge && this.docs.size > 0;
|
|
2323
|
+
if (!merging) {
|
|
2324
|
+
for (const d of this.docs.values()) await d.doc.destroy().catch(() => {
|
|
2325
|
+
});
|
|
2326
|
+
this.docs.clear();
|
|
2327
|
+
this.sheets.clear();
|
|
2328
|
+
this.conditions = [];
|
|
2329
|
+
this.shapes = [];
|
|
2330
|
+
this.markups = [];
|
|
2331
|
+
this.file = null;
|
|
2332
|
+
this.filePath = null;
|
|
2333
|
+
this.nextOrd = 1;
|
|
2334
|
+
this.journal = [];
|
|
2335
|
+
this.pendingCommits = [];
|
|
2336
|
+
} else if (this.docs.has(base)) {
|
|
2337
|
+
throw new UserError(`${base} is already loaded \u2014 merge adds NEW documents. To reload it, call load_plan without merge (replaces the whole session).`);
|
|
2338
|
+
}
|
|
2293
2339
|
this.graph = null;
|
|
2294
2340
|
const doc = await openPdf(filePath);
|
|
2295
|
-
|
|
2296
|
-
this.
|
|
2297
|
-
this.
|
|
2341
|
+
const resolved = path2.resolve(filePath);
|
|
2342
|
+
this.docs.set(base, { doc, path: resolved });
|
|
2343
|
+
if (!this.file) {
|
|
2344
|
+
this.file = base;
|
|
2345
|
+
this.filePath = resolved;
|
|
2346
|
+
}
|
|
2347
|
+
const added = [];
|
|
2298
2348
|
for (let n = 1; n <= doc.numPages; n++) {
|
|
2299
2349
|
const ph = await doc.page(n);
|
|
2300
|
-
const key = n === 1 ?
|
|
2301
|
-
|
|
2350
|
+
const key = n === 1 ? base : `${base}#${n}`;
|
|
2351
|
+
const state = {
|
|
2302
2352
|
key,
|
|
2353
|
+
ord: this.nextOrd++,
|
|
2303
2354
|
pageNum: n,
|
|
2304
2355
|
widthPt: ph.widthPt,
|
|
2305
2356
|
heightPt: ph.heightPt,
|
|
@@ -2310,43 +2361,72 @@ var Session = class _Session {
|
|
|
2310
2361
|
upp: null,
|
|
2311
2362
|
text: positionedText(ph),
|
|
2312
2363
|
page: ph
|
|
2313
|
-
}
|
|
2364
|
+
};
|
|
2365
|
+
this.sheets.set(key, state);
|
|
2366
|
+
added.push(sheetSummary(state));
|
|
2314
2367
|
}
|
|
2315
2368
|
return {
|
|
2316
|
-
file:
|
|
2317
|
-
|
|
2369
|
+
file: base,
|
|
2370
|
+
files: this.files,
|
|
2371
|
+
page_count: this.sheets.size,
|
|
2318
2372
|
sheets: [...this.sheets.values()].map(sheetSummary),
|
|
2319
|
-
note: "Replaced the previous session \u2014 all prior scales, conditions, and shapes were cleared."
|
|
2373
|
+
note: merging ? `Merged ${base} into the working set (${added.length} sheet${added.length === 1 ? "" : "s"} added) \u2014 every prior scale, condition, and shape kept. The sheet graph now spans ${this.docs.size} documents.` : "Replaced the previous session \u2014 all prior scales, conditions, and shapes were cleared."
|
|
2320
2374
|
};
|
|
2321
2375
|
}
|
|
2376
|
+
/** Every loaded document's basename, load order. */
|
|
2377
|
+
get files() {
|
|
2378
|
+
return [...this.docs.keys()];
|
|
2379
|
+
}
|
|
2380
|
+
/** The file (basename) a sheet key belongs to — the key codec's inverse. */
|
|
2381
|
+
fileFor(sheetKey) {
|
|
2382
|
+
return sheetKey.split("#")[0];
|
|
2383
|
+
}
|
|
2384
|
+
/** Absolute source path for a loaded file — the marked set re-reads bytes. */
|
|
2385
|
+
pathFor(file) {
|
|
2386
|
+
return this.docs.get(file)?.path ?? null;
|
|
2387
|
+
}
|
|
2322
2388
|
sheet(name) {
|
|
2323
|
-
if (!this.
|
|
2389
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
2324
2390
|
const hit = this.sheets.get(name);
|
|
2325
2391
|
if (hit) return hit;
|
|
2326
2392
|
const wanted = name.toUpperCase().replace(/\s+/g, "");
|
|
2327
2393
|
for (const s of this.sheets.values()) if (s.sheetNumber === wanted) return s;
|
|
2328
2394
|
throw new UserError(`Unknown sheet "${name}" \u2014 loaded sheets: ${[...this.sheets.keys()].join(", ")}.`);
|
|
2329
2395
|
}
|
|
2330
|
-
/**
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2396
|
+
/** Like sheet(), but null for an unknown key — import adoption iterates
|
|
2397
|
+
* merged rows that may reference files this document doesn't have. */
|
|
2398
|
+
sheetOrNull(name) {
|
|
2399
|
+
return this.sheets.get(name) ?? null;
|
|
2400
|
+
}
|
|
2401
|
+
/** Journal an externally-assembled commit gesture (import_takeoff) as one
|
|
2402
|
+
* reversible step — the same entry shape commit()+flushCommits() writes. */
|
|
2403
|
+
journalCommit(tool, ids) {
|
|
2404
|
+
this.record({ op: "commit", tool, ids });
|
|
2405
|
+
}
|
|
2406
|
+
/** Resource-URI addressing: 1-based position in load order across every
|
|
2407
|
+
* loaded document (=== page number for a single-document session). */
|
|
2408
|
+
sheetForPage(ord) {
|
|
2409
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
2410
|
+
const hit = this.sheetList()[ord - 1];
|
|
2411
|
+
if (!hit) throw new UserError(`No sheet ${ord} \u2014 the working set has sheets 1\u2013${this.sheets.size}.`);
|
|
2412
|
+
return hit;
|
|
2335
2413
|
}
|
|
2336
2414
|
/** Every loaded sheet, in page order — [] before any plan loads. */
|
|
2337
2415
|
sheetList() {
|
|
2338
|
-
return [...this.sheets.values()].sort((a, b) => a.
|
|
2416
|
+
return [...this.sheets.values()].sort((a, b) => a.ord - b.ord);
|
|
2339
2417
|
}
|
|
2340
2418
|
/** The takeoff://sheets index payload — cheap (no geometry is built). */
|
|
2341
2419
|
index() {
|
|
2342
|
-
if (!this.
|
|
2420
|
+
if (!this.docs.size) {
|
|
2343
2421
|
return { file: null, page_count: 0, sheets: [], hint: "No plan loaded \u2014 call the load_plan tool with a PDF path, then list resources again." };
|
|
2344
2422
|
}
|
|
2345
2423
|
return {
|
|
2346
2424
|
file: this.file,
|
|
2425
|
+
files: this.files,
|
|
2347
2426
|
page_count: this.sheets.size,
|
|
2348
2427
|
sheets: this.sheetList().map((s) => ({
|
|
2349
2428
|
...sheetSummary(s),
|
|
2429
|
+
ord: s.ord,
|
|
2350
2430
|
scale_set: s.upp != null,
|
|
2351
2431
|
shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length
|
|
2352
2432
|
}))
|
|
@@ -2354,8 +2434,8 @@ var Session = class _Session {
|
|
|
2354
2434
|
}
|
|
2355
2435
|
/** Rendered-page PNG, long edge capped at IMAGE_MAX_EDGE (never above the
|
|
2356
2436
|
* canvas-native RENDER_SCALE), cached per sheet until the next load_plan. */
|
|
2357
|
-
async renderSheetPng(
|
|
2358
|
-
const s = this.sheetForPage(
|
|
2437
|
+
async renderSheetPng(ord) {
|
|
2438
|
+
const s = this.sheetForPage(ord);
|
|
2359
2439
|
if (!s.png) {
|
|
2360
2440
|
const scale = Math.min(RENDER_SCALE, IMAGE_MAX_EDGE / Math.max(s.widthPt, s.heightPt));
|
|
2361
2441
|
s.png = await s.page.renderPng(scale);
|
|
@@ -2482,8 +2562,9 @@ var Session = class _Session {
|
|
|
2482
2562
|
s.geo = extractVectorGeometry(opList, s.page.viewport.transform, OPS2);
|
|
2483
2563
|
s.snap = buildSnapGrid(s.geo.points, SNAP_CELL);
|
|
2484
2564
|
const layerIds = s.geo.layerIds || [];
|
|
2485
|
-
if (layerIds.length && this.
|
|
2486
|
-
const
|
|
2565
|
+
if (layerIds.length && this.docs.size) {
|
|
2566
|
+
const owner = this.docs.get(this.fileFor(s.key));
|
|
2567
|
+
const byId = new Map(owner ? (await owner.doc.layers()).map((g) => [g.id, g]) : []);
|
|
2487
2568
|
const counts = /* @__PURE__ */ new Map();
|
|
2488
2569
|
const lo = s.geo.layerOf;
|
|
2489
2570
|
if (lo) {
|
|
@@ -2564,6 +2645,7 @@ var Session = class _Session {
|
|
|
2564
2645
|
has_vector_linework: geo.segs.length > 0,
|
|
2565
2646
|
scale_set: s.upp != null,
|
|
2566
2647
|
...s.upp != null ? { upp: s.upp } : {},
|
|
2648
|
+
...s.detected?.multi ? { multiple_scales: true } : {},
|
|
2567
2649
|
shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length,
|
|
2568
2650
|
// the sheet's PDF layer table (#85) — always emitted; [] = no Optional
|
|
2569
2651
|
// Content survived export and every engine path runs the heuristics
|
|
@@ -2605,7 +2687,33 @@ var Session = class _Session {
|
|
|
2605
2687
|
}
|
|
2606
2688
|
s.upp = upp;
|
|
2607
2689
|
s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
|
|
2608
|
-
return {
|
|
2690
|
+
return {
|
|
2691
|
+
sheet: s.key,
|
|
2692
|
+
upp,
|
|
2693
|
+
...label ? { label } : {},
|
|
2694
|
+
source,
|
|
2695
|
+
// #153 — several DISTINCT scale notes on one sheet means enlarged plans
|
|
2696
|
+
// or details are likely; region measurements will warn when a
|
|
2697
|
+
// disagreeing note sits inside them, but say it up front too
|
|
2698
|
+
...s.detected?.multi ? { warning: "This sheet carries MULTIPLE distinct scale notes \u2014 enlarged plans/details likely. Measurements inside a viewport whose note disagrees with this scale will carry a warning; calibrate or re-set_scale before trusting them." } : {}
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
/** Mixed-scale check (#153): does the measured region carry a scale note
|
|
2702
|
+
* that DISAGREES with the scale these quantities were figured at? Runs the
|
|
2703
|
+
* sheet-level detector on a region-filtered text set — same detector, no
|
|
2704
|
+
* second regex. Returns the warning to ride the reply, or undefined. */
|
|
2705
|
+
scaleWarningFor(s, ptsPx) {
|
|
2706
|
+
if (s.upp == null || !ptsPx.length) return void 0;
|
|
2707
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
2708
|
+
for (const [x, y] of ptsPx) {
|
|
2709
|
+
x0 = Math.min(x0, x);
|
|
2710
|
+
y0 = Math.min(y0, y);
|
|
2711
|
+
x1 = Math.max(x1, x);
|
|
2712
|
+
y1 = Math.max(y1, y);
|
|
2713
|
+
}
|
|
2714
|
+
const region = expandForScaleNotes({ x0, y0, x1, y1 });
|
|
2715
|
+
const adoptedLabel = s.detected && Math.abs(s.detected.upp - s.upp) / s.upp <= 1e-6 ? s.detected.label : void 0;
|
|
2716
|
+
return mixedScaleWarning(textItemsInRegion(s.page, region), s.page.viewport, s.upp, adoptedLabel);
|
|
2609
2717
|
}
|
|
2610
2718
|
conditionFor(tag) {
|
|
2611
2719
|
let c = this.conditions.find((x) => x.finish_tag === tag);
|
|
@@ -2687,7 +2795,8 @@ var Session = class _Session {
|
|
|
2687
2795
|
}).id;
|
|
2688
2796
|
}
|
|
2689
2797
|
this.flushCommits("one_click");
|
|
2690
|
-
|
|
2798
|
+
const mixed = this.scaleWarningFor(s, ring);
|
|
2799
|
+
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {}, ...mixed ? { warning: mixed } : {} };
|
|
2691
2800
|
}
|
|
2692
2801
|
/** Batch room detection: read every room-number label off the sheet's text
|
|
2693
2802
|
* layer, seed the existing One-Click flood at each, and trace/commit
|
|
@@ -2821,6 +2930,7 @@ var Session = class _Session {
|
|
|
2821
2930
|
...withheld,
|
|
2822
2931
|
...upp != null ? { min_area_sf: minAreaSf } : {}
|
|
2823
2932
|
},
|
|
2933
|
+
...s.detected?.multi ? { multiple_scales: true } : {},
|
|
2824
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.` } : {},
|
|
2825
2935
|
...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
|
|
2826
2936
|
};
|
|
@@ -2834,7 +2944,8 @@ var Session = class _Session {
|
|
|
2834
2944
|
let shape_id;
|
|
2835
2945
|
if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }, { method: "manual", actor: "agent" }).id;
|
|
2836
2946
|
this.flushCommits("measure_polygon");
|
|
2837
|
-
|
|
2947
|
+
const mixed = this.scaleWarningFor(s, verts);
|
|
2948
|
+
return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {}, ...mixed ? { warning: mixed } : {} };
|
|
2838
2949
|
}
|
|
2839
2950
|
measureLine(name, pts, opts) {
|
|
2840
2951
|
const s = this.sheet(name);
|
|
@@ -2870,6 +2981,68 @@ var Session = class _Session {
|
|
|
2870
2981
|
this.flushCommits("measure_surface");
|
|
2871
2982
|
return { condition: c.finish_tag, height_ft: h, length_lf: round2(LF), area_sf: round2(LF * h), npts: pts.length, shape_id: shape.id };
|
|
2872
2983
|
}
|
|
2984
|
+
/** derive_base (#148): the estimator's most mechanical derivation — wall
|
|
2985
|
+
* base LF = room perimeter − stated door openings — minted as committed
|
|
2986
|
+
* linear shapes from the floor shapes an agent (or detect_rooms) already
|
|
2987
|
+
* traced. Every committed floor shape carries its perimeter; the openings
|
|
2988
|
+
* stay the CALLER'S stated claim per room (it can see the doors in
|
|
2989
|
+
* view_sheet) — refusal over guessing, and the claim rides provenance.
|
|
2990
|
+
* Geometry: each base shape re-uses its source ring CLOSED (ring + the
|
|
2991
|
+
* first vertex again) so the run traces the whole room boundary on the
|
|
2992
|
+
* canvas and in the marked set. NOTE: quantities are assigned at derive
|
|
2993
|
+
* time (net of openings); a later edit_shape re-measure of the polyline is
|
|
2994
|
+
* the gross boundary again — the openings deduction lives here and in
|
|
2995
|
+
* origin.derived, not in the geometry. */
|
|
2996
|
+
deriveBase(opts) {
|
|
2997
|
+
const src = this.conditions.find((x) => x.finish_tag === opts.source_condition);
|
|
2998
|
+
if (!src) {
|
|
2999
|
+
throw new UserError(`No condition ${JSON.stringify(opts.source_condition)} \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}.`);
|
|
3000
|
+
}
|
|
3001
|
+
if (opts.condition === opts.source_condition) {
|
|
3002
|
+
throw new UserError("Base must land on its OWN condition (e.g. 'RB-1') \u2014 deriving onto the source would add its perimeter to the same tag's LF.");
|
|
3003
|
+
}
|
|
3004
|
+
const floors = this.shapes.filter((x) => x.condition_id === src.id && x.measure_role === "floor_area");
|
|
3005
|
+
if (!floors.length) {
|
|
3006
|
+
throw new UserError(`${src.finish_tag} has no floor_area shapes to derive from \u2014 commit rooms first (one_click / detect_rooms).`);
|
|
3007
|
+
}
|
|
3008
|
+
const byShape = /* @__PURE__ */ new Map();
|
|
3009
|
+
for (const [i, o] of (opts.openings ?? []).entries()) {
|
|
3010
|
+
const hit = floors.find((f) => f.id === o.shape_id);
|
|
3011
|
+
if (!hit) throw new UserError(`openings[${i}]: ${JSON.stringify(o.shape_id)} is not a floor_area shape of ${src.finish_tag} \u2014 list_shapes for real ids.`);
|
|
3012
|
+
if (!(o.lf >= 0)) throw new UserError(`openings[${i}]: lf must be >= 0.`);
|
|
3013
|
+
byShape.set(o.shape_id, (byShape.get(o.shape_id) ?? 0) + o.lf);
|
|
3014
|
+
}
|
|
3015
|
+
for (const f of floors) {
|
|
3016
|
+
const open = byShape.get(f.id) ?? 0;
|
|
3017
|
+
const gross = f.computed.perimeter_lf ?? 0;
|
|
3018
|
+
if (open >= gross) {
|
|
3019
|
+
throw new UserError(`Shape ${f.id}: stated openings (${round2(open)} LF) meet or exceed its perimeter (${round2(gross)} LF) \u2014 nothing would remain.`);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
const rooms = floors.map((f) => {
|
|
3023
|
+
const s = this.sheet(f.sheet_id);
|
|
3024
|
+
const gross = f.computed.perimeter_lf ?? 0;
|
|
3025
|
+
const open = byShape.get(f.id) ?? 0;
|
|
3026
|
+
const net = round2(gross - open);
|
|
3027
|
+
const ringPx = f.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
|
|
3028
|
+
const shape = this.commit(s, opts.condition, "linear", [...ringPx, ringPx[0]], { area_sf: 0, perimeter_lf: net }, {
|
|
3029
|
+
method: "agent_v1",
|
|
3030
|
+
actor: "agent",
|
|
3031
|
+
reviewed: false,
|
|
3032
|
+
derived: { from_shape_id: f.id, gross_lf: round2(gross), openings_lf: round2(open) }
|
|
3033
|
+
});
|
|
3034
|
+
return { source_shape_id: f.id, base_shape_id: shape.id, sheet: f.sheet_id, gross_lf: round2(gross), openings_lf: round2(open), net_lf: net };
|
|
3035
|
+
});
|
|
3036
|
+
this.flushCommits("derive_base");
|
|
3037
|
+
return {
|
|
3038
|
+
condition: opts.condition,
|
|
3039
|
+
source_condition: src.finish_tag,
|
|
3040
|
+
rooms,
|
|
3041
|
+
committed: rooms.length,
|
|
3042
|
+
total_lf: round2(rooms.reduce((n, r) => n + r.net_lf, 0)),
|
|
3043
|
+
note: "Base runs trace each room's boundary; openings are your stated claim, recorded on origin.derived. Verify with view_sheet overlay:true."
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
2873
3046
|
/** Count markers — the canvas's Count tool (commitCount): one point, one EA,
|
|
2874
3047
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
2875
3048
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
@@ -2888,7 +3061,7 @@ var Session = class _Session {
|
|
|
2888
3061
|
* delete_shape assume you have, without pulling the whole export_takeoff
|
|
2889
3062
|
* payload to find one shape. Filters narrow, they never 404 an empty list. */
|
|
2890
3063
|
listShapes(f = {}) {
|
|
2891
|
-
if (!this.
|
|
3064
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
2892
3065
|
let rows = this.shapes;
|
|
2893
3066
|
if (f.sheet) {
|
|
2894
3067
|
const s = this.sheet(f.sheet);
|
|
@@ -3281,7 +3454,7 @@ var Session = class _Session {
|
|
|
3281
3454
|
/** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
|
|
3282
3455
|
* store.saveAnnotations stamps) — importable by the app. */
|
|
3283
3456
|
exportPayload() {
|
|
3284
|
-
if (!this.
|
|
3457
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
3285
3458
|
return {
|
|
3286
3459
|
schema: ANN_SCHEMA,
|
|
3287
3460
|
project_name: "",
|
|
@@ -3304,7 +3477,7 @@ var Session = class _Session {
|
|
|
3304
3477
|
* materials as CONFIG rows and takeoff_summary strips them; only this
|
|
3305
3478
|
* document carries the computed order quantities. */
|
|
3306
3479
|
exportReport(projectName = "") {
|
|
3307
|
-
if (!this.
|
|
3480
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
3308
3481
|
const rows = conditionTotals(this.conditions, this.shapes).filter((r) => r.shape_count > 0);
|
|
3309
3482
|
const { dimsFor, uppFor } = this.rollInputs();
|
|
3310
3483
|
const { byCond } = computeRollTakeoff(this.conditions, this.shapes, dimsFor, uppFor);
|
|
@@ -3324,7 +3497,7 @@ var Session = class _Session {
|
|
|
3324
3497
|
// span plumbing plus the wire shapes.
|
|
3325
3498
|
graph = null;
|
|
3326
3499
|
async ensureGraph() {
|
|
3327
|
-
if (!this.
|
|
3500
|
+
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
3328
3501
|
if (!this.graph) {
|
|
3329
3502
|
const inputs = [];
|
|
3330
3503
|
for (const s of this.sheets.values()) {
|
|
@@ -3469,9 +3642,10 @@ var sheetSummary2 = {
|
|
|
3469
3642
|
detected_scale: z.string().optional().describe("Drawn scale note read off the sheet \u2014 a suggestion, never auto-applied")
|
|
3470
3643
|
};
|
|
3471
3644
|
var loadPlanOutput = {
|
|
3472
|
-
file: z.string(),
|
|
3473
|
-
|
|
3474
|
-
|
|
3645
|
+
file: z.string().describe("The document just loaded (basename)"),
|
|
3646
|
+
files: z.array(z.string()).describe("Every document in the working set, load order (#152 \u2014 one entry unless merge was used)"),
|
|
3647
|
+
page_count: z.number().int().describe("Total sheets across the working set"),
|
|
3648
|
+
sheets: z.array(z.object(sheetSummary2)).describe("EVERY sheet in the working set, not just the file loaded by this call"),
|
|
3475
3649
|
note: z.string()
|
|
3476
3650
|
};
|
|
3477
3651
|
var sheetInfoOutput = {
|
|
@@ -3481,6 +3655,7 @@ var sheetInfoOutput = {
|
|
|
3481
3655
|
scale_set: z.boolean(),
|
|
3482
3656
|
upp: z.number().optional().describe("Real feet per image px at render scale 2.0 \u2014 present once the scale is set"),
|
|
3483
3657
|
shape_count: z.number().int().describe("Committed shapes on this sheet"),
|
|
3658
|
+
multiple_scales: z.literal(true).optional().describe("Several DISTINCT scale notes on this sheet (#153) \u2014 enlarged plans/details likely"),
|
|
3484
3659
|
layers: z.array(z.object({
|
|
3485
3660
|
id: z.string().describe("Optional Content Group id \u2014 pass to one_click/detect_rooms layers.include/exclude"),
|
|
3486
3661
|
name: z.string().describe("The CAD layer name as exported (e.g. A-WALL-FULL)"),
|
|
@@ -3494,7 +3669,8 @@ var setScaleOutput = {
|
|
|
3494
3669
|
sheet: z.string(),
|
|
3495
3670
|
upp: z.number().describe("Real feet per image px at render scale 2.0"),
|
|
3496
3671
|
label: z.string().optional().describe("The standard scale label, when set by label or detected note"),
|
|
3497
|
-
source: z.enum(["label", "upp", "calibrate", "detected"])
|
|
3672
|
+
source: z.enum(["label", "upp", "calibrate", "detected"]),
|
|
3673
|
+
warning: z.string().optional().describe("Present when the sheet carries MULTIPLE distinct scale notes (#153) \u2014 enlarged plans/details likely; region measurements under a disagreeing note will warn")
|
|
3498
3674
|
};
|
|
3499
3675
|
var oneClickOutput = {
|
|
3500
3676
|
status: z.literal("ok"),
|
|
@@ -3507,7 +3683,7 @@ var oneClickOutput = {
|
|
|
3507
3683
|
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
3508
3684
|
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
3509
3685
|
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px"),
|
|
3510
|
-
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable
|
|
3686
|
+
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable \u2014 OR, in scaled mode, a mixed-scale warning (#153): a scale note disagreeing with the sheet's sits in the measured region (enlarged plan/detail viewport likely)")
|
|
3511
3687
|
};
|
|
3512
3688
|
var detectedRoom = z.object({
|
|
3513
3689
|
label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
|
|
@@ -3534,13 +3710,15 @@ var detectRoomsOutput = {
|
|
|
3534
3710
|
min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
|
|
3535
3711
|
}).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"),
|
|
3536
3712
|
note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
|
|
3713
|
+
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"),
|
|
3537
3714
|
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
3538
3715
|
};
|
|
3539
3716
|
var measurePolygonOutput = {
|
|
3540
3717
|
area_sf: z.number(),
|
|
3541
3718
|
perimeter_lf: z.number(),
|
|
3542
3719
|
nverts: z.number().int(),
|
|
3543
|
-
shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
|
|
3720
|
+
shape_id: z.string().optional().describe("Present when condition was passed and the shape committed"),
|
|
3721
|
+
warning: z.string().optional().describe("Mixed-scale warning (#153): a scale note disagreeing with the sheet's sits in the measured region \u2014 verify before trusting these numbers")
|
|
3544
3722
|
};
|
|
3545
3723
|
var measureSurfaceOutput = {
|
|
3546
3724
|
condition: z.string(),
|
|
@@ -3621,6 +3799,33 @@ var exportTakeoffOutput = {
|
|
|
3621
3799
|
sheet_tabs: z.array(z.unknown()),
|
|
3622
3800
|
sheet_levels: z.object({}).passthrough()
|
|
3623
3801
|
};
|
|
3802
|
+
var importTakeoffOutput = {
|
|
3803
|
+
file: z.string().describe("Basename of the imported file"),
|
|
3804
|
+
replaced: z.boolean().describe("true = the session was empty and adopted the file wholesale"),
|
|
3805
|
+
shapes_added: z.number().int(),
|
|
3806
|
+
shapes_pending: z.number().int().describe("Of the added shapes, how many are unreviewed machine pencil"),
|
|
3807
|
+
conditions_merged: z.number().int().describe("Imported conditions that joined an existing finish tag (its knobs won)"),
|
|
3808
|
+
conditions_added: z.number().int(),
|
|
3809
|
+
scales_adopted: z.number().int().describe("Sheets whose calibration came from the file (this session's own always wins)"),
|
|
3810
|
+
unknown_files: z.array(z.string()).describe("Files referenced by imported shapes that this document doesn't have \u2014 they count in totals but can't be viewed here"),
|
|
3811
|
+
shapes_total: z.number().int(),
|
|
3812
|
+
note: z.string()
|
|
3813
|
+
};
|
|
3814
|
+
var deriveBaseOutput = {
|
|
3815
|
+
condition: z.string().describe("The tag the base committed under"),
|
|
3816
|
+
source_condition: z.string(),
|
|
3817
|
+
rooms: z.array(z.object({
|
|
3818
|
+
source_shape_id: z.string(),
|
|
3819
|
+
base_shape_id: z.string().describe("The committed linear base shape"),
|
|
3820
|
+
sheet: z.string(),
|
|
3821
|
+
gross_lf: z.number().describe("The room's full perimeter"),
|
|
3822
|
+
openings_lf: z.number().describe("The openings you stated for this room"),
|
|
3823
|
+
net_lf: z.number().describe("gross \u2212 openings \u2014 the committed quantity")
|
|
3824
|
+
})),
|
|
3825
|
+
committed: z.number().int(),
|
|
3826
|
+
total_lf: z.number().describe("Sum of net_lf across rooms"),
|
|
3827
|
+
note: z.string()
|
|
3828
|
+
};
|
|
3624
3829
|
var listShapesOutput = {
|
|
3625
3830
|
shapes: z.array(z.object({
|
|
3626
3831
|
id: z.string(),
|
|
@@ -4538,9 +4743,9 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4538
4743
|
const { PDFDocument, StandardFonts, rgb, degrees, LineCapStyle } = await import("pdf-lib");
|
|
4539
4744
|
const condById = Object.fromEntries(conditions.map((c) => [c.id, c]));
|
|
4540
4745
|
const rfiNum = new Map((rfis || []).map((r) => [r.id, r.number]));
|
|
4541
|
-
const byKey = (
|
|
4746
|
+
const byKey = (arr2) => {
|
|
4542
4747
|
const m = /* @__PURE__ */ new Map();
|
|
4543
|
-
for (const s of
|
|
4748
|
+
for (const s of arr2) {
|
|
4544
4749
|
const a = m.get(s.sheet_id) || [];
|
|
4545
4750
|
a.push(s);
|
|
4546
4751
|
m.set(s.sheet_id, a);
|
|
@@ -4949,16 +5154,16 @@ async function exportMarkedPdf(session, opts) {
|
|
|
4949
5154
|
throw new UserError("Nothing to mark yet \u2014 commit shapes (one_click / detect_rooms / measure_polygon / measure_line with a condition) or annotate before exporting the marked set.");
|
|
4950
5155
|
}
|
|
4951
5156
|
const sheetStates = session.sheetList();
|
|
4952
|
-
const
|
|
5157
|
+
const byFilePage = new Map(sheetStates.map((s) => [`${session.fileFor(s.key)}#${s.pageNum}`, s.page]));
|
|
4953
5158
|
const sheets = sheetStates.map((s) => ({
|
|
4954
5159
|
key: s.key,
|
|
4955
|
-
file,
|
|
5160
|
+
file: session.fileFor(s.key),
|
|
4956
5161
|
page: s.pageNum,
|
|
4957
5162
|
label: s.sheetNumber ? `${s.sheetNumber} \xB7 p${s.pageNum}` : s.key
|
|
4958
5163
|
}));
|
|
4959
|
-
const getPage = async (
|
|
4960
|
-
const ph =
|
|
4961
|
-
if (!ph) throw new UserError(`No page ${pageNum} in the
|
|
5164
|
+
const getPage = async (srcFile, pageNum) => {
|
|
5165
|
+
const ph = byFilePage.get(`${srcFile}#${pageNum}`);
|
|
5166
|
+
if (!ph) throw new UserError(`No page ${pageNum} of ${srcFile} in the working set.`);
|
|
4962
5167
|
return {
|
|
4963
5168
|
rotate: ph.rotate,
|
|
4964
5169
|
getViewport: ({ scale }) => {
|
|
@@ -4968,8 +5173,17 @@ async function exportMarkedPdf(session, opts) {
|
|
|
4968
5173
|
}
|
|
4969
5174
|
};
|
|
4970
5175
|
};
|
|
4971
|
-
|
|
4972
|
-
const loadPdfData = async () =>
|
|
5176
|
+
const srcBytes = /* @__PURE__ */ new Map();
|
|
5177
|
+
const loadPdfData = async (srcFile) => {
|
|
5178
|
+
let bytes2 = srcBytes.get(srcFile);
|
|
5179
|
+
if (!bytes2) {
|
|
5180
|
+
const p = session.pathFor(srcFile);
|
|
5181
|
+
if (!p) throw new UserError(`No source path for ${srcFile} \u2014 is it loaded?`);
|
|
5182
|
+
bytes2 = new Uint8Array(await readFile2(p));
|
|
5183
|
+
srcBytes.set(srcFile, bytes2);
|
|
5184
|
+
}
|
|
5185
|
+
return bytes2;
|
|
5186
|
+
};
|
|
4973
5187
|
const base = file.replace(/\.pdf$/i, "");
|
|
4974
5188
|
const machine = session.shapes.filter((s) => s.origin?.reviewed !== true).length;
|
|
4975
5189
|
const credit = machine ? `Machine-traced via OpenTakeoff MCP \u2014 ${machine} shape${machine === 1 ? "" : "s"} pending human review` : null;
|
|
@@ -5000,6 +5214,160 @@ async function exportMarkedPdf(session, opts) {
|
|
|
5000
5214
|
};
|
|
5001
5215
|
}
|
|
5002
5216
|
|
|
5217
|
+
// src/importing.ts
|
|
5218
|
+
import path4 from "node:path";
|
|
5219
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
5220
|
+
|
|
5221
|
+
// ../web/src/lib/stamps.js
|
|
5222
|
+
var DEFAULT_STAMPS = [
|
|
5223
|
+
{ id: "stmp-direction", name: "Plank / tile direction", elements: [
|
|
5224
|
+
{ type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#1f3fc7", weight: 1.5 }
|
|
5225
|
+
] },
|
|
5226
|
+
{ id: "stmp-seam", name: "Seam direction", elements: [
|
|
5227
|
+
{ type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#b03a26", line_style: "dashed" }
|
|
5228
|
+
] },
|
|
5229
|
+
{ id: "stmp-origin", name: "Pattern origin", elements: [
|
|
5230
|
+
{ type: "bubble", at: [0, 0], r: 0.018, text: "PO", color: "#0d9488" }
|
|
5231
|
+
] }
|
|
5232
|
+
];
|
|
5233
|
+
var DEFAULT_STAMP_SETS = [
|
|
5234
|
+
{ id: "set-flooring", name: "Flooring shop drawings", stampIds: DEFAULT_STAMPS.map((s) => s.id) }
|
|
5235
|
+
];
|
|
5236
|
+
|
|
5237
|
+
// ../web/src/lib/store.js
|
|
5238
|
+
var ANN_SCHEMA2 = "opentakeoff.takeoff_canvas.v1";
|
|
5239
|
+
|
|
5240
|
+
// ../web/src/lib/importTakeoff.js
|
|
5241
|
+
function parseTakeoffImport(text) {
|
|
5242
|
+
let doc;
|
|
5243
|
+
try {
|
|
5244
|
+
doc = JSON.parse(text);
|
|
5245
|
+
} catch {
|
|
5246
|
+
throw new Error("Couldn't import takeoff: that file is not valid JSON.");
|
|
5247
|
+
}
|
|
5248
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.schema !== ANN_SCHEMA2) {
|
|
5249
|
+
throw new Error(`Couldn't import takeoff: not a takeoff export (expected schema "${ANN_SCHEMA2}" \u2014 the file export_takeoff or the app writes).`);
|
|
5250
|
+
}
|
|
5251
|
+
return doc;
|
|
5252
|
+
}
|
|
5253
|
+
var arr = (v) => Array.isArray(v) ? v : [];
|
|
5254
|
+
var tagKey = (t) => String(t || "").trim().toUpperCase();
|
|
5255
|
+
var freeId = (id, taken) => {
|
|
5256
|
+
let n = 2, next = id;
|
|
5257
|
+
while (taken.has(next)) next = `${id}~${n++}`;
|
|
5258
|
+
return next;
|
|
5259
|
+
};
|
|
5260
|
+
function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
5261
|
+
const cur = current && typeof current === "object" ? current : {};
|
|
5262
|
+
const impShapes = arr(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string");
|
|
5263
|
+
const impConds = arr(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
|
|
5264
|
+
const unknownFiles = (added) => {
|
|
5265
|
+
if (!Array.isArray(knownFiles)) return [];
|
|
5266
|
+
const known = new Set(knownFiles);
|
|
5267
|
+
return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
|
|
5268
|
+
};
|
|
5269
|
+
const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
|
|
5270
|
+
if (!arr(cur.shapes).length && !arr(cur.markups).length) {
|
|
5271
|
+
const payload2 = {
|
|
5272
|
+
...imported,
|
|
5273
|
+
...arr(imported.sheet_tabs).length ? {} : { sheet_tabs: arr(cur.sheet_tabs) },
|
|
5274
|
+
...arr(imported.sheet_group).length ? {} : { sheet_group: arr(cur.sheet_group) },
|
|
5275
|
+
...arr(imported.last_group).length ? {} : { last_group: arr(cur.last_group) }
|
|
5276
|
+
};
|
|
5277
|
+
return {
|
|
5278
|
+
payload: payload2,
|
|
5279
|
+
note: { replaced: true, shapes_added: impShapes.length, shapes_pending: pendingCount(impShapes), conditions_merged: 0, conditions_added: impConds.length, scales_adopted: arr(imported.sheets).length, unknown_files: unknownFiles(impShapes) }
|
|
5280
|
+
};
|
|
5281
|
+
}
|
|
5282
|
+
const conditions = [...arr(cur.conditions)];
|
|
5283
|
+
const byTag = new Map(conditions.map((c) => [tagKey(c.finish_tag), c.id]));
|
|
5284
|
+
const condIds = new Set(conditions.map((c) => c.id));
|
|
5285
|
+
const condMap = /* @__PURE__ */ new Map();
|
|
5286
|
+
let condMerged = 0, condAdded = 0;
|
|
5287
|
+
for (const c of impConds) {
|
|
5288
|
+
const hit = byTag.get(tagKey(c.finish_tag));
|
|
5289
|
+
if (hit) {
|
|
5290
|
+
condMap.set(c.id, hit);
|
|
5291
|
+
condMerged++;
|
|
5292
|
+
continue;
|
|
5293
|
+
}
|
|
5294
|
+
const id = condIds.has(c.id) ? freeId(c.id, condIds) : c.id;
|
|
5295
|
+
const next = id === c.id ? c : { ...c, id };
|
|
5296
|
+
conditions.push(next);
|
|
5297
|
+
condIds.add(id);
|
|
5298
|
+
byTag.set(tagKey(c.finish_tag), id);
|
|
5299
|
+
condMap.set(c.id, id);
|
|
5300
|
+
condAdded++;
|
|
5301
|
+
}
|
|
5302
|
+
const shapeIds = new Set(arr(cur.shapes).map((s) => s.id));
|
|
5303
|
+
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);
|
|
5304
|
+
const markupIds = new Set(arr(cur.markups).map((m) => m?.id).filter(Boolean));
|
|
5305
|
+
const addedMarkups = arr(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
|
|
5306
|
+
const rfiIds = new Set(arr(cur.rfis).map((r) => r?.id).filter(Boolean));
|
|
5307
|
+
const addedRfis = arr(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
|
|
5308
|
+
const sheets = [...arr(cur.sheets)];
|
|
5309
|
+
const scaled = new Set(sheets.map((s) => s?.sheet_id));
|
|
5310
|
+
let scalesAdopted = 0;
|
|
5311
|
+
for (const s of arr(imported.sheets)) {
|
|
5312
|
+
if (s && typeof s === "object" && s.sheet_id && s.units_per_px && !scaled.has(s.sheet_id)) {
|
|
5313
|
+
sheets.push(s);
|
|
5314
|
+
scaled.add(s.sheet_id);
|
|
5315
|
+
scalesAdopted++;
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
const payload = {
|
|
5319
|
+
...cur,
|
|
5320
|
+
conditions,
|
|
5321
|
+
shapes: [...arr(cur.shapes), ...addedShapes],
|
|
5322
|
+
markups: [...arr(cur.markups), ...addedMarkups],
|
|
5323
|
+
...addedRfis.length ? { rfis: [...arr(cur.rfis), ...addedRfis] } : {},
|
|
5324
|
+
sheets
|
|
5325
|
+
};
|
|
5326
|
+
return {
|
|
5327
|
+
payload,
|
|
5328
|
+
note: { replaced: false, shapes_added: addedShapes.length, shapes_pending: pendingCount(addedShapes), conditions_merged: condMerged, conditions_added: condAdded, scales_adopted: scalesAdopted, unknown_files: unknownFiles(addedShapes) }
|
|
5329
|
+
};
|
|
5330
|
+
}
|
|
5331
|
+
|
|
5332
|
+
// src/importing.ts
|
|
5333
|
+
var parseTakeoffImport2 = parseTakeoffImport;
|
|
5334
|
+
var mergeTakeoffImport2 = mergeTakeoffImport;
|
|
5335
|
+
async function importTakeoff(session, filePath) {
|
|
5336
|
+
if (!session.file) throw new UserError("No plan loaded \u2014 call load_plan first (the import lands on the loaded document).");
|
|
5337
|
+
let text;
|
|
5338
|
+
try {
|
|
5339
|
+
text = await readFile3(filePath, "utf8");
|
|
5340
|
+
} catch {
|
|
5341
|
+
throw new UserError(`Couldn't read ${JSON.stringify(filePath)} \u2014 is the path right?`);
|
|
5342
|
+
}
|
|
5343
|
+
let imported;
|
|
5344
|
+
try {
|
|
5345
|
+
imported = parseTakeoffImport2(text);
|
|
5346
|
+
} catch (e) {
|
|
5347
|
+
throw new UserError(e instanceof Error ? e.message : String(e));
|
|
5348
|
+
}
|
|
5349
|
+
const prevShapeIds = new Set(session.shapes.map((s) => s.id));
|
|
5350
|
+
const { payload, note } = mergeTakeoffImport2(session.exportPayload(), imported, session.files);
|
|
5351
|
+
session.conditions = payload.conditions ?? [];
|
|
5352
|
+
session.shapes = payload.shapes ?? [];
|
|
5353
|
+
session.markups = payload.markups ?? [];
|
|
5354
|
+
for (const row of payload.sheets ?? []) {
|
|
5355
|
+
const s = session.sheetOrNull(row.sheet_id);
|
|
5356
|
+
if (s && s.upp == null && row.units_per_px > 0) {
|
|
5357
|
+
s.upp = row.units_per_px;
|
|
5358
|
+
s.scaleSource = row.scale_source ?? "upp";
|
|
5359
|
+
}
|
|
5360
|
+
}
|
|
5361
|
+
const addedIds = session.shapes.filter((s) => !prevShapeIds.has(s.id)).map((s) => s.id);
|
|
5362
|
+
if (addedIds.length) session.journalCommit("import_takeoff", addedIds);
|
|
5363
|
+
return {
|
|
5364
|
+
file: path4.basename(filePath),
|
|
5365
|
+
...note,
|
|
5366
|
+
shapes_total: session.shapes.length,
|
|
5367
|
+
note: note.replaced ? "Empty session \u2014 the import IS the takeoff now. Unreviewed machine shapes stay pencil; verify with view_sheet overlay:true." : "Merged: same finish tags joined your conditions, new ids appended, duplicates skipped (re-import is idempotent), this session's calibration won per sheet."
|
|
5368
|
+
};
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5003
5371
|
// src/tools.ts
|
|
5004
5372
|
var COORDS = "Coordinates are image px at render scale 2.0: PDF pt \xD7 2, origin top-left, y down (the browser canvas's native space). Sheet payloads carry dims in both px and pt.";
|
|
5005
5373
|
var pointSchema = z2.tuple([z2.number(), z2.number()]);
|
|
@@ -5021,11 +5389,14 @@ var run = (tool, fn) => async (args) => {
|
|
|
5021
5389
|
};
|
|
5022
5390
|
function registerTools(server, session) {
|
|
5023
5391
|
server.registerTool("load_plan", {
|
|
5024
|
-
description: `Open a plan PDF from disk
|
|
5025
|
-
inputSchema: {
|
|
5392
|
+
description: `Open a plan PDF from disk. Default: replace the whole session (previous documents, scales, conditions, and shapes are cleared). merge: true ADDS the document to the working set instead (#152) \u2014 a bid set is plans + schedule + addenda, not one PDF \u2014 keeping every scale, condition, and shape; sheet keys carry file names so documents never collide, the sheet graph spans the whole set (resolve_tag can chain a plan tag on one file to a schedule row in another), and the marked set covers every worked sheet. Re-loading an already-merged file is refused \u2014 reload = replace, deliberately. Returns file, files, page_count, and one entry per sheet. The loaded sheets also become browsable resources (takeoff://sheets). ${COORDS}`,
|
|
5393
|
+
inputSchema: {
|
|
5394
|
+
path: z2.string().describe("Path to a plan PDF on disk"),
|
|
5395
|
+
merge: z2.boolean().optional().describe("true = ADD this document to the working set, keeping all existing work (merge into an empty session is just a load)")
|
|
5396
|
+
},
|
|
5026
5397
|
outputSchema: loadPlanOutput
|
|
5027
|
-
}, run("load_plan", async ({ path:
|
|
5028
|
-
const loaded = await session.loadPlan(
|
|
5398
|
+
}, run("load_plan", async ({ path: path5, merge: merge2 }) => {
|
|
5399
|
+
const loaded = await session.loadPlan(path5, { merge: merge2 });
|
|
5029
5400
|
server.sendResourceListChanged();
|
|
5030
5401
|
return loaded;
|
|
5031
5402
|
}));
|
|
@@ -5114,6 +5485,18 @@ function registerTools(server, session) {
|
|
|
5114
5485
|
},
|
|
5115
5486
|
outputSchema: placeCountOutput
|
|
5116
5487
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
5488
|
+
server.registerTool("derive_base", {
|
|
5489
|
+
description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
|
|
5490
|
+
inputSchema: {
|
|
5491
|
+
source_condition: z2.string().describe("Finish tag whose floor_area rooms the base derives from, e.g. 'CPT-1'"),
|
|
5492
|
+
condition: z2.string().describe("Finish tag the base commits under (minted on first use), e.g. 'RB-1'"),
|
|
5493
|
+
openings: z2.array(z2.object({
|
|
5494
|
+
shape_id: z2.string().describe("A floor_area shape id of source_condition (list_shapes)"),
|
|
5495
|
+
lf: z2.number().min(0).describe("Door/opening width to deduct from that room's perimeter, in feet")
|
|
5496
|
+
})).optional().describe("Stated openings per room \u2014 omit for gross perimeters")
|
|
5497
|
+
},
|
|
5498
|
+
outputSchema: deriveBaseOutput
|
|
5499
|
+
}, run("derive_base", (a) => session.deriveBase(a)));
|
|
5117
5500
|
server.registerTool("takeoff_summary", {
|
|
5118
5501
|
description: `Per-condition totals (floor/wall/border SF, LF, EA, SY, with and without waste) plus grand totals \u2014 the Report's numbers, computed by the same rules. Numbers only: the deliverable that SHOWS the work on the drawings is export_marked_pdf. ${COORDS}`,
|
|
5119
5502
|
inputSchema: {},
|
|
@@ -5146,6 +5529,13 @@ function registerTools(server, session) {
|
|
|
5146
5529
|
}
|
|
5147
5530
|
return doc;
|
|
5148
5531
|
}));
|
|
5532
|
+
server.registerTool("import_takeoff", {
|
|
5533
|
+
description: `The way BACK IN (#151): load an "opentakeoff.takeoff_canvas.v1" file \u2014 a prior export_takeoff, or the app's own save \u2014 into this session, through the SAME tested merge rules as the app's Sheet-menu import: finish-tag identity joins imported conditions onto this session's own (their knobs win), new ids append, duplicate ids skip (re-import is idempotent), and THIS session's calibration wins per sheet. An empty session adopts the file wholesale. Resume yesterday's work, extend a takeoff a human already reviewed (their ink stays ink \u2014 reviewed shapes arrive untouchable by agent verbs), or audit someone else's export with list_shapes/takeoff_summary. Requires a loaded plan; shapes referencing OTHER files ride along and count in totals but can't be viewed against this document \u2014 the reply's unknown_files names them. undo_last removes the imported SHAPES as one step; adopted conditions, scales, and annotations stay.`,
|
|
5534
|
+
inputSchema: {
|
|
5535
|
+
path: z2.string().describe("Path to a takeoff_canvas.v1 JSON file on disk")
|
|
5536
|
+
},
|
|
5537
|
+
outputSchema: importTakeoffOutput
|
|
5538
|
+
}, run("import_takeoff", (a) => importTakeoff(session, a.path)));
|
|
5149
5539
|
server.registerTool("export_marked_pdf", {
|
|
5150
5540
|
description: `The MARKED-UP PLANSET \u2014 the deliverable of every takeoff. Writes a distribution-ready PDF to disk: a legend cover (per-condition totals, swatches, a by-sheet breakdown) followed by every sheet that carries takeoff shapes or annotations, vector-copied from the source plan with the work burned in as drawn \u2014 condition colors and hatches, a quantity chip on every shape, annotation clouds/callouts/highlights. Built by the same module as the canvas's MARKED SET button, so agent output and app output are one implementation. A construction takeoff is no good without markup: finish EVERY takeoff by writing this file and giving the user its path (export_report carries the numbers for pricing; this carries the evidence). When the shapes were machine-traced and unreviewed, the document says so on its last page \u2014 the review path is importing the export_takeoff payload into the app, where agent shapes arrive as pencil proposals. Default path: next to the loaded plan as "<plan> - marked set.pdf". Needs no native canvas \u2014 pure vector copy, so it works even where view_sheet cannot render.`,
|
|
5151
5541
|
inputSchema: {
|
|
@@ -5342,7 +5732,7 @@ function parsePage(session, raw) {
|
|
|
5342
5732
|
function registerResources(server, session) {
|
|
5343
5733
|
const sheetEntries = (suffix, mimeType, what) => () => ({
|
|
5344
5734
|
resources: session.sheetList().map((s) => ({
|
|
5345
|
-
uri: `takeoff://sheet/${s.
|
|
5735
|
+
uri: `takeoff://sheet/${s.ord}${suffix}`,
|
|
5346
5736
|
name: `${s.key}${suffix.replace("/", " \xB7 ")}`,
|
|
5347
5737
|
...s.sheetNumber ? { title: `${s.sheetNumber} \u2014 ${what}` } : { title: `page ${s.pageNum} \u2014 ${what}` },
|
|
5348
5738
|
description: `${what} for ${s.key}${s.sheetNumber ? ` (${s.sheetNumber})` : ""}`,
|
|
@@ -5372,7 +5762,7 @@ function registerResources(server, session) {
|
|
|
5372
5762
|
async (uri, { page }) => {
|
|
5373
5763
|
const s = parsePage(session, page);
|
|
5374
5764
|
const idx = session.index();
|
|
5375
|
-
const row = idx.sheets.find((x) => x.
|
|
5765
|
+
const row = idx.sheets.find((x) => x.sheet === s.key);
|
|
5376
5766
|
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(row) }] };
|
|
5377
5767
|
}
|
|
5378
5768
|
);
|
|
@@ -5399,7 +5789,7 @@ function registerResources(server, session) {
|
|
|
5399
5789
|
},
|
|
5400
5790
|
async (uri, { page }) => {
|
|
5401
5791
|
const s = parsePage(session, page);
|
|
5402
|
-
const png = await session.renderSheetPng(s.
|
|
5792
|
+
const png = await session.renderSheetPng(s.ord);
|
|
5403
5793
|
return { contents: [{ uri: uri.href, mimeType: "image/png", blob: toBase64(png) }] };
|
|
5404
5794
|
}
|
|
5405
5795
|
);
|
|
@@ -5408,7 +5798,7 @@ function registerResources(server, session) {
|
|
|
5408
5798
|
// package.json
|
|
5409
5799
|
var package_default = {
|
|
5410
5800
|
name: "opentakeoff-mcp",
|
|
5411
|
-
version: "0.9.
|
|
5801
|
+
version: "0.9.18",
|
|
5412
5802
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
5413
5803
|
type: "module",
|
|
5414
5804
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -5424,7 +5814,7 @@ var package_default = {
|
|
|
5424
5814
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
5425
5815
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
5426
5816
|
typecheck: "tsc --noEmit",
|
|
5427
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
5817
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
5428
5818
|
},
|
|
5429
5819
|
dependencies: {
|
|
5430
5820
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "OpenTakeoff MCP server
|
|
6
|
+
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20"
|
|
@@ -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/resources.test.ts test/session.test.ts test/tools.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/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|