opentakeoff-mcp 0.9.21 → 0.9.25

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.
@@ -267,7 +267,21 @@ function textSpans(ph) {
267
267
  const x = t[4], y = t[5];
268
268
  const w = (it.width || 0) * RENDER_SCALE;
269
269
  const h = (it.height || 0) * RENDER_SCALE || Math.hypot(t[2], t[3]);
270
- out.push({ str, x0: +x.toFixed(1), y0: +(y - h).toFixed(1), x1: +(x + w).toFixed(1), y1: +y.toFixed(1) });
270
+ const dn = Math.hypot(t[0], t[1]) || 1;
271
+ const un = Math.hypot(t[2], t[3]) || 1;
272
+ const dx = t[0] / dn, dy = t[1] / dn;
273
+ const ux = t[2] / un, uy = t[3] / un;
274
+ const xs = [x, x + w * dx, x + h * ux, x + w * dx + h * ux];
275
+ const ys = [y, y + w * dy, y + h * uy, y + w * dy + h * uy];
276
+ const rot = (Math.round(Math.atan2(dy, dx) * 180 / Math.PI) % 360 + 360) % 360;
277
+ out.push({
278
+ str,
279
+ x0: +Math.min(...xs).toFixed(1),
280
+ y0: +Math.min(...ys).toFixed(1),
281
+ x1: +Math.max(...xs).toFixed(1),
282
+ y1: +Math.max(...ys).toFixed(1),
283
+ ...rot ? { rot } : {}
284
+ });
271
285
  }
272
286
  return out;
273
287
  }
@@ -408,27 +422,183 @@ function segRoles(layerOf, codes) {
408
422
  return any ? out : null;
409
423
  }
410
424
 
425
+ // ../web/src/lib/geometry.js
426
+ function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
427
+ const r = R * innerRatio;
428
+ let d = "";
429
+ for (let i = 0; i < points * 2; i++) {
430
+ const a = Math.PI * i / points - Math.PI / 2, rad = i % 2 === 0 ? R : r;
431
+ d += `${i === 0 ? "M" : "L"}${cx + rad * Math.cos(a)},${cy + rad * Math.sin(a)} `;
432
+ }
433
+ return d + "Z";
434
+ }
435
+ function arrowheadPath(fromX, fromY, tipX, tipY, size = 6) {
436
+ let dx = tipX - fromX, dy = tipY - fromY;
437
+ const len = Math.hypot(dx, dy);
438
+ if (len < 1e-6) {
439
+ dx = 0;
440
+ dy = 1;
441
+ } else {
442
+ dx /= len;
443
+ dy /= len;
444
+ }
445
+ const bx = tipX - dx * size, by = tipY - dy * size;
446
+ const nx = -dy, ny = dx, half = size * 0.5;
447
+ return `M${tipX},${tipY} L${bx + nx * half},${by + ny * half} L${bx - nx * half},${by - ny * half} Z`;
448
+ }
449
+ function arcToBezier(x0, y0, x1, y1, r, laf, sf) {
450
+ const dx = (x0 - x1) / 2, dy = (y0 - y1) / 2;
451
+ let rr = Math.abs(r) || 1;
452
+ const lambda = (dx * dx + dy * dy) / (rr * rr);
453
+ if (lambda > 1) rr *= Math.sqrt(lambda);
454
+ const sign = laf !== sf ? 1 : -1;
455
+ const num2 = rr * rr * rr * rr - rr * rr * dy * dy - rr * rr * dx * dx;
456
+ const den = rr * rr * dy * dy + rr * rr * dx * dx;
457
+ const coef = sign * Math.sqrt(Math.max(0, den === 0 ? 0 : num2 / den));
458
+ const cxp = coef * dy, cyp = -coef * dx;
459
+ const ang = (ux, uy, vx, vy) => {
460
+ const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1;
461
+ let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
462
+ if (ux * vy - uy * vx < 0) a = -a;
463
+ return a;
464
+ };
465
+ const th1 = ang(1, 0, (dx - cxp) / rr, (dy - cyp) / rr);
466
+ let dth = ang((dx - cxp) / rr, (dy - cyp) / rr, (-dx - cxp) / rr, (-dy - cyp) / rr);
467
+ if (!sf && dth > 0) dth -= 2 * Math.PI;
468
+ if (sf && dth < 0) dth += 2 * Math.PI;
469
+ const th2 = th1 + dth;
470
+ const alpha = 4 / 3 * Math.tan(dth / 4);
471
+ return [
472
+ x0 - alpha * rr * Math.sin(th1),
473
+ y0 + alpha * rr * Math.cos(th1),
474
+ x1 + alpha * rr * Math.sin(th2),
475
+ y1 - alpha * rr * Math.cos(th2)
476
+ ];
477
+ }
478
+ function cloudBezier(x0, y0, x1, y1) {
479
+ const ax0 = Math.min(x0, x1), ay0 = Math.min(y0, y1), ax1 = Math.max(x0, x1), ay1 = Math.max(y0, y1);
480
+ const r = Math.max(6, Math.min(22, (ax1 - ax0 + ay1 - ay0) / 22));
481
+ const arc = (len) => Math.max(1, Math.round(len / (r * 1.6)));
482
+ const segments = [];
483
+ let px = ax0, py = ay0;
484
+ const edge = (fromX, fromY, toX, toY) => {
485
+ const n = arc(Math.hypot(toX - fromX, toY - fromY));
486
+ for (let i = 1; i <= n; i++) {
487
+ const qx = fromX + (toX - fromX) * (i / n), qy = fromY + (toY - fromY) * (i / n);
488
+ const [c1x, c1y, c2x, c2y] = arcToBezier(px, py, qx, qy, r, 0, 1);
489
+ segments.push([[c1x, c1y], [c2x, c2y], [qx, qy]]);
490
+ px = qx;
491
+ py = qy;
492
+ }
493
+ };
494
+ edge(ax0, ay0, ax1, ay0);
495
+ edge(ax1, ay0, ax1, ay1);
496
+ edge(ax1, ay1, ax0, ay1);
497
+ edge(ax0, ay1, ax0, ay0);
498
+ return { start: [ax0, ay0], segments };
499
+ }
500
+ function buildSnapGrid(points, cell) {
501
+ const map = /* @__PURE__ */ new Map();
502
+ for (const p of points) {
503
+ const k = `${Math.floor(p[0] / cell)},${Math.floor(p[1] / cell)}`;
504
+ let a = map.get(k);
505
+ if (!a) {
506
+ a = [];
507
+ map.set(k, a);
508
+ }
509
+ if (a.length < 40) a.push(p);
510
+ }
511
+ return { cell, map };
512
+ }
513
+ function nearestSnap(grid, x, y, maxDist) {
514
+ if (!grid) return null;
515
+ const { cell, map } = grid, cx = Math.floor(x / cell), cy = Math.floor(y / cell);
516
+ let best = null, bestD = maxDist * maxDist;
517
+ for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) {
518
+ const a = map.get(`${gx},${gy}`);
519
+ if (!a) continue;
520
+ for (const p of a) {
521
+ const dx = p[0] - x, dy = p[1] - y, d = dx * dx + dy * dy;
522
+ if (d < bestD) {
523
+ bestD = d;
524
+ best = p;
525
+ }
526
+ }
527
+ }
528
+ return best;
529
+ }
530
+ function closedMetrics(pts) {
531
+ const n = pts.length;
532
+ if (n < 3) {
533
+ let perim2 = 0;
534
+ for (let i = 1; i < n; i++) perim2 += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
535
+ return { area: 0, perim: perim2 };
536
+ }
537
+ let area = 0, perim = 0;
538
+ for (let i = 0; i < n; i++) {
539
+ const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % n];
540
+ area += x1 * y2 - x2 * y1;
541
+ perim += Math.hypot(x2 - x1, y2 - y1);
542
+ }
543
+ return { area: Math.abs(area) / 2, perim };
544
+ }
545
+ function openLen(pts) {
546
+ let L = 0;
547
+ for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
548
+ return L;
549
+ }
550
+ function pointInPoly(x, y, pts) {
551
+ let inside = false;
552
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
553
+ const [xi, yi] = pts[i], [xj, yj] = pts[j];
554
+ if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
555
+ }
556
+ return inside;
557
+ }
558
+ function chiselRibbon(pts, w, nibDeg = 45) {
559
+ const a = nibDeg * Math.PI / 180, vx = Math.cos(a) * w / 2, vy = -(Math.sin(a) * w) / 2;
560
+ return [...pts.map(([x, y]) => [x + vx, y + vy]), ...[...pts].reverse().map(([x, y]) => [x - vx, y - vy])];
561
+ }
562
+
411
563
  // ../web/src/lib/oneclick.ts
412
564
  var MASK_MAX_DIM = 3e3;
565
+ function baselineImgDims(pageW, pageH, baseScale) {
566
+ const bs = Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
567
+ const w = Number.isFinite(pageW) && pageW > 0 ? pageW : 1;
568
+ const h = Number.isFinite(pageH) && pageH > 0 ? pageH : 1;
569
+ return { w: Math.max(1, Math.ceil(w * bs)), h: Math.max(1, Math.ceil(h * bs)) };
570
+ }
413
571
  var LEAK_FRACTION = 0.3;
414
- var TINY_PX = 30;
415
- var MIN_THICK = 4;
416
572
  var CURVE_STEPS = 8;
417
573
  var GAP_BRIDGE_MAX = 2;
574
+ var CAL_MPPF = 18;
575
+ var TINY_PX = 30;
576
+ var TINY_SF = TINY_PX / (CAL_MPPF * CAL_MPPF);
577
+ var TINY_PX_FLOOR = 8;
578
+ var MIN_THICK = 4;
579
+ var MIN_THICK_FT = MIN_THICK / CAL_MPPF;
580
+ var MIN_THICK_FLOOR = 2;
581
+ var NUDGE_PX = 3;
582
+ var NUDGE_FT = NUDGE_PX / CAL_MPPF;
418
583
  var SEG_CURVE = 1;
419
584
  var SEG_CLIP = 2;
420
585
  var SEG_FILLONLY = 4;
586
+ var SEG_POLYARC = 8;
587
+ var ARC_MIN_CHORDS = 4;
588
+ var ARC_MIN_TOTAL_TURN = 30;
589
+ var ARC_CHORD_TURN_MIN = 2;
590
+ var ARC_CHORD_TURN_MAX = 45;
591
+ var ARC_FIT_TOL_FRAC = 0.03;
592
+ var ARC_CLOSED_TURN = 300;
593
+ var ARC_CUSP_MIN = 3;
594
+ var ARC_CUSP_R_RATIO = 1.5;
595
+ var ARC_CUSP_SPAN_MULT = 8;
596
+ var MASK_NODOOR_BIT = 8;
421
597
  var HATCH_ANGLE_TOL = 2;
422
- var HATCH_MIN_RUN = 10;
423
598
  var HATCH_MAX_PITCH = 24;
424
- var HATCH_PITCH_TOL = 0.35;
425
- var HATCH_MIN_REGULAR = 0.7;
426
- var HATCH_OVERLAP_FRAC = 0.5;
427
- var ROW_EPS = 1.5;
428
- var WIDE_PROTECT_RATIO = 2;
429
- var SPAN_PROTECT_RATIO = 3;
599
+ var HATCH_MAX_PITCH_FT = HATCH_MAX_PITCH / 18;
430
600
  var HATCH_BOUND_FRAC = 0.7;
431
- var HATCH_ESCALATE_FRAC = 0.35;
601
+ var HATCH_ESCALATE_FRAC = 0.02;
432
602
  var HATCH_GROWTH_MAX = 2.5;
433
603
  var SENS_STRICT = 0;
434
604
  var SENS_BALANCED = 0.5;
@@ -438,8 +608,8 @@ var SENS_ANCHORS = [
438
608
  // moderate band empties (escalateFrac == HATCH_BOUND_FRAC) ⇒ pre-#32
439
609
  [SENS_BALANCED, HATCH_ESCALATE_FRAC, HATCH_GROWTH_MAX],
440
610
  // calibrated on the sample plan (issue #32)
441
- [SENS_AGGRESSIVE, 0.2, 4]
442
- // cross more hatch, tolerate more growth
611
+ [SENS_AGGRESSIVE, 0, 4]
612
+ // cross any hatch, tolerate more growth
443
613
  ];
444
614
  function escalationParams(sensitivity) {
445
615
  const s = Math.max(0, Math.min(1, Number.isFinite(sensitivity) ? sensitivity : SENS_BALANCED));
@@ -636,8 +806,349 @@ function extractVectorGeometry(opList, transform, OPS3) {
636
806
  }
637
807
  }
638
808
  }
