opentakeoff-mcp 0.9.63 → 0.9.65

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
@@ -158,6 +158,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
158
158
  | `export_report` | The **computed Report document**—`opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers—`export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path` (see **Writing to disk** below). |
159
159
  | `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import—finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. Correction rules (#88) ride the file too—`apply_rules` re-runs them. |
160
160
  | `apply_rules` | **Re-run the correction rules the takeoff arrived with** (#207)—the lessons an estimator TAUGHT the canvas (#88), for example, "every room like this loses the mechanical chase". Same pure `rules.ts` engine the canvas Preview runs; committed as the ONE batch its Apply makes (`reviewed: false`, one undo step, `origin {method rule_v1, actor rule}` with the rule/seed/room citation). The reply's per-rule disclosure—produced, skipped, ids—IS the preview an agent gets. Idempotent by construction: anything an existing deduct covers is dropped by the engine, so re-running after new rooms commit is the intended workflow. Rules arrive ONLY through `import_takeoff`; minting one is an estimator's correction and stays behind the canvas's human Preview→Apply gate. |
161
+ | `export_dxf` | The takeoff as a **CAD drawing**—a DXF (R2000) that AutoCAD, BricsCAD, LibreCAD and Revit import as native geometry. One sheet per file, like a DWG: every committed shape becomes an `LWPOLYLINE` (floor rings closed, walls and linear runs open, counts a circle) on a layer named for its finish—`OT-<TAG>`, with `-DEDUCT` / `-HOLE` / `-WALL` / `-LINEAR` / `-COUNT` suffix layers and room labels on `OT-LABELS`. Real units in the sheet's frame (origin bottom-left, Y up; feet, or `units:"m"`), and a ring's CAD area equals its `export_report` area to rounding. Refuses without a scale; with several sheets carrying shapes, `sheet` picks the drawing. Names every shape left out and why. `path` required. |
161
162
  | `export_marked_pdf` | The **marked-up planset**—the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in—built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself, and the cover states where the finish tags came from (`Finish assignment: N schedule-resolved · N agent-asserted · …`, plus any rooms the last assign run withheld). Default path: `<plan> - marked set.pdf` next to the plan (see **Writing to disk** below). Works without `@napi-rs/canvas`. |
162
163
  | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, room `label`, review state, and assignment verdict (`schedule` \| `asserted`—where its finish tag came from) in one compact read—the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
163
164
  | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
@@ -1432,6 +1432,306 @@ function classifyOffsetAnnotationSegs(segs, meta, ws, maxOffsetPx, minOffsetPx,
1432
1432
  }
1433
1433
  return soft;
1434
1434
  }
1435
+ var DIMSTR_ANGLE_TOL = 2;
1436
+ var DIMSTR_CHAIN_GAP_FT = 0.6;
1437
+ var DIMSTR_CHAIN_GAP_PX = 3;
1438
+ var DIMSTR_CHAIN_OFF_FT = 0.06;
1439
+ var DIMSTR_CHAIN_OFF_PX = 1.25;
1440
+ var DIMSTR_RUN_MIN_FT = 4;
1441
+ var DIMSTR_ISO_OFF_FT = 1;
1442
+ var DIMSTR_ISO_OVERLAP = 0.4;
1443
+ var DIMSTR_WITNESS_MIN_FT = 0.3;
1444
+ var DIMSTR_WITNESS_SOFT_MAX_FT = 8;
1445
+ var DIMSTR_ATTACH_FT = 0.35;
1446
+ var DIMSTR_ATTACH_PX = 2;
1447
+ var DIMSTR_JUNCTION_MIN_FT = 1.5;
1448
+ var DIMSTR_WIT_PER_JUNCTION_MAX = 2;
1449
+ var DIMSTR_TICK_MIN_FT = 0.2;
1450
+ var DIMSTR_TICK_MAX_FT = 1.5;
1451
+ var DIMSTR_TICK_ANG_LO = 20;
1452
+ var DIMSTR_TICK_ANG_HI = 70;
1453
+ var DIMSTR_TICK_AT_JUNCTION_FT = 0.5;
1454
+ var DIMSTR_MIN_JUNCTIONS = 2;
1455
+ var DIMSTR_MIN_TICKED = 2;
1456
+ var DIMSTR_TICKED_FRAC = 0.5;
1457
+ var DIMSTR_OVERSHOOT_FT = 0.1;
1458
+ var DIMSTR_OVERSHOOT_PX = 2;
1459
+ var DIMSTR_TOUCH_CLUTTER_MAX = 1;
1460
+ var DIMTEXT_NEAR_FT = 1;
1461
+ var DIMTEXT_REACH_FT = 40;
1462
+ var DIMTEXT_MIN_LINE_X = 1.5;
1463
+ var DIMTEXT_STUB_MAX_FT = 2.5;
1464
+ var DIMSTR_LINE_MARGIN_FT = 0.5;
1465
+ var DIMSTR_PIECE_MAX_DIMFRAC = 0.66;
1466
+ var DIMSTR_PIECE_MAX_CURVEFRAC = 0.25;
1467
+ var DIMSTR_BAND_FT = 0.7;
1468
+ var DIMSTR_BAND_CLUTTER_MAX = 4;
1469
+ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1470
+ const n = segs.length >> 2;
1471
+ const soft = new Uint8Array(n);
1472
+ const hits = [];
1473
+ if (!meta || !n || !(ftPx > 0)) return { soft, runs: hits };
1474
+ const cand = [];
1475
+ for (let i = 0; i < n; i++) {
1476
+ if (meta[i] & (SEG_CURVE | SEG_CLIP | SEG_FILLONLY)) continue;
1477
+ const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
1478
+ const L = Math.hypot(x2 - x1, y2 - y1);
1479
+ if (!(L > 0)) continue;
1480
+ let ang = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
1481
+ if (ang < 0) ang += 180;
1482
+ if (ang >= 180) ang -= 180;
1483
+ cand.push({ i, ang, x1, y1, x2, y2, L });
1484
+ }
1485
+ if (!cand.length) return { soft, runs: hits };
1486
+ const BIN = DIMSTR_ANGLE_TOL, NB = Math.round(180 / BIN);
1487
+ const gap = Math.max(DIMSTR_CHAIN_GAP_PX, DIMSTR_CHAIN_GAP_FT * ftPx);
1488
+ const offTol = Math.max(DIMSTR_CHAIN_OFF_PX, DIMSTR_CHAIN_OFF_FT * ftPx);
1489
+ const chains = [];
1490
+ const claimed = new Uint8Array(n);
1491
+ for (const shift of [0, BIN / 2]) {
1492
+ const bins = /* @__PURE__ */ new Map();
1493
+ for (const c of cand) {
1494
+ if (claimed[c.i]) continue;
1495
+ let b = Math.floor((c.ang + shift) / BIN);
1496
+ if (b >= NB) b -= NB;
1497
+ const axis = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1498
+ const th = axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1499
+ const r = {
1500
+ c,
1501
+ d: (c.x1 + c.x2) / 2 * nx + (c.y1 + c.y2) / 2 * ny,
1502
+ t0: Math.min(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy),
1503
+ t1: Math.max(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy)
1504
+ };
1505
+ const arr2 = bins.get(b);
1506
+ if (arr2) arr2.push(r);
1507
+ else bins.set(b, [r]);
1508
+ }
1509
+ for (const [b, rows] of bins) {
1510
+ const axis = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1511
+ rows.sort((a, z4) => a.d - z4.d || a.t0 - z4.t0);
1512
+ let g0 = 0;
1513
+ for (let k = 1; k <= rows.length; k++) {
1514
+ if (k < rows.length && rows[k].d - rows[k - 1].d <= offTol) continue;
1515
+ const grp = rows.slice(g0, k).sort((a, z4) => a.t0 - z4.t0 || a.t1 - z4.t1);
1516
+ let cur = null;
1517
+ const flush = () => {
1518
+ if (!cur) return;
1519
+ if (shift === 0 || cur.members.length > 1 || !claimed[cur.members[0]]) {
1520
+ if (shift === 0 && cur.members.length < 2) {
1521
+ cur = null;
1522
+ return;
1523
+ }
1524
+ cur.d = cur.dSum / cur.members.length;
1525
+ cur.len = cur.t1 - cur.t0;
1526
+ const th = axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1527
+ cur.p0 = [cur.t0 * dx + cur.d * nx, cur.t0 * dy + cur.d * ny];
1528
+ cur.p1 = [cur.t1 * dx + cur.d * nx, cur.t1 * dy + cur.d * ny];
1529
+ chains.push(cur);
1530
+ for (const m of cur.members) claimed[m] = 1;
1531
+ }
1532
+ cur = null;
1533
+ };
1534
+ for (const r of grp) {
1535
+ if (cur && r.t0 - cur.t1 <= gap) {
1536
+ cur.t1 = Math.max(cur.t1, r.t1);
1537
+ cur.members.push(r.c.i);
1538
+ cur.dSum += r.d;
1539
+ } else {
1540
+ flush();
1541
+ cur = { axis, d: 0, dSum: r.d, t0: r.t0, t1: r.t1, len: 0, members: [r.c.i], p0: [0, 0], p1: [0, 0] };
1542
+ }
1543
+ }
1544
+ flush();
1545
+ g0 = k;
1546
+ }
1547
+ }
1548
+ }
1549
+ const runMin = DIMSTR_RUN_MIN_FT * ftPx, isoOff = DIMSTR_ISO_OFF_FT * ftPx;
1550
+ const wMin = DIMSTR_WITNESS_MIN_FT * ftPx, wSoftMax = DIMSTR_WITNESS_SOFT_MAX_FT * ftPx;
1551
+ const attach = Math.max(DIMSTR_ATTACH_PX, DIMSTR_ATTACH_FT * ftPx);
1552
+ const jMin = DIMSTR_JUNCTION_MIN_FT * ftPx;
1553
+ const tickMin = DIMSTR_TICK_MIN_FT * ftPx, tickMax = DIMSTR_TICK_MAX_FT * ftPx;
1554
+ const tickAt = DIMSTR_TICK_AT_JUNCTION_FT * ftPx;
1555
+ const over = Math.max(DIMSTR_OVERSHOOT_PX, DIMSTR_OVERSHOOT_FT * ftPx);
1556
+ const band = DIMSTR_BAND_FT * ftPx;
1557
+ const hasParallelPartner = (C, skip) => {
1558
+ const th = C.axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1559
+ for (const O of chains) {
1560
+ if (O === C || O === skip) continue;
1561
+ let rel = Math.abs(O.axis - C.axis);
1562
+ if (rel > 90) rel = 180 - rel;
1563
+ if (rel > 3 * DIMSTR_ANGLE_TOL) continue;
1564
+ const d0 = O.p0[0] * nx + O.p0[1] * ny - C.d, d1 = O.p1[0] * nx + O.p1[1] * ny - C.d;
1565
+ const gapAbs = Math.min(Math.abs(d0), Math.abs(d1));
1566
+ if (gapAbs > isoOff || gapAbs < attach / 2) continue;
1567
+ const t0 = Math.min(O.p0[0] * dx + O.p0[1] * dy, O.p1[0] * dx + O.p1[1] * dy);
1568
+ const t1 = Math.max(O.p0[0] * dx + O.p0[1] * dy, O.p1[0] * dx + O.p1[1] * dy);
1569
+ if (Math.min(t1, C.t1) - Math.max(t0, C.t0) >= DIMSTR_ISO_OVERLAP * C.len) return true;
1570
+ }
1571
+ return false;
1572
+ };
1573
+ const reject = (R, why) => {
1574
+ rejects?.push({ axis: R.axis, d: R.d, t0: R.t0, t1: R.t1, lenFt: R.len / ftPx, why });
1575
+ };
1576
+ for (const R of chains) {
1577
+ if (R.len < runMin) continue;
1578
+ if (hasParallelPartner(R, null)) {
1579
+ reject(R, "iso");
1580
+ continue;
1581
+ }
1582
+ const th = R.axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1583
+ const wit = [], ticks = [];
1584
+ let touchClutter = 0;
1585
+ for (const C of chains) {
1586
+ if (C === R) continue;
1587
+ const d0 = C.p0[0] * nx + C.p0[1] * ny - R.d, d1 = C.p1[0] * nx + C.p1[1] * ny - R.d;
1588
+ const near0 = Math.abs(d0) <= attach, near1 = Math.abs(d1) <= attach;
1589
+ const crosses = d0 < -attach && d1 > attach || d1 < -attach && d0 > attach;
1590
+ if (!near0 && !near1 && !crosses) continue;
1591
+ const t0 = C.p0[0] * dx + C.p0[1] * dy, t1 = C.p1[0] * dx + C.p1[1] * dy;
1592
+ const tm = (t0 + t1) / 2;
1593
+ const tRef = near0 && !near1 ? t0 : near1 && !near0 ? t1 : tm;
1594
+ if (tRef < R.t0 - attach || tRef > R.t1 + attach) continue;
1595
+ let rel = Math.abs(C.axis - R.axis);
1596
+ if (rel > 90) rel = 180 - rel;
1597
+ if (rel >= 90 - 2 * DIMSTR_ANGLE_TOL && C.len >= wMin) {
1598
+ wit.push({ t: tRef, C, past: crosses || near0 && near1 });
1599
+ } else if (rel >= DIMSTR_TICK_ANG_LO && rel <= DIMSTR_TICK_ANG_HI && C.len >= tickMin && C.len <= tickMax && Math.abs((d0 + d1) / 2) <= attach) {
1600
+ ticks.push({ t: tm, C, past: false });
1601
+ } else if (++touchClutter > DIMSTR_TOUCH_CLUTTER_MAX) break;
1602
+ }
1603
+ if (touchClutter > DIMSTR_TOUCH_CLUTTER_MAX) {
1604
+ reject(R, "touch-clutter");
1605
+ continue;
1606
+ }
1607
+ const live = wit.filter((w) => w.past || w.t >= R.t0 + over && w.t <= R.t1 - over);
1608
+ if (live.length < DIMSTR_MIN_JUNCTIONS) {
1609
+ reject(R, `witnesses (raw=${wit.length} live=${live.length} ticks=${ticks.length})`);
1610
+ continue;
1611
+ }
1612
+ live.sort((a, z4) => a.t - z4.t);
1613
+ const junc = [];
1614
+ let dense = false;
1615
+ for (const w of live) {
1616
+ const j = junc[junc.length - 1];
1617
+ if (j && w.t - j.t < jMin) {
1618
+ if (++j.n > DIMSTR_WIT_PER_JUNCTION_MAX) {
1619
+ dense = true;
1620
+ break;
1621
+ }
1622
+ } else junc.push({ t: w.t, n: 1 });
1623
+ }
1624
+ if (dense || junc.length < DIMSTR_MIN_JUNCTIONS) {
1625
+ reject(R, dense ? "dense" : "junctions");
1626
+ continue;
1627
+ }
1628
+ let ticked = 0;
1629
+ const usedTicks = [];
1630
+ for (const j of junc) {
1631
+ const t = ticks.find((tk) => Math.abs(tk.t - j.t) <= tickAt);
1632
+ if (t) {
1633
+ ticked++;
1634
+ usedTicks.push(t);
1635
+ }
1636
+ }
1637
+ if (ticked < DIMSTR_MIN_TICKED || ticked < DIMSTR_TICKED_FRAC * junc.length) {
1638
+ reject(R, `ticked (junc=${junc.length} ticked=${ticked} ticks=${ticks.length})`);
1639
+ continue;
1640
+ }
1641
+ const witSet = new Set(live.map((w) => w.C)), tickSet = new Set(usedTicks.map((t) => t.C));
1642
+ let bandClutter = 0;
1643
+ for (const C of chains) {
1644
+ if (C === R || witSet.has(C) || tickSet.has(C)) continue;
1645
+ const mx = (C.p0[0] + C.p1[0]) / 2, my = (C.p0[1] + C.p1[1]) / 2;
1646
+ if (Math.abs(mx * nx + my * ny - R.d) > band) continue;
1647
+ const tt = mx * dx + my * dy;
1648
+ if (tt < R.t0 || tt > R.t1) continue;
1649
+ if (++bandClutter > DIMSTR_BAND_CLUTTER_MAX) break;
1650
+ }
1651
+ if (bandClutter > DIMSTR_BAND_CLUTTER_MAX) {
1652
+ reject(R, "band-clutter");
1653
+ continue;
1654
+ }
1655
+ hits.push(R);
1656
+ for (const m of R.members) soft[m] = 1;
1657
+ for (const t of usedTicks) for (const m of t.C.members) soft[m] = 1;
1658
+ for (const w of live) {
1659
+ if (w.C.len > wSoftMax) continue;
1660
+ if (hasParallelPartner(w.C, R)) continue;
1661
+ for (const m of w.C.members) soft[m] = 1;
1662
+ }
1663
+ }
1664
+ if (dimTexts && dimTexts.length) {
1665
+ const near = DIMTEXT_NEAR_FT * ftPx;
1666
+ const reach = DIMTEXT_REACH_FT * ftPx;
1667
+ const margin = DIMSTR_LINE_MARGIN_FT * ftPx;
1668
+ for (const T of dimTexts) {
1669
+ const ang = (T.ang % 180 + 180) % 180;
1670
+ const th = ang * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1671
+ const tx = T.x * ws, ty = T.y * ws;
1672
+ const dT = tx * nx + ty * ny, tT = tx * dx + ty * dy;
1673
+ const pieces = [];
1674
+ let dSum = 0, wSum = 0;
1675
+ for (const C of chains) {
1676
+ let rel = Math.abs(C.axis - ang);
1677
+ if (rel > 90) rel = 180 - rel;
1678
+ if (rel > 2 * DIMSTR_ANGLE_TOL) continue;
1679
+ const cd = (C.p0[0] + C.p1[0]) / 2 * nx + (C.p0[1] + C.p1[1]) / 2 * ny;
1680
+ if (Math.abs(cd - dT) > near) continue;
1681
+ const ct0 = Math.min(C.p0[0] * dx + C.p0[1] * dy, C.p1[0] * dx + C.p1[1] * dy);
1682
+ const ct1 = Math.max(C.p0[0] * dx + C.p0[1] * dy, C.p1[0] * dx + C.p1[1] * dy);
1683
+ if (ct1 < tT - reach || ct0 > tT + reach) continue;
1684
+ pieces.push(C);
1685
+ dSum += cd * C.len;
1686
+ wSum += C.len;
1687
+ }
1688
+ if (!wSum) continue;
1689
+ let t0 = Infinity, t1 = -Infinity, drawn = 0;
1690
+ for (const C of pieces) {
1691
+ t0 = Math.min(t0, Math.min(C.p0[0] * dx + C.p0[1] * dy, C.p1[0] * dx + C.p1[1] * dy));
1692
+ t1 = Math.max(t1, Math.max(C.p0[0] * dx + C.p0[1] * dy, C.p1[0] * dx + C.p1[1] * dy));
1693
+ drawn += C.len;
1694
+ }
1695
+ if (drawn < Math.max(runMin, DIMTEXT_MIN_LINE_X * T.wPx * ws)) continue;
1696
+ const dLine = dSum / wSum;
1697
+ const proxy = {
1698
+ axis: ang,
1699
+ d: dLine,
1700
+ t0,
1701
+ t1,
1702
+ len: t1 - t0,
1703
+ members: [],
1704
+ p0: [t0 * dx + dLine * nx, t0 * dy + dLine * ny],
1705
+ p1: [t1 * dx + dLine * nx, t1 * dy + dLine * ny]
1706
+ };
1707
+ if (hasParallelPartner(proxy, null)) continue;
1708
+ hits.push(proxy);
1709
+ for (const C of pieces) {
1710
+ if (C.len > t1 - t0 + 2 * margin) continue;
1711
+ if (hasParallelPartner(C, null)) continue;
1712
+ for (const m of C.members) soft[m] = 1;
1713
+ }
1714
+ for (const C of chains) {
1715
+ if (C.len < tickMin || C.len > DIMTEXT_STUB_MAX_FT * ftPx) continue;
1716
+ let rel = Math.abs(C.axis - ang);
1717
+ if (rel > 90) rel = 180 - rel;
1718
+ if (rel < DIMSTR_TICK_ANG_LO) continue;
1719
+ const d0 = C.p0[0] * nx + C.p0[1] * ny - dLine, d1 = C.p1[0] * nx + C.p1[1] * ny - dLine;
1720
+ const near0 = Math.abs(d0) <= attach, near1 = Math.abs(d1) <= attach;
1721
+ const crosses = d0 < -attach && d1 > attach || d1 < -attach && d0 > attach;
1722
+ if (!near0 && !near1 && !crosses) continue;
1723
+ const tm = (C.p0[0] + C.p1[0]) / 2 * dx + (C.p0[1] + C.p1[1]) / 2 * dy;
1724
+ if (tm < t0 - margin || tm > t1 + margin) continue;
1725
+ if (hasParallelPartner(C, null)) continue;
1726
+ for (const m of C.members) soft[m] = 1;
1727
+ }
1728
+ }
1729
+ }
1730
+ return { soft, runs: hits };
1731
+ }
1732
+ function classifyDimensionStringSegs(segs, meta, ws, ftPx, dimTexts) {
1733
+ return sweepDimensionStrings(segs, meta, ws, ftPx, void 0, dimTexts).soft;
1734
+ }
1435
1735
  var HATCH_ID_ANGLE_Q = 0.5;
