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