639
- return { points, segs, meta: Uint8Array.from(metaArr), imageArea, layerOf: Int32Array.from(layerOfArr), layerIds };
809
+ const meta = Uint8Array.from(metaArr);
810
+ markPolylineArcs(segs, meta);
811
+ return { points, segs, meta, imageArea, layerOf: Int32Array.from(layerOfArr), layerIds };
812
+ }
813
+ function markPolylineArcs(segs, meta) {
814
+ const n = segs.length >> 2;
815
+ if (!meta || n < ARC_MIN_CHORDS) return 0;
816
+ let marked = 0;
817
+ const len = (i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
818
+ let chain = [];
819
+ const flush = () => {
820
+ if (chain.length >= ARC_MIN_CHORDS) marked += scanChainForArcs(segs, meta, chain);
821
+ chain = [];
822
+ };
823
+ for (let i = 0; i < n; i++) {
824
+ if (meta[i] & (SEG_CURVE | SEG_CLIP)) {
825
+ flush();
826
+ continue;
827
+ }
828
+ if (len(i) < 0.5) continue;
829
+ if (chain.length) {
830
+ const p = chain[chain.length - 1];
831
+ const gap = Math.hypot(segs[i * 4] - segs[p * 4 + 2], segs[i * 4 + 1] - segs[p * 4 + 3]);
832
+ if (meta[i] !== meta[p] || gap > Math.max(len(i), len(p))) flush();
833
+ }
834
+ chain.push(i);
835
+ }
836
+ flush();
837
+ return marked;
838
+ }
839
+ function scanChainForArcs(segs, meta, chain) {
840
+ let marked = 0;
841
+ for (const w of scanChainWindows(segs, chain)) {
842
+ const cm = meta[chain[w.c0]];
843
+ for (let j = chain[w.c0]; j <= chain[w.c1]; j++) if (meta[j] === cm) meta[j] |= SEG_CURVE | SEG_POLYARC;
844
+ marked += w.c1 - w.c0 + 1;
845
+ }
846
+ return marked;
847
+ }
848
+ function scanChainWindows(segs, chain) {
849
+ const m = chain.length;
850
+ const dirs = [], lens = [];
851
+ for (const i of chain) {
852
+ const dx = segs[i * 4 + 2] - segs[i * 4], dy = segs[i * 4 + 3] - segs[i * 4 + 1];
853
+ dirs.push(Math.atan2(dy, dx) * 180 / Math.PI);
854
+ lens.push(Math.hypot(dx, dy));
855
+ }
856
+ const turn = [0];
857
+ for (let k = 1; k < m; k++) {
858
+ let t = dirs[k] - dirs[k - 1];
859
+ if (t > 180) t -= 360;
860
+ if (t <= -180) t += 360;
861
+ turn.push(t);
862
+ }
863
+ const ratioOk = (k) => {
864
+ const r = Math.max(lens[k], lens[k - 1]) / Math.max(1e-9, Math.min(lens[k], lens[k - 1]));
865
+ return r <= 3;
866
+ };
867
+ const signedTurn = (k) => Math.abs(turn[k]) >= ARC_CHORD_TURN_MIN && Math.abs(turn[k]) <= ARC_CHORD_TURN_MAX && ratioOk(k);
868
+ const neutral = (k) => Math.abs(turn[k]) < ARC_CHORD_TURN_MIN && ratioOk(k);
869
+ const out = [];
870
+ let s = 1;
871
+ while (s < m) {
872
+ if (!signedTurn(s)) {
873
+ s++;
874
+ continue;
875
+ }
876
+ const sgn = Math.sign(turn[s]);
877
+ let e = s, bridged = false;
878
+ for (let k = s + 1; k < m; k++) {
879
+ if (signedTurn(k) && Math.sign(turn[k]) === sgn) {
880
+ e = k;
881
+ bridged = false;
882
+ continue;
883
+ }
884
+ if (neutral(k) && !bridged) {
885
+ bridged = true;
886
+ continue;
887
+ }
888
+ break;
889
+ }
890
+ if (e - s + 2 >= ARC_MIN_CHORDS) {
891
+ let total = 0;
892
+ for (let j = s; j <= e; j++) total += Math.abs(turn[j]);
893
+ if (total >= ARC_MIN_TOTAL_TURN) {
894
+ for (const [c0, c1] of [[s - 1, e], [s, e], [s - 1, e - 1], [s, e - 1]]) {
895
+ if (c1 - c0 + 1 < ARC_MIN_CHORDS) continue;
896
+ let t = 0, signed = 0;
897
+ for (let j = c0 + 1; j <= c1; j++) {
898
+ t += Math.abs(turn[j]);
899
+ signed += turn[j];
900
+ }
901
+ const fit = t < ARC_MIN_TOTAL_TURN ? null : circleFitOk(segs, chain, c0, c1);
902
+ if (!fit) continue;
903
+ out.push({ c0, c1, turn: signed, r: fit.r });
904
+ break;
905
+ }
906
+ }
907
+ }
908
+ s = e + 1;
909
+ }
910
+ return out;
911
+ }
912
+ function flagNonDoorArcs(segs, meta) {
913
+ const n = segs.length >> 2;
914
+ const veto = new Uint8Array(n);
915
+ if (!meta || n < ARC_MIN_CHORDS) return veto;
916
+ const len = (i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
917
+ let chain = [];
918
+ const flush = () => {
919
+ if (chain.length >= ARC_MIN_CHORDS) judgeChain(segs, chain, veto);
920
+ chain = [];
921
+ };
922
+ for (let i = 0; i < n; i++) {
923
+ if (len(i) < 0.5) continue;
924
+ if (!(meta[i] & SEG_CURVE) || meta[i] & SEG_CLIP) {
925
+ flush();
926
+ continue;
927
+ }
928
+ if (chain.length) {
929
+ const p = chain[chain.length - 1];
930
+ const gap = Math.hypot(segs[i * 4] - segs[p * 4 + 2], segs[i * 4 + 1] - segs[p * 4 + 3]);
931
+ if (meta[i] !== meta[p] || gap > Math.max(len(i), len(p))) flush();
932
+ }
933
+ chain.push(i);
934
+ }
935
+ flush();
936
+ return veto;
937
+ }
938
+ function judgeChain(segs, chain, veto) {
939
+ const wins = scanChainWindows(segs, chain);
940
+ if (!wins.length) return;
941
+ const stamp = (w) => {
942
+ for (let j = chain[w.c0]; j <= chain[w.c1]; j++) veto[j] = 1;
943
+ };
944
+ let closed = false;
945
+ for (const w of wins) if (Math.abs(w.turn) >= ARC_CLOSED_TURN) {
946
+ stamp(w);
947
+ closed = true;
948
+ }
949
+ if (closed) return;
950
+ if (wins.length <= ARC_CUSP_MIN) return;
951
+ let run2 = 1, best = 1;
952
+ for (let k = 1; k < wins.length; k++) {
953
+ if (wins[k].c0 - wins[k - 1].c1 <= 2 && Math.sign(wins[k].turn) === Math.sign(wins[k - 1].turn)) run2++;
954
+ else run2 = 1;
955
+ if (run2 > best) best = run2;
956
+ }
957
+ if (best <= ARC_CUSP_MIN) return;
958
+ let rmin = Infinity, rmax = 0, span = 0;
959
+ for (const w of wins) {
960
+ if (w.r < rmin) rmin = w.r;
961
+ if (w.r > rmax) rmax = w.r;
962
+ }
963
+ for (const i of chain) span += Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
964
+ if (!(rmin > 0) || rmax > rmin * ARC_CUSP_R_RATIO) return;
965
+ if (rmax * ARC_CUSP_SPAN_MULT > span) return;
966
+ for (const w of wins) stamp(w);
967
+ }
968
+ function circleFitOk(segs, chain, c0, c1) {
969
+ const xs = [], ys = [];
970
+ xs.push(segs[chain[c0] * 4]);
971
+ ys.push(segs[chain[c0] * 4 + 1]);
972
+ for (let k = c0; k <= c1; k++) {
973
+ const i = chain[k];
974
+ xs.push(segs[i * 4 + 2]);
975
+ ys.push(segs[i * 4 + 3]);
976
+ }
977
+ const m = xs.length;
978
+ let mx = 0, my = 0;
979
+ for (let i = 0; i < m; i++) {
980
+ mx += xs[i];
981
+ my += ys[i];
982
+ }
983
+ mx /= m;
984
+ my /= m;
985
+ let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
986
+ for (let i = 0; i < m; i++) {
987
+ const x = xs[i] - mx, y = ys[i] - my, z3 = x * x + y * y;
988
+ sxx += x * x;
989
+ sxy += x * y;
990
+ syy += y * y;
991
+ sxz += x * z3;
992
+ syz += y * z3;
993
+ }
994
+ const det = sxx * syy - sxy * sxy;
995
+ if (Math.abs(det) < 1e-9) return null;
996
+ const cx = (sxz * syy - syz * sxy) / (2 * det);
997
+ const cy = (syz * sxx - sxz * sxy) / (2 * det);
998
+ let r = 0;
999
+ for (let i = 0; i < m; i++) r += Math.hypot(xs[i] - mx - cx, ys[i] - my - cy);
1000
+ r /= m;
1001
+ if (!(r > 0)) return null;
1002
+ const tol = Math.max(0.75, r * ARC_FIT_TOL_FRAC);
1003
+ for (let i = 0; i < m; i++) {
1004
+ if (Math.abs(Math.hypot(xs[i] - mx - cx, ys[i] - my - cy) - r) > tol) return null;
1005
+ }
1006
+ return { cx: cx + mx, cy: cy + my, r };
1007
+ }
1008
+ function classifyHatchSegs(segs, meta, ws, pitchCapPx = HATCH_MAX_PITCH) {
1009
+ const n = segs.length >> 2;
1010
+ const soft = new Uint8Array(n);
1011
+ if (!meta || !n) return soft;
1012
+ const HALF_CELL = 0.5;
1013
+ const cand = [];
1014
+ for (let i = 0; i < n; i++) {
1015
+ const mt = meta[i];
1016
+ if (mt & SEG_CURVE) continue;
1017
+ if (mt & SEG_CLIP) {
1018
+ soft[i] = 1;
1019
+ continue;
1020
+ }
1021
+ if (mt & SEG_FILLONLY) continue;
1022
+ const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
1023
+ const dx = x2 - x1, dy = y2 - y1;
1024
+ const len = Math.hypot(dx, dy);
1025
+ if (len < 0.75) continue;
1026
+ let ang = Math.atan2(dy, dx) * 180 / Math.PI;
1027
+ if (ang < 0) ang += 180;
1028
+ if (ang >= 180) ang -= 180;
1029
+ cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
1030
+ }
1031
+ if (cand.length < 5) return soft;
1032
+ cand.sort((a, b) => a.ang - b.ang);
1033
+ const clusters = [];
1034
+ let cl = [cand[0]];
1035
+ for (let k = 1; k < cand.length; k++) {
1036
+ if (cand[k].ang - cand[k - 1].ang <= HATCH_ANGLE_TOL) cl.push(cand[k]);
1037
+ else {
1038
+ clusters.push(cl);
1039
+ cl = [cand[k]];
1040
+ }
1041
+ }
1042
+ clusters.push(cl);
1043
+ if (clusters.length > 1) {
1044
+ const first = clusters[0], last = clusters[clusters.length - 1];
1045
+ if (first[0].ang < HATCH_ANGLE_TOL && last[last.length - 1].ang > 180 - HATCH_ANGLE_TOL) {
1046
+ for (const s of last) s.ang -= 180;
1047
+ clusters[0] = last.concat(first);
1048
+ clusters.pop();
1049
+ }
1050
+ }
1051
+ for (const members of clusters) {
1052
+ if (members.length < 5) continue;
1053
+ let sum = 0;
1054
+ for (const s of members) sum += s.ang;
1055
+ const th = sum / members.length * Math.PI / 180;
1056
+ const dxu = Math.cos(th), dyu = Math.sin(th);
1057
+ const nxu = -dyu, nyu = dxu;
1058
+ const pieces = members.map((s) => ({
1059
+ i: s.i,
1060
+ d: (s.x1 + s.x2) / 2 * nxu + (s.y1 + s.y2) / 2 * nyu,
1061
+ t0: Math.min(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu),
1062
+ t1: Math.max(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu),
1063
+ w: s.w
1064
+ })).sort((a, b) => a.d - b.d);
1065
+ const rowOf = new Int32Array(pieces.length);
1066
+ const rowD = [];
1067
+ const rowPieces = [];
1068
+ for (let k = 0; k < pieces.length; k++) {
1069
+ if (rowD.length && pieces[k].d - rowD[rowD.length - 1] <= HALF_CELL) {
1070
+ rowOf[k] = rowD.length - 1;
1071
+ rowPieces[rowPieces.length - 1].push(pieces[k]);
1072
+ } else {
1073
+ rowOf[k] = rowD.length;
1074
+ rowD.push(pieces[k].d);
1075
+ rowPieces.push([pieces[k]]);
1076
+ }
1077
+ }
1078
+ for (const rp of rowPieces) rp.sort((a, b) => a.t0 - b.t0);
1079
+ const rowMaxT1 = rowPieces.map((rp) => {
1080
+ const m = new Array(rp.length);
1081
+ let mx = -Infinity;
1082
+ for (let i = 0; i < rp.length; i++) {
1083
+ mx = Math.max(mx, rp[i].t1);
1084
+ m[i] = mx;
1085
+ }
1086
+ return m;
1087
+ });
1088
+ const rowHas = (j, w, t0, t1) => {
1089
+ const P = rowPieces[j], M = rowMaxT1[j];
1090
+ let lo = 0, hi = P.length - 1, last = -1;
1091
+ while (lo <= hi) {
1092
+ const mid = lo + hi >> 1;
1093
+ if (P[mid].t0 <= t1 - HALF_CELL) {
1094
+ last = mid;
1095
+ lo = mid + 1;
1096
+ } else hi = mid - 1;
1097
+ }
1098
+ for (let i = last; i >= 0; i--) {
1099
+ if (M[i] < t0 + HALF_CELL) break;
1100
+ const p = P[i];
1101
+ if (p.w === w && Math.min(p.t1, t1) - Math.max(p.t0, t0) >= HALF_CELL) return true;
1102
+ }
1103
+ return false;
1104
+ };
1105
+ const nearestRow = (j, dir, w, t0, t1) => {
1106
+ for (let r = j + dir; r >= 0 && r < rowD.length; r += dir) {
1107
+ if (Math.abs(rowD[r] - rowD[j]) > pitchCapPx + HALF_CELL) break;
1108
+ if (rowHas(r, w, t0, t1)) return r;
1109
+ }
1110
+ return -1;
1111
+ };
1112
+ const rowAt = (target, w, t0, t1) => {
1113
+ let lo = 0, hi = rowD.length - 1;
1114
+ while (lo < hi) {
1115
+ const mid = lo + hi >> 1;
1116
+ if (rowD[mid] < target - HALF_CELL) lo = mid + 1;
1117
+ else hi = mid;
1118
+ }
1119
+ for (let r = lo; r < rowD.length && rowD[r] <= target + HALF_CELL; r++) {
1120
+ if (rowD[r] >= target - HALF_CELL && rowHas(r, w, t0, t1)) return true;
1121
+ }
1122
+ return false;
1123
+ };
1124
+ for (let k = 0; k < pieces.length; k++) {
1125
+ const c = pieces[k];
1126
+ const j = rowOf[k];
1127
+ const up = nearestRow(j, 1, c.w, c.t0, c.t1);
1128
+ const dn = nearestRow(j, -1, c.w, c.t0, c.t1);
1129
+ if (up < 0 || dn < 0) continue;
1130
+ const pUp = rowD[up] - rowD[j], pDn = rowD[j] - rowD[dn];
1131
+ const at = (mult, p) => rowAt(rowD[j] + mult * p, c.w, c.t0, c.t1);
1132
+ for (const p of pUp === pDn ? [pUp] : [pUp, pDn]) {
1133
+ if (p < HALF_CELL || p > pitchCapPx + HALF_CELL) continue;
1134
+ const interior = at(1, p) && at(-1, p) && (at(2, p) || at(-2, p));
1135
+ const clipped = at(1, p) && at(2, p) && at(3, p) && pDn <= p + HALF_CELL || at(-1, p) && at(-2, p) && at(-3, p) && pUp <= p + HALF_CELL;
1136
+ if (interior || clipped) {
1137
+ soft[c.i] = 1;
1138
+ break;
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+ return soft;
640
1144
  }
1145
+ var HATCH_MIN_RUN = 10;
1146
+ var HATCH_PITCH_TOL = 0.35;
1147
+ var HATCH_MIN_REGULAR = 0.7;
1148
+ var HATCH_OVERLAP_FRAC = 0.5;
1149
+ var ROW_EPS = 1.5;
1150
+ var WIDE_PROTECT_RATIO = 2;
1151
+ var SPAN_PROTECT_RATIO = 3;
641
1152
  function sweepHatchRuns(segs, meta, ws) {
642
1153
  const n = segs.length >> 2;
643
1154
  const clipSoft = [];
@@ -770,13 +1281,6 @@ function sweepHatchRuns(segs, meta, ws) {
770
1281
  }
771
1282
  return { clipSoft, runs };
772
1283
  }
773
- function classifyHatchSegs(segs, meta, ws) {
774
- const soft = new Uint8Array(segs.length >> 2);
775
- const { clipSoft, runs } = sweepHatchRuns(segs, meta, ws);
776
- for (const i of clipSoft) soft[i] = 1;
777
- for (const r of runs) for (const i of r.softIdx) soft[i] = 1;
778
- return soft;
779
- }
780
1284
  var HATCH_ID_ANGLE_Q = 0.5;
781
1285
  var HATCH_ID_PITCH_Q = 0.1;
782
1286
  function hatchFamilies(segs, meta) {
@@ -796,16 +1300,38 @@ function hatchFamilies(segs, meta) {
796
1300
  };
797
1301
  });
798
1302
  }
799
- function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, roles = null) {
800
- const ws = Math.min(1, maxDim / Math.max(imgW, imgH, 1));
801
- const mw = Math.max(2, Math.ceil(imgW * ws)), mh = Math.max(2, Math.ceil(imgH * ws));
1303
+ var MASK_CURVE_BIT = 4;
1304
+ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt = 0, basePxPerFt = 0, page = null, roles = null) {
1305
+ if (pxPerFt instanceof Uint8Array || pxPerFt === null) {
1306
+ if (!roles) roles = pxPerFt;
1307
+ pxPerFt = 0;
1308
+ }
1309
+ const pg = page && Number.isFinite(page.pageW) && page.pageW > 0 && Number.isFinite(page.pageH) && page.pageH > 0 && Number.isFinite(page.baseScale) && page.baseScale > 0 && Number.isFinite(page.renderScale) && page.renderScale > 0 ? page : null;
1310
+ let k, bW, bH;
1311
+ if (pg) {
1312
+ k = pg.baseScale / pg.renderScale;
1313
+ const bd = baselineImgDims(pg.pageW, pg.pageH, pg.baseScale);
1314
+ bW = bd.w;
1315
+ bH = bd.h;
1316
+ } else {
1317
+ k = Number.isFinite(basePxPerFt) && basePxPerFt > 0 && Number.isFinite(pxPerFt) && pxPerFt > 0 ? basePxPerFt / pxPerFt : 1;
1318
+ bW = imgW * k;
1319
+ bH = imgH * k;
1320
+ }
1321
+ const wsB = Math.min(1, maxDim / Math.max(bW, bH, 1));
1322
+ const mw = Math.max(2, Math.ceil(bW * wsB)), mh = Math.max(2, Math.ceil(bH * wsB));
1323
+ const ws = k * wsB;
802
1324
  const mask = new Uint8Array(mw * mh);
803
- const soft = meta ? classifyHatchSegs(segs, meta, ws) : null;
1325
+ const mppf = Number.isFinite(pxPerFt) && pxPerFt > 0 ? pxPerFt * ws : 0;
1326
+ const soft = meta ? classifyHatchSegs(segs, meta, ws, mppf > 0 ? HATCH_MAX_PITCH_FT * mppf : HATCH_MAX_PITCH) : null;
1327
+ const noDoor = meta ? flagNonDoorArcs(segs, meta) : null;
804
1328
  let softCount = 0;
805
1329
  for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
806
1330
  const role = roles ? roles[si] : 0;
807
1331
  if (role === 2 || role === 3 || role === 5 || role === 6) continue;
808
- const v = role === 1 || role === 4 ? 1 : soft && soft[si] ? 2 : 1;
1332
+ let v = role === 1 || role === 4 ? 1 : soft && soft[si] ? 2 : 1;
1333
+ if (v === 1 && meta && meta[si] & SEG_CURVE) v = 1 | MASK_CURVE_BIT;
1334
+ if (v & MASK_CURVE_BIT && noDoor && noDoor[si]) v |= MASK_NODOOR_BIT;
809
1335
  if (v === 2) softCount++;
810
1336
  let x0 = Math.round(segs[i] * ws), y0 = Math.round(segs[i + 1] * ws);
811
1337
  const x1 = Math.round(segs[i + 2] * ws), y1 = Math.round(segs[i + 3] * ws);
@@ -826,18 +1352,44 @@ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, roles =
826
1352
  }
827
1353
  }
828
1354
  }
829
- return { mask, mw, mh, ws, softCount };
1355
+ return { mask, mw, mh, ws, softCount, mppf };
1356
+ }
1357
+ var regionBox = /* @__PURE__ */ new WeakMap();
1358
+ function boxOf(region, mw, mh) {
1359
+ return regionBox.get(region) || { x0: 0, y0: 0, x1: mw - 1, y1: mh - 1 };
1360
+ }
1361
+ var REGION_POOL_MAX = 2;
1362
+ var regionPool = [];
1363
+ function takeRegion(n) {
1364
+ for (let k = regionPool.length - 1; k >= 0; k--) {
1365
+ if (regionPool[k].length === n) {
1366
+ const b = regionPool.splice(k, 1)[0];
1367
+ b.fill(0);
1368
+ return b;
1369
+ }
1370
+ }
1371
+ return new Uint8Array(n);
1372
+ }
1373
+ function dropRegion(b) {
1374
+ if (regionPool.length < REGION_POOL_MAX) regionPool.push(b);
830
1375
  }
