opentakeoff-mcp 0.9.93 → 0.9.96

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 +659 -154
  2. package/package.json +1 -1
@@ -3466,6 +3466,32 @@ 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})?$/;
3493
+ var finishCodeOk = (p) => !/^[A-Z]{4,}$/.test(p) && CODE_RE.test(p);
3494
+
3469
3495
  // ../web/src/lib/sheetgraph.ts
3470
3496
  var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
3471
3497
  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 +3650,7 @@ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
3624
3650
  var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
3625
3651
  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
3652
  var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
3653
+ 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
3654
  var EQUIPMENT_HEADERS = [
3628
3655
  // identity + the shared columns
3629
3656
  "ID",
@@ -3995,8 +4022,6 @@ function bandLimits(anchors) {
3995
4022
  const rightMargin = WIDE_LAST.has(last.label) ? Math.max(300, medGap * 3) : Math.max(120, medGap);
3996
4023
  return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: last.x + rightMargin, medGap };
3997
4024
  }
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
4025
  var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
4001
4026
  var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
4002
4027
  var CORRIDOR_KEY_RE = /^[A-Z]{1,3}(?:\d{1,3}-\d{1,3}|\d{3})[A-Z]?$/;
@@ -4025,6 +4050,24 @@ function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
4025
4050
  if (q && buildings?.has(q[1])) return { key, building: q[1] };
4026
4051
  return null;
4027
4052
  }
4053
+ var SPLIT_DENY = /* @__PURE__ */ new Set(["SEE", "NOTE", "NOTES", "NOT", "USED", "BY", "OWNER", "NIC", "TBD", "ALL", "THRU", "TO", "AND", "OR"]);
4054
+ var ALT_WORDS = /* @__PURE__ */ new Set(["ALT", "OPT", "OPTION", "ADD", "DEDUCT"]);
4055
+ function splitKeyCell(raw) {
4056
+ const r = raw.trim();
4057
+ const sp = r.search(/\s/);
4058
+ const w1 = (sp < 0 ? r : r.slice(0, sp)).toUpperCase().replace(/[:,;]+$/, "");
4059
+ if (!finishCodeOk(w1) || !/\d/.test(w1)) return null;
4060
+ const after = sp < 0 ? "" : r.slice(sp).replace(/^[-–—:,\s]+/, "");
4061
+ const tail = normalizeTail(after);
4062
+ if (tail === "NOT USED" || tail === "NOT IN CONTRACT" || tail === "NIC") return { key: w1, notUsed: true, notUsedText: after };
4063
+ const words = tail ? tail.split(" ") : [];
4064
+ if (words.length < 1 || words.length > 2) return null;
4065
+ if (!words.every((w) => /^[A-Z]{2,8}$/.test(w) || /^\([A-Z]{1,8}\)$/.test(w))) return null;
4066
+ const bare = words.map((w) => w.replace(/[()]/g, ""));
4067
+ if (bare.some((w) => SPLIT_DENY.has(w)) || bare.join("").length < 3) return null;
4068
+ return { key: w1, qualifier: after };
4069
+ }
4070
+ var isAltQualifier = (q) => ALT_WORDS.has(q.toUpperCase().replace(/[()]/g, "").trim());
4028
4071
  var rowKeyAnswersFor = (key, want) => {
4029
4072
  const c = norm(key).replace(/\s+/g, "");
4030
4073
  const w = norm(want).replace(/\s+/g, "");
@@ -4195,6 +4238,58 @@ function columnStarts(rows, anchors, cfg, x0, x1, kind) {
4195
4238
  if (!center) return left;
4196
4239
  return center.score > left.score + 0.05 ? center : left;
4197
4240
  }
4241
+ var KEY_FIRST = /* @__PURE__ */ new Set(["CODE", "MARK", "SYMBOL", "TAG"]);
4242
+ var LETTERS_RE = /^[A-Z]{4,5}$/;
4243
+ var OTHER_FAMILY_WORDS = ["DOOR", "WINDOW", "PARTITION", "EQUIPMENT", "HARDWARE", "LOUVER", "SIGNAGE", "LIGHTING", "LUMINAIRE", "PLUMBING", "MECHANICAL", "ELECTRICAL", "STOREFRONT", "GLAZING", "CASEWORK", "MILLWORK", "APPLIANCE"];
4244
+ var LETTERS_STOP = /* @__PURE__ */ new Set([
4245
+ ...FINISH_HEADERS,
4246
+ ...EQUIPMENT_HEADERS,
4247
+ ...ROOM_HEADERS,
4248
+ ...FINISH_SECTION_HEADINGS.filter((h) => !h.includes(" ")),
4249
+ ...OTHER_FAMILY_WORDS.flatMap((w) => [w, w + "S"]),
4250
+ "LEGEND",
4251
+ "NOTE",
4252
+ "NOTES",
4253
+ "TYPE",
4254
+ "ITEM",
4255
+ "ROOM",
4256
+ "LEVEL",
4257
+ "AREA",
4258
+ "FIELD",
4259
+ "WHERE",
4260
+ "PATCH",
4261
+ "MATCH",
4262
+ "REFER",
4263
+ "STAIR",
4264
+ "ABOVE",
4265
+ "APPLY",
4266
+ "CAULK",
4267
+ "COVE",
4268
+ "COVED",
4269
+ "MFG",
4270
+ "MFR",
4271
+ "SPEC",
4272
+ "PAINT",
4273
+ "TILE",
4274
+ "STONE",
4275
+ "VINYL",
4276
+ "WOOD",
4277
+ "GLASS",
4278
+ "METAL",
4279
+ "EPOXY",
4280
+ "STAIN",
4281
+ "GROUT",
4282
+ // sheens and formats: a key-column wrap under a code (PT-1 / SATIN)
4283
+ "SATIN",
4284
+ "GLOSS",
4285
+ "MATTE",
4286
+ "FLAT",
4287
+ "HONED",
4288
+ "PLANK"
4289
+ ]);
4290
+ var HEADER_WORDS_STRONG = /* @__PURE__ */ new Set([...FINISH_HEADERS, ...EQUIPMENT_HEADERS, ...FOREIGN_HDR, "MFG", "MFR", "SPEC", "SPECIFICATION", "NOTES"]);
4291
+ var HEADER_WORDS = /* @__PURE__ */ new Set([...HEADER_WORDS_STRONG, ...ROOM_HEADERS]);
4292
+ var SPEC_SECTION_RE = /^\d{2} ?\d{2}[ .]?\d{2}\b/;
4198
4293
  function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4199
4294
  const { x0, x1, medGap } = bandLimits(anchors);
4200
4295
  const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
@@ -4212,13 +4307,12 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4212
4307
  const a = atOf(t);
4213
4308
  return a >= Math.min(x0, keyLo - colTol) && a < keyHi;
4214
4309
  };
