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 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. Replaces the whole session (old doc, scales, conditions, shapes all cleared). Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
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
- - One document per session; `load_plan` replaces it.
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.
@@ -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 = (arr) => {
642
- const a = arr.slice().sort((x, y) => x - y);
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 plan was loaded from — the marked-set export re-reads
2255
- * the source bytes here to vector-copy pages (pdf.js never hands them back). */
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
- doc = null;
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 replaces the session's document: the old doc is destroyed and
2280
- * ALL state — scales, caches, conditions, shapes — is cleared. */
2281
- async loadPlan(filePath) {
2282
- if (this.doc) await this.doc.destroy().catch(() => {
2283
- });
2284
- this.doc = null;
2285
- this.sheets.clear();
2286
- this.conditions = [];
2287
- this.shapes = [];
2288
- this.markups = [];
2289
- this.file = null;
2290
- this.filePath = null;
2291
- this.journal = [];
2292
- this.pendingCommits = [];
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
- this.doc = doc;
2296
- this.file = path2.basename(filePath);
2297
- this.filePath = path2.resolve(filePath);
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 ? this.file : `${this.file}#${n}`;
2301
- this.sheets.set(key, {
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: this.file,
2317
- page_count: doc.numPages,
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.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
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
- /** Resource-URI addressing: sheets by 1-based page number. */
2331
- sheetForPage(page) {
2332
- if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
2333
- for (const s of this.sheets.values()) if (s.pageNum === page) return s;
2334
- throw new UserError(`No page ${page} \u2014 the loaded plan has pages 1\u2013${this.sheets.size}.`);
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.pageNum - b.pageNum);
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.doc) {
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(page) {
2358
- const s = this.sheetForPage(page);
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.doc) {
2486
- const byId = new Map((await this.doc.layers()).map((g) => [g.id, g]));
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 { sheet: s.key, upp, ...label ? { label } : {}, source };
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
- return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
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
- return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {} };
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.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
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.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
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.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
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.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
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
- page_count: z.number().int(),
3474
- sheets: z.array(z.object(sheetSummary2)),
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 and what to do")
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 = (arr) => {
4746
+ const byKey = (arr2) => {
4542
4747
  const m = /* @__PURE__ */ new Map();
4543
- for (const s of arr) {
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 byPage = new Map(sheetStates.map((s) => [s.pageNum, s.page]));
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 (_file, pageNum) => {
4960
- const ph = byPage.get(pageNum);
4961
- if (!ph) throw new UserError(`No page ${pageNum} in the loaded plan.`);
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
- let srcBytes = null;
4972
- const loadPdfData = async () => srcBytes ??= new Uint8Array(await readFile2(filePath));
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 and replace the whole session (previous document, scales, conditions, and shapes are cleared). Returns file, page_count, and one entry per sheet: dims, title-block sheet_number, and the detected drawn scale where present. The loaded sheets also become browsable resources (takeoff://sheets). ${COORDS}`,
5025
- inputSchema: { path: z2.string().describe("Path to a plan PDF on disk") },
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: path4 }) => {
5028
- const loaded = await session.loadPlan(path4);
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.pageNum}${suffix}`,
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.page === s.pageNum);
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.pageNum);
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.12",
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.12",
3
+ "version": "0.9.18",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
- "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
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",