831
1376
  function floodPass(maskObj, ix, iy, barrier) {
832
1377
  const { mask, mw, mh, ws } = maskObj;
1378
+ const dilDT = maskObj.dilDT;
1379
+ const dilR = dilDT ? maskObj.dilR : -1;
1380
+ const bits = (i) => dilDT === void 0 ? mask[i] : (dilDT[i] <= dilR ? 1 : 0) | mask[i] & 2;
1381
+ const mppf = maskObj.mppf || 0;
1382
+ const tinyPx = mppf > 0 ? Math.max(TINY_PX_FLOOR, Math.round(TINY_SF * mppf * mppf)) : TINY_PX;
1383
+ const minThick = mppf > 0 ? Math.max(MIN_THICK_FLOOR, Math.round(MIN_THICK_FT * mppf)) : MIN_THICK;
1384
+ const nudge = mppf > 0 ? Math.max(NUDGE_PX, Math.round(NUDGE_FT * mppf)) : NUDGE_PX;
833
1385
  let sx = Math.round(ix * ws), sy = Math.round(iy * ws);
834
1386
  if (sx < 0 || sy < 0 || sx >= mw || sy >= mh) return { status: "boundary" };
835
- if (mask[sy * mw + sx] & barrier) {
1387
+ if (bits(sy * mw + sx) & barrier) {
836
1388
  let found = null;
837
- for (let r = 1; r <= 3 && !found; r++) {
1389
+ for (let r = 1; r <= nudge && !found; r++) {
838
1390
  for (let dy = -r; dy <= r && !found; dy++) for (let dx = -r; dx <= r; dx++) {
839
1391
  const nx = sx + dx, ny = sy + dy;
840
- if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && !(mask[ny * mw + nx] & barrier)) {
1392
+ if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && !(bits(ny * mw + nx) & barrier)) {
841
1393
  found = [nx, ny];
842
1394
  break;
843
1395
  }
@@ -847,34 +1399,68 @@ function floodPass(maskObj, ix, iy, barrier) {
847
1399
  sx = found[0];
848
1400
  sy = found[1];
849
1401
  }
850
- const region = new Uint8Array(mw * mh);
1402
+ const region = takeRegion(mw * mh);
851
1403
  const cap = Math.floor(mw * mh * LEAK_FRACTION);
852
- let count = 0, leaked = false, hardHits = 0, softHits = 0;
1404
+ let count = 0, hardHits = 0, softHits = 0;
853
1405
  let bx0 = sx, bx1 = sx, by0 = sy, by1 = sy;
854
- const stack = [[sx, sy]];
855
- while (stack.length) {
856
- const popped = stack.pop();
857
- const px = popped[0], py = popped[1];
1406
+ let stack = new Int32Array(1024);
1407
+ let sp = 0;
1408
+ const push = (x, y) => {
1409
+ if (sp === stack.length) {
1410
+ const g = new Int32Array(sp * 2);
1411
+ g.set(stack);
1412
+ stack = g;
1413
+ }
1414
+ stack[sp++] = y * mw + x;
1415
+ };
1416
+ const dil = dilDT !== void 0;
1417
+ const dtA = dilDT;
1418
+ push(sx, sy);
1419
+ while (sp > 0) {
1420
+ const cell = stack[--sp];
1421
+ const py = cell / mw | 0, px = cell - py * mw;
1422
+ const row = py * mw;
858
1423
  let x0 = px;
859
- while (x0 > 0 && !(mask[py * mw + x0 - 1] & barrier) && !region[py * mw + x0 - 1]) x0--;
860
- if (x0 > 0 && mask[py * mw + x0 - 1] & barrier) {
861
- if (mask[py * mw + x0 - 1] & 1) hardHits++;
862
- else softHits++;
1424
+ for (; ; ) {
1425
+ if (x0 === 0) break;
1426
+ const j = row + x0 - 1;
1427
+ if ((dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j]) & barrier) break;
1428
+ if (region[j]) break;
1429
+ x0--;
1430
+ }
1431
+ if (x0 > 0) {
1432
+ const j = row + x0 - 1, b = dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j];
1433
+ if (b & barrier) {
1434
+ if (b & 1) hardHits++;
1435
+ else softHits++;
1436
+ }
863
1437
  }
864
1438
  let x1 = px;
865
- while (x1 < mw - 1 && !(mask[py * mw + x1 + 1] & barrier) && !region[py * mw + x1 + 1]) x1++;
866
- if (x1 < mw - 1 && mask[py * mw + x1 + 1] & barrier) {
867
- if (mask[py * mw + x1 + 1] & 1) hardHits++;
868
- else softHits++;
1439
+ for (; ; ) {
1440
+ if (x1 >= mw - 1) break;
1441
+ const j = row + x1 + 1;
1442
+ if ((dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j]) & barrier) break;
1443
+ if (region[j]) break;
1444
+ x1++;
1445
+ }
1446
+ if (x1 < mw - 1) {
1447
+ const j = row + x1 + 1, b = dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j];
1448
+ if (b & barrier) {
1449
+ if (b & 1) hardHits++;
1450
+ else softHits++;
1451
+ }
1452
+ }
1453
+ if (x0 === 0 || x1 === mw - 1 || py === 0 || py === mh - 1) {
1454
+ dropRegion(region);
1455
+ return { status: "leak" };
869
1456
  }
870
- if (x0 === 0 || x1 === mw - 1 || py === 0 || py === mh - 1) leaked = true;
871
1457
  if (x0 < bx0) bx0 = x0;
872
1458
  if (x1 > bx1) bx1 = x1;
873
1459
  if (py < by0) by0 = py;
874
1460
  if (py > by1) by1 = py;
875
1461
  let upOpen = false, downOpen = false;
876
1462
  for (let x = x0; x <= x1; x++) {
877
- const idx = py * mw + x;
1463
+ const idx = row + x;
878
1464
  if (region[idx]) {
879
1465
  upOpen = downOpen = false;
880
1466
  continue;
@@ -883,14 +1469,15 @@ function floodPass(maskObj, ix, iy, barrier) {
883
1469
  count++;
884
1470
  if (py > 0) {
885
1471
  const u = idx - mw;
886
- if (!(mask[u] & barrier) && !region[u]) {
1472
+ const ub = dil ? (dtA[u] <= dilR ? 1 : 0) | mask[u] & 2 : mask[u];
1473
+ if (!(ub & barrier) && !region[u]) {
887
1474
  if (!upOpen) {
888
- stack.push([x, py - 1]);
1475
+ push(x, py - 1);
889
1476
  upOpen = true;
890
1477
  }
891
1478
  } else {
892
- if (mask[u] & barrier) {
893
- if (mask[u] & 1) hardHits++;
1479
+ if (ub & barrier) {
1480
+ if (ub & 1) hardHits++;
894
1481
  else softHits++;
895
1482
  }
896
1483
  upOpen = false;
@@ -898,28 +1485,35 @@ function floodPass(maskObj, ix, iy, barrier) {
898
1485
  }
899
1486
  if (py < mh - 1) {
900
1487
  const d = idx + mw;
901
- if (!(mask[d] & barrier) && !region[d]) {
1488
+ const db = dil ? (dtA[d] <= dilR ? 1 : 0) | mask[d] & 2 : mask[d];
1489
+ if (!(db & barrier) && !region[d]) {
902
1490
  if (!downOpen) {
903
- stack.push([x, py + 1]);
1491
+ push(x, py + 1);
904
1492
  downOpen = true;
905
1493
  }
906
1494
  } else {
907
- if (mask[d] & barrier) {
908
- if (mask[d] & 1) hardHits++;
1495
+ if (db & barrier) {
1496
+ if (db & 1) hardHits++;
909
1497
  else softHits++;
910
1498
  }
911
1499
  downOpen = false;
912
1500
  }
913
1501
  }
914
1502
  }
915
- if (count > cap) return { status: "leak" };
1503
+ if (count > cap) {
1504
+ dropRegion(region);
1505
+ return { status: "leak" };
1506
+ }
1507
+ }
1508
+ if (count < tinyPx || bx1 - bx0 + 1 < minThick || by1 - by0 + 1 < minThick) {
1509
+ dropRegion(region);
1510
+ return { status: "tiny", count };
916
1511
  }
917
- if (leaked) return { status: "leak" };
918
- if (count < TINY_PX || bx1 - bx0 + 1 < MIN_THICK || by1 - by0 + 1 < MIN_THICK) return { status: "tiny", count };
919
- return { status: "ok", region, count, mw, mh, ws, hardHits, softHits };
1512
+ regionBox.set(region, { x0: bx0, y0: by0, x1: bx1, y1: by1 });
1513
+ return { status: "ok", region, count, mw, mh, ws, mppf: mppf || void 0, hardHits, softHits };
920
1514
  }
921
1515
  function dilateHard(maskObj, r) {
922
- const { mask, mw, mh, ws, softCount } = maskObj;
1516
+ const { mask, mw, mh, ws, softCount, mppf } = maskObj;
923
1517
  const horiz = new Uint8Array(mask);
924
1518
  for (let y = 0; y < mh; y++) {
925
1519
  const row = y * mw;
@@ -940,32 +1534,27 @@ function dilateHard(maskObj, r) {
940
1534
  }
941
1535
  }
942
1536
  }
943
- return { mask: out, mw, mh, ws, softCount };
1537
+ return { mask: out, mw, mh, ws, softCount, mppf };
1538
+ }
1539
+ var bridgeCache = /* @__PURE__ */ new WeakMap();
1540
+ function bridgedMask(mo, r) {
1541
+ let per = bridgeCache.get(mo.mask);
1542
+ if (!per) {
1543
+ per = [];
1544
+ bridgeCache.set(mo.mask, per);
1545
+ }
1546
+ let m = per[r];
1547
+ if (!m) {
1548
+ m = dilateHard(mo, r).mask;
1549
+ per[r] = m;
1550
+ }
1551
+ return { mask: m, mw: mo.mw, mh: mo.mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf };
944
1552
  }
945
1553
  function floodRegion(maskObj, ix, iy, sensitivity = SENS_BALANCED) {
946
- const attempt = (m) => {
947
- const r1 = floodPass(m, ix, iy, 3);
948
- if (!m.softCount) return r1;
949
- if (r1.status === "leak") return r1;
950
- const { escalateFrac, growthMax } = escalationParams(sensitivity);
951
- let growthCap = Infinity;
952
- if (r1.status === "ok") {
953
- const blocks = (r1.hardHits || 0) + (r1.softHits || 0);
954
- const softFrac = blocks ? (r1.softHits || 0) / blocks : 0;
955
- if (softFrac < escalateFrac) return r1;
956
- if (softFrac < HATCH_BOUND_FRAC) growthCap = growthMax;
957
- }
958
- const r2 = floodPass(m, ix, iy, 1);
959
- if (r2.status === "ok" && (r1.status !== "ok" || r2.count <= r1.count * growthCap)) {
960
- r2.hatchFiltered = true;
961
- return r2;
962
- }
963
- return r1;
964
- };
965
- const r = attempt(maskObj);
1554
+ const r = floodRegionLadder(maskObj, ix, iy, sensitivity);
966
1555
  if (r.status !== "leak") return r;
967
1556
  for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
968
- const rb = attempt(dilateHard(maskObj, br));
1557
+ const rb = floodRegionLadder(bridgedMask(maskObj, br), ix, iy, sensitivity);
969
1558
  if (rb.status === "ok") {
970
1559
  rb.gapBridged = br;
971
1560
  return rb;
@@ -973,12 +1562,37 @@ function floodRegion(maskObj, ix, iy, sensitivity = SENS_BALANCED) {
973
1562
  }
974
1563
  return r;
975
1564
  }
1565
+ function floodRegionLadder(maskObj, ix, iy, sensitivity) {
1566
+ const r1 = floodPass(maskObj, ix, iy, 3);
1567
+ if (!maskObj.softCount) return r1;
1568
+ if (r1.status === "leak") return r1;
1569
+ const { escalateFrac, growthMax } = escalationParams(sensitivity);
1570
+ let growthCap = Infinity;
1571
+ if (r1.status === "ok") {
1572
+ const blocks = (r1.hardHits || 0) + (r1.softHits || 0);
1573
+ const softFrac = blocks ? (r1.softHits || 0) / blocks : 0;
1574
+ if (softFrac < escalateFrac) return r1;
1575
+ if (softFrac < HATCH_BOUND_FRAC) growthCap = growthMax;
1576
+ }
1577
+ const r2 = floodPass(maskObj, ix, iy, 1);
1578
+ if (r2.status === "ok" && (r1.status !== "ok" || r2.count > r1.count && r2.count <= r1.count * growthCap)) {
1579
+ r2.hatchFiltered = true;
1580
+ r2.hatchTier = growthCap !== Infinity ? "bounded" : r1.status === "ok" ? "override" : "trapped";
1581
+ return r2;
1582
+ }
1583
+ return r1;
1584
+ }
1585
+ var WEDGE_RIM_FT = 3 / CAL_MPPF;
976
1586
  function traceRegion(reg, epsMaskPx = 1.5) {
977
1587
  const { region, mw, mh, ws } = reg;
978
1588
  let s = -1;
979
- for (let i = 0; i < region.length; i++) if (region[i]) {
980
- s = i;
981
- break;
1589
+ const b = boxOf(region, mw, mh);
1590
+ for (let y = b.y0; y <= b.y1 && s < 0; y++) {
1591
+ const row = y * mw;
1592
+ for (let x = b.x0; x <= b.x1; x++) if (region[row + x]) {
1593
+ s = row + x;
1594
+ break;
1595
+ }
982
1596
  }
983
1597
  if (s < 0) return [];
984
1598
  const sx = s % mw, sy = s / mw | 0;
@@ -1069,10 +1683,12 @@ function ringArea(pts) {
1069
1683
  // ../web/src/lib/detectRooms.ts
1070
1684
  var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
1071
1685
  var BUBBLE_RATIO = 2.5;
1072
- function seedLadderPx(b) {
1686
+ function seedLadderPx(b, first = "anchor") {
1073
1687
  const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
1074
1688
  const h = Math.max(b.y1 - b.y0, 1);
1075
- return [[cx, cy], [cx, cy + 2 * h], [cx, cy - 2 * h], [cx, cy + 3.5 * h]];
1689
+ const center = [cx, cy], below = [cx, cy + 2 * h];
1690
+ const rest = [[cx, cy - 2 * h], [cx, cy + 3.5 * h]];
1691
+ return first === "below-box" ? [below, center, ...rest] : [center, below, ...rest];
1076
1692
  }
1077
1693
  function isLabelBubblePx(ring, b) {
1078
1694
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
@@ -1090,7 +1706,8 @@ function isLabelBubblePx(ring, b) {
1090
1706
  var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
1091
1707
  var merge = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
1092
1708
  var norm = (s) => (s || "").trim().toUpperCase();
1093
- var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.-]+)?$/;
1709
+ var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str || "").trim().length >= 4 && (s.w || 0) > 0 && (s.h || 0) > 2 * s.w;
1710
+ var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
1094
1711
  var ROLE_SIGNALS = [
1095
1712
  { re: /DEMOLITION\s+PLAN|DEMO\s+PLAN/, role: "demolition", conf: 0.9 },
1096
1713
  { re: /FINISH\s+PLAN|FLOOR\s+PLAN|FURNITURE\s+PLAN|CEILING\s+PLAN/, role: "plan", conf: 0.85 },
@@ -1125,6 +1742,22 @@ function classifySheetRole(sheet) {
1125
1742
  evidence: { sheet: sheet.key, text: best.span.str.trim(), bbox: bboxOf(best.span) }
1126
1743
  };
1127
1744
  }
1745
+ var BUILDING_RE = /\b(?:BUILDING|BLDG\.?)\s+([A-Z]\d?|\d{1,2})\b/g;
1746
+ var DESIGNATOR_RE = /^([A-Z]\d?|\d{1,2}|[A-Z]{2})$/;
1747
+ function buildingMentions(text) {
1748
+ const u = norm(text);
1749
+ if (u.length > 80 || REFERENCE_RE.test(u)) return [];
1750
+ return [...u.matchAll(BUILDING_RE)].map((m) => m[1]);
1751
+ }
1752
+ function sheetBuilding(sheet) {
1753
+ const seen = /* @__PURE__ */ new Map();
1754
+ for (const sp of sheet.spans) {
1755
+ for (const b of buildingMentions(sp.str)) if (!seen.has(b)) seen.set(b, sp);
1756
+ }
1757
+ if (seen.size !== 1) return null;
1758
+ const [building, span] = [...seen.entries()][0];
1759
+ return { building, evidence: { sheet: sheet.key, text: span.str.trim(), bbox: bboxOf(span) } };
1760
+ }
1128
1761
  function clusterRows(spans) {
1129
1762
  const toks = spans.filter((t) => t.str && t.str.trim()).sort((a, b) => a.y - b.y || a.x - b.x);
1130
1763
  const rows = [];
@@ -1142,7 +1775,8 @@ function clusterRows(spans) {
1142
1775
  if (cur.length) rows.push(cur);
1143
1776
  return rows.map((r) => r.sort((a, b) => a.x - b.x));
1144
1777
  }
1145
- var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT"];
1778
+ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
1779
+ var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "BLDG", "BUILDING"];
1146
1780
  var FINISH_HEADERS = ["CODE", "MARK", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN"];
1147
1781
  var headerLabel = (s, vocab) => {
1148
1782
  for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w)) return w;
@@ -1164,33 +1798,101 @@ function findHeaderRow(rows, vocab, required, minHits) {
1164
1798
  }
1165
1799
  return null;
1166
1800
  }
1801
+ function findRotatedHeader(vert, vocab, required, minHits) {
1802
+ const cands = vert.map((sp) => ({ sp, label: headerLabel(sp.str, vocab) })).filter((c) => !!c.label).sort((a, b) => a.sp.x - b.sp.x);
1803
+ let band = [];
1804
+ let y0 = 0, y1 = 0;
1805
+ const flush = () => {
1806
+ const seen = new Set(band.map((c) => c.label));
1807
+ if (band.length < minHits || seen.size < minHits || !required.some((r) => seen.has(r))) return null;
1808
+ const anchors = [];
1809
+ const used = /* @__PURE__ */ new Set();
1810
+ for (const c of band) if (!used.has(c.label)) {
1811
+ used.add(c.label);
1812
+ anchors.push({ label: c.label, x: c.sp.x + (c.sp.w || 0) / 2 });
1813
+ }
1814
+ return { anchors: anchors.sort((a, b) => a.x - b.x), top: y0, bottom: y1, spans: band.map((c) => c.sp) };
1815
+ };
1816
+ for (const c of cands) {
1817
+ const cy0 = c.sp.y, cy1 = c.sp.y + (c.sp.h || 0);
1818
+ if (band.length && (cy0 > y1 || cy1 < y0)) {
1819
+ const done = flush();
1820
+ if (done) return done;
1821
+ band = [];
1822
+ }
1823
+ if (!band.length) {
1824
+ y0 = cy0;
1825
+ y1 = cy1;
1826
+ } else {
1827
+ y0 = Math.min(y0, cy0);
1828
+ y1 = Math.max(y1, cy1);
1829
+ }
1830
+ band.push(c);
1831
+ }
1832
+ return band.length ? flush() : null;
1833
+ }
1167
1834
  var nearestAnchor = (x, anchors) => {
1168
1835
  let best = anchors[0];
1169
1836
  for (const a of anchors) if (Math.abs(a.x - x) < Math.abs(best.x - x)) best = a;
1170
1837
  return best.label;
1171
1838
  };
1839
+ function bandLimits(anchors) {
1840
+ const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
1841
+ const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
1842
+ return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: anchors[anchors.length - 1].x + Math.max(300, medGap * 3), medGap };
1843
+ }
1172
1844
  var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
1173
1845
  var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
1174
- function extractTable(sheet, kind) {
1175
- const rows = clusterRows(sheet.spans);
1176
- const found = kind === "room-finish" ? findHeaderRow(rows, ROOM_HEADERS, ["FLOOR", "BASE"], 4) : findHeaderRow(rows, FINISH_HEADERS, ["CODE", "MARK"], 3);
1177
- if (!found) return null;
1178
- const { anchors, rowIndex } = found;
1179
- const keyRe = kind === "room-finish" ? ROW_KEY_RE : CODE_RE;
1846
+ var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
1847
+ function rowKeyOf(raw, kind, buildings) {
1848
+ const key = norm(raw).replace(/[^A-Z0-9-]/g, "");
1849
+ if (kind === "finish") return CODE_RE.test(key) ? { key } : null;
1850
+ if (ROW_KEY_RE.test(key)) return { key };
1851
+ const q = key.match(QUALIFIED_KEY_RE);
1852
+ if (q && buildings?.has(q[1])) return { key, building: q[1] };
1853
+ return null;
1854
+ }
1855
+ var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
1856
+ function extractTable(sheet, kind, opts = {}) {
1857
+ const horiz = sheet.spans.filter((s) => !isVertical(s));
1858
+ const vert = sheet.spans.filter(isVertical);
1859
+ const rows = clusterRows(horiz);
1860
+ const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
1861
+ const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK"];
1862
+ const minHits = kind === "room-finish" ? 4 : 3;
1863
+ let anchors;
1864
+ let headerSpans;
1865
+ let dataFrom;
1866
+ let dataBelowY = -Infinity;
1867
+ let titleFrom;
1868
+ let rotated = false;
1869
+ const flat = findHeaderRow(rows, vocab, required, minHits);
1870
+ if (flat) {
1871
+ anchors = flat.anchors;
1872
+ headerSpans = rows[flat.rowIndex];
1873
+ dataFrom = flat.rowIndex + 1;
1874
+ titleFrom = flat.rowIndex - 1;
1875
+ } else {
1876
+ const rot = findRotatedHeader(vert, vocab, required, minHits);
1877
+ if (!rot) return null;
1878
+ rotated = true;
1879
+ anchors = rot.anchors;
1880
+ headerSpans = rot.spans;
1881
+ dataBelowY = rot.bottom - 2;
1882
+ dataFrom = 0;
1883
+ titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
1884
+ if (titleFrom < -1) titleFrom = rows.length - 1;
1885
+ }
1180
1886
  const out = [];
1181
1887
  let region = null;
1182
- const headerRow = rows[rowIndex];
1183
- for (const t of headerRow) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
1184
- const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
1185
- const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
1186
- const x0 = anchors[0].x - Math.max(80, medGap / 2);
1187
- const x1 = anchors[anchors.length - 1].x + Math.max(300, medGap * 3);
1188
- for (let i = rowIndex + 1; i < rows.length; i++) {
1888
+ for (const t of headerSpans) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
1889
+ const { x0, x1 } = bandLimits(anchors);
1890
+ for (let i = Math.max(dataFrom, 0); i < rows.length; i++) {
1891
+ if (rotated && rowY(rows[i]) <= dataBelowY) continue;
1189
1892
  const inBand = rows[i].filter((t) => t.x >= x0 && t.x <= x1);
1190
1893
  if (!inBand.length) continue;
1191
- const keyTok = inBand[0];
1192
- const key = norm(keyTok.str).replace(/[^A-Z0-9-]/g, "");
1193
- if (!keyRe.test(key)) continue;
1894
+ const keyed = rowKeyOf(inBand[0].str, kind, opts.buildings);
1895
+ if (!keyed) continue;
1194
1896
  const cells = {};
1195
1897
  for (const t of inBand) {
1196
1898
  const label = nearestAnchor(t.x, anchors);
@@ -1201,28 +1903,101 @@ function extractTable(sheet, kind) {
1201
1903
  }
1202
1904
  region = region ? merge(region, bboxOf(t)) : bboxOf(t);
1203
1905
  }
1204
- out.push({ key, cells });
1906
+ const row = { key: keyed.key, sheet: sheet.key, cells };
1907
+ const cellB = norm(cells.BLDG?.text || cells.BUILDING?.text || "");
1908
+ const b = keyed.building ?? (DESIGNATOR_RE.test(cellB) ? cellB : void 0);
1909
+ if (b) row.building = b;
1910
+ out.push(row);
1205
1911
  }
1206
1912
  if (!out.length) return null;
1207
1913
  let title = null;
1208
- for (let i = rowIndex - 1; i >= 0 && i >= rowIndex - 6 && !title; i--) {
1914
+ for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
1209
1915
  const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
1210
1916
  if (hit) title = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
1211
1917
  }
1212
- return { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region };
1918
+ const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
1919
+ if (rotated) table.rotated_headers = true;
1920
+ return table;
1921
+ }
1922
+ var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
1923
+ var isContinuationTitle = (text) => {
1924
+ const u = norm(text);
1925
+ return /SCHEDULE/.test(u) && CONT_TAIL_RE.test(u);
1926
+ };
1927
+ var baseTitleOf = (text) => norm(text).replace(CONT_TAIL_RE, "").replace(/[\s\-–—:.,()]+$/, "").trim();
1928
+ function findContinuationBase(logical, frag) {
1929
+ const sameKind = logical.filter((t) => t.kind === frag.kind && (frag.building == null || t.building == null || t.building === frag.building));
1930
+ if (!sameKind.length) return null;
1931
+ const fragBase = baseTitleOf(frag.title.text);
1932
+ const titled = sameKind.filter((t) => t.title && baseTitleOf(t.title.text) === fragBase);
1933
+ const pool = titled.length ? titled : sameKind;
1934
+ return pool[pool.length - 1];
1935
+ }
1936
+ function mergeContinuation(base, frag) {
1937
+ if (!base.parts) {
1938
+ base.parts = [{ sheet: base.sheet, title: base.title?.text || "", rows: base.rows.length, region: base.region, ...base.rotated_headers ? { rotated_headers: true } : {} }];
1939
+ }
1940
+ for (const r of frag.rows) if (r.building == null && frag.building != null) r.building = frag.building;
1941
+ base.parts.push({ sheet: frag.sheet, title: frag.title?.text || "", rows: frag.rows.length, region: frag.region, ...frag.rotated_headers ? { rotated_headers: true } : {} });
1942
+ base.rows.push(...frag.rows);
1943
+ }
1944
+ function adoptContinuationRows(sheet, titleSpan, base, buildings) {
1945
+ if (!base.anchors?.length || base.kind === "unknown") return null;
1946
+ const rows = clusterRows(sheet.spans.filter((s) => !isVertical(s)));
1947
+ const { x0, x1, medGap } = bandLimits(base.anchors);
1948
+ const keyTol = Math.max(40, medGap / 2);
1949
+ const out = [];
1950
+ let region = bboxOf(titleSpan);
1951
+ for (const row of rows) {
1952
+ if (rowY(row) <= titleSpan.y) continue;
1953
+ const inBand = row.filter((t) => t.x >= x0 && t.x <= x1);
1954
+ if (!inBand.length) continue;
1955
+ const keyed = rowKeyOf(inBand[0].str, base.kind, buildings);
1956
+ if (!keyed || Math.abs(inBand[0].x - base.anchors[0].x) > keyTol) continue;
1957
+ const cells = {};
1958
+ for (const t of inBand) {
1959
+ const label = nearestAnchor(t.x, base.anchors);
1960
+ const text = t.str.trim();
1961
+ if (!cells[label]) cells[label] = { text, bbox: bboxOf(t) };
1962
+ else {
1963
+ cells[label] = { text: `${cells[label].text} ${text}`, bbox: merge(cells[label].bbox, bboxOf(t)) };
1964
+ }
1965
+ region = merge(region, bboxOf(t));
1966
+ }
1967
+ const r = { key: keyed.key, sheet: sheet.key, cells };
1968
+ if (keyed.building) r.building = keyed.building;
1969
+ out.push(r);
1970
+ }
1971
+ if (!out.length) return null;
1972
+ return {
1973
+ kind: base.kind,
1974
+ sheet: sheet.key,
1975
+ title: { sheet: sheet.key, text: titleSpan.str.trim(), bbox: bboxOf(titleSpan) },
1976
+ headers: base.headers,
1977
+ rows: out,
1978
+ region
1979
+ };
1213
1980
  }
1214
- function roomTags(sheet) {
1981
+ var QUALIFIED_TAG_RE = /^([A-Z]{1,2})-(\d{2,3}[A-Z]?)$/;
1982
+ function roomTags(sheet, opts = {}) {
1215
1983
  const out = [];
1216
1984
  const spans = sheet.spans;
1985
+ const accept = (t) => {
1986
+ if (ROOM_LABEL_RE.test(t)) return { ok: true };
1987
+ const q = norm(t).match(QUALIFIED_TAG_RE);
1988
+ if (q && opts.buildings?.has(q[1]) && !opts.exclude?.has(norm(t).replace(/[^A-Z0-9]/g, ""))) return { ok: true, building: q[1] };
1989
+ return { ok: false };
1990
+ };
1217
1991
  for (const sp of spans) {
1218
1992
  const t = sp.str.trim();
1219
- if (!ROOM_LABEL_RE.test(t)) continue;
1993
+ const a = accept(t);
1994
+ if (!a.ok) continue;
1220
1995
  const b = bboxOf(sp);
1221
1996
  const hgt = Math.max(sp.h || 8, 6);
1222
1997
  let name = "";
1223
1998
  let best = Infinity;
1224
1999
  for (const cand of spans) {
1225
- if (cand === sp || ROOM_LABEL_RE.test(cand.str.trim())) continue;
2000
+ if (cand === sp || accept(cand.str.trim()).ok) continue;
1226
2001
  const cb = bboxOf(cand);
1227
2002
  const dy = b[1] - cb[3];
1228
2003
  if (dy < -hgt * 0.2 || dy > hgt * 2.2) continue;
@@ -1233,7 +2008,9 @@ function roomTags(sheet) {
1233
2008
  name = cand.str.trim();
1234
2009
  }
1235
2010
  }
1236
- out.push({ tag: t, name, sheet: sheet.key, bbox: b });
2011
+ const tag = { tag: t, name, sheet: sheet.key, bbox: b };
2012
+ if (a.building) tag.building = a.building;
2013
+ out.push(tag);
1237
2014
  }
1238
2015
  return out;
1239
2016
  }
@@ -1248,65 +2025,190 @@ function detailCallouts(sheet) {
1248
2025
  }
1249
2026
  function buildSheetGraph(sheets) {
1250
2027
  const withText = sheets.filter((s) => s.spans.length > 0);
1251
- if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [] };
2028
+ if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], notes: [] };
2029
+ const notes = [];
2030
+ const ctxBySheet = /* @__PURE__ */ new Map();
2031
+ const buildings = /* @__PURE__ */ new Set();
2032
+ for (const s of withText) {
2033
+ for (const sp of s.spans) for (const b of buildingMentions(sp.str)) buildings.add(b);
2034
+ const ctx = sheetBuilding(s);
2035
+ if (ctx) ctxBySheet.set(s.key, ctx.building);
2036
+ }
2037
+ const roles = /* @__PURE__ */ new Map();
2038
+ const fragments = [];
2039
+ const fragmentKinds = /* @__PURE__ */ new Map();
2040
+ for (const s of withText) {
2041
+ roles.set(s.key, classifySheetRole(s));
2042
+ for (const kind of ["room-finish", "finish"]) {
2043
+ const t = extractTable(s, kind, { buildings });
2044
+ if (!t) continue;
2045
+ const titleB = t.title ? buildingMentions(t.title.text) : [];
2046
+ const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
2047
+ if (b) t.building = b;
2048
+ for (const r of t.rows) if (r.building) buildings.add(r.building);
2049
+ fragments.push(t);
2050
+ if (!fragmentKinds.has(s.key)) fragmentKinds.set(s.key, /* @__PURE__ */ new Set());
2051
+ fragmentKinds.get(s.key).add(kind);
2052
+ }
2053
+ }
1252
2054
  const tables = [];
2055
+ for (const f of fragments) {
2056
+ const base = f.title && isContinuationTitle(f.title.text) ? findContinuationBase(tables, f) : null;
2057
+ if (base) mergeContinuation(base, f);
2058
+ else {
2059
+ if (f.title && isContinuationTitle(f.title.text)) {
2060
+ notes.push(`${f.sheet}: "${f.title.text}" reads as a continuation but no earlier ${f.kind} table matches \u2014 kept as a standalone table`);
2061
+ }
2062
+ tables.push(f);
2063
+ }
2064
+ }
2065
+ for (const s of withText) {
2066
+ for (const sp of s.spans) {
2067
+ const text = sp.str.trim();
2068
+ if (!isContinuationTitle(text)) continue;
2069
+ const fragBase = baseTitleOf(text);
2070
+ const base = [...tables].reverse().find((t) => t.kind !== "unknown" && t.title && baseTitleOf(t.title.text) === fragBase && t.sheet !== s.key && !t.parts?.some((p) => p.sheet === s.key));
2071
+ if (!base || fragmentKinds.get(s.key)?.has(base.kind)) continue;
2072
+ const adopted = adoptContinuationRows(s, sp, base, buildings);
2073
+ if (adopted) {
2074
+ if (adopted.building == null && ctxBySheet.get(s.key)) adopted.building = ctxBySheet.get(s.key);
2075
+ mergeContinuation(base, adopted);
2076
+ for (const r of adopted.rows) if (r.building) buildings.add(r.building);
2077
+ } else {
2078
+ notes.push(`${s.key}: "${text}" reads as a continuation of ${base.sheet} but no rows aligned to that table's columns \u2014 rows there are NOT indexed`);
2079
+ }
2080
+ }
2081
+ }
2082
+ const sheetNumbers = /* @__PURE__ */ new Set();
2083
+ for (const s of sheets) {
2084
+ const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
2085
+ if (n) sheetNumbers.add(n);
2086
+ }
1253
2087
  const rooms = [];
1254
2088
  const callouts = [];
1255
- const outSheets = [];
1256
2089
  for (const s of withText) {
1257
- const role = classifySheetRole(s);
1258
- const found = [];
1259
- for (const kind of ["room-finish", "finish"]) {
1260
- const t = extractTable(s, kind);
1261
- if (t) {
1262
- found.push(t);
1263
- tables.push(t);
2090
+ const role = roles.get(s.key);
2091
+ if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") {
2092
+ const ctxB = ctxBySheet.get(s.key);
2093
+ for (const r of roomTags(s, { buildings, exclude: sheetNumbers })) {
2094
+ if (r.building == null && ctxB) r.building = ctxB;
2095
+ rooms.push(r);
1264
2096
  }
1265
2097
  }
1266
- if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") rooms.push(...roomTags(s));
1267
2098
  callouts.push(...detailCallouts(s));
1268
- outSheets.push({
1269
- key: s.key,
1270
- role: role.role,
1271
- confidence: role.confidence,
1272
- evidence: role.evidence,
1273
- schedules: found.map((t) => ({ kind: t.kind, title: t.title?.text || "", rows: t.rows.length, region: t.region }))
1274
- });
1275
2099
  }
1276
- return { available: true, sheets: outSheets, rooms, tables, callouts };
2100
+ const outSheets = withText.map((s) => {
2101
+ const role = roles.get(s.key);
2102
+ const schedules = [];
2103
+ for (const t of tables) {
2104
+ const parts = t.parts ?? [{ sheet: t.sheet, title: t.title?.text || "", rows: t.rows.length, region: t.region, ...t.rotated_headers ? { rotated_headers: true } : {} }];
2105
+ for (let i = 0; i < parts.length; i++) {
2106
+ const p = parts[i];
2107
+ if (p.sheet !== s.key) continue;
2108
+ schedules.push({
2109
+ kind: t.kind,
2110
+ title: p.title || t.title?.text || "",
2111
+ rows: p.rows,
2112
+ region: p.region,
2113
+ ...i > 0 ? { continues: t.sheet } : {},
2114
+ ...p.rotated_headers ? { rotated_headers: true } : {}
2115
+ });
2116
+ }
2117
+ }
2118
+ const entry = { key: s.key, role: role.role, confidence: role.confidence, evidence: role.evidence, schedules };
2119
+ const b = ctxBySheet.get(s.key);
2120
+ if (b) entry.building = b;
2121
+ return entry;
2122
+ });
2123
+ return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), notes };
1277
2124
  }
1278
2125
  var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
1279
2126
  function resolveTag(graph, tag) {
1280
- const t = norm(tag);
1281
- const room = graph.rooms.find((r2) => norm(r2.tag) === t) || null;
2127
+ const t = norm(tag).replace(/\s+/g, "");
2128
+ const q = t.match(QUALIFIED_KEY_RE);
2129
+ const wantB = q ? q[1] : null;
2130
+ const num2 = q ? q[2] : t;
2131
+ const rooms = graph.rooms.filter((r2) => {
2132
+ const rt = norm(r2.tag).replace(/\s+/g, "");
2133
+ return rt === t || numOf(rt) === num2;
2134
+ });
2135
+ const pickRoom = (b) => {
2136
+ if (b) return rooms.find((r2) => r2.building === b) ?? rooms.find((r2) => !r2.building) ?? null;
2137
+ const distinct = new Set(rooms.map((r2) => r2.building || ""));
2138
+ return distinct.size > 1 ? null : rooms[0] ?? null;
2139
+ };
1282
2140
  const roomTables = graph.tables.filter((x) => x.kind === "room-finish");
1283
- if (!roomTables.length) return { status: "unresolved", tag: t, room, reason: "no room-finish schedule found in the set" };
1284
- const matches = roomTables.flatMap((tab2) => tab2.rows.filter((r2) => norm(r2.key) === t).map((r2) => ({ tab: tab2, r: r2 })));
1285
- if (!matches.length) return { status: "unresolved", tag: t, room, reason: `no schedule row for ${t} \u2014 the plan shows the room but no room-finish table lists it` };
1286
- if (matches.length > 1) return { status: "unresolved", tag: t, room, reason: `ambiguous: ${matches.length} schedule rows match ${t} (room numbers reused across the set?)` };
1287
- const { tab, r } = matches[0];
2141
+ if (!roomTables.length) return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: "no room-finish schedule found in the set" };
2142
+ const cands = [];
2143
+ for (const tab2 of roomTables) {
2144
+ for (const r2 of tab2.rows) {
2145
+ if (numOf(norm(r2.key)) !== num2) continue;
2146
+ const b = r2.building ?? tab2.building;
2147
+ cands.push({ tab: tab2, r: r2, ...b ? { building: b } : {} });
2148
+ }
2149
+ }
2150
+ const describe = (c) => `${c.building ? `building ${c.building}` : "no building"} (${c.r.sheet})`;
2151
+ const wire = (c) => ({ key: c.r.key, ...c.building ? { building: c.building } : {}, sheet: c.r.sheet, table: c.tab.title?.text || `${c.tab.kind} schedule` });
2152
+ let chosen;
2153
+ if (wantB) {
2154
+ const filtered = cands.filter((c) => c.building === wantB);
2155
+ if (!filtered.length) {
2156
+ if (!graph.buildings.length) {
2157
+ return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: `the set names no buildings \u2014 no BUILDING/BLDG text or qualified schedule keys anywhere; try resolve_tag "${num2}"`, ...cands.length ? { candidates: cands.map(wire) } : {} };
2158
+ }
2159
+ if (!graph.buildings.includes(wantB)) {
2160
+ return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: `the set names no building "${wantB}" (buildings found: ${graph.buildings.join(", ")})`, ...cands.length ? { candidates: cands.map(wire) } : {} };
2161
+ }
2162
+ if (cands.length) {
2163
+ return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: `no building-${wantB} schedule row for ${num2} \u2014 ${num2} is listed under ${cands.map(describe).join(", ")}`, candidates: cands.map(wire) };
2164
+ }
2165
+ return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: `no schedule row for ${t} \u2014 the plan shows the room but no room-finish table lists it` };
2166
+ }
2167
+ if (filtered.length > 1) {
2168
+ return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: `ambiguous: ${filtered.length} schedule rows match ${t} (${filtered.map((c) => c.r.sheet).join(", ")})`, candidates: filtered.map(wire) };
2169
+ }
2170
+ chosen = filtered[0];
2171
+ } else {
2172
+ if (!cands.length) return { status: "unresolved", tag: t, room: pickRoom(null), reason: `no schedule row for ${t} \u2014 the plan shows the room but no room-finish table lists it` };
2173
+ if (cands.length > 1) {
2174
+ const distinctB = [...new Set(cands.filter((c) => c.building).map((c) => c.building))];
2175
+ if (distinctB.length > 1) {
2176
+ return {
2177
+ status: "unresolved",
2178
+ tag: t,
2179
+ room: null,
2180
+ reason: `ambiguous: room ${num2} appears in ${distinctB.length} buildings \u2014 ${cands.map(describe).join(", ")} \u2014 qualify the tag, e.g. "${distinctB[0]}-${num2}"`,
2181
+ candidates: cands.map(wire)
2182
+ };
2183
+ }
2184
+ return { status: "unresolved", tag: t, room: pickRoom(null), reason: `ambiguous: ${cands.length} schedule rows match ${t} (room numbers reused across the set?)`, candidates: cands.map(wire) };
2185
+ }
2186
+ chosen = cands[0];
2187
+ }
2188
+ const { tab, r } = chosen;
2189
+ const room = pickRoom(chosen.building ?? null);
1288
2190
  const finTables = graph.tables.filter((x) => x.kind === "finish");
1289
2191
  const finishes = [];
1290
- const sources = [{ sheet: tab.sheet, text: `${tab.title?.text || "room-finish schedule"} row ${r.key}`, bbox: r.cells[Object.keys(r.cells)[0]]?.bbox || tab.region }];
2192
+ const sources = [{ sheet: r.sheet, text: `${tab.title?.text || "room-finish schedule"} row ${r.key}`, bbox: r.cells[Object.keys(r.cells)[0]]?.bbox || tab.region }];
1291
2193
  if (room) sources.unshift({ sheet: room.sheet, text: `${room.name ? room.name + " " : ""}${room.tag}`.trim(), bbox: room.bbox });
1292
2194
  for (const surface of SURFACE_HEADERS) {
1293
2195
  const cell = r.cells[surface];
1294
2196
  if (!cell || !cell.text.trim()) continue;
1295
2197
  const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
1296
- const fin = { surface, code: cell.text.trim(), source: { sheet: tab.sheet, text: cell.text.trim(), bbox: cell.bbox } };
2198
+ const fin = { surface, code: cell.text.trim(), source: { sheet: r.sheet, text: cell.text.trim(), bbox: cell.bbox } };
1297
2199
  for (const ft of finTables) {
1298
2200
  const def = ft.rows.find((fr) => norm(fr.key) === code);
1299
2201
  if (def) {
1300
2202
  const cells = {};
1301
2203
  for (const [k, v] of Object.entries(def.cells)) cells[k] = v.text;
1302
- fin.definition = { cells, source: { sheet: ft.sheet, text: `${ft.title?.text || "finish schedule"} row ${def.key}`, bbox: def.cells[Object.keys(def.cells)[0]]?.bbox || ft.region } };
2204
+ fin.definition = { cells, source: { sheet: def.sheet, text: `${ft.title?.text || "finish schedule"} row ${def.key}`, bbox: def.cells[Object.keys(def.cells)[0]]?.bbox || ft.region } };
1303
2205
  break;
1304
2206
  }
1305
2207
  }
1306
2208
  finishes.push(fin);
1307
2209
  }
1308
2210
  if (!finishes.length) return { status: "unresolved", tag: t, room, reason: `schedule row ${t} exists but carries no finish cells the extractor could band` };
1309
- return { status: "resolved", tag: t, room, finishes, sources };
2211
+ return { status: "resolved", tag: t, room, ...chosen.building ? { building: chosen.building } : {}, finishes, sources };
1310
2212
  }
1311
2213
 
1312
2214
  // src/format.ts
@@ -1425,7 +2327,15 @@ function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
1425
2327
  if (polarityMean < INVERT_MEAN) for (let i = 0; i < n; i++) gray[i] = 255 - gray[i];
1426
2328
  let mask = adaptiveThreshold(gray, mw, mh, opts.t, opts.absInk);
1427
2329
  if (opts.bridge !== false) mask = closeMask(mask, mw, mh);
1428
- return { mask, mw, mh, ws, softCount: 0 };
2330
+ return {
2331
+ mask,
2332
+ mw,
2333
+ mh,
2334
+ ws,
2335
+ softCount: 0,
2336
+ ...opts.dpiLimited ? { dpiLimited: true } : {},
2337
+ ...opts.scanDpi ? { scanDpi: opts.scanDpi } : {}
2338
+ };
1429
2339
  }
1430
2340
 
1431
2341
  // ../web/src/lib/symbolsweep.ts
@@ -1485,12 +2395,7 @@ var EndpointGrid = class {
1485
2395
  }
1486
2396
  };
1487
2397
  var segLen = (segs, i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
1488
- function sweepSymbols(segs, seedRect, opts = {}) {
1489
- const tol = opts.tolPx ?? SWEEP_TOL_PX;
1490
- const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
1491
- const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
1492
- const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
1493
- const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
2398
+ function fingerprintSymbol(segs, seedRect) {
1494
2399
  const n = segs.length >> 2;
1495
2400
  const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
1496
2401
  const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
@@ -1529,6 +2434,22 @@ function sweepSymbols(segs, seedRect, opts = {}) {
1529
2434
  sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
1530
2435
  sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
1531
2436
  }
2437
+ return {
2438
+ rel,
2439
+ totalLen,
2440
+ segments: seedIdx.length,
2441
+ center: [seedCx, seedCy],
2442
+ footprint: Math.hypot(sbx1 - sbx0, sby1 - sby0)
2443
+ };
2444
+ }
2445
+ function matchSymbol(fp, segs, opts = {}) {
2446
+ const tol = opts.tolPx ?? SWEEP_TOL_PX;
2447
+ const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
2448
+ const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
2449
+ const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
2450
+ const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
2451
+ const n = segs.length >> 2;
2452
+ const { rel, totalLen } = fp;
1532
2453
  const lenBucket = /* @__PURE__ */ new Map();
1533
2454
  for (let i = 0; i < n; i++) {
1534
2455
  const b = Math.round(segLen(segs, i));
@@ -1585,214 +2506,154 @@ function sweepSymbols(segs, seedRect, opts = {}) {
1585
2506
  const tol2 = tol * tol;
1586
2507
  const near = (x1, y1, x2, y2) => (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) <= tol2;
1587
2508
  const scoreAt = (m, tx, ty) => {
1588
- let matched = 0;
1589
- for (const r of rel) {
1590
- const a = apply(m, r[0], r[1]);
1591
- const b = apply(m, r[2], r[3]);
1592
- const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
1593
- let hit = false;
1594
- for (const j of grid.near(ax, ay, scratch)) {
1595
- const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
1596
- if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
1597
- hit = true;
1598
- break;
1599
- }
1600
- }
1601
- if (hit) matched += r[4];
1602
- }
1603
- return matched / totalLen;
1604
- };
1605
- const scored = [];
1606
- for (let k = 0; k < considered; k++) {
1607
- const c = proposals[k];
1608
- const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
1609
- if (score < scoreLow) continue;
1610
- scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
1611
- }
1612
- const mergeR = Math.max(2 * tol, 4);
1613
- const kept = [];
1614
- for (const s of scored) {
1615
- const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
1616
- if (!twin) {
1617
- kept.push(s);
1618
- continue;
1619
- }
1620
- if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
1621
- twin.at = s.at;
1622
- twin.score = s.score;
1623
- twin.rotation = s.rotation;
1624
- twin.mirrored = s.mirrored;
1625
- twin.xf = s.xf;
1626
- }
1627
- }
1628
- const suppressR = Math.max(mergeR, Math.hypot(sbx1 - sbx0, sby1 - sby0) / 2);
1629
- const away = kept.filter((s) => Math.hypot(s.at[0] - seedCx, s.at[1] - seedCy) > suppressR);
1630
- const matches = [];
1631
- const withheld = [];
1632
- const pct = (v) => Math.round(v * 1e3) / 1e3;
1633
- const row = (s) => ({
1634
- at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
1635
- score: pct(s.score),
1636
- rotation: s.rotation,
1637
- mirrored: s.mirrored
1638
- });
1639
- for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
1640
- for (const s of away) {
1641
- if (s.score >= scoreHigh) continue;
1642
- if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
1643
- withheld.push({ ...row(s), reason: `matched ${Math.round(s.score * 100)}% of the seed's linework (commit bar ${Math.round(scoreHigh * 100)}%) \u2014 likely a variant or an overlapped instance; look before counting it` });
1644
- }
1645
- const order = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0] || a.rotation - b.rotation || Number(a.mirrored) - Number(b.mirrored);
1646
- matches.sort(order);
1647
- withheld.sort(order);
1648
- return {
1649
- seed: {
1650
- segments: seedIdx.length,
1651
- center: [Math.round(seedCx * 10) / 10, Math.round(seedCy * 10) / 10],
1652
- length_px: Math.round(totalLen * 10) / 10
1653
- },
1654
- matches,
1655
- withheld,
1656
- candidates: { considered, dropped }
1657
- };
1658
- }
1659
-
1660
- // ../web/src/lib/geometry.js
1661
- function starPath(cx, cy, R, points = 4, innerRatio = 0.38) {
1662
- const r = R * innerRatio;
1663
- let d = "";
1664
- for (let i = 0; i < points * 2; i++) {
1665
- const a = Math.PI * i / points - Math.PI / 2, rad = i % 2 === 0 ? R : r;
1666
- d += `${i === 0 ? "M" : "L"}${cx + rad * Math.cos(a)},${cy + rad * Math.sin(a)} `;
1667
- }
1668
- return d + "Z";
1669
- }
1670
- function arrowheadPath(fromX, fromY, tipX, tipY, size = 6) {
1671
- let dx = tipX - fromX, dy = tipY - fromY;
1672
- const len = Math.hypot(dx, dy);
1673
- if (len < 1e-6) {
1674
- dx = 0;
1675
- dy = 1;
1676
- } else {
1677
- dx /= len;
1678
- dy /= len;
1679
- }
1680
- const bx = tipX - dx * size, by = tipY - dy * size;
1681
- const nx = -dy, ny = dx, half = size * 0.5;
1682
- return `M${tipX},${tipY} L${bx + nx * half},${by + ny * half} L${bx - nx * half},${by - ny * half} Z`;
1683
- }
1684
- function arcToBezier(x0, y0, x1, y1, r, laf, sf) {
1685
- const dx = (x0 - x1) / 2, dy = (y0 - y1) / 2;
1686
- let rr = Math.abs(r) || 1;
1687
- const lambda = (dx * dx + dy * dy) / (rr * rr);
1688
- if (lambda > 1) rr *= Math.sqrt(lambda);
1689
- const sign = laf !== sf ? 1 : -1;
1690
- const num2 = rr * rr * rr * rr - rr * rr * dy * dy - rr * rr * dx * dx;
1691
- const den = rr * rr * dy * dy + rr * rr * dx * dx;
1692
- const coef = sign * Math.sqrt(Math.max(0, den === 0 ? 0 : num2 / den));
1693
- const cxp = coef * dy, cyp = -coef * dx;
1694
- const ang = (ux, uy, vx, vy) => {
1695
- const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1;
1696
- let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
1697
- if (ux * vy - uy * vx < 0) a = -a;
1698
- return a;
1699
- };
1700
- const th1 = ang(1, 0, (dx - cxp) / rr, (dy - cyp) / rr);
1701
- let dth = ang((dx - cxp) / rr, (dy - cyp) / rr, (-dx - cxp) / rr, (-dy - cyp) / rr);
1702
- if (!sf && dth > 0) dth -= 2 * Math.PI;
1703
- if (sf && dth < 0) dth += 2 * Math.PI;
1704
- const th2 = th1 + dth;
1705
- const alpha = 4 / 3 * Math.tan(dth / 4);
1706
- return [
1707
- x0 - alpha * rr * Math.sin(th1),
1708
- y0 + alpha * rr * Math.cos(th1),
1709
- x1 + alpha * rr * Math.sin(th2),
1710
- y1 - alpha * rr * Math.cos(th2)
1711
- ];
1712
- }
1713
- function cloudBezier(x0, y0, x1, y1) {
1714
- const ax0 = Math.min(x0, x1), ay0 = Math.min(y0, y1), ax1 = Math.max(x0, x1), ay1 = Math.max(y0, y1);
1715
- const r = Math.max(6, Math.min(22, (ax1 - ax0 + ay1 - ay0) / 22));
1716
- const arc = (len) => Math.max(1, Math.round(len / (r * 1.6)));
1717
- const segments = [];
1718
- let px = ax0, py = ay0;
1719
- const edge = (fromX, fromY, toX, toY) => {
1720
- const n = arc(Math.hypot(toX - fromX, toY - fromY));
1721
- for (let i = 1; i <= n; i++) {
1722
- const qx = fromX + (toX - fromX) * (i / n), qy = fromY + (toY - fromY) * (i / n);
1723
- const [c1x, c1y, c2x, c2y] = arcToBezier(px, py, qx, qy, r, 0, 1);
1724
- segments.push([[c1x, c1y], [c2x, c2y], [qx, qy]]);
1725
- px = qx;
1726
- py = qy;
1727
- }
1728
- };
1729
- edge(ax0, ay0, ax1, ay0);
1730
- edge(ax1, ay0, ax1, ay1);
1731
- edge(ax1, ay1, ax0, ay1);
1732
- edge(ax0, ay1, ax0, ay0);
1733
- return { start: [ax0, ay0], segments };
1734
- }
1735
- function buildSnapGrid(points, cell) {
1736
- const map = /* @__PURE__ */ new Map();
1737
- for (const p of points) {
1738
- const k = `${Math.floor(p[0] / cell)},${Math.floor(p[1] / cell)}`;
1739
- let a = map.get(k);
1740
- if (!a) {
1741
- a = [];
1742
- map.set(k, a);
1743
- }
1744
- if (a.length < 40) a.push(p);
1745
- }
1746
- return { cell, map };
1747
- }
1748
- function nearestSnap(grid, x, y, maxDist) {
1749
- if (!grid) return null;
1750
- const { cell, map } = grid, cx = Math.floor(x / cell), cy = Math.floor(y / cell);
1751
- let best = null, bestD = maxDist * maxDist;
1752
- for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) {
1753
- const a = map.get(`${gx},${gy}`);
1754
- if (!a) continue;
1755
- for (const p of a) {
1756
- const dx = p[0] - x, dy = p[1] - y, d = dx * dx + dy * dy;
1757
- if (d < bestD) {
1758
- bestD = d;
1759
- best = p;
2509
+ let matched = 0;
2510
+ for (const r of rel) {
2511
+ const a = apply(m, r[0], r[1]);
2512
+ const b = apply(m, r[2], r[3]);
2513
+ const ax = a[0] + tx, ay = a[1] + ty, bx = b[0] + tx, by = b[1] + ty;
2514
+ let hit = false;
2515
+ for (const j of grid.near(ax, ay, scratch)) {
2516
+ const px = segs[j * 4], py = segs[j * 4 + 1], qx = segs[j * 4 + 2], qy = segs[j * 4 + 3];
2517
+ if (near(px, py, ax, ay) && near(qx, qy, bx, by) || near(qx, qy, ax, ay) && near(px, py, bx, by)) {
2518
+ hit = true;
2519
+ break;
2520
+ }
1760
2521
  }
2522
+ if (hit) matched += r[4];
1761
2523
  }
2524
+ return matched / totalLen;
2525
+ };
2526
+ const scored = [];
2527
+ for (let k = 0; k < considered; k++) {
2528
+ const c = proposals[k];
2529
+ const score = scoreAt(xforms[c.xf].m, c.tx, c.ty);
2530
+ if (score < scoreLow) continue;
2531
+ scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
1762
2532
  }
1763
- return best;
1764
- }
1765
- function closedMetrics(pts) {
1766
- const n = pts.length;
1767
- if (n < 3) {
1768
- let perim2 = 0;
1769
- for (let i = 1; i < n; i++) perim2 += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
1770
- return { area: 0, perim: perim2 };
2533
+ const mergeR = Math.max(2 * tol, 4);
2534
+ const kept = [];
2535
+ for (const s of scored) {
2536
+ const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
2537
+ if (!twin) {
2538
+ kept.push(s);
2539
+ continue;
2540
+ }
2541
+ if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
2542
+ twin.at = s.at;
2543
+ twin.score = s.score;
2544
+ twin.rotation = s.rotation;
2545
+ twin.mirrored = s.mirrored;
2546
+ twin.xf = s.xf;
2547
+ }
1771
2548
  }
1772
- let area = 0, perim = 0;
1773
- for (let i = 0; i < n; i++) {
1774
- const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % n];
1775
- area += x1 * y2 - x2 * y1;
1776
- perim += Math.hypot(x2 - x1, y2 - y1);
2549
+ const suppressR = Math.max(mergeR, fp.footprint / 2);
2550
+ const ex = opts.excludeCenter;
2551
+ const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
2552
+ const matches = [];
2553
+ const withheld = [];
2554
+ const pct = (v) => Math.round(v * 1e3) / 1e3;
2555
+ const row = (s) => ({
2556
+ at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
2557
+ score: pct(s.score),
2558
+ rotation: s.rotation,
2559
+ mirrored: s.mirrored
2560
+ });
2561
+ for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
2562
+ for (const s of away) {
2563
+ if (s.score >= scoreHigh) continue;
2564
+ if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
2565
+ 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` });
1777
2566
  }
1778
- return { area: Math.abs(area) / 2, perim };
2567
+ 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);
2568
+ matches.sort(order);
2569
+ withheld.sort(order);
2570
+ return { matches, withheld, candidates: { considered, dropped } };
1779
2571
  }
1780
- function openLen(pts) {
1781
- let L = 0;
1782
- for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
1783
- return L;
2572
+
2573
+ // ../web/src/lib/provenance.js
2574
+ var mintUuid = () => {
2575
+ const c = globalThis.crypto;
2576
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
2577
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
2578
+ };
2579
+ var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
2580
+
2581
+ // ../web/src/lib/approvals.js
2582
+ var APPROVAL_POLICY = {
2583
+ add: "mints id (apr- + uuid) + ts per record; restore: true re-adds VERBATIM at the recorded indices (undo of a lift)",
2584
+ delete: "no stamp; inverse re-adds the removed records verbatim, original order included",
2585
+ replace: "whole-array non-edit (hydrate) \u2014 nothing mints, inverse null (never recorded)"
2586
+ };
2587
+ var APPROVAL_ACTORS = ["estimator", "agent"];
2588
+ var APPROVAL_R = 0.022;
2589
+ var APPROVAL_INK = {
2590
+ estimator: { light: "#1f6b4a", dark: "#55b083" },
2591
+ agent: { light: "#6c6a5e", dark: "#9d9a8c" }
2592
+ };
2593
+ function approvalInk(actor, dark = false) {
2594
+ const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
2595
+ return dark ? c.dark : c.light;
1784
2596
  }
1785
- function pointInPoly(x, y, pts) {
1786
- let inside = false;
1787
- for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
1788
- const [xi, yi] = pts[i], [xj, yj] = pts[j];
1789
- if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
2597
+ var isPair = (p) => Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]);
2598
+ function sanitizeApprovals(raw) {
2599
+ const seen = /* @__PURE__ */ new Set();
2600
+ return (Array.isArray(raw) ? raw : []).filter((a) => {
2601
+ if (!a || typeof a !== "object" || Array.isArray(a)) return false;
2602
+ if (!(typeof a.id === "string" && a.id)) return false;
2603
+ if (!APPROVAL_ACTORS.includes(a.actor)) return false;
2604
+ if (!(typeof a.sheet_id === "string" && a.sheet_id)) return false;
2605
+ if (!isPair(a.at)) return false;
2606
+ if (seen.has(a.id)) return false;
2607
+ seen.add(a.id);
2608
+ return true;
2609
+ });
2610
+ }
2611
+ function approvalTally(approvals) {
2612
+ const t = { estimator: 0, agent: 0 };
2613
+ for (const a of Array.isArray(approvals) ? approvals : []) {
2614
+ if (a && a.actor in t) t[a.actor] += 1;
1790
2615
  }
1791
- return inside;
2616
+ return t;
1792
2617
  }
1793
- function chiselRibbon(pts, w, nibDeg = 45) {
1794
- const a = nibDeg * Math.PI / 180, vx = Math.cos(a) * w / 2, vy = -(Math.sin(a) * w) / 2;
1795
- return [...pts.map(([x, y]) => [x + vx, y + vy]), ...[...pts].reverse().map(([x, y]) => [x - vx, y - vy])];
2618
+ function applyApprovalCommand(approvals, cmd) {
2619
+ if (!cmd || !(cmd.type in APPROVAL_POLICY)) {
2620
+ throw new Error(`Unknown approval command type: ${cmd && cmd.type} \u2014 add it to APPROVAL_POLICY (and decide what it mints) first.`);
2621
+ }
2622
+ switch (cmd.type) {
2623
+ case "add": {
2624
+ const minted = cmd.restore ? cmd.approvals : cmd.approvals.map((a) => {
2625
+ const { id, ts, ...rest } = a;
2626
+ if (!APPROVAL_ACTORS.includes(rest.actor)) {
2627
+ throw new Error(`Unknown approval actor: ${rest.actor} \u2014 must be one of ${APPROVAL_ACTORS.join(", ")}.`);
2628
+ }
2629
+ return { id: id || `apr-${mintUuid()}`, ts: ts || nowIso(), ...rest };
2630
+ });
2631
+ let next;
2632
+ if (cmd.restore && Array.isArray(cmd.at) && cmd.at.length === minted.length) {
2633
+ next = approvals.slice();
2634
+ minted.forEach((a, k) => next.splice(Math.min(cmd.at[k], next.length), 0, a));
2635
+ } else {
2636
+ next = [...approvals, ...minted];
2637
+ }
2638
+ return { approvals: next, inverse: { type: "delete", ids: minted.map((a) => a.id) } };
2639
+ }
2640
+ case "delete": {
2641
+ const idSet = new Set(cmd.ids);
2642
+ const removed = [], at = [];
2643
+ approvals.forEach((a, i) => {
2644
+ if (idSet.has(a.id)) {
2645
+ removed.push(a);
2646
+ at.push(i);
2647
+ }
2648
+ });
2649
+ return {
2650
+ approvals: approvals.filter((a) => !idSet.has(a.id)),
2651
+ inverse: { type: "add", approvals: removed, restore: true, at }
2652
+ };
2653
+ }
2654
+ case "replace":
2655
+ return { approvals: Array.isArray(cmd.approvals) ? cmd.approvals : [], inverse: null };
2656
+ }
1796
2657
  }
1797
2658
 
1798
2659
  // ../web/src/lib/num.js
@@ -2593,9 +3454,11 @@ var SNAP_CELL = 24;
2593
3454
  var SNAP_TOL = 7;
2594
3455
  var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
2595
3456
  var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
2596
- var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
2597
- var uid = (p) => `${p}-${mintUuid()}`;
3457
+ var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
3458
+ var uid = (p) => `${p}-${mintUuid2()}`;
2598
3459
  var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
3460
+ var sanitizeApprovals2 = sanitizeApprovals;
3461
+ var applyApprovalCommand2 = applyApprovalCommand;
2599
3462
  var CONTEXT_MIN_LEN_PX = 2;
2600
3463
  var CONTEXT_MAX_SEGMENTS = 4e3;
2601
3464
  var CONTEXT_MAX_SEGMENTS_CEIL = 2e4;
@@ -2648,6 +3511,9 @@ var Session = class _Session {
2648
3511
  conditions = [];
2649
3512
  markups = [];
2650
3513
  shapes = [];
3514
+ /** Approval-family records (#176) — estimator seals arrive only by import;
3515
+ * agent verdicts mint through markVerdict and nothing else. */
3516
+ approvals = [];
2651
3517
  /** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
2652
3518
  * marked-set cover discloses as withheld. Replaced per assign run, cleared
2653
3519
  * with the rest of the session on a non-merge load_plan. Seeds ride
@@ -2690,6 +3556,7 @@ var Session = class _Session {
2690
3556
  this.conditions = [];
2691
3557
  this.shapes = [];
2692
3558
  this.markups = [];
3559
+ this.approvals = [];
2693
3560
  this.file = null;
2694
3561
  this.filePath = null;
2695
3562
  this.nextOrd = 1;
@@ -3552,11 +4419,24 @@ var Session = class _Session {
3552
4419
  * square symmetry group, score each as the length-weighted fraction of seed
3553
4420
  * segments reproduced within tolerance. This method is the plumbing plus
3554
4421
  * the wire shapes: rect clamping, the scan refusal, and the commit path —
3555
- * match centers through placeCount (ONE undo step, EA scale-free), origins
3556
- * telling the truth (`method: "symbol_sweep"`, per-match score/transform),
3557
- * withheld placements NEVER committed. */
4422
+ * match centers through the placeCount path (ONE undo step, EA scale-free),
4423
+ * origins telling the truth (`method: "symbol_sweep"`, per-match
4424
+ * score/transform), withheld placements NEVER committed.
4425
+ *
4426
+ * scope "set" (phase 2) sweeps the whole working set, restricted to
4427
+ * PLAN-role sheets by the sheet graph: a symbol instance drawn in a detail,
4428
+ * legend, or schedule is a reference drawing, not installed work, and must
4429
+ * never count itself. The seed rect may sit on ANY sheet — marqueeing the
4430
+ * assembly on a detail sheet is the estimator's own gesture — and a
4431
+ * non-plan seed sheet serves as the fingerprint SOURCE while staying
4432
+ * excluded from counting. Every excluded sheet is disclosed in `skipped`
4433
+ * with its role and reason; per-sheet results carry their own match /
4434
+ * withheld / cap accounting plus wall-clock elapsed_ms. The whole set-wide
4435
+ * commit is ONE undo step: the gesture the agent made was "sweep the set",
4436
+ * and taking it back should not require one undo per sheet. */
3558
4437
  async symbolSweep(name, opts) {
3559
4438
  const s = this.sheet(name);
4439
+ const scope = opts.scope ?? "sheet";
3560
4440
  if (opts.commit && !opts.condition) {
3561
4441
  throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
3562
4442
  }
@@ -3578,44 +4458,350 @@ var Session = class _Session {
3578
4458
  mirror: opts.mirror ?? true,
3579
4459
  tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
3580
4460
  };
3581
- let res;
4461
+ let fp;
3582
4462
  try {
3583
- res = sweepSymbols(geo.segs, rect, sweepOpts);
4463
+ fp = fingerprintSymbol(geo.segs, rect);
3584
4464
  } catch (e) {
3585
4465
  throw new UserError(e instanceof Error ? e.message : String(e));
3586
4466
  }
4467
+ const seedOut = {
4468
+ sheet: s.key,
4469
+ segments: fp.segments,
4470
+ center: [round1(fp.center[0]), round1(fp.center[1])],
4471
+ rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
4472
+ length_px: round1(fp.totalLen)
4473
+ };
4474
+ if (scope === "sheet") {
4475
+ const res = matchSymbol(fp, geo.segs, { ...sweepOpts, excludeCenter: fp.center });
4476
+ let committed2;
4477
+ if (opts.commit && res.matches.length) {
4478
+ committed2 = this.placeCount(name, res.matches.map((m) => m.at), {
4479
+ condition: opts.condition,
4480
+ tool: "symbol_sweep",
4481
+ origins: res.matches.map((m) => ({
4482
+ method: "symbol_sweep",
4483
+ actor: "agent",
4484
+ reviewed: false,
4485
+ symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored, seed: { source: "instance", sheet: s.key } }
4486
+ }))
4487
+ });
4488
+ }
4489
+ return {
4490
+ scope,
4491
+ found: res.matches.length,
4492
+ matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
4493
+ 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 })),
4494
+ seed: seedOut,
4495
+ candidates: res.candidates,
4496
+ ...committed2 ? {
4497
+ committed: committed2.committed,
4498
+ shape_ids: committed2.shape_ids,
4499
+ condition: committed2.condition,
4500
+ ea_total: committed2.ea_total
4501
+ } : {},
4502
+ ...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
4503
+ ...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.` } : {}
4504
+ };
4505
+ }
4506
+ const graph = await this.ensureGraph();
4507
+ if (!graph.available) {
4508
+ throw new UserError("This set has no text layer, so sheet ROLES are unknown \u2014 a set-wide sweep counts PLAN sheets only, and it will not guess which sheets those are. Sweep each sheet explicitly with scope 'sheet'.");
4509
+ }
4510
+ const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
4511
+ const seedRole = roleOf.get(s.key) ?? "unknown";
4512
+ const seedSource = seedRole === "plan" ? "instance" : "detail_sheet";
4513
+ const perSheet = [];
4514
+ const skipped = [];
4515
+ for (const sh of this.sheetList()) {
4516
+ const role = roleOf.get(sh.key) ?? "unknown";
4517
+ if (role !== "plan") {
4518
+ skipped.push({
4519
+ sheet: sh.key,
4520
+ role,
4521
+ reason: sh.key === s.key ? `the seed source \u2014 a symbol drawn on a ${role} sheet defines the fingerprint but is a reference drawing, never installed work` : role === "unknown" ? "role unknown (no classifiable title text) \u2014 sweep it explicitly with scope 'sheet' if it is a plan" : `a ${role} sheet \u2014 symbol instances here are reference drawings, not installed work`
4522
+ });
4523
+ continue;
4524
+ }
4525
+ const g2 = await this.ensureGeometry(sh);
4526
+ if (!g2.segs.length) {
4527
+ skipped.push({ sheet: sh.key, role, reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
4528
+ continue;
4529
+ }
4530
+ const t0 = process.hrtime.bigint();
4531
+ const res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...sh.key === s.key ? { excludeCenter: fp.center } : {} });
4532
+ const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
4533
+ perSheet.push({ state: sh, ...res, elapsed_ms });
4534
+ }
4535
+ const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
3587
4536
  let committed;
3588
- if (opts.commit && res.matches.length) {
3589
- committed = this.placeCount(name, res.matches.map((m) => m.at), {
3590
- condition: opts.condition,
3591
- tool: "symbol_sweep",
3592
- origins: res.matches.map((m) => ({
3593
- method: "symbol_sweep",
3594
- actor: "agent",
3595
- reviewed: false,
3596
- symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored }
3597
- }))
3598
- });
4537
+ if (opts.commit && found) {
4538
+ const ids = [];
4539
+ for (const ps of perSheet) {
4540
+ for (const m of ps.matches) {
4541
+ ids.push(this.commit(ps.state, opts.condition, "count", [m.at], { count: 1 }, {
4542
+ method: "symbol_sweep",
4543
+ actor: "agent",
4544
+ reviewed: false,
4545
+ symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored, seed: { source: seedSource, sheet: s.key, role: seedRole } }
4546
+ }).id);
4547
+ }
4548
+ }
4549
+ this.flushCommits("symbol_sweep");
4550
+ const c = this.conditions.find((x) => x.finish_tag === opts.condition);
4551
+ 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);
4552
+ committed = { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
4553
+ }
4554
+ const capped = perSheet.filter((p) => p.candidates.dropped > 0);
4555
+ const notes = [];
4556
+ if (!perSheet.length) notes.push("No plan-role sheet in the set was sweepable \u2014 nothing was counted; skipped[] says why, sheet by sheet.");
4557
+ if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
4558
+ return {
4559
+ scope,
4560
+ found,
4561
+ seed: { ...seedOut, role: seedRole },
4562
+ sheets: perSheet.map((p) => ({
4563
+ sheet: p.state.key,
4564
+ found: p.matches.length,
4565
+ matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
4566
+ withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
4567
+ candidates: p.candidates,
4568
+ elapsed_ms: p.elapsed_ms
4569
+ })),
4570
+ skipped,
4571
+ ...committed ?? {},
4572
+ ...notes.length ? { note: notes.join(" ") } : {},
4573
+ ...capped.length ? { warning: `Work cap: candidate placements were dropped un-scored on ${capped.map((p) => p.state.key).join(", ")} \u2014 the seed's linework is too common there for an exhaustive sweep. Tighten the seed rect around more distinctive geometry, or sweep those sheets singly and reconcile the counts.` } : {}
4574
+ };
4575
+ }
4576
+ /** sweep_schedule_row (phase 2) — the estimator's story, honored: a
4577
+ * transition type sometimes exists only as a schedule row plus tag markers
4578
+ * scattered across the plan sheets. Given the ROW's key, this mints the
4579
+ * condition FROM the row (the assign-from-schedule vocabulary: the tag is
4580
+ * the schedule's claim, `origin.assignment {source: "schedule"}` with the
4581
+ * citation) and sweeps every plan sheet for the marker the tag is drawn as.
4582
+ *
4583
+ * THE CONTRACT, stated precisely — refusal-honest, never text-to-geometry
4584
+ * guessing:
4585
+ * 1. The row must exist in a schedule table the sheet graph extracted
4586
+ * (one row — a key defined twice across tables is ambiguous, refused).
4587
+ * 2. The tag must be DRAWN on at least one plan-role sheet. A row whose
4588
+ * tag appears nowhere on the plans cannot be geometrically anchored,
4589
+ * and a fingerprint is NEVER guessed from text alone — refused, with
4590
+ * the fix (marquee an instance with symbol_sweep).
4591
+ * 3. The fingerprint is the linework around the tag's own drawn
4592
+ * occurrence (a deterministic pad ladder around the text bbox), and
4593
+ * where the tag occurs more than once it must CORROBORATE — recur at
4594
+ * a second occurrence — before it is trusted. No repeatable marker
4595
+ * geometry → refused.
4596
+ * 4. A geometric match COUNTS only when the row's own tag text sits
4597
+ * within the marker's footprint — drafting reuses one bubble shape
4598
+ * across many tags, so geometry alone is not identity. A match
4599
+ * carrying a SIBLING row's tag is excluded (disclosed with the tag it
4600
+ * carries); a match carrying no tag is withheld as a question; a tag
4601
+ * occurrence with no matching geometry is disclosed as text_only.
4602
+ * Commit is ONE undo step for the whole set-wide sweep. */
4603
+ async sweepScheduleRow(tag, opts = {}) {
4604
+ const t = (tag || "").trim().toUpperCase().replace(/\s+/g, "");
4605
+ if (!t) throw new UserError('Pass a schedule-row tag as drawn, e.g. sweep_schedule_row { tag: "T1" }.');
4606
+ const graph = await this.ensureGraph();
4607
+ if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, so schedule rows cannot be read.");
4608
+ const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => r2.key === t).map((r2) => ({ tb: tb2, r: r2 })));
4609
+ if (!rowHits.length) {
4610
+ const found2 = graph.tables.map((x) => `${x.kind} on ${x.sheet} (${x.rows.length} rows)`).join(" | ");
4611
+ throw new UserError(`No schedule row "${t}" in the set \u2014 tables found: ${found2 || "none"}. Check the tag as drawn (find_schedule shows each table's region), or merge the schedule sheet in with load_plan.`);
4612
+ }
4613
+ if (rowHits.length > 1) {
4614
+ throw new UserError(`Ambiguous: ${rowHits.length} schedule rows carry the key "${t}" \u2014 the same mark defined twice cannot seed one sweep. Marquee the marker yourself with symbol_sweep.`);
4615
+ }
4616
+ const { tb, r } = rowHits[0];
4617
+ const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.map((row) => row.key)))].filter((k) => k !== t).sort();
4618
+ const table = tb.title?.text || `${tb.kind} schedule`;
4619
+ const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
4620
+ const skipped = [];
4621
+ const planSheets = [];
4622
+ for (const sh of this.sheetList()) {
4623
+ const role = roleOf.get(sh.key) ?? "unknown";
4624
+ if (role === "plan") planSheets.push(sh);
4625
+ else {
4626
+ skipped.push({
4627
+ sheet: sh.key,
4628
+ role,
4629
+ reason: role === "unknown" ? "role unknown (no classifiable title text) \u2014 instances here are not counted" : `a ${role} sheet \u2014 the tag's instances here are reference drawings, never installed work`
4630
+ });
4631
+ }
4632
+ }
4633
+ const occOf = (sh, key) => {
4634
+ if (!sh.spans) sh.spans = textSpans(sh.page);
4635
+ return sh.spans.filter((sp) => sp.str.trim().toUpperCase() === key).map((sp) => ({ cx: (sp.x0 + sp.x1) / 2, cy: (sp.y0 + sp.y1) / 2, h: Math.max(sp.y1 - sp.y0, 6), bbox: [sp.x0, sp.y0, sp.x1, sp.y1] })).sort((a, b) => a.cy - b.cy || a.cx - b.cx);
4636
+ };
4637
+ const occBySheet = planSheets.map((sh) => ({ sh, occ: occOf(sh, t) }));
4638
+ const totalOcc = occBySheet.reduce((n, e) => n + e.occ.length, 0);
4639
+ if (!totalOcc) {
4640
+ throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) cannot be geometrically anchored \u2014 its tag is not drawn on any plan sheet, and a fingerprint is never guessed from text alone. If the marker is drawn untagged, marquee one instance with symbol_sweep {scope: "set"}.`);
4641
+ }
4642
+ const withOcc = occBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
4643
+ const anchorSheet = withOcc[0].sh;
4644
+ const anchor = withOcc[0].occ[0];
4645
+ const anchorGeo = await this.ensureGeometry(anchorSheet);
4646
+ if (!anchorGeo.segs.length) {
4647
+ throw new UserError(`${anchorSheet.key} carries the tag "${t}" but no vector linework \u2014 the marker cannot be fingerprinted on a scan.`);
4648
+ }
4649
+ const sweepOpts = {
4650
+ rotations: opts.rotations ?? true,
4651
+ mirror: opts.mirror ?? true,
4652
+ tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
4653
+ };
4654
+ let corro = null;
4655
+ if (withOcc[0].occ.length > 1) corro = { segs: anchorGeo.segs, occ: withOcc[0].occ.slice(1) };
4656
+ else if (withOcc.length > 1) corro = { segs: (await this.ensureGeometry(withOcc[1].sh)).segs, occ: withOcc[1].occ };
4657
+ const cX = (v) => Math.max(0, Math.min(v, anchorSheet.widthPx));
4658
+ const cY = (v) => Math.max(0, Math.min(v, anchorSheet.heightPx));
4659
+ let fp = null;
4660
+ let anchorRect = null;
4661
+ let corroborated = false;
4662
+ for (const padK of [1, 2, 3]) {
4663
+ const pad = padK * anchor.h;
4664
+ const rect = [
4665
+ [cX(anchor.bbox[0] - pad), cY(anchor.bbox[1] - pad)],
4666
+ [cX(anchor.bbox[2] + pad), cY(anchor.bbox[3] + pad)]
4667
+ ];
4668
+ let cand;
4669
+ try {
4670
+ cand = fingerprintSymbol(anchorGeo.segs, rect);
4671
+ } catch (e) {
4672
+ if (e instanceof Error && /region, not one symbol/.test(e.message)) break;
4673
+ continue;
4674
+ }
4675
+ if (!corro) {
4676
+ fp = cand;
4677
+ anchorRect = rect;
4678
+ break;
4679
+ }
4680
+ const probe = matchSymbol(cand, corro.segs, sweepOpts);
4681
+ const pr = cand.footprint / 2 + anchor.h;
4682
+ if (corro.occ.some((o) => probe.matches.some((m) => Math.hypot(m.at[0] - o.cx, m.at[1] - o.cy) <= pr))) {
4683
+ fp = cand;
4684
+ anchorRect = rect;
4685
+ corroborated = true;
4686
+ break;
4687
+ }
4688
+ }
4689
+ if (!fp || !anchorRect) {
4690
+ throw new UserError(corro ? `Schedule row "${t}" cannot be anchored: the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry to fingerprint. Marquee one instance with symbol_sweep instead.` : `Schedule row "${t}" cannot be anchored: no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}. Marquee one instance with symbol_sweep instead.`);
4691
+ }
4692
+ const R = fp.footprint / 2 + anchor.h;
4693
+ const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
4694
+ const perSheet = [];
4695
+ for (const { sh, occ } of occBySheet) {
4696
+ const g2 = await this.ensureGeometry(sh);
4697
+ if (!g2.segs.length) {
4698
+ skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
4699
+ continue;
4700
+ }
4701
+ const t0 = process.hrtime.bigint();
4702
+ const res = matchSymbol(fp, g2.segs, sweepOpts);
4703
+ const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
4704
+ const sibSpans = [];
4705
+ for (const k of siblings) for (const o of occOf(sh, k)) sibSpans.push({ key: k, cx: o.cx, cy: o.cy });
4706
+ const matches = [];
4707
+ const excluded = [];
4708
+ const withheld = [];
4709
+ const matchedOcc = /* @__PURE__ */ new Set();
4710
+ for (const m of res.matches) {
4711
+ let oi = -1;
4712
+ for (let k = 0; k < occ.length; k++) {
4713
+ if (Math.hypot(m.at[0] - occ[k].cx, m.at[1] - occ[k].cy) <= R) {
4714
+ oi = k;
4715
+ break;
4716
+ }
4717
+ }
4718
+ if (oi >= 0) {
4719
+ matchedOcc.add(oi);
4720
+ matches.push({ ...m, tag_at: occ[oi].bbox });
4721
+ continue;
4722
+ }
4723
+ const sib = sibSpans.find((sp) => Math.hypot(m.at[0] - sp.cx, m.at[1] - sp.cy) <= R);
4724
+ if (sib) {
4725
+ excluded.push({ at: m.at, tag: sib.key });
4726
+ continue;
4727
+ }
4728
+ withheld.push({ ...m, reason: `the marker geometry matches but carries no "${t}" tag within its footprint \u2014 an unlabeled instance or a shared marker shape; look before counting it` });
4729
+ }
4730
+ for (const w of res.withheld) {
4731
+ const near = occ.some((o) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R);
4732
+ withheld.push(near ? { ...w, reason: `${w.reason} \u2014 and the "${t}" tag is drawn beside it` } : w);
4733
+ }
4734
+ matches.sort(byPos);
4735
+ excluded.sort(byPos);
4736
+ withheld.sort(byPos);
4737
+ const text_only = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
4738
+ perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms });
4739
+ }
4740
+ const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
4741
+ let committed;
4742
+ if (opts.commit && found) {
4743
+ const ids = [];
4744
+ for (const ps of perSheet) {
4745
+ for (const m of ps.matches) {
4746
+ ids.push(this.commit(ps.state, t, "count", [m.at], { count: 1 }, {
4747
+ method: "symbol_sweep",
4748
+ actor: "agent",
4749
+ reviewed: false,
4750
+ assignment: { source: "schedule", schedule_sheet: tb.sheet },
4751
+ symbol: {
4752
+ score: m.score,
4753
+ rotation: m.rotation,
4754
+ mirrored: m.mirrored,
4755
+ seed: { source: "schedule_row", sheet: anchorSheet.key, row: { sheet: tb.sheet, key: t, table } }
4756
+ }
4757
+ }).id);
4758
+ }
4759
+ }
4760
+ this.flushCommits("sweep_schedule_row");
4761
+ const c = this.conditions.find((x) => x.finish_tag === t);
4762
+ 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);
4763
+ committed = { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
3599
4764
  }
4765
+ const cells = {};
4766
+ for (const [k, v] of Object.entries(r.cells)) cells[k] = v.text;
4767
+ const firstCell = r.cells[Object.keys(r.cells)[0]];
4768
+ const capped = perSheet.filter((p) => p.candidates.dropped > 0);
4769
+ const notes = [];
4770
+ if (!corroborated) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
4771
+ if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
3600
4772
  return {
3601
- found: res.matches.length,
3602
- matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
3603
- withheld: res.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
3604
- seed: {
3605
- segments: res.seed.segments,
3606
- center: [round1(res.seed.center[0]), round1(res.seed.center[1])],
3607
- rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
3608
- length_px: res.seed.length_px
4773
+ tag: t,
4774
+ row: {
4775
+ sheet: tb.sheet,
4776
+ table,
4777
+ key: t,
4778
+ cells,
4779
+ citation: { sheet: tb.sheet, text: `${table} row ${t}`, bbox: _Session.wireBox(firstCell?.bbox || tb.region) }
4780
+ },
4781
+ anchor: {
4782
+ sheet: anchorSheet.key,
4783
+ at: [round1(anchor.cx), round1(anchor.cy)],
4784
+ rect: [round1(anchorRect[0][0]), round1(anchorRect[0][1]), round1(anchorRect[1][0]), round1(anchorRect[1][1])],
4785
+ segments: fp.segments,
4786
+ length_px: round1(fp.totalLen),
4787
+ corroborated,
4788
+ occurrences: totalOcc
3609
4789
  },
3610
- candidates: res.candidates,
3611
- ...committed ? {
3612
- committed: committed.committed,
3613
- shape_ids: committed.shape_ids,
3614
- condition: committed.condition,
3615
- ea_total: committed.ea_total
3616
- } : {},
3617
- ...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
3618
- ...res.candidates.dropped > 0 ? { warning: `Work cap: ${res.candidates.dropped} candidate placement(s) were never scored \u2014 the seed's linework is too common on this sheet for an exhaustive sweep. Tighten the seed rect around more distinctive geometry, or sweep a region at a time and reconcile the counts.` } : {}
4790
+ found,
4791
+ sheets: perSheet.map((p) => ({
4792
+ sheet: p.state.key,
4793
+ found: p.matches.length,
4794
+ matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored, tag_at: _Session.wireBox(m.tag_at) })),
4795
+ withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
4796
+ excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
4797
+ text_only: p.text_only,
4798
+ candidates: p.candidates,
4799
+ elapsed_ms: p.elapsed_ms
4800
+ })),
4801
+ skipped,
4802
+ ...committed ?? {},
4803
+ ...notes.length ? { note: notes.join(" ") } : {},
4804
+ ...capped.length ? { warning: `Work cap: candidate placements were dropped un-scored on ${capped.map((p) => p.state.key).join(", ")} \u2014 sweep those sheets singly with symbol_sweep and reconcile the counts.` } : {}
3619
4805
  };
