opentakeoff-mcp 0.9.84 → 0.9.86

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.
Files changed (2) hide show
  1. package/dist/server-core.js +203 -120
  2. package/package.json +2 -2
@@ -118,8 +118,65 @@ function memberEmbed(vpTransform, member, pageH, renderScale) {
118
118
  };
119
119
  }
120
120
 
121
- // ../web/src/lib/sheets.ts
121
+ // ../web/src/lib/takeoffConstants.ts
122
+ var TAKEOFF_SCHEMA = "opentakeoff.takeoff_canvas.v1";
123
+ var REPORT_SCHEMA = "opentakeoff.report.v1";
122
124
  var RENDER_SCALE = 2;
125
+ var PALETTE = Object.freeze([
126
+ "#c96442",
127
+ "#2f7d54",
128
+ "#2563eb",
129
+ "#9333ea",
130
+ "#b8860b",
131
+ "#0d9488",
132
+ "#be185d",
133
+ "#1f2937",
134
+ "#dc2626",
135
+ "#0891b2"
136
+ ]);
137
+ var HATCH_IDS = Object.freeze([
138
+ "solid",
139
+ "diag",
140
+ "diag2",
141
+ "cross",
142
+ "diagdense",
143
+ "horiz",
144
+ "vert",
145
+ "grid",
146
+ "brick",
147
+ "plank",
148
+ "herring",
149
+ "basket",
150
+ "checker",
151
+ "wave",
152
+ "dots",
153
+ "speckle",
154
+ "iso",
155
+ "honeycomb",
156
+ "scan",
157
+ "plus",
158
+ "circuit",
159
+ "topo",
160
+ "woodgrain",
161
+ "chevron",
162
+ "pinwheel",
163
+ "harlequin",
164
+ "hexagon",
165
+ "penny",
166
+ "octagondot",
167
+ "fleur",
168
+ "concrete"
169
+ ]);
170
+ var SNAP_CELL = 24;
171
+ var SNAP_TOL = 7;
172
+ function nextHatchId(conditionCount) {
173
+ return HATCH_IDS[1 + conditionCount % (HATCH_IDS.length - 1)];
174
+ }
175
+ function nextPaletteColor(conditionCount) {
176
+ return PALETTE[conditionCount % PALETTE.length];
177
+ }
178
+
179
+ // ../web/src/lib/sheets.ts
123
180
  function sheetBaseLabelFromKey(key) {
124
181
  if (typeof key !== "string" || !key || isStitchKey(key)) return "";
125
182
  const t = parseSheetKey(key);
@@ -1404,8 +1461,8 @@ function sweepHatchRuns(segs, meta, ws) {
1404
1461
  clusters.pop();
1405
1462
  }
1406
1463
  }
