opentakeoff-mcp 0.9.90 → 0.9.93

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 +375 -54
  2. package/package.json +2 -2
@@ -3434,6 +3434,38 @@ function isLabelBubblePx(ring, b) {
3434
3434
  return rh <= BUBBLE_RATIO * lh && rw <= BUBBLE_WIDE_RATIO * lw;
3435
3435
  }
3436
3436
 
3437
+ // ../web/src/lib/finishSections.ts
3438
+ var FINISH_SECTION_HEADINGS = [
3439
+ "FLOORING",
3440
+ "FLOORS",
3441
+ "FLOOR",
3442
+ "FLOOR FINISHES",
3443
+ "BASE",
3444
+ "BASES",
3445
+ "WALL BASE",
3446
+ "WALLS",
3447
+ "WALL",
3448
+ "WALL FINISHES",
3449
+ "WALL PROTECTION",
3450
+ "TRANSITIONS",
3451
+ "TRANSITION",
3452
+ "TRIM",
3453
+ "CEILINGS",
3454
+ "CEILING",
3455
+ "MILLWORK",
3456
+ "MISC",
3457
+ "ACCESSORIES"
3458
+ ];
3459
+ var PHRASES = FINISH_SECTION_HEADINGS.filter((h) => h.includes(" ")).map((h) => ({ h, words: h.split(" ") })).sort((a, b) => b.words.length - a.words.length || b.h.length - a.h.length);
3460
+ var WORDS = new Set(FINISH_SECTION_HEADINGS.filter((h) => !h.includes(" ")));
3461
+ function finishSectionOf(text) {
3462
+ const words = (text || "").trim().toUpperCase().split(/\s+/);
3463
+ if (!words[0] || /[-\d]/.test(words[0])) return null;
3464
+ const clean = words.map((w) => w.replace(/[^A-Z]/g, ""));
3465
+ for (const p of PHRASES) if (p.words.every((w, i) => clean[i] === w)) return p.h;
3466
+ return WORDS.has(clean[0]) ? clean[0] : null;
3467
+ }
3468
+
3437
3469
  // ../web/src/lib/sheetgraph.ts
3438
3470
  var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
3439
3471
  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])];
