opentakeoff-mcp 0.9.59 → 0.9.61
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/README.md +1 -1
- package/dist/server-core.js +208 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
145
145
|
| `derive_transitions` | **The transition where two finishes meet**: pass two finish tags and the tag to commit under, and every committed room of each is compared against every room of the other. The catch this is built around—flood-traced rooms **do not share edges**, a partition puts 4–8″ between them—so proximity comes in two flavours and they are never conflated. A **butt joint** (rings running together inside one open space, within an inch) *is* the transition and commits as a linear shape, `origin.derived` naming both parents, the tags, and the measured gap. A **wall-separated** run means the rooms are adjacent across a partition, where the transition is a threshold in a doorway that nothing in the trace record locates (the flood engine reports how *much* boundary it sealed, never where)—those return in `withheld` with length, gap in inches, and an `at` point to `view_sheet`, as questions rather than a confident wrong number. `max_gap_in` (default 12) only ever turns more of the plan into questions, never into committed LF. All-or-nothing; one undo step. |
|
|
146
146
|
| `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob—pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
|
|
147
147
|
| `place_count` | **EA markers**: one point, one each—thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
|
|
148
|
-
| `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the vector linework is searched deterministically for every other placement—translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. **`scope: "set"` sweeps the whole working set, counting on PLAN-role sheets only** (the sheet graph decides; every excluded sheet disclosed in `skipped` with role and reason)—and the seed rect may sit on a detail or legend sheet, which then serves as the fingerprint SOURCE while staying excluded from counting: the estimator's "click the assembly in the detail, count it on the plans" gesture. Per-sheet results carry their own match/withheld lists, per-sheet cap accounting, and wall-clock `elapsed_ms`. `commit: true` + `condition` commits every match center as an EA count marker—the whole sweep (set-wide included) is one undo step, `origin.method "symbol_sweep"` with per-marker score, transform, and seed source (`origin.symbol.seed`). No scale required. **Counter-examples** (`exclude`, #259): rects around instances you do NOT mean, marqueed like the seed—the rect's own contents decide whether it rejects by extra contained linework or by the background line running THROUGH it that a real instance would break; every rejection disclosed in `rejected[]`, reinstatable with `place_count`, dead negatives refused with instructions. **Stroke-luminance gate** (`luminance_tolerance`, #260): for flattened exports where layers and pen weights are stripped but the file still states stroke color—a stated tolerance holds candidates to the seed's own pen, opt-in both ways, with `lum_gate` naming every placement the pen pulled under the bar. |
|
|
148
|
+
| `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the vector linework is searched deterministically for every other placement—translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. **`scope: "set"` sweeps the whole working set, counting on PLAN-role sheets only** (the sheet graph decides; every excluded sheet disclosed in `skipped` with role and reason)—and the seed rect may sit on a detail or legend sheet, which then serves as the fingerprint SOURCE while staying excluded from counting: the estimator's "click the assembly in the detail, count it on the plans" gesture. Per-sheet results carry their own match/withheld lists, per-sheet cap accounting, and wall-clock `elapsed_ms`. `commit: true` + `condition` commits every match center as an EA count marker—the whole sweep (set-wide included) is one undo step, `origin.method "symbol_sweep"` with per-marker score, transform, and seed source (`origin.symbol.seed`). No scale required. **Counter-examples** (`exclude`, #259): rects around instances you do NOT mean, marqueed like the seed—the rect's own contents decide whether it rejects by extra contained linework or by the background line running THROUGH it that a real instance would break; every rejection disclosed in `rejected[]`, reinstatable with `place_count`, dead negatives refused with instructions. **Stroke-luminance gate** (`luminance_tolerance`, #260): for flattened exports where layers and pen weights are stripped but the file still states stroke color—a stated tolerance holds candidates to the seed's own pen, opt-in both ways, with `lum_gate` naming every placement the pen pulled under the bar. **Labels** (#308): for labeled families the sweep reads the drawing's own names—a fixture token written beside a placement or connected by its drawn leader (leader-following arms only on multi-pen sheets)—as `label`/`label_via` on every row plus `seed.label`; the reply flags shape-only matches in a labeled family, withheld rows carrying the seed's own tag, and matches the drawing names differently. Disclosure, never a recount. |
|
|
149
149
|
| `sweep_schedule_row` | **Take off a schedule row's mark from the row itself**: pass the row's key (for example, `T1`) and the tool reads the row from the set's schedule tables (the row is the condition's cited source), anchors a 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—`anchor.corroborated`—before it is trusted), and sweeps every plan-role sheet. **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 key is `excluded` and says whose it is, an unlabeled match is `withheld` as a question, a tag drawn with no matching marker is `text_only`. Refusal over guessing, each with the reason and the fix: no such row, an ambiguous key, a tag drawn on no plan sheet, no repeatable marker linework—a fingerprint is never guessed from text alone. `commit: true` commits the counted matches under the row's own key—one undo step, `origin.assignment {source: "schedule"}` plus the anchor and row citation on `origin.symbol.seed`. No scale required. |
|
|
150
150
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
151
151
|
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload—exactly what the app autosaves. Inline, and to disk with `path` (see **Writing to disk** below). |
|
package/dist/server-core.js
CHANGED
|
@@ -4154,6 +4154,30 @@ function buildNegative(fp, segs, rect, opts = {}) {
|
|
|
4154
4154
|
center: [Math.round(best.at[0] * 10) / 10, Math.round(best.at[1] * 10) / 10]
|
|
4155
4155
|
};
|
|
4156
4156
|
}
|
|
4157
|
+
function mergeProposals(scored, mergeR) {
|
|
4158
|
+
const kept = [];
|
|
4159
|
+
for (const s of scored) {
|
|
4160
|
+
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
4161
|
+
if (!twin) {
|
|
4162
|
+
kept.push({ ...s });
|
|
4163
|
+
continue;
|
|
4164
|
+
}
|
|
4165
|
+
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
4166
|
+
twin.at = s.at;
|
|
4167
|
+
twin.score = s.score;
|
|
4168
|
+
twin.rotation = s.rotation;
|
|
4169
|
+
twin.mirrored = s.mirrored;
|
|
4170
|
+
twin.xf = s.xf;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
const byBest = [...kept].sort((a, b) => b.score - a.score || a.xf - b.xf || a.at[1] - b.at[1] || a.at[0] - b.at[0]);
|
|
4174
|
+
const out = [];
|
|
4175
|
+
for (const s of byBest) {
|
|
4176
|
+
if (out.some((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR)) continue;
|
|
4177
|
+
out.push(s);
|
|
4178
|
+
}
|
|
4179
|
+
return out;
|
|
4180
|
+
}
|
|
4157
4181
|
function matchSymbol(fp, segs, opts = {}) {
|
|
4158
4182
|
const scale = opts.scale ?? 1;
|
|
4159
4183
|
const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
|
|
@@ -4264,21 +4288,7 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
4264
4288
|
scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
|
|
4265
4289
|
}
|
|
4266
4290
|
const mergeR = Math.max(2 * tol, 4);
|
|
4267
|
-
const kept =
|
|
4268
|
-
for (const s of scored) {
|
|
4269
|
-
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
4270
|
-
if (!twin) {
|
|
4271
|
-
kept.push(s);
|
|
4272
|
-
continue;
|
|
4273
|
-
}
|
|
4274
|
-
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
4275
|
-
twin.at = s.at;
|
|
4276
|
-
twin.score = s.score;
|
|
4277
|
-
twin.rotation = s.rotation;
|
|
4278
|
-
twin.mirrored = s.mirrored;
|
|
4279
|
-
twin.xf = s.xf;
|
|
4280
|
-
}
|
|
4281
|
-
}
|
|
4291
|
+
const kept = mergeProposals(scored, mergeR);
|
|
4282
4292
|
const suppressR = Math.max(mergeR, fpS.footprint / 2);
|
|
4283
4293
|
const ex = opts.excludeCenter;
|
|
4284
4294
|
const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
|
|
@@ -4419,6 +4429,116 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
4419
4429
|
};
|
|
4420
4430
|
}
|
|
4421
4431
|
|
|
4432
|
+
// ../web/src/lib/symbollabels.ts
|
|
4433
|
+
var LABEL_TOKEN_RE = /^[A-Z]{1,4}-?\d{0,3}[A-Z]?$/;
|
|
4434
|
+
function labelTokens(spans) {
|
|
4435
|
+
return spans.filter((s) => {
|
|
4436
|
+
const t = s.str.trim();
|
|
4437
|
+
return t.length <= 6 && LABEL_TOKEN_RE.test(t) && /[A-Z]/.test(t);
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
var LABEL_ADJACENT_K = 2.2;
|
|
4441
|
+
var LEADER_MAX_DARK_SHARE = 0.9;
|
|
4442
|
+
var LEADER_DARK_LUM = 60;
|
|
4443
|
+
var LEADER_HOP_PX = 14;
|
|
4444
|
+
var LEADER_HOPS = 4;
|
|
4445
|
+
var LEADER_HIT_PX = 30;
|
|
4446
|
+
function buildDarkIndex(segs, lum) {
|
|
4447
|
+
const dark = [];
|
|
4448
|
+
const n = segs.length >> 2;
|
|
4449
|
+
for (let i = 0; i < n; i++) {
|
|
4450
|
+
if (lum[i] < LEADER_DARK_LUM) dark.push([segs[i * 4], segs[i * 4 + 1], segs[i * 4 + 2], segs[i * 4 + 3]]);
|
|
4451
|
+
}
|
|
4452
|
+
const cell = LEADER_HOP_PX * 2;
|
|
4453
|
+
const key = (x, y) => Math.floor(x / cell) * 73856093 ^ Math.floor(y / cell) * 19349663;
|
|
4454
|
+
const cells = /* @__PURE__ */ new Map();
|
|
4455
|
+
dark.forEach((s, i) => {
|
|
4456
|
+
for (const [x, y] of [[s[0], s[1]], [s[2], s[3]]]) {
|
|
4457
|
+
const k = key(x, y);
|
|
4458
|
+
const a = cells.get(k);
|
|
4459
|
+
if (a) a.push(i);
|
|
4460
|
+
else cells.set(k, [i]);
|
|
4461
|
+
}
|
|
4462
|
+
});
|
|
4463
|
+
return { segs: dark, cells, cell };
|
|
4464
|
+
}
|
|
4465
|
+
function nearDark(idx, x, y) {
|
|
4466
|
+
const out = [];
|
|
4467
|
+
const cx = Math.floor(x / idx.cell), cy = Math.floor(y / idx.cell);
|
|
4468
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
|
|
4469
|
+
const a = idx.cells.get((cx + dx) * 73856093 ^ (cy + dy) * 19349663);
|
|
4470
|
+
if (a) {
|
|
4471
|
+
for (const i of a) if (!out.includes(i)) out.push(i);
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
return out;
|
|
4475
|
+
}
|
|
4476
|
+
function chase(idx, start) {
|
|
4477
|
+
let frontier = [start];
|
|
4478
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4479
|
+
const reached = [start];
|
|
4480
|
+
for (let hop = 0; hop < LEADER_HOPS; hop++) {
|
|
4481
|
+
const next = [];
|
|
4482
|
+
for (const p of frontier) {
|
|
4483
|
+
for (const i of nearDark(idx, p[0], p[1])) {
|
|
4484
|
+
const s = idx.segs[i];
|
|
4485
|
+
const ends = [[s[0], s[1]], [s[2], s[3]]];
|
|
4486
|
+
for (let e = 0; e < 2; e++) {
|
|
4487
|
+
if (Math.hypot(ends[e][0] - p[0], ends[e][1] - p[1]) > LEADER_HOP_PX) continue;
|
|
4488
|
+
const other = ends[1 - e];
|
|
4489
|
+
const k = `${i}:${1 - e}`;
|
|
4490
|
+
if (seen.has(k)) continue;
|
|
4491
|
+
seen.add(k);
|
|
4492
|
+
next.push(other);
|
|
4493
|
+
reached.push(other);
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
if (!next.length) break;
|
|
4498
|
+
frontier = next;
|
|
4499
|
+
}
|
|
4500
|
+
return reached;
|
|
4501
|
+
}
|
|
4502
|
+
function labelPlacements(placements, spans, segs, lum) {
|
|
4503
|
+
const tokens = labelTokens(spans);
|
|
4504
|
+
if (!tokens.length || !placements.length) return placements.map(() => null);
|
|
4505
|
+
const out = placements.map(() => null);
|
|
4506
|
+
for (let p = 0; p < placements.length; p++) {
|
|
4507
|
+
const [px, py] = placements[p];
|
|
4508
|
+
let best = null;
|
|
4509
|
+
for (const t of tokens) {
|
|
4510
|
+
const h = Math.max(t.y1 - t.y0, 8);
|
|
4511
|
+
const d = Math.hypot((t.x0 + t.x1) / 2 - px, (t.y0 + t.y1) / 2 - py);
|
|
4512
|
+
if (d <= LABEL_ADJACENT_K * h && (!best || d < best.d)) best = { tag: t.str.trim(), d };
|
|
4513
|
+
}
|
|
4514
|
+
if (best) out[p] = { label: best.tag, via: "adjacent", distance_px: Math.round(best.d) };
|
|
4515
|
+
}
|
|
4516
|
+
if (lum && lum.length) {
|
|
4517
|
+
let darkLen = 0, totalLen = 0;
|
|
4518
|
+
const n = segs.length >> 2;
|
|
4519
|
+
for (let i = 0; i < n; i++) {
|
|
4520
|
+
const L = Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
4521
|
+
totalLen += L;
|
|
4522
|
+
if (lum[i] < LEADER_DARK_LUM) darkLen += L;
|
|
4523
|
+
}
|
|
4524
|
+
if (totalLen > 0 && darkLen / totalLen <= LEADER_MAX_DARK_SHARE) {
|
|
4525
|
+
const idx = buildDarkIndex(segs, lum);
|
|
4526
|
+
for (const t of tokens) {
|
|
4527
|
+
const cy = (t.y0 + t.y1) / 2;
|
|
4528
|
+
for (const start of [[t.x0 - 4, cy], [t.x1 + 4, cy]]) {
|
|
4529
|
+
const reach = chase(idx, start);
|
|
4530
|
+
for (let p = 0; p < placements.length; p++) {
|
|
4531
|
+
if (out[p]) continue;
|
|
4532
|
+
const hit = reach.reduce((m, q) => Math.min(m, Math.hypot(q[0] - placements[p][0], q[1] - placements[p][1])), Infinity);
|
|
4533
|
+
if (hit <= LEADER_HIT_PX) out[p] = { label: t.str.trim(), via: "leader", distance_px: Math.round(hit) };
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
return out;
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4422
4542
|
// ../web/src/lib/transitions.ts
|
|
4423
4543
|
var COINCIDENT_PX = 1e-9;
|
|
4424
4544
|
function segments(ring) {
|
|
@@ -7189,6 +7309,45 @@ var Session = class _Session {
|
|
|
7189
7309
|
complete: true
|
|
7190
7310
|
};
|
|
7191
7311
|
}
|
|
7312
|
+
/** #308 — resolve the drawing's own tag for a sweep's placements. Pure
|
|
7313
|
+
* resolution lives in web/src/lib/symbollabels.ts (shared with the canvas
|
|
7314
|
+
* Symbol tool, #264); this just lines the answers back up with the rows
|
|
7315
|
+
* they belong to. */
|
|
7316
|
+
sweepLabels(spans, geo, seedCenter, matches, withheld) {
|
|
7317
|
+
const centers = [...seedCenter ? [seedCenter] : [], ...matches.map((m) => m.at), ...withheld.map((w) => w.at)];
|
|
7318
|
+
const named = labelPlacements(centers, spans, geo.segs, geo.lum);
|
|
7319
|
+
const off = seedCenter ? 1 : 0;
|
|
7320
|
+
return {
|
|
7321
|
+
seed: seedCenter ? named[0] : null,
|
|
7322
|
+
matches: named.slice(off, off + matches.length),
|
|
7323
|
+
withheld: named.slice(off + matches.length)
|
|
7324
|
+
};
|
|
7325
|
+
}
|
|
7326
|
+
static labelFields(l) {
|
|
7327
|
+
return l ? { label: l.label, label_via: l.via } : {};
|
|
7328
|
+
}
|
|
7329
|
+
/** The label findings worth a sentence, in both directions (#308): a match
|
|
7330
|
+
* with no label in a labeled family is a shape-only count to look at; a
|
|
7331
|
+
* withheld row carrying the seed's own tag is the drawing vouching for it;
|
|
7332
|
+
* a withheld row carrying a different tag is a sibling fixture, answered. */
|
|
7333
|
+
static sweepLabelNote(lbl) {
|
|
7334
|
+
const parts = [];
|
|
7335
|
+
const seedTag = lbl.seed?.label;
|
|
7336
|
+
const anyLabeled = !!seedTag || lbl.matches.some(Boolean);
|
|
7337
|
+
const unlabeledMatches = lbl.matches.filter((l) => !l).length;
|
|
7338
|
+
if (anyLabeled && unlabeledMatches) {
|
|
7339
|
+
parts.push(`${unlabeledMatches} committed match(es) carry NO label while this family is labeled${seedTag ? ` (the seed reads "${seedTag}")` : ""} \u2014 counted on shape alone; view_sheet them before trusting the total.`);
|
|
7340
|
+
}
|
|
7341
|
+
if (seedTag) {
|
|
7342
|
+
const diffMatches = [...new Set(lbl.matches.filter((l) => !!l && l.label !== seedTag).map((l) => l.label))];
|
|
7343
|
+
if (diffMatches.length) parts.push(`Committed match(es) the drawing names differently (${diffMatches.join(", ")}) \u2014 the geometry matched but the tag disagrees; check they belong in this count, or exclude one as a counter-example.`);
|
|
7344
|
+
const same = lbl.withheld.filter((l) => l && l.label === seedTag).length;
|
|
7345
|
+
const others = [...new Set(lbl.withheld.filter((l) => !!l && l.label !== seedTag).map((l) => l.label))];
|
|
7346
|
+
if (same) parts.push(`${same} withheld placement(s) carry the seed's own tag "${seedTag}" \u2014 the drawing says they are real; look, then place_count.`);
|
|
7347
|
+
if (others.length) parts.push(`Withheld placements the drawing names differently (${others.join(", ")}) are sibling fixtures, not missed counts.`);
|
|
7348
|
+
}
|
|
7349
|
+
return parts.length ? parts.join(" ") : null;
|
|
7350
|
+
}
|
|
7192
7351
|
placeCount(name, points, opts) {
|
|
7193
7352
|
const s = this.sheet(name);
|
|
7194
7353
|
const ids = points.map(([x, y], i) => this.commit(
|
|
@@ -7311,6 +7470,8 @@ var Session = class _Session {
|
|
|
7311
7470
|
};
|
|
7312
7471
|
if (scope === "sheet") {
|
|
7313
7472
|
const res = matchSymbol(fp, geo.segs, { ...sweepOpts, lum: geo.lum, excludeCenter: fp.center, ...negatives.length ? { negatives } : {} });
|
|
7473
|
+
if (!s.spans) s.spans = textSpans(s.page);
|
|
7474
|
+
const lbl = this.sweepLabels(s.spans, geo, fp.center, res.matches, res.withheld);
|
|
7314
7475
|
let committed2;
|
|
7315
7476
|
if (opts.commit && res.matches.length) {
|
|
7316
7477
|
committed2 = this.placeCount(name, res.matches.map((m) => m.at), {
|
|
@@ -7324,12 +7485,13 @@ var Session = class _Session {
|
|
|
7324
7485
|
}))
|
|
7325
7486
|
});
|
|
7326
7487
|
}
|
|
7488
|
+
const labelNote2 = _Session.sweepLabelNote(lbl);
|
|
7327
7489
|
return {
|
|
7328
7490
|
scope,
|
|
7329
7491
|
found: res.matches.length,
|
|
7330
|
-
matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
7331
|
-
withheld: res.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
|
|
7332
|
-
seed: seedOut,
|
|
7492
|
+
matches: res.matches.map((m, i) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored, ..._Session.labelFields(lbl.matches[i]) })),
|
|
7493
|
+
withheld: res.withheld.map((w, i) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, ..._Session.labelFields(lbl.withheld[i]), reason: w.reason })),
|
|
7494
|
+
seed: { ...seedOut, ..._Session.labelFields(lbl.seed) },
|
|
7333
7495
|
...res.rejected.length ? { rejected: res.rejected.map((r) => ({ at: [round1(r.at[0]), round1(r.at[1])], score: r.score, rotation: r.rotation, mirrored: r.mirrored, by: r.by + 1, mode: r.mode, evidence: r.evidence, reason: r.reason })) } : {},
|
|
7334
7496
|
...res.negatives ? { negatives: res.negatives.filter((n) => !!n).map((n) => ({ mode: n.mode, segments: n.segments, center: [round1(n.center[0]), round1(n.center[1])] })) } : {},
|
|
7335
7497
|
...res.lum_gate ? { lum_gate: res.lum_gate } : {},
|
|
@@ -7341,7 +7503,12 @@ var Session = class _Session {
|
|
|
7341
7503
|
condition: committed2.condition,
|
|
7342
7504
|
ea_total: committed2.ea_total
|
|
7343
7505
|
} : {},
|
|
7344
|
-
...
|
|
7506
|
+
...(() => {
|
|
7507
|
+
const parts = [];
|
|
7508
|
+
if (opts.commit && !res.matches.length) parts.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
|
|
7509
|
+
if (labelNote2) parts.push(labelNote2);
|
|
7510
|
+
return parts.length ? { note: parts.join(" ") } : {};
|
|
7511
|
+
})(),
|
|
7345
7512
|
...res.candidates.dropped > 0 ? { warning: `Work ceiling: ${res.candidates.dropped} candidate placement(s) were never scored \u2014 this count is a FLOOR, not a total. The seed's linework is too common on this sheet for an exhaustive sweep; tighten the seed rect around more distinctive geometry, or sweep a region at a time and reconcile the counts.` } : {}
|
|
7346
7513
|
};
|
|
7347
7514
|
}
|
|
@@ -7386,7 +7553,9 @@ var Session = class _Session {
|
|
|
7386
7553
|
continue;
|
|
7387
7554
|
}
|
|
7388
7555
|
const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
|
|
7389
|
-
|
|
7556
|
+
if (!sh.spans) sh.spans = textSpans(sh.page);
|
|
7557
|
+
const labels = this.sweepLabels(sh.spans, g2, null, res.matches, res.withheld);
|
|
7558
|
+
perSheet.push({ state: sh, ...res, elapsed_ms, scale: ratio, labels });
|
|
7390
7559
|
}
|
|
7391
7560
|
const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
7392
7561
|
let committed;
|
|
@@ -7434,17 +7603,25 @@ var Session = class _Session {
|
|
|
7434
7603
|
if (lumRejectedTotal) {
|
|
7435
7604
|
notes.push(`${lumRejectedTotal} placement(s) the geometry would have committed were pulled under the bar by your stated luminance tolerance \u2014 each is named per sheet in lum_gate.at. Look before trusting the gate: a symbol somebody redrew in a different pen fails it honestly.`);
|
|
7436
7605
|
}
|
|
7606
|
+
if (!s.spans) s.spans = textSpans(s.page);
|
|
7607
|
+
const seedLbl = this.sweepLabels(s.spans, geo, fp.center, [], []).seed;
|
|
7608
|
+
const labelNote = _Session.sweepLabelNote({
|
|
7609
|
+
seed: seedLbl,
|
|
7610
|
+
matches: perSheet.flatMap((p) => p.labels.matches),
|
|
7611
|
+
withheld: perSheet.flatMap((p) => p.labels.withheld)
|
|
7612
|
+
});
|
|
7613
|
+
if (labelNote) notes.push(labelNote);
|
|
7437
7614
|
return {
|
|
7438
7615
|
scope,
|
|
7439
7616
|
found,
|
|
7440
|
-
seed: { ...seedOut, role: seedRole },
|
|
7617
|
+
seed: { ...seedOut, role: seedRole, ..._Session.labelFields(seedLbl) },
|
|
7441
7618
|
...rejectedTotal ? { rejected_total: rejectedTotal } : {},
|
|
7442
7619
|
...negatives.length ? { negatives: negatives.map((n) => ({ mode: n.mode, segments: n.rel.length, center: [round1(n.center[0]), round1(n.center[1])] })) } : {},
|
|
7443
7620
|
sheets: perSheet.map((p) => ({
|
|
7444
7621
|
sheet: p.state.key,
|
|
7445
7622
|
found: p.matches.length,
|
|
7446
|
-
matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
7447
|
-
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 })),
|
|
7623
|
+
matches: p.matches.map((m, i) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored, ..._Session.labelFields(p.labels.matches[i]) })),
|
|
7624
|
+
withheld: p.withheld.map((w, i) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, ..._Session.labelFields(p.labels.withheld[i]), reason: w.reason })),
|
|
7448
7625
|
...p.rejected.length ? { rejected: p.rejected.map((r) => ({ at: [round1(r.at[0]), round1(r.at[1])], score: r.score, rotation: r.rotation, mirrored: r.mirrored, by: r.by + 1, mode: r.mode, evidence: r.evidence, reason: r.reason })) } : {},
|
|
7449
7626
|
...p.lum_gate ? { lum_gate: p.lum_gate } : {},
|
|
7450
7627
|
candidates: p.candidates,
|
|
@@ -8941,7 +9118,9 @@ var sweepPlacement = {
|
|
|
8941
9118
|
at: z.tuple([z.number(), z.number()]).describe("The placed symbol's centroid (image px) \u2014 the point a commit places its count marker at"),
|
|
8942
9119
|
score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
|
|
8943
9120
|
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
8944
|
-
mirrored: z.boolean()
|
|
9121
|
+
mirrored: z.boolean(),
|
|
9122
|
+
label: z.string().optional().describe(`The drawing's own tag for this placement (#308) \u2014 a fixture token written beside it or connected by a drawn leader (e.g. "P-7", "FD1"). Disclosure, never a recount: a match with NO label in a labeled family was counted on shape alone (look before trusting), and a withheld row carrying the seed's own tag is the drawing vouching for it`),
|
|
9123
|
+
label_via: z.enum(["adjacent", "leader"]).optional().describe("How the tag reached this placement: written beside it, or followed along a drawn leader line (leader-following arms only on multi-pen sheets, where the annotation pen separates from the work)")
|
|
8945
9124
|
};
|
|
8946
9125
|
var sweepRejected = z.object({
|
|
8947
9126
|
...sweepPlacement,
|
|
@@ -9002,7 +9181,9 @@ var symbolSweepOutput = {
|
|
|
9002
9181
|
segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
|
|
9003
9182
|
center: z.tuple([z.number(), z.number()]).describe("The seed instance's own centroid (image px) \u2014 reported here, never double-committed as a match"),
|
|
9004
9183
|
rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
|
|
9005
|
-
length_px: z.number().describe("Total seed linework length, image px")
|
|
9184
|
+
length_px: z.number().describe("Total seed linework length, image px"),
|
|
9185
|
+
label: z.string().optional().describe(`The drawing's own tag for the seed instance (#308) \u2014 the family's identity, e.g. seeding a drain the sheet labels "P-7"`),
|
|
9186
|
+
label_via: z.enum(["adjacent", "leader"]).optional()
|
|
9006
9187
|
}),
|
|
9007
9188
|
rejected: z.array(sweepRejected).optional().describe("Sheet scope only. Placements the geometry accepted and a counter-example refused (#259) \u2014 NEVER counted in found, and never silent: each says which negative did it and what it saw. Reinstate one by hand with place_count at its `at` if you disagree"),
|
|
9008
9189
|
negatives: sweepNegatives.optional().describe("What each `exclude` rect was read as, in the order you passed them (#259)"),
|
|
@@ -11322,7 +11503,7 @@ function registerTools(realServer, session) {
|
|
|
11322
11503
|
outputSchema: placeCountOutput
|
|
11323
11504
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
11324
11505
|
server.registerTool("symbol_sweep", {
|
|
11325
|
-
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. Every proposed placement is scored up to a hard work ceiling sized for pathological sheets, and the reply says which it was: complete true means the count is a total; complete false (with candidates.dropped > 0) means the count is a FLOOR \u2014 some placements were never scored \u2014 so tighten the seed rect around more distinctive geometry rather than trusting it as a total. 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. Counter-examples (#259): drafting reuses one generic shape for different devices \u2014 a wall-mounted data outlet drawn as a plain triangle, the flush-floor variant the SAME triangle inside a square, keynote callouts a triangle with a letter in it \u2014 so the seed legitimately matches things you do not mean, and seeding more geometry only works where the drawing offers more to capture. \`exclude\` takes rects around instances you do NOT mean, marqueed exactly like the seed. You never choose a mechanism; the rect's contents decide, because both are the same gesture: a rect holding EXTRA linework beyond the seed rejects placements where that extra linework is present too (the box, the letter), and a rect holding no extra linework of its own is read as the line running THROUGH it \u2014 a bare ceiling-grid tile whose grid line a real fixture, drawn over it, would BREAK. That second mechanic is not expressible as a seed: only segments fully INSIDE a rect define a symbol, and background structure is long by nature. Every rejection is disclosed in rejected[] \u2014 which negative, what fraction of its evidence was found, and the placement \u2014 and NEVER counted in found: an exclusion is a judgement, so look at it and reinstate any you disagree with using place_count at its \`at\`. A counter-example that holds no instance of the seed, or holds the seed with nothing extra, is REFUSED rather than silently doing nothing. Stroke luminance (#260): a flattened export strips the layer tree and flattens every pen, but the file still STATES stroke color \u2014 a black fixture outline over a grey ceiling grid is unambiguous there even when the geometry is identical (two empty 2 ft grid tiles reproduce a 2\xD74 fixture's outline exactly). luminance_tolerance (0\u2013254) gates on it: a sheet segment only answers for a seed segment when their stroke luminances are within the stated tolerance (Rec. 709, 0 = black, 255 = white; 32\u201364 separates black from grey without touching anti-aliasing wobble). OPT-IN and disclosed, in the spirit of tolerance_px \u2014 omitted, sweeps score exactly as before; stated, the reply's lum_gate says the seed's own luminance band and names every placement the geometry would have committed and the pen did not, so you can LOOK at what a stated gate cost. Prefer geometry (a counter-example, a tighter seed) where the drawing offers it \u2014 color is the fallback for exports where nothing else survived. 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}`,
|
|
11506
|
+
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. Every proposed placement is scored up to a hard work ceiling sized for pathological sheets, and the reply says which it was: complete true means the count is a total; complete false (with candidates.dropped > 0) means the count is a FLOOR \u2014 some placements were never scored \u2014 so tighten the seed rect around more distinctive geometry rather than trusting it as a total. 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. Counter-examples (#259): drafting reuses one generic shape for different devices \u2014 a wall-mounted data outlet drawn as a plain triangle, the flush-floor variant the SAME triangle inside a square, keynote callouts a triangle with a letter in it \u2014 so the seed legitimately matches things you do not mean, and seeding more geometry only works where the drawing offers more to capture. \`exclude\` takes rects around instances you do NOT mean, marqueed exactly like the seed. You never choose a mechanism; the rect's contents decide, because both are the same gesture: a rect holding EXTRA linework beyond the seed rejects placements where that extra linework is present too (the box, the letter), and a rect holding no extra linework of its own is read as the line running THROUGH it \u2014 a bare ceiling-grid tile whose grid line a real fixture, drawn over it, would BREAK. That second mechanic is not expressible as a seed: only segments fully INSIDE a rect define a symbol, and background structure is long by nature. Every rejection is disclosed in rejected[] \u2014 which negative, what fraction of its evidence was found, and the placement \u2014 and NEVER counted in found: an exclusion is a judgement, so look at it and reinstate any you disagree with using place_count at its \`at\`. A counter-example that holds no instance of the seed, or holds the seed with nothing extra, is REFUSED rather than silently doing nothing. Stroke luminance (#260): a flattened export strips the layer tree and flattens every pen, but the file still STATES stroke color \u2014 a black fixture outline over a grey ceiling grid is unambiguous there even when the geometry is identical (two empty 2 ft grid tiles reproduce a 2\xD74 fixture's outline exactly). luminance_tolerance (0\u2013254) gates on it: a sheet segment only answers for a seed segment when their stroke luminances are within the stated tolerance (Rec. 709, 0 = black, 255 = white; 32\u201364 separates black from grey without touching anti-aliasing wobble). OPT-IN and disclosed, in the spirit of tolerance_px \u2014 omitted, sweeps score exactly as before; stated, the reply's lum_gate says the seed's own luminance band and names every placement the geometry would have committed and the pen did not, so you can LOOK at what a stated gate cost. Prefer geometry (a counter-example, a tighter seed) where the drawing offers it \u2014 color is the fallback for exports where nothing else survived. Labels (#308): for a LABELED family \u2014 fixtures, tagged equipment, keyed devices \u2014 the drawing already names every instance, and the sweep reads those names: a fixture token written beside a placement, or connected to it by a drawn leader line (leader-following arms only on multi-pen sheets, where the annotation pen separates from the work), comes back as \`label\` + \`label_via\` on the row, and the seed's own tag rides \`seed.label\`. Disclosure in both directions, never a recount: a committed match with NO label while the family is labeled was counted on shape alone (measured case: two 0.97 matches that were valve internals, not drains \u2014 LOOK at those first), a withheld row carrying the seed's own tag is the drawing vouching for a near-miss (look, then place_count), and a withheld row named a DIFFERENT tag is a sibling fixture answered, not a missed count. 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}`,
|
|
11326
11507
|
inputSchema: {
|
|
11327
11508
|
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)"),
|
|
11328
11509
|
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"),
|
|
@@ -11896,7 +12077,7 @@ function nameTheStageInRefusals(server) {
|
|
|
11896
12077
|
// package.json
|
|
11897
12078
|
var package_default = {
|
|
11898
12079
|
name: "opentakeoff-mcp",
|
|
11899
|
-
version: "0.9.
|
|
12080
|
+
version: "0.9.61",
|
|
11900
12081
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
11901
12082
|
type: "module",
|
|
11902
12083
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED