opentakeoff-mcp 0.9.27 → 0.9.29

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.
Files changed (2) hide show
  1. package/dist/server-core.js +220 -5
  2. package/package.json +1 -1
@@ -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) {
@@ -8945,7 +9160,7 @@ function registerResources(server, session) {
8945
9160
  // package.json
8946
9161
  var package_default = {
8947
9162
  name: "opentakeoff-mcp",
8948
- version: "0.9.27",
9163
+ version: "0.9.29",
8949
9164
  mcpName: "io.github.Kentucky-ai/opentakeoff",
8950
9165
  type: "module",
8951
9166
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
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.",