opentakeoff-mcp 0.9.21 → 0.9.26
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 +11 -5
- package/dist/server-core.js +2732 -480
- package/package.json +2 -2
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,194 @@ 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;
|
|
583
|
+
var MIN_PASS_FT = 0.5;
|
|
584
|
+
function minPassRadiusFor(maskPxPerFt) {
|
|
585
|
+
if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 0;
|
|
586
|
+
return Math.min(SEAL_R_MAX, Math.round(MIN_PASS_FT * maskPxPerFt / 2));
|
|
587
|
+
}
|
|
588
|
+
var DETERMINISM_MIN_MPPF = 8;
|
|
418
589
|
var SEG_CURVE = 1;
|
|
419
590
|
var SEG_CLIP = 2;
|
|
420
591
|
var SEG_FILLONLY = 4;
|
|
592
|
+
var SEG_POLYARC = 8;
|
|
593
|
+
var ARC_MIN_CHORDS = 4;
|
|
594
|
+
var ARC_MIN_TOTAL_TURN = 30;
|
|
595
|
+
var ARC_CHORD_TURN_MIN = 2;
|
|
596
|
+
var ARC_CHORD_TURN_MAX = 45;
|
|
597
|
+
var ARC_FIT_TOL_FRAC = 0.03;
|
|
598
|
+
var ARC_CLOSED_TURN = 300;
|
|
599
|
+
var ARC_CUSP_MIN = 3;
|
|
600
|
+
var ARC_CUSP_R_RATIO = 1.5;
|
|
601
|
+
var ARC_CUSP_SPAN_MULT = 8;
|
|
602
|
+
var MASK_NODOOR_BIT = 8;
|
|
603
|
+
var DOOR_R_MIN_FT = 1.5;
|
|
604
|
+
var DOOR_R_MAX_FT = 4.5;
|
|
605
|
+
var CLUSTER_FIT_TOL_FRAC = 0.05;
|
|
606
|
+
var CLUSTER_FIT_TOL_PX = 1.5;
|
|
421
607
|
var HATCH_ANGLE_TOL = 2;
|
|
422
|
-
var HATCH_MIN_RUN = 10;
|
|
423
608
|
var HATCH_MAX_PITCH = 24;
|
|
424
|
-
var
|
|
425
|
-
var
|
|
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;
|
|
609
|
+
var HATCH_MAX_PITCH_FT = HATCH_MAX_PITCH / 18;
|
|
610
|
+
var HATCH_TIER_RISK = { bounded: 0, trapped: 1, override: 2 };
|
|
430
611
|
var HATCH_BOUND_FRAC = 0.7;
|
|
431
|
-
var HATCH_ESCALATE_FRAC = 0.
|
|
612
|
+
var HATCH_ESCALATE_FRAC = 0.02;
|
|
432
613
|
var HATCH_GROWTH_MAX = 2.5;
|
|
433
614
|
var SENS_STRICT = 0;
|
|
434
615
|
var SENS_BALANCED = 0.5;
|
|
@@ -438,8 +619,8 @@ var SENS_ANCHORS = [
|
|
|
438
619
|
// moderate band empties (escalateFrac == HATCH_BOUND_FRAC) ⇒ pre-#32
|
|
439
620
|
[SENS_BALANCED, HATCH_ESCALATE_FRAC, HATCH_GROWTH_MAX],
|
|
440
621
|
// calibrated on the sample plan (issue #32)
|
|
441
|
-
[SENS_AGGRESSIVE, 0
|
|
442
|
-
// cross
|
|
622
|
+
[SENS_AGGRESSIVE, 0, 4]
|
|
623
|
+
// cross any hatch, tolerate more growth
|
|
443
624
|
];
|
|
444
625
|
function escalationParams(sensitivity) {
|
|
445
626
|
const s = Math.max(0, Math.min(1, Number.isFinite(sensitivity) ? sensitivity : SENS_BALANCED));
|
|
@@ -636,8 +817,349 @@ function extractVectorGeometry(opList, transform, OPS3) {
|
|
|
636
817
|
}
|
|
637
818
|
}
|
|
638
819
|
}
|
|
639
|
-
|
|
820
|
+
const meta = Uint8Array.from(metaArr);
|
|
821
|
+
markPolylineArcs(segs, meta);
|
|
822
|
+
return { points, segs, meta, imageArea, layerOf: Int32Array.from(layerOfArr), layerIds };
|
|
823
|
+
}
|
|
824
|
+
function markPolylineArcs(segs, meta) {
|
|
825
|
+
const n = segs.length >> 2;
|
|
826
|
+
if (!meta || n < ARC_MIN_CHORDS) return 0;
|
|
827
|
+
let marked = 0;
|
|
828
|
+
const len = (i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
829
|
+
let chain = [];
|
|
830
|
+
const flush = () => {
|
|
831
|
+
if (chain.length >= ARC_MIN_CHORDS) marked += scanChainForArcs(segs, meta, chain);
|
|
832
|
+
chain = [];
|
|
833
|
+
};
|
|
834
|
+
for (let i = 0; i < n; i++) {
|
|
835
|
+
if (meta[i] & (SEG_CURVE | SEG_CLIP)) {
|
|
836
|
+
flush();
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (len(i) < 0.5) continue;
|
|
840
|
+
if (chain.length) {
|
|
841
|
+
const p = chain[chain.length - 1];
|
|
842
|
+
const gap = Math.hypot(segs[i * 4] - segs[p * 4 + 2], segs[i * 4 + 1] - segs[p * 4 + 3]);
|
|
843
|
+
if (meta[i] !== meta[p] || gap > Math.max(len(i), len(p))) flush();
|
|
844
|
+
}
|
|
845
|
+
chain.push(i);
|
|
846
|
+
}
|
|
847
|
+
flush();
|
|
848
|
+
return marked;
|
|
849
|
+
}
|
|
850
|
+
function scanChainForArcs(segs, meta, chain) {
|
|
851
|
+
let marked = 0;
|
|
852
|
+
for (const w of scanChainWindows(segs, chain)) {
|
|
853
|
+
const cm = meta[chain[w.c0]];
|
|
854
|
+
for (let j = chain[w.c0]; j <= chain[w.c1]; j++) if (meta[j] === cm) meta[j] |= SEG_CURVE | SEG_POLYARC;
|
|
855
|
+
marked += w.c1 - w.c0 + 1;
|
|
856
|
+
}
|
|
857
|
+
return marked;
|
|
858
|
+
}
|
|
859
|
+
function scanChainWindows(segs, chain) {
|
|
860
|
+
const m = chain.length;
|
|
861
|
+
const dirs = [], lens = [];
|
|
862
|
+
for (const i of chain) {
|
|
863
|
+
const dx = segs[i * 4 + 2] - segs[i * 4], dy = segs[i * 4 + 3] - segs[i * 4 + 1];
|
|
864
|
+
dirs.push(Math.atan2(dy, dx) * 180 / Math.PI);
|
|
865
|
+
lens.push(Math.hypot(dx, dy));
|
|
866
|
+
}
|
|
867
|
+
const turn = [0];
|
|
868
|
+
for (let k = 1; k < m; k++) {
|
|
869
|
+
let t = dirs[k] - dirs[k - 1];
|
|
870
|
+
if (t > 180) t -= 360;
|
|
871
|
+
if (t <= -180) t += 360;
|
|
872
|
+
turn.push(t);
|
|
873
|
+
}
|
|
874
|
+
const ratioOk = (k) => {
|
|
875
|
+
const r = Math.max(lens[k], lens[k - 1]) / Math.max(1e-9, Math.min(lens[k], lens[k - 1]));
|
|
876
|
+
return r <= 3;
|
|
877
|
+
};
|
|
878
|
+
const signedTurn = (k) => Math.abs(turn[k]) >= ARC_CHORD_TURN_MIN && Math.abs(turn[k]) <= ARC_CHORD_TURN_MAX && ratioOk(k);
|
|
879
|
+
const neutral = (k) => Math.abs(turn[k]) < ARC_CHORD_TURN_MIN && ratioOk(k);
|
|
880
|
+
const out = [];
|
|
881
|
+
let s = 1;
|
|
882
|
+
while (s < m) {
|
|
883
|
+
if (!signedTurn(s)) {
|
|
884
|
+
s++;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const sgn = Math.sign(turn[s]);
|
|
888
|
+
let e = s, bridged = false;
|
|
889
|
+
for (let k = s + 1; k < m; k++) {
|
|
890
|
+
if (signedTurn(k) && Math.sign(turn[k]) === sgn) {
|
|
891
|
+
e = k;
|
|
892
|
+
bridged = false;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (neutral(k) && !bridged) {
|
|
896
|
+
bridged = true;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
if (e - s + 2 >= ARC_MIN_CHORDS) {
|
|
902
|
+
let total = 0;
|
|
903
|
+
for (let j = s; j <= e; j++) total += Math.abs(turn[j]);
|
|
904
|
+
if (total >= ARC_MIN_TOTAL_TURN) {
|
|
905
|
+
for (const [c0, c1] of [[s - 1, e], [s, e], [s - 1, e - 1], [s, e - 1]]) {
|
|
906
|
+
if (c1 - c0 + 1 < ARC_MIN_CHORDS) continue;
|
|
907
|
+
let t = 0, signed = 0;
|
|
908
|
+
for (let j = c0 + 1; j <= c1; j++) {
|
|
909
|
+
t += Math.abs(turn[j]);
|
|
910
|
+
signed += turn[j];
|
|
911
|
+
}
|
|
912
|
+
const fit = t < ARC_MIN_TOTAL_TURN ? null : circleFitOk(segs, chain, c0, c1);
|
|
913
|
+
if (!fit) continue;
|
|
914
|
+
out.push({ c0, c1, turn: signed, r: fit.r });
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
s = e + 1;
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
640
922
|
}
|
|
923
|
+
function flagNonDoorArcs(segs, meta) {
|
|
924
|
+
const n = segs.length >> 2;
|
|
925
|
+
const veto = new Uint8Array(n);
|
|
926
|
+
if (!meta || n < ARC_MIN_CHORDS) return veto;
|
|
927
|
+
const len = (i) => Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
928
|
+
let chain = [];
|
|
929
|
+
const flush = () => {
|
|
930
|
+
if (chain.length >= ARC_MIN_CHORDS) judgeChain(segs, chain, veto);
|
|
931
|
+
chain = [];
|
|
932
|
+
};
|
|
933
|
+
for (let i = 0; i < n; i++) {
|
|
934
|
+
if (len(i) < 0.5) continue;
|
|
935
|
+
if (!(meta[i] & SEG_CURVE) || meta[i] & SEG_CLIP) {
|
|
936
|
+
flush();
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (chain.length) {
|
|
940
|
+
const p = chain[chain.length - 1];
|
|
941
|
+
const gap = Math.hypot(segs[i * 4] - segs[p * 4 + 2], segs[i * 4 + 1] - segs[p * 4 + 3]);
|
|
942
|
+
if (meta[i] !== meta[p] || gap > Math.max(len(i), len(p))) flush();
|
|
943
|
+
}
|
|
944
|
+
chain.push(i);
|
|
945
|
+
}
|
|
946
|
+
flush();
|
|
947
|
+
return veto;
|
|
948
|
+
}
|
|
949
|
+
function judgeChain(segs, chain, veto) {
|
|
950
|
+
const wins = scanChainWindows(segs, chain);
|
|
951
|
+
if (!wins.length) return;
|
|
952
|
+
const stamp = (w) => {
|
|
953
|
+
for (let j = chain[w.c0]; j <= chain[w.c1]; j++) veto[j] = 1;
|
|
954
|
+
};
|
|
955
|
+
let closed = false;
|
|
956
|
+
for (const w of wins) if (Math.abs(w.turn) >= ARC_CLOSED_TURN) {
|
|
957
|
+
stamp(w);
|
|
958
|
+
closed = true;
|
|
959
|
+
}
|
|
960
|
+
if (closed) return;
|
|
961
|
+
if (wins.length <= ARC_CUSP_MIN) return;
|
|
962
|
+
let run2 = 1, best = 1;
|
|
963
|
+
for (let k = 1; k < wins.length; k++) {
|
|
964
|
+
if (wins[k].c0 - wins[k - 1].c1 <= 2 && Math.sign(wins[k].turn) === Math.sign(wins[k - 1].turn)) run2++;
|
|
965
|
+
else run2 = 1;
|
|
966
|
+
if (run2 > best) best = run2;
|
|
967
|
+
}
|
|
968
|
+
if (best <= ARC_CUSP_MIN) return;
|
|
969
|
+
let rmin = Infinity, rmax = 0, span = 0;
|
|
970
|
+
for (const w of wins) {
|
|
971
|
+
if (w.r < rmin) rmin = w.r;
|
|
972
|
+
if (w.r > rmax) rmax = w.r;
|
|
973
|
+
}
|
|
974
|
+
for (const i of chain) span += Math.hypot(segs[i * 4 + 2] - segs[i * 4], segs[i * 4 + 3] - segs[i * 4 + 1]);
|
|
975
|
+
if (!(rmin > 0) || rmax > rmin * ARC_CUSP_R_RATIO) return;
|
|
976
|
+
if (rmax * ARC_CUSP_SPAN_MULT > span) return;
|
|
977
|
+
for (const w of wins) stamp(w);
|
|
978
|
+
}
|
|
979
|
+
function circleFitOk(segs, chain, c0, c1) {
|
|
980
|
+
const xs = [], ys = [];
|
|
981
|
+
xs.push(segs[chain[c0] * 4]);
|
|
982
|
+
ys.push(segs[chain[c0] * 4 + 1]);
|
|
983
|
+
for (let k = c0; k <= c1; k++) {
|
|
984
|
+
const i = chain[k];
|
|
985
|
+
xs.push(segs[i * 4 + 2]);
|
|
986
|
+
ys.push(segs[i * 4 + 3]);
|
|
987
|
+
}
|
|
988
|
+
const m = xs.length;
|
|
989
|
+
let mx = 0, my = 0;
|
|
990
|
+
for (let i = 0; i < m; i++) {
|
|
991
|
+
mx += xs[i];
|
|
992
|
+
my += ys[i];
|
|
993
|
+
}
|
|
994
|
+
mx /= m;
|
|
995
|
+
my /= m;
|
|
996
|
+
let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
|
|
997
|
+
for (let i = 0; i < m; i++) {
|
|
998
|
+
const x = xs[i] - mx, y = ys[i] - my, z3 = x * x + y * y;
|
|
999
|
+
sxx += x * x;
|
|
1000
|
+
sxy += x * y;
|
|
1001
|
+
syy += y * y;
|
|
1002
|
+
sxz += x * z3;
|
|
1003
|
+
syz += y * z3;
|
|
1004
|
+
}
|
|
1005
|
+
const det = sxx * syy - sxy * sxy;
|
|
1006
|
+
if (Math.abs(det) < 1e-9) return null;
|
|
1007
|
+
const cx = (sxz * syy - syz * sxy) / (2 * det);
|
|
1008
|
+
const cy = (syz * sxx - sxz * sxy) / (2 * det);
|
|
1009
|
+
let r = 0;
|
|
1010
|
+
for (let i = 0; i < m; i++) r += Math.hypot(xs[i] - mx - cx, ys[i] - my - cy);
|
|
1011
|
+
r /= m;
|
|
1012
|
+
if (!(r > 0)) return null;
|
|
1013
|
+
const tol = Math.max(0.75, r * ARC_FIT_TOL_FRAC);
|
|
1014
|
+
for (let i = 0; i < m; i++) {
|
|
1015
|
+
if (Math.abs(Math.hypot(xs[i] - mx - cx, ys[i] - my - cy) - r) > tol) return null;
|
|
1016
|
+
}
|
|
1017
|
+
return { cx: cx + mx, cy: cy + my, r };
|
|
1018
|
+
}
|
|
1019
|
+
function classifyHatchSegs(segs, meta, ws, pitchCapPx = HATCH_MAX_PITCH) {
|
|
1020
|
+
const n = segs.length >> 2;
|
|
1021
|
+
const soft = new Uint8Array(n);
|
|
1022
|
+
if (!meta || !n) return soft;
|
|
1023
|
+
const HALF_CELL = 0.5;
|
|
1024
|
+
const cand = [];
|
|
1025
|
+
for (let i = 0; i < n; i++) {
|
|
1026
|
+
const mt = meta[i];
|
|
1027
|
+
if (mt & SEG_CURVE) continue;
|
|
1028
|
+
if (mt & SEG_CLIP) {
|
|
1029
|
+
soft[i] = 1;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (mt & SEG_FILLONLY) continue;
|
|
1033
|
+
const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
|
|
1034
|
+
const dx = x2 - x1, dy = y2 - y1;
|
|
1035
|
+
const len = Math.hypot(dx, dy);
|
|
1036
|
+
if (len < 0.75) continue;
|
|
1037
|
+
let ang = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
1038
|
+
if (ang < 0) ang += 180;
|
|
1039
|
+
if (ang >= 180) ang -= 180;
|
|
1040
|
+
cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
|
|
1041
|
+
}
|
|
1042
|
+
if (cand.length < 5) return soft;
|
|
1043
|
+
cand.sort((a, b) => a.ang - b.ang);
|
|
1044
|
+
const clusters = [];
|
|
1045
|
+
let cl = [cand[0]];
|
|
1046
|
+
for (let k = 1; k < cand.length; k++) {
|
|
1047
|
+
if (cand[k].ang - cand[k - 1].ang <= HATCH_ANGLE_TOL) cl.push(cand[k]);
|
|
1048
|
+
else {
|
|
1049
|
+
clusters.push(cl);
|
|
1050
|
+
cl = [cand[k]];
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
clusters.push(cl);
|
|
1054
|
+
if (clusters.length > 1) {
|
|
1055
|
+
const first = clusters[0], last = clusters[clusters.length - 1];
|
|
1056
|
+
if (first[0].ang < HATCH_ANGLE_TOL && last[last.length - 1].ang > 180 - HATCH_ANGLE_TOL) {
|
|
1057
|
+
for (const s of last) s.ang -= 180;
|
|
1058
|
+
clusters[0] = last.concat(first);
|
|
1059
|
+
clusters.pop();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
for (const members of clusters) {
|
|
1063
|
+
if (members.length < 5) continue;
|
|
1064
|
+
let sum = 0;
|
|
1065
|
+
for (const s of members) sum += s.ang;
|
|
1066
|
+
const th = sum / members.length * Math.PI / 180;
|
|
1067
|
+
const dxu = Math.cos(th), dyu = Math.sin(th);
|
|
1068
|
+
const nxu = -dyu, nyu = dxu;
|
|
1069
|
+
const pieces = members.map((s) => ({
|
|
1070
|
+
i: s.i,
|
|
1071
|
+
d: (s.x1 + s.x2) / 2 * nxu + (s.y1 + s.y2) / 2 * nyu,
|
|
1072
|
+
t0: Math.min(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu),
|
|
1073
|
+
t1: Math.max(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu),
|
|
1074
|
+
w: s.w
|
|
1075
|
+
})).sort((a, b) => a.d - b.d);
|
|
1076
|
+
const rowOf = new Int32Array(pieces.length);
|
|
1077
|
+
const rowD = [];
|
|
1078
|
+
const rowPieces = [];
|
|
1079
|
+
for (let k = 0; k < pieces.length; k++) {
|
|
1080
|
+
if (rowD.length && pieces[k].d - rowD[rowD.length - 1] <= HALF_CELL) {
|
|
1081
|
+
rowOf[k] = rowD.length - 1;
|
|
1082
|
+
rowPieces[rowPieces.length - 1].push(pieces[k]);
|
|
1083
|
+
} else {
|
|
1084
|
+
rowOf[k] = rowD.length;
|
|
1085
|
+
rowD.push(pieces[k].d);
|
|
1086
|
+
rowPieces.push([pieces[k]]);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
for (const rp of rowPieces) rp.sort((a, b) => a.t0 - b.t0);
|
|
1090
|
+
const rowMaxT1 = rowPieces.map((rp) => {
|
|
1091
|
+
const m = new Array(rp.length);
|
|
1092
|
+
let mx = -Infinity;
|
|
1093
|
+
for (let i = 0; i < rp.length; i++) {
|
|
1094
|
+
mx = Math.max(mx, rp[i].t1);
|
|
1095
|
+
m[i] = mx;
|
|
1096
|
+
}
|
|
1097
|
+
return m;
|
|
1098
|
+
});
|
|
1099
|
+
const rowHas = (j, w, t0, t1) => {
|
|
1100
|
+
const P = rowPieces[j], M = rowMaxT1[j];
|
|
1101
|
+
let lo = 0, hi = P.length - 1, last = -1;
|
|
1102
|
+
while (lo <= hi) {
|
|
1103
|
+
const mid = lo + hi >> 1;
|
|
1104
|
+
if (P[mid].t0 <= t1 - HALF_CELL) {
|
|
1105
|
+
last = mid;
|
|
1106
|
+
lo = mid + 1;
|
|
1107
|
+
} else hi = mid - 1;
|
|
1108
|
+
}
|
|
1109
|
+
for (let i = last; i >= 0; i--) {
|
|
1110
|
+
if (M[i] < t0 + HALF_CELL) break;
|
|
1111
|
+
const p = P[i];
|
|
1112
|
+
if (p.w === w && Math.min(p.t1, t1) - Math.max(p.t0, t0) >= HALF_CELL) return true;
|
|
1113
|
+
}
|
|
1114
|
+
return false;
|
|
1115
|
+
};
|
|
1116
|
+
const nearestRow = (j, dir, w, t0, t1) => {
|
|
1117
|
+
for (let r = j + dir; r >= 0 && r < rowD.length; r += dir) {
|
|
1118
|
+
if (Math.abs(rowD[r] - rowD[j]) > pitchCapPx + HALF_CELL) break;
|
|
1119
|
+
if (rowHas(r, w, t0, t1)) return r;
|
|
1120
|
+
}
|
|
1121
|
+
return -1;
|
|
1122
|
+
};
|
|
1123
|
+
const rowAt = (target, w, t0, t1) => {
|
|
1124
|
+
let lo = 0, hi = rowD.length - 1;
|
|
1125
|
+
while (lo < hi) {
|
|
1126
|
+
const mid = lo + hi >> 1;
|
|
1127
|
+
if (rowD[mid] < target - HALF_CELL) lo = mid + 1;
|
|
1128
|
+
else hi = mid;
|
|
1129
|
+
}
|
|
1130
|
+
for (let r = lo; r < rowD.length && rowD[r] <= target + HALF_CELL; r++) {
|
|
1131
|
+
if (rowD[r] >= target - HALF_CELL && rowHas(r, w, t0, t1)) return true;
|
|
1132
|
+
}
|
|
1133
|
+
return false;
|
|
1134
|
+
};
|
|
1135
|
+
for (let k = 0; k < pieces.length; k++) {
|
|
1136
|
+
const c = pieces[k];
|
|
1137
|
+
const j = rowOf[k];
|
|
1138
|
+
const up = nearestRow(j, 1, c.w, c.t0, c.t1);
|
|
1139
|
+
const dn = nearestRow(j, -1, c.w, c.t0, c.t1);
|
|
1140
|
+
if (up < 0 || dn < 0) continue;
|
|
1141
|
+
const pUp = rowD[up] - rowD[j], pDn = rowD[j] - rowD[dn];
|
|
1142
|
+
const at = (mult, p) => rowAt(rowD[j] + mult * p, c.w, c.t0, c.t1);
|
|
1143
|
+
for (const p of pUp === pDn ? [pUp] : [pUp, pDn]) {
|
|
1144
|
+
if (p < HALF_CELL || p > pitchCapPx + HALF_CELL) continue;
|
|
1145
|
+
const interior = at(1, p) && at(-1, p) && (at(2, p) || at(-2, p));
|
|
1146
|
+
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;
|
|
1147
|
+
if (interior || clipped) {
|
|
1148
|
+
soft[c.i] = 1;
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return soft;
|
|
1155
|
+
}
|
|
1156
|
+
var HATCH_MIN_RUN = 10;
|
|
1157
|
+
var HATCH_PITCH_TOL = 0.35;
|
|
1158
|
+
var HATCH_MIN_REGULAR = 0.7;
|
|
1159
|
+
var HATCH_OVERLAP_FRAC = 0.5;
|
|
1160
|
+
var ROW_EPS = 1.5;
|
|
1161
|
+
var WIDE_PROTECT_RATIO = 2;
|
|
1162
|
+
var SPAN_PROTECT_RATIO = 3;
|
|
641
1163
|
function sweepHatchRuns(segs, meta, ws) {
|
|
642
1164
|
const n = segs.length >> 2;
|
|
643
1165
|
const clipSoft = [];
|
|
@@ -770,13 +1292,6 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
770
1292
|
}
|
|
771
1293
|
return { clipSoft, runs };
|
|
772
1294
|
}
|
|
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
1295
|
var HATCH_ID_ANGLE_Q = 0.5;
|
|
781
1296
|
var HATCH_ID_PITCH_Q = 0.1;
|
|
782
1297
|
function hatchFamilies(segs, meta) {
|
|
@@ -796,16 +1311,38 @@ function hatchFamilies(segs, meta) {
|
|
|
796
1311
|
};
|
|
797
1312
|
});
|
|
798
1313
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1314
|
+
var MASK_CURVE_BIT = 4;
|
|
1315
|
+
function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt = 0, basePxPerFt = 0, page = null, roles = null) {
|
|
1316
|
+
if (pxPerFt instanceof Uint8Array || pxPerFt === null) {
|
|
1317
|
+
if (!roles) roles = pxPerFt;
|
|
1318
|
+
pxPerFt = 0;
|
|
1319
|
+
}
|
|
1320
|
+
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;
|
|
1321
|
+
let k, bW, bH;
|
|
1322
|
+
if (pg) {
|
|
1323
|
+
k = pg.baseScale / pg.renderScale;
|
|
1324
|
+
const bd = baselineImgDims(pg.pageW, pg.pageH, pg.baseScale);
|
|
1325
|
+
bW = bd.w;
|
|
1326
|
+
bH = bd.h;
|
|
1327
|
+
} else {
|
|
1328
|
+
k = Number.isFinite(basePxPerFt) && basePxPerFt > 0 && Number.isFinite(pxPerFt) && pxPerFt > 0 ? basePxPerFt / pxPerFt : 1;
|
|
1329
|
+
bW = imgW * k;
|
|
1330
|
+
bH = imgH * k;
|
|
1331
|
+
}
|
|
1332
|
+
const wsB = Math.min(1, maxDim / Math.max(bW, bH, 1));
|
|
1333
|
+
const mw = Math.max(2, Math.ceil(bW * wsB)), mh = Math.max(2, Math.ceil(bH * wsB));
|
|
1334
|
+
const ws = k * wsB;
|
|
802
1335
|
const mask = new Uint8Array(mw * mh);
|
|
803
|
-
const
|
|
1336
|
+
const mppf = Number.isFinite(pxPerFt) && pxPerFt > 0 ? pxPerFt * ws : 0;
|
|
1337
|
+
const soft = meta ? classifyHatchSegs(segs, meta, ws, mppf > 0 ? HATCH_MAX_PITCH_FT * mppf : HATCH_MAX_PITCH) : null;
|
|
1338
|
+
const noDoor = meta ? flagNonDoorArcs(segs, meta) : null;
|
|
804
1339
|
let softCount = 0;
|
|
805
1340
|
for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
|
|
806
1341
|
const role = roles ? roles[si] : 0;
|
|
807
1342
|
if (role === 2 || role === 3 || role === 5 || role === 6) continue;
|
|
808
|
-
|
|
1343
|
+
let v = role === 1 || role === 4 ? 1 : soft && soft[si] ? 2 : 1;
|
|
1344
|
+
if (v === 1 && meta && meta[si] & SEG_CURVE) v = 1 | MASK_CURVE_BIT;
|
|
1345
|
+
if (v & MASK_CURVE_BIT && noDoor && noDoor[si]) v |= MASK_NODOOR_BIT;
|
|
809
1346
|
if (v === 2) softCount++;
|
|
810
1347
|
let x0 = Math.round(segs[i] * ws), y0 = Math.round(segs[i + 1] * ws);
|
|
811
1348
|
const x1 = Math.round(segs[i + 2] * ws), y1 = Math.round(segs[i + 3] * ws);
|
|
@@ -826,18 +1363,44 @@ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, roles =
|
|
|
826
1363
|
}
|
|
827
1364
|
}
|
|
828
1365
|
}
|
|
829
|
-
return { mask, mw, mh, ws, softCount };
|
|
1366
|
+
return { mask, mw, mh, ws, softCount, mppf };
|
|
1367
|
+
}
|
|
1368
|
+
var regionBox = /* @__PURE__ */ new WeakMap();
|
|
1369
|
+
function boxOf(region, mw, mh) {
|
|
1370
|
+
return regionBox.get(region) || { x0: 0, y0: 0, x1: mw - 1, y1: mh - 1 };
|
|
1371
|
+
}
|
|
1372
|
+
var REGION_POOL_MAX = 2;
|
|
1373
|
+
var regionPool = [];
|
|
1374
|
+
function takeRegion(n) {
|
|
1375
|
+
for (let k = regionPool.length - 1; k >= 0; k--) {
|
|
1376
|
+
if (regionPool[k].length === n) {
|
|
1377
|
+
const b = regionPool.splice(k, 1)[0];
|
|
1378
|
+
b.fill(0);
|
|
1379
|
+
return b;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return new Uint8Array(n);
|
|
1383
|
+
}
|
|
1384
|
+
function dropRegion(b) {
|
|
1385
|
+
if (regionPool.length < REGION_POOL_MAX) regionPool.push(b);
|
|
830
1386
|
}
|
|
831
1387
|
function floodPass(maskObj, ix, iy, barrier) {
|
|
832
1388
|
const { mask, mw, mh, ws } = maskObj;
|
|
1389
|
+
const dilDT = maskObj.dilDT;
|
|
1390
|
+
const dilR = dilDT ? maskObj.dilR : -1;
|
|
1391
|
+
const bits = (i) => dilDT === void 0 ? mask[i] : (dilDT[i] <= dilR ? 1 : 0) | mask[i] & 2;
|
|
1392
|
+
const mppf = maskObj.mppf || 0;
|
|
1393
|
+
const tinyPx = mppf > 0 ? Math.max(TINY_PX_FLOOR, Math.round(TINY_SF * mppf * mppf)) : TINY_PX;
|
|
1394
|
+
const minThick = mppf > 0 ? Math.max(MIN_THICK_FLOOR, Math.round(MIN_THICK_FT * mppf)) : MIN_THICK;
|
|
1395
|
+
const nudge = mppf > 0 ? Math.max(NUDGE_PX, Math.round(NUDGE_FT * mppf)) : NUDGE_PX;
|
|
833
1396
|
let sx = Math.round(ix * ws), sy = Math.round(iy * ws);
|
|
834
1397
|
if (sx < 0 || sy < 0 || sx >= mw || sy >= mh) return { status: "boundary" };
|
|
835
|
-
if (
|
|
1398
|
+
if (bits(sy * mw + sx) & barrier) {
|
|
836
1399
|
let found = null;
|
|
837
|
-
for (let r = 1; r <=
|
|
1400
|
+
for (let r = 1; r <= nudge && !found; r++) {
|
|
838
1401
|
for (let dy = -r; dy <= r && !found; dy++) for (let dx = -r; dx <= r; dx++) {
|
|
839
1402
|
const nx = sx + dx, ny = sy + dy;
|
|
840
|
-
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && !(
|
|
1403
|
+
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && !(bits(ny * mw + nx) & barrier)) {
|
|
841
1404
|
found = [nx, ny];
|
|
842
1405
|
break;
|
|
843
1406
|
}
|
|
@@ -847,34 +1410,68 @@ function floodPass(maskObj, ix, iy, barrier) {
|
|
|
847
1410
|
sx = found[0];
|
|
848
1411
|
sy = found[1];
|
|
849
1412
|
}
|
|
850
|
-
const region =
|
|
1413
|
+
const region = takeRegion(mw * mh);
|
|
851
1414
|
const cap = Math.floor(mw * mh * LEAK_FRACTION);
|
|
852
|
-
let count = 0,
|
|
1415
|
+
let count = 0, hardHits = 0, softHits = 0;
|
|
853
1416
|
let bx0 = sx, bx1 = sx, by0 = sy, by1 = sy;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1417
|
+
let stack = new Int32Array(1024);
|
|
1418
|
+
let sp = 0;
|
|
1419
|
+
const push = (x, y) => {
|
|
1420
|
+
if (sp === stack.length) {
|
|
1421
|
+
const g = new Int32Array(sp * 2);
|
|
1422
|
+
g.set(stack);
|
|
1423
|
+
stack = g;
|
|
1424
|
+
}
|
|
1425
|
+
stack[sp++] = y * mw + x;
|
|
1426
|
+
};
|
|
1427
|
+
const dil = dilDT !== void 0;
|
|
1428
|
+
const dtA = dilDT;
|
|
1429
|
+
push(sx, sy);
|
|
1430
|
+
while (sp > 0) {
|
|
1431
|
+
const cell = stack[--sp];
|
|
1432
|
+
const py = cell / mw | 0, px = cell - py * mw;
|
|
1433
|
+
const row = py * mw;
|
|
858
1434
|
let x0 = px;
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1435
|
+
for (; ; ) {
|
|
1436
|
+
if (x0 === 0) break;
|
|
1437
|
+
const j = row + x0 - 1;
|
|
1438
|
+
if ((dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j]) & barrier) break;
|
|
1439
|
+
if (region[j]) break;
|
|
1440
|
+
x0--;
|
|
1441
|
+
}
|
|
1442
|
+
if (x0 > 0) {
|
|
1443
|
+
const j = row + x0 - 1, b = dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j];
|
|
1444
|
+
if (b & barrier) {
|
|
1445
|
+
if (b & 1) hardHits++;
|
|
1446
|
+
else softHits++;
|
|
1447
|
+
}
|
|
863
1448
|
}
|
|
864
1449
|
let x1 = px;
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1450
|
+
for (; ; ) {
|
|
1451
|
+
if (x1 >= mw - 1) break;
|
|
1452
|
+
const j = row + x1 + 1;
|
|
1453
|
+
if ((dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j]) & barrier) break;
|
|
1454
|
+
if (region[j]) break;
|
|
1455
|
+
x1++;
|
|
1456
|
+
}
|
|
1457
|
+
if (x1 < mw - 1) {
|
|
1458
|
+
const j = row + x1 + 1, b = dil ? (dtA[j] <= dilR ? 1 : 0) | mask[j] & 2 : mask[j];
|
|
1459
|
+
if (b & barrier) {
|
|
1460
|
+
if (b & 1) hardHits++;
|
|
1461
|
+
else softHits++;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (x0 === 0 || x1 === mw - 1 || py === 0 || py === mh - 1) {
|
|
1465
|
+
dropRegion(region);
|
|
1466
|
+
return { status: "leak" };
|
|
869
1467
|
}
|
|
870
|
-
if (x0 === 0 || x1 === mw - 1 || py === 0 || py === mh - 1) leaked = true;
|
|
871
1468
|
if (x0 < bx0) bx0 = x0;
|
|
872
1469
|
if (x1 > bx1) bx1 = x1;
|
|
873
1470
|
if (py < by0) by0 = py;
|
|
874
1471
|
if (py > by1) by1 = py;
|
|
875
1472
|
let upOpen = false, downOpen = false;
|
|
876
1473
|
for (let x = x0; x <= x1; x++) {
|
|
877
|
-
const idx =
|
|
1474
|
+
const idx = row + x;
|
|
878
1475
|
if (region[idx]) {
|
|
879
1476
|
upOpen = downOpen = false;
|
|
880
1477
|
continue;
|
|
@@ -883,14 +1480,15 @@ function floodPass(maskObj, ix, iy, barrier) {
|
|
|
883
1480
|
count++;
|
|
884
1481
|
if (py > 0) {
|
|
885
1482
|
const u = idx - mw;
|
|
886
|
-
|
|
1483
|
+
const ub = dil ? (dtA[u] <= dilR ? 1 : 0) | mask[u] & 2 : mask[u];
|
|
1484
|
+
if (!(ub & barrier) && !region[u]) {
|
|
887
1485
|
if (!upOpen) {
|
|
888
|
-
|
|
1486
|
+
push(x, py - 1);
|
|
889
1487
|
upOpen = true;
|
|
890
1488
|
}
|
|
891
1489
|
} else {
|
|
892
|
-
if (
|
|
893
|
-
if (
|
|
1490
|
+
if (ub & barrier) {
|
|
1491
|
+
if (ub & 1) hardHits++;
|
|
894
1492
|
else softHits++;
|
|
895
1493
|
}
|
|
896
1494
|
upOpen = false;
|
|
@@ -898,87 +1496,686 @@ function floodPass(maskObj, ix, iy, barrier) {
|
|
|
898
1496
|
}
|
|
899
1497
|
if (py < mh - 1) {
|
|
900
1498
|
const d = idx + mw;
|
|
901
|
-
|
|
1499
|
+
const db = dil ? (dtA[d] <= dilR ? 1 : 0) | mask[d] & 2 : mask[d];
|
|
1500
|
+
if (!(db & barrier) && !region[d]) {
|
|
902
1501
|
if (!downOpen) {
|
|
903
|
-
|
|
1502
|
+
push(x, py + 1);
|
|
904
1503
|
downOpen = true;
|
|
905
1504
|
}
|
|
906
1505
|
} else {
|
|
907
|
-
if (
|
|
908
|
-
if (
|
|
1506
|
+
if (db & barrier) {
|
|
1507
|
+
if (db & 1) hardHits++;
|
|
909
1508
|
else softHits++;
|
|
910
1509
|
}
|
|
911
1510
|
downOpen = false;
|
|
912
1511
|
}
|
|
913
1512
|
}
|
|
914
1513
|
}
|
|
915
|
-
if (count > cap)
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1514
|
+
if (count > cap) {
|
|
1515
|
+
dropRegion(region);
|
|
1516
|
+
return { status: "leak" };
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (count < tinyPx || bx1 - bx0 + 1 < minThick || by1 - by0 + 1 < minThick) {
|
|
1520
|
+
dropRegion(region);
|
|
1521
|
+
return { status: "tiny", count };
|
|
1522
|
+
}
|
|
1523
|
+
regionBox.set(region, { x0: bx0, y0: by0, x1: bx1, y1: by1 });
|
|
1524
|
+
return { status: "ok", region, count, mw, mh, ws, mppf: mppf || void 0, hardHits, softHits };
|
|
1525
|
+
}
|
|
1526
|
+
function dilateHard(maskObj, r) {
|
|
1527
|
+
const { mask, mw, mh, ws, softCount, mppf } = maskObj;
|
|
1528
|
+
const horiz = new Uint8Array(mask);
|
|
1529
|
+
for (let y = 0; y < mh; y++) {
|
|
1530
|
+
const row = y * mw;
|
|
1531
|
+
for (let x = 0; x < mw; x++) {
|
|
1532
|
+
if (mask[row + x] & 1) {
|
|
1533
|
+
const x1 = Math.min(mw - 1, x + r);
|
|
1534
|
+
for (let i = Math.max(0, x - r); i <= x1; i++) horiz[row + i] |= 1;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const out = new Uint8Array(horiz);
|
|
1539
|
+
for (let y = 0; y < mh; y++) {
|
|
1540
|
+
const row = y * mw;
|
|
1541
|
+
for (let x = 0; x < mw; x++) {
|
|
1542
|
+
if (horiz[row + x] & 1) {
|
|
1543
|
+
const y1 = Math.min(mh - 1, y + r);
|
|
1544
|
+
for (let j = Math.max(0, y - r); j <= y1; j++) out[j * mw + x] |= 1;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return { mask: out, mw, mh, ws, softCount, mppf };
|
|
1549
|
+
}
|
|
1550
|
+
var bridgeCache = /* @__PURE__ */ new WeakMap();
|
|
1551
|
+
function bridgedMask(mo, r) {
|
|
1552
|
+
let per = bridgeCache.get(mo.mask);
|
|
1553
|
+
if (!per) {
|
|
1554
|
+
per = [];
|
|
1555
|
+
bridgeCache.set(mo.mask, per);
|
|
1556
|
+
}
|
|
1557
|
+
let m = per[r];
|
|
1558
|
+
if (!m) {
|
|
1559
|
+
m = dilateHard(mo, r).mask;
|
|
1560
|
+
per[r] = m;
|
|
1561
|
+
}
|
|
1562
|
+
return { mask: m, mw: mo.mw, mh: mo.mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf };
|
|
1563
|
+
}
|
|
1564
|
+
function floodRegionLadder(maskObj, ix, iy, sensitivity) {
|
|
1565
|
+
const r1 = floodPass(maskObj, ix, iy, 3);
|
|
1566
|
+
if (!maskObj.softCount) return r1;
|
|
1567
|
+
if (r1.status === "leak") return r1;
|
|
1568
|
+
const { escalateFrac, growthMax } = escalationParams(sensitivity);
|
|
1569
|
+
let growthCap = Infinity;
|
|
1570
|
+
if (r1.status === "ok") {
|
|
1571
|
+
const blocks = (r1.hardHits || 0) + (r1.softHits || 0);
|
|
1572
|
+
const softFrac = blocks ? (r1.softHits || 0) / blocks : 0;
|
|
1573
|
+
if (softFrac < escalateFrac) return r1;
|
|
1574
|
+
if (softFrac < HATCH_BOUND_FRAC) growthCap = growthMax;
|
|
1575
|
+
}
|
|
1576
|
+
const r2 = floodPass(maskObj, ix, iy, 1);
|
|
1577
|
+
if (r2.status === "ok" && (r1.status !== "ok" || r2.count > r1.count && r2.count <= r1.count * growthCap)) {
|
|
1578
|
+
r2.hatchFiltered = true;
|
|
1579
|
+
r2.hatchTier = growthCap !== Infinity ? "bounded" : r1.status === "ok" ? "override" : "trapped";
|
|
1580
|
+
return r2;
|
|
1581
|
+
}
|
|
1582
|
+
return r1;
|
|
1583
|
+
}
|
|
1584
|
+
var SEAL_RADII = [1, 2, 4];
|
|
1585
|
+
var DOOR_SEAL_MAX_FT = 5;
|
|
1586
|
+
var SEAL_R_MAX = 128;
|
|
1587
|
+
var SEAL_VIRTUAL_MAX = 0.25;
|
|
1588
|
+
var SEAL_MAX_SHEET_FRAC = 0.3;
|
|
1589
|
+
var VIRTUAL_HUG_PX = 3;
|
|
1590
|
+
function sealRadiiFor(maskPxPerFt) {
|
|
1591
|
+
if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return SEAL_RADII;
|
|
1592
|
+
const maxR = Math.min(SEAL_R_MAX, Math.ceil(DOOR_SEAL_MAX_FT * maskPxPerFt / 2));
|
|
1593
|
+
const radii = [];
|
|
1594
|
+
for (let r = 1; r < maxR; r *= 2) radii.push(r);
|
|
1595
|
+
radii.push(maxR);
|
|
1596
|
+
return radii;
|
|
1597
|
+
}
|
|
1598
|
+
var sealCache = /* @__PURE__ */ new WeakMap();
|
|
1599
|
+
function hardDT(mask, mw, mh, out) {
|
|
1600
|
+
const dt = out || new Uint8Array(mw * mh).fill(255);
|
|
1601
|
+
for (let y = 0; y < mh; y++) {
|
|
1602
|
+
const row = y * mw;
|
|
1603
|
+
for (let x = 0; x < mw; x++) {
|
|
1604
|
+
const i = row + x;
|
|
1605
|
+
if (mask[i] & 1) {
|
|
1606
|
+
dt[i] = 0;
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
let d = 255;
|
|
1610
|
+
if (x > 0) d = Math.min(d, dt[i - 1] + 1);
|
|
1611
|
+
if (y > 0) d = Math.min(d, dt[i - mw] + 1);
|
|
1612
|
+
dt[i] = Math.min(255, d);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
for (let y = mh - 1; y >= 0; y--) {
|
|
1616
|
+
const row = y * mw;
|
|
1617
|
+
for (let x = mw - 1; x >= 0; x--) {
|
|
1618
|
+
const i = row + x;
|
|
1619
|
+
let d = dt[i];
|
|
1620
|
+
if (x < mw - 1) d = Math.min(d, dt[i + 1] + 1);
|
|
1621
|
+
if (y < mh - 1) d = Math.min(d, dt[i + mw] + 1);
|
|
1622
|
+
dt[i] = Math.min(255, d);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return dt;
|
|
1626
|
+
}
|
|
1627
|
+
function l1ToBox(x, y, bx0, by0, bx1, by1) {
|
|
1628
|
+
const dx = x < bx0 ? bx0 - x : x > bx1 ? x - bx1 : 0;
|
|
1629
|
+
const dy = y < by0 ? by0 - y : y > by1 ? y - by1 : 0;
|
|
1630
|
+
return dx + dy;
|
|
1631
|
+
}
|
|
1632
|
+
function dtDirtyWindow(cl, mw, mh, dt) {
|
|
1633
|
+
let cx0 = mw, cy0 = mh, cx1 = -1, cy1 = -1;
|
|
1634
|
+
for (const i of cl) {
|
|
1635
|
+
const y = i / mw | 0, x = i - y * mw;
|
|
1636
|
+
if (x < cx0) cx0 = x;
|
|
1637
|
+
if (x > cx1) cx1 = x;
|
|
1638
|
+
if (y < cy0) cy0 = y;
|
|
1639
|
+
if (y > cy1) cy1 = y;
|
|
1640
|
+
}
|
|
1641
|
+
if (cx1 < 0) return null;
|
|
1642
|
+
for (let w = 16; ; w *= 2) {
|
|
1643
|
+
const x0 = cx0 - w, y0 = cy0 - w, x1 = cx1 + w, y1 = cy1 + w;
|
|
1644
|
+
if (x0 <= 0 && y0 <= 0 && x1 >= mw - 1 && y1 >= mh - 1) return null;
|
|
1645
|
+
const clear = (x, y) => x < 0 || y < 0 || x >= mw || y >= mh || dt[y * mw + x] < l1ToBox(x, y, cx0, cy0, cx1, cy1);
|
|
1646
|
+
let ok2 = true;
|
|
1647
|
+
for (let x = x0 - 1; x <= x1 + 1 && ok2; x++) ok2 = clear(x, y0 - 1) && clear(x, y1 + 1);
|
|
1648
|
+
for (let y = y0; y <= y1 && ok2; y++) ok2 = clear(x0 - 1, y) && clear(x1 + 1, y);
|
|
1649
|
+
if (ok2) return { x0: Math.max(0, x0), y0: Math.max(0, y0), x1: Math.min(mw - 1, x1), y1: Math.min(mh - 1, y1) };
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
function hardDTWindow(mask, mw, mh, dt, b) {
|
|
1653
|
+
for (let y = b.y0; y <= b.y1; y++) {
|
|
1654
|
+
const row = y * mw;
|
|
1655
|
+
for (let x = b.x0; x <= b.x1; x++) {
|
|
1656
|
+
const i = row + x;
|
|
1657
|
+
if (mask[i] & 1) {
|
|
1658
|
+
dt[i] = 0;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
let d = 255;
|
|
1662
|
+
if (x > 0) d = Math.min(d, dt[i - 1] + 1);
|
|
1663
|
+
if (y > 0) d = Math.min(d, dt[i - mw] + 1);
|
|
1664
|
+
dt[i] = Math.min(255, d);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
for (let y = b.y1; y >= b.y0; y--) {
|
|
1668
|
+
const row = y * mw;
|
|
1669
|
+
for (let x = b.x1; x >= b.x0; x--) {
|
|
1670
|
+
const i = row + x;
|
|
1671
|
+
let d = dt[i];
|
|
1672
|
+
if (x < mw - 1) d = Math.min(d, dt[i + 1] + 1);
|
|
1673
|
+
if (y < mh - 1) d = Math.min(d, dt[i + mw] + 1);
|
|
1674
|
+
dt[i] = Math.min(255, d);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function dilatedView(mo, r, dt) {
|
|
1679
|
+
return { mask: mo.mask, mw: mo.mw, mh: mo.mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf, dilDT: dt, dilR: r };
|
|
1680
|
+
}
|
|
1681
|
+
function growRegionBack(f, orig, r, barrier, dt) {
|
|
1682
|
+
const { region, mw, mh } = f;
|
|
1683
|
+
const mask = orig.mask;
|
|
1684
|
+
const b = boxOf(region, mw, mh);
|
|
1685
|
+
regionBox.set(region, b);
|
|
1686
|
+
let frontier = [];
|
|
1687
|
+
for (let y = b.y0; y <= b.y1; y++) {
|
|
1688
|
+
const row = y * mw;
|
|
1689
|
+
for (let x = b.x0; x <= b.x1; x++) {
|
|
1690
|
+
const i = row + x;
|
|
1691
|
+
if (!region[i]) continue;
|
|
1692
|
+
if (x > 0 && !region[i - 1] || x < mw - 1 && !region[i + 1] || y > 0 && !region[i - mw] || y < mh - 1 && !region[i + mw]) frontier.push(i);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const tryGrow = (from, to, next) => {
|
|
1696
|
+
if (!region[to] && !(mask[to] & barrier) && dt[to] <= r && dt[to] <= dt[from]) {
|
|
1697
|
+
region[to] = 1;
|
|
1698
|
+
f.count++;
|
|
1699
|
+
next.push(to);
|
|
1700
|
+
const y = to / mw | 0, x = to - y * mw;
|
|
1701
|
+
if (x < b.x0) b.x0 = x;
|
|
1702
|
+
if (x > b.x1) b.x1 = x;
|
|
1703
|
+
if (y < b.y0) b.y0 = y;
|
|
1704
|
+
if (y > b.y1) b.y1 = y;
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
while (frontier.length) {
|
|
1708
|
+
const next = [];
|
|
1709
|
+
for (const i of frontier) {
|
|
1710
|
+
const x = i % mw, y = i / mw | 0;
|
|
1711
|
+
if (x > 0) tryGrow(i, i - 1, next);
|
|
1712
|
+
if (x < mw - 1) tryGrow(i, i + 1, next);
|
|
1713
|
+
if (y > 0) tryGrow(i, i - mw, next);
|
|
1714
|
+
if (y < mh - 1) tryGrow(i, i + mw, next);
|
|
1715
|
+
}
|
|
1716
|
+
frontier = next;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
var WEDGE_SLACK = 1.3;
|
|
1720
|
+
var WEDGE_GROWTH_FRAC = 0.3;
|
|
1721
|
+
var WEDGE_MAX_DOORS = 12;
|
|
1722
|
+
function doorWedgeCapPx(maskPxPerFt) {
|
|
1723
|
+
if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 0;
|
|
1724
|
+
return Math.round(Math.PI / 4 * (DOOR_SEAL_MAX_FT * maskPxPerFt) ** 2 * WEDGE_SLACK);
|
|
1725
|
+
}
|
|
1726
|
+
function boundaryCurveClusters(mo, region) {
|
|
1727
|
+
const { mw, mh } = mo;
|
|
1728
|
+
const src = mo.mask;
|
|
1729
|
+
const near = [];
|
|
1730
|
+
const b = boxOf(region, mw, mh);
|
|
1731
|
+
const y0 = Math.max(0, b.y0 - 3), y1 = Math.min(mh - 1, b.y1 + 3);
|
|
1732
|
+
const x0 = Math.max(0, b.x0 - 3), x1 = Math.min(mw - 1, b.x1 + 3);
|
|
1733
|
+
for (let y = y0; y <= y1; y++) {
|
|
1734
|
+
const row = y * mw;
|
|
1735
|
+
for (let x = x0; x <= x1; x++) {
|
|
1736
|
+
const i = row + x;
|
|
1737
|
+
if (!(src[i] & MASK_CURVE_BIT)) continue;
|
|
1738
|
+
let n = false;
|
|
1739
|
+
for (let dy = -3; dy <= 3 && !n; dy++) {
|
|
1740
|
+
const ny = y + dy;
|
|
1741
|
+
if (ny < 0 || ny >= mh) continue;
|
|
1742
|
+
for (let dx = -3; dx <= 3; dx++) {
|
|
1743
|
+
const nx = x + dx;
|
|
1744
|
+
if (nx >= 0 && nx < mw && region[ny * mw + nx]) {
|
|
1745
|
+
n = true;
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (n) near.push(i);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const clusters = [];
|
|
1754
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1755
|
+
for (const s of near) {
|
|
1756
|
+
if (seen.has(s)) continue;
|
|
1757
|
+
const cl = [];
|
|
1758
|
+
const stack = [s];
|
|
1759
|
+
seen.add(s);
|
|
1760
|
+
while (stack.length) {
|
|
1761
|
+
const i = stack.pop();
|
|
1762
|
+
cl.push(i);
|
|
1763
|
+
const x = i % mw, y = i / mw | 0;
|
|
1764
|
+
for (let dy = -3; dy <= 3; dy++) {
|
|
1765
|
+
const ny = y + dy;
|
|
1766
|
+
if (ny < 0 || ny >= mh) continue;
|
|
1767
|
+
for (let dx = -3; dx <= 3; dx++) {
|
|
1768
|
+
const nx = x + dx;
|
|
1769
|
+
if (nx < 0 || nx >= mw) continue;
|
|
1770
|
+
const j = ny * mw + nx;
|
|
1771
|
+
if (!seen.has(j) && src[j] & MASK_CURVE_BIT) {
|
|
1772
|
+
seen.add(j);
|
|
1773
|
+
stack.push(j);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
clusters.push(cl);
|
|
1779
|
+
}
|
|
1780
|
+
return clusters;
|
|
1781
|
+
}
|
|
1782
|
+
function arcClusterFit(cl, mw, mask) {
|
|
1783
|
+
const m = cl.length;
|
|
1784
|
+
const X = (i) => i % mw, Y = (i) => i / mw | 0;
|
|
1785
|
+
let mx = 0, my = 0, noDoor = 0;
|
|
1786
|
+
for (const i of cl) {
|
|
1787
|
+
mx += X(i);
|
|
1788
|
+
my += Y(i);
|
|
1789
|
+
if (mask[i] & MASK_NODOOR_BIT) noDoor++;
|
|
1790
|
+
}
|
|
1791
|
+
mx /= m;
|
|
1792
|
+
my /= m;
|
|
1793
|
+
let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
|
|
1794
|
+
for (const i of cl) {
|
|
1795
|
+
const x = X(i) - mx, y = Y(i) - my, z3 = x * x + y * y;
|
|
1796
|
+
sxx += x * x;
|
|
1797
|
+
sxy += x * y;
|
|
1798
|
+
syy += y * y;
|
|
1799
|
+
sxz += x * z3;
|
|
1800
|
+
syz += y * z3;
|
|
1801
|
+
}
|
|
1802
|
+
const tr = sxx + syy, dsc = Math.sqrt(Math.max(0, ((sxx - syy) / 2) ** 2 + sxy * sxy));
|
|
1803
|
+
const l1 = tr / 2 + dsc;
|
|
1804
|
+
let ux = sxy, uy = l1 - sxx;
|
|
1805
|
+
if (Math.hypot(ux, uy) < 1e-9) {
|
|
1806
|
+
ux = 1;
|
|
1807
|
+
uy = 0;
|
|
1808
|
+
} else {
|
|
1809
|
+
const L = Math.hypot(ux, uy);
|
|
1810
|
+
ux /= L;
|
|
1811
|
+
uy /= L;
|
|
1812
|
+
}
|
|
1813
|
+
let u0 = Infinity, u1 = -Infinity, n0 = Infinity, n1 = -Infinity;
|
|
1814
|
+
for (const i of cl) {
|
|
1815
|
+
const x = X(i) - mx, y = Y(i) - my;
|
|
1816
|
+
const a = x * ux + y * uy, b = -x * uy + y * ux;
|
|
1817
|
+
if (a < u0) u0 = a;
|
|
1818
|
+
if (a > u1) u1 = a;
|
|
1819
|
+
if (b < n0) n0 = b;
|
|
1820
|
+
if (b > n1) n1 = b;
|
|
1821
|
+
}
|
|
1822
|
+
const bu = u1 - u0 + 1, bn = n1 - n0 + 1;
|
|
1823
|
+
const base = { cx: mx, cy: my, r: 0, rms: Infinity, good: false, sweep: 0, noDoorFrac: noDoor / m, bu, bn, buH: bu, bnH: bn };
|
|
1824
|
+
const det = sxx * syy - sxy * sxy;
|
|
1825
|
+
if (m < ARC_MIN_CHORDS || Math.abs(det) < 1e-9) return base;
|
|
1826
|
+
const cx = (sxz * syy - syz * sxy) / (2 * det), cy = (syz * sxx - sxz * sxy) / (2 * det);
|
|
1827
|
+
let r = 0;
|
|
1828
|
+
for (const i of cl) r += Math.hypot(X(i) - mx - cx, Y(i) - my - cy);
|
|
1829
|
+
r /= m;
|
|
1830
|
+
if (!(r > 0)) return base;
|
|
1831
|
+
let s2 = 0;
|
|
1832
|
+
const angs = [];
|
|
1833
|
+
for (const i of cl) {
|
|
1834
|
+
const dx = X(i) - mx - cx, dy = Y(i) - my - cy;
|
|
1835
|
+
const d = Math.hypot(dx, dy) - r;
|
|
1836
|
+
s2 += d * d;
|
|
1837
|
+
angs.push(Math.atan2(dy, dx));
|
|
1838
|
+
}
|
|
1839
|
+
const rms = Math.sqrt(s2 / m);
|
|
1840
|
+
angs.sort((a, b) => a - b);
|
|
1841
|
+
let gap = angs[0] + 2 * Math.PI - angs[angs.length - 1];
|
|
1842
|
+
for (let k = 1; k < angs.length; k++) if (angs[k] - angs[k - 1] > gap) gap = angs[k] - angs[k - 1];
|
|
1843
|
+
const hu = cx * ux + cy * uy, hn = -cx * uy + cy * ux;
|
|
1844
|
+
return {
|
|
1845
|
+
...base,
|
|
1846
|
+
cx: cx + mx,
|
|
1847
|
+
cy: cy + my,
|
|
1848
|
+
r,
|
|
1849
|
+
rms,
|
|
1850
|
+
good: rms <= Math.max(CLUSTER_FIT_TOL_PX, CLUSTER_FIT_TOL_FRAC * r),
|
|
1851
|
+
sweep: Math.max(0, 2 * Math.PI - gap),
|
|
1852
|
+
buH: Math.max(u1, hu) - Math.min(u0, hu) + 1,
|
|
1853
|
+
bnH: Math.max(n1, hn) - Math.min(n0, hn) + 1
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
var WEDGE_RIM_FT = 3 / CAL_MPPF;
|
|
1857
|
+
function wedgeRimPx(maskPxPerFt) {
|
|
1858
|
+
if (!Number.isFinite(maskPxPerFt) || maskPxPerFt <= 0) return 3;
|
|
1859
|
+
return Math.max(3, Math.round(WEDGE_RIM_FT * maskPxPerFt));
|
|
1860
|
+
}
|
|
1861
|
+
function wedgeAllowance(fit, mppf, wedgeCapPx) {
|
|
1862
|
+
if (fit.noDoorFrac > 0.5 && !fit.good) return 0;
|
|
1863
|
+
if (fit.good && mppf > 0 && fit.r / mppf > DOOR_R_MAX_FT) return 0;
|
|
1864
|
+
const rim = wedgeRimPx(mppf);
|
|
1865
|
+
const bu = fit.good ? fit.buH : fit.bu, bn = fit.good ? fit.bnH : fit.bn;
|
|
1866
|
+
let area = bu * bn;
|
|
1867
|
+
if (fit.good) area = Math.min(area, 0.5 * fit.sweep * fit.r * fit.r);
|
|
1868
|
+
area += 2 * rim * (bu + bn) + 4 * rim * rim;
|
|
1869
|
+
return Math.min(2 * wedgeCapPx, Math.round(area * WEDGE_SLACK));
|
|
1870
|
+
}
|
|
1871
|
+
function doorLikeness(fit, mppf) {
|
|
1872
|
+
let s = 1 - fit.noDoorFrac;
|
|
1873
|
+
const swDeg = fit.sweep * 180 / Math.PI;
|
|
1874
|
+
if (fit.good) {
|
|
1875
|
+
s += 1;
|
|
1876
|
+
if (mppf > 0 && fit.r / mppf >= DOOR_R_MIN_FT && fit.r / mppf <= DOOR_R_MAX_FT) s += 4;
|
|
1877
|
+
if (swDeg >= 45 && swDeg <= 190) s += 2;
|
|
1878
|
+
}
|
|
1879
|
+
return s;
|
|
1880
|
+
}
|
|
1881
|
+
function ascendSeed(dt, mw, mh, ws, ix, iy, r) {
|
|
1882
|
+
let cx = Math.max(0, Math.min(mw - 1, Math.round(ix * ws)));
|
|
1883
|
+
let cy = Math.max(0, Math.min(mh - 1, Math.round(iy * ws)));
|
|
1884
|
+
for (let step = 0; step < 2 * r && dt[cy * mw + cx] <= r; step++) {
|
|
1885
|
+
let bx = cx, by = cy, bd = dt[cy * mw + cx];
|
|
1886
|
+
if (cx > 0 && dt[cy * mw + cx - 1] > bd) {
|
|
1887
|
+
bd = dt[cy * mw + cx - 1];
|
|
1888
|
+
bx = cx - 1;
|
|
1889
|
+
by = cy;
|
|
1890
|
+
}
|
|
1891
|
+
if (cx < mw - 1 && dt[cy * mw + cx + 1] > bd) {
|
|
1892
|
+
bd = dt[cy * mw + cx + 1];
|
|
1893
|
+
bx = cx + 1;
|
|
1894
|
+
by = cy;
|
|
1895
|
+
}
|
|
1896
|
+
if (cy > 0 && dt[(cy - 1) * mw + cx] > bd) {
|
|
1897
|
+
bd = dt[(cy - 1) * mw + cx];
|
|
1898
|
+
bx = cx;
|
|
1899
|
+
by = cy - 1;
|
|
1900
|
+
}
|
|
1901
|
+
if (cy < mh - 1 && dt[(cy + 1) * mw + cx] > bd) {
|
|
1902
|
+
bd = dt[(cy + 1) * mw + cx];
|
|
1903
|
+
bx = cx;
|
|
1904
|
+
by = cy + 1;
|
|
1905
|
+
}
|
|
1906
|
+
if (bx === cx && by === cy) break;
|
|
1907
|
+
cx = bx;
|
|
1908
|
+
cy = by;
|
|
1909
|
+
}
|
|
1910
|
+
return [cx / ws, cy / ws];
|
|
1911
|
+
}
|
|
1912
|
+
function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
|
|
1913
|
+
const scratch = () => {
|
|
1914
|
+
if (given) return given;
|
|
1915
|
+
let s = sealCache.get(mo.mask);
|
|
1916
|
+
if (!s) {
|
|
1917
|
+
s = { dt: hardDT(mo.mask, mo.mw, mo.mh) };
|
|
1918
|
+
sealCache.set(mo.mask, s);
|
|
1919
|
+
}
|
|
1920
|
+
return s;
|
|
1921
|
+
};
|
|
1922
|
+
let raw = null;
|
|
1923
|
+
const rawFlood = () => raw ??= floodRegionLadder(mo, ix, iy, sensitivity);
|
|
1924
|
+
let leakedR = 0;
|
|
1925
|
+
const cx = Math.max(0, Math.min(mo.mw - 1, Math.round(ix * mo.ws)));
|
|
1926
|
+
const cy = Math.max(0, Math.min(mo.mh - 1, Math.round(iy * mo.ws)));
|
|
1927
|
+
const cSoft = (mo.mask[cy * mo.mw + cx] & 2) !== 0;
|
|
1928
|
+
const cdt = (s) => cSoft ? 0 : s.dt[cy * mo.mw + cx];
|
|
1929
|
+
if (minPassPx > 0) {
|
|
1930
|
+
const s = scratch();
|
|
1931
|
+
const dm = dilatedView(mo, minPassPx, s.dt);
|
|
1932
|
+
const [ax, ay] = ascendSeed(s.dt, mo.mw, mo.mh, mo.ws, ix, iy, minPassPx);
|
|
1933
|
+
const f = floodRegionLadder(dm, ax, ay, sensitivity);
|
|
1934
|
+
if (f.status === "leak" && minPassPx > leakedR && cdt(s) > minPassPx) leakedR = minPassPx;
|
|
1935
|
+
if (f.status === "ok") {
|
|
1936
|
+
growRegionBack(f, mo, minPassPx, f.hatchFiltered ? 1 : 3, s.dt);
|
|
1937
|
+
const r0 = rawFlood();
|
|
1938
|
+
if (r0.status === "ok" && r0.count > 0) {
|
|
1939
|
+
const d = +(1 - f.count / r0.count).toFixed(4);
|
|
1940
|
+
if (d > 0) {
|
|
1941
|
+
f.minPassPx = minPassPx;
|
|
1942
|
+
f.minPassDelta = d;
|
|
1943
|
+
}
|
|
1944
|
+
return f;
|
|
1945
|
+
}
|
|
1946
|
+
const vf = f.count > f.mw * f.mh * SEAL_MAX_SHEET_FRAC ? 1 : virtualBoundaryFrac(f, s.dt);
|
|
1947
|
+
if (vf <= SEAL_VIRTUAL_MAX) {
|
|
1948
|
+
f.minPassPx = minPassPx;
|
|
1949
|
+
f.minPassDelta = 1;
|
|
1950
|
+
f.sealedPx = minPassPx;
|
|
1951
|
+
f.virtualFrac = +vf.toFixed(3);
|
|
1952
|
+
return f;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
const base = rawFlood();
|
|
1957
|
+
if (base.status === "ok") return base;
|
|
1958
|
+
const sc = scratch();
|
|
1959
|
+
for (const r of radii) {
|
|
1960
|
+
if (r <= minPassPx) continue;
|
|
1961
|
+
const dm = dilatedView(mo, r, sc.dt);
|
|
1962
|
+
const [ax, ay] = ascendSeed(sc.dt, mo.mw, mo.mh, mo.ws, ix, iy, r);
|
|
1963
|
+
const f = floodRegionLadder(dm, ax, ay, sensitivity);
|
|
1964
|
+
if (f.status !== "ok") {
|
|
1965
|
+
if (f.status === "leak" && r > leakedR && cdt(sc) > r) leakedR = r;
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
growRegionBack(f, mo, r, f.hatchFiltered ? 1 : 3, sc.dt);
|
|
1969
|
+
if (f.count > f.mw * f.mh * SEAL_MAX_SHEET_FRAC) continue;
|
|
1970
|
+
const vf = virtualBoundaryFrac(f, sc.dt);
|
|
1971
|
+
if (vf > SEAL_VIRTUAL_MAX) continue;
|
|
1972
|
+
f.sealedPx = r;
|
|
1973
|
+
f.virtualFrac = +vf.toFixed(3);
|
|
1974
|
+
return f;
|
|
1975
|
+
}
|
|
1976
|
+
if (base.status === "leak" && leakedR > 0) base.leakedDilationPx = leakedR;
|
|
1977
|
+
return base;
|
|
1978
|
+
}
|
|
1979
|
+
function floodRegionSealed(mo, ix, iy, sensitivity = SENS_BALANCED, radii = SEAL_RADII, wedgeCapPx = 0, minPassPx = 0) {
|
|
1980
|
+
const out = floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minPassPx);
|
|
1981
|
+
if (out.status === "ok") {
|
|
1982
|
+
const cf = curveBoundaryFrac(out, mo);
|
|
1983
|
+
if (cf > 0) out.curveFrac = +cf.toFixed(3);
|
|
1984
|
+
}
|
|
1985
|
+
return out;
|
|
1986
|
+
}
|
|
1987
|
+
function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minPassPx) {
|
|
1988
|
+
const r1 = sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx);
|
|
1989
|
+
if (r1.status === "leak") {
|
|
1990
|
+
const futileBelowPx = (r1.leakedDilationPx ?? 0) >> 1;
|
|
1991
|
+
for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
|
|
1992
|
+
if (br <= futileBelowPx) continue;
|
|
1993
|
+
const rb = floodRegionLadder(bridgedMask(mo, br), ix, iy, sensitivity);
|
|
1994
|
+
if (rb.status === "ok") {
|
|
1995
|
+
rb.gapBridged = br;
|
|
1996
|
+
return rb;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (!wedgeCapPx || r1.status !== "ok") return r1;
|
|
2001
|
+
const clusters = boundaryCurveClusters(mo, r1.region);
|
|
2002
|
+
if (!clusters.length) return r1;
|
|
2003
|
+
const globalAllowance = Math.max(wedgeCapPx, Math.min(Math.round(r1.count * WEDGE_GROWTH_FRAC), WEDGE_MAX_DOORS * wedgeCapPx));
|
|
2004
|
+
const { mw, mh } = mo;
|
|
2005
|
+
let region = null;
|
|
2006
|
+
let count = r1.count;
|
|
2007
|
+
let wedges = 0, ringWedges = 0;
|
|
2008
|
+
let hatchFiltered = !!r1.hatchFiltered;
|
|
2009
|
+
let hatchTier = r1.hatchTier;
|
|
2010
|
+
let sealedPx = r1.sealedPx, virtualFrac = r1.virtualFrac;
|
|
2011
|
+
let minPassPxOut = r1.minPassPx, minPassDelta = r1.minPassDelta;
|
|
2012
|
+
const ranked = clusters.map((cl) => {
|
|
2013
|
+
const fit = arcClusterFit(cl, mw, mo.mask);
|
|
2014
|
+
return { cl, fit, allow: wedgeAllowance(fit, mo.mppf || 0, wedgeCapPx), rank: doorLikeness(fit, mo.mppf || 0), at: cl[0] };
|
|
2015
|
+
}).filter((c) => c.allow >= 1).sort((a, b) => b.rank - a.rank || a.at - b.at);
|
|
2016
|
+
let m2mask = null, m2dt = null;
|
|
2017
|
+
let openedCl = null, dirty = null;
|
|
2018
|
+
const rb1 = boxOf(r1.region, mw, mh);
|
|
2019
|
+
let base = sealCache.get(mo.mask);
|
|
2020
|
+
if (!base) {
|
|
2021
|
+
base = { dt: hardDT(mo.mask, mw, mh) };
|
|
2022
|
+
sealCache.set(mo.mask, base);
|
|
2023
|
+
}
|
|
2024
|
+
const baseDT = base.dt;
|
|
2025
|
+
for (const { cl, fit, allow: clusterAllowance } of ranked.slice(0, WEDGE_MAX_DOORS)) {
|
|
2026
|
+
if (!m2mask) {
|
|
2027
|
+
m2mask = mo.mask.slice();
|
|
2028
|
+
m2dt = baseDT.slice();
|
|
2029
|
+
} else {
|
|
2030
|
+
if (openedCl) for (const i of openedCl) m2mask[i] = mo.mask[i];
|
|
2031
|
+
if (dirty) for (let y = dirty.y0; y <= dirty.y1; y++) {
|
|
2032
|
+
const r = y * mw;
|
|
2033
|
+
m2dt.set(baseDT.subarray(r + dirty.x0, r + dirty.x1 + 1), r + dirty.x0);
|
|
2034
|
+
}
|
|
2035
|
+
else m2dt.set(baseDT);
|
|
2036
|
+
}
|
|
2037
|
+
for (const i of cl) m2mask[i] = m2mask[i] & ~1;
|
|
2038
|
+
openedCl = cl;
|
|
2039
|
+
const m2 = { mask: m2mask, mw, mh, ws: mo.ws, softCount: mo.softCount, mppf: mo.mppf };
|
|
2040
|
+
dirty = dtDirtyWindow(cl, mw, mh, baseDT);
|
|
2041
|
+
if (dirty) hardDTWindow(m2mask, mw, mh, m2dt, dirty);
|
|
2042
|
+
else hardDT(m2mask, mw, mh, m2dt);
|
|
2043
|
+
const sc2 = { dt: m2dt };
|
|
2044
|
+
let bi = -1, bd = -1;
|
|
2045
|
+
for (let y = rb1.y0; y <= rb1.y1; y++) {
|
|
2046
|
+
const row = y * mw;
|
|
2047
|
+
for (let x = rb1.x0; x <= rb1.x1; x++) {
|
|
2048
|
+
const i = row + x;
|
|
2049
|
+
if (r1.region[i] && sc2.dt[i] > bd) {
|
|
2050
|
+
bd = sc2.dt[i];
|
|
2051
|
+
bi = i;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
const sx = bi < 0 ? ix : bi % mw / mo.ws, sy = bi < 0 ? iy : Math.floor(bi / mw) / mo.ws;
|
|
2056
|
+
const r2 = sealAttempt(m2, sx, sy, sensitivity, radii, minPassPx, sc2);
|
|
2057
|
+
if (r2.status !== "ok" || r2.count <= r1.count) continue;
|
|
2058
|
+
const growth = r2.count - r1.count;
|
|
2059
|
+
if (growth > clusterAllowance) continue;
|
|
2060
|
+
if (count - r1.count + growth > globalAllowance) continue;
|
|
2061
|
+
if (!region) {
|
|
2062
|
+
region = r1.region.slice();
|
|
2063
|
+
regionBox.set(region, { ...rb1 });
|
|
2064
|
+
}
|
|
2065
|
+
const rb = regionBox.get(region), b2 = boxOf(r2.region, mw, mh);
|
|
2066
|
+
for (let y = b2.y0; y <= b2.y1; y++) {
|
|
2067
|
+
const row = y * mw;
|
|
2068
|
+
for (let x = b2.x0; x <= b2.x1; x++) {
|
|
2069
|
+
const i = row + x;
|
|
2070
|
+
if (r2.region[i] && !region[i]) {
|
|
2071
|
+
region[i] = 1;
|
|
2072
|
+
count++;
|
|
2073
|
+
if (x < rb.x0) rb.x0 = x;
|
|
2074
|
+
if (x > rb.x1) rb.x1 = x;
|
|
2075
|
+
if (y < rb.y0) rb.y0 = y;
|
|
2076
|
+
if (y > rb.y1) rb.y1 = y;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
wedges++;
|
|
2081
|
+
if (fit.noDoorFrac > 0.5 && fit.good) ringWedges++;
|
|
2082
|
+
if (r2.hatchFiltered) hatchFiltered = true;
|
|
2083
|
+
if (r2.hatchTier && HATCH_TIER_RISK[r2.hatchTier] > (hatchTier ? HATCH_TIER_RISK[hatchTier] : -1)) hatchTier = r2.hatchTier;
|
|
2084
|
+
if (r2.sealedPx && (!sealedPx || r2.sealedPx > sealedPx)) sealedPx = r2.sealedPx;
|
|
2085
|
+
if (r2.virtualFrac != null && (virtualFrac == null || r2.virtualFrac > virtualFrac)) virtualFrac = r2.virtualFrac;
|
|
2086
|
+
if (r2.minPassPx && (!minPassPxOut || r2.minPassPx > minPassPxOut)) minPassPxOut = r2.minPassPx;
|
|
2087
|
+
if (r2.minPassDelta != null && (minPassDelta == null || r2.minPassDelta > minPassDelta)) minPassDelta = r2.minPassDelta;
|
|
2088
|
+
}
|
|
2089
|
+
if (!wedges || !region) return r1;
|
|
2090
|
+
const out = {
|
|
2091
|
+
status: "ok",
|
|
2092
|
+
region,
|
|
2093
|
+
count,
|
|
2094
|
+
mw,
|
|
2095
|
+
mh,
|
|
2096
|
+
ws: r1.ws,
|
|
2097
|
+
mppf: r1.mppf,
|
|
2098
|
+
hardHits: r1.hardHits,
|
|
2099
|
+
softHits: r1.softHits,
|
|
2100
|
+
hatchFiltered: hatchFiltered || void 0,
|
|
2101
|
+
hatchTier,
|
|
2102
|
+
sealedPx,
|
|
2103
|
+
virtualFrac,
|
|
2104
|
+
minPassPx: minPassPxOut,
|
|
2105
|
+
minPassDelta,
|
|
2106
|
+
...ringWedges ? { ringWedges } : {}
|
|
2107
|
+
};
|
|
2108
|
+
const reg = region, reg1 = r1.region, mask = mo.mask;
|
|
2109
|
+
const isDelta = (i) => reg[i] && !reg1[i];
|
|
2110
|
+
const rbA = boxOf(reg, mw, mh);
|
|
2111
|
+
const ay0 = Math.max(1, rbA.y0), ay1 = Math.min(mh - 2, rbA.y1);
|
|
2112
|
+
const ax0 = Math.max(1, rbA.x0), ax1 = Math.min(mw - 2, rbA.x1);
|
|
2113
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
2114
|
+
for (let y = ay0; y <= ay1; y++) {
|
|
2115
|
+
const row = y * mw;
|
|
2116
|
+
for (let x = ax0; x <= ax1; x++) {
|
|
2117
|
+
const i = row + x;
|
|
2118
|
+
if (reg[i] || !(mask[i] & 1)) continue;
|
|
2119
|
+
const pinchH = reg[i - 1] && reg[i + 1], pinchV = reg[i - mw] && reg[i + mw];
|
|
2120
|
+
if (pinchH && (isDelta(i - 1) || isDelta(i + 1)) || pinchV && (isDelta(i - mw) || isDelta(i + mw))) {
|
|
2121
|
+
reg[i] = 1;
|
|
2122
|
+
out.count++;
|
|
2123
|
+
}
|
|
930
2124
|
}
|
|
931
2125
|
}
|
|
932
2126
|
}
|
|
933
|
-
|
|
934
|
-
|
|
2127
|
+
out.wedges = wedges;
|
|
2128
|
+
out.wedgeGrowth = +(out.count / r1.count).toFixed(3);
|
|
2129
|
+
return out;
|
|
2130
|
+
}
|
|
2131
|
+
function virtualBoundaryFrac(f, dt) {
|
|
2132
|
+
const { region, mw, mh } = f;
|
|
2133
|
+
const b = boxOf(region, mw, mh);
|
|
2134
|
+
let boundary = 0, virtual = 0;
|
|
2135
|
+
for (let y = b.y0; y <= b.y1; y++) {
|
|
935
2136
|
const row = y * mw;
|
|
936
|
-
for (let x =
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
2137
|
+
for (let x = b.x0; x <= b.x1; x++) {
|
|
2138
|
+
const i = row + x;
|
|
2139
|
+
if (!region[i]) continue;
|
|
2140
|
+
if (x > 0 && !region[i - 1] || x < mw - 1 && !region[i + 1] || y > 0 && !region[i - mw] || y < mh - 1 && !region[i + mw]) {
|
|
2141
|
+
boundary++;
|
|
2142
|
+
if (dt[i] > VIRTUAL_HUG_PX) virtual++;
|
|
940
2143
|
}
|
|
941
2144
|
}
|
|
942
2145
|
}
|
|
943
|
-
return
|
|
2146
|
+
return boundary ? virtual / boundary : 1;
|
|
944
2147
|
}
|
|
945
|
-
function
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
}
|
|
963
|
-
return r1;
|
|
964
|
-
};
|
|
965
|
-
const r = attempt(maskObj);
|
|
966
|
-
if (r.status !== "leak") return r;
|
|
967
|
-
for (let br = 1; br <= GAP_BRIDGE_MAX; br++) {
|
|
968
|
-
const rb = attempt(dilateHard(maskObj, br));
|
|
969
|
-
if (rb.status === "ok") {
|
|
970
|
-
rb.gapBridged = br;
|
|
971
|
-
return rb;
|
|
2148
|
+
function curveBoundaryFrac(f, mo) {
|
|
2149
|
+
const { region, mw, mh } = f;
|
|
2150
|
+
const mask = mo.mask;
|
|
2151
|
+
const b = boxOf(region, mw, mh);
|
|
2152
|
+
let boundary = 0, curved = 0;
|
|
2153
|
+
for (let y = b.y0; y <= b.y1; y++) {
|
|
2154
|
+
const row = y * mw;
|
|
2155
|
+
for (let x = b.x0; x <= b.x1; x++) {
|
|
2156
|
+
const i = row + x;
|
|
2157
|
+
if (!region[i]) continue;
|
|
2158
|
+
const w = x > 0 ? i - 1 : -1, e = x < mw - 1 ? i + 1 : -1, n = y > 0 ? i - mw : -1, s = y < mh - 1 ? i + mw : -1;
|
|
2159
|
+
if (!(w >= 0 && !region[w] || e >= 0 && !region[e] || n >= 0 && !region[n] || s >= 0 && !region[s])) continue;
|
|
2160
|
+
boundary++;
|
|
2161
|
+
for (const j of [w, e, n, s]) if (j >= 0 && !region[j] && mask[j] & MASK_CURVE_BIT) {
|
|
2162
|
+
curved++;
|
|
2163
|
+
break;
|
|
2164
|
+
}
|
|
972
2165
|
}
|
|
973
2166
|
}
|
|
974
|
-
return
|
|
2167
|
+
return boundary ? curved / boundary : 0;
|
|
975
2168
|
}
|
|
976
2169
|
function traceRegion(reg, epsMaskPx = 1.5) {
|
|
977
2170
|
const { region, mw, mh, ws } = reg;
|
|
978
2171
|
let s = -1;
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
2172
|
+
const b = boxOf(region, mw, mh);
|
|
2173
|
+
for (let y = b.y0; y <= b.y1 && s < 0; y++) {
|
|
2174
|
+
const row = y * mw;
|
|
2175
|
+
for (let x = b.x0; x <= b.x1; x++) if (region[row + x]) {
|
|
2176
|
+
s = row + x;
|
|
2177
|
+
break;
|
|
2178
|
+
}
|
|
982
2179
|
}
|
|
983
2180
|
if (s < 0) return [];
|
|
984
2181
|
const sx = s % mw, sy = s / mw | 0;
|
|
@@ -1068,11 +2265,27 @@ function ringArea(pts) {
|
|
|
1068
2265
|
|
|
1069
2266
|
// ../web/src/lib/detectRooms.ts
|
|
1070
2267
|
var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
|
|
2268
|
+
function oneClickArgs(maskPxPerFt) {
|
|
2269
|
+
const mppf = Number.isFinite(maskPxPerFt) && maskPxPerFt > 0 ? maskPxPerFt : 0;
|
|
2270
|
+
return {
|
|
2271
|
+
mppf,
|
|
2272
|
+
scaleBlind: mppf <= 0,
|
|
2273
|
+
radii: sealRadiiFor(mppf),
|
|
2274
|
+
wedgeCapPx: doorWedgeCapPx(mppf),
|
|
2275
|
+
minPassPx: minPassRadiusFor(mppf)
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
function floodAtSeed(maskObj, ix, iy, sensitivity = SENS_BALANCED, maskPxPerFt = maskObj.mppf || 0) {
|
|
2279
|
+
const a = oneClickArgs(maskPxPerFt);
|
|
2280
|
+
return floodRegionSealed(maskObj, ix, iy, sensitivity, a.radii, a.wedgeCapPx, a.minPassPx);
|
|
2281
|
+
}
|
|
1071
2282
|
var BUBBLE_RATIO = 2.5;
|
|
1072
|
-
function seedLadderPx(b) {
|
|
2283
|
+
function seedLadderPx(b, first = "anchor") {
|
|
1073
2284
|
const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
|
|
1074
2285
|
const h = Math.max(b.y1 - b.y0, 1);
|
|
1075
|
-
|
|
2286
|
+
const center = [cx, cy], below = [cx, cy + 2 * h];
|
|
2287
|
+
const rest = [[cx, cy - 2 * h], [cx, cy + 3.5 * h]];
|
|
2288
|
+
return first === "below-box" ? [below, center, ...rest] : [center, below, ...rest];
|
|
1076
2289
|
}
|
|
1077
2290
|
function isLabelBubblePx(ring, b) {
|
|
1078
2291
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
@@ -1090,7 +2303,8 @@ function isLabelBubblePx(ring, b) {
|
|
|
1090
2303
|
var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
|
|
1091
2304
|
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
2305
|
var norm = (s) => (s || "").trim().toUpperCase();
|
|
1093
|
-
var
|
|
2306
|
+
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;
|
|
2307
|
+
var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
|
|
1094
2308
|
var ROLE_SIGNALS = [
|
|
1095
2309
|
{ re: /DEMOLITION\s+PLAN|DEMO\s+PLAN/, role: "demolition", conf: 0.9 },
|
|
1096
2310
|
{ re: /FINISH\s+PLAN|FLOOR\s+PLAN|FURNITURE\s+PLAN|CEILING\s+PLAN/, role: "plan", conf: 0.85 },
|
|
@@ -1125,6 +2339,22 @@ function classifySheetRole(sheet) {
|
|
|
1125
2339
|
evidence: { sheet: sheet.key, text: best.span.str.trim(), bbox: bboxOf(best.span) }
|
|
1126
2340
|
};
|
|
1127
2341
|
}
|
|
2342
|
+
var BUILDING_RE = /\b(?:BUILDING|BLDG\.?)\s+([A-Z]\d?|\d{1,2})\b/g;
|
|
2343
|
+
var DESIGNATOR_RE = /^([A-Z]\d?|\d{1,2}|[A-Z]{2})$/;
|
|
2344
|
+
function buildingMentions(text) {
|
|
2345
|
+
const u = norm(text);
|
|
2346
|
+
if (u.length > 80 || REFERENCE_RE.test(u)) return [];
|
|
2347
|
+
return [...u.matchAll(BUILDING_RE)].map((m) => m[1]);
|
|
2348
|
+
}
|
|
2349
|
+
function sheetBuilding(sheet) {
|
|
2350
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2351
|
+
for (const sp of sheet.spans) {
|
|
2352
|
+
for (const b of buildingMentions(sp.str)) if (!seen.has(b)) seen.set(b, sp);
|
|
2353
|
+
}
|
|
2354
|
+
if (seen.size !== 1) return null;
|
|
2355
|
+
const [building, span] = [...seen.entries()][0];
|
|
2356
|
+
return { building, evidence: { sheet: sheet.key, text: span.str.trim(), bbox: bboxOf(span) } };
|
|
2357
|
+
}
|
|
1128
2358
|
function clusterRows(spans) {
|
|
1129
2359
|
const toks = spans.filter((t) => t.str && t.str.trim()).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1130
2360
|
const rows = [];
|
|
@@ -1142,7 +2372,8 @@ function clusterRows(spans) {
|
|
|
1142
2372
|
if (cur.length) rows.push(cur);
|
|
1143
2373
|
return rows.map((r) => r.sort((a, b) => a.x - b.x));
|
|
1144
2374
|
}
|
|
1145
|
-
var
|
|
2375
|
+
var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
|
|
2376
|
+
var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "BLDG", "BUILDING"];
|
|
1146
2377
|
var FINISH_HEADERS = ["CODE", "MARK", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN"];
|
|
1147
2378
|
var headerLabel = (s, vocab) => {
|
|
1148
2379
|
for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w)) return w;
|
|
@@ -1164,33 +2395,101 @@ function findHeaderRow(rows, vocab, required, minHits) {
|
|
|
1164
2395
|
}
|
|
1165
2396
|
return null;
|
|
1166
2397
|
}
|
|
2398
|
+
function findRotatedHeader(vert, vocab, required, minHits) {
|
|
2399
|
+
const cands = vert.map((sp) => ({ sp, label: headerLabel(sp.str, vocab) })).filter((c) => !!c.label).sort((a, b) => a.sp.x - b.sp.x);
|
|
2400
|
+
let band = [];
|
|
2401
|
+
let y0 = 0, y1 = 0;
|
|
2402
|
+
const flush = () => {
|
|
2403
|
+
const seen = new Set(band.map((c) => c.label));
|
|
2404
|
+
if (band.length < minHits || seen.size < minHits || !required.some((r) => seen.has(r))) return null;
|
|
2405
|
+
const anchors = [];
|
|
2406
|
+
const used = /* @__PURE__ */ new Set();
|
|
2407
|
+
for (const c of band) if (!used.has(c.label)) {
|
|
2408
|
+
used.add(c.label);
|
|
2409
|
+
anchors.push({ label: c.label, x: c.sp.x + (c.sp.w || 0) / 2 });
|
|
2410
|
+
}
|
|
2411
|
+
return { anchors: anchors.sort((a, b) => a.x - b.x), top: y0, bottom: y1, spans: band.map((c) => c.sp) };
|
|
2412
|
+
};
|
|
2413
|
+
for (const c of cands) {
|
|
2414
|
+
const cy0 = c.sp.y, cy1 = c.sp.y + (c.sp.h || 0);
|
|
2415
|
+
if (band.length && (cy0 > y1 || cy1 < y0)) {
|
|
2416
|
+
const done = flush();
|
|
2417
|
+
if (done) return done;
|
|
2418
|
+
band = [];
|
|
2419
|
+
}
|
|
2420
|
+
if (!band.length) {
|
|
2421
|
+
y0 = cy0;
|
|
2422
|
+
y1 = cy1;
|
|
2423
|
+
} else {
|
|
2424
|
+
y0 = Math.min(y0, cy0);
|
|
2425
|
+
y1 = Math.max(y1, cy1);
|
|
2426
|
+
}
|
|
2427
|
+
band.push(c);
|
|
2428
|
+
}
|
|
2429
|
+
return band.length ? flush() : null;
|
|
2430
|
+
}
|
|
1167
2431
|
var nearestAnchor = (x, anchors) => {
|
|
1168
2432
|
let best = anchors[0];
|
|
1169
2433
|
for (const a of anchors) if (Math.abs(a.x - x) < Math.abs(best.x - x)) best = a;
|
|
1170
2434
|
return best.label;
|
|
1171
2435
|
};
|
|
2436
|
+
function bandLimits(anchors) {
|
|
2437
|
+
const gaps = anchors.slice(1).map((a, i) => a.x - anchors[i].x).sort((a, b) => a - b);
|
|
2438
|
+
const medGap = gaps.length ? gaps[gaps.length >> 1] : 150;
|
|
2439
|
+
return { x0: anchors[0].x - Math.max(80, medGap / 2), x1: anchors[anchors.length - 1].x + Math.max(300, medGap * 3), medGap };
|
|
2440
|
+
}
|
|
1172
2441
|
var CODE_RE = /^[A-Z]{1,4}(-?[A-Z0-9]{1,4})?$/;
|
|
1173
2442
|
var ROW_KEY_RE = /^\d{1,3}[A-Z]{0,2}$/;
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
if (
|
|
1178
|
-
|
|
1179
|
-
const
|
|
2443
|
+
var QUALIFIED_KEY_RE = /^([A-Z]{1,2})-(\d{1,3}[A-Z]{0,2})$/;
|
|
2444
|
+
function rowKeyOf(raw, kind, buildings) {
|
|
2445
|
+
const key = norm(raw).replace(/[^A-Z0-9-]/g, "");
|
|
2446
|
+
if (kind === "finish") return CODE_RE.test(key) ? { key } : null;
|
|
2447
|
+
if (ROW_KEY_RE.test(key)) return { key };
|
|
2448
|
+
const q = key.match(QUALIFIED_KEY_RE);
|
|
2449
|
+
if (q && buildings?.has(q[1])) return { key, building: q[1] };
|
|
2450
|
+
return null;
|
|
2451
|
+
}
|
|
2452
|
+
var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
|
|
2453
|
+
function extractTable(sheet, kind, opts = {}) {
|
|
2454
|
+
const horiz = sheet.spans.filter((s) => !isVertical(s));
|
|
2455
|
+
const vert = sheet.spans.filter(isVertical);
|
|
2456
|
+
const rows = clusterRows(horiz);
|
|
2457
|
+
const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
|
|
2458
|
+
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK"];
|
|
2459
|
+
const minHits = kind === "room-finish" ? 4 : 3;
|
|
2460
|
+
let anchors;
|
|
2461
|
+
let headerSpans;
|
|
2462
|
+
let dataFrom;
|
|
2463
|
+
let dataBelowY = -Infinity;
|
|
2464
|
+
let titleFrom;
|
|
2465
|
+
let rotated = false;
|
|
2466
|
+
const flat = findHeaderRow(rows, vocab, required, minHits);
|
|
2467
|
+
if (flat) {
|
|
2468
|
+
anchors = flat.anchors;
|
|
2469
|
+
headerSpans = rows[flat.rowIndex];
|
|
2470
|
+
dataFrom = flat.rowIndex + 1;
|
|
2471
|
+
titleFrom = flat.rowIndex - 1;
|
|
2472
|
+
} else {
|
|
2473
|
+
const rot = findRotatedHeader(vert, vocab, required, minHits);
|
|
2474
|
+
if (!rot) return null;
|
|
2475
|
+
rotated = true;
|
|
2476
|
+
anchors = rot.anchors;
|
|
2477
|
+
headerSpans = rot.spans;
|
|
2478
|
+
dataBelowY = rot.bottom - 2;
|
|
2479
|
+
dataFrom = 0;
|
|
2480
|
+
titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
|
|
2481
|
+
if (titleFrom < -1) titleFrom = rows.length - 1;
|
|
2482
|
+
}
|
|
1180
2483
|
const out = [];
|
|
1181
2484
|
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++) {
|
|
2485
|
+
for (const t of headerSpans) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
2486
|
+
const { x0, x1 } = bandLimits(anchors);
|
|
2487
|
+
for (let i = Math.max(dataFrom, 0); i < rows.length; i++) {
|
|
2488
|
+
if (rotated && rowY(rows[i]) <= dataBelowY) continue;
|
|
1189
2489
|
const inBand = rows[i].filter((t) => t.x >= x0 && t.x <= x1);
|
|
1190
2490
|
if (!inBand.length) continue;
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
if (!keyRe.test(key)) continue;
|
|
2491
|
+
const keyed = rowKeyOf(inBand[0].str, kind, opts.buildings);
|
|
2492
|
+
if (!keyed) continue;
|
|
1194
2493
|
const cells = {};
|
|
1195
2494
|
for (const t of inBand) {
|
|
1196
2495
|
const label = nearestAnchor(t.x, anchors);
|
|
@@ -1201,28 +2500,101 @@ function extractTable(sheet, kind) {
|
|
|
1201
2500
|
}
|
|
1202
2501
|
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
1203
2502
|
}
|
|
1204
|
-
|
|
2503
|
+
const row = { key: keyed.key, sheet: sheet.key, cells };
|
|
2504
|
+
const cellB = norm(cells.BLDG?.text || cells.BUILDING?.text || "");
|
|
2505
|
+
const b = keyed.building ?? (DESIGNATOR_RE.test(cellB) ? cellB : void 0);
|
|
2506
|
+
if (b) row.building = b;
|
|
2507
|
+
out.push(row);
|
|
1205
2508
|
}
|
|
1206
2509
|
if (!out.length) return null;
|
|
1207
2510
|
let title = null;
|
|
1208
|
-
for (let i =
|
|
2511
|
+
for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
|
|
1209
2512
|
const hit = rows[i].find((t) => /SCHEDULE/.test(norm(t.str)) && t.x >= x0 && t.x <= x1);
|
|
1210
2513
|
if (hit) title = { sheet: sheet.key, text: hit.str.trim(), bbox: bboxOf(hit) };
|
|
1211
2514
|
}
|
|
1212
|
-
|
|
2515
|
+
const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
|
|
2516
|
+
if (rotated) table.rotated_headers = true;
|
|
2517
|
+
return table;
|
|
2518
|
+
}
|
|
2519
|
+
var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
|
|
2520
|
+
var isContinuationTitle = (text) => {
|
|
2521
|
+
const u = norm(text);
|
|
2522
|
+
return /SCHEDULE/.test(u) && CONT_TAIL_RE.test(u);
|
|
2523
|
+
};
|
|
2524
|
+
var baseTitleOf = (text) => norm(text).replace(CONT_TAIL_RE, "").replace(/[\s\-–—:.,()]+$/, "").trim();
|
|
2525
|
+
function findContinuationBase(logical, frag) {
|
|
2526
|
+
const sameKind = logical.filter((t) => t.kind === frag.kind && (frag.building == null || t.building == null || t.building === frag.building));
|
|
2527
|
+
if (!sameKind.length) return null;
|
|
2528
|
+
const fragBase = baseTitleOf(frag.title.text);
|
|
2529
|
+
const titled = sameKind.filter((t) => t.title && baseTitleOf(t.title.text) === fragBase);
|
|
2530
|
+
const pool = titled.length ? titled : sameKind;
|
|
2531
|
+
return pool[pool.length - 1];
|
|
2532
|
+
}
|
|
2533
|
+
function mergeContinuation(base, frag) {
|
|
2534
|
+
if (!base.parts) {
|
|
2535
|
+
base.parts = [{ sheet: base.sheet, title: base.title?.text || "", rows: base.rows.length, region: base.region, ...base.rotated_headers ? { rotated_headers: true } : {} }];
|
|
2536
|
+
}
|
|
2537
|
+
for (const r of frag.rows) if (r.building == null && frag.building != null) r.building = frag.building;
|
|
2538
|
+
base.parts.push({ sheet: frag.sheet, title: frag.title?.text || "", rows: frag.rows.length, region: frag.region, ...frag.rotated_headers ? { rotated_headers: true } : {} });
|
|
2539
|
+
base.rows.push(...frag.rows);
|
|
2540
|
+
}
|
|
2541
|
+
function adoptContinuationRows(sheet, titleSpan, base, buildings) {
|
|
2542
|
+
if (!base.anchors?.length || base.kind === "unknown") return null;
|
|
2543
|
+
const rows = clusterRows(sheet.spans.filter((s) => !isVertical(s)));
|
|
2544
|
+
const { x0, x1, medGap } = bandLimits(base.anchors);
|
|
2545
|
+
const keyTol = Math.max(40, medGap / 2);
|
|
2546
|
+
const out = [];
|
|
2547
|
+
let region = bboxOf(titleSpan);
|
|
2548
|
+
for (const row of rows) {
|
|
2549
|
+
if (rowY(row) <= titleSpan.y) continue;
|
|
2550
|
+
const inBand = row.filter((t) => t.x >= x0 && t.x <= x1);
|
|
2551
|
+
if (!inBand.length) continue;
|
|
2552
|
+
const keyed = rowKeyOf(inBand[0].str, base.kind, buildings);
|
|
2553
|
+
if (!keyed || Math.abs(inBand[0].x - base.anchors[0].x) > keyTol) continue;
|
|
2554
|
+
const cells = {};
|
|
2555
|
+
for (const t of inBand) {
|
|
2556
|
+
const label = nearestAnchor(t.x, base.anchors);
|
|
2557
|
+
const text = t.str.trim();
|
|
2558
|
+
if (!cells[label]) cells[label] = { text, bbox: bboxOf(t) };
|
|
2559
|
+
else {
|
|
2560
|
+
cells[label] = { text: `${cells[label].text} ${text}`, bbox: merge(cells[label].bbox, bboxOf(t)) };
|
|
2561
|
+
}
|
|
2562
|
+
region = merge(region, bboxOf(t));
|
|
2563
|
+
}
|
|
2564
|
+
const r = { key: keyed.key, sheet: sheet.key, cells };
|
|
2565
|
+
if (keyed.building) r.building = keyed.building;
|
|
2566
|
+
out.push(r);
|
|
2567
|
+
}
|
|
2568
|
+
if (!out.length) return null;
|
|
2569
|
+
return {
|
|
2570
|
+
kind: base.kind,
|
|
2571
|
+
sheet: sheet.key,
|
|
2572
|
+
title: { sheet: sheet.key, text: titleSpan.str.trim(), bbox: bboxOf(titleSpan) },
|
|
2573
|
+
headers: base.headers,
|
|
2574
|
+
rows: out,
|
|
2575
|
+
region
|
|
2576
|
+
};
|
|
1213
2577
|
}
|
|
1214
|
-
|
|
2578
|
+
var QUALIFIED_TAG_RE = /^([A-Z]{1,2})-(\d{2,3}[A-Z]?)$/;
|
|
2579
|
+
function roomTags(sheet, opts = {}) {
|
|
1215
2580
|
const out = [];
|
|
1216
2581
|
const spans = sheet.spans;
|
|
2582
|
+
const accept = (t) => {
|
|
2583
|
+
if (ROOM_LABEL_RE.test(t)) return { ok: true };
|
|
2584
|
+
const q = norm(t).match(QUALIFIED_TAG_RE);
|
|
2585
|
+
if (q && opts.buildings?.has(q[1]) && !opts.exclude?.has(norm(t).replace(/[^A-Z0-9]/g, ""))) return { ok: true, building: q[1] };
|
|
2586
|
+
return { ok: false };
|
|
2587
|
+
};
|
|
1217
2588
|
for (const sp of spans) {
|
|
1218
2589
|
const t = sp.str.trim();
|
|
1219
|
-
|
|
2590
|
+
const a = accept(t);
|
|
2591
|
+
if (!a.ok) continue;
|
|
1220
2592
|
const b = bboxOf(sp);
|
|
1221
2593
|
const hgt = Math.max(sp.h || 8, 6);
|
|
1222
2594
|
let name = "";
|
|
1223
2595
|
let best = Infinity;
|
|
1224
2596
|
for (const cand of spans) {
|
|
1225
|
-
if (cand === sp ||
|
|
2597
|
+
if (cand === sp || accept(cand.str.trim()).ok) continue;
|
|
1226
2598
|
const cb = bboxOf(cand);
|
|
1227
2599
|
const dy = b[1] - cb[3];
|
|
1228
2600
|
if (dy < -hgt * 0.2 || dy > hgt * 2.2) continue;
|
|
@@ -1233,7 +2605,9 @@ function roomTags(sheet) {
|
|
|
1233
2605
|
name = cand.str.trim();
|
|
1234
2606
|
}
|
|
1235
2607
|
}
|
|
1236
|
-
|
|
2608
|
+
const tag = { tag: t, name, sheet: sheet.key, bbox: b };
|
|
2609
|
+
if (a.building) tag.building = a.building;
|
|
2610
|
+
out.push(tag);
|
|
1237
2611
|
}
|
|
1238
2612
|
return out;
|
|
1239
2613
|
}
|
|
@@ -1248,65 +2622,190 @@ function detailCallouts(sheet) {
|
|
|
1248
2622
|
}
|
|
1249
2623
|
function buildSheetGraph(sheets) {
|
|
1250
2624
|
const withText = sheets.filter((s) => s.spans.length > 0);
|
|
1251
|
-
if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [] };
|
|
2625
|
+
if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], notes: [] };
|
|
2626
|
+
const notes = [];
|
|
2627
|
+
const ctxBySheet = /* @__PURE__ */ new Map();
|
|
2628
|
+
const buildings = /* @__PURE__ */ new Set();
|
|
2629
|
+
for (const s of withText) {
|
|
2630
|
+
for (const sp of s.spans) for (const b of buildingMentions(sp.str)) buildings.add(b);
|
|
2631
|
+
const ctx = sheetBuilding(s);
|
|
2632
|
+
if (ctx) ctxBySheet.set(s.key, ctx.building);
|
|
2633
|
+
}
|
|
2634
|
+
const roles = /* @__PURE__ */ new Map();
|
|
2635
|
+
const fragments = [];
|
|
2636
|
+
const fragmentKinds = /* @__PURE__ */ new Map();
|
|
2637
|
+
for (const s of withText) {
|
|
2638
|
+
roles.set(s.key, classifySheetRole(s));
|
|
2639
|
+
for (const kind of ["room-finish", "finish"]) {
|
|
2640
|
+
const t = extractTable(s, kind, { buildings });
|
|
2641
|
+
if (!t) continue;
|
|
2642
|
+
const titleB = t.title ? buildingMentions(t.title.text) : [];
|
|
2643
|
+
const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
|
|
2644
|
+
if (b) t.building = b;
|
|
2645
|
+
for (const r of t.rows) if (r.building) buildings.add(r.building);
|
|
2646
|
+
fragments.push(t);
|
|
2647
|
+
if (!fragmentKinds.has(s.key)) fragmentKinds.set(s.key, /* @__PURE__ */ new Set());
|
|
2648
|
+
fragmentKinds.get(s.key).add(kind);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
1252
2651
|
const tables = [];
|
|
2652
|
+
for (const f of fragments) {
|
|
2653
|
+
const base = f.title && isContinuationTitle(f.title.text) ? findContinuationBase(tables, f) : null;
|
|
2654
|
+
if (base) mergeContinuation(base, f);
|
|
2655
|
+
else {
|
|
2656
|
+
if (f.title && isContinuationTitle(f.title.text)) {
|
|
2657
|
+
notes.push(`${f.sheet}: "${f.title.text}" reads as a continuation but no earlier ${f.kind} table matches \u2014 kept as a standalone table`);
|
|
2658
|
+
}
|
|
2659
|
+
tables.push(f);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
for (const s of withText) {
|
|
2663
|
+
for (const sp of s.spans) {
|
|
2664
|
+
const text = sp.str.trim();
|
|
2665
|
+
if (!isContinuationTitle(text)) continue;
|
|
2666
|
+
const fragBase = baseTitleOf(text);
|
|
2667
|
+
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));
|
|
2668
|
+
if (!base || fragmentKinds.get(s.key)?.has(base.kind)) continue;
|
|
2669
|
+
const adopted = adoptContinuationRows(s, sp, base, buildings);
|
|
2670
|
+
if (adopted) {
|
|
2671
|
+
if (adopted.building == null && ctxBySheet.get(s.key)) adopted.building = ctxBySheet.get(s.key);
|
|
2672
|
+
mergeContinuation(base, adopted);
|
|
2673
|
+
for (const r of adopted.rows) if (r.building) buildings.add(r.building);
|
|
2674
|
+
} else {
|
|
2675
|
+
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`);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
const sheetNumbers = /* @__PURE__ */ new Set();
|
|
2680
|
+
for (const s of sheets) {
|
|
2681
|
+
const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
|
|
2682
|
+
if (n) sheetNumbers.add(n);
|
|
2683
|
+
}
|
|
1253
2684
|
const rooms = [];
|
|
1254
2685
|
const callouts = [];
|
|
1255
|
-
const outSheets = [];
|
|
1256
2686
|
for (const s of withText) {
|
|
1257
|
-
const role =
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
tables.push(t);
|
|
2687
|
+
const role = roles.get(s.key);
|
|
2688
|
+
if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") {
|
|
2689
|
+
const ctxB = ctxBySheet.get(s.key);
|
|
2690
|
+
for (const r of roomTags(s, { buildings, exclude: sheetNumbers })) {
|
|
2691
|
+
if (r.building == null && ctxB) r.building = ctxB;
|
|
2692
|
+
rooms.push(r);
|
|
1264
2693
|
}
|
|
1265
2694
|
}
|
|
1266
|
-
if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") rooms.push(...roomTags(s));
|
|
1267
2695
|
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
2696
|
}
|
|
1276
|
-
|
|
2697
|
+
const outSheets = withText.map((s) => {
|
|
2698
|
+
const role = roles.get(s.key);
|
|
2699
|
+
const schedules = [];
|
|
2700
|
+
for (const t of tables) {
|
|
2701
|
+
const parts = t.parts ?? [{ sheet: t.sheet, title: t.title?.text || "", rows: t.rows.length, region: t.region, ...t.rotated_headers ? { rotated_headers: true } : {} }];
|
|
2702
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2703
|
+
const p = parts[i];
|
|
2704
|
+
if (p.sheet !== s.key) continue;
|
|
2705
|
+
schedules.push({
|
|
2706
|
+
kind: t.kind,
|
|
2707
|
+
title: p.title || t.title?.text || "",
|
|
2708
|
+
rows: p.rows,
|
|
2709
|
+
region: p.region,
|
|
2710
|
+
...i > 0 ? { continues: t.sheet } : {},
|
|
2711
|
+
...p.rotated_headers ? { rotated_headers: true } : {}
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const entry = { key: s.key, role: role.role, confidence: role.confidence, evidence: role.evidence, schedules };
|
|
2716
|
+
const b = ctxBySheet.get(s.key);
|
|
2717
|
+
if (b) entry.building = b;
|
|
2718
|
+
return entry;
|
|
2719
|
+
});
|
|
2720
|
+
return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), notes };
|
|
1277
2721
|
}
|
|
1278
2722
|
var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
|
|
1279
2723
|
function resolveTag(graph, tag) {
|
|
1280
|
-
const t = norm(tag);
|
|
1281
|
-
const
|
|
2724
|
+
const t = norm(tag).replace(/\s+/g, "");
|
|
2725
|
+
const q = t.match(QUALIFIED_KEY_RE);
|
|
2726
|
+
const wantB = q ? q[1] : null;
|
|
2727
|
+
const num2 = q ? q[2] : t;
|
|
2728
|
+
const rooms = graph.rooms.filter((r2) => {
|
|
2729
|
+
const rt = norm(r2.tag).replace(/\s+/g, "");
|
|
2730
|
+
return rt === t || numOf(rt) === num2;
|
|
2731
|
+
});
|
|
2732
|
+
const pickRoom = (b) => {
|
|
2733
|
+
if (b) return rooms.find((r2) => r2.building === b) ?? rooms.find((r2) => !r2.building) ?? null;
|
|
2734
|
+
const distinct = new Set(rooms.map((r2) => r2.building || ""));
|
|
2735
|
+
return distinct.size > 1 ? null : rooms[0] ?? null;
|
|
2736
|
+
};
|
|
1282
2737
|
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
|
-
|
|
2738
|
+
if (!roomTables.length) return { status: "unresolved", tag: t, room: pickRoom(wantB), reason: "no room-finish schedule found in the set" };
|
|
2739
|
+
const cands = [];
|
|
2740
|
+
for (const tab2 of roomTables) {
|
|
2741
|
+
for (const r2 of tab2.rows) {
|
|
2742
|
+
if (numOf(norm(r2.key)) !== num2) continue;
|
|
2743
|
+
const b = r2.building ?? tab2.building;
|
|
2744
|
+
cands.push({ tab: tab2, r: r2, ...b ? { building: b } : {} });
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
const describe = (c) => `${c.building ? `building ${c.building}` : "no building"} (${c.r.sheet})`;
|
|
2748
|
+
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` });
|
|
2749
|
+
let chosen;
|
|
2750
|
+
if (wantB) {
|
|
2751
|
+
const filtered = cands.filter((c) => c.building === wantB);
|
|
2752
|
+
if (!filtered.length) {
|
|
2753
|
+
if (!graph.buildings.length) {
|
|
2754
|
+
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) } : {} };
|
|
2755
|
+
}
|
|
2756
|
+
if (!graph.buildings.includes(wantB)) {
|
|
2757
|
+
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) } : {} };
|
|
2758
|
+
}
|
|
2759
|
+
if (cands.length) {
|
|
2760
|
+
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) };
|
|
2761
|
+
}
|
|
2762
|
+
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` };
|
|
2763
|
+
}
|
|
2764
|
+
if (filtered.length > 1) {
|
|
2765
|
+
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) };
|
|
2766
|
+
}
|
|
2767
|
+
chosen = filtered[0];
|
|
2768
|
+
} else {
|
|
2769
|
+
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` };
|
|
2770
|
+
if (cands.length > 1) {
|
|
2771
|
+
const distinctB = [...new Set(cands.filter((c) => c.building).map((c) => c.building))];
|
|
2772
|
+
if (distinctB.length > 1) {
|
|
2773
|
+
return {
|
|
2774
|
+
status: "unresolved",
|
|
2775
|
+
tag: t,
|
|
2776
|
+
room: null,
|
|
2777
|
+
reason: `ambiguous: room ${num2} appears in ${distinctB.length} buildings \u2014 ${cands.map(describe).join(", ")} \u2014 qualify the tag, e.g. "${distinctB[0]}-${num2}"`,
|
|
2778
|
+
candidates: cands.map(wire)
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
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) };
|
|
2782
|
+
}
|
|
2783
|
+
chosen = cands[0];
|
|
2784
|
+
}
|
|
2785
|
+
const { tab, r } = chosen;
|
|
2786
|
+
const room = pickRoom(chosen.building ?? null);
|
|
1288
2787
|
const finTables = graph.tables.filter((x) => x.kind === "finish");
|
|
1289
2788
|
const finishes = [];
|
|
1290
|
-
const sources = [{ sheet:
|
|
2789
|
+
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
2790
|
if (room) sources.unshift({ sheet: room.sheet, text: `${room.name ? room.name + " " : ""}${room.tag}`.trim(), bbox: room.bbox });
|
|
1292
2791
|
for (const surface of SURFACE_HEADERS) {
|
|
1293
2792
|
const cell = r.cells[surface];
|
|
1294
2793
|
if (!cell || !cell.text.trim()) continue;
|
|
1295
2794
|
const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
|
|
1296
|
-
const fin = { surface, code: cell.text.trim(), source: { sheet:
|
|
2795
|
+
const fin = { surface, code: cell.text.trim(), source: { sheet: r.sheet, text: cell.text.trim(), bbox: cell.bbox } };
|
|
1297
2796
|
for (const ft of finTables) {
|
|
1298
2797
|
const def = ft.rows.find((fr) => norm(fr.key) === code);
|
|
1299
2798
|
if (def) {
|
|
1300
2799
|
const cells = {};
|
|
1301
2800
|
for (const [k, v] of Object.entries(def.cells)) cells[k] = v.text;
|
|
1302
|
-
fin.definition = { cells, source: { sheet:
|
|
2801
|
+
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
2802
|
break;
|
|
1304
2803
|
}
|
|
1305
2804
|
}
|
|
1306
2805
|
finishes.push(fin);
|
|
1307
2806
|
}
|
|
1308
2807
|
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 };
|
|
2808
|
+
return { status: "resolved", tag: t, room, ...chosen.building ? { building: chosen.building } : {}, finishes, sources };
|
|
1310
2809
|
}
|
|
1311
2810
|
|
|
1312
2811
|
// src/format.ts
|
|
@@ -1329,6 +2828,82 @@ var fail = (err) => ({
|
|
|
1329
2828
|
var round2 = (n) => +n.toFixed(2);
|
|
1330
2829
|
var round1 = (n) => +n.toFixed(1);
|
|
1331
2830
|
|
|
2831
|
+
// ../web/src/lib/confidence.ts
|
|
2832
|
+
var CONF_RASTER = 0.9;
|
|
2833
|
+
var CONF_HATCH = 0.95;
|
|
2834
|
+
var CONF_HATCH_TIER = { bounded: CONF_HATCH, trapped: 0.93, override: 0.85 };
|
|
2835
|
+
var CONF_WEDGE = 0.97;
|
|
2836
|
+
var WEDGE_ANNEX_REF = 0.1;
|
|
2837
|
+
var CONF_COARSE = 0.9;
|
|
2838
|
+
var CONF_MINPASS = 0.99;
|
|
2839
|
+
var CONF_MINPASS_SOLE = 0.85;
|
|
2840
|
+
var CONF_CURVE_K = 0.5;
|
|
2841
|
+
var ROOM_PLAUSIBLE_SF = 5e3;
|
|
2842
|
+
var ROOM_ABSURD_SF = 5e4;
|
|
2843
|
+
var CONF_OVERSIZE_MAX = 0.35;
|
|
2844
|
+
var SEAL_VIRTUAL_DEFAULT = 0.1;
|
|
2845
|
+
var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
2846
|
+
function floodSignals(f, opts = {}) {
|
|
2847
|
+
return {
|
|
2848
|
+
raster: opts.raster,
|
|
2849
|
+
hatchFiltered: f.hatchFiltered,
|
|
2850
|
+
hatchTier: f.hatchTier,
|
|
2851
|
+
sealedPx: f.sealedPx,
|
|
2852
|
+
virtualFrac: f.virtualFrac,
|
|
2853
|
+
wedges: f.wedges,
|
|
2854
|
+
wedgeGrowth: f.wedgeGrowth,
|
|
2855
|
+
curveFrac: f.curveFrac,
|
|
2856
|
+
minPassPx: f.minPassPx,
|
|
2857
|
+
minPassDelta: f.minPassDelta,
|
|
2858
|
+
areaSF: opts.areaSF,
|
|
2859
|
+
mppf: f.mppf ?? opts.mppf
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
function traceConfidence(s) {
|
|
2863
|
+
let score = 1;
|
|
2864
|
+
const factors = [];
|
|
2865
|
+
if (s.raster) {
|
|
2866
|
+
score *= CONF_RASTER;
|
|
2867
|
+
factors.push("raster-traced");
|
|
2868
|
+
}
|
|
2869
|
+
if (s.hatchFiltered) {
|
|
2870
|
+
const tier = s.hatchTier ?? "bounded";
|
|
2871
|
+
score *= CONF_HATCH_TIER[tier];
|
|
2872
|
+
factors.push(`hatch-filtered(${tier})`);
|
|
2873
|
+
}
|
|
2874
|
+
if (s.sealedPx) {
|
|
2875
|
+
const vf = typeof s.virtualFrac === "number" ? clamp(s.virtualFrac, 0, 0.25) : SEAL_VIRTUAL_DEFAULT;
|
|
2876
|
+
score *= 1 - vf;
|
|
2877
|
+
factors.push(`sealed-opening(${Math.round(vf * 100)}% synthetic boundary)`);
|
|
2878
|
+
}
|
|
2879
|
+
if (s.minPassDelta) {
|
|
2880
|
+
const sole = s.minPassDelta >= 1;
|
|
2881
|
+
score *= sole ? CONF_MINPASS_SOLE : CONF_MINPASS;
|
|
2882
|
+
factors.push(sole ? "undecidable-passage(the drawn linework does not enclose this space)" : `min-passage-rule(${(s.minPassDelta * 100).toFixed(1)}% of the verbatim flood removed)`);
|
|
2883
|
+
}
|
|
2884
|
+
if (s.wedges) {
|
|
2885
|
+
const annex = typeof s.wedgeGrowth === "number" && s.wedgeGrowth > 1 ? (s.wedgeGrowth - 1) / s.wedgeGrowth : void 0;
|
|
2886
|
+
const w = annex === void 0 ? 1 : clamp(annex / WEDGE_ANNEX_REF, 0, 1);
|
|
2887
|
+
score *= 1 - (1 - CONF_WEDGE) * w;
|
|
2888
|
+
factors.push(annex === void 0 ? "door-swing-crossed" : `door-swing-crossed(${(annex * 100).toFixed(1)}% annexed swing)`);
|
|
2889
|
+
}
|
|
2890
|
+
if (s.curveFrac) {
|
|
2891
|
+
const cf = clamp(s.curveFrac, 0, 1);
|
|
2892
|
+
score *= 1 - CONF_CURVE_K * cf;
|
|
2893
|
+
factors.push(`curve-bounded(${Math.round(cf * 100)}% of the boundary)`);
|
|
2894
|
+
}
|
|
2895
|
+
if (typeof s.areaSF === "number" && s.areaSF > ROOM_PLAUSIBLE_SF) {
|
|
2896
|
+
const over = clamp((s.areaSF - ROOM_PLAUSIBLE_SF) / (ROOM_ABSURD_SF - ROOM_PLAUSIBLE_SF), 0, 1);
|
|
2897
|
+
score *= 1 - CONF_OVERSIZE_MAX * over;
|
|
2898
|
+
factors.push(`oversize-for-one-room(${Math.round(s.areaSF).toLocaleString("en-US")} SF)`);
|
|
2899
|
+
}
|
|
2900
|
+
if (typeof s.mppf === "number" && s.mppf > 0 && s.mppf < DETERMINISM_MIN_MPPF) {
|
|
2901
|
+
score *= CONF_COARSE;
|
|
2902
|
+
factors.push("coarse-mask");
|
|
2903
|
+
}
|
|
2904
|
+
return { score: +score.toFixed(2), factors };
|
|
2905
|
+
}
|
|
2906
|
+
|
|
1332
2907
|
// ../web/src/lib/rastermask.ts
|
|
1333
2908
|
var RASTER_MIN_IMG_FRAC = 0.1;
|
|
1334
2909
|
var RASTER_MIN_SEGS = 500;
|
|
@@ -1425,7 +3000,15 @@ function buildRasterMask(rgba, mw, mh, ws = 1, opts = {}) {
|
|
|
1425
3000
|
if (polarityMean < INVERT_MEAN) for (let i = 0; i < n; i++) gray[i] = 255 - gray[i];
|
|
1426
3001
|
let mask = adaptiveThreshold(gray, mw, mh, opts.t, opts.absInk);
|
|
1427
3002
|
if (opts.bridge !== false) mask = closeMask(mask, mw, mh);
|
|
1428
|
-
return {
|
|
3003
|
+
return {
|
|
3004
|
+
mask,
|
|
3005
|
+
mw,
|
|
3006
|
+
mh,
|
|
3007
|
+
ws,
|
|
3008
|
+
softCount: 0,
|
|
3009
|
+
...opts.dpiLimited ? { dpiLimited: true } : {},
|
|
3010
|
+
...opts.scanDpi ? { scanDpi: opts.scanDpi } : {}
|
|
3011
|
+
};
|
|
1429
3012
|
}
|
|
1430
3013
|
|
|
1431
3014
|
// ../web/src/lib/symbolsweep.ts
|
|
@@ -1485,12 +3068,7 @@ var EndpointGrid = class {
|
|
|
1485
3068
|
}
|
|
1486
3069
|
};
|
|
1487
3070
|
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);
|
|
3071
|
+
function fingerprintSymbol(segs, seedRect) {
|
|
1494
3072
|
const n = segs.length >> 2;
|
|
1495
3073
|
const rx0 = Math.min(seedRect[0][0], seedRect[1][0]), rx1 = Math.max(seedRect[0][0], seedRect[1][0]);
|
|
1496
3074
|
const ry0 = Math.min(seedRect[0][1], seedRect[1][1]), ry1 = Math.max(seedRect[0][1], seedRect[1][1]);
|
|
@@ -1529,6 +3107,22 @@ function sweepSymbols(segs, seedRect, opts = {}) {
|
|
|
1529
3107
|
sbx1 = Math.max(sbx1, segs[i * 4], segs[i * 4 + 2]);
|
|
1530
3108
|
sby1 = Math.max(sby1, segs[i * 4 + 1], segs[i * 4 + 3]);
|
|
1531
3109
|
}
|
|
3110
|
+
return {
|
|
3111
|
+
rel,
|
|
3112
|
+
totalLen,
|
|
3113
|
+
segments: seedIdx.length,
|
|
3114
|
+
center: [seedCx, seedCy],
|
|
3115
|
+
footprint: Math.hypot(sbx1 - sbx0, sby1 - sby0)
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
function matchSymbol(fp, segs, opts = {}) {
|
|
3119
|
+
const tol = opts.tolPx ?? SWEEP_TOL_PX;
|
|
3120
|
+
const scoreHigh = opts.scoreHigh ?? SWEEP_SCORE_HIGH;
|
|
3121
|
+
const scoreLow = opts.scoreLow ?? SWEEP_SCORE_LOW;
|
|
3122
|
+
const maxCandidates = opts.maxCandidates ?? SWEEP_MAX_CANDIDATES;
|
|
3123
|
+
const xforms = transformsFor(opts.rotations ?? true, opts.mirror ?? true);
|
|
3124
|
+
const n = segs.length >> 2;
|
|
3125
|
+
const { rel, totalLen } = fp;
|
|
1532
3126
|
const lenBucket = /* @__PURE__ */ new Map();
|
|
1533
3127
|
for (let i = 0; i < n; i++) {
|
|
1534
3128
|
const b = Math.round(segLen(segs, i));
|
|
@@ -1625,174 +3219,114 @@ function sweepSymbols(segs, seedRect, opts = {}) {
|
|
|
1625
3219
|
twin.xf = s.xf;
|
|
1626
3220
|
}
|
|
1627
3221
|
}
|
|
1628
|
-
const suppressR = Math.max(mergeR,
|
|
1629
|
-
const
|
|
1630
|
-
const
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
for (const s of away)
|
|
1641
|
-
|
|
1642
|
-
if (
|
|
1643
|
-
|
|
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;
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
return best;
|
|
1764
|
-
}
|
|
1765
|
-
function closedMetrics(pts) {
|
|
1766
|
-
const n = pts.length;
|
|
1767
|
-
if (n < 3) {
|
|
1768
|
-
let perim2 = 0;
|
|
1769
|
-
for (let i = 1; i < n; i++) perim2 += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
|
1770
|
-
return { area: 0, perim: perim2 };
|
|
1771
|
-
}
|
|
1772
|
-
let area = 0, perim = 0;
|
|
1773
|
-
for (let i = 0; i < n; i++) {
|
|
1774
|
-
const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % n];
|
|
1775
|
-
area += x1 * y2 - x2 * y1;
|
|
1776
|
-
perim += Math.hypot(x2 - x1, y2 - y1);
|
|
3222
|
+
const suppressR = Math.max(mergeR, fp.footprint / 2);
|
|
3223
|
+
const ex = opts.excludeCenter;
|
|
3224
|
+
const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
|
|
3225
|
+
const matches = [];
|
|
3226
|
+
const withheld = [];
|
|
3227
|
+
const pct = (v) => Math.round(v * 1e3) / 1e3;
|
|
3228
|
+
const row = (s) => ({
|
|
3229
|
+
at: [Math.round(s.at[0] * 10) / 10, Math.round(s.at[1] * 10) / 10],
|
|
3230
|
+
score: pct(s.score),
|
|
3231
|
+
rotation: s.rotation,
|
|
3232
|
+
mirrored: s.mirrored
|
|
3233
|
+
});
|
|
3234
|
+
for (const s of away) if (s.score >= scoreHigh) matches.push(row(s));
|
|
3235
|
+
for (const s of away) {
|
|
3236
|
+
if (s.score >= scoreHigh) continue;
|
|
3237
|
+
if (matches.some((m) => Math.hypot(m.at[0] - s.at[0], m.at[1] - s.at[1]) <= suppressR)) continue;
|
|
3238
|
+
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
3239
|
}
|
|
1778
|
-
|
|
3240
|
+
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);
|
|
3241
|
+
matches.sort(order);
|
|
3242
|
+
withheld.sort(order);
|
|
3243
|
+
return { matches, withheld, candidates: { considered, dropped } };
|
|
1779
3244
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
3245
|
+
|
|
3246
|
+
// ../web/src/lib/provenance.js
|
|
3247
|
+
var mintUuid = () => {
|
|
3248
|
+
const c = globalThis.crypto;
|
|
3249
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
3250
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3251
|
+
};
|
|
3252
|
+
var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
3253
|
+
|
|
3254
|
+
// ../web/src/lib/approvals.js
|
|
3255
|
+
var APPROVAL_POLICY = {
|
|
3256
|
+
add: "mints id (apr- + uuid) + ts per record; restore: true re-adds VERBATIM at the recorded indices (undo of a lift)",
|
|
3257
|
+
delete: "no stamp; inverse re-adds the removed records verbatim, original order included",
|
|
3258
|
+
replace: "whole-array non-edit (hydrate) \u2014 nothing mints, inverse null (never recorded)"
|
|
3259
|
+
};
|
|
3260
|
+
var APPROVAL_ACTORS = ["estimator", "agent"];
|
|
3261
|
+
var APPROVAL_R = 0.022;
|
|
3262
|
+
var APPROVAL_INK = {
|
|
3263
|
+
estimator: { light: "#1f6b4a", dark: "#55b083" },
|
|
3264
|
+
agent: { light: "#6c6a5e", dark: "#9d9a8c" }
|
|
3265
|
+
};
|
|
3266
|
+
function approvalInk(actor, dark = false) {
|
|
3267
|
+
const c = APPROVAL_INK[actor] || APPROVAL_INK.agent;
|
|
3268
|
+
return dark ? c.dark : c.light;
|
|
1784
3269
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
if (
|
|
3270
|
+
var isPair = (p) => Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
|
3271
|
+
function sanitizeApprovals(raw) {
|
|
3272
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3273
|
+
return (Array.isArray(raw) ? raw : []).filter((a) => {
|
|
3274
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return false;
|
|
3275
|
+
if (!(typeof a.id === "string" && a.id)) return false;
|
|
3276
|
+
if (!APPROVAL_ACTORS.includes(a.actor)) return false;
|
|
3277
|
+
if (!(typeof a.sheet_id === "string" && a.sheet_id)) return false;
|
|
3278
|
+
if (!isPair(a.at)) return false;
|
|
3279
|
+
if (seen.has(a.id)) return false;
|
|
3280
|
+
seen.add(a.id);
|
|
3281
|
+
return true;
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
function approvalTally(approvals) {
|
|
3285
|
+
const t = { estimator: 0, agent: 0 };
|
|
3286
|
+
for (const a of Array.isArray(approvals) ? approvals : []) {
|
|
3287
|
+
if (a && a.actor in t) t[a.actor] += 1;
|
|
1790
3288
|
}
|
|
1791
|
-
return
|
|
3289
|
+
return t;
|
|
1792
3290
|
}
|
|
1793
|
-
function
|
|
1794
|
-
|
|
1795
|
-
|
|
3291
|
+
function applyApprovalCommand(approvals, cmd) {
|
|
3292
|
+
if (!cmd || !(cmd.type in APPROVAL_POLICY)) {
|
|
3293
|
+
throw new Error(`Unknown approval command type: ${cmd && cmd.type} \u2014 add it to APPROVAL_POLICY (and decide what it mints) first.`);
|
|
3294
|
+
}
|
|
3295
|
+
switch (cmd.type) {
|
|
3296
|
+
case "add": {
|
|
3297
|
+
const minted = cmd.restore ? cmd.approvals : cmd.approvals.map((a) => {
|
|
3298
|
+
const { id, ts, ...rest } = a;
|
|
3299
|
+
if (!APPROVAL_ACTORS.includes(rest.actor)) {
|
|
3300
|
+
throw new Error(`Unknown approval actor: ${rest.actor} \u2014 must be one of ${APPROVAL_ACTORS.join(", ")}.`);
|
|
3301
|
+
}
|
|
3302
|
+
return { id: id || `apr-${mintUuid()}`, ts: ts || nowIso(), ...rest };
|
|
3303
|
+
});
|
|
3304
|
+
let next;
|
|
3305
|
+
if (cmd.restore && Array.isArray(cmd.at) && cmd.at.length === minted.length) {
|
|
3306
|
+
next = approvals.slice();
|
|
3307
|
+
minted.forEach((a, k) => next.splice(Math.min(cmd.at[k], next.length), 0, a));
|
|
3308
|
+
} else {
|
|
3309
|
+
next = [...approvals, ...minted];
|
|
3310
|
+
}
|
|
3311
|
+
return { approvals: next, inverse: { type: "delete", ids: minted.map((a) => a.id) } };
|
|
3312
|
+
}
|
|
3313
|
+
case "delete": {
|
|
3314
|
+
const idSet = new Set(cmd.ids);
|
|
3315
|
+
const removed = [], at = [];
|
|
3316
|
+
approvals.forEach((a, i) => {
|
|
3317
|
+
if (idSet.has(a.id)) {
|
|
3318
|
+
removed.push(a);
|
|
3319
|
+
at.push(i);
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
return {
|
|
3323
|
+
approvals: approvals.filter((a) => !idSet.has(a.id)),
|
|
3324
|
+
inverse: { type: "add", approvals: removed, restore: true, at }
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
case "replace":
|
|
3328
|
+
return { approvals: Array.isArray(cmd.approvals) ? cmd.approvals : [], inverse: null };
|
|
3329
|
+
}
|
|
1796
3330
|
}
|
|
1797
3331
|
|
|
1798
3332
|
// ../web/src/lib/num.js
|
|
@@ -2593,9 +4127,11 @@ var SNAP_CELL = 24;
|
|
|
2593
4127
|
var SNAP_TOL = 7;
|
|
2594
4128
|
var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
|
|
2595
4129
|
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}-${
|
|
4130
|
+
var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
4131
|
+
var uid = (p) => `${p}-${mintUuid2()}`;
|
|
2598
4132
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
4133
|
+
var sanitizeApprovals2 = sanitizeApprovals;
|
|
4134
|
+
var applyApprovalCommand2 = applyApprovalCommand;
|
|
2599
4135
|
var CONTEXT_MIN_LEN_PX = 2;
|
|
2600
4136
|
var CONTEXT_MAX_SEGMENTS = 4e3;
|
|
2601
4137
|
var CONTEXT_MAX_SEGMENTS_CEIL = 2e4;
|
|
@@ -2648,6 +4184,9 @@ var Session = class _Session {
|
|
|
2648
4184
|
conditions = [];
|
|
2649
4185
|
markups = [];
|
|
2650
4186
|
shapes = [];
|
|
4187
|
+
/** Approval-family records (#176) — estimator seals arrive only by import;
|
|
4188
|
+
* agent verdicts mint through markVerdict and nothing else. */
|
|
4189
|
+
approvals = [];
|
|
2651
4190
|
/** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
|
|
2652
4191
|
* marked-set cover discloses as withheld. Replaced per assign run, cleared
|
|
2653
4192
|
* with the rest of the session on a non-merge load_plan. Seeds ride
|
|
@@ -2690,6 +4229,7 @@ var Session = class _Session {
|
|
|
2690
4229
|
this.conditions = [];
|
|
2691
4230
|
this.shapes = [];
|
|
2692
4231
|
this.markups = [];
|
|
4232
|
+
this.approvals = [];
|
|
2693
4233
|
this.file = null;
|
|
2694
4234
|
this.filePath = null;
|
|
2695
4235
|
this.nextOrd = 1;
|
|
@@ -2977,15 +4517,41 @@ var Session = class _Session {
|
|
|
2977
4517
|
}
|
|
2978
4518
|
return segRoles(geo.layerOf, layerRoleCodes(geo.layerIds, infoById));
|
|
2979
4519
|
}
|
|
4520
|
+
/** The vector mask, built through buildMask's FULL scale-pinned signature
|
|
4521
|
+
* (RFC #60 / PR #179 — the canvas's ensureMask, verbatim in intent):
|
|
4522
|
+
* `pxPerFt` (the sheet scale as image px per foot) rides INTO the mask, so
|
|
4523
|
+
* the hatch pitch cap, the seal radii, door-wedge caps and the minimum-
|
|
4524
|
+
* passage rule are all feet-true through `mask.mppf` instead of guessing in
|
|
4525
|
+
* raster px; the `page` pin (audit A1/F3) makes the working grid a property
|
|
4526
|
+
* of the SHEET in points, never of a render. This server renders at the
|
|
4527
|
+
* canvas BASELINE (RENDER_SCALE) always, so renderScale === baseScale and
|
|
4528
|
+
* basePxPerFt === pxPerFt — the one degenerate case where the pin and the
|
|
4529
|
+
* legacy reconstruction agree bit-for-bit. */
|
|
4530
|
+
buildVectorMask(s, geo, layersOpt) {
|
|
4531
|
+
if (!geo.segs.length) return null;
|
|
4532
|
+
const pxPerFt = s.upp ? 1 / s.upp : 0;
|
|
4533
|
+
return buildMask(
|
|
4534
|
+
geo.segs,
|
|
4535
|
+
s.widthPx,
|
|
4536
|
+
s.heightPx,
|
|
4537
|
+
MASK_MAX_DIM,
|
|
4538
|
+
geo.meta,
|
|
4539
|
+
pxPerFt,
|
|
4540
|
+
pxPerFt,
|
|
4541
|
+
{ pageW: s.widthPt, pageH: s.heightPt, renderScale: RENDER_SCALE, baseScale: RENDER_SCALE },
|
|
4542
|
+
this.rolesFor(s, geo, layersOpt)
|
|
4543
|
+
);
|
|
4544
|
+
}
|
|
2980
4545
|
/** v1 masks come from the sheet's vector linework only; a scanned sheet
|
|
2981
4546
|
* (zero segments) is null here and the measuring tools fall back to
|
|
2982
4547
|
* ensureRasterMask (#154). Layer roles (#85) ride in as the stated
|
|
2983
|
-
* short-circuit; an unlayered sheet builds the identical pre-#85 mask.
|
|
4548
|
+
* short-circuit; an unlayered sheet builds the identical pre-#85 mask.
|
|
4549
|
+
* The cached mask BAKES THE SCALE IN (mppf) — set_scale evicts it. */
|
|
2984
4550
|
async ensureMask(name) {
|
|
2985
4551
|
const s = this.sheet(name);
|
|
2986
4552
|
if (s.mask === void 0) {
|
|
2987
4553
|
const geo = await this.ensureGeometry(s);
|
|
2988
|
-
s.mask =
|
|
4554
|
+
s.mask = this.buildVectorMask(s, geo);
|
|
2989
4555
|
}
|
|
2990
4556
|
return s.mask;
|
|
2991
4557
|
}
|
|
@@ -2996,16 +4562,18 @@ var Session = class _Session {
|
|
|
2996
4562
|
if (!layersOpt || !layersOpt.include?.length && !layersOpt.exclude?.length) return this.ensureMask(name);
|
|
2997
4563
|
const s = this.sheet(name);
|
|
2998
4564
|
const geo = await this.ensureGeometry(s);
|
|
2999
|
-
|
|
3000
|
-
return buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta, this.rolesFor(s, geo, layersOpt));
|
|
4565
|
+
return this.buildVectorMask(s, geo, layersOpt);
|
|
3001
4566
|
}
|
|
3002
4567
|
/** The raster-fallback mask (#154): a dedicated render of the sheet at mask
|
|
3003
4568
|
* scale (the view_sheet machinery — pdf.ts + @napi-rs/canvas), thresholded
|
|
3004
4569
|
* by the canvas's own rastermask engine into the same MaskObj shape
|
|
3005
|
-
* buildMask emits, so
|
|
3006
|
-
*
|
|
3007
|
-
*
|
|
3008
|
-
*
|
|
4570
|
+
* buildMask emits, so the sealed flood (floodAtSeed) and traceRegion run
|
|
4571
|
+
* unchanged on scans. It carries NO mppf of its own (pixels cannot know
|
|
4572
|
+
* the sheet scale), so flood call sites pass mask px per foot explicitly —
|
|
4573
|
+
* ws / upp, the canvas's own raster-path convention — and set_scale evicts
|
|
4574
|
+
* this cache alongside the vector mask. Where the optional native canvas
|
|
4575
|
+
* never installed, renderRgba throws its plain install-hint Error and the
|
|
4576
|
+
* tool reply carries it — a stated inability, never a guessed polygon. */
|
|
3009
4577
|
async ensureRasterMask(s) {
|
|
3010
4578
|
if (!s.rmask) {
|
|
3011
4579
|
const ws = Math.min(1, MASK_MAX_DIM / Math.max(s.widthPx, s.heightPx, 1));
|
|
@@ -3088,6 +4656,10 @@ var Session = class _Session {
|
|
|
3088
4656
|
} else {
|
|
3089
4657
|
throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
|
|
3090
4658
|
}
|
|
4659
|
+
if (s.upp !== upp) {
|
|
4660
|
+
s.mask = void 0;
|
|
4661
|
+
s.rmask = void 0;
|
|
4662
|
+
}
|
|
3091
4663
|
s.upp = upp;
|
|
3092
4664
|
s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
|
|
3093
4665
|
return {
|
|
@@ -3136,7 +4708,43 @@ var Session = class _Session {
|
|
|
3136
4708
|
}
|
|
3137
4709
|
return c;
|
|
3138
4710
|
}
|
|
3139
|
-
|
|
4711
|
+
/** Slim flood evidence — the engine result's SCALAR signals, harvested the
|
|
4712
|
+
* moment the flood returns so a batch sweep never pins N mask-sized region
|
|
4713
|
+
* bitmaps just to score confidence at commit time. `signals` goes through
|
|
4714
|
+
* floodSignals, THE adapter (audit A2: hand-listed signal fields are how an
|
|
4715
|
+
* engine emission goes silently inert); gapBridged/ringWedges ride beside
|
|
4716
|
+
* it because they are provenance the adapter does not carry. */
|
|
4717
|
+
static floodEvidence(f, raster, mppf) {
|
|
4718
|
+
return {
|
|
4719
|
+
signals: floodSignals(f, { raster, mppf }),
|
|
4720
|
+
raster,
|
|
4721
|
+
...f.gapBridged ? { gapBridged: f.gapBridged } : {},
|
|
4722
|
+
...f.ringWedges ? { ringWedges: f.ringWedges } : {}
|
|
4723
|
+
};
|
|
4724
|
+
}
|
|
4725
|
+
/** THE flood → provenance mapping (the canvas Create gate's field set,
|
|
4726
|
+
* TakeoffCanvas commitOneClickRegions). One function, spread into the
|
|
4727
|
+
* committed origin by commit() and into the tool reply by the flood call
|
|
4728
|
+
* sites, so the two surfaces cannot drift and no site hand-lists fields. */
|
|
4729
|
+
static floodStamp(ev, areaSF) {
|
|
4730
|
+
const conf = traceConfidence({ ...ev.signals, areaSF });
|
|
4731
|
+
const sig = ev.signals;
|
|
4732
|
+
return {
|
|
4733
|
+
confidence: conf.score,
|
|
4734
|
+
...conf.factors.length ? { confidence_factors: conf.factors } : {},
|
|
4735
|
+
...sig.hatchFiltered ? { hatch_filtered: true } : {},
|
|
4736
|
+
...sig.sealedPx ? { gap_sealed_px: sig.sealedPx } : {},
|
|
4737
|
+
...ev.gapBridged ? { gap_bridged_px: ev.gapBridged } : {},
|
|
4738
|
+
// min-pass fields ride only when the rule CHANGED the answer — the
|
|
4739
|
+
// canvas convention (minPassDelta gates minPassPx)
|
|
4740
|
+
...sig.minPassDelta ? { min_pass_px: sig.minPassPx || 0, min_pass_delta: sig.minPassDelta } : {},
|
|
4741
|
+
...sig.wedges ? { door_wedges: sig.wedges } : {},
|
|
4742
|
+
...ev.ringWedges ? { ring_interiors: ev.ringWedges } : {},
|
|
4743
|
+
...ev.raster ? { raster_traced: true } : {}
|
|
4744
|
+
};
|
|
4745
|
+
}
|
|
4746
|
+
commit(s, tag, role, vertsPx, computed, origin, flood) {
|
|
4747
|
+
if (origin && flood) origin = { ...origin, ..._Session.floodStamp(flood, computed.area_sf) };
|
|
3140
4748
|
if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
|
|
3141
4749
|
const c = this.conditionFor(tag);
|
|
3142
4750
|
const shape = {
|
|
@@ -3161,7 +4769,7 @@ var Session = class _Session {
|
|
|
3161
4769
|
const mask = await this.maskWithLayers(name, opts.layers);
|
|
3162
4770
|
if (!mask && !rasterEligible) throw new UserError("This sheet has no vector linework and no scan image to flood \u2014 nothing here bounds a region. Trace the space with measure_polygon instead.");
|
|
3163
4771
|
if (mask) {
|
|
3164
|
-
const r =
|
|
4772
|
+
const r = floodAtSeed(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
|
|
3165
4773
|
if (r.status === "ok") f = r;
|
|
3166
4774
|
else if (!rasterEligible) {
|
|
3167
4775
|
if (r.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
|
|
@@ -3172,29 +4780,23 @@ var Session = class _Session {
|
|
|
3172
4780
|
if (!f) {
|
|
3173
4781
|
this.refuseLayersOnRaster(opts.layers);
|
|
3174
4782
|
const rmask = await this.ensureRasterMask(s);
|
|
3175
|
-
const r =
|
|
4783
|
+
const r = floodAtSeed(rmask, x, y, SENS_BALANCED, s.upp ? rmask.ws / s.upp : 0);
|
|
3176
4784
|
if (r.status === "leak") throw new UserError("That space isn't enclosed on the scan \u2014 the fill escaped through a gap (faded line or open doorway). Seed a more enclosed spot, or trace it with measure_polygon.");
|
|
3177
4785
|
if (r.status !== "ok") throw new UserError("Landed on dense scan ink (text or hatching). Seed an open spot inside the room.");
|
|
3178
4786
|
f = r;
|
|
3179
4787
|
raster = true;
|
|
3180
4788
|
}
|
|
4789
|
+
const ev = _Session.floodEvidence(f, raster, s.upp ? f.ws / s.upp : 0);
|
|
3181
4790
|
const ring = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
3182
4791
|
if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
|
|
3183
4792
|
const areaPx2 = ringArea(ring);
|
|
3184
4793
|
const perimPx = closedMetrics(ring).perim;
|
|
3185
|
-
const common = {
|
|
3186
|
-
status: "ok",
|
|
3187
|
-
nverts: ring.length,
|
|
3188
|
-
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
3189
|
-
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
3190
|
-
// which path ran, disclosed both ways (#154): present = pixels bounded
|
|
3191
|
-
// this trace, absent = the vector linework did
|
|
3192
|
-
...raster ? { raster_traced: true } : {},
|
|
3193
|
-
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
3194
|
-
};
|
|
3195
4794
|
if (s.upp == null) {
|
|
3196
4795
|
return {
|
|
3197
|
-
|
|
4796
|
+
status: "ok",
|
|
4797
|
+
nverts: ring.length,
|
|
4798
|
+
..._Session.floodStamp(ev),
|
|
4799
|
+
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {},
|
|
3198
4800
|
area_px2: round1(areaPx2),
|
|
3199
4801
|
perimeter_px: round1(perimPx),
|
|
3200
4802
|
warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.`
|
|
@@ -3203,6 +4805,12 @@ var Session = class _Session {
|
|
|
3203
4805
|
const upp = s.upp;
|
|
3204
4806
|
const area_sf = round2(areaPx2 * upp * upp);
|
|
3205
4807
|
const perimeter_lf = round2(perimPx * upp);
|
|
4808
|
+
const common = {
|
|
4809
|
+
status: "ok",
|
|
4810
|
+
nverts: ring.length,
|
|
4811
|
+
..._Session.floodStamp(ev, area_sf),
|
|
4812
|
+
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
4813
|
+
};
|
|
3206
4814
|
let shape_id;
|
|
3207
4815
|
if (opts.condition) {
|
|
3208
4816
|
shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
|
|
@@ -3210,12 +4818,6 @@ var Session = class _Session {
|
|
|
3210
4818
|
actor: "agent",
|
|
3211
4819
|
seed_norm: [x / s.widthPx, y / s.heightPx],
|
|
3212
4820
|
reviewed: false,
|
|
3213
|
-
...f.hatchFiltered ? { hatch_filtered: true } : {},
|
|
3214
|
-
...f.gapBridged ? { gap_bridged_px: f.gapBridged } : {},
|
|
3215
|
-
// #154 — a trace whose boundary was scan PIXELS is a different claim
|
|
3216
|
-
// than one bounded by vector linework; the record says which (the
|
|
3217
|
-
// canvas's raster_traced vocabulary, contribution.v2)
|
|
3218
|
-
...raster ? { raster_traced: true } : {},
|
|
3219
4821
|
// #85 — a trace bounded by DECLARED boundary layers is categorically
|
|
3220
4822
|
// stronger evidence than one bounded by a pitch heuristic (vector
|
|
3221
4823
|
// path only: the raster mask never saw the layer table)
|
|
@@ -3224,7 +4826,7 @@ var Session = class _Session {
|
|
|
3224
4826
|
// how the shape was made (ShapeOrigin.fill_sensitivity) — vector path
|
|
3225
4827
|
// only; the knob is inert on a single-tier raster mask
|
|
3226
4828
|
...!raster && opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
|
|
3227
|
-
}).id;
|
|
4829
|
+
}, ev).id;
|
|
3228
4830
|
}
|
|
3229
4831
|
this.flushCommits("one_click");
|
|
3230
4832
|
const mixed = this.scaleWarningFor(s, ring);
|
|
@@ -3294,11 +4896,12 @@ var Session = class _Session {
|
|
|
3294
4896
|
const unresolved = [];
|
|
3295
4897
|
const byRing = /* @__PURE__ */ new Map();
|
|
3296
4898
|
const order = [];
|
|
4899
|
+
const sweepMppf = raster ? s.upp ? mask.ws / s.upp : 0 : mask.mppf || 0;
|
|
3297
4900
|
for (const lb of labels) {
|
|
3298
|
-
let ring = null,
|
|
4901
|
+
let ring = null, ev = null, seed = null;
|
|
3299
4902
|
let sawBubble = false, sawDegenerate = false;
|
|
3300
4903
|
for (const probe of seedLadderPx(lb.bbox)) {
|
|
3301
|
-
const f =
|
|
4904
|
+
const f = floodAtSeed(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED, sweepMppf);
|
|
3302
4905
|
if (f.status !== "ok") continue;
|
|
3303
4906
|
const r = raster ? traceRegion(f, RASTER_RDP_EPS) : snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
3304
4907
|
if (r.length < 3) {
|
|
@@ -3310,12 +4913,11 @@ var Session = class _Session {
|
|
|
3310
4913
|
continue;
|
|
3311
4914
|
}
|
|
3312
4915
|
ring = r;
|
|
3313
|
-
|
|
3314
|
-
gap = f.gapBridged || 0;
|
|
4916
|
+
ev = _Session.floodEvidence(f, raster, sweepMppf);
|
|
3315
4917
|
seed = probe;
|
|
3316
4918
|
break;
|
|
3317
4919
|
}
|
|
3318
|
-
if (!ring || !seed) {
|
|
4920
|
+
if (!ring || !ev || !seed) {
|
|
3319
4921
|
if (sawBubble) withheld.bubble++;
|
|
3320
4922
|
else if (sawDegenerate) withheld.degenerate++;
|
|
3321
4923
|
continue;
|
|
@@ -3333,8 +4935,7 @@ var Session = class _Session {
|
|
|
3333
4935
|
areaPx2: ringArea(ring),
|
|
3334
4936
|
perimPx: closedMetrics(ring).perim,
|
|
3335
4937
|
seed,
|
|
3336
|
-
|
|
3337
|
-
gap,
|
|
4938
|
+
ev,
|
|
3338
4939
|
merged: []
|
|
3339
4940
|
};
|
|
3340
4941
|
byRing.set(key, cand);
|
|
@@ -3342,17 +4943,16 @@ var Session = class _Session {
|
|
|
3342
4943
|
}
|
|
3343
4944
|
const upp = s.upp;
|
|
3344
4945
|
const rooms = order.map((c) => {
|
|
3345
|
-
const common = {
|
|
3346
|
-
label: c.label,
|
|
3347
|
-
nverts: c.ring.length,
|
|
3348
|
-
...c.merged.length ? { merged_labels: c.merged } : {},
|
|
3349
|
-
...c.hatch ? { hatch_filtered: true } : {},
|
|
3350
|
-
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
3351
|
-
...raster ? { raster_traced: true } : {},
|
|
3352
|
-
...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
3353
|
-
};
|
|
3354
4946
|
if (upp == null) {
|
|
3355
|
-
return {
|
|
4947
|
+
return {
|
|
4948
|
+
label: c.label,
|
|
4949
|
+
nverts: c.ring.length,
|
|
4950
|
+
...c.merged.length ? { merged_labels: c.merged } : {},
|
|
4951
|
+
..._Session.floodStamp(c.ev),
|
|
4952
|
+
...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {},
|
|
4953
|
+
area_px2: round1(c.areaPx2),
|
|
4954
|
+
perimeter_px: round1(c.perimPx)
|
|
4955
|
+
};
|
|
3356
4956
|
}
|
|
3357
4957
|
const area_sf = round2(c.areaPx2 * upp * upp);
|
|
3358
4958
|
if (area_sf < minAreaSf) {
|
|
@@ -3360,6 +4960,13 @@ var Session = class _Session {
|
|
|
3360
4960
|
return null;
|
|
3361
4961
|
}
|
|
3362
4962
|
const perimeter_lf = round2(c.perimPx * upp);
|
|
4963
|
+
const common = {
|
|
4964
|
+
label: c.label,
|
|
4965
|
+
nverts: c.ring.length,
|
|
4966
|
+
...c.merged.length ? { merged_labels: c.merged } : {},
|
|
4967
|
+
..._Session.floodStamp(c.ev, area_sf),
|
|
4968
|
+
...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
4969
|
+
};
|
|
3363
4970
|
let tag = opts.condition;
|
|
3364
4971
|
let assignment;
|
|
3365
4972
|
if (assign) {
|
|
@@ -3379,11 +4986,8 @@ var Session = class _Session {
|
|
|
3379
4986
|
actor: "agent",
|
|
3380
4987
|
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
3381
4988
|
reviewed: false,
|
|
3382
|
-
...c.hatch ? { hatch_filtered: true } : {},
|
|
3383
|
-
...c.gap ? { gap_bridged_px: c.gap } : {},
|
|
3384
|
-
...raster ? { raster_traced: true } : {},
|
|
3385
4989
|
...assignment ? { assignment } : {}
|
|
3386
|
-
}).id;
|
|
4990
|
+
}, c.ev).id;
|
|
3387
4991
|
}
|
|
3388
4992
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
|
|
3389
4993
|
}).filter((r) => r !== null);
|
|
@@ -3552,11 +5156,24 @@ var Session = class _Session {
|
|
|
3552
5156
|
* square symmetry group, score each as the length-weighted fraction of seed
|
|
3553
5157
|
* segments reproduced within tolerance. This method is the plumbing plus
|
|
3554
5158
|
* 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.
|
|
5159
|
+
* match centers through the placeCount path (ONE undo step, EA scale-free),
|
|
5160
|
+
* origins telling the truth (`method: "symbol_sweep"`, per-match
|
|
5161
|
+
* score/transform), withheld placements NEVER committed.
|
|
5162
|
+
*
|
|
5163
|
+
* scope "set" (phase 2) sweeps the whole working set, restricted to
|
|
5164
|
+
* PLAN-role sheets by the sheet graph: a symbol instance drawn in a detail,
|
|
5165
|
+
* legend, or schedule is a reference drawing, not installed work, and must
|
|
5166
|
+
* never count itself. The seed rect may sit on ANY sheet — marqueeing the
|
|
5167
|
+
* assembly on a detail sheet is the estimator's own gesture — and a
|
|
5168
|
+
* non-plan seed sheet serves as the fingerprint SOURCE while staying
|
|
5169
|
+
* excluded from counting. Every excluded sheet is disclosed in `skipped`
|
|
5170
|
+
* with its role and reason; per-sheet results carry their own match /
|
|
5171
|
+
* withheld / cap accounting plus wall-clock elapsed_ms. The whole set-wide
|
|
5172
|
+
* commit is ONE undo step: the gesture the agent made was "sweep the set",
|
|
5173
|
+
* and taking it back should not require one undo per sheet. */
|
|
3558
5174
|
async symbolSweep(name, opts) {
|
|
3559
5175
|
const s = this.sheet(name);
|
|
5176
|
+
const scope = opts.scope ?? "sheet";
|
|
3560
5177
|
if (opts.commit && !opts.condition) {
|
|
3561
5178
|
throw new UserError("commit: true needs a condition \u2014 the finish tag the match markers count under (e.g. 'FD-1').");
|
|
3562
5179
|
}
|
|
@@ -3578,44 +5195,350 @@ var Session = class _Session {
|
|
|
3578
5195
|
mirror: opts.mirror ?? true,
|
|
3579
5196
|
tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
|
|
3580
5197
|
};
|
|
3581
|
-
let
|
|
5198
|
+
let fp;
|
|
3582
5199
|
try {
|
|
3583
|
-
|
|
5200
|
+
fp = fingerprintSymbol(geo.segs, rect);
|
|
3584
5201
|
} catch (e) {
|
|
3585
5202
|
throw new UserError(e instanceof Error ? e.message : String(e));
|
|
3586
5203
|
}
|
|
5204
|
+
const seedOut = {
|
|
5205
|
+
sheet: s.key,
|
|
5206
|
+
segments: fp.segments,
|
|
5207
|
+
center: [round1(fp.center[0]), round1(fp.center[1])],
|
|
5208
|
+
rect: [round1(rect[0][0]), round1(rect[0][1]), round1(rect[1][0]), round1(rect[1][1])],
|
|
5209
|
+
length_px: round1(fp.totalLen)
|
|
5210
|
+
};
|
|
5211
|
+
if (scope === "sheet") {
|
|
5212
|
+
const res = matchSymbol(fp, geo.segs, { ...sweepOpts, excludeCenter: fp.center });
|
|
5213
|
+
let committed2;
|
|
5214
|
+
if (opts.commit && res.matches.length) {
|
|
5215
|
+
committed2 = this.placeCount(name, res.matches.map((m) => m.at), {
|
|
5216
|
+
condition: opts.condition,
|
|
5217
|
+
tool: "symbol_sweep",
|
|
5218
|
+
origins: res.matches.map((m) => ({
|
|
5219
|
+
method: "symbol_sweep",
|
|
5220
|
+
actor: "agent",
|
|
5221
|
+
reviewed: false,
|
|
5222
|
+
symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored, seed: { source: "instance", sheet: s.key } }
|
|
5223
|
+
}))
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
return {
|
|
5227
|
+
scope,
|
|
5228
|
+
found: res.matches.length,
|
|
5229
|
+
matches: res.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
5230
|
+
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 })),
|
|
5231
|
+
seed: seedOut,
|
|
5232
|
+
candidates: res.candidates,
|
|
5233
|
+
...committed2 ? {
|
|
5234
|
+
committed: committed2.committed,
|
|
5235
|
+
shape_ids: committed2.shape_ids,
|
|
5236
|
+
condition: committed2.condition,
|
|
5237
|
+
ea_total: committed2.ea_total
|
|
5238
|
+
} : {},
|
|
5239
|
+
...opts.commit && !res.matches.length ? { note: "commit requested but nothing cleared the bar \u2014 no shapes were committed." } : {},
|
|
5240
|
+
...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.` } : {}
|
|
5241
|
+
};
|
|
5242
|
+
}
|
|
5243
|
+
const graph = await this.ensureGraph();
|
|
5244
|
+
if (!graph.available) {
|
|
5245
|
+
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'.");
|
|
5246
|
+
}
|
|
5247
|
+
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
5248
|
+
const seedRole = roleOf.get(s.key) ?? "unknown";
|
|
5249
|
+
const seedSource = seedRole === "plan" ? "instance" : "detail_sheet";
|
|
5250
|
+
const perSheet = [];
|
|
5251
|
+
const skipped = [];
|
|
5252
|
+
for (const sh of this.sheetList()) {
|
|
5253
|
+
const role = roleOf.get(sh.key) ?? "unknown";
|
|
5254
|
+
if (role !== "plan") {
|
|
5255
|
+
skipped.push({
|
|
5256
|
+
sheet: sh.key,
|
|
5257
|
+
role,
|
|
5258
|
+
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`
|
|
5259
|
+
});
|
|
5260
|
+
continue;
|
|
5261
|
+
}
|
|
5262
|
+
const g2 = await this.ensureGeometry(sh);
|
|
5263
|
+
if (!g2.segs.length) {
|
|
5264
|
+
skipped.push({ sheet: sh.key, role, reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
|
|
5265
|
+
continue;
|
|
5266
|
+
}
|
|
5267
|
+
const t0 = process.hrtime.bigint();
|
|
5268
|
+
const res = matchSymbol(fp, g2.segs, { ...sweepOpts, ...sh.key === s.key ? { excludeCenter: fp.center } : {} });
|
|
5269
|
+
const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
|
|
5270
|
+
perSheet.push({ state: sh, ...res, elapsed_ms });
|
|
5271
|
+
}
|
|
5272
|
+
const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
3587
5273
|
let committed;
|
|
3588
|
-
if (opts.commit &&
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
5274
|
+
if (opts.commit && found) {
|
|
5275
|
+
const ids = [];
|
|
5276
|
+
for (const ps of perSheet) {
|
|
5277
|
+
for (const m of ps.matches) {
|
|
5278
|
+
ids.push(this.commit(ps.state, opts.condition, "count", [m.at], { count: 1 }, {
|
|
5279
|
+
method: "symbol_sweep",
|
|
5280
|
+
actor: "agent",
|
|
5281
|
+
reviewed: false,
|
|
5282
|
+
symbol: { score: m.score, rotation: m.rotation, mirrored: m.mirrored, seed: { source: seedSource, sheet: s.key, role: seedRole } }
|
|
5283
|
+
}).id);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
this.flushCommits("symbol_sweep");
|
|
5287
|
+
const c = this.conditions.find((x) => x.finish_tag === opts.condition);
|
|
5288
|
+
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);
|
|
5289
|
+
committed = { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
|
|
5290
|
+
}
|
|
5291
|
+
const capped = perSheet.filter((p) => p.candidates.dropped > 0);
|
|
5292
|
+
const notes = [];
|
|
5293
|
+
if (!perSheet.length) notes.push("No plan-role sheet in the set was sweepable \u2014 nothing was counted; skipped[] says why, sheet by sheet.");
|
|
5294
|
+
if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar on any plan sheet \u2014 no shapes were committed.");
|
|
5295
|
+
return {
|
|
5296
|
+
scope,
|
|
5297
|
+
found,
|
|
5298
|
+
seed: { ...seedOut, role: seedRole },
|
|
5299
|
+
sheets: perSheet.map((p) => ({
|
|
5300
|
+
sheet: p.state.key,
|
|
5301
|
+
found: p.matches.length,
|
|
5302
|
+
matches: p.matches.map((m) => ({ at: [round1(m.at[0]), round1(m.at[1])], score: m.score, rotation: m.rotation, mirrored: m.mirrored })),
|
|
5303
|
+
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 })),
|
|
5304
|
+
candidates: p.candidates,
|
|
5305
|
+
elapsed_ms: p.elapsed_ms
|
|
5306
|
+
})),
|
|
5307
|
+
skipped,
|
|
5308
|
+
...committed ?? {},
|
|
5309
|
+
...notes.length ? { note: notes.join(" ") } : {},
|
|
5310
|
+
...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.` } : {}
|
|
5311
|
+
};
|
|
5312
|
+
}
|
|
5313
|
+
/** sweep_schedule_row (phase 2) — the estimator's story, honored: a
|
|
5314
|
+
* transition type sometimes exists only as a schedule row plus tag markers
|
|
5315
|
+
* scattered across the plan sheets. Given the ROW's key, this mints the
|
|
5316
|
+
* condition FROM the row (the assign-from-schedule vocabulary: the tag is
|
|
5317
|
+
* the schedule's claim, `origin.assignment {source: "schedule"}` with the
|
|
5318
|
+
* citation) and sweeps every plan sheet for the marker the tag is drawn as.
|
|
5319
|
+
*
|
|
5320
|
+
* THE CONTRACT, stated precisely — refusal-honest, never text-to-geometry
|
|
5321
|
+
* guessing:
|
|
5322
|
+
* 1. The row must exist in a schedule table the sheet graph extracted
|
|
5323
|
+
* (one row — a key defined twice across tables is ambiguous, refused).
|
|
5324
|
+
* 2. The tag must be DRAWN on at least one plan-role sheet. A row whose
|
|
5325
|
+
* tag appears nowhere on the plans cannot be geometrically anchored,
|
|
5326
|
+
* and a fingerprint is NEVER guessed from text alone — refused, with
|
|
5327
|
+
* the fix (marquee an instance with symbol_sweep).
|
|
5328
|
+
* 3. The fingerprint is the linework around the tag's own drawn
|
|
5329
|
+
* occurrence (a deterministic pad ladder around the text bbox), and
|
|
5330
|
+
* where the tag occurs more than once it must CORROBORATE — recur at
|
|
5331
|
+
* a second occurrence — before it is trusted. No repeatable marker
|
|
5332
|
+
* geometry → refused.
|
|
5333
|
+
* 4. A geometric match COUNTS only when the row's own tag text sits
|
|
5334
|
+
* within the marker's footprint — drafting reuses one bubble shape
|
|
5335
|
+
* across many tags, so geometry alone is not identity. A match
|
|
5336
|
+
* carrying a SIBLING row's tag is excluded (disclosed with the tag it
|
|
5337
|
+
* carries); a match carrying no tag is withheld as a question; a tag
|
|
5338
|
+
* occurrence with no matching geometry is disclosed as text_only.
|
|
5339
|
+
* Commit is ONE undo step for the whole set-wide sweep. */
|
|
5340
|
+
async sweepScheduleRow(tag, opts = {}) {
|
|
5341
|
+
const t = (tag || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
5342
|
+
if (!t) throw new UserError('Pass a schedule-row tag as drawn, e.g. sweep_schedule_row { tag: "T1" }.');
|
|
5343
|
+
const graph = await this.ensureGraph();
|
|
5344
|
+
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.");
|
|
5345
|
+
const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => r2.key === t).map((r2) => ({ tb: tb2, r: r2 })));
|
|
5346
|
+
if (!rowHits.length) {
|
|
5347
|
+
const found2 = graph.tables.map((x) => `${x.kind} on ${x.sheet} (${x.rows.length} rows)`).join(" | ");
|
|
5348
|
+
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.`);
|
|
5349
|
+
}
|
|
5350
|
+
if (rowHits.length > 1) {
|
|
5351
|
+
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.`);
|
|
5352
|
+
}
|
|
5353
|
+
const { tb, r } = rowHits[0];
|
|
5354
|
+
const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.map((row) => row.key)))].filter((k) => k !== t).sort();
|
|
5355
|
+
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
5356
|
+
const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
|
|
5357
|
+
const skipped = [];
|
|
5358
|
+
const planSheets = [];
|
|
5359
|
+
for (const sh of this.sheetList()) {
|
|
5360
|
+
const role = roleOf.get(sh.key) ?? "unknown";
|
|
5361
|
+
if (role === "plan") planSheets.push(sh);
|
|
5362
|
+
else {
|
|
5363
|
+
skipped.push({
|
|
5364
|
+
sheet: sh.key,
|
|
5365
|
+
role,
|
|
5366
|
+
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`
|
|
5367
|
+
});
|
|
5368
|
+
}
|
|
5369
|
+
}
|
|
5370
|
+
const occOf = (sh, key) => {
|
|
5371
|
+
if (!sh.spans) sh.spans = textSpans(sh.page);
|
|
5372
|
+
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);
|
|
5373
|
+
};
|
|
5374
|
+
const occBySheet = planSheets.map((sh) => ({ sh, occ: occOf(sh, t) }));
|
|
5375
|
+
const totalOcc = occBySheet.reduce((n, e) => n + e.occ.length, 0);
|
|
5376
|
+
if (!totalOcc) {
|
|
5377
|
+
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"}.`);
|
|
5378
|
+
}
|
|
5379
|
+
const withOcc = occBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
|
|
5380
|
+
const anchorSheet = withOcc[0].sh;
|
|
5381
|
+
const anchor = withOcc[0].occ[0];
|
|
5382
|
+
const anchorGeo = await this.ensureGeometry(anchorSheet);
|
|
5383
|
+
if (!anchorGeo.segs.length) {
|
|
5384
|
+
throw new UserError(`${anchorSheet.key} carries the tag "${t}" but no vector linework \u2014 the marker cannot be fingerprinted on a scan.`);
|
|
5385
|
+
}
|
|
5386
|
+
const sweepOpts = {
|
|
5387
|
+
rotations: opts.rotations ?? true,
|
|
5388
|
+
mirror: opts.mirror ?? true,
|
|
5389
|
+
tolPx: opts.tolerancePx ?? SWEEP_TOL_PX
|
|
5390
|
+
};
|
|
5391
|
+
let corro = null;
|
|
5392
|
+
if (withOcc[0].occ.length > 1) corro = { segs: anchorGeo.segs, occ: withOcc[0].occ.slice(1) };
|
|
5393
|
+
else if (withOcc.length > 1) corro = { segs: (await this.ensureGeometry(withOcc[1].sh)).segs, occ: withOcc[1].occ };
|
|
5394
|
+
const cX = (v) => Math.max(0, Math.min(v, anchorSheet.widthPx));
|
|
5395
|
+
const cY = (v) => Math.max(0, Math.min(v, anchorSheet.heightPx));
|
|
5396
|
+
let fp = null;
|
|
5397
|
+
let anchorRect = null;
|
|
5398
|
+
let corroborated = false;
|
|
5399
|
+
for (const padK of [1, 2, 3]) {
|
|
5400
|
+
const pad = padK * anchor.h;
|
|
5401
|
+
const rect = [
|
|
5402
|
+
[cX(anchor.bbox[0] - pad), cY(anchor.bbox[1] - pad)],
|
|
5403
|
+
[cX(anchor.bbox[2] + pad), cY(anchor.bbox[3] + pad)]
|
|
5404
|
+
];
|
|
5405
|
+
let cand;
|
|
5406
|
+
try {
|
|
5407
|
+
cand = fingerprintSymbol(anchorGeo.segs, rect);
|
|
5408
|
+
} catch (e) {
|
|
5409
|
+
if (e instanceof Error && /region, not one symbol/.test(e.message)) break;
|
|
5410
|
+
continue;
|
|
5411
|
+
}
|
|
5412
|
+
if (!corro) {
|
|
5413
|
+
fp = cand;
|
|
5414
|
+
anchorRect = rect;
|
|
5415
|
+
break;
|
|
5416
|
+
}
|
|
5417
|
+
const probe = matchSymbol(cand, corro.segs, sweepOpts);
|
|
5418
|
+
const pr = cand.footprint / 2 + anchor.h;
|
|
5419
|
+
if (corro.occ.some((o) => probe.matches.some((m) => Math.hypot(m.at[0] - o.cx, m.at[1] - o.cy) <= pr))) {
|
|
5420
|
+
fp = cand;
|
|
5421
|
+
anchorRect = rect;
|
|
5422
|
+
corroborated = true;
|
|
5423
|
+
break;
|
|
5424
|
+
}
|
|
5425
|
+
}
|
|
5426
|
+
if (!fp || !anchorRect) {
|
|
5427
|
+
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.`);
|
|
5428
|
+
}
|
|
5429
|
+
const R = fp.footprint / 2 + anchor.h;
|
|
5430
|
+
const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
|
|
5431
|
+
const perSheet = [];
|
|
5432
|
+
for (const { sh, occ } of occBySheet) {
|
|
5433
|
+
const g2 = await this.ensureGeometry(sh);
|
|
5434
|
+
if (!g2.segs.length) {
|
|
5435
|
+
skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
|
|
5436
|
+
continue;
|
|
5437
|
+
}
|
|
5438
|
+
const t0 = process.hrtime.bigint();
|
|
5439
|
+
const res = matchSymbol(fp, g2.segs, sweepOpts);
|
|
5440
|
+
const elapsed_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e4) / 100;
|
|
5441
|
+
const sibSpans = [];
|
|
5442
|
+
for (const k of siblings) for (const o of occOf(sh, k)) sibSpans.push({ key: k, cx: o.cx, cy: o.cy });
|
|
5443
|
+
const matches = [];
|
|
5444
|
+
const excluded = [];
|
|
5445
|
+
const withheld = [];
|
|
5446
|
+
const matchedOcc = /* @__PURE__ */ new Set();
|
|
5447
|
+
for (const m of res.matches) {
|
|
5448
|
+
let oi = -1;
|
|
5449
|
+
for (let k = 0; k < occ.length; k++) {
|
|
5450
|
+
if (Math.hypot(m.at[0] - occ[k].cx, m.at[1] - occ[k].cy) <= R) {
|
|
5451
|
+
oi = k;
|
|
5452
|
+
break;
|
|
5453
|
+
}
|
|
5454
|
+
}
|
|
5455
|
+
if (oi >= 0) {
|
|
5456
|
+
matchedOcc.add(oi);
|
|
5457
|
+
matches.push({ ...m, tag_at: occ[oi].bbox });
|
|
5458
|
+
continue;
|
|
5459
|
+
}
|
|
5460
|
+
const sib = sibSpans.find((sp) => Math.hypot(m.at[0] - sp.cx, m.at[1] - sp.cy) <= R);
|
|
5461
|
+
if (sib) {
|
|
5462
|
+
excluded.push({ at: m.at, tag: sib.key });
|
|
5463
|
+
continue;
|
|
5464
|
+
}
|
|
5465
|
+
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` });
|
|
5466
|
+
}
|
|
5467
|
+
for (const w of res.withheld) {
|
|
5468
|
+
const near = occ.some((o) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R);
|
|
5469
|
+
withheld.push(near ? { ...w, reason: `${w.reason} \u2014 and the "${t}" tag is drawn beside it` } : w);
|
|
5470
|
+
}
|
|
5471
|
+
matches.sort(byPos);
|
|
5472
|
+
excluded.sort(byPos);
|
|
5473
|
+
withheld.sort(byPos);
|
|
5474
|
+
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)] }));
|
|
5475
|
+
perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, elapsed_ms });
|
|
5476
|
+
}
|
|
5477
|
+
const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
5478
|
+
let committed;
|
|
5479
|
+
if (opts.commit && found) {
|
|
5480
|
+
const ids = [];
|
|
5481
|
+
for (const ps of perSheet) {
|
|
5482
|
+
for (const m of ps.matches) {
|
|
5483
|
+
ids.push(this.commit(ps.state, t, "count", [m.at], { count: 1 }, {
|
|
5484
|
+
method: "symbol_sweep",
|
|
5485
|
+
actor: "agent",
|
|
5486
|
+
reviewed: false,
|
|
5487
|
+
assignment: { source: "schedule", schedule_sheet: tb.sheet },
|
|
5488
|
+
symbol: {
|
|
5489
|
+
score: m.score,
|
|
5490
|
+
rotation: m.rotation,
|
|
5491
|
+
mirrored: m.mirrored,
|
|
5492
|
+
seed: { source: "schedule_row", sheet: anchorSheet.key, row: { sheet: tb.sheet, key: t, table } }
|
|
5493
|
+
}
|
|
5494
|
+
}).id);
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
this.flushCommits("sweep_schedule_row");
|
|
5498
|
+
const c = this.conditions.find((x) => x.finish_tag === t);
|
|
5499
|
+
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);
|
|
5500
|
+
committed = { committed: ids.length, shape_ids: ids, condition: c.finish_tag, ea_total };
|
|
3599
5501
|
}
|
|
5502
|
+
const cells = {};
|
|
5503
|
+
for (const [k, v] of Object.entries(r.cells)) cells[k] = v.text;
|
|
5504
|
+
const firstCell = r.cells[Object.keys(r.cells)[0]];
|
|
5505
|
+
const capped = perSheet.filter((p) => p.candidates.dropped > 0);
|
|
5506
|
+
const notes = [];
|
|
5507
|
+
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.`);
|
|
5508
|
+
if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
|
|
3600
5509
|
return {
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
length_px: res.seed.length_px
|
|
5510
|
+
tag: t,
|
|
5511
|
+
row: {
|
|
5512
|
+
sheet: tb.sheet,
|
|
5513
|
+
table,
|
|
5514
|
+
key: t,
|
|
5515
|
+
cells,
|
|
5516
|
+
citation: { sheet: tb.sheet, text: `${table} row ${t}`, bbox: _Session.wireBox(firstCell?.bbox || tb.region) }
|
|
3609
5517
|
},
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
5518
|
+
anchor: {
|
|
5519
|
+
sheet: anchorSheet.key,
|
|
5520
|
+
at: [round1(anchor.cx), round1(anchor.cy)],
|
|
5521
|
+
rect: [round1(anchorRect[0][0]), round1(anchorRect[0][1]), round1(anchorRect[1][0]), round1(anchorRect[1][1])],
|
|
5522
|
+
segments: fp.segments,
|
|
5523
|
+
length_px: round1(fp.totalLen),
|
|
5524
|
+
corroborated,
|
|
5525
|
+
occurrences: totalOcc
|
|
5526
|
+
},
|
|
5527
|
+
found,
|
|
5528
|
+
sheets: perSheet.map((p) => ({
|
|
5529
|
+
sheet: p.state.key,
|
|
5530
|
+
found: p.matches.length,
|
|
5531
|
+
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) })),
|
|
5532
|
+
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 })),
|
|
5533
|
+
excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
|
|
5534
|
+
text_only: p.text_only,
|
|
5535
|
+
candidates: p.candidates,
|
|
5536
|
+
elapsed_ms: p.elapsed_ms
|
|
5537
|
+
})),
|
|
5538
|
+
skipped,
|
|
5539
|
+
...committed ?? {},
|
|
5540
|
+
...notes.length ? { note: notes.join(" ") } : {},
|
|
5541
|
+
...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
5542
|
};
|
|
3620
5543
|
}
|
|
3621
5544
|
/** The mid-session shape inventory (#149): every committed shape's id,
|
|
@@ -3907,6 +5830,9 @@ var Session = class _Session {
|
|
|
3907
5830
|
else c.roll_setup = e.before.roll_setup;
|
|
3908
5831
|
}
|
|
3909
5832
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
5833
|
+
} else if (e.op === "approval") {
|
|
5834
|
+
this.approvals = applyApprovalCommand2(this.approvals, e.inverse).approvals;
|
|
5835
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
3910
5836
|
} else {
|
|
3911
5837
|
for (const { shape, index } of e.removed) {
|
|
3912
5838
|
this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
|
|
@@ -3980,18 +5906,28 @@ var Session = class _Session {
|
|
|
3980
5906
|
}
|
|
3981
5907
|
/** Read annotations, optionally narrowed to a sheet and/or a condition.
|
|
3982
5908
|
* Resolves condition_id to its finish tag so a caller can act on the reply
|
|
3983
|
-
* without joining against the conditions array.
|
|
5909
|
+
* without joining against the conditions array.
|
|
5910
|
+
*
|
|
5911
|
+
* Verdict marks (#176) ride the same inventory as their own block: the
|
|
5912
|
+
* sheet filter applies directly (a record renders on its sheet), and a
|
|
5913
|
+
* condition filter reaches a verdict THROUGH its target shape — a verdict
|
|
5914
|
+
* on a CPT-1 shape is about CPT-1 work, while a sheet-point mark carries
|
|
5915
|
+
* no scope and drops out of any condition filter. */
|
|
3984
5916
|
listAnnotations(f = {}) {
|
|
3985
5917
|
const tagById = new Map(this.conditions.map((c) => [c.id, c.finish_tag]));
|
|
3986
5918
|
let rows = this.markups;
|
|
5919
|
+
let seals = this.approvals;
|
|
3987
5920
|
if (f.sheet) {
|
|
3988
5921
|
const s = this.sheet(f.sheet);
|
|
3989
5922
|
rows = rows.filter((m) => m.sheet_id === s.key);
|
|
5923
|
+
seals = seals.filter((a) => a.sheet_id === s.key);
|
|
3990
5924
|
}
|
|
5925
|
+
const shapeById = new Map(this.shapes.map((x) => [x.id, x]));
|
|
3991
5926
|
if (f.condition) {
|
|
3992
5927
|
const c = this.conditions.find((x) => x.finish_tag === f.condition);
|
|
3993
5928
|
if (!c) throw new UserError(`no condition "${f.condition}" \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}`);
|
|
3994
5929
|
rows = rows.filter((m) => m.condition_id === c.id);
|
|
5930
|
+
seals = seals.filter((a) => a.shape_id !== void 0 && shapeById.get(a.shape_id)?.condition_id === c.id);
|
|
3995
5931
|
}
|
|
3996
5932
|
const s0 = this.sheets;
|
|
3997
5933
|
const px = (m, p) => {
|
|
@@ -4016,7 +5952,22 @@ var Session = class _Session {
|
|
|
4016
5952
|
...m.len_ft != null ? { length_lf: m.len_ft } : {}
|
|
4017
5953
|
})),
|
|
4018
5954
|
count: rows.length,
|
|
4019
|
-
unattached: rows.filter((m) => !m.condition_id).length
|
|
5955
|
+
unattached: rows.filter((m) => !m.condition_id).length,
|
|
5956
|
+
verdicts: seals.map((a) => {
|
|
5957
|
+
const sh = s0.get(a.sheet_id);
|
|
5958
|
+
const target = a.shape_id !== void 0 ? shapeById.get(a.shape_id) : void 0;
|
|
5959
|
+
return {
|
|
5960
|
+
id: a.id,
|
|
5961
|
+
actor: a.actor,
|
|
5962
|
+
sheet: a.sheet_id,
|
|
5963
|
+
...sh ? { at: [round1(a.at[0] * sh.widthPx), round1(a.at[1] * sh.heightPx)] } : {},
|
|
5964
|
+
...a.ts ? { ts: a.ts } : {},
|
|
5965
|
+
...a.shape_id !== void 0 ? { shape_id: a.shape_id } : {},
|
|
5966
|
+
condition: target ? tagById.get(target.condition_id) ?? "" : "",
|
|
5967
|
+
...typeof a.text === "string" && a.text ? { text: a.text } : {}
|
|
5968
|
+
};
|
|
5969
|
+
}),
|
|
5970
|
+
verdict_count: seals.length
|
|
4020
5971
|
};
|
|
4021
5972
|
}
|
|
4022
5973
|
/** Attach an existing annotation to a condition, or detach it with "". The
|
|
@@ -4032,6 +5983,121 @@ var Session = class _Session {
|
|
|
4032
5983
|
m.condition_id = c.id;
|
|
4033
5984
|
return { id: m.id, condition: c.finish_tag, condition_id: c.id, note: `Attached to ${c.finish_tag}.` };
|
|
4034
5985
|
}
|
|
5986
|
+
// ── verdict marks (#176) — the agent half of the approval family ───────────
|
|
5987
|
+
/** Where a shape-targeted verdict draws, in the space of the verts given.
|
|
5988
|
+
* The anchor is a render decision, not a measurement: a closed room anchors
|
|
5989
|
+
* at its area centroid, an open run at its on-path midpoint (a bent run's
|
|
5990
|
+
* centroid can sit off the work; the midpoint never does), a count marker
|
|
5991
|
+
* at the marker itself. Callers pass SHEET-PX verts so the midpoint is the
|
|
5992
|
+
* drawn run's true midpoint — arc length does not commute with the
|
|
5993
|
+
* non-uniform norm↔px map (centroids do, so they'd be safe either way).
|
|
5994
|
+
* Degenerate geometry falls back to the vertex mean. */
|
|
5995
|
+
static verdictAnchor(v, role) {
|
|
5996
|
+
if (v.length === 1) return [v[0][0], v[0][1]];
|
|
5997
|
+
const closed = role === "floor_area" || role === "deduct";
|
|
5998
|
+
if (closed && v.length >= 3) {
|
|
5999
|
+
let a = 0, cx = 0, cy = 0;
|
|
6000
|
+
for (let i = 0; i < v.length; i++) {
|
|
6001
|
+
const [x1, y1] = v[i], [x2, y2] = v[(i + 1) % v.length];
|
|
6002
|
+
const w = x1 * y2 - x2 * y1;
|
|
6003
|
+
a += w;
|
|
6004
|
+
cx += (x1 + x2) * w;
|
|
6005
|
+
cy += (y1 + y2) * w;
|
|
6006
|
+
}
|
|
6007
|
+
if (Math.abs(a) > 1e-12) return [cx / (3 * a), cy / (3 * a)];
|
|
6008
|
+
} else if (!closed && v.length >= 2) {
|
|
6009
|
+
const lens = [];
|
|
6010
|
+
let total = 0;
|
|
6011
|
+
for (let i = 1; i < v.length; i++) {
|
|
6012
|
+
const l = Math.hypot(v[i][0] - v[i - 1][0], v[i][1] - v[i - 1][1]);
|
|
6013
|
+
lens.push(l);
|
|
6014
|
+
total += l;
|
|
6015
|
+
}
|
|
6016
|
+
let walk = total / 2;
|
|
6017
|
+
for (let i = 0; i < lens.length; i++) {
|
|
6018
|
+
if (walk <= lens[i] && lens[i] > 0) {
|
|
6019
|
+
const t = walk / lens[i];
|
|
6020
|
+
return [v[i][0] + (v[i + 1][0] - v[i][0]) * t, v[i][1] + (v[i + 1][1] - v[i][1]) * t];
|
|
6021
|
+
}
|
|
6022
|
+
walk -= lens[i];
|
|
6023
|
+
}
|
|
6024
|
+
}
|
|
6025
|
+
const n = v.length || 1;
|
|
6026
|
+
return [v.reduce((s, p) => s + p[0], 0) / n, v.reduce((s, p) => s + p[1], 0) / n];
|
|
6027
|
+
}
|
|
6028
|
+
/** Mint the agent's verdict mark. actor is the string literal "agent" on
|
|
6029
|
+
* the one line that writes the record — there is no actor parameter on this
|
|
6030
|
+
* method, on the tool, or anywhere between, so no MCP path can produce the
|
|
6031
|
+
* estimator's APPROVED seal (that ink stays behind the canvas's human-only
|
|
6032
|
+
* Approve tool). The mutation and its exact-restore inverse both come from
|
|
6033
|
+
* the canvas's pure apply, so a mark here undoes and hydrates exactly like
|
|
6034
|
+
* a mark made in the app. Touches no quantity. */
|
|
6035
|
+
markVerdict(a) {
|
|
6036
|
+
let sheetId;
|
|
6037
|
+
let atNorm;
|
|
6038
|
+
let shape;
|
|
6039
|
+
if (a.shape_id !== void 0) {
|
|
6040
|
+
shape = this.shapes.find((x) => x.id === a.shape_id);
|
|
6041
|
+
if (!shape) throw new UserError(`No shape with id ${JSON.stringify(a.shape_id)} \u2014 list_shapes has the real ids.`);
|
|
6042
|
+
const dup = this.approvals.find((x) => x.actor === "agent" && x.shape_id === shape.id);
|
|
6043
|
+
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.`);
|
|
6044
|
+
sheetId = shape.sheet_id;
|
|
6045
|
+
const dims = this.sheets.get(sheetId);
|
|
6046
|
+
if (dims) {
|
|
6047
|
+
const px = shape.verts_norm.map(([nx, ny]) => [nx * dims.widthPx, ny * dims.heightPx]);
|
|
6048
|
+
const [ax, ay] = _Session.verdictAnchor(px, shape.measure_role);
|
|
6049
|
+
atNorm = [ax / dims.widthPx, ay / dims.heightPx];
|
|
6050
|
+
} else {
|
|
6051
|
+
atNorm = _Session.verdictAnchor(shape.verts_norm, shape.measure_role);
|
|
6052
|
+
}
|
|
6053
|
+
} else {
|
|
6054
|
+
const s = this.sheet(a.sheet);
|
|
6055
|
+
sheetId = s.key;
|
|
6056
|
+
atNorm = [a.at[0] / s.widthPx, a.at[1] / s.heightPx];
|
|
6057
|
+
}
|
|
6058
|
+
const text = (a.text ?? "").trim();
|
|
6059
|
+
const { approvals, inverse } = applyApprovalCommand2(this.approvals, {
|
|
6060
|
+
type: "add",
|
|
6061
|
+
approvals: [{
|
|
6062
|
+
actor: "agent",
|
|
6063
|
+
// hardcoded — the structural impossibility, not a default
|
|
6064
|
+
sheet_id: sheetId,
|
|
6065
|
+
at: atNorm,
|
|
6066
|
+
...shape ? { shape_id: shape.id } : {},
|
|
6067
|
+
...text ? { text } : {}
|
|
6068
|
+
}]
|
|
6069
|
+
});
|
|
6070
|
+
this.approvals = approvals;
|
|
6071
|
+
this.record({ op: "approval", tool: "mark_verdict", inverse });
|
|
6072
|
+
const minted = this.approvals[this.approvals.length - 1];
|
|
6073
|
+
const sh = this.sheets.get(sheetId);
|
|
6074
|
+
const tag = shape ? this.conditions.find((c) => c.id === shape.condition_id)?.finish_tag ?? "" : void 0;
|
|
6075
|
+
return {
|
|
6076
|
+
id: minted.id,
|
|
6077
|
+
actor: "agent",
|
|
6078
|
+
sheet: sheetId,
|
|
6079
|
+
...sh ? { at: [round1(atNorm[0] * sh.widthPx), round1(atNorm[1] * sh.heightPx)] } : {},
|
|
6080
|
+
ts: minted.ts,
|
|
6081
|
+
...shape ? { shape_id: shape.id } : {},
|
|
6082
|
+
...tag !== void 0 ? { condition: tag } : {},
|
|
6083
|
+
...text ? { text } : {},
|
|
6084
|
+
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."
|
|
6085
|
+
};
|
|
6086
|
+
}
|
|
6087
|
+
/** Lift an agent verdict mark. The estimator's seal is human ink and is
|
|
6088
|
+
* refused — the same line editShape holds on reviewed shapes: an agent
|
|
6089
|
+
* retracts only its own marks. */
|
|
6090
|
+
deleteVerdict(id) {
|
|
6091
|
+
const a = this.approvals.find((x) => x.id === id);
|
|
6092
|
+
if (!a) throw new UserError(`No verdict ${JSON.stringify(id)} \u2014 list_annotations returns the real ids in verdicts[].`);
|
|
6093
|
+
if (a.actor !== "agent") {
|
|
6094
|
+
throw new UserError(`${id} is the estimator's APPROVED seal \u2014 human ink, refused. An agent lifts only its own marks (actor "agent").`);
|
|
6095
|
+
}
|
|
6096
|
+
const { approvals, inverse } = applyApprovalCommand2(this.approvals, { type: "delete", ids: [id] });
|
|
6097
|
+
this.approvals = approvals;
|
|
6098
|
+
this.record({ op: "approval", tool: "delete_verdict", inverse });
|
|
6099
|
+
return { deleted: id, verdicts_remaining: this.approvals.length };
|
|
6100
|
+
}
|
|
4035
6101
|
/** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
|
|
4036
6102
|
* store.saveAnnotations stamps) — importable by the app. */
|
|
4037
6103
|
exportPayload() {
|
|
@@ -4044,6 +6110,10 @@ var Session = class _Session {
|
|
|
4044
6110
|
conditions: this.conditions,
|
|
4045
6111
|
shapes: this.shapes,
|
|
4046
6112
|
markups: this.markups,
|
|
6113
|
+
// approvals ride the payload additively (#176) — present only when any
|
|
6114
|
+
// exist, exactly the canvas buildPayload's convention, so a verdict-free
|
|
6115
|
+
// export stays byte-identical to a pre-#176 one
|
|
6116
|
+
...this.approvals.length ? { approvals: this.approvals } : {},
|
|
4047
6117
|
sheet_group: [],
|
|
4048
6118
|
last_group: [],
|
|
4049
6119
|
sheet_tabs: [],
|
|
@@ -4086,7 +6156,7 @@ var Session = class _Session {
|
|
|
4086
6156
|
inputs.push({
|
|
4087
6157
|
key: s.key,
|
|
4088
6158
|
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 }))
|
|
6159
|
+
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
6160
|
});
|
|
4091
6161
|
}
|
|
4092
6162
|
this.graph = buildSheetGraph(inputs);
|
|
@@ -4108,10 +6178,20 @@ var Session = class _Session {
|
|
|
4108
6178
|
role: s.role,
|
|
4109
6179
|
confidence: s.confidence,
|
|
4110
6180
|
...s.evidence ? { evidence: _Session.wireEvidence(s.evidence) } : {},
|
|
4111
|
-
|
|
6181
|
+
...s.building ? { building: s.building } : {},
|
|
6182
|
+
schedules: s.schedules.map((t) => ({
|
|
6183
|
+
kind: t.kind,
|
|
6184
|
+
title: t.title,
|
|
6185
|
+
rows: t.rows,
|
|
6186
|
+
region: _Session.wireBox(t.region),
|
|
6187
|
+
...t.continues ? { continues: t.continues } : {},
|
|
6188
|
+
...t.rotated_headers ? { rotated_headers: true } : {}
|
|
6189
|
+
}))
|
|
4112
6190
|
})),
|
|
4113
|
-
rooms: g.rooms.map((r) => ({ tag: r.tag, name: r.name, sheet: r.sheet, bbox: _Session.wireBox(r.bbox) })),
|
|
6191
|
+
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
6192
|
callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
|
|
6193
|
+
...g.buildings.length ? { buildings: g.buildings } : {},
|
|
6194
|
+
...g.notes.length ? { notes: g.notes } : {},
|
|
4115
6195
|
counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
|
|
4116
6196
|
};
|
|
4117
6197
|
}
|
|
@@ -4138,12 +6218,21 @@ var Session = class _Session {
|
|
|
4138
6218
|
const g = await this.ensureGraph();
|
|
4139
6219
|
if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
|
|
4140
6220
|
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")
|
|
6221
|
+
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;
|
|
6222
|
+
if (res.status === "unresolved") {
|
|
6223
|
+
return {
|
|
6224
|
+
status: "unresolved",
|
|
6225
|
+
tag: res.tag,
|
|
6226
|
+
room,
|
|
6227
|
+
reason: res.reason,
|
|
6228
|
+
...res.candidates?.length ? { candidates: res.candidates } : {}
|
|
6229
|
+
};
|
|
6230
|
+
}
|
|
4143
6231
|
return {
|
|
4144
6232
|
status: "resolved",
|
|
4145
6233
|
tag: res.tag,
|
|
4146
6234
|
room,
|
|
6235
|
+
...res.building ? { building: res.building } : {},
|
|
4147
6236
|
finishes: res.finishes.map((f) => ({
|
|
4148
6237
|
surface: f.surface,
|
|
4149
6238
|
code: f.code,
|
|
@@ -4170,7 +6259,10 @@ var Session = class _Session {
|
|
|
4170
6259
|
title: t.title?.text || "",
|
|
4171
6260
|
rows: t.rows.length,
|
|
4172
6261
|
headers: t.headers,
|
|
4173
|
-
region: _Session.wireBox(t.region)
|
|
6262
|
+
region: _Session.wireBox(t.region),
|
|
6263
|
+
...t.building ? { building: t.building } : {},
|
|
6264
|
+
...t.rotated_headers ? { rotated_headers: true } : {},
|
|
6265
|
+
...t.parts ? { parts: t.parts.map((p) => ({ sheet: p.sheet, title: p.title, rows: p.rows, region: _Session.wireBox(p.region) })) } : {}
|
|
4174
6266
|
}))
|
|
4175
6267
|
};
|
|
4176
6268
|
}
|
|
@@ -4230,6 +6322,15 @@ function traceToolCall(tool, args, startedAt, reply) {
|
|
|
4230
6322
|
// src/outputs.ts
|
|
4231
6323
|
import { z } from "zod";
|
|
4232
6324
|
var point = z.tuple([z.number(), z.number()]);
|
|
6325
|
+
var traceProvenance = {
|
|
6326
|
+
confidence: z.number().optional().describe("0..1 \u2014 the trace scored from the engine's own signals (sealed openings, door wedges, min-passage rule, hatch tier, raster boundary, mask coarseness, implausible size). A review PRIORITIZER, not a verification: 1.0 means every signal came back clean, never that the trace is right. A low score is a view_sheet {overlay:true} audit prompt, not a fact to bid from"),
|
|
6327
|
+
confidence_factors: z.array(z.string()).optional().describe('The named factors behind a sub-1.0 confidence (e.g. "sealed-opening(10% synthetic boundary)") \u2014 each names the edge worth putting eyes on; absent when every signal ran clean'),
|
|
6328
|
+
gap_sealed_px: z.number().optional().describe("Present when the seal ladder closed a genuine OPENING this many mask px wide (doorway-scale \u2014 scaled by the sheet's feet, distinct from gap_bridged_px's drafting-pinhole rescue). Part of the boundary is synthetic, and confidence deducts by that share; rides origin.gap_sealed_px on the committed shape"),
|
|
6329
|
+
min_pass_px: z.number().optional().describe("The feet-true minimum-passage rule (openings under ~0.5 ft never connect two spaces) ran at this dilation radius AND changed the answer \u2014 present only with min_pass_delta"),
|
|
6330
|
+
min_pass_delta: z.number().optional().describe("Fraction of the verbatim flood the minimum-passage rule removed; 1 means the drawn linework bounds nothing here and the rule is the only reason there is a measurement \u2014 audit before trusting"),
|
|
6331
|
+
door_wedges: z.number().int().optional().describe("Door-swing wedges annexed into the region under grow-but-verify \u2014 how many doorways' swings were included, the canvas's own door handling; rides origin.door_wedges"),
|
|
6332
|
+
ring_interiors: z.number().int().optional().describe("Of those wedges, how many were a CLOSED ring's interior (round column, callout bubble) rather than a door swing \u2014 annexed floor you may want as a deduct instead")
|
|
6333
|
+
};
|
|
4233
6334
|
var sheetSummary2 = {
|
|
4234
6335
|
sheet: z.string().describe('Sheet key: page 1 is the bare file name ("plan.pdf"), pages 2+ are "plan.pdf#2"'),
|
|
4235
6336
|
page: z.number().int().describe("1-based page number"),
|
|
@@ -4274,6 +6375,7 @@ var setScaleOutput = {
|
|
|
4274
6375
|
var oneClickOutput = {
|
|
4275
6376
|
status: z.literal("ok"),
|
|
4276
6377
|
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
6378
|
+
...traceProvenance,
|
|
4277
6379
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
4278
6380
|
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region \u2014 the rescue rides provenance (origin.gap_bridged_px) rather than passing as a clean fill"),
|
|
4279
6381
|
raster_traced: z.literal(true).optional().describe("Present when the region was bounded by the sheet's RENDERED PIXELS (the scanned-sheet raster fallback, #154) rather than vector linework \u2014 absent means the vector path ran. Rides origin.raster_traced on the committed shape; a raster ring's corners are unsnapped (a scan has no true endpoints), so audit it with view_sheet overlay before trusting the total"),
|
|
@@ -4289,6 +6391,7 @@ var detectedRoom = z.object({
|
|
|
4289
6391
|
label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
|
|
4290
6392
|
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
4291
6393
|
merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
|
|
6394
|
+
...traceProvenance,
|
|
4292
6395
|
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
4293
6396
|
gap_bridged_px: z.number().optional().describe("Present when the seal ladder bridged a drafting pinhole this many px wide to close the region"),
|
|
4294
6397
|
raster_traced: z.literal(true).optional().describe("Present when the room was bounded by rendered pixels (scanned-sheet raster fallback, #154) rather than vector linework \u2014 sheet-wide per sweep, and it rides origin.raster_traced on the committed shape"),
|
|
@@ -4350,20 +6453,39 @@ var sweepPlacement = {
|
|
|
4350
6453
|
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
4351
6454
|
mirrored: z.boolean()
|
|
4352
6455
|
};
|
|
6456
|
+
var sweepCandidates = z.object({
|
|
6457
|
+
considered: z.number().int(),
|
|
6458
|
+
dropped: z.number().int().describe("Placements never scored because the work cap bit \u2014 always disclosed, never silent")
|
|
6459
|
+
});
|
|
6460
|
+
var sweepSheetBlock = z.object({
|
|
6461
|
+
sheet: z.string(),
|
|
6462
|
+
found: z.number().int(),
|
|
6463
|
+
matches: z.array(z.object(sweepPlacement)),
|
|
6464
|
+
withheld: z.array(z.object({ ...sweepPlacement, reason: z.string() })),
|
|
6465
|
+
candidates: sweepCandidates.describe("The work cap applies PER SHEET; dropped > 0 here names exactly where the count is incomplete"),
|
|
6466
|
+
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
|
|
6467
|
+
});
|
|
6468
|
+
var sweepSkipped = z.array(z.object({
|
|
6469
|
+
sheet: z.string(),
|
|
6470
|
+
role: z.string().describe("The sheet's graph role (plan / schedule / legend / detail / \u2026)"),
|
|
6471
|
+
reason: z.string()
|
|
6472
|
+
}));
|
|
4353
6473
|
var symbolSweepOutput = {
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
6474
|
+
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[])'),
|
|
6475
|
+
found: z.number().int().describe("Placements that cleared the commit bar \u2014 across every swept sheet in set scope"),
|
|
6476
|
+
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"),
|
|
6477
|
+
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
6478
|
seed: z.object({
|
|
6479
|
+
sheet: z.string().describe("The sheet the seed rect was marqueed on"),
|
|
6480
|
+
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
6481
|
segments: z.number().int().describe("Vector segments fully inside the seed rect \u2014 the fingerprint"),
|
|
4359
6482
|
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
6483
|
rect: z.array(z.number()).length(4).describe("The seed rect actually used, post-clamp [x0, y0, x1, y1]"),
|
|
4361
6484
|
length_px: z.number().describe("Total seed linework length, image px")
|
|
4362
6485
|
}),
|
|
4363
|
-
candidates:
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
}),
|
|
6486
|
+
candidates: sweepCandidates.optional().describe("Sheet scope only \u2014 set scope accounts per sheet in sheets[]"),
|
|
6487
|
+
sheets: z.array(sweepSheetBlock).optional().describe("Set scope only: one entry per swept PLAN-role sheet, load order"),
|
|
6488
|
+
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
6489
|
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per match"),
|
|
4368
6490
|
shape_ids: z.array(z.string()).optional(),
|
|
4369
6491
|
condition: z.string().optional().describe("commit mode: the finish tag the markers counted under"),
|
|
@@ -4431,6 +6553,7 @@ var exportTakeoffOutput = {
|
|
|
4431
6553
|
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
6554
|
}).passthrough()),
|
|
4433
6555
|
markups: z.array(z.unknown()),
|
|
6556
|
+
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
6557
|
sheet_group: z.array(z.unknown()),
|
|
4435
6558
|
last_group: z.array(z.unknown()),
|
|
4436
6559
|
sheet_tabs: z.array(z.unknown()),
|
|
@@ -4498,9 +6621,9 @@ var undoLastOutput = {
|
|
|
4498
6621
|
undone: z.number().int().describe("Steps actually reversed"),
|
|
4499
6622
|
steps: z.array(z.object({
|
|
4500
6623
|
seq: z.number().int(),
|
|
4501
|
-
op: z.enum(["commit", "edit", "delete", "materials", "condition"]),
|
|
6624
|
+
op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval"]),
|
|
4502
6625
|
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)
|
|
6626
|
+
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
6627
|
})).describe("Newest first"),
|
|
4505
6628
|
shape_count: z.number().int().describe("Committed shapes after the undo"),
|
|
4506
6629
|
remaining: z.number().int().describe("Steps still available to undo"),
|
|
@@ -4573,9 +6696,10 @@ var exportReportOutput = {
|
|
|
4573
6696
|
var exportMarkedPdfOutput = {
|
|
4574
6697
|
path: z.string().describe("Absolute path of the written marked-set PDF \u2014 hand this to the user"),
|
|
4575
6698
|
pages: z.number().int().describe("Legend cover + one page per marked sheet"),
|
|
4576
|
-
sheets_marked: z.number().int().describe("Sheets carrying shapes or
|
|
6699
|
+
sheets_marked: z.number().int().describe("Sheets carrying shapes, annotations, or approval marks \u2014 unmarked sheets are omitted"),
|
|
4577
6700
|
shapes_drawn: z.number().int(),
|
|
4578
6701
|
annotations_drawn: z.number().int(),
|
|
6702
|
+
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
6703
|
note: z.string()
|
|
4580
6704
|
};
|
|
4581
6705
|
var editConditionOutput = {
|
|
@@ -4607,7 +6731,13 @@ var readSheetTextOutput = {
|
|
|
4607
6731
|
};
|
|
4608
6732
|
var wireBox = z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() });
|
|
4609
6733
|
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({
|
|
6734
|
+
var graphRoom = z.object({
|
|
6735
|
+
tag: z.string(),
|
|
6736
|
+
name: z.string().describe("The name span stacked over the tag ('' when none)"),
|
|
6737
|
+
sheet: z.string(),
|
|
6738
|
+
bbox: wireBox,
|
|
6739
|
+
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')")
|
|
6740
|
+
});
|
|
4611
6741
|
var sheetGraphOutput = {
|
|
4612
6742
|
available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
|
|
4613
6743
|
sheets: z.array(z.object({
|
|
@@ -4615,35 +6745,98 @@ var sheetGraphOutput = {
|
|
|
4615
6745
|
role: z.enum(["plan", "schedule", "legend", "detail", "elevation", "demolition", "unknown"]),
|
|
4616
6746
|
confidence: z.number().describe("0..1; mixed title signals halve it, a bare sheet-number convention stays under 0.5"),
|
|
4617
6747
|
evidence: wireEvidence.optional(),
|
|
4618
|
-
|
|
6748
|
+
building: z.string().optional().describe("The sheet's building context, when it names exactly one (BUILDING A / BLDG 2)"),
|
|
6749
|
+
schedules: z.array(z.object({
|
|
6750
|
+
kind: z.string(),
|
|
6751
|
+
title: z.string(),
|
|
6752
|
+
rows: z.number().int(),
|
|
6753
|
+
region: wireBox,
|
|
6754
|
+
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"),
|
|
6755
|
+
rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn")
|
|
6756
|
+
}))
|
|
4619
6757
|
})),
|
|
4620
6758
|
rooms: z.array(graphRoom).describe("Room tags read off plan-role sheets \u2014 schedule sheets contribute rows, never phantom rooms"),
|
|
4621
6759
|
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
|
-
|
|
6760
|
+
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')"),
|
|
6761
|
+
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"),
|
|
6762
|
+
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
6763
|
};
|
|
4624
6764
|
var resolveTagOutput = {
|
|
4625
6765
|
status: z.enum(["resolved", "unresolved"]),
|
|
4626
6766
|
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"),
|
|
6767
|
+
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"),
|
|
6768
|
+
building: z.string().optional().describe("resolved only \u2014 the building whose schedule row answered, when the set names buildings"),
|
|
4628
6769
|
finishes: z.array(z.object({
|
|
4629
6770
|
surface: z.string().describe("The schedule column: FLOOR / BASE / WALL / \u2026"),
|
|
4630
6771
|
code: z.string(),
|
|
4631
6772
|
source: wireEvidence,
|
|
4632
6773
|
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
6774
|
})).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")
|
|
6775
|
+
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)"),
|
|
6776
|
+
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"),
|
|
6777
|
+
candidates: z.array(z.object({
|
|
6778
|
+
key: z.string(),
|
|
6779
|
+
building: z.string().optional(),
|
|
6780
|
+
sheet: z.string(),
|
|
6781
|
+
table: z.string()
|
|
6782
|
+
})).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
6783
|
};
|
|
4637
6784
|
var findScheduleOutput = {
|
|
4638
6785
|
matches: z.array(z.object({
|
|
4639
6786
|
sheet: z.string(),
|
|
4640
6787
|
kind: z.string(),
|
|
4641
6788
|
title: z.string(),
|
|
4642
|
-
rows: z.number().int(),
|
|
6789
|
+
rows: z.number().int().describe("Total data rows \u2014 a continued schedule counts every fragment's rows"),
|
|
4643
6790
|
headers: z.array(z.string()),
|
|
4644
|
-
region: wireBox.describe("Pass to view_sheet to look at the table")
|
|
6791
|
+
region: wireBox.describe("Pass to view_sheet to look at the table (the BASE fragment's region when the table continues)"),
|
|
6792
|
+
building: z.string().optional().describe("The building this table answers for, when its title or sheet names one"),
|
|
6793
|
+
rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn"),
|
|
6794
|
+
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
6795
|
}))
|
|
4646
6796
|
};
|
|
6797
|
+
var rowSweepPlacement = {
|
|
6798
|
+
at: z.tuple([z.number(), z.number()]).describe("The matched marker's centroid (image px)"),
|
|
6799
|
+
score: z.number().describe("Length-weighted fraction of the anchor's segments matched within tolerance, 0..1"),
|
|
6800
|
+
rotation: z.number().describe("Detected rotation in degrees (0 | 90 | 180 | 270)"),
|
|
6801
|
+
mirrored: z.boolean()
|
|
6802
|
+
};
|
|
6803
|
+
var sweepScheduleRowOutput = {
|
|
6804
|
+
tag: z.string().describe("The row key as normalized (the tag as drawn)"),
|
|
6805
|
+
row: z.object({
|
|
6806
|
+
sheet: z.string(),
|
|
6807
|
+
table: z.string().describe("The table's title (or kind, when untitled)"),
|
|
6808
|
+
key: z.string(),
|
|
6809
|
+
cells: z.record(z.string()).describe("The row's cells, header \u2192 text \u2014 what the schedule SAYS this mark is"),
|
|
6810
|
+
citation: wireEvidence
|
|
6811
|
+
}).describe("The schedule row the sweep was seeded from \u2014 the condition's source"),
|
|
6812
|
+
anchor: z.object({
|
|
6813
|
+
sheet: z.string().describe("The plan sheet the fingerprint was anchored on"),
|
|
6814
|
+
at: z.tuple([z.number(), z.number()]).describe("The anchoring tag occurrence's center (image px)"),
|
|
6815
|
+
rect: z.array(z.number()).length(4).describe("The fingerprint rect actually used [x0, y0, x1, y1] \u2014 the pad ladder's winning step"),
|
|
6816
|
+
segments: z.number().int().describe("Vector segments in the marker fingerprint"),
|
|
6817
|
+
length_px: z.number(),
|
|
6818
|
+
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)"),
|
|
6819
|
+
occurrences: z.number().int().describe("Drawn occurrences of the tag across all plan sheets")
|
|
6820
|
+
}),
|
|
6821
|
+
found: z.number().int().describe("Matches carrying the row's own tag \u2014 the honest count, across every plan sheet"),
|
|
6822
|
+
sheets: z.array(z.object({
|
|
6823
|
+
sheet: z.string(),
|
|
6824
|
+
found: z.number().int(),
|
|
6825
|
+
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") })),
|
|
6826
|
+
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"),
|
|
6827
|
+
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"),
|
|
6828
|
+
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"),
|
|
6829
|
+
candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
|
|
6830
|
+
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep")
|
|
6831
|
+
})).describe("One entry per swept PLAN-role sheet, load order"),
|
|
6832
|
+
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"),
|
|
6833
|
+
committed: z.number().int().optional().describe("commit mode: count shapes committed \u2014 one per counted match, the whole sweep ONE undo step"),
|
|
6834
|
+
shape_ids: z.array(z.string()).optional(),
|
|
6835
|
+
condition: z.string().optional().describe("commit mode: the condition minted FROM the row \u2014 its key is the tag"),
|
|
6836
|
+
ea_total: z.number().optional(),
|
|
6837
|
+
note: z.string().optional(),
|
|
6838
|
+
warning: z.string().optional().describe("Present when the per-sheet work cap dropped candidates")
|
|
6839
|
+
};
|
|
4647
6840
|
var hatchFamilyRow = z.object({
|
|
4648
6841
|
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
6842
|
angle_deg: z.number().describe("Raw mean angle [0, 180) \u2014 rides beside the id for tolerance matching at bucket boundaries"),
|
|
@@ -4674,7 +6867,7 @@ var sheetContextOutput = {
|
|
|
4674
6867
|
note: z.string().optional()
|
|
4675
6868
|
}),
|
|
4676
6869
|
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"),
|
|
6870
|
+
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
6871
|
count: z.number().int()
|
|
4679
6872
|
}),
|
|
4680
6873
|
hatch: z.object({ families: z.array(hatchFamilyRow), count: z.number().int() })
|
|
@@ -4704,10 +6897,37 @@ var annotateOutput = {
|
|
|
4704
6897
|
length_lf: z.number().optional().describe("Dimension only: the measured length (real feet) the annotation will label itself with"),
|
|
4705
6898
|
note: z.string()
|
|
4706
6899
|
};
|
|
6900
|
+
var verdictRow = z.object({
|
|
6901
|
+
id: z.string(),
|
|
6902
|
+
actor: z.enum(["estimator", "agent"]).describe('"estimator" = the human APPROVED ring (ink \u2014 import-borne here, never minted over MCP), "agent" = the AGENT diamond'),
|
|
6903
|
+
sheet: z.string(),
|
|
6904
|
+
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)"),
|
|
6905
|
+
ts: z.string().optional().describe("ISO-8601 mint time"),
|
|
6906
|
+
shape_id: z.string().optional().describe("Present when the verdict targets a committed shape \u2014 WHAT was marked, not where it draws"),
|
|
6907
|
+
condition: z.string().describe("The targeted shape's finish tag, resolved \u2014 '' for sheet-point marks"),
|
|
6908
|
+
text: z.string().optional().describe("The optional short note riding the record")
|
|
6909
|
+
});
|
|
6910
|
+
var markVerdictOutput = {
|
|
6911
|
+
id: z.string().describe('The minted record id ("apr-\u2026")'),
|
|
6912
|
+
actor: z.literal("agent").describe("Always agent \u2014 this tool is structurally incapable of minting the estimator's seal"),
|
|
6913
|
+
sheet: z.string(),
|
|
6914
|
+
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)"),
|
|
6915
|
+
ts: z.string().describe("ISO-8601 mint time"),
|
|
6916
|
+
shape_id: z.string().optional().describe("Shape mode: the committed shape this verdict is about"),
|
|
6917
|
+
condition: z.string().optional().describe("Shape mode: the marked shape's finish tag, resolved"),
|
|
6918
|
+
text: z.string().optional(),
|
|
6919
|
+
note: z.string()
|
|
6920
|
+
};
|
|
6921
|
+
var deleteVerdictOutput = {
|
|
6922
|
+
deleted: z.string().describe("The lifted record's id"),
|
|
6923
|
+
verdicts_remaining: z.number().int().describe("Approval-family records still on the takeoff (both actors)")
|
|
6924
|
+
};
|
|
4707
6925
|
var listAnnotationsOutput = {
|
|
4708
6926
|
annotations: z.array(annotationRow),
|
|
4709
6927
|
count: z.number().int(),
|
|
4710
|
-
unattached: z.number().int().describe("How many carry no condition \u2014 candidates for link_annotation")
|
|
6928
|
+
unattached: z.number().int().describe("How many carry no condition \u2014 candidates for link_annotation"),
|
|
6929
|
+
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)"),
|
|
6930
|
+
verdict_count: z.number().int()
|
|
4711
6931
|
};
|
|
4712
6932
|
var linkAnnotationOutput = {
|
|
4713
6933
|
id: z.string(),
|
|
@@ -4720,24 +6940,6 @@ var linkAnnotationOutput = {
|
|
|
4720
6940
|
import path3 from "node:path";
|
|
4721
6941
|
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
4722
6942
|
|
|
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
6943
|
// ../web/src/lib/svgpath.js
|
|
4742
6944
|
function isSep(c) {
|
|
4743
6945
|
return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
|
|
@@ -5866,7 +8068,7 @@ var buildMarkedSetPdf2 = buildMarkedSetPdf;
|
|
|
5866
8068
|
async function exportMarkedPdf(session, opts) {
|
|
5867
8069
|
const { file, filePath } = session;
|
|
5868
8070
|
if (!file || !filePath) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
5869
|
-
if (!session.shapes.length && !session.markups.length) {
|
|
8071
|
+
if (!session.shapes.length && !session.markups.length && !session.approvals.length) {
|
|
5870
8072
|
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
8073
|
}
|
|
5872
8074
|
const sheetStates = session.sheetList();
|
|
@@ -5910,6 +8112,10 @@ async function exportMarkedPdf(session, opts) {
|
|
|
5910
8112
|
sheets,
|
|
5911
8113
|
shapes: session.shapes,
|
|
5912
8114
|
markups: session.markups,
|
|
8115
|
+
// approval marks (#176): the estimator's APPROVED rings and the agent's
|
|
8116
|
+
// AGENT diamonds burn in above the markups, and the cover tallies the
|
|
8117
|
+
// ink/pencil split — the same builder path the canvas's MARKED SET uses
|
|
8118
|
+
approvals: session.approvals,
|
|
5913
8119
|
rfis: [],
|
|
5914
8120
|
conditions: session.conditions,
|
|
5915
8121
|
getPage,
|
|
@@ -5920,13 +8126,14 @@ async function exportMarkedPdf(session, opts) {
|
|
|
5920
8126
|
});
|
|
5921
8127
|
const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
|
|
5922
8128
|
await writeFile(outPath, bytes);
|
|
5923
|
-
const markedKeys = /* @__PURE__ */ new Set([...session.shapes.map((s) => s.sheet_id), ...session.markups.map((m) => m.sheet_id)]);
|
|
8129
|
+
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
8130
|
return {
|
|
5925
8131
|
path: outPath,
|
|
5926
8132
|
pages: 1 + markedKeys.size,
|
|
5927
8133
|
sheets_marked: markedKeys.size,
|
|
5928
8134
|
shapes_drawn: session.shapes.length,
|
|
5929
8135
|
annotations_drawn: session.markups.length,
|
|
8136
|
+
approvals_drawn: session.approvals.length,
|
|
5930
8137
|
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
8138
|
};
|
|
5932
8139
|
}
|
|
@@ -5984,7 +8191,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
5984
8191
|
return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
|
|
5985
8192
|
};
|
|
5986
8193
|
const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
|
|
5987
|
-
if (!arr(cur.shapes).length && !arr(cur.markups).length) {
|
|
8194
|
+
if (!arr(cur.shapes).length && !arr(cur.markups).length && !arr(cur.approvals).length) {
|
|
5988
8195
|
const payload2 = {
|
|
5989
8196
|
...imported,
|
|
5990
8197
|
...arr(imported.sheet_tabs).length ? {} : { sheet_tabs: arr(cur.sheet_tabs) },
|
|
@@ -6022,6 +8229,8 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
6022
8229
|
const addedMarkups = arr(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
|
|
6023
8230
|
const rfiIds = new Set(arr(cur.rfis).map((r) => r?.id).filter(Boolean));
|
|
6024
8231
|
const addedRfis = arr(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
|
|
8232
|
+
const approvalIds = new Set(arr(cur.approvals).map((a) => a?.id).filter(Boolean));
|
|
8233
|
+
const addedApprovals = sanitizeApprovals(imported.approvals).filter((a) => !approvalIds.has(a.id));
|
|
6025
8234
|
const sheets = [...arr(cur.sheets)];
|
|
6026
8235
|
const scaled = new Set(sheets.map((s) => s?.sheet_id));
|
|
6027
8236
|
let scalesAdopted = 0;
|
|
@@ -6038,6 +8247,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
6038
8247
|
shapes: [...arr(cur.shapes), ...addedShapes],
|
|
6039
8248
|
markups: [...arr(cur.markups), ...addedMarkups],
|
|
6040
8249
|
...addedRfis.length ? { rfis: [...arr(cur.rfis), ...addedRfis] } : {},
|
|
8250
|
+
...addedApprovals.length ? { approvals: [...arr(cur.approvals), ...addedApprovals] } : {},
|
|
6041
8251
|
sheets
|
|
6042
8252
|
};
|
|
6043
8253
|
return {
|
|
@@ -6068,6 +8278,7 @@ async function importTakeoff(session, filePath) {
|
|
|
6068
8278
|
session.conditions = payload.conditions ?? [];
|
|
6069
8279
|
session.shapes = payload.shapes ?? [];
|
|
6070
8280
|
session.markups = payload.markups ?? [];
|
|
8281
|
+
session.approvals = sanitizeApprovals2(payload.approvals);
|
|
6071
8282
|
for (const row of payload.sheets ?? []) {
|
|
6072
8283
|
const s = session.sheetOrNull(row.sheet_id);
|
|
6073
8284
|
if (s && s.upp == null && row.units_per_px > 0) {
|
|
@@ -6138,7 +8349,7 @@ function registerTools(server, session) {
|
|
|
6138
8349
|
return session.setScale(a.sheet, a);
|
|
6139
8350
|
}));
|
|
6140
8351
|
server.registerTool("one_click", {
|
|
6141
|
-
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood
|
|
8352
|
+
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 the sealed flood engine (RFC #60), contour trace, vertices snapped to true PDF endpoints. The engine's arguments are FEET-TRUE through the sheet's scale, exactly the canvas's: gap sealing bridges up to a door-width opening (disclosed as gap_sealed_px \u2014 that much boundary is synthetic), door-swing wedges annex the swing a doorway sweeps (door_wedges), and the minimum-passage rule keeps sub-half-foot slits from conjoining two rooms (min_pass_px/min_pass_delta). Every trace carries the engine's own account of itself: confidence (0..1, with confidence_factors naming what deducted) \u2014 a review PRIORITIZER, never a verification. 1.0 means every signal ran clean, not that the trace is right; a LOW confidence is a view_sheet {overlay: true} audit prompt, not a fact to bid from \u2014 put eyes on the flagged edge before the total means anything. SCANNED sheets work too (#154): where vectors can't bound the room (an image-only scan, or a scan wrapper whose only linework is the title block), the flood falls back automatically to the sheet's rendered pixels \u2014 same engine the canvas uses \u2014 and the reply plus the committed shape's origin carry raster_traced: true so a pixel-bounded ring is never mistaken for a vector-snapped one. Vector always wins where it works; a raster ring's corners are unsnapped, so audit it with view_sheet {overlay: true} before trusting the total. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff \u2014 the full engine account rides the committed shape's origin, so the export tells the truth about how each shape was made. Without a scale it returns px-only quantities with a warning and commits nothing (the engine also degrades to its scale-blind fallbacks \u2014 a weaker measurement, one more reason set_scale comes first). role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
|
|
6142
8353
|
inputSchema: {
|
|
6143
8354
|
sheet: z2.string(),
|
|
6144
8355
|
x: z2.number(),
|
|
@@ -6152,7 +8363,7 @@ function registerTools(server, session) {
|
|
|
6152
8363
|
outputSchema: oneClickOutput
|
|
6153
8364
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
|
|
6154
8365
|
server.registerTool("detect_rooms", {
|
|
6155
|
-
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
8366
|
+
description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. With the sheet's scale set, returns area_sf/perimeter_lf per room. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
|
|
6156
8367
|
inputSchema: {
|
|
6157
8368
|
sheet: z2.string(),
|
|
6158
8369
|
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
|
|
@@ -6209,12 +8420,13 @@ function registerTools(server, session) {
|
|
|
6209
8420
|
outputSchema: placeCountOutput
|
|
6210
8421
|
}, run("place_count", (a) => session.placeCount(a.sheet, a.points, { condition: a.condition })));
|
|
6211
8422
|
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
|
|
8423
|
+
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
8424
|
inputSchema: {
|
|
6214
|
-
sheet: z2.string(),
|
|
8425
|
+
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
8426
|
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
8427
|
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
8428
|
commit: z2.boolean().default(false).describe("Commit every MATCH center as one EA count marker (withheld placements never commit)"),
|
|
8429
|
+
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
8430
|
rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated placements"),
|
|
6219
8431
|
mirror: z2.boolean().default(true).describe("Also match mirrored placements"),
|
|
6220
8432
|
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 +8435,23 @@ function registerTools(server, session) {
|
|
|
6223
8435
|
}, run("symbol_sweep", (a) => session.symbolSweep(a.sheet, {
|
|
6224
8436
|
seedRect: a.seed_rect,
|
|
6225
8437
|
condition: a.condition,
|
|
8438
|
+
commit: a.commit,
|
|
8439
|
+
scope: a.scope,
|
|
8440
|
+
rotations: a.rotations,
|
|
8441
|
+
mirror: a.mirror,
|
|
8442
|
+
tolerancePx: a.tolerance_px
|
|
8443
|
+
})));
|
|
8444
|
+
server.registerTool("sweep_schedule_row", {
|
|
8445
|
+
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}`,
|
|
8446
|
+
inputSchema: {
|
|
8447
|
+
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"),
|
|
8448
|
+
commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
|
|
8449
|
+
rotations: z2.boolean().default(true).describe("Also match 90/180/270-rotated markers"),
|
|
8450
|
+
mirror: z2.boolean().default(true).describe("Also match mirrored markers"),
|
|
8451
|
+
tolerance_px: z2.number().positive().max(20).default(2).describe("Endpoint match tolerance in image px (default 2 \u2014 CAD jitter, not drift)")
|
|
8452
|
+
},
|
|
8453
|
+
outputSchema: sweepScheduleRowOutput
|
|
8454
|
+
}, run("sweep_schedule_row", (a) => session.sweepScheduleRow(a.tag, {
|
|
6226
8455
|
commit: a.commit,
|
|
6227
8456
|
rotations: a.rotations,
|
|
6228
8457
|
mirror: a.mirror,
|
|
@@ -6273,14 +8502,14 @@ function registerTools(server, session) {
|
|
|
6273
8502
|
return doc;
|
|
6274
8503
|
}));
|
|
6275
8504
|
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
|
|
8505
|
+
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
8506
|
inputSchema: {
|
|
6278
8507
|
path: z2.string().describe("Path to a takeoff_canvas.v1 JSON file on disk")
|
|
6279
8508
|
},
|
|
6280
8509
|
outputSchema: importTakeoffOutput
|
|
6281
8510
|
}, run("import_takeoff", (a) => importTakeoff(session, a.path)));
|
|
6282
8511
|
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.`,
|
|
8512
|
+
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
8513
|
inputSchema: {
|
|
6285
8514
|
path: z2.string().optional().describe('Where to write the PDF (default: "<plan dir>/<plan> - marked set.pdf")'),
|
|
6286
8515
|
project_name: z2.string().optional().describe("Cover-page project name (default: the plan file's name)")
|
|
@@ -6371,17 +8600,17 @@ function registerTools(server, session) {
|
|
|
6371
8600
|
outputSchema: undoLastOutput
|
|
6372
8601
|
}, run("undo_last", ({ n }) => session.undoLast(n)));
|
|
6373
8602
|
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
|
|
8603
|
+
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
8604
|
inputSchema: {},
|
|
6376
8605
|
outputSchema: sheetGraphOutput
|
|
6377
8606
|
}, run("sheet_graph", () => session.sheetGraph()));
|
|
6378
8607
|
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"') },
|
|
8608
|
+
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}`,
|
|
8609
|
+
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
8610
|
outputSchema: resolveTagOutput
|
|
6382
8611
|
}, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
|
|
6383
8612
|
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}`,
|
|
8613
|
+
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
8614
|
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes) or "finish"/"material" (codes \u2192 products)') },
|
|
6386
8615
|
outputSchema: findScheduleOutput
|
|
6387
8616
|
}, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
|
|
@@ -6445,7 +8674,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
6445
8674
|
outputSchema: annotateOutput
|
|
6446
8675
|
}, run("annotate", (a) => session.annotate(a)));
|
|
6447
8676
|
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}`,
|
|
8677
|
+
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
8678
|
inputSchema: {
|
|
6450
8679
|
sheet: z2.string().optional().describe("Only annotations on this sheet"),
|
|
6451
8680
|
condition: z2.string().optional().describe("Only annotations attached to this finish tag")
|
|
@@ -6460,6 +8689,29 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
6460
8689
|
},
|
|
6461
8690
|
outputSchema: linkAnnotationOutput
|
|
6462
8691
|
}, run("link_annotation", (a) => session.linkAnnotation(a.annotation_id, a.condition)));
|
|
8692
|
+
server.registerTool("mark_verdict", {
|
|
8693
|
+
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}`,
|
|
8694
|
+
inputSchema: {
|
|
8695
|
+
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"),
|
|
8696
|
+
sheet: z2.string().optional().describe("Sheet-point mode: the sheet, together with at"),
|
|
8697
|
+
at: pointSchema.optional().describe("Sheet-point mode: where the AGENT diamond renders (image px)"),
|
|
8698
|
+
text: z2.string().optional().describe("Optional short note riding the record and every export \u2014 the glyph always reads AGENT")
|
|
8699
|
+
},
|
|
8700
|
+
outputSchema: markVerdictOutput
|
|
8701
|
+
}, run("mark_verdict", (a) => {
|
|
8702
|
+
const byShape = a.shape_id !== void 0;
|
|
8703
|
+
const byPoint = a.sheet !== void 0 || a.at !== void 0;
|
|
8704
|
+
if (byShape === byPoint) throw new UserError("Provide exactly one target: shape_id (mark a committed shape), or sheet + at (mark a sheet point).");
|
|
8705
|
+
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).");
|
|
8706
|
+
return session.markVerdict({ shape_id: a.shape_id, sheet: a.sheet, at: a.at, text: a.text });
|
|
8707
|
+
}));
|
|
8708
|
+
server.registerTool("delete_verdict", {
|
|
8709
|
+
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.`,
|
|
8710
|
+
inputSchema: {
|
|
8711
|
+
verdict_id: z2.string().describe("Record id from mark_verdict or list_annotations verdicts[]")
|
|
8712
|
+
},
|
|
8713
|
+
outputSchema: deleteVerdictOutput
|
|
8714
|
+
}, run("delete_verdict", ({ verdict_id }) => session.deleteVerdict(verdict_id)));
|
|
6463
8715
|
}
|
|
6464
8716
|
|
|
6465
8717
|
// src/resources.ts
|
|
@@ -6541,7 +8793,7 @@ function registerResources(server, session) {
|
|
|
6541
8793
|
// package.json
|
|
6542
8794
|
var package_default = {
|
|
6543
8795
|
name: "opentakeoff-mcp",
|
|
6544
|
-
version: "0.9.
|
|
8796
|
+
version: "0.9.26",
|
|
6545
8797
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
6546
8798
|
type: "module",
|
|
6547
8799
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -6557,7 +8809,7 @@ var package_default = {
|
|
|
6557
8809
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
6558
8810
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
6559
8811
|
typecheck: "tsc --noEmit",
|
|
6560
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
8812
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
6561
8813
|
},
|
|
6562
8814
|
dependencies: {
|
|
6563
8815
|
"@modelcontextprotocol/sdk": "^1.12.0",
|