@@ -3746,16 +3778,26 @@ var EQUIPMENT_ONLY = /* @__PURE__ */ new Set([
3746
3778
  var EQUIP_KEY_RE = /^[A-Z]{1,4}(?:-\d{1,3}|\d{1,2})[A-Z]?$/;
3747
3779
  var TYPE_KEY_RE = /^[A-Z]{1,2}\d{0,2}[A-Z]?$/;
3748
3780
  var headerLabel = (s, vocab) => headerLabels(s, vocab)[0] ?? null;
3749
- var headerLabels = (s, vocab) => {
3781
+ var headerLabels = (s, vocab, wholeWords = false) => {
3750
3782
  const out = [];
3751
- for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w) && !out.includes(w)) out.push(w);
3783
+ const words = norm(s).split(/[^A-Z]+/);
3784
+ for (const w of words) if (w && vocab.includes(w) && !out.includes(w)) out.push(w);
3785
+ if (vocab === FINISH_HEADERS && !wholeWords) {
3786
+ for (const w of words) {
3787
+ if (!w) continue;
3788
+ for (const l of vocab) if (w.startsWith(l.slice(0, 5)) && !out.includes(l)) {
3789
+ out.push(l);
3790
+ break;
3791
+ }
3792
+ }
3793
+ }
3752
3794
  return out;
3753
3795
  };
3754
- function headerHits(row, vocab) {
3796
+ function headerHits(row, vocab, wholeWords = false) {
3755
3797
  const out = [];
3756
3798
  const used = /* @__PURE__ */ new Set();
3757
3799
  for (const t of row) {
3758
- const words = headerLabels(t.str, vocab);
3800
+ const words = headerLabels(t.str, vocab, wholeWords);
3759
3801
  if (!words.length) continue;
3760
3802
  const all = norm(t.str).split(/[^A-Z]+/).filter(Boolean);
3761
3803
  if (all.length >= 2 && all.every((w2) => SURFACE_WORDS.has(w2))) {
@@ -3778,15 +3820,15 @@ var qualifies = (hits, required, minHits) => {
3778
3820
  };
3779
3821
  function findHeaderRow(rows, vocab, required, minHits) {
3780
3822
  for (let i = 0; i < rows.length; i++) {
3823
+ if (!qualifies(headerHits(rows[i], vocab, true), required, minHits)) continue;
3781
3824
  let hits = headerHits(rows[i], vocab);
3782
- if (!qualifies(hits, required, minHits)) continue;
3783
3825
  let idx = i;
3784
3826
  for (; ; ) {
3785
3827
  let next = -1;
3786
3828
  for (let j = idx + 1; j < Math.min(idx + 4, rows.length); j++) {
3787
3829
  const h = headerHits(rows[j], vocab);
3788
3830
  const ratio = h.length / Math.max(1, rows[j].length);
3789
- if (qualifies(h, [], minHits) && h.length > hits.length && ratio >= 0.6) {
3831
+ if (qualifies(headerHits(rows[j], vocab, true), [], minHits) && h.length > hits.length && ratio >= 0.6) {
3790
3832
  next = j;
3791
3833
  break;
3792
3834
  }
@@ -3839,7 +3881,7 @@ function findHeaderRow(rows, vocab, required, minHits) {
3839
3881
  }
3840
3882
  }
3841
3883
  }
3842
- return { anchors: subTierAnchors(rows, idx, anchors.sort((a, b) => a.x - b.x), vocab), rowIndex: idx };
3884
+ return { anchors: subTierAnchors(rows, idx, anchors.sort((a, b) => a.x - b.x), vocab), rowIndex: idx, top: i };
3843
3885
  }
3844
3886
  return null;
3845
3887
  }
@@ -3899,7 +3941,7 @@ function subTierAnchors(rows, hdrIdx, anchors, vocab) {
3899
3941
  return out.sort((a, b) => a.x - b.x);
3900
3942
  }
3901
3943
  function findRotatedHeader(vert, vocab, required, minHits) {
3902
- const cands = vert.map((sp) => ({ sp, label: headerLabel(sp.str, vocab) })).filter((c) => !!c.label).sort((a, b) => a.sp.x - b.sp.x);
3944
+ const cands = vert.map((sp) => ({ sp, label: headerLabels(sp.str, vocab, true)[0] ?? null })).filter((c) => !!c.label).sort((a, b) => a.sp.x - b.sp.x);
3903
3945
  let band = [];
3904
3946
  let y0 = 0, y1 = 0;
3905
3947
  const flush = () => {
@@ -3954,6 +3996,7 @@ function bandLimits(anchors) {
3954
3996
  return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: last.x + rightMargin, medGap };
3955
3997
  }
3956
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);
3957
4000
  var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
3958
4001
  var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
3959
4002
  var CORRIDOR_KEY_RE = /^[A-Z]{1,3}(?:\d{1,3}-\d{1,3}|\d{3})[A-Z]?$/;
@@ -3973,8 +4016,8 @@ function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
3973
4016
  }
3974
4017
  if (kind === "finish") {
3975
4018
  const parts = kept.split("/").filter(Boolean);
3976
- if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
3977
- return CODE_RE.test(key) ? { key } : null;
4019
+ if (parts.length > 1 && parts.every(finishCodeOk)) return { key: parts.join("/") };
4020
+ return finishCodeOk(key) ? { key } : null;
3978
4021
  }
3979
4022
  if (ROW_KEY_RE.test(key)) return { key };
3980
4023
  if (CORRIDOR_KEY_RE.test(key)) return { key };
@@ -3990,15 +4033,33 @@ var rowKeyAnswersFor = (key, want) => {
3990
4033
  var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
3991
4034
  var centerX = (t) => t.x + (t.w || 0) / 2;
3992
4035
  var PLACEHOLDER_RE = /^[-–—]{1,3}$/;
3993
- function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
4036
+ function columnMapFor(rows, anchors, cfg, x0, x1, coord, kind) {
3994
4037
  const at = (t) => coord === "left" ? t.x : t.x + (t.w || 0) / 2;
3995
4038
  const xs = [];
3996
4039
  const hs = [];
3997
4040
  const dashes = [];
4041
+ let keyCellX = -Infinity;
4042
+ if (kind === "finish") {
4043
+ const kx = [];
4044
+ for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
4045
+ if (rowY(rows[i]) <= cfg.belowY) continue;
4046
+ const first = rows[i].filter((t) => t.x >= x0 && t.x <= x1).reduce((m, t) => !m || t.x < m.x ? t : m, null);
4047
+ if (first && rowKeyOf(first.str, kind)) kx.push(first.x);
4048
+ }
4049
+ kx.sort((p, q) => p - q);
4050
+ if (kx.length) keyCellX = kx[kx.length >> 1];
4051
+ }
3998
4052
  for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
3999
4053
  if (rowY(rows[i]) <= cfg.belowY) continue;
4054
+ let lead = null;
4055
+ if (kind === "finish") {
4056
+ const inb = rows[i].filter((t) => t.x >= x0 && t.x <= x1);
4057
+ if (inb.length === 1 && finishSectionOf(inb[0].str) && ((inb[0].w || 0) > 0 || inb[0].x < keyCellX + 4)) continue;
4058
+ const first = inb.reduce((m, t) => !m || t.x < m.x ? t : m, null);
4059
+ if (first && (first.w || 0) > 0 && finishSectionOf(first.str)) lead = first;
4060
+ }
4000
4061
  for (const t of rows[i]) {
4001
- if (t.x < x0 || t.x > x1 || revisionOf(t.str) != null) continue;
4062
+ if (t === lead || t.x < x0 || t.x > x1 || revisionOf(t.str) != null) continue;
4002
4063
  if (PLACEHOLDER_RE.test(t.str.trim())) {
4003
4064
  dashes.push(at(t));
4004
4065
  continue;
@@ -4022,13 +4083,48 @@ function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
4022
4083
  }
4023
4084
  const maxN = Math.max(...clusters.map((c) => c.n));
4024
4085
  const kept = clusters.filter((c) => c.n >= Math.max(2, maxN * 0.25));
4025
- if (kept.length < anchors.length) return null;
4086
+ const widthless = rows.every((r) => r.every((t) => !((t.w || 0) > 0)));
4087
+ const hMed = hs[hs.length >> 1];
4088
+ const contWord = (c) => kind === "finish" && widthless && kept.some((k) => k.start < c.start && c.start - k.start < 5 * hMed);
4089
+ const ownerOf = (c) => anchors.find((a) => a.x >= c.start);
4090
+ const rescued = [];
4091
+ const block = (cfg.hdrBand ?? cfg.hdrSpans ?? []).filter((t) => cfg.hdrSpans?.includes(t) || !/\b(LEGEND|ABBREVIATIONS?|SYMBOLS?|KEY|NOTES?)\b/.test(norm(t.str)));
4092
+ const named = (cfg.hdrSpans ?? []).filter((t) => anchors.some((a) => t.x - 0.5 <= a.x && a.x <= t.x + (t.w || 0) + 0.5));
4093
+ const unnamed = block.some((t) => t.x >= x0 && t.x <= x1 && !named.some((n) => t.x <= n.x + (n.w || 0) && n.x <= t.x + (t.w || 0)));
4094
+ const spanOf = (a) => cfg.hdrSpans?.find((t) => t.x - 0.5 <= a.x && a.x <= t.x + (t.w || 0) + 0.5);
4095
+ const fromHeader = kind === "finish" && coord === "left" && !!cfg.hdrSpans && !unnamed;
4096
+ if (fromHeader && !widthless) {
4097
+ const owned = /* @__PURE__ */ new Set();
4098
+ for (const c of kept) {
4099
+ const own = ownerOf(c);
4100
+ if (own && !contWord(c)) owned.add(own.label);
4101
+ }
4102
+ for (const c of clusters) {
4103
+ if (kept.includes(c)) continue;
4104
+ const k = anchors.findIndex((a) => a.x >= c.start);
4105
+ if (k <= 0 || owned.has(anchors[k].label)) continue;
4106
+ owned.add(anchors[k].label);
4107
+ rescued.push(c);
4108
+ }
4109
+ }
4110
+ const cols0 = [...kept, ...rescued];
4111
+ if (cols0.length < anchors.length && !fromHeader) return null;
4026
4112
  const byLabel = /* @__PURE__ */ new Map();
4027
- for (const c of kept) {
4028
- const own = anchors.find((a) => a.x >= c.start);
4113
+ const byLabelCont = /* @__PURE__ */ new Map();
4114
+ for (const c of cols0) {
4115
+ const own = ownerOf(c);
4029
4116
  if (!own) continue;
4030
- const cur = byLabel.get(own.label);
4031
- if (cur == null || c.start < cur) byLabel.set(own.label, c.start);
4117
+ const m = contWord(c) ? byLabelCont : byLabel;
4118
+ const cur = m.get(own.label);
4119
+ if (cur == null || c.start < cur) m.set(own.label, c.start);
4120
+ }
4121
+ const keyStart = byLabel.get(anchors[0].label);
4122
+ const flushHeaders = keyStart != null && Math.abs(anchors[0].x - keyStart) <= tol;
4123
+ for (const [l, st] of byLabelCont) {
4124
+ if (byLabel.has(l)) continue;
4125
+ const a = anchors.find((q) => q.label === l);
4126
+ const contMax = Math.max(...cols0.filter((c) => contWord(c) && ownerOf(c) === a).map((c) => c.start));
4127
+ byLabel.set(l, widthless && flushHeaders && a.x > contMax + tol ? a.x : st);
4032
4128
  }
4033
4129
  if (byLabel.size < anchors.length && dashes.length) {
4034
4130
  dashes.sort((a, b) => a - b);
@@ -4048,19 +4144,53 @@ function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
4048
4144
  byLabel.set(own.label, real ? real.start : c.start);
4049
4145
  }
4050
4146
  }
4147
+ let filled = false;
4148
+ if (fromHeader && !widthless && byLabel.size < anchors.length) {
4149
+ for (let k = 1; k < anchors.length; k++) {
4150
+ const own = spanOf(anchors[k]);
4151
+ if (byLabel.has(anchors[k].label) || !own) continue;
4152
+ byLabel.set(anchors[k].label, own.x);
4153
+ filled = true;
4154
+ }
4155
+ }
4051
4156
  if (byLabel.size !== anchors.length) return null;
4052
4157
  const cols = [...byLabel.entries()].map(([label, start]) => ({ label, start })).sort((a, b) => a.start - b.start);
4053
4158
  if (cols.map((c) => c.label).join("|") !== anchors.map((a) => a.label).join("|")) return null;
4054
- const starts = kept.map((c) => c.start);
4159
+ if (rescued.length || filled) {
4160
+ for (let k = 1; k < anchors.length; k++) {
4161
+ const own = spanOf(anchors[k]), prev = spanOf(anchors[k - 1]);
4162
+ if (!own || !prev) return null;
4163
+ const st = cols[k].start;
4164
+ const lo = prev === own ? anchors[k - 1].x : prev.x + (prev.w || 0);
4165
+ if (st < lo - tol || st > Math.min(own.x, anchors[k].x) + tol) return null;
4166
+ }
4167
+ }
4168
+ const starts = cols0.map((c) => c.start);
4055
4169
  let on = 0;
4056
4170
  for (const x of xs) if (starts.some((st) => Math.abs(x - st) <= tol)) on++;
4057
- return { coord, cols, score: on / xs.length };
4171
+ let capX;
4172
+ const lastHdr = cfg.hdrSpans ? cfg.hdrSpans.find((t) => t.x - 0.5 <= anchors[anchors.length - 1].x && anchors[anchors.length - 1].x <= t.x + (t.w || 0) + 0.5) : void 0;
4173
+ if (kind === "finish" && lastHdr) {
4174
+ const lastR = lastHdr.x + (lastHdr.w || 0);
4175
+ const band = block;
4176
+ const ps = cols.slice(1).map((c, k) => c.start - cols[k].start).sort((p, q) => p - q);
4177
+ const clear = Math.max(tol, 0.5 * (ps.length ? ps[ps.length - 1 >> 1] : 0));
4178
+ const foreign = kept.filter((c) => c.start > lastR + clear && !contWord(c)).map((c) => c.start).sort((a, b) => a - b);
4179
+ const right = (t) => t.x + (t.w || 0);
4180
+ const heads = band.filter((t) => t !== lastHdr && right(t) > lastR).map((t) => ({
4181
+ lo: Math.max(lastR, ...band.filter((u) => u !== t && right(u) <= t.x).map(right)),
4182
+ hi: right(t)
4183
+ }));
4184
+ const free = foreign.filter((st) => !heads.some((h) => st >= h.lo - tol && st <= h.hi + tol));
4185
+ if (free.length) capX = free[0];
4186
+ }
4187
+ return { coord, cols, score: on / xs.length, tol, ...capX != null ? { capX } : {} };
4058
4188
  }
4059
- function columnStarts(rows, anchors, cfg, x0, x1) {
4189
+ function columnStarts(rows, anchors, cfg, x0, x1, kind) {
4060
4190
  const FIT_FLOOR = 0.7;
4061
4191
  const fits = (m) => m && m.score >= FIT_FLOOR ? m : null;
4062
- const left = fits(columnMapFor(rows, anchors, cfg, x0, x1, "left"));
4063
- const center = fits(columnMapFor(rows, anchors, cfg, x0, x1, "center"));
4192
+ const left = fits(columnMapFor(rows, anchors, cfg, x0, x1, "left", kind));
4193
+ const center = fits(columnMapFor(rows, anchors, cfg, x0, x1, "center", kind));
4064
4194
  if (!left) return center;
4065
4195
  if (!center) return left;
4066
4196
  return center.score > left.score + 0.05 ? center : left;
@@ -4068,10 +4198,59 @@ function columnStarts(rows, anchors, cfg, x0, x1) {
4068
4198
  function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4069
4199
  const { x0, x1, medGap } = bandLimits(anchors);
4070
4200
  const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
4071
- const cols = columnStarts(rows, anchors, cfg, x0, x1);
4201
+ const cols = columnStarts(rows, anchors, cfg, x0, x1, kind);
4202
+ const xCap = cols?.capX;
4203
+ const inBand = (t) => t.x >= x0 && t.x <= x1 && (xCap == null || t.x < xCap);
4204
+ const finish = kind === "finish";
4205
+ let curSection;
4206
+ const halfGap = anchors.length > 1 ? (anchors[1].x - anchors[0].x) / 2 : 40;
4207
+ const keyLo = cols ? cols.cols[0].start : anchors[0].x - halfGap;
4208
+ const keyHi = cols ? cols.cols.length > 1 ? cols.cols[1].start : Infinity : anchors[0].x + halfGap;
4209
+ const colTol = cols?.tol ?? 4;
4210
+ const atOf = (t) => cols && cols.coord === "center" ? centerX(t) : t.x;
4211
+ const inKey = (t) => {
4212
+ const a = atOf(t);
4213
+ return a >= Math.min(x0, keyLo - colTol) && a < keyHi;
4214
+ };
4215
+ let tLeft = 0, tRight = 0, medPitch = 0;
4216
+ let prevBandY = null;
4217
+ if (finish) {
4218
+ let ys = rows.slice(Math.max(cfg.fromIdx, 0)).filter((r) => r.some(inBand)).map(rowY);
4219
+ 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;
4222
+ };
4223
+ const p0 = med(ys);
4224
+ const cut = p0 > 0 ? ys.findIndex((y, k) => k > 0 && y - ys[k - 1] > 8 * p0) : -1;
4225
+ if (cut > 0) ys = ys.slice(0, cut);
4226
+ const lastTableY = ys.length ? ys[ys.length - 1] : Infinity;
4227
+ if (cols) {
4228
+ 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;
4230
+ tLeft = cs[0].start;
4231
+ tRight = cs[cs.length - 1].start + pitch2;
4232
+ } else {
4233
+ tLeft = Infinity;
4234
+ tRight = -Infinity;
4235
+ for (let i = Math.max(cfg.fromIdx - 1, 0); i < rows.length && rowY(rows[i]) <= lastTableY; i++) for (const t of rows[i]) {
4236
+ if (!inBand(t)) continue;
4237
+ tLeft = Math.min(tLeft, t.x);
4238
+ tRight = Math.max(tRight, t.x + (t.w || 0));
4239
+ }
4240
+ }
4241
+ medPitch = med(ys);
4242
+ }
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
+ };
4072
4250
  const keyTol = cols && cols.cols.length > 1 ? Math.max(8, (cols.cols[1].start - cols.cols[0].start) * 0.5) : 40;
4073
4251
  const out = [];
4074
4252
  const outY = [];
4253
+ const everRows = [];
4075
4254
  let region = null;
4076
4255
  const columnOf = (t) => {
4077
4256
  if (!cols) return nearestAnchor(centerX(t), anchors);
@@ -4117,9 +4296,36 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4117
4296
  if (centerX(t) >= x0 - 2.5 * medGap && centerX(t) <= x1 + medGap) markers.push({ rev, span: t, ...tri ? { drawn: true, tri } : {} });
4118
4297
  continue;
4119
4298
  }
4120
- if (t.x >= x0 && t.x <= x1) banded.push(t);
4299
+ if (inBand(t)) banded.push(t);
4121
4300
  }
4122
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]));
4316
+ continue;
4317
+ }
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]));
4322
+ continue;
4323
+ }
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;
4327
+ }
4328
+ }
4123
4329
  if (kind === "equipment" && out.length) {
4124
4330
  if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
4125
4331
  const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
@@ -4138,11 +4344,13 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4138
4344
  if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
4139
4345
  const row = { key: keyed.key, sheet: sheetKey, cells: {} };
4140
4346
  if (keyed.building) row.building = keyed.building;
4347
+ if (curSection) row.section = curSection;
4141
4348
  add(row, banded);
4142
4349
  out.push(row);
4143
4350
  outY.push(rowY(rows[i]));
4351
+ everRows.push(row);
4144
4352
  }
