opentakeoff-mcp 0.9.26 → 0.9.29

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 +391 -24
  2. package/package.json +1 -1
@@ -1292,6 +1292,76 @@ function sweepHatchRuns(segs, meta, ws) {
1292
1292
  }
1293
1293
  return { clipSoft, runs };
1294
1294
  }
1295
+ var ANNOT_OFFSET_MAX_FT = 2;
1296
+ var ANNOT_OFFSET_MIN_FT = 0.25;
1297
+ var ANNOT_MIN_LEN_FT = 4;
1298
+ var ANNOT_OVERLAP_FRAC = 0.6;
1299
+ var ANNOT_MIN_PEN_STEP = 1;
1300
+ var ANNOT_MAX_SCAN = 64;
1301
+ function classifyOffsetAnnotationSegs(segs, meta, ws, maxOffsetPx, minOffsetPx, minLenPx) {
1302
+ const n = segs.length >> 2;
1303
+ const soft = new Uint8Array(n);
1304
+ if (!meta || !n || !(maxOffsetPx > 0) || !(minLenPx > 0)) return soft;
1305
+ const cand = [];
1306
+ for (let i = 0; i < n; i++) {
1307
+ const mt = meta[i];
1308
+ if (mt & (SEG_CURVE | SEG_CLIP | SEG_FILLONLY)) continue;
1309
+ const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
1310
+ const dx = x2 - x1, dy = y2 - y1;
1311
+ if (Math.hypot(dx, dy) < minLenPx) continue;
1312
+ let ang = Math.atan2(dy, dx) * 180 / Math.PI;
1313
+ if (ang < 0) ang += 180;
1314
+ if (ang >= 180) ang -= 180;
1315
+ cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
1316
+ }
1317
+ if (cand.length < 2) return soft;
1318
+ const BIN = HATCH_ANGLE_TOL, NBINS = Math.max(1, Math.round(180 / BIN));
1319
+ for (const shift of [0, BIN / 2]) {
1320
+ const bins = /* @__PURE__ */ new Map();
1321
+ for (const c of cand) {
1322
+ let b = Math.floor((c.ang + shift) / BIN);
1323
+ if (b >= NBINS) b -= NBINS;
1324
+ const th = c.ang * Math.PI / 180;
1325
+ const dxu = Math.cos(th), dyu = Math.sin(th);
1326
+ const nxu = -dyu, nyu = dxu;
1327
+ const r = {
1328
+ i: c.i,
1329
+ ang: c.ang,
1330
+ d: (c.x1 + c.x2) / 2 * nxu + (c.y1 + c.y2) / 2 * nyu,
1331
+ t0: Math.min(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
1332
+ t1: Math.max(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
1333
+ w: c.w
1334
+ };
1335
+ const arr2 = bins.get(b);
1336
+ if (arr2) arr2.push(r);
1337
+ else bins.set(b, [r]);
1338
+ }
1339
+ for (const runs of bins.values()) {
1340
+ if (runs.length < 2) continue;
1341
+ runs.sort((a, b) => a.d - b.d);
1342
+ for (let k = 0; k < runs.length; k++) {
1343
+ const c = runs[k];
1344
+ if (soft[c.i]) continue;
1345
+ const need = ANNOT_OVERLAP_FRAC * (c.t1 - c.t0);
1346
+ const alongside = (dir) => {
1347
+ for (let step = 1, j = k + dir; step <= ANNOT_MAX_SCAN && j >= 0 && j < runs.length; step++, j += dir) {
1348
+ const o = runs[j];
1349
+ const gap = Math.abs(o.d - c.d);
1350
+ if (gap > maxOffsetPx) break;
1351
+ if (gap < minOffsetPx) continue;
1352
+ if (Math.min(o.t1, c.t1) - Math.max(o.t0, c.t0) < need) continue;
1353
+ return o;
1354
+ }
1355
+ return null;
1356
+ };
1357
+ const up = alongside(1), dn = alongside(-1);
1358
+ const heavier = (o, other) => !!o && !other && o.w >= c.w + ANNOT_MIN_PEN_STEP;
1359
+ if (heavier(up, dn) || heavier(dn, up)) soft[c.i] = 1;
1360
+ }
1361
+ }
1362
+ }
1363
+ return soft;
1364
+ }
1295
1365
  var HATCH_ID_ANGLE_Q = 0.5;
1296
1366
  var HATCH_ID_PITCH_Q = 0.1;
1297
1367
  function hatchFamilies(segs, meta) {
@@ -1335,6 +1405,10 @@ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt
1335
1405
  const mask = new Uint8Array(mw * mh);
1336
1406
  const mppf = Number.isFinite(pxPerFt) && pxPerFt > 0 ? pxPerFt * ws : 0;
1337
1407
  const soft = meta ? classifyHatchSegs(segs, meta, ws, mppf > 0 ? HATCH_MAX_PITCH_FT * mppf : HATCH_MAX_PITCH) : null;
1408
+ const annot = meta && mppf > 0 ? classifyOffsetAnnotationSegs(segs, meta, ws, ANNOT_OFFSET_MAX_FT * mppf, ANNOT_OFFSET_MIN_FT * mppf, ANNOT_MIN_LEN_FT * mppf) : null;
1409
+ if (soft && annot) {
1410
+ for (let i = 0; i < soft.length; i++) if (annot[i]) soft[i] = 1;
1411
+ }
1338
1412
  const noDoor = meta ? flagNonDoorArcs(segs, meta) : null;
1339
1413
  let softCount = 0;
1340
1414
  for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
@@ -1779,6 +1853,123 @@ function boundaryCurveClusters(mo, region) {
1779
1853
  }
1780
1854
  return clusters;
1781
1855
  }
1856
+ var SPLIT_GAP = 1;
1857
+ var SPLIT_MIN_SEED = 8;
1858
+ var SAME_ARC_CENTRE_FT = 0.5;
1859
+ var SAME_ARC_RADIUS_FRAC = 0.15;
1860
+ function splitMergedArcs(cl, mw, mask, mppf) {
1861
+ if (cl.length < 2 * SPLIT_MIN_SEED) return [cl];
1862
+ if (arcClusterFit(cl, mw, mask).good) return [cl];
1863
+ const set = new Set(cl);
1864
+ const seen = /* @__PURE__ */ new Set();
1865
+ const parts = [];
1866
+ for (const start of cl) {
1867
+ if (seen.has(start)) continue;
1868
+ seen.add(start);
1869
+ const comp = [start];
1870
+ const stack = [start];
1871
+ while (stack.length) {
1872
+ const i = stack.pop();
1873
+ const y = i / mw | 0, x = i - y * mw;
1874
+ for (let dy = -SPLIT_GAP; dy <= SPLIT_GAP; dy++) {
1875
+ for (let dx = -SPLIT_GAP; dx <= SPLIT_GAP; dx++) {
1876
+ if (!dx && !dy) continue;
1877
+ const j = (y + dy) * mw + (x + dx);
1878
+ if (set.has(j) && !seen.has(j)) {
1879
+ seen.add(j);
1880
+ comp.push(j);
1881
+ stack.push(j);
1882
+ }
1883
+ }
1884
+ }
1885
+ }
1886
+ parts.push(comp);
1887
+ }
1888
+ if (parts.length < 2) return [cl];
1889
+ const centreTol = mppf > 0 ? Math.max(2, SAME_ARC_CENTRE_FT * mppf) : 4;
1890
+ const groups = [];
1891
+ const orphans = [];
1892
+ for (const p of parts.slice().sort((a, b) => b.length - a.length)) {
1893
+ const f = p.length >= SPLIT_MIN_SEED ? arcClusterFit(p, mw, mask) : null;
1894
+ const claim = f && f.good ? groups.find((gp) => Math.hypot(gp.cx - f.cx, gp.cy - f.cy) <= centreTol && Math.abs(gp.r - f.r) <= SAME_ARC_RADIUS_FRAC * Math.max(gp.r, f.r)) : void 0;
1895
+ if (claim) {
1896
+ claim.cells.push(...p);
1897
+ continue;
1898
+ }
1899
+ if (f && f.good) {
1900
+ groups.push({ cx: f.cx, cy: f.cy, r: f.r, cells: p.slice() });
1901
+ continue;
1902
+ }
1903
+ orphans.push(...p);
1904
+ }
1905
+ if (groups.length < 2) return [cl];
1906
+ const out = groups.map((gp) => gp.cells);
1907
+ if (orphans.length) out.push(orphans);
1908
+ return out;
1909
+ }
1910
+ var LEAF_MIN_SECTOR_FRAC = 0.15;
1911
+ var LEAF_MIN_COVER = 0.55;
1912
+ var LEAF_HALF_FT = 0.3;
1913
+ var LEAF_HALF_FLOOR_PX = 1.5;
1914
+ var LEAF_T0 = 0.2;
1915
+ var LEAF_T1 = 0.95;
1916
+ function doorLeafCells(fit, cl, mw, mh, mask, mppf = 0) {
1917
+ if (!fit.good || !(fit.r > 2)) return null;
1918
+ const LEAF_HALF_W = mppf > 0 ? Math.max(LEAF_HALF_FLOOR_PX, LEAF_HALF_FT * mppf) : LEAF_HALF_FLOOR_PX;
1919
+ const angs = cl.map((i) => Math.atan2((i / mw | 0) - fit.cy, i % mw - fit.cx)).sort((a, b) => a - b);
1920
+ if (angs.length < 2) return null;
1921
+ let gapAt = -1, gapMax = angs[0] + 2 * Math.PI - angs[angs.length - 1];
1922
+ for (let k = 1; k < angs.length; k++) {
1923
+ const gp = angs[k] - angs[k - 1];
1924
+ if (gp > gapMax) {
1925
+ gapMax = gp;
1926
+ gapAt = k;
1927
+ }
1928
+ }
1929
+ const ends = gapAt < 0 ? [angs[0], angs[angs.length - 1]] : [angs[gapAt], angs[gapAt - 1]];
1930
+ let best = null, bestCover = LEAF_MIN_COVER;
1931
+ for (const th of ends) {
1932
+ const ux = Math.cos(th), uy = Math.sin(th);
1933
+ const ax = fit.cx + ux * fit.r * LEAF_T0, ay = fit.cy + uy * fit.r * LEAF_T0;
1934
+ const bx = fit.cx + ux * fit.r * LEAF_T1, by = fit.cy + uy * fit.r * LEAF_T1;
1935
+ const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - LEAF_HALF_W - 1));
1936
+ const x1 = Math.min(mw - 1, Math.ceil(Math.max(ax, bx) + LEAF_HALF_W + 1));
1937
+ const y0 = Math.max(0, Math.floor(Math.min(ay, by) - LEAF_HALF_W - 1));
1938
+ const y1 = Math.min(mh - 1, Math.ceil(Math.max(ay, by) + LEAF_HALF_W + 1));
1939
+ const dx = bx - ax, dy = by - ay, L2 = dx * dx + dy * dy;
1940
+ if (!(L2 > 0)) continue;
1941
+ const cells = [];
1942
+ for (let y = y0; y <= y1; y++) {
1943
+ for (let x = x0; x <= x1; x++) {
1944
+ let t = ((x - ax) * dx + (y - ay) * dy) / L2;
1945
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
1946
+ const ex = ax + t * dx - x, ey = ay + t * dy - y;
1947
+ if (ex * ex + ey * ey > LEAF_HALF_W * LEAF_HALF_W) continue;
1948
+ const i = y * mw + x;
1949
+ if (mask[i] & 1 && !(mask[i] & MASK_CURVE_BIT)) cells.push(i);
1950
+ }
1951
+ }
1952
+ let hit = 0, tries = 0;
1953
+ for (let t = 0; t <= 1; t += 0.02) {
1954
+ tries++;
1955
+ const px = ax + t * dx, py = ay + t * dy;
1956
+ let inked = false;
1957
+ for (let o = -LEAF_HALF_W; o <= LEAF_HALF_W && !inked; o += 0.5) {
1958
+ const x = Math.round(px - dy / Math.sqrt(L2) * o), y = Math.round(py + dx / Math.sqrt(L2) * o);
1959
+ if (x < 0 || y < 0 || x >= mw || y >= mh) continue;
1960
+ const i = y * mw + x;
1961
+ if (mask[i] & 1 && !(mask[i] & MASK_CURVE_BIT)) inked = true;
1962
+ }
1963
+ if (inked) hit++;
1964
+ }
1965
+ const cover = tries ? hit / tries : 0;
1966
+ if (cover > bestCover && cells.length) {
1967
+ bestCover = cover;
1968
+ best = cells;
1969
+ }
1970
+ }
1971
+ return best && best.length ? best : null;
1972
+ }
1782
1973
  function arcClusterFit(cl, mw, mask) {
1783
1974
  const m = cl.length;
1784
1975
  const X = (i) => i % mw, Y = (i) => i / mw | 0;
@@ -2009,10 +2200,33 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
2009
2200
  let hatchTier = r1.hatchTier;
2010
2201
  let sealedPx = r1.sealedPx, virtualFrac = r1.virtualFrac;
2011
2202
  let minPassPxOut = r1.minPassPx, minPassDelta = r1.minPassDelta;
2012
- const ranked = clusters.map((cl) => {
2203
+ const entry = (cl, pass, fit) => {
2204
+ const f = fit ?? arcClusterFit(cl, mw, mo.mask);
2205
+ const ideal = 0.5 * f.r * f.r * f.sweep;
2206
+ return {
2207
+ cl,
2208
+ fit: f,
2209
+ allow: wedgeAllowance(f, mo.mppf || 0, wedgeCapPx),
2210
+ rank: doorLikeness(f, mo.mppf || 0),
2211
+ at: cl[0],
2212
+ pass,
2213
+ minGrow: pass === 2 ? Math.max(1, Math.round(LEAF_MIN_SECTOR_FRAC * ideal)) : 0
2214
+ };
2215
+ };
2216
+ const ranked = [];
2217
+ for (const cl of clusters) {
2013
2218
  const fit = arcClusterFit(cl, mw, mo.mask);
2014
- return { cl, fit, allow: wedgeAllowance(fit, mo.mppf || 0, wedgeCapPx), rank: doorLikeness(fit, mo.mppf || 0), at: cl[0] };
2015
- }).filter((c) => c.allow >= 1).sort((a, b) => b.rank - a.rank || a.at - b.at);
2219
+ ranked.push(entry(cl, 0, fit));
2220
+ const parts = splitMergedArcs(cl, mw, mo.mask, mo.mppf || 0);
2221
+ const pieces = parts.length > 1 ? parts : [cl];
2222
+ if (parts.length > 1) for (const p of parts) ranked.push(entry(p, 1));
2223
+ for (const p of pieces) {
2224
+ const pf = p === cl ? fit : arcClusterFit(p, mw, mo.mask);
2225
+ const leaf = doorLeafCells(pf, p, mw, mh, mo.mask, mo.mppf || 0);
2226
+ if (leaf) ranked.push({ ...entry(leaf, 2, pf), cl: leaf });
2227
+ }
2228
+ }
2229
+ ranked.splice(0, ranked.length, ...ranked.filter((c) => c.allow >= 1).sort((a, b) => a.pass - b.pass || b.rank - a.rank || a.at - b.at));
2016
2230
  let m2mask = null, m2dt = null;
2017
2231
  let openedCl = null, dirty = null;
2018
2232
  const rb1 = boxOf(r1.region, mw, mh);
@@ -2022,7 +2236,7 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
2022
2236
  sealCache.set(mo.mask, base);
2023
2237
  }