4215
- let tLeft = 0, tRight = 0, medPitch = 0;
4216
- let prevBandY = null;
4310
+ let tLeft = 0, tRight = 0, medPitch = 0, medPitchLo = 0;
4217
4311
  if (finish) {
4218
4312
  let ys = rows.slice(Math.max(cfg.fromIdx, 0)).filter((r) => r.some(inBand)).map(rowY);
4219
4313
  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;
4314
+ const d2 = v.slice(1).map((y, k) => y - v[k]).sort((p, q) => p - q);
4315
+ return d2.length ? d2[d2.length >> 1] : 0;
4222
4316
  };
4223
4317
  const p0 = med(ys);
4224
4318
  const cut = p0 > 0 ? ys.findIndex((y, k) => k > 0 && y - ys[k - 1] > 8 * p0) : -1;
@@ -4226,9 +4320,9 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4226
4320
  const lastTableY = ys.length ? ys[ys.length - 1] : Infinity;
4227
4321
  if (cols) {
4228
4322
  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;
4323
+ 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
4324
  tLeft = cs[0].start;
4231
- tRight = cs[cs.length - 1].start + pitch2;
4325
+ tRight = cs[cs.length - 1].start + pitch;
4232
4326
  } else {
4233
4327
  tLeft = Infinity;
4234
4328
  tRight = -Infinity;
@@ -4239,19 +4333,10 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4239
4333
  }
4240
4334
  }
4241
4335
  medPitch = med(ys);
4336
+ const d = ys.slice(1).map((y, k) => y - ys[k]).filter((g) => g > 0).sort((p, q) => p - q);
4337
+ medPitchLo = d.length ? d[d.length - 1 >> 1] : 0;
4242
4338
  }
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
4339
  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
4340
  const columnOf = (t) => {
4256
4341
  if (!cols) return nearestAnchor(centerX(t), anchors);
4257
4342
  const at = cols.coord === "left" ? t.x : centerX(t);
@@ -4281,147 +4366,549 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4281
4366
  const text = t.str.trim();
4282
4367
  if (!row.cells[label]) row.cells[label] = { text, bbox: bboxOf(t) };
4283
4368
  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
4369
  }
4286
4370
  };
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;
4371
+ const scan = (over2, gone, breaks) => {
4372
+ const S = { out: [], outY: [], outI: [], outToks: [], everRows: [], everI: [], orphans: [], markers: [], headings: [], headLines: [], lines: [], outEpoch: [], outLine: [] };
4373
+ let curSection2;
4374
+ let prevBandY = null;
4375
+ const consumeHeading = (banded, y, section) => {
4376
+ let hb = null;
4377
+ for (const t of banded) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
4378
+ S.headings.push({ bbox: hb, y, section });
4379
+ };
4380
+ const keyOf = (raw, i) => over2?.get(i) ?? rowKeyOf(raw, kind, buildings, typeKeyed);
4381
+ let epoch = 0, line = -1;
4382
+ for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
4383
+ if (rowY(rows[i]) <= cfg.belowY) continue;
4384
+ const banded = [];
4385
+ for (const t of rows[i]) {
4386
+ const tri = cfg.deltas?.get(t);
4387
+ const rev = tri ? norm(t.str) : revisionOf(t.str);
4388
+ if (rev != null) {
4389
+ if (centerX(t) >= x0 - 2.5 * medGap && centerX(t) <= x1 + medGap) S.markers.push({ rev, span: t, i, ...tri ? { drawn: true, tri } : {} });
4390
+ continue;
4391
+ }
4392
+ if (inBand(t)) banded.push(t);
4393
+ }
4394
+ if (!banded.length) continue;
4395
+ line++;
4396
+ const bandYBefore = prevBandY;
4397
+ prevBandY = rowY(rows[i]);
4398
+ S.lines.push({ i, y: rowY(rows[i]), toks: banded });
4399
+ if (finish) {
4400
+ const h = finishSectionOf(banded[0].str);
4401
+ const joinedLen = banded.map((t) => t.str.trim()).join(" ").length;
4402
+ const inKeyColumn = inKey(banded[0]) && banded.every((t) => t.x + (t.w || 0) <= keyHi);
4403
+ const one = banded.length === 1 ? banded[0] : null;
4404
+ const straddles = !!one && !!cols && cols.cols.slice(1).some((c) => one.x < c.start - colTol && one.x + (one.w || 0) > c.start + colTol);
4405
+ const hugs = bandYBefore != null && rowY(rows[i]) - bandYBefore < 0.6 * medPitch;
4406
+ const tMid = (tLeft + tRight) / 2;
4407
+ const centered = !!one && !hugs && (Math.abs(centerX(one) - tMid) <= Math.max(0.1 * (tRight - tLeft), 3 * (one.h || 8)) || straddles);
4408
+ if (h && (inKeyColumn || centered) && joinedLen < 24) {
4409
+ curSection2 = h;
4410
+ epoch++;
4411
+ consumeHeading(banded, rowY(rows[i]), h);
4412
+ continue;
4413
+ }
4414
+ if (h && !hugs) S.headLines.push(rowY(rows[i]));
4415
+ 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)) {
4416
+ curSection2 = void 0;
4417
+ epoch++;
4418
+ consumeHeading(banded, rowY(rows[i]), null);
4419
+ continue;
4420
+ }
4421
+ if (one && inKey(one) && (breaks?.has(i) || !keyOf(one.str, i))) {
4422
+ curSection2 = void 0;
4423
+ epoch++;
4424
+ if (!hugs && joinedLen < 24 && !/\d/.test(one.str)) continue;
4425
+ }
4426
+ }
4427
+ if (kind === "equipment" && S.out.length) {
4428
+ if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
4429
+ const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
4430
+ if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
4298
4431
  }
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]));
4432
+ const keyed = keyOf(banded[0].str, i);
4433
+ if (!keyed) {
4434
+ if (!gone?.has(i)) S.orphans.push({ toks: banded, y: rowY(rows[i]), i });
4316
4435
  continue;
4317
4436
  }
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]));
4437
+ if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
4438
+ S.orphans.push({ toks: banded, y: rowY(rows[i]), i });
4322
4439
  continue;