4145
- if (out.length > 2) {
4353
+ if (out.length > 2 && !(finish && cfg.marquee)) {
4146
4354
  const d = outY.slice(1).map((y, i) => y - outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
4147
4355
  const pitch0 = d.length ? d[d.length >> 1] : 0;
4148
4356
  if (pitch0 > 0) {
@@ -4157,7 +4365,9 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4157
4365
  }
4158
4366
  }
4159
4367
  }
4160
- const gaps = outY.slice(1).map((y, i) => y - outY[i]).filter((d) => d > 0).sort((a, b) => a - b);
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);
4161
4371
  const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
4162
4372
  const nearest = (y) => {
4163
4373
  let bi = -1, bd = Infinity;
@@ -4171,9 +4381,11 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4171
4381
  return { i: bi, d: bd };
4172
4382
  };
4173
4383
  const radius = (h) => pitch ? pitch * 0.6 : Math.max(h, 8) * 1.6;
4384
+ const headYs = [...headY].sort((p, q) => p - q);
4174
4385
  for (const o of orphans) {
4175
4386
  const { i, d } = nearest(o.y);
4176
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;
4177
4389
  add(out[i], o.toks);
4178
4390
  }
4179
4391
  for (const m of markers) {
@@ -4182,17 +4394,45 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
4182
4394
  const ebox = m.tri ? merge(bboxOf(m.span), m.tri) : bboxOf(m.span);
4183
4395
  out[i].revision = { rev: m.rev, source: { sheet: sheetKey, text: m.span.str.trim(), bbox: ebox }, ...m.drawn ? { drawn: true } : {} };
4184
4396
  }