3620
4806
  }
3621
4807
  /** The mid-session shape inventory (#149): every committed shape's id,
@@ -3907,6 +5093,9 @@ var Session = class _Session {
3907
5093
  else c.roll_setup = e.before.roll_setup;
3908
5094
  }
3909
5095
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
5096
+ } else if (e.op === "approval") {
5097
+ this.approvals = applyApprovalCommand2(this.approvals, e.inverse).approvals;
5098
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
3910
5099
  } else {
3911
5100
  for (const { shape, index } of e.removed) {
3912
5101
  this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
@@ -3980,18 +5169,28 @@ var Session = class _Session {
3980
5169
  }
3981
5170
  /** Read annotations, optionally narrowed to a sheet and/or a condition.
3982
5171
  * Resolves condition_id to its finish tag so a caller can act on the reply
3983
- * without joining against the conditions array. */
5172
+ * without joining against the conditions array.
5173
+ *
5174
+ * Verdict marks (#176) ride the same inventory as their own block: the
5175
+ * sheet filter applies directly (a record renders on its sheet), and a
5176
+ * condition filter reaches a verdict THROUGH its target shape — a verdict
5177
+ * on a CPT-1 shape is about CPT-1 work, while a sheet-point mark carries
5178
+ * no scope and drops out of any condition filter. */
3984
5179
  listAnnotations(f = {}) {
3985
5180
  const tagById = new Map(this.conditions.map((c) => [c.id, c.finish_tag]));
3986
5181
  let rows = this.markups;
5182
+ let seals = this.approvals;
3987
5183
  if (f.sheet) {
3988
5184
  const s = this.sheet(f.sheet);
3989
5185
  rows = rows.filter((m) => m.sheet_id === s.key);
5186
+ seals = seals.filter((a) => a.sheet_id === s.key);
3990
5187
  }
5188
+ const shapeById = new Map(this.shapes.map((x) => [x.id, x]));
3991
5189
  if (f.condition) {
3992
5190
  const c = this.conditions.find((x) => x.finish_tag === f.condition);
3993
5191
  if (!c) throw new UserError(`no condition "${f.condition}" \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}`);
3994
5192
  rows = rows.filter((m) => m.condition_id === c.id);
5193
+ seals = seals.filter((a) => a.shape_id !== void 0 && shapeById.get(a.shape_id)?.condition_id === c.id);
3995
5194
  }
3996
5195
  const s0 = this.sheets;
3997
5196
  const px = (m, p) => {
@@ -4016,7 +5215,22 @@ var Session = class _Session {
4016
5215
  ...m.len_ft != null ? { length_lf: m.len_ft } : {}
4017
5216
  })),
4018
5217
  count: rows.length,
4019
- unattached: rows.filter((m) => !m.condition_id).length
5218
+ unattached: rows.filter((m) => !m.condition_id).length,
5219
+ verdicts: seals.map((a) => {
5220
+ const sh = s0.get(a.sheet_id);
5221
+ const target = a.shape_id !== void 0 ? shapeById.get(a.shape_id) : void 0;
5222
+ return {
5223
+ id: a.id,
5224
+ actor: a.actor,
5225
+ sheet: a.sheet_id,
5226
+ ...sh ? { at: [round1(a.at[0] * sh.widthPx), round1(a.at[1] * sh.heightPx)] } : {},
5227
+ ...a.ts ? { ts: a.ts } : {},
5228
+ ...a.shape_id !== void 0 ? { shape_id: a.shape_id } : {},
5229
+ condition: target ? tagById.get(target.condition_id) ?? "" : "",
5230
+ ...typeof a.text === "string" && a.text ? { text: a.text } : {}
5231
+ };
5232
+ }),
5233
+ verdict_count: seals.length
4020
5234
  };