4323
4440
  }
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;
4441
+ if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
4442
+ if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
4443
+ const row = { key: keyed.key, sheet: sheetKey, cells: {} };
4444
+ if (keyed.building) row.building = keyed.building;
4445
+ if (curSection2) row.section = curSection2;
4446
+ if (keyed.qualifier) row.qualifier = keyed.qualifier;
4447
+ if (keyed.notUsed) {
4448
+ row.notUsed = true;
4449
+ row.notUsedText = keyed.notUsedText;
4327
4450
  }
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;
4451
+ add(row, banded);
4452
+ S.out.push(row);
4453
+ S.outY.push(rowY(rows[i]));
4454
+ S.outI.push(i);
4455
+ S.outToks.push(banded);
4456
+ S.outEpoch.push(epoch);
4457
+ S.outLine.push(line);
4458
+ S.everRows.push(row);
4459
+ S.everI.push(i);
4460
+ }
4461
+ return S;
4462
+ };
4463
+ const dropRowAt = (S, k) => {
4464
+ S.out.splice(k, 1);
4465
+ S.outY.splice(k, 1);
4466
+ S.outI.splice(k, 1);
4467
+ S.outToks.splice(k, 1);
4468
+ };
4469
+ const blankBandResets = (S) => {
4470
+ if (!finish || !cfg.resetAtBlankBand) return [];
4471
+ const { out, outY, outEpoch, outLine } = S;
4472
+ const clear = /* @__PURE__ */ new Set();
4473
+ const prefixOf = (k) => /^[A-Z]*/.exec(k)[0];
4474
+ const plain = [];
4475
+ for (let i = 1; i < out.length; i++) if (outEpoch[i] === outEpoch[i - 1] && outLine[i] === outLine[i - 1] + 1) plain.push(i);
4476
+ const byEpoch = /* @__PURE__ */ new Map();
4477
+ for (const i of plain) {
4478
+ const g = outY[i] - outY[i - 1];
4479
+ if (g > 0) byEpoch.set(outEpoch[i], [...byEpoch.get(outEpoch[i]) ?? [], g]);
4480
+ }
4481
+ for (const i of plain) {
4482
+ const gaps = (byEpoch.get(outEpoch[i]) ?? []).slice().sort((a, b) => a - b);
4483
+ if (gaps.length < 4) continue;
4484
+ const pitch = gaps[gaps.length - 1 >> 1];
4485
+ if (outY[i] - outY[i - 1] <= 1.6 * pitch || prefixOf(out[i].key) === prefixOf(out[i - 1].key)) continue;
4486
+ for (let j = i; j < out.length && outEpoch[j] === outEpoch[i]; j++) clear.add(out[j]);
4487
+ }
4488
+ return [...clear];
4489
+ };
4490
+ const resetSections = (rs) => {
4491
+ for (const r of rs) delete r.section;
4492
+ };
4493
+ const endCut = (S) => {
4494
+ if (S.out.length > 2 && !(finish && cfg.marquee)) {
4495
+ const d = S.outY.slice(1).map((y, i) => y - S.outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
4496
+ const pitch0 = d.length ? d[d.length >> 1] : 0;
4497
+ if (pitch0 > 0) {
4498
+ let end = S.out.length;
4499
+ for (let i = 1; i < S.outY.length; i++) if (S.outY[i] - S.outY[i - 1] > pitch0 * 8) {
4500
+ end = i;
4501
+ break;
4502
+ }
4503
+ if (end < S.out.length) {
4504
+ S.out.length = end;
4505
+ S.outY.length = end;
4506
+ S.outI.length = end;
4507
+ S.outToks.length = end;
4508
+ }
4365
4509
  }
4366
4510
  }
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
- }
4511
+ };
4512
+ const attachPlan = (S) => {
4513
+ const outY = S.outY;
4514
+ const headY = [...S.headings.map((h) => h.y), ...S.headLines];
4515
+ const ly2 = outY.length > 1 ? [...outY, ...headY].filter((y) => y <= outY[outY.length - 1]).sort((a, b) => a - b) : [];
4516
+ const gaps = ly2.slice(1).map((y, i) => y - ly2[i]).filter((d) => d > 0).sort((a, b) => a - b);
4517
+ const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
4518
+ const nearestIn = (ys, y) => {
4519
+ let bi = -1, bd = Infinity;
4520
+ ys.forEach((ry, i) => {
4521
+ const d = Math.abs(y - ry);
4522
+ if (d < bd) {
4523
+ bd = d;
4524
+ bi = i;
4525
+ }
4526
+ });
4527
+ return { i: bi, d: bd };
4528
+ };
4529
+ const radius = (h) => pitch ? pitch * 0.6 : Math.max(h, 8) * 1.6;
4530
+ const headYs = [...headY].sort((p, q) => p - q);
4531
+ const byHeading = (y, d, rowAt) => headYs.some((hy) => hy !== y && (Math.abs(y - hy) < d || Math.abs(y - hy) === d && hy < rowAt));
4532
+ const orphanT = S.orphans.map((o) => {
4533
+ const { i, d } = nearestIn(outY, o.y);
4534
+ if (i < 0 || d > radius(Math.max(...o.toks.map((t) => t.h || 8)))) return -1;
4535
+ return byHeading(o.y, d, outY[i]) ? -1 : i;
4536
+ });
4537
+ const taken = /* @__PURE__ */ new Set();
4538
+ const markerT = S.markers.map((m) => {
4539
+ const { i, d } = nearestIn(outY, m.span.y);
4540
+ if (i < 0 || d > radius(m.span.h || 8) || taken.has(i)) return -1;
4541
+ taken.add(i);
4542
+ return i;
4380
4543
  });
4381
- return { i: bi, d: bd };
4544
+ return { radius, nearestIn, byHeading, orphanT, markerT };
4382
4545
  };
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;
4546
+ const applyAttach = (S, orphanT, markerT) => {
4547
+ S.orphans.forEach((o, k) => {
4548
+ if (orphanT[k] >= 0) add(S.out[orphanT[k]], o.toks);
4549
+ });
4550
+ S.markers.forEach((m, k) => {
4551
+ const i = markerT[k];
4552
+ if (i < 0 || S.out[i].revision) return;
4553
+ const ebox = m.tri ? merge(bboxOf(m.span), m.tri) : bboxOf(m.span);
4554
+ S.out[i].revision = { rev: m.rev, source: { sheet: sheetKey, text: m.span.str.trim(), bbox: ebox }, ...m.drawn ? { drawn: true } : {} };
4555
+ });
4556
+ };
4557
+ const keyCol = cols ? cols.cols[0].label : anchors[0].label;
4558
+ const printed = (r) => {
4559
+ const keyRest = norm(r.cells[keyCol]?.text ?? r.key).replace(/[^A-Z0-9 ]/g, "").trim().replace(new RegExp("^" + r.key + "\\b"), "").trim();
4560
+ return [...keyRest ? [keyRest] : [], ...Object.entries(r.cells).filter(([k]) => k !== keyCol).map(([, c]) => norm(c.text))];
4561
+ };
4562
+ const prefixes = (label, n) => n.key.startsWith(label + "-") || new RegExp("^" + label + "\\d").test(n.key);
4563
+ const dropGroupLabels = (S) => {
4399
4564
  const dropped = /* @__PURE__ */ new Set();
4400
- for (let i = out.length - 1; i >= 0; i--) {
4401
- const r = out[i];
4565
+ for (let i = S.out.length - 1; i >= 0; i--) {
4566
+ const r = S.out[i];
4402
4567
  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))];
