opentakeoff-mcp 0.9.94 → 0.9.98

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 +698 -159
  2. package/package.json +2 -2
@@ -3466,6 +3466,39 @@ function finishSectionOf(text) {
3466
3466
  return WORDS.has(clean[0]) ? clean[0] : null;
3467
3467
  }
3468
3468
 
3469
+ // ../web/src/lib/notUsed.ts
3470
+ function normalizeTail(text) {
3471
+ let t = text.toUpperCase().trim().replace(/^[-–—:,\s]+/, "");
3472
+ t = t.replace(/\.$/, "");
3473
+ if (t.startsWith("(") && closingParen(t) === t.length - 1) t = t.slice(1, -1);
3474
+ t = t.replace(/\.$/, "");
3475
+ t = t.replace(/\bN\.?\s*I\.?\s*C\b\.?/g, "NIC");
3476
+ return t.replace(/\s+/g, " ").trim();
3477
+ }
3478
+ function closingParen(t) {
3479
+ let depth = 0;
3480
+ for (let i = 0; i < t.length; i++) {
3481
+ if (t[i] === "(") depth++;
3482
+ else if (t[i] === ")" && --depth === 0) return i;
3483
+ }
3484
+ return -1;
3485
+ }
3486
+ function normalizeNotUsed(text) {
3487
+ const t = normalizeTail(text);
3488
+ return t === "NOT USED" || t === "NOT IN CONTRACT" || t === "NIC" ? t : null;
3489
+ }
3490
+
3491
+ // ../web/src/lib/finishCode.ts
3492
+ var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?(\([A-Z0-9]{1,4}\))?$/;
3493
+ var QUALIFIER_SUFFIX_RE = /\([A-Z]{3,}\)$/;
3494
+ var finishCodeOk = (p) => !/^[A-Z]{4,}(\([A-Z0-9]{1,4}\))?$/.test(p) && CODE_RE.test(p) && !QUALIFIER_SUFFIX_RE.test(p);
3495
+ function finishKeyText(upper) {
3496
+ return upper.replace(/\(\s*([A-Z]{3,})\.?\s*\)/g, "$1").split("/").map((part) => {
3497
+ const open2 = (part.match(/\(/g) || []).length, close = (part.match(/\)/g) || []).length;
3498
+ return open2 === close ? part : part.replace(/[()]/g, "");
3499
+ }).join("/");
3500
+ }
3501
+
3469
3502
  // ../web/src/lib/sheetgraph.ts
3470
3503
  var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
3471
3504
  var merge = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
@@ -3624,6 +3657,7 @@ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
3624
3657
  var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
3625
3658
  var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "MAT", "MATERIAL", "COMMENTS", "CASEWORK", "CABINET", "COUNTER", "COUNTERTOP", "BLDG", "BUILDING"];
3626
3659
  var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
3660
+ var FOREIGN_HDR = /* @__PURE__ */ new Set(["QTY", "QUANTITY", "MESSAGE", "WIDTH", "HEIGHT", "HARDWARE", "CFM", "VOLTS", "VOLTAGE", "WATTS", "LAMP", "LAMPS", "CATALOG", "FIXTURE", "FRAME", "GLAZING", "THICKNESS", "RATING", "LOUVER"]);
3627
3661
  var EQUIPMENT_HEADERS = [
3628
3662
  // identity + the shared columns
3629
3663
  "ID",
@@ -3995,8 +4029,6 @@ function bandLimits(anchors) {
3995
4029
  const rightMargin = WIDE_LAST.has(last.label) ? Math.max(300, medGap * 3) : Math.max(120, medGap);
3996
4030
  return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: last.x + rightMargin, medGap };
3997
4031
  }
3998
- var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
3999
- var finishCodeOk = (p) => !/^[A-Z]{4,}$/.test(p) && CODE_RE.test(p);
4000
4032
  var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
4001
4033
  var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
4002
4034
  var CORRIDOR_KEY_RE = /^[A-Z]{1,3}(?:\d{1,3}-\d{1,3}|\d{3})[A-Z]?$/;
@@ -4006,7 +4038,8 @@ var isNonFinishSchedule = (title) => {
4006
4038
  return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
4007
4039
  };
4008
4040
  function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
4009
- const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
4041
+ const source = kind === "finish" ? finishKeyText(norm(raw)) : norm(raw);
4042
+ const kept = source.replace(kind === "finish" ? /[^A-Z0-9/()-]/g : /[^A-Z0-9/-]/g, "");
4010
4043
  const key = kept.replace(/\//g, "");
4011
4044
  if (kind === "equipment") {
4012
4045
  const parts = kept.split("/").filter(Boolean);
@@ -4025,6 +4058,34 @@ function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
4025
4058
  if (q && buildings?.has(q[1])) return { key, building: q[1] };
4026
4059
  return null;
4027
4060
  }
4061
+ var SPLIT_DENY = /* @__PURE__ */ new Set(["SEE", "NOTE", "NOTES", "NOT", "USED", "BY", "OWNER", "NIC", "TBD", "ALL", "THRU", "TO", "AND", "OR"]);
4062
+ var ALT_WORDS = /* @__PURE__ */ new Set(["ALT", "OPT", "OPTION", "ADD", "DEDUCT"]);
4063
+ function splitKeyCell(raw) {
4064
+ const r = raw.trim().replace(
4065
+ /^([A-Z]{1,4}-?[A-Z0-9]{0,4})\s*\(\s*([A-Z0-9]{1,4})\s*\)/i,
4066
+ (whole, base, suffix) => finishCodeOk(`${base}(${suffix})`.toUpperCase()) ? `${base}(${suffix})` : whole
4067
+ );
4068
+ const sp = r.search(/\s/);
4069
+ let w1 = (sp < 0 ? r : r.slice(0, sp)).toUpperCase().replace(/[:,;]+$/, "");
4070
+ let rest = sp < 0 ? "" : r.slice(sp);
4071
+ const glued = finishCodeOk(w1) ? null : w1.match(/^([A-Z]{1,4}-\d{1,3})([A-Z]{3,8})$/);
4072
+ if (glued) {
4073
+ w1 = glued[1];
4074
+ rest = ` ${glued[2]}${rest}`;
4075
+ }
4076
+ if (!finishCodeOk(w1) || !/\d/.test(w1)) return null;
4077
+ const after = rest.replace(/^[-–—:,\s]+/, "").replace(/\s*\(\s*([A-Z0-9]{1,2})\s*\)\s*$/i, (_m, s) => ` (${s.toUpperCase()})`).trim();
4078
+ const tail = normalizeTail(after);
4079
+ if (tail === "NOT USED" || tail === "NOT IN CONTRACT" || tail === "NIC") return { key: w1, notUsed: true, notUsedText: after };
4080
+ const words = tail ? tail.split(" ") : [];
4081
+ const suffixLast = words.length === 3 && /^\([A-Z]{1,2}\)$/.test(words[2]) && words.slice(0, 2).every((w) => /^[A-Z]{2,8}$/.test(w));
4082
+ if (words.length < 1 || words.length > 2 && !suffixLast) return null;
4083
+ if (!words.every((w) => /^[A-Z]{2,8}$/.test(w) || /^\([A-Z]{1,8}\.?\)$/.test(w))) return null;
4084
+ const bare = words.map((w) => w.replace(/[()]/g, ""));
4085
+ if (bare.some((w) => SPLIT_DENY.has(w)) || bare.join("").length < 3) return null;
4086
+ return { key: w1, qualifier: after };
4087
+ }
4088
+ var isAltQualifier = (q) => ALT_WORDS.has(q.toUpperCase().replace(/[()]/g, "").trim());
4028
4089
  var rowKeyAnswersFor = (key, want) => {
4029
4090
  const c = norm(key).replace(/\s+/g, "");
4030
4091
  const w = norm(want).replace(/\s+/g, "");
@@ -4195,6 +4256,58 @@ function columnStarts(rows, anchors, cfg, x0, x1, kind) {
4195
4256
  if (!center) return left;
4196
4257
  return center.score > left.score + 0.05 ? center : left;
4197
4258
  }
4259
+ var KEY_FIRST = /* @__PURE__ */ new Set(["CODE", "MARK", "SYMBOL", "TAG"]);
4260
+ var LETTERS_RE = /^[A-Z]{4,5}$/;
4261
+ var OTHER_FAMILY_WORDS = ["DOOR", "WINDOW", "PARTITION", "EQUIPMENT", "HARDWARE", "LOUVER", "SIGNAGE", "LIGHTING", "LUMINAIRE", "PLUMBING", "MECHANICAL", "ELECTRICAL", "STOREFRONT", "GLAZING", "CASEWORK", "MILLWORK", "APPLIANCE"];
4262
+ var LETTERS_STOP = /* @__PURE__ */ new Set([
4263
+ ...FINISH_HEADERS,
4264
+ ...EQUIPMENT_HEADERS,
4265
+ ...ROOM_HEADERS,
4266
+ ...FINISH_SECTION_HEADINGS.filter((h) => !h.includes(" ")),
4267
+ ...OTHER_FAMILY_WORDS.flatMap((w) => [w, w + "S"]),
4268
+ "LEGEND",
4269
+ "NOTE",
4270
+ "NOTES",
4271
+ "TYPE",
4272
+ "ITEM",
4273
+ "ROOM",
4274
+ "LEVEL",
4275
+ "AREA",
4276
+ "FIELD",
4277
+ "WHERE",
4278
+ "PATCH",
4279
+ "MATCH",
4280
+ "REFER",
4281
+ "STAIR",
4282
+ "ABOVE",
4283
+ "APPLY",
4284
+ "CAULK",
4285
+ "COVE",
4286
+ "COVED",
4287
+ "MFG",
4288
+ "MFR",
4289
+ "SPEC",
4290
+ "PAINT",
4291
+ "TILE",
4292
+ "STONE",
4293
+ "VINYL",
4294
+ "WOOD",
4295
+ "GLASS",
4296
+ "METAL",
4297
+ "EPOXY",
4298
+ "STAIN",
4299
+ "GROUT",
4300
+ // sheens and formats: a key-column wrap under a code (PT-1 / SATIN)
4301
+ "SATIN",
4302
+ "GLOSS",
4303
+ "MATTE",
4304
+ "FLAT",
4305
+ "HONED",
4306
+ "PLANK"
4307
+ ]);
4308
+ var HEADER_WORDS_STRONG = /* @__PURE__ */ new Set([...FINISH_HEADERS, ...EQUIPMENT_HEADERS, ...FOREIGN_HDR, "MFG", "MFR", "SPEC", "SPECIFICATION", "NOTES"]);
4309
+ var HEADER_WORDS = /* @__PURE__ */ new Set([...HEADER_WORDS_STRONG, ...ROOM_HEADERS]);
4310
+ var SPEC_SECTION_RE = /^\d{2} ?\d{2}[ .]?\d{2}\b/;
4198
4311
  function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4199
4312
  const { x0, x1, medGap } = bandLimits(anchors);
4200
4313
  const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
@@ -4212,13 +4325,12 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4212
4325
  const a = atOf(t);
4213
4326
  return a >= Math.min(x0, keyLo - colTol) && a < keyHi;
4214
4327
  };
4215
- let tLeft = 0, tRight = 0, medPitch = 0;
4216
- let prevBandY = null;
4328
+ let tLeft = 0, tRight = 0, medPitch = 0, medPitchLo = 0;
4217
4329
  if (finish) {
4218
4330
  let ys = rows.slice(Math.max(cfg.fromIdx, 0)).filter((r) => r.some(inBand)).map(rowY);
4219
4331
  const med = (v) => {
4220
- const d = v.slice(1).map((y, k) => y - v[k]).sort((p, q) => p - q);
4221
- return d.length ? d[d.length >> 1] : 0;
4332
+ const d2 = v.slice(1).map((y, k) => y - v[k]).sort((p, q) => p - q);
4333
+ return d2.length ? d2[d2.length >> 1] : 0;
4222
4334
  };
4223
4335
  const p0 = med(ys);
4224
4336
  const cut = p0 > 0 ? ys.findIndex((y, k) => k > 0 && y - ys[k - 1] > 8 * p0) : -1;
@@ -4226,9 +4338,9 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4226
4338
  const lastTableY = ys.length ? ys[ys.length - 1] : Infinity;
4227
4339
  if (cols) {
4228
4340
  const cs = cols.cols;
4229
- const pitch2 = cs.length > 1 ? cs.slice(1).map((c, k) => c.start - cs[k].start).sort((p, q) => p - q)[cs.length - 1 >> 1] : medGap;
4341
+ const pitch = cs.length > 1 ? cs.slice(1).map((c, k) => c.start - cs[k].start).sort((p, q) => p - q)[cs.length - 1 >> 1] : medGap;
4230
4342
  tLeft = cs[0].start;
4231
- tRight = cs[cs.length - 1].start + pitch2;
4343
+ tRight = cs[cs.length - 1].start + pitch;
4232
4344
  } else {
4233
4345
  tLeft = Infinity;
4234
4346
  tRight = -Infinity;
@@ -4239,19 +4351,10 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4239
4351
  }
4240
4352
  }
4241
4353
  medPitch = med(ys);
4354
+ const d = ys.slice(1).map((y, k) => y - ys[k]).filter((g) => g > 0).sort((p, q) => p - q);
4355
+ medPitchLo = d.length ? d[d.length - 1 >> 1] : 0;
4242
4356
  }