4021
5235
  }
4022
5236
  /** Attach an existing annotation to a condition, or detach it with "". The
@@ -4032,6 +5246,121 @@ var Session = class _Session {
4032
5246
  m.condition_id = c.id;
4033
5247
  return { id: m.id, condition: c.finish_tag, condition_id: c.id, note: `Attached to ${c.finish_tag}.` };
4034
5248
  }
5249
+ // ── verdict marks (#176) — the agent half of the approval family ───────────
5250
+ /** Where a shape-targeted verdict draws, in the space of the verts given.
5251
+ * The anchor is a render decision, not a measurement: a closed room anchors
5252
+ * at its area centroid, an open run at its on-path midpoint (a bent run's
5253
+ * centroid can sit off the work; the midpoint never does), a count marker
5254
+ * at the marker itself. Callers pass SHEET-PX verts so the midpoint is the
5255
+ * drawn run's true midpoint — arc length does not commute with the
5256
+ * non-uniform norm↔px map (centroids do, so they'd be safe either way).
5257
+ * Degenerate geometry falls back to the vertex mean. */
5258
+ static verdictAnchor(v, role) {
5259
+ if (v.length === 1) return [v[0][0], v[0][1]];
5260
+ const closed = role === "floor_area" || role === "deduct";
5261
+ if (closed && v.length >= 3) {
5262
+ let a = 0, cx = 0, cy = 0;
5263
+ for (let i = 0; i < v.length; i++) {
5264
+ const [x1, y1] = v[i], [x2, y2] = v[(i + 1) % v.length];
5265
+ const w = x1 * y2 - x2 * y1;
5266
+ a += w;
5267
+ cx += (x1 + x2) * w;
5268
+ cy += (y1 + y2) * w;
5269
+ }
5270
+ if (Math.abs(a) > 1e-12) return [cx / (3 * a), cy / (3 * a)];
5271
+ } else if (!closed && v.length >= 2) {
5272
+ const lens = [];
5273
+ let total = 0;
5274
+ for (let i = 1; i < v.length; i++) {
5275
+ const l = Math.hypot(v[i][0] - v[i - 1][0], v[i][1] - v[i - 1][1]);
5276
+ lens.push(l);
5277
+ total += l;
5278
+ }
5279
+ let walk = total / 2;
5280
+ for (let i = 0; i < lens.length; i++) {
5281
+ if (walk <= lens[i] && lens[i] > 0) {
5282
+ const t = walk / lens[i];
5283
+ return [v[i][0] + (v[i + 1][0] - v[i][0]) * t, v[i][1] + (v[i + 1][1] - v[i][1]) * t];
5284
+ }
5285
+ walk -= lens[i];
5286
+ }
5287
+ }
5288
+ const n = v.length || 1;
5289
+ return [v.reduce((s, p) => s + p[0], 0) / n, v.reduce((s, p) => s + p[1], 0) / n];
5290
+ }
5291
+ /** Mint the agent's verdict mark. actor is the string literal "agent" on
5292
+ * the one line that writes the record — there is no actor parameter on this
5293
+ * method, on the tool, or anywhere between, so no MCP path can produce the
5294
+ * estimator's APPROVED seal (that ink stays behind the canvas's human-only
5295
+ * Approve tool). The mutation and its exact-restore inverse both come from
5296
+ * the canvas's pure apply, so a mark here undoes and hydrates exactly like
5297
+ * a mark made in the app. Touches no quantity. */
5298
+ markVerdict(a) {
5299
+ let sheetId;
5300
+ let atNorm;
5301
+ let shape;
5302
+ if (a.shape_id !== void 0) {
5303
+ shape = this.shapes.find((x) => x.id === a.shape_id);
5304
+ if (!shape) throw new UserError(`No shape with id ${JSON.stringify(a.shape_id)} \u2014 list_shapes has the real ids.`);
5305
+ const dup = this.approvals.find((x) => x.actor === "agent" && x.shape_id === shape.id);
5306
+ if (dup) throw new UserError(`Shape ${shape.id} already carries an agent verdict (${dup.id}) \u2014 one mark per shape. delete_verdict it first to re-mark.`);
5307
+ sheetId = shape.sheet_id;
5308
+ const dims = this.sheets.get(sheetId);
5309
+ if (dims) {
5310
+ const px = shape.verts_norm.map(([nx, ny]) => [nx * dims.widthPx, ny * dims.heightPx]);
5311
+ const [ax, ay] = _Session.verdictAnchor(px, shape.measure_role);
5312
+ atNorm = [ax / dims.widthPx, ay / dims.heightPx];
5313
+ } else {
5314
+ atNorm = _Session.verdictAnchor(shape.verts_norm, shape.measure_role);
5315
+ }
5316
+ } else {
5317
+ const s = this.sheet(a.sheet);
5318
+ sheetId = s.key;
5319
+ atNorm = [a.at[0] / s.widthPx, a.at[1] / s.heightPx];
5320
+ }
5321
+ const text = (a.text ?? "").trim();
5322
+ const { approvals, inverse } = applyApprovalCommand2(this.approvals, {
5323
+ type: "add",
5324
+ approvals: [{
5325
+ actor: "agent",
5326
+ // hardcoded — the structural impossibility, not a default
5327
+ sheet_id: sheetId,
5328
+ at: atNorm,
5329
+ ...shape ? { shape_id: shape.id } : {},
5330
+ ...text ? { text } : {}
5331
+ }]
5332
+ });
5333
+ this.approvals = approvals;
5334
+ this.record({ op: "approval", tool: "mark_verdict", inverse });
5335
+ const minted = this.approvals[this.approvals.length - 1];
5336
+ const sh = this.sheets.get(sheetId);
5337
+ const tag = shape ? this.conditions.find((c) => c.id === shape.condition_id)?.finish_tag ?? "" : void 0;
5338
+ return {
5339
+ id: minted.id,
5340
+ actor: "agent",
5341
+ sheet: sheetId,
5342
+ ...sh ? { at: [round1(atNorm[0] * sh.widthPx), round1(atNorm[1] * sh.heightPx)] } : {},
5343
+ ts: minted.ts,
5344
+ ...shape ? { shape_id: shape.id } : {},
5345
+ ...tag !== void 0 ? { condition: tag } : {},
5346
+ ...text ? { text } : {},
5347
+ note: shape ? `AGENT diamond anchored on ${shape.id} \u2014 the agent's pencil-signature on its own work, beside the estimator's ink, never in its place. It touches no quantity.` : "AGENT diamond at the sheet point \u2014 the agent's pencil-signature, beside the estimator's ink, never in its place. It touches no quantity."
5348
+ };
5349
+ }
5350
+ /** Lift an agent verdict mark. The estimator's seal is human ink and is
5351
+ * refused — the same line editShape holds on reviewed shapes: an agent
5352
+ * retracts only its own marks. */
5353
+ deleteVerdict(id) {
5354
+ const a = this.approvals.find((x) => x.id === id);
5355
+ if (!a) throw new UserError(`No verdict ${JSON.stringify(id)} \u2014 list_annotations returns the real ids in verdicts[].`);
5356
+ if (a.actor !== "agent") {
5357
+ throw new UserError(`${id} is the estimator's APPROVED seal \u2014 human ink, refused. An agent lifts only its own marks (actor "agent").`);
5358
+ }
5359
+ const { approvals, inverse } = applyApprovalCommand2(this.approvals, { type: "delete", ids: [id] });
5360
+ this.approvals = approvals;
5361
+ this.record({ op: "approval", tool: "delete_verdict", inverse });
5362
+ return { deleted: id, verdicts_remaining: this.approvals.length };
5363
+ }
4035
5364
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
4036
5365
  * store.saveAnnotations stamps) — importable by the app. */
