opentakeoff-mcp 0.9.60 → 0.9.62
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 +2 -2
- package/dist/server-core.js +271 -21
- 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). |
|
|
@@ -172,7 +172,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
172
172
|
| `resolve_tag` | ONE room tag → its room-finish schedule row → each code's finish/material definition, every edge cited (sheet + literal text + bbox). Refusal over guessing: `unresolved` comes back with a reason, never as silence. A delta/REV marker on the answering row rides the result as `revisions`—the codes are the post-revision answer, and you're told the ink changed. |
|
|
173
173
|
| `find_schedule` | Locate a schedule table by kind ("room finish", "material")—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. |
|
|
174
174
|
| `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids—same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
|
|
175
|
-
| `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed. |
|
|
175
|
+
| `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed; `marks` (#297) burns disclosure layers in — `question` (withheld placements, orange ?-circle), `struck` (rejections, magenta struck ×), `ring` (the sweep's seed, violet double ring) — in colors off the common CAD pens, so what a reply names, the picture shows. |
|
|
176
176
|
|
|
177
177
|
### The agent revises its own work
|
|
178
178
|
|
package/dist/server-core.js
CHANGED
|
@@ -4429,6 +4429,116 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
4429
4429
|
};
|
|
4430
4430
|
}
|
|
4431
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
|
+
|
|
4432
4542
|
// ../web/src/lib/transitions.ts
|
|
4433
4543
|
var COINCIDENT_PX = 1e-9;
|
|
4434
4544
|
function segments(ring) {
|
|
@@ -5680,6 +5790,59 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
|
|
|
5680
5790
|
}
|
|
5681
5791
|
ctx.setLineDash([]);
|
|
5682
5792
|
}
|
|
5793
|
+
var MARK_QUESTION = "#ff8c00";
|
|
5794
|
+
var MARK_STRUCK = "#e10ee1";
|
|
5795
|
+
var MARK_RING = "#7a00e6";
|
|
5796
|
+
function polyCircle(ctx, x, y, r) {
|
|
5797
|
+
ctx.beginPath();
|
|
5798
|
+
for (let i = 0; i <= 16; i++) {
|
|
5799
|
+
const a = i / 16 * 2 * Math.PI;
|
|
5800
|
+
const px = x + r * Math.cos(a), py = y + r * Math.sin(a);
|
|
5801
|
+
if (i === 0) ctx.moveTo(px, py);
|
|
5802
|
+
else ctx.lineTo(px, py);
|
|
5803
|
+
}
|
|
5804
|
+
ctx.stroke();
|
|
5805
|
+
}
|
|
5806
|
+
function drawMarks(ctx, toCanvas, marks, longEdge) {
|
|
5807
|
+
const r = Math.max(7, longEdge / 110);
|
|
5808
|
+
const w = Math.max(1.6, longEdge / 600);
|
|
5809
|
+
ctx.setLineDash([]);
|
|
5810
|
+
ctx.lineWidth = w;
|
|
5811
|
+
let drawn = 0;
|
|
5812
|
+
for (const [mx, my] of marks.question ?? []) {
|
|
5813
|
+
const [x, y] = toCanvas(mx, my);
|
|
5814
|
+
ctx.strokeStyle = MARK_QUESTION;
|
|
5815
|
+
polyCircle(ctx, x, y, r);
|
|
5816
|
+
ctx.fillStyle = MARK_QUESTION;
|
|
5817
|
+
ctx.font = `bold ${Math.round(r * 1.4)}px sans-serif`;
|
|
5818
|
+
ctx.fillText("?", x - r * 0.35, y + r * 0.5);
|
|
5819
|
+
drawn++;
|
|
5820
|
+
}
|
|
5821
|
+
for (const [mx, my] of marks.struck ?? []) {
|
|
5822
|
+
const [x, y] = toCanvas(mx, my);
|
|
5823
|
+
ctx.strokeStyle = MARK_STRUCK;
|
|
5824
|
+
const m = r * 0.8;
|
|
5825
|
+
ctx.beginPath();
|
|
5826
|
+
ctx.moveTo(x - m, y - m);
|
|
5827
|
+
ctx.lineTo(x + m, y + m);
|
|
5828
|
+
ctx.moveTo(x - m, y + m);
|
|
5829
|
+
ctx.lineTo(x + m, y - m);
|
|
5830
|
+
ctx.stroke();
|
|
5831
|
+
ctx.beginPath();
|
|
5832
|
+
ctx.moveTo(x - r * 1.2, y);
|
|
5833
|
+
ctx.lineTo(x + r * 1.2, y);
|
|
5834
|
+
ctx.stroke();
|
|
5835
|
+
drawn++;
|
|
5836
|
+
}
|
|
5837
|
+
for (const [mx, my] of marks.ring ?? []) {
|
|
5838
|
+
const [x, y] = toCanvas(mx, my);
|
|
5839
|
+
ctx.strokeStyle = MARK_RING;
|
|
5840
|
+
polyCircle(ctx, x, y, r);
|
|
5841
|
+
polyCircle(ctx, x, y, r * 0.6);
|
|
5842
|
+
drawn++;
|
|
5843
|
+
}
|
|
5844
|
+
return drawn;
|
|
5845
|
+
}
|
|
5683
5846
|
|
|
5684
5847
|
// src/session.ts
|
|
5685
5848
|
var SNAP_CELL = 24;
|
|
@@ -5925,9 +6088,11 @@ var Session = class _Session {
|
|
|
5925
6088
|
}
|
|
5926
6089
|
const ppf = gridPxPerFoot(opts.grid, s.upp);
|
|
5927
6090
|
const sheetShapes = this.shapes.filter((x) => x.sheet_id === s.key);
|
|
6091
|
+
let marksDrawn = 0;
|
|
5928
6092
|
const { png, width, height, zoom } = await s.page.renderRegionPng(r, px, (ctx, toCanvas) => {
|
|
5929
6093
|
if (ppf) drawGrid(ctx, toCanvas, r, ppf);
|
|
5930
6094
|
if (opts.overlay) drawShapes(ctx, toCanvas, sheetShapes, s.widthPx, s.heightPx, px);
|
|
6095
|
+
if (opts.marks) marksDrawn = drawMarks(ctx, toCanvas, opts.marks, px);
|
|
5931
6096
|
});
|
|
5932
6097
|
return {
|
|
5933
6098
|
png,
|
|
@@ -5940,6 +6105,7 @@ var Session = class _Session {
|
|
|
5940
6105
|
zoom: +zoom.toFixed(4),
|
|
5941
6106
|
overlay: !!opts.overlay,
|
|
5942
6107
|
...opts.overlay ? { shapes_drawn: sheetShapes.length } : {},
|
|
6108
|
+
...opts.marks ? { marks_drawn: marksDrawn } : {},
|
|
5943
6109
|
grid_px_per_foot: ppf ? round2(ppf) : 0
|
|
5944
6110
|
}
|
|
5945
6111
|
};
|
|
@@ -7199,6 +7365,45 @@ var Session = class _Session {
|
|
|
7199
7365
|
complete: true
|
|
7200
7366
|
};
|
|
7201
7367
|
}
|
|
7368
|
+
/** #308 — resolve the drawing's own tag for a sweep's placements. Pure
|
|
7369
|
+
* resolution lives in web/src/lib/symbollabels.ts (shared with the canvas
|
|
7370
|
+
* Symbol tool, #264); this just lines the answers back up with the rows
|
|
7371
|
+
* they belong to. */
|
|
7372
|
+
sweepLabels(spans, geo, seedCenter, matches, withheld) {
|
|
7373
|
+
const centers = [...seedCenter ? [seedCenter] : [], ...matches.map((m) => m.at), ...withheld.map((w) => w.at)];
|
|
7374
|
+
const named = labelPlacements(centers, spans, geo.segs, geo.lum);
|
|
7375
|
+
const off = seedCenter ? 1 : 0;
|
|
7376
|
+
return {
|
|
7377
|
+
seed: seedCenter ? named[0] : null,
|
|
7378
|
+
matches: named.slice(off, off + matches.length),
|
|
7379
|
+
withheld: named.slice(off + matches.length)
|
|
7380
|
+
};
|
|
7381
|
+
}
|
|
7382
|
+
static labelFields(l) {
|
|
7383
|
+
return l ? { label: l.label, label_via: l.via } : {};
|
|
7384
|
+
}
|
|
7385
|
+
/** The label findings worth a sentence, in both directions (#308): a match
|
|
7386
|
+
* with no label in a labeled family is a shape-only count to look at; a
|
|
7387
|
+
* withheld row carrying the seed's own tag is the drawing vouching for it;
|
|
7388
|
+
* a withheld row carrying a different tag is a sibling fixture, answered. */
|
|
7389
|
+
static sweepLabelNote(lbl) {
|
|
7390
|
+
const parts = [];
|
|
7391
|
+
const seedTag = lbl.seed?.label;
|
|
7392
|
+
const anyLabeled = !!seedTag || lbl.matches.some(Boolean);
|
|
7393
|
+
const unlabeledMatches = lbl.matches.filter((l) => !l).length;
|
|
7394
|
+
if (anyLabeled && unlabeledMatches) {
|
|
7395
|
+
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.`);
|
|
7396
|
+
}
|
|
7397
|
+
if (seedTag) {
|
|
7398
|
+
const diffMatches = [...new Set(lbl.matches.filter((l) => !!l && l.label !== seedTag).map((l) => l.label))];
|
|
7399
|
+
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.`);
|
|
7400
|
+
const same = lbl.withheld.filter((l) => l && l.label === seedTag).length;
|
|
7401
|
+
const others = [...new Set(lbl.withheld.filter((l) => !!l && l.label !== seedTag).map((l) => l.label))];
|
|
7402
|
+
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.`);
|
|
7403
|
+
if (others.length) parts.push(`Withheld placements the drawing names differently (${others.join(", ")}) are sibling fixtures, not missed counts.`);
|
|
7404
|
+
}
|
|
7405
|
+
return parts.length ? parts.join(" ") : null;
|
|
7406
|
+
}
|
|
7202
7407
|
placeCount(name, points, opts) {
|
|
7203
7408
|
const s = this.sheet(name);
|
|
7204
7409
|
const ids = points.map(([x, y], i) => this.commit(
|
|
@@ -7272,6 +7477,12 @@ var Session = class _Session {
|
|
|
7272
7477
|
if (opts.commit && !opts.condition) {
|
|
7273
7478
|
throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
|
|
7274
7479
|
}
|
|
7480
|
+
if (opts.commitSeed && !opts.commit) {
|
|
7481
|
+
throw new UserError("commit_seed: true needs commit: true \u2014 the seed joins the same one-undo-step batch as the matches.");
|
|
7482
|
+
}
|
|
7483
|
+
if (opts.commitSeed && scope === "set") {
|
|
7484
|
+
throw new UserError("commit_seed applies to sheet scope only \u2014 in a set-wide sweep the seed may sit on a detail or legend sheet, where it is a reference drawing. If the seed instance is installed work, place_count it on its sheet explicitly.");
|
|
7485
|
+
}
|
|
7275
7486
|
const geo = await this.ensureGeometry(s);
|
|
7276
7487
|
if (!geo.segs.length) {
|
|
7277
7488
|
throw new UserError("This sheet has no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments; raster fallback not yet available in the MCP server.");
|
|
@@ -7321,25 +7532,35 @@ var Session = class _Session {
|
|
|
7321
7532
|
};
|
|
7322
7533
|
if (scope === "sheet") {
|
|
7323
7534
|
const res = matchSymbol(fp, geo.segs, { ...sweepOpts, lum: geo.lum, excludeCenter: fp.center, ...negatives.length ? { negatives } : {} });
|
|
7535
|
+
if (!s.spans) s.spans = textSpans(s.page);
|
|
7536
|
+
const lbl = this.sweepLabels(s.spans, geo, fp.center, res.matches, res.withheld);
|
|
7324
7537
|
let committed2;
|
|
7325
|
-
if (opts.commit && res.matches.length) {
|
|
7326
|
-
|
|
7538
|
+
if (opts.commit && (res.matches.length || opts.commitSeed)) {
|
|
7539
|
+
const points = [...opts.commitSeed ? [fp.center] : [], ...res.matches.map((m) => m.at)];
|
|
7540
|
+
const seedOrigin = {
|
|
7541
|
+
method: "symbol_sweep",
|
|
7542
|
+
actor: "agent",
|
|
7543
|
+
reviewed: false,
|
|
7544
|
+
symbol: { score: 1, rotation: 0, mirrored: false, seed: { source: "instance", sheet: s.key } }
|
|
7545
|
+
};
|
|
7546
|
+
committed2 = this.placeCount(name, points, {
|
|
7327
7547
|
condition: opts.condition,
|
|
7328
7548
|
tool: "symbol_sweep",
|
|
7329
|
-
origins: res.matches.map((m) => ({
|
|
7549
|
+
origins: [...opts.commitSeed ? [seedOrigin] : [], ...res.matches.map((m) => ({
|
|
7330
7550
|
method: "symbol_sweep",
|
|
7331
7551
|
actor: "agent",
|
|
7332
7552
|
reviewed: false,
|
|
7333
7553
|
symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored, seed: { source: "instance", sheet: s.key } }
|
|
7334
|
-
}))
|
|
7554
|
+
}))]
|
|
7335
7555
|
});
|
|
7336
7556
|
}
|
|
7557
|
+
const labelNote2 = _Session.sweepLabelNote(lbl);
|
|
7337
7558
|
return {
|
|
7338
7559
|
scope,
|
|
7339
7560
|
found: res.matches.length,
|
|
7340
|
-
matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
7341
|
-
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 })),
|
|
7342
|
-
seed: seedOut,
|
|
7561
|
+
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]) })),
|
|
7562
|
+
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 })),
|
|
7563
|
+
seed: { ...seedOut, ..._Session.labelFields(lbl.seed) },
|
|
7343
7564
|
...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 })) } : {},
|
|
7344
7565
|
...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])] })) } : {},
|
|
7345
7566
|
...res.lum_gate ? { lum_gate: res.lum_gate } : {},
|
|
@@ -7349,9 +7570,16 @@ var Session = class _Session {
|
|
|
7349
7570
|
committed: committed2.committed,
|
|
7350
7571
|
shape_ids: committed2.shape_ids,
|
|
7351
7572
|
condition: committed2.condition,
|
|
7352
|
-
ea_total: committed2.ea_total
|
|
7573
|
+
ea_total: committed2.ea_total,
|
|
7574
|
+
...opts.commitSeed ? { seed_committed: true } : {}
|
|
7353
7575
|
} : {},
|
|
7354
|
-
...
|
|
7576
|
+
...(() => {
|
|
7577
|
+
const parts = [];
|
|
7578
|
+
if (opts.commit && !res.matches.length && !opts.commitSeed) parts.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
|
|
7579
|
+
if (committed2 && !opts.commitSeed) parts.push(`The seed instance at (${round1(fp.center[0])}, ${round1(fp.center[1])}) is NOT in this count \u2014 if it is installed work, re-run with commit_seed: true or place_count it.`);
|
|
7580
|
+
if (labelNote2) parts.push(labelNote2);
|
|
7581
|
+
return parts.length ? { note: parts.join(" ") } : {};
|
|
7582
|
+
})(),
|
|
7355
7583
|
...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.` } : {}
|
|
7356
7584
|
};
|
|
7357
7585
|
}
|
|
@@ -7396,7 +7624,9 @@ var Session = class _Session {
|
|
|
7396
7624
|
continue;
|
|
7397
7625
|
}
|
|
7398
7626
|
const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
|
|
7399
|
-
|
|
7627
|
+
if (!sh.spans) sh.spans = textSpans(sh.page);
|
|
7628
|
+
const labels = this.sweepLabels(sh.spans, g2, null, res.matches, res.withheld);
|
|
7629
|
+
perSheet.push({ state: sh, ...res, elapsed_ms, scale: ratio, labels });
|
|
7400
7630
|
}
|
|
7401
7631
|
const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
7402
7632
|
let committed;
|
|
@@ -7444,17 +7674,25 @@ var Session = class _Session {
|
|
|
7444
7674
|
if (lumRejectedTotal) {
|
|
7445
7675
|
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.`);
|
|
7446
7676
|
}
|
|
7677
|
+
if (!s.spans) s.spans = textSpans(s.page);
|
|
7678
|
+
const seedLbl = this.sweepLabels(s.spans, geo, fp.center, [], []).seed;
|
|
7679
|
+
const labelNote = _Session.sweepLabelNote({
|
|
7680
|
+
seed: seedLbl,
|
|
7681
|
+
matches: perSheet.flatMap((p) => p.labels.matches),
|
|
7682
|
+
withheld: perSheet.flatMap((p) => p.labels.withheld)
|
|
7683
|
+
});
|
|
7684
|
+
if (labelNote) notes.push(labelNote);
|
|
7447
7685
|
return {
|
|
7448
7686
|
scope,
|
|
7449
7687
|
found,
|
|
7450
|
-
seed: { ...seedOut, role: seedRole },
|
|
7688
|
+
seed: { ...seedOut, role: seedRole, ..._Session.labelFields(seedLbl) },
|
|
7451
7689
|
...rejectedTotal ? { rejected_total: rejectedTotal } : {},
|
|
7452
7690
|
...negatives.length ? { negatives: negatives.map((n) => ({ mode: n.mode, segments: n.rel.length, center: [round1(n.center[0]), round1(n.center[1])] })) } : {},
|
|
7453
7691
|
sheets: perSheet.map((p) => ({
|
|
7454
7692
|
sheet: p.state.key,
|
|
7455
7693
|
found: p.matches.length,
|
|
7456
|
-
matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
7457
|
-
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 })),
|
|
7694
|
+
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]) })),
|
|
7695
|
+
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 })),
|
|
7458
7696
|
...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 })) } : {},
|
|
7459
7697
|
...p.lum_gate ? { lum_gate: p.lum_gate } : {},
|
|
7460
7698
|
candidates: p.candidates,
|
|
@@ -8951,7 +9189,9 @@ var sweepPlacement = {
|
|
|
8951
9189
|
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"),
|
|
8952
9190
|
score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
|
|
8953
9191
|
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
8954
|
-
mirrored: z.boolean()
|
|
9192
|
+
mirrored: z.boolean(),
|
|
9193
|
+
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`),
|
|
9194
|
+
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)")
|
|
8955
9195
|
};
|
|
8956
9196
|
var sweepRejected = z.object({
|
|
8957
9197
|
...sweepPlacement,
|
|
@@ -9012,11 +9252,14 @@ var symbolSweepOutput = {
|
|
|
9012
9252
|
segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
|
|
9013
9253
|
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"),
|
|
9014
9254
|
rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
|
|
9015
|
-
length_px: z.number().describe("Total seed linework length, image px")
|
|
9255
|
+
length_px: z.number().describe("Total seed linework length, image px"),
|
|
9256
|
+
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"`),
|
|
9257
|
+
label_via: z.enum(["adjacent", "leader"]).optional()
|
|
9016
9258
|
}),
|
|
9017
9259
|
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"),
|
|
9018
9260
|
negatives: sweepNegatives.optional().describe("What each `exclude` rect was read as, in the order you passed them (#259)"),
|
|
9019
9261
|
rejected_total: z.number().int().optional().describe("Set scope: placements counter-examples rejected across every swept sheet"),
|
|
9262
|
+
seed_committed: z.boolean().optional().describe("Present when commit_seed: true minted the seed instance into the batch (#296) \u2014 ea_total then includes it"),
|
|
9020
9263
|
lum_gate: sweepLumGate.optional().describe("Sheet scope only. The stated stroke-luminance gate's accounting (#260): the tolerance, the seed's own luminance band, and every placement the geometry would have committed that the pen pulled under the bar \u2014 NEVER counted in found, never silent. Set scope accounts per sheet in sheets[]"),
|
|
9021
9264
|
candidates: sweepCandidates.optional().describe("Sheet scope only \u2014 set scope accounts per sheet in sheets[]"),
|
|
9022
9265
|
complete: z.boolean().describe("True when every proposed placement was scored (every swept sheet, in set scope) and the count is a total. FALSE MEANS THE COUNT IS A FLOOR \u2014 acknowledge it before trusting found (#261)"),
|
|
@@ -11332,12 +11575,13 @@ function registerTools(realServer, session) {
|
|
|
11332
11575
|
outputSchema: placeCountOutput
|
|
11333
11576
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
11334
11577
|
server.registerTool("symbol_sweep", {
|
|
11335
|
-
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}`,
|
|
11578
|
+
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 SEED instance is not in that count (#296) \u2014 in sheet scope it is almost always installed work, so pass commit_seed: true to mint it into the same batch (the reply reminds you whenever a sheet-scope commit leaves it out; ea_total one short of the hand tally is exactly this). 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}`,
|
|
11336
11579
|
inputSchema: {
|
|
11337
11580
|
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)"),
|
|
11338
11581
|
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"),
|
|
11339
11582
|
condition: z2.string().optional().describe("Finish tag to commit match markers under (minted on first use), e.g. 'FD-1'. Required when commit is true"),
|
|
11340
11583
|
commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
|
|
11584
|
+
commit_seed: z2.boolean().default(false).describe("Sheet scope + commit only (#296): also commit the SEED instance \u2014 in sheet scope the seed is almost always installed work, and a count that excludes it bids one short. Joins the same one-undo-step batch, origin score 1. Refused in set scope, where a detail/legend seed is a reference drawing"),
|
|
11341
11585
|
scope: z2.enum(["sheet", "set"]).default("sheet").describe('"sheet" = this sheet only; "set" = every PLAN-role sheet in the working set (needs a text layer for the sheet graph; non-plan sheets are excluded and disclosed)'),
|
|
11342
11586
|
rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
|
|
11343
11587
|
mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
|
|
@@ -11355,7 +11599,8 @@ function registerTools(realServer, session) {
|
|
|
11355
11599
|
mirror: a.mirror,
|
|
11356
11600
|
tolerancePx: a.tolerance_px,
|
|
11357
11601
|
exclude: a.exclude,
|
|
11358
|
-
luminanceTolerance: a.luminance_tolerance
|
|
11602
|
+
luminanceTolerance: a.luminance_tolerance,
|
|
11603
|
+
commitSeed: a.commit_seed
|
|
11359
11604
|
})));
|
|
11360
11605
|
server.registerTool("sweep_schedule_row", {
|
|
11361
11606
|
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}`,
|
|
@@ -11608,19 +11853,24 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
11608
11853
|
outputSchema: findTextOutput
|
|
11609
11854
|
}, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
|
|
11610
11855
|
server.registerTool("view_sheet", {
|
|
11611
|
-
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
|
|
11856
|
+
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". marks (#297) burns DISCLOSURE layers into the render, so what a reply names, the picture shows: pass the coordinate lists a tool disclosed \u2014 question: withheld placements (orange ?-circles), struck: rejections a counter-example or luminance gate refused (magenta struck \xD7), ring: reference points like the sweep's own seed (violet double ring). The colors sit deliberately off the common CAD pens so they cannot vanish into color-plotted work. An overlay audit without marks shows only committed ink \u2014 the validation trap where 37 disclosed near-misses read as "it missed them". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
|
|
11612
11857
|
inputSchema: {
|
|
11613
11858
|
sheet: z2.string(),
|
|
11614
11859
|
region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
|
|
11615
11860
|
px: z2.number().int().min(200).max(2e3).optional().describe("Long-side pixel budget of the returned image (default 1400) \u2014 small region + high px = readable dimension strings"),
|
|
11616
11861
|
overlay: z2.boolean().optional().describe("Burn committed shapes into the render (solid = human-affirmed, dashed = unreviewed)"),
|
|
11617
|
-
grid: z2.string().optional().describe(`Burn in a calibrated 1-ft/5-ft measuring grid: "auto" = the sheet's set scale; otherwise the drawing scale as inches-per-foot, e.g. "1/4", "3/16", "0.25"`)
|
|
11862
|
+
grid: z2.string().optional().describe(`Burn in a calibrated 1-ft/5-ft measuring grid: "auto" = the sheet's set scale; otherwise the drawing scale as inches-per-foot, e.g. "1/4", "3/16", "0.25"`),
|
|
11863
|
+
marks: z2.object({
|
|
11864
|
+
question: z2.array(pointSchema).optional().describe("Open questions \u2014 withheld placements, spots to look at. Orange ?-in-circle"),
|
|
11865
|
+
struck: z2.array(pointSchema).optional().describe("Refusals \u2014 rejected[] placements, lum_gate.at. Magenta struck \xD7"),
|
|
11866
|
+
ring: z2.array(pointSchema).optional().describe("Reference points \u2014 the sweep's seed.center, an anchor. Violet double ring")
|
|
11867
|
+
}).optional().describe("Disclosure marks to burn into the render (#297): what the reply names, the picture shows. Coordinates in image px")
|
|
11618
11868
|
}
|
|
11619
11869
|
}, async (a) => {
|
|
11620
11870
|
const startedAt = process.hrtime.bigint();
|
|
11621
11871
|
let reply;
|
|
11622
11872
|
try {
|
|
11623
|
-
const { png, meta } = await session.viewSheet(a.sheet, { region: a.region, px: a.px, overlay: a.overlay, grid: a.grid });
|
|
11873
|
+
const { png, meta } = await session.viewSheet(a.sheet, { region: a.region, px: a.px, overlay: a.overlay, grid: a.grid, marks: a.marks });
|
|
11624
11874
|
reply = okImage(png, meta);
|
|
11625
11875
|
} catch (e) {
|
|
11626
11876
|
reply = fail(e);
|
|
@@ -11906,7 +12156,7 @@ function nameTheStageInRefusals(server) {
|
|
|
11906
12156
|
// package.json
|
|
11907
12157
|
var package_default = {
|
|
11908
12158
|
name: "opentakeoff-mcp",
|
|
11909
|
-
version: "0.9.
|
|
12159
|
+
version: "0.9.62",
|
|
11910
12160
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
11911
12161
|
type: "module",
|
|
11912
12162
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED