opentakeoff-mcp 0.9.19 → 0.9.20
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 -1
- package/dist/server-core.js +466 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
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. |
|
package/dist/server-core.js
CHANGED
|
@@ -1316,6 +1316,235 @@ var fail = (err) => ({
|
|
|
1316
1316
|
var round2 = (n) => +n.toFixed(2);
|
|
1317
1317
|
var round1 = (n) => +n.toFixed(1);
|
|
1318
1318
|
|
|
1319
|
+
// ../web/src/lib/symbolsweep.ts
|
|
1320
|
+
var SWEEP_TOL_PX = 2;
|
|
1321
|
+
var SWEEP_SCORE_HIGH = 0.92;
|
|
1322
|
+
var SWEEP_SCORE_LOW = 0.75;
|
|
1323
|
+
var SWEEP_MAX_CANDIDATES = 2e4;
|
|
1324
|
+
var ANCHOR_COUNT = 3;
|
|
1325
|
+
var MIN_SEG_LEN = 0.5;
|
|
1326
|
+
var MAX_SEED_SEGS = 2e3;
|
|
1327
|
+
function transformsFor(rotations, mirror) {
|
|
1328
|
+
const rots = [
|
|
1329
|
+
[0, [1, 0, 0, 1]],
|
|
1330
|
+
[90, [0, -1, 1, 0]],
|
|
1331
|
+
[180, [-1, 0, 0, -1]],
|
|
1332
|
+
[270, [0, 1, -1, 0]]
|
|
1333
|
+
];
|
|
1334
|
+
const use = rotations ? rots : rots.slice(0, 1);
|
|
1335
|
+
const out = use.map(([rotation, m]) => ({ rotation, mirrored: false, m }));
|
|
1336
|
+
if (mirror) {
|
|
1337
|
+
for (const [rotation, m] of use) out.push({ rotation, mirrored: true, m: [-m[0], m[1], -m[2], m[3]] });
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
}
|
|
1341
|
+
var apply = (m, x, y) => [m[0] * x + m[1] * y, m[2] * x + m[3] * y];
|
|
1342
|
+
var EndpointGrid = class {
|
|
1343
|
+
constructor(segs, tol) {
|
|
1344
|
+
this.segs = segs;
|
|
1345
|
+
this.cell = Math.max(2 * tol, 4);
|
|
1346
|
+
const n = segs.length >> 2;
|
|
1347
|
+
for (let i = 0; i < n; i++) {
|
|
1348
|
+
this.add(segs[i * 4], segs[i * 4 + 1], i);
|
|
1349
|
+
this.add(segs[i * 4 + 2], segs[i * 4 + 3], i);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
cells = /* @__PURE__ */ new Map();
|
|
1353
|
+
cell;
|
|
1354
|
+
key(cx, cy) {
|
|
1355
|
+
return cx * 73856093 ^ cy * 19349663;
|
|
1356
|
+
}
|
|
1357
|
+
add(x, y, i) {
|
|
1358
|
+
const k = this.key(Math.floor(x / this.cell), Math.floor(y / this.cell));
|
|
1359
|
+
const a = this.cells.get(k);
|
|
1360
|
+
if (a) {
|
|
1361
|
+
if (a[a.length - 1] !== i) a.push(i);
|
|
1362
|
+
} else this.cells.set(k, [i]);
|
|
1363
|
+
}
|
|
1364
|
+
/** Indices of segments with an endpoint within one cell of (x, y). */
|
|
1365
|
+
near(x, y, out) {
|
|
1366
|
+
out.length = 0;
|
|
1367
|
+
const cx = Math.floor(x / this.cell), cy = Math.floor(y / this.cell);
|
|
1368
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
1369
|
+
const a = this.cells.get(this.key(cx + dx, cy + dy));
|
|
1370
|
+
if (a) for (const i of a) out.push(i);
|
|
1371
|
+
}
|
|
1372
|
+
return out;
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
1376
|
+
function sweepSymbols(segs, seedRect, opts = {}) {
|
|
1377
|
+
const tol = opts.tolPx ?? SWEEP_TOL_PX;
|
|
1378
|
+
const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
|
|
1379
|
+
const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
|
|
1380
|
+
const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
|
|
1381
|
+
const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
|
|
1382
|
+
const n = segs.length >> 2;
|
|
1383
|
+
const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
|
|
1384
|
+
const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
|
|
1385
|
+
const inside = (x, y) => x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1;
|
|
1386
|
+
const seedIdx = [];
|
|
1387
|
+
for (let i = 0; i < n; i++) {
|
|
1388
|
+
if (inside(segs[i * 4], segs[i * 4 + 1]) && inside(segs[i * 4 + 2], segs[i * 4 + 3]) && segLen(segs, i) >= MIN_SEG_LEN) {
|
|
1389
|
+
seedIdx.push(i);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (!seedIdx.length) {
|
|
1393
|
+
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).");
|
|
1394
|
+
}
|
|
1395
|
+
if (seedIdx.length > MAX_SEED_SEGS) {
|
|
1396
|
+
throw new Error(`The seed rect holds ${seedIdx.length} segments \u2014 that is a region, not one symbol instance. Marquee a single symbol.`);
|
|
1397
|
+
}
|
|
1398
|
+
let totalLen = 0, cxw = 0, cyw = 0;
|
|
1399
|
+
for (const i of seedIdx) {
|
|
1400
|
+
const L = segLen(segs, i);
|
|
1401
|
+
totalLen += L;
|
|
1402
|
+
cxw += (segs[i * 4] + segs[i * 4 + 2]) / 2 * L;
|
|
1403
|
+
cyw += (segs[i * 4 + 1] + segs[i * 4 + 3]) / 2 * L;
|
|
1404
|
+
}
|
|
1405
|
+
const seedCx = cxw / totalLen, seedCy = cyw / totalLen;
|
|
1406
|
+
const rel = seedIdx.map((i) => [
|
|
1407
|
+
segs[i * 4] - seedCx,
|
|
1408
|
+
segs[i * 4 + 1] - seedCy,
|
|
1409
|
+
segs[i * 4 + 2] - seedCx,
|
|
1410
|
+
segs[i * 4 + 3] - seedCy,
|
|
1411
|
+
segLen(segs, i)
|
|
1412
|
+
]);
|
|
1413
|
+
let sbx0 = Infinity, sby0 = Infinity, sbx1 = -Infinity, sby1 = -Infinity;
|
|
1414
|
+
for (const i of seedIdx) {
|
|
1415
|
+
sbx0 = Math.min(sbx0, segs[i * 4], segs[i * 4 + 2]);
|
|
1416
|
+
sby0 = Math.min(sby0, segs[i * 4 + 1], segs[i * 4 + 3]);
|
|
1417
|
+
sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
|
|
1418
|
+
sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
|
|
1419
|
+
}
|
|
1420
|
+
const lenBucket = /* @__PURE__ */ new Map();
|
|
1421
|
+
for (let i = 0; i < n; i++) {
|
|
1422
|
+
const b = Math.round(segLen(segs, i));
|
|
1423
|
+
const a = lenBucket.get(b);
|
|
1424
|
+
if (a) a.push(i);
|
|
1425
|
+
else lenBucket.set(b, [i]);
|
|
1426
|
+
}
|
|
1427
|
+
const bucketBand = (L) => {
|
|
1428
|
+
const out = [];
|
|
1429
|
+
for (let b = Math.floor(L - 2 * tol); b <= Math.ceil(L + 2 * tol); b++) {
|
|
1430
|
+
const a = lenBucket.get(b);
|
|
1431
|
+
if (a) {
|
|
1432
|
+
for (const i of a) if (Math.abs(segLen(segs, i) - L) <= 2 * tol) out.push(i);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return out;
|
|
1436
|
+
};
|
|
1437
|
+
const byLenQ = /* @__PURE__ */ new Map();
|
|
1438
|
+
for (let k = 0; k < rel.length; k++) {
|
|
1439
|
+
const L = rel[k][4];
|
|
1440
|
+
const q = Math.round(L);
|
|
1441
|
+
if (!byLenQ.has(q)) byLenQ.set(q, { relIdx: k, len: L, rarity: bucketBand(L).length });
|
|
1442
|
+
}
|
|
1443
|
+
const anchors = [...byLenQ.values()].sort((a, b) => a.rarity - b.rarity || b.len - a.len || a.relIdx - b.relIdx).slice(0, ANCHOR_COUNT);
|
|
1444
|
+
const proposals = [];
|
|
1445
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1446
|
+
const quant = Math.max(tol, 1);
|
|
1447
|
+
for (let xi = 0; xi < xforms.length; xi++) {
|
|
1448
|
+
const { m } = xforms[xi];
|
|
1449
|
+
for (const anc of anchors) {
|
|
1450
|
+
const r = rel[anc.relIdx];
|
|
1451
|
+
const A = apply(m, r[0], r[1]);
|
|
1452
|
+
const B = apply(m, r[2], r[3]);
|
|
1453
|
+
for (const j of bucketBand(anc.len)) {
|
|
1454
|
+
const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
|
|
1455
|
+
for (const [ax, ay, bx, by] of [[A[0], A[1], B[0], B[1]], [B[0], B[1], A[0], A[1]]]) {
|
|
1456
|
+
const c1x = px - ax, c1y = py - ay;
|
|
1457
|
+
const c2x = qx - bx, c2y = qy - by;
|
|
1458
|
+
if (Math.abs(c1x - c2x) > 2 * tol || Math.abs(c1y - c2y) > 2 * tol) continue;
|
|
1459
|
+
const tx = (c1x + c2x) / 2, ty = (c1y + c2y) / 2;
|
|
1460
|
+
const key = `${xi}:${Math.round(tx / quant)}:${Math.round(ty / quant)}`;
|
|
1461
|
+
if (seen.has(key)) continue;
|
|
1462
|
+
seen.add(key);
|
|
1463
|
+
proposals.push({ tx, ty, xf: xi });
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
proposals.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.xf - b.xf);
|
|
1469
|
+
const considered = Math.min(proposals.length, maxCandidates);
|
|
1470
|
+
const dropped = proposals.length - considered;
|
|
1471
|
+
const grid = new EndpointGrid(segs, tol);
|
|
1472
|
+
const scratch = [];
|
|
1473
|
+
const tol2 = tol * tol;
|
|
1474
|
+
const near = (x1, y1, x2, y2) => (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) <= tol2;
|
|
1475
|
+
const scoreAt = (m, tx, ty) => {
|
|
1476
|
+
let matched = 0;
|
|
1477
|
+
for (const r of rel) {
|
|
1478
|
+
const a = apply(m, r[0], r[1]);
|
|
1479
|
+
const b = apply(m, r[2], r[3]);
|
|
1480
|
+
const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
|
|
1481
|
+
let hit = false;
|
|
1482
|
+
for (const j of grid.near(ax, ay, scratch)) {
|
|
1483
|
+
const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
|
|
1484
|
+
if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
|
|
1485
|
+
hit = true;
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (hit) matched += r[4];
|
|
1490
|
+
}
|
|
1491
|
+
return matched / totalLen;
|
|
1492
|
+
};
|
|
1493
|
+
const scored = [];
|
|
1494
|
+
for (let k = 0; k < considered; k++) {
|
|
1495
|
+
const c = proposals[k];
|
|
1496
|
+
const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
|
|
1497
|
+
if (score < scoreLow) continue;
|
|
1498
|
+
scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
|
|
1499
|
+
}
|
|
1500
|
+
const mergeR = Math.max(2 * tol, 4);
|
|
1501
|
+
const kept = [];
|
|
1502
|
+
for (const s of scored) {
|
|
1503
|
+
const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
|
|
1504
|
+
if (!twin) {
|
|
1505
|
+
kept.push(s);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
|
|
1509
|
+
twin.at = s.at;
|
|
1510
|
+
twin.score = s.score;
|
|
1511
|
+
twin.rotation = s.rotation;
|
|
1512
|
+
twin.mirrored = s.mirrored;
|
|
1513
|
+
twin.xf = s.xf;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const suppressR = Math.max(mergeR, Math.hypot(sbx1 - sbx0, sby1 - sby0) / 2);
|
|
1517
|
+
const away = kept.filter((s) => Math.hypot(s.at[0] - seedCx, s.at[1] - seedCy) > suppressR);
|
|
1518
|
+
const matches = [];
|
|
1519
|
+
const withheld = [];
|
|
1520
|
+
const pct = (v) => Math.round(v * 1e3) / 1e3;
|
|
1521
|
+
const row = (s) => ({
|
|
1522
|
+
at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
|
|
1523
|
+
score: pct(s.score),
|
|
1524
|
+
rotation: s.rotation,
|
|
1525
|
+
mirrored: s.mirrored
|
|
1526
|
+
});
|
|
1527
|
+
for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
|
|
1528
|
+
for (const s of away) {
|
|
1529
|
+
if (s.score >= scoreHigh) continue;
|
|
1530
|
+
if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
|
|
1531
|
+
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` });
|
|
1532
|
+
}
|
|
1533
|
+
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);
|
|
1534
|
+
matches.sort(order);
|
|
1535
|
+
withheld.sort(order);
|
|
1536
|
+
return {
|
|
1537
|
+
seed: {
|
|
1538
|
+
segments: seedIdx.length,
|
|
1539
|
+
center: [Math.round(seedCx * 10) / 10, Math.round(seedCy * 10) / 10],
|
|
1540
|
+
length_px: Math.round(totalLen * 10) / 10
|
|
1541
|
+
},
|
|
1542
|
+
matches,
|
|
1543
|
+
withheld,
|
|
1544
|
+
candidates: { considered, dropped }
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1319
1548
|
// ../web/src/lib/geometry.js
|
|
1320
1549
|
function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
|
|
1321
1550
|
const r = R * innerRatio;
|
|
@@ -1463,6 +1692,21 @@ function attrValue(attrs, colId) {
|
|
|
1463
1692
|
return visible(attrs?.[colId]);
|
|
1464
1693
|
}
|
|
1465
1694
|
|
|
1695
|
+
// ../web/src/lib/units.ts
|
|
1696
|
+
var M_PER_FT = 0.3048;
|
|
1697
|
+
function dimLabel(feet, units = "imperial") {
|
|
1698
|
+
if (!Number.isFinite(feet)) return "";
|
|
1699
|
+
if (units === "metric") return `${(feet * M_PER_FT).toFixed(2)} m`;
|
|
1700
|
+
const sign = feet < 0 ? "-" : "";
|
|
1701
|
+
let ft = Math.floor(Math.abs(feet) + 1e-9);
|
|
1702
|
+
let inch = Math.round((Math.abs(feet) - ft) * 12);
|
|
1703
|
+
if (inch === 12) {
|
|
1704
|
+
ft += 1;
|
|
1705
|
+
inch = 0;
|
|
1706
|
+
}
|
|
1707
|
+
return `${sign}${ft}'-${inch}"`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1466
1710
|
// ../web/src/lib/totals.js
|
|
1467
1711
|
function accumulateRole(acc, s) {
|
|
1468
1712
|
const cp = s.computed || {};
|
|
@@ -3090,15 +3334,99 @@ var Session = class _Session {
|
|
|
3090
3334
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
3091
3335
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
3092
3336
|
* the whole call one journal gesture — undoing a placement sweep is one step,
|
|
3093
|
-
* matching detect_rooms.
|
|
3337
|
+
* matching detect_rooms.
|
|
3338
|
+
*
|
|
3339
|
+
* `origins` (optional, aligned with points) lets a derived tool commit
|
|
3340
|
+
* through this same path while telling the truth about the method —
|
|
3341
|
+
* symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
|
|
3342
|
+
* marker; a bare place_count stays exactly the manual agent gesture it is. */
|
|
3094
3343
|
placeCount(name, points, opts) {
|
|
3095
3344
|
const s = this.sheet(name);
|
|
3096
|
-
const ids = points.map(([x, y]) => this.commit(
|
|
3097
|
-
|
|
3345
|
+
const ids = points.map(([x, y], i) => this.commit(
|
|
3346
|
+
s,
|
|
3347
|
+
opts.condition,
|
|
3348
|
+
"count",
|
|
3349
|
+
[[x, y]],
|
|
3350
|
+
{ count: 1 },
|
|
3351
|
+
opts.origins?.[i] ? { ...opts.origins[i] } : { method: "manual", actor: "agent" }
|
|
3352
|
+
).id);
|
|
3353
|
+
this.flushCommits(opts.tool ?? "place_count");
|
|
3098
3354
|
const c = this.conditions.find((x) => x.finish_tag === opts.condition);
|
|
3099
3355
|
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
3356
|
return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
|
|
3101
3357
|
}
|
|
3358
|
+
/** symbol_sweep — every placement of ONE example symbol, from the linework.
|
|
3359
|
+
* The engine is pure (web/src/lib/symbolsweep.ts): fingerprint the seed
|
|
3360
|
+
* rect's segments, propose placements by constellation anchoring under the
|
|
3361
|
+
* square symmetry group, score each as the length-weighted fraction of seed
|
|
3362
|
+
* segments reproduced within tolerance. This method is the plumbing plus
|
|
3363
|
+
* the wire shapes: rect clamping, the scan refusal, and the commit path —
|
|
3364
|
+
* match centers through placeCount (ONE undo step, EA scale-free), origins
|
|
3365
|
+
* telling the truth (`method: "symbol_sweep"`, per-match score/transform),
|
|
3366
|
+
* withheld placements NEVER committed. */
|
|
3367
|
+
async symbolSweep(name, opts) {
|
|
3368
|
+
const s = this.sheet(name);
|
|
3369
|
+
if (opts.commit && !opts.condition) {
|
|
3370
|
+
throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
|
|
3371
|
+
}
|
|
3372
|
+
const geo = await this.ensureGeometry(s);
|
|
3373
|
+
if (!geo.segs.length) {
|
|
3374
|
+
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.");
|
|
3375
|
+
}
|
|
3376
|
+
const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
|
|
3377
|
+
const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
|
|
3378
|
+
const rect = [
|
|
3379
|
+
[clampX(opts.seedRect[0][0]), clampY(opts.seedRect[0][1])],
|
|
3380
|
+
[clampX(opts.seedRect[1][0]), clampY(opts.seedRect[1][1])]
|
|
3381
|
+
];
|
|
3382
|
+
if (!(Math.abs(rect[1][0] - rect[0][0]) >= 1 && Math.abs(rect[1][1] - rect[0][1]) >= 1)) {
|
|
3383
|
+
throw new UserError(`Empty seed rect \u2014 need two distinct corners in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
|
|
3384
|
+
}
|
|
3385
|
+
const sweepOpts = {
|
|
3386
|
+
rotations: opts.rotations ?? true,
|
|
3387
|
+
mirror: opts.mirror ?? true,
|
|
3388
|
+
tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
|
|
3389
|
+
};
|
|
3390
|
+
let res;
|
|
3391
|
+
try {
|
|
3392
|
+
res = sweepSymbols(geo.segs, rect, sweepOpts);
|
|
3393
|
+
} catch (e) {
|
|
3394
|
+
throw new UserError(e instanceof Error ? e.message : String(e));
|
|
3395
|
+
}
|
|
3396
|
+
let committed;
|
|
3397
|
+
if (opts.commit && res.matches.length) {
|
|
3398
|
+
committed = this.placeCount(name, res.matches.map((m) => m.at), {
|
|
3399
|
+
condition: opts.condition,
|
|
3400
|
+
tool: "symbol_sweep",
|
|
3401
|
+
origins: res.matches.map((m) => ({
|
|
3402
|
+
method: "symbol_sweep",
|
|
3403
|
+
actor: "agent",
|
|
3404
|
+
reviewed: false,
|
|
3405
|
+
symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored }
|
|
3406
|
+
}))
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
return {
|
|
3410
|
+
found: res.matches.length,
|
|
3411
|
+
matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
3412
|
+
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 })),
|
|
3413
|
+
seed: {
|
|
3414
|
+
segments: res.seed.segments,
|
|
3415
|
+
center: [round1(res.seed.center[0]), round1(res.seed.center[1])],
|
|
3416
|
+
rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
|
|
3417
|
+
length_px: res.seed.length_px
|
|
3418
|
+
},
|
|
3419
|
+
candidates: res.candidates,
|
|
3420
|
+
...committed ? {
|
|
3421
|
+
committed: committed.committed,
|
|
3422
|
+
shape_ids: committed.shape_ids,
|
|
3423
|
+
condition: committed.condition,
|
|
3424
|
+
ea_total: committed.ea_total
|
|
3425
|
+
} : {},
|
|
3426
|
+
...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
|
|
3427
|
+
...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.` } : {}
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3102
3430
|
/** The mid-session shape inventory (#149): every committed shape's id,
|
|
3103
3431
|
* home, role, and quantities in one compact read — the ids edit_shape /
|
|
3104
3432
|
* delete_shape assume you have, without pulling the whole export_takeoff
|
|
@@ -3418,7 +3746,14 @@ var Session = class _Session {
|
|
|
3418
3746
|
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
3747
|
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
3748
|
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))
|
|
3749
|
+
if ((a.type === "arrow" || a.type === "dimension") && (!a.from || !a.to)) {
|
|
3750
|
+
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");
|
|
3751
|
+
}
|
|
3752
|
+
let len_ft;
|
|
3753
|
+
if (a.type === "dimension") {
|
|
3754
|
+
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
3755
|
+
len_ft = round2(Math.hypot(a.to[0] - a.from[0], a.to[1] - a.from[1]) * s.upp);
|
|
3756
|
+
}
|
|
3422
3757
|
const cond = a.condition ? this.conditionFor(a.condition) : null;
|
|
3423
3758
|
const m = {
|
|
3424
3759
|
id: uid("mk"),
|
|
@@ -3435,7 +3770,10 @@ var Session = class _Session {
|
|
|
3435
3770
|
...a.to ? { to: n(a.to) } : {},
|
|
3436
3771
|
// bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
|
|
3437
3772
|
// 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 } : {}
|
|
3773
|
+
...a.type === "bubble" ? { r: a.r != null ? a.r / s.widthPx : 0.02 } : {},
|
|
3774
|
+
// dimension: the measured length rides the markup so the renderers
|
|
3775
|
+
// (canvas, marked set) draw the label without scale plumbing
|
|
3776
|
+
...len_ft !== void 0 ? { len_ft } : {}
|
|
3439
3777
|
};
|
|
3440
3778
|
this.markups.push(m);
|
|
3441
3779
|
return {
|
|
@@ -3445,6 +3783,7 @@ var Session = class _Session {
|
|
|
3445
3783
|
text: m.text,
|
|
3446
3784
|
condition: cond?.finish_tag ?? "",
|
|
3447
3785
|
condition_id: m.condition_id,
|
|
3786
|
+
...len_ft !== void 0 ? { length_lf: len_ft } : {},
|
|
3448
3787
|
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
3788
|
};
|
|
3450
3789
|
}
|
|
@@ -3482,7 +3821,8 @@ var Session = class _Session {
|
|
|
3482
3821
|
...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
|
|
3483
3822
|
...m.from ? { from: px(m, m.from) } : {},
|
|
3484
3823
|
...m.to ? { to: px(m, m.to) } : {},
|
|
3485
|
-
...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {}
|
|
3824
|
+
...m.r != null ? { r: round1(m.r * (s0.get(m.sheet_id)?.widthPx ?? 0)) } : {},
|
|
3825
|
+
...m.len_ft != null ? { length_lf: m.len_ft } : {}
|
|
3486
3826
|
})),
|
|
3487
3827
|
count: rows.length,
|
|
3488
3828
|
unattached: rows.filter((m) => !m.condition_id).length
|
|
@@ -3811,6 +4151,33 @@ var placeCountOutput = {
|
|
|
3811
4151
|
condition: z.string(),
|
|
3812
4152
|
ea_total: z.number().describe("The condition's total EA after this call")
|
|
3813
4153
|
};
|
|
4154
|
+
var sweepPlacement = {
|
|
4155
|
+
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"),
|
|
4156
|
+
score: z.number().describe("Length-weighted fraction of the seed's segments matched within tolerance, 0..1"),
|
|
4157
|
+
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
4158
|
+
mirrored: z.boolean()
|
|
4159
|
+
};
|
|
4160
|
+
var symbolSweepOutput = {
|
|
4161
|
+
found: z.number().int().describe("Placements that cleared the commit bar \u2014 matches.length"),
|
|
4162
|
+
matches: z.array(z.object(sweepPlacement)).describe("Deterministic reading order (y, then x). The seed's own location is never listed here"),
|
|
4163
|
+
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"),
|
|
4164
|
+
seed: z.object({
|
|
4165
|
+
segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
|
|
4166
|
+
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"),
|
|
4167
|
+
rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
|
|
4168
|
+
length_px: z.number().describe("Total seed linework length, image px")
|
|
4169
|
+
}),
|
|
4170
|
+
candidates: z.object({
|
|
4171
|
+
considered: z.number().int(),
|
|
4172
|
+
dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
|
|
4173
|
+
}),
|
|
4174
|
+
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
|
|
4175
|
+
shape_ids: z.array(z.string()).optional(),
|
|
4176
|
+
condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
|
|
4177
|
+
ea_total: z.number().optional().describe("commit mode: the condition's total EA after this call"),
|
|
4178
|
+
note: z.string().optional(),
|
|
4179
|
+
warning: z.string().optional().describe("Present when the work cap dropped candidates \u2014 what a tighter seed rect would recover")
|
|
4180
|
+
};
|
|
3814
4181
|
var measureLineOutput = {
|
|
3815
4182
|
length_lf: z.number(),
|
|
3816
4183
|
npts: z.number().int(),
|
|
@@ -4129,9 +4496,10 @@ var annotationRow = z.object({
|
|
|
4129
4496
|
at: z.tuple([z.number(), z.number()]).optional(),
|
|
4130
4497
|
target: z.tuple([z.number(), z.number()]).optional(),
|
|
4131
4498
|
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)")
|
|
4499
|
+
from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail / dimension start (image px)"),
|
|
4500
|
+
to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head / dimension end (image px)"),
|
|
4501
|
+
r: z.number().optional().describe("Bubble radius (image px)"),
|
|
4502
|
+
length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
|
|
4135
4503
|
});
|
|
4136
4504
|
var annotateOutput = {
|
|
4137
4505
|
id: z.string(),
|
|
@@ -4140,6 +4508,7 @@ var annotateOutput = {
|
|
|
4140
4508
|
text: z.string(),
|
|
4141
4509
|
condition: z.string(),
|
|
4142
4510
|
condition_id: z.string(),
|
|
4511
|
+
length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
|
|
4143
4512
|
note: z.string()
|
|
4144
4513
|
};
|
|
4145
4514
|
var listAnnotationsOutput = {
|
|
@@ -4158,6 +4527,24 @@ var linkAnnotationOutput = {
|
|
|
4158
4527
|
import path3 from "node:path";
|
|
4159
4528
|
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
4160
4529
|
|
|
4530
|
+
// ../web/src/lib/approvals.js
|
|
4531
|
+
var APPROVAL_R = 0.022;
|
|
4532
|
+
var APPROVAL_INK = {
|
|
4533
|
+
estimator: { light: "#1f6b4a", dark: "#55b083" },
|
|
4534
|
+
agent: { light: "#6c6a5e", dark: "#9d9a8c" }
|
|
4535
|
+
};
|
|
4536
|
+
function approvalInk(actor, dark = false) {
|
|
4537
|
+
const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
|
|
4538
|
+
return dark ? c.dark : c.light;
|
|
4539
|
+
}
|
|
4540
|
+
function approvalTally(approvals) {
|
|
4541
|
+
const t = { estimator: 0, agent: 0 };
|
|
4542
|
+
for (const a of Array.isArray(approvals) ? approvals : []) {
|
|
4543
|
+
if (a && a.actor in t) t[a.actor] += 1;
|
|
4544
|
+
}
|
|
4545
|
+
return t;
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4161
4548
|
// ../web/src/lib/svgpath.js
|
|
4162
4549
|
function isSep(c) {
|
|
4163
4550
|
return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
|
|
@@ -4813,7 +5200,7 @@ function invertPixels(cv) {
|
|
|
4813
5200
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
4814
5201
|
ctx.restore();
|
|
4815
5202
|
}
|
|
4816
|
-
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
|
|
5203
|
+
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
5204
|
const M = units === "metric";
|
|
4818
5205
|
const uA = (sf) => M ? sf * 0.09290304 : sf;
|
|
4819
5206
|
const uL = (lf) => M ? lf * 0.3048 : lf;
|
|
@@ -4830,8 +5217,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4830
5217
|
}
|
|
4831
5218
|
return m;
|
|
4832
5219
|
};
|
|
4833
|
-
const shapesBy = byKey(shapes), marksBy = byKey(markups);
|
|
4834
|
-
const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length);
|
|
5220
|
+
const shapesBy = byKey(shapes), marksBy = byKey(markups), apBy = byKey(approvals);
|
|
5221
|
+
const marked = sheets.filter((sh) => (shapesBy.get(sh.key) || []).length || (marksBy.get(sh.key) || []).length || (apBy.get(sh.key) || []).length);
|
|
4835
5222
|
if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
|
|
4836
5223
|
const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
|
|
4837
5224
|
const doc = await PDFDocument.create();
|
|
@@ -4907,6 +5294,11 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4907
5294
|
metaY -= 12;
|
|
4908
5295
|
draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
4909
5296
|
}
|
|
5297
|
+
const apCount = approvalTally(marked.flatMap((sh) => apBy.get(sh.key) || []));
|
|
5298
|
+
if (apCount.estimator || apCount.agent) {
|
|
5299
|
+
metaY -= 12;
|
|
5300
|
+
draw(`Approval stamps: ${apCount.estimator} estimator-approved \xB7 ${apCount.agent} agent-marked`, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
5301
|
+
}
|
|
4910
5302
|
let y = metaY - 34;
|
|
4911
5303
|
const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
|
|
4912
5304
|
draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
|
|
@@ -4935,7 +5327,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4935
5327
|
for (const sh of marked) {
|
|
4936
5328
|
if (y < 90) break;
|
|
4937
5329
|
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 });
|
|
5330
|
+
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
5331
|
y -= 13;
|
|
4940
5332
|
for (const r of bySheetId.get(sh.key)?.rows || []) {
|
|
4941
5333
|
if (y < 92) break;
|
|
@@ -5177,6 +5569,21 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5177
5569
|
pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
|
|
5178
5570
|
const t = lbl(m.text);
|
|
5179
5571
|
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);
|
|
5572
|
+
} else if (m.type === "dimension" && m.from && m.to) {
|
|
5573
|
+
const fx = m.from[0] * W, fy = m.from[1] * H, dxq = m.to[0] * W, dyq = m.to[1] * H;
|
|
5574
|
+
line(fx, fy, dxq, dyq, mcol, 1.1 * mw, 0.95, mdash);
|
|
5575
|
+
const dl = Math.hypot(dxq - fx, dyq - fy) || 1;
|
|
5576
|
+
const dnx = -(dyq - fy) / dl, dny = (dxq - fx) / dl;
|
|
5577
|
+
const tk = 5 / ptScale;
|
|
5578
|
+
line(fx - dnx * tk, fy - dny * tk, fx + dnx * tk, fy + dny * tk, mcol, 1.1 * mw, 0.95);
|
|
5579
|
+
line(dxq - dnx * tk, dyq - dny * tk, dxq + dnx * tk, dyq + dny * tk, mcol, 1.1 * mw, 0.95);
|
|
5580
|
+
const t = lbl([Number(m.len_ft) > 0 ? dimLabel(m.len_ft, M ? "metric" : "imperial") : "", m.text].filter(Boolean).join(" \xB7 "));
|
|
5581
|
+
if (t) {
|
|
5582
|
+
const size = 8;
|
|
5583
|
+
const tw = bold.widthOfTextAtSize(winAnsiSafe(t), size);
|
|
5584
|
+
const [pmx, pmy] = toPage((fx + dxq) / 2 + dnx * (14 / ptScale), (fy + dyq) / 2 + dny * (14 / ptScale));
|
|
5585
|
+
pg.drawText(winAnsiSafe(t), { x: pmx - tw / 2, y: pmy - size / 2.7, size, font: bold, color: mcol, rotate: chipRot });
|
|
5586
|
+
}
|
|
5180
5587
|
} else if (m.type === "bubble" && m.at) {
|
|
5181
5588
|
const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
|
|
5182
5589
|
const [pcx, pcy] = toPage(cxImg, cyImg);
|
|
@@ -5213,6 +5620,26 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5213
5620
|
text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
|
|
5214
5621
|
}
|
|
5215
5622
|
}
|
|
5623
|
+
for (const a of apBy.get(sh.key) || []) {
|
|
5624
|
+
const isAgent = a.actor === "agent";
|
|
5625
|
+
const acol = rgb(...hex(approvalInk(a.actor, dark)));
|
|
5626
|
+
const cxImg = a.at[0] * W, cyImg = a.at[1] * H;
|
|
5627
|
+
const rImg = APPROVAL_R * W, rPt = rImg * ptScale;
|
|
5628
|
+
const [pcx, pcy] = toPage(cxImg, cyImg);
|
|
5629
|
+
const backing = dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1);
|
|
5630
|
+
if (isAgent) {
|
|
5631
|
+
const dia = (k) => [[cxImg, cyImg - rImg * k], [cxImg + rImg * k, cyImg], [cxImg, cyImg + rImg * k], [cxImg - rImg * k, cyImg]];
|
|
5632
|
+
pg.drawSvgPath(svgPath(dia(1)), { x: 0, y: 0, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
|
|
5633
|
+
pg.drawSvgPath(svgPath(dia(0.72)), { x: 0, y: 0, borderColor: acol, borderWidth: rPt * 0.035 });
|
|
5634
|
+
} else {
|
|
5635
|
+
pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt, yScale: rPt, color: backing, opacity: 0.72, borderColor: acol, borderWidth: rPt * 0.07 });
|
|
5636
|
+
pg.drawEllipse({ x: pcx, y: pcy, xScale: rPt * 0.78, yScale: rPt * 0.78, borderColor: acol, borderWidth: rPt * 0.035 });
|
|
5637
|
+
}
|
|
5638
|
+
const label = isAgent ? "AGENT" : "APPROVED";
|
|
5639
|
+
const size = rPt * (isAgent ? 0.3 : 0.26);
|
|
5640
|
+
const tw = bold.widthOfTextAtSize(label, size);
|
|
5641
|
+
pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
|
|
5642
|
+
}
|
|
5216
5643
|
text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
|
|
5217
5644
|
}
|
|
5218
5645
|
const allPages = doc.getPages();
|
|
@@ -5588,6 +6015,26 @@ function registerTools(server, session) {
|
|
|
5588
6015
|
},
|
|
5589
6016
|
outputSchema: placeCountOutput
|
|
5590
6017
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
6018
|
+
server.registerTool("symbol_sweep", {
|
|
6019
|
+
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}`,
|
|
6020
|
+
inputSchema: {
|
|
6021
|
+
sheet: z2.string(),
|
|
6022
|
+
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"),
|
|
6023
|
+
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"),
|
|
6024
|
+
commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
|
|
6025
|
+
rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
|
|
6026
|
+
mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
|
|
6027
|
+
tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
|
|
6028
|
+
},
|
|
6029
|
+
outputSchema: symbolSweepOutput
|
|
6030
|
+
}, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
|
|
6031
|
+
seedRect: a.seed_rect,
|
|
6032
|
+
condition: a.condition,
|
|
6033
|
+
commit: a.commit,
|
|
6034
|
+
rotations: a.rotations,
|
|
6035
|
+
mirror: a.mirror,
|
|
6036
|
+
tolerancePx: a.tolerance_px
|
|
6037
|
+
})));
|
|
5591
6038
|
server.registerTool("derive_base", {
|
|
5592
6039
|
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
6040
|
inputSchema: {
|
|
@@ -5785,21 +6232,21 @@ function registerTools(server, session) {
|
|
|
5785
6232
|
return reply;
|
|
5786
6233
|
});
|
|
5787
6234
|
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).
|
|
6235
|
+
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
6236
|
|
|
5790
6237
|
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
6238
|
|
|
5792
6239
|
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
6240
|
inputSchema: {
|
|
5794
6241
|
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"),
|
|
6242
|
+
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"),
|
|
6243
|
+
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
6244
|
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
6245
|
at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
|
|
5799
6246
|
target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
|
|
5800
6247
|
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
|
|
6248
|
+
from: pointSchema.optional().describe("Arrow tail / dimension start (image px)"),
|
|
6249
|
+
to: pointSchema.optional().describe("Arrow head / dimension end (image px)"),
|
|
5803
6250
|
r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
|
|
5804
6251
|
},
|
|
5805
6252
|
outputSchema: annotateOutput
|
|
@@ -5901,7 +6348,7 @@ function registerResources(server, session) {
|
|
|
5901
6348
|
// package.json
|
|
5902
6349
|
var package_default = {
|
|
5903
6350
|
name: "opentakeoff-mcp",
|
|
5904
|
-
version: "0.9.
|
|
6351
|
+
version: "0.9.20",
|
|
5905
6352
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
5906
6353
|
type: "module",
|
|
5907
6354
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
package/package.json
CHANGED