2024
2238
  const baseDT = base.dt;
2025
- for (const { cl, fit, allow: clusterAllowance } of ranked.slice(0, WEDGE_MAX_DOORS)) {
2239
+ for (const { cl, fit, allow: clusterAllowance, minGrow } of ranked.slice(0, 2 * WEDGE_MAX_DOORS)) {
2026
2240
  if (!m2mask) {
2027
2241
  m2mask = mo.mask.slice();
2028
2242
  m2dt = baseDT.slice();
@@ -2056,6 +2270,7 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
2056
2270
  const r2 = sealAttempt(m2, sx, sy, sensitivity, radii, minPassPx, sc2);
2057
2271
  if (r2.status !== "ok" || r2.count <= r1.count) continue;
2058
2272
  const growth = r2.count - r1.count;
2273
+ if (growth < minGrow) continue;
2059
2274
  if (growth > clusterAllowance) continue;
2060
2275
  if (count - r1.count + growth > globalAllowance) continue;
2061
2276
  if (!region) {
@@ -3019,6 +3234,9 @@ var SWEEP_MAX_CANDIDATES = 2e4;
3019
3234
  var ANCHOR_COUNT = 3;
3020
3235
  var MIN_SEG_LEN = 0.5;
3021
3236
  var MAX_SEED_SEGS = 2e3;
3237
+ var SWEEP_MIN_SCALE = 1 / 64;
3238
+ var SWEEP_MAX_SCALE = 64;
3239
+ var MIN_FOOTPRINT_TOLS = 6;
3022
3240
  function transformsFor(rotations, mirror) {
3023
3241
  const rots = [
3024
3242
  [0, [1, 0, 0, 1]],
@@ -3068,6 +3286,38 @@ var EndpointGrid = class {
3068
3286
  }
3069
3287
  };
3070
3288
  var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
3289
+ function scaleFingerprint(fp, k) {
3290
+ if (!Number.isFinite(k) || !(k > 0)) {
3291
+ throw new Error(`Size ratio must be a positive, finite number (seed-sheet px per target-sheet px) \u2014 got ${k}.`);
3292
+ }
3293
+ if (k === 1) return fp;
3294
+ if (k < SWEEP_MIN_SCALE || k > SWEEP_MAX_SCALE) {
3295
+ 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.`);
3296
+ }
3297
+ const rel = [];
3298
+ let totalLen = 0;
3299
+ let subPixelDropped = 0;
3300
+ for (const r of fp.rel) {
3301
+ const len = r[4] * k;
3302
+ if (len < MIN_SEG_LEN) {
3303
+ subPixelDropped++;
3304
+ continue;
3305
+ }
3306
+ rel.push([r[0] * k, r[1] * k, r[2] * k, r[3] * k, len]);
3307
+ totalLen += len;
3308
+ }
3309
+ if (!rel.length) {
3310
+ 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.`);
3311
+ }
3312
+ return {
3313
+ rel,
3314
+ totalLen,
3315
+ segments: rel.length,
3316
+ center: fp.center,
3317
+ footprint: fp.footprint * k,
3318
+ ...subPixelDropped ? { subPixelDropped } : {}
3319
+ };
3320
+ }
3071
3321
  function fingerprintSymbol(segs, seedRect) {
3072
3322
  const n = segs.length >> 2;
3073
3323
  const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
@@ -3116,13 +3366,21 @@ function fingerprintSymbol(segs, seedRect) {
3116
3366
  };
3117
3367
  }
3118
3368
  function matchSymbol(fp, segs, opts = {}) {
3119
- const tol = opts.tolPx ?? SWEEP_TOL_PX;
3369
+ const scale = opts.scale ?? 1;
3370
+ const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
3120
3371
  const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
3121
3372
  const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
3122
3373
  const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
3123
3374
  const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
3124
3375
  const n = segs.length >> 2;
3125
- const { rel, totalLen } = fp;
3376
+ if (scale !== 1 && opts.excludeCenter) {
3377
+ 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).");
3378
+ }
3379
+ const fpS = scale === 1 ? fp : scaleFingerprint(fp, scale);
3380
+ if (scale !== 1 && fpS.footprint < MIN_FOOTPRINT_TOLS * tol) {
3381
+ 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.`);
3382
+ }
3383
+ const { rel, totalLen } = fpS;
3126
3384
  const lenBucket = /* @__PURE__ */ new Map();
3127
3385
  for (let i = 0; i < n; i++) {
3128
3386
  const b = Math.round(segLen(segs, i));
@@ -3219,7 +3477,7 @@ function matchSymbol(fp, segs, opts = {}) {
3219
3477
  twin.xf = s.xf;
3220
3478
  }
3221
3479
  }
3222
- const suppressR = Math.max(mergeR, fp.footprint / 2);
3480
+ const suppressR = Math.max(mergeR, fpS.footprint / 2);
3223
3481
  const ex = opts.excludeCenter;
3224
3482
  const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
3225
3483
  const matches = [];
@@ -3240,7 +3498,20 @@ function matchSymbol(fp, segs, opts = {}) {
3240
3498
  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
3499
  matches.sort(order);
3242
3500
  withheld.sort(order);
3243
- return { matches, withheld, candidates: { considered, dropped } };
3501
+ return {
3502
+ matches,
3503
+ withheld,
3504
+ candidates: { considered, dropped },
3505
+ ...scale === 1 ? {} : {
3506
+ scaled: {
3507
+ ratio: Math.round(scale * 1e6) / 1e6,
3508
+ segments: fpS.segments,
3509
+ sub_pixel_dropped: fpS.subPixelDropped ?? 0,
3510
+ footprint_px: Math.round(fpS.footprint * 10) / 10,
3511
+ tol_px: Math.round(tol * 100) / 100
3512
+ }
3513
+ }
3514
+ };
3244
3515
  }
3245
3516
 
3246
3517
  // ../web/src/lib/provenance.js
@@ -5150,6 +5421,37 @@ var Session = class _Session {
5150
5421
  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
5422
  return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
5152
5423
  }
5424
+ /** The seed→target size ratio for a cross-sheet sweep (#186): seed-sheet
5425
+ * image px per target-sheet image px, which is exactly `upp_seed /
5426
+ * upp_target` — both sheets' own committed scales, no search and no guess.
5427
+ *
5428
+ * `known: false` means at least one of the two sheets has no scale set. The
5429
+ * sweep can still run at 1.0 (same-size drafting is the norm across the plan
5430
+ * sheets of one set) but the caller MUST disclose the assumption, because an
5431
+ * unknown ratio and a zero count together are indistinguishable from "the
5432
+ * symbol isn't there" — the exact silent wrong answer #186 exists to kill. */
5433
+ sweepRatio(seed, target) {
5434
+ if (seed.key === target.key) return { scale: 1, known: true };
5435
+ if (seed.upp && target.upp) return { scale: seed.upp / target.upp, known: true };
5436
+ return { scale: 1, known: false };
5437
+ }
5438
+ /** The refusal that has to fire before a detail-seeded sweep runs blind. A
5439
+ * detail, legend, or schedule sheet is drawn at ITS own enlarged scale — a
5440
+ * 1-1/2" = 1'-0" detail against a 1/8" plan is 12× — so sweeping it against
5441
+ * the plans without the ratio searches for a symbol twelve times too large
5442
+ * and reports a confident zero. Plan-to-plan is different and stays
5443
+ * permissive: one set's plan sheets are drawn at one scale nearly always,
5444
+ * and requiring set_scale there would break sweeps that work today. */
5445
+ requireCrossScale(seed, seedRole, targets) {
5446
+ if (seedRole === "plan") return;
5447
+ const seen = /* @__PURE__ */ new Set();
5448
+ const missing = [seed, ...targets].filter((sh) => !sh.upp && !seen.has(sh.key) && (seen.add(sh.key), true));
5449
+ if (!missing.length) return;
5450
+ const names = missing.map((sh) => sh.key);
5451
+ throw new UserError(
5452
+ `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'.`
5453
+ );
5454
+ }
5153
5455
  /** symbol_sweep — every placement of ONE example symbol, from the linework.
5154
5456
  * The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
5155
5457
  * rect's segments, propose placements by constellation anchoring under the
@@ -5247,6 +5549,7 @@ var Session = class _Session {
5247
5549
  const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
5248
5550
  const seedRole = roleOf.get(s.key) ?? "unknown";
5249
5551
  const seedSource = seedRole === "plan" ? "instance" : "detail_sheet";
5552
+ this.requireCrossScale(s, seedRole, this.sheetList().filter((sh) => (roleOf.get(sh.key) ?? "unknown") === "plan"));
5250
5553
  const perSheet = [];
5251
5554
  const skipped = [];
5252
5555
  for (const sh of this.sheetList()) {
@@ -5264,10 +5567,21 @@ var Session = class _Session {
5264
5567
  skipped.push({ sheet: sh.key, role, reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
5265
5568
  continue;
5266
5569
  }
5570
+ const ratio = this.sweepRatio(s, sh);
5267
5571
  const t0 = process.hrtime.bigint();
5268
- const res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...sh.key === s.key ? { excludeCenter: fp.center } : {} });
5572
+ let res;
5573
+ try {
5574
+ res = matchSymbol(fp, g2.segs, {
5575
+ ...sweepOpts,
5576
+ ...ratio.scale === 1 ? {} : { scale: ratio.scale },
5577
+ ...sh.key === s.key ? { excludeCenter: fp.center } : {}
5578
+ });
5579
+ } catch (e) {
5580
+ skipped.push({ sheet: sh.key, role, reason: e instanceof Error ? e.message : String(e) });
5581
+ continue;
5582
+ }
5269
5583
  const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
5270
- perSheet.push({ state: sh, ...res, elapsed_ms });
5584
+ perSheet.push({ state: sh, ...res, elapsed_ms, scale: ratio });
5271
5585
  }
5272
5586
  const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
5273
5587
  let committed;
@@ -5292,6 +5606,21 @@ var Session = class _Session {
5292
5606
  const notes = [];
5293
5607
  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
5608
  if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
5609
+ const rescaled = perSheet.filter((p) => p.scaled);
5610
+ const assumed = perSheet.filter((p) => !p.scale.known);
5611
+ if (rescaled.length) {
5612
+ 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.`);
5613
+ const thinned = rescaled.filter((p) => p.scaled.sub_pixel_dropped > 0);
5614
+ if (thinned.length) {
5615
+ 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.`);
5616
+ }
5617
+ }
5618
+ if (assumed.length) {
5619
+ const empty = assumed.filter((p) => !p.matches.length).map((p) => p.state.key);
5620
+ notes.push(
5621
+ `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.` : "")
5622
+ );
5623
+ }
5295
5624
  return {
5296
5625
  scope,
5297
5626
  found,
@@ -5302,7 +5631,9 @@ var Session = class _Session {
5302
5631
  matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
5303
5632
  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
5633
  candidates: p.candidates,
5305
- elapsed_ms: p.elapsed_ms
5634
+ elapsed_ms: p.elapsed_ms,
5635
+ ...p.scaled ? { scaled: p.scaled } : {},
5636
+ ...p.scale.known ? {} : { scale_assumed: "no scale set on the seed sheet or this one \u2014 swept at 1:1" }
5306
5637
  })),
5307
5638
  skipped,
5308
5639
  ...committed ?? {},
@@ -5389,8 +5720,8 @@ var Session = class _Session {
5389
5720
  tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
5390
5721
  };
5391
5722
  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 };
5723
+ if (withOcc[0].occ.length > 1) corro = { sh: anchorSheet, segs: anchorGeo.segs, occ: withOcc[0].occ.slice(1) };
5724
+ else if (withOcc.length > 1) corro = { sh: withOcc[1].sh, segs: (await this.ensureGeometry(withOcc[1].sh)).segs, occ: withOcc[1].occ };
5394
5725
  const cX = (v) => Math.max(0, Math.min(v, anchorSheet.widthPx));
5395
5726
  const cY = (v) => Math.max(0, Math.min(v, anchorSheet.heightPx));
5396
5727
  let fp = null;
@@ -5414,8 +5745,14 @@ var Session = class _Session {
5414
5745
  anchorRect = rect;
5415
5746
  break;
5416
5747
  }
5417
- const probe = matchSymbol(cand, corro.segs, sweepOpts);
5418
- const pr = cand.footprint / 2 + anchor.h;
5748
+ const cr = this.sweepRatio(anchorSheet, corro.sh);
5749
+ let probe;
5750
+ try {
5751
+ probe = matchSymbol(cand, corro.segs, { ...sweepOpts, ...cr.scale === 1 ? {} : { scale: cr.scale } });
5752
+ } catch {
5753
+ continue;
5754
+ }
5755
+ const pr = (probe.scaled ? probe.scaled.footprint_px : cand.footprint) / 2 + anchor.h;
5419
5756
  if (corro.occ.some((o) => probe.matches.some((m) => Math.hypot(m.at[0] - o.cx, m.at[1] - o.cy) <= pr))) {
5420
5757
  fp = cand;
5421
5758
  anchorRect = rect;
@@ -5426,7 +5763,7 @@ var Session = class _Session {
5426
5763
  if (!fp || !anchorRect) {
5427
5764
  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
5765
  }
5429
- const R = fp.footprint / 2 + anchor.h;
5766
+ const radiusFor = (sc) => (sc ? sc.footprint_px : fp.footprint) / 2 + anchor.h;
5430
5767
  const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
5431
5768
  const perSheet = [];
5432
5769
  for (const { sh, occ } of occBySheet) {
@@ -5435,8 +5772,16 @@ var Session = class _Session {
5435
5772
  skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
5436
5773
  continue;
5437
5774
  }
5775
+ const ratio = this.sweepRatio(anchorSheet, sh);
5438
5776
  const t0 = process.hrtime.bigint();
5439
- const res = matchSymbol(fp, g2.segs, sweepOpts);
5777
+ let res;
5778
+ try {
5779
+ res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...ratio.scale === 1 ? {} : { scale: ratio.scale } });
5780
+ } catch (e) {
5781
+ skipped.push({ sheet: sh.key, role: "plan", reason: e instanceof Error ? e.message : String(e) });
5782
+ continue;
5783
+ }
5784
+ const R = radiusFor(res.scaled);
5440
5785
  const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
5441
5786
  const sibSpans = [];
5442
5787
  for (const k of siblings) for (const o of occOf(sh, k)) sibSpans.push({ key: k, cx: o.cx, cy: o.cy });
@@ -5472,7 +5817,7 @@ var Session = class _Session {
5472
5817
  excluded.sort(byPos);
5473
5818
  withheld.sort(byPos);
5474
5819
  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 });
5820
+ perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
5476
5821
  }
5477
5822
  const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
5478
5823
  let committed;
@@ -5506,6 +5851,14 @@ var Session = class _Session {
5506
5851
  const notes = [];
5507
5852
  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
5853
  if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
5854
+ const rowRescaled = perSheet.filter((p) => p.scaled);
5855
+ if (rowRescaled.length) {
5856
+ 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.`);
5857
+ }
5858
+ const rowAssumed = perSheet.filter((p) => !p.scale.known && !p.matches.length);
5859
+ if (rowAssumed.length) {
5860
+ 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.`);
5861
+ }
5509
5862
  return {
5510
5863
  tag: t,
5511
5864
  row: {
@@ -5533,7 +5886,9 @@ var Session = class _Session {
5533
5886
  excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
5534
5887
  text_only: p.text_only,
5535
5888
  candidates: p.candidates,
5536
- elapsed_ms: p.elapsed_ms
5889
+ elapsed_ms: p.elapsed_ms,
5890
+ ...p.scaled ? { scaled: p.scaled } : {},
5891
+ ...p.scale.known ? {} : { scale_assumed: `no scale set on ${anchorSheet.key} or this sheet \u2014 swept at 1:1` }
5537
5892
  })),
5538
5893
  skipped,
5539
5894
  ...committed ?? {},
@@ -6457,13 +6812,23 @@ var sweepCandidates = z.object({
6457
6812
  considered: z.number().int(),
6458
6813
  dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
6459
6814
  });
6815
+ var sweepScaled = z.object({
6816
+ 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"),
6817
+ segments: z.number().int().describe("Fingerprint segments that survived the resize and were actually searched for"),
6818
+ 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"),
6819
+ footprint_px: z.number().describe("The symbol's size on THIS sheet after the resize"),
6820
+ 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")
6821
+ }).describe("#186: present only when the seed was resized for this sheet");
6822
+ 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
6823
  var sweepSheetBlock = z.object({
6461
6824
  sheet: z.string(),
6462
6825
  found: z.number().int(),
6463
6826
  matches: z.array(z.object(sweepPlacement)),
6464
6827
  withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })),
6465
6828
  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")
6829
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
6830
+ scaled: sweepScaled.optional(),
6831
+ scale_assumed: sweepScaleAssumed.optional()
6467
6832
  });
6468
6833
  var sweepSkipped = z.array(z.object({
6469
6834
  sheet: z.string(),
@@ -6827,7 +7192,9 @@ var sweepScheduleRowOutput = {
6827
7192
  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
7193
  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
7194
  candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
6830
- elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
7195
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
7196
+ scaled: sweepScaled.optional(),
7197
+ scale_assumed: sweepScaleAssumed.optional()
6831
7198
  })).describe("One entry per swept PLAN-role sheet, load order"),
6832
7199
  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
7200
  committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per counted match, the whole sweep ONE undo step"),
@@ -8420,7 +8787,7 @@ function registerTools(server, session) {
8420
8787
  outputSchema: placeCountOutput
8421
8788
  }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
8422
8789
  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}`,
8790
+ 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
8791
  inputSchema: {
8425
8792
  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
8793
  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 +8809,7 @@ function registerTools(server, session) {
8442
8809
  tolerancePx: a.tolerance_px
8443
8810
  })));
8444
8811
  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}`,
8812
+ 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
8813
  inputSchema: {
8447
8814
  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
8815
  commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
@@ -8793,7 +9160,7 @@ function registerResources(server, session) {
8793
9160
  // package.json
8794
9161
  var package_default = {
8795
9162
  name: "opentakeoff-mcp",
8796
- version: "0.9.26",
9163
+ version: "0.9.29",
8797
9164
  mcpName: "io.github.Kentucky-ai/opentakeoff",
8798
9165
  type: "module",
8799
9166
  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.29",
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.",