opentakeoff-mcp 0.9.18 → 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 +5 -4
- package/dist/server-core.js +585 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,12 +109,13 @@ includes document text, shape vertices, or result payload content.
|
|
|
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
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. |
|
|
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), never dropped silently.
|
|
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. |
|
|
@@ -123,10 +124,10 @@ includes document text, shape vertices, or result payload content.
|
|
|
123
124
|
| `edit_condition` | Set a condition's **waste %**, **×N multiplier**, **height_ft** (the H knob `measure_surface` quantifies against), and **roll_setup** (the roll-goods opt-in: seams figured, cuts packed, the reply echoes the order — cuts, `order_lf`, rolls, `order_qty` — and `export_report`'s `roll_goods` block carries the same rows; `null` opts out) — the knobs that turn measured quantities into order quantities. Resolves an **existing** finish tag or errors — a typo must not mint an empty condition. No review gate; one `undo_last` step restores the knobs verbatim. |
|
|
124
125
|
| `export_report` | The **computed Report document** — `opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers — `export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path`. |
|
|
125
126
|
| `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import — finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. |
|
|
126
|
-
| `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. Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
|
|
127
|
-
| `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities,
|
|
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`. |
|
|
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 || {};
|
|
@@ -2292,6 +2536,12 @@ var Session = class _Session {
|
|
|
2292
2536
|
conditions = [];
|
|
2293
2537
|
markups = [];
|
|
2294
2538
|
shapes = [];
|
|
2539
|
+
/** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
|
|
2540
|
+
* marked-set cover discloses as withheld. Replaced per assign run, cleared
|
|
2541
|
+
* with the rest of the session on a non-merge load_plan. Seeds ride
|
|
2542
|
+
* normalized so the export-time staleness drop can test them against
|
|
2543
|
+
* committed rings in verts_norm space. */
|
|
2544
|
+
scheduleWithheld = [];
|
|
2295
2545
|
/** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
|
|
2296
2546
|
journal = [];
|
|
2297
2547
|
seq = 0;
|
|
@@ -2331,6 +2581,7 @@ var Session = class _Session {
|
|
|
2331
2581
|
this.file = null;
|
|
2332
2582
|
this.filePath = null;
|
|
2333
2583
|
this.nextOrd = 1;
|
|
2584
|
+
this.scheduleWithheld = [];
|
|
2334
2585
|
this.journal = [];
|
|
2335
2586
|
this.pendingCommits = [];
|
|
2336
2587
|
} else if (this.docs.has(base)) {
|
|
@@ -2734,6 +2985,7 @@ var Session = class _Session {
|
|
|
2734
2985
|
return c;
|
|
2735
2986
|
}
|
|
2736
2987
|
commit(s, tag, role, vertsPx, computed, origin) {
|
|
2988
|
+
if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
|
|
2737
2989
|
const c = this.conditionFor(tag);
|
|
2738
2990
|
const shape = {
|
|
2739
2991
|
id: uid("shp"),
|
|
@@ -2830,6 +3082,16 @@ var Session = class _Session {
|
|
|
2830
3082
|
* judge and nothing commits anyway. */
|
|
2831
3083
|
async detectRooms(name, opts) {
|
|
2832
3084
|
const s = this.sheet(name);
|
|
3085
|
+
const assign = !!opts.assignFromSchedule;
|
|
3086
|
+
let graph = null;
|
|
3087
|
+
if (assign) {
|
|
3088
|
+
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
3089
|
+
graph = await this.ensureGraph();
|
|
3090
|
+
if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
|
|
3091
|
+
if (!graph.tables.some((t) => t.kind === "room-finish")) {
|
|
3092
|
+
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.");
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
2833
3095
|
const mask = await this.maskWithLayers(name, opts.layers);
|
|
2834
3096
|
if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
|
|
2835
3097
|
const minAreaSf = opts.minAreaSf ?? 5;
|
|
@@ -2839,7 +3101,8 @@ var Session = class _Session {
|
|
|
2839
3101
|
const num2 = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
|
|
2840
3102
|
if (num2) labels.push({ str: num2, bbox: sp });
|
|
2841
3103
|
}
|
|
2842
|
-
const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0 };
|
|
3104
|
+
const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0, unresolved: 0 };
|
|
3105
|
+
const unresolved = [];
|
|
2843
3106
|
const byRing = /* @__PURE__ */ new Map();
|
|
2844
3107
|
const order = [];
|
|
2845
3108
|
for (const lb of labels) {
|
|
@@ -2907,21 +3170,42 @@ var Session = class _Session {
|
|
|
2907
3170
|
return null;
|
|
2908
3171
|
}
|
|
2909
3172
|
const perimeter_lf = round2(c.perimPx * upp);
|
|
3173
|
+
let tag = opts.condition;
|
|
3174
|
+
let assignment;
|
|
3175
|
+
if (assign) {
|
|
3176
|
+
const hit = this.floorTagFor(graph, c.label);
|
|
3177
|
+
if ("reason" in hit) {
|
|
3178
|
+
withheld.unresolved++;
|
|
3179
|
+
unresolved.push({ label: c.label, reason: hit.reason, area_sf, perimeter_lf, seed: [round1(c.seed[0]), round1(c.seed[1])] });
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
tag = hit.tag;
|
|
3183
|
+
assignment = { source: "schedule", room_tag: c.label, surface: "FLOOR", schedule_sheet: hit.sheet };
|
|
3184
|
+
}
|
|
2910
3185
|
let shape_id;
|
|
2911
|
-
if (
|
|
2912
|
-
shape_id = this.commit(s,
|
|
3186
|
+
if (tag) {
|
|
3187
|
+
shape_id = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
|
|
2913
3188
|
method: "one_click_v1",
|
|
2914
3189
|
actor: "agent",
|
|
2915
3190
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
2916
3191
|
reviewed: false,
|
|
2917
3192
|
...c.hatch ? { hatch_filtered: true } : {},
|
|
2918
|
-
...c.gap ? { gap_bridged_px: c.gap } : {}
|
|
3193
|
+
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
3194
|
+
...assignment ? { assignment } : {}
|
|
2919
3195
|
}).id;
|
|
2920
3196
|
}
|
|
2921
|
-
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
3197
|
+
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
|
|
2922
3198
|
}).filter((r) => r !== null);
|
|
2923
3199
|
this.flushCommits("detect_rooms");
|
|
2924
|
-
|
|
3200
|
+
if (assign) {
|
|
3201
|
+
this.scheduleWithheld = unresolved.map((u) => ({
|
|
3202
|
+
sheet_id: s.key,
|
|
3203
|
+
label: u.label,
|
|
3204
|
+
reason: u.reason,
|
|
3205
|
+
seed_norm: [u.seed[0] / s.widthPx, u.seed[1] / s.heightPx]
|
|
3206
|
+
}));
|
|
3207
|
+
}
|
|
3208
|
+
const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible + withheld.unresolved;
|
|
2925
3209
|
return {
|
|
2926
3210
|
detected: rooms.length,
|
|
2927
3211
|
rooms,
|
|
@@ -2930,8 +3214,11 @@ var Session = class _Session {
|
|
|
2930
3214
|
...withheld,
|
|
2931
3215
|
...upp != null ? { min_area_sf: minAreaSf } : {}
|
|
2932
3216
|
},
|
|
3217
|
+
// assign mode always states the answer, empty array included: [] is the
|
|
3218
|
+
// positive claim "every detected room resolved against its own row"
|
|
3219
|
+
...assign ? { unresolved } : {},
|
|
2933
3220
|
...s.detected?.multi ? { multiple_scales: true } : {},
|
|
2934
|
-
...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable.` } : {},
|
|
3221
|
+
...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
|
|
2935
3222
|
...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
|
|
2936
3223
|
};
|
|
2937
3224
|
}
|
|
@@ -3047,15 +3334,99 @@ var Session = class _Session {
|
|
|
3047
3334
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
3048
3335
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
3049
3336
|
* the whole call one journal gesture — undoing a placement sweep is one step,
|
|
3050
|
-
* 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. */
|
|
3051
3343
|
placeCount(name, points, opts) {
|
|
3052
3344
|
const s = this.sheet(name);
|
|
3053
|
-
const ids = points.map(([x, y]) => this.commit(
|
|
3054
|
-
|
|
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");
|
|
3055
3354
|
const c = this.conditions.find((x) => x.finish_tag === opts.condition);
|
|
3056
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);
|
|
3057
3356
|
return { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
|
|
3058
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
|
+
}
|
|
3059
3430
|
/** The mid-session shape inventory (#149): every committed shape's id,
|
|
3060
3431
|
* home, role, and quantities in one compact read — the ids edit_shape /
|
|
3061
3432
|
* delete_shape assume you have, without pulling the whole export_takeoff
|
|
@@ -3085,6 +3456,7 @@ var Session = class _Session {
|
|
|
3085
3456
|
...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
|
|
3086
3457
|
nverts: x.verts_norm.length,
|
|
3087
3458
|
reviewed: x.origin?.reviewed === true,
|
|
3459
|
+
...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
|
|
3088
3460
|
...x.origin?.agent_edits ? { agent_edits: x.origin.agent_edits } : {}
|
|
3089
3461
|
})),
|
|
3090
3462
|
count: rows.length
|
|
@@ -3164,7 +3536,13 @@ var Session = class _Session {
|
|
|
3164
3536
|
verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
|
|
3165
3537
|
computed,
|
|
3166
3538
|
...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
|
|
3167
|
-
...cur.origin ? { origin: {
|
|
3539
|
+
...cur.origin ? { origin: {
|
|
3540
|
+
...cur.origin,
|
|
3541
|
+
agent_edits: (cur.origin.agent_edits ?? 0) + 1,
|
|
3542
|
+
// a reassign onto a different tag is the agent choosing the finish —
|
|
3543
|
+
// keeping "schedule" (and its citation) past that point would be a lie
|
|
3544
|
+
...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
|
|
3545
|
+
} } : {}
|
|
3168
3546
|
};
|
|
3169
3547
|
this.record({ op: "edit", tool: "edit_shape", before });
|
|
3170
3548
|
const changed = [
|
|
@@ -3368,7 +3746,14 @@ var Session = class _Session {
|
|
|
3368
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`);
|
|
3369
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`);
|
|
3370
3748
|
if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
|
|
3371
|
-
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
|
+
}
|
|
3372
3757
|
const cond = a.condition ? this.conditionFor(a.condition) : null;
|
|
3373
3758
|
const m = {
|
|
3374
3759
|
id: uid("mk"),
|
|
@@ -3385,7 +3770,10 @@ var Session = class _Session {
|
|
|
3385
3770
|
...a.to ? { to: n(a.to) } : {},
|
|
3386
3771
|
// bubble radius: px → fraction of sheet WIDTH (marked-set frame); the
|
|
3387
3772
|
// canvas default is 0.02 when unset — stored explicitly so exports agree
|
|
3388
|
-
...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 } : {}
|
|
3389
3777
|
};
|
|
3390
3778
|
this.markups.push(m);
|
|
3391
3779
|
return {
|
|
@@ -3395,6 +3783,7 @@ var Session = class _Session {
|
|
|
3395
3783
|
text: m.text,
|
|
3396
3784
|
condition: cond?.finish_tag ?? "",
|
|
3397
3785
|
condition_id: m.condition_id,
|
|
3786
|
+
...len_ft !== void 0 ? { length_lf: len_ft } : {},
|
|
3398
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."
|
|
3399
3788
|
};
|
|
3400
3789
|
}
|
|
@@ -3432,7 +3821,8 @@ var Session = class _Session {
|
|
|
3432
3821
|
...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {},
|
|
3433
3822
|
...m.from ? { from: px(m, m.from) } : {},
|
|
3434
3823
|
...m.to ? { to: px(m, m.to) } : {},
|
|
3435
|
-
...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 } : {}
|
|
3436
3826
|
})),
|
|
3437
3827
|
count: rows.length,
|
|
3438
3828
|
unattached: rows.filter((m) => !m.condition_id).length
|
|
@@ -3534,6 +3924,24 @@ var Session = class _Session {
|
|
|
3534
3924
|
counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
3535
3925
|
};
|
|
3536
3926
|
}
|
|
3927
|
+
/** The FLOOR finish a room's own schedule row states — assign-from-schedule's
|
|
3928
|
+
* per-room resolver (0.9.18): resolveTag's chain narrowed to the one surface
|
|
3929
|
+
* a floor takeoff commits. Refusal over guessing, per room: an unresolved
|
|
3930
|
+
* tag returns resolveTag's own reason; a resolved row with no FLOOR cell
|
|
3931
|
+
* says so; a compound cell ("CPT-1/VCT-1") is ambiguous — committing the
|
|
3932
|
+
* whole room's SF under a two-finish literal would assert an area split the
|
|
3933
|
+
* schedule never stated. Compound detection is narrow ("/", ",", " OR "),
|
|
3934
|
+
* so a hyphenated code never trips it. BASE/WALL are deliberately ignored:
|
|
3935
|
+
* those are derive_base's and measure_surface's measures. */
|
|
3936
|
+
floorTagFor(g, tag) {
|
|
3937
|
+
const res = resolveTag(g, tag);
|
|
3938
|
+
if (res.status !== "resolved") return { reason: res.reason };
|
|
3939
|
+
const floor = res.finishes.find((f) => f.surface === "FLOOR");
|
|
3940
|
+
const code = floor?.code.trim();
|
|
3941
|
+
if (!floor || !code) return { reason: `schedule row ${res.tag} states no FLOOR finish` };
|
|
3942
|
+
if (/[/,]|\bOR\b/i.test(code)) return { reason: `ambiguous: floor cell "${code}" names more than one finish with no stated split` };
|
|
3943
|
+
return { tag: code, sheet: floor.source.sheet };
|
|
3944
|
+
}
|
|
3537
3945
|
async resolveRoomTag(tag) {
|
|
3538
3946
|
if (!tag || !tag.trim()) throw new UserError('Pass a room tag, e.g. resolve_tag { tag: "134" }.');
|
|
3539
3947
|
const g = await this.ensureGraph();
|
|
@@ -3695,6 +4103,7 @@ var detectedRoom = z.object({
|
|
|
3695
4103
|
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
3696
4104
|
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
3697
4105
|
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
4106
|
+
condition: z.string().optional().describe("The finish tag this room committed under \u2014 the passed condition, or in assign mode the FLOOR finish its own schedule row states. Present exactly when shape_id is"),
|
|
3698
4107
|
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
3699
4108
|
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
|
|
3700
4109
|
});
|
|
@@ -3707,8 +4116,16 @@ var detectRoomsOutput = {
|
|
|
3707
4116
|
duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
|
|
3708
4117
|
bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
|
|
3709
4118
|
implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
|
|
4119
|
+
unresolved: z.number().int().describe("Assign mode: rooms the schedule could not answer for (no row, no FLOOR cell, or a compound cell) \u2014 withheld into unresolved[], never committed under a guess. Always present; 0 outside assign mode"),
|
|
3710
4120
|
min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
|
|
3711
4121
|
}).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
|
|
4122
|
+
unresolved: z.array(z.object({
|
|
4123
|
+
label: z.string().describe("The room tag as drawn"),
|
|
4124
|
+
reason: z.string().describe(`WHY the schedule could not answer \u2014 resolveTag's own reason, "states no FLOOR finish", or "ambiguous: \u2026" for a compound cell`),
|
|
4125
|
+
area_sf: z.number().describe("The room's real traced area \u2014 withheld from committing, not from reporting"),
|
|
4126
|
+
perimeter_lf: z.number(),
|
|
4127
|
+
seed: z.tuple([z.number(), z.number()]).describe("The flood seed (image px) \u2014 once the estimator answers, one_click here with the stated condition commits it")
|
|
4128
|
+
})).optional().describe("Assign mode only, empty array included: [] is the positive claim that every detected room resolved against its own schedule row"),
|
|
3712
4129
|
note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
|
|
3713
4130
|
multiple_scales: z.literal(true).optional().describe("Several DISTINCT scale notes on this sheet (#153) \u2014 rooms inside an enlarged viewport may be figured at the wrong scale"),
|
|
3714
4131
|
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
@@ -3734,6 +4151,33 @@ var placeCountOutput = {
|
|
|
3734
4151
|
condition: z.string(),
|
|
3735
4152
|
ea_total: z.number().describe("The condition's total EA after this call")
|
|
3736
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
|
+
};
|
|
3737
4181
|
var measureLineOutput = {
|
|
3738
4182
|
length_lf: z.number(),
|
|
3739
4183
|
npts: z.number().int(),
|
|
@@ -3791,7 +4235,7 @@ var exportTakeoffOutput = {
|
|
|
3791
4235
|
measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
|
|
3792
4236
|
verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
|
|
3793
4237
|
computed: z.object({ area_sf: z.number().optional(), perimeter_lf: z.number().optional(), count: z.number().optional() }).passthrough().describe("count shapes carry {count} alone; every other role carries area_sf + perimeter_lf"),
|
|
3794
|
-
origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
|
|
4238
|
+
origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), assignment (where the finish tag came from \u2014 {source: 'schedule', room_tag, surface, schedule_sheet} when the room's own schedule row decided it, {source: 'asserted'} when the agent chose; stamped on every agent commit), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
|
|
3795
4239
|
}).passthrough()),
|
|
3796
4240
|
markups: z.array(z.unknown()),
|
|
3797
4241
|
sheet_group: z.array(z.unknown()),
|
|
@@ -3838,6 +4282,7 @@ var listShapesOutput = {
|
|
|
3838
4282
|
height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
|
|
3839
4283
|
nverts: z.number().int(),
|
|
3840
4284
|
reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
|
|
4285
|
+
assignment: z.enum(["schedule", "asserted"]).optional().describe(`Where the finish tag came from: "schedule" = resolved from the room's own schedule row, "asserted" = the agent chose it. origin.assignment in export_takeoff carries the citation. Absent on human canvas shapes`),
|
|
3841
4286
|
agent_edits: z.number().int().optional().describe("Present when the agent has revised this shape")
|
|
3842
4287
|
})),
|
|
3843
4288
|
count: z.number().int()
|
|
@@ -4051,9 +4496,10 @@ var annotationRow = z.object({
|
|
|
4051
4496
|
at: z.tuple([z.number(), z.number()]).optional(),
|
|
4052
4497
|
target: z.tuple([z.number(), z.number()]).optional(),
|
|
4053
4498
|
rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional(),
|
|
4054
|
-
from: z.tuple([z.number(), z.number()]).optional().describe("Arrow tail (image px)"),
|
|
4055
|
-
to: z.tuple([z.number(), z.number()]).optional().describe("Arrow head (image px)"),
|
|
4056
|
-
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")
|
|
4057
4503
|
});
|
|
4058
4504
|
var annotateOutput = {
|
|
4059
4505
|
id: z.string(),
|
|
@@ -4062,6 +4508,7 @@ var annotateOutput = {
|
|
|
4062
4508
|
text: z.string(),
|
|
4063
4509
|
condition: z.string(),
|
|
4064
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"),
|
|
4065
4512
|
note: z.string()
|
|
4066
4513
|
};
|
|
4067
4514
|
var listAnnotationsOutput = {
|
|
@@ -4080,6 +4527,24 @@ var linkAnnotationOutput = {
|
|
|
4080
4527
|
import path3 from "node:path";
|
|
4081
4528
|
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
4082
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
|
+
|
|
4083
4548
|
// ../web/src/lib/svgpath.js
|
|
4084
4549
|
function isSep(c) {
|
|
4085
4550
|
return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
|
|
@@ -4735,7 +5200,7 @@ function invertPixels(cv) {
|
|
|
4735
5200
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
4736
5201
|
ctx.restore();
|
|
4737
5202
|
}
|
|
4738
|
-
async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = 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" }) {
|
|
4739
5204
|
const M = units === "metric";
|
|
4740
5205
|
const uA = (sf) => M ? sf * 0.09290304 : sf;
|
|
4741
5206
|
const uL = (lf) => M ? lf * 0.3048 : lf;
|
|
@@ -4752,8 +5217,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4752
5217
|
}
|
|
4753
5218
|
return m;
|
|
4754
5219
|
};
|
|
4755
|
-
const shapesBy = byKey(shapes), marksBy = byKey(markups);
|
|
4756
|
-
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);
|
|
4757
5222
|
if (!marked.length && !rfis?.length) throw new Error("Nothing to export \u2014 no sheet carries takeoffs or markups.");
|
|
4758
5223
|
const markedShapes = marked.flatMap((sh) => shapesBy.get(sh.key) || []);
|
|
4759
5224
|
const doc = await PDFDocument.create();
|
|
@@ -4825,6 +5290,15 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4825
5290
|
}
|
|
4826
5291
|
}
|
|
4827
5292
|
draw(`${marked.length} marked sheet${marked.length === 1 ? "" : "s"} \xB7 ${markedShapes.length} takeoff item${markedShapes.length === 1 ? "" : "s"} \xB7 quantities net of deducts, waste-adjusted where noted`, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
5293
|
+
if (provenance) {
|
|
5294
|
+
metaY -= 12;
|
|
5295
|
+
draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
|
|
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
|
+
}
|
|
4828
5302
|
let y = metaY - 34;
|
|
4829
5303
|
const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
|
|
4830
5304
|
draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
|
|
@@ -4853,7 +5327,7 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
4853
5327
|
for (const sh of marked) {
|
|
4854
5328
|
if (y < 90) break;
|
|
4855
5329
|
const items = shapesBy.get(sh.key) || [];
|
|
4856
|
-
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 });
|
|
4857
5331
|
y -= 13;
|
|
4858
5332
|
for (const r of bySheetId.get(sh.key)?.rows || []) {
|
|
4859
5333
|
if (y < 92) break;
|
|
@@ -5095,6 +5569,21 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5095
5569
|
pg.drawSvgPath(arrowheadPath(pfx, -pfy, ptx, -pty, 6 * mw), { x: 0, y: 0, color: mcol, opacity: 0.95 });
|
|
5096
5570
|
const t = lbl(m.text);
|
|
5097
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
|
+
}
|
|
5098
5587
|
} else if (m.type === "bubble" && m.at) {
|
|
5099
5588
|
const cxImg = m.at[0] * W, cyImg = m.at[1] * H;
|
|
5100
5589
|
const [pcx, pcy] = toPage(cxImg, cyImg);
|
|
@@ -5131,6 +5620,26 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5131
5620
|
text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
|
|
5132
5621
|
}
|
|
5133
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
|
+
}
|
|
5134
5643
|
text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
|
|
5135
5644
|
}
|
|
5136
5645
|
const allPages = doc.getPages();
|
|
@@ -5146,6 +5655,20 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
|
|
|
5146
5655
|
}
|
|
5147
5656
|
|
|
5148
5657
|
// src/marked.ts
|
|
5658
|
+
function assignmentDisclosure(shapes, withheld = []) {
|
|
5659
|
+
const agent = shapes.filter((s) => s.origin?.actor === "agent");
|
|
5660
|
+
const live = withheld.filter((w) => !shapes.some((s) => s.sheet_id === w.sheet_id && (s.measure_role === "floor_area" || s.measure_role === "deduct") && s.verts_norm.length >= 3 && pointInPoly(w.seed_norm[0], w.seed_norm[1], s.verts_norm)));
|
|
5661
|
+
if (!agent.length && !live.length) return null;
|
|
5662
|
+
const schedule = agent.filter((s) => s.origin?.assignment?.source === "schedule").length;
|
|
5663
|
+
const asserted = agent.filter((s) => s.origin?.assignment?.source === "asserted").length;
|
|
5664
|
+
const pending = agent.filter((s) => s.origin?.reviewed !== true).length;
|
|
5665
|
+
const parts = [];
|
|
5666
|
+
if (schedule) parts.push(`${schedule} schedule-resolved`);
|
|
5667
|
+
if (asserted) parts.push(`${asserted} agent-asserted`);
|
|
5668
|
+
if (pending) parts.push(`${pending} pending human review`);
|
|
5669
|
+
if (live.length) parts.push(`${live.length} room${live.length === 1 ? "" : "s"} withheld, unresolved against the schedule`);
|
|
5670
|
+
return parts.length ? `Finish assignment: ${parts.join(" \xB7 ")}` : null;
|
|
5671
|
+
}
|
|
5149
5672
|
var buildMarkedSetPdf2 = buildMarkedSetPdf;
|
|
5150
5673
|
async function exportMarkedPdf(session, opts) {
|
|
5151
5674
|
const { file, filePath } = session;
|
|
@@ -5199,6 +5722,7 @@ async function exportMarkedPdf(session, opts) {
|
|
|
5199
5722
|
getPage,
|
|
5200
5723
|
loadPdfData,
|
|
5201
5724
|
credit,
|
|
5725
|
+
provenance: assignmentDisclosure(session.shapes, session.scheduleWithheld),
|
|
5202
5726
|
coverTitle: "OpenTakeoff \xB7 Marked Set"
|
|
5203
5727
|
});
|
|
5204
5728
|
const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
|
|
@@ -5421,7 +5945,7 @@ function registerTools(server, session) {
|
|
|
5421
5945
|
return session.setScale(a.sheet, a);
|
|
5422
5946
|
}));
|
|
5423
5947
|
server.registerTool("one_click", {
|
|
5424
|
-
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. ${COORDS}`,
|
|
5948
|
+
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}`,
|
|
5425
5949
|
inputSchema: {
|
|
5426
5950
|
sheet: z2.string(),
|
|
5427
5951
|
x: z2.number(),
|
|
@@ -5435,10 +5959,11 @@ function registerTools(server, session) {
|
|
|
5435
5959
|
outputSchema: oneClickOutput
|
|
5436
5960
|
}, 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 })));
|
|
5437
5961
|
server.registerTool("detect_rooms", {
|
|
5438
|
-
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
|
|
5962
|
+
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}`,
|
|
5439
5963
|
inputSchema: {
|
|
5440
5964
|
sheet: z2.string(),
|
|
5441
|
-
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
|
|
5965
|
+
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
5966
|
+
assign_from_schedule: z2.boolean().default(false).describe(`Commit each room under the FLOOR finish its OWN room-finish schedule row states (resolve_tag's chain, per room): the citation rides origin.assignment, and rooms the schedule cannot answer for \u2014 no row, no FLOOR cell, a compound cell like "CPT-1/VCT-1" \u2014 are returned in unresolved[] with reasons and seeds instead of committed under a guess. Needs the sheet's scale and a room-finish schedule in the working set (merge the schedule sheet in with load_plan first). Mutually exclusive with condition`),
|
|
5442
5967
|
role: roleSchema,
|
|
5443
5968
|
return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
|
|
5444
5969
|
min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
|
|
@@ -5446,7 +5971,12 @@ function registerTools(server, session) {
|
|
|
5446
5971
|
layers: layersFilterSchema
|
|
5447
5972
|
},
|
|
5448
5973
|
outputSchema: detectRoomsOutput
|
|
5449
|
-
}, run("detect_rooms", (a) =>
|
|
5974
|
+
}, run("detect_rooms", (a) => {
|
|
5975
|
+
if (a.assign_from_schedule && a.condition !== void 0) {
|
|
5976
|
+
throw new UserError("Provide at most one of: condition (every room under one stated tag) or assign_from_schedule (each room's own schedule row decides).");
|
|
5977
|
+
}
|
|
5978
|
+
return session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers, assignFromSchedule: a.assign_from_schedule });
|
|
5979
|
+
}));
|
|
5450
5980
|
server.registerTool("measure_polygon", {
|
|
5451
5981
|
description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
|
|
5452
5982
|
inputSchema: {
|
|
@@ -5485,6 +6015,26 @@ function registerTools(server, session) {
|
|
|
5485
6015
|
},
|
|
5486
6016
|
outputSchema: placeCountOutput
|
|
5487
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
|
+
})));
|
|
5488
6038
|
server.registerTool("derive_base", {
|
|
5489
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.`,
|
|
5490
6040
|
inputSchema: {
|
|
@@ -5661,7 +6211,7 @@ function registerTools(server, session) {
|
|
|
5661
6211
|
outputSchema: findTextOutput
|
|
5662
6212
|
}, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
|
|
5663
6213
|
server.registerTool("view_sheet", {
|
|
5664
|
-
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet
|
|
6214
|
+
description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
|
|
5665
6215
|
inputSchema: {
|
|
5666
6216
|
sheet: z2.string(),
|
|
5667
6217
|
region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
|
|
@@ -5682,21 +6232,21 @@ function registerTools(server, session) {
|
|
|
5682
6232
|
return reply;
|
|
5683
6233
|
});
|
|
5684
6234
|
server.registerTool("annotate", {
|
|
5685
|
-
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.
|
|
5686
6236
|
|
|
5687
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.
|
|
5688
6238
|
|
|
5689
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}`,
|
|
5690
6240
|
inputSchema: {
|
|
5691
6241
|
sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
|
|
5692
|
-
type: z2.enum(["cloud", "text", "callout", "highlight", "arrow", "bubble"]).describe("cloud/highlight need rect; text/callout/bubble need at; callout also needs target; arrow
|
|
5693
|
-
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"),
|
|
5694
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"),
|
|
5695
6245
|
at: pointSchema.optional().describe("Anchor point (image px) \u2014 text, callout, and bubble (the circle's center)"),
|
|
5696
6246
|
target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
|
|
5697
6247
|
rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight"),
|
|
5698
|
-
from: pointSchema.optional().describe("Arrow tail (image px)"),
|
|
5699
|
-
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)"),
|
|
5700
6250
|
r: z2.number().positive().optional().describe("Bubble radius (image px); omitted \u2192 the canvas default (2% of sheet width)")
|
|
5701
6251
|
},
|
|
5702
6252
|
outputSchema: annotateOutput
|
|
@@ -5798,7 +6348,7 @@ function registerResources(server, session) {
|
|
|
5798
6348
|
// package.json
|
|
5799
6349
|
var package_default = {
|
|
5800
6350
|
name: "opentakeoff-mcp",
|
|
5801
|
-
version: "0.9.
|
|
6351
|
+
version: "0.9.20",
|
|
5802
6352
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
5803
6353
|
type: "module",
|
|
5804
6354
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -5875,8 +6425,8 @@ function buildServer(session = new Session()) {
|
|
|
5875
6425
|
"OpenTakeoff: quantity takeoff on construction plan PDFs.",
|
|
5876
6426
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
5877
6427
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
5878
|
-
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition
|
|
5879
|
-
"3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals.",
|
|
6428
|
+
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
|
|
6429
|
+
"3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
5880
6430
|
"4. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone."
|
|
5881
6431
|
].join("\n")
|
|
5882
6432
|
});
|
package/package.json
CHANGED