1436
1736
  var HATCH_ID_PITCH_Q = 0.1;
1437
1737
  function hatchFamilies(segs, meta) {
@@ -1452,7 +1752,8 @@ function hatchFamilies(segs, meta) {
1452
1752
  });
1453
1753
  }
1454
1754
  var MASK_CURVE_BIT = 4;
1455
- function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt = 0, basePxPerFt = 0, page = null, roles = null) {
1755
+ var MASK_DIMSTR_BIT = 16;
1756
+ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt = 0, basePxPerFt = 0, page = null, roles = null, dimTexts = null) {
1456
1757
  if (pxPerFt instanceof Uint8Array || pxPerFt === null) {
1457
1758
  if (!roles) roles = pxPerFt;
1458
1759
  pxPerFt = 0;
@@ -1479,15 +1780,20 @@ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null, pxPerFt
1479
1780
  if (soft && annot) {
1480
1781
  for (let i = 0; i < soft.length; i++) if (annot[i]) soft[i] = 1;
1481
1782
  }
1783
+ const dimstr = meta && mppf > 0 ? classifyDimensionStringSegs(segs, meta, ws, mppf, dimTexts) : null;
1784
+ if (soft && dimstr) {
1785
+ for (let i = 0; i < soft.length; i++) if (dimstr[i]) soft[i] = 1;
1786
+ }
1482
1787
  const noDoor = meta ? flagNonDoorArcs(segs, meta) : null;
1483
1788
  let softCount = 0;
1484
1789
  for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
1485
1790
  const role = roles ? roles[si] : 0;
1486
1791
  if (role === 2 || role === 3 || role === 5 || role === 6) continue;
1487
1792
  let v = role === 1 || role === 4 ? 1 : soft && soft[si] ? 2 : 1;
1793
+ if (v === 2 && dimstr && dimstr[si]) v |= MASK_DIMSTR_BIT;
1488
1794
  if (v === 1 && meta && meta[si] & SEG_CURVE) v = 1 | MASK_CURVE_BIT;
1489
1795
  if (v & MASK_CURVE_BIT && noDoor && noDoor[si]) v |= MASK_NODOOR_BIT;
1490
- if (v === 2) softCount++;
1796
+ if (v & 2 && !(v & 1)) softCount++;
1491
1797
  let x0 = Math.round(segs[i] * ws), y0 = Math.round(segs[i + 1] * ws);
1492
1798
  const x1 = Math.round(segs[i + 2] * ws), y1 = Math.round(segs[i + 3] * ws);
1493
1799
  const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0);
@@ -2190,6 +2496,112 @@ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
2190
2496
  const cy = Math.max(0, Math.min(mo.mh - 1, Math.round(iy * mo.ws)));
2191
2497
  const cSoft = (mo.mask[cy * mo.mw + cx] & 2) !== 0;
2192
2498
  const cdt = (s) => cSoft ? 0 : s.dt[cy * mo.mw + cx];
2499
+ const mergeAcrossDimStrings = (ref) => {
2500
+ if (!mo.softCount) return null;
2501
+ const { mw, mh, mask, ws } = mo;
2502
+ const isDim = (i) => (mask[i] & MASK_DIMSTR_BIT) !== 0 && (mask[i] & 1) === 0;
2503
+ const capCells = mw * mh * SEAL_MAX_SHEET_FRAC;
2504
+ const box = boxOf(ref.region, mw, mh);
2505
+ const region = ref.region.slice();
2506
+ let count = ref.count, mergedPieces = 0;
2507
+ const tried = /* @__PURE__ */ new Set();
2508
+ let frontier = true;
2509
+ for (let pass = 0; pass < 8 && frontier; pass++) {
2510
+ frontier = false;
2511
+ const b = { x0: Math.max(1, box.x0 - 2), y0: Math.max(1, box.y0 - 2), x1: Math.min(mw - 2, box.x1 + 2), y1: Math.min(mh - 2, box.y1 + 2) };
2512
+ for (let y = b.y0; y <= b.y1; y++) for (let x = b.x0; x <= b.x1; x++) {
2513
+ const i = y * mw + x;
2514
+ if (!isDim(i)) continue;
2515
+ if (!(region[i - 1] || region[i + 1] || region[i - mw] || region[i + mw])) continue;
2516
+ for (const o of [-1, 1, -mw, mw]) {
2517
+ let j = i + o, steps = 0;
2518
+ while (steps++ < 3 && isDim(j)) j += o;
2519
+ if (region[j] || mask[j] & 3 || tried.has(j)) continue;
2520
+ tried.add(j);
2521
+ const jx = j % mw, jy = j / mw | 0;
2522
+ const fr = floodPass(mo, (jx + 0.5) / ws, (jy + 0.5) / ws, 3);
2523
+ if (fr.status !== "ok") continue;
2524
+ if (count + fr.count > capCells) continue;
2525
+ {
2526
+ const fb2 = boxOf(fr.region, mw, mh);
2527
+ let bAll = 0, bDim = 0, bCurve = 0;
2528
+ for (let yy = Math.max(1, fb2.y0); yy <= Math.min(mh - 2, fb2.y1); yy++) for (let xx = Math.max(1, fb2.x0); xx <= Math.min(mw - 2, fb2.x1); xx++) {
2529
+ const k = yy * mw + xx;
2530
+ if (!fr.region[k]) continue;
2531
+ for (const oo of [-1, 1, -mw, mw]) {
2532
+ const mk = mask[k + oo];
2533
+ if (mk & 3 && !fr.region[k + oo]) {
2534
+ bAll++;
2535
+ if (isDim(k + oo)) bDim++;
2536
+ if (mk & MASK_CURVE_BIT) bCurve++;
2537
+ }
2538
+ }
2539
+ }
2540
+ if (bAll && bDim / bAll > DIMSTR_PIECE_MAX_DIMFRAC) continue;
2541
+ if (bAll && bCurve / bAll > DIMSTR_PIECE_MAX_CURVEFRAC) continue;
2542
+ }
2543
+ for (let k = 0; k < region.length; k++) if (fr.region[k] && !region[k]) {
2544
+ region[k] = 1;
2545
+ count++;
2546
+ }
2547
+ const fb = boxOf(fr.region, mw, mh);
2548
+ box.x0 = Math.min(box.x0, fb.x0);
2549
+ box.y0 = Math.min(box.y0, fb.y0);
2550
+ box.x1 = Math.max(box.x1, fb.x1);
2551
+ box.y1 = Math.max(box.y1, fb.y1);
2552
+ mergedPieces++;
2553
+ frontier = true;
2554
+ }
2555
+ }
2556
+ }
2557
+ if (!mergedPieces) return null;
2558
+ const final = new Uint8Array(mw * mh);
2559
+ const cx0 = Math.max(0, Math.min(mw - 1, Math.round(ix * ws)));
2560
+ const cy0 = Math.max(0, Math.min(mh - 1, Math.round(iy * ws)));
2561
+ let s0 = cy0 * mw + cx0;
2562
+ if (!region[s0]) {
2563
+ s0 = -1;
2564
+ for (let k = 0; k < region.length && s0 < 0; k++) if (ref.region[k]) s0 = k;
2565
+ if (s0 < 0) return null;
2566
+ }
2567
+ const stack = [s0];
2568
+ final[s0] = 1;
2569
+ let fCount = 0;
2570
+ const nb = { x0: mw, y0: mh, x1: 0, y1: 0 };
2571
+ while (stack.length) {
2572
+ const i = stack.pop();
2573
+ fCount++;
2574
+ const x = i % mw, y = i / mw | 0;
2575
+ if (x < nb.x0) nb.x0 = x;
2576
+ if (x > nb.x1) nb.x1 = x;
2577
+ if (y < nb.y0) nb.y0 = y;
2578
+ if (y > nb.y1) nb.y1 = y;
2579
+ for (const o of [-1, 1, -mw, mw]) {
2580
+ const j = i + o;
2581
+ if (j < 0 || j >= final.length || final[j]) continue;
2582
+ const xj = j % mw;
2583
+ if (Math.abs(xj - x) > 1) continue;
2584
+ if (region[j] || isDim(j)) {
2585
+ final[j] = 1;
2586
+ stack.push(j);
2587
+ }
2588
+ }
2589
+ }
2590
+ regionBox.set(final, nb);
2591
+ return {
2592
+ status: "ok",
2593
+ region: final,
2594
+ count: fCount,
2595
+ mw,
2596
+ mh,
2597
+ ws,
2598
+ mppf: mo.mppf,
2599
+ hardHits: ref.hardHits,
2600
+ softHits: ref.softHits,
2601
+ hatchFiltered: true,
2602
+ hatchTier: "bounded"
2603
+ };
2604
+ };
2193
2605
  if (minPassPx > 0) {
2194
2606
  const s = scratch();
2195
2607
  const dm = dilatedView(mo, minPassPx, s.dt);
@@ -2205,7 +2617,7 @@ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
2205
2617
  f.minPassPx = minPassPx;
2206
2618
  f.minPassDelta = d;
2207
2619
  }
2208
- return f;
2620
+ return mergeAcrossDimStrings(f) ?? f;
2209
2621
  }
