opentakeoff-mcp 0.9.26 → 0.9.27

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 +172 -20
  2. package/package.json +1 -1
@@ -3019,6 +3019,9 @@ var SWEEP_MAX_CANDIDATES = 2e4;
3019
3019
  var ANCHOR_COUNT = 3;
3020
3020
  var MIN_SEG_LEN = 0.5;
3021
3021
  var MAX_SEED_SEGS = 2e3;
3022
+ var SWEEP_MIN_SCALE = 1 / 64;
3023
+ var SWEEP_MAX_SCALE = 64;
3024
+ var MIN_FOOTPRINT_TOLS = 6;
3022
3025
  function transformsFor(rotations, mirror) {
3023
3026
  const rots = [
3024
3027
  [0, [1, 0, 0, 1]],
@@ -3068,6 +3071,38 @@ var EndpointGrid = class {
3068
3071
  }
3069
3072
  };
3070
3073
  var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
3074
+ function scaleFingerprint(fp, k) {
3075
+ if (!Number.isFinite(k) || !(k > 0)) {
3076
+ throw new Error(`Size ratio must be a positive, finite number (seed-sheet px per target-sheet px) \u2014 got ${k}.`);
3077
+ }
3078
+ if (k === 1) return fp;
3079
+ if (k < SWEEP_MIN_SCALE || k > SWEEP_MAX_SCALE) {
3080
+ throw new Error(`Size ratio ${k.toFixed(4)} is outside the sane band (${SWEEP_MIN_SCALE} \u2013 ${SWEEP_MAX_SCALE}) \u2014 that is a larger disagreement than any real sheet pair, so the likelier cause is a wrong scale on one of the two sheets. Check set_scale on both before sweeping across them.`);
3081
+ }
3082
+ const rel = [];
3083
+ let totalLen = 0;
3084
+ let subPixelDropped = 0;
3085
+ for (const r of fp.rel) {
3086
+ const len = r[4] * k;
3087
+ if (len < MIN_SEG_LEN) {
3088
+ subPixelDropped++;
3089
+ continue;
3090
+ }
3091
+ rel.push([r[0] * k, r[1] * k, r[2] * k, r[3] * k, len]);
3092
+ totalLen += len;
3093
+ }
3094
+ if (!rel.length) {
3095
+ throw new Error(`At a ${k.toFixed(4)} size ratio every segment of this symbol falls below ${MIN_SEG_LEN} px on the target sheet \u2014 there is no linework left to match. Marquee an instance drawn on the target sheet itself.`);
3096
+ }
3097
+ return {
3098
+ rel,
3099
+ totalLen,
3100
+ segments: rel.length,
3101
+ center: fp.center,
3102
+ footprint: fp.footprint * k,
3103
+ ...subPixelDropped ? { subPixelDropped } : {}
3104
+ };
3105
+ }
3071
3106
  function fingerprintSymbol(segs, seedRect) {
3072
3107
  const n = segs.length >> 2;
3073
3108
  const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
@@ -3116,13 +3151,21 @@ function fingerprintSymbol(segs, seedRect) {
3116
3151
  };
3117
3152
  }
3118
3153
  function matchSymbol(fp, segs, opts = {}) {
3119
- const tol = opts.tolPx ?? SWEEP_TOL_PX;
3154
+ const scale = opts.scale ?? 1;
3155
+ const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
3120
3156
  const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
3121
3157
  const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
3122
3158
  const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
3123
3159
  const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
3124
3160
  const n = segs.length >> 2;
3125
- const { rel, totalLen } = fp;
3161
+ if (scale !== 1 && opts.excludeCenter) {
3162
+ throw new Error("excludeCenter is a point on the SEED sheet and means nothing on a target sheet at a different scale \u2014 omit it when sweeping across sheets (there is no seed there to shadow).");
3163
+ }
3164
+ const fpS = scale === 1 ? fp : scaleFingerprint(fp, scale);
3165
+ if (scale !== 1 && fpS.footprint < MIN_FOOTPRINT_TOLS * tol) {
3166
+ throw new Error(`At a ${scale.toFixed(4)} size ratio this symbol is ${fpS.footprint.toFixed(1)} px across on the target sheet \u2014 inside the ${tol.toFixed(1)} px matching tolerance, where every placement scores alike and a "match" means nothing. The seed is drawn too large relative to the target for its linework to survive the trip: marquee an instance on the target sheet itself, or count the tag text with sweep_schedule_row.`);
3167
+ }
3168
+ const { rel, totalLen } = fpS;
3126
3169
  const lenBucket = /* @__PURE__ */ new Map();
3127
3170
  for (let i = 0; i < n; i++) {
3128
3171
  const b = Math.round(segLen(segs, i));
@@ -3219,7 +3262,7 @@ function matchSymbol(fp, segs, opts = {}) {
3219
3262
  twin.xf = s.xf;
3220
3263
  }
3221
3264
  }
3222
- const suppressR = Math.max(mergeR, fp.footprint / 2);
3265
+ const suppressR = Math.max(mergeR, fpS.footprint / 2);
3223
3266
  const ex = opts.excludeCenter;
3224
3267
  const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
3225
3268
  const matches = [];
@@ -3240,7 +3283,20 @@ function matchSymbol(fp, segs, opts = {}) {
3240
3283
  const order = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0] || a.rotation - b.rotation || Number(a.mirrored) - Number(b.mirrored);
3241
3284
  matches.sort(order);
3242
3285
  withheld.sort(order);
3243
- return { matches, withheld, candidates: { considered, dropped } };
3286
+ return {
3287
+ matches,
3288
+ withheld,
3289
+ candidates: { considered, dropped },
3290
+ ...scale === 1 ? {} : {
3291
+ scaled: {
3292
+ ratio: Math.round(scale * 1e6) / 1e6,
3293
+ segments: fpS.segments,
3294
+ sub_pixel_dropped: fpS.subPixelDropped ?? 0,
3295
+ footprint_px: Math.round(fpS.footprint * 10) / 10,
3296
+ tol_px: Math.round(tol * 100) / 100
3297
+ }
3298
+ }
3299
+ };
3244
3300
  }
3245
3301
 
3246
3302
  // ../web/src/lib/provenance.js
@@ -5150,6 +5206,37 @@ var Session = class _Session {
5150
5206
  const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((n, x) => n + (x.computed.count || 1), 0);
5151
5207
  return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
5152
5208
  }
5209
+ /** The seed→target size ratio for a cross-sheet sweep (#186): seed-sheet
5210
+ * image px per target-sheet image px, which is exactly `upp_seed /
5211
+ * upp_target` — both sheets' own committed scales, no search and no guess.
5212
+ *
5213
+ * `known: false` means at least one of the two sheets has no scale set. The
5214
+ * sweep can still run at 1.0 (same-size drafting is the norm across the plan
5215
+ * sheets of one set) but the caller MUST disclose the assumption, because an
5216
+ * unknown ratio and a zero count together are indistinguishable from "the
5217
+ * symbol isn't there" — the exact silent wrong answer #186 exists to kill. */
5218
+ sweepRatio(seed, target) {
5219
+ if (seed.key === target.key) return { scale: 1, known: true };
5220
+ if (seed.upp && target.upp) return { scale: seed.upp / target.upp, known: true };
5221
+ return { scale: 1, known: false };
5222
+ }
5223
+ /** The refusal that has to fire before a detail-seeded sweep runs blind. A
5224
+ * detail, legend, or schedule sheet is drawn at ITS own enlarged scale — a
5225
+ * 1-1/2" = 1'-0" detail against a 1/8" plan is 12× — so sweeping it against
5226
+ * the plans without the ratio searches for a symbol twelve times too large
5227
+ * and reports a confident zero. Plan-to-plan is different and stays
5228
+ * permissive: one set's plan sheets are drawn at one scale nearly always,
5229
+ * and requiring set_scale there would break sweeps that work today. */
5230
+ requireCrossScale(seed, seedRole, targets) {
5231
+ if (seedRole === "plan") return;
5232
+ const seen = /* @__PURE__ */ new Set();
5233
+ const missing = [seed, ...targets].filter((sh) => !sh.upp && !seen.has(sh.key) && (seen.add(sh.key), true));
5234
+ if (!missing.length) return;
5235
+ const names = missing.map((sh) => sh.key);
5236
+ throw new UserError(
5237
+ `The seed sits on a ${seedRole} sheet (${seed.key}), which is drawn at its own enlarged scale \u2014 matching it against the plans needs BOTH scales stated, and ${names.length === 1 ? `${names[0]} has none` : `these have none: ${names.join(", ")}`}. Sweeping without the ratio would search the plans for a symbol several times too large and report a confident zero, so it refuses instead. Run set_scale on ${names.join(", ")} first${missing.some((sh) => sh.detected) ? ` (detected: ${missing.filter((sh) => sh.detected).map((sh) => `${sh.key} \u2192 ${sh.detected.label}`).join(", ")})` : ""}, or marquee an instance drawn on a plan sheet itself and sweep with scope 'sheet'.`
5238
+ );
5239
+ }
5153
5240
  /** symbol_sweep — every placement of ONE example symbol, from the linework.
5154
5241
  * The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
5155
5242
  * rect's segments, propose placements by constellation anchoring under the
@@ -5247,6 +5334,7 @@ var Session = class _Session {
5247
5334
  const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
5248
5335
  const seedRole = roleOf.get(s.key) ?? "unknown";
5249
5336
  const seedSource = seedRole === "plan" ? "instance" : "detail_sheet";
5337
+ this.requireCrossScale(s, seedRole, this.sheetList().filter((sh) => (roleOf.get(sh.key) ?? "unknown") === "plan"));
5250
5338
  const perSheet = [];
5251
5339
  const skipped = [];
5252
5340
  for (const sh of this.sheetList()) {
@@ -5264,10 +5352,21 @@ var Session = class _Session {
5264
5352
  skipped.push({ sheet: sh.key, role, reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
5265
5353
  continue;
5266
5354
  }
5355
+ const ratio = this.sweepRatio(s, sh);
5267
5356
  const t0 = process.hrtime.bigint();
5268
- const res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...sh.key === s.key ? { excludeCenter: fp.center } : {} });
5357
+ let res;
5358
+ try {
5359
+ res = matchSymbol(fp, g2.segs, {
5360
+ ...sweepOpts,
5361
+ ...ratio.scale === 1 ? {} : { scale: ratio.scale },
5362
+ ...sh.key === s.key ? { excludeCenter: fp.center } : {}
5363
+ });
5364
+ } catch (e) {
5365
+ skipped.push({ sheet: sh.key, role, reason: e instanceof Error ? e.message : String(e) });
5366
+ continue;
5367
+ }
5269
5368
  const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
5270
- perSheet.push({ state: sh, ...res, elapsed_ms });
5369
+ perSheet.push({ state: sh, ...res, elapsed_ms, scale: ratio });
5271
5370
  }
5272
5371
  const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
5273
5372
  let committed;
@@ -5292,6 +5391,21 @@ var Session = class _Session {
5292
5391
  const notes = [];
5293
5392
  if (!perSheet.length) notes.push("No plan-role sheet in the set was sweepable \u2014 nothing was counted; skipped[] says why, sheet by sheet.");
5294
5393
  if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
5394
+ const rescaled = perSheet.filter((p) => p.scaled);
5395
+ const assumed = perSheet.filter((p) => !p.scale.known);
5396
+ if (rescaled.length) {
5397
+ notes.push(`Size ratio applied from the sheets' own scales: ${rescaled.map((p) => `${p.state.key} \xD7${p.scaled.ratio}`).join(", ")} \u2014 the seed was resized to each target sheet before matching, never scale-searched.`);
5398
+ const thinned = rescaled.filter((p) => p.scaled.sub_pixel_dropped > 0);
5399
+ if (thinned.length) {
5400
+ notes.push(`Scaling down cost detail: ${thinned.map((p) => `${p.state.key} dropped ${p.scaled.sub_pixel_dropped} sub-pixel segment(s)`).join(", ")} \u2014 scores there are a fraction of the linework that survived the trip, not of the whole seed.`);
5401
+ }
5402
+ }
5403
+ if (assumed.length) {
5404
+ const empty = assumed.filter((p) => !p.matches.length).map((p) => p.state.key);
5405
+ notes.push(
5406
+ `Swept at 1:1 on ${assumed.map((p) => p.state.key).join(", ")} \u2014 no scale is set on the seed sheet or on those, so the true size ratio is unknown and same-size drafting was assumed.` + (empty.length ? ` ${empty.join(", ")} found nothing, and an unstated ratio is a live explanation for that: if any of those sheets is drawn at a different scale than ${s.key}, the search was for a wrong-sized symbol. set_scale on both ends turns this from an assumption into arithmetic.` : "")
5407
+ );
5408
+ }
5295
5409
  return {
5296
5410
  scope,
5297
5411
  found,
@@ -5302,7 +5416,9 @@ var Session = class _Session {
5302
5416
  matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
5303
5417
  withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
5304
5418
  candidates: p.candidates,
5305
- elapsed_ms: p.elapsed_ms
5419
+ elapsed_ms: p.elapsed_ms,
5420
+ ...p.scaled ? { scaled: p.scaled } : {},
5421
+ ...p.scale.known ? {} : { scale_assumed: "no scale set on the seed sheet or this one \u2014 swept at 1:1" }
5306
5422
  })),
5307
5423
  skipped,
5308
5424
  ...committed ?? {},
@@ -5389,8 +5505,8 @@ var Session = class _Session {
5389
5505
  tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
5390
5506
  };
5391
5507
  let corro = null;
5392
- if (withOcc[0].occ.length > 1) corro = { segs: anchorGeo.segs, occ: withOcc[0].occ.slice(1) };
5393
- else if (withOcc.length > 1) corro = { segs: (await this.ensureGeometry(withOcc[1].sh)).segs, occ: withOcc[1].occ };
5508
+ if (withOcc[0].occ.length > 1) corro = { sh: anchorSheet, segs: anchorGeo.segs, occ: withOcc[0].occ.slice(1) };
5509
+ else if (withOcc.length > 1) corro = { sh: withOcc[1].sh, segs: (await this.ensureGeometry(withOcc[1].sh)).segs, occ: withOcc[1].occ };
5394
5510
  const cX = (v) => Math.max(0, Math.min(v, anchorSheet.widthPx));
5395
5511
  const cY = (v) => Math.max(0, Math.min(v, anchorSheet.heightPx));
5396
5512
  let fp = null;
@@ -5414,8 +5530,14 @@ var Session = class _Session {
5414
5530
  anchorRect = rect;
5415
5531
  break;
5416
5532
  }
5417
- const probe = matchSymbol(cand, corro.segs, sweepOpts);
5418
- const pr = cand.footprint / 2 + anchor.h;
5533
+ const cr = this.sweepRatio(anchorSheet, corro.sh);
5534
+ let probe;
5535
+ try {
5536
+ probe = matchSymbol(cand, corro.segs, { ...sweepOpts, ...cr.scale === 1 ? {} : { scale: cr.scale } });
5537
+ } catch {
5538
+ continue;
5539
+ }
5540
+ const pr = (probe.scaled ? probe.scaled.footprint_px : cand.footprint) / 2 + anchor.h;
5419
5541
  if (corro.occ.some((o) => probe.matches.some((m) => Math.hypot(m.at[0] - o.cx, m.at[1] - o.cy) <= pr))) {
5420
5542
  fp = cand;
5421
5543
  anchorRect = rect;
@@ -5426,7 +5548,7 @@ var Session = class _Session {
5426
5548
  if (!fp || !anchorRect) {
5427
5549
  throw new UserError(corro ? `Schedule row "${t}" cannot be anchored: the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry to fingerprint. Marquee one instance with symbol_sweep instead.` : `Schedule row "${t}" cannot be anchored: no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}. Marquee one instance with symbol_sweep instead.`);
5428
5550
  }
5429
- const R = fp.footprint / 2 + anchor.h;
5551
+ const radiusFor = (sc) => (sc ? sc.footprint_px : fp.footprint) / 2 + anchor.h;
5430
5552
  const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
5431
5553
  const perSheet = [];
5432
5554
  for (const { sh, occ } of occBySheet) {
@@ -5435,8 +5557,16 @@ var Session = class _Session {
5435
5557
  skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
5436
5558
  continue;
5437
5559
  }
5560
+ const ratio = this.sweepRatio(anchorSheet, sh);
5438
5561
  const t0 = process.hrtime.bigint();
5439
- const res = matchSymbol(fp, g2.segs, sweepOpts);
5562
+ let res;
5563
+ try {
5564
+ res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...ratio.scale === 1 ? {} : { scale: ratio.scale } });
5565
+ } catch (e) {
5566
+ skipped.push({ sheet: sh.key, role: "plan", reason: e instanceof Error ? e.message : String(e) });
5567
+ continue;
5568
+ }
5569
+ const R = radiusFor(res.scaled);
5440
5570
  const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
5441
5571
  const sibSpans = [];
5442
5572
  for (const k of siblings) for (const o of occOf(sh, k)) sibSpans.push({ key: k, cx: o.cx, cy: o.cy });
@@ -5472,7 +5602,7 @@ var Session = class _Session {
5472
5602
  excluded.sort(byPos);
5473
5603
  withheld.sort(byPos);
5474
5604
  const text_only = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
5475
- perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms });
5605
+ perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
5476
5606
  }
5477
5607
  const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
5478
5608
  let committed;
@@ -5506,6 +5636,14 @@ var Session = class _Session {
5506
5636
  const notes = [];
5507
5637
  if (!corroborated) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
5508
5638
  if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
5639
+ const rowRescaled = perSheet.filter((p) => p.scaled);
5640
+ if (rowRescaled.length) {
5641
+ notes.push(`Size ratio applied from the sheets' own scales: ${rowRescaled.map((p) => `${p.state.key} \xD7${p.scaled.ratio}`).join(", ")} \u2014 the marker was resized from ${anchorSheet.key} before matching.`);
5642
+ }
5643
+ const rowAssumed = perSheet.filter((p) => !p.scale.known && !p.matches.length);
5644
+ if (rowAssumed.length) {
5645
+ notes.push(`${rowAssumed.map((p) => p.state.key).join(", ")} found nothing and were swept at 1:1 \u2014 no scale is set on ${anchorSheet.key} or on them, so a different drawn scale there is a live explanation for the zero. set_scale on both ends to rule it out.`);
5646
+ }
5509
5647
  return {
5510
5648
  tag: t,
5511
5649
  row: {
@@ -5533,7 +5671,9 @@ var Session = class _Session {
5533
5671
  excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
5534
5672
  text_only: p.text_only,
5535
5673
  candidates: p.candidates,
5536
- elapsed_ms: p.elapsed_ms
5674
+ elapsed_ms: p.elapsed_ms,
5675
+ ...p.scaled ? { scaled: p.scaled } : {},
5676
+ ...p.scale.known ? {} : { scale_assumed: `no scale set on ${anchorSheet.key} or this sheet \u2014 swept at 1:1` }
5537
5677
  })),
5538
5678
  skipped,
5539
5679
  ...committed ?? {},
@@ -6457,13 +6597,23 @@ var sweepCandidates = z.object({
6457
6597
  considered: z.number().int(),
6458
6598
  dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
6459
6599
  });
6600
+ var sweepScaled = z.object({
6601
+ ratio: z.number().describe("Seed-sheet px per target-sheet px, computed from the two sheets' own committed scales (upp_seed / upp_target) \u2014 stated, never scale-searched"),
6602
+ segments: z.number().int().describe("Fingerprint segments that survived the resize and were actually searched for"),
6603
+ sub_pixel_dropped: z.number().int().describe("Seed segments that fell below matchable length when scaled down \u2014 excluded from the score rather than depressing it, so a score here is a fraction of what survived, not of the whole seed"),
6604
+ footprint_px: z.number().describe("The symbol's size on THIS sheet after the resize"),
6605
+ tol_px: z.number().describe("The endpoint tolerance actually applied \u2014 it rides the ratio up when the seed is magnified (its drawn jitter magnifies too) and never down")
6606
+ }).describe("#186: present only when the seed was resized for this sheet");
6607
+ var sweepScaleAssumed = z.string().describe("#186: present when the true ratio is UNKNOWN (a scale is missing on the seed sheet or this one) and the sweep ran at 1:1 \u2014 an unstated ratio plus a zero count is not evidence of absence");
6460
6608
  var sweepSheetBlock = z.object({
6461
6609
  sheet: z.string(),
6462
6610
  found: z.number().int(),
6463
6611
  matches: z.array(z.object(sweepPlacement)),
6464
6612
  withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })),
6465
6613
  candidates: sweepCandidates.describe("The work cap applies PER SHEET; dropped > 0 here names exactly where the count is incomplete"),
6466
- elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
6614
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
6615
+ scaled: sweepScaled.optional(),
6616
+ scale_assumed: sweepScaleAssumed.optional()
6467
6617
  });
6468
6618
  var sweepSkipped = z.array(z.object({
6469
6619
  sheet: z.string(),
@@ -6827,7 +6977,9 @@ var sweepScheduleRowOutput = {
6827
6977
  excluded: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag: z.string() })).describe("Markers matching the geometry but labeled with a SIBLING row's tag \u2014 the bubble shape is shared across marks, so these belong to that row, not this one"),
6828
6978
  text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with NO matching marker geometry nearby \u2014 a note reference or a variant marker; a question, never a count"),
6829
6979
  candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
6830
- elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
6980
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
6981
+ scaled: sweepScaled.optional(),
6982
+ scale_assumed: sweepScaleAssumed.optional()
6831
6983
  })).describe("One entry per swept PLAN-role sheet, load order"),
6832
6984
  skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })).describe("Sheets excluded from counting (schedule/detail/legend/unknown), each with its reason"),
6833
6985
  committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per counted match, the whole sweep ONE undo step"),
@@ -8420,7 +8572,7 @@ function registerTools(server, session) {
8420
8572
  outputSchema: placeCountOutput
8421
8573
  }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
8422
8574
  server.registerTool("symbol_sweep", {
8423
- description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Work is capped and the cap is disclosed: a reply with candidates.dropped > 0 says exactly that some placements were never scored \u2014 tighten the seed rect around more distinctive geometry rather than trusting a truncated count. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. scope "set" sweeps the WHOLE working set, counting on PLAN-role sheets only (the sheet graph decides): a symbol drawn in a detail, legend, or schedule is a reference drawing and never counts itself \u2014 which is also how you seed from one: marquee the assembly on the detail sheet and its plan-sheet occurrences are counted while the detail stays excluded (the exclusion disclosed in \`skipped\`, per-sheet results with per-sheet caps and wall-clock in \`sheets\`; the fingerprint is size-true, so a detail drawn at an enlarged scale will not match plan-size instances). commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep (set-wide included) is ONE undo step, each marker carries origin.method "symbol_sweep" with its score, transform, and seed source, and withheld placements are NEVER committed. No scale needed: EA is scale-free. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
8575
+ description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Work is capped and the cap is disclosed: a reply with candidates.dropped > 0 says exactly that some placements were never scored \u2014 tighten the seed rect around more distinctive geometry rather than trusting a truncated count. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. scope "set" sweeps the WHOLE working set, counting on PLAN-role sheets only (the sheet graph decides): a symbol drawn in a detail, legend, or schedule is a reference drawing and never counts itself \u2014 which is also how you seed from one: marquee the assembly on the detail sheet and its plan-sheet occurrences are counted while the detail stays excluded (the exclusion disclosed in \`skipped\`, per-sheet results with per-sheet caps and wall-clock in \`sheets\`). Scale across sheets: the fingerprint is size-true and is never scale-SEARCHED, so a detail drawn at 1-1/2" = 1'-0" is 12\xD7 the size of the same mark on a 1/8" plan \u2014 when BOTH sheets have a scale set, the exact ratio is computed from them and the seed is resized before matching (reported per sheet as \`scaled\`); when a scale is missing, the sweep runs at 1:1 and SAYS so (\`scale_assumed\`), because an unknown ratio plus a zero count is not evidence of absence. Seeding from a detail/legend/schedule sheet REFUSES outright until both scales are set \u2014 that is the case where an unstated ratio silently finds nothing. commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep (set-wide included) is ONE undo step, each marker carries origin.method "symbol_sweep" with its score, transform, and seed source, and withheld placements are NEVER committed. The COUNT is scale-free (EA), but matching across sheets of different scales is not \u2014 set_scale on the sheets involved is what turns the ratio from an assumption into arithmetic. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
8424
8576
  inputSchema: {
8425
8577
  sheet: z2.string().describe("The sheet the seed rect sits on \u2014 in scope 'set' it may be ANY sheet (a detail/legend seed sheet is fingerprint source only, never counted)"),
8426
8578
  seed_rect: z2.tuple([pointSchema, pointSchema]).describe("Marquee around ONE example instance, [[x0,y0],[x1,y1]] in image px \u2014 tight: segments fully inside define the symbol"),
@@ -8442,7 +8594,7 @@ function registerTools(server, session) {
8442
8594
  tolerancePx: a.tolerance_px
8443
8595
  })));
8444
8596
  server.registerTool("sweep_schedule_row", {
8445
- description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. No scale needed: EA is scale-free. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}`,
8597
+ description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}`,
8446
8598
  inputSchema: {
8447
8599
  tag: z2.string().min(1).describe("The schedule row's key exactly as drawn, e.g. 'T1', 'TR-2' \u2014 it becomes the condition tag on commit"),
8448
8600
  commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
@@ -8793,7 +8945,7 @@ function registerResources(server, session) {
8793
8945
  // package.json
8794
8946
  var package_default = {
8795
8947
  name: "opentakeoff-mcp",
8796
- version: "0.9.26",
8948
+ version: "0.9.27",
8797
8949
  mcpName: "io.github.Kentucky-ai/opentakeoff",
8798
8950
  type: "module",
8799
8951
  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.26",
3
+ "version": "0.9.27",
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.",