4397
+ if (kind === "finish") {
4398
+ const keyCol = cols ? cols.cols[0].label : anchors[0].label;
4399
+ const dropped = /* @__PURE__ */ new Set();
4400
+ for (let i = out.length - 1; i >= 0; i--) {
4401
+ const r = out[i];
4402
+ 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))];
4405
+ 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))) {
4407
+ dropped.add(r);
4408
+ out.splice(i, 1);
4409
+ outY.splice(i, 1);
4410
+ }
4411
+ }
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;
4415
+ }
4416
+ }
4185
4417
  for (const row of out) {
4186
4418
  if (row.building) continue;
4187
4419
  const cellB = norm(row.cells.BLDG?.text || row.cells.BUILDING?.text || "");
4188
4420
  if (DESIGNATOR_RE.test(cellB)) row.building = cellB;
4189
4421
  }
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;
4190
4424
  return { out, region };
4191
4425
  }
4192
4426
  function extractTable(sheet, kind, opts = {}) {
4193
4427
  const r = extractTableCore(sheet, kind, opts);
4194
4428
  return r && "table" in r ? r.table : null;
4195
4429
  }
4430
+ function readFinishTable(sheet, opts = {}) {
4431
+ const r = extractTableCore(sheet, "finish", opts);
4432
+ if (!r || !("table" in r)) return null;
4433
+ if (r.table.title && isNonFinishSchedule(r.table.title.text)) return { refused: "other-family", table: r.table };
4434
+ return { table: r.table, headerWords: r.headerWords };
4435
+ }
4196
4436
  function extractTables(sheet, kind, opts = {}) {
4197
4437
  const out = [];
4198
4438
  let spans = sheet.spans;
@@ -4222,6 +4462,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4222
4462
  const minHits = kind === "room-finish" ? 4 : 3;
4223
4463
  let anchors;
4224
4464
  let headerSpans;
4465
+ let hdrBlock;
4225
4466
  let dataFrom;
4226
4467
  let dataBelowY = -Infinity;
4227
4468
  let titleFrom;
@@ -4230,6 +4471,14 @@ function extractTableCore(sheet, kind, opts = {}) {
4230
4471
  if (flat) {
4231
4472
  anchors = flat.anchors;
4232
4473
  headerSpans = rows[flat.rowIndex];
4474
+ const hy = rowY(rows[flat.rowIndex]);
4475
+ const hhs = headerSpans.map((t) => t.h || 8).sort((a, b) => a - b);
4476
+ const hh = hhs[hhs.length >> 1];
4477
+ const band = /* @__PURE__ */ new Set();
4478
+ for (let k = flat.top; k <= flat.rowIndex; k++) band.add(k);
4479
+ for (let k = flat.rowIndex + 1; k < rows.length && rowY(rows[k]) - hy <= 1.5 * hh; k++) band.add(k);
4480
+ for (let k = flat.top, n = 0; n < 2 && k > 0 && rowY(rows[k]) - rowY(rows[k - 1]) <= 3 * hh; n++) band.add(--k);
4481
+ hdrBlock = [...band].flatMap((k) => rows[k]);
4233
4482
  dataFrom = flat.rowIndex + 1;
4234
4483
  titleFrom = flat.rowIndex - 1;
4235
4484
  } else {
@@ -4254,7 +4503,7 @@ function extractTableCore(sheet, kind, opts = {}) {
4254
4503
  if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
4255
4504
  region = region ? merge(region, bboxOf(t)) : bboxOf(t);
4256
4505
  }
4257
- const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers });
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 });
4258
4507
  const out = banded.out;