2210
2622
  const vf = f.count > f.mw * f.mh * SEAL_MAX_SHEET_FRAC ? 1 : virtualBoundaryFrac(f, s.dt);
2211
2623
  if (vf <= SEAL_VIRTUAL_MAX) {
@@ -2218,7 +2630,7 @@ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
2218
2630
  }
2219
2631
  }
2220
2632
  const base = rawFlood();
2221
- if (base.status === "ok") return base;
2633
+ if (base.status === "ok") return mergeAcrossDimStrings(base) ?? base;
2222
2634
  const sc = scratch();
2223
2635
  for (const r of radii) {
2224
2636
  if (r <= minPassPx) continue;
@@ -3680,6 +4092,483 @@ function splitFromFamily(conds, condId) {
3680
4092
  });
3681
4093
  }
3682
4094
 
4095
+ // ../web/src/lib/curve.js
4096
+ function crPoint(p0, p1, p2, p3, t) {
4097
+ const knot = (ti, a, b) => ti + Math.sqrt(Math.max(Math.hypot(b[0] - a[0], b[1] - a[1]), 1e-6));
4098
+ const t0 = 0, t1 = knot(t0, p0, p1), t2 = knot(t1, p1, p2), t3 = knot(t2, p2, p3);
4099
+ const u = t1 + (t2 - t1) * t;
4100
+ const lp = (a, b, ta, tb) => {
4101
+ const w = (u - ta) / (tb - ta || 1e-9);
4102
+ return [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w];
4103
+ };
4104
+ const A1 = lp(p0, p1, t0, t1), A2 = lp(p1, p2, t1, t2), A3 = lp(p2, p3, t2, t3);
4105
+ const B1 = lp(A1, A2, t0, t2), B2 = lp(A2, A3, t1, t3);
4106
+ return lp(B1, B2, t1, t2);
4107
+ }
4108
+ function flattenCurve(pts, opts = {}) {
4109
+ const maxPts = opts.maxPts || 220;
4110
+ const n = (pts || []).length;
4111
+ if (n < 3) return (pts || []).map((p) => [p[0], p[1]]);
4112
+ const P = [pts[0], ...pts, pts[n - 1]];
4113
+ const want = [];
4114
+ let total = 0;
4115
+ for (let i = 1; i < P.length - 2; i++) {
4116
+ const chord = Math.hypot(P[i + 1][0] - P[i][0], P[i + 1][1] - P[i][1]);
4117
+ const steps = Math.max(6, Math.min(24, Math.round(chord / 6)));
4118
+ want.push(steps);
4119
+ total += steps;
4120
+ }
4121
+ const scale = total > maxPts ? maxPts / total : 1;
4122
+ const out = [[pts[0][0], pts[0][1]]];
4123
+ for (let i = 1; i < P.length - 2; i++) {
4124
+ const steps = Math.max(2, Math.round(want[i - 1] * scale));
4125
+ for (let j = 1; j <= steps; j++) out.push(crPoint(P[i - 1], P[i], P[i + 1], P[i + 2], j / steps));
4126
+ }
4127
+ return out;
4128
+ }
4129
+
4130
+ // ../web/src/lib/dxf.ts
4131
+ var DXF_STAMP = "OpenTakeoff DXF export";
4132
+ var FT_PER_M = 1 / 0.3048;
4133
+ var CONDITION_COLORS = [5, 3, 1, 2, 4, 6, 30, 40, 90, 150, 200, 210, 32, 92, 172, 12];
4134
+ var COLOR_LABELS = 7;
4135
+ var COLOR_HOLE = 8;
4136
+ function dxfLayerName(tag, suffix) {
4137
+ let base = String(tag ?? "").trim().replace(/[<>\/\\":;?*|,=`]+/g, "-").replace(/\s+/g, " ").replace(/-{2,}/g, "-").replace(/^-|-$/g, "").toUpperCase();
4138
+ if (!base) base = "UNTAGGED";
4139
+ if (base.length > 48) base = base.slice(0, 48).replace(/-$/, "");
4140
+ return suffix ? `OT-${base}-${suffix}` : `OT-${base}`;
4141
+ }
4142
+ var fmt = (v) => {
4143
+ const r = Math.round(v * 1e4) / 1e4;
4144
+ return (Object.is(r, -0) ? 0 : r).toString();
4145
+ };
4146
+ var Dxf = class {
4147
+ out = [];
4148
+ seed = 32;
4149
+ handle() {
4150
+ return (this.seed++).toString(16).toUpperCase();
4151
+ }
4152
+ peek() {
4153
+ return this.seed.toString(16).toUpperCase();
4154
+ }
4155
+ add(code, value) {
4156
+ this.out.push(String(code), String(value));
4157
+ }
4158
+ text() {
4159
+ return this.out.join("\n") + "\n";
4160
+ }
4161
+ };
4162
+ function polygonCentroid(pts) {
4163
+ let a = 0, cx = 0, cy = 0;
4164
+ const n = pts.length;
4165
+ for (let i = 0; i < n; i++) {
4166
+ const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % n];
4167
+ const cross = x0 * y1 - x1 * y0;
4168
+ a += cross;
4169
+ cx += (x0 + x1) * cross;
4170
+ cy += (y0 + y1) * cross;
4171
+ }
4172
+ if (Math.abs(a) < 1e-12) {
4173
+ return [pts.reduce((s, p) => s + p[0], 0) / n, pts.reduce((s, p) => s + p[1], 0) / n];
4174
+ }
4175
+ a *= 0.5;
4176
+ return [cx / (6 * a), cy / (6 * a)];
4177
+ }
4178
+ function buildSheetDxf(sheet, opts = {}) {
4179
+ const units = opts.units === "m" ? "m" : "ft";
4180
+ const k = units === "m" ? 1 / FT_PER_M : 1;
4181
+ const textH = opts.text_height ?? (units === "m" ? 0.3 : 1);
4182
+ const { w, h } = sheet.dims;
4183
+ const upp = sheet.upp;
4184
+ if (!(w > 0) || !(h > 0)) throw new Error(`DXF: sheet ${sheet.sheet_id} has no image dimensions`);
4185
+ if (!(upp > 0)) throw new Error(`DXF: sheet ${sheet.sheet_id} has no scale \u2014 set the scale before exporting to CAD`);
4186
+ const toXY = ([nx, ny]) => [nx * w * upp * k, (1 - ny) * h * upp * k];
4187
+ const toPx = ([nx, ny]) => [nx * w, ny * h];
4188
+ const fromPx = ([px, py]) => [px * upp * k, (h - py) * upp * k];
4189
+ const condById = new Map(sheet.conditions.map((c) => [c.id, c]));
4190
+ const condColor = /* @__PURE__ */ new Map();
4191
+ sheet.conditions.forEach((c, i) => condColor.set(c.id, CONDITION_COLORS[i % CONDITION_COLORS.length]));
4192
+ const ents = [];
4193
+ const layers = /* @__PURE__ */ new Map();
4194
+ const useLayer = (name, color) => {
4195
+ if (!layers.has(name)) layers.set(name, color);
4196
+ return name;
4197
+ };
4198
+ const skipped = [];
4199
+ let shapesOut = 0;
4200
+ const ext = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
4201
+ const grow = ([x, y]) => {
4202
+ if (x < ext.minX) ext.minX = x;
4203
+ if (x > ext.maxX) ext.maxX = x;
4204
+ if (y < ext.minY) ext.minY = y;
4205
+ if (y > ext.maxY) ext.maxY = y;
4206
+ };
4207
+ for (const s of sheet.shapes) {
4208
+ if (s.sheet_id !== sheet.sheet_id) continue;
4209
+ const cond = condById.get(s.condition_id);
4210
+ const tag = cond?.finish_tag || cond?.name || "";
4211
+ const color = condColor.get(s.condition_id) ?? COLOR_LABELS;
4212
+ const verts = Array.isArray(s.verts_norm) ? s.verts_norm : [];
4213
+ const role = s.measure_role;
4214
+ const before = ents.length;
4215
+ if (role === "count") {
4216
+ const c = verts[0];
4217
+ if (!c) {
4218
+ skipped.push({ id: s.id, reason: "count mark without a position" });
4219
+ continue;
4220
+ }
4221
+ const at = toXY(c);
4222
+ ents.push({ kind: "circle", layer: useLayer(dxfLayerName(tag, "COUNT"), color), c: at, r: 0.5 * k });
4223
+ grow([at[0] - 0.5 * k, at[1] - 0.5 * k]);
4224
+ grow([at[0] + 0.5 * k, at[1] + 0.5 * k]);
4225
+ } else if (role === "surface_area" || role === "linear") {
4226
+ if (verts.length < 2) {
4227
+ skipped.push({ id: s.id, reason: `${role} shape with fewer than 2 vertices` });
4228
+ continue;
4229
+ }
4230
+ const px = verts.map(toPx);
4231
+ const run2 = role === "linear" && s.curved ? flattenCurve(px) : px;
4232
+ const pts = run2.map(fromPx);
4233
+ pts.forEach(grow);
4234
+ ents.push({ kind: "lwpoly", layer: useLayer(dxfLayerName(tag, role === "linear" ? "LINEAR" : "WALL"), color), pts, closed: false });
4235
+ } else if (role === "floor_area" || role === "deduct") {
4236
+ if (role === "deduct" && s.cuts_shape_id) {
4237
+ skipped.push({ id: s.id, reason: `deduct reconciled into ${s.cuts_shape_id} \u2014 ships as that ring's hole` });
4238
+ continue;
4239
+ }
4240
+ if (verts.length < 3) {
4241
+ skipped.push({ id: s.id, reason: `${role} ring with fewer than 3 vertices` });
4242
+ continue;
4243
+ }
4244
+ const pts = verts.map(toXY);
4245
+ pts.forEach(grow);
4246
+ const layer = useLayer(role === "deduct" ? dxfLayerName(tag, "DEDUCT") : dxfLayerName(tag), color);
4247
+ ents.push({ kind: "lwpoly", layer, pts, closed: true });
4248
+ for (const ring of s.verts_norm_holes || []) {
4249
+ if (!Array.isArray(ring) || ring.length < 3) continue;
4250
+ const hp = ring.map(toXY);
4251
+ hp.forEach(grow);
4252
+ ents.push({ kind: "lwpoly", layer: useLayer(dxfLayerName(tag, "HOLE"), COLOR_HOLE), pts: hp, closed: true });
4253
+ }
4254
+ if (role === "floor_area" && s.label && String(s.label).trim()) {
4255
+ ents.push({ kind: "text", layer: useLayer("OT-LABELS", COLOR_LABELS), at: polygonCentroid(pts), text: String(s.label).trim(), height: textH });
4256
+ }
4257
+ } else {
4258
+ skipped.push({ id: s.id, reason: `unknown measure_role "${role}"` });
4259
+ continue;
4260
+ }
4261
+ if (ents.length > before) shapesOut++;
4262
+ }
4263
+ const hasExt = Number.isFinite(ext.minX);
4264
+ const extents = hasExt ? { min: [ext.minX, ext.minY], max: [ext.maxX, ext.maxY] } : null;
4265
+ const d = new Dxf();
4266
+ const H = {
4267
+ vportTbl: d.handle(),
4268
+ vportActive: d.handle(),
4269
+ ltypeTbl: d.handle(),
4270
+ ltByBlock: d.handle(),
4271
+ ltByLayer: d.handle(),
4272
+ ltCont: d.handle(),
4273
+ layerTbl: d.handle(),
4274
+ layer0: d.handle(),
4275
+ styleTbl: d.handle(),
4276
+ styleStd: d.handle(),
4277
+ viewTbl: d.handle(),
4278
+ ucsTbl: d.handle(),
4279
+ appidTbl: d.handle(),
4280
+ appidAcad: d.handle(),
4281
+ dimTbl: d.handle(),
4282
+ dimStd: d.handle(),
4283
+ brTbl: d.handle(),
4284
+ brModel: d.handle(),
4285
+ brPaper: d.handle(),
4286
+ blkModel: d.handle(),
4287
+ endModel: d.handle(),
4288
+ blkPaper: d.handle(),
4289
+ endPaper: d.handle(),
4290
+ rootDict: d.handle(),
4291
+ groupDict: d.handle(),
4292
+ plotDict: d.handle(),
4293
+ plotPlaceholder: d.handle()
4294
+ };
4295
+ const layerHandles = /* @__PURE__ */ new Map();
4296
+ for (const name of layers.keys()) layerHandles.set(name, d.handle());
4297
+ const entHandles = ents.map(() => d.handle());
4298
+ d.add(999, `${DXF_STAMP} \u2014 sheet ${sheet.label || sheet.sheet_id} \u2014 units ${units === "m" ? "metres" : "feet"} \u2014 origin bottom-left of sheet, Y up`);
4299
+ d.add(0, "SECTION");
4300
+ d.add(2, "HEADER");
4301
+ d.add(9, "$ACADVER");
4302
+ d.add(1, "AC1015");
4303
+ d.add(9, "$HANDSEED");
4304
+ d.add(5, d.peek());
4305
+ d.add(9, "$INSUNITS");
4306
+ d.add(70, units === "m" ? 6 : 2);
4307
+ d.add(9, "$MEASUREMENT");
4308
+ d.add(70, units === "m" ? 1 : 0);
4309
+ d.add(9, "$LUNITS");
4310
+ d.add(70, units === "m" ? 2 : 4);
4311
+ d.add(9, "$EXTMIN");
4312
+ d.add(10, fmt(hasExt ? ext.minX : 0));
4313
+ d.add(20, fmt(hasExt ? ext.minY : 0));
4314
+ d.add(30, 0);
4315
+ d.add(9, "$EXTMAX");
4316
+ d.add(10, fmt(hasExt ? ext.maxX : w * upp * k));
4317
+ d.add(20, fmt(hasExt ? ext.maxY : h * upp * k));
4318
+ d.add(30, 0);
4319
+ d.add(9, "$LIMMIN");
4320
+ d.add(10, 0);
4321
+ d.add(20, 0);
4322
+ d.add(9, "$LIMMAX");
4323
+ d.add(10, fmt(w * upp * k));
4324
+ d.add(20, fmt(h * upp * k));
4325
+ d.add(9, "$CLAYER");
4326
+ d.add(8, "0");
4327
+ d.add(9, "$PDMODE");
4328
+ d.add(70, 0);
4329
+ d.add(0, "ENDSEC");
4330
+ d.add(0, "SECTION");
4331
+ d.add(2, "CLASSES");
4332
+ d.add(0, "ENDSEC");
4333
+ d.add(0, "SECTION");
4334
+ d.add(2, "TABLES");
4335
+ const table = (name, handle, count) => {
4336
+ d.add(0, "TABLE");
4337
+ d.add(2, name);
4338
+ d.add(5, handle);
4339
+ d.add(330, "0");
4340
+ d.add(100, "AcDbSymbolTable");
4341
+ d.add(70, count);
4342
+ };
4343
+ const record = (type, handle, owner, sub) => {
4344
+ d.add(0, type);
4345
+ d.add(5, handle);
4346
+ d.add(330, owner);
4347
+ d.add(100, "AcDbSymbolTableRecord");
4348
+ d.add(100, sub);
4349
+ };
4350
+ const endtab = () => d.add(0, "ENDTAB");
4351
+ table("VPORT", H.vportTbl, 1);
4352
+ record("VPORT", H.vportActive, H.vportTbl, "AcDbViewportTableRecord");
4353
+ d.add(2, "*Active");
4354
+ d.add(70, 0);
4355
+ d.add(10, 0);
4356
+ d.add(20, 0);
4357
+ d.add(11, 1);
4358
+ d.add(21, 1);
4359
+ const cx = hasExt ? (ext.minX + ext.maxX) / 2 : w * upp * k / 2;
4360
+ const cy = hasExt ? (ext.minY + ext.maxY) / 2 : h * upp * k / 2;
4361
+ const vh = hasExt ? Math.max(ext.maxY - ext.minY, (ext.maxX - ext.minX) / 1.6, 1) * 1.1 : h * upp * k;
4362
+ d.add(12, fmt(cx));
4363
+ d.add(22, fmt(cy));
4364
+ d.add(13, 0);
4365
+ d.add(23, 0);
4366
+ d.add(14, 10);
4367
+ d.add(24, 10);
4368
+ d.add(15, 10);
4369
+ d.add(25, 10);
4370
+ d.add(16, 0);
4371
+ d.add(26, 0);
4372
+ d.add(36, 1);
4373
+ d.add(17, 0);
4374
+ d.add(27, 0);
4375
+ d.add(37, 0);
4376
+ d.add(40, fmt(vh));
4377
+ d.add(41, 1.6);
4378
+ d.add(42, 50);
4379
+ d.add(43, 0);
4380
+ d.add(44, 0);
4381
+ d.add(50, 0);
4382
+ d.add(51, 0);
4383
+ d.add(71, 0);
4384
+ d.add(72, 100);
4385
+ d.add(73, 1);
4386
+ d.add(74, 3);
4387
+ d.add(75, 0);
4388
+ d.add(76, 0);
4389
+ d.add(77, 0);
4390
+ d.add(78, 0);
4391
+ endtab();
4392
+ table("LTYPE", H.ltypeTbl, 3);
4393
+ for (const [hd, name, desc] of [[H.ltByBlock, "ByBlock", ""], [H.ltByLayer, "ByLayer", ""], [H.ltCont, "Continuous", "Solid line"]]) {
4394
+ record("LTYPE", hd, H.ltypeTbl, "AcDbLinetypeTableRecord");
4395
+ d.add(2, name);
4396
+ d.add(70, 0);
4397
+ d.add(3, desc);
4398
+ d.add(72, 65);
4399
+ d.add(73, 0);
4400
+ d.add(40, 0);
4401
+ }
4402
+ endtab();
4403
+ table("LAYER", H.layerTbl, layers.size + 1);
4404
+ const layerRec = (hd, name, color) => {
4405
+ record("LAYER", hd, H.layerTbl, "AcDbLayerTableRecord");
4406
+ d.add(2, name);
4407
+ d.add(70, 0);
4408
+ d.add(62, color);
4409
+ d.add(6, "Continuous");
4410
+ d.add(370, -3);
4411
+ d.add(390, H.plotPlaceholder);
4412
+ };
4413
+ layerRec(H.layer0, "0", 7);
4414
+ for (const [name, color] of layers) layerRec(layerHandles.get(name), name, color);
4415
+ endtab();
4416
+ table("STYLE", H.styleTbl, 1);
4417
+ record("STYLE", H.styleStd, H.styleTbl, "AcDbTextStyleTableRecord");
4418
+ d.add(2, "Standard");
4419
+ d.add(70, 0);
4420
+ d.add(40, 0);
4421
+ d.add(41, 1);
4422
+ d.add(50, 0);
4423
+ d.add(71, 0);
4424
+ d.add(42, 0.2);
4425
+ d.add(3, "txt");
4426
+ d.add(4, "");
4427
+ endtab();
4428
+ table("VIEW", H.viewTbl, 0);
4429
+ endtab();
4430
+ table("UCS", H.ucsTbl, 0);
4431
+ endtab();
4432
+ table("APPID", H.appidTbl, 1);
4433
+ record("APPID", H.appidAcad, H.appidTbl, "AcDbRegAppTableRecord");
4434
+ d.add(2, "ACAD");
4435
+ d.add(70, 0);
4436
+ endtab();
4437
+ d.add(0, "TABLE");
4438
+ d.add(2, "DIMSTYLE");
4439
+ d.add(5, H.dimTbl);
4440
+ d.add(330, "0");
4441
+ d.add(100, "AcDbSymbolTable");
4442
+ d.add(70, 1);
4443
+ d.add(100, "AcDbDimStyleTable");
4444
+ d.add(0, "DIMSTYLE");
4445
+ d.add(105, H.dimStd);
4446
+ d.add(330, H.dimTbl);
4447
+ d.add(100, "AcDbSymbolTableRecord");
4448
+ d.add(100, "AcDbDimStyleTableRecord");
4449
+ d.add(2, "Standard");
4450
+ d.add(70, 0);
4451
+ d.add(340, H.styleStd);
4452
+ endtab();
4453
+ table("BLOCK_RECORD", H.brTbl, 2);
4454
+ for (const [hd, name] of [[H.brModel, "*Model_Space"], [H.brPaper, "*Paper_Space"]]) {
4455
+ record("BLOCK_RECORD", hd, H.brTbl, "AcDbBlockTableRecord");
4456
+ d.add(2, name);
4457
+ d.add(340, "0");
4458
+ }
4459
+ endtab();
4460
+ d.add(0, "ENDSEC");
4461
+ d.add(0, "SECTION");
4462
+ d.add(2, "BLOCKS");
4463
+ const block = (name, br, begin, end, paper) => {
4464
+ d.add(0, "BLOCK");
4465
+ d.add(5, begin);
4466
+ d.add(330, br);
4467
+ d.add(100, "AcDbEntity");
4468
+ if (paper) d.add(67, 1);
4469
+ d.add(8, "0");
4470
+ d.add(100, "AcDbBlockBegin");
4471
+ d.add(2, name);
4472
+ d.add(70, 0);
4473
+ d.add(10, 0);
4474
+ d.add(20, 0);
4475
+ d.add(30, 0);
4476
+ d.add(3, name);
4477
+ d.add(1, "");
4478
+ d.add(0, "ENDBLK");
4479
+ d.add(5, end);
4480
+ d.add(330, br);
4481
+ d.add(100, "AcDbEntity");
4482
+ if (paper) d.add(67, 1);
4483
+ d.add(8, "0");
4484
+ d.add(100, "AcDbBlockEnd");
4485
+ };
4486
+ block("*Model_Space", H.brModel, H.blkModel, H.endModel, false);
4487
+ block("*Paper_Space", H.brPaper, H.blkPaper, H.endPaper, true);
4488
+ d.add(0, "ENDSEC");
4489
+ d.add(0, "SECTION");
4490
+ d.add(2, "ENTITIES");
4491
+ ents.forEach((e, i) => {
4492
+ const hd = entHandles[i];
4493
+ if (e.kind === "lwpoly") {
4494
+ d.add(0, "LWPOLYLINE");
4495
+ d.add(5, hd);
4496
+ d.add(330, H.brModel);
4497
+ d.add(100, "AcDbEntity");
4498
+ d.add(8, e.layer);
4499
+ d.add(100, "AcDbPolyline");
4500
+ d.add(90, e.pts.length);
4501
+ d.add(70, e.closed ? 1 : 0);
4502
+ d.add(43, 0);
4503
+ for (const [x, y] of e.pts) {
4504
+ d.add(10, fmt(x));
4505
+ d.add(20, fmt(y));
4506
+ }
4507
+ } else if (e.kind === "circle") {
4508
+ d.add(0, "CIRCLE");
4509
+ d.add(5, hd);
4510
+ d.add(330, H.brModel);
4511
+ d.add(100, "AcDbEntity");
4512
+ d.add(8, e.layer);
4513
+ d.add(100, "AcDbCircle");
4514
+ d.add(10, fmt(e.c[0]));
4515
+ d.add(20, fmt(e.c[1]));
4516
+ d.add(30, 0);
4517
+ d.add(40, fmt(e.r));
4518
+ } else {
4519
+ d.add(0, "TEXT");
4520
+ d.add(5, hd);
4521
+ d.add(330, H.brModel);
4522
+ d.add(100, "AcDbEntity");
4523
+ d.add(8, e.layer);
4524
+ d.add(100, "AcDbText");
4525
+ d.add(10, fmt(e.at[0]));
4526
+ d.add(20, fmt(e.at[1]));
4527
+ d.add(30, 0);
4528
+ d.add(40, fmt(e.height));
4529
+ d.add(1, e.text.replace(/[\r\n]+/g, " "));
4530
+ d.add(72, 1);
4531
+ d.add(11, fmt(e.at[0]));
4532
+ d.add(21, fmt(e.at[1]));
4533
+ d.add(31, 0);
4534
+ d.add(100, "AcDbText");
4535
+ d.add(73, 2);
4536
+ }
4537
+ });
4538
+ d.add(0, "ENDSEC");
4539
+ d.add(0, "SECTION");
4540
+ d.add(2, "OBJECTS");
4541
+ d.add(0, "DICTIONARY");
4542
+ d.add(5, H.rootDict);
4543
+ d.add(330, "0");
4544
+ d.add(100, "AcDbDictionary");
4545
+ d.add(281, 1);
4546
+ d.add(3, "ACAD_GROUP");
4547
+ d.add(350, H.groupDict);
4548
+ d.add(3, "ACAD_PLOTSTYLENAME");
4549
+ d.add(350, H.plotDict);
4550
+ d.add(0, "DICTIONARY");
4551
+ d.add(5, H.groupDict);
4552
+ d.add(330, H.rootDict);
4553
+ d.add(100, "AcDbDictionary");
4554
+ d.add(281, 1);
4555
+ d.add(0, "ACDBDICTIONARYWDFLT");
4556
+ d.add(5, H.plotDict);
4557
+ d.add(330, H.rootDict);
4558
+ d.add(100, "AcDbDictionary");
4559
+ d.add(281, 1);
4560
+ d.add(3, "Normal");
4561
+ d.add(350, H.plotPlaceholder);
4562
+ d.add(100, "AcDbDictionaryWithDefault");
4563
+ d.add(340, H.plotPlaceholder);
4564
+ d.add(0, "ACDBPLACEHOLDER");
4565
+ d.add(5, H.plotPlaceholder);
4566
+ d.add(330, H.plotDict);
4567
+ d.add(0, "ENDSEC");
4568
+ d.add(0, "EOF");
4569
+ return { dxf: d.text(), layers: [...layers.keys()], entities: ents.length, shapes: shapesOut, skipped, extents };
4570
+ }
4571
+
3683
4572
  // ../web/src/lib/confidence.ts
3684
4573
  var CONF_RASTER = 0.9;
3685
4574
  var CONF_HATCH = 0.95;
@@ -8844,6 +9733,37 @@ var Session = class _Session {
8844
9733
  }
8845
9734
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
8846
9735
  * store.saveAnnotations stamps) — importable by the app. */
9736
+ /** The takeoff on ONE sheet as a DXF drawing (R2000, LWPOLYLINE per shape,
9737
+ * OT-<TAG> layer per finish, feet by default) — the CAD-native handoff.
9738
+ * `sheet` omitted resolves only when exactly one calibrated sheet carries
9739
+ * shapes; otherwise the refusal names the candidates, because two floors in
9740
+ * one model space at the same origin would be a lie. A sheet without a
9741
+ * scale refuses too (a CAD file in pixels is worse than none). */
9742
+ exportDxf(sheetName, units = "ft") {
9743
+ if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
9744
+ let s;
9745
+ if (sheetName != null && sheetName !== "") {
9746
+ s = this.sheet(sheetName);
9747
+ } else {
9748
+ const withShapes = new Set(this.shapes.map((x) => x.sheet_id));
9749
+ const cands = [...this.sheets.values()].filter((x) => withShapes.has(x.key));
9750
+ if (cands.length === 0) throw new UserError("No sheet carries committed shapes yet \u2014 measure something before exporting to CAD.");
9751
+ if (cands.length > 1) {
9752
+ throw new UserError(`Several sheets carry shapes \u2014 pass sheet to pick one drawing (a DXF is one sheet, like a DWG is): ${cands.map((x) => `"${x.key}"${x.sheetNumber ? ` (${x.sheetNumber})` : ""}`).join(", ")}.`);
9753
+ }
9754
+ s = cands[0];
9755
+ }
9756
+ if (s.upp == null) throw new UserError(`Sheet "${s.key}" has no scale \u2014 call set_scale first; a CAD file in pixels is worse than none.`);
9757
+ const build = buildSheetDxf({
9758
+ sheet_id: s.key,
9759
+ label: s.sheetNumber || s.key,
9760
+ dims: { w: s.widthPx, h: s.heightPx },
9761
+ upp: s.upp,
9762
+ shapes: this.shapes,
9763
+ conditions: this.conditions
9764
+ }, { units });
9765
+ return { sheet: s, build };
9766
+ }
8847
9767
  exportPayload() {
8848
9768
  if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
8849
9769
  return {
@@ -9363,6 +10283,18 @@ var takeoffSummaryOutput = {
9363
10283
  }).passthrough(),
9364
10284
  scale_unconfirmed: z.array(z.string()).optional().describe("Sheets whose scale is agent-set and no human has confirmed \u2014 these totals stand on an unverified scale; verify against a stated dimension or confirm in the canvas")
9365
10285
  };
