opentakeoff-mcp 0.9.68 → 0.9.71

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 +129 -24
  2. package/package.json +2 -2
@@ -233,18 +233,22 @@ function _findScales(canon) {
233
233
  }
234
234
  return out;
235
235
  }
236
+ function _scaleHits(parts) {
237
+ const joined = _findScales(parts.map(_canonScaleText).filter(Boolean).join("|"));
238
+ return joined.length ? joined : _findScales(_canonScaleText(parts.join(" ")));
239
+ }
236
240
  function detectScale(textContent, viewport) {
237
241
  const W = viewport.width, H = viewport.height;
238
- let all = "", tb = "";
242
+ const all = [], tb = [];
239
243
  for (const it of textContent.items || []) {
240
244
  const str = it.str || "";
241
245
  if (!str.trim()) continue;
242
- all += str + " ";
246
+ all.push(str);
243
247
  const t = pdfjsLib.Util.transform(viewport.transform, it.transform);
244
- if (t[4] > W * 0.55 && t[5] > H * 0.5) tb += str + " ";
248
+ if (t[4] > W * 0.55 && t[5] > H * 0.5) tb.push(str);
245
249
  }
246
- const tbHits = _findScales(_canonScaleText(tb));
247
- const allHits = _findScales(_canonScaleText(all));
250
+ const tbHits = _scaleHits(tb);
251
+ const allHits = _scaleHits(all);
248
252
  if (tbHits.length) return { upp: tbHits[0].upp, label: tbHits[0].label, multi: allHits.length > 1 };
249
253
  if (allHits.length === 1) return { upp: allHits[0].upp, label: allHits[0].label, multi: false };
250
254
  return null;
@@ -3209,6 +3213,7 @@ function floodAtSeed(maskObj, ix, iy, sensitivity = SENS_BALANCED, maskPxPerFt =
3209
3213
  return floodRegionSealed(maskObj, ix, iy, sensitivity, a.radii, a.wedgeCapPx, a.minPassPx);
3210
3214
  }
3211
3215
  var BUBBLE_RATIO = 2.5;
3216
+ var BUBBLE_WIDE_RATIO = 6;
3212
3217
  function seedLadderPx(b, first = "anchor") {
3213
3218
  const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
3214
3219
  const h = Math.max(b.y1 - b.y0, 1);
@@ -3216,6 +3221,25 @@ function seedLadderPx(b, first = "anchor") {
3216
3221
  const rest = [[cx, cy - 2 * h], [cx, cy + 3.5 * h]];
3217
3222
  return first === "below-box" ? [below, center, ...rest] : [center, below, ...rest];
3218
3223
  }
3224
+ var SURROUND_MIN_SIDES = 3;
3225
+ function floodSurroundsLabelPx(f, b) {
3226
+ const inRegion = (x, y) => {
3227
+ const mx = Math.round(x * f.ws), my = Math.round(y * f.ws);
3228
+ return mx >= 0 && my >= 0 && mx < f.mw && my < f.mh && !!f.region[my * f.mw + mx];
3229
+ };
3230
+ const h = Math.max(b.y1 - b.y0, 1);
3231
+ const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2, w = b.x1 - b.x0;
3232
+ const alongX = [cx - 0.35 * w, cx, cx + 0.35 * w];
3233
+ const alongY = [cy - 0.3 * h, cy, cy + 0.3 * h];
3234
+ const vd = [0.6, 1.1, 1.6].map((k) => k * h);
3235
+ const hd = [1.3, 1.8, 2.3].map((k) => k * h);
3236
+ let sides = 0;
3237
+ if (alongX.some((x) => vd.some((d) => inRegion(x, b.y0 - d)))) sides++;
3238
+ if (alongX.some((x) => vd.some((d) => inRegion(x, b.y1 + d)))) sides++;
3239
+ if (alongY.some((y) => hd.some((d) => inRegion(b.x0 - d, y)))) sides++;
3240
+ if (alongY.some((y) => hd.some((d) => inRegion(b.x1 + d, y)))) sides++;
3241
+ return sides >= SURROUND_MIN_SIDES;
3242
+ }
3219
3243
  function isLabelBubblePx(ring, b) {
3220
3244
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
3221
3245
  for (const [x, y] of ring) {
@@ -3225,7 +3249,9 @@ function isLabelBubblePx(ring, b) {
3225
3249
  if (y > y1) y1 = y;
3226
3250
  }
3227
3251
  const lw = Math.max(b.x1 - b.x0, 1e-6), lh = Math.max(b.y1 - b.y0, 1e-6);
3228
- return x1 - x0 <= BUBBLE_RATIO * lw && y1 - y0 <= BUBBLE_RATIO * lh;
3252
+ const rw = x1 - x0, rh = y1 - y0;
3253
+ if (rw <= BUBBLE_RATIO * lw && rh <= BUBBLE_RATIO * lh) return true;
3254
+ return rh <= BUBBLE_RATIO * lh && rw <= BUBBLE_WIDE_RATIO * lw;
3229
3255
  }
3230
3256
 
3231
3257
  // ../web/src/lib/sheetgraph.ts
@@ -3378,7 +3404,7 @@ function clusterRows(spans) {
3378
3404
  }
3379
3405
  var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
3380
3406
  var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
3381
- var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "CASEWORK", "CABINET", "COUNTER", "COUNTERTOP", "BLDG", "BUILDING"];
3407
+ 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"];
3382
3408
  var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
3383
3409
  var headerLabel = (s, vocab) => headerLabels(s, vocab)[0] ?? null;
3384
3410
  var headerLabels = (s, vocab) => {
@@ -3392,6 +3418,15 @@ function headerHits(row, vocab) {
3392
3418
  for (const t of row) {
3393
3419
  const words = headerLabels(t.str, vocab);
3394
3420
  if (!words.length) continue;
3421
+ const all = norm(t.str).split(/[^A-Z]+/).filter(Boolean);
3422
+ if (all.length >= 2 && all.every((w2) => SURFACE_WORDS.has(w2))) {
3423
+ const n = all.length, w2 = (t.w || 0) / n;
3424
+ all.forEach((word, k) => {
3425
+ used.add(word);
3426
+ out.push({ label: word, span: { ...t, x: t.x + w2 * k, w: w2 } });
3427
+ });
3428
+ continue;
3429
+ }
3395
3430
  const w = words.find((word) => !used.has(word)) ?? words[0];
3396
3431
  used.add(w);
3397
3432
  out.push({ label: w, span: t });
@@ -3400,7 +3435,7 @@ function headerHits(row, vocab) {
3400
3435
  }
3401
3436
  var qualifies = (hits, required, minHits) => {
3402
3437
  const seen = new Set(hits.map((h) => h.label));
3403
- return seen.size >= minHits && required.some((r) => seen.has(r));
3438
+ return seen.size >= minHits && (!required.length || required.some((r) => seen.has(r)));
3404
3439
  };
3405
3440
  function findHeaderRow(rows, vocab, required, minHits) {
3406
3441
  for (let i = 0; i < rows.length; i++) {
@@ -3412,7 +3447,7 @@ function findHeaderRow(rows, vocab, required, minHits) {
3412
3447
  for (let j = idx + 1; j < Math.min(idx + 4, rows.length); j++) {
3413
3448
  const h = headerHits(rows[j], vocab);
3414
3449
  const ratio = h.length / Math.max(1, rows[j].length);
3415
- if (qualifies(h, required, minHits) && h.length > hits.length && ratio >= 0.6) {
3450
+ if (qualifies(h, [], minHits) && h.length > hits.length && ratio >= 0.6) {
3416
3451
  next = j;
3417
3452
  break;
3418
3453
  }
@@ -3426,13 +3461,21 @@ function findHeaderRow(rows, vocab, required, minHits) {
3426
3461
  for (const h of hits) (once.has(h.label) ? dup : once).add(h.label);
3427
3462
  const anchors = [];
3428
3463
  const used = /* @__PURE__ */ new Set();
3464
+ const parents = [];
3429
3465
  for (let j = 0; j < hits.length; j++) {
3430
3466
  const h = hits[j];
3467
+ parents[j] = null;
3431
3468
  let label = h.label;
3432
3469
  if (dup.has(h.label) && !SURFACE_WORDS.has(h.label)) {
3433
3470
  const hi = j + 1 < hits.length ? hits[j + 1].span.x : Infinity;
3434
- const parent = parentLabelOver(rows, idx, i, h.span.x, hi, vocab);
3435
- if (parent && parent !== h.label) label = `${parent} ${h.label}`;
3471
+ const lo2 = j > 0 ? (hits[j - 1].span.x + (hits[j - 1].span.w || 0) + h.span.x) / 2 : h.span.x;
3472
+ const hi2 = j + 1 < hits.length ? (h.span.x + (h.span.w || 0) + hits[j + 1].span.x) / 2 : hi;
3473
+ let parent = parentLabelOver(rows, idx, i, h.span.x, hi, vocab) ?? parentLabelOver(rows, idx, i, lo2, hi2, vocab);
3474
+ if (!parent && j > 0 && parents[j - 1] && h.span.x - (hits[j - 1].span.x + (hits[j - 1].span.w || 0)) < bandLimits(hits.map((x) => ({ label: x.label, x: x.span.x }))).medGap * 1.5) parent = parents[j - 1];
3475
+ if (parent && parent !== h.label) {
3476
+ label = `${parent} ${h.label}`;
3477
+ parents[j] = parent;
3478
+ }
3436
3479
  }
3437
3480
  if (used.has(label)) {
3438
3481
  const alt = headerLabels(h.span.str, vocab).find((w) => !used.has(w));
@@ -3445,10 +3488,12 @@ function findHeaderRow(rows, vocab, required, minHits) {
3445
3488
  if (anchors.length < minHits) continue;
3446
3489
  if (idx > i) {
3447
3490
  const lo = Math.min(...anchors.map((a) => a.x)), hi = Math.max(...anchors.map((a) => a.x));
3491
+ const reach = bandLimits(anchors).medGap * 2;
3448
3492
  for (let j = i; j < idx; j++) {
3449
3493
  for (const h of headerHits(rows[j], vocab)) {
3450
3494
  const cx = h.span.x + (h.span.w || 0) / 2;
3451
3495
  if (cx >= lo && cx <= hi) continue;
3496
+ if (cx < lo - reach || cx > hi + reach) continue;
3452
3497
  if (used.has(h.label)) continue;
3453
3498
  used.add(h.label);
3454
3499
  anchors.push({ label: h.label, x: cx });
@@ -3561,7 +3606,7 @@ var nearestAnchor = (x, anchors) => {
3561
3606
  }
3562
3607
  return (best ?? anchors[0]).label;
3563
3608
  };
3564
- var WIDE_LAST = /* @__PURE__ */ new Set(["REMARKS", "DESCRIPTION", "NOTES"]);
3609
+ var WIDE_LAST = /* @__PURE__ */ new Set(["REMARKS", "DESCRIPTION", "NOTES", "COMMENTS"]);
3565
3610
  function bandLimits(anchors) {
3566
3611
  const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
3567
3612
  const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
@@ -3572,6 +3617,7 @@ function bandLimits(anchors) {
3572
3617
  var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
3573
3618
  var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
3574
3619
  var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
3620
+ var CORRIDOR_KEY_RE = /^[A-Z]{1,3}(?:\d{1,3}-\d{1,3}|\d{3})[A-Z]?$/;
3575
3621
  var OTHER_FAMILY_RE = /\b(DOOR|WINDOW|PARTITION|EQUIPMENT|HARDWARE|LOUVER|SIGNAGE|LIGHTING|LUMINAIRE|PLUMBING|MECHANICAL|ELECTRICAL|STOREFRONT|GLAZING|CASEWORK|MILLWORK|APPLIANCE)S?\b/;
3576
3622
  var isNonFinishSchedule = (title) => {
3577
3623
  const u = norm(title);
@@ -3586,6 +3632,7 @@ function rowKeyOf(raw, kind, buildings) {
3586
3632
  return CODE_RE.test(key) ? { key } : null;
3587
3633
  }
3588
3634
  if (ROW_KEY_RE.test(key)) return { key };
3635
+ if (CORRIDOR_KEY_RE.test(key)) return { key };
3589
3636
  const q = key.match(QUALIFIED_KEY_RE);
3590
3637
  if (q && buildings?.has(q[1])) return { key, building: q[1] };
3591
3638
  return null;
@@ -3597,14 +3644,20 @@ var rowKeyAnswersFor = (key, want) => {
3597
3644
  };
3598
3645
  var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
3599
3646
  var centerX = (t) => t.x + (t.w || 0) / 2;
3647
+ var PLACEHOLDER_RE = /^[-–—]{1,3}$/;
3600
3648
  function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
3601
3649
  const at = (t) => coord === "left" ? t.x : t.x + (t.w || 0) / 2;
3602
3650
  const xs = [];
3603
3651
  const hs = [];
3652
+ const dashes = [];
3604
3653
  for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
3605
3654
  if (rowY(rows[i]) <= cfg.belowY) continue;
3606
3655
  for (const t of rows[i]) {
3607
3656
  if (t.x < x0 || t.x > x1 || revisionOf(t.str) != null) continue;
3657
+ if (PLACEHOLDER_RE.test(t.str.trim())) {
3658
+ dashes.push(at(t));
3659
+ continue;
3660
+ }
3608
3661
  xs.push(at(t));
3609
3662
  hs.push(t.h || 8);
3610
3663
  }
@@ -3632,6 +3685,24 @@ function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
3632
3685
  const cur = byLabel.get(own.label);
3633
3686
  if (cur == null || c.start < cur) byLabel.set(own.label, c.start);
3634
3687
  }
3688
+ if (byLabel.size < anchors.length && dashes.length) {
3689
+ dashes.sort((a, b) => a - b);
3690
+ const dc = [];
3691
+ for (const x of dashes) {
3692
+ const last = dc[dc.length - 1];
3693
+ if (last && x - last.start <= tol) {
3694
+ last.n++;
3695
+ continue;
3696
+ }
3697
+ dc.push({ start: x, n: 1 });
3698
+ }
3699
+ for (const c of dc.filter((d) => d.n >= 2)) {
3700
+ const own = anchors.find((a) => a.x >= c.start);
3701
+ if (!own || byLabel.has(own.label)) continue;
3702
+ const real = clusters.filter((k) => k.n >= 1 && anchors.find((a) => a.x >= k.start)?.label === own.label).sort((a, b) => a.start - b.start)[0];
3703
+ byLabel.set(own.label, real ? real.start : c.start);
3704
+ }
3705
+ }
3635
3706
  if (byLabel.size !== anchors.length) return null;
3636
3707
  const cols = [...byLabel.entries()].map(([label, start]) => ({ label, start })).sort((a, b) => a.start - b.start);
3637
3708
  if (cols.map((c) => c.label).join("|") !== anchors.map((a) => a.label).join("|")) return null;
@@ -3664,6 +3735,19 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
3664
3735
  if (at + 1 >= c.start) label = c.label;
3665
3736
  else break;
3666
3737
  }
3738
+ if (cols.coord === "left" && (t.w || 0) > 0) {
3739
+ const x12 = t.x + (t.w || 0);
3740
+ let best = label, bestOv = -1;
3741
+ for (let ci = 0; ci < cols.cols.length; ci++) {
3742
+ const lo = cols.cols[ci].start, hi = ci + 1 < cols.cols.length ? cols.cols[ci + 1].start : Infinity;
3743
+ const ov = Math.min(hi, x12) - Math.max(lo, t.x);
3744
+ if (ov > bestOv) {
3745
+ bestOv = ov;
3746
+ best = cols.cols[ci].label;
3747
+ }
3748
+ }
3749
+ if (bestOv > 0) label = best;
3750
+ }
3667
3751
  return label;
3668
3752
  };
3669
3753
  const add = (row, toks) => {
@@ -4969,6 +5053,16 @@ function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
4969
5053
  };
4970
5054
  }
4971
5055
 
5056
+ // src/sweepGuard.ts
5057
+ var SEED_SEGMENTS_FLOOR = 40;
5058
+ var COMMON_MATCH_CEILING = 50;
5059
+ function sweepCommitRefusal(i) {
5060
+ if (i.variantGuard || i.negatives > 0) return null;
5061
+ if (i.seedSegments >= SEED_SEGMENTS_FLOOR) return null;
5062
+ if (i.found <= COMMON_MATCH_CEILING) return null;
5063
+ return `commit refused (#376): the seed is ${i.seedSegments} segment(s) of linework and ${i.found} placement(s) cleared the bar \u2014 geometry this small and this common on the sheet is tree canopy, text, and line ticks as often as it is the symbol. Nothing was committed; the ${i.found} placements are listed in matches so you can look. To commit, say what you mean: variant_guard: true (the seed IS the whole symbol \u2014 richer placements become questions), exclude rects around what you do NOT mean, or a seed rect that captures more of the symbol's own linework (${SEED_SEGMENTS_FLOOR}+ segments stands the guard down).`;
5064
+ }
5065
+
4972
5066
  // ../web/src/lib/symbolsweep.ts
4973
5067
  var SWEEP_TOL_PX = 2;
4974
5068
  var SWEEP_SCORE_HIGH = 0.92;
@@ -8026,14 +8120,14 @@ var Session = class _Session {
8026
8120
  const num2 = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
8027
8121
  if (num2) labels.push({ str: num2, bbox: sp });
8028
8122
  }
8029
- const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0, unresolved: 0 };
8123
+ const withheld = { degenerate: 0, duplicate: 0, bubble: 0, unowned: 0, implausible: 0, unresolved: 0 };
8030
8124
  const unresolved = [];
8031
8125
  const byRing = /* @__PURE__ */ new Map();
8032
8126
  const order = [];
8033
8127
  const sweepMppf = raster ? s.upp ? mask.ws / s.upp : 0 : mask.mppf || 0;
8034
8128
  for (const lb of labels) {
8035
8129
  let ring = null, ev = null, seed = null;
8036
- let sawBubble = false, sawDegenerate = false;
8130
+ let sawBubble = false, sawDegenerate = false, sawUnowned = false;
8037
8131
  for (const probe of seedLadderPx(lb.bbox)) {
8038
8132
  const f = floodAtSeed(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED, sweepMppf);
8039
8133
  if (f.status !== "ok") continue;
@@ -8046,13 +8140,18 @@ var Session = class _Session {
8046
8140
  sawBubble = true;
8047
8141
  continue;
8048
8142
  }
8143
+ if (!floodSurroundsLabelPx(f, lb.bbox)) {
8144
+ sawUnowned = true;
8145
+ continue;
8146
+ }
8049
8147
  ring = r;
8050
8148
  ev = _Session.floodEvidence(f, raster, sweepMppf);
8051
8149
  seed = probe;
8052
8150
  break;
8053
8151
  }
8054
8152
  if (!ring || !ev || !seed) {
8055
- if (sawBubble) withheld.bubble++;
8153
+ if (sawBubble && !sawUnowned) withheld.bubble++;
8154
+ else if (sawUnowned) withheld.unowned++;
8056
8155
  else if (sawDegenerate) withheld.degenerate++;
8057
8156
  continue;
8058
8157
  }
@@ -8136,7 +8235,7 @@ var Session = class _Session {
8136
8235
  seed_norm: [u.seed[0] / s.widthPx, u.seed[1] / s.heightPx]
8137
8236
  }));
8138
8237
  }
8139
- const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible + withheld.unresolved;
8238
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.unowned + withheld.implausible + withheld.unresolved;
8140
8239
  return {
8141
8240
  detected: rooms.length,
8142
8241
  rooms,
@@ -8149,7 +8248,7 @@ var Session = class _Session {
8149
8248
  // positive claim "every detected room resolved against its own row"
8150
8249
  ...assign ? { unresolved } : {},
8151
8250
  ...s.detected?.multi ? { multiple_scales: true } : {},
8152
- ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
8251
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.unowned} unowned (every clean flood was a neighbouring space or door pocket \u2014 one_click inside the room), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
8153
8252
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
8154
8253
  };
8155
8254
  }
@@ -8937,7 +9036,8 @@ var Session = class _Session {
8937
9036
  if (!s.spans) s.spans = textSpans(s.page);
8938
9037
  const lbl = this.sweepLabels(s.spans, geo, fp.center, res.matches, res.withheld);
8939
9038
  let committed2;
8940
- if (opts.commit && (res.matches.length || opts.commitSeed)) {
9039
+ const refusal2 = opts.commit ? sweepCommitRefusal({ seedSegments: fp.segments, found: res.matches.length, variantGuard: !!opts.variantGuard, negatives: negatives.length }) : null;
9040
+ if (opts.commit && !refusal2 && (res.matches.length || opts.commitSeed)) {
8941
9041
  const points = [...opts.commitSeed ? [fp.center] : [], ...res.matches.map((m) => m.at)];
8942
9042
  const seedOrigin = {
8943
9043
  method: "symbol_sweep",
@@ -8975,9 +9075,10 @@ var Session = class _Session {
8975
9075
  ea_total: committed2.ea_total,
8976
9076
  ...opts.commitSeed ? { seed_committed: true } : {}
8977
9077
  } : {},
9078
+ ...refusal2 ? { committed: 0, commit_refused: refusal2 } : {},
8978
9079
  ...(() => {
8979
9080
  const parts = [];
8980
- if (opts.commit && !res.matches.length && !opts.commitSeed) parts.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
9081
+ if (opts.commit && !refusal2 && !res.matches.length && !opts.commitSeed) parts.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
8981
9082
  if (committed2 && !opts.commitSeed) parts.push(`The seed instance at (${round1(fp.center[0])}, ${round1(fp.center[1])}) is NOT in this count \u2014 if it is installed work, re-run with commit_seed: true or place_count it.`);
8982
9083
  if (labelNote2) parts.push(labelNote2);
8983
9084
  return parts.length ? { note: parts.join(" ") } : {};
@@ -9032,7 +9133,8 @@ var Session = class _Session {
9032
9133
  }
9033
9134
  const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
9034
9135
  let committed;
9035
- if (opts.commit && found) {
9136
+ const refusal = opts.commit ? sweepCommitRefusal({ seedSegments: fp.segments, found, variantGuard: !!opts.variantGuard, negatives: negatives.length }) : null;
9137
+ if (opts.commit && !refusal && found) {
9036
9138
  const ids = [];
9037
9139
  for (const ps of perSheet) {
9038
9140
  for (const m of ps.matches) {
@@ -9052,7 +9154,7 @@ var Session = class _Session {
9052
9154
  const capped = perSheet.filter((p) => p.candidates.dropped > 0);
9053
9155
  const notes = [];
9054
9156
  if (!perSheet.length) notes.push("No plan-role sheet in the set was sweepable \u2014 nothing was counted; skipped[] says why, sheet by sheet.");
9055
- if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
9157
+ if (opts.commit && !refusal && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
9056
9158
  const rescaled = perSheet.filter((p) => p.scaled);
9057
9159
  const assumed = perSheet.filter((p) => !p.scale.known);
9058
9160
  if (rescaled.length) {
@@ -9106,6 +9208,7 @@ var Session = class _Session {
9106
9208
  complete: perSheet.every((p) => p.complete),
9107
9209
  skipped,
9108
9210
  ...committed ?? {},
9211
+ ...refusal ? { committed: 0, commit_refused: refusal } : {},
9109
9212
  ...notes.length ? { note: notes.join(" ") } : {},
9110
9213
  ...capped.length ? { warning: `Work ceiling: candidate placements were dropped un-scored on ${capped.map((p) => p.state.key).join(", ")} \u2014 counts there are FLOORS, not totals. The seed's linework is too common there for an exhaustive sweep; tighten the seed rect around more distinctive geometry, or sweep those sheets singly and reconcile the counts.` } : {}
9111
9214
  };
@@ -10545,7 +10648,7 @@ var Session = class _Session {
10545
10648
  floorTagFor(g, tag) {
10546
10649
  const res = resolveTag(g, tag);
10547
10650
  if (res.status !== "resolved") return { reason: res.reason };
10548
- const floor = res.finishes.find((f) => f.surface === "FLOOR");
10651
+ const floor = res.finishes.find((f) => f.surface === "FLOOR" || f.surface.startsWith("FLOOR "));
10549
10652
  const code = floor?.code.trim();
10550
10653
  if (!floor || !code) return { reason: `schedule row ${res.tag} states no FLOOR finish` };
10551
10654
  if (/[/,]|\bOR\b/i.test(code)) return { reason: `ambiguous: floor cell "${code}" names more than one finish with no stated split` };
@@ -10752,6 +10855,7 @@ var detectRoomsOutput = {
10752
10855
  degenerate: z.number().int().describe("Traced to fewer than 3 vertices"),
10753
10856
  duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
10754
10857
  bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
10858
+ unowned: z.number().int().describe("Labels whose every clean, non-bubble flood did not SURROUND the label's box \u2014 a ladder rung stepped past the wall into a neighbouring space or a door-swing pocket. Withheld rather than committed under the tag (#373); one_click inside the room answers it"),
10755
10859
  implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
10756
10860
  unresolved: z.number().int().describe("Assign mode: rooms the schedule could not answer for (no row, no FLOOR cell, or a compound cell) \u2014 withheld into unresolved[], never committed under a guess. Always present; 0 outside assign mode"),
10757
10861
  min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
@@ -10869,7 +10973,8 @@ var symbolSweepOutput = {
10869
10973
  complete: z.boolean().describe("True when every proposed placement was scored (every swept sheet, in set scope) and the count is a total. FALSE MEANS THE COUNT IS A FLOOR \u2014 acknowledge it before trusting found (#261)"),
10870
10974
  sheets: z.array(sweepSheetBlock).optional().describe("Set scope only: one entry per swept PLAN-role sheet, load order"),
10871
10975
  skipped: sweepSkipped.optional().describe("Set scope only: every sheet excluded from counting, with role and reason \u2014 including the seed's own sheet when it is not a plan"),
10872
- committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
10976
+ committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match (0 when commit_refused is present)"),
10977
+ commit_refused: z.string().optional().describe("commit mode (#376): present when the seed was too small and too common to commit on shape alone \u2014 fewer than 40 segments of seed linework and more than 50 placements cleared the bar. NOTHING was committed; the placements are still listed in matches (or per sheet in sheets[]) so you can look, and the text says what stands the guard down: variant_guard: true, exclude counter-examples, or a seed rect that captures more of the symbol"),
10873
10978
  shape_ids: z.array(z.string()).optional(),
10874
10979
  condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
10875
10980
  ea_total: z.number().optional().describe("commit mode: the condition's total EA after this call"),
@@ -13923,7 +14028,7 @@ function nameTheStageInRefusals(server) {
13923
14028
  // package.json
13924
14029
  var package_default = {
13925
14030
  name: "opentakeoff-mcp",
13926
- version: "0.9.68",
14031
+ version: "0.9.71",
13927
14032
  mcpName: "io.github.Kentucky-ai/opentakeoff",
13928
14033
  type: "module",
13929
14034
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.68",
3
+ "version": "0.9.71",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
- "description": "OpenTakeoff MCP server drive the takeoff engine from your MCP client over stdio.",
6
+ "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
7
7
  "license": "Apache-2.0",
8
8
  "engines": {
9
9
  "node": ">=20"