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.
- package/README.md +9 -3
- package/dist/server-core.js +1944 -440
- package/package.json +1 -1
package/dist/server-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
|
442
|
-
// cross
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1387
|
+
if (bits(sy * mw + sx) & barrier) {
|
|
836
1388
|
let found = null;
|
|
837
|
-
for (let r = 1; 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 && !(
|
|
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 =
|
|
1402
|
+
const region = takeRegion(mw * mh);
|
|
851
1403
|
const cap = Math.floor(mw * mh * LEAK_FRACTION);
|
|
852
|
-
let count = 0,
|
|
1404
|
+
let count = 0, hardHits = 0, softHits = 0;
|
|
853
1405
|
let bx0 = sx, bx1 = sx, by0 = sy, by1 = sy;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1475
|
+
push(x, py - 1);
|
|
889
1476
|
upOpen = true;
|
|
890
1477
|
}
|
|
891
1478
|
} else {
|
|
892
|
-
if (
|
|
893
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
+
push(x, py + 1);
|
|
904
1492
|
downOpen = true;
|
|
905
1493
|
}
|
|
906
1494
|
} else {
|
|
907
|
-
if (
|
|
908
|
-
if (
|
|
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)
|
|
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
|
-
|
|
918
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
if (
|
|
1178
|
-
|
|
1179
|
-
const
|
|
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
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
|
1192
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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 =
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
if (
|
|
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
|
|
2616
|
+
return t;
|
|
1792
2617
|
}
|
|
1793
|
-
function
|
|
1794
|
-
|
|
1795
|
-
|
|
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
|
|
2597
|
-
var uid = (p) => `${p}-${
|
|
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),
|
|
3556
|
-
* telling the truth (`method: "symbol_sweep"`, per-match
|
|
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
|
|
4461
|
+
let fp;
|
|
3582
4462
|
try {
|
|
3583
|
-
|
|
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 &&
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
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
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
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
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
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:
|
|
4364
|
-
|
|
4365
|
-
|
|
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)
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.",
|