10286
+ var exportDxfOutput = {
10287
+ path: z.string().describe("The DXF written"),
10288
+ sheet: z.string().describe("Sheet key the drawing was cut from"),
10289
+ sheet_number: z.string().nullable(),
10290
+ units: z.enum(["ft", "m"]),
10291
+ layers: z.array(z.string()).describe("Layer names in table order \u2014 OT-<TAG>, plus -DEDUCT/-HOLE/-WALL/-LINEAR/-COUNT suffix layers and OT-LABELS"),
10292
+ entities: z.number().int().describe("LWPOLYLINE + CIRCLE + TEXT entities in model space"),
10293
+ shapes: z.number().int().describe("Committed shapes that produced geometry"),
10294
+ skipped: z.array(z.object({ id: z.string(), reason: z.string() })).describe("Shapes on this sheet left out, each with why \u2014 never silent"),
10295
+ extents: z.object({ min: z.tuple([z.number(), z.number()]), max: z.tuple([z.number(), z.number()]) }).nullable().describe("Model-space bounding box in output units; origin = sheet's bottom-left, Y up"),
10296
+ bytes: z.number().int()
10297
+ };
9366
10298
  var exportTakeoffOutput = {
9367
10299
  schema: z.string(),
9368
10300
  project_name: z.string(),
@@ -10174,7 +11106,7 @@ function normalize(d) {
10174
11106
  }
10175
11107
  return out;
10176
11108
  }