4243
- const headings = [];
4244
- const headLines = [];
4245
- const consumeHeading = (banded, y) => {
4246
- let hb = null;
4247
- for (const t of banded) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
4248
- headings.push({ bbox: hb, y });
4249
- };
4250
4357
  const keyTol = cols && cols.cols.length > 1 ? Math.max(8, (cols.cols[1].start - cols.cols[0].start) * 0.5) : 40;
4251
- const out = [];
4252
- const outY = [];
4253
- const everRows = [];
4254
- let region = null;
4255
4358
  const columnOf = (t) => {
4256
4359
  if (!cols) return nearestAnchor(centerX(t), anchors);
4257
4360
  const at = cols.coord === "left" ? t.x : centerX(t);
@@ -4281,147 +4384,565 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4281
4384
  const text = t.str.trim();
4282
4385
  if (!row.cells[label]) row.cells[label] = { text, bbox: bboxOf(t) };
4283
4386
  else row.cells[label] = { text: `${row.cells[label].text} ${text}`, bbox: merge(row.cells[label].bbox, bboxOf(t)) };
4284
- region = region ? merge(region, bboxOf(t)) : bboxOf(t);
4285
4387
  }
4286
4388
  };
4287
- const orphans = [];
4288
- const markers = [];
4289
- for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
4290
- if (rowY(rows[i]) <= cfg.belowY) continue;
4291
- const banded = [];
4292
- for (const t of rows[i]) {
4293
- const tri = cfg.deltas?.get(t);
4294
- const rev = tri ? norm(t.str) : revisionOf(t.str);
4295
- if (rev != null) {
4296
- if (centerX(t) >= x0 - 2.5 * medGap && centerX(t) <= x1 + medGap) markers.push({ rev, span: t, ...tri ? { drawn: true, tri } : {} });
4297
- continue;
4389
+ const scan = (over2, gone, breaks) => {
4390
+ const S = { out: [], outY: [], outI: [], outToks: [], everRows: [], everI: [], orphans: [], markers: [], headings: [], headLines: [], lines: [], outEpoch: [], outLine: [] };
4391
+ let curSection2;
4392
+ let prevBandY = null;
4393
+ const consumeHeading = (banded, y, section) => {
4394
+ let hb = null;
4395
+ for (const t of banded) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
4396
+ S.headings.push({ bbox: hb, y, section });
4397
+ };
4398
+ const keyOf = (raw, i) => over2?.get(i) ?? rowKeyOf(raw, kind, buildings, typeKeyed);
4399
+ let epoch = 0, line = -1;
4400
+ for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
4401
+ if (rowY(rows[i]) <= cfg.belowY) continue;
4402
+ const banded = [];
4403
+ for (const t of rows[i]) {
4404
+ const tri = cfg.deltas?.get(t);
4405
+ const rev = tri ? norm(t.str) : revisionOf(t.str);
4406
+ if (rev != null) {
4407
+ if (centerX(t) >= x0 - 2.5 * medGap && centerX(t) <= x1 + medGap) S.markers.push({ rev, span: t, i, ...tri ? { drawn: true, tri } : {} });
4408
+ continue;
4409
+ }
4410
+ if (inBand(t)) banded.push(t);
4298
4411
  }
4299
- if (inBand(t)) banded.push(t);
4300
- }
4301
- if (!banded.length) continue;
4302
- const bandYBefore = prevBandY;
4303
- prevBandY = rowY(rows[i]);
4304
- if (finish) {
4305
- const h = finishSectionOf(banded[0].str);
4306
- const joinedLen = banded.map((t) => t.str.trim()).join(" ").length;
4307
- const inKeyColumn = inKey(banded[0]) && banded.every((t) => t.x + (t.w || 0) <= keyHi);
4308
- const one = banded.length === 1 ? banded[0] : null;
4309
- const straddles = !!one && !!cols && cols.cols.slice(1).some((c) => one.x < c.start - colTol && one.x + (one.w || 0) > c.start + colTol);
4310
- const hugs = bandYBefore != null && rowY(rows[i]) - bandYBefore < 0.6 * medPitch;
4311
- const tMid = (tLeft + tRight) / 2;
4312
- const centered = !!one && !hugs && (Math.abs(centerX(one) - tMid) <= Math.max(0.1 * (tRight - tLeft), 3 * (one.h || 8)) || straddles);
4313
- if (h && (inKeyColumn || centered) && joinedLen < 24) {
4314
- curSection = h;
4315
- consumeHeading(banded, rowY(rows[i]));
4412
+ if (!banded.length) continue;
4413
+ line++;
4414
+ const bandYBefore = prevBandY;
4415
+ prevBandY = rowY(rows[i]);
4416
+ S.lines.push({ i, y: rowY(rows[i]), toks: banded });
4417
+ if (finish) {
4418
+ const h = finishSectionOf(banded[0].str);
4419
+ const joinedLen = banded.map((t) => t.str.trim()).join(" ").length;
4420
+ const inKeyColumn = inKey(banded[0]) && banded.every((t) => t.x + (t.w || 0) <= keyHi);
4421
+ const one = banded.length === 1 ? banded[0] : null;
4422
+ const straddles = !!one && !!cols && cols.cols.slice(1).some((c) => one.x < c.start - colTol && one.x + (one.w || 0) > c.start + colTol);
4423
+ const hugs = bandYBefore != null && rowY(rows[i]) - bandYBefore < 0.6 * medPitch;
4424
+ const tMid = (tLeft + tRight) / 2;
4425
+ const centered = !!one && !hugs && (Math.abs(centerX(one) - tMid) <= Math.max(0.1 * (tRight - tLeft), 3 * (one.h || 8)) || straddles);
4426
+ if (h && (inKeyColumn || centered) && joinedLen < 24) {
4427
+ curSection2 = h;
4428
+ epoch++;
4429
+ consumeHeading(banded, rowY(rows[i]), h);
4430
+ continue;
4431
+ }
4432
+ if (h && !hugs) S.headLines.push(rowY(rows[i]));
4433
+ if (banded.length <= 2 && inKey(banded[0]) && /^\d{2} ?\d{2} ?\d{2}(\.\d+)?\b/.test(norm(banded[0].str)) && !rowKeyOf(banded[0].str, kind, buildings, typeKeyed)) {
4434
+ curSection2 = void 0;
4435
+ epoch++;
4436
+ consumeHeading(banded, rowY(rows[i]), null);
4437
+ continue;
4438
+ }
4439
+ if (one && inKey(one) && (breaks?.has(i) || !keyOf(one.str, i))) {
4440
+ curSection2 = void 0;
4441
+ epoch++;
4442
+ if (!hugs && joinedLen < 24 && !/\d/.test(one.str)) continue;
4443
+ }
4444
+ }
4445
+ if (kind === "equipment" && S.out.length) {
4446
+ if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
4447
+ const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
4448
+ if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
4449
+ }
4450
+ const keyed = keyOf(banded[0].str, i);
4451
+ if (!keyed) {
4452
+ if (!gone?.has(i)) S.orphans.push({ toks: banded, y: rowY(rows[i]), i });
4316
4453
  continue;
4317
4454
  }
4318
- if (h && !hugs) headLines.push(rowY(rows[i]));
4319
- if (banded.length <= 2 && inKey(banded[0]) && /^\d{2} ?\d{2} ?\d{2}(\.\d+)?\b/.test(norm(banded[0].str)) && !rowKeyOf(banded[0].str, kind, buildings, typeKeyed)) {
4320
- curSection = void 0;
4321
- consumeHeading(banded, rowY(rows[i]));
4455
+ if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
4456
+ S.orphans.push({ toks: banded, y: rowY(rows[i]), i });
4322
4457
  continue;
4323
4458
  }
4324
- if (one && inKey(one) && !rowKeyOf(one.str, kind, buildings, typeKeyed)) {
4325
- curSection = void 0;
4326
- if (!hugs && joinedLen < 24 && !/\d/.test(one.str)) continue;
4459
+ if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
4460
+ if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
4461
+ const row = { key: keyed.key, sheet: sheetKey, cells: {} };
4462
+ if (keyed.building) row.building = keyed.building;
4463
+ if (curSection2) row.section = curSection2;
4464
+ if (keyed.qualifier) row.qualifier = keyed.qualifier;
4465
+ if (keyed.notUsed) {
4466
+ row.notUsed = true;
4467
+ row.notUsedText = keyed.notUsedText;
4327
4468
  }
4328
- }
4329
- if (kind === "equipment" && out.length) {
4330
- if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
4331
- const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
4332
- if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
4333
- }
4334
- const keyed = rowKeyOf(banded[0].str, kind, buildings, typeKeyed);
4335
- if (!keyed) {
4336
- orphans.push({ toks: banded, y: rowY(rows[i]) });
4337
- continue;
4338
- }
4339
- if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
4340
- orphans.push({ toks: banded, y: rowY(rows[i]) });
4341
- continue;
4342
- }
4343
- if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
4344
- if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
4345
- const row = { key: keyed.key, sheet: sheetKey, cells: {} };
4346
- if (keyed.building) row.building = keyed.building;
4347
- if (curSection) row.section = curSection;
4348
- add(row, banded);
4349
- out.push(row);
4350
- outY.push(rowY(rows[i]));
4351
- everRows.push(row);
4352
- }
4353
- if (out.length > 2 && !(finish && cfg.marquee)) {
4354
- const d = outY.slice(1).map((y, i) => y - outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
4355
- const pitch0 = d.length ? d[d.length >> 1] : 0;
4356
- if (pitch0 > 0) {
4357
- let end = out.length;
4358
- for (let i = 1; i < outY.length; i++) if (outY[i] - outY[i - 1] > pitch0 * 8) {
4359
- end = i;
4360
- break;
4361
- }
4362
- if (end < out.length) {
4363
- out.length = end;
4364
- outY.length = end;
4469
+ add(row, banded);
4470
+ S.out.push(row);
4471
+ S.outY.push(rowY(rows[i]));
4472
+ S.outI.push(i);
4473
+ S.outToks.push(banded);
4474
+ S.outEpoch.push(epoch);
4475
+ S.outLine.push(line);
4476
+ S.everRows.push(row);
4477
+ S.everI.push(i);
4478
+ }
4479
+ return S;
4480
+ };
4481
+ const dropRowAt = (S, k) => {
4482
+ S.out.splice(k, 1);
4483
+ S.outY.splice(k, 1);
4484
+ S.outI.splice(k, 1);
4485
+ S.outToks.splice(k, 1);
4486
+ };
4487
+ const blankBandResets = (S) => {
4488
+ if (!finish || !cfg.resetAtBlankBand) return [];
4489
+ const { out, outY, outEpoch, outLine } = S;
4490
+ const clear = /* @__PURE__ */ new Set();
4491
+ const prefixOf = (k) => /^[A-Z]*/.exec(k)[0];
4492
+ const plain = [];
4493
+ for (let i = 1; i < out.length; i++) if (outEpoch[i] === outEpoch[i - 1] && outLine[i] === outLine[i - 1] + 1) plain.push(i);
4494
+ const byEpoch = /* @__PURE__ */ new Map();
4495
+ for (const i of plain) {
4496
+ const g = outY[i] - outY[i - 1];
4497
+ if (g > 0) byEpoch.set(outEpoch[i], [...byEpoch.get(outEpoch[i]) ?? [], g]);
4498
+ }
4499
+ for (const i of plain) {
4500
+ const gaps = (byEpoch.get(outEpoch[i]) ?? []).slice().sort((a, b) => a - b);
4501
+ if (gaps.length < 4) continue;
4502
+ const pitch = gaps[gaps.length - 1 >> 1];
4503
+ if (outY[i] - outY[i - 1] <= 1.6 * pitch || prefixOf(out[i].key) === prefixOf(out[i - 1].key)) continue;
4504
+ for (let j = i; j < out.length && outEpoch[j] === outEpoch[i]; j++) clear.add(out[j]);
4505
+ }
4506
+ return [...clear];
4507
+ };
4508
+ const resetSections = (rs) => {
4509
+ for (const r of rs) delete r.section;
4510
+ };
4511
+ const endCut = (S) => {
4512
+ if (S.out.length > 2 && !(finish && cfg.marquee)) {
4513
+ const d = S.outY.slice(1).map((y, i) => y - S.outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
4514
+ const pitch0 = d.length ? d[d.length >> 1] : 0;
4515
+ if (pitch0 > 0) {
4516
+ let end = S.out.length;
4517
+ for (let i = 1; i < S.outY.length; i++) if (S.outY[i] - S.outY[i - 1] > pitch0 * 8) {
4518
+ end = i;
4519
+ break;
4520
+ }
4521
+ if (end < S.out.length) {
4522
+ S.out.length = end;
4523
+ S.outY.length = end;
4524
+ S.outI.length = end;
4525
+ S.outToks.length = end;
4526
+ }
4365
4527
  }
4366
4528
  }
4367
- }
4368
- const headY = [...headings.map((h) => h.y), ...headLines];
4369
- const ly2 = outY.length > 1 ? [...outY, ...headY].filter((y) => y <= outY[outY.length - 1]).sort((a, b) => a - b) : [];
4370
- const gaps = ly2.slice(1).map((y, i) => y - ly2[i]).filter((d) => d > 0).sort((a, b) => a - b);
4371
- const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
4372
- const nearest = (y) => {
4373
- let bi = -1, bd = Infinity;
4374
- outY.forEach((ry, i) => {
4375
- const d = Math.abs(y - ry);
4376
- if (d < bd) {
4377
- bd = d;
4378
- bi = i;
4379
- }
4529
+ };
4530
+ const attachPlan = (S) => {
4531
+ const outY = S.outY;
4532
+ const headY = [...S.headings.map((h) => h.y), ...S.headLines];
4533
+ const ly2 = outY.length > 1 ? [...outY, ...headY].filter((y) => y <= outY[outY.length - 1]).sort((a, b) => a - b) : [];
4534
+ const gaps = ly2.slice(1).map((y, i) => y - ly2[i]).filter((d) => d > 0).sort((a, b) => a - b);
4535
+ const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
4536
+ const nearestIn = (ys, y) => {
4537
+ let bi = -1, bd = Infinity;
4538
+ ys.forEach((ry, i) => {
4539
+ const d = Math.abs(y - ry);
4540
+ if (d < bd) {
4541
+ bd = d;
4542
+ bi = i;
4543
+ }
4544
+ });
4545
+ return { i: bi, d: bd };
4546
+ };
4547
+ const radius = (h) => pitch ? pitch * 0.6 : Math.max(h, 8) * 1.6;
4548
+ const headYs = [...headY].sort((p, q) => p - q);
4549
+ const byHeading = (y, d, rowAt) => headYs.some((hy) => hy !== y && (Math.abs(y - hy) < d || Math.abs(y - hy) === d && hy < rowAt));
4550
+ const orphanT = S.orphans.map((o) => {
4551
+ const { i, d } = nearestIn(outY, o.y);
4552
+ if (i < 0 || d > radius(Math.max(...o.toks.map((t) => t.h || 8)))) return -1;
4553
+ return byHeading(o.y, d, outY[i]) ? -1 : i;
4554
+ });
4555
+ const taken = /* @__PURE__ */ new Set();
4556
+ const markerT = S.markers.map((m) => {
4557
+ const { i, d } = nearestIn(outY, m.span.y);
4558
+ if (i < 0 || d > radius(m.span.h || 8) || taken.has(i)) return -1;
4559
+ taken.add(i);
4560
+ return i;
4380
4561
  });
4381
- return { i: bi, d: bd };
4562
+ return { radius, nearestIn, byHeading, orphanT, markerT };
4382
4563
  };
4383
- const radius = (h) => pitch ? pitch * 0.6 : Math.max(h, 8) * 1.6;
4384
- const headYs = [...headY].sort((p, q) => p - q);
4385
- for (const o of orphans) {
4386
- const { i, d } = nearest(o.y);
4387
- if (i < 0 || d > radius(Math.max(...o.toks.map((t) => t.h || 8)))) continue;
4388
- if (headYs.some((hy) => hy !== o.y && (Math.abs(o.y - hy) < d || Math.abs(o.y - hy) === d && hy < outY[i]))) continue;
4389
- add(out[i], o.toks);
4390
- }
4391
- for (const m of markers) {
4392
- const { i, d } = nearest(m.span.y);
4393
- if (i < 0 || d > radius(m.span.h || 8) || out[i].revision) continue;
4394
- const ebox = m.tri ? merge(bboxOf(m.span), m.tri) : bboxOf(m.span);
4395
- out[i].revision = { rev: m.rev, source: { sheet: sheetKey, text: m.span.str.trim(), bbox: ebox }, ...m.drawn ? { drawn: true } : {} };
4396
- }
4397
- if (kind === "finish") {
4398
- const keyCol = cols ? cols.cols[0].label : anchors[0].label;
4564
+ const applyAttach = (S, orphanT, markerT) => {
4565
+ S.orphans.forEach((o, k) => {
4566
+ if (orphanT[k] >= 0) add(S.out[orphanT[k]], o.toks);
4567
+ });
4568
+ S.markers.forEach((m, k) => {
4569
+ const i = markerT[k];
4570
+ if (i < 0 || S.out[i].revision) return;
4571
+ const ebox = m.tri ? merge(bboxOf(m.span), m.tri) : bboxOf(m.span);
4572
+ S.out[i].revision = { rev: m.rev, source: { sheet: sheetKey, text: m.span.str.trim(), bbox: ebox }, ...m.drawn ? { drawn: true } : {} };
4573
+ });
4574
+ };
4575
+ const keyCol = cols ? cols.cols[0].label : anchors[0].label;
4576
+ const printed = (r) => {
4577
+ const keyRest = norm(r.cells[keyCol]?.text ?? r.key).replace(/[^A-Z0-9 ]/g, "").trim().replace(new RegExp("^" + r.key + "\\b"), "").trim();
4578
+ return [...keyRest ? [keyRest] : [], ...Object.entries(r.cells).filter(([k]) => k !== keyCol).map(([, c]) => norm(c.text))];
4579
+ };
4580
+ const prefixes = (label, n) => n.key.startsWith(label + "-") || new RegExp("^" + label + "\\d").test(n.key);
4581
+ const dropGroupLabels = (S) => {
4399
4582
  const dropped = /* @__PURE__ */ new Set();
4400
- for (let i = out.length - 1; i >= 0; i--) {
4401
- const r = out[i];
4583
+ for (let i = S.out.length - 1; i >= 0; i--) {
4584
+ const r = S.out[i];
4402
4585
  if (!/^[A-Z]{1,3}$/.test(r.key)) continue;
4403
- const keyRest = norm(r.cells[keyCol]?.text ?? r.key).replace(/[^A-Z0-9 ]/g, "").trim().replace(new RegExp("^" + r.key + "\\b"), "").trim();
4404
- const texts = [...keyRest ? [keyRest] : [], ...Object.entries(r.cells).filter(([k]) => k !== keyCol).map(([, c]) => norm(c.text))];
4586
+ const texts = printed(r);
4405
4587
  if (texts.length && !texts.some((x) => /^\d{2} ?\d{2}[ .]?\d{2}\b/.test(x))) continue;
4406
- if (out.slice(i + 1, i + 4).some((n) => n.key.startsWith(r.key + "-") || new RegExp("^" + r.key + "\\d").test(n.key))) {
4588
+ if (S.out.slice(i + 1, i + 4).some((n) => prefixes(r.key, n))) {
4407
4589
  dropped.add(r);
4408
- out.splice(i, 1);
4409
- outY.splice(i, 1);
4590
+ dropRowAt(S, i);
4410
4591
  }
4411
4592
  }
4412
- if (dropped.size) {
4413
- region = null;
4414
- for (const r of everRows) if (!dropped.has(r)) for (const c of Object.values(r.cells)) region = region ? merge(region, c.bbox) : c.bbox;
4593
+ return dropped;
4594
+ };
4595
+ const setBuildings = (out) => {
4596
+ for (const row of out) {
4597
+ if (row.building) continue;
4598
+ const cellB = norm(row.cells.BLDG?.text || row.cells.BUILDING?.text || "");
4599
+ if (DESIGNATOR_RE.test(cellB)) row.building = cellB;
4600
+ }
4601
+ };
4602
+ const regionOf = (S, dropped) => {
4603
+ let region = null;
4604
+ for (const r of S.everRows) if (!dropped.has(r)) for (const c of Object.values(r.cells)) region = region ? merge(region, c.bbox) : c.bbox;
4605
+ const lastY = S.outY.length ? S.outY[S.outY.length - 1] : -Infinity;
4606
+ for (const hd of S.headings) if (hd.y <= lastY) region = region ? merge(region, hd.bbox) : hd.bbox;
4607
+ return region;
4608
+ };
4609
+ const markWordSplitNotUsed = (S, keyFirst2) => {
4610
+ if (!keyFirst2) return;
4611
+ S.out.forEach((r, k) => {
4612
+ if (r.notUsed) return;
4613
+ const rest = S.outToks[k].slice(1).filter((t) => columnOf(t) === keyCol).map((t) => t.str.trim()).join(" ");
4614
+ if (!rest || !normalizeNotUsed(rest)) return;
4615
+ r.notUsed = true;
4616
+ r.notUsedText = rest.replace(/^[-–—:,\s]+/, "");
4617
+ });
4618
+ };
4619
+ const P1 = scan();
4620
+ const reset1 = blankBandResets(P1);
4621
+ endCut(P1);
4622
+ const plan1 = attachPlan(P1);
4623
+ const orphanLine1 = new Map(P1.orphans.map((o, k) => [o.i, plan1.orphanT[k] >= 0 ? P1.outI[plan1.orphanT[k]] : -1]));
4624
+ const markerLine1 = plan1.markerT.map((t) => t >= 0 ? P1.outI[t] : -1);
4625
+ const keyedY1 = new Map(P1.outI.map((i, k) => [i, P1.outY[k]]));
4626
+ const everLine1 = new Map(P1.everRows.map((r, k) => [r, P1.everI[k]]));
4627
+ const keyByLine1 = new Map(P1.everRows.map((r, k) => [P1.everI[k], r.key]));
4628
+ applyAttach(P1, plan1.orphanT, plan1.markerT);
4629
+ const dropped1 = finish ? dropGroupLabels(P1) : /* @__PURE__ */ new Set();
4630
+ setBuildings(P1.out);
4631
+ const region1 = regionOf(P1, dropped1);
4632
+ const mq = finish && !!cfg.marqueeRules && cfg.headerY != null;
4633
+ if (!mq) {
4634
+ resetSections(reset1);
4635
+ return { out: P1.out, region: region1 };
4636
+ }
4637
+ const headerY = cfg.headerY;
4638
+ const keyFirst = KEY_FIRST.has(anchors[0]?.label);
4639
+ const p1Lines = new Set(P1.outI);
4640
+ const lowerMedianGap = (ys) => {
4641
+ const d = ys.slice(1).map((y, k) => y - ys[k]).filter((g) => g > 0).sort((p, q) => p - q);
4642
+ return d.length ? d[d.length - 1 >> 1] : 0;
4643
+ };
4644
+ const lastP1Y = P1.outY.length ? P1.outY[P1.outY.length - 1] : -Infinity;
4645
+ const lineYs = [headerY, ...P1.lines.map((l) => l.y)].sort((p, q) => p - q);
4646
+ const linePitch = lowerMedianGap(lineYs.filter((y) => y <= lastP1Y));
4647
+ const keyXs = P1.outToks.map((t) => t[0].x).sort((p, q) => p - q);
4648
+ const keyHdr = cfg.hdrSpans?.find((t) => t.x - 0.5 <= anchors[0].x && anchors[0].x <= t.x + (t.w || 0) + 0.5);
4649
+ const keyRefX = keyXs.length ? keyXs[keyXs.length - 1 >> 1] : keyHdr ? keyHdr.x : anchors[0].x;
4650
+ const textH = (toks) => {
4651
+ const hs = toks.map((t) => t.h || 8).sort((p, q) => p - q);
4652
+ return hs[hs.length >> 1];
4653
+ };
4654
+ const keyCXs = P1.outToks.map((t) => centerX(t[0])).sort((p, q) => p - q);
4655
+ const keyRefCX = keyCXs.length ? keyCXs[keyCXs.length - 1 >> 1] : null;
4656
+ const aligned = (t, h) => inKey(t) && (cols ? Math.abs(atOf(t) - cols.cols[0].start) <= keyTol : Math.abs(t.x - keyRefX) <= Math.max(8, 0.5 * h) || keyRefCX != null && Math.abs(centerX(t) - keyRefCX) <= Math.max(8, 0.5 * h));
4657
+ const gapAbove = (y) => {
4658
+ let above = -Infinity;
4659
+ for (const ly of lineYs) if (ly < y && ly > above) above = ly;
4660
+ return y - above;
4661
+ };
4662
+ const eligible = [];
4663
+ const ocr = !!cfg.ocr;
4664
+ if (keyFirst) {
4665
+ for (const o of P1.orphans) {
4666
+ const h = textH(o.toks);
4667
+ if (!aligned(o.toks[0], h)) continue;
4668
+ const att = orphanLine1.get(o.i) ?? -1, gap = gapAbove(o.y);
4669
+ if (att < 0 || !ocr && gap >= Math.max(1.6 * h, 0.75 * linePitch)) eligible.push({ i: o.i, y: o.y, toks: o.toks, att, gap });
4670
+ else if (ocr && splitKeyCell(o.toks[0].str) && new Set(o.toks.slice(1).map(columnOf).filter((l) => l !== keyCol)).size >= 2) {
4671
+ eligible.push({ i: o.i, y: o.y, toks: o.toks, att, gap, ocrRow: true });
4672
+ }
4415
4673
  }
4416
4674
  }
4417
- for (const row of out) {
4418
- if (row.building) continue;
4419
- const cellB = norm(row.cells.BLDG?.text || row.cells.BUILDING?.text || "");
4420
- if (DESIGNATOR_RE.test(cellB)) row.building = cellB;
4675
+ const newRule = [];
4676
+ const cands = [];
4677
+ const hdrLines = [];
4678
+ const cellsOfLine = (toks) => {
4679
+ const by = /* @__PURE__ */ new Map();
4680
+ for (const t of toks.slice(1)) {
4681
+ const l = columnOf(t);
4682
+ if (l !== keyCol) by.set(l, by.has(l) ? `${by.get(l)} ${t.str.trim()}` : t.str.trim());
4683
+ }
4684
+ return by;
4685
+ };
4686
+ const wordsOfTok = (t) => norm(t.str).split(/[\s/]+/).map((w) => w.replace(/^[^A-Z0-9]+|[^A-Z0-9]+$/g, "")).filter(Boolean);
4687
+ const allHeaderWords = (t) => {
4688
+ const w = wordsOfTok(t);
4689
+ return w.length > 0 && /[A-Z]/.test(norm(t.str)) && w.every((x) => !/\d/.test(x) && HEADER_WORDS.has(x));
4690
+ };
4691
+ const headerLine = (toks) => toks.length > 2 && toks.slice(1).every(allHeaderWords) && toks.slice(1).some((t) => wordsOfTok(t).some((x) => HEADER_WORDS_STRONG.has(x)));
4692
+ const codeShaped = (x) => {
4693
+ const c = norm(x);
4694
+ return CODE_RE.test(c) && /\d/.test(c);
4695
+ };
4696
+ for (const e of eligible) {
4697
+ const s = splitKeyCell(e.toks[0].str);
4698
+ if (s) {
4699
+ newRule.push({ e, s });
4700
+ continue;
4701
+ }
4702
+ const word = norm(e.toks[0].str).replace(/^[^A-Z0-9]+|[^A-Z0-9]+$/g, "");
4703
+ if (LETTERS_RE.test(word) && !e.toks.slice(1).some(inKey)) {
4704
+ if (ocr) continue;
4705
+ if (LETTERS_STOP.has(word)) continue;
4706
+ if (headerLine(e.toks)) {
4707
+ hdrLines.push(e);
4708
+ continue;
4709
+ }
4710
+ const cells = cellsOfLine(e.toks);
4711
+ if ([...cells.values()].filter(codeShaped).length >= 2) continue;
4712
+ cands.push({ e, word, cells });
4713
+ continue;
4714
+ }
4715
+ if (allHeaderWords(e.toks[0]) && headerLine(e.toks)) hdrLines.push(e);
4716
+ }
4717
+ if (cands.length) {
4718
+ const decidedLines = /* @__PURE__ */ new Set([...newRule.map((n) => n.e.i), ...cands.map((c) => c.e.i), ...hdrLines.map((e) => e.i)]);
4719
+ const wraps = [];
4720
+ for (const o of P1.orphans) {
4721
+ const t1 = orphanLine1.get(o.i) ?? -1;
4722
+ const rowY1 = keyedY1.get(t1);
4723
+ if (t1 < 0 || rowY1 == null || decidedLines.has(o.i) || o.y <= rowY1) continue;
4724
+ wraps.push({ row: t1, off: o.y - rowY1 });
4725
+ }
4726
+ const rowAbove = (y) => {
4727
+ let best = null;
4728
+ P1.outY.forEach((ry, k) => {
4729
+ if (ry < y && (!best || ry > best.y)) best = { row: P1.outI[k], y: ry, cells: P1.outToks[k].slice(1).some((t) => columnOf(t) !== keyCol) };
4730
+ });
4731
+ return best;
4732
+ };
4733
+ for (let k = cands.length - 1; k >= 0; k--) {
4734
+ const c = cands[k], up = rowAbove(c.e.y);
4735
+ if (!up || !up.cells) continue;
4736
+ const off = c.e.y - up.y, tol = Math.max(1, 0.1 * textH(c.e.toks));
4737
+ if (wraps.some((w) => w.row !== up.row && Math.abs(w.off - off) <= tol)) cands.splice(k, 1);
4738
+ }
4739
+ }
4740
+ const rowGap = lowerMedianGap(P1.outY);
4741
+ const runPitch = P1.outY.length > 1 ? rowGap : medPitchLo;
4742
+ const walk = [
4743
+ ...P1.outY.map((y, k) => ({ y, kind: "row", at: k })),
4744
+ ...P1.headings.map((h, k) => ({ y: h.y, kind: "head", at: k })),
4745
+ ...newRule.map((n, k) => ({ y: n.e.y, kind: "new", at: k })),
4746
+ ...cands.map((c, k) => ({ y: c.e.y, kind: "cand", at: k }))
4747
+ ].sort((p, q) => p.y - q.y);
4748
+ const reachedDown = /* @__PURE__ */ new Set(), reached = /* @__PURE__ */ new Set();
4749
+ let prev = headerY;
4750
+ for (const w of walk) {
4751
+ if (w.y - prev <= (w.kind === "row" ? 8 : 2.5) * runPitch) {
4752
+ reachedDown.add(w);
4753
+ reached.add(w);
4754
+ prev = w.y;
4755
+ }
4756
+ }
4757
+ let next = Infinity;
4758
+ for (let k = walk.length - 1; k >= 0; k--) {
4759
+ const w = walk[k];
4760
+ if (w.kind === "row") {
4761
+ next = reachedDown.has(w) ? w.y : Infinity;
4762
+ continue;
4763
+ }
4764
+ if (w.kind === "head") continue;
4765
+ if (next - w.y <= 2.5 * runPitch) {
4766
+ reached.add(w);
4767
+ next = w.y;
4768
+ } else next = Infinity;
4769
+ }
4770
+ const runHasSection = walk.some((w) => w.kind === "head" && reachedDown.has(w) && P1.headings[w.at].section != null);
4771
+ const newReached = new Set(walk.filter((w) => w.kind === "new" && reached.has(w)).map((w) => w.at));
4772
+ const reachedNew = newRule.filter((_n, k) => newReached.has(k));
4773
+ const p1Keys = new Set(P1.out.map((r, k) => {
4774
+ const s = splitKeyCell(P1.outToks[k][0].str);
4775
+ return s?.notUsed ? s.key : r.key;
4776
+ }));
4777
+ const surviving = reachedNew.map((n) => {
4778
+ if (n.s.qualifier == null) return n;
4779
+ const clash = p1Keys.has(n.s.key) || reachedNew.some((o) => o !== n && o.s.key === n.s.key);
4780
+ if (!clash) return n;
4781
+ const key = finishKeyText(`${n.s.key} ${n.s.qualifier}`.toUpperCase()).replace(/[^A-Z0-9/()-]/g, "");
4782
+ return { e: n.e, s: { key } };
4783
+ });
4784
+ const rekey = /* @__PURE__ */ new Map();
4785
+ if (keyFirst) {
4786
+ const p1Split = P1.out.map((r, k) => ({ i: P1.outI[k], key: r.key, s: splitKeyCell(P1.outToks[k][0].str) }));
4787
+ for (const h of p1Split) if (h.s?.notUsed) rekey.set(h.i, { key: h.s.key, notUsed: true, notUsedText: h.s.notUsedText });
4788
+ const finalKeys = [...p1Split.map((h) => ({ i: h.i, key: h.s?.notUsed ? h.s.key : h.key })), ...surviving.map((n) => ({ i: n.e.i, key: n.s.key }))];
4789
+ const splitters = p1Split.filter((h) => h.s?.qualifier != null && !isAltQualifier(h.s.qualifier));
4790
+ for (const h of splitters) {
4791
+ const clash = finalKeys.some((f) => f.i !== h.i && f.key === h.s.key);
4792
+ const twin = splitters.some((o) => o !== h && o.s.key === h.s.key);
4793
+ if (!clash && !twin) rekey.set(h.i, { key: h.s.key, qualifier: h.s.qualifier });
4794
+ }
4795
+ }
4796
+ const fate = /* @__PURE__ */ new Map();
4797
+ const keyedRows = [...P1.out.map((r, k) => ({ key: r.key, y: P1.outY[k] })), ...surviving.map((n) => ({ key: n.s.key, y: n.e.y }))].sort((p, q) => p.y - q.y);
4798
+ const numberedYs = keyedRows.filter((r) => /\d/.test(r.key)).map((r) => r.y);
4799
+ for (let k = 0; k < walk.length; k++) {
4800
+ const w = walk[k];
4801
+ if (w.kind !== "cand") continue;
4802
+ const c = cands[w.at];
4803
+ if (!reached.has(w)) {
4804
+ fate.set(w.at, "out");
4805
+ continue;
4806
+ }
4807
+ const prefixed = keyedRows.filter((r) => r.y > c.e.y).slice(0, 3).some((r) => r.key.startsWith(c.word + "-") || new RegExp("^" + c.word + "\\d").test(r.key));
4808
+ if (prefixed && c.cells.size <= 2 && [...c.cells.values()].some((x) => SPEC_SECTION_RE.test(norm(x)))) {
4809
+ fate.set(w.at, "s");
4810
+ continue;
4811
+ }
4812
+ if (prefixed && c.cells.size === 1) {
4813
+ fate.set(w.at, "g");
4814
+ continue;
4815
+ }
4816
+ let above = null;
4817
+ for (let j = k - 1; j >= 0 && !above; j--) {
4818
+ const l = walk[j];
4819
+ if (l.kind === "head") continue;
4820
+ if (l.kind === "cand" && fate.get(l.at) !== "row") continue;
4821
+ if (l.kind === "new" && !newReached.has(l.at)) continue;
4822
+ above = l;
4823
+ }
4824
+ const aboveOk = !above || above.kind === "row" || above.kind === "new" || above.kind === "cand" && fate.get(above.at) === "row";
4825
+ fate.set(w.at, aboveOk && numberedYs.some((y) => y > c.e.y) && c.cells.size >= 2 ? "row" : "skipped");
4826
+ }
4827
+ const letterRows = cands.filter((_c, k) => fate.get(k) === "row");
4828
+ const drops = [
4829
+ ...hdrLines.map((e) => ({ e, reason: "h" })),
4830
+ ...cands.flatMap((c, k) => {
4831
+ const f = fate.get(k);
4832
+ return f === "s" || f === "g" || f === "skipped" ? [{ e: c.e, reason: f, word: c.word }] : [];
4833
+ })
4834
+ ].sort((p, q) => p.e.y - q.e.y);
4835
+ const skipped = drops.filter((d) => d.reason === "skipped").map((d) => d.word);
4836
+ const diagOf = (e, kind2) => ({ y: e.y, kind: kind2, gapAbove: e.gap, linePitch, runPitch });
4837
+ const diag = [
4838
+ ...surviving.map((n) => diagOf(n.e, "new")),
4839
+ ...letterRows.map((c) => diagOf(c.e, "new")),
4840
+ ...drops.map((d) => diagOf(d.e, d.reason === "skipped" ? "skipped" : "consumed"))
4841
+ ].sort((p, q) => p.y - q.y);
4842
+ const ownerOf = (line) => line >= 0 ? keyByLine1.get(line) ?? null : null;
4843
+ const textOf = (toks) => toks.map((t) => t.str.trim()).join(" ");
4844
+ const consumed = drops.map((d) => ({ text: textOf(d.e.toks), y: d.e.y, reason: d.reason, owner: ownerOf(d.e.att) }));
4845
+ const guard = { pass1Region: region1, pass1Rows: P1.out.length, pass1HasSection: P1.out.some((r) => !!r.section), runHasSection };
4846
+ const meta = /* @__PURE__ */ new Map();
4847
+ if (!surviving.length && !rekey.size && !letterRows.length && !drops.length) {
4848
+ for (let k = 0; k < P1.out.length; k++) meta.set(P1.out[k], { y: P1.outY[k], pass1: true });
4849
+ markWordSplitNotUsed(P1, keyFirst);
4850
+ resetSections(reset1);
4851
+ return { out: P1.out, region: region1, marquee: { ...guard, meta, consumed, diag, skipped } };
4852
+ }
4853
+ const over = new Map(rekey);
4854
+ for (const n of surviving) over.set(n.e.i, { key: n.s.key, qualifier: n.s.qualifier, notUsed: n.s.notUsed, notUsedText: n.s.notUsedText });
4855
+ for (const c of letterRows) over.set(c.e.i, { key: c.word });
4856
+ const dropLines = new Set(drops.map((d) => d.e.i));
4857
+ const P2 = scan(over, dropLines, ocr ? new Set(surviving.map((n) => n.e.i)) : void 0);
4858
+ const reset2 = blankBandResets(P2);
4859
+ endCut(P2);
4860
+ const lettersLines = new Set(letterRows.map((c) => c.e.i));
4861
+ const newLines = /* @__PURE__ */ new Set([...surviving.map((n) => n.e.i), ...lettersLines]);
4862
+ const at2 = new Map(P2.outI.map((i, k) => [i, k]));
4863
+ const p1Ys = [...keyedY1.entries()].sort((p, q) => p[1] - q[1]);
4864
+ const moved = /* @__PURE__ */ new Map();
4865
+ const dropCont = /* @__PURE__ */ new Set();
4866
+ const ungluedFrom = /* @__PURE__ */ new Map();
4867
+ const decided = [...surviving.map((n) => ({ e: n.e, drop: false })), ...letterRows.map((c) => ({ e: c.e, drop: false })), ...drops.map((d) => ({ e: d.e, drop: true }))].sort((p, q) => p.e.y - q.e.y);
4868
+ for (const d of decided) {
4869
+ const tookFrom = d.e.toks.map((t) => ({ from: ownerOf(d.e.att), text: t.str.trim() }));
4870
+ const nextKeyed = Math.min(...P2.outY.filter((y) => y > d.e.y), ...decided.map((x) => x.e.y).filter((y) => y > d.e.y), Infinity);
4871
+ const nextP1 = p1Ys.find(([, y]) => y > d.e.y);
4872
+ let lastY = d.e.y;
4873
+ for (const o of P2.orphans.filter((q) => q.y > d.e.y && q.y < nextKeyed).sort((p, q) => p.y - q.y)) {
4874
+ const t1 = orphanLine1.get(o.i) ?? -1;
4875
+ if (o.y - lastY > plan1.radius(Math.max(...o.toks.map((t) => t.h || 8)))) break;
4876
+ const take = t1 < 0 || t1 === d.e.att || !!nextP1 && t1 === nextP1[0] && o.y - lastY < nextP1[1] - o.y;
4877
+ if (!take) break;
4878
+ if (d.drop) {
4879
+ dropCont.add(o.i);
4880
+ consumed.push({ text: textOf(o.toks), y: o.y, reason: "continuation", owner: ownerOf(t1) });
4881
+ } else {
4882
+ moved.set(o.i, d.e.i);
4883
+ for (const t of o.toks) tookFrom.push({ from: ownerOf(t1), text: t.str.trim() });
4884
+ }
4885
+ lastY = o.y;
4886
+ }
4887
+ if (!d.drop) ungluedFrom.set(d.e.i, tookFrom);
4421
4888
  }
4422
- const lastY = outY.length ? outY[outY.length - 1] : -Infinity;
4423
- for (const hd of headings) if (hd.y <= lastY) region = region ? merge(region, hd.bbox) : hd.bbox;
4424
- return { out, region };
4889
+ consumed.sort((p, q) => p.y - q.y);
4890
+ const toNewRow = (y, h) => {
4891
+ const { i, d } = plan1.nearestIn(P2.outY, y);
4892
+ if (i < 0 || d > plan1.radius(h) || plan1.byHeading(y, d, P2.outY[i])) return -1;
4893
+ return newLines.has(P2.outI[i]) ? i : -1;
4894
+ };
4895
+ const orphanT2 = P2.orphans.map((o) => {
4896
+ if (dropCont.has(o.i)) return -1;
4897
+ const to = moved.get(o.i);
4898
+ if (to != null) return at2.get(to) ?? -1;
4899
+ const t1 = orphanLine1.get(o.i) ?? -1;
4900
+ if (t1 >= 0) return at2.get(t1) ?? -1;
4901
+ return toNewRow(o.y, Math.max(...o.toks.map((t) => t.h || 8)));
4902
+ });
4903
+ const markerT2 = P2.markers.map((m, k) => {
4904
+ if (dropLines.has(m.i)) return -1;
4905
+ if (newLines.has(m.i)) return at2.get(m.i) ?? -1;
4906
+ const t1 = markerLine1[k];
4907
+ if (t1 >= 0) return at2.get(t1) ?? -1;
4908
+ return toNewRow(m.span.y, m.span.h || 8);
4909
+ });
4910
+ applyAttach(P2, orphanT2, markerT2);
4911
+ const droppedLines1 = new Set([...dropped1].map((r) => everLine1.get(r)));
4912
+ const emptied = /* @__PURE__ */ new Set();
4913
+ for (const o of P1.orphans) {
4914
+ const t1 = orphanLine1.get(o.i) ?? -1;
4915
+ if (t1 >= 0 && (newLines.has(o.i) || moved.has(o.i) || dropLines.has(o.i) || dropCont.has(o.i))) emptied.add(t1);
4916
+ }
4917
+ const dropped2 = /* @__PURE__ */ new Set();
4918
+ for (let k = P2.out.length - 1; k >= 0; k--) if (droppedLines1.has(P2.outI[k])) {
4919
+ dropped2.add(P2.out[k]);
4920
+ dropRowAt(P2, k);
4921
+ }
4922
+ for (let k = P2.out.length - 1; k >= 0; k--) {
4923
+ const r = P2.out[k];
4924
+ if (!emptied.has(P2.outI[k]) || !/^[A-Z]{1,3}$/.test(r.key) || printed(r).length) continue;
4925
+ if (P2.out.slice(k + 1, k + 4).some((n) => prefixes(r.key, n))) {
4926
+ dropped2.add(r);
4927
+ dropRowAt(P2, k);
4928
+ }
4929
+ }
4930
+ setBuildings(P2.out);
4931
+ const region2 = regionOf(P2, dropped2);
4932
+ for (let k = 0; k < P2.out.length; k++) {
4933
+ const i = P2.outI[k], r = P2.out[k];
4934
+ const m = { y: P2.outY[k], pass1: p1Lines.has(i) };
4935
+ if (newLines.has(i)) {
4936
+ m.newRule = true;
4937
+ m.ungluedFrom = ungluedFrom.get(i);
4938
+ }
4939
+ if (lettersLines.has(i)) m.lettersKey = true;
4940
+ if (rekey.has(i)) m.pass1Key = keyByLine1.get(i);
4941
+ meta.set(r, m);
4942
+ }
4943
+ markWordSplitNotUsed(P2, keyFirst);
4944
+ resetSections(reset2);
4945
+ return { out: P2.out, region: region2, marquee: { ...guard, meta, consumed, diag, skipped } };
4425
4946
  }
4426
4947
  function extractTable(sheet, kind, opts = {}) {
4427
4948
  const r = extractTableCore(sheet, kind, opts);
@@ -4453,7 +4974,7 @@ function extractTables(sheet, kind, opts = {}) {
4453
4974
  }
4454
4975
  return out;
4455
4976
  }
4456
- function extractTableCore(sheet, kind, opts = {}) {
4977
+ function extractTableCore(sheet, kind, opts = {}, core = {}) {
4457
4978
  const horiz = sheet.spans.filter((s) => !isVertical(s));
4458
4979
  const vert = sheet.spans.filter(isVertical);
4459
4980
  const rows = clusterRows(horiz);
@@ -4467,6 +4988,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4467
4988
  let dataBelowY = -Infinity;
4468
4989
  let titleFrom;
4469
4990
  let rotated = false;
4991
+ let headerY;
4470
4992
  const flat = findHeaderRow(rows, vocab, required, minHits);
4471
4993
  if (flat) {
4472
4994
  anchors = flat.anchors;
@@ -4481,6 +5003,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4481
5003
  hdrBlock = [...band].flatMap((k) => rows[k]);
4482
5004
  dataFrom = flat.rowIndex + 1;
4483
5005
  titleFrom = flat.rowIndex - 1;
5006
+ headerY = hy;
4484
5007
  } else {
4485
5008
  const rot = findRotatedHeader(vert, vocab, required, minHits);
4486
5009
  if (!rot) return null;
@@ -4488,6 +5011,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4488
5011
  anchors = rot.anchors;
4489
5012
  headerSpans = rot.spans;
4490
5013
  dataBelowY = rot.bottom - 2;
5014
+ headerY = rot.bottom;
4491
5015
  dataFrom = 0;
4492
5016
  titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
4493
5017
  if (titleFrom < -1) titleFrom = rows.length - 1;
@@ -4503,23 +5027,38 @@ function extractTableCore(sheet, kind, opts = {}) {
4503
5027
  if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
4504
5028
  region = region ? merge(region, bboxOf(t)) : bboxOf(t);
4505
5029
  }
4506
- const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers, hdrSpans: headerSpans, hdrBand: hdrBlock, marquee: opts.marquee });
5030
+ const marqueeRules = kind === "finish" && !!core.marqueeRules;
5031
+ const headerRegion = region;
5032
+ const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers, hdrSpans: headerSpans, hdrBand: hdrBlock, marquee: opts.marquee, resetAtBlankBand: opts.resetAtBlankBand, ...marqueeRules ? { marqueeRules, headerY, ...core.ocr ? { ocr: true } : {} } : {} });
4507
5033
  const out = banded.out;
4508
5034
  if (banded.region) region = region ? merge(region, banded.region) : banded.region;
5035
+ const { x0, x1 } = bandLimits(anchors);
5036
+ const findTitle = () => {
5037
+ let title2 = null;
5038
+ for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title2; i--) {
5039
+ const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
5040
+ if (hit) title2 = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
5041
+ }
5042
+ return title2;
5043
+ };
5044
+ const wordsOf = () => headerSpans.flatMap((t) => t.str.toUpperCase().split(/[^A-Z]+/)).filter(Boolean);
4509
5045
  if (!out.length) {
4510
5046
  if (kind === "equipment" && region) return { skip: region };
5047
+ if (marqueeRules && headerRegion) {
5048
+ const mb2 = banded.marquee;
5049
+ return { headerOnly: { title: findTitle(), headers: anchors.map((a) => a.label), headerWords: wordsOf(), hasSection: mb2.runHasSection, region: headerRegion, skipped: mb2.skipped, trace: { meta: mb2.meta, consumed: mb2.consumed, diag: mb2.diag } } };
5050
+ }
4511
5051
  return null;
4512
5052
  }
4513
- const { x0, x1 } = bandLimits(anchors);
4514
- let title = null;
4515
- for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
4516
- const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
4517
- if (hit) title = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
4518
- }
5053
+ const title = findTitle();
4519
5054
  const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
4520
5055
  if (rotated) table.rotated_headers = true;
4521
- const headerWords = headerSpans.flatMap((t) => t.str.toUpperCase().split(/[^A-Z]+/)).filter(Boolean);
4522
- return { table, headerWords };
5056
+ const headerWords = wordsOf();
5057
+ if (!marqueeRules) return { table, headerWords };
5058
+ const mb = banded.marquee;
5059
+ const guardRegion = mb.pass1Rows ? headerRegion && mb.pass1Region ? merge(headerRegion, mb.pass1Region) : headerRegion ?? mb.pass1Region : headerRegion ?? region;
5060
+ const hasSection = mb.pass1Rows ? mb.pass1HasSection : mb.runHasSection;
5061
+ return { table, headerWords, guard: { guardRegion, hasSection, skipped: mb.skipped, trace: { meta: mb.meta, consumed: mb.consumed, diag: mb.diag } } };
4523
5062
  }
4524
5063
  var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
4525
5064
  var isContinuationTitle = (text) => {
@@ -4878,7 +5417,7 @@ function resolveTag(graph, tag) {
4878
5417
  for (const surface of surfaces) {
4879
5418
  const cell = r.cells[surface];
4880
5419
  if (!cell || !cell.text.trim()) continue;
4881
- const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
5420
+ const code = finishKeyText(norm(cell.text)).replace(/[^A-Z0-9()-]/g, "");
4882
5421
  const fin = { surface, code: cell.text.trim(), source: { sheet: r.sheet, text: cell.text.trim(), bbox: cell.bbox } };
4883
5422
  for (const ft of finTables) {
4884
5423
  const def = ft.rows.find((fr) => rowKeyAnswersFor(fr.key, code));
@@ -9192,7 +9731,7 @@ var Session = class _Session {
9192
9731
  if (assign) {
9193
9732
  if (s.upp == null) throw new UserError(this.scaleGate(s));
9194
9733
  graph = await this.ensureGraph();
9195
- if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
9734
+ if (!graph.available) throw new UserError("This set has no text layer (a raster image) \u2014 the sheet graph is unavailable, not empty.");
9196
9735
  if (!graph.tables.some((t) => t.kind === "room-finish")) {
9197
9736
  throw new UserError("No room-finish schedule in the working set \u2014 load_plan the schedule sheet with merge: true, or pass condition to commit every room under one tag.");
9198
9737
  }
@@ -16062,7 +16601,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
16062
16601
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
16063
16602
 
16064
16603
  // src/wiki.generated.ts
16065
- var WIKI_VERSION = "0.9.94";
16604
+ var WIKI_VERSION = "0.9.98";
16066
16605
  var WIKI_PAGES = [
16067
16606
  {
16068
16607
  "key": "index",
@@ -16077,8 +16616,8 @@ var WIKI_PAGES = [
16077
16616
  "uri": "takeoff://wiki/status",
16078
16617
  "title": "Capability status",
16079
16618
  "source": "docs/wiki/status.md",
16080
- "source_sha256": "a5957a23818ad11fedc3af59af7ae1c094267873dbc306db7d1145154816b148",
16081
- "text": "# Capability status\n\nThis page describes implementation support, not a promise that every plan can\nbe measured automatically. Follow its source links before changing behavior.\n\n| Capability | Current boundary | Evidence |\n|---|---|---|\n| Human and agent measurements | Shared geometry/quantity modules; source interpretation still requires inspection | [Geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md) |\n| Original machine geometry after human correction | Outer vertices preserved, including manual agent traces; missing historical originals cannot be recovered | [Compatibility](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md) |\n| Human stitching | Browser gallery and alignment; editable browser archive preserves composites | [Workflow](takeoff://wiki/workflows) |\n| MCP stitched documents | No stitch tool; current import/export omits stitch records | [Field inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) |\n| One-Click | Temporarily gated in the default browser/MCP builds | [Gate](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/design/ONE_CLICK_GATE.md) |\n| Browser/MCP room boundaries | Shared quantity modules do not prove identical detector boundaries; the open [#385](https://github.com/Kentucky-ai/opentakeoff/issues/385) report remains unresolved | [Architecture](takeoff://wiki/architecture), [geometry evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md) |\n| Plan-set search, Copy text, on-device page reads | Browser gallery and canvas; MCP text tools read the text layer only, no OCR. OCR reads scanned pages after a download notice and is labelled OCR; Copy text works on single sheets, not stitched surfaces. Kept OCR reads stay in this browser after sign-out, kept by the PDF's content hash | [User guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#copy-text) |\n| Toolbar command and voice | Gated in the default build; use normal controls and keyboard shortcuts | [User guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#17-voice-and-the-command-box) |\n| TakeoffDocument v1 | Draft schemas, preflight and opt-in repository adapters for a bounded profile; legacy writer format remains | [Protocol](takeoff://wiki/protocol) |\n| Annotation cleanup | Text-only MCP edit with undo; RFI-linked notes refuse | [MCP routing](takeoff://wiki/mcp) |\n| Agent knowledge | Packaged wiki resources and source-generated tool/schema references | [Index](takeoff://wiki) |\n| Academy interoperability | Incompatibilities inventoried; no adapter or certification bridge claimed | [Academy report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md) |\n| Shared work review | Personal layouts/shared review proposal is not merged; do not describe [PR #388](https://github.com/Kentucky-ai/opentakeoff/pull/388) as shipped | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Microsoft 365 annotation sync | Unproven across machines; the live validation remains open in [#315](https://github.com/Kentucky-ai/opentakeoff/issues/315) | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Learned floor-model bridge | Not on `main`; no model-generated geometry integration is advertised | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Independent geometry benchmark | Reference-assisted and synthetic checks are not independently reviewed ground truth | [Geometry evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md#reusable-evidence-from-a-project-test) |\n\nNext protocol work connects the tested contracts to focused MCP workflows and\nmeasures tool-selection/completion results. Broader adapter profiles remain\nbounded by preservation tests. Package extraction, verified identity/signatures, Academy credentials\nand optional anchoring come later. Rendering optimization is separate from\nthis protocol and knowledge work.\n"
16619
+ "source_sha256": "071d431c7dfeedfccd9864dbfb7e560b89cf718b1e71347baf88e7c6a1fcb95e",
16620
+ "text": "# Capability status\n\nThis page describes implementation support, not a promise that every plan can\nbe measured automatically. Follow its source links before changing behavior.\n\n| Capability | Current boundary | Evidence |\n|---|---|---|\n| Human and agent measurements | Shared geometry/quantity modules; source interpretation still requires inspection | [Geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md) |\n| Original machine geometry after human correction | Outer vertices preserved, including manual agent traces; missing historical originals cannot be recovered | [Compatibility](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md) |\n| Human stitching | Browser gallery and alignment; editable browser archive preserves composites | [Workflow](takeoff://wiki/workflows) |\n| MCP stitched documents | No stitch tool; current import/export omits stitch records | [Field inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) |\n| One-Click | Temporarily gated in the default browser/MCP builds | [Gate](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/design/ONE_CLICK_GATE.md) |\n| Browser/MCP room boundaries | Shared quantity modules do not prove identical detector boundaries; the open [#385](https://github.com/Kentucky-ai/opentakeoff/issues/385) report remains unresolved | [Architecture](takeoff://wiki/architecture), [geometry evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md) |\n| Plan-set search, Copy text, on-device page reads | Browser gallery and canvas; MCP text tools read the text layer only, no OCR. OCR reads scanned pages, and the pictures on a vector sheet that its text layer can't read, after a download notice and is labelled OCR; Copy text works on single sheets, not stitched surfaces. Import from schedule reads a raster schedule box on-device after the same notice; the in-canvas agent's `read_schedule` reads the text layer only. Kept OCR reads stay in this browser after sign-out, kept by the PDF's content hash | [User guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#copy-text) |\n| Import from schedule\u2014extra forms | Drawn box only: codes followed by a word (FTB-01 CUT) read as the code; NOT USED / N.I.C. rows start unticked; on a text-layer box, a four- or five-letter code with no number is read when the header or a read row is above it, it fills two or more other columns, and a numbered code comes after it, else listed as not read (the on-device raster read neither reads nor lists them, and there a NOT USED or word-after code line in a short table can stay part of the row above; it repairs a `$`, O or I misread in a code and flags the row **read as**); the sheet graph's whole-sheet index reads them as before | [User guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#import-from-schedule) |\n| Toolbar command and voice | Gated in the default build; use normal controls and keyboard shortcuts | [User guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#17-voice-and-the-command-box) |\n| TakeoffDocument v1 | Draft schemas, preflight and opt-in repository adapters for a bounded profile; legacy writer format remains | [Protocol](takeoff://wiki/protocol) |\n| Annotation cleanup | Text-only MCP edit with undo; RFI-linked notes refuse | [MCP routing](takeoff://wiki/mcp) |\n| Agent knowledge | Packaged wiki resources and source-generated tool/schema references | [Index](takeoff://wiki) |\n| Academy interoperability | Incompatibilities inventoried; no adapter or certification bridge claimed | [Academy report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md) |\n| Shared work review | Personal layouts/shared review proposal is not merged; do not describe [PR #388](https://github.com/Kentucky-ai/opentakeoff/pull/388) as shipped | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Microsoft 365 annotation sync | Unproven across machines; the live validation remains open in [#315](https://github.com/Kentucky-ai/opentakeoff/issues/315) | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Learned floor-model bridge | Not on `main`; no model-generated geometry integration is advertised | [Roadmap](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/ROADMAP.md) |\n| Independent geometry benchmark | Reference-assisted and synthetic checks are not independently reviewed ground truth | [Geometry evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md#reusable-evidence-from-a-project-test) |\n\nNext protocol work connects the tested contracts to focused MCP workflows and\nmeasures tool-selection/completion results. Broader adapter profiles remain\nbounded by preservation tests. Package extraction, verified identity/signatures, Academy credentials\nand optional anchoring come later. Rendering optimization is separate from\nthis protocol and knowledge work.\n"
16082
16621
  },
16083
16622
  {
16084
16623
  "key": "architecture",
@@ -16125,8 +16664,8 @@ var WIKI_PAGES = [
16125
16664
  "uri": "takeoff://wiki/repo-guide",
16126
16665
  "title": "Working on the repository",
16127
16666
  "source": "docs/wiki/repo-guide.md",
16128
- "source_sha256": "9ef949a3dca1f992a005764a825ca86b5f4da1fd631e9eed1011b8d6fe967ab3",
16129
- "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"
16667
+ "source_sha256": "2b68aee9d7a547ca2385613534d2d9d08c597b56790490f7283b494a442c4d4e",
16668
+ "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. AGPL-3.0-only. (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"
16130
16669
  },
16131
16670
  {
16132
16671
  "key": "tool-index",
@@ -16673,11 +17212,11 @@ function nameTheStageInRefusals(server) {
16673
17212
  // package.json
16674
17213
  var package_default = {
16675
17214
  name: "opentakeoff-mcp",
16676
- version: "0.9.94",
17215
+ version: "0.9.98",
16677
17216
  mcpName: "io.github.Kentucky-ai/opentakeoff",
16678
17217
  type: "module",
16679
17218
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
16680
- license: "Apache-2.0",
17219
+ license: "AGPL-3.0-only",
16681
17220
  engines: {
16682
17221
  node: ">=20"
16683
17222
  },
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.94",
3
+ "version": "0.9.98",
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.",
7
- "license": "Apache-2.0",
7
+ "license": "AGPL-3.0-only",
8
8
  "engines": {
9
9
  "node": ">=20"
10
10
  },