4037
5366
  exportPayload() {
@@ -4044,6 +5373,10 @@ var Session = class _Session {
4044
5373
  conditions: this.conditions,
4045
5374
  shapes: this.shapes,
4046
5375
  markups: this.markups,
5376
+ // approvals ride the payload additively (#176) — present only when any
5377
+ // exist, exactly the canvas buildPayload's convention, so a verdict-free
5378
+ // export stays byte-identical to a pre-#176 one
5379
+ ...this.approvals.length ? { approvals: this.approvals } : {},
4047
5380
  sheet_group: [],
4048
5381
  last_group: [],
4049
5382
  sheet_tabs: [],
@@ -4086,7 +5419,7 @@ var Session = class _Session {
4086
5419
  inputs.push({
4087
5420
  key: s.key,
4088
5421
  sheet_number: s.sheetNumber,
4089
- spans: s.spans.map((t) => ({ str: t.str, x: t.x0, y: t.y0, w: t.x1 - t.x0, h: t.y1 - t.y0 }))
5422
+ spans: s.spans.map((t) => ({ str: t.str, x: t.x0, y: t.y0, w: t.x1 - t.x0, h: t.y1 - t.y0, ...t.rot ? { rot: t.rot } : {} }))
4090
5423
  });
4091
5424
  }
4092
5425
  this.graph = buildSheetGraph(inputs);
@@ -4108,10 +5441,20 @@ var Session = class _Session {
4108
5441
  role: s.role,
4109
5442
  confidence: s.confidence,
4110
5443
  ...s.evidence ? { evidence: _Session.wireEvidence(s.evidence) } : {},
4111
- schedules: s.schedules.map((t) => ({ kind: t.kind, title: t.title, rows: t.rows, region: _Session.wireBox(t.region) }))
5444
+ ...s.building ? { building: s.building } : {},
5445
+ schedules: s.schedules.map((t) => ({
5446
+ kind: t.kind,
5447
+ title: t.title,
5448
+ rows: t.rows,
5449
+ region: _Session.wireBox(t.region),
5450
+ ...t.continues ? { continues: t.continues } : {},
5451
+ ...t.rotated_headers ? { rotated_headers: true } : {}
5452
+ }))
4112
5453
  })),
4113
- rooms: g.rooms.map((r) => ({ tag: r.tag, name: r.name, sheet: r.sheet, bbox: _Session.wireBox(r.bbox) })),
5454
+ rooms: g.rooms.map((r) => ({ tag: r.tag, name: r.name, sheet: r.sheet, bbox: _Session.wireBox(r.bbox), ...r.building ? { building: r.building } : {} })),
4114
5455
  callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
5456
+ ...g.buildings.length ? { buildings: g.buildings } : {},
5457
+ ...g.notes.length ? { notes: g.notes } : {},
4115
5458
  counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
4116
5459
  };
4117
5460
  }
