opentakeoff-mcp 0.9.19 → 0.9.21
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 +9 -5
- package/dist/server-core.js +679 -39
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -108,13 +108,14 @@ includes document text, shape vertices, or result payload content.
|
|
|
108
108
|
| `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152) — plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
|
|
109
109
|
| `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
|
|
110
110
|
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
|
|
111
|
-
| `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
|
|
111
|
+
| `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels — same engine as the canvas — with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit; `role: "deduct"` subtracts. |
|
|
112
112
|
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
114
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
115
|
| `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
|
|
116
116
|
| `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. |
|
|
117
117
|
| `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. |
|
|
118
|
+
| `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 sheet's 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. `commit: true` + `condition` commits every match center as an EA count marker through the `place_count` path — one undo step, `origin.method "symbol_sweep"` with per-marker score and transform. No scale required. |
|
|
118
119
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
119
120
|
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
|
|
120
121
|
| `delete_shape` | Remove a committed shape by id. |
|
|
@@ -126,7 +127,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
126
127
|
| `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself, and the cover states where the finish tags came from (`Finish assignment: N schedule-resolved · N agent-asserted · …`, plus any rooms the last assign run withheld). Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
|
|
127
128
|
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, review state, and assignment verdict (`schedule` \| `asserted` — where its finish tag came from) in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
|
|
128
129
|
| `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
|
|
129
|
-
| `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
|
|
130
|
+
| `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text), **dimension** (`from` + `to` — a dimension line with end ticks, labelled with the measured length at the sheet's scale; the one annotation the scale gate applies to — an unscaled sheet refuses like the measure tools). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
|
|
130
131
|
| `list_annotations` | Every annotation with its condition RESOLVED to a finish tag, coordinates back in image px; filter by sheet/condition. `unattached` counts the link_annotation candidates. |
|
|
131
132
|
| `link_annotation` | Attach an existing annotation to a condition (or detach with an empty tag) — the canvas's Attach/Detach control, reachable by an agent. |
|
|
132
133
|
| `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
|
|
@@ -243,9 +244,12 @@ sheet number (`A-101`) wherever a sheet is named.
|
|
|
243
244
|
|
|
244
245
|
## Limits (v1)
|
|
245
246
|
|
|
246
|
-
- **
|
|
247
|
-
|
|
248
|
-
(
|
|
247
|
+
- **Scanned sheets flood, but don't index.** `one_click` and `detect_rooms`
|
|
248
|
+
fall back to the sheet's rendered pixels where vectors can't bound the room
|
|
249
|
+
(#154) — disclosed as `raster_traced` — but a scan with no text layer still
|
|
250
|
+
has nothing for `detect_rooms`/`sheet_graph`/`resolve_tag` to read: seeds
|
|
251
|
+
come from you (`view_sheet`, then `one_click`). The raster path needs the
|
|
252
|
+
same optional `@napi-rs/canvas` as `view_sheet`.
|
|
249
253
|
- `load_plan` replaces the session by default; `merge: true` builds a multi-document working set (#152). Reloading a merged file is refused — reload = replace, deliberately.
|
|
250
254
|
- The takeoff lives in memory. `export_takeoff` (the app's exact save payload,
|
|
251
255
|
nothing lost in translation) and `export_marked_pdf` (the reviewable marked
|
package/dist/server-core.js
CHANGED
|
@@ -193,6 +193,19 @@ async function openPdf(filePath) {
|
|
|
193
193
|
factory.destroy(target);
|
|
194
194
|
}
|
|
195
195
|
},
|
|
196
|
+
async renderRgba(scale, width, height) {
|
|
197
|
+
await ensureCanvasGlobals();
|
|
198
|
+
const rvp = page.getViewport({ scale });
|
|
199
|
+
const factory = doc.canvasFactory;
|
|
200
|
+
const target = factory.create(width, height);
|
|
201
|
+
try {
|
|
202
|
+
await page.render({ canvasContext: target.context, viewport: rvp, background: "#ffffff" }).promise;
|
|
203
|
+
const ctx = target.context;
|
|
204
|
+
return ctx.getImageData(0, 0, width, height).data;
|
|
205
|
+
} finally {
|
|
206
|
+
factory.destroy(target);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
196
209
|
async renderRegionPng(region, longEdge, draw) {
|
|
197
210
|
await ensureCanvasGlobals();
|
|
198
211
|
const w = region.x1 - region.x0;
|
|
@@ -1316,6 +1329,334 @@ var fail = (err) => ({
|
|
|
1316
1329
|
var round2 = (n) => +n.toFixed(2);
|
|
1317
1330
|
var round1 = (n) => +n.toFixed(1);
|
|
1318
1331
|
|
|
1332
|
+
// ../web/src/lib/rastermask.ts
|
|
1333
|
+
var RASTER_MIN_IMG_FRAC = 0.1;
|
|
1334
|
+
var RASTER_MIN_SEGS = 500;
|
|
1335
|
+
var RASTER_T = 0.15;
|
|
1336
|
+
var RASTER_ABS_INK = 100;
|
|
1337
|
+
var RASTER_WIN_MIN = 15;
|
|
1338
|
+
var RASTER_WIN_DIV = 32;
|
|
1339
|
+
var INVERT_MEAN = 128;
|
|
1340
|
+
var RASTER_RDP_EPS = 2.5;
|
|
1341
|
+
function toGray(rgba, n) {
|
|
1342
|
+
const gray = new Uint8Array(n);
|
|
1343
|
+
let sum = 0;
|
|
1344
|
+
for (let i = 0, j = 0; i < n; i++, j += 4) {
|
|
1345
|
+
const g = 77 * rgba[j] + 151 * rgba[j + 1] + 28 * rgba[j + 2] >> 8;
|
|
1346
|
+
gray[i] = g;
|
|
1347
|
+
sum += g;
|
|
1348
|
+
}
|
|
1349
|
+
return { gray, mean: n ? sum / n : 255 };
|
|
1350
|
+
}
|
|
1351
|
+
function interiorMean(gray, mw, mh, marginFrac = 0.12) {
|
|
1352
|
+
const mx = Math.floor(mw * marginFrac), my = Math.floor(mh * marginFrac);
|
|
1353
|
+
const x0 = mx, x1 = mw - mx, y0 = my, y1 = mh - my;
|
|
1354
|
+
if (x1 <= x0 || y1 <= y0) {
|
|
1355
|
+
let sum2 = 0;
|
|
1356
|
+
for (let i = 0; i < gray.length; i++) sum2 += gray[i];
|
|
1357
|
+
return gray.length ? sum2 / gray.length : 255;
|
|
1358
|
+
}
|
|
1359
|
+
let sum = 0, count = 0;
|
|
1360
|
+
for (let y = y0; y < y1; y++) {
|
|
1361
|
+
const row = y * mw;
|
|
1362
|
+
for (let x = x0; x < x1; x++) {
|
|
1363
|
+
sum += gray[row + x];
|
|
1364
|
+
count++;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return count ? sum / count : 255;
|
|
1368
|
+
}
|
|
1369
|
+
function adaptiveThreshold(gray, mw, mh, t = RASTER_T, absInk = RASTER_ABS_INK) {
|
|
1370
|
+
const iw = mw + 1;
|
|
1371
|
+
const integral = new Uint32Array(iw * (mh + 1));
|
|
1372
|
+
for (let y = 0; y < mh; y++) {
|
|
1373
|
+
let rowSum = 0;
|
|
1374
|
+
for (let x = 0; x < mw; x++) {
|
|
1375
|
+
rowSum += gray[y * mw + x];
|
|
1376
|
+
integral[(y + 1) * iw + (x + 1)] = integral[y * iw + (x + 1)] + rowSum;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const win = Math.max(RASTER_WIN_MIN, Math.round(Math.min(mw, mh) / RASTER_WIN_DIV)) | 1;
|
|
1380
|
+
const half = win >> 1;
|
|
1381
|
+
const out = new Uint8Array(mw * mh);
|
|
1382
|
+
for (let y = 0; y < mh; y++) {
|
|
1383
|
+
const y0 = Math.max(0, y - half), y1 = Math.min(mh - 1, y + half);
|
|
1384
|
+
for (let x = 0; x < mw; x++) {
|
|
1385
|
+
const x0 = Math.max(0, x - half), x1 = Math.min(mw - 1, x + half);
|
|
1386
|
+
const area = (x1 - x0 + 1) * (y1 - y0 + 1);
|
|
1387
|
+
const sum = integral[(y1 + 1) * iw + (x1 + 1)] - integral[y0 * iw + (x1 + 1)] - integral[(y1 + 1) * iw + x0] + integral[y0 * iw + x0];
|
|
1388
|
+
const g = gray[y * mw + x];
|
|
1389
|
+
if (g < absInk || g * area < sum * (1 - t)) out[y * mw + x] = 1;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return out;
|
|
1393
|
+
}
|
|
1394
|
+
function closeMask(mask, mw, mh) {
|
|
1395
|
+
const pass = (src, horizontal, dilate) => {
|
|
1396
|
+
const dst = new Uint8Array(mw * mh);
|
|
1397
|
+
for (let y = 0; y < mh; y++) {
|
|
1398
|
+
for (let x = 0; x < mw; x++) {
|
|
1399
|
+
const i = y * mw + x;
|
|
1400
|
+
let a, b, c;
|
|
1401
|
+
if (horizontal) {
|
|
1402
|
+
a = x > 0 ? src[i - 1] : src[i];
|
|
1403
|
+
b = src[i];
|
|
1404
|
+
c = x < mw - 1 ? src[i + 1] : src[i];
|
|
1405
|
+
} else {
|
|
1406
|
+
a = y > 0 ? src[i - mw] : src[i];
|
|
1407
|
+
b = src[i];
|
|
1408
|
+
c = y < mh - 1 ? src[i + mw] : src[i];
|
|
1409
|
+
}
|
|
1410
|
+
dst[i] = dilate ? a | b | c : a & b & c;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return dst;
|
|
1414
|
+
};
|
|
1415
|
+
let m = pass(mask, true, true);
|
|
1416
|
+
m = pass(m, false, true);
|
|
1417
|
+
m = pass(m, true, false);
|
|
1418
|
+
m = pass(m, false, false);
|
|
1419
|
+
return m;
|
|
1420
|
+
}
|
|
1421
|
+
function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
|
|
1422
|
+
const n = mw * mh;
|
|
1423
|
+
const { gray } = toGray(rgba, n);
|
|
1424
|
+
const polarityMean = interiorMean(gray, mw, mh);
|
|
1425
|
+
if (polarityMean < INVERT_MEAN) for (let i = 0; i < n; i++) gray[i] = 255 - gray[i];
|
|
1426
|
+
let mask = adaptiveThreshold(gray, mw, mh, opts.t, opts.absInk);
|
|
1427
|
+
if (opts.bridge !== false) mask = closeMask(mask, mw, mh);
|
|
1428
|
+
return { mask, mw, mh, ws, softCount: 0 };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ../web/src/lib/symbolsweep.ts
|
|
1432
|
+
var SWEEP_TOL_PX = 2;
|
|
1433
|
+
var SWEEP_SCORE_HIGH = 0.92;
|
|
1434
|
+
var SWEEP_SCORE_LOW = 0.75;
|
|
1435
|
+
var SWEEP_MAX_CANDIDATES = 2e4;
|
|
1436
|
+
var ANCHOR_COUNT = 3;
|
|
1437
|
+
var MIN_SEG_LEN = 0.5;
|
|
1438
|
+
var MAX_SEED_SEGS = 2e3;
|
|
1439
|
+
function transformsFor(rotations, mirror) {
|
|
1440
|
+
const rots = [
|
|
1441
|
+
[0, [1, 0, 0, 1]],
|
|
1442
|
+
[90, [0, -1, 1, 0]],
|
|
1443
|
+
[180, [-1, 0, 0, -1]],
|
|
1444
|
+
[270, [0, 1, -1, 0]]
|
|
1445
|
+
];
|
|
1446
|
+
const use = rotations ? rots : rots.slice(0, 1);
|
|
1447
|
+
const out = use.map(([rotation, m]) => ({ rotation, mirrored: false, m }));
|
|
1448
|
+
if (mirror) {
|
|
1449
|
+
for (const [rotation, m] of use) out.push({ rotation, mirrored: true, m: [-m[0], m[1], -m[2], m[3]] });
|
|
1450
|
+
}
|
|
1451
|
+
return out;
|
|
1452
|
+
}
|
|
1453
|
+
var apply = (m, x, y) => [m[0] * x + m[1] * y, m[2] * x + m[3] * y];
|
|
1454
|
+
var EndpointGrid = class {
|
|
1455
|
+
constructor(segs, tol) {
|
|
1456
|
+
this.segs = segs;
|
|
1457
|
+
this.cell = Math.max(2 * tol, 4);
|
|
1458
|
+
const n = segs.length >> 2;
|
|
1459
|
+
for (let i = 0; i < n; i++) {
|
|
1460
|
+
this.add(segs[i * 4], segs[i * 4 + 1], i);
|
|
1461
|
+
this.add(segs[i * 4 + 2], segs[i * 4 + 3], i);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
cells = /* @__PURE__ */ new Map();
|
|
1465
|
+
cell;
|
|
1466
|
+
key(cx, cy) {
|
|
1467
|
+
return cx * 73856093 ^ cy * 19349663;
|
|
1468
|
+
}
|
|
1469
|
+
add(x, y, i) {
|
|
1470
|
+
const k = this.key(Math.floor(x / this.cell), Math.floor(y / this.cell));
|
|
1471
|
+
const a = this.cells.get(k);
|
|
1472
|
+
if (a) {
|
|
1473
|
+
if (a[a.length - 1] !== i) a.push(i);
|
|
1474
|
+
} else this.cells.set(k, [i]);
|
|
1475
|
+
}
|
|
1476
|
+
/** Indices of segments with an endpoint within one cell of (x, y). */
|
|
1477
|
+
near(x, y, out) {
|
|
1478
|
+
out.length = 0;
|
|
1479
|
+
const cx = Math.floor(x / this.cell), cy = Math.floor(y / this.cell);
|
|
1480
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
1481
|
+
const a = this.cells.get(this.key(cx + dx, cy + dy));
|
|
1482
|
+
if (a) for (const i of a) out.push(i);
|
|
1483
|
+
}
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
1488
|
+
function sweepSymbols(segs, seedRect, opts = {}) {
|
|
1489
|
+
const tol = opts.tolPx ?? SWEEP_TOL_PX;
|
|
1490
|
+
const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
|
|
1491
|
+
const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
|
|
1492
|
+
const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
|
|
1493
|
+
const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
|
|
1494
|
+
const n = segs.length >> 2;
|
|
1495
|
+
const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
|
|
1496
|
+
const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
|
|
1497
|
+
const inside = (x, y) => x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1;
|
|
1498
|
+
const seedIdx = [];
|
|
1499
|
+
for (let i = 0; i < n; i++) {
|
|
1500
|
+
if (inside(segs[i * 4], segs[i * 4 + 1]) && inside(segs[i * 4 + 2], segs[i * 4 + 3]) && segLen(segs, i) >= MIN_SEG_LEN) {
|
|
1501
|
+
seedIdx.push(i);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (!seedIdx.length) {
|
|
1505
|
+
throw new Error("No vector segments sit fully inside the seed rect \u2014 marquee tightly around one whole symbol instance (segments crossing the rect edge don't count as the symbol).");
|
|
1506
|
+
}
|
|
1507
|
+
if (seedIdx.length > MAX_SEED_SEGS) {
|
|
1508
|
+
throw new Error(`The seed rect holds ${seedIdx.length} segments \u2014 that is a region, not one symbol instance. Marquee a single symbol.`);
|
|
1509
|
+
}
|
|
1510
|
+
let totalLen = 0, cxw = 0, cyw = 0;
|
|
1511
|
+
for (const i of seedIdx) {
|
|
1512
|
+
const L = segLen(segs, i);
|
|
1513
|
+
totalLen += L;
|
|
1514
|
+
cxw += (segs[i * 4] + segs[i * 4 + 2]) / 2 * L;
|
|
1515
|
+
cyw += (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2 * L;
|
|
1516
|
+
}
|
|
1517
|
+
const seedCx = cxw / totalLen, seedCy = cyw / totalLen;
|
|
1518
|
+
const rel = seedIdx.map((i) => [
|
|
1519
|
+
segs[i * 4] - seedCx,
|
|
1520
|
+
segs[i * 4 + 1] - seedCy,
|
|
1521
|
+
segs[i * 4 + 2] - seedCx,
|
|
1522
|
+
segs[i * 4 + 3] - seedCy,
|
|
1523
|
+
segLen(segs, i)
|
|
1524
|
+
]);
|
|
1525
|
+
let sbx0 = Infinity, sby0 = Infinity, sbx1 = -Infinity, sby1 = -Infinity;
|
|
1526
|
+
for (const i of seedIdx) {
|
|
1527
|
+
sbx0 = Math.min(sbx0, segs[i * 4], segs[i * 4 + 2]);
|
|
1528
|
+
sby0 = Math.min(sby0, segs[i * 4 + 1], segs[i * 4 + 3]);
|
|
1529
|
+
sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
|
|
1530
|
+
sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
|
|
1531
|
+
}
|
|
1532
|
+
const lenBucket = /* @__PURE__ */ new Map();
|
|
1533
|
+
for (let i = 0; i < n; i++) {
|
|
1534
|
+
const b = Math.round(segLen(segs, i));
|
|
1535
|
+
const a = lenBucket.get(b);
|
|
1536
|
+
if (a) a.push(i);
|
|
1537
|
+
else lenBucket.set(b, [i]);
|
|
1538
|
+
}
|
|
1539
|
+
const bucketBand = (L) => {
|
|
1540
|
+
const out = [];
|
|
1541
|
+
for (let b = Math.floor(L - 2 * tol); b <= Math.ceil(L + 2 * tol); b++) {
|
|
1542
|
+
const a = lenBucket.get(b);
|
|
1543
|
+
if (a) {
|
|
1544
|
+
for (const i of a) if (Math.abs(segLen(segs, i) - L) <= 2 * tol) out.push(i);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return out;
|
|
1548
|
+
};
|
|
1549
|
+
const byLenQ = /* @__PURE__ */ new Map();
|
|
1550
|
+
for (let k = 0; k < rel.length; k++) {
|
|
1551
|
+
const L = rel[k][4];
|
|
1552
|
+
const q = Math.round(L);
|
|
1553
|
+
if (!byLenQ.has(q)) byLenQ.set(q, { relIdx: k, len: L, rarity: bucketBand(L).length });
|
|
1554
|
+
}
|
|
1555
|
+
const anchors = [...byLenQ.values()].sort((a, b) => a.rarity - b.rarity || b.len - a.len || a.relIdx - b.relIdx).slice(0, ANCHOR_COUNT);
|
|
1556
|
+
const proposals = [];
|
|
1557
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1558
|
+
const quant = Math.max(tol, 1);
|
|
1559
|
+
for (let xi = 0; xi < xforms.length; xi++) {
|
|
1560
|
+
const { m } = xforms[xi];
|
|
1561
|
+
for (const anc of anchors) {
|
|
1562
|
+
const r = rel[anc.relIdx];
|
|
1563
|
+
const A = apply(m, r[0], r[1]);
|
|
1564
|
+
const B = apply(m, r[2], r[3]);
|
|
1565
|
+
for (const j of bucketBand(anc.len)) {
|
|
1566
|
+
const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
|
|
1567
|
+
for (const [ax, ay, bx, by] of [[A[0], A[1], B[0], B[1]], [B[0], B[1], A[0], A[1]]]) {
|
|
1568
|
+
const c1x = px - ax, c1y = py - ay;
|
|
1569
|
+
const c2x = qx - bx, c2y = qy - by;
|
|
1570
|
+
if (Math.abs(c1x - c2x) > 2 * tol || Math.abs(c1y - c2y) > 2 * tol) continue;
|
|
1571
|
+
const tx = (c1x + c2x) / 2, ty = (c1y + c2y) / 2;
|
|
1572
|
+
const key = `${xi}:${Math.round(tx / quant)}:${Math.round(ty / quant)}`;
|
|
1573
|
+
if (seen.has(key)) continue;
|
|
1574
|
+
seen.add(key);
|
|
1575
|
+
proposals.push({ tx, ty, xf: xi });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
proposals.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.xf - b.xf);
|
|
1581
|
+
const considered = Math.min(proposals.length, maxCandidates);
|
|
1582
|
+
const dropped = proposals.length - considered;
|
|
1583
|
+
const grid = new EndpointGrid(segs, tol);
|
|
1584
|
+
const scratch = [];
|
|
1585
|
+
const tol2 = tol * tol;
|
|
1586
|
+
const near = (x1, y1, x2, y2) => (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) <= tol2;
|
|
1587
|
+
const scoreAt = (m, tx, ty) => {
|
|
1588
|
+
let matched = 0;
|
|
1589
|
+
for (const r of rel) {
|
|
1590
|
+
const a = apply(m, r[0], r[1]);
|
|
1591
|
+
const b = apply(m, r[2], r[3]);
|
|
1592
|
+
const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
|
|
1593
|
+
let hit = false;
|
|
1594
|
+
for (const j of grid.near(ax, ay, scratch)) {
|
|
1595
|
+
const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
|
|
1596
|
+
if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
|
|
1597
|
+
hit = true;
|
|
1598
|
+
break;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (hit) matched += r[4];
|
|
1602
|
+
}
|
|
1603
|
+
return matched / totalLen;
|
|
1604
|
+
};
|
|
1605
|
+
const scored = [];
|
|
1606
|
+
for (let k = 0; k < considered; k++) {
|
|
1607
|
+
const c = proposals[k];
|
|
1608
|
+
const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
|
|
1609
|
+
if (score < scoreLow) continue;
|
|
1610
|
+
scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
|
|
1611
|
+
}
|
|
1612
|
+
const mergeR = Math.max(2 * tol, 4);
|
|
1613
|
+
const kept = [];
|
|
1614
|
+
for (const s of scored) {
|
|
1615
|
+
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
1616
|
+
if (!twin) {
|
|
1617
|
+
kept.push(s);
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
1621
|
+
twin.at = s.at;
|
|
1622
|
+
twin.score = s.score;
|
|
1623
|
+
twin.rotation = s.rotation;
|
|
1624
|
+
twin.mirrored = s.mirrored;
|
|
1625
|
+
twin.xf = s.xf;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const suppressR = Math.max(mergeR, Math.hypot(sbx1 - sbx0, sby1 - sby0) / 2);
|
|
1629
|
+
const away = kept.filter((s) => Math.hypot(s.at[0] - seedCx, s.at[1] - seedCy) > suppressR);
|
|
1630
|
+
const matches = [];
|
|
1631
|
+
const withheld = [];
|
|
1632
|
+
const pct = (v) => Math.round(v * 1e3) / 1e3;
|
|
1633
|
+
const row = (s) => ({
|
|
1634
|
+
at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
|
|
1635
|
+
score: pct(s.score),
|
|
1636
|
+
rotation: s.rotation,
|
|
1637
|
+
mirrored: s.mirrored
|
|
1638
|
+
});
|
|
1639
|
+
for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
|
|
1640
|
+
for (const s of away) {
|
|
1641
|
+
if (s.score >= scoreHigh) continue;
|
|
1642
|
+
if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
|
|
1643
|
+
withheld.push({ ...row(s), reason: `matched ${Math.round(s.score * 100)}% of the seed's linework (commit bar ${Math.round(scoreHigh * 100)}%) \u2014 likely a variant or an overlapped instance; look before counting it` });
|
|
1644
|
+
}
|
|
1645
|
+
const order = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0] || a.rotation - b.rotation || Number(a.mirrored) - Number(b.mirrored);
|
|
1646
|
+
matches.sort(order);
|
|
1647
|
+
withheld.sort(order);
|
|
1648
|
+
return {
|
|
1649
|
+
seed: {
|
|
1650
|
+
segments: seedIdx.length,
|
|
1651
|
+
center: [Math.round(seedCx * 10) / 10, Math.round(seedCy * 10) / 10],
|
|
1652
|
+
length_px: Math.round(totalLen * 10) / 10
|
|
1653
|
+
},
|
|
1654
|
+
matches,
|
|
1655
|
+
withheld,
|
|
1656
|
+
candidates: { considered, dropped }
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1319
1660
|
// ../web/src/lib/geometry.js
|
|
1320
1661
|
function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
|
|
1321
1662
|
const r = R * innerRatio;
|
|
@@ -1463,6 +1804,21 @@ function attrValue(attrs, colId) {
|
|
|
1463
1804
|
return visible(attrs?.[colId]);
|
|
1464
1805
|
}
|
|
1465
1806
|
|
|
1807
|
+
// ../web/src/lib/units.ts
|
|
1808
|
+
var M_PER_FT = 0.3048;
|
|
1809
|
+
function dimLabel(feet, units = "imperial") {
|
|
1810
|
+
if (!Number.isFinite(feet)) return "";
|
|
1811
|
+
if (units === "metric") return `${(feet * M_PER_FT).toFixed(2)} m`;
|
|
1812
|
+
const sign = feet < 0 ? "-" : "";
|
|
1813
|
+
let ft = Math.floor(Math.abs(feet) + 1e-9);
|
|
1814
|
+
let inch = Math.round((Math.abs(feet) - ft) * 12);
|
|
1815
|
+
if (inch === 12) {
|
|
1816
|
+
ft += 1;
|
|
1817
|
+
inch = 0;
|
|
1818
|
+
}
|
|
1819
|
+
return `${sign}${ft}'-${inch}"`;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1466
1822
|
// ../web/src/lib/totals.js
|
|
1467
1823
|
function accumulateRole(acc, s) {
|
|
1468
1824
|
const cp = s.computed || {};
|
|
@@ -2621,9 +2977,9 @@ var Session = class _Session {
|
|
|
2621
2977
|
}
|
|
2622
2978
|
return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
|
|
2623
2979
|
}
|
|
2624
|
-
/** v1 masks come from the sheet's vector linework only
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
2980
|
+
/** v1 masks come from the sheet's vector linework only; a scanned sheet
|
|
2981
|
+
* (zero segments) is null here and the measuring tools fall back to
|
|
2982
|
+
* ensureRasterMask (#154). Layer roles (#85) ride in as the stated
|
|
2627
2983
|
* short-circuit; an unlayered sheet builds the identical pre-#85 mask. */
|
|
2628
2984
|
async ensureMask(name) {
|
|
2629
2985
|
const s = this.sheet(name);
|
|
@@ -2643,6 +2999,46 @@ var Session = class _Session {
|
|
|
2643
2999
|
if (!geo.segs.length) return null;
|
|
2644
3000
|
return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
|
|
2645
3001
|
}
|
|
3002
|
+
/** The raster-fallback mask (#154): a dedicated render of the sheet at mask
|
|
3003
|
+
* scale (the view_sheet machinery — pdf.ts + @napi-rs/canvas), thresholded
|
|
3004
|
+
* by the canvas's own rastermask engine into the same MaskObj shape
|
|
3005
|
+
* buildMask emits, so floodRegion/traceRegion run unchanged on scans.
|
|
3006
|
+
* Cached per sheet like geo/mask; where the optional native canvas never
|
|
3007
|
+
* installed, renderRgba throws its plain install-hint Error and the tool
|
|
3008
|
+
* reply carries it — a stated inability, never a guessed polygon. */
|
|
3009
|
+
async ensureRasterMask(s) {
|
|
3010
|
+
if (!s.rmask) {
|
|
3011
|
+
const ws = Math.min(1, MASK_MAX_DIM / Math.max(s.widthPx, s.heightPx, 1));
|
|
3012
|
+
const mw = Math.max(2, Math.ceil(s.widthPx * ws));
|
|
3013
|
+
const mh = Math.max(2, Math.ceil(s.heightPx * ws));
|
|
3014
|
+
const rgba = await s.page.renderRgba(RENDER_SCALE * ws, mw, mh);
|
|
3015
|
+
s.rmask = buildRasterMask(rgba, mw, mh, ws);
|
|
3016
|
+
}
|
|
3017
|
+
return s.rmask;
|
|
3018
|
+
}
|
|
3019
|
+
/** The canvas's trigger policy (#154), verbatim (TakeoffCanvas.jsx /
|
|
3020
|
+
* rastermask.ts constants): raster-ELIGIBLE = placed-image area covers
|
|
3021
|
+
* ≥ RASTER_MIN_IMG_FRAC of the sheet (a scan wrapper or photo underlay);
|
|
3022
|
+
* vector-VIABLE = enough segments that the vector mask can bound rooms.
|
|
3023
|
+
* Vector is exact and always wins where it works — a pure-vector sheet
|
|
3024
|
+
* never touches pixels; a scan wrapper with near-zero linework runs raster
|
|
3025
|
+
* primary; a mixed sheet retries on pixels only after the vector flood
|
|
3026
|
+
* fails. */
|
|
3027
|
+
rasterPolicy(s, geo) {
|
|
3028
|
+
const sheetArea = s.widthPx * s.heightPx;
|
|
3029
|
+
return {
|
|
3030
|
+
rasterEligible: sheetArea > 0 && geo.imageArea / sheetArea >= RASTER_MIN_IMG_FRAC,
|
|
3031
|
+
vectorViable: geo.segs.length >> 2 >= RASTER_MIN_SEGS
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
/** Layer overrides (#85) name PDF Optional Content — scan pixels carry
|
|
3035
|
+
* none, so a raster-path call that stated them is refused rather than
|
|
3036
|
+
* silently no-opped (the rolesFor resolve-or-error doctrine). */
|
|
3037
|
+
refuseLayersOnRaster(layersOpt) {
|
|
3038
|
+
if (layersOpt && (layersOpt.include?.length || layersOpt.exclude?.length)) {
|
|
3039
|
+
throw new UserError("Layer overrides can't apply here \u2014 this flood runs on the sheet's rendered pixels (raster fallback), and scan ink carries no PDF layers. Retry without layers.");
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
2646
3042
|
async sheetInfo(name) {
|
|
2647
3043
|
const s = this.sheet(name);
|
|
2648
3044
|
const geo = await this.ensureGeometry(s);
|
|
@@ -2758,12 +3154,31 @@ var Session = class _Session {
|
|
|
2758
3154
|
}
|
|
2759
3155
|
async oneClick(name, x, y, opts) {
|
|
2760
3156
|
const s = this.sheet(name);
|
|
2761
|
-
const
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
if (
|
|
2765
|
-
|
|
2766
|
-
|
|
3157
|
+
const { rasterEligible, vectorViable } = this.rasterPolicy(s, await this.ensureGeometry(s));
|
|
3158
|
+
let f = null;
|
|
3159
|
+
let raster = false;
|
|
3160
|
+
if (!rasterEligible || vectorViable) {
|
|
3161
|
+
const mask = await this.maskWithLayers(name, opts.layers);
|
|
3162
|
+
if (!mask && !rasterEligible) throw new UserError("This sheet has no vector linework and no scan image to flood \u2014 nothing here bounds a region. Trace the space with measure_polygon instead.");
|
|
3163
|
+
if (mask) {
|
|
3164
|
+
const r = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
|
|
3165
|
+
if (r.status === "ok") f = r;
|
|
3166
|
+
else if (!rasterEligible) {
|
|
3167
|
+
if (r.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
|
|
3168
|
+
throw new UserError("Landed in dense linework (hatching or text).");
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
if (!f) {
|
|
3173
|
+
this.refuseLayersOnRaster(opts.layers);
|
|
3174
|
+
const rmask = await this.ensureRasterMask(s);
|
|
3175
|
+
const r = floodRegion(rmask, x, y);
|
|
3176
|
+
if (r.status === "leak") throw new UserError("That space isn't enclosed on the scan \u2014 the fill escaped through a gap (faded line or open doorway). Seed a more enclosed spot, or trace it with measure_polygon.");
|
|
3177
|
+
if (r.status !== "ok") throw new UserError("Landed on dense scan ink (text or hatching). Seed an open spot inside the room.");
|
|
3178
|
+
f = r;
|
|
3179
|
+
raster = true;
|
|
3180
|
+
}
|
|
3181
|
+
const ring = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
2767
3182
|
if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
|
|
2768
3183
|
const areaPx2 = ringArea(ring);
|
|
2769
3184
|
const perimPx = closedMetrics(ring).perim;
|
|
@@ -2772,6 +3187,9 @@ var Session = class _Session {
|
|
|
2772
3187
|
nverts: ring.length,
|
|
2773
3188
|
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
2774
3189
|
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
3190
|
+
// which path ran, disclosed both ways (#154): present = pixels bounded
|
|
3191
|
+
// this trace, absent = the vector linework did
|
|
3192
|
+
...raster ? { raster_traced: true } : {},
|
|
2775
3193
|
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
2776
3194
|
};
|
|
2777
3195
|
if (s.upp == null) {
|
|
@@ -2794,12 +3212,18 @@ var Session = class _Session {
|
|
|
2794
3212
|
reviewed: false,
|
|
2795
3213
|
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
2796
3214
|
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
3215
|
+
// #154 — a trace whose boundary was scan PIXELS is a different claim
|
|
3216
|
+
// than one bounded by vector linework; the record says which (the
|
|
3217
|
+
// canvas's raster_traced vocabulary, contribution.v2)
|
|
3218
|
+
...raster ? { raster_traced: true } : {},
|
|
2797
3219
|
// #85 — a trace bounded by DECLARED boundary layers is categorically
|
|
2798
|
-
// stronger evidence than one bounded by a pitch heuristic
|
|
2799
|
-
|
|
3220
|
+
// stronger evidence than one bounded by a pitch heuristic (vector
|
|
3221
|
+
// path only: the raster mask never saw the layer table)
|
|
3222
|
+
...!raster && (s.layers || []).some((l) => l.visible && (l.role === "boundary" || l.role === "structure")) ? { layer_bounded: true } : {},
|
|
2800
3223
|
// canvas-parity provenance: a non-default fill sensitivity is part of
|
|
2801
|
-
// how the shape was made (ShapeOrigin.fill_sensitivity)
|
|
2802
|
-
|
|
3224
|
+
// how the shape was made (ShapeOrigin.fill_sensitivity) — vector path
|
|
3225
|
+
// only; the knob is inert on a single-tier raster mask
|
|
3226
|
+
...!raster && opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
|
|
2803
3227
|
}).id;
|
|
2804
3228
|
}
|
|
2805
3229
|
this.flushCommits("one_click");
|
|
@@ -2848,8 +3272,17 @@ var Session = class _Session {
|
|
|
2848
3272
|
throw new UserError("No room-finish schedule in the working set \u2014 load_plan the schedule sheet with merge: true, or pass condition to commit every room under one tag.");
|
|
2849
3273
|
}
|
|
2850
3274
|
}
|
|
2851
|
-
const
|
|
2852
|
-
|
|
3275
|
+
const geo = await this.ensureGeometry(s);
|
|
3276
|
+
const { rasterEligible, vectorViable } = this.rasterPolicy(s, geo);
|
|
3277
|
+
let mask = null;
|
|
3278
|
+
let raster = false;
|
|
3279
|
+
if (!rasterEligible || vectorViable) mask = await this.maskWithLayers(name, opts.layers);
|
|
3280
|
+
if (!mask) {
|
|
3281
|
+
if (!rasterEligible) throw new UserError("This sheet has no vector linework and no scan image to flood \u2014 nothing here bounds a region. Trace rooms with measure_polygon instead.");
|
|
3282
|
+
this.refuseLayersOnRaster(opts.layers);
|
|
3283
|
+
mask = await this.ensureRasterMask(s);
|
|
3284
|
+
raster = true;
|
|
3285
|
+
}
|
|
2853
3286
|
const minAreaSf = opts.minAreaSf ?? 5;
|
|
2854
3287
|
if (!s.spans) s.spans = textSpans(s.page);
|
|
2855
3288
|
const labels = [];
|
|
@@ -2867,7 +3300,7 @@ var Session = class _Session {
|
|
|
2867
3300
|
for (const probe of seedLadderPx(lb.bbox)) {
|
|
2868
3301
|
const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
|
|
2869
3302
|
if (f.status !== "ok") continue;
|
|
2870
|
-
const r = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
3303
|
+
const r = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
2871
3304
|
if (r.length < 3) {
|
|
2872
3305
|
sawDegenerate = true;
|
|
2873
3306
|
continue;
|
|
@@ -2915,6 +3348,7 @@ var Session = class _Session {
|
|
|
2915
3348
|
...c.merged.length ? { merged_labels: c.merged } : {},
|
|
2916
3349
|
...c.hatch ? { hatch_filtered: true } : {},
|
|
2917
3350
|
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
3351
|
+
...raster ? { raster_traced: true } : {},
|
|
2918
3352
|
...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
2919
3353
|
};
|
|
2920
3354
|
if (upp == null) {
|
|
@@ -2947,6 +3381,7 @@ var Session = class _Session {
|
|
|
2947
3381
|
reviewed: false,
|
|
2948
3382
|
...c.hatch ? { hatch_filtered: true } : {},
|
|
2949
3383
|
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
3384
|
+
...raster ? { raster_traced: true } : {},
|
|
2950
3385
|
...assignment ? { assignment } : {}
|
|
2951
3386
|
}).id;
|
|
2952
3387
|
}
|
|
@@ -3090,15 +3525,99 @@ var Session = class _Session {
|
|
|
3090
3525
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
3091
3526
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
3092
3527
|
* the whole call one journal gesture — undoing a placement sweep is one step,
|
|
3093
|
-
* matching detect_rooms.
|
|
3528
|
+
* matching detect_rooms.
|
|
3529
|
+
*
|
|
3530
|
+
* `origins` (optional, aligned with points) lets a derived tool commit
|
|
3531
|
+
* through this same path while telling the truth about the method —
|
|
3532
|
+
* symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
|
|
3533
|
+
* marker; a bare place_count stays exactly the manual agent gesture it is. */
|
|
3094
3534
|
placeCount(name, points, opts) {
|
|
3095
3535
|
const s = this.sheet(name);
|
|
3096
|
-
const ids = points.map(([x, y]) => this.commit(
|
|
3097
|
-
|
|
3536
|
+
const ids = points.map(([x, y], i) => this.commit(
|
|
3537
|
+
s,
|
|
3538
|
+
opts.condition,
|
|
3539
|
+
"count",
|
|
3540
|
+
[[x, y]],
|
|
3541
|
+
{ count: 1 },
|
|
3542
|
+
opts.origins?.[i] ? { ...opts.origins[i] } : { method: "manual", actor: "agent" }
|
|
3543
|
+
).id);
|
|
3544
|
+
this.flushCommits(opts.tool ?? "place_count");
|
|
3098
3545
|
const c = this.conditions.find((x) => x.finish_tag === opts.condition);
|
|
3099
3546
|
const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((n, x) => n + (x.computed.count || 1), 0);
|
|
3100
3547
|
return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
|
|
3101
3548
|
}
|
|
3549
|
+
/** symbol_sweep — every placement of ONE example symbol, from the linework.
|
|
3550
|
+
* The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
|
|
3551
|
+
* rect's segments, propose placements by constellation anchoring under the
|
|
3552
|
+
* square symmetry group, score each as the length-weighted fraction of seed
|
|
3553
|
+
* segments reproduced within tolerance. This method is the plumbing plus
|
|
3554
|
+
* the wire shapes: rect clamping, the scan refusal, and the commit path —
|
|
3555
|
+
* match centers through placeCount (ONE undo step, EA scale-free), origins
|
|
3556
|
+
* telling the truth (`method: "symbol_sweep"`, per-match score/transform),
|
|
3557
|
+
* withheld placements NEVER committed. */
|
|
3558
|
+
async symbolSweep(name, opts) {
|
|
3559
|
+
const s = this.sheet(name);
|
|
3560
|
+
if (opts.commit && !opts.condition) {
|
|
3561
|
+
throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
|
|
3562
|
+
}
|
|
3563
|
+
const geo = await this.ensureGeometry(s);
|
|
3564
|
+
if (!geo.segs.length) {
|
|
3565
|
+
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.");
|
|
3566
|
+
}
|
|
3567
|
+
const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
|
|
3568
|
+
const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
|
|
3569
|
+
const rect = [
|
|
3570
|
+
[clampX(opts.seedRect[0][0]), clampY(opts.seedRect[0][1])],
|
|
3571
|
+
[clampX(opts.seedRect[1][0]), clampY(opts.seedRect[1][1])]
|
|
3572
|
+
];
|
|
3573
|
+
if (!(Math.abs(rect[1][0] - rect[0][0]) >= 1 && Math.abs(rect[1][1] - rect[0][1]) >= 1)) {
|
|
3574
|
+
throw new UserError(`Empty seed rect \u2014 need two distinct corners in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
|
|
3575
|
+
}
|
|
3576
|
+
const sweepOpts = {
|
|
3577
|
+
rotations: opts.rotations ?? true,
|
|
3578
|
+
mirror: opts.mirror ?? true,
|
|
3579
|
+
tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
|
|
3580
|
+
};
|
|
3581
|
+
let res;
|
|
3582
|
+
try {
|
|
3583
|
+
res = sweepSymbols(geo.segs, rect, sweepOpts);
|
|
3584
|
+
} catch (e) {
|
|
3585
|
+
throw new UserError(e instanceof Error ? e.message : String(e));
|
|
3586
|
+
}
|
|
3587
|
+
let committed;
|
|
3588
|
+
if (opts.commit && res.matches.length) {
|
|
3589
|
+
committed = this.placeCount(name, res.matches.map((m) => m.at), {
|
|
3590
|
+
condition: opts.condition,
|
|
3591
|
+
tool: "symbol_sweep",
|
|
3592
|
+
origins: res.matches.map((m) => ({
|
|
3593
|
+
method: "symbol_sweep",
|
|
3594
|
+
actor: "agent",
|
|
3595
|
+
reviewed: false,
|
|
3596
|
+
symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored }
|
|
3597
|
+
}))
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
return {
|
|
3601
|
+
found: res.matches.length,
|
|
3602
|
+
matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
3603
|
+
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 })),
|
|
3604
|
+
seed: {
|
|
3605
|
+
segments: res.seed.segments,
|
|
3606
|
+
center: [round1(res.seed.center[0]), round1(res.seed.center[1])],
|
|
3607
|
+
rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
|
|
3608
|
+
length_px: res.seed.length_px
|
|
3609
|
+
},
|
|
3610
|
+
candidates: res.candidates,
|
|
3611
|
+
...committed ? {
|
|
3612
|
+
committed: committed.committed,
|
|
3613
|
+
shape_ids: committed.shape_ids,
|
|
3614
|
+
condition: committed.condition,
|
|
3615
|
+
ea_total: committed.ea_total
|
|
3616
|
+
} : {},
|
|
3617
|
+
...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
|
|
3618
|
+
...res.candidates.dropped > 0 ? { warning: `Work cap: ${res.candidates.dropped} candidate placement(s) were never scored \u2014 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.` } : {}
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3102
3621
|
/** The mid-session shape inventory (#149): every committed shape's id,
|
|
3103
3622
|
* home, role, and quantities in one compact read — the ids edit_shape /
|
|
3104
3623
|
* delete_shape assume you have, without pulling the whole export_takeoff
|
|
@@ -3418,7 +3937,14 @@ var Session = class _Session {
|
|
|
3418
3937
|
if ((a.type === "cloud" || a.type === "highlight") && !a.rect) throw new UserError(`a ${a.type} needs rect: [[x0,y0],[x1,y1]] in image px`);
|
|
3419
3938
|
if ((a.type === "text" || a.type === "callout" || a.type === "bubble") && !a.at) throw new UserError(`a ${a.type} needs at: [x,y] in image px`);
|
|
3420
3939
|
if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
|
|
3421
|
-
if (a.type === "arrow" && (!a.from || !a.to))
|
|
3940
|
+
if ((a.type === "arrow" || a.type === "dimension") && (!a.from || !a.to)) {
|
|
3941
|
+
throw new UserError(a.type === "arrow" ? "an arrow needs from: [x,y] and to: [x,y] \u2014 tail and head, in image px" : "a dimension needs from: [x,y] and to: [x,y] \u2014 its two measured endpoints, in image px");
|
|
3942
|
+
}
|
|
3943
|
+
let len_ft;
|
|
3944
|
+
if (a.type === "dimension") {
|
|
3945
|
+
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
3946
|
+
len_ft = round2(Math.hypot(a.to[0] - a.from[0], a.to[1] - a.from[1]) * s.upp);
|
|
3947
|
+
}
|
|
3422
3948
|
const cond = a.condition ? this.conditionFor(a.condition) : null;
|
|
3423
3949
|
const m = {
|
|
3424
3950
|
id: uid("mk"),
|
|
@@ -3435,7 +3961,10 @@ var Session = class _Session {
|
|
|
3435
3961
|
...a.to ? { to: n(a.to) } : {},
|
|
3436
3962
|
// bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
|
|
3437
3963
|
// canvas default is 0.02 when unset — stored explicitly so exports agree
|
|
3438
|
-
...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {}
|
|
3964
|
+
...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {},
|
|
3965
|
+
// dimension: the measured length rides the markup so the renderers
|
|
3966
|
+
// (canvas, marked set) draw the label without scale plumbing
|
|
3967
|
+
...len_ft !== void 0 ? { len_ft } : {}
|
|
3439
3968
|
};
|
|
3440
3969
|
this.markups.push(m);
|
|
3441
3970
|
return {
|
|
@@ -3445,6 +3974,7 @@ var Session = class _Session {
|
|
|
3445
3974
|
text: m.text,
|
|
3446
3975
|
condition: cond?.finish_tag ?? "",
|
|
3447
3976
|
condition_id: m.condition_id,
|
|
3977
|
+
...len_ft !== void 0 ? { length_lf: len_ft } : {},
|
|
3448
3978
|
note: cond ? `Attached to ${cond.finish_tag} \u2014 it wears that condition's colour on the canvas and in the marked set.` : "Unattached \u2014 a note about the sheet. Pass condition to tie it to a scope."
|
|
3449
3979
|
};
|
|
3450
3980
|
}
|
|
@@ -3482,7 +4012,8 @@ var Session = class _Session {
|
|
|
3482
4012
|
...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
|
|
3483
4013
|
...m.from ? { from: px(m, m.from) } : {},
|
|
3484
4014
|
...m.to ? { to: px(m, m.to) } : {},
|
|
3485
|
-
...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {}
|
|
4015
|
+
...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {},
|
|
4016
|
+
...m.len_ft != null ? { length_lf: m.len_ft } : {}
|
|
3486
4017
|
})),
|
|
3487
4018
|
count: rows.length,
|
|
3488
4019
|
unattached: rows.filter((m) => !m.condition_id).length
|
|
@@ -3745,6 +4276,7 @@ var oneClickOutput = {
|
|
|
3745
4276
|
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
3746
4277
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
3747
4278
|
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region \u2014 the rescue rides provenance (origin.gap_bridged_px) rather than passing as a clean fill"),
|
|
4279
|
+
raster_traced: z.literal(true).optional().describe("Present when the region was bounded by the sheet's RENDERED PIXELS (the scanned-sheet raster fallback, #154) rather than vector linework \u2014 absent means the vector path ran. Rides origin.raster_traced on the committed shape; a raster ring's corners are unsnapped (a scan has no true endpoints), so audit it with view_sheet overlay before trusting the total"),
|
|
3748
4280
|
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
3749
4281
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
3750
4282
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
@@ -3759,6 +4291,7 @@ var detectedRoom = z.object({
|
|
|
3759
4291
|
merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
|
|
3760
4292
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
3761
4293
|
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region"),
|
|
4294
|
+
raster_traced: z.literal(true).optional().describe("Present when the room was bounded by rendered pixels (scanned-sheet raster fallback, #154) rather than vector linework \u2014 sheet-wide per sweep, and it rides origin.raster_traced on the committed shape"),
|
|
3762
4295
|
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
3763
4296
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
3764
4297
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
@@ -3811,6 +4344,33 @@ var placeCountOutput = {
|
|
|
3811
4344
|
condition: z.string(),
|
|
3812
4345
|
ea_total: z.number().describe("The condition's total EA after this call")
|
|
3813
4346
|
};
|
|
4347
|
+
var sweepPlacement = {
|
|
4348
|
+
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"),
|
|
4349
|
+
score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
|
|
4350
|
+
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
4351
|
+
mirrored: z.boolean()
|
|
4352
|
+
};
|
|
4353
|
+
var symbolSweepOutput = {
|
|
4354
|
+
found: z.number().int().describe("Placements that cleared the commit bar \u2014 matches.length"),
|
|
4355
|
+
matches: z.array(z.object(sweepPlacement)).describe("Deterministic reading order (y, then x). The seed's own location is never listed here"),
|
|
4356
|
+
withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })).describe("Near-matches in the [0.75, 0.92) band \u2014 reported with a reason, NEVER committed. A withheld placement is a question you can answer with view_sheet; a hidden one is a miscount"),
|
|
4357
|
+
seed: z.object({
|
|
4358
|
+
segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
|
|
4359
|
+
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"),
|
|
4360
|
+
rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
|
|
4361
|
+
length_px: z.number().describe("Total seed linework length, image px")
|
|
4362
|
+
}),
|
|
4363
|
+
candidates: z.object({
|
|
4364
|
+
considered: z.number().int(),
|
|
4365
|
+
dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
|
|
4366
|
+
}),
|
|
4367
|
+
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
|
|
4368
|
+
shape_ids: z.array(z.string()).optional(),
|
|
4369
|
+
condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
|
|
4370
|
+
ea_total: z.number().optional().describe("commit mode: the condition's total EA after this call"),
|
|
4371
|
+
note: z.string().optional(),
|
|
4372
|
+
warning: z.string().optional().describe("Present when the work cap dropped candidates \u2014 what a tighter seed rect would recover")
|
|
4373
|
+
};
|
|
3814
4374
|
var measureLineOutput = {
|
|
3815
4375
|
length_lf: z.number(),
|
|
3816
4376
|
npts: z.number().int(),
|
|
@@ -4129,9 +4689,10 @@ var annotationRow = z.object({
|
|
|
4129
4689
|
at: z.tuple([z.number(), z.number()]).optional(),
|
|
4130
4690
|
target: z.tuple([z.number(), z.number()]).optional(),
|
|
4131
4691
|
rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional(),
|
|
4132
|
-
from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail (image px)"),
|
|
4133
|
-
to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head (image px)"),
|
|
4134
|
-
r: z.number().optional().describe("Bubble radius (image px)")
|
|
4692
|
+
from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail / dimension start (image px)"),
|
|
4693
|
+
to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head / dimension end (image px)"),
|
|
4694
|
+
r: z.number().optional().describe("Bubble radius (image px)"),
|
|
4695
|
+
length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
|
|
4135
4696
|
});
|
|
4136
4697
|
var annotateOutput = {
|
|
4137
4698
|
id: z.string(),
|
|
@@ -4140,6 +4701,7 @@ var annotateOutput = {
|
|
|
4140
4701
|
text: z.string(),
|
|
4141
4702
|
condition: z.string(),
|
|
4142
4703
|
condition_id: z.string(),
|
|
4704
|
+
length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
|
|
4143
4705
|
note: z.string()
|
|
4144
4706
|
};
|
|
4145
4707
|
var listAnnotationsOutput = {
|
|
@@ -4158,6 +4720,24 @@ var linkAnnotationOutput = {
|
|
|
4158
4720
|
import path3 from "node:path";
|
|
4159
4721
|
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
4160
4722
|
|
|
4723
|
+
// ../web/src/lib/approvals.js
|
|
4724
|
+
var APPROVAL_R = 0.022;
|
|
4725
|
+
var APPROVAL_INK = {
|
|
4726
|
+
estimator: { light: "#1f6b4a", dark: "#55b083" },
|
|
4727
|
+
agent: { light: "#6c6a5e", dark: "#9d9a8c" }
|
|
4728
|
+
};
|
|
4729
|
+
function approvalInk(actor, dark = false) {
|
|
4730
|
+
const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
|
|
4731
|
+
return dark ? c.dark : c.light;
|
|
4732
|
+
}
|
|
4733
|
+
function approvalTally(approvals) {
|
|
4734
|
+
const t = { estimator: 0, agent: 0 };
|
|
4735
|
+
for (const a of Array.isArray(approvals) ? approvals : []) {
|
|
4736
|
+
if (a && a.actor in t) t[a.actor] += 1;
|
|
4737
|
+
}
|
|
4738
|
+
return t;
|
|
4739
|
+
}
|
|
4740
|
+
|
|
4161
4741
|
// ../web/src/lib/svgpath.js
|
|
4162
4742
|
function isSep(c) {
|
|
4163
4743
|
return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
|
|
@@ -4813,7 +5393,7 @@ function invertPixels(cv) {
|
|
|
4813
5393
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
4814
5394
|
ctx.restore();
|
|
4815
5395
|
}
|
|
4816
|
-
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
|
|
5396
|
+
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, approvals = [], rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
|
|
4817
5397
|
const M = units === "metric";
|
|
4818
5398
|
const uA = (sf) => M ? sf * 0.09290304 : sf;
|
|
4819
5399
|
const uL = (lf) => M ? lf * 0.3048 : lf;
|
|
@@ -4830,8 +5410,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4830
5410
|
}
|
|
4831
5411
|
return m;
|
|
4832
5412
|
};
|
|
4833
|
-
const shapesBy = byKey(shapes), marksBy = byKey(markups);
|
|
4834
|
-
const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length);
|
|
5413
|
+
const shapesBy = byKey(shapes), marksBy = byKey(markups), apBy = byKey(approvals);
|
|
5414
|
+
const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length || (apBy.get(sh.key) || []).length);
|
|
4835
5415
|
if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
|
|
4836
5416
|
const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
|
|
4837
5417
|
const doc = await PDFDocument.create();
|
|
@@ -4907,6 +5487,11 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4907
5487
|
metaY -= 12;
|
|
4908
5488
|
draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
4909
5489
|
}
|
|
5490
|
+
const apCount = approvalTally(marked.flatMap((sh) => apBy.get(sh.key) || []));
|
|
5491
|
+
if (apCount.estimator || apCount.agent) {
|
|
5492
|
+
metaY -= 12;
|
|
5493
|
+
draw(`Approval stamps: ${apCount.estimator} estimator-approved \xB7 ${apCount.agent} agent-marked`, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
5494
|
+
}
|
|
4910
5495
|
let y = metaY - 34;
|
|
4911
5496
|
const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
|
|
4912
5497
|
draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
|
|
@@ -4935,7 +5520,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4935
5520
|
for (const sh of marked) {
|
|
4936
5521
|
if (y < 90) break;
|
|
4937
5522
|
const items = shapesBy.get(sh.key) || [];
|
|
4938
|
-
draw(`${sh.label} \xB7 page ${sh.page} \xB7 ${items.length + (marksBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
|
|
5523
|
+
draw(`${sh.label} \xB7 page ${sh.page} \xB7 ${items.length + (marksBy.get(sh.key) || []).length + (apBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
|
|
4939
5524
|
y -= 13;
|
|
4940
5525
|
for (const r of bySheetId.get(sh.key)?.rows || []) {
|
|
4941
5526
|
if (y < 92) break;
|
|
@@ -5177,6 +5762,21 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5177
5762
|
pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
|
|
5178
5763
|
const t = lbl(m.text);
|
|
5179
5764
|
if (t) text(t, (m.from[0] + m.to[0]) / 2 * W, (m.from[1] + m.to[1]) / 2 * H - 6 / ptScale, 8, mcol, bold);
|
|
5765
|
+
} else if (m.type === "dimension" && m.from && m.to) {
|
|
5766
|
+
const fx = m.from[0] * W, fy = m.from[1] * H, dxq = m.to[0] * W, dyq = m.to[1] * H;
|
|
5767
|
+
line(fx, fy, dxq, dyq, mcol, 1.1 * mw, 0.95, mdash);
|
|
5768
|
+
const dl = Math.hypot(dxq - fx, dyq - fy) || 1;
|
|
5769
|
+
const dnx = -(dyq - fy) / dl, dny = (dxq - fx) / dl;
|
|
5770
|
+
const tk = 5 / ptScale;
|
|
5771
|
+
line(fx - dnx * tk, fy - dny * tk, fx + dnx * tk, fy + dny * tk, mcol, 1.1 * mw, 0.95);
|
|
5772
|
+
line(dxq - dnx * tk, dyq - dny * tk, dxq + dnx * tk, dyq + dny * tk, mcol, 1.1 * mw, 0.95);
|
|
5773
|
+
const t = lbl([Number(m.len_ft) > 0 ? dimLabel(m.len_ft, M ? "metric" : "imperial") : "", m.text].filter(Boolean).join(" \xB7 "));
|
|
5774
|
+
if (t) {
|
|
5775
|
+
const size = 8;
|
|
5776
|
+
const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
|
|
5777
|
+
const [pmx, pmy] = toPage((fx + dxq) / 2 + dnx * (14 / ptScale), (fy + dyq) / 2 + dny * (14 / ptScale));
|
|
5778
|
+
pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
|
|
5779
|
+
}
|
|
5180
5780
|
} else if (m.type === "bubble" && m.at) {
|
|
5181
5781
|
const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
|
|
5182
5782
|
const [pcx, pcy] = toPage(cxImg, cyImg);
|
|
@@ -5213,6 +5813,26 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5213
5813
|
text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
|
|
5214
5814
|
}
|
|
5215
5815
|
}
|
|
5816
|
+
for (const a of apBy.get(sh.key) || []) {
|
|
5817
|
+
const isAgent = a.actor === "agent";
|
|
5818
|
+
const acol = rgb(...hex(approvalInk(a.actor, dark)));
|
|
5819
|
+
const cxImg = a.at[0] * W, cyImg = a.at[1] * H;
|
|
5820
|
+
const rImg = APPROVAL_R * W, rPt = rImg * ptScale;
|
|
5821
|
+
const [pcx, pcy] = toPage(cxImg, cyImg);
|
|
5822
|
+
const backing = dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1);
|
|
5823
|
+
if (isAgent) {
|
|
5824
|
+
const dia = (k) => [[cxImg, cyImg - rImg * k], [cxImg + rImg * k, cyImg], [cxImg, cyImg + rImg * k], [cxImg - rImg * k, cyImg]];
|
|
5825
|
+
pg.drawSvgPath(svgPath(dia(1)), { x: 0, y: 0, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
|
|
5826
|
+
pg.drawSvgPath(svgPath(dia(0.72)), { x: 0, y: 0, borderColor: acol, borderWidth: rPt * 0.035 });
|
|
5827
|
+
} else {
|
|
5828
|
+
pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt, yScale: rPt, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
|
|
5829
|
+
pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt * 0.78, yScale: rPt * 0.78, borderColor: acol, borderWidth: rPt * 0.035 });
|
|
5830
|
+
}
|
|
5831
|
+
const label = isAgent ? "AGENT" : "APPROVED";
|
|
5832
|
+
const size = rPt * (isAgent ? 0.3 : 0.26);
|
|
5833
|
+
const tw = bold.widthOfTextAtSize(label, size);
|
|
5834
|
+
pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
|
|
5835
|
+
}
|
|
5216
5836
|
text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
|
|
5217
5837
|
}
|
|
5218
5838
|
const allPages = doc.getPages();
|
|
@@ -5498,7 +6118,7 @@ function registerTools(server, session) {
|
|
|
5498
6118
|
return loaded;
|
|
5499
6119
|
}));
|
|
5500
6120
|
server.registerTool("sheet_info", {
|
|
5501
|
-
description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click
|
|
6121
|
+
description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click floods it when present; a scanned sheet falls back to rendered pixels, disclosed as raster_traced), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
|
|
5502
6122
|
inputSchema: { sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') },
|
|
5503
6123
|
outputSchema: sheetInfoOutput
|
|
5504
6124
|
}, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
|
|
@@ -5518,7 +6138,7 @@ function registerTools(server, session) {
|
|
|
5518
6138
|
return session.setScale(a.sheet, a);
|
|
5519
6139
|
}));
|
|
5520
6140
|
server.registerTool("one_click", {
|
|
5521
|
-
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
|
|
6141
|
+
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. SCANNED sheets work too (#154): where vectors can't bound the room (an image-only scan, or a scan wrapper whose only linework is the title block), the flood falls back automatically to the sheet's rendered pixels \u2014 same engine the canvas uses \u2014 and the reply plus the committed shape's origin carry raster_traced: true so a pixel-bounded ring is never mistaken for a vector-snapped one. Vector always wins where it works; a raster ring's corners are unsnapped, so audit it with view_sheet {overlay: true} before trusting the total. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
|
|
5522
6142
|
inputSchema: {
|
|
5523
6143
|
sheet: z2.string(),
|
|
5524
6144
|
x: z2.number(),
|
|
@@ -5532,7 +6152,7 @@ function registerTools(server, session) {
|
|
|
5532
6152
|
outputSchema: oneClickOutput
|
|
5533
6153
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
|
|
5534
6154
|
server.registerTool("detect_rooms", {
|
|
5535
|
-
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
6155
|
+
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
5536
6156
|
inputSchema: {
|
|
5537
6157
|
sheet: z2.string(),
|
|
5538
6158
|
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
@@ -5588,6 +6208,26 @@ function registerTools(server, session) {
|
|
|
5588
6208
|
},
|
|
5589
6209
|
outputSchema: placeCountOutput
|
|
5590
6210
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
6211
|
+
server.registerTool("symbol_sweep", {
|
|
6212
|
+
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 sheet's vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Work is capped and the cap is disclosed: a reply with candidates.dropped > 0 says exactly that some placements were never scored \u2014 tighten the seed rect around more distinctive geometry rather than trusting a truncated count. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep is ONE undo step, each marker carries origin.method "symbol_sweep" with its score and transform, and withheld placements are NEVER committed. No scale needed: EA is scale-free. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
|
|
6213
|
+
inputSchema: {
|
|
6214
|
+
sheet: z2.string(),
|
|
6215
|
+
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"),
|
|
6216
|
+
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"),
|
|
6217
|
+
commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
|
|
6218
|
+
rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
|
|
6219
|
+
mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
|
|
6220
|
+
tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
|
|
6221
|
+
},
|
|
6222
|
+
outputSchema: symbolSweepOutput
|
|
6223
|
+
}, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
|
|
6224
|
+
seedRect: a.seed_rect,
|
|
6225
|
+
condition: a.condition,
|
|
6226
|
+
commit: a.commit,
|
|
6227
|
+
rotations: a.rotations,
|
|
6228
|
+
mirror: a.mirror,
|
|
6229
|
+
tolerancePx: a.tolerance_px
|
|
6230
|
+
})));
|
|
5591
6231
|
server.registerTool("derive_base", {
|
|
5592
6232
|
description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
|
|
5593
6233
|
inputSchema: {
|
|
@@ -5785,21 +6425,21 @@ function registerTools(server, session) {
|
|
|
5785
6425
|
return reply;
|
|
5786
6426
|
});
|
|
5787
6427
|
server.registerTool("annotate", {
|
|
5788
|
-
description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text).
|
|
6428
|
+
description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text), dimension takes from:[x,y] and to:[x,y] (its two measured endpoints) and labels itself with the length between them at the sheet's scale \u2014 drawn as a dimension line with end ticks and the measurement centered. A dimension states a REAL length, so it is the one annotation the scale gate applies to: on an unscaled sheet it refuses exactly like the measure tools (set_scale first) rather than dressing a px figure up as feet. It still touches no quantity \u2014 a dimension is a note about a distance, not a takeoff line item.
|
|
5789
6429
|
|
|
5790
6430
|
Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like one_click/measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
|
|
5791
6431
|
|
|
5792
6432
|
No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
|
|
5793
6433
|
inputSchema: {
|
|
5794
6434
|
sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
|
|
5795
|
-
type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow
|
|
5796
|
-
text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'; a bubble's text draws centered in the circle"),
|
|
6435
|
+
type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble", "dimension"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow and dimension need from + to"),
|
|
6436
|
+
text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'; a bubble's text draws centered in the circle; a dimension appends it after the measured length"),
|
|
5797
6437
|
condition: z2.string().optional().describe("Finish tag to attach this note to, e.g. 'CPT-1' (minted on first use). Omit for an unattached sheet note"),
|
|
5798
6438
|
at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
|
|
5799
6439
|
target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
|
|
5800
6440
|
rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight"),
|
|
5801
|
-
from: pointSchema.optional().describe("Arrow tail (image px)"),
|
|
5802
|
-
to: pointSchema.optional().describe("Arrow head
|
|
6441
|
+
from: pointSchema.optional().describe("Arrow tail / dimension start (image px)"),
|
|
6442
|
+
to: pointSchema.optional().describe("Arrow head / dimension end (image px)"),
|
|
5803
6443
|
r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
|
|
5804
6444
|
},
|
|
5805
6445
|
outputSchema: annotateOutput
|
|
@@ -5901,7 +6541,7 @@ function registerResources(server, session) {
|
|
|
5901
6541
|
// package.json
|
|
5902
6542
|
var package_default = {
|
|
5903
6543
|
name: "opentakeoff-mcp",
|
|
5904
|
-
version: "0.9.
|
|
6544
|
+
version: "0.9.21",
|
|
5905
6545
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
5906
6546
|
type: "module",
|
|
5907
6547
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -5917,7 +6557,7 @@ var package_default = {
|
|
|
5917
6557
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
5918
6558
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
5919
6559
|
typecheck: "tsc --noEmit",
|
|
5920
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
6560
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
5921
6561
|
},
|
|
5922
6562
|
dependencies: {
|
|
5923
6563
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
19
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|