4568
+ const texts = printed(r);
4405
4569
  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))) {
4570
+ if (S.out.slice(i + 1, i + 4).some((n) => prefixes(r.key, n))) {
4407
4571
  dropped.add(r);
4408
- out.splice(i, 1);
4409
- outY.splice(i, 1);
4572
+ dropRowAt(S, i);
4410
4573
  }
4411
4574
  }
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;
4575
+ return dropped;
4576
+ };
4577
+ const setBuildings = (out) => {
4578
+ for (const row of out) {
4579
+ if (row.building) continue;
4580
+ const cellB = norm(row.cells.BLDG?.text || row.cells.BUILDING?.text || "");
4581
+ if (DESIGNATOR_RE.test(cellB)) row.building = cellB;
4582
+ }
4583
+ };
4584
+ const regionOf = (S, dropped) => {
4585
+ let region = null;
4586
+ 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;
4587
+ const lastY = S.outY.length ? S.outY[S.outY.length - 1] : -Infinity;
4588
+ for (const hd of S.headings) if (hd.y <= lastY) region = region ? merge(region, hd.bbox) : hd.bbox;
4589
+ return region;
4590
+ };
4591
+ const markWordSplitNotUsed = (S, keyFirst2) => {
4592
+ if (!keyFirst2) return;
4593
+ S.out.forEach((r, k) => {
4594
+ if (r.notUsed) return;
4595
+ const rest = S.outToks[k].slice(1).filter((t) => columnOf(t) === keyCol).map((t) => t.str.trim()).join(" ");
4596
+ if (!rest || !normalizeNotUsed(rest)) return;
4597
+ r.notUsed = true;
4598
+ r.notUsedText = rest.replace(/^[-–—:,\s]+/, "");
4599
+ });
4600
+ };
4601
+ const P1 = scan();
4602
+ const reset1 = blankBandResets(P1);
4603
+ endCut(P1);
4604
+ const plan1 = attachPlan(P1);
4605
+ const orphanLine1 = new Map(P1.orphans.map((o, k) => [o.i, plan1.orphanT[k] >= 0 ? P1.outI[plan1.orphanT[k]] : -1]));
4606
+ const markerLine1 = plan1.markerT.map((t) => t >= 0 ? P1.outI[t] : -1);
4607
+ const keyedY1 = new Map(P1.outI.map((i, k) => [i, P1.outY[k]]));
4608
+ const everLine1 = new Map(P1.everRows.map((r, k) => [r, P1.everI[k]]));
4609
+ const keyByLine1 = new Map(P1.everRows.map((r, k) => [P1.everI[k], r.key]));
4610
+ applyAttach(P1, plan1.orphanT, plan1.markerT);
4611
+ const dropped1 = finish ? dropGroupLabels(P1) : /* @__PURE__ */ new Set();
4612
+ setBuildings(P1.out);
4613
+ const region1 = regionOf(P1, dropped1);
4614
+ const mq = finish && !!cfg.marqueeRules && cfg.headerY != null;
4615
+ if (!mq) {
4616
+ resetSections(reset1);
4617
+ return { out: P1.out, region: region1 };
4618
+ }
4619
+ const headerY = cfg.headerY;
4620
+ const keyFirst = KEY_FIRST.has(anchors[0]?.label);
4621
+ const p1Lines = new Set(P1.outI);
4622
+ const lowerMedianGap = (ys) => {
4623
+ const d = ys.slice(1).map((y, k) => y - ys[k]).filter((g) => g > 0).sort((p, q) => p - q);
4624
+ return d.length ? d[d.length - 1 >> 1] : 0;
4625
+ };
4626
+ const lastP1Y = P1.outY.length ? P1.outY[P1.outY.length - 1] : -Infinity;
4627
+ const lineYs = [headerY, ...P1.lines.map((l) => l.y)].sort((p, q) => p - q);
4628
+ const linePitch = lowerMedianGap(lineYs.filter((y) => y <= lastP1Y));
4629
+ const keyXs = P1.outToks.map((t) => t[0].x).sort((p, q) => p - q);
4630
+ const keyHdr = cfg.hdrSpans?.find((t) => t.x - 0.5 <= anchors[0].x && anchors[0].x <= t.x + (t.w || 0) + 0.5);
4631
+ const keyRefX = keyXs.length ? keyXs[keyXs.length - 1 >> 1] : keyHdr ? keyHdr.x : anchors[0].x;
4632
+ const textH = (toks) => {
4633
+ const hs = toks.map((t) => t.h || 8).sort((p, q) => p - q);
4634
+ return hs[hs.length >> 1];
4635
+ };
4636
+ 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));
4637
+ const gapAbove = (y) => {
4638
+ let above = -Infinity;
4639
+ for (const ly of lineYs) if (ly < y && ly > above) above = ly;
4640
+ return y - above;
4641
+ };
4642
+ const eligible = [];
4643
+ const ocr = !!cfg.ocr;
4644
+ if (keyFirst) {
4645
+ for (const o of P1.orphans) {
4646
+ const h = textH(o.toks);
4647
+ if (!aligned(o.toks[0], h)) continue;
4648
+ const att = orphanLine1.get(o.i) ?? -1, gap = gapAbove(o.y);
4649
+ 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 });
4415
4650
  }
4416
4651
  }
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;
4652
+ const newRule = [];
4653
+ const cands = [];
4654
+ const hdrLines = [];
4655
+ const cellsOfLine = (toks) => {
4656
+ const by = /* @__PURE__ */ new Map();
4657
+ for (const t of toks.slice(1)) {
4658
+ const l = columnOf(t);
4659
+ if (l !== keyCol) by.set(l, by.has(l) ? `${by.get(l)} ${t.str.trim()}` : t.str.trim());
4660
+ }
4661
+ return by;
4662
+ };
4663
+ const wordsOfTok = (t) => norm(t.str).split(/[\s/]+/).map((w) => w.replace(/^[^A-Z0-9]+|[^A-Z0-9]+$/g, "")).filter(Boolean);
4664
+ const allHeaderWords = (t) => {
4665
+ const w = wordsOfTok(t);
4666
+ return w.length > 0 && /[A-Z]/.test(norm(t.str)) && w.every((x) => !/\d/.test(x) && HEADER_WORDS.has(x));
4667
+ };
4668
+ 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)));
4669
+ const codeShaped = (x) => {
4670
+ const c = norm(x);
4671
+ return CODE_RE.test(c) && /\d/.test(c);
4672
+ };
4673
+ for (const e of eligible) {
4674
+ const s = splitKeyCell(e.toks[0].str);
4675
+ if (s) {
4676
+ newRule.push({ e, s });
4677
+ continue;
4678
+ }
4679
+ const word = norm(e.toks[0].str).replace(/^[^A-Z0-9]+|[^A-Z0-9]+$/g, "");
4680
+ if (LETTERS_RE.test(word) && !e.toks.slice(1).some(inKey)) {
4681
+ if (ocr) continue;
4682
+ if (LETTERS_STOP.has(word)) continue;
4683
+ if (headerLine(e.toks)) {
4684
+ hdrLines.push(e);
4685
+ continue;
4686
+ }
4687
+ const cells = cellsOfLine(e.toks);
4688
+ if ([...cells.values()].filter(codeShaped).length >= 2) continue;
4689
+ cands.push({ e, word, cells });
4690
+ continue;
4691
+ }
4692
+ if (allHeaderWords(e.toks[0]) && headerLine(e.toks)) hdrLines.push(e);
4693
+ }
4694
+ if (cands.length) {
4695
+ const decidedLines = /* @__PURE__ */ new Set([...newRule.map((n) => n.e.i), ...cands.map((c) => c.e.i), ...hdrLines.map((e) => e.i)]);
4696
+ const wraps = [];
4697
+ for (const o of P1.orphans) {
4698
+ const t1 = orphanLine1.get(o.i) ?? -1;
4699
+ const rowY1 = keyedY1.get(t1);
4700
+ if (t1 < 0 || rowY1 == null || decidedLines.has(o.i) || o.y <= rowY1) continue;
4701
+ wraps.push({ row: t1, off: o.y - rowY1 });
4702
+ }
4703
+ const rowAbove = (y) => {
4704
+ let best = null;
4705
+ P1.outY.forEach((ry, k) => {
4706
+ 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) };
4707
+ });
4708
+ return best;
4709
+ };
4710
+ for (let k = cands.length - 1; k >= 0; k--) {
4711
+ const c = cands[k], up = rowAbove(c.e.y);
4712
+ if (!up || !up.cells) continue;
4713
+ const off = c.e.y - up.y, tol = Math.max(1, 0.1 * textH(c.e.toks));
4714
+ if (wraps.some((w) => w.row !== up.row && Math.abs(w.off - off) <= tol)) cands.splice(k, 1);
4715
+ }
4716
+ }
4717
+ const rowGap = lowerMedianGap(P1.outY);
4718
+ const runPitch = P1.outY.length > 1 ? rowGap : medPitchLo;
4719
+ const walk = [
4720
+ ...P1.outY.map((y, k) => ({ y, kind: "row", at: k })),
4721
+ ...P1.headings.map((h, k) => ({ y: h.y, kind: "head", at: k })),
4722
+ ...newRule.map((n, k) => ({ y: n.e.y, kind: "new", at: k })),
4723
+ ...cands.map((c, k) => ({ y: c.e.y, kind: "cand", at: k }))
4724
+ ].sort((p, q) => p.y - q.y);
4725
+ const reachedDown = /* @__PURE__ */ new Set(), reached = /* @__PURE__ */ new Set();
4726
+ let prev = headerY;
4727
+ for (const w of walk) {
4728
+ if (w.y - prev <= (w.kind === "row" ? 8 : 2.5) * runPitch) {
4729
+ reachedDown.add(w);
4730
+ reached.add(w);
4731
+ prev = w.y;
4732
+ }
4733
+ }
4734
+ let next = Infinity;
4735
+ for (let k = walk.length - 1; k >= 0; k--) {
4736
+ const w = walk[k];
4737
+ if (w.kind === "row") {
4738
+ next = reachedDown.has(w) ? w.y : Infinity;
4739
+ continue;
4740
+ }
4741
+ if (w.kind === "head") continue;
4742
+ if (next - w.y <= 2.5 * runPitch) {
4743
+ reached.add(w);
4744
+ next = w.y;
4745
+ } else next = Infinity;
4746
+ }
4747
+ const runHasSection = walk.some((w) => w.kind === "head" && reachedDown.has(w) && P1.headings[w.at].section != null);
4748
+ const newReached = new Set(walk.filter((w) => w.kind === "new" && reached.has(w)).map((w) => w.at));
4749
+ const surviving = newRule.filter((_n, k) => newReached.has(k));
4750
+ const rekey = /* @__PURE__ */ new Map();
4751
+ if (keyFirst) {
4752
+ const p1Split = P1.out.map((r, k) => ({ i: P1.outI[k], key: r.key, s: splitKeyCell(P1.outToks[k][0].str) }));
4753
+ for (const h of p1Split) if (h.s?.notUsed) rekey.set(h.i, { key: h.s.key, notUsed: true, notUsedText: h.s.notUsedText });
4754
+ 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 }))];
4755
+ const splitters = p1Split.filter((h) => h.s?.qualifier != null && !isAltQualifier(h.s.qualifier));
4756
+ for (const h of splitters) {
4757
+ const clash = finalKeys.some((f) => f.i !== h.i && f.key === h.s.key);
4758
+ const twin = splitters.some((o) => o !== h && o.s.key === h.s.key);
4759
+ if (!clash && !twin) rekey.set(h.i, { key: h.s.key, qualifier: h.s.qualifier });
4760
+ }
4761
+ }
4762
+ const fate = /* @__PURE__ */ new Map();
4763
+ 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);
4764
+ const numberedYs = keyedRows.filter((r) => /\d/.test(r.key)).map((r) => r.y);
4765
+ for (let k = 0; k < walk.length; k++) {
4766
+ const w = walk[k];
4767
+ if (w.kind !== "cand") continue;
4768
+ const c = cands[w.at];
4769
+ if (!reached.has(w)) {
4770
+ fate.set(w.at, "out");
4771
+ continue;
4772
+ }
4773
+ 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));
4774
+ if (prefixed && c.cells.size <= 2 && [...c.cells.values()].some((x) => SPEC_SECTION_RE.test(norm(x)))) {
4775
+ fate.set(w.at, "s");
4776
+ continue;
4777
+ }
4778
+ if (prefixed && c.cells.size === 1) {
4779
+ fate.set(w.at, "g");
4780
+ continue;
4781
+ }
4782
+ let above = null;
4783
+ for (let j = k - 1; j >= 0 && !above; j--) {
4784
+ const l = walk[j];
4785
+ if (l.kind === "head") continue;
4786
+ if (l.kind === "cand" && fate.get(l.at) !== "row") continue;
4787
+ if (l.kind === "new" && !newReached.has(l.at)) continue;
4788
+ above = l;
4789
+ }
4790
+ const aboveOk = !above || above.kind === "row" || above.kind === "new" || above.kind === "cand" && fate.get(above.at) === "row";
4791
+ fate.set(w.at, aboveOk && numberedYs.some((y) => y > c.e.y) && c.cells.size >= 2 ? "row" : "skipped");
4792
+ }
4793
+ const letterRows = cands.filter((_c, k) => fate.get(k) === "row");
4794
+ const drops = [
4795
+ ...hdrLines.map((e) => ({ e, reason: "h" })),
4796
+ ...cands.flatMap((c, k) => {
4797
+ const f = fate.get(k);
4798
+ return f === "s" || f === "g" || f === "skipped" ? [{ e: c.e, reason: f, word: c.word }] : [];
4799
+ })
4800
+ ].sort((p, q) => p.e.y - q.e.y);
4801
+ const skipped = drops.filter((d) => d.reason === "skipped").map((d) => d.word);
4802
+ const diagOf = (e, kind2) => ({ y: e.y, kind: kind2, gapAbove: e.gap, linePitch, runPitch });
4803
+ const diag = [
4804
+ ...surviving.map((n) => diagOf(n.e, "new")),
4805
+ ...letterRows.map((c) => diagOf(c.e, "new")),
4806
+ ...drops.map((d) => diagOf(d.e, d.reason === "skipped" ? "skipped" : "consumed"))
4807
+ ].sort((p, q) => p.y - q.y);
4808
+ const ownerOf = (line) => line >= 0 ? keyByLine1.get(line) ?? null : null;
4809
+ const textOf = (toks) => toks.map((t) => t.str.trim()).join(" ");
4810
+ const consumed = drops.map((d) => ({ text: textOf(d.e.toks), y: d.e.y, reason: d.reason, owner: ownerOf(d.e.att) }));
4811
+ const guard = { pass1Region: region1, pass1Rows: P1.out.length, pass1HasSection: P1.out.some((r) => !!r.section), runHasSection };
4812
+ const meta = /* @__PURE__ */ new Map();
4813
+ if (!surviving.length && !rekey.size && !letterRows.length && !drops.length) {
4814
+ for (let k = 0; k < P1.out.length; k++) meta.set(P1.out[k], { y: P1.outY[k], pass1: true });
4815
+ markWordSplitNotUsed(P1, keyFirst);
4816
+ resetSections(reset1);
4817
+ return { out: P1.out, region: region1, marquee: { ...guard, meta, consumed, diag, skipped } };
4818
+ }
4819
+ const over = new Map(rekey);
4820
+ 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 });
4821
+ for (const c of letterRows) over.set(c.e.i, { key: c.word });
4822
+ const dropLines = new Set(drops.map((d) => d.e.i));
4823
+ const P2 = scan(over, dropLines, ocr ? new Set(surviving.map((n) => n.e.i)) : void 0);
4824
+ const reset2 = blankBandResets(P2);
4825
+ endCut(P2);
4826
+ const lettersLines = new Set(letterRows.map((c) => c.e.i));
4827
+ const newLines = /* @__PURE__ */ new Set([...surviving.map((n) => n.e.i), ...lettersLines]);
4828
+ const at2 = new Map(P2.outI.map((i, k) => [i, k]));
4829
+ const p1Ys = [...keyedY1.entries()].sort((p, q) => p[1] - q[1]);
4830
+ const moved = /* @__PURE__ */ new Map();
4831
+ const dropCont = /* @__PURE__ */ new Set();
4832
+ const ungluedFrom = /* @__PURE__ */ new Map();
4833
+ 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);
4834
+ for (const d of decided) {
4835
+ const tookFrom = d.e.toks.map((t) => ({ from: ownerOf(d.e.att), text: t.str.trim() }));
4836
+ 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);
4837
+ const nextP1 = p1Ys.find(([, y]) => y > d.e.y);
4838
+ let lastY = d.e.y;
4839
+ for (const o of P2.orphans.filter((q) => q.y > d.e.y && q.y < nextKeyed).sort((p, q) => p.y - q.y)) {
4840
+ const t1 = orphanLine1.get(o.i) ?? -1;
4841
+ if (o.y - lastY > plan1.radius(Math.max(...o.toks.map((t) => t.h || 8)))) break;
4842
+ const take = t1 < 0 || t1 === d.e.att || !!nextP1 && t1 === nextP1[0] && o.y - lastY < nextP1[1] - o.y;
4843
+ if (!take) break;
4844
+ if (d.drop) {
4845
+ dropCont.add(o.i);
4846
+ consumed.push({ text: textOf(o.toks), y: o.y, reason: "continuation", owner: ownerOf(t1) });
4847
+ } else {
4848
+ moved.set(o.i, d.e.i);
4849
+ for (const t of o.toks) tookFrom.push({ from: ownerOf(t1), text: t.str.trim() });
4850
+ }
4851
+ lastY = o.y;
4852
+ }
4853
+ if (!d.drop) ungluedFrom.set(d.e.i, tookFrom);
4421
4854
  }
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 };
4855
+ consumed.sort((p, q) => p.y - q.y);
4856
+ const toNewRow = (y, h) => {
4857
+ const { i, d } = plan1.nearestIn(P2.outY, y);
4858
+ if (i < 0 || d > plan1.radius(h) || plan1.byHeading(y, d, P2.outY[i])) return -1;
4859
+ return newLines.has(P2.outI[i]) ? i : -1;
4860
+ };
4861
+ const orphanT2 = P2.orphans.map((o) => {
4862
+ if (dropCont.has(o.i)) return -1;
4863
+ const to = moved.get(o.i);
4864
+ if (to != null) return at2.get(to) ?? -1;
4865
+ const t1 = orphanLine1.get(o.i) ?? -1;
4866
+ if (t1 >= 0) return at2.get(t1) ?? -1;
4867
+ return toNewRow(o.y, Math.max(...o.toks.map((t) => t.h || 8)));
4868
+ });
4869
+ const markerT2 = P2.markers.map((m, k) => {
4870
+ if (dropLines.has(m.i)) return -1;
4871
+ if (newLines.has(m.i)) return at2.get(m.i) ?? -1;
4872
+ const t1 = markerLine1[k];
4873
+ if (t1 >= 0) return at2.get(t1) ?? -1;
4874
+ return toNewRow(m.span.y, m.span.h || 8);
4875
+ });
4876
+ applyAttach(P2, orphanT2, markerT2);
4877
+ const droppedLines1 = new Set([...dropped1].map((r) => everLine1.get(r)));
4878
+ const emptied = /* @__PURE__ */ new Set();
4879
+ for (const o of P1.orphans) {
4880
+ const t1 = orphanLine1.get(o.i) ?? -1;
4881
+ if (t1 >= 0 && (newLines.has(o.i) || moved.has(o.i) || dropLines.has(o.i) || dropCont.has(o.i))) emptied.add(t1);
4882
+ }
4883
+ const dropped2 = /* @__PURE__ */ new Set();
4884
+ for (let k = P2.out.length - 1; k >= 0; k--) if (droppedLines1.has(P2.outI[k])) {
4885
+ dropped2.add(P2.out[k]);
4886
+ dropRowAt(P2, k);
4887
+ }
4888
+ for (let k = P2.out.length - 1; k >= 0; k--) {
4889
+ const r = P2.out[k];
4890
+ if (!emptied.has(P2.outI[k]) || !/^[A-Z]{1,3}$/.test(r.key) || printed(r).length) continue;
4891
+ if (P2.out.slice(k + 1, k + 4).some((n) => prefixes(r.key, n))) {
4892
+ dropped2.add(r);
4893
+ dropRowAt(P2, k);
4894
+ }
4895
+ }
4896
+ setBuildings(P2.out);
4897
+ const region2 = regionOf(P2, dropped2);
4898
+ for (let k = 0; k < P2.out.length; k++) {
4899
+ const i = P2.outI[k], r = P2.out[k];
4900
+ const m = { y: P2.outY[k], pass1: p1Lines.has(i) };
4901
+ if (newLines.has(i)) {
4902
+ m.newRule = true;
4903
+ m.ungluedFrom = ungluedFrom.get(i);
4904
+ }
4905
+ if (lettersLines.has(i)) m.lettersKey = true;
4906
+ if (rekey.has(i)) m.pass1Key = keyByLine1.get(i);
4907
+ meta.set(r, m);
4908
+ }
4909
+ markWordSplitNotUsed(P2, keyFirst);
4910
+ resetSections(reset2);
4911
+ return { out: P2.out, region: region2, marquee: { ...guard, meta, consumed, diag, skipped } };
4425
4912
  }
4426
4913
  function extractTable(sheet, kind, opts = {}) {
4427
4914
  const r = extractTableCore(sheet, kind, opts);
@@ -4453,7 +4940,7 @@ function extractTables(sheet, kind, opts = {}) {
4453
4940
  }
4454
4941
  return out;
4455
4942
  }
4456
- function extractTableCore(sheet, kind, opts = {}) {
4943
+ function extractTableCore(sheet, kind, opts = {}, core = {}) {
4457
4944
  const horiz = sheet.spans.filter((s) => !isVertical(s));
4458
4945
  const vert = sheet.spans.filter(isVertical);
4459
4946
  const rows = clusterRows(horiz);
@@ -4467,6 +4954,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4467
4954
  let dataBelowY = -Infinity;
4468
4955
  let titleFrom;
4469
4956
  let rotated = false;
4957
+ let headerY;
4470
4958
  const flat = findHeaderRow(rows, vocab, required, minHits);
4471
4959
  if (flat) {
4472
4960
  anchors = flat.anchors;
@@ -4481,6 +4969,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4481
4969
  hdrBlock = [...band].flatMap((k) => rows[k]);
4482
4970
  dataFrom = flat.rowIndex + 1;
4483
4971
  titleFrom = flat.rowIndex - 1;
4972
+ headerY = hy;
4484
4973
  } else {
4485
4974
  const rot = findRotatedHeader(vert, vocab, required, minHits);
4486
4975
  if (!rot) return null;
@@ -4488,6 +4977,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4488
4977
  anchors = rot.anchors;
4489
4978
  headerSpans = rot.spans;
4490
4979
  dataBelowY = rot.bottom - 2;
4980
+ headerY = rot.bottom;
4491
4981
  dataFrom = 0;
4492
4982
  titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
4493
4983
  if (titleFrom < -1) titleFrom = rows.length - 1;
@@ -4503,23 +4993,38 @@ function extractTableCore(sheet, kind, opts = {}) {
4503
4993
  if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
4504
4994
  region = region ? merge(region, bboxOf(t)) : bboxOf(t);
4505
4995
  }
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 });
4996
+ const marqueeRules = kind === "finish" && !!core.marqueeRules;
4997
+ const headerRegion = region;
4998
+ 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
4999
  const out = banded.out;
4508
5000
  if (banded.region) region = region ? merge(region, banded.region) : banded.region;
5001
+ const { x0, x1 } = bandLimits(anchors);
5002
+ const findTitle = () => {
5003
+ let title2 = null;
5004
+ for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title2; i--) {
5005
+ const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
5006
+ if (hit) title2 = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
5007
+ }
5008
+ return title2;
5009
+ };
5010
+ const wordsOf = () => headerSpans.flatMap((t) => t.str.toUpperCase().split(/[^A-Z]+/)).filter(Boolean);
4509
5011
  if (!out.length) {
4510
5012
  if (kind === "equipment" && region) return { skip: region };
5013
+ if (marqueeRules && headerRegion) {
5014
+ const mb2 = banded.marquee;
5015
+ 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 } } };
5016
+ }
4511
5017
  return null;