@@ -4138,12 +5481,21 @@ var Session = class _Session {
4138
5481
  const g = await this.ensureGraph();
4139
5482
  if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
4140
5483
  const res = resolveTag(g, tag);
4141
- const room = res.room ? { tag: res.room.tag, name: res.room.name, sheet: res.room.sheet, bbox: _Session.wireBox(res.room.bbox) } : null;
4142
- if (res.status === "unresolved") return { status: "unresolved", tag: res.tag, room, reason: res.reason };
5484
+ const room = res.room ? { tag: res.room.tag, name: res.room.name, sheet: res.room.sheet, bbox: _Session.wireBox(res.room.bbox), ...res.room.building ? { building: res.room.building } : {} } : null;
5485
+ if (res.status === "unresolved") {
5486
+ return {
5487
+ status: "unresolved",
5488
+ tag: res.tag,
5489
+ room,
5490
+ reason: res.reason,
5491
+ ...res.candidates?.length ? { candidates: res.candidates } : {}
5492
+ };
5493
+ }
4143
5494
  return {
4144
5495
  status: "resolved",
4145
5496
  tag: res.tag,
4146
5497
  room,
5498
+ ...res.building ? { building: res.building } : {},
4147
5499
  finishes: res.finishes.map((f) => ({
4148
5500
  surface: f.surface,
4149
5501
  code: f.code,
@@ -4170,7 +5522,10 @@ var Session = class _Session {
4170
5522
  title: t.title?.text || "",
4171
5523
  rows: t.rows.length,
4172
5524
  headers: t.headers,
4173
- region: _Session.wireBox(t.region)
5525
+ region: _Session.wireBox(t.region),
5526
+ ...t.building ? { building: t.building } : {},
5527
+ ...t.rotated_headers ? { rotated_headers: true } : {},
5528
+ ...t.parts ? { parts: t.parts.map((p) => ({ sheet: p.sheet, title: p.title, rows: p.rows, region: _Session.wireBox(p.region) })) } : {}
4174
5529
  }))
4175
5530
  };
4176
5531
  }
@@ -4350,20 +5705,39 @@ var sweepPlacement = {
4350
5705
  rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
4351
5706
  mirrored: z.boolean()
4352
5707
  };
5708
+ var sweepCandidates = z.object({
5709
+ considered: z.number().int(),
5710
+ dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
5711
+ });
5712
+ var sweepSheetBlock = z.object({
5713
+ sheet: z.string(),
5714
+ found: z.number().int(),
5715
+ matches: z.array(z.object(sweepPlacement)),
5716
+ withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })),
5717
+ candidates: sweepCandidates.describe("The work cap applies PER SHEET; dropped > 0 here names exactly where the count is incomplete"),
5718
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
5719
+ });
5720
+ var sweepSkipped = z.array(z.object({
5721
+ sheet: z.string(),
5722
+ role: z.string().describe("The sheet's graph role (plan / schedule / legend / detail / \u2026)"),
5723
+ reason: z.string()
5724
+ }));
4353
5725
  var symbolSweepOutput = {
4354
- found: z.number().int().describe("Placements that cleared the commit bar \u2014 matches.length"),
4355
- matches: z.array(z.object(sweepPlacement)).describe("Deterministic reading order (y, then x). The seed's own location is never listed here"),
4356
- withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })).describe("Near-matches in the [0.75, 0.92) band \u2014 reported with a reason, NEVER committed. A withheld placement is a question you can answer with view_sheet; a hidden one is a miscount"),
5726
+ scope: z.enum(["sheet", "set"]).describe('"sheet" = the swept sheet alone (matches/withheld/candidates at top level); "set" = every PLAN-role sheet in the working set (per-sheet results in sheets[], exclusions in skipped[])'),
5727
+ found: z.number().int().describe("Placements that cleared the commit bar \u2014 across every swept sheet in set scope"),
5728
+ matches: z.array(z.object(sweepPlacement)).optional().describe("Sheet scope only. Deterministic reading order (y, then x). The seed's own location is never listed here"),
5729
+ withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })).optional().describe("Sheet scope only. Near-matches in the [0.75, 0.92) band \u2014 reported with a reason, NEVER committed. A withheld placement is a question you can answer with view_sheet; a hidden one is a miscount"),
4357
5730
  seed: z.object({
5731
+ sheet: z.string().describe("The sheet the seed rect was marqueed on"),
5732
+ role: z.string().optional().describe("Set scope: the seed sheet's graph role \u2014 a non-plan seed sheet is the fingerprint SOURCE and is excluded from counting"),
4358
5733
  segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
4359
5734
  center: z.tuple([z.number(), z.number()]).describe("The seed instance's own centroid (image px) \u2014 reported here, never double-committed as a match"),
4360
5735
  rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
4361
5736
  length_px: z.number().describe("Total seed linework length, image px")
4362
5737
  }),
4363
- candidates: z.object({
4364
- considered: z.number().int(),
4365
- dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
4366
- }),
5738
+ candidates: sweepCandidates.optional().describe("Sheet scope only \u2014 set scope accounts per sheet in sheets[]"),
5739
+ sheets: z.array(sweepSheetBlock).optional().describe("Set scope only: one entry per swept PLAN-role sheet, load order"),
5740
+ skipped: sweepSkipped.optional().describe("Set scope only: every sheet excluded from counting, with role and reason \u2014 including the seed's own sheet when it is not a plan"),
4367
5741
  committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
4368
5742
  shape_ids: z.array(z.string()).optional(),
4369
5743
  condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
@@ -4431,6 +5805,7 @@ var exportTakeoffOutput = {
4431
5805
  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)")
4432
5806
  }).passthrough()),
4433
5807
  markups: z.array(z.unknown()),
5808
+ approvals: z.array(z.unknown()).optional().describe("Approval-family records (#176) \u2014 the estimator's APPROVED seals and the agent's verdict marks {id, actor, ts, sheet_id, at:[nx,ny], shape_id?, text?}. Present only when any exist (the canvas payload's own convention), so a verdict-free export stays byte-identical"),
4434
5809
  sheet_group: z.array(z.unknown()),
4435
5810
  last_group: z.array(z.unknown()),
4436
5811
  sheet_tabs: z.array(z.unknown()),
@@ -4498,9 +5873,9 @@ var undoLastOutput = {
4498
5873
  undone: z.number().int().describe("Steps actually reversed"),
4499
5874
  steps: z.array(z.object({
4500
5875
  seq: z.number().int(),
4501
- op: z.enum(["commit", "edit", "delete", "materials", "condition"]),
5876
+ op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval"]),
4502
5877
  tool: z.string().describe("The tool call this step came from"),
4503
- shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes) and for a condition step (it restores the waste/multiplier pair)")
5878
+ shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes), for a condition step (it restores the waste/multiplier pair), and for an approval step (it re-seats or removes a verdict mark)")
4504
5879
  })).describe("Newest first"),
4505
5880
  shape_count: z.number().int().describe("Committed shapes after the undo"),
4506
5881
  remaining: z.number().int().describe("Steps still available to undo"),
@@ -4573,9 +5948,10 @@ var exportReportOutput = {
4573
5948
  var exportMarkedPdfOutput = {
4574
5949
  path: z.string().describe("Absolute path of the written marked-set PDF \u2014 hand this to the user"),
4575
5950
  pages: z.number().int().describe("Legend cover + one page per marked sheet"),
4576
- sheets_marked: z.number().int().describe("Sheets carrying shapes or annotations \u2014 unmarked sheets are omitted"),
5951
+ sheets_marked: z.number().int().describe("Sheets carrying shapes, annotations, or approval marks \u2014 unmarked sheets are omitted"),
4577
5952
  shapes_drawn: z.number().int(),
4578
5953
  annotations_drawn: z.number().int(),
5954
+ approvals_drawn: z.number().int().describe("Approval-family glyphs burned in (#176) \u2014 estimator APPROVED rings + agent AGENT diamonds; the cover tallies the split when any exist"),
4579
5955
  note: z.string()
4580
5956
  };
4581
5957
  var editConditionOutput = {
@@ -4607,7 +5983,13 @@ var readSheetTextOutput = {
4607
5983
  };
4608
5984
  var wireBox = z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() });
4609
5985
  var wireEvidence = z.object({ sheet: z.string(), text: z.string(), bbox: wireBox }).describe("An evidence pointer \u2014 the sheet, the literal text, and where it sits (image px). Every edge in the graph carries one; pass the bbox to view_sheet to LOOK at the source.");