1407
- const median2 = (arr2) => {
1408
- const a = arr2.slice().sort((x, y) => x - y);
1464
+ const median2 = (arr3) => {
1465
+ const a = arr3.slice().sort((x, y) => x - y);
1409
1466
  return a[a.length >> 1];
1410
1467
  };
1411
1468
  for (const members of clusters) {
@@ -1533,8 +1590,8 @@ function classifyOffsetAnnotationSegs(segs, meta, ws, maxOffsetPx, minOffsetPx,
1533
1590
  t1: Math.max(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
1534
1591
  w: c.w
1535
1592
  };
1536
- const arr2 = bins.get(b);
1537
- if (arr2) arr2.push(r);
1593
+ const arr3 = bins.get(b);
1594
+ if (arr3) arr3.push(r);
1538
1595
  else bins.set(b, [r]);
1539
1596
  }
1540
1597
  for (const runs of bins.values()) {
@@ -1711,8 +1768,8 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1711
1768
  t0: Math.min(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy),
1712
1769
  t1: Math.max(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy)
1713
1770
  };
1714
- const arr2 = bins.get(b);
1715
- if (arr2) arr2.push(r);
1771
+ const arr3 = bins.get(b);
1772
+ if (arr3) arr3.push(r);
1716
1773
  else bins.set(b, [r]);
1717
1774
  }
1718
1775
  for (const [b, rows] of bins) {
@@ -6601,12 +6658,12 @@ function applyRuleToProject(rule, shapes, sheetData) {
6601
6658
  const d = sheetData.get(s.sheet_id);
6602
6659
  if (!d) continue;
6603
6660
  const ring = s.verts_norm.map(([nx, ny]) => [nx * d.imgW, ny * d.imgH]);
6604
- let arr2 = deductsBySheet.get(s.sheet_id);
6605
- if (!arr2) {
6606
- arr2 = [];
6607
- deductsBySheet.set(s.sheet_id, arr2);
6661
+ let arr3 = deductsBySheet.get(s.sheet_id);
6662
+ if (!arr3) {
6663
+ arr3 = [];
6664
+ deductsBySheet.set(s.sheet_id, arr3);
6608
6665
  }
6609
- arr2.push(ring);
6666
+ arr3.push(ring);
6610
6667
  }
6611
6668
  const out = [];
6612
6669
  const acceptedBySheet = /* @__PURE__ */ new Map();
@@ -6631,12 +6688,12 @@ function applyRuleToProject(rule, shapes, sheetData) {
6631
6688
  verts_norm: ring.map(([x, y]) => [x / d.imgW, y / d.imgH]),
6632
6689
  area_sf: +areaSf.toFixed(2)
6633
6690
  });
6634
- let arr2 = acceptedBySheet.get(room.sheet_id);
6635
- if (!arr2) {
6636
- arr2 = [];
6637
- acceptedBySheet.set(room.sheet_id, arr2);
6691
+ let arr3 = acceptedBySheet.get(room.sheet_id);
6692
+ if (!arr3) {
6693
+ arr3 = [];
6694
+ acceptedBySheet.set(room.sheet_id, arr3);
6638
6695
  }
6639
- arr2.push(ring);
6696
+ arr3.push(ring);
6640
6697
  }
6641
6698
  }
6642
6699
  return out;
@@ -6937,7 +6994,7 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
6937
6994
  const colDefs = (Array.isArray(conditionColumns) ? conditionColumns : []).filter((cc) => cc && typeof cc === "object" && typeof cc.id === "string");
6938
6995
  const attrs = attrsByCond instanceof Map ? attrsByCond : /* @__PURE__ */ new Map();
6939
6996
  return {
6940
- schema: "opentakeoff.report.v1",
6997
+ schema: REPORT_SCHEMA,
6941
6998
  project_name: projectName || null,
6942
6999
  generated_with: "OpenTakeoff",
6943
7000
  // scale_confirmed (scale gate): false = an agent set this sheet's scale and
@@ -7655,15 +7712,82 @@ function drawMarks(ctx, toCanvas, marks, longEdge) {
7655
7712
  return drawn;
7656
7713
  }
7657
7714
 
7715
+ // ../web/src/lib/takeoffDocument.js
7716
+ var arr = (v) => Array.isArray(v) ? v : [];
7717
+ var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
7718
+ var TAKEOFF_DOCUMENT_KEYS = Object.freeze([
7719
+ "schema",
7720
+ "project_name",
7721
+ "units",
7722
+ "client_info",
7723
+ "sheets",
7724
+ "conditions",
7725
+ "condition_columns",
7726
+ "shape_labels",
7727
+ "palette",
7728
+ "shapes",
7729
+ "markups",
7730
+ "rfis",
7731
+ "approvals",
7732
+ "proposals",
7733
+ "condition_edit_proposals",
7734
+ "rules",
7735
+ "sheet_group",
7736
+ "last_group",
7737
+ "sheet_tabs",
7738
+ "stitches",
7739
+ "sheet_levels",
7740
+ "layer_overrides",
7741
+ "provenance_counters"
7742
+ ]);
7743
+ function sheetEntry({ sheet_id, units_per_px, scale_source = void 0, scale_confirmed = void 0 }) {
7744
+ return {
7745
+ sheet_id,
7746
+ units_per_px,
7747
+ ...scale_source ? { scale_source } : {},
7748
+ ...scale_confirmed === false ? { scale_confirmed: false } : {}
7749
+ };
7750
+ }
7751
+ function buildTakeoffDocument(f = {}) {
7752
+ const conditions = arr(f.conditions);
7753
+ const pinned = arr(f.palette).filter((id) => conditions.some((c) => c && c.id === id));
7754
+ const clientInfo = obj(f.client_info);
7755
+ const hasClient = Object.values(clientInfo).some((v) => v && String(v).trim());
7756
+ const sheetLevels = obj(f.sheet_levels);
7757
+ const layerOverrides = obj(f.layer_overrides);
7758
+ const prov = f.provenance_counters;
7759
+ const hasProv = !!(prov && prov.shapes_deleted && Object.keys(prov.shapes_deleted).length);
7760
+ return {
7761
+ schema: TAKEOFF_SCHEMA,
7762
+ project_name: typeof f.project_name === "string" ? f.project_name : "",
7763
+ ...f.units === "metric" ? { units: "metric" } : {},
7764
+ ...hasClient ? { client_info: clientInfo } : {},
7765
+ sheets: arr(f.sheets),
7766
+ conditions,
7767
+ ...arr(f.condition_columns).length ? { condition_columns: f.condition_columns } : {},
7768
+ ...arr(f.shape_labels).length ? { shape_labels: f.shape_labels } : {},
7769
+ ...pinned.length ? { palette: pinned } : {},
7770
+ shapes: arr(f.shapes),
7771
+ markups: arr(f.markups),
7772
+ rfis: arr(f.rfis),
7773
+ ...arr(f.approvals).length ? { approvals: f.approvals } : {},
7774
+ ...arr(f.proposals).length ? { proposals: f.proposals } : {},
7775
+ ...arr(f.condition_edit_proposals).length ? { condition_edit_proposals: f.condition_edit_proposals } : {},
7776
+ ...arr(f.rules).length ? { rules: f.rules } : {},
7777
+ sheet_group: arr(f.sheet_group),
7778
+ last_group: arr(f.last_group),
7779
+ sheet_tabs: arr(f.sheet_tabs),
7780
+ ...arr(f.stitches).length ? { stitches: f.stitches } : {},
7781
+ ...Object.keys(sheetLevels).length ? { sheet_levels: sheetLevels } : {},
7782
+ ...Object.keys(layerOverrides).length ? { layer_overrides: layerOverrides } : {},
7783
+ ...hasProv ? { provenance_counters: prov } : {}
7784
+ };
7785
+ }
7786
+
7658
7787
  // src/session.ts
7659
- var SNAP_CELL = 24;
7660
- var SNAP_TOL = 7;
7661
- var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
7662
- var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo", "woodgrain", "chevron", "pinwheel", "harlequin", "hexagon", "penny", "octagondot", "fleur", "concrete"];
7663
7788
  var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
7664
7789
  var uid = (p) => `${p}-${mintUuid2()}`;
7665
7790
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
7666
- var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
7667
7791
  var sanitizeApprovals2 = sanitizeApprovals;
7668
7792
  var applyApprovalCommand2 = applyApprovalCommand;
7669
7793
  var CONTEXT_MIN_LEN_PX = 2;
@@ -8374,13 +8498,14 @@ var Session = class _Session {
8374
8498
  conditionFor(tag) {
8375
8499
  let c = this.conditions.find((x) => x.finish_tag === tag);
8376
8500
  if (!c) {
8377
- const lc = PALETTE[this.conditions.length % PALETTE.length];
8501
+ const lc = nextPaletteColor(this.conditions.length);
8378
8502
  c = {
8379
8503
  id: uid("cnd"),
8504
+ created_at: nowIso2(),
8380
8505
  finish_tag: tag,
8381
8506
  color: lc,
8382
8507
  fill: lc,
8383
- hatch: HATCH_IDS[1 + this.conditions.length % (HATCH_IDS.length - 1)],
8508
+ hatch: nextHatchId(this.conditions.length),
8384
8509
  multiplier: 1,
8385
8510
  waste_pct: 0,
8386
8511
  materials: []
@@ -9584,9 +9709,9 @@ var Session = class _Session {
9584
9709
  }
9585
9710
  const tableRegions = /* @__PURE__ */ new Map();
9586
9711
  for (const tb of graph.tables) {
9587
- const arr2 = tableRegions.get(tb.sheet) ?? [];
9588
- arr2.push(tb.region);
9589
- tableRegions.set(tb.sheet, arr2);
9712
+ const arr3 = tableRegions.get(tb.sheet) ?? [];
9713
+ arr3.push(tb.region);
9714
+ tableRegions.set(tb.sheet, arr3);
9590
9715
  }
9591
9716
  const VAL_RE = /^[0-9][0-9,]{0,6}$/;
9592
9717
  const perMark = /* @__PURE__ */ new Map();
@@ -10793,7 +10918,7 @@ var Session = class _Session {
10793
10918
  tag: newTag,
10794
10919
  mintId: (p) => uid(p),
10795
10920
  nowIso: nowIso2,
10796
- nextHatch: HATCH_IDS[1 + (this.conditions.length + 1) % (HATCH_IDS.length - 1)]
10921
+ nextHatch: nextHatchId(this.conditions.length + 1)
10797
10922
  });
10798
10923
  if (parentPatch) Object.assign(src, parentPatch);
10799
10924
  this.conditions.push(twin);
@@ -11452,41 +11577,18 @@ var Session = class _Session {
11452
11577
  }
11453
11578
  exportPayload() {
11454
11579
  if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
11455
- return {
11456
- schema: ANN_SCHEMA,
11580
+ return buildTakeoffDocument({
11457
11581
  project_name: "",
11458
11582
  units: "imperial",
11459
- sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({
11460
- sheet_id: s.key,
11461
- units_per_px: s.upp,
11462
- // provenance rides the payload (it used to be dropped here): the canvas
11463
- // hydrates scale_source for its report and scale_confirmed for the
11464
- // scale gate's confirm affordance — absent = confirmed (pre-flag docs)
11465
- ...s.scaleSource ? { scale_source: s.scaleSource } : {},
11466
- ...s.scaleConfirmed === false ? { scale_confirmed: false } : {}
11467
- })),
11583
+ sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => sheetEntry({ sheet_id: s.key, units_per_px: s.upp, scale_source: s.scaleSource, scale_confirmed: s.scaleConfirmed })),
11468
11584
  conditions: this.conditions,
11469
11585
  shapes: this.shapes,
11470
11586
  markups: this.markups,
11471
- // approvals ride the payload additively (#176) — present only when any
11472
- // exist, exactly the canvas buildPayload's convention, so a verdict-free
11473
- // export stays byte-identical to a pre-#176 one
11474
- ...this.approvals.length ? { approvals: this.approvals } : {},
11475
- // RFIs (#364): the panel's own records, tombstones stripped — the app
11476
- // has no tombstone notion (its delete is a removal), and a withdrawn
11477
- // question must not resurface there as a stray Void row. Same
11478
- // present-only-when-any convention as approvals.
11479
- ...this.liveRfis().length ? { rfis: this.liveRfis() } : {},
11480
- // proposals (#365): the batches and the pending condition diffs ride
11481
- // the payload as transport — present only when any exist, so a
11482
- // proposal-free export stays byte-identical to a pre-#365 one.
11483
- ...this.proposals.length ? { proposals: this.proposals } : {},
11484
- ...this.conditionEditProposals.length ? { condition_edit_proposals: this.conditionEditProposals } : {},
11485
- sheet_group: [],
11486
- last_group: [],
11487
- sheet_tabs: [],
11488
- sheet_levels: {}
11489
- };
11587
+ rfis: this.liveRfis(),
11588
+ approvals: this.approvals,
11589
+ proposals: this.proposals,
11590
+ condition_edit_proposals: this.conditionEditProposals
11591
+ });
11490
11592
  }
11491
11593
  /** The computed Report document — "opentakeoff.report.v1", the SAME schema
11492
11594
  * and math as the canvas Report's JSON export (web reportJson, totals.js):
@@ -12116,7 +12218,7 @@ var exportDxfOutput = {
12116
12218
  var exportTakeoffOutput = {
12117
12219
  schema: z.string(),
12118
12220
  project_name: z.string(),
12119
- units: z.string(),
12221
+ units: z.string().optional().describe("Present only for a metric project; absent means imperial \u2014 the app's own diff-only convention"),
12120
12222
  sheets: z.array(z.object({
12121
12223
  sheet_id: z.string(),
12122
12224
  units_per_px: z.number(),
@@ -12148,7 +12250,7 @@ var exportTakeoffOutput = {
12148
12250
  sheet_group: z.array(z.unknown()),
12149
12251
  last_group: z.array(z.unknown()),
12150
12252
  sheet_tabs: z.array(z.unknown()),
12151
- sheet_levels: z.object({}).passthrough(),
12253
+ sheet_levels: z.object({}).passthrough().optional().describe("Present only when a sheet carries a level label (the app omits it when empty)"),
12152
12254
  proposals: z.array(z.object({ id: z.string(), label: z.string(), rationale: z.string(), created_at: z.string(), withdrawn_at: z.string().optional() }).passthrough()).optional().describe("Proposal batches (#365) \u2014 present only when any exist. Shapes reference them by origin.proposal_id; the canvas shows one Accept per batch"),
12153
12255
  condition_edit_proposals: z.array(z.object({ id: z.string(), condition_id: z.string(), proposed: z.object({}).passthrough(), rationale: z.string(), proposed_at: z.string() }).passthrough()).optional().describe("Pending condition-edit diffs (#365) \u2014 present only when any exist. Nothing on the condition changes until the estimator accepts in the canvas")
12154
12256
  };
@@ -12358,7 +12460,7 @@ var reportMaterialLine = z.object({
12358
12460
  qty: z.number().describe("Computed order quantity")
12359
12461
  }).passthrough();
12360
12462
  var exportReportOutput = {
12361
- schema: z.literal("opentakeoff.report.v1"),
12463
+ schema: z.literal(REPORT_SCHEMA),
12362
12464
  project_name: z.string().nullable(),
12363
12465
  generated_with: z.string(),
12364
12466
  sheets: z.array(z.object({ sheet_id: z.string(), sheet: z.string(), scale_source: z.string() }).passthrough()).describe("Scale provenance per sheet \u2014 how each scale was set"),
@@ -13467,9 +13569,9 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
13467
13569
  const { PDFDocument, StandardFonts, rgb, degrees, LineCapStyle } = await import("pdf-lib");
13468
13570
  const condById = Object.fromEntries(conditions.map((c) => [c.id, c]));
13469
13571
  const rfiNum = new Map((rfis || []).map((r) => [r.id, r.number]));
13470
- const byKey = (arr2) => {
13572
+ const byKey = (arr3) => {
13471
13573
  const m = /* @__PURE__ */ new Map();
13472
- for (const s of arr2) {
13574
+ for (const s of arr3) {
13473
13575
  const a = m.get(s.sheet_id) || [];
13474
13576
  a.push(s);
13475
13577
  m.set(s.sheet_id, a);
@@ -14182,25 +14284,6 @@ async function exportMarkedPdf(session, opts) {
14182
14284
  import path4 from "node:path";
14183
14285
  import { readFile as readFile3 } from "node:fs/promises";
14184
14286
 
14185
- // ../web/src/lib/stamps.js
14186
- var DEFAULT_STAMPS = [
14187
- { id: "stmp-direction", name: "Plank / tile direction", elements: [
14188
- { type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#1f3fc7", weight: 1.5 }
14189
- ] },
14190
- { id: "stmp-seam", name: "Seam direction", elements: [
14191
- { type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#b03a26", line_style: "dashed" }
14192
- ] },
14193
- { id: "stmp-origin", name: "Pattern origin", elements: [
14194
- { type: "bubble", at: [0, 0], r: 0.018, text: "PO", color: "#0d9488" }
14195
- ] }
14196
- ];
14197
- var DEFAULT_STAMP_SETS = [
14198
- { id: "set-flooring", name: "Flooring shop drawings", stampIds: DEFAULT_STAMPS.map((s) => s.id) }
14199
- ];
14200
-
14201
- // ../web/src/lib/store.js
14202
- var ANN_SCHEMA2 = "opentakeoff.takeoff_canvas.v1";
14203
-
14204
14287
  // ../web/src/lib/reviewState.js
14205
14288
  function normalizeAgentReview(shape) {
14206
14289
  return shape?.origin?.actor === "agent" && shape.origin.reviewed == null ? { ...shape, origin: { ...shape.origin, reviewed: false } } : shape;
@@ -14214,12 +14297,12 @@ function parseTakeoffImport(text) {
14214
14297
  } catch {
14215
14298
  throw new Error("Couldn't import takeoff: that file is not valid JSON.");
14216
14299
  }
14217
- if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.schema !== ANN_SCHEMA2) {
14218
- throw new Error(`Couldn't import takeoff: not a takeoff export (expected schema "${ANN_SCHEMA2}" \u2014 the file export_takeoff or the app writes).`);
14300
+ if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.schema !== TAKEOFF_SCHEMA) {
14301
+ throw new Error(`Couldn't import takeoff: not a takeoff export (expected schema "${TAKEOFF_SCHEMA}" \u2014 the file export_takeoff or the app writes).`);
14219
14302
  }
14220
14303
  return doc;
14221
14304
  }
14222
- var arr = (v) => Array.isArray(v) ? v : [];
14305
+ var arr2 = (v) => Array.isArray(v) ? v : [];
14223
14306
  var tagKey = (t) => String(t || "").trim().toUpperCase();
14224
14307
  var freeId = (id, taken) => {
14225
14308
  let n = 2, next = id;
@@ -14228,11 +14311,11 @@ var freeId = (id, taken) => {
14228
14311
  };
14229
14312
  function mergeTakeoffImport(current, imported, knownFiles = null) {
14230
14313
  const cur = current && typeof current === "object" ? current : {};
14231
- const impShapes = arr(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string").map(normalizeAgentReview);
14232
- const impConds = arr(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
14233
- const localScales = new Map(arr(cur.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14234
- const incomingScales = new Map(arr(imported.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14235
- const existingIds = new Set(arr(cur.shapes).map((s) => s.id));
14314
+ const impShapes = arr2(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string").map(normalizeAgentReview);
14315
+ const impConds = arr2(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
14316
+ const localScales = new Map(arr2(cur.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14317
+ const incomingScales = new Map(arr2(imported.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14318
+ const existingIds = new Set(arr2(cur.shapes).map((s) => s.id));
14236
14319
  for (const s of impShapes) {
14237
14320
  if (existingIds.has(s.id) || s.measure_role === "count") continue;
14238
14321
  const local = localScales.get(s.sheet_id), incoming = incomingScales.get(s.sheet_id);
@@ -14246,20 +14329,20 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
14246
14329
  return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
14247
14330
  };
14248
14331
  const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
14249
- if (!arr(cur.shapes).length && !arr(cur.markups).length && !arr(cur.approvals).length && !arr(cur.sheets).some((s) => s?.units_per_px > 0)) {
14332
+ if (!arr2(cur.shapes).length && !arr2(cur.markups).length && !arr2(cur.approvals).length && !arr2(cur.sheets).some((s) => s?.units_per_px > 0)) {
14250
14333
  const payload2 = {
14251
14334
  ...imported,
14252
14335
  shapes: impShapes,
14253
- ...arr(imported.sheet_tabs).length ? {} : { sheet_tabs: arr(cur.sheet_tabs) },
14254
- ...arr(imported.sheet_group).length ? {} : { sheet_group: arr(cur.sheet_group) },
14255
- ...arr(imported.last_group).length ? {} : { last_group: arr(cur.last_group) }
14336
+ ...arr2(imported.sheet_tabs).length ? {} : { sheet_tabs: arr2(cur.sheet_tabs) },
14337
+ ...arr2(imported.sheet_group).length ? {} : { sheet_group: arr2(cur.sheet_group) },
14338
+ ...arr2(imported.last_group).length ? {} : { last_group: arr2(cur.last_group) }
14256
14339
  };
14257
14340
  return {
14258
14341
  payload: payload2,
14259
- 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) }
14342
+ note: { replaced: true, shapes_added: impShapes.length, shapes_pending: pendingCount(impShapes), conditions_merged: 0, conditions_added: impConds.length, scales_adopted: arr2(imported.sheets).length, unknown_files: unknownFiles(impShapes) }
14260
14343
  };
14261
14344
  }
14262
- const conditions = [...arr(cur.conditions)];
14345
+ const conditions = [...arr2(cur.conditions)];
14263
14346
  const byTag = new Map(conditions.map((c) => [tagKey(c.finish_tag), c.id]));
14264
14347
  const condIds = new Set(conditions.map((c) => c.id));
14265
14348
  const condMap = /* @__PURE__ */ new Map();
@@ -14295,23 +14378,23 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
14295
14378
  conditions[i] = next;
14296
14379
  }
14297
14380
  }
14298
- const shapeIds = new Set(arr(cur.shapes).map((s) => s.id));
14381
+ const shapeIds = new Set(arr2(cur.shapes).map((s) => s.id));
14299
14382
  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);
14300
- const markupIds = new Set(arr(cur.markups).map((m) => m?.id).filter(Boolean));
14301
- const addedMarkups = arr(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
14302
- const rfiIds = new Set(arr(cur.rfis).map((r) => r?.id).filter(Boolean));
14303
- const addedRfis = arr(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
14304
- const proposalIds = new Set(arr(cur.proposals).map((p) => p?.id).filter(Boolean));
14305
- const addedProposals = arr(imported.proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !proposalIds.has(p.id));
14306
- const editIds = new Set(arr(cur.condition_edit_proposals).map((p) => p?.id).filter(Boolean));
14383
+ const markupIds = new Set(arr2(cur.markups).map((m) => m?.id).filter(Boolean));
14384
+ const addedMarkups = arr2(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
14385
+ const rfiIds = new Set(arr2(cur.rfis).map((r) => r?.id).filter(Boolean));
14386
+ const addedRfis = arr2(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
14387
+ const proposalIds = new Set(arr2(cur.proposals).map((p) => p?.id).filter(Boolean));
14388
+ const addedProposals = arr2(imported.proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !proposalIds.has(p.id));
14389
+ const editIds = new Set(arr2(cur.condition_edit_proposals).map((p) => p?.id).filter(Boolean));
14307
14390
  const takenTags = new Set(conditions.map((c) => tagKey(c.finish_tag)));
14308
- const addedEdits = arr(imported.condition_edit_proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !editIds.has(p.id) && p.proposed && typeof p.proposed === "object").map((p) => condMap.has(p.condition_id) ? { ...p, condition_id: condMap.get(p.condition_id) } : p).filter((p) => condIds.has(p.condition_id)).filter((p) => p.proposed.finish_tag === void 0 || !takenTags.has(tagKey(p.proposed.finish_tag)) || tagKey(p.proposed.finish_tag) === tagKey(conditions.find((c) => c.id === p.condition_id)?.finish_tag)).filter((p) => !arr(cur.condition_edit_proposals).some((q) => q?.condition_id === p.condition_id));
14309
- const approvalIds = new Set(arr(cur.approvals).map((a) => a?.id).filter(Boolean));
14391
+ const addedEdits = arr2(imported.condition_edit_proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !editIds.has(p.id) && p.proposed && typeof p.proposed === "object").map((p) => condMap.has(p.condition_id) ? { ...p, condition_id: condMap.get(p.condition_id) } : p).filter((p) => condIds.has(p.condition_id)).filter((p) => p.proposed.finish_tag === void 0 || !takenTags.has(tagKey(p.proposed.finish_tag)) || tagKey(p.proposed.finish_tag) === tagKey(conditions.find((c) => c.id === p.condition_id)?.finish_tag)).filter((p) => !arr2(cur.condition_edit_proposals).some((q) => q?.condition_id === p.condition_id));
14392
+ const approvalIds = new Set(arr2(cur.approvals).map((a) => a?.id).filter(Boolean));
14310
14393
  const addedApprovals = sanitizeApprovals(imported.approvals).filter((a) => !approvalIds.has(a.id));
14311
- const sheets = [...arr(cur.sheets)];
14394
+ const sheets = [...arr2(cur.sheets)];
14312
14395
  const scaled = new Set(sheets.map((s) => s?.sheet_id));
14313
14396
  let scalesAdopted = 0;
14314
- for (const s of arr(imported.sheets)) {
14397
+ for (const s of arr2(imported.sheets)) {
14315
14398
  if (s && typeof s === "object" && s.sheet_id && s.units_per_px && !scaled.has(s.sheet_id)) {
14316
14399
  sheets.push(s);
14317
14400
  scaled.add(s.sheet_id);
@@ -14321,12 +14404,12 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
14321
14404
  const payload = {
14322
14405
  ...cur,
14323
14406
  conditions,
14324
- shapes: [...arr(cur.shapes), ...addedShapes],
14325
- markups: [...arr(cur.markups), ...addedMarkups],
14326
- ...addedRfis.length ? { rfis: [...arr(cur.rfis), ...addedRfis] } : {},
14327
- ...addedProposals.length ? { proposals: [...arr(cur.proposals), ...addedProposals] } : {},
14328
- ...addedEdits.length ? { condition_edit_proposals: [...arr(cur.condition_edit_proposals), ...addedEdits] } : {},
14329
- ...addedApprovals.length ? { approvals: [...arr(cur.approvals), ...addedApprovals] } : {},
14407
+ shapes: [...arr2(cur.shapes), ...addedShapes],
14408
+ markups: [...arr2(cur.markups), ...addedMarkups],
14409
+ ...addedRfis.length ? { rfis: [...arr2(cur.rfis), ...addedRfis] } : {},
14410
+ ...addedProposals.length ? { proposals: [...arr2(cur.proposals), ...addedProposals] } : {},
14411
+ ...addedEdits.length ? { condition_edit_proposals: [...arr2(cur.condition_edit_proposals), ...addedEdits] } : {},
14412
+ ...addedApprovals.length ? { approvals: [...arr2(cur.approvals), ...addedApprovals] } : {},
14330
14413
  sheets
14331
14414
  };
14332
14415
  return {
@@ -15055,7 +15138,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
15055
15138
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
15056
15139
 
15057
15140
  // src/wiki.generated.ts
15058
- var WIKI_VERSION = "0.9.84";
15141
+ var WIKI_VERSION = "0.9.86";
15059
15142
  var WIKI_PAGES = [
15060
15143
  {
15061
15144
  "key": "index",
@@ -15118,8 +15201,8 @@ var WIKI_PAGES = [
15118
15201
  "uri": "takeoff://wiki/repo-guide",
15119
15202
  "title": "Working on the repository",
15120
15203
  "source": "docs/wiki/repo-guide.md",
15121
- "source_sha256": "f7ed92fc2e49cbb1e00b7e90335120dccd226bd837d1d7064c38bc9e74500cf7",
15122
- "text": "# Working on the repository\n\nOpenTakeoff is a **client-only React app**: a PDF construction-takeoff canvas for flooring (useful for any trade). No backend, no database, no auth\u2014everything runs and persists in the browser. Apache-2.0. (For the one-page project pitch and vision, see [`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md); for capability \u2192 code mapping, see [`FEATURES.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/FEATURES.md).)\n\n## Run / build / check\n\n```bash\ncd web\nnvm use # web/.nvmrc matches the root .nvmrc used by CI\nnpm install\nnpm run dev # http://localhost:5173 \u2014 hot reload\nnpm test # node:test over the pure geometry + totals math (test/*.test.ts)\nnpm run build # \u2192 web/dist/ (static output; this is what Netlify deploys)\nnpm run check # web typecheck + lint + test + benchmark + build\n```\n\n## Shipping \u2014 the required steps, every change\n\n`main` is protected on GitHub by a ruleset (PR-only, one approving review,\ngreen `web` check\u2014the repo owner has a standing bypass\nas the solo maintainer). **Merging to `main` deploys to production**\n(<https://opentakeoff.kentucky-ai.com>)\u2014Netlify's own git integration builds\nthe merge commit and publishes it. `netlify.toml` holds the whole recipe\n(`base = \"web\"`, `command = npm run build`, `publish = \"dist\"`), so the deploy\nruns a fresh build from the merged source; nothing is uploaded from CI.\n\n`.github/workflows/deploy.yml` used to do this by publishing `web/dist` with\n`--no-build`, and it was **deleted on 2026-07-13 in `e701f1a`** (\"deploys here\nare manual CLI; it fails on every push without the fork's secrets\"). Current checks and release automation live in\n[the workflow directory](https://github.com/Kentucky-ai/opentakeoff/blob/main/.github/workflows); `publish-mcp.yml` fires on\nan `mcp-v*` tag. The old line here said \"Netlify never builds anything\nitself\", which is now exactly backwards\u2014Netlify is the only thing that\nbuilds production. **Merge = deploy either way: that part has never changed.**\n\n> **This is the canonical `Kentucky-ai/opentakeoff` repo\u2014production is\n> <https://opentakeoff.kentucky-ai.com>, nothing else.** A downstream fork\n> (`knmurphy/opentakeoff`) tracks this repo as its own upstream and deploys\n> separately to `takeoff.345flooring.com`\u2014that URL belongs to *that* fork,\n> not this repo. If you see `takeoff.345flooring.com` referenced elsewhere in\n> this repo's docs, it's either an example value in the optional cloud-mode\n> guides or leftover content from that fork's docs that rode along in a\n> wholesale history merge (2026-07-13)\u2014treat it as describing the\n> *downstream* fork's deployment, not this one's.\n\nSo:\n\n1. **Branch first**\u2014never commit on `main`: `git checkout -b <topic>`.\n2. **`npm run check` before pushing** (in `web/`). It covers the independent web\n job on Node 24; a green web check does not replace the MCP, protocol, docs,\n capture or optional-server jobs.\n3. **Include review evidence in every PR**: screenshots or a short video for\n visible changes; measured expected-versus-observed stats and reproducible\n commands for engine/tool changes. Link the actual checks or evidence. This\n repository is also the maintainer's public portfolio: keep claims verifiable\n and private project data out of public artifacts.\n4. **Open a PR** and wait for the `web` check to pass. Don't merge red or\n pending.\n5. **Squash-merge with branch delete**\n (`gh pr merge <n> --squash --delete-branch`), then\n `git checkout main && git pull --ff-only` and delete the local branch\n (`git branch -D <topic>`\u2014squash merges need `-D`).\n6. **Remember a merge is a deploy.** Don't merge work you haven't verified in\n the running app.\n\nThe tests cover the pure math (`web/test/geometry.test.ts`, `web/test/totals.test.ts`); the canvas itself is verified by hand\u2014**Vite does not flag undefined identifiers in JSX**, so grep for your new identifiers after editing and load the app once before you call it done. The bundled sample plan (`web/public/demo/`, wired to the \"Load sample plan\" button) is the fastest end-to-end check: load it, press `A`, trace a room, open Report.\n\n## Where things live\n\n| Concern | Path |\n|---|---|\n| **The canvas\u201490% of the app** | `web/src/pages/TakeoffCanvas.jsx` (one large, deliberately monolithic component) |\n| Geometry: vector extraction, One-Click flood fill, vertex snap | `web/src/lib/oneclick.ts` |\n| Sheet/page helpers, scale detection | `web/src/lib/sheets.ts` |\n| Totals and materials math (waste, SY, coverage \u2192 order qty) | `web/src/lib/totals.js` |\n| Persistence (IndexedDB + localStorage) | `web/src/lib/store.js` |\n| PDF/image/zip ingest | `web/src/lib/ingest.js` |\n| Icon set | `web/src/brand/icons.jsx` |\n| Design tokens (colors, spacing\u2014the source of truth) | `web/src/styles/tokens.css` |\n| Sheet gallery / report UI | `web/src/components/` |\n| Pure-math tests (node:test) | `web/test/` |\n| **Optional AI backend** (pluggable adapter: scale/room/finish suggestions) | `server/`\u2014`app.py` + `adapters/base.py` (interface) + `adapters/heuristic.py` (default, no model) |\n\n## How the canvas works (the mental model)\n\n- Each open sheet renders into a `<canvas>` bitmap; **all takeoff geometry is an SVG overlay** on top; pan/zoom is a single CSS transform on the stage div, written imperatively (`tfRef` \u2192 `style.transform`) to avoid React re-renders per frame.\n- Coordinates: pointer events (client px) \u2192 `toImage()` \u2192 **stage px**; committed shapes store **normalized [0..1] vertices per sheet** (`verts_norm`), so quantities survive re-renders and zoom.\n- Cursor-following UI (crosshair hairlines, readout chip, rubber band) updates through **direct DOM writes in `moveCrosshair`**\u2014never React state per mousemove. Keep it that way.\n- Angle snapping: `angleSnap()` locks in-progress segments to the 45\xB0 family; endpoint snap (`nearestSnap` over a spatial hash of PDF vector endpoints) takes priority. The committed click reuses the same locked point (`angleRef`).\n- Past ~1.15\xD7 zoom, a **detail-view canvas** re-renders the visible region from PDF vectors at the current zoom (crispness); the base bitmap stays as first paint.\n- pdf.js rendering schedules work on `requestAnimationFrame`\u2014a fully hidden/occluded window will pause mid-render by design; it resumes when visible.\n\n## Conventions\n\n- **SVG presentation attributes take literal colors** (CSS vars don't resolve there): cobalt `#1f3fc7`, danger `#b03a26`, positive `#1f6b4a`\u2014centralized in `web/src/lib/ui.js` (`SVG`, with HUD-dark counterparts through `svgAccent(isDark)`). DOM/HTML chrome may use `var(--\u2026)` from `tokens.css`.\n- Condition palettes (`PALETTE` in `web/src/components/hatches.jsx`, the seeded condition colors in `FLOORING_DEFAULTS` in `web/src/lib/canvasConstants.js`, and the mirrored copies in `mcp/src/session.ts`) are **user data**\u2014don't re-theme them.\n- Waste applies only in the report (order quantities), never to live measured numbers.\n- Keyboard shortcuts are single letters registered on `window` (see `docs/USER_GUIDE.md` \xA715); toolbar menus pause them through `menuDepthRef`.\n- Brand voice: **precision instrument** (2026-08 overhaul). Light theme = \"ice\": bright white surfaces on a cool field, cool-slate neutrals, cobalt the one saturated thing. Dark theme = \"HUD\": true-black cockpit, electric blue `#3f8cff`, phosphor `--glow` on exactly five elements (active tool face, status verb, hero quantity, primary CTA, calibration dot). Square corners; the single sanctioned radius is `--r-1` on floating chrome. Mono tabular numerals on every readout. Drafting-table language stays. No vendor mimicry.\n- Layout/spacing/type come from the token scales in `tokens.css` (`--sp-*`, `--fs-*`, `--ctl-*`); zIndex comes from the `Z` ladder in `web/src/lib/ui.js`. No new magic numbers.\n\n## Docs to keep in sync when you change behavior\n\nThe draft Takeoff Protocol lives in [`protocol/`](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/README.md). When\nchanging persisted fields, consult its [inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) and\n[compatibility report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md). Run\n`npm run check --prefix protocol` after installing web, MCP, and protocol\ndependencies. Refresh schema references with\n`node protocol/scripts/check-docs.mjs --write`. The draft does not authorize\nchanging writer formats or adopting a migration.\n\n1. `README.md` (Features + \"What's in the box\")\n2. `docs/USER_GUIDE.md` (shortcuts + the relevant section)\n3. `CHANGELOG.md`\n\nTouching the **MCP server** also requires checking its runtime guidance and\ngenerated references:\n\n4. `mcp/src/tools.ts`\u2014the tool's own `description` is part of its integration.\n An MCP client reads it at runtime. Initialize instructions and packaged wiki\n resources are also agent-facing surfaces, so keep them consistent with the\n runtime behavior.\n5. `mcp/server.ts`\u2014the `instructions` block sent at `initialize`. This is the\n decision tree every client receives before its first call. A new *verb* does\n not belong here; a new *step in the standard finish* does. Packaged wiki\n resources are another deliberate agent-facing surface; update their source\n pages and run the wiki/resource checks when their guidance changes.\n6. `mcp/README.md` (the tool table) and `docs/MCP.md` (the reach-for-it ordering,\n the example session, and the tool count in its opening line). A new tool also\n needs a row in `mcp/src/staging.ts`'s `TOOL_STAGES`\u2014the four lists must\n partition the tool set exactly, and a test fails CI if one doesn't.\n `npm run check:tool-count --prefix mcp` checks default, gated and setup counts across the current entry points,\n reference-table coverage, and [`docs/MCP_TOOL_INDEX.md`](takeoff://wiki/tool-index)\n against runtime schemas. Add `-- --write` to regenerate counts and the index. If the\n change alters *doctrine* rather than adding a verb\u2014what withholds, what\n refuses, what has no agent verb\u2014it belongs in `docs/AGENT_GUIDE.md` too,\n and the tool count appears there and in `docs/USER_GUIDE.md` \xA714.\n7. **Version fields must agree.** The MCP source version is\n in `mcp/package.json`; its lockfile has two matching fields, while\n `mcp/server.json` and `web/public/.well-known/mcp.json` each have two. The\n version checker checks all seven MCP fields. Separately, it checks the web\n package version against the two root-version fields in its lockfile.\n Check current `origin/main` and existing tags before\n reserving a number. The web package has its own version; it is not the MCP\n release number.\n8. `npm run check:tool-count --prefix mcp` runs version agreement before checking\n counts and the schema-backed inventory. With `-- --write`, it refuses version\n mismatches before changing generated documents; update release metadata\n explicitly, then regenerate. Run `npm run check:versions --prefix mcp` for the\n version check alone. Wiki content has its own generator:\n `npm run check:wiki --prefix mcp -- --write`. Review generated diffs and run\n both checks without `--write` afterward.\n\nArchitecture rather than behavior\u2014what MCP is versus what the `/ai` sandbox\nis, and why the server imports the web engine in-process\u2014lives at the end of\n[`docs/MCP.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/MCP.md) (\"Where this sits\") and in\n[`server/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/server/README.md).\n\n## The doc set, and who each one is for\n\nFour documents carry the product, and they're deliberately split by audience\u2014don't\nanswer an estimator's question in the agent manual or vice versa:\n\n| Document | Audience | What belongs in it |\n|---|---|---|\n| [`README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/README.md) | everyone, ~60 seconds | what this is, the three doors, what's in the box |\n| [`docs/USER_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md) | the estimator at the canvas | every shipped UI behavior, the working order on a real bid, the glossary |\n| [`docs/AGENT_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) | an agent driving the engine | the operating model, the standard finish, withheld doctrine, staging, refusal\u2192next-move |\n| [`mcp/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/README.md) | an agent's integrator | tool-by-tool reference, resources, coordinate contract, limits |\n\nAll of them follow one house style\u2014the Apple Style Guide, with the rules that\nactually come up written out in [`CONTRIBUTING.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/CONTRIBUTING.md#docs-house-style).\nInterface text quoted in a doc is copied from the code verbatim, so changing a\nmessage means changing it in both places.\n\n[`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md) is the one-page orientation that routes to\nall four. A behavior change usually touches two of them; a new MCP tool touches\nthree plus this file's sync list above.\n"
15204
+ "source_sha256": "9ef949a3dca1f992a005764a825ca86b5f4da1fd631e9eed1011b8d6fe967ab3",
15205
+ "text": "# Working on the repository\n\nOpenTakeoff is a **client-only React app**: a PDF construction-takeoff canvas for flooring (useful for any trade). No backend, no database, no auth\u2014everything runs and persists in the browser. Apache-2.0. (For the one-page project pitch and vision, see [`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md); for capability \u2192 code mapping, see [`FEATURES.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/FEATURES.md).)\n\n## Run / build / check\n\n```bash\ncd web\nnvm use # web/.nvmrc matches the root .nvmrc used by CI\nnpm install\nnpm run dev # http://localhost:5173 \u2014 hot reload\nnpm test # node:test over the pure geometry + totals math (test/*.test.ts)\nnpm run build # \u2192 web/dist/ (static output; this is what Netlify deploys)\nnpm run check # web typecheck + lint + test + benchmark + build\n```\n\n## Shipping \u2014 the required steps, every change\n\n`main` is protected on GitHub by a ruleset (PR-only, one approving review,\ngreen `web` check\u2014the repo owner has a standing bypass\nas the solo maintainer). **Merging to `main` deploys to production**\n(<https://opentakeoff.kentucky-ai.com>)\u2014Netlify's own git integration builds\nthe merge commit and publishes it. `netlify.toml` holds the whole recipe\n(`base = \"web\"`, `command = npm run build`, `publish = \"dist\"`), so the deploy\nruns a fresh build from the merged source; nothing is uploaded from CI.\n\n`.github/workflows/deploy.yml` used to do this by publishing `web/dist` with\n`--no-build`, and it was **deleted on 2026-07-13 in `e701f1a`** (\"deploys here\nare manual CLI; it fails on every push without the fork's secrets\"). Current checks and release automation live in\n[the workflow directory](https://github.com/Kentucky-ai/opentakeoff/blob/main/.github/workflows); `publish-mcp.yml` fires on\nan `mcp-v*` tag. The old line here said \"Netlify never builds anything\nitself\", which is now exactly backwards\u2014Netlify is the only thing that\nbuilds production. **Merge = deploy either way: that part has never changed.**\n\n> **This is the canonical `Kentucky-ai/opentakeoff` repo\u2014production is\n> <https://opentakeoff.kentucky-ai.com>, nothing else.** A downstream fork\n> (`knmurphy/opentakeoff`) tracks this repo as its own upstream and deploys\n> separately to `takeoff.345flooring.com`\u2014that URL belongs to *that* fork,\n> not this repo. If you see `takeoff.345flooring.com` referenced elsewhere in\n> this repo's docs, it's either an example value in the optional cloud-mode\n> guides or leftover content from that fork's docs that rode along in a\n> wholesale history merge (2026-07-13)\u2014treat it as describing the\n> *downstream* fork's deployment, not this one's.\n\nSo:\n\n1. **Branch first**\u2014never commit on `main`: `git checkout -b <topic>`.\n2. **`npm run check` before pushing** (in `web/`). It covers the independent web\n job on Node 24; a green web check does not replace the MCP, protocol, docs,\n capture or optional-server jobs.\n3. **Include review evidence in every PR**: screenshots or a short video for\n visible changes; measured expected-versus-observed stats and reproducible\n commands for engine/tool changes. Link the actual checks or evidence. This\n repository is also the maintainer's public portfolio: keep claims verifiable\n and private project data out of public artifacts.\n4. **Open a PR** and wait for the `web` check to pass. Don't merge red or\n pending.\n5. **Squash-merge with branch delete**\n (`gh pr merge <n> --squash --delete-branch`), then\n `git checkout main && git pull --ff-only` and delete the local branch\n (`git branch -D <topic>`\u2014squash merges need `-D`).\n6. **Remember a merge is a deploy.** Don't merge work you haven't verified in\n the running app.\n\nThe tests cover the pure math (`web/test/geometry.test.ts`, `web/test/totals.test.ts`); the canvas itself is verified by hand\u2014**Vite does not flag undefined identifiers in JSX**, so grep for your new identifiers after editing and load the app once before you call it done. The bundled sample plan (`web/public/demo/`, wired to the \"Load sample plan\" button) is the fastest end-to-end check: load it, press `A`, trace a room, open Report.\n\n## Where things live\n\n| Concern | Path |\n|---|---|\n| **The canvas\u201490% of the app** | `web/src/pages/TakeoffCanvas.jsx` (one large, deliberately monolithic component) |\n| Geometry: vector extraction, One-Click flood fill, vertex snap | `web/src/lib/oneclick.ts` |\n| Sheet/page helpers, scale detection | `web/src/lib/sheets.ts` |\n| Totals and materials math (waste, SY, coverage \u2192 order qty) | `web/src/lib/totals.js` |\n| Persistence (IndexedDB + localStorage) | `web/src/lib/store.js` |\n| PDF/image/zip ingest | `web/src/lib/ingest.js` |\n| Icon set | `web/src/brand/icons.jsx` |\n| Design tokens (colors, spacing\u2014the source of truth) | `web/src/styles/tokens.css` |\n| Sheet gallery / report UI | `web/src/components/` |\n| Pure-math tests (node:test) | `web/test/` |\n| **Optional AI backend** (pluggable adapter: scale/room/finish suggestions) | `server/`\u2014`app.py` + `adapters/base.py` (interface) + `adapters/heuristic.py` (default, no model) |\n\n## How the canvas works (the mental model)\n\n- Each open sheet renders into a `<canvas>` bitmap; **all takeoff geometry is an SVG overlay** on top; pan/zoom is a single CSS transform on the stage div, written imperatively (`tfRef` \u2192 `style.transform`) to avoid React re-renders per frame.\n- Coordinates: pointer events (client px) \u2192 `toImage()` \u2192 **stage px**; committed shapes store **normalized [0..1] vertices per sheet** (`verts_norm`), so quantities survive re-renders and zoom.\n- Cursor-following UI (crosshair hairlines, readout chip, rubber band) updates through **direct DOM writes in `moveCrosshair`**\u2014never React state per mousemove. Keep it that way.\n- Angle snapping: `angleSnap()` locks in-progress segments to the 45\xB0 family; endpoint snap (`nearestSnap` over a spatial hash of PDF vector endpoints) takes priority. The committed click reuses the same locked point (`angleRef`).\n- Past ~1.15\xD7 zoom, a **detail-view canvas** re-renders the visible region from PDF vectors at the current zoom (crispness); the base bitmap stays as first paint.\n- pdf.js rendering schedules work on `requestAnimationFrame`\u2014a fully hidden/occluded window will pause mid-render by design; it resumes when visible.\n\n## Conventions\n\n- **SVG presentation attributes take literal colors** (CSS vars don't resolve there): cobalt `#1f3fc7`, danger `#b03a26`, positive `#1f6b4a`\u2014centralized in `web/src/lib/ui.js` (`SVG`, with HUD-dark counterparts through `svgAccent(isDark)`). DOM/HTML chrome may use `var(--\u2026)` from `tokens.css`.\n- Condition palettes (`PALETTE` and `HATCH_IDS` in `web/src/lib/takeoffConstants.ts`, which both the canvas and `mcp/src/session.ts` import, and the seeded condition colors in `FLOORING_DEFAULTS` in `web/src/lib/canvasConstants.js`) are **user data**\u2014don't re-theme them. The same module owns the takeoff and report schema ids, `RENDER_SCALE` and the snap constants; parity tests in `web/test`, `mcp/test` and `protocol/test` fail if a copy reappears.\n- Waste applies only in the report (order quantities), never to live measured numbers.\n- Keyboard shortcuts are single letters registered on `window` (see `docs/USER_GUIDE.md` \xA715); toolbar menus pause them through `menuDepthRef`.\n- Brand voice: **precision instrument** (2026-08 overhaul). Light theme = \"ice\": bright white surfaces on a cool field, cool-slate neutrals, cobalt the one saturated thing. Dark theme = \"HUD\": true-black cockpit, electric blue `#3f8cff`, phosphor `--glow` on exactly five elements (active tool face, status verb, hero quantity, primary CTA, calibration dot). Square corners; the single sanctioned radius is `--r-1` on floating chrome. Mono tabular numerals on every readout. Drafting-table language stays. No vendor mimicry.\n- Layout/spacing/type come from the token scales in `tokens.css` (`--sp-*`, `--fs-*`, `--ctl-*`); zIndex comes from the `Z` ladder in `web/src/lib/ui.js`. No new magic numbers.\n\n## Docs to keep in sync when you change behavior\n\nThe draft Takeoff Protocol lives in [`protocol/`](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/README.md). When\nchanging persisted fields, consult its [inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) and\n[compatibility report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md). Run\n`npm run check --prefix protocol` after installing web, MCP, and protocol\ndependencies. Refresh schema references with\n`node protocol/scripts/check-docs.mjs --write`. The draft does not authorize\nchanging writer formats or adopting a migration.\n\n1. `README.md` (Features + \"What's in the box\")\n2. `docs/USER_GUIDE.md` (shortcuts + the relevant section)\n3. `CHANGELOG.md`\n\nTouching the **MCP server** also requires checking its runtime guidance and\ngenerated references:\n\n4. `mcp/src/tools.ts`\u2014the tool's own `description` is part of its integration.\n An MCP client reads it at runtime. Initialize instructions and packaged wiki\n resources are also agent-facing surfaces, so keep them consistent with the\n runtime behavior.\n5. `mcp/server.ts`\u2014the `instructions` block sent at `initialize`. This is the\n decision tree every client receives before its first call. A new *verb* does\n not belong here; a new *step in the standard finish* does. Packaged wiki\n resources are another deliberate agent-facing surface; update their source\n pages and run the wiki/resource checks when their guidance changes.\n6. `mcp/README.md` (the tool table) and `docs/MCP.md` (the reach-for-it ordering,\n the example session, and the tool count in its opening line). A new tool also\n needs a row in `mcp/src/staging.ts`'s `TOOL_STAGES`\u2014the four lists must\n partition the tool set exactly, and a test fails CI if one doesn't.\n `npm run check:tool-count --prefix mcp` checks default, gated and setup counts across the current entry points,\n reference-table coverage, and [`docs/MCP_TOOL_INDEX.md`](takeoff://wiki/tool-index)\n against runtime schemas. Add `-- --write` to regenerate counts and the index. If the\n change alters *doctrine* rather than adding a verb\u2014what withholds, what\n refuses, what has no agent verb\u2014it belongs in `docs/AGENT_GUIDE.md` too,\n and the tool count appears there and in `docs/USER_GUIDE.md` \xA714.\n7. **Version fields must agree.** The MCP source version is\n in `mcp/package.json`; its lockfile has two matching fields, while\n `mcp/server.json` and `web/public/.well-known/mcp.json` each have two. The\n version checker checks all seven MCP fields. Separately, it checks the web\n package version against the two root-version fields in its lockfile.\n Check current `origin/main` and existing tags before\n reserving a number. The web package has its own version; it is not the MCP\n release number.\n8. `npm run check:tool-count --prefix mcp` runs version agreement before checking\n counts and the schema-backed inventory. With `-- --write`, it refuses version\n mismatches before changing generated documents; update release metadata\n explicitly, then regenerate. Run `npm run check:versions --prefix mcp` for the\n version check alone. Wiki content has its own generator:\n `npm run check:wiki --prefix mcp -- --write`. Review generated diffs and run\n both checks without `--write` afterward.\n\nArchitecture rather than behavior\u2014what MCP is versus what the `/ai` sandbox\nis, and why the server imports the web engine in-process\u2014lives at the end of\n[`docs/MCP.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/MCP.md) (\"Where this sits\") and in\n[`server/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/server/README.md).\n\n## The doc set, and who each one is for\n\nFour documents carry the product, and they're deliberately split by audience\u2014don't\nanswer an estimator's question in the agent manual or vice versa:\n\n| Document | Audience | What belongs in it |\n|---|---|---|\n| [`README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/README.md) | everyone, ~60 seconds | what this is, the three doors, what's in the box |\n| [`docs/USER_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md) | the estimator at the canvas | every shipped UI behavior, the working order on a real bid, the glossary |\n| [`docs/AGENT_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) | an agent driving the engine | the operating model, the standard finish, withheld doctrine, staging, refusal\u2192next-move |\n| [`mcp/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/README.md) | an agent's integrator | tool-by-tool reference, resources, coordinate contract, limits |\n\nAll of them follow one house style\u2014the Apple Style Guide, with the rules that\nactually come up written out in [`CONTRIBUTING.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/CONTRIBUTING.md#docs-house-style).\nInterface text quoted in a doc is copied from the code verbatim, so changing a\nmessage means changing it in both places.\n\n[`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md) is the one-page orientation that routes to\nall four. A behavior change usually touches two of them; a new MCP tool touches\nthree plus this file's sync list above.\n"
15123
15206
  },
15124
15207
  {
15125
15208
  "key": "tool-index",
@@ -15493,7 +15576,7 @@ function nameTheStageInRefusals(server) {
15493
15576
  // package.json
15494
15577
  var package_default = {
15495
15578
  name: "opentakeoff-mcp",
15496
- version: "0.9.84",
15579
+ version: "0.9.86",
15497
15580
  mcpName: "io.github.Kentucky-ai/opentakeoff",
15498
15581
  type: "module",
15499
15582
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -15513,7 +15596,7 @@ var package_default = {
15513
15596
  "check:wiki": "node scripts/check-wiki.mjs",
15514
15597
  "check:versions": "node ../scripts/check-version-consistency.mjs",
15515
15598
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
15516
- test: "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
15599
+ test: "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
15517
15600
  },
15518
15601
  dependencies: {
15519
15602
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.84",
3
+ "version": "0.9.86",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -20,7 +20,7 @@
20
20
  "check:wiki": "node scripts/check-wiki.mjs",
21
21
  "check:versions": "node ../scripts/check-version-consistency.mjs",
22
22
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
23
- "test": "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
23
+ "test": "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
24
24
  },
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.12.0",