4512
5018
  }
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
- }
5019
+ const title = findTitle();
4519
5020
  const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
4520
5021
  if (rotated) table.rotated_headers = true;
4521
- const headerWords = headerSpans.flatMap((t) => t.str.toUpperCase().split(/[^A-Z]+/)).filter(Boolean);
4522
- return { table, headerWords };
5022
+ const headerWords = wordsOf();
5023
+ if (!marqueeRules) return { table, headerWords };
5024
+ const mb = banded.marquee;
5025
+ const guardRegion = mb.pass1Rows ? headerRegion && mb.pass1Region ? merge(headerRegion, mb.pass1Region) : headerRegion ?? mb.pass1Region : headerRegion ?? region;
5026
+ const hasSection = mb.pass1Rows ? mb.pass1HasSection : mb.runHasSection;
5027
+ return { table, headerWords, guard: { guardRegion, hasSection, skipped: mb.skipped, trace: { meta: mb.meta, consumed: mb.consumed, diag: mb.diag } } };
4523
5028
  }
4524
5029
  var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
4525
5030
  var isContinuationTitle = (text) => {
@@ -9192,7 +9697,7 @@ var Session = class _Session {
9192
9697
  if (assign) {
9193
9698
  if (s.upp == null) throw new UserError(this.scaleGate(s));
9194
9699
  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.");
9700
+ if (!graph.available) throw new UserError("This set has no text layer (a raster image) \u2014 the sheet graph is unavailable, not empty.");
9196
9701
  if (!graph.tables.some((t) => t.kind === "room-finish")) {
9197
9702
  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
9703
  }
@@ -16062,7 +16567,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
16062
16567
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
16063
16568
 
16064
16569
  // src/wiki.generated.ts
16065
- var WIKI_VERSION = "0.9.93";
16570
+ var WIKI_VERSION = "0.9.96";
16066
16571
  var WIKI_PAGES = [
16067
16572
  {
16068
16573
  "key": "index",
@@ -16077,8 +16582,8 @@ var WIKI_PAGES = [
16077
16582
  "uri": "takeoff://wiki/status",
16078
16583
  "title": "Capability status",
16079
16584
  "source": "docs/wiki/status.md",
16080
- "source_sha256": "2db64b41c4510a679a5d589efc4546e4b74a9b7d31f34c9a8d13512d6bb5955b",
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| 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"
16585
+ "source_sha256": "af701c08166b85f012db03053e7adecf0634ac6a8145f24e8d4c9dca02d314a2",
16586
+ "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. 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); 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
16587
  },
16083
16588
  {
16084
16589
  "key": "architecture",
@@ -16673,7 +17178,7 @@ function nameTheStageInRefusals(server) {
16673
17178
  // package.json
16674
17179
  var package_default = {
16675
17180
  name: "opentakeoff-mcp",
16676
- version: "0.9.93",
17181
+ version: "0.9.96",
16677
17182
  mcpName: "io.github.Kentucky-ai/opentakeoff",
16678
17183
  type: "module",
16679
17184
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.93",
3
+ "version": "0.9.96",
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.",