4610
- var graphRoom = z.object({ tag: z.string(), name: z.string().describe("The name span stacked over the tag ('' when none)"), sheet: z.string(), bbox: wireBox });
5986
+ var graphRoom = z.object({
5987
+ tag: z.string(),
5988
+ name: z.string().describe("The name span stacked over the tag ('' when none)"),
5989
+ sheet: z.string(),
5990
+ bbox: wireBox,
5991
+ building: z.string().optional().describe("The building the room belongs to, when the set names one \u2014 its plan sheet's BUILDING/BLDG context, or the tag's own qualifier ('A-134')")
5992
+ });
4611
5993
  var sheetGraphOutput = {
4612
5994
  available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
4613
5995
  sheets: z.array(z.object({
@@ -4615,35 +5997,98 @@ var sheetGraphOutput = {
4615
5997
  role: z.enum(["plan", "schedule", "legend", "detail", "elevation", "demolition", "unknown"]),
4616
5998
  confidence: z.number().describe("0..1; mixed title signals halve it, a bare sheet-number convention stays under 0.5"),
4617
5999
  evidence: wireEvidence.optional(),
4618
- schedules: z.array(z.object({ kind: z.string(), title: z.string(), rows: z.number().int(), region: wireBox }))
6000
+ building: z.string().optional().describe("The sheet's building context, when it names exactly one (BUILDING A / BLDG 2)"),
6001
+ schedules: z.array(z.object({
6002
+ kind: z.string(),
6003
+ title: z.string(),
6004
+ rows: z.number().int(),
6005
+ region: wireBox,
6006
+ continues: z.string().optional().describe("Present on a continuation fragment ('\u2026 SCHEDULE \u2014 CONT'D'): the sheet carrying the table's base fragment. The fragments read as ONE table \u2014 resolve_tag and find_schedule already see the union"),
6007
+ rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn")
6008
+ }))
4619
6009
  })),
4620
6010
  rooms: z.array(graphRoom).describe("Room tags read off plan-role sheets \u2014 schedule sheets contribute rows, never phantom rooms"),
4621
6011
  callouts: z.array(z.object({ detail: z.string(), target_sheet: z.string(), sheet: z.string(), bbox: wireBox })).describe("Detail callouts (3/A-601) \u2014 edges to their target sheets"),
4622
- counts: z.object({ rooms: z.number().int(), schedules: z.number().int(), callouts: z.number().int() })
6012
+ buildings: z.array(z.string()).optional().describe("Every building designator the set names (sorted) \u2014 present only on multi-building-aware sets. Room numbers reused across these need qualified tags ('A-134')"),
6013
+ notes: z.array(z.string()).optional().describe("Named gaps found while indexing (e.g. a continuation whose rows could not be aligned) \u2014 the graph refuses silently dropping anything"),
6014
+ counts: z.object({ rooms: z.number().int(), schedules: z.number().int().describe("LOGICAL tables \u2014 a schedule continued across sheets counts once"), callouts: z.number().int() })
4623
6015
  };
4624
6016
  var resolveTagOutput = {
4625
6017
  status: z.enum(["resolved", "unresolved"]),
4626
6018
  tag: z.string(),
4627
- room: graphRoom.nullable().describe("The plan tag, when the room appears on a plan sheet \u2014 cited even when resolution fails"),
6019
+ room: graphRoom.nullable().describe("The plan tag, when the room appears on a plan sheet \u2014 cited even when resolution fails. null on a multi-building ambiguity: citing one building's tag would be quietly wrong"),
6020
+ building: z.string().optional().describe("resolved only \u2014 the building whose schedule row answered, when the set names buildings"),
4628
6021
  finishes: z.array(z.object({
4629
6022
  surface: z.string().describe("The schedule column: FLOOR / BASE / WALL / \u2026"),
4630
6023
  code: z.string(),
4631
6024
  source: wireEvidence,
4632
6025
  definition: z.object({ cells: z.record(z.string()), source: wireEvidence }).optional().describe("The finish/material-schedule row this code chains to, when one exists")
4633
6026
  })).optional(),
4634
- sources: z.array(wireEvidence).optional().describe("The chain: plan tag \u2192 schedule row"),
4635
- reason: z.string().optional().describe("unresolved only \u2014 WHY (no schedule row / ambiguous / no schedule found). A room that appears on the plan with no row comes back here, never as a silent omission")
6027
+ sources: z.array(wireEvidence).optional().describe("The chain: plan tag \u2192 schedule row (the row cites the sheet that CARRIES it \u2014 under a continuation that is the CONT'D sheet)"),
6028
+ reason: z.string().optional().describe("unresolved only \u2014 WHY (no schedule row / ambiguous / no schedule found). A room that appears on the plan with no row comes back here, never as a silent omission"),
6029
+ candidates: z.array(z.object({
6030
+ key: z.string(),
6031
+ building: z.string().optional(),
6032
+ sheet: z.string(),
6033
+ table: z.string()
6034
+ })).optional().describe('unresolved only \u2014 every schedule row that COULD have answered (an ambiguous multi-building tag lists one per building; qualify the tag, e.g. "A-134", to pick)')
4636
6035
  };
4637
6036
  var findScheduleOutput = {
4638
6037
  matches: z.array(z.object({
4639
6038
  sheet: z.string(),
4640
6039
  kind: z.string(),
4641
6040
  title: z.string(),
4642
- rows: z.number().int(),
6041
+ rows: z.number().int().describe("Total data rows \u2014 a continued schedule counts every fragment's rows"),
4643
6042
  headers: z.array(z.string()),
4644
- region: wireBox.describe("Pass to view_sheet to look at the table")
6043
+ region: wireBox.describe("Pass to view_sheet to look at the table (the BASE fragment's region when the table continues)"),
6044
+ building: z.string().optional().describe("The building this table answers for, when its title or sheet names one"),
6045
+ rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn"),
6046
+ parts: z.array(z.object({ sheet: z.string(), title: z.string(), rows: z.number().int(), region: wireBox })).optional().describe("Present when the table CONTINUES across sheets ('\u2026 SCHEDULE \u2014 CONT'D'): every fragment, base first, each with its own viewable region")
4645
6047
  }))
4646
6048
  };
6049
+ var rowSweepPlacement = {
6050
+ at: z.tuple([z.number(), z.number()]).describe("The matched marker's centroid (image px)"),
6051
+ score: z.number().describe("Length-weighted fraction of the anchor's segments matched within tolerance, 0..1"),
6052
+ rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
6053
+ mirrored: z.boolean()
6054
+ };
6055
+ var sweepScheduleRowOutput = {
6056
+ tag: z.string().describe("The row key as normalized (the tag as drawn)"),
6057
+ row: z.object({
6058
+ sheet: z.string(),
6059
+ table: z.string().describe("The table's title (or kind, when untitled)"),
6060
+ key: z.string(),
6061
+ cells: z.record(z.string()).describe("The row's cells, header \u2192 text \u2014 what the schedule SAYS this mark is"),
6062
+ citation: wireEvidence
6063
+ }).describe("The schedule row the sweep was seeded from \u2014 the condition's source"),
6064
+ anchor: z.object({
6065
+ sheet: z.string().describe("The plan sheet the fingerprint was anchored on"),
6066
+ at: z.tuple([z.number(), z.number()]).describe("The anchoring tag occurrence's center (image px)"),
6067
+ rect: z.array(z.number()).length(4).describe("The fingerprint rect actually used [x0, y0, x1, y1] \u2014 the pad ladder's winning step"),
6068
+ segments: z.number().int().describe("Vector segments in the marker fingerprint"),
6069
+ length_px: z.number(),
6070
+ corroborated: z.boolean().describe("true = the fingerprint recurred at a second tag occurrence before being trusted; false = the tag is drawn too sparsely to cross-check (see note)"),
6071
+ occurrences: z.number().int().describe("Drawn occurrences of the tag across all plan sheets")
6072
+ }),
6073
+ found: z.number().int().describe("Matches carrying the row's own tag \u2014 the honest count, across every plan sheet"),
6074
+ sheets: z.array(z.object({
6075
+ sheet: z.string(),
6076
+ found: z.number().int(),
6077
+ matches: z.array(z.object({ ...rowSweepPlacement, tag_at: wireBox.describe("The corroborating tag text's bbox \u2014 the evidence that this marker is THIS row's") })),
6078
+ withheld: z.array(z.object({ ...rowSweepPlacement, reason: z.string() })).describe("Questions, never counts: markers matching the geometry but carrying no tag (an unlabeled instance or a shared bubble shape), and near-miss scores in the [0.75, 0.92) band"),
6079
+ excluded: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag: z.string() })).describe("Markers matching the geometry but labeled with a SIBLING row's tag \u2014 the bubble shape is shared across marks, so these belong to that row, not this one"),
6080
+ text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with NO matching marker geometry nearby \u2014 a note reference or a variant marker; a question, never a count"),
6081
+ candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
6082
+ elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
6083
+ })).describe("One entry per swept PLAN-role sheet, load order"),
6084
+ skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })).describe("Sheets excluded from counting (schedule/detail/legend/unknown), each with its reason"),
6085
+ committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per counted match, the whole sweep ONE undo step"),
6086
+ shape_ids: z.array(z.string()).optional(),
6087
+ condition: z.string().optional().describe("commit mode: the condition minted FROM the row \u2014 its key is the tag"),
6088
+ ea_total: z.number().optional(),
6089
+ note: z.string().optional(),
6090
+ warning: z.string().optional().describe("Present when the per-sheet work cap dropped candidates")
6091
+ };
4647
6092
  var hatchFamilyRow = z.object({
4648
6093
  id: z.string().describe("Content hash of the quantized (angle, pitch, pen-width) signature \u2014 the SAME id for the same pattern spec anywhere on the sheet, so legend\u2194plan matching is id === id. Identifies a pattern, not a material; the legend maps pattern \u2192 material."),
4649
6094
  angle_deg: z.number().describe("Raw mean angle [0, 180) \u2014 rides beside the id for tolerance matching at bucket boundaries"),
@@ -4674,7 +6119,7 @@ var sheetContextOutput = {
4674
6119
  note: z.string().optional()
4675
6120
  }),
4676
6121
  text: z.object({
4677
- spans: z.array(z.object({ str: z.string(), x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() })).describe("Text with bboxes, image px, same frame as the vectors"),
6122
+ spans: z.array(z.object({ str: z.string(), x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number(), rot: z.number().optional().describe("Run direction in degrees, clockwise, y down \u2014 present only when rotated (90/270 = a quarter-turn, e.g. rotated schedule headers)") })).describe("Text with bboxes, image px, same frame as the vectors"),
4678
6123
  count: z.number().int()
4679
6124
  }),
4680
6125
  hatch: z.object({ families: z.array(hatchFamilyRow), count: z.number().int() })
@@ -4704,10 +6149,37 @@ var annotateOutput = {
4704
6149
  length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
4705
6150
  note: z.string()
4706
6151
  };
6152
+ var verdictRow = z.object({
6153
+ id: z.string(),
6154
+ actor: z.enum(["estimator", "agent"]).describe('"estimator" = the human APPROVED ring (ink \u2014 import-borne here, never minted over MCP), "agent" = the AGENT diamond'),
6155
+ sheet: z.string(),
6156
+ at: z.tuple([z.number(), z.number()]).optional().describe("Render anchor (image px) \u2014 absent only when the record rides a sheet from a file this session hasn't loaded (#152)"),
6157
+ ts: z.string().optional().describe("ISO-8601 mint time"),
6158
+ shape_id: z.string().optional().describe("Present when the verdict targets a committed shape \u2014 WHAT was marked, not where it draws"),
6159
+ condition: z.string().describe("The targeted shape's finish tag, resolved \u2014 '' for sheet-point marks"),
6160
+ text: z.string().optional().describe("The optional short note riding the record")
6161
+ });
6162
+ var markVerdictOutput = {
6163
+ id: z.string().describe('The minted record id ("apr-\u2026")'),
6164
+ actor: z.literal("agent").describe("Always agent \u2014 this tool is structurally incapable of minting the estimator's seal"),
6165
+ sheet: z.string(),
6166
+ at: z.tuple([z.number(), z.number()]).optional().describe("Where the AGENT diamond renders (image px) \u2014 absent only when the marked shape rides a sheet from a file this session hasn't loaded (#152)"),
6167
+ ts: z.string().describe("ISO-8601 mint time"),
6168
+ shape_id: z.string().optional().describe("Shape mode: the committed shape this verdict is about"),
6169
+ condition: z.string().optional().describe("Shape mode: the marked shape's finish tag, resolved"),
6170
+ text: z.string().optional(),
6171
+ note: z.string()
6172
+ };
6173
+ var deleteVerdictOutput = {
6174
+ deleted: z.string().describe("The lifted record's id"),
6175
+ verdicts_remaining: z.number().int().describe("Approval-family records still on the takeoff (both actors)")
6176
+ };
4707
6177
  var listAnnotationsOutput = {
4708
6178
  annotations: z.array(annotationRow),
4709
6179
  count: z.number().int(),
4710
- unattached: z.number().int().describe("How many carry no condition \u2014 candidates for link_annotation")
6180
+ unattached: z.number().int().describe("How many carry no condition \u2014 candidates for link_annotation"),
6181
+ verdicts: z.array(verdictRow).describe("Approval-family records (#176) under the same filters: sheet applies directly; a condition filter reaches a verdict THROUGH its target shape (a sheet-point mark carries no scope and drops out)"),
6182
+ verdict_count: z.number().int()
4711
6183
  };
4712
6184
  var linkAnnotationOutput = {
4713
6185
  id: z.string(),
@@ -4720,24 +6192,6 @@ var linkAnnotationOutput = {
4720
6192
  import path3 from "node:path";
4721
6193
  import { readFile as readFile2, writeFile } from "node:fs/promises";
4722
6194
 
4723
- // ../web/src/lib/approvals.js
4724
- var APPROVAL_R = 0.022;
4725
- var APPROVAL_INK = {
4726
- estimator: { light: "#1f6b4a", dark: "#55b083" },
4727
- agent: { light: "#6c6a5e", dark: "#9d9a8c" }
4728
- };
4729
- function approvalInk(actor, dark = false) {
4730
- const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
4731
- return dark ? c.dark : c.light;
4732
- }
4733
- function approvalTally(approvals) {
4734
- const t = { estimator: 0, agent: 0 };
4735
- for (const a of Array.isArray(approvals) ? approvals : []) {
4736
- if (a && a.actor in t) t[a.actor] += 1;
4737
- }
4738
- return t;
4739
- }
4740
-
4741
6195
  // ../web/src/lib/svgpath.js
4742
6196
  function isSep(c) {
4743
6197
  return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
@@ -5866,7 +7320,7 @@ var buildMarkedSetPdf2 = buildMarkedSetPdf;
5866
7320
  async function exportMarkedPdf(session, opts) {
5867
7321
  const { file, filePath } = session;
5868
7322
  if (!file || !filePath) throw new UserError("No plan loaded \u2014 call load_plan first.");
5869
- if (!session.shapes.length && !session.markups.length) {
7323
+ if (!session.shapes.length && !session.markups.length && !session.approvals.length) {
5870
7324
  throw new UserError("Nothing to mark yet \u2014 commit shapes (one_click / detect_rooms / measure_polygon / measure_line with a condition) or annotate before exporting the marked set.");
5871
7325
  }
5872
7326
  const sheetStates = session.sheetList();
@@ -5910,6 +7364,10 @@ async function exportMarkedPdf(session, opts) {
5910
7364
  sheets,
5911
7365
  shapes: session.shapes,
5912
7366
  markups: session.markups,
7367
+ // approval marks (#176): the estimator's APPROVED rings and the agent's
7368
+ // AGENT diamonds burn in above the markups, and the cover tallies the
7369
+ // ink/pencil split — the same builder path the canvas's MARKED SET uses
7370
+ approvals: session.approvals,
5913
7371
  rfis: [],
5914
7372
  conditions: session.conditions,
5915
7373
  getPage,
@@ -5920,13 +7378,14 @@ async function exportMarkedPdf(session, opts) {
5920
7378
  });
5921
7379
  const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
5922
7380
  await writeFile(outPath, bytes);
5923
- const markedKeys = /* @__PURE__ */ new Set([...session.shapes.map((s) => s.sheet_id), ...session.markups.map((m) => m.sheet_id)]);
7381
+ const markedKeys = /* @__PURE__ */ new Set([...session.shapes.map((s) => s.sheet_id), ...session.markups.map((m) => m.sheet_id), ...session.approvals.map((a) => a.sheet_id)]);
5924
7382
  return {
5925
7383
  path: outPath,
5926
7384
  pages: 1 + markedKeys.size,
5927
7385
  sheets_marked: markedKeys.size,
5928
7386
  shapes_drawn: session.shapes.length,
5929
7387
  annotations_drawn: session.markups.length,
7388
+ approvals_drawn: session.approvals.length,
5930
7389
  note: "The takeoff burned into the plan sheets, with a legend cover \u2014 hand this to the user to review. To revise in the app, import the export_takeoff payload; agent shapes arrive as pencil proposals there until accepted."
5931
7390
  };
5932
7391
  }
@@ -5984,7 +7443,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
5984
7443
  return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
5985
7444
  };
5986
7445
  const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
5987
- if (!arr(cur.shapes).length && !arr(cur.markups).length) {
7446
+ if (!arr(cur.shapes).length && !arr(cur.markups).length && !arr(cur.approvals).length) {
5988
7447
  const payload2 = {
5989
7448
  ...imported,
5990
7449
  ...arr(imported.sheet_tabs).length ? {} : { sheet_tabs: arr(cur.sheet_tabs) },
@@ -6022,6 +7481,8 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
6022
7481
  const addedMarkups = arr(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
6023
7482
  const rfiIds = new Set(arr(cur.rfis).map((r) => r?.id).filter(Boolean));
6024
7483
  const addedRfis = arr(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
7484
+ const approvalIds = new Set(arr(cur.approvals).map((a) => a?.id).filter(Boolean));
7485
+ const addedApprovals = sanitizeApprovals(imported.approvals).filter((a) => !approvalIds.has(a.id));
6025
7486
  const sheets = [...arr(cur.sheets)];
6026
7487
  const scaled = new Set(sheets.map((s) => s?.sheet_id));
6027
7488
  let scalesAdopted = 0;
@@ -6038,6 +7499,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
6038
7499
  shapes: [...arr(cur.shapes), ...addedShapes],
6039
7500
  markups: [...arr(cur.markups), ...addedMarkups],
6040
7501
  ...addedRfis.length ? { rfis: [...arr(cur.rfis), ...addedRfis] } : {},
7502
+ ...addedApprovals.length ? { approvals: [...arr(cur.approvals), ...addedApprovals] } : {},
6041
7503
  sheets
6042
7504
  };
6043
7505
  return {
@@ -6068,6 +7530,7 @@ async function importTakeoff(session, filePath) {
6068
7530
  session.conditions = payload.conditions ?? [];
6069
7531
  session.shapes = payload.shapes ?? [];
6070
7532
  session.markups = payload.markups ?? [];
7533
+ session.approvals = sanitizeApprovals2(payload.approvals);
6071
7534
  for (const row of payload.sheets ?? []) {
6072
7535
  const s = session.sheetOrNull(row.sheet_id);
6073
7536
  if (s && s.upp == null && row.units_per_px > 0) {
@@ -6209,12 +7672,13 @@ function registerTools(server, session) {
6209
7672
  outputSchema: placeCountOutput
6210
7673
  }, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
6211
7674
  server.registerTool("symbol_sweep", {
6212
- description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the sheet's vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. Work is capped and the cap is disclosed: a reply with candidates.dropped > 0 says exactly that some placements were never scored \u2014 tighten the seed rect around more distinctive geometry rather than trusting a truncated count. Marquee discipline: the rect must hug ONE instance \u2014 only segments FULLY inside it define the symbol, so a loose rect that swallows wall linework fingerprints the wall, not the symbol. commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep is ONE undo step, each marker carries origin.method "symbol_sweep" with its score and transform, and withheld placements are NEVER committed. No scale needed: EA is scale-free. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
7675
+ description: `Find EVERY instance of a repeated plan symbol from ONE example \u2014 drains, thresholds, fixtures, transition markers: marquee a tight seed_rect around a single instance and the vector linework is searched for every other placement of that same segment cluster. Deterministic geometry, not vision: each placement scores as the length-weighted fraction of the seed's segments reproduced within tolerance_px, under translation plus 0/90/180/270 rotation and mirroring (symbols rotate on plans \u2014 both ON by default; turn them off to pin orientation). Score \u2265 0.92 is a match; the 0.75\u20130.92 band comes back in \`withheld\` with a reason \u2014 a near-match is a question you answer by LOOKING (view_sheet at its \`at\`), never a silent commit and never a silent drop. The seed's own location is reported in \`seed\` and never double-committed. 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. scope "set" sweeps the WHOLE working set, counting on PLAN-role sheets only (the sheet graph decides): a symbol drawn in a detail, legend, or schedule is a reference drawing and never counts itself \u2014 which is also how you seed from one: marquee the assembly on the detail sheet and its plan-sheet occurrences are counted while the detail stays excluded (the exclusion disclosed in \`skipped\`, per-sheet results with per-sheet caps and wall-clock in \`sheets\`; the fingerprint is size-true, so a detail drawn at an enlarged scale will not match plan-size instances). commit: true (requires condition) commits every match center as an EA count marker through the same path as place_count \u2014 the whole sweep (set-wide included) is ONE undo step, each marker carries origin.method "symbol_sweep" with its score, transform, and seed source, and withheld placements are NEVER committed. No scale needed: EA is scale-free. After any batch commit, LOOK at what landed \u2014 view_sheet {overlay: true} over the swept area \u2014 and audit the markers against the drawing before trusting the EA total. ${COORDS}`,
6213
7676
  inputSchema: {
6214
- sheet: z2.string(),
7677
+ sheet: z2.string().describe("The sheet the seed rect sits on \u2014 in scope 'set' it may be ANY sheet (a detail/legend seed sheet is fingerprint source only, never counted)"),
6215
7678
  seed_rect: z2.tuple([pointSchema, pointSchema]).describe("Marquee around ONE example instance, [[x0,y0],[x1,y1]] in image px \u2014 tight: segments fully inside define the symbol"),
6216
7679
  condition: z2.string().optional().describe("Finish tag to commit match markers under (minted on first use), e.g. 'FD-1'. Required when commit is true"),
6217
7680
  commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
7681
+ scope: z2.enum(["sheet", "set"]).default("sheet").describe('"sheet" = this sheet only; "set" = every PLAN-role sheet in the working set (needs a text layer for the sheet graph; non-plan sheets are excluded and disclosed)'),
6218
7682
  rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
6219
7683
  mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
6220
7684
  tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
@@ -6223,6 +7687,23 @@ function registerTools(server, session) {
6223
7687
  }, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
6224
7688
  seedRect: a.seed_rect,
6225
7689
  condition: a.condition,
7690
+ commit: a.commit,
7691
+ scope: a.scope,
7692
+ rotations: a.rotations,
7693
+ mirror: a.mirror,
7694
+ tolerancePx: a.tolerance_px
7695
+ })));
7696
+ server.registerTool("sweep_schedule_row", {
7697
+ description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. No scale needed: EA is scale-free. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}`,
7698
+ inputSchema: {
7699
+ tag: z2.string().min(1).describe("The schedule row's key exactly as drawn, e.g. 'T1', 'TR-2' \u2014 it becomes the condition tag on commit"),
7700
+ commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
7701
+ rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated markers"),
7702
+ mirror: z2.boolean().default(true).describe("Also match mirrored markers"),
7703
+ tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
7704
+ },
7705
+ outputSchema: sweepScheduleRowOutput
7706
+ }, run("sweep_schedule_row", (a) => session.sweepScheduleRow(a.tag, {
6226
7707
  commit: a.commit,
6227
7708
  rotations: a.rotations,
6228
7709
  mirror: a.mirror,
@@ -6273,14 +7754,14 @@ function registerTools(server, session) {
6273
7754
  return doc;
6274
7755
  }));
6275
7756
  server.registerTool("import_takeoff", {
6276
- description: `The way BACK IN (#151): load an "opentakeoff.takeoff_canvas.v1" file \u2014 a prior export_takeoff, or the app's own save \u2014 into this session, through the SAME tested merge rules as the app's Sheet-menu import: finish-tag identity joins imported conditions onto this session's own (their knobs win), new ids append, duplicate ids skip (re-import is idempotent), and THIS session's calibration wins per sheet. An empty session adopts the file wholesale. Resume yesterday's work, extend a takeoff a human already reviewed (their ink stays ink \u2014 reviewed shapes arrive untouchable by agent verbs), or audit someone else's export with list_shapes/takeoff_summary. Requires a loaded plan; shapes referencing OTHER files ride along and count in totals but can't be viewed against this document \u2014 the reply's unknown_files names them. undo_last removes the imported SHAPES as one step; adopted conditions, scales, and annotations stay.`,
7757
+ description: `The way BACK IN (#151): load an "opentakeoff.takeoff_canvas.v1" file \u2014 a prior export_takeoff, or the app's own save \u2014 into this session, through the SAME tested merge rules as the app's Sheet-menu import: finish-tag identity joins imported conditions onto this session's own (their knobs win), new ids append, duplicate ids skip (re-import is idempotent), and THIS session's calibration wins per sheet. An empty session adopts the file wholesale. Resume yesterday's work, extend a takeoff a human already reviewed (their ink stays ink \u2014 reviewed shapes arrive untouchable by agent verbs), or audit someone else's export with list_shapes/takeoff_summary. Requires a loaded plan; shapes referencing OTHER files ride along and count in totals but can't be viewed against this document \u2014 the reply's unknown_files names them. Approval marks ride the file too \u2014 transport, not minting: an estimator seal arriving by import stays estimator ink, listable but untouchable here. undo_last removes the imported SHAPES as one step; adopted conditions, scales, annotations, and approval marks stay.`,
6277
7758
  inputSchema: {
6278
7759
  path: z2.string().describe("Path to a takeoff_canvas.v1 JSON file on disk")
6279
7760
  },
6280
7761
  outputSchema: importTakeoffOutput
6281
7762
  }, run("import_takeoff", (a) => importTakeoff(session, a.path)));
6282
7763
  server.registerTool("export_marked_pdf", {
6283
- description: `The MARKED-UP PLANSET \u2014 the deliverable of every takeoff. Writes a distribution-ready PDF to disk: a legend cover (per-condition totals, swatches, a by-sheet breakdown) followed by every sheet that carries takeoff shapes or annotations, vector-copied from the source plan with the work burned in as drawn \u2014 condition colors and hatches, a quantity chip on every shape, annotation clouds/callouts/highlights. Built by the same module as the canvas's MARKED SET button, so agent output and app output are one implementation. A construction takeoff is no good without markup: finish EVERY takeoff by writing this file and giving the user its path (export_report carries the numbers for pricing; this carries the evidence). When the shapes were machine-traced and unreviewed, the document says so on its last page \u2014 the review path is importing the export_takeoff payload into the app, where agent shapes arrive as pencil proposals. Default path: next to the loaded plan as "<plan> - marked set.pdf". Needs no native canvas \u2014 pure vector copy, so it works even where view_sheet cannot render.`,
7764
+ description: `The MARKED-UP PLANSET \u2014 the deliverable of every takeoff. Writes a distribution-ready PDF to disk: a legend cover (per-condition totals, swatches, a by-sheet breakdown) followed by every sheet that carries takeoff shapes or annotations, vector-copied from the source plan with the work burned in as drawn \u2014 condition colors and hatches, a quantity chip on every shape, annotation clouds/callouts/highlights, and approval marks (the estimator's APPROVED rings, the agent's AGENT diamonds \u2014 the cover tallies the split). Built by the same module as the canvas's MARKED SET button, so agent output and app output are one implementation. A construction takeoff is no good without markup: finish EVERY takeoff by writing this file and giving the user its path (export_report carries the numbers for pricing; this carries the evidence). When the shapes were machine-traced and unreviewed, the document says so on its last page \u2014 the review path is importing the export_takeoff payload into the app, where agent shapes arrive as pencil proposals. Default path: next to the loaded plan as "<plan> - marked set.pdf". Needs no native canvas \u2014 pure vector copy, so it works even where view_sheet cannot render.`,
6284
7765
  inputSchema: {
6285
7766
  path: z2.string().optional().describe('Where to write the PDF (default: "<plan dir>/<plan> - marked set.pdf")'),
6286
7767
  project_name: z2.string().optional().describe("Cover-page project name (default: the plan file's name)")
@@ -6371,17 +7852,17 @@ function registerTools(server, session) {
6371
7852
  outputSchema: undoLastOutput
6372
7853
  }, run("undo_last", ({ n }) => session.undoLast(n)));
6373
7854
  server.registerTool("sheet_graph", {
6374
- description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region), every room tag on the plan sheets (with the stacked room NAME when one exists), and the detail callouts (3/A-601 \u2192 sheet edges). Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
7855
+ description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every room tag on the plan sheets (with the stacked room NAME when one exists, and the room's BUILDING on multi-building sets), the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, and named indexing gaps in "notes". Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
6375
7856
  inputSchema: {},
6376
7857
  outputSchema: sheetGraphOutput
6377
7858
  }, run("sheet_graph", () => session.sheetGraph()));
6378
7859
  server.registerTool("resolve_tag", {
6379
- description: `Resolve ONE room tag across the set (#87): the plan tag \u2192 its room-finish schedule row \u2192 each finish code's definition in the finish/material schedule, EVERY edge carrying an evidence pointer (sheet + literal text + bbox \u2014 pass a bbox to view_sheet to look at the source). The doctrine is refusal over guessing: a room that appears on the plan with no schedule row returns status "unresolved" with the reason (and still cites the plan tag); reused room numbers across the set return "ambiguous" rather than picking one. This is the answer to "what finish is specified in room 134, and how do you know". ${COORDS}`,
6380
- inputSchema: { tag: z2.string().describe('The room tag as drawn, e.g. "134" or "139A"') },
7860
+ description: `Resolve ONE room tag across the set (#87): the plan tag \u2192 its room-finish schedule row \u2192 each finish code's definition in the finish/material schedule, EVERY edge carrying an evidence pointer (sheet + literal text + bbox \u2014 pass a bbox to view_sheet to look at the source). Rows carried by a continuation sheet ("\u2026 SCHEDULE \u2014 CONT'D") resolve exactly like base-sheet rows, citing the sheet the ink is on. The doctrine is refusal over guessing: a room that appears on the plan with no schedule row returns status "unresolved" with the reason (and still cites the plan tag); reused room numbers return "ambiguous" rather than picking one \u2014 on a multi-building set the refusal LISTS the candidate rows per building, and a building-qualified tag ("A-134") picks the building the set names. ${COORDS}`,
7861
+ inputSchema: { tag: z2.string().describe('The room tag as drawn, e.g. "134" or "139A" \u2014 or building-qualified on a multi-building set, e.g. "A-134" (building A, room 134)') },
6381
7862
  outputSchema: resolveTagOutput
6382
7863
  }, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
6383
7864
  server.registerTool("find_schedule", {
6384
- description: `Locate a schedule table in the set (#87): pass a kind ("room finish", "material"/"finish") and get every matching table's sheet, title, headers, row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
7865
+ description: `Locate a schedule table in the set (#87): pass a kind ("room finish", "material"/"finish") and get every matching table's sheet, title, headers, TOTAL row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. A schedule continued across sheets is ONE match whose "parts" list every fragment (base first) with its own viewable region; tables read through rotated headers say so; a table answering for one building carries "building". Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
6385
7866
  inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes) or "finish"/"material" (codes \u2192 products)') },
6386
7867
  outputSchema: findScheduleOutput
6387
7868
  }, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
@@ -6445,7 +7926,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
6445
7926
  outputSchema: annotateOutput
6446
7927
  }, run("annotate", (a) => session.annotate(a)));
6447
7928
  server.registerTool("list_annotations", {
6448
- description: `Every annotation on the takeoff, with condition_id RESOLVED to its finish tag so you can act on the reply without joining against conditions[]. Filter by sheet, by condition, or both. Coordinates come back in image px (the same frame you passed in), not the normalized form they're stored as. \`unattached\` counts the notes carrying no condition \u2014 the candidates for link_annotation. ${COORDS}`,
7929
+ description: `Every annotation on the takeoff, with condition_id RESOLVED to its finish tag so you can act on the reply without joining against conditions[]. Filter by sheet, by condition, or both. Coordinates come back in image px (the same frame you passed in), not the normalized form they're stored as. \`unattached\` counts the notes carrying no condition \u2014 the candidates for link_annotation. \`verdicts\` is the approval family's inventory (mark_verdict/delete_verdict): every mark with its actor stated \u2014 the estimator's APPROVED ring or the agent's AGENT diamond \u2014 under the same filters, a condition filter reaching a verdict through its target shape. ${COORDS}`,
6449
7930
  inputSchema: {
6450
7931
  sheet: z2.string().optional().describe("Only annotations on this sheet"),
6451
7932
  condition: z2.string().optional().describe("Only annotations attached to this finish tag")
@@ -6460,6 +7941,29 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
6460
7941
  },
6461
7942
  outputSchema: linkAnnotationOutput
6462
7943
  }, run("link_annotation", (a) => session.linkAnnotation(a.annotation_id, a.condition)));
7944
+ server.registerTool("mark_verdict", {
7945
+ description: `Mark the agent's VERDICT on work \u2014 the pencil half of the approval family, and the only half an agent can mint. Two actors exist on the record: the estimator's APPROVED ring is ink, minted solely by a human's click at the canvas's Approve tool; this tool mints the AGENT diamond and structurally nothing else \u2014 it takes no actor input to misuse. Target the work either way: shape_id anchors the mark ON a committed shape (a room at its area centroid, a run at its on-path midpoint, a count marker at its point) and records WHAT was marked \u2014 the shape_id stays on the record as provenance, and the glyph keeps its own anchor even if the shape is later deleted; or sheet + at drops the mark at a sheet point (image px). Exactly one target. Optional text rides the record through every export; the glyph itself always reads AGENT. A verdict touches no quantity and gates nothing: it is the agent's signed claim that it checked this work \u2014 pencil beside the estimator's ink, never in its place. The mark renders as the graphite AGENT diamond on the canvas and in the marked set, the marked-set cover tallies the split ("Approval stamps: N estimator-approved \xB7 M agent-marked"), and the record rides the annotations payload through export_takeoff / import_takeoff and the app's own saves. One mark per shape (re-mark = delete_verdict, then mark again); list_annotations returns the inventory in verdicts[]; undo_last steps over a mark exactly like any other mutation. ${COORDS}`,
7946
+ inputSchema: {
7947
+ shape_id: z2.string().optional().describe("Mark a committed shape (list_shapes has the ids) \u2014 anchored on the shape, recorded as provenance. Exactly one target: this OR sheet + at"),
7948
+ sheet: z2.string().optional().describe("Sheet-point mode: the sheet, together with at"),
7949
+ at: pointSchema.optional().describe("Sheet-point mode: where the AGENT diamond renders (image px)"),
7950
+ text: z2.string().optional().describe("Optional short note riding the record and every export \u2014 the glyph always reads AGENT")
7951
+ },
7952
+ outputSchema: markVerdictOutput
7953
+ }, run("mark_verdict", (a) => {
7954
+ const byShape = a.shape_id !== void 0;
7955
+ const byPoint = a.sheet !== void 0 || a.at !== void 0;
7956
+ if (byShape === byPoint) throw new UserError("Provide exactly one target: shape_id (mark a committed shape), or sheet + at (mark a sheet point).");
7957
+ if (byPoint && (a.sheet === void 0 || a.at === void 0)) throw new UserError("A sheet-point verdict needs BOTH sheet and at: [x, y] (image px).");
7958
+ return session.markVerdict({ shape_id: a.shape_id, sheet: a.sheet, at: a.at, text: a.text });
7959
+ }));
7960
+ server.registerTool("delete_verdict", {
7961
+ description: `Lift an agent verdict mark by id (mark_verdict's reply, or list_annotations verdicts[]). Agent marks only: the estimator's APPROVED seal is human ink and is refused \u2014 the same line edit_shape holds on reviewed shapes. Journaled like every mutation, so undo_last re-seats a lifted mark exactly where it was.`,
7962
+ inputSchema: {
7963
+ verdict_id: z2.string().describe("Record id from mark_verdict or list_annotations verdicts[]")
7964
+ },
7965
+ outputSchema: deleteVerdictOutput
7966
+ }, run("delete_verdict", ({ verdict_id }) => session.deleteVerdict(verdict_id)));
6463
7967
  }
6464
7968
 
6465
7969
  // src/resources.ts
@@ -6541,7 +8045,7 @@ function registerResources(server, session) {
6541
8045
  // package.json
6542
8046
  var package_default = {
6543
8047
  name: "opentakeoff-mcp",
6544
- version: "0.9.21",
8048
+ version: "0.9.25",
6545
8049
  mcpName: "io.github.Kentucky-ai/opentakeoff",
6546
8050
  type: "module",
6547
8051
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",