10177
- function fmt(n) {
11109
+ function fmt2(n) {
10178
11110
  let r = Math.round(n * 1e4) / 1e4;
10179
11111
  if (Object.is(r, -0)) r = 0;
10180
11112
  return String(r);
@@ -10202,7 +11134,7 @@ function transformPath(d, fn) {
10202
11134
  }
10203
11135
  if (bad) continue;
10204
11136
  let s = seg.t;
10205
- for (let k = 0; k < dst.length; k++) s += " " + fmt(dst[k]);
11137
+ for (let k = 0; k < dst.length; k++) s += " " + fmt2(dst[k]);
10206
11138
  parts.push(s);
10207
11139
  }
10208
11140
  return parts.join(" ");
@@ -10464,6 +11396,16 @@ function boostForDark(hex2) {
10464
11396
  }
10465
11397
 
10466
11398
  // ../web/src/lib/markedset.js
11399
+ function authorTallyLine(shapes) {
11400
+ const byAuthor = /* @__PURE__ */ new Map();
11401
+ for (const s of shapes || []) {
11402
+ const a = typeof s.author === "string" && s.author.trim() ? s.author.trim() : "";
11403
+ byAuthor.set(a, (byAuthor.get(a) || 0) + 1);
11404
+ }
11405
+ if (![...byAuthor.keys()].some(Boolean)) return null;
11406
+ const parts = [...byAuthor.entries()].sort((a, b) => a[0] === "" ? 1 : b[0] === "" ? -1 : a[0].localeCompare(b[0])).map(([a, n]) => `${a || "unattributed"} (${n})`);
11407
+ return `Marks by: ${parts.join(" \xB7 ")}`;
11408
+ }
10467
11409
  var COBALT = "#1f3fc7";
10468
11410
  var DEDUCT_RED = "#b03a26";
10469
11411
  var DARK_BG = [0.055, 0.07, 0.09];
@@ -10741,6 +11683,11 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
10741
11683
  metaY -= 12;
10742
11684
  draw(`Approval stamps: ${apCount.estimator} estimator-approved \xB7 ${apCount.agent} agent-marked`, { x: 52, y: metaY, size: 9.5, font, color: muted });
10743
11685
  }
