opentakeoff-mcp 0.9.27 → 0.9.30
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 +1 -0
- package/dist/server-core.js +471 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
114
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
115
|
| `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
|
|
116
|
+
| `derive_transitions` | **The transition where two finishes meet**: pass two finish tags and the tag to commit under, and every committed room of each is compared against every room of the other. The catch this is built around — flood-traced rooms **do not share edges**, a partition puts 4–8″ between them — so proximity comes in two flavours and they are never conflated. A **butt joint** (rings running together inside one open space, within an inch) *is* the transition and commits as a linear shape, `origin.derived` naming both parents, the tags, and the measured gap. A **wall-separated** run means the rooms are adjacent across a partition, where the transition is a threshold in a doorway that nothing in the trace record locates (the flood engine reports how *much* boundary it sealed, never where) — those return in `withheld` with length, gap in inches, and an `at` point to `view_sheet`, as questions rather than a confident wrong number. `max_gap_in` (default 12) only ever turns more of the plan into questions, never into committed LF. All-or-nothing; one undo step. |
|
|
116
117
|
| `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob — pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
|
|
117
118
|
| `place_count` | **EA markers**: one point, one each — thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
|
|
118
119
|
| `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the vector linework is searched deterministically for every other placement — translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. **`scope: "set"` sweeps the whole working set, counting on PLAN-role sheets only** (the sheet graph decides; every excluded sheet disclosed in `skipped` with role and reason) — and the seed rect may sit on a detail or legend sheet, which then serves as the fingerprint SOURCE while staying excluded from counting: the estimator's "click the assembly in the detail, count it on the plans" gesture. Per-sheet results carry their own match/withheld lists, per-sheet cap accounting, and wall-clock `elapsed_ms`. `commit: true` + `condition` commits every match center as an EA count marker — the whole sweep (set-wide included) is one undo step, `origin.method "symbol_sweep"` with per-marker score, transform, and seed source (`origin.symbol.seed`). No scale required. |
|
package/dist/server-core.js
CHANGED
|
@@ -479,14 +479,14 @@ function cloudBezier(x0, y0, x1, y1) {
|
|
|
479
479
|
const ax0 = Math.min(x0, x1), ay0 = Math.min(y0, y1), ax1 = Math.max(x0, x1), ay1 = Math.max(y0, y1);
|
|
480
480
|
const r = Math.max(6, Math.min(22, (ax1 - ax0 + ay1 - ay0) / 22));
|
|
481
481
|
const arc = (len) => Math.max(1, Math.round(len / (r * 1.6)));
|
|
482
|
-
const
|
|
482
|
+
const segments2 = [];
|
|
483
483
|
let px = ax0, py = ay0;
|
|
484
484
|
const edge = (fromX, fromY, toX, toY) => {
|
|
485
485
|
const n = arc(Math.hypot(toX - fromX, toY - fromY));
|
|
486
486
|
for (let i = 1; i <= n; i++) {
|
|
487
487
|
const qx = fromX + (toX - fromX) * (i / n), qy = fromY + (toY - fromY) * (i / n);
|
|
488
488
|
const [c1x, c1y, c2x, c2y] = arcToBezier(px, py, qx, qy, r, 0, 1);
|
|
489
|
-
|
|
489
|
+
segments2.push([[c1x, c1y], [c2x, c2y], [qx, qy]]);
|
|
490
490
|
px = qx;
|
|
491
491
|
py = qy;
|
|
492
492
|
}
|
|
@@ -495,7 +495,7 @@ function cloudBezier(x0, y0, x1, y1) {
|
|
|
495
495
|
edge(ax1, ay0, ax1, ay1);
|
|
496
496
|
edge(ax1, ay1, ax0, ay1);
|
|
497
497
|
edge(ax0, ay1, ax0, ay0);
|
|
498
|
-
return { start: [ax0, ay0], segments };
|
|
498
|
+
return { start: [ax0, ay0], segments: segments2 };
|
|
499
499
|
}
|
|
500
500
|
function buildSnapGrid(points, cell) {
|
|
501
501
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1203,7 +1203,7 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1203
1203
|
clusters.pop();
|
|
1204
1204
|
}
|
|
1205
1205
|
}
|
|
1206
|
-
const
|
|
1206
|
+
const median2 = (arr2) => {
|
|
1207
1207
|
const a = arr2.slice().sort((x, y) => x - y);
|
|
1208
1208
|
return a[a.length >> 1];
|
|
1209
1209
|
};
|
|
@@ -1240,17 +1240,17 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1240
1240
|
if (count < HATCH_MIN_RUN) return;
|
|
1241
1241
|
const gaps = [];
|
|
1242
1242
|
for (let k = a + 1; k <= b; k++) gaps.push(rows[k].d - rows[k - 1].d);
|
|
1243
|
-
const med =
|
|
1243
|
+
const med = median2(gaps);
|
|
1244
1244
|
if (!med) return;
|
|
1245
1245
|
let reg = 0;
|
|
1246
1246
|
for (const g of gaps) if (Math.abs(g - med) <= med * HATCH_PITCH_TOL) reg++;
|
|
1247
1247
|
if (reg / gaps.length < HATCH_MIN_REGULAR) return;
|
|
1248
1248
|
const widths = [];
|
|
1249
1249
|
for (let k = a; k <= b; k++) for (const s of rows[k].segs) widths.push(s.w);
|
|
1250
|
-
const modalW = Math.max(1,
|
|
1250
|
+
const modalW = Math.max(1, median2(widths));
|
|
1251
1251
|
const spans = [];
|
|
1252
1252
|
for (let k = a; k <= b; k++) spans.push(rows[k].t1 - rows[k].t0);
|
|
1253
|
-
const medSpan = Math.max(1,
|
|
1253
|
+
const medSpan = Math.max(1, median2(spans));
|
|
1254
1254
|
const memberIdx = [];
|
|
1255
1255
|
const softIdx = [];
|
|
1256
1256
|
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
@@ -1292,6 +1292,76 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1292
1292
|
}
|
|
1293
1293
|
return { clipSoft, runs };
|
|
1294
1294
|
}
|
|
1295
|
+
var ANNOT_OFFSET_MAX_FT = 2;
|
|
1296
|
+
var ANNOT_OFFSET_MIN_FT = 0.25;
|
|
1297
|
+
var ANNOT_MIN_LEN_FT = 4;
|
|
1298
|
+
var ANNOT_OVERLAP_FRAC = 0.6;
|
|
1299
|
+
var ANNOT_MIN_PEN_STEP = 1;
|
|
1300
|
+
var ANNOT_MAX_SCAN = 64;
|
|
1301
|
+
function classifyOffsetAnnotationSegs(segs, meta, ws, maxOffsetPx, minOffsetPx, minLenPx) {
|
|
1302
|
+
const n = segs.length >> 2;
|
|
1303
|
+
const soft = new Uint8Array(n);
|
|
1304
|
+
if (!meta || !n || !(maxOffsetPx > 0) || !(minLenPx > 0)) return soft;
|
|
1305
|
+
const cand = [];
|
|
1306
|
+
for (let i = 0; i < n; i++) {
|
|
1307
|
+
const mt = meta[i];
|
|
1308
|
+
if (mt & (SEG_CURVE | SEG_CLIP | SEG_FILLONLY)) continue;
|
|
1309
|
+
const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
|
|
1310
|
+
const dx = x2 - x1, dy = y2 - y1;
|
|
1311
|
+
if (Math.hypot(dx, dy) < minLenPx) continue;
|
|
1312
|
+
let ang = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
1313
|
+
if (ang < 0) ang += 180;
|
|
1314
|
+
if (ang >= 180) ang -= 180;
|
|
1315
|
+
cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
|
|
1316
|
+
}
|
|
1317
|
+
if (cand.length < 2) return soft;
|
|
1318
|
+
const BIN = HATCH_ANGLE_TOL, NBINS = Math.max(1, Math.round(180 / BIN));
|
|
1319
|
+
for (const shift of [0, BIN / 2]) {
|
|
1320
|
+
const bins = /* @__PURE__ */ new Map();
|
|
1321
|
+
for (const c of cand) {
|
|
1322
|
+
let b = Math.floor((c.ang + shift) / BIN);
|
|
1323
|
+
if (b >= NBINS) b -= NBINS;
|
|
1324
|
+
const th = c.ang * Math.PI / 180;
|
|
1325
|
+
const dxu = Math.cos(th), dyu = Math.sin(th);
|
|
1326
|
+
const nxu = -dyu, nyu = dxu;
|
|
1327
|
+
const r = {
|
|
1328
|
+
i: c.i,
|
|
1329
|
+
ang: c.ang,
|
|
1330
|
+
d: (c.x1 + c.x2) / 2 * nxu + (c.y1 + c.y2) / 2 * nyu,
|
|
1331
|
+
t0: Math.min(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
|
|
1332
|
+
t1: Math.max(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
|
|
1333
|
+
w: c.w
|
|
1334
|
+
};
|
|
1335
|
+
const arr2 = bins.get(b);
|
|
1336
|
+
if (arr2) arr2.push(r);
|
|
1337
|
+
else bins.set(b, [r]);
|
|
1338
|
+
}
|
|
1339
|
+
for (const runs of bins.values()) {
|
|
1340
|
+
if (runs.length < 2) continue;
|
|
1341
|
+
runs.sort((a, b) => a.d - b.d);
|
|
1342
|
+
for (let k = 0; k < runs.length; k++) {
|
|
1343
|
+
const c = runs[k];
|
|
1344
|
+
if (soft[c.i]) continue;
|
|
1345
|
+
const need = ANNOT_OVERLAP_FRAC * (c.t1 - c.t0);
|
|
1346
|
+
const alongside = (dir) => {
|
|
1347
|
+
for (let step = 1, j = k + dir; step <= ANNOT_MAX_SCAN && j >= 0 && j < runs.length; step++, j += dir) {
|
|
1348
|
+
const o = runs[j];
|
|
1349
|
+
const gap = Math.abs(o.d - c.d);
|
|
1350
|
+
if (gap > maxOffsetPx) break;
|
|
1351
|
+
if (gap < minOffsetPx) continue;
|
|
1352
|
+
if (Math.min(o.t1, c.t1) - Math.max(o.t0, c.t0) < need) continue;
|
|
1353
|
+
return o;
|
|
1354
|
+
}
|
|
1355
|
+
return null;
|
|
1356
|
+
};
|
|
1357
|
+
const up = alongside(1), dn = alongside(-1);
|
|
1358
|
+
const heavier = (o, other) => !!o && !other && o.w >= c.w + ANNOT_MIN_PEN_STEP;
|
|
1359
|
+
if (heavier(up, dn) || heavier(dn, up)) soft[c.i] = 1;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return soft;
|
|
1364
|
+
}
|
|
1295
1365
|
var HATCH_ID_ANGLE_Q = 0.5;
|
|
1296
1366
|
var HATCH_ID_PITCH_Q = 0.1;
|
|
1297
1367
|
function hatchFamilies(segs, meta) {
|
|
@@ -1335,6 +1405,10 @@ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt
|
|
|
1335
1405
|
const mask = new Uint8Array(mw * mh);
|
|
1336
1406
|
const mppf = Number.isFinite(pxPerFt) && pxPerFt > 0 ? pxPerFt * ws : 0;
|
|
1337
1407
|
const soft = meta ? classifyHatchSegs(segs, meta, ws, mppf > 0 ? HATCH_MAX_PITCH_FT * mppf : HATCH_MAX_PITCH) : null;
|
|
1408
|
+
const annot = meta && mppf > 0 ? classifyOffsetAnnotationSegs(segs, meta, ws, ANNOT_OFFSET_MAX_FT * mppf, ANNOT_OFFSET_MIN_FT * mppf, ANNOT_MIN_LEN_FT * mppf) : null;
|
|
1409
|
+
if (soft && annot) {
|
|
1410
|
+
for (let i = 0; i < soft.length; i++) if (annot[i]) soft[i] = 1;
|
|
1411
|
+
}
|
|
1338
1412
|
const noDoor = meta ? flagNonDoorArcs(segs, meta) : null;
|
|
1339
1413
|
let softCount = 0;
|
|
1340
1414
|
for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
|
|
@@ -1779,6 +1853,123 @@ function boundaryCurveClusters(mo, region) {
|
|
|
1779
1853
|
}
|
|
1780
1854
|
return clusters;
|
|
1781
1855
|
}
|
|
1856
|
+
var SPLIT_GAP = 1;
|
|
1857
|
+
var SPLIT_MIN_SEED = 8;
|
|
1858
|
+
var SAME_ARC_CENTRE_FT = 0.5;
|
|
1859
|
+
var SAME_ARC_RADIUS_FRAC = 0.15;
|
|
1860
|
+
function splitMergedArcs(cl, mw, mask, mppf) {
|
|
1861
|
+
if (cl.length < 2 * SPLIT_MIN_SEED) return [cl];
|
|
1862
|
+
if (arcClusterFit(cl, mw, mask).good) return [cl];
|
|
1863
|
+
const set = new Set(cl);
|
|
1864
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1865
|
+
const parts = [];
|
|
1866
|
+
for (const start of cl) {
|
|
1867
|
+
if (seen.has(start)) continue;
|
|
1868
|
+
seen.add(start);
|
|
1869
|
+
const comp = [start];
|
|
1870
|
+
const stack = [start];
|
|
1871
|
+
while (stack.length) {
|
|
1872
|
+
const i = stack.pop();
|
|
1873
|
+
const y = i / mw | 0, x = i - y * mw;
|
|
1874
|
+
for (let dy = -SPLIT_GAP; dy <= SPLIT_GAP; dy++) {
|
|
1875
|
+
for (let dx = -SPLIT_GAP; dx <= SPLIT_GAP; dx++) {
|
|
1876
|
+
if (!dx && !dy) continue;
|
|
1877
|
+
const j = (y + dy) * mw + (x + dx);
|
|
1878
|
+
if (set.has(j) && !seen.has(j)) {
|
|
1879
|
+
seen.add(j);
|
|
1880
|
+
comp.push(j);
|
|
1881
|
+
stack.push(j);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
parts.push(comp);
|
|
1887
|
+
}
|
|
1888
|
+
if (parts.length < 2) return [cl];
|
|
1889
|
+
const centreTol = mppf > 0 ? Math.max(2, SAME_ARC_CENTRE_FT * mppf) : 4;
|
|
1890
|
+
const groups = [];
|
|
1891
|
+
const orphans = [];
|
|
1892
|
+
for (const p of parts.slice().sort((a, b) => b.length - a.length)) {
|
|
1893
|
+
const f = p.length >= SPLIT_MIN_SEED ? arcClusterFit(p, mw, mask) : null;
|
|
1894
|
+
const claim = f && f.good ? groups.find((gp) => Math.hypot(gp.cx - f.cx, gp.cy - f.cy) <= centreTol && Math.abs(gp.r - f.r) <= SAME_ARC_RADIUS_FRAC * Math.max(gp.r, f.r)) : void 0;
|
|
1895
|
+
if (claim) {
|
|
1896
|
+
claim.cells.push(...p);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
if (f && f.good) {
|
|
1900
|
+
groups.push({ cx: f.cx, cy: f.cy, r: f.r, cells: p.slice() });
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
orphans.push(...p);
|
|
1904
|
+
}
|
|
1905
|
+
if (groups.length < 2) return [cl];
|
|
1906
|
+
const out = groups.map((gp) => gp.cells);
|
|
1907
|
+
if (orphans.length) out.push(orphans);
|
|
1908
|
+
return out;
|
|
1909
|
+
}
|
|
1910
|
+
var LEAF_MIN_SECTOR_FRAC = 0.15;
|
|
1911
|
+
var LEAF_MIN_COVER = 0.55;
|
|
1912
|
+
var LEAF_HALF_FT = 0.3;
|
|
1913
|
+
var LEAF_HALF_FLOOR_PX = 1.5;
|
|
1914
|
+
var LEAF_T0 = 0.2;
|
|
1915
|
+
var LEAF_T1 = 0.95;
|
|
1916
|
+
function doorLeafCells(fit, cl, mw, mh, mask, mppf = 0) {
|
|
1917
|
+
if (!fit.good || !(fit.r > 2)) return null;
|
|
1918
|
+
const LEAF_HALF_W = mppf > 0 ? Math.max(LEAF_HALF_FLOOR_PX, LEAF_HALF_FT * mppf) : LEAF_HALF_FLOOR_PX;
|
|
1919
|
+
const angs = cl.map((i) => Math.atan2((i / mw | 0) - fit.cy, i % mw - fit.cx)).sort((a, b) => a - b);
|
|
1920
|
+
if (angs.length < 2) return null;
|
|
1921
|
+
let gapAt = -1, gapMax = angs[0] + 2 * Math.PI - angs[angs.length - 1];
|
|
1922
|
+
for (let k = 1; k < angs.length; k++) {
|
|
1923
|
+
const gp = angs[k] - angs[k - 1];
|
|
1924
|
+
if (gp > gapMax) {
|
|
1925
|
+
gapMax = gp;
|
|
1926
|
+
gapAt = k;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const ends = gapAt < 0 ? [angs[0], angs[angs.length - 1]] : [angs[gapAt], angs[gapAt - 1]];
|
|
1930
|
+
let best = null, bestCover = LEAF_MIN_COVER;
|
|
1931
|
+
for (const th of ends) {
|
|
1932
|
+
const ux = Math.cos(th), uy = Math.sin(th);
|
|
1933
|
+
const ax = fit.cx + ux * fit.r * LEAF_T0, ay = fit.cy + uy * fit.r * LEAF_T0;
|
|
1934
|
+
const bx = fit.cx + ux * fit.r * LEAF_T1, by = fit.cy + uy * fit.r * LEAF_T1;
|
|
1935
|
+
const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - LEAF_HALF_W - 1));
|
|
1936
|
+
const x1 = Math.min(mw - 1, Math.ceil(Math.max(ax, bx) + LEAF_HALF_W + 1));
|
|
1937
|
+
const y0 = Math.max(0, Math.floor(Math.min(ay, by) - LEAF_HALF_W - 1));
|
|
1938
|
+
const y1 = Math.min(mh - 1, Math.ceil(Math.max(ay, by) + LEAF_HALF_W + 1));
|
|
1939
|
+
const dx = bx - ax, dy = by - ay, L2 = dx * dx + dy * dy;
|
|
1940
|
+
if (!(L2 > 0)) continue;
|
|
1941
|
+
const cells = [];
|
|
1942
|
+
for (let y = y0; y <= y1; y++) {
|
|
1943
|
+
for (let x = x0; x <= x1; x++) {
|
|
1944
|
+
let t = ((x - ax) * dx + (y - ay) * dy) / L2;
|
|
1945
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
1946
|
+
const ex = ax + t * dx - x, ey = ay + t * dy - y;
|
|
1947
|
+
if (ex * ex + ey * ey > LEAF_HALF_W * LEAF_HALF_W) continue;
|
|
1948
|
+
const i = y * mw + x;
|
|
1949
|
+
if (mask[i] & 1 && !(mask[i] & MASK_CURVE_BIT)) cells.push(i);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
let hit = 0, tries = 0;
|
|
1953
|
+
for (let t = 0; t <= 1; t += 0.02) {
|
|
1954
|
+
tries++;
|
|
1955
|
+
const px = ax + t * dx, py = ay + t * dy;
|
|
1956
|
+
let inked = false;
|
|
1957
|
+
for (let o = -LEAF_HALF_W; o <= LEAF_HALF_W && !inked; o += 0.5) {
|
|
1958
|
+
const x = Math.round(px - dy / Math.sqrt(L2) * o), y = Math.round(py + dx / Math.sqrt(L2) * o);
|
|
1959
|
+
if (x < 0 || y < 0 || x >= mw || y >= mh) continue;
|
|
1960
|
+
const i = y * mw + x;
|
|
1961
|
+
if (mask[i] & 1 && !(mask[i] & MASK_CURVE_BIT)) inked = true;
|
|
1962
|
+
}
|
|
1963
|
+
if (inked) hit++;
|
|
1964
|
+
}
|
|
1965
|
+
const cover = tries ? hit / tries : 0;
|
|
1966
|
+
if (cover > bestCover && cells.length) {
|
|
1967
|
+
bestCover = cover;
|
|
1968
|
+
best = cells;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return best && best.length ? best : null;
|
|
1972
|
+
}
|
|
1782
1973
|
function arcClusterFit(cl, mw, mask) {
|
|
1783
1974
|
const m = cl.length;
|
|
1784
1975
|
const X = (i) => i % mw, Y = (i) => i / mw | 0;
|
|
@@ -2009,10 +2200,33 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
|
|
|
2009
2200
|
let hatchTier = r1.hatchTier;
|
|
2010
2201
|
let sealedPx = r1.sealedPx, virtualFrac = r1.virtualFrac;
|
|
2011
2202
|
let minPassPxOut = r1.minPassPx, minPassDelta = r1.minPassDelta;
|
|
2012
|
-
const
|
|
2203
|
+
const entry = (cl, pass, fit) => {
|
|
2204
|
+
const f = fit ?? arcClusterFit(cl, mw, mo.mask);
|
|
2205
|
+
const ideal = 0.5 * f.r * f.r * f.sweep;
|
|
2206
|
+
return {
|
|
2207
|
+
cl,
|
|
2208
|
+
fit: f,
|
|
2209
|
+
allow: wedgeAllowance(f, mo.mppf || 0, wedgeCapPx),
|
|
2210
|
+
rank: doorLikeness(f, mo.mppf || 0),
|
|
2211
|
+
at: cl[0],
|
|
2212
|
+
pass,
|
|
2213
|
+
minGrow: pass === 2 ? Math.max(1, Math.round(LEAF_MIN_SECTOR_FRAC * ideal)) : 0
|
|
2214
|
+
};
|
|
2215
|
+
};
|
|
2216
|
+
const ranked = [];
|
|
2217
|
+
for (const cl of clusters) {
|
|
2013
2218
|
const fit = arcClusterFit(cl, mw, mo.mask);
|
|
2014
|
-
|
|
2015
|
-
|
|
2219
|
+
ranked.push(entry(cl, 0, fit));
|
|
2220
|
+
const parts = splitMergedArcs(cl, mw, mo.mask, mo.mppf || 0);
|
|
2221
|
+
const pieces = parts.length > 1 ? parts : [cl];
|
|
2222
|
+
if (parts.length > 1) for (const p of parts) ranked.push(entry(p, 1));
|
|
2223
|
+
for (const p of pieces) {
|
|
2224
|
+
const pf = p === cl ? fit : arcClusterFit(p, mw, mo.mask);
|
|
2225
|
+
const leaf = doorLeafCells(pf, p, mw, mh, mo.mask, mo.mppf || 0);
|
|
2226
|
+
if (leaf) ranked.push({ ...entry(leaf, 2, pf), cl: leaf });
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
ranked.splice(0, ranked.length, ...ranked.filter((c) => c.allow >= 1).sort((a, b) => a.pass - b.pass || b.rank - a.rank || a.at - b.at));
|
|
2016
2230
|
let m2mask = null, m2dt = null;
|
|
2017
2231
|
let openedCl = null, dirty = null;
|
|
2018
2232
|
const rb1 = boxOf(r1.region, mw, mh);
|
|
@@ -2022,7 +2236,7 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
|
|
|
2022
2236
|
sealCache.set(mo.mask, base);
|
|
2023
2237
|
}
|
|
2024
2238
|
const baseDT = base.dt;
|
|
2025
|
-
for (const { cl, fit, allow: clusterAllowance } of ranked.slice(0, WEDGE_MAX_DOORS)) {
|
|
2239
|
+
for (const { cl, fit, allow: clusterAllowance, minGrow } of ranked.slice(0, 2 * WEDGE_MAX_DOORS)) {
|
|
2026
2240
|
if (!m2mask) {
|
|
2027
2241
|
m2mask = mo.mask.slice();
|
|
2028
2242
|
m2dt = baseDT.slice();
|
|
@@ -2056,6 +2270,7 @@ function floodRegionSealedInner(mo, ix, iy, sensitivity, radii, wedgeCapPx, minP
|
|
|
2056
2270
|
const r2 = sealAttempt(m2, sx, sy, sensitivity, radii, minPassPx, sc2);
|
|
2057
2271
|
if (r2.status !== "ok" || r2.count <= r1.count) continue;
|
|
2058
2272
|
const growth = r2.count - r1.count;
|
|
2273
|
+
if (growth < minGrow) continue;
|
|
2059
2274
|
if (growth > clusterAllowance) continue;
|
|
2060
2275
|
if (count - r1.count + growth > globalAllowance) continue;
|
|
2061
2276
|
if (!region) {
|
|
@@ -3299,6 +3514,106 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
3299
3514
|
};
|
|
3300
3515
|
}
|
|
3301
3516
|
|
|
3517
|
+
// ../web/src/lib/transitions.ts
|
|
3518
|
+
var COINCIDENT_PX = 1e-9;
|
|
3519
|
+
function segments(ring) {
|
|
3520
|
+
const out = [];
|
|
3521
|
+
for (let i = 0; i < ring.length; i++) out.push([ring[i], ring[(i + 1) % ring.length]]);
|
|
3522
|
+
return out;
|
|
3523
|
+
}
|
|
3524
|
+
function nearestOnSeg(p, a, b) {
|
|
3525
|
+
const vx = b[0] - a[0], vy = b[1] - a[1];
|
|
3526
|
+
const len2 = vx * vx + vy * vy;
|
|
3527
|
+
const t = len2 > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2)) : 0;
|
|
3528
|
+
const at = [a[0] + vx * t, a[1] + vy * t];
|
|
3529
|
+
return { d: Math.hypot(p[0] - at[0], p[1] - at[1]), at };
|
|
3530
|
+
}
|
|
3531
|
+
function nearestOnRing(p, ring) {
|
|
3532
|
+
let best = { d: Infinity, at: p };
|
|
3533
|
+
for (const [a, b] of segments(ring)) {
|
|
3534
|
+
const hit = nearestOnSeg(p, a, b);
|
|
3535
|
+
if (hit.d < best.d) best = hit;
|
|
3536
|
+
}
|
|
3537
|
+
return best;
|
|
3538
|
+
}
|
|
3539
|
+
function sampleRing(ring, step) {
|
|
3540
|
+
const out = [];
|
|
3541
|
+
for (const [a, b] of segments(ring)) {
|
|
3542
|
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
3543
|
+
if (!(len > 0)) continue;
|
|
3544
|
+
const n = Math.max(1, Math.ceil(len / step));
|
|
3545
|
+
for (let k = 0; k < n; k++) {
|
|
3546
|
+
const t = k / n;
|
|
3547
|
+
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
return out;
|
|
3551
|
+
}
|
|
3552
|
+
function median(xs) {
|
|
3553
|
+
if (!xs.length) return Infinity;
|
|
3554
|
+
const s = [...xs].sort((p, q) => p - q);
|
|
3555
|
+
const m = s.length >> 1;
|
|
3556
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
3557
|
+
}
|
|
3558
|
+
function sharedRuns(ringA, ringB, opts) {
|
|
3559
|
+
if (ringA.length < 3 || ringB.length < 3) return [];
|
|
3560
|
+
const { step_px, touch_px, max_gap_px, min_len_px } = opts;
|
|
3561
|
+
const samples = sampleRing(ringA, step_px);
|
|
3562
|
+
if (samples.length < 2) return [];
|
|
3563
|
+
const near = samples.map((p) => nearestOnRing(p, ringB));
|
|
3564
|
+
const dists = near.map((h) => h.d);
|
|
3565
|
+
const alongside = samples.map((p, i) => {
|
|
3566
|
+
if (dists[i] > max_gap_px) return false;
|
|
3567
|
+
const prev = samples[(i - 1 + samples.length) % samples.length];
|
|
3568
|
+
const next = samples[(i + 1) % samples.length];
|
|
3569
|
+
const tx = next[0] - prev[0], ty = next[1] - prev[1];
|
|
3570
|
+
const tl = Math.hypot(tx, ty);
|
|
3571
|
+
const dx = near[i].at[0] - p[0], dy = near[i].at[1] - p[1];
|
|
3572
|
+
const dl = Math.hypot(dx, dy);
|
|
3573
|
+
if (tl === 0) return false;
|
|
3574
|
+
if (dl < COINCIDENT_PX) return true;
|
|
3575
|
+
return Math.abs((tx * dx + ty * dy) / (tl * dl)) <= 0.5;
|
|
3576
|
+
});
|
|
3577
|
+
const raw = [];
|
|
3578
|
+
let start = -1;
|
|
3579
|
+
for (let i = 0; i < samples.length; i++) {
|
|
3580
|
+
if (alongside[i] && start < 0) start = i;
|
|
3581
|
+
if (!alongside[i] && start >= 0) {
|
|
3582
|
+
raw.push({ from: start, to: i - 1 });
|
|
3583
|
+
start = -1;
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
if (start >= 0) raw.push({ from: start, to: samples.length - 1 });
|
|
3587
|
+
if (raw.length > 1) {
|
|
3588
|
+
const first = raw[0], last = raw[raw.length - 1];
|
|
3589
|
+
if (first.from === 0 && last.to === samples.length - 1) {
|
|
3590
|
+
raw.pop();
|
|
3591
|
+
raw[0] = { from: last.from, to: first.to + samples.length };
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
const runs = [];
|
|
3595
|
+
for (const r of raw) {
|
|
3596
|
+
const idx = [];
|
|
3597
|
+
for (let i = r.from; i <= r.to; i++) idx.push(i % samples.length);
|
|
3598
|
+
const path5 = idx.map((i) => samples[i]);
|
|
3599
|
+
let length_px = 0;
|
|
3600
|
+
for (let i = 1; i < path5.length; i++) {
|
|
3601
|
+
length_px += Math.hypot(path5[i][0] - path5[i - 1][0], path5[i][1] - path5[i - 1][1]);
|
|
3602
|
+
}
|
|
3603
|
+
if (length_px < min_len_px) continue;
|
|
3604
|
+
const gap_px = median(idx.map((i) => dists[i]));
|
|
3605
|
+
const mid = path5[path5.length >> 1];
|
|
3606
|
+
runs.push({
|
|
3607
|
+
kind: gap_px <= touch_px ? "butt" : "wall",
|
|
3608
|
+
path: path5,
|
|
3609
|
+
length_px,
|
|
3610
|
+
gap_px,
|
|
3611
|
+
at: [mid[0], mid[1]]
|
|
3612
|
+
});
|
|
3613
|
+
}
|
|
3614
|
+
return runs;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3302
3617
|
// ../web/src/lib/provenance.js
|
|
3303
3618
|
var mintUuid = () => {
|
|
3304
3619
|
const c = globalThis.crypto;
|
|
@@ -4478,9 +4793,9 @@ var Session = class _Session {
|
|
|
4478
4793
|
kept = visible2.slice().sort((a, b) => b.len - a.len).slice(0, cap);
|
|
4479
4794
|
droppedCap = visible2.length - cap;
|
|
4480
4795
|
}
|
|
4481
|
-
const
|
|
4796
|
+
const segments2 = [], metaOut = [], family = [];
|
|
4482
4797
|
for (const { i } of kept) {
|
|
4483
|
-
|
|
4798
|
+
segments2.push([
|
|
4484
4799
|
round1(geo.segs[i * 4]),
|
|
4485
4800
|
round1(geo.segs[i * 4 + 1]),
|
|
4486
4801
|
round1(geo.segs[i * 4 + 2]),
|
|
@@ -4502,7 +4817,7 @@ var Session = class _Session {
|
|
|
4502
4817
|
region: [round1(r.x0), round1(r.y0), round1(r.x1), round1(r.y1)],
|
|
4503
4818
|
has_vector_linework: hasVectors,
|
|
4504
4819
|
vectors: {
|
|
4505
|
-
segments,
|
|
4820
|
+
segments: segments2,
|
|
4506
4821
|
meta: metaOut,
|
|
4507
4822
|
family,
|
|
4508
4823
|
kept: kept.length,
|
|
@@ -5181,6 +5496,100 @@ var Session = class _Session {
|
|
|
5181
5496
|
note: "Base runs trace each room's boundary; openings are your stated claim, recorded on origin.derived. Verify with view_sheet overlay:true."
|
|
5182
5497
|
};
|
|
5183
5498
|
}
|
|
5499
|
+
/** Mint the transition where two finishes meet (#202) — the derivation that
|
|
5500
|
+
* follows derive_base, and the one an estimator draws by hand on every job.
|
|
5501
|
+
*
|
|
5502
|
+
* The geometry lives in web/src/lib/transitions.ts, and its headline is that
|
|
5503
|
+
* flood-traced rooms DO NOT SHARE EDGES: a partition puts four to eight
|
|
5504
|
+
* inches between two rings, so what is actually there is proximity, in two
|
|
5505
|
+
* flavours that mean different things. A BUTT JOINT (the rings run together
|
|
5506
|
+
* inside one open space) is the transition, and commits. A WALL-SEPARATED run
|
|
5507
|
+
* means the rooms are adjacent across a partition — the transition there is a
|
|
5508
|
+
* threshold in the DOORWAY, and nothing in the trace record says where the
|
|
5509
|
+
* doorway is: the flood engine seals openings and reports only how much
|
|
5510
|
+
* boundary it synthesised, never where. Committing thirty-four feet of
|
|
5511
|
+
* threshold because two rooms share thirty-four feet of wall would be a wrong
|
|
5512
|
+
* bid with a machine's confidence behind it, so those come back in
|
|
5513
|
+
* `withheld` — length, gap, and a point to look at — as questions.
|
|
5514
|
+
*
|
|
5515
|
+
* All-or-nothing like derive_base: unknown tags, a transition landing on
|
|
5516
|
+
* either source tag, or an unscaled sheet refuses the whole call before
|
|
5517
|
+
* anything commits. The sweep is ONE journal gesture. */
|
|
5518
|
+
deriveTransitions(opts) {
|
|
5519
|
+
const findCond = (tag) => {
|
|
5520
|
+
const c = this.conditions.find((x) => x.finish_tag === tag);
|
|
5521
|
+
if (!c) throw new UserError(`No condition ${JSON.stringify(tag)} \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}.`);
|
|
5522
|
+
return c;
|
|
5523
|
+
};
|
|
5524
|
+
const a = findCond(opts.condition_a), b = findCond(opts.condition_b);
|
|
5525
|
+
if (a.id === b.id) throw new UserError("condition_a and condition_b must be different finishes \u2014 a tag does not transition to itself.");
|
|
5526
|
+
if (opts.condition === a.finish_tag || opts.condition === b.finish_tag) {
|
|
5527
|
+
throw new UserError(`The transition must land on its OWN tag (e.g. 'T-1') \u2014 committing onto ${opts.condition} would add its LF to one of the finishes it separates.`);
|
|
5528
|
+
}
|
|
5529
|
+
const maxGapIn = opts.max_gap_in ?? 12;
|
|
5530
|
+
const minRunIn = opts.min_run_in ?? 12;
|
|
5531
|
+
if (!(maxGapIn > 0)) throw new UserError("max_gap_in must be > 0.");
|
|
5532
|
+
if (!(minRunIn > 0)) throw new UserError("min_run_in must be > 0.");
|
|
5533
|
+
const floors = (c) => this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "floor_area");
|
|
5534
|
+
const fa = floors(a), fb = floors(b);
|
|
5535
|
+
for (const [tag, list] of [[a.finish_tag, fa], [b.finish_tag, fb]]) {
|
|
5536
|
+
if (!list.length) throw new UserError(`${tag} has no floor_area shapes to derive from \u2014 commit rooms first (one_click / detect_rooms).`);
|
|
5537
|
+
}
|
|
5538
|
+
const sheetsInPlay = [...new Set([...fa, ...fb].map((s) => s.sheet_id))];
|
|
5539
|
+
for (const key of sheetsInPlay) {
|
|
5540
|
+
const s = this.sheet(key);
|
|
5541
|
+
if (s.upp == null) throw new UserError(`${key} has no scale \u2014 a transition is a real length, so set_scale first (${this.scaleGate(s)})`);
|
|
5542
|
+
}
|
|
5543
|
+
const committed = [], withheld = [];
|
|
5544
|
+
for (const key of sheetsInPlay) {
|
|
5545
|
+
const s = this.sheet(key);
|
|
5546
|
+
const upp = s.upp;
|
|
5547
|
+
const pxPerFt = 1 / upp;
|
|
5548
|
+
const toPx = (sh) => sh.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
|
|
5549
|
+
const onSheetA = fa.filter((x) => x.sheet_id === key), onSheetB = fb.filter((x) => x.sheet_id === key);
|
|
5550
|
+
for (const ra of onSheetA) {
|
|
5551
|
+
for (const rb of onSheetB) {
|
|
5552
|
+
const runs = sharedRuns(toPx(ra), toPx(rb), {
|
|
5553
|
+
step_px: Math.max(1, pxPerFt * 0.25),
|
|
5554
|
+
// a quarter-foot walk — finer than any transition matters
|
|
5555
|
+
touch_px: pxPerFt * (1 / 12),
|
|
5556
|
+
// within an inch: one open space, not two rooms
|
|
5557
|
+
max_gap_px: pxPerFt * (maxGapIn / 12),
|
|
5558
|
+
min_len_px: pxPerFt * (minRunIn / 12)
|
|
5559
|
+
});
|
|
5560
|
+
for (const r of runs) this.recordRun(s, r, upp, opts.condition, ra.id, rb.id, a.finish_tag, b.finish_tag, committed, withheld);
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
if (committed.length) this.flushCommits("derive_transitions");
|
|
5565
|
+
return {
|
|
5566
|
+
condition: opts.condition,
|
|
5567
|
+
between: [a.finish_tag, b.finish_tag],
|
|
5568
|
+
committed: committed.length,
|
|
5569
|
+
total_lf: round2(committed.reduce((n, r) => n + r.length_lf, 0)),
|
|
5570
|
+
runs: committed,
|
|
5571
|
+
withheld,
|
|
5572
|
+
withheld_lf: round2(withheld.reduce((n, r) => n + r.length_lf, 0)),
|
|
5573
|
+
note: withheld.length ? `${withheld.length} run(s) are adjacency ACROSS A WALL, not a butt joint \u2014 the transition there is a threshold in the doorway, and the trace record does not say where the doorway is. view_sheet each \`at\` and place them with measure_line / place_count.` : "Every run was a butt joint inside one open space. Verify with view_sheet overlay:true before trusting the total."
|
|
5574
|
+
};
|
|
5575
|
+
}
|
|
5576
|
+
/** One shared run → committed transition, or a disclosed question. */
|
|
5577
|
+
recordRun(s, r, upp, condition, aId, bId, aTag, bTag, committed, withheld) {
|
|
5578
|
+
const length_lf = round2(r.length_px * upp);
|
|
5579
|
+
const gap_in = round1(r.gap_px * upp * 12);
|
|
5580
|
+
const row = { sheet: s.key, between_shape_ids: [aId, bId], length_lf, gap_in, at: [Math.round(r.at[0]), Math.round(r.at[1])] };
|
|
5581
|
+
if (r.kind === "wall") {
|
|
5582
|
+
withheld.push({ ...row, reason: "wall_separated", detail: `${aTag} and ${bTag} run ${length_lf} LF apart across ${gap_in}" of wall \u2014 adjacent rooms, not a butt joint. If a door opens here the transition is a threshold at the door, which this cannot see.` });
|
|
5583
|
+
return;
|
|
5584
|
+
}
|
|
5585
|
+
const shape = this.commit(s, condition, "linear", r.path, { area_sf: 0, perimeter_lf: length_lf }, {
|
|
5586
|
+
method: "agent_v1",
|
|
5587
|
+
actor: "agent",
|
|
5588
|
+
reviewed: false,
|
|
5589
|
+
derived: { between_shape_ids: [aId, bId], between: [aTag, bTag], case: "butt", gap_in }
|
|
5590
|
+
});
|
|
5591
|
+
committed.push({ ...row, shape_id: shape.id });
|
|
5592
|
+
}
|
|
5184
5593
|
/** Count markers — the canvas's Count tool (commitCount): one point, one EA,
|
|
5185
5594
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
5186
5595
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
@@ -6736,6 +7145,27 @@ var deriveBaseOutput = {
|
|
|
6736
7145
|
total_lf: z.number().describe("Sum of net_lf across rooms"),
|
|
6737
7146
|
note: z.string()
|
|
6738
7147
|
};
|
|
7148
|
+
var transitionRun = {
|
|
7149
|
+
sheet: z.string(),
|
|
7150
|
+
between_shape_ids: z.array(z.string()).describe("The two floor_area shapes this run separates"),
|
|
7151
|
+
length_lf: z.number().describe("Run length along the first shape's boundary"),
|
|
7152
|
+
gap_in: z.number().describe("Median distance between the two rings across the run, in inches \u2014 0-ish is one open space, 4-8 is a partition"),
|
|
7153
|
+
at: z.array(z.number()).describe("Run midpoint (image px) \u2014 pass to view_sheet to look at it")
|
|
7154
|
+
};
|
|
7155
|
+
var deriveTransitionsOutput = {
|
|
7156
|
+
condition: z.string().describe("The tag the transitions committed under"),
|
|
7157
|
+
between: z.array(z.string()).describe("The two finish tags"),
|
|
7158
|
+
committed: z.number().int(),
|
|
7159
|
+
total_lf: z.number().describe("Sum of committed run lengths \u2014 butt joints only"),
|
|
7160
|
+
runs: z.array(z.object({ ...transitionRun, shape_id: z.string() })),
|
|
7161
|
+
withheld: z.array(z.object({
|
|
7162
|
+
...transitionRun,
|
|
7163
|
+
reason: z.literal("wall_separated"),
|
|
7164
|
+
detail: z.string()
|
|
7165
|
+
})).describe("Adjacency across a wall: real, measured, and NOT committed \u2014 the transition there is a threshold at a doorway this cannot locate"),
|
|
7166
|
+
withheld_lf: z.number().describe("Shared-wall length held back \u2014 never part of total_lf"),
|
|
7167
|
+
note: z.string()
|
|
7168
|
+
};
|
|
6739
7169
|
var listShapesOutput = {
|
|
6740
7170
|
shapes: z.array(z.object({
|
|
6741
7171
|
id: z.string(),
|
|
@@ -8621,6 +9051,27 @@ function registerTools(server, session) {
|
|
|
8621
9051
|
},
|
|
8622
9052
|
outputSchema: deriveBaseOutput
|
|
8623
9053
|
}, run("derive_base", (a) => session.deriveBase(a)));
|
|
9054
|
+
server.registerTool("derive_transitions", {
|
|
9055
|
+
description: `Mint the transition where two finishes MEET (#202) \u2014 the derivation that follows derive_base, and the line an estimator draws by hand on every job. Pass the two finish tags and the tag the transition commits under (e.g. condition_a 'CPT-1', condition_b 'PT-1', condition 'T-1'), and every committed room of each is compared against every committed room of the other.
|
|
9056
|
+
|
|
9057
|
+
WHAT THE GEOMETRY ACTUALLY IS, because it decides what you get back: flood-traced rooms DO NOT SHARE EDGES. A trace fills to the wall linework, so two rooms across a partition are separated by four to eight inches of nothing \u2014 testing for a shared edge finds zero transitions on a real planset. What is there is proximity, in two flavours that mean completely different things:
|
|
9058
|
+
|
|
9059
|
+
\u2022 BUTT JOINT \u2014 the two rings run together inside ONE open space (a lobby that changes from carpet to tile with no wall between). The transition IS that run, and it commits as a linear shape under your tag, origin.derived naming both parent shapes and the measured gap.
|
|
9060
|
+
|
|
9061
|
+
\u2022 WALL-SEPARATED \u2014 the rings run parallel across a partition. The rooms are adjacent, but the transition is NOT the shared wall: it is a threshold, in the doorway, and NOTHING in the trace record says where the doorway is (the flood engine seals openings and reports how MUCH boundary it synthesised, never where). Committing 34 LF of threshold because two rooms share 34 LF of wall would be a wrong bid with a machine's confidence behind it. These come back in \`withheld\` \u2014 measured, with their length, their gap in inches, and an \`at\` point \u2014 as questions you answer by LOOKING (view_sheet at \`at\`, then measure_line or place_count the threshold yourself). The symbol_sweep doctrine: a near-match is never a silent commit and never a silent drop.
|
|
9062
|
+
|
|
9063
|
+
Tuning: max_gap_in (default 12) is how far apart two rings can be and still count as adjacent at all \u2014 raise it for thick walls, and every extra inch turns more of the plan into wall_separated questions, never into committed LF. min_run_in (default 12) drops corner artifacts. The butt-joint threshold is fixed at one inch and is not a knob: "these two finishes touch" is not a judgement call.
|
|
9064
|
+
|
|
9065
|
+
All-or-nothing, like derive_base: an unknown tag, a transition landing on either source tag, the same tag twice, or a sheet without a scale refuses the whole call before anything commits. The whole sweep is ONE undo step. After it, LOOK \u2014 view_sheet {overlay: true} over each run \u2014 before trusting total_lf. ${COORDS}`,
|
|
9066
|
+
inputSchema: {
|
|
9067
|
+
condition_a: z2.string().describe("First finish tag, e.g. 'CPT-1' \u2014 its committed rooms are walked, and runs are traced along their boundaries"),
|
|
9068
|
+
condition_b: z2.string().describe("Second finish tag, e.g. 'PT-1'"),
|
|
9069
|
+
condition: z2.string().describe("Finish tag the transitions commit under (minted on first use), e.g. 'T-1'. Must differ from both sources"),
|
|
9070
|
+
max_gap_in: z2.number().positive().optional().describe("How far apart two rings can be and still count as adjacent, in inches (default 12 \u2014 a thick partition). Wider only produces more wall_separated QUESTIONS, never more committed LF"),
|
|
9071
|
+
min_run_in: z2.number().positive().optional().describe("Shortest run worth reporting, in inches (default 12) \u2014 below this is a corner where two rooms clip, not a transition")
|
|
9072
|
+
},
|
|
9073
|
+
outputSchema: deriveTransitionsOutput
|
|
9074
|
+
}, run("derive_transitions", (a) => session.deriveTransitions(a)));
|
|
8624
9075
|
server.registerTool("takeoff_summary", {
|
|
8625
9076
|
description: `Per-condition totals (floor/wall/border SF, LF, EA, SY, with and without waste) plus grand totals \u2014 the Report's numbers, computed by the same rules. Numbers only: the deliverable that SHOWS the work on the drawings is export_marked_pdf. ${COORDS}`,
|
|
8626
9077
|
inputSchema: {},
|
|
@@ -8945,7 +9396,7 @@ function registerResources(server, session) {
|
|
|
8945
9396
|
// package.json
|
|
8946
9397
|
var package_default = {
|
|
8947
9398
|
name: "opentakeoff-mcp",
|
|
8948
|
-
version: "0.9.
|
|
9399
|
+
version: "0.9.30",
|
|
8949
9400
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
8950
9401
|
type: "module",
|
|
8951
9402
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -8961,7 +9412,7 @@ var package_default = {
|
|
|
8961
9412
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
8962
9413
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
8963
9414
|
typecheck: "tsc --noEmit",
|
|
8964
|
-
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"
|
|
9415
|
+
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/transitions.test.ts test/view.test.ts"
|
|
8965
9416
|
},
|
|
8966
9417
|
dependencies: {
|
|
8967
9418
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -9023,8 +9474,10 @@ function buildServer(session = new Session()) {
|
|
|
9023
9474
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
9024
9475
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
9025
9476
|
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
|
|
9026
|
-
"3.
|
|
9027
|
-
"4.
|
|
9477
|
+
"3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
|
|
9478
|
+
"4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
9479
|
+
"5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
|
|
9480
|
+
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse."
|
|
9028
9481
|
].join("\n")
|
|
9029
9482
|
});
|
|
9030
9483
|
registerTools(server, session);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"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"
|
|
19
|
+
"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/transitions.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|