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.
- package/dist/server-core.js +391 -24
- package/package.json +1 -1
package/dist/server-core.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2015
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
5418
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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