11686
+ const authorsLine = authorTallyLine(markedShapes);
11687
+ if (authorsLine) {
11688
+ metaY -= 12;
11689
+ draw(authorsLine, { x: 52, y: metaY, size: 9.5, font, color: muted });
11690
+ }
10744
11691
  let y = metaY - 34;
10745
11692
  const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
10746
11693
  draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
@@ -11179,6 +12126,9 @@ async function isOwnExport(outPath, kind) {
11179
12126
  if (kind === "json") {
11180
12127
  return /"schema"\s*:\s*"opentakeoff\./.test((await head(outPath, 4096)).toString("utf8"));
11181
12128
  }
12129
+ if (kind === "dxf") {
12130
+ return /^999\r?\nOpenTakeoff DXF export/.test((await head(outPath, 64)).toString("utf8"));
12131
+ }
11182
12132
  if ((await head(outPath, 5)).toString("latin1") !== "%PDF-") return false;
11183
12133
  const { readFile: readFile4 } = await import("node:fs/promises");
11184
12134
  const { PDFDocument } = await import("pdf-lib");
@@ -11735,6 +12685,33 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
11735
12685
  }
11736
12686
  return payload;
11737
12687
  }));
12688
+ server.registerTool("export_dxf", {
12689
+ description: `The takeoff as a CAD drawing \u2014 a DXF (R2000) AutoCAD, BricsCAD, LibreCAD and Revit import as native geometry, not a picture. ONE sheet per file, like a DWG: every committed shape on that sheet becomes an LWPOLYLINE (floor rings CLOSED, walls and linear runs open, count marks a 1-ft circle), on a layer named for its finish \u2014 OT-<TAG>, with -DEDUCT / -HOLE / -WALL / -LINEAR / -COUNT suffix layers so a CAD user isolates any bucket with one layer filter, and room labels as TEXT on OT-LABELS. Coordinates are real units in the sheet's own frame: origin at the sheet's BOTTOM-left, Y up (CAD convention), feet by default ($INSUNITS 2) or metres with units:"m"; a ring's area in CAD equals its area in export_report to rounding, so the drawing IS the audit. Requires the sheet's scale (refuses otherwise \u2014 pixels in a DXF are worse than nothing); with several sheets carrying shapes, pass sheet to choose the drawing (the refusal lists them). The reply names every shape left out and why \u2014 a reconciled deduct ships as its parent's -HOLE ring, never twice. Writes to path (required \u2014 a DXF lives on disk, next to the DWG it aligns to); pair with export_marked_pdf for the reviewed planset.`,
12690
+ inputSchema: {
12691
+ path: z2.string().describe("File path to write the .dxf to"),
12692
+ sheet: z2.string().optional().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101"). Optional only when exactly one calibrated sheet carries shapes'),
12693
+ units: z2.enum(["ft", "m"]).optional().describe('Output units \u2014 "ft" (default) or "m"'),
12694
+ overwrite: z2.boolean().optional().describe(OVERWRITE_DESC)
12695
+ },
12696
+ outputSchema: exportDxfOutput
12697
+ }, run("export_dxf", async ({ path: outPath, sheet, units, overwrite }) => {
12698
+ const { sheet: s, build } = session.exportDxf(sheet, units ?? "ft");
12699
+ await assertWritable(outPath, "dxf", overwrite);
12700
+ const { writeFile: writeFile2 } = await import("node:fs/promises");
12701
+ await writeFile2(outPath, build.dxf, "utf8");
12702
+ return {
12703
+ path: outPath,
12704
+ sheet: s.key,
12705
+ sheet_number: s.sheetNumber ?? null,
12706
+ units: units ?? "ft",
12707
+ layers: build.layers,
12708
+ entities: build.entities,
12709
+ shapes: build.shapes,
12710
+ skipped: build.skipped,
12711
+ extents: build.extents,
12712
+ bytes: Buffer.byteLength(build.dxf, "utf8")
12713
+ };
12714
+ }));
11738
12715
  server.registerTool("export_report", {
11739
12716
  description: `The computed Report document \u2014 "opentakeoff.report.v1", the same schema the canvas Report's JSON export writes. Everything a pricing consumer needs without re-implementing the app's math: per-condition quantities with waste and multiplier applied (gross and *_net), the computed materials BUY LIST per condition (order quantity = basis \xF7 coverage rate, rounded up to whole purchase units) plus the project-wide roll-up summed by (name, unit), per-sheet BASE subtotals, scale provenance per sheet, and annotations. Contrast: export_takeoff is the raw canvas payload (materials as CONFIG rows, no computed quantities) and takeoff_summary strips materials for a compact reply \u2014 when the numbers are leaving for pricing, consume this. A report alone is HALF the deliverable: pair it with export_marked_pdf, because a takeoff is reviewed on marked drawings, not on numbers. Returned inline; pass path to also write it to disk as JSON.`,
11740
12717
  inputSchema: {
@@ -12122,9 +13099,13 @@ var TOOL_STAGES = {
12122
13099
  "export_report",
12123
13100
  "import_takeoff",
12124
13101
  "apply_rules",
12125
- "export_marked_pdf"
13102
+ "export_marked_pdf",
13103
+ "export_dxf"
12126
13104
  ]
12127
13105
  };
13106
+ var TOOL_NAMES = Object.freeze(
13107
+ Object.values(TOOL_STAGES).flat().sort()
13108
+ );
12128
13109
  var OPENABLE = ["measure", "revise", "handoff"];
12129
13110
  var openToolStageOutput = {
12130
13111
  stage: z3.string().describe("The stage that was opened"),
@@ -12211,7 +13192,7 @@ function nameTheStageInRefusals(server) {
12211
13192
  // package.json
12212
13193
  var package_default = {
12213
13194
  name: "opentakeoff-mcp",
12214
- version: "0.9.63",
13195
+ version: "0.9.65",
12215
13196
  mcpName: "io.github.Kentucky-ai/opentakeoff",
12216
13197
  type: "module",
12217
13198
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -12227,7 +13208,8 @@ var package_default = {
12227
13208
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
12228
13209
  prepublishOnly: "npm run typecheck && npm test && npm run build",
12229
13210
  typecheck: "tsc --noEmit",
12230
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
13211
+ "check:tool-count": "node scripts/check-tool-count.mjs",
13212
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
12231
13213
  },
12232
13214
  dependencies: {
12233
13215
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.63",
3
+ "version": "0.9.65",
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,8 @@
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/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
19
+ "check:tool-count": "node scripts/check-tool-count.mjs",
20
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
20
21
  },
21
22
  "dependencies": {
22
23
  "@modelcontextprotocol/sdk": "^1.12.0",