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 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. |
@@ -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 segments = [];
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
- segments.push([[c1x, c1y], [c2x, c2y], [qx, qy]]);
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 median = (arr2) => {
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 = median(gaps);
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, median(widths));
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, median(spans));
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 ranked = clusters.map((cl) => {
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
- 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);
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 segments = [], metaOut = [], family = [];
4796
+ const segments2 = [], metaOut = [], family = [];
4482
4797
  for (const { i } of kept) {
4483
- segments.push([
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.27",
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. 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).",
9027
- "4. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone."
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.27",
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",