4259
4508
  if (banded.region) region = region ? merge(region, banded.region) : banded.region;
4260
4509
  if (!out.length) {
@@ -4269,7 +4518,8 @@ function extractTableCore(sheet, kind, opts = {}) {
4269
4518
  }
4270
4519
  const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
4271
4520
  if (rotated) table.rotated_headers = true;
4272
- return { table };
4521
+ const headerWords = headerSpans.flatMap((t) => t.str.toUpperCase().split(/[^A-Z]+/)).filter(Boolean);
4522
+ return { table, headerWords };
4273
4523
  }
4274
4524
  var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
4275
4525
  var isContinuationTitle = (text) => {
@@ -4427,17 +4677,15 @@ function buildSheetGraph(sheets) {
4427
4677
  roles.set(s.key, classifySheetRole(s));
4428
4678
  const sheetFrags = [];
4429
4679
  const found2 = [];
4430
- for (const kind of ["room-finish", "finish"]) {
4431
- const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet });
4432
- if (t) found2.push(t);
4433
- }
4434
- found2.push(...extractTables(s, "equipment", { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet }));
4680
+ const xo = { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet };
4681
+ const rf = extractTable(s, "room-finish", xo);
4682
+ if (rf) found2.push(rf);
4683
+ const fin = readFinishTable(s, xo);
4684
+ if (fin && "refused" in fin) {
4685
+ notes.push(`${s.key}: "${fin.table.title.text}" names another schedule family, not a finish/material schedule \u2014 its ${fin.table.rows.length} rows are NOT indexed as finish definitions`);
4686
+ } else if (fin) found2.push(fin.table);
4687
+ found2.push(...extractTables(s, "equipment", xo));
4435
4688
  for (const t of found2) {
4436
- const kind = t.kind;
4437
- if (kind === "finish" && t.title && isNonFinishSchedule(t.title.text)) {
4438
- notes.push(`${s.key}: "${t.title.text}" names another schedule family, not a finish/material schedule \u2014 its ${t.rows.length} rows are NOT indexed as finish definitions`);
4439
- continue;
4440
- }
4441
4689
  const titleB = t.title ? buildingMentions(t.title.text) : [];
4442
4690
  const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
4443
4691
  if (b) t.building = b;
@@ -7028,6 +7276,11 @@ function attrValue(attrs, colId) {
7028
7276
 
7029
7277
  // ../web/src/lib/units.ts
7030
7278
  var M_PER_FT = 0.3048;
7279
+ var M2_PER_SF = 0.09290304;
7280
+ var areaVal = (sf, units) => units === "metric" ? sf * M2_PER_SF : sf;
7281
+ var areaUnit = (units) => units === "metric" ? "m\xB2" : "SF";
7282
+ var lenVal = (lf, units) => units === "metric" ? lf * M_PER_FT : lf;
7283
+ var lenUnit = (units) => units === "metric" ? "m" : "LF";
7031
7284
  function dimLabel(feet, units = "imperial") {
7032
7285
  if (!Number.isFinite(feet)) return "";
7033
7286
  if (units === "metric") return `${(feet * M_PER_FT).toFixed(2)} m`;
@@ -7041,6 +7294,49 @@ function dimLabel(feet, units = "imperial") {
7041
7294
  return `${sign}${ft}'-${inch}"`;
7042
7295
  }
7043
7296
 
7297
+ // ../web/src/lib/liveCounter.js
7298
+ function fmtQty(n) {
7299
+ const v = Math.round((Number(n) || 0) * 100) / 100;
7300
+ return v.toLocaleString("en-US", { maximumFractionDigits: 2 });
7301
+ }
7302
+
7303
+ // ../web/src/lib/noteFields.ts
7304
+ var FIELD_RE = /\{\{\s*([^{}]*?)\s*\}\}/g;
7305
+ var FIELD_WARN_INK = "#b03a26";
7306
+ var FIELD_WARN_INK_DARK = "#ff6b57";
7307
+ function hasFields(text) {
7308
+ return typeof text === "string" && text.includes("{{") && new RegExp(FIELD_RE.source).test(text);
7309
+ }
7310
+ function qtyLabels(totals, units = "imperial") {
7311
+ const out = /* @__PURE__ */ new Map();
7312
+ for (const t of totals || []) {
7313
+ if (!t || !t.shape_count) continue;
7314
+ const parts = [];
7315
+ if (t.total_sf) parts.push(`${fmtQty(areaVal(t.total_sf, units))} ${areaUnit(units)}`);
7316
+ if (t.lf) parts.push(`${fmtQty(lenVal(t.lf, units))} ${lenUnit(units)}`);
7317
+ if (t.ea) parts.push(`${fmtQty(t.ea)} EA`);
7318
+ if (parts.length) out.set(t.id, parts.join(" \xB7 "));
7319
+ }
7320
+ return out;
7321
+ }
7322
+ function resolveNote(text, conditionId, labels) {
7323
+ const s = typeof text === "string" ? text : "";
7324
+ if (!hasFields(s)) return { text: s, unresolved: [] };
7325
+ const unresolved = [];
7326
+ const label = typeof conditionId === "string" && conditionId ? labels.get(conditionId) : void 0;
7327
+ const out = s.replace(new RegExp(FIELD_RE.source, "g"), (whole, name) => {
7328
+ if (name.toLowerCase() === "qty" && label) return label;
7329
+ unresolved.push(whole);
7330
+ return whole;
7331
+ });
7332
+ return { text: out, unresolved };
7333
+ }
7334
+ function resolveMarkup(m, labels) {
7335
+ if (!m || !hasFields(m.text)) return { m, unresolved: [] };
7336
+ const r = resolveNote(m.text, m.condition_id, labels);
7337
+ return { m: r.unresolved.length ? { ...m, text: r.text, field_warn: true } : { ...m, text: r.text }, unresolved: r.unresolved };
7338
+ }
7339
+
7044
7340
  // ../web/src/lib/totals.js
7045
7341
  function accumulateRole(acc, s) {
7046
7342
  const cp = s.computed || {};
@@ -7196,6 +7492,7 @@ function grandTotals(rows) {
7196
7492
  }
7197
7493
  function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [], markups = [], rfis = [], sheetLabel = null, conditionColumns = [], attrsByCond = null, shapeLabels = [], byLabel = [], displayUnits = "imperial", rollGoods = [], proposedConditionEdits = [] }) {
7198
7494
  const label = (id) => sheetLabel ? sheetLabel(id) : id;
7495
+ const noteQty = qtyLabels(rows || [], displayUnits === "metric" ? "metric" : "imperial");
7199
7496
  const colDefs = (Array.isArray(conditionColumns) ? conditionColumns : []).filter((cc) => cc && typeof cc === "object" && typeof cc.id === "string");
7200
7497
  const attrs = attrsByCond instanceof Map ? attrsByCond : /* @__PURE__ */ new Map();
7201
7498
  return {
@@ -7232,9 +7529,14 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
7232
7529
  // resolved finish_tag rather than only the id, so a reader of the export
7233
7530
  // can see WHICH scope an annotation is about without joining two arrays;
7234
7531
  // the id stays authoritative. Unattached markups: "" for both.
7532
+ // text_resolved APPENDS (same rule), and only on a note that carries a
7533
+ // {{field}} — omit-when-empty, so every other markup row is unchanged.
7534
+ // `text` stays the stored note, template and all; text_resolved is what the
7535
+ // sheet shows, {{qty}} filled from the linked condition's measured quantity
7536
+ // (noteFields, #474). An unresolved field stays literal in both.
7235
7537
  markups: markups.map((m) => {
7236
7538
  const c = m.condition_id ? (rows || []).find((r) => r.id === m.condition_id) : null;
7237
- return { type: m.type, sheet_id: m.sheet_id, sheet: label(m.sheet_id), text: m.text || "", id: m.id ?? null, rfi_id: m.rfi_id || "", condition_id: m.condition_id || "", condition: c?.finish_tag || "" };
7539
+ return { type: m.type, sheet_id: m.sheet_id, sheet: label(m.sheet_id), text: m.text || "", id: m.id ?? null, rfi_id: m.rfi_id || "", condition_id: m.condition_id || "", condition: c?.finish_tag || "", ...hasFields(m.text) ? { text_resolved: resolveNote(m.text, m.condition_id, noteQty).text } : {} };
7238
7540
  }),
7239
7541
  // rfis APPENDS after markups (additive-only v1 — old exports had no RFI
7240
7542
  // register). linked_markups/linked_sheets are DERIVED from markup.rfi_id,
@@ -11547,6 +11849,12 @@ var Session = class _Session {
11547
11849
  seals = seals.filter((a) => a.shape_id !== void 0 && shapeById.get(a.shape_id)?.condition_id === c.id);
11548
11850
  }
11549
11851
  const s0 = this.sheets;
11852
+ const qtyL = rows.some((m) => hasFields(m.text)) ? qtyLabels(conditionTotals(this.conditions, this.shapes)) : /* @__PURE__ */ new Map();
11853
+ const fields = (m) => {
11854
+ if (!hasFields(m.text)) return {};
11855
+ const r = resolveNote(m.text, m.condition_id, qtyL);
11856
+ return { text_resolved: r.text, ...r.unresolved.length ? { unresolved_fields: r.unresolved } : {} };
11857
+ };
11550
11858
  const px = (m, p) => {
11551
11859
  const sh = s0.get(m.sheet_id);
11552
11860
  if (!p || !sh) return void 0;
@@ -11566,7 +11874,8 @@ var Session = class _Session {
11566
11874
  ...m.from ? { from: px(m, m.from) } : {},
11567
11875
  ...m.to ? { to: px(m, m.to) } : {},
11568
11876
  ...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {},
11569
- ...m.len_ft != null ? { length_lf: m.len_ft } : {}
11877
+ ...m.len_ft != null ? { length_lf: m.len_ft } : {},
11878
+ ...fields(m)
11570
11879
  })),
11571
11880
  count: rows.length,
11572
11881
  unattached: rows.filter((m) => !m.condition_id).length,
@@ -13083,7 +13392,9 @@ var annotationRow = z.object({
13083
13392
  from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail / dimension start (image px)"),
13084
13393
  to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head / dimension end (image px)"),
13085
13394
  r: z.number().optional().describe("Bubble radius (image px)"),
13086
- length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
13395
+ length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale"),
13396
+ text_resolved: z.string().optional().describe("Only when text carries a {{qty}} field: the note as the sheet shows it, {{qty}} filled with the linked condition's measured quantity (multiplier applied, no waste)"),
13397
+ unresolved_fields: z.array(z.string()).optional().describe("Fields that could not fill (no linked condition, nothing measured yet, or an unknown name) \u2014 they stay literal and print in the warning ink; link_annotation fixes the first case")
13087
13398
  });
13088
13399
  var editAnnotationOutput = { id: z.string(), text: z.string(), note: z.string() };
13089
13400
  var annotateOutput = {
@@ -13293,7 +13604,7 @@ function annotationScene(m, W, H, pixelScale = 2) {
13293
13604
  const width = Math.max(45 * pixelScale, ...lines.map((l) => l.length * font * 0.62)) + pad * 2;
13294
13605
  const height = Math.max(1, lines.length) * font * 1.35 + pad * 2;
13295
13606
  add([...path3([at, [at[0] + width, at[1]], [at[0] + width, at[1] + height], [at[0], at[1] + height]]), ["Z"]], { fill: "#ffffff" });
13296
- lines.forEach((text, i) => texts.push({ text, x: at[0] + pad, y: at[1] + pad + font * (1 + i * 1.35), size: font, color: "#172033" }));
13607
+ lines.forEach((text, i) => texts.push({ text, x: at[0] + pad, y: at[1] + pad + font * (1 + i * 1.35), size: font, color: m.field_warn ? FIELD_WARN_INK : "#172033" }));
13297
13608
  return [[at[0], at[1]], [at[0] + width, at[1] + height]];
13298
13609
  };
13299
13610
  let noteBox2 = null;
@@ -14099,6 +14410,15 @@ function invertPixels(cv) {
14099
14410
  }
14100
14411
  async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, approvals = [], rfis: rfisIn = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
14101
14412
  markups = (markups || []).filter((m) => !m.reference_only);
14413
+ const fieldWarn = /* @__PURE__ */ new Set();
14414
+ {
14415
+ const qtyL = qtyLabels(conditionTotals(conditions || [], shapes || []), units);
14416
+ markups = markups.map((m0) => {
14417
+ const r = resolveMarkup(m0, qtyL);
14418
+ if (r.unresolved.length) fieldWarn.add(m0.id);
14419
+ return r.m;
14420
+ });
14421
+ }
14102
14422
  const rfis = liveRfis(rfisIn);
14103
14423
  const M = units === "metric";
14104
14424
  const uA = (sf) => M ? sf * 0.09290304 : sf;
@@ -14516,6 +14836,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14516
14836
  const mCond = m.condition_id ? (conditions || []).find((c) => c.id === m.condition_id) : null;
14517
14837
  const mbase = m.color || mCond?.color || (m.rfi_id ? COBALT : "#c47a10");
14518
14838
  const mcol = rgb(...hex(dark ? boostForDark(mbase) : mbase));
14839
+ const tcol = fieldWarn.has(m.id) ? rgb(...hex(dark ? FIELD_WARN_INK_DARK : FIELD_WARN_INK)) : mcol;
14519
14840
  const mdash = pdfDashFor(m.line_style || "solid");
14520
14841
  const mw = clampWeight(m.weight);
14521
14842
  if (m.annotation_style && ["arrow", "highlight", "callout", "cloud", "text"].includes(m.type)) {
@@ -14560,7 +14881,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14560
14881
  const r = [[nx0 * W, ny0 * H], [nx1 * W, ny0 * H], [nx1 * W, ny1 * H], [nx0 * W, ny1 * H]];
14561
14882
  pg.drawSvgPath(svgPath(r), { x: 0, y: 0, color: mcol, opacity: 0.18 + alphaBoost / 2, borderColor: mcol, borderWidth: 1 * mw, borderOpacity: 0.9, ...mdash ? { borderDashArray: mdash } : {} });
14562
14883
  const t = lbl(m.text);
14563
- if (t) text(t, Math.min(nx0, nx1) * W, Math.min(ny0, ny1) * H - 10 / ptScale, 8, mcol, bold);
14884
+ if (t) text(t, Math.min(nx0, nx1) * W, Math.min(ny0, ny1) * H - 10 / ptScale, 8, tcol, bold);
14564
14885
  } else if (m.type === "cloud" && m.rect) {
14565
14886
  const [[nx0, ny0], [nx1, ny1]] = m.rect;
14566
14887
  const cb = cloudBezier(nx0 * W, ny0 * H, nx1 * W, ny1 * H);
@@ -14572,7 +14893,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14572
14893
  for (const [c1, c2, end] of cb.segments) d += ` C${P(c1)} ${P(c2)} ${P(end)}`;
14573
14894
  pg.drawSvgPath(d + " Z", { x: 0, y: 0, borderColor: mcol, borderWidth: 1.3 * mw, borderOpacity: 0.95, ...mdash ? { borderDashArray: mdash } : {} });
14574
14895
  const t = lbl(m.text);
14575
- if (t) text(t, Math.min(nx0, nx1) * W, Math.min(ny0, ny1) * H - 10 / ptScale, 8, mcol, bold);
14896
+ if (t) text(t, Math.min(nx0, nx1) * W, Math.min(ny0, ny1) * H - 10 / ptScale, 8, tcol, bold);
14576
14897
  if (Number.isFinite(m.rev) && m.rev > 0) {
14577
14898
  const cxImg = Math.max(nx0, nx1) * W, cyImg = Math.min(ny0, ny1) * H, s = 9 / ptScale;
14578
14899
  const tri = [[cxImg, cyImg - s], [cxImg + s, cyImg + s], [cxImg - s, cyImg + s]];
@@ -14586,7 +14907,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14586
14907
  const [ptx, pty] = toPage(m.to[0] * W, m.to[1] * H);
14587
14908
  pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
14588
14909
  const t = lbl(m.text);
14589
- if (t) text(t, (m.from[0] + m.to[0]) / 2 * W, (m.from[1] + m.to[1]) / 2 * H - 6 / ptScale, 8, mcol, bold);
14910
+ if (t) text(t, (m.from[0] + m.to[0]) / 2 * W, (m.from[1] + m.to[1]) / 2 * H - 6 / ptScale, 8, tcol, bold);
14590
14911
  } else if (m.type === "dimension" && m.from && m.to) {
14591
14912
  const fx = m.from[0] * W, fy = m.from[1] * H, dxq = m.to[0] * W, dyq = m.to[1] * H;
14592
14913
  line(fx, fy, dxq, dyq, mcol, 1.1 * mw, 0.95, mdash);
@@ -14600,7 +14921,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14600
14921
  const size = 8;
14601
14922
  const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
14602
14923
  const [pmx, pmy] = toPage((fx + dxq) / 2 + dnx * (14 / ptScale), (fy + dyq) / 2 + dny * (14 / ptScale));
14603
- pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
14924
+ pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: tcol, rotate: chipRot });
14604
14925
  }
14605
14926
  } else if (m.type === "bubble" && m.at) {
14606
14927
  const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
@@ -14611,7 +14932,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14611
14932
  if (t) {
14612
14933
  const size = 8;
14613
14934
  const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
14614
- pg.drawText(winAnsiSafe(t), { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
14935
+ pg.drawText(winAnsiSafe(t), { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: tcol, rotate: chipRot });
14615
14936
  }
14616
14937
  } else if (m.type === "callout" && m.at) {
14617
14938
  if (m.target) {
@@ -14620,9 +14941,9 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14620
14941
  const [ptx, pty] = toPage(m.target[0] * W, m.target[1] * H);
14621
14942
  pg.drawSvgPath(arrowheadPath(pax, -pay, ptx, -pty, 5), { x: 0, y: 0, color: mcol, opacity: 0.9 });
14622
14943
  }
14623
- noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, mcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1));
14944
+ noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, tcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1));
14624
14945
  } else if (m.type === "text" && m.at) {
14625
- noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, mcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 0.97, 0.93));
14946
+ noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, tcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 0.97, 0.93));
14626
14947
  } else if (m.type === "svg" && m.at && Array.isArray(m.vb) && typeof m.path === "string") {
14627
14948
  const { s: sx, bw, bh } = svgPlacedBox(m.vb, m.w, W);
14628
14949
  if (sx > 0) {
@@ -14634,7 +14955,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14634
14955
  const fillOn = m.fill && m.fill !== "none";
14635
14956
  if (d) pg.drawSvgPath(d, { x: 0, y: 0, borderColor: mcol, borderWidth: 1.2 * mw, borderOpacity: 0.95, ...fillOn ? { color: rgb(...hex(dark ? boostForDark(m.fill) : m.fill)), opacity: 0.9 } : {} });
14636
14957
  const t = lbl(m.text);
14637
- if (t) text(t, m.at[0] * W - bw / 2, y0 - 6 / ptScale, 8, mcol, bold);
14958
+ if (t) text(t, m.at[0] * W - bw / 2, y0 - 6 / ptScale, 8, tcol, bold);
14638
14959
  }
14639
14960
  } else if (m.type === "image" && m.at && m.src) {
14640
14961
  const fmt3 = pickEmbedFormat(m.src);
@@ -15640,7 +15961,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
15640
15961
  server.registerTool("annotate", {
15641
15962
  description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text), dimension takes from:[x,y] and to:[x,y] (its two measured endpoints) and labels itself with the length between them at the sheet's scale \u2014 drawn as a dimension line with end ticks and the measurement centered. A dimension states a REAL length, so it is the one annotation the scale gate applies to: on an unscaled sheet it refuses exactly like the measure tools (set_scale first) rather than dressing a px figure up as feet. It still touches no quantity \u2014 a dimension is a note about a distance, not a takeoff line item.
15642
15963
 
15643
- Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like ${oneClick ? "one_click/" : ""}measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
15964
+ Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like ${oneClick ? "one_click/" : ""}measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself. Write {{qty}} in the text to quote the linked condition's live measured quantity (multiplier applied, no waste): "x{{qty}} fixtures" reads "x6 EA fixtures" on the sheet and follows every later count; the stored text keeps the field, and list_annotations reports text_resolved.
15644
15965
 
15645
15966
  No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
15646
15967
  inputSchema: {
@@ -15741,7 +16062,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
15741
16062
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
15742
16063
 
15743
16064
  // src/wiki.generated.ts
15744
- var WIKI_VERSION = "0.9.90";
16065
+ var WIKI_VERSION = "0.9.93";
15745
16066
  var WIKI_PAGES = [
15746
16067
  {
15747
16068
  "key": "index",
@@ -16352,7 +16673,7 @@ function nameTheStageInRefusals(server) {
16352
16673
  // package.json
16353
16674
  var package_default = {
16354
16675
  name: "opentakeoff-mcp",
16355
- version: "0.9.90",
16676
+ version: "0.9.93",
16356
16677
  mcpName: "io.github.Kentucky-ai/opentakeoff",
16357
16678
  type: "module",
16358
16679
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -16372,7 +16693,7 @@ var package_default = {
16372
16693
  "check:wiki": "node scripts/check-wiki.mjs",
16373
16694
  "check:versions": "node ../scripts/check-version-consistency.mjs",
16374
16695
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
16375
- test: "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/splittags.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
16696
+ test: "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/finishSchedule.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/pageSpans.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scheduleImport.test.ts test/scope.test.ts test/session.test.ts test/splittags.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
16376
16697
  },
16377
16698
  dependencies: {
16378
16699
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.90",
3
+ "version": "0.9.93",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -20,7 +20,7 @@
20
20
  "check:wiki": "node scripts/check-wiki.mjs",
21
21
  "check:versions": "node ../scripts/check-version-consistency.mjs",
22
22
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
23
- "test": "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/splittags.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
23
+ "test": "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/finishSchedule.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/pageSpans.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scheduleImport.test.ts test/scope.test.ts test/session.test.ts test/splittags.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
24
24
  },
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.12.0",